@ultimat3/schema 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +26 -3
- package/README.md +23 -3
- package/package.json +2 -1
- package/src/absolute-url.ts +20 -0
- package/src/builder.ts +39 -4
- package/src/coerce.ts +32 -15
- package/src/error-codes.ts +1 -0
- package/src/errors.ts +32 -5
- package/src/index.ts +5 -1
- package/src/iso-date.ts +28 -4
- package/src/json-schema.ts +11 -1
- package/src/node-fits.ts +48 -0
- package/src/node.ts +2 -1
- package/src/render-meta.ts +53 -0
- package/src/standard.ts +12 -1
- package/src/validators.ts +2 -3
- package/src/wire-schema.ts +126 -0
package/CLAUDE.md
CHANGED
|
@@ -5,7 +5,7 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
|
|
|
5
5
|
| Rule | |
|
|
6
6
|
|---|---|
|
|
7
7
|
| Deps | none (`bun-types` only) |
|
|
8
|
-
| Errors | `SchemaError` mirrors `UltimateError` field-for-field **and message-for-message** (`code: title — cause`); keep `Symbol.for('ultimate.error')` |
|
|
8
|
+
| Errors | `SchemaError` mirrors `UltimateError` field-for-field **and message-for-message** (`code: title — cause`), `format({ docs })`, `retry` (always `'terminal'`) and a `toJSON().meta` that cannot throw (`render-meta.ts`, core's `renderMetaRecord` restated); keep `Symbol.for('ultimate.error')` |
|
|
9
9
|
| New validator | add to `validators.ts` **and** `TNamespace` **and** `t.ts` **and** `json-schema.ts` |
|
|
10
10
|
| IR | every schema carries `.node: SchemaNode`; generators read that, never the closure |
|
|
11
11
|
| **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` |
|
|
@@ -20,7 +20,8 @@ provider → t`. `char-count.ts` is imported by BOTH `validators.ts` (which reje
|
|
|
20
20
|
`describe-value.ts` (which renders the length in the same message), because they disagreed: the
|
|
21
21
|
rule counted code points and the message counted UTF-16 units, so `t.string.min(3)` refused `'👍a'`
|
|
22
22
|
with "at least 3 chars, received a string of 3 characters".
|
|
23
|
-
`standard.ts` and `errors.ts` depend on nothing but each other
|
|
23
|
+
`standard.ts` and `errors.ts` depend on nothing but each other, `render-meta.ts` (which reaches
|
|
24
|
+
`describe-value`) and `error-codes.ts`, a leaf of
|
|
24
25
|
plain data that core imports ALONE so a browser graph never keeps the `SchemaError` classes. `iso-date.ts` imports nothing and
|
|
25
26
|
is imported by `validators.ts` and `coerce.ts` — the two doors a `t.date` string comes through, so
|
|
26
27
|
the rule that a clock time must carry an offset or `Z` has one copy, not one per door.
|
|
@@ -144,7 +145,29 @@ Gotchas:
|
|
|
144
145
|
22.0.0): `'March 14, 2026'`, `'3/14/2026'` and `'12'` all parsed at the host's LOCAL midnight.
|
|
145
146
|
`iso-date.test.ts` runs the refusal set under two `TZ` values in subprocesses and requires one
|
|
146
147
|
answer. A number (epoch ms) still passes — it names an instant on every host.
|
|
147
|
-
-
|
|
148
|
+
- **`isIsoDateTime` checks the day against the month** (`As of 2026-10`): `new Date('2026-02-30')`
|
|
149
|
+
answers March 2nd. `DAYS_IN_MONTH` restates `daysInMonth` in `packages/time/src/plain-date.ts`
|
|
150
|
+
(tier 0 cannot import `time`); the `CALENDAR_PARITY` table in `iso-date.test.ts` and its twin in
|
|
151
|
+
`packages/time/src/plain-date.test.ts` hold them equal — the `time` copy asks BOTH predicates.
|
|
152
|
+
- **`t.url` is `isAbsoluteUrl` (`absolute-url.ts`), not bare `URL.canParse`**: input the parser
|
|
153
|
+
would strip (edge spaces / C0 controls, any tab or newline) is refused, since the validator
|
|
154
|
+
returns the string as written. Not `href === value` — that refuses `https://example.com`.
|
|
155
|
+
Non-http schemes still pass; narrowing them is an owner call nobody has made.
|
|
156
|
+
- **`isPlainObject` is a PROTOTYPE test** (`Object.prototype` or `null`). A `Map`, a `Date` and a
|
|
157
|
+
class instance parsed to `{}`. Cost: an object from another realm is refused too.
|
|
158
|
+
- **`.default(v)` runs `v` through the schema at declaration** — `X_SCHEMA_DEFAULT_INVALID`, the
|
|
159
|
+
sibling of `X_SCHEMA_DEFAULT_UNSHAREABLE`, whose title (cannot be copied) does not state this.
|
|
160
|
+
- **Async is "has a callable `then`"** (`isThenable` in `standard.ts`), never `instanceof Promise`:
|
|
161
|
+
a non-native thenable read as a result has no `issues`, so it was a success with no value.
|
|
162
|
+
- **`coerceNode` on a union tries every member** and takes the first whose result `fits`
|
|
163
|
+
(`node-fits.ts`, one level deep, never validation). A string some member takes as a string is
|
|
164
|
+
returned untouched first, so `number | string` never turns `01234` into 1234. Numerics are
|
|
165
|
+
decimal only (`DECIMAL`); `Number()` also reads `0x10`.
|
|
166
|
+
- Adding a `SchemaKind` means updating `json-schema.ts`, `coerce.ts` and `node-fits.ts` in the same commit.
|
|
167
|
+
- **`wire-schema.ts` is the one MCP tool-schema narrowing** (`toWireSchema`, `toWireOutputSchema`,
|
|
168
|
+
`WireJsonSchema`): `@ultimat3/mcp`'s `tools/list` and `@ultimat3/action`'s `.tool()` both publish
|
|
169
|
+
through it, so one declaration has one tool document. It lived in `mcp` (tier 4), out of
|
|
170
|
+
`action`'s reach, and the two documents differed (`format`, output bounds).
|
|
148
171
|
- **`ToJsonSchemaOptions.dialect` is a closed vocabulary read with `Object.hasOwn`** (`As of
|
|
149
172
|
2026-09-06`). `DIALECTS[dialect]` on an object literal answered the `Object` FUNCTION for
|
|
150
173
|
`dialect: 'constructor'` — measured: `$schema` held it, `JSON.stringify` dropped the key in
|
package/README.md
CHANGED
|
@@ -46,6 +46,15 @@ namespace member (`t.nullable`) and a free function (`nullableSchema`) — symme
|
|
|
46
46
|
|
|
47
47
|
Unknown object keys are **dropped**, never forwarded — an action cannot be mass-assigned.
|
|
48
48
|
|
|
49
|
+
Refused at the boundary rather than guessed at (`As of 2026-10`):
|
|
50
|
+
|
|
51
|
+
| Schema | Refuses | Because |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `t.date` | a day its month does not have — `2026-02-30`, `2026-04-31`, month `13` | `new Date` rolls it over to March 2nd; `@ultimat3/time`'s `plainDate` already refused it |
|
|
54
|
+
| `t.url` | leading / trailing spaces and C0 controls, a tab or newline anywhere | the URL parser strips them in silence and the validator returns the string as written. Trim before parsing |
|
|
55
|
+
| `t.object` `t.record` `t.money` | anything whose prototype is not `Object.prototype` or `null` — a `Map`, a `Date`, a class instance | none has the own keys the schema declared, so it parsed to `{}` |
|
|
56
|
+
| `.default(v)` | a `v` the schema itself rejects — `X_SCHEMA_DEFAULT_INVALID`, thrown where it is declared | an omitted field parsed to a value the same schema refuses when sent |
|
|
57
|
+
|
|
49
58
|
Every string-backed schema (`string` `uuid` `email` `url` `timezone` `locale` `slug` `cursor`, with
|
|
50
59
|
any `.min/.max/.pattern`) and every `t.record` key **refuses U+0000** — the one character Postgres
|
|
51
60
|
`text` and `jsonb` cannot store, so a NUL that passed reached the row write as a 500. Tabs, newlines
|
|
@@ -75,6 +84,11 @@ extension **and** appended to `description`, which is the only field an LLM read
|
|
|
75
84
|
is guaranteed to see. `.refine()` returns a plain `Schema`, so refining after `extend`/`pick`/`omit`
|
|
76
85
|
— which rebuild from the shape and would drop the rule — is a type error rather than a comment.
|
|
77
86
|
|
|
87
|
+
A flagged `.pattern(/^[a-z]+$/i)` keeps its flags the same two ways: JSON Schema's `pattern` has no
|
|
88
|
+
flag syntax (an inline `(?i)` is not ECMA-262), so the projection publishes `pattern` plus an
|
|
89
|
+
`x-ultimate-pattern-flags: 'i'` extension a compiling consumer reads (`@ultimat3/mcp`'s arg
|
|
90
|
+
validator), and states the flags in `description` for a reader.
|
|
91
|
+
|
|
78
92
|
### `discriminatedUnion` names the branch it judged
|
|
79
93
|
|
|
80
94
|
```ts
|
|
@@ -146,8 +160,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
146
160
|
|
|
147
161
|
| Class | Code | Declared in |
|
|
148
162
|
|---|---|---|
|
|
163
|
+
| `DefaultInvalidError` (extends `SchemaError`) | `X_SCHEMA_DEFAULT_INVALID` | `src/errors.ts` |
|
|
149
164
|
| `DiscriminantInvalidError` (extends `SchemaError`) | `X_SCHEMA_DISCRIMINANT_INVALID` | `src/errors.ts` |
|
|
150
|
-
| `SchemaError` | any schema code; the base of the
|
|
165
|
+
| `SchemaError` | any schema code; the base of the four that extend it. Extends `Error`, not core's `UltimateError` — schema imports nothing — and carries the same `Symbol.for('ultimate.error')` brand so `isUltimateError` answers `true` | `src/errors.ts` |
|
|
151
166
|
| `SchemaUnsupportedError` (extends `SchemaError`) | `X_SCHEMA_UNSUPPORTED` | `src/errors.ts` |
|
|
152
167
|
| `ValidationFailedError` (extends `SchemaError`) | `X_VALIDATION_FAILED` | `src/errors.ts` |
|
|
153
168
|
|
|
@@ -173,11 +188,16 @@ swap. `introspect()` is what OpenAPI/MCP generation needs; a provider without it
|
|
|
173
188
|
|
|
174
189
|
```ts
|
|
175
190
|
toJsonSchema(publishPost); // OpenAPI 3.1 (2020-12)
|
|
176
|
-
|
|
191
|
+
toWireSchema(publishPost); // an MCP tool's inputSchema: what tools/list serves and .tool() returns
|
|
192
|
+
toWireOutputSchema(publishPost); // its outputSchema: structure only; undefined for a non-object root
|
|
177
193
|
toJsonSchema(publishPost, { dialect: 'x' }); // X_SCHEMA_UNSUPPORTED — the two spellings are closed
|
|
178
194
|
parse(publishPost, coerceQuery(publishPost, url.searchParams));
|
|
179
195
|
```
|
|
180
196
|
|
|
181
197
|
Coercion is separate from validation on purpose: only the HTTP layer has strings that mean
|
|
182
198
|
numbers. `coerceQuery` promotes repeated params to arrays and leaves anything ambiguous
|
|
183
|
-
untouched so validation still produces the real error.
|
|
199
|
+
untouched so validation still produces the real error. It never invents data: only a **decimal**
|
|
200
|
+
numeral becomes a number (`0x10` stays text), only a plain object is read as one (an array is never
|
|
201
|
+
spread into `{ 0: … }`), and a union tries **every** member in declaration order — a string some
|
|
202
|
+
member already accepts is left alone, and a union of objects coerces through the member whose
|
|
203
|
+
literal fields the value carries.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/schema",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"src",
|
|
22
22
|
"!src/**/*.test.ts",
|
|
23
|
+
"!src/**/*-fixture.ts",
|
|
23
24
|
"CLAUDE.md",
|
|
24
25
|
"README.md",
|
|
25
26
|
"LICENSE"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Single responsibility: the one rule deciding whether a string is an absolute URL `t.url` may
|
|
2
|
+
// return as written — parseable, and not a string the parser had to cut before it could read it.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the URL parser DISCARDS before it reads anything: leading and trailing C0 controls and
|
|
6
|
+
* spaces, and every tab or newline wherever it sits (WHATWG URL §4.4, the two "validation error"
|
|
7
|
+
* strips). `URL.canParse` reports none of it, and this validator returns the string it was given —
|
|
8
|
+
* so `' https://a.b'` validated, was stored untrimmed, and rendered into an `href` as a relative
|
|
9
|
+
* path. The parsed `href` is only stable for input the parser did not have to cut.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately NOT `new URL(value).href === value`: that refuses `https://example.com` (the href
|
|
12
|
+
* gains a `/`) and any upper-case host, which are the same URL written differently, not a
|
|
13
|
+
* different string than the one validated.
|
|
14
|
+
*/
|
|
15
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: the controls are the rule.
|
|
16
|
+
const URL_PARSER_STRIPS = /^[\u0000-\u0020]|[\u0000-\u0020]$|[\t\n\r]/;
|
|
17
|
+
|
|
18
|
+
export function isAbsoluteUrl(value: string): boolean {
|
|
19
|
+
return !URL_PARSER_STRIPS.test(value) && URL.canParse(value);
|
|
20
|
+
}
|
package/src/builder.ts
CHANGED
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
// that turns a check function plus an IR node into a Standard-Schema-conforming object.
|
|
3
3
|
|
|
4
4
|
import { describeValue } from './describe-value';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DefaultInvalidError,
|
|
7
|
+
SchemaError,
|
|
8
|
+
ValidationFailedError,
|
|
9
|
+
type ValidationIssue,
|
|
10
|
+
} from './errors';
|
|
6
11
|
import type { SchemaNode, SchemaRefinement } from './node';
|
|
7
12
|
import {
|
|
13
|
+
formatIssues,
|
|
8
14
|
formatPath,
|
|
9
15
|
type InferInput,
|
|
10
16
|
type InferOutput,
|
|
17
|
+
isThenable,
|
|
11
18
|
type StandardIssue,
|
|
12
19
|
type StandardResult,
|
|
13
20
|
type StandardSchemaV1,
|
|
@@ -43,9 +50,19 @@ export function failWith(issues: readonly StandardIssue[]): CheckErr {
|
|
|
43
50
|
return { ok: false, issues };
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A record of own keys: an object literal, or a null-prototype one (what this package's own
|
|
55
|
+
* object and record parsers answer). The gate every object-ish check opens with.
|
|
56
|
+
*
|
|
57
|
+
* The PROTOTYPE is the test, not "an object that is not an array": a `Map`, a `Date` and a class
|
|
58
|
+
* instance are all that, and each has no own enumerable keys the schema declared — so
|
|
59
|
+
* `t.record(t.number)` parsed a `Map` of anything to `{}` and an all-optional `t.object` parsed a
|
|
60
|
+
* `Date` to `{}`, a success carrying none of what was sent.
|
|
61
|
+
*/
|
|
47
62
|
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
48
|
-
|
|
63
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
64
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
65
|
+
return prototype === Object.prototype || prototype === null;
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
/**
|
|
@@ -146,6 +163,22 @@ function defaultFactory<Out>(fallback: Out): () => Out {
|
|
|
146
163
|
return () => structuredClone(fallback);
|
|
147
164
|
}
|
|
148
165
|
|
|
166
|
+
/**
|
|
167
|
+
* A fallback the schema itself refuses, refused where it is WRITTEN. `t.number.min(5).default(1)`
|
|
168
|
+
* parsed an omitted field to 1 — a value the same schema rejects when a caller sends it — and
|
|
169
|
+
* published `minimum: 5, default: 1` to every generated client. Wrong for every parse that omits
|
|
170
|
+
* the field, so the first import of the authoring file says so, as `defaultFactory` does.
|
|
171
|
+
*/
|
|
172
|
+
function assertDefaultValid<Out>(check: Check<Out>, fallback: Out): void {
|
|
173
|
+
const result = check(fallback, []);
|
|
174
|
+
if (result.ok) return;
|
|
175
|
+
throw new DefaultInvalidError({
|
|
176
|
+
// The issue text is the RULE (`describeValue` never echoes content), so quoting it is safe.
|
|
177
|
+
cause: `default() received ${describeValue(fallback)}, which this schema refuses — ${formatIssues(result.issues).join('; ')}`,
|
|
178
|
+
fix: 'edit the .default(…) named in the stack so its value satisfies the rule quoted in cause, or relax that rule on the schema',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
149
182
|
/** The default declaration, dropped — for a wrapper that can no longer reach it. */
|
|
150
183
|
function withoutDefault(node: SchemaNode): SchemaNode {
|
|
151
184
|
const { hasDefault: _hasDefault, default: _default, ...rest } = node;
|
|
@@ -196,6 +229,8 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
|
|
|
196
229
|
);
|
|
197
230
|
},
|
|
198
231
|
default(fallback: Out): Schema<In | undefined, Out> {
|
|
232
|
+
// Rule before copy: a refused fallback has to be replaced, so its clone problem is moot.
|
|
233
|
+
assertDefaultValid(check, fallback);
|
|
199
234
|
const fresh = defaultFactory(fallback);
|
|
200
235
|
return makeSchema<In | undefined, Out>(
|
|
201
236
|
// The node keeps the DECLARATION, never a copy: `node.default` is what OpenAPI, the MCP
|
|
@@ -234,7 +269,7 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
|
|
|
234
269
|
export function checkOf<In, Out>(schema: Schema<In, Out>): Check<Out> {
|
|
235
270
|
return (value, path) => {
|
|
236
271
|
const result = schema['~standard'].validate(value);
|
|
237
|
-
if (result
|
|
272
|
+
if (isThenable(result)) {
|
|
238
273
|
return fail(path, 'expected a synchronous schema, received an async one');
|
|
239
274
|
}
|
|
240
275
|
if (result.issues === undefined) return pass(result.value);
|
package/src/coerce.ts
CHANGED
|
@@ -2,16 +2,25 @@
|
|
|
2
2
|
// HTTP layer has strings that "mean" numbers. Actions, jobs and MCP calls receive real JSON and
|
|
3
3
|
// must never get this leniency.
|
|
4
4
|
|
|
5
|
+
import { isPlainObject } from './builder';
|
|
5
6
|
import { isIsoDateTime } from './iso-date';
|
|
6
7
|
import type { SchemaNode } from './node';
|
|
8
|
+
import { fits } from './node-fits';
|
|
7
9
|
import { tryIntrospect } from './provider';
|
|
8
10
|
|
|
9
11
|
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
10
12
|
const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);
|
|
11
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A DECIMAL numeral, and nothing else `Number()` reads: it also takes `0x10`, `0b11` and `0o17`,
|
|
16
|
+
* so `?page=0x10` arrived as page 16 — a number the caller never wrote in the only notation a
|
|
17
|
+
* query string, a form field and the published `type: number` have in common.
|
|
18
|
+
*/
|
|
19
|
+
const DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
20
|
+
|
|
12
21
|
/** A numeric string as a number, or `undefined` for anything that is not confidently one. */
|
|
13
22
|
function numeric(raw: unknown): number | undefined {
|
|
14
|
-
if (typeof raw !== 'string' || raw.trim()
|
|
23
|
+
if (typeof raw !== 'string' || !DECIMAL.test(raw.trim())) return undefined;
|
|
15
24
|
const value = Number(raw);
|
|
16
25
|
return Number.isFinite(value) ? value : undefined;
|
|
17
26
|
}
|
|
@@ -38,9 +47,7 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
38
47
|
|
|
39
48
|
switch (node.kind) {
|
|
40
49
|
case 'number': {
|
|
41
|
-
|
|
42
|
-
const value = Number(raw);
|
|
43
|
-
return raw.trim() !== '' && Number.isFinite(value) ? value : raw;
|
|
50
|
+
return numeric(raw) ?? raw;
|
|
44
51
|
}
|
|
45
52
|
case 'boolean':
|
|
46
53
|
return booleanish(raw);
|
|
@@ -72,20 +79,23 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
72
79
|
return itemNode === undefined ? items : items.map((item) => coerceNode(itemNode, item));
|
|
73
80
|
}
|
|
74
81
|
case 'record': {
|
|
75
|
-
if (
|
|
82
|
+
if (!isPlainObject(raw) || node.valueNode === undefined) return raw;
|
|
76
83
|
// A null prototype for the reason `recordSchema` uses one: on a `{}` literal, assigning
|
|
77
84
|
// `out['__proto__']` hits the `Object.prototype` SETTER and the key vanishes, so the
|
|
78
85
|
// record validator's deliberate refusal of it never ran — the key was reported absent
|
|
79
86
|
// rather than rejected, on the one path (HTTP query) where it is caller-controlled.
|
|
80
87
|
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
81
|
-
for (const [key, value] of Object.entries(raw
|
|
88
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
82
89
|
out[key] = coerceNode(node.valueNode, value);
|
|
83
90
|
}
|
|
84
91
|
return out;
|
|
85
92
|
}
|
|
86
93
|
case 'object': {
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
// A plain object or nothing: `{ ...['x'] }` is `{ 0: 'x' }` and `{ ...new Date() }` is `{}`,
|
|
95
|
+
// so spreading anything else MADE an object the caller never sent — and an all-optional
|
|
96
|
+
// schema then accepted it. Untouched, validation says "expected an object".
|
|
97
|
+
if (!isPlainObject(raw) || node.properties === undefined) return raw;
|
|
98
|
+
const source = raw;
|
|
89
99
|
const out: Record<string, unknown> = { ...source };
|
|
90
100
|
for (const [key, child] of Object.entries(node.properties)) {
|
|
91
101
|
// `Object.hasOwn`, never `key in source`: `{ ...source }` above already dropped what a
|
|
@@ -96,8 +106,8 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
96
106
|
return out;
|
|
97
107
|
}
|
|
98
108
|
case 'money': {
|
|
99
|
-
if (
|
|
100
|
-
const source = raw
|
|
109
|
+
if (!isPlainObject(raw)) return raw;
|
|
110
|
+
const source = raw;
|
|
101
111
|
// Through `numeric` for the same reason `scale` is: `Number('')` is 0, so a blank amount
|
|
102
112
|
// field converted here would reach the validator as a legitimate zero and book an empty
|
|
103
113
|
// price input as free. A blank stays a blank and fails validation, which is the real error.
|
|
@@ -110,11 +120,18 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
110
120
|
return { ...source, minor, ...(scale === undefined ? {} : { scale }) };
|
|
111
121
|
}
|
|
112
122
|
case 'union': {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
const members = node.anyOf ?? [];
|
|
124
|
+
// A string some member takes AS A STRING is never converted behind its back: under
|
|
125
|
+
// `number | string` a postcode `01234` must not arrive as 1234.
|
|
126
|
+
if (typeof raw === 'string' && members.some((member) => fits(member, raw))) return raw;
|
|
127
|
+
// Every member is tried, in declaration order, and the first whose coercion FITS it wins.
|
|
128
|
+
// The first member alone used to decide: `'auto' | 2` left `"2"` a string because `'auto'`
|
|
129
|
+
// needs no conversion, and a union of objects coerced every value by its first branch.
|
|
130
|
+
for (const member of members) {
|
|
131
|
+
const coerced = coerceNode(member, raw);
|
|
132
|
+
if (fits(member, coerced)) return coerced;
|
|
133
|
+
}
|
|
134
|
+
return raw;
|
|
118
135
|
}
|
|
119
136
|
default:
|
|
120
137
|
return raw;
|
package/src/error-codes.ts
CHANGED
package/src/errors.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// and carries the same `Symbol.for('ultimate.error')` brand — `isUltimateError()` still matches.
|
|
4
4
|
|
|
5
5
|
import { SCHEMA_ERROR_CODES } from './error-codes';
|
|
6
|
+
import { renderMetaRecord } from './render-meta';
|
|
6
7
|
import { formatIssues } from './standard';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -78,9 +79,16 @@ export interface SchemaErrorJSON {
|
|
|
78
79
|
readonly cause: string;
|
|
79
80
|
readonly fix: string;
|
|
80
81
|
readonly docs: string;
|
|
82
|
+
/** Always present, as on `UltimateErrorJSON` — a client never has to infer it. */
|
|
83
|
+
readonly retry: 'terminal';
|
|
81
84
|
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
export interface SchemaFormatOptions {
|
|
88
|
+
/** Append a 4th `docs:` line. Off by default, as `UltimateError.format()` has it. */
|
|
89
|
+
readonly docs?: boolean | undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
export class SchemaError extends Error {
|
|
85
93
|
readonly [ULTIMATE_ERROR_BRAND] = true;
|
|
86
94
|
override readonly name: string = 'SchemaError';
|
|
@@ -89,6 +97,12 @@ export class SchemaError extends Error {
|
|
|
89
97
|
declare readonly cause: string;
|
|
90
98
|
readonly fix: string;
|
|
91
99
|
readonly docs: string;
|
|
100
|
+
/**
|
|
101
|
+
* `terminal` on every instance: a value that does not match its schema does not match it on
|
|
102
|
+
* attempt five. Carried HERE as well as in core's retry registry because `errorRetryOf` reads
|
|
103
|
+
* the field off the error, and a process that never imported core has no registry to ask.
|
|
104
|
+
*/
|
|
105
|
+
readonly retry = 'terminal' as const;
|
|
92
106
|
readonly meta: Readonly<Record<string, unknown>> | undefined;
|
|
93
107
|
|
|
94
108
|
constructor(init: SchemaErrorInit) {
|
|
@@ -119,10 +133,10 @@ export class SchemaError extends Error {
|
|
|
119
133
|
}
|
|
120
134
|
|
|
121
135
|
/** The same 3-line rendering as `UltimateError.format()`, escaped in the same place: neither. */
|
|
122
|
-
format(): string {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
);
|
|
136
|
+
format(options?: SchemaFormatOptions): string {
|
|
137
|
+
const lines = [`${this.code}: ${this.title}`, ` cause: ${this.cause}`, ` fix: ${this.fix}`];
|
|
138
|
+
if (options?.docs === true) lines.push(` docs: ${this.docs}`);
|
|
139
|
+
return lines.join('\n');
|
|
126
140
|
}
|
|
127
141
|
|
|
128
142
|
toJSON(): SchemaErrorJSON {
|
|
@@ -132,7 +146,10 @@ export class SchemaError extends Error {
|
|
|
132
146
|
cause: this.cause,
|
|
133
147
|
fix: this.fix,
|
|
134
148
|
docs: this.docs,
|
|
135
|
-
|
|
149
|
+
retry: this.retry,
|
|
150
|
+
// A schema error's `meta` can hold what the caller sent; a `bigint` or a cycle in it threw
|
|
151
|
+
// here, at `--json` render time, one layer past a constructor that had already succeeded.
|
|
152
|
+
meta: renderMetaRecord(this.meta),
|
|
136
153
|
};
|
|
137
154
|
}
|
|
138
155
|
}
|
|
@@ -185,6 +202,16 @@ export class DiscriminantInvalidError extends SchemaError {
|
|
|
185
202
|
}
|
|
186
203
|
}
|
|
187
204
|
|
|
205
|
+
/** Thrown where `.default()` is WRITTEN: a fallback the schema itself refuses is wrong for every parse. */
|
|
206
|
+
export class DefaultInvalidError extends SchemaError {
|
|
207
|
+
static readonly code = 'X_SCHEMA_DEFAULT_INVALID';
|
|
208
|
+
override readonly name = 'DefaultInvalidError';
|
|
209
|
+
|
|
210
|
+
constructor(init: Omit<SchemaErrorInit, 'code'>) {
|
|
211
|
+
super({ ...init, code: DefaultInvalidError.code });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
188
215
|
export class SchemaUnsupportedError extends SchemaError {
|
|
189
216
|
static readonly code = 'X_SCHEMA_UNSUPPORTED';
|
|
190
217
|
override readonly name = 'SchemaUnsupportedError';
|
package/src/index.ts
CHANGED
|
@@ -30,9 +30,11 @@ export { SCHEMA_ERROR_CODES } from './error-codes';
|
|
|
30
30
|
export type {
|
|
31
31
|
SchemaErrorInit,
|
|
32
32
|
SchemaErrorJSON,
|
|
33
|
+
SchemaFormatOptions,
|
|
33
34
|
ValidationIssue,
|
|
34
35
|
} from './errors';
|
|
35
36
|
export {
|
|
37
|
+
DefaultInvalidError,
|
|
36
38
|
DiscriminantInvalidError,
|
|
37
39
|
isSchemaError,
|
|
38
40
|
SchemaError,
|
|
@@ -49,7 +51,7 @@ export type {
|
|
|
49
51
|
JsonSchemaType,
|
|
50
52
|
ToJsonSchemaOptions,
|
|
51
53
|
} from './json-schema';
|
|
52
|
-
export { nodeToJsonSchema, toJsonSchema
|
|
54
|
+
export { nodeToJsonSchema, toJsonSchema } from './json-schema';
|
|
53
55
|
export type { MoneyValue } from './money-value';
|
|
54
56
|
export {
|
|
55
57
|
CURRENCY_CODE_PATTERN,
|
|
@@ -112,3 +114,5 @@ export {
|
|
|
112
114
|
refineSchema,
|
|
113
115
|
unionSchema,
|
|
114
116
|
} from './validators';
|
|
117
|
+
export type { WireJsonSchema } from './wire-schema';
|
|
118
|
+
export { toWireOutputSchema, toWireSchema } from './wire-schema';
|
package/src/iso-date.ts
CHANGED
|
@@ -20,7 +20,28 @@ const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i;
|
|
|
20
20
|
* because RFC 3339 §5.6 permits a lowercase `t` and `z`.
|
|
21
21
|
*/
|
|
22
22
|
const ISO_SHAPE =
|
|
23
|
-
|
|
23
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/i;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Days per month, January first, in a common year. **Twin: `daysInMonth` in
|
|
27
|
+
* `packages/time/src/plain-date.ts`** — this package is tier 0 and cannot import `@ultimat3/time`,
|
|
28
|
+
* so the Gregorian table is restated rather than shared. The two are held equal by the parity
|
|
29
|
+
* table in `iso-date.test.ts` and `packages/time/src/plain-date.test.ts`; edit both or neither.
|
|
30
|
+
*/
|
|
31
|
+
const DAYS_IN_MONTH: readonly number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
32
|
+
|
|
33
|
+
const isLeapYear = (year: number): boolean =>
|
|
34
|
+
(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The half a regex cannot hold. `new Date('2026-02-30')` does not refuse — it answers March 2nd —
|
|
38
|
+
* so a typo validated, and the instant that was stored is one the caller never wrote.
|
|
39
|
+
*/
|
|
40
|
+
function isCalendarDay(year: number, month: number, day: number): boolean {
|
|
41
|
+
const days = DAYS_IN_MONTH[month - 1];
|
|
42
|
+
if (days === undefined) return false;
|
|
43
|
+
return day >= 1 && day <= (month === 2 && isLeapYear(year) ? 29 : days);
|
|
44
|
+
}
|
|
24
45
|
|
|
25
46
|
/** What a `t.date` string must not be: a clock time with no offset and no `Z`. */
|
|
26
47
|
export function isZonelessDateTime(value: string): boolean {
|
|
@@ -28,9 +49,12 @@ export function isZonelessDateTime(value: string): boolean {
|
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
/**
|
|
31
|
-
* True when `value` is an ISO-8601 date, or date-time carrying `Z` or an offset
|
|
32
|
-
* whose instant is the same on every host
|
|
52
|
+
* True when `value` is an ISO-8601 date, or date-time carrying `Z` or an offset, naming a day its
|
|
53
|
+
* month has: the only strings whose instant is the same on every host and is the one written.
|
|
54
|
+
* Check it before `new Date(value)`, every time.
|
|
33
55
|
*/
|
|
34
56
|
export function isIsoDateTime(value: string): boolean {
|
|
35
|
-
|
|
57
|
+
const match = ISO_SHAPE.exec(value);
|
|
58
|
+
if (match === null || isZonelessDateTime(value)) return false;
|
|
59
|
+
return isCalendarDay(Number(match[1]), Number(match[2]), Number(match[3]));
|
|
36
60
|
}
|
package/src/json-schema.ts
CHANGED
|
@@ -34,6 +34,13 @@ export interface JsonSchema {
|
|
|
34
34
|
readonly minLength?: number;
|
|
35
35
|
readonly maxLength?: number;
|
|
36
36
|
readonly pattern?: string;
|
|
37
|
+
/**
|
|
38
|
+
* The RegExp flags `pattern` is applied with, when there are any. JSON Schema's `pattern` has no
|
|
39
|
+
* flag syntax and an inline `(?i)` is not ECMA-262, so they travel beside it as an extension a
|
|
40
|
+
* consumer that compiles the pattern (`@ultimat3/mcp`'s arg validator) reads; the prose copy in
|
|
41
|
+
* `description` is for a reader.
|
|
42
|
+
*/
|
|
43
|
+
readonly 'x-ultimate-pattern-flags'?: string;
|
|
37
44
|
readonly minimum?: number;
|
|
38
45
|
readonly maximum?: number;
|
|
39
46
|
readonly enum?: readonly (string | number)[];
|
|
@@ -99,6 +106,9 @@ function stringNode(node: SchemaNode): JsonSchema {
|
|
|
99
106
|
...(node.minLength === undefined ? {} : { minLength: node.minLength }),
|
|
100
107
|
...(node.maxLength === undefined ? {} : { maxLength: node.maxLength }),
|
|
101
108
|
...(node.pattern === undefined ? {} : { pattern: node.pattern }),
|
|
109
|
+
...(node.pattern === undefined || node.patternFlags === undefined || node.patternFlags === ''
|
|
110
|
+
? {}
|
|
111
|
+
: { 'x-ultimate-pattern-flags': node.patternFlags }),
|
|
102
112
|
};
|
|
103
113
|
}
|
|
104
114
|
|
|
@@ -315,7 +325,7 @@ export function toJsonSchema(schema: unknown, options?: ToJsonSchemaOptions): Js
|
|
|
315
325
|
};
|
|
316
326
|
}
|
|
317
327
|
|
|
318
|
-
/**
|
|
328
|
+
/** Draft-07, no `$schema`: the document `wire-schema.ts` narrows to what an MCP tool publishes. */
|
|
319
329
|
export function toMcpInputSchema(schema: unknown): JsonSchema {
|
|
320
330
|
return toJsonSchema(schema, { dialect: 'draft-07', includeDialect: false });
|
|
321
331
|
}
|
package/src/node-fits.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Single responsibility: does a value have the SHAPE a node names, one level deep — the question
|
|
2
|
+
// `coerce.ts` asks to pick the union member a raw HTTP value was written for.
|
|
3
|
+
|
|
4
|
+
import { isPlainObject } from './builder';
|
|
5
|
+
import type { SchemaNode } from './node';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether `value` is the SHAPE `node` names — one level deep, which is all a union needs to pick
|
|
9
|
+
* a branch. Not validation: it never reads a bound, a pattern or a refinement, so a value that
|
|
10
|
+
* fits can still be refused, with the real message, by the schema it is handed to next.
|
|
11
|
+
*
|
|
12
|
+
* An object fits when every LITERAL field it declares holds that literal — the tag of a
|
|
13
|
+
* discriminated union, read off the IR without asking which key the discriminant is.
|
|
14
|
+
*/
|
|
15
|
+
export function fits(node: SchemaNode, value: unknown): boolean {
|
|
16
|
+
switch (node.kind) {
|
|
17
|
+
case 'string':
|
|
18
|
+
return typeof value === 'string';
|
|
19
|
+
case 'number':
|
|
20
|
+
return typeof value === 'number';
|
|
21
|
+
case 'boolean':
|
|
22
|
+
return typeof value === 'boolean';
|
|
23
|
+
case 'date':
|
|
24
|
+
return value instanceof Date;
|
|
25
|
+
case 'literal':
|
|
26
|
+
return value === node.literal;
|
|
27
|
+
case 'enum':
|
|
28
|
+
return (node.values ?? []).some((member) => member === value);
|
|
29
|
+
case 'array':
|
|
30
|
+
return Array.isArray(value);
|
|
31
|
+
case 'record':
|
|
32
|
+
case 'money':
|
|
33
|
+
return isPlainObject(value);
|
|
34
|
+
case 'object': {
|
|
35
|
+
if (!isPlainObject(value)) return false;
|
|
36
|
+
return Object.entries(node.properties ?? {}).every(
|
|
37
|
+
([key, child]) =>
|
|
38
|
+
child.kind !== 'literal' ||
|
|
39
|
+
(child.optional === true && !Object.hasOwn(value, key)) ||
|
|
40
|
+
(Object.hasOwn(value, key) && value[key] === child.literal),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
case 'union':
|
|
44
|
+
return (node.anyOf ?? []).some((member) => fits(member, value));
|
|
45
|
+
default:
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/node.ts
CHANGED
|
@@ -54,7 +54,8 @@ export interface SchemaNode {
|
|
|
54
54
|
/**
|
|
55
55
|
* The RegExp's flags, carried beside the source for the same reason. Dropping them made
|
|
56
56
|
* `t.string.pattern(/^[a-z]+$/i)` reject `ABC` while quoting the pattern that matches it.
|
|
57
|
-
* JSON Schema's `pattern` has no flags, so `json-schema.ts`
|
|
57
|
+
* JSON Schema's `pattern` has no flags, so `json-schema.ts` publishes them as
|
|
58
|
+
* `x-ultimate-pattern-flags` beside it, and states them in `description` for a reader.
|
|
58
59
|
*/
|
|
59
60
|
readonly patternFlags?: string | undefined;
|
|
60
61
|
readonly minimum?: number | undefined;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Single responsibility: an error's `meta` as a record `JSON.stringify` cannot throw on. The twin
|
|
2
|
+
// of `renderMetaRecord` in `packages/core/src/error-render.ts`, restated because this package is
|
|
3
|
+
// tier 0 and may not import core — `SchemaError.toJSON()` makes the same `--json` promise.
|
|
4
|
+
|
|
5
|
+
import { describeValue } from './describe-value';
|
|
6
|
+
|
|
7
|
+
type Meta = Readonly<Record<string, unknown>>;
|
|
8
|
+
|
|
9
|
+
/** `JSON.stringify` with its throw removed: did the value survive being serialised at all? */
|
|
10
|
+
function canRender(value: unknown): boolean {
|
|
11
|
+
try {
|
|
12
|
+
JSON.stringify(value);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A record's own keys, or none — a `Proxy` may refuse to be enumerated. */
|
|
20
|
+
function metaKeys(meta: Meta): readonly string[] {
|
|
21
|
+
try {
|
|
22
|
+
return Object.keys(meta);
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One entry, kept as it is when it serialises. What does not is DESCRIBED, never echoed: a schema
|
|
30
|
+
* error's `meta` holds the value a caller sent, so `describeValue` — shape and length, no content —
|
|
31
|
+
* is the only rendering this package has for one. A function is described too: `JSON.stringify`
|
|
32
|
+
* drops it without throwing, but copying a `toJSON` across would have it invoked one layer out.
|
|
33
|
+
*/
|
|
34
|
+
function metaEntry(meta: Meta, key: string): unknown {
|
|
35
|
+
try {
|
|
36
|
+
const value = meta[key];
|
|
37
|
+
return canRender(value) && typeof value !== 'function' ? value : describeValue(value);
|
|
38
|
+
} catch {
|
|
39
|
+
return 'a value that cannot be read';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `meta` is machine-read, so a record that serialises is returned UNCHANGED, identity included.
|
|
45
|
+
* Only what cannot be rendered degrades, one key at a time — a `bigint` or a cycle in one entry
|
|
46
|
+
* must not cost the reader the entries beside it, and must not throw at `--json` render time.
|
|
47
|
+
*/
|
|
48
|
+
export function renderMetaRecord(meta: Meta | undefined): Meta | undefined {
|
|
49
|
+
if (meta === undefined || canRender(meta)) return meta;
|
|
50
|
+
const out: Record<string, unknown> = {};
|
|
51
|
+
for (const key of metaKeys(meta)) out[key] = metaEntry(meta, key);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
package/src/standard.ts
CHANGED
|
@@ -96,11 +96,22 @@ export function toValidationIssues(issues: readonly StandardIssue[]): readonly V
|
|
|
96
96
|
}));
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Async by the rule `await` itself uses — a callable `then` — never `instanceof Promise`. A
|
|
101
|
+
* library's own promise class, a polyfill or a Promise from another realm fails that test, and was
|
|
102
|
+
* then read as a RESULT: no `issues` key, so a success, with `value` undefined. `parse` returned
|
|
103
|
+
* `undefined` for input nothing had validated.
|
|
104
|
+
*/
|
|
105
|
+
export function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
106
|
+
if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return false;
|
|
107
|
+
return typeof (value as { then?: unknown }).then === 'function';
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
function assertSync<Output>(
|
|
100
111
|
result: StandardResult<Output> | Promise<StandardResult<Output>>,
|
|
101
112
|
vendor: string,
|
|
102
113
|
): StandardResult<Output> {
|
|
103
|
-
if (result
|
|
114
|
+
if (isThenable(result)) {
|
|
104
115
|
throw new SchemaUnsupportedError({
|
|
105
116
|
cause: `${vendor} validated asynchronously in a synchronous call site`,
|
|
106
117
|
fix: 'call validateAsync()/parseAsync() instead of validate()/parse()',
|
package/src/validators.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// ArkType or Zod replace them wholesale via `configureSchemaProvider()` — neither ships; the IR and the
|
|
3
3
|
// Standard Schema surface are what the rest of the framework actually depends on.
|
|
4
4
|
|
|
5
|
+
import { isAbsoluteUrl } from './absolute-url';
|
|
5
6
|
import {
|
|
6
7
|
type AnySchema,
|
|
7
8
|
type Check,
|
|
@@ -453,9 +454,7 @@ export const builtinT: TNamespace = Object.freeze({
|
|
|
453
454
|
email: makeStringSchema({ kind: 'string', format: 'email' }, 'an email address', (value) =>
|
|
454
455
|
EMAIL_RE.test(value),
|
|
455
456
|
),
|
|
456
|
-
url: makeStringSchema({ kind: 'string', format: 'uri' }, 'an absolute URL',
|
|
457
|
-
URL.canParse(value),
|
|
458
|
-
),
|
|
457
|
+
url: makeStringSchema({ kind: 'string', format: 'uri' }, 'an absolute URL', isAbsoluteUrl),
|
|
459
458
|
date: dateSchema,
|
|
460
459
|
money: moneySchema,
|
|
461
460
|
timezone: makeStringSchema(
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Single responsibility: the JSON Schema subset an MCP tool publishes, and the two narrowings onto
|
|
2
|
+
// it — an input's and an output's. One home, tier 0, so `@ultimat3/action`'s `.tool()` and
|
|
3
|
+
// `@ultimat3/mcp`'s `tools/list` publish ONE document for one declaration rather than two.
|
|
4
|
+
|
|
5
|
+
import type { JsonSchema } from './json-schema';
|
|
6
|
+
import { toMcpInputSchema } from './json-schema';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The subset a tool's `inputSchema` and `outputSchema` speak, and the one `@ultimat3/mcp`'s
|
|
10
|
+
* `validateArgs` holds a hand-written tool to. Narrow on purpose: a tool schema an agent cannot
|
|
11
|
+
* fully understand is a tool it will call wrong.
|
|
12
|
+
*/
|
|
13
|
+
export interface WireJsonSchema {
|
|
14
|
+
readonly type?: 'object' | 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'null';
|
|
15
|
+
readonly title?: string;
|
|
16
|
+
readonly description?: string;
|
|
17
|
+
readonly properties?: Readonly<Record<string, WireJsonSchema>>;
|
|
18
|
+
readonly required?: readonly string[];
|
|
19
|
+
readonly additionalProperties?: boolean;
|
|
20
|
+
readonly items?: WireJsonSchema;
|
|
21
|
+
readonly enum?: readonly (string | number | boolean | null)[];
|
|
22
|
+
readonly const?: string | number | boolean | null;
|
|
23
|
+
readonly default?: unknown;
|
|
24
|
+
/**
|
|
25
|
+
* NOT in the subset: `format` NAMES a rule whose meaning lives in this package (`uuid`, `email`,
|
|
26
|
+
* `iana-time-zone`), and `validateArgs` cannot hold a hand-written tool to it without a second
|
|
27
|
+
* definition of each one. `never`, so a narrowing that starts copying it again fails to compile.
|
|
28
|
+
* `pattern` is the opposite case — the rule travels WITH the schema, so it is kept.
|
|
29
|
+
*/
|
|
30
|
+
readonly format?: never;
|
|
31
|
+
readonly minimum?: number;
|
|
32
|
+
readonly maximum?: number;
|
|
33
|
+
readonly minLength?: number;
|
|
34
|
+
readonly maxLength?: number;
|
|
35
|
+
/** A `RegExp.source`, JSON Schema semantics (a partial match unless it anchors itself). */
|
|
36
|
+
readonly pattern?: string;
|
|
37
|
+
/** The flags `pattern` compiles with — JSON Schema's `pattern` carries none. */
|
|
38
|
+
readonly 'x-ultimate-pattern-flags'?: string;
|
|
39
|
+
readonly anyOf?: readonly WireJsonSchema[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A Standard Schema → a tool's `inputSchema`. Throws `X_SCHEMA_UNSUPPORTED` when it cannot say. */
|
|
43
|
+
export function toWireSchema(schema: unknown): WireJsonSchema {
|
|
44
|
+
return narrow(toMcpInputSchema(schema));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A Standard Schema → a tool's `outputSchema`, or `undefined` when its root is not an object (MCP's
|
|
49
|
+
* `structuredContent` is one) or it cannot be introspected. STRUCTURE ONLY: a client refuses a call
|
|
50
|
+
* whose `structuredContent` misses this document, and a bound, a pattern or `additionalProperties:
|
|
51
|
+
* false` on a value the server produced (a query's rows are never re-parsed) is a promise nothing
|
|
52
|
+
* enforces on the way out.
|
|
53
|
+
*/
|
|
54
|
+
export function toWireOutputSchema(schema: unknown): WireJsonSchema | undefined {
|
|
55
|
+
let rich: JsonSchema;
|
|
56
|
+
try {
|
|
57
|
+
rich = toMcpInputSchema(schema);
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return rich.type === 'object' ? shape(rich) : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// exactOptionalPropertyTypes: every field is attached only when present, never as an explicit
|
|
65
|
+
// `undefined` — `tools/list` serialises this object verbatim.
|
|
66
|
+
function narrow(source: JsonSchema): WireJsonSchema {
|
|
67
|
+
return {
|
|
68
|
+
...(source.type === undefined ? {} : { type: source.type }),
|
|
69
|
+
...(source.title === undefined ? {} : { title: source.title }),
|
|
70
|
+
...(source.description === undefined ? {} : { description: source.description }),
|
|
71
|
+
...(source.properties === undefined ? {} : { properties: mapKeys(source.properties, narrow) }),
|
|
72
|
+
...(source.required === undefined ? {} : { required: source.required }),
|
|
73
|
+
// The subset says yes or no to extra keys; an unrepresentable value schema becomes `true`
|
|
74
|
+
// (permit) rather than a rejection the agent was never warned about.
|
|
75
|
+
...(source.additionalProperties === undefined
|
|
76
|
+
? {}
|
|
77
|
+
: { additionalProperties: source.additionalProperties !== false }),
|
|
78
|
+
...(source.items === undefined ? {} : { items: narrow(source.items) }),
|
|
79
|
+
...(source.enum === undefined ? {} : { enum: source.enum }),
|
|
80
|
+
...(source.const === undefined ? {} : { const: source.const }),
|
|
81
|
+
...(source.default === undefined ? {} : { default: source.default }),
|
|
82
|
+
...(source.minimum === undefined ? {} : { minimum: source.minimum }),
|
|
83
|
+
...(source.maximum === undefined ? {} : { maximum: source.maximum }),
|
|
84
|
+
...(source.minLength === undefined ? {} : { minLength: source.minLength }),
|
|
85
|
+
...(source.maxLength === undefined ? {} : { maxLength: source.maxLength }),
|
|
86
|
+
...(source.pattern === undefined ? {} : { pattern: source.pattern }),
|
|
87
|
+
...(source['x-ultimate-pattern-flags'] === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { 'x-ultimate-pattern-flags': source['x-ultimate-pattern-flags'] }),
|
|
90
|
+
...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(narrow) }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shape(source: JsonSchema): WireJsonSchema {
|
|
95
|
+
return {
|
|
96
|
+
...(source.type === undefined ? {} : { type: source.type }),
|
|
97
|
+
...(source.title === undefined ? {} : { title: source.title }),
|
|
98
|
+
...(source.description === undefined ? {} : { description: source.description }),
|
|
99
|
+
...(source.properties === undefined ? {} : { properties: mapKeys(source.properties, shape) }),
|
|
100
|
+
...(source.required === undefined ? {} : { required: source.required }),
|
|
101
|
+
...(source.items === undefined ? {} : { items: shape(source.items) }),
|
|
102
|
+
...(source.enum === undefined ? {} : { enum: source.enum }),
|
|
103
|
+
...(source.const === undefined ? {} : { const: source.const }),
|
|
104
|
+
...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(shape) }),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `out[key] = …` is not an assignment for exactly one name: `__proto__` runs `Object.prototype`'s
|
|
110
|
+
* setter and RE-PROTOTYPES the record, so a declared field vanishes from `tools/list`.
|
|
111
|
+
*/
|
|
112
|
+
function mapKeys(
|
|
113
|
+
properties: Readonly<Record<string, JsonSchema>>,
|
|
114
|
+
each: (child: JsonSchema) => WireJsonSchema,
|
|
115
|
+
): Readonly<Record<string, WireJsonSchema>> {
|
|
116
|
+
const out: Record<string, WireJsonSchema> = {};
|
|
117
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
118
|
+
Object.defineProperty(out, key, {
|
|
119
|
+
value: each(child),
|
|
120
|
+
writable: true,
|
|
121
|
+
enumerable: true,
|
|
122
|
+
configurable: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|