恐竜本舗

エンジニアをしている恐竜の徒然日記です。

MCP Apps でClaude 上でカラーピッカーUIを表示&色取得する

2026年1月26日、MCPの公式拡張としてMCP Appsがリリースされました。

blog.modelcontextprotocol.io

MCP Apps で何が変わる?

今まで、コミュニティでは Apps SDKMCP-UIといった、Model Context Protocol(MCP)の仕組みを拡張して、下記を実現する仕組みが話題になっていました。

  • AIエージェントとのチャット内に MCPサーバが UI を返却する
  • 対話的にUI 上での操作、アクションを AI が読み取り、次のやり取りに進む

今までは各AIプラットフォームごとに上記のような仕様が模索されていましたが、MCPの公式拡張として、 MCP Apps に標準化がなされました。

この仕様の標準化により、一度作成されたMCP アプリは、どのプラットフォーム上(Claude Desctop, VSCode Github Copilot, Goose等)でも動くことが可能になります。

AIエージェントがインタラクティブなUIを返すとどんなことができるようになる?

AIがテキストだけでなくインタラクティブなUIコンポーネントを返せるようになり、AIチャット上でリッチな体験が実現します。

  • AIが返したグラフやチャートを直接操作し指示を出す
  • AIがカンバンUIを返し、その場でタスクを編集・登録する
  • AI チャット上にフォームを出し、普段使っているAIプラットフォームから何かしらのサービスと同じUX体験を提供する

以前、MCP-UIについては下記で書きました。

blog.daitasu.work

これは「AIの中にUIを設計する」という今までのUXと一風異なる体験設計で、個人的に興味があって追ってみてます。

今回は、 MCP Apps を用いて、Claude Desktopと接続し、Claude 上にカラーピッカーを表示 & 色取得する のをやってみます。

MCP アプリを作成する

MCP アプリを早速作ってみます。

主となるパッケージは、 @modelcontextprotocol/ext-app というパッケージになります。

github.com

この中に、MCP Appsの仕様に則ったアプリを構築するためのツール群が内包されています。

example として、マップやビデオプレーヤー、ヒートマップなど様々なものが提供されており、これらを触るとMCP Apps のイメージが掴みやすいです。

全体像

まず、 main.ts でMCPサーバを立ち上げます。

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";

export const startStreamableHTTPServer = async (
  createServer: () => McpServer,
): Promise<void> => {
  const port = parseInt(process.env.PORT ?? DEFAULT_PORT, PARSE_RADIX);

  const app = createMcpExpressApp({ host: "0.0.0.0" });
  app.use(cors());

  app.all("/mcp", async (req: Request, res: Response) => {
    const server = createServer();
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
    });

    res.on("close", () => {
      transport.close().catch(() => {});
      server.close().catch(() => {});
    });

    try {
      await server.connect(transport);
      await transport.handleRequest(req, res, req.body);
    } catch (error) {
      console.error("MCP error:", error);
      if (!res.headersSent) {
        res.status(HTTP_STATUS_INTERNAL_SERVER_ERROR).json({
          jsonrpc: "2.0",
          error: {
            code: JSON_RPC_INTERNAL_ERROR_CODE,
            message: "Internal server error",
          },
          id: null,
        });
      }
    }
  });

  const httpServer = app.listen(port, (err) => {
    if (err) {
      console.error("Failed to start server:", err);
      process.exit(1);
    }
    console.log(`MCP server listening on http://localhost:${port}/mcp`);
  });

  const shutdown = (): void => {
    console.log("\nShutting down...");
    httpServer.close(() => process.exit(0));
  };

  process.on("SIGINT", shutdown);
  process.on("SIGTERM", shutdown);
};

export const startStdioServer = async (
  createServer: () => McpServer,
): Promise<void> => {
  await createServer().connect(new StdioServerTransport());
};

const main = async (): Promise<void> => {
  if (process.argv.includes("--stdio")) {
    await startStdioServer(createServer);
  } else {
    await startStreamableHTTPServer(createServer);
  }
};

main().catch((e: unknown) => {
  console.error(e);
  process.exit(1);
});

そして、createServer の中でMCP アプリの登録を行います。

