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.
This commit is contained in:
Luca Chang 2026-08-21 13:44:01 -07:00 • committed by GitHub
commit e4345978be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 9556 additions and 1999 deletions

1173
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"generate:schemas": "tsx scripts/generate-schemas.ts", "generate:schemas": "tsx scripts/generate-schemas.ts",
"fetch:spec-schema": "tsx scripts/fetch-spec-schema.ts",
"check:schema": "npm run check:schema:ts && npm run check:schema:json", "check:schema": "npm run check:schema:ts && npm run check:schema:json",
"check:schema:ts": "tsc --noEmit", "check:schema:ts": "tsc --noEmit",
"check:schema:json": "tsx scripts/generate-schemas.ts --check", "check:schema:json": "tsx scripts/generate-schemas.ts --check",
@ -15,7 +16,6 @@
"docs:preview": "vitepress preview" "docs:preview": "vitepress preview"
}, },
"devDependencies": { "devDependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"mermaid": "^11.17.0", "mermaid": "^11.17.0",
"ts-to-zod": "^5.1.0", "ts-to-zod": "^5.1.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",

File diff suppressed because it is too large Load diff

View file

@ -12,17 +12,14 @@
*/ */
import type { import type {
CreateMessageRequest, Error as JSONRPCErrorObject,
CreateMessageResult, InputRequests,
ElicitRequest, InputResponses,
ElicitResult,
JSONRPCNotification, JSONRPCNotification,
JSONRPCRequest, JSONRPCRequest,
ListRootsRequest,
ListRootsResult,
NotificationParams, NotificationParams,
Result, Result,
} from "@modelcontextprotocol/sdk/types.js"; } from "./spec.types.js";
/* Tasks */ /* Tasks */
@ -79,6 +76,7 @@ export interface Task {
* Time-to-live duration from creation in integer milliseconds, null for unlimited. * Time-to-live duration from creation in integer milliseconds, null for unlimited.
* The server may discard the task after the TTL elapses. This value MAY change * The server may discard the task after the TTL elapses. This value MAY change
* over the lifetime of a task. * over the lifetime of a task.
* @format int
* @nullable * @nullable
*/ */
ttlMs: number | null; ttlMs: number | null;
@ -87,6 +85,7 @@ export interface Task {
* Suggested polling interval in integer milliseconds. Clients SHOULD honor * Suggested polling interval in integer milliseconds. Clients SHOULD honor
* this value to avoid overwhelming the server. This value MAY change over * this value to avoid overwhelming the server. This value MAY change over
* the lifetime of a task. * the lifetime of a task.
* @format int
*/ */
pollIntervalMs?: number; pollIntervalMs?: number;
} }
@ -103,51 +102,6 @@ export interface WorkingTask extends Task {
status: "working"; status: "working";
} }
/**
* A single input request from the server to the client during task execution.
*
* @category `tasks`
*/
// TODO: Import from SDK once updated for MRTR
export type InputRequest =
| CreateMessageRequest
| ListRootsRequest
| ElicitRequest;
/**
* A single input response from the client to the server during task execution.
*
* @category `tasks`
*/
// TODO: Import from SDK once updated for MRTR
export type InputResponse =
| CreateMessageResult
| ListRootsResult
| ElicitResult;
/**
* Outstanding server-to-client requests that need to be fulfilled during task execution.
* Keys are arbitrary identifiers for matching requests to responses. Each key MUST be
* unique over the lifetime of a single task.
*
* @category `tasks`
*/
// TODO: Import from SDK once updated for MRTR
export interface InputRequests {
[key: string]: InputRequest;
}
/**
* Client responses to outstanding input requests. Each key MUST correspond to a
* currently-outstanding inputRequest key.
*
* @category `tasks`
*/
// TODO: Import from SDK once updated for MRTR
export interface InputResponses {
[key: string]: InputResponse;
}
/** /**
* A task that is waiting for input from the client. * A task that is waiting for input from the client.
* Used by tasks/get and notifications/tasks. * Used by tasks/get and notifications/tasks.
@ -193,7 +147,7 @@ export interface FailedTask extends Task {
/** /**
* The JSON-RPC error that caused the task to fail. * The JSON-RPC error that caused the task to fail.
*/ */
error: { [key: string]: unknown }; error: JSONRPCErrorObject;
} }
/** /**
@ -230,7 +184,13 @@ export type DetailedTask =
* *
* @category `tasks` * @category `tasks`
*/ */
export type CreateTaskResult = Result & Task; export type CreateTaskResult = Result &
Task & {
/**
* Discriminator distinguishing a task handle from a standard result.
*/
resultType: "task";
};
/* Task Operations */ /* Task Operations */
@ -239,7 +199,7 @@ export type CreateTaskResult = Result & Task;
* *
* @category `tasks/get` * @category `tasks/get`
*/ */
export interface GetTaskRequest extends JSONRPCRequest { export type GetTaskRequest = JSONRPCRequest & {
method: "tasks/get"; method: "tasks/get";
params: { params: {
/** /**
@ -247,7 +207,7 @@ export interface GetTaskRequest extends JSONRPCRequest {
*/ */
taskId: string; taskId: string;
}; };
} };
/** /**
* The response to a tasks/get request. Carries the appropriate DetailedTask * The response to a tasks/get request. Carries the appropriate DetailedTask
@ -256,14 +216,20 @@ export interface GetTaskRequest extends JSONRPCRequest {
* *
* @category `tasks/get` * @category `tasks/get`
*/ */
export type GetTaskResult = Result & DetailedTask; export type GetTaskResult = Result &
DetailedTask & {
/**
* Discriminator marking this as the standard result shape for tasks/get.
*/
resultType: "complete";
};
/** /**
* A request to provide input responses to a task in the input_required state. * A request to provide input responses to a task in the input_required state.
* *
* @category `tasks/update` * @category `tasks/update`
*/ */
export interface UpdateTaskRequest extends JSONRPCRequest { export type UpdateTaskRequest = JSONRPCRequest & {
method: "tasks/update"; method: "tasks/update";
params: { params: {
/** /**
@ -277,7 +243,7 @@ export interface UpdateTaskRequest extends JSONRPCRequest {
*/ */
inputResponses: InputResponses; inputResponses: InputResponses;
}; };
} };
/** /**
* The response to a tasks/update request. An empty acknowledgement. * The response to a tasks/update request. An empty acknowledgement.
@ -285,14 +251,19 @@ export interface UpdateTaskRequest extends JSONRPCRequest {
* *
* @category `tasks/update` * @category `tasks/update`
*/ */
export type UpdateTaskResult = Result; export type UpdateTaskResult = Result & {
/**
* Discriminator marking this as the standard result shape for tasks/update.
*/
resultType: "complete";
};
/** /**
* A request to cancel a task. * A request to cancel a task.
* *
* @category `tasks/cancel` * @category `tasks/cancel`
*/ */
export interface CancelTaskRequest extends JSONRPCRequest { export type CancelTaskRequest = JSONRPCRequest & {
method: "tasks/cancel"; method: "tasks/cancel";
params: { params: {
/** /**
@ -300,7 +271,7 @@ export interface CancelTaskRequest extends JSONRPCRequest {
*/ */
taskId: string; taskId: string;
}; };
} };
/** /**
* The response to a tasks/cancel request. An empty acknowledgement. * The response to a tasks/cancel request. An empty acknowledgement.
@ -309,7 +280,12 @@ export interface CancelTaskRequest extends JSONRPCRequest {
* *
* @category `tasks/cancel` * @category `tasks/cancel`
*/ */
export type CancelTaskResult = Result; export type CancelTaskResult = Result & {
/**
* Discriminator marking this as the standard result shape for tasks/cancel.
*/
resultType: "complete";
};
/* Task Notifications */ /* Task Notifications */
@ -329,10 +305,10 @@ export type TaskStatusNotificationParams = NotificationParams &
* *
* @category `notifications/tasks` * @category `notifications/tasks`
*/ */
export interface TaskStatusNotification extends JSONRPCNotification { export type TaskStatusNotification = JSONRPCNotification & {
method: "notifications/tasks"; method: "notifications/tasks";
params: TaskStatusNotificationParams; params: TaskStatusNotificationParams;
} };
/* Subscription Additions */ /* Subscription Additions */

