Fredy Acuna
  • Posts
  • Projects
  • Tools
  • Contact
LinkedInXGitHubMedium

© 2026 Fredy Acuna

Back to posts
An MCP Server for a Real SaaS: How HitZero Lets Club Owners Run Their Club from Claude

An MCP Server for a Real SaaS: How HitZero Lets Club Owners Run Their Club from Claude

Fredy Acuna / October 30, 2026 / 9 min read

Run Your Club by Talking to Your Assistant

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:

  1. Same permissions as the app. Only active admins and coaches, only in the clubs where they hold that role.
  2. You confirm every change. Nothing is written or deleted until the user approves the exact request.
  3. Everything is logged. Every call lands in the club's audit log.

What You'll Learn

  • Why I exposed three meta-tools instead of one tool per endpoint
  • How the MCP layer reuses the existing REST routes so there is a single authorization path
  • How OAuth works for a remote connector with better-auth
  • How a signed, single-use confirmation token gates every write
  • What goes into the audit log, and what deliberately doesn't

The Stack

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.

Decision 1: Three Tools, Not Three Hundred

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 data
  • call_operation(id, args, confirmation_token?) runs it with the user's permissions

Building 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.

Decision 2: One Authorization Path

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.

Who Can Connect

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.

OAuth for a Remote Connector

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.

Decision 3: You Confirm Every Change

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:

  1. The first 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.
  2. The assistant shows that to the user. Only if the user approves, it repeats the same call with the token.

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.

What the Assistant Can't Touch

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.

Everything Is Logged

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 Endpoint

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 I'd Tell Myself Before Starting

  • Don't build a second authorization path. Calling your own routes in-process is the cheapest way to keep the MCP exactly as safe as the app.
  • Generate tools from your API spec. Hand-written tools drift; an OpenAPI index doesn't.
  • Make writes two-step and bind the token to the arguments. It's generic, testable, and it means the model can't "approve" a different request than the one the user saw.
  • Keep the audit shape closed. Log who did what and how it ended, not the data.
  • Read your auth library's checks. Expiry and disabled clients were on me.

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.

Subscribe to my newsletter

Get notified when I publish new posts.

We care about your data. Read our privacy policy.