[State of Federation 2026Share your insights on GraphQL Federation.Take the Survey→](/content/state-of-graphql-federation/2026/index.html)

[Ahmet Soormally](/content/people/ahmet-soormally/index.html)

Principal Solutions Engineer at WunderGraph

March 3, 2026·9min read

## What we're building

[Cosmo](https://github.com/wundergraph/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](/content/blog/graphql-persisted-operations-llms-mcp/index.html). 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](https://github.com/wundergraph/cosmo/pull/2438). In our configuration, each MCP operation declares the scopes it requires:

```yaml
mcp:
  oauth:
    enabled: true
    authorization_server_url: "https://auth.example.com"
    scopes:
      # MCP protocol layer
      initialize: ["mcp:connect"]
      tools_list: ["mcp:tools:read"]
      tools_call: ["mcp:tools:call"]
      # Cosmo native tools
      get_schema: ["schema:read"]
      execute_graphql: ["graphql:execute"]
      # Graph-specific tools (customer operations)
      get_employees: ["employees:read"]
      update_employee_mood: ["employees:write"]
```

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`](https://cosmo-docs.wundergraph.com/federation/directives/requiresscopes#and-scopes) directives in the schema.

The MCP spec has a name for this: **scope step-up authorization**. It's described in the [authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow). The TypeScript SDK has code paths for it. The [security best practices](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#scope-minimization) 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:

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`:

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](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors) 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](https://datatracker.ietf.org/doc/html/rfc6750#section-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](https://github.com/wundergraph/cosmo/pull/2438), 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.

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:

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](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#scope-minimization) 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](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy) only covers the initial 401.
- The [Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#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](https://github.com/modelcontextprotocol/typescript-sdk/issues/1582) against the TypeScript SDK, and there's already a [PR #1618](https://github.com/modelcontextprotocol/typescript-sdk/pull/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](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2349), 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](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#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`](https://cosmo-docs.wundergraph.com/federation/directives/requiresscopes#and-scopes) 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.

## Frequently Asked Questions (FAQ)

**What is scope step-up authorization in MCP?**  
Scope step-up authorization allows an AI agent to start a session with minimal permissions and progressively request additional scopes as needed. For example, connecting with a baseline scope, discovering tools with a read scope, and escalating to write scopes only when calling a specific tool. This follows the principle of least privilege.

**Why does per-tool authorization cause an infinite loop in the MCP TypeScript SDK?**  
The SDK's streamableHttp.ts overwrites the scope variable instead of merging it with existing scopes. When the server returns a 403 with a new required scope, the SDK re-authorizes with only that scope, losing the previous one. The next request needing the old scope fails, triggering another re-authorization that loses the new scope, creating an infinite loop.

**How does the MCP spec contradict RFC 6750 on scope challenges?**  
RFC 6750 says the scope attribute in a 403 should describe the scopes needed for the requested resource. The MCP spec says servers should also include the client's previously granted scopes. This changes the semantic meaning of a well-established OAuth primitive, requires servers to track client state, and conflates per-operation requirements with session history.

**How does WunderGraph handle per-tool scope challenges?**  
By default, Cosmo's MCP server returns only the scopes the operation needs in 403 challenges, consistent with RFC 6750. For clients that replace rather than accumulate scopes (like current MCP reference SDK implementations), there's a single override: `scope_challenge_include_token_scopes`. When enabled, the server unions the operation's scopes with the client's existing scopes. When the spec and SDKs catch up, users can simply remove the override.
