サーバーはどう受け取るか
MCPサーバーは、入力から JSON のメッセージを読み、出力へ書き出すプログラムです。メッセージは一行に一つです。
ここの扱いが雑だと、手元では動いても、本物のクライアントとはうまくいきません。モジュール 67 のレッスン 5 はクライアント側でした。ここでは、サーバーの読み込みの輪を書きます。
このレッスンのゴール
メモ
サーバーとクライアントが交わす JSON のメッセージを、読み書きできる。
キーワード
| ことば | いみ | れい |
|---|---|---|
| バッファ | 一行が揃うまで取っておくテキスト | |
| フレーム | 流れから改行で切り出した一つのメッセージ | |
| stdout | 出力の流れ。メッセージだけを書く | |
| stderr | エラーの流れ。ログはここに書く | |
| Parse error | 行が JSON ですらなかった | -32700 |
| Invalid Request | JSON だが、要求ではない | -32600 |
学生が入力するセルは、このプレビューでは空白です。
流れは行に切られる
入力は、完全なメッセージの形では届きません。断片で届きます。一つの断片が、メッセージの半分のことも、一つ分のことも、二つ半のこともあります。
サーバーはバッファを持ちます。断片を足し、完全な行を切り出し、残りは取っておきます。メッセージの中に生の改行はないので、改行は必ずメッセージの終わりです。
例: 三つの断片
| 届いた断片 | そのあとのバッファ | 揃ったメッセージ |
|---|---|---|
| {"id":1} {"id | {"id | 1 件: {"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 | 長い待ちが最後に終わった |
学生が入力するセルは、このプレビューでは空白です。