docs(spec): add specification for hosted remote MCP server and multi-tenant credential scoping

This commit is contained in:
Lucas Winther
2026-09-06 21:12:14 +02:00
parent 2fb02257f0
commit 1f3eae4258
@@ -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 <key>`
- `X-OpenProject-Api-Key: <key>`
- URL query parameter `?apiKey=<key>`
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 <token>`
2. `X-OpenProject-Api-Key: <token>`
3. Query parameter `?apiKey=<token>`
- 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<string, SessionData>`.
- Streams the initial `endpoint` SSE event to the client.
3. **`POST /messages`**:
- URL contains `?sessionId=<id>`.
- 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 <num>`):
- `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 <key>` succeeds and returns SSE stream.
- `GET /sse` with `X-OpenProject-Api-Key: <key>` succeeds.
- `GET /sse` with `?apiKey=<key>` 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.