MCP Appsでインタラクティブなツールを作る

MCPサーバー開発ガイドでは、モデルが呼び出せる関数としての「Tools」を実装しました。Toolsが返せるのはテキストや構造化データまでで、結果を人間が操作できるUIとして表示する仕組みはプロトコルの中に含まれていません。この隙間を埋めるのが、ui://リソースでインタラクティブなUIをツールに紐付けるMCP Apps拡張です。デモコードとして、カウンターウィジェット(get_counterツール + ui://widget/counter.htmlリソース)という最小構成で、サーバー(mcp-server)とクライアント/ホスト(mcp-client)を別プロジェクト・別プロセスに分離し、Streamable HTTPで接続しています。単体動作からセッション分離まで一通り動作確認済みです。

MCP Appsとは何か

MCP Apps(SEP-1865)は、ui://スキームのリソースをツールに紐付け、ツール呼び出しの結果をiframe内のインタラクティブなUIとして表示できるようにする拡張です。iframeとホスト間のpostMessageのやり取りは独自のJSON-RPCダイアレクトとしてすでに仕様化されており、公式SDK @modelcontextprotocol/ext-apps がこれを実装しています。

SDKは役割ごとに3つのエントリーポイントに分かれています。

パッケージ/エントリーポイント 役割 主に使う場所
@modelcontextprotocol/ext-apps/server registerAppToolregisterAppResourceRESOURCE_MIME_TYPE MCPサーバー側
@modelcontextprotocol/ext-apps Appクラス(ホストとのpostMessage通信を担う) UI(iframe内)側
@modelcontextprotocol/ext-apps/app-bridge AppBridgePostMessageTransport ホスト側

公式SDKが提供する範囲と自前で書く範囲の境界は、公式サイトやGitHubリポジトリのドキュメントだけでなく、インストール済みパッケージの.d.tsを直接確認すると正確に把握できます。

用語を整理する

MCP Appsの実装に入る前に、用語を整理しておきます。

用語 意味 今回の対応
Host LLMを呼び出し、複数のMCP Clientを管理する、ユーザー向けアプリケーション全体 mcp-client/(SvelteKitアプリ)全体
Client Host内部で、1つのMCPサーバーとの接続を担当するモジュール mcp-client/src/lib/server/mcp-client.ts
Server ツール・リソースを提供する側 mcp-server/
AIエージェント(Claude) Hostが相談する「頭脳」。Host/Client/Serverの三者構造には含まれない外部要素 Claude API

プロジェクト名はmcp-clientですが、実態は「Client(1ファイル)を内包するHost(アプリ全体)」という入れ子構造になっている点に注意してください。

仕様書の「最新」とSDKの「対応済み」はズレる

MCPコアプロトコルの最新仕様(Current)は2026-07-28です。一方で@modelcontextprotocol/sdk(npm、^1.30.0、2026-08-06時点でインストールしたバージョン)は、この2026-07-28にまだ対応していません。対応している最新プロトコルバージョンは2025-11-25です。

SDKが実際にどのプロトコルバージョンまで対応しているかは、MCP-Protocol-Versionヘッダーを明示してtools/listを呼び出し、返ってくるエラーメッセージのsupported versionsを確認すると分かります。

curl -s http://localhost:3001/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Unsupported protocol version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)"},"id":null}

MCP-Protocol-Version: 2025-11-25を指定すると、tools/list / tools/call / resources/readはすべて正常に動作します。「仕様書のCurrent」と「SDKが実際にサポートしている最新」は別物で、仕様策定と実装の間には常にタイムラグがあります。最新仕様を前提にコードを書く場合は、着手前にこのエラーメッセージから対応バージョンを確認しておくと手戻りを防げます。

2026-07-28版仕様の2025-11-25からの主な変更点は、GETストリームエンドポイントの廃止と、プロトコルレベルのセッション(Mcp-Session-Idヘッダーによるセッション管理)の廃止です。後述するセッション設計は、この2025-11-25のセッション機構に依存しています。2026-07-28ベースのSDKではセッション機構自体が存在しないため、このセッション設計はそのままでは成立しません。仕様の新しいバージョンを追う場合、必要な機能が新版でも維持されているかを事前に確認しておく必要があります。

ツールとUIリソースを紐付ける

