MCP Serverを自作する:Claude Code専用の能力をゼロから構築する

TypeScriptでプロジェクトコンテキスト管理用のMCP Serverをゼロから構築。意思決定の記録、タスク管理、セッション横断検索を実装し、ツール登録・Resource・デバッグ・配布まで網羅する。


これまでの3本のMCP記事では、既存のものを接続する話をしてきた——既成のMCP Server、データベース、社内API。今回は違う。ゼロからMCP Serverを構築して、既存のツールではできないことをやらせる。

他人のAPIをラップするのではなく、自分でロジックを実装する。

作るもの:プロジェクトコンテキストマネージャー

Claude Codeには実際の課題がある。セッションをまたいだ記憶がないのだ。新しいセッションを開くたびに、プロジェクトの背景、最近の意思決定、進行中のタスクをもう一度伝える必要がある。CLAUDE.mdで一部は解決できるが、静的なファイルなのでプロジェクトの進行に合わせて自動更新されない。

そこで、以下のことができるMCP Serverを作る:

  • プロジェクトの意思決定とコンテキストを記録する(「キャッシュにはRedisを使うことにした。理由はX」)
  • TODOや進行中のタスクを管理する
  • 新しいセッションで以前記録したコンテキストを検索する

データはローカルのJSONファイルに保存し、外部サービスには一切依存しない。

プロジェクトの初期化

mkdir mcp-project-context && cd mcp-project-context
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

package.json"type": "module"を追加する。

データ層

まずデータの読み書きを整える。すべてのデータはプロジェクトルートの.claude/project-context.jsonに保存する。

src/store.ts

import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { dirname } from "path";

export interface Decision {
  id: string;
  title: string;
  content: string;
  tags: string[];
  createdAt: string;
}

export interface Task {
  id: string;
  title: string;
  status: "todo" | "in-progress" | "done";
  notes: string;
  createdAt: string;
  updatedAt: string;
}

export interface ProjectContext {
  decisions: Decision[];
  tasks: Task[];
}

export class Store {
  private data: ProjectContext;

  constructor(private filePath: string) {
    this.data = this.load();
  }

  private load(): ProjectContext {
    if (!existsSync(this.filePath)) {
      return { decisions: [], tasks: [] };
    }
    return JSON.parse(readFileSync(this.filePath, "utf-8"));
  }

  private save(): void {
    const dir = dirname(this.filePath);
    if (!existsSync(dir)) {
      mkdirSync(dir, { recursive: true });
    }
    writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
  }

  // Decisions
  addDecision(title: string, content: string, tags: string[]): Decision {
    const decision: Decision = {
      id: crypto.randomUUID().slice(0, 8),
      title,
      content,
      tags,
      createdAt: new Date().toISOString(),
    };
    this.data.decisions.push(decision);
    this.save();
    return decision;
  }

  searchDecisions(query: string): Decision[] {
    const q = query.toLowerCase();
    return this.data.decisions.filter(
      (d) =>
        d.title.toLowerCase().includes(q) ||
        d.content.toLowerCase().includes(q) ||
        d.tags.some((t) => t.toLowerCase().includes(q))
    );
  }

  listDecisions(): Decision[] {
    return this.data.decisions;
  }

  // Tasks
  addTask(title: string): Task {
    const task: Task = {
      id: crypto.randomUUID().slice(0, 8),
      title,
      status: "todo",
      notes: "",
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    this.data.tasks.push(task);
    this.save();
    return task;
  }

  updateTask(
    id: string,
    updates: Partial<Pick<Task, "status" | "notes" | "title">>
  ): Task | null {
    const task = this.data.tasks.find((t) => t.id === id);
    if (!task) return null;
    Object.assign(task, updates, { updatedAt: new Date().toISOString() });
    this.save();
    return task;
  }

  listTasks(status?: Task["status"]): Task[] {
    if (status) {
      return this.data.tasks.filter((t) => t.status === status);
    }
    return this.data.tasks;
  }
}

設計上のポイント:

