← Voltar pro blog
steply / blog · mcp-server-from-scratch-tutorial-typescript-passo-a-passo.md
$ steply blog open mcp-server-from-scratch-tutorial-typescript-passo-a-passo
▸ loading article…
✓ ready

MCP do zero: tutorial de servidor MCP em TypeScript e por que ele bate seu agente vertical

porSteply5 min de leitura

Construir um agente vertical fechado ainda é o reflexo de muito time. Você abre um repo novo, escolhe um framework, codifica integração com banco, plugga LLM e empacota tudo numa CLI ou web app. Funciona pra um caso. No segundo, o time descobre que reescreveu metade do código pra rodar em outro cliente.

A alternativa: implementar uma vez como MCP server e consumir em Claude Desktop, Cursor, Windsurf e no seu agente próprio sem tocar no servidor. Este post tem dois objetivos. Primeiro, explicar quando MCP server bate agente vertical (e quando não bate, vamos ser honestos). Segundo, mostrar como montar um do zero em TypeScript, com SDK oficial e código que roda.

MCP server vs agente vertical: a tese honesta

Antes do clickbait, calibragem. MCP server não substitui agente. Agente é o loop (LLM + planner + execução), MCP server é a camada que expõe capacidades (tools, dados, prompts) pra serem consumidas por qualquer cliente compatível. A comparação correta é: MCP server vs construir tools e integrações verticais dentro de cada agente.

Quatro razões pra preferir MCP server quando a capacidade vai ser usada por mais de um cliente:

  • Reuso multi-cliente: o mesmo binário roda em Claude Desktop, Cursor, IDEs com IA e no seu agente custom. Sem reescrever integração.
  • Separação limpa: o servidor é uma camada fina sobre seu sistema. O agente cuida de raciocínio, o server cuida de execução determinística.
  • Evolução independente: trocar de modelo (Claude pra GPT) não mexe no server. Adicionar tool no server não força redeploy do agente.
  • Discoverability padronizada: o cliente pergunta tools/list e o servidor responde com schema. Sem documentação fora-de-banda.

Quando NÃO vale: aplicação one-shot, integração interna que ninguém mais vai consumir, ou loop com lógica de negócio densa que precisa estar dentro do agente. Nesses casos, agente vertical é o caminho. Use a régua: se mais de um consumidor vai usar essa capacidade, faça MCP server.

Anatomia de um MCP server

Um servidor MCP expõe três primitivas. Tools: funções que o LLM chama (criar ticket, consultar API, executar código). Resources: dados que o cliente lê (arquivo, registro, snapshot de banco). Prompts: templates reutilizáveis que o cliente pode injetar.

O transporte define como cliente fala com servidor. Os dois principais: stdio (server roda como processo filho, comunicação por entrada e saída padrão, ótimo pra rodar local em IDE) e HTTP/SSE (server roda como serviço de rede, ótimo pra agentes remotos). Comece por stdio. É mais simples e cobre 80% dos casos reais.

Setup do projeto

mkdir mcp-tickets && cd mcp-tickets
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

Ajuste o package.json pra ESM e adicione scripts.

{
 "name": "mcp-tickets",
 "type": "module",
 "scripts": {
 "dev": "tsx src/server.ts",
 "build": "tsc"
 }
}

Implementando o servidor com a primeira tool

Crie src/server.ts. Vamos expor uma tool create_ticket que cria um ticket fictício. Na sua versão real, isso bate em Linear, Jira ou um endpoint interno.

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
 CallToolRequestSchema,
 ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

const server = new Server(
 { name: "mcp-tickets", version: "0.1.0" },
 { capabilities: { tools: {} } }
);

const CreateTicketInput = z.object({
 title: z.string().min(3),
 priority: z.enum(["low", "medium", "high"]).default("medium"),
});

