TypeScript の MCP サーバーは、公式 SDK の McpServer を作り、registerTool(ツール)・registerResource(リソース)・registerPrompt(プロンプト)で機能を登録し、StdioServerTransport か StreamableHTTPServerTransport につなぐだけで作れます。 入力の形は zod で書くと、SDK が JSON Schema に変換してクライアントに渡し、呼び出しのたびに検証もします。

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: "notes-server", version: "1.0.0" });

server.registerTool(
  "search_notes",
  {
    description: "タイトルと本文にキーワードを含むメモを返します",
    inputSchema: { query: z.string().min(1), limit: z.number().int().default(10) },
  },
  async ({ query, limit }) => ({
    content: [{ type: "text", text: `「${query}」で最大${limit}件を検索しました` }],
  }),
);

await server.connect(new StdioServerTransport());
やりたいこと 書き方 見出し
ツールを作る server.registerTool(名前, { inputSchema }, 関数) ツール
戻り値の形を決める outputSchema と structuredContent ツール
失敗を返す { content: [...], isError: true } エラーの返し方
読み取り用のデータを出す server.registerResource(名前, URI, 情報, 関数) リソース
URI に変数を入れる new ResourceTemplate("notes://{id}", { list }) リソース
定型の依頼文を出す server.registerPrompt(名前, { argsSchema }, 関数) プロンプト
ローカルで動かす StdioServerTransport stdio
HTTP で公開する StreamableHTTPServerTransport+createMcpExpressApp() Streamable HTTP
動作を確かめる npx @modelcontextprotocol/inspector MCP Inspector

