こんにちは、アプリエンジニアの日野です。
最近、アプリ上でLLMからMCPサーバーと接続するサンプルを作ってみましたので自身の理解も兼ねて解説記事を作成しました。
今回は、LLMと外部ツールをつなぐ新しい標準プロトコル「MCP(Model Context Protocol)」について解説します。
MCPとは?
MCP(Model Context Protocol)は、LLM(ChatGPT, Gemini等)と外部ツール・サービスをつなぐためのプロトコルです。
これにより、API連携や外部サービスの操作を「自然言語」経由で柔軟に実現できます。
- MCPホストよりMCPサーバーから取得したツールやリソース情報をLLMに提供し、自然言語でAPI経由でのデータ収集やデータの書き込みなどができるようになります。
- 公式からSDKが公開されています(https://github.com/modelcontextprotocol)
- 公式がSDKを公開していない言語もコミュニティーで活発に開発されています
- 今回はFlutterのMCP Client用にmcp_client を利用させていただきました。
- 公式がSDKを公開していない言語もコミュニティーで活発に開発されています
どんなことができるの?
以下のようなプロンプトをLLMに投げると、MCP経由で自動的に外部サービスへリクエストが送られ、LLMと連携して必要な情報を返してくれます。
- 「Spotifyで再生中の曲を教えてください」
- Spotify MCPサーバー経由で再生中の曲名が取得後、表示されます
- 「Notionの〇〇ページに話した会話内容を書き出してください」
- Notionの任意のページにLLMとの会話履歴のテキストを書き出されます
- 「Slackの会話履歴から今日のやることリストを教えてください」
- 接続されているSlackのワークスペースやチャンネルをLLMへ入力後、やることリストを生成してくれます
- 「FigmaのデザインデータをFlutterに変換してください」
- FigmaのデザインデータをMCPサーバー経由で取得して、FlutterのコードにLLMが変換してくれます
MCPの構成要素
MCPは主に以下の3つの要素で構成されます。
- MCPホスト:アプリ側(UIやユーザー入力を受け付ける)
- MCPクライアント:ツールへの通信を中継
- MCPサーバー:ツール定義やAPIを公開
通信方式:stdio と SSE
stdio
- 標準入力/出力ベース(ローカル向き)
- 開発・デバッグ向き、設定が簡単
- 例:CursorやClaude Desktop、その他の開発PCで動作するデスクトップアプリ
// .cursor/mcp.json の例 { "mcpServers": { "notionApi": { "command": "npx", "args": ["-y", "@notionhq/notion-mcp-server"], "env": { "OPENAPI_MCP_HEADERS": "{\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\" }" } } } }
SSE
- Server-Sent Eventsと呼ばれる、一方向の非同期通信方式です
- 端末内にMCPサーバーを立てるハードルが高いモバイルアプリやWebアプリでMCPサーバーと接続する場合に管理しやすい
- 例: ネットワーク(private / public)に公開されているMCPサーバーへスマートフォンアプリからアクセスする場合
使用されるURLとその用途
下記ホストとポートはローカル環境で起動している為、ローカルのMCPサーバーのIPアドレスとポートを指定しております。 実運用時は httpsのURLを通じて接続する形になるかと思います。
- http://localhost:8000/sse
- 用途: クライアントがサーバーとのSSE接続を確立するために使用します。
- 動作: クライアントがこのURLにGETリクエストを送信すると、サーバーはSSEストリームを開始し、最初のイベントとしてendpointイベントを送信します。
- このイベントには、クライアントがメッセージを送信するためのエンドポイント(例: /event)が含まれています。
- http://localhost:8000/event
- 用途: クライアントがサーバーにメッセージを送信するために使用します。
- 動作: クライアントは、/sseエンドポイントから受け取ったendpointイベントに含まれるURL(この例では/event)に対して、HTTP POSTリクエストを送信します。
- これにより、クライアントからサーバーへのメッセージ送信が可能になります。
MCPホストの実行フロー
MCPホストがLLMとMCPサーバーを用いて必要な情報を取得する簡単なシーケンス図を添付します。
- ユーザーがプロンプトを入力
- MCPホストが利用可能Tool一覧をMCPクライアントから取得し、LLMへ渡す
- LLMが関数呼び出し(Tool Calling)を決定
- MCPクライアントがMCPサーバーに実行を依頼
- MCPサーバーがToolを実行し結果を返す
- LLMが応答を生成
- ユーザーに返す
Toolとは?
Toolは、LLMが外部サービスやAPIを「実行」するための機能です。
たとえば、Spotifyの再生情報取得やNotionへの書き込み、Slackからの情報取得など、プロンプトに応じて外部操作やデータ取得を行います。
MCPサーバーは自身のサーバーで使用可能なToolsを提示し、LLMはその中から必要なものを選んでリクエストを送ります。
実装例
ここでは、ステップに沿ってコードの説明をします。
1) 接続されている全てのMCPクライアントからTool一覧を取得
listToolsメソッドを呼び出すことでMCPクライアントのTool一覧を取得することができます。
final allTools = <mcp_client.Tool>[]; for (final clientInfo in _mcpManager.connectedClients) { try { final tools = await clientInfo.client.listTools(); allTools.addAll(tools); } catch (e) { debugPrint('Failed to get tools from ${clientInfo.name}: $e'); } }
2) MCP のTool一覧を Gemini 用のToolに変換
そのままだとGeminiではToolが渡せないので、Geminiに渡せるToolの型に変換します。 ここは汎用化することで他のLLMにも対応できます。
final geminiTools = _toGeminiTools(allTools); debugPrint( 'Gemini Tools: ${geminiTools.map((e) => e.functionDeclarations?.firstOrNull?.name)}', );
3. LLMが関数呼び出し(Tool Calling)を決定
LLM(Gemini)にプロンプトとTool一覧を渡して生成処理の結果から関数呼び出しが必要か決定してもらいます。
final userContent = gemini.Content.text(userPrompt); _chatHistory.add(userContent); final first = await model.generateContent( _chatHistory, tools: geminiTools, );
4) 関数呼び出しがあるか確認
生成処理の結果から関数呼び出しの配列を確認を行います。 関数呼び出しが1つもない場合は、LLMの応答としてそのままユーザーへ返します。
final call = first.functionCalls.isNotEmpty ? first.functionCalls.first : null; if (call == null) { final response = first.text ?? ''; _chatHistory.add(gemini.Content.text(response)); return response; }
5) 適切なMCPクライアントを探してツールを実行
関数呼び出し(Tool Calling)がある場合、Toolを保持しているMCPサーバーを探し、対象のMCPサーバーへ向けて引数とTool名をMCPサーバーへ渡して関数呼び出しを行います。
mcp_client.CallToolResult? toolResult; String? errorMessage; for (final clientInfo in _mcpManager.connectedClients) { try { final tools = await clientInfo.client.listTools(); if (tools.any((tool) => tool.name == call.name)) { toolResult = await clientInfo.client.callTool(call.name, call.args); break; } } catch (e) { errorMessage = 'Failed to execute tool on ${clientInfo.name}: $e'; debugPrint(errorMessage); } }
6) 実行結果を LLM に返し、要約を生成
関数呼び出しの結果をLLM(Gemini)に返して、その結果も含めて生成してもらいます。
final followUp = await model.generateContent([ ..._chatHistory, gemini.Content.text('以下の実行結果を日本語で分かりやすく要約してください:'), gemini.Content.model([call]), gemini.Content.functionResponse(call.name, {'result': resultJson}), ]);
7. ユーザーに返す
生成結果をユーザーへ返します。
_chatHistory.add(gemini.Content.text(response));
サンプルリポジトリ
実際にMCPを使ったNotionとSpotify連携のサンプルは、以下のリポジトリで公開しております。
サンプル概要
mcp_client
ライブラリを使い、Gemini経由でNotion MCPサーバーにSSE接続するサンプルアプリです。- サーバーはローカルPC上で起動します。
- stdio→SSE変換には supergateway を利用します。
個人で試したMCPサーバーはこちらです。
Notion MCPサーバー
- 新規ページ作成
- ページ編集
- 既存ページの検索 など
Spotify MCPサーバー
- プレイリストの取得
- 再生中のトラックの取得
- トラックの操作(スキップ、一時停止、再生)など
詳細は README を参照してください。
懸念点
MCPを利用する際に特に注意すべきなのが「MCPポイゾニング」と呼ばれるセキュリティリスクです。
MCPポイゾニングとは?
MCPポイゾニングとは、悪意のある第三者がMCPサーバーを偽装し、LLMに誤ったツール定義やAPIエンドポイントを認識させてしまう攻撃手法です。これにより、意図しない外部サービスへのアクセスや、情報漏洩、さらにはLLMを経由した不正操作が発生する可能性があります。
例
- 正規のMCPサーバーになりすました偽サーバーが、LLMに危険なツール定義を返す
- LLMがそのツールを信じて実行し、ユーザーの意図しないデータ送信や操作が行われる
次回の記事で実際にMCPポイゾニングがどのように発生するかを再現するサンプルアプリを用いて説明予定です。
攻撃例や防御策の実装例も含めて、より安全なMCP活用方法を検証していきます。
まとめ
- MCPでLLMが実行可能なアプリが構築でき、LLMのためにAPIの実装が不要になる
- SSE接続はアプリ向き
- 基本的にはアプリはToolsしか使用しないが、裏にResourcesが存在していることを認識しておく
- ツール呼び出しフローを理解することが重要
- MCPポイゾニングなどのセキュリティリスクにも十分注意し、署名やホワイトリストなどの対策を講じた安全な運用が不可欠です
MCPを活用することで、AIと外部サービスの連携がよりシンプルかつ強力になります。
今後はセキュリティ面にも配慮しつつ、サンプルリポジトリや実践例を参考にMCP連携アプリ開発にチャレンジしてみてください!
参考リンク: