agents
Version:
A home for your AI agents
298 lines (297 loc) • 9.82 kB
JavaScript
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