Skip to content

feat(server): add request-time OAuth scope challenges - #1624

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

feat(server): add request-time OAuth scope challenges#1624
SamMorrowDrums wants to merge 3 commits into
modelcontextprotocol:mainfrom
SamMorrowDrums:scope-challenge-server-sdk

Conversation

@SamMorrowDrums

@SamMorrowDrums SamMorrowDrums commented Mar 4, 2026

Copy link
Copy Markdown

Summary

  • Add a per-tool scopeChallenge callback that receives the full parsed JSON-RPC request and optional verified AuthInfo.
  • Add requireScopes(...scopes) for exact static all-of checks.
  • Serialize returned challenges as RFC 6750 403 insufficient_scope responses before tool invocation or SSE setup.
  • Apply the same preflight semantics to modern createMcpHandler requests and legacy Streamable HTTP batches.

API

type ScopeChallenge = {
  scopes: readonly [string, ...string[]];
  errorDescription?: string;
};

type ScopeChallengeHandler = (context: {
  request: JSONRPCRequest;
  authInfo?: AuthInfo;
}) => ScopeChallenge | undefined | Promise<ScopeChallenge | undefined>;

Tools register the callback as scopeChallenge. Returning undefined continues; returning a challenge sends its exact, complete scope set; throwing, rejecting, or returning an empty scope set fails closed. The SDK does not infer scope hierarchies, alternatives, unions, or missing scopes.

Validation

  • pnpm test:all
  • pnpm build:all
  • pnpm typecheck:all
  • pnpm docs:examples
  • pnpm docs:build
  • pnpm sync:snippets --check
  • ESLint and Prettier on every changed source, test, example, and changeset file

@changeset-bot

changeset-bot Bot commented Mar 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 77846c2

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

This PR includes changesets to release 10 packages
Name Type
@modelcontextprotocol/server Minor
@modelcontextprotocol/node Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/core Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/server-legacy Minor
@modelcontextprotocol/codemod Minor
@modelcontextprotocol/core-internal Patch

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

pkg-pr-new Bot commented Mar 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@1624

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@1624

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1624

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 77846c2

@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
SamMorrowDrums force-pushed the scope-challenge-server-sdk branch from 012707a to e52a2cc Compare May 26, 2026 10:59
SamMorrowDrums added a commit to SamMorrowDrums/typescript-sdk that referenced this pull request May 26, 2026
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 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

SamMorrowDrums commented May 26, 2026

Copy link
Copy Markdown
Author

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

SamMorrowDrums added a commit to SamMorrowDrums/typescript-sdk that referenced this pull request Aug 20, 2026
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 e52a2cc to 5f20c37 Compare August 20, 2026 16:12
Comment thread docs/migration/upgrade-to-v2.md Outdated
@SamMorrowDrums
SamMorrowDrums force-pushed the scope-challenge-server-sdk branch from 8f86350 to c898995 Compare August 20, 2026 23:59
@SamMorrowDrums SamMorrowDrums changed the title feat(server): OAuth scope challenge support (step-up auth) feat(server): add OAuth scope challenge policies Aug 20, 2026
@SamMorrowDrums
SamMorrowDrums force-pushed the scope-challenge-server-sdk branch from c898995 to aa2a3fe Compare August 21, 2026 00:29
@SamMorrowDrums SamMorrowDrums changed the title feat(server): add OAuth scope challenge policies feat(server): add per-tool OAuth scope challenges Aug 21, 2026
Let tools return exact OAuth scope challenges from request-aware callbacks before invocation or SSE setup.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@SamMorrowDrums
SamMorrowDrums force-pushed the scope-challenge-server-sdk branch from aa2a3fe to f724f40 Compare August 24, 2026 10:08
@SamMorrowDrums SamMorrowDrums changed the title feat(server): add per-tool OAuth scope challenges feat(server): add request-time OAuth scope challenges Aug 24, 2026
maxisbey and others added 2 commits August 25, 2026 14:23
…N-RPC batch length (modelcontextprotocol#2698)

Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
Resolve request body size limit conflicts while retaining scope challenge support.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@SamMorrowDrums

Copy link
Copy Markdown
Author

@maxisbey @felixweinberger how are we feeling about this latest design? I shipped effectively this to GitHub MCP server in production already, in Go to fix the final challenges of argument based OR scope challenges, and more complex ones like our workflow scope which can only be determined based on file paths.

github/github-mcp-server#3128

@panyam

panyam commented Aug 29, 2026

Copy link
Copy Markdown

Hey @SamMorrowDrums, I ran the SEP-2350 conformance scenario against this branch. Good news - rewrite was mostly hassle free. I had shared runs against Keycloak and Okta before, for both TS and an experimental Go SDK. Same 9 checks and all four combos still pass.

The scenarios I added check the wire and not hte dev API. So I found replacing the entire registration surface produced challenges that are identical byte by byte. Also watned to call out that the OR-hierarchy check also passed too. Now it is opt-in and behavioural (calling with a parent-scope token, gives 2xx), so expressing the hierarchy in a callback also ensure it exactly as a declarative accepted field did.

The SUT is also provider-neutral - one binary pointed at an issuer, discovering endpoints from .well-known/openid-configuration and reading scopes from scp, scope or scopes. Reason I had to do that was becuase Keycloak emits scope as a string and Okta emits scp as an array, and a verifier handling only one sees an empty scope set and challenges every call. This was another example of provider speicfic things to watch out for.

https://github.com/panyam/mcp-ts-sdk/tree/sut/verified-20260829/examples/scope-challenge

Few things I wanted to call out:

  1. On mid-call challenges being blocked by streaming. I think you mentioned this is unsolvable for SDKs that start streaming before the handler returns, which matches the C# report from the June maintainer meeting. Is it not an architecture question rather than an inherent one. My experimental SDK resolves the challenge in middleware before dispatch and sets SSE headers lazily, so a 403 plus WWW-Authenticate still reaches the wire in both the session and stateless modes (over time the stateful mode just goes away). These paths are exercised now. Happy to write it up properly if a portability argument would help the case.

  2. Re the -32600 in the challenge body createScopeChallengeResponse emits {"jsonrpc":"2.0","error":{"code":-32600,...}}. But JSON-RPC 2.0 reserves -32600 for "Invalid Request", whereas an insufficient-scope rejection is a well-formed request refused on authorization grounds. Is this deliberate or another code in the works?

  3. resolveScopeChallenge returns early on anything but tools/call and scopeChallenge is only on registerTool, so resources and prompts can't declare one yet. Was this intended? (or just for the first V)?

Happy to add fixtures for more providers. Descope, Entra and WorkOS are the open columns and thankfully several folks have offered to help.

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.

5 participants