Construa do zero um MCP Server de gerenciamento de contexto de projeto com TypeScript — registro de decisões, gestão de tarefas, busca entre sessões, registro de ferramentas, Resources, depuração e publicação.
Os três artigos anteriores sobre MCP tratavam de integrar coisas já existentes — MCP Servers prontos, bancos de dados, APIs internas. Este artigo é diferente: vamos construir um MCP Server do zero, para fazer algo que as ferramentas atuais não conseguem fazer.
Não se trata de encapsular a API de outra pessoa, mas de implementar a nossa própria lógica.
O Claude Code tem um ponto fraco real: ele não tem memória entre sessões. Toda vez que você abre uma nova sessão, precisa explicar novamente o contexto do projeto, as decisões recentes e as tarefas em andamento. O CLAUDE.md resolve parte do problema, mas ele é estático e não se atualiza automaticamente conforme o projeto avança.
Vamos criar um MCP Server que permita ao Claude:
Os dados ficam armazenados em um arquivo JSON local, sem depender de nenhum serviço externo.
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/**/*"]
}
Adicione "type": "module" no package.json.
Primeiro, resolvemos a leitura e escrita dos dados. Todos os dados ficam em .claude/project-context.json na raiz do projeto.
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;
}
}
Algumas decisões de design:
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",
});
// ---- Gerenciamento de decisões ----
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) }],
};
}
);
// ---- Gerenciamento de tarefas ----
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) }],
};
}
);
// ---- Inicialização ----
const transport = new StdioServerTransport();
server.connect(transport);
Além de ferramentas, o MCP também suporta Resources — dados que o Claude pode ler de forma proativa. Registramos um «resumo do projeto» como Resource, para que o Claude possa obter o contexto completo ao iniciar uma sessão:
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),
},
],
};
});
A diferença entre Resource e Tool: um Tool é algo que o Claude invoca sob demanda durante a conversa; um Resource é um contexto estático que o Claude pode ler, semelhante ao CLAUDE.md, mas gerado dinamicamente.
npx tsc
Configuração do 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"
}
}
}
}
Reinicie o Claude Code e digite /mcp para confirmar que project-context aparece na lista.
Depois de configurado, as conversas com o Claude ficam assim:
Registrar uma decisão
Acabamos de discutir e decidimos usar Bull + Redis para a fila de tarefas,
em vez de RabbitMQ. O motivo principal é que a equipe já usa Redis
e não queremos introduzir uma nova infraestrutura.
Anota aí, por favor.
→ Claude invoca record_decision
→ Decisão registrada [a3f2b1c9]: Escolha de fila de tarefas: Bull + Redis
Busca entre sessões
Em uma nova sessão:
Por que escolhemos Bull em vez de RabbitMQ?
→ Claude invoca search_decisions("Bull")
→ Encontra o registro anterior e reproduz a justificativa completa
Acompanhamento de tarefas
Temos três coisas pra fazer em seguida:
1. Implementar o endpoint de cadastro de usuários
2. Criar o template do e-mail de cadastro
3. Adicionar o fluxo de verificação de e-mail
→ Claude invoca add_task três vezes
→ As três tarefas são criadas com status todo
O endpoint de cadastro ficou pronto, atualiza o status.
→ Claude invoca update_task(id, "done")
→ Ao mesmo tempo, lista as tarefas pendentes restantes
Depois de concluir este Server, resumo algumas lições práticas.
Quando uma ferramenta MCP falha, o Claude mostra a mensagem de erro ao usuário. Por isso, as mensagens de erro precisam ser compreensíveis:
// Ruim
throw new Error("ENOENT");
// Bom
return {
content: [{ type: "text", text: `找不到任务 ${id},用 list_tasks 查看所有任务` }],
isError: true,
};
isError: true indica ao Claude que a chamada falhou, e ele ajustará seu comportamento de acordo (por exemplo, tentando de outra forma).
Um MCP Server com 5 a 10 ferramentas é razoável. Acima de 15, a probabilidade de o Claude escolher a ferramenta errada aumenta consideravelmente. Se houver capacidades demais, divida em vários Servers.
Uma ferramenta retornar «operação bem-sucedida» não é suficiente. O Claude precisa saber qual foi o resultado da operação para continuar a conversa:
// Ruim
return { content: [{ type: "text", text: "OK" }] };
// Bom
return { content: [{ type: "text", text: `已添加任务 [${task.id}]: ${task.title}` }] };
O SDK do MCP suporta zod nativamente. Usá-lo para definir parâmetros não só permite a validação de tipos, como também fornece instruções de uso ao Claude por meio do .describe(). Sem descrição, o Claude só pode adivinhar.
O MCP suporta dois mecanismos de transporte: stdio (entrada/saída padrão) e HTTP+SSE. Para MCP Servers de desenvolvimento local, stdio é suficiente: o Claude Code inicia o Server como processo filho e a comunicação acontece via stdin/stdout.
HTTP+SSE é mais adequado para Servers implantados remotamente, como serviços MCP fornecidos internamente em uma empresa.
A Anthropic oferece uma ferramenta de depuração chamada MCP Inspector, que permite testar seu MCP Server diretamente no navegador:
npx @modelcontextprotocol/inspector node dist/index.js
Ela abre uma interface web onde você pode invocar cada ferramenta manualmente, ver as entradas e saídas e verificar erros. Muito mais eficiente do que ficar testando repetidamente dentro do Claude Code.
O stdout do MCP Server está ocupado (é usado para a comunicação JSON-RPC), então você não pode usar console.log. Use console.error para informações de depuração, que são escritas no stderr:
console.error("[debug] processing request:", JSON.stringify(params));
Ou escreva em um arquivo:
import { appendFileSync } from "fs";
function log(msg: string) {
appendFileSync("/tmp/mcp-debug.log", `${new Date().toISOString()} ${msg}\n`);
}
Quando seu MCP Server estiver estável, existem várias formas de distribuí-lo:
Pacote npm
Adicione o campo bin e um shebang, e publique no npm:
{
"name": "@yourcompany/mcp-project-context",
"bin": {
"mcp-project-context": "./dist/index.js"
}
}
Adicione no topo de dist/index.js:
#!/usr/bin/env node
Para usar:
{
"mcpServers": {
"project-context": {
"command": "npx",
"args": ["-y", "@yourcompany/mcp-project-context"]
}
}
}
Docker
Se o Server tiver dependências externas (banco de dados, Redis), empacotá-lo com Docker é mais limpo:
{
"mcpServers": {
"project-context": {
"command": "docker",
"args": ["run", "-i", "--rm", "yourcompany/mcp-project-context"]
}
}
}
Repare no parâmetro -i — o transporte stdio precisa manter o stdin aberto.
O exemplo deste artigo é um MCP Server mínimo, mas completo. A partir dele, você pode expandir em muitas direções:
Os limites do MCP dependem da sua imaginação. Tudo que puder ser programado pode se tornar uma ferramenta para o Claude.