Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/(marketing)/docs/statistics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,47 @@ chromium --load-extension=./crawlproof-declare --disable-extensions-except=./cra
</p>
</section>

<section id="webmcp" className="mt-10 space-y-3">
<h2 className="text-2xl font-bold">WebMCP plugin (optional)</h2>
<p className="text-sm leading-relaxed">
WebMCP lets the AI agent in a visitor&apos;s browser use your site
through tools instead of screenshots and clicks. Add{" "}
<code className="font-mono">data-webmcp</code> to the tag and stats.js
loads{" "}
<a className="underline" href="https://github.com/profullstack/webmcp">
@profullstack/webmcp
</a>
: <span className="font-mono">site_info</span> (with your llms.txt),{" "}
<span className="font-mono">open_page</span>, a{" "}
<span className="font-mono">search_site</span> tool when you give it a
search URL, every form marked with{" "}
<span className="font-mono">toolname</span>, and every tool of your own
MCP server, called with the visitor&apos;s session.
</p>
<pre className="overflow-x-auto rounded border border-[var(--color-border)] bg-[#0b0d10] p-3 font-mono text-xs leading-relaxed">{`<script data-site="<project_id>" src="https://crawlproof.com/stats.js" async
data-webmcp
data-webmcp-mcp="/mcp"
data-webmcp-search="/search?q={query}"
data-webmcp-pages='{"pricing":"/pricing","docs":"/docs"}'></script>`}</pre>
<p className="text-sm leading-relaxed">
Other keys: <span className="font-mono">data-webmcp-search-api</span>{" "}
(a JSON search endpoint),{" "}
<span className="font-mono">data-webmcp-mcp-exclude</span> (comma
list of tools to hide), <span className="font-mono">data-webmcp-mcp-prefix</span>,{" "}
<span className="font-mono">data-webmcp-name</span>,{" "}
<span className="font-mono">data-webmcp-forms=&quot;false&quot;</span>.
The module is only fetched in browsers that ship WebMCP, so it costs
other visitors nothing.
</p>
<p className="text-sm leading-relaxed">
Every tool an agent runs shows up in your stats as a{" "}
<span className="font-mono">webmcp_tool</span> event whose target is
the tool name (or <span className="font-mono">name:error</span>), with
or without the plugin, as long as the page runs the embed. Inputs and
results are never sent.
</p>
</section>

<section id="server-side" className="mt-10 space-y-3">
<h2 className="text-2xl font-bold">Server-side, CLI, and programmatic</h2>
<p className="text-sm leading-relaxed">
Expand Down
52 changes: 52 additions & 0 deletions app/stats.js/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'));
Expand Down
17 changes: 17 additions & 0 deletions tests/contract/stats-js.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down