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 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 con isError: true y el mensaje del regex («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: true que 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 el outputSchema llegó al cliente tal cual. El SDK valida los campos declarados, pero no elimina los sobrantes. El resultado lleva los datos dos veces, en structuredContent y como texto en content, 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.json en 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 list los muestra como ⏸ Pending approval, aunque claude -p sí 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, node y express, sin @modelcontextprotocol/sdk. Puso serveStdio en src/stdio.ts y createMcpHandler en src/http.ts, e importó Zod con import * 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/list y tools/call de buscar_pedido trajeron serverInfo en _meta. Por HTTP, con --protocol-era modern, también: código 0 y serverInfo. 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 -p lo conectó y llamó a buscar_pedido. Respondió que el pedido no existía, y era cierto. Claude Code había inventado pedidos de ejemplo de los inquilinos demo y otro, sin valor por defecto para NORTIA_INQUILINO. Como el tercer prompt configura es, 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+Tab hasta ver ⏸ plan mode on, o arranca con claude --permission-mode plan. Así Claude explora y propone un plan sin tocar archivos, y Ctrl+G abre el plan para editarlo. Antes de aprobarlo, comprueba que el plan usa @modelcontextprotocol/server, serveStdio y createMcpHandler. 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.json y busca @modelcontextprotocol/sdk; busca StdioServerTransport y console.log en 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 list lo muestra como ⏸ Pending approval hasta que lo apruebes en una sesión interactiva, aunque claude -p ya lo conecta. Después, /mcp en 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() y server.prompt() ya no existen. En su lugar se usan registerTool, registerResource y registerPrompt con 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 stdout en un servidor stdio. Es el canal del protocolo. Logs y auditoría, a stderr.
  • 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 outputSchema no 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=4812 en el Inspector. Esa forma infiere tipos, así que el servidor recibió un número y respondió expected string, received number. Usa --tool-args-json para controlar los tipos.
  • Exponer el servidor HTTP tal cual. Este servidor no tiene autorización. createMcpExpressApp() añade la validación de Host y Origin cuando escuchas en 127.0.0.1. La especificación exige validar Origin y recomienda escuchar solo en 127.0.0.1 en local. Antes de publicarlo necesitas OAuth. Los fallos reales de este tipo están en seguridad en servidores MCP.
  • Olvidar el -- en claude mcp add con 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 isError y ProtocolError caso 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, con package-lock.json y las versiones exactas. Para ejecutarlo: cd nortia-pedidos, npm ci, npx tsc -p . y npx 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-almacen y el SDK oficial mcp 2.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

Fuentes

  1. Especificación MCP 2026-07-28: transportes
  2. Especificación MCP 2026-07-28: tools
  3. The 2026-07-28 Specification (blog oficial de MCP) (2026-07-28)
  4. SDK de TypeScript de MCP: releases (2026-10-02)
  5. MCP Inspector: releases (2026-09-30)
  6. Claude Code: MCP

Artículos relacionados