ツールの中身は変わりません。変わるのは誰がプロセスを起こすかです。
| 項目 | stdio | Streamable HTTP |
|---|---|---|
| 起動する人 | ホスト(子プロセスとして) | 自分(先に立てておく) |
| 寿命 | ホストと一緒に終わる | 止めるまで生きている |
| 共有 | ホストごとに 1 本ずつ立つ | 1 本を何人でも使える |
| 置き場所 | 同じ PC のみ | 別のマシンでもよい |
console.log | 禁止 | 使える |
| 手間 | 登録するだけ | 起動と終了を自分で見る |
samples/07-http/server.mjs の全文です。memo-tools.mjs はそのまま使います。
import { createServer } from 'node:http';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMemoServer } from '../06-memo/memo-tools.mjs';
const PORT = Number(process.env.PORT ?? 3333);
const http = createServer(async (req, res) => {
// MCP のエンドポイントは 1 つ。ここに POST が来る
if (!req.url?.startsWith('/mcp')) {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('/mcp へ POST してください\n');
return;
}
// セッションを持たない形。リクエストごとに使い捨てる
const server = createMemoServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
// 応答が終わったら片付ける。閉じ忘れると接続が溜まる
res.on('close', () => {
transport.close();
server.close();
});
try {
await server.connect(transport);
await transport.handleRequest(req, res);
} catch (err) {
console.error('[http] 失敗:', err);
if (!res.headersSent) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
}));
}
}
});
http.listen(PORT, () => {
// stdio 版と違い、標準出力に書いても壊れない
console.log(`メモサーバー(HTTP): http://localhost:${PORT}/mcp`);
});
Web のフレームワークは要りません。Node の node:http だけで足ります。transport.handleRequest(req, res) に投げれば、あとは SDK が処理します。
sessionIdGenerator をどうするかで、2 つの作りに分かれます。
| 書き方 | ふるまい | 向くもの |
|---|---|---|
undefined | セッションを持たない。1 回のリクエストで完結する | 状態をファイルや DB に持つサーバー |
() => randomUUID() | セッション ID を発行し、記憶の中に状態を持つ | 接続ごとの状態が要るサーバー |
メモサーバーは持たない側にしました。状態はファイルにあるので、リクエストごとに作り直しても困りません。
後片付けを忘れないでください。res.on('close', ...) で transport.close() と server.close() を呼びます。リクエストごとに作るのに閉じないと、使われないオブジェクトが溜まり続けます。
$env:PORT = "3333"
node docs/samples/07-http/server.mjs
メモサーバー(HTTP): http://localhost:3333/mcp
$body = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"手","version":"1.0"}}}'
Invoke-WebRequest -Uri http://localhost:3333/mcp -Method Post `
-Body $body -ContentType 'application/json' `
-Headers @{ Accept = 'application/json, text/event-stream' } -UseBasicParsing
返ってきたものです。
HTTP 200
event: message
data: {"result":{"protocolVersion":"2025-11-25","capabilities":{...},"serverInfo":{"name":"memo","version":"1.0.0"}},"jsonrpc":"2.0","id":1}
これが最初の関門です。応答は「ただの JSON」か「SSE のストリーム」のどちらかで返ります。両方を受け取れると伝えないと、サーバーは断ります。
Accept: application/json, text/event-stream
SSE で返る場合、本文はこうなります。data: の後ろが JSON-RPC のメッセージです。
event: message
data: {"result":{...},"jsonrpc":"2.0","id":1}
手で解く場合はこう取り出します(10 章のブリッジで使います)。
function extractMessages(contentType, body) {
if (contentType.includes('text/event-stream')) {
return body.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim())
.filter(Boolean);
}
return body.trim() ? [body.trim()] : [];
}
claude mcp add --transport http memo http://localhost:3333/mcp
codex mcp add memo --url http://localhost:3333/mcp
| 増えること | 対処 |
|---|---|
| 起動と終了 | 常駐させる仕掛けが要る(サービス登録など) |
| ポートの管理 | 他と重ならない番号を決める |
| 落ちたときの検知 | ホストからは「繋がらない」としか見えない |
| 誰でも繋げる | 同じ PC の他のプログラムからも叩ける。127.0.0.1 に限定する |
外に出すなら認証が要ります。この資料の例は localhost の中だけで使う前提です。ネットワークに出す場合は、MCP の認可(OAuth)が別に決まっています。認証なしのサーバーを社内ネットワークに置かないでください。ツールは実行される側であり、誰でも叩ける状態は危険です。
ここまでは「訊かれたら答える」だけでした。次はサーバーの側から伝える方法と、その限界を扱います。