
MCP Apps入門 - Skill付き!AIチャット内にオリジナルUIを埋め込む
Model Context Protocol Appsを使ってAIクライアント内にHTML UIを埋め込む方法を解説します。テキストだけでは伝えきれない色やチャートのようなビジュアル情報をインタラクティブに表示する技術を学べます。
公開日2026.02.08
更新日2026.02.08
サンプル動画
はじめに
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)の双方向通信によって成り立っています。
アーキテクチャ概要

主な構成要素は以下の通りです。
サーバー側
- 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スキーマを使う
registerAppToolのinputSchemaには、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を渡してしまう
registerAppToolのinputSchemaには、以下のような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 startpnpm startは以下の2ステップを実行します。
vite buildで mcp-app.html を単一ファイルに変換しdist/に出力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を埋め込むことができます。テキストだけでは伝えきれない色やグラフ、複雑なデータ構造を視覚的に表現できるため、ユーザー体験が向上します。
実装のポイントをまとめると以下の通りです。
- サーバー側では
registerAppToolとregisterAppResourceを使ってツールとUIを登録する inputSchemaには必ずZodスキーマを使う(JSON Schemaではない)- クライアント側では
Appクラスのconnect、callServerTool、ontoolresultを使って双方向通信を行う - 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サーバーを開発したい」といったご要望がありましたら、お気軽にお問い合わせください。
