
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,...
✨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:
| SDK | Melhor Para |
|---|---|
| TypeScript | Serviços web, APIs, JavaScript tooling |
| Python | Data science, ML pipelines, codebases Python |
| Go | Biná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
mkdir github-pr-analyzer
cd github-pr-analyzer
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-nodeCrie tsconfig.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:
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}O Esqueleto do Servidor
Todo servidor MCP segue a mesma estrutura básica:
// 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.errorpara 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:
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
// 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:
// 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:
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:
npx @modelcontextprotocol/inspector node dist/index.jsIsso 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
{
"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
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:
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:
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ão | Melhor Para |
|---|---|
| Docker containers | Isolamento, reprodutibilidade |
| Serverless functions | Servidores stateless, workloads burst |
| Processos dedicados | Estado 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