Max Nardit
← 記事
14 mindev

MCP サーバーの作り方 2026 年版: TypeScript のファクトリー 1 つで両方のプロトコル世代に対応し、SDK の HTTP ハンドラーが開発者に残す部分

ファクトリー 1 つで、両方のトランスポートと両方の世代のクライアントに対応できます。テスト実行では、SDK のハンドラーは偽造した Origin に 200 で応答し、クライアントがキャッシュの基準にできる鮮度の期間も返しませんでした。Inspector は、新しいハンドシェイクを指定するまで古いハンドシェイクしか確認しませんでした。追加すべき 2 つのガード、設定すべきキャッシュヒント、そして最初のサーバーで省いてよいものを説明します。

目次

MCP サーバー構築の公式ガイドでは、8 つの言語のどれでも動く stdio サーバーまでたどり着けます。TypeScript 版は、2026-07-28 リビジョンで生じたプロトコルの分岐にもすでに対応しています。ただし HTTP での提供は扱っていません。そのリビジョンで最も大きく変わったのは HTTP の部分で、ほかの人がアクセスできるサーバーはそこに置くことになります。

2026 年 11 月 15 日に HTTP の部分を作り、テストしました。TypeScript SDK の HTTP ハンドラーは、新旧のクライアントを 1 つのエンドポイントで処理し、新しいヘッダーをボディと照合しました。一方で、Origin: https://evil.example を付けたリクエストに 200 で応答し、リストの結果を鮮度の期間なしで返し、MCP Inspector は既定の設定では古いハンドシェイクしかテストしませんでした。1 つ目の修正は 2 行、2 つ目はオプション 1 つ、3 つ目はフラグ 1 つです。バージョンは @modelcontextprotocol/server 2.4.0、@modelcontextprotocol/node 2.1.2、Inspector 2.15.0、Node 22.23.5 です。コマンドと出力はそのときの実行から取っています。出力を短くした箇所はそう書いています。

比較対象として、このサイトの読み取り専用 MCP エンドポイントを使います。10 月に手書きした 276 行の Next.js ルートです。このサイトに必要な範囲(ディスカバリーと 3 つのツール、サブスクリプションなし)をカバーしていて、SDK のハンドラーとの違いは出てきた時点で指摘します。

MCP 2026-07-28 はサーバーを作る側にとって何を変えたのか?

プロトコルをステートレスにしました。initialize ハンドシェイク、プロトコルセッション、Mcp-Session-Id ヘッダーはなくなりました。すべてのリクエストがプロトコルバージョンとクライアントの capabilities を _meta に入れて送り、サーバーは新しい server/discover メソッドを実装しなければなりません。HTTP では、クライアントがボディの一部をヘッダーにも写し、ヘッダーとボディが食い違うリクエストをサーバーは拒否しなければなりません。

サーバーの作者に関わる部分を、changelog と Streamable HTTP バインディングから挙げます。

  • 各リクエストは、バージョンを io.modelcontextprotocol/protocolVersion に、capabilities を io.modelcontextprotocol/clientCapabilities に入れます。どちらも params._meta の中です。
  • すべての結果に resultType が付きます。通常の結果では "complete" です。
  • tools/list などのリスト結果には ttlMs と cacheScope が必須です。鮮度のヒントと、キャッシュしたコピーを 1 つの認可コンテキストの外でも共有してよいかどうかを示します。
  • すべての HTTP POST に MCP-Protocol-Version と Mcp-Method が付き、tools/call、resources/read、prompts/get ではさらに Mcp-Name が付きます。ヘッダーが欠けているか一致しない場合は、エラーコード -32020(HeaderMismatch)の 400 Bad Request になります。
  • GET ストリームと ping は削除され、logging/setLevel も削除されました。ログレベルはリクエストごとに送るようになり、Logging 機能全体が非推奨です。新しいリビジョンだけを話すサーバーは、GET と DELETE に 405 で応答します。

仕様は、クライアントが一斉に移行するとは想定していません。バージョニングのページは、両方に応答するサーバーを「dual-era」と呼び、両者の見分け方を説明しています。リクエストごとの _meta を持つリクエストはステートレスに処理し、initialize リクエストなら古い動作を選びます。modern 専用のサーバーはレガシーなクライアントを 400 で断り、仕様はレガシーなクライアントには「fall-forward の仕組みがない」と注記しています。すでに使われているクライアントを接続させたいなら、両方に対応することになります。

TypeScript SDK で MCP サーバーをどう作るか?

