Skip to content

feat(server): OAuth scope challenge support (step-up auth)#1624

Open
SamMorrowDrums wants to merge 3 commits into
modelcontextprotocol:mainfrom
SamMorrowDrums:scope-challenge-server-sdk
Open

feat(server): OAuth scope challenge support (step-up auth)#1624
SamMorrowDrums wants to merge 3 commits into
modelcontextprotocol:mainfrom
SamMorrowDrums:scope-challenge-server-sdk

Conversation

@SamMorrowDrums
Copy link
Copy Markdown

@SamMorrowDrums SamMorrowDrums commented Mar 4, 2026

Summary

Server-side OAuth scope challenge (step-up auth) support for the Streamable HTTP transport, aligned with merged SEP-2350 and RFC 6750 Section 3.1.

Servers declare required OAuth scopes per tool. The transport checks the client's token scopes (read from authInfo.scopes) before the tool runs and, when insufficient, returns HTTP 403 with a WWW-Authenticate: Bearer error="insufficient_scope", ... header, triggering the client's step-up authorization flow.

Companion: client-side scope accumulation lands in #1657.

Relates to #1151.

Design

Scope challenges are enforced at the transport layer before the SSE stream opens. HTTP status codes are committed before tool handlers execute, so this is the only viable interception point.

Developer API

const server = new McpServer({ name: "my-server", version: "1.0.0" });

// Option 1: Co-located with tool registration (simple AND of required).
server.registerTool("get_repo", {
  description: "Get repository details",
  inputSchema: z.object({ repo: z.string() }),
  scopes: ["repo:read"],
}, handler);

// Option 2: Scope hierarchy via `accepted` (OR escape hatch).
server.registerTool("get_repo", {
  description: "Get repository details",
  inputSchema: z.object({ repo: z.string() }),
  scopes: { required: ["repo:read"], accepted: ["repo:read", "repo"] },
}, handler);

// Option 3: Decoupled, e.g. from a central config map.
server.setToolScopes("get_repo", ["repo:read"]);
const transport = new WebStandardStreamableHTTPServerTransport({
  sessionIdGenerator: () => randomUUID(),
  scopeChallenge: {
    resourceMetadataUrl: "https://auth.example.com/.well-known/oauth-protected-resource",
    // Optional: defend against non-accumulating clients by unioning the
    // token's active scopes into the WWW-Authenticate `scope` value.
    // Default is `false` to follow SEP-2350 per-operation semantics.
    // includeGrantedScopes: true,
  },
});

await server.connect(transport); // auto-wires scope resolver

Key decisions

  • Per-operation required by default. The 403 scope value advertises only the scopes needed for the current operation, matching SEP-2350. Client-side accumulation handles the union.
  • includeGrantedScopes opt-in. Servers that need to defend against clients which still replace scopes on every challenge can set this to true to get the previous additive union behaviour.
  • required is AND, accepted is OR. required means every scope must be present in the token. accepted, when provided, flips satisfaction to "any of these scopes satisfies", supporting hierarchies like repo implying repo:read. accepted does not influence the 403 challenge advertisement, only the gate.
  • Implementer owns scope determination. The SDK reads authInfo.scopes as populated by the implementer's auth middleware. It does not determine which scopes are active.
  • HTTP only. Stdio and other non-HTTP transports ignore scope challenges by design.
  • No handler changes. Pre-execution check only. Mid-handler scope challenges are discussed in the proposal doc as future work.
  • tools/call only for now. Resources, prompts, and completions are mechanically equivalent and tracked as a stacked follow-up PR.
  • Quoted-string values are escaped per RFC 7235 so a " or \ in a scope or error description cannot break the header.

What is included

File What
packages/server/src/server/streamableHttp.ts ScopeChallengeConfig, ScopeResolver, ScopeAware, isScopeAware, setScopeResolver(), _checkScopeChallenge(), quoteAuthParam()
packages/server/src/server/mcp.ts ToolScopeConfig, scopes on registerTool(), setToolScopes(), getToolScopes(), typed auto-wiring on connect()
packages/server/src/index.ts Public re-exports for the new types and isScopeAware
packages/middleware/node/src/streamableHttp.ts setScopeResolver() delegation
packages/server/test/server/scopeChallenge.test.ts 17 behaviour-focused tests
docs/proposals/scope-challenge-server-sdk.md Proposal updated for SEP-2350 alignment

