mcp: spike — gandora MCP App audio probe (ui://gandora/probe + get_track)

This commit is contained in:
teahubxyz committed 2026-10-22 19:13:41 +00:00
1 parent f173b9dffd
commit 6da217f4c4
2 files changed
+169

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# gandora MCP spike
A throwaway experiment, not part of the page. Question: can a gandora player run as an MCP App inside claude.ai and
play audio, and by which path?
The chat Visualizer sandbox (Oct 22 2026) played Web Audio but refused `<audio>` from dev.gandora.lol and teahub.xyz
(MediaError code 4: CSP). An MCP App's UI resource can declare its own domains, and can call its server's tools
through the host, so this probe tests three paths:
1. **synth**: Web Audio, no network (control)
2. **remote**: `<audio>` from dev.gandora.lol, declared in the resource's `_meta.ui.csp.resourceDomains`
3. **blob**: the UI calls `get_track` through the host bridge, gets base64 mp3, plays it from a `blob:` URL
`worker.js` is a single stateless Cloudflare Worker speaking MCP over Streamable HTTP (POST `/mcp`, JSON replies).
Tools: `open_probe` (shows the UI) and `get_track` (app-only). Deployed as `gandora-mcp` at
`https://gandora-mcp.yolopreneur.lol/mcp` by teahubxyz/time › Actions › gandora-mcp (reads this file from `dev`).
Try it: add the URL as a custom connector in claude.ai, then ask Claude to open the gandora probe.
No auth, no keys: it only serves public test clips. Don't grow it in place; the real player server gets designed
from what this answers.
+148
View File
@@ -0,0 +1,148 @@
// gandora MCP spike: one stateless Streamable-HTTP MCP endpoint (POST /mcp, JSON responses) exposing an MCP App.
// Question it answers: can a gandora player inside claude.ai play audio, and by which path?
// 1. synth - Web Audio, no network
// 2. remote - <audio src> from dev.gandora.lol, allowed through the resource's declared CSP domains
// 3. blob - bytes returned by the get_track tool (called by the UI through the host bridge) -> blob: URL
// Throwaway: not wired to the page, the player or the inference worker. See mcp/README.md.
const MEDIA = "https://dev.gandora.lol";
const TRACKS = {
sky: "/asmr/audio/kokoro-sky.mp3",
rain: "/asmr/audio/bed-rain.mp3",
};
const UI_URI = "ui://gandora/probe";
const UI_MIME = "text/html;profile=mcp-app";
const UI_META = { ui: { csp: { resourceDomains: [MEDIA], connectDomains: [MEDIA] }, prefersBorder: true } };
const TOOLS = [
{
name: "open_probe",
title: "Open gandora audio probe",
description: "Shows the gandora audio probe player in chat (an MCP App). Use when the user asks to run the gandora probe or open the gandora player.",
inputSchema: { type: "object", properties: {} },
_meta: { ui: { resourceUri: UI_URI }, "ui/resourceUri": UI_URI },
},
{
name: "get_track",
title: "Get a gandora track",
description: "Returns one short test track's audio bytes (base64 mp3). Called by the probe UI.",
inputSchema: { type: "object", properties: { id: { type: "string", enum: Object.keys(TRACKS) } } },
_meta: { ui: { visibility: ["app"] } },
},
];
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);
}
async function callTool(name, args) {
if (name === "open_probe") {
return { content: [{ type: "text", text: "gandora probe opened. Tap each row, then Send results." }],
structuredContent: { media: MEDIA, tracks: Object.keys(TRACKS) } };
}
if (name === "get_track") {
const id = (args && args.id) || "sky";
const path = TRACKS[id];
if (!path) return { isError: true, content: [{ type: "text", text: `unknown track ${id}` }] };
const r = await fetch(MEDIA + path);
if (!r.ok) return { isError: true, content: [{ type: "text", text: `fetch ${path} -> ${r.status}` }] };
const data = b64(await r.arrayBuffer());
return { content: [{ type: "text", text: `track ${id}: ${data.length} base64 chars` }],
structuredContent: { id, mimeType: "audio/mpeg", data } };
}
return null;
}
async function handle(msg) {
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.1-spike" },
instructions: "gandora: generative radio. open_probe shows the audio probe player in chat.",
});
case "ping":
return ok({});
case "tools/list":
return ok({ tools: TOOLS });
case "tools/call": {
const res = await callTool(params && params.name, params && params.arguments);
return res ? ok(res) : err(-32602, `unknown tool ${params && params.name}`);
}
case "resources/list":
return ok({ resources: [{ uri: UI_URI, name: "gandora probe", mimeType: UI_MIME, _meta: UI_META }] });
case "resources/templates/list":
return ok({ resourceTemplates: [] });
case "resources/read":
if (params && params.uri === UI_URI)
return ok({ contents: [{ uri: UI_URI, mimeType: UI_MIME, text: PROBE_HTML, _meta: UI_META }] });
return err(-32002, "resource not found");
default:
if (id === undefined) return null; // notification
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) {
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", { headers: CORS });
if (url.pathname !== "/mcp") return new Response("gandora mcp spike: POST /mcp", { 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(handle))).filter(Boolean);
if (!out.length) return new Response(null, { status: 202, headers: CORS });
return Response.json(batch ? out : out[0], { headers: CORS });
},
};
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}
h1{font-size:15px;font-weight:600;margin:0 0 10px}
.row{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:14px;margin:8px 0}
.s{font-size:12px;opacity:.75}.ok{color:#2a8a3e;opacity:1}.bad{color:#c0392b;opacity:1}
button{font:inherit;font-size:13px;padding:5px 10px;border-radius:7px;border:1px solid #8886;background:transparent;color:inherit;cursor:pointer}
#log{font:11px ui-monospace,monospace;white-space:pre-wrap;opacity:.7;margin-top:10px;max-height:120px;overflow:auto}
</style></head><body>
<h1>gandora · MCP App probe</h1>
<div id="rows"></div>
<div style="display:flex;gap:8px;margin-top:12px"><button id="all">Run all</button><button id="send">Send results ↗</button></div>
<div id="log"></div>
<script>
const R={},log=t=>{document.getElementById('log').textContent+=t+'\\n'};
let nid=1;const pend={};
function rpc(method,params){const id=nid++;return new Promise((res,rej)=>{pend[id]={res,rej};window.parent.postMessage({jsonrpc:'2.0',id,method,params},'*');setTimeout(()=>{if(pend[id]){delete pend[id];rej(new Error('timeout '+method))}},20000)})}
function note(method,params){window.parent.postMessage({jsonrpc:'2.0',method,params},'*')}
window.addEventListener('message',e=>{const m=e.data;if(!m||m.jsonrpc!=='2.0')return;if(m.id!=null&&pend[m.id]){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){log('host: '+m.method)}});
let ready=rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-probe',version:'0.0.1'},appCapabilities:{}}).then(r=>{log('init ok '+(r&&r.protocolVersion||''));note('ui/notifications/initialized',{});return true}).catch(e=>{log('init: '+e.message);return false});
const tests=[{id:'synth',name:'Web Audio synth'},{id:'remote',name:'<audio> from dev.gandora.lol (CSP domain)'},{id:'blob',name:'bytes via get_track tool → blob:'}];
const rows=document.getElementById('rows');
for(const t of tests){const d=document.createElement('div');d.className='row';d.innerHTML='<span>'+t.name.replace(/</g,'&lt;')+'</span><span style="display:flex;gap:8px;align-items:center"><span class="s" id="s-'+t.id+'">not run</span><button>Play</button></span>';d.querySelector('button').onclick=()=>run(t);rows.appendChild(d)}
function set(id,txt,ok){R[id]=txt;const e=document.getElementById('s-'+id);e.textContent=txt;e.className='s'+(ok===true?' ok':ok===false?' bad':'')}
let cur;
async function playUrl(id,src){if(cur)cur.pause();const a=new Audio();cur=a;a.onerror=()=>set(id,'failed (code '+(a.error&&a.error.code)+')',false);a.src=src;try{await a.play();set(id,'playing',true);setTimeout(()=>a.pause(),4000)}catch(e){if(R[id]==='loading…')set(id,'refused: '+e.name,false)}}
async function run(t){set(t.id,'loading…');
if(t.id==='synth'){try{const C=new(window.AudioContext||window.webkitAudioContext)();await C.resume();const o=C.createOscillator(),g=C.createGain();o.frequency.value=440;g.gain.value=.08;o.connect(g).connect(C.destination);o.start();o.stop(C.currentTime+.8);set('synth','played ('+C.state+')',true)}catch(e){set('synth','error: '+e.name,false)}return}
if(t.id==='remote')return playUrl('remote','${MEDIA}/asmr/audio/bed-rain.mp3');
if(t.id==='blob'){try{if(!await ready)throw new Error('no host bridge');const r=await rpc('tools/call',{name:'get_track',arguments:{id:'sky'}});const sc=r&&r.structuredContent;if(!sc||!sc.data)throw new Error('no bytes in result');const bin=atob(sc.data),u=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i);log('got '+u.length+' bytes');return playUrl('blob',URL.createObjectURL(new Blob([u],{type:sc.mimeType})))}catch(e){set('blob','error: '+e.message,false)}}}
document.getElementById('all').onclick=async()=>{for(const t of tests){await run(t);await new Promise(r=>setTimeout(r,1500))}};
document.getElementById('send').onclick=async()=>{const text='gandora MCP App probe: '+tests.map(t=>t.id+'='+(R[t.id]||'not run')).join('; ');try{await ready;await rpc('ui/message',{role:'user',content:[{type:'text',text}]});log('sent')}catch(e){log('send failed: '+e.message+' — copy: '+text)}};
</script></body></html>`;