diff --git a/src/tools/work-packages.ts b/src/tools/work-packages.ts new file mode 100644 index 0000000..a9fa2f6 --- /dev/null +++ b/src/tools/work-packages.ts @@ -0,0 +1,175 @@ +/** + * Work Package Tools for OpenProject MCP. + * Implements openproject_list_work_packages and openproject_get_work_package tools. + */ + +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + formatToolSuccess, + formatToolError, + registerTool, + type McpToolResponse, + type ToolDefinition, +} from "./common"; +import { listWorkPackages, getWorkPackage } from "../services/work-packages"; +import type { FilterElement } from "../client/types"; + +export const listWorkPackagesShape = { + projectId: z + .union([z.number().int().positive(), z.string().min(1)]) + .optional() + .describe( + "Scope work packages to a specific project by numeric ID or slug identifier" + ), + status: z + .union([z.enum(["open", "closed"]), z.number().int().positive()]) + .optional() + .describe("Filter by status: 'open', 'closed', or numeric status ID"), + type: z + .number() + .int() + .positive() + .optional() + .describe( + "Filter by work package type ID (e.g. 1 for Task, 2 for Milestone)" + ), + assigneeId: z + .number() + .int() + .positive() + .optional() + .describe("Filter by assignee user ID"), + subject: z + .string() + .optional() + .describe("Filter by substring search in work package subject"), + pageSize: z + .number() + .int() + .positive() + .max(100) + .optional() + .describe("Number of items per page (max 100, default 20)"), + offset: z + .number() + .int() + .positive() + .optional() + .describe("Page number to retrieve (1-based, default 1)"), + sortBy: z + .string() + .optional() + .describe("Sorting criteria (e.g. 'id:asc', 'updatedAt:desc')"), + filters: z + .string() + .optional() + .describe( + "Optional raw OpenProject JSON filter string array overriding individual filters" + ), +}; + +export type ListWorkPackagesArgs = z.infer< + z.ZodObject +>; + +/** + * Tool execution handler for openproject_list_work_packages. + */ +export async function handleListWorkPackages( + args: ListWorkPackagesArgs +): Promise { + try { + let customFilters: FilterElement[] | undefined; + if (args.filters) { + const parsed = JSON.parse(args.filters); + if (!Array.isArray(parsed)) { + throw new Error("Filters must be a JSON array"); + } + customFilters = parsed as FilterElement[]; + } + + const result = await listWorkPackages({ + projectId: args.projectId, + status: args.status, + typeId: args.type, + assigneeId: args.assigneeId, + subject: args.subject, + pageSize: args.pageSize, + offset: args.offset, + sortBy: args.sortBy, + customFilters, + }); + + return formatToolSuccess({ + workPackages: result.items, + total: result.total, + count: result.count, + pageSize: result.pageSize, + offset: result.offset, + }); + } catch (error) { + return formatToolError(error); + } +} + +export const getWorkPackageShape = { + workPackageId: z + .number() + .int() + .positive() + .describe("The numeric ID of the work package"), +}; + +export type GetWorkPackageArgs = z.infer< + z.ZodObject +>; + +/** + * Tool execution handler for openproject_get_work_package. + */ +export async function handleGetWorkPackage( + args: GetWorkPackageArgs +): Promise { + try { + const result = await getWorkPackage(args.workPackageId); + return formatToolSuccess(result); + } catch (error) { + return formatToolError(error); + } +} + +export const listWorkPackagesTool: ToolDefinition< + typeof listWorkPackagesShape, + ListWorkPackagesArgs +> = { + name: "openproject_list_work_packages", + description: + "Browse and filter work packages (tasks, bugs, milestones, features) across projects or within a specific project", + parameters: listWorkPackagesShape, + readOnly: true, + execute: handleListWorkPackages, +}; + +export const getWorkPackageTool: ToolDefinition< + typeof getWorkPackageShape, + GetWorkPackageArgs +> = { + name: "openproject_get_work_package", + description: + "Get full details of a specific work package by its numeric ID, including status, priority, assignee, parent, and child relations", + parameters: getWorkPackageShape, + readOnly: true, + execute: handleGetWorkPackage, +}; + +export const workPackageTools = [listWorkPackagesTool, getWorkPackageTool]; + +/** + * Registers all work package tools with an McpServer instance. + */ +export function registerWorkPackageTools(server: McpServer): void { + for (const tool of workPackageTools) { + registerTool(server, tool); + } +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 2c01a31..3682215 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -13,6 +13,14 @@ import { projectTools, registerProjectTools, } from "../src/tools/projects"; +import { + listWorkPackagesShape, + handleListWorkPackages, + getWorkPackageShape, + handleGetWorkPackage, + workPackageTools, + registerWorkPackageTools, +} from "../src/tools/work-packages"; import { OpenProjectNotFoundError } from "../src/client/errors"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; @@ -269,4 +277,183 @@ describe("Project Tools", () => { }); }); +describe("Work Package Tools", () => { + const dummyClient = { + get: async (path: string) => { + if (path === "/api/v3/work_packages/38" || path === "work_packages/38") { + return { + id: 38, + subject: "Seed Task 1", + description: { raw: "Desc" }, + startDate: "2026-09-01", + dueDate: "2026-09-10", + estimatedTime: "PT8H", + percentageDone: 0, + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + _links: { + self: { href: "/api/v3/work_packages/38" }, + project: { href: "/api/v3/projects/4", title: "MCP Test Project" }, + type: { href: "/api/v3/types/1", title: "Task" }, + status: { href: "/api/v3/statuses/1", title: "New" }, + }, + }; + } + return { + _embedded: { + elements: [ + { + id: 38, + subject: "Seed Task 1", + _links: { + self: { href: "/api/v3/work_packages/38" }, + project: { href: "/api/v3/projects/4", title: "MCP Test Project" }, + type: { href: "/api/v3/types/1", title: "Task" }, + status: { href: "/api/v3/statuses/1", title: "New" }, + }, + }, + ], + }, + total: 1, + count: 1, + pageSize: 20, + offset: 1, + }; + }, + } as unknown as OpenProjectClient; + + test("listWorkPackages parses parameters and returns normalized list", async () => { + const schema = z.object(listWorkPackagesShape); + const parsed = schema.parse({ projectId: 4, status: "open" }); + const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => + handleListWorkPackages(parsed) + ); + + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.workPackages).toHaveLength(1); + expect(result.workPackages[0].id).toBe(38); + expect(result.workPackages[0].subject).toBe("Seed Task 1"); + expect(result.total).toBe(1); + }); + + test("listWorkPackages accepts string projectId and filter options", async () => { + const schema = z.object(listWorkPackagesShape); + const parsed = schema.parse({ + projectId: "mcp-test-project", + status: "closed", + type: 1, + assigneeId: 2, + subject: "Seed", + pageSize: 10, + offset: 1, + sortBy: "id:asc", + }); + const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => + handleListWorkPackages(parsed) + ); + + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.workPackages).toHaveLength(1); + }); + + test("listWorkPackages handles raw JSON string filters", async () => { + const schema = z.object(listWorkPackagesShape); + const parsed = schema.parse({ + filters: JSON.stringify([{ status_id: { operator: "o", values: [] } }]), + }); + const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => + handleListWorkPackages(parsed) + ); + + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.workPackages).toHaveLength(1); + }); + + test("listWorkPackages rejects invalid schema arguments", () => { + const schema = z.object(listWorkPackagesShape); + expect(() => schema.parse({ pageSize: 500 })).toThrow(); + expect(() => schema.parse({ pageSize: -1 })).toThrow(); + expect(() => schema.parse({ offset: 0 })).toThrow(); + expect(() => schema.parse({ projectId: "" })).toThrow(); + expect(() => schema.parse({ projectId: -4 })).toThrow(); + expect(() => schema.parse({ status: "invalid-status" })).toThrow(); + }); + + test("handleListWorkPackages catches JSON parsing errors and returns error response", async () => { + const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => + handleListWorkPackages({ filters: "{invalid-json" }) + ); + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Error"); + }); + + test("handleListWorkPackages catches non-array JSON filters and returns error response", async () => { + const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => + handleListWorkPackages({ filters: '{"not":"an array"}' }) + ); + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Filters must be a JSON array"); + }); + + test("getWorkPackage parses numeric ID and returns details", async () => { + const schema = z.object(getWorkPackageShape); + const parsed = schema.parse({ workPackageId: 38 }); + const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => + handleGetWorkPackage(parsed) + ); + + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.id).toBe(38); + expect(result.subject).toBe("Seed Task 1"); + expect(result.project).toBe("MCP Test Project"); + expect(result.projectId).toBe(4); + }); + + test("getWorkPackage rejects non-positive or non-integer ID", () => { + const schema = z.object(getWorkPackageShape); + expect(() => schema.parse({ workPackageId: -1 })).toThrow(); + expect(() => schema.parse({ workPackageId: 0 })).toThrow(); + expect(() => schema.parse({ workPackageId: 3.14 })).toThrow(); + expect(() => schema.parse({ workPackageId: "38" })).toThrow(); + }); + + test("getWorkPackage catches errors and formats error response", async () => { + const failingClient = { + get: async () => { + throw new Error("Work package not found"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext({ client: failingClient, isReadOnly: false }, () => + handleGetWorkPackage({ workPackageId: 999 }) + ); + + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Work package not found"); + }); + + test("registerWorkPackageTools registers tools on McpServer", () => { + const server = new McpServer({ name: "test-mcp", version: "1.0.0" }); + registerWorkPackageTools(server); + expect(workPackageTools).toHaveLength(2); + expect(workPackageTools.map((t) => t.name)).toEqual([ + "openproject_list_work_packages", + "openproject_get_work_package", + ]); + + const registeredTools = ( + server as unknown as { + _registeredTools: Record; + } + )._registeredTools; + + expect(registeredTools["openproject_list_work_packages"]).toBeDefined(); + expect(registeredTools["openproject_get_work_package"]).toBeDefined(); + }); +}); +