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
13 changed files with 1310 additions and 104 deletions

View file

@ -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.

View file

@ -15,6 +15,7 @@ import type {
JSONRPCMessage,
MessageExtraInfo,
RequestId,
ScopeChallengeHandler,
Transport,
WebStandardStreamableHTTPServerTransportOptions
} from '@modelcontextprotocol/server';
@ -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`.
*

View file

@ -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,

View file

@ -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';
@ -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);

View file

@ -47,6 +47,8 @@ import {
import type * as z from 'zod/v4';
import { getCompleter, isCompletable } from './completable';
import type { ScopeChallengeHandler } from './scopeChallenge';
import { supportsScopeChallengeResolver } from './scopeChallenge';
import type { ServerOptions } from './server';
import { Server } from './server';
@ -146,6 +148,9 @@ export class McpServer {
* ```
*/
async connect(transport: Transport): Promise<void> {
if (supportsScopeChallengeResolver(transport)) {
transport.setScopeChallengeResolver(context => this.resolveScopeChallenge(context));
}
return await this.server.connect(transport);
}
@ -156,6 +161,53 @@ export class McpServer {
await this.server.close();
}
/** @internal */
resolveScopeChallenge: ScopeChallengeHandler = context => {
switch (context.request.method) {
case 'tools/call': {
const toolName = (context.request.params as { name?: unknown } | undefined)?.name;
if (typeof toolName !== 'string') return;
const tool = this._registeredTools[toolName];
if (tool === undefined || !tool.enabled) return;
return tool.scopeChallenge?.(context);
}
case 'resources/read': {
const resourceUri = (context.request.params as { uri?: unknown } | undefined)?.uri;
if (typeof resourceUri !== 'string') return;
let uri: URL;
try {
uri = new URL(resourceUri);
} catch {
return;
}
const resource = this._registeredResources[uri.toString()];
if (resource !== undefined) {
return resource.enabled ? resource.scopeChallenge?.(context) : undefined;
}
for (const template of Object.values(this._registeredResourceTemplates)) {
const variables = template.resourceTemplate.uriTemplate.match(uri.toString());
if (variables) {
return template.enabled ? template.scopeChallenge?.(context) : undefined;
}
}
return;
}
case 'prompts/get': {
const promptName = (context.request.params as { name?: unknown } | undefined)?.name;
if (typeof promptName !== 'string') return;
const prompt = this._registeredPrompts[promptName];
if (prompt === undefined || !prompt.enabled) return;
return prompt.scopeChallenge?.(context);
}
default: {
return;
}
}
};
private _toolHandlersInitialized = false;
private setToolRequestHandlers() {
@ -502,6 +554,12 @@ export class McpServer {
for (const template of Object.values(this._registeredResourceTemplates)) {
const variables = template.resourceTemplate.uriTemplate.match(uri.toString());
if (variables) {
if (!template.enabled) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Resource template ${template.resourceTemplate.uriTemplate} disabled`
);
}
return attachCacheHintFallback(await template.readCallback(uri, variables, ctx), template.cacheHint);
}
}
@ -588,31 +646,27 @@ export class McpServer {
registerResource(
name: string,
uriOrTemplate: string,
config: ResourceMetadata & { cacheHint?: CacheHint },
config: ResourceMetadata & { cacheHint?: CacheHint; scopeChallenge?: ScopeChallengeHandler },
readCallback: ReadResourceCallback
): RegisteredResource;
registerResource(
name: string,
uriOrTemplate: ResourceTemplate,
config: ResourceMetadata & { cacheHint?: CacheHint },
config: ResourceMetadata & { cacheHint?: CacheHint; scopeChallenge?: ScopeChallengeHandler },
readCallback: ReadResourceTemplateCallback
): RegisteredResourceTemplate;
registerResource(
name: string,
uriOrTemplate: string | ResourceTemplate,
config: ResourceMetadata & { cacheHint?: CacheHint },
config: ResourceMetadata & { cacheHint?: CacheHint; scopeChallenge?: ScopeChallengeHandler },
readCallback: ReadResourceCallback | ReadResourceTemplateCallback
): RegisteredResource | RegisteredResourceTemplate {
// The cache hint configures the encode-time cache fields of this
// resource's `resources/read` results (2026-07-28); it is not resource
// metadata and never appears on `resources/list` entries.
const cacheHint = config.cacheHint;
let metadata: ResourceMetadata = config;
// These options configure request handling and are not advertised as
// resource metadata by `resources/list`.
const { cacheHint, scopeChallenge, ...resourceMetadata } = config;
const metadata: ResourceMetadata = resourceMetadata;
if (cacheHint !== undefined) {
assertValidCacheHint(cacheHint, `resource ${name}`);
const rest = { ...config };
delete rest.cacheHint;
metadata = rest;
}
if (typeof uriOrTemplate === 'string') {
@ -625,6 +679,7 @@ export class McpServer {
(config as BaseMetadata).title,
uriOrTemplate,
metadata,
scopeChallenge,
readCallback as ReadResourceCallback
);
if (cacheHint !== undefined) {
@ -644,6 +699,7 @@ export class McpServer {
(config as BaseMetadata).title,
uriOrTemplate,
metadata,
scopeChallenge,
readCallback as ReadResourceTemplateCallback
);
if (cacheHint !== undefined) {
@ -661,6 +717,7 @@ export class McpServer {
title: string | undefined,
uri: string,
metadata: ResourceMetadata | undefined,
scopeChallenge: ScopeChallengeHandler | undefined,
readCallback: ReadResourceCallback
): RegisteredResource {
const registeredResource: RegisteredResource = {
@ -668,6 +725,7 @@ export class McpServer {
title,
metadata,
readCallback,
scopeChallenge,
enabled: true,
disable: () => registeredResource.update({ enabled: false }),
enable: () => registeredResource.update({ enabled: true }),
@ -681,6 +739,9 @@ export class McpServer {
if (updates.title !== undefined) registeredResource.title = updates.title;
if (updates.metadata !== undefined) registeredResource.metadata = updates.metadata;
if (updates.callback !== undefined) registeredResource.readCallback = updates.callback;
if (updates.scopeChallenge !== undefined) {
registeredResource.scopeChallenge = updates.scopeChallenge === null ? undefined : updates.scopeChallenge;
}
if (updates.enabled !== undefined) registeredResource.enabled = updates.enabled;
this.sendResourceListChanged();
}
@ -694,6 +755,7 @@ export class McpServer {
title: string | undefined,
template: ResourceTemplate,
metadata: ResourceMetadata | undefined,
scopeChallenge: ScopeChallengeHandler | undefined,
readCallback: ReadResourceTemplateCallback
): RegisteredResourceTemplate {
const registeredResourceTemplate: RegisteredResourceTemplate = {
@ -701,6 +763,7 @@ export class McpServer {
title,
metadata,
readCallback,
scopeChallenge,
enabled: true,
disable: () => registeredResourceTemplate.update({ enabled: false }),
enable: () => registeredResourceTemplate.update({ enabled: true }),
@ -714,6 +777,9 @@ export class McpServer {
if (updates.template !== undefined) registeredResourceTemplate.resourceTemplate = updates.template;
if (updates.metadata !== undefined) registeredResourceTemplate.metadata = updates.metadata;
if (updates.callback !== undefined) registeredResourceTemplate.readCallback = updates.callback;
if (updates.scopeChallenge !== undefined) {
registeredResourceTemplate.scopeChallenge = updates.scopeChallenge === null ? undefined : updates.scopeChallenge;
}
if (updates.enabled !== undefined) registeredResourceTemplate.enabled = updates.enabled;
this.sendResourceListChanged();
}
@ -737,6 +803,7 @@ export class McpServer {
argsSchema: StandardSchemaWithJSON | undefined,
callback: PromptCallback<StandardSchemaWithJSON | undefined>,
icons: Icon[] | undefined,
scopeChallenge: ScopeChallengeHandler | undefined,
_meta: Record<string, unknown> | undefined
): RegisteredPrompt {
// Track current schema and callback for handler regeneration
@ -748,6 +815,7 @@ export class McpServer {
description,
argsSchema,
icons,
scopeChallenge,
_meta,
handler: createPromptHandler(name, argsSchema, callback),
enabled: true,
@ -762,6 +830,9 @@ export class McpServer {
if (updates.title !== undefined) registeredPrompt.title = updates.title;
if (updates.description !== undefined) registeredPrompt.description = updates.description;
if (updates.icons !== undefined) registeredPrompt.icons = updates.icons;
if (updates.scopeChallenge !== undefined) {
registeredPrompt.scopeChallenge = updates.scopeChallenge === null ? undefined : updates.scopeChallenge;
}
if (updates._meta !== undefined) registeredPrompt._meta = updates._meta;
// Track if we need to regenerate the handler
@ -811,6 +882,7 @@ export class McpServer {
annotations: ToolAnnotations | undefined,
icons: Icon[] | undefined,
execution: ToolExecution | undefined,
scopeChallenge: ScopeChallengeHandler | undefined,
_meta: Record<string, unknown> | undefined,
handler: AnyToolHandler<StandardSchemaWithJSON | undefined>
): RegisteredTool {
@ -856,6 +928,7 @@ export class McpServer {
annotations,
icons,
execution,
scopeChallenge,
_meta,
handler: handler,
executor: createToolExecutor(inputSchema, handler),
@ -911,6 +984,9 @@ export class McpServer {
}
if (updates.annotations !== undefined) registeredTool.annotations = updates.annotations;
if (updates.icons !== undefined) registeredTool.icons = updates.icons;
if (updates.scopeChallenge !== undefined) {
registeredTool.scopeChallenge = updates.scopeChallenge === null ? undefined : updates.scopeChallenge;
}
if (updates._meta !== undefined) registeredTool._meta = updates._meta;
if (updates.enabled !== undefined) registeredTool.enabled = updates.enabled;
this.sendToolListChanged();
@ -959,6 +1035,8 @@ export class McpServer {
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
icons?: Icon[];
/** Determines whether this tool call needs an OAuth scope challenge. */
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
cb: ToolCallback<InputArgs>
@ -973,6 +1051,7 @@ export class McpServer {
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
icons?: Icon[];
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
cb: LegacyToolCallback<InputArgs>
@ -986,6 +1065,7 @@ export class McpServer {
outputSchema?: StandardSchemaWithJSON | ZodRawShape;
annotations?: ToolAnnotations;
icons?: Icon[];
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
cb: ToolCallback<StandardSchemaWithJSON | undefined> | LegacyToolCallback<ZodRawShape>
@ -994,8 +1074,7 @@ export class McpServer {
throw new Error(`Tool ${name} is already registered`);
}
const { title, description, inputSchema, outputSchema, annotations, icons, _meta } = config;
const { title, description, inputSchema, outputSchema, annotations, icons, scopeChallenge, _meta } = config;
return this._createRegisteredTool(
name,
title,
@ -1005,6 +1084,7 @@ export class McpServer {
annotations,
icons,
undefined,
scopeChallenge,
_meta,
cb as ToolCallback<StandardSchemaWithJSON | undefined>
);
@ -1043,6 +1123,8 @@ export class McpServer {
description?: string;
argsSchema?: Args;
icons?: Icon[];
/** Determines whether this prompt retrieval needs an OAuth scope challenge. */
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
cb: PromptCallback<Args>
@ -1055,6 +1137,7 @@ export class McpServer {
description?: string;
argsSchema?: Args;
icons?: Icon[];
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
cb: LegacyPromptCallback<Args>
@ -1066,6 +1149,7 @@ export class McpServer {
description?: string;
argsSchema?: StandardSchemaWithJSON | ZodRawShape;
icons?: Icon[];
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
cb: PromptCallback<StandardSchemaWithJSON> | LegacyPromptCallback<ZodRawShape>
@ -1074,7 +1158,7 @@ export class McpServer {
throw new Error(`Prompt ${name} is already registered`);
}
const { title, description, argsSchema, icons, _meta } = config;
const { title, description, argsSchema, icons, scopeChallenge, _meta } = config;
const registeredPrompt = this._createRegisteredPrompt(
name,
@ -1083,6 +1167,7 @@ export class McpServer {
normalizeRawShapeSchema(argsSchema),
cb as PromptCallback<StandardSchemaWithJSON | undefined>,
icons,
scopeChallenge,
_meta
);
@ -1278,6 +1363,7 @@ export type RegisteredTool = {
annotations?: ToolAnnotations;
icons?: Icon[];
execution?: ToolExecution;
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
handler: AnyToolHandler<StandardSchemaWithJSON | undefined>;
/** @hidden */
@ -1293,6 +1379,7 @@ export type RegisteredTool = {
outputSchema?: StandardSchemaWithJSON;
annotations?: ToolAnnotations;
icons?: Icon[];
scopeChallenge?: ScopeChallengeHandler | null;
_meta?: Record<string, unknown>;
callback?: ToolCallback<StandardSchemaWithJSON>;
enabled?: boolean;
@ -1365,6 +1452,7 @@ export type RegisteredResource = {
metadata?: ResourceMetadata;
/** Cache hint applied to this resource's `resources/read` results on the 2026-07-28 revision. */
cacheHint?: CacheHint;
scopeChallenge?: ScopeChallengeHandler;
readCallback: ReadResourceCallback;
enabled: boolean;
enable(): void;
@ -1374,6 +1462,7 @@ export type RegisteredResource = {
title?: string;
uri?: string | null;
metadata?: ResourceMetadata;
scopeChallenge?: ScopeChallengeHandler | null;
callback?: ReadResourceCallback;
enabled?: boolean;
}): void;
@ -1395,6 +1484,7 @@ export type RegisteredResourceTemplate = {
metadata?: ResourceMetadata;
/** Cache hint applied to this template's `resources/read` results on the 2026-07-28 revision. */
cacheHint?: CacheHint;
scopeChallenge?: ScopeChallengeHandler;
readCallback: ReadResourceTemplateCallback;
enabled: boolean;
enable(): void;
@ -1404,6 +1494,7 @@ export type RegisteredResourceTemplate = {
title?: string;
template?: ResourceTemplate;
metadata?: ResourceMetadata;
scopeChallenge?: ScopeChallengeHandler | null;
callback?: ReadResourceTemplateCallback;
enabled?: boolean;
}): void;
@ -1433,6 +1524,7 @@ export type RegisteredPrompt = {
description?: string;
argsSchema?: StandardSchemaWithJSON;
icons?: Icon[];
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
/** @hidden */
handler: PromptHandler;
@ -1445,6 +1537,7 @@ export type RegisteredPrompt = {
description?: string;
argsSchema?: Args;
icons?: Icon[];
scopeChallenge?: ScopeChallengeHandler | null;
_meta?: Record<string, unknown>;
callback?: PromptCallback<Args>;
enabled?: boolean;

View file

@ -50,6 +50,12 @@ export interface BearerAuthOptions {
*
* Typically built with `getOAuthProtectedResourceMetadataUrl`, exported
* from this package.
*
* When verification succeeds the value is also stamped onto the returned
* {@link AuthInfo} (`authInfo.resourceMetadataUrl`, unless the verifier
* already set one), so challenges built after authentication — such as
* per-operation `insufficient_scope` scope challenges — advertise the
* same document without being configured separately.
*/
resourceMetadataUrl?: string;
}
@ -62,18 +68,27 @@ function headerQuotedValue(value: string): string {
return value.replaceAll(/[\\"]/g, String.raw`\$&`).replaceAll(/[^\u0020-\u007E]/g, ' ');
}
function buildWwwAuthenticateHeader(
/**
* Build a `WWW-Authenticate: Bearer …` challenge header value (RFC 6750).
*
* The single formatter behind every challenge this package emits — the
* bearer-auth 401/403 answers and the per-operation scope-challenge 403 — so
* all challenges from one server agree on parameter order and quoting. Every
* parameter value is emitted as an HTTP quoted-string with `\` and `"`
* escaped and non-printable characters replaced.
*/
export function buildWwwAuthenticateHeader(
errorCode: string,
description: string,
requiredScopes: string[],
requiredScopes: readonly string[],
resourceMetadataUrl: string | undefined
): string {
let header = `Bearer error="${headerQuotedValue(errorCode)}", error_description="${headerQuotedValue(description)}"`;
if (requiredScopes.length > 0) {
header += `, scope="${requiredScopes.join(' ')}"`;
header += `, scope="${headerQuotedValue(requiredScopes.join(' '))}"`;
}
if (resourceMetadataUrl) {
header += `, resource_metadata="${resourceMetadataUrl}"`;
header += `, resource_metadata="${headerQuotedValue(resourceMetadataUrl)}"`;
}
return header;
}
@ -120,6 +135,14 @@ export async function verifyBearerToken(authorizationHeader: string | null | und
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired');
}
// Hand the gate's discovery configuration inward with the verified token,
// so challenges built after authentication (per-operation scope
// challenges) advertise the same metadata document. A verifier-set value
// wins over the gate's configuration.
if (options.resourceMetadataUrl !== undefined && authInfo.resourceMetadataUrl === undefined) {
return { ...authInfo, resourceMetadataUrl: options.resourceMetadataUrl };
}
return authInfo;
}

View file

@ -89,7 +89,10 @@ export function buildOAuthProtectedResourceMetadata(options: AuthMetadataOptions
* ```
*/
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
return new URL(protectedResourceMetadataPath(serverUrl), serverUrl).href;
const metadataUrl = new URL(serverUrl);
metadataUrl.pathname = protectedResourceMetadataPath(serverUrl);
metadataUrl.hash = '';
return metadataUrl.href;
}
/** The RFC 9728 path-aware well-known path for a resource URL. */

View file

@ -0,0 +1,121 @@
import type { AuthInfo, JSONRPCRequest } from '@modelcontextprotocol/core-internal';
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal';
import { bearerAuthChallengeResponse } from './middleware/bearerAuth';
import { getOAuthProtectedResourceMetadataUrl } from './middleware/oauthMetadata';
/** OAuth scopes to request before handling an MCP request. */
export interface ScopeChallenge {
/** The exact, complete scope set to include in the challenge. Each scope must satisfy the OAuth `scope-token` grammar. */
scopes: readonly [string, ...string[]];
/** Optional human-readable detail satisfying the RFC 6750 `error-description` grammar. */
errorDescription?: string;
}
/** Determines whether an MCP request needs an OAuth scope challenge. */
export type ScopeChallengeHandler = (context: {
request: JSONRPCRequest;
authInfo?: AuthInfo;
}) => ScopeChallenge | undefined | Promise<ScopeChallenge | undefined>;
/** @internal */
export function supportsScopeChallengeResolver(
transport: unknown
): transport is { setScopeChallengeResolver(resolver: ScopeChallengeHandler): void } {
return (
typeof transport === 'object' &&
transport !== null &&
'setScopeChallengeResolver' in transport &&
typeof (transport as { setScopeChallengeResolver: unknown }).setScopeChallengeResolver === 'function'
);
}
function assertScope(scope: unknown, location: string): asserts scope is string {
if (typeof scope !== 'string' || !/^[\u0021\u0023-\u005B\u005D-\u007E]+$/.test(scope)) {
throw new TypeError(`${location} must satisfy the OAuth scope-token grammar`);
}
}
function validateScopeChallenge(challenge: ScopeChallenge): ScopeChallenge {
if (challenge === null || typeof challenge !== 'object' || !Array.isArray(challenge.scopes) || challenge.scopes.length === 0) {
throw new TypeError('scope challenge must contain at least one scope');
}
for (const [index, scope] of challenge.scopes.entries()) {
assertScope(scope, `scope challenge scopes[${index}]`);
}
if (challenge.errorDescription !== undefined && typeof challenge.errorDescription !== 'string') {
throw new TypeError('scope challenge errorDescription must be a string');
}
if (challenge.errorDescription !== undefined && !/^[\u0020-\u0021\u0023-\u005B\u005D-\u007E]+$/.test(challenge.errorDescription)) {
throw new TypeError('scope challenge errorDescription must satisfy the RFC 6750 error-description grammar');
}
return challenge;
}
/**
* Creates a handler that requires every supplied scope exactly.
*
* Requests without authentication are left to the server's authentication gate.
*/
export function requireScopes(...scopes: readonly [string, ...string[]]): ScopeChallengeHandler {
if (scopes.length === 0) {
throw new TypeError('requireScopes must contain at least one scope');
}
for (const [index, scope] of scopes.entries()) {
assertScope(scope, `requireScopes scope[${index}]`);
}
const requiredScopes = [...scopes] as [string, ...string[]];
return ({ authInfo }) => {
if (authInfo === undefined) return;
const activeScopes = new Set(authInfo.scopes);
if (requiredScopes.every(scope => activeScopes.has(scope))) return;
return { scopes: requiredScopes };
};
}
/** @internal */
export async function findScopeChallenge(
requests: readonly JSONRPCRequest[],
authInfo: AuthInfo | undefined,
resolve: ScopeChallengeHandler
): Promise<ScopeChallenge | undefined> {
for (const request of requests) {
const challenge = await resolve({ request, ...(authInfo !== undefined && { authInfo }) });
if (challenge !== undefined) {
return validateScopeChallenge(challenge);
}
}
return undefined;
}
/**
* The RFC 9728 Protected Resource Metadata URL to advertise on a scope
* challenge, derived from the verified {@link AuthInfo}: the URL the
* authentication gate stamped (`authInfo.resourceMetadataUrl`, set by the
* bearer-auth helpers from their `resourceMetadataUrl` option), falling back
* to the well-known location for an HTTP(S) RFC 8707 `resource` identifier, or
* `undefined` when neither is available (the `resource_metadata` parameter is
* then omitted, matching the bearer-auth challenges).
*
* @internal
*/
export function scopeChallengeResourceMetadataUrl(authInfo: AuthInfo | undefined): string | undefined {
if (authInfo?.resourceMetadataUrl !== undefined) {
return authInfo.resourceMetadataUrl;
}
if (authInfo?.resource?.protocol === 'https:' || authInfo?.resource?.protocol === 'http:') {
return getOAuthProtectedResourceMetadataUrl(authInfo.resource);
}
return undefined;
}
/** @internal */
export function createScopeChallengeResponse(challenge: ScopeChallenge, resourceMetadataUrl: string | undefined): Response {
return bearerAuthChallengeResponse(
new OAuthError(OAuthErrorCode.InsufficientScope, challenge.errorDescription ?? 'Insufficient scope'),
{
requiredScopes: [...challenge.scopes],
resourceMetadataUrl
}
);
}

View file

@ -7,7 +7,7 @@
* For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport.
*/
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core-internal';
import type { AuthInfo, JSONRPCMessage, JSONRPCRequest, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core-internal';
import {
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
isInitializeRequest,
@ -20,6 +20,8 @@ import {
} from '@modelcontextprotocol/core-internal';
import { MAX_BATCH_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody';
import type { ScopeChallengeHandler } from './scopeChallenge';
import { createScopeChallengeResponse, findScopeChallenge, scopeChallengeResourceMetadataUrl } from './scopeChallenge';
import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';
export type StreamId = string;
@ -267,6 +269,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
private _supportedProtocolVersions: string[];
private _keepAliveMs: number;
private _maxRequestBodySize: number;
private _scopeChallengeResolver?: ScopeChallengeHandler;
sessionId?: string;
onclose?: () => void;
@ -304,6 +307,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
return timer;
}
/** Sets the scope challenge resolver for parsed JSON-RPC requests. */
setScopeChallengeResolver(resolver: ScopeChallengeHandler): void {
this._scopeChallengeResolver = resolver;
}
/**
* Starts the transport. This is required by the {@linkcode Transport} interface but is a no-op
* for the Streamable HTTP transport as connections are managed per-request.
@ -352,6 +360,19 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
);
}
private async _checkScopeChallenge(messages: JSONRPCMessage[], authInfo?: AuthInfo): Promise<Response | undefined> {
// Active whenever a connected McpServer supplied a resolver (it
// resolves per-primitive scopeChallenge callbacks); the challenge's
// resource_metadata parameter is derived from the verified AuthInfo
// and omitted when unavailable.
if (!this._scopeChallengeResolver) {
return undefined;
}
const requests: JSONRPCRequest[] = messages.filter(message => isJSONRPCRequest(message));
const challenge = await findScopeChallenge(requests, authInfo, this._scopeChallengeResolver);
return challenge === undefined ? undefined : createScopeChallengeResponse(challenge, scopeChallengeResourceMetadataUrl(authInfo));
}
/**
* Validates request headers for DNS rebinding protection.
* @returns Error response if validation fails, `undefined` if validation passes.
@ -856,6 +877,18 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
return this.createJsonErrorResponse(404, -32_001, 'Session not found');
}
// Check before opening SSE so an insufficient token can receive HTTP 403.
let scopeChallengeResponse: Response | undefined;
try {
scopeChallengeResponse = await this._checkScopeChallenge(messages, options?.authInfo);
} catch (error) {
this.onerror?.(error as Error);
return this.createJsonErrorResponse(500, -32_603, 'Internal server error');
}
if (scopeChallengeResponse) {
return scopeChallengeResponse;
}
// check if it contains requests
const hasRequests = messages.some(element => isJSONRPCRequest(element));

View file

@ -82,6 +82,12 @@ describe('getOAuthProtectedResourceMetadataUrl', () => {
'https://api.example.com/.well-known/oauth-protected-resource'
);
});
it('preserves the resource identifier query', () => {
expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp?tenant=acme'))).toBe(
'https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=acme'
);
});
});
describe('oauthMetadataResponse', () => {

View file

@ -0,0 +1,292 @@
import { randomUUID } from 'node:crypto';
import type { AuthInfo, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import * as z from 'zod/v4';
import { McpServer } from '../../src/server/mcp';
import type { ScopeChallengeHandler } from '../../src/server/scopeChallenge';
import { createScopeChallengeResponse, requireScopes } from '../../src/server/scopeChallenge';
import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp';
const RESOURCE_METADATA_URL = 'https://auth.example.com/.well-known/oauth-protected-resource';
function toolCall(name = 'operate', args: Record<string, unknown> = {}, id: string | number = 'call-1'): JSONRPCRequest {
return {
jsonrpc: '2.0',
method: 'tools/call',
params: { name, arguments: args },
id
};
}
function auth(scopes: string[], resourceMetadataUrl?: string): AuthInfo {
return { token: 'token', clientId: 'client', scopes, ...(resourceMetadataUrl !== undefined && { resourceMetadataUrl }) };
}
describe('requireScopes', () => {
it('requires every supplied scope using exact matches', async () => {
const handler = requireScopes('repo:read', 'org:read');
const request = toolCall();
expect(await handler({ request, authInfo: auth(['repo:read']) })).toEqual({
scopes: ['repo:read', 'org:read']
});
expect(await handler({ request, authInfo: auth(['repo:read', 'org:read']) })).toBeUndefined();
expect(await handler({ request, authInfo: auth(['repo:read:all', 'org:read']) })).toEqual({
scopes: ['repo:read', 'org:read']
});
});
it('leaves unauthenticated requests to the authentication gate', async () => {
expect(await requireScopes('repo:read')({ request: toolCall() })).toBeUndefined();
});
it('rejects invalid static scope declarations', () => {
expect(() => (requireScopes as (...scopes: string[]) => ScopeChallengeHandler)()).toThrow('at least one');
for (const scope of ['repo read', 'repo"read', String.raw`repo\read`, 'repo:read🚀']) {
expect(() => requireScopes(scope)).toThrow('scope-token grammar');
}
});
});
describe('createScopeChallengeResponse', () => {
it('uses an OAuth error body rather than a JSON-RPC Invalid Request error', async () => {
const response = createScopeChallengeResponse(
{ scopes: ['repo:write'], errorDescription: 'Write access is required' },
RESOURCE_METADATA_URL
);
expect(response.status).toBe(403);
expect(response.headers.get('WWW-Authenticate')).toBe(
'Bearer error="insufficient_scope", error_description="Write access is required", scope="repo:write"' +
`, resource_metadata="${RESOURCE_METADATA_URL}"`
);
expect(await response.json()).toEqual({
error: 'insufficient_scope',
error_description: 'Write access is required'
});
});
});
interface LegacyHarness {
server: McpServer;
transport: WebStandardStreamableHTTPServerTransport;
calls: ReturnType<typeof vi.fn>;
}
async function createLegacyHarness(scopeChallenge: ScopeChallengeHandler): Promise<LegacyHarness> {
const calls = vi.fn();
const server = new McpServer({ name: 'scope-test', version: '1.0.0' });
server.registerTool(
'operate',
{
inputSchema: z.object({ mode: z.string().optional() }),
scopeChallenge
},
async args => {
calls(args);
return { content: [{ type: 'text', text: 'ok' }] };
}
);
server.registerTool('public', { inputSchema: z.object({}) }, async () => ({ content: [{ type: 'text', text: 'public' }] }));
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: true
});
await server.connect(transport);
return { server, transport, calls };
}
async function initializeLegacy(transport: WebStandardStreamableHTTPServerTransport): Promise<string> {
const request = new Request('http://localhost/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
params: {
clientInfo: { name: 'test-client', version: '1.0' },
protocolVersion: '2025-11-25',
capabilities: {}
},
id: 'init'
})
});
const response = await transport.handleRequest(request);
return response.headers.get('mcp-session-id')!;
}
function legacyRequest(body: JSONRPCMessage | JSONRPCMessage[], sessionId: string): Request {
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId,
'mcp-protocol-version': '2025-11-25'
},
body: JSON.stringify(body)
});
}
describe('legacy Streamable HTTP scope preflight', () => {
it('awaits the callback with the full request and auth info before dispatch', async () => {
const callback = vi.fn<ScopeChallengeHandler>(async ({ request, authInfo }) => {
await Promise.resolve();
const mode = (request.params as { arguments?: { mode?: unknown } }).arguments?.mode;
return mode === 'write' && !authInfo?.scopes.includes('repo:write')
? { scopes: ['repo:write'], errorDescription: 'Write access is required' }
: undefined;
});
const harness = await createLegacyHarness(callback);
const sessionId = await initializeLegacy(harness.transport);
const response = await harness.transport.handleRequest(
legacyRequest(toolCall('operate', { mode: 'write', nested: { value: 42 } }), sessionId),
{ authInfo: auth(['repo:read'], RESOURCE_METADATA_URL) }
);
expect(response.status).toBe(403);
const challenge = response.headers.get('WWW-Authenticate');
expect(challenge).toContain('scope="repo:write"');
expect(challenge).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
expect(challenge).toContain('error_description="Write access is required"');
expect(callback).toHaveBeenCalledWith({
request: expect.objectContaining({
method: 'tools/call',
params: { name: 'operate', arguments: { mode: 'write', nested: { value: 42 } } }
}),
authInfo: auth(['repo:read'], RESOURCE_METADATA_URL)
});
expect(harness.calls).not.toHaveBeenCalled();
await harness.transport.close();
});
it('rejects a whole batch on the first challenge before any member executes', async () => {
const callback = vi.fn<ScopeChallengeHandler>(requireScopes('repo:read'));
const harness = await createLegacyHarness(callback);
const sessionId = await initializeLegacy(harness.transport);
const response = await harness.transport.handleRequest(
legacyRequest(
[
{ jsonrpc: '2.0', method: 'tools/call', params: { name: 'public', arguments: {} }, id: 'public' },
toolCall('operate', {}, 'scoped')
],
sessionId
),
{ authInfo: auth([]) }
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'insufficient_scope',
error_description: 'Insufficient scope'
});
expect(callback).toHaveBeenCalledTimes(1);
expect(harness.calls).not.toHaveBeenCalled();
await harness.transport.close();
});
it('fails closed when a callback rejects or returns invalid scopes', async () => {
for (const callback of [
vi.fn<ScopeChallengeHandler>(async () => {
throw new Error('scope lookup failed');
}),
vi.fn<ScopeChallengeHandler>(() => ({ scopes: [] as unknown as [string, ...string[]] })),
vi.fn<ScopeChallengeHandler>(() => ({ scopes: ['repo:read🚀'] })),
vi.fn<ScopeChallengeHandler>(() => ({ scopes: ['repo:read'], errorDescription: '' })),
vi.fn<ScopeChallengeHandler>(() => ({ scopes: ['repo:read'], errorDescription: 'Need "repo:read"' })),
vi.fn<ScopeChallengeHandler>(() => ({ scopes: ['repo:read'], errorDescription: String.raw`Need repo\read` })),
vi.fn<ScopeChallengeHandler>(() => ({ scopes: ['repo:read'], errorDescription: 'Need 🚀 access' }))
]) {
const harness = await createLegacyHarness(callback);
const onerror = vi.fn();
harness.transport.onerror = onerror;
const sessionId = await initializeLegacy(harness.transport);
const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), {
authInfo: auth([])
});
expect(response.status).toBe(500);
expect(harness.calls).not.toHaveBeenCalled();
expect(onerror).toHaveBeenCalledOnce();
await harness.transport.close();
}
});
it('tracks callback changes across the registered-tool lifecycle', async () => {
const server = new McpServer({ name: 'scope-test', version: '1.0.0' });
const initial = requireScopes('repo:read');
const updated = requireScopes('repo:write');
const tool = server.registerTool('mutable', { scopeChallenge: initial }, async () => ({ content: [] }));
expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toEqual({
scopes: ['repo:read']
});
tool.disable();
expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toBeUndefined();
tool.enable();
tool.update({ scopeChallenge: updated });
expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toEqual({
scopes: ['repo:write']
});
tool.update({ scopeChallenge: null });
expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toBeUndefined();
tool.remove();
expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toBeUndefined();
});
it('serializes an optional challenge description', async () => {
const harness = await createLegacyHarness(() => ({
scopes: ['repo:read'],
errorDescription: 'Needs repo:read, path/to/thing'
}));
const sessionId = await initializeLegacy(harness.transport);
const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), {
authInfo: auth([])
});
expect(response.headers.get('WWW-Authenticate')).toContain('error_description="Needs repo:read, path/to/thing"');
await harness.transport.close();
});
it('omits resource_metadata when the auth info carries no metadata URL', async () => {
const harness = await createLegacyHarness(requireScopes('repo:write'));
const sessionId = await initializeLegacy(harness.transport);
const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), {
authInfo: auth(['repo:read'])
});
expect(response.status).toBe(403);
const challenge = response.headers.get('WWW-Authenticate');
expect(challenge).toContain('scope="repo:write"');
expect(challenge).not.toContain('resource_metadata');
await harness.transport.close();
});
it('derives resource_metadata from the RFC 8707 resource identifier when no URL was stamped', async () => {
const harness = await createLegacyHarness(requireScopes('repo:write'));
const sessionId = await initializeLegacy(harness.transport);
const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), {
authInfo: { ...auth(['repo:read']), resource: new URL('https://api.example.com/mcp?tenant=acme') }
});
expect(response.status).toBe(403);
expect(response.headers.get('WWW-Authenticate')).toContain(
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=acme"'
);
await harness.transport.close();
});
it('omits resource_metadata when an abstract RFC 8707 resource identifier cannot locate an RFC 9728 document', async () => {
const harness = await createLegacyHarness(requireScopes('repo:write'));
const sessionId = await initializeLegacy(harness.transport);
const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), {
authInfo: { ...auth(['repo:read']), resource: new URL('urn:example:mcp') }
});
expect(response.status).toBe(403);
expect(response.headers.get('WWW-Authenticate')).not.toContain('resource_metadata');
await harness.transport.close();
});
});

View file

@ -0,0 +1,272 @@
import type { AuthInfo, JSONRPCRequest } from '@modelcontextprotocol/core-internal';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import * as z from 'zod/v4';
import { fromJsonSchema } from '../../src/fromJsonSchema';
import { createMcpHandler } from '../../src/server/createMcpHandler';
import { McpServer } from '../../src/server/mcp';
import { requireBearerAuth } from '../../src/server/middleware/bearerAuth';
import type { ScopeChallengeHandler } from '../../src/server/scopeChallenge';
import { requireScopes } from '../../src/server/scopeChallenge';
const MODERN = '2026-07-28';
const RESOURCE_METADATA_URL = 'https://auth.example.com/.well-known/oauth-protected-resource';
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: MODERN,
[CLIENT_INFO_META_KEY]: { name: 'scope-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
function request(method: string, params: Record<string, unknown>, extraHeaders: Record<string, string> = {}): Request {
const candidateName = method === 'resources/read' ? params.uri : params.name;
const name = typeof candidateName === 'string' ? candidateName : undefined;
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-protocol-version': MODERN,
'mcp-method': method,
...(name !== undefined && { 'mcp-name': name }),
...extraHeaders
},
body: JSON.stringify({ jsonrpc: '2.0', id: 7, method, params: { ...params, _meta: ENVELOPE } })
});
}
function call(name: string, args: Record<string, unknown>, headers?: Record<string, string>): Request {
return request('tools/call', { name, arguments: args }, headers);
}
function auth(scopes: string[], resourceMetadataUrl?: string): AuthInfo {
return { token: 'token', clientId: 'client', scopes, ...(resourceMetadataUrl !== undefined && { resourceMetadataUrl }) };
}
function createHandler(scopeChallenge: ScopeChallengeHandler, onCall = vi.fn(), responseMode?: 'json' | 'sse') {
// No handler-level scope-challenge configuration exists: registering the
// callback on the primitive is all it takes to arm the preflight.
return createMcpHandler(
() => {
const server = new McpServer({ name: 'modern-scope', version: '1.0.0' });
server.registerTool(
'operate',
{
inputSchema: z.object({ mode: z.string().optional(), secret: z.string().optional() }),
scopeChallenge
},
async args => {
onCall(args);
return { content: [{ type: 'text', text: 'ok' }] };
}
);
return server;
},
{ ...(responseMode !== undefined && { responseMode }) }
);
}
describe('createMcpHandler scope preflight', () => {
it('passes the full parsed request and auth info to an async callback before invocation', async () => {
const callback = vi.fn<ScopeChallengeHandler>(async ({ request, authInfo }) => {
const args = (request.params as { arguments: { mode?: string } }).arguments;
return args.mode === 'write' && !authInfo?.scopes.includes('repo:write') ? { scopes: ['repo:write'] } : undefined;
});
const onCall = vi.fn();
const handler = createHandler(callback, onCall);
const incoming = call('operate', { mode: 'write', secret: 'high-cardinality-value' });
expect([...incoming.headers.keys()]).not.toContain('mcp-param-secret');
const response = await handler.fetch(incoming, { authInfo: auth(['repo:read']) });
expect(response.status).toBe(403);
// The bearer-auth formatter builds the header: error, then the default
// description, then the scope set; resource_metadata is omitted when
// the auth info carries no metadata URL.
expect(response.headers.get('WWW-Authenticate')).toBe(
'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="repo:write"'
);
expect(await response.json()).toEqual({
error: 'insufficient_scope',
error_description: 'Insufficient scope'
});
expect(callback).toHaveBeenCalledWith({
request: expect.objectContaining({
method: 'tools/call',
params: expect.objectContaining({
arguments: { mode: 'write', secret: 'high-cardinality-value' }
})
}),
authInfo: auth(['repo:read'])
});
expect(onCall).not.toHaveBeenCalled();
});
it('runs the callback after Mcp-Param header/body parity checks', async () => {
const callback = vi.fn<ScopeChallengeHandler>(() => ({ scopes: ['route:read'] }));
const routeSchema = fromJsonSchema<{ region: string }>({
type: 'object',
properties: { region: { type: 'string', 'x-mcp-header': 'Region' } as Record<string, unknown> },
required: ['region']
});
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'modern-scope', version: '1.0.0' });
server.registerTool('route', { inputSchema: routeSchema, scopeChallenge: callback }, async () => ({
content: [{ type: 'text', text: 'ok' }]
}));
return server;
});
const response = await handler.fetch(call('route', { region: 'us-west1' }, { 'Mcp-Param-Region': 'eu' }), {
authInfo: auth([])
});
expect(response.status).toBe(400);
expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32_020);
expect(callback).not.toHaveBeenCalled();
});
it('keeps challenged tools discoverable and uses exact static all-of checks', async () => {
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'modern-scope', version: '1.0.0' });
server.registerTool('scoped', { scopeChallenge: requireScopes('repo:read', 'org:read') }, async () => ({
content: []
}));
return server;
});
const listResponse = await handler.fetch(request('tools/list', {}), { authInfo: auth([]) });
expect(listResponse.status).toBe(200);
const body = (await listResponse.json()) as { result: { tools: Array<{ name: string }> } };
expect(body.result.tools.map(tool => tool.name)).toContain('scoped');
const challengeResponse = await handler.fetch(call('scoped', {}), { authInfo: auth(['repo:read']) });
expect(challengeResponse.status).toBe(403);
expect(challengeResponse.headers.get('WWW-Authenticate')).toContain('scope="repo:read org:read"');
});
it('fails closed before SSE when the callback throws', async () => {
const onCall = vi.fn();
const handler = createHandler(
async () => {
throw new Error('scope lookup failed');
},
onCall,
'sse'
);
const response = await handler.fetch(call('operate', {}), { authInfo: auth([]) });
expect(response.status).toBe(500);
expect(response.headers.get('content-type')).toContain('application/json');
expect(onCall).not.toHaveBeenCalled();
});
it('continues when the callback returns undefined', async () => {
const callback = vi.fn<ScopeChallengeHandler>(({ request }: { request: JSONRPCRequest }) => {
const mode = (request.params as { arguments?: { mode?: unknown } }).arguments?.mode;
return mode === 'write' ? { scopes: ['repo:write'] } : undefined;
});
const onCall = vi.fn();
const handler = createHandler(callback, onCall);
const response = await handler.fetch(call('operate', { mode: 'read' }), { authInfo: auth([]) });
expect(response.status).toBe(200);
expect(onCall).toHaveBeenCalledOnce();
});
it('challenges resource and prompt primitives before dispatch', async () => {
const onRead = vi.fn(async (uri: URL) => ({ contents: [{ uri: uri.href, text: 'secret' }] }));
const onPrompt = vi.fn(async () => ({
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'secret' } }]
}));
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'modern-scope', version: '1.0.0' });
server.registerResource('config', 'config://settings', { scopeChallenge: requireScopes('config:read') }, onRead);
server.registerPrompt('summarize', { scopeChallenge: requireScopes('prompt:read') }, onPrompt);
return server;
});
const resourceResponse = await handler.fetch(request('resources/read', { uri: 'config://settings' }), {
authInfo: auth([])
});
const promptResponse = await handler.fetch(request('prompts/get', { name: 'summarize', arguments: {} }), {
authInfo: auth([])
});
expect(resourceResponse.status).toBe(403);
expect(resourceResponse.headers.get('WWW-Authenticate')).toContain('scope="config:read"');
expect(promptResponse.status).toBe(403);
expect(promptResponse.headers.get('WWW-Authenticate')).toContain('scope="prompt:read"');
expect(onRead).not.toHaveBeenCalled();
expect(onPrompt).not.toHaveBeenCalled();
});
});
describe('scope challenge resource_metadata derivation', () => {
it('advertises the metadata URL stamped onto the auth info', async () => {
const handler = createHandler(requireScopes('repo:write'));
const response = await handler.fetch(call('operate', {}), {
authInfo: auth(['repo:read'], RESOURCE_METADATA_URL)
});
expect(response.status).toBe(403);
expect(response.headers.get('WWW-Authenticate')).toBe(
'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="repo:write"' +
`, resource_metadata="${RESOURCE_METADATA_URL}"`
);
});
it('carries the URL configured on requireBearerAuth through to the challenge header', async () => {
// The single configuration site: the bearer-auth gate stamps its
// resourceMetadataUrl onto the AuthInfo it returns, and the scope
// preflight reads it from there.
const gate = requireBearerAuth({
verifier: {
verifyAccessToken: async token => ({
token,
clientId: 'client',
scopes: ['repo:read'],
expiresAt: Math.floor(Date.now() / 1000) + 3600
})
},
resourceMetadataUrl: RESOURCE_METADATA_URL
});
const handler = createHandler(requireScopes('repo:write'));
const incoming = call('operate', {}, { Authorization: 'Bearer token-1' });
const gateResult = await gate(incoming);
expect(gateResult).not.toBeInstanceOf(Response);
const authInfo = gateResult as AuthInfo;
expect(authInfo.resourceMetadataUrl).toBe(RESOURCE_METADATA_URL);
const response = await handler.fetch(incoming, { authInfo });
expect(response.status).toBe(403);
expect(response.headers.get('WWW-Authenticate')).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
});
it('falls back to the well-known location for the RFC 8707 resource identifier', async () => {
const handler = createHandler(requireScopes('repo:write'));
const response = await handler.fetch(call('operate', {}), {
authInfo: { ...auth(['repo:read']), resource: new URL('https://api.example.com/mcp') }
});
expect(response.status).toBe(403);
expect(response.headers.get('WWW-Authenticate')).toContain(
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"'
);
});
it('omits resource_metadata entirely when the auth info offers no URL', async () => {
const handler = createHandler(requireScopes('repo:write'));
const response = await handler.fetch(call('operate', {}), { authInfo: auth(['repo:read']) });
expect(response.status).toBe(403);
expect(response.headers.get('WWW-Authenticate')).not.toContain('resource_metadata');
});
});

View file

@ -0,0 +1,195 @@
import { randomUUID } from 'node:crypto';
import type { AuthInfo, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import { McpServer, ResourceTemplate } from '../../src/server/mcp';
import type { ScopeChallengeHandler } from '../../src/server/scopeChallenge';
import { requireScopes } from '../../src/server/scopeChallenge';
import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp';
function auth(scopes: string[]): AuthInfo {
return { token: 'token', clientId: 'client', scopes };
}
function readResource(uri: string, id: string | number = 'read-1'): JSONRPCRequest {
return { jsonrpc: '2.0', method: 'resources/read', params: { uri }, id };
}
function getPrompt(name: string, id: string | number = 'prompt-1'): JSONRPCRequest {
return { jsonrpc: '2.0', method: 'prompts/get', params: { name, arguments: {} }, id };
}
function request(body: JSONRPCMessage | JSONRPCMessage[], sessionId?: string): Request {
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...(sessionId !== undefined && {
'mcp-session-id': sessionId,
'mcp-protocol-version': '2025-11-25'
})
},
body: JSON.stringify(body)
});
}
async function initialize(transport: WebStandardStreamableHTTPServerTransport): Promise<string> {
const response = await transport.handleRequest(
request({
jsonrpc: '2.0',
method: 'initialize',
params: {
clientInfo: { name: 'test-client', version: '1.0' },
protocolVersion: '2025-11-25',
capabilities: {}
},
id: 'init'
})
);
return response.headers.get('mcp-session-id')!;
}
async function createHarness(server: McpServer): Promise<{
transport: WebStandardStreamableHTTPServerTransport;
sessionId: string;
}> {
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: true
});
await server.connect(transport);
return { transport, sessionId: await initialize(transport) };
}
describe('scope challenges for resources and prompts', () => {
it('challenges static resources and prompts before their handlers run', async () => {
const server = new McpServer({ name: 'scope-primitives', version: '1.0.0' });
const read = vi.fn(async (uri: URL) => ({ contents: [{ uri: uri.href, text: 'secret' }] }));
const render = vi.fn(async () => ({
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'secret' } }]
}));
server.registerResource(
'config',
'config://settings',
{ mimeType: 'text/plain', scopeChallenge: requireScopes('config:read') },
read
);
server.registerPrompt('summarize', { scopeChallenge: requireScopes('prompt:read') }, render);
const { transport, sessionId } = await createHarness(server);
const resourceResponse = await transport.handleRequest(request(readResource('config://settings'), sessionId), {
authInfo: auth([])
});
const promptResponse = await transport.handleRequest(request(getPrompt('summarize'), sessionId), {
authInfo: auth([])
});
expect(resourceResponse.status).toBe(403);
expect(resourceResponse.headers.get('WWW-Authenticate')).toContain('scope="config:read"');
expect(promptResponse.status).toBe(403);
expect(promptResponse.headers.get('WWW-Authenticate')).toContain('scope="prompt:read"');
expect(read).not.toHaveBeenCalled();
expect(render).not.toHaveBeenCalled();
await transport.close();
});
it('routes a template resource request to its request-aware callback', async () => {
const callback = vi.fn<ScopeChallengeHandler>(({ request: incoming, authInfo }) => {
const uri = (incoming.params as { uri?: unknown }).uri;
const scopes = typeof uri === 'string' && uri.includes('/private/') ? (['repo:read'] as const) : (['public_repo'] as const);
return scopes.every(scope => authInfo?.scopes.includes(scope)) ? undefined : { scopes };
});
const read = vi.fn(async (uri: URL) => ({ contents: [{ uri: uri.href, text: 'repository' }] }));
const server = new McpServer({ name: 'scope-primitives', version: '1.0.0' });
server.registerResource(
'repository',
new ResourceTemplate('github://{owner}/{visibility}/{repo}', { list: undefined }),
{ scopeChallenge: callback },
read
);
const { transport, sessionId } = await createHarness(server);
const privateResponse = await transport.handleRequest(request(readResource('github://octo/private/sdk'), sessionId), {
authInfo: auth(['public_repo'])
});
const publicResponse = await transport.handleRequest(request(readResource('github://octo/public/sdk'), sessionId), {
authInfo: auth(['public_repo'])
});
expect(privateResponse.status).toBe(403);
expect(privateResponse.headers.get('WWW-Authenticate')).toContain('scope="repo:read"');
expect(publicResponse.status).toBe(200);
expect(callback).toHaveBeenCalledWith({
request: expect.objectContaining({
method: 'resources/read',
params: { uri: 'github://octo/private/sdk' }
}),
authInfo: auth(['public_repo'])
});
expect(read).toHaveBeenCalledOnce();
await transport.close();
});
it('tracks callback updates and enabled state for every primitive', async () => {
const server = new McpServer({ name: 'scope-primitives', version: '1.0.0' });
const resource = server.registerResource(
'config',
'config://settings',
{ scopeChallenge: requireScopes('config:read') },
async uri => ({ contents: [{ uri: uri.href, text: 'config' }] })
);
const template = server.registerResource(
'repository',
new ResourceTemplate('github://{owner}/{repo}', { list: undefined }),
{ scopeChallenge: requireScopes('repo:read') },
async uri => ({ contents: [{ uri: uri.href, text: 'repository' }] })
);
const prompt = server.registerPrompt('summarize', { scopeChallenge: requireScopes('prompt:read') }, async () => ({
messages: []
}));
expect(await server.resolveScopeChallenge({ request: readResource('config://settings'), authInfo: auth([]) })).toEqual({
scopes: ['config:read']
});
expect(await server.resolveScopeChallenge({ request: readResource('github://octo/sdk'), authInfo: auth([]) })).toEqual({
scopes: ['repo:read']
});
expect(await server.resolveScopeChallenge({ request: getPrompt('summarize'), authInfo: auth([]) })).toEqual({
scopes: ['prompt:read']
});
resource.update({ scopeChallenge: requireScopes('config:admin') });
template.disable();
prompt.update({ scopeChallenge: null });
expect(await server.resolveScopeChallenge({ request: readResource('config://settings'), authInfo: auth([]) })).toEqual({
scopes: ['config:admin']
});
expect(await server.resolveScopeChallenge({ request: readResource('github://octo/sdk'), authInfo: auth([]) })).toBeUndefined();
expect(await server.resolveScopeChallenge({ request: getPrompt('summarize'), authInfo: auth([]) })).toBeUndefined();
});
it('leaves malformed and non-invocation requests to normal protocol handling', async () => {
const callback = vi.fn<ScopeChallengeHandler>(requireScopes('resource:read'));
const server = new McpServer({ name: 'scope-primitives', version: '1.0.0' });
server.registerResource('config', 'config://settings', { scopeChallenge: callback }, async uri => ({
contents: [{ uri: uri.href, text: 'config' }]
}));
expect(
await server.resolveScopeChallenge({
request: readResource('not a valid URI'),
authInfo: auth([])
})
).toBeUndefined();
expect(
await server.resolveScopeChallenge({
request: { jsonrpc: '2.0', method: 'resources/list', params: {}, id: 'list' },
authInfo: auth([])
})
).toBeUndefined();
expect(callback).not.toHaveBeenCalled();
});
});