@analitikisler/ai-tag-debugger 0.20.1 → 0.22.0

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 CHANGED
@@ -29,7 +29,12 @@ import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
29
29
  import { createProvider } from "./providers.js";
30
30
  import { savePlan } from "./plan.js";
31
31
  import { changesLines, sinceLastRun } from "./changes.js";
32
- import { DEFAULT_PROVIDERS, isCollectionEndpoint, parseTrackingRequest, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
32
+ import { decodeGcd, DEFAULT_PROVIDERS, googleConsent, isCollectionEndpoint, parseTrackingRequest, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
33
+ import { hitParams } from "./hit-params.js";
34
+ import { CAMPAIGN_PARAMS, campaignParamsIn, withCampaignParams } from "./campaign.js";
35
+ import { cmpFacts } from "./cmp.js";
36
+ import { containerRows, loadSources } from "./containers.js";
37
+ import { consentMatrixMarkdown, consentTable, runConsentScenarios } from "./consent.js";
33
38
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
34
39
 
35
40
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -44,7 +49,7 @@ Two kinds of request: a PLAN is what should be tracked, an AUDIT is what the sit
44
49
 
45
50
  Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}, plus any other platform the site turns out to use); pass their choice as providers, or leave it out for the default.
46
51
 
47
- 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 against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language.
52
+ 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 against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language. When a question needs a hit's raw parameters (client id, gcs, gcd decoded per consent signal, session flags), call get_hit with the step number and the hit's [h] number; get_step shows an earlier step again.
48
53
 
49
54
  While browsing:
