1// The hosted MCP for everyone: `POST /jev/mcp`, reached with an OAuth 2// access token (oauth.ts) that acts for one signed-in listener. Anyone can 3// connect their own MCP client (Claude Code, Claude.ai's custom connectors, 4// anything that speaks MCP's authorization) and make songs with their own 5// AI: play code in their own open tabs, read the site's songs and 6// listeners', and publish and revise songs of their own through the same 7// pipeline the page uses (Jev screens, then public). 8// 9// What a token can reach is the user its grant names, and nothing else: 10// 11// - Tabs: that user's TabHub (`TAB_HUB.idFromName(userId)`, tab-hub.ts), 12// which only that user's own signed-in pages can join. Another visitor's 13// tab is not in any hub this token can name. The code it plays is not the 14// page's: the tab plays it in the sandbox (website/src/jev/sandbox.mjs), 15// in an opaque origin, never in the page, so a prompt-injected agent's 16// code cannot act as the user in the page. The tab's replies are rebuilt 17// from bounded fields (tab-hub-core.ts). 18// - Writing: content.ts's writeSong and commentAs, as that user, with their 19// rate, their cap on pending items and Jev's screening charged to their 20// budget. 21// - The tools the dev hub offers too (shared-tools.json, shared-mcp.ts): 22// the tab's sounds, settings and console, a song played by id, Jev's 23// picks asked from the page, a reaction, the reference, a vote. None 24// evaluates what the AI sent in the page: a site song played by id is 25// looked up in the page's own build, as a click on its card; everything 26// else the tab answers is data (website/src/jev/tabTools.mjs). 27// 28// Scopes: `play` for the tab tools, `publish` for writing and reading one's 29// own content; reading public songs and the reference, and an anonymous 30// vote, need only a valid token (hosted-tools.ts says why). A token 31// without the scope a tool needs gets the MCP scope challenge (403), so the 32// client can ask the user for it. 33// 34// Limits: MCP_LIMIT (per user, every request), a request body of at most 35// MAX_REQUEST_BYTES, code to play of at most MAX_PLAY_CODE_BYTES, and a 36// tab's replies as bounded in tab-hub-core.ts. Jev's calls from the played 37// 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'; 51 52export const MCP_PATH = '/jev/mcp'; 53export { SCOPES, TOOLS, type Scope } from './hosted-tools'; 54export type McpProps = { userId: string; app?: string }; 55 56// Every MCP request counts, per user: an agent writing a song plays, reads 57// its logs and plays again, a few calls a minute, and at most about one a 58// second in a burst. 120 a minute leaves that room twice over and bounds 59// what a runaway client can make the tab do. 60export const MCP_LIMIT = config.ratelimits.find((r) => r.name === 'MCP_LIMIT')!.simple; 61// 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); 63 64// 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. 66 67${JEV_PRIMER}`; 68 69const json = (x: unknown) => JSON.stringify(x, null, 2); 70const withoutEmpty = (x: Record<string, unknown>) => Object.fromEntries(Object.entries(x).filter(([, v]) => v !== undefined)); 71 72// 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)); 74 75// 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} 85 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} 207 208// The protected handler: ctx.props is what oauth.ts stored at consent 209// ({ 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}