gsclaw 0.0.0-stage → 0.1.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.
@@ -0,0 +1,4489 @@
1
+ import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
2
+ import { z } from "zod";
3
+ //#region src/core/activity.ts
4
+ /**
5
+ * Recent tool calls for the dashboard's trust view: metadata only, never arguments beyond the
6
+ * property or response payloads. In-memory and per instance: serverless platforms may run several
7
+ * instances, and each keeps its own list.
8
+ */
9
+ var ActivityLog = class {
10
+ capacity;
11
+ persistent = false;
12
+ entries = [];
13
+ constructor(capacity = 200) {
14
+ this.capacity = capacity;
15
+ }
16
+ record(entry) {
17
+ this.entries.push(entry);
18
+ if (this.entries.length > this.capacity) this.entries.shift();
19
+ }
20
+ list(limit = this.capacity) {
21
+ return this.entries.slice(-limit).reverse();
22
+ }
23
+ };
24
+ const KNOWN_CLIENTS = [
25
+ [/claude-code|claude code/i, "Claude Code"],
26
+ [/claude/i, "Claude"],
27
+ [/chatgpt|openai/i, "ChatGPT"],
28
+ [/cursor/i, "Cursor"],
29
+ [/visual studio code|vscode|copilot/i, "VS Code"],
30
+ [/inspector/i, "MCP Inspector"],
31
+ [/openclaw/i, "OpenClaw"],
32
+ [/gemini/i, "Gemini CLI"],
33
+ [/codex/i, "Codex"]
34
+ ];
35
+ /** Maps a User-Agent to a short client label. */
36
+ function clientLabel(userAgent) {
37
+ if (!userAgent) return "unknown";
38
+ for (const [pattern, label] of KNOWN_CLIENTS) if (pattern.test(userAgent)) return label;
39
+ return userAgent.split(/[\s/]/)[0].slice(0, 40) || "unknown";
40
+ }
41
+ //#endregion
42
+ //#region src/core/crypto.ts
43
+ const encoder = new TextEncoder();
44
+ function utf8(input) {
45
+ return encoder.encode(input);
46
+ }
47
+ function bytesToBase64(bytes) {
48
+ let binary = "";
49
+ for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
50
+ return btoa(binary);
51
+ }
52
+ function base64ToBytes(base64) {
53
+ const normalized = base64.replace(/-/g, "+").replace(/_/g, "/").replace(/\s+/g, "");
54
+ const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
55
+ const binary = atob(padded);
56
+ const bytes = new Uint8Array(binary.length);
57
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
58
+ return bytes;
59
+ }
60
+ function base64UrlEncode(input) {
61
+ return bytesToBase64(typeof input === "string" ? utf8(input) : input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
62
+ }
63
+ async function sha256(input) {
64
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", utf8(input)));
65
+ }
66
+ /**
67
+ * Constant-time string comparison. Both inputs are hashed first so the comparison time does not
68
+ * depend on where the strings differ or on their lengths.
69
+ */
70
+ async function timingSafeEqual(a, b) {
71
+ const [ha, hb] = await Promise.all([sha256(a), sha256(b)]);
72
+ let diff = 0;
73
+ for (let i = 0; i < ha.length; i++) diff |= ha[i] ^ hb[i];
74
+ return diff === 0;
75
+ }
76
+ function randomToken(bytes = 32) {
77
+ return [...crypto.getRandomValues(new Uint8Array(bytes))].map((b) => b.toString(16).padStart(2, "0")).join("");
78
+ }
79
+ //#endregion
80
+ //#region src/core/log.ts
81
+ const LOG_LEVELS = [
82
+ "debug",
83
+ "info",
84
+ "warn",
85
+ "error",
86
+ "silent"
87
+ ];
88
+ const RANK = {
89
+ debug: 10,
90
+ info: 20,
91
+ warn: 30,
92
+ error: 40,
93
+ silent: 100
94
+ };
95
+ const SECRET_KEY = /token|secret|password|authorization|cookie|private_?key|assertion|credential|api_?key/i;
96
+ const defaultWriter = (line, level) => {
97
+ if (level === "error" || level === "warn") console.error(line);
98
+ else console.log(line);
99
+ };
100
+ /** For stdio transports: stdout carries the MCP protocol, so every log line goes to stderr. */
101
+ const stderrWriter = (line) => console.error(line);
102
+ function redact(value, depth = 0) {
103
+ if (value instanceof Error) return {
104
+ name: value.name,
105
+ message: value.message
106
+ };
107
+ if (depth > 4 || value === null || typeof value !== "object") return value;
108
+ if (Array.isArray(value)) return value.slice(0, 20).map((v) => redact(v, depth + 1));
109
+ const out = {};
110
+ for (const [key, v] of Object.entries(value)) out[key] = SECRET_KEY.test(key) ? "[redacted]" : redact(v, depth + 1);
111
+ return out;
112
+ }
113
+ function createLogger(options = {}) {
114
+ const threshold = RANK[options.level ?? "info"];
115
+ const write = options.write ?? defaultWriter;
116
+ const base = options.base ?? {};
117
+ const emit = (level, msg, fields) => {
118
+ if (RANK[level] < threshold) return;
119
+ const entry = {
120
+ time: (/* @__PURE__ */ new Date()).toISOString(),
121
+ level,
122
+ msg,
123
+ ...base,
124
+ ...fields ?? {}
125
+ };
126
+ write(JSON.stringify(redact(entry)), level);
127
+ };
128
+ return {
129
+ debug: (msg, fields) => emit("debug", msg, fields),
130
+ info: (msg, fields) => emit("info", msg, fields),
131
+ warn: (msg, fields) => emit("warn", msg, fields),
132
+ error: (msg, fields) => emit("error", msg, fields),
133
+ child: (fields) => createLogger({
134
+ level: options.level,
135
+ write,
136
+ base: {
137
+ ...base,
138
+ ...fields
139
+ }
140
+ })
141
+ };
142
+ }
143
+ createLogger({ level: "silent" });
144
+ const SETUP_DOCS_URL = "https://github.com/tuyakhov/gsclaw/blob/main/docs/setup.md";
145
+ /**
146
+ * Loads and validates configuration from environment variables. Never throws.
147
+ *
148
+ * Error messages name variables but never echo their values: on serverless deployments they are
149
+ * shown on the public setup page, and a secret pasted into the wrong variable must not leak.
150
+ */
151
+ function loadConfig(env, opts) {
152
+ const errors = [];
153
+ const warnings = [];
154
+ const read = (key) => {
155
+ const value = env[key]?.trim();
156
+ return value ? value : void 0;
157
+ };
158
+ const hasServiceAccount = Boolean(read("GOOGLE_SERVICE_ACCOUNT_JSON"));
159
+ const hasOAuth = Boolean(read("GOOGLE_OAUTH_CLIENT_ID") || read("GOOGLE_OAUTH_CLIENT_SECRET"));
160
+ const explicitMode = read("GSCLAW_AUTH_MODE");
161
+ let authMode;
162
+ if (explicitMode) {
163
+ if (explicitMode === "service_account" || explicitMode === "oauth") authMode = explicitMode;
164
+ else errors.push({
165
+ variable: "GSCLAW_AUTH_MODE",
166
+ message: "must be \"service_account\" or \"oauth\"."
167
+ });
168
+ } else if (hasServiceAccount && hasOAuth) errors.push({
169
+ variable: "GSCLAW_AUTH_MODE",
170
+ message: "both service-account and Google OAuth variables are set. Set GSCLAW_AUTH_MODE to \"service_account\" or \"oauth\" to choose one."
171
+ });
172
+ else if (hasServiceAccount) authMode = "service_account";
173
+ else if (hasOAuth) authMode = "oauth";
174
+ else errors.push({ message: "No authentication mode is configured, so GSClaw refuses to start (it never runs open). " + (opts.transport === "http" ? "Set GOOGLE_SERVICE_ACCOUNT_JSON and GSCLAW_ACCESS_TOKEN (service-account mode), or GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, GSCLAW_ENCRYPTION_KEY and PUBLIC_BASE_URL (Google OAuth mode)." : "Set GOOGLE_SERVICE_ACCOUNT_JSON or GOOGLE_APPLICATION_CREDENTIALS.") + ` See ${SETUP_DOCS_URL}` });
175
+ let serviceAccount;
176
+ let accessToken;
177
+ if (authMode === "service_account") {
178
+ const raw = read("GOOGLE_SERVICE_ACCOUNT_JSON");
179
+ if (!raw) errors.push({
180
+ variable: "GOOGLE_SERVICE_ACCOUNT_JSON",
181
+ message: "is required in service-account mode (paste the key JSON, raw or base64)."
182
+ });
183
+ else {
184
+ const parsed = parseServiceAccountJson(raw);
185
+ if ("error" in parsed) errors.push({
186
+ variable: "GOOGLE_SERVICE_ACCOUNT_JSON",
187
+ message: parsed.error
188
+ });
189
+ else serviceAccount = parsed.key;
190
+ }
191
+ if (opts.transport === "http") {
192
+ accessToken = read("GSCLAW_ACCESS_TOKEN");
193
+ if (!accessToken) errors.push({
194
+ variable: "GSCLAW_ACCESS_TOKEN",
195
+ message: "is required for HTTP deployments so the endpoint is never public. Generate one with `openssl rand -hex 32` or `npx gsclaw generate-token`."
196
+ });
197
+ else if (accessToken.length < 32) errors.push({
198
+ variable: "GSCLAW_ACCESS_TOKEN",
199
+ message: `must be at least 32 characters. Generate one with \`openssl rand -hex 32\`.`
200
+ });
201
+ }
202
+ }
203
+ const encryptionKey = read("GSCLAW_ENCRYPTION_KEY");
204
+ if (encryptionKey && encryptionKey.length < 32) errors.push({
205
+ variable: "GSCLAW_ENCRYPTION_KEY",
206
+ message: `must be at least 32 characters. Generate one with \`openssl rand -hex 32\`.`
207
+ });
208
+ let googleOAuth;
209
+ if (authMode === "oauth") {
210
+ if (opts.transport === "stdio") errors.push({
211
+ variable: "GSCLAW_AUTH_MODE",
212
+ message: "Google OAuth mode is for HTTP deployments. For local stdio use, set GOOGLE_SERVICE_ACCOUNT_JSON or GOOGLE_APPLICATION_CREDENTIALS instead."
213
+ });
214
+ const clientId = read("GOOGLE_OAUTH_CLIENT_ID");
215
+ const clientSecret = read("GOOGLE_OAUTH_CLIENT_SECRET");
216
+ if (!clientId) errors.push({
217
+ variable: "GOOGLE_OAUTH_CLIENT_ID",
218
+ message: "is required in OAuth mode (Google Cloud → APIs & Services → Credentials → OAuth client ID)."
219
+ });
220
+ if (!clientSecret) errors.push({
221
+ variable: "GOOGLE_OAUTH_CLIENT_SECRET",
222
+ message: "is required in OAuth mode."
223
+ });
224
+ if (!encryptionKey) errors.push({
225
+ variable: "GSCLAW_ENCRYPTION_KEY",
226
+ message: "is required in OAuth mode; it encrypts the tokens GSClaw issues. Generate one with `openssl rand -hex 32`."
227
+ });
228
+ const list = (key) => (read(key) ?? "").split(",").map((v) => v.trim().toLowerCase().replace(/^@/, "")).filter(Boolean);
229
+ if (clientId && clientSecret) googleOAuth = {
230
+ clientId,
231
+ clientSecret,
232
+ allowedEmails: list("ALLOWED_GOOGLE_EMAILS"),
233
+ allowedDomains: list("ALLOWED_GOOGLE_DOMAINS")
234
+ };
235
+ }
236
+ const bool = (key, fallback) => {
237
+ const value = read(key);
238
+ if (value === void 0) return fallback;
239
+ const v = value.toLowerCase();
240
+ if ([
241
+ "1",
242
+ "true",
243
+ "yes",
244
+ "on"
245
+ ].includes(v)) return true;
246
+ if ([
247
+ "0",
248
+ "false",
249
+ "no",
250
+ "off"
251
+ ].includes(v)) return false;
252
+ errors.push({
253
+ variable: key,
254
+ message: "must be \"true\" or \"false\"."
255
+ });
256
+ return fallback;
257
+ };
258
+ const int = (key, fallback, min, max) => {
259
+ const value = read(key);
260
+ if (value === void 0) return fallback;
261
+ const n = Number(value);
262
+ if (!Number.isInteger(n) || n < min || n > max) {
263
+ errors.push({
264
+ variable: key,
265
+ message: `must be an integer between ${min} and ${max}.`
266
+ });
267
+ return fallback;
268
+ }
269
+ return n;
270
+ };
271
+ const logLevelRaw = (read("LOG_LEVEL") ?? "info").toLowerCase();
272
+ let logLevel = "info";
273
+ if (LOG_LEVELS.includes(logLevelRaw)) logLevel = logLevelRaw;
274
+ else errors.push({
275
+ variable: "LOG_LEVEL",
276
+ message: `must be one of ${LOG_LEVELS.join(", ")}.`
277
+ });
278
+ let publicBaseUrl = read("PUBLIC_BASE_URL") ?? detectPlatformBaseUrl(env);
279
+ if (publicBaseUrl) try {
280
+ const url = new URL(publicBaseUrl);
281
+ publicBaseUrl = url.origin + url.pathname.replace(/\/+$/, "");
282
+ } catch {
283
+ errors.push({
284
+ variable: "PUBLIC_BASE_URL",
285
+ message: "is not a valid URL (expected e.g. https://gsclaw.example.com)."
286
+ });
287
+ publicBaseUrl = void 0;
288
+ }
289
+ const allowedOrigins = [];
290
+ for (const entry of (read("GSCLAW_ALLOWED_ORIGINS") ?? "").split(",")) {
291
+ const value = entry.trim();
292
+ if (!value) continue;
293
+ try {
294
+ allowedOrigins.push(new URL(value).origin);
295
+ } catch {
296
+ errors.push({
297
+ variable: "GSCLAW_ALLOWED_ORIGINS",
298
+ message: "contains an invalid origin (expected e.g. https://app.example.com)."
299
+ });
300
+ }
301
+ }
302
+ const allowWrites = bool("GSCLAW_ALLOW_WRITES", false);
303
+ const dashboard = bool("GSCLAW_DASHBOARD", true);
304
+ const secretPath = bool("GSCLAW_SECRET_PATH", true);
305
+ const rateLimitPerMinute = int("GSCLAW_RATE_LIMIT_PER_MINUTE", 120, 0, 1e5);
306
+ const cacheTtlSeconds = int("GSCLAW_CACHE_TTL_SECONDS", 300, 0, 86400);
307
+ const maxRows = int("GSCLAW_MAX_ROWS", 25e3, 1, 5e5);
308
+ const batchInspectMax = int("GSCLAW_BATCH_INSPECT_MAX", 50, 1, 500);
309
+ const oauthRedirectHosts = (read("GSCLAW_OAUTH_REDIRECT_HOSTS") ?? "").split(",").map((h) => h.trim().toLowerCase()).filter(Boolean);
310
+ if (authMode === "oauth" && opts.transport === "http") {
311
+ if (!publicBaseUrl) errors.push({
312
+ variable: "PUBLIC_BASE_URL",
313
+ message: "is required in OAuth mode (e.g. https://gsclaw.example.com); Google redirects back to PUBLIC_BASE_URL/oauth/google/callback."
314
+ });
315
+ else {
316
+ const url = new URL(publicBaseUrl);
317
+ const loopback = [
318
+ "localhost",
319
+ "127.0.0.1",
320
+ "[::1]"
321
+ ].includes(url.hostname);
322
+ if (url.protocol !== "https:" && !loopback) errors.push({
323
+ variable: "PUBLIC_BASE_URL",
324
+ message: "must use https in OAuth mode."
325
+ });
326
+ }
327
+ }
328
+ if (errors.length > 0 || !authMode) return {
329
+ ok: false,
330
+ errors
331
+ };
332
+ if (googleOAuth && googleOAuth.allowedEmails.length === 0 && googleOAuth.allowedDomains.length === 0) warnings.push("No ALLOWED_GOOGLE_EMAILS or ALLOWED_GOOGLE_DOMAINS set: anyone with a Google account can sign in to this deployment (each person only sees their own Search Console properties).");
333
+ if (allowWrites) warnings.push("GSCLAW_ALLOW_WRITES=true: submit_sitemap and delete_sitemap are enabled. The service account needs Full permission on each property.");
334
+ return {
335
+ ok: true,
336
+ warnings,
337
+ config: {
338
+ authMode,
339
+ serviceAccount,
340
+ accessToken,
341
+ googleOAuth,
342
+ sealingSecret: opts.transport !== "http" ? void 0 : accessToken ? [encryptionKey, accessToken].filter(Boolean).join("\n") : encryptionKey,
343
+ oauthRedirectHosts,
344
+ publicBaseUrl,
345
+ allowWrites,
346
+ dashboard,
347
+ secretPath,
348
+ allowedOrigins,
349
+ logLevel,
350
+ rateLimitPerMinute,
351
+ cacheTtlSeconds,
352
+ maxRows,
353
+ batchInspectMax
354
+ }
355
+ };
356
+ }
357
+ function parseServiceAccountJson(raw) {
358
+ let text = raw.trim();
359
+ if (!text.startsWith("{")) try {
360
+ text = new TextDecoder().decode(base64ToBytes(text)).trim();
361
+ } catch {
362
+ return { error: "is neither JSON nor base64-encoded JSON." };
363
+ }
364
+ let data;
365
+ try {
366
+ data = JSON.parse(text);
367
+ } catch {
368
+ return { error: "is not valid JSON. Paste the whole key file you downloaded from Google Cloud (or base64-encode it)." };
369
+ }
370
+ if (!data || typeof data !== "object") return { error: "must be a JSON object." };
371
+ const obj = data;
372
+ if (obj.type !== void 0 && obj.type !== "service_account") return { error: `has type "${JSON.stringify(obj.type)}" but must be a service-account key ("type": "service_account"). OAuth client JSON files belong in GOOGLE_OAUTH_CLIENT_ID/SECRET instead.` };
373
+ if (typeof obj.client_email !== "string" || !obj.client_email.includes("@")) return { error: "is missing \"client_email\"." };
374
+ if (typeof obj.private_key !== "string" || !obj.private_key.includes("PRIVATE KEY")) return { error: "is missing \"private_key\"." };
375
+ const privateKey = obj.private_key.includes("\n") ? obj.private_key : obj.private_key.replace(/\\n/g, "\n");
376
+ return { key: {
377
+ client_email: obj.client_email,
378
+ private_key: privateKey,
379
+ private_key_id: typeof obj.private_key_id === "string" ? obj.private_key_id : void 0,
380
+ project_id: typeof obj.project_id === "string" ? obj.project_id : void 0,
381
+ token_uri: typeof obj.token_uri === "string" ? obj.token_uri : void 0
382
+ } };
383
+ }
384
+ /** Best-effort public URL detection so one-click deploys work without PUBLIC_BASE_URL. */
385
+ function detectPlatformBaseUrl(env) {
386
+ const https = (host) => host ? `https://${host.replace(/^https?:\/\//, "")}` : void 0;
387
+ return https(env.VERCEL_PROJECT_PRODUCTION_URL) ?? env.URL ?? env.RENDER_EXTERNAL_URL ?? https(env.RAILWAY_PUBLIC_DOMAIN) ?? env.APP_URL ?? (env.FLY_APP_NAME ? `https://${env.FLY_APP_NAME}.fly.dev` : void 0);
388
+ }
389
+ function formatConfigErrors(errors) {
390
+ return [
391
+ "GSClaw is not configured correctly and will not start:",
392
+ "",
393
+ ...errors.map((e) => ` • ${e.variable ? `${e.variable} ` : ""}${e.message}`),
394
+ "",
395
+ `Setup guide: ${SETUP_DOCS_URL}`
396
+ ].join("\n");
397
+ }
398
+ //#endregion
399
+ //#region src/core/errors.ts
400
+ /** An error whose message is written for the end user (and their AI) and safe to show verbatim. */
401
+ var UserFacingError = class extends Error {
402
+ kind;
403
+ constructor(message, kind = "invalid_input") {
404
+ super(message);
405
+ this.name = "UserFacingError";
406
+ this.kind = kind;
407
+ }
408
+ };
409
+ var GscApiError = class extends UserFacingError {
410
+ status;
411
+ reason;
412
+ googleMessage;
413
+ retryable;
414
+ constructor(opts) {
415
+ const suffix = opts.googleMessage ? `\n\nGoogle said: ${opts.googleMessage}` : "";
416
+ super(`${opts.advice}${suffix}`, opts.kind);
417
+ this.name = "GscApiError";
418
+ this.status = opts.status;
419
+ this.reason = opts.reason;
420
+ this.googleMessage = opts.googleMessage;
421
+ this.retryable = opts.retryable ?? false;
422
+ }
423
+ };
424
+ const RATE_LIMIT_REASONS = /* @__PURE__ */ new Set([
425
+ "rateLimitExceeded",
426
+ "userRateLimitExceeded",
427
+ "rateLimitExceededUnreg"
428
+ ]);
429
+ const DAILY_QUOTA_REASONS = /* @__PURE__ */ new Set([
430
+ "quotaExceeded",
431
+ "dailyLimitExceeded",
432
+ "dailyLimitExceededUnreg"
433
+ ]);
434
+ function who(identity) {
435
+ return identity.kind === "service_account" ? `The service account ${identity.email}` : `Your Google account (${identity.email})`;
436
+ }
437
+ /** Turns a Google API error response into an actionable message. */
438
+ function toGscApiError(status, body, ctx) {
439
+ const err = body?.error;
440
+ const googleMessage = (err?.message ?? (typeof body === "string" ? body : "")).slice(0, 500);
441
+ const reasons = /* @__PURE__ */ new Set();
442
+ for (const e of err?.errors ?? []) if (e.reason) reasons.add(e.reason);
443
+ for (const d of err?.details ?? []) if (d.reason) reasons.add(d.reason);
444
+ if (err?.status) reasons.add(err.status);
445
+ const reason = [...reasons][0];
446
+ const has = (...names) => names.some((n) => reasons.has(n));
447
+ const site = ctx.siteUrl ? `'${ctx.siteUrl}'` : "this property";
448
+ const { identity } = ctx;
449
+ if (status === 429 || has(...RATE_LIMIT_REASONS) || has("RESOURCE_EXHAUSTED") && !has(...DAILY_QUOTA_REASONS)) return new GscApiError({
450
+ kind: "rate_limited",
451
+ status,
452
+ reason,
453
+ googleMessage,
454
+ retryable: true,
455
+ advice: "Google's Search Console API rate limit was hit (Search Analytics allows ~1,200 queries/min per site; URL Inspection 600/min). Wait a minute and retry. If it keeps happening, use shorter date ranges or fewer page/query breakdowns, which count heavier against the load quota."
456
+ });
457
+ if (has(...DAILY_QUOTA_REASONS)) return new GscApiError({
458
+ kind: "quota_exceeded",
459
+ status,
460
+ reason,
461
+ googleMessage,
462
+ advice: "The daily Search Console API quota is used up (URL Inspection allows 2,000 inspections per property per day). It resets at midnight Pacific Time."
463
+ });
464
+ if (has("SERVICE_DISABLED", "accessNotConfigured") || /has not been used in project|is disabled/i.test(googleMessage)) {
465
+ const project = identity.projectId ?? googleMessage.match(/project (\d+|[a-z][a-z0-9-]+)/)?.[1];
466
+ const link = `https://console.cloud.google.com/apis/library/searchconsole.googleapis.com${project ? `?project=${project}` : ""}`;
467
+ return new GscApiError({
468
+ kind: "api_disabled",
469
+ status,
470
+ reason,
471
+ googleMessage,
472
+ advice: `The Google Search Console API is not enabled for the Google Cloud project${project ? ` "${project}"` : ""}. Enable it at ${link}, wait a minute, then retry.`
473
+ });
474
+ }
475
+ if (status === 401) return new GscApiError({
476
+ kind: "auth",
477
+ status,
478
+ reason,
479
+ googleMessage,
480
+ advice: identity.kind === "service_account" ? "Google rejected the service-account credentials. The key may have been deleted or rotated: create a new JSON key in Google Cloud and update GOOGLE_SERVICE_ACCOUNT_JSON." : "Your Google sign-in has expired or was revoked. Reconnect GSClaw in your AI client to sign in again."
481
+ });
482
+ if (status === 403) {
483
+ const fix = identity.kind === "service_account" ? `In Search Console, open the property → Settings → Users and permissions → Add user, and add ${identity.email} (Restricted is enough for reading; Full is needed for sitemap changes).` : "Ask an owner of the property to add your Google account in Search Console → Settings → Users and permissions.";
484
+ return new GscApiError({
485
+ kind: "permission",
486
+ status,
487
+ reason,
488
+ googleMessage,
489
+ advice: `${who(identity)} does not have access to ${site}. ${fix} Also check the property format: domain properties look like 'sc-domain:example.com', URL-prefix properties like 'https://www.example.com/' (exact scheme, www and trailing slash). Run list_sites to see every property this server can read.`
490
+ });
491
+ }
492
+ if (status === 404) return new GscApiError({
493
+ kind: "not_found",
494
+ status,
495
+ reason,
496
+ googleMessage,
497
+ advice: `Not found. Check the property and URL: run list_sites for valid properties, and pass sitemaps as full URLs exactly as list_sitemaps shows them.`
498
+ });
499
+ if (status === 400) return new GscApiError({
500
+ kind: "invalid_argument",
501
+ status,
502
+ reason,
503
+ googleMessage,
504
+ advice: "Google rejected the request parameters. Common causes: searchAppearance combined with other dimensions, aggregation_type \"byProperty\" with a page dimension/filter, Discover/Google News with query or position data, or dates older than 16 months."
505
+ });
506
+ return new GscApiError({
507
+ kind: "server",
508
+ status,
509
+ reason,
510
+ googleMessage,
511
+ retryable: status >= 500,
512
+ advice: `Google's Search Console API returned an error (HTTP ${status}). This is usually temporary; try again shortly.`
513
+ });
514
+ }
515
+ /** Message for any thrown value, safe to return to clients. Unknown errors are not echoed in detail. */
516
+ function publicErrorMessage(error) {
517
+ if (error instanceof UserFacingError) return {
518
+ message: error.message,
519
+ kind: error.kind
520
+ };
521
+ return {
522
+ message: "Unexpected internal error. Check the server logs for details.",
523
+ kind: "internal"
524
+ };
525
+ }
526
+ //#endregion
527
+ //#region src/core/gsc/sites.ts
528
+ function normalizePermission(level) {
529
+ switch (level.replace(/_/g, "").toLowerCase()) {
530
+ case "siteowner": return "owner";
531
+ case "sitefulluser": return "full";
532
+ case "siterestricteduser": return "restricted";
533
+ case "siteunverifieduser": return "unverified";
534
+ default: return "unknown";
535
+ }
536
+ }
537
+ function toProperty(entry) {
538
+ const permission = normalizePermission(entry.permissionLevel);
539
+ const isDomain = entry.siteUrl.startsWith("sc-domain:");
540
+ let host = entry.siteUrl;
541
+ if (isDomain) host = entry.siteUrl.slice(10);
542
+ else try {
543
+ host = new URL(entry.siteUrl).host;
544
+ } catch {}
545
+ return {
546
+ site_url: entry.siteUrl,
547
+ type: isDomain ? "domain" : "url_prefix",
548
+ host: host.toLowerCase(),
549
+ permission_level: permission,
550
+ has_access: permission !== "unverified"
551
+ };
552
+ }
553
+ async function listProperties(client, opts = {}) {
554
+ return (await client.listSites(opts)).map(toProperty).sort((a, b) => Number(b.has_access) - Number(a.has_access) || Number(b.type === "domain") - Number(a.type === "domain") || a.site_url.localeCompare(b.site_url));
555
+ }
556
+ function stripWww(host) {
557
+ return host.replace(/^www\./, "");
558
+ }
559
+ /** True if `property` would contain data for `url`. */
560
+ function propertyCoversUrl(property, url) {
561
+ let parsed;
562
+ try {
563
+ parsed = new URL(url);
564
+ } catch {
565
+ return false;
566
+ }
567
+ const host = parsed.host.toLowerCase();
568
+ if (property.type === "domain") return host === property.host || host.endsWith(`.${property.host}`);
569
+ return url.startsWith(property.site_url) || `${url}/` === property.site_url;
570
+ }
571
+ function describeAvailable(properties) {
572
+ const usable = properties.filter((p) => p.has_access);
573
+ if (usable.length === 0) return "This server cannot read any Search Console property yet. Add its Google identity as a user on your properties (Search Console → Settings → Users and permissions).";
574
+ return `Available properties: ${usable.slice(0, 20).map((p) => `'${p.site_url}'`).join(", ")}${usable.length > 20 ? `, … (${usable.length} total)` : ""}.`;
575
+ }
576
+ /**
577
+ * Resolves user input ('sc-domain:example.com', 'https://www.example.com/', 'example.com',
578
+ * or any URL on the site) to a property this identity can read.
579
+ */
580
+ async function resolveProperty(client, input) {
581
+ const properties = await listProperties(client);
582
+ const usable = properties.filter((p) => p.has_access);
583
+ const raw = input.trim();
584
+ const lower = raw.toLowerCase();
585
+ const exact = properties.find((p) => p.site_url.toLowerCase() === lower || p.site_url.toLowerCase() === `${lower}/`);
586
+ if (exact) {
587
+ if (!exact.has_access) throw new UserFacingError(`'${exact.site_url}' is listed but unverified for this identity, so Search Console exposes no data for it. Verify ownership or ask an owner for access. ${describeAvailable(properties)}`, "permission");
588
+ return exact;
589
+ }
590
+ if (lower.startsWith("sc-domain:")) throw new UserFacingError(`No access to '${raw}'. ${describeAvailable(properties)}`, "not_found");
591
+ const hasScheme = /^https?:\/\//.test(lower);
592
+ let url;
593
+ try {
594
+ url = new URL(hasScheme ? raw : `https://${raw}`);
595
+ } catch {
596
+ throw new UserFacingError(`'${raw}' is not a valid property, domain or URL. ${describeAvailable(properties)}`);
597
+ }
598
+ const host = url.host.toLowerCase();
599
+ const domainMatch = usable.filter((p) => p.type === "domain" && (host === p.host || host.endsWith(`.${p.host}`))).sort((a, b) => b.host.length - a.host.length)[0];
600
+ const prefixMatches = usable.filter((p) => p.type === "url_prefix").filter((p) => {
601
+ if (hasScheme) return propertyCoversUrl(p, url.href);
602
+ return stripWww(p.host) === stripWww(host) && (url.pathname === "/" || propertyCoversUrl(p, `https://${p.host}${url.pathname}`));
603
+ }).sort((a, b) => {
604
+ if (b.site_url.length !== a.site_url.length && hasScheme) return b.site_url.length - a.site_url.length;
605
+ const score = (p) => (p.site_url.startsWith("https://") ? 2 : 0) + (p.host === host ? 1 : 0);
606
+ return score(b) - score(a);
607
+ });
608
+ const match = hasScheme ? prefixMatches[0] ?? domainMatch : domainMatch ?? prefixMatches[0];
609
+ if (match) return match;
610
+ throw new UserFacingError(`No Search Console property matches '${raw}'. ${describeAvailable(properties)}`, "not_found");
611
+ }
612
+ /** Resolves a page given as an absolute URL or a path ('/pricing') against a property. */
613
+ function absolutePageUrl(property, page) {
614
+ if (/^https?:\/\//i.test(page)) return page;
615
+ if (!page.startsWith("/")) return null;
616
+ if (property.type === "url_prefix") return new URL(page.replace(/^\//, ""), property.site_url).href;
617
+ return null;
618
+ }
619
+ //#endregion
620
+ //#region src/core/gsc/auth.ts
621
+ const SCOPE_READONLY = "https://www.googleapis.com/auth/webmasters.readonly";
622
+ const SCOPE_READWRITE = "https://www.googleapis.com/auth/webmasters";
623
+ const EXPIRY_MARGIN_MS = 6e4;
624
+ /**
625
+ * Service-account tokens via the OAuth 2.0 JWT bearer flow, signed with WebCrypto (RS256) so it
626
+ * runs on Node and Cloudflare Workers alike. The imported key and the access token are cached for
627
+ * the life of the instance.
628
+ */
629
+ var ServiceAccountTokenSource = class {
630
+ identity;
631
+ scopes;
632
+ key;
633
+ fetchImpl;
634
+ now;
635
+ cryptoKey;
636
+ cached;
637
+ inflight;
638
+ constructor(key, scopes, opts = {}) {
639
+ this.key = key;
640
+ this.scopes = scopes;
641
+ this.fetchImpl = opts.fetch ?? ((input, init) => fetch(input, init));
642
+ this.now = opts.now ?? Date.now;
643
+ this.identity = {
644
+ kind: "service_account",
645
+ email: key.client_email,
646
+ projectId: key.project_id
647
+ };
648
+ }
649
+ async getAccessToken() {
650
+ if (this.cached && this.cached.expiresAt - EXPIRY_MARGIN_MS > this.now()) return this.cached.token;
651
+ this.inflight ??= this.fetchToken().finally(() => {
652
+ this.inflight = void 0;
653
+ });
654
+ return this.inflight;
655
+ }
656
+ invalidate() {
657
+ this.cached = void 0;
658
+ }
659
+ /** Builds the signed JWT assertion. Exposed for tests. */
660
+ async createAssertion() {
661
+ const iat = Math.floor(this.now() / 1e3);
662
+ const header = {
663
+ alg: "RS256",
664
+ typ: "JWT",
665
+ ...this.key.private_key_id ? { kid: this.key.private_key_id } : {}
666
+ };
667
+ const claims = {
668
+ iss: this.key.client_email,
669
+ scope: this.scopes.join(" "),
670
+ aud: this.key.token_uri ?? "https://oauth2.googleapis.com/token",
671
+ iat,
672
+ exp: iat + 3600
673
+ };
674
+ const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(claims))}`;
675
+ this.cryptoKey ??= importPkcs8(this.key.private_key);
676
+ const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", await this.cryptoKey, utf8(signingInput));
677
+ return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
678
+ }
679
+ async fetchToken() {
680
+ let assertion;
681
+ try {
682
+ assertion = await this.createAssertion();
683
+ } catch {
684
+ this.cryptoKey = void 0;
685
+ throw new GscApiError({
686
+ kind: "auth",
687
+ status: 0,
688
+ googleMessage: "",
689
+ advice: "The service-account private key could not be read. Make sure GOOGLE_SERVICE_ACCOUNT_JSON contains the complete key file, including \"-----BEGIN PRIVATE KEY-----\"."
690
+ });
691
+ }
692
+ const res = await this.fetchImpl(this.key.token_uri ?? "https://oauth2.googleapis.com/token", {
693
+ method: "POST",
694
+ headers: { "content-type": "application/x-www-form-urlencoded" },
695
+ body: new URLSearchParams({
696
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
697
+ assertion
698
+ }).toString()
699
+ });
700
+ const body = await res.json().catch(() => ({}));
701
+ if (!res.ok || !body.access_token) {
702
+ const detail = [body.error, body.error_description].filter(Boolean).join(": ");
703
+ throw new GscApiError({
704
+ kind: "auth",
705
+ status: res.status,
706
+ reason: body.error,
707
+ googleMessage: detail,
708
+ retryable: res.status >= 500,
709
+ advice: body.error === "invalid_grant" ? "Google refused the service-account token (invalid_grant). Usually the key was deleted in Google Cloud, or this server’s clock is off by more than a few minutes. Create a new key and update GOOGLE_SERVICE_ACCOUNT_JSON." : `Could not obtain a Google access token for the service account ${this.key.client_email}. Check that the key is valid and not disabled.`
710
+ });
711
+ }
712
+ this.cached = {
713
+ token: body.access_token,
714
+ expiresAt: this.now() + (body.expires_in ?? 3600) * 1e3
715
+ };
716
+ return body.access_token;
717
+ }
718
+ };
719
+ async function importPkcs8(pem) {
720
+ const der = base64ToBytes(pem.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, "").replace(/\s+/g, ""));
721
+ return crypto.subtle.importKey("pkcs8", der, {
722
+ name: "RSASSA-PKCS1-v1_5",
723
+ hash: "SHA-256"
724
+ }, false, ["sign"]);
725
+ }
726
+ /**
727
+ * A signed-in Google user's credentials (OAuth mode). Uses the access token carried in GSClaw's own
728
+ * token while it is fresh, then refreshes with the user's refresh token, sharing results across
729
+ * requests on this instance.
730
+ */
731
+ var UserTokenSource = class {
732
+ identity;
733
+ scopes;
734
+ opts;
735
+ inflight;
736
+ constructor(email, scopes, opts) {
737
+ this.identity = {
738
+ kind: "user",
739
+ email
740
+ };
741
+ this.scopes = scopes;
742
+ this.opts = opts;
743
+ }
744
+ nowMs() {
745
+ return this.opts.now ? this.opts.now() : Date.now();
746
+ }
747
+ async getAccessToken() {
748
+ const now = this.nowMs();
749
+ const { accessToken, expiresAt, cache, sub } = this.opts;
750
+ if (accessToken && expiresAt && expiresAt * 1e3 - EXPIRY_MARGIN_MS > now) return accessToken;
751
+ const cached = cache.get(sub);
752
+ if (cached && cached.expiresAt * 1e3 - EXPIRY_MARGIN_MS > now) return cached.token;
753
+ this.inflight ??= this.opts.refresh(this.opts.refreshToken).then((fresh) => {
754
+ cache.set(sub, {
755
+ token: fresh.accessToken,
756
+ expiresAt: fresh.expiresAt
757
+ });
758
+ return fresh.accessToken;
759
+ }).finally(() => {
760
+ this.inflight = void 0;
761
+ });
762
+ return this.inflight;
763
+ }
764
+ invalidate() {
765
+ this.opts.accessToken = void 0;
766
+ this.opts.cache.delete(this.opts.sub);
767
+ }
768
+ };
769
+ //#endregion
770
+ //#region package.json
771
+ var version = "0.1.0";
772
+ //#endregion
773
+ //#region src/core/version.ts
774
+ const NAME = "gsclaw";
775
+ const VERSION = version;
776
+ const REPO_URL = "https://github.com/tuyakhov/gsclaw";
777
+ //#endregion
778
+ //#region src/core/pages.ts
779
+ const escape = (s) => s.replace(/[&<>"']/g, (c) => ({
780
+ "&": "&amp;",
781
+ "<": "&lt;",
782
+ ">": "&gt;",
783
+ "\"": "&quot;",
784
+ "'": "&#39;"
785
+ })[c]);
786
+ const HTML_SECURITY_HEADERS = {
787
+ "content-type": "text/html; charset=utf-8",
788
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
789
+ "x-content-type-options": "nosniff",
790
+ "referrer-policy": "no-referrer",
791
+ "x-frame-options": "DENY",
792
+ "cache-control": "no-store"
793
+ };
794
+ function layout(title, body) {
795
+ return `<!doctype html>
796
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
797
+ <meta name="robots" content="noindex"><title>${escape(title)}</title>
798
+ <style>
799
+ :root{color-scheme:light dark;--fg:#1c1b1a;--muted:#6b6862;--bg:#faf9f7;--card:#fff;--line:#e6e3de;--accent:#0d9488;--accent-text:#fff}
800
+ @media (prefers-color-scheme:dark){:root{--fg:#ecebe8;--muted:#a19e98;--bg:#151413;--card:#1e1d1b;--line:#33312e;--accent:#2dd4bf;--accent-text:#062b27}}
801
+ body{margin:0;font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);color:var(--fg);display:grid;place-items:center;min-height:100vh;padding:16px;box-sizing:border-box}
802
+ main{max-width:560px;width:100%;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:28px}
803
+ h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:.5rem 0;color:var(--muted)}a{color:var(--accent)}
804
+ ul{padding-left:1.2rem}li{margin:.35rem 0}code{font-size:.9em}
805
+ form{display:grid;gap:10px;margin-top:16px}label{font-weight:600;font-size:.9rem}
806
+ input{font:inherit;height:40px;padding:0 12px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg)}
807
+ .actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
808
+ button,.button{font:inherit;font-weight:600;height:40px;border-radius:8px;padding:0 16px;border:1px solid var(--line);background:var(--card);color:var(--fg);cursor:pointer;text-decoration:none;display:inline-flex;align-items:center}
809
+ .primary{background:var(--accent);border-color:var(--accent);color:var(--accent-text)}
810
+ .client{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:14px 0;color:var(--fg)}
811
+ .client small{display:block;color:var(--muted)}.warn{color:#b45309}.error{color:#b91c1c;margin:0}
812
+ </style></head><body><main>${body}</main></body></html>`;
813
+ }
814
+ /** Minimal public page: confirms the server runs, reveals nothing about its configuration. */
815
+ function runningPage() {
816
+ return layout("GSClaw", `<h1>GSClaw is running</h1>
817
+ <p>This is a Google Search Console MCP server. Connect it from your AI client; the endpoint requires authentication.</p>
818
+ <p><a href="${REPO_URL}#readme">Documentation</a></p>`);
819
+ }
820
+ /** Shown on serverless deployments that are missing configuration (names only, never values). */
821
+ function setupPage(issues) {
822
+ return layout("GSClaw setup required", `<h1>GSClaw needs configuration</h1>
823
+ <p>This deployment is not configured yet, so it refuses to serve requests. Set these environment variables in your hosting dashboard and redeploy:</p>
824
+ <ul>${issues.map((i) => `<li>${i.variable ? `<code>${escape(i.variable)}</code> ` : ""}${escape(i.message)}</li>`).join("")}</ul>
825
+ <p><a href="${REPO_URL}/blob/main/docs/setup.md">Setup guide</a> · v${escape(VERSION)}</p>`);
826
+ }
827
+ //#endregion
828
+ //#region src/core/principal.ts
829
+ /** GSClaw's own OAuth scopes (what a client may do), mapped to Google scopes internally. */
830
+ const SCOPE_READ = "gsc:read";
831
+ const SCOPE_WRITE = "gsc:write";
832
+ const OWNER = {
833
+ kind: "owner",
834
+ sub: "owner",
835
+ scopes: [SCOPE_READ, SCOPE_WRITE],
836
+ via: "static_token"
837
+ };
838
+ //#endregion
839
+ //#region src/core/ratelimit.ts
840
+ /**
841
+ * Per-key token bucket (in memory, per instance). Refills `perMinute` tokens per minute with a
842
+ * burst of the same size. A limit of 0 disables limiting.
843
+ */
844
+ var RateLimiter = class {
845
+ perMinute;
846
+ now;
847
+ buckets = /* @__PURE__ */ new Map();
848
+ constructor(perMinute, now = Date.now) {
849
+ this.perMinute = perMinute;
850
+ this.now = now;
851
+ }
852
+ check(key) {
853
+ if (this.perMinute <= 0) return { ok: true };
854
+ const now = this.now();
855
+ const ratePerMs = this.perMinute / 6e4;
856
+ const bucket = this.buckets.get(key) ?? {
857
+ tokens: this.perMinute,
858
+ updated: now
859
+ };
860
+ bucket.tokens = Math.min(this.perMinute, bucket.tokens + (now - bucket.updated) * ratePerMs);
861
+ bucket.updated = now;
862
+ if (bucket.tokens >= 1) {
863
+ bucket.tokens -= 1;
864
+ this.buckets.set(key, bucket);
865
+ this.prune(now);
866
+ return { ok: true };
867
+ }
868
+ this.buckets.set(key, bucket);
869
+ return {
870
+ ok: false,
871
+ retryAfterSeconds: Math.ceil((1 - bucket.tokens) / ratePerMs / 1e3)
872
+ };
873
+ }
874
+ prune(now) {
875
+ if (this.buckets.size <= 1e3) return;
876
+ for (const [key, bucket] of this.buckets) if (now - bucket.updated > 12e4) this.buckets.delete(key);
877
+ }
878
+ };
879
+ //#endregion
880
+ //#region src/core/session.ts
881
+ const SESSION_COOKIE = "gsclaw_session";
882
+ function parseCookies(header) {
883
+ const out = {};
884
+ if (!header) return out;
885
+ for (const part of header.split(";")) {
886
+ const i = part.indexOf("=");
887
+ if (i < 0) continue;
888
+ const name = part.slice(0, i).trim();
889
+ if (name) out[name] = part.slice(i + 1).trim();
890
+ }
891
+ return out;
892
+ }
893
+ /**
894
+ * httpOnly + SameSite=Strict always. `Secure` everywhere except plain-http loopback development,
895
+ * where some browsers would otherwise drop the cookie. Strict is enough even after the cross-site
896
+ * return from Google sign-in: the HTML shell needs no cookie, and every API call is same-origin.
897
+ */
898
+ function sessionCookie(value, opts) {
899
+ const attrs = [
900
+ `${SESSION_COOKIE}=${value}`,
901
+ "Path=/",
902
+ "HttpOnly",
903
+ "SameSite=Strict",
904
+ `Max-Age=${opts.maxAge ?? 604800}`
905
+ ];
906
+ if (opts.secure) attrs.push("Secure");
907
+ return attrs.join("; ");
908
+ }
909
+ function clearedSessionCookie(secure) {
910
+ return sessionCookie("", {
911
+ secure,
912
+ maxAge: 0
913
+ });
914
+ }
915
+ const STATE_COOKIE = "gsclaw_oauth_state";
916
+ /**
917
+ * Binds a Google sign-in to the browser that started it (login-CSRF protection). SameSite=Lax, not
918
+ * Strict: it has to ride along on Google's cross-site redirect back to the callback, its only path.
919
+ */
920
+ function stateCookie(value, opts) {
921
+ const attrs = [
922
+ `${STATE_COOKIE}=${value}`,
923
+ "Path=/oauth/google/callback",
924
+ "HttpOnly",
925
+ "SameSite=Lax",
926
+ `Max-Age=${opts.maxAge}`
927
+ ];
928
+ if (opts.secure) attrs.push("Secure");
929
+ return attrs.join("; ");
930
+ }
931
+ //#endregion
932
+ //#region src/core/oauth/clients.ts
933
+ /** Redirect hosts of popular remote-MCP clients. Extend with GSCLAW_OAUTH_REDIRECT_HOSTS. */
934
+ const DEFAULT_REDIRECT_HOSTS = [
935
+ "claude.ai",
936
+ "claude.com",
937
+ "chatgpt.com",
938
+ "cursor.com",
939
+ "www.cursor.com",
940
+ "vscode.dev",
941
+ "insiders.vscode.dev"
942
+ ];
943
+ const LOOPBACK$1 = /* @__PURE__ */ new Set([
944
+ "localhost",
945
+ "127.0.0.1",
946
+ "[::1]"
947
+ ]);
948
+ const DCR_TTL_SECONDS = 31536e4;
949
+ var OAuthError = class extends Error {
950
+ error;
951
+ status;
952
+ constructor(error, message, status = 400) {
953
+ super(message);
954
+ this.error = error;
955
+ this.status = status;
956
+ }
957
+ };
958
+ /**
959
+ * Loopback http redirects (desktop and CLI clients) are always allowed; https redirects must go to
960
+ * a known client host or one listed in GSCLAW_OAUTH_REDIRECT_HOSTS ("*" allows any https host).
961
+ */
962
+ function redirectUriAllowed(uri, extraHosts) {
963
+ let url;
964
+ try {
965
+ url = new URL(uri);
966
+ } catch {
967
+ return false;
968
+ }
969
+ if (url.hash || url.username || url.password) return false;
970
+ if (url.protocol === "http:") return LOOPBACK$1.has(url.hostname);
971
+ if (url.protocol !== "https:") return false;
972
+ if (extraHosts.includes("*")) return true;
973
+ return [...DEFAULT_REDIRECT_HOSTS, ...extraHosts].includes(url.hostname.toLowerCase());
974
+ }
975
+ /** Loopback redirect URIs match regardless of port (RFC 8252 §7.3). */
976
+ function redirectUriMatches(registered, requested) {
977
+ if (registered === requested) return true;
978
+ try {
979
+ const a = new URL(registered);
980
+ const b = new URL(requested);
981
+ return a.protocol === "http:" && LOOPBACK$1.has(a.hostname) && a.hostname === b.hostname && a.protocol === b.protocol && a.pathname === b.pathname && a.search === b.search;
982
+ } catch {
983
+ return false;
984
+ }
985
+ }
986
+ /** RFC 7591 registration. The returned client_id *is* the registration. */
987
+ async function registerClient(body, sealer, extraHosts) {
988
+ const meta = body && typeof body === "object" ? body : {};
989
+ const requested = meta.redirect_uris;
990
+ if (!Array.isArray(requested) || requested.length === 0 || requested.length > 10) throw new OAuthError("invalid_redirect_uri", "redirect_uris must be a non-empty array.");
991
+ const redirectUris = [];
992
+ for (const uri of requested) {
993
+ if (typeof uri !== "string" || !redirectUriAllowed(uri, extraHosts)) throw new OAuthError("invalid_redirect_uri", `Redirect URI not allowed: ${String(uri).slice(0, 200)}. Use a loopback URI or a known client; the deployment owner can allow more hosts with GSCLAW_OAUTH_REDIRECT_HOSTS.`);
994
+ redirectUris.push(uri);
995
+ }
996
+ if ((meta.token_endpoint_auth_method ?? "none") !== "none") throw new OAuthError("invalid_client_metadata", "Only public clients are supported (token_endpoint_auth_method \"none\").");
997
+ const name = typeof meta.client_name === "string" && meta.client_name.trim() ? meta.client_name.trim().slice(0, 100) : "MCP client";
998
+ return {
999
+ client_id: await sealer.seal("client", {
1000
+ n: name,
1001
+ r: redirectUris
1002
+ }, DCR_TTL_SECONDS),
1003
+ client_id_issued_at: Math.floor(Date.now() / 1e3),
1004
+ client_name: name,
1005
+ redirect_uris: redirectUris,
1006
+ grant_types: ["authorization_code", "refresh_token"],
1007
+ response_types: ["code"],
1008
+ token_endpoint_auth_method: "none"
1009
+ };
1010
+ }
1011
+ const cimdCache = /* @__PURE__ */ new Map();
1012
+ /** Refuses obviously internal targets. Workers cannot resolve DNS, so hostnames are allowed. */
1013
+ function publicHttpsUrl(raw) {
1014
+ let url;
1015
+ try {
1016
+ url = new URL(raw);
1017
+ } catch {
1018
+ return null;
1019
+ }
1020
+ if (url.protocol !== "https:" || url.username || url.password || url.hash) return null;
1021
+ const host = url.hostname.toLowerCase();
1022
+ if (LOOPBACK$1.has(host) || host.endsWith(".localhost") || host.endsWith(".internal") || host.endsWith(".local")) return null;
1023
+ if (/^\[/.test(host)) return null;
1024
+ const ipv4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host);
1025
+ if (ipv4) {
1026
+ const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
1027
+ if (a === 10 || a === 127 || a === 0 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168) return null;
1028
+ }
1029
+ return url;
1030
+ }
1031
+ /** Fetches and validates a Client ID Metadata Document, cached for 10 minutes per instance. */
1032
+ async function resolveCimd(clientId, fetchImpl, extraHosts) {
1033
+ const cached = cimdCache.get(clientId);
1034
+ if (cached && cached.expiresAt > Date.now()) return cached.client;
1035
+ const url = publicHttpsUrl(clientId);
1036
+ if (!url) throw new OAuthError("invalid_client", "client_id URL must be a public https URL.");
1037
+ let doc;
1038
+ try {
1039
+ const res = await fetchImpl(url.href, {
1040
+ headers: { accept: "application/json" },
1041
+ redirect: "error",
1042
+ signal: AbortSignal.timeout(5e3)
1043
+ });
1044
+ const text = await res.text();
1045
+ if (!res.ok || text.length > 65536) throw new Error(`HTTP ${res.status}`);
1046
+ doc = JSON.parse(text);
1047
+ } catch {
1048
+ throw new OAuthError("invalid_client", "Could not fetch the client metadata document.");
1049
+ }
1050
+ if (doc.client_id !== clientId) throw new OAuthError("invalid_client", "Client metadata client_id does not match its URL.");
1051
+ const redirectUris = Array.isArray(doc.redirect_uris) ? doc.redirect_uris.filter((u) => typeof u === "string") : [];
1052
+ if (redirectUris.length === 0) throw new OAuthError("invalid_client", "Client metadata has no redirect_uris.");
1053
+ if (typeof doc.client_name !== "string" || !doc.client_name.trim()) throw new OAuthError("invalid_client", "Client metadata has no client_name.");
1054
+ const client = {
1055
+ clientId,
1056
+ name: doc.client_name.trim().slice(0, 100),
1057
+ redirectUris: redirectUris.filter((u) => redirectUriAllowed(u, extraHosts)),
1058
+ type: "cimd"
1059
+ };
1060
+ cimdCache.set(clientId, {
1061
+ client,
1062
+ expiresAt: Date.now() + 6e5
1063
+ });
1064
+ return client;
1065
+ }
1066
+ /** Looks up a client by ID: a sealed DCR registration or a CIMD URL. */
1067
+ async function resolveClient(clientId, deps) {
1068
+ if (clientId.startsWith("https://")) return resolveCimd(clientId, deps.fetch, deps.extraHosts);
1069
+ const sealed = await deps.sealer.open("client", clientId);
1070
+ if (!sealed) throw new OAuthError("invalid_client", "Unknown client. Re-register the client (remove and re-add the connector).", 401);
1071
+ return {
1072
+ clientId,
1073
+ name: sealed.n,
1074
+ redirectUris: sealed.r.filter((u) => redirectUriAllowed(u, deps.extraHosts)),
1075
+ type: "dcr"
1076
+ };
1077
+ }
1078
+ //#endregion
1079
+ //#region src/core/oauth/google.ts
1080
+ const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
1081
+ const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
1082
+ function googleAuthUrl(cfg, opts) {
1083
+ const params = new URLSearchParams({
1084
+ client_id: cfg.clientId,
1085
+ redirect_uri: opts.redirectUri,
1086
+ response_type: "code",
1087
+ scope: [
1088
+ "openid",
1089
+ "email",
1090
+ ...opts.scopes
1091
+ ].join(" "),
1092
+ access_type: "offline",
1093
+ prompt: "consent select_account",
1094
+ include_granted_scopes: "false",
1095
+ state: opts.state,
1096
+ code_challenge: opts.codeChallenge,
1097
+ code_challenge_method: "S256"
1098
+ });
1099
+ if (opts.loginHint) params.set("login_hint", opts.loginHint);
1100
+ return `${GOOGLE_AUTH_URL}?${params.toString()}`;
1101
+ }
1102
+ async function tokenRequest(fetchImpl, body, nowSeconds) {
1103
+ const res = await fetchImpl(GOOGLE_TOKEN_URL, {
1104
+ method: "POST",
1105
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1106
+ body: new URLSearchParams(body).toString()
1107
+ });
1108
+ const data = await res.json().catch(() => ({}));
1109
+ if (!res.ok || !data.access_token) throw new GscApiError({
1110
+ kind: "auth",
1111
+ status: res.status,
1112
+ reason: data.error,
1113
+ googleMessage: [data.error, data.error_description].filter(Boolean).join(": "),
1114
+ advice: data.error === "invalid_grant" ? "Your Google authorization has expired or was revoked. Sign in to GSClaw again." : "Google rejected the sign-in. Check GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET and the authorized redirect URI."
1115
+ });
1116
+ return {
1117
+ accessToken: data.access_token,
1118
+ expiresAt: nowSeconds + (data.expires_in ?? 3600),
1119
+ refreshToken: data.refresh_token,
1120
+ scope: (data.scope ?? "").split(" ").filter(Boolean),
1121
+ idToken: data.id_token
1122
+ };
1123
+ }
1124
+ function exchangeGoogleCode(cfg, opts) {
1125
+ return tokenRequest(opts.fetch, {
1126
+ grant_type: "authorization_code",
1127
+ code: opts.code,
1128
+ redirect_uri: opts.redirectUri,
1129
+ client_id: cfg.clientId,
1130
+ client_secret: cfg.clientSecret,
1131
+ code_verifier: opts.codeVerifier
1132
+ }, opts.nowSeconds);
1133
+ }
1134
+ function refreshGoogleToken(cfg, opts) {
1135
+ return tokenRequest(opts.fetch, {
1136
+ grant_type: "refresh_token",
1137
+ refresh_token: opts.refreshToken,
1138
+ client_id: cfg.clientId,
1139
+ client_secret: cfg.clientSecret
1140
+ }, opts.nowSeconds);
1141
+ }
1142
+ /**
1143
+ * Reads the identity from Google's ID token. The token was received directly from Google's token
1144
+ * endpoint over TLS (not via the browser), so OpenID Connect allows skipping signature validation;
1145
+ * we still check issuer, audience and expiry.
1146
+ */
1147
+ function identityFromIdToken(idToken, cfg, nowSeconds) {
1148
+ if (!idToken) return null;
1149
+ try {
1150
+ const payload = JSON.parse(new TextDecoder().decode(base64ToBytes(idToken.split(".")[1] ?? "")));
1151
+ if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") return null;
1152
+ if (payload.aud !== cfg.clientId) return null;
1153
+ if (!payload.exp || payload.exp < nowSeconds - 300) return null;
1154
+ if (!payload.sub || !payload.email) return null;
1155
+ return {
1156
+ sub: payload.sub,
1157
+ email: payload.email.toLowerCase(),
1158
+ emailVerified: payload.email_verified === true
1159
+ };
1160
+ } catch {
1161
+ return null;
1162
+ }
1163
+ }
1164
+ /** True when the allowlists permit this identity (no lists configured = everyone). */
1165
+ function isAllowed(identity, cfg) {
1166
+ if (cfg.allowedEmails.length === 0 && cfg.allowedDomains.length === 0) return true;
1167
+ if (!identity.emailVerified) return false;
1168
+ if (cfg.allowedEmails.includes(identity.email)) return true;
1169
+ const domain = identity.email.split("@")[1] ?? "";
1170
+ return cfg.allowedDomains.includes(domain);
1171
+ }
1172
+ //#endregion
1173
+ //#region src/core/oauth/pages.ts
1174
+ function hostOf(uri) {
1175
+ try {
1176
+ const url = new URL(uri);
1177
+ return url.protocol === "http:" ? `${url.hostname} (an app on this computer)` : url.host;
1178
+ } catch {
1179
+ return uri;
1180
+ }
1181
+ }
1182
+ /**
1183
+ * HTML headers for consent pages. Browsers apply `form-action` to the redirect that follows a form
1184
+ * submission, so the client's redirect origin must be allowed alongside 'self'.
1185
+ */
1186
+ function consentHeaders(redirectUri, extraOrigins = []) {
1187
+ const origins = [...extraOrigins];
1188
+ try {
1189
+ origins.push(new URL(redirectUri).origin);
1190
+ } catch {}
1191
+ return {
1192
+ ...HTML_SECURITY_HEADERS,
1193
+ "content-security-policy": HTML_SECURITY_HEADERS["content-security-policy"].replace("form-action 'self'", ["form-action 'self'", ...origins].join(" "))
1194
+ };
1195
+ }
1196
+ function clientBlock(info) {
1197
+ return `<div class="client"><strong>${escape(info.clientName)}</strong>
1198
+ <small>will receive access at ${escape(hostOf(info.redirectUri))}</small></div>`;
1199
+ }
1200
+ function access(info) {
1201
+ return info.canWrite ? "read your Search Console data and submit or remove sitemaps" : "read your Search Console data (read-only)";
1202
+ }
1203
+ /** Service-account mode: the owner confirms with the deployment's access token. */
1204
+ function ownerConsentPage(info) {
1205
+ return layout("Connect to GSClaw", `<h1>Allow access to GSClaw?</h1>
1206
+ ${clientBlock(info)}
1207
+ <p>This app wants to ${access(info)} through this GSClaw deployment. Only continue if you started this connection.</p>
1208
+ <form method="post" action="/oauth/authorize">
1209
+ <input type="hidden" name="request" value="${escape(info.request)}">
1210
+ <label for="token">Access token</label>
1211
+ <input id="token" name="token" type="password" autocomplete="current-password" required autofocus>
1212
+ ${info.error ? `<p class="error" role="alert">${escape(info.error)}</p>` : ""}
1213
+ <div class="actions">
1214
+ <button class="primary" type="submit" name="decision" value="allow">Allow</button>
1215
+ <button type="submit" name="decision" value="deny" formnovalidate>Deny</button>
1216
+ </div>
1217
+ </form>`);
1218
+ }
1219
+ /** OAuth mode: confirm the client, then continue to Google sign-in. */
1220
+ function googleConsentPage(info) {
1221
+ return layout("Connect to GSClaw", `<h1>Allow access to GSClaw?</h1>
1222
+ ${clientBlock(info)}
1223
+ <p>This app wants to ${access(info)} through this GSClaw deployment. Sign in with the Google account that has access to your Search Console properties.</p>
1224
+ <form method="post" action="/oauth/authorize">
1225
+ <input type="hidden" name="request" value="${escape(info.request)}">
1226
+ <div class="actions">
1227
+ <button class="primary" type="submit" name="decision" value="allow">Continue with Google</button>
1228
+ <button type="submit" name="decision" value="deny">Deny</button>
1229
+ </div>
1230
+ </form>`);
1231
+ }
1232
+ function oauthErrorPage(title, message) {
1233
+ return layout("GSClaw sign-in", `<h1>${escape(title)}</h1><p>${escape(message)}</p>`);
1234
+ }
1235
+ //#endregion
1236
+ //#region src/core/oauth/server.ts
1237
+ const REQUEST_TTL = 900;
1238
+ const CODE_TTL = 60;
1239
+ const ACCESS_TTL = 3600;
1240
+ const REFRESH_TTL = 7776e3;
1241
+ const SESSION_TTL = 604800;
1242
+ const CORS = {
1243
+ "access-control-allow-origin": "*",
1244
+ "access-control-allow-methods": "GET, POST, OPTIONS",
1245
+ "access-control-allow-headers": "authorization, content-type, mcp-protocol-version"
1246
+ };
1247
+ function json$2(body, status = 200, headers = {}) {
1248
+ return new Response(JSON.stringify(body), {
1249
+ status,
1250
+ headers: {
1251
+ "content-type": "application/json",
1252
+ "cache-control": "no-store",
1253
+ pragma: "no-cache",
1254
+ ...CORS,
1255
+ ...headers
1256
+ }
1257
+ });
1258
+ }
1259
+ function html$1(body, status = 200, headers = HTML_SECURITY_HEADERS) {
1260
+ return new Response(body, {
1261
+ status,
1262
+ headers
1263
+ });
1264
+ }
1265
+ function redirect(location, headers = {}) {
1266
+ return new Response(null, {
1267
+ status: 302,
1268
+ headers: {
1269
+ location,
1270
+ "cache-control": "no-store",
1271
+ ...headers
1272
+ }
1273
+ });
1274
+ }
1275
+ function withParams(uri, params) {
1276
+ const url = new URL(uri);
1277
+ for (const [k, v] of Object.entries(params)) if (v !== void 0) url.searchParams.set(k, v);
1278
+ return url.href;
1279
+ }
1280
+ async function readForm(request) {
1281
+ const type = request.headers.get("content-type") ?? "";
1282
+ const text = await request.text();
1283
+ if (type.includes("application/json")) try {
1284
+ const body = JSON.parse(text);
1285
+ return Object.fromEntries(Object.entries(body).map(([k, v]) => [k, typeof v === "string" ? v : String(v)]));
1286
+ } catch {
1287
+ return {};
1288
+ }
1289
+ return Object.fromEntries(new URLSearchParams(text));
1290
+ }
1291
+ function toPrincipal(p, extra) {
1292
+ return {
1293
+ kind: p.k,
1294
+ sub: p.sub,
1295
+ email: p.email,
1296
+ ...extra,
1297
+ google: p.g ? {
1298
+ accessToken: p.g.at,
1299
+ expiresAt: p.g.ex,
1300
+ refreshToken: p.g.rt,
1301
+ scope: p.g.sc
1302
+ } : void 0
1303
+ };
1304
+ }
1305
+ function createOAuthServer(deps) {
1306
+ const { config, sealer, logger } = deps;
1307
+ const nowSeconds = () => Math.floor(deps.now().getTime() / 1e3);
1308
+ const consentLimiter = new RateLimiter(10);
1309
+ const scopesSupported = config.allowWrites ? [SCOPE_READ, SCOPE_WRITE] : [SCOPE_READ];
1310
+ const google = config.googleOAuth;
1311
+ const clientDeps = {
1312
+ sealer,
1313
+ fetch: deps.fetch,
1314
+ extraHosts: config.oauthRedirectHosts
1315
+ };
1316
+ const resourceUrl = (base) => `${base}/mcp`;
1317
+ const metadataUrl = (base) => `${base}/.well-known/oauth-protected-resource/mcp`;
1318
+ function grantedScopes(requested) {
1319
+ const wanted = (requested ?? "").split(/\s+/).filter((s) => scopesSupported.includes(s));
1320
+ const scopes = new Set(wanted.length > 0 ? wanted : [SCOPE_READ]);
1321
+ scopes.add(SCOPE_READ);
1322
+ return [...scopes];
1323
+ }
1324
+ const googleScopeFor = (scopes) => scopes.includes("gsc:write") ? SCOPE_READWRITE : SCOPE_READONLY;
1325
+ function protectedResourceMetadata(base) {
1326
+ return {
1327
+ resource: resourceUrl(base),
1328
+ authorization_servers: [base],
1329
+ scopes_supported: scopesSupported,
1330
+ bearer_methods_supported: ["header"],
1331
+ resource_name: "GSClaw",
1332
+ resource_documentation: "https://github.com/tuyakhov/gsclaw#readme"
1333
+ };
1334
+ }
1335
+ function authorizationServerMetadata(base) {
1336
+ return {
1337
+ issuer: base,
1338
+ authorization_endpoint: `${base}/oauth/authorize`,
1339
+ token_endpoint: `${base}/oauth/token`,
1340
+ registration_endpoint: `${base}/oauth/register`,
1341
+ scopes_supported: scopesSupported,
1342
+ response_types_supported: ["code"],
1343
+ response_modes_supported: ["query"],
1344
+ grant_types_supported: ["authorization_code", "refresh_token"],
1345
+ token_endpoint_auth_methods_supported: ["none"],
1346
+ code_challenge_methods_supported: ["S256"],
1347
+ client_id_metadata_document_supported: true,
1348
+ authorization_response_iss_parameter_supported: true,
1349
+ service_documentation: "https://github.com/tuyakhov/gsclaw#readme"
1350
+ };
1351
+ }
1352
+ /**
1353
+ * The allowlist applies to every request, not just sign-in, so removing someone from
1354
+ * ALLOWED_GOOGLE_EMAILS/DOMAINS (and redeploying) cuts off their existing tokens and sessions too.
1355
+ */
1356
+ const stillAllowed = (p) => p.k !== "user" || !google || isAllowed({
1357
+ sub: p.sub,
1358
+ email: p.email ?? "",
1359
+ emailVerified: p.ev === true
1360
+ }, google);
1361
+ async function issueCode(req, p, base) {
1362
+ const code = await sealer.seal("code", {
1363
+ cid: req.cid,
1364
+ cn: req.cn,
1365
+ ru: req.ru,
1366
+ cc: req.cc,
1367
+ sc: req.sc,
1368
+ res: req.res,
1369
+ p
1370
+ }, CODE_TTL);
1371
+ logger.info("oauth code issued", {
1372
+ client: req.cn,
1373
+ kind: p.k
1374
+ });
1375
+ return redirect(withParams(req.ru, {
1376
+ code,
1377
+ state: req.st,
1378
+ iss: base
1379
+ }));
1380
+ }
1381
+ const clientError = (req, error, description, base) => redirect(withParams(req.ru, {
1382
+ error,
1383
+ error_description: description,
1384
+ state: req.st,
1385
+ iss: base
1386
+ }));
1387
+ async function startGoogle(target, scopes, base) {
1388
+ const state = {
1389
+ ...target,
1390
+ v: randomToken(32),
1391
+ b: randomToken(24)
1392
+ };
1393
+ const challenge = base64UrlEncode(await sha256(state.v));
1394
+ const sealedState = await sealer.seal("state", state, REQUEST_TTL);
1395
+ const secure = new URL(base).protocol === "https:";
1396
+ return redirect(googleAuthUrl(google, {
1397
+ redirectUri: `${base}/oauth/google/callback`,
1398
+ scopes: [googleScopeFor(scopes)],
1399
+ state: sealedState,
1400
+ codeChallenge: challenge
1401
+ }), { "set-cookie": stateCookie(state.b, {
1402
+ secure,
1403
+ maxAge: REQUEST_TTL
1404
+ }) });
1405
+ }
1406
+ /** GET /oauth/authorize: validate, then show the consent page. */
1407
+ async function authorize(url, base) {
1408
+ const q = url.searchParams;
1409
+ const clientId = q.get("client_id");
1410
+ if (!clientId) return html$1(oauthErrorPage("Invalid request", "client_id is missing."), 400);
1411
+ let client;
1412
+ try {
1413
+ client = await resolveClient(clientId, clientDeps);
1414
+ } catch (error) {
1415
+ return html$1(oauthErrorPage("Unknown app", error instanceof Error ? error.message : "Unknown client."), 400);
1416
+ }
1417
+ const requested = q.get("redirect_uri") ?? (client.redirectUris.length === 1 ? client.redirectUris[0] : "");
1418
+ if (!client.redirectUris.some((r) => redirectUriMatches(r, requested))) return html$1(oauthErrorPage("Invalid redirect", "The redirect_uri is not registered for this app."), 400);
1419
+ const req = {
1420
+ cid: clientId,
1421
+ cn: client.name,
1422
+ ru: requested,
1423
+ cc: q.get("code_challenge") ?? "",
1424
+ st: q.get("state") ?? void 0,
1425
+ sc: grantedScopes(q.get("scope")),
1426
+ res: resourceUrl(base)
1427
+ };
1428
+ if (q.get("response_type") !== "code") return clientError(req, "unsupported_response_type", "Only response_type=code is supported.", base);
1429
+ if (!req.cc || q.get("code_challenge_method") !== "S256") return clientError(req, "invalid_request", "PKCE with code_challenge_method=S256 is required.", base);
1430
+ const resource = q.get("resource");
1431
+ if (resource && resource.replace(/\/$/, "") !== req.res) return clientError(req, "invalid_target", `This server's resource is ${req.res}.`, base);
1432
+ const sealedReq = await sealer.seal("request", req, REQUEST_TTL);
1433
+ const info = {
1434
+ clientName: client.name,
1435
+ redirectUri: req.ru,
1436
+ request: sealedReq,
1437
+ canWrite: req.sc.includes(SCOPE_WRITE)
1438
+ };
1439
+ return config.authMode === "oauth" ? html$1(googleConsentPage(info), 200, consentHeaders(req.ru, ["https://accounts.google.com"])) : html$1(ownerConsentPage(info), 200, consentHeaders(req.ru));
1440
+ }
1441
+ /** POST /oauth/authorize: the consent decision. */
1442
+ async function decide(request, base) {
1443
+ const form = await readForm(request);
1444
+ const req = await sealer.open("request", form.request);
1445
+ if (!req) return html$1(oauthErrorPage("Sign-in expired", "This sign-in page expired. Start the connection again from your app."), 400);
1446
+ if (form.decision !== "allow") return clientError(req, "access_denied", "The request was denied.", base);
1447
+ if (config.authMode === "oauth") return startGoogle({ req }, req.sc, base);
1448
+ const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "local";
1449
+ const info = {
1450
+ clientName: req.cn,
1451
+ redirectUri: req.ru,
1452
+ request: form.request,
1453
+ canWrite: req.sc.includes(SCOPE_WRITE)
1454
+ };
1455
+ if (!consentLimiter.check(ip).ok) return html$1(ownerConsentPage({
1456
+ ...info,
1457
+ error: "Too many attempts. Wait a minute and try again."
1458
+ }), 429, consentHeaders(req.ru));
1459
+ if (!form.token || !await timingSafeEqual(form.token.trim(), config.accessToken)) {
1460
+ logger.warn("oauth owner consent: wrong access token");
1461
+ return html$1(ownerConsentPage({
1462
+ ...info,
1463
+ error: "That access token is not correct."
1464
+ }), 401, consentHeaders(req.ru));
1465
+ }
1466
+ return issueCode(req, {
1467
+ k: "owner",
1468
+ sub: "owner"
1469
+ }, base);
1470
+ }
1471
+ /** GET /oauth/google/callback (OAuth mode): finish Google sign-in for an MCP client or the dashboard. */
1472
+ async function googleCallback(request, url, base) {
1473
+ const state = await sealer.open("state", url.searchParams.get("state"));
1474
+ const bound = parseCookies(request.headers.get("cookie"))[STATE_COOKIE];
1475
+ if (!state || !google || !bound || !await timingSafeEqual(bound, state.b)) return html$1(oauthErrorPage("Sign-in expired", "This sign-in expired or was started in a different browser. Please start again."), 400);
1476
+ const fail = (error, description, dashCode) => state.req ? clientError(state.req, error, description, base) : redirect(`/?login_error=${dashCode}`);
1477
+ if (url.searchParams.get("error") || !url.searchParams.get("code")) return fail("access_denied", "Google sign-in was cancelled.", "cancelled");
1478
+ let tokens;
1479
+ try {
1480
+ tokens = await exchangeGoogleCode(google, {
1481
+ code: url.searchParams.get("code"),
1482
+ redirectUri: `${base}/oauth/google/callback`,
1483
+ codeVerifier: state.v,
1484
+ fetch: deps.fetch,
1485
+ nowSeconds: nowSeconds()
1486
+ });
1487
+ } catch (error) {
1488
+ logger.warn("google code exchange failed", { error });
1489
+ return fail("server_error", "Google sign-in failed.", "google_error");
1490
+ }
1491
+ const identity = identityFromIdToken(tokens.idToken, google, nowSeconds());
1492
+ if (!identity) return fail("server_error", "Google did not return a verified identity.", "google_error");
1493
+ if (!isAllowed(identity, google)) {
1494
+ logger.warn("google account not allowed", { domain: identity.email.split("@")[1] });
1495
+ return fail("access_denied", `${identity.email} is not allowed to use this GSClaw deployment.`, "not_allowed");
1496
+ }
1497
+ const needed = state.req ? googleScopeFor(state.req.sc) : SCOPE_READONLY;
1498
+ if (!(tokens.scope.includes(needed) || needed === "https://www.googleapis.com/auth/webmasters.readonly" && tokens.scope.includes("https://www.googleapis.com/auth/webmasters"))) return fail("access_denied", "Search Console access was not granted. Try again and keep the Search Console permission ticked.", "scope");
1499
+ if (!tokens.refreshToken) return fail("server_error", "Google did not return a refresh token. Remove GSClaw from your Google account’s third-party access and try again.", "google_error");
1500
+ const p = {
1501
+ k: "user",
1502
+ sub: identity.sub,
1503
+ email: identity.email,
1504
+ ev: identity.emailVerified,
1505
+ g: {
1506
+ at: tokens.accessToken,
1507
+ ex: tokens.expiresAt,
1508
+ rt: tokens.refreshToken,
1509
+ sc: tokens.scope
1510
+ }
1511
+ };
1512
+ if (state.dash) return redirect("/", { "set-cookie": sessionCookie(await sealer.seal("session", { p }, SESSION_TTL), {
1513
+ secure: new URL(base).protocol === "https:",
1514
+ maxAge: SESSION_TTL
1515
+ }) });
1516
+ return issueCode(state.req, p, base);
1517
+ }
1518
+ async function issueTokens(t, p) {
1519
+ let ttl = ACCESS_TTL;
1520
+ if (p.g?.ex) ttl = Math.max(300, Math.min(ACCESS_TTL, p.g.ex - nowSeconds() - 120));
1521
+ const access = await sealer.seal("access", {
1522
+ ...t,
1523
+ p
1524
+ }, ttl);
1525
+ const refreshPrincipal = p.g ? {
1526
+ ...p,
1527
+ g: {
1528
+ rt: p.g.rt,
1529
+ sc: p.g.sc
1530
+ }
1531
+ } : p;
1532
+ const refresh = await sealer.seal("refresh", {
1533
+ ...t,
1534
+ p: refreshPrincipal
1535
+ }, REFRESH_TTL);
1536
+ return json$2({
1537
+ access_token: access,
1538
+ token_type: "Bearer",
1539
+ expires_in: ttl,
1540
+ refresh_token: refresh,
1541
+ scope: t.sc.join(" ")
1542
+ });
1543
+ }
1544
+ /** POST /oauth/token */
1545
+ async function token(request) {
1546
+ const form = await readForm(request);
1547
+ const basic = request.headers.get("authorization");
1548
+ let clientId = form.client_id;
1549
+ if (!clientId && basic?.startsWith("Basic ")) try {
1550
+ clientId = decodeURIComponent(atob(basic.slice(6)).split(":")[0] ?? "");
1551
+ } catch {
1552
+ clientId = void 0;
1553
+ }
1554
+ const err = (error, description, status = 400) => json$2({
1555
+ error,
1556
+ error_description: description
1557
+ }, status);
1558
+ if (form.grant_type === "authorization_code") {
1559
+ const code = await sealer.open("code", form.code);
1560
+ if (!code) return err("invalid_grant", "The authorization code is invalid or expired.");
1561
+ if (code.cid !== clientId) return err("invalid_grant", "The code was issued to another client.");
1562
+ if (!form.redirect_uri || !redirectUriMatches(code.ru, form.redirect_uri)) return err("invalid_grant", "redirect_uri does not match.");
1563
+ if (!form.code_verifier || base64UrlEncode(await sha256(form.code_verifier)) !== code.cc) return err("invalid_grant", "PKCE verification failed.");
1564
+ if (form.resource && form.resource.replace(/\/$/, "") !== code.res) return err("invalid_target", "Unknown resource.");
1565
+ return issueTokens({
1566
+ cid: code.cid,
1567
+ cn: code.cn,
1568
+ sc: code.sc,
1569
+ aud: code.res
1570
+ }, code.p);
1571
+ }
1572
+ if (form.grant_type === "refresh_token") {
1573
+ const rt = await sealer.open("refresh", form.refresh_token);
1574
+ if (!rt) return err("invalid_grant", "The refresh token is invalid or expired.");
1575
+ if (clientId && clientId !== rt.cid) return err("invalid_grant", "The refresh token was issued to another client.");
1576
+ if (!stillAllowed(rt.p)) return err("invalid_grant", "This Google account is no longer allowed to use GSClaw.");
1577
+ let scopes = rt.sc;
1578
+ if (form.scope) {
1579
+ const narrowed = form.scope.split(/\s+/).filter((s) => rt.sc.includes(s));
1580
+ if (narrowed.length === 0) return err("invalid_scope", "Requested scope exceeds the original grant.");
1581
+ scopes = narrowed;
1582
+ }
1583
+ let p = rt.p;
1584
+ if (p.g && google) try {
1585
+ const fresh = await refreshGoogleToken(google, {
1586
+ refreshToken: p.g.rt,
1587
+ fetch: deps.fetch,
1588
+ nowSeconds: nowSeconds()
1589
+ });
1590
+ p = {
1591
+ ...p,
1592
+ g: {
1593
+ at: fresh.accessToken,
1594
+ ex: fresh.expiresAt,
1595
+ rt: fresh.refreshToken ?? p.g.rt,
1596
+ sc: p.g.sc
1597
+ }
1598
+ };
1599
+ } catch (error) {
1600
+ if (error instanceof GscApiError && error.reason === "invalid_grant") return err("invalid_grant", "Google access was revoked or expired. Sign in again.");
1601
+ return err("temporarily_unavailable", "Could not refresh Google credentials. Try again.", 503);
1602
+ }
1603
+ return issueTokens({
1604
+ cid: rt.cid,
1605
+ cn: rt.cn,
1606
+ sc: scopes,
1607
+ aud: rt.aud
1608
+ }, p);
1609
+ }
1610
+ return err("unsupported_grant_type", "Use authorization_code or refresh_token.");
1611
+ }
1612
+ return {
1613
+ resourceUrl,
1614
+ /** WWW-Authenticate value for /mcp 401s (RFC 9728 discovery). */
1615
+ challenge(base, error) {
1616
+ const parts = [`resource_metadata="${metadataUrl(base)}"`, `scope="${scopesSupported.join(" ")}"`];
1617
+ if (error) parts.push(`error="${error}"`);
1618
+ return `Bearer ${parts.join(", ")}`;
1619
+ },
1620
+ /** Validates a GSClaw-issued access token for this resource. */
1621
+ async verifyAccessToken(token, base) {
1622
+ const t = await sealer.open("access", token);
1623
+ if (!t || t.aud !== resourceUrl(base)) return null;
1624
+ if (config.authMode === "oauth" !== (t.p.k === "user") || !stillAllowed(t.p)) return null;
1625
+ return toPrincipal(t.p, {
1626
+ scopes: t.sc,
1627
+ via: "oauth",
1628
+ clientId: t.cid,
1629
+ clientName: t.cn
1630
+ });
1631
+ },
1632
+ /** Reads a dashboard session cookie value. */
1633
+ async openSession(value) {
1634
+ const s = await sealer.open("session", value);
1635
+ if (!s) return null;
1636
+ if (config.authMode === "oauth" !== (s.p.k === "user") || !stillAllowed(s.p)) return null;
1637
+ return toPrincipal(s.p, {
1638
+ scopes: [SCOPE_READ],
1639
+ via: "session"
1640
+ });
1641
+ },
1642
+ async ownerSession() {
1643
+ return sealer.seal("session", { p: {
1644
+ k: "owner",
1645
+ sub: "owner"
1646
+ } }, SESSION_TTL);
1647
+ },
1648
+ /** Routes /.well-known/* and /oauth/*; returns null for anything else. */
1649
+ async handle(request, url, path, base) {
1650
+ const method = request.method;
1651
+ if (!path.startsWith("/.well-known/") && !path.startsWith("/oauth/")) return null;
1652
+ if (method === "OPTIONS") return new Response(null, {
1653
+ status: 204,
1654
+ headers: CORS
1655
+ });
1656
+ try {
1657
+ if (path === "/.well-known/oauth-protected-resource" || path === "/.well-known/oauth-protected-resource/mcp") return json$2(protectedResourceMetadata(base));
1658
+ if (path === "/.well-known/oauth-authorization-server" || path === "/.well-known/oauth-authorization-server/mcp") return json$2(authorizationServerMetadata(base));
1659
+ if (path === "/oauth/register" && method === "POST") return json$2(await registerClient(await request.json().catch(() => null), sealer, config.oauthRedirectHosts), 201);
1660
+ if (path === "/oauth/authorize" && method === "GET") return authorize(url, base);
1661
+ if (path === "/oauth/authorize" && method === "POST") return decide(request, base);
1662
+ if (path === "/oauth/token" && method === "POST") return token(request);
1663
+ if (path === "/oauth/google/callback" && method === "GET") return googleCallback(request, url, base);
1664
+ if (path === "/oauth/google/start" && method === "GET" && google) return startGoogle({ dash: true }, [SCOPE_READ], base);
1665
+ } catch (error) {
1666
+ if (error instanceof OAuthError) return json$2({
1667
+ error: error.error,
1668
+ error_description: error.message
1669
+ }, error.status);
1670
+ throw error;
1671
+ }
1672
+ return json$2({ error: "not_found" }, 404);
1673
+ }
1674
+ };
1675
+ }
1676
+ //#endregion
1677
+ //#region src/core/update-check.ts
1678
+ const RELEASES_URL = "https://api.github.com/repos/tuyakhov/gsclaw/releases/latest";
1679
+ const CACHE_MS = 216e5;
1680
+ let cached;
1681
+ /** Compares dotted numeric versions; pre-release suffixes are ignored. */
1682
+ function isNewer(candidate, current) {
1683
+ const parse = (v) => v.replace(/^v/, "").split("-")[0].split(".").map((n) => Number(n) || 0);
1684
+ const a = parse(candidate);
1685
+ const b = parse(current);
1686
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
1687
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
1688
+ if (diff !== 0) return diff > 0;
1689
+ }
1690
+ return false;
1691
+ }
1692
+ /**
1693
+ * Latest published GSClaw release (cached for 6 hours per instance). Any failure (offline,
1694
+ * rate-limited, no releases yet) yields null so the dashboard simply shows no notice.
1695
+ */
1696
+ async function latestRelease(fetchImpl = (input, init) => fetch(input, init), now = Date.now()) {
1697
+ if (cached && now - cached.at < CACHE_MS) return cached.latest;
1698
+ let latest = null;
1699
+ try {
1700
+ const res = await fetchImpl(RELEASES_URL, {
1701
+ headers: {
1702
+ accept: "application/vnd.github+json",
1703
+ "user-agent": `gsclaw/${VERSION}`
1704
+ },
1705
+ signal: AbortSignal.timeout(3e3)
1706
+ });
1707
+ if (res.ok) {
1708
+ const body = await res.json();
1709
+ if (typeof body.tag_name === "string") latest = body.tag_name.replace(/^v/, "");
1710
+ }
1711
+ } catch {
1712
+ latest = null;
1713
+ }
1714
+ cached = {
1715
+ at: now,
1716
+ latest
1717
+ };
1718
+ return latest;
1719
+ }
1720
+ //#endregion
1721
+ //#region src/core/dashboard-api.ts
1722
+ function json$1(body, status = 200, headers = {}) {
1723
+ return new Response(JSON.stringify(body), {
1724
+ status,
1725
+ headers: {
1726
+ "content-type": "application/json",
1727
+ "cache-control": "no-store",
1728
+ ...headers
1729
+ }
1730
+ });
1731
+ }
1732
+ const LOOPBACK = /* @__PURE__ */ new Set([
1733
+ "localhost",
1734
+ "127.0.0.1",
1735
+ "[::1]"
1736
+ ]);
1737
+ /**
1738
+ * JSON API behind the dashboard. Every data endpoint runs the same tool implementations the MCP
1739
+ * server uses (`POST /api/tools/:name`) with the signed-in caller's own credentials, so the dashboard
1740
+ * and the AI always see identical numbers.
1741
+ */
1742
+ function createDashboardApi(deps) {
1743
+ const { config, runtime, logger, oauth } = deps;
1744
+ const isOAuth = config.authMode === "oauth";
1745
+ const publicOrigin = config.publicBaseUrl ? new URL(config.publicBaseUrl).origin : null;
1746
+ const loginLimiter = new RateLimiter(10);
1747
+ const isSecure = (url) => !(url.protocol === "http:" && LOOPBACK.has(url.hostname));
1748
+ async function signedIn(request) {
1749
+ return oauth.openSession(parseCookies(request.headers.get("cookie"))[SESSION_COOKIE]);
1750
+ }
1751
+ /** Same-origin check for state-changing requests (on top of SameSite=Strict cookies). */
1752
+ function sameOrigin(request, url) {
1753
+ const origin = request.headers.get("origin");
1754
+ if (!origin) return true;
1755
+ return origin === url.origin || origin === publicOrigin;
1756
+ }
1757
+ function clientKey(request) {
1758
+ return request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "local";
1759
+ }
1760
+ async function readJson(request) {
1761
+ if (!request.headers.get("content-type")?.includes("application/json")) return null;
1762
+ try {
1763
+ const body = await request.json();
1764
+ return body && typeof body === "object" && !Array.isArray(body) ? body : null;
1765
+ } catch {
1766
+ return null;
1767
+ }
1768
+ }
1769
+ async function login(request, url) {
1770
+ if (isOAuth) return json$1({ error: "Use “Sign in with Google”." }, 400);
1771
+ const limit = loginLimiter.check(clientKey(request));
1772
+ if (!limit.ok) return json$1({ error: "Too many attempts. Wait a minute and try again." }, 429, { "retry-after": String(limit.retryAfterSeconds) });
1773
+ const body = await readJson(request);
1774
+ const token = typeof body?.token === "string" ? body.token.trim() : "";
1775
+ if (!token || !await timingSafeEqual(token, config.accessToken)) {
1776
+ logger.warn("dashboard login failed");
1777
+ return json$1({ error: "That access token is not correct." }, 401);
1778
+ }
1779
+ return json$1({ ok: true }, 200, { "set-cookie": sessionCookie(await oauth.ownerSession(), {
1780
+ secure: isSecure(url),
1781
+ maxAge: SESSION_TTL
1782
+ }) });
1783
+ }
1784
+ async function status(principal, request, url) {
1785
+ const session = runtime.sessionFor(principal);
1786
+ const started = Date.now();
1787
+ let google;
1788
+ try {
1789
+ const properties = await listProperties(session.gsc, { fresh: true });
1790
+ google = {
1791
+ ok: true,
1792
+ latency_ms: Date.now() - started,
1793
+ properties
1794
+ };
1795
+ } catch (error) {
1796
+ const { message, kind } = publicErrorMessage(error);
1797
+ google = {
1798
+ ok: false,
1799
+ latency_ms: Date.now() - started,
1800
+ error: message,
1801
+ kind
1802
+ };
1803
+ }
1804
+ const latest = await latestRelease(deps.fetch);
1805
+ const base = deps.baseUrl(request, url);
1806
+ const warnings = [];
1807
+ if (!config.publicBaseUrl && !LOOPBACK.has(url.hostname)) warnings.push("PUBLIC_BASE_URL is not set and could not be detected; connection snippets use this page’s address.");
1808
+ const g = config.googleOAuth;
1809
+ const oauthInfo = g ? {
1810
+ redirect_uri: `${base}/oauth/google/callback`,
1811
+ allowed_domains: g.allowedDomains,
1812
+ allowed_email_count: g.allowedEmails.length,
1813
+ open_to_anyone: g.allowedDomains.length === 0 && g.allowedEmails.length === 0
1814
+ } : null;
1815
+ if (oauthInfo?.open_to_anyone) warnings.push("Anyone with a Google account can sign in (each person only sees their own properties). Set ALLOWED_GOOGLE_EMAILS or ALLOWED_GOOGLE_DOMAINS to restrict access.");
1816
+ return json$1({
1817
+ version: VERSION,
1818
+ latest_version: latest,
1819
+ update_available: latest !== null && isNewer(latest, VERSION),
1820
+ platform: deps.platform,
1821
+ auth_mode: config.authMode,
1822
+ identity: session.gsc.identity,
1823
+ writes_enabled: config.allowWrites,
1824
+ secret_path_enabled: !isOAuth && config.secretPath,
1825
+ public_base_url: config.publicBaseUrl ?? null,
1826
+ rate_limit_per_minute: config.rateLimitPerMinute,
1827
+ activity_persistent: runtime.activity.persistent,
1828
+ oauth: oauthInfo,
1829
+ google,
1830
+ warnings
1831
+ });
1832
+ }
1833
+ async function runTool(name, request, principal) {
1834
+ const session = runtime.sessionFor(principal);
1835
+ const tool = session.tools.find((t) => t.name === name && !t.write);
1836
+ if (!tool) return json$1({ error: `Unknown tool: ${name}` }, 404);
1837
+ const body = await readJson(request) ?? {};
1838
+ const parsed = tool.input.safeParse(body);
1839
+ if (!parsed.success) return json$1({ error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }, 400);
1840
+ try {
1841
+ return json$1({ result: await tool.run(session.ctx, parsed.data) });
1842
+ } catch (error) {
1843
+ const { message, kind } = publicErrorMessage(error);
1844
+ if (kind === "internal") logger.error("dashboard tool failed", {
1845
+ tool: name,
1846
+ error
1847
+ });
1848
+ return json$1({
1849
+ error: message,
1850
+ kind
1851
+ }, kind === "internal" ? 500 : 400);
1852
+ }
1853
+ }
1854
+ return { async handle(request, url, path) {
1855
+ const method = request.method;
1856
+ if (method !== "GET" && method !== "HEAD" && !sameOrigin(request, url)) return json$1({ error: "Cross-origin request rejected." }, 403);
1857
+ if (path === "/api/session") {
1858
+ if (method === "GET") {
1859
+ const principal = await signedIn(request);
1860
+ return json$1({
1861
+ authenticated: principal !== null,
1862
+ auth_mode: config.authMode,
1863
+ login: isOAuth ? "google" : "token",
1864
+ email: principal?.email ?? null
1865
+ });
1866
+ }
1867
+ if (method === "POST") return login(request, url);
1868
+ if (method === "DELETE") return json$1({ ok: true }, 200, { "set-cookie": clearedSessionCookie(isSecure(url)) });
1869
+ return json$1({ error: "Method not allowed" }, 405);
1870
+ }
1871
+ const principal = await signedIn(request);
1872
+ if (!principal) return json$1({ error: "Sign in required." }, 401);
1873
+ if (path === "/api/status" && method === "GET") return status(principal, request, url);
1874
+ if (path === "/api/connect" && method === "GET") {
1875
+ const base = deps.baseUrl(request, url);
1876
+ return json$1({
1877
+ base_url: base,
1878
+ mcp_url: `${base}/mcp`,
1879
+ auth_mode: config.authMode,
1880
+ secret_path_enabled: !isOAuth && config.secretPath
1881
+ });
1882
+ }
1883
+ if (path === "/api/connect/secret" && method === "GET") return isOAuth ? json$1({ error: "Not available in OAuth mode." }, 404) : json$1({ access_token: config.accessToken });
1884
+ if (path === "/api/activity" && method === "GET") {
1885
+ const entries = runtime.activity.list().filter((e) => !isOAuth || e.user === principal.email);
1886
+ return json$1({
1887
+ persistent: runtime.activity.persistent,
1888
+ entries
1889
+ });
1890
+ }
1891
+ const toolMatch = /^\/api\/tools\/([a-z_]+)$/.exec(path);
1892
+ if (toolMatch && method === "POST") return runTool(toolMatch[1], request, principal);
1893
+ return json$1({ error: "Not found" }, 404);
1894
+ } };
1895
+ }
1896
+ //#endregion
1897
+ //#region src/core/seal.ts
1898
+ /** Short visible prefixes make tokens recognisable in logs and support tickets (never their content). */
1899
+ const SEAL_PREFIX = {
1900
+ client: "gsc_client_",
1901
+ state: "gsc_state_",
1902
+ request: "gsc_req_",
1903
+ code: "gsc_code_",
1904
+ access: "gsc_at_",
1905
+ refresh: "gsc_rt_",
1906
+ session: "gsc_sess_"
1907
+ };
1908
+ function createSealer(secret, now = Date.now) {
1909
+ const keys = /* @__PURE__ */ new Map();
1910
+ const keyFor = (purpose) => {
1911
+ let key = keys.get(purpose);
1912
+ if (!key) {
1913
+ key = (async () => {
1914
+ const material = await crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]);
1915
+ return crypto.subtle.deriveKey({
1916
+ name: "HKDF",
1917
+ hash: "SHA-256",
1918
+ salt: utf8("gsclaw-seal-v1"),
1919
+ info: utf8(`gsclaw:${purpose}`)
1920
+ }, material, {
1921
+ name: "AES-GCM",
1922
+ length: 256
1923
+ }, false, ["encrypt", "decrypt"]);
1924
+ })();
1925
+ keys.set(purpose, key);
1926
+ }
1927
+ return key;
1928
+ };
1929
+ return {
1930
+ async seal(purpose, payload, ttlSeconds) {
1931
+ const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
1932
+ const body = utf8(JSON.stringify({
1933
+ ...payload,
1934
+ exp: Math.floor(now() / 1e3) + ttlSeconds
1935
+ }));
1936
+ const cipher = new Uint8Array(await crypto.subtle.encrypt({
1937
+ name: "AES-GCM",
1938
+ iv,
1939
+ additionalData: utf8(purpose)
1940
+ }, await keyFor(purpose), body));
1941
+ const out = new Uint8Array(iv.length + cipher.length);
1942
+ out.set(iv);
1943
+ out.set(cipher, iv.length);
1944
+ return SEAL_PREFIX[purpose] + base64UrlEncode(out);
1945
+ },
1946
+ async open(purpose, token) {
1947
+ const prefix = SEAL_PREFIX[purpose];
1948
+ if (!token?.startsWith(prefix) || token.length > 8192) return null;
1949
+ try {
1950
+ const bytes = base64ToBytes(token.slice(prefix.length));
1951
+ if (bytes.length < 29) return null;
1952
+ const plain = await crypto.subtle.decrypt({
1953
+ name: "AES-GCM",
1954
+ iv: bytes.subarray(0, 12),
1955
+ additionalData: utf8(purpose)
1956
+ }, await keyFor(purpose), bytes.subarray(12));
1957
+ const payload = JSON.parse(new TextDecoder().decode(plain));
1958
+ if (typeof payload.exp !== "number" || payload.exp <= Math.floor(now() / 1e3)) return null;
1959
+ return payload;
1960
+ } catch {
1961
+ return null;
1962
+ }
1963
+ }
1964
+ };
1965
+ }
1966
+ //#endregion
1967
+ //#region src/core/dashboard-shell.ts
1968
+ /** Strict CSP: the dashboard only loads its own bundle and talks to its own API. */
1969
+ const DASHBOARD_HEADERS = {
1970
+ "content-type": "text/html; charset=utf-8",
1971
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
1972
+ "x-content-type-options": "nosniff",
1973
+ "referrer-policy": "no-referrer",
1974
+ "x-frame-options": "DENY",
1975
+ "cache-control": "no-store"
1976
+ };
1977
+ /**
1978
+ * The dashboard's HTML shell. It contains no data; the app behind it requires a session.
1979
+ * Assets have fixed names and are cache-busted with the version.
1980
+ */
1981
+ function dashboardShell() {
1982
+ const v = encodeURIComponent(VERSION);
1983
+ return `<!doctype html>
1984
+ <html lang="en">
1985
+ <head>
1986
+ <meta charset="utf-8">
1987
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1988
+ <meta name="robots" content="noindex">
1989
+ <meta name="color-scheme" content="light dark">
1990
+ <title>GSClaw</title>
1991
+ <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
1992
+ <link rel="stylesheet" href="/assets/dashboard.css?v=${v}">
1993
+ <script type="module" src="/assets/dashboard.js?v=${v}"><\/script>
1994
+ </head>
1995
+ <body>
1996
+ <div id="app"><noscript>GSClaw's dashboard needs JavaScript.</noscript></div>
1997
+ </body>
1998
+ </html>`;
1999
+ }
2000
+ //#endregion
2001
+ //#region src/core/format.ts
2002
+ function round(value, decimals = 0) {
2003
+ const f = 10 ** decimals;
2004
+ return Math.round(value * f) / f;
2005
+ }
2006
+ function toMetrics(raw) {
2007
+ const ctr = raw.ctr ?? (raw.impressions > 0 ? raw.clicks / raw.impressions : 0);
2008
+ return {
2009
+ clicks: round(raw.clicks),
2010
+ impressions: round(raw.impressions),
2011
+ ctr_pct: round(ctr * 100, 2),
2012
+ position: raw.impressions > 0 && raw.position !== void 0 ? round(raw.position, 1) : null
2013
+ };
2014
+ }
2015
+ function fmtInt(value) {
2016
+ return Math.round(value).toLocaleString("en-US");
2017
+ }
2018
+ function fmtPct(pct) {
2019
+ const abs = Math.abs(pct);
2020
+ return `${abs > 0 && abs < 1 ? pct.toFixed(2) : pct.toFixed(1)}%`;
2021
+ }
2022
+ function fmtPos(position) {
2023
+ return position === null ? "–" : position.toFixed(1);
2024
+ }
2025
+ function fmtSigned(value, format = fmtInt) {
2026
+ if (value === 0) return format(0);
2027
+ return value > 0 ? `+${format(value)}` : `-${format(Math.abs(value))}`;
2028
+ }
2029
+ /** Relative change in percent, or null when the base is zero. */
2030
+ function pctChange(current, previous) {
2031
+ if (previous === 0) return null;
2032
+ return round((current - previous) / previous * 100, 1);
2033
+ }
2034
+ function fmtChange(current, previous) {
2035
+ const diff = current - previous;
2036
+ const rel = pctChange(current, previous);
2037
+ if (rel === null) return current === 0 ? "0" : `${fmtSigned(diff)} (new)`;
2038
+ return `${fmtSigned(diff)} (${fmtSigned(rel, (v) => `${v.toFixed(1)}%`)})`;
2039
+ }
2040
+ function cell(value) {
2041
+ if (value === null || value === void 0) return "";
2042
+ return String(value).replace(/\|/g, "\\|").replace(/\s+/g, " ");
2043
+ }
2044
+ function mdTable(headers, rows) {
2045
+ if (rows.length === 0) return "_No rows._";
2046
+ return [
2047
+ `| ${headers.join(" | ")} |`,
2048
+ `|${headers.map(() => "---").join("|")}|`,
2049
+ ...rows.map((r) => `| ${r.map(cell).join(" | ")} |`)
2050
+ ].join("\n");
2051
+ }
2052
+ function omittedNote(shown, total, hint) {
2053
+ if (total <= shown) return null;
2054
+ return `_${fmtInt(total - shown)} more row(s) not shown (${fmtInt(shown)} of ${fmtInt(total)}). ${hint}_`;
2055
+ }
2056
+ /**
2057
+ * Shortens a page URL for display: same-host URLs become their path. The full URL is always kept
2058
+ * in structured output.
2059
+ */
2060
+ function compactUrl(url, baseHost) {
2061
+ try {
2062
+ const u = new URL(url);
2063
+ const path = `${u.pathname}${u.search}${u.hash}`;
2064
+ if (baseHost && u.host === baseHost) return path;
2065
+ return `${u.host}${path}`;
2066
+ } catch {
2067
+ return url;
2068
+ }
2069
+ }
2070
+ /** Joins non-empty sections with blank lines. */
2071
+ function sections(...parts) {
2072
+ return parts.filter((p) => Boolean(p)).join("\n\n");
2073
+ }
2074
+ /** Hard cap so a single tool result never blows past client limits (claude.ai: ~150K chars). */
2075
+ const MAX_TEXT_CHARS = 6e4;
2076
+ function capText(text, max = MAX_TEXT_CHARS) {
2077
+ if (text.length <= max) return text;
2078
+ return `${text.slice(0, max)}\n\n_[Output truncated at ${fmtInt(max)} characters. Narrow the query or lower \`limit\`.]_`;
2079
+ }
2080
+ //#endregion
2081
+ //#region src/core/prompts.ts
2082
+ const seoHealthCheckArgs = z.object({
2083
+ site_url: z.string().describe("Property to review, e.g. 'sc-domain:example.com' or 'example.com'."),
2084
+ date_range: z.enum([
2085
+ "last_28_days",
2086
+ "last_3_months",
2087
+ "last_6_months"
2088
+ ]).optional().describe("Analysis period (default last_28_days)."),
2089
+ brand_terms: z.string().optional().describe("Optional RE2 regex of brand queries to exclude from opportunity analysis, e.g. 'acme|acme corp'.")
2090
+ });
2091
+ function seoHealthCheckPrompt(args) {
2092
+ const range = args.date_range ?? "last_28_days";
2093
+ const brand = args.brand_terms ? `Pass exclude_queries: "${args.brand_terms}" to the opportunity tools so brand searches don't skew results.` : "If the site has obvious brand queries, mention that a brand-excluded view (exclude_queries) may change the picture.";
2094
+ return `Run a complete SEO health check of the Search Console property "${args.site_url}" using the GSClaw tools. Period: ${range}.
2095
+
2096
+ Work through these steps, calling the tools yourself:
2097
+ 1. list_sites: confirm the exact property and that we have access.
2098
+ 2. compare_periods (dimension "none", then "query" and "page", compare_to "previous_period"): overall trend and the biggest movers. If the period allows, also check compare_to "year_over_year" to separate seasonality from real change.
2099
+ 3. striking_distance_keywords: the best quick-win queries ranking ~8–20.
2100
+ 4. ctr_opportunities: high-impression queries whose CTR is below expectation for their position (title/description fixes).
2101
+ 5. keyword_cannibalization: queries split across several pages.
2102
+ 6. content_decay: pages in steady decline.
2103
+ 7. list_sitemaps: sitemap errors/warnings and whether Google reads them.
2104
+ 8. page_report or inspect_url for the 2–3 most important pages you found (top losers or top striking-distance pages), to check index status and canonicals.
2105
+ ${brand}
2106
+
2107
+ Then write a concise report:
2108
+ - **Summary**: 3–5 bullets on overall health and trend (with numbers).
2109
+ - **Prioritized actions**: a numbered list ordered by expected impact vs effort. For each: the page/query, the evidence (numbers from the tools), and the concrete fix.
2110
+ - **Technical issues**: indexing, canonical or sitemap problems, if any.
2111
+ - **Watch list**: things to re-check in 2–4 weeks.
2112
+
2113
+ Remember: Search Console data lags ~2–3 days, positions are averages, and rare queries are anonymized. Don't invent numbers; cite only what the tools returned.`;
2114
+ }
2115
+ //#endregion
2116
+ //#region src/core/mcp.ts
2117
+ const SERVER_INSTRUCTIONS = `GSClaw gives you read access to Google Search Console (and sitemap writes only if the operator enabled them).
2118
+ - Start with list_sites to find the exact property; site_url also accepts a bare domain like "example.com".
2119
+ - Prefer the analysis tools (compare_periods, striking_distance_keywords, ctr_opportunities, keyword_cannibalization, content_decay, page_report) over raw search_analytics for common questions.
2120
+ - Dates are Pacific Time. Final data lags ~2–3 days; relative ranges end on the latest final day. Pass fresh=true for partial recent data.
2121
+ - Results show the top rows only and say how many were omitted; raise limit if you need more.
2122
+ - Search Console keeps ~16 months of data, and rare queries are anonymized.`;
2123
+ /** Builds a fresh MCP server instance (one per HTTP request or stdio connection). */
2124
+ function buildMcpServer(deps) {
2125
+ const server = new McpServer({
2126
+ name: NAME,
2127
+ title: "GSClaw",
2128
+ version: VERSION
2129
+ }, { instructions: SERVER_INSTRUCTIONS });
2130
+ for (const tool of deps.tools) server.registerTool(tool.name, {
2131
+ title: tool.title,
2132
+ description: tool.description,
2133
+ inputSchema: tool.input,
2134
+ annotations: {
2135
+ title: tool.title,
2136
+ ...tool.annotations
2137
+ }
2138
+ }, async (args) => {
2139
+ const started = Date.now();
2140
+ const site = typeof args.site_url === "string" ? args.site_url : typeof args.url === "string" ? args.url : null;
2141
+ try {
2142
+ const result = await tool.run(deps.ctx, args);
2143
+ const text = capText(tool.format(result, args));
2144
+ deps.activity?.record({
2145
+ time: (/* @__PURE__ */ new Date()).toISOString(),
2146
+ client: deps.client,
2147
+ tool: tool.name,
2148
+ site,
2149
+ ms: Date.now() - started,
2150
+ ok: true,
2151
+ user: deps.user
2152
+ });
2153
+ return {
2154
+ content: [{
2155
+ type: "text",
2156
+ text
2157
+ }],
2158
+ structuredContent: result
2159
+ };
2160
+ } catch (error) {
2161
+ const { message, kind } = publicErrorMessage(error);
2162
+ if (kind === "internal") deps.logger.error("tool failed", {
2163
+ tool: tool.name,
2164
+ error
2165
+ });
2166
+ else deps.logger.info("tool returned error", {
2167
+ tool: tool.name,
2168
+ kind
2169
+ });
2170
+ deps.activity?.record({
2171
+ time: (/* @__PURE__ */ new Date()).toISOString(),
2172
+ client: deps.client,
2173
+ tool: tool.name,
2174
+ site,
2175
+ ms: Date.now() - started,
2176
+ ok: false,
2177
+ error_kind: kind,
2178
+ user: deps.user
2179
+ });
2180
+ return {
2181
+ content: [{
2182
+ type: "text",
2183
+ text: message
2184
+ }],
2185
+ isError: true
2186
+ };
2187
+ }
2188
+ });
2189
+ server.registerPrompt("seo_health_check", {
2190
+ title: "SEO health check",
2191
+ description: "Full review of a Search Console property: trends, quick wins, CTR fixes, cannibalization, decaying content, sitemaps and indexing, ending in a prioritized action plan.",
2192
+ argsSchema: seoHealthCheckArgs
2193
+ }, (args) => ({ messages: [{
2194
+ role: "user",
2195
+ content: {
2196
+ type: "text",
2197
+ text: seoHealthCheckPrompt(args)
2198
+ }
2199
+ }] }));
2200
+ return server;
2201
+ }
2202
+ //#endregion
2203
+ //#region src/core/cache.ts
2204
+ /**
2205
+ * Small in-memory LRU with TTL and a total weight budget (for example, number of rows held) so a
2206
+ * few huge responses cannot exhaust memory on small runtimes such as Cloudflare Workers.
2207
+ */
2208
+ var LruCache = class {
2209
+ opts;
2210
+ map = /* @__PURE__ */ new Map();
2211
+ totalWeight = 0;
2212
+ constructor(opts) {
2213
+ this.opts = opts;
2214
+ }
2215
+ now() {
2216
+ return this.opts.now ? this.opts.now() : Date.now();
2217
+ }
2218
+ get(key) {
2219
+ const entry = this.map.get(key);
2220
+ if (!entry) return void 0;
2221
+ if (entry.expiresAt <= this.now()) {
2222
+ this.delete(key);
2223
+ return;
2224
+ }
2225
+ this.map.delete(key);
2226
+ this.map.set(key, entry);
2227
+ return entry.value;
2228
+ }
2229
+ set(key, value, ttlMs, weight = 1) {
2230
+ if (ttlMs <= 0 || weight > this.opts.maxWeight) return;
2231
+ this.delete(key);
2232
+ this.map.set(key, {
2233
+ value,
2234
+ expiresAt: this.now() + ttlMs,
2235
+ weight
2236
+ });
2237
+ this.totalWeight += weight;
2238
+ while (this.map.size > this.opts.maxEntries || this.totalWeight > this.opts.maxWeight) {
2239
+ const oldest = this.map.keys().next().value;
2240
+ if (oldest === void 0) break;
2241
+ this.delete(oldest);
2242
+ }
2243
+ }
2244
+ delete(key) {
2245
+ const entry = this.map.get(key);
2246
+ if (!entry) return;
2247
+ this.totalWeight -= entry.weight;
2248
+ this.map.delete(key);
2249
+ }
2250
+ deleteWhere(predicate) {
2251
+ for (const key of [...this.map.keys()]) if (predicate(key)) this.delete(key);
2252
+ }
2253
+ get size() {
2254
+ return this.map.size;
2255
+ }
2256
+ };
2257
+ //#endregion
2258
+ //#region src/core/gsc/client.ts
2259
+ const API_BASE = "https://searchconsole.googleapis.com";
2260
+ const WEBMASTERS = `${API_BASE}/webmasters/v3`;
2261
+ const INSPECT_URL = `${API_BASE}/v1/urlInspection/index:inspect`;
2262
+ /** Google's maximum page size for searchanalytics.query. */
2263
+ const PAGE_SIZE = 25e3;
2264
+ /** Weight budget is in rows, which keeps memory bounded on small runtimes (Workers: 128 MB). */
2265
+ function createResponseCache() {
2266
+ return new LruCache({
2267
+ maxEntries: 300,
2268
+ maxWeight: 6e4
2269
+ });
2270
+ }
2271
+ const RETRYABLE_STATUS = /* @__PURE__ */ new Set([
2272
+ 429,
2273
+ 500,
2274
+ 502,
2275
+ 503,
2276
+ 504
2277
+ ]);
2278
+ var GscClient = class {
2279
+ tokenSource;
2280
+ logger;
2281
+ fetchImpl;
2282
+ cache;
2283
+ cacheTtlMs;
2284
+ maxRetries;
2285
+ timeoutMs;
2286
+ sleep;
2287
+ constructor(opts) {
2288
+ this.tokenSource = opts.tokenSource;
2289
+ this.logger = opts.logger;
2290
+ this.fetchImpl = opts.fetch ?? ((input, init) => fetch(input, init));
2291
+ this.cache = opts.cache;
2292
+ this.cacheTtlMs = opts.cacheTtlMs ?? 3e5;
2293
+ this.maxRetries = opts.maxRetries ?? 3;
2294
+ this.timeoutMs = opts.timeoutMs ?? 3e4;
2295
+ this.sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
2296
+ }
2297
+ get identity() {
2298
+ return this.tokenSource.identity;
2299
+ }
2300
+ get canWrite() {
2301
+ return this.tokenSource.scopes.includes("https://www.googleapis.com/auth/webmasters");
2302
+ }
2303
+ /** `fresh` skips the cache read (used by the dashboard's live connectivity check). */
2304
+ async listSites(opts = {}) {
2305
+ return (await this.request({
2306
+ method: "GET",
2307
+ url: `${WEBMASTERS}/sites`,
2308
+ cacheWeight: () => 1,
2309
+ fresh: opts.fresh
2310
+ })).siteEntry ?? [];
2311
+ }
2312
+ async searchAnalytics(siteUrl, body) {
2313
+ return this.request({
2314
+ method: "POST",
2315
+ url: `${WEBMASTERS}/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`,
2316
+ body,
2317
+ siteUrl,
2318
+ cacheWeight: (d) => Math.max(1, d.rows?.length ?? 0)
2319
+ });
2320
+ }
2321
+ /**
2322
+ * Fetches up to `maxRows` rows, paging through `startRow` in 25,000-row pages. Google exposes at
2323
+ * most ~50K rows per day per search type, so very large sites may still be truncated upstream.
2324
+ */
2325
+ async searchAnalyticsAll(siteUrl, body, maxRows) {
2326
+ const rows = [];
2327
+ let metadata;
2328
+ let startRow = 0;
2329
+ for (;;) {
2330
+ const rowLimit = Math.min(PAGE_SIZE, maxRows - rows.length);
2331
+ const res = await this.searchAnalytics(siteUrl, {
2332
+ ...body,
2333
+ rowLimit,
2334
+ startRow
2335
+ });
2336
+ metadata ??= res.metadata;
2337
+ const page = res.rows ?? [];
2338
+ rows.push(...page);
2339
+ if (page.length < rowLimit) return {
2340
+ rows,
2341
+ truncated: false,
2342
+ metadata
2343
+ };
2344
+ if (rows.length >= maxRows) return {
2345
+ rows,
2346
+ truncated: true,
2347
+ metadata
2348
+ };
2349
+ startRow += page.length;
2350
+ }
2351
+ }
2352
+ async inspectUrl(siteUrl, inspectionUrl, languageCode = "en-US") {
2353
+ return this.request({
2354
+ method: "POST",
2355
+ url: INSPECT_URL,
2356
+ body: {
2357
+ inspectionUrl,
2358
+ siteUrl,
2359
+ languageCode
2360
+ },
2361
+ siteUrl,
2362
+ cacheWeight: () => 1
2363
+ });
2364
+ }
2365
+ async listSitemaps(siteUrl, sitemapIndex) {
2366
+ const query = sitemapIndex ? `?sitemapIndex=${encodeURIComponent(sitemapIndex)}` : "";
2367
+ return (await this.request({
2368
+ method: "GET",
2369
+ url: `${WEBMASTERS}/sites/${encodeURIComponent(siteUrl)}/sitemaps${query}`,
2370
+ siteUrl,
2371
+ cacheWeight: () => 1
2372
+ })).sitemap ?? [];
2373
+ }
2374
+ async getSitemap(siteUrl, feedpath) {
2375
+ return this.request({
2376
+ method: "GET",
2377
+ url: `${WEBMASTERS}/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`,
2378
+ siteUrl,
2379
+ cacheWeight: () => 1
2380
+ });
2381
+ }
2382
+ async submitSitemap(siteUrl, feedpath) {
2383
+ await this.request({
2384
+ method: "PUT",
2385
+ url: `${WEBMASTERS}/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`,
2386
+ siteUrl
2387
+ });
2388
+ this.invalidateSite(siteUrl);
2389
+ }
2390
+ async deleteSitemap(siteUrl, feedpath) {
2391
+ await this.request({
2392
+ method: "DELETE",
2393
+ url: `${WEBMASTERS}/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`,
2394
+ siteUrl
2395
+ });
2396
+ this.invalidateSite(siteUrl);
2397
+ }
2398
+ cacheKey(opts) {
2399
+ return `${this.identity.email}\n${opts.method} ${opts.url}\n${opts.body ? JSON.stringify(opts.body) : ""}`;
2400
+ }
2401
+ invalidateSite(siteUrl) {
2402
+ const marker = encodeURIComponent(siteUrl);
2403
+ this.cache?.deleteWhere((key) => key.startsWith(`${this.identity.email}\n`) && key.includes(marker));
2404
+ }
2405
+ async request(opts) {
2406
+ const useCache = this.cache && opts.cacheWeight && this.cacheTtlMs > 0;
2407
+ const key = useCache ? this.cacheKey(opts) : "";
2408
+ if (useCache && !opts.fresh) {
2409
+ const hit = this.cache.get(key);
2410
+ if (hit !== void 0) {
2411
+ this.logger.debug("gsc cache hit", {
2412
+ method: opts.method,
2413
+ path: pathOf(opts.url)
2414
+ });
2415
+ return hit;
2416
+ }
2417
+ }
2418
+ let refreshedToken = false;
2419
+ for (let attempt = 0;; attempt++) {
2420
+ const started = Date.now();
2421
+ let res;
2422
+ try {
2423
+ const token = await this.tokenSource.getAccessToken();
2424
+ res = await this.fetchImpl(opts.url, {
2425
+ method: opts.method,
2426
+ headers: {
2427
+ authorization: `Bearer ${token}`,
2428
+ ...opts.body !== void 0 ? { "content-type": "application/json" } : {}
2429
+ },
2430
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
2431
+ signal: AbortSignal.timeout(this.timeoutMs)
2432
+ });
2433
+ } catch (error) {
2434
+ if (error instanceof GscApiError) {
2435
+ if (error.retryable && attempt < this.maxRetries) {
2436
+ await this.sleep(backoff(attempt));
2437
+ continue;
2438
+ }
2439
+ throw error;
2440
+ }
2441
+ if (attempt < this.maxRetries) {
2442
+ this.logger.warn("gsc network error, retrying", {
2443
+ attempt,
2444
+ error
2445
+ });
2446
+ await this.sleep(backoff(attempt));
2447
+ continue;
2448
+ }
2449
+ throw new GscApiError({
2450
+ kind: "network",
2451
+ status: 0,
2452
+ googleMessage: "",
2453
+ retryable: true,
2454
+ advice: "Could not reach the Google Search Console API (network error or timeout). Try again shortly."
2455
+ });
2456
+ }
2457
+ this.logger.debug("gsc request", {
2458
+ method: opts.method,
2459
+ path: pathOf(opts.url),
2460
+ status: res.status,
2461
+ ms: Date.now() - started
2462
+ });
2463
+ if (res.ok) {
2464
+ const text = await res.text();
2465
+ const data = text ? JSON.parse(text) : {};
2466
+ if (useCache) this.cache.set(key, data, this.cacheTtlMs, opts.cacheWeight(data));
2467
+ return data;
2468
+ }
2469
+ const body = await res.json().catch(() => void 0);
2470
+ if (res.status === 401 && !refreshedToken) {
2471
+ refreshedToken = true;
2472
+ this.tokenSource.invalidate();
2473
+ continue;
2474
+ }
2475
+ const error = toGscApiError(res.status, body, {
2476
+ identity: this.identity,
2477
+ siteUrl: opts.siteUrl
2478
+ });
2479
+ if ((RETRYABLE_STATUS.has(res.status) || error.kind === "rate_limited" && error.retryable) && attempt < this.maxRetries) {
2480
+ const retryAfter = Number(res.headers.get("retry-after"));
2481
+ const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1e3, 1e4) : backoff(attempt);
2482
+ this.logger.info("gsc retrying", {
2483
+ status: res.status,
2484
+ attempt,
2485
+ delay
2486
+ });
2487
+ await this.sleep(delay);
2488
+ continue;
2489
+ }
2490
+ throw error;
2491
+ }
2492
+ }
2493
+ };
2494
+ /** Exponential backoff with jitter: ~0.5s, 1s, 2s, 4s (+ up to 250ms). */
2495
+ function backoff(attempt) {
2496
+ return 500 * 2 ** attempt + Math.floor(Math.random() * 250);
2497
+ }
2498
+ /** Path for logs, with the (possibly sensitive) site URL segment left encoded. */
2499
+ function pathOf(url) {
2500
+ return url.replace(API_BASE, "");
2501
+ }
2502
+ //#endregion
2503
+ //#region src/core/analysis/compare.ts
2504
+ const EMPTY = {
2505
+ clicks: 0,
2506
+ impressions: 0
2507
+ };
2508
+ function metricChange(current, previous) {
2509
+ return {
2510
+ clicks: current.clicks - previous.clicks,
2511
+ clicks_pct: pctChange(current.clicks, previous.clicks),
2512
+ impressions: current.impressions - previous.impressions,
2513
+ impressions_pct: pctChange(current.impressions, previous.impressions),
2514
+ ctr_pp: round(current.ctr_pct - previous.ctr_pct, 2),
2515
+ rank_improvement: current.position !== null && previous.position !== null ? round(previous.position - current.position, 1) : null
2516
+ };
2517
+ }
2518
+ /** Full outer join of two keyed row sets with per-key deltas. */
2519
+ function compareRows(current, previous) {
2520
+ const keys = /* @__PURE__ */ new Set([...current.keys(), ...previous.keys()]);
2521
+ const rows = [];
2522
+ for (const key of keys) {
2523
+ const cur = toMetrics(current.get(key) ?? EMPTY);
2524
+ const prev = toMetrics(previous.get(key) ?? EMPTY);
2525
+ rows.push({
2526
+ key,
2527
+ current: cur,
2528
+ previous: prev,
2529
+ change: metricChange(cur, prev),
2530
+ status: !previous.has(key) ? "new" : !current.has(key) ? "lost" : "both"
2531
+ });
2532
+ }
2533
+ return rows;
2534
+ }
2535
+ function topGainers(rows, n) {
2536
+ return rows.filter((r) => r.change.clicks > 0).sort((a, b) => b.change.clicks - a.change.clicks || b.change.impressions - a.change.impressions).slice(0, n);
2537
+ }
2538
+ function topLosers(rows, n) {
2539
+ return rows.filter((r) => r.change.clicks < 0).sort((a, b) => a.change.clicks - b.change.clicks || a.change.impressions - b.change.impressions).slice(0, n);
2540
+ }
2541
+ function topByCurrentClicks(rows, n) {
2542
+ return [...rows].filter((r) => r.status !== "lost").sort((a, b) => b.current.clicks - a.current.clicks || b.current.impressions - a.current.impressions).slice(0, n);
2543
+ }
2544
+ //#endregion
2545
+ //#region src/core/dates.ts
2546
+ const DATE_PRESETS = [
2547
+ "last_7_days",
2548
+ "last_28_days",
2549
+ "last_3_months",
2550
+ "last_6_months",
2551
+ "last_12_months",
2552
+ "last_16_months",
2553
+ "previous_period"
2554
+ ];
2555
+ const COMPARE_MODES = ["previous_period", "year_over_year"];
2556
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
2557
+ function isValidDate(value) {
2558
+ if (!DATE_RE.test(value)) return false;
2559
+ const d = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
2560
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
2561
+ }
2562
+ const ptFormatter = new Intl.DateTimeFormat("en-CA", {
2563
+ timeZone: "America/Los_Angeles",
2564
+ year: "numeric",
2565
+ month: "2-digit",
2566
+ day: "2-digit"
2567
+ });
2568
+ function todayPT(now = /* @__PURE__ */ new Date()) {
2569
+ return ptFormatter.format(now);
2570
+ }
2571
+ function toUtc(date) {
2572
+ return /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
2573
+ }
2574
+ function fromUtc(d) {
2575
+ return d.toISOString().slice(0, 10);
2576
+ }
2577
+ function addDays(date, days) {
2578
+ const d = toUtc(date);
2579
+ d.setUTCDate(d.getUTCDate() + days);
2580
+ return fromUtc(d);
2581
+ }
2582
+ /** Calendar month arithmetic that clamps to the end of shorter months (Mar 31 − 1 month = Feb 28). */
2583
+ function addMonths(date, months) {
2584
+ const d = toUtc(date);
2585
+ const day = d.getUTCDate();
2586
+ d.setUTCDate(1);
2587
+ d.setUTCMonth(d.getUTCMonth() + months);
2588
+ const lastDay = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
2589
+ d.setUTCDate(Math.min(day, lastDay));
2590
+ return fromUtc(d);
2591
+ }
2592
+ /** Inclusive number of days in a range. */
2593
+ function rangeLength(range) {
2594
+ return Math.round((toUtc(range.endDate).getTime() - toUtc(range.startDate).getTime()) / 864e5) + 1;
2595
+ }
2596
+ function resolvePreset(preset, endDate) {
2597
+ const lastDays = (n) => ({
2598
+ startDate: addDays(endDate, -(n - 1)),
2599
+ endDate
2600
+ });
2601
+ const lastMonths = (n) => ({
2602
+ startDate: addDays(addMonths(endDate, -n), 1),
2603
+ endDate
2604
+ });
2605
+ switch (preset) {
2606
+ case "last_7_days": return lastDays(7);
2607
+ case "last_28_days": return lastDays(28);
2608
+ case "last_3_months": return lastMonths(3);
2609
+ case "last_6_months": return lastMonths(6);
2610
+ case "last_12_months": return lastMonths(12);
2611
+ case "last_16_months": return lastMonths(16);
2612
+ case "previous_period": return previousPeriod(lastDays(28));
2613
+ }
2614
+ }
2615
+ /** The range of equal length immediately before `range`. */
2616
+ function previousPeriod(range) {
2617
+ const length = rangeLength(range);
2618
+ const endDate = addDays(range.startDate, -1);
2619
+ return {
2620
+ startDate: addDays(endDate, -(length - 1)),
2621
+ endDate
2622
+ };
2623
+ }
2624
+ /** The same calendar dates one year earlier. */
2625
+ function yearOverYear(range) {
2626
+ return {
2627
+ startDate: addMonths(range.startDate, -12),
2628
+ endDate: addMonths(range.endDate, -12)
2629
+ };
2630
+ }
2631
+ function comparisonRange(range, mode) {
2632
+ return mode === "year_over_year" ? yearOverYear(range) : previousPeriod(range);
2633
+ }
2634
+ /** Oldest date Search Console still has data for, given today's date (PT). */
2635
+ function earliestAvailableDate(today) {
2636
+ return addDays(addMonths(today, -16), 1);
2637
+ }
2638
+ /** Splits a range into `count` consecutive, equal-length windows ending at `endDate`. */
2639
+ function consecutiveWindows(endDate, count, days) {
2640
+ const windows = [];
2641
+ let end = endDate;
2642
+ for (let i = 0; i < count; i++) {
2643
+ const start = addDays(end, -(days - 1));
2644
+ windows.unshift({
2645
+ startDate: start,
2646
+ endDate: end
2647
+ });
2648
+ end = addDays(start, -1);
2649
+ }
2650
+ return windows;
2651
+ }
2652
+ //#endregion
2653
+ //#region src/core/gsc/types.ts
2654
+ const SEARCH_TYPES = [
2655
+ "web",
2656
+ "image",
2657
+ "video",
2658
+ "news",
2659
+ "discover",
2660
+ "googleNews"
2661
+ ];
2662
+ const DIMENSIONS = [
2663
+ "query",
2664
+ "page",
2665
+ "country",
2666
+ "device",
2667
+ "date",
2668
+ "searchAppearance",
2669
+ "hour"
2670
+ ];
2671
+ const FILTER_DIMENSIONS = [
2672
+ "query",
2673
+ "page",
2674
+ "country",
2675
+ "device",
2676
+ "searchAppearance"
2677
+ ];
2678
+ const FILTER_OPERATORS = [
2679
+ "equals",
2680
+ "notEquals",
2681
+ "contains",
2682
+ "notContains",
2683
+ "includingRegex",
2684
+ "excludingRegex"
2685
+ ];
2686
+ const AGGREGATION_TYPES = [
2687
+ "auto",
2688
+ "byPage",
2689
+ "byProperty",
2690
+ "byNewsShowcasePanel"
2691
+ ];
2692
+ //#endregion
2693
+ //#region src/core/tools/common.ts
2694
+ const siteUrlField = z.string().min(1).describe("Search Console property: 'sc-domain:example.com', 'https://www.example.com/', or just 'example.com' (matched against your properties; see list_sites).");
2695
+ const dateField = z.string().refine(isValidDate, "Use YYYY-MM-DD.").optional();
2696
+ const dateRangeFields = {
2697
+ date_range: z.enum(DATE_PRESETS).default("last_28_days").describe("Relative period ending on the latest day with final data (Search Console lags ~2–3 days). previous_period = the 28 days before last_28_days."),
2698
+ start_date: dateField.describe("Explicit start date YYYY-MM-DD (Pacific Time). Use together with end_date; overrides date_range."),
2699
+ end_date: dateField.describe("Explicit end date YYYY-MM-DD (Pacific Time), inclusive."),
2700
+ fresh: z.boolean().default(false).describe("Include fresh, still-changing data from the most recent ~2 days (dataState \"all\"). Default: final data only.")
2701
+ };
2702
+ const searchTypeField = z.enum(SEARCH_TYPES).default("web").describe("Search type. Discover and Google News have no query or position data.");
2703
+ const filterSchema = z.object({
2704
+ dimension: z.enum(FILTER_DIMENSIONS),
2705
+ operator: z.enum(FILTER_OPERATORS).default("equals").describe("equals/notEquals are case-sensitive; contains/notContains are not; regex uses RE2 syntax."),
2706
+ expression: z.string().min(1).max(4096).describe("Value to match. Countries are ISO 3166-1 alpha-3 (e.g. USA); devices are DESKTOP, MOBILE, TABLET.")
2707
+ });
2708
+ const filtersField = z.array(filterSchema).max(20).optional().describe("Filters, all ANDed together.");
2709
+ function limitField(defaultValue, max = 500) {
2710
+ return z.number().int().min(1).max(max).default(defaultValue).describe(`How many rows to show (top N, default ${defaultValue}). Results always say how many more exist.`);
2711
+ }
2712
+ /**
2713
+ * Latest date that already has final data, found with a cheap date-grouped query (cached by the
2714
+ * client). Falls back to today − 3 days for properties with no recent data.
2715
+ */
2716
+ async function latestFinalDate(ctx, siteUrl, searchType) {
2717
+ const today = todayPT(ctx.now());
2718
+ const dates = ((await ctx.gsc.searchAnalytics(siteUrl, {
2719
+ startDate: addDays(today, -10),
2720
+ endDate: today,
2721
+ dimensions: ["date"],
2722
+ type: searchType,
2723
+ dataState: "final",
2724
+ rowLimit: 15
2725
+ })).rows ?? []).map((r) => r.keys?.[0]).filter((d) => Boolean(d));
2726
+ return dates.length > 0 ? dates.sort().at(-1) : addDays(today, -3);
2727
+ }
2728
+ async function resolveWindow(ctx, property, input, searchType = "web") {
2729
+ const today = todayPT(ctx.now());
2730
+ const dataState = input.fresh ? "all" : "final";
2731
+ if (input.start_date || input.end_date) {
2732
+ if (!input.start_date || !input.end_date) throw new UserFacingError("Provide both start_date and end_date, or neither (to use date_range).");
2733
+ if (input.start_date > input.end_date) throw new UserFacingError("start_date must be on or before end_date.");
2734
+ if (input.end_date > today) throw new UserFacingError(`end_date cannot be in the future (today is ${today} Pacific Time).`);
2735
+ const earliest = earliestAvailableDate(today);
2736
+ if (input.end_date < earliest) throw new UserFacingError(`Search Console only keeps ~16 months of data; the earliest available date is about ${earliest}.`);
2737
+ return {
2738
+ startDate: input.start_date < earliest ? earliest : input.start_date,
2739
+ endDate: input.end_date,
2740
+ dataState,
2741
+ label: `${input.start_date} → ${input.end_date}`
2742
+ };
2743
+ }
2744
+ const endDate = input.fresh ? today : await latestFinalDate(ctx, property.site_url, searchType);
2745
+ const range = resolvePreset(input.date_range, endDate);
2746
+ const earliest = earliestAvailableDate(today);
2747
+ return {
2748
+ ...range,
2749
+ startDate: range.startDate < earliest ? earliest : range.startDate,
2750
+ dataState,
2751
+ label: input.date_range
2752
+ };
2753
+ }
2754
+ function windowNote(window) {
2755
+ const state = window.dataState === "all" ? "includes fresh data; the last ~2 days are incomplete and will still change" : "final data only; Search Console lags ~2–3 days";
2756
+ return `_${window.startDate} → ${window.endDate} (Pacific Time, ${state})._`;
2757
+ }
2758
+ function toFilterGroups(filters) {
2759
+ if (!filters || filters.length === 0) return void 0;
2760
+ return [{
2761
+ groupType: "and",
2762
+ filters
2763
+ }];
2764
+ }
2765
+ /** All rows for a window and dimension set (auto-paginated, capped by GSCLAW_MAX_ROWS). */
2766
+ async function fetchRows(ctx, siteUrl, range, dimensions, opts = {}) {
2767
+ return ctx.gsc.searchAnalyticsAll(siteUrl, {
2768
+ startDate: range.startDate,
2769
+ endDate: range.endDate,
2770
+ dimensions,
2771
+ type: opts.type ?? "web",
2772
+ dimensionFilterGroups: toFilterGroups(opts.filters),
2773
+ dataState: opts.dataState ?? "final"
2774
+ }, Math.min(opts.maxRows ?? ctx.config.maxRows, ctx.config.maxRows));
2775
+ }
2776
+ /** Property totals for a window (a single dimensionless row). */
2777
+ async function fetchTotals(ctx, siteUrl, range, opts = {}) {
2778
+ return (await ctx.gsc.searchAnalytics(siteUrl, {
2779
+ startDate: range.startDate,
2780
+ endDate: range.endDate,
2781
+ type: opts.type ?? "web",
2782
+ dimensionFilterGroups: toFilterGroups(opts.filters),
2783
+ dataState: opts.dataState ?? "final",
2784
+ rowLimit: 1
2785
+ })).rows?.[0] ?? {
2786
+ clicks: 0,
2787
+ impressions: 0
2788
+ };
2789
+ }
2790
+ /** Keyed rows for one dimension, e.g. query → metrics. */
2791
+ async function fetchKeyed(ctx, siteUrl, range, dimension, opts = {}) {
2792
+ const { rows, truncated } = await fetchRows(ctx, siteUrl, range, [dimension], opts);
2793
+ return {
2794
+ rows: new Map(rows.map((r) => [r.keys?.[0] ?? "", r])),
2795
+ truncated
2796
+ };
2797
+ }
2798
+ /** Adds an excludingRegex query filter when a pattern is given (e.g. to drop branded queries). */
2799
+ function withQueryExclusion(filters, pattern) {
2800
+ if (!pattern) return filters;
2801
+ return [...filters ?? [], {
2802
+ dimension: "query",
2803
+ operator: "excludingRegex",
2804
+ expression: pattern
2805
+ }];
2806
+ }
2807
+ const excludeQueriesField = z.string().max(4096).optional().describe("RE2 regex of queries to exclude, e.g. your brand: 'acme|acme corp'.");
2808
+ async function propertyFor(ctx, siteUrl) {
2809
+ return resolveProperty(ctx.gsc, siteUrl);
2810
+ }
2811
+ /** Host used to shorten page URLs in text output (URL-prefix properties only). */
2812
+ function displayHost(siteUrl) {
2813
+ if (siteUrl.startsWith("sc-domain:")) return void 0;
2814
+ try {
2815
+ return new URL(siteUrl).host;
2816
+ } catch {
2817
+ return;
2818
+ }
2819
+ }
2820
+ //#endregion
2821
+ //#region src/core/tools/types.ts
2822
+ function defineTool(def) {
2823
+ return def;
2824
+ }
2825
+ const READ_ONLY = {
2826
+ readOnlyHint: true,
2827
+ destructiveHint: false,
2828
+ idempotentHint: true,
2829
+ openWorldHint: false
2830
+ };
2831
+ //#endregion
2832
+ //#region src/core/tools/compare.ts
2833
+ const COMPARE_DIMENSIONS = [
2834
+ "query",
2835
+ "page",
2836
+ "country",
2837
+ "device",
2838
+ "searchAppearance",
2839
+ "none"
2840
+ ];
2841
+ const COMPARE_VIEWS = [
2842
+ "movers",
2843
+ "top",
2844
+ "all"
2845
+ ];
2846
+ const comparePeriods = defineTool({
2847
+ name: "compare_periods",
2848
+ title: "Compare periods (gainers & losers)",
2849
+ description: "Compares a period with the previous period or the same dates last year: property totals with deltas, plus the top gaining and losing queries/pages/countries/devices by clicks. Use for \"what changed?\", traffic drops, and month-over-month or year-over-year reports. dimension \"none\" returns totals only.",
2850
+ input: z.object({
2851
+ site_url: siteUrlField,
2852
+ ...dateRangeFields,
2853
+ compare_to: z.enum(COMPARE_MODES).default("previous_period").describe("previous_period (same length, right before) or year_over_year (same dates last year)."),
2854
+ dimension: z.enum(COMPARE_DIMENSIONS).default("query"),
2855
+ view: z.enum(COMPARE_VIEWS).default("movers").describe("movers = top gainers + losers; top = biggest rows now with deltas; all = both."),
2856
+ filters: filtersField,
2857
+ search_type: searchTypeField,
2858
+ limit: limitField(10, 100)
2859
+ }),
2860
+ annotations: READ_ONLY,
2861
+ async run(ctx, input) {
2862
+ const property = await propertyFor(ctx, input.site_url);
2863
+ const current = await resolveWindow(ctx, property, input, input.search_type);
2864
+ const previous = comparisonRange(current, input.compare_to);
2865
+ const earliest = earliestAvailableDate(todayPT(ctx.now()));
2866
+ if (previous.startDate < earliest) throw new UserFacingError(`The comparison period would start ${previous.startDate}, but Search Console only keeps ~16 months of data (earliest ≈ ${earliest}). Use a shorter date_range or compare_to "previous_period".`);
2867
+ const opts = {
2868
+ type: input.search_type,
2869
+ filters: input.filters,
2870
+ dataState: current.dataState
2871
+ };
2872
+ const dimension = input.dimension === "none" ? null : input.dimension;
2873
+ const [curTotals, prevTotals, curRows, prevRows] = await Promise.all([
2874
+ fetchTotals(ctx, property.site_url, current, opts),
2875
+ fetchTotals(ctx, property.site_url, previous, opts),
2876
+ dimension ? fetchKeyed(ctx, property.site_url, current, dimension, opts) : null,
2877
+ dimension ? fetchKeyed(ctx, property.site_url, previous, dimension, opts) : null
2878
+ ]);
2879
+ const rows = curRows && prevRows ? compareRows(curRows.rows, prevRows.rows) : [];
2880
+ const cur = toMetrics(curTotals);
2881
+ const prev = toMetrics(prevTotals);
2882
+ const wantTop = input.view !== "movers";
2883
+ const wantMovers = input.view !== "top";
2884
+ return {
2885
+ site_url: property.site_url,
2886
+ dimension: input.dimension,
2887
+ compare_to: input.compare_to,
2888
+ current: {
2889
+ startDate: current.startDate,
2890
+ endDate: current.endDate
2891
+ },
2892
+ previous,
2893
+ data_state: current.dataState,
2894
+ totals: {
2895
+ current: cur,
2896
+ previous: prev,
2897
+ change: metricChange(cur, prev)
2898
+ },
2899
+ top: wantTop ? topByCurrentClicks(rows, input.limit) : [],
2900
+ gainers: wantMovers ? topGainers(rows, input.limit) : [],
2901
+ losers: wantMovers ? topLosers(rows, input.limit) : [],
2902
+ new_count: rows.filter((r) => r.status === "new").length,
2903
+ lost_count: rows.filter((r) => r.status === "lost").length,
2904
+ compared_count: rows.length,
2905
+ truncated: Boolean(curRows?.truncated || prevRows?.truncated)
2906
+ };
2907
+ },
2908
+ format(r) {
2909
+ const t = r.totals;
2910
+ const host = displayHost(r.site_url);
2911
+ const label = (key) => r.dimension === "page" ? compactUrl(key, host) : key;
2912
+ const totals = mdTable([
2913
+ "Metric",
2914
+ "Current",
2915
+ "Previous",
2916
+ "Change"
2917
+ ], [
2918
+ [
2919
+ "Clicks",
2920
+ fmtInt(t.current.clicks),
2921
+ fmtInt(t.previous.clicks),
2922
+ fmtChange(t.current.clicks, t.previous.clicks)
2923
+ ],
2924
+ [
2925
+ "Impressions",
2926
+ fmtInt(t.current.impressions),
2927
+ fmtInt(t.previous.impressions),
2928
+ fmtChange(t.current.impressions, t.previous.impressions)
2929
+ ],
2930
+ [
2931
+ "CTR",
2932
+ fmtPct(t.current.ctr_pct),
2933
+ fmtPct(t.previous.ctr_pct),
2934
+ `${fmtSigned(t.change.ctr_pp, (v) => v.toFixed(2))} pp`
2935
+ ],
2936
+ [
2937
+ "Avg position",
2938
+ fmtPos(t.current.position),
2939
+ fmtPos(t.previous.position),
2940
+ positionChange(t.change.rank_improvement)
2941
+ ]
2942
+ ]);
2943
+ const table = (title, rows) => rows.length === 0 ? null : sections(`**${title}**`, mdTable([
2944
+ r.dimension,
2945
+ "Clicks",
2946
+ "Δ clicks",
2947
+ "Impr.",
2948
+ "Δ impr.",
2949
+ "Pos.",
2950
+ "Δ pos."
2951
+ ], rows.map((row) => [
2952
+ label(row.key),
2953
+ fmtInt(row.current.clicks),
2954
+ fmtChange(row.current.clicks, row.previous.clicks),
2955
+ fmtInt(row.current.impressions),
2956
+ fmtSigned(row.change.impressions),
2957
+ fmtPos(row.current.position),
2958
+ positionChange(row.change.rank_improvement)
2959
+ ])));
2960
+ return sections(`**${r.site_url}: ${r.current.startDate} → ${r.current.endDate} vs ${r.previous.startDate} → ${r.previous.endDate}** (${r.compare_to === "year_over_year" ? "year over year" : "previous period"}, ${r.data_state} data)`, totals, table(`Top ${r.dimension} values now`, r.top), table("Top gainers (by clicks)", r.gainers), table("Top losers (by clicks)", r.losers), r.dimension !== "none" ? `_${fmtInt(r.compared_count)} ${r.dimension} values compared: ${fmtInt(r.new_count)} new, ${fmtInt(r.lost_count)} no longer appearing.${r.truncated ? " Row cap reached; the long tail is not included." : ""}_` : null);
2961
+ }
2962
+ });
2963
+ function positionChange(improvement) {
2964
+ if (improvement === null) return "–";
2965
+ if (improvement === 0) return "0";
2966
+ return improvement > 0 ? `↑${improvement.toFixed(1)}` : `↓${Math.abs(improvement).toFixed(1)}`;
2967
+ }
2968
+ //#endregion
2969
+ //#region src/core/analysis/decay.ts
2970
+ /**
2971
+ * Pages whose clicks fell steadily across consecutive windows: at least `minBaselineClicks` in the
2972
+ * first window, at least `minDeclinePct` lower in the last one, and falling in all but at most one
2973
+ * window-to-window step.
2974
+ */
2975
+ function findContentDecay(series, windows, opts) {
2976
+ const out = [];
2977
+ const requiredDeclines = Math.max(1, windows - 2);
2978
+ for (const [page, values] of series) {
2979
+ const metrics = Array.from({ length: windows }, (_, i) => toMetrics(values[i] ?? {
2980
+ clicks: 0,
2981
+ impressions: 0
2982
+ }));
2983
+ const clicks = metrics.map((m) => m.clicks);
2984
+ const baseline = clicks[0];
2985
+ const latest = clicks.at(-1);
2986
+ if (baseline < opts.minBaselineClicks) continue;
2987
+ const declinePct = (baseline - latest) / baseline * 100;
2988
+ if (declinePct < opts.minDeclinePct) continue;
2989
+ let declines = 0;
2990
+ for (let i = 1; i < clicks.length; i++) if (clicks[i] < clicks[i - 1]) declines++;
2991
+ if (declines < requiredDeclines) continue;
2992
+ const first = metrics[0];
2993
+ const last = metrics.at(-1);
2994
+ const positionDrop = first.position !== null && last.position !== null ? last.position - first.position : 0;
2995
+ const impressionDrop = first.impressions ? (first.impressions - last.impressions) / first.impressions : 0;
2996
+ const ctrDrop = first.ctr_pct ? (first.ctr_pct - last.ctr_pct) / first.ctr_pct : 0;
2997
+ let cause = "mixed";
2998
+ if (positionDrop >= 1.5) cause = "ranking_loss";
2999
+ else if (impressionDrop >= .2 && Math.abs(positionDrop) < 1) cause = "lower_demand";
3000
+ else if (ctrDrop >= .2 && Math.abs(positionDrop) < 1) cause = "lower_ctr";
3001
+ out.push({
3002
+ page,
3003
+ clicks,
3004
+ impressions: metrics.map((m) => m.impressions),
3005
+ position: metrics.map((m) => m.position),
3006
+ baseline_clicks: baseline,
3007
+ latest_clicks: latest,
3008
+ lost_clicks: baseline - latest,
3009
+ decline_pct: round(declinePct, 1),
3010
+ declines,
3011
+ likely_cause: cause
3012
+ });
3013
+ }
3014
+ return out.sort((a, b) => b.lost_clicks - a.lost_clicks);
3015
+ }
3016
+ //#endregion
3017
+ //#region src/core/tools/content-decay.ts
3018
+ const CAUSE_LABEL = {
3019
+ ranking_loss: "rankings dropped",
3020
+ lower_demand: "fewer impressions (demand/seasonality)",
3021
+ lower_ctr: "CTR fell (SERP/snippet change)",
3022
+ mixed: "mixed"
3023
+ };
3024
+ const contentDecay = defineTool({
3025
+ name: "content_decay",
3026
+ title: "Content decay",
3027
+ description: "Finds pages whose clicks declined steadily over consecutive windows (default 4 × 28 days), not just one bad week. For each page: clicks per window, total decline, position trend and a likely cause (ranking loss, lower demand, lower CTR).",
3028
+ input: z.object({
3029
+ site_url: siteUrlField,
3030
+ windows: z.number().int().min(3).max(8).default(4).describe("Number of consecutive windows to compare."),
3031
+ window_days: z.number().int().min(7).max(91).default(28).describe("Length of each window in days."),
3032
+ min_baseline_clicks: z.number().int().min(1).default(20).describe("Minimum clicks in the oldest window."),
3033
+ min_decline_pct: z.number().min(1).max(100).default(25).describe("Minimum drop from the oldest to the newest window, in percent."),
3034
+ exclude_queries: excludeQueriesField,
3035
+ limit: limitField(20)
3036
+ }),
3037
+ annotations: READ_ONLY,
3038
+ async run(ctx, input) {
3039
+ const property = await propertyFor(ctx, input.site_url);
3040
+ const windows = consecutiveWindows(await latestFinalDate(ctx, property.site_url, "web"), input.windows, input.window_days);
3041
+ const earliest = earliestAvailableDate(todayPT(ctx.now()));
3042
+ if (windows[0].startDate < earliest) throw new UserFacingError(`${input.windows} × ${input.window_days} days reaches back to ${windows[0].startDate}, beyond Search Console's ~16 months of data. Use fewer or shorter windows.`);
3043
+ const filters = input.exclude_queries ? [{
3044
+ dimension: "query",
3045
+ operator: "excludingRegex",
3046
+ expression: input.exclude_queries
3047
+ }] : void 0;
3048
+ const results = await Promise.all(windows.map((w) => fetchKeyed(ctx, property.site_url, w, "page", { filters })));
3049
+ const series = /* @__PURE__ */ new Map();
3050
+ results.forEach((result, i) => {
3051
+ for (const [page, metrics] of result.rows) {
3052
+ const url = page.split("#")[0];
3053
+ const list = series.get(url) ?? new Array(windows.length).fill(void 0);
3054
+ const prev = list[i];
3055
+ list[i] = prev ? mergeMetrics(prev, metrics) : metrics;
3056
+ series.set(url, list);
3057
+ }
3058
+ });
3059
+ const matches = findContentDecay(series, windows.length, {
3060
+ minBaselineClicks: input.min_baseline_clicks,
3061
+ minDeclinePct: input.min_decline_pct
3062
+ });
3063
+ return {
3064
+ site_url: property.site_url,
3065
+ windows,
3066
+ min_baseline_clicks: input.min_baseline_clicks,
3067
+ min_decline_pct: input.min_decline_pct,
3068
+ total_matches: matches.length,
3069
+ rows: matches.slice(0, input.limit),
3070
+ truncated: results.some((r) => r.truncated)
3071
+ };
3072
+ },
3073
+ format(r) {
3074
+ const host = displayHost(r.site_url);
3075
+ const span = `${r.windows[0].startDate} → ${r.windows.at(-1).endDate}, ${r.windows.length} windows`;
3076
+ if (r.total_matches === 0) return `No decaying pages on ${r.site_url} (${span}; ≥${r.min_baseline_clicks} clicks in the first window and ≥${r.min_decline_pct}% sustained decline).`;
3077
+ return sections(`**${fmtInt(r.total_matches)} decaying pages** on ${r.site_url} (${span})`, mdTable([
3078
+ "Page",
3079
+ "Clicks per window",
3080
+ "Decline",
3081
+ "Position",
3082
+ "Likely cause"
3083
+ ], r.rows.map((row) => [
3084
+ compactUrl(row.page, host),
3085
+ row.clicks.map(fmtInt).join(" → "),
3086
+ `-${fmtInt(row.lost_clicks)} (-${row.decline_pct}%)`,
3087
+ `${fmtPos(row.position[0] ?? null)} → ${fmtPos(row.position.at(-1) ?? null)}`,
3088
+ CAUSE_LABEL[row.likely_cause]
3089
+ ])), omittedNote(r.rows.length, r.total_matches, "Raise `limit` to see more."), r.truncated ? "_Row cap reached in at least one window; small pages may be missing._" : null);
3090
+ }
3091
+ });
3092
+ function mergeMetrics(a, b) {
3093
+ const impressions = a.impressions + b.impressions;
3094
+ return {
3095
+ clicks: a.clicks + b.clicks,
3096
+ impressions,
3097
+ position: impressions ? ((a.position ?? 0) * a.impressions + (b.position ?? 0) * b.impressions) / impressions : void 0
3098
+ };
3099
+ }
3100
+ //#endregion
3101
+ //#region src/core/tools/inspection.ts
3102
+ const urlField = z.string().url().describe("Full URL of the page to inspect (must belong to the property).");
3103
+ const optionalSiteField = z.string().optional().describe("Property that contains the URL. Optional: inferred from your properties when omitted.");
3104
+ /** Finds the most specific readable property that contains `url`. */
3105
+ async function propertyForUrl(gsc, url, siteUrl) {
3106
+ if (siteUrl) {
3107
+ const property = await resolveProperty(gsc, siteUrl);
3108
+ if (!propertyCoversUrl(property, url)) throw new UserFacingError(`${url} is not inside the property '${property.site_url}'.`);
3109
+ return property;
3110
+ }
3111
+ const properties = (await listProperties(gsc)).filter((p) => p.has_access && propertyCoversUrl(p, url));
3112
+ properties.sort((a, b) => Number(b.type === "url_prefix") - Number(a.type === "url_prefix") || b.site_url.length - a.site_url.length);
3113
+ const best = properties[0];
3114
+ if (!best) throw new UserFacingError(`None of your Search Console properties contains ${url}. Run list_sites to check.`, "not_found");
3115
+ return best;
3116
+ }
3117
+ function summarizeInspection(url, siteUrl, result) {
3118
+ const idx = result?.indexStatusResult ?? {};
3119
+ const google = idx.googleCanonical ?? null;
3120
+ const user = idx.userCanonical ?? null;
3121
+ const issues = (list) => (list ?? []).map((i) => `${i.severity ? `${i.severity}: ` : ""}${i.issueMessage ?? i.message ?? "issue"}`);
3122
+ return {
3123
+ url,
3124
+ site_url: siteUrl,
3125
+ verdict: idx.verdict ?? "VERDICT_UNSPECIFIED",
3126
+ coverage_state: idx.coverageState ?? null,
3127
+ indexing_state: idx.indexingState ?? null,
3128
+ robots_txt_state: idx.robotsTxtState ?? null,
3129
+ page_fetch_state: idx.pageFetchState ?? null,
3130
+ last_crawl_time: idx.lastCrawlTime ?? null,
3131
+ crawled_as: idx.crawledAs ?? null,
3132
+ google_canonical: google,
3133
+ user_canonical: user,
3134
+ canonical_mismatch: Boolean(google && user && google !== user),
3135
+ sitemaps: idx.sitemap ?? [],
3136
+ referring_urls: (idx.referringUrls ?? []).slice(0, 10),
3137
+ mobile_usability: result?.mobileUsabilityResult ? {
3138
+ verdict: result.mobileUsabilityResult.verdict ?? "VERDICT_UNSPECIFIED",
3139
+ issues: issues(result.mobileUsabilityResult.issues)
3140
+ } : null,
3141
+ rich_results: result?.richResultsResult ? {
3142
+ verdict: result.richResultsResult.verdict ?? "VERDICT_UNSPECIFIED",
3143
+ items: (result.richResultsResult.detectedItems ?? []).flatMap((d) => (d.items ?? [{}]).map((item) => ({
3144
+ type: d.richResultType ?? "unknown",
3145
+ name: item.name ?? null,
3146
+ issues: issues(item.issues)
3147
+ })))
3148
+ } : null,
3149
+ inspection_link: result?.inspectionResultLink ?? null
3150
+ };
3151
+ }
3152
+ async function inspectOne(ctx, url, siteUrl, languageCode) {
3153
+ const property = await propertyForUrl(ctx.gsc, url, siteUrl);
3154
+ const res = await ctx.gsc.inspectUrl(property.site_url, url, languageCode);
3155
+ return summarizeInspection(url, property.site_url, res.inspectionResult);
3156
+ }
3157
+ function formatInspection(s) {
3158
+ const lines = [
3159
+ `**URL Inspection** for ${s.url}`,
3160
+ `- Verdict: **${s.verdict}**${s.coverage_state ? ` — ${s.coverage_state}` : ""}`,
3161
+ `- Indexing: ${s.indexing_state ?? "unknown"} · robots.txt: ${s.robots_txt_state ?? "unknown"} · fetch: ${s.page_fetch_state ?? "unknown"}`,
3162
+ `- Last crawl: ${s.last_crawl_time ?? "never / unknown"}${s.crawled_as ? ` (${s.crawled_as.toLowerCase()} crawler)` : ""}`,
3163
+ `- Canonical — Google: ${s.google_canonical ?? "n/a"} · declared: ${s.user_canonical ?? "n/a"}${s.canonical_mismatch ? " ⚠️ **mismatch**" : ""}`
3164
+ ];
3165
+ if (s.sitemaps.length) lines.push(`- In sitemaps: ${s.sitemaps.join(", ")}`);
3166
+ if (s.rich_results) {
3167
+ const items = s.rich_results.items.map((i) => `${i.type}${i.name ? ` (${i.name})` : ""}${i.issues.length ? `: ${i.issues.join("; ")}` : ""}`).join(" · ");
3168
+ lines.push(`- Rich results: ${s.rich_results.verdict}${items ? ` — ${items}` : ""}`);
3169
+ }
3170
+ if (s.mobile_usability) lines.push(`- Mobile usability (deprecated report): ${s.mobile_usability.verdict}${s.mobile_usability.issues.length ? ` — ${s.mobile_usability.issues.join("; ")}` : ""}`);
3171
+ if (s.inspection_link) lines.push(`- Open in Search Console: ${s.inspection_link}`);
3172
+ lines.push("_URL Inspection reports the indexed version; it is not a live test._");
3173
+ return lines.join("\n");
3174
+ }
3175
+ const inspectUrl = defineTool({
3176
+ name: "inspect_url",
3177
+ title: "Inspect URL (index status)",
3178
+ description: "Shows Google's index status for one URL: verdict, coverage state, robots.txt and fetch status, last crawl, Google-selected vs declared canonical, sitemaps and rich results. Uses the URL Inspection API (quota: 2,000/day and 600/min per property).",
3179
+ input: z.object({
3180
+ url: urlField,
3181
+ site_url: optionalSiteField,
3182
+ language_code: z.string().default("en-US").describe("Language for issue messages (BCP-47).")
3183
+ }),
3184
+ annotations: READ_ONLY,
3185
+ async run(ctx, input) {
3186
+ return inspectOne(ctx, input.url, input.site_url, input.language_code);
3187
+ },
3188
+ format: formatInspection
3189
+ });
3190
+ async function mapWithConcurrency(items, concurrency, fn) {
3191
+ const results = new Array(items.length);
3192
+ let next = 0;
3193
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
3194
+ while (next < items.length) {
3195
+ const i = next++;
3196
+ results[i] = await fn(items[i]);
3197
+ }
3198
+ });
3199
+ await Promise.all(workers);
3200
+ return results;
3201
+ }
3202
+ const batchInspectUrls = defineTool({
3203
+ name: "batch_inspect_urls",
3204
+ title: "Inspect many URLs",
3205
+ description: "Runs URL Inspection on a list of URLs with limited concurrency and summarizes index status (verdicts, coverage states, canonical mismatches). Each URL costs one URL Inspection call (2,000/day and 600/min per property), so keep batches focused.",
3206
+ input: z.object({
3207
+ urls: z.array(z.string().url()).min(1).max(500).describe("URLs to inspect (the server caps the batch size; default cap 50)."),
3208
+ site_url: optionalSiteField,
3209
+ concurrency: z.number().int().min(1).max(10).default(5).describe("Parallel requests (max 10 ≈ the 600/min quota).")
3210
+ }),
3211
+ annotations: READ_ONLY,
3212
+ async run(ctx, input) {
3213
+ const urls = [...new Set(input.urls)];
3214
+ if (urls.length > ctx.config.batchInspectMax) throw new UserFacingError(`Too many URLs (${urls.length}). This server allows up to ${ctx.config.batchInspectMax} per batch (GSCLAW_BATCH_INSPECT_MAX) to protect the 2,000/day URL Inspection quota. Split the list.`);
3215
+ const outcomes = await mapWithConcurrency(urls, input.concurrency, async (url) => {
3216
+ try {
3217
+ return {
3218
+ ok: true,
3219
+ value: await inspectOne(ctx, url, input.site_url)
3220
+ };
3221
+ } catch (error) {
3222
+ return {
3223
+ ok: false,
3224
+ url,
3225
+ error: publicErrorMessage(error).message.split("\n")[0]
3226
+ };
3227
+ }
3228
+ });
3229
+ const results = outcomes.flatMap((o) => o.ok ? [o.value] : []);
3230
+ const errors = outcomes.flatMap((o) => o.ok ? [] : [{
3231
+ url: o.url,
3232
+ error: o.error
3233
+ }]);
3234
+ const count = (values) => values.reduce((acc, v) => {
3235
+ const k = v ?? "unknown";
3236
+ acc[k] = (acc[k] ?? 0) + 1;
3237
+ return acc;
3238
+ }, {});
3239
+ return {
3240
+ inspected: results.length,
3241
+ failed: errors.length,
3242
+ verdicts: count(results.map((r) => r.verdict)),
3243
+ coverage: count(results.map((r) => r.coverage_state)),
3244
+ canonical_mismatches: results.filter((r) => r.canonical_mismatch).length,
3245
+ results,
3246
+ errors
3247
+ };
3248
+ },
3249
+ format(r) {
3250
+ const host = r.results[0] ? displayHost(r.results[0].site_url) : void 0;
3251
+ const summary = Object.entries(r.coverage).sort((a, b) => b[1] - a[1]).map(([state, n]) => `${n} × ${state}`).join(" · ");
3252
+ const table = mdTable([
3253
+ "URL",
3254
+ "Verdict",
3255
+ "Coverage",
3256
+ "Last crawl",
3257
+ "Canonical"
3258
+ ], r.results.map((s) => [
3259
+ compactUrl(s.url, host),
3260
+ s.verdict,
3261
+ s.coverage_state,
3262
+ s.last_crawl_time?.slice(0, 10) ?? "—",
3263
+ s.canonical_mismatch ? `⚠️ Google chose ${compactUrl(s.google_canonical ?? "", host)}` : "ok"
3264
+ ]));
3265
+ return sections(`**Inspected ${r.inspected} URL(s)**${r.failed ? `, ${r.failed} failed` : ""}. ${summary}`, r.canonical_mismatches ? `⚠️ ${r.canonical_mismatches} URL(s) where Google picked a different canonical.` : null, table, r.errors.length ? `**Errors:**\n${r.errors.map((e) => `- ${e.url}: ${e.error}`).join("\n")}` : null);
3266
+ }
3267
+ });
3268
+ //#endregion
3269
+ //#region src/core/analysis/ctr-curve.ts
3270
+ /**
3271
+ * Approximate organic CTR by position (percent), compiled from public click-through-rate studies
3272
+ * of Google results. Real CTRs vary a lot by query intent, brand vs non-brand, device and SERP
3273
+ * features such as AI Overviews, which is why GSClaw prefers the site's own curve when it has
3274
+ * enough data.
3275
+ */
3276
+ const INDUSTRY_CTR_CURVE = [
3277
+ 27,
3278
+ 15,
3279
+ 11,
3280
+ 8,
3281
+ 6,
3282
+ 4.6,
3283
+ 3.6,
3284
+ 2.9,
3285
+ 2.4,
3286
+ 2,
3287
+ 1.6,
3288
+ 1.4,
3289
+ 1.2,
3290
+ 1.1,
3291
+ 1,
3292
+ .9,
3293
+ .8,
3294
+ .7,
3295
+ .65,
3296
+ .6
3297
+ ];
3298
+ const MAX_CURVE_POSITION = INDUSTRY_CTR_CURVE.length;
3299
+ function median(values) {
3300
+ const sorted = [...values].sort((a, b) => a - b);
3301
+ const mid = Math.floor(sorted.length / 2);
3302
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
3303
+ }
3304
+ /**
3305
+ * Builds a CTR-by-position curve. With `benchmark: 'site'`, each position bucket uses the median
3306
+ * CTR of the site's own rows (rows with at least `minImpressions`) when it has `minSamples` of
3307
+ * them, and the industry curve otherwise. The curve is forced to be non-increasing.
3308
+ */
3309
+ function buildCtrCurve(rows, opts) {
3310
+ const minImpressions = opts.minImpressions ?? 20;
3311
+ const minSamples = opts.minSamples ?? 8;
3312
+ const buckets = /* @__PURE__ */ new Map();
3313
+ if (opts.benchmark === "site") for (const r of rows) {
3314
+ if (r.impressions < minImpressions) continue;
3315
+ const bucket = Math.round(r.position);
3316
+ if (bucket < 1 || bucket > MAX_CURVE_POSITION) continue;
3317
+ const list = buckets.get(bucket) ?? [];
3318
+ list.push(r.clicks / r.impressions * 100);
3319
+ buckets.set(bucket, list);
3320
+ }
3321
+ const curve = [];
3322
+ for (let position = 1; position <= MAX_CURVE_POSITION; position++) {
3323
+ const samples = buckets.get(position) ?? [];
3324
+ const useSite = samples.length >= minSamples;
3325
+ let ctr = useSite ? median(samples) : INDUSTRY_CTR_CURVE[position - 1];
3326
+ const prev = curve.at(-1);
3327
+ if (prev && ctr > prev.ctr_pct) ctr = prev.ctr_pct;
3328
+ curve.push({
3329
+ position,
3330
+ ctr_pct: round(ctr, 2),
3331
+ source: useSite ? "site" : "industry",
3332
+ samples: samples.length
3333
+ });
3334
+ }
3335
+ return curve;
3336
+ }
3337
+ /** Expected CTR (percent) at a fractional position, interpolating linearly between buckets. */
3338
+ function expectedCtr(curve, position) {
3339
+ if (curve.length === 0) return 0;
3340
+ if (position <= 1) return curve[0].ctr_pct;
3341
+ if (position >= curve.length) return curve.at(-1).ctr_pct;
3342
+ const lower = Math.floor(position);
3343
+ const frac = position - lower;
3344
+ const a = curve[lower - 1].ctr_pct;
3345
+ return a + (curve[lower].ctr_pct - a) * frac;
3346
+ }
3347
+ //#endregion
3348
+ //#region src/core/analysis/opportunities.ts
3349
+ function groupByQuery(rows, mergeFragments) {
3350
+ const byQuery = /* @__PURE__ */ new Map();
3351
+ for (const row of rows) {
3352
+ const page = mergeFragments ? row.page.split("#")[0] : row.page;
3353
+ const pages = byQuery.get(row.query) ?? /* @__PURE__ */ new Map();
3354
+ const existing = pages.get(page);
3355
+ if (existing) {
3356
+ const impressions = existing.impressions + row.impressions;
3357
+ pages.set(page, {
3358
+ ...existing,
3359
+ clicks: existing.clicks + row.clicks,
3360
+ impressions,
3361
+ ctr: impressions ? (existing.clicks + row.clicks) / impressions : 0,
3362
+ position: impressions ? (existing.position * existing.impressions + row.position * row.impressions) / impressions : existing.position
3363
+ });
3364
+ } else pages.set(page, {
3365
+ ...row,
3366
+ page
3367
+ });
3368
+ byQuery.set(row.query, pages);
3369
+ }
3370
+ return byQuery;
3371
+ }
3372
+ function findStrikingDistance(rows, opts) {
3373
+ const targetCtr = expectedCtr(opts.curve, opts.targetPosition) / 100;
3374
+ const out = [];
3375
+ for (const [query, pages] of groupByQuery(rows, true)) {
3376
+ const sorted = [...pages.values()].sort((a, b) => b.impressions - a.impressions);
3377
+ const primary = sorted[0];
3378
+ if (primary.position < opts.minPosition || primary.position > opts.maxPosition) continue;
3379
+ if (primary.impressions < opts.minImpressions) continue;
3380
+ const metrics = toMetrics(primary);
3381
+ out.push({
3382
+ query,
3383
+ page: primary.page,
3384
+ ...metrics,
3385
+ other_pages: sorted.length - 1,
3386
+ potential_extra_clicks: Math.max(0, Math.round(primary.impressions * targetCtr - primary.clicks))
3387
+ });
3388
+ }
3389
+ return out.sort((a, b) => b.potential_extra_clicks - a.potential_extra_clicks || b.impressions - a.impressions);
3390
+ }
3391
+ function findCtrOpportunities(rows, opts) {
3392
+ const out = [];
3393
+ for (const row of rows) {
3394
+ if (row.impressions < opts.minImpressions || row.position > opts.maxPosition) continue;
3395
+ const expected = expectedCtr(opts.curve, row.position);
3396
+ const actual = row.clicks / row.impressions * 100;
3397
+ if (actual >= expected * opts.threshold) continue;
3398
+ out.push({
3399
+ key: row.key,
3400
+ ...toMetrics(row),
3401
+ expected_ctr_pct: round(expected, 2),
3402
+ ctr_gap_pp: round(actual - expected, 2),
3403
+ missed_clicks: Math.round(row.impressions * (expected / 100) - row.clicks)
3404
+ });
3405
+ }
3406
+ return out.sort((a, b) => b.missed_clicks - a.missed_clicks);
3407
+ }
3408
+ function findCannibalization(rows, opts) {
3409
+ const out = [];
3410
+ for (const [query, pages] of groupByQuery(rows, true)) {
3411
+ if (pages.size < 2) continue;
3412
+ const list = [...pages.values()];
3413
+ const impressions = list.reduce((s, r) => s + r.impressions, 0);
3414
+ if (impressions < opts.minImpressions) continue;
3415
+ const shares = list.map((r) => ({
3416
+ page: r.page,
3417
+ ...toMetrics(r),
3418
+ share_pct: round(r.impressions / impressions * 100, 1)
3419
+ })).sort((a, b) => b.impressions - a.impressions);
3420
+ const competing = shares.filter((s) => s.share_pct >= opts.minSharePct);
3421
+ if (competing.length < 2) continue;
3422
+ out.push({
3423
+ query,
3424
+ clicks: list.reduce((s, r) => s + r.clicks, 0),
3425
+ impressions,
3426
+ contested_impressions: impressions - shares[0].impressions,
3427
+ competing_pages: competing.length,
3428
+ pages: shares.slice(0, opts.maxPagesShown ?? 5)
3429
+ });
3430
+ }
3431
+ return out.sort((a, b) => b.contested_impressions - a.contested_impressions);
3432
+ }
3433
+ //#endregion
3434
+ //#region src/core/tools/opportunities.ts
3435
+ function toQueryPageRows(rows) {
3436
+ return rows.map((r) => ({
3437
+ query: r.keys?.[0] ?? "",
3438
+ page: r.keys?.[1] ?? "",
3439
+ clicks: r.clicks,
3440
+ impressions: r.impressions,
3441
+ ctr: r.ctr,
3442
+ position: r.position
3443
+ }));
3444
+ }
3445
+ async function queryPageRows(ctx, siteUrl, window, excludeQueries) {
3446
+ const { rows, truncated } = await fetchRows(ctx, siteUrl, window, ["query", "page"], {
3447
+ filters: withQueryExclusion(void 0, excludeQueries),
3448
+ dataState: window.dataState
3449
+ });
3450
+ return {
3451
+ rows: toQueryPageRows(rows),
3452
+ truncated
3453
+ };
3454
+ }
3455
+ const truncatedNote = (truncated) => truncated ? "_Row cap reached: only the top rows by clicks were analysed (raise GSCLAW_MAX_ROWS or shorten the date range)._" : null;
3456
+ const strikingDistanceKeywords = defineTool({
3457
+ name: "striking_distance_keywords",
3458
+ title: "Striking-distance keywords",
3459
+ description: "Finds queries ranking just off the top spots (default average position 8–20) with meaningful impressions: the cheapest wins in SEO. Returns the ranking page, position, impressions and an estimate of extra clicks if the page reached position 3 (based on the site’s own CTR curve).",
3460
+ input: z.object({
3461
+ site_url: siteUrlField,
3462
+ ...dateRangeFields,
3463
+ min_position: z.number().min(1).max(100).default(8),
3464
+ max_position: z.number().min(1).max(100).default(20),
3465
+ min_impressions: z.number().int().min(0).default(20).describe("Minimum impressions in the period."),
3466
+ target_position: z.number().min(1).max(10).default(3).describe("Position used to estimate potential clicks."),
3467
+ exclude_queries: excludeQueriesField,
3468
+ limit: limitField(25)
3469
+ }),
3470
+ annotations: READ_ONLY,
3471
+ async run(ctx, input) {
3472
+ const property = await propertyFor(ctx, input.site_url);
3473
+ const window = await resolveWindow(ctx, property, input);
3474
+ const { rows, truncated } = await queryPageRows(ctx, property.site_url, window, input.exclude_queries);
3475
+ const curve = buildCtrCurve(rows, { benchmark: "site" });
3476
+ const matches = findStrikingDistance(rows, {
3477
+ minPosition: input.min_position,
3478
+ maxPosition: input.max_position,
3479
+ minImpressions: input.min_impressions,
3480
+ targetPosition: input.target_position,
3481
+ curve
3482
+ });
3483
+ return {
3484
+ site_url: property.site_url,
3485
+ start_date: window.startDate,
3486
+ end_date: window.endDate,
3487
+ data_state: window.dataState,
3488
+ position_range: [input.min_position, input.max_position],
3489
+ min_impressions: input.min_impressions,
3490
+ target_position: input.target_position,
3491
+ expected_ctr_at_target_pct: curve[Math.round(input.target_position) - 1]?.ctr_pct ?? 0,
3492
+ total_matches: matches.length,
3493
+ rows: matches.slice(0, input.limit),
3494
+ truncated
3495
+ };
3496
+ },
3497
+ format(r) {
3498
+ const host = displayHost(r.site_url);
3499
+ if (r.total_matches === 0) return `No striking-distance queries for ${r.site_url} (position ${r.position_range[0]}–${r.position_range[1]}, ≥${r.min_impressions} impressions, ${r.start_date} → ${r.end_date}). Try a longer date range or lower min_impressions.`;
3500
+ return sections(`**${fmtInt(r.total_matches)} striking-distance queries** for ${r.site_url} (avg position ${r.position_range[0]}–${r.position_range[1]}, ≥${r.min_impressions} impressions)`, windowNote({
3501
+ startDate: r.start_date,
3502
+ endDate: r.end_date,
3503
+ dataState: r.data_state === "all" ? "all" : "final",
3504
+ label: ""
3505
+ }), mdTable([
3506
+ "Query",
3507
+ "Ranking page",
3508
+ "Pos.",
3509
+ "Impr.",
3510
+ "Clicks",
3511
+ "CTR",
3512
+ `+Clicks @#${r.target_position}`
3513
+ ], r.rows.map((row) => [
3514
+ row.query,
3515
+ compactUrl(row.page, host) + (row.other_pages ? ` (+${row.other_pages} other)` : ""),
3516
+ fmtPos(row.position),
3517
+ fmtInt(row.impressions),
3518
+ fmtInt(row.clicks),
3519
+ fmtPct(row.ctr_pct),
3520
+ `+${fmtInt(row.potential_extra_clicks)}`
3521
+ ])), omittedNote(r.rows.length, r.total_matches, "Raise `limit` to see more."), `_Potential clicks assume ${fmtPct(r.expected_ctr_at_target_pct)} CTR at position ${r.target_position} (your site's CTR curve where data allows, otherwise an industry average). "+N other" = more pages competing for the query; see keyword_cannibalization._`, truncatedNote(r.truncated));
3522
+ }
3523
+ });
3524
+ const ctrOpportunities = defineTool({
3525
+ name: "ctr_opportunities",
3526
+ title: "CTR opportunities",
3527
+ description: "Finds queries or pages with many impressions but a CTR well below what their position usually earns: candidates for better titles, meta descriptions and rich results. Compares against a CTR-by-position curve (the site’s own by default, falling back to an industry curve) and returns the curve used plus estimated missed clicks.",
3528
+ input: z.object({
3529
+ site_url: siteUrlField,
3530
+ ...dateRangeFields,
3531
+ dimension: z.enum(["query", "page"]).default("query"),
3532
+ max_position: z.number().min(1).max(20).default(10).describe("Only consider rows ranking at or above this average position."),
3533
+ min_impressions: z.number().int().min(0).default(100),
3534
+ benchmark: z.enum(["site", "industry"]).default("site").describe("site = your own median CTR per position (industry values fill gaps); industry = a generic curve from public CTR studies."),
3535
+ threshold_pct: z.number().min(1).max(100).default(60).describe("Flag rows whose CTR is below this % of the expected CTR (default 60%)."),
3536
+ exclude_queries: excludeQueriesField,
3537
+ limit: limitField(25)
3538
+ }),
3539
+ annotations: READ_ONLY,
3540
+ async run(ctx, input) {
3541
+ const property = await propertyFor(ctx, input.site_url);
3542
+ const window = await resolveWindow(ctx, property, input);
3543
+ const { rows, truncated } = await fetchRows(ctx, property.site_url, window, [input.dimension], {
3544
+ filters: withQueryExclusion(void 0, input.exclude_queries),
3545
+ dataState: window.dataState
3546
+ });
3547
+ const keyed = rows.map((r) => ({
3548
+ key: r.keys?.[0] ?? "",
3549
+ ...r
3550
+ }));
3551
+ const curve = buildCtrCurve(keyed, { benchmark: input.benchmark });
3552
+ const matches = findCtrOpportunities(keyed, {
3553
+ maxPosition: input.max_position,
3554
+ minImpressions: input.min_impressions,
3555
+ threshold: input.threshold_pct / 100,
3556
+ curve
3557
+ });
3558
+ return {
3559
+ site_url: property.site_url,
3560
+ start_date: window.startDate,
3561
+ end_date: window.endDate,
3562
+ data_state: window.dataState,
3563
+ dimension: input.dimension,
3564
+ benchmark: input.benchmark,
3565
+ threshold_pct: input.threshold_pct,
3566
+ min_impressions: input.min_impressions,
3567
+ max_position: input.max_position,
3568
+ curve: curve.slice(0, Math.max(10, Math.ceil(input.max_position))),
3569
+ total_matches: matches.length,
3570
+ rows: matches.slice(0, input.limit),
3571
+ truncated
3572
+ };
3573
+ },
3574
+ format(r) {
3575
+ const host = displayHost(r.site_url);
3576
+ const curve = r.curve.map((p) => `#${p.position}: ${fmtPct(p.ctr_pct)}${p.source === "industry" ? "*" : ""}`).join(" · ");
3577
+ if (r.total_matches === 0) return sections(`No CTR opportunities for ${r.site_url}: no ${r.dimension} with ≥${r.min_impressions} impressions at position ≤${r.max_position} has a CTR below ${r.threshold_pct}% of the expected value.`, `_Benchmark curve: ${curve}_`);
3578
+ return sections(`**${fmtInt(r.total_matches)} ${r.dimension === "query" ? "queries" : "pages"} with below-expected CTR** for ${r.site_url} (CTR < ${r.threshold_pct}% of the ${r.benchmark} benchmark for their position)`, windowNote({
3579
+ startDate: r.start_date,
3580
+ endDate: r.end_date,
3581
+ dataState: r.data_state === "all" ? "all" : "final",
3582
+ label: ""
3583
+ }), mdTable([
3584
+ r.dimension === "page" ? "Page" : "Query",
3585
+ "Pos.",
3586
+ "Impr.",
3587
+ "Clicks",
3588
+ "CTR",
3589
+ "Expected",
3590
+ "Missed clicks"
3591
+ ], r.rows.map((row) => [
3592
+ r.dimension === "page" ? compactUrl(row.key, host) : row.key,
3593
+ fmtPos(row.position),
3594
+ fmtInt(row.impressions),
3595
+ fmtInt(row.clicks),
3596
+ fmtPct(row.ctr_pct),
3597
+ fmtPct(row.expected_ctr_pct),
3598
+ fmtInt(row.missed_clicks)
3599
+ ])), omittedNote(r.rows.length, r.total_matches, "Raise `limit` to see more."), `_Benchmark CTR by position (${r.benchmark}; * = industry fallback): ${curve}. Branded queries inflate CTR at top positions; use exclude_queries for a non-brand view._`, truncatedNote(r.truncated));
3600
+ }
3601
+ });
3602
+ const keywordCannibalization = defineTool({
3603
+ name: "keyword_cannibalization",
3604
+ title: "Keyword cannibalization",
3605
+ description: "Finds queries where two or more of the site's pages each get a meaningful share of impressions, so they compete with each other. Returns each page's impression share, clicks and position. URL #fragments are merged into their page.",
3606
+ input: z.object({
3607
+ site_url: siteUrlField,
3608
+ ...dateRangeFields,
3609
+ min_impressions: z.number().int().min(0).default(50).describe("Minimum total impressions for the query."),
3610
+ min_share_pct: z.number().min(1).max(50).default(10).describe("A page counts as competing if it has at least this share of the query’s impressions."),
3611
+ exclude_queries: excludeQueriesField,
3612
+ limit: limitField(20)
3613
+ }),
3614
+ annotations: READ_ONLY,
3615
+ async run(ctx, input) {
3616
+ const property = await propertyFor(ctx, input.site_url);
3617
+ const window = await resolveWindow(ctx, property, input);
3618
+ const { rows, truncated } = await queryPageRows(ctx, property.site_url, window, input.exclude_queries);
3619
+ const matches = findCannibalization(rows, {
3620
+ minImpressions: input.min_impressions,
3621
+ minSharePct: input.min_share_pct
3622
+ });
3623
+ return {
3624
+ site_url: property.site_url,
3625
+ start_date: window.startDate,
3626
+ end_date: window.endDate,
3627
+ data_state: window.dataState,
3628
+ min_impressions: input.min_impressions,
3629
+ min_share_pct: input.min_share_pct,
3630
+ total_matches: matches.length,
3631
+ rows: matches.slice(0, input.limit),
3632
+ truncated
3633
+ };
3634
+ },
3635
+ format(r) {
3636
+ const host = displayHost(r.site_url);
3637
+ if (r.total_matches === 0) return `No cannibalization found for ${r.site_url}: no query with ≥${r.min_impressions} impressions has 2+ pages each above ${r.min_share_pct}% share (${r.start_date} → ${r.end_date}).`;
3638
+ const blocks = r.rows.map((q) => sections(`**"${q.query}"** — ${fmtInt(q.impressions)} impr., ${fmtInt(q.clicks)} clicks, ${q.competing_pages} competing pages`, mdTable([
3639
+ "Page",
3640
+ "Share",
3641
+ "Impr.",
3642
+ "Clicks",
3643
+ "Pos."
3644
+ ], q.pages.map((p) => [
3645
+ compactUrl(p.page, host),
3646
+ fmtPct(p.share_pct),
3647
+ fmtInt(p.impressions),
3648
+ fmtInt(p.clicks),
3649
+ fmtPos(p.position)
3650
+ ]))));
3651
+ return sections(`**${fmtInt(r.total_matches)} cannibalized queries** for ${r.site_url} (sorted by impressions not going to the top page)`, windowNote({
3652
+ startDate: r.start_date,
3653
+ endDate: r.end_date,
3654
+ dataState: r.data_state === "all" ? "all" : "final",
3655
+ label: ""
3656
+ }), ...blocks, omittedNote(r.rows.length, r.total_matches, "Raise `limit` to see more."), truncatedNote(r.truncated));
3657
+ }
3658
+ });
3659
+ //#endregion
3660
+ //#region src/core/tools/page-report.ts
3661
+ /** Turns a path like '/pricing' into the page URL Search Console reports, using the page's own data. */
3662
+ async function resolvePageUrl(ctx, property, page, window) {
3663
+ const direct = absolutePageUrl(property, page);
3664
+ if (direct) return direct;
3665
+ if (!page.startsWith("/")) throw new UserFacingError(`'${page}' is not a URL or a path starting with '/'.`);
3666
+ const { rows } = await fetchRows(ctx, property.site_url, window, ["page"], {
3667
+ filters: [{
3668
+ dimension: "page",
3669
+ operator: "contains",
3670
+ expression: page
3671
+ }],
3672
+ maxRows: 1e3
3673
+ });
3674
+ const candidates = rows.map((r) => ({
3675
+ url: r.keys?.[0] ?? "",
3676
+ impressions: r.impressions
3677
+ })).filter((c) => {
3678
+ try {
3679
+ return new URL(c.url).pathname === page;
3680
+ } catch {
3681
+ return false;
3682
+ }
3683
+ }).sort((a, b) => b.impressions - a.impressions);
3684
+ if (!candidates[0]) throw new UserFacingError(`No page with path '${page}' has Search data on ${property.site_url} in this period. Pass the full URL (e.g. https://example.com${page}).`, "not_found");
3685
+ return candidates[0].url;
3686
+ }
3687
+ const pageReport = defineTool({
3688
+ name: "page_report",
3689
+ title: "Page report",
3690
+ description: "One-call report for a single page: clicks/impressions/CTR/position vs the previous period, a daily trend, the page's top queries with deltas, and its URL Inspection status (index state, canonical, last crawl).",
3691
+ input: z.object({
3692
+ page: z.string().min(1).describe("Full page URL, or a path like '/pricing' (then site_url is required)."),
3693
+ site_url: z.string().optional().describe("Property. Optional when page is a full URL."),
3694
+ ...dateRangeFields,
3695
+ compare_to: z.enum(COMPARE_MODES).default("previous_period"),
3696
+ top_queries: limitField(15, 100),
3697
+ include_inspection: z.boolean().default(true).describe("Also run URL Inspection (uses 1 of 2,000 daily inspections).")
3698
+ }),
3699
+ annotations: READ_ONLY,
3700
+ async run(ctx, input) {
3701
+ let property;
3702
+ if (/^https?:\/\//i.test(input.page)) property = await propertyForUrl(ctx.gsc, input.page, input.site_url);
3703
+ else if (input.site_url) property = await propertyFor(ctx, input.site_url);
3704
+ else throw new UserFacingError("Pass site_url when page is a path.");
3705
+ const current = await resolveWindow(ctx, property, input);
3706
+ const previous = comparisonRange(current, input.compare_to);
3707
+ const pageUrl = await resolvePageUrl(ctx, property, input.page, current);
3708
+ const opts = {
3709
+ filters: [{
3710
+ dimension: "page",
3711
+ operator: "equals",
3712
+ expression: pageUrl
3713
+ }],
3714
+ dataState: current.dataState
3715
+ };
3716
+ const inspection = input.include_inspection ? inspectOne(ctx, pageUrl, property.site_url).then((value) => ({
3717
+ value,
3718
+ error: null
3719
+ }), (error) => ({
3720
+ value: null,
3721
+ error: publicErrorMessage(error).message.split("\n")[0]
3722
+ })) : Promise.resolve({
3723
+ value: null,
3724
+ error: null
3725
+ });
3726
+ const [curTotals, prevTotals, trendRows, curQueries, prevQueries, inspected] = await Promise.all([
3727
+ fetchTotals(ctx, property.site_url, current, opts),
3728
+ fetchTotals(ctx, property.site_url, previous, opts),
3729
+ fetchRows(ctx, property.site_url, current, ["date"], opts),
3730
+ fetchKeyed(ctx, property.site_url, current, "query", {
3731
+ ...opts,
3732
+ maxRows: 5e3
3733
+ }),
3734
+ fetchKeyed(ctx, property.site_url, previous, "query", {
3735
+ ...opts,
3736
+ maxRows: 5e3
3737
+ }),
3738
+ inspection
3739
+ ]);
3740
+ const cur = toMetrics(curTotals);
3741
+ const prev = toMetrics(prevTotals);
3742
+ const queries = compareRows(curQueries.rows, prevQueries.rows);
3743
+ return {
3744
+ site_url: property.site_url,
3745
+ page: pageUrl,
3746
+ current: {
3747
+ startDate: current.startDate,
3748
+ endDate: current.endDate
3749
+ },
3750
+ previous,
3751
+ compare_to: input.compare_to,
3752
+ data_state: current.dataState,
3753
+ totals: {
3754
+ current: cur,
3755
+ previous: prev,
3756
+ change: metricChange(cur, prev)
3757
+ },
3758
+ trend: trendRows.rows.map((r) => ({
3759
+ date: r.keys?.[0] ?? "",
3760
+ ...toMetrics(r)
3761
+ })).sort((a, b) => a.date.localeCompare(b.date)),
3762
+ top_queries: topByCurrentClicks(queries, input.top_queries),
3763
+ query_count: curQueries.rows.size,
3764
+ inspection: inspected.value,
3765
+ inspection_error: inspected.error
3766
+ };
3767
+ },
3768
+ format(r) {
3769
+ const t = r.totals;
3770
+ const weekly = summarizeTrend(r.trend);
3771
+ return sections(`**Page report:** ${r.page}`, `_${r.current.startDate} → ${r.current.endDate} vs ${r.previous.startDate} → ${r.previous.endDate} (${r.compare_to === "year_over_year" ? "year over year" : "previous period"}, ${r.data_state} data)._`, mdTable([
3772
+ "Metric",
3773
+ "Current",
3774
+ "Previous",
3775
+ "Change"
3776
+ ], [
3777
+ [
3778
+ "Clicks",
3779
+ fmtInt(t.current.clicks),
3780
+ fmtInt(t.previous.clicks),
3781
+ fmtChange(t.current.clicks, t.previous.clicks)
3782
+ ],
3783
+ [
3784
+ "Impressions",
3785
+ fmtInt(t.current.impressions),
3786
+ fmtInt(t.previous.impressions),
3787
+ fmtChange(t.current.impressions, t.previous.impressions)
3788
+ ],
3789
+ [
3790
+ "CTR",
3791
+ fmtPct(t.current.ctr_pct),
3792
+ fmtPct(t.previous.ctr_pct),
3793
+ `${t.change.ctr_pp >= 0 ? "+" : ""}${t.change.ctr_pp.toFixed(2)} pp`
3794
+ ],
3795
+ [
3796
+ "Avg position",
3797
+ fmtPos(t.current.position),
3798
+ fmtPos(t.previous.position),
3799
+ positionChange(t.change.rank_improvement)
3800
+ ]
3801
+ ]), weekly ? `**Trend (clicks per 7 days, oldest → newest):** ${weekly}` : null, r.top_queries.length ? sections(`**Top queries** (${fmtInt(r.query_count)} total)`, mdTable([
3802
+ "Query",
3803
+ "Clicks",
3804
+ "Δ clicks",
3805
+ "Impr.",
3806
+ "CTR",
3807
+ "Pos.",
3808
+ "Δ pos."
3809
+ ], r.top_queries.map((q) => [
3810
+ q.key,
3811
+ fmtInt(q.current.clicks),
3812
+ fmtChange(q.current.clicks, q.previous.clicks),
3813
+ fmtInt(q.current.impressions),
3814
+ fmtPct(q.current.ctr_pct),
3815
+ fmtPos(q.current.position),
3816
+ positionChange(q.change.rank_improvement)
3817
+ ]))) : "_No query data for this page in the period._", r.inspection ? formatInspection(r.inspection) : r.inspection_error ? `_URL Inspection failed: ${r.inspection_error}_` : null);
3818
+ }
3819
+ });
3820
+ function summarizeTrend(trend) {
3821
+ if (trend.length < 7) return trend.length ? trend.map((d) => fmtInt(d.clicks)).join(" → ") : null;
3822
+ const buckets = [];
3823
+ for (let end = trend.length; end > 0; end -= 7) {
3824
+ const slice = trend.slice(Math.max(0, end - 7), end);
3825
+ if (slice.length === 7) buckets.unshift(slice.reduce((s, d) => s + d.clicks, 0));
3826
+ }
3827
+ return buckets.map(fmtInt).join(" → ");
3828
+ }
3829
+ //#endregion
3830
+ //#region src/core/tools/search-analytics.ts
3831
+ const SORT_KEYS = [
3832
+ "clicks",
3833
+ "impressions",
3834
+ "ctr",
3835
+ "position"
3836
+ ];
3837
+ const searchAnalytics = defineTool({
3838
+ name: "search_analytics",
3839
+ title: "Search performance (raw query)",
3840
+ description: "Queries Search Console performance data (clicks, impressions, CTR, average position) grouped by any dimensions, with filters (equals, contains, regex…), search type and aggregation type. Auto-paginates up to row_limit. Use for custom breakdowns; prefer the analysis tools (compare_periods, striking_distance_keywords, …) for common questions. Note: the API exposes only top rows (max ~50K/day/search type), and anonymized rare queries are omitted from query rows but included in totals.",
3841
+ input: z.object({
3842
+ site_url: siteUrlField,
3843
+ ...dateRangeFields,
3844
+ dimensions: z.array(z.enum(DIMENSIONS)).max(5).default(["query"]).describe("Group by. searchAppearance cannot be combined with other dimensions. hour requires recent dates (last ~10 days) and switches to hourly data. Use [] for property totals."),
3845
+ filters: filtersField,
3846
+ search_type: searchTypeField,
3847
+ aggregation_type: z.enum(AGGREGATION_TYPES).default("auto").describe("auto (default), byPage, byProperty (not with page dimension/filter), byNewsShowcasePanel."),
3848
+ row_limit: z.number().int().min(1).max(5e5).default(1e3).describe("Rows to fetch from Google (auto-paginated in 25K pages; capped by the server’s GSCLAW_MAX_ROWS)."),
3849
+ order_by: z.enum(SORT_KEYS).default("clicks").describe("Sort shown rows by this metric (position sorts ascending)."),
3850
+ limit: limitField(25),
3851
+ include_totals: z.boolean().default(true).describe("Also fetch property totals for the same filters (one extra request).")
3852
+ }),
3853
+ annotations: READ_ONLY,
3854
+ async run(ctx, input) {
3855
+ if (input.dimensions.includes("searchAppearance") && input.dimensions.length > 1) throw new UserFacingError("searchAppearance cannot be combined with other dimensions. Query searchAppearance alone first, then filter on one appearance and group by other dimensions.");
3856
+ const property = await propertyFor(ctx, input.site_url);
3857
+ const window = await resolveWindow(ctx, property, input, input.search_type);
3858
+ const hourly = input.dimensions.includes("hour");
3859
+ const base = {
3860
+ startDate: window.startDate,
3861
+ endDate: window.endDate,
3862
+ type: input.search_type,
3863
+ dimensionFilterGroups: toFilterGroups(input.filters),
3864
+ aggregationType: input.aggregation_type,
3865
+ dataState: hourly ? "hourly_all" : window.dataState
3866
+ };
3867
+ const maxRows = Math.min(input.row_limit, ctx.config.maxRows);
3868
+ const [result, totalsRes] = await Promise.all([input.dimensions.length > 0 ? ctx.gsc.searchAnalyticsAll(property.site_url, {
3869
+ ...base,
3870
+ dimensions: input.dimensions
3871
+ }, maxRows) : Promise.resolve(null), input.include_totals || input.dimensions.length === 0 ? ctx.gsc.searchAnalytics(property.site_url, {
3872
+ ...base,
3873
+ rowLimit: 1
3874
+ }) : Promise.resolve(null)]);
3875
+ const rows = (result?.rows ?? []).map((r) => ({
3876
+ keys: Object.fromEntries(input.dimensions.map((d, i) => [d, r.keys?.[i] ?? ""])),
3877
+ ...toMetrics(r)
3878
+ }));
3879
+ sortRows(rows, input.order_by);
3880
+ const totalRow = totalsRes?.rows?.[0];
3881
+ const meta = result?.metadata ?? totalsRes?.metadata;
3882
+ return {
3883
+ site_url: property.site_url,
3884
+ start_date: window.startDate,
3885
+ end_date: window.endDate,
3886
+ data_state: base.dataState ?? "final",
3887
+ search_type: input.search_type,
3888
+ dimensions: input.dimensions,
3889
+ totals: totalRow ? toMetrics(totalRow) : totalsRes ? toMetrics({
3890
+ clicks: 0,
3891
+ impressions: 0
3892
+ }) : null,
3893
+ rows: rows.slice(0, input.limit),
3894
+ total_rows: rows.length,
3895
+ truncated: result?.truncated ?? false,
3896
+ first_incomplete_date: meta?.first_incomplete_date ?? meta?.firstIncompleteDate
3897
+ };
3898
+ },
3899
+ format(r) {
3900
+ const host = displayHost(r.site_url);
3901
+ const totals = r.totals ? `**Totals:** ${fmtInt(r.totals.clicks)} clicks · ${fmtInt(r.totals.impressions)} impressions · CTR ${fmtPct(r.totals.ctr_pct)} · avg position ${fmtPos(r.totals.position)}` : null;
3902
+ const table = r.dimensions.length > 0 ? mdTable([
3903
+ ...r.dimensions,
3904
+ "Clicks",
3905
+ "Impr.",
3906
+ "CTR",
3907
+ "Pos."
3908
+ ], r.rows.map((row) => [
3909
+ ...r.dimensions.map((d) => d === "page" ? compactUrl(row.keys[d] ?? "", host) : row.keys[d]),
3910
+ fmtInt(row.clicks),
3911
+ fmtInt(row.impressions),
3912
+ fmtPct(row.ctr_pct),
3913
+ fmtPos(row.position)
3914
+ ])) : null;
3915
+ return sections(`**Search analytics** for ${r.site_url} (${r.search_type}${r.dimensions.length ? `, by ${r.dimensions.join(" × ")}` : ""})`, windowNote({
3916
+ startDate: r.start_date,
3917
+ endDate: r.end_date,
3918
+ dataState: r.data_state === "final" ? "final" : "all",
3919
+ label: ""
3920
+ }), totals, table, omittedNote(r.rows.length, r.total_rows, "Raise `limit` to see more, or add filters."), r.truncated ? `_Row cap reached (${fmtInt(r.total_rows)} rows fetched); more rows may exist. Raise row_limit or narrow filters/dates._` : null, r.first_incomplete_date ? `_Data from ${r.first_incomplete_date} onward is incomplete._` : null);
3921
+ }
3922
+ });
3923
+ function sortRows(rows, key) {
3924
+ const value = (r) => key === "ctr" ? r.ctr_pct : key === "position" ? -(r.position ?? Infinity) : r[key];
3925
+ rows.sort((a, b) => value(b) - value(a));
3926
+ }
3927
+ //#endregion
3928
+ //#region src/core/tools/sites.ts
3929
+ const listSites = defineTool({
3930
+ name: "list_sites",
3931
+ title: "List Search Console properties",
3932
+ description: "Lists every Search Console property this server can access, with its type (domain 'sc-domain:' vs URL-prefix) and permission level. Call this first to find the exact property to pass as site_url.",
3933
+ input: z.object({}),
3934
+ annotations: READ_ONLY,
3935
+ async run(ctx) {
3936
+ return {
3937
+ identity: ctx.gsc.identity.email,
3938
+ properties: await listProperties(ctx.gsc)
3939
+ };
3940
+ },
3941
+ format(result) {
3942
+ if (result.properties.length === 0) return `No Search Console properties are visible to ${result.identity}. Add it as a user on your properties: Search Console → Settings → Users and permissions → Add user.`;
3943
+ const table = mdTable([
3944
+ "Property",
3945
+ "Type",
3946
+ "Permission"
3947
+ ], result.properties.map((p) => [
3948
+ p.site_url,
3949
+ p.type === "domain" ? "domain" : "URL prefix",
3950
+ p.has_access ? p.permission_level : "unverified (no data)"
3951
+ ]));
3952
+ return sections(`**${result.properties.length} properties** visible to ${result.identity}:`, table);
3953
+ }
3954
+ });
3955
+ //#endregion
3956
+ //#region src/core/tools/sitemaps.ts
3957
+ const num = (v) => v === void 0 ? 0 : Number(v) || 0;
3958
+ function summarizeSitemap(s) {
3959
+ const contents = (s.contents ?? []).map((c) => ({
3960
+ type: c.type ?? "web",
3961
+ submitted: num(c.submitted)
3962
+ }));
3963
+ return {
3964
+ path: s.path,
3965
+ type: s.type ?? null,
3966
+ is_index: Boolean(s.isSitemapsIndex),
3967
+ is_pending: Boolean(s.isPending),
3968
+ last_submitted: s.lastSubmitted ?? null,
3969
+ last_downloaded: s.lastDownloaded ?? null,
3970
+ errors: num(s.errors),
3971
+ warnings: num(s.warnings),
3972
+ submitted: contents.reduce((sum, c) => sum + c.submitted, 0),
3973
+ contents
3974
+ };
3975
+ }
3976
+ const INDEXED_NOTE = "_Google no longer reports per-sitemap indexed counts (the API field is deprecated). Use inspect_url / batch_inspect_urls to check index status of specific URLs._";
3977
+ function sitemapTable(sitemaps) {
3978
+ return mdTable([
3979
+ "Sitemap",
3980
+ "Type",
3981
+ "Submitted URLs",
3982
+ "Errors",
3983
+ "Warnings",
3984
+ "Last read",
3985
+ "Status"
3986
+ ], sitemaps.map((s) => [
3987
+ s.path,
3988
+ s.is_index ? "index" : s.type ?? "—",
3989
+ fmtInt(s.submitted),
3990
+ s.errors,
3991
+ s.warnings,
3992
+ s.last_downloaded?.slice(0, 10) ?? "never",
3993
+ s.is_pending ? "pending" : s.errors > 0 ? "has errors" : "ok"
3994
+ ]));
3995
+ }
3996
+ const listSitemaps = defineTool({
3997
+ name: "list_sitemaps",
3998
+ title: "List sitemaps",
3999
+ description: "Lists sitemaps submitted for a property with status, errors, warnings, submitted URL counts and when Google last read them. Pass sitemap_index to list the children of a sitemap index.",
4000
+ input: z.object({
4001
+ site_url: siteUrlField,
4002
+ sitemap_index: z.string().url().optional().describe("Full URL of a sitemap index to list its child sitemaps.")
4003
+ }),
4004
+ annotations: READ_ONLY,
4005
+ async run(ctx, input) {
4006
+ const property = await propertyFor(ctx, input.site_url);
4007
+ const sitemaps = await ctx.gsc.listSitemaps(property.site_url, input.sitemap_index);
4008
+ return {
4009
+ site_url: property.site_url,
4010
+ sitemaps: sitemaps.map(summarizeSitemap)
4011
+ };
4012
+ },
4013
+ format(r) {
4014
+ if (r.sitemaps.length === 0) return `No sitemaps are submitted for ${r.site_url}. Submit one in Search Console → Sitemaps (or with submit_sitemap if writes are enabled).`;
4015
+ const withErrors = r.sitemaps.filter((s) => s.errors > 0).length;
4016
+ return sections(`**${r.sitemaps.length} sitemap(s)** for ${r.site_url}${withErrors ? ` — ${withErrors} with errors` : ""}:`, sitemapTable(r.sitemaps), INDEXED_NOTE);
4017
+ }
4018
+ });
4019
+ const getSitemap = defineTool({
4020
+ name: "get_sitemap",
4021
+ title: "Get sitemap details",
4022
+ description: "Shows one sitemap: status, errors, warnings, submitted URL counts per content type and last download time.",
4023
+ input: z.object({
4024
+ site_url: siteUrlField,
4025
+ sitemap_url: z.string().url().describe("Full sitemap URL exactly as submitted (see list_sitemaps).")
4026
+ }),
4027
+ annotations: READ_ONLY,
4028
+ async run(ctx, input) {
4029
+ const property = await propertyFor(ctx, input.site_url);
4030
+ return {
4031
+ site_url: property.site_url,
4032
+ sitemap: summarizeSitemap(await ctx.gsc.getSitemap(property.site_url, input.sitemap_url))
4033
+ };
4034
+ },
4035
+ format(r) {
4036
+ const s = r.sitemap;
4037
+ const contents = s.contents.map((c) => `${c.type}: ${fmtInt(c.submitted)}`).join(" · ");
4038
+ return sections(`**Sitemap** ${s.path}`, [
4039
+ `- Type: ${s.is_index ? "sitemap index" : s.type ?? "unknown"}${s.is_pending ? " (processing pending)" : ""}`,
4040
+ `- Submitted URLs: ${fmtInt(s.submitted)}${contents ? ` (${contents})` : ""}`,
4041
+ `- Errors: ${s.errors} · Warnings: ${s.warnings}`,
4042
+ `- Last submitted: ${s.last_submitted ?? "unknown"} · Last read by Google: ${s.last_downloaded ?? "never"}`
4043
+ ].join("\n"), INDEXED_NOTE);
4044
+ }
4045
+ });
4046
+ const WRITE_NOTE = "Requires GSCLAW_ALLOW_WRITES=true and Full (not Restricted) permission on the property.";
4047
+ //#endregion
4048
+ //#region src/core/tools/registry.ts
4049
+ /** Every tool, in the order clients list them. */
4050
+ const ALL_TOOLS = [
4051
+ listSites,
4052
+ searchAnalytics,
4053
+ comparePeriods,
4054
+ strikingDistanceKeywords,
4055
+ ctrOpportunities,
4056
+ keywordCannibalization,
4057
+ contentDecay,
4058
+ pageReport,
4059
+ inspectUrl,
4060
+ batchInspectUrls,
4061
+ listSitemaps,
4062
+ getSitemap,
4063
+ defineTool({
4064
+ name: "submit_sitemap",
4065
+ title: "Submit sitemap (writes to Search Console)",
4066
+ description: `MUTATES STATE: submits (or resubmits) a sitemap URL to Search Console for the property, asking Google to crawl it. ${WRITE_NOTE}`,
4067
+ input: z.object({
4068
+ site_url: siteUrlField,
4069
+ sitemap_url: z.string().url().describe("Full sitemap URL to submit. Must be on the property.")
4070
+ }),
4071
+ annotations: {
4072
+ readOnlyHint: false,
4073
+ destructiveHint: false,
4074
+ idempotentHint: true,
4075
+ openWorldHint: false
4076
+ },
4077
+ write: true,
4078
+ async run(ctx, input) {
4079
+ const property = await propertyFor(ctx, input.site_url);
4080
+ if (property.permission_level === "restricted") throw new UserFacingError(`Restricted users cannot submit sitemaps. Give ${ctx.gsc.identity.email} Full permission on '${property.site_url}' in Search Console → Settings → Users and permissions.`, "permission");
4081
+ await ctx.gsc.submitSitemap(property.site_url, input.sitemap_url);
4082
+ return {
4083
+ site_url: property.site_url,
4084
+ sitemap_url: input.sitemap_url,
4085
+ submitted: true
4086
+ };
4087
+ },
4088
+ format(r) {
4089
+ return `Submitted ${r.sitemap_url} for ${r.site_url}. Google will fetch it shortly; check status later with get_sitemap.`;
4090
+ }
4091
+ }),
4092
+ defineTool({
4093
+ name: "delete_sitemap",
4094
+ title: "Delete sitemap (writes to Search Console)",
4095
+ description: `MUTATES STATE (destructive): removes a submitted sitemap from Search Console for the property. This does not delete the file from your site. ${WRITE_NOTE}`,
4096
+ input: z.object({
4097
+ site_url: siteUrlField,
4098
+ sitemap_url: z.string().url().describe("Full sitemap URL to remove, exactly as listed by list_sitemaps.")
4099
+ }),
4100
+ annotations: {
4101
+ readOnlyHint: false,
4102
+ destructiveHint: true,
4103
+ idempotentHint: true,
4104
+ openWorldHint: false
4105
+ },
4106
+ write: true,
4107
+ async run(ctx, input) {
4108
+ const property = await propertyFor(ctx, input.site_url);
4109
+ await ctx.gsc.deleteSitemap(property.site_url, input.sitemap_url);
4110
+ return {
4111
+ site_url: property.site_url,
4112
+ sitemap_url: input.sitemap_url,
4113
+ deleted: true
4114
+ };
4115
+ },
4116
+ format(r) {
4117
+ return `Removed ${r.sitemap_url} from Search Console for ${r.site_url}. The file on your site is unchanged.`;
4118
+ }
4119
+ })
4120
+ ];
4121
+ /** Tools available for a request: write tools only when enabled and the credentials can write. */
4122
+ function availableTools(opts) {
4123
+ return ALL_TOOLS.filter((tool) => !tool.write || opts.allowWrites && opts.canWrite);
4124
+ }
4125
+ //#endregion
4126
+ //#region src/core/runtime.ts
4127
+ function createRuntime(config, opts = {}) {
4128
+ const logger = createLogger({
4129
+ level: config.logLevel,
4130
+ write: opts.logWriter,
4131
+ base: opts.logFields
4132
+ });
4133
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
4134
+ const cache = createResponseCache();
4135
+ const userTokens = /* @__PURE__ */ new Map();
4136
+ const sessionWith = (gsc, principal) => {
4137
+ const canWrite = gsc.canWrite && (!principal || principal.scopes.includes("gsc:write"));
4138
+ return {
4139
+ gsc,
4140
+ ctx: {
4141
+ gsc,
4142
+ config,
4143
+ logger,
4144
+ now
4145
+ },
4146
+ tools: availableTools({
4147
+ allowWrites: config.allowWrites,
4148
+ canWrite
4149
+ })
4150
+ };
4151
+ };
4152
+ let owner;
4153
+ if (config.authMode === "service_account") {
4154
+ if (!config.serviceAccount) throw new Error("Service-account mode requires a service account key.");
4155
+ const scopes = [config.allowWrites ? SCOPE_READWRITE : SCOPE_READONLY];
4156
+ owner = sessionWith(new GscClient({
4157
+ tokenSource: new ServiceAccountTokenSource(config.serviceAccount, scopes, { fetch: opts.fetch }),
4158
+ logger,
4159
+ fetch: opts.fetch,
4160
+ cache,
4161
+ cacheTtlMs: config.cacheTtlSeconds * 1e3
4162
+ }));
4163
+ }
4164
+ return {
4165
+ config,
4166
+ logger,
4167
+ activity: new ActivityLog(),
4168
+ owner,
4169
+ sessionFor(principal) {
4170
+ if (config.authMode === "service_account") return sessionWith(owner.gsc, principal);
4171
+ const google = principal.google;
4172
+ const oauth = config.googleOAuth;
4173
+ if (principal.kind !== "user" || !google || !principal.email || !oauth) throw new Error("OAuth mode requires a signed-in Google user.");
4174
+ const tokenSource = new UserTokenSource(principal.email, google.scope, {
4175
+ sub: principal.sub,
4176
+ accessToken: google.accessToken,
4177
+ expiresAt: google.expiresAt,
4178
+ refreshToken: google.refreshToken,
4179
+ cache: userTokens,
4180
+ now: () => now().getTime(),
4181
+ refresh: async (refreshToken) => {
4182
+ const fresh = await refreshGoogleToken(oauth, {
4183
+ refreshToken,
4184
+ fetch: opts.fetch ?? ((input, init) => fetch(input, init)),
4185
+ nowSeconds: Math.floor(now().getTime() / 1e3)
4186
+ });
4187
+ return {
4188
+ accessToken: fresh.accessToken,
4189
+ expiresAt: fresh.expiresAt
4190
+ };
4191
+ }
4192
+ });
4193
+ return sessionWith(new GscClient({
4194
+ tokenSource,
4195
+ logger,
4196
+ fetch: opts.fetch,
4197
+ cache,
4198
+ cacheTtlMs: config.cacheTtlSeconds * 1e3
4199
+ }), principal);
4200
+ }
4201
+ };
4202
+ }
4203
+ //#endregion
4204
+ //#region src/core/app.ts
4205
+ const MAX_BODY_BYTES = 1e6;
4206
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
4207
+ "localhost",
4208
+ "127.0.0.1",
4209
+ "[::1]",
4210
+ "::1"
4211
+ ]);
4212
+ const MCP_CORS_HEADERS = {
4213
+ "access-control-allow-methods": "POST, GET, DELETE, OPTIONS",
4214
+ "access-control-allow-headers": "authorization, content-type, accept, mcp-protocol-version, mcp-method, mcp-name, mcp-session-id, last-event-id",
4215
+ "access-control-expose-headers": "www-authenticate, mcp-protocol-version, mcp-session-id",
4216
+ "access-control-max-age": "600"
4217
+ };
4218
+ function json(body, status = 200, headers = {}) {
4219
+ return new Response(JSON.stringify(body), {
4220
+ status,
4221
+ headers: {
4222
+ "content-type": "application/json",
4223
+ "cache-control": "no-store",
4224
+ ...headers
4225
+ }
4226
+ });
4227
+ }
4228
+ function html(body, status = 200) {
4229
+ return new Response(body, {
4230
+ status,
4231
+ headers: HTML_SECURITY_HEADERS
4232
+ });
4233
+ }
4234
+ /** Malformed percent-encoding must look like any other wrong secret (404), not a 500. */
4235
+ function safeDecode(segment) {
4236
+ try {
4237
+ return decodeURIComponent(segment);
4238
+ } catch {
4239
+ return segment;
4240
+ }
4241
+ }
4242
+ /** Deployments are private endpoints; ask crawlers not to index them. */
4243
+ const robots = () => new Response("User-agent: *\nDisallow: /\n", { headers: {
4244
+ "content-type": "text/plain; charset=utf-8",
4245
+ "cache-control": "public, max-age=86400"
4246
+ } });
4247
+ const notFound = () => new Response("Not found", {
4248
+ status: 404,
4249
+ headers: { "content-type": "text/plain" }
4250
+ });
4251
+ function hostnameOf(hostHeader) {
4252
+ if (!hostHeader) return null;
4253
+ try {
4254
+ return new URL(`http://${hostHeader}`).hostname;
4255
+ } catch {
4256
+ return null;
4257
+ }
4258
+ }
4259
+ /** Builds the web-standard request handler shared by every adapter. */
4260
+ function createApp(env, platform) {
4261
+ const loaded = loadConfig(env, { transport: "http" });
4262
+ if (!loaded.ok) {
4263
+ createLogger({
4264
+ write: platform.logWriter,
4265
+ base: { platform: platform.name }
4266
+ }).error("configuration invalid; serving setup page only", { issues: loaded.errors.map((e) => `${e.variable ?? ""} ${e.message}`.trim()) });
4267
+ return {
4268
+ setupErrors: loaded.errors,
4269
+ warnings: [],
4270
+ runtime: null,
4271
+ async fetch(request) {
4272
+ const path = new URL(request.url).pathname;
4273
+ if (path === "/healthz") return json({
4274
+ status: "setup_required",
4275
+ version: VERSION
4276
+ }, 503);
4277
+ if (path === "/robots.txt") return robots();
4278
+ if (path === "/" && (request.method === "GET" || request.method === "HEAD")) return html(setupPage(loaded.errors), 503);
4279
+ return json({
4280
+ error: "setup_required",
4281
+ error_description: "This GSClaw deployment is not configured."
4282
+ }, 503);
4283
+ }
4284
+ };
4285
+ }
4286
+ const { config } = loaded;
4287
+ const runtime = createRuntime(config, {
4288
+ fetch: platform.fetch,
4289
+ logWriter: platform.logWriter,
4290
+ now: platform.now,
4291
+ logFields: { platform: platform.name }
4292
+ });
4293
+ const { logger } = runtime;
4294
+ for (const warning of loaded.warnings) logger.warn(warning);
4295
+ const now = platform.now ?? (() => /* @__PURE__ */ new Date());
4296
+ const oauth = createOAuthServer({
4297
+ config,
4298
+ sealer: createSealer(config.sealingSecret, () => now().getTime()),
4299
+ logger,
4300
+ fetch: platform.fetch ?? ((input, init) => fetch(input, init)),
4301
+ now
4302
+ });
4303
+ const limiter = new RateLimiter(config.rateLimitPerMinute);
4304
+ const publicOrigin = config.publicBaseUrl ? new URL(config.publicBaseUrl).origin : null;
4305
+ /**
4306
+ * Public base URL for OAuth metadata and token audiences: PUBLIC_BASE_URL (or the platform's
4307
+ * detected URL), else the forwarded or request origin.
4308
+ */
4309
+ const baseUrl = (request, url) => {
4310
+ if (config.publicBaseUrl) return config.publicBaseUrl;
4311
+ const proto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
4312
+ const host = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
4313
+ if (proto && host && /^https?$/.test(proto)) return `${proto}://${host}`;
4314
+ return url.origin;
4315
+ };
4316
+ const dashboardApi = config.dashboard ? createDashboardApi({
4317
+ config,
4318
+ runtime,
4319
+ oauth,
4320
+ logger,
4321
+ platform: platform.name,
4322
+ fetch: platform.fetch,
4323
+ now: platform.now,
4324
+ baseUrl
4325
+ }) : null;
4326
+ const mcp = createMcpHandler(({ authInfo }) => {
4327
+ const principal = authInfo?.extra?.principal;
4328
+ const session = runtime.sessionFor(principal);
4329
+ return buildMcpServer({
4330
+ tools: session.tools,
4331
+ ctx: session.ctx,
4332
+ logger,
4333
+ activity: runtime.activity,
4334
+ client: typeof authInfo?.extra?.client === "string" ? authInfo.extra.client : "unknown",
4335
+ user: principal.kind === "user" ? principal.email : void 0
4336
+ });
4337
+ }, {
4338
+ legacy: "stateless",
4339
+ responseMode: "auto",
4340
+ onerror: (error) => logger.debug("mcp transport error", { error })
4341
+ });
4342
+ const originAllowed = (origin, requestUrl) => {
4343
+ if (origin === requestUrl.origin || origin === publicOrigin) return true;
4344
+ if (config.allowedOrigins.includes(origin)) return true;
4345
+ if (platform.localOnly) {
4346
+ const host = hostnameOf(origin.replace(/^https?:\/\//, ""));
4347
+ return host !== null && LOOPBACK_HOSTS.has(host);
4348
+ }
4349
+ return false;
4350
+ };
4351
+ const corsHeaders = (origin) => origin ? {
4352
+ "access-control-allow-origin": origin,
4353
+ vary: "Origin",
4354
+ ...MCP_CORS_HEADERS
4355
+ } : {};
4356
+ /**
4357
+ * Resolves the caller of /mcp. Service-account mode: the static access token, the secret path, or
4358
+ * a GSClaw-issued OAuth token (owner sign-in). OAuth mode: a GSClaw-issued token for a Google user.
4359
+ */
4360
+ async function authenticate(request, path, base) {
4361
+ if (path !== "/mcp") {
4362
+ const secret = path.slice(5);
4363
+ if (config.authMode !== "service_account" || !config.secretPath || secret.includes("/") || !await timingSafeEqual(safeDecode(secret), config.accessToken)) return notFound();
4364
+ return {
4365
+ ...OWNER,
4366
+ via: "secret_path"
4367
+ };
4368
+ }
4369
+ const header = request.headers.get("authorization") ?? "";
4370
+ const token = /^Bearer\s+(.+)$/i.exec(header)?.[1]?.trim();
4371
+ if (token) {
4372
+ if (config.authMode === "service_account" && await timingSafeEqual(token, config.accessToken)) return OWNER;
4373
+ const principal = await oauth.verifyAccessToken(token, base);
4374
+ if (principal) return principal;
4375
+ }
4376
+ return json({
4377
+ error: "invalid_token",
4378
+ error_description: `Missing or invalid credentials. ${config.authMode === "oauth" ? "Sign in with OAuth (your MCP client does this automatically)." : "Send 'Authorization: Bearer <GSCLAW_ACCESS_TOKEN>' or sign in with OAuth."}`
4379
+ }, 401, { "www-authenticate": oauth.challenge(base, token ? "invalid_token" : void 0) });
4380
+ }
4381
+ async function handleMcp(request, url, path, log) {
4382
+ const origin = request.headers.get("origin");
4383
+ if (origin && !originAllowed(origin, url)) {
4384
+ log.warn("rejected request from disallowed origin", { origin });
4385
+ return json({
4386
+ error: "forbidden",
4387
+ error_description: "Origin not allowed. Add it to GSCLAW_ALLOWED_ORIGINS."
4388
+ }, 403);
4389
+ }
4390
+ if (platform.localOnly) {
4391
+ const host = hostnameOf(request.headers.get("host"));
4392
+ if (!host || !LOOPBACK_HOSTS.has(host)) return json({
4393
+ error: "forbidden",
4394
+ error_description: "Invalid Host header."
4395
+ }, 403);
4396
+ }
4397
+ if (request.method === "OPTIONS") return new Response(null, {
4398
+ status: 204,
4399
+ headers: corsHeaders(origin)
4400
+ });
4401
+ const principal = await authenticate(request, path, baseUrl(request, url));
4402
+ if (principal instanceof Response) {
4403
+ for (const [k, v] of Object.entries(corsHeaders(origin))) principal.headers.set(k, v);
4404
+ return principal;
4405
+ }
4406
+ const limit = limiter.check(principal.sub);
4407
+ if (!limit.ok) return json({
4408
+ error: "rate_limited",
4409
+ error_description: "Too many requests. Slow down and retry."
4410
+ }, 429, {
4411
+ "retry-after": String(limit.retryAfterSeconds),
4412
+ ...corsHeaders(origin)
4413
+ });
4414
+ if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) return json({ error: "payload_too_large" }, 413);
4415
+ const body = request.method === "POST" ? await request.text() : void 0;
4416
+ if (body && body.length > MAX_BODY_BYTES) return json({ error: "payload_too_large" }, 413);
4417
+ const headers = new Headers(request.headers);
4418
+ headers.delete("authorization");
4419
+ headers.delete("cookie");
4420
+ const clean = new Request(new URL("/mcp", url), {
4421
+ method: request.method,
4422
+ headers,
4423
+ body
4424
+ });
4425
+ const response = await mcp.fetch(clean, { authInfo: {
4426
+ token: "redacted",
4427
+ clientId: principal.clientId ?? principal.sub,
4428
+ scopes: principal.scopes,
4429
+ extra: {
4430
+ principal,
4431
+ client: principal.clientName ?? clientLabel(request.headers.get("user-agent"))
4432
+ }
4433
+ } });
4434
+ const out = new Response(response.body, response);
4435
+ out.headers.set("cache-control", "no-store");
4436
+ for (const [k, v] of Object.entries(corsHeaders(origin))) out.headers.set(k, v);
4437
+ return out;
4438
+ }
4439
+ async function route(request, log) {
4440
+ const url = new URL(request.url);
4441
+ const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : url.pathname;
4442
+ if (path === "/healthz") return json({
4443
+ status: "ok",
4444
+ version: VERSION
4445
+ });
4446
+ if (path === "/robots.txt") return robots();
4447
+ if (path === "/mcp" || path.startsWith("/mcp/")) return handleMcp(request, url, path, log);
4448
+ const oauthResponse = await oauth.handle(request, url, path, baseUrl(request, url));
4449
+ if (oauthResponse) return oauthResponse;
4450
+ const isRead = request.method === "GET" || request.method === "HEAD";
4451
+ if (path === "/" && isRead) return dashboardApi ? new Response(dashboardShell(), { headers: DASHBOARD_HEADERS }) : html(runningPage());
4452
+ if (path === "/api" || path.startsWith("/api/")) return dashboardApi ? dashboardApi.handle(request, url, path) : notFound();
4453
+ if (isRead && platform.serveStatic) {
4454
+ const file = await platform.serveStatic(request);
4455
+ if (file) return file;
4456
+ }
4457
+ return notFound();
4458
+ }
4459
+ return {
4460
+ setupErrors: [],
4461
+ warnings: loaded.warnings,
4462
+ runtime,
4463
+ async fetch(request) {
4464
+ const started = Date.now();
4465
+ const url = new URL(request.url);
4466
+ const logPath = url.pathname.startsWith("/mcp/") ? "/mcp/<secret>" : url.pathname;
4467
+ const log = logger.child({
4468
+ method: request.method,
4469
+ path: logPath
4470
+ });
4471
+ let response;
4472
+ try {
4473
+ response = await route(request, log);
4474
+ } catch (error) {
4475
+ log.error("unhandled error", { error });
4476
+ response = json({ error: "internal_error" }, 500);
4477
+ }
4478
+ if (logPath !== "/healthz") log.info("request", {
4479
+ status: response.status,
4480
+ ms: Date.now() - started
4481
+ });
4482
+ return response;
4483
+ }
4484
+ };
4485
+ }
4486
+ //#endregion
4487
+ export { VERSION as a, stderrWriter as c, REPO_URL as i, randomToken as l, createRuntime as n, formatConfigErrors as o, buildMcpServer as r, loadConfig as s, createApp as t };
4488
+
4489
+ //# sourceMappingURL=app-DdGjQNXR.js.map