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