0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCP入門、TypeScript SDKで最小サーバーを作ると新仕様は既定でオフ

0
Posted at

はじめに

MCP(Model Context Protocol)のサーバーを自作しようとすると、最初に迷うのが「どの版の仕様に合わせて書くか」です。2026-07-28 版の仕様では、これまで必須だった initialize ハンドシェイクが廃止されました。一方で、公式 TypeScript SDK で最小構成に書いたサーバーは、今も initialize を受け付けて普通に動きます。

では、いま npm install した SDK で最小のサーバーを書くと、そのサーバーは実際にどの版のプロトコルを話すのでしょうか。足し算ツールを 1 つだけ持つ stdio サーバーを v1 系と v2 系の SDK で書いて動かし、生の JSON-RPC まで覗いて確かめました。

対象読者は、MCP サーバーを初めて自作する TypeScript / JavaScript の開発者です。Claude Code などの MCP クライアントを使ったことはあっても、サーバー側のやり取りはまだ見たことがない方を想定しています。

実行した環境

  • 実行環境: クラウドの Linux 実行環境、Node.js v22.22.2
  • v1 系: @modelcontextprotocol/sdk 1.31.0 と zod 4.6.5
  • v2 系: @modelcontextprotocol/server 2.2.0 と zod 4.6.5
  • 確認日: 2026-09-29(JST)

コードは型注釈なしの JavaScript(ESM・拡張子 .mjs)で書き、Node.js で直接実行しました。使っているのは公式の TypeScript SDK ですが、今回は tsc でのビルドを省いています。

TL;DR(この記事でできるようになること)

  • 公式 TypeScript SDK で、ツール 1 個だけの最小 MCP サーバー(stdio)を書き、SDK のクライアントから tools/list と tools/call を呼べるようになります
  • 引数ミスが例外ではなく isError: true の結果として返ることを前提に、呼び出し側の判定を組めるようになります
  • 2026-07-28 仕様の主な変更点と、SDK の既定構成がまだ 2025-11-25 を話す理由(v1 は実装なし、v2 はオプトイン)を説明できるようになります

MCP サーバーの構成を先に押さえる

ざっくり言うと、MCP サーバーは「AI アプリから呼べる関数(ツール)の一覧を、決まった形式で公開する小さなプロセス」です。やり取りには JSON-RPC 2.0(リクエストとレスポンスを JSON で表す軽量な RPC 形式)を使います。

stdio トランスポートでは、クライアントがサーバーを子プロセスとして起動し、標準入出力でメッセージを送り合います。仕様では、サーバーは stdin から JSON-RPC メッセージを読んで stdout に書き、メッセージは改行区切りで 1 行に 1 つ置くと定められています(stdio トランスポートの仕様)。

今回作るのは図の右半分です。ツールは「2 つの数を足す add」の 1 つだけに絞り、プロトコルの挙動を観察しやすくしています。

手順

1. プロジェクトを用意して SDK の版を確かめる

mkdir mcp && cd mcp
npm init -y
npm install @modelcontextprotocol/sdk zod

あわせて、npm 上の最新版も確認しました。

$ npm view @modelcontextprotocol/sdk version dist-tags --json
{ "version": "1.31.0", "dist-tags": { "latest": "1.31.0" } }

latest は v1 系の 1.31.0 でした。v2 系は @modelcontextprotocol/server と @modelcontextprotocol/client という別名のパッケージで配布されているため、@modelcontextprotocol/sdk を入れると v1 系が入ります。

2. サーバーを書く(server.mjs)

// server.mjs
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "mini-demo", version: "0.1.0" });
server.registerTool("add", {
  description: "2つの数を足す",
  inputSchema: { a: z.number(), b: z.number() },
}, async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }));
await server.connect(new StdioServerTransport());

押さえる点は 4 つです。

  • McpServer の第 1 引数はサーバーの名前と版です。クライアントからは serverInfo として見えます
  • registerTool にツール名・説明・入力スキーマ・ハンドラーを渡します。入力スキーマは zod で書き、SDK が JSON Schema に変換して公開します
  • ハンドラーは content 配列を返します。ここでは計算結果を文字列にした text を 1 つだけ入れています
  • 最後の connect で stdio トランスポートにつなぐと、プロセスは stdin からの入力を待ち続けます

