From d49e0fc8815d616ae6382492d04cad9a8cc3a66c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 6 Sep 2026 19:03:09 +0200 Subject: [PATCH] feat(server): implement MCP server factory and transport lifecycle --- src/server.ts | 67 ++++++++++++++ tests/mcp-server.test.ts | 188 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/server.ts create mode 100644 tests/mcp-server.test.ts diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..a4e1d65 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,67 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { AppConfig } from "./config"; +import { OpenProjectClient } from "./client/api-client"; +import { runWithContext } from "./context"; +import { registerAllTools } from "./tools"; +import { formatToolError } from "./tools/common"; +import { OpenProjectError } from "./client/errors"; + +export interface OpenProjectMcpServer { + server: McpServer; + client: OpenProjectClient; + start: (transport?: Transport) => Promise; + stop: () => Promise; +} + +export const SERVER_NAME = "openproject-mcp"; +export const SERVER_VERSION = "0.1.0"; + +/** + * Creates and configures an OpenProject MCP server instance. + * + * @param config - Server configuration including baseUrl, apiKey, and readOnly flag. + * @returns Configured OpenProjectMcpServer instance with lifecycle methods. + */ +export function createServer(config: AppConfig): OpenProjectMcpServer { + const server = new McpServer({ + name: SERVER_NAME, + version: SERVER_VERSION, + }); + + const client = new OpenProjectClient({ + baseUrl: config.baseUrl, + apiKey: config.apiKey, + }); + + // Register all tools with ambient context execution and read-only guards + registerAllTools(server, { + readOnly: config.readOnly, + wrapExecute: async (fn, tool) => { + if (config.readOnly && !tool.readOnly) { + return formatToolError( + new OpenProjectError( + "SERVER_READ_ONLY", + "Operation rejected. OpenProject MCP server is running in read-only mode." + ) + ); + } + return runWithContext({ client, isReadOnly: config.readOnly }, fn); + }, + }); + + return { + server, + client, + start: async (transport?: Transport) => { + const activeTransport = transport ?? new StdioServerTransport(); + await server.connect(activeTransport); + console.error(`[${SERVER_NAME}] Server started (readOnly=${config.readOnly})`); + }, + stop: async () => { + await server.close(); + console.error(`[${SERVER_NAME}] Server stopped`); + }, + }; +} diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts new file mode 100644 index 0000000..02316f1 --- /dev/null +++ b/tests/mcp-server.test.ts @@ -0,0 +1,188 @@ +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) => Promise; + } + ).get = async (path: string, _query?: Record) => { + 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(); + }); +});