Fredy Acuna
  • Publicaciones
  • Proyectos
  • Herramientas
  • Contacto
LinkedInXGitHubMedium

© 2026 Fredy Acuna

Volver a publicaciones
Un Servidor MCP para un SaaS Real: Cómo HitZero Permite Administrar un Club desde Claude

Un Servidor MCP para un SaaS Real: Cómo HitZero Permite Administrar un Club desde Claude

Fredy Acuna / October 30, 2026 / 10 min read

Administra tu Club Hablando con tu Asistente

HitZero es una plataforma de gestión para clubes deportivos: atletas, equipos, clases, pagos, inventario. Los dueños de clubes y los entrenadores pasan el día en la app, y las preguntas que le hacen siempre son las mismas: quién debe todavía la mensualidad, qué hay programado mañana, quién está en el equipo Élite.

Por eso agregué un servidor MCP. Un administrador del club agrega un conector remoto en Claude (o en cualquier asistente compatible con MCP) con esta dirección:

https://api.hitzero.club/api/mcp

Inicia sesión con su cuenta normal de HitZero y, desde ese momento, puede pedir las cosas con sus propias palabras. Los planes pagos (Basic, Pro y Enterprise) lo incluyen.

Este artículo trata de cómo está construido y, sobre todo, de las tres reglas que me dieron la tranquilidad de publicarlo:

  1. Los mismos permisos que en la app. Solo administradores y entrenadores activos, y solo en los clubes donde tienen ese rol.
  2. Tú confirmas cada cambio. Nada se escribe ni se elimina hasta que el usuario aprueba la solicitud exacta.
  3. Todo queda registrado. Cada llamada queda en la auditoría del club.

Qué Vas a Aprender

  • Por qué expuse tres meta-herramientas en lugar de una herramienta por endpoint
  • Cómo la capa MCP reutiliza las rutas REST existentes para tener un único camino de autorización
  • Cómo funciona OAuth para un conector remoto con better-auth
  • Cómo un token de confirmación firmado y de un solo uso protege cada escritura
  • Qué se guarda en la auditoría y qué no, a propósito

El Stack

La API es una app de Hono (rutas con @hono/zod-openapi, Drizzle, Postgres, Better Auth). La parte MCP usa el SDK oficial @modelcontextprotocol/sdk con su transporte Streamable HTTP basado en estándares web, montado como una ruta más de la misma API.

Decisión 1: Tres Herramientas, No Trescientas

Cuando las conté, la API tenía alrededor de 286 definiciones createRoute en 35 archivos de rutas. Escribir a mano una herramienta MCP para cada una sería una trampa de mantenimiento, y cargar cientos de definiciones de herramientas llenaría el contexto del modelo.

En cambio, cada ruta ya tiene su definición OpenAPI, así que el servidor construye un índice de operaciones a partir del documento OpenAPI, dentro del mismo proceso, y expone tres meta-herramientas:

  • search_operations(query) busca operaciones por palabra clave (atletas, pagos, clases...)
  • describe_operation(id) devuelve los parámetros, el esquema del cuerpo y si modifica o elimina datos
  • call_operation(id, args, confirmation_token?) la ejecuta con los permisos del usuario

Construir el índice es un recorrido directo por las rutas OpenAPI, saltando todo lo que está en la lista de exclusiones:

for (const [path, item] of Object.entries(doc.paths ?? {})) {
  for (const method of METHODS) {
    const op = item[method];
    if (!isRecord(op) || isExcluded(method, path)) continue;
    out.push({
      id: str(op.operationId) || slug(`${method}_${path}`),
      method,
      path,
      summary: str(op.summary),
      requestBody: inlineRefs(op.requestBody, schemas, []),
      isMutation: method !== 'get',
      isDelete: method === 'delete',
    });
  }
}

El paso inlineRefs importa más de lo que parece: sin él, describe_operation devolvía punteros $ref que el modelo no podía seguir, y tenía que adivinar los campos del cuerpo.

El efecto secundario es muy útil: una ruta nueva queda disponible para el asistente el mismo día en que se publica, siempre que no esté en la lista de exclusiones. No hay que tocar código MCP.

Decisión 2: Un Solo Camino de Autorización

