feat: add OpenProject MCP server source
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+1716
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@kollect/openproject-mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP server for OpenProject integration",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
Generated
+1106
File diff suppressed because it is too large
Load Diff
+242
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* OpenProject API v3 HTTP client with Basic Auth (API key).
|
||||
*/
|
||||
|
||||
export interface OpenProjectConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
_type: string;
|
||||
total: number;
|
||||
count: number;
|
||||
pageSize: number;
|
||||
offset: number;
|
||||
_embedded: { elements: T[] };
|
||||
}
|
||||
|
||||
export class OpenProjectClient {
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(config: OpenProjectConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "") + "/api/v3";
|
||||
const credentials = Buffer.from(`apikey:${config.apiKey}`).toString(
|
||||
"base64",
|
||||
);
|
||||
this.headers = {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method,
|
||||
headers: this.headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(
|
||||
`OpenProject API ${method} ${path} failed (${response.status}): ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
// --- Projects ---
|
||||
|
||||
async listProjects(
|
||||
offset = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedResponse<any>> {
|
||||
return this.request("GET", "/projects", undefined, {
|
||||
offset: String(offset),
|
||||
pageSize: String(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
async getProject(id: number | string): Promise<any> {
|
||||
return this.request("GET", `/projects/${id}`);
|
||||
}
|
||||
|
||||
// --- Work Packages ---
|
||||
|
||||
async listWorkPackages(
|
||||
projectId?: number | string,
|
||||
filters?: any[],
|
||||
offset = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedResponse<any>> {
|
||||
const path = projectId
|
||||
? `/projects/${projectId}/work_packages`
|
||||
: "/work_packages";
|
||||
const params: Record<string, string> = {
|
||||
offset: String(offset),
|
||||
pageSize: String(pageSize),
|
||||
};
|
||||
if (filters) {
|
||||
params.filters = JSON.stringify(filters);
|
||||
}
|
||||
return this.request("GET", path, undefined, params);
|
||||
}
|
||||
|
||||
async getWorkPackage(id: number): Promise<any> {
|
||||
return this.request("GET", `/work_packages/${id}`);
|
||||
}
|
||||
|
||||
async createWorkPackage(projectId: number | string, data: any): Promise<any> {
|
||||
return this.request("POST", `/projects/${projectId}/work_packages`, data);
|
||||
}
|
||||
|
||||
async updateWorkPackage(id: number, data: any): Promise<any> {
|
||||
// Fetch current lockVersion for optimistic concurrency control
|
||||
const current = await this.getWorkPackage(id);
|
||||
data.lockVersion = current.lockVersion;
|
||||
return this.request("PATCH", `/work_packages/${id}`, data);
|
||||
}
|
||||
|
||||
// --- Statuses ---
|
||||
|
||||
async listStatuses(): Promise<PaginatedResponse<any>> {
|
||||
return this.request("GET", "/statuses");
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
async listTypes(): Promise<PaginatedResponse<any>> {
|
||||
return this.request("GET", "/types");
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
async getMe(): Promise<any> {
|
||||
return this.request("GET", "/users/me");
|
||||
}
|
||||
|
||||
async listUsers(
|
||||
offset = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedResponse<any>> {
|
||||
return this.request("GET", "/users", undefined, {
|
||||
offset: String(offset),
|
||||
pageSize: String(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Time Entries ---
|
||||
|
||||
async listTimeEntries(
|
||||
projectId?: number | string,
|
||||
offset = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedResponse<any>> {
|
||||
const params: Record<string, string> = {
|
||||
offset: String(offset),
|
||||
pageSize: String(pageSize),
|
||||
};
|
||||
if (projectId) {
|
||||
params.filters = JSON.stringify([
|
||||
{ project: { operator: "=", values: [String(projectId)] } },
|
||||
]);
|
||||
}
|
||||
return this.request("GET", "/time_entries", undefined, params);
|
||||
}
|
||||
|
||||
async createTimeEntry(data: any): Promise<any> {
|
||||
return this.request("POST", "/time_entries", data);
|
||||
}
|
||||
|
||||
// --- Priorities ---
|
||||
|
||||
async listPriorities(): Promise<PaginatedResponse<any>> {
|
||||
return this.request("GET", "/priorities");
|
||||
}
|
||||
|
||||
// --- Versions ---
|
||||
|
||||
async listVersions(
|
||||
projectId: number | string,
|
||||
): Promise<PaginatedResponse<any>> {
|
||||
return this.request("GET", `/projects/${projectId}/versions`);
|
||||
}
|
||||
|
||||
// --- Relations ---
|
||||
|
||||
async listRelations(
|
||||
workPackageId: number,
|
||||
): Promise<PaginatedResponse<any>> {
|
||||
return this.request(
|
||||
"GET",
|
||||
"/relations",
|
||||
undefined,
|
||||
{
|
||||
filters: JSON.stringify([
|
||||
{ involved: { operator: "=", values: [String(workPackageId)] } },
|
||||
]),
|
||||
pageSize: "100",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async createRelation(
|
||||
fromId: number,
|
||||
toId: number,
|
||||
type: string,
|
||||
description?: string,
|
||||
): Promise<any> {
|
||||
return this.request(
|
||||
"POST",
|
||||
`/work_packages/${fromId}/relations`,
|
||||
{
|
||||
_links: {
|
||||
from: { href: `/api/v3/work_packages/${fromId}` },
|
||||
to: { href: `/api/v3/work_packages/${toId}` },
|
||||
},
|
||||
type,
|
||||
...(description ? { description } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRelation(relationId: number): Promise<void> {
|
||||
return this.request("DELETE", `/relations/${relationId}`);
|
||||
}
|
||||
|
||||
// --- Activities ---
|
||||
|
||||
async listActivities(workPackageId: number): Promise<PaginatedResponse<any>> {
|
||||
return this.request(
|
||||
"GET",
|
||||
`/work_packages/${workPackageId}/activities`,
|
||||
);
|
||||
}
|
||||
|
||||
async addComment(workPackageId: number, comment: string): Promise<any> {
|
||||
return this.request(
|
||||
"POST",
|
||||
`/work_packages/${workPackageId}/activities`,
|
||||
{ comment: { raw: comment } },
|
||||
);
|
||||
}
|
||||
}
|
||||
+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.";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OpenProject MCP Server
|
||||
*
|
||||
* Connects Claude to OpenProject via the v3 REST API.
|
||||
*
|
||||
* Environment variables:
|
||||
* OPENPROJECT_URL — Base URL of your OpenProject instance (e.g. https://op.example.com)
|
||||
* OPENPROJECT_API_KEY — API key (generated under My Account > Access Tokens)
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { OpenProjectClient } from "./client.js";
|
||||
import { toolDefs } from "./tools.js";
|
||||
import { createHandlers } from "./handlers.js";
|
||||
|
||||
function getEnvOrThrow(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
console.error(`Missing required environment variable: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const client = new OpenProjectClient({
|
||||
baseUrl: getEnvOrThrow("OPENPROJECT_URL"),
|
||||
apiKey: getEnvOrThrow("OPENPROJECT_API_KEY"),
|
||||
});
|
||||
|
||||
const handlers = createHandlers(client);
|
||||
|
||||
const server = new McpServer({
|
||||
name: "openproject",
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
// Register each tool from toolDefs with its corresponding handler.
|
||||
for (const [name, def] of Object.entries(toolDefs)) {
|
||||
const handler = handlers[name as keyof typeof handlers];
|
||||
if (!handler) {
|
||||
console.error(`No handler for tool: ${name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
server.tool(
|
||||
name,
|
||||
def.description,
|
||||
def.inputSchema,
|
||||
async (args: any) => {
|
||||
try {
|
||||
const result = await (handler as Function)(args);
|
||||
return { content: [{ type: "text" as const, text: String(result) }] };
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: ${err.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("OpenProject MCP server running on stdio");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* MCP tool definitions for OpenProject operations.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const toolDefs = {
|
||||
// --- Projects ---
|
||||
list_projects: {
|
||||
description:
|
||||
"List all accessible OpenProject projects. Returns project name, identifier, status, and description.",
|
||||
inputSchema: {
|
||||
offset: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(1)
|
||||
.describe("Page number (1-based)"),
|
||||
pageSize: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(20)
|
||||
.describe("Items per page (max 200)"),
|
||||
},
|
||||
},
|
||||
|
||||
get_project: {
|
||||
description:
|
||||
"Get detailed information about a specific OpenProject project by ID or identifier.",
|
||||
inputSchema: {
|
||||
id: z
|
||||
.union([z.number(), z.string()])
|
||||
.describe("Project numeric ID or string identifier"),
|
||||
},
|
||||
},
|
||||
|
||||
// --- Work Packages ---
|
||||
list_work_packages: {
|
||||
description:
|
||||
"List work packages (tasks, bugs, features, etc.) with optional filtering by project, status, type, or assignee.",
|
||||
inputSchema: {
|
||||
projectId: z
|
||||
.union([z.number(), z.string()])
|
||||
.optional()
|
||||
.describe("Filter by project ID or identifier"),
|
||||
statusId: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Filter by status IDs"),
|
||||
typeId: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Filter by type IDs"),
|
||||
assigneeId: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Filter by assignee user IDs"),
|
||||
offset: z.number().optional().default(1),
|
||||
pageSize: z.number().optional().default(20),
|
||||
},
|
||||
},
|
||||
|
||||
get_work_package: {
|
||||
description:
|
||||
"Get detailed information about a specific work package including status, assignee, description, and custom fields.",
|
||||
inputSchema: {
|
||||
id: z.number().describe("Work package ID"),
|
||||
},
|
||||
},
|
||||
|
||||
create_work_package: {
|
||||
description:
|
||||
"Create a new work package (task, bug, feature, etc.) in a project.",
|
||||
inputSchema: {
|
||||
projectId: z
|
||||
.union([z.number(), z.string()])
|
||||
.describe("Project ID or identifier"),
|
||||
subject: z.string().describe("Work package title/subject"),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Work package description (markdown supported)"),
|
||||
typeHref: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Type API href, e.g. /api/v3/types/1"),
|
||||
statusHref: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Status API href, e.g. /api/v3/statuses/1"),
|
||||
assigneeHref: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Assignee API href, e.g. /api/v3/users/5"),
|
||||
priorityHref: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Priority API href, e.g. /api/v3/priorities/2"),
|
||||
startDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Start date (YYYY-MM-DD)"),
|
||||
dueDate: z.string().optional().describe("Due date (YYYY-MM-DD)"),
|
||||
estimatedTime: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Estimated time in ISO 8601 duration, e.g. PT2H"),
|
||||
},
|
||||
},
|
||||
|
||||
update_work_package: {
|
||||
description:
|
||||
"Update an existing work package. Only provided fields are changed.",
|
||||
inputSchema: {
|
||||
id: z.number().describe("Work package ID"),
|
||||
subject: z.string().optional().describe("New title/subject"),
|
||||
description: z.string().optional().describe("New description"),
|
||||
statusHref: z.string().optional().describe("New status API href"),
|
||||
assigneeHref: z.string().optional().describe("New assignee API href"),
|
||||
priorityHref: z.string().optional().describe("New priority API href"),
|
||||
startDate: z.string().optional().describe("New start date (YYYY-MM-DD)"),
|
||||
dueDate: z.string().optional().describe("New due date (YYYY-MM-DD)"),
|
||||
estimatedTime: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("New estimated time (ISO 8601 duration)"),
|
||||
percentageDone: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Completion percentage (0-100)"),
|
||||
},
|
||||
},
|
||||
|
||||
add_work_package_comment: {
|
||||
description: "Add a comment to a work package.",
|
||||
inputSchema: {
|
||||
workPackageId: z.number().describe("Work package ID"),
|
||||
comment: z.string().describe("Comment text (markdown supported)"),
|
||||
},
|
||||
},
|
||||
|
||||
// --- Relations ---
|
||||
list_relations: {
|
||||
description:
|
||||
"List all relations for a work package (requires, follows, blocks, etc.).",
|
||||
inputSchema: {
|
||||
workPackageId: z.number().describe("Work package ID"),
|
||||
},
|
||||
},
|
||||
|
||||
create_relation: {
|
||||
description:
|
||||
"Create a relation between two work packages. Types: requires, follows, blocks, relates, includes, duplicates.",
|
||||
inputSchema: {
|
||||
fromId: z.number().describe("Source work package ID"),
|
||||
toId: z.number().describe("Target work package ID"),
|
||||
type: z
|
||||
.enum([
|
||||
"relates",
|
||||
"duplicates",
|
||||
"duplicated",
|
||||
"blocks",
|
||||
"blocked",
|
||||
"precedes",
|
||||
"follows",
|
||||
"includes",
|
||||
"partof",
|
||||
"requires",
|
||||
"required",
|
||||
])
|
||||
.describe("Relation type"),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional description of the relation"),
|
||||
},
|
||||
},
|
||||
|
||||
delete_relation: {
|
||||
description: "Delete a relation by its ID.",
|
||||
inputSchema: {
|
||||
relationId: z.number().describe("Relation ID to delete"),
|
||||
},
|
||||
},
|
||||
|
||||
// --- Lookup / Reference ---
|
||||
list_statuses: {
|
||||
description:
|
||||
"List all available work package statuses (e.g., New, In Progress, Closed). Use to discover status hrefs for filtering or updating.",
|
||||
inputSchema: {},
|
||||
},
|
||||
|
||||
list_types: {
|
||||
description:
|
||||
"List all available work package types (e.g., Task, Bug, Feature, Epic). Use to discover type hrefs.",
|
||||
inputSchema: {},
|
||||
},
|
||||
|
||||
list_priorities: {
|
||||
description:
|
||||
"List all available priorities (e.g., Low, Normal, High, Urgent). Use to discover priority hrefs.",
|
||||
inputSchema: {},
|
||||
},
|
||||
|
||||
// --- Users ---
|
||||
get_me: {
|
||||
description:
|
||||
"Get the currently authenticated user's profile. Useful for verifying the connection.",
|
||||
inputSchema: {},
|
||||
},
|
||||
|
||||
list_users: {
|
||||
description: "List all users in the OpenProject instance.",
|
||||
inputSchema: {
|
||||
offset: z.number().optional().default(1),
|
||||
pageSize: z.number().optional().default(20),
|
||||
},
|
||||
},
|
||||
|
||||
// --- Time Entries ---
|
||||
list_time_entries: {
|
||||
description:
|
||||
"List time entries, optionally filtered by project.",
|
||||
inputSchema: {
|
||||
projectId: z
|
||||
.union([z.number(), z.string()])
|
||||
.optional()
|
||||
.describe("Filter by project ID"),
|
||||
offset: z.number().optional().default(1),
|
||||
pageSize: z.number().optional().default(20),
|
||||
},
|
||||
},
|
||||
|
||||
log_time: {
|
||||
description: "Log time spent on a work package.",
|
||||
inputSchema: {
|
||||
workPackageId: z.number().describe("Work package ID"),
|
||||
hours: z.string().describe("Time in ISO 8601 duration, e.g. PT1H30M"),
|
||||
activityHref: z
|
||||
.string()
|
||||
.describe("Activity type API href, e.g. /api/v3/time_entries/activities/1"),
|
||||
comment: z.string().optional().describe("Time entry comment"),
|
||||
spentOn: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Date spent (YYYY-MM-DD, defaults to today)"),
|
||||
},
|
||||
},
|
||||
|
||||
// --- Versions ---
|
||||
list_versions: {
|
||||
description:
|
||||
"List versions/milestones for a project.",
|
||||
inputSchema: {
|
||||
projectId: z
|
||||
.union([z.number(), z.string()])
|
||||
.describe("Project ID or identifier"),
|
||||
},
|
||||
},
|
||||
|
||||
// --- Activities ---
|
||||
list_work_package_activities: {
|
||||
description:
|
||||
"List the activity/journal history of a work package (comments, status changes, etc.).",
|
||||
inputSchema: {
|
||||
workPackageId: z.number().describe("Work package ID"),
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user