50
55
  ${SYSTEM_PROMPT.split("How to work:\n")[1]}`;
@@ -82,28 +87,34 @@ function wholeFieldsIn(value, depth = 0, out = []) {
82
87
  return out;
83
88
  }
84
89
 
85
- function describeHit(h, steps) {
90
+ const consentText = (c) => Object.entries(c).map(([k, v]) => `${k}=${v}`).join(" ");
91
+ /** The Google protocol fields of a hit, short: values for the ids and consent strings, bare names for the 1-flags. */
92
+ const protocolText = (p) => Object.entries(p).map(([k, v]) => (["_fv", "_ss", "_nsi"].includes(k) && v === "1" ? k : `${k}=${v}`)).join(" ");
93
+
94
+ function describeHit(h, steps, index) {
86
95
  const entries = Object.entries(h.params ?? {}).filter(([k]) => !["v", "tid", "gtm", "_p", "cid", "sid", "_s", "sct", "seg", "dl", "dr", "dt", "ul", "sr", "_et", "en"].includes(k));
87
96
  const params = [
88
97
  ...entries.filter(([k]) => !WHOLE_FIELDS.has(k)).slice(0, 12).map(([k, v]) => cut(`${k}=${show(v)}`, VALUE_LIMIT)),
89
98
  ...entries.filter(([k]) => WHOLE_FIELDS.has(k)).map(([k, v]) => `${k}=${show(v)}`),
90
99
  ].join(", ");
91
100
  const late = h.late ? ` (arrived ${(h.late.ms / 1000).toFixed(1)} s late, after the step that loaded its page: "${steps[h.step]?.label ?? `step ${h.step}`}")` : "";
92
- return `- ${h.platform} ${h.name}${h.consent ? ` [consent ${Object.entries(h.consent).map(([k, v]) => `${k}=${v}`).join(" ")}]` : ""}${params ? `: ${params}` : ""}${late}`;
101
+ return `- [h${index}] ${h.platform} ${h.name}${h.consent ? ` [consent ${consentText(h.consent)}]` : ""}${h.protocol ? ` {${protocolText(h.protocol)}}` : ""}${params ? `: ${params}` : ""}${late}`;
93
102
  }
94
103
 
95
104
  /** A consent command in full, as key=value; any other push as JSON, cut, with its page address fields whole. */
96
105
  function describePush(p) {
106
+ // Pushes to a dataLayer of another name (a container's l=) say which.
107
+ const layer = p.layer ? `[${p.layer}] ` : "";
97
108
  if (isConsentCommand(p, "default") || isConsentCommand(p, "update")) {
98
109
  const signals = p.value[2] && typeof p.value[2] === "object" ? Object.entries(p.value[2]).map(([k, v]) => `${k}=${show(v)}`).join(" ") : show(p.value[2]);
99
- return `- consent ${p.value[1]}: ${signals}`;
110
+ return `- ${layer}consent ${p.value[1]}: ${signals}`;
100
111
  }
101
112
  const json = JSON.stringify(p.value) ?? String(p.value);
102
113
  const whole = json.length > PUSH_LIMIT ? wholeFieldsIn(p.value) : [];
103
- return `- ${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
114
+ return `- ${layer}${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
104
115
  }
105
116
 
106
- /** A call to a tracking endpoint that carries no event the tool reads, such as a Consent Mode ping, with its consent parameters. */
117
+ /** A call to a tracking endpoint that carries no event the tool reads, such as a Consent Mode ping, with its consent. */
107
118
  function describeCall(c) {
108
119
  let u;
109
120
  try {
@@ -111,8 +122,42 @@ function describeCall(c) {
111
122
  } catch {
112
123
  return `- ${c.vendor ?? c.provider} ${cut(c.url, VALUE_LIMIT)}`;
113
124
  }
114
- const signals = ["gcs", "gcd", "dma", "npa"].filter((k) => u.searchParams.has(k)).map((k) => `${k}=${u.searchParams.get(k)}`).join(" ");
115
- return `- ${c.vendor ?? c.provider}: ${u.hostname}${u.pathname}${signals ? ` [${signals}]` : ""}`;
125
+ const consent = googleConsent(u.searchParams);
126
+ // gclid is the landing click id the ping reports (0 when ads_data_redaction blanks it), gclaw the one _gcl_aw holds.
127
+ const signals = ["gcs", "gcd", "dma", "npa", "gclid", "gclaw"].filter((k) => u.searchParams.has(k))
128
+ .map((k) => `${k}=${u.searchParams.get(k)}${k === "gclid" && u.searchParams.get(k) === "0" ? " (redacted)" : ""}`).join(" ");
129
+ return `- ${c.vendor ?? c.provider}: ${u.hostname}${u.pathname}${signals ? ` [${signals}]` : ""}${consent ? ` [consent ${consentText(consent)}]` : ""}`;
130
+ }
131
+
132
+ const NAMES_SHOWN = 15;
133
+ /** The cookies a step added, changed or removed, by name, and what the analytics and ad cookies say when that changed. */
134
+ function cookieLines(session, step) {
135
+ const lines = [];
136
+ const changes = (session.cookieChanges ?? []).filter((c) => c.step === step);
137
+ const names = (kind) => [...new Set(changes.filter((c) => c.change === kind).map((c) => c.name))];
138
+ const part = (label, list) => (list.length ? `${label} ${list.slice(0, NAMES_SHOWN).join(", ")}${list.length > NAMES_SHOWN ? ` and ${list.length - NAMES_SHOWN} more` : ""}` : null);
139
+ const parts = [part("added", names("added")), part("changed", names("changed")), part("removed", names("removed"))].filter(Boolean);
140
+ if (parts.length) lines.push(`Cookies (names only): ${parts.join("; ")}`);
141
+ const id = session.steps[step]?.identity;
142
+ const before = session.steps.slice(0, step).map((st) => st.identity).filter(Boolean).at(-1);
143
+ if (!id || (!parts.length && JSON.stringify(id) === JSON.stringify(before))) return lines;
144
+ const facts = [];
145
+ if (id.ga_client_id) {
146
+ const prev = before?.ga_client_id;
147
+ const since = session.steps.findIndex((st) => st.identity?.ga_client_id === id.ga_client_id);
148
+ facts.push(`GA4 client id (_ga) ${id.ga_client_id}, ${!prev ? "new at this step" : prev === id.ga_client_id ? `the same since step ${since}` : `changed from ${prev}`}`);
149
+ }
150
+ if (id.gcl_aw) facts.push(id.gcl_aw === "landing" ? "_gcl_aw holds the landing gclid" : "_gcl_aw holds a gclid other than the landing one");
151
+ if (id.fbc) facts.push(id.fbc === "landing" ? "_fbc holds the landing fbclid" : "_fbc holds an fbclid other than the landing one");
152
+ if (facts.length) lines.push(`${facts.join("; ")}.`);
153
+ return lines;
154
+ }
155
+
156
+ /** One container load in words: its ID, what inserted it, its dataLayer and where it came from. */
157
+ function describeLoad(l) {
158
+ const c = messages("en").ui.containers;
159
+ const extra = [l.layer !== "dataLayer" && `dataLayer ${l.layer}`, l.served === "site" && `served from ${l.host}`, l.environment && c.environment(l.environment)].filter(Boolean);
160
+ return ` ${l.id} (${c.script[l.script]}): ${c.kinds[l.kind](l.by)}${extra.length ? `; ${extra.join(", ")}` : ""}`;
116
161
  }
117
162
 
118
163
  export function stepCapture(audit, step) {
@@ -122,13 +167,137 @@ export function stepCapture(audit, step) {
122
167
  // Hits that arrived during this step for a page an earlier step loaded are listed there, and noted here.
123
168
  const lateHere = session.hits.filter((h) => h.late?.during === step);
124
169
  const pings = session.network.filter((c) => c.step === step && isCollectionEndpoint(c.url) && !parseTrackingRequest(c.url, c.postData ?? null));
125
- const lines = [`Tracking hits sent by this step: ${hits.length}`, ...hits.map((h) => describeHit(h, session.steps))];
170
+ const problem = session.steps[step]?.pageProblem;
171
+ const lines = [
172
+ ...(problem?.kind === "challenge" ? [`WARNING: the page is a bot challenge from ${problem.vendor}, not the site. Nothing captured here says anything about the site's tracking; run the audit from a network the site trusts.`] : []),
173
+ ...(problem?.kind === "crash" ? [`WARNING: the page crashed in the browser (the ${problem.vendor} error screen)${problem.refusedScripts ? `; ${problem.refusedScripts} of its own scripts came back as HTML pages, which bot protection or a mismatched deployment does` : ""}. Its tags may never have run.`] : []),
174
+ `Step ${step}: "${session.steps[step]?.label ?? ""}". Read a hit's full parameters with get_hit (audit_id, step ${step}, its [h] number).`,
175
+ `Tracking hits sent by this step: ${hits.length}`,
176
+ ...hits.map((h, i) => describeHit(h, session.steps, i)),
177
+ ];
126
178
  if (lateHere.length) lines.push(`Arrived during this step but belong to the step that loaded their page: ${lateHere.map((h) => `${h.platform} ${h.name} (step "${session.steps[h.step]?.label ?? h.step}")`).join(", ")}`);
127
179
  if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
128
180
  if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
