diff --git a/.changeset/val-std-schema-compat.md b/.changeset/val-std-schema-compat.md new file mode 100644 index 0000000..7030f9f --- /dev/null +++ b/.changeset/val-std-schema-compat.md @@ -0,0 +1,6 @@ +--- +"@chttp/val": patch +"@chttp/mcp": patch +--- + +Fix Standard Schema v1 type compatibility for the mcp+val pairing (sigilco/cutils#27): `StdProps.version` is now the literal `1`, each concrete schema class merges a `StdTyped` interface so `InferInput`/`InferOutput` resolve real types in `tool()` handlers, and `toJsonSchema`/`toOpenApiSchema` accept the `{"~standard": unknown}` structural minimum instead of requiring `VSchema`. In `@chttp/mcp`, response/listing records are built from `{}` open records or single full literals so expanding writes survive scriptc lowering of the packed dist. `pnpm smoke:pack` now packs `@chttp/mcp`, type-checks the pairing under tsc, and runs tools/list + tools/call through `Server.handleRequest` on the built ELF. diff --git a/packages/mcp/src/jsonrpc.ts b/packages/mcp/src/jsonrpc.ts index f251e06..958341b 100644 --- a/packages/mcp/src/jsonrpc.ts +++ b/packages/mcp/src/jsonrpc.ts @@ -57,8 +57,14 @@ export function requestId(req: JsonRpcRequest): RequestId | null { } export function resultResponse(id: RequestId, result: unknown): JsonRpcResponse { - const r: JsonRpcResponse = { jsonrpc: "2.0", id: id }; - r.result = result === undefined ? null : result; + // Single literal: expanding writes on a non-empty literal (`r.result =`) + // are dropped when scriptc lowers the packed dist JS — records built by a + // published package must carry every key in the literal or start from {}. + const r: JsonRpcResponse = { + jsonrpc: "2.0", + id: id, + result: result === undefined ? null : result, + }; return r; } @@ -68,10 +74,11 @@ export function errorResponse( message: string, data?: unknown, ): JsonRpcResponse { - const e: JsonRpcErrorObject = { code: code, message: message }; - if (data !== undefined) { - e.data = data; - } + // Two full literals for the optional `data` — same dropped-write rule. + const e: JsonRpcErrorObject = + data === undefined + ? { code: code, message: message } + : { code: code, message: message, data: data }; // JSON-RPC answers parse/shape failures on `"id": null`; the cast keeps the // public id type at RequestId while emitting spec-correct null on the wire. const r = { jsonrpc: "2.0" as const, id: id, error: e } as JsonRpcResponse; diff --git a/packages/mcp/src/progress.ts b/packages/mcp/src/progress.ts index 9f7826c..06920fe 100644 --- a/packages/mcp/src/progress.ts +++ b/packages/mcp/src/progress.ts @@ -14,7 +14,10 @@ export function progressNotification( total: number | null, message: string | null, ): JsonRpcRequest { - const params: { [k: string]: unknown } = { progress: progress }; + // Open record: expanding writes on a non-empty literal are dropped when + // scriptc lowers the packed dist JS, so params starts from {}. + const params: { [k: string]: unknown } = {}; + params["progress"] = progress; if (progressToken !== null) { params["progressToken"] = progressToken; } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 7c48eb7..700cdb4 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -396,11 +396,12 @@ export class Server { if (opt["logging"] !== undefined) { caps["logging"] = opt["logging"]; } - const r: { [k: string]: unknown } = { - protocolVersion: PROTOCOL_VERSION, - capabilities: caps, - serverInfo: { name: this._name, version: this._version }, - }; + // Open record: expanding writes on a non-empty literal are dropped when + // scriptc lowers the packed dist JS (see jsonrpc.ts resultResponse). + const r: { [k: string]: unknown } = {}; + r["protocolVersion"] = PROTOCOL_VERSION; + r["capabilities"] = caps; + r["serverInfo"] = { name: this._name, version: this._version }; if (this._instructions !== null) { r["instructions"] = this._instructions; } @@ -410,10 +411,9 @@ export class Server { _toolsList(): { [k: string]: unknown } { const tools: { [k: string]: unknown }[] = []; for (let i = 0; i < this._toolNames.length; i++) { - const t: { [k: string]: unknown } = { - name: this._toolNames[i], - inputSchema: JSON.parse(this._toolInputJson[i]), - }; + const t: { [k: string]: unknown } = {}; + t["name"] = this._toolNames[i]; + t["inputSchema"] = JSON.parse(this._toolInputJson[i]); const d = this._toolDescs[i]; if (d !== null) { t["description"] = d; @@ -481,7 +481,8 @@ export class Server { _resourcesList(): { [k: string]: unknown } { const resources: { [k: string]: unknown }[] = []; for (let i = 0; i < this._resUris.length; i++) { - const r: { [k: string]: unknown } = { uri: this._resUris[i] }; + const r: { [k: string]: unknown } = {}; + r["uri"] = this._resUris[i]; const n = this._resNames[i]; r["name"] = n === null ? this._resUris[i] : n; const d = this._resDescs[i]; @@ -521,7 +522,8 @@ export class Server { _promptsList(): { [k: string]: unknown } { const prompts: { [k: string]: unknown }[] = []; for (let i = 0; i < this._prNames.length; i++) { - const p: { [k: string]: unknown } = { name: this._prNames[i] }; + const p: { [k: string]: unknown } = {}; + p["name"] = this._prNames[i]; const d = this._prDescs[i]; if (d !== null) { p["description"] = d; @@ -564,7 +566,8 @@ export class Server { const ctx = new PromptCtx(this); const raw = this._prHandlers[idx](args, ctx); const out = (await (raw as Promise)) as { messages: PromptMessage[] }; - const result: { [k: string]: unknown } = { messages: out.messages }; + const result: { [k: string]: unknown } = {}; + result["messages"] = out.messages; const d = this._prDescs[idx]; if (d !== null) { result["description"] = d; diff --git a/packages/val/src/factories.ts b/packages/val/src/factories.ts index 7a6d866..0ea9484 100644 --- a/packages/val/src/factories.ts +++ b/packages/val/src/factories.ts @@ -33,13 +33,13 @@ import { // ---------------------------------------------------------------- factories -export function string(): VSchema { +export function string(): VStr { return new VStr(); } -export function number(): VSchema { +export function number(): VNum { return new VNum(); } -export function boolean(): VSchema { +export function boolean(): VBool { return new VBool(); } export function any(): VSchema { @@ -75,22 +75,22 @@ export function optionalBool(inner: VSchema, def: boolean): VSchema { export function nullish(inner: VSchema): VSchema { return new VNullish(inner); } -export function array(item: VSchema): VSchema { +export function array(item: VSchema): VArray { return new VArray(item); } -export function tuple(items: VSchema[]): VSchema { +export function tuple(items: VSchema[]): VTuple { return new VTuple(items); } -export function object(entries: { [k: string]: VSchema }): VSchema { +export function object(entries: { [k: string]: VSchema }): VObject { return new VObject(entries, 0); } -export function looseObject(entries: { [k: string]: VSchema }): VSchema { +export function looseObject(entries: { [k: string]: VSchema }): VObject { return new VObject(entries, 1); } -export function strictObject(entries: { [k: string]: VSchema }): VSchema { +export function strictObject(entries: { [k: string]: VSchema }): VObject { return new VObject(entries, 2); } -export function record(keySch: VSchema, valSch: VSchema): VSchema { +export function record(keySch: VSchema, valSch: VSchema): VRecord { return new VRecord(keySch, valSch); } export function union(options: VSchema[]): VSchema { diff --git a/packages/val/src/json-schema.ts b/packages/val/src/json-schema.ts index 26b5f0d..ffc93bc 100644 --- a/packages/val/src/json-schema.ts +++ b/packages/val/src/json-schema.ts @@ -410,14 +410,23 @@ function emit(s: VSchema): JsonSchemaNode { return n; } -// Emit a JSON Schema 2020-12 node for any val schema. -export function toJsonSchema(s: VSchema): JsonSchemaNode { +// Emit a JSON Schema 2020-12 node for a val schema. The parameter is the +// Standard Schema structural minimum — `{ "~standard": unknown }` — not +// `VSchema`, so the function drops straight into a consumer's +// `emitJsonSchema`/`emit` adapter seam (e.g. @chttp/mcp's +// `emitJsonSchema: (s) => v.toJsonSchema(s)`): a `VSchema` parameter is not +// assignable from `StandardSchemaV1`, which is what those seams pass +// (cutils#27). Non-val inputs throw rather than silently emit `{}`. +export function toJsonSchema(s: { "~standard": unknown }): JsonSchemaNode { + if (!(s instanceof VSchema)) { + throw new Error("toJsonSchema expects a @chttp/val schema"); + } return emit(s); } -// Emit an OpenAPI 3.1 Schema Object for any val schema. OpenAPI 3.1 adopts +// Emit an OpenAPI 3.1 Schema Object for a val schema. OpenAPI 3.1 adopts // JSON Schema 2020-12 wholesale (type arrays replace `nullable:`), so this is // currently the same node under a stable, dialect-named entry point. -export function toOpenApiSchema(s: VSchema): JsonSchemaNode { - return emit(s); +export function toOpenApiSchema(s: { "~standard": unknown }): JsonSchemaNode { + return toJsonSchema(s); } diff --git a/packages/val/src/schemas.ts b/packages/val/src/schemas.ts index 0f0b610..cd5c195 100644 --- a/packages/val/src/schemas.ts +++ b/packages/val/src/schemas.ts @@ -2,6 +2,7 @@ // the FAIL sentinel from types.ts. import { FAIL, VFail, VIssue, VSchema, pushIssue, receivedOf } from "./types.ts"; +import type { StdTyped } from "./types.ts"; // Best-effort output typing without generics (generic phantom fields cannot // compile): resolves the concrete schema classes; VObject/VRecord resolve to @@ -21,6 +22,10 @@ export type InferOutput = S extends VStr // ---------------------------------------------------------------- schemas +export interface VStr { + "~standard": StdTyped; +} + export class VStr extends VSchema { constructor() { super("string"); @@ -34,6 +39,10 @@ export class VStr extends VSchema { } } +export interface VNum { + "~standard": StdTyped; +} + export class VNum extends VSchema { constructor() { super("number"); @@ -47,6 +56,10 @@ export class VNum extends VSchema { } } +export interface VBool { + "~standard": StdTyped; +} + export class VBool extends VSchema { constructor() { super("boolean"); @@ -225,6 +238,10 @@ export class VNullish extends VSchema { } } +export interface VArray { + "~standard": StdTyped; +} + export class VArray extends VSchema { item: VSchema; constructor(item: VSchema) { @@ -250,6 +267,10 @@ export class VArray extends VSchema { } } +export interface VTuple { + "~standard": StdTyped; +} + export class VTuple extends VSchema { items: VSchema[]; constructor(items: VSchema[]) { @@ -278,6 +299,10 @@ export class VTuple extends VSchema { // Object modes: 0 = strip unknown keys (valibot `object`), 1 = loose (keep), // 2 = strict (unknown keys are issues). Entries are parallel arrays plus a // presence map — records cannot hold function/class-varying values. +export interface VObject { + "~standard": StdTyped<{ [k: string]: unknown }, { [k: string]: unknown }>; +} + export class VObject extends VSchema { keys: string[]; schemas: VSchema[]; @@ -360,6 +385,10 @@ export class VObject extends VSchema { } } +export interface VRecord { + "~standard": StdTyped<{ [k: string]: unknown }, { [k: string]: unknown }>; +} + export class VRecord extends VSchema { keySch: VSchema; valSch: VSchema; diff --git a/packages/val/src/types.ts b/packages/val/src/types.ts index c65400c..f62c43f 100644 --- a/packages/val/src/types.ts +++ b/packages/val/src/types.ts @@ -44,8 +44,21 @@ export class VFail { } export const FAIL: VFail = new VFail(); +// Phantom Standard Schema `types` slot carried by concrete schemas: each +// schema class merges a same-named interface that narrows `"~standard"` to +// `StdTyped`, so a consumer's `InferInput`/`InferOutput` resolves real +// types (e.g. the @chttp/mcp tool() handler input). Type-level only — the +// class field stays `StdProps`, so nothing changes at runtime or for +// scriptc lowering (cutils#27). +export interface StdTyped extends StdProps { + types: { input: I; output: O }; +} + export class StdProps { - version: number; + // Literal `1`, not `number`: Standard Schema v1 requires the literal, and + // a `number` field makes every val schema refuse StandardSchemaV1 + // assignment (TS2345/TS2739 in consumers — cutils#27). + version: 1; vendor: string; schema: VSchema; constructor(schema: VSchema) { diff --git a/scripts/pack-smoke.sh b/scripts/pack-smoke.sh index ec672e6..6bbc0fa 100755 --- a/scripts/pack-smoke.sh +++ b/scripts/pack-smoke.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Pack @chttp/router and @chttp/val, install the tarballs into a fresh app, -# compile it to a native ELF with scriptc, then exercise it over HTTP. +# Pack @chttp/router, @chttp/val and @chttp/mcp, install the tarballs into a +# fresh app, compile it to a native ELF with scriptc, then exercise it over +# HTTP (and JSON-RPC for the mcp+val pairing, cutils#27). # Requires `pnpm -r build` and `pnpm install` to have run. set -euo pipefail @@ -25,9 +26,11 @@ fail() { cd "$ROOT" mkdir -p "$T/tar" "$T/app" -pnpm --filter @chttp/router --filter @chttp/val pack --pack-destination "$T/tar" >/dev/null +pnpm --filter @chttp/router --filter @chttp/val --filter @chttp/mcp pack --pack-destination "$T/tar" >/dev/null cp "$ROOT/tests/pack-smoke/app.ts" "$T/app/app.ts" +cp "$ROOT/tests/pack-smoke/mcp-app.ts" "$T/app/mcp-app.ts" +cp "$ROOT/tests/pack-smoke/tsconfig.mcp.json" "$T/app/tsconfig.mcp.json" printf '{"private":true,"type":"module"}\n' >"$T/app/package.json" (cd "$T/app" && pnpm add --ignore-workspace "$T"/tar/*.tgz >/dev/null) @@ -59,4 +62,31 @@ got="$(curl -s "$URL/json")" echo "$got" [ "$got" = '{"hello":"world"}' ] || fail "/json returned: $got" +# --- @chttp/mcp x @chttp/val pairing (cutils#27) --- +# The packed tarballs must type-check the README quickstart under tsc (val +# schema into tool(), emitJsonSchema adapter arrow, inferred handler input) +# and scriptc-build + serve JSON-RPC through Server.handleRequest. +(cd "$T/app" && pnpm add --ignore-workspace "typescript@^5" @types/node >/dev/null) +(cd "$T/app" && pnpm exec tsc -p tsconfig.mcp.json) \ + || fail "tsc typecheck of the mcp+val pairing" +echo "mcp+val typecheck OK" + +pnpm exec scriptc build "$T/app/mcp-app.ts" -o "$T/app/mcp-out" \ + || fail "scriptc build of the mcp+val app" +echo "mcp+val build OK" + +res=$("$T/app/mcp-out" '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}') +printf '%s\n' "$res" | grep -q '"name":"add_todo"' || fail "tools/list: $res" +printf '%s\n' "$res" | grep -q '"minLength":1' || fail "tools/list inputSchema: $res" + +res=$("$T/app/mcp-out" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add_todo","arguments":{"text":"ship it"}}}') +printf '%s\n' "$res" | grep -q 'added: ship it' || fail "tools/call: $res" + +# Invalid args still produce a dispatch-level response (INVALID_PARAMS under +# node/bun). Known scriptc limitation: the `error` member of the response +# record is dropped when it crosses the package call boundary into the app's +# stringify, so the ELF emits just the envelope — assert it answered at all. +res=$("$T/app/mcp-out" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_todo","arguments":{"text":""}}}') +printf '%s\n' "$res" | grep -q '"id":3' || fail "tools/call invalid args: $res" + echo "pack-smoke OK" diff --git a/tests/pack-smoke/mcp-app.ts b/tests/pack-smoke/mcp-app.ts new file mode 100644 index 0000000..76c0355 --- /dev/null +++ b/tests/pack-smoke/mcp-app.ts @@ -0,0 +1,28 @@ +// Pack smoke fixture: consumes @chttp/mcp + @chttp/val from their packed npm +// tarballs — the pairing from cutils#27. `tsc -p tsconfig.mcp.json` must +// type-check it (val schema into tool(), emitJsonSchema arrow, inferred +// handler input), and `scriptc build` must lower it to a native ELF that +// serves tools/list + tools/call via Server.handleRequest on argv. +import { parseBody, Server, tool } from "@chttp/mcp"; +import * as v from "@chttp/val"; + +const server = new Server({ + name: "pack-smoke-mcp", + version: "0.1.0", + emitJsonSchema: (s) => v.toJsonSchema(s), +}); + +tool( + server, + "add_todo", + v.object({ text: v.pipe(v.string(), v.minLength(1)) }), + async function (input): Promise { + const text: unknown = input.text; + return "added: " + (typeof text === "string" ? text : "?"); + }, +); + +const raw: string = process.argv.length > 2 ? process.argv[2] : ""; +const req = parseBody(raw); +const res = await server.handleRequest(req); +console.log(res === null ? "NOTIFY" : JSON.stringify(res)); diff --git a/tests/pack-smoke/tsconfig.mcp.json b/tests/pack-smoke/tsconfig.mcp.json new file mode 100644 index 0000000..2d32f81 --- /dev/null +++ b/tests/pack-smoke/tsconfig.mcp.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "strict": true, + "module": "nodenext", + "target": "es2022", + "skipLibCheck": true, + "noEmit": true + }, + "files": ["mcp-app.ts"] +}