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>
| A | .claude/ | 12 | +++ |
| A | site/ | 118 | ++++++++++++++++++++++++++++++ |
| M | site/ | 2 | + |
@@ -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+}@@ -1,4 +1,5 @@
11 import { define } from "../utils.ts";2+import WebMcp from "../islands/WebMcp.tsx";23 34 export default define.page(function App({ Component }) {45 return (@@ -17,6 +18,7 @@ html,body{margin:0;height:100%;background:var(--bg);color:var(--text-strong);fon
1718 </style>1819 </head>1920 <body>21+ <WebMcp />2022 <Component />2123 </body>2224 </html>