飛んでいるツバメのイラスト

AI エージェントがインタラクティブな UI を返すことを可能にする MCP UI

MCP UI は Model Context Protocol (MCP) を拡張して、AI エージェントがインタラクティブな UI コンポーネントを返すことを可能にする仕組みです。これにより、AI エージェントとのチャットの返答としてグラフや画像ギャラリー、購入フォームなどを表示できます。この記事では MCP UI の SDK を利用して、AI エージェントがインタラクティブな UI コンポーネントを返す方法を試してみます。

MCP UIModel Context Protocol (MCP) を拡張して、AI エージェントがインタラクティブな UI コンポーネントを返すことを可能にする仕組みです。MCP UI を使用することで、AI エージェントとのチャットの返答としてグラフを表示したり、商品の画像ギャラリーや購入フォームを表示することが可能になります。従来のテキストベースの応答に加えて、ユーザーは AI エージェントとの対話をよりリッチでインタラクティブなものにできます。

この記事では MCP UI の SDK を利用して、AI エージェントがインタラクティブな UI コンポーネントを返す方法を試してみます。この記事で書いたコードのサンプルは以下のリポジトリで公開しています。

TypeScript SDK を使用して MCP UI を実装する

MCP UI では TypeScript と Ruby の SDK が提供されています。ここでは TypeScript SDK を使用します。サーバー向けの SDK とブラウザ向けの SDK がそれぞれ提供されています。

  • @mcp-ui/server - npm: MCP の Resource を実装するためのヘルパー関数を提供する
  • @mcp-ui/client - npm: インタラクティブな UI コンポーネントを提供する. React コンポーネントと Web コンポーネントの両方が提供される。

まずはサーバー側の実装から始めましょう。MCP サーバーの実装として Cloudflare が提供する agents パッケージを使用します。agents パッケージは Streamable HTTP を使用したリモート MCP サーバーの実装を簡単に行うことができます。

Cloudflare Workers のプロジェクトを作成しましょう。

npm create cloudflare@latest my-mcp-ui-server

続いて以下のパッケージをインストールします。

npm install agents @modelcontextprotocol/sdk zod @mcp-ui/server

MCP ツールを実装する

MCP UI では MCP の Resource としてインタラクティブな UI コンポーネントを提供します。Resource は Resource Link もしくは Embedded Resource としてツールの応答に含めることができます。

agents パッケージでは McpAgent クラスを継承して MCP サーバーを実装します。init メソッド内で this.server.tool() を呼び出すことで MCP ツールを定義できます。以下のコードはサイコロの目を振るツールを実装した例です。

src/index.ts
import { McpAgent } from 'agents/mcp';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { createUIResource } from '@mcp-ui/server';
 
export class MyMCP extends McpAgent {
	server = new McpServer({
		name: 'MyMCP Server',
		version: '0.1.0',
	});
 
	async init() {
		this.server.tool(
			// ツールの名前
			'dice_roll',
			// ツールの説明
			'サイコロを降った結果を返します',
			// ツールの引数のスキーマ
			{ sides: z.number().min(1).max(100).default(6).describe('サイコロの面の数') },
			// ツールの実行関数
			async ({ sides }) => {
				// サイコロを振る
				const result = Math.floor(Math.random() * sides) + 1;
				// サイコロの目を元に UI コンポーネントを作成する
				const resourceBlock = createUIResource({
          // URI スキーマ
					uri: `ui://dice_roll/${result}`,
          // HTML 文字列もしくはリモートの URL を指定
					content: {
            // rawHtml | externalUrl | remoteDom
						type: 'rawHtml',
						htmlString: `
            <div>
              <p style="color: ${result === 1 ? 'red' : 'black'}; font-size: 24px;">サイコロの目: ${result}</p>
            </div>`,
					},
          // text | blob
					encoding: 'text',
				});
 
				return {
					content: [resourceBlock],
				};
			}
		);
	}
}

