From f068cdba8c16f71a47db91abbd0a0366fce0832b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusufhan=20Sa=C3=A7ak?= Date: Thu, 5 Nov 2026 13:48:14 +0000 Subject: [PATCH 1/2] feat(server): add protected resource metadata discovery scenario Add auth/protected-resource-metadata, a server scenario that probes an MCP endpoint without a token and grades the Protected Resource Metadata it publishes (RFC 9728): the 401 challenge, discovery via resource_metadata or the well-known URIs, the 200 application/json response, the resource field and a non-empty authorization_servers list, plus a SHOULD-level scope check that is SKIPPED at 2025-06-18, where the spec says nothing about scope in the challenge. A 2xx to the unauthenticated probe reports every check untestable. The fetch and parse logic lives in a standalone helper under src/scenarios/server/auth/prm.ts so other server auth scenarios can reuse it. Every request is bounded to 10 s, headers and body, so a server that holds a stream open fails with a reason naming the timeout instead of hanging the suite. Discovery candidates are fetched once per URL, and the challenge parser survives a token68 challenge and an unquoted resource_metadata value. The scenario starts in the pending suite because the everything-server does not run with authorization enabled; an auth-enabled fixture with one break mode per check backs the vitest cases. Closes #565 --- examples/servers/typescript/README.md | 35 ++ .../servers/typescript/auth-prm-server.ts | 156 ++++++ src/scenarios/index.ts | 12 +- src/scenarios/server/auth/prm.ts | 404 +++++++++++++++ .../auth/protected-resource-metadata.test.ts | 480 ++++++++++++++++++ .../auth/protected-resource-metadata.ts | 433 ++++++++++++++++ src/scenarios/server/auth/spec-references.ts | 32 ++ 7 files changed, 1551 insertions(+), 1 deletion(-) create mode 100644 examples/servers/typescript/auth-prm-server.ts create mode 100644 src/scenarios/server/auth/prm.ts create mode 100644 src/scenarios/server/auth/protected-resource-metadata.test.ts create mode 100644 src/scenarios/server/auth/protected-resource-metadata.ts create mode 100644 src/scenarios/server/auth/spec-references.ts diff --git a/examples/servers/typescript/README.md b/examples/servers/typescript/README.md index 53c28fdc..d74691dc 100644 --- a/examples/servers/typescript/README.md +++ b/examples/servers/typescript/README.md @@ -188,3 +188,38 @@ npx @modelcontextprotocol/conformance server \ ``` **DO NOT** use this pattern in production servers. Always use `createMcpExpressApp()` or the `localhostHostValidation()` middleware for localhost servers. + +### auth-prm-server.ts + +A small auth-enabled server for the `auth/protected-resource-metadata` scenario. Requests without a bearer token get a `401` with a `WWW-Authenticate` challenge; the Protected Resource Metadata (RFC 9728) is served at the path-based well-known URL. It listens on port 3013 (`PORT` overrides). + +`PRM_BREAK` picks one deliberate defect so each check has a negative case. Unknown values exit with the allowed list. + +| `PRM_BREAK` | Effect | Expected result | +| ------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | +| `none` (default) | compliant | all checks pass | +| `no-prm` | no `resource_metadata`, no well-known document | `server-prm-discovery` fails, document checks untestable | +| `challenge-404` | `resource_metadata` points at a 404; well-known works | `server-prm-discovery` fails | +| `wrong-content-type` | metadata served as `text/html` | `server-prm-response` fails | +| `wrong-resource` | `resource` names another server | `server-prm-resource` fails | +| `missing-resource` | `resource` omitted | `server-prm-resource` fails | +| `missing-authorization-servers` | `authorization_servers` omitted | `server-prm-authorization-servers` fails | +| `empty-authorization-servers` | `authorization_servers` is `[]` | `server-prm-authorization-servers` fails | +| `status-403` | `403` instead of `401` for a missing token | `server-auth-401` fails | +| `no-scope` | challenge omits `scope` | `server-www-authenticate-scope` warns | +| `well-known-only` | no `resource_metadata`; well-known works | passes at 2025-11-25+, discovery fails at 2025-06-18 | +| `root-well-known` | metadata only at the root URL, `resource` = origin | all checks pass | +| `no-auth` | no authorization at all | every check untestable | + +```bash +# Run the compliant server +npx tsx auth-prm-server.ts + +# Run one negative case +PRM_BREAK=no-scope npx tsx auth-prm-server.ts + +# Grade it +npx @modelcontextprotocol/conformance server \ + --url http://localhost:3013/mcp \ + --scenario auth/protected-resource-metadata +``` diff --git a/examples/servers/typescript/auth-prm-server.ts b/examples/servers/typescript/auth-prm-server.ts new file mode 100644 index 00000000..09f14c2a --- /dev/null +++ b/examples/servers/typescript/auth-prm-server.ts @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +/** + * Auth-enabled fixture for the auth/protected-resource-metadata scenario. + * + * Requests without a bearer token get a 401 challenge; the Protected Resource + * Metadata (RFC 9728) is served at the path-based well-known URL. PRM_BREAK + * selects one deliberate defect so each check has a negative case: + * none compliant (default) + * no-prm no resource_metadata, no well-known document + * challenge-404 resource_metadata points at a 404; well-known works + * wrong-content-type metadata served as text/html + * wrong-resource resource names another server + * missing-resource resource omitted + * missing-authorization-servers authorization_servers omitted + * empty-authorization-servers authorization_servers is [] + * status-403 403 instead of 401 for a missing token + * no-scope challenge omits scope + * well-known-only no resource_metadata; well-known works + * root-well-known metadata only at the root URL, resource = origin + * no-auth no authorization at all (2xx to every request) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express, { type Request, type Response } from 'express'; +import { z } from 'zod'; + +const BREAK_MODES = [ + 'none', + 'no-prm', + 'challenge-404', + 'wrong-content-type', + 'wrong-resource', + 'missing-resource', + 'missing-authorization-servers', + 'empty-authorization-servers', + 'status-403', + 'no-scope', + 'well-known-only', + 'root-well-known', + 'no-auth' +]; + +const PORT = parseInt(process.env.PORT || '3013', 10); +const BREAK = process.env.PRM_BREAK || 'none'; +if (!BREAK_MODES.includes(BREAK)) { + console.error( + `Unknown PRM_BREAK "${BREAK}". Allowed: ${BREAK_MODES.join(', ')}` + ); + process.exit(1); +} +const ORIGIN = `http://localhost:${PORT}`; +const MCP_URL = `${ORIGIN}/mcp`; +const AUTHORIZATION_SERVER = 'https://auth.example.com'; +const PATH_PRM = '/.well-known/oauth-protected-resource/mcp'; +const ROOT_PRM = '/.well-known/oauth-protected-resource'; + +function prmDocument(atRoot: boolean): Record { + const doc: Record = { + resource: atRoot ? ORIGIN : MCP_URL, + authorization_servers: [AUTHORIZATION_SERVER], + scopes_supported: ['mcp:read'] + }; + if (BREAK === 'wrong-resource') doc.resource = 'https://attacker.example/mcp'; + if (BREAK === 'missing-resource') delete doc.resource; + if (BREAK === 'missing-authorization-servers') + delete doc.authorization_servers; + if (BREAK === 'empty-authorization-servers') doc.authorization_servers = []; + return doc; +} + +function challenge(): string { + const params: string[] = []; + if (BREAK === 'challenge-404') { + params.push(`resource_metadata="${ORIGIN}/missing-prm"`); + } else if (BREAK === 'root-well-known') { + params.push(`resource_metadata="${ORIGIN}${ROOT_PRM}"`); + } else if (BREAK !== 'no-prm' && BREAK !== 'well-known-only') { + params.push(`resource_metadata="${ORIGIN}${PATH_PRM}"`); + } + if (BREAK !== 'no-scope') params.push('scope="mcp:read"'); + return `Bearer ${params.join(', ')}`; +} + +function createMcpServer(): McpServer { + const server = new McpServer({ + name: 'auth-prm-server', + version: '1.0.0' + }); + server.registerTool( + 'echo', + { + description: 'Echo the input back', + inputSchema: { message: z.string() } + }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Echo: ${message}` }] + }) + ); + return server; +} + +function sendPrm(res: Response, atRoot: boolean): void { + const body = JSON.stringify(prmDocument(atRoot)); + if (BREAK === 'wrong-content-type') { + res.status(200).type('text/html').send(body); + return; + } + res.status(200).type('application/json').send(body); +} + +const app = express(); +app.use(express.json()); + +if (BREAK !== 'no-prm') { + if (BREAK === 'root-well-known') { + app.get(ROOT_PRM, (_req, res) => sendPrm(res, true)); + } else { + app.get(PATH_PRM, (_req, res) => sendPrm(res, false)); + } +} + +app.post('/mcp', async (req: Request, res: Response) => { + const authorization = req.get('authorization') ?? ''; + if (BREAK !== 'no-auth' && !/^bearer\s+\S+/i.test(authorization)) { + res + .status(BREAK === 'status-403' ? 403 : 401) + .set('WWW-Authenticate', challenge()) + .json({ error: 'unauthorized' }); + return; + } + try { + const server = createMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, + id: null + }); + } + } +}); + +app.listen(PORT, '127.0.0.1', () => { + console.log(`Auth PRM server running on ${MCP_URL} (PRM_BREAK=${BREAK})`); +}); diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index ff377f54..2cf83b14 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -73,6 +73,7 @@ import { } from './server/prompts'; import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { ProtectedResourceMetadataScenario } from './server/auth/protected-resource-metadata'; import { CachingScenario } from './server/caching'; // InputRequiredResult scenarios from (SEP-2322) @@ -166,7 +167,13 @@ const pendingClientScenariosList: ClientScenario[] = [ // `npm start -- server --scenario sep-2640-skills-* --url `. new SkillsDirectoryReadScenario(), new SkillsEnumerationScenario(), - new SkillsManifestScenario() + new SkillsManifestScenario(), + + // Protected Resource Metadata discovery (RFC 9728). Pending because the + // everything-server does not run with authorization enabled; targeted runs + // point at an auth-enabled server via + // `npm start -- server --scenario auth/protected-resource-metadata --url `. + new ProtectedResourceMetadataScenario() ]; // All client scenarios @@ -234,6 +241,9 @@ const allClientScenariosList: ClientScenario[] = [ // Security scenarios new DNSRebindingProtectionScenario(), + // Authorization scenarios + new ProtectedResourceMetadataScenario(), + // Caching scenarios (SEP-2549) new CachingScenario(), // HTTP Standardization scenarios (SEP-2243) diff --git a/src/scenarios/server/auth/prm.ts b/src/scenarios/server/auth/prm.ts new file mode 100644 index 00000000..ef2083f9 --- /dev/null +++ b/src/scenarios/server/auth/prm.ts @@ -0,0 +1,404 @@ +/** + * Protected Resource Metadata discovery helpers (RFC 9728). + * + * Shared by server-side auth scenarios: probe an MCP endpoint without a + * token, read the WWW-Authenticate challenge, and fetch the metadata the way + * an MCP client would (resource_metadata URL, then the path-based well-known + * URL, then the root one). Nothing here grades a response; callers decide + * which checks a result passes or fails. + */ + +import { request } from 'undici'; +import { DRAFT_PROTOCOL_VERSION } from '../../../types'; +import { buildStandardHeaders, withRequestMeta } from '../../../connection'; + +export const WELL_KNOWN_PRM_PATH = '/.well-known/oauth-protected-resource'; + +/** + * Upper bound on one request, headers and body together, matching the + * stateless connection's default. A server that leaves an SSE stream open or + * never ends a JSON body must not hang the suite. + */ +export const REQUEST_TIMEOUT_MS = 10000; + +export interface RequestOptions { + timeoutMs?: number; +} + +export interface UnauthenticatedProbe { + statusCode: number; + wwwAuthenticate: string | undefined; + /** Mcp-Session-Id the server assigned, if it opened a session. */ + sessionId: string | undefined; + body: string; +} + +function timeoutError(timeoutMs: number): Error { + return new Error(`timed out after ${timeoutMs} ms`); +} + +/** + * Run `fn` with an abort signal that fires after `timeoutMs`. The signal is + * aborted on the way out as well so a still-open response body is torn + * down. A timeout surfaces as an Error naming it; other failures pass through. + */ +async function withDeadline( + timeoutMs: number, + fn: (signal: AbortSignal) => Promise +): Promise { + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + try { + return await fn(controller.signal); + } catch (error) { + if (timedOut) throw timeoutError(timeoutMs); + throw error; + } finally { + clearTimeout(timer); + controller.abort(); + } +} + +function probeBody(specVersion: string): { + jsonrpc: '2.0'; + id: number; + method: string; + params: Record; +} { + if (specVersion === DRAFT_PROTOCOL_VERSION) { + return { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: withRequestMeta({}, specVersion) + }; + } + return { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: specVersion, + capabilities: {}, + clientInfo: { name: 'conformance-prm-test', version: '1.0.0' } + } + }; +} + +export function singleHeader( + value: string | string[] | undefined +): string | undefined { + if (Array.isArray(value)) return value.join(', '); + return value; +} + +/** + * Send one request without an Authorization header. The body is the request + * any server for the spec version accepts without prior setup, so a 401 can + * only come from the missing token. + */ +export async function probeUnauthenticated( + serverUrl: string, + specVersion: string, + options: RequestOptions = {} +): Promise { + const probe = probeBody(specVersion); + const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS; + return withDeadline(timeoutMs, async (signal) => { + const response = await request(serverUrl, { + method: 'POST', + headers: buildStandardHeaders(probe.method, probe.params, { + headers: { 'MCP-Protocol-Version': specVersion } + }), + body: JSON.stringify(probe), + signal + }); + const body = await response.body.text(); + return { + statusCode: response.statusCode, + wwwAuthenticate: singleHeader(response.headers['www-authenticate']), + sessionId: singleHeader(response.headers['mcp-session-id']), + body + }; + }); +} + +export interface Challenge { + scheme: string; + params: Record; +} + +// RFC 9110 §5.6.2 token characters. +const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+/; + +/** + * Parse a WWW-Authenticate header into its challenges (RFC 9110 §11.6.1). + * A token followed by `=` is an auth-param; any other token starts a new + * challenge. Quoted-string values are unescaped. Param names are lowercased. + * token68 credentials (`Negotiate abc==`) are skipped so the challenges after + * them survive. An unquoted value runs to the next space or comma, wider than + * the grammar's token, because `resource_metadata=https://...` is seen in the + * wild. + */ +export function parseChallenges(header: string): Challenge[] { + const challenges: Challenge[] = []; + let i = 0; + const skip = (chars: RegExp) => { + while (i < header.length && chars.test(header[i])) i++; + }; + while (i < header.length) { + skip(/[\s,]/); + const token = TOKEN.exec(header.slice(i)); + if (!token) break; + i += token[0].length; + skip(/[ \t]/); + const current = challenges[challenges.length - 1]; + if (header[i] === '=' && current) { + i++; + skip(/[ \t]/); + let value = ''; + if (header[i] === '"') { + i++; + while (i < header.length && header[i] !== '"') { + if (header[i] === '\\' && i + 1 < header.length) i++; + value += header[i]; + i++; + } + i++; + } else { + if (/^[=\s,]/.test(header.slice(i)) || i >= header.length) { + // token68 (`abc==`): skip the `=` run; nothing to record. + skip(/=/); + continue; + } + const raw = /^[^\s,]+/.exec(header.slice(i)); + value = raw ? raw[0] : ''; + i += value.length; + } + current.params[token[0].toLowerCase()] = value; + continue; + } + if (header[i] === '=') { + i++; + continue; + } + challenges.push({ scheme: token[0], params: {} }); + } + return challenges; +} + +/** First value of `name` across every challenge in the header. */ +export function challengeParam( + header: string | undefined, + name: string +): string | undefined { + if (!header) return undefined; + for (const challenge of parseChallenges(header)) { + const value = challenge.params[name.toLowerCase()]; + if (value !== undefined) return value; + } + return undefined; +} + +/** + * The two well-known URLs RFC 9728 §3.1 derives from a resource identifier: + * the suffix inserted before the path, and the suffix alone at the origin. + * They coincide for a resource at the origin. + */ +export function wellKnownUrls(resourceUrl: string): { + pathBased: string; + root: string; +} { + const u = new URL(resourceUrl); + const path = u.pathname === '/' ? '' : u.pathname; + return { + pathBased: `${u.origin}${WELL_KNOWN_PRM_PATH}${path}${u.search}`, + root: `${u.origin}${WELL_KNOWN_PRM_PATH}` + }; +} + +export function isRootWellKnownUrl(url: string): boolean { + try { + return new URL(url).pathname === WELL_KNOWN_PRM_PATH; + } catch { + return false; + } +} + +/** + * RFC 9728 §3.3: `resource` must equal the identifier the well-known suffix + * was inserted into. For a document served at the root well-known URL that + * identifier is the origin; the MCP endpoint is accepted there too, matching + * what MCP clients such as the TypeScript SDK accept. + */ +export function acceptedResources( + resourceUrl: string, + metadataUrl: string +): string[] { + if (!isRootWellKnownUrl(metadataUrl)) return [resourceUrl]; + const { origin } = new URL(resourceUrl); + return [resourceUrl, origin, `${origin}/`]; +} + +export interface MetadataFetch { + /** URL first requested. */ + requestedUrl: string; + /** URL that answered, after any redirects. */ + url: string; + statusCode: number; + contentType: string | undefined; + text: string; + /** Parsed body when it is a JSON object, whatever the Content-Type. */ + doc: Record | undefined; + /** Transport failure; the other fields describe nothing. */ + error?: string; +} + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const MAX_REDIRECTS = 3; + +/** RFC 9728 §3.2: `application/json`, media-type parameters allowed. */ +export function isJsonContentType(value: string | undefined): boolean { + if (!value) return false; + return value.split(';')[0].trim().toLowerCase() === 'application/json'; +} + +export async function fetchMetadata( + url: string, + options: RequestOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS; + let current = url; + for (let hop = 0; ; hop++) { + let statusCode: number; + let headers: Record; + let text = ''; + try { + ({ statusCode, headers, text } = await withDeadline( + timeoutMs, + async (signal) => { + const res = await request(current, { + method: 'GET', + headers: { accept: 'application/json' }, + signal + }); + return { + statusCode: res.statusCode, + headers: res.headers, + text: await res.body.text() + }; + } + )); + } catch (e) { + return { + requestedUrl: url, + url: current, + statusCode: 0, + contentType: undefined, + text: '', + doc: undefined, + error: e instanceof Error ? e.message : String(e) + }; + } + const location = singleHeader(headers.location); + if (REDIRECT_STATUSES.has(statusCode) && location && hop < MAX_REDIRECTS) { + try { + current = new URL(location, current).href; + continue; + } catch { + // Fall through and report the redirect as the final response. + } + } + let doc: Record | undefined; + try { + const parsed: unknown = JSON.parse(text); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + doc = parsed as Record; + } + } catch { + doc = undefined; + } + return { + requestedUrl: url, + url: current, + statusCode, + contentType: singleHeader(headers['content-type']), + text, + doc + }; + } +} + +export type MetadataSource = + | 'resource_metadata' + | 'well-known-path' + | 'well-known-root'; + +export interface MetadataAttempt extends MetadataFetch { + source: MetadataSource; +} + +export interface MetadataDiscovery { + /** `resource_metadata` from the challenge, resolved against the server URL. */ + challengeUrl: string | undefined; + /** First attempt that answered 200, in client discovery order. */ + found: MetadataAttempt | undefined; + attempts: MetadataAttempt[]; +} + +/** + * Discover the metadata in the order MCP clients use: the challenge's + * `resource_metadata` URL when present, then the path-based well-known URL, + * then the root one. Every URL tried is recorded so a caller can grade the + * challenge URL on its own and still find a document to grade when the + * challenge URL is broken. A URL is fetched once even when two sources name + * it, the common case being a challenge that points at the well-known URL. + */ +export async function discoverProtectedResourceMetadata( + resourceUrl: string, + wwwAuthenticate: string | undefined, + options: RequestOptions = {} +): Promise { + const attempts: MetadataAttempt[] = []; + const candidates: Array<{ source: MetadataSource; url: string }> = []; + const seen = new Set(); + const add = (source: MetadataSource, url: string) => { + if (seen.has(url)) return; + seen.add(url); + candidates.push({ source, url }); + }; + + let challengeUrl: string | undefined; + const param = challengeParam(wwwAuthenticate, 'resource_metadata'); + if (param !== undefined && param.length > 0) { + try { + challengeUrl = new URL(param, resourceUrl).href; + add('resource_metadata', challengeUrl); + } catch { + challengeUrl = param; + } + } + + const { pathBased, root } = wellKnownUrls(resourceUrl); + add('well-known-path', pathBased); + add('well-known-root', root); + + let found: MetadataAttempt | undefined; + for (const candidate of candidates) { + const attempt = { + source: candidate.source, + ...(await fetchMetadata(candidate.url, options)) + }; + attempts.push(attempt); + if (attempt.statusCode === 200) { + found = attempt; + break; + } + } + + return { challengeUrl, found, attempts }; +} diff --git a/src/scenarios/server/auth/protected-resource-metadata.test.ts b/src/scenarios/server/auth/protected-resource-metadata.test.ts new file mode 100644 index 00000000..0c6e01af --- /dev/null +++ b/src/scenarios/server/auth/protected-resource-metadata.test.ts @@ -0,0 +1,480 @@ +import { spawn, ChildProcess } from 'child_process'; +import { createServer as createHttpServer, type Server } from 'http'; +import { createServer } from 'net'; +import path from 'path'; +import { testContext } from '../../../connection/testing'; +import { + ProtectedResourceMetadataScenario, + PRM_DECLARED_CHECK_IDS +} from './protected-resource-metadata'; +import { + acceptedResources, + parseChallenges, + REQUEST_TIMEOUT_MS, + wellKnownUrls +} from './prm'; +import type { ConformanceCheck } from '../../../types'; + +const FIXTURE = path.join( + process.cwd(), + 'examples/servers/typescript/auth-prm-server.ts' +); + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +function startServer( + port: number, + env: Record +): Promise { + return new Promise((resolve, reject) => { + const isWindows = process.platform === 'win32'; + const proc = spawn('npx', ['tsx', FIXTURE], { + env: { ...process.env, PORT: port.toString(), ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: isWindows + }); + let stderr = ''; + proc.stderr?.on('data', (d) => (stderr += d.toString())); + const timeout = setTimeout(() => { + proc.kill('SIGKILL'); + reject( + new Error(`Server ${FIXTURE} failed to start within 30s: ${stderr}`) + ); + }, 30000); + proc.stdout?.on('data', (data) => { + if (data.toString().includes('running on')) { + clearTimeout(timeout); + resolve(proc); + } + }); + proc.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +function stopServer(proc: ChildProcess | null): Promise { + return new Promise((resolve) => { + if (!proc || proc.killed) return resolve(); + const t = setTimeout(() => { + proc.kill('SIGKILL'); + resolve(); + }, 5000); + proc.once('exit', () => { + clearTimeout(t); + resolve(); + }); + proc.kill('SIGTERM'); + }); +} + +async function runAgainst( + breakMode: string, + specVersion?: '2025-06-18' | '2025-11-25' | '2026-07-28' +): Promise { + const port = await freePort(); + const proc = await startServer(port, { PRM_BREAK: breakMode }); + try { + return await new ProtectedResourceMetadataScenario().run( + testContext(`http://localhost:${port}/mcp`, specVersion) + ); + } finally { + await stopServer(proc); + } +} + +function byId(checks: ConformanceCheck[], id: string): ConformanceCheck { + const found = checks.filter((c) => c.id === id); + expect(found, id).toHaveLength(1); + return found[0]; +} + +// server-www-authenticate-scope is not graded at 2025-06-18. +const SKIPPED_AT_2025_06_18 = ['server-www-authenticate-scope']; + +function expectAllPass( + checks: ConformanceCheck[], + skipped: string[] = [] +): void { + expect(checks.map((c) => c.id).sort()).toEqual( + [...PRM_DECLARED_CHECK_IDS].sort() + ); + for (const check of checks) { + expect(check.status, `${check.id}: ${check.errorMessage}`).toBe( + skipped.includes(check.id) ? 'SKIPPED' : 'SUCCESS' + ); + } +} + +function expectOnlyFailing( + checks: ConformanceCheck[], + id: string, + severity: 'FAILURE' | 'WARNING', + skipped: string[] = [] +): ConformanceCheck { + expect(checks.map((c) => c.id).sort()).toEqual( + [...PRM_DECLARED_CHECK_IDS].sort() + ); + for (const check of checks) { + if (check.id === id) continue; + expect(check.status, `${check.id}: ${check.errorMessage}`).toBe( + skipped.includes(check.id) ? 'SKIPPED' : 'SUCCESS' + ); + } + const failing = byId(checks, id); + expect(failing.status).toBe(severity); + expect(failing.details?.untestable).toBeUndefined(); + return failing; +} + +describe('auth/protected-resource-metadata', () => { + describe('compliant fixture', () => { + it('passes every check at the latest dated version', async () => { + expectAllPass(await runAgainst('none')); + }, 40000); + + it('passes every check at the draft version', async () => { + expectAllPass(await runAgainst('none', '2026-07-28')); + }, 40000); + + it('passes every check at 2025-06-18, scope check skipped', async () => { + const checks = await runAgainst('none', '2025-06-18'); + expectAllPass(checks, SKIPPED_AT_2025_06_18); + const scope = byId(checks, 'server-www-authenticate-scope'); + expect(scope.errorMessage).toContain('2025-11-25'); + expect(scope.details?.untestable).toBeUndefined(); + }, 40000); + + it('fetches a challenge URL that equals the well-known URL once', async () => { + const checks = await runAgainst('none'); + expectAllPass(checks); + const attempts = byId(checks, 'server-prm-discovery').details + ?.attempts as Array<{ source: string; url: string }>; + expect(attempts).toHaveLength(1); + expect(attempts[0].source).toBe('resource_metadata'); + }, 40000); + + it('accepts the origin as resource for metadata at the root well-known URL', async () => { + const checks = await runAgainst('root-well-known'); + expectAllPass(checks); + expect(byId(checks, 'server-prm-resource').details?.accepted).toContain( + byId(checks, 'server-prm-resource').details?.resource + ); + }, 40000); + }); + + describe('well-known only', () => { + it('passes at 2025-11-25 via the path-based well-known URL', async () => { + const checks = await runAgainst('well-known-only', '2025-11-25'); + expectAllPass(checks); + const attempts = byId(checks, 'server-prm-discovery').details + ?.attempts as Array<{ source: string; statusCode: number }>; + expect(attempts[0]).toMatchObject({ + source: 'well-known-path', + statusCode: 200 + }); + }, 40000); + + it('fails discovery at 2025-06-18, which requires the challenge URL', async () => { + const checks = await runAgainst('well-known-only', '2025-06-18'); + const failing = expectOnlyFailing( + checks, + 'server-prm-discovery', + 'FAILURE', + SKIPPED_AT_2025_06_18 + ); + expect(failing.errorMessage).toContain('2025-06-18'); + }, 40000); + }); + + describe('break modes', () => { + it('no-prm fails discovery and leaves the document checks untestable', async () => { + const checks = await runAgainst('no-prm'); + const discovery = byId(checks, 'server-prm-discovery'); + expect(discovery.status).toBe('FAILURE'); + expect(discovery.details?.untestable).toBeUndefined(); + expect(discovery.errorMessage).toContain('no resource_metadata'); + for (const id of [ + 'server-prm-response', + 'server-prm-resource', + 'server-prm-authorization-servers' + ]) { + const check = byId(checks, id); + expect(check.status, id).toBe('FAILURE'); + expect(check.errorMessage, id).toContain('Not testable:'); + expect(check.details, id).toMatchObject({ untestable: true }); + } + expect(byId(checks, 'server-auth-401').status).toBe('SUCCESS'); + expect(byId(checks, 'server-www-authenticate-scope').status).toBe( + 'SUCCESS' + ); + }, 40000); + + it('challenge-404 fails discovery even though the well-known document works', async () => { + const failing = expectOnlyFailing( + await runAgainst('challenge-404'), + 'server-prm-discovery', + 'FAILURE' + ); + expect(failing.errorMessage).toContain('/missing-prm'); + expect(failing.errorMessage).toContain('HTTP 404'); + }, 40000); + + it('wrong-content-type fails the response check', async () => { + const failing = expectOnlyFailing( + await runAgainst('wrong-content-type'), + 'server-prm-response', + 'FAILURE' + ); + expect(failing.errorMessage).toContain('text/html'); + }, 40000); + + it('wrong-resource fails the resource check', async () => { + const failing = expectOnlyFailing( + await runAgainst('wrong-resource'), + 'server-prm-resource', + 'FAILURE' + ); + expect(failing.errorMessage).toContain('https://attacker.example/mcp'); + }, 40000); + + it('missing-resource fails the resource check', async () => { + const failing = expectOnlyFailing( + await runAgainst('missing-resource'), + 'server-prm-resource', + 'FAILURE' + ); + expect(failing.errorMessage).toContain('resource is missing'); + }, 40000); + + it('missing-authorization-servers fails the authorization_servers check', async () => { + const failing = expectOnlyFailing( + await runAgainst('missing-authorization-servers'), + 'server-prm-authorization-servers', + 'FAILURE' + ); + expect(failing.errorMessage).toContain('missing'); + }, 40000); + + it('empty-authorization-servers fails the authorization_servers check', async () => { + const failing = expectOnlyFailing( + await runAgainst('empty-authorization-servers'), + 'server-prm-authorization-servers', + 'FAILURE' + ); + expect(failing.errorMessage).toContain('empty'); + }, 40000); + + it('status-403 fails the 401 check', async () => { + const failing = expectOnlyFailing( + await runAgainst('status-403'), + 'server-auth-401', + 'FAILURE' + ); + expect(failing.errorMessage).toContain('got 403'); + }, 40000); + + it('no-scope warns on the scope check', async () => { + const failing = expectOnlyFailing( + await runAgainst('no-scope'), + 'server-www-authenticate-scope', + 'WARNING' + ); + expect(failing.errorMessage).toContain('scope'); + }, 40000); + + it('no-auth makes every check untestable, never SKIPPED', async () => { + const checks = await runAgainst('no-auth'); + expect(checks.map((c) => c.id).sort()).toEqual( + [...PRM_DECLARED_CHECK_IDS].sort() + ); + for (const check of checks) { + expect(check.status, check.id).toBe( + check.id === 'server-www-authenticate-scope' ? 'WARNING' : 'FAILURE' + ); + expect(check.errorMessage, check.id).toContain('Not testable:'); + expect(check.details, check.id).toMatchObject({ untestable: true }); + } + expect(checks.every((c) => c.status !== 'SKIPPED')).toBe(true); + }, 40000); + }); +}); + +describe('timeouts', () => { + // A server that never ends its response must not hang the suite: each + // request is bounded to REQUEST_TIMEOUT_MS (10 s) and the scenario reports + // the timeout instead of waiting. + let server: Server; + let url: string; + let mode: 'probe-hangs' | 'metadata-hangs'; + + beforeAll(async () => { + server = createHttpServer((req, res) => { + if (req.method === 'POST' && req.url === '/mcp') { + if (mode === 'probe-hangs') { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write(': open\n\n'); + return; // never ends + } + res.writeHead(401, { + 'www-authenticate': `Bearer resource_metadata="${url}/prm", scope="mcp:read"` + }); + res.end('{"error":"unauthorized"}'); + return; + } + if (req.method === 'GET' && req.url === '/prm') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.write('{"resource":'); + return; // never ends + } + res.writeHead(404).end(); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + url = `http://127.0.0.1:${port}`; + }); + + afterAll(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('reports every check untestable when the probe response never ends', async () => { + mode = 'probe-hangs'; + const started = Date.now(); + const checks = await new ProtectedResourceMetadataScenario().run( + testContext(`${url}/mcp`) + ); + expect(Date.now() - started).toBeLessThan(15000); + expect(checks.map((c) => c.id).sort()).toEqual( + [...PRM_DECLARED_CHECK_IDS].sort() + ); + for (const check of checks) { + expect(check.errorMessage, check.id).toContain('Not testable:'); + expect(check.errorMessage, check.id).toContain( + `timed out after ${REQUEST_TIMEOUT_MS} ms` + ); + } + }, 20000); + + it('fails discovery when the metadata body never ends', async () => { + mode = 'metadata-hangs'; + const started = Date.now(); + const checks = await new ProtectedResourceMetadataScenario().run( + testContext(`${url}/mcp`) + ); + expect(Date.now() - started).toBeLessThan(15000); + expect(byId(checks, 'server-auth-401').status).toBe('SUCCESS'); + const discovery = byId(checks, 'server-prm-discovery'); + expect(discovery.status).toBe('FAILURE'); + expect(discovery.details?.untestable).toBeUndefined(); + expect(discovery.errorMessage).toContain( + `timed out after ${REQUEST_TIMEOUT_MS} ms` + ); + }, 20000); +}); + +describe('prm helpers', () => { + it('keeps the Bearer challenge after a token68 challenge', () => { + expect( + parseChallenges( + 'Negotiate abc==, Bearer resource_metadata="https://x.example/.well-known/oauth-protected-resource"' + ) + ).toEqual([ + { scheme: 'Negotiate', params: {} }, + { + scheme: 'Bearer', + params: { + resource_metadata: + 'https://x.example/.well-known/oauth-protected-resource' + } + } + ]); + }); + + it('reads an unquoted resource_metadata value up to the next comma or space', () => { + expect( + parseChallenges( + 'Bearer resource_metadata=https://x.example/.well-known/oauth-protected-resource/mcp, scope=mcp:read' + ) + ).toEqual([ + { + scheme: 'Bearer', + params: { + resource_metadata: + 'https://x.example/.well-known/oauth-protected-resource/mcp', + scope: 'mcp:read' + } + } + ]); + }); + + it('parses multiple challenges with quoted and bare params', () => { + const challenges = parseChallenges( + 'Bearer realm="mcp", scope="a b", resource_metadata="https://x.example/.well-known/oauth-protected-resource/mcp", DPoP algs=ES256' + ); + expect(challenges).toEqual([ + { + scheme: 'Bearer', + params: { + realm: 'mcp', + scope: 'a b', + resource_metadata: + 'https://x.example/.well-known/oauth-protected-resource/mcp' + } + }, + { scheme: 'DPoP', params: { algs: 'ES256' } } + ]); + }); + + it('unescapes quoted-pair characters', () => { + expect(parseChallenges('Bearer error_description="say \\"hi\\""')).toEqual([ + { scheme: 'Bearer', params: { error_description: 'say "hi"' } } + ]); + }); + + it('derives the path-based well-known URL before the root one', () => { + expect(wellKnownUrls('http://localhost:9/public/mcp')).toEqual({ + pathBased: + 'http://localhost:9/.well-known/oauth-protected-resource/public/mcp', + root: 'http://localhost:9/.well-known/oauth-protected-resource' + }); + expect(wellKnownUrls('http://localhost:9/')).toEqual({ + pathBased: 'http://localhost:9/.well-known/oauth-protected-resource', + root: 'http://localhost:9/.well-known/oauth-protected-resource' + }); + }); + + it('accepts the origin only for a document at the root well-known URL', () => { + expect( + acceptedResources( + 'http://localhost:9/mcp', + 'http://localhost:9/.well-known/oauth-protected-resource' + ) + ).toEqual([ + 'http://localhost:9/mcp', + 'http://localhost:9', + 'http://localhost:9/' + ]); + expect( + acceptedResources( + 'http://localhost:9/mcp', + 'http://localhost:9/.well-known/oauth-protected-resource/mcp' + ) + ).toEqual(['http://localhost:9/mcp']); + }); +}); diff --git a/src/scenarios/server/auth/protected-resource-metadata.ts b/src/scenarios/server/auth/protected-resource-metadata.ts new file mode 100644 index 00000000..150cf399 --- /dev/null +++ b/src/scenarios/server/auth/protected-resource-metadata.ts @@ -0,0 +1,433 @@ +/** + * Server-side Protected Resource Metadata discovery (RFC 9728). + * + * The harness plays an MCP client with no token: one unauthenticated POST to + * the MCP endpoint, then metadata discovery in client order. Six checks come + * from those few requests. A server that answers the probe with 2xx is not + * configured for authorization, so every check is reported untestable (#248). + */ + +import { + ClientScenario, + ConformanceCheck, + type SpecReference +} from '../../../types'; +import type { RunContext } from '../../../connection'; +import { terminateSessionRaw } from '../../../connection/sdk-client'; +import { untestableCheck } from '../../untestable'; +import { SpecReferences } from './spec-references'; +import { + acceptedResources, + challengeParam, + discoverProtectedResourceMetadata, + isJsonContentType, + probeUnauthenticated, + type MetadataAttempt, + type MetadataDiscovery, + type UnauthenticatedProbe +} from './prm'; + +const CHALLENGE_REFERENCES: SpecReference[] = [ + SpecReferences.MCP_AUTH_ERROR_HANDLING, + SpecReferences.RFC_9728_WWW_AUTHENTICATE +]; + +const DISCOVERY_REFERENCES: SpecReference[] = [ + SpecReferences.MCP_PRM_DISCOVERY_REQUIREMENTS, + SpecReferences.RFC_9728_WWW_AUTHENTICATE +]; + +const RESPONSE_REFERENCES: SpecReference[] = [ + SpecReferences.MCP_AUTHORIZATION_SERVER_LOCATION, + SpecReferences.RFC_9728_RESPONSE +]; + +const RESOURCE_REFERENCES: SpecReference[] = [ + SpecReferences.MCP_AUTHORIZATION_SERVER_LOCATION, + SpecReferences.RFC_9728_VALIDATION +]; + +const AUTHORIZATION_SERVERS_REFERENCES: SpecReference[] = [ + SpecReferences.MCP_AUTHORIZATION_SERVER_LOCATION +]; + +const SCOPE_REFERENCES: SpecReference[] = [ + SpecReferences.MCP_SCOPE_SELECTION_STRATEGY +]; + +interface CheckSpec { + id: string; + name: string; + description: string; + specReferences: SpecReference[]; + severity: 'FAILURE' | 'WARNING'; +} + +const CHECKS = { + auth401: { + id: 'server-auth-401', + name: 'ServerAuth401', + description: + 'Server answers an unauthenticated request with 401 and a WWW-Authenticate header', + specReferences: CHALLENGE_REFERENCES, + severity: 'FAILURE' + }, + discovery: { + id: 'server-prm-discovery', + name: 'ServerPrmDiscovery', + description: + 'Protected resource metadata is reachable via the resource_metadata challenge parameter or a well-known URI', + specReferences: DISCOVERY_REFERENCES, + severity: 'FAILURE' + }, + response: { + id: 'server-prm-response', + name: 'ServerPrmResponse', + description: + 'Protected resource metadata is served as HTTP 200 application/json with a JSON object body', + specReferences: RESPONSE_REFERENCES, + severity: 'FAILURE' + }, + resource: { + id: 'server-prm-resource', + name: 'ServerPrmResource', + description: + 'Protected resource metadata resource field identifies the MCP server', + specReferences: RESOURCE_REFERENCES, + severity: 'FAILURE' + }, + authorizationServers: { + id: 'server-prm-authorization-servers', + name: 'ServerPrmAuthorizationServers', + description: + 'Protected resource metadata lists at least one authorization server', + specReferences: AUTHORIZATION_SERVERS_REFERENCES, + severity: 'FAILURE' + }, + scope: { + id: 'server-www-authenticate-scope', + name: 'ServerWwwAuthenticateScope', + description: + 'The 401 challenge includes a scope parameter (servers SHOULD include one)', + specReferences: SCOPE_REFERENCES, + severity: 'WARNING' + } +} as const satisfies Record; + +export const PRM_DECLARED_CHECK_IDS: string[] = Object.values(CHECKS).map( + (check) => check.id +); + +function check( + spec: CheckSpec, + errorMessage: string | undefined, + details: Record +): ConformanceCheck { + return { + id: spec.id, + name: spec.name, + description: spec.description, + status: errorMessage === undefined ? 'SUCCESS' : spec.severity, + timestamp: new Date().toISOString(), + specReferences: spec.specReferences, + details, + ...(errorMessage === undefined ? {} : { errorMessage }) + }; +} + +function untestable(spec: CheckSpec, reason: string): ConformanceCheck { + return untestableCheck( + spec.id, + spec.name, + spec.description, + reason, + spec.specReferences, + spec.severity + ); +} + +function attemptSummary(attempt: MetadataAttempt): Record { + return { + source: attempt.source, + url: attempt.url, + ...(attempt.url === attempt.requestedUrl + ? {} + : { requestedUrl: attempt.requestedUrl }), + statusCode: attempt.statusCode, + contentType: attempt.contentType ?? null, + ...(attempt.error ? { error: attempt.error } : {}) + }; +} + +function describeMiss(attempt: MetadataAttempt): string { + if (attempt.error) return `${attempt.url} failed: ${attempt.error}`; + return `${attempt.url} answered HTTP ${attempt.statusCode}`; +} + +function gradeDiscovery( + discovery: MetadataDiscovery, + specVersion: string +): ConformanceCheck { + const { challengeUrl, found, attempts } = discovery; + const details = { + specVersion, + resourceMetadata: challengeUrl ?? null, + attempts: attempts.map(attemptSummary) + }; + const challengeAttempt = attempts.find( + (attempt) => attempt.source === 'resource_metadata' + ); + + if (challengeUrl !== undefined) { + if (!challengeAttempt) { + return check( + CHECKS.discovery, + `resource_metadata in the WWW-Authenticate challenge is not a URL: ${challengeUrl}`, + details + ); + } + if (challengeAttempt.statusCode !== 200) { + return check( + CHECKS.discovery, + `the WWW-Authenticate challenge names ${challengeUrl} but ${describeMiss(challengeAttempt)}`, + details + ); + } + return check(CHECKS.discovery, undefined, details); + } + + // 2025-06-18 only knows the header form; "one of" header or well-known + // arrived in 2025-11-25. + if (specVersion === '2025-06-18') { + return check( + CHECKS.discovery, + 'the 401 challenge carries no resource_metadata parameter; spec version 2025-06-18 requires the metadata URL in WWW-Authenticate', + details + ); + } + if (!found) { + return check( + CHECKS.discovery, + `no resource_metadata parameter in the challenge and no metadata at ${attempts + .map(describeMiss) + .join(' or ')}`, + details + ); + } + return check(CHECKS.discovery, undefined, details); +} + +function gradeResponse(found: MetadataAttempt): ConformanceCheck { + const details = { + ...attemptSummary(found), + bodyPreview: found.text.slice(0, 200) + }; + if (!isJsonContentType(found.contentType)) { + return check( + CHECKS.response, + `Content-Type is ${found.contentType ?? 'missing'}, not application/json`, + details + ); + } + if (!found.doc) { + return check(CHECKS.response, 'body is not a JSON object', details); + } + return check(CHECKS.response, undefined, details); +} + +function gradeResource( + doc: Record, + resourceUrl: string, + metadataUrl: string +): ConformanceCheck { + const allowed = acceptedResources(resourceUrl, metadataUrl); + const details = { resource: doc.resource ?? null, accepted: allowed }; + if (doc.resource === undefined) { + return check( + CHECKS.resource, + `resource is missing (expected ${allowed.join(' or ')})`, + details + ); + } + if (typeof doc.resource !== 'string' || !allowed.includes(doc.resource)) { + return check( + CHECKS.resource, + `resource ${JSON.stringify(doc.resource)} does not identify the server (expected ${allowed.join(' or ')})`, + details + ); + } + return check(CHECKS.resource, undefined, details); +} + +function gradeAuthorizationServers( + doc: Record +): ConformanceCheck { + const servers = doc.authorization_servers; + const details = { authorization_servers: servers ?? null }; + if (servers === undefined) { + return check( + CHECKS.authorizationServers, + 'authorization_servers is missing', + details + ); + } + if (!Array.isArray(servers)) { + return check( + CHECKS.authorizationServers, + 'authorization_servers is not an array', + details + ); + } + if (servers.length === 0) { + return check( + CHECKS.authorizationServers, + 'authorization_servers is empty', + details + ); + } + const invalid = servers.filter((entry) => { + if (typeof entry !== 'string') return true; + try { + new URL(entry); + return false; + } catch { + return true; + } + }); + if (invalid.length > 0) { + return check( + CHECKS.authorizationServers, + `authorization_servers contains entries that are not URLs: ${JSON.stringify(invalid)}`, + details + ); + } + return check(CHECKS.authorizationServers, undefined, details); +} + +function gradeScope( + probe: UnauthenticatedProbe, + specVersion: string +): ConformanceCheck { + const details = { wwwAuthenticate: probe.wwwAuthenticate ?? null }; + // The SHOULD first appears in 2025-11-25 ("Scope Selection Strategy"); + // 2025-06-18 says nothing about scope in the challenge, so the check is + // not applicable there rather than untestable. + if (specVersion === '2025-06-18') { + return { + ...check(CHECKS.scope, undefined, { ...details, specVersion }), + status: 'SKIPPED', + errorMessage: + 'not graded at spec version 2025-06-18: the scope parameter SHOULD first appears in 2025-11-25' + }; + } + if (!probe.wwwAuthenticate) { + return check( + CHECKS.scope, + 'no WWW-Authenticate header to carry a scope parameter', + details + ); + } + const scope = challengeParam(probe.wwwAuthenticate, 'scope'); + if (scope === undefined || scope.trim().length === 0) { + return check( + CHECKS.scope, + 'WWW-Authenticate challenge has no scope parameter', + details + ); + } + return check(CHECKS.scope, undefined, { ...details, scope }); +} + +export class ProtectedResourceMetadataScenario implements ClientScenario { + name = 'auth/protected-resource-metadata'; + readonly source = { introducedIn: '2025-06-18' } as const; + description = `Test that an MCP server which requires authorization publishes usable Protected Resource Metadata (RFC 9728). + +The harness sends one unauthenticated request to the MCP endpoint, then looks for the metadata the way a client does: the \`resource_metadata\` URL from the \`WWW-Authenticate\` challenge, then the path-based well-known URL, then the root one. + +**Server Implementation Requirements:** +- An unauthenticated request **MUST** get \`401 Unauthorized\` with a \`WWW-Authenticate\` header +- The metadata **MUST** be discoverable: via \`resource_metadata\` in the challenge, or at a well-known URI (2025-11-25 and later; 2025-06-18 requires the challenge parameter) +- The metadata response **MUST** be HTTP 200 \`application/json\` with a JSON object body (RFC 9728 §3.2) +- \`resource\` **MUST** identify the MCP server (RFC 9728 §3.3); for metadata at the root well-known URL the origin is accepted +- \`authorization_servers\` **MUST** contain at least one authorization server +- The challenge **SHOULD** include a \`scope\` parameter (2025-11-25 and later; SKIPPED at 2025-06-18) + +**Prerequisite:** the server under test must require authorization. A 2xx to the unauthenticated request makes every check untestable. Each request is bounded to 10 s; a server that holds a stream open fails the checks it blocks with a reason naming the timeout.`; + + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; + const all = Object.values(CHECKS); + + let probe: UnauthenticatedProbe; + try { + probe = await probeUnauthenticated(serverUrl, specVersion); + } catch (error) { + const reason = `unauthenticated probe to ${serverUrl} failed: ${error instanceof Error ? error.message : String(error)}`; + return all.map((spec) => untestable(spec, reason)); + } + if (probe.sessionId !== undefined) { + // The probe was accepted and opened a session; do not leave it behind. + await terminateSessionRaw(serverUrl, probe.sessionId, specVersion); + } + + if (probe.statusCode >= 200 && probe.statusCode < 300) { + const reason = `server answered the unauthenticated request with HTTP ${probe.statusCode}; it does not require authorization`; + return all.map((spec) => untestable(spec, reason)); + } + + const checks: ConformanceCheck[] = []; + const probeDetails = { + statusCode: probe.statusCode, + wwwAuthenticate: probe.wwwAuthenticate ?? null + }; + if (probe.statusCode !== 401) { + checks.push( + check( + CHECKS.auth401, + `expected HTTP 401 for a missing token, got ${probe.statusCode}`, + probeDetails + ) + ); + } else if (!probe.wwwAuthenticate) { + checks.push( + check( + CHECKS.auth401, + '401 response has no WWW-Authenticate header', + probeDetails + ) + ); + } else { + checks.push(check(CHECKS.auth401, undefined, probeDetails)); + } + + const discovery = await discoverProtectedResourceMetadata( + serverUrl, + probe.wwwAuthenticate + ); + checks.push(gradeDiscovery(discovery, specVersion)); + + const { found } = discovery; + if (!found) { + const reason = `no protected resource metadata found at ${discovery.attempts + .map(describeMiss) + .join(' or ')}`; + checks.push(untestable(CHECKS.response, reason)); + checks.push(untestable(CHECKS.resource, reason)); + checks.push(untestable(CHECKS.authorizationServers, reason)); + } else { + checks.push(gradeResponse(found)); + if (!found.doc) { + const reason = `metadata at ${found.url} is not a JSON object`; + checks.push(untestable(CHECKS.resource, reason)); + checks.push(untestable(CHECKS.authorizationServers, reason)); + } else { + checks.push(gradeResource(found.doc, serverUrl, found.url)); + checks.push(gradeAuthorizationServers(found.doc)); + } + } + + checks.push(gradeScope(probe, specVersion)); + return checks; + } +} diff --git a/src/scenarios/server/auth/spec-references.ts b/src/scenarios/server/auth/spec-references.ts new file mode 100644 index 00000000..4d7bfd4c --- /dev/null +++ b/src/scenarios/server/auth/spec-references.ts @@ -0,0 +1,32 @@ +import { SpecReference } from '../../../types'; + +export const SpecReferences: { [key: string]: SpecReference } = { + MCP_AUTHORIZATION_SERVER_LOCATION: { + id: 'MCP-Authorization-server-location', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-location' + }, + MCP_PRM_DISCOVERY_REQUIREMENTS: { + id: 'MCP-Protected-resource-metadata-discovery-requirements', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements' + }, + MCP_SCOPE_SELECTION_STRATEGY: { + id: 'MCP-Scope-selection-strategy', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy' + }, + MCP_AUTH_ERROR_HANDLING: { + id: 'MCP-Auth-error-handling', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling' + }, + RFC_9728_RESPONSE: { + id: 'RFC-9728-protected-resource-metadata-response', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2' + }, + RFC_9728_VALIDATION: { + id: 'RFC-9728-protected-resource-metadata-validation', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.3' + }, + RFC_9728_WWW_AUTHENTICATE: { + id: 'RFC-9728-www-authenticate', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-5.1' + } +}; From a9aede28317fb4678f19a2889da1f128d4872773 Mon Sep 17 00:00:00 2001 From: Yusufhan Sacak Date: Sat, 7 Nov 2026 13:14:38 +0000 Subject: [PATCH 2/2] feat(server): check RFC 9728 zero-value parameters in PRM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9728 §3.2: "Parameters with zero values MUST be omitted from the response." scopes_supported: [] passed every check. Add server-prm-no-empty-parameters as its own check, FAILURE per the MUST. It covers the array parameters §2 defines. bearer_methods_supported is left out because §2 allows [] there. Parameters §2 does not define are left out too: the MUST has no carve-out for them, but the harness cannot know what [] means in someone else's parameter, and §3.2 tells readers to ignore what they do not understand. authorization_servers: [] now fails this check as well as the MCP at-least-one rule, since it breaks both. Add empty-scopes-supported and empty-bearer-methods-supported fixture modes, plus in-process tests for the remaining parameters, for falsy non-array values including null, and for non-object bodies. --- examples/servers/typescript/README.md | 32 ++-- .../servers/typescript/auth-prm-server.ts | 7 + .../auth/protected-resource-metadata.test.ts | 165 +++++++++++++++++- .../auth/protected-resource-metadata.ts | 47 ++++- 4 files changed, 230 insertions(+), 21 deletions(-) diff --git a/examples/servers/typescript/README.md b/examples/servers/typescript/README.md index d74691dc..93b094ed 100644 --- a/examples/servers/typescript/README.md +++ b/examples/servers/typescript/README.md @@ -195,21 +195,23 @@ A small auth-enabled server for the `auth/protected-resource-metadata` scenario. `PRM_BREAK` picks one deliberate defect so each check has a negative case. Unknown values exit with the allowed list. -| `PRM_BREAK` | Effect | Expected result | -| ------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | -| `none` (default) | compliant | all checks pass | -| `no-prm` | no `resource_metadata`, no well-known document | `server-prm-discovery` fails, document checks untestable | -| `challenge-404` | `resource_metadata` points at a 404; well-known works | `server-prm-discovery` fails | -| `wrong-content-type` | metadata served as `text/html` | `server-prm-response` fails | -| `wrong-resource` | `resource` names another server | `server-prm-resource` fails | -| `missing-resource` | `resource` omitted | `server-prm-resource` fails | -| `missing-authorization-servers` | `authorization_servers` omitted | `server-prm-authorization-servers` fails | -| `empty-authorization-servers` | `authorization_servers` is `[]` | `server-prm-authorization-servers` fails | -| `status-403` | `403` instead of `401` for a missing token | `server-auth-401` fails | -| `no-scope` | challenge omits `scope` | `server-www-authenticate-scope` warns | -| `well-known-only` | no `resource_metadata`; well-known works | passes at 2025-11-25+, discovery fails at 2025-06-18 | -| `root-well-known` | metadata only at the root URL, `resource` = origin | all checks pass | -| `no-auth` | no authorization at all | every check untestable | +| `PRM_BREAK` | Effect | Expected result | +| -------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `none` (default) | compliant | all checks pass | +| `no-prm` | no `resource_metadata`, no well-known document | `server-prm-discovery` fails, document checks untestable | +| `challenge-404` | `resource_metadata` points at a 404; well-known works | `server-prm-discovery` fails | +| `wrong-content-type` | metadata served as `text/html` | `server-prm-response` fails | +| `wrong-resource` | `resource` names another server | `server-prm-resource` fails | +| `missing-resource` | `resource` omitted | `server-prm-resource` fails | +| `missing-authorization-servers` | `authorization_servers` omitted | `server-prm-authorization-servers` fails | +| `empty-authorization-servers` | `authorization_servers` is `[]` | `server-prm-authorization-servers` and `server-prm-no-empty-parameters` fail | +| `empty-scopes-supported` | `scopes_supported` is `[]` | `server-prm-no-empty-parameters` fails | +| `empty-bearer-methods-supported` | `bearer_methods_supported` is `[]`, allowed by RFC 9728 §2 | all checks pass | +| `status-403` | `403` instead of `401` for a missing token | `server-auth-401` fails | +| `no-scope` | challenge omits `scope` | `server-www-authenticate-scope` warns | +| `well-known-only` | no `resource_metadata`; well-known works | passes at 2025-11-25+, discovery fails at 2025-06-18 | +| `root-well-known` | metadata only at the root URL, `resource` = origin | all checks pass | +| `no-auth` | no authorization at all | every check untestable | ```bash # Run the compliant server diff --git a/examples/servers/typescript/auth-prm-server.ts b/examples/servers/typescript/auth-prm-server.ts index 09f14c2a..712e768e 100644 --- a/examples/servers/typescript/auth-prm-server.ts +++ b/examples/servers/typescript/auth-prm-server.ts @@ -14,6 +14,8 @@ * missing-resource resource omitted * missing-authorization-servers authorization_servers omitted * empty-authorization-servers authorization_servers is [] + * empty-scopes-supported scopes_supported is [] + * empty-bearer-methods-supported bearer_methods_supported is [] (allowed by RFC 9728 §2) * status-403 403 instead of 401 for a missing token * no-scope challenge omits scope * well-known-only no resource_metadata; well-known works @@ -35,6 +37,8 @@ const BREAK_MODES = [ 'missing-resource', 'missing-authorization-servers', 'empty-authorization-servers', + 'empty-scopes-supported', + 'empty-bearer-methods-supported', 'status-403', 'no-scope', 'well-known-only', @@ -67,6 +71,9 @@ function prmDocument(atRoot: boolean): Record { if (BREAK === 'missing-authorization-servers') delete doc.authorization_servers; if (BREAK === 'empty-authorization-servers') doc.authorization_servers = []; + if (BREAK === 'empty-scopes-supported') doc.scopes_supported = []; + if (BREAK === 'empty-bearer-methods-supported') + doc.bearer_methods_supported = []; return doc; } diff --git a/src/scenarios/server/auth/protected-resource-metadata.test.ts b/src/scenarios/server/auth/protected-resource-metadata.test.ts index 0c6e01af..ddce6c28 100644 --- a/src/scenarios/server/auth/protected-resource-metadata.test.ts +++ b/src/scenarios/server/auth/protected-resource-metadata.test.ts @@ -172,6 +172,14 @@ describe('auth/protected-resource-metadata', () => { byId(checks, 'server-prm-resource').details?.resource ); }, 40000); + + it('accepts bearer_methods_supported: [], which RFC 9728 §2 allows', async () => { + const checks = await runAgainst('empty-bearer-methods-supported'); + expectAllPass(checks); + expect( + byId(checks, 'server-prm-response').details?.bodyPreview + ).toContain('"bearer_methods_supported":[]'); + }, 40000); }); describe('well-known only', () => { @@ -208,7 +216,8 @@ describe('auth/protected-resource-metadata', () => { for (const id of [ 'server-prm-response', 'server-prm-resource', - 'server-prm-authorization-servers' + 'server-prm-authorization-servers', + 'server-prm-no-empty-parameters' ]) { const check = byId(checks, id); expect(check.status, id).toBe('FAILURE'); @@ -267,13 +276,33 @@ describe('auth/protected-resource-metadata', () => { expect(failing.errorMessage).toContain('missing'); }, 40000); - it('empty-authorization-servers fails the authorization_servers check', async () => { - const failing = expectOnlyFailing( - await runAgainst('empty-authorization-servers'), + it('empty-authorization-servers fails the MCP rule and RFC 9728 §3.2', async () => { + const checks = await runAgainst('empty-authorization-servers'); + const failing = [ 'server-prm-authorization-servers', + 'server-prm-no-empty-parameters' + ]; + for (const check of checks) { + expect(check.status, check.id).toBe( + failing.includes(check.id) ? 'FAILURE' : 'SUCCESS' + ); + } + expect( + byId(checks, 'server-prm-authorization-servers').errorMessage + ).toContain('empty'); + expect( + byId(checks, 'server-prm-no-empty-parameters').errorMessage + ).toContain('authorization_servers'); + }, 40000); + + it('empty-scopes-supported fails the zero-value check', async () => { + const failing = expectOnlyFailing( + await runAgainst('empty-scopes-supported'), + 'server-prm-no-empty-parameters', 'FAILURE' ); - expect(failing.errorMessage).toContain('empty'); + expect(failing.errorMessage).toContain('scopes_supported'); + expect(failing.details?.empty).toEqual(['scopes_supported']); }, 40000); it('status-403 fails the 401 check', async () => { @@ -388,6 +417,132 @@ describe('timeouts', () => { }, 20000); }); +describe('zero-valued parameters', () => { + // RFC 9728 §3.2 read against documents the fixture has no break mode for. + let server: Server; + let url: string; + let extra: Record; + let rawBody: string | undefined; + + beforeAll(async () => { + server = createHttpServer((req, res) => { + if (req.method === 'POST' && req.url === '/mcp') { + res.writeHead(401, { + 'www-authenticate': `Bearer resource_metadata="${url}/prm", scope="mcp:read"` + }); + res.end('{"error":"unauthorized"}'); + return; + } + if (req.method === 'GET' && req.url === '/prm') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + rawBody ?? + JSON.stringify({ + resource: `${url}/mcp`, + authorization_servers: ['https://auth.example.com'], + ...extra + }) + ); + return; + } + res.writeHead(404).end(); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + url = `http://127.0.0.1:${port}`; + }); + + afterAll(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }); + + async function grade( + parameters: Record + ): Promise { + extra = parameters; + rawBody = undefined; + return new ProtectedResourceMetadataScenario().run( + testContext(`${url}/mcp`) + ); + } + + it.each([ + 'scopes_supported', + 'resource_signing_alg_values_supported', + 'authorization_details_types_supported', + 'dpop_signing_alg_values_supported' + ])('fails on an empty %s', async (parameter) => { + const failing = expectOnlyFailing( + await grade({ [parameter]: [] }), + 'server-prm-no-empty-parameters', + 'FAILURE' + ); + expect(failing.errorMessage).toContain(parameter); + }); + + it('names every empty parameter in one failure', async () => { + const failing = expectOnlyFailing( + await grade({ + scopes_supported: [], + dpop_signing_alg_values_supported: [], + bearer_methods_supported: [] + }), + 'server-prm-no-empty-parameters', + 'FAILURE' + ); + expect(failing.details?.empty).toEqual([ + 'scopes_supported', + 'dpop_signing_alg_values_supported' + ]); + }); + + it.each([ + ['a populated array', { scopes_supported: ['mcp:read'] }], + ['bearer_methods_supported: []', { bearer_methods_supported: [] }], + ['a single-valued parameter that is empty', { resource_name: '' }], + ['a boolean set to false', { dpop_bound_access_tokens_required: false }], + ['an empty parameter it does not understand', { example_extension: [] }], + // null is a type problem, not zero values, and nothing here checks types. + ['scopes_supported: null', { scopes_supported: null }], + ['a listed parameter set to an empty string', { scopes_supported: '' }], + ['a listed parameter set to false', { scopes_supported: false }], + ['a listed parameter set to 0', { scopes_supported: 0 }] + ])('passes on %s', async (_name, parameters) => { + expectAllPass(await grade(parameters)); + }); + + it.each([ + ['a JSON array', '[]'], + ['JSON null', 'null'], + ['invalid JSON', '{'] + ])('leaves the document checks untestable on %s', async (_name, body) => { + rawBody = body; + const checks = await new ProtectedResourceMetadataScenario().run( + testContext(`${url}/mcp`) + ); + expect(checks.map((c) => c.id).sort()).toEqual( + [...PRM_DECLARED_CHECK_IDS].sort() + ); + const response = byId(checks, 'server-prm-response'); + expect(response.status).toBe('FAILURE'); + expect(response.details?.untestable).toBeUndefined(); + for (const id of [ + 'server-prm-resource', + 'server-prm-authorization-servers', + 'server-prm-no-empty-parameters' + ]) { + const check = byId(checks, id); + expect(check.status, id).toBe('FAILURE'); + expect(check.errorMessage, id).toContain('Not testable:'); + expect(check.details, id).toMatchObject({ untestable: true }); + } + }); +}); + describe('prm helpers', () => { it('keeps the Bearer challenge after a token68 challenge', () => { expect( diff --git a/src/scenarios/server/auth/protected-resource-metadata.ts b/src/scenarios/server/auth/protected-resource-metadata.ts index 150cf399..3f68c3e0 100644 --- a/src/scenarios/server/auth/protected-resource-metadata.ts +++ b/src/scenarios/server/auth/protected-resource-metadata.ts @@ -2,7 +2,7 @@ * Server-side Protected Resource Metadata discovery (RFC 9728). * * The harness plays an MCP client with no token: one unauthenticated POST to - * the MCP endpoint, then metadata discovery in client order. Six checks come + * the MCP endpoint, then metadata discovery in client order. Seven checks come * from those few requests. A server that answers the probe with 2xx is not * configured for authorization, so every check is reported untestable (#248). */ @@ -55,6 +55,21 @@ const SCOPE_REFERENCES: SpecReference[] = [ SpecReferences.MCP_SCOPE_SELECTION_STRATEGY ]; +// The array parameters RFC 9728 §2 defines that §3.2 ("Parameters with zero +// values MUST be omitted from the response") applies to. Left out on purpose: +// bearer_methods_supported, because §2 says "The empty array [] can be used to +// indicate that no bearer methods are supported"; and any parameter §2 does +// not define. The MUST has no carve-out for those, but the harness cannot know +// what [] means in someone else's parameter, and §3.2 tells readers to ignore +// parameters they do not understand. +const ZERO_VALUE_PARAMETERS = [ + 'authorization_servers', + 'scopes_supported', + 'resource_signing_alg_values_supported', + 'authorization_details_types_supported', + 'dpop_signing_alg_values_supported' +]; + interface CheckSpec { id: string; name: string; @@ -104,6 +119,14 @@ const CHECKS = { specReferences: AUTHORIZATION_SERVERS_REFERENCES, severity: 'FAILURE' }, + noEmptyParameters: { + id: 'server-prm-no-empty-parameters', + name: 'ServerPrmNoEmptyParameters', + description: + 'Protected resource metadata omits array parameters with zero values; bearer_methods_supported may be empty', + specReferences: RESPONSE_REFERENCES, + severity: 'FAILURE' + }, scope: { id: 'server-www-authenticate-scope', name: 'ServerWwwAuthenticateScope', @@ -304,6 +327,24 @@ function gradeAuthorizationServers( return check(CHECKS.authorizationServers, undefined, details); } +function gradeNoEmptyParameters( + doc: Record +): ConformanceCheck { + const empty = ZERO_VALUE_PARAMETERS.filter((parameter) => { + const value = doc[parameter]; + return Array.isArray(value) && value.length === 0; + }); + const details = { empty }; + if (empty.length > 0) { + return check( + CHECKS.noEmptyParameters, + `parameters with zero values must be omitted, found empty: ${empty.join(', ')}`, + details + ); + } + return check(CHECKS.noEmptyParameters, undefined, details); +} + function gradeScope( probe: UnauthenticatedProbe, specVersion: string @@ -351,6 +392,7 @@ The harness sends one unauthenticated request to the MCP endpoint, then looks fo - The metadata response **MUST** be HTTP 200 \`application/json\` with a JSON object body (RFC 9728 §3.2) - \`resource\` **MUST** identify the MCP server (RFC 9728 §3.3); for metadata at the root well-known URL the origin is accepted - \`authorization_servers\` **MUST** contain at least one authorization server +- Array parameters with zero values **MUST** be omitted (RFC 9728 §3.2); \`bearer_methods_supported\` is exempt, RFC 9728 §2 allows \`[]\` there - The challenge **SHOULD** include a \`scope\` parameter (2025-11-25 and later; SKIPPED at 2025-06-18) **Prerequisite:** the server under test must require authorization. A 2xx to the unauthenticated request makes every check untestable. Each request is bounded to 10 s; a server that holds a stream open fails the checks it blocks with a reason naming the timeout.`; @@ -415,15 +457,18 @@ The harness sends one unauthenticated request to the MCP endpoint, then looks fo checks.push(untestable(CHECKS.response, reason)); checks.push(untestable(CHECKS.resource, reason)); checks.push(untestable(CHECKS.authorizationServers, reason)); + checks.push(untestable(CHECKS.noEmptyParameters, reason)); } else { checks.push(gradeResponse(found)); if (!found.doc) { const reason = `metadata at ${found.url} is not a JSON object`; checks.push(untestable(CHECKS.resource, reason)); checks.push(untestable(CHECKS.authorizationServers, reason)); + checks.push(untestable(CHECKS.noEmptyParameters, reason)); } else { checks.push(gradeResource(found.doc, serverUrl, found.url)); checks.push(gradeAuthorizationServers(found.doc)); + checks.push(gradeNoEmptyParameters(found.doc)); } }