ext-tasks/scripts/fetch-spec-schema.ts
Luca Chang e4345978be
fix: make generated schema.json enforce spec constraints (#18)
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.
2026-08-21 13:44:01 -07:00

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);
});