Skip to content

LIVE MCP

github-actions[bot] edited this page Oct 11, 2026 · 29 revisions

A live Current inside an MCP host

Status: built and tested end to end against fakes and against the reference package's own host class: the MCP server, the app a host is given, the page it frames, the port, the adapter, and the runtime through them. Not tried in any product host (ChatGPT, Claude, VS Code, or any other). Off by default, and not reachable on the deployed site until the creator turns it on (see Turning it on).

Current scope (2026-10-04): the MCP server, app and admitted delivery described here are the Composer transport and in scope (decision). A Dive through sampling is out of current scope.

The idea, and why it needs so little

In an MCP host the provider is the host's own model. It does not stream to RISE; it calls a tool whose argument is a sealed Current (rise.current.v1). That is exactly the seam RISE already has: an external declarative answer, validated, lowered, and played by the existing Player. So the host adds no runtime logic. It adds an adapter, a server that says what the tool is, and an app the host shows for the tool call.

host model ─ rise_present({ current }) ─▶ host ─ tool-input ─▶ app (RISE's page in a frame)
                                                                   │ port ─▶ mcp-app adapter ─▶ validateRiseCurrent (strict)
                                                                   ▼
                              the same reducer, runtime, Player, Chamber, voice, controls, microphone

a Dive:  app ─ sampling/createMessage ─▶ host ─▶ host model ─▶ its words come back in the same call

The pieces

  • The server (worker/mcp-server.mjs, at /api/mcp). MCP's Streamable HTTP in its simplest legal form: every request a POST, one JSON body back, no stream, no session, nothing kept. It says what its tools are (rise_present, whose description says when to call it and shows one plain v1 Current, and rise_guide, read-only, which takes { "style": … } and returns the whole guide to writing a Current, then that style's full guidance with two worked Currents, as one text block; amended 2026-10-09: the guide left the description, which a host reads on every turn, so tools/list went from 28 KB to 10.5 KB), refuses a Current that is not valid and tells the model why so it can try again, and serves the app as the resource ui://rise/current (text/html;profile=mcp-app). It also lists one Markdown resource per style, ui://rise/guide/premium-educational and ui://rise/guide/open-field (text/markdown), the same text rise_guide returns, for hosts whose model reads resources; any other guide address is not found (-32002). In claude.ai the reader attaches resources and the model only calls tools, so rise_present's description names each style in one line and points at rise_guide, not at the resources. It calls no model, holds no key, spends nothing. It answers only requests with no Origin (a host's own server sends none) or from its own origin, reads a body of at most 256 KB, and echoes back only a validator's message, clipped.
  • The app (src/live/hosts/mcp-relay.js). What the host is given is a small HTML document that does one thing: it frames RISE's own page, /live?embed=mcp, and passes the host's JSON-RPC messages between the two. It understands nothing it passes, sends to the page only at the page's origin, and takes from it only what that origin, from that frame, sent. This is what lets the app be the real shell, Player, Chamber, controls and microphone, with no second presenter and no new room, and nothing inlined or fetched cross-origin.
  • The page (/live?embed=mcp, src/live/host/LiveHost.js). No prompt and no provider to choose. It says hello to its parent, validates and holds one Current, then presents an accessible Begin button. Playback starts only after the reader presses it. Opened directly in a browser with no host, it says what it is for and does nothing.
  • The port (src/live/hosts/mcp-port.js). The page's side of the host's messaging: JSON-RPC over postMessage, listening only to its parent, ignoring anything malformed or oversize, reading a Current only from the two notifications one arrives in and handing it over once, answering the host's own requests (ping, ui/resource-teardown), asking the host's model (sampling/createMessage), and telling the host its size.
  • The adapter (src/live/adapters/mcp-app.js) turns the admitted sealed Current into the events a streaming provider would have sent, after validateRiseCurrent, so a hostile field, marker, anchor or oversize answer is refused before one event exists. Nothing is added: a sealed Current has no evidence and no condition, and none is invented.
  • The guide (src/live/guide/): what a model that has never seen RISE is told a Current is, split by concern: the v1 contract, the themes and looks, beats and code scenes, and one file per style (styles/). Its numbers come from the validator's own limits, its examples are real Currents the validator accepts and whose scenes the admission admits, and its account of rise.lib is exactly the library's, each held by a test. The worked Currents are also the start of the evaluation corpus (docs/evals/creative-control/, npm run eval:creative).
  • The voice is RISE's own. A host's voice timing is not something an app can rely on, and a Dive has to hold the voice.

