# 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: 1. **The framework buffers `$this->response`** — headers and body are emitted only after the controller returns. Streaming (SSE) and immediate-header cases must use native `header()` + `echo` + `exit`. The framework path also emits `Content-Length: 0` for SSE bodies set via `setContent()` — clients receive an empty event stream and never learn the endpoint. 2. **The exception handler rebuilds the response object**, so headers set on `$this->response` do NOT survive `throw`/`App::abort`. Any response that must carry specific headers on an error path (401 challenge, 403 group denial) has to be raw `header()`/`exit`. 3. **Consequence: every raw-emit path must re-emit CORS headers itself.** Setting `Access-Control-Allow-Origin` once in `execute()` 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 `*` with `Access-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-Id` on 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-Headers` must include `Authorization, 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-Id` request 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-Id` response 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. - **`ping`** results 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: endpoint` SSE 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`, flush `ob_` 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).