Repository navigation
Add xwebmcp npm package for React and Next.js sites - #113
Conversation
f85eb65 to
c5cbd73
Compare
masnwilliams
left a comment
There was a problem hiding this comment.
approving. the approach is right: hooks live in the components that own the action, the provider is built from what's mounted, and the 'use client' entries import the core by package name, so all three share one registry and one React context. merging is safe because publishing is a manual dispatch and kernel/kernel vendors the tarball. the first four items below should land before the first npm publish, though, since changing them after that breaks users.
before first publish
-
duplicate-name precedence doesn't match the docs, and each registry resolves duplicates its own way. the README (
js/README.md:95-97,:166-168) and theWebMCPPageContextJSDoc say the most recently mounted entry wins. every hook re-registers as cleanup then add, though, so in practice whichever entry changed most recently wins:- tools: two components register
t. if the first-mounted one's description changes, it takes over. - page contexts: same behavior. updating the first-mounted
<WebMCPPageContext id="page">makes it win. - routes: these do the opposite.
setRoutekeeps the first declaration (react.tsx:81-84) until that component re-renders with a new description, and then the other copy wins.
with a desktop and a mobile nav mounting the same items, the description an agent sees depends on render order. pick one rule, implement it once, and document it.
- tools: two components register
-
detection starts polling again on every registration after the 5s window (
index.ts:98-111).detectinggoes back tofalseat the deadline, so on a browser without WebMCP each laterregisterToolstarts another 5s/250ms interval and logs theconsole.infoagain. that happens on every navigation that mounts tools. the README (:269-271) says it only checks after the first registration. a single state like'polling' | 'absent' | ModelContextwould fix it: once the deadline passes, the state stays'absent'untilrefresh()is called. -
get_page_contextreturnstitle, while everydomains/*/get_page_context.jsreturnspage_title(react.tsx:184). an agent moving between registry sites and sites using this package gets two shapes for the same tool name. better to align them now. -
the built-in tools have no
outputSchema(react.tsx:236-283). the repo's tool-authoring skill asks for a typed output with explicit success and error branches.navigate_to_pagecurrently returns three ad-hoc shapes inline (navigated,timeout,error). aNavigateResultunion plus schemas on both tools would cover it. moving theexecutebody out of the component, next toreadPageContext, would also make the provider easier to read.
smaller
registerToolremoves by object identity (index.ts:126). if the same tool object is registered twice and one signal aborts, the tool is unregistered even though the other signal is still live. the hooks always create a new object so they can't hit this, but direct callers of the core API can. wrapping each registration in its own entry fixes it.executeRef.current = executeruns during render (react.tsx:28). if React throws that render away, an agent can call a closure that never committed. assigning it inuseInsertionEffect/useLayoutEffectavoids that.useMemo(() => new PageStore(), [])(react.tsx:232) should beuseState(() => new PageStore())[0].useMemodoesn't guarantee it keeps the value.
optional: one registry instead of three
the tool stacks in index.ts and the routes and contexts in PageStore are the same structure written three times, and that's how #1 happened. a single Registry<T> would give one precedence rule and fix the identity bug. it would also let PageStore, the route dedup loop, both useId keys and two of the three listener sets go away. it would hold a stack per key with newest winning, a cached snapshot, and subscribe. routes would be keyed by path and contexts by id. fine to do this as a follow-up and just fix the rules in place for now.
export class Registry<T> {
private stacks = new Map<string, Array<{ value: T }>>();
private listeners = new Set<(key: string) => void>();
private snapshot: T[] = [];
add(key: string, value: T, signal: AbortSignal): void {
if (signal.aborted) return;
const entry = { value };
this.stacks.set(key, [...(this.stacks.get(key) ?? []), entry]);
signal.addEventListener('abort', () => {
const rest = this.stacks.get(key)!.filter((e) => e !== entry);
if (rest.length) this.stacks.set(key, rest);
else this.stacks.delete(key);
this.emit(key);
}, { once: true });
this.emit(key);
}
top = (key: string) => this.stacks.get(key)?.at(-1)?.value;
values = () => this.snapshot;
subscribe = (listener: (key: string) => void) => {
this.listeners.add(listener);
return () => void this.listeners.delete(listener);
};
private emit(key: string) {
this.snapshot = [...this.stacks.values()].map((s) => s.at(-1)!.value);
for (const l of this.listeners) l(key);
}
}questions
next >= 14peer range: the acceptance run was on Next 16. has 14 or 15 been tried? if not, I'd set the floor to what's been tested.- detecting navigation by polling the URL: this works in the kernel/kernel#4854 run. is there a case where the App Router commits the new page but
locationlags long enough to hit the timeout, or where a redirect lands back on the same path? both would come back astimeouteven though the page changed. - tests: worth adding coverage for #1 and #2 and the identity case, since those are the things the README makes promises about.
… re-polling for WebMCP after the detection window
- Registry<T> holds every registration per key in mount order and exposes the
newest; registerTool returns { update } so a changed definition keeps its
place. Routes (by path), page context entries (by id) and tools (by name)
share it, and the hooks hold one registration per mount.
- Detection moves to a single state; once the window passes it stays absent
until refresh().
- get_page_context returns page_title, matching the registry's site tools,
and both built-in tools publish an outputSchema.
- navigate_to_page's body moves out of the provider; a timeout still waits
for registrations to settle before reading the page.
- executeRef is assigned in an insertion effect; the provider store comes
from useState.
|
Thanks. All four pre-publish items plus the smaller ones are in
Smaller: registrations are tracked as entries, so the same tool object under two signals survives one aborting (test added); Questions
kernel/kernel#4854 vendors the rebuilt tarball from this commit. |
masnwilliams
left a comment
There was a problem hiding this comment.
lgtm. checked out a36d680: 21 tests, typecheck and build pass, and the new tests cover the exact cases from the first review (older tool changes, desktop nav changes while the mobile copy is mounted, layout-level page context changes, same object under two signals, no re-poll after the window).
the single Registry<T> with in-place update reads well, and "most recently mounted wins" now holds for tools, routes and context. detection state, page_title, the output schemas and NavigateResult all look right. thanks for testing 14.2/15.5 and documenting the same-path redirect gap.
one non-blocking nit: Registry is now a public export of xwebmcp because the react entry imports it by package name, so it lands in the npm API surface. an @internal tag in its JSDoc would keep users from depending on it before 0.1.0 goes out.
|
Done in |
Adds
js/, an npm package (xwebmcp) that lets a React or Next.js app expose WebMCP tools from the components that own them, instead of from a central list.API
xwebmcp:registerTool(tool, { signal })(returns{ update }),refresh(),getModelContext(),availableToolNames(),subscribe(), andRegistry<T>. Does nothing unless the page hasdocument.modelContextornavigator.modelContext; it polls for 5s after the first registration so a late extension or polyfill is picked up, then stays inert untilrefresh()is called.Registry<T>keeps every registration per key in the order it was added and exposes the newest;updatechanges a registration in place without reordering, and when the newest goes away the previous one takes over. Tools are keyed by name, routes by path and page context entries by id, and each hook holds one registration per mount, so "most recently mounted wins" holds regardless of render order.xwebmcp/react:useWebMCPTool({ ...tool, enabled }),useWebMCPRoute({ path, description, enabled }),<WebMCPPageContext id value suggestedNextSteps>, and<WebMCPPageProvider navigate>for non-Next routers.xwebmcp/nextjs:<WebMCPProvider>for the App Router (navigates withrouter.push), plus re-exports of the above.The provider registers two tools:
get_page_contextreturns the URL,page_title(the registry's field name), every tool on the page (viagetTools()when available, so it also lists tools registered without this package), the merged<WebMCPPageContext>values, and suggested next steps.navigate_to_page: its description lists the routes declared by mounteduseWebMCPRoutecalls. It navigates client-side, waits for the URL to change and registrations to settle, and returns the new page's context and tools. Both tools publish anoutputSchema;navigate_to_page's result is theNavigateResultunion (navigated/timeoutwith the page context, orerror).reactandnextjsare built with'use client'and import the core by package name, so all three entries share one registry and one React context.Testing
npm run typecheck,npm test(21 tests: inert/late detection and no re-polling after the window, precedence for tools/routes/contexts including an update to the older registration, registration identity, StrictMode, page context, routes incl.enabledand dedup across sidebars, navigation, cross-origin, timeout),npm run build. Added as ajsjob invalidate.yml.next buildof a minimal App Router app using all three entry points passes on Next 14.2 (React 18) and 15.5 (React 19), so thenext >= 14peer range is covered.kernel browsers webmcp list/invoke:<WebMCPProvider>in the root layout;useWebMCPRoutein the sidebar nav item (locked/disabled items opt out withenabled);<WebMCPPageContext>and akill_sessiontool on the browser session page, sharing one function with the confirm dialog (dialog-only choices become explicit tool inputs);sign_in_start({ email })/sign_in_verify({ code })on the sign-in page via Clerk's email code flow.navigate_to_pageto the sessions list and then to a session page (about 3-4s each, returning the new page's tools and context),kill_sessiondeleted a real session andget_page_contextreflecteddeletedafterrouter.refresh().executereaches the agent as the error message.Publishing
The package is MIT (
js/LICENSE; the rest of the repo is unchanged).publish-js.ymlruns on manual dispatch frommainand publishes with npm trusted publishing (OIDC), with no npm token stored. Nothing has been published yet.npm only lets you add a trusted publisher to a package that already exists, so the first version is published by hand:
cd js && npm ci && npm run build && npm publishfrom a logged-in npm account.xwebmcp: GitHub Actions,kernel/xwebmcp, workflowpublish-js.yml, with "npm publish" allowed.Provenance attestations won't be generated while the repo is private (an npm limitation).