Skip to main content
Glama
useshortcut

Shortcut MCP Server

Official
by useshortcut

stories-add-task

Add tasks to Shortcut stories with descriptions and assign owners to organize project work.

Instructions

Add a task to a story

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
storyPublicIdYesThe public ID of the story
taskDescriptionYesThe description of the task
taskOwnerIdsNoArray of user IDs to assign as owners of the task

Implementation Reference

  • Handler function that executes the logic for adding a task to a Shortcut story. Validates inputs, fetches the story, verifies owners if specified, calls the client to add the task, and returns a success message.
    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}.`);
    }
  • Registration of the 'stories-add-task' MCP tool using server.addToolWithWriteAccess, including description, input schema, and reference to the handler function.
    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),
    );
  • Zod schema defining the input parameters for the 'stories-add-task' tool: storyPublicId (required positive number), taskDescription (required non-empty string), taskOwnerIds (optional array of strings).
    {
    	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"),
    },
  • The StoryTools class extending BaseTools, which contains all story-related tool methods including the handler.
    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-create-subtask",
    			"Create a new story as a sub-task",
    			{
    				parentStoryPublicId: z.number().positive().describe("The public ID of the parent story"),
    				name: z.string().min(1).max(512).describe("The name of the sub-task. Required."),
    				description: z.string().max(10_000).optional().describe("The description of the sub-task"),
    			},
    			async (params) => await tools.createSubTask(params),
    		);
    
    		server.addToolWithWriteAccess(
    			"stories-add-subtask",
    			"Add an existing story as a sub-task",
    			{
    				parentStoryPublicId: z.number().positive().describe("The public ID of the parent story"),
    				subTaskPublicId: z.number().positive().describe("The public ID of the sub-task story"),
    			},
    			async (params) => await tools.addStoryAsSubTask(params),
    		);
    
    		server.addToolWithWriteAccess(
    			"stories-remove-subtask",
    			"Remove a story from its parent. The sub-task will become a regular story.",
    			{
    				subTaskPublicId: z.number().positive().describe("The public ID of the sub-task story"),
    			},
    			async (params) => await tools.removeSubTaskFromParent(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: sc-${story.id}`);
    	}
    
    	async createSubTask({
    		parentStoryPublicId,
    		name,
    		description,
    	}: {
    		parentStoryPublicId: number;
    		name: string;
    		description?: string;
    	}) {
    		if (!parentStoryPublicId) throw new Error("ID of parent story is required");
    		if (!name) throw new Error("Sub-task name is required");
    
    		const parentStory = await this.client.getStory(parentStoryPublicId);
    		if (!parentStory)
    			throw new Error(`Failed to retrieve parent story with public ID: ${parentStoryPublicId}`);
    
    		const workflow = await this.client.getWorkflow(parentStory.workflow_id);
    		if (!workflow) throw new Error("Failed to retrieve workflow of parent story");
    
    		const workflowState = workflow.states[0];
    		if (!workflowState) throw new Error("Failed to determine default state for sub-task");
    
    		const subTask = await this.client.createStory({
    			name,
    			description,
    			story_type: parentStory.story_type as CreateStoryParams["story_type"],
    			epic_id: parentStory.epic_id,
    			group_id: parentStory.group_id,
    			workflow_state_id: workflowState.id,
    			parent_story_id: parentStoryPublicId,
    		});
    
    		return this.toResult(`Created sub-task: sc-${subTask.id}`);
    	}
    
    	async addStoryAsSubTask({
    		parentStoryPublicId,
    		subTaskPublicId,
    	}: {
    		parentStoryPublicId: number;
    		subTaskPublicId: number;
    	}) {
    		if (!parentStoryPublicId) throw new Error("ID of parent story is required");
    		if (!subTaskPublicId) throw new Error("ID of sub-task story is required");
    
    		const subTask = await this.client.getStory(subTaskPublicId);
    		if (!subTask) throw new Error(`Failed to retrieve story with public ID: ${subTaskPublicId}`);
    		const parentStory = await this.client.getStory(parentStoryPublicId);
    		if (!parentStory)
    			throw new Error(`Failed to retrieve parent story with public ID: ${parentStoryPublicId}`);
    
    		await this.client.updateStory(subTaskPublicId, {
    			parent_story_id: parentStoryPublicId,
    		});
    
    		return this.toResult(
    			`Added story sc-${subTaskPublicId} as a sub-task of sc-${parentStoryPublicId}`,
    		);
    	}
    
    	async removeSubTaskFromParent({ subTaskPublicId }: { subTaskPublicId: number }) {
    		if (!subTaskPublicId) throw new Error("ID of sub-task story is required");
    
    		const subTask = await this.client.getStory(subTaskPublicId);
    		if (!subTask) throw new Error(`Failed to retrieve story with public ID: ${subTaskPublicId}`);
    
    		await this.client.updateStory(subTaskPublicId, {
    			parent_story_id: null,
    		});
    
    		return this.toResult(`Removed story sc-${subTaskPublicId} from its parent story`);
    	}
    
    	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}`);
    	}
    }
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations are provided, so the description carries full burden. It states 'Add a task to a story', implying a write operation, but does not disclose behavioral traits like permissions required, whether the task is immediately visible, if it triggers notifications, or error conditions. For a mutation tool with zero annotation coverage, this is a significant gap in transparency.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single, efficient sentence with zero waste. It is front-loaded with the core action and resource, making it easy to parse. Every word earns its place, and there is no redundant or verbose language.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the complexity of a mutation tool (adding a task) with no annotations and no output schema, the description is incomplete. It lacks information on behavioral aspects, error handling, and return values. While the schema covers parameters well, the overall context for safe and effective use is insufficient.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so the schema already documents all three parameters (storyPublicId, taskDescription, taskOwnerIds) with descriptions. The description adds no additional meaning beyond what the schema provides, such as format examples or constraints. Baseline 3 is appropriate when schema does the heavy lifting.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the action ('Add') and resource ('task to a story'), providing specific verb+resource pairing. It distinguishes from some siblings like 'stories-create' or 'stories-update-task', though not all (e.g., 'stories-add-subtask' is similar). The purpose is unambiguous but could be more precise about what type of task is being added.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

No guidance is provided on when to use this tool versus alternatives like 'stories-add-subtask' or 'stories-update-task'. The description lacks context about prerequisites, such as needing an existing story, or exclusions, like when not to use it. Usage is implied by the name but not explicitly stated.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

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