feat(tools): implement query tools and registration

This commit is contained in:
Lucas Winther committed 2026-09-06 17:54:42 +02:00
1 parent e3c956f900
commit ff05b48f81
2 files changed
+350

No files matched your search

+201
View File
@@ -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<string, unknown>;
}
)._registeredTools;
expect(registeredTools["openproject_list_queries"]).toBeDefined();
expect(registeredTools["openproject_get_query"]).toBeDefined();
});
});