feat(agent-readiness): WebMCP in-page tool surface (#3316) (#3356)

* feat(agent-readiness): WebMCP in-page tool surface (#3316)

Closes #3316. Exposes two UI tools to in-browser agents via the draft
WebMCP spec (webmachinelearning.github.io/webmcp), mirroring the static
Agent Skills index (#3310) for consistency:

- openCountryBrief({ iso2 }): opens the country deep-dive panel.
- openSearch(): opens the global command palette.

No bypass: both tools route through the exact methods a click would
hit (countryIntel.openCountryBriefByCode, searchModal.open), so auth
and Pro-tier gates apply to agent invocations unchanged.

Feature-detected: no-ops in Firefox, Safari, and older Chrome without
navigator.modelContext. No behavioural change outside WebMCP browsers.
Lazy-imported from App.ts so the module only enters the bundle if the
dynamic import resolves; keeps the hot-path init synchronous.

Each execute is wrapped in a logging shim that emits a typed
webmcp-tool-invoked analytics event per call; webmcp-registered fires
once at setup so we can distinguish capable-browser share from actual
tool usage.

v1 tools do not branch on auth state, so a single registration at
init is correct. Source-level comment flags that any future Pro-only
tool must re-register on sign-in/sign-out per the symmetric-listener
rule documented in the memory system.

tests/webmcp.test.mjs asserts the contract: feature-detect gate runs
before provideContext, two-or-more tools ship, ISO-2 validation lives
in the tool execute, every execute is wrapped in logging, and the
AppBindings surface stays narrow.

* fix(agent-readiness): WebMCP bindings surface missing-target as errors (#3316)

Addresses PR #3356 review.

P1 — silent-success via optional-chain no-op:
The App.ts bindings used this.state.searchModal?.open() and an
unchecked call to countryIntel.openCountryBriefByCode(). When the
underlying UI state was absent (pre-init, or in a variant that
skips the panel), the optional chain and the method's own null
guard both returned quietly, but the tool still reported "Opened"
with ok:true. Agents relying on that result would be misled.

Bindings now throw when the required UI target is missing. The
existing withInvocationLogging shim catches the throw, emits
ok:false in analytics, and returns isError:true, so agents get an
honest failure instead of a fake success. Fixed both bindings.

P2: dropped unused beforeEach import in tests/webmcp.test.mjs.

Added source-level assertions that both bindings throw when the
UI target is absent, so a future refactor that drops the check
fails loudly at CI time.
This commit is contained in:
Elie Habib
2026-04-24 07:14:04 +04:00
committed by GitHub
parent 6d4c717e75
commit efb6037fcc
4 changed files with 267 additions and 0 deletions

View File

@@ -1033,6 +1033,34 @@ export class App {
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();
},
});
});
// Phase 5: Event listeners + URL sync
this.eventHandlers.init();
// Capture deep link params BEFORE URL sync overwrites them

View File

@@ -50,6 +50,9 @@ const EVENTS = {
'mcp-connect-attempt': true,
'mcp-connect-success': true,
'mcp-panel-add': true,
// WebMCP (in-page agent tool surface)
'webmcp-registered': true,
'webmcp-tool-invoked': true,
// Route Explorer
'route-explorer:opened': true,
'route-explorer:query': true,

130
src/services/webmcp.ts Normal file
View File

@@ -0,0 +1,130 @@
// 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.
//
// Current tools mirror the static Agent Skills set (#3310) for consistency:
// 1. openCountryBrief({ iso2 }) — opens the country deep-dive panel.
// 2. openSearch() — opens the global command palette.
//
// 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).
import { track } from './analytics';
// Minimal draft-spec types — WebMCP has no published typings yet.
interface WebMcpToolContent {
type: 'text';
text: string;
}
interface WebMcpToolResult {
content: WebMcpToolContent[];
isError?: boolean;
}
interface WebMcpTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
execute: (args: Record<string, unknown>) => Promise<WebMcpToolResult>;
}
interface WebMcpProvider {
provideContext(ctx: { tools: WebMcpTool[] }): void;
}
interface NavigatorWithWebMcp extends Navigator {
modelContext?: WebMcpProvider;
}
export interface WebMcpAppBindings {
openCountryBriefByCode(code: string, country: string): Promise<void>;
resolveCountryName(code: string): string;
openSearch(): void;
}
const ISO2 = /^[A-Z]{2}$/;
function textResult(text: string, isError = false): WebMcpToolResult {
return { content: [{ type: 'text', text }], isError };
}
function withInvocationLogging(name: string, fn: WebMcpTool['execute']): WebMcpTool['execute'] {
return async (args) => {
try {
const result = await fn(args);
track('webmcp-tool-invoked', { tool: name, ok: !result.isError });
return result;
} catch (err) {
track('webmcp-tool-invoked', { tool: name, ok: false });
return textResult(`Tool ${name} failed: ${(err as Error).message ?? String(err)}`, true);
}
};
}
export function buildWebMcpTools(app: WebMcpAppBindings): WebMcpTool[] {
return [
{
name: 'openCountryBrief',
description:
'Open the intelligence brief panel for a country by ISO 3166-1 alpha-2 code (e.g. "DE", "IR"). Routes the user to the country deep-dive view; the brief itself is fetched by the same path a click would take.',
inputSchema: {
type: 'object',
properties: {
iso2: {
type: 'string',
description: 'ISO 3166-1 alpha-2 country code, uppercase.',
pattern: '^[A-Z]{2}$',
},
},
required: ['iso2'],
additionalProperties: false,
},
execute: withInvocationLogging('openCountryBrief', async (args) => {
const iso2 = typeof args.iso2 === 'string' ? args.iso2.toUpperCase() : '';
if (!ISO2.test(iso2)) {
return textResult(
'iso2 must be an ISO 3166-1 alpha-2 code, e.g. "DE" or "IR".',
true,
);
}
const name = app.resolveCountryName(iso2);
await app.openCountryBriefByCode(iso2, name);
return textResult(`Opened intelligence brief for ${name} (${iso2}).`);
}),
},
{
name: 'openSearch',
description:
'Open the global search command palette so the user can find countries, signals, alerts, and other entities tracked by World Monitor.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
execute: withInvocationLogging('openSearch', async () => {
app.openSearch();
return textResult('Opened search palette.');
}),
},
];
}
// 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;
const provider = (navigator as NavigatorWithWebMcp).modelContext;
if (!provider || typeof provider.provideContext !== 'function') return false;
const tools = buildWebMcpTools(app);
provider.provideContext({ tools });
track('webmcp-registered', { toolCount: tools.length });
return true;
}