新しい McpServer を作ってツールを登録する関数を 1 つ書き、その関数を、使うトランスポートに渡します。SDK はこの関数を HTTP リクエストごと、stdio 接続ごとに呼びます。stdio では、捨てるだけのディスカバリーのプローブのためにもう 1 回呼ぶこともあります。そのため共有インスタンスではなく、呼び出しの軽いファクトリーにしておく必要があります。

テストしたバージョンでプロジェクトをセットアップします。Inspector 2.15.0 のパッケージが宣言している Node は ^22.23.4、^24.22.1、>=26.12.1 で、ドキュメントのページに書かれている範囲より狭くなっています。

shell
mkdir notes-mcp && cd notes-mcp
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server@2.4.0 @modelcontextprotocol/node@2.1.2 zod@4.6.7
npm install -D typescript@7.0.2 @types/node

使った tsconfig.json です。

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "types": ["node"],
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

サーバー本体は src/server.ts です。ハードコードしたリストを対象にした読み取り専用のツールが 1 つだけで、例の焦点をデータベースではなく MCP に保っています。

ts
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
 
const NOTES = [
  { slug: "prompt-cache", title: "How prompt caching changes an agent bill" },
  { slug: "mcp-vs-api", title: "MCP vs a plain HTTP API" },
];
 
// One factory for every entry point: stdio calls it per connection,
// the HTTP handler calls it per request.
export function createServer() {
  const server = new McpServer(
    { name: "notes", version: "1.0.0" },
    {
      // Public, rarely changing data: let clients cache the lists.
      cacheHints: {
        "server/discover": { ttlMs: 3_600_000, cacheScope: "public" },
        "tools/list": { ttlMs: 3_600_000, cacheScope: "public" },
      },
    },
  );
 
  server.registerTool(
    "search_notes",
    {
      title: "Search notes",
      description:
        "Search note titles. Returns titles and canonical URLs, not full text.",
      inputSchema: z.object({
        query: z.string().min(1).max(100).describe('Words to look for, e.g. "prompt".'),
        limit: z.number().int().min(1).max(10).default(5),
      }),
      annotations: { readOnlyHint: true, openWorldHint: false },
    },
    async ({ query, limit }) => {
      const q = query.toLowerCase();
      const results = NOTES.filter((n) => n.title.toLowerCase().includes(q))
        .slice(0, limit)
        .map((n) => ({ title: n.title, url: `https://example.com/notes/${n.slug}` }));
      return {
        content: [{ type: "text", text: JSON.stringify({ results }) }],
        structuredContent: { results },
      };
    },
  );
 
  return server;
}

stdio のエントリーである src/stdio.ts は、公式ガイドに載っているものと同じです。

ts
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./server.js";
 
serveStdio(createServer, { onerror: (e) => console.error("notes:", e) });
console.error("notes MCP server on stdio");

ログは stdout ではなく stderr に出します。stdio では stdout がプロトコルのチャネルなので、紛れ込んだ console.log 1 つで通信が壊れます。ガイドはさらに、server.connect(new StdioServerTransport()) で手動でサーバーをつなぐ方法はコンパイルこそ通るものの、2025 年世代のクライアントにしか対応しないと警告しています。両方に応答するのは serveStdio のほうです。デスクトップクライアントからサーバーを使うには、ガイドの Claude for Desktop の手順にあるとおり、ビルドした build/stdio.js をクライアントの MCP 設定に追加します。

MCP Inspector で MCP サーバーを両方のプロトコル世代でテストするには?

Inspector の CLI を 2 回、--protocol-era modern と --protocol-era legacy で実行します。Inspector の既定は legacy で、古い initialize ハンドシェイクしか送りません。既定のフラグで 1 回成功しても、新しいリビジョンのクライアントについては何もわかりません。

shell
npx tsc
npx @modelcontextprotocol/inspector@2.15.0 --cli node build/stdio.js \
  --protocol-era modern --method tools/list --format json
npx @modelcontextprotocol/inspector@2.15.0 --cli node build/stdio.js \
  --protocol-era legacy --method tools/list --format json

どちらの実行でも、search_notes が同じスキーマで一覧に出ました。

3 つ目の値として auto もあります。server/discover をプローブし、だめなら initialize にフォールバックします。dual-era のクライアントの動きにより近いのですが、protocol eras のページはこれが既定でない理由を説明しています。応答しないレガシーな stdio サーバーに対しては、プローブが止まってしまうことがあるからです。自分のサーバーをテストするなら、世代をそれぞれ固定するほうが明確な確認になります。

