Skip to main content
Glama
server.ts69.7 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema, } from '@modelcontextprotocol/sdk/types.js' import { ShortcutClient } from './shortcutClient' import type { ShortcutConfig, UpdateStory } from './shortcut-types' import { z } from 'zod' import * as dotenv from 'dotenv' import * as path from 'path' import * as os from 'os' import * as fs from 'fs' import { getMimeTypeDescription, isTextFile } from './mimeTypes' import { extractLoomVideos, formatLoomVideoSummary } from './loomDetector' import { LoomClient } from './loomClient' import type { VideoAnalysisOptions } from './videoAnalysis' import { VideoAnalysisService } from './videoAnalysis' import { VideoCodeAnalyzer } from './videoCodeAnalyzer' import { EnhancedVideoAnalyzer } from './enhancedVideoAnalyzer' import { DebuggingAssistant } from './debuggingAssistant' import { getSmartDefaults, generateBugReportTemplate } from './shortcutHelpers' // Anthropic video analyzer removed - direct video access is not available const ENV_PATH = path.join(os.homedir(), '.shortcut_mcp', '.env') // Helper function to format file sizes function formatFileSize(bytes: number): string { if (bytes === 0) return '0 Bytes' const k = 1024 const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } // Helper function to check response size and provide guidance function checkResponseSize(data: unknown, operation: string): { valid: boolean; message?: string } { const jsonString = JSON.stringify(data, null, 2) const sizeInChars = jsonString.length // Rough estimate: 1 token ≈ 4 characters const estimatedTokens = Math.ceil(sizeInChars / 4) if (estimatedTokens > 20000) { let suggestion = '' switch (operation) { case 'epic_stories': suggestion = 'Try using search with filters like "epic:<id> state:started" or "epic:<id> created:2025-06"' break case 'project_stories': suggestion = 'Try using search with filters like "project:<id> state:started" or "project:<id> label:bug"' break case 'search': suggestion = 'Try adding more specific filters like state, label, created date, or limit to a specific epic/project' break } return { valid: false, message: `Response too large (≈${estimatedTokens} tokens). ${suggestion}`, } } return { valid: true } } if (!fs.existsSync(ENV_PATH)) { console.error(`Error: Environment file not found at ${ENV_PATH}`) console.error('Please create ~/.shortcut_mcp/.env with SHORTCUT_TOKEN=your-token') process.exit(1) } dotenv.config({ path: ENV_PATH }) const SHORTCUT_TOKEN = process.env.SHORTCUT_TOKEN if (!SHORTCUT_TOKEN) { console.error('Error: SHORTCUT_TOKEN not found in ~/.shortcut_mcp/.env') process.exit(1) } const config: ShortcutConfig = { apiToken: SHORTCUT_TOKEN, baseUrl: 'https://api.app.shortcut.com/api/v3', } const shortcutClient = new ShortcutClient(config) // Initialize Loom client (without auth for now, will use public endpoints) const loomClient = new LoomClient({ baseUrl: 'https://www.loom.com/v1', }) const videoAnalysisService = new VideoAnalysisService(loomClient) const GetStorySchema = ToolSchema.extend({ name: z.literal('shortcut_get_story'), description: z.literal('Get details for a specific Shortcut story by ID'), inputSchema: z.object({ story_id: z.number().describe('The numeric ID of the story'), }), }) const SearchStoriesSchema = ToolSchema.extend({ name: z.literal('shortcut_search_stories'), description: z.literal( 'Search for Shortcut stories using a query string. Returns up to 25 results. Use specific filters like "state:", "label:", "created:", "updated:", "epic:", "project:" to narrow results and avoid token limits. Example: "state:started label:bug created:2025-06"' ), inputSchema: z.object({ query: z .string() .describe( 'Search query with filters. Examples: "label:bug state:started", "project:988 type:bug created:2025-06", "epic:123 state:done". Available filters: state, label, type, owner, requester, created, updated, epic, project, is (e.g., is:started, is:done)' ), }), }) const GetStoriesInProjectSchema = ToolSchema.extend({ name: z.literal('shortcut_get_project_stories'), description: z.literal( 'Get stories in a specific project (returns up to 100 stories). For large projects, use search with more specific filters like "project:<id> state:<state>" to avoid token limits' ), inputSchema: z.object({ project_id: z.number().describe('The numeric ID of the project'), }), }) const GetStoriesInEpicSchema = ToolSchema.extend({ name: z.literal('shortcut_get_epic_stories'), description: z.literal( 'Get stories in a specific epic (returns up to 100 stories). For large epics, use search with more specific filters like "epic:<id> state:<state>" to avoid token limits' ), inputSchema: z.object({ epic_id: z.number().describe('The numeric ID of the epic'), }), }) const CreateStorySchema = ToolSchema.extend({ name: z.literal('shortcut_create_story'), description: z.literal('Create a new Shortcut story'), inputSchema: z.object({ name: z.string().describe('The story title'), description: z.string().optional().describe('The story description'), story_type: z.enum(['feature', 'bug', 'chore']).describe('The type of story'), project_id: z.number().describe('The project ID to create the story in'), epic_id: z.number().optional().describe('The epic ID to add the story to'), estimate: z.number().optional().describe('Story point estimate'), labels: z .array(z.object({ name: z.string() })) .optional() .describe('Array of label names to apply'), workflow_state_id: z .number() .optional() .describe('ID of the workflow state (defaults to first state in workflow)'), requested_by_id: z.string().optional().describe('ID of the member who requested the story'), owner_ids: z.array(z.string()).optional().describe('Array of member IDs to assign as owners'), follower_ids: z.array(z.string()).optional().describe('Array of member IDs to follow the story'), deadline: z.string().nullable().optional().describe('Due date in ISO format (YYYY-MM-DD or full datetime)'), iteration_id: z.number().nullable().optional().describe('Sprint/iteration ID'), group_id: z.string().nullable().optional().describe('Team/group ID'), custom_fields: z .array(z.object({ field_id: z.string().describe('Custom field ID'), value_id: z.string().describe('Custom field enum value ID') })) .optional() .describe('Custom field values'), story_template_id: z.string().nullable().optional().describe('Story template ID to associate'), external_links: z.array(z.string()).optional().describe('External URLs to attach'), file_ids: z.array(z.number()).optional().describe('File IDs to attach'), move_to: z.enum(['first', 'last']).optional().describe('Position in workflow state'), }), }) const UpdateStorySchema = ToolSchema.extend({ name: z.literal('shortcut_update_story'), description: z.literal('Update a Shortcut story (description, labels, state, etc.)'), inputSchema: z.object({ story_id: z.number().describe('The numeric ID of the story to update'), description: z.string().optional().describe('New description for the story'), estimate: z.number().nullable().optional().describe('Story point estimate'), labels: z .array(z.object({ name: z.string() })) .optional() .describe('Array of label names to apply'), workflow_state_id: z .number() .optional() .describe('ID of the workflow state to move story to'), project_id: z.number().optional().describe('ID of the project to move story to'), epic_id: z .number() .nullable() .optional() .describe('ID of the epic (null to remove from epic)'), }), }) const AddCommentSchema = ToolSchema.extend({ name: z.literal('shortcut_add_comment'), description: z.literal('Add a comment to a Shortcut story'), inputSchema: z.object({ story_id: z.number().describe('The numeric ID of the story'), text: z.string().describe('The comment text to add'), }), }) const GetCommentsSchema = ToolSchema.extend({ name: z.literal('shortcut_get_comments'), description: z.literal('Get all comments for a Shortcut story'), inputSchema: z.object({ story_id: z.number().describe('The numeric ID of the story'), }), }) const GetStoryFilesSchema = ToolSchema.extend({ name: z.literal('shortcut_get_story_files'), description: z.literal('Get all file attachments for a story'), inputSchema: z.object({ story_id: z.number().describe('The numeric ID of the story'), }), }) const DownloadFileSchema = ToolSchema.extend({ name: z.literal('shortcut_download_file'), description: z.literal('Download file content from a story attachment'), inputSchema: z.object({ file_url: z.string().describe('The URL of the file to download'), as_text: z.boolean().optional().describe('Whether to return text files as strings'), }), }) const GetStoryLoomVideosSchema = ToolSchema.extend({ name: z.literal('shortcut_get_story_loom_videos'), description: z.literal('Extract all Loom video links from a story'), inputSchema: z.object({ story_id: z.number().describe('The numeric ID of the story'), }), }) const AnalyzeLoomVideoSchema = ToolSchema.extend({ name: z.literal('shortcut_analyze_loom_video'), description: z.literal('Analyze a Loom video for debugging information'), inputSchema: z.object({ video_url: z.string().describe('The Loom video URL'), include_transcript: z .boolean() .optional() .default(true) .describe('Include transcript analysis'), detect_accounts: z .boolean() .optional() .default(true) .describe('Detect account information'), }), }) const AnalyzeVideoWithCodebaseSchema = ToolSchema.extend({ name: z.literal('shortcut_analyze_video_with_codebase'), description: z.literal( 'Analyze a Loom video and correlate it with the codebase to find relevant files' ), inputSchema: z.object({ story_id: z.number().optional().describe('The story ID to get video from'), video_url: z.string().optional().describe('Direct Loom video URL (if no story_id)'), codebase_path: z.string().describe('Path to the codebase to analyze'), search_depth: z .number() .optional() .default(20) .describe('Maximum number of files to return'), }), }) const DebugVideoIssueSchema = ToolSchema.extend({ name: z.literal('shortcut_debug_video_issue'), description: z.literal( 'Analyze a Loom video to debug issues with detailed action tracking and debugging suggestions' ), inputSchema: z.object({ story_id: z.number().optional().describe('The story ID to get video from'), video_url: z.string().optional().describe('Direct Loom video URL (if no story_id)'), codebase_path: z.string().describe('Path to the codebase to analyze'), include_debugging_plan: z .boolean() .optional() .default(true) .describe('Include detailed debugging plan'), }), }) const AnalyzeVideoWithAnthropicSchema = ToolSchema.extend({ name: z.literal('shortcut_analyze_video_with_anthropic'), description: z.literal( 'Use Claude Vision to analyze Loom video frames and audio for comprehensive debugging insights' ), inputSchema: z.object({ story_id: z.number().optional().describe('The story ID to get video from'), video_url: z.string().optional().describe('Direct Loom video URL (if no story_id)'), anthropic_api_key: z.string().describe('Anthropic API key for Claude Vision'), max_frames: z.number().optional().default(20).describe('Maximum frames to analyze'), include_audio: z.boolean().optional().default(true).describe('Include audio analysis'), codebase_path: z .string() .optional() .describe('Path to codebase for mapping UI text to source files'), }), }) // Add timestamp to verify reloads const startTime = new Date().toISOString() console.error(`[HOT RELOAD TEST] Server initialized at: ${startTime}`) const server = new Server( { name: 'shortcut-mcp', vendor: 'shortcut-api', version: '1.0.1-hot-reload-test', }, { capabilities: { tools: {}, }, } ) server.setRequestHandler(ListToolsRequestSchema, () => { return { tools: [ { name: 'shortcut_get_story', description: 'Get details for a specific Shortcut story by ID', inputSchema: { type: 'object', properties: { story_id: { type: 'number', description: 'The numeric ID of the story', }, }, required: ['story_id'], }, }, { name: 'shortcut_search_stories', description: 'Search for Shortcut stories using a query string', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query (e.g., "bug in login", "project:mobile")', }, }, required: ['query'], }, }, { name: 'shortcut_get_project_stories', description: 'Get all stories in a specific project', inputSchema: { type: 'object', properties: { project_id: { type: 'number', description: 'The numeric ID of the project', }, }, required: ['project_id'], }, }, { name: 'shortcut_get_epic_stories', description: 'Get all stories in a specific epic', inputSchema: { type: 'object', properties: { epic_id: { type: 'number', description: 'The numeric ID of the epic', }, }, required: ['epic_id'], }, }, { name: 'shortcut_create_story', description: 'Create a new Shortcut story', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'The story title', }, description: { type: 'string', description: 'The story description', }, story_type: { type: 'string', enum: ['feature', 'bug', 'chore'], description: 'The type of story', }, project_id: { type: 'number', description: 'The project ID to create the story in', }, epic_id: { type: 'number', description: 'The epic ID to add the story to', }, estimate: { type: 'number', description: 'Story point estimate', }, labels: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, }, required: ['name'], }, description: 'Array of label names to apply', }, workflow_state_id: { type: 'number', description: 'ID of the workflow state (defaults to first state in workflow)', }, requested_by_id: { type: 'string', description: 'ID of the member who requested the story', }, owner_ids: { type: 'array', items: { type: 'string' }, description: 'Array of member IDs to assign as owners', }, follower_ids: { type: 'array', items: { type: 'string' }, description: 'Array of member IDs to follow the story', }, deadline: { type: ['string', 'null'], description: 'Due date in ISO format (YYYY-MM-DD or full datetime)', }, iteration_id: { type: ['number', 'null'], description: 'Sprint/iteration ID', }, group_id: { type: ['string', 'null'], description: 'Team/group ID', }, custom_fields: { type: 'array', items: { type: 'object', properties: { field_id: { type: 'string', description: 'Custom field ID' }, value_id: { type: 'string', description: 'Custom field enum value ID' }, }, required: ['field_id', 'value_id'], }, description: 'Custom field values', }, story_template_id: { type: ['string', 'null'], description: 'Story template ID to associate', }, external_links: { type: 'array', items: { type: 'string' }, description: 'External URLs to attach', }, file_ids: { type: 'array', items: { type: 'number' }, description: 'File IDs to attach', }, move_to: { type: 'string', enum: ['first', 'last'], description: 'Position in workflow state', }, }, required: ['name', 'story_type', 'project_id'], }, }, { name: 'shortcut_update_story', description: 'Update a Shortcut story (description, labels, state, etc.)', inputSchema: { type: 'object', properties: { story_id: { type: 'number', description: 'The numeric ID of the story to update', }, description: { type: 'string', description: 'New description for the story', }, estimate: { type: ['number', 'null'], description: 'Story point estimate', }, labels: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, }, required: ['name'], }, description: 'Array of label names to apply', }, workflow_state_id: { type: 'number', description: 'ID of the workflow state to move story to', }, project_id: { type: 'number', description: 'ID of the project to move story to', }, epic_id: { type: ['number', 'null'], description: 'ID of the epic (null to remove from epic)', }, }, required: ['story_id'], }, }, { name: 'shortcut_add_comment', description: 'Add a comment to a Shortcut story', inputSchema: { type: 'object', properties: { story_id: { type: 'number', description: 'The numeric ID of the story', }, text: { type: 'string', description: 'The comment text to add', }, }, required: ['story_id', 'text'], }, }, { name: 'shortcut_get_comments', description: 'Get all comments for a Shortcut story', inputSchema: { type: 'object', properties: { story_id: { type: 'number', description: 'The numeric ID of the story', }, }, required: ['story_id'], }, }, { name: 'shortcut_list_workflows', description: 'List all workflows and their states', inputSchema: { type: 'object', properties: {}, }, }, { name: 'shortcut_list_projects', description: 'List all projects in the workspace', inputSchema: { type: 'object', properties: {}, }, }, { name: 'shortcut_list_epics', description: 'List all epics in the workspace', inputSchema: { type: 'object', properties: {}, }, }, { name: 'shortcut_get_story_files', description: 'Get all file attachments for a story', inputSchema: { type: 'object', properties: { story_id: { type: 'number', description: 'The numeric ID of the story', }, }, required: ['story_id'], }, }, { name: 'shortcut_download_file', description: 'Download file content from a story attachment', inputSchema: { type: 'object', properties: { file_url: { type: 'string', description: 'The URL of the file to download', }, as_text: { type: 'boolean', description: 'Whether to return text files as strings (default: true)', }, }, required: ['file_url'], }, }, { name: 'shortcut_get_story_loom_videos', description: 'Extract all Loom video links from a story', inputSchema: { type: 'object', properties: { story_id: { type: 'number', description: 'The numeric ID of the story', }, }, required: ['story_id'], }, }, { name: 'shortcut_analyze_loom_video', description: 'Analyze a Loom video for debugging information', inputSchema: { type: 'object', properties: { video_url: { type: 'string', description: 'The Loom video URL', }, include_transcript: { type: 'boolean', description: 'Include transcript analysis (default: true)', }, detect_accounts: { type: 'boolean', description: 'Detect account information (default: true)', }, }, required: ['video_url'], }, }, { name: 'shortcut_analyze_video_with_codebase', description: 'Analyze a Loom video and correlate it with the codebase to find relevant files', inputSchema: { type: 'object', properties: { story_id: { type: 'number', description: 'The story ID to get video from', }, video_url: { type: 'string', description: 'Direct Loom video URL (if no story_id)', }, codebase_path: { type: 'string', description: 'Path to the codebase to analyze', }, search_depth: { type: 'number', description: 'Maximum number of files to return (default: 20)', }, }, required: ['codebase_path'], }, }, { name: 'shortcut_debug_video_issue', description: 'Analyze a Loom video to debug issues with detailed action tracking and debugging suggestions', inputSchema: { type: 'object', properties: { story_id: { type: 'number', description: 'The story ID to get video from', }, video_url: { type: 'string', description: 'Direct Loom video URL (if no story_id)', }, codebase_path: { type: 'string', description: 'Path to the codebase to analyze', }, include_debugging_plan: { type: 'boolean', description: 'Include detailed debugging plan (default: true)', }, }, required: ['codebase_path'], }, }, { name: 'shortcut_analyze_video_with_anthropic', description: 'Use Claude Vision to analyze Loom video frames and audio for comprehensive debugging insights', inputSchema: { type: 'object', properties: { story_id: { type: 'number', description: 'The story ID to get video from', }, video_url: { type: 'string', description: 'Direct Loom video URL (if no story_id)', }, anthropic_api_key: { type: 'string', description: 'Anthropic API key for Claude Vision', }, max_frames: { type: 'number', description: 'Maximum frames to analyze (default: 20)', }, include_audio: { type: 'boolean', description: 'Include audio analysis (default: true)', }, }, required: ['anthropic_api_key'], }, }, ], } }) server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params switch (name) { case 'shortcut_get_story': { const { story_id } = GetStorySchema.shape.inputSchema.parse(args) const story = await shortcutClient.getStory(story_id) return { content: [ { type: 'text', text: JSON.stringify(story, null, 2), }, ], } } case 'shortcut_search_stories': { const { query } = SearchStoriesSchema.shape.inputSchema.parse(args) const results = await shortcutClient.searchStories(query) const sizeCheck = checkResponseSize(results, 'search') if (!sizeCheck.valid) { return { content: [ { type: 'text', text: `Error: ${sizeCheck.message}\n\nFound ${results.stories.total} stories and ${results.epics.total} epics matching "${query}".`, }, ], } } return { content: [ { type: 'text', text: JSON.stringify(results, null, 2), }, ], } } case 'shortcut_get_project_stories': { const { project_id } = GetStoriesInProjectSchema.shape.inputSchema.parse(args) const stories = await shortcutClient.getStoriesInProject(project_id) const sizeCheck = checkResponseSize(stories, 'project_stories') if (!sizeCheck.valid) { return { content: [ { type: 'text', text: `Error: ${sizeCheck.message}\n\nProject ${project_id} contains ${stories.length} stories (showing up to 100).`, }, ], } } return { content: [ { type: 'text', text: JSON.stringify(stories, null, 2), }, ], } } case 'shortcut_get_epic_stories': { const { epic_id } = GetStoriesInEpicSchema.shape.inputSchema.parse(args) const stories = await shortcutClient.getStoriesInEpic(epic_id) const sizeCheck = checkResponseSize(stories, 'epic_stories') if (!sizeCheck.valid) { return { content: [ { type: 'text', text: `Error: ${sizeCheck.message}\n\nEpic ${epic_id} contains ${stories.length} stories (showing up to 100).`, }, ], } } return { content: [ { type: 'text', text: JSON.stringify(stories, null, 2), }, ], } } case 'shortcut_create_story': { const params = CreateStorySchema.shape.inputSchema.parse(args) // Get workflows to determine smart defaults const workflows = await shortcutClient.listWorkflows() // Apply smart defaults const smartDefaults = getSmartDefaults(params, workflows) // Build the create params object with proper typing, merging defaults const createParams: Parameters<typeof shortcutClient.createStory>[0] = { name: smartDefaults.name || params.name, story_type: params.story_type, project_id: params.project_id, } // Add optional fields only if they're defined (prefer user values over defaults) if (params.description !== undefined) createParams.description = params.description if (params.epic_id !== undefined) createParams.epic_id = params.epic_id else if (smartDefaults.epic_id) createParams.epic_id = smartDefaults.epic_id if (params.estimate !== undefined) createParams.estimate = params.estimate if (params.labels !== undefined) createParams.labels = params.labels else if (smartDefaults.labels) createParams.labels = smartDefaults.labels if (params.workflow_state_id !== undefined) createParams.workflow_state_id = params.workflow_state_id else if (smartDefaults.workflow_state_id) createParams.workflow_state_id = smartDefaults.workflow_state_id if (params.requested_by_id !== undefined) createParams.requested_by_id = params.requested_by_id if (params.owner_ids !== undefined) createParams.owner_ids = params.owner_ids if (params.follower_ids !== undefined) createParams.follower_ids = params.follower_ids if (params.deadline !== undefined) createParams.deadline = params.deadline else if (smartDefaults.deadline) createParams.deadline = smartDefaults.deadline if (params.iteration_id !== undefined) createParams.iteration_id = params.iteration_id if (params.group_id !== undefined) createParams.group_id = params.group_id if (params.custom_fields !== undefined) createParams.custom_fields = params.custom_fields else if (smartDefaults.custom_fields) createParams.custom_fields = smartDefaults.custom_fields if (params.story_template_id !== undefined) createParams.story_template_id = params.story_template_id else if (smartDefaults.story_template_id) createParams.story_template_id = smartDefaults.story_template_id if (params.external_links !== undefined) createParams.external_links = params.external_links if (params.file_ids !== undefined) createParams.file_ids = params.file_ids if (params.move_to !== undefined) createParams.move_to = params.move_to const story = await shortcutClient.createStory(createParams) return { content: [ { type: 'text', text: `Story created successfully:\n${JSON.stringify(story, null, 2)}`, }, ], } } case 'shortcut_update_story': { const update = UpdateStorySchema.shape.inputSchema.parse(args) const { story_id, ...updateData } = update // Build update object with only defined fields const cleanedUpdate: UpdateStory = {} if (updateData.description !== undefined) cleanedUpdate.description = updateData.description if (updateData.estimate !== undefined) cleanedUpdate.estimate = updateData.estimate if (updateData.labels !== undefined) cleanedUpdate.labels = updateData.labels if (updateData.workflow_state_id !== undefined) cleanedUpdate.workflow_state_id = updateData.workflow_state_id if (updateData.project_id !== undefined) cleanedUpdate.project_id = updateData.project_id if (updateData.epic_id !== undefined) cleanedUpdate.epic_id = updateData.epic_id const story = await shortcutClient.updateStory(story_id, cleanedUpdate) return { content: [ { type: 'text', text: `Story ${story_id} updated successfully:\n${JSON.stringify(story, null, 2)}`, }, ], } } case 'shortcut_add_comment': { const { story_id, text } = AddCommentSchema.shape.inputSchema.parse(args) const comment = await shortcutClient.addComment(story_id, { text }) return { content: [ { type: 'text', text: `Comment added to story ${story_id}:\n${JSON.stringify(comment, null, 2)}`, }, ], } } case 'shortcut_get_comments': { const { story_id } = GetCommentsSchema.shape.inputSchema.parse(args) const comments = await shortcutClient.getComments(story_id) return { content: [ { type: 'text', text: JSON.stringify(comments, null, 2), }, ], } } case 'shortcut_list_workflows': { const workflows = await shortcutClient.getWorkflows() return { content: [ { type: 'text', text: JSON.stringify(workflows, null, 2), }, ], } } case 'shortcut_list_projects': { const projects = await shortcutClient.getProjects() return { content: [ { type: 'text', text: JSON.stringify(projects, null, 2), }, ], } } case 'shortcut_list_epics': { const epics = await shortcutClient.getEpics() return { content: [ { type: 'text', text: JSON.stringify(epics, null, 2), }, ], } } case 'shortcut_get_story_files': { const { story_id } = GetStoryFilesSchema.shape.inputSchema.parse(args) const files = await shortcutClient.getStoryFiles(story_id) // Enhance file info with mime type descriptions const enhancedFiles = files.map((file) => ({ ...file, content_type_description: getMimeTypeDescription( file.content_type || 'unknown' ), size_readable: formatFileSize(file.size), })) return { content: [ { type: 'text', text: JSON.stringify(enhancedFiles, null, 2), }, ], } } case 'shortcut_download_file': { const { file_url, as_text = true } = DownloadFileSchema.shape.inputSchema.parse(args) const { content, contentType } = await shortcutClient.downloadFile(file_url) const isText = isTextFile(contentType, file_url) if (as_text && isText) { return { content: [ { type: 'text', text: content.toString('utf-8'), }, ], } } else { return { content: [ { type: 'text', text: JSON.stringify( { contentType, contentTypeDescription: getMimeTypeDescription(contentType), isTextFile: isText, base64Content: content.toString('base64'), size: content.length, sizeReadable: formatFileSize(content.length), }, null, 2 ), }, ], } } } case 'shortcut_get_story_loom_videos': { const { story_id } = GetStoryLoomVideosSchema.shape.inputSchema.parse(args) const story = await shortcutClient.getStory(story_id) const loomVideos = extractLoomVideos(story) const summary = formatLoomVideoSummary(loomVideos) return { content: [ { type: 'text', text: summary + '\n\n' + JSON.stringify(loomVideos, null, 2), }, ], } } case 'shortcut_analyze_loom_video': { const { video_url, include_transcript = true, detect_accounts = true, } = AnalyzeLoomVideoSchema.shape.inputSchema.parse(args) const videoId = LoomClient.extractVideoId(video_url) if (!videoId) { return { content: [ { type: 'text', text: 'Error: Invalid Loom URL. Could not extract video ID.', }, ], } } const analysisOptions: VideoAnalysisOptions = { includeTranscript: include_transcript, extractKeyFrames: false, // Not implemented yet detectAccounts: detect_accounts, analyzeFeatures: true, } const loomVideo = { url: video_url, videoId, source: 'manual' as const, } try { const analysis = await videoAnalysisService.analyzeVideo( loomVideo, analysisOptions ) return { content: [ { type: 'text', text: JSON.stringify(analysis, null, 2), }, ], } } catch (error) { console.error('Error analyzing Loom video:', error) console.error('Stack trace:', error instanceof Error ? error.stack : 'No stack') return { content: [ { type: 'text', text: `Error analyzing video: ${error instanceof Error ? error.message : 'Unknown error'}\nStack: ${error instanceof Error ? error.stack : 'No stack'}`, }, ], } } } case 'shortcut_analyze_video_with_codebase': { const { story_id, video_url, codebase_path, search_depth = 20, } = AnalyzeVideoWithCodebaseSchema.shape.inputSchema.parse(args) try { let videoUrlToAnalyze: string if (story_id) { // Get video from story const story = await shortcutClient.getStory(story_id) const loomVideos = extractLoomVideos(story) if (loomVideos.length === 0) { return { content: [ { type: 'text', text: `No Loom videos found in story ${story_id}`, }, ], } } // Use the first video found videoUrlToAnalyze = loomVideos[0].url } else if (video_url) { videoUrlToAnalyze = video_url } else { return { content: [ { type: 'text', text: 'Error: Either story_id or video_url must be provided', }, ], } } // Create analyzer const analyzer = new VideoCodeAnalyzer( loomClient, videoAnalysisService, codebase_path ) // Analyze video with codebase const analysis = await analyzer.analyzeVideoWithCodebase(videoUrlToAnalyze) // Format the response let response = `# Video-to-Code Analysis\n\n` response += `## Video: ${analysis.videoTitle}\n` response += `Video ID: ${analysis.videoId}\n` response += `Confidence Score: ${(analysis.confidenceScore * 100).toFixed(0)}%\n\n` if (analysis.analysisNotes.length > 0) { response += `## Analysis Summary\n` analysis.analysisNotes.forEach((note) => { response += `- ${note}\n` }) response += '\n' } if (analysis.codeContext.components.length > 0) { response += `## Components Identified\n` response += analysis.codeContext.components.slice(0, 10).join(', ') + '\n\n' } if (analysis.codeContext.routes.length > 0) { response += `## Routes/Pages\n` response += analysis.codeContext.routes.slice(0, 10).join(', ') + '\n\n' } if (analysis.relevantFiles.length > 0) { response += `## Relevant Files (${analysis.relevantFiles.length} found)\n\n` analysis.relevantFiles.slice(0, search_depth).forEach((file, index) => { response += `### ${index + 1}. ${file.path}\n` response += `**Relevance:** ${(file.relevance * 100).toFixed(0)}%\n` response += `**Reason:** ${file.reason}\n` if (file.snippets && file.snippets.length > 0) { response += `**Key snippets:**\n` file.snippets.slice(0, 3).forEach((snippet) => { response += `- Line ${snippet.line}: \`${snippet.content.slice(0, 80)}${snippet.content.length > 80 ? '...' : ''}\`\n` }) } response += '\n' }) } if (analysis.suggestedFiles.length > 0) { response += `## Additional Suggested Files\n` analysis.suggestedFiles.slice(0, 10).forEach((file) => { response += `- ${file}\n` }) } return { content: [ { type: 'text', text: response, }, ], } } catch (error) { console.error('Error analyzing video with codebase:', error) return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], } } } case 'shortcut_debug_video_issue': { const { story_id, video_url, codebase_path, include_debugging_plan = true, } = DebugVideoIssueSchema.shape.inputSchema.parse(args) try { let videoUrlToAnalyze: string if (story_id) { // Get video from story const story = await shortcutClient.getStory(story_id) const loomVideos = extractLoomVideos(story) if (loomVideos.length === 0) { return { content: [ { type: 'text', text: `No Loom videos found in story ${story_id}`, }, ], } } videoUrlToAnalyze = loomVideos[0].url } else if (video_url) { videoUrlToAnalyze = video_url } else { return { content: [ { type: 'text', text: 'Error: Either story_id or video_url must be provided', }, ], } } // Create enhanced analyzer const enhancedAnalyzer = new EnhancedVideoAnalyzer( loomClient, videoAnalysisService, codebase_path ) // Perform deep analysis const analysis = await enhancedAnalyzer.analyzeVideoInDepth(videoUrlToAnalyze) // Format the response let response = `# Video Debugging Analysis\n\n` response += `## Video: ${analysis.videoTitle}\n` response += `Video ID: ${analysis.videoId}\n\n` // Screen flow summary if (analysis.screenStates.length > 0) { response += `## User Journey\n` const routes = [ ...new Set(analysis.screenStates.map((s) => s.route).filter((r) => r)), ] response += `Pages visited: ${routes.join(' → ')}\n\n` } // User actions summary if (analysis.userActions.length > 0) { response += `## User Actions (${analysis.userActions.length} total)\n` const failedActions = analysis.userActions.filter( (a) => a.result === 'error' ) if (failedActions.length > 0) { response += `⚠️ ${failedActions.length} actions failed:\n` failedActions.forEach((action) => { response += `- ${action.type} on "${action.target}" at ${action.timestamp}s\n` }) } response += '\n' } // Errors detected if (analysis.errors.length > 0) { response += `## Errors Detected\n` analysis.errors.forEach((error, index) => { response += `### ${index + 1}. ${error.errorMessage}\n` response += `Type: ${error.errorType}\n` response += `Possible causes:\n` error.possibleCauses.forEach((cause) => { response += `- ${cause}\n` }) if (error.relatedCode.length > 0) { response += `Related code:\n` error.relatedCode.slice(0, 3).forEach((ref) => { response += `- ${ref.file}:${ref.line} (${ref.type})\n` }) } response += '\n' }) } // Reproducible steps if (analysis.reproducibleSteps.length > 0) { response += `## Steps to Reproduce\n` analysis.reproducibleSteps.forEach((step) => { response += `${step.order}. ${step.action}\n` response += ` Expected: ${step.expectedResult}\n` if (step.actualResult) { response += ` Actual: ${step.actualResult} ❌\n` } }) response += '\n' } // Audio analysis results if (analysis.audioAnalysis) { response += `## Audio Analysis\n\n` // Verbal issues if (analysis.audioAnalysis.verbalIssues.length > 0) { response += `### Verbal Issues Detected\n` analysis.audioAnalysis.verbalIssues.forEach((issue, index) => { response += `${index + 1}. **${issue.type}** (${issue.severity} severity, ${(issue.confidence * 100).toFixed(0)}% confidence)\n` response += ` Time: ${issue.timestamp}s\n` response += ` Quote: "${issue.text}"\n` response += ` Keywords: ${issue.keywords.join(', ')}\n\n` }) } // Speaker intent if (analysis.audioAnalysis.speakerIntents.length > 0) { response += `### Speaker Intent\n` const intents = [ ...new Set( analysis.audioAnalysis.speakerIntents.map((i) => i.intent) ), ] response += `User is: ${intents.join(', ')}\n\n` } // Key phrases if (analysis.audioAnalysis.keyPhrases.length > 0) { response += `### Frequently Mentioned\n` analysis.audioAnalysis.keyPhrases.slice(0, 5).forEach((phrase) => { response += `- "${phrase.phrase}" (${phrase.frequency}x)\n` }) response += '\n' } // Problem statements if (analysis.audioAnalysis.problemStatements.length > 0) { response += `### Problem Statements\n` analysis.audioAnalysis.problemStatements.forEach((problem, index) => { response += `${index + 1}. At ${problem.timestamp}s:\n` if (problem.expectedBehavior && problem.actualBehavior) { response += ` Expected: ${problem.expectedBehavior}\n` response += ` Actual: ${problem.actualBehavior}\n` } else { response += ` "${problem.statement}"\n` } if (problem.userImpact) { response += ` Impact: ${problem.userImpact}\n` } response += '\n' }) } // Emotional tone const frustrationCount = analysis.audioAnalysis.emotionalTone.filter( (t) => t.tone === 'frustrated' ).length const confusionCount = analysis.audioAnalysis.emotionalTone.filter( (t) => t.tone === 'confused' ).length if (frustrationCount > 0 || confusionCount > 0) { response += `### User Experience\n` if (frustrationCount > 0) { response += `- User expressed frustration ${frustrationCount} time(s)\n` } if (confusionCount > 0) { response += `- User expressed confusion ${confusionCount} time(s)\n` } response += '\n' } } // Verbal-Visual correlations if ( analysis.verbalVisualCorrelations && analysis.verbalVisualCorrelations.length > 0 ) { response += `## Verbal-Visual Correlations\n` analysis.verbalVisualCorrelations .filter((c) => c.correlationScore > 0.5) .slice(0, 5) .forEach((correlation, index) => { response += `${index + 1}. ${correlation.combinedInsight}\n` response += ` Confidence: ${(correlation.correlationScore * 100).toFixed(0)}%\n\n` }) } // Debugging plan if (include_debugging_plan) { const debuggingAssistant = new DebuggingAssistant() const plan = debuggingAssistant.generateDebuggingPlan(analysis) response += plan.summary + '\n' response += `## Investigation Steps\n` plan.investigationSteps.forEach((step) => { response += `${step.order}. ${step.action}\n` if (step.targetFile) { response += ` File: ${step.targetFile}${step.targetLine ? ':' + step.targetLine : ''}\n` } response += ` Tools: ${step.tools.join(', ')}\n` response += ` Expected: ${step.expectedOutcome}\n\n` }) if (plan.codeChangeSuggestions.length > 0) { response += `## Suggested Code Changes\n` plan.codeChangeSuggestions.forEach((suggestion, index) => { response += `### ${index + 1}. ${suggestion.description}\n` response += `File: ${suggestion.file}\n` response += `Type: ${suggestion.type}\n` response += `Priority: ${suggestion.priority}\n` if (suggestion.code) { response += `\`\`\`javascript\n${suggestion.code}\n\`\`\`\n` } }) } } return { content: [ { type: 'text', text: response, }, ], } } catch (error) { console.error('Error in debug video analysis:', error) return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], } } } case 'shortcut_analyze_video_with_anthropic': { const { story_id, video_url } = AnalyzeVideoWithAnthropicSchema.shape.inputSchema.parse(args) try { let videoId: string if (story_id) { // Get video from story const story = await shortcutClient.getStory(story_id) const loomVideos = extractLoomVideos(story) if (loomVideos.length === 0) { return { content: [ { type: 'text', text: `No Loom videos found in story ${story_id}`, }, ], } } videoId = loomVideos[0].videoId } else if (video_url) { videoId = LoomClient.extractVideoId(video_url) || '' if (!videoId) { return { content: [ { type: 'text', text: 'Error: Invalid Loom URL', }, ], } } } else { return { content: [ { type: 'text', text: 'Error: Either story_id or video_url must be provided', }, ], } } // Anthropic analyzer removed - direct video access is not available // Video download functionality has been removed // Direct video access is not available from Loom return { content: [ { type: 'text', text: 'Error: Video download functionality has been removed. Direct video access is not available from Loom. The Anthropic video analysis feature requires downloading video files which is no longer supported.', }, ], } } catch (error) { console.error('Error in Anthropic video analysis:', error) return { content: [ { type: 'text', text: `Error analyzing video: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], } } } default: return { content: [ { type: 'text', text: `Unknown tool: ${name}`, }, ], } } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' let responseData = '' if ( error instanceof Error && 'response' in error && (error as unknown as { response?: { data?: unknown } }).response?.data ) { responseData = `\n${JSON.stringify((error as unknown as { response: { data: unknown } }).response.data, null, 2)}` } return { content: [ { type: 'text', text: `Error: ${errorMessage}${responseData}`, }, ], } } }) async function main() { const transport = new StdioServerTransport() await server.connect(transport) console.error('Shortcut MCP server running') } main().catch((error) => { console.error('Server error:', error) process.exit(1) })

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/currentspace/shortcut_mcp'

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