MCPサーバーをTypeScript SDK v2で実装する — デプロイ承認をステートレスに作る【後編】

2026-08-01 | AIエージェント基盤

【技術相談】本件の内容に関して30分間の無料相談承ります →

MCPサーバーをTypeScript SDK v2で実装する — デプロイ承認をステートレスに作る【後編】
📘 この記事は【後編】です

新仕様そのものの解説は前編で扱っています。initializeの廃止、MRTR、ヘッダールーティングといった用語に馴染みがない方は、 前編「MCP 2026-07-28で何が変わったのか」 を先にお読みいただくと、この記事のコードが何をやっているのか掴みやすくなります。

この記事をシェア

前編では、MCP 2026-07-28で通信モデルがどう変わったのかを仕様レベルで整理した。initializeMcp-Session-Idが消え、セッションに置いていた状態は明示的なハンドルとして引き回すことになり、サーバーからクライアントへの問い合わせはMRTR(Multi Round-Trip Requests)という形でレスポンスに載るようになった。

ただ、仕様を読んだだけだと「で、コードとしてはどう書くのか」がなかなか像を結ばない。少なくとも僕はそうだった。そこで後編では、TypeScript SDK v2で実際に動くMCPサーバーを1本作りながら、新仕様の各要素がコードのどこに現れるのかを確認していく。

題材はデプロイ承認サーバーにした。ツールを2つ用意して、こういう流れを作る。

  1. prepare_deploymentでデプロイ計画を作り、署名付きトークンとして返す
  2. execute_deploymentがそのトークンを検証し、MRTRでオペレーターに承認を求める
  3. 承認された場合だけデプロイを実行する

ただ、いきなり「デプロイ計画を作ります」と言われても、何の話なのか掴みにくいと思う。先に、どういう場面を想定しているのかを書いておく。

前提:どんな場面で使うサーバーなのか

想定しているシナリオ

開発者が、AIアシスタントにこう頼む場面を想像してほしい。

「認証APIのタイムアウトを直したv1.4.0を、本番に反映しておいて」

従来なら、CI/CDの管理画面を開いて、対象を選んで、承認ボタンを押していた作業だ。それをチャットから頼めるようにする。裏側では、AIが社内のデプロイ用MCPサーバーへ接続し、ツールを呼び出している。

ただし、この依頼をそのまま実行させるわけにはいかない。本番環境の更新は、失敗すればサービスが止まる操作だ。AIが指示を取り違えている可能性もあるし、そもそも人間が最終確認せずに本番へ手を入れる運用は通らない。

そこで、こういう二段構えにする。

  1. AIがまず計画だけを作る。どの環境に、どのバージョンを、何の目的で入れるのか
  2. 実行しようとすると、サーバーが人間へ確認を求める。画面には計画の内容が表示される
  3. 人間が承認したときだけ、実際のデプロイが走る

要するに、稟議を回してから作業する、という流れをそのままツールに落とし込んだものだ。AIには手順を進めてもらうが、実行の可否を決めるのは人間に残す。

なぜツールを2つに分けるのか

prepare_deploymentexecute_deploymentを分けているのには理由がある。

1つのツールにまとめると、AIが「デプロイして」という指示を解釈した結果が、そのまま実行に直結する。引数の解釈がずれていても、人間が気づく機会がない。計画を作る工程を分けておけば、承認画面に「production / v1.4.0 / 認証APIのタイムアウト処理を修正」と具体的な内容が出るので、意図と違えばそこで止められる。

AIに任せる範囲と、人間が判断する範囲の線引きをどこに引くか、という設計の話でもある。

デプロイ以外にも同じ形が使える

この記事ではデプロイを題材にしているが、パターンとしては「AIに実行させたいが、無条件では実行させたくない操作」すべてに当てはまる。

  • 顧客データの削除
  • 返金処理
  • 本番DBへのマイグレーション
  • 権限の付与・剥奪

どれも、AIが操作できると便利だが、確認なしで走られては困るものだ。計画を作って人間に見せ、承認を得てから実行する、という骨格は共通して使える。

なぜこれが新仕様の題材として適切なのか

承認待ちが挟まるということは、1回の作業が複数のHTTPリクエストに分かれるということだ。計画を作るリクエスト、実行しようとするリクエスト、承認後に実行するリクエスト。

旧仕様なら、接続を張ったまま「承認が返ってくるまでハンドラーの中で待つ」と書けた。新仕様ではそれができない。前編で書いたステートレス化の影響が、いちばん露骨に出る題材だ。裏を返せば、これが書ければ他のツールはだいたい書ける。

必要な環境と前提知識

手元で動かすのに必要なのは、Node.jsとnpmだけだ。TypeScriptを読める程度の知識があれば追える。

実際のAWSやKubernetesには接続しない。デプロイ処理そのものはシミュレーションで、目的はあくまで新仕様の通信モデルを手元で再現することにある。そのぶん、本番で足りなくなる部分(トークンではなくDBを使う判断、冪等性、認可)は後半でまとめて扱う。

16. 今回作るサーバー

章番号が16から始まっているのは、前編の15章からの続きだからだ。

実装するツールは2つ。

prepare_deployment

デプロイ計画を作成する。入力はこうなる。

{
  "environment": "production",
  "version": "v1.4.0",
  "changeSummary": "認証APIのタイムアウト処理を修正"
}

返すのは計画そのものと、署名付きのplanTokenだ。

{
  "planId": "plan_...",
  "environment": "production",
  "version": "v1.4.0",
  "changeSummary": "認証APIのタイムアウト処理を修正",
  "issuedAt": 1785312000000,
  "planToken": "..."
}

execute_deployment

planTokenを受け取り、MRTRでユーザーへ承認を求める。承認された場合のみ、デプロイ完了結果を返す。

ポイントは、この2つのツールのあいだに「サーバーが覚えている情報」がひとつもないことだ。計画の内容はすべてplanTokenの中にあり、クライアントが持ち歩く。

17. プロジェクトを作成する

TypeScript SDK v2では、サーバー、クライアント、ランタイム用ミドルウェアがパッケージとして分割されている。

@modelcontextprotocol/server
@modelcontextprotocol/client
@modelcontextprotocol/node

v2はMCP 2026-07-28と同時に公開された安定版系列で、Node.js用のHTTPアダプターは@modelcontextprotocol/nodeから提供される。Web標準のRequestを扱うコア部分と、Node.jsのnode:httpに橋渡しする部分が分かれている、という構成だ。CloudflareやDenoのような別ランタイムに載せ替えやすくするための分割だと理解している。

まず、プロジェクトを作る。

mkdir mcp-2026-deploy-demo
cd mcp-2026-deploy-demo

npm init -y
npm pkg set type=module

npm install \
  @modelcontextprotocol/server \
  @modelcontextprotocol/client \
  @modelcontextprotocol/node \
  zod

