The hosted MCP for everyone: POST /jev/mcp, reached with an OAuth
access token (oauth.ts) that acts for one signed-in listener. Anyone can
connect their own MCP client (Claude Code, Claude.ai's custom connectors,
anything that speaks MCP's authorization) and make songs with their own
AI: play code in their own open tabs, read the site's songs and
listeners', and publish and revise songs of their own through the same
pipeline the page uses (Jev screens, then public).
What a token can reach is the user its grant names, and nothing else:
- Tabs: that user's TabHub (
TAB_HUB.idFromName(userId), tab-hub.ts), which only that user's own signed-in pages can join. Another visitor's tab is not in any hub this token can name. The code it plays is not the page's: the tab plays it in the sandbox (website/src/jev/sandbox.mjs), in an opaque origin, never in the page, so a prompt-injected agent's code cannot act as the user in the page. The tab's replies are rebuilt from bounded fields (tab-hub-core.ts). - Writing: content.ts's writeSong and commentAs, as that user, with their rate, their cap on pending items and Jev's screening charged to their budget.
- The tools the dev hub offers too (shared-tools.json, shared-mcp.ts): the tab's sounds, settings and console, a song played by id, Jev's picks asked from the page, a reaction, the reference, a vote. None evaluates what the AI sent in the page: a site song played by id is looked up in the page's own build, as a click on its card; everything else the tab answers is data (website/src/jev/tabTools.mjs).
Scopes: play for the tab tools, publish for writing and reading one's
own content; reading public songs and the reference, and an anonymous
vote, need only a valid token (hosted-tools.ts says why). A token
without the scope a tool needs gets the MCP scope challenge (403), so the
client can ask the user for it.
Limits: MCP_LIMIT (per user, every request), a request body of at most MAX_REQUEST_BYTES, code to play of at most MAX_PLAY_CODE_BYTES, and a tab's replies as bounded in tab-hub-core.ts. Jev's calls from the played code are charged to the user's daily budget by their tab (sandbox.mjs).
38import { insufficientScope, type OAuthResourceContext } from '@cloudflare/workers-oauth-provider'; 39import { d1Accounts, type User } from './accounts-store'; 40import { catalog, SITE_SONG, siteSong } from './catalog'; 41import { commentAs, MAX_CODE_BYTES, MAX_SPEC_BYTES, writeSong, LISTENER_SONG } from './content'; 42import { d1Content, type ContentStore } from './content-store'; 43import { sweep } from './content-jobs'; 44import type { Env } from './env'; 45import { TOOLS } from './hosted-tools'; 46import { serveRpc } from './mcp-rpc'; 47import { JEV_PRIMER } from './primer'; 48import { runShared, SHARED_NAMES } from './shared-mcp'; 49import { MAX_PLAY_CODE_BYTES, pickTab, type SharedCommand, type TabReply } from './tab-hub-core'; 50import config from '../wrangler.json';
Every MCP request counts, per user: an agent writing a song plays, reads its logs and plays again, a few calls a minute, and at most about one a second in a burst. 120 a minute leaves that room twice over and bounds what a runaway client can make the tab do.
60export const MCP_LIMIT = config.ratelimits.find((r) => r.name === 'MCP_LIMIT')!.simple;
A publish carries a song's code and spec, JSON-escaped: content.ts's own request cap.
62export const MAX_REQUEST_BYTES = 4 * (MAX_CODE_BYTES + MAX_SPEC_BYTES);
The hosted side's own steps, then how to write a song with Jev (primer.ts).
65const INSTRUCTIONS = `jevstrudel is a Strudel REPL where songs ask Jev to arrange them live with jev(). Open the site in a browser, signed in to the account you connected, click the page once, then play code into it with play_code and read get_logs. Publish with publish_song when it is good.
The hub of the user this token acts for: the only tabs it can reach.
73const hubOf = (env: Env, userId: string) => env.TAB_HUB.get(env.TAB_HUB.idFromName(userId));
The console tab keeps no times, so its lines have none.
76function logsText(reply: TabReply, filter: unknown, untimed = false): string { 77 if (reply.error) throw new Error(reply.error); 78 const f = typeof filter === 'string' ? filter : ''; 79 const lines = (reply.logs ?? []).filter((l) => !f || l.message.includes(f)); 80 if (!lines.length) return f ? `nothing matching "${f}"` : 'nothing logged'; 81 return lines 82 .map((l) => `${untimed ? '' : `${l.at.toFixed(1)}s `}${l.kind ? `[${l.kind}] ` : ''}${l.message}${l.count > 1 ? ` (×${l.count})` : ''}`) 83 .join('\n'); 84}
86const CODE_WORDS: Record<string, string> = { 87 none: 'nothing loaded', 88 own: "the page's own code (a site song, or what the listener wrote)", 89 ai: "an AI's code (sandboxed)", 90 listener: "a listener's song (sandboxed)", 91 link: 'code from a link (sandboxed)', 92}; 93 94export async function runTool( 95 env: Env, 96 ctx: Pick<ExecutionContext, 'waitUntil'>, 97 user: User, 98 appName: string, 99 name: string, 100 args: Record<string, unknown>, 101 store: ContentStore = d1Content(env.DB), 102): Promise<string> { 103 switch (name) { 104 case 'get_status': { 105 const hub = hubOf(env, user.id); 106 const open = await hub.sessions(); 107 if (!open.length) { 108 return `You (${user.displayName}) have no jevstrudel tab open. Open the site signed in to this account and click the page once.`; 109 } 110 const rows = await Promise.all( 111 open.map(async (s) => { 112 try { 113 const { status } = await hub.command(s, { type: 'status' }); 114 if (!status) return `${s}: open`; 115 const playing = status.playing ? `playing${status.cycle === null ? '' : `, cycle ${status.cycle.toFixed(1)}`}` : 'stopped'; 116 return `${s}: ${playing}; ${CODE_WORDS[status.code] ?? status.code}`; 117 } catch (e) { 118 return `${s}: open, not answering (${(e as Error).message})`; 119 } 120 }), 121 ); 122 return `Your open tabs (${user.displayName}):\n${rows.join('\n')}`; 123 } 124 case 'play_code': 125 case 'stop_play': 126 case 'get_logs': 127 case 'get_currently_playing_code': { 128 const hub = hubOf(env, user.id); 129 const tab = pickTab(await hub.sessions(), args.session_id); 130 if (name === 'play_code') { 131 const code = args.code; 132 if (typeof code !== 'string' || !code.trim()) throw new Error('code is empty'); 133 if (new TextEncoder().encode(code).length > MAX_PLAY_CODE_BYTES) throw new Error(`code is at most ${MAX_PLAY_CODE_BYTES / 1024} KiB`); 134 const reply = await hub.command(tab, { type: 'play', code, app: appName }); 135 if (reply.error) throw new Error(`in tab ${tab}: ${reply.error}`); 136 return `playing in ${tab}, in its sandbox`; 137 } 138 if (name === 'stop_play') { 139 const reply = await hub.command(tab, { type: 'stop' }); 140 if (reply.error) throw new Error(reply.error); 141 return `stopped ${tab}`; 142 } 143 if (name === 'get_logs') { 144 const source = args.source ?? 'sandbox'; 145 if (source !== 'sandbox' && source !== 'console') throw new Error('source is sandbox or console'); 146 return logsText(await hub.command(tab, { type: source === 'console' ? 'get-console' : 'get-logs' }), args.filter, source === 'console'); 147 } 148 const reply = await hub.command(tab, { type: 'get-code' }); 149 if (reply.error) throw new Error(reply.error); 150 return reply.code ?? ''; 151 } 152 case 'list_songs': { 153 const [site, listeners] = await Promise.all([catalog(env), store.publicSongs()]); 154 return json({ 155 site: site.map((s) => withoutEmpty({ id: s.id, title: s.title, theme: s.theme, description: s.description || undefined, art: s.art ?? undefined, seconds: s.seconds ?? undefined })), 156 listeners: listeners.map((s) => 157 withoutEmpty({ id: `listener:${s.id}`, title: s.title, author: s.author, description: s.description || undefined, art: s.art?.art ?? undefined, rev: s.rev }), 158 ), 159 }); 160 } 161 case 'get_song': { 162 const id = typeof args.id === 'string' ? args.id : ''; 163 const listener = LISTENER_SONG.exec(id); 164 if (listener) { 165 const s = await store.publicSong(listener[1]); 166 if (!s) throw new Error(`no public song ${id}`); 167 return json({ id, title: s.title, author: s.author, rev: s.rev, description: s.description, art: s.art?.art ?? null, spec: s.spec, code: s.code }); 168 } 169 if (!SITE_SONG.test(id)) throw new Error('id is "<theme>/<song>" or "listener:<id>", from list_songs'); 170 const s = await siteSong(env, id); 171 if (!s) throw new Error(`the site has no song ${id}`); 172 return json(s); 173 } 174 case 'publish_song': 175 case 'revise_song': { 176 const { id, ...fields } = args; 177 let songId: string | null = null; 178 if (name === 'revise_song') { 179 if (typeof id !== 'string') throw new Error('id is the song to revise, "listener:<id>"'); 180 songId = LISTENER_SONG.exec(id)?.[1] ?? id; 181 } else if (id !== undefined) { 182 throw new Error('publish_song makes a new song; to change one of yours, use revise_song'); 183 } 184 const written = await writeSong(env, ctx, store, user, songId, fields); 185 if (written.status >= 400) throw new Error(String(written.body.error)); 186 const b = written.body as { id: string; rev: number; status: string }; 187 return json({ ...written.body, id: `listener:${b.id}`, url: `/?listener=${b.id}` }); 188 } 189 case 'comment': { 190 const written = await commentAs(env, ctx, store, user, { song: args.song, body: args.body }); 191 if (written.status >= 400) throw new Error(String(written.body.error)); 192 return json(written.body); 193 } 194 case 'my_content': { 195 ctx.waitUntil(sweep(env, store, { userId: user.id, limit: 3 }).then(() => undefined)); 196 return json(await store.mine(user.id)); 197 } 198 default: { 199 if (!SHARED_NAMES.has(name)) throw new Error(`unknown tool ${name}`); 200 const hub = hubOf(env, user.id); 201 // a tab is picked only when the tool reaches one 202 const tab = async (command: SharedCommand) => hub.command(pickTab(await hub.sessions(), args.session_id), command); 203 return runShared({ env, tab, voter: `user:${user.id}`, hosted: true }, name, args); 204 } 205 } 206}
The protected handler: ctx.props is what oauth.ts stored at consent ({ userId }), ctx.auth the verified token (its scopes and client).
210export async function hostedMcp(request: Request, env: Env, ctx: OAuthResourceContext<McpProps>): Promise<Response> { 211 const { userId } = ctx.props; 212 const { success } = await env.MCP_LIMIT.limit({ key: `user:${userId}` }); 213 if (!success) { 214 return new Response('too many MCP requests; try again in a minute', { 215 status: 429, 216 headers: { 'Retry-After': String(MCP_LIMIT.period), 'Cache-Control': 'no-store' }, 217 }); 218 } 219 const user = await d1Accounts(env.DB).user(userId); 220 // the account is gone: its grants are no use 221 if (!user) return new Response('this account no longer exists', { status: 401, headers: { 'Cache-Control': 'no-store' } }); 222 const client = ctx.props.app ?? "an MCP client"; 223 return serveRpc( 224 request, 225 { 226 name: 'jevstrudel', 227 version: '1.0.0', 228 instructions: INSTRUCTIONS, 229 tools: TOOLS.map(({ scope: _scope, ...tool }) => tool), 230 call: async (name, args) => { 231 const scope = TOOLS.find((t) => t.name === name)?.scope; 232 if (scope && !ctx.auth.scope.includes(scope)) throw insufficientScope(ctx.auth, [scope], `${name} needs the ${scope} scope`); 233 return runTool(env, ctx, user, client, name, args); 234 }, 235 }, 236 { maxBytes: MAX_REQUEST_BYTES }, 237 ); 238}