feat(tools): implement work package tools and registration
This commit is contained in:
@@ -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<typeof listWorkPackagesShape>
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool execution handler for openproject_list_work_packages.
|
||||||
|
*/
|
||||||
|
export async function handleListWorkPackages(
|
||||||
|
args: ListWorkPackagesArgs
|
||||||
|
): Promise<McpToolResponse> {
|
||||||
|
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<typeof getWorkPackageShape>
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool execution handler for openproject_get_work_package.
|
||||||
|
*/
|
||||||
|
export async function handleGetWorkPackage(
|
||||||
|
args: GetWorkPackageArgs
|
||||||
|
): Promise<McpToolResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,14 @@ import {
|
|||||||
projectTools,
|
projectTools,
|
||||||
registerProjectTools,
|
registerProjectTools,
|
||||||
} from "../src/tools/projects";
|
} from "../src/tools/projects";
|
||||||
|
import {
|
||||||
|
listWorkPackagesShape,
|
||||||
|
handleListWorkPackages,
|
||||||
|
getWorkPackageShape,
|
||||||
|
handleGetWorkPackage,
|
||||||
|
workPackageTools,
|
||||||
|
registerWorkPackageTools,
|
||||||
|
} from "../src/tools/work-packages";
|
||||||
import { OpenProjectNotFoundError } from "../src/client/errors";
|
import { OpenProjectNotFoundError } from "../src/client/errors";
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { z } from "zod";
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
)._registeredTools;
|
||||||
|
|
||||||
|
expect(registeredTools["openproject_list_work_packages"]).toBeDefined();
|
||||||
|
expect(registeredTools["openproject_get_work_package"]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user