jevstrudel.git / worker / src / hosted-mcp.ts

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';
52export const MCP_PATH = '/jev/mcp';
53export { SCOPES, TOOLS, type Scope } from './hosted-tools';
54export type McpProps = { userId: string; app?: string };

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.
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));

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}