npm install -D \
  typescript \
  tsx \
  @types/node

npm pkg set type=moduleを忘れると、後でESMのimportが通らずに詰まる。ここは最初に入れておいたほうがいい。

ソースを置くディレクトリも作る。

mkdir src

18. npmスクリプトを設定する

npm pkg set scripts.server="tsx src/server.ts"
npm pkg set scripts.client="tsx src/client.ts"
npm pkg set scripts.typecheck="tsc --noEmit"

最終的なpackage.jsonのスクリプト部分は、次のようになる。

{
  "scripts": {
    "server": "tsx src/server.ts",
    "client": "tsx src/client.ts",
    "typecheck": "tsc --noEmit"
  }
}

19. tsconfig.jsonを作成する

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "types": [
      "node"
    ],
    "outDir": "dist"
  },
  "include": [
    "src/**/*.ts"
  ]
}

noUncheckedIndexedAccessexactOptionalPropertyTypesを有効にしてあるのは、MRTRで受け取るinputResponsesのように「あるかもしれないし、ないかもしれない」データを扱うコードが多いからだ。型で先に気づけるほうが、実行してから気づくより安い。

既存のNodeプロジェクトの設定をそのまま持ち込むより、検証用にこれくらいの小さい設定から始めたほうが動かしやすいと思う。

20. MCPサーバーを実装する

src/server.tsを作成する。まずimportから。

import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

import {
  acceptedContent,
  createMcpHandler,
  inputRequired,
  inputResponse,
  McpServer
} from "@modelcontextprotocol/server";

import type {
  CallToolResult,
  InputRequiredResult
} from "@modelcontextprotocol/server";

import {
  localhostHostValidation,
  localhostOriginValidation,
  toNodeHandler
} from "@modelcontextprotocol/node";

import * as z from "zod/v4";

inputRequiredinputResponseacceptedContentの3つがMRTR用のヘルパーだ。この3つの役割分担が分かれば、MRTRの実装はだいたい見通せる。

スキーマを定義する

デプロイ計画のスキーマを定義する。

const environmentSchema = z.enum([
  "staging",
  "production"
]);

const deploymentPlanSchema = z.object({
  planId: z.string(),
  environment: environmentSchema,
  version: z.string().min(1),
  changeSummary: z.string().min(1),
  issuedAt: z.number().int()
});

type DeploymentPlan = z.infer<typeof deploymentPlanSchema>;

ツールの出力スキーマも定義しておく。

const prepareDeploymentOutputSchema = deploymentPlanSchema.extend({
  planToken: z.string()
});

const executeDeploymentOutputSchema = z.object({
  deploymentId: z.string(),
  planId: z.string(),
  environment: environmentSchema,
  version: z.string(),
  status: z.literal("deployed"),
  completedAt: z.string()
});

MRTRで表示する確認フォームのスキーマも定義する。

const approvalSchema = z.object({
  approve: z.boolean().meta({
    title: "デプロイを承認する"
  })
});

.meta({ title: ... })で付けたラベルは、そのままJSON Schemaへ出力され、クライアント側のフォーム項目名になる。ここを埋めておかないと、承認画面にapproveという素っ気ない項目が出るだけになる。

計画を署名付きトークンにする

今回のサンプルでは、デプロイ計画をサーバーへ保存する代わりに、HMAC署名付きトークンとしてクライアントへ返す。こうしておけば、別のサーバーインスタンスが次のリクエストを受け取っても、トークンを検証するだけで計画を復元できる。

const TOKEN_TTL_MS = 10 * 60 * 1000;

const signingKey =
  process.env.MCP_DEMO_SIGNING_KEY ??
  "development-only-signing-key-change-me";

フォールバック文字列を置いてあるが、これは動作確認用だ。本番で環境変数が未設定のまま起動すると、誰でも計画を偽造できることになる。実運用では、キーが取れなければ起動時に落とすほうが安全だと思う。

署名を作る。

function createSignature(payload: string): string {
  return createHmac("sha256", signingKey)
    .update(payload)
    .digest("base64url");
}

計画をトークン化する。

function mintPlanToken(plan: DeploymentPlan): string {
  const payload = Buffer
    .from(JSON.stringify(plan), "utf8")
    .toString("base64url");

  const signature = createSignature(payload);

  return `${payload}.${signature}`;
}

トークンを検証する。ここがいちばん間違えやすいので、丁寧に書く。

function verifyPlanToken(token: string): DeploymentPlan {
  const [payload, encodedSignature] = token.split(".");

  if (!payload || !encodedSignature) {
    throw new Error("Invalid deployment plan token");
  }

  const expectedSignature = createSignature(payload);

  const actualBuffer = Buffer.from(encodedSignature, "base64url");
  const expectedBuffer = Buffer.from(expectedSignature, "base64url");

  if (
    actualBuffer.length !== expectedBuffer.length ||
    !timingSafeEqual(actualBuffer, expectedBuffer)
  ) {
    throw new Error("Deployment plan signature mismatch");
  }

  const decoded = Buffer.from(payload, "base64url").toString("utf8");

  const plan = deploymentPlanSchema.parse(JSON.parse(decoded));

  if (Date.now() - plan.issuedAt > TOKEN_TTL_MS) {
    throw new Error("Deployment plan token has expired");
  }

  return plan;
}

3点だけ補足しておく。

ひとつ目はtimingSafeEqualを使っていること。署名の比較を===で書くと、一致した文字数だけ比較時間が延びるため、理屈のうえでは総当たりの手がかりになる。ただしtimingSafeEqualは長さが違うバッファを渡すと例外を投げるので、先に長さを比べてから呼ぶ必要がある。

ふたつ目は、署名の検証が通ってからJSONをパースしていること。逆順にすると、署名を確認する前に攻撃者の作ったデータを解釈することになる。

3つ目は、パース後にdeploymentPlanSchema.parse()を通していること。署名が正しくても、こちらが以前発行したトークンの形式が変わっている可能性はある。デコードした結果を素通しで信用しない。

エラー結果を作るヘルパーも用意しておく。

function toolError(message: string): CallToolResult {
  return {
    isError: true,
    content: [
      {
        type: "text",
        text: message
      }
    ]
  };
}

ハンドラーファクトリーを作る

MCPサーバー本体はcreateMcpHandlerで作る。

const mcpHandler = createMcpHandler(() => {
  const server = new McpServer({
    name: "deployment-approval-server",
    version: "1.0.0"
  });

  return server;
});

ここで押さえておきたいのは、渡しているのがサーバーインスタンスではなくファクトリー関数だという点だ。TypeScript SDK v2のHTTPサーバーでは、この関数がHTTPリクエストごとに実行され、毎回新しいMcpServerインスタンスが作られる。

