feat: add OpenProject MCP server source
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+342
@@ -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.";
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user