// gandora MCP spike: one stateless Streamable-HTTP MCP endpoint (POST /mcp, JSON responses) exposing MCP Apps. // ui://gandora/probe the audio probe (synth / remote / blob) — answered Oct 22 2026: all three play in claude.ai // ui://gandora/radio Claude as the DJ: dj_play(sheet) shows a card that renders the track and the DJ's spoken line // through app-only tools, plays voice then song, and (auto-DJ) asks Claude for the next one. // Rendering goes through the shared inference worker by service binding (env.INFERENCE -> time-inference), as app // "gandora" from the dev origin, so it shares gandora's quota. Throwaway: see mcp/README.md. const MEDIA = "https://dev.gandora.lol"; const ORIGIN = "https://dev.gandora.lol"; const TRACKS = { sky: "/asmr/audio/kokoro-sky.mp3", rain: "/asmr/audio/bed-rain.mp3" }; const PROBE_URI = "ui://gandora/probe"; const RADIO_URI = "ui://gandora/station"; const UI_MIME = "text/html;profile=mcp-app"; const UI_META = { ui: { csp: { resourceDomains: [MEDIA], connectDomains: [MEDIA] }, prefersBorder: true } }; const APP_ONLY = { ui: { visibility: ["app"] } }; const SHEET = { type: "object", properties: { title: { type: "string", description: "Song title" }, tags: { type: "string", description: "ACE-Step style prompt: genre, instruments, mood, vocal type, production. Comma-separated." }, lyrics: { type: "string", description: "Optional lyrics with [verse]/[chorus] section tags. Empty for instrumental." }, bpm: { type: "number", description: "30-300" }, key: { type: "string", description: "e.g. 'A minor', 'F# major'" }, meter: { type: "string", description: "2/4, 3/4, 4/4 or 6/8", default: "4/4" }, seconds: { type: "number", description: "Length, 30-120. Default 60.", default: 60 }, dj_line: { type: "string", description: "What the DJ says before the track (1-3 sentences, spoken by TTS). Optional." }, voice: { type: "string", description: "Kokoro voice for the DJ line, e.g. af_sky, af_nicole, am_adam, bf_emma. Default af_sky." }, station: { type: "string", description: "The station's theme in a few words (e.g. 'city girl: late-night city pop'). The card keeps playing tracks on this theme by itself." }, }, required: ["title", "tags"], }; const TOOLS = [ { name: "open_probe", title: "Open gandora audio probe", description: "Shows the gandora audio probe player in chat (an MCP App).", inputSchema: { type: "object", properties: {} }, _meta: { ui: { resourceUri: PROBE_URI }, "ui/resourceUri": PROBE_URI } }, { name: "dj_station", title: "Open a gandora station", description: "Preferred over dj_play. You are the gandora DJ: write the opening track sheet plus a station theme. A self-running station card appears in chat: it renders and plays your track, then keeps the station going by itself (a small DJ model writes each following sheet, steered by the listener's Like / Not this / Next), with no messages to the chat. Its play log reaches you as model context. Call again only for a new station or a change of direction.", inputSchema: SHEET, _meta: { ui: { resourceUri: RADIO_URI }, "ui/resourceUri": RADIO_URI } }, { name: "dj_play", title: "Play a track on gandora", description: "You are the gandora DJ. Write the opening track sheet (and a station theme) and put it on air: a player card appears in chat, renders the song (ACE-Step, ~15-60 s), speaks your dj_line, then plays. The card then keeps the station going on its own (a small DJ model writes the following sheets in your style, steered by the listener's Like / Not this / Next), so you do not need to call dj_play per track. Its play log and reactions reach you as model context. Call dj_play again only when the listener asks you for a change of direction or a new station.", inputSchema: SHEET, _meta: { ui: { resourceUri: RADIO_URI }, "ui/resourceUri": RADIO_URI } }, { name: "get_track", title: "Get a gandora test clip", description: "Probe UI only.", inputSchema: { type: "object", properties: { id: { type: "string", enum: Object.keys(TRACKS) } } }, _meta: APP_ONLY }, { name: "render_track", title: "Render a gandora track", description: "Radio UI only: renders a sheet to mp3 (base64).", inputSchema: SHEET, _meta: APP_ONLY }, { name: "dj_next", title: "Write the next sheet", description: "Radio UI only: the in-card DJ writes the next track sheet from the station and play history.", inputSchema: { type: "object", properties: { station: { type: "string" }, history: { type: "array" } } }, _meta: APP_ONLY }, { name: "dj_speak", title: "Speak a DJ line", description: "Radio UI only: TTS for the DJ line (base64 mp3).", inputSchema: { type: "object", properties: { text: { type: "string" }, voice: { type: "string" } }, required: ["text"] }, _meta: APP_ONLY }, ]; function b64(buf) { const u = new Uint8Array(buf); let s = ""; for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode.apply(null, u.subarray(i, i + 0x8000)); return btoa(s); } const fail = (text) => ({ isError: true, content: [{ type: "text", text }] }); const clamp = (n, lo, hi, d) => { n = +n; return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d; }; function hash32(s) { let h = 2166136261; for (const c of String(s)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return h >>> 1; } async function inference(env, path, body) { if (!env.INFERENCE) throw new Error("no INFERENCE service binding on this worker"); const r = await env.INFERENCE.fetch("https://inference.yolopreneur.lol" + path, { method: "POST", body: JSON.stringify(body), headers: { origin: ORIGIN, "x-app": "gandora", "content-type": "application/json", accept: "*/*" } }); if (!r.ok) { let t = ""; try { t = (await r.json()).error || ""; } catch {} throw new Error(path + " -> " + r.status + (t ? ": " + t : "")); } return r; } async function callTool(name, a, env) { a = a || {}; if (name === "open_probe") return { content: [{ type: "text", text: "gandora probe opened." }], structuredContent: { media: MEDIA, tracks: Object.keys(TRACKS) } }; if (name === "get_track") { const path = TRACKS[a.id || "sky"]; if (!path) return fail("unknown track"); const r = await fetch(MEDIA + path); if (!r.ok) return fail("fetch " + r.status); return { content: [{ type: "text", text: "clip" }], structuredContent: { id: a.id || "sky", mimeType: "audio/mpeg", data: b64(await r.arrayBuffer()) } }; } if (name === "dj_play" || name === "dj_station") { const sheet = { ...a, seconds: clamp(a.seconds, 30, 120, 60), seed: hash32(a.title + "|" + a.tags) }; return { content: [{ type: "text", text: `On air: "${a.title}" (${sheet.seconds}s). The card is rendering it now; wait for a [gandora] message before playing the next one.` }], structuredContent: sheet }; } if (name === "render_track") { try { const r = await inference(env, "/v1/render", { prompt: a.tags, lyrics: a.lyrics || "", duration: clamp(a.seconds, 30, 120, 60), bpm: a.bpm, key: a.key, timeSignature: a.meter || "4/4", seed: a.seed ?? hash32(a.title + "|" + a.tags), quality: "turbo" }); const data = b64(await r.arrayBuffer()); return { content: [{ type: "text", text: "rendered" }], structuredContent: { mimeType: r.headers.get("content-type") || "audio/mpeg", data, renderer: r.headers.get("x-renderer") || "", cost: r.headers.get("x-cost") || "" } }; } catch (e) { return fail(String(e.message || e)); } } if (name === "dj_next") { try { const hist = (Array.isArray(a.history) ? a.history : []).slice(-8); const sys = "You are the late-night DJ of gandora, a generative radio station. Write the NEXT track sheet as one JSON object and nothing else: " + "{\"title\",\"tags\",\"lyrics\",\"bpm\",\"key\",\"seconds\",\"dj_line\"}. tags: an ACE-Step style prompt (genre, instruments, mood, vocal type, production), comma-separated, under 300 chars. " + "lyrics: original, short ([verse] 4 lines, [chorus] 4 lines), never quote real songs; or empty for an instrumental now and then. key like 'A minor'. seconds 60-90. " + "dj_line: 1-2 warm, short sentences spoken before the track, can reference the last track. Stay on the station theme but vary tempo, key, texture and title; " + "lean toward what was liked, away from what was disliked or skipped."; const user = JSON.stringify({ station: a.station || "", recent: hist.map((h) => ({ title: h.title, tags: h.tags, bpm: h.bpm, key: h.key, reaction: h.reaction || "played" })) }); const r = await inference(env, "/v1/chat/completions", { model: "claude-haiku-5-5", max_tokens: 900, temperature: 1, messages: [{ role: "system", content: sys }, { role: "user", content: user }] }); const d = await r.json(); const text = String(d.choices?.[0]?.message?.content || ""); const m = text.match(/\{[\s\S]*\}/); if (!m) return fail("DJ wrote no sheet"); const s = JSON.parse(m[0]); if (!s.title || !s.tags) return fail("DJ sheet missing title/tags"); const sheet = { ...s, station: a.station || "", seconds: clamp(s.seconds, 30, 120, 75), seed: hash32(s.title + "|" + s.tags) }; return { content: [{ type: "text", text: "next: " + sheet.title }], structuredContent: sheet }; } catch (e) { return fail(String(e.message || e)); } } if (name === "dj_speak") { try { const r = await inference(env, "/v1/speech", { input: String(a.text || "").slice(0, 600), model: "tts-kokoro", voice: a.voice || "af_sky", response_format: "mp3" }); return { content: [{ type: "text", text: "spoken" }], structuredContent: { mimeType: "audio/mpeg", data: b64(await r.arrayBuffer()) } }; } catch (e) { return fail(String(e.message || e)); } } return null; } async function handle(msg, env) { const { id, method, params } = msg; const ok = (result) => ({ jsonrpc: "2.0", id, result }); const err = (code, message) => ({ jsonrpc: "2.0", id, error: { code, message } }); switch (method) { case "initialize": return ok({ protocolVersion: (params && params.protocolVersion) || "2025-06-18", capabilities: { tools: {}, resources: {} }, serverInfo: { name: "gandora", title: "gandora", version: "0.0.2-spike" }, instructions: "gandora: generative radio, with you as the DJ. dj_play puts a track on air in chat. Messages starting with [gandora] come from the player (track ended, next, reactions): answer them with the next dj_play." }); case "ping": return ok({}); case "tools/list": return ok({ tools: TOOLS }); case "tools/call": { const res = await callTool(params && params.name, params && params.arguments, env); return res ? ok(res) : err(-32602, `unknown tool ${params && params.name}`); } case "resources/list": return ok({ resources: [ { uri: PROBE_URI, name: "gandora probe", mimeType: UI_MIME, _meta: UI_META }, { uri: RADIO_URI, name: "gandora radio", mimeType: UI_MIME, _meta: UI_META } ] }); case "resources/templates/list": return ok({ resourceTemplates: [] }); case "resources/read": if (params && params.uri === PROBE_URI) return ok({ contents: [{ uri: PROBE_URI, mimeType: UI_MIME, text: PROBE_HTML, _meta: UI_META }] }); if (params && (params.uri === RADIO_URI || params.uri === "ui://gandora/radio" || params.uri === "ui://gandora/radio-v3")) return ok({ contents: [{ uri: RADIO_URI, mimeType: UI_MIME, text: RADIO_HTML, _meta: UI_META }] }); return err(-32002, "resource not found"); default: if (id === undefined) return null; return err(-32601, `method not found: ${method}`); } } const CORS = { "access-control-allow-origin": "*", "access-control-allow-headers": "content-type, mcp-session-id, mcp-protocol-version, authorization", "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", }; export default { async fetch(req, env) { const url = new URL(req.url); if (req.method === "OPTIONS") return new Response(null, { headers: CORS }); if (url.pathname === "/health") return new Response("ok gandora-mcp 0.0.2 inference=" + (env.INFERENCE ? "bound" : "missing"), { headers: CORS }); const want = env.TOKEN ? "/mcp/" + env.TOKEN : "/mcp"; if (url.pathname !== want && !url.pathname.startsWith(want + "/")) return new Response("gandora mcp spike", { status: 404, headers: CORS }); if (req.method !== "POST") return new Response(null, { status: 405, headers: CORS }); let body; try { body = await req.json(); } catch { return Response.json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }, { status: 400, headers: CORS }); } const batch = Array.isArray(body); const out = (await Promise.all((batch ? body : [body]).map((m) => handle(m, env)))).filter(Boolean); if (!out.length) return new Response(null, { status: 202, headers: CORS }); return Response.json(batch ? out : out[0], { headers: CORS }); }, }; /* the host bridge both UIs share: JSON-RPC over postMessage (matched the claude.ai host in the Oct 22 probe) */ const BRIDGE = String.raw` let nid=1;const pend={},listeners={}; function rpc(method,params,ms){const id=nid++;return new Promise((res,rej)=>{pend[id]={res,rej};parent.postMessage({jsonrpc:'2.0',id,method,params},'*');setTimeout(()=>{if(pend[id]){delete pend[id];rej(new Error('timeout '+method))}},ms||20000)})} function note(method,params){parent.postMessage({jsonrpc:'2.0',method,params},'*')} function on(method,fn){listeners[method]=fn} addEventListener('message',e=>{const m=e.data;if(!m||m.jsonrpc!=='2.0')return;if(m.id!=null&&pend[m.id]&&!m.method){const p=pend[m.id];delete pend[m.id];m.error?p.rej(new Error(m.error.message||JSON.stringify(m.error))):p.res(m.result)}else if(m.method){if(listeners[m.method])listeners[m.method](m.params||{});if(m.id!=null)parent.postMessage({jsonrpc:'2.0',id:m.id,result:{}},'*')}}); function say(text){return rpc('ui/message',{role:'user',content:[{type:'text',text}]})} function bytes(sc){const bin=atob(sc.data),u=new Uint8Array(bin.length);for(let i=0;i gandora · probe
`; const RADIO_HTML_V2 = `

tuning in…

waiting for the DJ
sheet
`; /* v3: one card is a whole station. Claude's dj_play sheet opens it; dj_next (a small DJ model) writes each following sheet, pre-cooked while the current track plays. Reactions and the play log go to Claude as model context only. */ const RADIO_HTML = `

tuning in…

waiting for the DJ
log
`;