DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on • Originally published at prepstack.co.in

MCP Deep Dive, Part 7: Reaching a Tool Isn't Being Allowed — Least-Privilege Authorization for MCP Agents

Here's the quiet truth about shipping agents inside a real company: the protocol was never the blocker, and neither was the connection. Identity was — and right behind it, the policy that says what that identity may do. A tool your agent can reach but isn't allowed to use is not an integration. It's a liability with a network route.

This is Part 7 of a 15-part deep dive on Model Context Protocol (MCP). Part 6 answered who is calling — authentication, cryptographically. This part answers the harder question: what may they do? Authentication gets you a reachable tool. Authorization is what makes it an allowed one.

TL;DR

Question AuthN only (before) AuthN + AuthZ (after)
A valid token means… the tool just runs identity — a separate decision gates the tool
Which tools? any tool only tools whose scope the principal holds
Which data? whatever the args say the tenant from the token (+ RLS)
Privilege the union, granted to all least privilege per agent
Destructive actions a scope is enough scope + a fresh confirmation
Policy scattered if checks central, auditable, provisioned

The one mental shift: authentication proves the connection; authorization is the permission. Model the identity, then write the policy — what may this identity do, to this data, right now? Everything else is a reachable liability.

1. Reaching a tool is not being allowed

Once Part 6's RequireAuthorization() passed, every tool simply ran. Authentication was mistaken for authorization.

// BEFORE: a valid token -> the tool executes. "Reachable" was treated as "allowed."
app.MapMcp("/mcp").RequireAuthorization();   // proves WHO — and then nothing else checks WHAT
Enter fullscreen mode Exit fullscreen mode
// AFTER: the validated principal (Part 6) is checked against a policy, per call.
public async Task<ToolResult> InvokeAsync(McpToolCall call, AiPrincipal principal, CancellationToken ct)
{
    var decision = await authorizer.AuthorizeAsync(principal, call, ct);   // reachable != allowed
    if (!decision.Allowed)
    {
        await audit.DeniedAsync(principal, call, decision.Reason, ct);      // a denied call is a signal
        return ToolResult.Denied(decision.Reason);
    }
    return await next(call, ct);
}
Enter fullscreen mode Exit fullscreen mode

Authentication answers "who is calling"; authorization answers "may this caller do this, to this data, right now?" A valid token is a reachable connection — not a permission.

2. Enforce the required scope per tool

Each tool declares the scope it requires; the authorizer checks it against the principal's scopes (baked into the token in Part 6).

// The tool declares what it needs; the authorizer enforces it.
[McpServerTool(Name = "create_report"), RequiresScope("reports:create")]
public Task<ReportQueued> CreateReport(...);

// In the authorizer:
if (call.RequiredScope is { } scope && !principal.Scopes.Contains(scope))
    return AuthDecision.Deny($"missing scope '{scope}'");
Enter fullscreen mode Exit fullscreen mode

A read agent's token carries campaigns:read but not reports:create, so the create tool is denied before its handler runs — enforced by the server, not requested in a prompt. Every tool declares a required scope, and a call without it is a 403 in the audit log — never a silent success.

3. Tenant isolation is authorization

The tenant comes from the token and bounds every query; row-level security is the backstop.

// "Can call the tool" and "can read THIS campaign" are TWO decisions. The second is data
// authorization — the tenant from the token bounds the query; RLS enforces it in the store.
var kpis = await campaigns.GetKpisAsync(principal.TenantId, campaignId, range, ct);
//                                       ^ from the validated token, never from arguments
Enter fullscreen mode Exit fullscreen mode

Scope authorization says may call this tool; tenant authorization says may touch this data. Multi-tenant systems leak at the second one. Tenant-bounded queries plus RLS are the reason for zero cross-tenant leaks in six months.

4. Least privilege per agent

Each caller gets the minimal scope set for its job. Nothing more.

BEFORE: every agent -> { campaigns:read, events:read, reports:create, admin:flags, ... }

AFTER (least privilege):
  Insights           -> { campaigns:read, events:read }        read + reason
  Reporter           -> { campaigns:read, reports:create }     read + enqueue a report
  Admin bot          -> { admin:flags } + step-up              narrow + confirmed
  External assistant -> { campaigns:read, events:read }        read-only, tenant-scoped
Enter fullscreen mode Exit fullscreen mode

