feat(server): wire HTTP transport into CLI, add docker-compose.server.yml, and update docs

This commit is contained in:
Lucas Winther
2026-09-07 00:14:49 +02:00
parent ec85bf811b
commit d0dc27b2e3
7 changed files with 257 additions and 20 deletions
+81 -5
View File
@@ -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 <key>`, `X-OpenProject-Api-Key: <key>`, or URL parameter `?apiKey=<key>`.
- Each connection establishes an isolated session with dynamic `RequestContext` scoping—no credentials bleed across sessions or persist globally.
---
## MCP Client Configuration ## MCP Client Configuration
### Claude Desktop ### 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 ## 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: 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 | | Variable | Description | Default |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| `OPENPROJECT_BASE_URL` | Base URL of the OpenProject instance | `http://localhost:8080` | | `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` | | `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` | | `PORT` | HTTP server port when running hosted MCP server, or web port for local dev stack | `3000` (MCP) / `8080` (test stack) |
| `TAG` | OpenProject container image tag | `17-slim` | | `HOST` | Bind host for hosted HTTP server | `0.0.0.0` |
| `POSTGRES_VERSION` | PostgreSQL container image tag | `17` | | `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 # Run MCP server locally over stdio
bun run src/index.ts 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 ## Development Roadmap
- **Phase 1 (Completed)**: Read-only browsing tools for projects, work packages, queries, taxonomies, and OpenAPI introspection. - **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 2 (Upcoming)**: Mutating operations (create/update work packages, add comments, log time).
- **Phase 3**: Attachment reading and document resources. - **Phase 3**: Attachment reading and document resources.
- **Phase 4**: SSE / Stream transport for remote server deployments.
--- ---
+18
View File
@@ -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
+35
View File
@@ -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/<owner>/openproject-mcp:latest`. - Provides seamless Docker execution for desktop and server MCP clients via `docker run -i --rm ghcr.io/<owner>/openproject-mcp:latest`.
- Prevents container build regressions through automated PR verification. - Prevents container build regressions through automated PR verification.
- Guarantees multi-architecture compatibility across Apple Silicon and x86_64 host machines. - 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 <key>`, `X-OpenProject-Api-Key: <key>`) or query parameters (`?apiKey=<key>`) 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 <token>`
- Priority 2: `X-OpenProject-Api-Key: <token>`
- Priority 3: `?apiKey=<token>` 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.
+13 -2
View File
@@ -96,10 +96,23 @@
- [x] Recorded [ADR-015](DECISIONS.md#adr-015-docker-container-packaging-and-github-actions-cicd-pipeline). - [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] 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 ## 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** - [ ] **Phase 2: Mutating Operations**
- [ ] Create work package tool (`openproject_create_work_package`). - [ ] Create work package tool (`openproject_create_work_package`).
- [ ] Update work package tool (`openproject_update_work_package`). - [ ] Update work package tool (`openproject_update_work_package`).
@@ -108,5 +121,3 @@
- [ ] **Phase 3: Attachments & Documents** - [ ] **Phase 3: Attachments & Documents**
- [ ] Inspect and download attachment resources. - [ ] Inspect and download attachment resources.
- [ ] Wiki pages and project documents browsing. - [ ] Wiki pages and project documents browsing.
- [ ] **Phase 4: Remote Transport**
- [ ] Add SSE (Server-Sent Events) transport option for remote deployments.
+38 -13
View File
@@ -5,27 +5,51 @@
import { parseConfig } from "./config"; import { parseConfig } from "./config";
import { createServer } from "./server"; import { createServer } from "./server";
import { startHttpServer } from "./http-server";
async function main(): Promise<void> { async function main(): Promise<void> {
try { try {
const config = parseConfig(process.env, process.argv); const config = parseConfig(process.env, process.argv);
const mcpServer = createServer(config);
// Bind graceful termination
let isShuttingDown = false; 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); if (config.port !== undefined) {
process.on("SIGTERM", shutdown); 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 const shutdown = async () => {
await mcpServer.start(); 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) { } catch (error) {
console.error( console.error(
"[openproject-mcp] Fatal startup error:", "[openproject-mcp] Fatal startup error:",
@@ -42,4 +66,5 @@ if (import.meta.main) {
export { createServer } from "./server"; export { createServer } from "./server";
export { SERVER_NAME, SERVER_VERSION } from "./server"; export { SERVER_NAME, SERVER_VERSION } from "./server";
export { startHttpServer, type HttpServerInstance } from "./http-server";
+12
View File
@@ -30,6 +30,18 @@ describe("Docker Container Packaging", () => {
expect(content).toContain("scripts"); 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 () => { test("Docker image builds and starts up cleanly in container", async () => {
if (process.env.SKIP_DOCKER_TESTS === "true" || process.env.CI === "true") { if (process.env.SKIP_DOCKER_TESTS === "true" || process.env.CI === "true") {
console.log("Skipping live docker build test in CI / SKIP_DOCKER_TESTS environment"); console.log("Skipping live docker build test in CI / SKIP_DOCKER_TESTS environment");
+60
View File
@@ -315,4 +315,64 @@ describe("Hosted Remote MCP Server (HTTP/SSE)", () => {
await readerA!.cancel(); await readerA!.cancel();
await readerB!.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;
});
}); });