Contents

Claude on the web, over MCP

Status: built, 2026-10-05. Builds on the web service (m12-web-service.md), sharing (m12-sharing.md) and the desktop's Claude plan path (claude-plan.md).

On the desktop, Connect Claude adds Reelwright's MCP server (reelwright mcp) to the Claude Code on the person's computer, so their own Claude Pro or Max plan drives the showrunner's tools on their show and Studio reviews what it proposes. A person on the web edition has no Reelwright on their computer, so the service offers the MCP server itself: https://reelwright.diy/app/mcp. Claude Code connects with one command, and the Claude apps (claude.ai, Claude Desktop) connect to the same address as a custom connector. It works as well as the API key path in the other direction: the same tools, on the same show, as the same person, reviewed in the same Studio.

Why this is within Anthropic's terms

Nothing here touches a Claude login. The person's own Claude client (Claude Code, a Claude app) signs in to Claude through Anthropic's own flow and does all the talking to Claude; it calls Reelwright's tools as any MCP client calls any remote MCP server. Reelwright never sees, stores or relays a Claude credential, never calls Anthropic's API on a plan, and offers no plan as a service: what claude-plan.md says Reelwright never builds stays unbuilt. The tokens here are Reelwright's own, for Reelwright's own endpoint.

The pieces

Claude Code / claude.ai / Claude Desktop  (the person's own Claude, on their plan)
   │  MCP, Streamable HTTP, Authorization: Bearer rwa_… (or rwp_…)
   ▼
reelwright.diy/app/mcp ──(Vercel rewrite)──▶ reelwright host  (core/src/host/mcp.ts)
   │                                            │ a session per 2025 client, in memory;
   │                                            │ a 2026-07-28 request on its own
   │                                            │ the show it works on: a socket of its own
   │                                            ▼
   │                                  reelwright serve --show <the show>   (the show's daemon,
   │                                    hello as the person, with their role  the same one
   │                                    tools.list / tools.call / changes.*   Studio uses)
   │                                            │ change.event to everyone in the show
   │                                            ▼
   │                                  the person's Studio tab: the proposal appears, for review
   └── OAuth: /app/oauth/{register,authorize,token,revoke}  (core/src/host/oauth.ts)
  • The endpoint is MCP's Streamable HTTP transport, in both protocols a client may speak, on the one address. A 2025 client (protocol up to 2025-11-25, the official SDK 1.x's StreamableHTTPServerTransport) POSTs initialize, gets an Mcp-Session-Id, and sends every later request with it, each answered as an event stream that says : still working every 25 s through Vercel's rewrite. A GET with the session id opens the session's own stream, kept talking the same way, on which the server tells the client unasked that its tools changed (notifications/tools/list_changed); DELETE ends a session.
  • 2026-07-28 (MCP SDK 2.x, @modelcontextprotocol/server's createMcpHandler with legacy: "reject"): a request that says that version in its _meta and headers stands alone. Claude Code sends server/discover first (2.1.296 does) and, answered, speaks it from then on: no initialize, no session. The SDK's isLegacyRequest sorts each session-less POST (a JSON media type is required, 415 otherwise); one of 2026 goes to the person's own handler, which makes a server for that request alone (server-modern.ts, the same answers as the 2025 server's, mcpHandlers). Its answers are event streams too, with the same keepalive. With nothing kept between requests, there is no choose_show: every show tool takes a show (an id from list_shows, taken off before the daemon sees the input), and a call that names none works on the connection's ?show=, else the person's default; list_shows marks that one byDefault. The socket a request opens to a show is kept for the connection's next one, while the person's role there is what it was, and closed after an idle hour or with the connection. Tool changes go on the stream the client opens for them (subscriptions/listen): the person's handler is theirs alone, so its notify.toolsChanged() reaches their streams and no one else's. A person keeps at most 32 such streams.
  • The tools offered are what the person may do somewhere: list_shows only with no show; a viewer somewhere, the read tools, choose_show and list_comments; a reviewer, with add_comment and reply_comment; an owner or editor, every tool. A call still finds every tool, so one a show's role forbids is refused there in words by its daemon. Every 2 s (TOOLS_WATCH) the service looks again at each session's person, and each person whose 2026 client listed tools: a first show made or shared, a role changed or a show left changes what is offered, and the session (or the person's listening streams) is sent tools/list_changed, so Claude lists its tools again without a reconnect. Sessions live in memory: a restart, an hour with no request, or the connection being revoked ends one, and the client starts another (a 404 for an unknown session tells it to). A person has at most eight at once.
  • A session works on one show: the one the person last opened in Studio (else their newest, else the first shared with them), until Claude chooses another. It is pinned to that show for the session, so the model is never moved under its feet. Five tools are the service's own: list_shows (the person's shows and those shared with them, each with their role, which is open in Studio, and which this session is on), choose_show (by id, or by an exact title), and the show's comments (M13), list_comments, add_comment and reply_comment, as comments.list, comments.create and comments.reply. Every other tool is the daemon's own, listed by the session method tools.list; with no show at all, only the service's own are listed.
  • Where the person is in Studio (studio_context, as on the desktop's server; see "Where the person is" in docs/design/m3-showrunner.md): the place the person signed in to the session last said in a Studio open on the show (the daemon keeps it from chat.focus in the show's .reelwright/studio/focus-<person>.json), with that page's brief drawn from the show through the session's daemon and Studio's suggestions there. Another person's place is never read. Given a context (a chat message's), it answers that place's brief as a turn carries it: what the plan bridge asks for each message.
  • Reelwright's prompts and the place as a resource (both servers, both protocols; core/src/mcp/prompts.ts, docs/design/showrunner-connected.md slice 4): here (the place, as studio_context says it, and that the person's words come with it), suggestions (what Studio's chat suggests asking there), asked (the directions Studio left waiting for Claude, the person's own, with the call that takes each), review (each proposal waiting, why and what it changes, and that deciding is the person's) and next (the Show page's next steps, read from the daemon: episodes.status, renders.list, memory.list, graph.status), each carrying the place; and the resource reelwright://studio, the place itself. None takes an argument: the real Claude Code passes a prompt only the first word of one (and drops a command whose required one is missing), while Claude reads the person's whole words beside it. Claude Code runs them typed as /mcp__reelwright__here (its / menu shows /reelwright:here (MCP), which typed runs nothing); claude.ai and the Claude app list them in the + menu under Connectors.
  • Through the show's daemon, as the person. The session opens (or reuses) the show's daemon with DaemonPool.open(…, {current: false}), which puts it in the person's list without making it the show they have open: their Studio stays where it is. It opens a WebSocket of its own to the daemon and says session.hello with the daemon's token and as (the person, their name, their role in the show now), exactly as the host's /ws pipe does for a browser. The pool counts it as one of the person's sockets (join), so their grant goes first, and a role change (1012), a removal (4403), a key connected, Sign out everywhere or the daemon stopping close it like any of theirs; the next call opens a new one and asks their role again. The daemon enforces roles from the schema's method classes, as for Studio: a reviewer reads and comments, and a propose or decide tool is refused with session.comment_only; a viewer only reads, and is refused a comment as well as a change (session.read_only).
  • Proposals wait. A propose tool goes as tools.call with author: "showrunner": the daemon runs it like the showrunner's chat does, so its change sets stay proposed (Studio's own tools.call, the timeline's edits, is still applied as the person's edit). The change carries by, the person, and the daemon tells everyone in the show (change.event): the person's open Studio shows the proposal as it is made, to approve or reject there. Decide tools (approve_change, reject_change, undo, redo, lock, unlock) go as the matching changes.* methods, carry anthropic/requiresUserInteraction as on the desktop (Claude Code asks first), and say in their description to be called only when the person asks in so many words. The everyday tools, studio_context and list_shows carry anthropic/alwaysLoad, so Claude Code keeps them in view and finds the rest with its tool search (coreTools in core/src/showrunner/tool-search.ts).
  • Previews come back as images, as on the desktop: the endpoint reads a preview file only when it is inside the show the session is on, makes its JPEG with ffmpeg into the show's .reelwright/cache/mcp, and names files by their path in the show, never the server's.
  • Quotas and money apply as in Studio because it is the same daemon: renders and takes are not tools, a tool that calls a model uses the person's own key and cap through their grant, and storage counts against the show's owner.

