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).
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}