Voltar para todos os artigos
Construindo Seu Primeiro Servidor MCP de Produção

Construindo Seu Primeiro Servidor MCP de Produção

Tutorial hands-on para construir um servidor MCP completo em TypeScript. Aprenda implementação de tools, resources, prompts, tratamento de erros, caching,...

Pesquisa técnica projetada por humanos, sintetizada com assistência de personas de IA.
10 min de leitura

TL;DR / Sumário Executivo

Tutorial hands-on para construir um servidor MCP completo em TypeScript. Aprenda implementação de tools, resources, prompts, tratamento de erros, caching,...

💡 TL;DR (Resumo)

Este tutorial prático guia você na construção de um servidor MCP completo que analisa Pull Requests do GitHub. Usando TypeScript e o SDK oficial, você implementará tools (get_pr_details, get_pr_diff), resources (descrição do PR atual) e prompts (template de code review). O artigo cobre padrões de produção: tratamento de erros, caching, rate limiting, e deployment com Docker e HTTP/SSE.

Teoria só leva você até certo ponto. O artigo anterior explicou o que é MCP e por que importa; este coloca a mão na massa com implementação real. Vamos construir um servidor MCP completo que faz algo útil—analisar pull requests do GitHub—e cobrir os padrões, armadilhas e considerações de produção que separam exemplos de brinquedo de ferramentas do mundo real.


Escolhendo Seu SDK

MCP tem SDKs oficiais para TypeScript, Python, C#, Go, Java e Kotlin. A escolha depende do seu ecossistema:

SDKMelhor Para
TypeScriptServiços web, APIs, JavaScript tooling
PythonData science, ML pipelines, codebases Python
GoBinários únicos, ambientes com recursos limitados
C#Ecossistemas .NET, Azure deployments

Para este tutorial, usaremos TypeScript pela maturidade do SDK e excelente tooling.


Setup do Projeto

bash
mkdir github-pr-analyzer cd github-pr-analyzer npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node ts-node

Crie tsconfig.json:

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

Atualize package.json:

json
{ "type": "module", "scripts": { "build": "tsc", "start": "node dist/index.js" } }

O Esqueleto do Servidor

Todo servidor MCP segue a mesma estrutura básica:

typescript
// src/index.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; const server = new Server( { name: "github-pr-analyzer", version: "1.0.0", }, { capabilities: { tools: {}, resources: {}, prompts: {}, }, } ); // Registros de handlers virão aqui async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("GitHub PR Analyzer MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });

⚠️ Atenção: Use console.error para logging! O servidor comunica com o client via stdout, então qualquer log em stdout corrompe o stream JSON-RPC.


Implementando Sua Primeira Tool

Tools são funções que o LLM pode chamar. Cada tool precisa de nome, descrição, schema de input e handler:

typescript
import { z } from "zod"; // Define o schema de input usando Zod const GetPRDetailsSchema = z.object({ owner: z.string().describe("Owner do repositório (username ou organização)"), repo: z.string().describe("Nome do repositório"), pr_number: z.number().describe("Número do pull request"), }); // Registra o handler de listagem de tools server.setRequestHandler("tools/list", async () => { return { tools: [ { name: "get_pr_details", description: "Busca detalhes de um pull request do GitHub incluindo título, descrição, autor, status e arquivos alterados. Use para entender sobre o que é um PR antes de analisar as mudanças de código.", inputSchema: { type: "object", properties: { owner: { type: "string", description: "Owner do repositório" }, repo: { type: "string", description: "Nome do repositório" }, pr_number: { type: "number", description: "Número do PR" }, }, required: ["owner", "repo", "pr_number"], }, }, ], }; }); // Registra o handler de chamada de tools server.setRequestHandler("tools/call", async (request) => { const { name, arguments: args } = request.params; if (name === "get_pr_details") { const { owner, repo, pr_number } = GetPRDetailsSchema.parse(args); try { const response = await fetch( `https://api.github.com/repos/${owner}/${repo}/pulls/${pr_number}`, { headers: { Accept: "application/vnd.github.v3+json", // Adicione token para rate limits maiores: // Authorization: `token ${process.env.GITHUB_TOKEN}` }, } ); if (!response.ok) { throw new Error(`GitHub API error: ${response.status}`); } const pr = await response.json(); return { content: [ { type: "text", text: JSON.stringify({ title: pr.title, author: pr.user.login, state: pr.state, body: pr.body, additions: pr.additions, deletions: pr.deletions, changed_files: pr.changed_files, }, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Erro ao buscar detalhes do PR: ${error instanceof Error ? error.message : "Erro desconhecido"}`, }, ], isError: true, }; } } throw new Error(`Tool desconhecida: ${name}`); });

