HTTP を初めて学んだとき、何をしましたか?
自分は telnet で生のリクエストを送って、ヘッダーラインに GET / HTTP/1.1 があり、そのあとにヘッダーが続き、空行を挟んでボディがある——という構造を手で確認しました。教科書で読むよりも、実際にバイト列を見た方がずっと理解が早かったです。
最近、AI エージェントの文脈で MCP (Model Context Protocol) がよく話題に上がります。Claude や ChatGPT がツールを呼び出すためのプロトコルで、仕様は公開されているのですが、仕様書を読むだけだと「結局どういう HTTP が飛んでいるのか」がいまいちピンとこない。
ということで、HTTP のときと同じアプローチで MCP を学んでみました。自分では1行もプロトコル実装を書かず、既存ツールでサーバーを立てて、curl で1リクエストずつ叩いて中身を観察する。
この記事はその記録です。
MCP の仕様を理解するのが目的なので、自分で実装はしません。
自分で実装してしまうと、仕様の誤解に基づくコードが「正しい通信」として動いてしまい、間違った理解のまま進んでしまう可能性があります。あくまで、信頼できる既存のミドルウェアが実際にどう通信しているかを観察するアプローチです。
使うものはこれだけ:
| ツール | 役割 |
|---|---|
| mcp-proxy | stdio ベースの MCP サーバーを Streamable HTTP エンドポイントに変換するブリッジ。pip install で入るコミュニティ OSS(GitHub Star 1.7k)。https://github.com/sparfenyuk/mcp-proxy |
| @modelcontextprotocol/server-everything | MCP 仕様策定チームが公式に提供するリファレンス実装。tools / resources / prompts を一通り備えたテスト用サーバーで、npx で即起動できる。https://github.com/modelcontextprotocol/servers/tree/main/src/everything |
| curl | HTTP リクエストを手で叩く |
コーディングは一切不要です。
# 作業ディレクトリ
mkdir /tmp/workspace-mcp-observe && cd /tmp/workspace-mcp-observe
# Python venv を作って mcp-proxy をインストール
python3.13 -m venv .venv
source .venv/bin/activate
pip3 install mcp-proxy
# MCP サーバー起動(これだけ!)
mcp-proxy --port 8080 -- npx -y @modelcontextprotocol/server-everything
mcp-proxy は、stdio ベースの MCP サーバーを Streamable HTTP のエンドポイントに変換してくれるブリッジです。これで http://127.0.0.1:8080/mcp に MCP サーバーが立ちます。
サーバーのログに以下が出れば準備完了:
INFO: Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit)

いきなり GET してみます。
curl http://127.0.0.1:8080/mcp
{
"jsonrpc": "2.0",
"id": "server-error",
"error": {
"code": -32600,
"message": "Not Acceptable: Client must accept text/event-stream"
}
}
いきなり怒られました。Accept ヘッダーに text/event-stream が必要とのこと。
では付けてみます。
curl -H 'Accept: text/event-stream' http://127.0.0.1:8080/mcp
{
"jsonrpc": "2.0",
"id": "server-error",
"error": {
"code": -32600,
"message": "Bad Request: Missing session ID"
}
}
エラーの通りに Accept ヘッダーを付けてみましたが、今度は「セッション ID がない」という別のエラーになってしまいました。GET でアクセスするだけではダメらしい。
この時点では、何が正しいリクエスト方法なのかさっぱりわかりません。
手探りで curl を叩いても埒が明かないので、MCP の公式仕様を調べてみました。
現在の MCP 仕様(2025-11-25)では Streamable HTTP が標準のリモートトランスポートとして定義されています。トランスポート層の通信ルールを図にすると、以下のようになります:

なるほど。ポイントは以下でした:
さっき GET で叩いてセッション ID がないと怒られたのは当然で、そもそも GET ではなく POST で initialize から始めなければいけなかったわけです。
仕様がわかったので、改めて正しい手順で通信してみます。
仕様に従い、POST で initialize メソッドを送ります。
curl -X POST http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream, application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "test",
"version": "0.1"
}
}
}'
ここで Accept ヘッダーに注目してください。`text/event-stream` と `application/json` の両方を指定しています。実は最初、text/event-stream だけで試したところ:
{"error": {"message": "Not Acceptable: Client must accept application/json"}}
と怒られました。仕様上、クライアントは両方の Content-Type を受け入れる必要があるということですね。
正しいリクエストのレスポンスを -v (verbose) 付きで見てみます:
> POST /mcp HTTP/1.1
> Host: 127.0.0.1:8080
> Accept: text/event-stream, application/json
> Content-Type: application/json
> Content-Length: 151
< HTTP/1.1 200 OK
< content-type: application/json
< mcp-session-id: 772a1ba1423842caaa7baba242321b8d
< content-length: 311
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"experimental": {},
"logging": {},
"prompts": { "listChanged": false },
"resources": { "subscribe": false, "listChanged": false },
"tools": { "listChanged": false },
"completions": {}
},
"serverInfo": {
"name": "mcp-servers/everything",
"version": "1.26.0"
}
}
}
気づいたポイント:
HTTP のステータスコードではなく、JSON-RPC のレスポンス構造でやり取りしているのがポイントです。
初期化レスポンスを受け取ったら、クライアントは notifications/initialized を送ります。これは「初期化完了しました」という通知で、`id` フィールドがない のが特徴です(JSON-RPC の通知 = レスポンス不要)。
curl -v -X POST http://127.0.0.1:8080/mcp \
-H 'Accept: text/event-stream, application/json' \
-H 'Content-Type: application/json' \
-H 'Mcp-Session-Id: 772a1ba1423842caaa7baba242321b8d' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
< HTTP/1.1 202 Accepted
< content-type: application/json
< mcp-session-id: 772a1ba1423842caaa7baba242321b8d
< content-length: 0
202 Accepted、ボディなし。通知なので「受け取りました」とだけ返ってきます。
ここで Mcp-Session-Id ヘッダーを付けている点に注目。Step 2 で受け取ったセッション ID を返しています。
いよいよ、このサーバーにどんなツールがあるか聞いてみます。
curl -X POST http://127.0.0.1:8080/mcp \
-H 'Accept: text/event-stream, application/json' \
-H 'Content-Type: application/json' \
-H 'Mcp-Session-Id: 772a1ba1423842caaa7baba242321b8d' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
レスポンスは長いので、一部抜粋します:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "echo",
"title": "Echo Tool",
"description": "Echoes back the input string",
"inputSchema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Message to echo"
}
},
"required": ["message"]
}
},
{
"name": "get-sum",
"title": "Get Sum Tool",
"description": "Returns the sum of two numbers",
"inputSchema": {
"type": "object",
"properties": {
"a": { "type": "number", "description": "First number" },
"b": { "type": "number", "description": "Second number" }
},
"required": ["a", "b"]
}
}
]
}
}
各ツールが JSON Schema 形式の inputSchema を持っています。AI はこのスキーマを見て、ツールの引数をどう組み立てればいいか理解します。これが MCP で「AI がツールを使える」仕組みの根幹です。
echo ツールを呼んでみます。
curl -v -X POST http://127.0.0.1:8080/mcp \
-H 'Accept: text/event-stream, application/json' \
-H 'Content-Type: application/json' \
-H 'Mcp-Session-Id: 772a1ba1423842caaa7baba242321b8d' \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "echo",
"arguments": { "message": "hello MCP" }
}
}'
> POST /mcp HTTP/1.1
> Accept: text/event-stream, application/json
> Content-Type: application/json
> Mcp-Session-Id: 772a1ba1423842caaa7baba242321b8d
> Content-Length: 107
< HTTP/1.1 200 OK
< content-type: application/json
< mcp-session-id: 772a1ba1423842caaa7baba242321b8d
< content-length: 104
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "Echo: hello MCP"
}
],
"isError": false
}
}
ツールの呼び出しが成功しました。レスポンスの構造を見ると:
Claude や ChatGPT がツールを使うとき、裏ではまさにこういう HTTP リクエストが飛んでいるわけです。
最後に、時間のかかるオペレーションも試してみました。
curl -N -X POST http://127.0.0.1:8080/mcp \
-H 'Accept: text/event-stream, application/json' \
-H 'Content-Type: application/json' \
-H 'Mcp-Session-Id: 772a1ba1423842caaa7baba242321b8d' \
-d '{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "trigger-long-running-operation",
"arguments": { "duration": 5, "steps": 3 }
}
}'
5秒後にレスポンスが返ってきました:
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"content": [
{
"type": "text",
"text": "Long running operation completed. Duration: 5 seconds, Steps: 3."
}
],
"isError": false
}
}
このケースでは application/json で返ってきましたが、仕様上、サーバーは SSE ストリーム (text/event-stream) で進捗を返すことも可能です。クライアントが Accept で両方を受け入れているので、サーバー側が適切な方を選べるという仕組みです。
ここまで server-everything という公式テストサーバーで検証しましたが、echo や get-sum のようなおもちゃツールだけでは「本当に実用的なサーバーでも同じなのか?」という疑問が残ります。
そこで、コード解析ツール serena を MCP サーバーとして立ててみました。serena は LSP(Language Server Protocol)を活用したコードインテリジェンスツールで、シンボル検索やリファクタリングなど、実際の開発で使うような機能を MCP ツールとして提供しています。自分も普段めちゃくちゃ活用しています。
https://github.com/oraios/serena
起動方法は server-everything のときと全く同じです:
mcp-proxy --port 8080 -- uvx --from git+https://github.com/oraios/serena \
serena start-mcp-server --transport stdio --project /path/to/project
ハンドシェイク(initialize → initialized)は省略しますが、手順は前回と同一です。
curl -s -X POST http://127.0.0.1:8080/mcp \
-H 'Accept: text/event-stream, application/json' \
-H 'Content-Type: application/json' \
-H "Mcp-Session-Id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| jq '[.result.tools[] | {name, description: (.description | split("\n")[0])}]'
返ってきたツール群は echo や get-sum ではなく、こういうものでした(一部抜粋):
[
{ "name": "read_file", "description": "Reads the given file or a chunk of it." },
{ "name": "list_dir", "description": "Lists files and directories in the given directory." },
{ "name": "find_symbol", "description": "Retrieves information on all symbols/code entities." },
{ "name": "get_symbols_overview", "description": "Get a high-level understanding of code symbols in a file." },
{ "name": "search_for_pattern", "description": "Offers a flexible search for arbitrary patterns in the codebase." },
{ "name": "replace_symbol_body", "description": "Replaces the body of the symbol with the given name_path." },
{ "name": "execute_shell_command", "description": "Execute a shell command and return its output." }
]
ファイル操作、シンボル検索、コード編集、シェルコマンド実行——IDE が裏でやっているようなことが、MCP ツールとして並んでいます。inputSchema の形式は server-everything と全く同じ JSON Schema 形式です。
list_dir ツールを呼んで、実際のプロジェクトのディレクトリ構造を取得してみます:
curl -s -X POST http://127.0.0.1:8080/mcp \
-H 'Accept: text/event-stream, application/json' \
-H 'Content-Type: application/json' \
-H "Mcp-Session-Id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
"name":"list_dir",
"arguments":{"relative_path":".","recursive":false}
}}' | jq
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "{\"dirs\": [\"src\", \"public\", \".github\", ...], \"files\": [\"package.json\", \"tsconfig.json\", \"Dockerfile\", ...]}"
}
],
"isError": false
}
}
echo ツールが "Echo: hello MCP" を返したのと全く同じ構造——content[0].type は "text"、isError は false。実際のプロジェクトのディレクトリ一覧が返ってきているだけで、HTTP レベルの envelope は一切変わっていません。
ちなみに、プロジェクトをアクティブ化する前に list_dir を呼んだところ、こんなレスポンスが返ってきました:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "Error: No active project. Ask the user to provide the project path or to select a project from this list of known projects: ['sample-app', 'example-api', ...]"
}
],
"isError": false
}
}
注目すべきは HTTP ステータスは 200 OK で、`isError` も `false` だということ。ツール内部のエラーは、HTTP レベルやプロトコルレベルのエラーではなく、content[].text の中にテキストとして返ってくるっぽい。「ツールの実行自体は成功したが、結果としてエラーメッセージを返した」という扱いらしいです。
HTTP のエラーハンドリングに慣れている身からすると少し違和感がありますが、AI エージェントにとっては「エラーメッセージを読んで次のアクションを考える」という自然な流れになるんだろうなと思います。実際、OpenAI Codex でこの serena MCP サーバーを使うと、エラーメッセージの内容を読み取って activate_project を自動で呼び直してくれます。