MCP の Resource を作成するために @mcp-ui/server パッケージの createUIResource 関数を使用します。この関数は以下の引数を受け取ります。

  • uri: 一意な Resource の URI。ui:// スキーマを使用する。クライアント側の実装ではスキーマが ui:// で始まるかどうかを確認して、MCP UI として Resource を検出する
  • content: Resource の内容。HTML 文字列もしくはリモートの URL を指定する
    • type: rawHtmlexternalUrlremoteDom のいずれかを指定。rawHtml は HTML 文字列を直接指定する。externalUrl は iframe の URL を指定する。remoteDom は React もしくは Web コンポーネントでレンダリングされる script を指定する
  • encoding: text もしくは blob を指定

作成した Resource は { content: [resourceBlock] } のようにツールの応答に含めます。これでサイコロの目に応じて異なる色のテキストを表示する UI コンポーネントが作成されます。

作成した MyMCP クラスを Cloudflare Workers のエントリポイントである fetch ハンドラで使用します。

src/index.ts
export default {
	fetch(request: Request, env: Env, ctx: ExecutionContext) {
  // CORS ヘッダーを設定
		if (request.method === 'OPTIONS') {
			return new Response(null, {
				headers: {
					'Access-Control-Allow-Origin': '*',
					'Access-Control-Allow-Methods': 'GET,HEAD,OPTIONS',
					'Access-Control-Allow-Headers': '*',
				},
			});
		}
		const url = new URL(request.url);
 
		// /sse エンドポイントの場合は SSE で応答する
		if (url.pathname === '/sse' || url.pathname === '/sse/message') {
			// @ts-ignore
			return MyMCP.serveSSE('/sse').fetch(request, env, ctx);
		}
 
		// /mcp エンドポイントの場合は Streamable HTTP で応答する
		if (url.pathname === '/mcp') {
			// @ts-ignore
			return MyMCP.serve('/mcp').fetch(request, env, ctx);
		}
 
		return new Response('Not found', { status: 404 });
	},
};

最後に Durable Objects を使用するために wrangler.jsonc を編集します。McpAgent クラスを使用する場合には、MCP_OBJECT という名前を指定する必要があります。

wrangler.jsonc
{
  "migrations": [
		{
			"new_sqlite_classes": [
				"MyMCP"
			],
			"tag": "v1"
		}
	],
	"durable_objects": {
		"bindings": [
			{
				"class_name": "MyMCP",
				"name": "MCP_OBJECT"
			}
		]
	},
}

MCP サーバーをテストする

以下のコマンドで Cloudflare Workers のローカルサーバーを起動します。

npm run dev

正しく MCP サーバーを構築できているか確認するために MCP Inspector を使用しましょう。これは GUI ベースで MCP サーバーのデバッグを行うためのツールです。

npx @modelcontextprotocol/inspector

http://127.0.0.1:6274 にアクセスして MCP Inspector を開きます。「Transport Type」で「Streamable HTTP」を選択し、URL 欄に http://localhost:8787/mcp を入力して「Connect」ボタンをクリックします。「List Tools」ボタンをクリックすると、実装した dice_roll ツールが表示されます。「Run Tool」ボタンをクリックすると、ツールを実行できます。結果が表示されていることを確認してください。

ツールの結果として以下の JSON が返されます。

{
  "uri": "ui://dice_roll/6",
  "mimeType": "text/html",
  "text": "<p style=\"color: black; font-size: 24px;\">サイコロの目: 6</p>"
}

MCP UI のクライアントを実装する

続いてクライアント側を実装します。MCP UI のクライアントは React コンポーネントと Web コンポーネントの両方が提供されています。メインのコンポーネントは <UIResourceRenderer /> です。これは MCP サーバーからの応答を受け取り、リソースの種類を検出して適切なコンポーネントをレンダリングします。サーバーから受け取った HTML とスクリプトはサンドボックス化された iframe 内で実行されるため、セキュリティ上の問題を回避できます。

ここでは React コンポーネントを使用します。React アプリケーションを作成しましょう。

npm create vite@latest my-mcp-ui-client -- --template react-ts

以下のパッケージをインストールします。

npm install @mcp-ui/client @modelcontextprotocol/sdk

src/App.tsx を以下のように編集します。

