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