02. 最小のサーバー

ライブラリを使わず、JSON-RPC を手で組み立てて動かす

📅 作成: 2026-09-07 / 更新: 2026-09-07

この資料の内容

  1. なぜ手で書くのか
  2. 標準入出力でやりとりする
  3. 3 つのメソッドに答える
  4. 動かす
  5. stdout を汚さない

なぜ手で書くのか

MCP には公式の SDK があり、次の章ではそれを使います。ここであえて手で書くのには理由があります。

SDK を使うと 20 行ほどでサーバーが完成します。ところが、いざホストに登録して動かないとき、どこまで届いているのかが分かりません。プロセスは起動しているのか。初期化まで進んだのか。ツールの一覧は渡ったのか。SDK の内側で起きていることが見えないと、切り分けようがありません。

この章で JSON-RPC を 1 往復させておけば、以降どの章で詰まっても「どこまで来ているか」を自分で確かめられます。15 分の投資で、残りの時間が助かります。

SDK から入ると 自分で書いたコード 見える SDK の中 JSON-RPC の組み立て・初期化・エラー 見えない 動かないとき、どこで止まっているか分からない 手書きから入ると 自分で書いたコード JSON-RPC の組み立ても 初期化もエラーも 全部見える あとで SDK に移っても、中で何が起きているか分かる
この章で見た景色が、以降すべての章の土台になる。

標準入出力でやりとりする

MCP には運び方(トランスポート)が 2 つあります。ここで使うのは stdio です。

運び方やっていることいつ使うか
stdioホストがサーバーを子プロセスとして起動し、その標準入出力に JSON を 1 行ずつ流す手元の PC で動かすとき。まずこれ
Streamable HTTP1 つの URL に HTTP POST する別のマシンに置くとき、1 本を共有するとき

stdio の実体は改行区切りの JSONです。特別なライブラリは要りません。

ホスト 親プロセス サーバー 子プロセス(node) stdin リクエストの JSON が 1 行ずつ届く stdout 応答の JSON だけを書く stderr(ログはこちら) stdout は JSON-RPC 専用の通り道 ここに他のものを書くと通信が壊れる(第 5 章)
ログを出したくなったら stderr へ。stdout は 1 行 1 メッセージの通り道として空けておく。

受け取る側の骨組み

import { createInterface } from 'node:readline';

// stdout は JSON-RPC 専用。ログは stderr へ出す
function log(...args) {
	console.error('[minimal]', ...args);
}

// 1 メッセージ 1 行。末尾の改行が区切りになる
function send(message) {
	process.stdout.write(JSON.stringify(message) + '\n');
}

const rl = createInterface({ input: process.stdin });

rl.on('line', (line) => {
	if (!line.trim()) return;
	const request = JSON.parse(line);
	// ここで処理する
});

readline が改行で区切ってくれるので、こちらは 1 行ずつ受け取るだけです。

3 つのメソッドに答える

最低限のサーバーが答えるべきメソッドは 3 つです。

メソッド訊かれていることいつ来るか
initializeあなたは誰で、何ができますか接続の直後に 1 回
tools/listどんなツールを持っていますか初期化の後
tools/callこのツールを実行してくださいモデルが使うたび
initialize 名乗り合い(1 回) tools/list 品書きを渡す tools/call 実行する(何度でも) それ以外 -32601 を返す 通知(id が無いメッセージ)には応答しない 例: notifications/initialized は「初期化が済んだ」という知らせで、返事は要らない 返事を返すと、相手は身に覚えのない応答を受け取ることになる
通知とリクエストの見分けは id の有無だけ。ここを取り違えると、繋がっているのに動かない状態になる。

ツールの品書き

tools/list で返す内容です。inputSchema は JSON Schema で書きます。モデルはこの説明だけを頼りに呼ぶので、description は人に説明するつもりで書きます。

const TOOLS = [
	{
		name: 'add',
		description: '2 つの数を足す',
		inputSchema: {
			type: 'object',
			properties: {
				a: { type: 'number', description: '1 つめの数' },
				b: { type: 'number', description: '2 つめの数' },
			},
			required: ['a', 'b'],
		},
	},
];

3 つに答える

