標準ライブラリで作るstdioサーバー
一言でいうと
MCPのstdioサーバーは、「stdinから1行読み、JSONとして解釈し、idがあれば返答し、stdoutに1行書いてflushする」ループです。そのループをjson・sys・sqlite3だけで組むと、SDKが隠していた4つのことが見えてきます。フレーミング、idの対応付け、通知の無視、2種類のエラーです。
なぜ必要なのか
SDKでサーバーを作ると、デコレーター1つに関数1つで済みます。その便利さは良いものですが、問題が起きたときにログが言っていることを読めなくなります。「レスポンスをパースできません」「対応するリクエストのないレスポンス」「initializeの前にリクエストを受け取りました」といった文は、すべてプロトコルの層から出てくる言葉で、その層を一度も自分で触ったことのない人にはただのノイズです。
そして事故は、その層で起きます。求人票にMCPと書いてある職務の実際の仕事は、「サーバーを作ること」よりも、「ほかのチームが作ったサーバーがなぜ自社のアプリにつながらないのかを調べること」に近いものです。その仕事をするには、仕様が定めたメッセージの形を手で作って送り、受け取ってみた経験が必要です。このモジュールは、その経験を標準ライブラリだけで作ります。
どう動くのか
フレーミング。トランスポート仕様のstdioの節は、メッセージが改行で区切られ、メッセージの中に改行があってはならないと定めています。そのため、サーバーの骨組みはfor line in sys.stdin:です。レスポンスも1行でなければならないので、json.dumpsの結果に\nを付けて書き込み、必ずflush()します。パイプはターミナルと違って行バッファリングではないため、flushがないとレスポンスがバッファに閉じ込められたまま、クライアントは永遠に待ちます。「サーバーが止まったようだ」という報告の半分がこれです。
idで分かれる3つの道。1行をjson.loadsで解釈すると、3つの場合が出てきます。idがあればリクエストなので、必ず同じidで返答します。idメンバーがなければ通知なので、返答しません。msg.get("id") is Noneで判断すると、idが実際にnullである不正なリクエストと区別がつかないため、"id" in msgで判定します。そしてjson.loadsそのものが失敗したら、JSON-RPC 2.0の5節のとおり、id: nullで-32700 Parse errorを返します。この3つの道をtryで囲まないと、壊れた行が1つあるだけでサーバーが落ち、クライアントはそれを「サーバーが消えた」と受け取ります。
for line in sys.stdin:
try:
msg = json.loads(line)
except json.JSONDecodeError:
reply(None, error={"code": -32700, "message": "Parse error"}); continue
if "id" not in msg: # 알림 — 답하지 않는다
continue
try:
reply(msg["id"], result=handle(msg))
except RpcError as e: # 프로토콜 오류
reply(msg["id"], error={"code": e.code, "message": e.message})
initializeは最初のリクエストです。ライフサイクルのとおり、protocolVersion・capabilities・serverInfoを載せたresultを返します。ツールを提供するサーバーはcapabilities.toolsを宣言しなければなりません(MUST)。このオブジェクトが空でも、キーは必要です。そのあとクライアントはnotifications/initializedを送りますが、これは通知なので、サーバーは返答しません。手で組んだサーバーで最もよくある間違いは、ここでレスポンスを出してしまうことです。
tools/listとtools/call。ツール仕様の形式そのままです。一覧の各ツールはname・description・inputSchemaを持ち、inputSchemaはtype: objectのJSON Schemaです。モデルがこのスキーマを読んで引数を作るので、descriptionはそのままモデルに渡す使用説明書になります。呼び出しはparamsのnameとargumentsを受け取り、content配列で返します。存在しないツール名はプロトコルエラー(仕様の例は-32602)で、ツールが実行されている途中で失敗した場合はisError: trueです。知らない状態値で注文数を数えてほしいというリクエストは、後者です。モデルがテキストを読んで、正しい値で呼び直せなければなりません。
クライアントも同じルールで組みます。subprocess.Popenでサーバーを起動し、stdinに書き込み、stdoutからreadlineします。通知を送ったあとはreadlineしません。返答が来ないので永遠に待つことになるからです。終了するときは、ライフサイクルのstdio終了の順序のとおり、先にstdinを閉じ、サーバーが自分で終わるのを待ちます。
現場での姿
あるチームが作ったMCPサーバーが、あるホストアプリではつながるのに、別のアプリでは「初期化失敗」になりました。原因は、サーバーが起動時にstdoutにバナーを1行出力していたことでした。寛容なホストはJSONではない行を捨て、厳格なホストは最初の行がパースできないために接続を切りました。仕様の一文(「stdoutにMCPメッセージ以外を書かない」)が、2つのホストの違いを説明しています。このモジュールの最後のステップが、その衛生管理を採点します。
もう1つは、initialized通知にレスポンスを返すサーバーです。ホストはそのレスポンスを受け取って「対応するリクエストのないid」と警告を出しただけでしたが、そのあとのtools/listのレスポンスと順序が混ざり、ツール一覧が空に見える症状が出ました。通知には返答しないというルールを破った結果が、まったく別の場所に現れたのです。
次のラボですること
店のDBを作り、initializeからtools/callまでを処理するサーバーをステップごとに組んだあと、そのサーバーを子プロセスとして起動するクライアントを使って、1つのセッションを最後まで通します。最後にstdoutを汚していないことを確認します。