src/App.tsx
import React, { useState } from "react";
import { UIResourceRenderer } from "@mcp-ui/client";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type {
  ContentBlock,
  Resource,
} from "@modelcontextprotocol/sdk/types.js";
 
// MCP Client を使用してツールを呼び出す関数
const fetchMcpResource = async (toolName: string): Promise<ContentBlock> => {
  const client = new Client({
    name: "streamable-http-client",
    version: "1.0.0",
  });
 
  // Streamable HTTP を使用して接続
  const transport = new StreamableHTTPClientTransport(
    new URL("http://localhost:8787/mcp")
  );
  await client.connect(transport);
  let result;
  // ツール名に応じて呼び出す
  // server で実装した dice_roll ツールを呼び出す
  if (toolName === "dice_roll") {
    result = await client.callTool({
      name: toolName,
      arguments: {
        sides: 6,
      },
    });
  } else {
    throw new Error(`Unknown tool: ${toolName}`);
  }
 
  return (result?.content as ContentBlock[])[0];
};
 
const App: React.FC = () => {
  const [uiResource, setUIResource] = useState<Resource | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
 
  const loadResource = async (toolName: string) => {
    setLoading(true);
    setError(null);
    setUIResource(null);
    try {
      const block = await fetchMcpResource(toolName);
      setUIResource(block.resource as Resource);
    } catch (e: any) {
      setError(e.message);
    }
    setLoading(false);
  };
 
  return (
    <div>
      <h1>MCP-UI Client Demo</h1>
      <button onClick={() => loadResource("dice_roll")}>Dice Roll</button>
 
      {loading && <p>Loading resource...</p>}
      {error && <p style={{ color: "red" }}>Error: {error}</p>}
 
      {uiResource && (
        <div style={{ marginTop: 20, border: "2px solid blue", padding: 10 }}>
          <h2>Rendering Resource: {uiResource.uri}</h2>
          <UIResourceRenderer
            resource={uiResource}
            onUIAction={async (result) => {
              alert("UI Action Result:", result);
            }}
          />
        </div>
      )}
    </div>
  );
};
 
export default App;

ここでは AI エージェントとのやり取りは省略して fetchMcpResource 関数を使用して MCP サーバーから直接ツールを呼び出しています。client.callTool メソッドを使用して、先ほどサーバー側で実装した dice_roll ツールを呼び出します。ツールの結果として返される Resource を UIResourceRenderer コンポーネントに渡してレンダリングします。

<UIResourceRenderer resource={uiResource} />

UIResourceRenderer コンポーネントはリソースのタイプに応じて HTMLResourceRenderer もしくは RemoteDOMResourceRenderer を出し分けてレンダリングします。

リソースのタイプの検出は以下のように行われます。

  1. resource.contentType が明示的に指定されている場合、そのタイプに応じたレンダラーを使用
  2. MIME タイプに基づいて選択
  • text/html: rawHtml
  • text/uri-list: externalUrl
  • application/vnd.mcp-ui.remote-dom+javascript: remoteDom
  1. サポートされていないリソースタイプの場合はエラーを表示

supportedContentTypes Props を使用して特定のリソースタイプのみに制限することも可能です。

<UIResourceRenderer
  resource={uiResource}
  supportedContentTypes={["rawHtml"]}
/>

実際にブラウザでアプリケーションを起動して、ボタンをクリックするとサイコロの目が表示されるはずです。

npm run dev

インタラクティブな UI アクションを処理する

リソースのコンテンツとして script を含めることで、インタラクティブな UI コンポーネントを実装できます。クライアントとやり取りするために、window.parent.postMessage を使用してメッセージを送信します。

ここではリソースタイプとして remoteDom を使用します。これは React コンポーネントや Web コンポーネントを使用してレンダリングされるスクリプトを指定するためのタイプです。

MCP UI のサーバー側の実装でインタラクティブなボタン要素を返す action_button ツールを追加してみましょう。

