DEV Community

Cover image for Amit Got Stuck in MCP: A Valid Token, the Wrong Tenant
Amit Upadhyay for AI Wisdom

Posted on Edited on Originally published at aiwisdom.dev

Amit Got Stuck in MCP: A Valid Token, the Wrong Tenant

Every check passed. Valid token, right scope, 200 OK. And Maya from Acme was reading Globex's deployments.

A story: the names and numbers are invented, the failure is real.

I shipped my MCP server on a Friday. Bearer token on /mcp, a scope check, and I tested it both ways: the right token worked, a missing token got a 401. Green. I went home early.

On Monday, Maya from Acme support sent me a screenshot. Her assistant had shown her deployments for payments-globex. That is a Globex service. Maya works for Acme.

I rebuilt what happened from the logs:

  1. Maya's assistant connected with a valid token that carried the mcp:devops scope.
  2. She asked, "What shipped to payments last night?" The model called list_recent_deployments with service: "payments". Fine.
  3. Later, a pasted ticket mentioned payments-globex. The model called the tool with that name.
  4. The endpoint saw a valid token with the right scope and let it through.

Valid token. Right scope. 200 OK. Every check had passed, and the wrong customer's data still came out. I stared at that for an hour.

When I'm stuck, I open the one reference that does not hand out answers. AI Wisdom is not a friendly tutor. It is a guru, and it makes you earn the solution.

Chat between AI Wisdom and Amit. AI Wisdom: Is your server secure? Amit: Token, scope, and a 401 for a missing token. I tested it. AI Wisdom: You tested the doors that should open. Which door should stay shut?

I had no answer.

Chat between AI Wisdom and Amit. AI Wisdom: Before I give you a fix, tell me the problem in one sentence. A fix without a problem is how the same bug ships twice. Amit: The wrong customer's data came out. AI Wisdom: That is what happened. I asked what the problem is. Amit: The token is not enough. AI Wisdom: Not enough for what?

I sat with that one for a while.

Chat between AI Wisdom and Amit. Amit: A valid token tells me who is calling. It does not tell me what the model is allowed to ask for. AI Wisdom: Now you have a problem. Until now you only had an incident.

Draw the problem

Once I could say it in one sentence, I drew it. First the version I would have understood in my first year. Then the one for people who read tokens for a living.

Beginner view: a hotel key card. The token is a key card. Door one asks, "is this a real card?" Mine passed. Door two asks, "does this card open this room?" I never built door two.

%%{init: {"themeVariables": {"fontSize": "16px", "lineColor": "#8B97A8"}, "flowchart": {"wrappingWidth": 180, "nodeSpacing": 20, "rankSpacing": 32, "padding": 10, "subGraphTitleMargin": {"top": 6, "bottom": 20}}}}%%
flowchart TD
  M(["Maya, Acme support"]):::person -->|shows her assistant's key card| D1
  subgraph D1["Door 1: is this a real card?"]
    direction TB
    A["Valid token, scope mcp:devops"]:::ok --> B["The server says YES"]:::ok
  end
  subgraph D2["Door 2: does it open THIS room?"]
    direction TB
    C["The room is payments-globex, a Globex service. Nobody built this door."]:::bad --> D["Globex's deployments"]:::bad
  end
  D1 --> D2
  D2 -->|shown to| M2(["Maya, an Acme user"]):::person
  classDef ok fill:#DCFCE7,stroke:#16A34A,color:#14532D
  classDef bad fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D
  classDef note fill:#E0E7FF,stroke:#4F46E5,color:#1E1B4B
  classDef person fill:#FCE7F3,stroke:#DB2777,color:#831843
  style D1 fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
  style D2 fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569

Technical view: the same Monday, as requests. The model chose the service name both times. The endpoint only sees the token, and the token was fine.

%%{init: {"themeVariables": {"fontSize": "16px", "lineColor": "#8B97A8"}, "flowchart": {"wrappingWidth": 180, "nodeSpacing": 20, "rankSpacing": 32, "padding": 10, "subGraphTitleMargin": {"top": 6, "bottom": 20}}}}%%
flowchart TD
  M(["Maya, Acme support"]):::person -->|asks her assistant| R1
  subgraph R1["Request 1: payments"]
    direction TB
    a1["Endpoint: token valid, scope OK"]:::ok -->|tool runs| a2["Acme's deployments"]:::ok
  end
  R1 --> P["A pasted ticket mentions payments-globex.<br/>The model calls again, same token."]:::note
  P --> R2
  subgraph R2["Request 2: payments-globex"]
    direction TB
    b1["Endpoint: token valid, scope OK"]:::ok -->|tool runs| b2["Nobody asks: may THIS caller read it?"]:::bad
    b2 --> b3["Globex's deployments"]:::bad
  end
  R2 -->|reach| M2(["Maya, an Acme user"]):::person
  classDef ok fill:#DCFCE7,stroke:#16A34A,color:#14532D
  classDef bad fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D
  classDef note fill:#E0E7FF,stroke:#4F46E5,color:#1E1B4B
  classDef person fill:#FCE7F3,stroke:#DB2777,color:#831843
  style R1 fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
  style R2 fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569