A descrição da tool é crucial—é a única informação que o LLM tem sobre quando e como usá-la. Uma descrição vaga leva a mau uso; uma descrição precisa guia o modelo para invocações apropriadas.


Adicionando uma Segunda Tool: Buscando o Diff

typescript
// Adicione à lista de tools { name: "get_pr_diff", description: "Busca o diff de código de um pull request do GitHub. Retorna as mudanças linha por linha. Use após get_pr_details quando precisar analisar as mudanças específicas de código.", inputSchema: { type: "object", properties: { owner: { type: "string", description: "Owner do repositório" }, repo: { type: "string", description: "Nome do repositório" }, pr_number: { type: "number", description: "Número do PR" }, }, required: ["owner", "repo", "pr_number"], }, } // Adicione ao handler tools/call if (name === "get_pr_diff") { const { owner, repo, pr_number } = GetPRDetailsSchema.parse(args); try { const response = await fetch( `https://api.github.com/repos/${owner}/${repo}/pulls/${pr_number}`, { headers: { Accept: "application/vnd.github.v3.diff", }, } ); if (!response.ok) { throw new Error(`GitHub API error: ${response.status}`); } const diff = await response.text(); // Trunca diffs muito grandes para evitar problemas de context window const maxLength = 50000; const truncated = diff.length > maxLength; const content = truncated ? diff.slice(0, maxLength) + "\n\n... [diff truncado]" : diff; return { content: [{ type: "text", text: content }], }; } catch (error) { return { content: [ { type: "text", text: `Erro ao buscar diff: ${error instanceof Error ? error.message : "Erro desconhecido"}`, }, ], isError: true, }; } }

Note a lógica de truncamento para diffs grandes. LLMs têm limites de context window, e despejar um diff de 100.000 linhas no contexto é tanto desperdiçador quanto provavelmente degradar a qualidade da resposta.


Adicionando Resources

Resources fornecem dados que o LLM pode ler sem chamadas de função explícitas:

typescript
// Estado para o PR atual let currentPR: { owner: string; repo: string; number: number; body: string } | null = null; server.setRequestHandler("resources/list", async () => { return { resources: [ { uri: "pr://current/description", name: "Descrição do PR Atual", description: "A descrição/body do PR atualmente analisado", mimeType: "text/markdown", }, ], }; }); server.setRequestHandler("resources/read", async (request) => { const { uri } = request.params; if (uri === "pr://current/description") { if (!currentPR) { return { contents: [ { uri, mimeType: "text/plain", text: "Nenhum PR carregado. Use get_pr_details primeiro.", }, ], }; } return { contents: [ { uri, mimeType: "text/markdown", text: currentPR.body || "Sem descrição fornecida.", }, ], }; } throw new Error(`Resource desconhecido: ${uri}`); });

Adicionando Prompts

Prompts codificam expertise em templates reutilizáveis:

typescript
server.setRequestHandler("prompts/list", async () => { return { prompts: [ { name: "review_pr", description: "Gera uma revisão de código completa para um pull request", arguments: [ { name: "owner", description: "Owner do repositório", required: true }, { name: "repo", description: "Nome do repositório", required: true }, { name: "pr_number", description: "Número do PR", required: true }, { name: "focus", description: "Áreas específicas para focar (security, performance, etc.)", required: false }, ], }, ], }; }); server.setRequestHandler("prompts/get", async (request) => { const { name, arguments: args } = request.params; if (name === "review_pr") { const focus = args?.focus || "qualidade geral, bugs potenciais e melhores práticas"; return { messages: [ { role: "user", content: { type: "text", text: `Por favor, revise o pull request ${args?.owner}/${args?.repo}#${args?.pr_number}. Primeiro, use get_pr_details para entender o contexto do PR. Depois, use get_pr_diff para ver as mudanças reais. Forneça uma revisão de código completa focando em: ${focus} Estruture sua revisão como: 1. Resumo: O que este PR faz? 2. Pontos Fortes: O que está bem feito? 3. Preocupações: Que problemas ou riscos você vê? 4. Sugestões: Melhorias específicas com exemplos de código onde útil 5. Veredito: Aprovar, solicitar mudanças, ou precisa discussão`, }, }, ], }; } throw new Error(`Prompt desconhecido: ${name}`); });