つまり、サーバーインスタンスへリクエスト間の状態を保存してはいけない。前編で書いた「セッションがなくなった」という話が、コードのレベルではこの形で現れる。

ファクトリーの中でツールを登録した完成形は、次のようになる。

const mcpHandler = createMcpHandler(() => {
  const server = new McpServer({
    name: "deployment-approval-server",
    version: "1.0.0"
  });

  server.registerTool(
    "prepare_deployment",
    {
      title: "デプロイ計画の作成",
      description: "デプロイ計画を作成し、署名付きplanTokenを返します。",

      inputSchema: z.object({
        environment: environmentSchema,

        version: z.string().min(1)
          .describe("デプロイするアプリケーションバージョン"),

        changeSummary: z.string().min(1).max(500)
          .describe("デプロイ内容の要約")
      }),

      outputSchema: prepareDeploymentOutputSchema,

      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false
      }
    },

    async ({
      environment,
      version,
      changeSummary
    }): Promise<CallToolResult> => {
      const plan: DeploymentPlan = {
        planId: `plan_${randomUUID()}`,
        environment,
        version,
        changeSummary,
        issuedAt: Date.now()
      };

      const planToken = mintPlanToken(plan);

      const result = {
        ...plan,
        planToken
      };

      return {
        content: [
          {
            type: "text",
            text: [
              "デプロイ計画を作成しました。",
              `環境: ${environment}`,
              `バージョン: ${version}`,
              `変更内容: ${changeSummary}`,
              `計画ID: ${plan.planId}`
            ].join("\n")
          }
        ],

        structuredContent: result
      };
    }
  );

  server.registerTool(
    "execute_deployment",
    {
      title: "デプロイの実行",
      description: "署名済みデプロイ計画を検証し、オペレーターの承認後にデプロイします。",

      inputSchema: z.object({
        planToken: z.string().min(1)
          .describe("prepare_deploymentが返した署名付きトークン")
      }),

      outputSchema: executeDeploymentOutputSchema,

      annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: false,
        openWorldHint: true
      }
    },

    async (
      { planToken },
      ctx
    ): Promise<CallToolResult | InputRequiredResult> => {
      let plan: DeploymentPlan;

      try {
        plan = verifyPlanToken(planToken);
      } catch (error) {
        const message =
          error instanceof Error
            ? error.message
            : "Unknown token validation error";

        return toolError(`デプロイ計画を検証できませんでした: ${message}`);
      }

      /*
       * inputResponsesはクライアント入力なので、
       * 信頼せず必ず検証する。
       */
      const approvalResponse = inputResponse(
        ctx.mcpReq.inputResponses,
        "approval"
      );

      /*
       * 初回呼び出しではkindがmissingになる。
       * ユーザーが拒否・キャンセルした場合は、
       * 再度承認を要求せず処理を終了する。
       */
      if (
        approvalResponse.kind === "elicit" &&
        approvalResponse.action !== "accept"
      ) {
        return toolError("オペレーターによってデプロイがキャンセルされました。");
      }

      /*
       * acceptされた入力だけを取り出し、
       * Zodスキーマで検証する。
       */
      const approval = acceptedContent(
        ctx.mcpReq.inputResponses,
        "approval",
        approvalSchema
      );

      /*
       * 初回、または承認結果が不足している場合、
       * input_requiredを返す。
       */
      if (approval === undefined) {
        return inputRequired({
          inputRequests: {
            approval: inputRequired.elicit({
              message: [
                "次のデプロイを実行しますか?",
                "",
                `環境: ${plan.environment}`,
                `バージョン: ${plan.version}`,
                `変更内容: ${plan.changeSummary}`,
                `計画ID: ${plan.planId}`
              ].join("\n"),

              requestedSchema: approvalSchema
            })
          }
        });
      }

      if (!approval.approve) {
        return toolError("デプロイは承認されませんでした。");
      }

      /*
       * 実際のシステムでは、ここで
       * CodePipeline、ECS、Kubernetesなどを呼び出す。
       */
      const result = {
        deploymentId: `deployment_${randomUUID()}`,
        planId: plan.planId,
        environment: plan.environment,
        version: plan.version,
        status: "deployed" as const,
        completedAt: new Date().toISOString()
      };

      return {
        content: [
          {
            type: "text",
            text: [
              "デプロイが完了しました。",
              `環境: ${result.environment}`,
              `バージョン: ${result.version}`,
              `デプロイID: ${result.deploymentId}`
            ].join("\n")
          }
        ],

        structuredContent: result
      };
    }
  );

  return server;
});

execute_deploymentのハンドラーが、MRTRの実装そのものだ。流れをもう一度言葉にすると、こうなる。

  1. まずinputResponsesを見て、承認の回答が来ているか調べる
  2. 来ていない(初回)ならinputRequired()を返して、いったん処理を終える
  3. 回答が来ていれば、Zodで検証したうえで本処理へ進む

同じハンドラーが、初回と再実行の両方で呼ばれる。acceptedContent()にZodスキーマを渡しているのは、クライアントから届いた未信頼データを検証してから使うためだ。

21. Node.jsのHTTPサーバーへマウントする

createMcpHandlerが返すのは、Web標準のRequestを処理するハンドラーだ。Node.jsのnode:httpで使うには、toNodeHandlerで変換する。

const nodeHandler = toNodeHandler(mcpHandler);

ローカル開発では、HostヘッダーとOriginの検証を挟む。

const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

これはDNSリバインディング対策だ。ローカルで動かしているMCPサーバーへ、ブラウザ上の悪意あるページから勝手にリクエストを送られるのを防ぐ。手元で試すだけだから要らない、と省略したくなるところだが、127.0.0.1で待ち受けるサーバーはブラウザからも到達できる。入れておいたほうがいい。

HTTPサーバーを起動する。

const httpServer = createServer((request, response) => {
  if (!validateHost(request, response)) {
    return;
  }

  if (!validateOrigin(request, response)) {
    return;
  }

  void nodeHandler(request, response);
});

ポート3000で待ち受ける。

httpServer.listen(3000, "127.0.0.1", () => {
  console.log("MCP server listening on http://127.0.0.1:3000/mcp");
});

バインド先を127.0.0.1と明示しているのも意図的だ。省略すると環境によっては0.0.0.0で待ち受け、同じネットワークの別マシンから叩けてしまう。

終了処理も入れておく。

process.on("SIGINT", () => {
  void mcpHandler
    .close()
    .finally(() => {
      httpServer.close(() => {
        process.exit(0);
      });
    });
});

22. server.tsの完成版

ここまでをまとめたsrc/server.tsの完成版は次のとおり。これをそのまま貼れば動く。

import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

import {
  acceptedContent,
  createMcpHandler,
  inputRequired,
  inputResponse,
  McpServer
} from "@modelcontextprotocol/server";

