# MCP Tool Definitions & Schema Registration Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Expose OpenProject capabilities as 10 Model Context Protocol (MCP) tools with Zod input validation, error sanitization, and server registration. **Architecture:** Modular tool definitions in `src/tools/` wrapping domain services (`src/services/`), returning standard MCP text content blocks, resolving client via ambient `RequestContext`, and registering on `@modelcontextprotocol/sdk` `McpServer`. **Tech Stack:** Bun, TypeScript, `@modelcontextprotocol/sdk` (McpServer), Zod, `bun:test`. **Spec:** [docs/specs/2026-09-06-mcp-tools-design.md](file:///home/user/openproject-mcp/docs/specs/2026-09-06-mcp-tools-design.md) ## Global Constraints - Bun runtime (>= 1.3), `bun test` for verification. - Strict TypeScript (`strict: true`), no `any` (use explicit Zod inference or `unknown`). - Stateless execution: resolve ambient client via `RequestContext` (no global mutable client). - All tools are read-only (`readOnly: true`). - Standard MCP tool return format: `{ content: [{ type: "text", text: string }], isError?: boolean }`. - Plain text responses / no secrets in error messages. --- ### Task 1: Common Tool Utilities & Types **Files:** - Create: `src/tools/common.ts` - Test: `tests/tools.test.ts` **Interfaces:** - Consumes: `src/client/errors.ts`, `zod`, `@modelcontextprotocol/sdk/server/mcp.js` - Produces: - `McpToolResponse`: `{ content: Array<{ type: "text"; text: string }>; isError?: boolean }` - `formatToolSuccess(data: unknown): McpToolResponse` - `formatToolError(error: unknown): McpToolResponse` - `ToolDefinition` interface: `{ name: string; description: string; schema?: TShape; readOnly: boolean; execute: (args: any) => Promise }` - `registerTool(server: McpServer, tool: ToolDefinition): void` - [x] **Step 1: Write failing tests for common tool utilities** In `tests/tools.test.ts`: ```typescript import { describe, expect, test } from "bun:test"; import { formatToolSuccess, formatToolError } from "../src/tools/common"; import { OpenProjectNotFoundError } from "../src/client/errors"; describe("Tool Utilities", () => { test("formatToolSuccess formats data into MCP text response", () => { const data = { id: 1, name: "Test" }; const response = formatToolSuccess(data); expect(response.isError).toBeUndefined(); expect(response.content).toHaveLength(1); expect(response.content[0].type).toBe("text"); expect(JSON.parse(response.content[0].text)).toEqual(data); }); test("formatToolError formats error into MCP isError response", () => { const error = new OpenProjectNotFoundError("Project 99 not found"); const response = formatToolError(error); expect(response.isError).toBe(true); expect(response.content).toHaveLength(1); expect(response.content[0].type).toBe("text"); expect(response.content[0].text).toContain("Project 99 not found"); }); test("formatToolError handles non-Error unknown values", () => { const response = formatToolError("unexpected failure string"); expect(response.isError).toBe(true); expect(response.content[0].text).toContain("unexpected failure string"); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/tools.test.ts` Expected: FAIL with module `../src/tools/common` not found. - [x] **Step 3: Implement minimal code for common tool utilities** In `src/tools/common.ts`: ```typescript import type { ZodRawShape } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { OpenProjectError } from "../client/errors"; export interface McpToolResponse { content: Array<{ type: "text"; text: string }>; isError?: boolean; } export function formatToolSuccess(data: unknown): McpToolResponse { return { content: [ { type: "text", text: JSON.stringify(data, null, 2), }, ], }; } export function formatToolError(error: unknown): McpToolResponse { let message: string; if (error instanceof OpenProjectError) { message = `Error [${error.code}]: ${error.message}`; } else if (error instanceof Error) { message = `Error: ${error.message}`; } else { message = `Error: ${String(error)}`; } return { content: [ { type: "text", text: message, }, ], isError: true, }; } export interface ToolDefinition { name: string; description: string; parameters?: TShape; readOnly: boolean; execute: (args: any) => Promise; } export function registerTool(server: McpServer, tool: ToolDefinition): void { if (tool.parameters) { server.tool(tool.name, tool.description, tool.parameters, async (args: any) => { return tool.execute(args); }); } else { server.tool(tool.name, tool.description, async () => { return tool.execute({}); }); } } ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/tools.test.ts` Expected: PASS (3 pass). - [x] **Step 5: Commit** ```bash git add src/tools/common.ts tests/tools.test.ts git commit -m "feat(tools): add common tool utilities, types, and error formatting" ``` --- ### Task 2: Project Tools **Files:** - Create: `src/tools/projects.ts` - Modify: `tests/tools.test.ts` **Interfaces:** - Consumes: `src/tools/common.ts`, `src/services/projects.ts`, `zod` - Produces: - `listProjectsShape`: ZodRawShape for `openproject_list_projects` - `handleListProjects(args)`: handler returning `Promise` - `getProjectShape`: ZodRawShape for `openproject_get_project` - `handleGetProject(args)`: handler returning `Promise` - `listProjectsTool`: ToolDefinition - `getProjectTool`: ToolDefinition - `projectTools`: ToolDefinition[] - `registerProjectTools(server: McpServer): void` - [x] **Step 1: Write failing tests for project tools** In `tests/tools.test.ts`: ```typescript import { listProjectsShape, handleListProjects, getProjectShape, handleGetProject, projectTools, registerProjectTools, } from "../src/tools/projects"; import { runWithContext } from "../src/context"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; describe("Project Tools", () => { const dummyClient = { get: async (path: string, query?: Record) => { if (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, pageSize: 20, offset: 1, }; }, } as any; 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("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 any; 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", ]); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/tools.test.ts` Expected: FAIL with module `../src/tools/projects` not found. - [x] **Step 3: Implement project tools** In `src/tools/projects.ts`: ```typescript 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"; 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 async function handleListProjects(args: z.infer>): Promise { try { const result = await listProjects(args); return formatToolSuccess(result); } 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 async function handleGetProject(args: z.infer>): Promise { try { const result = await getProject(args.projectId); return formatToolSuccess(result); } catch (error) { return formatToolError(error); } } export const listProjectsTool: ToolDefinition = { 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 = { 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 { for (const tool of projectTools) { registerTool(server, tool); } } ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/tools.test.ts` Expected: PASS. - [x] **Step 5: Commit** ```bash git add src/tools/projects.ts tests/tools.test.ts git commit -m "feat(tools): implement project tools and registration" ``` --- ### Task 3: Work Package Tools **Files:** - Create: `src/tools/work-packages.ts` - Modify: `tests/tools.test.ts` **Interfaces:** - Consumes: `src/tools/common.ts`, `src/services/work-packages.ts`, `zod` - Produces: - `listWorkPackagesShape`: ZodRawShape for `openproject_list_work_packages` - `handleListWorkPackages(args)`: handler returning `Promise` - `getWorkPackageShape`: ZodRawShape for `openproject_get_work_package` - `handleGetWorkPackage(args)`: handler returning `Promise` - `listWorkPackagesTool`: ToolDefinition - `getWorkPackageTool`: ToolDefinition - `workPackageTools`: ToolDefinition[] - `registerWorkPackageTools(server: McpServer): void` - [x] **Step 1: Write failing tests for work package tools** In `tests/tools.test.ts`: ```typescript import { listWorkPackagesShape, handleListWorkPackages, getWorkPackageShape, handleGetWorkPackage, workPackageTools, registerWorkPackageTools, } from "../src/tools/work-packages"; describe("Work Package Tools", () => { const dummyClient = { get: async (path: string) => { if (path === "/api/v3/work_packages/38") { return { id: 38, subject: "Seed Task 1", description: { raw: "Desc" }, startDate: "2026-09-01", dueDate: "2026-09-10", estimatedTime: "PT8H", percentageDone: 0, createdAt: "2026-09-01T00:00:00Z", updatedAt: "2026-09-01T00:00:00Z", _links: { self: { href: "/api/v3/work_packages/38" }, project: { href: "/api/v3/projects/4", title: "MCP Test Project" }, type: { href: "/api/v3/types/1", title: "Task" }, status: { href: "/api/v3/statuses/1", title: "New" }, }, }; } return { _embedded: { elements: [ { id: 38, subject: "Seed Task 1", _links: { self: { href: "/api/v3/work_packages/38" }, project: { href: "/api/v3/projects/4", title: "MCP Test Project" }, type: { href: "/api/v3/types/1", title: "Task" }, status: { href: "/api/v3/statuses/1", title: "New" }, }, }, ], }, total: 1, pageSize: 20, offset: 1, }; }, } as any; test("listWorkPackages parses parameters and returns normalized list", async () => { const schema = z.object(listWorkPackagesShape); const parsed = schema.parse({ projectId: 4, status: "open" }); const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => handleListWorkPackages(parsed) ); expect(response.isError).toBeUndefined(); const result = JSON.parse(response.content[0].text); expect(result.workPackages).toHaveLength(1); expect(result.workPackages[0].id).toBe(38); expect(result.workPackages[0].subject).toBe("Seed Task 1"); }); test("getWorkPackage parses numeric ID and returns details", async () => { const schema = z.object(getWorkPackageShape); const parsed = schema.parse({ workPackageId: 38 }); const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => handleGetWorkPackage(parsed) ); expect(response.isError).toBeUndefined(); const result = JSON.parse(response.content[0].text); expect(result.id).toBe(38); expect(result.subject).toBe("Seed Task 1"); expect(result.project?.title).toBe("MCP Test Project"); }); 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.map((t) => t.name)).toEqual([ "openproject_list_work_packages", "openproject_get_work_package", ]); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/tools.test.ts` Expected: FAIL with module `../src/tools/work-packages` not found. - [x] **Step 3: Implement work package tools** In `src/tools/work-packages.ts`: ```typescript import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { formatToolSuccess, formatToolError, registerTool, type McpToolResponse, type ToolDefinition } from "./common"; import { listWorkPackages, getWorkPackage } from "../services/work-packages"; export const listWorkPackagesShape = { projectId: z.union([z.number().int().positive(), z.string()]).optional().describe("Scope work packages to a specific project by numeric ID or slug identifier"), status: z.union([z.enum(["open", "closed"]), z.number().int().positive()]).optional().describe("Filter by status: 'open', 'closed', or numeric status ID"), type: z.number().int().positive().optional().describe("Filter by work package type ID (e.g. 1 for Task, 2 for Milestone)"), assigneeId: z.number().int().positive().optional().describe("Filter by assignee user ID"), subject: z.string().optional().describe("Filter by substring search in work package subject"), pageSize: z.number().int().positive().max(100).optional().describe("Number of items 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. 'id:asc', 'updatedAt:desc')"), filters: z.string().optional().describe("Optional raw OpenProject JSON filter string array overriding individual filters"), }; export async function handleListWorkPackages(args: z.infer>): Promise { try { const result = await listWorkPackages(args); return formatToolSuccess(result); } catch (error) { return formatToolError(error); } } export const getWorkPackageShape = { workPackageId: z.number().int().positive().describe("The numeric ID of the work package"), }; export async function handleGetWorkPackage(args: z.infer>): Promise { try { const result = await getWorkPackage(args.workPackageId); return formatToolSuccess(result); } catch (error) { return formatToolError(error); } } export const listWorkPackagesTool: ToolDefinition = { name: "openproject_list_work_packages", description: "Browse and filter work packages (tasks, bugs, milestones, features) across projects or within a specific project", parameters: listWorkPackagesShape, readOnly: true, execute: handleListWorkPackages, }; export const getWorkPackageTool: ToolDefinition = { name: "openproject_get_work_package", description: "Get full details of a specific work package by its numeric ID, including status, priority, assignee, parent, and child relations", parameters: getWorkPackageShape, readOnly: true, execute: handleGetWorkPackage, }; export const workPackageTools = [listWorkPackagesTool, getWorkPackageTool]; export function registerWorkPackageTools(server: McpServer): void { for (const tool of workPackageTools) { registerTool(server, tool); } } ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/tools.test.ts` Expected: PASS. - [x] **Step 5: Commit** ```bash git add src/tools/work-packages.ts tests/tools.test.ts git commit -m "feat(tools): implement work package tools and registration" ``` --- ### Task 4: Query Tools **Files:** - Create: `src/tools/queries.ts` - Modify: `tests/tools.test.ts` **Interfaces:** - Consumes: `src/tools/common.ts`, `src/services/queries.ts`, `zod` - Produces: - `listQueriesShape`: ZodRawShape for `openproject_list_queries` - `handleListQueries(args)`: handler returning `Promise` - `getQueryShape`: ZodRawShape for `openproject_get_query` - `handleGetQuery(args)`: handler returning `Promise` - `listQueriesTool`: ToolDefinition - `getQueryTool`: ToolDefinition - `queryTools`: ToolDefinition[] - `registerQueryTools(server: McpServer): void` - [x] **Step 1: Write failing tests for query tools** In `tests/tools.test.ts`: ```typescript import { listQueriesShape, handleListQueries, getQueryShape, handleGetQuery, queryTools, registerQueryTools, } from "../src/tools/queries"; describe("Query Tools", () => { const dummyClient = { get: async (path: string) => { if (path === "/api/v3/queries/30") { return { id: 30, name: "MCP Active Tasks", public: true, starred: false, createdAt: "2026-09-01T00:00:00Z", updatedAt: "2026-09-01T00:00:00Z", _links: { self: { href: "/api/v3/queries/30" }, results: { href: "/api/v3/queries/30/results" }, }, }; } if (path === "/api/v3/queries/30/results") { return { _embedded: { elements: [ { id: 38, subject: "Seed Task 1", _links: { self: { href: "/api/v3/work_packages/38" }, type: { href: "/api/v3/types/1", title: "Task" }, status: { href: "/api/v3/statuses/1", title: "New" }, }, }, ], }, total: 1, pageSize: 20, offset: 1, }; } return { _embedded: { elements: [ { id: 30, name: "MCP Active Tasks", public: true, starred: false, _links: { self: { href: "/api/v3/queries/30" } }, }, ], }, total: 1, pageSize: 20, offset: 1, }; }, } as any; test("listQueries parses parameters and returns normalized query list", async () => { const schema = z.object(listQueriesShape); const parsed = schema.parse({ projectId: 4 }); const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => handleListQueries(parsed) ); expect(response.isError).toBeUndefined(); const result = JSON.parse(response.content[0].text); expect(result.queries).toHaveLength(1); expect(result.queries[0].id).toBe(30); }); test("getQuery returns query details along with result work packages", async () => { const schema = z.object(getQueryShape); const parsed = schema.parse({ queryId: 30 }); const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => handleGetQuery(parsed) ); expect(response.isError).toBeUndefined(); const result = JSON.parse(response.content[0].text); expect(result.query.id).toBe(30); expect(result.results.workPackages).toHaveLength(1); expect(result.results.workPackages[0].id).toBe(38); }); test("registerQueryTools registers tools on McpServer", () => { const server = new McpServer({ name: "test-mcp", version: "1.0.0" }); registerQueryTools(server); expect(queryTools).toHaveLength(2); expect(queryTools.map((t) => t.name)).toEqual([ "openproject_list_queries", "openproject_get_query", ]); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/tools.test.ts` Expected: FAIL with module `../src/tools/queries` not found. - [x] **Step 3: Implement query tools** In `src/tools/queries.ts`: ```typescript import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { formatToolSuccess, formatToolError, registerTool, type McpToolResponse, type ToolDefinition } from "./common"; import { listQueries, getQuery, getQueryResults } from "../services/queries"; export const listQueriesShape = { projectId: z.union([z.number().int().positive(), z.string()]).optional().describe("Optional project ID or identifier to scope saved queries"), pageSize: z.number().int().positive().max(100).optional().describe("Number of queries to return per page (max 100, default 20)"), offset: z.number().int().positive().optional().describe("Page number to retrieve (1-based, default 1)"), }; export async function handleListQueries(args: z.infer>): Promise { try { const result = await listQueries(args); return formatToolSuccess(result); } catch (error) { return formatToolError(error); } } export const getQueryShape = { queryId: z.number().int().positive().describe("The numeric ID of the saved query"), pageSize: z.number().int().positive().max(100).optional().describe("Maximum number of result work packages to return (max 100, default 20)"), offset: z.number().int().positive().optional().describe("Page number of results to retrieve (1-based, default 1)"), }; export async function handleGetQuery(args: z.infer>): Promise { try { const query = await getQuery(args.queryId); const results = await getQueryResults(args.queryId, { pageSize: args.pageSize, offset: args.offset, }); return formatToolSuccess({ query, results }); } catch (error) { return formatToolError(error); } } export const listQueriesTool: ToolDefinition = { name: "openproject_list_queries", description: "List saved queries (custom views, filter sets) accessible to the user, optionally filtered by project", parameters: listQueriesShape, readOnly: true, execute: handleListQueries, }; export const getQueryTool: ToolDefinition = { name: "openproject_get_query", description: "Retrieve metadata and work package results for a saved query", parameters: getQueryShape, readOnly: true, execute: handleGetQuery, }; export const queryTools = [listQueriesTool, getQueryTool]; export function registerQueryTools(server: McpServer): void { for (const tool of queryTools) { registerTool(server, tool); } } ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/tools.test.ts` Expected: PASS. - [x] **Step 5: Commit** ```bash git add src/tools/queries.ts tests/tools.test.ts git commit -m "feat(tools): implement query tools and registration" ``` --- ### Task 5: Metadata Tools **Files:** - Create: `src/tools/metadata.ts` - Modify: `tests/tools.test.ts` **Interfaces:** - Consumes: `src/tools/common.ts`, `src/services/metadata.ts`, `zod` - Produces: - `listTypesShape`: ZodRawShape for `openproject_list_types` - `handleListTypes(args)`: handler returning `Promise` - `listStatusesShape`: empty ZodRawShape `{}` - `handleListStatuses(args)`: handler returning `Promise` - `listPrioritiesShape`: empty ZodRawShape `{}` - `handleListPriorities(args)`: handler returning `Promise` - `listUsersShape`: ZodRawShape for `openproject_list_users` - `handleListUsers(args)`: handler returning `Promise` - `metadataTools`: ToolDefinition[] - `registerMetadataTools(server: McpServer): void` - [x] **Step 1: Write failing tests for metadata tools** In `tests/tools.test.ts`: ```typescript import { listTypesShape, handleListTypes, listStatusesShape, handleListStatuses, listPrioritiesShape, handleListPriorities, listUsersShape, handleListUsers, metadataTools, registerMetadataTools, } from "../src/tools/metadata"; describe("Metadata Tools", () => { const dummyClient = { get: async (path: string) => { if (path === "/api/v3/types") { return { _embedded: { elements: [ { id: 1, name: "Task", color: "#1a73e8", isDefault: true, isMilestone: false, _links: { self: { href: "/api/v3/types/1" } } }, ], }, total: 1, }; } if (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, }; } if (path === "/api/v3/priorities") { return { _embedded: { elements: [ { id: 1, name: "Normal", isDefault: true, _links: { self: { href: "/api/v3/priorities/1" } } }, ], }, total: 1, }; } if (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, pageSize: 20, offset: 1, }; } return { _embedded: { elements: [] }, total: 0 }; }, } as any; 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("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("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("listUsers returns paginated user list", async () => { const response = await runWithContext({ client: dummyClient, isReadOnly: false }, () => handleListUsers({ pageSize: 10 }) ); 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"); }); 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", ]); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/tools.test.ts` Expected: FAIL with module `../src/tools/metadata` not found. - [x] **Step 3: Implement metadata tools** In `src/tools/metadata.ts`: ```typescript 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()]).optional().describe("Optional numeric project ID or identifier to scope types"), }; export async function handleListTypes(args: z.infer>): Promise { try { const result = await listTypes(args.projectId); return formatToolSuccess(result); } catch (error) { return formatToolError(error); } } export const listStatusesShape = {}; export async function handleListStatuses(_args: Record): Promise { try { const result = await listStatuses(); return formatToolSuccess(result); } catch (error) { return formatToolError(error); } } export const listPrioritiesShape = {}; export async function handleListPriorities(_args: Record): Promise { try { const result = await listPriorities(); return formatToolSuccess(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)"), }; export async function handleListUsers(args: z.infer>): Promise { try { const result = await listUsers(args); return formatToolSuccess(result); } catch (error) { return formatToolError(error); } } export const listTypesTool: ToolDefinition = { 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 = { 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 = { name: "openproject_list_priorities", description: "List all priority levels (Low, Normal, High, Immediate)", parameters: listPrioritiesShape, readOnly: true, execute: handleListPriorities, }; export const listUsersTool: ToolDefinition = { 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, ]; export function registerMetadataTools(server: McpServer): void { for (const tool of metadataTools) { registerTool(server, tool); } } ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/tools.test.ts` Expected: PASS. - [x] **Step 5: Commit** ```bash git add src/tools/metadata.ts tests/tools.test.ts git commit -m "feat(tools): implement metadata tools and registration" ``` --- ### Task 6: Tool Registry & Barrel Export **Files:** - Create: `src/tools/index.ts` - Modify: `tests/tools.test.ts` **Interfaces:** - Consumes: `src/tools/common.ts`, `src/tools/projects.ts`, `src/tools/work-packages.ts`, `src/tools/queries.ts`, `src/tools/metadata.ts` - Produces: - `allTools`: Array of all 10 `ToolDefinition` objects - `registerAllTools(server: McpServer, options?: { readOnly?: boolean }): void` - Re-exports of all individual tools, shapes, and handlers - [x] **Step 1: Write failing tests for tool registry** In `tests/tools.test.ts`: ```typescript import { allTools, registerAllTools } from "../src/tools/index"; describe("Tool Registry", () => { test("allTools contains exactly 10 Phase 1 tools", () => { expect(allTools).toHaveLength(10); 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_queries", "openproject_get_query", "openproject_list_types", "openproject_list_statuses", "openproject_list_priorities", "openproject_list_users", ]); }); test("all tools in Phase 1 are marked readOnly: true", () => { for (const tool of allTools) { expect(tool.readOnly).toBe(true); } }); test("registerAllTools registers all 10 tools on McpServer", () => { const server = new McpServer({ name: "test-mcp", version: "1.0.0" }); registerAllTools(server); // Verified via successful registration without throws expect(true).toBe(true); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/tools.test.ts` Expected: FAIL with module `../src/tools/index` not found. - [x] **Step 3: Implement tool registry** In `src/tools/index.ts`: ```typescript import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerTool, type ToolDefinition } from "./common"; import { projectTools } from "./projects"; import { workPackageTools } from "./work-packages"; import { queryTools } from "./queries"; import { metadataTools } from "./metadata"; export * from "./common"; export * from "./projects"; export * from "./work-packages"; export * from "./queries"; export * from "./metadata"; export const allTools: ToolDefinition[] = [ ...projectTools, ...workPackageTools, ...queryTools, ...metadataTools, ]; export interface RegisterToolsOptions { readOnly?: boolean; } export function registerAllTools(server: McpServer, options?: RegisterToolsOptions): void { const isReadOnly = options?.readOnly ?? false; for (const tool of allTools) { // If server is in read-only mode, skip any non-read-only tools if (isReadOnly && !tool.readOnly) { continue; } registerTool(server, tool); } } ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/tools.test.ts` Expected: PASS. - [x] **Step 5: Commit** ```bash git add src/tools/index.ts tests/tools.test.ts git commit -m "feat(tools): add tool registry and barrel export" ``` --- ### Task 7: Live Container Integration & End-to-End Verification **Files:** - Modify: `tests/tools.test.ts` - Modify: `docs/TODO.md` - [x] **Step 1: Add live integration tests for all 10 tools** In `tests/tools.test.ts`: ```typescript import { OpenProjectClient } from "../src/client/api-client"; import { handleListProjects, handleGetProject, handleListWorkPackages, handleGetWorkPackage, handleListQueries, handleGetQuery, handleListTypes, handleListStatuses, handleListPriorities, handleListUsers, } from "../src/tools/index"; describe("Live Container Integration (All 10 MCP Tools)", () => { const baseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080"; const apiKey = process.env.OPENPROJECT_API_KEY || ""; const liveClient = new OpenProjectClient({ baseUrl, apiKey }); test("live: list projects and get project tool execution", async () => { await runWithContext({ client: liveClient, isReadOnly: false }, async () => { const listRes = await handleListProjects({ pageSize: 5 }); expect(listRes.isError).toBeUndefined(); const listData = JSON.parse(listRes.content[0].text); expect(listData.projects.length).toBeGreaterThan(0); const targetProject = listData.projects.find((p: any) => p.identifier === "mcp-test-project") || listData.projects[0]; const getRes = await handleGetProject({ projectId: targetProject.id }); expect(getRes.isError).toBeUndefined(); const getData = JSON.parse(getRes.content[0].text); expect(getData.id).toBe(targetProject.id); }); }); test("live: list work packages and get work package tool execution", async () => { await runWithContext({ client: liveClient, isReadOnly: false }, async () => { const listRes = await handleListWorkPackages({ pageSize: 5 }); expect(listRes.isError).toBeUndefined(); const listData = JSON.parse(listRes.content[0].text); expect(listData.workPackages.length).toBeGreaterThan(0); const wpId = listData.workPackages[0].id; const getRes = await handleGetWorkPackage({ workPackageId: wpId }); expect(getRes.isError).toBeUndefined(); const getData = JSON.parse(getRes.content[0].text); expect(getData.id).toBe(wpId); expect(getData.subject).toBeDefined(); }); }); test("live: list queries and get query tool execution", async () => { await runWithContext({ client: liveClient, isReadOnly: false }, async () => { const listRes = await handleListQueries({}); expect(listRes.isError).toBeUndefined(); const listData = JSON.parse(listRes.content[0].text); expect(listData.queries.length).toBeGreaterThan(0); const queryId = listData.queries[0].id; const getRes = await handleGetQuery({ queryId }); expect(getRes.isError).toBeUndefined(); const getData = JSON.parse(getRes.content[0].text); expect(getData.query.id).toBe(queryId); expect(getData.results).toBeDefined(); }); }); test("live: metadata tools execution (types, statuses, priorities, users)", async () => { await runWithContext({ client: liveClient, isReadOnly: false }, async () => { const typesRes = await handleListTypes({}); expect(typesRes.isError).toBeUndefined(); const typesData = JSON.parse(typesRes.content[0].text); expect(typesData.types.length).toBeGreaterThan(0); const statusesRes = await handleListStatuses({}); expect(statusesRes.isError).toBeUndefined(); const statusesData = JSON.parse(statusesRes.content[0].text); expect(statusesData.statuses.length).toBeGreaterThan(0); const prioritiesRes = await handleListPriorities({}); expect(prioritiesRes.isError).toBeUndefined(); const prioritiesData = JSON.parse(prioritiesRes.content[0].text); expect(prioritiesData.priorities.length).toBeGreaterThan(0); const usersRes = await handleListUsers({ pageSize: 5 }); expect(usersRes.isError).toBeUndefined(); const usersData = JSON.parse(usersRes.content[0].text); expect(usersData.users.length).toBeGreaterThan(0); }); }); }); ``` - [x] **Step 2: Run all tests against live container** Run: `bun test` Expected: All tests pass (client, smoke, services, tools). - [x] **Step 3: Update `docs/TODO.md`** Mark Task 3 as completed in `docs/TODO.md`. - [x] **Step 4: Commit** ```bash git add tests/tools.test.ts docs/TODO.md git commit -m "feat(tools): add live container tests and mark Task 3 complete in TODO.md" ```