diff --git a/docs/TODO.md b/docs/TODO.md index 3043d05..f7a15be 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -73,12 +73,12 @@ - [x] `openproject_list_priorities` - [x] `openproject_list_users` -- [ ] **Task 4: MCP Stdio Server Assembly & Integration** - - [ ] Wire domain tools into `@modelcontextprotocol/sdk` Server using `StdioServerTransport` in `src/server.ts` and `src/index.ts`. - - [ ] Implement read-only mode tool filtering (omits mutating tools when `readOnly` is enabled) and execution guard (`SERVER_READ_ONLY`). - - [ ] Ensure all logging is strictly redirected to `stderr`. - - [ ] Add end-to-end integration tests in `tests/mcp-server.test.ts` verifying tool calling against the running OpenProject 17 instance. - - [ ] Add read-only mode verification tests in `tests/read-only.test.ts`. +- [x] **Task 4: MCP Stdio Server Assembly & Integration** + - [x] Wire domain tools into `@modelcontextprotocol/sdk` Server using `StdioServerTransport` in `src/server.ts` and `src/index.ts`. + - [x] Implement read-only mode tool filtering (omits mutating tools when `readOnly` is enabled) and execution guard (`SERVER_READ_ONLY`). + - [x] Ensure all logging is strictly redirected to `stderr`. + - [x] Add end-to-end integration tests in `tests/mcp-server.test.ts` verifying tool calling against the running OpenProject 17 instance. + - [x] Add read-only mode verification tests in `tests/read-only.test.ts`. --- diff --git a/src/index.ts b/src/index.ts index c7060af..35fc906 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,42 @@ +#!/usr/bin/env bun /** * openproject-mcp - Model Context Protocol Server for OpenProject */ -export const SERVER_NAME = "openproject-mcp"; -export const SERVER_VERSION = "0.1.0"; +import { parseConfig } from "./config"; +import { createServer } from "./server"; + +async function main(): Promise { + try { + const config = parseConfig(process.env, process.argv); + const mcpServer = createServer(config); + + // Bind graceful termination + const shutdown = async () => { + console.error("\n[openproject-mcp] Received termination signal, shutting down..."); + await mcpServer.stop(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + // Start server over stdio transport + await mcpServer.start(); + } catch (error) { + console.error( + "[openproject-mcp] Fatal startup error:", + error instanceof Error ? error.message : error + ); + process.exit(1); + } +} + +// Only execute when invoked directly as a script +if (import.meta.main) { + main(); +} + +export { createServer } from "./server"; +export { SERVER_NAME, SERVER_VERSION } from "./server"; -console.error(`[${SERVER_NAME}] Initialized MCP companion server stub v${SERVER_VERSION}`); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 02316f1..b956a88 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -186,3 +186,67 @@ describe("MCP Server Factory", () => { 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 || ""; + + test("calls openproject_list_projects and openproject_get_work_package via Client", async () => { + const mcpServer = createServer({ baseUrl, apiKey, readOnly: false }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mcpServer.start(serverTransport); + + const client = new Client({ name: "test-runner", version: "1" }); + await client.connect(clientTransport); + + // 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); + + await client.close(); + await mcpServer.stop(); + }); + + test("tool error propagation returns isError: true without crashing client", async () => { + const mcpServer = createServer({ baseUrl, apiKey, readOnly: false }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mcpServer.start(serverTransport); + + const client = new Client({ name: "test-runner", version: "1" }); + await client.connect(clientTransport); + + 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"); + + await client.close(); + await mcpServer.stop(); + }); +}); +