Esta fue la decisión de diseño más importante. Las herramientas MCP no hablan con los repositorios. call_operation construye una solicitud HTTP normal y la envía a través de la misma app de Hono, dentro del proceso.

Eso significa que el aislamiento por club, las proyecciones por rol (un entrenador nunca recibe los datos de contacto de los tutores), las validaciones de plan y los límites de tasa se comportan exactamente igual que en la app. La capa MCP solo traduce los argumentos a una ruta y recorta la respuesta. Si una ruta responde 403 a un entrenador, también responde 403 a través del asistente.

La parte delicada es la identidad. La solicitud interna debe llevar al usuario verificado, pero un header se podría falsificar desde la red. La solución fue un WeakMap cuya clave es el propio objeto Request:

// Keyed by object identity, so it cannot be forged
// from the wire (headers, cookies, URL).
const bound = new WeakMap<Request, string>();

export function bindInternalUser(req: Request, userId: string): void {
  bound.set(req, userId);
}

El middleware de autenticación consulta ese mapa primero. El invocador solo envía los headers content-type y accept, así que las credenciales del usuario nunca cruzan esa frontera.

Quién Puede Conectarse

Después de verificar el token OAuth, una segunda revisión decide si esa persona puede usar el MCP y en qué clubes:

const MCP_ROLES = new Set(['admin', 'trainer']);

const orgIds = memberships
  .filter((m) => m.status === 'active' && m.roles.some((r) => MCP_ROLES.has(r)))
  .map((m) => m.org_id);

Tutores y atletas reciben un 403. Y el club nunca se toma como confiable desde los argumentos de la herramienta: cuando una operación tiene {orgId} en su ruta, el orgId recibido debe estar entre esas membresías en cada llamada, o la llamada falla antes de ejecutar nada.

OAuth para un Conector Remoto

Los conectores MCP remotos usan OAuth. HitZero ya usaba Better Auth, y la versión instalada trae un plugin mcp que provee el servidor de autorización: registro dinámico de clientes, endpoints de autorización y de token, y los metadatos de descubrimiento.

mcp({
  loginPage: `https://${getPlatformUrl()}/login`,
  resource: env.BETTER_AUTH_BASE_URL ?? 'https://api.hitzero.club',
}),

El flujo desde el lado del usuario: el asistente llama a /api/mcp, recibe un 401 con un header WWW-Authenticate que apunta a /.well-known/oauth-protected-resource, se registra solo y abre el login normal de HitZero. Las rutas .well-known se sirven en la raíz, fuera del prefijo /api, porque ahí es donde los clientes las buscan.

Algo que encontré al leer el plugin: la consulta de sesión para tokens MCP no revisaba el vencimiento del token de acceso, y tampoco le importaba si el cliente OAuth estaba deshabilitado. Así que el envoltorio revisa ambas cosas:

const token = await auth.api.getMcpSession({ headers });
if (!token?.userId) return null;
if (!(token.accessTokenExpiresAt instanceof Date)) return null;
if (token.accessTokenExpiresAt.getTime() <= Date.now()) return null;
// Disabling an OAuth client must cut off tokens it already issued.
if (!client || client.disabled) return null;

Lección: cuando una librería te entrega una pieza de autenticación, lee qué verifica realmente.

Decisión 3: Tú Confirmas Cada Cambio

Las lecturas se ejecutan directamente. Todo lo que sea POST, PUT, PATCH o DELETE pasa por dos pasos, y funciona para todas las rutas sin escribir código de simulación por endpoint:

  1. La primera llamada a call_operation no ejecuta nada. Devuelve la solicitud exacta que enviaría, más un token de confirmación de corta duración. En las eliminaciones también incluye los efectos secundarios, tomados de la descripción de la ruta.
  2. El asistente se lo muestra al usuario. Solo si el usuario aprueba, repite la misma llamada con el token.

El token es un payload firmado con HMAC y atado al usuario, al club, a la operación y a un hash de los argumentos:

const binding = {
  sub: input.user.id,
  orgId,
  opId: op.id,
  argsHash: hashArgs(input.args),
};