What is not included

  • No protocol-layer changes, no JSON-RPC handling changes, no stdio transport changes, no client SDK changes.
  • No mid-handler scope challenges. Discussed in the proposal doc as future work.
  • No spec changes. SEP-2350 already merged.
  • No changes to the Tool schema. Scope metadata is server-side only.
  • Resources, prompts, and completions step-up is being prepared as a stacked PR.

Testing

  • 17 new behaviour tests for the scope challenge flow.
  • 82 total tests in @modelcontextprotocol/server pass.
  • Typecheck clean across the workspace.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 4, 2026

🦋 Changeset detected

Latest commit: e52a2cc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@modelcontextprotocol/server Minor
@modelcontextprotocol/node Major
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 4, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1624

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1624

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1624

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1624

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1624

commit: ae1db55

@BossChaos

This comment was marked as abuse.

@localden
Copy link
Copy Markdown
Contributor

Cross-checking this against SEP-2350 which landed after this PR was opened - a few things shifted:

  1. The "additive scoping" at streamableHttp.ts:422 (union of activeScopes and required) was the old recommended approach. SEP-2350 moved accumulation to the client side - servers now emit scopes for the current operation only per RFC 6750 section 3.1, and the spec example was changed from scope="files:read files:write user:profile" to just scope="files:write". The current behavior is still allowed (server has flexibility), but it's no longer the recommended default, and it adds a dependency on authInfo.scopes being a complete enumeration of the token's grants, which not every introspection setup gives you. Can we flip the default to toolScopes.required only and make additive an opt-in (includeGrantedScopes: true or similar) for servers that want to defend against non-accumulating clients? fix: accumulate OAuth scopes on 401/403 instead of overwriting #1657 is landing the client-side accumulation so the belt-and-suspenders shouldn't be needed by default.

  2. acceptedScopes.some(s => activeScopes.includes(s)) means required: ['a', 'b'] is satisfied by either a or b. I'd read required as "needs all of these." If ANY-of is intentional for the hierarchy case, can we either rename to make that obvious or document it loudly in ToolScopeConfig? Otherwise every() with accepted handling the OR.

  3. The WWW-Authenticate value string-interpolates required and errorDescription without escaping. A scope or description with a " or , will break the header. Probably worth a small quoting helper given buildErrorDescription is developer-supplied.

  4. Minor - tools/call only for now is fine, but the SEP talks about "operations" generically, so worth a note in the proposal doc that resources/prompts are follow-up.

SamMorrowDrums and others added 3 commits May 26, 2026 10:20
Implement server-side scope challenge handling per MCP spec §10.1.
This enables servers to declare required OAuth scopes per tool and
automatically return HTTP 403 with WWW-Authenticate headers when
a client's token lacks sufficient scopes.

Key additions:

- ToolScopeConfig type for declaring required/accepted scopes per tool
- ScopeChallengeConfig on StreamableHTTP transport options
- Pre-execution scope check in transport layer (before SSE stream opens)
- McpServer.registerTool() accepts scopes option (string[] or config)
- McpServer.setToolScopes() for decoupled/centralized scope declaration
- Auto-wiring of scope resolver in McpServer.connect()
- NodeStreamableHTTPServerTransport delegates setScopeResolver()
- Additive scoping: challenges include union of existing + required scopes
- 17 tests covering scope checks, overrides, batches, and auto-wiring
- Proposal document for SDK devs and Tool Scopes Working Group

Scope challenges are HTTP-only (ignored for stdio), operate at the
transport layer before handlers execute, and follow the additive
scoping pattern established by github/github-mcp-server.

Relates to modelcontextprotocol#1151

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Apply review feedback from @localden on PR modelcontextprotocol#1624:

- Flip the WWW-Authenticate `scope` value to advertise only the
  per-operation `required` scopes by default, per RFC 6750 Section 3.1
  and SEP-2350. Add an opt-in `scopeChallenge.includeGrantedScopes`
  flag that restores the additive union behaviour for servers that
  need to defend against non-accumulating clients.

- Change `ToolScopeConfig.required` to AND semantics (every scope
  must be present in the token). `accepted` is now the explicit
  OR/hierarchy escape hatch.

- Escape `"` and `\` in all WWW-Authenticate quoted-string
  auth-param values per RFC 7235.

- Replace the duck-typed transport check in `McpServer.connect`
  with a typed `ScopeAware` interface and `isScopeAware` guard.
  Export `ScopeAware`, `ScopeResolver`, `ScopeChallengeConfig`,
  `ToolScopeConfig`, and `isScopeAware` from
  `@modelcontextprotocol/server`.

- Tests rewritten to focus on public-surface behaviour. 17 tests
  covering 403 emission, AND-required, OR-accepted, the
  `includeGrantedScopes` opt-in, header quoting, batch handling,
  setToolScopes override, custom error description, and
  auto-wiring.

- Proposal doc updated to reflect SEP-2350 alignment and call out
  resources/prompts/completions step-up as follow-up work.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@SamMorrowDrums SamMorrowDrums force-pushed the scope-challenge-server-sdk branch from 012707a to e52a2cc Compare May 26, 2026 10:59
@SamMorrowDrums SamMorrowDrums marked this pull request as ready for review May 26, 2026 11:00
@SamMorrowDrums SamMorrowDrums requested a review from a team as a code owner May 26, 2026 11:00
@SamMorrowDrums
Copy link
Copy Markdown
Author

SamMorrowDrums commented May 26, 2026

Thanks for the review @localden, all four points addressed and rebased on latest main.

1. Per-operation scope value by default, additive opt-in.
The WWW-Authenticate scope value now advertises only the per-operation required scopes, matching SEP-2350 and RFC 6750 Section 3.1. Servers that still need the additive union (defensive against non-accumulating clients, GitHub MCP server style will continue to do this until at least VS Code does the union) can opt in with scopeChallenge.includeGrantedScopes: true. The proposal doc's old "additive scoping is correct" position has been retracted and points at the merged SEP and at #1657 for client-side accumulation.

2. required is AND, accepted is explicitly OR.
The satisfaction check is now:

  • If accepted is provided: some(accepted) in active satisfies (hierarchy / superset escape hatch).
  • Otherwise: every(required) in active (strict AND).

I will note that there are still some situations that come unstuck here where you have two required scopes, and one of them has a hierarchy. It is possible you could end up not issuing a challenge when needed in that OR case (because you don't know which scope(s) contain the other). I think perhaps there always needs to be an option for custom should issue challenge logic; the challenge itself should always be correct, it is just the question of when to issue it that is complicated.

I deliberately kept accepted from influencing the 403 challenge advertisement; the server still advertises only required. The rationale is that accepted is server-side hierarchy reasoning and the client should be steered toward the minimum scope, not the broader form. Does that align with your thinking?

3. Quoted-string escaping. Added a quoteAuthParam helper that escapes \ and " per RFC 7235 and applied it to scope, resource_metadata, and error_description.

4. Resources / prompts. Noted in the proposal doc as follow-up. I can add a stacked PR for this (also covering completions, since those can require scopes too).

One open question: re-auth

I researched how every SDK handles the related 401 case (full re-auth, no refresh token) while a client has accumulated scopes from prior step-ups. The picture is:

SEP-2350 and the draft spec are silent on this. The Python PR #2676 authored by @dogacancolak pins the "drop on 401" invariant explicitly as the natural down-scoping moment. I think the spec should make this normative either way to stop the next round of cross-SDK drift. Does that need a PR or SEP clarification itself?

Marking this PR ready for review on the back of the rebase and the fixes above.

@SamMorrowDrums
Copy link
Copy Markdown
Author

Also @localden I had a crack at the other primitives: #2157

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants