Build Your Own MCP Server: Create a Custom Capability for Claude Code

Build a project context manager MCP Server from scratch with TypeScript — implementing decision logging, task management, and cross-session retrieval, covering tool registration, Resources, debugging, and publishing.


The previous three MCP articles were all about connecting to existing things — off-the-shelf MCP Servers, databases, internal APIs. This one is different: we're going to build an MCP Server from scratch, one that does something no existing tool can do.

Not wrapping someone else's API — implementing our own logic.

What We're Building: A Project Context Manager

Claude Code has a real-world pain point: it has no memory across sessions. Every time you start a new conversation, you have to re-explain the project background, recent decisions, and in-progress tasks. CLAUDE.md helps with some of this, but it's static — it doesn't update automatically as the project evolves.

We're going to build an MCP Server that lets Claude:

  • Record project decisions and context ("We decided to use Redis for caching because X")
  • Manage to-do items and in-progress tasks
  • Retrieve previously recorded context in new sessions

All data is stored in a local JSON file with no external dependencies.

Project Setup

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/**/*"]
}

Add "type": "module" to your package.json.

The Data Layer

Let's start by nailing down data persistence. All data lives in .claude/project-context.json at the project root.

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;
  }
}

A few design choices worth noting:

  • IDs use the first 8 characters of a UUID — short enough to reference verbally ("update task a3f2b1c9")
  • Every write operation persists immediately; there's no in-memory caching
  • The data structure is intentionally simple — just enough to get the job done

Registering Tools

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);

Registering a Resource

Beyond tools, MCP also supports Resources — data that Claude can proactively read. We'll register a "project summary" as a Resource so Claude can grab the full context at the start of a session:

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),
      },
    ],
  };
});

The difference between Resources and Tools: Tools are invoked by Claude on demand during a conversation; Resources are static context that Claude can read, similar to CLAUDE.md but dynamically generated.

Build and Configure

npx tsc

Configure 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"
      }
    }
  }
}

Restart Claude Code and type /mcp to confirm that project-context appears in the list.

Using It in Practice

Once everything is configured, conversations with Claude start looking like this:

Recording a decision

我们刚讨论完,决定用 Bull + Redis 做任务队列,不用 RabbitMQ。
主要原因是团队已经在用 Redis,不想引入新的基础设施。
帮我记一下。

→ Claude 调用 record_decision
→ 已记录决策 [a3f2b1c9]: 任务队列选型:Bull + Redis

Retrieving context across sessions

In a new session:

之前我们为什么选了 Bull 而不是 RabbitMQ?

→ Claude 调用 search_decisions("Bull")
→ 找到之前的记录,完整复述决策原因

Task tracking

接下来要做三件事:
1. 实现用户注册接口
2. 写注册邮件的模板
3. 加上邮箱验证流程

→ Claude 调用 add_task 三次
→ 三个任务分别创建,状态都是 todo
注册接口写完了,帮我更新状态

→ Claude 调用 update_task(id, "done")
→ 同时列出剩余的 todo 任务

Key Takeaways from Building an MCP Server

After building this server, here are a few practical lessons learned.

Make Error Handling User-Friendly

When an MCP tool throws an error, Claude reads the error message back to the user. So error messages should read like plain language:

// 差
throw new Error("ENOENT");

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

isError: true tells Claude the call failed, and it will adjust its behavior accordingly (for example, by retrying with a different approach).

Don't Expose Too Many Tools

5–10 tools per MCP Server is a reasonable range. Beyond 15, the probability of Claude picking the wrong tool goes up noticeably. If you have too many capabilities, split them across multiple servers.

Return Values Should Be Informative

Returning "operation successful" isn't enough. Claude needs to know what happened to continue the conversation effectively:

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

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

Use Zod for Parameter Validation

The MCP SDK has native zod support. Using it to define parameters gives you not only type checking but also usage hints for Claude via .describe(). Without descriptions, Claude is left guessing.

stdio Is the Simplest Transport

MCP supports two transport methods: stdio (standard I/O) and HTTP+SSE. For locally-run MCP Servers, stdio is all you need — Claude Code launches the server as a child process and communicates over stdin/stdout.

HTTP+SSE is better suited for remotely deployed servers, like a company-wide MCP service.

Debugging

Use the MCP Inspector

Anthropic provides a debugging tool called MCP Inspector that lets you test your MCP Server directly in the browser:

npx @modelcontextprotocol/inspector node dist/index.js

It launches a web UI where you can manually invoke each tool, inspect inputs and outputs, and check for errors. Much more efficient than testing repeatedly inside Claude Code.

Add Logging

An MCP Server's stdout is reserved for JSON-RPC communication, so you can't use console.log. Use console.error for debug output instead — it writes to stderr:

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

Or write to a file:

import { appendFileSync } from "fs";

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

Publishing and Distribution

Once your MCP Server is stable, there are a few ways to distribute it:

npm package

Add a bin field and a shebang, then publish to npm:

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

Add this to the top of dist/index.js:

#!/usr/bin/env node

Usage:

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

Docker

If your server has external dependencies (databases, Redis), Docker gives you a cleaner package:

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

Note the -i flag — stdio transport requires stdin to stay open.

What's Next

The example in this article is a minimal but complete MCP Server. From here, you can extend it in many directions:

  • Add a project timeline (automatically log what was done each day)
  • Integrate git log (link commits to decisions)
  • Support multiple projects (one server managing context for several projects)
  • Add prompt templates (e.g., a predefined format for generating daily project summaries)

The limits of MCP are the limits of your imagination. If you can write it in code, you can turn it into a tool for Claude.