stdio サーバーでは、stdout が JSON-RPC 専用の通り道になります。仕様は「サーバーは有効な MCP メッセージ以外を stdout に書いてはならない(MUST NOT)」とし、ログは stderr に書いてよい(MAY)としています。デバッグ出力には console.log ではなく console.error を使ってください。

3. クライアントから呼び出す(client.mjs)

動作確認には、同じ SDK に入っているクライアントを使いました。

// client.mjs
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const t = new StdioClientTransport({ command: "node", args: ["server.mjs"] });
const c = new Client({ name: "demo-client", version: "0.0.1" });
await c.connect(t);
console.log("serverVersion:", JSON.stringify(c.getServerVersion()));
console.log("serverCaps:", JSON.stringify(c.getServerCapabilities()));
console.log("tools/list:", JSON.stringify(await c.listTools()));
console.log("call ok:", JSON.stringify(await c.callTool({ name: "add", arguments: { a: 2, b: 3 } })));
try { console.log("call bad:", JSON.stringify(await c.callTool({ name: "add", arguments: { a: "x", b: 3 } }))); }
catch (e) { console.log("call bad THROW:", e.code, e.message); }
await c.close();

StdioClientTransport が node server.mjs を子プロセスとして起動し、connect() の中でハンドシェイクを済ませます。getServerVersion() でサーバー名が取れているのは、このハンドシェイクの応答を受け取っているからです。

実行結果は次のとおりです。

$ node client.mjs
serverVersion: {"name":"mini-demo","version":"0.1.0"}
serverCaps: {"tools":{"listChanged":true}}
tools/list: {"tools":[{"name":"add","description":"2つの数を足す","inputSchema":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"$schema":"http://json-schema.org/draft-07/schema#"},"execution":{"taskSupport":"forbidden"}}]}
call ok: {"content":[{"type":"text","text":"5"}]}
call bad: {"content":[{"type":"text","text":"MCP error -32602: Input validation error: Invalid arguments for tool add: Invalid input: expected number, received string at a"}],"isError":true}

zod で書いた { a: z.number(), b: z.number() } は、required: ["a","b"] 付きの JSON Schema になって tools/list に載っています。capabilities の指定はコードに書いていませんが、ツールを登録しただけで tools.listChanged: true が広告されていました。2 + 3 の呼び出しは、文字列 "5" の text として返っています。

4. 引数を間違えたときの返り方を確かめる

a に文字列 "x" を渡した最後の呼び出しは、try で囲んでいたにもかかわらず catch 側(call bad THROW: の行)に入りませんでした。結果は JSON-RPC のエラーではなく、通常の結果の中に isError: true を立てた形 で返っています。

これは SDK の癖ではなく、仕様に沿った挙動です。2025-11-25 版の changelog には、入力バリデーションのエラーはモデルが自分で直せるように、プロトコルエラーではなくツール実行エラーとして返すと明記されています(SEP-1303)。エラーの文面が LLM に届けば、LLM は引数を直して呼び直せます。

裏返すと、呼び出し側で例外だけを見ていると引数ミスを成功扱いしてしまいます。自前のクライアントやテストを書くときは、callTool の戻り値の isError を必ず確認し、true なら content の text をエラー内容として扱ってください。

5. 生の JSON-RPC で版の交渉を覗く

SDK のクライアントは版の交渉を内部で済ませてしまうため、サーバーの stdin に JSON-RPC を 1 行ずつ直接流し込んで応答を観察しました。送ったのは次の 4 つです。