Least privilege is what turns a prompt-injection incident (Part 8) into a contained one. The Insights agent has never had a write scope, so no amount of prompt injection can make it create, change, or delete anything — its blast radius is capped at "read data it was already allowed to read."

5. Step-up for destructive tools

Destructive tools (flagged by Part 5's annotations) require the scope AND a fresh confirmation or a step-up token.

// A destructive tool needs the scope AND explicit, fresh confirmation — a scope alone is too much
// standing power to hand an autonomous agent for an irreversible action.
if (call.IsDestructive)   // from the tool's annotations (Part 5)
{
    if (!principal.Scopes.Contains(call.RequiredScope))
        return AuthDecision.Deny($"missing scope '{call.RequiredScope}'");
    if (!confirmation.IsFreshlyConfirmed(principal, call))   // a human tick, or a step-up token
        return AuthDecision.RequireConfirmation(call);
}
Enter fullscreen mode Exit fullscreen mode

For irreversible actions, a standing scope is too much standing power for something that can be talked into anything. Every destructive mattrx-admin tool requires a step-up confirmation on top of admin:flags.

6. Policy as data, decided centrally

One authorizer evaluates a central policy that maps (principal, tool, resource) to allow/deny, and records every decision.

// Authorization is a policy DECISION, not scattered if-statements. One authorizer, one audit
// trail, policies defined centrally (and, in the enterprise, provisioned via the IdP — Part 11).
public sealed class PolicyAuthorizer(IPolicyStore policies, IAiAuditLog audit) : IAuthorizer
{
    public async Task<AuthDecision> AuthorizeAsync(AiPrincipal p, McpToolCall call, CancellationToken ct)
    {
        var policy = await policies.ForAsync(p, call.Tool, ct);   // (principal, tool) -> rule
        var decision = policy.Evaluate(p, call);                  // scope + tenant + step-up
        await audit.DecisionAsync(p, call, decision, ct);         // every allow/deny recorded
        return decision;
    }
}
Enter fullscreen mode Exit fullscreen mode

Scattering authorization across handlers means it drifts, can't be audited, and can't be governed. Centralize the decision so "what may this identity do" is one policy you can read, test, and audit. One authorizer fronts all three servers, so "who was allowed to do what, and who was denied" is one query.

The numbers, in one place

Concern AuthN only (before) AuthN + AuthZ (after)
Valid token → action tool runs policy decides, per call
Cross-tenant leaks (6 mo) possible 0 (tenant from token + RLS)
Hijacked-agent blast radius every scope the agent's minimal scopes
Destructive actions scope alone scope + fresh confirmation
Denied calls silent / unlogged audited 403 (a security signal)
Policy location scattered ifs one central, auditable policy

The model to carry forward

Reaching is authentication; allowed is authorization. The dependency your agent actually has isn't the connection — it's an identity and a policy. Establish the identity cryptographically (Part 6), then decide, on every call, what that identity may do to which data — in code, centrally, and audited.

  • Separate can-reach from allowed. Never let a valid token be mistaken for a permission.
  • Authorize the data, not just the tool. Scope for tools, tenant for data — both, on every call.
  • Least privilege, and step up for destruction. Grant the minimum scopes per agent; require confirmation for anything irreversible.

Originally published on PrepStack. Modeling authorization for your agents and want a second pair of eyes on the scope-and-policy design? Reach me at randhir.jassal[at]gmail.com.

Top comments (4)

Collapse
 
circuit profile image
Rahul S

Good split on reachable vs allowed — that's the distinction most MCP auth writeups skip. The one place I'd push back is leaning on the cross-tenant track record as evidence that tenant-bound queries + RLS is enough. RLS only guards the query path, which is maybe the easy 80%; the leaks I've watched happen live all came from where it doesn't sit — a cache keyed without the tenant id, a log or trace line that dumps the whole row, an error message echoing another tenant's record, and the tenant claim itself: if token minting doesn't bind it hard, a replayed or swapped claim gets you RLS-blessed access to the wrong tenant. So RLS is the floor, not the proof. Curious whether your authorizer treats the tenant id as trusted input or re-derives it per call.

Collapse
 
kirandeepjassalcrypto profile image
kirandeepjassal-crypto

Yes — and you've named the exact failure mode I under-weighted in the post. RLS is the floor, not the proof, and leaning on the track record as evidence was me confusing "hasn't leaked" with "can't." Fair hit.

To your direct question: the authorizer re-derives the tenant id per call from the authenticated principal — it's never accepted as input. Nothing model-supplied, nothing in the tool arguments, and nothing in the request body can set the tenant; the tool binds it from the principal in code (the "tenant bound in code, not in args" point from the post). So a swapped tenant arg maps to nothing. But you're right that this only holds if the claim itself is trustworthy — and that pushes the problem back to token minting. There, the tenant is bound at mint time from the server-side session/identity, signed, and short-lived; a replayed token is still scoped to its original tenant, and there's no code path that reads a tenant claim the caller could set. If that binding were soft, everything downstream would be RLS-blessed access to the wrong tenant, exactly as you say.

The three you listed are the honest gaps, and they're all outside the query path where RLS simply isn't watching:

Cache keyed without the tenant id — this is the one that scares me most, because it fails silently and looks like a hit. Every cache key is tenant-prefixed, but that's a convention enforced by review, not by a type. Making the tenant a mandatory component of the key type (so an un-tenanted key can't be constructed) is on my list after this comment.
Logs/traces dumping a whole row / errors echoing another tenant's record — covered only because we redact at the egress boundary and log projections + hashes rather than raw rows, but that's defense that happens to help here, not a control designed for it. An error message is the sneakiest version — it bypasses the "data" path entirely and rides the exception channel.
The tenant claim binding — covered above, and it's the load-bearing one; if that's soft, the other mitigations are theater.

