Part 2 of the MCP series.
- one public edge
- identity stamped from claims
- three trust tiers
- fail-closed permissions
- tool-level audit
The problem
Behind one MCP URL sat many MCP servers: tools for deploys, a read-only CLI tool, analytics, CDN settings, code tools for an AI builder, connections to other services. Each one had been written at a different time, and each one wanted to know the same three things: who is calling, for which store, and what they are allowed to do.
If every server answers those questions itself, every server has to verify tokens, and every server can get it wrong in its own way. I wanted them answered once, in one place, and then trusted everywhere behind it.
The shape
MCP client ──Bearer JWT──▶ edge
verify token once
strip client identity headers
stamp headers from claims
│
▼
gateway (router)
tier allowlist ∩ permission map
audit every call and denial
│
┌──────────────┼──────────────┐
▼ ▼ ▼
server A server B server CThe edge is the only component that sees the raw token. The gateway routes each tool call to the right server. The servers trust the headers they receive and do no auth of their own.
Identity, established once
The edge verifies the token's signature, issuer, audience and expiry. Then it sets internal headers for the user, the store, the permissions and the trust tier, all taken from the verified claims.
The order matters. First it deletes any copy of those headers the client sent, in any letter case. Only then does it stamp its own. Without that step, a client could send its own "permissions" header and the servers behind would believe it. With it, the only way to get a header is to get a claim, and the only way to get a claim is through the authorization server from part 1.
The same rule applies to tool arguments. A tool that takes a slug parameter means a branch name, or a page name. It must never be read as "which store". The verified store from the edge always wins over anything a model writes into an argument.
Trust tiers selected by a claim
Not every caller should even see every server. I split the surface into three trust tiers: one for customers and their own agents, one for the AI builder, and one for analytics. The tier is a claim in the token, minted by the issuer and stamped by the edge. A client cannot pick its own.
Each tier has an allowlist of servers. The builder's code tools live on exactly one tier, and that tier's allowlist contains nothing that can reach a production store. So "can the builder's tools change production?" is not a question about careful code. The answer is no, because the servers that could do it are not on its list.
An absent or unknown tier denies. It never falls back to the customer tier, because a default is just a quiet way of granting access.
Permissions that fail closed
Inside the gateway, two filters stack:
visible servers = tier allowlist(tier) ∩ servers the user's permissions admitThe permission map says which permission admits each server. A server with no entry in the map is invisible. A request with no permissions header is denied. The same parser feeds both the list of tools a client sees and the re-check on every call, so the two can never disagree.
Failing closed has a cost: adding a server without a map entry makes it invisible. That happened more than once, and it was always a confusing "the pod is healthy but nobody sees its tools". It is still the right failure. A missing tool gets reported within a day. A tool that is visible to the wrong people may never be.
Audit, denials included
Every tool call is recorded at the gateway: who, which store, which tier, which server and tool, and the result. Denials are recorded too. A denied call is the most interesting line in the log. It means a client, a model or a bug tried something the design says should not happen.
Each request also carries a correlation ID from the edge, so one call can be followed from the client to the server that ran it.
What I'd do differently
- One shared middleware package, earlier. Several servers grew their own copy of the tenant-isolation code, and the copies drifted. I'd pull it into one shared package before the third copy, not after.
- Group by trust tier, not by team. Next time I'd lay out namespaces by trust from day one: the public edge in one, the gateway and its servers in another, per region. That removes a whole class of network-policy drift between namespaces.
Takeaway
Verify once, at the edge. Strip what the client claims, stamp what the token proves, let a claim pick the trust tier, and make every missing answer a "no". Then the servers behind can be simple, and the safety comes from structure instead of every server getting it right.