クリーム色の背景、色付き表示、高コントラスト、大きな文字のほうが読みやすいですか?配色と文字を選ぶと、すべてのページに一度で反映されます。

無料レッスン

MCP を手で書く: stdio 上の JSON-RPC

このレッスンのあとで: ツールサーバーとそのクライアントが交わすメッセージを、読んで書ける。

70分のレッスンコマンド6件AI コーディングエージェントのラボ:opencode でコードを説明・修正し、その結果を確かめる

ツールサーバーが実際に送るもの

Model Context Protocol (MCP) は、エージェントが別のプログラムのツールを見つけて呼ぶための、標準の方法です。メッセージは素朴な JSON で、標準入出力に1行ずつ流れます。メッセージが読めれば、どのツールサーバーも調べられます。

単独では動くのに、あなたのエージェントとは動かないツールサーバーは、たいてい、どれか1つのメッセージの形が違っています。

このレッスンのゴール

メモ

ツールサーバーとそのクライアントが交わすメッセージを、読んで書ける。

キーワード

ことばいみれい
JSON-RPCメソッドを呼ぶための、JSON のメッセージ形式
リクエストid を持ち、応答を待つメッセージ
通知id がなく、応答もないメッセージ
stdioプロセスの標準入力と標準出力
エラーコード失敗の種類を表す数-32601

学生が入力するセルは、このプレビューでは空白です。

JSON-RPC のメッセージは3種類

リクエストは、id、メソッド名、省略できる引数を持ちます。応答は、その id を写し、結果かエラーのどちらかを持ちます。通知は id を持たないので、何も応答しません。

stdio では、1つのメッセージが JSON の1行です。サーバーは、出力にメッセージだけを書き、ログはエラー出力に送ります。

例: 3つのメッセージ

種類例応答
リクエスト{"jsonrpc":"2.0","id":2,"method":"tools/list"}id が 2 の応答
応答{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}なし
通知{"jsonrpc":"2.0","method":"notifications/initialized"}なし

学生が入力するセルは、このプレビューでは空白です。

形であって、プロトコルの全部ではない

重要

下のサーバーは、本物のプロトコルの形を小さくしたものです。本物のサーバーは公式の SDK を使い、バージョン、取り消し、ほかの内容の種類、多くのメソッドも扱います。ここでは何も入れません。

ツールサーバーを書く(1)

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const PRICES = { \"おにぎり\": 150, \"お茶\": 140 };' 'const TOOLS = [{ name: \"get_price\", description: \"店の商品の値段を円で返す\", inputSchema: { type: \"object\", properties: { item: { type: \"string\" } }, required: [\"item\"] } }];' 'function send(message) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, message)) + \"\n\"); }' 'function reply(id, result) { send({ id: id, result: result }); }' 'function fail(id, code, text) { send({ id: id, error: { code: code, message: text } }); }' 'function text(words, isError) { return { content: [{ type: \"text\", text: words }], isError: isError }; }' > server.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

ツールサーバーを書く(2)

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function call(id, params) {' '  if (params.name === \"get_price\") {' '    const price = PRICES[(params.arguments || {}).item];' '    if (price === undefined) return reply(id, text(\"不明な商品\", true));' '    return reply(id, text(price + \" 円\", false));' '  }' '  return fail(id, -32602, \"不明なツール: \" + params.name);' '}' 'function handle(msg) {' '  if (msg.id === undefined) return;' '  if (msg.method === \"initialize\") return reply(msg.id, { protocolVersion: \"2024-11-05\", capabilities: { tools: {} }, serverInfo: { name: \"shop\", version: \"0.1\" } });' '  if (msg.method === \"tools/list\") return reply(msg.id, { tools: TOOLS });' '  if (msg.method === \"tools/call\") return call(msg.id, msg.params || {});' '  return fail(msg.id, -32601, \"メソッドが見つかりません: \" + msg.method);' '}' >> server.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

ツールサーバーを書く(3)

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'require(\"readline\").createInterface({ input: process.stdin }).on(\"line\", function (line) {' '  try { handle(JSON.parse(line)); } catch (error) { fail(null, -32700, \"解析エラー\"); }' '});' >> server.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

台本付きのクライアントを書く(1)

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const server = require(\"child_process\").spawn(\"node\", [\"server.js\"]);' 'const call = function (n, name, args) { return { id: n, method: \"tools/call\", params: { name: name, arguments: args } }; };' 'const script = [' '  { id: 1, method: \"initialize\", params: { protocolVersion: \"2024-11-05\", capabilities: {}, clientInfo: { name: \"demo\", version: \"0.1\" } } },' '  { method: \"notifications/initialized\" },' '  { id: 2, method: \"tools/list\" },' '  call(3, \"get_price\", { item: \"お茶\" }),' '  call(4, \"get_price\", { item: \"キウイ\" }),' '  call(5, \"delete_all\", {}),' '  { id: 6, method: \"resources/list\" },' '];' > client.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

台本付きのクライアントを書く(2)

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'let waiting = 6;' 'require(\"readline\").createInterface({ input: server.stdout }).on(\"line\", function (line) {' '  console.log(\"<- \" + line);' '  waiting -= 1;' '  if (waiting === 0) server.stdin.end();' '});' 'script.forEach(function (m) {' '  const full = Object.assign({ jsonrpc: \"2.0\" }, m);' '  console.log(\"-> \" + JSON.stringify(full));' '  server.stdin.write(JSON.stringify(full) + \"\n\");' '});' >> client.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

クライアントをサーバーに向けて実行

コマンド
docker exec -w /home/node/project agent node client.js

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

出力の一例

リクエスト応答
initializeプロトコルのバージョンとサーバー名を持つ結果
tools/listget_price を並べた結果
お茶の get_price「140 円」の文字列を持つ結果
キウイの get_priceisError が true で、「不明な商品」の結果
delete_allコード -32602 のエラー
resources/listコード -32601 のエラー

学生が入力するセルは、このプレビューでは空白です。

セッションには順序がある

クライアントは、まず initialize を送り、サーバーは、できることを答えます。次にクライアントは、準備ができたという通知を送ります。そのあとで初めて、tools/list を頼み、tools/call を使います。

エージェントは、一覧にあるツールをモデルに見せます。モデルがツールを求めたら、クライアントが tools/call を送ります。

失敗には2種類ある

プロトコルのエラーは、JSON-RPC のエラー応答です。リクエスト自体が間違っています。不明なメソッド、不明なツール、読めない JSON などです。それぞれコードがあり、メソッドがないときは -32601 です。

ツールのエラーは、isError が true の普通の結果です。呼び出しは正しかったのに、ツールができなかったのです。モデルは文章を読んで、別の方法を試せます。

よくある間違い

注意

「失敗はすべて JSON-RPC のエラーにすべきだ」と考える人が多くいます。エラー応答は、リクエストの形が間違っていると伝えるものです。動いたのに商品が見つからなかったツールは、isError が true の結果を返します。そうすれば、モデルは理由が分かり、立て直せます。

このラボとソースについて: AI コーディングエージェントのラボ:opencode でコードを説明・修正し、その結果を確かめる

授業で使いますか?このモジュールを学生に割り当てると、教員用ガイド、自動採点つきの課題、学生ごとのラボを使えます。