Crear un servidor MCP en TypeScript (2026): tutorial con el SDK v2
Tutorial para crear un servidor MCP en TypeScript con el SDK oficial v2 y la especificación 2026-07-28: tool, resources, prompt, stdio, HTTP y Claude Code.
Por Andrés León Bohórquez Actualizado el
Datos a 7 de octubre de 2026 · especificación MCP 2026-07-28
En este tutorial vas a crear un servidor MCP en TypeScript con el SDK oficial v2, el que habla la especificación 2026-07-28. El servidor se llama nortia-pedidos y consulta pedidos de Nortia Logística, la empresa ficticia que recorre el curso de DesarrolloMCP. Tendrá una tool para buscar un pedido, un resource fijo, otro con plantilla y un prompt.
Lo servirás de dos formas: por stdio, para que un cliente lo lance como proceso local, y por Streamable HTTP sin estado, detrás de una URL. Después lo probarás con el MCP Inspector y lo conectarás a Claude Code. Al final verás cómo pedirle a Claude Code que lo construya por ti fijando el SDK v2, para que no te devuelva código de 2025.
Todo el código del servidor es literal de la lección 9 del curso, donde se ejecutó con las versiones que se indican abajo. Si todavía no sabes qué son un host, un cliente o una tool, empieza por qué es MCP.
Requisitos y versiones
El SDK v2 se reparte en paquetes separados. Estas son las versiones con las que se ejecutó todo lo que sigue:
| Pieza | Versión |
|---|---|
@modelcontextprotocol/server y @modelcontextprotocol/client |
2.3.0 (2 de octubre de 2026) |
@modelcontextprotocol/node |
2.1.1 |
@modelcontextprotocol/express |
2.0.2 |
zod |
4.6.5 (el SDK exige 4.2 o superior; Zod 3 no está soportado) |
typescript / tsx |
7.0.2 / 4.23.15 |
@modelcontextprotocol/inspector |
2.9.0 (30 de septiembre de 2026) |
| Node.js | 22 o superior; el Inspector exige 22.19.0 o posterior |
Los paquetes v2 declaran Node 20 o superior en su campo engines. Aun así, el código de este tutorial solo se ha ejecutado con Node 24.12, y la lección pide Node 22.
Si buscas «MCP TypeScript» encontrarás sobre todo código de 2025. Ese código usa el paquete único @modelcontextprotocol/sdk, que es la v1. La v1 sigue publicándose (versión 1.32.0) y, según el README del SDK, recibirá correcciones al menos seis meses desde la salida de la v2. Aun así, no la uses para un servidor nuevo: sus imports profundos (@modelcontextprotocol/sdk/server/mcp.js) y sus métodos server.tool(), server.resource() y server.prompt() no existen en la v2. La v2.0.0 se publicó el 27 de julio de 2026, un día antes que la especificación.
Ten clara una cosa desde el principio: instalar la v2 no basta para hablar 2026-07-28. La guía oficial del SDK lo dice así: «Nothing in v2 puts a 2026-07-28 byte on the wire by default». La versión del protocolo la decide el punto de entrada con el que sirves el servidor, y lo verás en el paso 4. Si quieres el contexto de qué cambió en esa versión (sin initialize, sin sesiones, con _meta en cada petición), lo tienes en MCP 2026-07-28: el protocolo sin estado.
Paso 1: el proyecto
mkdir nortia-pedidos && cd nortia-pedidos
npm init -y && npm pkg set type=module
npm i @modelcontextprotocol/server @modelcontextprotocol/client \
@modelcontextprotocol/node @modelcontextprotocol/express express zod
npm i -D typescript tsx @types/node @types/express
Fuente: lección 09, bloque «Terminal».
{
"compilerOptions": {
"target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",
"strict": true, "skipLibCheck": true, "noEmit": true, "types": ["node"]
},
"include": ["src", "test"]
}
Fuente: lección 09, tsconfig.json.
npx tsc -p . comprueba los tipos sin escribir nada (noEmit). npx tsx archivo.ts ejecuta TypeScript sin compilar a disco.
Paso 2: los datos, detrás de una interfaz
Antes de tocar el SDK, define el dominio. España y México son inquilinos distintos de Nortia, y una consulta no puede cruzar de uno a otro. Por eso el inquilino es un parámetro obligatorio de cada método del repositorio. Hoy los datos están en memoria. En el nivel Avanzado del curso, PostgreSQL cumple la misma interfaz.
El archivo src/pedidos.ts define el tipo Inquilino ('es' o 'mx'), los estados de envío y el tipo Pedido. También define la interfaz RepositorioPedidos, con dos métodos que reciben siempre el inquilino: buscarPorId y listarRecientes. La implementación de ejemplo, RepositorioEnMemoria, guarda tres pedidos de prueba. Está entera en la lección y en el repositorio de ejemplos.
Paso 3: la fábrica con la tool, los resources y el prompt
Construyes todo el servidor dentro de una función que recibe sus dependencias y devuelve un McpServer. Hay un motivo técnico: los puntos de entrada que hablan 2026-07-28 reciben una fábrica, no una instancia, y en HTTP la ejecutan en cada petición.
import { McpServer, ResourceNotFoundError, ResourceTemplate } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import { ESTADOS } from './pedidos.js';
import type { EstadoEnvio, Inquilino, Pedido, RepositorioPedidos } from './pedidos.js';
export interface EventoAuditoria {
operacion: string;
pedido: string;
resultado: 'ok' | 'no_encontrado';
}
export interface Dependencias {
repo: RepositorioPedidos;
inquilino: Inquilino;
auditar: (evento: EventoAuditoria) => void;
}
const idPedido = z
.string()
.regex(/^PED-\d{4}-\d{6}$/, 'Formato esperado: PED-AAAA-NNNNNN')
.describe('Identificador del pedido, por ejemplo PED-2026-004812');
export const pedidoSchema = z.object({
id: z.string(),
estado: z.enum(Object.keys(ESTADOS) as [EstadoEnvio, ...EstadoEnvio[]]),
almacen: z.string(),
transportista: z.string(),
entregaPrevista: z.string().describe('Fecha ISO, AAAA-MM-DD'),
clienteRef: z.string()
});
// Lo que sale del servidor se elige campo a campo: el inquilino y las columnas futuras se quedan dentro.
function publico(pedido: Pedido): z.infer<typeof pedidoSchema> {
const { id, estado, almacen, transportista, entregaPrevista, clienteRef } = pedido;
return { id, estado, almacen, transportista, entregaPrevista, clienteRef };
}
const json = (uri: URL, datos: unknown) => ({
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(datos) }]
});
export function crearServidor({ repo, inquilino, auditar }: Dependencias): McpServer {
const server = new McpServer({ name: 'nortia-pedidos', version: '1.4.0' });
server.registerTool(
'buscar_pedido',
{
title: 'Buscar pedido',
description:
'Devuelve estado, almacén, transportista y fecha prevista de entrega de un pedido ' +
'de Nortia. Úsala cuando el usuario dé un identificador PED-AAAA-NNNNNN. ' +
'No busca por cliente ni por fecha.',
inputSchema: z.object({ pedido_id: idPedido }),
outputSchema: pedidoSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async ({ pedido_id }) => {
const pedido = await repo.buscarPorId(inquilino, pedido_id);
auditar({ operacion: 'buscar_pedido', pedido: pedido_id, resultado: pedido ? 'ok' : 'no_encontrado' });
if (!pedido) {
const text =
`No existe el pedido ${pedido_id} en este entorno. ` +
'Pide al usuario que confirme el identificador; no pruebes con otros.';
return { content: [{ type: 'text', text }], isError: true };
}
const salida = publico(pedido);
return { content: [{ type: 'text', text: JSON.stringify(salida) }], structuredContent: salida };
}
);
server.registerResource(
'estados-envio',
'pedidos://catalogo/estados',
{ title: 'Estados de envío', description: 'Qué significa cada estado', mimeType: 'application/json' },
async uri => json(uri, ESTADOS)
);
server.registerResource(
'ficha-pedido',
new ResourceTemplate('pedidos://pedido/{pedidoId}', {
list: async () => {
const recientes = await repo.listarRecientes(inquilino, 20);
return {
resources: recientes.map(p => ({ uri: `pedidos://pedido/${p.id}`, name: `Pedido ${p.id}` }))
};
}
}),
{ title: 'Ficha de pedido', description: 'Datos de un pedido', mimeType: 'application/json' },
async (uri, variables) => {
const id = String(variables.pedidoId);
const pedido = await repo.buscarPorId(inquilino, id);
auditar({ operacion: 'resources/read', pedido: id, resultado: pedido ? 'ok' : 'no_encontrado' });
if (!pedido) throw new ResourceNotFoundError(uri.href);
return json(uri, publico(pedido));
}
);
server.registerPrompt(
'respuesta_retraso',
{
title: 'Respuesta a un cliente por retraso',
description: 'Borrador de respuesta de soporte sobre un pedido retrasado',
argsSchema: z.object({ pedido_id: idPedido })
},
({ pedido_id }) => {
const text =
`Consulta el pedido ${pedido_id} con buscar_pedido y redacta una respuesta al cliente sobre ` +
'su retraso.\nNormas: indica el estado actual y la fecha prevista tal como constan; ' +
'no prometas otra fecha; si el estado es "incidencia", ofrece abrir un ticket; ' +
'no incluyas datos personales.';
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text } }] };
}
);
return server;
}
Fuente: lección 09, src/servidor.ts (idéntico en basico/09-typescript/nortia-pedidos/src/servidor.ts).
La tool con Zod
registerTool recibe el nombre, un objeto de configuración y el handler. inputSchema y outputSchema son esquemas z.object(...) de Zod 4, y el SDK los publica como JSON Schema en tools/list. El regex de idPedido viaja como pattern, así que el modelo ve el formato antes de llamar. Hay tres decisiones que no se deducen del código:
- El SDK valida la entrada antes del handler. Si el modelo envía
"4812", recibe un resultado conisError: truey el mensaje delregex(«Formato esperado: PED-AAAA-NNNNNN») sin que el handler llegue a ejecutarse. Por eso ese mensaje está escrito para el modelo. - Un pedido inexistente es un error de ejecución, un resultado normal con
isError: trueque el cliente pasa al modelo, no una excepción. El texto dice qué hacer después. Un pedido de México consultado desde España devuelve exactamente el mismo texto: decir «existe, pero no es tuyo» filtraría información entre inquilinos. publico()es necesaria. En una prueba de la lección, un campo que no estaba en eloutputSchemallegó al cliente tal cual. El SDK valida los campos declarados, pero no elimina los sobrantes. El resultado lleva los datos dos veces, enstructuredContenty como texto encontent, como recomienda la especificación por compatibilidad.
Las anotaciones (readOnlyHint, destructiveHint, idempotentHint y openWorldHint) son pistas para el cliente. El código declara las cuatro para que ningún cliente tenga que suponer los valores por defecto, aunque destructiveHint e idempotentHint solo cuentan cuando readOnlyHint es false. La lección 5 explica cómo se diseñan las tools y sus errores.
Los resources y el prompt
Un resource fijo recibe cuatro argumentos (nombre, URI, metadatos y callback). En la v2 los metadatos son obligatorios, aunque sean un objeto vacío. El de plantilla recibe un ResourceTemplate: la variable {pedidoId} llega como segundo argumento del callback, y list decide qué URIs concretas aparecen en resources/list.
Los resources no tienen isError. Si el pedido no existe, el callback lanza ResourceNotFoundError, que llega al cliente como error -32602 con la URI en data.
El prompt reutiliza idPedido para validar su argumento y no incluye datos del pedido. Le pide al modelo que los consulte con buscar_pedido, así que la lectura queda auditada como cualquier otra.
Paso 4: servir por stdio y por Streamable HTTP
Los dos puntos de entrada comparten la configuración. El inquilino sale de una variable de entorno y el repositorio se crea una vez por proceso, fuera de la fábrica.
import { RepositorioEnMemoria } from './pedidos.js';
import type { Dependencias } from './servidor.js';
// Una sola instancia por proceso: aquí irá el pool de PostgreSQL. La fábrica del servidor no lo crea.
const repo = new RepositorioEnMemoria();
export function dependencias(transporte: 'stdio' | 'http'): Dependencias {
const inquilino = process.env.NORTIA_INQUILINO;
if (inquilino !== 'es' && inquilino !== 'mx') {
throw new Error('NORTIA_INQUILINO debe ser "es" o "mx"');
}
return {
repo,
inquilino,
// stdout es el canal JSON-RPC en stdio: la auditoría va siempre a stderr.
auditar: evento =>
console.error(JSON.stringify({ ...evento, inquilino, transporte, ts: new Date().toISOString() }))
};
}
Fuente: lección 09, src/entorno.ts.
stdio con serveStdio
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { dependencias } from './entorno.js';
import { crearServidor } from './servidor.js';
const deps = dependencias('stdio'); // falla al arrancar si la configuración es inválida
const handle = serveStdio(() => crearServidor(deps));
console.error(`nortia-pedidos (${deps.inquilino}) escuchando por stdio`);
process.on('SIGINT', () => void handle.close());
Fuente: lección 09, src/stdio.ts.
serveStdio(fábrica) es lo que hace que el servidor hable 2026-07-28. Por defecto también atiende a clientes de las versiones con handshake. Fíjate en que el código escribe todo con console.error. En stdio, la especificación prohíbe que el servidor escriba en stdout nada que no sea un mensaje MCP válido, y permite los logs en stderr.
Streamable HTTP sin estado con createMcpHandler
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { dependencias } from './entorno.js';
import { crearServidor } from './servidor.js';
const deps = dependencias('http');
// La fábrica se ejecuta una vez por petición HTTP: no hay nada que recordar entre dos POST.
const handler = createMcpHandler(() => crearServidor(deps));
const app = createMcpExpressApp(); // express.json() y validación de Host y Origin
const node = toNodeHandler(handler);
app.all('/mcp', (req, res) => void node(req, res, req.body));
const PORT = Number(process.env.PORT ?? 3000);
const httpServer = app.listen(PORT, '127.0.0.1', () => {
console.error(`nortia-pedidos (${deps.inquilino}) en http://127.0.0.1:${PORT}/mcp`);
});
process.on('SIGTERM', async () => {
await handler.close();
httpServer.close();
});
Fuente: lección 09, src/http.ts.
createMcpHandler devuelve un handler web estándar, y toNodeHandler lo adapta a Express. No hay sesiones ni cabecera Mcp-Session-Id: cada POST se resuelve con lo que trae. Como la fábrica se ejecuta en cada petición, lo caro (pools, cachés, clientes de otras APIs) vive a nivel de módulo, como el repositorio de entorno.ts.
Arráncalo y envíale una tools/call a mano:
NORTIA_INQUILINO=es npx tsx src/http.ts
# En otra terminal; peticion.json contiene el cuerpo de la petición tools/call
curl -s -X POST http://127.0.0.1:3000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' -H 'Mcp-Name: buscar_pedido' \
-d @peticion.json
Fuente: lección 09, bloque «Terminal», con el comentario adaptado.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "buscar_pedido",
"arguments": { "pedido_id": "PED-2026-004812" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "curl", "version": "8" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
Fuente: basico/09-typescript/nortia-pedidos/peticion.json del repositorio de ejemplos.
El _meta lleva la versión y las capacidades del cliente en cada petición, porque ya no hay handshake que las fije antes. En la ejecución de la lección, la respuesta trajo el pedido en content y en structuredContent, "resultType": "complete" y la identidad del servidor en _meta.
Paso 5: probarlo
Con el MCP Inspector
El MCP Inspector es un solo paquete con tres interfaces: web (npx @modelcontextprotocol/inspector), de terminal (--tui) y una CLI (--cli) pensada para scripts y CI. La lección solo ejecutó la CLI, con un archivo que describe cómo lanzar el servidor:
{ "mcpServers": {
"nortia-pedidos": {
"command": "npx", "args": ["tsx", "src/stdio.ts"], "env": { "NORTIA_INQUILINO": "es" }
}
} }
Fuente: lección 09, .inspector/config.json.
export MCP_CLIENT_CONFIG_PATH="$PWD/.inspector/mcp.json"
npx -y @modelcontextprotocol/inspector@2.9.0 --cli --config .inspector/config.json \
--server nortia-pedidos --protocol-era modern \
--method tools/call --tool-name buscar_pedido --tool-args-json '{"pedido_id":"PED-2026-999999"}'
echo "exit=$?"
Fuente: lección 09, bloque «Terminal». La variable MCP_CLIENT_CONFIG_PATH hace que el Inspector guarde su configuración en el proyecto y no en ~/.mcp-inspector.
{
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "nortia-pedidos",
"version": "1.4.0"
}
},
"content": [
{
"type": "text",
"text": "No existe el pedido PED-2026-999999 en este entorno. Pide al usuario que confirme el identificador; no pruebes con otros."
}
],
"isError": true
}
exit=5
Fuente: lección 09, «Salida real».
Lo importante es --protocol-era modern. El Inspector usa por defecto legacy, es decir, las versiones con handshake. Sin esa opción, la misma llamada pasa, pero sin probar 2026-07-28. Con modern, el Inspector exige exactamente 2026-07-28 y falla si el servidor no la ofrece. La señal de que se negoció la versión actual es el io.modelcontextprotocol/serverInfo dentro de _meta.
El código de salida refleja isError: 5 aquí y 0 con un pedido que existe (PED-2026-004812). Por eso sirve en un pipeline.
La documentación del Inspector también permite fijar la versión en el propio archivo de configuración, con el campo "protocolEra": "modern" en la entrada del servidor. Su README dice que la opción --protocol-era es para servidores ad hoc. En la 2.9.0 funcionó con --config, pero lo documentado es el campo "protocolEra": "modern" del archivo de configuración. Esa forma también funcionó en nuestra prueba del 7 de octubre de 2026 (ver «Hazlo con Claude Code»), con el campo dentro de la entrada del servidor. Si lo pones en la raíz del archivo, el Inspector lo ignora sin avisar y negocia con handshake.
Con Claude Code
Claude Code añade servidores con claude mcp add. Esta es la sintaxis de su documentación:
# HTTP (recomendado para servidores remotos)
claude mcp add --transport http <name> <url>
# stdio (proceso local)
claude mcp add [options] <name> -- <command> [args...]
Fuente: https://code.claude.com/docs/en/mcp (las dos formas generales de su bloque de ejemplos, sin los ejemplos con servidores de terceros).
Con nortia-pedidos arrancado por HTTP (NORTIA_INQUILINO=es npx tsx src/http.ts), la forma HTTP queda así:
claude mcp add --transport http nortia-pedidos-http http://127.0.0.1:3000/mcp
claude mcp list # ✔ Connected
Composición a partir de la sintaxis documentada; no la hemos ejecutado.
Para la forma stdio hay dos trampas documentadas. La primera: el -- es obligatorio, porque separa las opciones de Claude Code (--transport, --env, --scope) del comando del servidor. La segunda: si el nombre del servidor va justo después de --env NORTIA_INQUILINO=es, la CLI lo lee como otra pareja clave-valor y lo rechaza. Pon otra opción en medio, como --transport stdio.
Usa rutas absolutas a tu proyecto, porque la documentación no dice en qué directorio lanza Claude Code el proceso (solo garantiza la variable CLAUDE_PROJECT_DIR).
En nuestra prueba del 7 de octubre de 2026, con Claude Code 2.1.293, este comando registró el nortia-pedidos que había generado Claude Code, y claude -p lo conectó y llamó a su tool. El comando lo propuso el propio Claude Code. Evitó npx tsx y usó el tsx de node_modules/.bin con ruta absoluta, para no depender de la carpeta desde la que Claude Code lanza el servidor (su documentación no la fija):
claude mcp add --scope project --env NORTIA_INQUILINO=es --transport stdio nortia-pedidos \
-- /ruta/absoluta/nortia-pedidos/node_modules/.bin/tsx /ruta/absoluta/nortia-pedidos/src/stdio.ts
Fuente: prueba del 7 de octubre de 2026 (comando que dio Claude Code, con la ruta de ejemplo en lugar de la nuestra).
Hay tres ámbitos:
local(por defecto): solo tú y solo este proyecto.project: escribe.mcp.jsonen la raíz para versionarlo con el equipo. En sesiones interactivas, Claude Code pide aprobación antes de usar sus servidores. Hasta entonces,claude mcp listlos muestra como⏸ Pending approval, aunqueclaude -psí los conecta.user: todos tus proyectos.
Después, claude mcp list, claude mcp get <nombre> y, dentro de la sesión, /mcp muestran el estado. /mcp muestra además el número de tools de cada servidor.
¿Hablará 2026-07-28? La documentación de Claude Code dice que su cliente v2 se basa en el SDK de TypeScript 2.0, «which adds MCP protocol revision 2026-07-28», y que pregunta a los servidores HTTP si la soportan.
Para stdio, el changelog de la versión 2.1.292 (6 de octubre de 2026) dice que las conexiones locales negocian 2026-07-28 por defecto en todas las instalaciones (MCP_PROTOCOL_NEGOTIATION=legacy lo desactiva). La página de MCP, en cambio, aún describe un despliegue gradual.
En nuestra prueba, Claude Code 2.1.293 conectó por stdio con el nortia-pedidos que había generado y llamó a buscar_pedido. Eso no dice qué versión negoció, y la documentación no explica cómo verlo desde Claude Code. Para comprobarlo en tu servidor, la prueba fiable es el Inspector con modern. Si sirves el servidor con server.connect(new StdioServerTransport()), solo habla la versión anterior y Claude Code se conectará con el handshake antiguo.
Hazlo con Claude Code
Si prefieres que Claude Code escriba el servidor, puedes, con una condición: tienes que decirle qué SDK usar. Lo que sigue sale de la documentación de Claude Code y de los repositorios de las skills, leídos el 7 de octubre de 2026, y de una prueba real hecha ese mismo día con Claude Code 2.1.293.
Las skills recomendadas
Una skill es una carpeta con un SKILL.md que Claude carga cuando la petición encaja con su descripción, o que invocas con /nombre. Si quieres más contexto, lo tienes en qué son las Agent Skills. Para construir servidores MCP hay dos de Anthropic, ambas con licencia Apache 2.0:
| Qué instalar | Comandos (dentro de Claude Code) | Para qué sirve |
|---|---|---|
Plugin mcp-server-dev (marketplace oficial claude-plugins-official) |
/plugin install mcp-server-dev@claude-plugins-official (si responde que no encuentra el marketplace: /plugin marketplace add anthropics/claude-plugins-official y reintentar). Se invoca con /mcp-server-dev:build-mcp-server |
Es el camino que recomiendan la documentación de Claude Code y la de modelcontextprotocol.io. Primero pregunta por el caso de uso (a qué se conecta, quién lo usará, qué autenticación), recomienda HTTP remoto, MCPB o stdio local, elige el patrón de tools y genera el esqueleto. Trae además build-mcp-app (MCP Apps) y build-mcpb (MCP Bundles) |
Skill mcp-builder, dentro del plugin example-skills (repositorio anthropics/skills) |
/plugin marketplace add anthropics/skills y /plugin install example-skills@anthropic-agent-skills. Puede activarse sola por su descripción, pero no siempre (en nuestra prueba, en 1 de 4 peticiones). Para asegurarte, invócala con /example-skills:mcp-builder |
Guía en cuatro fases: investigación y plan, implementación, revisión y pruebas con el Inspector, y evaluaciones con diez preguntas reales. El repositorio advierte que sus skills son «for demonstration and educational purposes only» |
Desde el shell existen los equivalentes claude plugin marketplace add … y claude plugin install <plugin>@<marketplace>.
Aviso: las dos skills generan código de la v1
Ninguna de las dos está al día con lo que enseña este tutorial. El andamiaje de TypeScript de mcp-server-dev instala @modelcontextprotocol/sdk (la v1) e importa @modelcontextprotocol/sdk/server/mcp.js. Su último cambio es del 28 de abril de 2026. La guía de Node de mcp-builder usa @modelcontextprotocol/sdk ^1.6.1, imports profundos y server.connect(new StdioServerTransport()), y su contenido no cambia desde el 1 de diciembre de 2025. Las dos son anteriores a la especificación 2026-07-28.
Aprovecha su método (preguntar antes de programar, diseñar bien las tools, probar con el Inspector, evaluar), pero fija en el prompt el paquete @modelcontextprotocol/server v2, su versión y los puntos de entrada (serveStdio, createMcpHandler), y revisa el resultado.
Lo comprobamos el 7 de octubre de 2026 con Claude Code 2.1.293 en modo no interactivo, cada skill en su propia carpeta y con la misma petición: un servidor stdio con una tool que devuelve el estado de un pedido.
Sin fijar el SDK, las dos generaron la v1 (@modelcontextprotocol/sdk y server.connect(new StdioServerTransport())). El proyecto compila sin errores, pero el Inspector con --protocol-era modern falla con «Version negotiation failed: the server did not offer pinned protocol version 2026-07-28». Es decir, no negocia 2026-07-28.
Con esta frase añadida a la petición, mcp-server-dev generó @modelcontextprotocol/server 2.3.0 con serveStdio y zod/v4, y el Inspector terminó con código 0:
Usa el SDK oficial v2: el paquete `@modelcontextprotocol/server` (2.3.0) con Zod 4 (`import * as z from 'zod/v4'`), y sirve por stdio con `serveStdio(fábrica)` desde `@modelcontextprotocol/server/stdio` (para HTTP sería `createMcpHandler`). Nada de `@modelcontextprotocol/sdk` (v1) ni de `server.connect(new StdioServerTransport())`, aunque lo usen las guías de la skill.
Fuente: prueba del 7 de octubre de 2026, frase de fijación usada (literal).
Con mcp-builder y la misma frase, la skill no llegó a activarse, así que ese resultado prueba la frase, no la skill.
Prompts listos para copiar
Estos prompts no son texto oficial. Aplican las prácticas de la documentación de Claude Code y coinciden con los nombres y comandos de las lecciones 09 y 10. Escríbelos en plan mode (claude --permission-mode plan) y aprueba el plan antes de que implemente.
El primero crea un CLAUDE.md con lo que Claude no puede deducir del código. Cubre los dos SDK del curso. Si solo vas a escribir TypeScript, quita la línea de Python.
Crea un CLAUDE.md corto para este repositorio, que va a contener servidores MCP del curso de DesarrolloMCP.
Incluye solo lo que no se deduce del código:
- Especificación MCP objetivo: 2026-07-28 (sin estado: sin initialize ni sesión; server/discover y _meta en cada petición).
- Python: SDK oficial `mcp` 2.x (2.2.0) con `from mcp.server import MCPServer`. Prohibido `mcp.server.fastmcp` (no existe en 2.x) y prohibido el paquete independiente `fastmcp`. Gestor: uv. Tests: `uv run pytest -q`.
- TypeScript: SDK v2 en paquetes separados (`@modelcontextprotocol/server`, `@modelcontextprotocol/client`, `/node`, `/express`), Zod 4 importado como `import * as z from 'zod/v4'`. Prohibido `@modelcontextprotocol/sdk` (v1). Para stdio usa `serveStdio(fábrica)` y para HTTP `createMcpHandler(fábrica)`: `server.connect(new StdioServerTransport())` solo habla la era legacy. Comprobar tipos con `npx tsc -p .`.
- Los servidores stdio nunca escriben en stdout: los logs van a stderr.
- Verificación obligatoria al terminar: tests en verde y una llamada con la CLI del MCP Inspector (`npx -y @modelcontextprotocol/inspector@2.9.0 --cli`) con `"protocolEra": "modern"` dentro de la entrada del servidor en el fichero de configuración (o `--protocol-era modern` con un servidor ad hoc).
Fuente: docs/investigacion/claude-code-servidores-mcp.md, §6.a, prompt 1.
El segundo construye nortia-pedidos:
Quiero construir el servidor MCP `nortia-pedidos` en TypeScript dentro de ./nortia-pedidos.
Antes de escribir código, lee el README y la guía de migración actuales de https://github.com/modelcontextprotocol/typescript-sdk y propón un plan.
Requisitos:
- `npm init -y && npm pkg set type=module`; dependencias `@modelcontextprotocol/server @modelcontextprotocol/client @modelcontextprotocol/node @modelcontextprotocol/express express zod`; desarrollo `typescript tsx vitest @types/node @types/express`. Nada de `@modelcontextprotocol/sdk` (v1), aunque lo usen las guías de mcp-builder o mcp-server-dev.
- tsconfig con module y moduleResolution NodeNext, strict, noEmit.
- `src/servidor.ts` exporta una fábrica `crearServidor(deps)` que devuelve `new McpServer({ name: 'nortia-pedidos', version: '1.4.0' })` con `registerTool('buscar_pedido', …)` (inputSchema y outputSchema con z.object), los recursos `pedidos://catalogo/estados` y la plantilla `pedidos://pedido/{pedidoId}`, y el prompt `respuesta_retraso`.
- `src/stdio.ts` sirve con `serveStdio(() => crearServidor(deps))` desde `@modelcontextprotocol/server/stdio`; `src/http.ts` sirve con `createMcpHandler` + `createMcpExpressApp` + `toNodeHandler` en http://127.0.0.1:3000/mcp. Inquilino desde NORTIA_INQUILINO (por defecto "es"), con pedidos de ejemplo de ese inquilino y alguno de otro inquilino; logs a stderr.
- Tests con vitest. Ejecuta `npx tsc -p .` y los tests, y enséñame la salida.
Fuente: misma investigación, §6.a, prompt 3 (literal).
El tercero lo prueba con el Inspector y te da los comandos de conexión sin ejecutarlos. El original cubre también nortia-almacen, el servidor de Python de la lección 10. Aquí está reducido a nortia-pedidos:
Ahora conecta y prueba el servidor:
1. Crea `.inspector/config.json` con una entrada "nortia-pedidos": `npx tsx src/stdio.ts` con cwd ./nortia-pedidos, con `"env": {"NORTIA_INQUILINO": "es"}` y `"protocolEra": "modern"` dentro de la entrada del servidor (en la raíz del fichero se ignora).
2. Ejecuta `npx -y @modelcontextprotocol/inspector@2.9.0 --cli --config .inspector/config.json --server nortia-pedidos --method tools/list` y una llamada `tools/call` real (buscar_pedido con un pedido inexistente). Comprueba que la respuesta trae `_meta` con `io.modelcontextprotocol/serverInfo` (prueba de que se negoció 2026-07-28) y enséñame los comandos y la salida.
3. Dime el comando `claude mcp add` exacto para registrarlo en este proyecto con `--scope project` (rutas absolutas, también al ejecutable `tsx` de `node_modules/.bin`, para no depender de la carpeta desde la que Claude Code lanza el servidor; `--env NORTIA_INQUILINO=es` seguido de `--transport stdio` antes del nombre, y `--` antes del comando), pero NO lo ejecutes: lo ejecuto yo.
Fuente: misma investigación, §6.a, prompt 4, reducido al servidor de TypeScript.
Qué salió al ejecutarlos
Ejecutamos los tres prompts el 7 de octubre de 2026 con Claude Code 2.1.293, en modo no interactivo (claude -p; el tercero, en el mismo hilo con --continue), en una carpeta vacía:
- El servidor. Claude Code leyó el README, la guía de migración y la documentación de stdio, Express y tests del SDK. Usó la v2:
@modelcontextprotocol/server,client,nodeyexpress, sin@modelcontextprotocol/sdk. PusoserveStdioensrc/stdio.tsycreateMcpHandlerensrc/http.ts, e importó Zod conimport * as z from 'zod/v4'.npx tsc -p .terminó sin errores y los tests de vitest pasaron (8 de 8). - El Inspector. Por stdio, con
"protocolEra": "modern"en la entrada del servidor,tools/listytools/calldebuscar_pedidotrajeronserverInfoen_meta. Por HTTP, con--protocol-era modern, también: código 0 yserverInfo. El servidor HTTP lo arrancamos nosotros, porque los permisos con que lanzamos la prueba no dejaron a Claude Code hacerlo. - Claude Code. Registramos el servidor con el comando que dio Claude Code (el de «Con Claude Code»), y
claude -plo conectó y llamó abuscar_pedido. Respondió que el pedido no existía, y era cierto. Claude Code había inventado pedidos de ejemplo de los inquilinosdemoyotro, sin valor por defecto paraNORTIA_INQUILINO. Como el tercer prompt configuraes, desde Claude Code no existía ningún pedido. Por eso el segundo prompt dice ahora qué inquilino usar por defecto y qué datos crear.
La prueba no cubre una sesión interactiva. En -p, Claude Code no se detuvo a que aprobaras el plan, así que el paso de plan mode no está probado.
Además, la prueba se hizo con la versión anterior de los prompts. Tres frases se añadieron después y no las hemos vuelto a ejecutar: la del inquilino en el segundo, la de la era del Inspector en el primero y la del tsx en el tercero.
Cómo revisar lo que hace Claude Code
- Plan antes que código. Pulsa
Shift+Tabhasta ver⏸ plan mode on, o arranca conclaude --permission-mode plan. Así Claude explora y propone un plan sin tocar archivos, yCtrl+Gabre el plan para editarlo. Antes de aprobarlo, comprueba que el plan usa@modelcontextprotocol/server,serveStdioycreateMcpHandler. La propia documentación avisa de que para cambios pequeños no compensa: «If you could describe the diff in one sentence, skip the plan». - Dale una comprobación que pueda ejecutar. La primera buena práctica de la documentación es «Give Claude a check it can run: tests, a build, a screenshot to compare». También recomienda pedirle evidencias: «the test output, the command it ran and what it returned». Para un servidor MCP eso son
npx tsc -p ., los tests y la CLI del Inspector con--protocol-era modern, que devuelve un código de salida. - Revisa tú lo que no comprueba un test. Abre
package.jsony busca@modelcontextprotocol/sdk; buscaStdioServerTransportyconsole.logen el código del servidor. Prueba también la tool con un identificador de otro inquilino: debe responder igual que uno inexistente. - Mira el servidor conectado. Si lo registraste con
--scope project,claude mcp listlo muestra como⏸ Pending approvalhasta que lo apruebes en una sesión interactiva, aunqueclaude -pya lo conecta. Después,/mcpen la sesión muestra si conecta y cuántas tools expone. Una pregunta por un pedido de tu inquilino comprueba que hay datos: en nuestra prueba, ninguno existía.
Errores comunes
- Seguir un tutorial de la v1.
server.tool(),server.resource()yserver.prompt()ya no existen. En su lugar se usanregisterTool,registerResourceyregisterPromptcon objeto de configuración. Tampoco hay imports profundos como.../server/mcp.js. - Migrar el paquete y creer que ya hablas 2026-07-28.
server.connect(new StdioServerTransport())sigue compilando contra la v2, pero solo negocia 2025-11-25. El codemod oficial (npx @modelcontextprotocol/codemod@latest v1-to-v2 .) reescribe imports y registros. En la prueba del curso, sin embargo, no cambió ese cableado ni subió Zod a la versión 4: solo avisó. - Zod 3. La v2 exige Zod 4.2 o superior como dependencia de tu proyecto.
- Escribir en
stdouten un servidor stdio. Es el canal del protocolo. Logs y auditoría, astderr. - Crear lo caro dentro de la fábrica. En HTTP se ejecuta en cada POST: un pool de conexiones ahí abriría conexiones nuevas en cada llamada.
- Devolver la fila entera. El
outputSchemano recorta campos sobrantes: elige la salida campo a campo. - Probar con el Inspector sin la era
modern. El job pasa en verde sin haber ejercitado la versión actual. --tool-arg pedido_id=4812en el Inspector. Esa forma infiere tipos, así que el servidor recibió un número y respondióexpected string, received number. Usa--tool-args-jsonpara controlar los tipos.- Exponer el servidor HTTP tal cual. Este servidor no tiene autorización.
createMcpExpressApp()añade la validación deHostyOrigincuando escuchas en127.0.0.1. La especificación exige validarOriginy recomienda escuchar solo en127.0.0.1en local. Antes de publicarlo necesitas OAuth. Los fallos reales de este tipo están en seguridad en servidores MCP. - Olvidar el
--enclaude mcp addcon stdio, o poner el nombre del servidor justo después de--env.
Siguientes pasos
- La lección completa. La lección 9, Servidor en TypeScript, es parte del nivel Básico, gratis con registro. Añade lo que aquí no cabe: un cliente mínimo que recorre diez casos de éxito y de error, la diferencia entre
isErroryProtocolErrorcaso a caso, la migración desde la v1, un quiz y un ejercicio para añadir la primera tool con efecto. - El código. Está completo en el repositorio público de ejemplos, en la carpeta
basico/09-typescript, conpackage-lock.jsony las versiones exactas. Para ejecutarlo:cd nortia-pedidos,npm ci,npx tsc -p .ynpx tsx src/cliente.ts. - El mismo servidor en Python. Si tu equipo trabaja en Python, el tutorial hermano es crear un servidor MCP en Python, con
nortia-almaceny el SDK oficialmcp2.x. - Un agente que lo use. La lección 11, Agentes + MCP, conecta un agente con Claude a dos servidores de Nortia.
Aprende esto en el curso
- Básico Gratis Servidor en TypeScript · Construye nortia-pedidos con el SDK oficial v2
- Básico Gratis Agentes + MCP · Un agente con Claude que orquesta varios servidores
- Básico Gratis Herramientas · Acciones que el modelo puede invocar, con esquemas y errores
La lección 1 la puedes leer ya, sin registro. Las otras 13 del Básico son gratis con una cuenta.
Empieza gratis Ir a la lección