Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
# 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
# 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'
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`.
Expand Down
84 changes: 83 additions & 1 deletion src/sdk-runner/config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
};
}
19 changes: 13 additions & 6 deletions src/sdk-runner/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand Down
60 changes: 48 additions & 12 deletions src/sdk-runner/known-sdks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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: {
Expand All @@ -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`
Expand All @@ -96,15 +104,38 @@ 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
// (UseArtifactsOutput), not per-project bin/. The client binary takes the scenario as its first
// 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',
Expand All @@ -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' }
}
}
}
};
Expand Down
81 changes: 79 additions & 2 deletions src/sdk-runner/sdk-runner.test.ts
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
];
Expand Down
Loading