The dev hub's MCP (mcp.ts): its own tools and the shared ones (shared-mcp.ts), against a stand-in hub whose tabs answer as the page does.
10type Command = { type: string } & Record<string, unknown>; 11let tabs: Map<string, (c: Command) => Record<string, unknown>>; 12let asked: { session: string; command: Command }[]; 13let voteKeys: string[]; 14 15const hub = { 16 sessions: async () => [...tabs.keys()], 17 command: async (session: string, command: Command) => { 18 asked.push({ session, command }); 19 const tab = tabs.get(session); 20 if (!tab) throw new Error(`no tab with session ${session}`); 21 return tab(command); 22 }, 23}; 24const env = () => 25 ({ 26 HUB: { idFromName: () => 'hub', get: () => hub }, 27 DB: testD1(), 28 SONGS_URL: 'http://songs.invalid/jev/songs.json', 29 VOTE_LIMIT: { limit: async ({ key }: { key: string }) => (voteKeys.push(key), { success: true }) }, 30 }) as never; 31 32let id = 0; 33async function call(name: string, args: Record<string, unknown> = {}) { 34 const res = await mcp( 35 new Request('http://localhost:4323/mcp', { 36 method: 'POST', 37 headers: { 'Content-Type': 'application/json' }, 38 body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method: 'tools/call', params: { name, arguments: args } }), 39 }), 40 env(), 41 ); 42 const body = (await res.json()) as { result: { content: { text: string }[]; isError: boolean } }; 43 return { text: body.result.content[0].text, isError: body.result.isError }; 44} 45 46beforeEach(() => { 47 tabs = new Map(); 48 asked = []; 49 voteKeys = []; 50 forgetSongs(); 51 forgetReference(); 52 vi.stubGlobal('fetch', async (input: RequestInfo | URL) => { 53 const url = String(input); 54 if (url === 'http://songs.invalid/jev/songs.json') return Response.json({ songs: ['jev/dial-up', 'jev/hey-listen'] }); 55 if (url === 'http://songs.invalid/jev/catalog.json') return Response.json({ songs: [] }); 56 if (url === 'http://songs.invalid/jev/reference.json') { 57 return Response.json({ functions: [{ name: 'fast', synonyms: [], tags: [], description: 'Speeds up a pattern.', params: [], examples: [] }] }); 58 } 59 throw new Error(`unexpected fetch ${url}`); 60 }); 61}); 62afterEach(() => vi.unstubAllGlobals()); 63 64describe('the dev MCP', () => { 65 it("lists its own tools, then the shared ones, as the proxy's offline list does", () => { 66 const names = DEV_TOOLS.map((t) => t.name); 67 expect(names.slice(0, 5)).toEqual(['play_code', 'stop_play', 'get_currently_playing_code', 'get_logs', 'get_mcp_status']); 68 expect(names).toContain('list_sounds'); 69 expect(DEV_TOOLS.every((t) => !('scope' in t))).toBe(true); 70 // tools/mcp/strudel-mcp.mjs reads the same two files when the Worker is down 71 const read = (f: string) => JSON.parse(readFileSync(new URL(f, import.meta.url), 'utf8')); 72 const offline = [...read('./tools.json'), ...read('./shared-tools.json').map(({ scope: _s, ...t }: { scope: unknown }) => t)]; 73 expect(offline).toEqual(DEV_TOOLS); 74 }); 75 76 it('sends a shared tool to the one open tab, and reads its reply bounded', async () => { 77 tabs.set('probe', (c) => 78 c.type === 'list-sounds' ? { sounds: { total: 1, items: [{ name: 'bd', category: 'samples', type: 'sample', variants: 1, use: 's("bd")', secret: 'x' }] } } : {}, 79 ); 80 expect(await call('list_sounds', { category: 'samples' })).toEqual({ text: '1 samples sound; 1 to 1:\nbd (samples): s("bd")', isError: false }); 81 expect(asked).toEqual([{ session: 'probe', command: { type: 'list-sounds', category: 'samples', offset: 0, limit: 100 } }]); 82 }); 83 84 it('asks which tab when several are open', async () => { 85 tabs.set('a1', () => ({})); 86 tabs.set('b2', () => ({ settings: { theme: 'dracula' }, themes: [] })); 87 expect((await call('get_settings')).text).toMatch(/several tabs are open/); 88 expect(JSON.parse((await call('get_settings', { session_id: 'b2' })).text).settings).toEqual({ theme: 'dracula' }); 89 }); 90 91 it('answers the reference and a vote with no tab open, voting as local', async () => { 92 expect((await call('api_reference', { name: 'fast' })).text).toMatch(/^fast\(\)/); 93 expect((await call('vote', { a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/dial-up' })).text).toMatch(/^counted/); 94 expect(voteKeys).toEqual(['local']); 95 expect((await call('list_sounds')).text).toBe('no browser tab is open'); 96 }); 97});