fix: gate SEP-2350 scope-union check to 2026-07-28+ (#337)
The auth/scope-step-up scenario emitted sep-2350-scope-union-on-reauth at every spec version, but the set-wise union requirement (SEP-2350) was only introduced in 2026-07-28. This wrongly warned clients that are conformant at 2025-11-25 and drop the previously-granted scope on re-authorization (e.g. python-sdk v1, surfaced while wiring up its runner in #329). Gate the check behind specVersionAtLeast(specVersion, DRAFT_PROTOCOL_VERSION), a small timeline-ordering helper added to types.ts (mirrors the ordering used by scenario applicability). Add tests pinning both sides of the branch: enforced at draft, silent at 2025-11-25. Follow-up to #329. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7a620cb192
commit
8445dd3aa0
3 changed files with 76 additions and 26 deletions
|
|
@ -130,8 +130,12 @@ describe('Negative tests', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('client only responds to 401, not 403', async () => {
|
test('client only responds to 401, not 403', async () => {
|
||||||
|
// Run at draft so the SEP-2350 union check is also emitted: a client that
|
||||||
|
// never makes the second authorization request fails both the escalation
|
||||||
|
// check and the union check.
|
||||||
const runner = new InlineClientRunner(ignore403Client);
|
const runner = new InlineClientRunner(ignore403Client);
|
||||||
await runClientAgainstScenario(runner, 'auth/scope-step-up', {
|
await runClientAgainstScenario(runner, 'auth/scope-step-up', {
|
||||||
|
specVersion: DRAFT_PROTOCOL_VERSION,
|
||||||
expectedFailureSlugs: [
|
expectedFailureSlugs: [
|
||||||
'scope-step-up-escalation',
|
'scope-step-up-escalation',
|
||||||
'sep-2350-scope-union-on-reauth'
|
'sep-2350-scope-union-on-reauth'
|
||||||
|
|
@ -139,13 +143,26 @@ describe('Negative tests', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('client echoes challenge scope without accumulating prior grant (SEP-2350)', async () => {
|
test('client echoes challenge scope without accumulating prior grant (SEP-2350) at draft', async () => {
|
||||||
|
// The set-wise union requirement (SEP-2350) was added in the draft spec
|
||||||
|
// (2026-07-28); a client that echoes only the challenged scope fails it.
|
||||||
const runner = new InlineClientRunner(echoScopeClient);
|
const runner = new InlineClientRunner(echoScopeClient);
|
||||||
await runClientAgainstScenario(runner, 'auth/scope-step-up', {
|
await runClientAgainstScenario(runner, 'auth/scope-step-up', {
|
||||||
|
specVersion: DRAFT_PROTOCOL_VERSION,
|
||||||
expectedFailureSlugs: ['sep-2350-scope-union-on-reauth']
|
expectedFailureSlugs: ['sep-2350-scope-union-on-reauth']
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('client echoes challenge scope without accumulating prior grant (SEP-2350) at 2025-11-25', async () => {
|
||||||
|
// At dated spec versions that predate SEP-2350, the union check is not
|
||||||
|
// emitted at all, so a client that drops the previously-granted scope on
|
||||||
|
// re-auth passes cleanly.
|
||||||
|
const runner = new InlineClientRunner(echoScopeClient);
|
||||||
|
await runClientAgainstScenario(runner, 'auth/scope-step-up', {
|
||||||
|
specVersion: '2025-11-25'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('client uses DCR instead of CIMD when server supports it', async () => {
|
test('client uses DCR instead of CIMD when server supports it', async () => {
|
||||||
const runner = new InlineClientRunner(noCimdClient);
|
const runner = new InlineClientRunner(noCimdClient);
|
||||||
await runClientAgainstScenario(runner, 'auth/basic-cimd', {
|
await runClientAgainstScenario(runner, 'auth/basic-cimd', {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { createServer } from './helpers/createServer';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||||
import { SpecReferences } from './spec-references';
|
import { SpecReferences } from './spec-references';
|
||||||
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
||||||
|
import { DRAFT_PROTOCOL_VERSION, specVersionAtLeast } from '../../../types';
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -294,9 +295,17 @@ export class ScopeStepUpAuthScenario implements Scenario {
|
||||||
private authServer = new ServerLifecycle();
|
private authServer = new ServerLifecycle();
|
||||||
private server = new ServerLifecycle();
|
private server = new ServerLifecycle();
|
||||||
private checks: ConformanceCheck[] = [];
|
private checks: ConformanceCheck[] = [];
|
||||||
|
// SEP-2350's set-wise union requirement was introduced in 2026-07-28 (the
|
||||||
|
// current draft); it was not a requirement at 2025-11-25, where
|
||||||
|
// non-accumulating re-auth is conformant. Gate the union check accordingly.
|
||||||
|
private unionRequired = false;
|
||||||
|
|
||||||
async start(ctx: ScenarioContext): Promise<ScenarioUrls> {
|
async start(ctx: ScenarioContext): Promise<ScenarioUrls> {
|
||||||
this.checks = [];
|
this.checks = [];
|
||||||
|
this.unionRequired = specVersionAtLeast(
|
||||||
|
ctx.specVersion,
|
||||||
|
DRAFT_PROTOCOL_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
const initialScope = 'mcp:basic';
|
const initialScope = 'mcp:basic';
|
||||||
// tools/call gates on mcp:write only (not the union) so the scenario can
|
// tools/call gates on mcp:write only (not the union) so the scenario can
|
||||||
|
|
@ -352,22 +361,24 @@ export class ScopeStepUpAuthScenario implements Scenario {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const retainedPrior = requestedScopes.includes(initialScope);
|
if (this.unionRequired) {
|
||||||
this.checks.push({
|
const retainedPrior = requestedScopes.includes(initialScope);
|
||||||
id: 'sep-2350-scope-union-on-reauth',
|
this.checks.push({
|
||||||
name: 'Client accumulates previously-granted scopes on re-authorization',
|
id: 'sep-2350-scope-union-on-reauth',
|
||||||
description: retainedPrior
|
name: 'Client accumulates previously-granted scopes on re-authorization',
|
||||||
? 'Client included previously-granted scopes alongside the newly challenged scope when re-authorizing'
|
description: retainedPrior
|
||||||
: 'Client SHOULD compute the union of previously requested scopes and newly challenged scopes when initiating re-authorization (SEP-2350); previously-granted scope was dropped',
|
? 'Client included previously-granted scopes alongside the newly challenged scope when re-authorizing'
|
||||||
status: retainedPrior ? 'SUCCESS' : 'WARNING',
|
: 'Client SHOULD compute the union of previously requested scopes and newly challenged scopes when initiating re-authorization (SEP-2350); previously-granted scope was dropped',
|
||||||
timestamp: data.timestamp,
|
status: retainedPrior ? 'SUCCESS' : 'WARNING',
|
||||||
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING],
|
timestamp: data.timestamp,
|
||||||
details: {
|
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING],
|
||||||
previouslyGranted: initialScope,
|
details: {
|
||||||
challengedScope: stepUpScope,
|
previouslyGranted: initialScope,
|
||||||
requestedScope: data.scope || 'none'
|
challengedScope: stepUpScope,
|
||||||
}
|
requestedScope: data.scope || 'none'
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -500,15 +511,17 @@ export class ScopeStepUpAuthScenario implements Scenario {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY]
|
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY]
|
||||||
});
|
});
|
||||||
this.checks.push({
|
if (this.unionRequired) {
|
||||||
id: 'sep-2350-scope-union-on-reauth',
|
this.checks.push({
|
||||||
name: 'Client accumulates previously-granted scopes on re-authorization',
|
id: 'sep-2350-scope-union-on-reauth',
|
||||||
description:
|
name: 'Client accumulates previously-granted scopes on re-authorization',
|
||||||
'Client did not make a second authorization request - scope union check could not be performed',
|
description:
|
||||||
status: 'FAILURE',
|
'Client did not make a second authorization request - scope union check could not be performed',
|
||||||
timestamp: new Date().toISOString(),
|
status: 'FAILURE',
|
||||||
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING]
|
timestamp: new Date().toISOString(),
|
||||||
});
|
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING]
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.checks;
|
return this.checks;
|
||||||
|
|
|
||||||
20
src/types.ts
20
src/types.ts
|
|
@ -57,6 +57,26 @@ export const NEGOTIABLE_PROTOCOL_VERSIONS: readonly string[] = [
|
||||||
*/
|
*/
|
||||||
export type SpecVersion = DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION;
|
export type SpecVersion = DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION;
|
||||||
|
|
||||||
|
/** Spec versions in timeline order, dated revisions followed by the draft. */
|
||||||
|
const SPEC_VERSION_TIMELINE: readonly SpecVersion[] = [
|
||||||
|
...DATED_SPEC_VERSIONS,
|
||||||
|
DRAFT_PROTOCOL_VERSION
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `v` is at or after `threshold` on the spec timeline. Lets a check
|
||||||
|
* gate itself to the version that introduced its requirement (e.g. a draft-only
|
||||||
|
* requirement passes `DRAFT_PROTOCOL_VERSION` as the threshold).
|
||||||
|
*/
|
||||||
|
export function specVersionAtLeast(
|
||||||
|
v: SpecVersion,
|
||||||
|
threshold: SpecVersion
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
SPEC_VERSION_TIMELINE.indexOf(v) >= SPEC_VERSION_TIMELINE.indexOf(threshold)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Scenarios may also be tagged 'extension' to mark them as off-timeline
|
// Scenarios may also be tagged 'extension' to mark them as off-timeline
|
||||||
// (selectable via --suite extensions, never via --spec-version). See #256.
|
// (selectable via --suite extensions, never via --spec-version). See #256.
|
||||||
export type ScenarioSpecTag = SpecVersion | 'extension';
|
export type ScenarioSpecTag = SpecVersion | 'extension';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue