0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCP 2026-07-28対応のMCPサーバーをTypeScript SDK v2で作ってみた(実装編)

0
Posted at

MCP 2026-07-28対応のMCPサーバーをTypeScript SDK v2で作ってみた(実装編)

前回の記事でMCP 2026-07-28の仕様変更を整理しました。今回はその後編で、TypeScript SDK v2を使って実際に動くMCPサーバーを1本作ります。

仕様を読んだだけだと「で、コードとしてはどう書くの?」が掴めなかったので、手を動かして確認した記録です。

作るもの

デプロイ承認サーバーです。ツールを2つ用意します。

  • prepare_deployment:デプロイ計画を作り、署名付きの planToken を返す
  • execute_deploymentplanToken を検証し、ユーザーに承認を求めてから実行する

実際の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点です。

  1. 署名の比較に timingSafeEqual を使う(ただし長さが違うと例外を投げるので、先に長さを比べる)
  2. 署名を検証してからJSONをパースする(逆順にすると、確認前に攻撃者のデータを解釈することになる)
  3. パース後に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({ /* 承認フォーム */ });
}

初回は kindmissing になるので上の条件には入りません。拒否・キャンセルのときだけ抜けます。

実際に飛んでいる通信

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 が返ってくるのを見ると、仕様の理解がだいぶ変わると思います。

▼ 前編(仕様解説)を先に読むと、この記事のコードが何をやっているか掴みやすいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?