Implement MCP 2026-07-28 (#2286)

Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Felix Weinberger 2026-06-26 14:01:10 +01:00 • committed by GitHub
commit 1823aae891
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 69131 additions and 9001 deletions

View file

@ -0,0 +1,232 @@
#!/usr/bin/env tsx
/**
* Build-and-e2e-run every story under `examples/` over every transport × era
* leg it supports. Each story's `client.ts` is a self-verifying top-level-await
* script: a `check.*` failure throws, Node prints the error and exits 1; on
* success `client.close()` releases the last handle and Node exits 0. The
* harness reports PASS/FAIL from the child's exit code (a timeout is a FAIL
* with "hung — possible unclosed handle").
*
* - **stdio** (default for dual-transport stories): run `client.ts` with no
* transport flag; it spawns the sibling server binary itself and speaks
* MCP over the pipe.
* - **HTTP**: start `server.ts --http --port <P>`, poll until ready, run
* `client.ts --http http://127.0.0.1:<P>/<path>`, kill the server.
* - **modern** (default): the client negotiates the 2026-07-28 era
* (`versionNegotiation: { mode: 'auto' }`).
* - **legacy**: pass `--legacy` to the client so it uses the 2025
* `initialize` handshake (`versionNegotiation: { mode: 'legacy' }`).
*
* Per-story configuration lives in the story's `package.json` under the
* `"example"` field — most stories have none. `excluded` stories are listed
* (with their reason) but not run. Stories without a `client.ts` are skipped.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { connect } from 'node:net';
import { join, resolve } from 'node:path';
type Era = 'modern' | 'legacy';
type Transport = 'stdio' | 'http';
interface ExampleConfig {
/** Transports to run (default: `['stdio', 'http']`). */
transports?: Transport[];
/** `'dual'` (modern + legacy; the default), `'modern'`, or `'legacy'`. */
era?: 'dual' | Era;
/** HTTP port (default: a per-story port assigned below). */
port?: number;
/** Endpoint path (default: `'/mcp'`). */
path?: string;
/** Extra environment for the server process. */
env?: Record<string, string>;
/** Per-leg timeout in milliseconds (default: 30000). */
timeoutMs?: number;
/** Optional substring the client's stdout must contain. */
expects?: { stdout?: string };
/** When present, the story is skipped (with this reason printed). */
excluded?: string;
}
const ROOT = resolve(import.meta.dirname, '../..');
const EXAMPLES = join(ROOT, 'examples');
const TSX = join(ROOT, 'node_modules', '.bin', 'tsx');
/** Directories that are never stories. */
const NON_STORY = new Set(['shared', 'guides', 'server-quickstart', 'client-quickstart', 'node_modules']);
/** Distinct per-story HTTP ports so the servers never collide. */
let nextPort = 8530;
const portFor = new Map<string, number>();
function assignPort(story: string, config: ExampleConfig): number {
if (config.port) return config.port;
if (!portFor.has(story)) portFor.set(story, nextPort++);
return portFor.get(story)!;
}
function readConfig(dir: string): ExampleConfig {
const file = join(dir, 'package.json');
if (!existsSync(file)) return {};
const pkg = JSON.parse(readFileSync(file, 'utf8')) as { example?: ExampleConfig };
return pkg.example ?? {};
}
function run(
cmd: string,
args: string[],
opts: { cwd: string; env?: Record<string, string>; timeoutMs: number }
): Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }> {
return new Promise(resolvePromise => {
const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env } });
let stdout = '';
let stderr = '';
child.stdout.on('data', d => (stdout += String(d)));
child.stderr.on('data', d => (stderr += String(d)));
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolvePromise({ code: 124, stdout, stderr: stderr + '\n[harness] timed out', timedOut: true });
}, opts.timeoutMs);
child.on('close', code => {
clearTimeout(timer);
resolvePromise({ code: code ?? 1, stdout, stderr, timedOut: false });
});
child.on('error', err => {
clearTimeout(timer);
resolvePromise({ code: 1, stdout, stderr: stderr + `\n[harness] spawn error: ${err.message}`, timedOut: false });
});
});
}
/**
* Story `client.ts` files are top-level-await scripts: a thrown `check.*`
* propagates as an unhandled rejection (Node prints + exits 1); a clean run
* exits 0 once `client.close()` releases the last handle. The harness prints
* PASS/FAIL itself from the child's exit code — there is no in-band OK/FAIL
* line. A timeout means the process never exited on its own and is reported as
* a hang (possible unclosed handle).
*/
function toLegResult(
story: string,
leg: string,
result: { code: number; stdout: string; stderr: string; timedOut: boolean },
config: ExampleConfig,
serverLog?: string
): LegResult {
if (result.timedOut) {
return { story, leg, ok: false, detail: `(hung — possible unclosed handle)\n${result.stderr || result.stdout}${serverLog ?? ''}` };
}
const ok = result.code === 0 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout));
return {
story,
leg,
ok,
detail: ok
? (result.stdout.trim().split('\n').pop() ?? '')
: `exit ${result.code}\n${result.stderr || result.stdout}${serverLog ?? ''}`
};
}
async function waitForPort(port: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ok = await new Promise<boolean>(resolvePromise => {
const sock = connect({ port, host: '127.0.0.1' }, () => {
sock.destroy();
resolvePromise(true);
});
sock.on('error', () => resolvePromise(false));
});
if (ok) return true;
await new Promise(r => setTimeout(r, 200));
}
return false;
}
interface LegResult {
story: string;
leg: string;
ok: boolean;
detail: string;
}
const eraArgs = (era: Era): string[] => (era === 'legacy' ? ['--legacy'] : []);
async function runStdioLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise<LegResult> {
const timeoutMs = config.timeoutMs ?? 30_000;
const result = await run(TSX, [join(dir, 'client.ts'), ...eraArgs(era)], { cwd: ROOT, timeoutMs });
return toLegResult(story, `stdio/${era}`, result, config);
}
async function runHttpLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise<LegResult> {
const timeoutMs = config.timeoutMs ?? 30_000;
const port = assignPort(story, config);
const path = config.path ?? '/mcp';
const url = `http://127.0.0.1:${port}${path}`;
let serverStderr = '';
const server: ChildProcess = spawn(TSX, [join(dir, 'server.ts'), '--http', '--port', String(port)], {
cwd: ROOT,
env: { ...process.env, PORT: String(port), ...config.env }
});
server.stderr?.on('data', d => (serverStderr += String(d)));
server.stdout?.on('data', d => (serverStderr += String(d)));
try {
const ready = await waitForPort(port, 15_000);
if (!ready) {
return { story, leg: `http/${era}`, ok: false, detail: `server never bound :${port}\n--- server log ---\n${serverStderr}` };
}
const result = await run(TSX, [join(dir, 'client.ts'), '--http', url, ...eraArgs(era)], { cwd: ROOT, timeoutMs });
return toLegResult(story, `http/${era}`, result, config, `\n--- server log ---\n${serverStderr}`);
} finally {
server.kill('SIGTERM');
await new Promise(r => setTimeout(r, 100));
// `.killed` flips true the moment kill() is called, so it can't gate
// the backstop; check whether the process actually exited instead.
if (server.exitCode === null && server.signalCode === null) server.kill('SIGKILL');
}
}
async function main(): Promise<void> {
const stories = readdirSync(EXAMPLES, { withFileTypes: true })
.filter(d => d.isDirectory() && !NON_STORY.has(d.name))
.map(d => d.name)
.filter(name => existsSync(join(EXAMPLES, name, 'client.ts')))
.sort();
const results: LegResult[] = [];
const excluded: Array<{ story: string; reason: string }> = [];
for (const story of stories) {
const dir = join(EXAMPLES, story);
const config = readConfig(dir);
if (config.excluded) {
excluded.push({ story, reason: config.excluded });
console.log(`\n::group::example ${story}\nSKIPPED: ${config.excluded}\n::endgroup::`);
continue;
}
const transports: Transport[] = config.transports ?? ['stdio', 'http'];
const era = config.era ?? 'dual';
const eras: Era[] = era === 'dual' ? ['modern', 'legacy'] : [era];
console.log(`\n::group::example ${story} (${transports.join('+')} × ${era})`);
for (const t of transports) {
for (const e of eras) {
const r = t === 'stdio' ? await runStdioLeg(story, dir, config, e) : await runHttpLeg(story, dir, config, e);
results.push(r);
console.log(`[${r.leg}] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`);
if (!r.ok) console.log(r.detail);
}
}
console.log('::endgroup::');
}
const passed = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok);
console.log('\n=== examples e2e summary ===');
console.log(`stories: ${stories.length - excluded.length} run / ${excluded.length} excluded`);
console.log(`legs: ${passed} passed / ${failed.length} failed`);
for (const r of failed) console.log(` FAIL ${r.story} [${r.leg}]`);
for (const e of excluded) console.log(` SKIP ${e.story}: ${e.reason}`);
process.exit(failed.length === 0 ? 0 : 1);
}
void main();

