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:
Bill Prin 2026-06-17 10:55:31 -07:00 • committed by GitHub
commit 8445dd3aa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 76 additions and 26 deletions

View file

@ -130,8 +130,12 @@ describe('Negative tests', () => {
});
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);
await runClientAgainstScenario(runner, 'auth/scope-step-up', {
specVersion: DRAFT_PROTOCOL_VERSION,
expectedFailureSlugs: [
'scope-step-up-escalation',
'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);
await runClientAgainstScenario(runner, 'auth/scope-step-up', {
specVersion: DRAFT_PROTOCOL_VERSION,
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 () => {
const runner = new InlineClientRunner(noCimdClient);
await runClientAgainstScenario(runner, 'auth/basic-cimd', {

View file

@ -6,6 +6,7 @@ import { createServer } from './helpers/createServer';
import { ServerLifecycle } from './helpers/serverLifecycle';
import { SpecReferences } from './spec-references';
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
import { DRAFT_PROTOCOL_VERSION, specVersionAtLeast } from '../../../types';
import type { Request, Response, NextFunction } from 'express';
/**
@ -294,9 +295,17 @@ export class ScopeStepUpAuthScenario implements Scenario {
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
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> {
this.checks = [];
this.unionRequired = specVersionAtLeast(
ctx.specVersion,
DRAFT_PROTOCOL_VERSION
);
const initialScope = 'mcp:basic';
// 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);
this.checks.push({
id: 'sep-2350-scope-union-on-reauth',
name: 'Client accumulates previously-granted scopes on re-authorization',
description: retainedPrior
? 'Client included previously-granted scopes alongside the newly challenged scope when re-authorizing'
: 'Client SHOULD compute the union of previously requested scopes and newly challenged scopes when initiating re-authorization (SEP-2350); previously-granted scope was dropped',
status: retainedPrior ? 'SUCCESS' : 'WARNING',
timestamp: data.timestamp,
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING],
details: {
previouslyGranted: initialScope,
challengedScope: stepUpScope,
requestedScope: data.scope || 'none'
}
});
if (this.unionRequired) {
const retainedPrior = requestedScopes.includes(initialScope);
this.checks.push({
id: 'sep-2350-scope-union-on-reauth',
name: 'Client accumulates previously-granted scopes on re-authorization',
description: retainedPrior
? 'Client included previously-granted scopes alongside the newly challenged scope when re-authorizing'
: 'Client SHOULD compute the union of previously requested scopes and newly challenged scopes when initiating re-authorization (SEP-2350); previously-granted scope was dropped',
status: retainedPrior ? 'SUCCESS' : 'WARNING',
timestamp: data.timestamp,
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING],
details: {
previouslyGranted: initialScope,
challengedScope: stepUpScope,
requestedScope: data.scope || 'none'
}
});
}
}
}
});
@ -500,15 +511,17 @@ export class ScopeStepUpAuthScenario implements Scenario {
timestamp: new Date().toISOString(),
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY]
});
this.checks.push({
id: 'sep-2350-scope-union-on-reauth',
name: 'Client accumulates previously-granted scopes on re-authorization',
description:
'Client did not make a second authorization request - scope union check could not be performed',
status: 'FAILURE',
timestamp: new Date().toISOString(),
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING]
});
if (this.unionRequired) {
this.checks.push({
id: 'sep-2350-scope-union-on-reauth',
name: 'Client accumulates previously-granted scopes on re-authorization',
description:
'Client did not make a second authorization request - scope union check could not be performed',
status: 'FAILURE',
timestamp: new Date().toISOString(),
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING]
});
}
}
return this.checks;

View file

@ -57,6 +57,26 @@ export const NEGOTIABLE_PROTOCOL_VERSIONS: readonly string[] = [
*/
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
// (selectable via --suite extensions, never via --spec-version). See #256.
export type ScenarioSpecTag = SpecVersion | 'extension';