Blog
19 de junio de 20269 min

Cómo construir un servidor MCP para tu propio CMS

Un servidor MCP convierte tu CMS en herramientas que un asistente puede ejecutar. Aquí está el mío para Payload: 36 herramientas, la forma de cada una, y los tres detalles de diseño que hacen la diferencia entre útil y peligroso.

MCPPayload CMSClaude CodeNode.jsTutorial
Cómo construir un servidor MCP para tu propio CMS

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

Una lámpara alumbrando una hoja
Un linter no arregla nada: sólo alumbra lo que ya estaba mal. Por eso se puede correr sin miedo.

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

Depurando dentro de un contenedor de producción
El síntoma apuntaba a un grupo de herramientas. El culpable estaba dos capas más abajo, en el token.

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.

¿Te sirvió, o quieres discutirlo?

Escríbeme — me interesa cómo lo estás resolviendo tú.

Hablemos