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
22 changes: 22 additions & 0 deletions app/(app)/dashboard/projects/[id]/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -276,6 +279,25 @@ export default async function ProjectStatsPage({
</section>
)}

{trackerEnabled && (
<section className="card p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h2 className="text-lg font-semibold">WebMCP plugin</h2>
<p className="text-sm text-[var(--color-muted)]">
{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."}{" "}
<a href="/docs/statistics#webmcp" className="underline hover:text-[var(--color-foreground)]">
How it works
</a>
</p>
</div>
<WebmcpToggle projectId={id} initialEnabled={webmcpEnabled} />
</div>
</section>
)}

<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h2 className="text-lg font-semibold">Traffic</h2>
Expand Down
37 changes: 37 additions & 0 deletions app/(app)/dashboard/projects/[id]/stats/webmcp-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { useState, useTransition } from "react";
import { setWebmcpEnabled } from "@/app/actions/webmcp";

export function WebmcpToggle({ projectId, initialEnabled }: { projectId: string; initialEnabled: boolean }) {
const [enabled, setEnabled] = useState(initialEnabled);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);

function flip() {
const next = !enabled;
setError(null);
startTransition(async () => {
const res = await setWebmcpEnabled({ projectId, enabled: next });
if (!res.ok) {
setError(res.error);
return;
}
setEnabled(res.enabled);
});
}

return (
<div className="space-y-2">
<button
type="button"
onClick={flip}
disabled={pending}
className={`btn ${enabled ? "btn-secondary" : "btn-primary"}`}
>
{pending ? "…" : enabled ? "Turn off WebMCP" : "Turn on WebMCP"}
</button>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
);
}
9 changes: 9 additions & 0 deletions app/(marketing)/docs/statistics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</p>
<p className="text-sm leading-relaxed">
No attribute? Switch it on per project under{" "}
<span className="font-mono">Stats → WebMCP plugin</span> instead and
the snippet you already installed picks it up.{" "}
<span className="font-mono">data-webmcp=&quot;off&quot;</span> 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.
</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
Expand Down
33 changes: 33 additions & 0 deletions app/actions/webmcp.ts
Original file line number Diff line number Diff line change
@@ -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<Result> {
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<string, unknown> = { 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 };
}
40 changes: 40 additions & 0 deletions app/api/tracker/v1/webmcp/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// WebMCP plugin config for /stats.js.
// GET /api/tracker/v1/webmcp?site=<project_id> -> { 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 },
);
}
42 changes: 33 additions & 9 deletions app/stats.js/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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; }
Expand Down
25 changes: 25 additions & 0 deletions lib/tracker/webmcp.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const src = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
const out: Record<string, unknown> = {};
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<string, string> = {};
for (const [label, path] of Object.entries(src.pages as Record<string, unknown>).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;
}
19 changes: 19 additions & 0 deletions supabase/migrations/20261111100000_webmcp_plugin.sql
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 12 additions & 1 deletion tests/contract/stats-js.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand Down