diff --git a/app/(app)/dashboard/projects/[id]/stats/page.tsx b/app/(app)/dashboard/projects/[id]/stats/page.tsx
index a88f82aa..8037e1f8 100644
--- a/app/(app)/dashboard/projects/[id]/stats/page.tsx
+++ b/app/(app)/dashboard/projects/[id]/stats/page.tsx
@@ -19,6 +19,7 @@ import {
import { InstallSnippet } from "./install-snippet";
import { TrackerToggle } from "./tracker-toggle";
import { CareersToggle } from "./careers-toggle";
+import { WebmcpToggle } from "./webmcp-toggle";
import { AutoInstall } from "./auto-install";
import { LiveVisitors } from "./live-visitors";
import { StatsSubnav } from "./stats-subnav";
@@ -121,6 +122,8 @@ export default async function ProjectStatsPage({
.tracker_enabled;
const careersEnabled = !!(project as { careers_enabled?: boolean })
.careers_enabled;
+ const webmcpEnabled = !!(project as { webmcp_enabled?: boolean })
+ .webmcp_enabled;
// GitHub auto-install: best-effort. If env is missing or the user has
// no connected installations, we just hide the button.
@@ -276,6 +279,25 @@ export default async function ProjectStatsPage({
)}
+ {trackerEnabled && (
+
+
+
+
WebMCP plugin
+
+ {webmcpEnabled
+ ? "On. In browsers with WebMCP, your snippet gives AI agents tools for this site (site info, pages, search, forms, and your MCP server), and each tool an agent runs shows up here as a webmcp_tool event."
+ : "Optional module. Let AI agents in your visitors' browsers use your site through tools instead of clicks. Only loads in browsers that ship WebMCP."}{" "}
+
+ How it works
+
+
+ );
+}
diff --git a/app/(marketing)/docs/statistics/page.tsx b/app/(marketing)/docs/statistics/page.tsx
index ad69b429..5946338d 100644
--- a/app/(marketing)/docs/statistics/page.tsx
+++ b/app/(marketing)/docs/statistics/page.tsx
@@ -240,6 +240,15 @@ chromium --load-extension=./crawlproof-declare --disable-extensions-except=./cra
The module is only fetched in browsers that ship WebMCP, so it costs
other visitors nothing.
+
+ No attribute? Switch it on per project under{" "}
+ Stats → WebMCP plugin instead and
+ the snippet you already installed picks it up.{" "}
+ data-webmcp="off" on the
+ tag always wins. It never loads twice: not when the page includes
+ stats.js twice, and not when the page already carries its own copy of
+ the embed.
+
Every tool an agent runs shows up in your stats as a{" "}
webmcp_tool event whose target is
diff --git a/app/actions/webmcp.ts b/app/actions/webmcp.ts
new file mode 100644
index 00000000..62d3e5af
--- /dev/null
+++ b/app/actions/webmcp.ts
@@ -0,0 +1,33 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { requireProjectAccess } from "@/lib/lx/currentSite";
+
+type Result = { ok: true; enabled: boolean } | { ok: false; error: string };
+
+// Flip the WebMCP plugin on/off. Like careers, it rides on the stats tracker,
+// so enabling it without the tracker would do nothing; we say so.
+export async function setWebmcpEnabled(input: { projectId: string; enabled: boolean }): Promise {
+ const access = await requireProjectAccess(input.projectId);
+ if (!access.ok) return { ok: false, error: access.error };
+
+ if (input.enabled) {
+ const { data: project } = await access.supabase
+ .from("projects")
+ .select("tracker_enabled")
+ .eq("id", input.projectId)
+ .maybeSingle();
+ if (!project?.tracker_enabled) {
+ return { ok: false, error: "Enable the stats tracker first. The WebMCP plugin loads through it." };
+ }
+ }
+
+ const patch: Record = { webmcp_enabled: input.enabled };
+ if (input.enabled) patch.webmcp_enabled_at = new Date().toISOString();
+
+ const { error } = await access.supabase.from("projects").update(patch).eq("id", input.projectId);
+ if (error) return { ok: false, error: error.message };
+
+ revalidatePath(`/dashboard/projects/${input.projectId}/stats`);
+ return { ok: true, enabled: input.enabled };
+}
diff --git a/app/api/tracker/v1/webmcp/route.ts b/app/api/tracker/v1/webmcp/route.ts
new file mode 100644
index 00000000..7bd945d7
--- /dev/null
+++ b/app/api/tracker/v1/webmcp/route.ts
@@ -0,0 +1,40 @@
+// WebMCP plugin config for /stats.js.
+// GET /api/tracker/v1/webmcp?site= -> { enabled, config }
+//
+// stats.js only asks when the visitor's browser ships WebMCP and the tag has no
+// data-webmcp attribute of its own, so this is a rare request. Unknown or
+// disabled projects answer { enabled: false } (never a 404) so the failure mode
+// is "no tools", not a broken host page.
+
+import { NextRequest, NextResponse } from "next/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { publicWebmcpConfig } from "@/lib/tracker/webmcp";
+
+export const runtime = "nodejs";
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+const headers = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-methods": "GET, OPTIONS",
+ "cache-control": "public, max-age=300, s-maxage=300",
+};
+
+export function OPTIONS() {
+ return new NextResponse(null, { status: 204, headers });
+}
+
+export async function GET(request: NextRequest) {
+ const site = request.nextUrl.searchParams.get("site") ?? "";
+ if (!UUID.test(site)) return NextResponse.json({ enabled: false }, { headers });
+ const { data } = await serviceClient()
+ .from("projects")
+ .select("tracker_enabled, webmcp_enabled, webmcp_config")
+ .eq("id", site)
+ .maybeSingle();
+ const enabled = Boolean(data?.tracker_enabled && data?.webmcp_enabled);
+ return NextResponse.json(
+ enabled ? { enabled, config: publicWebmcpConfig(data?.webmcp_config) } : { enabled: false },
+ { headers },
+ );
+}
diff --git a/app/stats.js/route.ts b/app/stats.js/route.ts
index 189b37ad..4b08c099 100644
--- a/app/stats.js/route.ts
+++ b/app/stats.js/route.ts
@@ -22,6 +22,12 @@ const snippet = `(function(){
}
var siteId = s && s.dataset && s.dataset.site;
if (!siteId) return;
+ // Load once per site per page. A second copy of the tag (a template and a
+ // plugin both adding it, or a SPA re-inserting it) would double every
+ // pageview, click and module load, so it stops here.
+ var loaded = window.__crawlproofStats = window.__crawlproofStats || {};
+ if (loaded[siteId]) return;
+ loaded[siteId] = true;
var endpoint = ${JSON.stringify(env.siteUrl)} + '/api/track';
// Careers module. Opt-in per project in the dashboard, but we only pay to
@@ -55,20 +61,36 @@ 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.
+ // WebMCP plugin. Loads the @profullstack/webmcp embed (site_info, open_page,
+ // search, forms as tools, and the site's own MCP server) so an AI agent in
+ // the visitor's browser can use the site through tools instead of clicks.
+ // On when the tag has data-webmcp, or when the project switched it on in
+ // the dashboard (asked only if the tag says nothing); data-webmcp="off"
+ // always wins. Only browsers that ship WebMCP ever fetch anything; everyone
+ // else pays one property check. Loaded at most once per page: skipped when
+ // the page already has the embed or another copy of this tag injected it.
(function(){
try {
var d = s.dataset;
- if (d.webmcp === undefined || d.webmcp === 'off' || d.webmcp === 'false') return;
+ if (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;
+ function present() {
+ return window.__crawlproofWebMCP || window.ProfullstackWebMCP
+ // The site's own tag may not have run yet (module scripts are
+ // deferred, this one is not), so look for the element too.
+ || document.querySelector('script[src*="@profullstack/webmcp"],script[type="module"][data-webmcp]');
+ }
+ if (present()) return;
+ if (d.webmcp !== undefined) return inject({});
+ fetch(${JSON.stringify(env.siteUrl)} + '/api/tracker/v1/webmcp?site=' + encodeURIComponent(siteId), { credentials: 'omit', mode: 'cors' })
+ .then(function(r){ return r.ok ? r.json() : null; })
+ .then(function(j){ if (j && j.enabled && !present()) inject(j.config || {}); })
+ .catch(function(){});
+ } catch (_) {}
+ function inject(server) {
+ window.__crawlproofWebMCP = true;
var cfg = window.webmcpConfig || {};
function put(key, value) { if (value != null && value !== '' && cfg[key] == null) cfg[key] = value; }
put('mcp', d.webmcpMcp);
@@ -82,13 +104,15 @@ const snippet = `(function(){
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);
+ // Tag attributes first, then the dashboard config for anything unset.
+ for (var k in server) put(k, server[k]);
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; }
diff --git a/lib/tracker/webmcp.ts b/lib/tracker/webmcp.ts
new file mode 100644
index 00000000..fd09f26a
--- /dev/null
+++ b/lib/tracker/webmcp.ts
@@ -0,0 +1,25 @@
+// WebMCP plugin config (projects.webmcp_config) as served to /stats.js.
+// Only the embed's known options pass, as strings / arrays / objects of strings,
+// so nothing else stored in the column can leak to the public endpoint.
+
+const STRING_KEYS = ["mcp", "mcpPrefix", "search", "searchApi", "searchDescription", "name", "description"] as const;
+
+export function publicWebmcpConfig(raw: unknown): Record {
+ const src = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {};
+ const out: Record = {};
+ for (const k of STRING_KEYS) {
+ const v = src[k];
+ if (typeof v === "string" && v.trim()) out[k] = v.trim().slice(0, 300);
+ }
+ if (Array.isArray(src.mcpExclude)) {
+ out.mcpExclude = src.mcpExclude.filter((x): x is string => typeof x === "string").slice(0, 50);
+ }
+ if (src.pages && typeof src.pages === "object" && !Array.isArray(src.pages)) {
+ const pages: Record = {};
+ for (const [label, path] of Object.entries(src.pages as Record).slice(0, 20)) {
+ if (typeof path === "string" && path.startsWith("/")) pages[label.slice(0, 60)] = path.slice(0, 300);
+ }
+ if (Object.keys(pages).length) out.pages = pages;
+ }
+ return out;
+}
diff --git a/supabase/migrations/20261111100000_webmcp_plugin.sql b/supabase/migrations/20261111100000_webmcp_plugin.sql
new file mode 100644
index 00000000..039dc340
--- /dev/null
+++ b/supabase/migrations/20261111100000_webmcp_plugin.sql
@@ -0,0 +1,19 @@
+-- WebMCP plugin: an opt-in module of the drop-in tracker (/stats.js).
+-- With webmcp_enabled, the same snippet loads @profullstack/webmcp in browsers
+-- that ship WebMCP (document.modelContext), so an AI agent in the visitor's
+-- browser gets site_info, open_page, search and form tools, plus the site's own
+-- MCP server when webmcp_config.mcp names it. A data-webmcp attribute on the tag
+-- still works without this flag; data-webmcp="off" on the tag always wins.
+--
+-- webmcp_config holds the embed's options: mcp, mcpPrefix, mcpExclude (array),
+-- search, searchApi, searchDescription, pages (object), name, description.
+-- Served publicly by /api/tracker/v1/webmcp, so it must never hold secrets.
+
+alter table public.projects
+ add column if not exists webmcp_enabled boolean not null default false;
+
+alter table public.projects
+ add column if not exists webmcp_enabled_at timestamptz;
+
+alter table public.projects
+ add column if not exists webmcp_config jsonb not null default '{}'::jsonb;
diff --git a/tests/contract/stats-js.test.ts b/tests/contract/stats-js.test.ts
index fa9c9b8d..75759686 100644
--- a/tests/contract/stats-js.test.ts
+++ b/tests/contract/stats-js.test.ts
@@ -48,8 +48,11 @@ describe("/stats.js", () => {
const response = await GET();
const script = await response.text();
- expect(script).toContain("d.webmcp === undefined");
+ expect(script).toContain("d.webmcp === 'off'");
expect(script).toContain("document.modelContext || navigator.modelContext");
+ // Dashboard opt-in is only asked for when the tag says nothing.
+ expect(script).toContain("/api/tracker/v1/webmcp?site=");
+ expect(script).toContain("window.__crawlproofWebMCP");
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.
@@ -61,6 +64,14 @@ describe("/stats.js", () => {
expect(script).not.toContain("c.result");
});
+ it("runs once per site per page even when the tag is included twice", async () => {
+ const response = await GET();
+ const script = await response.text();
+
+ expect(script).toContain("window.__crawlproofStats = window.__crawlproofStats || {}");
+ expect(script).toContain("if (loaded[siteId]) return;");
+ });
+
it("does not collect identity, consent, or lead-capture PII", async () => {
const response = await GET();
const script = await response.text();