Your MCP Server Checks the Token. Does It Check the Tenant?
You built an MCP server so every assistant in the company can call the same internal tools. You put a bearer token on /mcp, added a scope check, and tested it both ways: the right token works, and a missing token gets a
You built an MCP server so every assistant in the company can call the same internal tools. You put a bearer token on /mcp, added a scope check, and tested it both ways: the right token works, and a missing token gets a 401.
It feels finished. Here is the request it does not stop.
The request that gets through
Picture a deployment tool behind that server. It takes a service name and returns that service's recent deployments. (This scenario is illustrative.)
- Maya works at Acme. Her assistant connects with a valid token that carries the
mcp:devopsscope. - She asks, "What shipped to payments last night?" The model calls
list_recent_deploymentswithservice: "payments". Fine. - Later, a pasted ticket mentions
payments-globex, a service that belongs to another tenant. The model calls the tool with that name. - The endpoint sees a valid token with the right scope and waves it through. The tool does what it was asked. Another tenant's deployment history comes back to an Acme user.
A valid token tells you who is calling. It does not tell you what the model is allowed to ask for.
Do you have this problem?
Open your server and check:
- A tool takes a
service,tenant,projectoridargument and passes it straight to a backend - The only authorization code sits on the endpoint (
RequireAuthorization), and none inside the tools - You have never run a test where a valid token asks for someone else's resource
- Some tools are "just read-only", so nobody looked at what they can leak
If you ticked even one, keep reading.
Why the demo never shows it
The endpoint policy is a coarse boundary: right issuer, right audience, right scope. In a demo, the person asking owns everything they ask for, so every request passes.
The gap only opens when the arguments point at something the caller does not own. And the model is the one choosing the arguments.
The fix: check inside the tool
Each sensitive tool derives the caller from the validated identity and authorizes the specific resource before it does anything else. Look at the first line of the method body:
[McpServerTool(Name = "list_recent_deployments")]
[Description("List recent deployments for a service")]
public async Task<string> ListDeployments(
[Description("Service name")] string service,
[Description("Number of deployments to return")] int count = 5)
{
await callerAccess.RequireServicePermissionAsync(service, "deployments:read");
var response = await _http.GetAsync(
$"/api/deployments?service={Uri.EscapeDataString(service)}&top={count}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
callerAccess is built from validated claims for HTTP, and from an explicit local policy for stdio. It never trusts a tenant or service name supplied by the model.
Prove it with six tests
Write these before the second tool, not after the first incident:
- No token
- A token from the wrong issuer
- A token for the wrong audience
- An expired token
- A valid token with a missing scope
- A valid token asking for another tenant's resource
If that last test passes, you have a backdoor with a nice schema.
What I'd do first
Start with one read-only tool that a team actually wants, such as deployment status or scoped log search, and make it tenant-aware and tested before you add another. Read-only does not mean low risk: the risk comes from the data and permissions the tool exposes.
And if you have one assistant and one tool, a direct integration is simpler. MCP pays off once the same tool is copied across several assistants.
Go deeper
That is the permission half of running an MCP server in production. The full guide on AI Wisdom covers the rest:
- The complete five-step .NET build: stdio, tools, VS Code
mcp.json, and Streamable HTTP with JWT bearer auth - What the 2026-07-28 revision removed (the
initializehandshake andMcp-Session-Id), so any replica can answer a request - Routing and rate limiting by tool with the new
Mcp-Nameheader - Long-running tools that survive dropped connections and redeploys
- A completion checklist to run before you hand the server to another team
| Full guide on AI Wisdom | |
|---|---|
![]() |
MCP Servers: Building Tool-Using AI Agents with the Model Context Protocol How MCP works, how to build MCP servers that expose tools to AI agents, and practical patterns for connecting LLMs to your systems. Read the full guide on aiwisdom.dev → |
One check, right now: open your MCP server, find a tool that takes a service or tenant argument, and look at where the caller's access to that value is checked. Reply with where yours is: "endpoint only", "inside the tool", or "nowhere I can find".
The free Production AI System Checklist is a 6-page PDF with 30 checks from the same site.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.