Skip to content

Commit 17f1f93

Browse files
authored
feat: support "draft" as a first-class spec version target (#255)
* feat: support "draft" as a first-class spec version target - Add LATEST_SPEC_VERSION and DATED_SPEC_VERSIONS constants so the next spec release is a one-line change in types.ts. - Accept "draft" as a valid protocolVersion in the initialize check and mock-server response. - --spec-version draft now selects latest-dated scenarios plus draft-tagged ones, so SEP authors can run the full suite against an SDK tracking the in-progress spec without retagging core scenarios. - Forward --spec-version to the client process via MCP_CONFORMANCE_SPEC_VERSION so SDK examples can pick the matching protocolVersion. Closes #253 * review: collapse SpecVersion to wire strings; dedup NEGOTIABLE_PROTOCOL_VERSIONS; split out 'extension' Addresses review feedback from @felixweinberger and @mikekistler: - SpecVersion is now always a wire protocolVersion string (DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION). The separate 'draft' tag literal is gone from the type system; 'draft' survives only as a CLI input alias in resolveSpecVersion. Removes the tag-vs-wire confusion and the specVersionToProtocolVersion mapping. - NEGOTIABLE_PROTOCOL_VERSIONS in types.ts is the single source for what the mock server accepts on initialize (was duplicated in checks/client.ts and scenarios/client/initialize.ts). - 'extension' moved out of SpecVersion into a separate ScenarioSpecTag type. --spec-version extension is no longer valid; extension scenarios remain reachable via --suite extensions. - Draft-tagged scenarios now use the DRAFT_PROTOCOL_VERSION constant so a draft revision bump is a one-line change in types.ts.
1 parent d944122 commit 17f1f93

17 files changed

Lines changed: 221 additions & 78 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,12 @@ npx @modelcontextprotocol/conformance client --command "<client-command>" --scen
6464
- `--command` - The command to run your MCP client (can include flags)
6565
- `--scenario` - The test scenario to run (e.g., "initialize")
6666
- `--suite` - Run a suite of tests in parallel (e.g., "auth")
67+
- `--spec-version <version>` - Filter scenarios by spec version (e.g., `2025-11-25`, `DRAFT-2026-v1`; `draft` is accepted as an alias for the current draft identifier). The draft version selects the latest dated release plus any draft-only scenarios
6768
- `--expected-failures <path>` - Path to YAML baseline file of known failures (see [Expected Failures](#expected-failures))
6869
- `--timeout` - Timeout in milliseconds (default: 30000)
6970
- `--verbose` - Show verbose output
7071

71-
The framework appends `<server-url>` as an argument to your command and sets the `MCP_CONFORMANCE_SCENARIO` environment variable to the scenario name. For scenarios that require additional context (e.g., client credentials), the `MCP_CONFORMANCE_CONTEXT` environment variable contains a JSON object with scenario-specific data.
72+
The framework appends `<server-url>` as an argument to your command and sets the `MCP_CONFORMANCE_SCENARIO` environment variable to the scenario name. For scenarios that require additional context (e.g., client credentials), the `MCP_CONFORMANCE_CONTEXT` environment variable contains a JSON object with scenario-specific data. When `--spec-version` is passed, its resolved value is forwarded to the client process as `MCP_CONFORMANCE_PROTOCOL_VERSION`; example clients can use this value directly as their `protocolVersion`. SDKs that hard-code their protocol version can ignore it.
7273

7374
### Server Testing
7475

src/checks/checks.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createClientInitializationCheck } from './client';
2+
import { DRAFT_PROTOCOL_VERSION } from '../types';
23

34
describe('createClientInitializationCheck', () => {
45
it('should return SUCCESS for a valid initialize request', () => {
@@ -68,6 +69,31 @@ describe('createClientInitializationCheck', () => {
6869
expect(check.errorMessage).toContain('Client version missing');
6970
});
7071

72+
it('should accept the current draft protocol version', () => {
73+
const request = {
74+
protocolVersion: DRAFT_PROTOCOL_VERSION,
75+
clientInfo: { name: 'TestClient', version: '1.0.0' }
76+
};
77+
78+
const check = createClientInitializationCheck(request);
79+
expect(check.status).toBe('SUCCESS');
80+
expect(check.errorMessage).toBeUndefined();
81+
});
82+
83+
it.each(['DRAFT-2025-v1', 'draft'])(
84+
'should reject stale or non-canonical draft version %s',
85+
(protocolVersion) => {
86+
const request = {
87+
protocolVersion,
88+
clientInfo: { name: 'TestClient', version: '1.0.0' }
89+
};
90+
91+
const check = createClientInitializationCheck(request);
92+
expect(check.status).toBe('FAILURE');
93+
expect(check.errorMessage).toContain('Version mismatch');
94+
}
95+
);
96+
7197
it('should support custom expected spec version', () => {
7298
const request = {
7399
protocolVersion: '2024-11-05',

src/checks/client.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { ConformanceCheck, CheckStatus } from '../types';
1+
import {
2+
ConformanceCheck,
3+
CheckStatus,
4+
LATEST_SPEC_VERSION,
5+
NEGOTIABLE_PROTOCOL_VERSIONS
6+
} from '../types';
27

38
export function createServerInfoCheck(serverInfo: {
49
name: string;
@@ -23,19 +28,18 @@ export function createServerInfoCheck(serverInfo: {
2328
};
2429
}
2530

26-
// Valid MCP protocol versions
27-
const VALID_PROTOCOL_VERSIONS = ['2025-06-18', '2025-11-25'];
28-
2931
export function createClientInitializationCheck(
3032
initializeRequest: any,
31-
expectedSpecVersion: string = '2025-11-25'
33+
expectedSpecVersion: string = LATEST_SPEC_VERSION
3234
): ConformanceCheck {
3335
const protocolVersionSent = initializeRequest?.protocolVersion;
3436

3537
// Accept known valid versions OR custom expected version (for backward compatibility)
36-
const validVersions = VALID_PROTOCOL_VERSIONS.includes(expectedSpecVersion)
37-
? VALID_PROTOCOL_VERSIONS
38-
: [...VALID_PROTOCOL_VERSIONS, expectedSpecVersion];
38+
const validVersions = NEGOTIABLE_PROTOCOL_VERSIONS.includes(
39+
expectedSpecVersion
40+
)
41+
? NEGOTIABLE_PROTOCOL_VERSIONS
42+
: [...NEGOTIABLE_PROTOCOL_VERSIONS, expectedSpecVersion];
3943
const versionMatch = validVersions.includes(protocolVersionSent);
4044

4145
const errors: string[] = [];

src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ program
152152
options.command,
153153
scenarioName,
154154
timeout,
155-
outputDir
155+
outputDir,
156+
specVersionFilter
156157
);
157158
return {
158159
scenario: scenarioName,
@@ -259,7 +260,8 @@ program
259260
validated.command,
260261
validated.scenario,
261262
timeout,
262-
outputDir
263+
outputDir,
264+
specVersionFilter
263265
);
264266

265267
const { overallFailure } = printClientResults(

src/runner/client.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { spawn } from 'child_process';
22
import { promises as fs } from 'fs';
33
import path from 'path';
4-
import { ConformanceCheck } from '../types';
4+
import { ConformanceCheck, SpecVersion } from '../types';
55
import { getScenario } from '../scenarios';
66
import { createResultDir, formatPrettyChecks } from './utils';
77

@@ -17,7 +17,8 @@ async function executeClient(
1717
scenarioName: string,
1818
serverUrl: string,
1919
timeout: number = 30000,
20-
context?: Record<string, unknown>
20+
context?: Record<string, unknown>,
21+
specVersion?: SpecVersion
2122
): Promise<ClientExecutionResult> {
2223
const commandParts = command.split(' ');
2324
const executable = commandParts[0];
@@ -34,6 +35,9 @@ async function executeClient(
3435
// 3. Semantic separation: scenario identifies "which test", context provides "test data"
3536
const env = { ...process.env };
3637
env.MCP_CONFORMANCE_SCENARIO = scenarioName;
38+
if (specVersion) {
39+
env.MCP_CONFORMANCE_PROTOCOL_VERSION = specVersion;
40+
}
3741
if (context) {
3842
// Include scenario name in context for discriminated union parsing
3943
env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({
@@ -92,7 +96,8 @@ export async function runConformanceTest(
9296
clientCommand: string,
9397
scenarioName: string,
9498
timeout: number = 30000,
95-
outputDir?: string
99+
outputDir?: string,
100+
specVersion?: SpecVersion
96101
): Promise<{
97102
checks: ConformanceCheck[];
98103
clientOutput: ClientExecutionResult;
@@ -123,7 +128,8 @@ export async function runConformanceTest(
123128
scenarioName,
124129
urls.serverUrl,
125130
timeout,
126-
urls.context
131+
urls.context,
132+
specVersion
127133
);
128134

129135
// Print stdout/stderr if client exited with nonzero code

src/scenarios/client/auth/client-credentials.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
Scenario,
55
ConformanceCheck,
66
ScenarioUrls,
7-
SpecVersion
7+
ScenarioSpecTag
88
} from '../../../types';
99
import { createAuthServer } from './helpers/createAuthServer';
1010
import { createServer } from './helpers/createServer';
@@ -37,7 +37,7 @@ async function generateTestKeypair(): Promise<{
3737
*/
3838
export class ClientCredentialsJwtScenario implements Scenario {
3939
name = 'auth/client-credentials-jwt';
40-
specVersions: SpecVersion[] = ['extension'];
40+
specVersions: ScenarioSpecTag[] = ['extension'];
4141
description =
4242
'Tests OAuth client_credentials flow with private_key_jwt authentication (SEP-1046)';
4343

@@ -256,7 +256,7 @@ export class ClientCredentialsJwtScenario implements Scenario {
256256
*/
257257
export class ClientCredentialsBasicScenario implements Scenario {
258258
name = 'auth/client-credentials-basic';
259-
specVersions: SpecVersion[] = ['extension'];
259+
specVersions: ScenarioSpecTag[] = ['extension'];
260260
description =
261261
'Tests OAuth client_credentials flow with client_secret_basic authentication';
262262

src/scenarios/client/auth/cross-app-access.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
Scenario,
66
ConformanceCheck,
77
ScenarioUrls,
8-
SpecVersion
8+
ScenarioSpecTag
99
} from '../../../types';
1010
import { createAuthServer } from './helpers/createAuthServer';
1111
import { createServer } from './helpers/createServer';
@@ -60,7 +60,7 @@ async function createIdpIdToken(
6060
*/
6161
export class CrossAppAccessCompleteFlowScenario implements Scenario {
6262
name = 'auth/cross-app-access-complete-flow';
63-
specVersions: SpecVersion[] = ['extension'];
63+
specVersions: ScenarioSpecTag[] = ['extension'];
6464
description =
6565
'Tests complete SEP-990 flow: token exchange + JWT bearer grant (Enterprise Managed OAuth)';
6666

src/scenarios/client/auth/offline-access.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { Scenario, ConformanceCheck } from '../../../types';
2-
import { ScenarioUrls, SpecVersion } from '../../../types';
2+
import {
3+
ScenarioUrls,
4+
SpecVersion,
5+
DRAFT_PROTOCOL_VERSION
6+
} from '../../../types';
37
import { createAuthServer } from './helpers/createAuthServer';
48
import { createServer } from './helpers/createServer';
59
import { ServerLifecycle } from './helpers/serverLifecycle';
@@ -23,7 +27,7 @@ import { MockTokenVerifier } from './helpers/mockTokenVerifier';
2327
*/
2428
export class OfflineAccessScopeScenario implements Scenario {
2529
name = 'auth/offline-access-scope';
26-
specVersions: SpecVersion[] = ['draft'];
30+
specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION];
2731
description =
2832
'Tests that a client that wants a refresh token handles offline_access scope and refresh_token grant type when AS supports them (SEP-2207)';
2933

@@ -227,7 +231,7 @@ export class OfflineAccessScopeScenario implements Scenario {
227231
*/
228232
export class OfflineAccessNotSupportedScenario implements Scenario {
229233
name = 'auth/offline-access-not-supported';
230-
specVersions: SpecVersion[] = ['draft'];
234+
specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION];
231235
description =
232236
'Tests that client does not request offline_access when AS does not list it in scopes_supported (SEP-2207)';
233237

src/scenarios/client/auth/resource-mismatch.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { Scenario, ConformanceCheck } from '../../../types.js';
2-
import { ScenarioUrls, SpecVersion } from '../../../types.js';
2+
import {
3+
ScenarioUrls,
4+
SpecVersion,
5+
DRAFT_PROTOCOL_VERSION
6+
} from '../../../types.js';
37
import { createAuthServer } from './helpers/createAuthServer.js';
48
import { createServer } from './helpers/createServer.js';
59
import { ServerLifecycle } from './helpers/serverLifecycle.js';
@@ -27,7 +31,7 @@ import { MockTokenVerifier } from './helpers/mockTokenVerifier.js';
2731
*/
2832
export class ResourceMismatchScenario implements Scenario {
2933
name = 'auth/resource-mismatch';
30-
specVersions: SpecVersion[] = ['draft'];
34+
specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION];
3135
description =
3236
'Tests that client rejects when PRM resource does not match server URL';
3337
allowClientError = true;

src/scenarios/client/initialize.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import {
33
Scenario,
44
ScenarioUrls,
55
ConformanceCheck,
6-
SpecVersion
6+
SpecVersion,
7+
LATEST_SPEC_VERSION,
8+
NEGOTIABLE_PROTOCOL_VERSIONS
79
} from '../../types';
810
import { clientChecks } from '../../checks/index';
911

@@ -117,11 +119,10 @@ export class InitializeScenario implements Scenario {
117119
this.checks.push(clientChecks.createServerInfoCheck(serverInfo));
118120

119121
// Echo back client's version if valid, otherwise use latest
120-
const VALID_VERSIONS = ['2025-06-18', '2025-11-25'];
121122
const clientVersion = initializeRequest?.protocolVersion;
122-
const responseVersion = VALID_VERSIONS.includes(clientVersion)
123+
const responseVersion = NEGOTIABLE_PROTOCOL_VERSIONS.includes(clientVersion)
123124
? clientVersion
124-
: '2025-11-25';
125+
: LATEST_SPEC_VERSION;
125126

126127
const response = {
127128
jsonrpc: '2.0',

0 commit comments

Comments
 (0)