De API REST a servidor MCP en TypeScript: 1 endpoint no es 1 tool
Un equipo con el que trabajé tenía una API REST de facturación con cuarenta y tantos endpoints. Documentada con OpenAPI, en producción desde hacía años, sin drama.
Quisieron abrirla a un agente. Para pasar de API REST a servidor MCP hicieron lo obvio: generar el servidor desde el spec de OpenAPI. Cuarenta endpoints, cuarenta tools. Media tarde de trabajo. Funcionaba.
Y el agente era inútil.
Preguntabas "¿cómo está la cuenta de Marta?" y el modelo elegía listInvoices sin filtro, se tragaba doscientas facturas en el contexto y contestaba una vaguedad. Otras veces llamaba a getCustomer, luego a getCustomerById, luego a searchCustomers — tres tools que hacían casi lo mismo porque el backend llevaba cinco años acumulando variantes.
El problema no era el modelo. Era que habían traducido en vez de diseñar.
Cuando pasas de una API REST a un servidor MCP, la conversión mecánica es el error por defecto. Un buen servidor MCP expone menos tools que endpoints tiene la API. Y si no tienes API previa y quieres montar todo desde cero, empieza por construir el agente y su servidor MCP paso a paso — este post asume que ya tienes el backend en producción.
En una frase: un servidor MCP es un proceso que expone las capacidades de tu backend como tools —funciones con nombre, schema de entrada y descripción— para que un modelo pueda elegirlas y ejecutarlas por sí mismo. Si vienes de cero con el protocolo, el mapa completo está en qué son los servidores MCP. Aquí vamos a lo que casi nadie cuenta: cómo se decide qué parte de tu API merece ser una tool.
API REST describe recursos, servidor MCP describe capacidades
La diferencia entre una API REST y un servidor MCP no es el transporte: es quién decide qué llamar, cuándo y con qué información delante.
| API REST | Servidor MCP | |
|---|---|---|
| Quién elige la llamada | Un programador, en tiempo de desarrollo | Un modelo, en tiempo de ejecución |
| Unidad de diseño | El recurso (/customers/{id}) |
La intención ("cómo está la cuenta de X") |
| Para qué sirve la descripción | Documentación que se lee una vez | Prompt que decide la llamada |
| Coste de añadir una más | Cercano a cero | Contexto en cada petición y más riesgo de elegir mal |
| Respuesta ideal | El objeto completo, el cliente filtra | Lo mínimo para razonar, el servidor recorta |
| Errores | Código HTTP y cuerpo estructurado | Frase accionable con isError: true |
Tu API REST está escrita para un programador: alguien que ya sabe lo que quiere y que entiende por qué /customers/{id}/invoices devuelve algo distinto de /invoices?customer_id={id}. El contrato REST asume una decisión ya tomada.
MCP es lo contrario. El que lee tu catálogo de tools no sabe nada de tu dominio y tiene que decidir cuál llamar, con qué argumentos y en qué orden — a partir de una frase ambigua de un humano.
Esto cambia una cosa fundamental: la descripción de la tool no es documentación, es prompt. Es el texto que el modelo tiene delante en el momento de elegir. Si escribes "Obtiene un cliente" dejas la decisión al azar. Si escribes "Úsala cuando necesites el estado de facturación de un cliente a partir de su email; no sirve para crear ni modificar facturas", programas el comportamiento.
Y hay un coste que en REST no existe: la lista de tools viaja en cada petición. Cuarenta tools con sus schemas ocupan contexto antes de que el agente haya hecho nada. Cada tool que añades encarece todas las llamadas y hace la elección más difícil.
Qué endpoints de tu API REST se convierten en tool MCP (y cuáles no)
No, no va una tool por endpoint. El criterio que uso es uno solo: ¿este endpoint responde a una intención completa que un humano formularía?
Si un usuario puede decir "dime el estado de facturación de Marta" y ese endpoint lo resuelve entero, es candidato. Si es un paso intermedio que solo tiene sentido dentro de una secuencia, no lo es.
Con eso, esto queda fuera:
- CRUD granular.
PATCH /customers/{id}/phoneno es una intención, es un detalle de implementación. Si el agente necesita actualizar datos de contacto, una sola toolupdate_customer_contactcon varios campos opcionales. - Endpoints internos. Health checks, webhooks, callbacks de terceros, migraciones. El agente no los necesita y solo compiten por su atención.
- Los que devuelven payloads enormes. Un
GET /eventsque escupe cinco mil registros no se convierte en tool: se convierte en tool con filtros obligatorios, o no se convierte. - Los destructivos sin confirmación.
DELETE /customers/{id}no va al servidor MCP tal cual. O lo marcas condestructiveHinty lo dejas detrás de una confirmación del cliente, o directamente no lo expones. Yo arranco siempre en solo lectura y añado escritura una a una.
Y una regla que ahorra mucho dolor: si dos endpoints se llaman siempre juntos, no son dos tools. Son una.
La tool de intención: consolida, no traduzcas
Una tool de intención es una sola tool que resuelve una pregunta completa del usuario agregando por dentro varias llamadas a tu API REST. Ahí está el cambio de mentalidad. La pregunta "cómo está la cuenta de Marta" en tu API REST son tres llamadas:
GET /customers?email=... → el cliente
GET /customers/{id}/invoices → sus facturas
GET /customers/{id}/payments → el estado de pagos
La traducción mecánica te da getCustomer, listInvoices y getPaymentStatus. Tres tools, tres decisiones que el modelo puede equivocar, tres respuestas verbosas en el contexto y una orquestación que el agente tiene que inventarse en cada conversación.
La versión diseñada te da una: get_customer_billing_summary. Recibe un email, encadena esas llamadas por dentro y devuelve un resumen legible.
Tres decisiones menos que tomar, dos viajes menos de contexto y una orquestación que ya no depende de que el modelo acierte. Es el mismo backend; cambia dónde vive la lógica de composición.
Las cuatro piezas que no se traducen solas
Autenticación. El token de tu API REST no viaja como viajaba. Regla dura: el token nunca es un parámetro de la tool. Si lo pones en el inputSchema, acaba en el contexto del modelo y en los logs del cliente. En local, por stdio, el servidor lo lee de su entorno y el modelo ni se entera. En cuanto lo expones por red la historia se complica bastante — ahí tu servidor pasa a ser un resource server de OAuth 2.1 y toca leer qué se rompe cuando el MCP server sale del portátil.
Paginación. El agente no debe paginar a mano. Si expones page y per_page, hará cinco llamadas seguidas quemando contexto para reconstruir algo que podías haberle dado resumido.
Dos opciones honestas: un tope de resultados con un cursor explícito que el modelo pueda pasar de vuelta, o —mejor— los N más relevantes más un "hay 340 resultados, afina el filtro por fecha o estado". Empujar al agente a filtrar gana casi siempre a dejarle paginar.
Errores. Un 422 con un cuerpo tipo {"errors":{"date":"invalid format"}} es perfecto para un frontend y horrible para un modelo. El agente necesita texto que le diga qué corregir: "El campo date debe ir en formato YYYY-MM-DD. Reformatea el valor y vuelve a llamar." Y va como resultado con isError: true, no como excepción sin capturar: así el modelo lo lee y se autocorrige en el mismo turno en vez de rendirse.
Tamaño de la respuesta. Tu endpoint devuelve el objeto entero porque a un frontend le sale gratis ignorar campos. Al agente no: cada campo que no usa lo paga en contexto. Recorta en el servidor. De un objeto factura con treinta campos, el agente necesita número, fecha, importe y estado.
Servidor MCP en TypeScript con el SDK oficial
Un archivo para hablar con la API que ya tienes, sobre el SDK oficial de TypeScript:
// src/rest.ts
const BASE = process.env.BILLING_API_URL!;
const TOKEN = process.env.BILLING_API_TOKEN!; // del entorno, nunca del modelo
export class RestError extends Error {
constructor(readonly status: number, readonly body: unknown) {
super(`REST ${status}`);
}
}
export async function rest<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { Authorization: `Bearer ${TOKEN}`, Accept: 39;application/json39; }
});
if (!res.ok) {
throw new RestError(res.status, await res.json().catch(() => null));
}
return res.json() as Promise<T>;
}
export interface Customer {
id: string;
name: string;
email: string;
}
export interface Invoice {
number: string;
issuedAt: string; // YYYY-MM-DD
amount: number;
status: 39;draft39; | 39;sent39; | 39;paid39; | 39;overdue39;;
}
Y el servidor con la tool de intención que agrega dos llamadas REST:
// src/server.ts
import { McpServer } from 39;@modelcontextprotocol/sdk/server/mcp.js39;;
import { StdioServerTransport } from 39;@modelcontextprotocol/sdk/server/stdio.js39;;
import { z } from 39;zod39;;
import { rest, RestError, type Customer, type Invoice } from 39;./rest.js39;;
const server = new McpServer({ name: 39;billing39;, version: 39;1.0.039; });
server.registerTool(
39;get_customer_billing_summary39;,
{
title: 39;Resumen de facturación de un cliente39;,
description:
39;Devuelve el estado de facturación de un cliente a partir de su email: 39; +
39;datos básicos, facturas recientes e importe vencido. Úsala para responder 39; +
39;"cómo está la cuenta de X". No sirve para crear ni modificar facturas.39;,
inputSchema: {
email: z.email().describe(39;Email del cliente, tal como lo dio el usuario39;),
months: z.number().int().min(1).max(12).default(3)
.describe(39;Meses de histórico de facturas a incluir. Por defecto 3.39;)
},
annotations: { readOnlyHint: true }
},
async ({ email, months }) => {
const since = new Date();
since.setMonth(since.getMonth() - months);
try {
const [customer] = await rest<Customer[]>(
`/customers?email=${encodeURIComponent(email)}`
);
if (!customer) {
return {
content: [{
type: 39;text39;,
text: `No existe ningún cliente con el email ${email}. ` +
`Pide al usuario el email exacto antes de reintentar.`
}],
isError: true
};
}
const invoices = await rest<Invoice[]>(
`/customers/${customer.id}/invoices` +
`?limit=20&since=${since.toISOString().slice(0, 10)}`
);
const overdue = invoices.filter(i => i.status === 39;overdue39;);
const owed = overdue.reduce((sum, i) => sum + i.amount, 0);
// Recorte deliberado: solo lo que el agente necesita para razonar
const lines = invoices
.slice(0, 10)
.map(i => `- ${i.number} · ${i.issuedAt} · ${i.amount} € · ${i.status}`);
return {
content: [{
type: 39;text39;,
text: [
`Cliente: ${customer.name} (${customer.email})`,
`Facturas últimos ${months} meses: ${invoices.length}`,
`Vencidas: ${overdue.length} · Importe pendiente: ${owed} €`,
39;39;,
...lines,
invoices.length > 10 ? `… y ${invoices.length - 10} más.` : 39;39;
].join(39;\n39;)
}]
};
} catch (error) {
if (error instanceof RestError && error.status === 422) {
return {
content: [{
type: 39;text39;,
text: `La API rechazó los parámetros: ${JSON.stringify(error.body)}. ` +
`Corrige el valor indicado y vuelve a llamar.`
}],
isError: true
};
}
throw error;
}
}
);
await server.connect(new StdioServerTransport());
Fíjate en el .describe() de cada campo: es la única documentación que el modelo recibe de ese argumento. En una API REST el tipo basta porque hay un humano leyendo el spec; aquí el texto es la interfaz. Esa combinación de tipado y semántica es donde Zod deja de ser un validador y pasa a ser parte del diseño — si quieres exprimirlo, lo trabajo a fondo en el curso de Zod para TypeScript. Si sigues en Zod 3, esa línea es z.string().email(); desde Zod 4 la forma recomendada es z.email().
Un apunte de versiones (septiembre de 2026): el código de arriba corre sobre @modelcontextprotocol/sdk 1.30.0, cuyo inputSchema admite el shape suelto —{ email: z.string() }— y también un z.object({ ... }). La v2 se publica como paquete aparte, @modelcontextprotocol/server 2.0.0, y ahí el shape suelto queda deprecado a favor del z.object() explícito. El monolítico no está deprecado y es el que sigues viendo en la mayoría de servidores, que es por lo que el ejemplo va con él. Los dos implementan la revisión 2026-07-28 de la spec, donde están definidas las annotations y el outputSchema. El criterio de diseño de este post no cambia entre versiones.
Para probarlo, regístralo en tu cliente y lánzale la pregunta en lenguaje natural. Los scopes y el claude mcp add los tienes desglosados en el tutorial de MCP server con Claude Code.
Checklist de migración de API REST a servidor MCP
Antes de dar por buena la conversión de tu API:
- Cuenta. ¿Tienes menos tools que endpoints? Si no, no has diseñado, has traducido.
- Lee las descripciones en voz alta. Si no explican cuándo usar la tool y cuándo no, reescríbelas.
- Busca solapes. Dos tools que un humano confundiría, un modelo también.
- Mide el peor payload. Si una respuesta puede reventar el contexto, mete tope y filtros obligatorios.
- Convierte los errores. Cada error de tu API tiene que salir como frase accionable con
isError: true. - Saca el token del schema. Si aparece en
inputSchema, tienes una fuga. - Arranca en solo lectura. Escritura y borrado después, uno a uno y con confirmación.
Lo que haría hoy
Abre el OpenAPI de tu API. Marca los endpoints que responden a una frase completa de un usuario. Normalmente son entre cinco y ocho de cuarenta.
Esos son tus tools. El resto es la fontanería que vive dentro de ellos.
Y luego escribe las descripciones como si fueran prompts — porque lo son. Esa es la parte que casi nadie hace, y la que separa un servidor MCP que el agente usa bien de uno que solo se ve bonito en el tools/list.
Este salto de "envolver lo que ya tengo" a "diseñar la superficie que el agente necesita" es el mismo que trabajo en el curso Construye con IA, y si quieres ver servidores MCP reales con sus decisiones y sus errores, los desmenuzamos en Dominicode Labs.
Preguntas frecuentes
¿Cuántas tools debería tener mi servidor MCP?
No hay número mágico, pero sí una señal: si tienes tantas tools como endpoints, has traducido en vez de diseñar. En APIs de tamaño medio suelo acabar entre cinco y diez tools de intención. La pregunta correcta no es cuántas caben, sino cuántas puedes quitar sin perder capacidad real.
¿Puedo generar el servidor MCP automáticamente desde mi OpenAPI?
Puedes, y es justo lo que produce agentes malos. Un generador hace exactamente la conversión mecánica de 1 endpoint = 1 tool: sin criterio sobre qué endpoints son intenciones completas, sin consolidar llamadas y sin descripciones pensadas para un modelo. Úsalo como inventario de partida si quieres, pero la selección y el redactado de las descripciones son trabajo manual.
¿Cómo paso el token de mi API REST al servidor MCP?
Nunca como parámetro de la tool: ahí acaba en el contexto del modelo y en los logs del cliente. En local, con transporte stdio, el servidor lo lee de una variable de entorno y el modelo ni lo ve. Si lo expones por HTTP, el cliente presenta su propio token al servidor MCP y es tu servidor quien traduce esa identidad a la credencial de la API interna.
¿Qué hago con los endpoints de escritura o destructivos?
Sepáralos desde el arranque. Empieza en solo lectura, marcando esas tools con readOnlyHint, y añade escritura una a una cuando ya sabes cómo se comporta el agente con tu dominio. Lo destructivo lleva destructiveHint y confirmación del cliente; si algo cobra dinero o borra registros, además tiene que ser idempotente.
¿La tool debe devolver JSON o texto?
Texto, salvo razón concreta para lo contrario. El JSON crudo arrastra campos que el agente no usa y paga en contexto. Si además necesitas la forma estructurada, el SDK permite declarar un outputSchema y devolver structuredContent junto al texto.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
