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/brand-rights-input-schemas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@adcp/sdk": patch
---

Expose generated input helpers for brand discovery and verification custom tools, including a full-schema helper for union-shaped `verify_brand_claim` requests.
32 changes: 31 additions & 1 deletion scripts/check-adopter-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import { createAdcpServer as createLegacyAdcpServer } from '@adcp/sdk/server/leg
import { createSingleAgentClient, extractAdcpErrorFromMcp, extractAdcpErrorFromTransport } from '@adcp/sdk';
import type { CreateMediaBuySuccess, ServerPayload as ServerPayloadFromTypes } from '@adcp/sdk/types';
import type { AccountReference } from '@adcp/sdk';
import { customToolFor, TOOL_INPUT_SHAPES, TOOL_REQUEST_SCHEMAS } from '@adcp/sdk/schemas';
import { customToolFor, customToolForSchema, TOOL_INPUT_SCHEMAS, TOOL_INPUT_SHAPES, TOOL_REQUEST_SCHEMAS } from '@adcp/sdk/schemas';

declare const _server: AdcpServer;
void _server;
Expand Down Expand Up @@ -161,6 +161,9 @@ 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;
void TOOL_INPUT_SHAPES.search_brands.query;
void TOOL_INPUT_SHAPES.verify_brand_claims.claims;
void TOOL_INPUT_SCHEMAS.verify_brand_claim.parse;