Testando com MCP Inspector

Antes de conectar a um client real, teste seu servidor com o MCP Inspector:

bash
npx @modelcontextprotocol/inspector node dist/index.js

Isso lança uma interface web onde você pode:

  • Ver as tools, resources e prompts que seu servidor expõe
  • Chamar tools com argumentos arbitrários
  • Ler resources
  • Ver a troca de mensagens JSON-RPC raw

Conectando ao Claude Desktop

Após testes no Inspector, conecte ao Claude Desktop. Edite o arquivo de configuração:

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json Windows: %APPDATA%\Claude\claude_desktop_config.json

json
{ "mcpServers": { "github-pr-analyzer": { "command": "node", "args": ["/caminho/absoluto/para/github-pr-analyzer/dist/index.js"], "env": { "GITHUB_TOKEN": "seu_token_github_aqui" } } } }

Reinicie o Claude Desktop. Seu servidor deve aparecer na lista de tools!


Padrões de Tratamento de Erros

typescript
class MCPError extends Error { constructor( message: string, public readonly code: string, public readonly details?: unknown ) { super(message); this.name = "MCPError"; } } function handleToolError(error: unknown): { content: Array<{ type: string; text: string }>; isError: true } { console.error("Tool error:", error); let message: string; if (error instanceof MCPError) { message = `${error.code}: ${error.message}`; } else if (error instanceof z.ZodError) { message = `Argumentos inválidos: ${error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join(", ")}`; } else if (error instanceof Error) { message = error.message; } else { message = "Ocorreu um erro inesperado"; } return { content: [{ type: "text", text: message }], isError: true, }; }

Rate Limiting e Caching

A API do GitHub tem rate limits (60 requests/hora sem autenticação, 5000/hora com). Implemente caching básico:

typescript
const cache = new Map<string, { data: unknown; timestamp: number }>(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutos async function cachedFetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> { const cached = cache.get(key); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.data as T; } const data = await fetcher(); cache.set(key, { data, timestamp: Date.now() }); return data; } // Uso no handler de tool const pr = await cachedFetch( `pr:${owner}/${repo}/${pr_number}`, () => fetchPRDetails(owner, repo, pr_number) );

Transporte HTTP para Deploy Remoto

STDIO funciona para servidores locais, mas deploy remoto requer HTTP:

typescript
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express from "express"; const app = express(); app.get("/sse", async (req, res) => { const transport = new SSEServerTransport("/message", res); await server.connect(transport); }); app.post("/message", express.json(), async (req, res) => { // Trata mensagens incoming }); // Autenticação app.use((req, res, next) => { const apiKey = req.headers["x-api-key"]; if (apiKey !== process.env.MCP_API_KEY) { return res.status(401).json({ error: "Unauthorized" }); } next(); }); app.listen(3000, () => { console.log("MCP server listening on port 3000"); });

Opções de Deployment

PadrãoMelhor Para
Docker containersIsolamento, reprodutibilidade
Serverless functionsServidores stateless, workloads burst
Processos dedicadosEstado persistente, conexões persistentes

Próximos Passos

Este artigo estabeleceu uma base sólida para implementação. O próximo artigo da série confronta os desafios de segurança que o design do MCP cria—e por que alguns engenheiros argumentam que o protocolo é fundamentalmente inseguro.


"A diferença entre teoria e prática é que na prática, a diferença é maior."

— Daedalus, The Architect @ gsstk

Receba novos artigos

Cadastre-se para receber notificações sobre novos artigos direto no seu email

Não enviaremos spam. Você pode cancelar a inscrição a qualquer momento.