src/index.ts
export class MyMCP extends McpAgent {
  init() {
    // 既存のツール定義...
 
    this.server.tool(
      'action_button',
      'インタラクティブなボタンを返します',
      {},
      async () => {
        const resourceBlock = createUIResource({
          uri: 'ui://action_button',
          content: {
            type: 'remoteDom',
            script: `
              // ui-button は MCP UI のクライアントが提供するカスタム要素
              const button = document.createElement('ui-button');
              button.textContent = 'Click Me!';
              button.addEventListener('press', () => {
                // ボタンがクリックされたときのアクション
                window.parent.postMessage({ 
                  type: 'tool', 
                  payload: { 
                    toolName: 'action_from_button',
                    params: {
                      data: 'Button clicked!',
                      timestamp: ${Date.now()}
                    },
                  }
                }, '*');
              });
 
              root.appendChild(button);
            `,
            framework: 'react',
          },
          encoding: 'text',
        });
 
        return {
          content: [resourceBlock],
        };
      }
    );
  }
}

この script では、ui-button というカスタム要素を作成し、クリックイベント(ここでは press)を購読しています。ui-button は MCP UI のクライアントが提供する basicComponentLibrary の一部です。ボタンがクリックされると、親ウィンドウに postMessage を使用してメッセージを送信します。このメッセージには typepayload が含まれています。type は以下の値を指定できます。

  • tool
  • prompt
  • link
  • intent
  • notify

クライアントの実装では、UIResourceRenderer コンポーネントの onUIAction プロパティを使用して、ボタンがクリックされたときのアクションを処理します。また remoteDom タイプのリソースをレンダリングする方法を指定する remoteDomProps.libraryremoteDomProps.remoteElements を設定する必要があります。ここでは @mcp-ui/client パッケージが提供する basicComponentLibrary を使用します。

src/App.tsx
import {
  basicComponentLibrary,
  remoteButtonDefinition,
  remoteTextDefinition,
  UIResourceRenderer,
  type UIActionResult,
} from "@mcp-ui/client";
 
const fetchMcpResource = async (toolName: string): Promise<ContentBlock> => {
  // 省略...
 if (toolName === "dice_roll") {
    result = await client.callTool({
      name: toolName,
      arguments: {
        sides: 6,
      },
    });
  } else if (toolName === "action_button") {
    result = await client.callTool({
      name: toolName,
      arguments: {
        label: "Click Me!",
        action: {
          type: "tool",
          toolName: "dice_roll",
          params: {},
        },
      },
    });
  } else {
    throw new Error(`Unknown tool: ${toolName}`);
  }
 
  return (result?.content as ContentBlock[])[0];
};
 
const App: React.FC = () => {
  // 省略...
  const handleGenericMcpAction = async (result: UIActionResult) => {
    if (result.type === "tool") {
      alert(
        `Action received in host app - Tool: ${result.payload.toolName}, Params: ${result.payload.params.data}, Timestamp: ${result.payload.params.timestamp}`
      );
    } else if (result.type === "prompt") {
      alert(`Prompt received in host app: ${result.payload.prompt}`);
    } else if (result.type === "link") {
      alert(`Link received in host app: ${result.payload.url}`);
    } else if (result.type === "intent") {
      alert(`Intent received in host app: ${result.payload.intent}`);
    } else if (result.type === "notify") {
      alert(`Notification received in host app: ${result.payload.message}`);
    }
    return {
      status: "Action handled by host application",
    };
  };
 
  return (
    <div>
      {/* 省略 */}
      <button onClick={() => loadResource("action_button")}>Action Button</button>
 
      <UIResourceRenderer
        resource={uiResource}
        onUIAction={handleGenericMcpAction}
        remoteDomProps={{
          library: basicComponentLibrary,
          remoteElements: [remoteButtonDefinition, remoteTextDefinition],
        }}
      />
    </div>
  );
};

ブラウザで「Action Button」ボタンをクリックすると、「Click Me!」というボタンが表示されます。このボタンをクリックすると、handleGenericMcpAction 関数が呼び出され、アラートが表示されます。

非同期でメッセージをやり取りする

messageId フィールドを使用することで UI リソースとクライアントの双方向のメッセージングを非同期で行うことができます。サーバー側の実装では window.addEventListener('message', (event) => { ... }) を使用してクライアントからのメッセージを受信します。