function assertOptionalAccountReference(account: AccountReference | undefined): void {
if (account && 'account_id' in account) {
Expand Down Expand Up @@ -193,13 +196,40 @@ customToolFor('preview_creative', 'Preview a creative', TOOL_INPUT_SHAPES.previe
void requestType;
});

customToolFor('search_brands', 'Search brands', TOOL_INPUT_SHAPES.search_brands, async args => {
const query: string = args.query;
void query;
});

customToolFor('verify_brand_claims', 'Verify brand claims', TOOL_INPUT_SHAPES.verify_brand_claims, async args => {
const firstClaim = args.claims[0];
if (firstClaim) {
const claimType: 'subsidiary' | 'parent' | 'property' | 'trademark' = firstClaim.claim_type;
void claimType;
}
});

customToolForSchema('verify_brand_claim', 'Verify a brand claim', TOOL_INPUT_SCHEMAS.verify_brand_claim, async args => {
if (args.claim_type === 'subsidiary') {
const domain: string = args.claim.subsidiary_domain;
void domain;
}
// @ts-expect-error passthrough allows extra keys as unknown, not as typed sibling-variant fields
const parentDomain: string = args.claim.parent_domain;
void parentDomain;
});

declare const runtimeToolName: string;
void TOOL_INPUT_SHAPES[runtimeToolName];
void TOOL_INPUT_SCHEMAS[runtimeToolName]?.parse;
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 verify_brand_claim is union-shaped, so callers must use customToolForSchema
customToolFor('verify_brand_claim', 'x', TOOL_INPUT_SHAPES.verify_brand_claim, async args => args);

// @ts-expect-error unknown fields should not type-check
void TOOL_INPUT_SHAPES.creative_approval.not_a_real_field;
`;
Expand Down
79 changes: 79 additions & 0 deletions scripts/generate-zod-from-ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,77 @@ function postProcessObjectIntersections(content: string): string {
}
}

function postProcessObjectUnionIntersections(content: string): string {
const schemaExpressions = extractSchemaExports(content);
const shapeCache = new Map<string, ObjectShape | undefined>();
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 = name.endsWith('RequestSchema')
? rewriteObjectUnionIntersection(expression, schemaExpressions, shapeCache)
: expression;

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

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

function rewriteObjectUnionIntersection(
expression: string,
schemaExpressions: Map<string, string>,
shapeCache: Map<string, ObjectShape | undefined>
): 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;

const trailing = expression.slice(arg.end).trim();
if (trailing) return expression;

const baseShape = schemaShapeForExpression(base, schemaExpressions, shapeCache);
const arms = unionArmsForExpression(arg.body);
if (!baseShape || !arms?.length) return expression;

const mergedArms: string[] = [];
for (const arm of arms) {
const armShape = schemaShapeForExpression(arm, schemaExpressions, shapeCache);
if (!armShape || !canSafelyMerge(baseShape, armShape)) return expression;
mergedArms.push(`${base}.merge(${arm})`);
}

return `z.union([${mergedArms.join(', ')}])`;
}
}

return expression;
}

function rewriteTopLevelObjectAnds(
expression: string,
schemaExpressions: Map<string, string>,
Expand Down Expand Up @@ -1463,6 +1534,13 @@ async function generateZodSchemas() {
// intersection for maintainers to handle deliberately.
zodSchemas = postProcessMarkerUnionObjectIntersections(zodSchemas);

// Post-process: Distribute object-envelope intersections over union object arms.
// A schema like `Envelope.and(z.union([VariantA, VariantB]))` is equivalent to
// `z.union([Envelope.merge(VariantA), Envelope.merge(VariantB)])` when the
// envelope and every arm are merge-safe ZodObjects. The distributed form
// preserves discriminated-union inference for custom tool handlers.
zodSchemas = postProcessObjectUnionIntersections(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 @@ -1531,6 +1609,7 @@ if (require.main === module) {
export const __test__ = {
postProcessForNullish,
postProcessMarkerUnionObjectIntersections,
postProcessObjectUnionIntersections,
postProcessObjectIntersections,
};

Expand Down
80 changes: 70 additions & 10 deletions src/lib/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@
*
* The generated Zod schemas in `../types/schemas.generated` cover every
* AdCP tool — the framework-registered {@link AdcpToolMap} tools AND
* tools like `creative_approval` / `update_rights` that ship as
* `customTools` extensions because the spec models them as out-of-band
* callbacks rather than MCP-registered surfaces.
* tools like `creative_approval`, `search_brands`, and brand verification
* tasks that ship as `customTools` extensions because the spec models them
* outside the SDK's framework-registered surface.
*
* This module re-exports the generated schemas plus two convenience
* helpers for the `customTools` registration path:
*
* - {@link TOOL_INPUT_SHAPES}: `toolName → inputSchema` map, ready to
* pass as `inputSchema` to MCP SDK's `server.registerTool()`. Uses
* the same tool-name keys as `AdcpServerConfig.customTools`.
* - {@link TOOL_INPUT_SHAPES}: `toolName → raw Zod shape` map, ready to
* pass as `inputSchema` to MCP SDK's `server.registerTool()` when the
* request schema is a ZodObject.
* - {@link TOOL_INPUT_SCHEMAS}: `toolName → full Zod schema` map for
* custom tools whose request schema is a union/intersection and cannot
* be represented as a raw shape without weakening validation.
* - {@link customToolFor}: sugar for registering a single custom tool
* with type-safe `handler` params derived from the schema's shape.
* - {@link customToolForSchema}: same sugar for full Zod schemas.
*
* ```ts
* import { createAdcpServer } from '@adcp/sdk/server/legacy/v5';
Expand Down Expand Up @@ -43,15 +47,29 @@ export * from '../types/schemas.generated';
export { TOOL_REQUEST_SCHEMAS } from '../utils/tool-request-schemas';

type InputShape = Record<string, z.ZodType>;
type InputSchema = 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;
search_brands: typeof schemas.SearchBrandsRequestSchema.shape;
verify_brand_claims: typeof schemas.VerifyBrandClaimsRequestBulkSchema.shape;
} & {
readonly [toolName: string]: Readonly<InputShape> | undefined;
};