import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type {
  CallToolResult,
  ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";

const DEFAULT_COLOR = "#6366f1";

const DIST_DIR = import.meta.filename.endsWith(".ts")
  ? path.join(import.meta.dirname, "dist")
  : import.meta.dirname;

export const createServer = (): McpServer => {
  const server = new McpServer({
    name: "Color Picker MCP App",
    version: "1.0.0",
  });

  const resourceUri = "ui://color-picker/mcp-app.html";

  // Register the color picker tool
  registerAppTool(
    server,
    "color-picker",
    {
      title: "Color Picker",
      description:
        "Opens an interactive color picker UI. Optionally accepts an initial color.",
      inputSchema: {
        color: z
          .string()
          .optional()
          .describe(
            `Initial color in hex format (e.g. #ff0000). Defaults to ${DEFAULT_COLOR}.`,
          ),
      },
      outputSchema: z.object({
        color: z.string(),
      }),
      _meta: { ui: { resourceUri } },
    },
    async (args): Promise<CallToolResult> => {
      const color = args.color ?? DEFAULT_COLOR;
      return {
        content: [{ type: "text", text: `Color picker opened with: ${color}` }],
        structuredContent: { color },
      };
    },
  );

  // Register the resource serving the bundled HTML UI
  registerAppResource(
    server,
    resourceUri,
    resourceUri,
    { mimeType: RESOURCE_MIME_TYPE },
    async (): Promise<ReadResourceResult> => {
      const html = await fs.readFile(
        path.join(DIST_DIR, "mcp-app.html"),
        "utf-8",
      );
      return {
        contents: [
          { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
        ],
      };
    },
  );

  return server;
}

ここで重要なのは、 @modelcontextprotocol/ext-apps から import している2つのパッケージです。

import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";

registerAppTool (MCP ツールの登録)

まず、作成されたMCPサーバに対し、 registerAppTool でMCPアプリをツールとして登録します。

inputSchema, outputSchema は Zod Schema で表現します。

通常のMCPツールの作成方法とほとんど同じですが、UI resourceを提供するためのURIが必要になります。このときのスキームは ui://xxxxxx となります。

UI コンポーネントは、 _meta.ui.resourceUriというメタ情報として登録されます。

これにより、MCP Apps に対応していないクライアントの場合は無視され、単なるテキスト応答が返されます。

最後の引数に渡すコールバック関数には、インタラクティブUI上でのアクションの結果が受け渡されます。

  const server = new McpServer({
    name: "Color Picker MCP App",
    version: "1.0.0",
  });

  const resourceUri = "ui://color-picker/mcp-app.html";

  // Register the color picker tool
  registerAppTool(
    server, // MCP サーバ
    "color-picker", // ツール名
    {
      title: "Color Picker",
      description:
        "Opens an interactive color picker UI. Optionally accepts an initial color.",
      inputSchema: {
        color: z
          .string()
          .optional()
          .describe(
            `Initial color in hex format (e.g. #ff0000). Defaults to ${DEFAULT_COLOR}.`,
          ),
      },
      outputSchema: z.object({
        color: z.string(),
      }),
      _meta: { ui: { resourceUri } }, // UIコンポーネントのリソースURI
    },
    async (args): Promise<CallToolResult> => {
      const color = args.color ?? DEFAULT_COLOR;
      return {
        content: [{ type: "text", text: `Color picker opened with: ${color}` }],
        structuredContent: { color },
      };
    },
  );

registerAppResource (UI リソースの登録)

次に、 registerAppResource です。 UI コンポーネントのリソースを登録します。

今回はReact コンポーネントとしてカラーピッカーを構築し、それを mcp-app.html の形でビルドして渡しています。

これは非常にシンプルで、ビルドされた html, js, css をresourceUri に紐づけている処理になります。

  // Register the resource serving the bundled HTML UI
  registerAppResource(
    server,
    resourceUri,
    resourceUri,
    { mimeType: RESOURCE_MIME_TYPE },
    async (): Promise<ReadResourceResult> => {
      const html = await fs.readFile(
        path.join(DIST_DIR, "mcp-app.html"),
        "utf-8",
      );
      return {
        contents: [
          { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
        ],
      };
    },
  );

クライアント側

クライアント側は、React でカラーピッカーを構築しているだけなので、ここでは概要だけ。

mcp-app.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="color-scheme" content="light dark">
  <title>Color Picker</title>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="/src/mcp-app.tsx"></script>
</body>
</html>

React で作成したコンポーネントを読み込んでいます。

mcp-app.tsx

mcp-apps-sandbox/src/mcp-app.tsx at main · daitasu/mcp-apps-sandbox · GitHub

特徴的な点で、 @modelcontextprotocol/ext-apps/react から下記を読み取っています。

import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";

useApp でアプリの情報を設定したり、MCPアプリがAI エージェントによってレンダリングされてからの一連のライフサイクルに応じた処理を記載できます。

  const { app } = useApp({
    appInfo: { name: "Color Picker", version: "1.0.0" },
    capabilities: {},
    onAppCreated: (createdApp) => {
      createdApp.ontoolinput = (params) => {
        const args = params.arguments as { color?: string } | undefined;
        if (args?.color) {
          setExternalColor(args.color);
        }
      };

      createdApp.ontoolresult = (result: CallToolResult) => {
        const structured = result.structuredContent as { color?: string } | undefined;
        if (structured?.color) {
          setExternalColor(structured.color);
        }
      };

      createdApp.onteardown = async () => ({});
      createdApp.onerror = console.error;

      createdApp.onhostcontextchanged = (ctx) => {
        if (ctx.safeAreaInsets) {
          setSafeAreaInsets(ctx.safeAreaInsets);
        }
      };
    },
  });

このように作成することで、Claude DesktopなどでMCP接続を行うと、作成したUIコンポーネントが返却され、その値を扱うことができるようになります。

Claude Desctop での設定

今回ローカルで起動したMCPアプリを、簡易的に cloudflared tunnel を使って公開し、Claude Desktopに接続します。

npx cloudflared tunnel --url http://localhost:3001

Claude Desktop の「設定」→「コネクタ」→「カスタムコネクタを追加」より、上記で表示されたURLを設定します。

これにより、Claude Desktopで今回作成したアプリが扱えるようになりました。

早速触ってみます。

Claude Desktopから、カラーピッカーのUIを呼び出し、そこで選択した色をAIが読み取ることができました!

今回は簡易なUIで実験をしました。

色々と試してみないと実際の利活用は難しそうですが、本番運用しているWEBアプリケーションの一部の体験をMCPクライアントに渡すことで、新しいUX設計を考えることができるかもしれません。

今回試しているリポジトリはこちら。

github.com