Authorization

Per the MCP authorization spec (2025-06-18 and 2025-11-25): OAuth 2.1 with PKCE, the authorization server's metadata (RFC 8414), the resource's metadata (RFC 9728), client ID metadata documents (draft-ietf-oauth-client-id-metadata-document-02), dynamic client registration (RFC 7591), resource indicators (RFC 8707) and revocation (RFC 7009).

A client named by its metadata document (core/src/host/client-metadata.ts). The metadata says client_id_metadata_document_supported, so a client may give an https address for its client_id: claude.ai's "Use Claude's published identity" (https://claude.ai/oauth/mcp-oauth-client-metadata) and Claude Code (https://claude.ai/oauth/claude-code-client-metadata, which it prefers to registering once offered). The authorize endpoint reads the document only for someone signed in (signed out, it sends them to sign in first, having read nothing), and refuses an address that is not https, has no path, a user, a query, a fragment or a ./.. segment. The read is careful: the name looked up once, refused when any of its addresses is special-use (RFC 6890: private, loopback, link-local, CGNAT, Fly's 6PN among the ULAs, multicast, documentation…), the connection made to the address checked (no DNS rebinding), no redirect followed, only a 200, 5 KB and 5 s at most, 30 reads an hour a person. The document must name exactly its address as client_id, hold no secret, sign in as a public client (none), and list redirect URIs that are https or loopback; other grant types of its own are its business. It is kept, as a registered client is, for its Cache-Control's max-age (a day at most, none for no-store) and read again after, and the redirect URI asked for must be one it lists (a loopback one on any port). The question names it by its own name and its address's host, "Claude (claude.ai)", as Anthropic's docs and the draft ask, since a document says what it likes of itself; an answer that goes to a loopback address says it is "a program on this computer". The token endpoint takes it by its address and its PKCE verifier, no secret, like any public client.