Streamable HTTP で両方の世代に MCP サーバーを提供するには?

同じファクトリーを createMcpHandler に渡します。返されるハンドラーオブジェクトの fetch(request) メソッドは Web 標準の Response を返し、既定で両方の世代を 1 つのエンドポイントで処理します。modern のリクエストはステートレスに、レガシーのリクエストはステートレスなフォールバックで処理します。Node のサーバーなら @modelcontextprotocol/node の toNodeHandler で変換でき、ハンドラーが自分では適用しない 2 つのガードも同じパッケージに入っています。

src/http.ts:

ts
import { createServer as createHttpServer } from "node:http";
import { createMcpHandler } from "@modelcontextprotocol/server";
import {
  localhostHostValidation,
  localhostOriginValidation,
  toNodeHandler,
} from "@modelcontextprotocol/node";
import { createServer } from "./server.js";
 
const mcp = toNodeHandler(createMcpHandler(createServer, { responseMode: "json" }));
const hostOk = localhostHostValidation();
const originOk = localhostOriginValidation();
 
createHttpServer((req, res) => {
  if (req.url !== "/mcp") return void res.writeHead(404).end();
  if (!hostOk(req, res) || !originOk(req, res)) return; // already answered 403
  void mcp(req, res);
}).listen(8787, "127.0.0.1");

Web 標準のハンドラーを受け付けるプラットフォーム(Workers、Bun、Deno)では、アダプターを使わず、各リクエストをハンドラーの fetch メソッドに渡します。

legacy オプションは古いクライアントの扱いを決めます。既定は "stateless" で、"reject" にするとエンドポイントは modern 専用になります。responseMode: "json" は、modern のリクエストに SSE ストリームへ切り替えず普通の JSON を返します。その代わり、結果より前に送られる進捗通知は落ちますが、検索ツールはそもそも送りません。subscriptions/listen リクエストには、このオプションに関係なくストリームが返ります。

createMcpHandler は新しいものではありません。仕様リビジョンの前日、2026 年 7 月 27 日に公開された @modelcontextprotocol/server 2.0.0 の型宣言にすでにあり、このサイトのエンドポイントを書いた時点の最新リリースだった 2.3.1 にもあります。つまり手書きのルートは、SDK に欠けている機能の回避策ではありません。今から作るサーバーはこのハンドラーを使えます。

HTTP でも同じようにビルドしてテストします。

shell
npx tsc
node build/http.js &
npx @modelcontextprotocol/inspector@2.15.0 --cli http://127.0.0.1:8787/mcp --transport http \
  --protocol-era modern --method tools/call --tool-name search_notes --tool-arg query=prompt --format json
npx @modelcontextprotocol/inspector@2.15.0 --cli http://127.0.0.1:8787/mcp --transport http \
  --protocol-era legacy --method tools/call --tool-name search_notes --tool-arg query=prompt --format json

どちらも該当するノートを返しました。以下は modern の実行での Inspector のデコード済み出力です。resultType はワイヤー上には存在し、SDK のクライアントが消費しています。legacy の実行では _meta ブロックのない同じ結果が表示されました。その世代では、サーバーは initialize の結果で一度だけ名乗るからです。

json
{"result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"notes","version":"1.0.0"}},"content":[{"type":"text","text":"{\"results\":[{\"title\":\"How prompt caching changes an agent bill\",\"url\":\"https://example.com/notes/prompt-cache\"}]}"}],"structuredContent":{"results":[{"title":"How prompt caching changes an agent bill","url":"https://example.com/notes/prompt-cache"}]}}}

ハンドラーが自分で行うことを curl で確認しました。search_notes への tools/call をヘッダーに Mcp-Name: delete_everything を付けて送ると 400 と -32020 が返り、GET には 405 が返りました。このヘッダーのルールはゲートウェイのためにあります。ロードバランサーやポリシー層はボディを解析せずにヘッダーでルーティングするかもしれず、サーバーのほうはボディに書かれたとおりに実行します。だから両者は一致していなければなりません。

MCP SDK の HTTP ハンドラーは何を開発者に残すのか?

2 つのチェックと 1 つの設定です。ハンドラーは Origin も Host も検証せず、キャッシュ可能な結果すべてに ttlMs: 0 を付けていました。どちらのチェックも、自分のマシン上のサーバーで最も重要になります。localhost で待ち受けているサーバーこそ、DNS リバインディングの標的になるからです。

