@machfivetechchicago/machvive-webmcp-ai 5.5.6 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,8 @@ elements with Shadow DOM that work anywhere `customElements` does.
25
25
  | Component | Tag | What it does |
26
26
  | --- | --- | --- |
27
27
  | WebMCP polyfill | `<machvive-webmcp-polyfill>` | Shims `navigator.modelContext` so a page can expose tools to AI agents |
28
+ | WebMCP inspector | `<machvive-webmcp-inspect>` | Lists registered tools, builds a form from each schema, runs them |
29
+ | WebMCP analytics | `<machvive-webmcp-analytics>` | Captures every tool call for listing, editing, export, replay, and dataLayer |
28
30
  | Lorum Ipsum | `<machvive-lorum-ipsum>` | Placeholder copy that projects slotted content |
29
31
 
30
32
  ## ⚡ Integration with Vite (Vanilla JS)
@@ -127,6 +129,83 @@ const result = await navigator.modelContext.callTool('add_to_cart', { sku: 'M5T-
127
129
  `callTool` never throws: a handler that rejects comes back as
128
130
  `{ content: [...], isError: true }`.
129
131
 
132
+ ## 🔎 Inspector
133
+
134
+ `machvive-webmcp-inspect` lists every registered tool, renders a form from its
135
+ `inputSchema`, and executes it with what you type. Values are coerced to the types
136
+ the schema declares — an `integer` field sends `3`, not `"3"` — required fields are
137
+ enforced before anything runs, and `object`/`array` fields accept JSON.
138
+
139
+ ```html
140
+ <!-- inline: renders where you place it -->
141
+ <machvive-webmcp-inspect></machvive-webmcp-inspect>
142
+
143
+ <!-- floating: docks as an overlay panel with a toggle, no layout impact -->
144
+ <machvive-webmcp-inspect floating></machvive-webmcp-inspect>
145
+
146
+ <!-- floating and expanded on load -->
147
+ <machvive-webmcp-inspect floating open></machvive-webmcp-inspect>
148
+ ```
149
+
150
+ `show()` and `hide()` drive the panel from script. The list refreshes automatically
151
+ as tools are registered or removed.
152
+
153
+ ## 📊 Analytics
154
+
155
+ `machvive-webmcp-analytics` captures every WebMCP invocation — params, result,
156
+ duration, and errors — then lets you list, edit, export, replay, or forward them.
157
+
158
+ ```javascript
159
+ import '@machfivetechchicago/machvive-webmcp-ai/webmcp-analytics';
160
+ ```
161
+
162
+ ```html
163
+ <machvive-webmcp-analytics></machvive-webmcp-analytics>
164
+ ```
165
+
166
+ **Import it before you register tools.** Capture works by wrapping each tool's
167
+ handler at registration time, so anything registered earlier is invisible to it —
168
+ the component warns in the console when it detects this. Wrapping the handler rather
169
+ than the caller is deliberate: it records invocations from *any* caller, including a
170
+ native `navigator.modelContext` and real agents, neither of which route through this
171
+ library.
172
+
173
+ Captured calls persist to **IndexedDB**, so a log survives reloads and is not bound
174
+ by the ~5 MB localStorage ceiling. The store degrades to memory-only where IndexedDB
175
+ is unavailable. The default cap is 500 entries, oldest evicted first.
176
+
177
+ ### Working with the log
178
+
179
+ ```javascript
180
+ import { callLog } from '@machfivetechchicago/machvive-webmcp-ai/webmcp-analytics';
181
+
182
+ await callLog.ready; // restore from IndexedDB is async
183
+
184
+ callLog.entries; // captured calls, oldest first
185
+ callLog.toJSON(); // export as JSON
186
+ callLog.import(json); // merge a previously exported log
187
+ callLog.update(id, { params }); // edit before replaying
188
+ await callLog.replay(id); // re-run as captured
189
+ await callLog.replay(id, { sku: 'OTHER' }); // re-run with edited params
190
+ ```
191
+
192
+ Recording is strictly best-effort: a failure inside the log — a throwing subscriber,
193
+ an unwritable store — can never change a tool's result or make a passing call look
194
+ like it errored.
195
+
196
+ ### Google Tag Manager
197
+
198
+ Pushing to `window.dataLayer` is **off unless you opt in**, so importing the
199
+ component never emits tracking traffic on its own:
200
+
201
+ ```html
202
+ <machvive-webmcp-analytics datalayer></machvive-webmcp-analytics>
203
+ ```
204
+
205
+ Each call then pushes `{ event: 'webmcp_tool_call', webmcp_tool, webmcp_status,
206
+ webmcp_duration_ms, webmcp_params }`. Without the attribute, push individual entries
207
+ on demand with `callLog.pushToDataLayer(id)` or the per-entry button in the UI.
208
+
130
209
  ## TypeScript
131
210
 
132
211
  Declarations ship with the package — no `@types/*` needed. Importing a component
package/index.d.ts CHANGED
@@ -12,3 +12,12 @@ export type {
12
12
  WebmcpToolResult,
13
13
  WebmcpToolSummary
14
14
  } from './src/wc/machvive-webmcp-polyfill/machvive-webmcp-polyfill.js';
15
+ export { MachviveWebmcpInspect } from './src/wc/machvive-webmcp-inspect/machvive-webmcp-inspect.js';
16
+ export {
17
+ MachviveWebmcpAnalytics,
18
+ CallLog,
19
+ callLog,
20
+ installAnalytics,
21
+ CALL_EVENT
22
+ } from './src/wc/machvive-webmcp-analytics/machvive-webmcp-analytics.js';
23
+ export type { CapturedCall, CallLogOptions } from './src/wc/machvive-webmcp-analytics/machvive-webmcp-analytics.js';
package/index.js CHANGED
@@ -5,11 +5,25 @@ import {
5
5
  installWebmcpPolyfill,
6
6
  TOOLS_CHANGED_EVENT
7
7
  } from './src/wc/machvive-webmcp-polyfill/machvive-webmcp-polyfill.js';
8
+ import { MachviveWebmcpInspect } from './src/wc/machvive-webmcp-inspect/machvive-webmcp-inspect.js';
9
+ import {
10
+ MachviveWebmcpAnalytics,
11
+ CallLog,
12
+ callLog,
13
+ installAnalytics,
14
+ CALL_EVENT
15
+ } from './src/wc/machvive-webmcp-analytics/machvive-webmcp-analytics.js';
8
16
 
9
17
  // Export them all from a single entry point
10
18
  export {
11
19
  MachviveLorumIpsum,
12
20
  MachviveWebmcpPolyfill,
21
+ MachviveWebmcpInspect,
22
+ MachviveWebmcpAnalytics,
23
+ CallLog,
24
+ callLog,
13
25
  installWebmcpPolyfill,
14
- TOOLS_CHANGED_EVENT
26
+ installAnalytics,
27
+ TOOLS_CHANGED_EVENT,
28
+ CALL_EVENT
15
29
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@machfivetechchicago/machvive-webmcp-ai",
3
- "version": "5.5.6",
3
+ "version": "5.6.0",
4
4
  "description": "I don't want to use your product's agent; I want my agent to be able to use your product.",
5
5
  "keywords": [
6
6
  "WebMCP",
@@ -40,6 +40,14 @@
40
40
  "types": "./src/wc/machvive-webmcp-polyfill/machvive-webmcp-polyfill.d.ts",
41
41
  "default": "./src/wc/machvive-webmcp-polyfill/machvive-webmcp-polyfill.js"
42
42
  },
43
+ "./webmcp-inspect": {
44
+ "types": "./src/wc/machvive-webmcp-inspect/machvive-webmcp-inspect.d.ts",
45
+ "default": "./src/wc/machvive-webmcp-inspect/machvive-webmcp-inspect.js"
46
+ },
47
+ "./webmcp-analytics": {
48
+ "types": "./src/wc/machvive-webmcp-analytics/machvive-webmcp-analytics.d.ts",
49
+ "default": "./src/wc/machvive-webmcp-analytics/machvive-webmcp-analytics.js"
50
+ },
43
51
  "./package.json": "./package.json"
44
52
  },
45
53
  "files": [
@@ -56,6 +64,7 @@
56
64
  "prepublishOnly": "npm test"
57
65
  },
58
66
  "devDependencies": {
67
+ "fake-indexeddb": "^6.2.5",
59
68
  "jsdom": "^29.1.1"
60
69
  }
61
70
  }
@@ -0,0 +1,83 @@
1
+ import type { WebmcpToolResult } from '../machvive-webmcp-polyfill/machvive-webmcp-polyfill.js';
2
+
3
+ /** One captured tool invocation. */
4
+ export interface CapturedCall {
5
+ id: string;
6
+ tool: string;
7
+ params?: unknown;
8
+ result?: unknown;
9
+ error?: string;
10
+ status: 'ok' | 'error';
11
+ startedAt: string;
12
+ durationMs: number;
13
+ pushedToDataLayer?: boolean;
14
+ }
15
+
16
+ export interface CallLogOptions {
17
+ /** Max entries retained; oldest are evicted first. Default 500. */
18
+ limit?: number;
19
+ /** Persist to IndexedDB. Default true. */
20
+ persist?: boolean;
21
+ }
22
+
23
+ export interface CallLogChangeEvent {
24
+ type: 'change';
25
+ detail: {
26
+ reason: 'add' | 'update' | 'remove' | 'clear' | 'import' | 'restore';
27
+ entry: CapturedCall | null;
28
+ entries: CapturedCall[];
29
+ };
30
+ }
31
+
32
+ export class CallLog {
33
+ constructor(options?: CallLogOptions);
34
+ /** Resolves once entries have been loaded from IndexedDB. */
35
+ readonly ready: Promise<void>;
36
+ /** True when entries are being written to IndexedDB. */
37
+ readonly persistent: boolean;
38
+ readonly entries: CapturedCall[];
39
+ readonly size: number;
40
+ addEventListener(type: 'change', listener: (e: CallLogChangeEvent) => void): void;
41
+ removeEventListener(type: 'change', listener: (e: CallLogChangeEvent) => void): void;
42
+ add(entry: Omit<CapturedCall, 'id'>): CapturedCall;
43
+ update(id: string, patch: Partial<CapturedCall>): CapturedCall | null;
44
+ remove(id: string): boolean;
45
+ clear(): void;
46
+ toJSON(space?: number): string;
47
+ /** Merges a previously exported log; returns how many entries were added. */
48
+ import(json: string | { entries: CapturedCall[] }): number;
49
+ /** Re-invokes a captured call, optionally with edited params. */
50
+ replay(id: string, params?: Record<string, unknown>): Promise<WebmcpToolResult>;
51
+ /** Pushes one entry to window.dataLayer. */
52
+ pushToDataLayer(id: string): boolean;
53
+ }
54
+
55
+ /** Shared log used by every <machvive-webmcp-analytics> on the page. */
56
+ export const callLog: CallLog;
57
+
58
+ /** Event dispatched on window whenever the log changes. */
59
+ export const CALL_EVENT: 'machvive-webmcp-call';
60
+ export const DB_NAME: 'machvive-webmcp';
61
+ export const STORE_NAME: 'calls';
62
+
63
+ /**
64
+ * Instruments navigator.modelContext so future registrations are captured.
65
+ * Tools registered before this runs cannot be instrumented.
66
+ */
67
+ export function installAnalytics(log?: CallLog): boolean;
68
+
69
+ export class MachviveWebmcpAnalytics extends HTMLElement {
70
+ readonly log: CallLog;
71
+ }
72
+
73
+ declare global {
74
+ interface HTMLElementTagNameMap {
75
+ 'machvive-webmcp-analytics': MachviveWebmcpAnalytics;
76
+ }
77
+ interface WindowEventMap {
78
+ 'machvive-webmcp-call': CustomEvent<{ reason: string; entry: CapturedCall | null }>;
79
+ }
80
+ interface Window {
81
+ dataLayer?: unknown[];
82
+ }
83
+ }
@@ -0,0 +1,565 @@
1
+ /**
2
+ * Captures every WebMCP tool invocation — request, response, timing, errors —
3
+ * and exposes them for listing, editing, export, replay, and dataLayer push.
4
+ *
5
+ * Instrumentation wraps each tool's `execute` handler at registration time rather
6
+ * than hooking the polyfill's `callTool`. That captures invocations from any
7
+ * caller, including a native `navigator.modelContext` and real agents, neither of
8
+ * which route through our code.
9
+ */
10
+
11
+ // Importing the polyfill guarantees navigator.modelContext exists before
12
+ // installAnalytics() runs. Without this, importing analytics first — which is
13
+ // exactly what capturing every call requires — finds no registry to instrument
14
+ // and silently captures nothing.
15
+ import '../machvive-webmcp-polyfill/machvive-webmcp-polyfill.js';
16
+
17
+ export const CALL_EVENT = 'machvive-webmcp-call';
18
+ export const DB_NAME = 'machvive-webmcp';
19
+ export const STORE_NAME = 'calls';
20
+
21
+ const DB_VERSION = 1;
22
+ const DEFAULT_LIMIT = 500;
23
+
24
+ /**
25
+ * Minimal IndexedDB wrapper. Every method resolves to a harmless value when
26
+ * IndexedDB is unavailable (jsdom, private mode, disabled storage) so capture
27
+ * degrades to memory-only rather than breaking the page.
28
+ */
29
+ class IdbStore {
30
+ #dbPromise = null;
31
+
32
+ get available() {
33
+ return Boolean(globalThis.indexedDB);
34
+ }
35
+
36
+ #db() {
37
+ if (!this.available) return Promise.resolve(null);
38
+ this.#dbPromise ??= new Promise((resolve) => {
39
+ let request;
40
+ try {
41
+ request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
42
+ } catch {
43
+ return resolve(null);
44
+ }
45
+ request.onupgradeneeded = () => {
46
+ const db = request.result;
47
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
48
+ db.createObjectStore(STORE_NAME, { keyPath: 'id' });
49
+ }
50
+ };
51
+ request.onsuccess = () => resolve(request.result);
52
+ request.onerror = () => resolve(null);
53
+ request.onblocked = () => resolve(null);
54
+ });
55
+ return this.#dbPromise;
56
+ }
57
+
58
+ async #tx(mode, run) {
59
+ const db = await this.#db();
60
+ if (!db) return null;
61
+ return new Promise((resolve) => {
62
+ let tx;
63
+ try {
64
+ tx = db.transaction(STORE_NAME, mode);
65
+ } catch {
66
+ return resolve(null);
67
+ }
68
+ const request = run(tx.objectStore(STORE_NAME));
69
+ tx.oncomplete = () => resolve(request ? request.result : null);
70
+ tx.onerror = () => resolve(null);
71
+ tx.onabort = () => resolve(null);
72
+ });
73
+ }
74
+
75
+ all() {
76
+ return this.#tx('readonly', (store) => store.getAll()).then((r) => r ?? []);
77
+ }
78
+
79
+ put(entry) {
80
+ return this.#tx('readwrite', (store) => store.put(entry));
81
+ }
82
+
83
+ delete(id) {
84
+ return this.#tx('readwrite', (store) => store.delete(id));
85
+ }
86
+
87
+ clear() {
88
+ return this.#tx('readwrite', (store) => store.clear());
89
+ }
90
+ }
91
+
92
+ let nextId = 0;
93
+ const newId = () => `call-${Date.now().toString(36)}-${(nextId++).toString(36)}`;
94
+
95
+ /** Deep-copies through JSON so a later mutation of caller state can't rewrite history. */
96
+ function snapshot(value) {
97
+ if (value === undefined) return undefined;
98
+ try {
99
+ return JSON.parse(JSON.stringify(value));
100
+ } catch {
101
+ return String(value);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * The captured call log. A single instance is shared by every
107
+ * <machvive-webmcp-analytics> element on the page.
108
+ */
109
+ export class CallLog {
110
+ #listeners = new Set();
111
+ #entries = [];
112
+ #limit;
113
+ #persist;
114
+ #store = new IdbStore();
115
+
116
+ /** Resolves once persisted entries have been loaded. Await before first read. */
117
+ ready;
118
+
119
+ constructor({ limit = DEFAULT_LIMIT, persist = true } = {}) {
120
+ this.#limit = limit;
121
+ this.#persist = persist;
122
+ this.ready = this.#restore();
123
+ }
124
+
125
+ /** True when entries are being written to IndexedDB. */
126
+ get persistent() {
127
+ return this.#persist && this.#store.available;
128
+ }
129
+
130
+ /** EventTarget-shaped for familiarity; only the 'change' type is emitted. */
131
+ addEventListener(type, listener) {
132
+ if (type === 'change' && typeof listener === 'function') this.#listeners.add(listener);
133
+ }
134
+
135
+ removeEventListener(type, listener) {
136
+ if (type === 'change') this.#listeners.delete(listener);
137
+ }
138
+
139
+ get entries() {
140
+ return [...this.#entries];
141
+ }
142
+
143
+ get size() {
144
+ return this.#entries.length;
145
+ }
146
+
147
+ add(entry) {
148
+ const record = { id: newId(), ...entry };
149
+ this.#entries.push(record);
150
+ // Oldest-first trim keeps the most recent calls, which are what anyone debugging wants.
151
+ let evicted = [];
152
+ if (this.#entries.length > this.#limit) {
153
+ evicted = this.#entries.splice(0, this.#entries.length - this.#limit);
154
+ }
155
+ this.#write((store) => {
156
+ store.put(record);
157
+ for (const gone of evicted) store.delete(gone.id);
158
+ });
159
+ this.#changed('add', record);
160
+ return record;
161
+ }
162
+
163
+ update(id, patch) {
164
+ const entry = this.#entries.find((e) => e.id === id);
165
+ if (!entry) return null;
166
+ Object.assign(entry, patch);
167
+ this.#write((store) => store.put(entry));
168
+ this.#changed('update', entry);
169
+ return entry;
170
+ }
171
+
172
+ remove(id) {
173
+ const index = this.#entries.findIndex((e) => e.id === id);
174
+ if (index === -1) return false;
175
+ const [removed] = this.#entries.splice(index, 1);
176
+ this.#write((store) => store.delete(removed.id));
177
+ this.#changed('remove', removed);
178
+ return true;
179
+ }
180
+
181
+ clear() {
182
+ this.#entries = [];
183
+ this.#write((store) => store.clear());
184
+ this.#changed('clear', null);
185
+ }
186
+
187
+ /** Serialized log, suitable for a file or a clipboard. */
188
+ toJSON(space = 2) {
189
+ return JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), entries: this.#entries }, null, space);
190
+ }
191
+
192
+ /** Merges a previously exported log. Returns how many entries were added. */
193
+ import(json) {
194
+ const parsed = typeof json === 'string' ? JSON.parse(json) : json;
195
+ const incoming = Array.isArray(parsed) ? parsed : parsed?.entries;
196
+ if (!Array.isArray(incoming)) throw new TypeError('Analytics: import expects an entries array');
197
+ for (const entry of incoming) {
198
+ const record = { ...entry, id: entry.id ?? newId() };
199
+ this.#entries.push(record);
200
+ this.#write((store) => store.put(record));
201
+ }
202
+ this.#changed('import', null);
203
+ return incoming.length;
204
+ }
205
+
206
+ /**
207
+ * Re-invokes a captured call. Passing `params` runs it with edited input
208
+ * instead of what was originally recorded.
209
+ */
210
+ async replay(id, params) {
211
+ const entry = this.#entries.find((e) => e.id === id);
212
+ if (!entry) throw new Error(`Analytics: no captured call ${id}`);
213
+ const mc = globalThis.navigator?.modelContext;
214
+ if (!mc?.callTool) throw new Error('Analytics: navigator.modelContext.callTool is unavailable');
215
+ return mc.callTool(entry.tool, params ?? entry.params ?? {});
216
+ }
217
+
218
+ /** Pushes one entry to window.dataLayer in a GTM-friendly flat shape. */
219
+ pushToDataLayer(id) {
220
+ const entry = this.#entries.find((e) => e.id === id);
221
+ if (!entry) return false;
222
+ const layer = (globalThis.window.dataLayer ||= []);
223
+ layer.push({
224
+ event: 'webmcp_tool_call',
225
+ webmcp_tool: entry.tool,
226
+ webmcp_status: entry.status,
227
+ webmcp_duration_ms: entry.durationMs,
228
+ webmcp_params: entry.params,
229
+ webmcp_error: entry.error ?? undefined
230
+ });
231
+ this.update(id, { pushedToDataLayer: true });
232
+ return true;
233
+ }
234
+
235
+ #changed(reason, entry) {
236
+ const detail = { reason, entry, entries: this.entries };
237
+ for (const listener of this.#listeners) {
238
+ try {
239
+ listener({ type: 'change', detail });
240
+ } catch {
241
+ // One broken subscriber must not stop the others, nor reach the tool call.
242
+ }
243
+ }
244
+ // Also on window so page code can observe without holding a CallLog reference.
245
+ try {
246
+ globalThis.window?.dispatchEvent?.(new CustomEvent(CALL_EVENT, { detail: { reason, entry } }));
247
+ } catch {
248
+ // No window, or a CustomEvent from a foreign realm.
249
+ }
250
+ }
251
+
252
+ /** Fire-and-forget write. Persistence failing must never break capture. */
253
+ #write(run) {
254
+ if (!this.persistent) return;
255
+ this.#runBatch(run).catch(() => {});
256
+ }
257
+
258
+ async #runBatch(run) {
259
+ // IdbStore exposes one operation per transaction; a batch callback needs a
260
+ // tiny shim so callers can express several writes in one place.
261
+ const ops = [];
262
+ run({
263
+ put: (entry) => ops.push(['put', entry]),
264
+ delete: (id) => ops.push(['delete', id]),
265
+ clear: () => ops.push(['clear'])
266
+ });
267
+ for (const [op, arg] of ops) {
268
+ await this.#store[op](arg);
269
+ }
270
+ }
271
+
272
+ async #restore() {
273
+ if (!this.persistent) return;
274
+ try {
275
+ const stored = await this.#store.all();
276
+ if (!Array.isArray(stored) || stored.length === 0) return;
277
+ // Persisted rows win only where memory has nothing — a call captured during
278
+ // startup must not be clobbered by the restore that was already in flight.
279
+ const seen = new Set(this.#entries.map((e) => e.id));
280
+ const merged = [...stored.filter((e) => !seen.has(e.id)), ...this.#entries];
281
+ this.#entries = merged.slice(-this.#limit);
282
+ this.#changed('restore', null);
283
+ } catch {
284
+ // Corrupt or unreadable storage must never stop the page from loading.
285
+ }
286
+ }
287
+ }
288
+
289
+ export const callLog = new CallLog();
290
+
291
+ /** Wraps a descriptor's handler so every invocation is recorded. */
292
+ function instrumentTool(tool, log) {
293
+ // Leave invalid descriptors alone so the polyfill's own validation still throws.
294
+ if (!tool || typeof tool !== 'object' || typeof tool.execute !== 'function') return tool;
295
+ if (tool.execute.__machviveWrapped) return tool;
296
+
297
+ const original = tool.execute;
298
+ const wrapped = async function (params, agent) {
299
+ const startedAt = new Date().toISOString();
300
+ const t0 = Date.now();
301
+ const record = (fields) => {
302
+ try {
303
+ log.add(fields);
304
+ } catch {
305
+ // Capture is best-effort; never let it change the tool's outcome.
306
+ }
307
+ };
308
+
309
+ try {
310
+ const result = await original.call(this, params, agent);
311
+ record({
312
+ tool: tool.name,
313
+ params: snapshot(params),
314
+ result: snapshot(result),
315
+ status: 'ok',
316
+ startedAt,
317
+ durationMs: Date.now() - t0
318
+ });
319
+ return result;
320
+ } catch (err) {
321
+ record({
322
+ tool: tool.name,
323
+ params: snapshot(params),
324
+ error: String(err?.message ?? err),
325
+ status: 'error',
326
+ startedAt,
327
+ durationMs: Date.now() - t0
328
+ });
329
+ throw err;
330
+ }
331
+ };
332
+ wrapped.__machviveWrapped = true;
333
+ return { ...tool, execute: wrapped };
334
+ }
335
+
336
+ let installed = false;
337
+
338
+ /**
339
+ * Instruments `navigator.modelContext` so future registrations are captured.
340
+ *
341
+ * Tools registered *before* this runs cannot be instrumented — the polyfill hides
342
+ * handlers from `tools` by design — so import this module before registering.
343
+ *
344
+ * @returns {boolean} true if this call instrumented the registry.
345
+ */
346
+ export function installAnalytics(log = callLog) {
347
+ const mc = globalThis.navigator?.modelContext;
348
+ if (!mc || installed) return false;
349
+
350
+ if (mc.tools?.length) {
351
+ console.warn(
352
+ `WebMCP analytics: ${mc.tools.length} tool(s) were registered before analytics loaded ` +
353
+ 'and will not be captured. Import the analytics module earlier.'
354
+ );
355
+ }
356
+
357
+ const register = mc.registerTool.bind(mc);
358
+ mc.registerTool = (tool) => register(instrumentTool(tool, log));
359
+
360
+ if (typeof mc.provideContext === 'function') {
361
+ const provide = mc.provideContext.bind(mc);
362
+ mc.provideContext = (config = {}) =>
363
+ provide({ ...config, tools: (config.tools ?? []).map((t) => instrumentTool(t, log)) });
364
+ }
365
+
366
+ installed = true;
367
+ return true;
368
+ }
369
+
370
+ const STYLES = `
371
+ :host { display: block; font: 13px/1.5 system-ui, sans-serif; color: #1a1a1a; }
372
+ :host([hidden]) { display: none; }
373
+ .bar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
374
+ .count { font-weight: 600; margin-right: auto; }
375
+ button { font: inherit; padding: 3px 9px; border: 1px solid #ccc; border-radius: 4px;
376
+ background: #fff; cursor: pointer; }
377
+ button:hover { background: #f2f2f2; }
378
+ button.danger { color: #a01; border-color: #d8a0a0; }
379
+ ol { list-style: none; margin: 0; padding: 0; border: 1px solid #e2e2e2; border-radius: 6px;
380
+ max-height: 380px; overflow-y: auto; }
381
+ li { border-bottom: 1px solid #eee; }
382
+ li:last-child { border-bottom: 0; }
383
+ .row { display: flex; gap: 8px; align-items: center; padding: 6px 10px; cursor: pointer; }
384
+ .row:hover { background: #fafafa; }
385
+ .tool { font-family: ui-monospace, monospace; font-weight: 600; }
386
+ .status { font-size: 11px; padding: 1px 6px; border-radius: 10px; }
387
+ .status.ok { background: #e6f4ea; color: #137333; }
388
+ .status.error { background: #fce8e6; color: #c5221f; }
389
+ .ms { color: #777; font-size: 11px; margin-left: auto; }
390
+ .detail { padding: 8px 10px; background: #fbfbfb; border-top: 1px solid #eee; }
391
+ .detail label { display: block; font-size: 11px; color: #666; margin: 6px 0 2px; }
392
+ textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, monospace;
393
+ font-size: 12px; border: 1px solid #ddd; border-radius: 4px; padding: 5px; }
394
+ pre { margin: 0; padding: 6px; background: #fff; border: 1px solid #eee; border-radius: 4px;
395
+ font-size: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
396
+ .empty { padding: 20px; text-align: center; color: #888; }
397
+ .err { color: #c5221f; }
398
+ `;
399
+
400
+ export class MachviveWebmcpAnalytics extends HTMLElement {
401
+ #log = callLog;
402
+ #expanded = null;
403
+ #onChange = () => this.#render();
404
+
405
+ static get observedAttributes() {
406
+ return ['datalayer'];
407
+ }
408
+
409
+ constructor() {
410
+ super();
411
+ this.attachShadow({ mode: 'open' });
412
+ }
413
+
414
+ connectedCallback() {
415
+ this.shadowRoot.innerHTML = `<style>${STYLES}</style><div id="root"></div>`;
416
+ this.#log.addEventListener('change', this.#onChange);
417
+ // Auto-push only when explicitly opted in, so importing this never emits GTM traffic.
418
+ globalThis.window.addEventListener(CALL_EVENT, this.#maybeAutoPush);
419
+ this.#render();
420
+ // Restore is async; repaint once persisted calls have loaded.
421
+ this.#log.ready?.then(() => this.isConnected && this.#render());
422
+ }
423
+
424
+ disconnectedCallback() {
425
+ this.#log.removeEventListener('change', this.#onChange);
426
+ globalThis.window.removeEventListener(CALL_EVENT, this.#maybeAutoPush);
427
+ }
428
+
429
+ /** The shared call log, for page code that wants direct access. */
430
+ get log() {
431
+ return this.#log;
432
+ }
433
+
434
+ #maybeAutoPush = (e) => {
435
+ if (!this.hasAttribute('datalayer')) return;
436
+ if (e.detail?.reason !== 'add' || !e.detail.entry) return;
437
+ this.#log.pushToDataLayer(e.detail.entry.id);
438
+ };
439
+
440
+ #render() {
441
+ const root = this.shadowRoot?.getElementById('root');
442
+ if (!root) return;
443
+ const entries = this.#log.entries.slice().reverse();
444
+
445
+ root.innerHTML = `
446
+ <div class="bar">
447
+ <span class="count">${entries.length} call${entries.length === 1 ? '' : 's'}</span>
448
+ <button data-act="export">Export</button>
449
+ <button data-act="copy">Copy JSON</button>
450
+ <button data-act="clear" class="danger">Clear</button>
451
+ </div>
452
+ ${
453
+ entries.length === 0
454
+ ? `<div class="empty">No WebMCP calls captured yet.</div>`
455
+ : `<ol>${entries.map((e) => this.#renderEntry(e)).join('')}</ol>`
456
+ }
457
+ `;
458
+ root.querySelector('.bar').addEventListener('click', (ev) => this.#onBarClick(ev));
459
+ root.querySelectorAll('li').forEach((li) => this.#wireEntry(li));
460
+ }
461
+
462
+ #renderEntry(entry) {
463
+ const open = this.#expanded === entry.id;
464
+ return `
465
+ <li data-id="${entry.id}">
466
+ <div class="row">
467
+ <span class="tool">${escapeHtml(entry.tool ?? '(unknown)')}</span>
468
+ <span class="status ${entry.status}">${entry.status}</span>
469
+ <span class="ms">${entry.durationMs ?? 0}ms</span>
470
+ </div>
471
+ ${
472
+ open
473
+ ? `<div class="detail">
474
+ <label>Params (editable — used on replay)</label>
475
+ <textarea rows="3" data-role="params">${escapeHtml(
476
+ JSON.stringify(entry.params ?? {}, null, 2)
477
+ )}</textarea>
478
+ <label>${entry.status === 'error' ? 'Error' : 'Result'}</label>
479
+ <pre class="${entry.status === 'error' ? 'err' : ''}">${escapeHtml(
480
+ entry.status === 'error' ? entry.error ?? '' : JSON.stringify(entry.result ?? null, null, 2)
481
+ )}</pre>
482
+ <div class="bar" style="margin-top:8px">
483
+ <button data-act="save">Save params</button>
484
+ <button data-act="replay">Replay</button>
485
+ <button data-act="push">Push to dataLayer</button>
486
+ <button data-act="remove" class="danger">Delete</button>
487
+ </div>
488
+ </div>`
489
+ : ''
490
+ }
491
+ </li>
492
+ `;
493
+ }
494
+
495
+ #wireEntry(li) {
496
+ const id = li.dataset.id;
497
+ li.querySelector('.row').addEventListener('click', () => {
498
+ this.#expanded = this.#expanded === id ? null : id;
499
+ this.#render();
500
+ });
501
+ li.querySelectorAll('button[data-act]').forEach((btn) => {
502
+ btn.addEventListener('click', (ev) => {
503
+ ev.stopPropagation();
504
+ this.#onEntryAction(btn.dataset.act, id, li);
505
+ });
506
+ });
507
+ }
508
+
509
+ async #onEntryAction(action, id, li) {
510
+ const readParams = () => {
511
+ const raw = li.querySelector('[data-role="params"]')?.value ?? '{}';
512
+ try {
513
+ return JSON.parse(raw);
514
+ } catch {
515
+ globalThis.window.alert('Params must be valid JSON.');
516
+ return null;
517
+ }
518
+ };
519
+
520
+ if (action === 'save') {
521
+ const params = readParams();
522
+ if (params) this.#log.update(id, { params });
523
+ } else if (action === 'replay') {
524
+ const params = readParams();
525
+ if (params) await this.#log.replay(id, params);
526
+ } else if (action === 'push') {
527
+ this.#log.pushToDataLayer(id);
528
+ } else if (action === 'remove') {
529
+ if (this.#expanded === id) this.#expanded = null;
530
+ this.#log.remove(id);
531
+ }
532
+ }
533
+
534
+ #onBarClick(ev) {
535
+ const act = ev.target.dataset?.act;
536
+ if (act === 'clear') {
537
+ this.#expanded = null;
538
+ this.#log.clear();
539
+ } else if (act === 'copy') {
540
+ globalThis.navigator.clipboard?.writeText(this.#log.toJSON());
541
+ } else if (act === 'export') {
542
+ this.#download();
543
+ }
544
+ }
545
+
546
+ #download() {
547
+ const blob = new Blob([this.#log.toJSON()], { type: 'application/json' });
548
+ const url = URL.createObjectURL(blob);
549
+ const a = document.createElement('a');
550
+ a.href = url;
551
+ a.download = `webmcp-analytics-${Date.now()}.json`;
552
+ a.click();
553
+ URL.revokeObjectURL(url);
554
+ }
555
+ }
556
+
557
+ function escapeHtml(value) {
558
+ return String(value).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
559
+ }
560
+
561
+ installAnalytics();
562
+
563
+ if (!customElements.get('machvive-webmcp-analytics')) {
564
+ customElements.define('machvive-webmcp-analytics', MachviveWebmcpAnalytics);
565
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Lists registered WebMCP tools, builds a form from each tool's inputSchema,
3
+ * and executes it. Inline by default; `floating` docks it as an overlay.
4
+ */
5
+ export class MachviveWebmcpInspect extends HTMLElement {
6
+ /** Opens the panel (floating mode only). */
7
+ show(): void;
8
+ /** Closes the panel (floating mode only). */
9
+ hide(): void;
10
+ }
11
+
12
+ declare global {
13
+ interface HTMLElementTagNameMap {
14
+ 'machvive-webmcp-inspect': MachviveWebmcpInspect;
15
+ }
16
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Lists the tools registered with WebMCP, builds a form from each tool's
3
+ * inputSchema, and executes it with the values entered.
4
+ *
5
+ * Renders inline by default; add the `floating` attribute to dock it as an
6
+ * overlay panel, and `open` to start that panel expanded.
7
+ *
8
+ * Importing this also installs the polyfill, since discovery (`tools`) and
9
+ * invocation (`callTool`) are additions the bare spec does not provide.
10
+ */
11
+ import { TOOLS_CHANGED_EVENT } from '../machvive-webmcp-polyfill/machvive-webmcp-polyfill.js';
12
+
13
+ /** Reads a form control back as the JSON type its schema calls for. */
14
+ function readControl(input, schema = {}) {
15
+ if (input.type === 'checkbox') return input.checked;
16
+ const raw = input.value;
17
+ if (raw === '' ) return undefined;
18
+ if (schema.type === 'number' || schema.type === 'integer') {
19
+ const n = Number(raw);
20
+ if (Number.isNaN(n)) throw new TypeError(`"${raw}" is not a number`);
21
+ return schema.type === 'integer' ? Math.trunc(n) : n;
22
+ }
23
+ if (schema.type === 'object' || schema.type === 'array') {
24
+ try {
25
+ return JSON.parse(raw);
26
+ } catch {
27
+ throw new TypeError(`must be valid JSON`);
28
+ }
29
+ }
30
+ return raw;
31
+ }
32
+
33
+ const STYLES = `
34
+ :host { display: block; font: 13px/1.5 system-ui, sans-serif; color: #1a1a1a; }
35
+ :host([hidden]) { display: none; }
36
+
37
+ /* Floating mode docks the panel without disturbing page layout. */
38
+ :host([floating]) { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000;
39
+ display: block; width: auto; }
40
+ :host([floating]) .panel { display: none; width: min(420px, calc(100vw - 32px));
41
+ max-height: min(70vh, 560px); overflow: auto;
42
+ box-shadow: 0 8px 28px rgba(0,0,0,.18); background: #fff; }
43
+ :host([floating][open]) .panel { display: block; }
44
+ :host([floating]) .fab { display: inline-flex; }
45
+ .fab { display: none; align-items: center; gap: 6px; margin-top: 8px; float: right;
46
+ padding: 7px 13px; border-radius: 999px; border: 1px solid #ccc; background: #fff;
47
+ cursor: pointer; font: inherit; box-shadow: 0 2px 8px rgba(0,0,0,.14); }
48
+
49
+ .panel { border: 1px solid #e2e2e2; border-radius: 8px; overflow: hidden; }
50
+ header { display: flex; align-items: center; gap: 8px; padding: 7px 10px;
51
+ background: #fafafa; border-bottom: 1px solid #eee; font-weight: 600; }
52
+ header .close { margin-left: auto; border: 0; background: none; cursor: pointer;
53
+ font-size: 16px; line-height: 1; color: #666; }
54
+ :host(:not([floating])) header .close { display: none; }
55
+
56
+ .body { display: flex; min-height: 150px; }
57
+ @media (max-width: 520px) { .body { flex-direction: column; } }
58
+
59
+ .tools { flex: 0 0 150px; border-right: 1px solid #eee; overflow-y: auto; }
60
+ @media (max-width: 520px) { .tools { flex: none; border-right: 0; border-bottom: 1px solid #eee; } }
61
+ .tools button { display: block; width: 100%; text-align: left; padding: 6px 10px;
62
+ border: 0; background: none; cursor: pointer; font: inherit;
63
+ font-family: ui-monospace, monospace; border-bottom: 1px solid #f4f4f4; }
64
+ .tools button:hover { background: #f6f6f6; }
65
+ .tools button[aria-current="true"] { background: #e8f0fe; font-weight: 600; }
66
+
67
+ .form { flex: 1; padding: 10px; min-width: 0; }
68
+ .desc { color: #666; margin: 0 0 8px; }
69
+ label { display: block; margin-bottom: 7px; }
70
+ .name { font-family: ui-monospace, monospace; font-size: 12px; }
71
+ .req { color: #c5221f; }
72
+ .hint { color: #888; font-size: 11px; }
73
+ input, select, textarea { width: 100%; box-sizing: border-box; font: inherit; font-size: 12px;
74
+ padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px; }
75
+ input[type="checkbox"] { width: auto; }
76
+ textarea { font-family: ui-monospace, monospace; }
77
+ .run { margin-top: 4px; padding: 5px 14px; border: 1px solid #1a73e8; border-radius: 4px;
78
+ background: #1a73e8; color: #fff; cursor: pointer; font: inherit; }
79
+ .run:disabled { opacity: .6; cursor: default; }
80
+ pre { margin: 8px 0 0; padding: 7px; background: #fafafa; border: 1px solid #eee;
81
+ border-radius: 4px; font-size: 12px; white-space: pre-wrap; word-break: break-word;
82
+ max-height: 180px; overflow: auto; }
83
+ pre.error { background: #fce8e6; border-color: #f5c6c2; color: #c5221f; }
84
+ .field-error { color: #c5221f; font-size: 11px; }
85
+ .empty { padding: 20px; text-align: center; color: #888; }
86
+ `;
87
+
88
+ export class MachviveWebmcpInspect extends HTMLElement {
89
+ #selected = null;
90
+ #result = null;
91
+ #onToolsChanged = () => this.#render();
92
+
93
+ static get observedAttributes() {
94
+ return ['floating', 'open'];
95
+ }
96
+
97
+ constructor() {
98
+ super();
99
+ this.attachShadow({ mode: 'open' });
100
+ }
101
+
102
+ connectedCallback() {
103
+ this.shadowRoot.innerHTML = `<style>${STYLES}</style><div id="root"></div>`;
104
+ globalThis.window.addEventListener(TOOLS_CHANGED_EVENT, this.#onToolsChanged);
105
+ this.#render();
106
+ }
107
+
108
+ disconnectedCallback() {
109
+ globalThis.window.removeEventListener(TOOLS_CHANGED_EVENT, this.#onToolsChanged);
110
+ }
111
+
112
+ attributeChangedCallback() {
113
+ if (this.shadowRoot?.getElementById('root')) this.#render();
114
+ }
115
+
116
+ /** Opens the panel (floating mode only). */
117
+ show() {
118
+ this.setAttribute('open', '');
119
+ }
120
+
121
+ /** Closes the panel (floating mode only). */
122
+ hide() {
123
+ this.removeAttribute('open');
124
+ }
125
+
126
+ get #context() {
127
+ return globalThis.navigator?.modelContext ?? null;
128
+ }
129
+
130
+ get #tools() {
131
+ return this.#context?.tools ?? [];
132
+ }
133
+
134
+ #render() {
135
+ const root = this.shadowRoot?.getElementById('root');
136
+ if (!root) return;
137
+
138
+ const mc = this.#context;
139
+ // A native modelContext has no tools/callTool — those are our additions.
140
+ const usable = Boolean(mc && Array.isArray(mc.tools) && typeof mc.callTool === 'function');
141
+ const tools = usable ? this.#tools : [];
142
+ if (this.#selected && !tools.some((t) => t.name === this.#selected)) this.#selected = null;
143
+ this.#selected ??= tools[0]?.name ?? null;
144
+ const tool = tools.find((t) => t.name === this.#selected) ?? null;
145
+
146
+ root.innerHTML = `
147
+ <div class="panel">
148
+ <header>WebMCP Inspector<button class="close" title="Close">&times;</button></header>
149
+ ${
150
+ !usable
151
+ ? `<div class="empty">navigator.modelContext is unavailable here.<br>
152
+ <span class="hint">Needs a secure context, and tool discovery requires the machvive polyfill.</span></div>`
153
+ : tools.length === 0
154
+ ? `<div class="empty">No tools registered.</div>`
155
+ : `<div class="body">
156
+ <div class="tools">${tools
157
+ .map(
158
+ (t) =>
159
+ `<button data-tool="${escapeAttr(t.name)}" aria-current="${
160
+ t.name === this.#selected
161
+ }">${escapeHtml(t.name)}</button>`
162
+ )
163
+ .join('')}</div>
164
+ <div class="form">${this.#renderForm(tool)}</div>
165
+ </div>`
166
+ }
167
+ </div>
168
+ <button class="fab" title="WebMCP Inspector">&#128295; WebMCP</button>
169
+ `;
170
+ this.#wire(root);
171
+ }
172
+
173
+ #renderForm(tool) {
174
+ if (!tool) return '';
175
+ const schema = tool.inputSchema ?? {};
176
+ const props = schema.properties ?? {};
177
+ const required = new Set(schema.required ?? []);
178
+ const names = Object.keys(props);
179
+
180
+ const fields = names.length
181
+ ? names.map((name) => this.#renderField(name, props[name], required.has(name))).join('')
182
+ : `<p class="hint">This tool takes no parameters.</p>`;
183
+
184
+ return `
185
+ ${tool.description ? `<p class="desc">${escapeHtml(tool.description)}</p>` : ''}
186
+ <form>
187
+ ${fields}
188
+ <button type="submit" class="run">Execute</button>
189
+ </form>
190
+ ${
191
+ this.#result
192
+ ? `<pre class="${this.#result.isError ? 'error' : ''}">${escapeHtml(this.#result.text)}</pre>`
193
+ : ''
194
+ }
195
+ `;
196
+ }
197
+
198
+ #renderField(name, schema = {}, isRequired) {
199
+ const label = `<span class="name">${escapeHtml(name)}</span>${
200
+ isRequired ? ' <span class="req" title="required">*</span>' : ''
201
+ }${schema.description ? ` <span class="hint">— ${escapeHtml(schema.description)}</span>` : ''}`;
202
+
203
+ let control;
204
+ if (Array.isArray(schema.enum)) {
205
+ control = `<select data-field="${escapeAttr(name)}">${
206
+ isRequired ? '' : '<option value=""></option>'
207
+ }${schema.enum
208
+ .map((v) => `<option value="${escapeAttr(v)}">${escapeHtml(v)}</option>`)
209
+ .join('')}</select>`;
210
+ } else if (schema.type === 'boolean') {
211
+ control = `<input type="checkbox" data-field="${escapeAttr(name)}">`;
212
+ } else if (schema.type === 'object' || schema.type === 'array') {
213
+ control = `<textarea rows="3" data-field="${escapeAttr(name)}" placeholder="JSON"></textarea>`;
214
+ } else {
215
+ const numeric = schema.type === 'number' || schema.type === 'integer';
216
+ control = `<input type="${numeric ? 'number' : 'text'}"${
217
+ schema.type === 'integer' ? ' step="1"' : ''
218
+ } data-field="${escapeAttr(name)}">`;
219
+ }
220
+
221
+ return `<label>${label}${control}<span class="field-error" data-error="${escapeAttr(
222
+ name
223
+ )}"></span></label>`;
224
+ }
225
+
226
+ #wire(root) {
227
+ root.querySelector('.fab')?.addEventListener('click', () =>
228
+ this.hasAttribute('open') ? this.hide() : this.show()
229
+ );
230
+ root.querySelector('header .close')?.addEventListener('click', () => this.hide());
231
+
232
+ root.querySelectorAll('[data-tool]').forEach((btn) =>
233
+ btn.addEventListener('click', () => {
234
+ this.#selected = btn.dataset.tool;
235
+ this.#result = null;
236
+ this.#render();
237
+ })
238
+ );
239
+
240
+ root.querySelector('form')?.addEventListener('submit', (ev) => {
241
+ ev.preventDefault();
242
+ this.#execute(root);
243
+ });
244
+ }
245
+
246
+ async #execute(root) {
247
+ const tool = this.#tools.find((t) => t.name === this.#selected);
248
+ if (!tool) return;
249
+ const props = tool.inputSchema?.properties ?? {};
250
+ const required = new Set(tool.inputSchema?.required ?? []);
251
+
252
+ const params = {};
253
+ let invalid = false;
254
+ root.querySelectorAll('[data-error]').forEach((el) => (el.textContent = ''));
255
+
256
+ for (const [name, schema] of Object.entries(props)) {
257
+ const input = root.querySelector(`[data-field="${CSS.escape(name)}"]`);
258
+ if (!input) continue;
259
+ const errorEl = root.querySelector(`[data-error="${CSS.escape(name)}"]`);
260
+ try {
261
+ const value = readControl(input, schema);
262
+ if (value === undefined) {
263
+ if (required.has(name)) {
264
+ if (errorEl) errorEl.textContent = 'required';
265
+ invalid = true;
266
+ }
267
+ continue;
268
+ }
269
+ params[name] = value;
270
+ } catch (err) {
271
+ if (errorEl) errorEl.textContent = String(err.message ?? err);
272
+ invalid = true;
273
+ }
274
+ }
275
+ if (invalid) return;
276
+
277
+ const button = root.querySelector('.run');
278
+ if (button) button.disabled = true;
279
+ try {
280
+ const result = await this.#context.callTool(tool.name, params);
281
+ const text = (result?.content ?? [])
282
+ .map((block) => block?.text ?? JSON.stringify(block))
283
+ .join('\n');
284
+ this.#result = { text: text || JSON.stringify(result, null, 2), isError: Boolean(result?.isError) };
285
+ } catch (err) {
286
+ // callTool is documented never to reject, but a native impl might.
287
+ this.#result = { text: String(err?.message ?? err), isError: true };
288
+ } finally {
289
+ if (button) button.disabled = false;
290
+ }
291
+ this.#render();
292
+ }
293
+ }
294
+
295
+ function escapeHtml(value) {
296
+ return String(value).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
297
+ }
298
+ const escapeAttr = escapeHtml;
299
+
300
+ if (!customElements.get('machvive-webmcp-inspect')) {
301
+ customElements.define('machvive-webmcp-inspect', MachviveWebmcpInspect);
302
+ }