function handle(request) {
	const { id, method, params } = request;

	switch (method) {
		// ホストが最初に送ってくる。名乗り合いをする
		case 'initialize':
			return reply(id, {
				// 相手が送ってきたバージョンをそのまま返す
				protocolVersion: params?.protocolVersion ?? '2025-06-18',
				capabilities: { tools: {} },
				serverInfo: { name: 'minimal', version: '1.0.0' },
			});

		// 提供するツールの一覧を返す
		case 'tools/list':
			return reply(id, { tools: TOOLS });

		// ツールを実行する
		case 'tools/call': {
			if (params?.name !== 'add') {
				return replyError(id, -32602, `unknown tool: ${params?.name}`);
			}
			const { a, b } = params.arguments ?? {};
			if (typeof a !== 'number' || typeof b !== 'number') {
				// 引数が不正なときは、プロトコルのエラーではなく結果として返す
				return reply(id, {
					content: [{ type: 'text', text: 'a と b には数値を渡してください' }],
					isError: true,
				});
			}
			return reply(id, {
				content: [{ type: 'text', text: String(a + b) }],
			});
		}

		default:
			return replyError(id, -32601, `method not found: ${method}`);
	}
}

相手が言ってきた版をそのまま返します。params?.protocolVersion ?? '2025-06-18' としているのはそのためです。01. 背景 で見たとおり、ホストによって版が違います。自分の対応版を押し付けると、片方で繋がらなくなります。

ツールの失敗は isError で返します。JSON-RPC のエラー(error)にすると「サーバーが壊れた」扱いになり、モデルには理由が伝わりません。isError: true と本文を返せば、モデルはそれを読んで引数を直し、もう一度呼べます。直せる失敗は結果として返すのが原則です。

全体は samples/02-minimal/server.mjs にあります。90 行ほどです。

動かす

ホストに登録する前に、手で叩いて確かめます。標準入力に JSON を流し込むだけです。

@(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"手","version":"1.0"}}}'
'{"jsonrpc":"2.0","method":"notifications/initialized"}'
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}'
) -join "`n" | node server.mjs

実際の出力です。

[minimal] 起動した
[minimal] 受信: {"jsonrpc":"2.0","id":1,"method":"initialize",...}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"minimal","version":"1.0.0"}}}
[minimal] 受信: {"jsonrpc":"2.0","method":"notifications/initialized"}
[minimal] 通知: notifications/initialized
[minimal] 受信: {"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"add","description":"2 つの数を足す",...}]}}
[minimal] 受信: {"jsonrpc":"2.0","id":3,"method":"tools/call",...}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"5"}]}}

[minimal] が付いている行は stderr、付いていない行が stdout です。混ざって見えていても、実際には別の流れです。

端末で見ると [minimal] 起動した {"jsonrpc":"2.0","id":1,...} [minimal] 受信: ... {"jsonrpc":"2.0","id":2,...} 交互に並ぶ stdout {"id":1,...} {"id":2,...} ホストが読む stderr [minimal] 起動した [minimal] 受信: ... 人が読む
端末では 1 つに見えるが、ホストが読むのは stdout だけ。

stdout を汚さない

MCP サーバーで最も多い事故がこれです。

// これをやると壊れる
console.log('サーバーを起動しました');

console.log は stdout に書きます。stdout は JSON-RPC の通り道なので、ホストはこの行をメッセージとして読もうとします。JSON として読めないので、その接続は成立しません。

ホストが stdout から 1 行ずつ読む {"jsonrpc":"2.0","id":1,"result":{...}} 読めた サーバーを起動しました JSON として読めない {"jsonrpc":"2.0","id":2,"result":{...}} ここまで届かない 「登録したのにツールが出てこない」の原因は、たいていこれ
1 行の console.log で接続ごと落ちる。しかもホスト側には「繋がらない」としか出ない。

対策

やること書き方
ログを出すconsole.error(...) を使う。console.log は使わない
ライブラリの出力を抑える読み込んだライブラリが console.log する場合がある。疑わしければファイルに記録する形に変える
確かめる手で動かして、stdout に JSON 以外が出ていないか目で見る

この癖は stdio のときだけ必要です。07. HTTP に載せ替える で扱う HTTP 版では、標準出力は空いているので console.log を使えます。同じサーバーでも運び方で作法が変わります。

次の章へ

ここまでで、MCP サーバーが何をしているかは全部見えました。次は同じものを SDK で書き直し、何が省けるのかを確かめます。