サンプル動画
はじめに
スキル
ソースコード
対象読者
MCP Appsの仕組み
アーキテクチャ概要
サーバー側
クライアント側(UI)
実装手順
プロジェクトセットアップ
サーバー実装
UI実装
ハマりポイント
inputSchemaにJSON Schemaを渡してしまう
Express 5でのボディストリーム消費問題
Claude Desktopでurl形式が使えない
zod v4の依存関係
ローカルで試す手順
Step 1: セットアップとビルド
依存関係のインストール
UIをビルドしてサーバーを起動
Step 2: curlで動作確認
initializeリクエスト
Step 3: Claude Desktopに接続
まとめ
AI活用・MCP開発のご相談
https://s3.ap-northeast-1.amazonaws.com/hanzochang.com/_v2/1770559477743_oqevmxri89e.png

MCP Apps入門 - Skill付き!AIチャット内にオリジナルUIを埋め込む

Model Context Protocol Appsを使ってAIクライアント内にHTML UIを埋め込む方法を解説します。テキストだけでは伝えきれない色やチャートのようなビジュアル情報をインタラクティブに表示する技術を学べます。

公開日2026.02.08

更新日2026.02.08

AI

サンプル動画

はじめに

Model Context Protocol (MCP) は、AIアシスタントと外部システムを連携させるための標準プロトコルです。通常のMCPサーバーはテキストベースの情報をやり取りしますが、テキストだけでは表現しきれない情報があります。

例えば、カラーパレット、データビジュアライゼーション、インタラクティブなチャートなどは、テキストで説明するよりも視覚的に表示した方が理解しやすいです。

MCP Apps は、この課題を解決する拡張機能です。AIクライアント(Claudeなど)の会話画面内に、インタラクティブなHTML UIを直接埋め込むことができます。ユーザーはAIとの対話の中で、ビジュアルなコンテンツを操作したり、情報を視覚的に理解したりできます。

この記事では、MCP Appsの仕組みから実装手順、実際に遭遇するハマりポイントまで詳しく解説します。

スキル

https://github.com/hanzochang/mcp-apps-sample/blob/main/skills/dev_init-mcp-app/SKILL.md コピペして持ってくるだけで、mcpアプリのベース構築可能です。

ソースコード

https://github.com/hanzochang/mcp-apps-sample

対象読者

  • MCPサーバーの基本的な構造を理解している方
  • TypeScript / JavaScriptでの開発経験がある方
  • Node.js環境での開発ができる方

MCP Appsの仕組み

MCP Appsは、サーバーとクライアント(UI)の双方向通信によって成り立っています。

アーキテクチャ概要

34 1

主な構成要素は以下の通りです。

サーバー側

  • registerAppTool: AIクライアントから呼び出せるツールを登録します。ツールには_meta.ui.resourceUriを指定し、UI側に結果を返すことができます。
  • registerAppResource: UIとなるHTMLファイルをリソースとして登録します。RESOURCE_MIME_TYPEを指定することで、AIクライアントがこのリソースをUI表示用として扱います。

クライアント側(UI)

  • App クラス: @modelcontextprotocol/ext-appsパッケージが提供するクラスです。connect()メソッドでサーバーに接続します。
  • callServerTool: サーバーのツールを呼び出すメソッドです。引数を渡してサーバー側の処理を実行します。
  • ontoolresult: サーバーからツール実行結果を受け取るイベントハンドラです。ここでUIを更新します。

この双方向通信により、AIの会話の流れの中でリアルタイムにUIを更新したり、ユーザーの操作をサーバーに伝えたりできます。

実装手順

プロジェクトセットアップ

必要なパッケージをインストールします。

{
  "dependencies": {
    "@modelcontextprotocol/ext-apps": "^1.0.1",
    "@modelcontextprotocol/sdk": "^1.26.0",
    "zod": "^4.3.6"
  },
  "devDependencies": {
    "vite": "^6.0.0",
    "vite-plugin-singlefile": "^2.3.0",
    "typescript": "^5.7.0"
  }
}
💡 zod v4の重要性

zodはv4系を使う必要があります。MCP Apps SDKはzod v4をpeer dependencyとして要求しており、v3では動作しません。