src/index.ts
this.server.tool('async_message_test', '非同期メッセージのテスト', {}, async () => {
  const resourceBlock = createUIResource({
    uri: 'ui://async_message_test',
    content: {
      type: 'rawHtml',
      htmlString: `
      <p id="status">メッセージを送信してください</p>
      <button id="send-message">Send Message</button>
      <script>
        // 待機中のメッセージを格納する Map
        const pendingMessages = new Map();
 
        const statusElement = document.getElementById('status');
        const sendButton = document.getElementById('send-message');
 
        sendButton.addEventListener('click', () => {
          const messageId = Math.random().toString(36).substring(2, 15);
 
          pendingMessages.set(messageId, 'sending');
          statusElement.textContent = 'Sending message...';
 
          window.parent.postMessage(
            {
              type: 'tool',
              messageId,
              payload: {
                toolName: 'processData',
                params: {
                  data: 'Hello from MCP UI!',
                  timestamp: ${Date.now()},
                },
              },
            },
            '*'
          );
        });
 
        window.addEventListener('message', (event) => {
          const message = event.data;
          if (!message.messageId || !pendingMessages.has(message.messageId)) {
            return;
          }
 
          switch (message.type) {
            case 'ui-message-received':
              statusElement.textContent = 'Message received';
              pendingMessages.set(message.messageId, 'pending');
              break;
            case 'ui-message-response':
              if (message.payload.error) {
                statusElement.textContent = 'Error: ' + message.payload.error;
                pendingMessages.delete(message.messageId);
                return;
              }
              statusElement.textContent = 'Message response: ' + message.payload.response.processedData;
              pendingMessages.delete(message.messageId);
              break;
          }
        });
      </script>
      `,
    },
    encoding: 'text',
  });
  return {
    content: [resourceBlock],
  };
});

ボタンがクリックされた時に postMessage でメッセージを送信する際に、messageId を生成して送信します。クライアント側では messageId を使用してメッセージの状態を管理します。サーバー側では messageId を使用して応答を返すことができます。この messageId はメッセージの競合を避けるために一意である必要があります。生成した messageIdpendingMessages という Map に格納され、メッセージの状態を追跡します。

const messageId = Math.random().toString(36).substring(2, 15);
 
pendingMessages.set(messageId, 'sending');
 
window.parent.postMessage(
  {
    type: 'tool',
    messageId,
    payload: {
      toolName: 'processData',
      params: {
        data: 'Hello from MCP UI!',
        timestamp: Date.now(),
      },
    },
  },
  '*'
);

クライアントからの応答メッセージを window.addEventListener('message', (event) => { ... }) で受信します。始めに event.data.messageId を確認して、pendingMessages に存在するかどうかをチェックします。存在する場合はメッセージの状態を更新し、応答を処理します。メッセージの種類には以下のものがあります。

  • 'ui-message-received'
  • ui-message-response

クライアント側の実装は大きく変更はありません。onUIAction ハンドラで返したオブジェクトの値が message.payload.response として送信されます。メッセージに messageId を含めたり、メッセージタイプの指定はクライアントのライブラリによって処理されます。

src/App.tsx
  const handleGenericMcpAction = async (result: UIActionResult) => {
    if (result.type === "tool") {
      if (result.payload.toolName === "processData") {
        // 人工的な遅延を追加
        await new Promise((resolve) => setTimeout(resolve, 1000));
        return {
          status: "success",
          processedData: `Processed: ${result.payload.params.data}`,
          timestamp: new Date().toISOString(),
        };
      }
 
      alert(
        `Action received in host app - Tool: ${result.payload.toolName}, Params: ${result.payload.params.data}, Timestamp: ${result.payload.params.timestamp}`
      );
      
    }
    // 他のアクションタイプの処理...
    return {
      status: "Action handled by host application",
    };
  };

ブラウザで実行してみると、ボタンをクリックした後に「Sending message...」と表示され、1 秒後に「Message response: Processed: Hello from MCP UI!」と表示されることが確認できます。

外部の URL を使用する

リソースタイプとして externalUrl を使用することで、iframe 内に外部の URL を表示することも可能です。記事の slug を引数に受け取り、該当の記事を表示するツールを実装してみましょう。