server.setRequestHandler(ListToolsRequestSchema, async () => ({
 tools: [
 {
 name: "create_ticket",
 description: "Cria um ticket de suporte com título e prioridade.",
 inputSchema: {
 type: "object",
 properties: {
 title: { type: "string", minLength: 3 },
 priority: { type: "string", enum: ["low", "medium", "high"] },
 },
 required: ["title"],
 },
 },
 ],
}));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
 if (req.params.name !== "create_ticket") {
 throw new Error(`tool desconhecida: ${req.params.name}`);
 }
 const args = CreateTicketInput.parse(req.params.arguments);
 // aqui você faria a chamada real ao seu backend
 const ticket = { id: `TKT-${Date.now()}`, ...args, status: "open" };
 return {
 content: [{ type: "text", text: JSON.stringify(ticket, null, 2) }],
 };
});

const transport = new StdioServerTransport();
await server.connect(transport);

Pronto. Esse arquivo é um MCP server funcional. npm run dev sobe o processo. Ele fica esperando o cliente conectar via stdio.

Conectando no Claude Desktop

Pra testar, adicione o server no config do Claude Desktop. No macOS, edite ~/Library/Application Support/Claude/claude_desktop_config.json:

{
 "mcpServers": {
 "tickets": {
 "command": "npx",
 "args": ["tsx", "/caminho/absoluto/mcp-tickets/src/server.ts"]
 }
 }
}

Reinicie o Claude Desktop. Abra um chat e peça: cria um ticket com título "deploy travado" e prioridade high. O Claude detecta a tool create_ticket, pede permissão e executa. A resposta volta com o JSON do ticket criado, formatado pelo agente.

Adicionando resources e validação séria

Tools fazem ação. Resources expõem dado. Use resources quando o LLM precisa ler contexto, não agir. Exemplo: expor a lista de tickets abertos como recurso, pra qualquer cliente MCP poder ler.

import {
 ListResourcesRequestSchema,
 ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

server.setRequestHandler(ListResourcesRequestSchema, async () => ({
 resources: [
 {
 uri: "tickets://open",
 name: "Tickets abertos",
 mimeType: "application/json",
 },
 ],
}));

server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
 if (req.params.uri !== "tickets://open") {
 throw new Error(`resource desconhecido: ${req.params.uri}`);
 }
 const open = [{ id: "TKT-1", title: "exemplo", status: "open" }];
 return {
 contents: [
 { uri: req.params.uri, mimeType: "application/json", text: JSON.stringify(open) },
 ],
 };
});

Atenção em produção: schema é contrato. Use Zod (ou equivalente) tanto na entrada quanto na saída. Se o LLM passa argumento errado, retorne erro estruturado, não exception crua. O cliente MCP repassa pro LLM como observação, e ele aprende a corrigir.

Erros que matam um MCP server real

  • Tools não idempotentes sem aviso: criar ticket duas vezes porque o LLM retentou. Use chave de idempotência no input ou no backend.
  • Descrição vaga da tool: o LLM escolhe tool errada quando duas descrições competem. Seja específico e dê exemplo no campo description.
  • Sem timeout: tool que chama API externa lenta trava o cliente. Defina timeout por tool e devolva erro estruturado.
  • Auth no lugar errado: server stdio assume contexto local; server HTTP precisa de auth real (OAuth, mTLS). Não exponha HTTP sem pelo menos bearer token e rate limit.
  • Sem log estruturado: você precisa correlacionar chamada do cliente, parâmetros, tempo, resultado. Sem log, debug vira adivinhação.

O reframe: padrão antes de framework

Times maduros adotaram MCP porque entenderam algo simples: protocolo aberto bate framework fechado quando você quer escalar pra múltiplos clientes. O agente que você constrói hoje vai ser substituído ou complementado em dois anos. O MCP server que você expõe sobrevive porque é apenas uma fachada padronizada sobre seu sistema, e o sistema raramente muda na mesma velocidade dos frameworks de IA.

Comece pequeno. Um server com uma tool útil já abre porta pra qualquer cliente MCP do mercado consumir sua capacidade. É a peça mais subestimada da stack de IA atual.