UNPKG

@mgsoftwarebv/mcp-server-bridge

Version:

MCP Server bridge for MG Tickets - connects Cursor to HTTP MCP server with image support and GitHub code exploration

1,219 lines (1,202 loc) 85 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { ListToolsRequestSchema, ListResourcesRequestSchema, CallToolRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { createClient } from '@supabase/supabase-js'; import { createHash } from 'crypto'; import { Octokit } from '@octokit/rest'; var args = process.argv.slice(2); var apiKey = args.find((arg) => arg.startsWith("--api-key="))?.split("=")[1] || process.env.MG_TICKETS_API_KEY; var supabaseUrl = args.find((arg) => arg.startsWith("--supabase-url="))?.split("=")[1] || process.env.SUPABASE_URL || "https://cvjdbczxyczjnatuolsk.supabase.co"; var supabaseKey = args.find((arg) => arg.startsWith("--supabase-key="))?.split("=")[1] || process.env.SUPABASE_SERVICE_ROLE_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImN2amRiY3p4eWN6am5hdHVvbHNrIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1NjE0NzcyNCwiZXhwIjoyMDcxNzIzNzI0fQ.LljuNdCZXDcSIVTeIVOSNsvGNBfWsIM1QIswBJmGXKE"; if (!apiKey) { console.error("\u274C API key is required. Use --api-key=your_key or set MG_TICKETS_API_KEY environment variable"); process.exit(1); } var supabase = createClient(supabaseUrl, supabaseKey); function roundToNearest15Minutes(minutes) { if (minutes <= 0) return 0; return Math.round(minutes / 15) * 15; } async function getAccessibleTeamIds(teamId) { const { data: accessibleTeams } = await supabase.from("teams").select("id").or(`id.eq.${teamId},parent_team_id.eq.${teamId}`); return accessibleTeams?.map((t) => t.id) || [teamId]; } async function getAccessibleProjectIds(userId, teamId) { const { data, error } = await supabase.rpc("get_accessible_project_ids", { p_user_id: userId, p_team_id: teamId }); if (error) { console.error("\u274C Error getting accessible project IDs:", error); return []; } return data?.map((row) => row.project_id) || []; } async function getAccessibleCustomerIds(teamId) { const teamIds = await getAccessibleTeamIds(teamId); const { data: ownCustomers } = await supabase.from("customers").select("id").in("team_id", teamIds); const ownCustomerIds = ownCustomers?.map((c) => c.id) || []; const { data: sharedCustomers } = await supabase.from("customer_shared_teams").select("customer_id").eq("team_id", teamId); const sharedCustomerIds = sharedCustomers?.map((c) => c.customer_id) || []; return [.../* @__PURE__ */ new Set([...ownCustomerIds, ...sharedCustomerIds])]; } async function validateApiKey(key) { if (!key.startsWith("mid_") || key.length !== 68) { console.error("\u{1F511} Invalid API key format"); return null; } try { const keyHash = createHash("sha256").update(key).digest("hex"); console.error(`\u{1F50D} Validating API key hash: ${keyHash.substring(0, 16)}...`); const { data: apiKeyData, error } = await supabase.from("api_keys").select("id, user_id, team_id, scopes, last_used_at").eq("key_hash", keyHash).single(); if (error || !apiKeyData) { console.error("\u274C API key not found or invalid:", error?.message); return null; } await supabase.from("api_keys").update({ last_used_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", apiKeyData.id); console.error(`\u2705 API key validated for user ${apiKeyData.user_id} in team ${apiKeyData.team_id}`); return { userId: apiKeyData.user_id, teamId: apiKeyData.team_id, scopes: apiKeyData.scopes || [] }; } catch (error) { console.error("\u{1F4A5} API key validation error:", error); return null; } } var authContext = null; function isImageFile(mimeType) { return mimeType.startsWith("image/") && ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"].includes(mimeType); } async function downloadImageAsBase64(storageKey) { try { const { data: urlData, error: urlError } = await supabase.storage.from("vault").createSignedUrl(storageKey, 3600); if (urlError || !urlData?.signedUrl) { console.error(`Failed to create signed URL for ${storageKey}:`, urlError); return null; } const response = await fetch(urlData.signedUrl); if (!response.ok) { console.error(`Failed to download file ${storageKey}: ${response.status}`); return null; } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); return buffer.toString("base64"); } catch (error) { console.error(`Error downloading image ${storageKey}:`, error); return null; } } async function getGithubTokenForProject(projectId, teamId) { try { const { data: repoData, error: repoError } = await supabase.from("project_github_repositories").select("repository_full_name").eq("project_id", projectId).eq("team_id", teamId).single(); if (repoError || !repoData) { console.error(`No GitHub repository linked to project ${projectId}`); return null; } const { data: appData, error: appError } = await supabase.from("apps").select("config").eq("team_id", teamId).eq("app_id", "github").single(); if (appError || !appData?.config?.access_token) { console.error(`GitHub app not connected for team ${teamId}`); return null; } const accessToken = appData.config.access_token; const repositoryFullName = repoData.repository_full_name; const [owner, repo] = repositoryFullName.split("/"); if (!owner || !repo) { console.error(`Invalid repository full name: ${repositoryFullName}`); return null; } return { token: accessToken, repositoryFullName, owner, repo }; } catch (error) { console.error("Error getting GitHub token for project:", error); return null; } } async function transitionToNextPhase(sessionId, currentPhase) { try { const now = /* @__PURE__ */ new Date(); const phaseOrder = ["analysis", "bug_investigation", "development", "communication"]; const { data: allPhases, error: fetchError } = await supabase.from("ai_time_logs").select("*").eq("ai_session_id", sessionId).order("activity_type"); if (fetchError || !allPhases) { console.error("Failed to fetch phases for transition:", fetchError); return; } let currentPhaseType = currentPhase; if (!currentPhaseType) { const activePhase = allPhases.find((p) => p.status === "in_progress"); currentPhaseType = activePhase?.activity_type; } if (!currentPhaseType) { const analysisPhase = allPhases.find((p) => p.activity_type === "analysis"); if (analysisPhase && analysisPhase.status === "pending" && analysisPhase.estimated_duration_seconds > 0) { await supabase.from("ai_time_logs").update({ status: "in_progress", started_at: now.toISOString() }).eq("id", analysisPhase.id); console.error("\u2705 Started analysis phase"); } return; } const currentPhaseRecord = allPhases.find((p) => p.activity_type === currentPhaseType && p.status === "in_progress"); if (currentPhaseRecord) { const duration = Math.round((now.getTime() - new Date(currentPhaseRecord.started_at).getTime()) / 1e3); await supabase.from("ai_time_logs").update({ status: "completed", ended_at: now.toISOString(), duration_seconds: duration }).eq("id", currentPhaseRecord.id); console.error(`\u2705 Completed phase: ${currentPhaseType} (${duration}s)`); } const currentIndex = phaseOrder.indexOf(currentPhaseType); if (currentIndex === -1 || currentIndex === phaseOrder.length - 1) { console.error("No next phase to transition to"); return; } for (let i = currentIndex + 1; i < phaseOrder.length; i++) { const nextPhaseType = phaseOrder[i]; const nextPhase = allPhases.find((p) => p.activity_type === nextPhaseType); if (!nextPhase) continue; if (nextPhase.estimated_duration_seconds === 0) { await supabase.from("ai_time_logs").update({ status: "skipped" }).eq("id", nextPhase.id); console.error(`\u23ED\uFE0F Skipped phase: ${nextPhaseType} (0 minutes estimated)`); continue; } if (nextPhase.status === "pending") { await supabase.from("ai_time_logs").update({ status: "in_progress", started_at: now.toISOString() }).eq("id", nextPhase.id); console.error(`\u2705 Started next phase: ${nextPhaseType}`); return; } } console.error("All remaining phases skipped or completed"); } catch (error) { console.error("Error transitioning to next phase:", error); } } var server = new Server( { name: "mg-tickets-mcp-bridge", version: "2.0.0" }, { capabilities: { tools: {}, resources: {} } } ); var TOOLS = [ { name: "get-tickets", description: "Get tickets with optional filtering by status, priority, project, customer, or search query", inputSchema: { type: "object", properties: { status: { type: "string", enum: ["open", "in_progress", "review", "resolved", "closed", "backlog"] }, priority: { type: "string", enum: ["low", "medium", "high", "critical"] }, projectId: { type: "string" }, customerId: { type: "string" }, q: { type: "string", description: "Search query for title or description" }, pageSize: { type: "number", default: 20, maximum: 100 } }, required: [] } }, { name: "get-ticket-by-id", description: "Get a specific ticket by its ID, including all attachments, comments, and images. Images from ticket attachments and comment attachments are automatically downloaded and returned as base64-encoded content that can be analyzed by AI.", inputSchema: { type: "object", properties: { id: { type: "string", description: "Ticket ID" } }, required: ["id"] } }, { name: "create-ticket", description: "Create a new ticket", inputSchema: { type: "object", properties: { title: { type: "string", description: "Ticket title" }, description: { type: "string" }, status: { type: "string", enum: ["open", "in_progress", "review", "resolved", "closed", "backlog"], default: "open" }, priority: { type: "string", enum: ["low", "medium", "high", "critical"], default: "medium" }, type: { type: "string", enum: ["task", "bug", "feature", "support", "question", "improvement"], default: "task" }, projectId: { type: "string" }, customerId: { type: "string" } }, required: ["title"] } }, { name: "get-customers", description: "Get customers with optional search", inputSchema: { type: "object", properties: { q: { type: "string", description: "Search query for customer name or email" }, pageSize: { type: "number", default: 20, maximum: 100 } }, required: [] } }, { name: "create-customer", description: "Create a new customer", inputSchema: { type: "object", properties: { name: { type: "string", description: "Customer name" }, email: { type: "string" }, website: { type: "string" } }, required: ["name"] } }, { name: "get-projects", description: "Get projects with optional filtering", inputSchema: { type: "object", properties: { customerId: { type: "string", description: "Filter by customer ID" }, q: { type: "string", description: "Search query for project name" }, pageSize: { type: "number", default: 20, maximum: 100 } }, required: [] } }, { name: "create-project", description: "Create a new project", inputSchema: { type: "object", properties: { name: { type: "string", description: "Project name" }, description: { type: "string" }, customerId: { type: "string" }, status: { type: "string", enum: ["active", "on_hold", "completed", "cancelled"], default: "active" } }, required: ["name"] } }, // === NEW AI SESSION TOOLS === { name: "start-ai-session-smart", description: "Start a new AI development session with automatic tracking (time breakdown provided by Cursor AI)", inputSchema: { type: "object", properties: { ticketId: { type: "string" }, ticketUrl: { type: "string", description: "URL to the ticket" }, cursorSessionId: { type: "string", description: "Cursor session identifier" }, codebaseContext: { type: "array", items: { type: "string" }, description: "Relevant files for complexity analysis" }, timeBreakdown: { type: "object", description: "Time estimate breakdown by development phase (REQUIRED)", properties: { analysisMinutes: { type: "number", description: "Ticket/context analysis time" }, investigationMinutes: { type: "number", description: "Bug investigation/reproduction (0 if not a bug)" }, developmentMinutes: { type: "number", description: "Actual coding/fixing time" }, communicationMinutes: { type: "number", description: "Customer response writing time" } }, required: ["analysisMinutes", "investigationMinutes", "developmentMinutes", "communicationMinutes"] }, complexityScore: { type: "number", minimum: 1, maximum: 10, description: "Estimated complexity from 1-10" } }, required: ["ticketId", "timeBreakdown"] } }, { name: "track-manual-follow-up", description: "Track manual follow-up prompt by developer", inputSchema: { type: "object", properties: { aiSessionId: { type: "string" }, originalPrompt: { type: "string" }, aiResponse: { type: "string" }, developerFollowUp: { type: "string" }, followUpReason: { type: "string", enum: ["incomplete_result", "wrong_approach", "needs_clarification", "error_in_code"] }, outcome: { type: "string", enum: ["success", "partial_success", "still_failed"], default: "success" }, estimatedMinutes: { type: "number", description: "Estimated time needed for this follow-up work (think as senior dev WITHOUT AI, in minutes)" }, workDescription: { type: "string", description: "Detailed work description generated by AI (2-3 sentences, summarizing all work done in session including follow-ups)" } }, required: ["aiSessionId", "originalPrompt", "aiResponse", "developerFollowUp", "followUpReason", "estimatedMinutes", "workDescription"] } }, { name: "get-session-context", description: "Get current session context for follow-up continuity", inputSchema: { type: "object", properties: { aiSessionId: { type: "string" }, includeTicketData: { type: "boolean", default: true }, includeTodoProgress: { type: "boolean", default: true }, includeFollowUpHistory: { type: "boolean", default: false } }, required: ["aiSessionId"] } }, { name: "sync-session-todos", description: "Synchronize todo list with AI session (replace existing) or add new todos", inputSchema: { type: "object", properties: { aiSessionId: { type: "string" }, todos: { type: "array", items: { type: "object", properties: { todoId: { type: "string", description: "Optional external todo ID for tracking" }, content: { type: "string" }, status: { type: "string", enum: ["pending", "in_progress", "completed", "cancelled"] }, estimatedMinutes: { type: "number" } }, required: ["content", "status"] } }, replaceAll: { type: "boolean", default: true, description: "If true, replace all existing todos. If false, add new todos to existing ones" } }, required: ["aiSessionId", "todos"] } }, { name: "add-follow-up-todos", description: "Add new todos from follow-up (without replacing existing ones)", inputSchema: { type: "object", properties: { aiSessionId: { type: "string" }, newTodos: { type: "array", items: { type: "object", properties: { content: { type: "string" }, status: { type: "string", enum: ["pending", "in_progress"], default: "pending" }, estimatedMinutes: { type: "number" }, addedInFollowUp: { type: "boolean", default: true } }, required: ["content"] } }, followUpReason: { type: "string", description: "Why were these todos added in follow-up" } }, required: ["aiSessionId", "newTodos"] } }, { name: "update-session-status", description: "Update AI session status and completion info", inputSchema: { type: "object", properties: { aiSessionId: { type: "string" }, status: { type: "string", enum: ["started", "in_progress", "paused", "completed", "failed"] }, actualTimeMinutes: { type: "number" }, completionNotes: { type: "string" } }, required: ["aiSessionId", "status"] } }, { name: "get-completion-context", description: "Get all context needed for Cursor AI to generate customer response", inputSchema: { type: "object", properties: { aiSessionId: { type: "string" }, includeFollowUps: { type: "boolean", default: true }, includeTimeMetrics: { type: "boolean", default: true }, includeTodos: { type: "boolean", default: true } }, required: ["aiSessionId"] } }, { name: "save-customer-response", description: "Save customer response generated by Cursor AI", inputSchema: { type: "object", properties: { aiSessionId: { type: "string" }, customerResponse: { type: "string", description: "Customer response generated by Cursor AI" }, responseType: { type: "string", enum: ["completion", "progress_update", "needs_clarification"], default: "completion" } }, required: ["aiSessionId", "customerResponse"] } }, { name: "complete-ai-session", description: "Complete AI session with work summary - time calculated automatically", inputSchema: { type: "object", properties: { aiSessionId: { type: "string" }, workCompleted: { type: "array", items: { type: "string" }, description: "List of completed tasks/todos in English" }, technicalSummary: { type: "string", description: "Technical summary of work done in English" }, invoiceDescription: { type: "string", description: "Short invoice-friendly description in the language of the ticket (2-3 sentences max, suitable for billing)" }, efficiencyNotes: { type: "string" } }, required: ["aiSessionId", "workCompleted"] } }, { name: "log-hours", description: "Analyze current chat conversation and log hours as draft tracker entry. AI analyzes chat context to estimate hours as a senior developer would (without AI assistance). Cursor AI matches workspace name to correct project from list (optional).", inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Project ID (UUID) - Optional. Cursor AI should call get-projects first to try matching workspace name. If no clear match, omit this field." }, ticketId: { type: "string", description: "Ticket ID (UUID) - Optional. Cursor AI should call get-tickets (filtered by project) to try matching chat context to an open ticket. Only include if a clear match is found." }, aiSessionId: { type: "string", description: "AI Session ID - Optional. If a ticket has an active AI dev session, include this ID to link the hours to that session." }, workDescription: { type: "string", description: "Short description of the work done (for the tracker entry)" }, estimatedHours: { type: "number", description: "AI-estimated hours as senior developer would spend WITHOUT AI (e.g., 2.5 for 2.5 hours)" }, chatContextSummary: { type: "string", description: "Brief summary of chat context for internal logging (optional)" } }, required: ["workDescription", "estimatedHours"] } }, // === GITHUB TOOLS === { name: "get-github-file", description: "Get the contents of a specific file from a GitHub repository. Use this after finding relevant files to read their full content.", inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Project ID (UUID)" }, filePath: { type: "string", description: 'Full path to the file in the repository (e.g., "src/components/Button.tsx")' }, ref: { type: "string", description: "Optional: Git reference (branch, tag, or commit SHA). Defaults to repository default branch." } }, required: ["projectId", "filePath"] } }, { name: "list-github-directory", description: "List files and directories in a GitHub repository directory. Use this to explore repository structure.", inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Project ID (UUID)" }, directoryPath: { type: "string", description: 'Path to directory (e.g., "src/components"). Use empty string or "/" for root directory.' }, ref: { type: "string", description: "Optional: Git reference (branch, tag, or commit SHA). Defaults to repository default branch." } }, required: ["projectId", "directoryPath"] } } ]; var RESOURCES = [ { uri: "tickets://recent", name: "Recent Tickets", description: "Most recently created tickets", mimeType: "application/json" }, { uri: "customers://all", name: "All Customers", description: "Complete customer directory", mimeType: "application/json" }, { uri: "projects://active", name: "Active Projects", description: "Currently active projects", mimeType: "application/json" } ]; server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: TOOLS }; }); server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: RESOURCES }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!authContext) { return { content: [{ type: "text", text: "Error: Not authenticated. API key validation failed." }] }; } const { name, arguments: args2 } = request.params; console.error(`\u{1F6E0}\uFE0F Executing tool: ${name} for team ${authContext.teamId}`); try { switch (name) { case "get-tickets": { const { status, priority, projectId, customerId, q, pageSize = 20 } = args2; const teamIds = await getAccessibleTeamIds(authContext.teamId); const projectIds = await getAccessibleProjectIds(authContext.userId, authContext.teamId); const customerIds = await getAccessibleCustomerIds(authContext.teamId); let teamFilter = `team_id.in.(${teamIds.join(",")})`; let projectFilter = projectIds.length > 0 ? `project_id.in.(${projectIds.join(",")})` : null; let customerFilter = customerIds.length > 0 ? `customer_id.in.(${customerIds.join(",")})` : null; const filters = [teamFilter, projectFilter, customerFilter].filter(Boolean); let query = supabase.from("tickets").select(` id, ticket_number, title, description, status, priority, type, created_at, project_id, customer_id, projects:project_id(id, name), customers:customer_id(id, name) `).or(filters.join(",")).limit(Math.min(pageSize, 100)); if (status) query = query.eq("status", status); if (priority) query = query.eq("priority", priority); if (projectId) query = query.eq("project_id", projectId); if (customerId) query = query.eq("customer_id", customerId); if (q) query = query.or(`title.ilike.%${q}%,description.ilike.%${q}%`); const { data, error } = await query.order("created_at", { ascending: false }); if (error) throw error; return { content: [{ type: "text", text: `Found ${data?.length || 0} tickets: ${data?.map( (ticket) => `**${ticket.ticket_number}**: ${ticket.title} Status: ${ticket.status} | Priority: ${ticket.priority} ${ticket.projects?.name ? `Project: ${ticket.projects.name} ` : ""}${ticket.customers?.name ? `Customer: ${ticket.customers.name} ` : ""}Created: ${new Date(ticket.created_at).toLocaleDateString()} ` ).join("\n") || "No tickets found."}` }] }; } case "get-ticket-by-id": { const { id } = args2; const { data: ticketData, error } = await supabase.from("tickets").select(` *, projects:project_id(id, name), customers:customer_id(id, name), assignee:assignee_id(id, full_name, email), requester:requester_id(id, full_name, email) `).eq("id", id).single(); if (error) throw error; let hasAccess = false; const teamIds = await getAccessibleTeamIds(authContext.teamId); if (teamIds.includes(ticketData.team_id)) { hasAccess = true; } if (!hasAccess && ticketData.project_id) { const projectIds = await getAccessibleProjectIds(authContext.userId, authContext.teamId); if (projectIds.includes(ticketData.project_id)) { hasAccess = true; } } if (!hasAccess && ticketData.customer_id) { const customerIds = await getAccessibleCustomerIds(authContext.teamId); if (customerIds.includes(ticketData.customer_id)) { hasAccess = true; } } if (!hasAccess) { throw new Error("Access denied: You do not have permission to view this ticket"); } const data = ticketData; const { data: attachments, error: attachmentsError } = await supabase.from("ticket_attachments").select(` id, file_name, file_size, mime_type, storage_key, created_at, users:user_id(id, full_name) `).eq("ticket_id", id).order("created_at", { ascending: true }); if (attachmentsError) { console.error("Error fetching attachments:", attachmentsError); } const { data: comments, error: commentsError } = await supabase.from("ticket_comments").select(` id, content, created_at, users:user_id(id, full_name) `).eq("ticket_id", id).order("created_at", { ascending: true }); if (commentsError) { console.error("Error fetching comments:", commentsError); } const commentIds = comments?.map((c) => c.id) || []; let commentAttachments = []; if (commentIds.length > 0) { const { data: commAttachments, error: commAttachmentsError } = await supabase.from("ticket_comment_attachments").select(` id, comment_id, file_name, file_size, mime_type, storage_key, created_at `).in("comment_id", commentIds); if (commAttachmentsError) { console.error("Error fetching comment attachments:", commAttachmentsError); } else { commentAttachments = commAttachments || []; } } const content = [{ type: "text", text: `**Ticket Details:** **${data.ticket_number}**: ${data.title} Status: ${data.status} Priority: ${data.priority} Type: ${data.type} ${data.description ? `Description: ${data.description} ` : ""}${data.projects?.name ? `Project: ${data.projects.name} ` : ""}${data.customers?.name ? `Customer: ${data.customers.name} ` : ""}${data.assignee?.full_name ? `Assignee: ${data.assignee.full_name} ` : ""}Requester: ${data.requester?.full_name || "Unknown"} Created: ${new Date(data.created_at).toLocaleDateString()} ${attachments && attachments.length > 0 ? ` \u{1F4CE} Attachments: ${attachments.length} ` : ""}${comments && comments.length > 0 ? `\u{1F4AC} Comments: ${comments.length} ` : ""}` }]; if (attachments && attachments.length > 0) { console.error(`\u{1F4CE} Processing ${attachments.length} ticket attachments...`); for (const attachment of attachments) { if (isImageFile(attachment.mime_type)) { console.error(`\u{1F5BC}\uFE0F Downloading image: ${attachment.file_name}`); const base64Data = await downloadImageAsBase64(attachment.storage_key); if (base64Data) { content.push({ type: "image", data: base64Data, mimeType: attachment.mime_type }); content.push({ type: "text", text: ` \u{1F4F8} **Image from ticket**: ${attachment.file_name} (${Math.round(attachment.file_size / 1024)}KB, uploaded by ${attachment.users?.full_name || "Unknown"} on ${new Date(attachment.created_at).toLocaleDateString()}) ` }); } } } } if (commentAttachments.length > 0) { console.error(`\u{1F4CE} Processing ${commentAttachments.length} comment attachments...`); for (const attachment of commentAttachments) { if (isImageFile(attachment.mime_type)) { console.error(`\u{1F5BC}\uFE0F Downloading comment image: ${attachment.file_name}`); const base64Data = await downloadImageAsBase64(attachment.storage_key); if (base64Data) { const comment = comments?.find((c) => c.id === attachment.comment_id); content.push({ type: "image", data: base64Data, mimeType: attachment.mime_type }); content.push({ type: "text", text: ` \u{1F4F8} **Image from comment** by ${comment?.users?.full_name || "Unknown"} on ${new Date(attachment.created_at).toLocaleDateString()}: ${attachment.file_name} (${Math.round(attachment.file_size / 1024)}KB) ` + (comment?.content ? `Comment text: "${comment.content.substring(0, 100)}${comment.content.length > 100 ? "..." : ""}" ` : "") }); } } } } console.error(`\u2705 Returning ticket with ${content.filter((c) => c.type === "image").length} images`); return { content }; } case "create-ticket": { const { title, description, status = "open", priority = "medium", type = "task", projectId, customerId } = args2; const year = (/* @__PURE__ */ new Date()).getFullYear(); const { count } = await supabase.from("tickets").select("*", { count: "exact", head: true }).eq("team_id", authContext.teamId); const ticketNumber = `${year}-${String((count || 0) + 1).padStart(3, "0")}`; const { data, error } = await supabase.from("tickets").insert({ team_id: authContext.teamId, ticket_number: ticketNumber, title, description, status, priority, type, project_id: projectId || null, customer_id: customerId || null, requester_id: authContext.userId }).select().single(); if (error) throw error; return { content: [{ type: "text", text: `\u2705 **Ticket Created Successfully!** Ticket Number: **${ticketNumber}** Title: ${title} Status: ${status} Priority: ${priority} Type: ${type} ` }] }; } case "get-customers": { const { q, pageSize = 20 } = args2; const customerIds = await getAccessibleCustomerIds(authContext.teamId); if (customerIds.length === 0) { return { content: [{ type: "text", text: "No customers found or no access to any customers." }] }; } let query = supabase.from("customers").select("id, name, email, website, created_at").in("id", customerIds).limit(Math.min(pageSize, 100)); if (q) query = query.or(`name.ilike.%${q}%,email.ilike.%${q}%`); const { data, error } = await query.order("name"); if (error) throw error; return { content: [{ type: "text", text: `Found ${data?.length || 0} customers: ${data?.map( (customer) => `**${customer.name}** ${customer.email ? `Email: ${customer.email} ` : ""}${customer.website ? `Website: ${customer.website} ` : ""}Created: ${new Date(customer.created_at).toLocaleDateString()} ` ).join("\n") || "No customers found."}` }] }; } case "create-customer": { const { name: name2, email, website } = args2; const { data, error } = await supabase.from("customers").insert({ team_id: authContext.teamId, name: name2, email: email || null, website: website || null, user_id: authContext.userId }).select().single(); if (error) throw error; return { content: [{ type: "text", text: `\u2705 **Customer Created Successfully!** Name: ${name2} ${email ? `Email: ${email} ` : ""}${website ? `Website: ${website} ` : ""}` }] }; } case "get-projects": { const { customerId, q, pageSize = 20 } = args2; const projectIds = await getAccessibleProjectIds(authContext.userId, authContext.teamId); if (projectIds.length === 0) { return { content: [{ type: "text", text: "No projects found or no access to any projects." }] }; } let query = supabase.from("projects").select(` id, name, description, customer_id, created_at `).in("id", projectIds).limit(Math.min(pageSize, 100)); if (customerId) query = query.eq("customer_id", customerId); if (q) query = query.ilike("name", `%${q}%`); const { data, error } = await query.order("name"); if (error) throw error; return { content: [{ type: "text", text: `Found ${data?.length || 0} projects: ${data?.map( (project) => `**${project.name}** (ID: ${project.id}) ${project.description ? `Description: ${project.description} ` : ""}Created: ${new Date(project.created_at).toLocaleDateString()} ` ).join("\n") || "No projects found."}` }] }; } case "create-project": { const { name: name2, description, customerId, status = "active" } = args2; const { data, error } = await supabase.from("projects").insert({ team_id: authContext.teamId, name: name2, description: description || null, customer_id: customerId || null, status, user_id: authContext.userId }).select().single(); if (error) throw error; return { content: [{ type: "text", text: `\u2705 **Project Created Successfully!** Name: ${name2} Status: ${status} ${description ? `Description: ${description} ` : ""}` }] }; } // === AI SESSION TOOLS === case "start-ai-session-smart": { const { ticketId, ticketUrl, cursorSessionId, codebaseContext, timeBreakdown, complexityScore } = args2; if (!timeBreakdown || !timeBreakdown.analysisMinutes || timeBreakdown.developmentMinutes === void 0) { throw new Error("timeBreakdown is required with all phases: analysisMinutes, investigationMinutes, developmentMinutes, communicationMinutes"); } const roundedTimeBreakdown = { analysisMinutes: roundToNearest15Minutes(timeBreakdown.analysisMinutes || 0), investigationMinutes: roundToNearest15Minutes(timeBreakdown.investigationMinutes || 0), developmentMinutes: roundToNearest15Minutes(timeBreakdown.developmentMinutes || 0), communicationMinutes: roundToNearest15Minutes(timeBreakdown.communicationMinutes || 0) }; const totalEstimateMinutes = roundedTimeBreakdown.analysisMinutes + roundedTimeBreakdown.investigationMinutes + roundedTimeBreakdown.developmentMinutes + roundedTimeBreakdown.communicationMinutes; const sessionStartTime = /* @__PURE__ */ new Date(); const { data: sessionData, error } = await supabase.from("ai_sessions").insert({ ticket_id: ticketId, provider_user_id: authContext.userId, team_id: authContext.teamId, cursor_session_id: cursorSessionId || null, ai_time_estimate_minutes: totalEstimateMinutes, complexity_score: complexityScore || null, status: "in_progress" }).select("id, ticket_id, cursor_session_id, created_at").single(); if (error) throw error; const phaseActivities = [ { ai_session_id: sessionData.id, activity_type: "analysis", description: "Ticket analysis and context understanding", duration_seconds: 0, estimated_duration_seconds: roundedTimeBreakdown.analysisMinutes * 60, status: "in_progress", // Analysis starts immediately productivity_score: 8, started_at: sessionStartTime.toISOString() }, { ai_session_id: sessionData.id, activity_type: "bug_investigation", description: "Bug investigation and root cause analysis", duration_seconds: 0, estimated_duration_seconds: roundedTimeBreakdown.investigationMinutes * 60, status: "pending", productivity_score: null }, { ai_session_id: sessionData.id, activity_type: "development", description: "Implementation and coding", duration_seconds: 0, estimated_duration_seconds: roundedTimeBreakdown.developmentMinutes * 60, status: "pending", productivity_score: null }, { ai_session_id: sessionData.id, activity_type: "communication", description: "Customer response and documentation", duration_seconds: 0, estimated_duration_seconds: roundedTimeBreakdown.communicationMinutes * 60, status: "pending", productivity_score: null } ]; await supabase.from("ai_time_logs").insert(phaseActivities); const sessionId = `ai-sess-${sessionData.id.substring(0, 8)}`; return { content: [{ type: "text", text: `\u{1F680} **AI Session Started Successfully!** \u{1F194} Session ID: **${sessionId}** \u{1F3AB} Ticket: ${ticketId} \u{1F4CA} **Time Breakdown:** \u2022 Analysis: ${roundedTimeBreakdown.analysisMinutes} min \u2022 Investigation: ${roundedTimeBreakdown.investigationMinutes} min \u2022 Development: ${roundedTimeBreakdown.developmentMinutes} min \u2022 Communication: ${roundedTimeBreakdown.communicationMinutes} min \u2022 **Total: ${totalEstimateMinutes} min** ${complexityScore ? `\u{1F3AF} Complexity: ${complexityScore}/10 ` : ""}\u23F1\uFE0F **Phase Tracking Started** (Analysis in progress) ${cursorSessionId ? `\u{1F517} Cursor Session: ${cursorSessionId} ` : ""}\u{1F4C5} Started: ${sessionStartTime.toLocaleString()} \u2705 Session initialized with phase breakdown! \u{1F4DD} Timetrack entry will be created when you complete the session.` }] }; } case "track-manual-follow-up": { const { aiSessionId, originalPrompt, aiResponse, developerFollowUp, followUpReason, outcome = "success", estimatedMinutes, workDescription } = args2; const sessionUuid = aiSessionId.replace("ai-sess-", ""); const teamIds = await getAccessibleTeamIds(authContext.teamId); const { data: allSessions, error: sessionError } = await supabase.from("ai_sessions").select("id, status, created_at, ai_time_estimate_minutes").in("team_id", teamIds); if (sessionError) { throw new Error(`Database error: ${sessionError.message}`); } const session = allSessions?.find((s) => s.id.toString().startsWith(sessionUuid)); if (!session) { throw new Error(`Session not found: ${aiSessionId} (searched ${allSessions?.length || 0} sessions)`); } const followUpTime = /* @__PURE__ */ new Date(); const oldEstimate = session.ai_time_estimate_minutes || 60; const roundedFollowUpMinutes = roundToNearest15Minutes(estimatedMinutes || 0); const newEstimate = oldEstimate + roundedFollowUpMinutes; await supabase.from("ai_sessions").update({ status: "in_progress", // Restart active tracking ai_time_estimate_minutes: newEstimate // Increase estimate based on follow-up work // Don't update completed_at - session continues }).eq("id", session.id); const { data, error } = await supabase.from("manual_follow_ups").insert({ ai_session_id: session.id, developer_id: authContext.userId, team_id: authContext.teamId, original_prompt: originalPrompt, ai_response: aiResponse, follow_up_prompt: developerFollowUp, follow_up_reason: followUpReason, outcome, time_spent_minutes: null, // Calculated automatically from session timestamps resolved_at: outcome === "success" ? (/* @__PURE__ */ new Date()).toISOString() : null }).select().single(); if (error) throw error; await supabase.from("ai_time_logs").insert({ ai_session_id: session.id, activity_type: "debugging", // Follow-ups are typically debugging/fixing description: `Follow-up: ${followUpReason.replace("_", " ")} - ${outcome}`, duration_seconds: 0, // Duration calculated automatically from timestamps productivity_score: outcome === "success" ? 9 : outcome === "partial_success" ? 6 : 4, started_at: followUpTime.toISOString() }); const sessionStartTime = new Date(session.created_at); const totalMinutesElapsed = Math.round((followUpTime.getTime() - sessionStartTime.getTime()) / 6e4); const currentEfficiency = totalMinutesElapsed > 0 ? totalMinutesElapsed / newEstimate : 1; await supabase.from("ai_sessions").update({ efficiency_score: currentEfficiency.toFixed(2), actual_time_minutes: totalMinutesElapsed }).eq("id", session.id); const { data: existingEntry, error: entryError } = await supabase.from("agenda_events").select("id, tracked_duration, title, description, start_time").eq("ai_session_id", session.id).eq("status", "draft").single(); let trackerAction = ""; let trackerDetails = ""; if (existingEntry && !entryError) { const newDuration = (existingEntry.tracked_duration || 0) + roundedFollowUpMinutes * 60; await supabase.from("agenda_events").update({ tracked_duration: newDuration, end_time: followUpTime.toISOString(), title: workDescription, description: workDescription }).eq("id", existingEntry.id); trackerAction = "Updated existing tracker"; trackerDetails = ` \u2022 Total tracked time: ${Math.round(newDuration / 60)} minutes (+${roundedFollowUpMinutes} min) \u2022 Description: ${workDescription} `; } else { const durationSeconds = roundedFollowUpMinutes * 60; const startTime = new Date(followUpTime.getTime() - durationSeconds * 1e3); await supabase.from("agenda_events").insert({ team_id: authContext.teamId, user_id: authContext.userId, ai_session_id: session.id, title: workDescription, description: workDescription, start_time: startTime.toISOString(), end_time: followUpTime.toISOString(), type: "work", status: "draft", all_day: false, is_tracked: true, tracked_duration: durationSeconds }); trackerAction = "Created new tracker"; trackerDetails = ` \u2022 Tracked time: ${roundedFollowUpMinutes} minutes \u2022 Description: ${workDescription} `; } return { content: [{ type: "text", text: `\u{1F504} **Follow-up Tracked & Session Restarted!** \u{1F194} Session: ${aiSessionId} (back to active) \u{1F50D} Reason: ${followUpReason.replace("_", " ")} \u2705 Outcome: ${outcome} \u{1F4CA} **Time Estimate Updated:** \u2022 Old estimate: ${oldEstimate} minutes \u2022 Follow-up estimate: +${roundedFollowUpMinutes} minutes (rounded to 15min) \u2022 New estimate: ${newEstimate} minutes \u{1F4C8} **Current Progress:** \u2022 Total time elapsed: ${totalMinutesElapsed} minutes \u2022 Efficiency: ${currentEfficiency < 1 ? "\u{1F680} " : currentEfficiency > 1.5 ? "\u26A0\uFE0F " : "\u23F1\uFE0F "}${(currentEfficiency * 100).toFixed(0)}% \u23F1\uFE0F **Tracker Entry: ${trackerAction}** ` + trackerDetails + ` \u26A1 **Time tracking resumed** - continue with confidence!` }] }; } case "get-session-context": { const { aiSessionId, includeTicketData = true, includeTodoProgress = true, includeFollowUpHistory = false } = args2; const sessionUuid = aiSessionId.replace("ai-sess-", ""); const teamIds = await getAccessibleTeamIds(authContext.teamId); const { data: allSessions, error: sessionError } = await supabase.from("ai_sessions").select(` id, ticket_id, status, ai_time_estimate_minutes, actual_time_minutes, complexity_score, created_at, cursor_session_id `).in("team_id", teamIds); if (sessionError) { throw new Error(`Database error: ${sessionError.message}`); } const session = allSessions?.find((s) => s.id.toString().startsWith(sessionUuid)); if (sessionError || !session) { throw new Error(`Session not found: ${aiSessionId}`); } let context = { sessionId: aiSessionId, status: session.status, timeEstimate: session.ai_time_estimate_minutes, actualTime: session.actual_time_minutes, complexity: session.complexity_score, createdAt: session.created_at }; if (includeTicketData) { const { data: ticket } = await supabase.from("tickets").select("id, ticket_number, title, description, status, priority, type").eq("id", session.ticket_id).single(); context.ticketData = ticket; } if (includeTodoProgress) { const { data: todos } = await supabase.from("ai_todos").select("id, content, status, estimated_minutes, actual_minutes").eq("ai_session_id", session.id).order("sequence_order"); context.todos = todos || []; context.todoProgress = { total: todos?.length || 0, completed: todos?.filter((t) => t.status === "completed").length || 0, inProgress: todos?.filter((t) => t.status === "in_progress").length || 0 }; } if (includeFollowUpHistory) { const { data: followUps } = await supabase.from("manual_follow_ups").select("follow_up_reason, outcome, time_spent_minutes, created_at").eq("ai_session_id", session.id).order("created_at"); context.followUpHistory = followUps || []; } return { content: [{ type: "text", text: `\u{1F3AF} **Session Context Retrieved** Session: ${aiSessionId} Status: ${session.status} ${context.ticketData ? `Ticket: ${context.ticketData.ticket_number} - ${context.ticketData.title} ` : ""}${context.todoProgress ? `Todo Progress: ${context.todoProgress.completed}/${context.todoProgress.total} completed ` : ""}${context.followUpHistory ? `Follow-ups: ${context.followUpHistory.length} ` : ""} \u{1F4CB} Full context preserved for seamless continuation!` }] }; } case "sync-session-todos": { const { aiSessionId, todos, replaceAll = true } = args2; const sessionUuid = aiSessionId.replace("ai-sess-