feat(tools): implement query tools and registration
This commit is contained in:
@@ -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<z.ZodObject<typeof listQueriesShape>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool execution handler for openproject_list_queries.
|
||||||
|
*/
|
||||||
|
export async function handleListQueries(
|
||||||
|
args: ListQueriesArgs
|
||||||
|
): Promise<McpToolResponse> {
|
||||||
|
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<z.ZodObject<typeof getQueryShape>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool execution handler for openproject_get_query.
|
||||||
|
*/
|
||||||
|
export async function handleGetQuery(
|
||||||
|
args: GetQueryArgs
|
||||||
|
): Promise<McpToolResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -21,6 +21,14 @@ import {
|
|||||||
workPackageTools,
|
workPackageTools,
|
||||||
registerWorkPackageTools,
|
registerWorkPackageTools,
|
||||||
} from "../src/tools/work-packages";
|
} from "../src/tools/work-packages";
|
||||||
|
import {
|
||||||
|
listQueriesShape,
|
||||||
|
handleListQueries,
|
||||||
|
getQueryShape,
|
||||||
|
handleGetQuery,
|
||||||
|
queryTools,
|
||||||
|
registerQueryTools,
|
||||||
|
} from "../src/tools/queries";
|
||||||
import { OpenProjectNotFoundError } from "../src/client/errors";
|
import { OpenProjectNotFoundError } from "../src/client/errors";
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { z } from "zod";
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
)._registeredTools;
|
||||||
|
|
||||||
|
expect(registeredTools["openproject_list_queries"]).toBeDefined();
|
||||||
|
expect(registeredTools["openproject_get_query"]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user