type ToolInputSchemas = {
[K in keyof KnownToolRequestSchemas]: KnownToolRequestSchemas[K];
} & {
creative_approval: typeof schemas.CreativeApprovalRequestSchema;
search_brands: typeof schemas.SearchBrandsRequestSchema;
verify_brand_claim: typeof schemas.VerifyBrandClaimRequestSchema;
verify_brand_claims: typeof schemas.VerifyBrandClaimsRequestBulkSchema;
} & {
readonly [toolName: string]: InputSchema | undefined;
};

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 @@ -66,9 +84,14 @@ function shapeOf<T extends { shape?: InputShape }>(s: T | undefined): T['shape']
* registered with the framework (get_products, create_media_buy,
* sync_catalogs, check_governance, comply_test_controller, all five
* *_collection_list tools, validate_property_delivery, acquire_rights,
* et al.) PLUS the two tools the spec models as customTool-only
* extensions — `creative_approval` and `update_rights` — so sellers
* don't have to hand-author shapes for those either.
* et al.) PLUS shape-compatible custom surfaces such as `creative_approval`,
* `search_brands`, and `verify_brand_claims` so sellers don't have to
* hand-author shapes for those either.
*
* `verify_brand_claim` is intentionally not present here: its request schema
* is an envelope intersected with a claim-variant union, so use
* {@link TOOL_INPUT_SCHEMAS} with {@link customToolForSchema} to preserve the
* discriminated union at validation time.
*
* Known tool names retain exact `.shape` field types for IDE completion and
* handler inference. Arbitrary string lookups return `undefined` until callers
Expand All @@ -92,9 +115,26 @@ export const TOOL_INPUT_SHAPES = Object.freeze({
})
),
creative_approval: schemas.CreativeApprovalRequestSchema.shape,
update_rights: schemas.UpdateRightsRequestSchema.shape,
search_brands: schemas.SearchBrandsRequestSchema.shape,
verify_brand_claims: schemas.VerifyBrandClaimsRequestBulkSchema.shape,
}) as Readonly<ToolInputShapes>;

/**
* Map of known AdCP tool names to their full generated Zod request schemas.
*
* Prefer {@link TOOL_INPUT_SHAPES} when registering a shape-compatible tool
* with `registerTool()`. Use this map for union/intersection request schemas,
* notably `verify_brand_claim`, where a raw shape would lose the correlation
* between `claim_type` and the corresponding `claim` payload.
*/
export const TOOL_INPUT_SCHEMAS = Object.freeze({
...TOOL_REQUEST_SCHEMAS,
creative_approval: schemas.CreativeApprovalRequestSchema,
search_brands: schemas.SearchBrandsRequestSchema,
verify_brand_claim: schemas.VerifyBrandClaimRequestSchema,
verify_brand_claims: schemas.VerifyBrandClaimsRequestBulkSchema,
}) as Readonly<ToolInputSchemas>;

/**
* Register a custom tool with MCP-compatible `inputSchema` + handler
* wiring. Returns an object shaped for
Expand Down Expand Up @@ -124,3 +164,23 @@ export function customToolFor<TShape extends InputShape>(
void name;
return { description, inputSchema, handler };
}

/**
* Register a custom tool whose MCP `inputSchema` is a full Zod schema rather
* than a raw shape. Use this for request schemas with top-level unions or
* intersections, where `.shape` would either be unavailable or would weaken
* runtime validation.
*/
export function customToolForSchema<TSchema extends InputSchema>(
name: string,
description: string,
inputSchema: TSchema,
handler: (args: z.input<TSchema>, extra?: unknown) => unknown | Promise<unknown>
): {
description: string;
inputSchema: TSchema;
handler: (args: z.input<TSchema>, extra?: unknown) => unknown | Promise<unknown>;
} {
void name;
return { description, inputSchema, handler };
}
4 changes: 2 additions & 2 deletions src/lib/types/schemas.generated.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated Zod v4 schemas from TypeScript types
// Generated at: 2026-05-24T14:49:43.372Z
// Generated at: 2026-05-24T15:29:22.144Z
// Sources:
// - core.generated.ts (core types)
// - tools.generated.ts (tool types)
Expand Down Expand Up @@ -8131,7 +8131,7 @@ export const UpdateRightsResponseSchema = z.object({
adcp_major_version: z.number().optional()
}).passthrough().and(z.union([UpdateRightsSuccessSchema, UpdateRightsErrorSchema]));