Do you have this problem?

Chat between AI Wisdom and Amit. AI Wisdom: Do not ask me. Open your server and ask yourself.

  • A tool takes a service, tenant, project or id argument 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

I ticked three. How many do you tick?

Why the demo never showed it

Chat between AI Wisdom and Amit. AI Wisdom: In your demo, the person asking owns everything they ask for, so every request passes. The gap opens only when the arguments point at something the caller does not own. Who chooses the arguments? Amit: The model.

The fix that is a trap

Chat between AI Wisdom and Amit. AI Wisdom: Fix it. Amit: Easy. I add a tenant argument to the tool and compare it with the tenant in the token. AI Wisdom: Who fills in tenant? Amit: The model does. AI Wisdom: Then you asked the visitor to print his own pass. A guard who accepts the visitor's pass is not a guard.

I never trust a tenant identifier that came from the model now. It is not permission.

The fix: check inside the tool

Chat between AI Wisdom and Amit. AI Wisdom: The tool decides from who the caller is, never from what the caller says. Derive the caller from the validated identity, and authorize the specific resource before 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();
}
Enter fullscreen mode Exit fullscreen mode

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.

The fix as a flow. The check moves from the endpoint into the tool. It uses the caller from the validated identity, not anything the model wrote.

%%{init: {"themeVariables": {"fontSize": "16px", "lineColor": "#8B97A8"}, "flowchart": {"wrappingWidth": 150, "nodeSpacing": 20, "rankSpacing": 32, "padding": 10, "subGraphTitleMargin": {"top": 6, "bottom": 20}}}}%%
flowchart TD
  R(["Request: token + a service name chosen by the model"]):::note --> EP
  subgraph EP["Endpoint"]
    direction TB
    E["Token valid? Scope present?"]
  end
  subgraph TL["Tool, first line"]
    direction TB
    T["May THIS caller read this service?<br/>deployments:read"]:::note
  end
  EP -->|no| X1["401 or 403"]:::stop
  EP -->|yes| TL
  TL -->|"may NOT"| X2["Refused: backend untouched"]:::stop
  TL -->|"may"| B["Backend returns the rows"]:::ok
  classDef ok fill:#DCFCE7,stroke:#16A34A,color:#14532D
  classDef bad fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D
  classDef stop fill:#FEF3C7,stroke:#D97706,color:#78350F
  classDef note fill:#E0E7FF,stroke:#4F46E5,color:#1E1B4B
  style EP fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
  style TL fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569

Prove it with six tests

I wrote these before I touched the second tool:

  • 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

Which layer stops which test? The first five are about the token. Only the tool knows whose data a valid token is asking for.

%%{init: {"themeVariables": {"fontSize": "16px", "lineColor": "#8B97A8"}, "flowchart": {"wrappingWidth": 190, "nodeSpacing": 20, "rankSpacing": 32, "padding": 10, "subGraphTitleMargin": {"top": 6, "bottom": 20}}}}%%
flowchart TD
  subgraph EP["The endpoint stops these five"]
    direction TB
    E["1. No token<br/>2. Wrong issuer<br/>3. Wrong audience<br/>4. Expired token<br/>5. Valid token, missing scope"]:::ok
  end
  subgraph TL["Only the tool can stop this one"]
    direction TB
    T["6. Valid token, another tenant's resource"]:::bad
  end
  EP --> TL
  classDef ok fill:#DCFCE7,stroke:#16A34A,color:#14532D
  classDef bad fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D
  classDef stop fill:#FEF3C7,stroke:#D97706,color:#78350F
  classDef note fill:#E0E7FF,stroke:#4F46E5,color:#1E1B4B
  style EP fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
  style TL fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569

On the sixth, my old server answered instead of refusing. If the sixth request gets an answer, you have a backdoor with a nice schema.

The guru's last word

Chat between AI Wisdom and Amit. AI Wisdom: Start with one read-only tool that a team actually wants, such as deployment status or scoped log search. 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. AI Wisdom: And if you have one assistant and one tool, use a direct integration. MCP pays off once the same tool is copied across several assistants. AI Wisdom: Understand the problem, and the solution becomes a small thing.

Go deeper

My problem is fixed. The rest of running an MCP server in production is in the full guide:

  • 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 initialize handshake and Mcp-Session-Id), so any replica can answer a request
  • Routing and rate limiting by tool with the new Mcp-Name header
  • Long-running tools that survive dropped connections and redeploys
  • A completion checklist to run before you hand the server to another team

More from AI Wisdom

AI Wisdom, aiwisdom.dev: the engineering reference for production AI. 179 in-depth articles, 18 topic tracks, and mock exams for AI certifications.

Read the full MCP guide on AI Wisdom →

Neighbouring problems, in the same library:

Which Amit are you? 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".

Top comments (1)

Collapse
 
junfengzhou01 profile image
Junfeng Zhou •

Deriving the tenant from the token on the server, never from a tool argument, closes most of this, since the model can pass any id it saw earlier.