1/* 2useWebSocketMCP.jsx - MCP integration with the Strudel REPL 3 4jevstrudel: the tab holds a WebSocket to the MCP hub in the jevstrudel 5Worker (worker/src/hub.ts), which exists only under `wrangler dev --env dev` 6(started by `buck2 run //:dev`). The tab probes /strudel/status first: on 7the public site the Worker has no hub and answers 404, so MCP stays off (no 8session, no badge). In the dev server the probe repeats until the Worker is 9up, and the socket reconnects whenever the Worker reloads. 10 11The same session id also names this tab in its signed-in listener's own 12hub, everywhere, production included (jev/myTabs.mjs): the hosted MCP for 13everyone's own AI, whose code plays only in the sandbox. `mine` is that 14connection's state: 'off' (signed out), 'connecting' or 'connected'. 15*/ 16 17import { useEffect, useState } from 'react'; 18import { getAudioContext } from '@strudel/webaudio'; 19import { joinMyTabs } from '../jev/myTabs.mjs'; 20import { setOwnCode } from '../jev/sandbox.mjs'; 21import { TAB_TOOLS } from '../jev/tabTools.mjs'; 22 23const generateSessionId = () => { 24 const letters = Math.random().toString(36).substring(2, 5); 25 const digit = Math.floor(Math.random() * 10); 26 return letters + digit; 27}; 28 29// What the tab has logged since the last play, for get_logs: Strudel's 30// logger dispatches every message as a `strudel.log` event, and uncaught 31// errors are added. Consecutive repeats fold into one line with a count. 32const LOG_LIMIT = 1000; 33let logs = []; 34let logStart = typeof performance !== 'undefined' ? performance.now() : 0; 35function record(message) { 36 const last = logs.at(-1); 37 if (last?.message === message) { 38 last.count++; 39 return; 40 } 41 logs.push({ at: (performance.now() - logStart) / 1000, message, count: 1 }); 42 if (logs.length > LOG_LIMIT) logs = logs.slice(-LOG_LIMIT); 43} 44function clearLogs() { 45 logs = []; 46 logStart = performance.now(); 47} 48 49async function hubIsUp() { 50 try { 51 const res = await fetch('/strudel/status'); 52 const status = res.ok ? await res.json() : null; 53 return Array.isArray(status?.active_sessions); 54 } catch { 55 return false; 56 } 57} 58 59async function run(editor, command) { 60 if (!editor) return { error: 'the editor is not ready' }; 61 switch (command.type) { 62 case 'play': { 63 // Evaluation waits for the tab's first click (browsers keep audio off 64 // until then), so say so rather than hang until the hub times out. 65 if (getAudioContext().state !== 'running') { 66 return { error: 'this tab has not been clicked yet, so its audio is off; click the page once' }; 67 } 68 // Songs count cycles from 0, and only stop() resets the count. 69 await editor.stop(); 70 clearLogs(); 71 // Claude's code, in the dev server only: the page's own 72 setOwnCode(editor, command.code); 73 await editor.evaluate(); 74 const err = editor.repl.state.evalError; 75 return err ? { error: err.message ?? String(err) } : { ok: true }; 76 } 77 case 'stop': 78 await editor.stop(); 79 return { ok: true }; 80 case 'get-code': 81 return { code: editor.code }; 82 case 'get-logs': 83 return { logs }; 84 default: { 85 // the tools both MCPs share (jev/tabTools.mjs), loaded on first use 86 if (!TAB_TOOLS.has(command.type)) return { error: `unknown command ${command.type}` }; 87 if (command.type === 'play-song' || (command.type === 'ask-song' && command.play)) clearLogs(); 88 const { runTabTool } = await import('../jev/tabCommands.mjs'); 89 return runTabTool(editor, command); 90 } 91 } 92} 93 94export function useWebSocketMCP(editorRef) { 95 // `?session=name` pins the id, so it survives reloads (the headless tab 96 // Claude checks songs in uses ?session=probe); otherwise it is random. 97 const [sessionId] = useState(() => 98 typeof window === 'undefined' 99 ? '' 100 : (new URLSearchParams(window.location.search).get('session') ?? generateSessionId()), 101 ); 102 const [mcpAvailable, setMcpAvailable] = useState(false); 103 const [connectionStatus, setConnectionStatus] = useState('disconnected'); 104 const [mine, setMine] = useState('off'); 105 106 // the signed-in listener's own hub (jev/myTabs.mjs) 107 useEffect(() => joinMyTabs(editorRef, sessionId, setMine), []); 108 109 useEffect(() => { 110 let closed = false; 111 let socket; 112 let retry; 113 114 const onLog = (e) => record(String(e.detail?.message ?? '')); 115 const onError = (e) => record(`[uncaught] ${e.message ?? e.reason?.message ?? e.reason}`); 116 document.addEventListener('strudel.log', onLog); 117 window.addEventListener('error', onError); 118 window.addEventListener('unhandledrejection', onError); 119 120 const connect = () => { 121 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 122 socket = new WebSocket(`${protocol}//${window.location.host}/strudel/ws?session_id=${sessionId}`); 123 socket.onopen = () => setConnectionStatus('connected'); 124 socket.onmessage = async (event) => { 125 let message; 126 try { 127 message = JSON.parse(event.data); 128 } catch { 129 return; 130 } 131 if (message.type !== 'command') return; 132 let reply; 133 try { 134 reply = await run(editorRef.current, message.command); 135 } catch (e) { 136 reply = { error: e.message ?? String(e) }; 137 } 138 socket.send(JSON.stringify({ type: 'reply', id: message.id, ...reply })); 139 }; 140 socket.onclose = () => { 141 setConnectionStatus('disconnected'); 142 if (!closed) retry = setTimeout(connect, 2000); 143 }; 144 socket.onerror = () => setConnectionStatus('error'); 145 }; 146 147 const probe = async () => { 148 if (closed) return; 149 if (await hubIsUp()) { 150 setMcpAvailable(true); 151 connect(); 152 } else if (import.meta.env.DEV) { 153 // the Worker may still be starting beside the dev server 154 retry = setTimeout(probe, 5000); 155 } 156 }; 157 probe(); 158 159 return () => { 160 closed = true; 161 clearTimeout(retry); 162 socket?.close(); 163 document.removeEventListener('strudel.log', onLog); 164 window.removeEventListener('error', onError); 165 window.removeEventListener('unhandledrejection', onError); 166 }; 167 }, []); 168 169 return { sessionId, connectionStatus, isMCPEnabled: mcpAvailable, mine }; 170}