export const VerifyBrandClaimRequestSchema = AdCPVersionEnvelopeSchema.and(z.union([VerifySubsidiaryClaimSchema, VerifyParentClaimSchema, VerifyPropertyClaimSchema, VerifyTrademarkClaimSchema]));
export const VerifyBrandClaimRequestSchema = z.union([AdCPVersionEnvelopeSchema.merge(VerifySubsidiaryClaimSchema), AdCPVersionEnvelopeSchema.merge(VerifyParentClaimSchema), AdCPVersionEnvelopeSchema.merge(VerifyPropertyClaimSchema), AdCPVersionEnvelopeSchema.merge(VerifyTrademarkClaimSchema)]);

export const VerifyBrandClaimResponseSchema = z.object({
context_id: z.string().optional(),
Expand Down
35 changes: 34 additions & 1 deletion src/type-tests/zod-object-intersections.type-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// common helpers such as .shape, .extend(), .omit(), and .pick().

import { z } from 'zod';
import { customToolFor, TOOL_INPUT_SHAPES } from '../lib/schemas';
import { customToolFor, customToolForSchema, TOOL_INPUT_SCHEMAS, TOOL_INPUT_SHAPES } from '../lib/schemas';
import type { AccountReference } from '../lib/types';
import {
CanonicalFormatImageSchema,
Expand Down Expand Up @@ -63,6 +63,10 @@ void TOOL_INPUT_SHAPES.update_media_buy.not_a_real_field;
const creativeApprovalShape = TOOL_INPUT_SHAPES.creative_approval;
void creativeApprovalShape.rights_id;

void TOOL_INPUT_SHAPES.search_brands.query;
void TOOL_INPUT_SHAPES.verify_brand_claims.claims;
void TOOL_INPUT_SCHEMAS.verify_brand_claim.parse;

function assertOptionalAccountReference(account: AccountReference | undefined): void {
if (account && 'account_id' in account) {
const accountId: string = account.account_id;
Expand Down Expand Up @@ -94,16 +98,45 @@ customToolFor('preview_creative', 'Preview a creative', TOOL_INPUT_SHAPES.previe
void requestType;
});

customToolFor('search_brands', 'Search brands', TOOL_INPUT_SHAPES.search_brands, async args => {
const query: string = args.query;
void query;
});

customToolFor('verify_brand_claims', 'Verify brand claims', TOOL_INPUT_SHAPES.verify_brand_claims, async args => {
const firstClaim = args.claims[0];
if (firstClaim) {
const claimType: 'subsidiary' | 'parent' | 'property' | 'trademark' = firstClaim.claim_type;
void claimType;
}
});

customToolForSchema('verify_brand_claim', 'Verify a brand claim', TOOL_INPUT_SCHEMAS.verify_brand_claim, async args => {
if (args.claim_type === 'subsidiary') {
const domain: string = args.claim.subsidiary_domain;
void domain;
}
// @ts-expect-error passthrough allows extra keys as unknown, not as typed sibling-variant fields
const parentDomain: string = args.claim.parent_domain;
void parentDomain;
});

declare const runtimeToolName: string;
const runtimeToolShape = TOOL_INPUT_SHAPES[runtimeToolName];
void runtimeToolShape;

const runtimeRequestSchema = TOOL_REQUEST_SCHEMAS[runtimeToolName];
void runtimeRequestSchema?.shape;

const runtimeInputSchema = TOOL_INPUT_SCHEMAS[runtimeToolName];
void runtimeInputSchema?.parse;

// @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 verify_brand_claim is union-shaped, so callers must use customToolForSchema
customToolFor('verify_brand_claim', 'x', TOOL_INPUT_SHAPES.verify_brand_claim, async args => args);

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

Expand Down
Loading
Loading