Skip to content

feat(server): scope challenges for resources, prompts, completions (stacked on #1624)#2157

Draft
SamMorrowDrums wants to merge 4 commits into
modelcontextprotocol:mainfrom
SamMorrowDrums:stack-resources-prompts-completions
Draft

feat(server): scope challenges for resources, prompts, completions (stacked on #1624)#2157
SamMorrowDrums wants to merge 4 commits into
modelcontextprotocol:mainfrom
SamMorrowDrums:stack-resources-prompts-completions

Conversation

@SamMorrowDrums
Copy link
Copy Markdown

Stacked on top of #1624. Until that merges, the diff in this PR will include those commits; once #1624 merges this PR will rebase cleanly against main.

Summary

Extends the pre-execution scope challenge check from tools/call to the rest of the MCP operation surface: resources/read, prompts/get, and completion/complete.

Same architecture, same opinions on per-operation scopes, AND/OR semantics, and SEP-2350 alignment, just generalised across primitives.

What's new

Resources

registerResource() now accepts scopes in its config. For templated resources, scopes can also be a function that receives the concrete URI and matched template variables, so the required scope can depend on path parameters (for example, public vs private repositories).

// Static URI, static scope
server.registerResource(
  'config',
  'config://settings',
  { scopes: ['config:read'], mimeType: 'text/plain' },
  readCallback,
);

// Templated URI, dynamic per-request scope resolution
server.registerResource(
  'repo',
  new ResourceTemplate('github://{owner}/{repo}', { list: undefined }),
  { scopes: (_uri, vars) => vars.owner === 'private-org' ? ['repo'] : ['public_repo'] },
  readCallback,
);

Prompts

registerPrompt() accepts scopes in its config. Exact-name lookup like tools.

server.registerPrompt(
  'summarise_repo',
  { description: 'Summarise a repo', scopes: ['repo:read'], argsSchema: ... },
  handler,
);

Completions

Completions get an explicit, separate scope domain. No inheritance from the referenced prompt or resource by design: search and read are typically distinct capabilities (repo:list is not implied by repo:read). If you genuinely want them identical, pass the same scopes array to both registerPrompt and setCompletionScopes.

server.setCompletionScopes(
  { type: 'ref/prompt', name: 'summarise_repo' },
  'repository',   // or '*' for any argument of this ref
  ['repo:list'],
);

Central overrides

For decoupled or centralised scope configuration: setPromptScopes(name, scopes), setResourceScopes(uriOrTemplateName, scopes), setCompletionScopes(ref, arg, scopes). All take precedence over registration-time scopes.

Architecture

  • ScopeResolver widens from (toolName) => ToolScopeConfig to (request: JSONRPCRequest) => ScopeResolution | Promise<...>. Breaking for any alpha consumer of setScopeResolver; no external consumers yet.
  • New ScopeResolution = { operationName: string; scopes: ToolScopeConfig }. The operationName (e.g. tool:get_repo, resource:github://octo/hello, prompt:summarise, completion:prompt:summarise/repository) is used in the default 403 error description and is passed to buildErrorDescription.
  • _checkScopeChallenge becomes async to support async resource resolvers.
  • McpServer.connect() auto-wires an operation-aware router that dispatches on JSON-RPC method.

What's NOT in scope

  • */list scope filtering (SEP-1881). When tools, resources, or prompts declare scopes, tools/list, resources/list, prompts/list, and resources/templates/list could automatically filter their results to items the current token can satisfy. Intentionally out of scope for this PR but worth a follow-up.
  • Mid-handler scope challenges. Same constraint as feat(server): OAuth scope challenge support (step-up auth) #1624: HTTP status codes are committed before handlers run.

Testing

14 new behaviour tests in scopeChallengePrimitives.test.ts covering:

  • Static and templated resources: 403, pass, no-scopes passthrough, template URI matching, dynamic per-request resolution.
  • Prompts: 403, pass, no-scopes passthrough.
  • Completions: 403, no-inheritance contract, '*' argument wildcard.
  • Override registries: setResourceScopes, setPromptScopes.
  • Mixed JSON-RPC batch with a prompt and a resource read; the first failing operation surfaces a 403.

The 17 tool tests in scopeChallenge.test.ts continue to pass unchanged. All 96 tests in @modelcontextprotocol/server pass; typecheck and lint clean.

Notes for review

  • Completion's "no inheritance" was the considered choice; it matches typical OAuth scope shapes where listing capabilities are distinct from reading. Inheritance can be added later as an opt-in ({ inherit: true } flag) if real-world usage shows it's wanted.
  • accepted (OR escape hatch) extends to all primitives because it lives on ToolScopeConfig. Dynamic resource resolvers can return ToolScopeConfig directly to use accepted per-URI.

SamMorrowDrums and others added 4 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>
Builds on modelcontextprotocol#1624 to extend the pre-execution scope challenge check from
`tools/call` to the rest of the MCP operation surface:

- `resources/read`: static URIs match exactly; templated URIs match the
  template. `registerResource()` accepts `scopes` (string[], ToolScopeConfig,
  or a dynamic resolver `(uri, variables) => scopes | Promise<scopes>` for
  cases where the required scope depends on path parameters, e.g. public vs
  private repositories).
- `prompts/get`: `registerPrompt()` accepts `scopes`. Exact name lookup.
- `completion/complete`: explicit, separate scope domain via
  `setCompletionScopes(ref, argumentName, scopes)`. No inheritance from the
  referenced prompt or resource (search and read are typically distinct
  capabilities, e.g. `repo:list` vs `repo:read`). Pass `'*'` as argumentName
  to apply scopes to every argument of a reference.

Architecture:

- Widen `ScopeResolver` from `(toolName) => ToolScopeConfig` to
  `(request: JSONRPCRequest) => ScopeResolution | Promise<...>`.
- New `ScopeResolution` carries the resolved scope config and an
  `operationName` label used in 403 error descriptions (e.g.
  `tool:get_repo`, `resource:github://octo/hello`).
- `_checkScopeChallenge` becomes async to support async resource resolvers.
- `McpServer.connect()` auto-wires a router that dispatches on JSON-RPC
  method.
- New public API: `setPromptScopes`, `setResourceScopes`,
  `setCompletionScopes`, and matching `get*Scopes` accessors. Public type
  exports: `ResourceScopeConfig`, `ResourceScopeFn`, `ScopeResolution`.

Tests: 14 new behavior tests covering static and templated resources,
dynamic per-request resolution, prompts, completions (including the
no-inheritance contract and `'*'` wildcard), override registries, and mixed
JSON-RPC batches. All 96 tests in @modelcontextprotocol/server pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 26, 2026

🦋 Changeset detected

Latest commit: 3bcf533

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

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.

1 participant