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
5 changes: 5 additions & 0 deletions .changeset/product-schema-object-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@adcp/sdk": patch
---

Restore ZodObject helper access on ProductSchema and marker-backed canonical format schemas by collapsing marker-only intersections during schema generation.
58 changes: 58 additions & 0 deletions scripts/check-adopter-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,70 @@ const ADOPTER_SOURCE = `
// orthogonal to this guard's scope. Widen when those are addressed.
import type { AdcpServer } from '@adcp/sdk/server';
import { createSingleAgentClient, extractAdcpErrorFromMcp, extractAdcpErrorFromTransport } from '@adcp/sdk';
import type { AccountReference } from '@adcp/sdk';
import { customToolFor, TOOL_INPUT_SHAPES, TOOL_REQUEST_SCHEMAS } from '@adcp/sdk/schemas';

declare const _server: AdcpServer;
void _server;
void createSingleAgentClient;
void extractAdcpErrorFromMcp;
void extractAdcpErrorFromTransport;

void TOOL_REQUEST_SCHEMAS.get_products.shape.brief;
void TOOL_REQUEST_SCHEMAS.create_media_buy.shape.account;
// @ts-expect-error known tool request schemas should reject bogus fields
void TOOL_REQUEST_SCHEMAS.create_media_buy.shape.not_a_real_field;
void TOOL_REQUEST_SCHEMAS.preview_creative.shape.request_type;
const previewRequestType: 'single' | 'batch' | 'variant' =
TOOL_REQUEST_SCHEMAS.preview_creative.shape.request_type.parse('single');
void previewRequestType;
// @ts-expect-error TS7056 object annotations should keep known request fields exact
void TOOL_REQUEST_SCHEMAS.preview_creative.shape.not_a_real_field;
void TOOL_INPUT_SHAPES.creative_approval.rights_id;
void TOOL_INPUT_SHAPES.update_media_buy.media_buy_id;
// @ts-expect-error update_media_buy input shape should reject bogus fields
void TOOL_INPUT_SHAPES.update_media_buy.not_a_real_field;

function assertOptionalAccountReference(account: AccountReference | undefined): void {
if (account && 'account_id' in account) {
const accountId: string = account.account_id;
void accountId;
}
}

customToolFor('creative_approval', 'Submit creative for approval', TOOL_INPUT_SHAPES.creative_approval, async args => {
const rightsId: string = args.rights_id;
void rightsId;
// @ts-expect-error unknown creative approval fields should not type-check
void args.not_a_real_field;
});

customToolFor('create_media_buy', 'Create a media buy', TOOL_INPUT_SHAPES.create_media_buy, async args => {
assertOptionalAccountReference(args.account);
});

customToolFor('update_media_buy', 'Update a media buy', TOOL_INPUT_SHAPES.update_media_buy, async args => {
const mediaBuyId: string = args.media_buy_id;
void mediaBuyId;
assertOptionalAccountReference(args.account);
// @ts-expect-error customToolFor handler args should reject bogus update fields
void args.not_a_real_field;
});

customToolFor('preview_creative', 'Preview a creative', TOOL_INPUT_SHAPES.preview_creative, async args => {
const requestType: 'single' | 'batch' | 'variant' = args.request_type;
void requestType;
});

declare const runtimeToolName: string;
void TOOL_INPUT_SHAPES[runtimeToolName];
void TOOL_REQUEST_SCHEMAS[runtimeToolName]?.shape;

// @ts-expect-error unknown tool names are not valid customToolFor shapes without narrowing
customToolFor('creative_approval', 'x', TOOL_INPUT_SHAPES.typo_tool, async args => args);

// @ts-expect-error unknown fields should not type-check
void TOOL_INPUT_SHAPES.creative_approval.not_a_real_field;
`;