106
tests/webmcp.test.mjs Normal file
View File

@@ -0,0 +1,106 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const ROOT = resolve(dirname(__filename), '..');
const WEBMCP_PATH = resolve(ROOT, 'src/services/webmcp.ts');
// The real module depends on the analytics service and a DOM globalThis.
// Rather than transpile+execute it under tsx (and drag in its transitive
// imports), we assert contract properties by reading the source directly.
// This mirrors how tests/edge-functions.test.mjs validates edge handlers.
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.
assert.match(
src,
/typeof provider\.provideContext !== 'function'\) return false[\s\S]+?provider\.provideContext\(/,
'feature detection must short-circuit before provideContext is invoked',
);
});
it('guards against non-browser runtimes (navigator undefined)', () => {
assert.match(src, /typeof navigator === 'undefined'\) return false/);
});
it('ships at least two tools (acceptance criterion: >=2 tools)', () => {
const toolCount = (src.match(/^\s+name: '[a-zA-Z]+',$/gm) || []).length;
assert.ok(toolCount >= 2, `expected >=2 tool entries, found ${toolCount}`);
});
it('openCountryBrief validates ISO-2 before dispatching to the app', () => {
// Guards against agents passing "usa" or "USA " etc. The check must live
// inside the tool's own execute, not the UI. Regex + uppercase normalise.
assert.match(src, /const ISO2 = \/\^\[A-Z\]\{2\}\$\//);
assert.match(src, /if \(!ISO2\.test\(iso2\)\)/);
});
it('every tool invocation is wrapped in logging', () => {
// withInvocationLogging emits a 'webmcp-tool-invoked' analytics event
// per call so we can observe agent traffic separately from user clicks.
const executeLines = src.match(/execute: withInvocationLogging\(/g) || [];
const toolCount = (src.match(/^\s+name: '[a-zA-Z]+',$/gm) || []).length;
assert.equal(
executeLines.length,
toolCount,
'every tool must route execute through withInvocationLogging',
);
});
it('exposes the narrow AppBindings surface (no AppContext leakage)', () => {
assert.match(src, /export interface WebMcpAppBindings \{/);
assert.match(src, /openCountryBriefByCode\(code: string, country: string\): Promise<void>/);
assert.match(src, /openSearch\(\): void/);
// Must not import AppContext — would couple the service to every module.
assert.doesNotMatch(src, /from '@\/app\/app-context'/);
});
});
// Behavioural tests against buildWebMcpTools() — we can exercise the pure
// builder by re-implementing the minimal shape it needs. This is a sanity
// check that the exported surface behaves the way the contract claims.
describe('webmcp.ts: tool behaviour (source-level invariants)', () => {
it('openCountryBrief ISO-2 regex rejects invalid inputs', () => {
const ISO2 = /^[A-Z]{2}$/;
assert.equal(ISO2.test('DE'), true);
assert.equal(ISO2.test('de'), false);
assert.equal(ISO2.test('USA'), false);
assert.equal(ISO2.test(''), false);
assert.equal(ISO2.test('D1'), false);
});
});
// 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', () => {
const appSrc = readFileSync(resolve(ROOT, 'src/App.ts'), 'utf-8');
const bindingBlock = appSrc.match(
/registerWebMcpTools\(\{[\s\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', () => {
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/,
);
});
});