What the reader sees first

  • A poster. Once the page has validated the answer, the frame says "Answer ready.", shows the answer's title as its heading, and offers Begin. The title is set as text, never as markup. Nothing plays until the reader presses Begin.
  • The answer's colors. The host model may name one theme for the whole answer, one of the shipped color themes (JEV_COLOR_THEMES in src/core/jev-color-themes.js). RISE takes the page colors only from its own shipped palettes (jevColors in src/core/jev-palette.js), and the filament and drawings from a fixed table of its own renderer settings. The model supplies no color, number, CSS or typeface. The poster, the controls and the Chamber all take the theme's colors.
  • No theme, RISE ink. An answer that names no theme keeps RISE's own ink ground (#06051A), and so does the relay page around it.
  • A Dive keeps the answer's theme, whatever the Dive itself says.
  • The stage bar. Added 2026-10-08 (PLY-001): once the reading plays, the card's controls are a row of round objects over it, back a passage, Play/Pause, forward a passage, say this passage again, the pace (0.8×, 1×, 1.25×, 1.5×), full screen where the host offers it, and Settings at the right, under a thin line of one tick per passage that is also the progress and seeks on a tap (src/live/host/stage-controls.js). A seek, a replay and the pace move the voice first and the words follow it (runtime.seek, replay, setPace). The pace never interrupts what the voice is saying (cancelling and speaking again silenced or skipped a network voice in the field run of 2026-10-09): the voice takes it at its next sentence or passage, the words follow once it has, and the stage says "from the next passage" until then. While the reading plays, the bar hides itself 2.5 s after the pointer last moved, leaving the tick line faint as progress, and comes back on any pointer movement, touch, key or focus, and whenever the reading is paused, ended or the sheet is open. The centred words move down into the bar's room as it hides, by the band's transform (--bar-offset), never by the field's padding: the field is the box each visual's canvas is sized to, and padding that eased with the bar resized the scene's canvas on every frame, which cleared it, and the card flickered black (field report of 2026-10-09). On a phone (pass 2, 2026-10-09) a caption sits 8 px above the bar's room (--embed-bar-room, 64 px or 88 px with the full transport) rather than at the field's centre, and the scene's canvas and a figure end where that room begins, sized once per card, never while the bar hides. In full screen on a phone (2026-10-09) the bar rises by the host's bottom safe-area inset, so the room adds that inset and the words keep the same gap above the beat line as inline; the picture also begins below the top inset, the notch, and both change only with the display mode. The keys on the stage are Space, the arrows, R, F and minus or equals. transport: 'minimal' keeps the two objects of the embed stage decision for ChatGPT's two-action guideline. Between two phrases (2026-10-11, PLY-001) the glass behind the words stays and eases to the next phrase's size while only the words fade, and it dissolves only in a silence known to outlast 1.2 s, when no words have come 1.5 s after the last left, on a pause or at the end (src/components/read/glass-continuity.js; before, it vanished for each gap, 183 to 217 ms with the paced voice, and its box collapsed from 619 × 160 to 66 × 50 px).
  • A bed under the voice. Added 2026-10-09 (SND-001): a beat's sound plays in the card. The Chamber plays the Current's beds and tones on the app's audio engine, which now starts in a host's opaque origin (its personal-swells store, IndexedDB, is skipped there with one warning). The browser's voice is not the engine's, so the host ducks the engine's musical layers on each speech.start and lets them back on speech.end, voice.failed, voice.taken and voice.held. Every offered sound is levelled to one loudness band (the levels manifest). Each bed or tone that starts is one [RISE audio] console line (t=… audio.bed id=starlight trimDb=-2), and under ?measure=1 window.__riseLive.audio() lists them and names the one sounding. Settings has a Sound switch, on by default and remembered with the reader's settings: off silences the reading's sound the way the Chamber's own None does, and on gives it back where the reading is.

What was wrong in the first design, and is fixed

