36d5632ed9
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>
429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
/**
|
||
* 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: `,
|
||
]
|
||
: [
|
||
`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.";
|
||
},
|
||
};
|
||
}
|