
Fredy Acuna / October 30, 2026 / 10 min read
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:
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.
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 datoscall_operation(id, args, confirmation_token?) la ejecuta con los permisos del usuarioConstruir 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.
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.
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.
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.
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:
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.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.
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.
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.
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 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.