View file

@ -0,0 +1,73 @@
/**
* Vendors the generated `schema.json` twins from the spec repository into
* `packages/core-internal/test/corpus/schema-twins/` as RAW UPSTREAM BYTES.
*
* The twins are TEST-ONLY conformance oracles (never bundled, never runtime):
* `packages/core-internal/test/wire/schemaTwinConformance.test.ts` compiles them into
* generated validators and locks the hand-written wire layer to them. Their
* authority rests on provenance, so they are vendored verbatim — no
* formatting of any kind (the directory is .prettierignore'd) — and each file
* is locked to the manifest's sha256/byte values at test time. Any rewrite
* (prettier, an editor, a manual touch-up) turns CI red.
*
* Refresh ATOMICALLY with the matching spec.types anchor (see
* packages/core-internal/src/types/README.md lifecycle rule 4).
*
* Usage:
* pnpm fetch:schema-twins [sha] # default: the manifest's current source commit
*
* Sources are fetched from GitHub at the given commit, mirroring
* scripts/fetch-spec-types.ts; the manifest's provenance values (source
* commit, sha256, byte size) are recomputed from the fetched bytes.
*/
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const PROJECT_ROOT = join(dirname(__filename), '..');
const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol';
const TWINS_DIR = join(PROJECT_ROOT, 'packages', 'core-internal', 'test', 'corpus', 'schema-twins');
const MANIFEST_PATH = join(TWINS_DIR, 'manifest.json');
interface TwinManifest {
comment: string;
source: { repository: string; commit: string };
files: Record<string, { sha256: string; bytes: number; upstreamPath: string }>;
}
async function fetchRawBytes(sha: string, upstreamPath: string): Promise<Buffer> {
const url = `https://raw.githubusercontent.com/${SPEC_REPO}/${sha}/${upstreamPath}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${upstreamPath}: ${response.status} ${response.statusText}`);
}
return Buffer.from(await response.arrayBuffer());
}
async function main(): Promise<void> {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as TwinManifest;
const sha = process.argv[2] ?? manifest.source.commit;
for (const [revision, entry] of Object.entries(manifest.files)) {
console.log(`[${revision}] Fetching ${entry.upstreamPath} at ${sha}`);
const bytes = await fetchRawBytes(sha, entry.upstreamPath);
// Verbatim: the twin IS the upstream artifact, byte for byte.
writeFileSync(join(TWINS_DIR, `${revision}.schema.json`), bytes);
entry.sha256 = createHash('sha256').update(bytes).digest('hex');
entry.bytes = bytes.byteLength;
console.log(`[${revision}] ${entry.bytes} bytes, sha256 ${entry.sha256}`);
}
manifest.source = { repository: SPEC_REPO, commit: sha };
writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 4)}\n`, 'utf8');
console.log(`Updated ${MANIFEST_PATH}`);
}
main().catch((error: unknown) => {
console.error('Error:', error instanceof Error ? error.message : String(error));
process.exit(1);
});

View file

@ -0,0 +1,150 @@
/**
* Vendors the draft-revision (2026-07-28) example corpus from the spec
* repository into `packages/core-internal/test/corpus/fixtures/2026-07-28/`.
*
* The spec repository ships canonical example instances for the draft schema
* (`schema/draft/examples/<TypeName>/*.json`). The corpus harness
* (`packages/core-internal/test/corpus/specCorpus.test.ts`) parses every vendored
* example through the SDK's wire schemas, so accept-side drift between the
* SDK and the specification turns CI red.
*
* Files are vendored verbatim, plus a `manifest.json` recording provenance
* (source commit) and the directory/file inventory so corpus drift is loud.
*
* Usage:
* pnpm fetch:spec-examples --spec-dir <path-to-spec-checkout>
* pnpm fetch:spec-examples [sha] # fetch from GitHub (default: latest main)
*
* With `--spec-dir`, examples are read from a local checkout of
* modelcontextprotocol/modelcontextprotocol (provenance is the checkout's
* HEAD commit). Without it, sources are fetched from GitHub at the given
* commit, mirroring scripts/fetch-spec-types.ts.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const PROJECT_ROOT = join(dirname(__filename), '..');
const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol';
/** The upcoming protocol revision; its examples live in the spec repo's draft directory. */
const DRAFT_REVISION = '2026-07-28';
const EXAMPLES_PATH = 'schema/draft/examples';
const OUTPUT_DIR = join(PROJECT_ROOT, 'packages', 'core-internal', 'test', 'corpus', 'fixtures', DRAFT_REVISION);
interface ExampleFile {
/** `<TypeName>/<file>.json` relative to the examples root. */
relPath: string;
content: string;
}
async function fetchLatestSHA(): Promise<string> {
const url = `https://api.github.com/repos/${SPEC_REPO}/commits?path=${EXAMPLES_PATH}&per_page=1`;
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch commit info: ${response.status} ${response.statusText}`);
const commits = (await response.json()) as Array<{ sha: string }>;
if (!commits?.length) throw new Error('No commits found for the examples path');
return commits[0].sha;
}
async function listExamplesFromGitHub(sha: string): Promise<string[]> {
const url = `https://api.github.com/repos/${SPEC_REPO}/git/trees/${sha}?recursive=1`;
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch repo tree: ${response.status} ${response.statusText}`);
const tree = (await response.json()) as { truncated?: boolean; tree: Array<{ path: string; type: string }> };
if (tree.truncated) throw new Error('GitHub tree listing truncated; cannot enumerate examples reliably');
return tree.tree
.filter(entry => entry.type === 'blob' && entry.path.startsWith(`${EXAMPLES_PATH}/`) && entry.path.endsWith('.json'))
.map(entry => entry.path.slice(EXAMPLES_PATH.length + 1));
}
async function fetchExamplesFromGitHub(sha: string): Promise<ExampleFile[]> {
const relPaths = await listExamplesFromGitHub(sha);
const files: ExampleFile[] = [];
for (const relPath of relPaths) {
const url = `https://raw.githubusercontent.com/${SPEC_REPO}/${sha}/${EXAMPLES_PATH}/${relPath}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch ${relPath}: ${response.status} ${response.statusText}`);
files.push({ relPath, content: await response.text() });
}
return files;
}
function readExamplesFromDir(specDir: string): { files: ExampleFile[]; sha: string } {
const root = join(specDir, ...EXAMPLES_PATH.split('/'));
const files: ExampleFile[] = [];
for (const typeDir of readdirSync(root).sort()) {
const dirPath = join(root, typeDir);
if (!statSync(dirPath).isDirectory()) continue;
for (const file of readdirSync(dirPath).sort()) {
if (!file.endsWith('.json')) continue;
files.push({ relPath: `${typeDir}/${file}`, content: readFileSync(join(dirPath, file), 'utf8') });
}
}
const sha = execFileSync('git', ['-C', specDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
return { files, sha };
}
function writeCorpus(files: ExampleFile[], sha: string): void {
if (files.length === 0) throw new Error('No example files found — refusing to write an empty corpus');
rmSync(OUTPUT_DIR, { recursive: true, force: true });
mkdirSync(OUTPUT_DIR, { recursive: true });
const dirs: Record<string, string[]> = {};
for (const file of files.sort((a, b) => a.relPath.localeCompare(b.relPath))) {
// The path components come from outside this repo (a spec checkout or the
// GitHub trees API); reject anything that could escape the output directory.
const parts = file.relPath.split('/');
if (parts.length !== 2 || parts.some(p => !p || p === '.' || p === '..' || p.includes('\\'))) {
throw new Error(`Unsafe or unexpected example path: ${file.relPath}`);
}
const [typeDir, fileName] = parts as [string, string];
const destFile = resolve(OUTPUT_DIR, typeDir, fileName);
if (!destFile.startsWith(resolve(OUTPUT_DIR) + sep)) {
throw new Error(`Example path escapes the output directory: ${file.relPath}`);
}
mkdirSync(join(OUTPUT_DIR, typeDir), { recursive: true });
// Validate now so a malformed upstream example fails the vendoring, not the harness.
JSON.parse(file.content);
writeFileSync(destFile, file.content);
(dirs[typeDir] ??= []).push(fileName);
}
const manifest = {
revision: DRAFT_REVISION,
source: { repo: SPEC_REPO, path: EXAMPLES_PATH, commit: sha },
regenerate: 'pnpm fetch:spec-examples --spec-dir <spec-checkout> # or [sha] to fetch from GitHub',
directoryCount: Object.keys(dirs).length,
fileCount: files.length,
directories: dirs
};
writeFileSync(join(OUTPUT_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 4)}\n`);
console.log(`Vendored ${files.length} example files across ${Object.keys(dirs).length} directories (source ${sha.slice(0, 8)})`);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const specDirIndex = args.indexOf('--spec-dir');
if (specDirIndex !== -1) {
const specDir = args[specDirIndex + 1];
if (!specDir) throw new Error('--spec-dir requires a path argument');
const { files, sha } = readExamplesFromDir(specDir);
writeCorpus(files, sha);
return;
}
const sha = args[0] ?? (await fetchLatestSHA());
const files = await fetchExamplesFromGitHub(sha);
writeCorpus(files, sha);
}
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});

View file

@ -27,6 +27,23 @@ const UPSTREAM_SCHEMA_DIRS: Record<SpecVersion, string> = {
'2026-07-28': 'draft'
};
/**
* Generation pin per released revision. Released revisions are frozen: without
* an explicit SHA argument, their types are regenerated from the pinned spec
* commit below — never from the latest upstream commit — so a released anchor
* can only change through a deliberate, reviewed repin. Moving a pin (or
* freezing a newly released revision) must land in the same commit that
* retargets `.github/workflows/update-spec-types.yml`.
*
* Draft-tracking revisions have no entry and float to the latest upstream
* commit via the nightly workflow's refresh PRs.
*
* See `packages/core-internal/src/types/README.md` for the full lifecycle policy.
*/
const RELEASED_REVISION_PINS: Partial<Record<SpecVersion, string>> = {
'2025-11-25': '0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65'
};
interface GitHubCommit {
sha: string;
}
@ -59,10 +76,14 @@ async function fetchSpecTypes(version: SpecVersion, sha: string): Promise<string
}
async function updateSpecTypes(version: SpecVersion, providedSHA?: string): Promise<void> {
const pinnedSHA = RELEASED_REVISION_PINS[version];
let sha: string;
if (providedSHA) {
console.log(`[${version}] Using provided SHA: ${providedSHA}`);
sha = providedSHA;
} else if (pinnedSHA) {
console.log(`[${version}] Using pinned SHA for released revision: ${pinnedSHA}`);
sha = pinnedSHA;
} else {
console.log(`[${version}] Fetching latest commit SHA...`);
sha = await fetchLatestSHA(version);