import type {
  CallToolResult,
  InputRequiredResult
} from "@modelcontextprotocol/server";

import {
  localhostHostValidation,
  localhostOriginValidation,
  toNodeHandler
} from "@modelcontextprotocol/node";

import * as z from "zod/v4";

const environmentSchema = z.enum([
  "staging",
  "production"
]);

const deploymentPlanSchema = z.object({
  planId: z.string(),
  environment: environmentSchema,
  version: z.string().min(1),
  changeSummary: z.string().min(1),
  issuedAt: z.number().int()
});

type DeploymentPlan = z.infer<typeof deploymentPlanSchema>;

const prepareDeploymentOutputSchema = deploymentPlanSchema.extend({
  planToken: z.string()
});

const executeDeploymentOutputSchema = z.object({
  deploymentId: z.string(),
  planId: z.string(),
  environment: environmentSchema,
  version: z.string(),
  status: z.literal("deployed"),
  completedAt: z.string()
});

const approvalSchema = z.object({
  approve: z.boolean().meta({
    title: "デプロイを承認する"
  })
});

const TOKEN_TTL_MS = 10 * 60 * 1000;

const signingKey =
  process.env.MCP_DEMO_SIGNING_KEY ??
  "development-only-signing-key-change-me";

function createSignature(payload: string): string {
  return createHmac("sha256", signingKey)
    .update(payload)
    .digest("base64url");
}

function mintPlanToken(plan: DeploymentPlan): string {
  const payload = Buffer
    .from(JSON.stringify(plan), "utf8")
    .toString("base64url");

  const signature = createSignature(payload);

  return `${payload}.${signature}`;
}

function verifyPlanToken(token: string): DeploymentPlan {
  const [payload, encodedSignature] = token.split(".");

  if (!payload || !encodedSignature) {
    throw new Error("Invalid deployment plan token");
  }

  const expectedSignature = createSignature(payload);

  const actualBuffer = Buffer.from(encodedSignature, "base64url");
  const expectedBuffer = Buffer.from(expectedSignature, "base64url");

  if (
    actualBuffer.length !== expectedBuffer.length ||
    !timingSafeEqual(actualBuffer, expectedBuffer)
  ) {
    throw new Error("Deployment plan signature mismatch");
  }

  const decoded = Buffer.from(payload, "base64url").toString("utf8");

  const plan = deploymentPlanSchema.parse(JSON.parse(decoded));

  if (Date.now() - plan.issuedAt > TOKEN_TTL_MS) {
    throw new Error("Deployment plan token has expired");
  }

  return plan;
}

function toolError(message: string): CallToolResult {
  return {
    isError: true,
    content: [
      {
        type: "text",
        text: message
      }
    ]
  };
}

const mcpHandler = createMcpHandler(() => {
  const server = new McpServer({
    name: "deployment-approval-server",
    version: "1.0.0"
  });

  server.registerTool(
    "prepare_deployment",
    {
      title: "デプロイ計画の作成",
      description: "デプロイ計画を作成し、署名付きplanTokenを返します。",

      inputSchema: z.object({
        environment: environmentSchema,

        version: z.string().min(1)
          .describe("デプロイするバージョン"),

        changeSummary: z.string().min(1).max(500)
          .describe("デプロイ内容の要約")
      }),

      outputSchema: prepareDeploymentOutputSchema,

      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false
      }
    },

    async ({
      environment,
      version,
      changeSummary
    }): Promise<CallToolResult> => {
      const plan: DeploymentPlan = {
        planId: `plan_${randomUUID()}`,
        environment,
        version,
        changeSummary,
        issuedAt: Date.now()
      };

      const planToken = mintPlanToken(plan);

      const result = {
        ...plan,
        planToken
      };

      return {
        content: [
          {
            type: "text",
            text: [
              "デプロイ計画を作成しました。",
              `環境: ${environment}`,
              `バージョン: ${version}`,
              `変更内容: ${changeSummary}`,
              `計画ID: ${plan.planId}`
            ].join("\n")
          }
        ],

        structuredContent: result
      };
    }
  );

  server.registerTool(
    "execute_deployment",
    {
      title: "デプロイの実行",
      description: "計画を検証し、承認後にデプロイします。",

      inputSchema: z.object({
        planToken: z.string().min(1)
          .describe("prepare_deploymentが返したトークン")
      }),

      outputSchema: executeDeploymentOutputSchema,

      annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: false,
        openWorldHint: true
      }
    },

    async (
      { planToken },
      ctx
    ): Promise<CallToolResult | InputRequiredResult> => {
      let plan: DeploymentPlan;

      try {
        plan = verifyPlanToken(planToken);
      } catch (error) {
        const message =
          error instanceof Error
            ? error.message
            : "Unknown token error";

        return toolError(`デプロイ計画を検証できませんでした: ${message}`);
      }

      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({
          inputRequests: {
            approval: inputRequired.elicit({
              message: [
                "デプロイを実行しますか?",
                "",
                `環境: ${plan.environment}`,
                `バージョン: ${plan.version}`,
                `変更内容: ${plan.changeSummary}`,
                `計画ID: ${plan.planId}`
              ].join("\n"),

              requestedSchema: approvalSchema
            })
          }
        });
      }

      if (!approval.approve) {
        return toolError("デプロイは承認されませんでした。");
      }

      const result = {
        deploymentId: `deployment_${randomUUID()}`,
        planId: plan.planId,
        environment: plan.environment,
        version: plan.version,
        status: "deployed" as const,
        completedAt: new Date().toISOString()
      };

      return {
        content: [
          {
            type: "text",
            text: [
              "デプロイが完了しました。",
              `環境: ${result.environment}`,
              `バージョン: ${result.version}`,
              `デプロイID: ${result.deploymentId}`
            ].join("\n")
          }
        ],

        structuredContent: result
      };
    }
  );

  return server;
});

const nodeHandler = toNodeHandler(mcpHandler);

const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

const httpServer = createServer((request, response) => {
  if (
    !validateHost(request, response) ||
    !validateOrigin(request, response)
  ) {
    return;
  }

  void nodeHandler(request, response);
});

httpServer.listen(3000, "127.0.0.1", () => {
  console.log("MCP server listening on http://127.0.0.1:3000/mcp");
});

process.on("SIGINT", () => {
  void mcpHandler
    .close()
    .finally(() => {
      httpServer.close(() => {
        process.exit(0);
      });
    });
});

200行ほどのコードだが、旧仕様のサーバーと比べたときにないものに注目してほしい。

  • 初期化ハンドラがない(initializeを処理するコードがどこにもない)
  • セッションIDを扱うコードがない
  • 接続を保持する仕組みがない

あるのはツール登録とHTTPサーバーの起動だけだ。普通のWeb APIサーバーとほとんど同じ構造になっている。前編で「HTTPインフラとの親和性が上がった」と書いたのは、要するにこういうことだった。

