Files
Lucas WintherandClaude Opus 5 326db0659f 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]>
2026-09-18 11:23:48 +02:00

349 lines
13 KiB
TypeScript

import { describe, expect, test, spyOn } from "bun:test";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
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 19 tools via InMemoryTransport", async () => {
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
};
const mcpServer = createServer(config);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
const toolsResult = await client.listTools();
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");
expect(names).toContain("openproject_list_statuses");
expect(names).toContain("openproject_list_priorities");
expect(names).toContain("openproject_list_users");
expect(names).toContain("openproject_get_openapi_spec");
expect(names).toContain("openproject_list_meetings");
expect(names).toContain("openproject_get_meeting");
expect(names).toContain("openproject_search_meetings");
expect(names).toContain("openproject_get_wiki_page");
expect(names).toContain("openproject_search_wiki_pages");
expect(names).toContain("openproject_list_wiki_page_links");
await client.close();
await mcpServer.stop();
});
test("server exposes server, client, start, and stop properties with correct constants", () => {
expect(SERVER_NAME).toBe("openproject-mcp");
expect(SERVER_VERSION).toBe("0.1.0");
const config = {
baseUrl: "https://openproject.example.com",
apiKey: "secret-key",
readOnly: true,
};
const mcpServer = createServer(config);
expect(mcpServer.server).toBeDefined();
expect(mcpServer.client).toBeDefined();
expect(typeof mcpServer.start).toBe("function");
expect(typeof mcpServer.stop).toBe("function");
});
test("executes tool through MCP client and injects RequestContext", async () => {
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
};
const mcpServer = createServer(config);
// Mock client.get on mcpServer.client to verify ambient context is used
let interceptedPath: string | undefined;
(
mcpServer.client as unknown as {
get: (path: string, query?: Record<string, unknown>) => Promise<unknown>;
}
).get = async (path: string, _query?: Record<string, unknown>) => {
interceptedPath = path;
return {
_embedded: {
elements: [
{
id: 42,
identifier: "ambient-project",
name: "Ambient Test Project",
active: true,
public: true,
description: { raw: "Ambient description" },
createdAt: "2026-09-06T00:00:00Z",
updatedAt: "2026-09-06T00:00:00Z",
},
],
},
total: 1,
count: 1,
pageSize: 20,
offset: 1,
};
};
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
const callResult = (await client.callTool({
name: "openproject_list_projects",
arguments: {},
})) as {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
expect(callResult.isError).toBeFalsy();
expect(interceptedPath).toBe("projects");
const parsed = JSON.parse(callResult.content[0]?.text ?? "{}");
expect(parsed.total).toBe(1);
expect(parsed.projects[0]?.id).toBe(42);
expect(parsed.projects[0]?.identifier).toBe("ambient-project");
await client.close();
await mcpServer.stop();
});
test("read-only mode filters tools and maintains read-only status in context", async () => {
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
};
const mcpServer = createServer(config);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
// In read-only mode, only readOnly: true tools should be registered
const toolsResult = await client.listTools();
expect(toolsResult.tools).toHaveLength(19);
await client.close();
await mcpServer.stop();
});
test("start defaults to StdioServerTransport when no transport is provided", async () => {
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
};
const mcpServer = createServer(config);
let capturedTransport: unknown;
const connectSpy = spyOn(mcpServer.server, "connect").mockImplementation(
async (transport) => {
capturedTransport = transport;
}
);
await mcpServer.start();
expect(connectSpy).toHaveBeenCalled();
expect(capturedTransport).toBeInstanceOf(StdioServerTransport);
connectSpy.mockRestore();
});
test("start and stop log messages via console.error", async () => {
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
};
const mcpServer = createServer(config);
const [, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
expect(errorSpy).toHaveBeenCalledWith(
"[openproject-mcp] Server started (readOnly=false)"
);
await mcpServer.stop();
expect(errorSpy).toHaveBeenCalledWith("[openproject-mcp] Server stopped");
errorSpy.mockRestore();
});
});
describe("End-to-End Live Tool Calling over MCP Client", () => {
const baseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080";
const apiKey = process.env.OPENPROJECT_API_KEY;
const runLiveTests = apiKey ? test : test.skip;
runLiveTests("calls openproject_list_projects and openproject_get_work_package via Client", async () => {
const mcpServer = createServer({ baseUrl, apiKey: apiKey || "skip", readOnly: false });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-runner", version: "1" });
await client.connect(clientTransport);
try {
// Call openproject_list_projects
const projRes = (await client.callTool({
name: "openproject_list_projects",
arguments: { pageSize: 5 },
})) as {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
expect(projRes.isError).toBeFalsy();
const projData = JSON.parse(projRes.content[0]?.text ?? "{}");
expect(projData.projects.length).toBeGreaterThan(0);
// Call openproject_get_work_package
const wpRes = (await client.callTool({
name: "openproject_get_work_package",
arguments: { workPackageId: 38 },
})) as {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
expect(wpRes.isError).toBeFalsy();
const wpData = JSON.parse(wpRes.content[0]?.text ?? "{}");
expect(wpData.id).toBe(38);
} finally {
await client.close();
await mcpServer.stop();
}
});
runLiveTests("tool error propagation returns isError: true without crashing client", async () => {
const mcpServer = createServer({ baseUrl, apiKey: apiKey || "skip", readOnly: false });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-runner", version: "1" });
await client.connect(clientTransport);
try {
const errRes = (await client.callTool({
name: "openproject_get_project",
arguments: { projectId: 999999 },
})) as {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
expect(errRes.isError).toBe(true);
expect(errRes.content[0]?.text).toContain("OPENPROJECT_NOT_FOUND");
} finally {
await client.close();
await mcpServer.stop();
}
});
runLiveTests("calls meetings, wikis, and activities tools via Client", async () => {
const mcpServer = createServer({ baseUrl, apiKey: apiKey || "skip", readOnly: false });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-runner", version: "1" });
await client.connect(clientTransport);
try {
// openproject_list_meetings
const listMeetingsRes = (await client.callTool({
name: "openproject_list_meetings",
arguments: { projectId: 1 },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(listMeetingsRes.isError).toBeFalsy();
const meetingsData = JSON.parse(listMeetingsRes.content[0]?.text ?? "{}");
expect(meetingsData.total).toBeGreaterThanOrEqual(4);
expect(meetingsData.elements.length).toBeGreaterThan(0);
// openproject_get_meeting
const getMeetingRes = (await client.callTool({
name: "openproject_get_meeting",
arguments: { id: 2, includeAgendaItems: true },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(getMeetingRes.isError).toBeFalsy();
const meetingData = JSON.parse(getMeetingRes.content[0]?.text ?? "{}");
expect(meetingData.id).toBe(2);
expect(meetingData.title).toBe("Weekly");
expect(meetingData.agendaItems.length).toBeGreaterThan(0);
// openproject_search_meetings
const searchMeetingsRes = (await client.callTool({
name: "openproject_search_meetings",
arguments: { query: "Weekly" },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(searchMeetingsRes.isError).toBeFalsy();
const searchMeetingsData = JSON.parse(searchMeetingsRes.content[0]?.text ?? "{}");
expect(searchMeetingsData.total).toBeGreaterThan(0);
expect(searchMeetingsData.elements.length).toBeGreaterThan(0);
// openproject_get_wiki_page
const getWikiRes = (await client.callTool({
name: "openproject_get_wiki_page",
arguments: { id: 1 },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(getWikiRes.isError).toBeFalsy();
const wikiData = JSON.parse(getWikiRes.content[0]?.text ?? "{}");
expect(wikiData.id).toBe(1);
expect(wikiData.title).toBe("Wiki");
expect(wikiData.project.id).toBe(1);
// openproject_search_wiki_pages
const searchWikiRes = (await client.callTool({
name: "openproject_search_wiki_pages",
arguments: { query: "Wiki" },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(searchWikiRes.isError).toBeFalsy();
const searchWikiData = JSON.parse(searchWikiRes.content[0]?.text ?? "[]");
expect(Array.isArray(searchWikiData)).toBe(true);
expect(searchWikiData.length).toBeGreaterThan(0);
expect(
searchWikiData.some(
(w: { id: number; title: string }) => w.id === 1 && w.title === "Wiki"
)
).toBe(true);
// openproject_list_work_package_activities
const activitiesRes = (await client.callTool({
name: "openproject_list_work_package_activities",
arguments: { workPackageId: 38 },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(activitiesRes.isError).toBeFalsy();
const activitiesData = JSON.parse(activitiesRes.content[0]?.text ?? "[]");
expect(Array.isArray(activitiesData)).toBe(true);
expect(activitiesData.length).toBeGreaterThan(0);
expect(activitiesData[0].id).toBeDefined();
} finally {
await client.close();
await mcpServer.stop();
}
});
});