MCP Scope Step-Up Authorization: From Implementation to Spec Contribution

cover
Ahmet Soormally

Ahmet Soormally

min read

What we're building

Cosmo , our open-source federated GraphQL platform, already ships with an MCP server that lets you expose your graph and curated capabilities to LLMs and agents as MCP tools. You define named GraphQL operations like get_employees, update_employee_mood, or get_schema, and the MCP server exposes each one as a tool that any MCP-compatible AI client can call.

Authorization was already part of the story. But not every tool should require the same level of access. A read operation like get_employees is low risk. update_employee_mood mutates state. get_schema exposes your API's internal structure. These operations have different risk profiles, and they should have different authorization requirements. An MCP client shouldn't need a god token with every scope upfront - it should only obtain the scopes it actually needs, when it needs them.

So we implemented per-tool OAuth scope enforcement . In our configuration, each MCP operation declares the scopes it requires:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Authorization is enforced in layers. An AI agent first obtains mcp:connect to establish a connection, then mcp:tools:read to discover available tools. When it calls a specific tool, it needs mcp:tools:call plus the scopes for that tool: employees:read for get_employees, employees:write for update_employee_mood. Each layer narrows access further, and the agent only obtains what it needs at each step.

Graph-specific tool scopes can be configured statically as shown above, or derived dynamically from @requiresScopes directives in the schema.

The MCP spec has a name for this: scope step-up authorization. It's described in the authorization specification . The TypeScript SDK has code paths for it. The security best practices section actively recommends it.

We assumed this was a solved problem, but it isn't yet. What we found while implementing it is worth sharing, because it affects anyone building per-tool authorization on MCP.

The test

We used the official simpleOAuthClient.ts example from the MCP TypeScript SDK. Our server on localhost:5025/mcp, an OAuth authorization server on localhost:9099, per-tool scopes configured.

The server returns a minimal scope in the initial WWW-Authenticate 401 challenge:

1
2
3

The idea: client connects with just employees:read, calls get_employees successfully, then when it tries to call update_employee_mood, the server returns a 403 with scope="employees:write", the client re-authorizes with the additional scope, and so on.

What broke

After connecting successfully with employees:read, the agent called get_employees without issue. Then it tried to call update_employee_mood. The browser opened for re-authorization. Then it opened again. And again. Infinite loop.

The trace:

  1. Client calls update_employee_mood with a token carrying employees:read
  2. Server returns 403 Forbidden with scope="employees:write"
  3. SDK re-authorizes with scope=employees:write, overwriting the previous scope
  4. Client gets a new token with employees:write but without employees:read
  5. Next request needing employees:read fails → scope="employees:read" → overwrites employees:write
  6. Go to step 1

The client SDK's streamableHttp.ts:

1
2
3

An assignment instead of a union. Was the SDK wrong, or was it implementing the spec correctly?

What the spec says vs. what the RFC says

The MCP spec's Server Scope Management defines three strategies for what servers should include in a 403 scope parameter:

  • Minimum approach: the scopes for the operation, plus "any existing granted scopes as well, if they are required, to prevent clients from losing previously granted permissions"
  • Recommended approach: "existing relevant scopes and newly required scopes"
  • Extended approach: existing, new, and related scopes

All three expect the server to include the client's previously granted scopes.

RFC 6750 §3.1 says:

the resource server SHOULD include the 'scope' attribute with the value of the scope necessary to access the requested resource

"The requested resource." Not "the requested resource plus everything else the client might need in the future." There's a gap between what the MCP spec asks servers to do and what the underlying RFC defines.

We may be misreading the spec here. The likely intention is to simplify clients: if the server includes previously granted scopes in the challenge, the client can treat it as a complete scope set without tracking state. That makes sense as a design choice, but it needs to be documented explicitly. Without that clarity, the guidance reads as contradicting RFC 6750, the reference SDK doesn't handle it consistently, and clients end up requesting more scopes than they need for a given operation.

Here's why we think this matters:

It diverges from the RFC. Calling update_employee_mood needs employees:write. Including employees:read in the 403 challenge misrepresents the operation's requirements. The scope attribute stops describing what the resource needs and starts describing client session history.

It requires servers to track client state. To include "existing granted scopes," the server must inspect the client's current token. Stateless servers that validate tokens and report per-operation requirements can't easily do this.

It places responsibility at the wrong layer. The server knows what each operation requires. The client knows what it has accumulated. Scope accumulation across a session is inherently a client-side concern.

How we're thinking to solve it

In our PR , the default behavior is what we believe is correct: the server returns only the scopes needed for the requested operation, consistent with RFC 6750. When a client calls update_employee_mood without employees:write, the 403 challenge says scope="employees:write". Nothing more. The server describes what the operation needs. The client is responsible for accumulating scopes across its session.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

