feat: add OpenProject MCP server source

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 16:56:56 +08:00
parent e9b2135616
commit f23296ec82
8 changed files with 3783 additions and 0 deletions
+342
View File
@@ -0,0 +1,342 @@
/**
* 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");
}
export function createHandlers(client: OpenProjectClient) {
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.";
},
// --- 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.";
},
};
}