UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

180 lines (163 loc) • 5.55 kB
"use client"; import { useCallback, useEffect, useInsertionEffect, useRef, useState, useSyncExternalStore, } from "react"; import { useResources, useTapRoot, withKey } from "@assistant-ui/tap"; import { useAui } from "@assistant-ui/store"; import type { Tool } from "assistant-stream"; import { getDefaultWebMcpHost, type WebMcpHost } from "./webmcp-host"; import { defaultWebMcpFilter, toWebMcpInputSchema } from "./convertTools"; import { WebMcpRegistrationResource } from "./WebMcpRegistrationResource"; import { shallowEqual } from "@assistant-ui/store/internal"; import { useModelContextSnapshot, type ModelContextSnapshotSource, } from "../useModelContextSnapshot"; /** @deprecated Experimental since 2026-08-29. Not scheduled for removal; the API may change in any release. */ export type Unstable_WebMcpProviderOptions = { filter?: (name: string, tool: Tool<any, any>) => boolean; }; /** @deprecated Experimental since 2026-08-29. Not scheduled for removal; the API may change in any release. */ export type Unstable_WebMcpProviderResult = { status: "unsupported" | "active"; registeredToolNames: readonly string[]; }; const EMPTY_NAMES: readonly string[] = Object.freeze([]); const EMPTY_TOOLS: Record<string, Tool<any, any>> = Object.freeze({}); // The description is re-read on every sync so mutating it in place is // observed; the schema is converted only when the tool object itself changes. // Keyed on the tool so a schema or filter that throws warns once instead of // on every model-context notify. const warned = new WeakSet<Tool<any, any>>(); const signatures = new WeakMap< Tool<any, any>, { description: string | undefined; signature: string } >(); const signatureOf = (tool: Tool<any, any>) => { const cached = signatures.get(tool); if (cached && cached.description === tool.description) return cached.signature; const signature = `${tool.description ?? ""}\u0000${JSON.stringify( toWebMcpInputSchema(tool), )}`; signatures.set(tool, { description: tool.description, signature }); return signature; }; const NO_SUBSCRIPTION = () => {}; // No `isEqual`: a caller republishing an unchanged tool set is how it asks for // a re-sync, which is what re-attempts a tool whose filter has stopped throwing // and what picks up a description edited in place on a stable tool object. const modelContextToolSource: ModelContextSnapshotSource< Record<string, Tool<any, any>> > = { empty: EMPTY_TOOLS, read: (aui) => aui.modelContext.getModelContext().tools ?? EMPTY_TOOLS, subscribe: (aui, onChange) => aui.modelContext.subscribe?.(onChange) ?? NO_SUBSCRIPTION, }; const useStableNames = (names: readonly (string | null)[]) => { const [cell] = useState(() => ({ names: EMPTY_NAMES })); const next = names.filter((name): name is string => name !== null).sort(); const previous = cell.names; if (shallowEqual(previous, next)) { return previous; } cell.names = next; return next; }; const useWebMcpRegistry = ({ host, filter, tools, getCurrentTool, }: { host: WebMcpHost; filter: (name: string, tool: Tool<any, any>) => boolean; tools: Record<string, Tool<any, any>>; getCurrentTool: (name: string) => Tool<any, any> | undefined; }) => { const elements = []; for (const [name, tool] of Object.entries(tools)) { try { if (!filter(name, tool)) continue; const signature = signatureOf(tool); elements.push( withKey( name, WebMcpRegistrationResource({ host, name, signature, tool, getCurrentTool, }), [host, name, signature, tool, getCurrentTool], ), ); } catch (error) { if (warned.has(tool)) continue; warned.add(tool); console.warn( `[assistant-ui] Skipping WebMCP registration for tool "${name}": filter or schema conversion failed.`, error, ); } } return useStableNames(useResources(elements)); }; /** * Publishes the frontend tools in the model context to a WebMCP-capable * browser, so the user's own browser agent can call them. * * Returns `status: "unsupported"` when the page exposes no * `document.modelContext` (or `navigator.modelContext`), and the sorted names * of the tools currently registered with the host. * * @deprecated Experimental since 2026-08-29. Not scheduled for removal; the API may change in any release. */ export const unstable_useWebMcpProvider = ( options: Unstable_WebMcpProviderOptions = {}, ): Unstable_WebMcpProviderResult => { const aui = useAui(); const [host] = useState(getDefaultWebMcpHost); const filter = options.filter ?? defaultWebMcpFilter; const tools = useModelContextSnapshot( aui, host.available, modelContextToolSource, ); const currentToolsRef = useRef(tools); useInsertionEffect(() => { currentToolsRef.current = tools; }, [tools]); const getCurrentTool = useCallback( (name: string) => currentToolsRef.current[name], [], ); const root = useTapRoot(function WebMcpProviderRoot() { return useWebMcpRegistry({ host, filter, tools, getCurrentTool, }); }); const registeredToolNames = useSyncExternalStore( root.subscribe, root.getValue, () => EMPTY_NAMES, ); const [published, setPublished] = useState(false); useEffect(() => { if (host.available) setPublished(true); }, [host]); return { status: published ? "active" : "unsupported", registeredToolNames, }; };