Building an MCP Server for a Multi-Tenant SaaS: One URL, Every Tenant
How I added a production MCP server to ClubPal so club admins can ask Claude about their own club: OAuth 2.1 with dynamic client registration, per-call permissions, an allowlisted query engine over Cosmos DB, and the timezone bug that made an 18:00 session start at 19:00.
ClubPal is the platform I'm building for sports clubs: memberships, session bookings, events, payments and a members' app. Every club gets its own site, and a lot of club admins have started asking Claude and ChatGPT questions like "how many members will auto-renew next month?" or "who's booked on Saturday?". Until recently the model had no way to see the answer.
So I built an MCP server. Admins add one connector in Claude, sign in with their normal club login, and ask questions about their own club's data. It's live, it's read-only, and it now has over 50 tools covering 23 kinds of record.
Most MCP examples are a single user running a server against their own data. A multi-tenant SaaS is different: one server, many clubs, every admin with different permissions, and data that must never cross from one club to another. This post covers the decisions that made that work, and the bugs I'd warn anyone about.
One connector URL for every tenant
The first decision was the most important one: the token carries the tenant, not the URL.
Every club connects to the same address, https://mcp.clubpal.app/mcp. I could have given each club its own MCP endpoint on its own subdomain, but then admins would need to find the right URL, and every custom domain would need its own OAuth metadata. One URL means one set of instructions in the docs, and Claude.ai custom connectors and Claude Code both just work.
The MCP host runs inside the existing ASP.NET Core web app, routed by host name. There's no new App Service and nothing extra to deploy. When a request arrives on the MCP host, the tenant middleware skips the usual club lookup and treats it as a small system tenant that holds only OAuth documents.
OAuth 2.1, hand-rolled
MCP clients find out how to sign in from a 401 that points at your protected resource metadata:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.clubpal.app/.well-known/oauth-protected-resource"
From there the client reads the authorization server metadata (RFC 8414), registers itself with dynamic client registration (RFC 7591), and runs an authorization code flow with PKCE. I wrote this myself instead of pulling in a full identity server, because the surface is small: metadata, register, authorize, token and revoke.
The interesting part is getting from one URL to the right club. The flow goes like this:
- A club picker on the MCP host. The admin types their club's name and picks it from the matches. It also accepts an address typed straight in, including custom domains.
- A signed handoff to the club's own site. The MCP host redirects to
/connect/authorizeon the club's site, with the request id, tenant and expiry signed with HMAC-SHA256. The club site checks the signature and that the tenant matches the host it's serving. It never has to read the MCP host's data. - The club's normal login and consent. Because consent happens on the club's own site, everything I'd already built for sign-in carries over: MFA, passkeys, lockout and external logins. The consent page shows which profile is connecting and which permissions it holds.
- A signed callback to the MCP host, which mints a one-time code and sends it to the client. The redirect to the client always comes from the issuer named in the metadata.
A few rules I'd recommend to anyone doing the same:
- Public clients only, PKCE with S256 mandatory. No client secrets to leak.
- Store hashes, not tokens. Access and refresh tokens are 32 random bytes; only their SHA-256 hash is stored, as the document id, so checking a token is a single point read.
- Prefix your tokens.
cpa_for access,cpr_for refresh. A leaked token in a log or a paste is recognisable at a glance, and secret scanners can match it. - Rotate refresh tokens and treat replay as theft. Presenting a refresh token that has already been rotated revokes the whole grant.
- Let the database expire things. Auth requests, codes and tokens are Cosmos DB documents with a per-document TTL, so there's no clean-up job.
Permissions are read on every call, never put in the token
The token identifies a grant: this user, this club, this client. It says nothing about what the user can do. On every call, the bearer handler loads the grant, checks it isn't revoked, and sets the tenant for the rest of the request. From there the same permission checks the admin pages use run unchanged.
That means an admin who is demoted, or removed from the club, loses access on their next call, not when a token expires an hour later. Admins can also see and revoke their connections from a Connected Apps page.
The C# MCP SDK makes the tool side easy. It honours [Authorize] on tool methods for both tools/list and tools/call, so a tool the admin can't use is hidden from the model entirely:
[McpServerTool(Name = "list_members", ReadOnly = true)]
[Description("Members matching a query: filter on status, groups, membership type, expiry and more. Call describe_entity with 'members' for the fields.")]
[Authorize(Policy = Permissions.ViewMembers)]
public Task<McpRows> ListMembers(
[Description("Filter, sort, page and field selection.")] McpQuery? query = null,
CancellationToken cancellationToken = default) =>
engine.ListAsync(registry.Members, query, cancellationToken);
An admin without the payments permission doesn't get told "access denied" on a payments question; the model never sees a payments tool to try.
Why I didn't give the model SQL
The first version had a dozen hand-written tools, one per question: renewals_summary, payments_summary, session_attendance and so on. They worked, but admins' questions are open-ended. "How many will fail to renew" got an answer; "who will fail to renew" needed a new tool.
The obvious next step is to let the model write queries. Plenty of database MCP servers do exactly that. In a multi-tenant SaaS I think it's the wrong call, even read-only:
- Tenant isolation and personal data need a server-side allowlist. Card details and internal fields must never be reachable, whatever the model asks for.
- Permissions map to kinds of record, not to columns in a query.
- Raw rows are expensive. A thousand payment rows cost far more context than one aggregate, and the model then does the arithmetic badly.
So I built a small query engine instead. Every kind of record is declared once in a registry, field by field:
new() { Name = "expires", Type = FieldType.Date, Description = "When it expires.",
Ops = FieldOps.Ordered, CosmosPath = "c.expires",
Predicate = Predicates.NullableDate<Membership>(m => m.Expires),
SortKey = m => m.Expires!, Project = m => m.Expires },
Each list_ tool takes the same JSON shape: filters, a sort, a page and the fields to return. The engine turns the filters into a LINQ expression and runs it through the same repository the rest of the app uses, which already handles tenant scoping and soft deletes. Two generic tools, count_by and sum_by, generate parameterised SQL from the same registry for questions like "attendance by member this quarter" or "revenue by category this year against last".
A field the registry doesn't declare simply doesn't exist as far as the model is concerned. There's no reflection over the model classes, no dynamic LINQ from strings, and no client text ever reaches the database. That, and not the fact that it's read-only, is what keeps it safe.
The registry pays off in other ways too:
- The schema is generated from it. It's published as MCP resources and through a
describe_entitytool (some clients ignore resources), so the model learns the real field names instead of guessing. - Errors teach the model. An unknown field or an operator a field doesn't accept fails with the list of what is allowed, and the model corrects itself on the next call.
- One test checks every field translates. Not every C# property can become a Cosmos DB
WHEREclause; computed properties often can't. A unit test walks the registry and asserts that every filterable field's predicate and every sortable field's selector actually translates, so a field that only works for display can't be marked filterable by mistake.
Being honest about partial answers
Some useful fields aren't stored at all. Whether a membership can actually renew depends on whether the member has a card that can be charged, which lives on a different record. I mark these fields as derived. Filtering on one makes the engine page through the database with the other filters applied, evaluate the derived one in memory, and stop at 2,000 rows.
When it stops early, the response says truncated: true. A model that's told it has a partial count will say so; a model that isn't will confidently report a wrong total to a club treasurer.
The bug that made an 18:00 session start at 19:00
This is the one I'd warn every MCP builder about, because a model makes it worse.
For historical reasons, ClubPal stores some times as the club's wall-clock time labelled as UTC. A session that starts at 18:00 in London is stored as 18:00Z. Other times, like when a member last logged in, are real instants.
The first version of one tool treated a session start as a real instant and converted it to club time. In British Summer Time, an 18:00 session came back as 19:00+01:00. A person reading an admin page would never see that value. A model will read it, believe it, and tell an admin their session starts an hour late.
The fix was to make the difference explicit in the field types. Real instants are converted to the club's timezone on the way out and returned with an offset. Wall-clock values come back exactly as stored, with no offset, and a filter value with an offset is refused for them. Calendar dates like an expiry date are compared as plain yyyy-MM-dd.
The lesson is broader than timezones: anything a human would correct without thinking, a model will repeat with confidence. Ambiguous units, currencies and dates need to be unambiguous in the tool output, because there's no one reading it with common sense.
Smaller things worth knowing
- Optional parameters need defaults. In the C# SDK version I started on, a parameter like
string? xwith no default is advertised to the model as required. I have a test that checks every optional parameter on every tool has a default value. - Run stateless. The server uses Streamable HTTP in stateless mode, so deployment slot swaps and scaling out never strand a client's session.
- A refusal is not a server error. When a tool rejects a bad query, the SDK was logging it as an unhandled exception at Error level, so a model's typo looked like an outage. I now return the message as a normal tool error and log it as a Warning.
- Renames need aliases. When I renamed some records to match ClubPal's outbound webhooks, clients connected before the rename still had the old tool names cached. The old names still work as hidden aliases that nothing lists, and each use is logged so I'll know when they can go.
- Rate limit per grant, not per IP. 60 calls a minute per connection, with a
Retry-Afteron the429. - Let the club decide. Connections are off until a club admin with full permissions turns them on, because whatever the assistant reads goes to the company that provides it. That's the club's decision, not one admin's.
Would I do it again?
Yes. The hardest parts weren't MCP itself; the protocol and the C# SDK are pleasant to work with. The work was in the things any multi-tenant API needs (tenant resolution, permissions, an allowlist), plus taking seriously that the consumer is a model that will believe everything you return.
If you run a ClubPal club and want to try it, the setup takes a couple of minutes: see Connect an AI assistant in the ClubPal docs.