@analitikisler/ai-tag-debugger 0.0.0-stage → 0.5.2

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/src/mcp.js ADDED
@@ -0,0 +1,425 @@
1
+ // MCP server: lets the AI in a chat app (Claude, ChatGPT, Gemini, or any MCP client)
2
+ // drive the audit itself, so no separate API key is needed.
3
+ //
4
+ // stdio (default): for apps that start local servers, e.g. Claude Desktop, Gemini CLI, Cursor.
5
+ // --http: Streamable HTTP, for apps that connect to a URL, e.g. ChatGPT and Gemini
6
+ // (expose it over HTTPS with a tunnel or host it).
7
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
8
+ import { createServer } from "node:http";
9
+ import { readFile } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import path from "node:path";
12
+ import { z } from "zod";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
16
+ import { AuditSession } from "./browser.js";
17
+ import { navigationError, privateHostChecker } from "./guard.js";
18
+ import { collect, pageView, SYSTEM_PROMPT } from "./agent.js";
19
+ import { checkRun, groupFindings, summarize } from "./checks.js";
20
+ import { LANGUAGES, messages } from "./i18n.js";
21
+ import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
22
+ import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
23
+ import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
24
+
25
+ const VERSION = "0.5.2";
26
+ const MAX_OPEN_AUDITS = 4;
27
+ const IDLE_MS = 15 * 60 * 1000; // an audit untouched this long is closed
28
+ const MAX_BODY_BYTES = 1024 * 1024;
29
+ const MIN_TOKEN_LENGTH = 16;
30
+
31
+ const INSTRUCTIONS = `ai-tag-debugger checks a website's analytics and ad tracking (GA4, Google Ads, Meta Pixel, TikTok Pixel, Consent Mode).
32
+
33
+ Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}); pass their choice as providers.
34
+
35
+ To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit with the measurement plan to get the findings and a full HTML report. Explain the findings to the user in plain language.
36
+
37
+ While browsing:
38
+ ${SYSTEM_PROMPT.split("How to work:\n")[1]}`;
39
+
40
+ /** "Checked: GA4, Meta. Out of scope: Microsoft Clarity 5 (5 requests skipped)." */
41
+ function skippedLine(run) {
42
+ const totals = {};
43
+ for (const counts of Object.values(run.skipped ?? {})) for (const [v, n] of Object.entries(counts)) totals[v] = (totals[v] ?? 0) + n;
44
+ const checked = `Providers checked: ${(run.providers ?? []).map((k) => TRACKING_PROVIDERS[k] ?? k).join(", ")}.`;
45
+ const entries = Object.entries(totals);
46
+ return entries.length ? `${checked} Out of scope, answered locally and not checked: ${entries.map(([v, n]) => `${v} ${n}`).join(", ")}.` : checked;
47
+ }
48
+
49
+ /** Open audits, by id. One browser each. */
50
+ const audits = new Map();
51
+
52
+ const text = (t) => ({ content: [{ type: "text", text: t }] });
53
+ const fail = (t) => ({ content: [{ type: "text", text: t }], isError: true });
54
+
55
+ function describeHit(h) {
56
+ const params = Object.entries(h.params ?? {})
57
+ .filter(([k]) => !["v", "tid", "gtm", "_p", "cid", "sid", "_s", "sct", "seg", "dl", "dr", "dt", "ul", "sr", "_et", "en"].includes(k))
58
+ .slice(0, 12)
59
+ .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`.slice(0, 120))
60
+ .join(", ");
61
+ return `- ${h.platform} ${h.name}${h.consent ? ` [consent ${Object.entries(h.consent).map(([k, v]) => `${k}=${v}`).join(" ")}]` : ""}${params ? `: ${params}` : ""}`;
62
+ }
63
+
64
+ function stepCapture(audit, step) {
65
+ const hits = audit.session.hits.filter((h) => h.step === step);
66
+ const pushes = audit.session.dataLayerLog.filter((e) => e.step === step);
67
+ const lines = [`Tracking hits sent by this step: ${hits.length}`, ...hits.map(describeHit)];
68
+ if (pushes.length) lines.push(`dataLayer pushes: ${pushes.map((p) => JSON.stringify(p.value).slice(0, 160)).join(" | ")}`);
69
+ return lines.join("\n");
70
+ }
71
+
72
+ function getAudit(id) {
73
+ const audit = audits.get(id);
74
+ if (!audit) throw new Error(`No open audit "${id}". Start one with start_audit. Audits close after ${IDLE_MS / 60000} minutes without activity.`);
75
+ audit.lastUsed = Date.now();
76
+ return audit;
77
+ }
78
+
79
+ /** Closes audits nobody has touched for a while, so abandoned ones don't hold browsers and slots. */
80
+ export async function closeIdleAudits(now = Date.now(), idleMs = IDLE_MS) {
81
+ const idle = [...audits.values()].filter((a) => now - a.lastUsed > idleMs).map((a) => a.id);
82
+ await Promise.all(idle.map(closeAudit));
83
+ return idle;
84
+ }
85
+ setInterval(() => closeIdleAudits().catch(() => {}), 60 * 1000).unref();
86
+
87
+ async function closeAudit(id) {
88
+ const audit = audits.get(id);
89
+ audits.delete(id);
90
+ await audit?.session.close().catch(() => {});
91
+ }
92
+
93
+ /** The plan can be given inline (JSON text) or, for local servers, as a file path. */
94
+ async function readPlan(plan, allowFiles) {
95
+ if (!plan) return { events: [] };
96
+ const trimmed = plan.trim();
97
+ if (trimmed.startsWith("{")) return JSON.parse(trimmed);
98
+ if (!allowFiles) throw new Error("Over HTTP, pass the measurement plan as JSON text, not a file path.");
99
+ return readJson(trimmed);
100
+ }
101
+
102
+ /**
103
+ * @param {{ allowFiles: boolean, blockPrivate?: boolean, reportsDir: string, reportsUrl?: string }} opts
104
+ * allowFiles: local (stdio) servers may read config and plan files; HTTP servers may not.
105
+ * blockPrivate: the browser refuses loopback, private and link-local addresses (HTTP servers, by default).
106
+ * reportsUrl: where an HTTP server serves finished reports, if it knows its public URL.
107
+ */
108
+ export function buildServer(opts) {
109
+ const server = new McpServer({ name: "ai-tag-debugger", version: VERSION }, { instructions: INSTRUCTIONS });
110
+
111
+ server.registerTool(
112
+ "start_audit",
113
+ {
114
+ title: "Start a tracking audit",
115
+ description:
116
+ "Opens a fresh browser on a website and starts recording every analytics and ad tracking request (GA4, Google Ads, Meta, TikTok) and dataLayer push. Tracking requests are answered locally, so no test data reaches the real analytics or ad accounts. Returns an audit_id and the first page view. Then use browser_action to walk through one journey (for example product, cart, checkout) and finish_audit to check the results.",
117
+ inputSchema: {
118
+ site: z.string().describe("The page to open, e.g. https://staging.example.com/"),
119
+ journey: z
120
+ .string()
121
+ .optional()
122
+ .describe(`A ready-made journey (${Object.keys(JOURNEY_PRESETS).join(", ")}), which comes with its goal and the events to expect, or a short name for your own journey, e.g. newsletter.`),
123
+ viewport: z.enum(["desktop", "mobile"]).optional().describe("Default desktop."),
124
+ allowed_hosts: z
125
+ .array(z.string())
126
+ .optional()
127
+ .describe("Other hosts the journey may visit, e.g. a hosted checkout like checkout.example-payments.com. The browser stays on the site's own host and subdomains otherwise."),
128
+ providers: z
129
+ .array(z.enum(Object.keys(TRACKING_PROVIDERS)))
130
+ .optional()
131
+ .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}. Requests to the others are still answered locally, but they are only counted, not shown or checked.`),
132
+ },
133
+ },
134
+ async ({ site, journey, viewport, allowed_hosts, providers }) => {
135
+ let url;
136
+ try {
137
+ url = new URL(site);
138
+ if (!["http:", "https:"].includes(url.protocol)) throw new Error();
139
+ } catch {
140
+ return fail("site must be an http or https URL.");
141
+ }
142
+ if (opts.blockPrivate) {
143
+ const refused = await navigationError(url.href, { isPrivate: privateHostChecker() });
144
+ if (refused) return fail(`${refused} Over HTTP, audits are limited to public websites.`);
145
+ }
146
+ await closeIdleAudits();
147
+ if (audits.size >= MAX_OPEN_AUDITS) return fail(`${audits.size} audits are already open. Finish one with finish_audit first.`);
148
+ let session;
149
+ try {
150
+ session = new AuditSession({ viewport: viewport ?? "desktop", site: url.href, allowedHosts: allowed_hosts, providers, blockPrivate: opts.blockPrivate });
151
+ } catch (err) {
152
+ return fail(err.message);
153
+ }
154
+ try {
155
+ await session.start();
156
+ } catch (err) {
157
+ await session.close().catch(() => {});
158
+ return fail(`Could not start the browser: ${err.message}. If Chromium is missing, run: npx playwright install chromium`);
159
+ }
160
+ const id = randomUUID().slice(0, 8);
161
+ const preset = presetFor(journey);
162
+ const name = preset?.id ?? ((journey || "journey").toLowerCase().replace(/[^\w-]+/g, "-").slice(0, 40) || "journey");
163
+ const audit = { id, session, site: url.href, journey: name, preset, viewport: viewport ?? "desktop", lastUsed: Date.now() };
164
+ audits.set(id, audit);
165
+ const error = await session.act({ action: "goto", url: url.href, reason: `Open ${url.href}` });
166
+ const page = pageView(await session.snapshot(), error);
167
+ const goal = preset ? `Journey goal: ${preset.goal}\nEvents expected on the way: ${preset.events.map((e) => `${e.platform} ${e.name}`).join(", ")}\n\n` : "";
168
+ return text(`audit_id: ${id}\n${goal}\n${page}\n\n${stepCapture(audit, session.currentStep)}`);
169
+ },
170
+ );
171
+
172
+ server.registerTool(
173
+ "browser_action",
174
+ {
175
+ title: "Act in the audit browser",
176
+ description:
177
+ "Performs one action in the audit's browser and returns the updated page plus the tracking hits that action sent. Actions: goto (url), click (ref or text), fill (ref or text, value), select (ref or text, value), scroll (value = down or up), wait (value = milliseconds). Refs come from the latest page view, which lists on-screen elements first; if an element has no ref, scroll or pass its visible text in text. If a cookie banner appears, accept it and set accepts_consent to true on that click. Use test data in forms and never enter real payment details.",
178
+ inputSchema: {
179
+ audit_id: z.string(),
180
+ action: z.enum(["goto", "click", "fill", "select", "scroll", "wait"]),
181
+ ref: z.number().int().optional().describe("Element ref for click, fill and select."),
182
+ text: z.string().optional().describe("Instead of ref: the element's visible text or label, e.g. \"Book now\"."),
183
+ value: z.string().optional().describe("Text to type, option to select, or milliseconds to wait."),
184
+ url: z.string().optional().describe("Destination for goto, absolute or relative to the current page."),
185
+ reason: z.string().describe("Short description of the step, e.g. 'Add the first product to the cart'. Shown in the report."),
186
+ accepts_consent: z.boolean().optional().describe("True only when this click accepts the cookie or consent banner."),
187
+ },
188
+ },
189
+ async (input) => {
190
+ const audit = getAudit(input.audit_id);
191
+ if (["click", "fill", "select"].includes(input.action) && !Number.isInteger(input.ref) && !input.text?.trim()) return fail(`${input.action} needs a ref or text.`);
192
+ if (input.action === "goto" && !input.url) return fail("goto needs a url.");
193
+ const error = await audit.session.act(input);
194
+ const page = pageView(await audit.session.snapshot(), error);
195
+ return text(`${page}\n\n${stepCapture(audit, audit.session.currentStep)}`);
196
+ },
197
+ );
198
+
199
+ server.registerTool(
200
+ "get_page",
201
+ {
202
+ title: "Show the current page",
203
+ description: "Returns the current page view of an open audit without doing anything.",
204
+ inputSchema: { audit_id: z.string() },
205
+ annotations: { readOnlyHint: true },
206
+ },
207
+ async ({ audit_id }) => text(pageView(await getAudit(audit_id).session.snapshot())),
208
+ );
209
+
210
+ server.registerTool(
211
+ "finish_audit",
212
+ {
213
+ title: "Finish and check the audit",
214
+ description:
215
+ "Closes the browser, checks every captured hit against the measurement plan (missing events, missing parameters, duplicates, hits before consent, Consent Mode defaults, emails sent in plain text, unplanned events) and writes a full HTML report with the event timeline. Returns the findings.",
216
+ inputSchema: {
217
+ audit_id: z.string(),
218
+ completed: z.boolean().describe("Whether the journey reached its goal."),
219
+ note: z.string().describe("One sentence on how the journey ended."),
220
+ measurement_plan: z
221
+ .string()
222
+ .optional()
223
+ .describe(
224
+ 'The measurement plan as JSON text, e.g. {"consent_mode": true, "events": [{"platform": "ga4", "name": "purchase", "required_params": ["transaction_id", "value", "currency"]}]}. On a local server this can also be a file path. Without a plan, a ready-made journey checks its own events; otherwise only consent, duplicate and personal data checks run.',
225
+ ),
226
+ events: z.array(z.string()).optional().describe("Only check these events, e.g. [\"purchase\", \"add_to_cart\"]. On your own journey, these events are also expected to fire."),
227
+ language: z.enum(Object.keys(LANGUAGES)).optional().describe("Report language. Default en."),
228
+ branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
229
+ },
230
+ },
231
+ async ({ audit_id, completed, note, measurement_plan, events = [], language, branding }) => {
232
+ const audit = getAudit(audit_id);
233
+ let plan;
234
+ try {
235
+ plan = await readPlan(measurement_plan, opts.allowFiles);
236
+ if (!Array.isArray(plan.events)) throw new Error('The plan needs an "events" list.');
237
+ } catch (err) {
238
+ return fail(`Could not read the measurement plan: ${err.message}`);
239
+ }
240
+ if (audit.preset) plan = withPresetEvents(plan, audit.journey, audit.preset);
241
+ else plan = expectEvents(plan, audit.journey, events);
242
+ plan = onlyEvents(plan, events);
243
+ const lang = language ?? "en";
244
+ const journey = { id: audit.journey };
245
+ const run = { ...collect(audit.session, journey, audit.viewport), completed, note, cookies: await audit.session.cookies().catch(() => []) };
246
+ await closeAudit(audit_id);
247
+
248
+ const findings = filterFindings(checkRun(plan, run, { lang }), events);
249
+ const grouped = groupFindings(findings);
250
+ const counts = summarize(grouped);
251
+ const outDir = path.join(opts.reportsDir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${audit.journey}`);
252
+ const reportPath = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, branding: branding !== false });
253
+ const m = messages(lang);
254
+ const lines = [
255
+ m.ui.counts(counts),
256
+ "",
257
+ ...grouped.filter((f) => f.severity !== "ok").map((f) => `- ${m.severity[f.severity]}: ${f.detail}${f.evidence ? ` (captured: ${f.evidence})` : ""} [${f.where.map((w) => m.ui.whereStep(w.journey, w.viewport, w.step)).join("; ")}]`),
258
+ "",
259
+ `${run.hits.length} tracking hits, ${run.dataLayerLog.length} dataLayer pushes and ${run.network.length} analytics or ad network calls captured over ${run.steps.length} steps.`,
260
+ skippedLine(run),
261
+ opts.allowFiles
262
+ ? `Full report with the event timeline: ${reportPath}`
263
+ : opts.reportsUrl
264
+ ? `Full report with the event timeline: ${opts.reportsUrl}/${path.basename(outDir)}/report.html`
265
+ : "",
266
+ "",
267
+ "Explain each problem to the user with its likely cause and fix.",
268
+ ];
269
+ return text(lines.filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n"));
270
+ },
271
+ );
272
+
273
+ if (opts.allowFiles) {
274
+ server.registerTool(
275
+ "run_audit",
276
+ {
277
+ title: "Run a configured audit",
278
+ description:
279
+ "Runs every journey in a ai-tag-debugger config file. With scripted true, it replays each journey's fixed steps and needs no API key. Otherwise the journeys are browsed by the AI provider configured on this computer (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY). Writes the reports and returns the findings.",
280
+ inputSchema: {
281
+ config: z.string().describe("Path to the config file, e.g. ai-tag-debugger.config.json"),
282
+ scripted: z.boolean().optional().describe("Replay fixed steps instead of browsing with an AI model. Default true."),
283
+ language: z.enum(Object.keys(LANGUAGES)).optional(),
284
+ branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
285
+ providers: z
286
+ .array(z.enum(Object.keys(TRACKING_PROVIDERS)))
287
+ .optional()
288
+ .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}. Requests to the others are still answered locally, but they are only counted, not shown or checked.`),
289
+ },
290
+ },
291
+ async ({ config: configFile, scripted, language, branding, providers }) => {
292
+ const loaded = await loadConfig(configFile);
293
+ const replay = scripted !== false;
294
+ const result = await runAudit({ ...loaded, scripted: replay, ai: !replay, lang: language, branding, providers, outDir: path.join(opts.reportsDir, new Date().toISOString().replace(/[:.]/g, "-")) });
295
+ const m = messages(result.lang);
296
+ const grouped = groupFindings(result.findings).filter((f) => f.severity !== "ok");
297
+ return text(
298
+ [
299
+ m.ui.counts(result.counts),
300
+ ...result.runs.map((r) => `${r.journeyId} (${r.viewport}): ${r.completed ? "completed" : "not completed"}, ${r.note}`),
301
+ ...grouped.map((f) => `- ${m.severity[f.severity]}: ${f.detail}`),
302
+ `Report: ${result.reportPath}`,
303
+ ].join("\n"),
304
+ );
305
+ },
306
+ );
307
+ }
308
+
309
+ return server;
310
+ }
311
+
312
+ /** Starts the MCP server on stdio, or on HTTP with --http. */
313
+ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1", token, reportsDir, publicUrl, allowPrivateNetwork = false } = {}) {
314
+ const dir = path.resolve(reportsDir ?? path.join(homedir(), "ai-tag-debugger-reports"));
315
+ const shutdown = async () => {
316
+ await Promise.all([...audits.keys()].map(closeAudit));
317
+ process.exit(0);
318
+ };
319
+ process.on("SIGINT", shutdown);
320
+ process.on("SIGTERM", shutdown);
321
+
322
+ if (!http) {
323
+ const server = buildServer({ allowFiles: true, reportsDir: dir });
324
+ await server.connect(new StdioServerTransport());
325
+ // stdout carries the protocol, so status goes to stderr.
326
+ console.error(`ai-tag-debugger MCP server ready on stdio. Reports go to ${dir}`);
327
+ return;
328
+ }
329
+
330
+ // The URL carries a secret path, so only someone who has the URL can use the browser.
331
+ if (token !== undefined && (token.length < MIN_TOKEN_LENGTH || !/^[\w-]+$/.test(token))) {
332
+ throw new Error(`--token must be at least ${MIN_TOKEN_LENGTH} characters of letters, digits, - and _. Leave it out to get a random one.`);
333
+ }
334
+ if (!["127.0.0.1", "localhost", "::1"].includes(host)) {
335
+ console.error(`Warning: listening on ${host}, so other machines on the network can reach this server. Anyone with the URL can drive its browser.`);
336
+ }
337
+ if (allowPrivateNetwork) {
338
+ console.error("Warning: --allow-private-network lets audits open localhost and private network addresses. Use it only for a local tunnel you control.");
339
+ }
340
+ const secret = token ?? randomBytes(18).toString("base64url");
341
+ const endpoint = `/mcp/${secret}`;
342
+ const digest = (s) => createHash("sha256").update(s).digest();
343
+ const secretDigest = digest(secret);
344
+ // Compares the secret segment in constant time: /mcp/<secret> or /mcp/<secret>/...
345
+ const authorized = (pathname) => {
346
+ const m = pathname.match(/^\/mcp\/([^/]+)(\/.*)?$/);
347
+ return m ? { ok: timingSafeEqual(digest(m[1]), secretDigest), rest: m[2] ?? "" } : { ok: false, rest: "" };
348
+ };
349
+ const securityHeaders = {
350
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
351
+ "X-Content-Type-Options": "nosniff",
352
+ "Referrer-Policy": "no-referrer",
353
+ };
354
+ const reportsUrl = publicUrl ? `${publicUrl.replace(/\/+$/, "")}${endpoint}/reports` : undefined;
355
+ const httpServer = createServer(async (req, res) => {
356
+ const pathname = new URL(req.url, "http://x").pathname;
357
+ const { ok, rest } = authorized(pathname);
358
+ if (!ok) {
359
+ res.writeHead(404).end();
360
+ return;
361
+ }
362
+ // Finished reports, behind the same secret path: /mcp/<secret>/reports/<audit folder>/<file>
363
+ const report = rest.startsWith("/reports/") && rest.slice(9).match(/^(\w[\w.-]*)\/(report\.html|report\.md|timeline\.csv)$/);
364
+ if (report && req.method === "GET") {
365
+ try {
366
+ const body = await readFile(path.join(dir, report[1], report[2]));
367
+ const type = { html: "text/html", md: "text/markdown", csv: "text/csv" }[report[2].split(".").pop()];
368
+ res.writeHead(200, { ...securityHeaders, "Content-Type": `${type}; charset=utf-8` }).end(body);
369
+ } catch {
370
+ res.writeHead(404).end();
371
+ }
372
+ return;
373
+ }
374
+ if (rest !== "") {
375
+ res.writeHead(404).end();
376
+ return;
377
+ }
378
+ if (req.method !== "POST") {
379
+ res.writeHead(405, { Allow: "POST" }).end();
380
+ return;
381
+ }
382
+ if (Number(req.headers["content-length"]) > MAX_BODY_BYTES) {
383
+ res.writeHead(413, { Connection: "close" }).end("Request body too large");
384
+ req.destroy();
385
+ return;
386
+ }
387
+ let body;
388
+ try {
389
+ const chunks = [];
390
+ let size = 0;
391
+ for await (const chunk of req) {
392
+ size += chunk.length;
393
+ if (size > MAX_BODY_BYTES) {
394
+ res.writeHead(413, { Connection: "close" }).end("Request body too large");
395
+ req.destroy();
396
+ return;
397
+ }
398
+ chunks.push(chunk);
399
+ }
400
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
401
+ } catch {
402
+ if (!res.headersSent) res.writeHead(400).end("Invalid JSON");
403
+ return;
404
+ }
405
+ // Stateless: a new server and transport per request. Open audits live in the module, keyed by audit_id.
406
+ const server = buildServer({ allowFiles: false, blockPrivate: !allowPrivateNetwork, reportsDir: dir, reportsUrl });
407
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
408
+ res.on("close", () => {
409
+ transport.close();
410
+ server.close();
411
+ });
412
+ try {
413
+ await server.connect(transport);
414
+ await transport.handleRequest(req, res, body);
415
+ } catch (err) {
416
+ if (!res.headersSent) res.writeHead(500).end(err.message);
417
+ }
418
+ });
419
+ await new Promise((resolve) => httpServer.listen(port, host, resolve));
420
+ console.error(`ai-tag-debugger MCP server listening on http://${host}:${port}${endpoint}`);
421
+ if (publicUrl) console.error(`Connect your chat app to ${publicUrl.replace(/\/+$/, "")}${endpoint}`);
422
+ else console.error("Chat apps need an HTTPS URL: expose this port with a tunnel (e.g. cloudflared or ngrok), keep the same path, and pass --public-url <https://your-tunnel> so report links work.");
423
+ console.error(`Reports go to ${dir}`);
424
+ return httpServer;
425
+ }
package/src/parsers.js ADDED
@@ -0,0 +1,234 @@
1
+ // Turns raw network requests into normalized tracking hits.
2
+ import { isUserDataKey } from "./redact.js";
3
+ // Each parser returns an array of hits (one request can carry several events,
4
+ // e.g. a batched GA4 POST) or null when the request is not for that vendor.
5
+
6
+ /** @typedef {{ platform: string, name: string, params: Record<string,string>, id?: string, consent?: Record<string,string>, url: string }} Hit */
7
+
8
+ const GA4_PARAM_ALIASES = { cu: "currency", dl: "page_location", dt: "page_title", dr: "page_referrer" };
9
+
10
+ function ga4ParamsFrom(searchParams) {
11
+ const params = {};
12
+ for (const [key, value] of searchParams) {
13
+ if (key.startsWith("ep.")) params[key.slice(3)] = value;
14
+ else if (key.startsWith("epn.")) params[key.slice(4)] = value;
15
+ else if (key in GA4_PARAM_ALIASES) params[GA4_PARAM_ALIASES[key]] = value;
16
+ else if (/^pr\d+$/.test(key)) params.items = (params.items ? params.items + "|" : "") + value;
17
+ }
18
+ return params;
19
+ }
20
+
21
+ // gcs=G1xy: x = ad_storage, y = analytics_storage (1 granted, 0 denied).
22
+ function ga4Consent(searchParams) {
23
+ const gcs = searchParams.get("gcs");
24
+ if (!gcs || !/^G1[01-]{2}$/.test(gcs)) return undefined;
25
+ const state = (c) => (c === "1" ? "granted" : c === "0" ? "denied" : "unknown");
26
+ return { ad_storage: state(gcs[2]), analytics_storage: state(gcs[3]) };
27
+ }
28
+
29
+ /** @returns {Hit[] | null} */
30
+ export function parseGa4(url, postData) {
31
+ const u = new URL(url);
32
+ // Matched on the GA4 protocol rather than the host, so server-side GTM
33
+ // endpoints on first-party domains are captured too.
34
+ if (!u.pathname.endsWith("/g/collect") || u.searchParams.get("v") !== "2") return null;
35
+ const base = new URLSearchParams(u.search);
36
+ const lines = postData ? postData.split("\n").filter(Boolean) : [""];
37
+ return lines.map((line) => {
38
+ const merged = new URLSearchParams(base);
39
+ for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
40
+ const items = [...merged].filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v);
41
+ const user = [...merged].filter(([k]) => isUserDataKey(k));
42
+ return {
43
+ platform: "ga4",
44
+ name: merged.get("en") || "(unnamed)",
45
+ id: merged.get("tid") || undefined,
46
+ params: ga4ParamsFrom(merged),
47
+ consent: ga4Consent(merged),
48
+ ...(items.length ? { items } : {}),
49
+ ...(user.length ? { userFields: Object.fromEntries(user) } : {}),
50
+ url,
51
+ };
52
+ });
53
+ }
54
+
55
+ /** @returns {Hit[] | null} */
56
+ export function parseMeta(url) {
57
+ const u = new URL(url);
58
+ if (!/(^|\.)facebook\.com$/.test(u.hostname) || u.pathname !== "/tr" && u.pathname !== "/tr/") return null;
59
+ const params = {};
60
+ for (const [k, v] of u.searchParams) {
61
+ const m = k.match(/^cd\[(.+)\]$/);
62
+ if (m) params[m[1]] = v;
63
+ }
64
+ return [{ platform: "meta", name: u.searchParams.get("ev") || "(unnamed)", id: u.searchParams.get("id") || undefined, params, url }];
65
+ }
66
+
67
+ /** @returns {Hit[] | null} */
68
+ export function parseGoogleAds(url) {
69
+ const u = new URL(url);
70
+ const isAds =
71
+ (u.hostname === "www.googleadservices.com" && u.pathname.startsWith("/pagead/conversion/")) ||
72
+ (/(^|\.)google\.[a-z.]+$/.test(u.hostname) && u.pathname.startsWith("/pagead/1p-conversion/"));
73
+ if (!isAds) return null;
74
+ const conversionId = u.pathname.split("/").filter(Boolean)[2];
75
+ const params = {};
76
+ for (const key of ["label", "value", "currency_code", "oid"]) {
77
+ if (u.searchParams.has(key)) params[key === "currency_code" ? "currency" : key === "oid" ? "transaction_id" : key] = u.searchParams.get(key);
78
+ }
79
+ return [{ platform: "google_ads", name: params.label ? `conversion:${params.label}` : "conversion", id: conversionId, params, url }];
80
+ }
81
+
82
+ /** @returns {Hit[] | null} */
83
+ export function parseTikTok(url, postData) {
84
+ const u = new URL(url);
85
+ if (u.hostname !== "analytics.tiktok.com" || !u.pathname.startsWith("/api/v2/pixel")) return null;
86
+ let body = {};
87
+ try { body = postData ? JSON.parse(postData) : {}; } catch { /* not JSON */ }
88
+ const props = body.properties || body.context?.properties || {};
89
+ const params = Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]));
90
+ const user = Object.entries(body.context?.user ?? {}).filter(([k, v]) => isUserDataKey(`context.user.${k}`) && v);
91
+ return [{ platform: "tiktok", name: body.event || "(unnamed)", id: body.context?.pixel?.code, params, ...(user.length ? { userFields: Object.fromEntries(user.map(([k, v]) => [`context.user.${k}`, String(v)])) } : {}), url }];
92
+ }
93
+
94
+ /**
95
+ * X (Twitter) pixel. Basic: the event name comes from event_id (conversion events,
96
+ * "tw-…") or the first entry of "events" (e.g. pageview); value and quantity are kept.
97
+ * @returns {Hit[] | null}
98
+ */
99
+ export function parseX(url) {
100
+ const u = new URL(url);
101
+ if (!/^(analytics\.twitter\.com|t\.co)$/.test(u.hostname) || !/^\/(1\/)?i\/adsct/.test(u.pathname)) return null;
102
+ const q = u.searchParams;
103
+ let name = q.get("event_id") || "";
104
+ if (!name) {
105
+ try { name = JSON.parse(q.get("events") ?? "[]")?.[0]?.[0] ?? ""; } catch { /* not JSON */ }
106
+ }
107
+ const params = {};
108
+ for (const [from, to] of [["tw_sale_amount", "value"], ["tw_order_quantity", "quantity"], ["tw_document_href", "page_location"]]) {
109
+ if (q.has(from)) params[to] = q.get(from);
110
+ }
111
+ return [{ platform: "x", name: name || "(unnamed)", id: q.get("txn_id") || undefined, params, url }];
112
+ }
113
+
114
+ const PARSERS = [parseGa4, parseMeta, parseGoogleAds, parseTikTok, parseX];
115
+
116
+ /** Returns normalized hits for a request, or null when it is not a tracking request. */
117
+ export function parseTrackingRequest(url, postData) {
118
+ let parsedUrl;
119
+ try { parsedUrl = new URL(url); } catch { return null; }
120
+ if (!/^https?:$/.test(parsedUrl.protocol)) return null;
121
+ for (const parse of PARSERS) {
122
+ const hits = parse(url, postData);
123
+ if (hits) return hits;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ // Other beacons that report to ad and analytics accounts without being parsed into
129
+ // hits: Google Ads remarketing and consent pings, and the other common ad pixels.
130
+ const OTHER_BEACONS = [
131
+ [/(^|\.)(doubleclick\.net|googleadservices\.com|google\.[a-z.]+)$/, /^\/pagead\/(viewthroughconversion|1p-user-list|landing)\//],
132
+ [/(^|\.)google\.[a-z.]+$/, /^\/ccm\/collect/],
133
+ [/^td\.doubleclick\.net$/, /^\/td\//],
134
+ [/(^|\.)facebook\.com$/, /^\/privacy_sandbox\/pixel\//],
135
+ [/^bat\.bing\.com$/, /^\/action\//],
136
+ [/^px\.ads\.linkedin\.com$/, /^\/(collect|wa)/],
137
+ [/^ct\.pinterest\.com$/, /^\/(v3|user)/],
138
+ [/^tr\.snapchat\.com$/, /^\//],
139
+ ];
140
+
141
+ /**
142
+ * Collection endpoints we answer locally, so audits never send test traffic
143
+ * into the site's real analytics and ad accounts.
144
+ */
145
+ export function isCollectionEndpoint(url) {
146
+ if (parseTrackingRequest(url, null) !== null) return true;
147
+ let u;
148
+ try { u = new URL(url); } catch { return false; }
149
+ if (!/^https?:$/.test(u.protocol)) return false;
150
+ return OTHER_BEACONS.some(([host, pathname]) => host.test(u.hostname) && pathname.test(u.pathname));
151
+ }
152
+
153
+ // Providers the user can choose to check. Requests to the ones not chosen are still
154
+ // answered locally, but they are left out of the timeline and only counted.
155
+ export const PROVIDERS = {
156
+ ga4: "GA4",
157
+ google_ads: "Google Ads",
158
+ meta: "Meta",
159
+ tiktok: "TikTok",
160
+ x: "X",
161
+ linkedin: "LinkedIn",
162
+ microsoft_ads: "Microsoft Ads",
163
+ pinterest: "Pinterest",
164
+ snapchat: "Snapchat",
165
+ clarity: "Microsoft Clarity",
166
+ hotjar: "Hotjar",
167
+ doubleclick: "DoubleClick",
168
+ adsense: "Google AdSense",
169
+ };
170
+ export const DEFAULT_PROVIDERS = ["ga4", "google_ads", "meta", "tiktok"];
171
+ // Tag managers and consent platforms are always shown: they explain how the tags loaded.
172
+ const ALWAYS_SHOWN = { gtm: "Google Tag Manager", consent: "Consent platform" };
173
+
174
+ const HOSTS = [
175
+ [/(^|\.)(google-analytics|analytics\.google)\.com$/, "ga4"],
176
+ [/(^|\.)googleadservices\.com$/, "google_ads"],
177
+ [/(^|\.)googlesyndication\.com$/, "adsense"],
178
+ [/(^|\.)(facebook\.com|facebook\.net)$/, "meta"],
179
+ [/(^|\.)tiktok\.com$/, "tiktok"],
180
+ [/(^|\.)(x\.com|twitter\.com|t\.co|ads-twitter\.com)$/, "x"],
181
+ [/(^|\.)(licdn\.com|ads\.linkedin\.com)$/, "linkedin"],
182
+ [/(^|\.)bing\.com$/, "microsoft_ads"],
183
+ [/(^|\.)clarity\.ms$/, "clarity"],
184
+ [/(^|\.)hotjar\.(com|io)$/, "hotjar"],
185
+ [/(^|\.)(pinterest\.com|pinimg\.com)$/, "pinterest"],
186
+ [/(^|\.)(snapchat\.com|sc-static\.net)$/, "snapchat"],
187
+ [/(^|\.)(cookiebot\.com|cookielaw\.org|onetrust\.com|usercentrics\.eu|cookieyes\.com)$/, "consent"],
188
+ ];
189
+
190
+ /**
191
+ * The provider a request belongs to, as { key, label, always }, or null for ordinary
192
+ * site traffic. Google Ads paths on google.<tld> and doubleclick.net, gtag.js loads
193
+ * by tag id, and server-side GA4 endpoints on first-party hosts are recognized too.
194
+ */
195
+ export function providerOf(url) {
196
+ let u;
197
+ try { u = new URL(url); } catch { return null; }
198
+ if (!/^https?:$/.test(u.protocol)) return null;
199
+ const of = (key) => ({ key, label: PROVIDERS[key] ?? ALWAYS_SHOWN[key], always: key in ALWAYS_SHOWN });
200
+ const host = u.hostname;
201
+ if (/(^|\.)googletagmanager\.com$/.test(host)) {
202
+ const id = u.searchParams.get("id") ?? "";
203
+ return of(u.pathname.startsWith("/gtag/") && id.startsWith("G-") ? "ga4" : u.pathname.startsWith("/gtag/") && /^(AW|DC)-/.test(id) ? "google_ads" : "gtm");
204
+ }
205
+ if (/(^|\.)(doubleclick\.net|google\.[a-z.]+)$/.test(host) && /^\/(pagead|ccm)\//.test(u.pathname)) return of("google_ads");
206
+ if (/(^|\.)doubleclick\.net$/.test(host)) return of("doubleclick");
207
+ for (const [pattern, key] of HOSTS) if (pattern.test(host)) return of(key);
208
+ if (u.pathname.endsWith("/g/collect")) return of("ga4");
209
+ if (u.pathname.startsWith("/pagead/")) return of("google_ads");
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * Throws on unknown provider keys; returns the list. When none are given: the default
215
+ * providers plus any known provider the measurement plan names.
216
+ */
217
+ export function resolveProviders(list, plan) {
218
+ const keys = (Array.isArray(list) ? list : String(list ?? "").split(",")).map((k) => String(k).trim().toLowerCase()).filter(Boolean);
219
+ if (!keys.length) {
220
+ const planned = (plan?.events ?? []).map((e) => String(e.platform ?? "").toLowerCase()).filter((p) => p in PROVIDERS);
221
+ return [...new Set([...DEFAULT_PROVIDERS, ...planned])];
222
+ }
223
+ const unknown = keys.filter((k) => !(k in PROVIDERS));
224
+ if (unknown.length) throw new Error(`Unknown provider ${unknown.join(", ")}. Choose from: ${Object.keys(PROVIDERS).join(", ")}.`);
225
+ return [...new Set(keys)];
226
+ }
227
+
228
+ /** The vendor's display name for the network timeline, or null for ordinary site traffic. */
229
+ export function vendorOf(url) {
230
+ const p = providerOf(url);
231
+ if (!p) return null;
232
+ if (p.key === "ga4") return /(^|\.)(google-analytics|analytics\.google|googletagmanager)\.com$/.test(new URL(url).hostname) ? "Google Analytics" : "Google Analytics (server-side)";
233
+ return p.label;
234
+ }