23. MRTR対応クライアントを作る

次にsrc/client.tsを作る。必要なクラスを読み込む。

import {
  Client,
  StreamableHTTPClientTransport
} from "@modelcontextprotocol/client";

クライアントを作る。

const client = new Client(
  {
    name: "deployment-demo-client",
    version: "1.0.0"
  },
  {
    versionNegotiation: {
      mode: {
        pin: "2026-07-28"
      }
    },

    capabilities: {
      elicitation: {
        form: {}
      }
    },

    inputRequired: {
      maxRounds: 3
    }
  }
);

第2引数に3つ設定を入れている。それぞれ意味がある。

versionNegotiationでプロトコルバージョンを2026-07-28へ固定しているのは、今回は新仕様の挙動を確認するのが目的だからだ。固定しないと、サーバー側の状況によっては旧世代へフォールバックしてしまい、「MRTRを試したつもりが旧仕様で動いていた」ということが起こりうる。

versionNegotiation: {
  mode: {
    pin: "2026-07-28"
  }
}

capabilitiesでElicitationを宣言しているのは、これがないとサーバーがinput_requiredを返せないからだ。前編で書いたとおり、Capabilityは初期化時ではなくリクエストごとに_metaへ載って飛ぶ。

capabilities: {
  elicitation: {
    form: {}
  }
}

inputRequired.maxRoundsは、MRTRの往復回数の上限だ。サーバーがinput_requiredを返し続けたときに無限ループへ入らないようにする安全弁で、これは後述する。

続いてElicitationのハンドラーを登録する。

client.setRequestHandler(
  "elicitation/create",
  async request => {
    if (request.params.mode === "url") {
      return {
        action: "decline"
      };
    }

    console.log("\n承認要求を受信しました:");
    console.log(request.params.message);

    /*
     * デモなので自動承認する。
     *
     * 実際のクライアントでは、
     * requestedSchemaからフォームを生成し、
     * ユーザーの回答を返す。
     */
    return {
      action: "accept",
      content: {
        approve: true
      }
    };
  }
);

ここは検証用に自動承認しているが、実際のクライアントではrequest.params.requestedSchemaからフォームを組み立てて、ユーザーの回答を返すことになる。mode === "url"を弾いているのは、ブラウザを開いて外部で入力させるタイプの要求に、CLIクライアントでは応えられないからだ。

MRTRのときもSDKは同じelicitation/createハンドラーを呼び、回答を付けてツールを再実行する。つまり、旧仕様のElicitation対応クライアントを書いたことがあれば、ハンドラーの書き方自体は変わっていない。変わったのは、その要求がストリームで届くのか、レスポンスとして届くのかという裏側の話だ。

サーバーへ接続する。

const transport = new StreamableHTTPClientTransport(
  new URL("http://127.0.0.1:3000/mcp")
);

await client.connect(transport);

利用中のプロトコル世代を確認する。

console.log("Protocol era:", client.getProtocolEra());

新仕様で接続できていれば、次のように表示される。ここがlegacyになっていたら、バージョン固定が効いていないので設定を見直す。

Protocol era: modern

まず、デプロイ計画を作る。

const prepared = await client.callTool({
  name: "prepare_deployment",

  arguments: {
    environment: "production",
    version: "v1.4.0",
    changeSummary: "認証APIのタイムアウト処理を修正"
  }
});

structuredContentからトークンを取り出す。

const preparedData = prepared.structuredContent as
  | { planToken?: string }
  | undefined;

const planToken = preparedData?.planToken;

if (!planToken) {
  throw new Error("planTokenが返されませんでした");
}

次に、デプロイを実行する。

const executed = await client.callTool({
  name: "execute_deployment",

  arguments: {
    planToken
  }
});

このcallTool()の1行の内部で、実際には次の処理が走っている。

  1. execute_deploymentを呼び出す
  2. サーバーがinput_requiredを返す
  3. SDKがelicitation/createハンドラーを呼ぶ
  4. 回答をinputResponsesへ格納する
  5. SDKがexecute_deploymentを再実行する
  6. サーバーがcomplete結果を返す

HTTPリクエストは2回飛んでいるのに、呼び出し側のコードは通常のツール呼び出しと変わらない。MRTRの往復はSDKが吸収してくれる。

console.dir(executed, { depth: null });

await client.close();

24. client.tsの完成版

import {
  Client,
  StreamableHTTPClientTransport
} from "@modelcontextprotocol/client";

async function main(): Promise<void> {
  const client = new Client(
    {
      name: "deployment-demo-client",
      version: "1.0.0"
    },
    {
      versionNegotiation: {
        mode: {
          pin: "2026-07-28"
        }
      },

      capabilities: {
        elicitation: {
          form: {}
        }
      },

      inputRequired: {
        maxRounds: 3
      }
    }
  );

  client.setRequestHandler(
    "elicitation/create",
    async request => {
      if (request.params.mode === "url") {
        return {
          action: "decline"
        };
      }

      console.log("\n承認要求を受信しました:");
      console.log(request.params.message);

      return {
        action: "accept",
        content: {
          approve: true
        }
      };
    }
  );

  const transport = new StreamableHTTPClientTransport(
    new URL("http://127.0.0.1:3000/mcp")
  );

  await client.connect(transport);

  console.log("Protocol era:", client.getProtocolEra());

  const prepared = await client.callTool({
    name: "prepare_deployment",

    arguments: {
      environment: "production",
      version: "v1.4.0",
      changeSummary: "認証APIのタイムアウト処理を修正"
    }
  });

  console.log("\nデプロイ計画:");
  console.dir(prepared.structuredContent, { depth: null });

  const preparedData = prepared.structuredContent as
    | { planToken?: string }
    | undefined;

  const planToken = preparedData?.planToken;

  if (!planToken) {
    throw new Error("planTokenが返されませんでした");
  }

  const executed = await client.callTool({
    name: "execute_deployment",

    arguments: {
      planToken
    }
  });

  console.log("\n実行結果:");
  console.dir(executed, { depth: null });

  await client.close();
}

main().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

25. 実行する

最初に型チェックしておく。

npm run typecheck

次に署名キーを設定する。Linux、macOS、WSLではこう。

export MCP_DEMO_SIGNING_KEY="$(openssl rand -hex 32)"

PowerShellならこうなる。

