Tool-level filtering alone is not access control
Model Context Protocol gave AI agents a clean, uniform way to call tools against real infrastructure: a tool name, a bag of arguments, sent over JSON-RPC: call_aws, soqlQuery, execute_sql. In an enterprise, those calls now typically pass through an MCP gateway first, a proxy sitting between the agent and the tool server that authenticates the caller and forwards each request on, before reaching the tool server itself. As agents got wired up to production AWS accounts, CRMs, and databases, the obvious question followed: how does the MCP gateway control what an agent is actually allowed to do?
The obvious first answer looks like the access control everyone already knows from web apps. Give the gateway an allowlist. Maybe an allowlist of tool names: this agent may call get_log_events, may not call delete_log_group. Once someone notices how excessive that is, the natural next step is an allowlist that also inspects arguments: may call get_log_events, but only if log_group_name matches /app/*.
Both versions feel like authorization but neither one is. The gap between “looks like authorization” and “is authorization” is where most agentic access control breaks down, and it’s worth being precise about why.
The unbounded blast radius problem
Look at the primary tool AWS’s own aws-api-mcp-server exposes for acting on an AWS account:
call_aws(cli_command: string | list[string], max_results?: int)
cli_command is a raw AWS CLI invocation, passed through as text: “aws s3api list-buckets”, or “aws iam attach-user-policy –user-name jsmith –policy-arn arn:aws:iam::aws:policy/AdministratorAccess”, or “aws ec2 terminate-instances –instance-ids i-0abc123”. Every one of those is a call to the exact same tool. A tool-name allowlist can express exactly one bit of information about call_aws: may run AWS CLI commands, or may not. It cannot get any more specific than that, because the tool schema doesn’t get any more specific than that. The entire surface area of the AWS CLI, across every service, lives inside one string argument.
This isn’t a one-off design wrinkle. It’s the natural shape for an MCP server that wraps an existing command or query language instead of hand-modeling every operation as its own tool. Salesforce’s hosted MCP server exposes soqlQuery, whose entire signature is one required string: query: “A valid SOQL query string”. Whether that query reads a single contact’s name or joins across Account, Opportunity, and Contact to pull every customer’s contract value depends entirely on text inside the string. The tool name never changes. Plenty of open-source database MCP servers follow the same shape: postgres-mcp’s execute_sql tool is documented simply as executing “SQL statements on the database, with read-only limitations when connected in restricted mode”. Whether a given call is a SELECT or a DROP TABLE is not something the tool name, or even the tool’s own restricted/unrestricted mode flag, can tell you on a per-call basis.
A tool-name allowlist has already made its decision before it looks inside any of these strings. It said yes to call_aws, or soqlQuery, or execute_sql, as a category — and the category contains everything from a harmless read to full account takeover.
The accuracy problem
Fine, filter the arguments too.
That starts with a problem more basic than IAM semantics: parsing the argument accurately in the first place. cli_command, query, and sql aren’t structured values — they’re free text in the AWS CLI’s own syntax, in SOQL, and in SQL, each a real grammar with its own quoting, escaping, wildcards, subqueries, and batch forms. call_aws alone accepts a –region * shorthand the tool expands into one call per enabled region, a list of independent commands run as a single batch, and –cli-input-json for parameters passed as a nested JSON blob instead of flags. A gateway filter has to parse every one of those forms correctly, and every quoting or escaping edge case the real AWS CLI accepts, before it can even ask whether the parsed intent is safe. Miss a form and the filter is either too strict to be usable, or silently permissive on the one spelling of the command an attacker happened to use.
The shadow IAM problem
Suppose the parser is perfect. Correctly authorizing the “aws iam attach-user-policy” call it extracts still isn’t a string-pattern problem. You need to know what the named policy actually grants, whether it can be combined with the user’s existing permissions to escalate further, whether a service control policy or permission boundary changes the effective outcome, and whether the account’s own resource policies interact with any of it. That entire evaluation already exists. It’s called IAM, it’s continuously maintained by the cloud provider, and it accounts for interactions no gateway-side pattern match will ever fully mirror.
The trap: build that evaluation a second time at the MCP gateway — one parser and rule engine per downstream system, per API version, forever kept in sync with a service you don’t own — and you’ve committed to maintaining a second, shadow IAM that is always stale and always incomplete relative to the real one.
It fails in exactly the direction you don’t want: it looks like a security control while quietly drifting out of sync with what the resource owner actually enforces.
Not every MCP server collapses its whole surface into one command-string tool. GitHub’s MCP server goes the other way: over a hundred narrow, single-purpose tools instead of one generic call_github. merge_pull_request is a real example of the fine-grained design tool-argument filtering was built for:
merge_pull_request(
owner: string,
repo: string,
pullNumber: number,
commit_title?: string,
commit_message?: string,
merge_method?: string
)
No free-form command string to parse, just an owner, a repo, and a PR number. Gate it on owner/repo and you’ve written a rule, not a parser.
Except the same underlying problem resurfaces one level down, just with cleaner inputs. Knowing that a caller may invoke merge_pull_request on a given owner/repo doesn’t tell you whether this merge is safe: whether required reviews have actually been satisfied, whether the target branch is protected and demands specific status checks, whether a CODEOWNERS rule reserves that path for someone else, whether the PR itself was opened from an untrusted fork.
All of that lives in GitHub’s own permissions and branch-protection model, and it varies per repo, per branch, sometimes per file path. To authorize the call correctly by argument, you’d have to mirror GitHub’s own merge-eligibility logic at the gateway with the same kept-in-sync-forever project as mirroring IAM, just for a different vendor, and still guaranteed to drift.
Fine-grained isn’t immune: fine-grained tools get you a cleaner audit trail and a simpler first-pass permission surface than “gate everything hidden inside one string” tools do. They don’t change where the real decision has to be made. Whether the schema is one opaque command string or six named fields, the authorization question is still “is this specific operation, on this specific resource, allowed under the resource owner’s own policy” and that answer lives with the resource owner, not with a rule written against the tool’s argument names.
The fan-out problem
There’s a further issue underneath both of these, and it doesn’t go away even with a perfect argument filter. A single MCP tool call is not the same thing as a single downstream operation. call_aws doesn’t even hide this — it documents batch mode as a first-class feature, accepting a list of CLI commands executed together in one tools/call: create a user, attach a policy, create an access key, all inside one envelope the gateway sees as a single request. Even without batching, a “list resources” call from any of these tool servers can paginate through an arbitrary number of underlying API requests the gateway never observes individually. It sees one JSON envelope going in and one result coming back. Authorization applied to that envelope can express “may call this tool”; it cannot express “and every real operation that call fans out into must individually satisfy this policy,” because those operations never cross the wire in a form the gateway can see.
You can’t authorize what you can’t observe, and the protocol was never designed to expose that level of detail. Trying to make it do so means re-deriving, for every wrapped tool server, exactly which internal calls a given argument combination will produce which is a moving target defined by someone else’s implementation.
We have to move the decision to whatever system already has real, expressive, continuously maintained authorization primitives for the resource in question. Using cloud infrastructure as an example, that’s the cloud’s own IAM engine. It already understands ARNs, wildcards, resource hierarchies, conditions, and cross-account trust because that’s its entire job.
The identity problem
IAM can only decide between principals (not requests) and most MCP deployments give it only one principal to work with: a single, static service account shared across every session. Whatever that account can do, every session can do, all the time, for the same reason call_aws puts the whole AWS CLI behind one tool name: one identity is as unbounded a surface as one command string.
Without a distinct identity per session, there are only two ways to staff that principal, and neither is scoped to what the agent is actually doing. One is that shared service account. The other is handing the agent a human’s own credentials outright — their real console access, their real CRM login. This answers the identity question by picking the one with highest levels of access, then inherits its entire permissions set: whatever that person can do in a given sensitive system, the agent can now do too, for a task that likely needs a sliver of it to execute on.
Federation produces a third option: an identity scoped to neither extreme, whose access is defined by this request’s approved intent rather than by a shared account or a borrowed human’s full privilege.
In practice, that means never giving the agent a broad, standing credential at all: you give it a credential tied to that session-specific identity, its entitlements already scoped to exactly what the agent is doing, minted fresh for this one grant, and gone shortly after.
Agents move fast, and what each one needs varies request to request. You can’t pre-provision an identity, credential, and entitlement for every intent an agent might have. They need to be assembled at the moment of the request with the runtime business context at hand. Just-in-time access is what does that: minting exactly what’s needed, exactly when it’s needed, at the speed and scale agentic workloads run at.
What intent-based, just-in-time access actually looks like
This is the pattern that closes the gap, and it has two moving parts: intent-based access and an ephemeral, single-purpose execution environment.

Access control flow for MCP gateway
- Intend: the agent session asks for specific access — reviewed by a human, a policy, or both.
- Federate: platform is a trusted OIDC provider; the session is exchanged via AssumeRoleWithWebIdentity or equivalent.
- Scope: short-lived credential, bound to a role and policy scoped to this session only — nothing broader.
- Isolate: a container or pod created for this session alone, running the real, unmodified MCP server.
- Enforce: the MCP server’s API call is checked by the actual, native policy — the one place equipped to decide.
- Expire: idle sandbox killed, credential expires, revocation takes effect within the same short window.
By the time the call reaches the resource owner, the credential cannot exceed the grant regardless of tool name or arguments.
Two kinds of “just in time,” and both matter
Scope. The credential is minted per grant, not pulled from a static pool of broad roles. A read-only log viewer gets a role that can read logs matching the approved pattern — nothing else — rather than a general-purpose role that happens to also cover this request.
Lifetime. The credential expires quickly, the sandbox is torn down on idle, and revoking the grant actually revokes access in the same short window, not at the next audit cycle. A stale filter rule is a liability precisely because nothing forces it to expire.
What this leaves the gateway to do
None of this means the protocol layer is useless — it’s just not where enforcement belongs. The gateway is still the right place to authenticate the caller, log every tool call and its arguments for audit, and drive the approval workflow that decides whether to mint a credential at all. What it shouldn’t be asked to do is re-derive, request by request, whether a given (tool name, arguments) pair is safe — that determination depends on state (existing permissions, resource ownership, org policy) that lives in the resource owner’s system, not in the MCP message.
Tool-name filtering answers “is this a category of thing we sometimes allow.” Argument filtering tries to answer “is this specific thing safe” with a rule engine that can only ever approximate the real one. Just-in-time, federated, scoped credentials sidestep the approximation entirely making sure that whatever it does, it can’t exceed what was actually granted.
