Construir un agente vertical cerrado sigue siendo el reflejo de muchos equipos. Abres un repo nuevo, eliges un framework, programas la integración con la base de datos, conectas un LLM y empaquetas todo en una CLI o web app. Funciona para un caso. En el segundo, el equipo descubre que reescribió la mitad del código para correr en otro cliente.
La alternativa: implementarlo una vez como MCP server y consumirlo en Claude Desktop, Cursor, Windsurf y en tu propio agente sin tocar el servidor. Este post tiene dos objetivos. Primero, explicar cuándo un MCP server le gana a un agente vertical (y cuándo no, seamos honestos). Segundo, mostrar cómo montar uno desde cero en TypeScript, con el SDK oficial y código que corre.
MCP server vs agente vertical: la tesis honesta
Antes del clickbait, una calibración. El MCP server no sustituye al agente. El agente es el loop (LLM + planner + ejecución), el MCP server es la capa que expone capacidades (tools, datos, prompts) para ser consumidas por cualquier cliente compatible. La comparación correcta es: MCP server vs construir tools e integraciones verticales dentro de cada agente.
Cuatro razones para preferir un MCP server cuando la capacidad va a ser usada por más de un cliente:
- Reuso multi-cliente: el mismo binario corre en Claude Desktop, Cursor, IDEs con IA y en tu agente custom. Sin reescribir la integración.
- Separación limpia: el servidor es una capa fina sobre tu sistema. El agente se encarga del razonamiento, el server se encarga de la ejecución determinística.
- Evolución independiente: cambiar de modelo (Claude a GPT) no toca el server. Agregar una tool al server no obliga a redeploy del agente.
- Discoverability estandarizada: el cliente pregunta
tools/listy el servidor responde con un schema. Sin documentación fuera de banda.
Cuándo NO vale la pena: una aplicación one-shot, una integración interna que nadie más va a consumir, o un loop con lógica de negocio densa que necesita estar dentro del agente. En esos casos, el agente vertical es el camino. Usa la regla: si más de un consumidor va a usar esa capacidad, haz un MCP server.
Anatomía de un MCP server
Un servidor MCP expone tres primitivas. Tools: funciones que el LLM llama (crear un ticket, consultar una API, ejecutar código). Resources: datos que el cliente lee (un archivo, un registro, un snapshot de base de datos). Prompts: templates reutilizables que el cliente puede inyectar.
El transporte define cómo el cliente habla con el servidor. Los dos principales: stdio (el server corre como proceso hijo, comunicación por entrada y salida estándar, ideal para correr local en un IDE) y HTTP/SSE (el server corre como servicio de red, ideal para agentes remotos). Empieza por stdio. Es más simple y cubre el 80% de los casos reales.
Setup del proyecto
mkdir mcp-tickets && cd mcp-tickets
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init
Ajusta el package.json para ESM y agrega scripts.
{
"name": "mcp-tickets",
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc"
}
}
Implementando el servidor con la primera tool
Crea src/server.ts. Vamos a exponer una tool create_ticket que crea un ticket ficticio. En tu versión real, esto llega a Linear, Jira o un 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);
Listo. Ese archivo es un MCP server funcional. npm run dev levanta el proceso. Se queda esperando a que el cliente conecte vía stdio.
Conectando en Claude Desktop
Para probarlo, agrega el server a la config de Claude Desktop. En macOS, edita ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"tickets": {
"command": "npx",
"args": ["tsx", "/caminho/absoluto/mcp-tickets/src/server.ts"]
}
}
}
Reinicia Claude Desktop. Abre un chat y pide: crea un ticket con el título "deploy trabado" y prioridad high. Claude detecta la tool create_ticket, pide permiso y la ejecuta. La respuesta vuelve con el JSON del ticket creado, formateado por el agente.
Agregando resources y validación seria
Las tools hacen una acción. Los resources exponen datos. Usa resources cuando el LLM necesita leer contexto, no actuar. Ejemplo: exponer la lista de tickets abiertos como recurso, para que cualquier cliente MCP pueda leerla.
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) },
],
};
});
Atención en producción: el schema es un contrato. Usa Zod (o un equivalente) tanto en la entrada como en la salida. Si el LLM pasa un argumento equivocado, devuelve un error estructurado, no una excepción cruda. El cliente MCP se lo pasa al LLM como observación, y él aprende a corregirse.
Errores que matan a un MCP server real
- Tools no idempotentes sin aviso: crear un ticket dos veces porque el LLM reintentó. Usa una clave de idempotencia en el input o en el backend.
- Descripción vaga de la tool: el LLM elige la tool equivocada cuando dos descripciones compiten. Sé específico y da un ejemplo en el campo
description. - Sin timeout: una tool que llama a una API externa lenta traba al cliente. Define un timeout por tool y devuelve un error estructurado.
- Auth en el lugar equivocado: un server stdio asume un contexto local; un server HTTP necesita auth real (OAuth, mTLS). No expongas HTTP sin al menos un bearer token y un rate limit.
- Sin log estructurado: necesitas correlacionar la llamada del cliente, los parámetros, el tiempo y el resultado. Sin log, el debug se vuelve adivinanza.
El reframe: patrón antes que framework
Los equipos maduros adoptaron MCP porque entendieron algo simple: un protocolo abierto le gana a un framework cerrado cuando quieres escalar a múltiples clientes. El agente que construyes hoy será reemplazado o complementado en dos años. El MCP server que expones sobrevive porque es apenas una fachada estandarizada sobre tu sistema, y el sistema rara vez cambia a la misma velocidad que los frameworks de IA.
Empieza pequeño. Un server con una tool útil ya abre la puerta para que cualquier cliente MCP del mercado consuma tu capacidad. Es la pieza más subestimada del stack de IA actual.