UNPKG

agents

Version:

A home for your AI agents

298 lines (297 loc) 9.82 kB
import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; //#region src/experimental/webmcp.ts /** * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! * !! WARNING: EXPERIMENTAL — DO NOT USE IN PRODUCTION !! * !! !! * !! This API is under active development and WILL break between !! * !! releases. Google's WebMCP API (navigator.modelContext) is still !! * !! in early preview and subject to change. !! * !! !! * !! If you use this, pin your agents version and expect to rewrite !! * !! your code when upgrading. !! * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! * * WebMCP adapter for Cloudflare Agents SDK. * * Bridges tools registered on an McpAgent server to Chrome's native * navigator.modelContext API, so browser-native agents can discover * and call them without extra infrastructure. * * @example Bridge a remote McpAgent endpoint into the page * ```ts * import { registerWebMcp } from "agents/experimental/webmcp"; * * const handle = await registerWebMcp({ url: "/mcp" }); * * // Later, to clean up: * await handle.dispose(); * ``` * * @example Mix in-page tools with bridged tools (recommended pattern) * ```ts * import { registerWebMcp } from "agents/experimental/webmcp"; * * // 1. Register page-local tools — things only the page can do * navigator.modelContext?.registerTool({ * name: "scroll_to_section", * description: "Scroll the page to a named section", * inputSchema: { * type: "object", * properties: { id: { type: "string" } }, * required: ["id"] * }, * async execute({ id }) { * document.getElementById(String(id))?.scrollIntoView({ behavior: "smooth" }); * return "ok"; * } * }); * * // 2. Bridge server tools — things that need durable storage / auth / DB access * const handle = await registerWebMcp({ * url: "/mcp", * prefix: "remote.", // optional namespace to avoid collisions * getHeaders: async () => ({ Authorization: `Bearer ${await getToken()}` }) * }); * * // The browser AI sees both kinds of tools side by side. * ``` * * @experimental This API is not yet stable and may change. */ const DEFAULT_LOGGER = { info: (...args) => console.info("[webmcp-adapter]", ...args), warn: (...args) => console.warn("[webmcp-adapter]", ...args), error: (...args) => console.error("[webmcp-adapter]", ...args) }; const SILENT_LOGGER = { info: () => {}, warn: () => {}, error: () => {} }; var McpHttpClient = class { constructor(url, headers, getHeaders, timeoutMs) { const resolvedUrl = new URL(url, globalThis.location?.origin); this._timeoutMs = timeoutMs; const transportOptions = { requestInit: { headers: headers ?? {} } }; if (getHeaders) transportOptions.fetch = async (input, init) => { const dynamic = await getHeaders(); const merged = new Headers(init?.headers); for (const [k, v] of Object.entries(dynamic)) merged.set(k, v); return globalThis.fetch(input, { ...init, headers: merged }); }; this._transport = new StreamableHTTPClientTransport(resolvedUrl, transportOptions); this._client = new Client({ name: "webmcp-adapter", version: "0.1.0" }, { capabilities: {} }); } async initialize(signal) { await this._client.connect(this._transport); this._client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { if (signal?.aborted) return; this._onToolsChanged?.(); }); } async listTools(signal) { const allTools = []; let cursor; do { if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); const result = await this._client.listTools(cursor ? { cursor } : void 0, { signal, timeout: this._timeoutMs }); for (const t of result.tools) allTools.push({ name: t.name, description: t.description, inputSchema: t.inputSchema, annotations: t.annotations ? { readOnlyHint: t.annotations.readOnlyHint } : void 0 }); cursor = result.nextCursor; } while (cursor); return allTools; } async callTool(name, args, signal) { const result = await this._client.callTool({ name, arguments: args }, void 0, { signal, timeout: this._timeoutMs }); if ("content" in result) return { content: result.content.map((c) => ({ type: c.type, text: "text" in c ? c.text : void 0, data: "data" in c ? c.data : void 0, mimeType: "mimeType" in c ? c.mimeType : void 0 })), isError: "isError" in result ? result.isError : false }; return { content: [], isError: false }; } listenForChanges(onToolsChanged) { this._onToolsChanged = onToolsChanged; } async close() { try { await this._client.close(); } catch {} } }; /** * Discovers tools from a Cloudflare McpAgent endpoint and registers them * with Chrome's native `navigator.modelContext` API. * * On browsers without `navigator.modelContext` (everything except recent * Chrome with the relevant flags), this function is a no-op and returns a * handle with an empty tools array. No network request is made. * * @example * ```ts * import { registerWebMcp } from "agents/experimental/webmcp"; * * const handle = await registerWebMcp({ url: "/mcp" }); * console.log("Registered tools:", handle.tools); * * // Clean up when done (e.g. in a React effect cleanup) * await handle.dispose(); * ``` * * See the JSDoc on the module itself for the recommended "in-page tools + * remote tools" composition pattern. */ async function registerWebMcp(options) { const { url, headers, getHeaders, watch = true, prefix = "", timeoutMs, logger: userLogger, quiet = false, onSync, onError } = options; const logger = quiet ? SILENT_LOGGER : userLogger ?? DEFAULT_LOGGER; const registeredTools = []; const toolControllers = /* @__PURE__ */ new Map(); const lifecycleController = new AbortController(); let disposed = false; let inflightSync = null; if (!navigator.modelContext) { logger.info("navigator.modelContext not available — skipping registration. This is expected on non-Chrome browsers."); onSync?.([]); return { get tools() { return []; }, get disposed() { return disposed; }, refresh: async () => {}, dispose: async () => { disposed = true; } }; } const modelContext = navigator.modelContext; const client = new McpHttpClient(url, headers, getHeaders, timeoutMs); function unregisterAll() { for (const controller of toolControllers.values()) controller.abort(); toolControllers.clear(); registeredTools.length = 0; } function registerTools(tools) { for (const tool of tools) { const registeredName = `${prefix}${tool.name}`; const toolDef = { name: registeredName, description: tool.description ?? tool.name, ...tool.inputSchema ? { inputSchema: tool.inputSchema } : {}, ...tool.annotations ? { annotations: { readOnlyHint: tool.annotations.readOnlyHint } } : {}, execute: async (input) => { if (disposed) throw new Error("WebMCP adapter has been disposed"); const result = await client.callTool(tool.name, input, lifecycleController.signal); if (result.isError) { const errorText = result.content.map((c) => c.text ?? "").join("\n"); throw new Error(errorText || "Tool execution failed"); } const parts = []; let sawUnsupported = false; for (const c of result.content) if (c.type === "text" && c.text) parts.push(c.text); else if (c.type === "image" && c.data) parts.push(`data:${c.mimeType ?? "image/png"};base64,${c.data}`); else if (c.data) { parts.push(c.data); sawUnsupported = true; } else sawUnsupported = true; if (sawUnsupported) logger.warn(`Tool "${tool.name}" returned content type(s) the adapter cannot fully represent as a string.`); return parts.join("\n"); } }; try { const controller = new AbortController(); modelContext.registerTool(toolDef, { signal: controller.signal }); toolControllers.set(registeredName, controller); registeredTools.push(registeredName); } catch (err) { logger.warn(`Failed to register tool "${registeredName}":`, err); } } } function syncTools() { if (disposed) return Promise.resolve(); if (inflightSync) return inflightSync; inflightSync = (async () => { try { const tools = await client.listTools(lifecycleController.signal); if (disposed) return; unregisterAll(); registerTools(tools); onSync?.(tools); } finally { inflightSync = null; } })(); return inflightSync; } try { await client.initialize(lifecycleController.signal); await syncTools(); if (watch) client.listenForChanges(() => { if (disposed) return; syncTools().catch((err) => { if (disposed) return; const error = err instanceof Error ? err : new Error(String(err)); logger.warn("Watch-mode sync failed:", error); onError?.(error); }); }); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); logger.error("Initialization failed:", error); await client.close(); throw error; } return { get tools() { return [...registeredTools]; }, get disposed() { return disposed; }, refresh: syncTools, async dispose() { if (disposed) return; disposed = true; lifecycleController.abort(); unregisterAll(); try { await inflightSync; } catch {} await client.close(); } }; } //#endregion export { registerWebMcp }; //# sourceMappingURL=webmcp.js.map