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:
parent
dcc8d2bbec
commit
e4345978be
8 changed files with 9556 additions and 1999 deletions
1173
package-lock.json
generated
1173
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@
|
|||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"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:ts": "tsc --noEmit",
|
||||
"check:schema:json": "tsx scripts/generate-schemas.ts --check",
|
||||
|
|
@ -15,7 +16,6 @@
|
|||
"docs:preview": "vitepress preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"mermaid": "^11.17.0",
|
||||
"ts-to-zod": "^5.1.0",
|
||||
"tsx": "^4.21.0",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -12,17 +12,14 @@
|
|||
*/
|
||||
|
||||
import type {
|
||||
CreateMessageRequest,
|
||||
CreateMessageResult,
|
||||
ElicitRequest,
|
||||
ElicitResult,
|
||||
Error as JSONRPCErrorObject,
|
||||
InputRequests,
|
||||
InputResponses,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
ListRootsRequest,
|
||||
ListRootsResult,
|
||||
NotificationParams,
|
||||
Result,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
} from "./spec.types.js";
|
||||
|
||||
/* Tasks */
|
||||
|
||||
|
|
@ -79,6 +76,7 @@ export interface Task {
|
|||
* 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
|
||||
* over the lifetime of a task.
|
||||
* @format int
|
||||
* @nullable
|
||||
*/
|
||||
ttlMs: number | null;
|
||||
|
|
@ -87,6 +85,7 @@ export interface Task {
|
|||
* Suggested polling interval in integer milliseconds. Clients SHOULD honor
|
||||
* this value to avoid overwhelming the server. This value MAY change over
|
||||
* the lifetime of a task.
|
||||
* @format int
|
||||
*/
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
|
@ -103,51 +102,6 @@ export interface WorkingTask extends Task {
|
|||
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.
|
||||
* 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.
|
||||
*/
|
||||
error: { [key: string]: unknown };
|
||||
error: JSONRPCErrorObject;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -230,7 +184,13 @@ export type DetailedTask =
|
|||
*
|
||||
* @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 */
|
||||
|
||||
|
|
@ -239,7 +199,7 @@ export type CreateTaskResult = Result & Task;
|
|||
*
|
||||
* @category `tasks/get`
|
||||
*/
|
||||
export interface GetTaskRequest extends JSONRPCRequest {
|
||||
export type GetTaskRequest = JSONRPCRequest & {
|
||||
method: "tasks/get";
|
||||
params: {
|
||||
/**
|
||||
|
|
@ -247,7 +207,7 @@ export interface GetTaskRequest extends JSONRPCRequest {
|
|||
*/
|
||||
taskId: string;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The response to a tasks/get request. Carries the appropriate DetailedTask
|
||||
|
|
@ -256,14 +216,20 @@ export interface GetTaskRequest extends JSONRPCRequest {
|
|||
*
|
||||
* @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.
|
||||
*
|
||||
* @category `tasks/update`
|
||||
*/
|
||||
export interface UpdateTaskRequest extends JSONRPCRequest {
|
||||
export type UpdateTaskRequest = JSONRPCRequest & {
|
||||
method: "tasks/update";
|
||||
params: {
|
||||
/**
|
||||
|
|
@ -277,7 +243,7 @@ export interface UpdateTaskRequest extends JSONRPCRequest {
|
|||
*/
|
||||
inputResponses: InputResponses;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The response to a tasks/update request. An empty acknowledgement.
|
||||
|
|
@ -285,14 +251,19 @@ export interface UpdateTaskRequest extends JSONRPCRequest {
|
|||
*
|
||||
* @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.
|
||||
*
|
||||
* @category `tasks/cancel`
|
||||
*/
|
||||
export interface CancelTaskRequest extends JSONRPCRequest {
|
||||
export type CancelTaskRequest = JSONRPCRequest & {
|
||||
method: "tasks/cancel";
|
||||
params: {
|
||||
/**
|
||||
|
|
@ -300,7 +271,7 @@ export interface CancelTaskRequest extends JSONRPCRequest {
|
|||
*/
|
||||
taskId: string;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The response to a tasks/cancel request. An empty acknowledgement.
|
||||
|
|
@ -309,7 +280,12 @@ export interface CancelTaskRequest extends JSONRPCRequest {
|
|||
*
|
||||
* @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 */
|
||||
|
||||
|
|
@ -329,10 +305,10 @@ export type TaskStatusNotificationParams = NotificationParams &
|
|||
*
|
||||
* @category `notifications/tasks`
|
||||
*/
|
||||
export interface TaskStatusNotification extends JSONRPCNotification {
|
||||
export type TaskStatusNotification = JSONRPCNotification & {
|
||||
method: "notifications/tasks";
|
||||
params: TaskStatusNotificationParams;
|
||||
}
|
||||
};
|
||||
|
||||
/* Subscription Additions */
|
||||
|
||||
|
|
|
|||
3942
schema/draft/spec.schema.json
Normal file
3942
schema/draft/spec.schema.json
Normal file
File diff suppressed because it is too large
Load diff
3205
schema/draft/spec.types.ts
Normal file
3205
schema/draft/spec.types.ts
Normal file
File diff suppressed because it is too large
Load diff
133
scripts/fetch-spec-schema.ts
Normal file
133
scripts/fetch-spec-schema.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -9,12 +9,13 @@
|
|||
* ## Post-Processing Steps
|
||||
*
|
||||
* ### 1. Zod Import Path (`"zod"` → `"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.
|
||||
* ts-to-zod generates `import { z } from "zod"`. We rewrite to `import { z } from "zod/v4"`.
|
||||
*
|
||||
* ### 2. External Type References (`z.any()` → SDK schemas)
|
||||
* ts-to-zod cannot resolve types imported from @modelcontextprotocol/sdk.
|
||||
* We replace z.any() placeholders with actual SDK schema imports.
|
||||
* ### 2. External Type References (`z.any()` → spec-owned stand-ins)
|
||||
* ts-to-zod cannot resolve types imported from the vendored spec.types.ts.
|
||||
* 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()`)
|
||||
* 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 { fileURLToPath } from "node:url";
|
||||
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 __dirname = dirname(__filename);
|
||||
|
|
@ -38,33 +39,64 @@ const SPEC_TYPES_FILE = join(SCHEMA_DIR, "schema.ts");
|
|||
const GENERATED_DIR = join(SCHEMA_DIR, "generated");
|
||||
const SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, "schema.ts");
|
||||
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.
|
||||
* Only includes types that have matching SDK exports.
|
||||
* Definitions owned by the core specification. In the generated schema.json
|
||||
* 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[] = [
|
||||
"JSONRPCRequestSchema",
|
||||
"JSONRPCNotificationSchema",
|
||||
"PaginatedRequestSchema",
|
||||
"PaginatedResultSchema",
|
||||
"ResultSchema",
|
||||
"RequestIdSchema",
|
||||
"ProgressTokenSchema",
|
||||
"CursorSchema",
|
||||
];
|
||||
const SPEC_OWNED_DEFS: Record<string, string> = {
|
||||
// Referenced by InputRequest/InputResponse unions in the canonical spec
|
||||
CreateMessageRequestSchema: "CreateMessageRequest",
|
||||
ListRootsRequestSchema: "ListRootsRequest",
|
||||
ElicitRequestSchema: "ElicitRequest",
|
||||
CreateMessageResultSchema: "CreateMessageResult",
|
||||
ListRootsResultSchema: "ListRootsResult",
|
||||
ElicitResultSchema: "ElicitResult",
|
||||
// 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.
|
||||
* We replace their z.any() placeholders with local Zod definitions.
|
||||
* Runtime Zod stand-ins for spec-owned types referenced from schema.ts.
|
||||
*
|
||||
* 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> = {
|
||||
RequestParamsSchema: `z.object({
|
||||
_meta: z.object({ progressToken: ProgressTokenSchema.optional() }).passthrough().optional(),
|
||||
}).passthrough()`,
|
||||
NotificationParamsSchema: `z.object({
|
||||
_meta: z.record(z.string(), z.unknown()).optional(),
|
||||
}).passthrough()`,
|
||||
InputRequestsSchema: `z.record(z.string(), z.unknown()).meta({ id: "InputRequests" })`,
|
||||
InputResponsesSchema: `z.record(z.string(), z.unknown()).meta({ id: "InputResponses" })`,
|
||||
JSONRPCErrorObjectSchema: `z.object({}).passthrough().meta({ id: "Error" })`,
|
||||
JSONRPCRequestSchema: `z.object({}).passthrough().meta({ id: "JSONRPCRequest" })`,
|
||||
JSONRPCNotificationSchema: `z.object({}).passthrough().meta({ id: "JSONRPCNotification" })`,
|
||||
ResultSchema: `z.object({}).passthrough().meta({ id: "Result" })`,
|
||||
NotificationParamsSchema: `z.object({}).passthrough().meta({ id: "NotificationParams" })`,
|
||||
};
|
||||
|
||||
async function main() {
|
||||
|
|
@ -169,6 +201,17 @@ async function generateJsonSchemaContent(
|
|||
const fileUrl = new URL(`file:///${zodSchemasFile.replace(/\\/g, "/")}`);
|
||||
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: {
|
||||
$schema: string;
|
||||
$id: string;
|
||||
|
|
@ -184,6 +227,8 @@ async function generateJsonSchemaContent(
|
|||
$defs: {},
|
||||
};
|
||||
|
||||
const conversionFailures: string[] = [];
|
||||
|
||||
for (const [name, schema] of Object.entries(schemas)) {
|
||||
if (
|
||||
name.endsWith("Schema") &&
|
||||
|
|
@ -192,54 +237,158 @@ async function generateJsonSchemaContent(
|
|||
) {
|
||||
const typeName = name.replace(/Schema$/, "");
|
||||
|
||||
// Skip SDK external types and local replacement types — only include task-specific types
|
||||
if (
|
||||
EXTERNAL_TYPE_SCHEMAS.includes(name) ||
|
||||
name in LOCAL_SCHEMA_REPLACEMENTS
|
||||
) {
|
||||
// Skip SDK external types, local replacement types, and spec-owned
|
||||
// types — only include task-specific types. Spec-owned definitions are
|
||||
// spliced from the vendored canonical spec schema below.
|
||||
if (name in LOCAL_SCHEMA_REPLACEMENTS || name in SPEC_OWNED_DEFS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
jsonSchema.$defs[typeName] = toJSONSchema(schema as $ZodType, {
|
||||
const emitted = toJSONSchema(schema as $ZodType, {
|
||||
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) {
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function postProcess(content: string): string {
|
||||
// 1. Rewrite to zod/v4 and add MCP SDK schema imports
|
||||
const sdkImports = EXTERNAL_TYPE_SCHEMAS.join(",\n ");
|
||||
|
||||
// 1. Rewrite to zod/v4
|
||||
content = content.replace(
|
||||
'import { z } from "zod";',
|
||||
`import { z } from "zod/v4";
|
||||
import {
|
||||
${sdkImports},
|
||||
} from "@modelcontextprotocol/sdk/types.js";`
|
||||
'import { z } from "zod/v4";'
|
||||
);
|
||||
|
||||
// 2. Remove z.any() placeholders for SDK-imported external types
|
||||
for (const schemaName of EXTERNAL_TYPE_SCHEMAS) {
|
||||
content = content.replace(
|
||||
new RegExp(
|
||||
`(?:export )?const ${schemaName} = z\\.any\\(\\);\\n?`,
|
||||
"g"
|
||||
),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Replace z.any() placeholders for locally-defined types
|
||||
// 2. Replace z.any() placeholders for spec-owned imported types with
|
||||
// permissive local stand-ins carrying canonical `.meta({ id })` marks.
|
||||
for (const [schemaName, replacement] of Object.entries(
|
||||
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()
|
||||
content = replaceRecordAndWithPassthrough(content);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue