- AI ⚙️
MCPサーバーの作り方(TypeScript)|公式SDKでツール・リソースを作る
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期連続の黒字決算。