Tutorial de servidor MCP en TypeScript: ejemplo del SDK y plantilla lista para copiar
Un servidor MCP funcional en TypeScript, desde npm install hasta Claude Code. Copia la plantilla del SDK, registra una herramienta, un recurso y un prompt, elige stdio o Streamable HTTP, prueba todo en el Inspector y añade herramientas de imagen y video que llamen a una API real sin bloquear al cliente.
Puedes conectar un modelo de lenguaje a tu propio código en unas cuarenta líneas. Este tutorial de servidor MCP en TypeScript construye exactamente eso: un servidor funcional con el SDK oficial, una plantilla de proyecto que puedes copiar y los dos transportes que importan, stdio para clientes locales y Streamable HTTP para remotos. Al final tendrás una herramienta, un recurso y un prompt, probados en el Inspector y registrados en Claude Code. Después añadimos herramientas de imagen y video, porque ahí es donde un servidor de Model Context Protocol deja de ser una demo y empieza a hacer trabajo real. Cada fragmento de código funciona con Node.js 20 o superior y el paquete @modelcontextprotocol/sdk.
Qué hace un servidor MCP
El Model Context Protocol (MCP) es un estándar abierto que permite a un cliente de IA, como Claude Code, Claude Desktop o un agente de un IDE, llamar a funciones y leer datos que viven dentro de tu proceso. Los mensajes viajan como JSON-RPC 2.0. Tu servidor anuncia lo que ofrece, el cliente lista esas capacidades y el modelo decide cuándo usarlas. Tu código nunca habla directamente con el modelo. Responde a las peticiones, y por eso el servidor se mantiene pequeño.
Tres bloques de construcción
Todo servidor MCP se compone de alguna combinación de tres primitivas:
Bloque
Quién decide usarlo
Uso típico
Herramienta
El modelo
Consultar una base de datos, llamar a una API, generar una imagen
Recurso
La aplicación o el usuario
Exponer un documento, un archivo o una configuración como contexto legible
Prompt
El usuario
Una plantilla reutilizable, como "revisa este pull request"
En la práctica, las herramientas hacen la mayor parte del trabajo. Una herramienta es una función con nombre, un esquema de entrada tipado y un resultado de texto o imagen. Los recursos y los prompts son opcionales, pero cuestan casi nada añadirlos una vez que el servidor existe.
Cliente, servidor y transporte
El transporte es solo la tubería por la que circulan los mensajes JSON-RPC. El mismo objeto McpServer funciona con stdio o con HTTP, así que la estructura correcta es construir el servidor en una sola función y conectar el transporte en un archivo de entrada aparte. La plantilla de abajo sigue esa regla, lo que simplifica las pruebas y te permite publicar ambos transportes desde una sola base de código.
Preparar el proyecto de TypeScript
Instalar el SDK y zod
Crea una carpeta e instala las dependencias. El SDK usa zod para los esquemas de entrada: los convierte a JSON Schema para el cliente y valida cada argumento que llega antes de que se ejecute tu handler.
La estructura de carpetas es deliberadamente sencilla:
mcp-notes-server/
src/
index.ts stdio transport and startup
http.ts Streamable HTTP transport
server.ts buildServer() factory
package.json
tsconfig.json
💡 Las importaciones del SDK terminan en .js, incluso en archivos TypeScript. Con la resolución Node16, el compilador exige extensiones explícitas, y si falta una, el error aparece en tiempo de ejecución como ERR_MODULE_NOT_FOUND.
Construir la plantilla del servidor
La fábrica del servidor
Pon todo lo que ofrece el servidor en src/server.ts. Esta versión registra una herramienta que guarda una nota y otra que busca una nota:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const notes = new Map<string, string>();
export function buildServer(): McpServer {
const server = new McpServer({ name: "notes-server", version: "1.0.0" });
server.registerTool(
"add_note",
{
title: "Add note",
description: "Save a short note under a unique id. Overwrites an existing id.",
inputSchema: {
id: z.string().min(1).describe("Unique id, for example 'standup-0612'"),
text: z.string().min(1).max(2000).describe("The note body"),
},
},
async ({ id, text }) => {
notes.set(id, text);
return { content: [{ type: "text", text: `Saved note ${id}` }] };
}
);
server.registerTool(
"get_note",
{
title: "Get note",
description: "Return the text of a saved note by id.",
inputSchema: { id: z.string().min(1).describe("The note id") },
},
async ({ id }) => {
const text = notes.get(id);
if (text === undefined) {
return { isError: true, content: [{ type: "text", text: `No note with id ${id}` }] };
}
return { content: [{ type: "text", text }] };
}
);
// resources and prompts go here (next section)
return server;
}
Hay dos detalles más importantes de lo que parecen. Primero, la descripción es lo que lee el modelo para decidir si llama a la herramienta, así que escríbela como si fuera documentación para un compañero. Segundo, cuando algo falla, devuelve isError: true con un mensaje legible en lugar de lanzar una excepción. Así el modelo puede reintentar con un argumento corregido o explicarle el problema al usuario.
Añadir un recurso y un prompt
Sustituye el comentario de marcador de posición por esto:
server.registerResource(
"all-notes",
"notes://all",
{
title: "All notes",
description: "Every saved note as JSON",
mimeType: "application/json",
},
async (uri) => ({
contents: [{ uri: uri.href, text: JSON.stringify([...notes.entries()]) }],
})
);
server.registerPrompt(
"summarize-notes",
{
title: "Summarize notes",
description: "Ask for a short summary of the saved notes",
argsSchema: { tone: z.string().optional() },
},
({ tone }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Summarize my saved notes in a ${tone ?? "neutral"} tone.`,
},
},
],
})
);
Los recursos se identifican por URI (notes://all), y los clientes suelen mostrarlos en un selector para que el usuario los adjunte como contexto. Los prompts aparecen como comandos de barra o entradas de menú, según el cliente.
Deja que un modelo de programación te ayude
Una vez que esta plantilla funciona, un modelo de programación puede añadir herramientas en minutos. Pega src/server.ts en un chat y pide una herramienta nueva que siga el mismo patrón: primero el esquema, isError en caso de fallo. Claude Sonnet 5, Kimi K2.6 y GPT 5.6 Sol están diseñados para trabajo de código y están disponibles en PicassoIA. Lee el esquema generado antes de aceptarlo: un modelo hará opcional sin pensarlo un campo que debería ser obligatorio.
Elegir un transporte
stdio para clientes locales
stdio es el transporte más sencillo. El cliente lanza tu servidor como proceso hijo y se comunica con él por stdin y stdout. Crea src/index.ts:
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildServer } from "./server.js";
const server = buildServer();
await server.connect(new StdioServerTransport());
console.error("notes-server ready on stdio");
💡 Nunca uses console.log en un servidor stdio. La salida estándar transporta el protocolo, así que una sola línea de log suelta corrompe el flujo y el cliente se desconecta con un error de análisis. Envía los logs a stderr con console.error.
Streamable HTTP para clientes remotos
Para un servidor que vive en un host en lugar de en un equipo portátil, usa Streamable HTTP. Sustituyó al antiguo transporte HTTP plus SSE y necesita un único endpoint. Instala Express con npm install express y npm install -D @types/express, y luego guarda esto como src/http.ts:
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildServer } from "./server.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, () => console.error("MCP endpoint on http://localhost:3000/mcp"));
Al establecer sessionIdGenerator: undefined, el transporte funciona en modo stateless: un servidor nuevo por petición, sin sesión que seguir y escalado horizontal como cualquier otra API. Si necesitas notificaciones iniciadas por el servidor o flujos reanudables, cambia al modo stateful con ids de sesión.
stdio
Streamable HTTP
Se ejecuta en
Proceso hijo del cliente
Cualquier host accesible por HTTP
Autenticación
Hereda el entorno del usuario
La añades tú (OAuth o tokens bearer)
Ideal para
Herramientas personales y de desarrollo local
Servidores compartidos y alojados
Escalado
Un proceso por cliente
Sin estado, escala como una API
Probar antes de conectar
Ejecutar el MCP Inspector
El Inspector es la interfaz de depuración oficial. Compila el proyecto y lanza tu servidor a través de él:
npm run build
npx @modelcontextprotocol/inspector node dist/index.js
Abre la URL local que muestra, pulsa Connect y luego usa la pestaña Tools para listar las herramientas y ejecutar add_note con un argumento JSON. El panel de historial muestra el tráfico JSON-RPC sin procesar, que es la forma más rápida de detectar un esquema que no coincide con lo que pretendías.
Registrar en Claude Code y Claude Desktop
Claude Code registra un servidor local con un solo comando, y un servidor HTTP con una opción de transporte:
claude mcp add notes -- node /absolute/path/mcp-notes-server/dist/index.js
claude mcp add --transport http notes-remote http://localhost:3000/mcp
Claude Desktop lee un archivo JSON (claude_desktop_config.json):
💡 Usa rutas absolutas en ambos casos. Una ruta relativa se resuelve desde el directorio de trabajo del cliente, no desde el tuyo, y el mensaje de error rara vez lo indica.
Añadir herramientas de imagen y video
Un servidor de notas demuestra el patrón. Las herramientas multimedia muestran por qué compensa: trabajos lentos, salidas grandes y una API externa con sus propios límites. PicassoIA expone una API para desarrolladores al estilo de Replicate en https://api.picassoia.com/v1, autenticada con un token Bearer que empieza por pia_sk_. Cuatro modelos son accesibles a través de la API y del conector MCP: PicassoIA Image para texto a imagen, PicassoIA Image Editor Pro para ediciones, PicassoIA Video para video y Seedance 2.5 Lite para video con audio. Los trabajos son asíncronos: creas una predicción, la consultas repetidamente y después lees el resultado.
Envolver un endpoint de imagen
Dos pequeños helpers sirven para todos los modelos, porque la forma de la API es la misma:
const API = "https://api.picassoia.com/v1";
const headers = {
Authorization: `Bearer ${process.env.PICASSOIA_API_TOKEN}`,
"Content-Type": "application/json",
};
export async function createPrediction(model: string, input: Record<string, unknown>) {
const res = await fetch(`${API}/models/${model}/predictions`, {
method: "POST",
headers,
body: JSON.stringify({ input }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`Create failed: HTTP ${res.status}`);
return (await res.json()) as { id: string };
}
export async function getPrediction(id: string) {
const res = await fetch(`${API}/predictions/${id}`, { headers });
if (!res.ok) throw new Error(`Status failed: HTTP ${res.status}`);
return (await res.json()) as { status: string; output?: unknown; error?: string };
}
La herramienta de imagen los usa y espera hasta dos minutos por un resultado:
server.registerTool(
"generate_image",
{
title: "Generate image",
description: "Create an image from a text prompt and return its URL.",
inputSchema: { prompt: z.string().min(10).max(4000) },
},
async ({ prompt }) => {
try {
const { id } = await createPrediction("picassoia/picassoia-image", { prompt });
for (let i = 0; i < 60; i++) {
const job = await getPrediction(id);
if (job.status === "succeeded") {
return { content: [{ type: "text", text: JSON.stringify(job.output) }] };
}
if (job.status === "failed") throw new Error(job.error ?? "Generation failed");
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error("Timed out waiting for the image");
} catch (err) {
return { isError: true, content: [{ type: "text", text: String(err) }] };
}
}
);
El límite de 4000 caracteres del esquema coincide con el límite de prompt de la API, y la página de cada modelo enumera sus campos de entrada exactos y su formato de salida. La API también permite 5 predicciones simultáneas por cuenta, compartidas entre tokens y conexiones MCP, así que pon en cola las llamadas paralelas a herramientas en lugar de lanzarlas todas a la vez.
Gestionar los trabajos lentos de video
El video tarda mucho más que una imagen, y una llamada a herramienta que se bloquea durante minutos puede superar el tiempo de espera propio del cliente. Divide el trabajo en dos herramientas: una inicia el trabajo y devuelve su id de inmediato, la otra consulta su estado.
server.registerTool(
"start_video",
{
title: "Start video",
description: "Start a video job from a prompt. Returns a prediction id to check later.",
inputSchema: { prompt: z.string().min(10).max(4000) },
},
async ({ prompt }) => {
const { id } = await createPrediction("picassoia/picassoia-video", { prompt });
return { content: [{ type: "text", text: JSON.stringify({ predictionId: id }) }] };
}
);
server.registerTool(
"check_video",
{
title: "Check video",
description: "Return the status and output of a video job by prediction id.",
inputSchema: { predictionId: z.string().min(1) },
},
async ({ predictionId }) => {
const job = await getPrediction(predictionId);
return { content: [{ type: "text", text: JSON.stringify(job) }] };
}
);
El modelo llama a start_video, hace otras cosas y consulta check_video hasta que el estado indique succeeded. Nada se bloquea, y un trabajo fallido es solo otro estado que reportar. Para video con audio, cambia el modelo a picassoia/seedance-2.5-lite (Seedance 2.5 Lite); los helpers no cambian.
Publicarlo con seguridad
Validar entradas y proteger los secretos
Trata cada argumento de herramienta como no confiable. Un modelo puede ser dirigido por texto que lee en la web o en un archivo, así que una página manipulada puede pedir a tu herramienta que haga algo que nunca pretendiste. Tres hábitos reducen la mayor parte del riesgo:
Acota cada campo en zod: min, max, enum y regex para los ids.
Nunca pases argumentos a un comando de shell ni a una cadena SQL. Usa consultas parametrizadas y limita las rutas de archivo a un único directorio base.
Lee los tokens desde variables de entorno, nunca los escribas en el código, y nunca los devuelvas en el resultado de una herramienta.
Para los clientes stdio, define los secretos en el bloque env de la configuración del cliente. Para los servidores HTTP, exige una cabecera Authorization y verifícala antes de que se ejecute handleRequest.
Corregir los tres errores habituales
console.log en stdio. Cámbialo por console.error.
Extensiones .js que faltan. Una importación como ./server falla en tiempo de ejecución con Node16.
Descripciones vagas. Una herramienta llamada run con la descripción "hace cosas" nunca se elige, o se elige con los argumentos equivocados. Nómbrala según la acción y di cuándo usarla.
Para publicarlo, mantén la línea shebang al principio de src/index.ts, ejecuta npm run build y después npm publish. Cualquiera puede registrarlo con claude mcp add notes -- npx -y mcp-notes-server. Los servidores HTTP se distribuyen como contenedor o se ejecutan en cualquier host de Node.
Pruébalo en Picasso IA
Ahora tienes una plantilla que funciona en local, funciona en remoto y puede llamar a modelos de imagen y video. La forma más rápida de ver lo que devuelven esas herramientas es probar primero los modelos a mano. Abre PicassoIA Image y escribe un prompt tan específico como los que enviarías desde generate_image: sujeto, lente, luz, escenario. Después prueba PicassoIA Video o Seedance 2.5 Lite para animar la idea, y anota qué formulación da el resultado que quieres antes de fijar valores por defecto en tu servidor. Elige cualquier modelo del catálogo completo en picassoia.com/en/all-models y conéctalo con los mismos dos helpers. Crea tus propias imágenes en Picasso IA hoy, y deja que tu primera llamada a una herramienta MCP te entregue el resultado.