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?

生成AIのチャット画面に独自UIを表示できるMCP Appsを作成しVS Codeのチャットに自作UIを表示する

Last updated at Posted at 2026-01-31

MCP Apps とは

MCP AppsはClaudeやChatGPTの様な生成AIのチャット画面に、Webページの様なインタラクティブなUIを提供する技術です。

今までもOpenAI Apps SDKの様に、LLMベンダーのアプリに特化したものはありましたが、それがOpen AIに限らず標準化された仕様となっています。

今回は公式のQuick Startを参考にMCP Appsを作成しVS CodeのCopilot Chatで呼び出してみます。

実行した時のイメージは以下の通り。

mcp-apps-sample.gif

実装したソースコード

公式のQuick Start

プロジェクト設定

はじめに必要ライブラリのインストールと設定ファイルを作成。

mkdir sample-mcp-app && cd sample-mcp-app

依存パッケージをインストール

npm init -y
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors
npm install -D typescript vite vite-plugin-singlefile @types/express @types/cors @types/node tsx concurrently cross-env

packaget.jsonに設定を追加

npm pkg set type=module
npm pkg set scripts.build="tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build"
npm pkg set scripts.start="concurrently 'cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch' 'tsx watch main.ts'"

tsconfig.jsonを作成します。

./tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src", "server.ts", "main.ts"]
}

サーバー側の設定としてtsconfig.server.jsonを作成

./tsconfig.server.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "./dist",
    "rootDir": ".",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["server.ts", "main.ts"]
}

最後にviteの設定を行う。

MCP AppsではUIを1つのHTMLファイルにまとめて返却する必要があるため、viteを利用して一つのHTMLファイルにする必要がある(参考

ts./vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

const INPUT = process.env.INPUT;
if (!INPUT) {
  throw new Error("INPUT environment variable is not set");
}

const isDevelopment = process.env.NODE_ENV === "development";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    sourcemap: isDevelopment ? "inline" : undefined,
    cssMinify: !isDevelopment,
    minify: !isDevelopment,

    rollupOptions: {
      input: INPUT,
    },
    outDir: "dist",
    emptyOutDir: false,
  },
});

この設定でpackage.jsonbuildstartに設定された以下のviteのビルドの際にdist/mcp-app.htmlとして出力される

INPUT=mcp-app.html vite build

MCPサーバーの開発

./server.ts
import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import fs from "node:fs/promises";
import path from "node:path";

const DIST_DIR = path.join(import.meta.dirname, "dist");

/**
 * Toolと表示するUI Resourceを登録するMCPサーバーを作成
 */
