diff --git a/apps/e2e/demo-e2e-direct/e2e/tool-output-schema-modes.e2e.spec.ts b/apps/e2e/demo-e2e-direct/e2e/tool-output-schema-modes.e2e.spec.ts
index 1e69744ac..def70bf9e 100644
--- a/apps/e2e/demo-e2e-direct/e2e/tool-output-schema-modes.e2e.spec.ts
+++ b/apps/e2e/demo-e2e-direct/e2e/tool-output-schema-modes.e2e.spec.ts
@@ -13,8 +13,24 @@
* - 'none': expose it nowhere.
*
* Driven through the real `tools/list` flow via `server.listTools()`.
+ *
+ * `output.undeclaredKeys` decides what `tools/call` does with keys an answer carries that its
+ * `outputSchema` does not declare: 'strip', 'warn' (strip and log once), 'keep' or 'fail'.
+ * A call that renders the tool's UI sends declared keys only, whatever the policy.
*/
-import { App, FrontMcpInstance, Tool, ToolContext, z, type DirectMcpServer } from '@frontmcp/sdk';
+import {
+ App,
+ connect,
+ FrontMcpInstance,
+ LogLevel,
+ LogTransport,
+ LogTransportInterface,
+ Tool,
+ ToolContext,
+ z,
+ type DirectMcpServer,
+ type LogRecord,
+} from '@frontmcp/sdk';
// A structured (top-level object) output so the schema is eligible for both
// outputSchema advertisement and the description rendering.
@@ -204,3 +220,147 @@ describe('tools/list output.schemaMode (E2E)', () => {
});
});
});
+
+const UNDECLARED_VALUE = 'QCC-4410';
+const quoteAnswer = { price: 10, quoteCostCenter: UNDECLARED_VALUE } as { price: number };
+const quoteOutput = { price: z.number() };
+
+@Tool({ name: 'quote_default', inputSchema: {}, outputSchema: quoteOutput })
+class DefaultQuoteTool extends ToolContext {
+ async execute() {
+ return quoteAnswer;
+ }
+}
+
+@Tool({ name: 'quote_warned', inputSchema: {}, outputSchema: quoteOutput, output: { undeclaredKeys: 'warn' } })
+class WarnedQuoteTool extends ToolContext {
+ async execute() {
+ return quoteAnswer;
+ }
+}
+
+@Tool({ name: 'quote_kept', inputSchema: {}, outputSchema: quoteOutput, output: { undeclaredKeys: 'keep' } })
+class KeptQuoteTool extends ToolContext {
+ async execute() {
+ return quoteAnswer;
+ }
+}
+
+@Tool({ name: 'quote_strict', inputSchema: {}, outputSchema: quoteOutput, output: { undeclaredKeys: 'fail' } })
+class StrictQuoteTool extends ToolContext {
+ async execute() {
+ return quoteAnswer;
+ }
+}
+
+@Tool({
+ name: 'quote_view',
+ inputSchema: {},
+ outputSchema: quoteOutput,
+ output: { undeclaredKeys: 'keep' },
+ ui: { template: (ctx) => `
${ctx.helpers.escapeHtml(JSON.stringify(ctx.output))}` },
+})
+class QuoteViewTool extends ToolContext {
+ async execute() {
+ return quoteAnswer;
+ }
+}
+
+@App({
+ name: 'UndeclaredKeys',
+ description: 'Tools exercising output.undeclaredKeys',
+ tools: [DefaultQuoteTool, WarnedQuoteTool, KeptQuoteTool, StrictQuoteTool, QuoteViewTool],
+})
+class UndeclaredKeysApp {}
+
+const policyWarnings: string[] = [];
+
+@LogTransport({ name: 'UndeclaredKeysE2eCapture', description: 'Keeps warnings for assertions' })
+class PolicyWarningCapture extends LogTransportInterface {
+ log(record: LogRecord): void {
+ if (record.level === LogLevel.Warn) policyWarnings.push(record.message);
+ }
+}
+
+const undeclaredKeysServerConfig = {
+ info: { name: 'undeclared-keys-e2e', version: '0.0.0' },
+ apps: [UndeclaredKeysApp],
+ auth: { mode: 'public' as const },
+ // A client named quote-viewer is treated as Claude, which renders the UI without structuredContent.
+ transport: { platformDetection: { mappings: [{ pattern: 'quote-viewer', platform: 'claude' as const }] } },
+ logging: { level: LogLevel.Warn, enableConsole: false, transports: [PolicyWarningCapture] },
+};
+
+describe('tools/call output.undeclaredKeys (E2E)', () => {
+ let server: DirectMcpServer;
+
+ beforeAll(async () => {
+ server = await FrontMcpInstance.createDirect(undeclaredKeysServerConfig);
+ });
+
+ afterAll(async () => {
+ await server.dispose();
+ });
+
+ it("strips undeclared keys silently by default outside development ('strip')", async () => {
+ const result = await server.callTool('quote_default', {});
+
+ expect(result.structuredContent).toEqual({ price: 10 });
+ expect(JSON.stringify(result)).not.toContain(UNDECLARED_VALUE);
+ expect(policyWarnings.filter((line) => line.includes('quote_default'))).toEqual([]);
+ });
+
+ it("'warn' strips undeclared keys and warns once for the tool and key set", async () => {
+ const first = await server.callTool('quote_warned', {});
+ await server.callTool('quote_warned', {});
+
+ expect(first.structuredContent).toEqual({ price: 10 });
+ const warnings = policyWarnings.filter((line) => line.includes('quote_warned'));
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toContain('quoteCostCenter');
+ });
+
+ it("'keep' passes undeclared keys through, and an MCP client accepts them against the listed schema", async () => {
+ const client = await connect(undeclaredKeysServerConfig, {
+ clientInfo: { name: 'policy-probe', version: '1.0.0' },
+ });
+ try {
+ await client.listTools();
+ const result = (await client.callTool('quote_kept', {})) as {
+ structuredContent?: unknown;
+ content?: Array<{ text?: string }>;
+ };
+
+ expect(result.structuredContent).toEqual({ price: 10, quoteCostCenter: UNDECLARED_VALUE });
+ expect(result.content?.[0]?.text).toContain(UNDECLARED_VALUE);
+ } finally {
+ await client.close();
+ }
+ });
+
+ it("'fail' refuses the answer with INVALID_OUTPUT", async () => {
+ await expect(server.callTool('quote_strict', {})).rejects.toMatchObject({ code: 'INVALID_OUTPUT' });
+ });
+
+ it('a call that renders the UI sends declared keys only, even under keep', async () => {
+ const result = await server.callTool('quote_view', {});
+
+ expect(result._meta?.['ui/html']).toBeDefined();
+ expect(result.structuredContent).toEqual({ price: 10 });
+ expect(JSON.stringify(result)).not.toContain(UNDECLARED_VALUE);
+ });
+
+ it('a UI render for a client that takes no structuredContent sends declared keys only, even under keep', async () => {
+ const client = await connect(undeclaredKeysServerConfig, {
+ clientInfo: { name: 'quote-viewer', version: '1.0.0' },
+ });
+ try {
+ const result = await client.callTool('quote_view', {});
+
+ expect(JSON.stringify(result)).toContain('ui/html');
+ expect(JSON.stringify(result)).not.toContain(UNDECLARED_VALUE);
+ } finally {
+ await client.close();
+ }
+ });
+});
diff --git a/apps/e2e/demo-e2e-direct/e2e/tools-list-output-schema.e2e.spec.ts b/apps/e2e/demo-e2e-direct/e2e/tools-list-output-schema.e2e.spec.ts
index 9f5b095d0..804cde3c5 100644
--- a/apps/e2e/demo-e2e-direct/e2e/tools-list-output-schema.e2e.spec.ts
+++ b/apps/e2e/demo-e2e-direct/e2e/tools-list-output-schema.e2e.spec.ts
@@ -9,8 +9,9 @@
* Verifies the SDK now converts structured object outputs (symmetric with
* inputSchema), while primitive outputs correctly remain unadvertised (they flow
* through `content`, and MCP requires `outputSchema` to be a top-level object).
+ * A union whose members are all objects is listed too, typed `object` at the top.
*/
-import { App, FrontMcpInstance, Tool, ToolContext, z, type DirectMcpServer } from '@frontmcp/sdk';
+import { App, connect, FrontMcpInstance, Tool, ToolContext, z, type DirectMcpServer } from '@frontmcp/sdk';
const weatherOutput = { city: z.string(), tempF: z.number() };
@@ -50,24 +51,83 @@ class CountPrimitiveTool extends ToolContext {
}
}
+const documentHit = z.object({ kind: z.literal('document'), title: z.string() });
+const personHit = z.object({ kind: z.literal('person'), name: z.string() });
+
+@Tool({
+ name: 'search_union',
+ description: 'Tool with a z.union() of objects outputSchema',
+ inputSchema: { query: z.string() },
+ outputSchema: z.union([documentHit, personHit]),
+})
+class SearchUnionTool extends ToolContext {
+ async execute(input: { query: string }) {
+ return { kind: 'document' as const, title: input.query };
+ }
+}
+
+@Tool({
+ name: 'resolve_discriminated',
+ description: 'Tool with a z.discriminatedUnion() outputSchema',
+ inputSchema: { handle: z.string() },
+ outputSchema: z.discriminatedUnion('kind', [documentHit, personHit]),
+})
+class ResolveDiscriminatedTool extends ToolContext {
+ async execute(input: { handle: string }) {
+ return { kind: 'person' as const, name: input.handle };
+ }
+}
+
+// Schemas registered with an id serialize to `$ref`s into `$defs`
+const registeredHit = z.union([
+ documentHit.meta({ id: 'ToolsListE2eDocumentHit' }),
+ personHit.meta({ id: 'ToolsListE2ePersonHit' }),
+]);
+
+@Tool({
+ name: 'lookup_registered',
+ description: 'Tool with a z.union() of objects registered with an id',
+ inputSchema: { name: z.string() },
+ outputSchema: registeredHit,
+})
+class LookupRegisteredTool extends ToolContext {
+ async execute(input: { name: string }) {
+ return { kind: 'person' as const, name: input.name };
+ }
+}
+
@App({
name: 'OutputSchema',
description: 'Tools exercising outputSchema advertisement',
- tools: [WeatherStructTool, ZodObjectOutTool, CountPrimitiveTool],
+ tools: [
+ WeatherStructTool,
+ ZodObjectOutTool,
+ CountPrimitiveTool,
+ SearchUnionTool,
+ ResolveDiscriminatedTool,
+ LookupRegisteredTool,
+ ],
})
class OutputSchemaApp {}
-type JsonSchemaLike = { type?: string; properties?: Record };
+const outputSchemaServerConfig = {
+ info: { name: 'output-schema-e2e', version: '0.0.0' },
+ apps: [OutputSchemaApp],
+ auth: { mode: 'public' as const },
+};
+
+type JsonSchemaLike = {
+ type?: string;
+ properties?: Record;
+ anyOf?: JsonSchemaLike[];
+ oneOf?: JsonSchemaLike[];
+};
describe('tools/list outputSchema advertisement (E2E)', () => {
let server: DirectMcpServer;
beforeAll(async () => {
- server = await FrontMcpInstance.createDirect({
- info: { name: 'output-schema-e2e', version: '0.0.0' },
- apps: [OutputSchemaApp],
- auth: { mode: 'public' as const },
- });
+ server = await FrontMcpInstance.createDirect(outputSchemaServerConfig);
});
afterAll(async () => {
@@ -98,4 +158,73 @@ describe('tools/list outputSchema advertisement (E2E)', () => {
expect(tool).toBeDefined();
expect(tool?.outputSchema).toBeUndefined();
});
+
+ it('advertises a z.union() of objects with a top-level object type and anyOf', async () => {
+ const { tools } = await server.listTools();
+ const out = tools.find((t) => t.name === 'search_union')?.outputSchema as JsonSchemaLike | undefined;
+
+ expect(out?.type).toBe('object');
+ expect(out?.anyOf?.map((member) => member.type)).toEqual(['object', 'object']);
+ expect(out?.anyOf?.[0]?.properties?.['title']).toBeDefined();
+ });
+
+ it('advertises a z.discriminatedUnion() with a top-level object type and oneOf', async () => {
+ const { tools } = await server.listTools();
+ const out = tools.find((t) => t.name === 'resolve_discriminated')?.outputSchema as JsonSchemaLike | undefined;
+
+ expect(out?.type).toBe('object');
+ expect(out?.oneOf?.map((member) => member.type)).toEqual(['object', 'object']);
+ });
+
+ it('answers a union tool with structuredContent an MCP client validates against the listed schema', async () => {
+ const client = await connect(outputSchemaServerConfig, { clientInfo: { name: 'schema-probe', version: '1.0.0' } });
+ try {
+ await client.listTools();
+ const result = (await client.callTool('resolve_discriminated', { handle: 'Ada' })) as {
+ structuredContent?: unknown;
+ };
+
+ expect(result.structuredContent).toEqual({ kind: 'person', name: 'Ada' });
+ } finally {
+ await client.close();
+ }
+ });
+
+ it('advertises a union of objects registered with an id, and an MCP client validates its answer', async () => {
+ const { tools } = await server.listTools();
+ const out = tools.find((t) => t.name === 'lookup_registered')?.outputSchema as
+ | (JsonSchemaLike & { $defs?: Record })
+ | undefined;
+ expect(out?.type).toBe('object');
+ expect(Object.keys(out?.$defs ?? {})).toEqual(['ToolsListE2eDocumentHit', 'ToolsListE2ePersonHit']);
+
+ const client = await connect(outputSchemaServerConfig, { clientInfo: { name: 'schema-probe', version: '1.0.0' } });
+ try {
+ await client.listTools();
+ const result = (await client.callTool('lookup_registered', { name: 'Ada' })) as { structuredContent?: unknown };
+
+ expect(result.structuredContent).toEqual({ kind: 'person', name: 'Ada' });
+ } finally {
+ await client.close();
+ }
+ });
+
+ it('lists a union registered with an id that an MCP client compiles when elicitation wraps it', async () => {
+ const client = await connect(
+ { ...outputSchemaServerConfig, elicitation: { enabled: true } },
+ { clientInfo: { name: 'schema-probe', version: '1.0.0' } },
+ );
+ try {
+ const tools = (await client.listTools()) as Array<{ name: string; outputSchema?: unknown }>;
+ expect(tools.find((t) => t.name === 'lookup_registered')?.outputSchema).toMatchObject({
+ type: 'object',
+ $defs: { ToolsListE2eDocumentHit: { type: 'object' } },
+ });
+
+ const result = (await client.callTool('lookup_registered', { name: 'Ada' })) as { structuredContent?: unknown };
+ expect(result.structuredContent).toEqual({ kind: 'person', name: 'Ada' });
+ } finally {
+ await client.close();
+ }
+ });
});
diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
index e5bbbe45e..ecdf0a0f3 100644
--- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
+++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
@@ -30,6 +30,7 @@ const fixtureFiles = [
'hooks-must-compile.ts',
'host-must-compile.ts',
'skills-must-compile.ts',
+ 'output-schemas-must-compile.ts',
].map((fileName) => path.join(fixtureDir, fileName));
function compileFixture(): { diagnostics: FixtureDiagnostic[]; sourceFiles: string[] } {
@@ -102,6 +103,10 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => {
expect(describeEach(inFixtureFile('skills-must-compile.ts'))).toEqual([]);
});
+ it('types a z.union() or z.discriminatedUnion() outputSchema with no cast, and output.undeclaredKeys', () => {
+ expect(describeEach(inFixtureFile('output-schemas-must-compile.ts'))).toEqual([]);
+ });
+
it('ships declaration files that type-check with skipLibCheck disabled', () => {
const sdkDeclarationDiagnostics = diagnostics.filter((diagnostic) =>
diagnostic.fileName.startsWith(sdkDeclarationsDir + path.sep),
diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/output-schemas-must-compile.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/output-schemas-must-compile.ts
new file mode 100644
index 000000000..3d30388f5
--- /dev/null
+++ b/apps/e2e/demo-e2e-dts-consumer/fixture/output-schemas-must-compile.ts
@@ -0,0 +1,38 @@
+import 'reflect-metadata';
+
+import { App, FrontMcp, Tool, ToolContext, z } from '@frontmcp/sdk';
+
+const searchHit = z.union([
+ z.object({ kind: z.literal('document'), title: z.string() }),
+ z.object({ kind: z.literal('person'), name: z.string() }),
+]);
+
+@Tool({ name: 'search', inputSchema: { query: z.string() }, outputSchema: searchHit })
+export class SearchTool extends ToolContext {
+ async execute(input: { query: string }) {
+ return { kind: 'document' as const, title: input.query };
+ }
+}
+
+const principal = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('user'), id: z.string() }),
+ z.object({ kind: z.literal('group'), members: z.array(z.string()) }),
+]);
+
+@Tool({
+ name: 'resolve_principal',
+ inputSchema: {},
+ outputSchema: principal,
+ output: { undeclaredKeys: 'fail' },
+})
+export class ResolvePrincipalTool extends ToolContext {
+ async execute() {
+ return { kind: 'group' as const, members: ['u_1'] };
+ }
+}
+
+@App({ name: 'Directory', tools: [SearchTool, ResolvePrincipalTool], output: { undeclaredKeys: 'warn' } })
+export class DirectoryApp {}
+
+@FrontMcp({ info: { name: 'directory', version: '1.0.0' }, apps: [DirectoryApp], output: { undeclaredKeys: 'keep' } })
+export class DirectoryServer {}
diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/tool-must-fail.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/tool-must-fail.ts
index f847e524d..d4d3dc25c 100644
--- a/apps/e2e/demo-e2e-dts-consumer/fixture/tool-must-fail.ts
+++ b/apps/e2e/demo-e2e-dts-consumer/fixture/tool-must-fail.ts
@@ -1,6 +1,6 @@
import 'reflect-metadata';
-import { Agent, Tool, ToolContext, z } from '@frontmcp/sdk';
+import { Tool, ToolContext, z, type Agent } from '@frontmcp/sdk';
type IsAny = 0 extends 1 & T ? true : false;
@@ -33,3 +33,11 @@ export class Total extends ToolContext {
return { total: '12.50 EUR' };
}
}
+
+// @ts-expect-error output.undeclaredKeys accepts 'strip' | 'warn' | 'keep' | 'fail' only
+@Tool({ name: 'drop_extra', inputSchema: {}, output: { undeclaredKeys: 'drop' } })
+export class DropExtra extends ToolContext {
+ async execute() {
+ return 1;
+ }
+}
diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
index 282aa3e60..023df07ba 100644
--- a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
+++ b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
@@ -15,6 +15,7 @@
"prompts-must-compile.ts",
"hooks-must-compile.ts",
"host-must-compile.ts",
- "skills-must-compile.ts"
+ "skills-must-compile.ts",
+ "output-schemas-must-compile.ts"
]
}
diff --git a/libs/lazy-zod/README.md b/libs/lazy-zod/README.md
index c1cc9cecd..ed8fc1f33 100644
--- a/libs/lazy-zod/README.md
+++ b/libs/lazy-zod/README.md
@@ -46,6 +46,7 @@ A stand-in behaves like the schema it stands for: it parses the same, `instanceo
| `lazyZ(build)` | A schema that calls `build()` the first time it's used, and only then. |
| `isLazy(schema)` | `true` for a stand-in, from `z` or from `lazyZ()`. |
| `forceMaterialize(schema)` | The real Zod schema, with every stand-in inside it built. |
+| `hasTransform(schema)` | `true` when the schema runs a transform anywhere in its tree (`.transform()`, `z.preprocess()`, `.pipe()`, a codec), so what it parses to can differ in shape from its input. |
| `toJSONSchema(schema)` | Zod's JSON Schema converter, run on `forceMaterialize(schema)`. Use it instead of Zod's own `z.toJSONSchema()`, which throws `TypeError: Cannot set properties of undefined (setting 'ref')` when an object or union in the schema is `.optional()` or has a `.default()`. |
| `ZodObject`, `ZodError`, … | Zod's classes and types (`ZodTypeAny`, `infer`, `input`, `output`), so your code needs no import from `zod`. |
diff --git a/libs/lazy-zod/src/__tests__/has-transform.spec.ts b/libs/lazy-zod/src/__tests__/has-transform.spec.ts
new file mode 100644
index 000000000..244f08d05
--- /dev/null
+++ b/libs/lazy-zod/src/__tests__/has-transform.spec.ts
@@ -0,0 +1,43 @@
+import { z as realZ } from 'zod';
+
+import { z } from '../lazy-z';
+import { hasTransform } from '../utils';
+
+describe('hasTransform', () => {
+ it.each([
+ ['a .transform()', z.object({ a: z.string() }).transform((value) => ({ renamed: value.a }))],
+ ['a z.preprocess()', z.preprocess((value) => value, z.object({ a: z.string() }))],
+ ['a .pipe()', z.string().pipe(z.coerce.number())],
+ ['a codec', z.codec(z.string(), z.number(), { decode: Number, encode: String })],
+ ['a transform on an object field', z.object({ at: z.string().transform((value) => new Date(value)) })],
+ ['a transform in an array element', z.array(z.object({ a: z.string() }).transform((value) => value.a))],
+ ['a transform in a union option', z.union([z.object({ a: z.string() }), z.string().transform(Number)])],
+ ['a transform under optional and nullable', z.object({ a: z.string().transform(Number).optional().nullable() })],
+ ['a transform in a record value', z.record(z.string(), z.string().transform(Number))],
+ ['a transform behind z.lazy()', z.object({ next: z.lazy(() => z.string().transform(Number)) })],
+ ['a real zod transform', realZ.object({ a: realZ.string() }).transform((value) => value.a)],
+ ])('is true for %s', (_label, schema) => {
+ expect(hasTransform(schema)).toBe(true);
+ });
+
+ it.each([
+ ['an object', z.object({ a: z.string(), b: z.array(z.number()) })],
+ [
+ 'a discriminated union',
+ z.discriminatedUnion('k', [z.object({ k: z.literal('a') }), z.object({ k: z.literal('b') })]),
+ ],
+ ['refinements and defaults', z.object({ a: z.string().refine(Boolean).default('x') })],
+ ['an intersection and a tuple', z.intersection(z.object({ a: z.string() }), z.tuple([z.number()]))],
+ ['a real zod object', realZ.object({ a: realZ.string() })],
+ ['something that is not a schema', { a: 'b' }],
+ ])('is false for %s', (_label, schema) => {
+ expect(hasTransform(schema)).toBe(false);
+ });
+
+ it('terminates on a recursive schema', () => {
+ type Node = { children: Node[] };
+ const node: z.ZodType = z.object({ children: z.array(z.lazy(() => node)) });
+
+ expect(hasTransform(node)).toBe(false);
+ });
+});
diff --git a/libs/lazy-zod/src/index.ts b/libs/lazy-zod/src/index.ts
index 3c56ae162..a306e63af 100644
--- a/libs/lazy-zod/src/index.ts
+++ b/libs/lazy-zod/src/index.ts
@@ -21,7 +21,7 @@ export { z } from './lazy-z';
export { z as default } from './lazy-z';
export { eagerZ } from './eager-z';
export { lazyZ, LazyZodSchema, LAZY_BRAND, LAZY_TARGET, type InferLazy } from './lazy-schema';
-export { isLazy, forceMaterialize } from './utils';
+export { isLazy, forceMaterialize, hasTransform } from './utils';
// JSON-Schema conversion. Kept here so consumer code never reaches past the
// `@frontmcp/lazy-zod` boundary into `zod/v4` directly — one place to swap the
diff --git a/libs/lazy-zod/src/utils.ts b/libs/lazy-zod/src/utils.ts
index 269f1e8de..f14d3d091 100644
--- a/libs/lazy-zod/src/utils.ts
+++ b/libs/lazy-zod/src/utils.ts
@@ -11,6 +11,32 @@ export function isLazy(value: unknown): boolean {
return (value as Record)[LAZY_BRAND] === true;
}
+/**
+ * True when a schema runs a transform anywhere in its tree: `.transform()`, `z.preprocess()`,
+ * `.pipe()` and codecs all parse through a pipe, so what the schema returns can differ in shape
+ * from what it was given.
+ */
+export function hasTransform(schema: unknown): boolean {
+ return containsPipe(schema, new WeakSet