[scenario] authz metadata discovery tests: compatibility with 2025-03-26 spec, prm root, and www-authenticate (#28)

* clean up constructor calls for server lifecycle

* add back compat test

* fix dup logging, rm prm

* avoid fallbacks

* better error from example client

* add final endpoint method

* add test for OASM priority order

* s/prepack/prepare/ for npx

* try switching to .js

* make tsdown version happy default

* fix lock

* fix prmPath issue

* back to main package-lock

* fix bin path

* Add comprehensive auth scenario test suite

- Add index.test.ts to test all auth scenarios with auth-test.ts client
- Upgrade @modelcontextprotocol/sdk from 1.20.1 to 1.22.0
- All 5 auth scenarios now pass including auth/basic-metadata-var2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* consolidate tests into index.test.ts

* rename metadata

* fix comments

* rework auth scenario listing

* add 3rd variation of metadata

* fix some type issues introduced by version bump

* add reusable spec references

* more refs

* fix up spec references

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Carleton 2025-11-14 18:36:07 +00:00 • committed by GitHub
commit f7c8c4460f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 724 additions and 230 deletions

View file

@ -1,28 +0,0 @@
import {
runClientAgainstScenario,
SpawnedClientRunner
} from './test_helpers/testClient.js';
import path from 'path';
describe('PRM Path-Based Discovery', () => {
test('client discovers PRM at path-based location before root', async () => {
const clientPath = path.join(
process.cwd(),
'examples/clients/typescript/auth-test.ts'
);
const runner = new SpawnedClientRunner(clientPath);
await runClientAgainstScenario(runner, 'auth/basic-dcr');
});
test('bad client requests root PRM location', async () => {
const clientPath = path.join(
process.cwd(),
'examples/clients/typescript/auth-test-broken1.ts'
);
const runner = new SpawnedClientRunner(clientPath);
await runClientAgainstScenario(runner, 'auth/basic-dcr', [
// There will be other failures, but this is the one that matters
'prm-priority-order'
]);
});
});

View file

@ -4,27 +4,26 @@ import { createAuthServer } from './helpers/createAuthServer.js';
import { createServer } from './helpers/createServer.js';
import { ServerLifecycle } from './helpers/serverLifecycle.js';
import { Request, Response } from 'express';
import { SpecReferences } from './spec-references.js';
export class AuthBasicDCRScenario implements Scenario {
name = 'auth/basic-dcr';
description =
'Tests Basic OAuth flow with DCR, PRM at path-based location, OAuth metadata at root location, and no scopes required';
private authServer = new ServerLifecycle(() => this.authBaseUrl);
private server = new ServerLifecycle(() => this.baseUrl);
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
private baseUrl: string = '';
private authBaseUrl: string = '';
async start(): Promise<ScenarioUrls> {
this.checks = [];
const authApp = createAuthServer(this.checks, () => this.authBaseUrl);
this.authBaseUrl = await this.authServer.start(authApp);
const authApp = createAuthServer(this.checks, this.authServer.getUrl);
await this.authServer.start(authApp);
const app = createServer(
this.checks,
() => this.baseUrl,
() => this.authBaseUrl
this.server.getUrl,
this.authServer.getUrl
);
// For this scenario, reject PRM requests at root location since we have the path-based PRM.
@ -39,10 +38,8 @@ export class AuthBasicDCRScenario implements Scenario {
status: 'FAILURE',
timestamp: new Date().toISOString(),
specReferences: [
{
id: 'mcp-authorization-prm',
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements'
}
SpecReferences.RFC_PRM_DISCOVERY,
SpecReferences.MCP_PRM_DISCOVERY
],
details: {
url: req.url,
@ -58,9 +55,9 @@ export class AuthBasicDCRScenario implements Scenario {
}
);
this.baseUrl = await this.server.start(app);
await this.server.start(app);
return { serverUrl: `${this.baseUrl}/mcp` };
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {

View file

@ -1,16 +0,0 @@
import {
runClientAgainstScenario,
SpawnedClientRunner
} from './test_helpers/testClient.js';
import path from 'path';
describe('OAuth Metadata at OpenID Configuration Path', () => {
test('client discovers OAuth metadata at OpenID configuration path', async () => {
const clientPath = path.join(
process.cwd(),
'examples/clients/typescript/auth-test.ts'
);
const runner = new SpawnedClientRunner(clientPath);
await runClientAgainstScenario(runner, 'auth/basic-metadata-var1');
});
});

View file

@ -1,73 +0,0 @@
import type { Scenario, ConformanceCheck } from '../../../types.js';
import { ScenarioUrls } from '../../../types.js';
import { createAuthServer } from './helpers/createAuthServer.js';
import { createServer } from './helpers/createServer.js';
import { ServerLifecycle } from './helpers/serverLifecycle.js';
export class AuthBasicMetadataVar1Scenario implements Scenario {
// TODO: name should match what we put in the scenario map
name = 'auth/basic-metadata-var1';
description =
'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required';
private authServer = new ServerLifecycle(() => this.authBaseUrl);
private server = new ServerLifecycle(() => this.baseUrl);
private checks: ConformanceCheck[] = [];
private baseUrl: string = '';
private authBaseUrl: string = '';
async start(): Promise<ScenarioUrls> {
this.checks = [];
const authApp = createAuthServer(this.checks, () => this.authBaseUrl, {
metadataPath: '/.well-known/openid-configuration',
isOpenIdConfiguration: true
});
this.authBaseUrl = await this.authServer.start(authApp);
const app = createServer(
this.checks,
() => this.baseUrl,
() => this.authBaseUrl,
{
// TODO: this will put this path in the WWW-Authenticate header
// but RFC 9728 states that in that case, the resource in the PRM
// must match the URL used to make the request to the resource server.
// We'll need to establish an opinion on whether that means the
// URL for the metadata fetch, or the URL for the MCP endpoint,
// or more generally what are the valid scenarios / combos.
prmPath: '/.well-known/oauth-protected-resource'
}
);
this.baseUrl = await this.server.start(app);
return { serverUrl: `${this.baseUrl}/mcp` };
}
async stop() {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
const expectedSlugs = [
'authorization-server-metadata',
'client-registration',
'authorization-request',
'token-request'
];
for (const slug of expectedSlugs) {
if (!this.checks.find((c) => c.id === slug)) {
this.checks.push({
id: slug,
name: `Expected Check Missing: ${slug}`,
description: `Expected Check Missing: ${slug}`,
status: 'FAILURE',
timestamp: new Date().toISOString()
});
}
}
return this.checks;
}
}

View file

@ -0,0 +1,210 @@
import type { Scenario, ConformanceCheck } from '../../../types.js';
import { ScenarioUrls } from '../../../types.js';
import { createAuthServer } from './helpers/createAuthServer.js';
import { createServer } from './helpers/createServer.js';
import { ServerLifecycle } from './helpers/serverLifecycle.js';
import { SpecReferences } from './spec-references.js';
export class AuthBasicMetadataVar1Scenario implements Scenario {
name = 'auth/basic-metadata-var1';
description =
'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required';
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
metadataPath: '/.well-known/openid-configuration',
isOpenIdConfiguration: true
});
await this.authServer.start(authApp);
const app = createServer(
this.checks,
this.server.getUrl,
this.authServer.getUrl,
{
prmPath: '/.well-known/oauth-protected-resource'
}
);
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
const expectedSlugs = [
'authorization-server-metadata',
'client-registration',
'authorization-request',
'token-request'
];
for (const slug of expectedSlugs) {
if (!this.checks.find((c) => c.id === slug)) {
this.checks.push({
id: slug,
name: `Expected Check Missing: ${slug}`,
description: `Expected Check Missing: ${slug}`,
status: 'FAILURE',
timestamp: new Date().toISOString()
});
}
}
return this.checks;
}
}
export class AuthBasicMetadataVar2Scenario implements Scenario {
name = 'auth/basic-metadata-var2';
description =
'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at path-based OAuth discovery path';
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
metadataPath: '/tenant1/.well-known/openid-configuration',
isOpenIdConfiguration: true,
routePrefix: '/tenant1'
});
authApp.get('/.well-known/oauth-authorization-server', (req, res) => {
this.checks.push({
id: 'authorization-server-metadata-wrong-path',
name: 'AuthorizationServerMetadataWrongPath',
description:
'Client requested authorization server at the root path when the AS URL has a path-based location',
status: 'FAILURE',
timestamp: new Date().toISOString(),
specReferences: [
SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST,
SpecReferences.MCP_AUTH_DISCOVERY
],
details: {
url: req.url
}
});
res.status(404).send('Not Found');
});
await this.authServer.start(authApp);
const app = createServer(
this.checks,
this.server.getUrl,
() => `${this.authServer.getUrl()}/tenant1`,
{
prmPath: '/.well-known/oauth-protected-resource'
}
);
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
const expectedSlugs = [
'authorization-server-metadata',
'client-registration',
'authorization-request',
'token-request'
];
for (const slug of expectedSlugs) {
if (!this.checks.find((c) => c.id === slug)) {
this.checks.push({
id: slug,
name: `Expected Check Missing: ${slug}`,
description: `Expected Check Missing: ${slug}`,
status: 'FAILURE',
timestamp: new Date().toISOString()
});
}
}
return this.checks;
}
}
export class AuthBasicMetadataVar3Scenario implements Scenario {
name = 'auth/basic-metadata-var3';
description =
'Tests Basic OAuth flow with DCR, PRM at custom location listed in WWW-Authenticate header, OAuth metadata is at nested OpenID discovery path, and no scopes required';
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
metadataPath: '/tenant1/.well-known/openid-configuration',
isOpenIdConfiguration: true,
routePrefix: '/tenant1'
});
await this.authServer.start(authApp);
const app = createServer(
this.checks,
this.server.getUrl,
() => {
return `${this.authServer.getUrl()}/tenant1`;
},
{
// This is a custom path, so unable to get via probing, it's only available
// via following the `resource_metadata_url` in the WWW-Authenticate header.
// The resource must match the original request URL per RFC 9728.
prmPath: '/custom/metadata/location.json'
}
);
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
const expectedSlugs = [
'authorization-server-metadata',
'client-registration',
'authorization-request',
'token-request'
];
for (const slug of expectedSlugs) {
if (!this.checks.find((c) => c.id === slug)) {
this.checks.push({
id: slug,
name: `Expected Check Missing: ${slug}`,
description: `Expected Check Missing: ${slug}`,
status: 'FAILURE',
timestamp: new Date().toISOString()
});
}
}
return this.checks;
}
}

