jevstrudel.git / worker / src / hosted-mcp.ts
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}