clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

webmcp: the page exposes the /mcp tools to an agent driving the tab

dami committed 149a446
An island mirrors tools/list onto navigator.modelContext and proxies
execute through tools/call, so the browser surface always matches the
server surface (the daily host still hides start_mosaic). Both host
shapes (registerTool, provideContext) are served; a late-injected host
is caught by a short poll; a browser join defaults harness=webmcp.
commit
149a4465e887aaca9625a3cdbb9bcc9ba4b180f3
parent
0898f96118c29558a30f16d807c48e8ed9a5ccc9
author
dami <gazey55@gmail.com>
3 files changed, +132 -0
A.claude/launch.json12+++
Asite/islands/WebMcp.tsx118++++++++++++++++++++++++++++++
Msite/routes/_app.tsx2+
A .claude/launch.json +12 -0
A site/islands/WebMcp.tsx +118 -0
@@ -0,0 +1,118 @@
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.6+import { useEffect } from "preact/hooks";7+8+type McpResult = {9+ content?: { type: string; text?: string }[];10+ structuredContent?: Record<string, unknown>;11+ isError?: boolean;12+};13+14+type ListedTool = {15+ name: string;16+ description?: string;17+ inputSchema?: Record<string, unknown>;18+};19+20+type ModelContext = {21+ registerTool?: (tool: unknown) => void;22+ provideContext?: (ctx: { tools: unknown[] }) => void;23+};24+25+let nextId = 1;26+async 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+}44+45+/** The server may answer as JSON or as one SSE frame; take the last data line. */46+function 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+}56+57+function 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+}80+81+export 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+}
M site/routes/_app.tsx +2 -0