diff --git a/apps/mcp/README.md b/apps/mcp/README.md index b983f99b..344f2aad 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -95,11 +95,74 @@ The Worker takes the variables in the table below as Wrangler `vars` or secrets, | `MCP_ALLOWED_ORIGINS` | Comma-separated allowed browser origin hostnames. A configured list replaces defaults; an explicitly empty list permits no Origin header value. Requests without Origin remain supported. | | `MCP_RESPONSE_MODE` | `json` for modern MCP final-answer-only responses; otherwise SSE, which supports client-requested progress notifications. Older-host compatibility responses may use SSE. | | `ANSARI_RELEASE` | Optional safe release identifier, embedded in served HTML and health/log records. | +| `MCP_VIEW_DIAGNOSTICS_ORIGIN` | Set only on a deployment being diagnosed: its own public origin, such as `https://ansari-mcp-development.up.railway.app`. The card may then connect to that one origin and reports its start-up milestones to `POST /diag/view`; this changes the card's bytes, address and permissions. Unset, the card, its address and its closed permissions are unchanged and the route does not exist. A value that is not a bare `https` origin (`http` only for localhost) stops startup. Implies `MCP_LOG_LEVEL=debug`. See [First-load diagnosis](#first-load-diagnosis). | +| `MCP_LOG_LEVEL` | `info` (default) or `debug`. `debug` also writes the first-load measurement records; set it only on a deployment being measured. Any other value, or `info` together with `MCP_VIEW_DIAGNOSTICS_ORIGIN`, stops startup. | `POST /mcp` serves stateless MCP requests through Hono and the official MCP request handler. Modern requests need no initialization; older hosts can use the handler’s stateless compatibility path. No MCP session IDs, session map, expiry timer or replay store are retained. `GET /mcp` and `DELETE /mcp` return 405. `/health` exposes the release. The HTML is supplied by the hosting target (read from `dist/` at Node start, bundled into the Worker), revision-stamped and fingerprinted once before the first request is served: the release is stamped first, so equal final bytes have equal identities and a release change legitimately changes the hash. Each request creates its own lightweight MCP server using that prepared view; resource bytes and identity stay fixed until restart. No demo, font server, alternative renderer or historical resource is served. Tool calls have a fresh 60-second budget; timeout yields a safe error and leaves subsequent calls usable. The same lifetime signal aborts the upstream fetch and response-body read when the request ends or the budget expires. Closing a request server releases its budget even if it was never connected to a transport. On SIGINT or SIGTERM the listener stops accepting requests, closes MCP handlers and HTTP connections, and exits; unfinished calls are interrupted, including older-host SSE calls. Fetch cancellation does not guarantee that the remote backend has stopped processing. Caller-disconnect propagation depends on the host transport; the local Workers runtime does not establish immediate upstream cancellation from a client disconnect. Actual fetch cancellation at the request deadline is covered on both hosting targets. +## First-load diagnosis + +A bounded experiment for issue #335, not a permanent observability design. In Claude web and ChatGPT web the host's +own error replaced the card on first load and one Retry recovered it; the card's own failure texts never appeared, so +the host gave up before the card showed anything. The aim is to locate the first missing step between the card read, +the card's start, the host handshake, tool-result arrival and the first rendered answer, where that step can be +observed at all. After the trials, this is removed or narrowed to what proved useful. + +**Log levels.** Every record carries a `level`. `error`: `mcp.error` and `source.error`, by error name and code only. +`warn`: rejected or rate-limited card reports. `info`: the operational records (`startup`, `mcp.request` with the HTTP +method, `source.start`, `source.complete`). `debug`: the measurement records below, written only with +`MCP_LOG_LEVEL=debug`. At `info` the server neither inspects nor wraps an exchange, and it writes the records of a +deployment without this experiment, plus a level on each and error +names and codes; dropping the `debug` lines from a measured deployment leaves the same records. + +**Server records, while measuring.** Each `/mcp` exchange also writes `mcp.rpc` and `mcp.response` lines joined by +`rid`. The request is inspected from a copy, alongside the handler and up to 64 KiB actually read, so inspection never holds up +or alters the exchange; a larger body is recorded as `oversize`. The request line names the JSON-RPC method; for a card +read, the address read and whether it is the one this instance serves (`uriCurrent`); for the handshake, the client's +name, version and protocol; for a tool call, the tool name. The response line has the HTTP status, bytes, time to +headers and total time, and `stream`: whether this side's response stream was `drained`, `cancelled` or `failed`. That +is not proof the host received or rendered it. Responses up to 64 KiB are decoded as JSON-RPC for an error code; a +stale card read is `-32602` inside an HTTP 200. At start, `view.prepared` records the served card's address, size, +whether it is a diagnostic card and its connect origins. Handler errors are logged by name and code. These records hold +no question, answer or other parameter value. A closed-card trial still needs these records: set +`MCP_LOG_LEVEL=debug` and leave `MCP_VIEW_DIAGNOSTICS_ORIGIN` unset. + +**Card reports, diagnostic card only.** With `MCP_VIEW_DIAGNOSTICS_ORIGIN` set, the served card carries an +`ansari-diagnostics` address in its head, may connect to that one origin, and changes bytes, address and permissions. +Compare its trials with the closed card's rather than assuming they behave the same. An early script, which runs +before the bundle is parsed, records errors, unhandled rejections, content-policy violations and when the host's +messages arrive (first message, tool input, tool result, cancellation, teardown request), by method name only. The +stack's hooks add font loading, host-context delivery, how starting ended and the first rendered answer or problem. The +early script reports by itself at `load`, at `pagehide` and after 15 s without a start, so a bundle that never runs is +still reported; the bundle adds reports when the card is ready, fails, or renders its first answer or problem. Each +report is an `[ansari-view]` console line and a beacon to `POST /diag/view`. The route accepts only the fixed report +shape, at most 8 KiB and 120 reports a minute per instance, and logs `view.report` (level `debug`) with `clientAuthored: true`: the +shape limits what is logged, but anyone who can reach the route can send a report. Report fields are milestones, +enumerated failures and error names; the stack still prints a callback's own error to the console, as before. One +violation is expected on every load: zod's `new Function('')` capability probe under the card's `script-src`, which it +catches. The closed card's early script does nothing. + +**Trials.** Use the deployment for nothing else while a trial runs. Run one trial per new conversation, with the +deployment live for hours (`aged`) and right after a redeploy (`fresh`), and with the diagnostic and the closed card. +The trial script records the deployment's release and the first attempt and Retry as separate windows: + +```sh +bash scripts/first-load-trial.sh https://ansari-mcp-development.up.railway.app claude aged diagnostic +# Railway: deployment and edge HTTP logs covering the trials, then one timeline per trial. +railway logs --deployment --json --since 1h > test-results/first-load/app.jsonl +railway logs --http --json --since 1h > test-results/first-load/edge.jsonl +node scripts/first-load-timeline.mjs test-results/first-load/trials.jsonl test-results/first-load/app.jsonl test-results/first-load/edge.jsonl +``` + +Records are attributed to an attempt by time only; neither `rid` nor a card's id links to the host's attempt. The +timeline lists what was observed in each window and flags windows with more than one MCP client, overlapping trials or +another release as ambiguous. A finding such as "no host message observed by its last report" says only that. Two +controlled cases have known answers: a stale card read (`resources/read` of an earlier address, answered `-32602`) and +a bundle that fails before it runs (covered by the browser checks: a `load` report without `main` and with the +`SyntaxError`). + ## Workspace and container This package lives at `apps/mcp` and is named `ansari-mcp`. The root `pnpm-lock.yaml` and `pnpm-workspace.yaml` are the only install inputs. `pnpm mcp