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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ The descriptions below are the exact strings advertised through MCP tool discove
| --- | --- | --- |
| `get_context` | Read | Confirm one selected Teamspace at the start of an Asana workflow or when diagnosing schema warnings; do not call before every tool. |
| `list_workspaces` | Read | List workspaces accessible to the configured Asana identity for Teamspace discovery or access diagnosis. |
| `find_teamspaces` | Read | Find recent or query-matched Teamspace candidates in one workspace; candidates are not schema-validated. |
| `find_teamspaces` | Read | Find recent or query-matched Teamspace candidates in one workspace; each candidate reports whether it has a resolvable Command ticket custom type. |
| `get_teamspace_schema` | Read | Return the freshly discovered Command schema used for this tool call. |
| `read_ticket` | Read | Read one Command ticket by Asana GID, Command short ID, or Asana task URL. |
| `list_tickets` | Read | Enumerate tickets in the selected Teamspace with bounded type, label, assignee, Release, and completion-status filtering plus opaque pagination. Use search_tickets instead for completion-date ranges. |
Expand Down
18 changes: 18 additions & 0 deletions src/schema_discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,24 @@ function resolveTicketCustomType(customTypes: CustomType[]): CustomType {
schemaAmbiguous("Multiple ticket custom types found", customTypes);
}

export async function hasResolvableTicketCustomType(
executor: AsanaRequestExecutorPort,
teamspaceId: string,
options: AsanaRequestOptions,
trace: AsanaRequestTrace,
): Promise<boolean> {
try {
const customTypes = await collectCustomTypes(executor, teamspaceId, options, trace);
resolveTicketCustomType(customTypes);
return true;
} catch (error) {
if (error instanceof CommandError) {
return false;
}
throw error;
}
}

