feat: add openproject_list_work_package_activities MCP tool
This commit is contained in:
@@ -12,7 +12,11 @@ import {
|
|||||||
type McpToolResponse,
|
type McpToolResponse,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
} from "./common";
|
} from "./common";
|
||||||
import { listWorkPackages, getWorkPackage } from "../services/work-packages";
|
import {
|
||||||
|
listWorkPackages,
|
||||||
|
getWorkPackage,
|
||||||
|
listWorkPackageActivities,
|
||||||
|
} from "../services/work-packages";
|
||||||
import type { FilterElement } from "../client/types";
|
import type { FilterElement } from "../client/types";
|
||||||
|
|
||||||
export const listWorkPackagesShape = {
|
export const listWorkPackagesShape = {
|
||||||
@@ -163,7 +167,59 @@ export const getWorkPackageTool: ToolDefinition<
|
|||||||
execute: handleGetWorkPackage,
|
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.
|
* Registers all work package tools with an McpServer instance.
|
||||||
@@ -171,4 +227,5 @@ export const workPackageTools = [listWorkPackagesTool, getWorkPackageTool];
|
|||||||
export function registerWorkPackageTools(server: McpServer): void {
|
export function registerWorkPackageTools(server: McpServer): void {
|
||||||
registerTool(server, listWorkPackagesTool);
|
registerTool(server, listWorkPackagesTool);
|
||||||
registerTool(server, getWorkPackageTool);
|
registerTool(server, getWorkPackageTool);
|
||||||
|
registerTool(server, listWorkPackageActivitiesTool);
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-6
@@ -25,6 +25,9 @@ import {
|
|||||||
handleListWorkPackages,
|
handleListWorkPackages,
|
||||||
getWorkPackageShape,
|
getWorkPackageShape,
|
||||||
handleGetWorkPackage,
|
handleGetWorkPackage,
|
||||||
|
listWorkPackageActivitiesShape,
|
||||||
|
handleListWorkPackageActivities,
|
||||||
|
listWorkPackageActivitiesTool,
|
||||||
workPackageTools,
|
workPackageTools,
|
||||||
registerWorkPackageTools,
|
registerWorkPackageTools,
|
||||||
} from "../src/tools/work-packages";
|
} from "../src/tools/work-packages";
|
||||||
@@ -481,13 +484,107 @@ describe("Work Package Tools", () => {
|
|||||||
expect(response.content[0]?.text).toContain("Work package not found");
|
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", () => {
|
test("registerWorkPackageTools registers tools on McpServer", () => {
|
||||||
const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
|
const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
|
||||||
registerWorkPackageTools(server);
|
registerWorkPackageTools(server);
|
||||||
expect(workPackageTools).toHaveLength(2);
|
expect(workPackageTools).toHaveLength(3);
|
||||||
expect(workPackageTools.map((t) => t.name)).toEqual([
|
expect(workPackageTools.map((t) => t.name)).toEqual([
|
||||||
"openproject_list_work_packages",
|
"openproject_list_work_packages",
|
||||||
"openproject_get_work_package",
|
"openproject_get_work_package",
|
||||||
|
"openproject_list_work_package_activities",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const registeredTools = (
|
const registeredTools = (
|
||||||
@@ -498,6 +595,9 @@ describe("Work Package Tools", () => {
|
|||||||
|
|
||||||
expect(registeredTools["openproject_list_work_packages"]).toBeDefined();
|
expect(registeredTools["openproject_list_work_packages"]).toBeDefined();
|
||||||
expect(registeredTools["openproject_get_work_package"]).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", () => {
|
describe("Tool Registry", () => {
|
||||||
test("allTools contains exactly 11 tools including OpenAPI spec", () => {
|
test("allTools contains exactly 12 tools including OpenAPI spec", () => {
|
||||||
expect(allTools).toHaveLength(11);
|
expect(allTools).toHaveLength(12);
|
||||||
const names = allTools.map((t) => t.name);
|
const names = allTools.map((t) => t.name);
|
||||||
expect(names).toEqual([
|
expect(names).toEqual([
|
||||||
"openproject_list_projects",
|
"openproject_list_projects",
|
||||||
"openproject_get_project",
|
"openproject_get_project",
|
||||||
"openproject_list_work_packages",
|
"openproject_list_work_packages",
|
||||||
"openproject_get_work_package",
|
"openproject_get_work_package",
|
||||||
|
"openproject_list_work_package_activities",
|
||||||
"openproject_list_queries",
|
"openproject_list_queries",
|
||||||
"openproject_get_query",
|
"openproject_get_query",
|
||||||
"openproject_list_types",
|
"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" });
|
const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
|
||||||
registerAllTools(server);
|
registerAllTools(server);
|
||||||
|
|
||||||
@@ -989,7 +1090,7 @@ describe("Tool Registry", () => {
|
|||||||
}
|
}
|
||||||
)._registeredTools;
|
)._registeredTools;
|
||||||
|
|
||||||
expect(Object.keys(registeredTools)).toHaveLength(11);
|
expect(Object.keys(registeredTools)).toHaveLength(12);
|
||||||
for (const tool of allTools) {
|
for (const tool of allTools) {
|
||||||
expect(registeredTools[tool.name]).toBeDefined();
|
expect(registeredTools[tool.name]).toBeDefined();
|
||||||
}
|
}
|
||||||
@@ -1021,7 +1122,7 @@ describe("Tool Registry", () => {
|
|||||||
expect(
|
expect(
|
||||||
registeredTools["openproject_synthetic_mutating_tool"]
|
registeredTools["openproject_synthetic_mutating_tool"]
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
expect(Object.keys(registeredTools)).toHaveLength(11);
|
expect(Object.keys(registeredTools)).toHaveLength(12);
|
||||||
for (const tool of allTools) {
|
for (const tool of allTools) {
|
||||||
expect(registeredTools[tool.name]).toBeDefined();
|
expect(registeredTools[tool.name]).toBeDefined();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user