Where What
POST /app/mcp without a token 401, WWW-Authenticate: Bearer resource_metadata="https://reelwright.diy/.well-known/oauth-protected-resource/app/mcp", scope="shows" (with error="invalid_token" for a bad one)
GET /.well-known/oauth-protected-resource/app/mcp {"resource":"https://reelwright.diy/app/mcp","authorization_servers":["https://reelwright.diy/app"],"scopes_supported":["shows"],"bearer_methods_supported":["header"]}
GET /.well-known/oauth-authorization-server/app the issuer https://reelwright.diy/app and its endpoints; response_types_supported: ["code"], code_challenge_methods_supported: ["S256"], grant_types_supported: ["authorization_code","refresh_token"], token_endpoint_auth_methods_supported: ["none","client_secret_post","client_secret_basic"]
POST /app/oauth/register a client registers itself: 201 with its client_id (and a client_secret when it asked for a secret method). Redirect URIs: https, or http to the loopback (localhost, 127.0.0.1, [::1], where Claude Code listens), at most ten; 30 registrations an hour from one address; a client nobody connected with is forgotten after a week
GET /app/oauth/authorize an unknown client or a redirect it did not register is answered on a page, never redirected; then response_type=code, PKCE S256 (required), scope shows, resource this endpoint. Signed out: to /app/login?next=… (the service's own emailed code) and back. Signed in: "Allow Claude to work on your shows?", naming the client as it named itself, the person's address, what it may do, and where it goes back to. A loopback redirect matches on any port (RFC 8252)
POST /app/oauth/authorize the person's answer, from the service's own page only (another site's Origin or Sec-Fetch-Site is refused), for a question asked of them in the last ten minutes. Allow: a code (32 random bytes, five minutes, once) to the client's address with state and iss; Cancel: error=access_denied. The page's CSP lets its form lead to that one address and nowhere else
POST /app/oauth/token authorization_code (once, from the client it was issued to, the same redirect_uri, the PKCE verifier whose S256 is the challenge; a wrong verifier spends the code) or refresh_token (rotates: a new refresh token each time, the old access tokens gone; a spent refresh token used again ends the whole connection). Access tokens rwa_… last an hour; refresh tokens rwr_… thirty days unused
POST /app/oauth/revoke ends the connection an access or refresh token belongs to; always 200

Everything secret is 32 random bytes kept only as its SHA-256 (oauth_codes, mcp_access, mcp_connections, oauth_clients in reelwright.db, opened by McpAccess alongside the store, each person's rows going with their account). Why a personal token ended is kept by its hash for a year (mcp_ended: disconnected, rotated or expired), so the plan bridge can be told (docs/design/m12-plan-bridge.md). A connection rotated out by newer ones ends its sessions and bridge at once, as one disconnected does. A bearer token is accepted by /mcp and by nothing else: every page and /api route signs in by the session cookie (or, for the installed Studio, its rwd_ device token, which /mcp refuses in turn), and /admin needs a signed-in owner's browser. A disabled account's tokens open nothing; during maintenance /mcp answers 503 for everyone but the owners.

Personal tokens for a setup with no browser (rwp_…, a year, revocable): made in Studio (POST /app/api/mcp/tokens) and shown once, then used as a header: claude mcp add --transport http reelwright https://reelwright.diy/app/mcp --header "Authorization: Bearer rwp_…".

A person's connections are each client they allowed and each token they made: Studio's Connect Claude lists them with when each was last used and disconnects any; the admin console's page of a person lists them with Disconnect, and Sign out everywhere ends them all. Each is audited: mcp.allowed, mcp.connected, mcp.token_made, mcp.revoked.

The two metadata documents live at the root of the origin, where RFC 8414 and RFC 9728 put them for an issuer and a resource with a path, so site/vercel.json passes those two paths on to the service as it passes /app. The service answers them under /app too.

A browser-based MCP client (the MCP Inspector, an app's own page) calls the endpoint, the two metadata documents, registration, tokens and revocation from another origin (BROWSER_ROUTES in core/src/host/mcp.ts). Each answers a CORS preflight (OPTIONS) before anything signs in: 204 with the methods it takes, the headers a client sends (Authorization, Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-ID, and any Mcp-Param-* asked for) and a day's Max-Age. Every answer carries Access-Control-Allow-Origin: * and exposes WWW-Authenticate, Mcp-Session-Id and MCP-Protocol-Version, so a page reads the 401's challenge and its session's id. None of these routes takes a cookie and none allows credentials, so * lets a page do only what the token it holds already could. The authorize page is a navigation, and Studio's API and the person's answer on that page keep refusing other sites.

Studio's side

GET /app/api/mcp says what Connect Claude shows on the web: the endpoint's address, the exact claude mcp add command, and the person's connections ({id, kind: "app"|"token", label, createdAt, lastUsedAt, expiresAt}). Writes (POST /app/api/mcp/tokens {name}, DELETE /app/api/mcp/connections/<id>) are Studio's own, as the key routes are: JSON, X-Reelwright-Studio: 1, no other site's Origin or Sec-Fetch-Site.

Tests and proof

core/test/host-mcp.test.ts runs the whole flow against real reelwright serve daemons (child processes): the SDK's own discovery from the 401, registration, authorize signed out and in, PKCE, tokens hashed at rest, refresh rotation and replay, revocation, the tools listed from the daemon, a proposal heard by the Studio socket while the file stays unchanged, approval, a viewer refused, a stranger kept out, another person's token on a session refused, /admin and /api refusing the token, and Studio's API and the console. core/test/session-daemon.test.ts covers tools.list and tools.call for an assistant. tools/web/mcp-check.ts proves it on a real service with the SDK's own OAuth client, as Claude Code drives it.

Not yet

  • No streaming of a long tool's progress. The daemon's tools.call answers once; a long call (directing a whole episode) is one wait. Vercel's rewrite may cut a request that runs for minutes; the client can call again, and what was proposed is in Studio either way.
  • Sessions are in memory, so a deploy ends them; clients start new ones on their own.
  • No per-tool scopes. One scope, shows, and the person's role in each show is the limit, as in Studio.