feat(http): support MCP Streamable HTTP transport on /sse, /mcp, and / for Open WebUI
This commit is contained in:
@@ -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 <your-openproject-api-key>` or append `?apiKey=<your-key>`.
|
||||||
|
5. Click **Verify** to test connectivity and discover tools.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Interactive Testing with MCP Inspector
|
## Interactive Testing with MCP Inspector
|
||||||
|
|||||||
+7
-5
@@ -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.
|
4. **Health & Lifecycle Management**: Provide standardized `/health` endpoints and clean session eviction on client disconnect to prevent memory leaks.
|
||||||
|
|
||||||
### Decision
|
### 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**:
|
2. **Credential Extraction Precedence**:
|
||||||
- Priority 1: `Authorization: Bearer <token>`
|
- Priority 1: `Authorization: Bearer <token>`
|
||||||
- Priority 2: `X-OpenProject-Api-Key: <token>`
|
- Priority 2: `X-OpenProject-Api-Key: <token>`
|
||||||
- Priority 3: `?apiKey=<token>` URL query parameter
|
- Priority 3: `?apiKey=<token>` URL query parameter
|
||||||
- Reject unauthenticated `/sse` requests immediately with HTTP 401 Unauthorized.
|
- Priority 4: Server-configured `OPENPROJECT_API_KEY` fallback (single-tenant deployments)
|
||||||
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`.
|
- Reject unauthenticated requests immediately with HTTP 401 Unauthorized.
|
||||||
4. **Session Eviction**: Register connection abort and stream cancellation handlers (`req.signal.addEventListener("abort")` and `stream.cancel()`) that evict disconnected sessions from active memory.
|
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.
|
5. **Container Deployment**: Provide `docker-compose.server.yml` with built-in `/health` probe checking `GET /health` every 30s.
|
||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
- **Positive**:
|
- **Positive**:
|
||||||
- Organizations can deploy a single shared `openproject-mcp` service without sharing API tokens.
|
- 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.
|
- Backward compatibility: when `PORT` is not set, stdio transport remains the default with zero overhead.
|
||||||
- **Negative**:
|
- **Negative**:
|
||||||
- Requires network infrastructure (e.g. reverse proxy with TLS termination) in production to protect credentials in transit.
|
- Requires network infrastructure (e.g. reverse proxy with TLS termination) in production to protect credentials in transit.
|
||||||
|
|
||||||
|
|||||||
+436
-122
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import type { Server } from "bun";
|
import type { Server } from "bun";
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
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 type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
import {
|
import {
|
||||||
JSONRPCMessageSchema,
|
JSONRPCMessageSchema,
|
||||||
@@ -36,6 +37,7 @@ export interface HttpServerInstance {
|
|||||||
* 1. Authorization: Bearer <key>
|
* 1. Authorization: Bearer <key>
|
||||||
* 2. X-OpenProject-Api-Key: <key>
|
* 2. X-OpenProject-Api-Key: <key>
|
||||||
* 3. ?apiKey=<key> query parameter
|
* 3. ?apiKey=<key> query parameter
|
||||||
|
* 4. Server-configured defaultApiKey fallback (single-tenant deployments)
|
||||||
*/
|
*/
|
||||||
export function extractApiKey(
|
export function extractApiKey(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -135,7 +137,7 @@ export class BunSseTransport implements Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionRecord {
|
interface SseSessionRecord {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
transport: BunSseTransport;
|
transport: BunSseTransport;
|
||||||
server: McpServer;
|
server: McpServer;
|
||||||
@@ -143,17 +145,314 @@ interface SessionRecord {
|
|||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CORS_HEADERS = {
|
interface StreamableSessionRecord {
|
||||||
|
sessionId: string;
|
||||||
|
transport: WebStandardStreamableHTTPServerTransport;
|
||||||
|
server: McpServer;
|
||||||
|
client: OpenProjectClient;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
lastActive: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORS_HEADERS: Record<string, string> = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenProject-Api-Key",
|
"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.
|
* 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<HttpServerInstance> {
|
export async function startHttpServer(config: AppConfig): Promise<HttpServerInstance> {
|
||||||
const activeSessions = new Map<string, SessionRecord>();
|
const activeSseSessions = new Map<string, SseSessionRecord>();
|
||||||
|
const activeStreamableSessions = new Map<string, StreamableSessionRecord>();
|
||||||
|
|
||||||
|
// 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<Response> {
|
||||||
|
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({
|
const server = Bun.serve({
|
||||||
port: config.port ?? 3000,
|
port: config.port ?? 3000,
|
||||||
@@ -172,45 +471,48 @@ export async function startHttpServer(config: AppConfig): Promise<HttpServerInst
|
|||||||
// 1. Health check endpoint
|
// 1. Health check endpoint
|
||||||
if (url.pathname === "/health") {
|
if (url.pathname === "/health") {
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
return withCors(
|
||||||
status: 405,
|
new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
status: 405,
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return new Response(
|
return withCors(
|
||||||
JSON.stringify({
|
new Response(
|
||||||
status: "ok",
|
JSON.stringify({
|
||||||
mode: "remote-mcp",
|
status: "ok",
|
||||||
openproject: config.baseUrl,
|
mode: "remote-mcp",
|
||||||
readOnly: config.readOnly,
|
openproject: config.baseUrl,
|
||||||
}),
|
readOnly: config.readOnly,
|
||||||
{
|
}),
|
||||||
status: 200,
|
{
|
||||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
status: 200,
|
||||||
}
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SSE connection initiation endpoint
|
// 2. Classic MCP SSE endpoint: GET /sse without Mcp-Session-Id header
|
||||||
if (url.pathname === "/sse") {
|
if (
|
||||||
if (req.method !== "GET") {
|
url.pathname === "/sse" &&
|
||||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
req.method === "GET" &&
|
||||||
status: 405,
|
!req.headers.has("mcp-session-id")
|
||||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
) {
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = extractApiKey(req, url, config.apiKey);
|
const apiKey = extractApiKey(req, url, config.apiKey);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return new Response(
|
return withCors(
|
||||||
JSON.stringify({
|
new Response(
|
||||||
error:
|
JSON.stringify({
|
||||||
"Missing OpenProject API key. Provide via Authorization header, X-OpenProject-Api-Key header, or ?apiKey= query parameter.",
|
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", ...CORS_HEADERS },
|
status: 401,
|
||||||
}
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,34 +524,13 @@ export async function startHttpServer(config: AppConfig): Promise<HttpServerInst
|
|||||||
apiKey,
|
apiKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionServer = new McpServer({
|
const sessionServer = createSessionServer(config, sessionClient);
|
||||||
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: sessionClient, isReadOnly: config.readOnly },
|
|
||||||
fn
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let cleanedUp = false;
|
let cleanedUp = false;
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
if (cleanedUp) return;
|
if (cleanedUp) return;
|
||||||
cleanedUp = true;
|
cleanedUp = true;
|
||||||
activeSessions.delete(sessionId);
|
activeSseSessions.delete(sessionId);
|
||||||
try {
|
try {
|
||||||
await transport.close();
|
await transport.close();
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -258,14 +539,14 @@ export async function startHttpServer(config: AppConfig): Promise<HttpServerInst
|
|||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionRecord: SessionRecord = {
|
const sessionRecord: SseSessionRecord = {
|
||||||
sessionId,
|
sessionId,
|
||||||
transport,
|
transport,
|
||||||
server: sessionServer,
|
server: sessionServer,
|
||||||
client: sessionClient,
|
client: sessionClient,
|
||||||
cleanup,
|
cleanup,
|
||||||
};
|
};
|
||||||
activeSessions.set(sessionId, sessionRecord);
|
activeSseSessions.set(sessionId, sessionRecord);
|
||||||
|
|
||||||
if (req.signal.aborted) {
|
if (req.signal.aborted) {
|
||||||
cleanup().catch(() => {});
|
cleanup().catch(() => {});
|
||||||
@@ -292,68 +573,79 @@ export async function startHttpServer(config: AppConfig): Promise<HttpServerInst
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(stream, {
|
return withCors(
|
||||||
status: 200,
|
new Response(stream, {
|
||||||
headers: {
|
status: 200,
|
||||||
"Content-Type": "text/event-stream",
|
headers: {
|
||||||
"Cache-Control": "no-cache, no-transform",
|
"Content-Type": "text/event-stream",
|
||||||
"Connection": "keep-alive",
|
"Cache-Control": "no-cache, no-transform",
|
||||||
...CORS_HEADERS,
|
"Connection": "keep-alive",
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. JSON-RPC message posting endpoint
|
// 3. Classic MCP JSON-RPC message posting endpoint: POST /messages
|
||||||
if (url.pathname === "/messages") {
|
if (url.pathname === "/messages") {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
return withCors(
|
||||||
status: 405,
|
new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
status: 405,
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = url.searchParams.get("sessionId");
|
const sessionId = url.searchParams.get("sessionId");
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return new Response(
|
return withCors(
|
||||||
JSON.stringify({ error: "Missing sessionId query parameter" }),
|
new Response(
|
||||||
{
|
JSON.stringify({ error: "Missing sessionId query parameter" }),
|
||||||
status: 400,
|
{
|
||||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
status: 400,
|
||||||
}
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = activeSessions.get(sessionId);
|
const session = activeSseSessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return new Response(JSON.stringify({ error: "Session not found" }), {
|
return withCors(
|
||||||
status: 404,
|
new Response(JSON.stringify({ error: "Session not found" }), {
|
||||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
status: 404,
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
|
return withCors(
|
||||||
status: 400,
|
new Response(JSON.stringify({ error: "Invalid JSON" }), {
|
||||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
status: 400,
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(body)) {
|
if (Array.isArray(body)) {
|
||||||
for (const item of body) {
|
for (const item of body) {
|
||||||
const parseResult = JSONRPCMessageSchema.safeParse(item);
|
const parseResult = JSONRPCMessageSchema.safeParse(item);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
return new Response(
|
return withCors(
|
||||||
JSON.stringify({
|
new Response(
|
||||||
error: "Invalid JSON-RPC message in batch",
|
JSON.stringify({
|
||||||
details: parseResult.error.format(),
|
error: "Invalid JSON-RPC message in batch",
|
||||||
}),
|
details: parseResult.error.format(),
|
||||||
{
|
}),
|
||||||
status: 400,
|
{
|
||||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
status: 400,
|
||||||
}
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,15 +660,17 @@ export async function startHttpServer(config: AppConfig): Promise<HttpServerInst
|
|||||||
} else {
|
} else {
|
||||||
const parseResult = JSONRPCMessageSchema.safeParse(body);
|
const parseResult = JSONRPCMessageSchema.safeParse(body);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
return new Response(
|
return withCors(
|
||||||
JSON.stringify({
|
new Response(
|
||||||
error: "Invalid JSON-RPC message",
|
JSON.stringify({
|
||||||
details: parseResult.error.format(),
|
error: "Invalid JSON-RPC message",
|
||||||
}),
|
details: parseResult.error.format(),
|
||||||
{
|
}),
|
||||||
status: 400,
|
{
|
||||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
status: 400,
|
||||||
}
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,17 +682,32 @@ export async function startHttpServer(config: AppConfig): Promise<HttpServerInst
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response("Accepted", {
|
return withCors(
|
||||||
status: 202,
|
new Response("Accepted", {
|
||||||
headers: { "Content-Type": "text/plain", ...CORS_HEADERS },
|
status: 202,
|
||||||
});
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Default Not Found
|
// 4. Streamable HTTP MCP endpoints: /sse (POST/DELETE/GET with session), /mcp, /
|
||||||
return new Response(JSON.stringify({ error: "Not Found" }), {
|
const isStreamablePath =
|
||||||
status: 404,
|
url.pathname === "/sse" ||
|
||||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
url.pathname === "/mcp" ||
|
||||||
});
|
url.pathname === "/" ||
|
||||||
|
url.pathname === "";
|
||||||
|
|
||||||
|
if (isStreamablePath) {
|
||||||
|
return handleStreamableRequest(req, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Default Not Found
|
||||||
|
return withCors(
|
||||||
|
new Response(JSON.stringify({ error: "Not Found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -406,14 +715,19 @@ export async function startHttpServer(config: AppConfig): Promise<HttpServerInst
|
|||||||
server,
|
server,
|
||||||
port: server.port ?? (config.port ?? 3000),
|
port: server.port ?? (config.port ?? 3000),
|
||||||
async stop() {
|
async stop() {
|
||||||
for (const session of activeSessions.values()) {
|
clearInterval(cleanupInterval);
|
||||||
|
for (const session of activeSseSessions.values()) {
|
||||||
await session.cleanup();
|
await session.cleanup();
|
||||||
}
|
}
|
||||||
activeSessions.clear();
|
activeSseSessions.clear();
|
||||||
|
for (const session of activeStreamableSessions.values()) {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
activeStreamableSessions.clear();
|
||||||
server.stop(true);
|
server.stop(true);
|
||||||
},
|
},
|
||||||
getActiveSessionsCount() {
|
getActiveSessionsCount() {
|
||||||
return activeSessions.size;
|
return activeSseSessions.size + activeStreamableSessions.size;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,6 +337,180 @@ describe("Hosted Remote MCP Server (HTTP/SSE)", () => {
|
|||||||
await readerB!.cancel();
|
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 () => {
|
test("CLI entrypoint (src/index.ts) boots HTTP server when --port is provided", async () => {
|
||||||
const proc = Bun.spawn(
|
const proc = Bun.spawn(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user