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
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:
parent
b65426158e
commit
6032170087
13 changed files with 1310 additions and 104 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
121
packages/server/src/server/scopeChallenge.ts
Normal file
121
packages/server/src/server/scopeChallenge.ts
Normal 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
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
292
packages/server/test/server/scopeChallenge.test.ts
Normal file
292
packages/server/test/server/scopeChallenge.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
272
packages/server/test/server/scopeChallengeModern.test.ts
Normal file
272
packages/server/test/server/scopeChallengeModern.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
195
packages/server/test/server/scopeChallengePrimitives.test.ts
Normal file
195
packages/server/test/server/scopeChallengePrimitives.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue