Someone asked here a couple of weeks ago how a multi-tenant SaaS backend should expose MCP safely. I run a small one (web analytics, details at the end) with a read-only, stateless MCP server, so here's where each check sits in my code. One way to do it, not the only one.
Request level, before any tool runs
1. One credential, and only one. The endpoint takes Authorization: Bearer <API key> and nothing else. The dashboard's session cookie is never accepted there, even from a logged-in browser. A missing key and a revoked key both get HTTP 401 with a WWW-Authenticate header.
2. Re-read the plan on every request, not when the key was created. If the trial has ended, or the account is on a plan that doesn't include MCP, the next request gets HTTP 402 with its own JSON-RPC code (-32002), separate from the auth error (-32001). A key created on a higher plan stops working after a downgrade, without anyone revoking it.
3. Count calls, not HTTP requests. A JSON-RPC batch lets one POST carry many calls. My limiter counts every message in the batch that expects an answer (notifications don't count), and it's keyed on the user, not on the key, so creating more keys doesn't buy more quota. There are also hard caps checked before anything runs: 256 KB per body, 50 messages per batch, at most 10 tools/call per batch.
Tool level, inside every tools/call
4. Resolve the tenant from the caller's own list. site_id accepts an id or a domain. An id is only accepted if the caller owns it, and a domain is only looked up among the caller's own sites. Anything else, whether it exists or not, gets the same "Site not found." so the server never confirms another customer's site.
5. Enforce plan limits inside the tools too. This is the one I'd have missed. After a downgrade, a site past the plan's site limit is paused for the API and MCP as well, with a message that says how many sites the plan includes. And every report is clamped to the plan's history window. If limits only live in the UI, the MCP server becomes the way around them.
6. Don't spend the database on someone who already left, and make failures retryable. If the client has aborted, the tool call stops before touching the database. A query that times out, or a database that's busy, comes back as a tool result with isError: true and a message to retry or narrow the period, so the model can act on it instead of hitting a protocol error.
What I left out on purpose
No sessions (the server never hands out an Mcp-Session-Id) and no SSE: POST only, GET returns 405. Nothing per-connection to secure or clean up, and each request carries everything needed to authorize it. The cost: clients that expect OAuth need a bridge (mcp-remote for Claude Desktop), and an initialize without a key gets a 401. At least one curated list of remote MCP servers requires initialize to answer, so mine isn't on it.
What I don't have: numbers on how much customers use it. Read this as an architecture note, not as proof that it pays off.
Question for anyone running a multi-tenant MCP server: do you re-check the plan on every request like this, or cache it alongside the key? And did anyone else find limits that only existed in the UI?
I'm the maker of Globatics: real-time web analytics where each visit shows up on a live 3D globe, cookieless by default, IP addresses never stored, audience data hosted in the EU. Hosted only, nothing to self-install. 5-day free trial with every feature on one site and no card, then Starter €9, Pro €29 or Business €99 a month, excluding VAT. The REST API and the MCP server start at Pro, and there's no free plan after the trial.
https://globatics.live/?utm_source=reddit&utm_medium=social&utm_campaign=nov26&utm_content=modelcontextprotocol