Per-Tool OAuth Scopes for MCP, Derived from Your Schema

Ahmet Soormally
TL;DR
AI agents should not be given unrestricted access. They need scoped permissions based on what each MCP tool actually touches. The key point is that this already exists in your federated schema through @requiresScopes. You do not need a separate auth layer. The router should enforce these rules automatically for agents, the same way it does for any other client. If access is insufficient, the response should clearly indicate which scopes are required so the client can request them.
When we first shipped the Cosmo Router MCP server , we were focused on getting the basics right — exposing GraphQL operations as MCP tools, handling sessions, making it work with Claude, Cursor, and Windsurf. Authorization was simple: forward the Authorization header to the GraphQL endpoint and let the existing router auth handle it.
Then we watched what happened in practice. Every agent that connected got access to every tool. A read-only analytics assistant and an admin agent shared the same permission surface. There was no way to say "get_employees needs employees:read but update_employee_mood needs employees:write" at the MCP layer.
That's how agent integrations quietly become over-privileged. The safe option is to mint a broad token and move on. The result is predictable: every tool is available, least privilege disappears, and your schema-level security model stops at the MCP boundary. The @requiresScopes directives your schema already declares — the ones your router already enforces for human consumers — were invisible to the MCP server.
The usual answer would be to build another BFF, this time for agents. It starts small. A couple of tools, a little scope mapping, maybe some middleware. Six months later, it's another policy surface, another deployment, another thing that can drift from the schema, and another place security has to audit.
A new consumer type should not require a new authorization system. The graph already represents the business. It already has governance, observability, tracing, performance, and field-level security built in.
The authorization requirements for every operation are already declared in the federated schema:
The router already evaluates these rules for every GraphQL request from human consumers. The schema already knows the answer. The MCP server just wasn't reading it.
This matters because MCP tool authorization isn't an endpoint-level problem — it's a field-level problem. A single MCP tool maps to a GraphQL operation that can touch dozens of fields across multiple subgraphs, each with different authorization requirements. A tool that queries both topSecretFacts and employee needs a combined scope requirement derived from both fields. A tool that only queries topSecretFacts needs less. Per-endpoint authorization (one flat scope per tool) can't express this — you'd either over-privilege or under-privilege every tool. Per-field authorization via @requiresScopes means the scope requirements are computed from exactly which fields the operation touches.
Cosmo Router now enforces per-tool OAuth scopes for MCP servers by deriving scope requirements directly from @requiresScopes in your federated schema. When scopes are missing, it doesn't just reject the request — it tells the client exactly what to ask for. An actionable WWW-Authenticate challenge, computed from the schema, so the agent can step up authorization on the same session without reconnecting and without a human in the loop.
In our previous post , we described how our first implementation hit an infinite loop in the MCP TypeScript SDK, uncovered a tension between the MCP spec and RFC 6750, and contributed fixes to both. This post is the delivery of the capability we outlined there.
- Derives per-tool scopes from the schema — the MCP server parses each operation at startup, identifies which fields it touches, and computes combined scope requirements from
@requiresScopesdirectives. No additional config. - Returns actionable scope challenges — when a token lacks required scopes, the router returns a
WWW-Authenticateheader with the exact scopes to request, picking the cheapest path through OR-of-AND requirements. - Enables MCP step-up authorization — agents escalate permissions on the same session without reconnecting. Each HTTP POST carries its own token.
- Applies authorization at every layer of the MCP flow — connection, method, built-in tools, per-tool schema-derived scopes, and runtime checks for arbitrary GraphQL execution.
- Integrates with any OAuth provider — JWKS-based JWT validation works with Keycloak, Auth0, Okta, or any standards-compliant MCP authorization server.
Reading @requiresScopes and blocking requests was the easy part. The more interesting problem was what happens after the block.
The standard pattern for MCP OAuth authorization failures is a 403 with no actionable guidance. The client gets rejected and is left to figure out what went wrong. For a human developer, that's a solvable annoyance — read the error, check the docs, request a new token. For an AI agent running autonomously, it's a dead end. The agent can't proceed, can't self-correct, and can't escalate.
This is where least privilege usually falls apart. Rejecting the call is easy. The hard part is telling the client exactly which scopes to request so it can obtain a new token and retry, automatically, without a human in the loop.
For simple, flat scope requirements, this is trivial. But @requiresScopes uses OR-of-AND semantics — and when an operation touches multiple protected fields, the requirements combine via Cartesian product. The challenge response needs to pick the right path through a potentially complex set of alternatives.
Consider a tool backed by an operation that touches two fields:
The combined requirement for a tool that queries both fields is the Cartesian product:
| Option | Required scopes |
|---|---|
| 1 | read:fact AND read:employee AND read:private |
| 2 | read:fact AND read:all |
| 3 | read:all AND read:employee AND read:private |
| 4 | read:all |
Any one of these four AND-groups satisfies the requirement. But which one should the WWW-Authenticate challenge return?
The router doesn't guess. It evaluates which AND-group is cheapest for the client based on what the token already has.
If the client's token carries read:employee and read:private:
| AND-group | Missing scopes | Count |
|---|---|---|
read:fact, read:employee, read:private | read:fact | 1 |
read:fact, read:all | read:fact, read:all | 2 |
read:all, read:employee, read:private | read:all | 1 |
read:all | read:all | 1 |
Groups 1, 3, and 4 are tied at one missing scope. The router picks the first match and returns the complete AND-group in the challenge:
The client now knows exactly what to request from the MCP authorization server. No guessing, no over-requesting, no under-requesting.
For pre-defined operations, the scope requirements are pre-computed at startup and cached per tool. The runtime cost is comparing the token's scopes against the pre-computed AND-groups — a handful of string comparisons.
This is the difference between authorization that blocks and authorization that enables. A router that supports @requiresScopes can reject a request when scopes are missing. A router that dynamically computes scope challenges can tell the client exactly how to fix the problem — turning a dead end into an MCP step-up authorization flow that resolves autonomously.
MCP gateway authorization isn't a single gate, and treating it like one is how over-privileging happens. The implementation enforces scopes at five additive levels. Each one is a gate, and a request must pass all applicable levels:
Levels 1–3 are configured by the platform team. They define org-wide baselines:
Levels 4 and 5 come from the schema itself. Schema owners declare @requiresScopes on fields, and the MCP server picks it up automatically. No coordination meeting. No separate config to keep in sync. The platform team controls the protocol-level gates. The schema owners control the data-level gates. Both are enforced by the same router, in the same request path, with the same observability.
The Streamable HTTP transport — where each JSON-RPC request is a separate HTTP POST — makes token rotation natural. Each POST carries its own Authorization header. The session persists via the Mcp-Session-Id header.
No reconnection. No session loss. The agent progressively acquires only the permissions it needs, when it needs them. The Mcp-Session-Id stays constant across all token changes — the session is decoupled from the credential.
This is where the story gets interesting. When we first tested per-tool scope challenges with the official MCP TypeScript SDK, we watched the browser open for re-authorization. Then open again. And again. Infinite loop.
The SDK's streamableHttp.ts overwrites the scope variable on each 403 challenge instead of merging it with existing scopes. Gaining employees:write loses employees:read. The next request fails, triggering re-authorization that loses employees:write. Back to square one.
We spent time tracing through the SDK code and the MCP spec to understand why. The root cause is a tension in the specification itself. 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. These pull in different directions, and the SDK was implementing one interpretation while our server followed the other.
Our default behavior is what we believe is correct: the server returns only the scopes the operation needs, consistent with RFC 6750. For clients that replace rather than accumulate scopes, we added a single config override to bridge the gap:
When enabled, the server unions the operation's required scopes with the scopes already on the client's token. It's not elegant, but it exists for one reason: to interoperate with clients that don't accumulate scopes yet. We filed issue #1582 against the TypeScript SDK, and there's already a PR that adds client-side scope merging . We also opened an issue against the spec itself . When the ecosystem catches up, users simply remove the override.
There's a simpler path for well-behaved clients that don't want to deal with step-up at all. When OAuth is enabled, the router exposes a public metadata endpoint at /.well-known/oauth-protected-resource/mcp per RFC 9728 . Clients discover the authorization server and all supported scopes upfront, without manual configuration:
The scopes_supported field is computed automatically as the union of all configured static scopes and all scopes extracted from @requiresScopes directives. Well-behaved clients request all supported scopes upfront during the initial authorization — the authorization server decides which to grant. MCP step-up authorization challenges serve as a fallback when the client doesn't have everything upfront.
We've seen what happens when teams build a separate BFF for agents. It starts small — a couple of tools, a little scope mapping, maybe some middleware. Then the problems arrive:
- Auth policy drifts from schema policy. The BFF's scope config is a manual copy of what the schema already declares. They diverge silently. Nobody notices until an audit.
- Platform teams duplicate governance. A new authorization layer means new audit surface, new monitoring, and new incident response playbooks. Another config file is not a security model.
- Agents become a separate security domain. Different rules for agents than for human consumers, maintained by different teams, validated by different processes. If your schema already declares the rules, copying them into another layer is not architecture. It's drift waiting to happen.
- Every new consumer type creates another control surface. After agents, it's workflow engines, partner integrations, internal tools — each with its own BFF.
The router already enforces @requiresScopes for GraphQL consumers. Adding MCP authorization to the same router means agents get the same security model — one policy, one enforcement point, one thing to monitor.
A partner integration team connects their AI assistant to your API for customer support. Their OAuth client is configured with mcp:connect, mcp:tools:read, mcp:tools:execute, and orders:read. Your schema has @requiresScopes(scopes: [["orders:read"]]) on order query fields and @requiresScopes(scopes: [["orders:write"]]) on mutation fields. The partner's agent can call get_order_status freely. If it tries to call cancel_order, it gets a 403 with scope="orders:write" — a scope the partner's OAuth client isn't authorized to obtain. The router enforces the boundary.
Your internal AI assistant needs broad read access but should only acquire write scopes when a user explicitly approves a destructive action. The agent starts with read scopes and reads data freely. When a user says "delete that deployment," the agent calls delete_deployment, gets a 403 with scope="deployments:delete", surfaces this to the user, triggers a consent flow via the authorization server, obtains a scoped-up token, and retries — all on the same session.
The platform team sets levels 1–3 once: mcp:connect to connect, mcp:tools:read to discover, mcp:tools:execute to invoke. Individual service teams declare @requiresScopes on their fields as they already do. When a new team onboards, the MCP server picks up their scope requirements automatically on the next config reload. No MCP-specific authorization config to maintain.
We started this work because we watched agents connect with broad tokens and call every tool indiscriminately. The authorization model we'd built for human consumers — field-level, scope-based, declared in the schema — wasn't reaching the MCP layer.
Now it does. Agents should not need god tokens just because the transport is new. Per-tool OAuth scopes for MCP close the loop — agents get least-privilege access, actionable scope challenges, and step-up authorization on the same session, all derived from the same @requiresScopes directives that govern every other consumer. No new BFF. No separate config. No drift.
- Search & Execute — tool discovery by intent, reducing token consumption
- Code-mode — natural language to GraphQL execution on the MCP server
- Advanced Prompt-to-Query — bridging the gap where LLMs can't model your business domain from a schema alone
- MCP Gateway Documentation — Full setup guide and configuration reference
- OAuth 2.1 Authorization — MCP scope enforcement, JWKS config, step-up authorization
@requiresScopesDirective — Declaring per-tool authorization in your schema- Automate GraphQL Federation Development with MCP — Use the Cosmo MCP server for schema management and development workflows
- GitHub: Cosmo — Open-source federated GraphQL platform
Frequently Asked Questions (FAQ)
Per-tool scope enforcement means each MCP tool (derived from a GraphQL operation) can require its own set of OAuth scopes. The Cosmo Router extracts these requirements from @requiresScopes directives in your federated schema and enforces them at the HTTP transport layer. AI agents only need the scopes for the specific tools they call, not a god token with every permission.
Schema-derived authorization for MCP means the router computes each tool's required OAuth scopes from the GraphQL fields the tool touches, using @requiresScopes directives as the source of truth. No separate authorization config is needed — the schema defines the rules, and the router enforces them for every consumer including AI agents.
At startup, the scope extractor parses each registered operation, identifies which schema fields it touches, and collects the @requiresScopes directives on those fields. The combined scope requirement is computed using OR-of-AND Cartesian product rules and cached per tool. This means zero runtime overhead for pre-defined operations.
The router returns a 403 Forbidden with a WWW-Authenticate header containing the exact scopes needed. For OR-of-AND requirements, the challenge algorithm evaluates each scope group, picks the one requiring the fewest additional scopes based on what the token already has, and returns it. The client obtains a new token and retries on the same session.
Each HTTP POST to the MCP server carries its own Authorization header. When a tool call fails with insufficient scopes, the client obtains a new token from the authorization server with additional scopes, then retries with the new token and the same Mcp-Session-Id. The session persists across token changes.
Scopes are enforced at five additive levels: (1) Initialize scopes on all requests, (2) Method scopes for tools/list and tools/call, (3) Built-in tool scopes for execute_graphql, get_schema, and get_operation_info, (4) Per-tool scopes from @requiresScopes on operation fields, and (5) Runtime scopes for execute_graphql arbitrary queries. Each level is a gate — a request must pass all applicable levels.
Yes. The MCP OAuth configuration uses standard JWKS-based JWT validation. You point it at your identity provider's JWKS endpoint, and the router validates tokens using the same infrastructure you already use for your GraphQL API.
A compatibility bridge for MCP client SDKs that overwrite scopes instead of accumulating them during step-up authorization. When enabled, the server includes the token's existing scopes alongside the required scopes in the 403 challenge, preventing an infinite loop. The default (false) is RFC 6750-compliant and more secure.
When OAuth is enabled, the router exposes a public metadata endpoint at /.well-known/oauth-protected-resource/mcp. It advertises the authorization server URL and all supported scopes (computed from config and @requiresScopes directives). MCP clients can request all scopes upfront during initial authorization, avoiding step-up challenges entirely.
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.

