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 (1)

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.