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?

MCP Appsで天気予報ダッシュボードを作ってみた記録

Posted at

はじめに

MCP App(Model Context Protocol App)は、2026年1月26日に正式リリースされた新しい標準です。Claude Desktop、ChatGPTなどの会話型AIクライアント内で、インタラクティブなUIを表示できるようになりました。

この記事では、AWS Lambda上で動作するMCP Appとして天気予報ダッシュボードを実装した記録です。
仕様の理解が追いついていない点があるので、少しずつ更新したいと思います。

開発にはKiroとAI-DLCワークフローを使用しました。詳細は別記事「AI-DLC(Kiroとawslabs/aidlc-workflows)でAI駆動開発をやってみる」を参照してください。

完成イメージ

今回は、MCP Appの作り方を確認したかったので、パブリックなリモートMCPを立ててみます。
バックエンドでは、OpenWeatherMap API(フリープラン)で天気予報のデータを取得し、そのデータにUIを加えて返すようなリモートMCPサーバにします。

「横浜の天気を教えて」と入力すると、天気ダッシュボードが表示されます。UI上で都市を変更したり、単位(°C/°F)を切り替えたりできます。
image.png

MCP Appとは

MCP Appは、MCPサーバーがツールの実行結果としてテキストだけでなく、インタラクティブUIを返せるようにする拡張仕様です。

詳細は公式ドキュメントを参照してください。

MCP Appの動作フロー

1. UIプリロード: ツール定義の_meta.ui.resourceUriからUIリソースを事前取得
2. リソース取得: サーバーからHTMLページ(JS/CSS含む)を取得
3. サンドボックス描画: ホストがiframe内でHTMLをレンダリング
4. 双方向通信: JSON-RPCプロトコルでアプリとホストが通信

詳細なシーケンス図は公式ドキュメントを参照してください。

従来のMCPツールはテキストや構造化データを返すだけでしたが、MCP AppではUIを会話内に直接表示できます。
ダッシュボード、フォーム、チャートなど、UI/UXがだいぶ変わりそうですね。

アーキテクチャ(雑)

リモートMCPサーバはAWS上に構築します。
シンプルな構成で確認したかったので、API Gateway + lambdaにしつつ、OpenWeatherMap APIのAPIキーをSecret Managerに保管してLambdaから使うようにしています。

Claude Desktop/Mobile App
    ↓ JSON-RPC over HTTPS
API Gateway (HTTP API v2)
    ↓
AWS Lambda (Node.js)
    ↓
MCP SDK + Custom Lambda Transport
    ↓
OpenWeatherMap API

AWS Lambda + API Gatewayの構成では、Streamable HTTP Transportの一部機能のみしかサポートできないと認識しています。
今回の実装は、SSEやサーバープッシュは使用できません。ただし、MCP Appの基本的なツール呼び出しとUI表示は可能です。

Streamable HTTP仕様 今回の実装(API Gateway+Lambda) 備考
HTTP POSTでJSON-RPC送信 API Gateway HTTP API v2で受信
JSONレスポンス(単一) Lambdaの戻り値として返却
SSEレスポンス(ストリーム) 実装は1リクエスト1レスポンス
セッションID管理 Mcp-Session-Idヘッダーで管理
サーバープッシュ通知(SSE) Lambdaはステートレス

技術スタック

領域 技術
Backend Node.js + TypeScript
MCP SDK @modelcontextprotocol/sdk
MCP Apps SDK @modelcontextprotocol/ext-apps
Frontend React + TypeScript
Build Vite
Infrastructure AWS Lambda + API Gateway
Deployment AWS CDK (TypeScript)

プロジェクト構成