181
+ const loads = loadSources({ containerLoads: (session.containerLoads ?? []).filter((l) => l.step === step), dataLayerLog: session.dataLayerLog });
182
+ if (loads.length) lines.push("Google containers loaded:", ...loads.map(describeLoad));
183
+ lines.push(...cookieLines(session, step));
184
+ const tag = session.steps[step]?.tagConsent;
185
+ if (tag) {
186
+ const state = (e) => (e.default && e.update ? `${e.default} → ${e.update}` : e.default ? `${e.default} (default)` : e.update ? `${e.update} (update)` : "not set");
187
+ const flags = [tag.usedDefault ? "default applied" : "no default applied", tag.usedUpdate && "update applied", tag.wasSetLate && "default set late", tag.waitPeriodTimedOut && "wait_for_update ran out"].filter(Boolean);
188
+ lines.push(`Google tag consent state (google_tag_data.ics), changed at this step: ${Object.entries(tag.entries).map(([k, e]) => `${k} ${state(e)}`).join(", ")}; ${flags.join(", ")}.`);
189
+ }
190
+ // The consent platform once, on the step it was first seen at.
191
+ const platform = session.cmp?.step === step ? cmpFacts(messages("en"), [{ cmp: session.cmp, network: session.network }]) : null;
192
+ if (platform) lines.push(`Consent platform: ${platform.vendor}. ${platform.facts.join(" ")}`);
193
+ return lines.join("\n");
194
+ }
195
+
196
+ const MAX_PARAM_VALUE = 2000;
197
+ /** What a gcd letter says about one signal, in words. */
198
+ function gcdWords({ letter, default: d, update: u, known }) {
199
+ if (!known) return `${letter}: a letter this tool doesn't know`;
200
+ if (!d && !u) return `${letter}: consent mode not used`;
201
+ return `${letter}: ${d ? `${d} by default` : "no default"}, ${u ? `${u} by update` : "no update"}`;
202
+ }
203
+
204
+ /** One hit in full: where it was sent, its consent and every parameter its request carried. */
205
+ export function hitDetail(audit, step, index) {
206
+ const { session } = audit;
207
+ const hits = session.hits.filter((h) => h.step === step);
208
+ const h = hits[index];
209
+ if (!h) return null;
210
+ const { call, batch, event, context } = hitParams(session, h);
211
+ const label = session.steps[step]?.label ?? "";
212
+ const rows = (entries) => entries.map(([k, v]) => ` ${k} = ${cut(String(v), MAX_PARAM_VALUE)}`);
213
+ const lines = [`[h${index}] ${h.platform} ${h.name}, step ${step} "${label}"${h.late ? `, sent ${(h.late.ms / 1000).toFixed(1)} s late during step ${h.late.during} "${session.steps[h.late.during]?.label ?? ""}"` : ""}`];
214
+ lines.push(`Request: ${call ? `${call.method ?? ""} ${call.url}`.trim() : h.url}${call?.blocked ? " (answered locally, never reached the platform)" : ""}`);
215
+ if (h.consent) lines.push(`Consent: ${consentText(h.consent)}`);
216
+ const gcd = decodeGcd(h.protocol?.gcd);
217
+ if (gcd) lines.push(`gcd ${h.protocol.gcd}: ${Object.entries(gcd).map(([signal, state]) => `${signal} ${gcdWords(state)}`).join("; ")}`);
218
+ if (h.userData?.fields?.length) lines.push(`User data redacted from this hit: ${h.userData.fields.join(", ")}`);
219
+ lines.push(`Event parameters${batch ? ` (event ${batch[0]} of ${batch[1]} in one request)` : ""}:`, ...(event.length ? rows(event) : [" (none)"]));
220
+ if (context.length) lines.push("Other parameters (ids, page, device, consent):", ...rows(context));
129
221
  return lines.join("\n");
130
222
  }
131
223
 
