OAuth on an MCP server checks the key, not what the key may open. A valid token tells your server that it is genuine, that it was issued for this server, and which scopes it carries. It does not decide whether this call may approve this expense. That rule is yours to write, and to check on every tool call before the tool changes anything.
The rest of this page shows where the line falls, what the MCP specification requires on each side of it, and how to test the call that should fail.
First, the words in plain English
- MCP (Model Context Protocol) is an open standard that lets an AI app use tools offered by a separate program, the MCP server. The glossary entry has the short version.
- OAuth is the standard behind the “allow this app to access your account?” screen. Instead of your password, the app receives an access token: a temporary pass it sends with every request.
- A scope is a named permission written into that token, such as
expenses:readorexpenses:approve. - Authentication establishes who is calling. Authorization decides what they may do.
Checking a token’s scopes is already authorization. It is just coarse: it knows that a token says expenses:approve, not which tool needs that scope or which expenses this person may touch.
A worked example: one token, three tools
Imagine a hypothetical twelve-person design studio. Its AI assistant connects to an MCP server for expenses with three tools: list_expenses, view_expense, and approve_expense.
Priya, the office manager, signs in through OAuth. The assistant receives a valid token, issued for the expense server, carrying expenses:read.
Three different questions now get asked, by three different parts of the system:
| Question | Who answers it | Where the rule comes from |
|---|---|---|
| Which app is asking? | The authorization server (the service that issues tokens) | The MCP specification |
| Is this token genuine, unexpired, and issued for this server? | Your MCP server, validating the token | The MCP specification |
| May this call approve this particular expense? | Your code | Nobody but you |
Priya’s token passes the first two. When the assistant calls approve_expense, only the third question can stop it. Her token lacks expenses:approve, so the server refuses.
Now suppose her manager Tom has expenses:approve. He asks the assistant to approve an expense that belongs to another team, or one he filed himself. The token is valid and the scope is present. If your code does not check which team the expense belongs to, and who filed it, nothing else will.
What does the MCP specification require?
The MCP authorization specification, revision 2026-07-28, makes authorization optional. When a server supports it over HTTP (a server reached over the network), it should follow the specification. A local server that runs over stdio (started as a program on the same machine) should not use this flow. It takes its credentials from its environment instead, so the question becomes which credentials that process receives.
For HTTP servers, the main rules are:
- The client sends the token in the
Authorizationheader on every request, and never in the web address. - The client names the server it wants a token for with a
resourceparameter. The server must check that the token was issued for it, and must not accept or pass on tokens meant for anything else. - Invalid or expired tokens must get a
401response. - A valid token without the scope an operation needs should get a
403response withinsufficient_scope, listing every scope that operation needs in one go. The client can then “step up”: ask for a new token that combines its earlier scopes with the new ones. Keeping track of that combination is the client’s job. - Servers must account for scope hierarchies, where a broader scope implies narrower ones. A plain text match can wrongly reject a token that should pass, so write the hierarchy down and test it.
The specification’s page on tools adds a rule people skip: servers must “implement proper access controls”. It requires the control. It does not design it for you.
The same page allows a server to show each caller only the tools its scopes permit when the client asks for the tool list. That is good design, because the model then never sees a button it cannot press. But hiding a tool is not blocking it. The check that counts runs when the tool is called.
Identifying the app is not granting a permission
The 2026-07-28 revision says authorization servers and clients should support Client ID Metadata Documents (CIMD). The app’s ID is a web address, and the authorization server fetches a short document at that address that describes the app. The older method, where an app registers itself on the fly (Dynamic Client Registration), is deprecated but kept for backwards compatibility.
CIMD tells the authorization server which app is asking. It does not grant expenses:approve, and it does not approve a tool call. Identifying the app, validating the token, and deciding whether this call may approve this expense are three separate checks. Passing the first two does not answer the third.
Write the permission map
This is the artifact that turns the principle into something you can review. One row per tool, with a default for everything else:
| Tool | Required scope | Extra rule | Checked where |
|---|---|---|---|
list_expenses | expenses:read | Only expenses in the caller’s team | In the tool’s code |
view_expense | expenses:read | The expense belongs to the caller’s team | In the tool’s code |
approve_expense | expenses:approve | Same team; not the caller’s own expense; amount under the approval limit | In the tool’s code, before anything is written |
| Any tool not in this table | None | Deny | Default |
The Agentic Codebase, one of Len’s books (available now), writes the same idea into a short contract file per tool: a list of what the tool is allowed, a list of what it is denied, and any scope not on the allow list is refused. You do not need the book to use the table above.
Approving money is also a decision many teams keep with a person no matter what the token says. What an agent may never do covers that list.
Where can the check live?
In the tool’s own code, in shared authorization code that every tool calls, or in a gateway (a proxy that sits in front of the server). The specification does not require a separate policy engine. Pick the place that covers every protected tool and that your team can read and test.
A gateway has one practical help from the specification. Over HTTP, every request must carry Mcp-Method and Mcp-Name headers, such as tools/call and approve_expense, so a gateway can see which tool is being called without reading the message body. The body remains the source of truth.
How do vendors handle it?
These are implementation choices, not requirements of MCP.
WorkOS says in its build notes of 31 July 2026 that its AuthKit product does not ship a scope-per-tool feature, so you create a permission such as expenses:approve and check it inside each tool. Their summary is worth pinning up: “‘Access to the server’ isn’t a permission worth having. ‘Can call approve_expense’ is.” One caution: their example helper allows any tool that has no permission mapped. Decide that default on purpose before you copy it.
Permit.io takes the gateway route. Its gateway overview says that on each call the gateway verifies the token, asks whether this agent may call this tool on this server, and logs the decision. Its co-founder Or Weis argues that OAuth is necessary but not sufficient. That is a vendor’s view of its own category, and it matches the specification’s split.
What does a passing connection test leave untested?
A connection test can pass while these defects remain:
- Every tool accepts the same broad scope. Looking up an expense and approving one need different access, but both accept the same token.
- The permission was checked only at sign-in. Later calls run without checking the scope they need.
- Record rules are missing. The tool checks the scope but ignores an input that selects another team’s expense.
MCP explained for founders has a wider fifteen-minute review of a server; this page is only the authorization slice.
Test the call that must fail
Use one valid token for two calls: one it may make and one it must not. For example, allow an expense lookup and refuse an approval when the token lacks expenses:approve. Then test the other failure paths one at a time:
| Test | Expected result |
|---|---|
| Missing, invalid, or expired token | 401; no protected tool runs |
| Valid token issued for another server | 401; no protected tool runs |
| Valid token without the required scope | 403 insufficient_scope; the tool does not run |
| Required scope present, but a record rule forbids the action | Your application’s refusal; nothing changes |
| Required scope and record rules satisfied | The action succeeds |
A record-rule refusal is not an insufficient_scope error. Tom’s token already has the scope. Telling his app to fetch a bigger token cannot fix a rule about which team owns the expense.
Check the record, not just the response. An error message returned after the expense was approved is a failed check.
Try this today (20 minutes)
- List every tool on one MCP server you run or depend on.
- Fill in the permission map above for each one, including the last row: what happens to a tool nobody listed?
- With a valid token that lacks one permission, call the tool that needs it.
- Open the record afterwards. If it changed, you found the gap before a customer did.
Cite this:MCP OAuth is not tool authorization: a valid token is not a yes.Len P. van der Hof. https://lenvanderhof.com/en/blog/mcp-oauth-is-not-tool-authorization/ ·