function fieldCandidate(field: CustomField): Candidate {
return { gid: field.gid, name: field.name };
}
Expand Down
2 changes: 1 addition & 1 deletion src/tool_definitions/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const findTeamspaces = defineUnscopedTool({
name: "find_teamspaces",
title: "Find Command Teamspaces",
description:
"Find recent or query-matched Teamspace candidates in one workspace; candidates are not schema-validated.",
"Find recent or query-matched Teamspace candidates in one workspace; each candidate reports whether it has a resolvable Command ticket custom type.",
input: FindTeamspacesInputSchema,
output: TeamspaceCandidatesSchema,
readOnly: true,
Expand Down
28 changes: 22 additions & 6 deletions src/tools/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
import { commandTeamspaceUrl } from "../asana_url.js";
import { CommandError } from "../errors.js";
import { collectPages } from "../pagination/scanner.js";
import type { DiscoveryResult } from "../schema_discovery.js";
import { type DiscoveryResult, hasResolvableTicketCustomType } from "../schema_discovery.js";
import { TeamspaceReferenceSchema } from "../teamspace_identity.js";
import { createGitHubUpdateChecker, type UpdateChecker } from "../update_check.js";

Expand All @@ -24,11 +24,15 @@ export const WorkspaceListSchema = z.object({

const TeamspaceCandidateReferenceSchema = TeamspaceReferenceSchema.extend({
url: z.string().url(),
schema_validated: z
.boolean()
.describe(
"Whether this candidate has a resolvable Command ticket custom type. False means a scoped tool call such as get_context will fail with schema_incompatible or schema_ambiguous. True increases confidence but does not guarantee every other schema field is valid.",
),
});

export const TeamspaceCandidatesSchema = z.object({
candidates: z.array(TeamspaceCandidateReferenceSchema),
schema_validated: z.literal(false),
truncated: z.boolean(),
});

Expand Down Expand Up @@ -128,13 +132,25 @@ async function findTeamspaces(
trace,
);

return {
candidates: page.items.map((candidate) => ({
// Checked sequentially, one Asana call per candidate, to keep concurrency bounded and stay
// within the caller's deadline rather than bursting up to `limit` (max 20) requests at once.
const candidates: TeamspaceCandidates["candidates"] = [];
for (const candidate of page.items) {
candidates.push({
gid: candidate.gid,
name: candidate.name,
url: commandTeamspaceUrl(input.workspaceGid, candidate.gid),
})),
schema_validated: false,
schema_validated: await hasResolvableTicketCustomType(
executor,
candidate.gid,
options,
trace,
),
});
}

return {
candidates,
truncated: page.items.length === input.limit,
};
}
Expand Down
70 changes: 65 additions & 5 deletions tests/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,30 +43,48 @@ function createThrowingApi<T extends object>(apiName: string): T {
type ResourceMethods = {
getWorkspaces?: WorkspacesApi["getWorkspacesWithHttpInfo"];
typeahead?: TypeaheadApi["typeaheadForWorkspaceWithHttpInfo"];
getCustomTypes?: CustomTypesApi["getCustomTypesWithHttpInfo"];
};

function createResourceBundle(methods: ResourceMethods): AsanaResourceBundle {
const workspaces = createThrowingApi<WorkspacesApi>("workspaces");
const typeahead = createThrowingApi<TypeaheadApi>("typeahead");
const customTypes = createThrowingApi<CustomTypesApi>("customTypes");
if (methods.getWorkspaces !== undefined) {
workspaces.getWorkspacesWithHttpInfo = methods.getWorkspaces;
}
if (methods.typeahead !== undefined) {
typeahead.typeaheadForWorkspaceWithHttpInfo = methods.typeahead;
}
if (methods.getCustomTypes !== undefined) {
customTypes.getCustomTypesWithHttpInfo = methods.getCustomTypes;
}

return {
tasks: createThrowingApi<TasksApi>("tasks"),
projects: createThrowingApi<ProjectsApi>("projects"),
stories: createThrowingApi<StoriesApi>("stories"),
attachments: createThrowingApi<AttachmentsApi>("attachments"),
customFieldSettings: createThrowingApi<CustomFieldSettingsApi>("customFieldSettings"),
customTypes: createThrowingApi<CustomTypesApi>("customTypes"),
customTypes,
typeahead,
workspaces,
};
}

function customTypesFor(
resolvableGids: ReadonlySet<string>,
): CustomTypesApi["getCustomTypesWithHttpInfo"] {
return async (opts) => {
const projectGid = (opts as { project?: string } | undefined)?.project;
const types =
projectGid !== undefined && resolvableGids.has(projectGid)
? [{ gid: "1800000000000001", name: "Dev ticket" }]
: [];
return collectionResult(types);
};
}

function collectionResult(items: unknown[], nextOffset?: string): AsanaHttpResult {
return {
response: { headers: {} },
Expand Down Expand Up @@ -175,7 +193,14 @@ describe("context service", () => {
{ gid: "1600000000000002", name: "Mobile" },
]);
};
const service = createContextService(createExecutor(createResourceBundle({ typeahead })));
const service = createContextService(
createExecutor(
createResourceBundle({
typeahead,
getCustomTypes: customTypesFor(new Set([TEAMSPACE_GID, "1600000000000002"])),
}),
),
);

const result = await service.findTeamspaces({
workspaceGid: WORKSPACE_GID,
Expand All @@ -197,18 +222,49 @@ describe("context service", () => {
gid: TEAMSPACE_GID,
name: "Platform",
url: `https://app.asana.com/1/${WORKSPACE_GID}/dev/space/${TEAMSPACE_GID}`,
schema_validated: true,
},
{
gid: "1600000000000002",
name: "Mobile",
url: `https://app.asana.com/1/${WORKSPACE_GID}/dev/space/1600000000000002`,
schema_validated: true,
},
],
schema_validated: false,
truncated: true,
});
});

it("distinguishes candidates with and without a resolvable ticket custom type", async () => {
const validGid = TEAMSPACE_GID;
const invalidGid = "1600000000000002";
const observedProjectGids: Array<string | undefined> = [];
const typeahead: TypeaheadApi["typeaheadForWorkspaceWithHttpInfo"] = async () =>
collectionResult([
{ gid: validGid, name: "Platform" },
{ gid: invalidGid, name: "Ordinary project" },
]);
const getCustomTypes: CustomTypesApi["getCustomTypesWithHttpInfo"] = async (opts) => {
observedProjectGids.push((opts as { project?: string } | undefined)?.project);
return customTypesFor(new Set([validGid]))(opts);
};
const service = createContextService(
createExecutor(createResourceBundle({ typeahead, getCustomTypes })),
);

const result = await service.findTeamspaces({
workspaceGid: WORKSPACE_GID,
limit: 2,
deadlineMs: DEADLINE_MS,
});

expect(observedProjectGids).toEqual([validGid, invalidGid]);
expect(result.candidates).toEqual([
expect.objectContaining({ gid: validGid, schema_validated: true }),
expect.objectContaining({ gid: invalidGid, schema_validated: false }),
]);
});

it("omits an absent typeahead query and is not truncated below the requested limit", async () => {
let observedOptions: Record<string, unknown> | null = null;
const typeahead: TypeaheadApi["typeaheadForWorkspaceWithHttpInfo"] = async (
Expand All @@ -219,7 +275,11 @@ describe("context service", () => {
observedOptions = options ?? {};
return collectionResult([{ gid: TEAMSPACE_GID, name: "Platform" }]);
};
const service = createContextService(createExecutor(createResourceBundle({ typeahead })));
const service = createContextService(
createExecutor(
createResourceBundle({ typeahead, getCustomTypes: customTypesFor(new Set()) }),
),
);

const result = await service.findTeamspaces({
workspaceGid: WORKSPACE_GID,
Expand All @@ -232,7 +292,7 @@ describe("context service", () => {
opt_fields: "gid,name",
});
expect(observedOptions).not.toHaveProperty("query");
expect(result.schema_validated).toBe(false);
expect(result.candidates[0]?.schema_validated).toBe(false);
expect(result.truncated).toBe(false);
});

Expand Down
34 changes: 34 additions & 0 deletions tests/schema_discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
computeDiscoveryFingerprint,
discoverTeamspaceSchema,
type FieldDefinition,
hasResolvableTicketCustomType,
readReferencedReleaseGids,
} from "../src/schema_discovery.js";
import { parseEnvelopeData } from "./helpers/tool_test_helpers.js";
Expand Down Expand Up @@ -281,6 +282,39 @@ function completeTeamspaceState(

const requestOptions: AsanaRequestOptions = { deadlineMs: deadlineAfter(10_000) };

describe("hasResolvableTicketCustomType", () => {
it("returns true when exactly one ticket custom type resolves", async () => {
const executor = createFakeExecutor(completeTeamspaceState());

await expect(
hasResolvableTicketCustomType(executor, TEAMSPACE_ID, requestOptions, executor.createTrace()),
).resolves.toBe(true);
});

it("returns false when no custom types are found", async () => {
const executor = createFakeExecutor(completeTeamspaceState({ customTypes: [] }));

await expect(
hasResolvableTicketCustomType(executor, TEAMSPACE_ID, requestOptions, executor.createTrace()),
).resolves.toBe(false);
});

it("returns false when multiple ambiguous custom types are found", async () => {
const executor = createFakeExecutor(
completeTeamspaceState({
customTypes: [
{ gid: "1800000000000001", name: "Alpha" },
{ gid: "1800000000000002", name: "Beta" },
],
}),
);

await expect(
hasResolvableTicketCustomType(executor, TEAMSPACE_ID, requestOptions, executor.createTrace()),
).resolves.toBe(false);
});
});

describe("discoverTeamspaceSchema", () => {
it("resolves every required field for a complete Teamspace", async () => {
const state = completeTeamspaceState();
Expand Down
20 changes: 14 additions & 6 deletions tests/tool_definitions/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe("context tool definitions", () => {
name: "find_teamspaces",
title: "Find Command Teamspaces",
description:
"Find recent or query-matched Teamspace candidates in one workspace; candidates are not schema-validated.",
"Find recent or query-matched Teamspace candidates in one workspace; each candidate reports whether it has a resolvable Command ticket custom type.",
},
{
name: "get_teamspace_schema",
Expand Down Expand Up @@ -143,14 +143,25 @@ describe("context tool definitions", () => {
it("requires a URL on every find_teamspaces candidate", () => {
const output = findTool("find_teamspaces").outputSchema;
const resultWithoutUrl = output.safeParse({
candidates: [{ gid: TEAMSPACE_ID, name: "Engineering Teamspace" }],
schema_validated: false,
candidates: [{ gid: TEAMSPACE_ID, name: "Engineering Teamspace", schema_validated: true }],
truncated: false,
});

expect(resultWithoutUrl.success).toBe(false);
});

it("requires a schema_validated flag on every find_teamspaces candidate", () => {
const output = findTool("find_teamspaces").outputSchema;
const resultWithoutFlag = output.safeParse({
candidates: [
{ gid: TEAMSPACE_ID, name: "Engineering Teamspace", url: "https://app.asana.com/x" },
],
truncated: false,
});

expect(resultWithoutFlag.success).toBe(false);
});

it("lists workspaces without triggering schema discovery", async () => {
let observedDeadline: number | null = null;
const context = createUnexpectedContextService({
Expand Down Expand Up @@ -191,7 +202,6 @@ describe("context tool definitions", () => {
observedInput = input;
return {
candidates: [],
schema_validated: false,
truncated: false,
};
},
Expand All @@ -202,7 +212,6 @@ describe("context tool definitions", () => {
tool.execute({ workspace_gid: WORKSPACE_GID }, callContext(createServices({ context }))),
).resolves.toEqual({
candidates: [],
schema_validated: false,
truncated: false,
});
expect(observedInput).toEqual({
Expand All @@ -219,7 +228,6 @@ describe("context tool definitions", () => {
observedInput = input;
return {
candidates: [],
schema_validated: false,
truncated: false,
};
},
Expand Down