[auth] Scenarios for scope selection (#36)

* wip scope change

* rm useless bool

* fix-me: package changes for linking

* negative test

* negative test for multiple scopes

* fix displays of warnings

* wip step-up

* negative test for step up auth

* move warning into main request flow

* clean up more checks

* cleanup step-up scenario

* cleanup and simplify

* cleanup middleware

* inline negative tests

* inline the other test

* refactor logging and inlining for standalone execution too

* skip pending tests
This commit is contained in:
Paul Carleton 2025-11-18 17:16:23 +00:00 • committed by GitHub
commit 5c151133bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1074 additions and 186 deletions

View file

@ -2,12 +2,24 @@ import express, { Request, Response } from 'express';
import type { ConformanceCheck } from '../../../../types.js';
import { createRequestLogger } from '../../../request-logger.js';
import { SpecReferences } from '../spec-references.js';
import { MockTokenVerifier } from './mockTokenVerifier.js';
export interface AuthServerOptions {
metadataPath?: string;
isOpenIdConfiguration?: boolean;
loggingEnabled?: boolean;
routePrefix?: string;
scopesSupported?: string[];
tokenVerifier?: MockTokenVerifier;
onTokenRequest?: (requestData: {
scope?: string;
grantType: string;
timestamp: string;
}) => { token: string; scopes: string[] };
onAuthorizationRequest?: (requestData: {
scope?: string;
timestamp: string;
}) => void;
}
export function createAuthServer(
@ -19,9 +31,16 @@ export function createAuthServer(
metadataPath = '/.well-known/oauth-authorization-server',
isOpenIdConfiguration = false,
loggingEnabled = true,
routePrefix = ''
routePrefix = '',
scopesSupported,
tokenVerifier,
onTokenRequest,
onAuthorizationRequest
} = options;
// Track scopes from the most recent authorization request
let lastAuthorizationScopes: string[] = [];
const authRoutes = {
authorization_endpoint: `${routePrefix}/authorize`,
token_endpoint: `${routePrefix}/token`,
@ -69,6 +88,11 @@ export function createAuthServer(
token_endpoint_auth_methods_supported: ['none']
};
// Add scopes_supported if provided
if (scopesSupported !== undefined) {
metadata.scopes_supported = scopesSupported;
}
// Add OpenID Configuration specific fields
if (isOpenIdConfiguration) {
metadata.jwks_uri = `${getAuthBaseUrl()}/.well-known/jwks.json`;
@ -80,23 +104,30 @@ export function createAuthServer(
});
app.get(authRoutes.authorization_endpoint, (req: Request, res: Response) => {
const timestamp = new Date().toISOString();
checks.push({
id: 'authorization-request',
name: 'AuthorizationRequest',
description: 'Client made authorization request',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
timestamp,
specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_ENDPOINT],
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
query: req.query
}
});
// Track scopes from authorization request for token issuance
const scopeParam = req.query.scope as string | undefined;
lastAuthorizationScopes = scopeParam ? scopeParam.split(' ') : [];
if (onAuthorizationRequest) {
onAuthorizationRequest({
scope: scopeParam,
timestamp
});
}
const redirectUri = req.query.redirect_uri as string;
const state = req.query.state as string;
const redirectUrl = new URL(redirectUri);
@ -109,12 +140,15 @@ export function createAuthServer(
});
app.post(authRoutes.token_endpoint, (req: Request, res: Response) => {
const timestamp = new Date().toISOString();
const requestedScope = req.body.scope;
checks.push({
id: 'token-request',
name: 'TokenRequest',
description: 'Client requested access token',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
timestamp,
specReferences: [SpecReferences.OAUTH_2_1_TOKEN],
details: {
endpoint: '/token',
@ -122,10 +156,29 @@ export function createAuthServer(
}
});
let token = `test-token-${Date.now()}`;
let scopes: string[] = lastAuthorizationScopes;
if (onTokenRequest) {
const result = onTokenRequest({
scope: requestedScope,
grantType: req.body.grant_type,
timestamp
});
token = result.token;
scopes = result.scopes;
}
// Register token with verifier if provided
if (tokenVerifier) {
tokenVerifier.registerToken(token, scopes);
}
res.json({
access_token: 'test-token',
access_token: token,
token_type: 'Bearer',
expires_in: 3600
expires_in: 3600,
...(scopes.length > 0 && { scope: scopes.join(' ') })
});
});

View file

@ -1,6 +1,12 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import {
CallToolRequestSchema,
CallToolResult,
ErrorCode,
ListToolsRequestSchema,
McpError
} from '@modelcontextprotocol/sdk/types.js';
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
import express, { Request, Response, NextFunction } from 'express';
import type { ConformanceCheck } from '../../../../types.js';
@ -10,6 +16,11 @@ import { SpecReferences } from '../spec-references.js';
export interface ServerOptions {
prmPath?: string | null;
requiredScopes?: string[];
scopesSupported?: string[];
includeScopeInWwwAuth?: boolean;
authMiddleware?: express.RequestHandler;
tokenVerifier?: MockTokenVerifier;
}
export function createServer(
@ -18,7 +29,13 @@ export function createServer(
getAuthServerUrl: () => string,
options: ServerOptions = {}
): express.Application {
const { prmPath = '/.well-known/oauth-protected-resource/mcp' } = options;
const {
prmPath = '/.well-known/oauth-protected-resource/mcp',
requiredScopes = [],
scopesSupported,
includeScopeInWwwAuth = false,
tokenVerifier
} = options;
const server = new Server(
{
name: 'auth-prm-pathbased-server',
@ -33,10 +50,30 @@ export function createServer(
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: []
tools: [
{
name: 'test-tool',
inputSchema: { type: 'object' }
}
]
};
});
server.setRequestHandler(
CallToolRequestSchema,
async (request): Promise<CallToolResult> => {
if (request.params.name === 'test-tool') {
return {
content: [{ type: 'text', text: 'test' }]
};
}
throw new McpError(
ErrorCode.InvalidParams,
`Tool ${request.params.name} not found`
);
}
);
const app = express();
app.use(express.json());
@ -73,10 +110,16 @@ export function createServer(
? getBaseUrl()
: `${getBaseUrl()}/mcp`;
res.json({
const prmResponse: any = {
resource,
authorization_servers: [getAuthServerUrl()]
});
};
if (scopesSupported !== undefined) {
prmResponse.scopes_supported = scopesSupported;
}
res.json(prmResponse);
});
}
@ -84,13 +127,19 @@ export function createServer(
// Apply bearer token auth per-request in order to delay setting PRM URL
// until after the server has started
// TODO: Find a way to do this w/ pre-applying middleware.
const authMiddleware = requireBearerAuth({
verifier: new MockTokenVerifier(checks),
requiredScopes: [],
...(prmPath !== null && {
resourceMetadataUrl: `${getBaseUrl()}${prmPath}`
})
});
const verifier =
tokenVerifier || new MockTokenVerifier(checks, requiredScopes);
const authMiddleware =
options.authMiddleware ??
requireBearerAuth({
verifier,
// Only pass requiredScopes if we want them in the WWW-Authenticate header
requiredScopes: includeScopeInWwwAuth ? requiredScopes : [],
...(prmPath !== null && {
resourceMetadataUrl: `${getBaseUrl()}${prmPath}`
})
});
authMiddleware(req, res, async (err?: any) => {
if (err) return next(err);

View file

@ -4,10 +4,23 @@ import type { ConformanceCheck } from '../../../../types.js';
import { SpecReferences } from '../spec-references.js';
export class MockTokenVerifier implements OAuthTokenVerifier {
constructor(private checks: ConformanceCheck[]) {}
private tokenScopes: Map<string, string[]> = new Map();
constructor(
private checks: ConformanceCheck[],
private expectedScopes: string[] = []
) {}
registerToken(token: string, scopes: string[]) {
this.tokenScopes.set(token, scopes);
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
if (token === 'test-token') {
// Accept tokens that start with 'test-token'
if (token.startsWith('test-token')) {
// Get scopes for this token, or use empty array
const scopes = this.tokenScopes.get(token) || [];
this.checks.push({
id: 'valid-bearer-token',
name: 'ValidBearerToken',
@ -16,13 +29,14 @@ export class MockTokenVerifier implements OAuthTokenVerifier {
timestamp: new Date().toISOString(),
specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE],
details: {
token: token.substring(0, 10) + '...'
token: token.substring(0, 15) + '...',
scopes
}
});
return {
token,
clientId: 'test-client',
scopes: [],
scopes,
expiresAt: Math.floor(Date.now() / 1000) + 3600
};
}

View file

@ -1,20 +1,39 @@
import { authScenariosList } from './index.js';
import {
runClientAgainstScenario,
SpawnedClientRunner
InlineClientRunner
} from './test_helpers/testClient.js';
import path from 'path';
import { runClient as goodClient } from '../../../../examples/clients/typescript/auth-test.js';
import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm.js';
import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope.js';
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes.js';
import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403.js';
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger.js';
beforeAll(() => {
setLogLevel('error');
});
const skipScenarios = new Set<string>([
// Waiting on typescript-sdk support in bearerAuth middleware to include
// scope in WWW-Authenticate header
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1133
'auth/scope-from-www-authenticate',
// Waiting on typescript-sdk support for using scopes_supported from PRM
// to request scopes.
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1133
'auth/scope-from-scopes-supported'
]);
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);
if (skipScenarios.has(scenario.name)) {
// TODO: skip in a native way?
return;
}
const runner = new InlineClientRunner(goodClient);
await runClientAgainstScenario(runner, scenario.name);
});
}
@ -22,16 +41,39 @@ describe('Client Auth Scenarios', () => {
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);
const runner = new InlineClientRunner(badPrmClient);
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
test('client ignores scope from WWW-Authenticate header', async () => {
const runner = new InlineClientRunner(ignoreScopeClient);
await runClientAgainstScenario(runner, 'auth/scope-from-www-authenticate', [
'scope-from-www-authenticate'
]);
});
test('client only requests subset of scopes_supported', async () => {
const runner = new InlineClientRunner(partialScopesClient);
await runClientAgainstScenario(runner, 'auth/scope-from-scopes-supported', [
'scope-from-scopes-supported'
]);
});
test('client requests scope even if scopes_supported is empty', async () => {
const runner = new InlineClientRunner(partialScopesClient);
await runClientAgainstScenario(
runner,
'auth/scope-omitted-when-undefined',
['scope-omitted-when-undefined']
);
});
test('client only responds to 401, not 403', async () => {
const runner = new InlineClientRunner(ignore403Client);
await runClientAgainstScenario(runner, 'auth/scope-step-up', [
'scope-step-up-escalation'
]);
});
});

View file

@ -9,6 +9,12 @@ import {
Auth20250326OAuthMetadataBackcompatScenario,
Auth20250326OEndpointFallbackScenario
} from './march-spec-backcompat.js';
import {
ScopeFromWwwAuthenticateScenario,
ScopeFromScopesSupportedScenario,
ScopeOmittedWhenUndefinedScenario,
ScopeStepUpAuthScenario
} from './scope-handling.js';
export const authScenariosList: Scenario[] = [
new AuthBasicDCRScenario(),
@ -16,5 +22,9 @@ export const authScenariosList: Scenario[] = [
new AuthBasicMetadataVar2Scenario(),
new AuthBasicMetadataVar3Scenario(),
new Auth20250326OAuthMetadataBackcompatScenario(),
new Auth20250326OEndpointFallbackScenario()
new Auth20250326OEndpointFallbackScenario(),
new ScopeFromWwwAuthenticateScenario(),
new ScopeFromScopesSupportedScenario(),
new ScopeOmittedWhenUndefinedScenario(),
new ScopeStepUpAuthScenario()
];

View file

@ -0,0 +1,423 @@
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';
import { MockTokenVerifier } from './helpers/mockTokenVerifier.js';
import type { Request, Response, NextFunction } from 'express';
/**
* Scenario 1: Client uses scope from WWW-Authenticate header
*
* Tests that clients SHOULD follow the scope parameter from the initial
* WWW-Authenticate header in the 401 response, per the scope selection strategy.
*/
export class ScopeFromWwwAuthenticateScenario implements Scenario {
name = 'auth/scope-from-www-authenticate';
description =
'Tests that client uses scope parameter from WWW-Authenticate header when provided';
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
const expectedScope = 'mcp:basic';
const tokenVerifier = new MockTokenVerifier(this.checks, [expectedScope]);
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
tokenVerifier,
onAuthorizationRequest: (data) => {
// Check if client used the scope from WWW-Authenticate header
const requestedScopes = data.scope ? data.scope.split(' ') : [];
const usedCorrectScope = requestedScopes.includes(expectedScope);
this.checks.push({
id: 'scope-from-www-authenticate',
name: 'Client scope selection from WWW-Authenticate header',
description: usedCorrectScope
? 'Client correctly used the scope parameter from the WWW-Authenticate header'
: 'Client SHOULD use the scope parameter from the WWW-Authenticate header when provided',
status: usedCorrectScope ? 'SUCCESS' : 'WARNING',
timestamp: data.timestamp,
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY],
details: {
expectedScope,
requestedScope: data.scope || 'none'
}
});
}
});
await this.authServer.start(authApp);
const app = createServer(
this.checks,
this.server.getUrl,
this.authServer.getUrl,
{
prmPath: '/.well-known/oauth-protected-resource/mcp',
requiredScopes: [expectedScope],
includeScopeInWwwAuth: true,
tokenVerifier
}
);
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
return this.checks;
}
}
/**
* Scenario 2: Client falls back to scopes_supported when scope not in WWW-Authenticate
*
* Tests that clients SHOULD use all scopes from scopes_supported in the PRM
* when the scope parameter is not available in the WWW-Authenticate header.
*/
export class ScopeFromScopesSupportedScenario implements Scenario {
name = 'auth/scope-from-scopes-supported';
description =
'Tests that client uses all scopes from scopes_supported when scope not in WWW-Authenticate header';
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
const scopesSupported = ['mcp:basic', 'mcp:read', 'mcp:write'];
const tokenVerifier = new MockTokenVerifier(this.checks, scopesSupported);
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
tokenVerifier,
onAuthorizationRequest: (data) => {
// Check if client requested all scopes from scopes_supported
const requestedScopes = data.scope ? data.scope.split(' ') : [];
const hasAllScopes = scopesSupported.every((scope) =>
requestedScopes.includes(scope)
);
this.checks.push({
id: 'scope-from-scopes-supported',
name: 'Client scope selection from scopes_supported',
description: hasAllScopes
? 'Client correctly used all scopes from scopes_supported in PRM when scope not in WWW-Authenticate'
: 'Client SHOULD use all scopes from scopes_supported when scope not available in WWW-Authenticate header',
status: hasAllScopes ? 'SUCCESS' : 'WARNING',
timestamp: data.timestamp,
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY],
details: {
scopesSupported: scopesSupported.join(' '),
requestedScope: data.scope || 'none',
...(hasAllScopes
? {}
: {
missingScopes: scopesSupported
.filter((s) => !requestedScopes.includes(s))
.join(' ')
})
}
});
}
});
await this.authServer.start(authApp);
const app = createServer(
this.checks,
this.server.getUrl,
this.authServer.getUrl,
{
prmPath: '/.well-known/oauth-protected-resource/mcp',
requiredScopes: scopesSupported,
scopesSupported: scopesSupported,
includeScopeInWwwAuth: false,
tokenVerifier
}
);
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
return this.checks;
}
}
/**
* Scenario 3: Client omits scope when scopes_supported is undefined
*
* Tests that clients SHOULD omit the scope parameter when scopes_supported
* is not available in the PRM and scope is not in WWW-Authenticate header.
*/
export class ScopeOmittedWhenUndefinedScenario implements Scenario {
name = 'auth/scope-omitted-when-undefined';
description =
'Tests that client omits scope parameter when scopes_supported is undefined';
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
const tokenVerifier = new MockTokenVerifier(this.checks, []);
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
tokenVerifier,
onAuthorizationRequest: (data) => {
// Check if client omitted scope parameter
const scopeOmitted = !data.scope || data.scope.trim() === '';
this.checks.push({
id: 'scope-omitted-when-undefined',
name: 'Client scope omission when scopes_supported undefined',
description: scopeOmitted
? 'Client correctly omitted scope parameter when scopes_supported is undefined'
: 'Client SHOULD omit scope parameter when scopes_supported is undefined and scope not in WWW-Authenticate',
status: scopeOmitted ? 'SUCCESS' : 'WARNING',
timestamp: data.timestamp,
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY],
details: {
scopeParameter: scopeOmitted ? 'omitted' : data.scope
}
});
}
});
await this.authServer.start(authApp);
const app = createServer(
this.checks,
this.server.getUrl,
this.authServer.getUrl,
{
prmPath: '/.well-known/oauth-protected-resource/mcp',
requiredScopes: [],
scopesSupported: undefined,
includeScopeInWwwAuth: false,
tokenVerifier
}
);
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
return this.checks;
}
}
/**
* Scenario 4: Client performs step-up authentication
*
* Tests that clients handle step-up authentication where:
* - initialize/notifications do not require auth
* - listTools requires mcp:basic scope (401 if missing)
* - tools/call requires mcp:basic + mcp:write scopes (403 if insufficient)
* Client must handle both 401 and 403 responses with different scope requirements
*/
export class ScopeStepUpAuthScenario implements Scenario {
name = 'auth/scope-step-up';
description =
'Tests that client handles step-up authentication with different scope requirements per operation';
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
const initialScope = 'mcp:basic';
const escalatedScopes = ['mcp:basic', 'mcp:write'];
const tokenVerifier = new MockTokenVerifier(this.checks, escalatedScopes);
let authRequestCount = 0;
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
tokenVerifier,
onAuthorizationRequest: (data) => {
authRequestCount++;
const requestedScopes = data.scope ? data.scope.split(' ') : [];
if (authRequestCount === 1) {
// First auth request - should request mcp:basic from WWW-Authenticate
const usedCorrectScope = requestedScopes.includes(initialScope);
this.checks.push({
id: 'scope-step-up-initial',
name: 'Client initial scope selection for step-up auth',
description: usedCorrectScope
? 'Client correctly used scope from WWW-Authenticate header for initial auth'
: 'Client SHOULD use the scope parameter from the WWW-Authenticate header',
status: usedCorrectScope ? 'SUCCESS' : 'WARNING',
timestamp: data.timestamp,
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY],
details: {
expectedScope: initialScope,
requestedScope: data.scope || 'none'
}
});
} else if (authRequestCount === 2) {
// Second auth request - should escalate to mcp:basic + mcp:write
const hasAllScopes = escalatedScopes.every((s) =>
requestedScopes.includes(s)
);
this.checks.push({
id: 'scope-step-up-escalation',
name: 'Client scope escalation for step-up auth',
description: hasAllScopes
? 'Client correctly escalated scopes for step-up authentication'
: 'Client SHOULD request additional scopes when receiving 403 with new scope requirements',
status: hasAllScopes ? 'SUCCESS' : 'WARNING',
timestamp: data.timestamp,
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY],
details: {
expectedScopes: escalatedScopes.join(' '),
requestedScope: data.scope || 'none'
}
});
}
}
});
await this.authServer.start(authApp);
// Inline step-up auth middleware
const resourceMetadataUrl = () =>
`${this.server.getUrl()}/.well-known/oauth-protected-resource/mcp`;
const stepUpMiddleware = async (
req: Request,
res: Response,
next: NextFunction
) => {
// Parse body to check method
let body = req.body;
if (typeof body === 'string') {
body = JSON.parse(body);
}
const method = body?.method;
// Allow initialize and notifications without auth
if (method === 'initialize' || method?.startsWith('notifications/')) {
return next();
}
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
// No auth - return 401 with initial scope
return res
.status(401)
.set(
'WWW-Authenticate',
`Bearer scope="${initialScope}", resource_metadata="${resourceMetadataUrl()}"`
)
.json({
error: 'invalid_token',
error_description: 'Missing Authorization header'
});
}
const token = authHeader.substring('Bearer '.length);
const authInfo = await tokenVerifier.verifyAccessToken(token);
const tokenScopes = authInfo.scopes || [];
// Determine required scopes based on method
const isToolCall = method === 'tools/call';
const requiredScopes = isToolCall ? escalatedScopes : [initialScope];
const hasRequiredScopes = requiredScopes.every((s) =>
tokenScopes.includes(s)
);
if (!hasRequiredScopes) {
// Has token but insufficient scopes - return 403
return res
.status(403)
.set(
'WWW-Authenticate',
`Bearer scope="${requiredScopes.join(' ')}", resource_metadata="${resourceMetadataUrl()}"`
)
.json({
error: 'insufficient_scope',
error_description: 'Token has insufficient scope'
});
}
next();
};
const baseApp = createServer(
this.checks,
this.server.getUrl,
this.authServer.getUrl,
{
prmPath: '/.well-known/oauth-protected-resource/mcp',
requiredScopes: escalatedScopes,
scopesSupported: escalatedScopes,
includeScopeInWwwAuth: true,
authMiddleware: stepUpMiddleware,
tokenVerifier
}
);
await this.server.start(baseApp);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
// Emit failure checks if expected auth requests didn't happen
const hasInitialCheck = this.checks.some(
(c) => c.id === 'scope-step-up-initial'
);
const hasEscalationCheck = this.checks.some(
(c) => c.id === 'scope-step-up-escalation'
);
if (!hasInitialCheck) {
this.checks.push({
id: 'scope-step-up-initial',
name: 'Client initial scope selection for step-up auth',
description: 'Client did not make an initial authorization request',
status: 'FAILURE',
timestamp: new Date().toISOString(),
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY]
});
}
if (!hasEscalationCheck) {
this.checks.push({
id: 'scope-step-up-escalation',
name: 'Client scope escalation for step-up auth',
description:
'Client did not make a second authorization request for scope escalation',
status: 'FAILURE',
timestamp: new Date().toISOString(),
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY]
});
}
return this.checks;
}
}

View file

@ -40,5 +40,17 @@ export const SpecReferences: { [key: string]: SpecReference } = {
MCP_ACCESS_TOKEN_USAGE: {
id: 'MCP-Access-token-usage',
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#access-token-usage'
},
MCP_SCOPE_SELECTION_STRATEGY: {
id: 'MCP-Scope-selection-strategy',
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-selection-strategy'
},
MCP_SCOPE_CHALLENGE_HANDLING: {
id: 'MCP-Scope-challenge-handling',
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling'
},
MCP_AUTH_ERROR_HANDLING: {
id: 'MCP-Auth-error-handling',
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#error-handling'
}
};

View file

@ -136,7 +136,9 @@ export async function runClientAgainstScenario(
}
// Verify that only the expected checks failed
const failures = nonInfoChecks.filter((c) => c.status === 'FAILURE');
const failures = nonInfoChecks.filter(
(c) => c.status === 'FAILURE' || c.status === 'WARNING'
);
const failureSlugs = failures.map((c) => c.id);
// Check that failureSlugs contains all expectedFailureSlugs
expect(failureSlugs).toEqual(