server.ts•69.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)
})