diff --git a/src/App.ts b/src/App.ts index 1df1af3a4..106be99cd 100644 --- a/src/App.ts +++ b/src/App.ts @@ -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; + 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(); 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((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 { 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 { + let timer: ReturnType | null = null; + const timeout = new Promise((_, 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 { diff --git a/src/services/webmcp.ts b/src/services/webmcp.ts index 3baec5522..4ea4c97f4 100644 --- a/src/services/webmcp.ts +++ b/src/services/webmcp.ts @@ -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; 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; } 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; } diff --git a/tests/webmcp.test.mjs b/tests/webmcp.test.mjs index c7025a6cb..17f05207b 100644 --- a/tests/webmcp.test.mjs +++ b/tests/webmcp.test.mjs @@ -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 \{([\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', + ); + }); });