Le di mi CMS a Claude por medio de un servidor MCP: en vez de abrir el admin y hacer clics, le digo lo que quiero y pasa. La pregunta obvia es cómo se construye uno.
Va el recorrido completo, con el código del que uso a diario para este portafolio.
Qué es MCP, sin marketing
MCP (Model Context Protocol) es un protocolo para exponerle herramientas a un asistente. Tú declaras una lista de funciones con su esquema de entrada; el asistente decide cuándo llamarlas y con qué argumentos; tu servidor las ejecuta y devuelve texto.
Eso es todo. No hay magia: es un RPC con descubrimiento de capacidades. Lo valioso no es el protocolo, es que tú decides qué puede tocar y con qué garantías.
El transporte más simple es stdio: tu servidor es un proceso de Node que lee JSON-RPC de la entrada estándar y escribe en la salida. El cliente lo arranca cuando lo necesita.
El esqueleto mínimo
Con el SDK oficial son unas veinte líneas:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "portfolio", version: "2.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const handler = handlers[req.params.name];
if (!handler) return fail(`Herramienta desconocida: ${req.params.name}`);
try {
return await handler(req.params.arguments ?? {});
} catch (err) {
return fail(err.message);
}
});
await server.connect(new StdioServerTransport());
Dos piezas: TOOLS, la lista de declaraciones, y handlers, un objeto de funciones con el mismo nombre. Todo lo demás es tu dominio.
La declaración de una herramienta
Cada herramienta es un nombre, una descripción y un JSON Schema de entrada:
{
name: "list_posts",
description: `Lista posts del blog como JSON estructurado. Devuelve por post: ${POST_FIELDS_DOC}. Filtros: status, tag. Paginación: limit/offset.`,
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["draft", "published", "all"], description: "Filtrar por estado (default: all)" },
tag: { type: "string", description: "Filtrar por tag exacto" },
},
},
}
La descripción es la parte más importante del código. Es literalmente el manual que lee el asistente antes de decidir si llamar esa herramienta y con qué argumentos. Una descripción vaga produce llamadas equivocadas; una que dice qué campos devuelve y qué filtros acepta produce llamadas correctas a la primera.
Fíjate en el detalle: POST_FIELDS_DOC es una constante que enumera los campos del registro, y la reutilizo en list_posts, get_post y create_post. Así la documentación no se desincroniza entre herramientas que devuelven lo mismo.
Los tres detalles que sí importan
Con lo anterior ya tienes un servidor funcional. Estas tres decisiones son las que hacen que se pueda usar en producción sin sudar frío.
1. dry_run en todo lo que escribe
Cada herramienta destructiva acepta un dry_run que devuelve el diff campo por campo, sin tocar nada:
const changes = fieldDiff(stripMeta(before), stripMeta(after));
if (args.dry_run) {
return ok(`DRY RUN — post "${args.slug}": ${Object.keys(changes).length} campo(s) cambiarían`,
{ dry_run: true, id: doc.id, changes });
}
await patchLocales("posts", doc.id, postBodies(args));
El patrón es siempre el mismo: calcula el estado siguiente, diffea contra el actual, y sólo entonces decide si escribes. Como efecto secundario obtienes gratis el resumen de qué cambió para la respuesta real, no sólo para el ensayo.
Este detalle es el que me deja soltar el control. Antes de un cambio grande pido el dry-run, leo el diff, y entonces sí.
2. Errores que dicen qué hacer después
Un create_post con un slug que ya existe no debe crear un duplicado ni fallar con un stack trace. Debe decir exactamente qué encontró y cuál es la salida:
const existing = await findBySlug("posts", args.slug);
if (existing) {
return fail(
`Conflicto: ya existe un post con slug "${args.slug}" (id ${existing.id}, ` +
`título "${existing.title}"). Usa update_post.`,
{ conflict: { collection: "posts", id: existing.id, slug: existing.slug } }
);
}
La diferencia práctica es enorme: con ese mensaje, el asistente corrige solo y llama a update_post. Con un error genérico, se queda atorado o inventa.
3. Una herramienta que no escribe nada

Mi favorita del set es lint_portfolio. No modifica nada; revisa todo el contenido y devuelve hallazgos agrupados por severidad: locales faltantes, fechas guardadas a medianoche UTC (que se corren de día según la zona horaria del lector), Markdown crudo metido en campos HTML, proyectos sin imagen, huecos en las fechas de trabajo, media huérfana.
Es la revisión aburrida que nunca haces a mano y que una máquina hace en dos segundos. Y como no escribe, la puedes correr sin pensarlo.
Autenticación: el bug que me costó una tarde

Mi servidor habla con Payload por su API REST, con un JWT que obtiene al hacer login. La primera versión cacheaba ese token para siempre:
let _token = null;
async function getToken() {
if (_token) return _token; // ← se cachea para siempre
_token = await login();
return _token;
}
El JWT expira a las ~2 horas. En una sesión larga, el token cacheado se vuelve basura — pero las lecturas siguen funcionando, porque las colecciones tienen acceso público de lectura. Sólo mueren las escrituras.
El síntoma es engañosísimo: parece que un grupo de herramientas está roto, cuando en realidad falla cualquier cosa que escriba pasada la marca de las dos horas. El arreglo son dos cosas chicas — TTL para refrescar antes de que caduque, y un reintento cuando la API responde 401 o 403:
async function api(method, path, body, _retried = false) {
const res = await fetch(url, { headers: { Authorization: await getToken() }, ... });
if ((res.status === 401 || res.status === 403) && !_retried) {
_token = null; // fuerza re-login
return api(method, path, body, true); // y un solo reintento
}
return res.json();
}
Si tu MCP habla con una API autenticada, escribe esto desde el día uno.
Cómo conectarlo
En Claude Code, un bloque en la configuración de MCP:
{
"mcpServers": {
"portfolio": {
"command": "node",
"args": ["/ruta/a/mcp-server/index.mjs"],
"env": {
"PORTFOLIO_URL": "https://tu-sitio.com",
"PORTFOLIO_EMAIL": "...",
"PORTFOLIO_PASSWORD": "..."
}
}
}
}
Las credenciales viven en el entorno del servidor, no en la conversación. El asistente nunca las ve.
Qué gané con esto
El mío terminó en 36 herramientas y ~2,000 líneas: contenido (proyectos, experiencias, posts), media (subir imágenes, portadas, capturas, CV), mantenimiento (reordenar, normalizar fechas, lint) y publicación (exportar, importar, revalidar rutas).
El cambio real de flujo es este: antes, "subir tres fotos a una experiencia y moverla arriba de otra" eran diez minutos de clics en el admin. Ahora es una frase. Y como todo pasa por dry_run y por mensajes de error que explican el siguiente paso, no es un salto de fe: veo qué va a cambiar antes de que cambie.
Si tienes un CMS propio y ya trabajas con un asistente, es de las cosas con mejor relación esfuerzo/beneficio que puedes construir en un fin de semana.
