diff --git a/src/tools/wikis.ts b/src/tools/wikis.ts new file mode 100644 index 0000000..e17b07c --- /dev/null +++ b/src/tools/wikis.ts @@ -0,0 +1,173 @@ +/** + * Wiki Tools for OpenProject MCP. + * Implements openproject_get_wiki_page, openproject_search_wiki_pages, and openproject_list_wiki_page_links 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 { + getWikiPage, + searchWikiPages, + listWikiPageLinks, +} from "../services/wikis"; + +export const getWikiPageShape = { + id: z + .number() + .int() + .positive() + .describe("Numeric ID of the wiki page"), +}; + +export type GetWikiPageArgs = z.input>; + +/** + * Tool execution handler for openproject_get_wiki_page. + */ +export async function handleGetWikiPage( + args: GetWikiPageArgs +): Promise { + try { + const result = await getWikiPage(args.id); + return formatToolSuccess(result); + } catch (error) { + return formatToolError(error); + } +} + +export const searchWikiPagesShape = { + query: z + .string() + .optional() + .describe("Substring keyword to match in wiki page title"), + projectId: z + .union([z.string(), z.number()]) + .optional() + .describe("Project ID or identifier to scope search"), + limit: z + .number() + .int() + .positive() + .max(100) + .optional() + .default(20) + .describe("Maximum results to return"), + refreshCache: z + .boolean() + .optional() + .default(false) + .describe("Force cache refresh"), +}; + +export type SearchWikiPagesArgs = z.input>; + +/** + * Tool execution handler for openproject_search_wiki_pages. + */ +export async function handleSearchWikiPages( + args: SearchWikiPagesArgs +): Promise { + try { + const result = await searchWikiPages(args); + return formatToolSuccess(result); + } catch (error) { + return formatToolError(error); + } +} + +export const listWikiPageLinksShape = { + workPackageId: z + .number() + .int() + .positive() + .optional() + .describe("Work package ID to scope links"), + offset: z + .number() + .int() + .positive() + .optional() + .default(1) + .describe("Page number to retrieve (1-based, default 1)"), + pageSize: z + .number() + .int() + .positive() + .max(100) + .optional() + .default(30) + .describe("Number of items per page (max 100, default 30)"), +}; + +export type ListWikiPageLinksArgs = z.input< + z.ZodObject +>; + +/** + * Tool execution handler for openproject_list_wiki_page_links. + */ +export async function handleListWikiPageLinks( + args: ListWikiPageLinksArgs +): Promise { + try { + const result = await listWikiPageLinks(args); + return formatToolSuccess(result); + } catch (error) { + return formatToolError(error); + } +} + +export const getWikiPageTool: ToolDefinition< + typeof getWikiPageShape, + GetWikiPageArgs +> = { + name: "openproject_get_wiki_page", + description: "Retrieve wiki page metadata, project, and attachments by numeric ID.", + parameters: getWikiPageShape, + readOnly: true, + execute: handleGetWikiPage, +}; + +export const searchWikiPagesTool: ToolDefinition< + typeof searchWikiPagesShape, + SearchWikiPagesArgs +> = { + name: "openproject_search_wiki_pages", + description: "Discover and search wiki pages matching keywords or project.", + parameters: searchWikiPagesShape, + readOnly: true, + execute: handleSearchWikiPages, +}; + +export const listWikiPageLinksTool: ToolDefinition< + typeof listWikiPageLinksShape, + ListWikiPageLinksArgs +> = { + name: "openproject_list_wiki_page_links", + description: "List links connecting work packages to wiki pages.", + parameters: listWikiPageLinksShape, + readOnly: true, + execute: handleListWikiPageLinks, +}; + +export const wikisTools = [ + getWikiPageTool, + searchWikiPagesTool, + listWikiPageLinksTool, +]; + +/** + * Registers all wikis tools with an McpServer instance. + */ +export function registerWikisTools(server: McpServer): void { + registerTool(server, getWikiPageTool); + registerTool(server, searchWikiPagesTool); + registerTool(server, listWikiPageLinksTool); +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 2d2b84b..f98558b 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeEach } from "bun:test"; import { allTools, registerAllTools, @@ -64,11 +64,28 @@ import { meetingsTools, registerMeetingsTools, } from "../src/tools/meetings"; +import { + getWikiPageShape, + handleGetWikiPage, + searchWikiPagesShape, + handleSearchWikiPages, + listWikiPageLinksShape, + handleListWikiPageLinks, + getWikiPageTool, + searchWikiPagesTool, + listWikiPageLinksTool, + wikisTools, + registerWikisTools, +} from "../src/tools/wikis"; +import { clearWikiCache } from "../src/services/wikis"; import { OpenProjectNotFoundError } from "../src/client/errors"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { runWithContext } from "../src/context"; -import { OpenProjectClient } from "../src/client/api-client"; +import { + OpenProjectClient, + OpenProjectAuthenticationError, +} from "../src/client/api-client"; describe("Tool Utilities", () => { @@ -1329,6 +1346,296 @@ describe("Meeting Tools", () => { }); }); +describe("Wikis Tools", () => { + beforeEach(() => { + clearWikiCache(); + }); + + const dummyClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path === "/api/v3/wiki_pages/1" || path === "wiki_pages/1") { + return { + _type: "WikiPage", + id: 1, + title: "Architecture Guide", + _links: { + self: { href: "/api/v3/wiki_pages/1" }, + project: { href: "/api/v3/projects/4", title: "MCP Test Project" }, + }, + }; + } + if ( + path === "/api/v3/wiki_pages/1/attachments" || + path === "wiki_pages/1/attachments" + ) { + return { + _type: "Collection", + total: 1, + _embedded: { + elements: [ + { + _type: "Attachment", + id: 99, + fileName: "architecture.png", + fileSize: 1024, + contentType: "image/png", + _links: { + downloadLocation: { + href: "/api/v3/attachments/99/content", + }, + }, + }, + ], + }, + }; + } + if ( + path.startsWith("/api/v3/wiki_page_links") || + path.startsWith("wiki_page_links") || + path.startsWith("/api/v3/work_packages/38/wiki_page_links") || + path.startsWith("work_packages/38/wiki_page_links") + ) { + return { + _type: "Collection", + total: 1, + count: 1, + pageSize: 30, + offset: 1, + _embedded: { + elements: [ + { + _type: "WikiPageLink", + id: 10, + pageTitle: "Architecture Guide", + pageUrl: "http://localhost:8080/projects/demo/wiki/architecture", + provider: "openproject", + _links: { + self: { href: "/api/v3/wiki_page_links/10" }, + wikiPage: { href: "/api/v3/wiki_pages/1" }, + workPackage: { + href: "/api/v3/work_packages/38", + title: "Seed Task 1", + }, + }, + }, + ], + }, + }; + } + if ( + path === "projects/4" || + path === "/api/v3/projects/4" || + path === "projects/mcp-test-project" || + path === "/api/v3/projects/mcp-test-project" + ) { + return { + id: 4, + identifier: "mcp-test-project", + name: "MCP Test Project", + }; + } + const err = new Error("Not found"); + (err as unknown as { statusCode: number }).statusCode = 404; + throw err; + }, + } as unknown as OpenProjectClient; + + test("wikisTools array contains 3 tools and all are readOnly: true", () => { + expect(wikisTools).toHaveLength(3); + expect(wikisTools.map((t) => t.name)).toEqual([ + "openproject_get_wiki_page", + "openproject_search_wiki_pages", + "openproject_list_wiki_page_links", + ]); + for (const tool of wikisTools) { + expect(tool.readOnly).toBe(true); + } + }); + + test("wikis tools have correct metadata and descriptions", () => { + expect(getWikiPageTool.name).toBe("openproject_get_wiki_page"); + expect(getWikiPageTool.description).toBe( + "Retrieve wiki page metadata, project, and attachments by numeric ID." + ); + expect(getWikiPageTool.readOnly).toBe(true); + + expect(searchWikiPagesTool.name).toBe("openproject_search_wiki_pages"); + expect(searchWikiPagesTool.description).toBe( + "Discover and search wiki pages matching keywords or project." + ); + expect(searchWikiPagesTool.readOnly).toBe(true); + + expect(listWikiPageLinksTool.name).toBe("openproject_list_wiki_page_links"); + expect(listWikiPageLinksTool.description).toBe( + "List links connecting work packages to wiki pages." + ); + expect(listWikiPageLinksTool.readOnly).toBe(true); + }); + + test("getWikiPage parses parameters and calls service", async () => { + const schema = z.object(getWikiPageShape); + const parsed = schema.parse({ id: 1 }); + expect(parsed.id).toBe(1); + + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleGetWikiPage(parsed) + ); + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.id).toBe(1); + expect(result.title).toBe("Architecture Guide"); + expect(result.project.id).toBe(4); + expect(result.attachments).toHaveLength(1); + expect(result.attachments[0].fileName).toBe("architecture.png"); + }); + + test("getWikiPage rejects invalid schema arguments", () => { + const schema = z.object(getWikiPageShape); + expect(() => schema.parse({})).toThrow(); + expect(() => schema.parse({ id: 0 })).toThrow(); + expect(() => schema.parse({ id: -1 })).toThrow(); + expect(() => schema.parse({ id: 1.5 })).toThrow(); + }); + + test("handleGetWikiPage catches errors and returns error response", async () => { + const failingClient = { + get: async () => { + throw new Error("Wiki page not found"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext( + { client: failingClient, isReadOnly: false }, + () => handleGetWikiPage({ id: 999 }) + ); + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Wiki page not found"); + }); + + test("searchWikiPages parses parameters and defaults correctly", async () => { + const schema = z.object(searchWikiPagesShape); + const parsedDefault = schema.parse({}); + expect(parsedDefault.limit).toBe(20); + expect(parsedDefault.refreshCache).toBe(false); + + const parsedCustom = schema.parse({ + query: "Guide", + projectId: "mcp-test-project", + limit: 10, + refreshCache: true, + }); + expect(parsedCustom.query).toBe("Guide"); + expect(parsedCustom.projectId).toBe("mcp-test-project"); + expect(parsedCustom.limit).toBe(10); + expect(parsedCustom.refreshCache).toBe(true); + + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleSearchWikiPages(parsedCustom) + ); + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "[]"); + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + expect(result[0].title).toBe("Architecture Guide"); + expect(result[0].attachmentsCount).toBe(1); + }); + + test("searchWikiPages rejects invalid schema arguments", () => { + const schema = z.object(searchWikiPagesShape); + expect(() => schema.parse({ limit: 0 })).toThrow(); + expect(() => schema.parse({ limit: -5 })).toThrow(); + expect(() => schema.parse({ limit: 150 })).toThrow(); + }); + + test("handleSearchWikiPages catches errors and returns error response", async () => { + const failingClient = { + baseUrl: "http://localhost:8080", + get: async () => { + throw new OpenProjectAuthenticationError("Unauthorized"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext( + { client: failingClient, isReadOnly: false }, + () => handleSearchWikiPages({ query: "fail" }) + ); + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Unauthorized"); + }); + + test("listWikiPageLinks parses parameters and defaults correctly", async () => { + const schema = z.object(listWikiPageLinksShape); + const parsedDefault = schema.parse({}); + expect(parsedDefault.offset).toBe(1); + expect(parsedDefault.pageSize).toBe(30); + + const parsedCustom = schema.parse({ + workPackageId: 38, + offset: 2, + pageSize: 15, + }); + expect(parsedCustom.workPackageId).toBe(38); + expect(parsedCustom.offset).toBe(2); + expect(parsedCustom.pageSize).toBe(15); + + const response = await runWithContext( + { client: dummyClient, isReadOnly: false }, + () => handleListWikiPageLinks(parsedCustom) + ); + expect(response.isError).toBeUndefined(); + const result = JSON.parse(response.content[0]?.text ?? "{}"); + expect(result.elements).toBeDefined(); + expect(result.elements).toHaveLength(1); + expect(result.elements[0].id).toBe(10); + expect(result.elements[0].pageTitle).toBe("Architecture Guide"); + }); + + test("listWikiPageLinks rejects invalid schema arguments", () => { + const schema = z.object(listWikiPageLinksShape); + expect(() => schema.parse({ pageSize: 0 })).toThrow(); + expect(() => schema.parse({ pageSize: -1 })).toThrow(); + expect(() => schema.parse({ pageSize: 200 })).toThrow(); + expect(() => schema.parse({ offset: 0 })).toThrow(); + expect(() => schema.parse({ offset: -1 })).toThrow(); + expect(() => schema.parse({ workPackageId: 0 })).toThrow(); + expect(() => schema.parse({ workPackageId: -1 })).toThrow(); + }); + + test("handleListWikiPageLinks catches errors and returns error response", async () => { + const failingClient = { + get: async () => { + throw new Error("Wiki links service failure"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext( + { client: failingClient, isReadOnly: false }, + () => handleListWikiPageLinks({ offset: 1, pageSize: 30 }) + ); + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Wiki links service failure"); + }); + + test("registerWikisTools registers all 3 wiki tools on McpServer", () => { + const server = new McpServer({ name: "test-mcp", version: "1.0.0" }); + registerWikisTools(server); + + const registeredTools = ( + server as unknown as { + _registeredTools: Record; + } + )._registeredTools; + + expect(registeredTools["openproject_get_wiki_page"]).toBeDefined(); + expect(registeredTools["openproject_search_wiki_pages"]).toBeDefined(); + expect(registeredTools["openproject_list_wiki_page_links"]).toBeDefined(); + }); +}); + describe("Tool Registry", () => { test("allTools contains exactly 12 tools including OpenAPI spec", () => { expect(allTools).toHaveLength(12);