Skip to main content
Glama

Shortcut MCP Server

Official
by useshortcut
stories.ts25.2 kB
import type { MemberInfo, Story } from "@shortcut/client"; 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, has, is, user } from "./utils/validation"; export class StoryTools extends BaseTools { static create(client: ShortcutClientWrapper, server: CustomMcpServer) { const tools = new StoryTools(client); server.addToolWithReadAccess( "stories-get-by-id", "Get a Shortcut story by public ID", { storyPublicId: z.number().positive().describe("The public ID of the story to get"), full: z .boolean() .optional() .default(false) .describe( "True to return all story fields from the API. False to return a slim version that excludes uncommon fields", ), }, async ({ storyPublicId, full }) => await tools.getStory(storyPublicId, full), ); server.addToolWithReadAccess( "stories-search", "Find Shortcut stories.", { 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 stories with the specified public ID"), name: z.string().optional().describe("Find only stories matching the specified name"), description: z .string() .optional() .describe("Find only stories matching the specified description"), comment: z.string().optional().describe("Find only stories matching the specified comment"), type: z .enum(["feature", "bug", "chore"]) .optional() .describe("Find only stories of the specified type"), estimate: z .number() .optional() .describe("Find only stories matching the specified estimate"), branch: z.string().optional().describe("Find only stories matching the specified branch"), commit: z.string().optional().describe("Find only stories matching the specified commit"), pr: z.number().optional().describe("Find only stories matching the specified pull request"), project: z.number().optional().describe("Find only stories matching the specified project"), epic: z.number().optional().describe("Find only stories matching the specified epic"), objective: z .number() .optional() .describe("Find only stories matching the specified objective"), state: z.string().optional().describe("Find only stories matching the specified state"), label: z.string().optional().describe("Find only stories matching the specified label"), owner: user("owner"), requester: user("requester"), team: z .string() .optional() .describe( "Find only stories matching the specified team. This can be a team mention name or team name.", ), skillSet: z .string() .optional() .describe("Find only stories matching the specified skill set"), productArea: z .string() .optional() .describe("Find only stories matching the specified product area"), technicalArea: z .string() .optional() .describe("Find only stories matching the specified technical area"), priority: z .string() .optional() .describe("Find only stories matching the specified priority"), severity: z .string() .optional() .describe("Find only stories matching the specified severity"), isDone: is("completed"), isStarted: is("started"), isUnstarted: is("unstarted"), isUnestimated: is("unestimated"), isOverdue: is("overdue"), isArchived: is("archived").default(false), isBlocker: is("blocking"), isBlocked: is("blocked"), hasComment: has("a comment"), hasLabel: has("a label"), hasDeadline: has("a deadline"), hasOwner: has("an owner"), hasPr: has("a pr"), hasCommit: has("a commit"), hasBranch: has("a branch"), hasEpic: has("an epic"), hasTask: has("a task"), hasAttachment: has("an attachment"), created: date(), updated: date(), completed: date(), due: date(), }, async ({ nextPageToken, ...params }) => await tools.searchStories(params, nextPageToken), ); server.addToolWithReadAccess( "stories-get-branch-name", "Get a valid branch name for a specific story.", { storyPublicId: z.number().positive().describe("The public Id of the story"), }, async ({ storyPublicId }) => await tools.getStoryBranchName(storyPublicId), ); server.addToolWithWriteAccess( "stories-create", `Create a new Shortcut story. Name is required, and either a Team or Workflow must be specified: - If only Team is specified, we will use the default workflow for that team. - If Workflow is specified, it will be used regardless of Team. The story will be added to the default state for the workflow. `, { name: z.string().min(1).max(512).describe("The name of the story. Required."), description: z.string().max(10_000).optional().describe("The description of the story"), type: z .enum(["feature", "bug", "chore"]) .default("feature") .describe("The type of the story"), owner: z.string().optional().describe("The user id of the owner of the story"), epic: z.number().optional().describe("The epic id of the epic the story belongs to"), iteration: z .number() .optional() .describe("The iteration id of the iteration the story belongs to"), team: z .string() .optional() .describe( "The team ID or mention name of the team the story belongs to. Required unless a workflow is specified.", ), workflow: z .number() .optional() .describe("The workflow ID to add the story to. Required unless a team is specified."), }, async ({ name, description, type, owner, epic, iteration, team, workflow }) => await tools.createStory({ name, description, type, owner, epic, iteration, team, workflow, }), ); server.addToolWithWriteAccess( "stories-update", "Update an existing Shortcut story. Only provide fields you want to update. The story public ID will always be included in updates.", { storyPublicId: z.number().positive().describe("The public ID of the story to update"), name: z.string().max(512).optional().describe("The name of the story"), description: z.string().max(10_000).optional().describe("The description of the story"), type: z.enum(["feature", "bug", "chore"]).optional().describe("The type of the story"), epic: z .number() .nullable() .optional() .describe("The epic id of the epic the story belongs to, or null to unset"), estimate: z .number() .nullable() .optional() .describe("The point estimate of the story, or null to unset"), iteration: z .number() .nullable() .optional() .describe("The iteration id of the iteration the story belongs to, or null to unset"), owner_ids: z .array(z.string()) .optional() .describe("Array of user UUIDs to assign as owners of the story"), workflow_state_id: z .number() .optional() .describe("The workflow state ID to move the story to"), labels: z .array( z.object({ name: z.string().describe("The name of the label"), color: z.string().optional().describe("The color of the label"), description: z.string().optional().describe("The description of the label"), }), ) .optional() .describe("Labels to assign to the story"), }, async (params) => await tools.updateStory(params), ); server.addToolWithWriteAccess( "stories-upload-file", "Upload a file and link it to a story.", { storyPublicId: z.number().positive().describe("The public ID of the story"), filePath: z.string().describe("The path to the file to upload"), }, async ({ storyPublicId, filePath }) => await tools.uploadFileToStory(storyPublicId, filePath), ); server.addToolWithWriteAccess( "stories-assign-current-user", "Assign the current user as the owner of a story", { storyPublicId: z.number().positive().describe("The public ID of the story"), }, async ({ storyPublicId }) => await tools.assignCurrentUserAsOwner(storyPublicId), ); server.addToolWithWriteAccess( "stories-unassign-current-user", "Unassign the current user as the owner of a story", { storyPublicId: z.number().positive().describe("The public ID of the story"), }, async ({ storyPublicId }) => await tools.unassignCurrentUserAsOwner(storyPublicId), ); server.addToolWithWriteAccess( "stories-create-comment", "Create a comment on a story", { storyPublicId: z.number().positive().describe("The public ID of the story"), text: z.string().min(1).describe("The text of the comment"), }, async (params) => await tools.createStoryComment(params), ); server.addToolWithWriteAccess( "stories-add-task", "Add a task to a story", { storyPublicId: z.number().positive().describe("The public ID of the story"), taskDescription: z.string().min(1).describe("The description of the task"), taskOwnerIds: z .array(z.string()) .optional() .describe("Array of user IDs to assign as owners of the task"), }, async (params) => await tools.addTaskToStory(params), ); server.addToolWithWriteAccess( "stories-update-task", "Update a task in a story", { storyPublicId: z.number().positive().describe("The public ID of the story"), taskPublicId: z.number().positive().describe("The public ID of the task"), taskDescription: z.string().optional().describe("The description of the task"), taskOwnerIds: z .array(z.string()) .optional() .describe("Array of user IDs to assign as owners of the task"), isCompleted: z.boolean().optional().describe("Whether the task is completed or not"), }, async (params) => await tools.updateTask(params), ); server.addToolWithWriteAccess( "stories-add-relation", "Add a story relationship to a story", { storyPublicId: z.number().positive().describe("The public ID of the story"), relatedStoryPublicId: z.number().positive().describe("The public ID of the related story"), relationshipType: z .enum(["relates to", "blocks", "blocked by", "duplicates", "duplicated by"]) .optional() .default("relates to") .describe("The type of relationship"), }, async (params) => await tools.addRelationToStory(params), ); server.addToolWithWriteAccess( "stories-add-external-link", "Add an external link to a Shortcut story", { storyPublicId: z.number().positive().describe("The public ID of the story"), externalLink: z.string().url().max(2048).describe("The external link URL to add"), }, async ({ storyPublicId, externalLink }) => await tools.addExternalLinkToStory(storyPublicId, externalLink), ); server.addToolWithWriteAccess( "stories-remove-external-link", "Remove an external link from a Shortcut story", { storyPublicId: z.number().positive().describe("The public ID of the story"), externalLink: z.string().url().max(2048).describe("The external link URL to remove"), }, async ({ storyPublicId, externalLink }) => await tools.removeExternalLinkFromStory(storyPublicId, externalLink), ); server.addToolWithWriteAccess( "stories-set-external-links", "Replace all external links on a story with a new set of links", { storyPublicId: z.number().positive().describe("The public ID of the story"), externalLinks: z .array(z.string().url().max(2048)) .describe("Array of external link URLs to set (replaces all existing links)"), }, async ({ storyPublicId, externalLinks }) => await tools.setStoryExternalLinks(storyPublicId, externalLinks), ); server.addToolWithReadAccess( "stories-get-by-external-link", "Find all stories that contain a specific external link", { externalLink: z.string().url().max(2048).describe("The external link URL to search for"), }, async ({ externalLink }) => await tools.getStoriesByExternalLink(externalLink), ); return tools; } async assignCurrentUserAsOwner(storyPublicId: number) { const story = await this.client.getStory(storyPublicId); if (!story) throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); const currentUser = await this.client.getCurrentUser(); if (!currentUser) throw new Error("Failed to retrieve current user"); if (story.owner_ids.includes(currentUser.id)) return this.toResult(`Current user is already an owner of story sc-${storyPublicId}`); await this.client.updateStory(storyPublicId, { owner_ids: story.owner_ids.concat([currentUser.id]), }); return this.toResult(`Assigned current user as owner of story sc-${storyPublicId}`); } async unassignCurrentUserAsOwner(storyPublicId: number) { const story = await this.client.getStory(storyPublicId); if (!story) throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); const currentUser = await this.client.getCurrentUser(); if (!currentUser) throw new Error("Failed to retrieve current user"); if (!story.owner_ids.includes(currentUser.id)) return this.toResult(`Current user is not an owner of story sc-${storyPublicId}`); await this.client.updateStory(storyPublicId, { owner_ids: story.owner_ids.filter((ownerId) => ownerId !== currentUser.id), }); return this.toResult(`Unassigned current user as owner of story sc-${storyPublicId}`); } private createBranchName(currentUser: MemberInfo, story: Story) { return `${currentUser.mention_name}/sc-${story.id}/${story.name .toLowerCase() .replace(/\s+/g, "-") .replace(/[^\w-]/g, "")}`.substring(0, 50); } async getStoryBranchName(storyPublicId: number) { const currentUser = await this.client.getCurrentUser(); if (!currentUser) throw new Error("Unable to find current user"); const story = await this.client.getStory(storyPublicId); if (!story) throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); const branchName = (story as Story & { formatted_vcs_branch_name: string | null }).formatted_vcs_branch_name || this.createBranchName(currentUser, story); return this.toResult(`Branch name for story sc-${storyPublicId}: ${branchName}`); } async createStory({ name, description, type, owner, epic, iteration, team, workflow, }: { name: string; description?: string; type: "feature" | "bug" | "chore"; owner?: string; epic?: number; iteration?: number; team?: string; workflow?: number; }) { if (!workflow && !team) throw new Error("Team or Workflow has to be specified"); if (!workflow && team) { const fullTeam = await this.client.getTeam(team); workflow = fullTeam?.workflow_ids?.[0]; } if (!workflow) throw new Error("Failed to find workflow for team"); const fullWorkflow = await this.client.getWorkflow(workflow); if (!fullWorkflow) throw new Error("Failed to find workflow"); const story = await this.client.createStory({ name, description, story_type: type, owner_ids: owner ? [owner] : [], epic_id: epic, iteration_id: iteration, group_id: team, workflow_state_id: fullWorkflow.default_state_id, }); return this.toResult(`Created story: ${story.id}`); } async searchStories(params: QueryParams, nextToken?: string) { const currentUser = await this.client.getCurrentUser(); const query = await buildSearchQuery(params, currentUser); const { stories, total, next_page_token } = await this.client.searchStories(query, nextToken); if (!stories) throw new Error(`Failed to search for stories matching your query: "${query}".`); if (!stories.length) return this.toResult(`Result: No stories found.`); return this.toResult( `Result (${stories.length} shown of ${total} total stories found):`, await this.entitiesWithRelatedEntities(stories, "stories"), next_page_token, ); } async getStory(storyPublicId: number, full = false) { const story = await this.client.getStory(storyPublicId); if (!story) throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}.`); return this.toResult( `Story: sc-${storyPublicId}`, await this.entityWithRelatedEntities(story, "story", full), ); } async createStoryComment({ storyPublicId, text }: { storyPublicId: number; text: string }) { if (!storyPublicId) throw new Error("Story public ID is required"); if (!text) throw new Error("Story comment text is required"); const story = await this.client.getStory(storyPublicId); if (!story) throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); const storyComment = await this.client.createStoryComment(storyPublicId, { text }); return this.toResult( `Created comment on story sc-${storyPublicId}. Comment URL: ${storyComment.app_url}.`, ); } async updateStory({ storyPublicId, ...updates }: { storyPublicId: number; name?: string; description?: string; type?: "feature" | "bug" | "chore"; epic?: number | null; estimate?: number | null; iteration?: number | null; owner_ids?: string[]; workflow_state_id?: number; labels?: Array<{ name: string; color?: string; description?: string; }>; }) { if (!storyPublicId) throw new Error("Story public ID is required"); // Verify the story exists const story = await this.client.getStory(storyPublicId); if (!story) throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); // Convert API parameters const updateParams: Record<string, unknown> = {}; if (updates.name !== undefined) updateParams.name = updates.name; if (updates.description !== undefined) updateParams.description = updates.description; if (updates.type !== undefined) updateParams.story_type = updates.type; if (updates.epic !== undefined) updateParams.epic_id = updates.epic; if (updates.estimate !== undefined) updateParams.estimate = updates.estimate; if (updates.iteration !== undefined) updateParams.iteration_id = updates.iteration; if (updates.owner_ids !== undefined) updateParams.owner_ids = updates.owner_ids; if (updates.workflow_state_id !== undefined) updateParams.workflow_state_id = updates.workflow_state_id; if (updates.labels !== undefined) updateParams.labels = updates.labels; // Update the story const updatedStory = await this.client.updateStory(storyPublicId, updateParams); return this.toResult(`Updated story sc-${storyPublicId}. Story URL: ${updatedStory.app_url}`); } async uploadFileToStory(storyPublicId: number, filePath: string) { if (!storyPublicId) throw new Error("Story public ID is required"); if (!filePath) throw new Error("File path is required"); const story = await this.client.getStory(storyPublicId); if (!story) throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); const uploadedFile = await this.client.uploadFile(storyPublicId, filePath); if (!uploadedFile) throw new Error(`Failed to upload file to story sc-${storyPublicId}`); return this.toResult( `Uploaded file "${uploadedFile.name}" to story sc-${storyPublicId}. File ID is: ${uploadedFile.id}`, ); } async addTaskToStory({ storyPublicId, taskDescription, taskOwnerIds, }: { storyPublicId: number; taskDescription: string; taskOwnerIds?: string[]; }) { if (!storyPublicId) throw new Error("Story public ID is required"); if (!taskDescription) throw new Error("Task description is required"); const story = await this.client.getStory(storyPublicId); if (!story) throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); if (taskOwnerIds?.length) { const owners = await this.client.getUserMap(taskOwnerIds as string[]); if (!owners) throw new Error(`Failed to retrieve users with IDs: ${taskOwnerIds.join(", ")}`); } const task = await this.client.addTaskToStory(storyPublicId, { description: taskDescription, ownerIds: taskOwnerIds, }); return this.toResult(`Created task for story sc-${storyPublicId}. Task ID: ${task.id}.`); } async updateTask({ storyPublicId, taskPublicId, taskDescription, taskOwnerIds, isCompleted, }: { storyPublicId: number; taskPublicId: number; taskDescription?: string; taskOwnerIds?: string[]; isCompleted?: boolean; }) { if (!storyPublicId) throw new Error("Story public ID is required"); if (!taskPublicId) throw new Error("Task public ID is required"); const story = await this.client.getStory(storyPublicId); if (!story) throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); const task = await this.client.getTask(storyPublicId, taskPublicId); if (!task) throw new Error(`Failed to retrieve Shortcut task with public ID: ${taskPublicId}`); const updatedTask = await this.client.updateTask(storyPublicId, taskPublicId, { description: taskDescription, ownerIds: taskOwnerIds, isCompleted, }); let message = `Updated task for story sc-${storyPublicId}. Task ID: ${updatedTask.id}.`; if (isCompleted) { message = `Completed task for story sc-${storyPublicId}. Task ID: ${updatedTask.id}.`; } return this.toResult(message); } async addRelationToStory({ storyPublicId, relatedStoryPublicId, relationshipType, }: { storyPublicId: number; relatedStoryPublicId: number; relationshipType: "relates to" | "blocks" | "blocked by" | "duplicates" | "duplicated by"; }) { if (!storyPublicId) throw new Error("Story public ID is required"); if (!relatedStoryPublicId) throw new Error("Related story public ID is required"); const story = await this.client.getStory(storyPublicId); if (!story) throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); const relatedStory = await this.client.getStory(relatedStoryPublicId); if (!relatedStory) throw new Error(`Failed to retrieve Shortcut story with public ID: ${relatedStoryPublicId}`); let subjectStoryId = storyPublicId; let objectStoryId = relatedStoryPublicId; if (relationshipType === "blocked by" || relationshipType === "duplicated by") { relationshipType = relationshipType === "blocked by" ? "blocks" : "duplicates"; subjectStoryId = relatedStoryPublicId; objectStoryId = storyPublicId; } await this.client.addRelationToStory(subjectStoryId, objectStoryId, relationshipType); return this.toResult( relationshipType === "blocks" ? `Marked sc-${subjectStoryId} as a blocker to sc-${objectStoryId}.` : relationshipType === "duplicates" ? `Marked sc-${subjectStoryId} as a duplicate of sc-${objectStoryId}.` : `Added a relationship between sc-${subjectStoryId} and sc-${objectStoryId}.`, ); } async addExternalLinkToStory(storyPublicId: number, externalLink: string) { if (!storyPublicId) throw new Error("Story public ID is required"); if (!externalLink) throw new Error("External link is required"); const updatedStory = await this.client.addExternalLinkToStory(storyPublicId, externalLink); return this.toResult( `Added external link to story sc-${storyPublicId}. Story URL: ${updatedStory.app_url}`, ); } async removeExternalLinkFromStory(storyPublicId: number, externalLink: string) { if (!storyPublicId) throw new Error("Story public ID is required"); if (!externalLink) throw new Error("External link is required"); const updatedStory = await this.client.removeExternalLinkFromStory(storyPublicId, externalLink); return this.toResult( `Removed external link from story sc-${storyPublicId}. Story URL: ${updatedStory.app_url}`, ); } async getStoriesByExternalLink(externalLink: string) { if (!externalLink) throw new Error("External link is required"); const { stories, total } = await this.client.getStoriesByExternalLink(externalLink); if (!stories || !stories.length) { return this.toResult(`No stories found with external link: ${externalLink}`); } return this.toResult( `Found ${total} stories with external link: ${externalLink}`, await this.entitiesWithRelatedEntities(stories, "stories"), ); } async setStoryExternalLinks(storyPublicId: number, externalLinks: string[]) { if (!storyPublicId) throw new Error("Story public ID is required"); if (!Array.isArray(externalLinks)) throw new Error("External links must be an array"); const updatedStory = await this.client.setStoryExternalLinks(storyPublicId, externalLinks); const linkCount = externalLinks.length; const message = linkCount === 0 ? `Removed all external links from story sc-${storyPublicId}` : `Set ${linkCount} external link${linkCount === 1 ? "" : "s"} on story sc-${storyPublicId}`; return this.toResult(`${message}. Story URL: ${updatedStory.app_url}`); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/useshortcut/mcp-server-shortcut'

If you have feedback or need assistance with the MCP directory API, please join our Discord server