Crie seu próprio MCP Server: construa uma capacidade sob medida para o Claude Code

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 que vamos fazer: um gerenciador de contexto de projeto

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:

  • Registrar decisões e contexto do projeto («Decidimos usar Redis para cache, pelo motivo X»)
  • Gerenciar tarefas pendentes e em andamento
  • Recuperar o contexto registrado anteriormente em novas sessões

Os dados ficam armazenados em um arquivo JSON local, sem depender de nenhum serviço externo.

Inicialização do projeto

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.

Camada de dados

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:

  • Os IDs usam os primeiros 8 caracteres de um UUID, curtos o suficiente para serem referenciados verbalmente («atualize a tarefa a3f2b1c9»)
  • Toda operação de escrita é persistida imediatamente, sem cache em memória
  • A estrutura de dados foi mantida intencionalmente simples — o suficiente para funcionar

Registro de ferramentas

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

Registro de Resources

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.

Compilação e configuração

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.

Uso na prática

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

Pontos-chave no desenvolvimento de MCP Servers

Depois de concluir este Server, resumo algumas lições práticas.

Erros devem ser amigáveis

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

Não exponha ferramentas demais

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.

Valores de retorno devem ser informativos

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

Use zod para validação de parâmetros

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.

stdio é o mecanismo de transporte mais simples

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.

Métodos de depuração

Use o MCP Inspector

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.

Adicione logs

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

Publicação e distribuição

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.

Próximos passos

O exemplo deste artigo é um MCP Server mínimo, mas completo. A partir dele, você pode expandir em muitas direções:

  • Adicionar uma linha do tempo do projeto (registrar automaticamente o que foi feito a cada dia)
  • Integrar o git log (vincular commits às decisões)
  • Suportar múltiplos projetos (um único Server gerenciando o contexto de vários projetos)
  • Adicionar templates de prompts (por exemplo, predefinir um formato de geração para um «relatório diário do projeto»)

Os limites do MCP dependem da sua imaginação. Tudo que puder ser programado pode se tornar uma ferramenta para o Claude.