Skip to main content
Glama
PederHP

mcp-apps-ravenwatch

by PederHP
README.md
# Ravenwatch report generator — MCP Apps demo

Demo for the Copenhagen Claude Code meetup talk (11 Aug 2026): a fully
**stateless** MCP server (spec `2026-07-28` style — fresh server + transport per
request, state as an explicit `reportId` handle) that ships its own interactive
report widget via the **MCP Apps** extension (`io.modelcontextprotocol/ui`).

Scaffolding vendored from [prestidigitation](https://github.com/PederHP/prestidigitation)
(same author, MIT). SDK pins `@modelcontextprotocol/sdk@1.29.0` +
`@modelcontextprotocol/ext-apps@1.7.5` — proven against claude.ai in July 2026;
bump only if the connector is rejected.

## Tools

| tool | visibility | role |
|---|---|---|
| `generate_report` | model | flow-starting; carries `_meta.ui.resourceUri`; returns `reportId` handle + summary |
| `get_report` | app-only | full report JSON, fetched by the widget on mount |
| `create_survey` | model | starts the survey act; the ONLY survey tool with a `resourceUri` — renders the builder widget |
| `add_survey_question` | model + app | **same tool, both callers**: the model batches questions in, the builder's Add button calls it too. No `resourceUri` → updates the open builder instead of rendering a duplicate |
| `remove_survey_question` | model + app | prune by id (ids ride in add results); the builder's per-row ✕ calls it too. No `resourceUri` |
| `confirm_create_survey` | app-only | **the human gate** — no model-callable confirm exists; only the builder's Confirm button |
| `get_survey` | app-only | survey state; supports **long-polling** (`sinceVersion` + `waitMs` hold the response until the draft changes) — the live channel on claude.ai, whose sandbox throttles iframe timers so a clock-based poll dies after one tick |

Topics: `player-retention` (**the live-demo topic** — planted anomaly at W35) and `q3-revenue`.
All data is canned and deterministic (`server/src/data.ts`) — same pixels every run.
The survey act is the visibility-tier showcase: model-only / both / app-only on one
workflow, with `updateModelContext` closing the loop after the human confirms.

## Commands

```bash
npm install
npm run build       # widget → widget/dist/index.html (single file)
npm run typecheck
npm run smoke       # self-contained headless test (starts its own server on :3999)
npm run serve       # the demo server on :3010  (POST /mcp · GET /healthz · GET /preview)
npm run watch       # rebuild widget on change (server re-reads dist per request)
npm run tunnel      # cloudflared → https://<random>.trycloudflare.com
```

`http://localhost:3010/preview` renders the widget with injected data, **no MCP
host needed** — for layout work. `?topic=q3-revenue`, `?theme=dark`, `?metric=d7`.
`/preview-survey` does the same for the survey builder (`?state=confirmed`,
`?theme=dark`); its Add/Confirm work locally in preview so the layout is clickable.
Note: `npm run watch` only rebuilds the report entry — after survey-widget edits
run `npm run build`.

## Connecting a host

`npm run serve` + `npm run tunnel`, then add the tunnel URL + `/mcp` as a
custom connector. Insert the **analyst** prompt from the connector's prompt
menu to put the model in character (incognito/temporary chats have no custom
instructions field — so the server ships its persona as an MCP prompt).

## Host lessons (claude.ai, July–August 2026)

Hard-won behaviors this repo works around — likely useful if you're building
your own MCP App:

- **Widget reuse.** A model tool call that carries `_meta.ui.resourceUri`
  renders a NEW widget instance every time. Give the resourceUri only to the
  flow-starting tool; follow-up mutators declare `visibility` only, so the
  already-open widget updates in place instead of duplicating.
- **No timers in the sandbox.** The widget iframe's timers are throttled to
  near-zero, so a `setTimeout` poll dies after one tick. The live channel is
  **long-polling**: `get_survey` takes `sinceVersion` + `waitMs` and the server
  holds the response until the state changes — the arriving response is what
  wakes the throttled frame. At mount the widget probes whether the bridge runs
  calls concurrently (a held call must never block the user's own clicks) and
  falls back to timer/activity polling if not; the wire log shows the verdict.
- **Echo user edits.** App-initiated tool calls are invisible to the model, so
  every user edit in the widget (add/remove/confirm) is echoed via
  `updateModelContext` — otherwise the conversation drifts out of sync with
  the UI. Claude reads these lazily: they land before its next turn.
- **Resource caching.** Claude Desktop caches widget resources and tool
  schemas keyed by server URL — surviving connector remove/re-add even under a
  new connector name. Test in claude.ai in the browser, and carry a visible
  build stamp in the widget footer so a stale bundle is obvious in seconds.

The survey builder's collapsible **wire log** panel shows the app-side traffic
live (bridge calls, refresh sources, `updateModelContext` outcomes) — half
debugging tool, half proof that the model never sees any of it.