第8章 HTTPで動かす — もう1つの繋ぎ方
📖 MCPサーバーを作って学ぶ AIに道具を持たせる入門 — はじめに・目次 ← 目次に戻る ここまで作ってきたメモサーバーは、あなたのパソコンの中だけで動いていました。この章では、それをURLで呼べる形に変えます。あわせて「そのURL、誰でも叩けるのでは?」という怖い話を、正面から扱います。
📱 Claude ではこう見える 🟢
Claude Desktop の設定画面をもう一度開いてみてください。コネクタを追加するとき、実は2つの入口があります。
- ひとつは、これまで使ってきた設定ファイルにコマンドを書くやり方
- もうひとつは、URL を入力するやり方
世の中で「リモートMCPサーバー」と呼ばれているのは後者です。GitHub や Notion のような会社が公開しているMCPサーバーは、たいていこちらです。あなたのパソコンには何もインストールされません。向こうのサーバーに、URLで繋ぎに行くだけです。
そして、これも自分で作れます。
今回やることは、ひとことで言うとこれです。 「Claudeがあなたのプログラムを起動する」から、「あなたのプログラムがずっと待っていて、Claudeが訪ねてくる」へ。
🤔 なぜ?/やらないとどうなる 🟢
MCPの話し方は、2つだけ
まず、いちばん大事な事実を押さえます。
MCPのトランスポート(通信のやり方)は、
stdioとStreamable HTTPの2つだけです。
「トランスポート」は運び方という意味です。中身のやりとり(tools/list とか tools/call とか)はまったく同じで、それを何に載せて運ぶかだけが違います。宅配便で言えば、荷物は同じで、トラックか船か、という違いです。
- stdio(標準入出力) … 第3章でやった、あれです。手元で起動して、口移しでやりとりする
- Streamable HTTP … URL を叩いてやりとりする
3つ目はありません。ここは自信を持ってください。
⚠️ 「SSEトランスポート」という言葉を見かけたら、それは古い情報です。 2024年11月版の仕様には
HTTP+SSEという別のやり方がありましたが、今は非推奨です。ネットの記事やAIの回答には、まだたくさん残っています。新しく作るなら Streamable HTTP です。(SSE という技術そのものは Streamable HTTP の中でも使われます。混乱しやすいので、後で説明します。)
どっちを使えばいいのか
迷ったらこの表を見てください。
| こういうとき | 選ぶのは | 理由 |
|---|---|---|
| 手元のPCで、自分だけが使う | stdio | 設定が一番簡単。ネットワークの設定が要らない |
| 自分のメモ・自分のファイルを触る | stdio | そもそも自分のPCの中にしかデータがない |
| 別のマシンから使いたい | HTTP | stdio は「同じPCの中」でしか成立しない |
| チームで共有したい | HTTP | 全員が同じサーバーに繋ぐ |
| クラウドに置きたい | HTTP | URLで公開できるのは HTTP だけ |
| 複数のアプリから同時に使いたい | HTTP | stdio は繋いだ相手1人ぶんしか居ない |
💡 この教材のメモサーバーは、本来 stdio が正解です。 自分のPCの中のメモを読むだけなので、わざわざHTTPにする理由がありません。この章は「必要になったときに選べるようにしておく」ための章です。作ってみて「あ、自分には要らないな」と思ったら、それが正しい判断です。
stdio との、決定的な3つの違い
ここが理解の山場です。HTTPにすると、これまでの常識が3つひっくり返ります。
(a) サーバーは、自分で起動しつづける
第1章で「MCPサーバーは常駐していない」と書きました。stdio では、Claude Desktop があなたのプログラムを起動し、終わったら終了させていました。あなたは何もしなくてよかった。
HTTPでは逆です。
あなたが
node build/index.jsを実行して、そのまま動かしっぱなしにします。 Claudeは、起動しません。すでに動いているサーバーに、訪ねてくるだけです。
だから——サーバーを起動し忘れたら、当然繋がりません。 stdio では起こらなかった失敗です。ターミナルを閉じたら止まる、パソコンを再起動したら止まる。「常に動いている」を誰が保証するのかは、あなたの仕事になります。
(b) console.log を書いても、壊れません
第3章でさんざん脅した話を、ここで解除します。
stdio で console.log が禁止だったのは、標準出力そのものが Claude との会話チャンネルだったからです。HTTPでは、会話はネットワーク越しに行われます。標準出力は、ただの画面に戻ります。
Streamable HTTP では、
console.logでログを出して問題ありません。
ただし、ちょうど裏返しのことが起きます。
console.error(標準エラー出力)は、クライアントに拾われません。
stdio では Claude Desktop が stderr をログとして拾ってくれました。HTTPでは誰も見ていません。自分のターミナルに出るだけです。ログをどこに残すかは、自分で決める必要があります。
stdout(console.log) |
stderr(console.error) |
|
|---|---|---|
| stdio | ❌ 絶対禁止(会話が壊れる) | ✅ 安全。ホストがログに拾う |
| Streamable HTTP | ✅ 問題なし | ⚠️ 出せるが、クライアントには届かない |
💡 どちらでも動く手段が1つあります。第4章で出てきた
await server.server.sendLoggingMessage({ level: "info", data: "…" })です。これはMCPの仕組みでログを送るので、トランスポートを選びません。ただしcapabilities: { logging: {} }を宣言していないと、エラーも出ないまま黙って何も送りません(第3章参照)。
(c) 複数のクライアントが、同時に繋がれる
stdio は1対1です。プロセスが1つ立ち上がって、相手も1人。
HTTPは1対多です。Claude Desktop からも、同僚のPCからも、自作アプリからも、同時に繋がれます。便利ですが、これは同時に「誰が繋いできたのか分からない」という意味でもあります。この章の後半(🔒)が丸ごとその話です。
Streamable HTTP の「かたち」
仕組みは、驚くほどシンプルです。
エンドポイント(URL)は1つだけ。 例えば
/mcp。その1つが、POST も GET も両方受けます。
| メソッド | 何に使うか |
|---|---|
POST /mcp |
クライアント → サーバー。「ツール一覧ちょうだい」「これ実行して」を送るメインの道 |
GET /mcp |
サーバー → クライアント。SSE(サーバー送信イベント)でサーバー側から通知を流すための道 |
GET のほうが分かりにくいので補足します。ふつうHTTPは「聞かれたら答える」の一方通行で、サーバーから勝手に話しかけられません。そこでクライアントが GET で線を1本繋ぎっぱなしにしておいて、サーバーはそこに流し込む——これが SSE(Server-Sent Events) です。「ツールの一覧が変わりましたよ」といった通知に使います。
💡 だから紛らわしいのです。SSEという技術は今も使われていますが、「SSEトランスポート」という古い方式は非推奨。使われているのは中身の技術のほうだけ、と覚えてください。
やりとりには、2つのヘッダが登場します。
| ヘッダ | 役割 |
|---|---|
Mcp-Session-Id |
セッション(会話の続き)の管理。サーバーが最初の応答で発行し、クライアントは以降ずっとこれを付けて送る |
MCP-Protocol-Version |
「私はこの版の仕様で話します」の宣言 |
🛠 こう作る 🟢
第5章〜第7章で作った memo サーバーを、HTTPで喋れるようにします。ツールの中身(search_notes など)は1行も変わりません。 変えるのは、外側の「繋ぎ方」だけです。ここが規格のありがたいところです(背骨①)。
✅ この節のコードは
@modelcontextprotocol/sdk1.29.0 で実際に動かして確認しています。MCPは動きが速いので、うまくいかないときは必ず公式ドキュメントとTypeScript SDKを確認してください。
① 追加でインストールする
HTTPサーバーを立てるので、Webフレームワークの Express を足します。
npm install express
npm install -D @types/express
② 全体像(これが完成形です)
src/index.ts をこう書き換えます。長く見えますが、やっていることは3つだけです。
⚠️ 下のコードは、トランスポートの違いを見せるための「骨組み」です。
search_notesの中身はダミーに置き換えてあります。実際には、第7章までに書いたツール・リソース・プロンプトの登録部分を、そのままcreateServer()の中に移すだけです(fs/path/NOTES_DIRの読み込み、read_note/write_note、registerResource/registerPromptもそのまま持ってきてください)。捨てる必要はありません。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { z } from "zod";
// ① 道具の中身。ここは第7章までに書いたものをそのまま移すだけ
// (下の search_notes は、骨組みを見せるためのダミーです)
function createServer() {
const server = new McpServer({ name: "memo", version: "1.0.0" });
server.registerTool(
"search_notes",
{
description: "メモを全文検索して、見つかったファイル名と抜粋を返す",
inputSchema: {
query: z.string().describe("検索したいキーワード"),
},
},
async ({ query }) => {
// ★ダミー。第5章で書いた本物の検索処理をここに入れる
return { content: [{ type: "text", text: `「${query}」の結果` }] };
},
);
// ここに read_note / write_note(第6章)、
// registerResource / registerPrompt(第7章)もそのまま並べます
return server;
}
// ② HTTPの入れ物を用意する
const app = createMcpExpressApp();
// ③ /mcp に来たPOSTを、MCPのトランスポートに渡す
app.post("/mcp", async (req, res) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, "127.0.0.1", () => {
console.log("Memo MCP Server: http://127.0.0.1:3000/mcp");
});
💡 上のコードでは
new McpServer({ name: "memo", version: "1.0.0" })としか書いていません。sendLoggingMessageでログを送りたいなら、第3章のとおり第2引数に{ capabilities: { logging: {} } }を足してください。 宣言しないと、エラーも出ないまま黙って何も送りません。
③ 1つずつ読む
createServer() を関数にした理由
第5章では const server = new McpServer(...) とトップレベルに1つだけ作っていました。ここでは関数の中に入れています。
理由は、リクエストごとに新しく作るためです。HTTPは複数の相手が同時に繋いできます。1個のサーバーを使い回すと、2人目が来たときに Server already initialized(もう初期化済みです)というエラーで弾かれます。これは実際に起きます(後述)。
createMcpExpressApp()
ふつうの express() の代わりに、SDKが用意しているMCP用のExpressアプリを使います。これは既定で 127.0.0.1 に向けた安全設定(DNSリバインディング対策)が入った状態で出てきます。次の🔒節の主役です。
💡
app.use(express.json())を自分で書く必要はありません。createMcpExpressApp()は、返すアプリにJSONパーサをあらかじめ組み込んでいます。自分でもう一度呼んでも壊れはしませんが、同じ処理を二重に通すだけなので書かないでください。AIが生成したコードにはよく混ざっています。
sessionIdGenerator: undefined
「セッションを使わない」という宣言です。これを ステートレスモード(状態を持たないモード)と言います。
- ステートレス(今回)… 1回のやりとりごとに使い捨て。
Mcp-Session-Idは発行されない。シンプルで、まず動かすのに向く - ステートフル …
sessionIdGenerator: () => randomUUID()のように書くとセッションIDを発行する。会話の続きを覚えられるが、セッションIDごとにトランスポートを保管する処理を自分で書く必要があり、初心者には荷が重い
💡 まずステートレスで動かしてください。セッションが要るのは、サーバー側から通知を送りたいときや、長い処理の途中経過を返したいときです。必要になったら公式SDKのドキュメントを見に行きましょう。
res.on("close", ...)
レスポンスが終わったら、作ったサーバーとトランスポートを片付けます。書かないと、リクエストのたびにゴミが積もっていきます。
transport.handleRequest(req, res, req.body)
ここが本体です。受け取ったHTTPリクエストを、丸ごとMCPに翻訳して処理してくれるメソッドです。第3引数の req.body は「JSONパーサがもう中身をパースしているので、それを使ってね」という意味です(そのパーサは createMcpExpressApp() が入れてくれています)。
app.listen(3000, "127.0.0.1", ...)
第2引数の "127.0.0.1" がとても重要です。次の節でしつこく説明します。
console.log を使っている点に注目
第3章では絶対禁止だった console.log を、最後の行で使っています。HTTPでは問題ないからです。ここで(b)の話が回収されます。
④ 動かして、確かめる
npm run build
node build/index.js
Memo MCP Server: http://127.0.0.1:3000/mcp と出れば起動成功です。このターミナルは閉じないでください。 閉じたらサーバーが止まります。
別のターミナルを開いて、MCP Inspector で繋ぎます。
npx @modelcontextprotocol/inspector
Inspector の画面で、Transport Type に Streamable HTTP、URL に http://127.0.0.1:3000/mcp を入れて Connect。ツール一覧に search_notes が出れば成功です。
💡 ブラウザで
http://127.0.0.1:3000/mcpを直接開いても、意味のあるものは出ません。MCPはブラウザで見るものではないからです(第1章のハマりどころ①と同じ話)。
🔒 絶対にやること3つ 🟢
この節は読み飛ばさないでください。 この章でいちばん大事なところです。
まず、怖い話から
あなたは今、自分のPCで http://127.0.0.1:3000/mcp というサーバーを動かしました。「127.0.0.1 は自分のPCの中だから、外からは見えない。安全だ」——そう思いますよね。
そこに穴があります。
あなたがブラウザで、まったく関係ない悪意あるWebサイトを開いたとします。そのページのJavaScriptは、あなたのブラウザの中で動いています。そしてあなたのブラウザは、127.0.0.1 にアクセスできます。
つまり——
悪意あるWebサイトが、あなたのブラウザを踏み台にして、あなたのPCの中で動いているMCPサーバーを叩けてしまう。
そのサーバーはあなたのメモを読めます。第6章で write_note も作りましたね。書き込めます。
これを DNSリバインディング攻撃 と言います。仕組みはこうです。
- 攻撃者が
evil.example.comというドメインを用意する - 最初の一瞬だけ、本物のIPアドレスを返してページを読み込ませる
- 直後にDNSの答えを
127.0.0.1に書き換える - ブラウザは「同じ
evil.example.comだから同じサイトだ」と信じたまま、実際にはあなたのPCの中にリクエストを送る
ブラウザの同一オリジンポリシー(別サイトのデータを勝手に読ませない仕組み)を、ドメイン名は同じまま行き先だけすり替えてすり抜ける手口です。
⚠️ これは理論上の話ではありません。MCPのTypeScript SDKでも、この対策が既定で無効だったことが脆弱性(CVE-2025-66414)として公表されています。 だから今のSDKには、後述の安全な入り口が用意されました。
対策は3つ。全部やってください。
① Origin ヘッダを必ず検証する
ブラウザは、別サイトからのリクエストに Origin ヘッダ(どのサイトから来たか)を自動で付けます。攻撃者はこれを偽装できません。だから、知らない Origin は門前払いにします。
const ALLOWED_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && !ALLOWED_ORIGINS.includes(origin)) {
res.status(403).json({ error: "Origin not allowed" });
return;
}
next();
});
createMcpExpressApp() の直後、app.post("/mcp", ...) より前に置いてください。ふつうのExpressのミドルウェアなので、難しいことはしていません。「許可リストに無いなら403で返す」——それだけです。
💡 「JSONパーサより前に置く」ことはできません。パーサは
createMcpExpressApp()の中で先に登録済みだからです。気にする必要もありません。大事なのは「ハンドラに届く前に弾く」ことで、ボディを読んだあとで403を返しても守りとしては同じです。
💡
originが付いていないリクエスト(Inspector やコマンドラインからのアクセス)は、ブラウザ経由ではないので通しています。攻撃はブラウザ経由で来る、という前提の対策です。
② ローカルでは 127.0.0.1 にバインドする(0.0.0.0 にしない)
app.listen(3000, "127.0.0.1", () => { /* ... */ }); // ✅ 正しい
app.listen(3000, () => { /* ... */ }); // ❌ 危ない
app.listen(3000, "0.0.0.0", () => { /* ... */ }); // ❌ 危ない
バインドは「どの入り口で待ち受けるか」という設定です。
127.0.0.1… 自分のPCの中からしか届かない0.0.0.0… 繋がっているすべてのネットワークから届く
0.0.0.0 にすると、カフェのWi-Fiで作業しているとき、同じWi-Fiにいる全員があなたのメモサーバーを叩けます。第2引数を書き忘れると 0.0.0.0 相当になるので、書き忘れがそのまま事故です。
createMcpExpressApp() は、既定で 127.0.0.1 前提のホスト名チェックも入れてくれます。実際、Host: evil.example.com を付けたリクエストは 403 で弾かれます。これがDNSリバインディング対策の実体です。
③ 認証を入れる
インターネットに公開するなら、必須です。
①と②は「ローカルで自分だけが使う」ための守りです。クラウドに置いた瞬間、URLを知っている人は誰でも叩けます。あなたのメモが、世界中から読めます。
- 最低限:秘密のトークンをヘッダで受け取って、合わなければ401で返す
- 本格的には:OAuth。MCPの仕様にも認可の仕組みが定められています
⚠️ 認証なしのMCPサーバーをインターネットに公開しない。 これだけは覚えて帰ってください。「試しに公開してみた」が一番危ないパターンです。
まとめると
| やること | 忘れると | |
|---|---|---|
| ① | Origin ヘッダを検証する |
悪意あるサイトがブラウザ越しに叩ける |
| ② | 127.0.0.1 にバインドする |
同じネットワークの全員が叩ける |
| ③ | 認証を入れる | 世界中の誰でも叩ける |
⚠️ ハマりどころ 🟢
① サーバーを起動し忘れる
stdio では起こらなかった、HTTP最頻出の失敗です。Claudeはサーバーを起動してくれません。ターミナルで自分で起動して、開いたままにしておく。 繋がらないときは、まずこれを疑ってください。
② Server already initialized と言われる
サーバーインスタンスを使い回していると、2人目のクライアント(2回目の接続)で必ずこれが出ます。
{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"}}
リクエストごとに createServer() を呼ぶ形にしてください。上のコードがそうなっているのは、この失敗を避けるためです。
③ Bad Request: Mcp-Session-Id header is required
ステートフル(sessionIdGenerator にランダム生成を渡した)にしたのに、セッションの管理を書いていないと、2回目以降のリクエストでこれが出ます。まずステートレス(undefined)で動かしてください。
④ 403 が返ってくる
これは壊れているのではなく、守りが効いているサインです。
Hostヘッダが許可リストに無い → DNSリバインディング対策が働いたOriginが許可リストに無い → ①の検証が働いた
自分の環境で正しく403が出るなら、対策は生きています。テストで意図せず403になる場合は、送っているヘッダを確認してください。
⑤ GET /mcp が 404 になる
上のコードには app.post しか書いていないので、GETは404です。ステートレス構成では、サーバーからの通知を使わないのでこれで問題ありません。 サーバー→クライアントの通知が必要になったら、セッション管理と一緒に app.get("/mcp", ...) を足します。
⑥ 「HTTPのほうが本格的だから偉い」と思ってしまう
違います。 自分のPCのメモを読むだけなら stdio のほうが正解です。HTTPは、設定が増え、守るべきものが増え、起動を自分で面倒みるやり方です。使い分け表に戻ってください。
⑦ Claude Desktop に繋ぐには
Claude Desktop からリモートMCPサーバーに繋ぐ手順は、バージョンによって変わります。必ず公式ドキュメントで最新の手順を確認してください。 動作確認だけなら MCP Inspector が最短です。まずそちらで繋がることを確かめましょう。
🤖 AIに頼むなら 🟢
この章は、AIが最も古い情報を出しやすいところです。理由は単純で、MCPのHTTPまわりは仕様もSDKも何度も変わってきたからです。
AIが出しがちな、古い/危ない書き方
| AIが書きがちなもの | 何が問題か |
|---|---|
SSEServerTransport を使う |
非推奨の旧方式。今は StreamableHTTPServerTransport |
/sse と /messages の2つのエンドポイント |
旧方式の形。今は /mcp 1つが POST と GET を受ける |
app.listen(3000) とだけ書く |
バインド先の指定漏れ。"127.0.0.1" を必ず付ける |
enableDnsRebindingProtection をトランスポートに渡す |
現在の StreamableHTTPServerTransport には存在しないオプション。渡すと型エラーになります。createMcpExpressApp() やミドルウェアで守るのが今の形 |
app.use(express.json()) を自分で足す |
createMcpExpressApp() が既に入れています。二重で通すだけの冗長な行 |
Origin の検証が入っていない |
AIは頼まないと書きません。必ず明示的に頼む |
こう頼むと精度が上がります
「@modelcontextprotocol/sdk の最新版で、Streamable HTTP のMCPサーバーを書いて。SSEトランスポート(旧HTTP+SSE)は使わないで。エンドポイントは
/mcp1つ。ステートレス(sessionIdGenerator は undefined)で。127.0.0.1 にバインドして、Origin ヘッダの検証も入れて」
そして、出てきたコードで必ずこの3つを目視確認してください。
listenの第2引数に"127.0.0.1"があるかOriginを見ているミドルウェアがあるか- サーバーインスタンスがリクエストごとに作られているか
AIはセキュリティを黙って省略します。 悪気があるわけではなく、「動くコード」を最短で出そうとするからです。動くことと安全であることは、別の話です。
📗 ことばメモ
| ことば | よみ | 意味 |
|---|---|---|
| トランスポート | — | MCPのやりとりを「何に載せて運ぶか」。stdio と Streamable HTTP の2つだけ |
| Streamable HTTP | ストリーマブルエイチティーティーピー | 現行のHTTPトランスポート。1つのエンドポイントが POST と GET を受ける |
| HTTP+SSE | — | 2024-11-05版の旧トランスポート。非推奨。新規に使わない |
| SSE | エスエスイー/Server-Sent Events | サーバーからクライアントへ一方的にデータを流す仕組み。GET側で使われる |
| エンドポイント | — | 受け付けるURLの道。この章では /mcp |
Mcp-Session-Id |
— | 会話の続きを識別するヘッダ。ステートフルのときサーバーが発行する |
MCP-Protocol-Version |
— | 「この版の仕様で話します」と宣言するヘッダ |
| ステートレス | — | 状態を持たない構成。1回ごとに使い捨て。まずはこれ |
| バインド | — | どの入り口で待ち受けるかの設定。ローカルでは 127.0.0.1 |
0.0.0.0 |
— | すべてのネットワークから受け付ける設定。ローカル用途では使わない |
| DNSリバインディング攻撃 | — | ドメイン名の行き先をすり替え、ブラウザ越しに手元のサーバーを叩く攻撃 |
Origin ヘッダ |
オリジン | リクエストがどのサイトから来たかをブラウザが自動で付ける情報。偽装できない |
| OAuth | オーオース | 認可の標準的な仕組み。MCPの仕様にも定められている |
➡️ 次へ
HTTPにした瞬間、あなたは「知らない誰かが叩いてくるかもしれない」という前提に引っ越しました。この章で扱ったのは、その入り口の守りです。
でも、守りはもう一段あります。入り口を通り抜けた相手が、あなたの道具で何をできてしまうかです。第6章で作った write_note を思い出してください。あれはファイルを書き換える道具です。filename に ../../.ssh/authorized_keys と入れられたら、どうなるでしょう?
第9章 安全 — 書ける道具は、危ない道具 では、道具そのものを頑丈にします。フォルダの外に出させない封じ込め、上書き前の確認、そして「渡す権限は最小限に」という考え方です。stdio でもHTTPでも、書ける道具を作った人全員に関係する話です。
関連ページ
- MCPサーバーを作って学ぶ AIに道具を持たせる入門 — はじめに・目次 — 目次
- 第1章 「コネクタ」の正体 — MCPは道具の共通規格 — 「サーバーは常駐していない」の話は、この章でひっくり返りました
- 第3章 標準入出力の正体 —
console.log1行でサーバーが壊れる — HTTPで禁止が解ける理由 - 第9章 安全 — 書ける道具は、危ない道具 — 次章
- ChatGPTクローンで学ぶ LLMアプリ開発入門 — はじめに・目次 — 第2弾。Express と守りの基礎はこちら