Files
site/mcp/worker.js
T

260 lines
21 KiB
JavaScript

// 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/radio";
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_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") {
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) 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) 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<bin.length;i++)u[i]=bin.charCodeAt(i);return URL.createObjectURL(new Blob([u],{type:sc.mimeType||'audio/mpeg'}))}
async function tool(name,args,ms){const r=await rpc('tools/call',{name,arguments:args},ms);if(r&&r.isError)throw new Error((r.content&&r.content[0]&&r.content[0].text)||'tool error');return r&&r.structuredContent}
`;
const PROBE_HTML = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<style>:root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;padding:14px;background:transparent;color:CanvasText}
.row{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:14px;margin:8px 0}.s{font-size:12px;opacity:.75}
button{font:inherit;font-size:13px;padding:5px 10px;border-radius:7px;border:1px solid #8886;background:transparent;color:inherit;cursor:pointer}</style></head><body>
<b>gandora · probe</b><div id="rows"></div>
<script>${BRIDGE}
const ready=rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-probe',version:'0.0.2'},appCapabilities:{}}).then(()=>{note('ui/notifications/initialized',{});return true}).catch(()=>false);
const rows=document.getElementById('rows');
[['remote','${MEDIA}/asmr/audio/bed-rain.mp3'],['blob','']].forEach(([id,src])=>{const d=document.createElement('div');d.className='row';d.innerHTML='<span>'+id+'</span><span class="s" id="s-'+id+'">not run</span><button>Play</button>';
d.querySelector('button').onclick=async()=>{const s=document.getElementById('s-'+id);try{const u=src||bytes(await tool('get_track',{id:'sky'}));const a=new Audio(u);await a.play();s.textContent='playing';setTimeout(()=>a.pause(),4000)}catch(e){s.textContent='error: '+e.message}};rows.appendChild(d)});
</script></body></html>`;
const RADIO_HTML = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<style>
:root{color-scheme:light dark;font-family:system-ui,sans-serif;--mut:color-mix(in srgb,CanvasText 55%,transparent);--line:color-mix(in srgb,CanvasText 18%,transparent)}
body{margin:0;padding:16px 16px 18px;background:transparent;color:CanvasText}
.card{display:flex;gap:16px;align-items:center}
canvas{width:150px;height:150px;border-radius:14px;flex:none}
@media (max-width:480px){canvas{width:110px;height:110px}}
.t{font-size:16px;font-weight:600;margin:0}.g{font-size:12px;color:var(--mut);margin:2px 0 0;line-height:1.4}
.st{font-size:12px;color:var(--mut);margin-top:6px;min-height:16px}
.line{font-size:13px;font-style:italic;margin:10px 0 0;color:var(--mut)}
.bar{height:3px;background:var(--line);border-radius:2px;margin:12px 0 10px;overflow:hidden}.bar i{display:block;height:100%;width:0;background:CanvasText;transition:width .25s linear}
.ctl{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
button{font:inherit;font-size:13px;padding:5px 10px;border-radius:7px;border:1px solid var(--line);background:transparent;color:inherit;cursor:pointer}
button:disabled{opacity:.4;cursor:default}label{font-size:12px;color:var(--mut);display:flex;gap:4px;align-items:center;margin-left:auto}
details{margin-top:10px;font-size:12px;color:var(--mut)}pre{white-space:pre-wrap;font:11px ui-monospace,monospace}
</style></head><body>
<div class="card"><canvas id="cv" width="300" height="300"></canvas>
<div style="min-width:0"><p class="t" id="title">tuning in…</p><p class="g" id="tags"></p><div class="st" id="st">waiting for the DJ</div></div></div>
<p class="line" id="line" hidden></p>
<div class="bar"><i id="pos"></i></div>
<div class="ctl">
<button id="pp" disabled>Play</button><button id="up" disabled>Like</button><button id="down" disabled>Not this</button><button id="next" disabled>Next ↗</button>
<label><input type="checkbox" id="auto" checked> auto-DJ</label></div>
<details><summary>sheet</summary><pre id="sheet"></pre><pre id="log"></pre></details>
<script>${BRIDGE}
const $=id=>document.getElementById(id), log=t=>{$('log').textContent+=t+'\\n'};
let S=null,started=false,song=null,voice=null,sent=false,t0=0,tick=null;
const bc=('BroadcastChannel' in window)?new BroadcastChannel('gandora-radio'):null;
const me=Math.random().toString(36).slice(2);
if(bc)bc.onmessage=e=>{if(e.data&&e.data.on&&e.data.on!==me){[song,voice].forEach(a=>a&&a.pause());sent=true;$('pp').textContent='Play'}};
function hue(k){const n=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const m=String(k||'').match(/^([A-G])(#|b)?/i);if(!m)return 260;let i=n.indexOf(m[1].toUpperCase());if(m[2]==='#')i++;if(m[2]==='b')i--;return((i+12)%12)*30}
function cover(s){const c=$('cv'),x=c.getContext('2d'),h=hue(s.key),minor=/minor/i.test(s.key||''),bpm=+s.bpm||100;let r=s.seed||1;const rnd=()=>{r=(r*1103515245+12345)&0x7fffffff;return r/0x7fffffff};
x.fillStyle='hsl('+h+' 45% '+(minor?18:30)+'%)';x.fillRect(0,0,300,300);const n=3+Math.round(bpm/30);
for(let i=n;i>0;i--){x.beginPath();x.arc(150+(rnd()-.5)*46,150+(rnd()-.5)*46,i*(132/n),0,7);x.strokeStyle='hsl('+((h+i*(minor?8:20))%360)+' 70% '+(55+i*3)+'%)';x.lineWidth=1.5+rnd()*3;x.stroke()}}
function show(s){S=s;$('title').textContent=s.title||'untitled';$('tags').textContent=[s.tags,s.bpm&&(s.bpm+' bpm'),s.key].filter(Boolean).join(' · ');
if(s.dj_line){$('line').textContent='“'+s.dj_line+'”';$('line').hidden=false}$('sheet').textContent=JSON.stringify(s,null,1);cover(s)}
function status(t){$('st').textContent=t}
async function begin(s){if(started)return;started=true;show(s);t0=Date.now();tick=setInterval(()=>status('cooking… '+Math.round((Date.now()-t0)/1000)+'s'),1000);
const vp=s.dj_line?tool('dj_speak',{text:s.dj_line,voice:s.voice},60000).then(bytes).catch(e=>{log('voice: '+e.message);return null}):Promise.resolve(null);
try{const r=await tool('render_track',s,180000);clearInterval(tick);song=new Audio(bytes(r));log('renderer '+r.renderer+' cost '+r.cost);
const vu=await vp;if(vu)voice=new Audio(vu);wire();status('ready · '+Math.round((Date.now()-t0)/1000)+'s to cook');['pp','up','down','next'].forEach(i=>$(i).disabled=false);go()}
catch(e){clearInterval(tick);status('render failed: '+e.message);$('next').disabled=false}}
function wire(){song.ontimeupdate=()=>{$('pos').style.width=(100*song.currentTime/(song.duration||1))+'%'};
song.onended=()=>{$('pp').textContent='Play';status('ended');tell('finished "'+S.title+'" all the way through');if($('auto').checked)ask('track ended: "'+S.title+'". Next track please.')};
if(voice)voice.onended=()=>song.play().catch(()=>{status('tap Play');$('pp').textContent='Play'})}
async function go(){if(bc)bc.postMessage({on:me});sent=false;try{if(voice&&voice.currentTime===0&&song.currentTime===0)await voice.play();else await song.play();$('pp').textContent='Pause';status('on air')}catch(e){status('tap Play to start');$('pp').textContent='Play'}}
$('pp').onclick=()=>{const live=(voice&&!voice.paused)?voice:song;if(!live.paused){live.pause();$('pp').textContent='Play'}else{live.play();$('pp').textContent='Pause';if(bc)bc.postMessage({on:me})}};
async function ask(text){if(sent)return;sent=true;try{await say('[gandora] '+text)}catch(e){log('send: '+e.message);sent=false}}
const events=[];
async function tell(text){events.push(text);status(text);try{await rpc('ui/update-model-context',{content:[{type:'text',text:'[gandora] listener feedback on this card, oldest first:\n- '+events.join('\n- ')}],structuredContent:{title:S&&S.title,events}});log('context: '+text)}catch(e){log('context failed: '+e.message)}}
$('up').onclick=()=>{tell('liked "'+S.title+'" ('+S.tags+')');$('up').textContent='Liked'};
$('down').onclick=()=>{[song,voice].forEach(a=>a&&a.pause());$('pp').textContent='Play';tell('disliked "'+S.title+'" ('+S.tags+'), stopped it')};
const fit=()=>note('ui/notifications/size-changed',{width:document.documentElement.scrollWidth,height:document.documentElement.scrollHeight});
if('ResizeObserver' in window)new ResizeObserver(fit).observe(document.body);addEventListener('load',fit);
$('next').onclick=()=>{[song,voice].forEach(a=>a&&a.pause());ask('skip "'+(S?S.title:'')+'". Next track please.')};
on('ui/notifications/tool-input',p=>{if(p.arguments&&p.arguments.title&&!S)show(p.arguments)});
on('ui/notifications/tool-result',p=>{const sc=p.structuredContent||(p.result&&p.result.structuredContent);if(sc&&sc.tags)begin(sc)});
rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-radio',version:'0.0.2'},appCapabilities:{}}).then(r=>{log('init ok');note('ui/notifications/initialized',{})}).catch(e=>log('init: '+e.message));
</script></body></html>`;