MCP 2026-07-28対応のMCPサーバーをTypeScript SDK v2で作ってみた(実装編)
前回の記事でMCP 2026-07-28の仕様変更を整理しました。今回はその後編で、TypeScript SDK v2を使って実際に動くMCPサーバーを1本作ります。
仕様を読んだだけだと「で、コードとしてはどう書くの?」が掴めなかったので、手を動かして確認した記録です。
作るもの
デプロイ承認サーバーです。ツールを2つ用意します。
-
prepare_deployment:デプロイ計画を作り、署名付きのplanTokenを返す -
execute_deployment:planTokenを検証し、ユーザーに承認を求めてから実行する
実際のAWSやKubernetesには接続しません。デプロイ処理自体はシミュレーションで、目的は新仕様の通信モデルを手元で再現することです。
パッケージ構成
TypeScript SDK v2は3つに分かれています。
npm install \
@modelcontextprotocol/server \
@modelcontextprotocol/client \
@modelcontextprotocol/node \
zod
@modelcontextprotocol/server がWeb標準の Request を扱うコア部分、@modelcontextprotocol/node がNode.jsの node:http に橋渡しする部分です。この分割のおかげでCloudflare WorkersやDenoにも載せ替えやすくなっています。
npm pkg set type=module を忘れるとESMのimportが通らないので、最初にやっておきましょう。
つまずきポイント1:サーバーインスタンスは毎回作り直される
createMcpHandler に渡すのは、サーバーインスタンスではなくファクトリー関数です。
const mcpHandler = createMcpHandler(() => {
const server = new McpServer({
name: "deployment-approval-server",
version: "1.0.0"
});
// ここでツールを登録する
return server;
});
この関数はHTTPリクエストごとに実行され、毎回新しい McpServer が作られます。つまり、こういうコードを書いてはいけません。
// これは動かない
const inMemoryApprovals = new Map<string, boolean>();
厄介なのは、手元の1プロセスで試している限りは動いてしまうケースがあることです。ECSタスクを複数立てた途端に壊れます。
つまずきポイント2:状態は自分で運ぶ
セッションがなくなったので、prepare_deployment で作った計画を execute_deployment へ渡す手段が要ります。今回はHMAC署名付きトークンにしました。
function mintPlanToken(plan: DeploymentPlan): string {
const payload = Buffer
.from(JSON.stringify(plan), "utf8")
.toString("base64url");
return `${payload}.${createSignature(payload)}`;
}
検証側で気をつけたのは3点です。
- 署名の比較に
timingSafeEqualを使う(ただし長さが違うと例外を投げるので、先に長さを比べる) - 署名を検証してからJSONをパースする(逆順にすると、確認前に攻撃者のデータを解釈することになる)
- パース後にZodスキーマを通す(署名が正しくても、形式が想定と違う可能性はある)
つまずきポイント3:MRTRの初回と拒否を区別する
ここがいちばんハマりました。
MRTRでは、承認を求めるときに inputRequired() を返し、ユーザーの回答が付いた状態で同じハンドラーが再実行されます。回答を取り出すのは acceptedContent() ですが、これは次の3つすべてで undefined を返します。
- 初回で回答がない
- ユーザーが拒否した
- ユーザーがキャンセルした
なので「undefined なら確認を出す」と素直に書くと、拒否 → また確認 → 拒否 → また確認、の無限ループになります。
先に inputResponse() で状態を判定するのが正解でした。
const response = inputResponse(ctx.mcpReq.inputResponses, "approval");
if (response.kind === "elicit" && response.action !== "accept") {
return toolError("キャンセルされました");
}
const approval = acceptedContent(
ctx.mcpReq.inputResponses,
"approval",
approvalSchema
);
if (approval === undefined) {
return inputRequired({ /* 承認フォーム */ });
}
初回は kind が missing になるので上の条件には入りません。拒否・キャンセルのときだけ抜けます。
実際に飛んでいる通信
SDKを使うと callTool() 1行でMRTRの往復が完結するので、中身が見えません。記事では生のリクエストとレスポンスも載せました。
1回目のレスポンスがこうなります。
{
"jsonrpc": "2.0",
"id": 100,
"result": {
"resultType": "input_required",
"inputRequests": { "approval": { "method": "elicitation/create", "params": { ... } } }
}
}
この時点でサーバーのHTTP処理は完了しています。接続は閉じていて、ユーザーの入力を待っていません。旧仕様なら、ここでハンドラーの中で待っていた部分です。
2回目は新しいJSON-RPC IDで、arguments を丸ごと再送しつつ inputResponses を付けて飛んできます。サーバーは何も覚えていないので、クライアントが全部持ってくるしかない、というのが体感できました。
Mcp-Session-Id ヘッダーがどこにもないことも確認できます。
本番向けに足りないもの
記事の後半では、動かして終わりにしないための話も書いています。
- 署名付きトークンで足りるか、DBに保存すべきか(監査・無効化の要件があるならDB)
- 冪等キーによる二重実行の防止(レスポンス切断時にリトライされる前提で設計する)
-
clientInfo.nameを認可に使ってはいけない理由(自己申告なので偽装できる) - ALBのスティッキーセッションを無効にする構成
コードは全文載せてあるので、コピペして npm run server すれば動きます。手元で input_required が返ってくるのを見ると、仕様の理解がだいぶ変わると思います。
▼ 前編(仕様解説)を先に読むと、この記事のコードが何をやっているか掴みやすいです。