The reality is that current MCP reference SDK implementations, including the TypeScript SDK, replace scopes on each 403 challenge rather than accumulating them. So we added a single override:

1
2
3

When enabled, the server unions the operation's required scopes with the scopes already present on the client's token. It's not an elegant configuration option, but it exists for one reason: to interoperate with clients that don't accumulate scopes yet.

The idea is that when the spec and SDKs catch up (and we hope they will), users can simply remove this option. The default behavior is already the more secure one.

Two parts of the spec that pull in different directions

The MCP spec's own security best practices aligns with our default behavior (scope_challenge_include_token_scopes: false):

  • "Emit precise scope challenges; avoid returning the full catalog"
  • "Incremental elevation via targeted WWW-Authenticate scope="..." challenges"
  • "Minimal initial scope set containing only low-risk discovery/read operations"

This is precisely what our default does. But following this security guidance with a client that doesn't accumulate scopes produces the infinite loop we hit.

There's tension between two parts of the spec:

  • Security best practices: Be precise. Only return what this operation needs.
  • Server Scope Management: Also include existing granted scopes. The "Recommended approach" includes them explicitly.

And the spec doesn't yet give clients explicit guidance on scope accumulation:

  • The Scope Selection Strategy only covers the initial 401.
  • The Step-Up Authorization Flow doesn't say whether to merge or replace scopes.
  • The spec says clients "MUST treat the scopes provided in the challenge as authoritative", but authoritative for what? For what the operation needs? Or as the complete set to request?

These are the kinds of ambiguities that surface when you actually implement scope step-up end to end. The spec is still evolving, and we think clarifying this will help everyone building on it.

What we're contributing to the protocol

We've filed issue #1582 against the TypeScript SDK, and there's already a PR #1618 that adds client-side scope merging. The SDK fix helps, but without spec-level clarity, every new SDK implementation will need to figure this out independently.

We've also opened an issue against the spec itself , suggesting three clarifications:

1. Align Server Scope Management with RFC 6750 §3.1

We think the scope attribute in 403 responses should describe the scopes needed for the requested operation, consistent with RFC 6750. An update_employee_mood 403 should say scope="employees:write", full stop. The server shouldn't need to know or include what the client already has.

2. Add explicit client-side scope accumulation

The Step-Up Authorization Flow should specify that clients compute the union of their existing scope set and the challenge's scope set before re-authorizing. One sentence would prevent every SDK from independently rediscovering this problem.

3. Clarify "authoritative"

Clarify that "authoritative for satisfying the current request" means the scopes are required for this operation, not that they are the exclusive set the client should request.

Where we're headed: surfacing missing scopes from the schema

Cosmo already supports @requiresScopes on fields and types in the schema. When a token lacks the scopes needed for a field, the router knows exactly which scopes are missing.

The next step is connecting that to the scope challenge. When an MCP client calls an operation and the token doesn't satisfy the @requiresScopes directives on the fields it touches, the router should be able to surface those missing scopes in the 403 WWW-Authenticate challenge. The client can then re-authorize with precisely the scopes it needs, no more.

This closes the loop between schema-level authorization and MCP scope step-up. The scopes are already declared in the schema. The router already evaluates them. All that's left is returning them in the challenge so the client can act on them. First, we need the protocol to get scope step-up right.

Why this matters

As AI agents become the primary consumers of APIs, per-operation authorization becomes critical. You don't want an agent that starts a session with admin:* because it might need to delete something later. You want progressive, least-privilege escalation. The same model that's been standard in OAuth for years.

The MCP spec is in a unique position to get this right. It's the emerging standard for how AI agents interact with tools and APIs. The authorization model it defines will be implemented by every MCP SDK, every MCP server, and eventually every AI platform that supports tool calling.

Getting scope accumulation right (clarifying that servers describe operations and clients accumulate sessions) is a small spec change with outsized impact. It aligns MCP with established OAuth semantics, enables stateless resource servers, and makes progressive authorization work out of the box.

We're building the infrastructure for AI agents to interact with enterprise APIs at WunderGraph. Per-tool authorization isn't optional. It's what enterprises expect. The MCP spec and ecosystem are moving fast, and we want to contribute what we've learned so that scope step-up authorization works reliably for everyone building on it.


WunderGraph builds API infrastructure for AI. Our Cosmo platform turns federated GraphQL APIs into AI-ready MCP tools with per-operation authorization.

Ahmet Soormally

Ahmet Soormally

Principal Solutions Engineer at WunderGraph

Ahmet is a Principal Solutions Engineer at WunderGraph, helping teams adopt Cosmo. He leads technical evaluations, builds prototypes, and runs workshops to accelerate adoption, while improving SDKs, documentation, and onboarding to enhance the developer experience.