MCPサーバーとは――SDK無しの92行で作り、生のJSON-RPCを全部覗いた

目次
  1. SDKを使わずに「MCPサーバー」を書いてみた
  2. ワイヤを覗く——initializeからtools/callまでの生JSON-RPC
  3. 公式クライアントは認めるか——mcp SDKで接続した
  4. stdoutに1行printするだけで接続は死ぬ
  5. 「改行区切り」は本当か——Content-Lengthフレーミングで試した
  6. 「MCPサーバー」と呼ばれる物の正体が分かると何ができるか

MCPサーバーを設定ファイルにコマンド名で書いて使っていると、動かないときに何を疑えばいいか分からない。設定ミスなのか、サーバー側のバグなのか、プロトコル違反なのか。概念図や用語集の解説は多いが、実際にワイヤを流れる物を見せている記事は少なかったので、SDKを使わずに自分で書いて全部覗いてみた。

SDKを使わずに「MCPサーバー」を書いてみた

検証環境は macOS 26.6.1 / Python 3.14.7 である。

$ sh -c 'sw_vers; python3 -V'
ProductName:		macOS
ProductVersion:		26.6.1
BuildVersion:		25G76
Python 3.14.7
# exit=0 (28 ms)

stdioトランスポートのMCPサーバーがやることは、仕様上は「stdinからメッセージを読み、stdoutへ応答を書く」だけだ。ならばSDKなしでどこまで書けるかを試した。server.py は改行区切りのJSON-RPCを1行ずつ読んで、initialize / tools/list / tools/call / ping に応答を返すだけの単一ファイルで、コアはこの形である。

for line in sys.stdin:
    msg = json.loads(line)
    if "id" not in msg:      # notification: 応答しない
        continue
    resp = {"jsonrpc": "2.0", "id": msg["id"], "result": handle(msg)}
    sys.stdout.write(json.dumps(resp) + "\n")

ツールとして add(2数の加算)と echo(文字列をそのまま返す)の2つを実装した。行数を測ると、サーバー92行、送信用の自前クライアント110行、あとで使う公式SDKクライアント33行だった。

$ wc -l server.py client.py sdk_client.py
      92 server.py
     110 client.py
      33 sdk_client.py
     235 total
# exit=0 (7 ms)

ワイヤを覗く——initializeからtools/callまでの生JSON-RPC

自前クライアントでサーバーを子プロセスとして起動し、送った行(>>)と受け取った行(<<)をすべて記録した。まず initialize を送ると、サーバーは protocolVersion・capabilities・serverInfo を返す。

$ python3 client.py happy
>> {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "raw-client", "version": "0.0.1"}}}
<< {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "raw-stdio-server", "version": "0.1.0"}}}
>> {"jsonrpc": "2.0", "method": "notifications/initialized"}
>> {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
<< {"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "add", "description": "add two numbers", "inputSchema": {"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"]}}, {"name": "echo", "description": "echo back the given text", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}]}}
>> {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "add", "arguments": {"a": 19, "b": 23}}}
<< {"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "42"}]}}
>> {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": "hello mcp"}}}
<< {"jsonrpc": "2.0", "id": 4, "result": {"content": [{"type": "text", "text": "hello mcp"}]}}
...
# exit=0 (52 ms)

ここで分かることが3つある。

1つ目。notifications/initialized には id が無く、ログのとおり << の応答行が返ってこない。JSON-RPC の通知は「返事をしないメッセージ」で、クライアントは初期化が済んだことを一方的に告げるだけだ。

2つ目。tools/list の応答にはツール名・説明・inputSchema がそのまま入っている。AIエージェント側が「どんなツールがあるか」を知る仕組みは、このJSONの中身をモデルに見せることでしかない。

3つ目。tools/call の結果は result.content に type: "text" の配列で返る。add に 19 と 23 を渡すと "42" が文字列として返ってきた。

エラーの形も同じログで確認できる。存在しないツールを呼ぶと isError: true 付きの result が返り、存在しないメソッドにはコード -32601 の error が、壊れたJSONには -32700 が返った。

$ python3 client.py happy
...
>> {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "nonexistent", "arguments": {}}}
<< {"jsonrpc": "2.0", "id": 5, "result": {"isError": true, "content": [{"type": "text", "text": "unknown tool"}]}}
>> {"jsonrpc": "2.0", "id": 6, "method": "no/such.method"}
<< {"jsonrpc": "2.0", "id": 6, "error": {"code": -32601, "message": "Method not found: no/such.method"}}
>> {not json
<< {"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}}
# exit=0 (52 ms)

「ツールが無い」と「メソッドが無い」でエラーの層が違う。前者はツール実行の失敗として result の中で返り、後者はJSON-RPC層の error として返る。

公式クライアントは認めるか——mcp SDKで接続した

手書きの92行が「MCPサーバー」を名乗れるかは、公式クライアントが受け付けるかで決まる。公式 mcp パッケージ 2.3.0 の ClientSession でこのサーバーに接続してみた。

$ sh -c './sdkenv/bin/python -c "import importlib.metadata as m; print(m.version(\"mcp\"))" && ./sdkenv/bin/python -V'
2.3.0
Python 3.14.7
# exit=0 (56 ms)

なお、検証環境のネットワーク制約で uv run --with mcp がプロキシ検出の処理ごとパニックしたため、別途 venv を作って pip で mcp を入れてある。そこでも1回つまずいた。初回は init.protocolVersion という属性名で結果を読もうとして失敗した。

$ ./sdkenv/bin/python sdk_client.py
...
      | AttributeError: 'InitializeResult' object has no attribute 'protocolVersion'. Did you mean: 'protocol_version'?
      +------------------------------------
# exit=1 (1033 ms)

この AttributeError はむしろ収穫で、session.initialize() 自体は成功していた。SDKはワイヤ上の camelCase を pydantic モデルの snake_case に変換して返していた。protocol_version に直して実行すると、公式クライアントは手書きサーバーに普通に接続できた。

$ ./sdkenv/bin/python sdk_client.py
connected. protocolVersion = 2025-11-25
serverInfo = raw-stdio-server 0.1.0
tools found: 2
 - add : add two numbers
 - echo : echo back the given text
call_tool(add, 19, 23) -> 42
call_tool(echo) -> hello from sdk
# exit=0 (619 ms)

SDKが要求してきたプロトコルバージョンは 2025-11-25 で、手書きサーバーは受け取った値をそのまま返している(実装が msg["params"]["protocolVersion"] をエコーするだけなので、厳密なバージョン交渉はしていない)。それでも公式クライアントは2つのツールを認識し、call_tool が通った。92行の標準入出力ループが、MCPサーバーとして受理される。

stdoutに1行printするだけで接続は死ぬ

MCPサーバーの自作記事では「stdioモードで print() すると通信が壊れる」という注意がよく出る。壊れ方を実際に見るため、サーバーが initialize を受け取るたびに stdout へ DEBUG: got initialize と1行出力するようにして試した。

自前クライアントでは、応答として最初に届いた行がJSONではなかったためパースに失敗し、本物の応答は次の行にずれて届いた。

$ python3 client.py debugprint
>> {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "raw-client", "version": "0.0.1"}}}
<< DEBUG: got initialize
parsed: FAILED (Expecting value: line 1 column 1 (char 0))
<< {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "raw-stdio-server", "version": "0.1.0"}}}
# exit=0 (47 ms)

