clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / admin / ui / webmcp.js
webmcp.js 4.5K
1// Airwaves channel admin, for AI agents: registers the server's agent tools2// (the ones /admin/mcp serves, internal/admin/mcp.go) with WebMCP, so an3// agent working through this page, in the browser or on this computer by4// way of the WebMCP local relay, manages channels as the page does. Each5// tool calls the server; after a change the page refreshes to show it.6//7// WebMCP needs a secure context, so this only runs with the page opened at8// http://localhost (an SSH tunnel) or over HTTPS; over plain HTTP to the9// tailnet address it does nothing, and agents use /admin/mcp directly.10//11// vendor/mcp-b (MIT, see its LICENSE): @mcp-b/global 5.1.0's12// dist/index.iife.js, which installs document.modelContext, and13// @mcp-b/webmcp-local-relay 5.1.0's dist/browser, which hands the tools to14// `npx @mcp-b/webmcp-local-relay` on 127.0.0.1:9333 when one runs.15'use strict';1617(() => {18 const MCP = '/admin/mcp';19 const VENDOR = 'vendor/mcp-b/';20 const RELAY = { 'data-relay-host': '127.0.0.1', 'data-relay-port': '9333', 'data-request-timeout': '120000' };2122 let seq = 0;23 async function rpc(method, params) {24 const res = await fetch(MCP, {25 method: 'POST',26 headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', 'X-Airwaves-Via': 'webmcp' },27 body: JSON.stringify({ jsonrpc: '2.0', id: ++seq, method, params }),28 });29 const text = await res.text();30 let msg = null;31 try { msg = JSON.parse(text); } catch { msg = null; }32 if (!res.ok || !msg) throw new Error(`${method}: ${res.status} ${text.trim().slice(0, 200)}`);33 if (msg.error) throw new Error(`${method}: ${msg.error.message}`);34 return msg.result;35 }3637 function load(src, attrs = {}) {38 return new Promise((resolve, reject) => {39 const s = document.createElement('script');40 s.src = src;41 for (const [k, v] of Object.entries(attrs)) s.setAttribute(k, v);42 s.onload = resolve;43 s.onerror = () => reject(new Error(`couldn't load ${src}`));44 document.head.append(s);45 });46 }4748 // refreshPage has the page take in a change an agent made (admin.js's49 // airwavesAdmin.refresh), following the open channel when the change gave50 // it a new number: from is the number the tool was given, to the one the51 // channel has after (in the tool's result).52 function refreshPage(args, result) {53 let out = null;54 try { out = JSON.parse(((result && result.content) || [])[0].text); } catch { out = null; }55 const ch = out && out.channel;56 const hint = {57 from: args && args.number != null ? String(args.number) : undefined,58 to: ch && ch.number ? String(ch.number) : undefined,59 };60 if (window.airwavesAdmin) window.airwavesAdmin.refresh(hint);61 }6263 async function start() {64 if (!window.isSecureContext) {65 console.info(`[airwaves] WebMCP is off: it needs the page at http://localhost or over HTTPS. Agents can use ${location.origin}${MCP} directly.`);66 return;67 }68 // Never for a page that frames this one.69 if (window.top !== window) return;70 // Only this window may call the tools, never a frame's parent.71 window.__webModelContextOptions = { transport: { iframeServer: false, tabServer: { allowedOrigins: [location.origin] } } };72 await load(VENDOR + 'global.iife.js');73 const ctx = document.modelContext;74 if (!ctx || typeof ctx.registerTool !== 'function') throw new Error('document.modelContext is missing');7576 const tools = [];77 let cursor;78 do {79 const page = await rpc('tools/list', cursor ? { cursor } : {});80 tools.push(...page.tools);81 cursor = page.nextCursor;82 } while (cursor);83 if (!tools.length) return;84 for (const t of tools) {85 const changes = !(t.annotations && t.annotations.readOnlyHint);86 await ctx.registerTool({87 name: t.name,88 title: t.title,89 description: t.description,90 inputSchema: t.inputSchema,91 annotations: t.annotations,92 execute: async (args) => {93 try {94 const result = await rpc('tools/call', { name: t.name, arguments: args || {} });95 if (changes && !result.isError) refreshPage(args, result);96 return result;97 } catch (e) {98 return { content: [{ type: 'text', text: `${t.name} failed: ${e.message || e}` }], isError: true };99 }100 },101 });102 }103 console.info(`[airwaves] ${tools.length} WebMCP tools registered`);104 await load(VENDOR + 'webmcp-local-relay/embed.js', RELAY);105 }106107 start().catch((e) => console.warn('[airwaves] WebMCP tools not registered:', e.message || e));108})();