mcp.tsannotatedmcp.tssource80 lines · 3.6 KB · raw

The dev hub's MCP at POST /mcp (mcp-rpc.ts speaks the protocol): Claude Code reaches it through the stdio proxy in tools/mcp/, which forwards each message here, and its tools become commands sent to a tab through the hub (hub.ts). Dev only, unauthenticated: the hosted MCP for everyone is hosted-mcp.ts. Its tools are tools.json's, the dev hub's own, and shared-tools.json's, which the hosted MCP offers too (shared-mcp.ts).

7import type { Env } from './env';
8import type { Hub, Reply } from './hub';
9import { serveRpc, type Tool } from './mcp-rpc';
10import { runShared, SHARED_NAMES, SHARED_TOOLS } from './shared-mcp';
11import own from './tools.json';
13type Args = { session_id?: string; code?: string; filter?: string };
14
15export const DEV_TOOLS: Tool[] = [...own, ...SHARED_TOOLS.map(({ scope: _scope, ...tool }) => tool)];
16
17async function run(env: Env, hub: DurableObjectStub<Hub>, name: string, args: Args & Record<string, unknown>): Promise<string> {
18  // needs no tab
19  if (name === 'api_reference' || name === 'vote') {
20    return runShared({ env, voter: 'local', hosted: false, tab: () => Promise.reject(new Error('no tab needed')) }, name, args);
21  }
22  const open = await hub.sessions();
23  if (name === 'get_mcp_status') return open.length ? `open tabs: ${open.join(', ')}` : 'no browser tab is open';
24
25  let session = args.session_id;
26  if (session && !open.includes(session))
27    throw new Error(`no tab with session ${session}; open: ${open.join(', ') || 'none'}`);
28  if (!session) {
29    if (open.length !== 1) {
30      throw new Error(
31        open.length ? `several tabs are open (${open.join(', ')}); pass session_id` : 'no browser tab is open',
32      );
33    }
34    session = open[0];
35  }
36
37  if (SHARED_NAMES.has(name)) {
38    const tab = session;
39    return runShared({ env, voter: 'local', hosted: false, tab: (command) => hub.command(tab, command) }, name, args);
40  }
41
42  let reply: Reply;
43  switch (name) {
44    case 'play_code':
45      if (typeof args.code !== 'string' || !args.code.trim()) throw new Error('code is empty');
46      reply = await hub.command(session, { type: 'play', code: args.code });
47      if (reply.error) throw new Error(`evaluation failed in ${session}: ${reply.error}`);
48      return `playing in ${session}`;
49    case 'stop_play':
50      await hub.command(session, { type: 'stop' });
51      return `stopped ${session}`;
52    case 'get_currently_playing_code':
53      reply = await hub.command(session, { type: 'get-code' });
54      if (reply.error) throw new Error(reply.error);
55      return reply.code ?? '';
56    case 'get_logs': {
57      reply = await hub.command(session, { type: 'get-logs' });
58      const lines = (reply.logs ?? []).filter((l) => !args.filter || l.message.includes(args.filter));
59      if (!lines.length) return args.filter ? `nothing matching "${args.filter}"` : 'nothing logged';
60      return lines.map((l) => `${l.at.toFixed(1)}s ${l.message}${l.count > 1 ? ` (×${l.count})` : ''}`).join('\n');
61    }
62    default:
63      throw new Error(`unknown tool ${name}`);
64  }
65}

The dev hub drives any open tab on this machine, so it is only ever bound under wrangler dev --env dev (worker/CLAUDE.md). Its messages are small; a song is at most a few tens of KB.

70const MAX_BYTES = 1024 * 1024;
72export async function mcp(request: Request, env: Env): Promise<Response> {
73  if (!env.HUB) return new Response('not found', { status: 404 });
74  const hub = env.HUB.get(env.HUB.idFromName('hub'));
75  return serveRpc(
76    request,
77    { name: 'jevstrudel', version: '1.0.0', tools: DEV_TOOLS, call: (name, args) => run(env, hub, name, args as Args) },
78    { maxBytes: MAX_BYTES },
79  );
80}