From 8ccbebe34ffa8c7165b6cbbac8b65ae73bc1a678 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 6 Sep 2026 01:30:17 +0200 Subject: [PATCH] feat(tools): implement project tools and registration --- src/tools/projects.ts | 120 ++++++++++++++++++++++++++++++++++ tests/tools.test.ts | 149 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/tools/projects.ts diff --git a/src/tools/projects.ts b/src/tools/projects.ts new file mode 100644 index 0000000..59e4f98 --- /dev/null +++ b/src/tools/projects.ts @@ -0,0 +1,120 @@ +/** + * Project Tools for OpenProject MCP. + * Implements openproject_list_projects and openproject_get_project 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 { listProjects, getProject } from "../services/projects"; +import type { FilterElement } from "../client/types"; + +export const listProjectsShape = { + pageSize: z + .number() + .int() + .positive() + .max(100) + .optional() + .describe("Number of projects to return 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. 'name:asc', 'id:desc')"), + filters: z + .string() + .optional() + .describe("Optional raw OpenProject JSON filters string"), +}; + +export type ListProjectsArgs = z.infer>; + +export async function handleListProjects( + args: ListProjectsArgs +): Promise { + try { + let filters: FilterElement[] | undefined; + if (args.filters) { + filters = JSON.parse(args.filters) as FilterElement[]; + } + + const result = await listProjects({ + pageSize: args.pageSize, + offset: args.offset, + sortBy: args.sortBy, + filters, + }); + + return formatToolSuccess({ + projects: result.items, + total: result.total, + count: result.count, + pageSize: result.pageSize, + offset: result.offset, + }); + } catch (error) { + return formatToolError(error); + } +} + +export const getProjectShape = { + projectId: z + .union([z.number().int().positive(), z.string()]) + .describe("Numeric project ID or string identifier/slug"), +}; + +export type GetProjectArgs = z.infer>; + +export async function handleGetProject( + args: GetProjectArgs +): Promise { + try { + const result = await getProject(args.projectId); + return formatToolSuccess(result); + } catch (error) { + return formatToolError(error); + } +} + +export const listProjectsTool: ToolDefinition< + typeof listProjectsShape, + ListProjectsArgs +> = { + name: "openproject_list_projects", + description: + "List OpenProject projects accessible to the authenticated user with optional pagination and sorting", + parameters: listProjectsShape, + readOnly: true, + execute: handleListProjects, +}; + +export const getProjectTool: ToolDefinition< + typeof getProjectShape, + GetProjectArgs +> = { + name: "openproject_get_project", + description: + "Retrieve detailed information for a single project by its numeric ID or string identifier (slug)", + parameters: getProjectShape, + readOnly: true, + execute: handleGetProject, +}; + +export const projectTools = [listProjectsTool, getProjectTool]; + +export function registerProjectTools(server: McpServer): void { + registerTool(server, listProjectsTool); + registerTool(server, getProjectTool); +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index c56f093..2c01a31 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -5,9 +5,20 @@ import { registerTool, type ToolDefinition, } from "../src/tools/common"; +import { + listProjectsShape, + handleListProjects, + getProjectShape, + handleGetProject, + projectTools, + registerProjectTools, +} from "../src/tools/projects"; import { OpenProjectNotFoundError } from "../src/client/errors"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { runWithContext } from "../src/context"; +import type { OpenProjectClient } from "../src/client/api-client"; + describe("Tool Utilities", () => { test("formatToolSuccess formats data into MCP text response", () => { @@ -121,3 +132,141 @@ describe("Tool Utilities", () => { expect(JSON.parse(executionResult?.content[0]?.text ?? "{}")).toEqual({ ok: true }); }); }); + +describe("Project Tools", () => { + const dummyClient = { + get: async (path: string) => { + if (path === "projects/4" || path === "/api/v3/projects/4") { + return { + id: 4, + identifier: "mcp-test-project", + name: "MCP Test Project", + active: true, + public: false, + description: { raw: "Desc", html: "

Desc

" }, + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + _links: { self: { href: "/api/v3/projects/4" } }, + }; + } + return { + _embedded: { + elements: [ + { + id: 4, + identifier: "mcp-test-project", + name: "MCP Test Project", + active: true, + public: false, + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + _links: { self: { href: "/api/v3/projects/4" } }, + }, + ], + }, + total: 1, + count: 1, + pageSize: 20, + offset: 1, + }; + }, + } as unknown as OpenProjectClient; + + test("listProjects validates schema and executes successfully", async () => { + const schema = z.object(listProjectsShape); + const parsed = schema.parse({ pageSize: 10, offset: 1 }); + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleListProjects(parsed) + ); + + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.projects).toHaveLength(1); + expect(result.projects[0].id).toBe(4); + expect(result.total).toBe(1); + }); + + test("listProjects handles raw JSON string filters", async () => { + const schema = z.object(listProjectsShape); + const parsed = schema.parse({ + filters: JSON.stringify([{ active: { operator: "=", values: ["t"] } }]), + }); + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleListProjects(parsed) + ); + + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.projects).toHaveLength(1); + }); + + test("listProjects rejects invalid schema arguments", () => { + const schema = z.object(listProjectsShape); + expect(() => schema.parse({ pageSize: 500 })).toThrow(); + expect(() => schema.parse({ pageSize: -5 })).toThrow(); + expect(() => schema.parse({ offset: 0 })).toThrow(); + }); + + test("handleListProjects catches JSON parsing errors and returns error response", async () => { + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleListProjects({ filters: "{invalid-json" }) + ); + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Error"); + }); + + test("getProject validates schema and retrieves single project", async () => { + const schema = z.object(getProjectShape); + const parsed = schema.parse({ projectId: 4 }); + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleGetProject(parsed) + ); + + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.id).toBe(4); + expect(result.identifier).toBe("mcp-test-project"); + }); + + test("getProject catches errors and formats error response", async () => { + const failingClient = { + get: async () => { + throw new Error("Network timeout"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext( + { client: failingClient, isReadOnly: false }, + () => handleGetProject({ projectId: 999 }) + ); + + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Network timeout"); + }); + + test("registerProjectTools registers tools on McpServer", () => { + const server = new McpServer({ name: "test-mcp", version: "1.0.0" }); + registerProjectTools(server); + + expect(projectTools).toHaveLength(2); + expect(projectTools.map((t) => t.name)).toEqual([ + "openproject_list_projects", + "openproject_get_project", + ]); + + const registeredTools = ( + server as unknown as { + _registeredTools: Record; + } + )._registeredTools; + + expect(registeredTools["openproject_list_projects"]).toBeDefined(); + expect(registeredTools["openproject_get_project"]).toBeDefined(); + }); +}); + +