feat(work-packages): add ranked search tool over subject, description, and comments

Registers openproject_search_work_packages (readOnly: true), taking the tool
registry from 18 to 19. searchWorkPackages now unions a precise server-side
subject filter with a broad recency-ordered candidate window, ranks via the
shared search pipeline, and deep-enriches top candidates with description and
comment text. Exact-mode requests skip the broad query entirely.

searchWorkPackages's return type changes from a flat PaginatedResult<WorkPackageSummary>
to WorkPackageSearchPage, nesting the summary under `workPackage` alongside
score, matchedFields, and an optional snippet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-18 11:23:48 +02:00
1 parent 9dee458b1f
commit 326db0659f
7 files changed
+349 -29

No files matched your search

+4 -3
View File
@@ -5,7 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { createServer, SERVER_NAME, SERVER_VERSION } from "../src/server";
describe("MCP Server Factory", () => {
test("creates server and lists all 18 tools via InMemoryTransport", async () => {
test("creates server and lists all 19 tools via InMemoryTransport", async () => {
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
@@ -21,13 +21,14 @@ describe("MCP Server Factory", () => {
await client.connect(clientTransport);
const toolsResult = await client.listTools();
expect(toolsResult.tools).toHaveLength(18);
expect(toolsResult.tools).toHaveLength(19);
const names = toolsResult.tools.map((t) => t.name);
expect(names).toContain("openproject_list_projects");
expect(names).toContain("openproject_get_project");
expect(names).toContain("openproject_list_work_packages");
expect(names).toContain("openproject_get_work_package");
expect(names).toContain("openproject_list_work_package_activities");
expect(names).toContain("openproject_search_work_packages");
expect(names).toContain("openproject_list_queries");
expect(names).toContain("openproject_get_query");
expect(names).toContain("openproject_list_types");
@@ -143,7 +144,7 @@ describe("MCP Server Factory", () => {
// In read-only mode, only readOnly: true tools should be registered
const toolsResult = await client.listTools();
expect(toolsResult.tools).toHaveLength(18);
expect(toolsResult.tools).toHaveLength(19);
await client.close();
await mcpServer.stop();
+2 -2
View File
@@ -231,10 +231,10 @@ describe("OpenApi MCP Tool Registration & Execution", () => {
await server.close();
});
test("allTools includes openproject_get_openapi_spec (total: 18 tools)", () => {
test("allTools includes openproject_get_openapi_spec (total: 19 tools)", () => {
const toolNames = allTools.map((t) => t.name);
expect(toolNames).toContain("openproject_get_openapi_spec");
expect(allTools).toHaveLength(18);
expect(allTools).toHaveLength(19);
});
});
+3 -3
View File
@@ -74,7 +74,7 @@ describe("Read-Only Mode Enforcement", () => {
expect(configFalse.readOnly).toBe(false);
});
test("read-only server lists all 18 tools (all are readOnly: true)", async () => {
test("read-only server lists all 19 tools (all are readOnly: true)", async () => {
const mcpServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
@@ -87,7 +87,7 @@ describe("Read-Only Mode Enforcement", () => {
await client.connect(clientTransport);
const toolsResult = await client.listTools();
expect(toolsResult.tools).toHaveLength(18);
expect(toolsResult.tools).toHaveLength(19);
await client.close();
await mcpServer.stop();
@@ -118,7 +118,7 @@ describe("Read-Only Mode Enforcement", () => {
const standaloneTools = await client2.listTools();
const standaloneNames = standaloneTools.tools.map((t) => t.name);
expect(standaloneNames).not.toContain("openproject_create_work_package_synthetic");
expect(standaloneTools.tools).toHaveLength(18);
expect(standaloneTools.tools).toHaveLength(19);
await client2.close();
await standaloneServer.close();
+117 -7
View File
@@ -334,9 +334,12 @@ describe("Work Packages Service", () => {
});
test("searchWorkPackages sets subject query filter", async () => {
let requestedUrl = "";
// The union strategy issues several requests in parallel (the precise
// subject filter, a broad recency window, and per-candidate enrichment
// calls), so every requested URL is captured rather than just the last.
const requestedUrls: string[] = [];
const mockFetch = async (input: string | URL | Request) => {
requestedUrl = String(input);
requestedUrls.push(String(input));
return new Response(
JSON.stringify({
_type: "Collection",
@@ -357,11 +360,12 @@ describe("Work Packages Service", () => {
});
await searchWorkPackages("MCP Server", { projectId: 4 }, client);
expect(requestedUrl).toContain("filters=");
// Filter must include substring operator for subject
const decodedUrl = decodeURIComponent(requestedUrl.replace(/\+/g, " "));
expect(decodedUrl).toContain('"subject"');
expect(decodedUrl).toContain("MCP Server");
// Filter must include substring operator for subject on the precise request.
const decodedUrls = requestedUrls.map((url) => decodeURIComponent(url.replace(/\+/g, " ")));
const subjectRequestUrl = decodedUrls.find((url) => url.includes('"subject"'));
expect(subjectRequestUrl).toBeDefined();
expect(subjectRequestUrl).toContain("MCP Server");
});
});
@@ -776,3 +780,109 @@ describe("Live Container Integration (Domain Services)", () => {
});
});
describe("Work Package Fuzzy Search", () => {
function workPackageFixtureClient(calls: string[] = []) {
const elements = [
{
_type: "WorkPackage",
id: 1,
subject: "Fix login redirect",
_links: {
type: { title: "Bug" },
status: { title: "New" },
project: { href: "/api/v3/projects/1", title: "Demo project" },
},
updatedAt: "2026-09-10T00:00:00Z",
},
{
_type: "WorkPackage",
id: 2,
subject: "Update dependencies",
_links: {
type: { title: "Task" },
status: { title: "New" },
project: { href: "/api/v3/projects/1", title: "Demo project" },
},
updatedAt: "2026-09-11T00:00:00Z",
},
];
return {
get: async (path: string, query?: Record<string, unknown>) => {
// Record the query alongside the path so assertions can inspect the
// filter payload (the real client encodes filters into the query
// string of the resolved URL rather than the bare path).
calls.push(query ? `${path}?${JSON.stringify(query)}` : path);
if (path.startsWith("work_packages/") || path.startsWith("/api/v3/work_packages/")) {
// Extract the id following "work_packages/" specifically, rather
// than concatenating every digit in the path: the activities path
// (`/api/v3/work_packages/{id}/activities`) contains a stray digit
// in "v3" that a blanket \D+ strip would fold into the id.
const id = Number(path.match(/work_packages\/(\d+)/)?.[1] ?? NaN);
if (path.includes("activities")) {
return {
_type: "Collection",
_embedded: {
elements:
id === 2
? [
{
id: 90,
version: 1,
createdAt: "2026-09-11T00:00:00Z",
comment: { raw: "Blocked by the expired TLS certificate" },
_links: { user: { href: "/api/v3/users/4", title: "Admin" } },
},
]
: [],
},
};
}
const match = elements.find((e) => e.id === id);
return { ...match, description: { raw: id === 1 ? "Session cookie is dropped" : "" } };
}
return {
_type: "Collection",
total: elements.length,
count: elements.length,
pageSize: 100,
offset: 1,
_embedded: { elements },
};
},
} as unknown as OpenProjectClient;
}
test("finds a work package despite a typo in the subject query", async () => {
const result = await searchWorkPackages("login redirct", {}, workPackageFixtureClient());
expect(result.elements[0]!.workPackage.id).toBe(1);
});
test("finds a work package by a comment only", async () => {
const result = await searchWorkPackages("TLS certificate", {}, workPackageFixtureClient());
expect(result.elements[0]!.workPackage.id).toBe(2);
expect(result.elements[0]!.matchedFields).toContain("comments");
});
test("finds a work package by description only", async () => {
const result = await searchWorkPackages("session cookie", {}, workPackageFixtureClient());
expect(result.elements[0]!.workPackage.id).toBe(1);
expect(result.elements[0]!.matchedFields).toContain("description");
});
test("still issues the precise server-side subject filter", async () => {
const calls: string[] = [];
await searchWorkPackages("login", {}, workPackageFixtureClient(calls));
expect(calls.some((path) => path.includes("subject"))).toBe(true);
});
test("exact mode finds nothing for a typo", async () => {
const result = await searchWorkPackages(
"login redirct",
{ matchMode: "exact" },
workPackageFixtureClient()
);
expect(result.elements).toHaveLength(0);
});
});
+9 -6
View File
@@ -610,11 +610,12 @@ describe("Work Package Tools", () => {
test("registerWorkPackageTools registers tools on McpServer", () => {
const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
registerWorkPackageTools(server);
expect(workPackageTools).toHaveLength(3);
expect(workPackageTools).toHaveLength(4);
expect(workPackageTools.map((t) => t.name)).toEqual([
"openproject_list_work_packages",
"openproject_get_work_package",
"openproject_list_work_package_activities",
"openproject_search_work_packages",
]);
const registeredTools = (
@@ -628,6 +629,7 @@ describe("Work Package Tools", () => {
expect(
registeredTools["openproject_list_work_package_activities"]
).toBeDefined();
expect(registeredTools["openproject_search_work_packages"]).toBeDefined();
});
});
@@ -1643,8 +1645,8 @@ describe("Wikis Tools", () => {
});
describe("Tool Registry", () => {
test("allTools contains exactly 18 tools including OpenAPI spec, meetings, and wikis", () => {
expect(allTools).toHaveLength(18);
test("allTools contains exactly 19 tools including OpenAPI spec, meetings, and wikis", () => {
expect(allTools).toHaveLength(19);
const names = allTools.map((t) => t.name);
expect(names).toEqual([
"openproject_list_projects",
@@ -1652,6 +1654,7 @@ describe("Tool Registry", () => {
"openproject_list_work_packages",
"openproject_get_work_package",
"openproject_list_work_package_activities",
"openproject_search_work_packages",
"openproject_list_queries",
"openproject_get_query",
"openproject_list_types",
@@ -1674,7 +1677,7 @@ describe("Tool Registry", () => {
}
});
test("registerAllTools registers all 18 tools on McpServer", () => {
test("registerAllTools registers all 19 tools on McpServer", () => {
const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
registerAllTools(server);
@@ -1684,7 +1687,7 @@ describe("Tool Registry", () => {
}
)._registeredTools;
expect(Object.keys(registeredTools)).toHaveLength(18);
expect(Object.keys(registeredTools)).toHaveLength(19);
for (const tool of allTools) {
expect(registeredTools[tool.name]).toBeDefined();
}
@@ -1716,7 +1719,7 @@ describe("Tool Registry", () => {
expect(
registeredTools["openproject_synthetic_mutating_tool"]
).toBeUndefined();
expect(Object.keys(registeredTools)).toHaveLength(18);
expect(Object.keys(registeredTools)).toHaveLength(19);
for (const tool of allTools) {
expect(registeredTools[tool.name]).toBeDefined();
}