MCPサーバー入門と設計の5原則
エージェントから呼べる最小のMCPサーバーを、実際に動くコードで作る。人間のタイポとは質の違う誤入力から、ツール側で身を守る設計まで。
MCP(Model Context Protocol)は、AI のクライアントと外部のツールやデータをつなぐための共通の規格です。1つサーバーを書けば、MCP に対応したどのクライアントからでも同じツールを呼べます。
この記事では、手元のメモを読むだけの最小サーバーを作ります。そのうえで、編集部が自前のツールを運用して分かった「エージェント向けツールの設計」を5つにまとめます。
1. 動く最小サーバー
mkdir notes-mcp && cd notes-mcp && npm init -y
npm i @modelcontextprotocol/sdk zod
server.mjs:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import path from 'node:path';
import fs from 'node:fs/promises';
const ROOT = path.resolve(process.env.NOTES_DIR ?? './notes');
async function readNoteImpl({ name }) {
if (!/^[\w.-]{1,64}$/.test(name) || name.includes('..')) throw new Error(`invalid name: ${JSON.stringify(name)}`);
const p = path.resolve(ROOT, name + '.md');
if (!p.startsWith(ROOT + path.sep)) throw new Error('path escapes root');
return fs.readFile(p, 'utf8');
}
const server = new McpServer({ name: 'notes', version: '0.1.0' });
server.registerTool('read_note', {
description: 'Read one markdown note by name (without .md). Use when the user refers to a saved note.',
inputSchema: { name: z.string().describe('note name, e.g. "deploy-checklist"') },
}, async (args) => ({ content: [{ type: 'text', text: await readNoteImpl(args) }] }));
await server.connect(new StdioServerTransport());
stdio で動くサーバーです。クライアントの設定に node /絶対パス/server.mjs を登録すれば呼べるようになります。編集部で確かめたところ、read_note({name:"hello"}) は本文を返し、read_note({name:"../../etc/passwd"}) は isError: true で拒否されました。
2. 設計の5原則
原則1:実装は1つにして、CLI と MCP の両方から呼ぶ
上のコードの readNoteImpl は MCP から切り離してあります。編集部の自前ツールでは、同じ _impl 関数を CLI(Click など)と MCP(FastMCP など)の両方から呼び、スキーマは型定義から自動で生成しています。テストは _impl に対して1か所に書けば済みます。順番としては、先に「エージェントがどう使うか」を設計し、人間向けの CLI はその副産物と考えています。
原則2:細かいフラグより JSON を受け取る
LLM は構造化した JSON を作るのが得意です。--from、--to、--dry-run のようなフラグに分けると、変換の途中で意味が抜け落ちやすくなります。入力は1つのオブジェクトとして受け取り、そのオブジェクトをスキーマで検証します。
原則3:入力は「人間のタイポ」ではなく「別種の誤り」として疑う
エージェントの誤入力は、人間のタイポとは質が違います。編集部で実際に来たものを挙げます。
../../.sshのようなパストラバーサル- ID の欄にクエリ文字列が埋め込まれる(
abc123?fields=all) - 二重の URL エンコード(
%252F) - 目に見えない制御文字
ツールは最後の防衛線です。名前の allowlist による検証と、パスを解決したあとのルート配下チェックの両方を入れます(上のコードの2つの if)。
原則4:返す量を絞れるようにする
レスポンスは、そのままモデルのコンテキストを消費します。フィールドマスク(fields: ["id","title"])や、1行1件の NDJSON で返せるようにして、必要な分だけ渡します。
原則5:ツールの説明は「いつ使うか」を書く
description には機能の説明ではなく、使う場面を書きます(上の例の “Use when the user refers to a saved note”)。やってはいけないこと(不変条件)は、別に skill やドキュメントに書いておきます。
ツールに渡す権限は、読み取り専用のラッパーで絞れます。「AIエージェントの権限設計」に書きました。
3. 常時ロードされる MCP は「コンテキスト税」
登録した MCP サーバーのツール定義は、呼ばれなくても毎ターン送られます。
- 編集部で直近30日のツール呼び出しを集計したところ、呼ばれていない skill が15本ありました。それらを手動で呼ぶときだけ読み込む扱いに変えると、常時ロードされる説明文は 14.5k字から7.3k字に半分になりました。
- 11のプロジェクトに登録されていたのに、30日間で一度も呼ばれていない重い MCP は、全部外しました。
- ローカル LLM と組み合わせると、この差がそのまま待ち時間になります。同じタスクで、1ターンに 2,834トークンを送るツールは24.6秒、30,301トークンを送るツールは4分35秒かかりました。後者のトークンの大半は MCP のツール定義でした。
常時読まれる文章を削った実測は「CLAUDE.mdは削るほど効く」にあります。
4. 「消したのに復活する」とき
削除したはずの MCP サーバーがまた読み込まれる原因は、たいてい設定の残骸です。
- ユーザースコープに登録したまま
- 各プロジェクトのローカル設定に、サーバー名が残っている
- 「プロジェクトの MCP をすべて有効にする」設定
- 許可リストに残った、個別ツールの許可
全部を掃除して、ようやくゼロになりました。
もう1つの典型は、GUI から起動したクライアントがシェルの環境変数を継承しないことです。設定ファイルの ${API_KEY} が空のまま渡り、サーバーは「認証エラー」とだけ言います。サーバーを起動するときに、キーの有無(値は出さない)をログに出すようにしておくと、切り分けが早くなります。
既存の MCP サーバーを選んで入れるときの基準と、権限の絞り方は「MCPサーバーの選び方」にまとめました。
まとめ
- 実装は1つにまとめ、MCP は薄い殻にする。
- 入力は JSON で受け、エージェント特有の誤り(パス、エンコード、制御文字)を前提に検証する。
- 使っていないツールは、呼ばれなくてもコストがかかる。30日の呼び出し数で棚卸しする。