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

The tools both MCPs offer (shared-tools.json): the dev hub's (mcp.ts) and the hosted one's (hosted-mcp.ts). Each MCP says how to reach the tab a call picked and who is voting; the rest is here, once.

Tab tools become a command to the tab (tab-hub-core.ts's TabCommand), which answers from what its panel shows (website/src/jev/tabTools.mjs); the reply is rebuilt from typed, bounded fields before it is read, in the dev hub as in the hosted one. api_reference and vote need no tab.

9import { catalog } from './catalog';
10import type { Env } from './env';
11import { whyNotChanges } from './mcp-settings.mjs';
12import { answerReference, reference } from './reference';
13import shared from './shared-tools.json';
14import { MAX_SOUNDS, rebuildReply, type SharedCommand, type Sound, type TabReply } from './tab-hub-core';
15import { castVote, VOTE_LIMIT } from './votes';
16import { d1Votes } from './votes-store';
18export type Scope = 'play' | 'publish';
19export type SharedTool = { name: string; scope: Scope | null; description: string; inputSchema: Record<string, unknown> };
20export const SHARED_TOOLS = shared as SharedTool[];
21export const SHARED_NAMES = new Set(SHARED_TOOLS.map((t) => t.name));
22
23export const CATEGORIES = ['samples', 'drum-machines', 'synths', 'wavetables', 'user', 'imported'];
24const DESCRIPTION_MAX = 280;
25const FILTER_MAX = 64;
26
27export type SharedContext = {
28  env: Env;
29  // sends a command to the tab this call is for (picked by session_id)
30  tab: (command: SharedCommand) => Promise<object>;
31  // the VOTE_LIMIT key: the account, or 'local' in dev
32  voter: string;
33  // the hosted MCP's AI plays in the sandbox, where imported sounds are not
34  hosted: boolean;
35};
36
37const pct = (p: number | null) => (p === null ? '' : `${Math.round(p * 100)}%`);
38const category = (c: unknown) => {
39  if (c === undefined || c === null || c === '') return undefined;
40  if (typeof c !== 'string' || !CATEGORIES.includes(c)) throw new Error(`category is one of ${CATEGORIES.join(', ')}`);
41  return c;
42};
43const filter = (f: unknown) => {
44  if (f === undefined || f === null) return undefined;
45  if (typeof f !== 'string' || f.length > FILTER_MAX) throw new Error(`filter is text of at most ${FILTER_MAX} characters`);
46  return f.trim() || undefined;
47};
48
49async function ask(ctx: SharedContext, command: SharedCommand): Promise<TabReply> {
50  const reply = rebuildReply((await ctx.tab(command)) as Record<string, unknown>);
51  if (reply.error) throw new Error(reply.error);
52  return reply;
53}
54
55const soundLine = (s: Sound) =>
56  `${s.name} (${s.category}${s.type && s.type !== 'sample' ? ` ${s.type}` : ''}${s.variants > 1 ? `, ${s.variants} variants` : ''}): ${s.use}`;
57
58export async function runShared(ctx: SharedContext, name: string, args: Record<string, unknown>): Promise<string> {
59  switch (name) {
60    case 'list_sounds': {
61      const cat = category(args.category);
62      const f = filter(args.filter);
63      const limit = args.limit === undefined ? 100 : Number(args.limit);
64      if (!Number.isInteger(limit) || limit < 1 || limit > MAX_SOUNDS) throw new Error(`limit is 1 to ${MAX_SOUNDS}`);
65      const offset = args.cursor === undefined ? 0 : Number(args.cursor);
66      if (!Number.isInteger(offset) || offset < 0) throw new Error('cursor is the one a page of sounds gave');
67      const { sounds } = await ask(ctx, { type: 'list-sounds', category: cat, filter: f, offset, limit });
68      if (!sounds) throw new Error('the tab did not list its sounds');
69      const what = `${cat ?? 'all'} sound${sounds.total === 1 ? '' : 's'}${f ? ` matching "${f}"` : ''}`;
70      if (!sounds.total) return `no ${what} in this tab`;
71      const end = offset + sounds.items.length;
72      const lines = [`${sounds.total} ${what}; ${offset + 1} to ${end}${end < sounds.total ? ` (next: cursor "${end}")` : ''}:`];
73      if (ctx.hosted && (cat === 'user' || cat === 'imported')) {
74        lines.push("(imported sounds are this browser's own: the page's code plays them, the sandbox your code plays in cannot)");
75      }
76      lines.push(...sounds.items.map(soundLine));
77      return lines.join('\n');
78    }
79    case 'ask_jev_sound': {
80      const description = typeof args.description === 'string' ? args.description.trim() : '';
81      if (!description || description.length > DESCRIPTION_MAX) throw new Error(`description is the sound wanted, 1 to ${DESCRIPTION_MAX} characters`);
82      const { pick } = await ask(ctx, { type: 'ask-sound', description, category: category(args.category), filter: filter(args.filter) });
83      if (!pick?.picks.length) throw new Error('Jev picked nothing');
84      const [best, ...rest] = pick.picks;
85      const lines = [`Jev picks ${soundLine(best)}${best.p === null ? '' : ` (${pct(best.p)})`}`];
86      if (rest.length) lines.push('then:', ...rest.map((s) => `  ${soundLine(s)}${s.p === null ? '' : ` (${pct(s.p)})`}`));
87      const how = pick.calls ? `${pick.calls} Jev call${pick.calls > 1 ? 's' : ''}` : 'no Jev call';
88      lines.push(
89        pick.scope === 'finalists'
90          ? `Asked among ${pick.candidates} sounds in ${pick.banks} banks, ${how}: the percentages are among the ${pick.banks} banks' finalists.`
91          : `Asked among ${pick.candidates} sounds, ${how}${pick.confidence === null ? '' : `; confidence ${pct(pick.confidence)}`}.`,
92      );
93      return lines.join('\n');
94    }
95    case 'api_reference':
96      return answerReference(await reference(ctx.env), args);
97    case 'get_settings': {
98      const { settings, themes } = await ask(ctx, { type: 'get-settings' });
99      return JSON.stringify({ settings: settings ?? {}, themes: themes ?? [] });
100    }
101    case 'set_settings': {
102      const why = whyNotChanges(args.changes);
103      if (why) throw new Error(why);
104      const { settings } = await ask(ctx, { type: 'set-settings', changes: args.changes as Record<string, string | number | boolean> });
105      return `changed ${Object.keys(args.changes as object).join(', ')}; now: ${JSON.stringify(settings ?? {})}`;
106    }
107    case 'play_song': {
108      if (typeof args.id !== 'string' || args.id.length > 96) throw new Error('id is "<theme>/<song>" or "listener:<id>", from list_songs');
109      const { song } = await ask(ctx, { type: 'play-song', id: args.id });
110      if (!song) throw new Error('the tab did not say what it played');
111      return `playing ${song.title} (${song.id}) ${song.where === 'sandbox' ? 'in its sandbox' : 'in the page'}, from the top`;
112    }
113    case 'ask_jev_song': {
114      const mood = typeof args.mood === 'string' ? args.mood.trim() : '';
115      if (!mood || mood.length > DESCRIPTION_MAX) throw new Error(`mood is 1 to ${DESCRIPTION_MAX} characters`);
116      if (args.play !== undefined && typeof args.play !== 'boolean') throw new Error('play is true or false');
117      const { song } = await ask(ctx, { type: 'ask-song', mood, play: args.play === true });
118      if (!song) throw new Error('Jev picked nothing');
119      const odds = song.p === null ? '' : ` (${pct(song.p)})`;
120      return `Jev picks ${song.title} (${song.id})${odds}${song.where ? '; playing it in the page, from the top' : ''}`;
121    }
122    case 'react': {
123      if (args.kind !== 'fire' && args.kind !== 'sleep') throw new Error('kind is fire or sleep');
124      const { reaction } = await ask(ctx, { type: 'react', kind: args.kind });
125      if (!reaction) throw new Error('the tab did not react');
126      return `${reaction.kind === 'fire' ? '🔥' : '😴'} for section ${reaction.section}: Jev hears it before its next choice${reaction.counted ? ", and it counts in the song's tally" : ' (not counted for the song: it is edited, or not a site song)'}`;
127    }
128    case 'vote': {
129      const { a, b, pick } = args;
130      const { success } = await ctx.env.VOTE_LIMIT.limit({ key: ctx.voter });
131      if (!success) throw new Error(`too many votes; try again in ${VOTE_LIMIT.period} s`);
132      const cast = await castVote(ctx.env, d1Votes(ctx.env.DB), { a, b, pick });
133      if (!cast.ok) throw new Error(cast.why);
134      let jev = '';
135      try {
136        const art = new Map((await catalog(ctx.env)).map((s) => [s.id, s.art]));
137        const [pa, pb] = [art.get(a as string), art.get(b as string)];
138        if (typeof pa === 'number' && typeof pb === 'number' && pa !== pb) {
139          const jevPick = pa > pb ? a : b;
140          jev = `; Jev scores ${a} ${pct(pa)} and ${b} ${pct(pb)}, so you ${jevPick === pick ? 'agree' : 'disagree'} with Jev`;
141        } else jev = '; Jev has no preference between them';
142      } catch {
143        // the counts do not depend on it
144      }
145      return `counted: ${pick} over ${pick === a ? b : a}${jev}`;
146    }
147    default:
148      throw new Error(`unknown tool ${name}`);
149  }
150}