順 送ったメッセージ 主な中身 サーバーの応答
1 initialize(id 1) protocolVersion に 2026-07-28 を指定 protocolVersion: "2025-11-25" で応答
2 notifications/initialized 通知なので id なし 応答なし
3 tools/call(id 2) a に文字列を渡す isError: true の結果
4 server/discover(id 3) 2026-07-28 で追加されたメソッド -32601 Method not found
{"jsonrpc":"2.0","id":3,"error":{"code":-32601,"message":"Method not found"}}
{"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"mini-demo","version":"0.1.0"}},"jsonrpc":"2.0","id":1}
{"result":{"content":[{"type":"text","text":"MCP error -32602: Input validation error: ..."}],"isError":true},"jsonrpc":"2.0","id":2}

3 行目のエラー本文は省略しています。応答は非同期に返るため、出力の順番は送信順と一致しません。id で対応を取ってください。

2026-07-28 を要求しても、サーバーは 2025-11-25 で応答しました。initialize に 2025-11-25 を入れて送った場合も応答は 2025-11-25 でした。2025-11-25 版のライフサイクル仕様では、サーバーは要求された版に対応していなければ自分が対応する別の版で応答し、それは対応する最新版であるべき(SHOULD)とされています。クライアントは応答された版に対応していなければ切断すべき(SHOULD)です。今回の応答はこの取り決めどおりです。

SDK 1.31.0 が対応している版は、同梱コードに定数として書かれていました。

// node_modules/@modelcontextprotocol/sdk/dist/esm/types.js(1.31.0 の該当行)
export const LATEST_PROTOCOL_VERSION = '2025-11-25';
export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07'];

5 つの版のうち最新が 2025-11-25 で、2026-07-28 は含まれていません。実測でも server/discover は -32601 を返しました。v1 系の 1.31.0 は、新仕様を話さない SDK と判断できます。

v1 と v2 で変わる書き方

v2 系でも同じサーバーを書いて動かしました。インストールは npm install @modelcontextprotocol/server @modelcontextprotocol/client zod です。

// server.mjs(v2 系)
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const server = new McpServer({ name: "mini-demo-v2", version: "0.1.0" });
server.registerTool("add", { description: "add", inputSchema: z.object({ a: z.number(), b: z.number() }) },
  async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }));
await server.connect(new StdioServerTransport());

生の JSON-RPC で確認した違いを表にしました。

項目 v1(@modelcontextprotocol/sdk 1.31.0) v2(@modelcontextprotocol/server 2.2.0)
パッケージ構成 単一パッケージ server と client に分割
McpServer の import 元 @modelcontextprotocol/sdk/server/mcp.js @modelcontextprotocol/server
今回渡した inputSchema 素のシェイプ { a: z.number(), ... } z.object({ ... })
tools/list の $schema draft-07 2020-12
引数ミスの結果 isError: true(本文は MCP error -32602: ...) isError: true(本文は Input validation error: ...)
initialize への応答版 2025-11-25 2025-11-25
server/discover -32601 -32601(今回の書き方の場合)

$schema の違いは仕様の変化と対応しています。2025-11-25 版の changelog は JSON Schema 2020-12 を MCP の既定の方言にしたと書いており(SEP-1613)、v2 の出力はそれに揃っています。

v2 では、2026-07-28 の protocolVersion を _meta に入れた生リクエストも送ってみました。

{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}
{"result":{"tools":[{"name":"add",...,"$schema":"https://json-schema.org/draft/2020-12/schema",...}]},"jsonrpc":"2.0","id":2}
{"result":{"protocolVersion":"2025-11-25",...},"jsonrpc":"2.0","id":5}

id 1 が server/discover、id 2 が initialize 前に送った tools/list、id 5 が 2025-11-25 を指定した initialize です(... は省略箇所)。server/discover は v1 と同じく -32601 で、tools/list は initialize を経ずに返ってきたものの、新仕様で必須になる resultType や ttlMs、cacheScope は付いていませんでした。

利用状況にも差があります。npm の公開ダウンロード API によると、2026-09-21〜09-27 の 1 週間で v1 の @modelcontextprotocol/sdk は 64,725,178 回(API)、v2 の @modelcontextprotocol/server は 7,834,753 回(API)ダウンロードされており、約 8 倍の開きがありました。v2 の 2.0.0 は 2026-07-28 08:55(JST)に公開されています(npm registry)。

2026-07-28 仕様で変わること(公式 changelog より)

ここからは実機では動かしていない内容で、2026-07-28 版の changelog に基づく整理です。