MCP Apps側の実装で中心になるのがregisterAppToolです。_meta.ui.resourceUriでツールにUIリソースを紐付けます。

registerAppTool(
  server,
  "get_counter",
  {
    title: "Get Counter",
    description: "Get or update the counter value.",
    inputSchema: { action: z.enum(["increment", "decrement"]).optional() },
    _meta: { ui: { resourceUri: "ui://widget/counter.html" } },
  },
  async ({ action }) => {
    if (action === "increment") count++;
    if (action === "decrement") count--;
    return { content: [{ type: "text", text: String(count) }] };
  },
);

Claude APIに渡すツール定義には_metaを含めません。name / description / input_schemaだけを渡します。_meta.uiはホスト側がUIレンダリング可否を判断するための情報であり、モデルの意思決定には不要という設計判断です。バックエンド側では、MCPサーバーから取得したツール一覧をClaude API向けに変換する際、この_metaを落とす処理を挟みます。

UIリソースの実装

ui://リソースの中身は、CSS/JSを含めて1枚のHTMLとして配信すると、別ホストへのCSP設定が不要になります。今回はVite + vite-plugin-singlefileで、INPUT=mcp-app.html vite buildのようにエントリポイントを指定してビルドし、1ファイルのHTMLを生成しています。

RESOURCE_MIME_TYPEの実際の値はtext/html;profile=mcp-appです。ドキュメント上は定数名としてしか紹介されていないため、値そのものを確認したい場合はresources/readを実行して確認する必要があります。

呼び出し経路をバックエンド1本に統一する

AppBridgeはコンストラクタにMCP Clientを渡すと、View(iframe内アプリ)からのtools/call等を自動的にそのClient経由でサーバーに転送する機能を持っています。ただしこの構成でブラウザ側からも直接mcp-serverに接続すると、「Claude起点の呼び出し(バックエンド経由)」と「ボタン起点の呼び出し(ブラウザ直接)」で接続経路が2本に分かれ、両方を同じセッションとして扱うのが難しくなります。

そのためこの実装では、AppBridgeのコンストラクタにclient: nullを渡し、oncalltoolを自前実装してバックエンドAPI(/api/tool-call)に中継する構成にしています。自動転送の便利さは失いますが、経路を1本に統一でき、セッション管理がシンプルになります。ブラウザが裏側のmcp-serverのURLを直接知らない構成になる分、本番向けの構成にも近づきます。

セッション設計——カウンターをユーザーごとに分離する

カウンターの値をサーバーのモジュールスコープの変数として実装すると、全ユーザー・全会話で1つのカウンターを共有してしまいます。要件は次の2点です。

  • 同じ会話(=同じページを開いている間)ではカウンターの値は共有されてよい
  • ページをリロードした場合、または別のユーザー・別のブラウザタブの場合は、独立した新しいカウンター(0から)になってほしい

これを満たすため、セッション単位で状態を分離しています。設計は3層です。

  1. ブラウザ:ページ読み込み時にcrypto.randomUUID()uiSessionIdを1つ生成し($stateにはしない、ただの定数)、チャット送信・ツール呼び出し中継の両方のリクエストに含める
  2. SvelteKitバックエンドMap<uiSessionId, Client>を保持し、同じuiSessionIdなら同じMCP Client(=mcp-server側の同じMCPセッション)を使い回す
  3. mcp-server:MCPプロトコル本来のセッション機構(StreamableHTTPServerTransportsessionIdGeneratorMcp-Session-Idヘッダー)を有効化し、セッションごとにcreateMcpServer()で独立したMcpServerインスタンス(=クロージャで閉じ込めた独立のcount)を生成する

サーバー側の骨格は次のようになります。

function createMcpServer() {
  const server = new McpServer({ name: "Counter MCP Server", version: "1.0.0" });
  let count = 0;

  registerAppTool(server, "get_counter", { /* ... */ }, async ({ action }) => {
    if (action === "increment") count++;
    if (action === "decrement") count--;
    return { content: [{ type: "text", text: String(count) }] };
  });

  registerAppResource(server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => {
    /* ビルド済みHTMLを読み込んで返す */
  });

  return server;
}

const transports = new Map<string, StreamableHTTPServerTransport>();

