feat(sdk-runner): add rust-sdk + per-spec-version config overlays (#419)

* feat(sdk-runner): add rust-sdk to KNOWN_SDKS

The mcp-conformance package's fixture bins follow the harness contract
directly (URL argv + MCP_CONFORMANCE_SCENARIO env), and the server
selects the SEP-2575 stateless lifecycle via STATELESS=1. Requires the
repo's pinned Rust toolchain (rustup installs it on demand).

* feat(sdk-runner): per-spec-version config overlays (specOverrides)

Several KNOWN_SDKS entries needed a different invocation when targeting
2026-07-28 — go-sdk's -stateless flag, csharp-sdk's /stateless URL,
rust-sdk's STATELESS=1 env, python-sdk's per-revision baseline — and each
carried a comment telling the caller which flag to paste. Encode those
variants in the entry instead: specOverrides maps a canonical spec
version to a partial config that is merged over the base entry when a
run targets that version (--spec-version, after 'draft' alias
resolution, or the entry's own specVersion default). Explicit CLI flags
still win.

Keys are validated against the known spec versions at schema level so a
'draft' key or a typo'd date fails loudly instead of silently never
matching.

* style: prettier fixes in sdk-runner tests

* refactor(sdk-runner): reuse isSpecVersion for specOverrides key validation

Reuses the canonical spec-version list from #421 instead of keeping a
separate copy; SPEC_VERSION_TIMELINE is now exported so the validation
message can enumerate the valid keys.
This commit is contained in:
Paul Carleton 2026-07-28 10:56:25 +01:00 • committed by GitHub
commit 49103de6ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 241 additions and 28 deletions

View file

@ -299,14 +299,25 @@ npm start -- sdk typescript-sdk --mode client --spec-version draft
Build/run commands for each official SDK are looked up by name from [`src/sdk-runner/known-sdks.ts`](src/sdk-runner/known-sdks.ts) — no config file is required in the SDK repo. Resolution order is **CLI flag > built-in entry**, so any field can be overridden on the command line for refs that diverge from the built-in.
An SDK can have more than one entry when its layout differs across major versions — e.g. `typescript-sdk` (v2, the `main` monorepo) and `typescript-sdk-v1` (the published npm v1.x line). An entry may set `defaultRef` (the branch used when you don't pass `@<ref>`) and `repo` (the real clone target when the entry name is an alias). Overriding built-in fields for a one-off run:
An SDK can have more than one entry when its layout differs across major versions — e.g. `typescript-sdk` (v2, the `main` monorepo) and `typescript-sdk-v1` (the published npm v1.x line). An entry may set `defaultRef` (the branch used when you don't pass `@<ref>`) and `repo` (the real clone target when the entry name is an alias).
When the right invocation depends on the spec version being targeted, the entry carries it in `specOverrides` instead of a comment to copy from. The matching entry is merged over the base config when you pass `--spec-version` (or when the entry's own `specVersion` default applies), so version-specific runs need no extra flags:
```bash
# e.g. a 2026-07-28 run against go-sdk's server — the built-in server command
# pins -stateless=false (what the dated-spec suites need), so override it to
# get the SEP-2575 stateless lifecycle:
npm start -- sdk go-sdk --mode server --suite all --spec-version 2026-07-28 \
--server-cmd './.conformance-server -http=localhost:3000'
# go-sdk's server pins -stateless=false for the dated-spec suites; its
# specOverrides['2026-07-28'] entry swaps in the stateless invocation, so
# this is the whole command:
npm start -- sdk go-sdk --mode server --suite all --spec-version 2026-07-28
# same for csharp-sdk (stateless URL), rust-sdk (STATELESS=1 env), and
# python-sdk (per-revision expected-failures baseline)
```
Explicit CLI flags still beat everything, config included — overriding a field for a one-off run:
```bash
npm start -- sdk go-sdk@my-fork-branch --mode server \
--build-cmd 'go build -o ./.conformance-server ./experimental/server'
```
To add a new SDK to the matrix, add an entry to `KNOWN_SDKS`.

View file

@ -1,4 +1,32 @@
import { z } from 'zod';
import {
DRAFT_PROTOCOL_VERSION,
SPEC_VERSION_TIMELINE,
isSpecVersion
} from '../types';
// Fields an entry may vary per targeted spec version. Identity fields
// (repo / defaultRef / specVersion) can't vary — they pick what to clone,
// not how to drive it. `server` is partial here so an override can change
// just the command (go-sdk) or just the url (csharp-sdk).
const SpecOverrideSchema = z.object({
build: z.string().optional(),
client: z
.object({
command: z.string()
})
.optional(),
server: z
.object({
command: z.string().optional(),
url: z.string().url().optional(),
readyTimeoutMs: z.number().int().positive().optional()
})
.optional(),
expectedFailures: z.string().optional()
});
export type SpecOverride = z.infer<typeof SpecOverrideSchema>;
export const SdkConfigSchema = z.object({
// Clone this repo instead of the KNOWN_SDKS key — lets an alias entry
@ -23,7 +51,61 @@ export const SdkConfigSchema = z.object({
// Spec version this SDK targets, used as the default --spec-version when
// the flag isn't given (e.g. a v1 SDK pinned to the latest dated spec).
// An explicit --spec-version on the sdk command always wins.
specVersion: z.string().optional()
specVersion: z.string().optional(),
// Per-spec-version defaults, keyed by the canonical spec version a run
// targets (--spec-version after alias resolution — so keys are the dated
// strings, never 'draft' — or the entry's own specVersion). Matched entries
// are merged over the base config field-by-field before CLI flags apply, so
// `sdk go-sdk --mode server --spec-version 2026-07-28` picks up the right
// server invocation with no manual overrides.
// Precedence: CLI flag > specOverrides > base entry.
specOverrides: z
.record(z.string(), SpecOverrideSchema)
.superRefine((overrides, ctx) => {
// Keys must be canonical spec versions: a 'draft' key (or a typo'd
// date) would silently never match, because the requested version is
// resolved to its dated form before the lookup.
for (const key of Object.keys(overrides)) {
if (!isSpecVersion(key)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
`specOverrides key '${key}' is not a spec version. ` +
`Use one of: ${SPEC_VERSION_TIMELINE.join(', ')} ` +
`('draft' resolves to ${DRAFT_PROTOCOL_VERSION} before lookup, so key the dated form).`
});
}
}
})
.optional()
});
export type SdkConfig = z.infer<typeof SdkConfigSchema>;
/**
* Resolve the config to use for a run targeting `specVersion`: the base
* entry with the matching specOverrides entry (if any) merged on top.
* Returns the input unchanged when nothing matches; never mutates it.
*/
export function resolveConfigForSpec(
config: SdkConfig,
specVersion: string | undefined
): SdkConfig {
const override = specVersion
? config.specOverrides?.[specVersion]
: undefined;
if (!override) return config;
const server =
config.server || override.server
? { ...config.server, ...override.server }
: undefined;
return {
...config,
build: override.build ?? config.build,
client: override.client ?? config.client,
// The base schema guarantees command+url when a base server exists; an
// override without a base server must itself be complete to be usable.
server: server as SdkConfig['server'],
expectedFailures: override.expectedFailures ?? config.expectedFailures
};
}

View file

@ -1,9 +1,10 @@
import { spawn, ChildProcess } from 'child_process';
import path from 'path';
import { Command, Option } from 'commander';
import { SdkConfig } from './config';
import { SdkConfig, resolveConfigForSpec } from './config';
import { parseSdkSpec, ensureCheckout } from './checkout';
import { lookupBuiltinConfig, knownSdkNames } from './known-sdks';
import { resolveSpecVersion } from '../scenarios';
type Mode = 'client' | 'server';
@ -190,7 +191,17 @@ export function createSdkCommand(): Command {
spec?.name ?? path.basename(path.resolve(options.path!));
// Resolution: CLI flag > built-in entry (KNOWN_SDKS).
const builtinConfig: SdkConfig = lookupBuiltinConfig(sdkName) ?? {};
const baseConfig: SdkConfig = lookupBuiltinConfig(sdkName) ?? {};
// The targeted spec version (explicit flag wins over the per-SDK
// default) selects that version's specOverrides entry, so version-
// specific invocations live in config instead of copy-paste flags.
// 'draft' resolves to its dated alias so overlay keys stay canonical.
const requestedSpec = options.specVersion ?? baseConfig.specVersion;
const specVersion: string | undefined = requestedSpec
? resolveSpecVersion(requestedSpec)
: undefined;
const builtinConfig = resolveConfigForSpec(baseConfig, specVersion);
// The built-in entry may be an alias (e.g. typescript-sdk-v1): honor its
// `repo` (real clone target) and `defaultRef` (branch when no @ref given).
@ -223,10 +234,6 @@ export function createSdkCommand(): Command {
const output = options.output
? path.resolve(options.output)
: undefined;
// Explicit flag wins over the per-SDK default.
const specVersion: string | undefined =
options.specVersion ?? builtinConfig.specVersion;
if (buildCmd && !options.skipBuild) {
console.error(`[sdk] Building: ${buildCmd}`);
await execShell(buildCmd, dir);

View file

@ -40,10 +40,9 @@ export const KNOWN_SDKS: Record<string, SdkConfig> = {
},
// Fixtures live under conformance/ (everything-client + everything-server,
// mirroring scripts/{client,server}-conformance.sh). The server's -stateless
// flag defaults to true; -stateless=false pins the stateful transport, which
// the dated-spec (`active` suite) scenarios need — for a 2026-07-28 /
// SEP-2575 run, override with --server-cmd './.conformance-server -http=localhost:3000'
// to get the stateless lifecycle.
// flag defaults to true; the dated-spec (`active` suite) scenarios need the
// stateful transport, so the base command pins -stateless=false and the
// 2026-07-28 override drops it for the SEP-2575 stateless lifecycle.
'go-sdk': {
build:
'go build -o ./.conformance-server ./conformance/everything-server && go build -o ./.conformance-client ./conformance/everything-client',
@ -54,14 +53,17 @@ export const KNOWN_SDKS: Record<string, SdkConfig> = {
command: './.conformance-server -http=localhost:3000 -stateless=false',
url: 'http://localhost:3000'
},
expectedFailures: 'conformance/baseline.yml'
expectedFailures: 'conformance/baseline.yml',
specOverrides: {
'2026-07-28': {
server: { command: './.conformance-server -http=localhost:3000' }
}
}
},
// main — targets the 2026-07-28 revision. Same uv workspace layout as v1.x
// (client fixture in .github/actions/conformance/, mcp-everything-server
// workspace package). Two baselines exist upstream: expected-failures.yml
// covers runs at the latest dated spec (the default here), and
// expected-failures.2026-07-28.yml covers --spec-version 2026-07-28 runs —
// pass the latter via --expected-failures when targeting the new revision.
// workspace package). Two baselines exist upstream, one per spec target;
// the 2026-07-28 override picks the matching one.
'python-sdk': {
build: 'uv sync --frozen --all-extras --all-packages',
client: {
@ -71,7 +73,13 @@ export const KNOWN_SDKS: Record<string, SdkConfig> = {
command: 'uv run --frozen mcp-everything-server --port 3000',
url: 'http://localhost:3000/mcp'
},
expectedFailures: '.github/actions/conformance/expected-failures.yml'
expectedFailures: '.github/actions/conformance/expected-failures.yml',
specOverrides: {
'2026-07-28': {
expectedFailures:
'.github/actions/conformance/expected-failures.2026-07-28.yml'
}
}
},
// v1.x — the stable, published line of the python-sdk, analogous to
// typescript-sdk-v1. Clones the python-sdk repo, defaulting to the `v1.x`
@ -96,6 +104,29 @@ export const KNOWN_SDKS: Record<string, SdkConfig> = {
},
expectedFailures: '.github/actions/conformance/expected-failures.yml'
},
// Fixtures live in conformance/ (the mcp-conformance package's
// conformance-client + conformance-server bins; the package is excluded from
// the workspace default-members, so build it explicitly). The server reads
// PORT and STATELESS from the environment: the stateful (dated-spec)
// lifecycle is the default, and the 2026-07-28 override sets STATELESS=1
// for the SEP-2575 stateless lifecycle.
'rust-sdk': {
build: 'cargo build -p mcp-conformance',
client: {
command: './target/debug/conformance-client'
},
server: {
command: 'PORT=3000 ./target/debug/conformance-server',
url: 'http://localhost:3000/mcp'
},
specOverrides: {
'2026-07-28': {
server: {
command: 'STATELESS=1 PORT=3000 ./target/debug/conformance-server'
}
}
}
},
// Fixtures live in tests/ModelContextProtocol.ConformanceClient and
// tests/ModelContextProtocol.ConformanceServer (requires the .NET 10 SDK,
// per global.json); build output goes to the repo-level artifacts/ tree
@ -103,8 +134,8 @@ export const KNOWN_SDKS: Record<string, SdkConfig> = {
// argument rather than reading MCP_CONFORMANCE_SCENARIO, so the command
// bridges the env var into argv ($1 is the server URL the harness appends).
// The server serves the stateful lifecycle at / and the SEP-2575 stateless
// lifecycle at /stateless from the same port — for a 2026-07-28 run,
// override with --server-url http://localhost:3000/stateless.
// lifecycle at /stateless from the same port; the 2026-07-28 override
// points runs at the stateless endpoint.
'csharp-sdk': {
build:
'dotnet build tests/ModelContextProtocol.ConformanceClient -c Release -f net10.0 && dotnet build tests/ModelContextProtocol.ConformanceServer -c Release -f net10.0',
@ -115,6 +146,11 @@ export const KNOWN_SDKS: Record<string, SdkConfig> = {
command:
'dotnet artifacts/bin/ModelContextProtocol.ConformanceServer/Release/net10.0/ModelContextProtocol.ConformanceServer.dll --urls http://localhost:3000',
url: 'http://localhost:3000'
},
specOverrides: {
'2026-07-28': {
server: { url: 'http://localhost:3000/stateless' }
}
}
}
};

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { parseSdkSpec } from './checkout';
import { SdkConfigSchema } from './config';
import { lookupBuiltinConfig, KNOWN_SDKS } from './known-sdks';
import { SdkConfigSchema, resolveConfigForSpec } from './config';
describe('parseSdkSpec', () => {
it('leaves ref undefined when omitted (resolved later via defaultRef/main)', () => {
@ -74,7 +74,7 @@ describe('lookupBuiltinConfig', () => {
});
it('returns null for unknown SDKs', () => {
expect(lookupBuiltinConfig('rust-sdk')).toBeNull();
expect(lookupBuiltinConfig('swift-sdk')).toBeNull();
});
it('exposes python-sdk-v1 with repo + defaultRef + specVersion and both commands', () => {
@ -133,9 +133,86 @@ describe('lookupBuiltinConfig', () => {
expect(cs?.server?.url).toBe('http://localhost:3000');
});
it('exposes rust-sdk with the mcp-conformance fixture bins', () => {
const rs = lookupBuiltinConfig('rust-sdk');
expect(rs?.build).toBe('cargo build -p mcp-conformance');
expect(rs?.client?.command).toBe('./target/debug/conformance-client');
expect(rs?.server?.command).toContain('conformance-server');
expect(rs?.server?.url).toBe('http://localhost:3000/mcp');
});
it('every built-in entry validates against SdkConfigSchema', () => {
for (const [name, cfg] of Object.entries(KNOWN_SDKS)) {
expect(() => SdkConfigSchema.parse(cfg), name).not.toThrow();
}
});
});
describe('resolveConfigForSpec', () => {
it("rejects a 'draft' or typo'd specOverrides key at schema level", () => {
const bad = {
server: { command: 'x', url: 'http://localhost:3000' },
specOverrides: { draft: { server: { command: 'y' } } }
};
expect(() => SdkConfigSchema.parse(bad)).toThrow(/not a spec version/);
const typo = { ...bad, specOverrides: { '2026-7-28': {} } };
expect(() => SdkConfigSchema.parse(typo)).toThrow(/not a spec version/);
});
it('returns the base config when no spec version is given', () => {
const go = KNOWN_SDKS['go-sdk'];
expect(resolveConfigForSpec(go, undefined)).toBe(go);
});
it('returns the base config for a version with no override', () => {
const go = KNOWN_SDKS['go-sdk'];
expect(resolveConfigForSpec(go, '2025-11-25')).toBe(go);
});
it('swaps the whole server command for go-sdk at 2026-07-28', () => {
const resolved = resolveConfigForSpec(KNOWN_SDKS['go-sdk'], '2026-07-28');
expect(resolved.server?.command).toBe(
'./.conformance-server -http=localhost:3000'
);
// untouched fields carry through
expect(resolved.server?.url).toBe('http://localhost:3000');
expect(resolved.client?.command).toBe('./.conformance-client');
expect(resolved.expectedFailures).toBe('conformance/baseline.yml');
});
it('merges a partial server override (csharp url) over the base command', () => {
const resolved = resolveConfigForSpec(
KNOWN_SDKS['csharp-sdk'],
'2026-07-28'
);
expect(resolved.server?.url).toBe('http://localhost:3000/stateless');
expect(resolved.server?.command).toContain(
'ModelContextProtocol.ConformanceServer.dll'
);
});
it('swaps the expected-failures baseline for python-sdk at 2026-07-28', () => {
const resolved = resolveConfigForSpec(
KNOWN_SDKS['python-sdk'],
'2026-07-28'
);
expect(resolved.expectedFailures).toBe(
'.github/actions/conformance/expected-failures.2026-07-28.yml'
);
expect(resolved.server?.command).toContain('mcp-everything-server');
});
it('prefixes STATELESS=1 for rust-sdk at 2026-07-28', () => {
const resolved = resolveConfigForSpec(KNOWN_SDKS['rust-sdk'], '2026-07-28');
expect(resolved.server?.command).toBe(
'STATELESS=1 PORT=3000 ./target/debug/conformance-server'
);
});
it('does not mutate the base entry', () => {
const go = KNOWN_SDKS['go-sdk'];
const before = JSON.stringify(go);
resolveConfigForSpec(go, '2026-07-28');
expect(JSON.stringify(go)).toBe(before);
});
});

View file

@ -64,7 +64,7 @@ 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[] = [
export const SPEC_VERSION_TIMELINE: readonly SpecVersion[] = [
...DATED_SPEC_VERSIONS,
DRAFT_PROTOCOL_VERSION
];