feat: sdk subcommand to run local conformance against any SDK ref (#277)
* feat: add sdk subcommand to run conformance against any SDK ref (#250) * Revise sdk runner: explicit --mode, KNOWN_SDKS-only config, v1/v2 entries Addresses review feedback on the sdk subcommand: - Require --mode (client|server) and remove "both". Each invocation now tests exactly one side with its own exit code; the old default ran client then server but combined exit codes with ||=, which skipped the server side entirely whenever the client run failed. - Resolve build/run config from KNOWN_SDKS + CLI flags only; drop the conformance.config.yaml loader (no SDK ships one yet). The Zod schema stays as the type for the built-in entries. - Split the typescript entry by major version: typescript-sdk (v2/main, pnpm install + build:all, expected-failures.yaml) and typescript-sdk-v1 (v1.x, npm ci + build, conformance-baseline.yml). An entry may set `repo` (real clone target for an alias) and `defaultRef` (branch used when no @ref is given). parseSdkSpec now leaves ref undefined when omitted so defaultRef can apply; a trailing @ is treated as no ref. - Key the clone cache by ref (<repo>/<ref>) so different refs of the same repo no longer share one checkout. - Bound the server readiness probe with a per-request AbortSignal timeout so a server that accepts the socket but never responds can't hang past the overall deadline. * fix(sdk-runner): resolve -o to absolute path; add --expected-failures override; replaceAll for safeName
This commit is contained in:
parent
70f7ba0004
commit
cfcecdba2c
10 changed files with 643 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ dist/
|
|||
.vscode/
|
||||
.idea/
|
||||
.claude/settings.local.json
|
||||
.sdk-under-test/
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ Keep scenarios separate when they're genuinely independent features or when they
|
|||
- **Same `id` for SUCCESS and FAIL.** A check should use one slug and flip `status` + `errorMessage`, not branch into `foo-success` vs `foo-failure` slugs.
|
||||
- **Optimize for Ctrl+F on the slug.** Repetitive check blocks are fine — easier to find the failing one than to unwind a clever helper.
|
||||
- Reuse `ConformanceCheck` and other types from `src/types.ts` rather than defining parallel shapes.
|
||||
- **Don't reimplement the runner.** New subcommands that need to "select scenarios → run them → print summary → compute exit code" must go through the existing `client` / `server` commands (subprocess via `process.execPath` like `tier-check` and `sdk` do) or call shared helpers — never a parallel suite-map / summary loop.
|
||||
- Include `specReferences` pointing to the relevant spec section.
|
||||
- **Severity follows the spec keyword:** MUST / MUST NOT → `FAILURE`; SHOULD / SHOULD NOT → `WARNING`. (CI treats WARNING as a failure, so Tier-1 SDKs still need to satisfy SHOULDs — see #245.)
|
||||
|
||||
|
|
|
|||
44
README.md
44
README.md
|
|
@ -210,6 +210,50 @@ Run `npx @modelcontextprotocol/conformance list --server` to see all available s
|
|||
- **resources-\*** - Resource management scenarios
|
||||
- **prompts-\*** - Prompt management scenarios
|
||||
|
||||
## Running Against an SDK at a Specific Ref
|
||||
|
||||
The `sdk` subcommand clones an SDK repository at a given ref, builds it, and runs the **local** conformance build against it. This is the inner-loop tool for scenario authors and the basis for cross-SDK CI. Examples below use `npm start --` so they run from source — no `npm run build` between edits.
|
||||
|
||||
`--mode client` or `--mode server` is required — each invocation tests exactly one side, so client and server are run (and pass/fail) independently.
|
||||
|
||||
```bash
|
||||
# Run the client conformance suite against typescript-sdk @main (v2)
|
||||
npm start -- sdk typescript-sdk --mode client
|
||||
|
||||
# Run the server conformance suite (separate invocation)
|
||||
npm start -- sdk typescript-sdk --mode server
|
||||
|
||||
# A specific main-line SHA or branch (v2 monorepo)
|
||||
npm start -- sdk typescript-sdk@abc123f --mode client
|
||||
npm start -- sdk typescript-sdk@some-branch --mode server
|
||||
|
||||
# The published v1.x line — separate entry (npm build), defaults to the v1.x branch
|
||||
npm start -- sdk typescript-sdk-v1 --mode client
|
||||
npm start -- sdk typescript-sdk-v1@v1.29.0 --mode server
|
||||
|
||||
# Use an existing local checkout (no clone, no fetch)
|
||||
npm start -- sdk --path ../typescript-sdk --skip-build --mode client
|
||||
|
||||
# Narrow to one scenario / suite
|
||||
npm start -- sdk --path ../typescript-sdk --mode server --scenario server-initialize
|
||||
npm start -- sdk typescript-sdk --mode client --suite auth
|
||||
```
|
||||
|
||||
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 for a one-off ref:
|
||||
|
||||
```bash
|
||||
npm start -- sdk owner/go-sdk@some-branch \
|
||||
--mode client \
|
||||
--build-cmd 'go build -tags mcp_go_client_oauth -o ./.conformance-client ./conformance/everything-client' \
|
||||
--client-cmd './.conformance-client'
|
||||
```
|
||||
|
||||
To add a new SDK to the matrix, add an entry to `KNOWN_SDKS`.
|
||||
|
||||
Clones are cached under `.sdk-under-test/` and reused (fetched) on subsequent runs.
|
||||
|
||||
## SDK Tier Assessment
|
||||
|
||||
The `tier-check` subcommand evaluates an MCP SDK repository against [SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730) (the SDK Tiering System):
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
} from './expected-failures';
|
||||
import { createTierCheckCommand } from './tier-check';
|
||||
import { createNewSepCommand } from './new-sep';
|
||||
import { createSdkCommand } from './sdk-runner';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
// Note on naming: `command` refers to which CLI command is calling this.
|
||||
|
|
@ -544,6 +545,9 @@ program.addCommand(createTierCheckCommand());
|
|||
// New SEP scaffolding command
|
||||
program.addCommand(createNewSepCommand());
|
||||
|
||||
// SDK command - run local conformance against an SDK at a specific ref
|
||||
program.addCommand(createSdkCommand());
|
||||
|
||||
// List scenarios command
|
||||
program
|
||||
.command('list')
|
||||
|
|
|
|||
126
src/sdk-runner/checkout.ts
Normal file
126
src/sdk-runner/checkout.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface SdkSpec {
|
||||
name: string;
|
||||
ref: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed `<name>[@<ref>]` argument. `ref` is left undefined when the user
|
||||
* omits `@<ref>` so the caller can fall back to a per-SDK default branch
|
||||
* (KNOWN_SDKS `defaultRef`) before settling on `main`.
|
||||
*/
|
||||
export interface ParsedSdkSpec {
|
||||
name: string;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ORG = 'modelcontextprotocol';
|
||||
|
||||
export function parseSdkSpec(spec: string): ParsedSdkSpec {
|
||||
const at = spec.lastIndexOf('@');
|
||||
if (at <= 0) {
|
||||
return { name: spec };
|
||||
}
|
||||
// A trailing `@` (empty ref) is treated as "no ref given" so the caller's
|
||||
// defaultRef/main fallback applies, rather than checking out the empty ref.
|
||||
const ref = spec.slice(at + 1);
|
||||
return ref ? { name: spec.slice(0, at), ref } : { name: spec.slice(0, at) };
|
||||
}
|
||||
|
||||
function repoUrl(name: string): string {
|
||||
if (name.includes('/')) {
|
||||
return `https://github.com/${name}.git`;
|
||||
}
|
||||
return `https://github.com/${DEFAULT_ORG}/${name}.git`;
|
||||
}
|
||||
|
||||
async function git(
|
||||
args: string[],
|
||||
cwd: string
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const cmd = 'git';
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (d) => (stdout += d.toString()));
|
||||
child.stderr.on('data', (d) => (stderr += d.toString()));
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`${cmd} ${args.join(' ')} exited with ${code}\n${stderr || stdout}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function dirExists(dir: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(dir);
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an SDK is checked out at the requested ref under cacheDir.
|
||||
* Clones on first use; on subsequent calls fetches and resets to the ref.
|
||||
* Returns the absolute path to the checkout.
|
||||
*/
|
||||
export async function ensureCheckout(
|
||||
spec: SdkSpec,
|
||||
cacheDir: string
|
||||
): Promise<string> {
|
||||
const safeName = spec.name.replace(/\//g, '__');
|
||||
// Key the checkout by ref as well, so different refs of the same repo (e.g.
|
||||
// the typescript-sdk `main` and typescript-sdk-v1 `v1.x` entries) get their
|
||||
// own directory instead of thrashing one checkout between refs/build systems.
|
||||
const safeRef = spec.ref.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const dir = path.resolve(cacheDir, safeName, safeRef);
|
||||
await fs.mkdir(path.dirname(dir), { recursive: true });
|
||||
|
||||
if (await dirExists(path.join(dir, '.git'))) {
|
||||
console.error(`[sdk] Fetching ${spec.name} (cached at ${dir})`);
|
||||
await git(['fetch', '--tags', 'origin'], dir);
|
||||
} else {
|
||||
console.error(`[sdk] Cloning ${repoUrl(spec.name)} -> ${dir}`);
|
||||
await git(['clone', repoUrl(spec.name), dir], path.dirname(dir));
|
||||
}
|
||||
|
||||
// Try the ref as a remote branch first, then fall back to a local-resolvable
|
||||
// ref (tag or SHA).
|
||||
const candidates = [`origin/${spec.ref}`, spec.ref];
|
||||
let resolved: string | undefined;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await git(['rev-parse', '--verify', `${candidate}^{commit}`], dir);
|
||||
resolved = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// rev-parse failure means this candidate doesn't exist; try the next form
|
||||
}
|
||||
}
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
`Ref '${spec.ref}' not found in ${spec.name} (tried ${candidates.join(', ')})`
|
||||
);
|
||||
}
|
||||
|
||||
console.error(`[sdk] Checking out ${spec.name}@${spec.ref} (${resolved})`);
|
||||
await git(['checkout', '--detach', resolved], dir);
|
||||
|
||||
const { stdout } = await git(['rev-parse', '--short', 'HEAD'], dir);
|
||||
console.error(`[sdk] HEAD is ${stdout.trim()}`);
|
||||
|
||||
return dir;
|
||||
}
|
||||
25
src/sdk-runner/config.ts
Normal file
25
src/sdk-runner/config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const SdkConfigSchema = z.object({
|
||||
// Clone this repo instead of the KNOWN_SDKS key — lets an alias entry
|
||||
// (e.g. typescript-sdk-v1) point at the real repo (typescript-sdk).
|
||||
repo: z.string().optional(),
|
||||
// Ref to check out when the SDK is named with no @ref (the "default branch").
|
||||
defaultRef: z.string().optional(),
|
||||
build: z.string().optional(),
|
||||
client: z
|
||||
.object({
|
||||
command: z.string()
|
||||
})
|
||||
.optional(),
|
||||
server: z
|
||||
.object({
|
||||
command: z.string(),
|
||||
url: z.string().url(),
|
||||
readyTimeoutMs: z.number().int().positive().optional()
|
||||
})
|
||||
.optional(),
|
||||
expectedFailures: z.string().optional()
|
||||
});
|
||||
|
||||
export type SdkConfig = z.infer<typeof SdkConfigSchema>;
|
||||
293
src/sdk-runner/index.ts
Normal file
293
src/sdk-runner/index.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import { spawn, ChildProcess } from 'child_process';
|
||||
import path from 'path';
|
||||
import { Command, Option } from 'commander';
|
||||
import { SdkConfig } from './config';
|
||||
import { parseSdkSpec, ensureCheckout } from './checkout';
|
||||
import { lookupBuiltinConfig, knownSdkNames } from './known-sdks';
|
||||
|
||||
type Mode = 'client' | 'server';
|
||||
|
||||
function execShell(command: string, cwd: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, { shell: true, cwd, stdio: 'inherit' });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Command failed (exit ${code}): ${command}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-invoke this CLI as a subprocess so scenario selection / reporting stay in
|
||||
* one place (same approach tier-check uses). Preserves execArgv so tsx/loader
|
||||
* hooks carry over when running from source.
|
||||
*/
|
||||
function selfInvoke(args: string[], cwd: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[...process.execArgv, process.argv[1], ...args],
|
||||
{ cwd, stdio: 'inherit' }
|
||||
);
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve(code ?? 1));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForReady(url: string, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
// Per-probe timeout: a server that accepts the socket but never responds must
|
||||
// not block past the overall deadline (fetch has no timeout of its own).
|
||||
const probeTimeoutMs = 2000;
|
||||
let lastErr: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(probeTimeoutMs)
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Server at ${url} did not become ready within ${timeoutMs}ms: ${lastErr}`
|
||||
);
|
||||
}
|
||||
|
||||
async function withManagedServer<T>(
|
||||
command: string,
|
||||
cwd: string,
|
||||
url: string,
|
||||
readyTimeoutMs: number,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
console.error(`[sdk] Starting server: ${command}`);
|
||||
const child: ChildProcess = spawn(command, {
|
||||
shell: true,
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32'
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (d) => process.stderr.write(`[server] ${d}`));
|
||||
child.stderr?.on('data', (d) => {
|
||||
stderr += d.toString();
|
||||
process.stderr.write(`[server] ${d}`);
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
const exited = new Promise<never>((_, reject) => {
|
||||
child.on('exit', (code) => {
|
||||
if (stopping) return;
|
||||
reject(
|
||||
new Error(
|
||||
`Server exited with code ${code} before tests completed\n${stderr}`
|
||||
)
|
||||
);
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
exited.catch(() => {});
|
||||
|
||||
try {
|
||||
await Promise.race([waitForReady(url, readyTimeoutMs), exited]);
|
||||
console.error(`[sdk] Server ready at ${url}`);
|
||||
return await Promise.race([fn(), exited]);
|
||||
} finally {
|
||||
stopping = true;
|
||||
console.error(`[sdk] Stopping server`);
|
||||
if (process.platform !== 'win32' && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function passThrough(options: {
|
||||
scenario?: string;
|
||||
suite?: string;
|
||||
timeout?: string;
|
||||
verbose?: boolean;
|
||||
output?: string;
|
||||
}): string[] {
|
||||
const args: string[] = [];
|
||||
if (options.scenario) args.push('--scenario', options.scenario);
|
||||
else if (options.suite) args.push('--suite', options.suite);
|
||||
if (options.timeout) args.push('--timeout', options.timeout);
|
||||
if (options.verbose) args.push('--verbose');
|
||||
if (options.output) args.push('-o', options.output);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function createSdkCommand(): Command {
|
||||
return new Command('sdk')
|
||||
.description(
|
||||
'Run the local conformance build against an SDK checked out at a specific ref'
|
||||
)
|
||||
.argument(
|
||||
'[sdk]',
|
||||
'SDK to test as <name>[@<ref>], e.g. typescript-sdk@main. Name may be owner/repo.'
|
||||
)
|
||||
.option(
|
||||
'--path <dir>',
|
||||
'Use an existing local SDK checkout instead of cloning'
|
||||
)
|
||||
.option(
|
||||
'--cache-dir <dir>',
|
||||
'Directory for cached SDK clones',
|
||||
'.sdk-under-test'
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
'--mode <mode>',
|
||||
'Which side to test (required): client or server'
|
||||
).choices(['client', 'server'])
|
||||
)
|
||||
.option('--scenario <name>', 'Run a single scenario (passed through)')
|
||||
.option('--suite <name>', 'Run a suite (passed through)')
|
||||
.option('--skip-build', 'Skip the SDK build step (reuse prior build)')
|
||||
.option('--build-cmd <cmd>', 'Override the build command from config')
|
||||
.option('--client-cmd <cmd>', 'Override the client command from config')
|
||||
.option('--server-cmd <cmd>', 'Override the server command from config')
|
||||
.option('--server-url <url>', 'Override the server URL from config')
|
||||
.option(
|
||||
'--expected-failures <path>',
|
||||
'Override the expected-failures baseline file from config'
|
||||
)
|
||||
.option('--timeout <ms>', 'Per-scenario client timeout (passed through)')
|
||||
.option('-o, --output <dir>', 'Output directory (passed through)')
|
||||
.option('--verbose', 'Verbose output (passed through)')
|
||||
.action(async (sdkArg: string | undefined, options) => {
|
||||
try {
|
||||
const mode = options.mode as Mode | undefined;
|
||||
if (!mode) {
|
||||
throw new Error(`--mode is required (client | server)`);
|
||||
}
|
||||
if (!sdkArg && !options.path) {
|
||||
throw new Error(
|
||||
`Provide an SDK spec (e.g. typescript-sdk@main) or --path`
|
||||
);
|
||||
}
|
||||
|
||||
const spec = sdkArg ? parseSdkSpec(sdkArg) : undefined;
|
||||
const sdkName =
|
||||
spec?.name ?? path.basename(path.resolve(options.path!));
|
||||
|
||||
// Resolution: CLI flag > built-in entry (KNOWN_SDKS).
|
||||
const builtinConfig: SdkConfig = lookupBuiltinConfig(sdkName) ?? {};
|
||||
|
||||
// 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).
|
||||
const dir = options.path
|
||||
? path.resolve(options.path)
|
||||
: await ensureCheckout(
|
||||
{
|
||||
name: builtinConfig.repo ?? spec!.name,
|
||||
ref: spec!.ref ?? builtinConfig.defaultRef ?? 'main'
|
||||
},
|
||||
options.cacheDir
|
||||
);
|
||||
const buildCmd: string | undefined =
|
||||
options.buildCmd ?? builtinConfig.build;
|
||||
const clientCmd: string | undefined =
|
||||
options.clientCmd ?? builtinConfig.client?.command;
|
||||
const serverCmd: string | undefined =
|
||||
options.serverCmd ?? builtinConfig.server?.command;
|
||||
const serverUrl: string | undefined =
|
||||
options.serverUrl ?? builtinConfig.server?.url;
|
||||
// CLI override resolves relative to the user's invocation cwd; the
|
||||
// built-in default resolves relative to the SDK checkout.
|
||||
const expectedFailures = options.expectedFailures
|
||||
? path.resolve(options.expectedFailures)
|
||||
: builtinConfig.expectedFailures
|
||||
? path.resolve(dir, builtinConfig.expectedFailures)
|
||||
: undefined;
|
||||
// Resolve -o to an absolute path so it lands where the user expects,
|
||||
// not relative to the SDK checkout (selfInvoke runs with cwd = dir).
|
||||
const output = options.output
|
||||
? path.resolve(options.output)
|
||||
: undefined;
|
||||
|
||||
if (buildCmd && !options.skipBuild) {
|
||||
console.error(`[sdk] Building: ${buildCmd}`);
|
||||
await execShell(buildCmd, dir);
|
||||
} else if (!buildCmd) {
|
||||
console.error(
|
||||
`[sdk] No build command in config; assuming SDK is already built`
|
||||
);
|
||||
}
|
||||
|
||||
let exitCode: number;
|
||||
|
||||
if (mode === 'client') {
|
||||
if (!clientCmd) {
|
||||
throw new Error(
|
||||
`No client command for '${sdkName}'. Pass --client-cmd, or add it to KNOWN_SDKS in src/sdk-runner/known-sdks.ts (known: ${knownSdkNames().join(', ')}).`
|
||||
);
|
||||
}
|
||||
const args = [
|
||||
'client',
|
||||
'--command',
|
||||
clientCmd,
|
||||
...passThrough({
|
||||
scenario: options.scenario,
|
||||
suite: options.suite ?? 'all',
|
||||
timeout: options.timeout,
|
||||
verbose: options.verbose,
|
||||
output
|
||||
})
|
||||
];
|
||||
if (expectedFailures)
|
||||
args.push('--expected-failures', expectedFailures);
|
||||
console.error(`\n[sdk] conformance ${args.join(' ')}\n`);
|
||||
exitCode = await selfInvoke(args, dir);
|
||||
} else {
|
||||
if (!serverCmd || !serverUrl) {
|
||||
throw new Error(
|
||||
`No server command/url for '${sdkName}'. Pass --server-cmd / --server-url, or add it to KNOWN_SDKS in src/sdk-runner/known-sdks.ts (known: ${knownSdkNames().join(', ')}).`
|
||||
);
|
||||
}
|
||||
const args = [
|
||||
'server',
|
||||
'--url',
|
||||
serverUrl,
|
||||
...passThrough({
|
||||
scenario: options.scenario,
|
||||
suite: options.suite,
|
||||
verbose: options.verbose,
|
||||
output
|
||||
})
|
||||
];
|
||||
if (expectedFailures)
|
||||
args.push('--expected-failures', expectedFailures);
|
||||
exitCode = await withManagedServer(
|
||||
serverCmd,
|
||||
dir,
|
||||
serverUrl,
|
||||
builtinConfig.server?.readyTimeoutMs ?? 15000,
|
||||
async () => {
|
||||
console.error(`\n[sdk] conformance ${args.join(' ')}\n`);
|
||||
return selfInvoke(args, dir);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[sdk] ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
61
src/sdk-runner/known-sdks.ts
Normal file
61
src/sdk-runner/known-sdks.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { SdkConfig } from './config';
|
||||
|
||||
/**
|
||||
* Built-in conformance configs for official SDKs, keyed by repo name.
|
||||
*
|
||||
* These live here (not in the SDK repos) so adding an SDK to the matrix
|
||||
* doesn't require a coordinated cross-repo PR. Any field can be overridden
|
||||
* per-invocation via the CLI flags (--build-cmd / --client-cmd / etc.).
|
||||
*/
|
||||
export const KNOWN_SDKS: Record<string, SdkConfig> = {
|
||||
// v2 — the monorepo on `main` (pnpm). Default ref is `main`.
|
||||
'typescript-sdk': {
|
||||
build: 'pnpm install && pnpm run build:all',
|
||||
client: {
|
||||
command: 'npx tsx test/conformance/src/everythingClient.ts'
|
||||
},
|
||||
server: {
|
||||
command: 'npx tsx test/conformance/src/everythingServer.ts',
|
||||
url: 'http://localhost:3000/mcp'
|
||||
},
|
||||
expectedFailures: 'test/conformance/expected-failures.yaml'
|
||||
},
|
||||
// v1.x — the published npm line. Same fixtures as v2; differs only in the
|
||||
// build (npm, not pnpm) and the baseline filename. Clones the typescript-sdk
|
||||
// repo, defaulting to the `v1.x` branch.
|
||||
'typescript-sdk-v1': {
|
||||
repo: 'typescript-sdk',
|
||||
defaultRef: 'v1.x',
|
||||
build: 'npm ci && npm run build',
|
||||
client: {
|
||||
command: 'npx tsx test/conformance/src/everythingClient.ts'
|
||||
},
|
||||
server: {
|
||||
command: 'npx tsx test/conformance/src/everythingServer.ts',
|
||||
url: 'http://localhost:3000/mcp'
|
||||
},
|
||||
expectedFailures: 'test/conformance/conformance-baseline.yml'
|
||||
},
|
||||
'go-sdk': {
|
||||
build: 'go build -o ./.conformance-server ./examples/server/conformance',
|
||||
// Upstream go-sdk has no client conformance fixture yet (see go-sdk#859).
|
||||
server: {
|
||||
command: './.conformance-server -http=:3000',
|
||||
url: 'http://localhost:3000'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up a built-in config by SDK name. Accepts bare names (typescript-sdk),
|
||||
* owner/repo (modelcontextprotocol/typescript-sdk), or a checkout path
|
||||
* basename — only the final path segment is used as the key.
|
||||
*/
|
||||
export function lookupBuiltinConfig(name: string): SdkConfig | null {
|
||||
const key = name.split('/').pop() ?? name;
|
||||
return KNOWN_SDKS[key] ?? null;
|
||||
}
|
||||
|
||||
export function knownSdkNames(): string[] {
|
||||
return Object.keys(KNOWN_SDKS);
|
||||
}
|
||||
87
src/sdk-runner/sdk-runner.test.ts
Normal file
87
src/sdk-runner/sdk-runner.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSdkSpec } from './checkout';
|
||||
import { SdkConfigSchema } from './config';
|
||||
import { lookupBuiltinConfig, KNOWN_SDKS } from './known-sdks';
|
||||
|
||||
describe('parseSdkSpec', () => {
|
||||
it('leaves ref undefined when omitted (resolved later via defaultRef/main)', () => {
|
||||
expect(parseSdkSpec('typescript-sdk')).toEqual({
|
||||
name: 'typescript-sdk'
|
||||
});
|
||||
});
|
||||
|
||||
it('splits name@ref', () => {
|
||||
expect(parseSdkSpec('typescript-sdk@v1.29.0')).toEqual({
|
||||
name: 'typescript-sdk',
|
||||
ref: 'v1.29.0'
|
||||
});
|
||||
});
|
||||
|
||||
it('handles owner/repo@ref', () => {
|
||||
expect(parseSdkSpec('someorg/some-sdk@abc123')).toEqual({
|
||||
name: 'someorg/some-sdk',
|
||||
ref: 'abc123'
|
||||
});
|
||||
});
|
||||
|
||||
it('treats leading @ as part of the name', () => {
|
||||
expect(parseSdkSpec('@scope/pkg')).toEqual({
|
||||
name: '@scope/pkg'
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a trailing @ as no ref (falls through to defaultRef/main)', () => {
|
||||
expect(parseSdkSpec('typescript-sdk@')).toEqual({ name: 'typescript-sdk' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SdkConfigSchema', () => {
|
||||
it('accepts a minimal client-only config', () => {
|
||||
const cfg = SdkConfigSchema.parse({
|
||||
client: { command: 'tsx fixture.ts' }
|
||||
});
|
||||
expect(cfg.client?.command).toBe('tsx fixture.ts');
|
||||
expect(cfg.server).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects server config without a url', () => {
|
||||
expect(() =>
|
||||
SdkConfigSchema.parse({ server: { command: 'tsx server.ts' } })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookupBuiltinConfig', () => {
|
||||
it('finds an SDK by bare name', () => {
|
||||
expect(lookupBuiltinConfig('typescript-sdk')?.client?.command).toBeTruthy();
|
||||
});
|
||||
|
||||
it('strips owner/ prefix and path segments', () => {
|
||||
expect(lookupBuiltinConfig('modelcontextprotocol/typescript-sdk')).toBe(
|
||||
KNOWN_SDKS['typescript-sdk']
|
||||
);
|
||||
expect(lookupBuiltinConfig('/some/path/to/go-sdk')).toBe(
|
||||
KNOWN_SDKS['go-sdk']
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for unknown SDKs', () => {
|
||||
expect(lookupBuiltinConfig('rust-sdk')).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes the typescript-sdk-v1 alias with repo + defaultRef', () => {
|
||||
const v1 = lookupBuiltinConfig('typescript-sdk-v1');
|
||||
expect(v1?.repo).toBe('typescript-sdk');
|
||||
expect(v1?.defaultRef).toBe('v1.x');
|
||||
});
|
||||
|
||||
it('bare typescript-sdk (v2) has no defaultRef', () => {
|
||||
expect(lookupBuiltinConfig('typescript-sdk')?.defaultRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it('every built-in entry validates against SdkConfigSchema', () => {
|
||||
for (const [name, cfg] of Object.entries(KNOWN_SDKS)) {
|
||||
expect(() => SdkConfigSchema.parse(cfg), name).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ export default defineConfig({
|
|||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', 'dist'],
|
||||
exclude: ['**/node_modules/**', 'dist', '.sdk-under-test'],
|
||||
// Run test files sequentially to avoid port conflicts
|
||||
fileParallelism: false,
|
||||
// Increase timeout for server tests in CI
|
||||
|
|
|
|||
Loading…
Reference in a new issue