diff --git a/README.md b/README.md index 673f053..7635afc 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,18 @@ In `claude_desktop_config.json` or clients connecting directly via SSE URL: } ``` +#### Open WebUI (Streamable HTTP) +In Open WebUI (**Admin Panel > Settings > External Tools** or **User Settings > Tools**): +1. Click **+** (Add Connection). +2. Select **Type**: `MCP (Streamable HTTP)`. +3. Set **URL**: + - In Docker Compose on the same network: `http://openproject-mcp:3000/mcp` (or `http://openproject-mcp:3000/sse`) + - From host machine: `http://localhost:3000/mcp` +4. Set **API Key / Auth Header** (optional): + - If `OPENPROJECT_API_KEY` is configured in the server container environment, no key is needed in the client. + - For multi-user environments, pass `Bearer ` or append `?apiKey=`. +5. Click **Verify** to test connectivity and discover tools. + --- ## Interactive Testing with MCP Inspector diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 68a68d8..6ce221d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -381,20 +381,22 @@ Key requirements for hosted operation: 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`). +1. **HTTP/SSE & Streamable HTTP Transport Engine**: Implement `src/http-server.ts` utilizing native `Bun.serve` and Web Streams. Support both classic SSE transport (`GET /sse` + `POST /messages`) and modern MCP Streamable HTTP transport (`POST`, `GET`, `DELETE` on `/sse`, `/mcp`, and `/`) for clients like Open WebUI. 2. **Credential Extraction Precedence**: - Priority 1: `Authorization: Bearer ` - Priority 2: `X-OpenProject-Api-Key: ` - Priority 3: `?apiKey=` 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. + - Priority 4: Server-configured `OPENPROJECT_API_KEY` fallback (single-tenant deployments) + - Reject unauthenticated requests immediately with HTTP 401 Unauthorized. +3. **Per-Connection Isolation**: For each connection or session, 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, stream cancellation handlers, and session TTL timers 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. + - Full compatibility with both classic SSE clients (Cursor, Claude Desktop) and Streamable HTTP clients (Open WebUI, Python MCP SDK). - 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. + diff --git a/src/http-server.ts b/src/http-server.ts index 2692c17..25b4890 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -10,6 +10,7 @@ import type { Server } from "bun"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { JSONRPCMessageSchema, @@ -36,6 +37,7 @@ export interface HttpServerInstance { * 1. Authorization: Bearer * 2. X-OpenProject-Api-Key: * 3. ?apiKey= query parameter + * 4. Server-configured defaultApiKey fallback (single-tenant deployments) */ export function extractApiKey( req: Request, @@ -135,7 +137,7 @@ export class BunSseTransport implements Transport { } } -interface SessionRecord { +interface SseSessionRecord { sessionId: string; transport: BunSseTransport; server: McpServer; @@ -143,17 +145,314 @@ interface SessionRecord { cleanup: () => Promise; } -const CORS_HEADERS = { +interface StreamableSessionRecord { + sessionId: string; + transport: WebStandardStreamableHTTPServerTransport; + server: McpServer; + client: OpenProjectClient; + cleanup: () => Promise; + lastActive: number; +} + +const CORS_HEADERS: Record = { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenProject-Api-Key", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, X-OpenProject-Api-Key, Mcp-Session-Id, Last-Event-ID, Mcp-Protocol-Version", + "Access-Control-Expose-Headers": "Mcp-Session-Id, Mcp-Protocol-Version", }; +/** + * Attaches CORS headers to a response if not already set. + */ +function withCors(response: Response): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(CORS_HEADERS)) { + if (!headers.has(key)) { + headers.set(key, value); + } + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +/** + * Normalizes an incoming Request for Streamable HTTP compliance: + * - Ensures Accept includes both application/json and text/event-stream (preventing 406 on clients like Open WebUI) + * - Propagates sessionId from query parameter to Mcp-Session-Id header if missing + */ +function normalizeMcpRequest(req: Request): Request { + const headers = new Headers(req.headers); + const accept = headers.get("accept") ?? ""; + if (!accept.includes("application/json") || !accept.includes("text/event-stream")) { + headers.set("accept", "application/json, text/event-stream"); + } + const sessionId = + headers.get("mcp-session-id") ?? + new URL(req.url).searchParams.get("sessionId") ?? + new URL(req.url).searchParams.get("mcp-session-id"); + if (sessionId && !headers.has("mcp-session-id")) { + headers.set("mcp-session-id", sessionId); + } + return new Request(req, { headers }); +} + +/** + * Checks if a parsed JSON-RPC payload is or contains an initialize request. + */ +function isInitializeMessage(msg: unknown): boolean { + if (!msg || typeof msg !== "object") return false; + if (Array.isArray(msg)) return msg.some(isInitializeMessage); + return (msg as { method?: unknown }).method === "initialize"; +} + +/** + * Helper to construct an McpServer instance with all tools registered and execution wrapped in RequestContext. + */ +function createSessionServer( + config: AppConfig, + client: OpenProjectClient +): McpServer { + const sessionServer = new McpServer({ + name: SERVER_NAME, + version: SERVER_VERSION, + }); + + registerAllTools(sessionServer, { + readOnly: config.readOnly, + wrapExecute: async (fn, tool) => { + if (config.readOnly && !tool.readOnly) { + return formatToolError( + new OpenProjectError( + "Operation rejected. OpenProject MCP server is running in read-only mode.", + { code: "SERVER_READ_ONLY" } + ) + ); + } + return runWithContext( + { client, isReadOnly: config.readOnly }, + fn + ); + }, + }); + + return sessionServer; +} + /** * Starts the hosted HTTP/SSE MCP server with Bun.serve. + * Supports both classic SSE transport (GET /sse + POST /messages) and + * modern Streamable HTTP transport (POST/GET/DELETE on /sse, /mcp, and /). */ export async function startHttpServer(config: AppConfig): Promise { - const activeSessions = new Map(); + const activeSseSessions = new Map(); + const activeStreamableSessions = new Map(); + + // Periodically clean up inactive Streamable HTTP sessions (TTL: 1 hour) + const SESSION_TTL_MS = 60 * 60 * 1000; + const cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [, session] of activeStreamableSessions.entries()) { + if (now - session.lastActive > SESSION_TTL_MS) { + session.cleanup().catch(() => {}); + } + } + }, 5 * 60 * 1000); + + /** + * Handles Streamable HTTP transport requests (POST, GET, DELETE). + */ + async function handleStreamableRequest(req: Request, url: URL): Promise { + const normReq = normalizeMcpRequest(req); + + // 1. DELETE request terminates session + if (req.method === "DELETE") { + const sessionId = + normReq.headers.get("mcp-session-id") ?? url.searchParams.get("sessionId"); + if (!sessionId) { + return withCors( + new Response(JSON.stringify({ error: "Missing session ID" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) + ); + } + const session = activeStreamableSessions.get(sessionId); + if (!session) { + return withCors( + new Response(JSON.stringify({ error: "Session not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }) + ); + } + await session.cleanup(); + return withCors(new Response(null, { status: 204 })); + } + + // 2. GET request on session (SSE stream for server-to-client notifications) + if (req.method === "GET") { + const sessionId = + normReq.headers.get("mcp-session-id") ?? url.searchParams.get("sessionId"); + if (!sessionId) { + return withCors( + new Response( + JSON.stringify({ + error: + "Missing Mcp-Session-Id header. To initiate a Streamable HTTP session, send a POST initialize request.", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ) + ); + } + const session = activeStreamableSessions.get(sessionId); + if (!session) { + return withCors( + new Response(JSON.stringify({ error: "Session not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }) + ); + } + session.lastActive = Date.now(); + const res = await runWithContext( + { client: session.client, isReadOnly: config.readOnly }, + () => session.transport.handleRequest(normReq) + ); + return withCors(res); + } + + if (req.method !== "POST") { + return withCors( + new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { "Content-Type": "application/json" }, + }) + ); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return withCors( + new Response(JSON.stringify({ error: "Invalid JSON" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) + ); + } + + const sessionId = + normReq.headers.get("mcp-session-id") ?? url.searchParams.get("sessionId"); + + // Existing session message (already authenticated upon initialization) + if (sessionId && !isInitializeMessage(body)) { + const session = activeStreamableSessions.get(sessionId); + if (!session) { + return withCors( + new Response(JSON.stringify({ error: "Session not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }) + ); + } + session.lastActive = Date.now(); + const res = await runWithContext( + { client: session.client, isReadOnly: config.readOnly }, + () => session.transport.handleRequest(normReq, { parsedBody: body }) + ); + return withCors(res); + } + + // 3. New session initialization or stateless request requires API key + const apiKey = extractApiKey(req, url, config.apiKey); + if (!apiKey) { + return withCors( + new Response( + JSON.stringify({ + error: + "Missing OpenProject API key. Provide via Authorization header, X-OpenProject-Api-Key header, or ?apiKey= query parameter.", + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + } + ) + ); + } + + // New session initialization + if (isInitializeMessage(body)) { + const newSessionId = crypto.randomUUID(); + const sessionClient = new OpenProjectClient({ + baseUrl: config.baseUrl, + apiKey, + }); + const sessionServer = createSessionServer(config, sessionClient); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => newSessionId, + enableJsonResponse: true, + }); + await sessionServer.connect(transport); + + let cleanedUp = false; + const cleanup = async () => { + if (cleanedUp) return; + cleanedUp = true; + activeStreamableSessions.delete(newSessionId); + try { + await transport.close(); + } catch {} + try { + await sessionServer.close(); + } catch {} + }; + + const record: StreamableSessionRecord = { + sessionId: newSessionId, + transport, + server: sessionServer, + client: sessionClient, + cleanup, + lastActive: Date.now(), + }; + activeStreamableSessions.set(newSessionId, record); + + const res = await runWithContext( + { client: sessionClient, isReadOnly: config.readOnly }, + () => transport.handleRequest(normReq, { parsedBody: body }) + ); + return withCors(res); + } + + // Stateless fallback request (e.g. tools/list or tools/call without session ID) + const statelessClient = new OpenProjectClient({ + baseUrl: config.baseUrl, + apiKey, + }); + const statelessServer = createSessionServer(config, statelessClient); + const statelessTransport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + }); + await statelessServer.connect(statelessTransport); + try { + const res = await runWithContext( + { client: statelessClient, isReadOnly: config.readOnly }, + () => statelessTransport.handleRequest(normReq, { parsedBody: body }) + ); + return withCors(res); + } finally { + await statelessTransport.close().catch(() => {}); + await statelessServer.close().catch(() => {}); + } + } const server = Bun.serve({ port: config.port ?? 3000, @@ -172,45 +471,48 @@ export async function startHttpServer(config: AppConfig): Promise { - if (config.readOnly && !tool.readOnly) { - return formatToolError( - new OpenProjectError( - "Operation rejected. OpenProject MCP server is running in read-only mode.", - { code: "SERVER_READ_ONLY" } - ) - ); - } - return runWithContext( - { client: sessionClient, isReadOnly: config.readOnly }, - fn - ); - }, - }); + const sessionServer = createSessionServer(config, sessionClient); let cleanedUp = false; const cleanup = async () => { if (cleanedUp) return; cleanedUp = true; - activeSessions.delete(sessionId); + activeSseSessions.delete(sessionId); try { await transport.close(); } catch {} @@ -258,14 +539,14 @@ export async function startHttpServer(config: AppConfig): Promise {}); @@ -292,68 +573,79 @@ export async function startHttpServer(config: AppConfig): Promise { await readerB!.cancel(); }); + test("POST /sse with initialize message returns HTTP 200 with Mcp-Session-Id and serverInfo (Open WebUI compatibility)", async () => { + const res = await fetch(`${serverUrl}/sse?apiKey=test-streamable-key`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "*/*", // Open WebUI / httpx often sends */* or application/json + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "open-webui", version: "0.5.0" }, + }, + }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + const sessionId = res.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + const data = (await res.json()) as { + jsonrpc: string; + id: number; + result: { + protocolVersion: string; + serverInfo: { name: string; version: string }; + }; + }; + expect(data.jsonrpc).toBe("2.0"); + expect(data.id).toBe(1); + expect(data.result.serverInfo.name).toBe("openproject-mcp"); + + // Subsequent tools/list request with Mcp-Session-Id + const toolsRes = await fetch(`${serverUrl}/sse`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Mcp-Session-Id": sessionId!, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }), + }); + + expect(toolsRes.status).toBe(200); + const toolsData = (await toolsRes.json()) as { + jsonrpc: string; + id: number; + result: { tools: Array<{ name: string }> }; + }; + expect(toolsData.id).toBe(2); + expect(toolsData.result.tools.length).toBeGreaterThan(0); + expect( + toolsData.result.tools.some((t) => t.name === "openproject_list_projects") + ).toBe(true); + + // DELETE session to clean up + const deleteRes = await fetch(`${serverUrl}/sse`, { + method: "DELETE", + headers: { + "Mcp-Session-Id": sessionId!, + }, + }); + expect(deleteRes.status).toBe(204); + + // Subsequent request on deleted session returns 404 + const afterDeleteRes = await fetch(`${serverUrl}/sse`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Mcp-Session-Id": sessionId!, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "tools/list", + params: {}, + }), + }); + expect(afterDeleteRes.status).toBe(404); + }); + + test("Streamable HTTP on /mcp and / root endpoints", async () => { + // 1. /mcp endpoint + const mcpRes = await fetch(`${serverUrl}/mcp`, { + method: "POST", + headers: { + Authorization: "Bearer test-mcp-path-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 10, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }), + }); + expect(mcpRes.status).toBe(200); + const mcpSessionId = mcpRes.headers.get("mcp-session-id"); + expect(mcpSessionId).toBeTruthy(); + + // 2. / root endpoint + const rootRes = await fetch(`${serverUrl}/`, { + method: "POST", + headers: { + "X-OpenProject-Api-Key": "test-root-path-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 20, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }), + }); + expect(rootRes.status).toBe(200); + const rootSessionId = rootRes.headers.get("mcp-session-id"); + expect(rootSessionId).toBeTruthy(); + expect(rootSessionId).not.toBe(mcpSessionId); + }); + + test("POST /mcp without API key returns HTTP 401 Unauthorized", async () => { + const res = await fetch(`${serverUrl}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + }, + }), + }); + expect(res.status).toBe(401); + const data = (await res.json()) as { error: string }; + expect(data.error).toContain("Missing OpenProject API key"); + }); + + test("POST /mcp with invalid sessionId returns HTTP 404", async () => { + const res = await fetch(`${serverUrl}/mcp`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + "Mcp-Session-Id": "non-existent-uuid", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }), + }); + expect(res.status).toBe(404); + }); + test("CLI entrypoint (src/index.ts) boots HTTP server when --port is provided", async () => { const proc = Bun.spawn( [