  • IDにはUUIDの先頭8文字を使用。口頭で参照できるほど短い(「タスクa3f2b1c9を更新して」)
  • 書き込み操作のたびに即座に永続化し、メモリキャッシュは行わない
  • データ構造は意図的にシンプルに保つ。必要十分であればよい

ツールの登録

src/index.ts

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { Store } from "./store.js";

const CONTEXT_FILE =
  process.env.CONTEXT_FILE || ".claude/project-context.json";
const store = new Store(CONTEXT_FILE);

const server = new McpServer({
  name: "project-context",
  version: "1.0.0",
});

// ---- 意思決定管理 ----

server.tool(
  "record_decision",
  "記録一条项目决策。当团队做出技术选型、架构决定、或重要的设计取舍时使用",
  {
    title: z.string().describe("決策標題,如「缓存方案选型」"),
    content: z.string().describe("決策内容和原因"),
    tags: z
      .array(z.string())
      .optional()
      .default([])
      .describe("标签,便于后续检索,如 [\"cache\", \"architecture\"]"),
  },
  async ({ title, content, tags }) => {
    const decision = store.addDecision(title, content, tags);
    return {
      content: [
        {
          type: "text",
          text: `已记录决策 [${decision.id}]: ${decision.title}`,
        },
      ],
    };
  }
);

server.tool(
  "search_decisions",
  "搜索已记录的项目决策。当需要回顾之前为什么做某个选择时使用",
  {
    query: z.string().describe("搜索关键词,会匹配标题、内容和标签"),
  },
  async ({ query }) => {
    const results = store.searchDecisions(query);
    if (results.length === 0) {
      return {
        content: [{ type: "text", text: `没有找到与「${query}」相关的决策` }],
      };
    }
    return {
      content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
    };
  }
);

server.tool(
  "list_decisions",
  "列出所有已记录的项目决策",
  {},
  async () => {
    const decisions = store.listDecisions();
    return {
      content: [{ type: "text", text: JSON.stringify(decisions, null, 2) }],
    };
  }
);

// ---- タスク管理 ----

server.tool(
  "add_task",
  "添加一个待办任务",
  {
    title: z.string().describe("任务标题"),
  },
  async ({ title }) => {
    const task = store.addTask(title);
    return {
      content: [
        { type: "text", text: `已添加任务 [${task.id}]: ${task.title}` },
      ],
    };
  }
);

server.tool(
  "update_task",
  "更新任务的状态或备注",
  {
    id: z.string().describe("任务 ID"),
    status: z
      .enum(["todo", "in-progress", "done"])
      .optional()
      .describe("新状态"),
    notes: z.string().optional().describe("备注信息"),
    title: z.string().optional().describe("更新标题"),
  },
  async ({ id, status, notes, title }) => {
    const task = store.updateTask(id, { status, notes, title });
    if (!task) {
      return {
        content: [{ type: "text", text: `找不到任务 ${id}` }],
        isError: true,
      };
    }
    return {
      content: [{ type: "text", text: JSON.stringify(task, null, 2) }],
    };
  }
);

server.tool(
  "list_tasks",
  "列出任务。可以按状态筛选",
  {
    status: z
      .enum(["todo", "in-progress", "done"])
      .optional()
      .describe("按状态筛选,不传则列出全部"),
  },
  async ({ status }) => {
    const tasks = store.listTasks(status);
    return {
      content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }],
    };
  }
);

// ---- 起動 ----

const transport = new StdioServerTransport();
server.connect(transport);

Resourceの登録

ツールに加えて、MCPはResourceもサポートしている。Claudeが能動的に読み取れるデータだ。「プロジェクト概要」をResourceとして登録すれば、Claudeはセッション開始時に完全なコンテキストを取得できる:

server.resource("project-summary", "project://summary", async (uri) => {
  const decisions = store.listDecisions();
  const tasks = store.listTasks();
  const activeTasks = tasks.filter((t) => t.status !== "done");

  const summary = {
    totalDecisions: decisions.length,
    recentDecisions: decisions.slice(-5),
    activeTasks,
    completedTasks: tasks.filter((t) => t.status === "done").length,
  };

  return {
    contents: [
      {
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(summary, null, 2),
      },
    ],
  };
});

ResourceとToolの違い:Toolは会話中にClaudeが必要に応じて呼び出すもの。Resourceは静的なコンテキストとしてClaudeが読み取れるもので、CLAUDE.mdに似ているが動的に生成できる。

ビルドと設定

npx tsc

Claude Codeの設定:

{
  "mcpServers": {
    "project-context": {
      "command": "node",
      "args": ["/path/to/mcp-project-context/dist/index.js"],
      "env": {
        "CONTEXT_FILE": "/path/to/your-project/.claude/project-context.json"
      }
    }
  }
}

Claude Codeを再起動し、/mcpと入力してproject-contextがリストに表示されることを確認する。

実際の使い方

設定が完了すると、Claudeとの会話はこんな感じになる:

意思決定の記録

さっき話し合って、タスクキューにはBull + Redisを使うことにした。RabbitMQは使わない。
主な理由は、チームがすでにRedisを使っていて、新しいインフラを増やしたくないから。
記録しておいて。

→ Claudeがrecord_decisionを呼び出す
→ 決策を記録しました [a3f2b1c9]: タスクキュー選定:Bull + Redis

セッションをまたいだ検索

新しいセッションで:

前にBullを選んでRabbitMQにしなかったのはなぜだっけ?

→ Claudeがsearch_decisions("Bull")を呼び出す
→ 以前の記録を見つけて、決定理由を詳しく説明する

