Skip to content
22 changes: 22 additions & 0 deletions .changeset/scope-challenge-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/node': minor
---

Add request-time OAuth scope challenges for tools, resources, resource templates,
and prompts. Each primitive's `scopeChallenge` callback receives the parsed
request and verified authentication info, then either continues or returns the
exact scope set for an `insufficient_scope` response. `requireScopes` provides a
small helper for static all-of checks.

`createMcpHandler` and Streamable HTTP transports return HTTP 403 with an
`insufficient_scope` challenge before handler execution or SSE setup. The
preflight is active whenever a registered primitive carries a `scopeChallenge`
callback — there is no handler- or transport-level configuration. The
challenge's `WWW-Authenticate` header is built by the same formatter as the
bearer-auth 401/403 answers, and its `resource_metadata` parameter is derived
from the verified `AuthInfo`: `requireBearerAuth` / `verifyBearerToken` now
stamp their configured `resourceMetadataUrl` onto the `AuthInfo` they return
(new optional `AuthInfo.resourceMetadataUrl` field), with a fallback to the
well-known location for an HTTP(S) RFC 8707 `resource` identifier; the
parameter is omitted when neither is available.
1 change: 1 addition & 0 deletions docs/behavior-surface-pins.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ CI pass — that reopens the silent-drift hole the pin exists to close.
| Published package set, export maps, dual ESM/CJS topology | `packages/core-internal/test/packageTopologyPins.test.ts` |
| stdio environment-inheritance safelist | `packages/client/test/client/stdioEnvPins.test.ts` |
| 2025-11-25 wire method-registry membership, schema identity | `packages/core-internal/test/types/registryPins.test.ts` |
| OAuth scope challenge timing and serialization | `packages/server/test/server/scopeChallenge.test.ts` |

## Writing a new pin

Expand Down
57 changes: 42 additions & 15 deletions docs/serving/authorization.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 };
Expand Down Expand Up @@ -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
Expand All @@ -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`.
41 changes: 32 additions & 9 deletions examples/guides/serving/authorization.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,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 };
Expand Down Expand Up @@ -72,14 +73,36 @@ function buildServer(): McpServer {
});
//#endregion authInfo_handler

//#region 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' }] };
});
//#endregion perToolScopes_handler
//#region 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` }] })
);
//#endregion perOperationScopes_challenge

return server;
}
Expand Down
13 changes: 13 additions & 0 deletions packages/core-internal/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,19 @@ export interface AuthInfo {
*/
resource?: URL;

/**
* URL of the RFC 9728 Protected Resource Metadata document for the
* resource server that accepted this token.
*
* The bearer-auth helpers stamp their configured `resourceMetadataUrl`
* here when verification succeeds, so challenge responses built after
* authentication (for example per-operation `insufficient_scope` scope
* challenges) can advertise the same document as the authentication
* gate's own challenges without separate configuration. Verifiers may
* also populate it directly; a verifier-set value wins.
*/
resourceMetadataUrl?: string;

/**
* Additional data associated with the token.
* This field should be used for any additional data that needs to be attached to the auth info.
Expand Down
6 changes: 6 additions & 0 deletions packages/middleware/node/src/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
JSONRPCMessage,
MessageExtraInfo,
RequestId,
ScopeChallengeHandler,
Transport,
WebStandardStreamableHTTPServerTransportOptions
} from '@modelcontextprotocol/server';
Expand Down Expand Up @@ -169,6 +170,11 @@ export class NodeStreamableHTTPServerTransport implements Transport {
this._webStandardTransport.setSupportedProtocolVersions(versions);
}

/** Sets the scope challenge resolver used by the wrapped Web Standard transport. */
setScopeChallengeResolver(resolver: ScopeChallengeHandler): void {
this._webStandardTransport.setScopeChallengeResolver(resolver);
}

/**
* Handles an incoming HTTP request, whether `GET` or `POST`.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export { InMemoryServerEventBus } from './server/serverEventBus';
// StdioServerTransport and the serveStdio entry are exported from the './stdio' subpath — server stdio
// has only type-level Node imports (erased at compile time), but matching the client's `./stdio` subpath
// gives consumers a consistent shape across packages.
export type { ScopeChallenge, ScopeChallengeHandler } from './server/scopeChallenge';
export { requireScopes } from './server/scopeChallenge';
export type {
EventId,
EventStore,
Expand Down
21 changes: 21 additions & 0 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { createListenRouter, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter';
import { McpServer } from './mcp';
import type { PerRequestResponseMode } from './perRequestTransport';
import { DEFAULT_MAX_REQUEST_BODY_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody';
import { createScopeChallengeResponse, findScopeChallenge, scopeChallengeResourceMetadataUrl } from './scopeChallenge';
import type { Server } from './server';
import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server';
import type { ServerEventBus, ServerNotifier } from './serverEventBus';
Expand Down Expand Up @@ -831,6 +832,26 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
}
}

// Run scope preflight after Mcp-Param headers have been checked against
// the body. Active whenever the factory's instance registers a
// per-primitive scopeChallenge callback — no handler-level
// configuration exists: the challenge's resource_metadata parameter is
// derived from the verified AuthInfo (stamped by the bearer-auth gate,
// or the token's RFC 8707 resource identifier) and omitted otherwise.
if (route.messageKind === 'request' && product instanceof McpServer) {
try {
const challenge = await findScopeChallenge([route.message], authInfo, context => product.resolveScopeChallenge(context));
if (challenge !== undefined) {
void product.close().catch(reportError);
return createScopeChallengeResponse(challenge, scopeChallengeResourceMetadataUrl(authInfo));
}
} catch (error) {
void product.close().catch(reportError);
reportError(toError(error));
return internalServerErrorResponse(route.message.id);
}
}

// Era-write at instance binding, then modern-only handler installation —
// both before the instance is connected to the per-request transport.
setNegotiatedProtocolVersion(server, claimedRevision);
Expand Down
Loading
Loading