Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/val-std-schema-compat.md
Original file line number Diff line number Diff line change
@@ -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<I, O>` 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.
19 changes: 13 additions & 6 deletions packages/mcp/src/jsonrpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion packages/mcp/src/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
27 changes: 15 additions & 12 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<unknown>)) 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;
Expand Down
18 changes: 9 additions & 9 deletions packages/val/src/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 14 additions & 5 deletions packages/val/src/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
29 changes: 29 additions & 0 deletions packages/val/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +22,10 @@ export type InferOutput<S> = S extends VStr

// ---------------------------------------------------------------- schemas

export interface VStr {
"~standard": StdTyped<string, string>;
}

export class VStr extends VSchema {
constructor() {
super("string");
Expand All @@ -34,6 +39,10 @@ export class VStr extends VSchema {
}
}

export interface VNum {
"~standard": StdTyped<number, number>;
}

export class VNum extends VSchema {
constructor() {
super("number");
Expand All @@ -47,6 +56,10 @@ export class VNum extends VSchema {
}
}

export interface VBool {
"~standard": StdTyped<boolean, boolean>;
}

export class VBool extends VSchema {
constructor() {
super("boolean");
Expand Down Expand Up @@ -225,6 +238,10 @@ export class VNullish extends VSchema {
}
}

export interface VArray {
"~standard": StdTyped<unknown[], unknown[]>;
}

export class VArray extends VSchema {
item: VSchema;
constructor(item: VSchema) {
Expand All @@ -250,6 +267,10 @@ export class VArray extends VSchema {
}
}

export interface VTuple {
"~standard": StdTyped<unknown[], unknown[]>;
}

export class VTuple extends VSchema {
items: VSchema[];
constructor(items: VSchema[]) {
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion packages/val/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<I, O>`, 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<I, O> 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) {
Expand Down
36 changes: 33 additions & 3 deletions scripts/pack-smoke.sh
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -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"
28 changes: 28 additions & 0 deletions tests/pack-smoke/mcp-app.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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));
10 changes: 10 additions & 0 deletions tests/pack-smoke/tsconfig.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"strict": true,
"module": "nodenext",
"target": "es2022",
"skipLibCheck": true,
"noEmit": true
},
"files": ["mcp-app.ts"]
}
Loading