MCP Deep Dive, Part 7: Reaching a Tool Isn't Being Allowed — Least-Privilege Authorization for MCP Agents
A valid token proves who an agent is, not what it may do. Here's scoped, least-privilege authorization for MCP tools — identity plus policy.
- Author
- Randhir Jassal
- Published
- Reading time
- 15 min read
- Views
- 13 views
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. Your agent's real dependency isn't the connection — it's an identity and a policy. Here's how we enforce that on Mattrx.
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 |
- Reaching a tool isn't being allowed — a valid token (Part 6) proves who, not what.
- Your agent's real dependency is an identity + a policy that says what that identity may do.
- Each tool declares a required scope; the server enforces it against the principal's token scopes.
- Tenant isolation is authorization — the same tool, a different tenant, is a different decision.
- Grant least privilege per agent — the read-only Insights agent can't create reports or touch admin.
- Destructive tools require step-up — a scope and a fresh confirmation (Part 5's annotations + policy).
- Make authorization policy-as-data, decided centrally — one authorizer, one audit trail.
- Least privilege caps the blast radius of a prompt-injected agent (Part 8) to one narrow capability.
- Every allow/deny is append-only audited — you can answer "who was allowed to do what."
- Enterprise: provision the policy once through your IdP and every agent inherits it (Part 11).
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.
The running example: from identity to permission on Mattrx
Part 6 left us with a validated AiPrincipal — tenant, user, and scopes, all from a cryptographically verified token. That's the "CAN REACH" box: a proven identity with a network path to mattrx-analytics, mattrx-reports, and mattrx-admin. This part builds the "ALLOWED" box: a per-call decision about whether this identity may run this tool against this data. Here's each piece, with the before that trusted a token too much and the after that doesn't.
The pipeline: from token to permission
Bearer token
|
v
[ AUTHENTICATION — Part 6 ] validate sig/iss/aud/exp -> AiPrincipal { tenant, user, scopes }
| answers "who is calling" -> CAN REACH
v
[ AUTHORIZATION — Part 7 ]
|-- required scope present? (which tools)
|-- tenant matches the data? (which data)
|-- destructive? -> confirmation (how dangerous)
|-- central policy: allow / deny (identity + policy)
| answers "may they do THIS" -> ALLOWED
v (allow) (deny -> audited 403)
tool handler
1. Reaching a tool is not being allowed
Before
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
After
Identity is the input to an authorization decision made on every tool call.
// 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);
}
Diagnostic: 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. The integration surface moved from "wire up the API" to "model who this agent is, and what it may do."
Mattrx metric: separating the two is the reason a compromised or over-eager agent has a bounded set of actions — the token gets it to the door; the policy decides which rooms open.
2. Enforce the required scope per tool
Before
Any authenticated caller could invoke any tool. The token's scopes were carried but never checked.
After
Each tool declares the scope it requires; the authorizer checks it against the principal's scopes (the ones 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}'");
Diagnostic: scopes are the policy in its simplest form. 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.
Mattrx metric: every tool across the three servers declares a required scope (campaigns:read, events:read, reports:create, admin:flags), and a call without the scope is a 403 in the audit log — never a silent success.
3. Tenant isolation is authorization
Before
A caller authorized to run get_campaign_kpis could read any campaign — authorization stopped at "which tool," never reaching "which data."
After
The tenant comes from the token and bounds every query; row-level security is the backstop (from the Enterprise AI Security post).
// "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
Diagnostic: scope authorization says may call this tool; tenant authorization says may touch this data. Multi-tenant systems leak at the second one — a correctly-scoped tool call still has to be bounded to the caller's tenant, in code and in the database, not in a prompt.
Mattrx metric: tenant-bounded queries plus row-level security are the reason for zero cross-tenant leaks in six months — scope gets you the tool, tenant gets you only your own data.
4. Least privilege per agent
Before
One god-scope-set was granted to every agent — the union of everything, "to keep it simple."
After
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 (section 5) narrow + confirmed
External assistant -> { campaigns:read, events:read } read-only, tenant-scoped
Diagnostic: least privilege is what turns a prompt-injection incident (Part 8) into a contained one. Grant the union and a hijacked read agent can suddenly delete an audience; grant the minimum and the worst a compromised agent can do is exactly what its two read scopes allow — nothing more.
Mattrx metric: 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
Before
Holding the scope was enough to run a destructive tool — a standing permission to delete.
After
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);
}
Diagnostic: for irreversible actions, a standing scope is too much standing power for something that can be talked into anything. Combine the annotation ("this tool destroys") with a policy ("require a fresh human confirmation or an elevated token") so an over-eager or hijacked agent can't quietly delete.
Mattrx metric: every destructive mattrx-admin tool requires a step-up confirmation on top of admin:flags — the reason no agent has ever silently dropped an audience or flipped a production flag on its own.
6. Policy as data, decided centrally
Before
Authorization was scattered as if checks across dozens of handlers — impossible to audit, easy to drift, and different in every tool.
After
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;
}
}
Diagnostic: 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 — and, in an enterprise, provision once through your identity provider and have every agent inherit it (the enterprise-managed model we build out in Part 11). That is the shift the industry is going through: from per-user OAuth and consent screens to an identity and a policy, managed centrally.
Mattrx metric: one authorizer fronts all three servers, so every authorization decision — allow or deny — lands in the same append-only audit log; "who was allowed to do what, and who was denied" is one query, not an archaeology dig across handlers.
The least-privilege matrix
Agent / caller campaigns:read events:read reports:create admin:flags step-up
------------------ -------------- ----------- -------------- ----------- -------
Insights * * - - -
Reporter * - * - -
Admin bot - - - * yes
External assistant * * - - - (tenant-scoped)
* = granted - = denied by policy Grant the minimum, never the union.
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 |
Authorization checklist
- Treat authentication as the input to authorization — never let a valid token run a tool by itself.
- Declare a required scope per tool; enforce it against the token's scopes.
- Authorize the data (tenant from the token) as well as the tool; back it with RLS.
- Grant least privilege per agent — the minimum scope set, never the union.
- Require step-up confirmation for destructive/irreversible tools (pair with Part 5's annotations).
- Centralize the decision in one authorizer with policy-as-data; never scatter
ifchecks. - Audit every allow and deny — a denied call is a signal worth alerting on.
- Keep authorization in code, never in a prompt — a system-prompt rule is a suggestion.
The honest stuff: when to keep it simple
- Single-tenant, single-agent internal tool. One coarse scope is fine; don't build a policy engine for one caller.
- Policy in the prompt. "Only read your own tenant" in a system prompt is a suggestion, not authorization. Enforce in code and in the store.
- Granting the union "to move fast." Every extra scope is blast radius the day an agent is hijacked. Least privilege is the fast path, long-term.
- Scope instead of tenant checks. Scope says may call this tool; tenant says may touch this data. You need both — one without the other leaks.
- 200 micro-scopes nobody maintains. Over-fine-grained authorization rots as badly as one god-scope. Right-size scopes to real capabilities.
- Skipping the deny audit. A denied call is an agent trying something it shouldn't — that's a security event. Log and watch it.
- Step-up fatigue. Confirming every read trains users to click through blindly. Reserve step-up for genuinely destructive, irreversible actions.
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. Get the policy right and connectors become the easy part; get it wrong and no amount of protocol polish will let agents ship inside a real company.
Three habits that keep authorization sound:
- 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.
In Part 8 we defend the whole surface against active abuse: securing MCP against prompt injection and tool abuse — because even a perfectly authorized agent can be told to do the wrong thing.
Continue the series — MCP Deep Dive
- Why Model Context Protocol Kills Integration Glue Code for Good
- Inside the MCP Architecture: Hosts, Clients, and Servers
- Build a Production-Grade MCP Server From Scratch
- Build an MCP Client That Connects to Any Tool (and Any Model)
- Custom MCP Tools Your AI Agents Can Actually Trust
- MCP Authentication With OAuth and Entra ID, Done Right
- Reaching a Tool Isn't Being Allowed — Least-Privilege Authorization for MCP Agents (you are here)
- Securing MCP Against Prompt Injection and Tool Abuse
- Streaming and Long-Running Tools Over MCP
- Debugging and Observability for MCP in Production
- Rolling MCP Out Across the Enterprise
- Building MCP Servers in C# and .NET 9
- Hosting MCP on Azure at Real Scale
- Wiring MCP Into OpenAI and Agent Frameworks
- Running MCP in Production — Lessons From Mattrx
Further reading
- MCP Deep Dive, Part 6: MCP Authentication With OAuth and Entra ID, Done Right
- MCP Deep Dive, Part 8: Securing MCP Against Prompt Injection and Tool Abuse
- MCP Deep Dive, Part 11: Rolling MCP Out Across the Enterprise
Modeling authorization for your agents and want a second pair of eyes on the scope-and-policy design? I'm always happy to compare notes — reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.