Origin. 仕様は、サーバーは「DNS リバインディング攻撃を防ぐため」に Origin ヘッダーを検証しなければならず(MUST)、不正なものには 403 で応答すると定めています。ハンドラーだけで動かした最初の実行では、次のリクエストに通常の server/discover の結果と 200 が返りました。

shell
curl -s http://127.0.0.1:8787/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: server/discover' \
  -H 'Origin: https://evil.example' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'

src/http.ts のガードを前に置いた場合です。

{"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Origin: evil.example"},"id":null}
HTTP 403

Host. 同じリクエストを Host: evil.example にすると、Invalid Host: evil.example と 403 が返りました。127.0.0.1 にバインドすることで決まるのは誰が接続できるかで、ブラウザーがどのホスト名と話しているつもりでいるかではありません。そしてリバインディング攻撃では、そのホスト名は攻撃者のものです。

localhost 用のヘルパーはどちらも localhost 系の名前しか許可しません。デプロイしたサーバーは自分のリストを originValidation() と hostHeaderValidation() に渡します。リストに入れるのは URL ではなくホスト名で、["https://example.com"] ではなく ["example.com"] です。ポートは無視されます。Origin ヘッダーのないリクエストは通ります。ブラウザー以外のクライアントは送らないからです。このサイトのエンドポイントは、コードのコメントに書いたとおり別の判断をしています。公開されていてユーザーデータを持たないので、Origin がない場合、リテラルの null、URL として解析できる値はすべて受け付け、それ以外には 403 で応答します。公開の読み取り専用データには合っていますが、真似すべき既定値ではありません。

キャッシュヒント. cacheHints オプションがないと、server/discover と tools/list は ttlMs: 0 と cacheScope: "private" 付きで返ってきました。仕様は満たしていますが、クライアントに鮮度の期間を与えません。キャッシュしたコピーはすべて即座に古くなります。デプロイ時にしか変わらないツールリストなので 1 時間と public を設定したところ、どちらの結果にも "ttlMs":3600000,"cacheScope":"public" が付きました。代償として、クライアントはデプロイ後最大 1 時間、古いリストを使い続けるかもしれませんが、公開データなら許容範囲です。手書きのエンドポイントも同じ値を使っています。ユーザーごとに内容が違うリストは private のままにします。

テストスイートでつまずく小さな意外な点が 2 つあります。responseMode は modern のリクエストにしか効きません。curl で送ったレガシーな initialize(トランスポートの要件どおり Accept: application/json, text/event-stream を付けたもの)は SSE のフレーム、つまり event: message と data: の行で返ってきて、ボディを JSON として解析するテストはそこで失敗します。また、SDK の server/discover は supportedVersions に "2026-07-28" しか載せていませんでしたが、同じエンドポイントは 2025-06-18 の initialize に応答しました。自分のエンドポイントはレガシーのバージョンも載せています。dual-era のクライアントはこのリストには頼りません。最初の modern のリクエストが成功するか、modern のエラーを返すかで世代を判断します。

モデルが正しく使える MCP ツールのスキーマはどう設計するか?

ツールは少なく、狭く、できるところは読み取り専用にし、ツールが何をするかだけでなく何を返すかも書きます。モデルはツールを名前、説明、スキーマから選びます。「returns titles and canonical URLs, not full text」と書かれた説明があれば、呼び出しを使う前に、次のステップがどうなるかがモデルにわかります。

このサイトのエンドポイントには get_contact、list_products、search_articles の 3 つのツールがあります。3 つとも readOnlyHint: true を持っています。アノテーションはホストへのヒントで、ホストはそれを見てユーザーに確認するタイミングを決めることがあります。仕様は、サーバーが信頼できる場合を除いてアノテーションを信頼しないようクライアントに求めているので、確認の判断材料にはなっても、何かを守るものではありません。検索は引数を最大 3 つ取り、どのスキーマも additionalProperties: false を設定していて、サーバー自身がスキーマを強制します。未知の引数はツールエラーとして、不正な形式の呼び出しは JSON-RPC の -32602 として返ります。

その履歴から 2 つのバグを紹介します。ツール名を in で調べていたため、文字列 "constructor" が継承されたプロパティに一致してハンドラーがクラッシュしました。今は Object.hasOwn を使っています。また 10 月 26 日に、プロトコルバージョン 2025-03-26 の提供をやめました。そのリビジョンでは JSON-RPC バッチの受け付けが必須になっていて、このサーバーはバッチを拒否するからです。そのバージョンを求めるクライアントには、今は initialize の結果で 2025-11-25 が返ります。その世代のネゴシエーションはこう動きます。

