jevstrudel.git / worker / src / mcp.test.ts

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.

3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4import { readFileSync } from 'node:fs';
5import { testD1 } from '../test/d1';
6import { DEV_TOOLS, mcp } from './mcp';
7import { forgetReference } from './reference';
8import { forgetSongs } from './votes';
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});