Files
backnotprop__plannotator/packages/ui/webmcp/useToolset.ts
Michael Ramos 6903d7a3dd feat(webmcp): expose plan review and annotate as WebMCP tools for browser agents (#1393)
Phase 1 of WebMCP support: a zero-dependency, feature-detected engine in packages/ui/webmcp plus a read-and-comment tool catalog for plan review and annotate (read_document, add_comments, update_comment, remove_comments, reveal, nudge_user, list_documents). No decision tools; the human approves. Zero footprint in browsers without document.modelContext (DOM, network, console, timers, and cookies identical to main), idle until called where the API exists, and never registered inside the annotate iframes. Adds an optional inReplyTo field on annotations for threaded replies. Client-only; no server changes.

AI-assisted (Claude) under maintainer direction.
2026-08-26 14:39:37 -07:00

75 lines
3.2 KiB
TypeScript

/**
* React hook: attach a named toolset to the document's registry.
*
* Zero footprint without WebMCP: `resolveModelContext` runs once per mount
* and, when it returns null, the hook builds nothing, registers nothing and
* owns no effect body. Handlers read through refs, so a re-render never
* touches `registerTool`; only `deps` (surface membership inputs) rebuild the
* tool list, and the registry reconciles by name so an unchanged descriptor
* merely swaps its handler in place.
*/
import { useEffect, useMemo, useRef } from 'react';
import { resolveModelContext, type ModelContextLike } from './modelContext';
import { getWebMcpPolicy } from './policy';
import { getRegistryFor, type ToolSpec, type ToolsetHooks } from './toolset';
export interface UseToolsetOptions {
/** Stable set id (one per surface). */
id: string;
/** Registers while true; false aborts every controller of the set. Default true. */
active?: boolean;
/** Builds the tool list; only invoked when a provider exists and the set is active. */
build: () => ToolSpec<never, unknown>[];
/** Inputs whose change should rebuild the list (surface, read-only, submitted, folder). */
deps: ReadonlyArray<unknown>;
hooks: ToolsetHooks;
/** Test seam: inject a fake ModelContext instead of resolving `document`. */
context?: ModelContextLike | null;
}
export interface UseToolsetResult {
/** `document.modelContext` exists in this browser. */
available: boolean;
/** Tools are currently attached. */
registered: boolean;
}
export function useToolset(options: UseToolsetOptions): UseToolsetResult {
const { id, active = true, build, deps, hooks, context } = options;
// One detection per mount; the entry point is created with the document
// and never appears later.
const ctx = useMemo(() => (context === undefined ? resolveModelContext() : context), [context]);
const enabled = !!ctx && getWebMcpPolicy().enabled && active;
const hooksRef = useRef(hooks);
hooksRef.current = hooks;
const stableHooks = useMemo<ToolsetHooks>(() => ({
buildNudges: (info) => hooksRef.current.buildNudges(info),
afterResponse: (response) => hooksRef.current.afterResponse?.(response),
}), []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const tools = useMemo(() => (enabled ? build() : null), [enabled, ...deps]);
// Two effects on purpose: a rebuilt tool list reconciles IN PLACE (the
// registry swaps handlers for unchanged descriptors and only aborts names
// that disappeared), while unmount and `active: false` are the only paths
// that detach the whole set. A single effect keyed on `tools` would detach
// and re-register every tool on each rebuild.
const detachRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!ctx || !enabled || !tools) return;
const registry = getRegistryFor(ctx, { prefix: () => getWebMcpPolicy().namePrefix });
detachRef.current = registry.attach(id, tools, stableHooks);
}, [ctx, enabled, tools, id, stableHooks]);
useEffect(() => {
if (!ctx || !enabled) return;
return () => {
detachRef.current?.();
detachRef.current = null;
};
}, [ctx, enabled, id]);
return { available: !!ctx, registered: enabled && !!tools };
}