Zod では、出力を見て初めてわかる点が 1 つあります。z.object に対して SDK が公開したスキーマには additionalProperties: false がなく、bogus という余分な引数を付けた呼び出しは何の問題もなく通りました。z.strictObject で宣言すると、同じツールの tools/list に "additionalProperties":false が付き、同じ呼び出しはツールエラー Unrecognized key: "bogus" として返りました。モデルに伝え、呼び出しを拒否したいなら z.strictObject を使います。

ツールを 1 つ足すごとに、その名前、説明、スキーマが、ホストがコンテキストに読み込むかもしれない内容に加わります。このコストについてはエージェントのツールにかかるトークン税で書きました。

最初の MCP サーバーでは何を省くべきか?

持っていない認証、呼び出し間の状態、そして新しいリビジョンが非推奨にした機能です。公開データを扱う読み取り専用のサーバーにはどれも不要で、1 つ足すたびに、2 つの世代でテストすべき対象が増えます。

  • 認証. データが公開されているなら認証なしで出し、サーバーの instructions にそう書きます。公開されていないなら、仕様の認可の章はそれだけで 1 つのプロジェクトになります。readiness チェッカーが点数を付けるからといって、運用していない認証のために OAuth のディスカバリー文書を公開しないでください。
  • 呼び出し間の状態. リビジョンはプロトコルセッションを意図的に削除しました。呼び出しをまたぐ状態が必要なサーバーは明示的なハンドルをツールの引数で渡し、長いジョブは Tasks 拡張の領分で、それは別の作り方になります。
  • Roots、Sampling、Logging. 3 つとも 2026-07-28 で非推奨です。stdio では stderr にログを出すか、OpenTelemetry を使います。
  • サブスクリプション. subscriptions/listen が古い GET ストリームに代わりますが、ツールリストがデプロイ時にしか変わらないサーバーにはそこで知らせることがありません。SDK は既定でツールに listChanged: true を広告し、クライアントに listen を促します。自分のエンドポイントは、レガシーのクライアントには listChanged: false を、modern のクライアントには素の tools: {} を広告しています。

そもそも MCP サーバーを作るべきか?

誰かがすでに接続すると決めている場合に限ります。これは AI エージェントにとっての MCP と API の比較で主張した立場です。MCP が元を取れるのは、ホストアプリケーションが意図してあなたのサービスを統合する場合で、Web を読んでいる途中であなたを見つけるエージェントには、OpenAPI の記述を備えた普通の HTTP エンドポイントのほうが役に立ちます。

作るなら、今やサーバーは安い部分です。2026 年 11 月 15 日にこのサイトのエンドポイントをローカルでビルドしたところ、Inspector は modern、legacy、auto のどれでも同じ検索結果を返し、サイト独自のエンドツーエンドテストも失敗ゼロで通りました。その実行からは、外部の誰かが呼び出しているかどうかはわかりませんし、誰かが呼び出しているという記録もありません。ホストに接続してもらうことは、どの SDK も代わりにやってくれない部分です。

01 / ディスカッション

コメント欄はありません。議論は X で行っています。

Max Nardit@mnardit

02 / ほかの記事

  1. Agentic Workflows: 1 つの言葉が正反対の 2 つの設計を指す。違いはループを誰が持つか

    Anthropic の分類と GitHub の製品では、この言葉が指す範囲が食い違っています。だからラベルを見ても設計については何もわかりません。判断基準は 1 つです。作業が終わったことを LLM 自身の申告ではなく外部のチェックで確認できるときに限り、LLM に舵を渡す、という基準です。

  2. Anthropic の always-on エージェントガイド: ワーカーが死んだとき inbox、タイマー、outbox は何をするのか、SQLite で検証

    ガイドが触れていない障害処理の大半は、1 つの手に行き着きます。すべての起動を inbox イベントにし、データベースが 2 度目の保存を拒むキーを付けることです。リトライされた Webhook、タイマーの再実行、承認の回答とその期限切れは、どれもこの 1 つの制約で扱えます。リース、リトライ、outbox にはもう少し手が要ります。

  3. セルフホスト版 Claude Managed Agents: サーバーは自分のものになり、何も報告しない失敗も自分のものになる

    セルフホストにすると、トランスクリプトの管理は自分の手に移り、運用も一緒についてきます。Anthropic の差分リストには、作業が止まっている、捨てられている、あるいは別のモデルが答えているのに、サーバーが成功を返すか何も返さないケースが並んでいます。そのリストの各行こそが、デプロイの受け入れテストです。