feat: add openproject_list_work_package_activities MCP tool

This commit is contained in:
Lucas Winther
2026-09-07 02:39:01 +02:00
parent 6003925bcf
commit 190278fcce
2 changed files with 166 additions and 8 deletions
+59 -2
View File
@@ -12,7 +12,11 @@ import {
type McpToolResponse,
type ToolDefinition,
} from "./common";
import { listWorkPackages, getWorkPackage } from "../services/work-packages";
import {
listWorkPackages,
getWorkPackage,
listWorkPackageActivities,
} from "../services/work-packages";
import type { FilterElement } from "../client/types";
export const listWorkPackagesShape = {
@@ -163,7 +167,59 @@ export const getWorkPackageTool: ToolDefinition<
execute: handleGetWorkPackage,
};
export const workPackageTools = [listWorkPackagesTool, getWorkPackageTool];
export const listWorkPackageActivitiesShape = {
workPackageId: z
.number()
.int()
.positive()
.describe("Numeric ID of the work package"),
onlyComments: z
.boolean()
.optional()
.default(false)
.describe(
"When true, filters out property change audits and returns only comments"
),
};
export type ListWorkPackageActivitiesArgs = z.infer<
z.ZodObject<typeof listWorkPackageActivitiesShape>
>;
/**
* Tool execution handler for openproject_list_work_package_activities.
*/
export async function handleListWorkPackageActivities(
args: ListWorkPackageActivitiesArgs
): Promise<McpToolResponse> {
try {
const result = await listWorkPackageActivities({
workPackageId: args.workPackageId,
onlyComments: args.onlyComments,
});
return formatToolSuccess(result);
} catch (error) {
return formatToolError(error);
}
}
export const listWorkPackageActivitiesTool: ToolDefinition<
typeof listWorkPackageActivitiesShape,
ListWorkPackageActivitiesArgs
> = {
name: "openproject_list_work_package_activities",
description:
"Retrieve timeline activities and comments for a work package. Can optionally filter to only include user comments.",
parameters: listWorkPackageActivitiesShape,
readOnly: true,
execute: handleListWorkPackageActivities,
};
export const workPackageTools = [
listWorkPackagesTool,
getWorkPackageTool,
listWorkPackageActivitiesTool,
];
/**
* Registers all work package tools with an McpServer instance.
@@ -171,4 +227,5 @@ export const workPackageTools = [listWorkPackagesTool, getWorkPackageTool];
export function registerWorkPackageTools(server: McpServer): void {
registerTool(server, listWorkPackagesTool);
registerTool(server, getWorkPackageTool);
registerTool(server, listWorkPackageActivitiesTool);
}
+107 -6
View File
@@ -25,6 +25,9 @@ import {
handleListWorkPackages,
getWorkPackageShape,
handleGetWorkPackage,
listWorkPackageActivitiesShape,
handleListWorkPackageActivities,
listWorkPackageActivitiesTool,
workPackageTools,
registerWorkPackageTools,
} from "../src/tools/work-packages";
@@ -481,13 +484,107 @@ describe("Work Package Tools", () => {
expect(response.content[0]?.text).toContain("Work package not found");
});
test("listWorkPackageActivitiesTool has readOnly: true and valid schema", () => {
expect(listWorkPackageActivitiesTool.name).toBe(
"openproject_list_work_package_activities"
);
expect(listWorkPackageActivitiesTool.readOnly).toBe(true);
expect(listWorkPackageActivitiesTool.description).toContain(
"Retrieve timeline activities and comments for a work package"
);
expect(listWorkPackageActivitiesTool.parameters).toBeDefined();
expect(workPackageTools).toContain(listWorkPackageActivitiesTool);
});
test("listWorkPackageActivities parses parameters and returns activities", async () => {
const dummyClientWithActivities = {
get: async (path: string) => {
if (
path === "/api/v3/work_packages/38/activities" ||
path === "work_packages/38/activities"
) {
return {
_type: "Collection",
total: 1,
count: 1,
_embedded: {
elements: [
{
id: 101,
version: 1,
createdAt: "2026-09-06T10:00:00Z",
comment: { raw: "A test comment" },
details: [],
_links: { user: { href: "/api/v3/users/5", title: "Bob" } },
},
],
},
};
}
throw new Error(`Unexpected path: ${path}`);
},
} as unknown as OpenProjectClient;
const schema = z.object(listWorkPackageActivitiesShape);
const parsed = schema.parse({ workPackageId: 38, onlyComments: true });
const response = await runWithContext(
{ client: dummyClientWithActivities, isReadOnly: false },
() => handleListWorkPackageActivities(parsed)
);
expect(response.isError).toBeUndefined();
const result = JSON.parse(response.content[0]?.text ?? "[]");
expect(result).toHaveLength(1);
expect(result[0].id).toBe(101);
expect(result[0].comment).toBe("A test comment");
expect(result[0].isComment).toBe(true);
expect(result[0].user).toEqual({ id: 5, name: "Bob" });
});
test("listWorkPackageActivities rejects invalid workPackageId", () => {
const schema = z.object(listWorkPackageActivitiesShape);
expect(() => schema.parse({ workPackageId: 0 })).toThrow();
expect(() => schema.parse({ workPackageId: -5 })).toThrow();
expect(() => schema.parse({ workPackageId: 3.14 })).toThrow();
expect(() => schema.parse({ workPackageId: "38" })).toThrow();
});
test("listWorkPackageActivities defaults onlyComments to false", () => {
const schema = z.object(listWorkPackageActivitiesShape);
const parsed = schema.parse({ workPackageId: 38 });
expect(parsed.onlyComments).toBe(false);
});
test("handleListWorkPackageActivities catches errors and formats error response", async () => {
const failingClient = {
get: async () => {
throw new Error("Work package activities failed");
},
} as unknown as OpenProjectClient;
const response = await runWithContext(
{ client: failingClient, isReadOnly: false },
() =>
handleListWorkPackageActivities({
workPackageId: 999,
onlyComments: false,
})
);
expect(response.isError).toBe(true);
expect(response.content[0]?.text).toContain(
"Work package activities failed"
);
});
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).toHaveLength(3);
expect(workPackageTools.map((t) => t.name)).toEqual([
"openproject_list_work_packages",
"openproject_get_work_package",
"openproject_list_work_package_activities",
]);
const registeredTools = (
@@ -498,6 +595,9 @@ describe("Work Package Tools", () => {
expect(registeredTools["openproject_list_work_packages"]).toBeDefined();
expect(registeredTools["openproject_get_work_package"]).toBeDefined();
expect(
registeredTools["openproject_list_work_package_activities"]
).toBeDefined();
});
});
@@ -955,14 +1055,15 @@ describe("Metadata Tools", () => {
});
describe("Tool Registry", () => {
test("allTools contains exactly 11 tools including OpenAPI spec", () => {
expect(allTools).toHaveLength(11);
test("allTools contains exactly 12 tools including OpenAPI spec", () => {
expect(allTools).toHaveLength(12);
const names = allTools.map((t) => t.name);
expect(names).toEqual([
"openproject_list_projects",
"openproject_get_project",
"openproject_list_work_packages",
"openproject_get_work_package",
"openproject_list_work_package_activities",
"openproject_list_queries",
"openproject_get_query",
"openproject_list_types",
@@ -979,7 +1080,7 @@ describe("Tool Registry", () => {
}
});
test("registerAllTools registers all 11 tools on McpServer", () => {
test("registerAllTools registers all 12 tools on McpServer", () => {
const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
registerAllTools(server);
@@ -989,7 +1090,7 @@ describe("Tool Registry", () => {
}
)._registeredTools;
expect(Object.keys(registeredTools)).toHaveLength(11);
expect(Object.keys(registeredTools)).toHaveLength(12);
for (const tool of allTools) {
expect(registeredTools[tool.name]).toBeDefined();
}
@@ -1021,7 +1122,7 @@ describe("Tool Registry", () => {
expect(
registeredTools["openproject_synthetic_mutating_tool"]
).toBeUndefined();
expect(Object.keys(registeredTools)).toHaveLength(11);
expect(Object.keys(registeredTools)).toHaveLength(12);
for (const tool of allTools) {
expect(registeredTools[tool.name]).toBeDefined();
}