$env:MCP_DEMO_SIGNING_KEY = `
  [Convert]::ToHexString(
    [Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
  )

サーバーを起動する。

npm run server

次のように表示されれば待ち受けている。

MCP server listening on http://127.0.0.1:3000/mcp

サーバーは起動したままにして、別のターミナルからクライアントを実行する。このとき、署名キーの環境変数はサーバー側のターミナルにあればよい。クライアントはトークンを検証しないので、クライアント側に設定は要らない。

npm run client

結果は概念的には次のようになる。

Protocol era: modern

デプロイ計画:
{
  planId: 'plan_...',
  environment: 'production',
  version: 'v1.4.0',
  changeSummary: '認証APIのタイムアウト処理を修正',
  issuedAt: 1785312000000,
  planToken: 'eyJwbGFuSWQiOi...'
}

承認要求を受信しました:
デプロイを実行しますか?

環境: production
バージョン: v1.4.0
変更内容: 認証APIのタイムアウト処理を修正
計画ID: plan_...

実行結果:
{
  content: [
    {
      type: 'text',
      text: 'デプロイが完了しました。\n...'
    }
  ],
  structuredContent: {
    deploymentId: 'deployment_...',
    planId: 'plan_...',
    environment: 'production',
    version: 'v1.4.0',
    status: 'deployed',
    completedAt: '2026-07-29T...Z'
  }
}

「承認要求を受信しました」の行が出ていれば、MRTRが1往復ぶん動いたということだ。ここが出ずにいきなり実行結果へ飛ぶ場合は、クライアントのCapability宣言が抜けているか、バージョン固定が効いていない。

拒否したときの挙動も確認しておくといい。クライアントのelicitation/createハンドラーの戻り値を{ action: "decline" }へ変えると、サーバー側は「オペレーターによってキャンセルされました」を返して終わる。承認フォームを繰り返し出さないことを確認しておく。

26. 通信をワイヤーレベルで確認する

SDKを使うとMRTRの再実行は自動化されるので、コードだけ見ていると何が飛んでいるか分からない。新仕様を理解するには、生のリクエストとレスポンスを一度見ておいたほうが早い。

1回目の呼び出し

POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: execute_deployment
{
  "jsonrpc": "2.0",
  "id": 100,
  "method": "tools/call",
  "params": {
    "name": "execute_deployment",
    "arguments": {
      "planToken": "eyJwbGFuSWQiOi..."
    },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {
        "elicitation": {
          "form": {}
        }
      },
      "io.modelcontextprotocol/clientInfo": {
        "name": "deployment-demo-client",
        "version": "1.0.0"
      }
    }
  }
}

ここで確認したいのは2点。Mcp-Session-Idヘッダーが存在しないこと。そして、プロトコルバージョン・Capability・クライアント情報が、初期化時ではなくこのリクエストの_metaに入っていることだ。

Mcp-MethodMcp-NameがHTTPヘッダーに出ているのも新仕様ならではで、これによってALBやWAFがJSONボディを解析せずに「今どのツールが呼ばれようとしているか」を判断できる。

サーバーの中間結果

{
  "jsonrpc": "2.0",
  "id": 100,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "approval": {
        "method": "elicitation/create",
        "params": {
          "message": "デプロイを実行しますか?",
          "requestedSchema": {
            "type": "object",
            "properties": {
              "approve": {
                "type": "boolean",
                "title": "デプロイを承認する"
              }
            },
            "required": [
              "approve"
            ]
          }
        }
      }
    }
  }
}

ここが旧仕様といちばん違うところだ。この時点で、サーバーのHTTP処理は完了している。接続は閉じており、サーバーはユーザーの入力を待っていない。旧仕様なら、ここでサーバーは開いたストリーム上にelicitation/createを送り、レスポンスが返るまでハンドラーの中で待っていた。

先ほどapprovalSchemaに付けた.meta({ title: "デプロイを承認する" })が、requestedSchemaの中にそのまま出ているのも確認できる。

2回目の呼び出し

クライアントがユーザーの回答を受け取ると、新しいJSON-RPC IDで同じツールを呼び出す。

POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: execute_deployment
{
  "jsonrpc": "2.0",
  "id": 101,
  "method": "tools/call",
  "params": {
    "name": "execute_deployment",
    "arguments": {
      "planToken": "eyJwbGFuSWQiOi..."
    },
    "inputResponses": {
      "approval": {
        "action": "accept",
        "content": {
          "approve": true
        }
      }
    },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {
        "elicitation": {
          "form": {}
        }
      },
      "io.modelcontextprotocol/clientInfo": {
        "name": "deployment-demo-client",
        "version": "1.0.0"
      }
    }
  }
}

IDが100から101へ変わっている。これは「前のリクエストの続き」ではなく、独立した新しいリクエストだからだ。argumentsも1回目とまったく同じものが再送されている。サーバーは何も覚えていないので、クライアントが全部持ってくるしかない。

最終結果

{
  "jsonrpc": "2.0",
  "id": 101,
  "result": {
    "resultType": "complete",
    "content": [
      {
        "type": "text",
        "text": "デプロイが完了しました。"
      }
    ],
    "structuredContent": {
      "deploymentId": "deployment_...",
      "planId": "plan_...",
      "environment": "production",
      "version": "v1.4.0",
      "status": "deployed",
      "completedAt": "2026-07-29T07:00:00.000Z"
    }
  }
}

resultTypeinput_requiredからcompleteへ変わった。クライアントはこのフィールドを見て、「まだ聞かれている」のか「終わった」のかを判断する。

27. この実装のどこが新仕様なのか

ここまで書いてきたコードの、どこがMCP 2026-07-28の設計を反映しているのか整理しておく。

initializeを使っていない

サーバー側には、初期化イベントを処理するコードがない。

const mcpHandler = createMcpHandler(() => {
  return new McpServer({
    name: "deployment-server",
    version: "1.0.0"
  });
});

各リクエストは独立して処理される。

サーバーインスタンスがリクエストごとに作られる

先ほども触れたが、このファクトリーはHTTPリクエストごとに実行される。したがって、次のようなコードを書いてはいけない。

const inMemoryApprovals = new Map<string, boolean>();

これをサーバーインスタンス内に置いても、次のリクエストでは新しいインスタンスになるので消えている。さらに、複数のECSタスクやLambdaインスタンスで動かせば、同じプロセスへ届く保証もない。手元の1プロセスで試している限りは動いてしまうことがある分、余計に厄介だ。

状態を明示的なトークンとして渡している

const planToken = mintPlanToken(plan);

2つのツール間で必要な情報は、セッションへ保存せず、通常のツール引数として渡している。

await client.callTool({
  name: "execute_deployment",
  arguments: {
    planToken
  }
});

MRTRをレスポンスとして返している

return inputRequired({
  inputRequests: {
    approval: inputRequired.elicit({
      message: "デプロイしますか?",
      requestedSchema: approvalSchema
    })
  }
});

サーバーは、クライアントへ直接メッセージを送信していない。returnしているだけだ。

再実行された回答を検証している

const approval = acceptedContent(
  ctx.mcpReq.inputResponses,
  "approval",
  approvalSchema
);

inputResponsesはクライアントから届く入力だ。必ず未信頼データとして扱う必要がある。

ツールの性質をannotationsで表現している

annotations: {
  readOnlyHint: false,
  destructiveHint: true,
  idempotentHint: false,
  openWorldHint: true
}

これは、クライアントやモデルへツールの性質を伝えるためのヒントだ。ただし、セキュリティ境界ではない。destructiveHint: trueと設定しても、サーバー側の認可処理が自動的に追加されるわけではない。「危険だと宣言してあるから大丈夫」という話ではないので、そこは分けて考える。

28. 本番環境では署名付きトークンだけで十分か

今回のサンプルでは、説明を単純にするためデプロイ計画をHMAC署名付きトークンへ格納した。これが有効なのは、次の条件が揃っている場合だ。

  • データサイズが小さい
  • 短時間だけ利用する
  • サーバー側で無効化する必要がない
  • トークン内の情報がクライアントから見えてもよい
  • 改ざん検知だけできればよい

逆に、次の要件があるならDynamoDBやPostgreSQLへ保存したほうがいい。

  • 承認前に計画を無効化したい
  • 計画の変更履歴を保存したい
  • 誰が承認したか監査したい
  • 同じ計画の二重実行を防止したい
  • 数KBを超えるデータを扱いたい
  • 機密情報をクライアントへ渡したくない
  • 長期間有効な処理を扱いたい

実際のデプロイ承認なら、監査要件だけでもDB側に倒れることが多いはずだ。その場合は、クライアントへIDだけを返す。

{
  "deploymentPlanHandle": "plan_01K1M..."
}

サーバー側では、認証済みユーザーと関連付けて保存する。

await deploymentPlanRepository.save({
  planId,
  ownerId: authInfo.clientId,
  environment,
  version,
  changeSummary,
  status: "waiting_for_approval",
  expiresAt
});

次の呼び出しで取得する。ownerIdを検索条件に含めているのが重要で、これがないと他人の計画IDを推測して実行できてしまう。

const plan = await deploymentPlanRepository.find({
  planId: args.deploymentPlanHandle,
  ownerId: authInfo.clientId
});

ここで押さえておきたいのは、状態を保存すること自体は問題ではないということだ。問題なのは、状態をMCPの暗黙的な接続セッションへ保存することのほうだ。ステートレス化したから外部ストアも使うな、という話ではない。

29. 本番向けに冪等性を追加する

今回のexecute_deploymentは、説明の都合でidempotentHint: falseにしてある。しかし実運用では冪等化したほうが安全だ。

理由はMRTRの構造にある。承認後の2回目のリクエストは独立したHTTPリクエストなので、レスポンスが返る途中でネットワークが切れると、クライアントは「デプロイが実行されたのかどうか」を判断できない。リトライすれば二重デプロイ、諦めれば未実行かもしれない、という状態になる。

入力スキーマに冪等キーを追加する。

inputSchema: z.object({
  planToken: z.string(),
  idempotencyKey: z.string().uuid()
})

デプロイ開始前に処理済みか確認する。

const existing = await executionRepository
  .findByIdempotencyKey(idempotencyKey);

if (existing) {
  return {
    content: [
      {
        type: "text",
        text: "既に実行済みのため、保存済み結果を返しました。"
      }
    ],
    structuredContent: existing.result
  };
}

未実行の場合は、条件付き書き込みで実行権を取得する。DynamoDBであれば、概念的には次のような条件式を使う。

await dynamoDb.send(
  new PutCommand({
    TableName: "McpIdempotency",

    Item: {
      idempotencyKey,
      status: "processing",
      createdAt: new Date().toISOString()
    },

    ConditionExpression: "attribute_not_exists(idempotencyKey)"
  })
);

ConditionExpressionを付けているのは、確認と書き込みのあいだに別のリクエストが割り込む可能性があるからだ。「読んで、なければ書く」を2ステップでやると、同時に来た2本が両方とも「なかった」と判断してしまう。

処理完了後に結果を保存する。

await executionRepository.complete({
  idempotencyKey,
  result
});

これで、レスポンス切断後にクライアントが再実行しても、同じデプロイを二重実行せず既存結果を返せる。

30. MRTRを実装するときの注意点

inputResponsesを信頼しない

次のような直接参照は避ける。

const approved = ctx.mcpReq.inputResponses
  ?.approval
  ?.content
  ?.approve;

入力形式が正しい保証はどこにもない。acceptedContent()にスキーマを渡して検証する。

const approval = acceptedContent(
  ctx.mcpReq.inputResponses,
  "approval",
  approvalSchema
);

初回と拒否を区別する

acceptedContent()は、次の3つの場合すべてでundefinedを返す。

  • 初回で回答がない
  • ユーザーが拒否した
  • ユーザーがキャンセルした

ここを区別せずに「undefinedなら確認を出す」と書くと、拒否したのにまた確認画面が出て、拒否してまた出て、という無限ループになる。実装していていちばん踏みやすい罠がこれだと思う。

そのため、inputResponse()で先に状態を判定する。

const response = inputResponse(
  ctx.mcpReq.inputResponses,
  "approval"
);

if (
  response.kind === "elicit" &&
  response.action !== "accept"
) {
  return toolError("キャンセルされました");
}

初回はkindmissingになるので、この条件には入らない。拒否・キャンセルのときだけ抜ける。

ラウンド数を制限する

クライアント側で最大ラウンド数を設定する。

inputRequired: {
  maxRounds: 3
}

サーバーのバグや悪意ある応答による無限ループを、クライアント側でも止められるようにしておく。サーバーを直せる立場にないクライアントを書くこともあるので、ここは入れておいたほうがいい。

1ラウンドで複数の入力を要求できる

inputRequestsはMap形式なので、複数の質問をまとめられる。

return inputRequired({
  inputRequests: {
    approval: inputRequired.elicit({
      message: "実行を承認しますか?",
      requestedSchema: approvalSchema
    }),

    ticket: inputRequired.elicit({
      message: "変更管理チケットを入力してください",
      requestedSchema: z.object({
        ticketId: z.string()
      })
    })
  }
});

まとめられるものはまとめたほうが、ユーザーの手数が減る。ただし「承認された場合だけチケット番号を聞く」のように質問同士に依存関係がある場合は、素直に複数ラウンドへ分ける。

31. 認証とクライアント情報を混同しない

リクエストの_metaには、次のクライアント情報が含まれる。

{
  "io.modelcontextprotocol/clientInfo": {
    "name": "deployment-demo-client",
    "version": "1.0.0"
  }
}

これはクライアントの自己申告だ。次のような認可判断に使ってはいけない。

if (clientInfo.name === "admin-client") {
  allowProductionDeploy();
}

攻撃者も同じ名前を申告できるからだ。clientInfoはログや識別のために使うもので、権限の根拠にはならない。

本番環境では、OAuthアクセストークンなどから検証済みの認証情報を取得する。概念的には次のように扱う。

createMcpHandler(({ authInfo }) => {
  const server = new McpServer(/* ... */);

  server.registerTool(
    "execute_deployment",
    config,
    async args => {
      if (!authInfo?.scopes.includes("deployment:execute")) {
        throw new Error("Insufficient scope");
      }

      // デプロイ処理
    }
  );

  return server;
});

本番デプロイであれば、さらに次のような条件もサーバー側で検証することになる。

  • 認証済みユーザーか
  • deployment:execute権限を持つか
  • production環境への権限があるか
  • 対象サービスへの権限があるか
  • 承認者と実行者の分離が必要か
  • 対象計画が同じユーザーまたは組織に属するか
  • トークンが失効していないか

32. AWSへデプロイする場合の構成

今回のサーバーはステートレスなので、AWS上では普通のHTTPアプリケーションとして構成できる。たとえばこうなる。

Claude / MCP Client
        |
        v
Amazon CloudFront
        |
        v
AWS WAF
        |
        v
Application Load Balancer
        |
        v
ECS Fargate
  +-----+-----+
  |           |
Task A      Task B
  |           |
  +-----+-----+
        |
        v
DynamoDB / RDS / S3

ALBではスティッキーセッションを有効にしない。

stickiness.enabled = false

各リクエストは任意のECSタスクへ送られる。

1回目:
Client → ALB → ECS Task A

MRTR再実行:
Client → ALB → ECS Task B

前編で「水平スケーリングしやすくなった」と書いたのは、この図のことだ。旧仕様なら、セッションをタスクのメモリに持っている以上、Task AとTask Bで状態が食い違う。だからスティッキーセッションを有効にするか、Redisなどでセッションを共有するしかなかった。GitHubのMCPサーバーが新仕様対応でRedisのセッション管理を削除できたのも、同じ理由だ。

どちらのタスクへ届いても処理できるためには、次の情報だけで完結している必要がある。

  • ツール引数
  • inputResponses
  • 認証情報
  • 明示的な状態ハンドル
  • 外部データストア

Mcp-MethodMcp-Nameは、アクセスログやWAFルールへ利用できる。ログ項目は概念的には次のようになる。

{
  "mcpMethod": "tools/call",
  "mcpName": "execute_deployment",
  "clientId": "client-123",
  "requestId": "request-456",
  "statusCode": 200,
  "durationMs": 842
}

ひとつ注意しておきたいのは、ツール名をメトリクスの高カーディナリティディメンションへ無制限に設定すると、CloudWatchやOpenTelemetryバックエンドのコストが増える可能性があることだ。Mcp-Nameはクライアントが自由に付けられる値なので、存在しないツール名を大量に送られると、そのぶんディメンションが増える。

メトリクスへ使う値は登録済みツール名だけに制限し、未知の値はunknownへ集約する、といった対策を入れておく。

33. まとめ

MCP 2026-07-28の中心にあるのは、MCPを特殊な双方向セッションプロトコルから、普通のWebインフラへ載せやすいステートレスなプロトコルへ変えることだ。

特に重要な変更は次の5点だと僕は考えている。

1. セッションがなくなった

initializeinitializedMcp-Session-Idが廃止された。各リクエストが、自分のプロトコルバージョン、クライアント情報、Capabilityを持つ。

2. 状態は明示的なハンドルになった

状態が必要な場合は、ツールがハンドルを発行し、次のツール引数として渡す。状態がなくなったのではなく、暗黙的なセッションから明示的なデータフローへ移動しただけだ。

3. 対話処理はMRTRになった

サーバーはクライアントへ要求をプッシュせず、input_requiredを返す。クライアントが回答を付け、元のリクエストを再実行する。

4. HTTPインフラとの親和性が上がった

Mcp-MethodMcp-Nameによって、ゲートウェイ、WAF、ロードバランサーがJSONボディを解析せずにルーティングや制御を行える。

5. コアと拡張の境界が明確になった

MCPのコアはステートレスなリクエスト/レスポンスへ絞られ、Tasks、MCP Apps、Enterprise Managed Authorizationなどは正式な拡張仕様として進化する。

今回のサンプルの流れ

作ったサンプルでは、デプロイ計画を署名付きトークンとして明示的に渡し、MRTRで承認を取得した。

prepare_deployment
        |
        v
署名付きplanToken
        |
        v
execute_deployment
        |
        v
input_required
        |
        v
ユーザー承認
        |
        v
execute_deploymentを再実行
        |
        v
complete

この設計なら、1回目と2回目のリクエストが別のサーバーインスタンスへ到達しても問題ない。今回の仕様変更によって、MCPサーバーはECS、Kubernetes、Lambda、Cloudflare Workersなど、一般的なスケーラブルHTTP基盤へ配置しやすくなった。

一方で、ステートレス化によってツール実装者が意識すべき責任は増えている。プロトコルが面倒を見てくれていた部分が、実装側へ降りてきたということだ。

  • 状態ハンドルの設計
  • 入力の検証
  • 冪等性
  • 認証と認可
  • トークンの失効
  • 再実行時の二重処理防止
  • キャッシュスコープ
  • 監査ログ
  • 分散トレーシング

新仕様のMCPサーバーを作るとき、「セッションを消せばよい」と考えるだけでは足りない。僕としては、次の原則を設計の起点にしたい。

どのリクエストも、それ単体と明示的な外部状態だけで、安全に再実行できるようにする。

これが、MCP 2026-07-28を実運用へ持ち込むうえで、いちばん重要な考え方だと思う。

参考にした一次情報

  • Model Context Protocol公式「The 2026-07-28 Specification」
  • MCP 2026-07-28公式Changelog
  • GitHub Changelog「GitHub MCP Server supports the next MCP specification」
  • Claude Blog「Bringing MCP 2026-07-28 to Claude」
  • MCP TypeScript SDK v2公式ドキュメント
📗 前編もあわせてどうぞ

仕様そのものの解説は 「MCP 2026-07-28で何が変わったのか — ステートレス化、MRTR、ヘッダールーティングを完全解説」 にまとめています。旧MCPサーバーからの移行チェックリストも載せました。

この記事が役に立ったらシェアしてください

MCP 2026-07-28対応のサーバー実装を、動くコードで解説しました。

カテゴリ

AIエージェント基盤

公開日

2026-08-01

💬 無料技術相談のご案内

この記事でご紹介した技術について、導入や活用のご相談を30分間無料承っております。

  • 「自社でも導入できる?」といった技術的な疑問
  • 既存システムとの連携・移行に関するご相談
  • コスト感や導入スケジュールの目安

30年以上のIT経験をもとに、率直にお答えします。強引なセールスや勧誘は一切ありません。

野口真一 野口真一

お気軽にご相談ください

記事に関するご質問や、AI・IT技術導入のご相談など、お気軽にお問い合わせください。