The first design (PR #315) had a Dive asked as a ui/message carrying a one-time reference, expecting the model to answer by calling rise_present again with that reference and the answer to reach the same view. Reading the specification and the reference package showed it cannot: ui/message is answered with only whether the host took it, and a model's reply goes to the conversation as a new tool call, which a host shows in a view of its own. A Dive now asks the model through sampling (sampling/createMessage), which returns the words in the same call. Sampling is an optional host capability; the host says whether it offers it when the app says hello. Where it does not, there is no Dive, and the page says so in words before anything is asked. The message, the reference and their machinery are gone.

Three smaller things were also wrong against the reference: the handshake sent protocol version 2025-11-21 (the extension's is 2026-01-26); the host's own requests ping and ui/resource-teardown were ignored, so a host waiting on them would wait; and the same Current arrives twice, as a tool's input and again as its result, so the second copy would have been handed to whatever opened next as its answer.

A test in a sandboxed frame found one more: a frame sandboxed without allow-forms never fires a form's submit event, so the Dive input did nothing. The controls now ask by the button and by Enter, and no longer depend on the form being submitted.

The tool contract

rise_present takes { current: <rise.current.v1 or rise.current.v2> } and nothing else. A valid Current returns the validated object in structuredContent.current, where the app admits it, with one line of text for the model: its title, id, number of passages or beats and about how long it plays (amended 2026-10-09; the output schema names the Current rather than spelling it out a second time). The server does not acknowledge playback. An invalid one gets a tool error (isError), not a protocol error, saying what was wrong and where, so the model can correct it. The tool is read-only and idempotent. It points at the app in the extension's key (_meta.ui.resourceUri) and its older flat spelling (_meta["ui/resourceUri"]), which the reference server helper also emits.

A rise.current.v2 scene may be code the model wrote ({ id, code }). After the Current validates, the server parses each scene's code with acorn and never runs it (src/core/scene-admission.js): at most 24,576 bytes, an ES module with one default export function, no import or import(), no debugger, none of the names a scene may not reach (fetch, self, globalThis, setTimeout and the rest; refused wherever the name appears, a local one included), and reportsCompletion only as a boolean literal. One refused scene refuses the call: isError, one line per problem (Scene "vector" was refused: line 14, column 7: …, at most ten, each cleaned and clipped like every other refusal), then a line telling the model to repair and call again. In the card, a scene that still fails at run time is reported to the host's model for its next turn with ui/update-model-context, only where the host's ui/initialize answer offers hostCapabilities.updateModelContext.text (Claude does), at most five times per Current and 2,000 characters each, in RISE's words with the scene's own error message quoted as data; the same line goes to DevTools as [RISE scene] and, under ?measure=1, to window.__riseLive.scenes().

A scene may instead be a figure the model drew ({ id, svg }, CC-009): one SVG document of at most 32,768 bytes, for a picture that is right standing still, such as a labelled diagram. It takes no cues, and a hold under it lasts its ms. The server admits it with a small tokenizer that never builds a DOM (src/core/svg-admission.js): the root <svg> with the SVG namespace and a viewBox, an element allowlist (no script, foreignObject, image, a or feImage), no event handler, no href or url() but #id, nothing naming javascript:, data:, http(s): or //, and no @import or escape in CSS. A refused figure refuses the call with the same Scene "<id>" was refused: line L, column C: … lines. The card admits it again with the same function before drawing, and shows it only as an <img> of a blob: URL, which runs no script and fetches nothing; a refused figure, or one the image cannot draw, gives way to the look's field and is reported as [RISE scene]. Whether Claude's and ChatGPT's sandboxes let the card show a blob: image is not yet measured.

The whole renderer contract a third party builds against (the Current v2, manifests and cues, generated scenes, these tools and the report), with every limit held to the constant that sets it, is RISE-SDK.md: provisional until the owner's Live Run.

What was verified, and how

How Result
Server handler 32 Node tests with real Request objects; ten mutations of its guards each caught pass
Server, against the official SDK client (@modelcontextprotocol/sdk 1.31) over real HTTP a local one-off; the SDK is not a dependency of this repo 14 of 14: connects, negotiates 2025-11-25, lists and calls the tool, refuses an invalid Current as a tool error, lists and reads the resource, errors correctly
The tool's _meta against the reference server helper (registerAppTool) same one-off identical
App, page and port against the reference package's own host class (AppBridge, @modelcontextprotocol/ext-apps 2.0.3), in Chromium a local one-off; not a dependency of this repo 23 of 23 across a host that offers sampling and one that does not: it validated the handshake, received the app's size, was handed the answer (input and result), answered a Dive as a sampling request that passed its own schema, accepted the teardown reply, and reported no protocol errors
The same flows against a fake host page in the repo's browser suite e2e/live-mcp.spec.js, 7 tests pass
Relay: passes JSON-RPC only, from the right window and origin only 11 unit tests, and a browser test where the app's frame navigates to another origin and cannot speak (its check removed, the test fails) pass
Port, adapter, guide, controls, host unit tests with mutation checks pass
The worker bundles for Cloudflare esbuild with the workerd conditions (Wrangler needs Node 22; this machine has 20) 119 KB gzipped

The two reference checks are one-off local runs, not part of the test suite, because they need packages RISE does not depend on. They are described here so they can be repeated.

Turning it on (the creator's decision)

Amended 2026-10-08: it is on in production, with the self-contained card (MCP_ENABLED and MCP_SELF_CONTAINED both "true" in wrangler.production.jsonc; absent in staging). The card is RISE's page served as the host's resource, so nothing frames /live?embed=mcp and it keeps X-Frame-Options: DENY; worker/production-config.test.js refuses MCP on without the card. What follows is the history of the relay card.

It was off. MCP_ENABLED was "false" in wrangler.production.jsonc and absent in staging. Nothing about the deployed site changed until both of these were done:

  1. Set MCP_ENABLED to "true".
  2. Add "/live" to assets.run_worker_first in that Wrangler config, so the Worker sees /live?embed=mcp and can serve it framable. This is a change to the site's framing posture: every response says X-Frame-Options: DENY and frame-ancestors 'none', and a test holds that. With MCP on, exactly one request shape, GET/HEAD /live?embed=mcp, is served without X-Frame-Options and with frame-ancestors *, because a host's sandbox is on an origin RISE cannot know. Every other request is the asset, untouched. What that page can do when framed by a stranger is display a Current it is handed, and offer the microphone button, which needs the reader's own press and the browser's own permission. That is a judgment for the creator, not for me. Amended 2026-10-05: #423 adds "/live" to run_worker_first, guarded by worker/production-config.test.js; with MCP_ENABLED off the page is served exactly as before, so only step 1 remains (embed stage decision §6).

Then a host adds https://<site>/api/mcp as a connector. How each product does that is the product's business.

What is not verified, and known limits

  1. No product host. ChatGPT, Claude, VS Code and others each implement the extension their own way. The reference package is the specification's own implementation, not a product. Whether a product lets a view frame another page (the frameDomains this asks for), passes the microphone through, offers sampling, or sizes an inline frame as asked, is that product's policy.

  2. Sampling may be absent. A host that does not offer it has no Dive here. That may be most of them today. The answer itself does not depend on it.

  3. Sampling may ask the reader. The specification lets a host show the request to the reader and let them refuse. A refused Dive says so in words.

  4. Streaming. An answer arrives whole, so the first words wait for the whole answer, unlike the standalone host. The adapter states streaming: false.

  5. Evidence and conditions. A sealed Current carries neither. A Dive's answer is a sealed Current too. Changing that is a change to the sealed schema, which I have kept strict on purpose.

  6. A model has to follow the guide. A model that returns something that is not a Current is refused and told why, on the tool; for a Dive there is no retry, and the Dive fails in words.

  7. Size. The app asks its host for 640 px of height once. A host may ignore it. Amended 2026-10-05: the app declares inline as its only display mode, in appCapabilities and in the resource's _meta['openai/ui'], and reports one computed height whenever it changes, never a width (embed stage decision §5). Amended 2026-10-08: at ui/initialize the app declares inline, fullscreen and pip, so a host that offers full screen or floating may show it there when the reader asks (ui/request-display-mode, the stage bar's full screen object); the resource's _meta['openai/ui'] stays inline only. Amended 2026-10-09: the resource declares the same modes as ui/initialize (DISPLAY_MODES in src/live/hosts/mcp-port.js, inline first), since a host must not move an app to a mode it was not told of.

  8. The origin rule. The server refuses a request that carries another site's Origin, so a page on another site cannot use a browser to make it talk to this one. A browser-based MCP client on another origin therefore cannot connect. Server-side connectors send no Origin and are unaffected.

  9. Audio needs a gesture. Autoplay and speech synthesis inside a host's frame are the frame's allow policy; if speech is unavailable the reading is paced silently and says so. Amended 2026-10-09: on a phone the Claude app shows the card in a native WebView, not an iframe. On iOS (WKWebView), WebKit drops every speak() made outside a user gesture, silently, until one speak() has gone through during one, and the reading's first words come several awaits after Play; so the poster's Play and Play again speak one empty, silent utterance synchronously inside the tap (src/live/voices/unlock.js). A Play after a pause re-arms a voice the reading stood down from (voice.rearmed): it says the passage on screen again from its start and is the clock again, or stands down again if it still does not begin; while it is given up on, the Play object carries the silent mark. Android's WebView ships with speechSynthesis switched off (Chromium starts it with --disable-speech-synthesis-api, crbug.com/487255), so there the card reads paced and silent and marks Play silent; no change to RISE's Web Speech code can make it speak. Settings ends with About this reading, collapsed: the voice and why it went quiet, how often speech began, the host's context, the device, the audio engine and the last 20 [RISE voice] and [RISE audio] lines, with Copy, for a reader with no DevTools. Amended again 2026-10-09: the browser voice is no longer left to the platform's default when several voices claim it (iOS listed 68 and spoke its compact default); chooseVoice ranks Premium, Natural and Enhanced voices first, Settings offers every installed voice of the reading's language beside Sound (taken from the next passage, never interrupting one), and About lists up to 20 of their names. The beds on iOS: WebKit puts Web Audio playing alone under the Ambient audio session category, which the ring/silent switch mutes, while the browser's voice is spoken by the Claude app itself and is heard regardless (WebCore MediaSessionManagerCocoa.mm, WebPageProxy::speechSynthesisSpeak). So the same tap also resumes the engine's context and, where navigator.audioSession exists (WebKit), sets its type to playback and starts a half-second silent /audio/silence.wav looping, which puts the page under Playback even where WebKit ignores the type for want of the microphone permission (src/audio/unlock.js); the loop stops with the reading. On a host that says platform: 'mobile' the session is lifted by PHONE_SPEAKER_LIFT_DB (+6 dB: the bed near −23 dBFS, −32 under the voice, against −29 and −38 elsewhere), and About adds the output level, the phone level and the audio session. Amended 2026-10-11 (an Edge 155 field trace on "Microsoft Ava Online (Natural)"): a passage a network voice takes and never begins is tried again once, a sentence at a time, after the engine is cleared (voice.retried), and the clock stands down only if that does not begin either; on a network voice a passage over 600 characters is said a sentence at a time from the start; and About's voice trouble line names the passage, its length and whether it was tried again. The reading ends only after the voice has said the last beat that has words; a closing show+hold beat is silent by the style's design, so the 4 s between the last speech.end and run.finished in that trace was premium-educational's 3,500 ms closing line and the seam before it (src/live/last-beat.test.js).

  10. The rate limit. Added 2026-10-05: POST /api/mcp holds each client address to the same Cloudflare rate limiter as live answers (DECISION_LIMITER, declared in wrangler.production.jsonc and wrangler.demo.jsonc and held there by worker/production-config.test.js, under its own mcp: key) and answers 429 RATE_LIMITED past it, before the body is read. Where there is no limiter binding or no client address (tests, a local run), or the limiter itself fails, the route behaves as it did before: the limit is the platform's and its absence closes nothing. The limit is shared across every call a host's server makes from one address, so a large host that fronts many readers from few addresses could hit it; the number is the Wrangler configuration's to tune. Amended 2026-10-09: that is Claude. Every Claude user's call arrives from Anthropic's published egress, 160.79.104.0/21, so that range is never limited (inAnthropicEgress); every other address keeps the limit. The limiter still runs before the body is read, so that every request is charged, a malformed or oversized one too; a tripped limit then reads the bounded body only for its id and answers HTTP 429 with a JSON-RPC error carrying it (-32000, data.code RATE_LIMITED, Retry-After: 60).

  11. The card on a phone. Added 2026-10-09 (phone card pass 1, PLY-001): measured in Chromium emulating an iPhone 14 (390 px wide, pixel ratio 3, touch), not on a phone. Selected words take a touch (touch-action: none on .atom-display.is-band-movable only), so a drag moves them and no longer scrolls the chat around the card; unselected words still let it scroll. The empty phone band (a hold, or a placed caption) keeps its height and paints no scrim. A card shorter than 481 px keeps the phone type instead of the Reader's landscape type; a caption on a phone takes a phone size. The 36 px objects below 400 px wide take a 44 px press. In the card the words cannot be selected, a tap shows no highlight, a double tap on an object never zooms, and the phone band keeps its 80 % scrim without its blur. In a frame shorter than 481 px the Settings sheet stays inside the frame and its rows scroll, the card's one scroll. The Sign in pill no longer sits over the reading: sign-in is in the Settings sheet's head, beside its title, where it costs the sheet no height, and the host is asked to open it outside the card (ui/open-link). Still to see on a real phone: the drag inside Claude's app, the long-press callout and the tap highlight on iOS, and the GPU cost of the band with and without its blur.

    Pass 3 (2026-10-09, PLY-001): on a phone the words no longer lie over a figure or a generated scene. The Chamber marks the field data-picture before it inserts its first figure or scene and keeps the mark for the reading; ambient looks (attractor, signal field, gallery) leave it unmarked and keep the overlay. In a card 640 px wide or less a marked field stacks, top to bottom: the picture, a strip kept for the words (--embed-words-strip, three lines of the band's face, 134 px at 390 wide and 140 at 412), and the bar's room. The words sit at the foot of the strip, never the field's centre; longer ones rise over the picture's foot on their scrim rather than resize it. The picture's box is set once per card, at the first mount, so a scene is never resized while it plays (0 resizes in 8 s, and 0 for a scene first mounted mid-reading). Below 350 px tall the picture would be under 130 px, so it keeps the field and the words lie over its foot. The words rest there but are not caged (2026-10-09, reversing the line first written here): a tap selects them and a drag lifts them over the picture as far as 8 px under the field's top edge and brings them back down to the strip, never below it, a caption with them; the picture's box never changes and the conversation does not scroll (e2e/live-mcp.spec.js). Each press, move, release and pointercancel on the words is a [RISE band] line in About this reading, so a phone's field report shows whether the app took the drag. Measured in Chromium emulating an iPhone 14, the wide 400 × 260 figure under a three-line line: 390 × 481 from 51.8 % of the figure under the words to 0 % (the figure 390 × 254 above them); 412 × 481 from 51.3 % to 0 %; 390 × 844 from 51.8 % to 0 %, the words just above the bar; 390 × 360 from 51.8 % to 0 % (the figure 213 × 139); 390 × 300 still 62 %, the words now at the foot instead of the centre. Wide cards whose space above the bar is under 420 px (Claude's desktop card at 481) keep the overlay for now.

Where things are

worker/mcp-server.mjs (+ test), src/live/hosts/mcp-relay.js, src/live/hosts/mcp-port.js, src/live/adapters/mcp-app.js, src/live/guide/, src/live/host/LiveHost.js (?embed=mcp), tests beside them, e2e/live-mcp.spec.js, src/test/fake-mcp-port.js and sealed-current.js.

The connector directory's screenshots of the card, with their paired prompts: docs/assets/directory.

ChatGPT demonstration

The reader-controlled demo uses a dedicated configuration; production MCP remains disabled. Setup, ten acceptance cases and the evidence requirements are in CHATGPT-DEMO.md. Engineering tests and reference-host results are separate from real ChatGPT acceptance. A signed-in developer-mode account and explicit isolated deployment approval are required before the live demonstration.


Published from docs/plans/LIVE-MCP.md. Edits made here are overwritten on the next push to main.

Start here

Release

Pilot

The Archive - texts and their editing

Authoring - scores, rooms, and rendering

Reading surfaces

Imagery - what may be shown, and on whose authority

The Chapel

Direction and unbuilt work

Affect and the Emotions room

Reading decision service and retired gate

Product direction and team tracking


Documentation index · Repository

Clone this wiki locally