Skip to main content
Glama

Shortcut MCP Server

Official
by useshortcut
shortcut.ts15.8 kB
import { File } from "node:buffer"; import { readFileSync } from "node:fs"; import { basename } from "node:path"; import type { ShortcutClient as BaseClient, CreateDoc, CreateEpic, CreateIteration, CreateStoryComment, CreateStoryParams, CustomField, DocSlim, Epic, Group, Iteration, IterationSlim, Member, MemberInfo, Story, StoryComment, StoryLink, Task, UpdateStory, Workflow, } from "@shortcut/client"; import { Cache } from "./cache"; /** * This is a thin wrapper over the official Shortcut API client. * * Its main reasons for existing are: * - Add a caching layer for common calls like fetching members or teams. * - Unwrap and simplify some response types. * - Only expose a subset of methods and a subset of the possible input parameters to those methods. */ export class ShortcutClientWrapper { private currentUser: MemberInfo | null = null; private userCache: Cache<string, Member>; private teamCache: Cache<string, Group>; private workflowCache: Cache<number, Workflow>; private customFieldCache: Cache<string, CustomField>; constructor(private client: BaseClient) { this.userCache = new Cache(); this.teamCache = new Cache(); this.workflowCache = new Cache(); this.customFieldCache = new Cache(); } private getNextPageToken(next: string | null | undefined) { let next_page_token = null; if (next) { try { const [, t] = /next=(.+)(?:&|$)/.exec(next) || []; if (t) next_page_token = t; } catch {} } return next_page_token; } private async loadMembers() { if (this.userCache.isStale) { const response = await this.client.listMembers({}); const members = response?.data ?? null; if (members) { this.userCache.setMany(members.map((member) => [member.id, member])); } } } private async loadTeams() { if (this.teamCache.isStale) { const response = await this.client.listGroups(); const groups = response?.data ?? null; if (groups) { this.teamCache.setMany(groups.map((group) => [group.id, group])); } } } private async loadWorkflows() { if (this.workflowCache.isStale) { const response = await this.client.listWorkflows(); const workflows = response?.data ?? null; if (workflows) { this.workflowCache.setMany(workflows.map((workflow) => [workflow.id, workflow])); } } } private async loadCustomFields() { if (this.customFieldCache.isStale) { const response = await this.client.listCustomFields(); const customFields = response?.data ?? null; if (customFields) { this.customFieldCache.setMany( customFields.map((customField) => [customField.id, customField]), ); } } } async getCurrentUser() { if (this.currentUser) return this.currentUser; const response = await this.client.getCurrentMemberInfo(); const user = response?.data; if (!user) return null; this.currentUser = user; return user; } async getUser(userId: string) { const response = await this.client.getMember(userId, {}); const user = response?.data; if (!user) return null; return user; } async getUserMap(userIds: string[]) { await this.loadMembers(); return new Map( userIds .map((id) => [id, this.userCache.get(id)]) .filter((user): user is [string, Member] => user[1] !== null), ); } async getUsers(userIds: string[]) { await this.loadMembers(); return userIds .map((id) => this.userCache.get(id)) .filter((user): user is Member => user !== null); } async listMembers() { await this.loadMembers(); const members: Member[] = Array.from(this.userCache.values()); return members; } async getWorkflowMap(workflowIds: number[]) { await this.loadWorkflows(); return new Map( workflowIds .map((id) => [id, this.workflowCache.get(id)]) .filter((workflow): workflow is [number, Workflow] => workflow[1] !== null), ); } async getWorkflows() { await this.loadWorkflows(); return Array.from(this.workflowCache.values()); } async getWorkflow(workflowPublicId: number) { const response = await this.client.getWorkflow(workflowPublicId); const workflow = response?.data; if (!workflow) return null; return workflow; } async getTeams() { await this.loadTeams(); const teams: Group[] = Array.from(this.teamCache.values()); return teams; } async getTeamMap(teamIds: string[]) { await this.loadTeams(); return new Map( teamIds .map((id) => [id, this.teamCache.get(id)]) .filter((team): team is [string, Group] => team[1] !== null), ); } async getTeam(teamPublicId: string) { const response = await this.client.getGroup(teamPublicId); const group = response?.data; if (!group) return null; return group; } async createStory(params: CreateStoryParams) { const response = await this.client.createStory(params); const story = response?.data ?? null; if (!story) throw new Error(`Failed to create the story: ${response.status}`); return story; } async updateStory(storyPublicId: number, params: UpdateStory) { const response = await this.client.updateStory(storyPublicId, params); const story = response?.data ?? null; if (!story) throw new Error(`Failed to update the story: ${response.status}`); return story; } async getStory(storyPublicId: number) { const response = await this.client.getStory(storyPublicId); const story = response?.data ?? null; if (!story) return null; return story; } async getEpic(epicPublicId: number) { const response = await this.client.getEpic(epicPublicId); const epic = response?.data ?? null; if (!epic) return null; return epic; } async getIteration(iterationPublicId: number) { const response = await this.client.getIteration(iterationPublicId); const iteration = response?.data ?? null; if (!iteration) return null; return iteration; } async getMilestone(milestonePublicId: number) { const response = await this.client.getMilestone(milestonePublicId); const milestone = response?.data ?? null; if (!milestone) return null; return milestone; } async searchStories(query: string, nextToken?: string) { const response = await this.client.searchStories({ query, page_size: 25, detail: "full", next: nextToken, }); const stories = response?.data?.data; const total = response?.data?.total; const next = response?.data?.next; if (!stories) return { stories: null, total: null, next_page_token: null }; return { stories, total, next_page_token: this.getNextPageToken(next) }; } async searchIterations(query: string, nextToken?: string) { const response = await this.client.searchIterations({ query, page_size: 25, detail: "full", next: nextToken, }); const iterations = response?.data?.data; const total = response?.data?.total; const next = response?.data?.next; if (!iterations) return { iterations: null, total: null, next_page_token: null }; return { iterations, total, next_page_token: this.getNextPageToken(next) }; } async getActiveIteration(teamIds: string[]) { const response = await this.client.listIterations(); const iterations = response?.data; if (!iterations) return new Map<string, IterationSlim[]>(); const [today] = new Date().toISOString().split("T"); const activeIterationByTeam = iterations.reduce((acc, iteration) => { if (iteration.status !== "started") return acc; const [startDate] = new Date(iteration.start_date).toISOString().split("T"); const [endDate] = new Date(iteration.end_date).toISOString().split("T"); if (!startDate || !endDate) return acc; if (startDate > today || endDate < today) return acc; if (!iteration.group_ids?.length) iteration.group_ids = ["none"]; for (const groupId of iteration.group_ids) { if (groupId !== "none" && !teamIds.includes(groupId)) continue; const prevIterations = acc.get(groupId); if (prevIterations) { acc.set(groupId, prevIterations.concat([iteration])); } else acc.set(groupId, [iteration]); } return acc; }, new Map<string, IterationSlim[]>()); return activeIterationByTeam; } async getUpcomingIteration(teamIds: string[]) { const response = await this.client.listIterations(); const iterations = response?.data; if (!iterations) return new Map<string, IterationSlim[]>(); const [today] = new Date().toISOString().split("T"); const upcomingIterationByTeam = iterations.reduce((acc, iteration) => { if (iteration.status !== "unstarted") return acc; const [startDate] = new Date(iteration.start_date).toISOString().split("T"); const [endDate] = new Date(iteration.end_date).toISOString().split("T"); if (!startDate || !endDate) return acc; if (startDate < today) return acc; if (!iteration.group_ids?.length) iteration.group_ids = ["none"]; for (const groupId of iteration.group_ids) { if (groupId !== "none" && !teamIds.includes(groupId)) continue; const prevIterations = acc.get(groupId); if (prevIterations) { acc.set(groupId, prevIterations.concat([iteration])); } else acc.set(groupId, [iteration]); } return acc; }, new Map<string, IterationSlim[]>()); return upcomingIterationByTeam; } async searchEpics(query: string, nextToken?: string) { const response = await this.client.searchEpics({ query, page_size: 25, detail: "full", next: nextToken, }); const epics = response?.data?.data; const total = response?.data?.total; const next = response?.data?.next; if (!epics) return { epics: null, total: null, next_page_token: null }; return { epics, total, next_page_token: this.getNextPageToken(next) }; } async searchMilestones(query: string, nextToken?: string) { const response = await this.client.searchMilestones({ query, page_size: 25, detail: "full", next: nextToken, }); const milestones = response?.data?.data; const total = response?.data?.total; const next = response?.data?.next; if (!milestones) return { milestones: null, total: null, next_page_token: null }; return { milestones, total, next_page_token: this.getNextPageToken(next) }; } async listIterationStories(iterationPublicId: number, includeDescription = false) { const response = await this.client.listIterationStories(iterationPublicId, { includes_description: includeDescription, }); const stories = response?.data; if (!stories) return { stories: null, total: null }; return { stories, total: stories.length }; } async createStoryComment( storyPublicId: number, params: CreateStoryComment, ): Promise<StoryComment> { const response = await this.client.createStoryComment(storyPublicId, params); const storyComment = response?.data ?? null; if (!storyComment) throw new Error(`Failed to create the comment: ${response.status}`); return storyComment; } async createIteration(params: CreateIteration): Promise<Iteration> { const response = await this.client.createIteration(params); const iteration = response?.data ?? null; if (!iteration) throw new Error(`Failed to create the iteration: ${response.status}`); return iteration; } async createEpic(params: CreateEpic): Promise<Epic> { const response = await this.client.createEpic(params); const epic = response?.data ?? null; if (!epic) throw new Error(`Failed to create the epic: ${response.status}`); return epic; } async addTaskToStory( storyPublicId: number, taskParams: { description: string; ownerIds?: string[]; }, ): Promise<Task> { const { description, ownerIds } = taskParams; const params = { description, owner_ids: ownerIds, }; const response = await this.client.createTask(storyPublicId, params); const task = response?.data ?? null; if (!task) throw new Error(`Failed to create the task: ${response.status}`); return task; } async addRelationToStory( storyPublicId: number, linkedStoryId: number, verb: "blocks" | "duplicates" | "relates to", ): Promise<StoryLink> { const response = await this.client.createStoryLink({ object_id: linkedStoryId, subject_id: storyPublicId, verb, }); const storyLink = response?.data ?? null; if (!storyLink) throw new Error(`Failed to create the story links: ${response.status}`); return storyLink; } async getTask(storyPublicId: number, taskPublicId: number): Promise<Task> { const response = await this.client.getTask(storyPublicId, taskPublicId); const task = response?.data ?? null; if (!task) throw new Error(`Failed to get the task: ${response.status}`); return task; } async updateTask( storyPublicId: number, taskPublicId: number, taskParams: { description?: string; ownerIds?: string[]; isCompleted?: boolean; }, ): Promise<Task> { const { description, ownerIds } = taskParams; const params = { description, owner_ids: ownerIds, complete: taskParams.isCompleted, }; const response = await this.client.updateTask(storyPublicId, taskPublicId, params); const task = response?.data ?? null; if (!task) throw new Error(`Failed to update the task: ${response.status}`); return task; } async addExternalLinkToStory(storyPublicId: number, externalLink: string): Promise<Story> { const story = await this.getStory(storyPublicId); if (!story) throw new Error(`Story ${storyPublicId} not found`); const currentLinks = story.external_links || []; if (currentLinks.some((link) => link.toLowerCase() === externalLink.toLowerCase())) { return story; } const updatedLinks = [...currentLinks, externalLink]; return await this.updateStory(storyPublicId, { external_links: updatedLinks }); } async removeExternalLinkFromStory(storyPublicId: number, externalLink: string): Promise<Story> { const story = await this.getStory(storyPublicId); if (!story) throw new Error(`Story ${storyPublicId} not found`); const currentLinks = story.external_links || []; const updatedLinks = currentLinks.filter( (link) => link.toLowerCase() !== externalLink.toLowerCase(), ); return await this.updateStory(storyPublicId, { external_links: updatedLinks }); } async getStoriesByExternalLink(externalLink: string) { const response = await this.client.getExternalLinkStories({ external_link: externalLink.toLowerCase(), }); const stories = response?.data; if (!stories) return { stories: null, total: null }; return { stories, total: stories.length }; } async setStoryExternalLinks(storyPublicId: number, externalLinks: string[]): Promise<Story> { return await this.updateStory(storyPublicId, { external_links: externalLinks }); } async createDoc(params: CreateDoc): Promise<DocSlim> { const response = await this.client.createDoc(params); const doc = response?.data ?? null; if (!doc) throw new Error(`Failed to create the document: ${response.status}`); return doc; } async uploadFile(storyId: number, filePath: string) { const fileContent = readFileSync(filePath); const fileName = basename(filePath); const file = new File([fileContent], fileName); // biome-ignore lint/suspicious/noExplicitAny: I think the JS API expects a browser File.. but Node's File type is different, but compatible. const response = await this.client.uploadFiles({ story_id: storyId, file0: file as any }); const uploadedFile = response?.data ?? null; if (!uploadedFile?.length) throw new Error(`Failed to upload the file: ${response.status}`); return uploadedFile[0]; } async getCustomFieldMap(customFieldIds: string[]) { await this.loadCustomFields(); return new Map( customFieldIds .map((id) => [id, this.customFieldCache.get(id)]) .filter((customField): customField is [string, CustomField] => customField[1] !== null), ); } async getCustomFields() { await this.loadCustomFields(); return Array.from(this.customFieldCache.values()); } }

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