Viteの設定ファイルを用意します。vite-plugin-singlefileを使って、CSS・JavaScriptを含めた単一のHTMLファイルにバンドルします。

// 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')
}

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    rollupOptions: {
      input: INPUT,
    },
    outDir: 'dist',
  },
})

サーバー実装

MCPサーバーでツールとリソースを登録します。

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

const DIST_DIR = path.join(import.meta.dirname, 'dist')
const RESOURCE_URI = 'ui://sample-app/view.html'

export const createServer = (): McpServer => {
  const server = new McpServer({
    name: 'Sample App',
    version: '1.0.0',
  })

  // ツールの登録
  registerAppTool(
    server,
    'generate-data',
    {
      title: 'Generate Data',
      description: 'データを生成してUIに表示する',
      // Zodスキーマを使用(JSON Schemaではない)
      inputSchema: {
        query: z.string().describe('検索クエリ'),
        limit: z.number().default(10).describe('取得件数'),
      },
      // UIリソースを指定
      _meta: { ui: { resourceUri: RESOURCE_URI } },
    },
    async ({ query, limit }) => {
      // ツールの処理
      const result = { query, limit, items: [] }
      return {
        content: [{ type: 'text', text: JSON.stringify(result) }],
      }
    },
  )

  // UIリソースの登録
  registerAppResource(
    server,
    RESOURCE_URI,
    RESOURCE_URI,
    { mimeType: RESOURCE_MIME_TYPE },
    async (): Promise<ReadResourceResult> => {
      const html = await fs.promises.readFile(path.join(DIST_DIR, 'mcp-app.html'), 'utf-8')
      return {
        contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
      }
    },
  )

  return server
}
⚠️ inputSchemaはZodスキーマを使う

registerAppToolinputSchemaには、JSON SchemaではなくZodスキーマを渡す必要があります。JSON Schema形式を渡すとv3Schema.safeParseAsync is not a functionというエラーが発生します。これはMCP Apps SDKがZodを前提としているためです。

エントリーポイントとなるmain.tsを作成します。Claude Desktopはstdio接続を使うため、--stdioフラグによるモード切り替えを実装しておくと便利です。

// main.ts
import http from 'node:http'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { createServer } from './server.js'

// Claude Desktop等のstdioクライアント用
const startStdioServer = async () => {
  const server = createServer()
  const transport = new StdioServerTransport()
  await server.connect(transport)
}

// HTTP経由での接続用(curlテストやWebクライアント向け)
const startHttpServer = () => {
  const httpServer = http.createServer(async (req, res) => {
    res.setHeader('Access-Control-Allow-Origin', '*')
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id')

    if (req.method === 'OPTIONS') {
      res.writeHead(204)
      res.end()
      return
    }

    if (req.url !== '/mcp') {
      res.writeHead(404)
      res.end()
      return
    }

    const server = createServer()
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
    res.on('close', async () => {
      await transport.close()
      await server.close()
    })
    await server.connect(transport)
    await transport.handleRequest(req, res)
  })

  const port = parseInt(process.env.PORT ?? '3001', 10)
  httpServer.listen(port, () => {
    console.log(`MCP server listening on http://localhost:${port}/mcp`)
  })
}

// --stdio フラグでモード切り替え
if (process.argv.includes('--stdio')) {
  startStdioServer()
} else {
  startHttpServer()
}
💡 2つのトランスポート
  • stdio: Claude Desktopなど、子プロセスとして起動するクライアント向け。--stdioフラグで起動します
  • Streamable HTTP: curlでのテストやWebベースのクライアント向け。フラグなしで起動します
⚠️ Express 5での注意点

Express 5を使う場合、リクエストボディのストリーム消費に問題が発生することがあります。transport.handleRequestにボディを渡す前にストリームが消費されてしまうためです。Node.jsの標準http.createServerを使うか、Expressを使う場合はボディを適切に処理する必要があります。

UI実装

クライアント側のUI部分を実装します。

// src/mcp-app.ts
import { App } from '@modelcontextprotocol/ext-apps'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'

const app = new App({ name: 'Sample App', version: '1.0.0' })

