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

+134 -8
View File
@@ -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<WorkPackageSearchResult> {
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<WorkPackageDeepContent> {
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<PaginatedResult<WorkPackageSummary>> {
return listWorkPackages(
): Promise<WorkPackageSearchPage> {
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<WorkPackageSummary[]> => {
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<number, WorkPackageSummary>();
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<WorkPackageSummary>[] = [
{ name: "subject", weight: 3, extract: (wp) => wp.subject },
];
const deepFields: FieldSpec<WorkPackageDeepContent>[] = [
{ name: "description", weight: 1, extract: (d) => d.description },
{ name: "comments", weight: 1, extract: (d) => d.comments },
];
const result = await searchPipeline<WorkPackageSummary, WorkPackageDeepContent>(
{
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 {
+80
View File
@@ -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<z.ZodObject<typeof searchWorkPackagesShape>>;
/**
* Tool execution handler for openproject_search_work_packages.
*/
export async function handleSearchWorkPackages(
args: SearchWorkPackagesArgs
): Promise<McpToolResponse> {
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);
}