En la segunda llamada el servidor recalcula ese vínculo y rechaza el token si algo cambió: otro usuario, otro club, otra operación o argumentos distintos. Vence a los cinco minutos y es de un solo uso, controlado en Redis. Si ese almacenamiento no responde, falla de forma segura con un mensaje genérico en lugar de ejecutar la escritura.

Quiero ser preciso sobre lo que esto garantiza. El servidor no puede ver la pantalla del usuario. Lo que sí puede garantizar es que nada se escribe en la primera llamada, y que la segunda ejecuta exactamente la solicitud que se mostró, una sola vez. La descripción de la herramienta le indica al asistente que muestre la vista previa y espere un sí, y el asistente pregunta antes de ejecutarla.

Lo que el Asistente No Puede Tocar

Algunas áreas están excluidas por completo, cada una con su razón escrita en exclusions.ts. El resumen público: los roles, las invitaciones y la suscripción no se pueden operar desde el asistente. En el código eso cubre facturación, webhooks, cambios de rol y de estado de miembros, invitaciones, carga de archivos, envío de correos, dominios personalizados, desactivar el club y la integración contable.

Las respuestas también se recortan antes de llegar al modelo. Una lista de bloqueo elimina de cada respuesta, de forma recursiva, tokens, contraseñas, números de documento, campos médicos, tipo de sangre, contactos de emergencia y direcciones IP.

Todo Queda Registrado

Cada call_operation escribe una entrada mcp.tool_called en la auditoría del club, con el resultado: ok, preview o error. La forma es cerrada a propósito:

/** Closed shape on purpose: no arguments, bodies, responses or PII. */
export interface McpAuditEntry {
  tool: string;
  operationId: string;
  actorId: string;
  orgId?: string;
  outcome: 'ok' | 'preview' | 'error';
  errorCode?: string;
}

Sin argumentos, sin nombres, sin montos. El dueño de un club puede ver que un entrenador ejecutó "registrar pago" desde un asistente a las 6:42 p. m., sin que la auditoría se convierta en una segunda copia de los datos. Y el orgId solo se escribe cuando es una de las membresías verificadas de quien llama, así que un id hostil nunca termina en el registro.

La escritura en la auditoría es de mejor esfuerzo: si falla, el error va al sistema de seguimiento de errores, y la llamada a la herramienta igual responde.

El Endpoint

La ruta en sí es pequeña. Funciona sin estado: un servidor y un transporte por solicitud, sin sesión que mantener.

const identity = await verifyMcpAccessToken(env, c.req.raw.headers);
if (!identity) return c.json({ error: 'Autenticación requerida.' }, 401, { ... });

const access = await resolveMcpAccess(env, identity.userId);
if (!access.allowed) return c.json({ error: '...' }, 403);

const server = createHitzeroMcpServer({ ... });
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
return transport.handleRequest(c.req.raw);

Tiene su propio limitador de tasa, y las llamadas internas se agrupan por usuario en lugar de compartir todas un mismo contador. Todo salió detrás de un feature flag, apagado por defecto, para que la migración de base de datos con las tablas OAuth pudiera desplegarse antes de encender nada.

Lo que Me Habría Dicho Antes de Empezar

  • No construyas un segundo camino de autorización. Llamar a tus propias rutas dentro del proceso es la forma más barata de que el MCP sea exactamente tan seguro como la app.
  • Genera las herramientas desde la especificación de tu API. Las herramientas escritas a mano se desactualizan; un índice OpenAPI no.
  • Haz las escrituras en dos pasos y ata el token a los argumentos. Es genérico, se puede probar, y significa que el modelo no puede "aprobar" una solicitud distinta a la que vio el usuario.
  • Mantén cerrada la forma de la auditoría. Registra quién hizo qué y cómo terminó, no los datos.
  • Lee las validaciones de tu librería de autenticación. El vencimiento y los clientes deshabilitados quedaron por mi cuenta.

Lo que sigue abierto: un pequeño conjunto de herramientas dedicadas para las preguntas más comunes (pagos vencidos, clases de hoy), que se decidirá después de ver el uso real. Si administras un club en HitZero, puedes ver la página pública en hitzero.club/mcp.

Suscribite al newsletter

Te aviso cuando publico algo nuevo.

Cuidamos tus datos. Leé nuestra política de privacidad.