src/index.ts
this.server.tool(
  'show_article',
  '指定された記事を表示します',
  { slug: z.string().describe('記事のスラッグ') },
  async ({ slug }) => {
    const resourceBlock = createUIResource({
      uri: `ui://article/${slug}`,
      content: {
        type: 'externalUrl',
        iframeUrl: `https://azukiazusa.dev/blog/${slug}`,
      },
      encoding: 'text',
    });
 
    return {
      content: [resourceBlock],
    };
  }
);

クライアント側では、show_article ツールを呼び出して記事を表示するボタンを追加します。

src/App.tsx
const fetchMcpResource = async (toolName: string): Promise<ContentBlock> => {
  // 省略...
  if (toolName === "show_article") {
    result = await client.callTool({
      name: toolName,
      arguments: {
        slug: "serena-coding-agent",
      },
    });
  } else {
    throw new Error(`Unknown tool: ${toolName}`);
  }
 
  return (result?.content as ContentBlock[])[0];
};
 
const App: React.FC = () => {
  // 省略...
  return (
    <div>
      {/* 省略 */}
      <button onClick={() => loadResource("show_article")}>Show Article</button>
 
      {uiResource && (
        <div style={{ marginTop: 20, border: "2px solid blue", padding: 10 }}>
          <h2>Rendering Resource: {uiResource.uri}</h2>
          <UIResourceRenderer
            resource={uiResource}
            onUIAction={handleGenericMcpAction}
            remoteDomProps={{
              library: basicComponentLibrary,
              remoteElements: [remoteButtonDefinition, remoteTextDefinition],
            }}
          />
        </div>
      )}
    </div>
  );
};

ブラウザで「Show Article」ボタンをクリックすると、指定した記事が iframe 内に表示されます。

まとめ

  • MCP UI は Model Context Protocol (MCP) を拡張して、AI エージェントがインタラクティブな UI コンポーネントを返すことを可能にする
  • MCP UI のサーバー側の実装では @mcp-ui/server パッケージを使用して MCP の Resource を定義する。作成された Resource は MCP のツールの応答として返される
  • Resource は ui:// スキーマを使用して識別され、HTML 文字列やリモートの URL を指定できる
  • Resource の内容は rawHtmlexternalUrlremoteDom のいずれかのタイプで指定される
  • MCP UI のクライアント側の実装では @mcp-ui/client パッケージを使用して Resource をレンダリングする。UIResourceRenderer コンポーネントを使用して、MCP サーバーからの応答をレンダリングする
  • インタラクティブな UI コンポーネントを作成するために window.parent.postMessage を使用してクライアントとサーバー間でメッセージをやり取りする
  • postMessage のパラメータには messageId を含めることで、非同期のメッセージングを実現する。クライアントからの応答は window.addEventListener('message', (event) => { ... }) で受信する
  • externalUrl タイプを使用することで、iframe 内に外部の URL を表示することも可能

参考

記事の理解度チェック

以下の問題に答えて、記事の理解を深めましょう。

MCP UI で Resource を作成する際に使用する URI スキーマはどれですか?

  • ui://

    正解!

    MCP UI では ui:// スキーマを使用して Resource を識別します。クライアント側の実装ではこのスキーマで MCP UI として検出されます。

  • blob://

    もう一度考えてみましょう

  • resource://

    もう一度考えてみましょう

  • https://

    もう一度考えてみましょう

MCP UI の Resource のコンテンツタイプで、React や Web コンポーネントでレンダリングされるスクリプトを指定するタイプはどれですか?

  • remoteDom

    正解!

    remoteDom タイプは React もしくは Web コンポーネントでレンダリングされるスクリプトを指定する際に使用されます。

  • rawHtml

    もう一度考えてみましょう

    rawHtml は HTML 文字列を直接指定する際に使用されるタイプです。

  • externalUrl

    もう一度考えてみましょう

    externalUrl は iframe の URL を指定する際に使用されるタイプです。

  • component

    もう一度考えてみましょう

    component というタイプは MCP UI には存在しません。