変更 公式 changelog の記述の要点 関連
ハンドシェイク廃止 initialize と notifications/initialized を削除。各リクエストの _meta に版と client capabilities を載せ、版の不一致は UnsupportedProtocolVersionError で返す SEP-2575
セッション廃止 プロトコル上のセッションと Mcp-Session-Id ヘッダを削除。呼び出しをまたぐ状態は、サーバーが発行したハンドルを通常のツール引数で受け渡す SEP-2567
server/discover サーバーは実装必須(MUST)。対応版・capabilities・識別情報を返す SEP-2575
MRTR サーバー発のリクエスト(sampling/createMessage など)を、resultType: "input_required" の途中結果とクライアントの再送(inputResponses)に置き換える SEP-2322
resultType すべての result に必須。通常は "complete"。旧版サーバーの欠落は "complete" とみなす(MUST) SEP-2322
HTTP ヘッダ Streamable HTTP の POST に Mcp-Method と Mcp-Name を必須化 SEP-2243
キャッシュ tools/list などの結果に ttlMs と cacheScope を必須化 SEP-2549
認可 iss の検証、発行元ごとの資格情報の分離、DCR を非推奨にして Client ID Metadata Documents を推奨 SEP-2468 ほか
拡張 capabilities に extensions を追加。Tasks はコアから公式拡張 io.modelcontextprotocol/tasks へ移動 SEP-2663
非推奨・削除 Roots・Sampling・Logging を Deprecated に。ping と logging/setLevel は削除 SEP-2577 / SEP-2575

Deprecated になった機能は、非推奨期間(最低 12 か月)のあいだは動き続けますが、新しい実装では採用しないよう求められています。

流れの違いを図にすると次のようになります。上半分が今回実機で動かした形、下半分は仕様の記述に基づくもので、本記事では実行していません。

実測と仕様を突き合わせる

v2 は新仕様を実装済みだが、既定では使わない

既定構成の実測結果だけを見ると「SDK はまだ新仕様に対応していない」と結論したくなります。ところが v2 の同梱コードと README を読むと、実装はすでに入っていました。挙動だけで判断せず、ソースと移行ドキュメントまで当たる必要があったポイントです。

@modelcontextprotocol/server の GitHub 上の README(2026-09-29 時点)は、v2 を「2026-07-28 版の仕様を実装した stable 系列」と説明しています。そのうえで、SDK リポジトリの移行ドキュメント「Supporting protocol revision 2026-07-28」は次のように書いています。

Nothing in v2 puts a 2026-07-28 byte on the wire by default: a hand-constructed Client / Server / McpServer keeps speaking the 2025-era protocol it was written for.

今回の v2 サーバーは、まさに McpServer を手で組み立てて StdioServerTransport につないだ形です。実測で server/discover が -32601 になったことは、この移行ドキュメントの記述と整合します。ソースの実装を独立に照合したわけではないため、既定の対応版リストの中身までは断定しません。

新仕様を話させる入口として、同梱コードには次の 2 つが用意されています(いずれも本記事では未実行)。

  • サーバー側: @modelcontextprotocol/server/stdio の serveStdio(factory)。JSDoc には「接続の最初のやり取りで世代(era)を決め、ファクトリから作った 1 つのインスタンスを接続の間固定する」と書かれ、例のコメントには「同じファクトリで両方の世代に応える」とあります
  • クライアント側: Client のオプション versionNegotiation。既定は mode: 'legacy' で、'auto' にすると接続時に server/discover で探り、旧世代のサーバーなら initialize に戻ります。{ pin: '2026-07-28' } は新仕様だけを受け入れます

serveStdio の JSDoc にある例は次のとおりです(コメントも原文のまま)。

import { serveStdio } from '@modelcontextprotocol/server/stdio';

serveStdio(() => {
    const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
    // register tools/resources/prompts once — the same factory serves both eras
    return server;
});

-32601 は「旧世代のサーバー」の合図になる

2026-07-28 版の stdio 仕様には、新旧両方に対応するクライアントのための判定手順があります。server/discover を最初に送り、応答の種類で相手の世代を見分けます。

