There is no way to answer "which scenarios must my implementation pass to conform to spec revision X". The suite accumulates scenarios continuously, so what an implementation is measured against keeps growing after the revision shipped, and it can drop below 100% while standing still. --spec-version does not answer it: it filters on which revision a scenario targets, not on when the scenario became a requirement. Add requirements/<revision>.yaml naming the scenarios a revision requires, and --requirements <revision> on the client, server, list and sdk commands to run exactly that set. The revision also fixes the wire version its scenarios speak: the dated revisions through 2025-11-25 use the stateful initialize handshake and 2026-07-28 is stateless with per-request _meta, so a scenario belonging to two revisions runs once under each and one run does not cover the other. Passing no flag keeps today's behaviour of running everything. tier-check gains --sdk <name[@ref]> / --sdk-path <dir>: for each claimed revision it resolves that revision's build, server invocation and client command from the sdk-runner's KNOWN_SDKS specOverrides, manages the server, runs both legs with cwd = the SDK checkout, and merges. Every claimed revision must pass for Tier 1. This is what makes multi-revision tiering correct for SDKs whose wire era is fixed per server process (go-sdk only speaks >= 2026-07-28 when constructed stateless) or per endpoint (csharp-sdk serves it at /stateless): one --conformance-server-url cannot describe them, and their config already does. The single-URL mode remains for SDKs not in the config. Ship two sets. 2026-07-28 is a snapshot of 0.2.0-alpha.10, published the day before that revision shipped. 2025-11-25 is a reconstruction from the same anchor and its header says so: the release current on its ship date predates spec-version awareness entirely. not_scored entries run and are reported with a reason (extension per SEP-1730, or added-after-release) but never affect a pass rate or the exit code, and --expected-failures judges scored scenarios only. Reports label every entry with its revision, and JSON passed/failed/total describe the scored set so they always agree with pass_rate. tier-check's exit code now gates on the machine-checkable half: nonzero when a scored scenario fails or a leg could not be measured. A child run that cannot happen at all (a set naming a renamed scenario, an unreachable server) reports as not measured rather than as 0% against the implementation. Conformance runs to completion before the GitHub checks, so an API failure can no longer interrupt a leg and orphan a managed server, and the legs spawn their child asynchronously: the previous synchronous exec blocked the event loop, the parent stopped draining the managed server's log pipes, and a chatty server (rust-sdk's per-request tracing) froze on a full pipe and was scored as failing. Driving every tier-1 SDK through the new path also fixed three configs: csharp-sdk's server now launches from its build-output directory so ASP.NET finds appsettings.json (its AllowedHosts filter, which dns-rebinding-protection tests, silently never loaded from the repo root); rust-sdk's STATELESS=1 override is removed because its CI runs one default server for both revisions and the forced mode fails its SEP-2575 scenarios; python-sdk's build mirrors its CI's two targeted syncs because --all-packages is unbuildable on main. withManagedServer now waits for its child to exit before returning, so consecutive runs on one port cannot race a dying predecessor.
252 lines
9.1 KiB
TypeScript
252 lines
9.1 KiB
TypeScript
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
import { dirname, join } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { parse as parseYaml } from 'yaml';
|
|
import { ALL_SPEC_VERSIONS } from './scenarios';
|
|
|
|
/**
|
|
* A frozen requirement set for one specification revision: the scenarios an
|
|
* implementation must pass to conform to that revision, fixed when it shipped.
|
|
*
|
|
* This is the project's contract, not an implementation's configuration. It is
|
|
* the opposite of an expected-failures baseline, which lives in an
|
|
* implementation's own repository and records what that implementation knows it
|
|
* fails. A baselined failure is still a failure against a requirement set.
|
|
*/
|
|
export interface RequirementSet {
|
|
/** Revision this set belongs to, e.g. `2026-07-28`. */
|
|
revision: string;
|
|
/** Scenarios `conformance server` runs against a server implementation. */
|
|
server: string[];
|
|
/** Scenarios `conformance client` runs against a client implementation. */
|
|
client: string[];
|
|
/**
|
|
* Scenarios run and reported alongside the required ones but never counted
|
|
* toward a pass rate. Three reasons qualify, and the report names which:
|
|
* an extension is optional by definition, a scenario added after the
|
|
* revision shipped is one no implementation could have been passing, and a
|
|
* pending scenario is one the suite's own reference fixture cannot pass yet
|
|
* — the implementation under test still might, so it runs for visibility.
|
|
*/
|
|
notScored: NotScored[];
|
|
}
|
|
|
|
export type NotScoredReason = 'extension' | 'added-after-release' | 'pending';
|
|
|
|
export interface NotScored {
|
|
scenario: string;
|
|
leg: Leg;
|
|
reason: NotScoredReason;
|
|
note?: string;
|
|
}
|
|
|
|
/**
|
|
* Roles a requirement set covers. The MCP specification defines two: an MCP
|
|
* server acting as an OAuth resource server, and an MCP client acting as an
|
|
* OAuth client. Authorization-server implementation is explicitly beyond the
|
|
* specification's scope, so those scenarios are not a conformance requirement
|
|
* for anything the spec defines and have no place in a requirement set.
|
|
*/
|
|
export type Leg = 'client' | 'server';
|
|
|
|
const LEGS: Leg[] = ['client', 'server'];
|
|
export const REASONS: NotScoredReason[] = [
|
|
'extension',
|
|
'added-after-release',
|
|
'pending'
|
|
];
|
|
|
|
/** Requirement sets ship with the package; see the `files` entry in package.json. */
|
|
function requirementsDir(): string {
|
|
return join(dirname(fileURLToPath(import.meta.url)), '..', 'requirements');
|
|
}
|
|
|
|
export function listRequirementRevisions(): string[] {
|
|
const dir = requirementsDir();
|
|
if (!existsSync(dir)) return [];
|
|
return readdirSync(dir)
|
|
.filter((f) => f.endsWith('.yaml'))
|
|
.map((f) => f.replace(/\.yaml$/, ''))
|
|
.sort();
|
|
}
|
|
|
|
function asNameList(value: unknown, field: string, revision: string): string[] {
|
|
if (value === undefined) return [];
|
|
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
throw new Error(
|
|
`requirements/${revision}.yaml: "${field}" must be a list of scenario names`
|
|
);
|
|
}
|
|
return value as string[];
|
|
}
|
|
|
|
export function loadRequirements(revision: string): RequirementSet {
|
|
// A revision names a bundled file. Arbitrary paths are deliberately not
|
|
// accepted: a requirement set is the project's contract, so an implementation
|
|
// under test must not be able to supply its own.
|
|
if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(revision)) {
|
|
// A run has one exit code and one expected-failures baseline, so it targets
|
|
// one revision. tier-check is the aggregator and takes several at once.
|
|
throw new Error(
|
|
revision.includes(',')
|
|
? `A run targets one revision at a time, because it has one exit code and one baseline. Run each of "${revision}" separately, or pass them together to \`tier-check --requirements\`.`
|
|
: `Invalid requirements revision: ${revision}. Expected a date such as 2026-07-28.`
|
|
);
|
|
}
|
|
|
|
// The revision is also the wire version its scenarios run at, so it has to be
|
|
// a protocol version this build knows, not merely a date-shaped string.
|
|
if (!(ALL_SPEC_VERSIONS as readonly string[]).includes(revision)) {
|
|
throw new Error(
|
|
`Unknown spec revision: ${revision}. Known revisions: ${ALL_SPEC_VERSIONS.join(', ')}`
|
|
);
|
|
}
|
|
|
|
const path = join(requirementsDir(), `${revision}.yaml`);
|
|
if (!existsSync(path)) {
|
|
const known = listRequirementRevisions();
|
|
throw new Error(
|
|
`No requirement set for ${revision}.` +
|
|
(known.length
|
|
? ` Available: ${known.join(', ')}`
|
|
: ' No requirement sets are bundled with this release.')
|
|
);
|
|
}
|
|
|
|
const parsed = parseYaml(readFileSync(path, 'utf-8')) ?? {};
|
|
|
|
// A frozen contract must fail loudly on anything it does not recognise: a
|
|
// typo'd key ("sever:") would otherwise silently empty a leg and the gate
|
|
// would pass having required nothing.
|
|
const KNOWN_KEYS = new Set(['server', 'client', 'not_scored']);
|
|
for (const key of Object.keys(parsed)) {
|
|
if (key === 'authorization') continue; // rejected below with its own reason
|
|
if (!KNOWN_KEYS.has(key)) {
|
|
throw new Error(
|
|
`requirements/${revision}.yaml: unknown key "${key}" (expected server, client, not_scored)`
|
|
);
|
|
}
|
|
}
|
|
const notScoredRaw = parsed.not_scored ?? [];
|
|
if (!Array.isArray(notScoredRaw)) {
|
|
throw new Error(
|
|
`requirements/${revision}.yaml: "not_scored" must be a list`
|
|
);
|
|
}
|
|
|
|
if (parsed.authorization !== undefined) {
|
|
throw new Error(
|
|
`requirements/${revision}.yaml: requirement sets cover the client and server roles only. ` +
|
|
'Authorization-server implementation is beyond the scope of the MCP specification, so it is not a conformance requirement.'
|
|
);
|
|
}
|
|
|
|
const server = asNameList(parsed.server, 'server', revision);
|
|
const client = asNameList(parsed.client, 'client', revision);
|
|
for (const [leg, names] of [
|
|
['server', server],
|
|
['client', client]
|
|
] as const) {
|
|
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
if (dupes.length > 0) {
|
|
throw new Error(
|
|
`requirements/${revision}.yaml: duplicate ${leg} scenario(s): ${[...new Set(dupes)].join(', ')}`
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
revision,
|
|
server,
|
|
client,
|
|
notScored: notScoredRaw.map((entry: unknown) => {
|
|
const e = entry as NotScored;
|
|
if (!e || typeof e.scenario !== 'string') {
|
|
throw new Error(
|
|
`requirements/${revision}.yaml: each not_scored entry needs a "scenario" name`
|
|
);
|
|
}
|
|
if (!LEGS.includes(e.leg)) {
|
|
throw new Error(
|
|
`requirements/${revision}.yaml: ${e.scenario} needs a "leg" of ${LEGS.join(', ')}`
|
|
);
|
|
}
|
|
if (!REASONS.includes(e.reason)) {
|
|
throw new Error(
|
|
`requirements/${revision}.yaml: ${e.scenario} needs a "reason" of ${REASONS.join(', ')}`
|
|
);
|
|
}
|
|
// A half-finished promotion (a scenario both scored and not_scored)
|
|
// would run twice and be counted inconsistently across code paths.
|
|
const scoredNames = e.leg === 'server' ? server : client;
|
|
if (scoredNames.includes(e.scenario)) {
|
|
throw new Error(
|
|
`requirements/${revision}.yaml: ${e.scenario} is both scored (${e.leg}) and not_scored — finish the promotion by removing one entry`
|
|
);
|
|
}
|
|
return {
|
|
scenario: e.scenario,
|
|
leg: e.leg,
|
|
reason: e.reason,
|
|
note: e.note
|
|
};
|
|
})
|
|
};
|
|
}
|
|
|
|
/** Scenarios that count toward the pass rate for a leg. */
|
|
export function scoredScenarios(
|
|
requirements: RequirementSet,
|
|
leg: Leg
|
|
): string[] {
|
|
return requirements[leg];
|
|
}
|
|
|
|
/** Scenarios run and reported for a leg but excluded from the pass rate. */
|
|
export function notScoredScenarios(
|
|
requirements: RequirementSet,
|
|
leg: Leg
|
|
): NotScored[] {
|
|
return requirements.notScored.filter((entry) => entry.leg === leg);
|
|
}
|
|
|
|
/** Everything a leg runs: what it scores, plus what it only reports. */
|
|
export function scenariosToRun(
|
|
requirements: RequirementSet,
|
|
leg: Leg
|
|
): string[] {
|
|
return [
|
|
...scoredScenarios(requirements, leg),
|
|
...notScoredScenarios(requirements, leg).map((e) => e.scenario)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Narrow a scenario list to the revision's requirements.
|
|
*
|
|
* Names the requirement set asks for that this build does not have are an
|
|
* error: the set is frozen, so a missing name means the suite renamed or
|
|
* removed a scenario out from under it, and silently scoring a smaller set
|
|
* would understate what conformance means.
|
|
*/
|
|
export function filterScenariosByRequirements(
|
|
allScenarios: string[],
|
|
requirements: RequirementSet,
|
|
leg: Leg
|
|
): string[] {
|
|
const scored = new Set(scoredScenarios(requirements, leg));
|
|
const available = new Set(allScenarios);
|
|
const wanted = scenariosToRun(requirements, leg);
|
|
// Only the scored names are a contract violation when absent. A not-scored
|
|
// entry can legitimately postdate the build being run, which is the whole
|
|
// reason it is listed separately.
|
|
const missing = wanted.filter(
|
|
(name) => !available.has(name) && scored.has(name)
|
|
);
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`requirements/${requirements.revision}.yaml lists ${leg} scenarios that this build does not provide: ${missing.join(', ')}`
|
|
);
|
|
}
|
|
return wanted.filter((name) => available.has(name));
|
|
}
|