feat(server): wire HTTP transport into CLI, add docker-compose.server.yml, and update docs
This commit is contained in:
@@ -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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user