From 1f3eae4258802f227016a8acd6f8a9d3eb443bd9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 6 Sep 2026 21:12:14 +0200 Subject: [PATCH] docs(spec): add specification for hosted remote MCP server and multi-tenant credential scoping --- .../2026-09-06-hosted-remote-server-design.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/specs/2026-09-06-hosted-remote-server-design.md diff --git a/docs/specs/2026-09-06-hosted-remote-server-design.md b/docs/specs/2026-09-06-hosted-remote-server-design.md new file mode 100644 index 0000000..d501033 --- /dev/null +++ b/docs/specs/2026-09-06-hosted-remote-server-design.md @@ -0,0 +1,200 @@ +# Specification: Hosted Remote MCP Server (HTTP/SSE Transport) & Multi-Tenant Scoping + +## 1. Overview & Objectives + +Currently, `openproject-mcp` runs as a local subprocess communicating via standard input/output (stdio) transports. Every user must have Docker or Bun installed locally and run their own MCP process. + +This specification introduces a **Hosted Remote MCP Server** capability. Organizations can deploy a single hosted instance of `openproject-mcp` (e.g. at `https://mcp.example.com`). Individual users and their AI clients (Claude Desktop, Cursor, web agents) connect directly to the hosted server over the standard Model Context Protocol HTTP/SSE (Server-Sent Events) transport, supplying their own personal OpenProject API keys. + +### Primary Objectives +1. **Hosted Multi-Tenant Remote Server**: Provide an HTTP/SSE server implementation based on native `Bun.serve` and `@modelcontextprotocol/sdk`. +2. **Dual-Transport Entrypoint**: Preserve existing local stdio operation by default; activate HTTP/SSE transport when `PORT` (e.g., `PORT=3000`) or `--port` is supplied. +3. **Flexible Credential Scoping**: Extract the user's OpenProject API key from incoming client requests via: + - `Authorization: Bearer ` + - `X-OpenProject-Api-Key: ` + - URL query parameter `?apiKey=` +4. **Single-Instance Multi-User Isolation**: Target a shared OpenProject base URL (`OPENPROJECT_BASE_URL`) configured on the server, while strictly isolating each client's requests inside `RequestContext` (`AsyncLocalStorage`) using their respective personal API key. +5. **Unauthorized Rejection**: Return clean HTTP 401 Unauthorized responses if a client connects without an API key. +6. **Docker Compose Deployment**: Provide a `docker-compose.server.yml` file and `README.md` guide for hosting the server with health checks. + +--- + +## 2. Architecture & Data Flow + +``` ++------------------------------------+ +------------------------------------+ +| User A (Cursor / Claude Desktop) | | User B (Claude Desktop / Agent) | +| Headers: X-OpenProject-Api-Key: keyA| | URL: /sse?apiKey=keyB | ++-----------------+------------------+ +-----------------+------------------+ + | (GET /sse) | (GET /sse) + +-----------------------+-------------------+ + | + v + +------------------------------+ + | Hosted openproject-mcp Server | + | (Bun.serve on PORT=3000) | + +--------------+---------------+ + | + +-----------------+-----------------+ + | Session A | Session B + | Client A (key: keyA) | Client B (key: keyB) + | McpServer A (runWithContext) | McpServer B (runWithContext) + +-----------------+-----------------+ + | + v + +------------------------------+ + | OpenProject 17 REST API v3 | + | (Shared OPENPROJECT_BASE_URL)| + +------------------------------+ +``` + +### 2.1 HTTP Endpoints + +1. **`GET /health`**: + - Responds with HTTP 200: + ```json + { + "status": "ok", + "mode": "remote-mcp", + "openproject": "https://openproject.example.com", + "readOnly": false + } + ``` + - Used by load balancers, Kubernetes probes, and Docker health checks. + +2. **`GET /sse`**: + - Establishes a persistent Server-Sent Events (SSE) connection with the client. + - Extracts the API key from: + 1. `Authorization: Bearer ` + 2. `X-OpenProject-Api-Key: ` + 3. Query parameter `?apiKey=` + - If missing: Rejects immediately with HTTP 401 and JSON error message: + ```json + { "error": "Missing OpenProject API key. Provide via Authorization header, X-OpenProject-Api-Key header, or ?apiKey= query parameter." } + ``` + - If present: + - Generates a cryptographically random `sessionId`. + - Creates an isolated `OpenProjectClient({ baseUrl, apiKey })`. + - Creates an `SSEServerTransport` bound to `/messages?sessionId=${sessionId}`. + - Creates an `McpServer` instance with all tools registered and wrapped with `runWithContext`. + - Stores the session in `activeSessions: Map`. + - Streams the initial `endpoint` SSE event to the client. + +3. **`POST /messages`**: + - URL contains `?sessionId=`. + - Looks up the session in `activeSessions`. + - If session not found: Returns HTTP 404 Not Found. + - Forwards the JSON-RPC request to the session transport's message handler, ensuring tool execution resolves the session's client via `runWithContext`. + +4. **Session Eviction**: + - On SSE connection close or error, `transport.onclose` evicts the session from `activeSessions` to prevent memory leaks. + +--- + +## 3. Configuration & CLI Flags + +### Configuration Extension (`src/config/index.ts`) +```typescript +export interface AppConfig { + baseUrl: string; + apiKey?: string; // Optional when running in hosted HTTP mode + readOnly: boolean; + port?: number; // When present, launches HTTP/SSE server instead of stdio + host?: string; // Default: "0.0.0.0" +} +``` + +- When `port` is defined (`process.env.PORT` or `--port `): + - `apiKey` is optional at server startup because individual connecting users supply their own keys. + - Server starts in HTTP mode. +- When `port` is undefined: + - Server runs in stdio mode (requires `OPENPROJECT_API_KEY` in environment). + +--- + +## 4. Docker Compose Setup (`docker-compose.server.yml`) + +A ready-to-run Docker Compose file for deploying the hosted MCP server: + +```yaml +version: "3.8" + +services: + openproject-mcp: + image: ghcr.io/stereotypicalcat/openproject-mcp:latest + container_name: openproject-mcp-hosted + restart: unless-stopped + ports: + - "3000:3000" + environment: + - OPENPROJECT_BASE_URL=https://openproject.example.com + - PORT=3000 + - 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 +``` + +--- + +## 5. Client Configuration Examples + +### Cursor +Add to `.cursor/mcp.json` (or Cursor Settings > Features > MCP): +```json +{ + "mcpServers": { + "openproject": { + "url": "https://mcp.example.com/sse", + "headers": { + "X-OpenProject-Api-Key": "your-personal-api-key" + } + } + } +} +``` +Or via URL query parameter: +```json +{ + "mcpServers": { + "openproject": { + "url": "https://mcp.example.com/sse?apiKey=your-personal-api-key" + } + } +} +``` + +### Claude Desktop +In clients that connect to remote SSE servers via standard proxy or direct URL: +```json +{ + "mcpServers": { + "openproject": { + "url": "https://mcp.example.com/sse?apiKey=your-personal-api-key" + } + } +} +``` + +--- + +## 6. Verification & Test Plan + +1. **Unit & Protocol Tests (`tests/http-server.test.ts`)**: + - `GET /health` returns HTTP 200 with status info. + - `GET /sse` without credentials returns HTTP 401. + - `GET /sse` with `Authorization: Bearer ` succeeds and returns SSE stream. + - `GET /sse` with `X-OpenProject-Api-Key: ` succeeds. + - `GET /sse` with `?apiKey=` succeeds. + - Concurrent clients with different API keys execute tools with their own respective credentials without bleed. + - Disconnecting client evicts session from memory. +2. **CLI & Entrypoint Tests**: + - `PORT=3000` boots the HTTP server. + - CLI flags `--port 3000` boots the HTTP server. +3. **Docker Compose Validation**: + - Validate `docker-compose.server.yml` configuration and verify container runs in HTTP mode. +4. **Documentation**: + - Record ADR-016 in `docs/DECISIONS.md`. + - Update `README.md` with Hosted Remote Server and Docker Compose deployment instructions.