From 8cdca0fd83b33f69be849353e4c9fcd97ed8f248 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 6 Sep 2026 18:01:43 +0200 Subject: [PATCH] feat(tools): implement metadata tools and registration --- src/tools/metadata.ts | 196 ++++++++++++++++++++++++++++++ tests/tools.test.ts | 273 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 src/tools/metadata.ts diff --git a/src/tools/metadata.ts b/src/tools/metadata.ts new file mode 100644 index 0000000..b7b018a --- /dev/null +++ b/src/tools/metadata.ts @@ -0,0 +1,196 @@ +/** + * Metadata Tools for OpenProject MCP. + * Implements tools for inspecting OpenProject taxonomies and users: + * - openproject_list_types + * - openproject_list_statuses + * - openproject_list_priorities + * - openproject_list_users + */ + +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + formatToolSuccess, + formatToolError, + registerTool, + type McpToolResponse, + type ToolDefinition, +} from "./common"; +import { + listTypes, + listStatuses, + listPriorities, + listUsers, +} from "../services/metadata"; + +export const listTypesShape = { + projectId: z + .union([z.number().int().positive(), z.string().min(1)]) + .optional() + .describe("Optional numeric project ID or identifier to scope types"), +}; + +export type ListTypesArgs = z.infer>; + +/** + * Tool execution handler for openproject_list_types. + */ +export async function handleListTypes( + args: ListTypesArgs = {} +): Promise { + try { + const result = await listTypes( + args.projectId !== undefined ? { projectId: args.projectId } : undefined + ); + return formatToolSuccess({ + types: result, + }); + } catch (error) { + return formatToolError(error); + } +} + +export const listStatusesShape = {}; + +export type ListStatusesArgs = Record; + +/** + * Tool execution handler for openproject_list_statuses. + */ +export async function handleListStatuses( + _args: ListStatusesArgs = {} +): Promise { + try { + const result = await listStatuses(); + return formatToolSuccess({ + statuses: result, + }); + } catch (error) { + return formatToolError(error); + } +} + +export const listPrioritiesShape = {}; + +export type ListPrioritiesArgs = Record; + +/** + * Tool execution handler for openproject_list_priorities. + */ +export async function handleListPriorities( + _args: ListPrioritiesArgs = {} +): Promise { + try { + const result = await listPriorities(); + return formatToolSuccess({ + priorities: result, + }); + } catch (error) { + return formatToolError(error); + } +} + +export const listUsersShape = { + pageSize: z + .number() + .int() + .positive() + .max(100) + .optional() + .describe("Number of users to return per page (max 100, default 20)"), + offset: z + .number() + .int() + .positive() + .optional() + .describe("Page number to retrieve (1-based, default 1)"), + status: z + .string() + .optional() + .describe("Filter users by status (e.g. 'active', 'locked', 'invited')"), +}; + +export type ListUsersArgs = z.infer>; + +/** + * Tool execution handler for openproject_list_users. + */ +export async function handleListUsers( + args: ListUsersArgs = {} +): Promise { + try { + const result = await listUsers(args); + return formatToolSuccess({ + users: result.items, + total: result.total, + count: result.count, + pageSize: result.pageSize, + offset: result.offset, + }); + } catch (error) { + return formatToolError(error); + } +} + +export const listTypesTool: ToolDefinition< + typeof listTypesShape, + ListTypesArgs +> = { + name: "openproject_list_types", + description: + "List all available work package types (Task, Bug, Milestone, Feature, etc.), optionally scoped to a project", + parameters: listTypesShape, + readOnly: true, + execute: handleListTypes, +}; + +export const listStatusesTool: ToolDefinition< + typeof listStatusesShape, + ListStatusesArgs +> = { + name: "openproject_list_statuses", + description: + "List all available work package statuses (New, In Progress, Closed, etc.)", + parameters: listStatusesShape, + readOnly: true, + execute: handleListStatuses, +}; + +export const listPrioritiesTool: ToolDefinition< + typeof listPrioritiesShape, + ListPrioritiesArgs +> = { + name: "openproject_list_priorities", + description: "List all priority levels (Low, Normal, High, Immediate)", + parameters: listPrioritiesShape, + readOnly: true, + execute: handleListPriorities, +}; + +export const listUsersTool: ToolDefinition< + typeof listUsersShape, + ListUsersArgs +> = { + name: "openproject_list_users", + description: "List users in the OpenProject instance with optional pagination", + parameters: listUsersShape, + readOnly: true, + execute: handleListUsers, +}; + +export const metadataTools = [ + listTypesTool, + listStatusesTool, + listPrioritiesTool, + listUsersTool, +]; + +/** + * Registers all metadata tools with an McpServer instance. + */ +export function registerMetadataTools(server: McpServer): void { + registerTool(server, listTypesTool); + registerTool(server, listStatusesTool); + registerTool(server, listPrioritiesTool); + registerTool(server, listUsersTool); +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 3d0dd31..db4dcdb 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -29,6 +29,18 @@ import { queryTools, registerQueryTools, } from "../src/tools/queries"; +import { + listTypesShape, + handleListTypes, + listStatusesShape, + handleListStatuses, + listPrioritiesShape, + handleListPriorities, + listUsersShape, + handleListUsers, + metadataTools, + registerMetadataTools, +} from "../src/tools/metadata"; import { OpenProjectNotFoundError } from "../src/client/errors"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; @@ -656,5 +668,266 @@ describe("Query Tools", () => { }); }); +describe("Metadata Tools", () => { + const dummyClient = { + get: async (path: string) => { + if ( + path === "types" || + path === "/api/v3/types" || + path === "projects/4/types" || + path === "projects/mcp-test-project/types" + ) { + return { + _embedded: { + elements: [ + { + id: 1, + name: "Task", + color: "#1a73e8", + isDefault: true, + isMilestone: false, + _links: { self: { href: "/api/v3/types/1" } }, + }, + ], + }, + total: 1, + count: 1, + pageSize: 20, + offset: 1, + }; + } + if (path === "statuses" || path === "/api/v3/statuses") { + return { + _embedded: { + elements: [ + { + id: 1, + name: "New", + isClosed: false, + isDefault: true, + color: "#34a853", + _links: { self: { href: "/api/v3/statuses/1" } }, + }, + ], + }, + total: 1, + count: 1, + pageSize: 20, + offset: 1, + }; + } + if (path === "priorities" || path === "/api/v3/priorities") { + return { + _embedded: { + elements: [ + { + id: 1, + name: "Normal", + isDefault: true, + _links: { self: { href: "/api/v3/priorities/1" } }, + }, + ], + }, + total: 1, + count: 1, + pageSize: 20, + offset: 1, + }; + } + if (path === "users" || path === "/api/v3/users") { + return { + _embedded: { + elements: [ + { + id: 1, + name: "OpenProject Admin", + email: "admin@example.com", + status: "active", + admin: true, + _links: { self: { href: "/api/v3/users/1" } }, + }, + ], + }, + total: 1, + count: 1, + pageSize: 20, + offset: 1, + }; + } + return { _embedded: { elements: [] }, total: 0, count: 0 }; + }, + } as unknown as OpenProjectClient; + + test("listTypes returns types collection", async () => { + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleListTypes({}) + ); + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.types).toHaveLength(1); + expect(result.types[0].name).toBe("Task"); + }); + + test("listTypes accepts string and numeric projectId", async () => { + const schema = z.object(listTypesShape); + const parsedNum = schema.parse({ projectId: 4 }); + const responseNum = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleListTypes(parsedNum) + ); + expect(responseNum.isError).toBeUndefined(); + const resultNum = JSON.parse(responseNum.content[0]?.text ?? "{}"); + expect(resultNum.types).toHaveLength(1); + + const parsedStr = schema.parse({ projectId: "mcp-test-project" }); + const responseStr = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleListTypes(parsedStr) + ); + expect(responseStr.isError).toBeUndefined(); + const resultStr = JSON.parse(responseStr.content[0]?.text ?? "{}"); + expect(resultStr.types).toHaveLength(1); + }); + + test("listTypes rejects invalid projectId schema arguments", () => { + const schema = z.object(listTypesShape); + expect(() => schema.parse({ projectId: 0 })).toThrow(); + expect(() => schema.parse({ projectId: -1 })).toThrow(); + expect(() => schema.parse({ projectId: "" })).toThrow(); + }); + + test("handleListTypes catches errors and returns error response", async () => { + const failingClient = { + get: async () => { + throw new Error("Types service failure"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext( + { client: failingClient, isReadOnly: false }, + () => handleListTypes({}) + ); + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Types service failure"); + }); + + test("listStatuses returns status items", async () => { + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleListStatuses({}) + ); + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.statuses).toHaveLength(1); + expect(result.statuses[0].name).toBe("New"); + }); + + test("handleListStatuses catches errors and returns error response", async () => { + const failingClient = { + get: async () => { + throw new Error("Statuses service failure"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext( + { client: failingClient, isReadOnly: false }, + () => handleListStatuses({}) + ); + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Statuses service failure"); + }); + + test("listPriorities returns priority items", async () => { + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleListPriorities({}) + ); + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.priorities).toHaveLength(1); + expect(result.priorities[0].name).toBe("Normal"); + }); + + test("handleListPriorities catches errors and returns error response", async () => { + const failingClient = { + get: async () => { + throw new Error("Priorities service failure"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext( + { client: failingClient, isReadOnly: false }, + () => handleListPriorities({}) + ); + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Priorities service failure"); + }); + + test("listUsers returns paginated user list", async () => { + const schema = z.object(listUsersShape); + const parsed = schema.parse({ pageSize: 10, offset: 1 }); + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleListUsers(parsed) + ); + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.users).toHaveLength(1); + expect(result.users[0].name).toBe("OpenProject Admin"); + expect(result.total).toBe(1); + expect(result.count).toBe(1); + expect(result.pageSize).toBe(20); + expect(result.offset).toBe(1); + }); + + test("listUsers rejects invalid schema arguments", () => { + const schema = z.object(listUsersShape); + expect(() => schema.parse({ pageSize: 0 })).toThrow(); + expect(() => schema.parse({ pageSize: -1 })).toThrow(); + expect(() => schema.parse({ pageSize: 500 })).toThrow(); + expect(() => schema.parse({ offset: 0 })).toThrow(); + expect(() => schema.parse({ offset: -1 })).toThrow(); + }); + + test("handleListUsers catches errors and returns error response", async () => { + const failingClient = { + get: async () => { + throw new Error("Users service failure"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext( + { client: failingClient, isReadOnly: false }, + () => handleListUsers({ pageSize: 10 }) + ); + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Users service failure"); + }); + + test("registerMetadataTools registers all 4 metadata tools", () => { + const server = new McpServer({ name: "test-mcp", version: "1.0.0" }); + registerMetadataTools(server); + expect(metadataTools).toHaveLength(4); + expect(metadataTools.map((t) => t.name)).toEqual([ + "openproject_list_types", + "openproject_list_statuses", + "openproject_list_priorities", + "openproject_list_users", + ]); + + const registeredTools = ( + server as unknown as { + _registeredTools: Record; + } + )._registeredTools; + + expect(registeredTools["openproject_list_types"]).toBeDefined(); + expect(registeredTools["openproject_list_statuses"]).toBeDefined(); + expect(registeredTools["openproject_list_priorities"]).toBeDefined(); + expect(registeredTools["openproject_list_users"]).toBeDefined(); + }); +}); +