├── src/
│   ├── index.ts              # Lambda handler
│   ├── server.ts             # MCP Server(tools, resources登録)
│   ├── transport/
│   │   └── lambda-transport.ts  # カスタムLambda Transport
│   ├── services/
│   │   └── weather-service.ts   # OpenWeatherMap API
│   └── generated/
│       └── ui-bundle.ts      # UIバンドル(自動生成)
├── ui/
│   ├── src/
│   │   ├── App.tsx           # メインコンポーネント
│   │   ├── hooks/
│   │   │   └── useWeatherApp.ts  # 状態管理
│   │   └── components/       # UIコンポーネント
│   └── vite.config.ts
├── cdk/
│   └── lib/
│       └── mcp-app-stack.ts  # CDKスタック
└── build.js                  # UIバンドル生成スクリプト

AIコーディングエージェントでのMCP App開発

KiroとAI-DLCワークフローを使用しました。
詳細は別記事「AI-DLC(Kiroとawslabs/aidlc-workflows)でAI駆動開発をやってみる
ここに、「Create MCP App」Skillを使用しています。
Skillをインストールすることで、エージェントがMCP Appのアーキテクチャやベストプラクティスを理解した上でプロジェクトを生成してくれます。

Kiro IDEでの使用方法

1. スキルのインストール

npx skills add https://github.com/modelcontextprotocol/ext-apps --skill "Create MCP App"

image.png

これで .kiro/skills/ ディレクトリにスキルがインストールされます。

image.png

実装のポイント

ポイント1: Lambda向けカスタムTransportが必要

MCP SDKは以下の3つのTransportを標準でサポートしています

Transport 用途
Streamable HTTP リモートサーバー向け(推奨)
HTTP + SSE 後方互換性のためのレガシー
stdio ローカルプロセス統合向け

しかし、AWS Lambdaは1リクエスト1レスポンスのステートレスな実行モデルのため、カスタムTransportを実装しています。
MCP SDKのTransportインターフェースを実装し、API Gatewayイベントを受け取ってJSON-RPCメッセージをパースし、SDKの処理結果をLambdaのレスポンスとして返す橋渡し役を担います。

// lambda-transport.ts 抜粋
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';  // ← MCP SDKのインターフェース
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';        // ← MCP SDKの型定義

// MCP SDKのTransportインターフェースを実装
export class LambdaServerTransport implements Transport {
  private pendingMessage: JSONRPCMessage | null = null;
  private responseResolve: ((result: APIGatewayProxyResultV2) => void) | null = null;

  // Transport.onmessage - MCP SDKがメッセージ受信時に呼び出すコールバック
  onmessage?: (message: JSONRPCMessage) => void;

  async start(): Promise<void> {
    // API Gatewayイベントからリクエストをパースして保存
    this.pendingMessage = JSON.parse(this.event.body);
  }

  async processPendingMessage(): Promise<void> {
    // McpServer.connect()後、保存したメッセージをSDKに渡す
    if (this.onmessage && this.pendingMessage) {
      this.onmessage(this.pendingMessage);  // ← SDKがこのコールバックを登録
    }
  }

  // Transport.send() - MCP SDKがレスポンス送信時に呼び出す
  async send(message: JSONRPCMessage): Promise<void> {
    if (this.responseResolve) {
      this.responseResolve({
        statusCode: 200,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message)
      });
    }
  }

  // Lambda用の追加メソッド - レスポンスをPromiseで取得
  getResponsePromise(): Promise<APIGatewayProxyResultV2> {
    return new Promise((resolve) => {
      this.responseResolve = resolve;
    });
  }
}

ポイント2: Notification処理 (クライアント:Claude DesktopからNotificationが来る)

MCPの初期化フローでは、クライアント(Claude Desktop)からNotificationが送られてきます。

1. クライアント → サーバー: initialize (id: 1)
   サーバー → クライアント: initializeレスポンス (id: 1)

2. クライアント → サーバー: notifications/initialized (idなし) ← Notification
   サーバー: レスポンスを返してはいけない

JSON-RPC 2.0仕様では、idフィールドがないメッセージは「Notification」として扱われます。

A Notification is a Request object without an "id" member. ... The Server MUST NOT reply to a Notification.
(Notificationは"id"メンバーを持たないRequestオブジェクトである。... サーバーはNotificationに返信してはならない。)