File diff suppressed because it is too large Load diff

3205
schema/draft/spec.types.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
/**
* 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);
});

View file

@ -9,12 +9,13 @@
* ## Post-Processing Steps * ## Post-Processing Steps
* *
* ### 1. Zod Import Path (`"zod"` → `"zod/v4"`) * ### 1. Zod Import Path (`"zod"` → `"zod/v4"`)
* ts-to-zod generates `import { z } from "zod"`. We rewrite to `import { z } from "zod/v4"` * ts-to-zod generates `import { z } from "zod"`. We rewrite to `import { z } from "zod/v4"`.
* to match MCP SDK's zod/v4 schemas.
* *
* ### 2. External Type References (`z.any()` → SDK schemas) * ### 2. External Type References (`z.any()` → spec-owned stand-ins)
* ts-to-zod cannot resolve types imported from @modelcontextprotocol/sdk. * ts-to-zod cannot resolve types imported from the vendored spec.types.ts.
* We replace z.any() placeholders with actual SDK schema imports. * We replace z.any() placeholders with permissive stand-ins whose
* `.meta({ id })` marks emit `$ref`s resolved by canonical spec splicing.
* Generation fails on any unmapped placeholder.
* *
* ### 3. Index Signatures (`z.record().and()` → `.passthrough()`) * ### 3. Index Signatures (`z.record().and()` → `.passthrough()`)
* TypeScript index signatures are translated to z.record().and(z.object({...})). * TypeScript index signatures are translated to z.record().and(z.object({...})).
@ -27,7 +28,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join, relative } from "node:path"; import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { generate } from "ts-to-zod"; import { generate } from "ts-to-zod";
import { toJSONSchema, type $ZodType } from "zod/v4/core"; import { toJSONSchema, globalRegistry, type $ZodType } from "zod/v4/core";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -38,33 +39,64 @@ const SPEC_TYPES_FILE = join(SCHEMA_DIR, "schema.ts");
const GENERATED_DIR = join(SCHEMA_DIR, "generated"); const GENERATED_DIR = join(SCHEMA_DIR, "generated");
const SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, "schema.ts"); const SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, "schema.ts");
const JSON_SCHEMA_OUTPUT_FILE = join(SCHEMA_DIR, "schema.json"); const JSON_SCHEMA_OUTPUT_FILE = join(SCHEMA_DIR, "schema.json");
const VENDORED_SPEC_SCHEMA_FILE = join(SCHEMA_DIR, "spec.schema.json");
/** /**
* Mapping from ts-to-zod generated placeholder names to actual SDK schema exports. * Definitions owned by the core specification. In the generated schema.json
* Only includes types that have matching SDK exports. * these are emitted as `$ref: "#/$defs/<Name>"` and their definitions are
* spliced verbatim (with their transitive `$ref` closure) from the vendored
* canonical spec schema (spec.schema.json — see scripts/fetch-spec-schema.ts).
*
* This avoids re-deriving core-spec shapes through this repo's Zod pipeline:
* corrections to the core spec's types flow into this artifact by re-running
* `npm run fetch:spec-schema` + `npm run generate:schemas`.
*
* Maps generated/imported Zod schema export name -> canonical spec $defs name.
*/ */
const EXTERNAL_TYPE_SCHEMAS: string[] = [ const SPEC_OWNED_DEFS: Record<string, string> = {
"JSONRPCRequestSchema", // Referenced by InputRequest/InputResponse unions in the canonical spec
"JSONRPCNotificationSchema", CreateMessageRequestSchema: "CreateMessageRequest",
"PaginatedRequestSchema", ListRootsRequestSchema: "ListRootsRequest",
"PaginatedResultSchema", ElicitRequestSchema: "ElicitRequest",
"ResultSchema", CreateMessageResultSchema: "CreateMessageResult",
"RequestIdSchema", ListRootsResultSchema: "ListRootsResult",
"ProgressTokenSchema", ElicitResultSchema: "ElicitResult",
"CursorSchema", // MRTR types imported from spec.types.ts
]; InputRequestSchema: "InputRequest",
InputResponseSchema: "InputResponse",
InputRequestsSchema: "InputRequests",
InputResponsesSchema: "InputResponses",
// The spec's bare JSON-RPC error object (imported as JSONRPCErrorObject)
JSONRPCErrorObjectSchema: "Error",
// JSON-RPC envelope and params types intersected by the extension's types
JSONRPCRequestSchema: "JSONRPCRequest",
JSONRPCNotificationSchema: "JSONRPCNotification",
ResultSchema: "Result",
NotificationParamsSchema: "NotificationParams",
};
/** /**
* External types that don't have direct SDK schema exports. * Runtime Zod stand-ins for spec-owned types referenced from schema.ts.
* We replace their z.any() placeholders with local Zod definitions. *
* ts-to-zod cannot resolve imported types, so it emits `z.any()` placeholders
* for everything schema.ts imports from the vendored spec.types.ts. Each
* placeholder is replaced with a permissive local schema carrying a
* `.meta({ id })` mark: the mark makes every reference emit as
* `$ref: "#/$defs/<Name>"`, and the canonical definition for each id is then
* spliced verbatim from the vendored spec.schema.json (see SPEC_OWNED_DEFS).
* The stand-ins' own shapes NEVER reach the published artifact — they exist
* only so the generated module evaluates — so nothing here depends on any
* SDK package, published or otherwise. The vendored spec is the sole source
* of truth for these shapes.
*/ */
const LOCAL_SCHEMA_REPLACEMENTS: Record<string, string> = { const LOCAL_SCHEMA_REPLACEMENTS: Record<string, string> = {
RequestParamsSchema: `z.object({ InputRequestsSchema: `z.record(z.string(), z.unknown()).meta({ id: "InputRequests" })`,
_meta: z.object({ progressToken: ProgressTokenSchema.optional() }).passthrough().optional(), InputResponsesSchema: `z.record(z.string(), z.unknown()).meta({ id: "InputResponses" })`,
}).passthrough()`, JSONRPCErrorObjectSchema: `z.object({}).passthrough().meta({ id: "Error" })`,
NotificationParamsSchema: `z.object({ JSONRPCRequestSchema: `z.object({}).passthrough().meta({ id: "JSONRPCRequest" })`,
_meta: z.record(z.string(), z.unknown()).optional(), JSONRPCNotificationSchema: `z.object({}).passthrough().meta({ id: "JSONRPCNotification" })`,
}).passthrough()`, ResultSchema: `z.object({}).passthrough().meta({ id: "Result" })`,
NotificationParamsSchema: `z.object({}).passthrough().meta({ id: "NotificationParams" })`,
}; };
async function main() { async function main() {
@ -169,6 +201,17 @@ async function generateJsonSchemaContent(
const fileUrl = new URL(`file:///${zodSchemasFile.replace(/\\/g, "/")}`); const fileUrl = new URL(`file:///${zodSchemasFile.replace(/\\/g, "/")}`);
const schemas = await import(fileUrl.href); const schemas = await import(fileUrl.href);
// Mark spec-owned schema instances with their canonical ids so every
// reference to them is emitted as `$ref: "#/$defs/<Name>"` instead of an
// inlined, Zod-derived approximation of the core spec shape.
const specOwnedNames = new Set(Object.values(SPEC_OWNED_DEFS));
for (const [exportName, defName] of Object.entries(SPEC_OWNED_DEFS)) {
const schema = schemas[exportName];
if (schema) {
globalRegistry.add(schema as $ZodType, { id: defName });
}
}
const jsonSchema: { const jsonSchema: {
$schema: string; $schema: string;
$id: string; $id: string;
@ -184,6 +227,8 @@ async function generateJsonSchemaContent(
$defs: {}, $defs: {},
}; };
const conversionFailures: string[] = [];
for (const [name, schema] of Object.entries(schemas)) { for (const [name, schema] of Object.entries(schemas)) {
if ( if (
name.endsWith("Schema") && name.endsWith("Schema") &&
@ -192,54 +237,158 @@ async function generateJsonSchemaContent(
) { ) {
const typeName = name.replace(/Schema$/, ""); const typeName = name.replace(/Schema$/, "");
// Skip SDK external types and local replacement types — only include task-specific types // Skip SDK external types, local replacement types, and spec-owned
if ( // types — only include task-specific types. Spec-owned definitions are
EXTERNAL_TYPE_SCHEMAS.includes(name) || // spliced from the vendored canonical spec schema below.
name in LOCAL_SCHEMA_REPLACEMENTS if (name in LOCAL_SCHEMA_REPLACEMENTS || name in SPEC_OWNED_DEFS) {
) {
continue; continue;
} }
try { try {
jsonSchema.$defs[typeName] = toJSONSchema(schema as $ZodType, { const emitted = toJSONSchema(schema as $ZodType, {
unrepresentable: "any", unrepresentable: "any",
}); // Emit input-mode schemas: strip-mode objects stay open (no
// `additionalProperties: false`). TypeScript interfaces are open
// types, and closed branches inside `allOf` intersections (e.g.
// CreateTaskResult = Result & Task & { resultType }) would make
// the intersection unsatisfiable.
io: "input",
}) as Record<string, unknown>;
// Zod nests a local $defs copy of each referenced (registered)
// schema. JSON Pointer fragments resolve from the document root, so
// `#/$defs/<Name>` targets the top-level canonical definitions we
// splice from the spec — the nested Zod-derived copies are dead
// weight and a drift hazard. Remove them.
stripSpecOwnedDefs(emitted, specOwnedNames);
jsonSchema.$defs[typeName] = emitted;
} catch (error) { } catch (error) {
console.warn(`⚠️ Could not convert ${name} to JSON Schema: ${error}`); console.error(`❌ Could not convert ${name} to JSON Schema: ${error}`);
conversionFailures.push(name);
} }
} }
} }
if (conversionFailures.length > 0) {
throw new Error(
`JSON Schema conversion failed for: ${conversionFailures.join(", ")}. ` +
"A definition must never be silently omitted from the generated artifact."
);
}
spliceSpecDefinitions(jsonSchema.$defs, specOwnedNames);
return JSON.stringify(jsonSchema, null, 2) + "\n"; return JSON.stringify(jsonSchema, null, 2) + "\n";
} }
/**
* Remove spec-owned entries from an emitted schema's local `$defs`, dropping
* the `$defs` object entirely if it becomes empty.
*/
function stripSpecOwnedDefs(
emitted: Record<string, unknown>,
specOwnedNames: Set<string>
): void {
const localDefs = emitted.$defs as Record<string, unknown> | undefined;
if (!localDefs) return;
for (const name of Object.keys(localDefs)) {
if (specOwnedNames.has(name)) {
delete localDefs[name];
}
}
if (Object.keys(localDefs).length === 0) {
delete emitted.$defs;
}
}
/**
* Splice the canonical definitions for all spec-owned names — plus their
* transitive `$ref` closure — verbatim from the vendored spec schema into
* the output `$defs`.
*/
function spliceSpecDefinitions(
outDefs: Record<string, unknown>,
specOwnedNames: Set<string>
): void {
if (!existsSync(VENDORED_SPEC_SCHEMA_FILE)) {
throw new Error(
`Vendored spec schema not found: ${VENDORED_SPEC_SCHEMA_FILE}. ` +
"Run: npm run fetch:spec-schema"
);
}
const spec = JSON.parse(readFileSync(VENDORED_SPEC_SCHEMA_FILE, "utf-8")) as {
$defs?: Record<string, unknown>;
};
const specDefs = spec.$defs;
if (!specDefs) {
throw new Error("Vendored spec schema has no $defs");
}
// Transitive $ref closure over the vendored spec definitions.
const closure = new Set<string>();
const frontier = [...specOwnedNames];
while (frontier.length > 0) {
const name = frontier.pop()!;
if (closure.has(name)) continue;
const def = specDefs[name];
if (def === undefined) {
throw new Error(
`Spec definition "${name}" not found in vendored spec schema. ` +
"The vendored copy may be stale — run: npm run fetch:spec-schema"
);
}
closure.add(name);
for (const ref of collectRefs(def)) {
if (!closure.has(ref)) frontier.push(ref);
}
}
for (const name of [...closure].sort()) {
if (name in outDefs) {
throw new Error(
`Name collision: extension definition "${name}" conflicts with a ` +
"canonical spec definition of the same name"
);
}
outDefs[name] = specDefs[name];
}
}
/**
* Collect local `#/$defs/<Name>` reference targets reachable in a schema node.
*/
function collectRefs(node: unknown): Set<string> {
const out = new Set<string>();
if (Array.isArray(node)) {
for (const item of node) {
for (const ref of collectRefs(item)) out.add(ref);
}
} else if (typeof node === "object" && node !== null) {
for (const [key, value] of Object.entries(node)) {
if (key === "$ref" && typeof value === "string") {
const match = value.match(/^#\/\$defs\/(.+)$/);
if (match) out.add(match[1]);
} else {
for (const ref of collectRefs(value)) out.add(ref);
}
}
}
return out;
}
/** /**
* Post-process generated Zod schemas for project compatibility. * Post-process generated Zod schemas for project compatibility.
*/ */
function postProcess(content: string): string { function postProcess(content: string): string {
// 1. Rewrite to zod/v4 and add MCP SDK schema imports // 1. Rewrite to zod/v4
const sdkImports = EXTERNAL_TYPE_SCHEMAS.join(",\n ");
content = content.replace( content = content.replace(
'import { z } from "zod";', 'import { z } from "zod";',
`import { z } from "zod/v4"; 'import { z } from "zod/v4";'
import {
${sdkImports},
} from "@modelcontextprotocol/sdk/types.js";`
); );
// 2. Remove z.any() placeholders for SDK-imported external types // 2. Replace z.any() placeholders for spec-owned imported types with
for (const schemaName of EXTERNAL_TYPE_SCHEMAS) { // permissive local stand-ins carrying canonical `.meta({ id })` marks.
content = content.replace(
new RegExp(
`(?:export )?const ${schemaName} = z\\.any\\(\\);\\n?`,
"g"
),
""
);
}
// 3. Replace z.any() placeholders for locally-defined types
for (const [schemaName, replacement] of Object.entries( for (const [schemaName, replacement] of Object.entries(
LOCAL_SCHEMA_REPLACEMENTS LOCAL_SCHEMA_REPLACEMENTS
)) { )) {
@ -252,6 +401,20 @@ import {
); );
} }
// 3. Fail on any leftover placeholder: an unmapped z.any() would emit `{}`
// (accept-everything) into the artifact — the exact defect class this
// pipeline exists to prevent.
const leftover = [...content.matchAll(/const (\w+) = z\.any\(\);/g)].map(
(m) => m[1]
);
if (leftover.length > 0) {
throw new Error(
`Unmapped external type placeholder(s): ${leftover.join(", ")}. ` +
"Add each to LOCAL_SCHEMA_REPLACEMENTS (and SPEC_OWNED_DEFS if the " +
"canonical definition should be spliced from the vendored spec)."
);
}
// 4. Replace z.record().and(z.object({...})) with z.object({...}).passthrough() // 4. Replace z.record().and(z.object({...})) with z.object({...}).passthrough()
content = replaceRecordAndWithPassthrough(content); content = replaceRecordAndWithPassthrough(content);