TT Lab
はじめる
学ぶ 学習パス コース

エージェントが私のDBを消した

標準ライブラリだけでMCP stdioサーバーを作る

TT Labで続きを見る

目標

json・sys・sqlite3だけで、MCPサーバーを1本書きます。クライアントが送ってくるinitialize → notifications/initialized → tools/list → tools/callを順番に受け付け、最後にそのサーバーを子プロセスとして起動するクライアントまで書きます。

なぜ重要なのか

SDKを使えば5行で終わる作業です。しかし事故は、SDKが隠してくれていた部分で起きます。stdoutに出力したデバッグ用の1行がクライアントのJSONパーサーを壊し、通知に返答を送って対応相手のいないレスポンスが生まれ、ツールが失敗したのにプロトコルエラーで返したためにモデルが「サーバーが壊れた」と判断してしまいます。フレーミング(1行 = 1メッセージ)、idによる対応付け、通知の無視、2種類のエラーの区別。この4つがMCPの実体であり、手で一度組んでみると、SDKのログが何を言っているのか読めるようになります。

ステップ

  1. シードSQL(/root/mcp/seed.sql)を保存し、DB(/root/mcp/shop.db)へ読み込んでください。customersが5行、ordersが8行になっている必要があります。
  2. サーバーのファイル(/root/mcp/server.py)を作成してください。stdinから1行ずつJSON-RPCリクエストを読み、initializeには、protocolVersionが2025-06-18で、capabilities.toolsとserverInfo.nameを持つresultで返答する必要があります。
  3. 同じファイルで、通知(idのないメッセージ)には返答せず、未知のメソッドには-32601、壊れたJSONには-32700のエラー(idはnull)で返答するようにしてください。サーバーが落ちてはいけません。
  4. tools/listにツールを2つ返してください。list_customersとcount_orders(inputSchema.properties.status、文字列、required)です。各ツールに、descriptionと、type: objectのinputSchemaが必要です。
  5. tools/callを実装してください。count_ordersに{"status":"paid"}を渡すと、DBのpaidの件数がcontent[0].textに入っている必要があり、list_customersは5人の顧客名をすべて含める必要があります。DBのパスは、環境変数MCP_DBがあればそれを、なければ/root/mcp/shop.dbを使います。
  6. エラーを2種類に分けてください。存在しないツール名にはJSON-RPCエラー-32602を返し、count_ordersに未知の状態値(例: banana)を渡したときは、resultにisError: trueと説明のテキストを入れて返す必要があります。
  7. クライアントのファイル(/root/mcp/client.py)を作成してください。サーバーをsubprocessで起動し、initialize → notifications/initialized → tools/list → tools/call(count_orders、paid)を送って、結果を、tools(名前の一覧)とpaid_orders(レスポンスのテキスト)として、ファイル(/root/mcp/session.json)に書き込みます。環境変数MCP_SESSION_OUTがあれば、そのパスに書きます。
  8. サーバーのすべてのログをstderrに送ってください。リクエストごとにメソッド名がstderrに1行ずつ出力され、stdoutにはJSONのレスポンス以外に何も出てはいけません。

参考

店のDBを作る

シードSQL(/root/mcp/seed.sql)を保存し、DB(/root/mcp/shop.db)へ読み込んでください。customersが5行、ordersが8行になっている必要があります。

sqlite3では、sqlite3 shop.db < seed.sqlでファイルをまるごと実行します。Pythonで行うなら、sqlite3.connect(...).executescript(open(...).read())です。すでにあるDBに再度読み込むと、テーブルが存在するというエラーになるので、先に削除してください。

initializeに返答する

サーバーのファイル(/root/mcp/server.py)を作成してください。stdinから1行ずつJSON-RPCリクエストを読み、initializeには、protocolVersionが2025-06-18で、capabilities.toolsとserverInfo.nameを持つresultで返答する必要があります。

骨組みの例のループで、method == "initialize"のときにresultの辞書を作り、reply(msg["id"], result=...)で送ります。resultには、protocolVersion、capabilities(toolsキーを持つオブジェクト)、serverInfo(name・version)の3つが入ります。

通知には返答しない

同じファイルで、通知(idのないメッセージ)には返答せず、未知のメソッドには-32601、壊れたJSONには-32700のエラー(idはnull)で返答するようにしてください。サーバーが落ちてはいけません。

JSON-RPCでは、通知はidメンバーのないリクエストであり、サーバーは返答してはいけません。json.loadsをtryで囲み、JSONDecodeErrorならidをnullにして-32700を送り、continueしてください。それ以外の例外も捕まえて-32603で返答すれば、サーバーは生き残ります。

ツール一覧を返す

tools/listにツールを2つ返してください。list_customersとcount_orders(inputSchema.properties.status、文字列、required)です。各ツールに、descriptionと、type: objectのinputSchemaが必要です。

resultは{"tools": [...]}で、ツール1つはname・description・inputSchemaの3つのキーを持ちます。inputSchemaはJSON Schemaのオブジェクトなので、"type": "object"とpropertiesを持ちます。引数のないツールにも、properties: {}は置きます。

ツールを実際に実行する

tools/callを実装してください。count_ordersに{"status":"paid"}を渡すと、DBのpaidの件数がcontent[0].textに入っている必要があり、list_customersは5人の顧客名をすべて含める必要があります。DBのパスは、環境変数MCP_DBがあればそれを、なければ/root/mcp/shop.dbを使います。

paramsは{"name": 도구이름, "arguments": {...}}の形です(プレースホルダーはツール名です)。結果は{"content": [{"type": "text", "text": "..."}]}の形にしてください。os.environ.get("MCP_DB", "/root/mcp/shop.db")でパスを決め、リクエストごとにsqlite3.connectして閉じてください。

エラーを2種類に分ける

エラーを2種類に分けてください。存在しないツール名にはJSON-RPCエラー-32602を返し、count_ordersに未知の状態値(例: banana)を渡したときは、resultにisError: trueと説明のテキストを入れて返す必要があります。

仕様は、「未知のツール・不正な引数」をプロトコルエラー(errorメンバー、例のコードは-32602)、「ツールが実行されている途中で失敗したもの」をツール実行エラー(result内のisError: true)として区別しています。後者は、モデルが読んで再試行できるように、テキストで理由を書きます。

クライアントで1つのセッションを通す

クライアントのファイル(/root/mcp/client.py)を作成してください。サーバーをsubprocessで起動し、initialize → notifications/initialized → tools/list → tools/call(count_orders、paid)を送って、結果を、tools(名前の一覧)とpaid_orders(レスポンスのテキスト)として、ファイル(/root/mcp/session.json)に書き込みます。環境変数MCP_SESSION_OUTがあれば、そのパスに書きます。

subprocess.Popen([...], stdin=PIPE, stdout=PIPE, text=True)で起動し、送るたびにstdin.flush()し、受け取るときはstdout.readline()で1行をjson.loadsします。通知を送ったあとはreadlineしないでください。返答が来ないので永遠に待つことになります。終了するときは、stdinを閉じてwait()します。

stdoutをプロトコル専用にする

サーバーのすべてのログをstderrに送ってください。リクエストごとにメソッド名がstderrに1行ずつ出力され、stdoutにはJSONのレスポンス以外に何も出てはいけません。

sys.stderr.write(...)またはprint(..., file=sys.stderr)を使います。仕様(stdioトランスポート)は、サーバーがstdoutに有効なMCPメッセージ以外を書いてはならず、stderrはログ用途に使ってよいと定めています。採点ツールは、stdoutのすべての行をJSONとしてパースしてみます。