From 326db0659fb6c7b3a7bef0b6d3e760eb577d18a0 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 18 Sep 2026 11:23:48 +0200 Subject: [PATCH] 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 to WorkPackageSearchPage, nesting the summary under `workPackage` alongside score, matchedFields, and an optional snippet. Co-Authored-By: Claude Opus 5 (1M context) --- src/services/work-packages.ts | 142 ++++++++++++++++++++++++++++++++-- src/tools/work-packages.ts | 80 +++++++++++++++++++ tests/mcp-server.test.ts | 7 +- tests/openapi.test.ts | 4 +- tests/read-only.test.ts | 6 +- tests/services.test.ts | 124 +++++++++++++++++++++++++++-- tests/tools.test.ts | 15 ++-- 7 files changed, 349 insertions(+), 29 deletions(-) diff --git a/src/services/work-packages.ts b/src/services/work-packages.ts index ce8565e..48c9605 100644 --- a/src/services/work-packages.ts +++ b/src/services/work-packages.ts @@ -18,6 +18,8 @@ import type { WorkPackageSummary, } from "../client/types.ts"; import { resolveClient, resolveProjectId } from "./helper.ts"; +import { searchPipeline } from "../search/pipeline.ts"; +import type { FieldSpec, MatchMode } from "../search/rank.ts"; export interface ListWorkPackagesParams extends WorkPackageFilterParams { pageSize?: number; @@ -29,6 +31,10 @@ export interface SearchWorkPackagesOptions { projectId?: number | string; pageSize?: number; offset?: number; + status?: "open" | "closed" | string | number; + typeId?: number | string; + assigneeId?: number | string; + matchMode?: MatchMode; } /** @@ -73,23 +79,143 @@ export async function getWorkPackage( return normalizeWorkPackage(response); } +export interface WorkPackageSearchResult { + workPackage: WorkPackageSummary; + /** Relevance score in the range 0..1. */ + score: number; + matchedFields: string[]; + snippet?: string; +} + +export interface WorkPackageSearchPage extends PaginatedResult { + elements: WorkPackageSearchResult[]; + degraded: boolean; + enrichmentFailures: number; +} + +interface WorkPackageDeepContent { + id: number; + description: string; + comments: string[]; +} + +const MAX_WORK_PACKAGE_CANDIDATES = 250; + /** - * Convenience helper to search work packages by subject text, optionally within a project. + * Fetches the description and comment text for one work package. + */ +async function fetchWorkPackageDeepContent( + workPackageId: number, + client: OpenProjectClient +): Promise { + const [detail, activities] = await Promise.all([ + getWorkPackage(workPackageId, client), + listWorkPackageActivities({ workPackageId, onlyComments: true }, client), + ]); + + return { + id: workPackageId, + description: detail.description ?? "", + comments: activities + .map((activity) => activity.comment ?? "") + .filter((comment) => comment.length > 0), + }; +} + +/** + * Searches work packages by subject, description, and comments using fuzzy + * matching. + * + * Candidates come from the union of two parallel requests: the precise + * server-side `subject ~ query` filter (so every result the old + * implementation returned is still returned) and a broad recency-ordered + * window (so fuzzy matching has something to rank). Fuzzy matching therefore + * only ever adds results, never removes them. */ export async function searchWorkPackages( query: string, options?: SearchWorkPackagesOptions, client?: OpenProjectClient -): Promise> { - return listWorkPackages( +): Promise { + const opClient = resolveClient(client); + const matchMode = options?.matchMode ?? "fuzzy"; + + const sharedFilters = { + projectId: options?.projectId, + status: options?.status, + typeId: options?.typeId, + assigneeId: options?.assigneeId, + }; + + const fetchCandidates = async (): Promise => { + const [precise, broad] = await Promise.all([ + listWorkPackages( + { ...sharedFilters, subject: query, pageSize: MAX_WORK_PACKAGE_CANDIDATES }, + opClient + ).catch(() => ({ items: [] as WorkPackageSummary[] })), + matchMode === "exact" + ? Promise.resolve({ items: [] as WorkPackageSummary[] }) + : listWorkPackages( + { + ...sharedFilters, + pageSize: MAX_WORK_PACKAGE_CANDIDATES, + sortBy: '[["updatedAt","desc"]]', + }, + opClient + ).catch(() => ({ items: [] as WorkPackageSummary[] })), + ]); + + const byId = new Map(); + for (const item of [...precise.items, ...broad.items]) { + byId.set(item.id, item); + } + return Array.from(byId.values()).slice(0, MAX_WORK_PACKAGE_CANDIDATES); + }; + + const shallowFields: FieldSpec[] = [ + { name: "subject", weight: 3, extract: (wp) => wp.subject }, + ]; + + const deepFields: FieldSpec[] = [ + { name: "description", weight: 1, extract: (d) => d.description }, + { name: "comments", weight: 1, extract: (d) => d.comments }, + ]; + + const result = await searchPipeline( { - subject: query, - projectId: options?.projectId, - pageSize: options?.pageSize, - offset: options?.offset, + fetchCandidates, + shallowFields, + enrich: (workPackage) => fetchWorkPackageDeepContent(workPackage.id, opClient), + deepFields, + recencyOf: (workPackage) => workPackage.updatedAt ?? "", + idOf: (workPackage) => workPackage.id, }, - client + query, + { matchMode } ); + + const all: WorkPackageSearchResult[] = result.ranked.map((entry) => ({ + workPackage: entry.record, + score: entry.score, + matchedFields: entry.matches.map((match) => match.field), + snippet: entry.matches[0]?.snippet, + })); + + const offset = options?.offset ?? 1; + const pageSize = options?.pageSize ?? 20; + const startIndex = Math.max(0, offset - 1); + const paged = all.slice(startIndex, startIndex + pageSize); + + return { + total: all.length, + count: paged.length, + pageSize, + offset, + elements: paged, + items: paged, + degraded: result.degraded, + enrichmentFailures: result.enrichmentFailures, + }; } export interface ActivityDetail { diff --git a/src/tools/work-packages.ts b/src/tools/work-packages.ts index 6dd7557..3ddf900 100644 --- a/src/tools/work-packages.ts +++ b/src/tools/work-packages.ts @@ -16,6 +16,7 @@ import { listWorkPackages, getWorkPackage, listWorkPackageActivities, + searchWorkPackages, } from "../services/work-packages"; import type { FilterElement } from "../client/types"; @@ -215,10 +216,88 @@ export const listWorkPackageActivitiesTool: ToolDefinition< execute: handleListWorkPackageActivities, }; +export const searchWorkPackagesShape = { + query: z + .string() + .min(1) + .describe( + "Search text matched against work package subjects, descriptions, and " + + "comments. Natural-language phrasing works and typos are tolerated." + ), + projectId: z + .union([z.number().int().positive(), z.string().min(1)]) + .optional() + .describe("Scope search to a specific project by numeric ID or slug identifier"), + status: z + .union([z.enum(["open", "closed"]), z.number().int().positive()]) + .optional() + .describe("Filter by status: 'open', 'closed', or numeric status ID"), + matchMode: z + .enum(["fuzzy", "exact"]) + .optional() + .default("fuzzy") + .describe( + "Matching strategy. 'fuzzy' (default) tolerates typos, word reordering, and " + + "partial words. Use 'exact' only for literal strings you know verbatim." + ), + 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(20) + .describe("Number of items per page (max 100, default 20)"), +}; + +export type SearchWorkPackagesArgs = z.input>; + +/** + * Tool execution handler for openproject_search_work_packages. + */ +export async function handleSearchWorkPackages( + args: SearchWorkPackagesArgs +): Promise { + try { + const result = await searchWorkPackages(args.query, { + projectId: args.projectId, + status: args.status, + matchMode: args.matchMode, + offset: args.offset, + pageSize: args.pageSize, + }); + return formatToolSuccess(result); + } catch (error) { + return formatToolError(error); + } +} + +export const searchWorkPackagesTool: ToolDefinition< + typeof searchWorkPackagesShape, + SearchWorkPackagesArgs +> = { + name: "openproject_search_work_packages", + description: + "Search work packages by subject, description, and comments. Accepts " + + "natural-language phrasing and tolerates typos. Returns results ranked by " + + "relevance with matching excerpts.", + parameters: searchWorkPackagesShape, + readOnly: true, + execute: handleSearchWorkPackages, +}; + export const workPackageTools = [ listWorkPackagesTool, getWorkPackageTool, listWorkPackageActivitiesTool, + searchWorkPackagesTool, ]; /** @@ -228,4 +307,5 @@ export function registerWorkPackageTools(server: McpServer): void { registerTool(server, listWorkPackagesTool); registerTool(server, getWorkPackageTool); registerTool(server, listWorkPackageActivitiesTool); + registerTool(server, searchWorkPackagesTool); } diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index b47a81b..6ed6c3b 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -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(); diff --git a/tests/openapi.test.ts b/tests/openapi.test.ts index 681b60f..9c63351 100644 --- a/tests/openapi.test.ts +++ b/tests/openapi.test.ts @@ -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); }); }); diff --git a/tests/read-only.test.ts b/tests/read-only.test.ts index f8a9128..aa18018 100644 --- a/tests/read-only.test.ts +++ b/tests/read-only.test.ts @@ -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(); diff --git a/tests/services.test.ts b/tests/services.test.ts index a091796..9a2c162 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -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) => { + // 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); + }); +}); + diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 3bf4b04..86ef553 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -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(); }