1// The tools both MCPs share (shared-mcp.ts), apart from either MCP: what 2// each checks before a tab is asked, what it asks the tab, how a tab's reply 3// is bounded before it is read, and the two that need no tab (the reference 4// and a vote). hosted-mcp.test.ts holds their scopes, mcp.test.ts the dev hub. 5import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 6import { testD1 } from '../test/d1'; 7import { SETTINGS } from './mcp-settings.mjs'; 8import { forgetReference } from './reference'; 9import { runShared, SHARED_TOOLS, type SharedContext } from './shared-mcp'; 10import type { SharedCommand } from './tab-hub-core'; 11import { forgetSongs } from './votes'; 12 13const FNS = [ 14 { name: 'lpf', synonyms: ['cutoff', 'ctf', 'lp'], tags: ['filter'], description: 'Applies the cutoff frequency of the low-pass filter. More text.', params: [{ name: 'frequency', type: 'number | Pattern', description: 'audible between 0 and 20000' }], examples: ['s("bd*4").lpf(800)'] }, 15 { name: 'hpf', synonyms: ['hp'], tags: ['filter'], description: 'Applies the cutoff frequency of the high-pass filter.', params: [], examples: [] }, 16 { name: 'fast', synonyms: [], tags: ['temporal'], description: 'Speeds up a pattern.', params: [{ name: 'factor', type: 'number', description: '' }], examples: ['s("bd sd").fast(2)'] }, 17]; 18 19let db: D1Database; 20let asked: SharedCommand[]; 21let answer: (c: SharedCommand) => Record<string, unknown>; 22let voteKeys: string[]; 23let voteLimited: boolean; 24 25const env = () => 26 ({ 27 DB: db, 28 SONGS_URL: 'http://songs.invalid/jev/songs.json', 29 VOTE_LIMIT: { limit: async ({ key }: { key: string }) => (voteKeys.push(key), { success: !voteLimited }) }, 30 }) as never; 31const ctx = (over: Partial<SharedContext> = {}): SharedContext => ({ 32 env: env(), 33 voter: 'user:u1', 34 hosted: true, 35 tab: async (c) => { 36 asked.push(c); 37 return answer(c); 38 }, 39 ...over, 40}); 41const run = (name: string, args: Record<string, unknown> = {}, over?: Partial<SharedContext>) => runShared(ctx(over), name, args); 42 43beforeEach(() => { 44 db = testD1(); 45 asked = []; 46 voteKeys = []; 47 voteLimited = false; 48 answer = () => ({ ok: true }); 49 forgetSongs(); 50 forgetReference(); 51 vi.spyOn(console, 'error').mockImplementation(() => {}); 52 vi.stubGlobal('fetch', async (input: RequestInfo | URL) => { 53 const url = String(input instanceof Request ? input.url : input); 54 if (url === 'http://songs.invalid/jev/songs.json') return Response.json({ songs: ['jev/dial-up', 'jev/hey-listen', 'jev/no-score'] }); 55 if (url === 'http://songs.invalid/jev/catalog.json') { 56 return Response.json({ 57 songs: [ 58 { id: 'jev/dial-up', title: 'Dial-Up', theme: 'jev', description: '', art: 0.8, seconds: 1 }, 59 { id: 'jev/hey-listen', title: 'Hey Listen', theme: 'jev', description: '', art: 0.6, seconds: 1 }, 60 { id: 'jev/no-score', title: 'No Score', theme: 'jev', description: '', art: null, seconds: 1 }, 61 ], 62 }); 63 } 64 if (url === 'http://songs.invalid/jev/reference.json') return Response.json({ functions: FNS }); 65 throw new Error(`unexpected fetch ${url}`); 66 }); 67}); 68afterEach(() => { 69 vi.unstubAllGlobals(); 70 vi.restoreAllMocks(); 71}); 72 73describe('the definitions', () => { 74 it('are small, and each names its scope', () => { 75 for (const t of SHARED_TOOLS) { 76 expect(t.description.length).toBeLessThanOrEqual(240); 77 expect([null, 'play', 'publish']).toContain(t.scope); 78 } 79 // what needs no tab needs no scope, and every tab tool needs play 80 expect(SHARED_TOOLS.filter((t) => t.scope === null).map((t) => t.name).sort()).toEqual(['api_reference', 'vote']); 81 }); 82}); 83 84describe('list_sounds', () => { 85 it('asks the tab for one page and says where the next begins', async () => { 86 answer = () => ({ 87 sounds: { total: 3, items: [{ name: 'RolandTR808_bd', category: 'drum-machines', type: 'sample', variants: 3, use: 's("bd").bank("RolandTR808")' }] }, 88 }); 89 const text = await run('list_sounds', { category: 'drum-machines', filter: '808', limit: 1, cursor: '1' }); 90 expect(asked).toEqual([{ type: 'list-sounds', category: 'drum-machines', filter: '808', offset: 1, limit: 1 }]); 91 expect(text).toBe('3 drum-machines sounds matching "808"; 2 to 2 (next: cursor "2"):\nRolandTR808_bd (drum-machines, 3 variants): s("bd").bank("RolandTR808")'); 92 }); 93 94 it('refuses a category, limit or cursor it does not know, before asking the tab', async () => { 95 await expect(run('list_sounds', { category: 'drums' })).rejects.toThrow(/category is one of/); 96 await expect(run('list_sounds', { limit: 0 })).rejects.toThrow(/limit is 1 to 200/); 97 await expect(run('list_sounds', { limit: 201 })).rejects.toThrow(/limit/); 98 await expect(run('list_sounds', { cursor: '-3' })).rejects.toThrow(/cursor/); 99 await expect(run('list_sounds', { filter: 'x'.repeat(65) })).rejects.toThrow(/filter/); 100 expect(asked).toEqual([]); 101 }); 102 103 it("bounds what the tab sends: at most 200 sounds, each field cut", async () => { 104 answer = () => ({ 105 sounds: { total: 1e9, items: Array.from({ length: 500 }, (_, i) => ({ name: `s${i}${'x'.repeat(300)}`, category: 'samples', type: 'sample', variants: -4, use: 'y'.repeat(900), extra: 'dropped' })) }, 106 }); 107 const lines = (await run('list_sounds')).split('\n'); 108 expect(lines).toHaveLength(201); 109 expect(lines[1].length).toBeLessThan(128 + 300 + 40); 110 expect(lines[0]).toMatch(/^1000000 all sounds; 1 to 200/); 111 }); 112 113 it("says, hosted, that imported sounds do not reach the sandbox", async () => { 114 answer = () => ({ sounds: { total: 1, items: [{ name: 'swoop', category: 'imported', type: 'sample', variants: 3, use: 's("swoop")' }] } }); 115 expect(await run('list_sounds', { category: 'imported' })).toMatch(/sandbox your code plays in cannot/); 116 expect(await run('list_sounds', { category: 'imported' }, { hosted: false })).not.toMatch(/sandbox/); 117 }); 118 119 it("passes on the tab's error", async () => { 120 answer = () => ({ error: 'the editor is not ready' }); 121 await expect(run('list_sounds')).rejects.toThrow('the editor is not ready'); 122 }); 123}); 124 125describe('ask_jev_sound', () => { 126 it('asks the tab, and says when the odds are among finalists only', async () => { 127 answer = () => ({ 128 pick: { 129 picks: [ 130 { name: 'gm_electric_guitar_muted', category: 'synths', type: 'soundfont', variants: 2, use: 'note("c3 e3 g3").s("gm_electric_guitar_muted")', p: 0.62 }, 131 { name: 'gm_electric_guitar_clean', category: 'synths', type: 'soundfont', variants: 1, use: 'x', p: 0.2 }, 132 ], 133 confidence: 0.6, 134 candidates: 1432, 135 banks: 6, 136 calls: 3, 137 scope: 'finalists', 138 }, 139 }); 140 const text = await run('ask_jev_sound', { description: 'a muted funk guitar', category: 'synths' }); 141 expect(asked).toEqual([{ type: 'ask-sound', description: 'a muted funk guitar', category: 'synths', filter: undefined }]); 142 expect(text).toMatch(/^Jev picks gm_electric_guitar_muted \(synths soundfont, 2 variants\): note\("c3 e3 g3"\)\.s\("gm_electric_guitar_muted"\) \(62%\)/); 143 expect(text).toMatch(/1432 sounds in 6 banks, 3 Jev calls: the percentages are among the 6 banks' finalists/); 144 }); 145 146 it('refuses an empty or long description before any Jev call', async () => { 147 await expect(run('ask_jev_sound', {})).rejects.toThrow(/description/); 148 await expect(run('ask_jev_sound', { description: 'x'.repeat(281) })).rejects.toThrow(/description/); 149 expect(asked).toEqual([]); 150 }); 151 152 it('keeps a probability only when it is one', async () => { 153 answer = () => ({ pick: { picks: [{ name: 'bd', category: 'samples', type: 'sample', variants: 1, use: 's("bd")', p: 7 }], candidates: 2, calls: 1, scope: 'all' } }); 154 expect(await run('ask_jev_sound', { description: 'a kick' })).toBe('Jev picks bd (samples): s("bd")\nAsked among 2 sounds, 1 Jev call.'); 155 }); 156}); 157 158describe('api_reference', () => { 159 it('searches names first, then descriptions', async () => { 160 const text = await run('api_reference', { query: 'filter' }); 161 expect(text.split('\n')[0]).toBe('2 of 3 functions match; api_reference with name for one whole:'); 162 expect(text).toContain('lpf (cutoff, ctf, lp): Applies the cutoff frequency of the low-pass filter.'); 163 expect(await run('api_reference', { query: 'fast' })).toMatch(/^1 of 3.*\nfast: Speeds up a pattern\.$/); 164 expect(await run('api_reference', { query: 'nothing-like-it' })).toMatch(/nothing in the reference matches/); 165 }); 166 167 it('finds functions matching any of several words, a name before a mention', async () => { 168 // one query naming several functions finds them all ("jev noul choice score walk", 2026-09-27) 169 const text = await run('api_reference', { query: 'filter fast, nothing-like-it' }); 170 expect(text.split('\n')).toEqual([ 171 '3 of 3 functions match, best first; api_reference with name for one whole:', 172 'fast: Speeds up a pattern.', 173 'hpf (hp): Applies the cutoff frequency of the high-pass filter.', 174 'lpf (cutoff, ctf, lp): Applies the cutoff frequency of the low-pass filter.', 175 ]); 176 // a function matching more of the words comes first among equal weights 177 expect((await run('api_reference', { query: 'low-pass cutoff' })).split('\n')[1]).toMatch(/^lpf/); 178 }); 179 180 it('gives one function whole, by name or synonym, and suggests near names', async () => { 181 const lpf = await run('api_reference', { name: 'cutoff' }); 182 expect(lpf).toBe( 183 'lpf(frequency)\nsynonyms: cutoff, ctf, lp\ntags: filter\n\nApplies the cutoff frequency of the low-pass filter. More text.\n\nparameters:\n- frequency (number | Pattern): audible between 0 and 20000\n\nexamples:\ns("bd*4").lpf(800)', 184 ); 185 await expect(run('api_reference', { name: 'pf' })).rejects.toThrow(/no function pf; did you mean lpf, hpf/); 186 await expect(run('api_reference', {})).rejects.toThrow(/query .* or name/); 187 }); 188 189 it('needs no tab', async () => { 190 await run('api_reference', { query: 'fast' }); 191 expect(asked).toEqual([]); 192 }); 193}); 194 195describe('settings', () => { 196 it('reads only the settings an MCP may see, whatever the tab sends', async () => { 197 answer = () => ({ settings: { theme: 'dracula', fontSize: '22', isZen: 'true', prebakeScript: 'fetch("/x")', audioEngineTarget: 'osc' }, themes: ['dracula', 7] }); 198 expect(JSON.parse(await run('get_settings'))).toEqual({ settings: { theme: 'dracula', fontSize: 22, isZen: true }, themes: ['dracula'] }); 199 }); 200 201 it('never sends a change to a setting that runs or routes anything', async () => { 202 for (const changes of [ 203 { prebakeScript: 'x' }, 204 { audioEngineTarget: 'osc' }, 205 { isSyncEnabled: true }, 206 { patternAutoStart: true }, 207 { userPatterns: '{}' }, 208 { theme: 'dracula', latestCode: 's("x")' }, 209 { fontSize: 99 }, 210 { keybindings: 'nano' }, 211 { isZen: 'yes' }, 212 {}, 213 'theme=dracula', 214 ]) { 215 await expect(run('set_settings', { changes })).rejects.toThrow(); 216 } 217 expect(asked).toEqual([]); 218 expect(Object.keys(SETTINGS)).not.toContain('prebakeScript'); 219 }); 220 221 it('sends an allowed change and reports the result', async () => { 222 answer = () => ({ settings: { theme: 'dracula', fontSize: 22 } }); 223 expect(await run('set_settings', { changes: { theme: 'dracula', fontSize: 22 } })).toBe('changed theme, fontSize; now: {"theme":"dracula","fontSize":22}'); 224 expect(asked).toEqual([{ type: 'set-settings', changes: { theme: 'dracula', fontSize: 22 } }]); 225 }); 226}); 227 228describe('songs', () => { 229 it('plays a song by id, and says where', async () => { 230 answer = (c) => ({ song: { id: (c as { id: string }).id, title: 'Dial-Up', where: 'page' } }); 231 expect(await run('play_song', { id: 'jev/dial-up' })).toBe('playing Dial-Up (jev/dial-up) in the page, from the top'); 232 answer = () => ({ song: { id: 'listener:abc', title: 'Theirs', where: 'sandbox' } }); 233 expect(await run('play_song', { id: 'listener:abc' })).toMatch(/in its sandbox/); 234 await expect(run('play_song', { id: 7 })).rejects.toThrow(/id is/); 235 }); 236 237 it("asks the mood picker, and plays Jev's pick only when told", async () => { 238 answer = (c) => ({ song: { id: 'jev/dial-up', title: 'Dial-Up', p: 0.41, where: (c as { play: boolean }).play ? 'page' : null } }); 239 expect(await run('ask_jev_song', { mood: 'sleepy' })).toBe('Jev picks Dial-Up (jev/dial-up) (41%)'); 240 expect(await run('ask_jev_song', { mood: 'sleepy', play: true })).toBe('Jev picks Dial-Up (jev/dial-up) (41%); playing it in the page, from the top'); 241 expect(asked).toEqual([ 242 { type: 'ask-song', mood: 'sleepy', play: false }, 243 { type: 'ask-song', mood: 'sleepy', play: true }, 244 ]); 245 await expect(run('ask_jev_song', { mood: '' })).rejects.toThrow(/mood/); 246 await expect(run('ask_jev_song', { mood: 'x', play: 'yes' })).rejects.toThrow(/play/); 247 }); 248 249 it('reacts to the section playing', async () => { 250 answer = () => ({ reaction: { section: 'drop', kind: 'fire', counted: true } }); 251 expect(await run('react', { kind: 'fire' })).toBe("🔥 for section drop: Jev hears it before its next choice, and it counts in the song's tally"); 252 await expect(run('react', { kind: 'love' })).rejects.toThrow(/fire or sleep/); 253 }); 254}); 255 256describe('vote', () => { 257 const count = () => db.prepare('SELECT a, b, pick, n FROM votes').all().then((r) => r.results); 258 259 it("counts through the votes' own rules, and says whether you agree with Jev", async () => { 260 expect(await run('vote', { a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/dial-up' })).toBe( 261 'counted: jev/dial-up over jev/hey-listen; Jev scores jev/dial-up 80% and jev/hey-listen 60%, so you agree with Jev', 262 ); 263 expect(await run('vote', { a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/hey-listen' })).toMatch(/disagree/); 264 expect(await run('vote', { a: 'jev/dial-up', b: 'jev/no-score', pick: 'jev/no-score' })).toMatch(/no preference/); 265 expect(await count()).toHaveLength(3); 266 expect(voteKeys).toEqual(['user:u1', 'user:u1', 'user:u1']); 267 expect(asked).toEqual([]); 268 }); 269 270 it('refuses what the page could not vote, and holds the rate per account', async () => { 271 await expect(run('vote', { a: 'jev/dial-up', b: 'jev/nope', pick: 'jev/dial-up' })).rejects.toThrow(/not a song/); 272 await expect(run('vote', { a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/no-score' })).rejects.toThrow(/a or b/); 273 voteLimited = true; 274 await expect(run('vote', { a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/dial-up' })).rejects.toThrow(/too many votes/); 275 expect(await count()).toEqual([]); 276 }); 277});