// ツール実行結果を受け取る
app.ontoolresult = (result: CallToolResult) => {
  const textContent = result.content?.find((c: any) => c.type === 'text')
  if (textContent && 'text' in textContent) {
    try {
      const data = JSON.parse(textContent.text)
      // UIを更新する処理
      updateUI(data)
    } catch (error) {
      console.error('Failed to parse result:', error)
    }
  }
}

// サーバーツールを呼び出す
const fetchData = async (query: string) => {
  try {
    const result: CallToolResult = await app.callServerTool({
      name: 'generate-data',
      arguments: { query, limit: 10 },
    })
    // ontoolresultで結果を受け取る
  } catch (error) {
    console.error('Failed to call server tool:', error)
  }
}

// UIを構築
const buildUI = () => {
  const appEl = document.getElementById('app')!
  const button = document.createElement('button')
  button.textContent = 'データを取得'
  button.addEventListener('click', () => {
    fetchData('sample query')
  })
  appEl.appendChild(button)
}

// 初期化
buildUI()
app.connect().then(() => {
  console.log('Connected to MCP server')
})

const updateUI = (data: any) => {
  // データを受け取ってUIを更新
  console.log('Received data:', data)
}

HTMLエントリーポイントを作成します。

<!-- mcp-app.html -->
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Sample App</title>
    <link rel="stylesheet" href="/src/global.css" />
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/mcp-app.ts"></script>
  </body>
</html>

ビルドスクリプトを追加します。

{
  "scripts": {
    "build:ui": "cross-env INPUT=mcp-app.html vite build",
    "serve": "tsx main.ts",
    "start": "pnpm build:ui && pnpm serve"
  }
}

ハマりポイント

実際の開発で遭遇したハマりポイントと対処法を紹介します。

inputSchemaにJSON Schemaを渡してしまう

registerAppToolinputSchemaには、以下のようなJSON Schema形式を渡したくなります。

// これはエラーになる
inputSchema: {
  type: 'object',
  properties: {
    query: { type: 'string', description: '検索クエリ' },
  },
  required: ['query'],
}

しかし、このコードは以下のエラーを引き起こします。

v3Schema.safeParseAsync is not a function

これは、MCP Apps SDKがZodスキーマを前提としているためです。正しくはZodスキーマを使います。

// 正しい書き方
import { z } from 'zod';

inputSchema: {
  query: z.string().describe('検索クエリ'),
}

Express 5でのボディストリーム消費問題

Express 5を使ってMCPサーバーを実装すると、transport.handleRequestが正しく動作しないことがあります。これはExpressがリクエストボディのストリームを消費してしまい、handleRequestに渡される時点でストリームが空になっているためです。

対処法は2つあります。

対処法1: Node.js標準のhttpを使う

import http from 'node:http'

const httpServer = http.createServer(async (req, res) => {
  // 直接リクエストとレスポンスを扱う
  await transport.handleRequest(req, res)
})

対処法2: Expressでボディを適切に扱う

Expressを使う場合は、ボディを読み取った後に再度ストリームとして提供する必要があります。ただし、これは複雑になるため、Node.js標準のhttpを使う方が簡単です。

Claude Desktopでurl形式が使えない

Claude Desktopの設定で、以下のようにurlフィールドを使いたくなります。

{
  "mcpServers": {
    "my-app": {
      "url": "http://localhost:3001/mcp"
    }
  }
}

しかし、Claude Desktopのバージョンによってはcommandフィールドが必須で、urlだけでは「command: Required」というバリデーションエラーが発生します。

stdioトランスポートを実装し、command + args形式で設定する必要があります。

{
  "mcpServers": {
    "my-app": {
      "command": "npx",
      "args": ["tsx", "/path/to/main.ts", "--stdio"]
    }
  }
}

zod v4の依存関係

MCP Apps SDKはzod v4をpeer dependencyとして要求します。zodのバージョンがv3以下の場合、型エラーやランタイムエラーが発生します。

pnpm add zod@^4.3.6

必ずzod v4以降をインストールしてください。

ローカルで試す手順

Step 1: セットアップとビルド

# 依存関係のインストール
pnpm install

# UIをビルドしてサーバーを起動
pnpm start

