Files
MCP-OpenProject/src/handlers.ts
T
fadhli 36d5632ed9 feat: add attachment tools (list, upload, delete)
Add MCP tools for managing work package attachments:
- list_attachments: list all files on a work package
- upload_attachment: upload local files with multipart/form-data
- delete_attachment: remove an attachment by ID

Includes embed syntax hints (macro + markdown) in upload response
and enhanced description fields with attachment embedding guidance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:37:03 +08:00

429 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.";
},
};
}