app.post("/mcp", async (req, res) => {
  const sessionId = req.headers["mcp-session-id"];
  let transport;

  if (sessionId && transports.has(sessionId)) {
    transport = transports.get(sessionId);
  } else if (!sessionId && isInitializeRequest(req.body)) {
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (id) => transports.set(id, transport),
    });
    transport.onclose = () => transports.delete(transport.sessionId);
    await createMcpServer().connect(transport);
  } else {
    res.status(400).json({ /* No valid session ID */ });
    return;
  }

  await transport.handleRequest(req, res, req.body);
});

app.get("/mcp", (req, res) => res.status(405).end());

uiSessionId(アプリケーション層)→ バックエンドのMCP Client(1接続)→ mcp-server側のMCPセッション(Mcp-Session-Id)→ 独立したcount、という1対1対応が成立します。ブラウザが直接mcp-serverに繋ぐ経路を残したままだとこの1対1対応が崩れてしまう(Claude起点とボタン起点で別々のセッションになる)ため、経路の統一とセッション設計はセットで検討する必要があります。

動作確認では、curlで2つの別セッション(別々のinitialize呼び出し)を作り、片方だけincrementしてももう片方には影響しないこと、ブラウザで「チャットで増やす → リロード → もう一度カウンターを見せて」と操作するとリロード後は0から始まることを確認しています。

app.get("/mcp", ...)で405を返している点にも触れておきます。MCP SDKのクライアントは接続時にオプションのGET SSEストリームを試みます。サーバー側にGETのルートが無いと素の404が返り、SDKはこれを「想定外のエラー」として扱ってしまいます。405を返すようにすると、「対応していないだけ」として正常に無視されます。

実装時の留意点

実装の過程で再現性がありそうな注意点をまとめておきます。

  • ESMパッケージのサブパスインポートには拡張子.jsが必須です@modelcontextprotocol/sdk/server/mcp.jsのように書かないと、Bunのexportsマップ解決に失敗しCannot find moduleになります
  • SvelteKit環境では、サードパーティSDKがprocess.envを直接参照できないことがあります@anthropic-ai/sdkは内部でprocess.env.ANTHROPIC_API_KEYを直接参照しますが、SvelteKit環境ではこれが見つからずCould not resolve authentication methodというエラーになります。$env/dynamic/privateから明示的にnew Anthropic({ apiKey: env.ANTHROPIC_API_KEY })と渡す必要があります
  • AppBridgeインスタンスは再描画のたびに後片付けが必要です:iframeをsrcdocで再読み込みしてもcontentWindowオブジェクトは同じままのため、前回分のAppBridge(postMessageのリスナー)をclose()せずに残すと、新しいViewの初期化に古いインスタンスが反応してしまいます。再描画のたびに前回分の後片付け関数を呼ぶ設計にしておく必要があります

Svelte 5特有の注意点

  • $stateでラップした値はProxyになりますpostMessage(構造化複製)に直接渡すとDataCloneErrorになるため、AppBridgesendToolInput / sendToolResultに渡す前に$state.snapshot()でプレーンオブジェクトに戻す必要があります
  • $effect内で非同期の後片付け処理を扱う場合は工夫が必要です。UIの再描画関数が非同期で「後片付け関数」を返す構成にすると、$effectのコールバック自体は同期のまま、内部で.then()を使って後片付け関数を受け取ることになります。$effectが再実行される前に非同期処理が終わっていないケースに対応するため、cancelledフラグでレースを防ぐ必要があります

まとめ

MCP Appsの実装は、公式SDK(@modelcontextprotocol/ext-apps)に乗ることで、registerAppToolregisterAppResourceAppBridgeを組み合わせるだけの見通しの良い作業になります。難しいのは拡張の仕様そのものより、「Claude起点とボタン起点、2つの呼び出し経路をどう1本に統一し、セッションをどう対応付けるか」という設計判断の部分です。

もう1つの実務上の注意点は、「最新仕様で作る」という前提を置く場合ほど、実際にインストールしたSDKが本当にその仕様に対応しているかを、ドキュメントではなくcurlのようなツールで直接確認する価値があるという点です。仕様策定と実装のタイムラグは今後も起こり得るため、着手前にサーバーが返すエラーメッセージのsupported versionsを確認しておくと、手戻りを防げます。