1// WebMCP: expose the /mcp tools to an agent driving this browser tab2// (navigator.modelContext). The surface is mirrored, not duplicated: on3// load the island asks /mcp for tools/list and registers each tool with4// an execute that proxies tools/call, so whatever the server serves5// (the daily host hides start_mosaic) is what the page exposes.6import { useEffect } from "preact/hooks";78type McpResult = {9 content?: { type: string; text?: string }[];10 structuredContent?: Record<string, unknown>;11 isError?: boolean;12};1314type ListedTool = {15 name: string;16 description?: string;17 inputSchema?: Record<string, unknown>;18};1920type ModelContext = {21 registerTool?: (tool: unknown) => void;22 provideContext?: (ctx: { tools: unknown[] }) => void;23};2425let nextId = 1;26async function rpc(method: string, params: unknown): Promise<unknown> {27 const res = await fetch("/mcp", {28 method: "POST",29 headers: {30 "Content-Type": "application/json",31 "Accept": "application/json, text/event-stream",32 "MCP-Protocol-Version": "2025-06-18",33 },34 body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }),35 });36 const text = await res.text();37 if (!res.ok) {38 throw new Error(`${method}: HTTP ${res.status} ${text.slice(0, 300)}`);39 }40 const msg = JSON.parse(unframe(text));41 if (msg.error) throw new Error(`${method}: ${JSON.stringify(msg.error)}`);42 return msg.result;43}4445/** The server may answer as JSON or as one SSE frame; take the last data line. */46function unframe(text: string): string {47 const t = text.trimStart();48 if (!t.startsWith("event:") && !t.startsWith("data:") && !t.startsWith(":")) {49 return text;50 }51 const data = text.split("\n").filter((l) => l.startsWith("data:")).map((l) =>52 l.slice(5).trim()53 );54 return data.at(-1) ?? "";55}5657function toWebTool(t: ListedTool) {58 return {59 name: t.name,60 description: t.description ?? "",61 inputSchema: t.inputSchema ?? { type: "object", properties: {} },62 async execute(63 args: Record<string, unknown> | undefined,64 ): Promise<McpResult> {65 const a = { ...(args ?? {}) };66 // a browser-driven join has no transport identity; say where it came from67 if (t.name === "join" && a.harness === undefined) a.harness = "webmcp";68 try {69 return await rpc("tools/call", {70 name: t.name,71 arguments: a,72 }) as McpResult;73 } catch (error) {74 const message = error instanceof Error ? error.message : String(error);75 return { isError: true, content: [{ type: "text", text: message }] };76 }77 },78 };79}8081export default function WebMcp() {82 useEffect(() => {83 let cancelled = false;84 const register = async (mc: ModelContext) => {85 try {86 const r = await rpc("tools/list", {}) as { tools?: ListedTool[] };87 if (cancelled || !r.tools?.length) return;88 const tools = r.tools.map(toWebTool);89 if (typeof mc.registerTool === "function") {90 for (const tool of tools) mc.registerTool(tool);91 } else if (typeof mc.provideContext === "function") {92 mc.provideContext({ tools });93 }94 } catch (error) {95 console.warn("webmcp: tools not exposed:", error);96 }97 };98 // a built-in host has the API at load; an extension host may inject99 // it after hydration, so look for a late arrival for a while100 let tries = 0;101 const look = () => {102 const mc = (navigator as { modelContext?: ModelContext }).modelContext;103 if (mc) {104 clearInterval(timer);105 register(mc);106 } else if (++tries >= 60) {107 clearInterval(timer);108 }109 };110 const timer = setInterval(look, 500);111 look();112 return () => {113 cancelled = true;114 clearInterval(timer);115 };116 }, []);117 return null;118}