server-everything と serena、中身は全く違うサーバーですが:
サーバーの中身が hello world レベルでも、本格的なコードインテリジェンスツールでも、プロトコルの「型」は変わらない。これが MCP で統一プロトコルを定めている意味です。
ここまでの curl セッションで、先ほど仕様書から読み取った通信フローを、実際に手で再現できました。
改めてポイントをまとめると:
ここまでは curl で「クライアントの気持ち」を手動で体験しましたが、本物の AI クライアントは実際にどう通信しているのでしょうか? ngrok でローカルの MCP サーバーをインターネットに公開し、ChatGPT から接続してみます。
前述と同じ mcp-proxy + server-everything の構成をそのまま使います。ngrok で HTTPS トンネルを張るだけで、mcp-proxy 側の変更は不要です。
ngrok http 8080
ngrok が公開 URL を発行したら、それを ChatGPT に登録します。
ChatGPT で MCP サーバーを使うには、まず開発者モードを有効にする必要があります:


構成はこうなります:
ChatGPT
↓ HTTPS
ngrok (公開URL → localhost:8080)
↓ HTTP
mcp-proxy (:8080)
↓ stdio
MCP server-everything
ChatGPT で「構造化データを返す」ツールを呼んでみると、mcp-proxy 側にこんなログが流れます:
POST /mcp 200 OK ← initialize
POST /mcp 200 OK ← initialize(もう1セッション)
POST /mcp 202 Accepted ← notifications/initialized
GET /mcp 200 OK ← SSE ストリーム開始
POST /mcp 200 OK ← tools/list
POST /mcp 200 OK ← tools/call
curl で手動でやった initialize → initialized(202)→ tools/list → tools/call の流れが、そのまま再現されています。本番クライアントも、自分が curl で叩いたのと全く同じプロトコルで通信していることがわかります。
ngrok には http://localhost:4040 にトラフィックインスペクターが内蔵されています。ここを開くと、ChatGPT が送った JSON-RPC リクエストのヘッダー・ボディをリアルタイムで確認できます。curl の -v で見ていたのと同じ情報が、今度は本番クライアントの通信として流れてくるわけです。
ngrok トラフィックインスペクター画面
プロトコルの理解は、仕様書を読むのと実際にバイト列を見るのとでは全然違います。curl さえあれば MCP の中身は丸見えなので、興味がある方はぜひ試してみてください。