問題点

  • クライアントがnotifications/initializedを送ってくる(idフィールドなし)
  • JSON-RPCではレスポンスを返してはいけない
  • しかしLambda/API Gatewayは必ずHTTPレスポンスを返す必要がある

解決策
idがないリクエストを検出したら、MCP SDKに渡さずに202 Accepted(空ボディ)を返します。

// index.ts - Notification処理
if (parsedBody.id === undefined) {
  // Notificationは202 Acceptedで即座に返す(空ボディ)
  return {
    statusCode: 202,
    headers: { 'Access-Control-Allow-Origin': '*' },
    body: '',
  };
}

ポイント3: ツールの呼び出し主体が2つある

MCP Appでは、ツールを呼び出す主体が2つあり、結果の受け取り方が異なります。公式ドキュメントでも以下のように説明されています。

  • app.ontoolresult: A callback that fires when the host pushes a tool result to your app
  • app.callServerTool(): Lets your app proactively call tools on the server

パターン1: LLMがツールを呼び出す場合

ユーザー: 「東京の天気を教えて」
    ↓
LLM: getWeatherツールを呼び出す
    ↓
ホスト → MCPサーバー → ツール実行
    ↓
ホスト → UI: ontoolresultコールバックで結果を通知

パターン2: UI操作(ボタンクリック等)でツールを呼び出す場合

ユーザー: UIの「更新」ボタンをクリック
    ↓
UI: app.callServerTool()を呼び出す
    ↓
UI → ホスト → MCPサーバー → ツール実行
    ↓
UI: Promiseの戻り値で結果を受け取る
呼び出し元 結果の受け取り方
LLM app.ontoolresultコールバック
UI(callServerTool) Promiseの戻り値(awaitで取得)

公式ドキュメントのサンプルコードでも、ボタンクリック時にawait app.callServerTool()の戻り値を直接使用しています:

// 公式ドキュメントのサンプル
getTimeBtn.addEventListener("click", async () => {
  const result = await app.callServerTool({
    name: "get-time",
    arguments: {},
  });
  const time = result.content?.find((c) => c.type === "text")?.text;
  serverTimeEl.textContent = time ?? "[ERROR]";
});

今回の天気アプリでも同様のパターンで実装しています:

// useWeatherApp.ts - callServerToolの使い方
const handleRefresh = useCallback(async (app: App) => {
  setLoading(true);
  try {
    const result = await app.callServerTool({
      name: 'getWeather',
      arguments: { city: cityToSearch, units }
    });
    
    // resultを直接処理
    const textContent = result.content?.find((c) => c.type === 'text')?.text;
    if (textContent) {
      const parsed = parseWeatherText(textContent);
      setWeather(parsed);
    }
  } catch (err) {
    setError('Refresh failed');
  } finally {
    setLoading(false);
  }
}, [inputCity, units]);

ポイント4: resources/read リクエストへのレスポンス

MCP Appの動作フローでは、ホストがUIリソースを取得してiframe内でレンダリングします

ホスト(Claude Desktop等)
    ↓ resources/read リクエスト(ui://... のURI指定)
MCPサーバー
    ↓ レスポンス(HTMLページ)
ホスト ← HTMLページ(JS/CSS含む)を受け取る
    ↓
サンドボックスiframe内でレンダリング

公式ドキュメントでは、このリソースについて以下のように説明されています:

This resource contains an HTML page, often bundled with its JavaScript and CSS for simplicity. Apps can also load external scripts and resources from origins specified in _meta.ui.csp.

(このリソースにはHTMLページが含まれており、簡便さのためにJavaScriptとCSSを一緒にバンドルすることが多いです。アプリは_meta.ui.cspで指定されたオリジンから外部スクリプトやリソースをロードすることもできます。)

今回はLambda環境での管理のしやすさも考慮し、Viteのvite-plugin-singlefileを使ってCSS/JSをすべてインライン化した単一HTMLファイルにしました。

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteSingleFile } from 'vite-plugin-singlefile';

export default defineConfig({
  plugins: [react(), viteSingleFile()],
  build: {
    target: 'esnext',
    assetsInlineLimit: 100000000,
    cssCodeSplit: false,
  },
});

