13 KiB
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 testfor test execution. - Strict TypeScript:
strict: true,"noUncheckedIndexedAccess": true, noany. - Multi-Tenant Credential Scoping: User credentials must never be stored in global mutable variables. Each session must use its own
OpenProjectClientinsiderunWithContext. - Credential Extraction Precedence:
Authorization: Bearer <key>X-OpenProject-Api-Key: <key>?apiKey=<key>query parameter
- Rejection: Missing API keys must return HTTP 401 with JSON error
{ error: "..." }. - Backward Compatibility: Default behavior when
PORTis 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 intests/config.test.ts)
Interfaces:
-
Consumes:
process.env, CLIprocess.argv -
Produces: Updated
AppConfiginterface andloadConfig()function supportingport?: number,host?: string, and optionalapiKeywhenportis set. -
Step 1: Write failing tests in
tests/config.test.ts
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?: numberandhost?: stringtoAppConfig. -
In
loadConfig:- Check for
--port <number>inprocess.argvorprocess.env.PORT. - Check for
--host <string>inprocess.argvorprocess.env.HOST. - If
portis defined,apiKeyis optional (does not throw if missing). - If
portis undefined, requireapiKeyas before.
- Check for
-
Step 4: Run test to verify it passes
Run: bun test tests/config.test.ts
Expected: PASS (all 4 tests pass).
- Step 5: Commit
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:
export interface HttpServerInstance { server: import("bun").Server<unknown>; port: number; stop(): Promise<void>; } export function startHttpServer(config: AppConfig): Promise<HttpServerInstance>; -
Step 1: Write failing tests in
tests/http-server.test.ts
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")(stripBearer). - Check
req.headers.get("x-openproject-api-key"). - Check
url.searchParams.get("apiKey").
- Check
-
Implement endpoints:
/health: Return JSON status./sse: Validate key -> create session -> returntext/event-streamwith/messages?sessionId=${sessionId}endpoint./messages: ValidatesessionId-> route JSON-RPC message into session handler insiderunWithContext.- 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
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.tsfor 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).
- Log to stderr:
-
Else:
- Run existing stdio server lifecycle.
-
Step 2: Create
docker-compose.server.yml
Create docker-compose.server.yml:
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.mdanddocs/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. Indocs/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
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"