Skip to content
SHEET ARCHITECTURE · CASE STUDY

A Tenant-Scoped MCP Server With a Fail-Closed Security Boundary

Twelve dashboard tools for LLMs, gated by OAuth 2.1, per-request audience binding, and a trusted-host check that runs before anything else

ARCHITECTURE SR. SOFTWARE ENGINEERSTARTUP
163
TESTS, 78 OF THEM SECURITY-FOCUSED

The Problem

The customer dashboard held records, documents, and notification counts behind a UI that only a human could query. The team wanted an LLM to answer questions over that data in plain language: how many records were opened last quarter, which clients generate the most activity. That only works if the access model behind it is as strict as the UI’s, because a tenant-isolation bug in an LLM tool surface does not show up as a confusing screen. It shows up as one tenant reading another tenant’s records.

This is a different project from the MCP scaffolding built for the pipeline platform. This server lives in its own repository, built for the customer dashboard, and by the time I finished it, it had a live OAuth 2.1 resource server in front of it. The two projects share a protocol, not a codebase.

The specific risk I was designing against was scope-widening: any tool surface that lets a caller supply its own tenant identifier is one bug away from letting tenant A ask about tenant B. I wanted that identity check to happen somewhere a tool author could not accidentally route around, not somewhere they had to remember to call.

The Approach

I built the server as a Micronaut 5 OAuth 2.1 resource server in front of twelve tenant-scoped dashboard tools, each backed by the platform’s dashboard gRPC service.

FIG. 12 / MCP SERVER · MODEL CONTEXT PROTOCOL AUTHENTICATION & MULTI-TENANT BOUNDARY
STAGING ONLY · 12 TENANT-SCOPED TOOLS · SHEET MCP-01
TENANT CLAIMS BEARER JWT HOST HEADER FETCH KEYS MCP SERVER SECURITY GATE FAIL-CLOSED MICRONAUT MCP CLIENT LLM AGENT AGENT RUNTIME TRUSTED HOSTS ALLOWLIST CHECK RUNS BEFORE TOKEN EVAL PLATFORM JWKS PUBLIC KEYS ENDPOINT ROTATING SIGNING KEYS DASHBOARD TOOLS SCOPED BY TENANT ID 12 TOOLS · NO TENANT ARGUMENT

Every request clears the trusted-host check first. Before the server derives the RFC 9728 protected-resource metadata, the RFC 8707 audience, or the token issuer, it validates the incoming Host header against a configured allow-list. Every one of those derivations reads from that same header, so validating it first closes off subdomain-spoofing behind a shared load balancer in one place, instead of at every call site that happens to remember to check.

The audience is derived per request, not configured once. RFC 8707 audience binding means a token is only valid for the specific host it names, computed fresh from that same trusted Host header on every call. A token scoped to one tenant’s subdomain is useless anywhere else, including against a different tenant’s endpoint on the same server.

Only RS256 is accepted. The server pins the signature algorithm and rejects HS256 tokens outright, even ones bearing a published RSA key, which closes off the classic algorithm-confusion forgery. Keys are fetched live from the platform’s JWKS endpoint at request time, so a key rotation on the identity side needs no redeploy here.

Tenant identity comes from the token, never from a tool argument. None of the twelve tools accept a tenant parameter. Scope resolves from the JWT’s tenant claims before the tool body ever runs, so there is nothing a caller could pass to widen its own access. Two end-to-end specs exist for the sole purpose of proving that a token minted for one tenant cannot read another tenant’s dashboard through any of the twelve tools, not just the ones I thought to check by hand.

The most dangerous line in the original config was a default that let everything work by accident. An early configuration shipped a default JWKS URL, so a misconfigured environment did not fail loudly. It silently validated tokens against the dev environment’s issuer instead of its own. I removed the default. The JWKS URL is required at startup now, and a missing one fails the service instead of quietly trusting the wrong issuer.

Testing leaned into the same posture as the design. Of 163 tests, 78 live in the security package alone, covering forged tokens, algorithm confusion, and missing or mismatched issuers, not just the happy path through a valid one.

Results

The server runs on the staging environment today: the initial service and ingress setup was done by hand, and every deploy since flows through CI. That staging instance doubles as the platform’s sales demo environment, where the sales team has begun using the tools ahead of upcoming industry conferences. A production deploy path is merged but has never run, so nothing has actually reached production. The first version shipped without token revocation, bounded instead by a twenty-four hour token lifetime cap; revocation has since been added, and the findings that gated a production rollout have been closed. The STDIO transport the server also supports carries no bearer-token security at all, which keeps it a development-only path and never a deployment target.

None of that is where the design fell short. It is where the scope stopped on purpose, and it is safer to name the edge than to let someone assume it does not exist. An agent boundary is a security boundary first and an API second.