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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions src/asana_contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,10 @@ export const NextPageSchema = z
})
.nullable();

export function singleObjectEnvelope<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
data: dataSchema,
});
export function singleObjectEnvelope<T extends z.ZodTypeAny>(
dataSchema: T,
): z.ZodType<{ data: z.infer<T> }> {
return z.object({ data: dataSchema }) as unknown as z.ZodType<{ data: z.infer<T> }>;
}

export function collectionEnvelope<T extends z.ZodTypeAny>(itemSchema: T) {
Expand Down
2 changes: 1 addition & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
});
Expand Down
29 changes: 24 additions & 5 deletions src/mutation_envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,25 @@ function zodUnionFromSchemas(schemas: readonly z.ZodTypeAny[]): z.ZodTypeAny {
return z.union([first, second, ...rest]);
}

export function mutationVariantsToSchemas(
variants: readonly MutationVariant<string, string, z.ZodTypeAny>[],
type MutationVariantOutput<V> = V extends MutationVariant<
infer TStatus,
infer TOutcome,
infer TDataSchema
>
? MutationMetadata & {
status: TStatus;
outcome: TOutcome;
data: z.infer<TDataSchema>;
}
: never;

export function mutationVariantsToSchemas<
const TVariants extends readonly MutationVariant<string, string, z.ZodTypeAny>[],
>(
variants: TVariants,
): {
runtimeSchema: z.ZodTypeAny;
protocolSchema: z.ZodTypeAny;
runtimeSchema: z.ZodType<MutationVariantOutput<TVariants[number]>>;
protocolSchema: z.ZodType<MutationVariantOutput<TVariants[number]>>;
} {
if (variants.length === 0) {
throw new Error("mutationVariantsToSchemas requires at least one variant");
Expand Down Expand Up @@ -106,7 +120,12 @@ export function mutationVariantsToSchemas(
data: zodUnionFromSchemas(dataSchemas),
});

return { runtimeSchema, protocolSchema };
return {
runtimeSchema: runtimeSchema as z.ZodType<MutationVariantOutput<TVariants[number]>>,
protocolSchema: protocolSchema as unknown as z.ZodType<
MutationVariantOutput<TVariants[number]>
>,
};
}

export function buildMutationResult<
Expand Down
68 changes: 34 additions & 34 deletions src/ticket_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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
Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand Down
10 changes: 5 additions & 5 deletions src/tool_definitions/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions src/tools/tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
16 changes: 14 additions & 2 deletions src/tools/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AddDependencyOutput>,
removeDependency: (ticketIdentifier, dependencyIdentifier, snapshot, deadlineMs) =>
changeDependency("remove", ticketIdentifier, dependencyIdentifier, snapshot, deadlineMs),
changeDependency(
"remove",
ticketIdentifier,
dependencyIdentifier,
snapshot,
deadlineMs,
) as Promise<RemoveDependencyOutput>,
};
}
9 changes: 7 additions & 2 deletions tests/comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -123,7 +128,7 @@ function executor(bundle: AsanaResourceBundle, state: ExecutorState): AsanaReque
): Promise<z.infer<TSchema>> {
const response = await callback(bundle);
collectRequestId(response, trace);
return z.object({ data: schema }).parse(response.data).data;
return parseEnvelopeData(schema, response.data);
}

return {
Expand Down
13 changes: 13 additions & 0 deletions tests/helpers/tool_test_helpers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<TSchema extends z.ZodTypeAny>(
schema: TSchema,
value: unknown,
): z.infer<TSchema> {
return (z.object({ data: schema }).parse(value) as { data: z.infer<TSchema> }).data;
}

export function createUnexpectedExecutorFake(): AsanaRequestExecutorPort {
return {
createTrace: () => unexpectedExecutorCall("createTrace"),
Expand Down
11 changes: 8 additions & 3 deletions tests/releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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: () => {
Expand Down
4 changes: 2 additions & 2 deletions tests/schema_discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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) => {
Expand Down
Loading