224
+ /**
225
+ * One entry per step, short enough to return with finish_audit and read without the report: what the step did, the
226
+ * hits it sent with their consent, its consent commands and other tracking calls, the Google tag's consent when it
227
+ * changed, and the cookies and ids that changed.
228
+ */
229
+ /** A container load or row as compact JSON: the ID, its source, and the dataLayer, host and environment when they are not the usual ones. */
230
+ function containerJson(l) {
231
+ return {
232
+ id: l.id, source: l.kind, ...(l.by ? { by: l.by } : {}),
233
+ ...(l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
234
+ ...(l.served === "site" ? { served_from: l.host } : {}),
235
+ ...(l.environment ? { gtm_environment: l.environment } : {}),
236
+ ...(l.pages ? { pages: l.pages } : {}),
237
+ };
238
+ }
239
+
240
+ /** The Google containers of the runs and how each reached the page, as lines for a tool result; [] when none loaded. */
241
+ export function containerLines(runs) {
242
+ const rows = containerRows(runs);
243
+ if (!rows.length) return [];
244
+ const c = messages("en").ui.containers;
245
+ return [
246
+ "Google containers (how each reached the page):",
247
+ ...rows.map((r) => {
248
+ const extra = [r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
249
+ return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
250
+ }),
251
+ ];
252
+ }
253
+
254
+ export function timelineOf(run) {
255
+ let lastIds = null;
256
+ return (run.steps ?? []).map((st) => {
257
+ const hits = (run.hits ?? []).filter((h) => h.step === st.index);
258
+ const commands = (run.dataLayerLog ?? []).filter((e) => e.step === st.index && (isConsentCommand(e, "default") || isConsentCommand(e, "update")));
259
+ const others = (run.network ?? []).filter((c) => c.step === st.index && isCollectionEndpoint(c.url) && !parseTrackingRequest(c.url, c.postData ?? null));
260
+ const changes = (run.cookieChanges ?? []).filter((c) => c.step === st.index);
261
+ const names = (kind) => [...new Set(changes.filter((c) => c.change === kind).map((c) => c.name))];
262
+ const entry = { step: st.index, action: st.label, ...(st.pagePath ? { page: st.pagePath } : {}) };
263
+ if (st.error) entry.error = st.error;
264
+ if (hits.length) entry.hits = hits.map((h) => ({ platform: h.platform, event: h.name, ...(h.consent ? { consent: h.consent } : {}), ...(h.late ? { late_ms: h.late.ms } : {}) }));
265
+ if (others.length) entry.other_tracking_calls = others.length;
266
+ if (commands.length) entry.consent_commands = commands.map((e) => ({ [e.value[1]]: e.value[2] }));
267
+ if (st.tagConsent) entry.tag_consent = Object.fromEntries(Object.entries(st.tagConsent.entries).map(([k, e]) => [k, e.update ?? e.default ?? null]));
268
+ const cookies = Object.fromEntries(["added", "changed", "removed"].map((k) => [k, names(k)]).filter(([, list]) => list.length));
269
+ if (Object.keys(cookies).length) entry.cookies = cookies;
270
+ const loads = loadSources({ containerLoads: (run.containerLoads ?? []).filter((l) => l.step === st.index), dataLayerLog: run.dataLayerLog });
271
+ if (loads.length) entry.containers = loads.map(containerJson);
272
+ if (st.identity && JSON.stringify(st.identity) !== JSON.stringify(lastIds)) entry.ids = st.identity;
273
+ if (st.identity) lastIds = st.identity;
274
+ return entry;
275
+ });
276
+ }
277
+
278
+ const campaignSchema = () => z.union([z.boolean(), z.object(Object.fromEntries(CAMPAIGN_PARAMS.map((k) => [k, z.string().min(1).optional()]))).strict()]).optional();
279
+ const CAMPAIGN_DOC = `Default true: test values (utm_source=aitd, gclid=aitd-gclid, fbclid=aitd-fbclid...) unless the site address already carries campaign parameters. false opens the site as given. Or your own values, e.g. {"utm_source": "newsletter", "utm_campaign": "autumn", "gclid": "test123"}; allowed keys: ${CAMPAIGN_PARAMS.join(", ")}.`;
280
+
281
+ const LIVE_NOTE = "LIVE: tracking requests go to the real analytics and ad accounts, so this test traffic shows in their reports (GA4 Realtime and DebugView, Meta Events Manager).";
282
+
283
+ const OFFSCREEN_SHOWN = 40;
284
+ const UNCHANGED_TEXT = "(unchanged since the last page view)";
285
+ const UNCHANGED_ELEMENTS = "(unchanged since the last page view: the same refs still apply)";
286
+ const DETAIL_DOC = "compact (default for browser_action): up to 40 elements above or below the screen, and the page text or element list replaced by a note when unchanged since the last view. full: every element and the whole text again.";
287
+
288
+ /**
289
+ * The page view for the chat app. Long sessions lose their oldest results from the context, so a compact view
290
+ * leaves out what the last one already showed: the same text, the same elements (and so the same refs).
291
+ */
292
+ async function viewFor(audit, error, detail = "compact") {
293
+ const full = detail === "full";
294
+ const snap = await audit.session.snapshot({ offscreenLimit: full ? null : OFFSCREEN_SHOWN });
295
+ const last = audit.lastView;
296
+ audit.lastView = { url: snap.url, text: snap.text, elements: snap.elements };
297
+ if (full || !last || last.url !== snap.url) return pageView(snap, error);
298
+ return pageView({ ...snap, text: snap.text === last.text ? UNCHANGED_TEXT : snap.text, elements: snap.elements === last.elements ? UNCHANGED_ELEMENTS : snap.elements }, error);
299
+ }
300
+
132
301
  function getAudit(id) {
133
302
  const audit = audits.get(id);
134
303
  if (!audit) throw new Error(`No open audit "${id}". Start one with start_audit. Audits close after ${IDLE_MS / 60000} minutes without activity.`);
@@ -187,6 +356,8 @@ async function loadAgainst(ref, site, opts) {
187
356
  */
188
357
  export function buildServer(opts) {
189
358
  const server = new McpServer({ name: "ai-tag-debugger", version: VERSION }, { instructions: INSTRUCTIONS });
359
+ // The reports this server wrote, by folder name: get_report reads these and nothing else, over HTTP too.
360
+ const written = new Map();
190
361
 
191
362
  server.registerTool(
192
363
  "start_audit",
@@ -209,9 +380,11 @@ export function buildServer(opts) {
209
380
  .array(z.enum(Object.keys(TRACKING_PROVIDERS)))
210
381
  .optional()
211
382
  .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
383
+ campaign: campaignSchema().describe(`Open the first page the way an ad click does, so finish_audit checks that the campaign parameters survive redirects and reach the hits. ${CAMPAIGN_DOC}`),
384
+ live: z.boolean().optional().describe("Default false: tracking requests are answered locally and never reach the site's accounts. true lets them through to the real analytics and ad accounts, to check them in GA4 Realtime or DebugView, Meta Events Manager or Tag Assistant: the test traffic then shows in the site's reports. Only when the user asks for it."),
212
385
  },
213
386
  },
214
- async ({ site, journey, viewport, allowed_hosts, providers }) => {
387
+ async ({ site, journey, viewport, allowed_hosts, providers, campaign = true, live = false }) => {
215
388
  let url;
216
389
  try {
217
390
  url = new URL(site);
@@ -227,7 +400,8 @@ export function buildServer(opts) {
227
400
  if (audits.size >= MAX_OPEN_AUDITS) return fail(`${audits.size} audits are already open. Finish one with finish_audit first.`);
228
401
  let session;
229
402
  try {
230
- session = new AuditSession({ viewport: viewport ?? "desktop", site: url.href, allowedHosts: allowed_hosts, providers, blockPrivate: opts.blockPrivate });
403
+ if (live && !opts.allowLive) return fail("Live audits are off on this server: it was started without --allow-live. Leave live out to audit with tracking requests answered locally.");
404
+ session = new AuditSession({ viewport: viewport ?? "desktop", site: url.href, allowedHosts: allowed_hosts, providers, blockPrivate: opts.blockPrivate, blockHits: !live });
231
405
  } catch (err) {
232
406
  return fail(err.message);
233
407
  }
@@ -242,10 +416,14 @@ export function buildServer(opts) {
242
416
  const name = preset?.id ?? ((journey || "journey").toLowerCase().replace(/[^\w-]+/g, "-").slice(0, 40) || "journey");
243
417
  const audit = { id, session, site: url.href, journey: name, preset, viewport: viewport ?? "desktop", lastUsed: Date.now() };
244
418
  audits.set(id, audit);
245
- const error = await session.act({ action: "goto", url: url.href, reason: `Open ${url.href}` });
246
- const page = pageView(await session.snapshot(), error);
419
+ const start = campaign === false ? url.href : withCampaignParams(url.href, typeof campaign === "object" ? campaign : null);
420
+ const error = await session.act({ action: "goto", url: start, reason: `Open ${start}` });
421
+ const page = await viewFor(audit, error);
247
422
  const goal = preset ? `Journey goal: ${preset.goal}\nEvents expected on the way: ${preset.events.map((e) => `${e.platform} ${e.name}`).join(", ")}\n\n` : "";
248
- return text(`audit_id: ${id}\n${goal}\n${page}\n\n${stepCapture(audit, session.currentStep)}`);
423
+ const sent = campaignParamsIn(start);
424
+ const opened = Object.keys(sent).length ? `Opened like an ad click with ${Object.entries(sent).map(([k, v]) => `${k}=${v}`).join(", ")}; finish_audit checks that they survive redirects and reach the hits.\n` : "";
425
+ const liveLine = live ? `${LIVE_NOTE}\n` : "";
426
+ return text(`audit_id: ${id}\n${liveLine}${opened}${goal}\n${page}\n\n${stepCapture(audit, session.currentStep)}`);
249
427
  },
250
428
  );
251
429
 
@@ -265,6 +443,7 @@ export function buildServer(opts) {
265
443
  reason: z.string().describe("Short description of the step, e.g. 'Add the first product to the cart'. Shown in the report."),
266
444
  accepts_consent: z.boolean().optional().describe("True only when this click accepts the cookie or consent banner."),
267
445
  rejects_consent: z.boolean().optional().describe("True only when this click rejects or declines all cookies on the consent banner."),
446
+ detail: z.enum(["compact", "full"]).optional().describe(DETAIL_DOC),
268
447
  },
269
448
  },
270
449
  async (input) => {
@@ -272,7 +451,7 @@ export function buildServer(opts) {
272
451
  if (["click", "fill", "select"].includes(input.action) && !Number.isInteger(input.ref) && !input.text?.trim()) return fail(`${input.action} needs a ref or text.`);
273
452
  if (input.action === "goto" && !input.url) return fail("goto needs a url.");
274
453
  const error = await audit.session.act(input);
275
- const page = pageView(await audit.session.snapshot(), error);
454
+ const page = await viewFor(audit, error, input.detail);
276
455
  return text(`${page}\n\n${stepCapture(audit, audit.session.currentStep)}`);
277
456
  },
278
457
  );
@@ -281,11 +460,134 @@ export function buildServer(opts) {
281
460
  "get_page",
282
461
  {
283
462
  title: "Show the current page",
284
- description: "Returns the current page view of an open audit without doing anything.",
463
+ description: "Returns the current page view of an open audit without doing anything. By default in full: every element and the whole page text.",
464
+ inputSchema: { audit_id: z.string(), detail: z.enum(["compact", "full"]).optional().describe(`Default full here. ${DETAIL_DOC}`) },
465
+ annotations: { readOnlyHint: true },
466
+ },
467
+ async ({ audit_id, detail = "full" }) => text(await viewFor(getAudit(audit_id), null, detail)),
468
+ );
469
+
470
+ server.registerTool(
471
+ "get_hit",
472
+ {
473
+ title: "Show one tracking hit in full",
474
+ description:
475
+ "Returns every parameter one tracking hit's request carried (client and session ids, gcs and gcd with gcd decoded per consent signal, page, device and event parameters), where it was sent, and its consent. Step results list each hit with an [h] number; pass that and the step number.",
476
+ inputSchema: {
477
+ audit_id: z.string(),
478
+ step: z.number().int().min(0).describe("The step number from the step result's first line."),
479
+ hit: z.number().int().min(0).describe("The hit's [h] number in that step."),
480
+ },
481
+ annotations: { readOnlyHint: true },
482
+ },
483
+ async ({ audit_id, step, hit }) => {
484
+ const audit = getAudit(audit_id);
485
+ const detail = hitDetail(audit, step, hit);
486
+ if (detail !== null) return text(detail);
487
+ const count = audit.session.hits.filter((h) => h.step === step).length;
488
+ return fail(step >= audit.session.steps.length ? `There is no step ${step}; the last is ${audit.session.steps.length - 1}.` : `Step ${step} has ${count} hit${count === 1 ? "" : "s"}${count ? ` (h0 to h${count - 1})` : ""}.`);
489
+ },
490
+ );
491
+
492
+ server.registerTool(
493
+ "get_step",
494
+ {
495
+ title: "Show an earlier step's capture again",
496
+ description:
497
+ "Returns what an earlier step captured, as browser_action showed it: its hits, other tracking calls and dataLayer pushes. Use it to see a step again, for example after a hit that arrived late was added to the step that loaded its page.",
498
+ inputSchema: { audit_id: z.string(), step: z.number().int().min(0).describe("The step number from the step result's first line.") },
499
+ annotations: { readOnlyHint: true },
500
+ },
501
+ async ({ audit_id, step }) => {
502
+ const audit = getAudit(audit_id);
503
+ if (step >= audit.session.steps.length) return fail(`There is no step ${step}; the last is ${audit.session.steps.length - 1}.`);
504
+ return text(stepCapture(audit, step));
505
+ },
506
+ );
507
+
508
+ server.registerTool(
509
+ "get_containers",
510
+ {
511
+ title: "List the Google containers loaded so far",
512
+ description:
513
+ "Returns every GTM container (gtm.js) and Google tag (gtag/js) this audit's pages loaded so far, and how each reached the page: a script tag in the HTML, the inline snippet, another script (named by its address, such as a consent platform), a Custom HTML tag or a Google tag in another GTM container, a container loading another itself (a Zone or a tag template), a gtag('config') command, an event's send_to with no config, or a destination of the Google tag. Also the dataLayer each reads, whether it was served from the site's own domain, the GTM environment when one is named, and the step it loaded at. Duplicates, GTM environments and split dataLayers are flagged by finish_audit.",
285
514
  inputSchema: { audit_id: z.string() },
286
515
  annotations: { readOnlyHint: true },
287
516
  },
288
- async ({ audit_id }) => text(pageView(await getAudit(audit_id).session.snapshot())),
517
+ async ({ audit_id }) => {
518
+ const { session } = getAudit(audit_id);
519
+ const loads = loadSources({ containerLoads: session.containerLoads ?? [], dataLayerLog: session.dataLayerLog });
520
+ if (!loads.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
521
+ const lines = loads.map((l) => `Step ${l.step} ("${session.steps[l.step]?.label ?? ""}"), ${l.page ?? "?"}:\n${describeLoad(l)}`);
522
+ return text(`${loads.length} container ${loads.length === 1 ? "load" : "loads"}:\n${lines.join("\n")}\n\nAs JSON: ${JSON.stringify(loads.map((l) => ({ step: l.step, page: l.page, ...containerJson(l) })))}`);
523
+ },
524
+ );
525
+
526
+ server.registerTool(
527
+ "get_report",
528
+ {
529
+ title: "Read a report this server wrote",
530
+ description:
531
+ "Returns a report finish_audit or run_audit wrote, so it can be read without access to the computer running this server. part markdown (default): report.md, the verdict, findings, consent and journeys in about 120 lines. summary: summary.json, the counts, journeys and findings as JSON. timeline: one JSON entry per step of each journey (hits with their consent, consent commands, the Google tag's consent, cookies and ids, the Google containers loaded). containers: every GTM container and Google tag the journeys loaded as JSON, with how each reached the page (source html, inline, script, custom_html, google_tag, nested, config, send_to or destination, and by: the container or script that loaded it), its dataLayer, host, GTM environment and pages.",
532
+ inputSchema: {
533
+ report: z.string().describe("The report name finish_audit or run_audit gave."),
534
+ part: z.enum(["markdown", "summary", "timeline", "containers"]).optional().describe("Default markdown."),
535
+ },
536
+ annotations: { readOnlyHint: true },
537
+ },
538
+ async ({ report, part = "markdown" }) => {
539
+ const dir = written.get(report);
540
+ if (!dir) return fail(written.size ? `No report "${report}" from this server. Its reports: ${[...written.keys()].join(", ")}.` : "This server has written no report yet.");
541
+ try {
542
+ if (part === "markdown") return text(await readFile(path.join(dir, "report.md"), "utf8"));
543
+ if (part === "summary") return text(await readFile(path.join(dir, "summary.json"), "utf8"));
544
+ const capture = JSON.parse(await readFile(path.join(dir, "capture.json"), "utf8"));
545
+ if (part === "containers") return text(JSON.stringify(containerRows(capture.runs ?? []).map(containerJson)));
546
+ return text(JSON.stringify((capture.runs ?? []).map((r) => ({ journey: r.journeyId, viewport: r.viewport, steps: timelineOf(r) }))));
547
+ } catch (err) {
548
+ return fail(`Could not read the report: ${err.message}`);
549
+ }
550
+ },
551
+ );
552
+
553
+ server.registerTool(
554
+ "consent_matrix",
555
+ {
556
+ title: "Compare every consent choice side by side",
557
+ description:
558
+ "Opens the site's start page like an ad click in a fresh browser once per consent choice: no choice and on to the next page, accept all, reject all and analytics only. Returns which tags sent after each choice, and a matrix of the first Google hit's consent, whether the campaign parameters reached it, whether the GA4 client id was kept, the _gcl_aw cookie, the cookies set, when each tag was called and the Google tag's own consent, with what should not happen marked. Needs a consent banner the tool can answer. Takes about half a minute per choice.",
559
+ inputSchema: {
560
+ site: z.string().describe("The page to start from, e.g. https://www.example.com/"),
561
+ campaign: campaignSchema().describe(`The campaign parameters to open the start page with, as the UTM check. ${CAMPAIGN_DOC}`),
562
+ providers: z.array(z.enum(Object.keys(TRACKING_PROVIDERS))).optional().describe(`Tracking providers to count. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to.`),
563
+ allowed_hosts: z.array(z.string()).optional().describe("Other hosts the pages may load from or redirect to."),
564
+ concurrency: z.number().int().min(1).max(4).optional().describe("How many choices to try at once, each in its own browser. Default 2."),
565
+ language: z.enum(Object.keys(LANGUAGES)).optional().describe("Language of the answer. Default en."),
566
+ },
567
+ annotations: { readOnlyHint: true },
568
+ },
569
+ async ({ site, campaign, providers, allowed_hosts, concurrency = 2, language = "en" }) => {
570
+ let url;
571
+ try {
572
+ url = new URL(site);
573
+ if (!["http:", "https:"].includes(url.protocol)) throw new Error();
574
+ } catch {
575
+ return fail("site must be an http or https URL.");
576
+ }
577
+ if (opts.blockPrivate) {
578
+ const refused = await navigationError(url.href, { isPrivate: privateHostChecker() });
579
+ if (refused) return fail(`${refused} Over HTTP, audits are limited to public websites.`);
580
+ }
581
+ const config = { site: url.href, providers, allowed_hosts };
582
+ const sc = await runConsentScenarios(config, { campaign, concurrency, ...(opts.blockPrivate ? { blockPrivate: opts.blockPrivate } : {}) });
583
+ if (!sc) return text(`No consent banner the tool can answer was found on ${url.href}, so the choices could not be compared. Walk the journey with start_audit and browser_action instead.`);
584
+ const m = messages(language);
585
+ const table = consentTable(sc);
586
+ const lines = [`Consent banner answered through: ${sc.cmp}.`, "", `| | ${table.cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(table.cols.length + 1)}`];
587
+ for (const r of table.rows) lines.push(`| ${TRACKING_PROVIDERS[r.platform] ?? r.platform} ${r.event} | ${r.cells.map((c) => (c.count === null ? "–" : `${c.count}${c.problem ? " ⚠" : ""}`)).join(" | ")} |`);
588
+ lines.push("", "Hits per tag and event after each choice; ⚠: sent with cookies where the choice says no.", "", ...consentMatrixMarkdown(m, sc));
589
+ return text(lines.join("\n"));
590
+ },
289
591
  );
290
592
 
291
593
  server.registerTool(
@@ -338,8 +640,10 @@ export function buildServer(opts) {
338
640
  const counts = summarize(grouped);
339
641
  const outDir = path.join(opts.reportsDir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${audit.journey}`);
340
642
  const { reportPath } = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, against: againstPlan, branding: branding !== false });
643
+ written.set(path.basename(outDir), outDir);
341
644
  const m = messages(lang);
342
645
  const lines = [
646
+ ...(run.live ? [LIVE_NOTE] : []),
343
647
  m.ui.counts(counts),
344
648
  ...(againstPlan ? [m.ui.checkedAgainst(againstPlan.label)] : []),
345
649
  "",
@@ -347,15 +651,23 @@ export function buildServer(opts) {
347
651
  "",
348
652
  `${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.`,
349
653
  skippedLine(run),
654
+ ...((platform) => (platform ? [`Consent platform: ${platform.vendor}. ${platform.facts.join(" ")}`] : []))(cmpFacts(messages("en"), [run])),
655
+ ...containerLines([run]),
350
656
  opts.allowFiles
351
657
  ? `Full report with the event timeline: ${reportPath}`
352
658
  : opts.reportsUrl
353
659
  ? `Full report with the event timeline: ${opts.reportsUrl}/${path.basename(outDir)}/report.html`
354
660
  : "",
355
661
  "",
662
+ `To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary, timeline or containers).`,
356
663
  "Explain each problem to the user with its likely cause and fix.",
357
664
  ];
358
- return text(lines.filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n"));
665
+ return {
666
+ content: [
667
+ { type: "text", text: lines.filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n") },
668
+ { type: "text", text: `Timeline (JSON, one entry per step):\n${JSON.stringify(timelineOf(run))}` },
669
+ ],
670
+ };
359
671
  },
360
672
  );
361
673
 
@@ -484,6 +796,7 @@ export function buildServer(opts) {
484
796
  "No AI provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY) is set on the computer running this server, so scripted false can't browse with a model. Call run_audit again with scripted true (or leave it out) to replay the journeys' fixed steps, or browse the site yourself with start_audit, browser_action and finish_audit. Neither needs a key.",
485
797
  );
486
798
  }
799
+ written.set(path.basename(result.outDir), result.outDir);
487
800
  const m = messages(result.lang);
488
801
  const grouped = groupFindings(result.findings).filter((f) => f.severity !== "ok");
489
802
  return text(
@@ -492,7 +805,9 @@ export function buildServer(opts) {
492
805
  ...(result.baseline ? [`${result.baseline.accepted} accepted from the baseline${result.baseline.partial ? `, ${result.baseline.partial} accepted in some places only (still counted)` : ""}, ${result.baseline.stale.length} stale baseline entries`] : []),
493
806
  ...result.runs.map((r) => `${r.journeyId} (${r.viewport}): ${r.completed ? "completed" : "not completed"}, ${r.note}`),
494
807
  ...grouped.map((f) => `- ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${f.detail}`),
808
+ ...containerLines(result.runs),
495
809
  `Report: ${result.reportPath}`,
810
+ `To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary, timeline or containers).`,
496
811
  ].join("\n"),
497
812
  );
498
813
  },
@@ -503,7 +818,7 @@ export function buildServer(opts) {
503
818
  }
504
819
 
505
820
  /** Starts the MCP server on stdio, or on HTTP with --http. */
506
- export async function startMcp({ http = false, port = 8787, host = "127.0.0.1", token, reportsDir, publicUrl, allowPrivateNetwork = false } = {}) {
821
+ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1", token, reportsDir, publicUrl, allowPrivateNetwork = false, allowLive = false } = {}) {
507
822
  const dir = path.resolve(reportsDir ?? path.join(homedir(), "ai-tag-debugger-reports"));
508
823
  const shutdown = async () => {
509
824
  await Promise.all([...audits.keys()].map(closeAudit));
@@ -513,7 +828,8 @@ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1",
513
828
  process.on("SIGTERM", shutdown);
514
829
 
515
830
  if (!http) {
516
- const server = buildServer({ allowFiles: true, reportsDir: dir });
831
+ // On your own computer live runs are yours to start; a server others reach needs --allow-live.
832
+ const server = buildServer({ allowFiles: true, reportsDir: dir, allowLive: true });
517
833
  await server.connect(new StdioServerTransport());
518
834
  // stdout carries the protocol, so status goes to stderr.
519
835
  console.error(`ai-tag-debugger MCP server ready on stdio. Reports go to ${dir}`);
@@ -530,6 +846,9 @@ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1",
530
846
  if (allowPrivateNetwork) {
531
847
  console.error("Warning: --allow-private-network lets audits open localhost and private network addresses. Use it only for a local tunnel you control.");
532
848
  }
849
+ if (allowLive) {
850
+ console.error("Warning: --allow-live lets start_audit send tracking requests to the real analytics and ad accounts of the sites it audits.");
851
+ }
533
852
  const secret = token ?? randomBytes(18).toString("base64url");
534
853
  const endpoint = `/mcp/${secret}`;
535
854
  const digest = (s) => createHash("sha256").update(s).digest();
@@ -597,7 +916,7 @@ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1",
597
916
  return;
598
917
  }
599
918
  // Stateless: a new server and transport per request. Open audits live in the module, keyed by audit_id.
600
- const server = buildServer({ allowFiles: false, blockPrivate: !allowPrivateNetwork, reportsDir: dir, reportsUrl });
919
+ const server = buildServer({ allowFiles: false, blockPrivate: !allowPrivateNetwork, reportsDir: dir, reportsUrl, allowLive });
601
920
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
602
921
  res.on("close", () => {
603
922
  transport.close();
package/src/metrics.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // { plan, runs, findings, consentScenarios } (no I/O, no AI, no HTML) and returns plain data; the
5
5
  // report decides how to show it. The rules are documented in docs/design/metrics.md.
6
6
 
7
- import { canonicalEvent, groupFindings, hasParam, hitMatchesEvent, isConsentCommand, PLAN_BUCKETS, planCheck, summarize } from "./checks.js";
7
+ import { canonicalEvent, groupFindings, hasParam, hitMatchesEvent, isConsentCommand, PLAN_BUCKETS, planCheck, summarize, tagConsentStates } from "./checks.js";
8
8
  import { AD_PLATFORMS, CONSENT_STATES, consentTable } from "./consent.js";
9
9
  import { presetFor } from "./journeys.js";
10
10
  import { OWNERS, ownerOf } from "./verify.js";
@@ -32,7 +32,7 @@ const GOOGLE_PLATFORMS = new Set(["ga4", "google_ads"]);
32
32
  const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
33
33
  // Consent findings that say the visitor's "no" was not honoured, against the ones that say the setup is risky.
34
34
  const CONSENT_FAIL_CHECKS = new Set(["after_reject", "cookies_after_reject", "reject_ignored", "reject_button_failed"]);
35
- const CONSENT_ATTENTION_CHECKS = new Set(["before_consent", "cookies_before_consent", "consent_default_missing", "consent_default_late", "consent_v2_missing", "consent_v2_update"]);
35
+ const CONSENT_ATTENTION_CHECKS = new Set(["before_consent", "cookies_before_consent", "consent_default_missing", "consent_default_late", "consent_default_not_applied", "consent_v2_missing", "consent_v2_update"]);
36
36
  const CONSENT_CHECK = /^(consent_|cookies_|reject_|before_consent|after_reject)/;
37
37
 
38
38
  // Ecommerce parameters sites send under another name (UA-style products/revenue, order ids), as checks.js reads them.
@@ -243,15 +243,21 @@ export function platformParity({ runs = [] } = {}) {
243
243
  function consentDefaultIn(run) {
244
244
  const log = run.dataLayerLog ?? [];
245
245
  const entry = log.find((e) => isConsentCommand(e, "default")) ?? null;
246
+ // Without a dataLayer command, the Google tag's own state can still show a default (a consent platform's GTM template).
247
+ const tagStates = entry ? [] : tagConsentStates({ steps: run.steps ?? [] });
248
+ const tagDefault = tagStates.find((st) => st.usedDefault) ?? null;
249
+ const set = Boolean(entry || tagDefault);
246
250
  const firstGoogleHit = (run.hits ?? []).filter((h) => GOOGLE_PLATFORMS.has(h.platform)).sort(byTime)[0] ?? null;
247
251
  const step = entry ? stepOf(run, entry.step) : null;
248
- const declared = uniq(log.filter((e) => isConsentCommand(e, "default")).flatMap((e) => Object.keys(e.value[2] ?? {})));
252
+ const declared = entry
253
+ ? uniq(log.filter((e) => isConsentCommand(e, "default")).flatMap((e) => Object.keys(e.value[2] ?? {})))
254
+ : tagDefault ? Object.entries(tagDefault.entries).filter(([, e]) => e.default).map(([k]) => k) : [];
249
255
  const missing = V2_SIGNALS.filter((k) => !declared.includes(k));
250
256
  return {
251
- journey: run.journeyId, viewport: run.viewport, present: Boolean(entry),
252
- step: entry?.step ?? null, stepLabel: step?.label ?? null, ms: entry && step && entry.t != null && step.t != null ? entry.t - step.t : null,
253
- beforeFirstGoogleHit: entry && firstGoogleHit ? !sentBefore(firstGoogleHit, entry) : null,
254
- v2Signals: { present: Boolean(entry) && !missing.length, missing: entry ? missing : V2_SIGNALS },
257
+ journey: run.journeyId, viewport: run.viewport, present: set, source: entry ? "dataLayer" : tagDefault ? "tag" : null,
258
+ step: entry?.step ?? tagDefault?.step ?? null, stepLabel: step?.label ?? null, ms: entry && step && entry.t != null && step.t != null ? entry.t - step.t : null,
259
+ beforeFirstGoogleHit: entry && firstGoogleHit ? !sentBefore(firstGoogleHit, entry) : tagDefault ? !tagStates.some((st) => st.wasSetLate) : null,
260
+ v2Signals: { present: set && !missing.length, missing: set ? missing : V2_SIGNALS },
255
261
  };
256
262
  }
257
263