mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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:
28
src/App.ts
28
src/App.ts
@@ -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
|
||||
|
||||
@@ -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
130
src/services/webmcp.ts
Normal 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
106
tests/webmcp.test.mjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user