タスク管理

次にやることは3つ:
1. ユーザー登録APIの実装
2. 登録メールのテンプレート作成
3. メールアドレス認証フローの追加

→ Claudeがadd_taskを3回呼び出す
→ 3つのタスクがそれぞれ作成され、ステータスはすべてtodo
登録APIができたので、ステータスを更新して

→ Claudeがupdate_task(id, "done")を呼び出す
→ 同時に残りのtodoタスクを一覧表示する

MCP Server開発のポイント

このServerを作り終えて、いくつかの実践的な知見をまとめておく。

エラー処理はユーザーフレンドリーに

MCPツールがエラーを返すと、Claudeはそのエラーメッセージをそのままユーザーに伝える。だからエラーメッセージは人間が読んで分かる文章にする:

// ダメ
throw new Error("ENOENT");

// 良い
return {
  content: [{ type: "text", text: `找不到任务 ${id},用 list_tasks 查看所有任务` }],
  isError: true,
};

isError: trueはClaudeにこの呼び出しが失敗したことを伝え、Claudeはそれに応じて後続の動作を調整する(例えば別の方法でリトライする)。

ツール数は多すぎないこと

1つのMCP Serverが公開するツールは5〜10個が妥当だ。15個を超えると、Claudeが間違ったツールを選ぶ確率が明らかに上がる。機能が多すぎる場合は、複数のServerに分割しよう。

戻り値には情報を持たせる

ツールが「操作成功」とだけ返すのでは不十分だ。Claudeは操作結果が何だったのかを知らないと会話を続けられない:

// ダメ
return { content: [{ type: "text", text: "OK" }] };

// 良い
return { content: [{ type: "text", text: `已添加任务 [${task.id}]: ${task.title}` }] };

zodでパラメータバリデーションを行う

MCP SDKはzodをネイティブサポートしている。zodでパラメータを定義すれば型チェックができるだけでなく、.describe()でClaudeに使い方の説明を提供できる。説明のないパラメータは、Claudeが推測するしかない。

stdioが最もシンプルなトランスポート

MCPは2種類のトランスポートをサポートしている:stdio(標準入出力)とHTTP+SSE。ローカル開発用のMCP Serverならstdioで十分だ。Claude CodeがServerを子プロセスとして起動し、stdin/stdoutで通信する。

HTTP+SSEはリモートデプロイされるServerに適している。例えば社内で統一的に提供されるMCPサービスなど。

デバッグ方法

MCP Inspectorを使う

AnthropicはMCP Inspectorというデバッグツールを提供している。ブラウザ上でMCP Serverを直接テストできる:

npx @modelcontextprotocol/inspector node dist/index.js

Webインターフェースが起動し、各ツールを手動で呼び出したり、入出力を確認したり、エラーをチェックしたりできる。Claude Code上で何度もテストするよりはるかに効率的だ。

ログを追加する

MCP Serverのstdoutは占有されている(JSON-RPC通信に使用)ので、console.logは使えない。デバッグ情報はconsole.errorで出力し、stderrに書き込む:

console.error("[debug] processing request:", JSON.stringify(params));

またはファイルに書き出す:

import { appendFileSync } from "fs";

function log(msg: string) {
  appendFileSync("/tmp/mcp-debug.log", `${new Date().toISOString()} ${msg}\n`);
}

公開と配布

MCP Serverが安定したら、いくつかの配布方法がある:

npmパッケージ

binフィールドとshebangを追加して、npmに公開する:

{
  "name": "@yourcompany/mcp-project-context",
  "bin": {
    "mcp-project-context": "./dist/index.js"
  }
}

dist/index.jsの先頭に追加:

#!/usr/bin/env node

使用する際:

{
  "mcpServers": {
    "project-context": {
      "command": "npx",
      "args": ["-y", "@yourcompany/mcp-project-context"]
    }
  }
}

Docker

Serverに外部依存(データベース、Redis)がある場合、Dockerでパッケージングするほうがクリーンだ:

{
  "mcpServers": {
    "project-context": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "yourcompany/mcp-project-context"]
    }
  }
}

-iオプションに注意——stdioトランスポートではstdinを開いたままにする必要がある。

次にできること

この記事の例は、最小限だが完全なMCP Serverだ。これをベースに、さまざまな方向に拡張できる:

  • プロジェクトのtimelineを追加する(毎日何をしたかを自動記録)
  • git logと連携する(コミット履歴と意思決定を紐づける)
  • マルチプロジェクトに対応する(1つのServerで複数プロジェクトのコンテキストを管理)
  • promptテンプレートを追加する(例えば「プロジェクト日報」の生成フォーマットを事前に定義)

MCPの可能性は想像力次第だ。コードで書けることなら、何でもClaudeのツールにできる。