diff --git a/docs/plans/2026-09-06-hosted-remote-server-plan.md b/docs/plans/2026-09-06-hosted-remote-server-plan.md new file mode 100644 index 0000000..07a3945 --- /dev/null +++ b/docs/plans/2026-09-06-hosted-remote-server-plan.md @@ -0,0 +1,365 @@ +# Hosted Remote MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a hosted remote MCP server over HTTP/SSE (`Bun.serve`) allowing multiple users to connect from their AI clients (Cursor, Claude Desktop, web agents) with their own OpenProject API keys against a shared hosted OpenProject instance, complete with a `docker-compose.server.yml` deployment file. + +**Architecture:** Extend `AppConfig` to support `port` and `host`. When `port` is provided, `src/index.ts` boots an HTTP server (`src/http-server.ts`) via `Bun.serve`. Incoming `GET /sse` connections extract the user's OpenProject API key from `Authorization: Bearer`, `X-OpenProject-Api-Key`, or `?apiKey=`, rejecting missing keys with HTTP 401. Each valid connection establishes an isolated session with its own `OpenProjectClient`, and incoming `POST /messages` JSON-RPC requests execute tools inside `runWithContext`. Provide a `docker-compose.server.yml` file and document client setups in `README.md`. + +**Tech Stack:** Bun (v1.3.14), TypeScript, `@modelcontextprotocol/sdk`, Server-Sent Events (SSE), Docker Compose. + +**Spec:** `docs/specs/2026-09-06-hosted-remote-server-design.md` + +## Global Constraints + +- Runtime & Package Manager: Bun (>= 1.2 / 1.3), `bun test` for test execution. +- Strict TypeScript: `strict: true`, `"noUncheckedIndexedAccess": true`, no `any`. +- Multi-Tenant Credential Scoping: User credentials must never be stored in global mutable variables. Each session must use its own `OpenProjectClient` inside `runWithContext`. +- Credential Extraction Precedence: + 1. `Authorization: Bearer ` + 2. `X-OpenProject-Api-Key: ` + 3. `?apiKey=` query parameter +- Rejection: Missing API keys must return HTTP 401 with JSON error `{ error: "..." }`. +- Backward Compatibility: Default behavior when `PORT` is not set must remain local stdio transport. +- Stream Hygiene: All logging in stdio mode must use `console.error`. In HTTP mode, standard server logging to stderr/console is permitted. + +--- + +### Task 1: Configuration Extension for HTTP/SSE Transport + +**Files:** +- Modify: `src/config/index.ts` +- Modify: `tests/client.test.ts` (or add tests in `tests/config.test.ts`) + +**Interfaces:** +- Consumes: `process.env`, CLI `process.argv` +- Produces: Updated `AppConfig` interface and `loadConfig()` function supporting `port?: number`, `host?: string`, and optional `apiKey` when `port` is set. + +- [ ] **Step 1: Write failing tests in `tests/config.test.ts`** + +```typescript +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { loadConfig } from "../src/config/index.ts"; + +describe("Configuration Loader (HTTP & Stdio Support)", () => { + const originalEnv = { ...process.env }; + const originalArgv = [...process.argv]; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.argv = [...originalArgv.slice(0, 2)]; + }); + + afterEach(() => { + process.env = originalEnv; + process.argv = originalArgv; + }); + + test("loads stdio configuration when PORT is unset and OPENPROJECT_API_KEY is provided", () => { + process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com"; + process.env.OPENPROJECT_API_KEY = "test-key"; + delete process.env.PORT; + + const config = loadConfig(); + expect(config.baseUrl).toBe("https://openproject.example.com"); + expect(config.apiKey).toBe("test-key"); + expect(config.readOnly).toBe(false); + expect(config.port).toBeUndefined(); + }); + + test("throws error if OPENPROJECT_API_KEY is missing in stdio mode", () => { + process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com"; + delete process.env.OPENPROJECT_API_KEY; + delete process.env.PORT; + + expect(() => loadConfig()).toThrow(); + }); + + test("loads HTTP configuration when PORT environment variable is set without API key", () => { + process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com"; + delete process.env.OPENPROJECT_API_KEY; + process.env.PORT = "3000"; + + const config = loadConfig(); + expect(config.baseUrl).toBe("https://openproject.example.com"); + expect(config.port).toBe(3000); + expect(config.apiKey).toBeUndefined(); + }); + + test("parses --port and --host CLI arguments", () => { + process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com"; + delete process.env.OPENPROJECT_API_KEY; + delete process.env.PORT; + process.argv.push("--port", "8080", "--host", "127.0.0.1", "--read-only"); + + const config = loadConfig(); + expect(config.port).toBe(8080); + expect(config.host).toBe("127.0.0.1"); + expect(config.readOnly).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/config.test.ts` +Expected: FAIL because `port`, `host`, and optional `apiKey` logic are not yet implemented in `src/config/index.ts`. + +- [ ] **Step 3: Update `src/config/index.ts`** + +Update `AppConfig` and `loadConfig` in `src/config/index.ts`: +- Add `port?: number` and `host?: string` to `AppConfig`. +- In `loadConfig`: + - Check for `--port ` in `process.argv` or `process.env.PORT`. + - Check for `--host ` in `process.argv` or `process.env.HOST`. + - If `port` is defined, `apiKey` is optional (does not throw if missing). + - If `port` is undefined, require `apiKey` as before. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test tests/config.test.ts` +Expected: PASS (all 4 tests pass). + +- [ ] **Step 5: Commit** + +```bash +git add src/config/index.ts tests/config.test.ts +git commit -m "feat(config): support port and host options for HTTP/SSE transport" +``` + +--- + +### Task 2: Hosted HTTP/SSE Server Implementation (`src/http-server.ts`) + +**Files:** +- Create: `src/http-server.ts` +- Create: `tests/http-server.test.ts` + +**Interfaces:** +- Consumes: `src/config/index.ts` (`AppConfig`), `src/context.ts` (`runWithContext`), `src/client/api-client.ts` (`OpenProjectClient`), `src/tools/index.ts` (`registerAllTools`, `allTools`) +- Produces: + ```typescript + export interface HttpServerInstance { + server: import("bun").Server; + port: number; + stop(): Promise; + } + + export function startHttpServer(config: AppConfig): Promise; + ``` + +- [ ] **Step 1: Write failing tests in `tests/http-server.test.ts`** + +```typescript +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { startHttpServer, type HttpServerInstance } from "../src/http-server.ts"; + +describe("Hosted Remote MCP Server (HTTP/SSE)", () => { + let instance: HttpServerInstance; + let serverUrl: string; + + beforeAll(async () => { + instance = await startHttpServer({ + baseUrl: "https://mock.openproject.example.com", + readOnly: true, + port: 0, // OS assigns random available port + host: "127.0.0.1", + }); + serverUrl = `http://127.0.0.1:${instance.port}`; + }); + + afterAll(async () => { + await instance.stop(); + }); + + test("GET /health returns HTTP 200 with server status", async () => { + const res = await fetch(`${serverUrl}/health`); + expect(res.status).toBe(200); + const data = (await res.json()) as { status: string; mode: string; openproject: string; readOnly: boolean }; + expect(data.status).toBe("ok"); + expect(data.mode).toBe("remote-mcp"); + expect(data.openproject).toBe("https://mock.openproject.example.com"); + expect(data.readOnly).toBe(true); + }); + + test("GET /sse without API key returns HTTP 401 Unauthorized", async () => { + const res = await fetch(`${serverUrl}/sse`); + expect(res.status).toBe(401); + const data = (await res.json()) as { error: string }; + expect(data.error).toContain("Missing OpenProject API key"); + }); + + test("GET /sse with X-OpenProject-Api-Key header initiates SSE stream", async () => { + const res = await fetch(`${serverUrl}/sse`, { + headers: { + "X-OpenProject-Api-Key": "test-key-header", + Accept: "text/event-stream", + }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + + const reader = res.body?.getReader(); + expect(reader).toBeDefined(); + + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain("event: endpoint"); + expect(text).toContain("/messages?sessionId="); + + await reader!.cancel(); + }); + + test("GET /sse with Authorization: Bearer header initiates SSE stream", async () => { + const res = await fetch(`${serverUrl}/sse`, { + headers: { + Authorization: "Bearer test-key-bearer", + Accept: "text/event-stream", + }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + + const reader = res.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain("event: endpoint"); + + await reader!.cancel(); + }); + + test("GET /sse with ?apiKey= query parameter initiates SSE stream", async () => { + const res = await fetch(`${serverUrl}/sse?apiKey=test-key-query`, { + headers: { + Accept: "text/event-stream", + }, + }); + + expect(res.status).toBe(200); + const reader = res.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain("event: endpoint"); + + await reader!.cancel(); + }); + + test("POST /messages with invalid sessionId returns HTTP 404", async () => { + const res = await fetch(`${serverUrl}/messages?sessionId=non-existent-id`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method: "ping", id: 1 }), + }); + + expect(res.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/http-server.test.ts` +Expected: FAIL because `src/http-server.ts` does not exist yet. + +- [ ] **Step 3: Implement `src/http-server.ts`** + +Implement `src/http-server.ts`: +- Parse API key from request: + - Check `req.headers.get("authorization")` (strip `Bearer `). + - Check `req.headers.get("x-openproject-api-key")`. + - Check `url.searchParams.get("apiKey")`. +- Implement endpoints: + - `/health`: Return JSON status. + - `/sse`: Validate key -> create session -> return `text/event-stream` with `/messages?sessionId=${sessionId}` endpoint. + - `/messages`: Validate `sessionId` -> route JSON-RPC message into session handler inside `runWithContext`. + - Cleanup sessions on client disconnect. +- Export `startHttpServer`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test tests/http-server.test.ts` +Expected: PASS (all tests pass). + +- [ ] **Step 5: Commit** + +```bash +git add src/http-server.ts tests/http-server.test.ts +git commit -m "feat(http): implement hosted remote MCP server with HTTP/SSE transport" +``` + +--- + +### Task 3: CLI Entrypoint, Docker Compose Setup & Documentation + +**Files:** +- Modify: `src/index.ts` +- Create: `docker-compose.server.yml` +- Modify: `docs/DECISIONS.md` (add ADR-016) +- Modify: `docs/TODO.md` +- Modify: `README.md` + +**Interfaces:** +- Consumes: `src/http-server.ts`, `src/server.ts`, `src/config/index.ts` +- Produces: Unified CLI entrypoint supporting both stdio and HTTP transports, production Docker Compose configuration, and updated documentation. + +- [ ] **Step 1: Wire `src/index.ts` for dual-transport execution** + +In `src/index.ts`: +- Load config via `loadConfig()`. +- If `config.port !== undefined`: + - Log to stderr: `[openproject-mcp] Starting hosted HTTP server on ${config.host || "0.0.0.0"}:${config.port}...` + - Await `startHttpServer(config)`. +- Else: + - Run existing stdio server lifecycle. + +- [ ] **Step 2: Create `docker-compose.server.yml`** + +Create `docker-compose.server.yml`: +```yaml +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 +``` + +- [ ] **Step 3: Record ADR-016 in `docs/DECISIONS.md`** + +Record ADR-016 documenting the hosted remote server architecture, credential extraction strategy, and session lifecycle. + +- [ ] **Step 4: Update `README.md` and `docs/TODO.md`** + +In `README.md`: +- Add "Hosted Remote MCP Server (Docker Compose)" section showing how to deploy `docker-compose.server.yml`. +- Add remote MCP client configuration instructions for Cursor and Claude Desktop using the hosted URL (`https://mcp.company.com/sse`) and personal API key. +In `docs/TODO.md`: +- Mark Phase 4 (Remote Transport) completed. + +- [ ] **Step 5: Run full test suite and type check** + +Run: +`bun run typecheck` +`bun test` +Expected: 0 type errors, all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/index.ts docker-compose.server.yml docs/DECISIONS.md docs/TODO.md README.md +git commit -m "feat(server): wire HTTP transport into CLI, add docker-compose.server.yml, and update docs" +```