MCP Serverの実装

ツールとリソースの登録

// server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerAppTool, registerAppResource } from '@modelcontextprotocol/ext-apps';

const WEATHER_DASHBOARD_URI = 'weather-dashboard';

export function createMcpServer() {
  const server = new McpServer({
    name: 'weather-mcp-app',
    version: '1.0.0',
  });

  // getWeather - 現在の天気取得(LLM + UI両方から呼び出し可能)
  registerAppTool(server, 'getWeather', {
    title: 'Get Weather',
    description: 'Get current weather for a city',
    inputSchema: {
      type: 'object',
      properties: {
        city: { type: 'string', description: 'City name' },
        units: { type: 'string', enum: ['metric', 'imperial'], default: 'metric' }
      },
      required: ['city']
    },
    _meta: {
      ui: { resourceUri: WEATHER_DASHBOARD_URI }
    }
  }, async ({ city, units }) => {
    const weather = await weatherService.getWeather(city, units);
    return {
      content: [{ type: 'text', text: formatWeatherText(weather) }]
    };
  });

  // refreshData - データ更新(UI専用)
  registerAppTool(server, 'refreshData', {
    title: 'Refresh Data',
    description: 'Refresh weather data (UI only)',
    inputSchema: { /* ... */ },
    _meta: {
      ui: { 
        resourceUri: WEATHER_DASHBOARD_URI,
        visibility: ['app']  // UIからのみ呼び出し可能
      }
    }
  }, async ({ city, units }) => {
    // getWeatherと同じ処理
  });

  // Weather Dashboard UI
  registerAppResource(server, 'Weather Dashboard', WEATHER_DASHBOARD_URI, {
    description: 'Interactive weather dashboard'
  }, async () => {
    return {
      contents: [{
        uri: WEATHER_DASHBOARD_URI,
        mimeType: 'text/html',
        text: UI_BUNDLE  // 生成されたUIバンドル
      }]
    };
  });

  return server;
}

React UIの実装

useAppフック

// App.tsx
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';

function App() {
  const { weather, loading, error, handleToolInput, handleToolResult } = useWeatherApp();

  const { app, isConnected } = useApp({
    appInfo: { name: 'Weather Dashboard', version: '1.0.0' },
    capabilities: {},
    onAppCreated: (app) => {
      // ホストからツールが呼び出された時のハンドラー
      app.ontoolinput = handleToolInput;
      app.ontoolresult = handleToolResult;
    }
  });

  // ホストのテーマ(ライト/ダーク)に自動適応
  useHostStyles(app);

  if (!isConnected) {
    return <div>Connecting...</div>;
  }

  return (
    <div className="weather-app">
      {loading && <LoadingSkeleton />}
      {error && <ErrorMessage message={error} />}
      {weather && <WeatherDisplay weather={weather} />}
      <RefreshButton onClick={() => handleRefresh(app)} loading={loading} />
    </div>
  );
}

ホストスタイルの統合

useHostStylesフックを使うと、Claude Desktopのテーマに自動的に適応するらしい。

/* app.css - ホストのCSS変数を使用 */
.weather-app {
  background: var(--color-background);
  color: var(--color-text);
  font-family: var(--font-family);
}

.weather-card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
}

Claude DesktopでのMCP設定

今回は、作り方の確認をしたかったため、認証・認可を設けていません。
なので、Claude Desktopのカスタムコネクタに、デプロイしたAPI Gateway のエンドポイントを入力して追加しているだけです。

image.png

動作確認(再掲)

「横浜の天気を教えて」と入力すると、天気ダッシュボードが表示されます。UI上で都市を変更したり、単位(°C/°F)を切り替えたりできます。
image.png

まとめ

MCP Appは、会話型AIクライアント内でリッチなUIを表示できる強力な仕組みです。AWS Lambdaでの実装には独自の課題がありましたが、動作を確認することができました。

理解しきれていない部分も含まれています。
齟齬などありましたら、ご指摘いただけるとありがたいです。

参考リンク

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?