/** * Tool handler implementations — maps MCP tool calls to OpenProject API client methods. */ import { OpenProjectClient } from "./client.js"; /** Format a HAL+JSON work package into a readable summary. */ function formatWorkPackage(wp: any): string { const status = wp._links?.status?.title ?? "Unknown"; const type = wp._links?.type?.title ?? "Unknown"; const assignee = wp._links?.assignee?.title ?? "Unassigned"; const priority = wp._links?.priority?.title ?? "Normal"; return [ `#${wp.id}: ${wp.subject}`, ` Type: ${type} | Status: ${status} | Priority: ${priority}`, ` Assignee: ${assignee}`, wp.startDate ? ` Start: ${wp.startDate}` : null, wp.dueDate ? ` Due: ${wp.dueDate}` : null, wp.percentageDone != null ? ` Progress: ${wp.percentageDone}%` : null, wp.description?.raw ? ` Description: ${wp.description.raw.slice(0, 200)}` : null, ] .filter(Boolean) .join("\n"); } /** Format a HAL+JSON project into a readable summary. */ function formatProject(p: any): string { const status = p.status ?? p._links?.status?.title ?? "Unknown"; return [ `${p.name} (${p.identifier})`, ` ID: ${p.id} | Status: ${typeof status === "object" ? status.code ?? status : status}`, p.description?.raw ? ` Description: ${p.description.raw.slice(0, 200)}` : null, ] .filter(Boolean) .join("\n"); } function formatPaginatedList( response: any, formatter: (item: any) => string, label: string, ): string { const elements = response._embedded?.elements ?? []; if (elements.length === 0) { return `No ${label} found.`; } const header = `${label} (${response.offset}–${response.offset + response.count - 1} of ${response.total})`; return [header, "", ...elements.map(formatter)].join("\n"); } /** Build the URL-path prefix for attachment content links (e.g. "/openproject"). */ function getBasePath(baseUrl: string): string { try { const url = new URL(baseUrl); return url.pathname.replace(/\/+$/, ""); // e.g. "/openproject" or "" } catch { return ""; } } export function createHandlers(client: OpenProjectClient, baseUrl: string) { const basePath = getBasePath(baseUrl); return { // --- Projects --- async list_projects(args: { offset?: number; pageSize?: number }) { const result = await client.listProjects(args.offset, args.pageSize); return formatPaginatedList(result, formatProject, "Projects"); }, async get_project(args: { id: number | string }) { const project = await client.getProject(args.id); return formatProject(project); }, // --- Work Packages --- async list_work_packages(args: { projectId?: number | string; statusId?: string[]; typeId?: string[]; assigneeId?: string[]; offset?: number; pageSize?: number; }) { const filters: any[] = []; if (args.statusId?.length) { filters.push({ status_id: { operator: "=", values: args.statusId }, }); } if (args.typeId?.length) { filters.push({ type_id: { operator: "=", values: args.typeId } }); } if (args.assigneeId?.length) { filters.push({ assignee: { operator: "=", values: args.assigneeId }, }); } const result = await client.listWorkPackages( args.projectId, filters.length > 0 ? filters : undefined, args.offset, args.pageSize, ); return formatPaginatedList(result, formatWorkPackage, "Work Packages"); }, async get_work_package(args: { id: number }) { const wp = await client.getWorkPackage(args.id); return formatWorkPackage(wp); }, async create_work_package(args: { projectId: number | string; subject: string; description?: string; typeHref?: string; statusHref?: string; assigneeHref?: string; priorityHref?: string; startDate?: string; dueDate?: string; estimatedTime?: string; }) { const payload: any = { subject: args.subject, _links: {}, }; if (args.description) { payload.description = { raw: args.description }; } if (args.typeHref) { payload._links.type = { href: args.typeHref }; } if (args.statusHref) { payload._links.status = { href: args.statusHref }; } if (args.assigneeHref) { payload._links.assignee = { href: args.assigneeHref }; } if (args.priorityHref) { payload._links.priority = { href: args.priorityHref }; } if (args.startDate) payload.startDate = args.startDate; if (args.dueDate) payload.dueDate = args.dueDate; if (args.estimatedTime) payload.estimatedTime = args.estimatedTime; const wp = await client.createWorkPackage(args.projectId, payload); return `Created work package:\n${formatWorkPackage(wp)}`; }, async update_work_package(args: { id: number; subject?: string; description?: string; statusHref?: string; assigneeHref?: string; priorityHref?: string; startDate?: string; dueDate?: string; estimatedTime?: string; percentageDone?: number; }) { const payload: any = { _links: {} }; if (args.subject) payload.subject = args.subject; if (args.description) payload.description = { raw: args.description }; if (args.statusHref) payload._links.status = { href: args.statusHref }; if (args.assigneeHref) payload._links.assignee = { href: args.assigneeHref }; if (args.priorityHref) payload._links.priority = { href: args.priorityHref }; if (args.startDate) payload.startDate = args.startDate; if (args.dueDate) payload.dueDate = args.dueDate; if (args.estimatedTime) payload.estimatedTime = args.estimatedTime; if (args.percentageDone != null) payload.percentageDone = args.percentageDone; // Remove empty _links if (Object.keys(payload._links).length === 0) delete payload._links; const wp = await client.updateWorkPackage(args.id, payload); return `Updated work package:\n${formatWorkPackage(wp)}`; }, async add_work_package_comment(args: { workPackageId: number; comment: string; }) { await client.addComment(args.workPackageId, args.comment); return `Comment added to work package #${args.workPackageId}.`; }, // --- Relations --- async list_relations(args: { workPackageId: number }) { const result = await client.listRelations(args.workPackageId); const elements = result._embedded?.elements ?? []; if (elements.length === 0) { return `No relations found for work package #${args.workPackageId}.`; } return elements .map((r: any) => { const fromId = r._links?.from?.href?.split("/").pop() ?? "?"; const toId = r._links?.to?.href?.split("/").pop() ?? "?"; const fromTitle = r._links?.from?.title ?? `#${fromId}`; const toTitle = r._links?.to?.title ?? `#${toId}`; return `${r.id}: ${r.type} — #${fromId} (${fromTitle}) → #${toId} (${toTitle})${r.description ? ` "${r.description}"` : ""}`; }) .join("\n"); }, async create_relation(args: { fromId: number; toId: number; type: string; description?: string; }) { const rel = await client.createRelation( args.fromId, args.toId, args.type, args.description, ); const fromId = rel._links?.from?.href?.split("/").pop() ?? "?"; const toId = rel._links?.to?.href?.split("/").pop() ?? "?"; return `Created relation #${rel.id}: ${rel.type} — #${fromId} → #${toId}`; }, async delete_relation(args: { relationId: number }) { await client.deleteRelation(args.relationId); return `Deleted relation #${args.relationId}.`; }, // --- Lookup --- async list_statuses() { const result = await client.listStatuses(); const elements = result._embedded?.elements ?? []; return elements .map((s: any) => `${s.id}: ${s.name} (href: /api/v3/statuses/${s.id})`) .join("\n") || "No statuses found."; }, async list_types() { const result = await client.listTypes(); const elements = result._embedded?.elements ?? []; return elements .map((t: any) => `${t.id}: ${t.name} (href: /api/v3/types/${t.id})`) .join("\n") || "No types found."; }, async list_priorities() { const result = await client.listPriorities(); const elements = result._embedded?.elements ?? []; return elements .map( (p: any) => `${p.id}: ${p.name} (href: /api/v3/priorities/${p.id})`, ) .join("\n") || "No priorities found."; }, // --- Users --- async get_me() { const user = await client.getMe(); return [ `Authenticated as: ${user.name} (${user.login})`, ` ID: ${user.id}`, ` Email: ${user.email ?? "N/A"}`, ` Admin: ${user.admin ?? false}`, ` Status: ${user.status}`, ].join("\n"); }, async list_users(args: { offset?: number; pageSize?: number }) { const result = await client.listUsers(args.offset, args.pageSize); const elements = result._embedded?.elements ?? []; return formatPaginatedList( result, (u: any) => `${u.id}: ${u.name} (${u.login}) — ${u.status} (href: /api/v3/users/${u.id})`, "Users", ); }, // --- Time Entries --- async list_time_entries(args: { projectId?: number | string; offset?: number; pageSize?: number; }) { const result = await client.listTimeEntries( args.projectId, args.offset, args.pageSize, ); return formatPaginatedList( result, (te: any) => { const wp = te._links?.workPackage?.title ?? "N/A"; const user = te._links?.user?.title ?? "N/A"; return `${te.id}: ${te.hours} on "${wp}" by ${user} (${te.spentOn})`; }, "Time Entries", ); }, async log_time(args: { workPackageId: number; hours: string; activityHref: string; comment?: string; spentOn?: string; }) { const payload: any = { hours: args.hours, comment: args.comment ? { raw: args.comment } : undefined, spentOn: args.spentOn ?? new Date().toISOString().split("T")[0], _links: { workPackage: { href: `/api/v3/work_packages/${args.workPackageId}` }, activity: { href: args.activityHref }, }, }; const te = await client.createTimeEntry(payload); return `Time entry #${te.id} logged: ${te.hours} on work package #${args.workPackageId}.`; }, // --- Versions --- async list_versions(args: { projectId: number | string }) { const result = await client.listVersions(args.projectId); const elements = result._embedded?.elements ?? []; return elements .map( (v: any) => `${v.id}: ${v.name} — ${v.status} (start: ${v.startDate ?? "N/A"}, due: ${v.endDate ?? "N/A"})`, ) .join("\n") || "No versions found."; }, // --- Attachments --- async list_attachments(args: { workPackageId: number }) { const result = await client.listAttachments(args.workPackageId); const elements = result._embedded?.elements ?? []; if (elements.length === 0) { return `No attachments found for work package #${args.workPackageId}.`; } return elements .map((a: any) => { const size = a.fileSize ? `${(a.fileSize / 1024).toFixed(1)} KB` : "Unknown size"; const author = a._links?.author?.title ?? "Unknown"; const downloadUrl = a._links?.downloadLocation?.href ?? "N/A"; return [ `${a.id}: ${a.fileName}`, ` Type: ${a.contentType ?? "Unknown"} | Size: ${size}`, ` Author: ${author} | Created: ${a.createdAt?.split("T")[0] ?? "Unknown"}`, a.description?.raw ? ` Description: ${a.description.raw}` : null, ` Download: ${downloadUrl}`, ] .filter(Boolean) .join("\n"); }) .join("\n"); }, async upload_attachment(args: { workPackageId: number; filePath: string; fileName?: string; description?: string; }) { const attachment = await client.uploadAttachment( args.workPackageId, args.filePath, args.fileName, args.description, ); const size = attachment.fileSize ? `${(attachment.fileSize / 1024).toFixed(1)} KB` : "Unknown size"; const contentPath = `${basePath}/api/v3/attachments/${attachment.id}/content`; const isImage = (attachment.contentType ?? "").startsWith("image/"); const embedLines = isImage ? [ `To embed in description or comment (pick one):`, ` Macro (preferred): attachment:${attachment.fileName}`, ` Markdown: ![${attachment.fileName}](${contentPath})`, ] : [ `To link in description or comment (pick one):`, ` Macro (preferred): attachment:${attachment.fileName}`, ` Markdown: [${attachment.fileName}](${contentPath})`, ]; return [ `Uploaded attachment to work package #${args.workPackageId}:`, ` ID: ${attachment.id}`, ` File: ${attachment.fileName}`, ` Type: ${attachment.contentType ?? "Unknown"} | Size: ${size}`, attachment.description?.raw ? ` Description: ${attachment.description.raw}` : null, ``, ...embedLines, ] .filter((line) => line !== null) .join("\n"); }, async delete_attachment(args: { attachmentId: number }) { await client.deleteAttachment(args.attachmentId); return `Deleted attachment #${args.attachmentId}.`; }, // --- Activities --- async list_work_package_activities(args: { workPackageId: number }) { const result = await client.listActivities(args.workPackageId); const elements = result._embedded?.elements ?? []; return elements .map((a: any) => { const user = a._links?.user?.title ?? "System"; const date = a.createdAt?.split("T")[0] ?? "Unknown"; const comment = a.comment?.raw ? `\n "${a.comment.raw.slice(0, 150)}"` : ""; return `${a.id}: ${date} by ${user}${comment}`; }) .join("\n") || "No activities found."; }, }; }