Connect two different people's agents to the same MCP server and, in most deployments today, they get back the exact same answer to the exact same question, because the server was given one shared credential and never asked to tell them apart. That gap between "this agent may open a session" and "this specific asker may see this specific record" is what mcp authorization actually has to close, and the protocol spec does not close it for you.
What the MCP spec actually covers
The Model Context Protocol's authorization specification describes how an MCP client obtains a token to talk to an MCP server, built on OAuth 2.1 with PKCE. It is a real and useful piece of engineering: it standardises how a client proves it is allowed to open a session, and it is optional for STDIO transports precisely because a local process already gets its credentials from the environment it runs in.
What it does not describe is what happens after the session opens. Once a client holds a valid token, the spec is silent on whether every tool call against that session should return the same data for every caller. That question, whether the person on the other end of this specific call may see this specific record, is left entirely to whoever wrote the server. Most servers answer it by not asking. The token authenticates the connection once, and every tool call after that runs with the same access as the token itself.
Does OAuth for MCP solve authorization?
Not on its own, and the terminology is worth being precise about. Authentication answers "who is calling." Authorization answers "what may they see or do." MCP's OAuth layer is squarely aimed at the first question. A support agent's key and an engineering lead's key can both pass every check in the spec and still be, functionally, the same key from the server's point of view, if nothing downstream tells them apart.
Per person, not per server
The alternative is to resolve every call to the specific person whose credential it carries, and check their entitlement against the specific thing they are asking for, not against the server as a whole. Concretely, that means:
- A credential resolves to a member, with a role and a set of explicit grants, not to an undifferentiated "the app."
- A grant is a scope string, a colon-delimited path like
finance:payroll:uk. A grant can end in a wildcard to cover a subtree, sofinance:*coversfinance:payroll:ukwithout naming every leaf, and a bare*(which owner and admin roles hold implicitly) covers everything. - Every record carries its own required scopes, and a call only returns a record when the caller's effective grants satisfy them.
That is a small, deliberately boring piece of algebra. It does not need a bespoke permissions DSL or an external policy engine to express most of a real org chart: department heads with a subtree wildcard, individual contributors with a handful of specific leaves, everyone else with nothing for that data at all.
"Authorization is OPTIONAL for MCP implementations. Implementations that do use authorization... SHOULD conform to this specification."
Model Context Protocol specification, Authorization, June 2025 revision
Read literally, that line is about whether a server bothers with OAuth at all. Read practically, it is a reminder that the spec treats authorization as a connection-level nicety a server can adopt or skip, and says nothing about what happens for the calls that come after.
Where the check actually has to run
The place that matters is not the connection handshake. It is the instant a tool call is about to return a result, and the cleanest way to enforce it is to make entitlement a factor in ranking rather than a filter bolted on afterwards:
score = relevance × (1 − w + w × freshness) × entitlementFactor
entitlementFactor is either 1 or 0, never a partial value. A record the caller is not entitled to see scores exactly zero and is dropped by the same cut that removes results that were simply irrelevant to the question asked. There is no separate redaction pass afterwards, and no synthesis step that ever receives the withheld object in the first place, because a model already holding a record cannot reliably be trusted to leave it out of its answer on request.
| Where the check runs | What it catches | What it misses |
|---|---|---|
| At the connection (a bearer token, checked once) | Whether this caller may talk to the server at all | Everything about which specific records this caller may see |
| As a prompt instruction ("do not reveal salary data") | Nothing reliably; it is advice to a model that has already read the data | The actual disclosure, if the model does not follow the instruction |
| As a multiplicative factor inside the ranking function | Every call, every record, with no reliance on the model's judgement | Nothing structural, though it depends on every record correctly declaring its own required scopes |
Table 1: three places to put an authorization check, and what each one actually stops.
What this looks like end to end
An agent calling context_search with a natural-language question gets back a handful of small memory objects, each already filtered to what the caller may see. The reply also carries entitlement.withheld_for_entitlement, a count, above zero when relevant material existed that this specific asker could not see. That number is not decorative. An agent that ignores it will answer a question as though it had the full picture when it did not, and the honest response is usually to say so and point the person at a colleague who holds the missing scope, not to quietly work around the gap.
What happens to a record I am not entitled to see?
It is not returned, not summarised, and not mentioned by content, only by count. The distinction matters because a system that answers "forbidden" for something that exists but is hidden has already disclosed that it exists. The honest answer, and the one that gives an attacker or an over-curious colleague nothing to work with, is the same not_found response you would get for a record that never existed at all.
Common pitfalls when adding this to an existing MCP server
- Authenticating the connection and stopping there. A bearer token that resolves to "the app" rather than to a specific member with specific grants leaves every subsequent tool call unable to tell callers apart.
- Putting the instruction in the prompt instead of the pipeline. Telling a model what it should not repeat is advice, not a control, and it only applies after the model has already read the thing it is being asked not to mention.
- Filtering after assembly instead of during ranking. A redaction pass that runs on an already-built answer has to catch every place a withheld fact could have leaked into surrounding prose. Never assembling it is simpler and does not depend on catching every leak.
- Returning a different error for "forbidden" than for "not found." Distinguishable error states let a caller learn something about data they cannot see just from how the system fails.
If you are evaluating this for your own stack, the MCP pillar page states plainly what is and is not in scope for a permissioned layer, /security has the entitlement ordering written out against the actual code, and /self-host lets you run the same engine against your own Postgres to see the scoring in action. For the retrieval side of the same guarantee applied to a broader RAG setup rather than specifically MCP, permission aware rag covers the general pattern this post is one instance of.
