diff --git a/app/(marketing)/docs/statistics/page.tsx b/app/(marketing)/docs/statistics/page.tsx index 3beba1a6..ad69b429 100644 --- a/app/(marketing)/docs/statistics/page.tsx +++ b/app/(marketing)/docs/statistics/page.tsx @@ -208,6 +208,47 @@ chromium --load-extension=./crawlproof-declare --disable-extensions-except=./cra
+
+ WebMCP lets the AI agent in a visitor's browser use your site
+ through tools instead of screenshots and clicks. Add{" "}
+ data-webmcp to the tag and stats.js
+ loads{" "}
+
+ @profullstack/webmcp
+
+ : site_info (with your llms.txt),{" "}
+ open_page, a{" "}
+ search_site tool when you give it a
+ search URL, every form marked with{" "}
+ toolname, and every tool of your own
+ MCP server, called with the visitor's session.
+
{``}
+ + Other keys: data-webmcp-search-api{" "} + (a JSON search endpoint),{" "} + data-webmcp-mcp-exclude (comma + list of tools to hide), data-webmcp-mcp-prefix,{" "} + data-webmcp-name,{" "} + data-webmcp-forms="false". + The module is only fetched in browsers that ship WebMCP, so it costs + other visitors nothing. +
++ Every tool an agent runs shows up in your stats as a{" "} + webmcp_tool event whose target is + the tool name (or name:error), with + or without the plugin, as long as the page runs the embed. Inputs and + results are never sent. +
+diff --git a/app/stats.js/route.ts b/app/stats.js/route.ts index 6be79cea..189b37ad 100644 --- a/app/stats.js/route.ts +++ b/app/stats.js/route.ts @@ -7,6 +7,10 @@ import { env } from "@/lib/env"; import { VISITOR_SNIPPET } from "@/lib/tracker/visitorSnippet"; +// The opt-in WebMCP plugin (data-webmcp) loads this. @0 follows every 0.x +// release of github.com/profullstack/webmcp, the same way sites tag it. +const WEBMCP_EMBED = "https://cdn.jsdelivr.net/npm/@profullstack/webmcp@0/src/embed.js"; + const snippet = `(function(){ try { var s = document.currentScript; @@ -51,6 +55,42 @@ const snippet = `(function(){ } catch (_) {} })(); + // WebMCP plugin. Opt in with data-webmcp on this tag: it loads the + // @profullstack/webmcp embed (site_info, open_page, search, forms as tools, + // and the site's own MCP server via data-webmcp-mcp) so an AI agent in the + // visitor's browser can use the site through tools instead of clicks. It + // is only fetched in browsers that ship WebMCP; everyone else pays one + // property check. Skipped when the page already loads the embed itself. + (function(){ + try { + var d = s.dataset; + if (d.webmcp === undefined || d.webmcp === 'off' || d.webmcp === 'false') return; + var mc = null; + try { mc = document.modelContext || navigator.modelContext; } catch (_) {} + if (!mc || typeof mc.registerTool !== 'function') return; + if (window.ProfullstackWebMCP || document.querySelector('script[src*="@profullstack/webmcp"]')) return; + var cfg = window.webmcpConfig || {}; + function put(key, value) { if (value != null && value !== '' && cfg[key] == null) cfg[key] = value; } + put('mcp', d.webmcpMcp); + put('mcpPrefix', d.webmcpMcpPrefix); + if (d.webmcpMcpExclude) put('mcpExclude', d.webmcpMcpExclude.split(',').map(function(x){ return x.trim(); })); + put('search', d.webmcpSearch); + put('searchApi', d.webmcpSearchApi); + put('searchDescription', d.webmcpSearchDescription); + put('name', d.webmcpName); + put('description', d.webmcpDescription); + if (d.webmcpPages) { try { put('pages', JSON.parse(d.webmcpPages)); } catch (_) {} } + if (d.webmcpForms === 'false') put('forms', false); + if (d.webmcpLlmsTxt) put('llmsTxt', d.webmcpLlmsTxt === 'false' ? false : d.webmcpLlmsTxt); + window.webmcpConfig = cfg; + var ws = document.createElement('script'); + ws.type = 'module'; + ws.src = ${JSON.stringify(WEBMCP_EMBED)}; + ws.async = true; + (document.head || document.documentElement).appendChild(ws); + } catch (_) {} + })(); + function pageUrl() { return location.origin + location.pathname + location.search; } ${VISITOR_SNIPPET} // Session-only companion state. The visitor equivalents live in the shared @@ -193,6 +233,18 @@ ${VISITOR_SNIPPET} } catch (_) {} }, true); + // Agent tool calls. The WebMCP embed (the plugin above, or the site's own + // copy) fires webmcp:call after every call an in-browser agent makes, with + // the tool name and outcome only, never its input or result. Each one is a + // webmcp_tool event: target "search_site", or "search_site:error". + document.addEventListener('webmcp:call', function(e){ + try { + var c = (e && e.detail) || {}; + if (!c.name) return; + send('webmcp_tool', String(c.name).slice(0, 64) + (c.ok === false ? ':' + (c.error === 'aborted' ? 'aborted' : 'error') : '')); + } catch (_) {} + }); + document.addEventListener('submit', function(e){ var form = e.target; send('form_submit', form && (form.getAttribute('name') || form.getAttribute('id') || form.getAttribute('action') || 'form')); diff --git a/tests/contract/stats-js.test.ts b/tests/contract/stats-js.test.ts index 7479fe16..fa9c9b8d 100644 --- a/tests/contract/stats-js.test.ts +++ b/tests/contract/stats-js.test.ts @@ -44,6 +44,23 @@ describe("/stats.js", () => { expect(script).toContain("prev && prev.q"); }); + it("loads the WebMCP plugin only when opted in and only where WebMCP exists", async () => { + const response = await GET(); + const script = await response.text(); + + expect(script).toContain("d.webmcp === undefined"); + expect(script).toContain("document.modelContext || navigator.modelContext"); + expect(script).toContain("https://cdn.jsdelivr.net/npm/@profullstack/webmcp@0/src/embed.js"); + expect(script).toContain("ws.type = 'module'"); + // Never loaded twice when the page carries its own embed. + expect(script).toContain("window.ProfullstackWebMCP"); + // Agent tool calls are counted by name, without inputs or results. + expect(script).toContain("'webmcp:call'"); + expect(script).toContain("send('webmcp_tool'"); + expect(script).not.toContain("c.input"); + expect(script).not.toContain("c.result"); + }); + it("does not collect identity, consent, or lead-capture PII", async () => { const response = await GET(); const script = await response.text();