Files
awesome-copilot/skills/webmcpify/references/runtime.md
T
github-actions[bot] 38eb0a34b0 chore: publish from main
2026-07-23 17:56:34 +00:00

7.2 KiB

Runtime — vendoring and wiring the templates

Copy from this skill's templates/ directory into the target project (suggested: src/webmcp/):

  • TypeScript projects: templates/webmcpify.ts + templates/webmcp.d.ts; React TSX projects additionally templates/webmcp-jsx.d.ts (JSX typings for the declarative attributes — a MODULE file; keep it separate from webmcp.d.ts, which must stay a global script file).
  • JavaScript projects: templates/webmcpify.jsES module only (export): load via a bundler or <script type="module">. For CommonJS/classic-script projects, transpile or vendor the TS variant instead.

Vendor, don't depend — the runtime is small, MIT, and a target repo must not gain a dependency for an origin-trial API. Keep the full MIT notice header in every copied file: the license's retention condition requires the copyright line and permission notice to travel with the code, and the header IS that notice — never trim it down to a bare link. Record the copied file paths in the manifest (pipeline.setup.runtimeVendored: ["src/webmcp/webmcpify.ts", ...]).

What it provides:

Export Purpose
getModelContext() The ONLY place document.modelContext / deprecated navigator.modelContext is referenced — spec churn stays a one-file fix
isWebMCPAvailable() Feature detection — the app must work identically without WebMCP
createToolScope(key, tools, options?) Registers a tool set under one AbortController; returns a callable dispose handle carrying ready: Promise<boolean> (true = all registrations committed; false = no WebMCP / duplicate key / failure / disposed first — never rejects). Validates contracts BEFORE registering; rolls back the whole scope on any failure, including sync-throwing legacy registerTool (reported via options.onError, default console.error — NOT called when disposed before settling). An already-active key returns a no-op handle — safe under React StrictMode
dispatchAndWait(event, detail?, timeoutMs?) Bridges execute() to the app's own event/state flow. The dispatched detail carries requestId plus signal — an AbortSignal aborted on timeout; pass it to fetch() and skip state commits once aborted. Resolves only after the component confirms with an explicit boolean ok; a completion with missing/non-boolean ok fails closed to an "ERROR: ..." string, as do timeouts and ok: false (self-correction convention — never rejects). For tools whose confirmation involves a network round-trip (mailers, slow APIs), pass an explicit timeoutMs (e.g. 20_000) instead of relying on the 10 s default
singleFlight(fn, busyMessage?) Serializes a tool's execute: while one call is in flight, further calls resolve immediately to a busy "ERROR: ..." string instead of racing shared UI state

Validation note: budget checks auto-enable when the bundler substitutes process.env.NODE_ENV (Vite/webpack automatic; esbuild via --define) and it isn't 'production'; unbundled projects default to off — pass { validate: true } during development.

The completion contract (the part integrators get wrong)

dispatchAndWait resolves when the component fires tool-completion-<requestId> with detail: { ok: boolean, message?: string, error?: string }. ok must be an explicit boolean — anything else fails closed to an ERROR result. Fire it after the async work has truly finished — awaited fetch, committed state, rendered result — never right after starting the action. Agents plan from what is on screen; a completion fired early produces false greens.

Hardened component bridge (React example — adapt per framework). Five clauses: (1) completion fires from an effect observing the committed state, (2) availability gate, (3) single-flight, (4) timeout coordination via detail.signal, (5) unmount cancellation.

const pending = useRef<{ requestId: string; count: number } | null>(null);
const [results, setResults] = useState<Ticket[] | null>(null);

useEffect(() => {
  if (!isWebMCPAvailable()) return;                       // (2) attach only when WebMCP exists
  let inFlight = false;
  const onSearch = async (e: Event) => {
    const { query, requestId, signal } = (e as CustomEvent).detail;
    const fail = (error: string) =>
      window.dispatchEvent(new CustomEvent(`tool-completion-${requestId}`, {
        detail: { ok: false, error },
      }));
    if (inFlight) return fail('A search is already running.'); // (3) single-flight
    inFlight = true;
    try {
      const found = await runSearch(query, { signal });   // (4) the runtime aborts this signal on timeout
      if (signal?.aborted) return;                        // (4) timed out — runtime already answered; no late commits
      pending.current = { requestId, count: found.length };
      setResults(found);                                  // commit → the effect below confirms
    } catch (err) {
      if (signal?.aborted) return;
      fail(err instanceof Error ? err.message : 'Search failed.');
    } finally {
      inFlight = false;
    }
  };
  window.addEventListener('webmcp:search_tickets', onSearch);
  return () => window.removeEventListener('webmcp:search_tickets', onSearch); // (5) unmount detaches
}, []);

useEffect(() => {
  if (!pending.current || results === null) return;       // (1) confirm AFTER the commit rendered
  const { requestId, count } = pending.current;
  pending.current = null;
  window.dispatchEvent(new CustomEvent(`tool-completion-${requestId}`, {
    detail: { ok: true, message: `Search finished — ${count} results are now visible.` },
  }));
}, [results]);

Why clause (1): React 18 batches renders — state set after the await is not yet on screen when the next line of the handler runs, so dispatching the completion there reports success before the user (and the agent's next snapshot) can see it. Dispatching from an effect keyed on the updated state guarantees the commit happened. Equivalents: Vue await nextTick(); Svelte await tick() — then dispatch inline.

Wiring patterns

// bootstrap (app-wide tools, static registration — the default):
import { createToolScope } from './webmcp/webmcpify';
import { appTools } from './webmcp/tools';
createToolScope('app', appTools);

// per-view tools (only when genuinely view-bound):
useEffect(() => createToolScope('tickets-view', ticketViewTools), []);
// the handle IS the dispose fn → React runs it on unmount. StrictMode's
// double-mount is safe: the second call no-ops, an unmount before registration
// settles rolls back silently (ready → false, no onError).

// when you need to know registration committed:
const handle = createToolScope('app', appTools);
handle.ready.then((ok) => { if (!ok) console.warn('WebMCP tools not active'); });

Role-scoped SaaS registration — dispose and re-create on auth changes:

let dispose: (() => void) | undefined;
export function syncToolsForUser(user: User | null) {
  dispose?.();
  const tools = [...publicTools, ...(user ? memberTools : []),
                 ...(user?.role === 'admin' ? adminTools : [])];
  dispose = createToolScope('auth-scoped', tools);
}
// call on login, logout, role change, tenant switch