export function createServer(): McpServer {
  const server = new McpServer({
    name: "MCP App Server",
    version: "1.0.0",
  });

  // Toolに表示するUI ResourceのURIを設定。ToolとResourceの両方で同じUIを設定する
  const resourceUri = "ui://get-time/mcp-app.html";

  // UIのResourceをToolの`meta.ui.resourceUri`に設定する
  // Toolは呼び出されたとき`meta.ui.resourceUri`を参照し、UIをチャット上にレンダリングする。
  registerAppTool(
    server,
    "get-time",
    {
      title: "Get Time",
      description: "Returns the current server time.",
      inputSchema: {},
      _meta: { ui: { resourceUri } }, // Resourceの設定
    },
    async () => {
      // 日本時間を返却するTool
      const time = new Date().toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" });
      return { content: [{ type: "text", text: time }] };
    },
  );

  // 表示するUIリソースを設定。viteでビルドされた mcp-app.htmlを
  // チャット画面に表示されるResourceとして登録する
  registerAppResource(
    server,
    resourceUri,
    resourceUri,
    { mimeType: RESOURCE_MIME_TYPE },
    async () => {
      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;
}

次にMCPサーバをExpressを利用して作成する

main.ts
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";

/**
 * Expressを利用してStreamable HTTPを利用したMCPサーバーを起動する
 * リクエストごとに新しいMCP Serverインスタンスを作成する
 */
export async function startStreamableHTTPServer(
  createServer: () => McpServer,
): Promise<void> {
  const port = parseInt(process.env.PORT ?? "3001", 10);

  // MCP用のExpressのアプリを作成。MPC用にJSON-RPC用のエントリーポイントを提供する
  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({
      // SessionIdを維持することで過去の会話の状態を維持できるが、
      // 今回はシンプルなサービスでセッションを維持する必要がないのでステートレスな運用
      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(500).json({
          jsonrpc: "2.0",
          error: { code: -32603, 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 = () => {
    console.log("\nShutting down...");
    httpServer.close(() => process.exit(0));
  };

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

async function main() {
  await startStreamableHTTPServer(createServer);
}

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

以上でサーバー側の開発は完了

チャット画面表示するUIを作成

チャットUI上に表示されるHTMLファイルを作成

mcp-app.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Get Time App</title>
  </head>
  <body>
    <p>
      <strong>サーバー時間:</strong> <code id="server-time">Loading...</code>
    </p>
    <button id="get-time-btn">現在のサーバー時間を取得</button>
    <script type="module" src="/src/mcp-app.ts"></script>
  </body>
</html>

次にMCPサーバーと接続するクライアントを作成する。

src/mcp-app.ts
import { App } from "@modelcontextprotocol/ext-apps";

const serverTimeEl = document.getElementById("server-time")!;
const getTimeBtn = document.getElementById("get-time-btn")!;

// MCPサーバー側と接続するためのUIクライアントを作成する
const app = new App({ name: "Get Time App", version: "1.0.0" });

// `app.connect()`より前に設定する必要あり
// MCPサーバのツールの実行結果を受け取るイベント
// get-timeがresult.contentに現在時刻を設定して返却するので、その値を取得してserverTimeElに代入する
app.ontoolresult = (result) => {
  const time = result.content?.find((c) => c.type === "text")?.text;
  serverTimeEl.textContent = time ?? "[ERROR]";
};

// ボタンクリック時の挙動
getTimeBtn.addEventListener("click", async () => {
  // `app.callServerTool()`で指定したToolを実行する
  const result = await app.callServerTool({ name: "get-time", arguments: {} });
  const time = result.content?.find((c) => c.type === "text")?.text;
  serverTimeEl.textContent = time ?? "[ERROR]";
});

// ホストと接続
app.connect();

最終的に以下のフォルダ構成となっている。

sample-mcp-app/
├── main.ts
├── mcp-app.html
├── package.json
├── server.ts
├── src/
│   └── mcp-app.ts
├── tsconfig.json
├── tsconfig.server.json
└── vite.config.ts

ビルドを実行しエラー等が出なければOK

npm run build

最後にサーバーを起動すると、3001ポートにMCPのエンドポイントが起動する

npm start

> my-mcp-app@1.0.0 start
> concurrently 'cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch' 'tsx watch main.ts'

[0] vite v7.3.1 building client environment for production...
[0] 
[0] watching for file changes...
[0] 
[0] build started...
[0] transforming...
[1] Warning: Server is binding to 0.0.0.0 without DNS rebinding protection. Consider using the allowedHosts option to restrict allowed hosts, or use authentication to protect your server.
[1] MCP server listening on http://localhost:3001/mcp
[0] ✓ 142 modules transformed.
[0] rendering chunks...
[0] [plugin vite:singlefile] 
[0] 
[0] [plugin vite:singlefile] Inlining: mcp-app-BG8yD_ef.js
[0] computing gzip size...
[0] dist/mcp-app.html  2,127.43 kB │ gzip: 425.50 kB
[0] built in 867ms.

VS Codeから実行

記事執筆時点(2026/1/31)ではVS Codeで実行する場合は、insiders版でのみMCP Appsを利用可能です。以下のURLよりインストールしてください

VS Codeが今回作成したMCP Appsに接続できる様に設定を行う。

.vsocde/mcp
{
  "servers": {
    "my-mcp": {
      "type": "http",
      "url": "http://localhost:3001/mcp"
    }
  }
}

設定後VS Code上に「Runnning」が表示されるのでクリック

runnging-mcp.png

その後ツールの設定で作成したmy-appのMCPサーバーを選択し「OK」をクリック

select-mcp-server.png

その後チャット画面で「今何時?」と質問するとMCPサーバに接続されUIが表示される。

「現在のサーバー時間を取得」をクリックすることでUI上のサーバー時間が更新されていることが確認できる。

mcp-apps-sample.gif

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?