feat(server): add request-time OAuth scope challenges (#1624)
Some checks failed
Conformance Tests / client-conformance (push) Has been cancelled
Conformance Tests / server-conformance (push) Has been cancelled
Deploy Docs / deploy-docs (push) Has been cancelled
Examples / examples (build + e2e) (push) Has been cancelled
/ build (push) Has been cancelled
/ test (20) (push) Has been cancelled
/ test (22) (push) Has been cancelled
/ test (24) (push) Has been cancelled
/ test-e2e (20) (push) Has been cancelled
/ test-e2e (22) (push) Has been cancelled
/ test-e2e (24) (push) Has been cancelled
/ test-runtimes (bun, 1.x) (push) Has been cancelled
/ test-runtimes (deno, v2.x) (push) Has been cancelled
Publish Any Commit / pkg-publish (push) Has been cancelled
Release / Version (push) Has been cancelled
Release / Publish (push) Has been cancelled

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Sam Morrow 2026-09-16 18:51:56 +02:00 • committed by GitHub
commit 6032170087
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,6 +1,6 @@
---
shape: how-to
description: 'Require a bearer token on a server you run: verification, protected-resource metadata, and per-tool scopes.'
description: 'Require a bearer token on a server you run: verification, protected-resource metadata, and per-operation scopes.'
---
# Require authorization
@ -21,7 +21,8 @@ import {
} from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer, requireScopes } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const mcpServerUrl = new URL('https://api.example.com/mcp');
const verifier: OAuthTokenVerifier = { verifyAccessToken };
@ -116,23 +117,49 @@ server.registerTool('whoami', { description: 'Report the authenticated caller' }
The per-request factory itself receives the same value as `ctx.authInfo`, so it can register a different tool set per caller before any handler runs.
:::
## Enforce per-tool scopes
## Enforce per-operation scopes
`requiredScopes` gates the whole endpoint. For a scope only some tools need, check inside the handler — the handler is the only place that knows which tool is executing.
`requiredScopes` gates the whole endpoint. For scope step-up on an individual tool call, resource read, or prompt retrieval, set `scopeChallenge` on its registration — no handler or transport configuration is needed. The callback receives the full parsed request and verified `authInfo`. Return `undefined` to continue, or return the exact, complete scope set to send `403 insufficient_scope` before invocation or SSE. Throwing or rejecting fails closed.
```ts source="../../examples/guides/serving/authorization.examples.ts#perToolScopes_handler"
server.registerTool('purge-notes', { description: 'Delete every note' }, async ctx => {
if (!ctx.http?.authInfo?.scopes.includes('notes:write')) {
return { content: [{ type: 'text', text: 'insufficient_scope: purge-notes requires notes:write' }], isError: true };
}
return { content: [{ type: 'text', text: 'All notes deleted' }] };
});
The challenge uses the same OAuth `insufficient_scope` JSON body and `WWW-Authenticate` formatter as `requireBearerAuth`'s own `403` answer. Its `resource_metadata` parameter comes from the verified `AuthInfo`: the gate stamps its configured `resourceMetadataUrl` onto the `AuthInfo` it returns, so the metadata URL is configured exactly once — on `requireBearerAuth`. Without a stamped value the parameter falls back to the well-known location for the token's RFC 8707 `resource` identifier, or is omitted.
Use `requireScopes` for a static exact all-of check. Use a callback when the required scope set depends on the request:
```ts source="../../examples/guides/serving/authorization.examples.ts#perOperationScopes_challenge"
server.registerTool('purge-notes', { scopeChallenge: requireScopes('notes:write') }, async () => ({
content: [{ type: 'text', text: 'All notes deleted' }]
}));
server.registerResource('private-notes', 'notes://private', { scopeChallenge: requireScopes('notes:read') }, async uri => ({
contents: [{ uri: uri.href, text: 'Private notes' }]
}));
server.registerPrompt('summarize-notes', { scopeChallenge: requireScopes('notes:read') }, async () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'Summarize my private notes' } }]
}));
server.registerTool(
'read-repository',
{
inputSchema: z.object({ visibility: z.enum(['public', 'private']) }),
scopeChallenge: ({ request, authInfo }) => {
const visibility = (request.params as { arguments?: { visibility?: unknown } }).arguments?.visibility;
if (visibility !== 'public' && visibility !== 'private') return;
const scopes = visibility === 'private' ? (['repo:read'] as const) : (['public_repo'] as const);
return scopes.every(scope => authInfo?.scopes.includes(scope))
? undefined
: { scopes, errorDescription: `${visibility} repository access is required` };
}
},
async ({ visibility }) => ({ content: [{ type: 'text', text: `Read ${visibility} repository` }] })
);
```
A caller holding only `mcp` gets an ordinary tool result with `isError: true`, so the model reads the refusal and moves on instead of losing the connection.
Scope interpretation belongs to your callback; the SDK does not infer hierarchies, alternatives, or missing scopes. Challenged primitives remain visible in their list operations.
::: info
Responding `403 insufficient_scope` at the HTTP layer instead triggers the client transport's automatic scope step-up (SEP-2350) — see [Authenticate a user with OAuth](../clients/oauth.md).
::: warning
The callback runs before the primitive's input schema is validated or transformed. Its `request` contains the JSON-parsed wire values, so dynamic authorization should validate or canonicalize any value whose schema changes its meaning before handler invocation. Scope names must follow the OAuth `scope-token` grammar; `errorDescription`, when provided, must follow RFC 6750's `error-description` grammar.
:::
## Recap
@ -141,5 +168,5 @@ Responding `403 insufficient_scope` at the HTTP layer instead triggers the clien
- `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens.
- Missing, invalid, or expired tokens get `401 invalid_token`; a token missing a `requiredScopes` entry gets `403 insufficient_scope`; both carry a `WWW-Authenticate: Bearer` challenge.
- `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata.
- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-tool scopes are a check inside the handler that returns `isError: true`.
- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-operation callbacks can trigger HTTP `403` scope step-up before invocation, advertising the metadata URL the gate stamped onto `AuthInfo`.
- The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`.