Skip to content

Add xwebmcp npm package for React and Next.js sites - #113

Merged
rgarcia merged 10 commits into
mainfrom
hypeship/js-react-nextjs
Oct 27, 2026
Merged

rgarcia merged 10 commits into
mainfrom
hypeship/js-react-nextjs

Conversation

@rgarcia

@rgarcia rgarcia commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

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(), and Registry<T>. Does nothing unless the page has document.modelContext or navigator.modelContext; it polls for 5s after the first registration so a late extension or polyfill is picked up, then stays inert until refresh() is called.
  • One precedence rule, implemented once: Registry<T> keeps every registration per key in the order it was added and exposes the newest; update changes 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 with router.push), plus re-exports of the above.

The provider registers two tools:

  • get_page_context returns the URL, page_title (the registry's field name), every tool on the page (via getTools() 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 mounted useWebMCPRoute calls. 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 an outputSchema; navigate_to_page's result is the NavigateResult union (navigated / timeout with the page context, or error).

react and nextjs are 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. enabled and dedup across sidebars, navigation, cross-origin, timeout), npm run build. Added as a js job in validate.yml.
  • next build of a minimal App Router app using all three entry points passes on Next 14.2 (React 18) and 15.5 (React 19), so the next >= 14 peer range is covered.
  • Acceptance test against the Kernel dashboard, kernel/kernel#4854 (the reference integration; it also informed the README's Patterns section), (Next 16, React 19, Clerk) with the packed tarball, driven from a Kernel browser on Chrome's native WebMCP through kernel browsers webmcp list/invoke:
    • <WebMCPProvider> in the root layout; useWebMCPRoute in the sidebar nav item (locked/disabled items opt out with enabled); <WebMCPPageContext> and a kill_session tool 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.
    • Signed in with the two tools, navigate_to_page to the sessions list and then to a session page (about 3-4s each, returning the new page's tools and context), kill_session deleted a real session and get_page_context reflected deleted after router.refresh().
    • A thrown error in execute reaches the agent as the error message.

Publishing

The package is MIT (js/LICENSE; the rest of the repo is unchanged). publish-js.yml runs on manual dispatch from main and 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:

  1. After merge: cd js && npm ci && npm run build && npm publish from a logged-in npm account.
  2. On npmjs.com, add a trusted publisher to xwebmcp: GitHub Actions, kernel / xwebmcp, workflow publish-js.yml, with "npm publish" allowed.
  3. Within 2 days, bump the version and run publish js (an unused trusted-publisher config expires after 2 days).
  4. Then turn off token publishing for the package.

Provenance attestations won't be generated while the repo is private (an npm limitation).

@rgarcia
rgarcia force-pushed the hypeship/js-react-nextjs branch from f85eb65 to c5cbd73 Compare October 16, 2026 12:26
@rgarcia
rgarcia requested a review from masnwilliams October 16, 2026 16:01

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. 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 the WebMCPPageContext JSDoc 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. setRoute keeps 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.

  2. detection starts polling again on every registration after the 5s window (index.ts:98-111). detecting goes back to false at the deadline, so on a browser without WebMCP each later registerTool starts another 5s/250ms interval and logs the console.info again. 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' | ModelContext would fix it: once the deadline passes, the state stays 'absent' until refresh() is called.

  3. get_page_context returns title, while every domains/*/get_page_context.js returns page_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.

  4. 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_page currently returns three ad-hoc shapes inline (navigated, timeout, error). a NavigateResult union plus schemas on both tools would cover it. moving the execute body out of the component, next to readPageContext, would also make the provider easier to read.

smaller

  • registerTool removes 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 = execute runs during render (react.tsx:28). if React throws that render away, an agent can call a closure that never committed. assigning it in useInsertionEffect/useLayoutEffect avoids that.
  • useMemo(() => new PageStore(), []) (react.tsx:232) should be useState(() => new PageStore())[0]. useMemo doesn'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 >= 14 peer 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 location lags long enough to hit the timeout, or where a redirect lands back on the same path? both would come back as timeout even 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.
@rgarcia

rgarcia commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. All four pre-publish items plus the smaller ones are in a36d680:

  1. One precedence rule, implemented once. Your Registry<T> suggestion, taken now rather than as a follow-up, with one addition: add returns { update } so a registration can change in place without moving. Tools are keyed by name, routes by path, page context entries by id, and each hook (useRegistration) holds one registration for the life of its mount and applies definition changes through update. So "most recently mounted wins" is what happens regardless of render order: a desktop nav that re-renders with a new description doesn't take the slot back from the mobile copy. Tests cover the older-registration-changes case for all three, and the README states the rule once. PageStore, the route dedup loop, both useId keys and the extra listener sets are gone.
  2. Detection is a single state ('unchecked' | 'polling' | 'absent' | ModelContext). After the 5s window it stays absent until refresh(); later registrations don't start another interval or log again. Test added.
  3. title → page_title. The other fields (current_url, current_path, available_tools, suggested_next_steps) already match the registry tools that have them.
  4. outputSchema on both built-in tools, NavigateResult as a union (navigated / timeout with the page context, or error), and navigate_to_page's body moved out of the component next to readPageContext. A timeout now also waits for registrations to settle before reading the page, so the context it returns is current.

Smaller: registrations are tracked as entries, so the same tool object under two signals survives one aborting (test added); executeRef is assigned in useInsertionEffect (no SSR warning on React 18, unlike useLayoutEffect); the provider store comes from useState.

Questions

  • next >= 14: a minimal App Router app using all three entry points builds on Next 14.2 (React 18) and 15.5 (React 19), so the floor holds; noted in the PR body. The only Next API used is useRouter().push from next/navigation.
  • URL polling: the App Router commits the tree and then updates location in the same commit's layout effect, so a 50ms poll can't miss it; the subsequent settle() then waits for the new page's registrations. A redirect that lands back on the starting path is the real gap: the URL never changes, so it comes back as timeout, though the result still carries the current page context. Documented in the README rather than guessed at; if we want to detect it, watching for a registration change during the wait would be the signal.
  • Tests for Add united.com search_flights WebMCP tool #1, Add alaskaair.com search_flights WebMCP tool #2 and the identity case are in (21 total).

kernel/kernel#4854 vendors the rebuilt tarball from this commit.

@rgarcia
rgarcia requested a review from masnwilliams October 26, 2026 16:19

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@rgarcia

rgarcia commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Done in 3bac5dd: Registry is tagged @internal and stripInternal drops it from the published .d.ts (the runtime export stays, since xwebmcp/react needs it to share one registry). registerTool / refresh / subscribe remain the public way to register tools outside React, and the README no longer lists Registry.

@rgarcia
rgarcia merged commit 188ad0b into main Oct 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants