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) POSTsinitialize, gets anMcp-Session-Id, and sends every later request with it, each answered as an event stream that says: still workingevery 25 s through Vercel's rewrite. AGETwith 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);DELETEends a session. - 2026-07-28 (MCP SDK 2.x,
@modelcontextprotocol/server'screateMcpHandlerwithlegacy: "reject"): a request that says that version in its_metaand headers stands alone. Claude Code sendsserver/discoverfirst (2.1.296 does) and, answered, speaks it from then on: noinitialize, no session. The SDK'sisLegacyRequestsorts 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 nochoose_show: every show tool takes ashow(an id fromlist_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_showsmarks that onebyDefault. 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 itsnotify.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_showandlist_comments; a reviewer, withadd_commentandreply_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 senttools/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_commentandreply_comment, ascomments.list,comments.createandcomments.reply. Every other tool is the daemon's own, listed by the session methodtools.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" indocs/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 fromchat.focusin 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 acontext(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) andnext(the Show page's next steps, read from the daemon:episodes.status,renders.list,memory.list,graph.status), each carrying the place; and the resourcereelwright://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 sayssession.hellowith the daemon's token andas(the person, their name, their role in the show now), exactly as the host's/wspipe 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 withsession.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.callwithauthor: "showrunner": the daemon runs it like the showrunner's chat does, so its change sets stay proposed (Studio's owntools.call, the timeline's edits, is still applied as the person's edit). The change carriesby, 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 matchingchanges.*methods, carryanthropic/requiresUserInteractionas 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_contextandlist_showscarryanthropic/alwaysLoad, so Claude Code keeps them in view and finds the rest with its tool search (coreToolsincore/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.callanswers 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.