Skip to content

Commit 21988e6

Browse files
fix(core): stamp type:'object' on object-only union output roots; SUBSCHEMA_KEYWORDS JSDoc accuracy
isProvablyObjectShapedRoot: a typeless oneOf/anyOf/allOf root whose every member is type:'object' is also stamped, so z.discriminatedUnion / z.union of objects / z.intersection of objects keep their 2025-era outputSchema advertisement (the previous unconditional stamp happened to work for these; the fix-#2 narrow heuristic regressed them). A typeless union with any non-object member is still returned as-is. 3 new output-arm unit tests. SUBSCHEMA_KEYWORDS JSDoc: 'consumed only within this module's tests' → 'has no consumers' (the test file does not import it either).
1 parent e640ed9 commit 21988e6

4 files changed

Lines changed: 62 additions & 20 deletions

File tree

docs/migration.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1447,10 +1447,10 @@ being handed a schema their codec cannot parse. When such a tool's handler retur
14471447
or primitive there is invalid 2025 wire data and a strictly-conformant 2025 client would reject the entire response (the fallback included). The text block is what reaches a legacy client; the structured value reaches a 2026 client. While you serve 2025-era clients, keep schemas
14481448
in the 2025 subset (object roots) where you can.
14491449

1450-
**Typeless-root output schemas are not stamped `type:"object"`.** A Standard-Schema value whose JSON Schema root has no `type` — for example `z.union([z.string(), z.number()])` (`{anyOf:[…]}`), `z.any()` (`{}`), or `z.object({…}).nullable()` — is advertised as-is on the 2026
1451-
era and dropped from the 2025 projection (with the same warn-once). The SDK only defaults `type:"object"` when the root carries `properties`/`patternProperties`/`additionalProperties`/`required`, because stamping it on a typeless union would produce a self-contradictory schema
1452-
(e.g., `{type:"object", anyOf:[{type:"string"}, …]}`) that rejects every value. This is the conservative-correct choice: it means a `z.discriminatedUnion(...)` `outputSchema` (typeless `{oneOf:[{type:"object",…}, …]}`) is also dropped on the 2025 projection even though every
1453-
branch is an object — the previous behavior happened to work for that case by accident. If you need a discriminated-union `outputSchema` advertised on the 2025 era, give it an explicit root `type` via `fromJsonSchema({ type: "object", oneOf: […] })`.
1450+
**Typeless-root output schemas are only stamped `type:"object"` when provably safe.** A Standard-Schema value whose JSON Schema root has no `type` — for example `z.union([z.string(), z.number()])` (`{anyOf:[…]}`), `z.any()` (`{}`), or `z.object({…}).nullable()` — is advertised
1451+
as-is on the 2026 era and dropped from the 2025 projection (with the same warn-once), because stamping `type:"object"` there would produce a self-contradictory schema that rejects every value. The SDK still defaults `type:"object"` when the root carries object keywords
1452+
(`properties`/`patternProperties`/`additionalProperties`/`required`) **or** is a `oneOf`/`anyOf`/`allOf` whose every member is `type:"object"` — so `z.discriminatedUnion(...)`, `z.union([z.object(...), …])`, and `z.intersection(...)` of objects keep their 2025-era advertisement
1453+
unchanged.
14541454

14551455
## Unchanged APIs
14561456

packages/core/src/util/standardSchema.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -205,19 +205,18 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
205205
);
206206
}
207207
if (io === 'output') {
208-
// SEP-2106: outputSchema may have any JSON Schema root type (array, string, …) — no
209-
// non-object throw. An explicit `type` (object or not) is returned as-is. A typeless
210-
// root is returned as-is when it is NOT provably object-shaped — e.g. a `z.union(...)`
211-
// emits `{anyOf:[…]}`, and stamping `type:'object'` there would be self-contradictory.
212-
// Only when the root is typeless AND object-shaped (carries `properties` /
213-
// `patternProperties` / `additionalProperties` / `required`) do we default
214-
// `type:'object'` so the 2025-era wire bytes are unchanged for the pre-SEP object
215-
// schemas. The legacy projection drop gates anything that does not end up
216-
// `type:'object'`.
208+
// SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or
209+
// not) is returned as-is. A typeless root only gets `type:'object'` defaulted when it is
210+
// PROVABLY object-shaped — either it carries object keywords at the root, or every
211+
// member of a root `oneOf`/`anyOf`/`allOf` is itself `type:'object'` (the
212+
// `z.discriminatedUnion(...)`, `z.union([z.object(...), ...])`, `z.intersection(...)`
213+
// cases). Those pre-SEP schemas were valid 2025 wire data via the unconditional stamp,
214+
// so the stamp is kept where it is provably safe. A typeless root that is NOT provably
215+
// object-shaped (e.g. `z.union([z.string(), z.number()])` → `{anyOf:[…]}`) is returned
216+
// as-is — stamping there would be self-contradictory. The legacy projection drop gates
217+
// anything that does not end up `type:'object'`.
217218
if (result.type !== undefined) return result;
218-
const isObjectShaped =
219-
'properties' in result || 'patternProperties' in result || 'additionalProperties' in result || 'required' in result;
220-
return isObjectShaped ? { type: 'object', ...result } : result;
219+
return isProvablyObjectShapedRoot(result) ? { type: 'object', ...result } : result;
221220
}
222221
if (result.type !== undefined && result.type !== 'object') {
223222
throw new Error(
@@ -228,6 +227,25 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
228227
return { type: 'object', ...result };
229228
}
230229

230+
/**
231+
* A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords
232+
* directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a
233+
* composition (`oneOf`/`anyOf`/`allOf`) whose every member is itself `type:'object'`. Used to
234+
* decide whether stamping `type:'object'` is safe (redundant-but-valid) versus self-contradictory.
235+
*/
236+
function isProvablyObjectShapedRoot(schema: Record<string, unknown>): boolean {
237+
if ('properties' in schema || 'patternProperties' in schema || 'additionalProperties' in schema || 'required' in schema) {
238+
return true;
239+
}
240+
for (const key of ['oneOf', 'anyOf', 'allOf'] as const) {
241+
const members = schema[key];
242+
if (Array.isArray(members) && members.length > 0) {
243+
return members.every(m => m !== null && typeof m === 'object' && (m as Record<string, unknown>).type === 'object');
244+
}
245+
}
246+
return false;
247+
}
248+
231249
// Validation
232250

233251
export type StandardSchemaValidationResult<T> = { success: true; data: T } | { success: false; error: string };

packages/core/src/validators/schemaBounds.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,9 @@ export const DEFAULT_MAX_SCHEMA_DEPTH = 64;
2929
export const DEFAULT_MAX_SUBSCHEMA_COUNT = 10_000;
3030

3131
/**
32-
* Vocabulary list of JSON Schema keywords whose values are themselves schemas. Currently consumed
33-
* only within this module's tests; intended as the single source for the SEP-2243 `x-mcp-header`
34-
* reachability scan in `mcpParamHeaders.ts` (which today carries its own private list —
35-
* consolidation is a follow-up).
32+
* Vocabulary list of JSON Schema keywords whose values are themselves schemas. Currently has no
33+
* consumers; intended as the single source for the SEP-2243 `x-mcp-header` reachability scan in
34+
* `mcpParamHeaders.ts` (which today carries its own private list — consolidation is a follow-up).
3635
*
3736
* NOTE: {@link assertSchemaSafeToCompile} does **not** consult this list; its walk is intentionally
3837
* exhaustive (every key) so a future or vendor keyword cannot smuggle a `$ref` past the guard.

packages/core/test/util/standardSchema.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,30 @@ describe('standardSchemaToJsonSchema', () => {
5858
const result = standardSchemaToJsonSchema(z.object({ a: z.string() }), 'output');
5959
expect(result.type).toBe('object');
6060
});
61+
62+
test('discriminatedUnion (typeless oneOf of objects) is stamped type:object', () => {
63+
// every branch is `type:'object'` so stamping is redundant-but-valid; preserves
64+
// 2025-era wire bytes for a pre-SEP object schema.
65+
const schema = z.discriminatedUnion('kind', [
66+
z.object({ kind: z.literal('a'), x: z.number() }),
67+
z.object({ kind: z.literal('b'), y: z.string() })
68+
]);
69+
const result = standardSchemaToJsonSchema(schema, 'output');
70+
expect(result.type).toBe('object');
71+
expect(result.oneOf).toBeDefined();
72+
});
73+
74+
test('union of objects (typeless anyOf of objects) is stamped type:object', () => {
75+
const schema = z.union([z.object({ a: z.number() }), z.object({ b: z.string() })]);
76+
const result = standardSchemaToJsonSchema(schema, 'output');
77+
expect(result.type).toBe('object');
78+
});
79+
80+
test('mixed union (object + primitive) is NOT stamped', () => {
81+
const schema = z.union([z.object({ a: z.number() }), z.string()]);
82+
const result = standardSchemaToJsonSchema(schema, 'output');
83+
// not every branch is type:'object' — stamping would be self-contradictory.
84+
expect(result.type).toBeUndefined();
85+
});
6186
});
6287
});

0 commit comments

Comments
 (0)