pnpm startは以下の2ステップを実行します。

  1. vite build で mcp-app.html を単一ファイルに変換し dist/ に出力
  2. tsx main.ts でMCPサーバーを起動

Step 2: curlで動作確認

サーバーがhttp://localhost:3001/mcpで起動したら、別のターミナルからcurlでテストできます。

# initializeリクエスト
curl -X POST http://localhost:3001/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-03-26",
      "capabilities": {},
      "clientInfo": { "name": "test", "version": "1.0.0" }
    }
  }'

正常に動作していれば、サーバー情報を含むSSEレスポンスが返ってきます。

⚠️ Acceptヘッダーが必須

curlでテストする際はAccept: application/json, text/event-streamヘッダーが必要です。これがないとNot Acceptableエラーが返されます。

Step 3: Claude Desktopに接続

Claude Desktopから使うには、設定ファイルにMCPサーバーを追加します。

macOSの場合、~/Library/Application Support/Claude/claude_desktop_config.jsonを編集します。

{
  "mcpServers": {
    "my-mcp-app": {
      "command": "npx",
      "args": ["tsx", "/path/to/your/project/main.ts", "--stdio"]
    }
  }
}

commandにはnpxの絶対パス(which npxで確認)、argsにはtsx・main.tsの絶対パス・--stdioフラグを指定します。

⚠️ commandフィールドは必須

Claude Desktopはurl形式(HTTP接続)に対応していないバージョンがあります。その場合、command + argsによるstdio接続を使う必要があります。urlフィールドだけを指定すると「command: Required」というバリデーションエラーが発生します。

設定を保存したら、Claude Desktopを再起動してください。ツールが認識されると、「カラーパレットを生成して」のような指示でツールが呼び出されます。

画像生成プロンプト: Claude Desktopのチャット画面。ユーザーが「カラーパレットを生成して」と入力し、AIが応答している。AIの応答の中にパレットタイプの選択肢(補色、類似色、トライアドなど)が表示されている。ダークテーマのデスクトップアプリケーション。macOSのウィンドウフレーム付き。

まとめ

MCP Appsを使うことで、AIクライアント内にインタラクティブなHTML UIを埋め込むことができます。テキストだけでは伝えきれない色やグラフ、複雑なデータ構造を視覚的に表現できるため、ユーザー体験が向上します。

実装のポイントをまとめると以下の通りです。

  • サーバー側ではregisterAppToolregisterAppResourceを使ってツールとUIを登録する
  • inputSchemaには必ずZodスキーマを使う(JSON Schemaではない)
  • クライアント側ではAppクラスのconnectcallServerToolontoolresultを使って双方向通信を行う
  • Viteとvite-plugin-singlefileで単一のHTMLファイルにバンドルする
  • Node.js標準のhttpを使うことでExpress 5のボディストリーム問題を回避できる
  • Claude Desktopにはstdioトランスポートで接続する(--stdioフラグ)
  • zod v4をインストールすることが必須

MCP Appsにより、テキストだけでは表現しきれなかった色やグラフ、地図といったビジュアル情報をAIの会話の中で扱えるようになります。

AI活用・MCP開発のご相談

hanzochangでは、MCP AppsをはじめとするAI技術の業務導入を支援しています。「社内ツールにAIを組み込みたい」「MCPサーバーを開発したい」といったご要望がありましたら、お気軽にお問い合わせください。

picture
hanzochang - 半澤勇大
慶應義塾大学卒業後、Webプランナーとして勤務。 ナショナルクライアントのキャンペーンサイトの企画・演出を担当。 その後開発会社に創業メンバーとして参加。 Fintech案件や大手企業のDXプロジェクトに関わり、その後個人事業主として独立し、 2023年にWeb3に特化した開発会社として法人化しました。 現在はWeb3アプリ開発を中心にAI開発フローの整備を行っています。
また、趣味で2017年ごろより匿名アカウントでCryptoの調査等を行い、 ブロックチェーンメディアやSNSでビットコイン論文等の図解等を発信していました。
X (Twitter)

お問い合わせはこちらから

内容によっては返信ができない場合や、お時間をいただく場合がございます。あらかじめご了承ください。