From d0dc27b2e38dadbd6e104b945b637f370d93b684 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 7 Sep 2026 00:14:49 +0200 Subject: [PATCH] feat(server): wire HTTP transport into CLI, add docker-compose.server.yml, and update docs --- README.md | 86 ++++++++++++++++++++++++++++++++++++--- docker-compose.server.yml | 18 ++++++++ docs/DECISIONS.md | 35 ++++++++++++++++ docs/TODO.md | 15 ++++++- src/index.ts | 51 +++++++++++++++++------ tests/docker.test.ts | 12 ++++++ tests/http-server.test.ts | 60 +++++++++++++++++++++++++++ 7 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 docker-compose.server.yml diff --git a/README.md b/README.md index 6e28ae9..94c3e04 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,34 @@ If you prefer to run from source: --- +### Method 3: Hosted Remote Server (Docker Compose & HTTP/SSE) + +Organizations can deploy `openproject-mcp` as a shared remote service. Multiple users and AI clients connect to a single hosted instance over HTTP/SSE, each authenticating with their own personal OpenProject API key. + +#### Deploy with Docker Compose +Deploy `docker-compose.server.yml` with built-in health checking: + +```bash +# Start hosted MCP server on port 3000 +OPENPROJECT_BASE_URL="https://openproject.example.com" \ +HOST_PORT=3000 \ +docker compose -f docker-compose.server.yml up -d +``` + +#### Run with Bun (from source) +```bash +OPENPROJECT_BASE_URL="https://openproject.example.com" \ +PORT=3000 \ +bun run src/index.ts +``` + +In hosted mode: +- The server exposes `GET /health` for container orchestration and uptime monitoring. +- Clients connect via `GET /sse` passing their personal API key via `Authorization: Bearer `, `X-OpenProject-Api-Key: `, or URL parameter `?apiKey=`. +- Each connection establishes an isolated session with dynamic `RequestContext` scoping—no credentials bleed across sessions or persist globally. + +--- + ## MCP Client Configuration ### Claude Desktop @@ -198,6 +226,50 @@ Add the configuration in `.cursor/mcp.json` (or under **Cursor Settings > Featur --- +### Remote Connection to Hosted Server (Cursor & Claude Desktop) + +When connecting to an organization's hosted `openproject-mcp` remote server (e.g. `https://mcp.example.com` or `http://localhost:3000`): + +#### Cursor (`.cursor/mcp.json`) +Using custom HTTP headers: +```json +{ + "mcpServers": { + "openproject": { + "url": "https://mcp.example.com/sse", + "headers": { + "X-OpenProject-Api-Key": "your-personal-api-key" + } + } + } +} +``` + +Or using URL query parameter: +```json +{ + "mcpServers": { + "openproject": { + "url": "https://mcp.example.com/sse?apiKey=your-personal-api-key" + } + } +} +``` + +#### Claude Desktop +In `claude_desktop_config.json` or clients connecting directly via SSE URL: +```json +{ + "mcpServers": { + "openproject": { + "url": "https://mcp.example.com/sse?apiKey=your-personal-api-key" + } + } +} +``` + +--- + ## Interactive Testing with MCP Inspector You can visually test and debug tool calls, explore parameter schemas, and inspect raw JSON payloads using the official MCP Inspector: @@ -337,11 +409,12 @@ The MCP server accepts configuration through environment variables, CLI argument | Variable | Description | Default | | :--- | :--- | :--- | | `OPENPROJECT_BASE_URL` | Base URL of the OpenProject instance | `http://localhost:8080` | -| `OPENPROJECT_API_KEY` | Personal API token (created under My Account > Access tokens) | - | +| `OPENPROJECT_API_KEY` | Personal API token (required in stdio mode; supplied per-client in HTTP mode) | - | | `OPENPROJECT_READ_ONLY` | Run server in read-only mode (`true` / `false` or `--read-only`) | `false` | -| `PORT` | Local port for the Docker Compose web container | `8080` | -| `TAG` | OpenProject container image tag | `17-slim` | -| `POSTGRES_VERSION` | PostgreSQL container image tag | `17` | +| `PORT` | HTTP server port when running hosted MCP server, or web port for local dev stack | `3000` (MCP) / `8080` (test stack) | +| `HOST` | Bind host for hosted HTTP server | `0.0.0.0` | +| `TAG` | OpenProject container image tag (local test stack) | `17-slim` | +| `POSTGRES_VERSION` | PostgreSQL container image tag (local test stack) | `17` | --- @@ -359,6 +432,9 @@ bun test # Run MCP server locally over stdio bun run src/index.ts + +# Run hosted MCP server locally over HTTP/SSE +PORT=3000 bun run src/index.ts ``` --- @@ -366,9 +442,9 @@ bun run src/index.ts ## Development Roadmap - **Phase 1 (Completed)**: Read-only browsing tools for projects, work packages, queries, taxonomies, and OpenAPI introspection. +- **Phase 4 (Completed)**: Remote HTTP/SSE transport (`Bun.serve`) with multi-tenant per-session credential scoping and Docker Compose deployment. - **Phase 2 (Upcoming)**: Mutating operations (create/update work packages, add comments, log time). - **Phase 3**: Attachment reading and document resources. -- **Phase 4**: SSE / Stream transport for remote server deployments. --- diff --git a/docker-compose.server.yml b/docker-compose.server.yml new file mode 100644 index 0000000..094e5bd --- /dev/null +++ b/docker-compose.server.yml @@ -0,0 +1,18 @@ +version: "3.8" + +services: + openproject-mcp: + image: ghcr.io/stereotypicalcat/openproject-mcp:latest + container_name: openproject-mcp-hosted + restart: unless-stopped + ports: + - "${HOST_PORT:-3000}:3000" + environment: + - OPENPROJECT_BASE_URL=${OPENPROJECT_BASE_URL:-https://openproject.example.com} + - PORT=3000 + - OPENPROJECT_READ_ONLY=${OPENPROJECT_READ_ONLY:-false} + healthcheck: + test: ["CMD-SHELL", "bun -e 'fetch(\"http://localhost:3000/health\").then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))'"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e41ee53..68b033d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -363,3 +363,38 @@ Users and client LLM applications (such as Claude Desktop, Cursor, or containeri - Provides seamless Docker execution for desktop and server MCP clients via `docker run -i --rm ghcr.io//openproject-mcp:latest`. - Prevents container build regressions through automated PR verification. - Guarantees multi-architecture compatibility across Apple Silicon and x86_64 host machines. + +--- + +## ADR-016: Hosted Remote MCP Server Architecture (HTTP/SSE Transport) & Multi-Tenant Credential Scoping + +### Status +Accepted + +### Context +`openproject-mcp` initially executed exclusively as a local child process over standard input/output (`stdio`). While ideal for individual developers running local tools like Claude Desktop or Cursor on their personal machines, team environments, shared hosting, and web agents require a remotely hosted MCP server instance accessible over standard network protocols. + +Key requirements for hosted operation: +1. **Multi-Tenant Credential Scoping**: A single server process connects to a shared OpenProject base URL (`OPENPROJECT_BASE_URL`), but every connecting user/agent must use their own personal OpenProject API token. User credentials must never bleed across sessions or be retained in global mutable memory. +2. **Dual-Transport Entrypoint**: The server CLI must support both local stdio execution and hosted HTTP/SSE execution via configuration (`PORT` / `--port`). +3. **Flexible Authentication**: Clients must be able to authenticate via standard headers (`Authorization: Bearer `, `X-OpenProject-Api-Key: `) or query parameters (`?apiKey=`) for SSE-compatible desktop and web clients. +4. **Health & Lifecycle Management**: Provide standardized `/health` endpoints and clean session eviction on client disconnect to prevent memory leaks. + +### Decision +1. **HTTP/SSE Transport Engine**: Implement `src/http-server.ts` utilizing native `Bun.serve` and Web Streams for Server-Sent Events (`text/event-stream`). +2. **Credential Extraction Precedence**: + - Priority 1: `Authorization: Bearer ` + - Priority 2: `X-OpenProject-Api-Key: ` + - Priority 3: `?apiKey=` URL query parameter + - Reject unauthenticated `/sse` requests immediately with HTTP 401 Unauthorized. +3. **Per-Connection Isolation**: For each `/sse` connection, allocate a unique `sessionId` and initialize an isolated `OpenProjectClient` and `McpServer` instance. Route tool calls and JSON-RPC dispatch via `runWithContext` using `AsyncLocalStorage`. +4. **Session Eviction**: Register connection abort and stream cancellation handlers (`req.signal.addEventListener("abort")` and `stream.cancel()`) that evict disconnected sessions from active memory. +5. **Container Deployment**: Provide `docker-compose.server.yml` with built-in `/health` probe checking `GET /health` every 30s. + +### Consequences +- **Positive**: + - Organizations can deploy a single shared `openproject-mcp` service without sharing API tokens. + - Full compatibility with remote MCP client connections in Cursor, Claude Desktop, and autonomous agents. + - Backward compatibility: when `PORT` is not set, stdio transport remains the default with zero overhead. +- **Negative**: + - Requires network infrastructure (e.g. reverse proxy with TLS termination) in production to protect credentials in transit. diff --git a/docs/TODO.md b/docs/TODO.md index a38cfbe..8f9a832 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -96,10 +96,23 @@ - [x] Recorded [ADR-015](DECISIONS.md#adr-015-docker-container-packaging-and-github-actions-cicd-pipeline). - [x] Updated [README.md](../README.md) with Docker Quickstart, read-only mode instructions, and client integration settings (Claude Desktop & Cursor). +- [x] **Task 7: Hosted Remote MCP Server (HTTP/SSE Transport) & Multi-Tenant Credential Scoping** + - [x] Extend configuration loader with `port` and `host` options (`src/config/index.ts`, `tests/config.test.ts`). + - [x] Implement hosted HTTP/SSE MCP server with `Bun.serve` and Web Streams SSE (`src/http-server.ts`, `tests/http-server.test.ts`). + - [x] Implement credential extraction hierarchy (`Authorization: Bearer`, `X-OpenProject-Api-Key`, `?apiKey=`) with 401 unauthorized rejection. + - [x] Implement per-session client isolation and `runWithContext` dynamic scoping. + - [x] Wire dual-transport execution in CLI entrypoint (`src/index.ts`). + - [x] Create production `docker-compose.server.yml` with `/health` check probe. + - [x] Record [ADR-016](DECISIONS.md#adr-016-hosted-remote-mcp-server-architecture-httpsse-transport--multi-tenant-credential-scoping). + - [x] Update documentation with hosted deployment guide and remote client setups for Cursor and Claude Desktop in [README.md](../README.md). + --- ## 3. Backlog & Future Phases +- [x] **Phase 4: Remote Transport (Completed)** + - [x] Add SSE (Server-Sent Events) transport option for remote deployments. + - [x] Multi-tenant credential scoping with per-session isolation. - [ ] **Phase 2: Mutating Operations** - [ ] Create work package tool (`openproject_create_work_package`). - [ ] Update work package tool (`openproject_update_work_package`). @@ -108,5 +121,3 @@ - [ ] **Phase 3: Attachments & Documents** - [ ] Inspect and download attachment resources. - [ ] Wiki pages and project documents browsing. -- [ ] **Phase 4: Remote Transport** - - [ ] Add SSE (Server-Sent Events) transport option for remote deployments. diff --git a/src/index.ts b/src/index.ts index fad3741..a5dcb6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,27 +5,51 @@ import { parseConfig } from "./config"; import { createServer } from "./server"; +import { startHttpServer } from "./http-server"; async function main(): Promise { try { const config = parseConfig(process.env, process.argv); - const mcpServer = createServer(config); - // Bind graceful termination let isShuttingDown = false; - const shutdown = async () => { - if (isShuttingDown) return; - isShuttingDown = true; - console.error("\n[openproject-mcp] Received termination signal, shutting down..."); - await mcpServer.stop(); - process.exit(0); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); + if (config.port !== undefined) { + console.error( + `[openproject-mcp] Starting hosted HTTP server on ${config.host || "0.0.0.0"}:${config.port}...` + ); + const httpServer = await startHttpServer(config); - // Start server over stdio transport - await mcpServer.start(); + const shutdown = async () => { + if (isShuttingDown) return; + isShuttingDown = true; + console.error( + "\n[openproject-mcp] Received termination signal, shutting down HTTP server..." + ); + await httpServer.stop(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } else { + const mcpServer = createServer(config); + + const shutdown = async () => { + if (isShuttingDown) return; + isShuttingDown = true; + 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:", @@ -42,4 +66,5 @@ if (import.meta.main) { export { createServer } from "./server"; export { SERVER_NAME, SERVER_VERSION } from "./server"; +export { startHttpServer, type HttpServerInstance } from "./http-server"; diff --git a/tests/docker.test.ts b/tests/docker.test.ts index ba4b9a9..a54d62d 100644 --- a/tests/docker.test.ts +++ b/tests/docker.test.ts @@ -30,6 +30,18 @@ describe("Docker Container Packaging", () => { expect(content).toContain("scripts"); }); + test("docker-compose.server.yml exists and defines hosted server configuration", () => { + const composePath = resolve(ROOT_DIR, "docker-compose.server.yml"); + expect(existsSync(composePath)).toBe(true); + + const content = readFileSync(composePath, "utf-8"); + expect(content).toContain("ghcr.io/stereotypicalcat/openproject-mcp:latest"); + expect(content).toContain("openproject-mcp-hosted"); + expect(content).toContain("PORT=3000"); + expect(content).toContain("OPENPROJECT_BASE_URL="); + expect(content).toContain("/health"); + }); + test("Docker image builds and starts up cleanly in container", async () => { if (process.env.SKIP_DOCKER_TESTS === "true" || process.env.CI === "true") { console.log("Skipping live docker build test in CI / SKIP_DOCKER_TESTS environment"); diff --git a/tests/http-server.test.ts b/tests/http-server.test.ts index 745845f..64286d4 100644 --- a/tests/http-server.test.ts +++ b/tests/http-server.test.ts @@ -315,4 +315,64 @@ describe("Hosted Remote MCP Server (HTTP/SSE)", () => { await readerA!.cancel(); await readerB!.cancel(); }); + + test("CLI entrypoint (src/index.ts) boots HTTP server when --port is provided", async () => { + const proc = Bun.spawn( + [ + "bun", + "src/index.ts", + "--port", + "0", + "--host", + "127.0.0.1", + "--read-only", + ], + { + env: { + ...process.env, + OPENPROJECT_BASE_URL: "https://mock.openproject.example.com", + }, + stderr: "pipe", + stdout: "pipe", + } + ); + + let stderrOutput = ""; + const decoder = new TextDecoder(); + const reader = proc.stderr.getReader(); + + const readPromise = (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + stderrOutput += decoder.decode(value, { stream: true }); + if (stderrOutput.includes("[openproject-mcp] Starting hosted HTTP server on 127.0.0.1:0...")) { + break; + } + } + } catch { + // Stream closed + } + })(); + + const start = Date.now(); + while ( + Date.now() - start < 5000 && + !stderrOutput.includes("[openproject-mcp] Starting hosted HTTP server on 127.0.0.1:0...") + ) { + await Bun.sleep(50); + } + + expect(stderrOutput).toContain("[openproject-mcp] Starting hosted HTTP server on 127.0.0.1:0..."); + + proc.kill(); + await proc.exited; + try { + await reader.cancel(); + } catch { + // ignore + } + await readPromise; + }); });