The com_mcp gateway: PHP realities, CORS, SSE, sessions
Audience: hub platform maintainers. Tool authors don’t touch this layer, but if your clients fail with browser/CORS/session symptoms, this is the vocabulary for the bug report.
The gateway (com_mcp/api/controllers/mcpv1_0.php) sits between MCP clients
and the tool-session server. Endpoints:
GET /api/mcp/{tool} session bootstrap (returns endpoints)
GET /api/mcp/{tool}/mcp streamable HTTP / SSE endpoint event
POST /api/mcp/{tool}/mcp JSON-RPC proxy (the main path)
GET /api/mcp/{tool}/mcp/.well-known/oauth-protected-resource
GET /api/mcp/{tool}/openapi.json
HubZero/PHP framework traps
These drive almost every design decision in the controller:
The framework buffers
$this->response— headers and body are emitted only after the controller returns. Streaming (SSE) and immediate-header cases must use nativeheader()+echo+exit. The framework path also emitsContent-Length: 0for SSE bodies set viasetContent()— clients receive an empty event stream and never learn the endpoint.The exception handler rebuilds the response object, so headers set on
$this->responsedo NOT survivethrow/App::abort. Any response that must carry specific headers on an error path (401 challenge, 403 group denial) has to be rawheader()/exit.Consequence: every raw-emit path must re-emit CORS headers itself. Setting
Access-Control-Allow-Originonce inexecute()covers only framework-sent responses (e.g. the OPTIONS preflight) — which is exactly how you end up with “CORS present on preflight, missing on actual responses”, the single most reported browser-host failure.
CORS rules for MCP over browsers
Access-Control-Allow-Origin: *on every response: preflight, 200 raw passthrough, SSE, 401 challenge, 403 denial.Never combine
*withAccess-Control-Allow-Credentials: true— the pair is spec-invalid and browsers reject it. Bearer-token auth needs no cookies.Access-Control-Expose-Headers: WWW-Authenticate, Mcp-Session-Idon actual responses — without it, browser JS cannot read the OAuth challenge (so discovery never starts) nor the session id (so async tasks break).Preflight
Access-Control-Allow-Headersmust includeAuthorization, Content-Type, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID— otherwise the browser refuses to send the MCP session header.The 403 group-denial body must be CORS-readable too, or browser users see an opaque error instead of “you need group X”.
Session forwarding (async tools depend on it)
Forward the client’s
Mcp-Session-Idrequest header to the MCP server — it is the spec’s session channel. Dropping it breaks every task-capable async tool (“Task-capable async tool calls require an MCP session”).Echo a server-minted
Mcp-Session-Idresponse header back to the client so task ownership and polling survive across calls.
Proxy behaviors that keep strict clients happy
Raw byte passthrough: when nothing was rewritten, echo the MCP server’s response bytes verbatim. A decode→re-encode round trip can’t tell
{}from[]and corrupts empty arrays (CSP domain lists!), which claude.ai rejects against the tool’s output schema.Notifications (JSON-RPC without
id) are acknowledged with HTTP 202.pingresults are normalized to{}(some backends return[]).SSE without real streaming: PHP/nginx can’t reliably hold streams, so the GET endpoint returns one complete
event: endpointSSE body (raw-emit) pointing clients at the POST endpoint — spec-compliant and buffer-proof. For true streaming paths, disable buffering (zlib.output_compression=0,X-Accel-Buffering: no, flushob_levels) and send headers natively.
The chat host is also a host
com_mcp ships its own browser chat (site/assets/js/). It must declare its
capabilities at initialize like any other host — Tasks
(io.modelcontextprotocol/tasks) and MCP Apps
(io.modelcontextprotocol/ui with
mimeTypes: ["text/html;profile=mcp-app"]) — because servers gate behavior
on that declaration. Forgetting the declaration breaks your own host first.
Gateway URI quirk: the gateway prepends its origin to resource URIs
(https://nanohub.org/ui://tool/app), so hosts must canonicalize before
comparing against advertised ui:// URIs (keep from the last non-http scheme
marker onward).