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}