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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9dee458b1f
commit
326db0659f
@@ -18,6 +18,8 @@ import type {
|
|||||||
WorkPackageSummary,
|
WorkPackageSummary,
|
||||||
} from "../client/types.ts";
|
} from "../client/types.ts";
|
||||||
import { resolveClient, resolveProjectId } from "./helper.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 {
|
export interface ListWorkPackagesParams extends WorkPackageFilterParams {
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -29,6 +31,10 @@ export interface SearchWorkPackagesOptions {
|
|||||||
projectId?: number | string;
|
projectId?: number | string;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
offset?: 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);
|
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(
|
export async function searchWorkPackages(
|
||||||
query: string,
|
query: string,
|
||||||
options?: SearchWorkPackagesOptions,
|
options?: SearchWorkPackagesOptions,
|
||||||
client?: OpenProjectClient
|
client?: OpenProjectClient
|
||||||
): Promise<PaginatedResult<WorkPackageSummary>> {
|
): Promise<WorkPackageSearchPage> {
|
||||||
return listWorkPackages(
|
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,
|
fetchCandidates,
|
||||||
projectId: options?.projectId,
|
shallowFields,
|
||||||
pageSize: options?.pageSize,
|
enrich: (workPackage) => fetchWorkPackageDeepContent(workPackage.id, opClient),
|
||||||
offset: options?.offset,
|
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 {
|
export interface ActivityDetail {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
listWorkPackages,
|
listWorkPackages,
|
||||||
getWorkPackage,
|
getWorkPackage,
|
||||||
listWorkPackageActivities,
|
listWorkPackageActivities,
|
||||||
|
searchWorkPackages,
|
||||||
} from "../services/work-packages";
|
} from "../services/work-packages";
|
||||||
import type { FilterElement } from "../client/types";
|
import type { FilterElement } from "../client/types";
|
||||||
|
|
||||||
@@ -215,10 +216,88 @@ export const listWorkPackageActivitiesTool: ToolDefinition<
|
|||||||
execute: handleListWorkPackageActivities,
|
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 = [
|
export const workPackageTools = [
|
||||||
listWorkPackagesTool,
|
listWorkPackagesTool,
|
||||||
getWorkPackageTool,
|
getWorkPackageTool,
|
||||||
listWorkPackageActivitiesTool,
|
listWorkPackageActivitiesTool,
|
||||||
|
searchWorkPackagesTool,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -228,4 +307,5 @@ export function registerWorkPackageTools(server: McpServer): void {
|
|||||||
registerTool(server, listWorkPackagesTool);
|
registerTool(server, listWorkPackagesTool);
|
||||||
registerTool(server, getWorkPackageTool);
|
registerTool(server, getWorkPackageTool);
|
||||||
registerTool(server, listWorkPackageActivitiesTool);
|
registerTool(server, listWorkPackageActivitiesTool);
|
||||||
|
registerTool(server, searchWorkPackagesTool);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|||||||
import { createServer, SERVER_NAME, SERVER_VERSION } from "../src/server";
|
import { createServer, SERVER_NAME, SERVER_VERSION } from "../src/server";
|
||||||
|
|
||||||
describe("MCP Server Factory", () => {
|
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 = {
|
const config = {
|
||||||
baseUrl: "http://localhost:8080",
|
baseUrl: "http://localhost:8080",
|
||||||
apiKey: "test-key",
|
apiKey: "test-key",
|
||||||
@@ -21,13 +21,14 @@ describe("MCP Server Factory", () => {
|
|||||||
await client.connect(clientTransport);
|
await client.connect(clientTransport);
|
||||||
|
|
||||||
const toolsResult = await client.listTools();
|
const toolsResult = await client.listTools();
|
||||||
expect(toolsResult.tools).toHaveLength(18);
|
expect(toolsResult.tools).toHaveLength(19);
|
||||||
const names = toolsResult.tools.map((t) => t.name);
|
const names = toolsResult.tools.map((t) => t.name);
|
||||||
expect(names).toContain("openproject_list_projects");
|
expect(names).toContain("openproject_list_projects");
|
||||||
expect(names).toContain("openproject_get_project");
|
expect(names).toContain("openproject_get_project");
|
||||||
expect(names).toContain("openproject_list_work_packages");
|
expect(names).toContain("openproject_list_work_packages");
|
||||||
expect(names).toContain("openproject_get_work_package");
|
expect(names).toContain("openproject_get_work_package");
|
||||||
expect(names).toContain("openproject_list_work_package_activities");
|
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_list_queries");
|
||||||
expect(names).toContain("openproject_get_query");
|
expect(names).toContain("openproject_get_query");
|
||||||
expect(names).toContain("openproject_list_types");
|
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
|
// In read-only mode, only readOnly: true tools should be registered
|
||||||
const toolsResult = await client.listTools();
|
const toolsResult = await client.listTools();
|
||||||
expect(toolsResult.tools).toHaveLength(18);
|
expect(toolsResult.tools).toHaveLength(19);
|
||||||
|
|
||||||
await client.close();
|
await client.close();
|
||||||
await mcpServer.stop();
|
await mcpServer.stop();
|
||||||
|
|||||||
@@ -231,10 +231,10 @@ describe("OpenApi MCP Tool Registration & Execution", () => {
|
|||||||
await server.close();
|
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);
|
const toolNames = allTools.map((t) => t.name);
|
||||||
expect(toolNames).toContain("openproject_get_openapi_spec");
|
expect(toolNames).toContain("openproject_get_openapi_spec");
|
||||||
expect(allTools).toHaveLength(18);
|
expect(allTools).toHaveLength(19);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe("Read-Only Mode Enforcement", () => {
|
|||||||
expect(configFalse.readOnly).toBe(false);
|
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({
|
const mcpServer = createServer({
|
||||||
baseUrl: "http://localhost:8080",
|
baseUrl: "http://localhost:8080",
|
||||||
apiKey: "test-key",
|
apiKey: "test-key",
|
||||||
@@ -87,7 +87,7 @@ describe("Read-Only Mode Enforcement", () => {
|
|||||||
await client.connect(clientTransport);
|
await client.connect(clientTransport);
|
||||||
|
|
||||||
const toolsResult = await client.listTools();
|
const toolsResult = await client.listTools();
|
||||||
expect(toolsResult.tools).toHaveLength(18);
|
expect(toolsResult.tools).toHaveLength(19);
|
||||||
|
|
||||||
await client.close();
|
await client.close();
|
||||||
await mcpServer.stop();
|
await mcpServer.stop();
|
||||||
@@ -118,7 +118,7 @@ describe("Read-Only Mode Enforcement", () => {
|
|||||||
const standaloneTools = await client2.listTools();
|
const standaloneTools = await client2.listTools();
|
||||||
const standaloneNames = standaloneTools.tools.map((t) => t.name);
|
const standaloneNames = standaloneTools.tools.map((t) => t.name);
|
||||||
expect(standaloneNames).not.toContain("openproject_create_work_package_synthetic");
|
expect(standaloneNames).not.toContain("openproject_create_work_package_synthetic");
|
||||||
expect(standaloneTools.tools).toHaveLength(18);
|
expect(standaloneTools.tools).toHaveLength(19);
|
||||||
|
|
||||||
await client2.close();
|
await client2.close();
|
||||||
await standaloneServer.close();
|
await standaloneServer.close();
|
||||||
|
|||||||
+117
-7
@@ -334,9 +334,12 @@ describe("Work Packages Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("searchWorkPackages sets subject query filter", async () => {
|
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) => {
|
const mockFetch = async (input: string | URL | Request) => {
|
||||||
requestedUrl = String(input);
|
requestedUrls.push(String(input));
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
_type: "Collection",
|
_type: "Collection",
|
||||||
@@ -357,11 +360,12 @@ describe("Work Packages Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await searchWorkPackages("MCP Server", { projectId: 4 }, client);
|
await searchWorkPackages("MCP Server", { projectId: 4 }, client);
|
||||||
expect(requestedUrl).toContain("filters=");
|
|
||||||
// Filter must include substring operator for subject
|
// Filter must include substring operator for subject on the precise request.
|
||||||
const decodedUrl = decodeURIComponent(requestedUrl.replace(/\+/g, " "));
|
const decodedUrls = requestedUrls.map((url) => decodeURIComponent(url.replace(/\+/g, " ")));
|
||||||
expect(decodedUrl).toContain('"subject"');
|
const subjectRequestUrl = decodedUrls.find((url) => url.includes('"subject"'));
|
||||||
expect(decodedUrl).toContain("MCP Server");
|
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
@@ -610,11 +610,12 @@ describe("Work Package Tools", () => {
|
|||||||
test("registerWorkPackageTools registers tools on McpServer", () => {
|
test("registerWorkPackageTools registers tools on McpServer", () => {
|
||||||
const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
|
const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
|
||||||
registerWorkPackageTools(server);
|
registerWorkPackageTools(server);
|
||||||
expect(workPackageTools).toHaveLength(3);
|
expect(workPackageTools).toHaveLength(4);
|
||||||
expect(workPackageTools.map((t) => t.name)).toEqual([
|
expect(workPackageTools.map((t) => t.name)).toEqual([
|
||||||
"openproject_list_work_packages",
|
"openproject_list_work_packages",
|
||||||
"openproject_get_work_package",
|
"openproject_get_work_package",
|
||||||
"openproject_list_work_package_activities",
|
"openproject_list_work_package_activities",
|
||||||
|
"openproject_search_work_packages",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const registeredTools = (
|
const registeredTools = (
|
||||||
@@ -628,6 +629,7 @@ describe("Work Package Tools", () => {
|
|||||||
expect(
|
expect(
|
||||||
registeredTools["openproject_list_work_package_activities"]
|
registeredTools["openproject_list_work_package_activities"]
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
|
expect(registeredTools["openproject_search_work_packages"]).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1643,8 +1645,8 @@ describe("Wikis Tools", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Tool Registry", () => {
|
describe("Tool Registry", () => {
|
||||||
test("allTools contains exactly 18 tools including OpenAPI spec, meetings, and wikis", () => {
|
test("allTools contains exactly 19 tools including OpenAPI spec, meetings, and wikis", () => {
|
||||||
expect(allTools).toHaveLength(18);
|
expect(allTools).toHaveLength(19);
|
||||||
const names = allTools.map((t) => t.name);
|
const names = allTools.map((t) => t.name);
|
||||||
expect(names).toEqual([
|
expect(names).toEqual([
|
||||||
"openproject_list_projects",
|
"openproject_list_projects",
|
||||||
@@ -1652,6 +1654,7 @@ describe("Tool Registry", () => {
|
|||||||
"openproject_list_work_packages",
|
"openproject_list_work_packages",
|
||||||
"openproject_get_work_package",
|
"openproject_get_work_package",
|
||||||
"openproject_list_work_package_activities",
|
"openproject_list_work_package_activities",
|
||||||
|
"openproject_search_work_packages",
|
||||||
"openproject_list_queries",
|
"openproject_list_queries",
|
||||||
"openproject_get_query",
|
"openproject_get_query",
|
||||||
"openproject_list_types",
|
"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" });
|
const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
|
||||||
registerAllTools(server);
|
registerAllTools(server);
|
||||||
|
|
||||||
@@ -1684,7 +1687,7 @@ describe("Tool Registry", () => {
|
|||||||
}
|
}
|
||||||
)._registeredTools;
|
)._registeredTools;
|
||||||
|
|
||||||
expect(Object.keys(registeredTools)).toHaveLength(18);
|
expect(Object.keys(registeredTools)).toHaveLength(19);
|
||||||
for (const tool of allTools) {
|
for (const tool of allTools) {
|
||||||
expect(registeredTools[tool.name]).toBeDefined();
|
expect(registeredTools[tool.name]).toBeDefined();
|
||||||
}
|
}
|
||||||
@@ -1716,7 +1719,7 @@ describe("Tool Registry", () => {
|
|||||||
expect(
|
expect(
|
||||||
registeredTools["openproject_synthetic_mutating_tool"]
|
registeredTools["openproject_synthetic_mutating_tool"]
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
expect(Object.keys(registeredTools)).toHaveLength(18);
|
expect(Object.keys(registeredTools)).toHaveLength(19);
|
||||||
for (const tool of allTools) {
|
for (const tool of allTools) {
|
||||||
expect(registeredTools[tool.name]).toBeDefined();
|
expect(registeredTools[tool.name]).toBeDefined();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user