確認環境:@modelcontextprotocol/sdk 1.32.1(npm の最新版)・zod 4.6.7・TypeScript 7.0.2・tsx 4.23.15・Node.js 22.22.2(Linux)。この記事のサーバーを stdio・Streamable HTTP(ステートレスとステートフル)の両方で動かし、SDK の Client と curl と MCP Inspector 2.10.1(npx)から呼んで、出力とエラー文を確かめました。Claude Code 2.1.296 で claude mcp add を実行し、作られた .mcp.json を確かめています。Claude Desktop と Cursor の設定は、MCP の公式ドキュメントと Cursor のドキュメントで確かめました。v2 系の分割パッケージ(@modelcontextprotocol/server 2.3.1)も最小のサーバーで動かしました(確認日 2026年10月13日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「MCPサーバーの作り方(TypeScript)|公式SDKでツール・リソースを作る」

MCP サーバーが公開するもの

MCP(Model Context Protocol)は、AI のアプリ(クライアント)に、外部の機能やデータを決まった形で渡すための約束事です。 サーバーが公開できるものは主に3つです。

種類 何をするか 誰が使うか この記事の例
ツール(tools) 処理を実行する。書き込みもできる モデルが判断して呼ぶ add_note・search_notes・delete_note
リソース(resources) 読み取り用のデータを URI で出す アプリや利用者が選んで渡す notes://index・notes://{id}
プロンプト(prompts) 引数つきの依頼文のひな形を出す 利用者が選んで使う summarize_notes

MCP の全体像は、関連記事「MCPとは?サーバーとクライアントの仕組みと設定例を図解」で扱っています。

プロジェクトを作る

mkdir notes-mcp && cd notes-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node @types/express tsx

1.32.1 の README では、zod は「必須の peer dependency」で、SDK の内部は zod/v4 を使い、zod v3.25 以降とも互換とされています。package.json の peerDependencies は "zod": "^3.25 || ^4.0" でした。依存として express 5・hono・ajv・cors などが一緒に入り、node_modules は全体で 74MB でした。

{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src"]
}

TypeScript 7.0.2 では "types": ["node"] を書かないと、process が見つからないエラーになりました。

src/http.ts(23,21): error TS2591: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.

v2 の SDK の README にも「TypeScript 6.0 以降は @types/* を自動では含めない」と書かれています。@types/express を入れていないと、Express のハンドラーの req・res が implicitly has an 'any' type(TS7006)になりました。tsconfig の項目は、関連記事「tsconfig設定一覧|TypeScript 7対応の推奨値とテンプレート」で扱っています。

ファイルの構成です。

notes-mcp/
├── package.json
├── tsconfig.json
└── src/
    ├── server.ts           # ツール・リソース・プロンプトの定義(共通)
    ├── stdio.ts            # stdio で起動する入口
    ├── http.ts             # Streamable HTTP(ステートレス)の入口
    └── http-stateful.ts    # Streamable HTTP(ステートフル)の入口

サーバー本体(ツール・リソース・プロンプト)

ツールなどの定義は createServer() という関数にまとめ、stdio と HTTP の入口から呼びます。 次が src/server.ts の全体です。

import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

type Note = { id: number; title: string; body: string; tags: string[] };

// データはサーバーの外に置く(HTTP のステートレスではリクエストごとに createServer() が呼ばれるため)
const notes = new Map<number, Note>([
  [1, { id: 1, title: "MCPの基本", body: "MCPはツール・リソース・プロンプトを公開する仕組み", tags: ["mcp"] }],
  [2, { id: 2, title: "zodの使い方", body: "z.object で入力の形を決める", tags: ["zod", "typescript"] }],
]);
let nextId = 3;

export function createServer(): McpServer {
  const server = new McpServer({ name: "notes-server", version: "1.0.0" });

  // ツール1:メモを追加する
  server.registerTool(
    "add_note",
    {
      title: "メモを追加",
      description: "メモを1件追加し、付けたIDを返します",
      inputSchema: {
        title: z.string().min(1).max(100).describe("メモのタイトル"),
        body: z.string().describe("本文"),
        tags: z.array(z.string()).default([]).describe("タグの配列"),
      },
      outputSchema: { id: z.number(), title: z.string() },
    },
    async ({ title, body, tags }) => {
      const note = { id: nextId++, title, body, tags };
      notes.set(note.id, note);
      const output = { id: note.id, title: note.title };
      return {
        content: [{ type: "text", text: JSON.stringify(output) }],
        structuredContent: output,
      };
    },
  );

  // ツール2:メモを検索する(読み取りだけ)
  server.registerTool(
    "search_notes",
    {
      title: "メモを検索",
      description: "タイトルと本文にキーワードを含むメモを返します",
      inputSchema: {
        query: z.string().min(1).describe("検索するキーワード"),
        limit: z.number().int().min(1).max(50).default(10).describe("最大件数"),
      },
      outputSchema: {
        count: z.number(),
        notes: z.array(z.object({ id: z.number(), title: z.string() })),
      },
      annotations: { readOnlyHint: true },
    },
    async ({ query, limit }) => {
      const hits = [...notes.values()]
        .filter((n) => n.title.includes(query) || n.body.includes(query))
        .slice(0, limit)
        .map((n) => ({ id: n.id, title: n.title }));
      const output = { count: hits.length, notes: hits };
      return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output };
    },
  );

  // ツール3:メモを削除する(無いIDは isError で返す)
  server.registerTool(
    "delete_note",
    {
      title: "メモを削除",
      description: "IDを指定してメモを削除します",
      inputSchema: { id: z.number().int().positive() },
      annotations: { destructiveHint: true },
    },
    async ({ id }) => {
      if (!notes.has(id)) {
        return { content: [{ type: "text", text: `ID ${id} のメモはありません` }], isError: true };
      }
      notes.delete(id);
      return { content: [{ type: "text", text: `ID ${id} を削除しました` }] };
    },
  );

  // リソース1:固定の URI
  server.registerResource(
    "notes-index",
    "notes://index",
    { title: "メモの一覧", description: "全メモのIDとタイトル", mimeType: "application/json" },
    async (uri) => ({
      contents: [{
        uri: uri.href, mimeType: "application/json",
        text: JSON.stringify([...notes.values()].map((n) => ({ id: n.id, title: n.title }))),
      }],
    }),
  );

  // リソース2:URI テンプレート(notes://{id})
  server.registerResource(
    "note",
    new ResourceTemplate("notes://{id}", {
      list: async () => ({
        resources: [...notes.values()].map((n) => ({ uri: `notes://${n.id}`, name: n.title, mimeType: "text/markdown" })),
      }),
    }),
    { title: "メモ", description: "1件のメモ本文", mimeType: "text/markdown" },
    async (uri, { id }) => {
      const note = notes.get(Number(id));
      if (!note) throw new Error(`notes://${id} は見つかりません`);
      return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: `# ${note.title}\n\n${note.body}` }] };
    },
  );

  // プロンプト:引数を受け取ってメッセージを組み立てる
  server.registerPrompt(
    "summarize_notes",
    {
      title: "メモを要約",
      description: "指定したタグのメモを要約させるプロンプト",
      argsSchema: { tag: z.string().describe("対象のタグ"), style: z.string().optional().describe("文体") },
    },
    ({ tag, style }) => {
      const target = [...notes.values()].filter((n) => n.tags.includes(tag));
      return {
        messages: [{
          role: "user",
          content: {
            type: "text",
            text: `次のメモを${style ?? "箇条書き"}で要約してください。\n\n` +
              target.map((n) => `- ${n.title}: ${n.body}`).join("\n"),
          },
        }],
      };
    },
  );

  return server;
}

ツール:registerTool と zod の入力スキーマ

registerTool(名前, 設定, 関数) の設定に inputSchema を書くと、引数が型付きで関数に渡り、クライアントには JSON Schema として公開されます。 1.32.1 では、以前の server.tool(...) は @deprecated Use registerTool instead. となっていました。

設定の項目 意味
title 画面に表示する名前
description モデルがツールを選ぶときに読む説明。いつ使うかを具体的に書く
inputSchema 引数の形。zod のフィールドを並べたオブジェクト
outputSchema 戻り値の structuredContent の形
annotations readOnlyHint・destructiveHint・idempotentHint・openWorldHint などのヒント
_meta 任意のメタデータ

zod から作られた JSON Schema

search_notes の inputSchema は、クライアントの listTools() で次のように見えました。

{
  "type": "object",
  "properties": {
    "query": { "type": "string", "minLength": 1, "description": "検索するキーワード" },
    "limit": { "default": 10, "description": "最大件数", "type": "integer", "minimum": 1, "maximum": 50 }
  },
  "required": ["query"],
  "$schema": "http://json-schema.org/draft-07/schema#"
}

.default(10) を付けた limit は required に入らず、default が付きました。.describe() の文は description になります。モデルは description を読んで引数を決めるので、単位や形式(例:「YYYY-MM-DD 形式」)まで書いておくと、呼び出しの誤りが減ります。zod の書き方は、関連記事「Zodの使い方|v4対応のAPI早見表とエラー出力つき実例3つ」で扱っています。

戻り値:content と structuredContent

ツールの戻り値は content(テキストや画像の配列)が基本です。outputSchema を書いたときは、structuredContent に型どおりのオブジェクトも入れます。

{
  "content": [{ "type": "text", "text": "{\"id\":3,\"title\":\"Streamable HTTP\"}" }],
  "structuredContent": { "id": 3, "title": "Streamable HTTP" }
}

structuredContent だけを読まないクライアントもあるので、同じ内容を content のテキストにも入れておくと、どのクライアントでも結果が伝わります。

エラーの返し方

ツールの失敗は、例外ではなく isError: true の結果として返すのが基本です。 モデルはエラーの文を読んで、引数を直して呼び直すなどの判断ができます。SDK がそれぞれの失敗をどう返したかを、Client から呼んで確かめました。

状況 クライアントが受け取った結果
自分で isError: true を返す ID 99 のメモはありません(isError: true)
ツールの中で throw new Error(...) DB に接続できません(isError: true)
引数が zod の検証に通らない MCP error -32602: Input validation error: Invalid arguments for tool search_notes: Too small: expected string to have >=1 characters at query(isError: true)
存在しないツール MCP error -32602: Tool nope not found(isError: true)
structuredContent が outputSchema と合わない MCP error -32602: Output validation error: Invalid structured content for tool bad_output: Invalid input: expected number, received string at n(isError: true)
outputSchema があるのに structuredContent が無い MCP error -32602: Output validation error: Tool no_struct has an output schema but no structured content was provided(isError: true)
リソースの読み込み関数で throw readResource() が例外:MCP error -32603: notes://999 は見つかりません
プロンプトの必須の引数が無い getPrompt() が例外:MCP error -32602: ... Invalid arguments for prompt summarize_notes: Invalid input: expected string, received undefined at tag

型違いの例では、query: "" と limit: "3"(文字列)を同時に渡すと、2つの問題が改行区切りで1つのメッセージに入りました。

ツールでは、throw も SDK が isError: true の結果に変えて返しました。 ただし、例外の message がそのままクライアント(とモデル)に渡ります。接続文字列や内部のパスが例外の文に入る処理は、try/catch で包んで、渡してよい文に置き換えて返します。

async ({ id }) => {
  try {
    const row = await db.findNote(id);
    return { content: [{ type: "text", text: JSON.stringify(row) }] };
  } catch (e) {
    console.error(e);                                       // 詳細は stderr のログへ
    return { content: [{ type: "text", text: "メモの取得に失敗しました。時間をおいて再度お試しください" }], isError: true };
  }
}

リソースとプロンプトは、ツールと違い、JSON-RPC のエラーとしてクライアントの例外になります。 -32602 は引数の誤り(Invalid params)、-32603 は内部エラー(Internal error)の番号です。

リソース:固定の URI と ResourceTemplate

リソースは、モデルやアプリに読み取り用のデータを URI で渡す機能です。 固定の URI は文字列で、変数を含む URI は ResourceTemplate で登録します。

listResources() の結果には、固定の notes://index と、テンプレートの list が返した notes://1・notes://2… が並びました。listResourceTemplates() には notes://{id} が1件返りました。

[
  { "name": "notes-index", "title": "メモの一覧", "uri": "notes://index", "description": "全メモのIDとタイトル", "mimeType": "application/json" },
  { "name": "MCPの基本", "title": "メモ", "uri": "notes://1", "description": "1件のメモ本文", "mimeType": "text/markdown" },
  { "name": "zodの使い方", "title": "メモ", "uri": "notes://2", "description": "1件のメモ本文", "mimeType": "text/markdown" }
]

readResource({ uri: "notes://2" }) では、テンプレートの {id} に "2"(文字列)が入り、次の結果になりました。

{ "contents": [{ "uri": "notes://2", "mimeType": "text/markdown", "text": "# zodの使い方\n\nz.object で入力の形を決める" }] }

テンプレートの変数は文字列で届くので、数値として使うときは Number(id) で変換し、無い ID のときの扱いを書きます。list を undefined にすると、一覧には出さず、URI を知っているときだけ読めるリソースになります。

プロンプト:registerPrompt

プロンプトは、利用者が選んで使う「引数つきの依頼文」です。 argsSchema に zod で引数を書きます。listPrompts() では、.optional() を付けた style が "required": false になりました。

{
  "name": "summarize_notes",
  "title": "メモを要約",
  "description": "指定したタグのメモを要約させるプロンプト",
  "arguments": [
    { "name": "tag", "description": "対象のタグ", "required": true },
    { "name": "style", "description": "文体", "required": false }
  ]
}

getPrompt({ name: "summarize_notes", arguments: { tag: "mcp" } }) の結果の messages[0].content.text は、「次のメモを箇条書きで要約」という依頼の文の後に、mcp タグのメモが並んだ文でした。プロンプトの引数は、MCP の仕様上どれも文字列で渡されます。

stdio で動かす

stdio は、クライアントがサーバーを子プロセスとして起動し、標準入出力で JSON-RPC をやり取りする方式です。 自分のパソコンで動かすツールに向いています。src/stdio.ts です。

#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.js";

const server = createServer();
await server.connect(new StdioServerTransport());
console.error("notes-server (stdio) started");   // stdout は通信に使うので、ログは stderr に出す
npx tsc -p .            # dist/ に出力
node dist/stdio.js      # 単体で起動すると入力待ちになる(Ctrl+C で終了)

stdio のサーバーでは console.log を使いません。 起動時のログを console.log に変えて試すと、クライアント側で次のエラーが出ました。stdout に出した文字が JSON-RPC のメッセージとして読まれるためです。

onerror: Unexpected token 'o', "notes-serve"... is not valid JSON

開発中は npx tsx src/stdio.ts で、ビルドせずに TypeScript のまま起動することもできます。TypeScript の実行方法は、関連記事「TypeScriptをNode.jsで実行|tsx・ts-node・型ストリップ」で扱っています。

Streamable HTTP で動かす

Streamable HTTP は、1つの URL(例:/mcp)に HTTP の POST で JSON-RPC を送る方式です。 返答は JSON か SSE(Server-Sent Events)で返ります。複数の利用者で共有するサーバーや、リモートに置くサーバーに使います。1.32.1 の README は、リモートのサーバーには Streamable HTTP を勧め、以前の HTTP+SSE は互換のためだけとしています。

ステートレス(リクエストごとに作る)

src/http.ts です。セッションを持たず、リクエストごとにサーバーとトランスポートを作ります。

import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } from "./server.js";

const app = createMcpExpressApp();   // 既定は 127.0.0.1。DNS リバインディング対策が有効になる

app.post("/mcp", async (req, res) => {
  const server = createServer();
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  res.on("close", () => {
    transport.close();
    server.close();
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

// ステートレスでは GET(通知用の SSE)と DELETE(セッション終了)は使わない
const notAllowed = { jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null };
app.get("/mcp", (_req, res) => res.status(405).json(notAllowed));
app.delete("/mcp", (_req, res) => res.status(405).json(notAllowed));

const PORT = Number(process.env.PORT ?? 3000);
app.listen(PORT, () => console.error(`notes-server (http) listening on http://127.0.0.1:${PORT}/mcp`));

ステートレスでは、リクエストごとに createServer() が呼ばれます。 最初はメモの Map を createServer() の中に書いていたため、add_note で足したメモが、次のリクエストの listResources() には出てきませんでした(stdio では出ていました)。データは関数の外(またはデータベース)に置きます。

curl で中身を見る

curl -i -X POST http://127.0.0.1:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
HTTP/1.1 200 OK
content-type: text/event-stream

event: message
data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"listChanged":true}},"serverInfo":{"name":"notes-server","version":"1.0.0"}},"jsonrpc":"2.0","id":1}

返答は SSE の形(event: message と data:)でした。ステートレスなので、initialize をせずに tools/call を直接送っても答えが返りました。

送り方 結果
Accept ヘッダーなし 406 Not Acceptable:Client must accept both application/json and text/event-stream
Host: evil.example.com 403 Forbidden:Invalid Host: evil.example.com
GET /mcp(上のコードの場合) 405 Method Not Allowed

createMcpExpressApp() は、既定で 127.0.0.1 に結び付き、Host ヘッダーを確かめる DNS リバインディング対策(外部のサイトから手元のサーバーを呼ばせる攻撃への対策)を有効にします。createMcpExpressApp({ host: "0.0.0.0" }) にすると、この自動の対策は付かないと SDK の型定義のコメントに書かれています。その場合は allowedHosts を指定します。

ステートフル(セッションを持つ)

サーバーからの通知(進み具合・ログ・一覧の変更)を送りたいときは、セッションを持つ形にします。src/http-stateful.ts です。

import { randomUUID } from "node:crypto";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { createServer } from "./server.js";

const app = createMcpExpressApp();
const transports: Record<string, StreamableHTTPServerTransport> = {};

app.post("/mcp", async (req, res) => {
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
  let transport = sessionId ? transports[sessionId] : undefined;
  if (!transport) {
    if (sessionId || !isInitializeRequest(req.body)) {
      res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: No valid session ID provided" }, id: null });
      return;
    }
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (id) => { transports[id] = transport!; },
    });
    transport.onclose = () => { if (transport!.sessionId) delete transports[transport!.sessionId]; };
    await createServer().connect(transport);
  }
  await transport.handleRequest(req, res, req.body);
});

// GET(サーバーからの通知の SSE)と DELETE(セッション終了)
const handleSession = async (req: any, res: any) => {
  const transport = transports[req.headers["mcp-session-id"] as string];
  if (!transport) { res.status(400).send("Invalid or missing session ID"); return; }
  await transport.handleRequest(req, res);
};
app.get("/mcp", handleSession);
app.delete("/mcp", handleSession);

app.listen(Number(process.env.PORT ?? 3001));

initialize の返答には mcp-session-id: bc4a0b0d-... のヘッダーが付きました。そのヘッダーを付けずに tools/list を送ると、Bad Request: No valid session ID provided が返りました。SDK のクライアントはこのヘッダーを自動で付けるので、StreamableHTTPClientTransport からはステートレスと同じコードで呼べました。

形 向いている場面 注意
ステートレス 単純な API の呼び出し。複数台に増やしやすい サーバーからの通知は送れない。状態は外に置く
ステートフル 通知・長い処理の進み具合を送る セッションはメモリにあるので、複数台では同じ台に振り分ける工夫が要る

SDK のクライアントで動作を確かめる

同じ Client のコードで、stdio と Streamable HTTP の両方のサーバーを呼べます。 この記事の表の結果は、次のスクリプトで取りました。

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = process.argv[2] === "http"
  ? new StreamableHTTPClientTransport(new URL("http://127.0.0.1:3000/mcp"))
  : new StdioClientTransport({ command: "node", args: ["dist/stdio.js"] });

const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(transport);
console.log(client.getServerVersion(), client.getServerCapabilities());

console.log((await client.listTools()).tools.map((t) => t.name));
console.log(await client.callTool({ name: "search_notes", arguments: { query: "MCP" } }));
console.log(await client.callTool({ name: "delete_note", arguments: { id: 99 } }));
console.log((await client.listResources()).resources);
console.log(await client.readResource({ uri: "notes://2" }));
console.log(await client.getPrompt({ name: "summarize_notes", arguments: { tag: "mcp" } }));
await client.close();
{ name: 'notes-server', version: '1.0.0' } {"prompts":{"listChanged":true},"resources":{"listChanged":true},"tools":{"listChanged":true}}
[ 'add_note', 'search_notes', 'delete_note', 'fail' ]

(fail は、例外を投げたときの動きを見るために足したツールです。)node dist/client.js で stdio、node dist/client.js http で HTTP のサーバーを呼び、同じ結果になりました。テストでは InMemoryTransport.createLinkedPair()(@modelcontextprotocol/sdk/inMemory.js)を使うと、プロセスや HTTP を使わずにサーバーとクライアントをつなげます。表の outputSchema のエラーはこの方法で確かめました。

MCP Inspector(npx)で確かめる

MCP Inspector は、MCP サーバーを画面やコマンドから呼べる公式のデバッグ用ツールです。 npx で入れずに使えます。2026年10月13日時点で npx @modelcontextprotocol/inspector は 2.10.1 でした。

画面で使う

npx @modelcontextprotocol/inspector node dist/stdio.js
MCP Inspector Web is up and running at:
   http://127.0.0.1:6274?MCP_INSPECTOR_API_TOKEN=c0164dc4...
   Sandbox (MCP Apps): http://127.0.0.1:6275/sandbox

表示された URL(トークン付き)をブラウザで開くと、ツールの一覧・呼び出し・リソース・プロンプトを画面で試せます。OS のキーチェーンが無い Linux では、The OS keychain is not available, so secrets will be kept in: File (unencrypted) at ~/.mcp-inspector/secrets.json という警告が出て、秘密情報はファイルに保存されました。MCP_INSPECTOR_SECRET_STORE=memory で保存先を選べると案内が出ます。

コマンドで使う(--cli)

CI やスクリプトで確かめるときは --cli を付けます。モードの指定(--cli)は、ほかの引数より前に書きます。

# ツールの一覧
npx @modelcontextprotocol/inspector --cli node dist/stdio.js --method tools/list

# ツールを呼ぶ
npx @modelcontextprotocol/inspector --cli node dist/stdio.js \
  --method tools/call --tool-name search_notes --tool-arg query=zod

# HTTP のサーバーのリソースを読む
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp --transport http \
  --method resources/read --uri notes://1

# プロンプトを取る
npx @modelcontextprotocol/inspector --cli node dist/stdio.js \
  --method prompts/get --prompt-name summarize_notes --prompt-args tag=zod

tools/call の結果です。

{
  "content": [{ "type": "text", "text": "{\"count\":1,\"notes\":[{\"id\":2,\"title\":\"zodの使い方\"}]}" }],
  "structuredContent": { "count": 1, "notes": [{ "id": 2, "title": "zodの使い方" }] }
}

--help では、モードとして --web(既定)・--cli・--tui の3つが表示されました。

Claude Code・Claude Desktop・Cursor への登録

Claude Code

# stdio(-- の後がサーバーの起動コマンド)
claude mcp add --scope project notes -- node /abs/path/notes-mcp/dist/stdio.js

# Streamable HTTP
claude mcp add --scope project --transport http notes-http http://127.0.0.1:3000/mcp

claude mcp list

Claude Code 2.1.296 で実行すると、Added stdio MCP server notes with command: node ... to project config と表示され、プロジェクトの直下に次の .mcp.json ができました。

{
  "mcpServers": {
    "notes": {
      "type": "stdio",
      "command": "node",
      "args": ["/abs/path/notes-mcp/dist/stdio.js"],
      "env": {}
    },
    "notes-http": {
      "type": "http",
      "url": "http://127.0.0.1:3000/mcp"
    }
  }
}

claude mcp list では、どちらも ⏸ Pending approval (run claude to approve) と表示されました。プロジェクトの .mcp.json のサーバーは、claude を起動して承認してから使えるようになります。

スコープ 使える範囲 保存先(ドキュメント)
local(既定) 今のプロジェクトだけ・自分だけ ~/.claude.json
project 今のプロジェクト。リポジトリで共有 プロジェクト直下の .mcp.json
user 自分のすべてのプロジェクト ~/.claude.json

ドキュメントでは、.mcp.json の中で ${API_KEY}・${VAR:-既定値} の形で環境変数を展開できます。ツールの出力が 10,000 トークンを超えると警告が出て、既定では 25,000 トークンまでに制限されます(MAX_MCP_OUTPUT_TOKENS で変更)。大量のデータを返すツールは、件数の上限(この記事の limit)を付けておくと安全です。設定の一覧は、関連記事「Claude Code MCP設定の追加方法|.mcp.json雛形と一覧」で扱っています。

Claude Desktop

MCP の公式ドキュメントでは、Claude Desktop の「設定」→「Developer」→「Edit Config」で設定ファイルを開きます。

OS 設定ファイル
macOS ~/Library/Application Support/Claude/claude_desktop_config.json
Windows %APPDATA%\Claude\claude_desktop_config.json
{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": ["/Users/username/notes-mcp/dist/stdio.js"]
    }
  }
}

パスは絶対パスで書き、保存したら Claude Desktop を完全に終了して起動し直します。サーバーの stderr のログは、macOS では ~/Library/Logs/Claude/mcp-server-notes.log、Windows では %APPDATA%\Claude\logs に出ると書かれています。リモートの HTTP サーバーは、この設定ファイルではなく、「設定」→「Connectors」→「Add custom connector」から URL を登録する手順がドキュメントにあります。

Cursor

Cursor のドキュメントでは、プロジェクト用は .cursor/mcp.json、全体用は ~/.cursor/mcp.json に書きます。

{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": ["${workspaceFolder}/dist/stdio.js"],
      "env": { "API_KEY": "${env:API_KEY}" }
    },
    "notes-http": {
      "url": "http://127.0.0.1:3000/mcp"
    }
  }
}

Cursor の変数は ${env:NAME}・${workspaceFolder}・${userHome} の形で、Claude Code の ${NAME} とは書き方が違います。Cursor の設定全般は、関連記事「Cursor 使い方|Cursor Rulesの書き方・ショートカット・雛形」で扱っています。

項目 Claude Code Claude Desktop Cursor
設定の場所 .mcp.json・~/.claude.json claude_desktop_config.json .cursor/mcp.json・~/.cursor/mcp.json
stdio "type": "stdio"・command・args command・args command・args
HTTP "type": "http"・url 「Connectors」から登録 url
環境変数 ${NAME}・${NAME:-既定} env に値を書く ${env:NAME}・envFile
追加のしかた claude mcp add ファイルを編集して再起動 ファイルを編集

Python 版との違い

関連記事「MCPサーバーの作り方|Pythonで作ってClaude Codeにつなぐ」の Python の書き方と比べます。

項目 TypeScript(sdk 1.32.1) Python(mcp)
サーバーのクラス McpServer v1 は FastMCP。2.3.0 は MCPServer
ツールの登録 server.registerTool(名前, 設定, 関数) @mcp.tool() デコレーター
入力の形 zod で書く 関数の型ヒントから作られる
HTTP Express などに StreamableHTTPServerTransport を組み込む mcp.run(transport="streamable-http")
実行 ビルドして node、または tsx python や uv run
配布 npx で起動できる npm パッケージにしやすい uvx で起動できる形にしやすい

Python の mcp パッケージは 2.3.0 で FastMCP が MCPServer に改名されました。 2.3.0 で from mcp.server.fastmcp import FastMCP を実行すると、No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) というエラーになり、mcp<2 に固定する方法が案内されました。MCPServer.run() の transport は 'stdio'・'sse'・'streamable-http' の3つでした。

TypeScript を選ぶ理由になりやすい点は、Web の API やフロントエンドと同じ言語・同じ型で書けること、Express や Hono の既存のサーバーに組み込めることです。

v2 の分割パッケージ

npm には、v2 として @modelcontextprotocol/server・@modelcontextprotocol/client・@modelcontextprotocol/core(いずれも 2.3.1、2026年10月5日公開)もあります。 @modelcontextprotocol/server の README には、v2 が「安定版の系列」で、MCP 仕様 2026-07-28 を実装し、v1 の @modelcontextprotocol/sdk を置き換えるものと書かれています。@modelcontextprotocol/sdk の最新は 1.32.1(README では仕様 2025-11-25 を実装)です。

v2 で同じツールを書いた最小の例です。

import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";

const server = new McpServer({ name: "notes-v2", version: "1.0.0" });
server.registerTool(
  "search_notes",
  {
    description: "キーワードでメモを検索します",
    inputSchema: z.object({ query: z.string().min(1), limit: z.number().int().default(10) }),   // z.object で包む
    outputSchema: z.object({ count: z.number() }),
  },
  async ({ query }) => {
    const output = { count: query.length };
    return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output };
  },
);
await server.connect(new StdioServerTransport());
項目 v1(sdk 1.32.1) v2(server 2.3.1)
import @modelcontextprotocol/sdk/server/mcp.js など @modelcontextprotocol/server・/stdio
inputSchema { query: z.string() }(フィールドを並べる) z.object({ query: z.string() })。並べる形は @deprecated
zod ^3.25 || ^4.0 ^4.2.0
JSON Schema の版 draft-07 draft 2020-12
検証エラーの文 MCP error -32602: Input validation error: ... Input validation error: ...
HTTP StreamableHTTPServerTransport・createMcpExpressApp @modelcontextprotocol/express などのアダプターが別パッケージ

v2 のクライアント(@modelcontextprotocol/client)からこのサーバーを呼ぶと、ツールの一覧・呼び出し・検証エラーが返り、MCP Inspector 2.10.1 の --cli からも tools/list が取れました。このときの交渉されたプロトコルの版は 2025-11-25 でした。v1 のコードを v2 に移すときは、import 先と z.object() で包む部分が主な書き換えです。 細かい違いは v2 の移行ガイド(ts.sdk.modelcontextprotocol.io)にまとめられています。

よくある質問

Q. ツールが Claude Code や Claude Desktop に出てきません。 A. まず npx @modelcontextprotocol/inspector --cli node dist/stdio.js --method tools/list で、サーバー単体でツールが返るか確かめます。返るなら、設定のパス(絶対パスか)・node の場所・ビルド済みか(dist/ があるか)を見ます。Claude Code の project スコープは承認待ち(Pending approval)になっていないかも確かめます。

Q. stdio と Streamable HTTP のどちらで作ればよいですか。 A. 自分のパソコンのファイルやコマンドを扱うツールは stdio、チームで共有する・社内のシステムにつなぐサーバーは Streamable HTTP が向いています。この記事のように createServer() を分けておくと、両方の入口を用意できます。

Q. HTTP のサーバーに認証を付けるには? A. SDK には OAuth の仕組み(@modelcontextprotocol/sdk/server/auth)があり、README の例の一覧に OAuth 付きのサーバーとクライアントがあります。社内向けで簡単に済ませるなら、リバースプロキシや Express のミドルウェアで Authorization ヘッダーを確かめる方法もあります。Claude Code では --header "Authorization: Bearer ..." で送るヘッダーを指定できます(ドキュメント)。

まとめ

  • ツールは registerTool(名前, { inputSchema, outputSchema }, 関数)。server.tool() は非推奨
  • zod の .describe() が JSON Schema の description になり、モデルが読む
  • 失敗は isError: true で返す。throw も isError になるが、例外の文はそのままモデルに渡る
  • リソースは URI、テンプレートは ResourceTemplate("notes://{id}")。変数は文字列で届く
  • stdio では console.log を使わず stderr にログを出す
  • Streamable HTTP は createMcpExpressApp()+StreamableHTTPServerTransport。ステートレスではデータを createServer() の外に置く
  • 動作確認は npx @modelcontextprotocol/inspector --cli でスクリプトにできる
  • TypeScript 7 では tsconfig に "types": ["node"] が要る
  • npm には v2 の分割パッケージ(@modelcontextprotocol/server 2.3.1) もある。移行は import と z.object() が中心

参考・出典

確認日はいずれも 2026年10月13日です。

  • modelcontextprotocol/typescript-sdk(GitHub、v1.x の README と docs/server.md):https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/docs/server.md
  • @modelcontextprotocol/sdk(npm):https://www.npmjs.com/package/@modelcontextprotocol/sdk
  • @modelcontextprotocol/server(npm、v2):https://www.npmjs.com/package/@modelcontextprotocol/server
  • MCP TypeScript SDK v2 ドキュメント:https://ts.sdk.modelcontextprotocol.io/v2/
  • MCP Inspector(GitHub):https://github.com/modelcontextprotocol/inspector
  • Model Context Protocol「Connect to local MCP servers」:https://modelcontextprotocol.io/docs/develop/connect-local-servers
  • Model Context Protocol「Connect to remote MCP Servers」:https://modelcontextprotocol.io/docs/develop/connect-remote-servers
  • Claude Code ドキュメント「Connect Claude Code to tools via MCP」:https://code.claude.com/docs/en/mcp
  • Cursor ドキュメント「Model Context Protocol (MCP)」:https://cursor.com/docs/context/mcp
  • mcp(PyPI):https://pypi.org/project/mcp/

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「MCPサーバーの作り方(TypeScript)|公式SDKでツール・リソースを作る」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?