feat(tools): implement project tools and registration

This commit is contained in:
Lucas Winther
2026-09-06 01:30:17 +02:00
parent 43eeee2e04
commit 8ccbebe34f
2 changed files with 269 additions and 0 deletions
+120
View File
@@ -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<z.ZodObject<typeof listProjectsShape>>;
export async function handleListProjects(
args: ListProjectsArgs
): Promise<McpToolResponse> {
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<z.ZodObject<typeof getProjectShape>>;
export async function handleGetProject(
args: GetProjectArgs
): Promise<McpToolResponse> {
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);
}
+149
View File
@@ -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: "<p>Desc</p>" },
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<string, unknown>;
}
)._registeredTools;
expect(registeredTools["openproject_list_projects"]).toBeDefined();
expect(registeredTools["openproject_get_project"]).toBeDefined();
});
});