diff --git a/src/tools/queries.ts b/src/tools/queries.ts new file mode 100644 index 0000000..75a6e98 --- /dev/null +++ b/src/tools/queries.ts @@ -0,0 +1,149 @@ +/** + * Query Tools for OpenProject MCP. + * Implements openproject_list_queries and openproject_get_query 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 { listQueries, getQuery, getQueryResults } from "../services/queries"; + +export const listQueriesShape = { + projectId: z + .union([z.number().int().positive(), z.string().min(1)]) + .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 type ListQueriesArgs = z.infer>; + +/** + * Tool execution handler for openproject_list_queries. + */ +export async function handleListQueries( + args: ListQueriesArgs +): Promise { + try { + const result = await listQueries({ + projectId: args.projectId, + pageSize: args.pageSize, + offset: args.offset, + }); + + return formatToolSuccess({ + queries: result.items, + total: result.total, + count: result.count, + pageSize: result.pageSize, + offset: result.offset, + }); + } 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 type GetQueryArgs = z.infer>; + +/** + * Tool execution handler for openproject_get_query. + */ +export async function handleGetQuery( + args: GetQueryArgs +): Promise { + try { + const query = await getQuery(args.queryId); + const results = await getQueryResults(args.queryId, { + pageSize: args.pageSize, + offset: args.offset, + }); + + return formatToolSuccess({ + query, + results: { + workPackages: results.items, + total: results.total, + count: results.count, + pageSize: results.pageSize, + offset: results.offset, + }, + }); + } catch (error) { + return formatToolError(error); + } +} + +export const listQueriesTool: ToolDefinition< + typeof listQueriesShape, + ListQueriesArgs +> = { + 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< + typeof getQueryShape, + GetQueryArgs +> = { + 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]; + +/** + * Registers all query tools with an McpServer instance. + */ +export function registerQueryTools(server: McpServer): void { + registerTool(server, listQueriesTool); + registerTool(server, getQueryTool); +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 3682215..3d0dd31 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -21,6 +21,14 @@ import { workPackageTools, registerWorkPackageTools, } from "../src/tools/work-packages"; +import { + listQueriesShape, + handleListQueries, + getQueryShape, + handleGetQuery, + queryTools, + registerQueryTools, +} from "../src/tools/queries"; import { OpenProjectNotFoundError } from "../src/client/errors"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; @@ -456,4 +464,197 @@ describe("Work Package Tools", () => { }); }); +describe("Query Tools", () => { + const dummyClient = { + get: async (path: string) => { + if (path === "queries/30" || 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" }, + columns: [{ href: "/api/v3/queries/columns/id", title: "ID" }], + }, + }; + } + if ( + path === "queries/30/results" || + path === "/api/v3/queries/30/results" + ) { + 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, + count: 1, + pageSize: 20, + offset: 1, + }; + } + if (path === "queries" || path === "/api/v3/queries") { + return { + _embedded: { + elements: [ + { + id: 30, + name: "MCP Active Tasks", + public: true, + starred: false, + _links: { + self: { href: "/api/v3/queries/30" }, + project: { href: "/api/v3/projects/4", title: "MCP Test Project" }, + }, + }, + ], + }, + total: 1, + count: 1, + pageSize: 20, + offset: 1, + }; + } + if (path === "projects/mcp-test-project" || path === "/api/v3/projects/mcp-test-project") { + return { id: 4 }; + } + throw new Error(`Unexpected path: ${path}`); + }, + } as unknown as OpenProjectClient; + + test("listQueries parses parameters and returns normalized query list", async () => { + const schema = z.object(listQueriesShape); + const parsed = schema.parse({ projectId: 4, pageSize: 10, offset: 1 }); + 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); + expect(result.queries[0].name).toBe("MCP Active Tasks"); + expect(result.total).toBe(1); + expect(result.count).toBe(1); + expect(result.pageSize).toBe(20); + expect(result.offset).toBe(1); + }); + + test("listQueries accepts string projectId", async () => { + const schema = z.object(listQueriesShape); + const parsed = schema.parse({ projectId: "mcp-test-project" }); + 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("listQueries rejects invalid schema arguments", () => { + const schema = z.object(listQueriesShape); + expect(() => schema.parse({ pageSize: 500 })).toThrow(); + expect(() => schema.parse({ pageSize: -1 })).toThrow(); + expect(() => schema.parse({ offset: 0 })).toThrow(); + expect(() => schema.parse({ projectId: "" })).toThrow(); + expect(() => schema.parse({ projectId: -4 })).toThrow(); + }); + + test("handleListQueries catches errors and returns error response", async () => { + const failingClient = { + get: async () => { + throw new Error("Queries service failure"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext({ client: failingClient, isReadOnly: false }, () => + handleListQueries({}) + ); + + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Queries service failure"); + }); + + test("getQuery returns query details along with result work packages", async () => { + const schema = z.object(getQueryShape); + const parsed = schema.parse({ queryId: 30, pageSize: 20, offset: 1 }); + 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.query.name).toBe("MCP Active Tasks"); + expect(result.results.workPackages).toHaveLength(1); + expect(result.results.workPackages[0].id).toBe(38); + expect(result.results.workPackages[0].subject).toBe("Seed Task 1"); + expect(result.results.total).toBe(1); + expect(result.results.count).toBe(1); + expect(result.results.pageSize).toBe(20); + expect(result.results.offset).toBe(1); + }); + + test("getQuery rejects invalid schema arguments", () => { + const schema = z.object(getQueryShape); + expect(() => schema.parse({ queryId: -1 })).toThrow(); + expect(() => schema.parse({ queryId: 0 })).toThrow(); + expect(() => schema.parse({ queryId: 3.14 })).toThrow(); + expect(() => schema.parse({ queryId: "30" })).toThrow(); + expect(() => schema.parse({ queryId: 30, pageSize: 500 })).toThrow(); + expect(() => schema.parse({ queryId: 30, offset: 0 })).toThrow(); + }); + + test("handleGetQuery catches errors and returns error response", async () => { + const failingClient = { + get: async () => { + throw new Error("Query not found"); + }, + } as unknown as OpenProjectClient; + + const response = await runWithContext({ client: failingClient, isReadOnly: false }, () => + handleGetQuery({ queryId: 999 }) + ); + + expect(response.isError).toBe(true); + expect(response.content[0]?.text).toContain("Query not found"); + }); + + 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", + ]); + + const registeredTools = ( + server as unknown as { + _registeredTools: Record; + } + )._registeredTools; + + expect(registeredTools["openproject_list_queries"]).toBeDefined(); + expect(registeredTools["openproject_get_query"]).toBeDefined(); + }); +}); + +