View file

@ -1,10 +1,13 @@
import express, { Request, Response } from 'express';
import type { ConformanceCheck } from '../../../../types.js';
import { createRequestLogger } from '../../../request-logger.js';
import { SpecReferences } from '../spec-references.js';
export interface AuthServerOptions {
metadataPath?: string;
isOpenIdConfiguration?: boolean;
loggingEnabled?: boolean;
routePrefix?: string;
}
export function createAuthServer(
@ -14,18 +17,29 @@ export function createAuthServer(
): express.Application {
const {
metadataPath = '/.well-known/oauth-authorization-server',
isOpenIdConfiguration = false
isOpenIdConfiguration = false,
loggingEnabled = true,
routePrefix = ''
} = options;
const authRoutes = {
authorization_endpoint: `${routePrefix}/authorize`,
token_endpoint: `${routePrefix}/token`,
registration_endpoint: `${routePrefix}/register`
};
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(
createRequestLogger(checks, {
incomingId: 'incoming-auth-request',
outgoingId: 'outgoing-auth-response'
})
);
if (loggingEnabled) {
app.use(
createRequestLogger(checks, {
incomingId: 'incoming-auth-request',
outgoingId: 'outgoing-auth-response'
})
);
}
app.get(metadataPath, (req: Request, res: Response) => {
checks.push({
@ -35,10 +49,8 @@ export function createAuthServer(
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: [
{
id: 'RFC-8414',
url: 'https://tools.ietf.org/html/rfc8414'
}
SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST,
SpecReferences.MCP_AUTH_DISCOVERY
],
details: {
url: req.url,
@ -48,9 +60,9 @@ export function createAuthServer(
const metadata: any = {
issuer: getAuthBaseUrl(),
authorization_endpoint: `${getAuthBaseUrl()}/authorize`,
token_endpoint: `${getAuthBaseUrl()}/token`,
registration_endpoint: `${getAuthBaseUrl()}/register`,
authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`,
token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`,
registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
@ -67,19 +79,14 @@ export function createAuthServer(
res.json(metadata);
});
app.get('/authorize', (req: Request, res: Response) => {
app.get(authRoutes.authorization_endpoint, (req: Request, res: Response) => {
checks.push({
id: 'authorization-request',
name: 'AuthorizationRequest',
description: 'Client made authorization request',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: [
{
id: 'RFC-6749-4.1.1',
url: 'https://tools.ietf.org/html/rfc6749#section-4.1.1'
}
],
specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_ENDPOINT],
details: {
response_type: req.query.response_type,
client_id: req.query.client_id,
@ -101,19 +108,14 @@ export function createAuthServer(
res.redirect(redirectUrl.toString());
});
app.post('/token', (req: Request, res: Response) => {
app.post(authRoutes.token_endpoint, (req: Request, res: Response) => {
checks.push({
id: 'token-request',
name: 'TokenRequest',
description: 'Client requested access token',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: [
{
id: 'RFC-6749-4.1.3',
url: 'https://tools.ietf.org/html/rfc6749#section-4.1.3'
}
],
specReferences: [SpecReferences.OAUTH_2_1_TOKEN],
details: {
endpoint: '/token',
grantType: req.body.grant_type
@ -127,19 +129,14 @@ export function createAuthServer(
});
});
app.post('/register', (req: Request, res: Response) => {
app.post(authRoutes.registration_endpoint, (req: Request, res: Response) => {
checks.push({
id: 'client-registration',
name: 'ClientRegistration',
description: 'Client registered with authorization server',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: [
{
id: 'RFC-7591-2',
url: 'https://tools.ietf.org/html/rfc7591#section-2'
}
],
specReferences: [SpecReferences.MCP_DCR],
details: {
endpoint: '/register',
clientName: req.body.client_name

View file

@ -6,9 +6,10 @@ import express, { Request, Response, NextFunction } from 'express';
import type { ConformanceCheck } from '../../../../types.js';
import { createRequestLogger } from '../../../request-logger.js';
import { MockTokenVerifier } from './mockTokenVerifier.js';
import { SpecReferences } from '../spec-references.js';
export interface ServerOptions {
prmPath?: string;
prmPath?: string | null;
}
export function createServer(
@ -47,30 +48,37 @@ export function createServer(
})
);
app.get(prmPath, (req: Request, res: Response) => {
checks.push({
id: 'prm-pathbased-requested',
name: 'PRMPathBasedRequested',
description: 'Client requested PRM metadata at path-based location',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: [
{
id: 'RFC-9728-3',
url: 'https://tools.ietf.org/html/rfc9728#section-3'
if (prmPath !== null) {
app.get(prmPath, (req: Request, res: Response) => {
checks.push({
id: 'prm-pathbased-requested',
name: 'PRMPathBasedRequested',
description: 'Client requested PRM metadata at path-based location',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: [
SpecReferences.RFC_PRM_DISCOVERY,
SpecReferences.MCP_PRM_DISCOVERY
],
details: {
url: req.url,
path: req.path
}
],
details: {
url: req.url,
path: req.path
}
});
});
res.json({
resource: getBaseUrl(),
authorization_servers: [getAuthServerUrl()]
// Resource is usually $baseUrl/mcp, but if PRM is at the root,
// the resource identifier is the root.
const resource =
prmPath === '/.well-known/oauth-protected-resource'
? getBaseUrl()
: `${getBaseUrl()}/mcp`;
res.json({
resource,
authorization_servers: [getAuthServerUrl()]
});
});
});
}
app.post('/mcp', async (req: Request, res: Response, next: NextFunction) => {
// Apply bearer token auth per-request in order to delay setting PRM URL
@ -79,7 +87,9 @@ export function createServer(
const authMiddleware = requireBearerAuth({
verifier: new MockTokenVerifier(checks),
requiredScopes: [],
resourceMetadataUrl: `${getBaseUrl()}${prmPath}`
...(prmPath !== null && {
resourceMetadataUrl: `${getBaseUrl()}${prmPath}`
})
});
authMiddleware(req, res, async (err?: any) => {

View file

@ -1,6 +1,7 @@
import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js';
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import type { ConformanceCheck } from '../../../../types.js';
import { SpecReferences } from '../spec-references.js';
export class MockTokenVerifier implements OAuthTokenVerifier {
constructor(private checks: ConformanceCheck[]) {}
@ -13,12 +14,7 @@ export class MockTokenVerifier implements OAuthTokenVerifier {
description: 'Client provided valid bearer token',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: [
{
id: 'MCP-Authorization',
url: 'https://spec.modelcontextprotocol.io/specification/architecture/#authorization'
}
],
specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE],
details: {
token: token.substring(0, 10) + '...'
}
@ -37,12 +33,7 @@ export class MockTokenVerifier implements OAuthTokenVerifier {
description: 'Client provided invalid bearer token',
status: 'FAILURE',
timestamp: new Date().toISOString(),
specReferences: [
{
id: 'MCP-Authorization',
url: 'https://spec.modelcontextprotocol.io/specification/architecture/#authorization'
}
],
specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE],
details: {
message: 'Token verification failed',
token: token ? token.substring(0, 10) + '...' : 'missing'

View file

@ -5,7 +5,10 @@ export class ServerLifecycle {
private httpServer: any = null;
private baseUrl: string = '';
constructor(private getBaseUrl: () => string) {}
// Arrow function to avoid needing lots of .bind(this)
getUrl = (): string => {
return this.baseUrl;
};
async start(app: express.Application): Promise<string> {
this.app = app;
@ -25,8 +28,4 @@ export class ServerLifecycle {
}
this.app = null;
}
getUrl(): string {
return this.baseUrl;
}
}

View file

@ -0,0 +1,37 @@
import { authScenariosList } from './index.js';
import {
runClientAgainstScenario,
SpawnedClientRunner
} from './test_helpers/testClient.js';
import path from 'path';
describe('Client Auth Scenarios', () => {
const clientPath = path.join(
process.cwd(),
'examples/clients/typescript/auth-test.ts'
);
// Generate individual test for each auth scenario
for (const scenario of authScenariosList) {
test(`${scenario.name} passes`, async () => {
const runner = new SpawnedClientRunner(clientPath);
await runClientAgainstScenario(runner, scenario.name);
});
}
});
describe('Negative tests', () => {
test('bad client requests root PRM location', async () => {
const clientPath = path.join(
process.cwd(),
'examples/clients/typescript/auth-test-broken1.ts'
);
const runner = new SpawnedClientRunner(clientPath);
await runClientAgainstScenario(runner, 'auth/basic-dcr', [
// There will be other failures, but this is the one that matters
'prm-priority-order'
]);
});
// TODO: Add more negative tests here
});

View file

@ -0,0 +1,20 @@
import { Scenario } from '../../../types';
import { AuthBasicDCRScenario } from './basic-dcr.js';
import {
AuthBasicMetadataVar1Scenario,
AuthBasicMetadataVar2Scenario,
AuthBasicMetadataVar3Scenario
} from './basic-metadata.js';
import {
Auth20250326OAuthMetadataBackcompatScenario,
Auth20250326OEndpointFallbackScenario
} from './march-spec-backcompat.js';
export const authScenariosList: Scenario[] = [
new AuthBasicDCRScenario(),
new AuthBasicMetadataVar1Scenario(),
new AuthBasicMetadataVar2Scenario(),
new AuthBasicMetadataVar3Scenario(),
new Auth20250326OAuthMetadataBackcompatScenario(),
new Auth20250326OEndpointFallbackScenario()
];

View file

@ -0,0 +1,192 @@
import type { Scenario, ConformanceCheck } from '../../../types.js';
import { ScenarioUrls } from '../../../types.js';
import { createAuthServer } from './helpers/createAuthServer.js';
import { createServer } from './helpers/createServer.js';
import { ServerLifecycle } from './helpers/serverLifecycle.js';
import express, { Request, Response } from 'express';
import { SpecReferences } from './spec-references.js';
export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario {
name = 'auth/2025-03-26-oauth-metadata-backcompat';
description =
'Tests 2025-03-26 spec OAuth flow: no PRM (Protected Resource Metadata), OAuth metadata at root location';
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
// Legacy server, so we create the auth server endpoints on the
// same URL as the main server (rather than separating AS / RS).
const authApp = createAuthServer(this.checks, this.server.getUrl, {
// Disable logging since the main server will already have logging enabled
loggingEnabled: false,
// Add a prefix to auth endpoints to avoid being caught by auth fallbacks
routePrefix: '/oauth'
});
const app = createServer(
this.checks,
this.server.getUrl,
this.server.getUrl,
// Explicitly set to null to indicate no PRM available
{ prmPath: null }
);
app.use(authApp);
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
const expectedSlugs = [
'authorization-server-metadata',
'client-registration',
'authorization-request',
'token-request'
];
for (const slug of expectedSlugs) {
if (!this.checks.find((c) => c.id === slug)) {
this.checks.push({
id: slug,
name: `Expected Check Missing: ${slug}`,
description: `Expected Check Missing: ${slug}`,
status: 'FAILURE',
timestamp: new Date().toISOString(),
specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_DISCOVERY]
});
}
}
return this.checks;
}
}
export class Auth20250326OEndpointFallbackScenario implements Scenario {
name = 'auth/2025-03-26-oauth-endpoint-fallback';
description =
'Tests OAuth flow with no metadata endpoints, relying on fallback to standard OAuth endpoints at server root (2025-03-26 spec behavior)';
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
const app = createServer(
this.checks,
this.server.getUrl,
this.server.getUrl,
{ prmPath: null }
);
// needed for /token endpoint
app.use(express.urlencoded({ extended: true }));
app.get('/authorize', (req: Request, res: Response) => {
this.checks.push({
id: 'authorization-request',
name: 'AuthorizationRequest',
description: 'Client made authorization request to fallback endpoint',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK],
details: {
response_type: req.query.response_type,
client_id: req.query.client_id,
redirect_uri: req.query.redirect_uri,
state: req.query.state,
code_challenge: req.query.code_challenge ? 'present' : 'missing',
code_challenge_method: req.query.code_challenge_method
}
});
const redirectUri = req.query.redirect_uri as string;
const state = req.query.state as string;
const redirectUrl = new URL(redirectUri);
redirectUrl.searchParams.set('code', 'test-auth-code');
if (state) {
redirectUrl.searchParams.set('state', state);
}
res.redirect(redirectUrl.toString());
});
app.post('/token', (req: Request, res: Response) => {
this.checks.push({
id: 'token-request',
name: 'TokenRequest',
description: 'Client requested access token from fallback endpoint',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK],
details: {
endpoint: '/token',
grantType: req.body.grant_type
}
});
res.json({
access_token: 'test-token',
token_type: 'Bearer',
expires_in: 3600
});
});
app.post('/register', (req: Request, res: Response) => {
this.checks.push({
id: 'client-registration',
name: 'ClientRegistration',
description:
'Client registered with authorization server at fallback endpoint',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK],
details: {
endpoint: '/register',
clientName: req.body.client_name
}
});
res.status(201).json({
client_id: 'test-client-id',
client_secret: 'test-client-secret',
client_name: req.body.client_name || 'test-client',
redirect_uris: req.body.redirect_uris || []
});
});
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
const expectedSlugs = [
'client-registration',
'authorization-request',
'token-request'
];
for (const slug of expectedSlugs) {
if (!this.checks.find((c) => c.id === slug)) {
this.checks.push({
id: slug,
name: `Expected Check Missing: ${slug}`,
description: `Expected Check Missing: ${slug}`,
status: 'FAILURE',
timestamp: new Date().toISOString()
});
}
}
return this.checks;
}
}

View file

@ -0,0 +1,44 @@
import { SpecReference } from '../../../types';
export const SpecReferences: { [key: string]: SpecReference } = {
RFC_PRM_DISCOVERY: {
id: 'RFC-9728',
url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.1'
},
RFC_AUTH_SERVER_METADATA_REQUEST: {
id: 'RFC-8414-metadata-request',
url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-3.1'
},
LEGACY_2025_03_26_AUTH_DISCOVERY: {
id: 'MCP-2025-03-26-Authorization-metadata-discovery',
url: 'https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery'
},
LEGACY_2025_03_26_AUTH_URL_FALLBACK: {
id: 'MCP-2025-03-26-Authorization-metadata-url-fallback',
url: 'https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery'
},
MCP_PRM_DISCOVERY: {
id: 'MCP-2025-06-18-PRM-discovery',
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements'
},
MCP_AUTH_DISCOVERY: {
id: 'MCP-Authorization-metadata-discovery',
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery'
},
MCP_DCR: {
id: 'MCP-Dynamic-client-registration',
url: 'https://modelcontextprotocol.io/specification/draft/basic/client#dynamic-client-registration'
},
OAUTH_2_1_AUTHORIZATION_ENDPOINT: {
id: 'OAUTH-2.1-authorization-endpoint',
url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#name-authorization-endpoint'
},
OAUTH_2_1_TOKEN: {
id: 'OAUTH-2.1-token-request',
url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#name-token-request'
},
MCP_ACCESS_TOKEN_USAGE: {
id: 'MCP-Access-token-usage',
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#access-token-usage'
}
};