fix(agent-readiness): WebMCP uses registerTool + static import (#3316) (#3361)

* fix(agent-readiness): WebMCP uses registerTool + static import (#3316)

isitagentready.com reported "No WebMCP tools detected on page load"
on prod. Two compounding bugs in PR #3356:

1) API shape mismatch. Deployed code calls
   navigator.modelContext.provideContext({ tools }), but the scanner
   SKILL and shipping Chrome implementation use
   navigator.modelContext.registerTool(tool, { signal }) per tool with
   AbortController-driven teardown. The older provideContext form is
   kept as a fallback.

2) Dynamic-import timing. The webmcp module was lazy-loaded from a
   deep init phase, so the chunk resolved after the scanner probe
   window elapsed.

Fix:

- Rewrite registerWebMcpTools to prefer registerTool with an
  AbortController. provideContext becomes a legacy fallback. Returns
  the AbortController so teardown paths exist.
- Static-import webmcp in App.ts and call registerWebMcpTools
  synchronously at the start of init, before any await. Bindings
  close over lazy refs so throw-on-null guards still fire correctly
  when a tool is invoked later.

Test additions lock in registerTool-precedes-provideContext ordering,
AbortController pattern, static import, and call-before-first-await.

* fix(agent-readiness): WebMCP readiness wait + teardown on destroy (#3316)

Addresses three findings on PR #3361.

P1 — startup race. Early registration is required for scanner probes,
but a tool invoked during the window between register and Phase-4 UI
init threw "Search modal is not initialised yet." Both scanners and
agents that probe-and-invoke hit this. Bindings now await a uiReady
promise that resolves after searchManager.init and countryIntel.init.
A 10s timeout keeps a broken init from hanging the caller. After
readiness, a still-null target is a real failure and still throws.

Mechanics: App constructor builds uiReady as a Promise with its
resolve stored on the instance; Phase-4 end calls resolveUiReady;
waitForUiReady races uiReady against a timeout; both bindings await it.

P2 — AbortController was returned and dropped. registerWebMcpTools
returns a controller so callers can unregister on teardown, but App
discarded it. Stored on App now and aborted in destroy, so test
harnesses and SPA re-inits don't accumulate stale registrations.

P2 — test coverage. Added assertions for: bindings await
waitForUiReady before accessing state; resolveUiReady fires after
countryIntel.init; waitForUiReady uses Promise.race with a timeout;
destroy aborts the stored controller. Kept silent-success guard
assertions so bindings still throw when state is absent post-readiness.

Tests: 16 webmcp, 6682 full suite, all green.

* test(webmcp): tighten init()/destroy() regex anchoring (#3316)

Addresses P2 from PR #3361 review. The init() and destroy() body
captures used lazy `[\s\S]+?\n  }` which stops at the first
2-space-indent close brace. An intermediate `}` inside init (e.g.
some exotic scope block) would truncate the slice; the downstream
`.split(/\n\s+await\s/)` would then operate on a smaller string and
could let a refactor slip by without tripping the assertion.

Both regexes now end with a lookahead for the next class member
(`\n\n  (?:public|private) `), so the capture spans the whole method
body regardless of internal braces. If the next-member anchor ever
breaks, the match returns null and the `assert.ok` guard fails
loudly instead of silently accepting a short capture.

P1 (AbortController silently dropped) was already addressed in
f3bbd2170 — `this.webMcpController` is stored and destroy() aborts
it. Greptile reviewed the first push.
This commit is contained in:
Elie Habib
2026-04-24 08:21:07 +04:00
committed by GitHub
parent 38f7002f19
commit a409d5f79d
3 changed files with 230 additions and 60 deletions

View File

@@ -64,6 +64,7 @@ import { fetchBootstrapData, getBootstrapHydrationState, markBootstrapAsLive, ty
import { describeFreshness } from '@/services/persistent-cache';
import { DesktopUpdater } from '@/app/desktop-updater';
import { CountryIntelManager } from '@/app/country-intel';
import { registerWebMcpTools } from '@/services/webmcp';
import { SearchManager } from '@/app/search-manager';
import { RefreshScheduler } from '@/app/refresh-scheduler';
import { PanelLayoutManager } from '@/app/panel-layout';
@@ -112,6 +113,18 @@ export class App {
private modules: { destroy(): void }[] = [];
private unsubAiFlow: (() => void) | null = null;
private unsubFreeTier: (() => void) | null = null;
// Resolves once Phase-4 UI modules (searchManager, countryIntel) have
// initialised so WebMCP bindings can await readiness before touching
// the nullable UI targets. Avoids the startup race where an agent
// discovers a tool via early registerTool and invokes it before the
// target panel exists.
private uiReady!: Promise<void>;
private resolveUiReady!: () => void;
// Returned by registerWebMcpTools when running in a registerTool-capable
// browser — aborting it unregisters every tool. destroy() triggers it
// so that test harnesses / same-document re-inits don't accumulate
// duplicate registrations.
private webMcpController: AbortController | null = null;
private visiblePanelPrimed = new Set<string>();
private visiblePanelPrimeRaf: number | null = null;
private bootstrapHydrationState: BootstrapHydrationState = getBootstrapHydrationState();
@@ -417,6 +430,10 @@ export class App {
const el = document.getElementById(containerId);
if (!el) throw new Error(`Container ${containerId} not found`);
this.uiReady = new Promise<void>((resolve) => {
this.resolveUiReady = resolve;
});
const PANEL_ORDER_KEY = 'panel-order';
const PANEL_SPANS_KEY = 'worldmonitor-panel-spans';
@@ -803,6 +820,33 @@ export class App {
public async init(): Promise<void> {
const initStart = performance.now();
// WebMCP — register synchronously before any init awaits so agent
// scanners (isitagentready.com, in-browser agents) find the tools on
// their first probe. No-op in browsers without navigator.modelContext.
// Bindings await `this.uiReady` (resolves after Phase-4 UI init) so
// a tool invoked during the startup window waits for the target
// panel to exist instead of throwing. A 10s timeout keeps a genuinely
// broken state from hanging the caller. Store the returned controller
// so destroy() can unregister every tool on teardown.
this.webMcpController = registerWebMcpTools({
openCountryBriefByCode: async (code, country) => {
await this.waitForUiReady();
if (!this.state.countryBriefPage) {
throw new Error('Country brief panel is not initialised');
}
await this.countryIntel.openCountryBriefByCode(code, country);
},
resolveCountryName: (code) => CountryIntelManager.resolveCountryName(code),
openSearch: async () => {
await this.waitForUiReady();
if (!this.state.searchModal) {
throw new Error('Search modal is not initialised');
}
this.state.searchModal.open();
},
});
await initDB();
await initI18n();
const aiFlow = getAiFlowSettings();
@@ -1032,34 +1076,8 @@ export class App {
this.searchManager.init();
this.eventHandlers.setupMapLayerHandlers();
this.countryIntel.init();
// WebMCP — expose a small set of UI tools to in-page agents.
// Feature-detects `navigator.modelContext`; no-ops in browsers without it.
// Registered after search+country modules so the bound callbacks have real
// targets; tools intentionally route through the same paths a click takes
// so paywall/auth gates still apply to agent invocations.
//
// Bindings throw when a required UI target is missing so the tool's
// withInvocationLogging shim catches it and reports ok:false instead of
// a misleading "Opened …" success — the underlying UI methods silently
// no-op on null targets.
void import('@/services/webmcp').then(({ registerWebMcpTools }) => {
registerWebMcpTools({
openCountryBriefByCode: async (code, country) => {
if (!this.state.countryBriefPage) {
throw new Error('Country brief panel is not initialised yet');
}
await this.countryIntel.openCountryBriefByCode(code, country);
},
resolveCountryName: (code) => CountryIntelManager.resolveCountryName(code),
openSearch: () => {
if (!this.state.searchModal) {
throw new Error('Search modal is not initialised yet');
}
this.state.searchModal.open();
},
});
});
// Unblock any WebMCP tool invocations that arrived during startup.
this.resolveUiReady();
// Phase 5: Event listeners + URL sync
this.eventHandlers.init();
@@ -1212,6 +1230,31 @@ export class App {
this.cachedModeBannerEl = null;
this.state.map?.destroy();
disconnectAisStream();
// Unregister every WebMCP tool so a same-document re-init (tests,
// HMR, SPA harness) doesn't leave the browser with stale bindings
// pointing at a disposed App.
this.webMcpController?.abort();
this.webMcpController = null;
}
// Waits for Phase-4 UI modules (searchManager + countryIntel) to finish
// initialising. WebMCP bindings call this before touching nullable UI
// state so a tool invoked during startup waits rather than throwing;
// the timeout guards against a genuinely broken init path hanging the
// agent forever.
private async waitForUiReady(timeoutMs = 10_000): Promise<void> {
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`UI did not initialise within ${timeoutMs}ms`)),
timeoutMs,
);
});
try {
await Promise.race([this.uiReady, timeout]);
} finally {
if (timer !== null) clearTimeout(timer);
}
}
private handleDeepLinks(): void {

View File

@@ -1,11 +1,11 @@
// WebMCP — in-page agent tool surface.
//
// Registers a small set of tools via `navigator.modelContext.provideContext`
// so that browsers implementing the draft WebMCP spec
// (webmachinelearning.github.io/webmcp) can drive the site the same way a
// human does. Tools MUST route through existing UI code paths so agents
// inherit every auth/entitlement gate a browser user is subject to — they
// are not a backdoor around the paywall.
// Registers a small set of tools via `navigator.modelContext.registerTool`
// so browsers implementing the WebMCP spec as shipped in Chrome
// (developer.chrome.com/blog/webmcp-epp, webmachinelearning.github.io/webmcp)
// can drive the site the same way a human does. Tools MUST route through
// existing UI code paths so agents inherit every auth/entitlement gate a
// browser user is subject to — they are not a backdoor around the paywall.
//
// Current tools mirror the static Agent Skills set (#3310) for consistency:
// 1. openCountryBrief({ iso2 }) — opens the country deep-dive panel.
@@ -14,6 +14,11 @@
// The two v1 tools don't branch on auth state, so a single registration at
// init time is correct. Any future Pro-only tool MUST re-register on
// sign-in/sign-out (see feedback_reactive_listeners_must_be_symmetric.md).
//
// Scanner compatibility: isitagentready.com probes for
// `navigator.modelContext.registerTool` invocations during initial page load.
// Register synchronously from App.ts (no dynamic import, no init-phase
// awaits) so the probe finds the tools before it gives up.
import { track } from './analytics';
@@ -36,7 +41,11 @@ interface WebMcpTool {
}
interface WebMcpProvider {
provideContext(ctx: { tools: WebMcpTool[] }): void;
// Chrome-implemented form — one call per tool, unregistration via AbortSignal.
registerTool?: (tool: WebMcpTool, options?: { signal?: AbortSignal }) => void;
// Older editor-draft form — kept as a compatibility fallback for browsers
// shipping the batch-registration shape. Harmless no-op when absent.
provideContext?: (ctx: { tools: WebMcpTool[] }) => void;
}
interface NavigatorWithWebMcp extends Navigator {
@@ -46,7 +55,11 @@ interface NavigatorWithWebMcp extends Navigator {
export interface WebMcpAppBindings {
openCountryBriefByCode(code: string, country: string): Promise<void>;
resolveCountryName(code: string): string;
openSearch(): void;
// Returns a Promise because implementations may await a readiness signal
// (e.g. waiting for the search modal to exist during startup) before
// dispatching. Tool executes must `await` it so rejections surface to
// withInvocationLogging's catch path.
openSearch(): void | Promise<void>;
}
const ISO2 = /^[A-Z]{2}$/;
@@ -109,7 +122,7 @@ export function buildWebMcpTools(app: WebMcpAppBindings): WebMcpTool[] {
additionalProperties: false,
},
execute: withInvocationLogging('openSearch', async () => {
app.openSearch();
await app.openSearch();
return textResult('Opened search palette.');
}),
},
@@ -118,13 +131,31 @@ export function buildWebMcpTools(app: WebMcpAppBindings): WebMcpTool[] {
// Registers tools with the browser's WebMCP provider, if present.
// Safe to call on every load: no-op in browsers without `navigator.modelContext`.
// Returns true if registration actually happened (for tests / telemetry).
export function registerWebMcpTools(app: WebMcpAppBindings): boolean {
if (typeof navigator === 'undefined') return false;
// Returns an AbortController whose `.abort()` tears down every registration
// (for the `registerTool` path); null when WebMCP is absent or only the
// legacy `provideContext` form is available (no per-call teardown in that shape).
export function registerWebMcpTools(app: WebMcpAppBindings): AbortController | null {
if (typeof navigator === 'undefined') return null;
const provider = (navigator as NavigatorWithWebMcp).modelContext;
if (!provider || typeof provider.provideContext !== 'function') return false;
if (!provider) return null;
const tools = buildWebMcpTools(app);
provider.provideContext({ tools });
track('webmcp-registered', { toolCount: tools.length });
return true;
// Chrome-implemented form — preferred, and the shape isitagentready.com scans for.
if (typeof provider.registerTool === 'function') {
const controller = new AbortController();
for (const tool of tools) {
provider.registerTool(tool, { signal: controller.signal });
}
track('webmcp-registered', { toolCount: tools.length, api: 'registerTool' });
return controller;
}
// Older editor-draft form — batch registration, no per-call teardown.
if (typeof provider.provideContext === 'function') {
provider.provideContext({ tools });
track('webmcp-registered', { toolCount: tools.length, api: 'provideContext' });
}
return null;
}

View File

@@ -15,18 +15,29 @@ const WEBMCP_PATH = resolve(ROOT, 'src/services/webmcp.ts');
const src = readFileSync(WEBMCP_PATH, 'utf-8');
describe('webmcp.ts: draft-spec contract', () => {
it('feature-detects navigator.modelContext before calling provideContext', () => {
// The detection gate must run before any call. If a future refactor
// inverts the order, this regex stops matching and fails.
it('prefers registerTool (Chrome-implemented form) over provideContext (legacy)', () => {
// isitagentready.com scans for navigator.modelContext.registerTool calls.
// The registerTool branch must come first; provideContext is a legacy
// fallback. If a future refactor inverts order, the scanner will miss us.
const registerIdx = src.search(/typeof provider\.registerTool === 'function'/);
const provideIdx = src.search(/typeof provider\.provideContext === 'function'/);
assert.ok(registerIdx >= 0, 'registerTool branch missing');
assert.ok(provideIdx >= 0, 'provideContext fallback missing');
assert.ok(
registerIdx < provideIdx,
'registerTool must be checked before provideContext (Chrome-impl form is the primary target)',
);
});
it('uses AbortController for registerTool teardown (draft-spec pattern)', () => {
assert.match(
src,
/typeof provider\.provideContext !== 'function'\) return false[\s\S]+?provider\.provideContext\(/,
'feature detection must short-circuit before provideContext is invoked',
/const controller = new AbortController\(\)[\s\S]+?provider\.registerTool\(tool, \{ signal: controller\.signal \}\)/,
);
});
it('guards against non-browser runtimes (navigator undefined)', () => {
assert.match(src, /typeof navigator === 'undefined'\) return false/);
assert.match(src, /typeof navigator === 'undefined'\) return null/);
});
it('ships at least two tools (acceptance criterion: >=2 tools)', () => {
@@ -76,31 +87,116 @@ describe('webmcp.ts: tool behaviour (source-level invariants)', () => {
});
});
// App.ts wiring — guards against silent-success bugs where a binding
// forwards to a nullable UI target whose no-op the tool then falsely
// reports as success. Bindings MUST throw when the target is absent
// so withInvocationLogging's catch path can return isError:true.
describe('webmcp App.ts binding: guard against silent success', () => {
// App.ts wiring — guards against two classes of bug:
// (1) Silent success when a binding forwards to a nullable UI target.
// (2) Startup race when a tool is invoked during the window between
// early registration (needed for scanners) and Phase-4 UI init.
// Bindings await a readiness signal before touching UI state and fall
// through to a throw if the signal never resolves; withInvocationLogging
// converts that throw into isError:true.
describe('webmcp App.ts binding: readiness + teardown', () => {
const appSrc = readFileSync(resolve(ROOT, 'src/App.ts'), 'utf-8');
const bindingBlock = appSrc.match(
/registerWebMcpTools\(\{[\s\S]+?\}\);\s*\}\);/,
/registerWebMcpTools\(\{[\s\S]+?\}\);/,
);
it('the WebMCP binding block exists in App.ts init', () => {
assert.ok(bindingBlock, 'could not locate registerWebMcpTools(...) in App.ts');
});
it('openSearch binding throws when searchModal is absent', () => {
it('is imported statically (not via dynamic import)', () => {
// Scanner timing: dynamic import defers registration past the probe
// window. A static import lets the synchronous call at init-start run
// before any await in init(), catching the first scanner probe.
assert.match(
appSrc,
/^import \{ registerWebMcpTools \} from '@\/services\/webmcp';$/m,
'registerWebMcpTools must be imported statically',
);
assert.doesNotMatch(
appSrc,
/import\(['"]@\/services\/webmcp['"]\)/,
"no dynamic import('@/services/webmcp') — defers past scanner probe window",
);
});
it('is called before the first await in init()', () => {
// Anchor the end of the capture to the NEXT class-level member
// (public/private) so an intermediate 2-space-indent `}` inside
// init() can't truncate the body. A lazy `[\s\S]+?\n }` match
// would stop at the first such closing brace and silently shrink
// the slice we search for the pre-await pattern.
const initBody = appSrc.match(
/public async init\(\): Promise<void> \{([\s\S]*?)\n \}(?=\n\n (?:public|private) )/,
);
assert.ok(initBody, 'could not locate init() body (anchor to next class member missing)');
const preAwait = initBody[1].split(/\n\s+await\s/, 2)[0];
assert.match(
preAwait,
/registerWebMcpTools\(/,
'registerWebMcpTools must be invoked before the first await in init()',
);
});
it('both bindings await the UI-readiness signal before touching state', () => {
// Before-fix regression: openSearch threw immediately on first
// invocation during startup. Both bindings must wait for Phase-4
// UI init to complete, then check the state, then dispatch.
assert.match(
bindingBlock[0],
/openSearch:[\s\S]+?await this\.waitForUiReady\(\)[\s\S]+?this\.state\.searchModal/,
'openSearch must await waitForUiReady() before accessing searchModal',
);
assert.match(
bindingBlock[0],
/openCountryBriefByCode:[\s\S]+?await this\.waitForUiReady\(\)[\s\S]+?this\.state\.countryBriefPage/,
'openCountryBriefByCode must await waitForUiReady() before accessing countryBriefPage',
);
});
it('bindings still throw (not silently succeed) when state is absent after readiness', () => {
// The silent-success guard from PR #3356 review must survive the
// readiness refactor. After awaiting readiness, a missing target is
// a real failure — throw so withInvocationLogging returns isError.
assert.match(
bindingBlock[0],
/openSearch:[\s\S]+?if \(!this\.state\.searchModal\)[\s\S]+?throw new Error/,
);
});
it('openCountryBriefByCode binding throws when countryBriefPage is absent', () => {
assert.match(
bindingBlock[0],
/openCountryBriefByCode:[\s\S]+?if \(!this\.state\.countryBriefPage\)[\s\S]+?throw new Error/,
);
});
it('uiReady is resolved after Phase-4 UI modules initialise', () => {
// waitForUiReady() hangs forever if nothing ever resolves uiReady.
// The resolve must live right after countryIntel.init() so that all
// Phase-4 modules are ready by the time waiters unblock.
assert.match(
appSrc,
/this\.countryIntel\.init\(\);[\s\S]{0,200}this\.resolveUiReady\(\)/,
'resolveUiReady() must fire after countryIntel.init() in Phase 4',
);
});
it('waitForUiReady enforces a timeout so a broken init cannot hang the agent', () => {
assert.match(
appSrc,
/private async waitForUiReady\(timeoutMs = [\d_]+\)[\s\S]+?Promise\.race\(\[this\.uiReady/,
);
});
it('destroy() aborts the WebMCP controller so re-inits do not duplicate registrations', () => {
// Same anchoring as init() — end at the next class member so an
// intermediate 2-space-indent close brace can't truncate the capture.
const destroyBody = appSrc.match(
/public destroy\(\): void \{([\s\S]*?)\n \}(?=\n\n (?:public|private) )/,
);
assert.ok(destroyBody, 'could not locate destroy() body (anchor to next class member missing)');
assert.match(
destroyBody[1],
/this\.webMcpController\?\.abort\(\)/,
'destroy() must abort the stored WebMCP AbortController',
);
});
});