Files
openproject-mcp/tests/mcp-server.test.ts
T

189 lines
6.1 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 10 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(10);
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_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");
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(10);
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();
});
});