Napisz własny MCP Server: stwórz dedykowaną zdolność dla Claude Code

Budujemy MCP Server do zarządzania kontekstem projektu od zera w TypeScript — rejestrowanie decyzji, zarządzanie zadaniami, wyszukiwanie między sesjami, rejestracja narzędzi, Resources, debugowanie i publikacja.


W poprzednich trzech artykułach o MCP podłączaliśmy gotowe rozwiązania — istniejące serwery MCP, bazy danych, wewnętrzne API. Ten artykuł jest zupełnie inny: od zera zbudujemy własny MCP Server, który robi coś, czego nie potrafi żadne istniejące narzędzie.

Nie opakowujemy cudzego API, lecz piszemy własną logikę.

Co będziemy budować: menedżer kontekstu projektu

Claude Code ma praktyczny problem: nie pamięta niczego między sesjami. Za każdym razem, gdy otwierasz nowy czat, musisz od nowa tłumaczyć kontekst projektu, ostatnie decyzje, bieżące zadania. CLAUDE.md częściowo pomaga, ale jest statyczny i nie aktualizuje się automatycznie w toku projektu.

Stworzymy MCP Server, który pozwoli Claude:

  • Zapisywać decyzje projektowe i kontekst („Zdecydowaliśmy się na Redis do cache'owania, bo X")
  • Zarządzać listą zadań i ich statusami
  • Pobierać wcześniej zapisany kontekst w nowych sesjach

Dane przechowywane są w lokalnym pliku JSON — żadne zewnętrzne usługi nie są potrzebne.

Inicjalizacja projektu

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

W package.json dodaj "type": "module".

Warstwa danych

Zacznijmy od odczytu i zapisu danych. Wszystko przechowywane jest w pliku .claude/project-context.json w katalogu głównym projektu.

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

Kilka decyzji projektowych:

  • ID to pierwsze 8 znaków UUID — wystarczająco krótkie, żeby podać je słownie („zaktualizuj zadanie a3f2b1c9")
  • Każda operacja zapisu natychmiast trafia na dysk, bez cache'owania w pamięci
  • Struktura danych jest celowo prosta — tyle, ile trzeba

Rejestracja narzędzi

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

Rejestracja Resource

Oprócz narzędzi MCP obsługuje Resource — dane, które Claude może odczytywać z własnej inicjatywy. Zarejestrujemy „podsumowanie projektu" jako Resource, żeby Claude miał pełny kontekst na początku sesji:

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

Różnica między Resource a Tool: Tool to coś, co Claude wywołuje w trakcie rozmowy w razie potrzeby; Resource to statyczny kontekst, który Claude może odczytać — coś jak dynamicznie generowany odpowiednik CLAUDE.md.

Kompilacja i konfiguracja

npx tsc

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

Uruchom ponownie Claude Code i wpisz /mcp, żeby upewnić się, że project-context pojawił się na liście.

Praktyczne użycie

Po skonfigurowaniu rozmowa z Claude będzie wyglądać tak:

Zapisywanie decyzji

Właśnie ustaliliśmy, że użyjemy Bull + Redis jako kolejki zadań,
a nie RabbitMQ. Główny powód — zespół już korzysta z Redisa
i nie chce dodawać nowej infrastruktury. Zapisz to.

→ Claude wywołuje record_decision
→ Zapisano decyzję [a3f2b1c9]: Wybór kolejki zadań: Bull + Redis

Wyszukiwanie między sesjami

W nowej sesji:

Dlaczego wtedy wybraliśmy Bull, a nie RabbitMQ?

→ Claude wywołuje search_decisions("Bull")
→ Znajduje wcześniejszy wpis, w pełni przytacza powody decyzji

Śledzenie zadań

Mamy trzy rzeczy do zrobienia:
1. Zaimplementować endpoint rejestracji użytkowników
2. Napisać szablon maila rejestracyjnego
3. Dodać weryfikację adresu email

→ Claude wywołuje add_task trzy razy
→ Tworzone są trzy zadania ze statusem todo
Endpoint rejestracji jest gotowy, zaktualizuj status

→ Claude wywołuje update_task(id, "done")
→ Przy okazji wyświetla pozostałe zadania ze statusem todo

Najważniejsze zasady przy tworzeniu MCP Server

Po zbudowaniu tego serwera — kilka praktycznych wniosków.

Błędy powinny być zrozumiałe

Kiedy narzędzie MCP zwraca błąd, Claude odczytuje go użytkownikowi. Dlatego komunikaty o błędach powinny być napisane ludzkim językiem:

// Źle
throw new Error("ENOENT");

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

isError: true informuje Claude, że wywołanie się nie powiodło, i dostosuje on dalsze działanie (np. spróbuje innego podejścia).

Nie przesadzaj z liczbą narzędzi

5–10 narzędzi na jeden MCP Server to rozsądny zakres. Powyżej 15 — prawdopodobieństwo, że Claude wybierze niewłaściwe narzędzie, wyraźnie rośnie. Jeśli funkcjonalności jest za dużo, podziel ją na kilka serwerów.

Zwracaj informacyjne odpowiedzi

Zwrócenie „operacja wykonana" to za mało. Claude musi znać wynik operacji, żeby kontynuować rozmowę:

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

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

Używaj zod do walidacji parametrów

MCP SDK natywnie wspiera zod. Za jego pomocą można nie tylko sprawdzać typy parametrów, ale też przez .describe() dawać Claude instrukcje dotyczące użycia. Parametry bez opisu Claude będzie musiał odgadywać.

stdio to najprostszy sposób transportu

MCP obsługuje dwa sposoby transportu: stdio (standardowe wejście/wyjście) i HTTP+SSE. Do lokalnego developmentu stdio w zupełności wystarcza: Claude Code uruchamia serwer jako proces potomny i komunikuje się przez stdin/stdout.

HTTP+SSE nadaje się do zdalnie wdrożonych serwerów — na przykład scentralizowanych usług MCP wewnątrz firmy.

Debugowanie

MCP Inspector

Anthropic udostępnia narzędzie do debugowania — MCP Inspector. Pozwala testować MCP Server bezpośrednio w przeglądarce:

npx @modelcontextprotocol/inspector node dist/index.js

Uruchamia interfejs webowy, w którym można ręcznie wywoływać każde narzędzie, przeglądać dane wejściowe i wyjściowe, sprawdzać błędy. O wiele efektywniejsze niż testowanie przez Claude Code.

Logowanie

stdout MCP Servera jest zajęty (używany do komunikacji JSON-RPC), więc nie można korzystać z console.log. Do informacji debugowych używaj console.error — trafia do stderr:

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

Albo zapisuj do pliku:

import { appendFileSync } from "fs";

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

Publikacja i dystrybucja

Gdy Twój MCP Server się ustabilizuje, jest kilka sposobów na jego dystrybucję:

Pakiet npm

Dodaj pole bin i shebang, a potem opublikuj w npm:

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

Na początku dist/index.js dodaj:

#!/usr/bin/env node

Użycie:

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

Docker

Jeśli serwer ma zewnętrzne zależności (bazy danych, Redis), obraz Docker to czystsze rozwiązanie:

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

Zwróć uwagę na flagę -i — transport stdio wymaga otwartego stdin.

Co dalej

Przykład z tego artykułu to minimalny, ale w pełni funkcjonalny MCP Server. Na jego bazie można rozwijać projekt w wielu kierunkach:

  • Dodać oś czasu projektu (automatycznie zapisywać, co robiono każdego dnia)
  • Zintegrować git log (powiązać commity z decyzjami)
  • Obsłużyć wiele projektów (jeden serwer zarządza kontekstem kilku projektów)
  • Dodać szablony promptów (np. predefiniowany format „dziennego raportu projektowego")

Możliwości MCP ogranicza tylko Twoja wyobraźnia. Wszystko, co da się zapisać w kodzie, można zamienić w narzędzie dla Claude.