@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
JavaScript
#!/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-