pagesight 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,685 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import {
4
+ type GscSite,
5
+ type GscSitemap,
6
+ getSite,
7
+ getSitemap,
8
+ type InspectionResult,
9
+ inspectUrl,
10
+ listSitemaps,
11
+ listSites,
12
+ querySearchAnalytics,
13
+ type SearchAnalyticsFilter,
14
+ type SearchAnalyticsResponse,
15
+ } from "../lib/gsc.js";
16
+ import { fetchSitemap, type InspectionSummary, inspectSingle, sampleUrls } from "../lib/sitemap.js";
17
+
18
+ // ── Inspect formatters ──
19
+
20
+ function formatInspection(url: string, siteUrl: string, r: InspectionResult): string {
21
+ const idx = r.indexStatusResult;
22
+ const lines: string[] = [
23
+ `=== URL Inspection: ${url} ===`,
24
+ `Property: ${siteUrl}`,
25
+ "",
26
+ "--- Index Status ---",
27
+ "",
28
+ `Verdict: ${idx.verdict}`,
29
+ `Coverage: ${idx.coverageState}`,
30
+ `Page fetch: ${idx.pageFetchState}`,
31
+ `Robots.txt: ${idx.robotsTxtState}`,
32
+ `Indexing: ${idx.indexingState}`,
33
+ `Crawled as: ${idx.crawledAs ?? "unknown"}`,
34
+ ];
35
+
36
+ if (idx.lastCrawlTime) lines.push(`Last crawled: ${idx.lastCrawlTime}`);
37
+ if (idx.userCanonical) lines.push(`Your canonical: ${idx.userCanonical}`);
38
+ if (idx.googleCanonical) lines.push(`Google's canonical: ${idx.googleCanonical}`);
39
+
40
+ if (idx.userCanonical && idx.googleCanonical && idx.userCanonical !== idx.googleCanonical) {
41
+ lines.push(`\n⚠ CANONICAL MISMATCH: You declared "${idx.userCanonical}" but Google chose "${idx.googleCanonical}"`);
42
+ }
43
+
44
+ if (idx.sitemap && idx.sitemap.length > 0) {
45
+ lines.push(`\nSitemaps: ${idx.sitemap.join(", ")}`);
46
+ }
47
+
48
+ if (idx.referringUrls && idx.referringUrls.length > 0) {
49
+ lines.push(`\nReferring URLs: ${idx.referringUrls.join(", ")}`);
50
+ }
51
+
52
+ if (idx.verdict !== "PASS") {
53
+ const gscUrl = `https://search.google.com/search-console/inspect?resource_id=${encodeURIComponent(siteUrl)}&id=${encodeURIComponent(url)}`;
54
+ lines.push("", "→ This page is not indexed. Request indexing manually in Google Search Console:", ` ${gscUrl}`);
55
+ }
56
+
57
+ // Rich Results
58
+ if (r.richResultsResult) {
59
+ lines.push("", "--- Rich Results ---", "");
60
+ lines.push(`Verdict: ${r.richResultsResult.verdict}`);
61
+
62
+ for (const item of r.richResultsResult.detectedItems ?? []) {
63
+ lines.push(`\nType: ${item.richResultType}`);
64
+ for (const instance of item.items ?? []) {
65
+ if (instance.name) lines.push(` Name: ${instance.name}`);
66
+ const issues = instance.issues ?? [];
67
+ if (issues.length === 0) {
68
+ lines.push(" Status: PASS");
69
+ } else {
70
+ for (const issue of issues) {
71
+ lines.push(` ${issue.severity}: ${issue.issueMessage}`);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ // Mobile Usability (deprecated but still returned)
79
+ if (r.mobileUsabilityResult) {
80
+ lines.push("", "--- Mobile Usability (deprecated) ---", "");
81
+ lines.push(`Verdict: ${r.mobileUsabilityResult.verdict}`);
82
+ if (r.mobileUsabilityResult.issues) {
83
+ for (const issue of r.mobileUsabilityResult.issues) {
84
+ lines.push(` ${issue.issueType}${issue.message ? `: ${issue.message}` : ""}`);
85
+ }
86
+ }
87
+ }
88
+
89
+ // Inspection link
90
+ if (r.inspectionResultLink) {
91
+ lines.push("", `Full report: ${r.inspectionResultLink}`);
92
+ }
93
+
94
+ return lines.join("\n");
95
+ }
96
+
97
+ // ── Sample-inspect formatters ──
98
+
99
+ function humanizeState(state: string): string {
100
+ const map: Record<string, string> = {
101
+ PAGE_FETCH_STATE_UNSPECIFIED: "not yet crawled",
102
+ ROBOTS_TXT_STATE_UNSPECIFIED: "not yet checked",
103
+ INDEXING_STATE_UNSPECIFIED: "not yet determined",
104
+ };
105
+ return map[state] ?? state;
106
+ }
107
+
108
+ function formatSampleResults(
109
+ siteUrl: string,
110
+ sitemapUrl: string,
111
+ totalUrls: number,
112
+ results: InspectionSummary[],
113
+ ): string {
114
+ const lines: string[] = [
115
+ `=== Sample Inspection: ${siteUrl} ===`,
116
+ `Sitemap: ${sitemapUrl} (${totalUrls.toLocaleString()} URLs)`,
117
+ `Sampled: ${results.length}`,
118
+ "",
119
+ ];
120
+
121
+ // Summary
122
+ const verdictCounts: Record<string, number> = {};
123
+ const coverageCounts: Record<string, number> = {};
124
+ const fetchCounts: Record<string, number> = {};
125
+ let indexed = 0;
126
+ let errors = 0;
127
+
128
+ for (const r of results) {
129
+ if (r.error) {
130
+ errors++;
131
+ continue;
132
+ }
133
+ verdictCounts[r.verdict] = (verdictCounts[r.verdict] ?? 0) + 1;
134
+ coverageCounts[r.coverageState] = (coverageCounts[r.coverageState] ?? 0) + 1;
135
+ fetchCounts[r.pageFetchState] = (fetchCounts[r.pageFetchState] ?? 0) + 1;
136
+ if (r.verdict === "PASS") indexed++;
137
+ }
138
+
139
+ const inspected = results.length - errors;
140
+ lines.push("--- Summary ---", "");
141
+ lines.push(`Indexed: ${indexed}/${inspected}`);
142
+
143
+ if (indexed < inspected) {
144
+ lines.push(`Not indexed: ${inspected - indexed}/${inspected}`);
145
+ for (const [state, count] of Object.entries(coverageCounts)) {
146
+ if (state !== "Submitted and indexed" && state !== "Indexing allowed") {
147
+ lines.push(` ${state}: ${count}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ // Page fetch issues
153
+ const fetchIssues = Object.entries(fetchCounts).filter(([s]) => s !== "SUCCESSFUL");
154
+ if (fetchIssues.length > 0) {
155
+ lines.push("");
156
+ lines.push("Page fetch issues:");
157
+ for (const [state, count] of fetchIssues) {
158
+ lines.push(` ${humanizeState(state)}: ${count}`);
159
+ }
160
+ }
161
+
162
+ if (errors > 0) {
163
+ lines.push(`\nInspection errors: ${errors}`);
164
+ }
165
+
166
+ // Individual results
167
+ lines.push("", "--- Details ---", "");
168
+ for (let i = 0; i < results.length; i++) {
169
+ const r = results[i];
170
+ lines.push(`${i + 1}. ${r.url}`);
171
+ if (r.error) {
172
+ lines.push(` Error: ${r.error}`);
173
+ } else {
174
+ lines.push(` Verdict: ${r.verdict}`);
175
+ lines.push(` Coverage: ${r.coverageState}`);
176
+ lines.push(` Page fetch: ${humanizeState(r.pageFetchState)}`);
177
+ if (r.robotsTxtState !== "ALLOWED") lines.push(` Robots.txt: ${humanizeState(r.robotsTxtState)}`);
178
+ if (r.indexingState !== "INDEXING_ALLOWED") lines.push(` Indexing: ${humanizeState(r.indexingState)}`);
179
+ if (r.lastCrawlTime) lines.push(` Last crawled: ${r.lastCrawlTime}`);
180
+ if (r.googleCanonical && r.googleCanonical !== r.url) {
181
+ lines.push(` Google canonical: ${r.googleCanonical}`);
182
+ }
183
+ }
184
+ lines.push("");
185
+ }
186
+
187
+ return lines.join("\n");
188
+ }
189
+
190
+ // ── Sitemaps formatters ──
191
+
192
+ function formatSites(sites: GscSite[]): string {
193
+ if (sites.length === 0) return "No Search Console properties found.";
194
+
195
+ const lines: string[] = [`=== GSC Properties (${sites.length}) ===`, ""];
196
+ for (const site of sites) {
197
+ lines.push(`${site.siteUrl} (${site.permissionLevel})`);
198
+ }
199
+ return lines.join("\n");
200
+ }
201
+
202
+ function formatSite(site: GscSite): string {
203
+ return [`=== Site: ${site.siteUrl} ===`, "", `Permission: ${site.permissionLevel}`].join("\n");
204
+ }
205
+
206
+ function formatSitemapDetail(sm: GscSitemap): string {
207
+ const lines: string[] = [`=== Sitemap: ${sm.path} ===`, ""];
208
+ if (sm.type) lines.push(`Type: ${sm.type}`);
209
+ if (sm.lastSubmitted) lines.push(`Submitted: ${sm.lastSubmitted}`);
210
+ if (sm.lastDownloaded) lines.push(`Downloaded: ${sm.lastDownloaded}`);
211
+ lines.push(`Pending: ${sm.isPending}`);
212
+ lines.push(`Index: ${sm.isSitemapsIndex}`);
213
+ if (sm.warnings) lines.push(`Warnings: ${sm.warnings}`);
214
+ if (sm.errors) lines.push(`Errors: ${sm.errors}`);
215
+ if (sm.contents && sm.contents.length > 0) {
216
+ lines.push("", "Contents:");
217
+ for (const c of sm.contents) {
218
+ lines.push(` ${c.type}: ${c.submitted ?? "?"} submitted, ${c.indexed ?? "?"} indexed`);
219
+ }
220
+ }
221
+ return lines.join("\n");
222
+ }
223
+
224
+ function formatSitemaps(siteUrl: string, sitemaps: GscSitemap[]): string {
225
+ if (sitemaps.length === 0) return `No sitemaps found for ${siteUrl}.`;
226
+
227
+ const lines: string[] = [`=== Sitemaps: ${siteUrl} (${sitemaps.length}) ===`, ""];
228
+
229
+ for (const sm of sitemaps) {
230
+ lines.push(`${sm.path}`);
231
+ if (sm.type) lines.push(` Type: ${sm.type}`);
232
+ if (sm.lastSubmitted) lines.push(` Submitted: ${sm.lastSubmitted}`);
233
+ if (sm.lastDownloaded) lines.push(` Downloaded: ${sm.lastDownloaded}`);
234
+ lines.push(` Pending: ${sm.isPending}`);
235
+ lines.push(` Index: ${sm.isSitemapsIndex}`);
236
+ if (sm.warnings) lines.push(` Warnings: ${sm.warnings}`);
237
+ if (sm.errors) lines.push(` Errors: ${sm.errors}`);
238
+ if (sm.contents) {
239
+ for (const c of sm.contents) {
240
+ lines.push(` ${c.type}: ${c.submitted ?? "?"} submitted, ${c.indexed ?? "?"} indexed`);
241
+ }
242
+ }
243
+ lines.push("");
244
+ }
245
+
246
+ return lines.join("\n").trimEnd();
247
+ }
248
+
249
+ // ── Performance formatters ──
250
+
251
+ interface Totals {
252
+ clicks: number;
253
+ impressions: number;
254
+ ctr: number;
255
+ position: number;
256
+ }
257
+
258
+ function computeTotals(rows: SearchAnalyticsResponse["rows"]): Totals {
259
+ const r = rows ?? [];
260
+ const clicks = r.reduce((sum, row) => sum + row.clicks, 0);
261
+ const impressions = r.reduce((sum, row) => sum + row.impressions, 0);
262
+ const ctr = impressions > 0 ? clicks / impressions : 0;
263
+ const position = impressions > 0 ? r.reduce((sum, row) => sum + row.position * row.impressions, 0) / impressions : 0;
264
+ return { clicks, impressions, ctr, position };
265
+ }
266
+
267
+ function pctChange(current: number, previous: number): string {
268
+ if (previous === 0) return current > 0 ? `+${current} (new)` : "0%";
269
+ const change = ((current - previous) / previous) * 100;
270
+ return `${change > 0 ? "+" : ""}${change.toFixed(1)}%`;
271
+ }
272
+
273
+ function formatPerformance(
274
+ siteUrl: string,
275
+ result: SearchAnalyticsResponse,
276
+ dimensions: string[],
277
+ startDate: string,
278
+ endDate: string,
279
+ ): string {
280
+ const rows = result.rows ?? [];
281
+ const lines: string[] = [
282
+ `=== Search Performance: ${siteUrl} ===`,
283
+ `Period: ${startDate} to ${endDate}`,
284
+ `Dimensions: ${dimensions.join(", ")}`,
285
+ `Aggregation: ${result.responseAggregationType ?? "auto"}`,
286
+ `Results: ${rows.length}`,
287
+ "",
288
+ ];
289
+
290
+ if (result.metadata) {
291
+ if (result.metadata.first_incomplete_date)
292
+ lines.push(`Data incomplete from: ${result.metadata.first_incomplete_date}`);
293
+ if (result.metadata.first_incomplete_hour)
294
+ lines.push(`Hourly data incomplete from: ${result.metadata.first_incomplete_hour}`);
295
+ lines.push("");
296
+ }
297
+
298
+ if (rows.length === 0) {
299
+ lines.push("No data found for this period and filters.");
300
+ return lines.join("\n");
301
+ }
302
+
303
+ const totals = computeTotals(rows);
304
+
305
+ lines.push(
306
+ `--- Summary (${rows.length} rows returned) ---`,
307
+ "",
308
+ `Clicks: ${totals.clicks.toLocaleString()}`,
309
+ `Impressions: ${totals.impressions.toLocaleString()}`,
310
+ `Avg CTR: ${(totals.ctr * 100).toFixed(1)}%`,
311
+ `Avg Position: ${totals.position.toFixed(1)}`,
312
+ "",
313
+ "--- Top Results ---",
314
+ "",
315
+ );
316
+
317
+ const top = rows.slice(0, 25);
318
+ for (const row of top) {
319
+ const keys = row.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
320
+ lines.push(`${keys}`);
321
+ lines.push(
322
+ ` Clicks: ${row.clicks} | Impressions: ${row.impressions} | CTR: ${(row.ctr * 100).toFixed(1)}% | Position: ${row.position.toFixed(1)}`,
323
+ );
324
+ }
325
+
326
+ if (rows.length > 25) {
327
+ lines.push("", `... and ${rows.length - 25} more rows`);
328
+ }
329
+
330
+ return lines.join("\n");
331
+ }
332
+
333
+ function formatComparison(
334
+ siteUrl: string,
335
+ current: SearchAnalyticsResponse,
336
+ previous: SearchAnalyticsResponse,
337
+ dimensions: string[],
338
+ currentStart: string,
339
+ currentEnd: string,
340
+ previousStart: string,
341
+ previousEnd: string,
342
+ ): string {
343
+ const curRows = current.rows ?? [];
344
+ const prevRows = previous.rows ?? [];
345
+ const cur = computeTotals(curRows);
346
+ const prev = computeTotals(prevRows);
347
+
348
+ const lines: string[] = [
349
+ `=== Search Performance: ${siteUrl} ===`,
350
+ `Current: ${currentStart} to ${currentEnd}`,
351
+ `Previous: ${previousStart} to ${previousEnd}`,
352
+ `Dimensions: ${dimensions.join(", ")}`,
353
+ "",
354
+ "--- Summary ---",
355
+ "",
356
+ " Current Previous Change",
357
+ `Clicks: ${String(cur.clicks.toLocaleString()).padEnd(12)} ${String(prev.clicks.toLocaleString()).padEnd(12)} ${pctChange(cur.clicks, prev.clicks)}`,
358
+ `Impressions: ${String(cur.impressions.toLocaleString()).padEnd(12)} ${String(prev.impressions.toLocaleString()).padEnd(12)} ${pctChange(cur.impressions, prev.impressions)}`,
359
+ `Avg CTR: ${`${(cur.ctr * 100).toFixed(1)}%`.padEnd(12)} ${`${(prev.ctr * 100).toFixed(1)}%`.padEnd(12)} ${((cur.ctr - prev.ctr) * 100).toFixed(1)}pp`,
360
+ `Avg Position:${String(cur.position.toFixed(1)).padEnd(13)} ${String(prev.position.toFixed(1)).padEnd(12)} ${prev.position === 0 && cur.position > 0 ? "new" : cur.position < prev.position ? "improved" : cur.position > prev.position ? "regressed" : "stable"} (${(cur.position - prev.position).toFixed(1)})`,
361
+ "",
362
+ ];
363
+
364
+ // Build lookup for previous period rows
365
+ const prevMap = new Map<string, (typeof prevRows)[0]>();
366
+ for (const row of prevRows) {
367
+ prevMap.set(row.keys.join("|"), row);
368
+ }
369
+
370
+ // Find biggest movers (by click change)
371
+ const movers: Array<{ keys: string[]; curClicks: number; prevClicks: number; curPos: number; prevPos: number }> = [];
372
+ for (const row of curRows) {
373
+ const key = row.keys.join("|");
374
+ const prevRow = prevMap.get(key);
375
+ movers.push({
376
+ keys: row.keys,
377
+ curClicks: row.clicks,
378
+ prevClicks: prevRow?.clicks ?? 0,
379
+ curPos: row.position,
380
+ prevPos: prevRow?.position ?? 0,
381
+ });
382
+ }
383
+
384
+ // Also include rows that disappeared (were in previous but not in current)
385
+ const curKeys = new Set(curRows.map((r) => r.keys.join("|")));
386
+ for (const row of prevRows) {
387
+ const key = row.keys.join("|");
388
+ if (!curKeys.has(key)) {
389
+ movers.push({
390
+ keys: row.keys,
391
+ curClicks: 0,
392
+ prevClicks: row.clicks,
393
+ curPos: 0,
394
+ prevPos: row.position,
395
+ });
396
+ }
397
+ }
398
+
399
+ // Sort by absolute click change descending
400
+ movers.sort((a, b) => Math.abs(b.curClicks - b.prevClicks) - Math.abs(a.curClicks - a.prevClicks));
401
+
402
+ const improved = movers.filter((m) => m.curClicks > m.prevClicks).slice(0, 10);
403
+ const regressed = movers.filter((m) => m.curClicks < m.prevClicks).slice(0, 10);
404
+
405
+ if (improved.length > 0) {
406
+ lines.push("--- Improved ---", "");
407
+ for (const m of improved) {
408
+ const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
409
+ const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
410
+ lines.push(`${keys}`);
411
+ lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
412
+ }
413
+ lines.push("");
414
+ }
415
+
416
+ if (regressed.length > 0) {
417
+ lines.push("--- Regressed ---", "");
418
+ for (const m of regressed) {
419
+ const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
420
+ const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
421
+ lines.push(`${keys}`);
422
+ lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
423
+ }
424
+ lines.push("");
425
+ }
426
+
427
+ return lines.join("\n");
428
+ }
429
+
430
+ function daysAgo(n: number): string {
431
+ // GSC dates are in PT (Pacific Time). Use UTC-8 as a stable approximation.
432
+ const now = new Date(Date.now() - 8 * 60 * 60 * 1000);
433
+ now.setDate(now.getDate() - n);
434
+ return now.toISOString().split("T")[0];
435
+ }
436
+
437
+ // ── Tool registration ──
438
+
439
+ export function registerSearchTool(server: McpServer): void {
440
+ server.tool(
441
+ "search",
442
+ "Query Google Search Console. Inspect URL indexing status, sample-inspect sitemap URLs, list properties and sitemaps, or analyze search traffic (clicks, impressions, CTR, rankings).",
443
+ {
444
+ action: z
445
+ .enum(["inspect", "sample", "sitemaps", "analytics", "list_sites", "get_site", "get_sitemap"])
446
+ .optional()
447
+ .describe("Action to perform. Auto-detected from params when unambiguous."),
448
+ site_url: z.string().optional().describe("GSC property (e.g., 'sc-domain:example.com')."),
449
+ url: z.string().url().optional().describe("URL to inspect in Google's index."),
450
+ sitemap_url: z.string().url().optional().describe("Sitemap URL for sample inspection or get_sitemap."),
451
+ sample_size: z.number().min(1).max(10).optional().describe("URLs to sample-inspect (1-10). Default: 5."),
452
+ sample_strategy: z
453
+ .enum(["random", "first", "spread"])
454
+ .optional()
455
+ .describe("Sampling strategy. Default: 'random'."),
456
+ start_date: z.string().optional().describe("Start date (YYYY-MM-DD) for analytics. Default: 28 days ago."),
457
+ end_date: z.string().optional().describe("End date (YYYY-MM-DD) for analytics. Default: 3 days ago."),
458
+ dimensions: z
459
+ .array(z.enum(["query", "page", "country", "device", "date", "searchAppearance", "hour"]))
460
+ .optional()
461
+ .describe("Analytics dimensions. Default: auto."),
462
+ search_type: z
463
+ .enum(["web", "image", "video", "news", "discover", "googleNews"])
464
+ .optional()
465
+ .describe("Search type for analytics."),
466
+ data_state: z.enum(["all", "final", "hourly_all"]).optional().describe("Data freshness for analytics."),
467
+ aggregation_type: z
468
+ .enum(["auto", "byPage", "byProperty", "byNewsShowcasePanel"])
469
+ .optional()
470
+ .describe("Aggregation mode for analytics."),
471
+ filters: z
472
+ .array(
473
+ z.object({
474
+ dimension: z.enum(["query", "page", "country", "device", "searchAppearance"]),
475
+ operator: z.enum(["equals", "contains", "notEquals", "notContains", "includingRegex", "excludingRegex"]),
476
+ expression: z.string(),
477
+ }),
478
+ )
479
+ .optional()
480
+ .describe("Dimension filters for analytics."),
481
+ row_limit: z.number().optional().describe("Max rows for analytics (1-25000). Default: 1000."),
482
+ start_row: z.number().optional().describe("Pagination offset for analytics."),
483
+ compare: z.boolean().optional().describe("Compare current vs previous period for analytics."),
484
+ },
485
+ async (params) => {
486
+ const {
487
+ site_url,
488
+ url,
489
+ sitemap_url,
490
+ sample_size,
491
+ sample_strategy,
492
+ start_date,
493
+ end_date,
494
+ dimensions,
495
+ search_type,
496
+ data_state,
497
+ aggregation_type,
498
+ filters,
499
+ row_limit,
500
+ start_row,
501
+ compare,
502
+ } = params;
503
+
504
+ // ── Route to action ──
505
+ const hasAnalyticsParams = !!(start_date || end_date || dimensions || filters || compare);
506
+
507
+ const resolvedAction =
508
+ params.action ??
509
+ (url && site_url && !hasAnalyticsParams
510
+ ? "inspect"
511
+ : sample_size
512
+ ? "sample"
513
+ : hasAnalyticsParams
514
+ ? "analytics"
515
+ : sitemap_url && site_url
516
+ ? "get_sitemap"
517
+ : site_url
518
+ ? "sitemaps"
519
+ : "list_sites");
520
+
521
+ try {
522
+ // ── inspect ──
523
+ if (resolvedAction === "inspect") {
524
+ if (!url) return textResult("Error: url is required for inspect.");
525
+ if (!site_url) return textResult("Error: site_url is required for inspect.");
526
+
527
+ try {
528
+ const result = await inspectUrl(url, site_url);
529
+ return textResult(formatInspection(url, site_url, result));
530
+ } catch (err) {
531
+ const msg = err instanceof Error ? err.message : String(err);
532
+ if (msg.includes("PERMISSION_DENIED")) {
533
+ const domain = new URL(url).hostname;
534
+ return textResult(
535
+ [
536
+ `Error: Cannot inspect "${url}" — permission denied for property "${site_url}".`,
537
+ "",
538
+ "Possible causes:",
539
+ ` 1. ${domain} is not a verified property in Google Search Console`,
540
+ ` 2. The authenticated account does not have access to "${site_url}"`,
541
+ ` 3. The property format is wrong — try "sc-domain:${domain}" or "https://${domain}/"`,
542
+ "",
543
+ "To verify a property: https://search.google.com/search-console",
544
+ "To check your auth: use the setup tool to verify credentials",
545
+ ].join("\n"),
546
+ );
547
+ }
548
+ return textResult(`Error inspecting URL: ${msg}`);
549
+ }
550
+ }
551
+
552
+ // ── sample ──
553
+ if (resolvedAction === "sample") {
554
+ if (!site_url) return textResult("Error: site_url is required for sample.");
555
+
556
+ const count = sample_size ?? 5;
557
+ const strategy = sample_strategy ?? "random";
558
+
559
+ // Discover sitemap URL if not provided
560
+ let resolvedSitemapUrl = sitemap_url;
561
+ if (!resolvedSitemapUrl) {
562
+ const sitemaps = await listSitemaps(site_url);
563
+ if (sitemaps.length === 0) {
564
+ return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
565
+ }
566
+ // Pick the first non-index sitemap, or the first one
567
+ const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
568
+ resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
569
+ }
570
+
571
+ // Fetch and parse sitemap
572
+ let parsed = await fetchSitemap(resolvedSitemapUrl);
573
+
574
+ // If it's a sitemap index, fetch the first child
575
+ if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
576
+ const childUrl = parsed.childSitemaps[0];
577
+ parsed = await fetchSitemap(childUrl);
578
+ resolvedSitemapUrl = `${resolvedSitemapUrl} → ${childUrl}`;
579
+ }
580
+
581
+ if (parsed.urls.length === 0) {
582
+ return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
583
+ }
584
+
585
+ // Sample URLs
586
+ const sampled = sampleUrls(parsed.urls, count, strategy);
587
+
588
+ // Inspect each URL sequentially (API rate limits)
589
+ const results: InspectionSummary[] = [];
590
+ for (const u of sampled) {
591
+ results.push(await inspectSingle(u, site_url));
592
+ }
593
+
594
+ return textResult(formatSampleResults(site_url, resolvedSitemapUrl, parsed.urls.length, results));
595
+ }
596
+
597
+ // ── list_sites ──
598
+ if (resolvedAction === "list_sites") {
599
+ const sites = await listSites();
600
+ return textResult(formatSites(sites));
601
+ }
602
+
603
+ // ── get_site ──
604
+ if (resolvedAction === "get_site") {
605
+ if (!site_url) return textResult("Error: site_url is required for get_site.");
606
+ const site = await getSite(site_url);
607
+ return textResult(formatSite(site));
608
+ }
609
+
610
+ // ── get_sitemap ──
611
+ if (resolvedAction === "get_sitemap") {
612
+ if (!site_url) return textResult("Error: site_url is required for get_sitemap.");
613
+ if (!sitemap_url) return textResult("Error: sitemap_url is required for get_sitemap.");
614
+ const sm = await getSitemap(site_url, sitemap_url);
615
+ return textResult(formatSitemapDetail(sm));
616
+ }
617
+
618
+ // ── sitemaps (list_sitemaps) ──
619
+ if (resolvedAction === "sitemaps") {
620
+ if (!site_url) return textResult("Error: site_url is required for sitemaps.");
621
+ const sitemaps = await listSitemaps(site_url);
622
+ return textResult(formatSitemaps(site_url, sitemaps));
623
+ }
624
+
625
+ // ── analytics ──
626
+ if (resolvedAction === "analytics") {
627
+ if (!site_url) return textResult("Error: site_url is required for analytics.");
628
+
629
+ const startDate = start_date ?? daysAgo(28);
630
+ const endDate = end_date ?? daysAgo(3);
631
+ const dims = dimensions ?? ["query", "page"];
632
+
633
+ const filterGroups =
634
+ filters && filters.length > 0
635
+ ? [{ groupType: "and" as const, filters: filters as SearchAnalyticsFilter[] }]
636
+ : undefined;
637
+
638
+ const queryOpts = {
639
+ startDate,
640
+ endDate,
641
+ dimensions: dims,
642
+ type: search_type,
643
+ dataState: data_state,
644
+ aggregationType: aggregation_type,
645
+ rowLimit: row_limit ?? 1000,
646
+ startRow: start_row,
647
+ dimensionFilterGroups: filterGroups,
648
+ };
649
+
650
+ if (compare) {
651
+ // Calculate previous period of equal length
652
+ const start = new Date(startDate);
653
+ const end = new Date(endDate);
654
+ const durationMs = end.getTime() - start.getTime();
655
+ const prevEnd = new Date(start.getTime() - 1 * 24 * 60 * 60 * 1000); // day before current start
656
+ const prevStart = new Date(prevEnd.getTime() - durationMs);
657
+ const prevStartDate = prevStart.toISOString().split("T")[0];
658
+ const prevEndDate = prevEnd.toISOString().split("T")[0];
659
+
660
+ const [current, previous] = await Promise.all([
661
+ querySearchAnalytics(site_url, queryOpts),
662
+ querySearchAnalytics(site_url, { ...queryOpts, startDate: prevStartDate, endDate: prevEndDate }),
663
+ ]);
664
+
665
+ return textResult(
666
+ formatComparison(site_url, current, previous, dims, startDate, endDate, prevStartDate, prevEndDate),
667
+ );
668
+ }
669
+
670
+ const result = await querySearchAnalytics(site_url, queryOpts);
671
+ return textResult(formatPerformance(site_url, result, dims, startDate, endDate));
672
+ }
673
+
674
+ return textResult(`Error: Unknown action "${resolvedAction}".`);
675
+ } catch (err) {
676
+ const msg = err instanceof Error ? err.message : String(err);
677
+ return textResult(`Error: ${msg}`);
678
+ }
679
+ },
680
+ );
681
+ }
682
+
683
+ function textResult(text: string) {
684
+ return { content: [{ type: "text" as const, text }] };
685
+ }