mcp: spike — gandora MCP App audio probe (ui://gandora/probe + get_track)
This commit is contained in:
1 parent
f173b9dffd
commit
6da217f4c4
2 files changed
+169
No files matched your search
@@ -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
@@ -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,'<')+'</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>`;
|
||||
Reference in new issue
Block a user