diff --git a/package-lock.json b/package-lock.json index 6cfb834..eabd6ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/sdk": "1.30.0", "@napi-rs/keyring": "2.0.0", "asana": "3.2.0", - "zod": "3.25.76" + "zod": "4.5.4" }, "bin": { "asana-command-mcp": "dist/index.js" @@ -6059,9 +6059,9 @@ "peer": true }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 5054011..2c04531 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@modelcontextprotocol/sdk": "1.30.0", "@napi-rs/keyring": "2.0.0", "asana": "3.2.0", - "zod": "3.25.76" + "zod": "4.5.4" }, "devDependencies": { "@biomejs/biome": "2.1.2", diff --git a/src/asana_contracts.ts b/src/asana_contracts.ts index 5400a55..13cbef1 100644 --- a/src/asana_contracts.ts +++ b/src/asana_contracts.ts @@ -137,10 +137,10 @@ export const NextPageSchema = z }) .nullable(); -export function singleObjectEnvelope(dataSchema: T) { - return z.object({ - data: dataSchema, - }); +export function singleObjectEnvelope( + dataSchema: T, +): z.ZodType<{ data: z.infer }> { + return z.object({ data: dataSchema }) as unknown as z.ZodType<{ data: z.infer }>; } export function collectionEnvelope(itemSchema: T) { diff --git a/src/errors.ts b/src/errors.ts index 1d02689..523d127 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -29,7 +29,7 @@ export const ErrorPayloadSchema = z.object({ message: z.string(), retryable: z.boolean(), suggested_action: z.string().optional(), - details: z.record(z.unknown()).optional(), + details: z.record(z.string(), z.unknown()).optional(), }), asana_request_ids: z.array(z.string()), }); diff --git a/src/mutation_envelope.ts b/src/mutation_envelope.ts index c17ca16..8e63852 100644 --- a/src/mutation_envelope.ts +++ b/src/mutation_envelope.ts @@ -67,11 +67,25 @@ function zodUnionFromSchemas(schemas: readonly z.ZodTypeAny[]): z.ZodTypeAny { return z.union([first, second, ...rest]); } -export function mutationVariantsToSchemas( - variants: readonly MutationVariant[], +type MutationVariantOutput = V extends MutationVariant< + infer TStatus, + infer TOutcome, + infer TDataSchema +> + ? MutationMetadata & { + status: TStatus; + outcome: TOutcome; + data: z.infer; + } + : never; + +export function mutationVariantsToSchemas< + const TVariants extends readonly MutationVariant[], +>( + variants: TVariants, ): { - runtimeSchema: z.ZodTypeAny; - protocolSchema: z.ZodTypeAny; + runtimeSchema: z.ZodType>; + protocolSchema: z.ZodType>; } { if (variants.length === 0) { throw new Error("mutationVariantsToSchemas requires at least one variant"); @@ -106,7 +120,12 @@ export function mutationVariantsToSchemas( data: zodUnionFromSchemas(dataSchemas), }); - return { runtimeSchema, protocolSchema }; + return { + runtimeSchema: runtimeSchema as z.ZodType>, + protocolSchema: protocolSchema as unknown as z.ZodType< + MutationVariantOutput + >, + }; } export function buildMutationResult< diff --git a/src/ticket_inputs.ts b/src/ticket_inputs.ts index 8f43751..923f57b 100644 --- a/src/ticket_inputs.ts +++ b/src/ticket_inputs.ts @@ -71,15 +71,15 @@ export const ListTicketFiltersSchema = z .string() .trim() .min(1) - .describe("Opaque cursor from a prior call with exactly the same filters and limit") - .optional(), - completed: z.boolean().describe("Exact ticket completion state").optional(), - type: NonEmptyNameSchema.describe("Teamspace-local ticket type name").optional(), - label: NonEmptyNameSchema.describe("Teamspace-local label name").optional(), - assignee: NonEmptyNameSchema.describe( + .optional() + .describe("Opaque cursor from a prior call with exactly the same filters and limit"), + completed: z.boolean().optional().describe("Exact ticket completion state"), + type: NonEmptyNameSchema.optional().describe("Teamspace-local ticket type name"), + label: NonEmptyNameSchema.optional().describe("Teamspace-local label name"), + assignee: NonEmptyNameSchema.optional().describe( "Assignee name, email address, or numeric Asana user GID", - ).optional(), - release: NonEmptyNameSchema.describe("Release project name or numeric GID").optional(), + ), + release: NonEmptyNameSchema.optional().describe("Release project name or numeric GID"), }) .strict(); @@ -89,10 +89,10 @@ export const SearchTicketFiltersSchema = z .string() .trim() .min(1, "Search text must not be empty") - .describe("Distinctive text to search for in ticket names and descriptions") - .optional(), + .optional() + .describe("Distinctive text to search for in ticket names and descriptions"), assignee: WorkspaceSearchAssigneeSchema.optional(), - completed: z.boolean().describe("Exact completion state").optional(), + completed: z.boolean().optional().describe("Exact completion state"), "completed_on.before": DateOnlySchema.optional(), "completed_on.after": DateOnlySchema.optional(), compact: z @@ -143,26 +143,26 @@ export const LabelUpdateSchema = z export const UpdateTicketFieldsSchema = z .object({ - name: NonEmptyNameSchema.describe("The replacement ticket name").optional(), + name: NonEmptyNameSchema.optional().describe("The replacement ticket name"), description: z .string() + .optional() .describe( "The replacement plain-text description; an empty string clears it. Markdown is not rendered; use description_html for rich formatting.", - ) - .optional(), - description_html: z.string().describe(TICKET_DESCRIPTION_HTML_DESCRIPTION).optional(), - completed: z.boolean().describe("Whether the ticket is completed").optional(), - type: NonEmptyNameSchema.describe("A Teamspace-local ticket type option name").optional(), + ), + description_html: z.string().optional().describe(TICKET_DESCRIPTION_HTML_DESCRIPTION), + completed: z.boolean().optional().describe("Whether the ticket is completed"), + type: NonEmptyNameSchema.optional().describe("A Teamspace-local ticket type option name"), labels: LabelUpdateSchema.optional(), assignee: AssigneeIdentifierSchema.nullable() - .describe("An Asana user GID or email address, or null to clear the assignee") - .optional(), + .optional() + .describe("An Asana user GID or email address, or null to clear the assignee"), predicted_start_on: DateOnlySchema.nullable() - .describe("The predicted start date in YYYY-MM-DD form, or null to clear it") - .optional(), + .optional() + .describe("The predicted start date in YYYY-MM-DD form, or null to clear it"), predicted_completion_on: DateOnlySchema.nullable() - .describe("The predicted completion date in YYYY-MM-DD form, or null to clear it") - .optional(), + .optional() + .describe("The predicted completion date in YYYY-MM-DD form, or null to clear it"), }) .strict(); @@ -171,22 +171,22 @@ export const CreateTicketFieldsSchema = z name: NonEmptyNameSchema.describe("The ticket name"), description: z .string() + .optional() .describe( "The initial plain-text description. Markdown is not rendered; use description_html for rich formatting.", - ) - .optional(), - description_html: z.string().describe(TICKET_DESCRIPTION_HTML_DESCRIPTION).optional(), - type: NonEmptyNameSchema.describe("A Teamspace-local ticket type option name").optional(), - labels: LabelNamesSchema.describe("Initial Teamspace-local label option names").optional(), - assignee: AssigneeIdentifierSchema.describe( + ), + description_html: z.string().optional().describe(TICKET_DESCRIPTION_HTML_DESCRIPTION), + type: NonEmptyNameSchema.optional().describe("A Teamspace-local ticket type option name"), + labels: LabelNamesSchema.optional().describe("Initial Teamspace-local label option names"), + assignee: AssigneeIdentifierSchema.optional().describe( "Initial assignee user GID or email address", - ).optional(), - predicted_start_on: DateOnlySchema.describe( + ), + predicted_start_on: DateOnlySchema.optional().describe( "Initial predicted start date in YYYY-MM-DD form", - ).optional(), - predicted_completion_on: DateOnlySchema.describe( + ), + predicted_completion_on: DateOnlySchema.optional().describe( "Initial predicted completion date in YYYY-MM-DD form", - ).optional(), + ), }) .strict(); diff --git a/src/tool_definitions/comments.ts b/src/tool_definitions/comments.ts index 284d052..b3d06ef 100644 --- a/src/tool_definitions/comments.ts +++ b/src/tool_definitions/comments.ts @@ -19,8 +19,8 @@ const GetCommentsInputSchema = withTicketId({ .describe("Maximum number of comments to return, from 1 to 100"), cursor: z .string() - .describe("Opaque cursor from a prior call for the same ticket and limit") - .optional(), + .optional() + .describe("Opaque cursor from a prior call for the same ticket and limit"), }).strict(); const getComments = defineTeamspaceScopedTool({ @@ -53,11 +53,11 @@ const AddCommentInputSchema = withTicketId({ .string() .trim() .min(1, "Comment text must not be empty") + .optional() .describe( "Plain-text comment; Markdown is not rendered; exactly one of text or text_html must be provided.", - ) - .optional(), - text_html: z.string().describe(COMMENT_TEXT_HTML_DESCRIPTION).optional(), + ), + text_html: z.string().optional().describe(COMMENT_TEXT_HTML_DESCRIPTION), }) .strict() .superRefine((value, context) => { diff --git a/src/tools/tickets.ts b/src/tools/tickets.ts index f83b14e..97d06c2 100644 --- a/src/tools/tickets.ts +++ b/src/tools/tickets.ts @@ -49,12 +49,12 @@ const TicketLookupSchema = z.object({ const AssigneeViewSchema = z.object({ gid: GidSchema.describe("Numeric Asana user GID"), name: z.string().describe("Assignee display name"), - email: z.string().describe("Assignee email when Asana returns it").optional(), + email: z.string().optional().describe("Assignee email when Asana returns it"), }); const DependencyViewSchema = z.object({ gid: GidSchema.describe("Numeric GID of a task blocking this ticket"), - name: z.string().describe("Blocking task name when Asana returns it").optional(), + name: z.string().optional().describe("Blocking task name when Asana returns it"), }); export const TicketViewSchema = z.object({ diff --git a/src/tools/workflow.ts b/src/tools/workflow.ts index 801df62..a7e8047 100644 --- a/src/tools/workflow.ts +++ b/src/tools/workflow.ts @@ -226,8 +226,20 @@ export function createWorkflowService( return { addDependency: (ticketIdentifier, dependencyIdentifier, snapshot, deadlineMs) => - changeDependency("add", ticketIdentifier, dependencyIdentifier, snapshot, deadlineMs), + changeDependency( + "add", + ticketIdentifier, + dependencyIdentifier, + snapshot, + deadlineMs, + ) as Promise, removeDependency: (ticketIdentifier, dependencyIdentifier, snapshot, deadlineMs) => - changeDependency("remove", ticketIdentifier, dependencyIdentifier, snapshot, deadlineMs), + changeDependency( + "remove", + ticketIdentifier, + dependencyIdentifier, + snapshot, + deadlineMs, + ) as Promise, }; } diff --git a/tests/comments.test.ts b/tests/comments.test.ts index c988457..5a42aa4 100644 --- a/tests/comments.test.ts +++ b/tests/comments.test.ts @@ -26,7 +26,12 @@ import { GetCommentsOutputSchema, } from "../src/tools/comments.js"; import type { TicketService } from "../src/tools/tickets.js"; -import { buildDiscoverySnapshot, DEADLINE_MS, TEAMSPACE_ID } from "./helpers/tool_test_helpers.js"; +import { + buildDiscoverySnapshot, + DEADLINE_MS, + parseEnvelopeData, + TEAMSPACE_ID, +} from "./helpers/tool_test_helpers.js"; const TICKET_GID = "1700000000000001"; const OTHER_TICKET_GID = "1700000000000002"; @@ -123,7 +128,7 @@ function executor(bundle: AsanaResourceBundle, state: ExecutorState): AsanaReque ): Promise> { const response = await callback(bundle); collectRequestId(response, trace); - return z.object({ data: schema }).parse(response.data).data; + return parseEnvelopeData(schema, response.data); } return { diff --git a/tests/helpers/tool_test_helpers.ts b/tests/helpers/tool_test_helpers.ts index a42e149..51c0d7f 100644 --- a/tests/helpers/tool_test_helpers.ts +++ b/tests/helpers/tool_test_helpers.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { AsanaRequestExecutorPort } from "../../src/asana_gateway.js"; import type { Config } from "../../src/config.js"; import type { DiscoveryResult } from "../../src/schema_discovery.js"; @@ -39,6 +40,18 @@ function unexpectedExecutorCall(method: string): never { throw new UnexpectedExecutorCallError(method); } +/** + * Zod v4 can't statically resolve `.data` on `z.object({ data: schema }).parse(...)` when + * `schema` is a generic type parameter (its mapped object-shape type can't distribute over an + * unresolved `T`); the runtime parse is fine, so this only needs to fix the static type. + */ +export function parseEnvelopeData( + schema: TSchema, + value: unknown, +): z.infer { + return (z.object({ data: schema }).parse(value) as { data: z.infer }).data; +} + export function createUnexpectedExecutorFake(): AsanaRequestExecutorPort { return { createTrace: () => unexpectedExecutorCall("createTrace"), diff --git a/tests/releases.test.ts b/tests/releases.test.ts index ea77262..747e798 100644 --- a/tests/releases.test.ts +++ b/tests/releases.test.ts @@ -9,7 +9,7 @@ import type { WorkspacesApi, } from "asana"; import { describe, expect, it } from "vitest"; -import { z } from "zod"; +import type { z } from "zod"; import type { Task } from "../src/asana_contracts.js"; import type { AsanaHttpResult, @@ -21,7 +21,12 @@ import { CommandError } from "../src/errors.js"; import type { DiscoveryResult, ReleaseReference } from "../src/schema_discovery.js"; import { createReleaseService } from "../src/tools/releases.js"; import type { TicketService } from "../src/tools/tickets.js"; -import { buildDiscoverySnapshot, DEADLINE_MS, TEAMSPACE_ID } from "./helpers/tool_test_helpers.js"; +import { + buildDiscoverySnapshot, + DEADLINE_MS, + parseEnvelopeData, + TEAMSPACE_ID, +} from "./helpers/tool_test_helpers.js"; const TICKET_GID = "1700000000000001"; const RELEASE_GID = "1800000000000101"; @@ -101,7 +106,7 @@ function executor(bundle: AsanaResourceBundle, observed: ExecutorState): AsanaRe if (trace !== undefined && typeof requestId === "string") { trace.requestIds.push(requestId); } - return z.object({ data: schema }).parse(response.data).data; + return parseEnvelopeData(schema, response.data); } return { createTrace: () => { diff --git a/tests/schema_discovery.test.ts b/tests/schema_discovery.test.ts index 3a6d2c9..3b2eaf0 100644 --- a/tests/schema_discovery.test.ts +++ b/tests/schema_discovery.test.ts @@ -13,6 +13,7 @@ import { type FieldDefinition, readReferencedReleaseGids, } from "../src/schema_discovery.js"; +import { parseEnvelopeData } from "./helpers/tool_test_helpers.js"; const TEAMSPACE_ID = "1600000000000001"; const WORKSPACE = { gid: "1500000000000001", name: "Command Workspace" }; @@ -94,8 +95,7 @@ function createFakeExecutor(state: FakeState): AsanaRequestExecutorPort { }, }; const result = await callback(resources as never); - const envelope = z.object({ data: schema }); - return envelope.parse(result.data).data; + return parseEnvelopeData(schema, result.data); }, write: async () => unusedMethod("write"), readPage: async (schema, _options, callback) => { diff --git a/tests/ticket_mutations.test.ts b/tests/ticket_mutations.test.ts index fa8a4a1..36bbd1b 100644 --- a/tests/ticket_mutations.test.ts +++ b/tests/ticket_mutations.test.ts @@ -9,7 +9,7 @@ import type { WorkspacesApi, } from "asana"; import { describe, expect, it } from "vitest"; -import { z } from "zod"; +import type { z } from "zod"; import type { Task } from "../src/asana_contracts.js"; import type { AsanaHttpResult, @@ -25,7 +25,12 @@ import { createTicketService, UPDATE_PENDING_WARNING, } from "../src/tools/tickets.js"; -import { buildDiscoverySnapshot, DEADLINE_MS, TEAMSPACE_ID } from "./helpers/tool_test_helpers.js"; +import { + buildDiscoverySnapshot, + DEADLINE_MS, + parseEnvelopeData, + TEAMSPACE_ID, +} from "./helpers/tool_test_helpers.js"; const TASK_GID = "1700000000000001"; const TYPE_FIELD_GID = "1900000000000010"; @@ -114,7 +119,7 @@ function executor( if (trace !== undefined && typeof requestId === "string") { trace.requestIds.push(requestId); } - return z.object({ data: schema }).parse(response.data).data; + return parseEnvelopeData(schema, response.data); } return { createTrace: () => { diff --git a/tests/tickets.test.ts b/tests/tickets.test.ts index b50a9ff..d38187b 100644 --- a/tests/tickets.test.ts +++ b/tests/tickets.test.ts @@ -9,7 +9,6 @@ import type { WorkspacesApi, } from "asana"; import { describe, expect, it } from "vitest"; -import { z } from "zod"; import { FULL_TASK_FIELDS, type Task } from "../src/asana_contracts.js"; import type { AsanaHttpResult, @@ -19,7 +18,12 @@ import type { } from "../src/asana_gateway.js"; import type { DiscoveryResult } from "../src/schema_discovery.js"; import { createTicketService, projectTicketView, TicketViewSchema } from "../src/tools/tickets.js"; -import { buildDiscoverySnapshot, DEADLINE_MS, TEAMSPACE_ID } from "./helpers/tool_test_helpers.js"; +import { + buildDiscoverySnapshot, + DEADLINE_MS, + parseEnvelopeData, + TEAMSPACE_ID, +} from "./helpers/tool_test_helpers.js"; const TICKET_GID = "1700000000000001"; const RELEASE_GID = "1700000000000098"; @@ -93,7 +97,7 @@ function createExecutor( traces.push(trace); } const result = await callback(resources); - return z.object({ data: schema }).parse(result.data).data; + return parseEnvelopeData(schema, result.data); }, write: async () => unexpectedCall("AsanaRequestExecutor.write"), readPage: async () => unexpectedCall("AsanaRequestExecutor.readPage"), diff --git a/tests/tool_definitions/workflow.test.ts b/tests/tool_definitions/workflow.test.ts index a8cc832..440cb4d 100644 --- a/tests/tool_definitions/workflow.test.ts +++ b/tests/tool_definitions/workflow.test.ts @@ -122,10 +122,10 @@ describe("workflow tool definitions", () => { dependencies: [{ gid: DEPENDENCY_GID, name: "Blocking ticket" }], }, }; - const method: WorkflowService[typeof methodName] = async (...args) => { + const method: WorkflowService[typeof methodName] = (async (...args) => { observedCalls.push(args); return mutation; - }; + }) as WorkflowService[typeof methodName]; const workflow = workflowService({ [methodName]: method }); const context: CallContext = { deadlineMs: DEADLINE_MS, diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 62ab05a..00fa68d 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -25,6 +25,7 @@ import { buildDiscoverySnapshot, createUnexpectedTicketServiceFake, DEADLINE_MS, + parseEnvelopeData, TEAMSPACE_ID, } from "./helpers/tool_test_helpers.js"; @@ -125,7 +126,7 @@ function executor(bundle: AsanaResourceBundle, observed: ExecutorState): AsanaRe observed.writes.push(options.deadlineMs); const response = await callback(bundle); collectRequestId(response, trace); - return z.object({ data: schema }).parse(response.data).data; + return parseEnvelopeData(schema, response.data); }, readPage: async (schema, options, callback, trace) => { observed.reads.push(options.deadlineMs); diff --git a/tests/zod_describe_order.test.ts b/tests/zod_describe_order.test.ts new file mode 100644 index 0000000..d2e0c77 --- /dev/null +++ b/tests/zod_describe_order.test.ts @@ -0,0 +1,98 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC_ROOT = join(import.meta.dirname, "../src"); +const WRAPPER_CALL_PATTERN = /^\.(optional|nullable|nullish)\(/; + +function listTypeScriptFiles(directory: string, files: string[] = []): string[] { + for (const entry of readdirSync(directory)) { + const fullPath = join(directory, entry); + const stats = statSync(fullPath); + if (stats.isDirectory()) { + listTypeScriptFiles(fullPath, files); + } else if (entry.endsWith(".ts")) { + files.push(fullPath); + } + } + return files; +} + +/** + * Zod v4 silently drops a schema's description when `.optional()`, `.nullable()`, or + * `.nullish()` is chained after `.describe(...)` (the wrapper doesn't forward it) — no type + * error, no runtime error, just an undocumented field in the public tool schema. `.describe()` + * must always be the last call in a chain. See CODE-1176-adjacent zod v4 migration notes. + */ +function findDescribeBeforeWrapper(content: string): number[] { + const violationLines: number[] = []; + let searchIndex = 0; + while (true) { + const describeStart = content.indexOf(".describe(", searchIndex); + if (describeStart === -1) { + break; + } + + let parenDepth = 0; + let cursor = describeStart + ".describe(".length - 1; + let stringDelimiter: string | null = null; + let closeIndex = -1; + for (; cursor < content.length; cursor += 1) { + const character = content[cursor]; + if (stringDelimiter !== null) { + if (character === "\\") { + cursor += 1; + continue; + } + if (character === stringDelimiter) { + stringDelimiter = null; + } + continue; + } + if (character === '"' || character === "'" || character === "`") { + stringDelimiter = character; + continue; + } + if (character === "(") { + parenDepth += 1; + } else if (character === ")") { + parenDepth -= 1; + if (parenDepth === 0) { + closeIndex = cursor; + break; + } + } + } + if (closeIndex === -1) { + searchIndex = describeStart + 1; + continue; + } + + let afterIndex = closeIndex + 1; + while (afterIndex < content.length && /\s/.test(content[afterIndex] ?? "")) { + afterIndex += 1; + } + if (WRAPPER_CALL_PATTERN.test(content.slice(afterIndex, afterIndex + 12))) { + violationLines.push(content.slice(0, describeStart).split("\n").length); + } + searchIndex = closeIndex + 1; + } + return violationLines; +} + +describe("zod .describe() chain order", () => { + it("never calls .optional(), .nullable(), or .nullish() after .describe() in src/", () => { + const violations: string[] = []; + for (const filePath of listTypeScriptFiles(SRC_ROOT)) { + const content = readFileSync(filePath, "utf8"); + const lines = findDescribeBeforeWrapper(content); + for (const line of lines) { + violations.push(`${relative(SRC_ROOT, filePath)}:${line}`); + } + } + expect( + violations, + `Found .describe().optional()/.nullable()/.nullish() ordering (drops the description under zod v4). Swap to .optional().describe(...) etc. at:\n${violations.join("\n")}`, + ).toEqual([]); + }); +});