function run(cmd: string, args: string[], cwd: string, env?: NodeJS.ProcessEnv): void {
Expand Down
194 changes: 185 additions & 9 deletions scripts/generate-zod-from-ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ const TS7056_SCHEMAS: Array<{ name: string; tsType?: string; objectShape?: boole
// 3.1.0-beta.2 pin flip — `.and(z.union([...]))` compound patterns push
// inferred types past TS7056's .d.ts serialization limit. Carry the TS
// type so callers' `params` keep narrowing.
{ name: 'PreviewCreativeRequestSchema', tsType: 'PreviewCreativeRequest' },
{ name: 'UpdateMediaBuyRequestSchema', tsType: 'UpdateMediaBuyRequest' },
{ name: 'PreviewCreativeRequestSchema', tsType: 'PreviewCreativeRequest', objectShape: true },
{ name: 'UpdateMediaBuyRequestSchema', tsType: 'UpdateMediaBuyRequest', objectShape: true },
{ name: 'UpdateMediaBuyResponseSchema', tsType: 'UpdateMediaBuyResponse' },
{ name: 'BuildCreativeResponseSchema', tsType: 'BuildCreativeResponse' },
{ name: 'SyncEventSourcesResponseSchema', tsType: 'SyncEventSourcesResponse' },
Expand All @@ -100,11 +100,10 @@ function postProcessTS7056Annotations(content: string): string {
);
}
// Object-shaped schemas (pure `z.object({...}).passthrough()`) are
// annotated `z.ZodObject<any>` so call sites that constrain to
// ZodObject (e.g. `withOptionalAccount<T extends z.ZodObject<any>>`)
// keep working. The `any` shape parameter erases inner-field inference
// — that's the trade-off TS7056 forces on us, and it's what these call
// sites already accept for the in-bound `z.ZodObject<any>` constraint.
// annotated as ZodObjects so call sites that use `.shape`, `.extend()`,
// `.pick()`, or `.omit()` keep working. When a TS type is available,
// keep the shape keys and field value types tied to that type so
// downstream helpers like customToolFor() still infer typed args.
//
// Intersection-shaped schemas (`z.object().passthrough().and(z.union(...))`)
// use the 2-type-param `z.ZodType<Output, Input>` form. `z.input<typeof X>`
Expand All @@ -113,7 +112,14 @@ function postProcessTS7056Annotations(content: string): string {
// keep their narrowing.
let annotation: string;
if (objectShape) {
annotation = 'z.ZodObject<any>';
if (tsType) {
const widened = `${tsType} & Record<string, unknown>`;
const objectShapeType = `{ [K in keyof ${tsType}]-?: z.ZodType<${tsType}[K], ${tsType}[K]> }`;
annotation = `z.ZodObject<${objectShapeType}, any> & z.ZodType<${widened}, ${widened}>`;
typesToImport.push(tsType);
} else {
annotation = 'z.ZodObject<any>';
}
} else if (tsType) {
const widened = `${tsType} & Record<string, unknown>`;
annotation = `z.ZodType<${widened}, ${widened}>`;
Expand Down Expand Up @@ -798,6 +804,164 @@ function schemaShapeForExpression(
return undefined;
}

function splitTopLevelList(body: string): string[] | undefined {
const parts: string[] = [];
let depth = 0;
let partStart = 0;

for (let i = 0; i < body.length; i++) {
const ch = body[i];
const literalEnd = skipQuotedOrRegexLiteral(body, i);
if (literalEnd !== undefined) {
i = literalEnd - 1;
continue;
}

if (ch === '(' || ch === '{' || ch === '[') depth++;
else if (ch === ')' || ch === '}' || ch === ']') depth--;
else if (ch === ',' && depth === 0) {
const part = body.slice(partStart, i).trim();
if (!part) return undefined;
parts.push(part);
partStart = i + 1;
}
}

const last = body.slice(partStart).trim();
if (!last) return undefined;
parts.push(last);
return parts;
}

function unionArmsForExpression(expression: string): string[] | undefined {
const trimmed = expression.trim();
if (!trimmed.startsWith('z.union(')) return undefined;

const call = scanBalanced(trimmed, 'z.union'.length);
if (!call || trimmed.slice(call.end).trim()) return undefined;

const arg = call.body.trim();
if (!arg.startsWith('[')) return undefined;

const array = scanBalanced(arg, 0, '[', ']');
if (!array || arg.slice(array.end).trim()) return undefined;

return splitTopLevelList(array.body);
}

function isOpaqueRecordMarkerExpression(
expression: string,
schemaExpressions: Map<string, string>,
cache: Map<string, boolean>,
visiting = new Set<string>()
): boolean {
const trimmed = normalizeSchemaExpression(expression);
if (trimmed === 'z.record(z.string(), z.unknown())') return true;

const named = trimmed.match(/^(\w+Schema)$/)?.[1];
if (!named) return false;
if (cache.has(named)) return cache.get(named) ?? false;
if (visiting.has(named)) return false;

const namedExpression = schemaExpressions.get(named);
if (!namedExpression) return false;

visiting.add(named);
const result = isOpaqueRecordMarkerExpression(namedExpression, schemaExpressions, cache, visiting);
visiting.delete(named);
cache.set(named, result);
return result;
}

function isOpaqueMarkerUnion(
expression: string,
schemaExpressions: Map<string, string>,
markerCache: Map<string, boolean>,
visiting = new Set<string>()
): boolean {
const trimmed = normalizeSchemaExpression(expression);
const named = trimmed.match(/^(\w+Schema)$/)?.[1];
if (named) {
if (visiting.has(named)) return false;
const namedExpression = schemaExpressions.get(named);
if (!namedExpression) return false;

visiting.add(named);
const result = isOpaqueMarkerUnion(namedExpression, schemaExpressions, markerCache, visiting);
visiting.delete(named);
return result;
}

const arms = unionArmsForExpression(trimmed);
return (
arms !== undefined &&
arms.length > 0 &&
arms.every(arm => isOpaqueRecordMarkerExpression(arm, schemaExpressions, markerCache))
);
}

function rewriteLeadingMarkerUnionObjectAnd(
expression: string,
schemaExpressions: Map<string, string>,
shapeCache: Map<string, ObjectShape | undefined>,
markerCache: Map<string, boolean>
): string {
let depth = 0;

for (let i = 0; i < expression.length; i++) {
const ch = expression[i];
const literalEnd = skipQuotedOrRegexLiteral(expression, i);
if (literalEnd !== undefined) {
i = literalEnd - 1;
continue;
}

if (ch === '(' || ch === '{' || ch === '[') depth++;
else if (ch === ')' || ch === '}' || ch === ']') depth--;
else if (depth === 0 && expression.startsWith('.and(', i)) {
const base = expression.slice(0, i);
const arg = scanBalanced(expression, i + '.and'.length);
if (!arg) return expression;

if (isOpaqueMarkerUnion(base, schemaExpressions, markerCache)) {
const argShape = schemaShapeForExpression(arg.body, schemaExpressions, shapeCache);
if (argShape) return arg.body + expression.slice(arg.end);
}

return expression;
}
}

return expression;
}

function postProcessMarkerUnionObjectIntersections(content: string): string {
const schemaExpressions = extractSchemaExports(content);
const shapeCache = new Map<string, ObjectShape | undefined>();
const markerCache = new Map<string, boolean>();
const exportRegex = /export const (\w+Schema)(?::[^=]+)? = /g;
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;

while ((match = exportRegex.exec(content))) {
const name = match[1];
const expressionStart = exportRegex.lastIndex;
const expression = schemaExpressions.get(name);
if (!expression) continue;

const expressionEnd = expressionStart + expression.length;
const rewritten = rewriteLeadingMarkerUnionObjectAnd(expression, schemaExpressions, shapeCache, markerCache);

result += content.slice(lastIndex, expressionStart) + rewritten;
lastIndex = expressionEnd;
exportRegex.lastIndex = expressionEnd;
}

result += content.slice(lastIndex);
return result;
}

function postProcessObjectIntersections(content: string): string {
const schemaExpressions = extractSchemaExports(content);
const shapeCache = new Map<string, ObjectShape | undefined>();
Expand Down Expand Up @@ -1039,6 +1203,14 @@ async function generateZodSchemas() {
// Zod strips those fields, causing data loss for consumers who need them.
zodSchemas = postProcessForPassthrough(zodSchemas);

// Post-process: Collapse marker-only union/object intersections.
// ProductSchema currently intersects opaque V1/V2 marker records with its real object shape.
// While those marker schemas are just z.record(z.string(), z.unknown()), the union adds no
// validation beyond passthrough object semantics and only removes .extend/.omit/.pick helpers.
// When the marker schemas gain real fields, this pass stops firing and preserves the richer
// intersection for maintainers to handle deliberately.
zodSchemas = postProcessMarkerUnionObjectIntersections(zodSchemas);

// Post-process: Turn safe object/object intersections into ZodObject merges.
// ts-to-zod emits `.and()` for TypeScript object intersections, but ZodIntersection
// does not expose object helpers like .shape/.extend/.omit/.pick. This keeps the
Expand Down Expand Up @@ -1104,6 +1276,10 @@ if (require.main === module) {
});
}

export const __test__ = { postProcessForNullish, postProcessObjectIntersections };
export const __test__ = {
postProcessForNullish,
postProcessMarkerUnionObjectIntersections,
postProcessObjectIntersections,
};

export { generateZodSchemas };
19 changes: 14 additions & 5 deletions src/lib/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,24 @@
import type { z } from 'zod';
import * as schemas from '../types/schemas.generated';
import { TOOL_REQUEST_SCHEMAS } from '../utils/tool-request-schemas';
import type { KnownToolRequestSchemas } from '../utils/tool-request-schemas';

export * from '../types/schemas.generated';
export { TOOL_REQUEST_SCHEMAS } from '../utils/tool-request-schemas';

type InputShape = Record<string, z.ZodType>;
type ShapeOf<T> = T extends { shape: infer TShape extends InputShape } ? TShape : never;
type ToolInputShapes = {
[K in keyof KnownToolRequestSchemas]: ShapeOf<KnownToolRequestSchemas[K]>;
} & {
creative_approval: typeof schemas.CreativeApprovalRequestSchema.shape;
update_rights: typeof schemas.UpdateRightsRequestSchema.shape;
} & {
readonly [toolName: string]: Readonly<InputShape> | undefined;
};

function shapeOf(s: unknown): InputShape | undefined {
if (!s) return undefined;
const candidate = (s as { shape?: InputShape }).shape;
function shapeOf<T extends { shape?: InputShape }>(s: T | undefined): T['shape'] | undefined {
const candidate = s?.shape;
return candidate && typeof candidate === 'object' ? candidate : undefined;
}

Expand All @@ -67,7 +76,7 @@ function shapeOf(s: unknown): InputShape | undefined {
* framework-registrable) — CI's `ci:schema-check` catches missing
* map entries by diffing against the generated schemas.
*/
export const TOOL_INPUT_SHAPES: Readonly<Record<string, Readonly<InputShape>>> = Object.freeze({
export const TOOL_INPUT_SHAPES = Object.freeze({
...Object.fromEntries(
Object.entries(TOOL_REQUEST_SCHEMAS).map(([k, s]) => {
const shape = shapeOf(s);
Expand All @@ -81,7 +90,7 @@ export const TOOL_INPUT_SHAPES: Readonly<Record<string, Readonly<InputShape>>> =
),
creative_approval: schemas.CreativeApprovalRequestSchema.shape,
update_rights: schemas.UpdateRightsRequestSchema.shape,
});
}) as Readonly<ToolInputShapes>;

/**
* Register a custom tool with MCP-compatible `inputSchema` + handler
Expand Down
2 changes: 1 addition & 1 deletion src/lib/server/create-adcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3452,7 +3452,7 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA
}

const meta = TOOL_META[toolName];
const schema = TOOL_REQUEST_SCHEMAS[toolName] as { shape: Record<string, unknown> } | undefined;
const schema = (TOOL_REQUEST_SCHEMAS as Readonly<Record<string, { shape: Record<string, unknown> }>>)[toolName];
if (!schema?.shape) {
logger.warn(`No schema found for tool "${toolName}" in TOOL_REQUEST_SCHEMAS, skipping`);
continue;
Expand Down
Loading
Loading