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

無料レッスン

MCPメッセージを読み書きする

このレッスンのあとで: サーバーとクライアントが交わす JSON のメッセージを、読み書きできる。

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

サーバーはどう受け取るか

MCPサーバーは、入力から JSON のメッセージを読み、出力へ書き出すプログラムです。メッセージは一行に一つです。

ここの扱いが雑だと、手元では動いても、本物のクライアントとはうまくいきません。モジュール 67 のレッスン 5 はクライアント側でした。ここでは、サーバーの読み込みの輪を書きます。

このレッスンのゴール

メモ

サーバーとクライアントが交わす JSON のメッセージを、読み書きできる。

キーワード

ことばいみれい
バッファ一行が揃うまで取っておくテキスト
フレーム流れから改行で切り出した一つのメッセージ
stdout出力の流れ。メッセージだけを書く
stderrエラーの流れ。ログはここに書く
Parse error行が JSON ですらなかった-32700
Invalid RequestJSON だが、要求ではない-32600

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

流れは行に切られる

入力は、完全なメッセージの形では届きません。断片で届きます。一つの断片が、メッセージの半分のことも、一つ分のことも、二つ半のこともあります。

サーバーはバッファを持ちます。断片を足し、完全な行を切り出し、残りは取っておきます。メッセージの中に生の改行はないので、改行は必ずメッセージの終わりです。

例: 三つの断片

届いた断片そのあとのバッファ揃ったメッセージ
{"id":1} {"id{"id1 件: {"id":1}
":2} (空)1 件: {"id":2}
{"i{"iまだない

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

悪い行にも答える

JSON でない行には、parse error を返します。id が読めないので、id は null です。

JSON だが要求でない行には、Invalid Request を返します。id が読めるなら、それを写します。id のない要求は通知で、知らないメソッドの通知にも、何も返しません。

サーバーが返すもの

行答え
not json at allエラー -32700、id は null
{"jsonrpc":"2.0","id":5,"method":5}エラー -32600、id は 5
[1,2]エラー -32600、id は null
{"jsonrpc":"2.0","method":"nonsense"}何も返さない(通知)

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

答えは順不同で返る

遅い要求が、速い要求を待たせてはいけません。サーバーは、答えができたものから返します。

だから、クライアントは位置で答えを結べません。id で結びます。サーバーが id を、数字なら数字、文字なら文字のまま、正確に写すのはそのためです。

よくある間違い

注意

確認のために print を書く人は多いです。stdout に出した行はメッセージではないので、クライアントはそこで失敗します。stdout にはメッセージだけを書きます。ログは stderr に書けば、クライアントは見ることも、無視することもできます。

モデル用の補助を書く

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' 'async function ask(messages, sample, max) {' '  try {' '    const res = await fetch(base + \"/v1/chat/completions\", { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" }, body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 80 }), signal: AbortSignal.timeout(30000) });' '    if (res.ok === false) throw new Error(\"status \" + res.status);' '    const data = await res.json();' '    return { text: data.choices[0].message.content, recorded: false };' '  } catch (error) {' '    console.log(\"記録済みサンプル: モデルが答えませんでした (\" + error.message + \")。\");' '    return { text: sample, recorded: true };' '  }' '}' 'module.exports = { ask };' > model.js"

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

サーバーを書く(1)

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'let buffer = \"\";' 'function send(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'function fail(id, code, text) { send({ id: id, error: { code: code, message: text } }); }' 'function log(text) { process.stderr.write(\"log: \" + text + \"\n\"); }' > server1.js"

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

サーバーを書く(2)

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function handle(line) {' '  let msg;' '  try { msg = JSON.parse(line); } catch (error) { return fail(null, -32700, \"Parse error\"); }' '  const seen = msg && (typeof msg.id === \"number\" || typeof msg.id === \"string\") ? msg.id : null;' '  const good = msg && typeof msg === \"object\" && msg.jsonrpc === \"2.0\" && typeof msg.method === \"string\";' '  if (Boolean(good) === false) return fail(seen, -32600, \"Invalid Request\");' '  log(\"got \" + msg.method);' '  if (msg.id === undefined) return;' '  const ms = (msg.params || {}).ms || 0;' '  if (msg.method === \"ping\") return send({ id: msg.id, result: {} });' '  if (msg.method === \"wait\") return setTimeout(function () { send({ id: msg.id, result: { waited: ms } }); }, ms);' '  fail(msg.id, -32601, \"Method not found: \" + msg.method);' '}' >> server1.js"

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

サーバーを書く(3)

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'process.stdin.on(\"data\", function (chunk) {' '  buffer += chunk.toString();' '  let at = buffer.indexOf(\"\n\");' '  while (at >= 0) {' '    const line = buffer.slice(0, at);' '    buffer = buffer.slice(at + 1);' '    if (line.trim().length > 0) handle(line);' '    at = buffer.indexOf(\"\n\");' '  }' '});' 'log(\"ready\");' >> server1.js"

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

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

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const server = require(\"child_process\").spawn(\"node\", [\"server1.js\"]);' 'const m = function (obj) { return JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, obj)); };' 'const wait = function (id, ms) { return m({ id: id, method: \"wait\", params: { ms: ms } }) + \"\n\"; };' 'const ping = m({ id: 3, method: \"ping\" }) + \"\n\";' 'const bad = \"not json at all\n\" + m({ id: 5, method: 5 }) + \"\n\" + m({ method: \"notifications/x\" }) + \"\n\";' 'const steps = [[0, wait(1, 80)], [10, wait(2, 10)], [20, ping.slice(0, 12)], [40, ping.slice(12) + bad]];' > client1.js"

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

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

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function start() {' '  steps.forEach(function (s) {' '    setTimeout(function () { console.log(\"-> \" + s[1].trim().split(\"\n\").join(\" | \")); server.stdin.write(s[1]); }, s[0]);' '  });' '  setTimeout(function () { server.stdin.end(); }, 200);' '}' 'require(\"readline\").createInterface({ input: server.stderr }).on(\"line\", function (line) {' '  console.log(\"stderr \" + line);' '  if (line === \"log: ready\") start();' '});' 'require(\"readline\").createInterface({ input: server.stdout }).on(\"line\", function (line) { console.log(\"<- \" + line); });' >> client1.js"

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

台本のクライアントを書く(3)

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'server.on(\"close\", async function () {' '  const q = \"応答が要求の id を持たなければならないのは、なぜですか。一文で答えてください。\";' '  const r = await ask([{ role: \"user\", content: q }], \"答えは順番どおりに届くとは限らないので、クライアントが、どの要求への答えかを見分けられるようにするためです。\", 60);' '  console.log(\"モデル: \" + r.text);' '});' >> client1.js"

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

クライアントをサーバーに向けて動かす

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

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

クライアントがすること

クライアントはサーバーを起動し、stderr に ready の行が出るのを待ちます。そして、遅い要求を二つ、ping の前半、残りの ping と三つの行を順に送ります。

最後に、モデルへ一文を書かせる呼び出しをします。モデルがないと、その行は「記録済みサンプル」になります。

届いた順の答え

<- で始まる行その順に届いた理由
id 2、waited 10短い待ちが先に終わった
id 3、空の result後半が届いて、ping が揃った
id null、エラー -32700行 "not json at all"
id 5、エラー -32600メソッドが数字だった
id 1、waited 80長い待ちが最後に終わった

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

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

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