AI Systems Research guide

MCP OAuth is not tool authorization: a valid token is not a yes

OAuth tells your server the token is genuine and meant for it. Your code decides whether this call may approve this expense.

A daylight tool wall with one open wire cage of hand tools and a separate closed steel cage beside it
Getting to the wall is one check. Opening the second cage is another.

Direct answer

On a remote MCP server, OAuth answers two questions: is this access token genuine and issued for this server, and which scopes (named permissions) does it carry? It does not know which scope each tool needs, or that a user may only approve expenses in their own team. You write that map and check it on every tool call, before the tool changes anything. Local MCP servers that run over stdio skip this OAuth flow and take credentials from their environment.

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:read or expenses: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:

QuestionWho answers itWhere 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 tokenThe MCP specification
May this call approve this particular expense?Your codeNobody 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 Authorization header on every request, and never in the web address.
  • The client names the server it wants a token for with a resource parameter. 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 401 response.
  • A valid token without the scope an operation needs should get a 403 response with insufficient_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:

ToolRequired scopeExtra ruleChecked where
list_expensesexpenses:readOnly expenses in the caller’s teamIn the tool’s code
view_expenseexpenses:readThe expense belongs to the caller’s teamIn the tool’s code
approve_expenseexpenses:approveSame team; not the caller’s own expense; amount under the approval limitIn the tool’s code, before anything is written
Any tool not in this tableNoneDenyDefault

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:

TestExpected result
Missing, invalid, or expired token401; no protected tool runs
Valid token issued for another server401; no protected tool runs
Valid token without the required scope403 insufficient_scope; the tool does not run
Required scope present, but a record rule forbids the actionYour application’s refusal; nothing changes
Required scope and record rules satisfiedThe 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)

  1. List every tool on one MCP server you run or depend on.
  2. Fill in the permission map above for each one, including the last row: what happens to a tool nobody listed?
  3. With a valid token that lacks one permission, call the tool that needs it.
  4. 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/ ·

Terminology

Sources

  1. Authorization, MCP specification 2026-07-28 · Model Context Protocol
  2. Tools, MCP specification 2026-07-28 · Model Context Protocol
  3. Streamable HTTP transport, MCP specification 2026-07-28 · Model Context Protocol
  4. How to build an MCP app on the 2026-07-28 spec with WorkOS AuthKit · WorkOS
  5. Permit MCP Gateway overview · Permit.io
  6. MCP Auth vs Tool-Call Authorization After the 2026-07-28 Spec · Permit.io
  7. MCP (glossary)
  8. MCP explained for founders
  9. The Agentic Codebase

Further reading

Markdown for LLMs