はじめに
MCPの仕様を確認したり、自作する過程で MCPは実際にどんな通信をしているのか疑問に思ったことはないでしょうか?
本記事では、MCPの通信の中身を JSON-RPC 2.0 を使って確認してみたいと思います。
MCPのアーキテクチャ
はじめにコンポーネントとアーキテクチャをおさらいします。
公式ドキュメントには、以下のMCPのアーキテクチャの説明があります。
自分なりにまとめると以下のような感じです。
Component | Description |
---|---|
Host | Claude Desktopや AIエディタ(例:Cursor, Windsurf) など、ユーザーインターフェースを提供するアプリケーション |
Clients | Host内に組み込まれ、MCPサーバーと接続してツールの一覧取得や実行をする (AIエディタの裏側で動作することが多いため、通常の利用者がクライアントを実装する機会はあまりないと思われる) |
Servers | ツールを提供するMCPサーバー本体 ローカル (スクリプトや Docker) や リモート(HTTP)のサービスとして提供される (例:Context7は、 Streamable HTTP でも利用できる) |
公式サイトの Core architecture にUI(Chat) とLLMを追加した図
UI(Chat) は Host 内にあり GPT、Gemini 等 LLM は 別枠の位置づけになるかと思います。
Transport Layer
Client と Server 間の通信レイヤーでは 主に 2つの方式 が用いられています。
全てのトランスポートは JSON-RPC 2.0 を使用してメッセージを交換します。
Transport | Description |
---|---|
Stdio | 標準入出力で通信します。ローカルでの実行で利用されます。 |
Streamable HTTP1 | HTTP で通信します。リモートサーバーとの通信に適しています。(localhost でも利用できます) |
MCPサーバーとの通信を試す
アーキテクチャを理解したところで本題の「MCPのやりとりをJSON-RPCで確認」をやってみます。
MCPサーバーを準備
まずはテスト用のMCPサーバーを用意します。(執筆時点の mcpバージョン: 1.10.1)
以下は足し算するだけの MCPサーバー (test.py
) です。
from mcp.server.fastmcp import FastMCP
# Initialize FastMCP server
mcp = FastMCP("Calculator")
@mcp.tool()
async def add(num1: float, num2: float) -> float:
return num1 + num2
# Start MCP Server with Stdio transport
if __name__ == "__main__":
mcp.run(transport="stdio")
Python仮想環境を作成して実行
標準入出力待ちになるので Ctrl + C
を何回が連打して終了してください。この後、作成したMCPサーバーを使って確認していきます。
$ uv init test
$ cd test
$ uv sync
$ . .venv/bin/activate
(test) $ uv add mcp
$ uv run test.py
# 標準入出力待ち
JSON-RPC でやり取り(Stdio)
以下は接続初期化の流れです。
それでは実際に通信の中身を確認するため JSON-RPC 2.0 を使って、先ほど作成したMCPサーバーと 接続してみます。
接続初期化(initialize
) と 接続確認(Ping
)
実行例:
$ uv run test.py
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"bash-client","version":"1.0.0"}}}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"Calculator","version":"1.11.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
{"jsonrpc":"2.0","id":1,"method":"ping"}
Processing request of type PingRequest
{"jsonrpc":"2.0","id":1,"result":{}}
1行ずつ見ていきます。
まずテスト用MCPサーバーを起動します。
MCPサーバーの transport は mcp.run(transport="stdio")
で標準入出力待ちになります。
$ uv run test.py
# 標準入出力待ち
次にinitialize
リクエストを送信します(入力)
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"bash-client","version":"1.0.0"}}}
MCPサーバーから initialize
レスポンスを受信します
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"Calculator","version":"1.10.1"}}}
続いて initialization
が成功したら notifications/initialized
通知を送信します(入力)
After successful initialization, the client MUST send an
initialized
notification
to indicate it is ready to begin normal operations:
{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
MCPサーバーに ping
リクエストを送信します(入力)
{"jsonrpc":"2.0","id":1,"method":"ping"}
MCPサーバーからの応答 (空の応答が返る)
The receiver MUST respond promptly with an empty response:
{"jsonrpc":"2.0","id":1,"result":{}}
MCPクライアントが JSON-RPC を使って MCPサーバーと通信するイメージが掴めたのではないでしょうか。
MCP サーバーの tool を実行する
JSON-RPC の通信が確認できたので、もう少し具体的な動きを確認します。
細かい内容が続きますが、JSON-RPC 2.0での通信内容は MCPクライアントとMCPサーバーがどのようにツールを実行しているのか、LLMはどのようにしてMCPと連携しているのかを理解するに役立ちます。
実際に MCP サーバーの tool を試してみます。使うメソッドは tools/list
と tools/call
の2つです。
ツールの一覧取得 (tools/list)
JSON-RPC 2.0を使って tools/list
で tool の一覧を取得します。「MCPサーバーの実装」で作成した test.py
の 足し算ツールが取得できれば成功です。
実行例: 接続初期化の後に "method":"tools/list"
をリクエスト
$ uv run test.py
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"bash-client","version":"1.0.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
応答:(json.tool で整形)
ツールが取得できました。("tools":
> "name": "add"
)
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "add",
"description": "",
"inputSchema": {
"properties": {
"num1": {
"title": "Num1",
"type": "number"
},
"num2": {
"title": "Num2",
"type": "number"
}
},
"required": [
"num1",
"num2"
],
"title": "addArguments",
"type": "object"
},
"outputSchema": {
..省略..
}
}
]
}
}
ツールを実行 (tools/call):
実行例: "method": "tools/call"
で 足し算add
( 20 + 80 ) をリクエスト
$ uv run test.py
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"bash-client","version":"1.0.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "add", "arguments": {"num1": 20, "num2": 80}}}
応答:100 ("result":"100.0"
)
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"100.0"}],"structuredContent":{"result":100.0},"isError":false}}
MCPクライアントとMCPサーバーがどのようなリクエストでツールを実行しているかを確認できました。
AIエディタを使っていると LLM がツールを実行しているように見えますが、厳密には ツールの実行はすべてMCPを通じて行われることであり、LLMがツールを "直接" 実行することはない というのがこの例から分かると思います。
LLMは、提供されているツールの中から適切なものを選択し、実行の指示(引数などの生成)をすることであり、アーキテクチャの図のように 明確に役割が分かれていることが理解できます。
JSON-RPC でやり取り(Streamable HTTP ステートレス)
次に Streamable HTTP(ステートレス) で同じことをやってみます。
ステートレスは、curl
などの簡易検証や疎通確認、デバッグ時に便利です。
足し算するだけの MCPサーバー (test.py
) を以下のように変更します。
mcp = FastMCP("Calculator", stateless_http=True)
mcp.run(transport="streamable-http")
from mcp.server.fastmcp import FastMCP
# Initialize FastMCP server
mcp = FastMCP("Calculator", stateless_http=True)
@mcp.tool()
async def add(num1: float, num2: float) -> float:
return num1 + num2
# Start MCP Server with streamable-http transport
if __name__ == "__main__":
mcp.run(transport="streamable-http")
MCPサーバーを起動する
http://127.0.0.1:8000
で MCPサーバーが起動します。
$ uv run test.py
INFO: Started server process [6724]
INFO: Waiting for application startup.
StreamableHTTP session manager started
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
MCP サーバーの tool を実行する
ツールの一覧取得 (tools/list)
ステートレスなので、いきなり送ってもOK です。
$ curl -X POST http://localhost:8000/mcp/ \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
応答:ツールが取得できました。("tools":
> "name": "add"
)
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "add",
"description": "",
"inputSchema": {
"properties": {
"num1": {
"title": "Num1",
"type": "number"
},
"num2": {
"title": "Num2",
"type": "number"
}
},
"required": [
"num1",
"num2"
],
"title": "addArguments",
"type": "object"
},
..省略..
ツールを実行 (tools/call):
実行例: "method": "tools/call"
で 足し算add
( 10 + 20 ) をリクエスト
$ curl -X POST http://localhost:8000/mcp/ \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"num1":10,"num2":20}}}'
応答: 30 ("result":"30.0"
)
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "30.0"
}
],
"structuredContent": {
"result": 30.0
},
"isError": false
}
}
まとめ
この記事では、initialize
や tools/list
などのリクエストを自前のMCPサーバーに送信することで、MCPの通信の中身を確認しました。
JSON-RPC 2.0 の通信内容から MCPが 明確なルールの下に通信していることを確認することで、MCPが LLMと連携するための共通規格 となっていることの理解が深まりました。
個人的に重要だと思うのは、MCPの役割分担のところで、ツールの実行はすべてMCPを通じて行われ、LLMは「どのツールを、どんな引数で呼ぶか」を指示するだけ という点です。
AI搭載エディタ等を使う場合は、エディタが MCPホストとなり MCPクライアントの役割を担うので、自分でMCPサーバー作成する際は、ツール実装に注力すれば良いことが納得できました。
-
SSE(Server-Sent Events) はDeprecated になっています(https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse-deprecated) ↩