iterations.ts•9.35 kB
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import type { ShortcutClientWrapper } from "@/client/shortcut";
import type { CustomMcpServer } from "@/mcp/CustomMcpServer";
import { BaseTools } from "./base";
import { buildSearchQuery, type QueryParams } from "./utils/search";
import { date } from "./utils/validation";
export class IterationTools extends BaseTools {
static create(client: ShortcutClientWrapper, server: CustomMcpServer) {
const tools = new IterationTools(client);
server.addToolWithReadAccess(
"iterations-get-stories",
"Get stories in a specific iteration by iteration public ID",
{
iterationPublicId: z.number().positive().describe("The public ID of the iteration"),
includeStoryDescriptions: z
.boolean()
.optional()
.default(false)
.describe(
"Indicate whether story descriptions should be included. Including descriptions may take longer and will increase the size of the response.",
),
},
async ({ iterationPublicId, includeStoryDescriptions }) =>
await tools.getIterationStories(iterationPublicId, includeStoryDescriptions),
);
server.addToolWithReadAccess(
"iterations-get-by-id",
"Get a Shortcut iteration by public ID",
{
iterationPublicId: z.number().positive().describe("The public ID of the iteration to get"),
full: z
.boolean()
.optional()
.default(false)
.describe(
"True to return all iteration fields from the API. False to return a slim version that excludes uncommon fields",
),
},
async ({ iterationPublicId, full }) => await tools.getIteration(iterationPublicId, full),
);
server.addToolWithReadAccess(
"iterations-search",
"Find Shortcut iterations.",
{
nextPageToken: z
.string()
.optional()
.describe(
"If a next_page_token was returned from the search result, pass it in to get the next page of results. Should be combined with the original search parameters.",
),
id: z.number().optional().describe("Find only iterations with the specified public ID"),
name: z.string().optional().describe("Find only iterations matching the specified name"),
description: z
.string()
.optional()
.describe("Find only iterations matching the specified description"),
state: z
.enum(["started", "unstarted", "done"])
.optional()
.describe("Find only iterations matching the specified state"),
team: z
.string()
.optional()
.describe(
"Find only iterations matching the specified team. This can be a team ID or mention name.",
),
created: date(),
updated: date(),
startDate: date(),
endDate: date(),
},
async ({ nextPageToken, ...params }) => await tools.searchIterations(params, nextPageToken),
);
server.addToolWithWriteAccess(
"iterations-create",
"Create a new Shortcut iteration",
{
name: z.string().describe("The name of the iteration"),
startDate: z.string().describe("The start date of the iteration in YYYY-MM-DD format"),
endDate: z.string().describe("The end date of the iteration in YYYY-MM-DD format"),
teamId: z.string().optional().describe("The ID of a team to assign the iteration to"),
description: z.string().optional().describe("A description of the iteration"),
},
async (params) => await tools.createIteration(params),
);
server.addToolWithReadAccess(
"iterations-get-active",
"Get the active Shortcut iterations for the current user based on their team memberships",
{
teamId: z.string().optional().describe("The ID of a team to filter iterations by"),
},
async ({ teamId }) => await tools.getActiveIterations(teamId),
);
server.addToolWithReadAccess(
"iterations-get-upcoming",
"Get the upcoming Shortcut iterations for the current user based on their team memberships",
{
teamId: z.string().optional().describe("The ID of a team to filter iterations by"),
},
async ({ teamId }) => await tools.getUpcomingIterations(teamId),
);
return tools;
}
async getIterationStories(iterationPublicId: number, includeDescription: boolean) {
const { stories } = await this.client.listIterationStories(
iterationPublicId,
includeDescription,
);
if (!stories)
throw new Error(
`Failed to retrieve Shortcut stories in iteration with public ID: ${iterationPublicId}.`,
);
return this.toResult(
`Result (${stories.length} stories found):`,
await this.entitiesWithRelatedEntities(stories, "stories"),
);
}
async searchIterations(params: QueryParams, nextToken?: string) {
const currentUser = await this.client.getCurrentUser();
const query = await buildSearchQuery(params, currentUser);
const { iterations, total, next_page_token } = await this.client.searchIterations(
query,
nextToken,
);
if (!iterations)
throw new Error(`Failed to search for iterations matching your query: "${query}".`);
if (!iterations.length) return this.toResult(`Result: No iterations found.`);
return this.toResult(
`Result (${iterations.length} shown of ${total} total iterations found):`,
await this.entitiesWithRelatedEntities(iterations, "iterations"),
next_page_token,
);
}
async getIteration(iterationPublicId: number, full = false) {
const iteration = await this.client.getIteration(iterationPublicId);
if (!iteration)
throw new Error(
`Failed to retrieve Shortcut iteration with public ID: ${iterationPublicId}.`,
);
return this.toResult(
`Iteration: ${iterationPublicId}`,
await this.entityWithRelatedEntities(iteration, "iteration", full),
);
}
async createIteration({
name,
startDate,
endDate,
teamId,
description,
}: {
name: string;
startDate: string;
endDate: string;
teamId?: string;
description?: string;
}): Promise<CallToolResult> {
const iteration = await this.client.createIteration({
name,
start_date: startDate,
end_date: endDate,
group_ids: teamId ? [teamId] : undefined,
description,
});
if (!iteration) throw new Error(`Failed to create the iteration.`);
return this.toResult(`Iteration created with ID: ${iteration.id}.`);
}
async getActiveIterations(teamId?: string) {
if (teamId) {
const team = await this.client.getTeam(teamId);
if (!team) throw new Error(`No team found matching id: "${teamId}"`);
const result = await this.client.getActiveIteration([teamId]);
const iterations = result.get(teamId);
if (!iterations?.length) return this.toResult(`Result: No active iterations found for team.`);
if (iterations.length === 1)
return this.toResult(
"The active iteration for the team is:",
await this.entityWithRelatedEntities(iterations[0], "iteration"),
);
return this.toResult(
"The active iterations for the team are:",
await this.entitiesWithRelatedEntities(iterations, "iterations"),
);
}
const currentUser = await this.client.getCurrentUser();
if (!currentUser) throw new Error("Failed to retrieve current user.");
const teams = await this.client.getTeams();
const teamIds = teams
.filter((team) => team.member_ids.includes(currentUser.id))
.map((team) => team.id);
if (!teamIds.length) throw new Error("Current user does not belong to any teams.");
const resultsByTeam = await this.client.getActiveIteration(teamIds);
const allActiveIterations = [...resultsByTeam.values()].flat();
if (!allActiveIterations.length)
return this.toResult("Result: No active iterations found for any of your teams.");
return this.toResult(
`You have ${allActiveIterations.length} active iterations for your teams:`,
await this.entitiesWithRelatedEntities(allActiveIterations, "iterations"),
);
}
async getUpcomingIterations(teamId?: string) {
if (teamId) {
const team = await this.client.getTeam(teamId);
if (!team) throw new Error(`No team found matching id: "${teamId}"`);
const result = await this.client.getUpcomingIteration([teamId]);
const iterations = result.get(teamId);
if (!iterations?.length)
return this.toResult(`Result: No upcoming iterations found for team.`);
if (iterations.length === 1)
return this.toResult(
"The next upcoming iteration for the team is:",
await this.entityWithRelatedEntities(iterations[0], "iteration"),
);
return this.toResult(
"The next upcoming iterations for the team are:",
await this.entitiesWithRelatedEntities(iterations, "iterations"),
);
}
const currentUser = await this.client.getCurrentUser();
if (!currentUser) throw new Error("Failed to retrieve current user.");
const teams = await this.client.getTeams();
const teamIds = teams
.filter((team) => team.member_ids.includes(currentUser.id))
.map((team) => team.id);
if (!teamIds.length) throw new Error("Current user does not belong to any teams.");
const resultsByTeam = await this.client.getUpcomingIteration(teamIds);
const allUpcomingIterations = [...resultsByTeam.values()].flat();
if (!allUpcomingIterations.length)
return this.toResult("Result: No upcoming iterations found for any of your teams.");
return this.toResult(
"The upcoming iterations for all your teams are:",
await this.entitiesWithRelatedEntities(allUpcomingIterations, "iterations"),
);
}
}