仕様は、この判定を特定のエラーコードに結び付けてはならない(MUST NOT)としています。旧世代のサーバーは initialize 前の未知のリクエストに実装依存のエラー(よくあるのは -32601 か -32602)を返すか、何も返さないからです。今回 v1 と v2 の既定構成が返した -32601 は、この「それ以外のエラー」の典型例でした。

initialize 前の tools/list が通ったことの意味

v2 の既定構成は、initialize を送る前の tools/list に旧形式の結果を返しました。仕様の stdio のページは、次のような旧世代のサーバーがあると書いています。今回の tools/list が通った原因は調べていませんが、この記述と整合的な挙動でした。「旧世代のサーバーの中には、リクエストが initialize の後に来たかを検証せず、tools/call のような世代を区別しにくいメソッドを旧仕様のまま処理してしまうものがある」。だからこそ、新仕様だけに対応するクライアントにも、最初に server/discover で探ることが推奨されています(RECOMMENDED)。

実際に、今回の tools/list の応答には resultType がありませんでした。新仕様のクライアントは欠落を "complete" とみなす決まりなので、世代を確かめずに送ると、旧世代のサーバーの応答を新仕様の結果として受け取ってしまいかねません。探りを入れる手順が仕様に書かれている理由が、実際の応答から見えてきます。

新仕様を見据えて、いま書くサーバーで意識すること

changelog の記述から、今のうちに寄せておける設計を挙げます。

  • 呼び出しをまたぐ状態はセッションに持たせない: セッションが廃止されるため、状態が必要ならサーバーが発行した ID をツール引数で受け渡す形にしておくと移行が楽になります
  • ログは stderr へ: Logging 機能は Deprecated で、changelog は stdio なら stderr、それ以外は OpenTelemetry への移行を勧めています
  • Roots や Sampling を新しく使わない: Roots の代わりにツール引数・リソース URI・サーバー設定でパスを渡し、Sampling の代わりに LLM プロバイダーの API を直接呼ぶ移行先が示されています
  • tools/list の順序を固定する: 決定的な順序で返すべき(SHOULD)とされ、クライアント側のキャッシュや LLM のプロンプトキャッシュに効くと説明されています

今回の add サーバーは状態もログも持たないため、この観点では書き換えが要りません。新仕様への移行作業は、主に SDK 側の入口(serveStdio など)の選び方になります。

トラブルシューティング

Q1: 引数を間違えたのに例外にならない

A: 入力バリデーションのエラーは、仕様(2025-11-25 の changelog)上、ツール実行エラーとして isError: true 付きの結果で返すべきとされています。callTool の戻り値の isError を確認してください。

Q2: server/discover を送ると -32601 Method not found が返る

A: 相手のサーバーが 2025-11-25 以前の世代で動いています。v1 系 SDK には実装がなく、v2 系でも McpServer を手で組み立てて StdioServerTransport につないだ既定構成ではハンドラーが登録されません。新仕様を話させたい場合は、v2 の serveStdio などのオプトインの入口を検討してください。

Q3: サーバーにログを足したらクライアントとの通信が壊れた

A: stdio では stdout が JSON-RPC 専用です。仕様は MCP メッセージ以外を stdout に書くことを禁じているため、ログは console.error などで stderr に出してください。

おわりに

  • 公式 TypeScript SDK で最小の stdio サーバーを書くと、v1 系 1.31.0 も v2 系 2.2.0 も、既定では 2025-11-25 を話します
  • 引数ミスは JSON-RPC エラーではなく isError: true で返るので、呼び出し側は戻り値を見て判定します
  • v1 系は 2026-07-28 の実装を持たず、v2 系は、移行ドキュメントの記述では実装を持ちながら既定ではオフにしています。server/discover への -32601 は、仕様上「旧世代のサーバー」と判定する材料になります

次の一歩は、v2 の serveStdio とクライアントの versionNegotiation: { mode: 'auto' } を組み合わせ、同じ add ツールが 2026-07-28 の形式(resultType や ttlMs 付き)で返るかを確かめることです。

関連記事

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?