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

View file

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

View file

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

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

View file

@ -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 };
@ -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;
}

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();
});
});

View file

@ -12,12 +12,14 @@ import { randomUUID } from 'node:crypto';
import { localhostHostValidation } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node';
import type {
AuthInfo,
CallToolResult,
EventId,
EventStore,
GetPromptResult,
InputRequests,
InputRequiredResult,
JSONRPCRequest,
ReadResourceResult,
ServerContext,
StreamId
@ -45,6 +47,28 @@ import * as z from 'zod/v4';
const resourceSubscriptions = new Set<string>();
const watchedResourceContent = 'Watched resource content';
const SCOPE_CHALLENGE_LOW_TOKEN = 'mcp-conformance-scope-low';
const SCOPE_CHALLENGE_FULL_TOKEN = 'mcp-conformance-scope-full';
const SCOPE_CHALLENGE_BASELINE_SCOPE = 'mcp:conformance:baseline';
const SCOPE_CHALLENGE_SCOPES = {
tool: ['mcp:conformance:tools:call', 'mcp:conformance:tools:test_simple_text'],
staticResource: ['mcp:conformance:resources:read', 'mcp:conformance:resources:static'],
templateResource: ['mcp:conformance:resources:read', 'mcp:conformance:resources:template:123'],
prompt: ['mcp:conformance:prompts:get', 'mcp:conformance:prompts:test_simple_prompt']
} as const;
type ConformanceScopeChallengeHandler = (context: {
request: JSONRPCRequest;
authInfo?: AuthInfo;
}) => { scopes: readonly [string, ...string[]] } | undefined;
function requireConformanceScopes(...scopes: readonly [string, ...string[]]): ConformanceScopeChallengeHandler {
return ({ authInfo }) => {
if (authInfo === undefined || scopes.every(scope => authInfo.scopes.includes(scope))) return;
return { scopes };
};
}
// Session management
const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {};
const servers: { [sessionId: string]: McpServer } = {};
@ -244,17 +268,15 @@ function createMcpServer() {
);
// Simple text tool
mcpServer.registerTool(
'test_simple_text',
{
description: 'Tests simple text content response'
},
async (): Promise<CallToolResult> => {
return {
content: [{ type: 'text', text: 'This is a simple text response for testing.' }]
};
}
);
const simpleTextToolConfig = {
description: 'Tests simple text content response',
scopeChallenge: requireConformanceScopes(...SCOPE_CHALLENGE_SCOPES.tool)
};
mcpServer.registerTool('test_simple_text', simpleTextToolConfig, async (): Promise<CallToolResult> => {
return {
content: [{ type: 'text', text: 'This is a simple text response for testing.' }]
};
});
// Image content tool
mcpServer.registerTool(
@ -1092,26 +1114,23 @@ function createMcpServer() {
// ===== RESOURCES =====
// Static text resource
mcpServer.registerResource(
'static-text',
'test://static-text',
{
title: 'Static Text Resource',
description: 'A static text resource for testing',
mimeType: 'text/plain'
},
async (): Promise<ReadResourceResult> => {
return {
contents: [
{
uri: 'test://static-text',
mimeType: 'text/plain',
text: 'This is the content of the static text resource.'
}
]
};
}
);
const staticTextResourceConfig = {
title: 'Static Text Resource',
description: 'A static text resource for testing',
mimeType: 'text/plain',
scopeChallenge: requireConformanceScopes(...SCOPE_CHALLENGE_SCOPES.staticResource)
};
mcpServer.registerResource('static-text', 'test://static-text', staticTextResourceConfig, async (): Promise<ReadResourceResult> => {
return {
contents: [
{
uri: 'test://static-text',
mimeType: 'text/plain',
text: 'This is the content of the static text resource.'
}
]
};
});
// Static binary resource
mcpServer.registerResource(
@ -1136,14 +1155,16 @@ function createMcpServer() {
);
// Resource template
const resourceTemplateConfig = {
title: 'Resource Template',
description: 'A resource template with parameter substitution',
mimeType: 'application/json',
scopeChallenge: requireConformanceScopes(...SCOPE_CHALLENGE_SCOPES.templateResource)
};
mcpServer.registerResource(
'template',
new ResourceTemplate('test://template/{id}/data', { list: undefined }),
{
title: 'Resource Template',
description: 'A resource template with parameter substitution',
mimeType: 'application/json'
},
resourceTemplateConfig,
async (uri, variables): Promise<ReadResourceResult> => {
const id = variables.id;
return {
@ -1202,26 +1223,24 @@ function createMcpServer() {
// ===== PROMPTS =====
// Simple prompt
mcpServer.registerPrompt(
'test_simple_prompt',
{
title: 'Simple Test Prompt',
description: 'A simple prompt without arguments'
},
async (): Promise<GetPromptResult> => {
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'This is a simple prompt for testing.'
}
const simplePromptConfig = {
title: 'Simple Test Prompt',
description: 'A simple prompt without arguments',
scopeChallenge: requireConformanceScopes(...SCOPE_CHALLENGE_SCOPES.prompt)
};
mcpServer.registerPrompt('test_simple_prompt', simplePromptConfig, async (): Promise<GetPromptResult> => {
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'This is a simple prompt for testing.'
}
]
};
}
);
}
]
};
});
// Prompt with arguments
mcpServer.registerPrompt(
@ -1386,9 +1405,12 @@ function createMcpServer() {
// `createMcpServer()` fixture definition the 2025 sessions use. Legacy traffic
// never reaches this handler (see the routing in the POST handler below), so
// the 2025 stateful session path is unchanged.
const modernHandler = createMcpHandler(() => createMcpServer(), {
onerror: error => console.error('Modern-era MCP handler error:', error)
});
const PORT = process.env.PORT || 3000;
const scopeChallengeResourceMetadataUrl = `http://localhost:${PORT}/.well-known/oauth-protected-resource/mcp`;
const modernHandlerOptions = {
onerror: (error: Error) => console.error('Modern-era MCP handler error:', error)
};
const modernHandler = createMcpHandler(() => createMcpServer(), modernHandlerOptions);
const modernNodeHandler = toNodeHandler(modernHandler);
/** Normalize a possibly-repeated HTTP header to its first value. */
@ -1401,6 +1423,38 @@ function headerValue(value: string | string[] | undefined): string | undefined {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
const authorization = req.header('authorization');
const token = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
if (token === SCOPE_CHALLENGE_LOW_TOKEN) {
req.auth = {
token,
clientId: 'mcp-conformance-scope-challenge',
scopes: [SCOPE_CHALLENGE_BASELINE_SCOPE],
// Scope-challenge 403s derive their resource_metadata parameter
// from the verified AuthInfo (a real deployment's bearer-auth gate
// stamps this from its resourceMetadataUrl option).
resourceMetadataUrl: scopeChallengeResourceMetadataUrl
};
} else if (token === SCOPE_CHALLENGE_FULL_TOKEN) {
req.auth = {
token,
clientId: 'mcp-conformance-scope-challenge',
resourceMetadataUrl: scopeChallengeResourceMetadataUrl,
scopes: [
SCOPE_CHALLENGE_BASELINE_SCOPE,
...new Set([
...SCOPE_CHALLENGE_SCOPES.tool,
...SCOPE_CHALLENGE_SCOPES.staticResource,
...SCOPE_CHALLENGE_SCOPES.templateResource,
...SCOPE_CHALLENGE_SCOPES.prompt
])
]
};
}
next();
});
// DNS rebinding protection: reject non-localhost Host headers
app.use(localhostHostValidation());
@ -1409,7 +1463,7 @@ app.use(
cors({
origin: '*',
exposedHeaders: ['Mcp-Session-Id'],
allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id', 'mcp-protocol-version', 'mcp-method']
allowedHeaders: ['Authorization', 'Content-Type', 'mcp-session-id', 'last-event-id', 'mcp-protocol-version', 'mcp-method']
})
);
@ -1560,7 +1614,6 @@ app.delete('/mcp', async (req: Request, res: Response) => {
});
// Start server
const PORT = process.env.PORT || 3000;
const httpServer = app.listen(PORT, () => {
console.log(`MCP Conformance Test Server running on http://localhost:${PORT}`);
console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`);