The generated JSON Schema artifact previously failed to encode several
normative requirements, forcing non-TypeScript implementations to
maintain their own mirrors of core shapes and discriminators:
- InputRequest/InputResponse collapsed to anyOf:[{},{},{}] because the
imported request/result types resolved to empty schemas, accepting
any instance.
- The required resultType discriminators ('task' on CreateTaskResult,
'complete' on Get/Update/CancelTaskResult) appeared nowhere in the
artifact despite being normative MUSTs in the extension prose.
- Strip-mode object emission produced additionalProperties:false inside
allOf intersections, making CreateTaskResult unsatisfiable: every
spec-conformant instance (with resultType or _meta) was rejected.
The canonical specification is now the sole source of truth for all
core-spec shapes — no SDK package is consulted at any layer.
scripts/fetch-spec-schema.ts vendors spec.schema.json and spec.types.ts
from the same recorded spec commit with provenance headers:
- schema.ts imports every core type (JSON-RPC envelopes, Result,
NotificationParams, the MRTR InputRequests/InputResponses, and the
JSON-RPC Error object) from the vendored spec.types.ts, deleting the
local mirrors that had already drifted from upstream.
- generate-schemas.ts emits $ref for all spec-owned types — including
the JSON-RPC envelopes intersected by the extension's own types — and
splices their transitive $ref closure verbatim from the vendored
canonical schema.json. Core-spec corrections flow in via an explicit,
reviewable refresh (npm run fetch:spec-schema) instead of silently
diverging. The @modelcontextprotocol/sdk devDependency is removed.
- Runtime Zod stand-ins for imported types are permissive locals whose
shapes never reach the artifact; generation fails on any unmapped
placeholder, and on any definition that cannot be converted.
- JSON Schema emission uses io:'input' so TypeScript's open interface
semantics are preserved and intersections remain satisfiable.
- resultType discriminators are encoded structurally in schema.ts.
- FailedTask.error now requires the canonical JSON-RPC Error shape;
ttlMs and pollIntervalMs are integers per the prose.
- createdAt/lastUpdatedAt deliberately remain unconstrained strings:
the prose requires ISO 8601 generally, and JSON Schema's date-time
format/pattern machinery can only express RFC 3339, which would
reject valid basic-form timestamps.
Validated with a Draft 2020-12 jsonschema matrix covering union
rejection/acceptance, discriminator enforcement, JSON-RPC envelope
enforcement, error-shape and integer constraints, byte-fidelity of all
56 spliced canonical definitions, complete $ref resolution, and
check-mode idempotency.
133 lines
4.7 KiB
TypeScript
133 lines
4.7 KiB
TypeScript
/**
|
|
* Spec Schema Vendoring Script
|
|
*
|
|
* Fetches the core MCP specification's canonical JSON Schema
|
|
* (schema/draft/schema.json in modelcontextprotocol/modelcontextprotocol)
|
|
* and vendors it into this repository with commit provenance.
|
|
*
|
|
* Modeled on the TypeScript SDK's scripts/fetch-spec-types.ts: the vendored
|
|
* artifact records the exact upstream commit it was pulled from, and updating
|
|
* it is an explicit, reviewed action (`npm run fetch:spec-schema`), never an
|
|
* implicit side effect of schema generation. This keeps `npm run
|
|
* generate:schemas` deterministic and offline while giving spec corrections a
|
|
* defined path into this repository.
|
|
*
|
|
* Usage:
|
|
* npm run fetch:spec-schema # latest upstream commit for the draft schema
|
|
* npm run fetch:spec-schema -- <sha> # a specific upstream commit
|
|
*
|
|
* Once this extension targets a released spec revision, pin its SHA in
|
|
* RELEASED_REVISION_PINS (see the TypeScript SDK's lifecycle policy).
|
|
*/
|
|
|
|
import { writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const PROJECT_ROOT = join(__dirname, "..");
|
|
|
|
const SPEC_REPO = "modelcontextprotocol/modelcontextprotocol";
|
|
const UPSTREAM_SCHEMA_PATH = "schema/draft/schema.json";
|
|
const UPSTREAM_TYPES_PATH = "schema/draft/schema.ts";
|
|
const OUTPUT_FILE = join(
|
|
PROJECT_ROOT,
|
|
"schema",
|
|
"draft",
|
|
"spec.schema.json"
|
|
);
|
|
const TYPES_OUTPUT_FILE = join(PROJECT_ROOT, "schema", "draft", "spec.types.ts");
|
|
|
|
/**
|
|
* Generation pin per released revision. The draft schema floats to the
|
|
* latest upstream commit; a released revision must be pinned here so it can
|
|
* only change through a deliberate, reviewed repin.
|
|
*/
|
|
const RELEASED_REVISION_PINS: Record<string, string> = {};
|
|
|
|
interface GitHubCommit {
|
|
sha: string;
|
|
}
|
|
|
|
async function fetchLatestSHA(): Promise<string> {
|
|
const url = `https://api.github.com/repos/${SPEC_REPO}/commits?path=${UPSTREAM_SCHEMA_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 GitHubCommit[];
|
|
if (!commits || commits.length === 0) {
|
|
throw new Error("No commits found");
|
|
}
|
|
return commits[0].sha;
|
|
}
|
|
|
|
async function fetchSpecFile(sha: string, path: string): Promise<string> {
|
|
const url = `https://raw.githubusercontent.com/${SPEC_REPO}/${sha}/${path}`;
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to fetch ${path}: ${response.status} ${response.statusText}`
|
|
);
|
|
}
|
|
return await response.text();
|
|
}
|
|
|
|
async function main() {
|
|
const providedSHA = process.argv[2];
|
|
const pinnedSHA = RELEASED_REVISION_PINS["draft"];
|
|
|
|
let sha: string;
|
|
if (providedSHA) {
|
|
console.log(`Using provided SHA: ${providedSHA}`);
|
|
sha = providedSHA;
|
|
} else if (pinnedSHA) {
|
|
console.log(`Using pinned SHA: ${pinnedSHA}`);
|
|
sha = pinnedSHA;
|
|
} else {
|
|
console.log("Fetching latest upstream commit SHA...");
|
|
sha = await fetchLatestSHA();
|
|
}
|
|
|
|
// Fetch BOTH artifacts before writing either, so a failed second fetch can
|
|
// never leave the two vendored files at different upstream revisions.
|
|
console.log(`Fetching ${UPSTREAM_SCHEMA_PATH} from commit: ${sha}`);
|
|
const raw = await fetchSpecFile(sha, UPSTREAM_SCHEMA_PATH);
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
|
|
console.log(`Fetching ${UPSTREAM_TYPES_PATH} from commit: ${sha}`);
|
|
const typesContent = await fetchSpecFile(sha, UPSTREAM_TYPES_PATH);
|
|
|
|
// Record provenance in the vendored artifact itself. $comment is valid in
|
|
// JSON Schema 2020-12 and ignored by validators.
|
|
const vendored = {
|
|
$comment:
|
|
`Vendored from https://github.com/${SPEC_REPO}/blob/${sha}/${UPSTREAM_SCHEMA_PATH}. ` +
|
|
"DO NOT EDIT MANUALLY. Update with: npm run fetch:spec-schema",
|
|
...parsed,
|
|
};
|
|
|
|
const typesHeader =
|
|
`/**\n` +
|
|
` * This file is automatically vendored from the Model Context Protocol specification.\n` +
|
|
` *\n` +
|
|
` * Source: https://github.com/${SPEC_REPO}/blob/${sha}/${UPSTREAM_TYPES_PATH}\n` +
|
|
` * Last updated from commit: ${sha}\n` +
|
|
` *\n` +
|
|
` * DO NOT EDIT THIS FILE MANUALLY. Update with: npm run fetch:spec-schema\n` +
|
|
` */\n`;
|
|
|
|
writeFileSync(OUTPUT_FILE, JSON.stringify(vendored, null, 2) + "\n", "utf-8");
|
|
console.log(`✅ Written: ${OUTPUT_FILE}`);
|
|
writeFileSync(TYPES_OUTPUT_FILE, typesHeader + typesContent, "utf-8");
|
|
console.log(`✅ Written: ${TYPES_OUTPUT_FILE}`);
|
|
console.log("Now run: npm run generate:schemas");
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error("❌ Spec schema fetch failed:", error);
|
|
process.exit(1);
|
|
});
|