公式SDKクライアントではもっと脆かった。1行の print でセッションごと死んだ。

$ ./sdkenv/bin/python sdk_client_debug.py
FAILED: ExceptionGroup unhandled errors in a TaskGroup (1 sub-exception)
Failed to parse JSONRPC message from server
...
pydantic_core._pydantic_core.ValidationError: 1 validation error for union[JSONRPCRequest,JSONRPCNotification,JSONRPCResponse,JSONRPCError]
  Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='DEBUG: got initialize', input_type=str]
    For further information visit https://errors.pydantic.dev/2.13/v/json_invalid
# exit=0 (493 ms)

エラーメッセージの input_value に混入した行 DEBUG: got initialize がそのまま残っている。stdout はクライアントとの通信路そのものなので、そこにJSON以外のバイトを書けば、クライアント側は最初の行でパースに失敗する。デバッグ出力は stderr へ逃がすのが作法になる。

「改行区切り」は本当か——Content-Lengthフレーミングで試した

LSP(Language Server Protocol)では Content-Length ヘッダでメッセージを区切る。MCPのstdioも似た立地なので、同じフレーミングが通用するかを試した。ヘッダ行・空行・本文の3行をそのまま流し込むと、サーバーは2つの応答を返した。

$ python3 client.py lsp
>> Content-Length: 174\r\n\r\n{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "raw-client", "version": "0.0.1"}}}   (sent as 2 raw lines)
<< {"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}}
<< {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "raw-stdio-server", "version": "0.1.0"}}}
# exit=0 (47 ms)

Content-Length: 174 の行はJSONではないので -32700 で捨てられ、空行は読み飛ばされ、本文のJSONは1行まるごとJSONとして読めるため普通に処理された。「1行=1メッセージ」という規則が、裏側からも確認できた。MCPのstdioではヘッダではなく改行だけが境界である。

「MCPサーバー」と呼ばれる物の正体が分かると何ができるか

実測で見えた実体は単純だった。stdioのMCPサーバーとは、クライアントに子プロセスとして起動され、stdinから1行ずつJSON-RPCを読んで、stdoutへ1行ずつ応答を書くプロセスである。ネットワークサーバーのようにポートを開けて待つ物ではない。設定ファイルに書く command は、このプロセスを起動するための起動方法でしかない。

そして動かないときの切り分けは、今回の検証そのままの順序でできる。

  1. コマンドを手で起動して、プロセスが立ち上がるか
  2. initialize を1行のJSONとして書き込んで、1行のJSONが返るか(返らなければサーバー側の問題)
  3. stdout にJSON以外の行が混ざっていないか(混ざっていれば、今回見たようにクライアントはパースに失敗して死ぬ)

この3点は、概念解説を読んだだけでは出てこない。「サーバーが応答しない」を「プロセスが起動していない」のか「応答はあるが壊れている」のかに分けられるかどうかで、直す場所が変わる。

限界も書いておく。今回確かめたのはstdioトランスポートだけで、Streamable HTTP・認証・capability 交渉の厳密な挙動は検証していない。手書きサーバーは protocolVersion をそのままエコーしているだけで、本来のバージョン交渉は実装していない点も残っている。「MCPサーバーとは」の実体把握と、stdioでの一次切り分けには十分だが、本番のサーバー実装はSDKに任せるのが正しいだろう。

関連書籍