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

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