So: RLS guards the easy 80% (the query), and the leaks live in the 20% it never sees. The thing I'd change in the post is to stop citing the streak and instead state the invariant — tenant is re-derived per call, never trusted input — and then enumerate the non-query surfaces (cache, logs, traces, errors, token minting) as their own controls. Thanks for the push; this is the comment that turns "it hasn't leaked" into something actually defensible.

Collapse
 
xgrnetwork profile image
XGR.Network

The fresh-confirmation check is the right second boundary for destructive tools. I would define “fresh” as cryptographically bound to the exact resolved call, not only recent in time or compatible with the same scope.

The one-use confirmation could cover a canonical digest of the tool and version, tenant, target resource, normalized parameters, policy version, expiry, and nonce. If any of those change, the executor should reject the call and require a new confirmation. That prevents an approval obtained for one proposed mutation from being reused after its target or arguments have changed.

It also makes the audit statement precise: this principal approved this exact mutation under this policy, rather than merely having completed a recent step-up.

Collapse
 
kirandeepjassalcrypto profile image
kirandeepjassal-crypto

Agreed, and this closes a gap I'd left implicit. "Fresh" as recent in time is the weak version — it proves someone stepped up lately, not that they approved this mutation. What you're describing binds the confirmation to the resolved call itself, which is the only version that actually resists the attack that matters here.

The specific hole your model plugs: today our step-up is bound to the tool and scope, but not to the fully-resolved arguments and target. So there's a window — a confirmation obtained for "archive campaign 123" is, in principle, reusable for "archive campaign 456" if the executor only re-checks tool + scope + recency. Nothing model-supplied changes the tenant (that's re-derived from the principal), but the target resource and normalized params are exactly the fields that can shift between proposal and execution, and those are the ones a recency check waves through. Classic confirmed-deputy problem.

Your digest is the fix: a one-use confirmation covering the canonical tool + version, tenant, target resource, normalized parameters, policy version, expiry, and nonce — and the executor rejects and re-prompts if any field drifts. The two details I'd underline from your framing:

Normalization has to be canonical on both sides, or you get either bypasses (two encodings of the same target that hash differently) or false rejects (a semantically-identical param that serializes differently). The digest is only as strong as the canonicalizer.
Policy version in the digest is the part I hadn't connected — it means a confirmation obtained under a looser policy can't be replayed after the policy tightens. That's subtle and correct.

And the audit benefit is the real prize, because it changes the statement the log can make: not "this principal completed a recent step-up," but "this principal approved this exact mutation — this tool, this target, these arguments — under this policy version." One is a vibe; the other is evidence you can stand behind in an incident review. That's the same shift another commenter pushed me toward on the gateway decision-ID — approvals should be bound tokens, not timestamps.

Going to spec the confirmation digest alongside it. Thank you — genuinely sharpening the design.