
Fredy Acuna / October 30, 2026 / 9 min read
HitZero is a management platform for sports clubs: athletes, teams, classes, payments, inventory. Club owners and coaches live in the app all day, and the questions they ask it are always the same: who still owes this month's fee, what's on the schedule tomorrow, who is on the Elite roster.
So I added an MCP server. A club admin adds a remote connector in Claude (or any MCP-compatible assistant) with this address:
https://api.hitzero.club/api/mcp
They sign in with their normal HitZero account, and from then on they can ask in their own words. The paid plans (Basic, Pro and Enterprise) include it.
This post is about how it's built, and mostly about the three rules that made me comfortable shipping it:
The API is a Hono app (@hono/zod-openapi routes, Drizzle, Postgres, Better Auth). The MCP side uses the official @modelcontextprotocol/sdk with its web-standard Streamable HTTP transport, mounted as one more route on the same API.
When I counted, the API had around 286 createRoute definitions across 35 route files. Writing an MCP tool by hand for each one would be a maintenance trap, and loading hundreds of tool definitions would also flood the model's context.
Instead, every route already has an OpenAPI definition, so the server builds an operation index from the OpenAPI document in-process and exposes three meta-tools:
search_operations(query) finds operations by keyword (athletes, payments, classes...)describe_operation(id) returns the parameters, the body schema, and whether it modifies or deletes datacall_operation(id, args, confirmation_token?) runs it with the user's permissionsBuilding the index is a straight walk over the OpenAPI paths, skipping anything on the exclusion list:
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',
});
}
}
The inlineRefs step matters more than it looks: without it, describe_operation returned $ref pointers the model couldn't follow, and it had to guess the body fields.
The nice side effect: a new route is available to the assistant the day it ships, as long as it's not on the exclusion list. No MCP code to touch.
This was the most important design decision. The MCP tools do not talk to the repositories. call_operation builds a normal HTTP request and sends it through the same Hono app, in-process.
That means tenant scoping, role projections (a coach never gets guardian contact data), plan checks and rate limits behave exactly as they do in the app. The MCP layer only maps arguments to a route and trims the response. If a route answers 403 for a coach, it answers 403 through the assistant too.
The tricky part is identity. The in-process request must carry the verified user, but a header would be spoofable from the wire. The answer was a WeakMap keyed by the Request object itself:
// 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);
}
The auth middleware checks that map first. The invoker only ever sends content-type and accept headers, so the caller's own credentials never cross that boundary.
After the OAuth token is verified, a second check decides whether this person may use the MCP at all, and on which clubs:
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);
Guardians and athletes get a 403. And the club is never trusted from the tool arguments: when an operation has {orgId} in its path, the given orgId must be one of those memberships on every call, or the call fails before anything runs.
Remote MCP connectors use OAuth. HitZero already used Better Auth, and the installed version ships an mcp plugin that provides the authorization server: dynamic client registration, authorize and token endpoints, and the discovery metadata.
mcp({
loginPage: `https://${getPlatformUrl()}/login`,
resource: env.BETTER_AUTH_BASE_URL ?? 'https://api.hitzero.club',
}),
The flow from the user's side: the assistant hits /api/mcp, gets a 401 with a WWW-Authenticate header pointing to /.well-known/oauth-protected-resource, registers itself, and opens the normal HitZero login. The .well-known routes are served at the root, outside the /api base path, because that's where clients look.
One thing I found while reading the plugin: the session lookup for MCP tokens did not check the access token's expiry, and it didn't care whether the OAuth client had been disabled. So the wrapper checks both:
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;
Lesson: when a library hands you an auth primitive, read what it actually verifies.
Reads run directly. Anything that is a POST, PUT, PATCH or DELETE goes through two steps, and it works for every route without writing dry-run code per endpoint:
call_operation does not execute anything. It returns the exact request it would send, plus a short-lived confirmation token. For deletes it also includes the side effects, taken from the route description.The token is an HMAC-signed payload bound to the user, the club, the operation and a hash of the arguments:
const binding = {
sub: input.user.id,
orgId,
opId: op.id,
argsHash: hashArgs(input.args),
};
On the second call the server recomputes that binding and rejects the token if anything changed: another user, another club, another operation, or different arguments. It expires after five minutes and is single use, tracked in Redis. If the store is down, it fails closed with a generic message instead of running the write.
I want to be precise about what this guarantees. The server can't see the user's screen. What it can guarantee is that nothing is written on the first call, and that the second call runs exactly the request that was previewed, once. The tool description tells the assistant to show the preview and wait for a yes, and the assistant asks before running it.
Some areas are excluded entirely, each with a written reason in exclusions.ts. The public summary: roles, invitations and the subscription can't be operated from the assistant. In the code that covers billing, webhooks, member role and status changes, invitations, file uploads, outgoing email, custom domains, deactivating the club, and the accounting integration.
Responses are also trimmed before they reach the model. A deny-list removes tokens, passwords, document numbers, medical fields, blood type, emergency contacts and IP addresses from every response, recursively.
Every call_operation writes one mcp.tool_called entry to the club's audit log, with the outcome: ok, preview or error. The shape is closed on purpose:
/** 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;
}
No arguments, no names, no amounts. A club owner can see that a coach ran "register payment" from an assistant at 6:42 pm, without the log turning into a second copy of the data. And the orgId is only written when it's one of the caller's verified memberships, so a hostile id never ends up in the log.
The audit write is best effort: if it fails, the error goes to error tracking, and the tool call still answers.
The route itself is small. It runs in stateless mode: one server and one transport per request, no session to keep.
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);
It has its own rate limiter, and in-process calls are keyed by user instead of all sharing one bucket. The whole thing shipped behind a feature flag, off by default, so the database migration for the OAuth tables could deploy before anything was switched on.
What's still open: a small set of curated tools for the most common questions (overdue payments, today's classes), to be decided after seeing real usage. If you run a club on HitZero, you can see the public page at hitzero.club/en/mcp.