pagesight 0.15.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "description": "See your site the way search engines and AI see it.",
5
5
  "keywords": [
6
6
  "seo",
@@ -326,7 +326,16 @@ export function registerAuditTool(server: McpServer): void {
326
326
  if (pagespeedResult.status === "fulfilled") {
327
327
  addPagespeedFindings(pagespeedResult.value, findings);
328
328
  } else {
329
- errors.push(`PageSpeed: ${pagespeedResult.reason}`);
329
+ const psiErr = String(pagespeedResult.reason);
330
+ const statusMatch = psiErr.match(/\((\d{3})\)/);
331
+ const status = statusMatch ? Number(statusMatch[1]) : 0;
332
+ if (status === 403) {
333
+ errors.push("PageSpeed: SKIPPED (API key not authorized — enable PageSpeed Insights API in Google Cloud)");
334
+ } else if (status === 429) {
335
+ errors.push("PageSpeed: SKIPPED (rate limited — try again later or set GOOGLE_API_KEY)");
336
+ } else {
337
+ errors.push(`PageSpeed: ${psiErr.replace(/:\s*\{[\s\S]*$/, "")}`);
338
+ }
330
339
  }
331
340
 
332
341
  // Process robots
@@ -105,6 +105,59 @@ function humanizeState(state: string): string {
105
105
  return map[state] ?? state;
106
106
  }
107
107
 
108
+ // ── Coverage helpers ──
109
+
110
+ const COVERAGE_FILTERS = [
111
+ "not_indexed",
112
+ "server_error",
113
+ "redirect",
114
+ "soft_404",
115
+ "blocked",
116
+ "duplicate",
117
+ "discovered",
118
+ "crawled_not_indexed",
119
+ ] as const;
120
+
121
+ type CoverageFilter = (typeof COVERAGE_FILTERS)[number];
122
+
123
+ function matchesCoverageFilter(result: InspectionSummary, filter: CoverageFilter): boolean {
124
+ if (result.error) return false;
125
+ const cs = result.coverageState.toLowerCase();
126
+ const pf = result.pageFetchState.toLowerCase();
127
+ switch (filter) {
128
+ case "not_indexed":
129
+ return result.verdict !== "PASS";
130
+ case "server_error":
131
+ return cs.includes("server error") || pf === "server_error";
132
+ case "redirect":
133
+ return cs.includes("redirect") || pf.includes("redirect");
134
+ case "soft_404":
135
+ return cs.includes("soft 404") || pf === "soft_404";
136
+ case "blocked":
137
+ return cs.includes("blocked");
138
+ case "duplicate":
139
+ return cs.includes("duplicate");
140
+ case "discovered":
141
+ return cs.includes("discovered");
142
+ case "crawled_not_indexed":
143
+ return cs.includes("crawled - currently not indexed");
144
+ default:
145
+ return false;
146
+ }
147
+ }
148
+
149
+ function filterNameForState(state: string): string | null {
150
+ const s = state.toLowerCase();
151
+ if (s.includes("server error")) return "server_error";
152
+ if (s.includes("blocked")) return "blocked";
153
+ if (s.includes("redirect")) return "redirect";
154
+ if (s.includes("soft 404")) return "soft_404";
155
+ if (s.includes("duplicate")) return "duplicate";
156
+ if (s.includes("discovered - currently not indexed")) return "discovered";
157
+ if (s.includes("crawled - currently not indexed")) return "crawled_not_indexed";
158
+ return null;
159
+ }
160
+
108
161
  function formatSampleResults(
109
162
  siteUrl: string,
110
163
  sitemapUrl: string,
@@ -187,6 +240,90 @@ function formatSampleResults(
187
240
  return lines.join("\n");
188
241
  }
189
242
 
243
+ // ── Coverage formatter ──
244
+
245
+ function formatCoverage(siteUrl: string, sitemapUrl: string, totalUrls: number, results: InspectionSummary[]): string {
246
+ const inspected = results.filter((r) => !r.error);
247
+ const errors = results.filter((r) => r.error);
248
+ const indexed = inspected.filter((r) => r.verdict === "PASS");
249
+ const notIndexed = inspected.filter((r) => r.verdict !== "PASS");
250
+
251
+ const lines: string[] = [
252
+ `=== Index Coverage: ${siteUrl} ===`,
253
+ `Sitemap: ${sitemapUrl} (${totalUrls.toLocaleString()} URLs)`,
254
+ `Inspected: ${results.length}`,
255
+ "",
256
+ ];
257
+
258
+ const total = inspected.length;
259
+ if (total === 0) {
260
+ lines.push("No URLs could be inspected.");
261
+ if (errors.length > 0) {
262
+ lines.push("");
263
+ for (const e of errors) lines.push(` ${e.url}: ${e.error}`);
264
+ }
265
+ return lines.join("\n");
266
+ }
267
+
268
+ // Group not-indexed by coverageState
269
+ const issueGroups: Record<string, InspectionSummary[]> = {};
270
+ for (const r of notIndexed) {
271
+ const key = r.coverageState;
272
+ if (!issueGroups[key]) issueGroups[key] = [];
273
+ issueGroups[key].push(r);
274
+ }
275
+ const sortedIssues = Object.entries(issueGroups).sort((a, b) => b[1].length - a[1].length);
276
+
277
+ // Breakdown
278
+ lines.push("--- Breakdown ---", "");
279
+ lines.push(`Indexed: ${indexed.length}/${total} (${Math.round((indexed.length / total) * 100)}%)`);
280
+ lines.push(`Not indexed: ${notIndexed.length}/${total} (${Math.round((notIndexed.length / total) * 100)}%)`);
281
+ for (const [state, urls] of sortedIssues) {
282
+ lines.push(` ${state}: ${urls.length}`);
283
+ }
284
+ if (errors.length > 0) {
285
+ lines.push(`Inspection errors: ${errors.length}`);
286
+ }
287
+
288
+ // Extrapolated estimates
289
+ if (totalUrls > total) {
290
+ lines.push("", "--- Estimated Totals ---", "");
291
+ const indexedEst = Math.round((indexed.length / total) * totalUrls);
292
+ const notIndexedEst = Math.round((notIndexed.length / total) * totalUrls);
293
+ lines.push(`~${indexedEst.toLocaleString()} indexed (of ${totalUrls.toLocaleString()})`);
294
+ lines.push(`~${notIndexedEst.toLocaleString()} not indexed`);
295
+ for (const [state, urls] of sortedIssues) {
296
+ const est = Math.round((urls.length / total) * totalUrls);
297
+ lines.push(` ~${est.toLocaleString()} ${state}`);
298
+ }
299
+ lines.push("", `Estimates based on ${total}-URL sample — actual numbers may vary.`);
300
+ }
301
+
302
+ // Example URLs per issue
303
+ if (sortedIssues.length > 0) {
304
+ lines.push("", "--- URLs by Issue ---", "");
305
+ for (const [state, urls] of sortedIssues) {
306
+ lines.push(`${state}:`);
307
+ for (const u of urls.slice(0, 3)) {
308
+ lines.push(` ${u.url}`);
309
+ if (u.lastCrawlTime) lines.push(` Last crawled: ${u.lastCrawlTime}`);
310
+ if (u.pageFetchState !== "SUCCESSFUL") lines.push(` Fetch: ${humanizeState(u.pageFetchState)}`);
311
+ }
312
+ if (urls.length > 3) lines.push(` ... and ${urls.length - 3} more in sample`);
313
+ lines.push("");
314
+ }
315
+ }
316
+
317
+ // Suggest drill-down
318
+ const filterSuggestions = sortedIssues.map(([state]) => filterNameForState(state)).filter(Boolean);
319
+ if (filterSuggestions.length > 0) {
320
+ lines.push(`Drill deeper: search(action='sample', filter='...') to investigate specific issues.`);
321
+ lines.push(` Available filters: ${filterSuggestions.join(", ")}`);
322
+ }
323
+
324
+ return lines.join("\n");
325
+ }
326
+
190
327
  // ── Sitemaps formatters ──
191
328
 
192
329
  function formatSites(sites: GscSite[]): string {
@@ -466,25 +603,141 @@ function daysAgo(n: number): string {
466
603
  return parts; // en-CA formats as YYYY-MM-DD
467
604
  }
468
605
 
606
+ // ── Keyword gap analysis ──
607
+
608
+ function extractPageText(html: string): string {
609
+ // Strip scripts, styles, and HTML tags to get visible text content
610
+ let text = html;
611
+ text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, " ");
612
+ text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ");
613
+ text = text.replace(/<[^>]+>/g, " ");
614
+ // Decode common HTML entities
615
+ text = text
616
+ .replace(/&amp;/g, "&")
617
+ .replace(/&lt;/g, "<")
618
+ .replace(/&gt;/g, ">")
619
+ .replace(/&quot;/g, '"')
620
+ .replace(/&#39;/g, "'")
621
+ .replace(/&nbsp;/g, " ");
622
+ // Normalize whitespace
623
+ text = text.replace(/\s+/g, " ").trim().toLowerCase();
624
+ return text;
625
+ }
626
+
627
+ function formatGapAnalysis(
628
+ pageUrl: string,
629
+ siteUrl: string,
630
+ queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>,
631
+ pageText: string,
632
+ ): string {
633
+ const lines: string[] = [`=== Keyword Gap Analysis: ${pageUrl} ===`, `Property: ${siteUrl}`, ""];
634
+
635
+ if (queries.length === 0) {
636
+ lines.push("No search queries found for this page.");
637
+ return lines.join("\n");
638
+ }
639
+
640
+ const onPage: typeof queries = [];
641
+ const gaps: typeof queries = [];
642
+
643
+ for (const q of queries) {
644
+ const words = q.query.toLowerCase().split(/\s+/);
645
+ const allPresent = words.every((w) => pageText.includes(w));
646
+ if (allPresent) {
647
+ onPage.push(q);
648
+ } else {
649
+ gaps.push(q);
650
+ }
651
+ }
652
+
653
+ lines.push(`Queries analyzed: ${queries.length}`);
654
+ lines.push(`On page: ${onPage.length}`);
655
+ lines.push(`Gaps (not on page): ${gaps.length}`);
656
+ lines.push("");
657
+
658
+ if (gaps.length > 0) {
659
+ lines.push("--- Keyword Gaps (queries with impressions but missing from page) ---", "");
660
+ gaps.sort((a, b) => b.impressions - a.impressions);
661
+ for (const q of gaps.slice(0, 20)) {
662
+ const ctr = (q.ctr * 100).toFixed(1);
663
+ lines.push(
664
+ ` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
665
+ );
666
+ }
667
+ if (gaps.length > 20) lines.push(` ... and ${gaps.length - 20} more`);
668
+ lines.push("");
669
+ }
670
+
671
+ if (onPage.length > 0) {
672
+ lines.push("--- Already Targeted (query found on page) ---", "");
673
+ onPage.sort((a, b) => b.impressions - a.impressions);
674
+ for (const q of onPage.slice(0, 10)) {
675
+ const ctr = (q.ctr * 100).toFixed(1);
676
+ lines.push(
677
+ ` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
678
+ );
679
+ }
680
+ if (onPage.length > 10) lines.push(` ... and ${onPage.length - 10} more`);
681
+ lines.push("");
682
+ }
683
+
684
+ const totalGapImpressions = gaps.reduce((sum, q) => sum + q.impressions, 0);
685
+ const totalImpressions = queries.reduce((sum, q) => sum + q.impressions, 0);
686
+ if (totalImpressions > 0) {
687
+ const gapPct = Math.round((totalGapImpressions / totalImpressions) * 100);
688
+ lines.push(`${gapPct}% of impressions come from queries not on the page — potential content opportunity.`);
689
+ }
690
+
691
+ return lines.join("\n");
692
+ }
693
+
469
694
  // ── Tool registration ──
470
695
 
471
696
  export function registerSearchTool(server: McpServer): void {
472
697
  server.tool(
473
698
  "search",
474
- "Query Google Search Console. Inspect URL indexing status, sample-inspect sitemap URLs, list properties and sitemaps, or analyze search traffic (clicks, impressions, CTR, rankings).",
699
+ "Query Google Search Console. Inspect URL indexing, get coverage breakdown by issue type, sample-inspect with filters, list properties and sitemaps, analyze search traffic, or find keyword gaps.",
475
700
  {
476
701
  action: z
477
- .enum(["inspect", "sample", "sitemaps", "analytics", "list_sites", "get_site", "get_sitemap"])
702
+ .enum([
703
+ "inspect",
704
+ "sample",
705
+ "coverage",
706
+ "sitemaps",
707
+ "analytics",
708
+ "gaps",
709
+ "list_sites",
710
+ "get_site",
711
+ "get_sitemap",
712
+ ])
478
713
  .optional()
479
714
  .describe("Action to perform. Auto-detected from params when unambiguous."),
480
715
  site_url: z.string().optional().describe("GSC property (e.g., 'sc-domain:example.com')."),
481
716
  url: z.string().url().optional().describe("URL to inspect in Google's index."),
482
- sitemap_url: z.string().url().optional().describe("Sitemap URL for sample inspection or get_sitemap."),
483
- sample_size: z.number().min(1).max(10).optional().describe("URLs to sample-inspect (1-10). Default: 5."),
717
+ sitemap_url: z.string().url().optional().describe("Sitemap URL for sample/coverage inspection or get_sitemap."),
718
+ sample_size: z
719
+ .number()
720
+ .min(1)
721
+ .max(50)
722
+ .optional()
723
+ .describe("URLs to inspect (1-50). Default: 5 for sample, 20 for coverage."),
484
724
  sample_strategy: z
485
725
  .enum(["random", "first", "spread"])
486
726
  .optional()
487
- .describe("Sampling strategy. Default: 'random'."),
727
+ .describe("Sampling strategy. Default: 'spread' for coverage, 'random' for sample."),
728
+ filter: z
729
+ .enum([
730
+ "not_indexed",
731
+ "server_error",
732
+ "redirect",
733
+ "soft_404",
734
+ "blocked",
735
+ "duplicate",
736
+ "discovered",
737
+ "crawled_not_indexed",
738
+ ])
739
+ .optional()
740
+ .describe("Filter sample results by coverage issue type. Inspects more URLs internally to find matches."),
488
741
  start_date: z.string().optional().describe("Start date (YYYY-MM-DD) for analytics. Default: 28 days ago."),
489
742
  end_date: z.string().optional().describe("End date (YYYY-MM-DD) for analytics. Default: 3 days ago."),
490
743
  dimensions: z
@@ -521,6 +774,7 @@ export function registerSearchTool(server: McpServer): void {
521
774
  sitemap_url,
522
775
  sample_size,
523
776
  sample_strategy,
777
+ filter,
524
778
  start_date,
525
779
  end_date,
526
780
  dimensions,
@@ -540,7 +794,7 @@ export function registerSearchTool(server: McpServer): void {
540
794
  params.action ??
541
795
  (url && site_url && !hasAnalyticsParams
542
796
  ? "inspect"
543
- : sample_size
797
+ : sample_size || filter
544
798
  ? "sample"
545
799
  : hasAnalyticsParams
546
800
  ? "analytics"
@@ -595,7 +849,6 @@ export function registerSearchTool(server: McpServer): void {
595
849
  if (sitemaps.length === 0) {
596
850
  return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
597
851
  }
598
- // Pick the first non-index sitemap, or the first one
599
852
  const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
600
853
  resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
601
854
  }
@@ -603,7 +856,6 @@ export function registerSearchTool(server: McpServer): void {
603
856
  // Fetch and parse sitemap
604
857
  let parsed = await fetchSitemap(resolvedSitemapUrl);
605
858
 
606
- // If it's a sitemap index, fetch the first child
607
859
  if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
608
860
  const childUrl = parsed.childSitemaps[0];
609
861
  parsed = await fetchSitemap(childUrl);
@@ -614,10 +866,72 @@ export function registerSearchTool(server: McpServer): void {
614
866
  return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
615
867
  }
616
868
 
617
- // Sample URLs
869
+ if (filter) {
870
+ // Filtered sample: inspect more URLs, return only matches
871
+ const scanLimit = Math.min(count * 5, 50, parsed.urls.length);
872
+ const pool = sampleUrls(parsed.urls, scanLimit, strategy);
873
+ const matched: InspectionSummary[] = [];
874
+ let scanned = 0;
875
+
876
+ for (const u of pool) {
877
+ const result = await inspectSingle(u, site_url);
878
+ scanned++;
879
+ if (matchesCoverageFilter(result, filter)) {
880
+ matched.push(result);
881
+ if (matched.length >= count) break;
882
+ }
883
+ }
884
+
885
+ if (matched.length === 0) {
886
+ return textResult(
887
+ [
888
+ `=== Filtered Sample: ${site_url} ===`,
889
+ `Sitemap: ${resolvedSitemapUrl} (${parsed.urls.length.toLocaleString()} URLs)`,
890
+ `Filter: ${filter}`,
891
+ `Scanned: ${scanned} | Matched: 0`,
892
+ "",
893
+ `No URLs matching "${filter}" found in ${scanned} inspected URLs.`,
894
+ "Try a larger sample_size or a different filter.",
895
+ ].join("\n"),
896
+ );
897
+ }
898
+
899
+ const lines: string[] = [
900
+ `=== Filtered Sample: ${site_url} ===`,
901
+ `Sitemap: ${resolvedSitemapUrl} (${parsed.urls.length.toLocaleString()} URLs)`,
902
+ `Filter: ${filter}`,
903
+ `Scanned: ${scanned} | Matched: ${matched.length}`,
904
+ "",
905
+ "--- Matching URLs ---",
906
+ "",
907
+ ];
908
+
909
+ for (let i = 0; i < matched.length; i++) {
910
+ const r = matched[i];
911
+ lines.push(`${i + 1}. ${r.url}`);
912
+ lines.push(` Coverage: ${r.coverageState}`);
913
+ lines.push(` Page fetch: ${humanizeState(r.pageFetchState)}`);
914
+ if (r.robotsTxtState !== "ALLOWED") lines.push(` Robots.txt: ${humanizeState(r.robotsTxtState)}`);
915
+ if (r.indexingState !== "INDEXING_ALLOWED") lines.push(` Indexing: ${humanizeState(r.indexingState)}`);
916
+ if (r.lastCrawlTime) lines.push(` Last crawled: ${r.lastCrawlTime}`);
917
+ if (r.googleCanonical && r.googleCanonical !== r.url) {
918
+ lines.push(` Google canonical: ${r.googleCanonical}`);
919
+ }
920
+ lines.push("");
921
+ }
922
+
923
+ if (matched.length < count && scanned >= scanLimit) {
924
+ lines.push(
925
+ `Found ${matched.length}/${count} requested — scanned ${scanned} URLs (limit ${scanLimit}). Increase sample_size for a wider scan.`,
926
+ );
927
+ }
928
+
929
+ return textResult(lines.join("\n"));
930
+ }
931
+
932
+ // Unfiltered sample (original behavior)
618
933
  const sampled = sampleUrls(parsed.urls, count, strategy);
619
934
 
620
- // Inspect each URL sequentially (API rate limits)
621
935
  const results: InspectionSummary[] = [];
622
936
  for (const u of sampled) {
623
937
  results.push(await inspectSingle(u, site_url));
@@ -626,6 +940,46 @@ export function registerSearchTool(server: McpServer): void {
626
940
  return textResult(formatSampleResults(site_url, resolvedSitemapUrl, parsed.urls.length, results));
627
941
  }
628
942
 
943
+ // ── coverage ──
944
+ if (resolvedAction === "coverage") {
945
+ if (!site_url) return textResult("Error: site_url is required for coverage.");
946
+
947
+ const count = sample_size ?? 20;
948
+ const strategy = sample_strategy ?? "spread";
949
+
950
+ // Discover sitemap URL if not provided
951
+ let resolvedSitemapUrl = sitemap_url;
952
+ if (!resolvedSitemapUrl) {
953
+ const sitemaps = await listSitemaps(site_url);
954
+ if (sitemaps.length === 0) {
955
+ return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
956
+ }
957
+ const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
958
+ resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
959
+ }
960
+
961
+ let parsed = await fetchSitemap(resolvedSitemapUrl);
962
+
963
+ if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
964
+ const childUrl = parsed.childSitemaps[0];
965
+ parsed = await fetchSitemap(childUrl);
966
+ resolvedSitemapUrl = `${resolvedSitemapUrl} → ${childUrl}`;
967
+ }
968
+
969
+ if (parsed.urls.length === 0) {
970
+ return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
971
+ }
972
+
973
+ const sampled = sampleUrls(parsed.urls, count, strategy);
974
+
975
+ const results: InspectionSummary[] = [];
976
+ for (const u of sampled) {
977
+ results.push(await inspectSingle(u, site_url));
978
+ }
979
+
980
+ return textResult(formatCoverage(site_url, resolvedSitemapUrl, parsed.urls.length, results));
981
+ }
982
+
629
983
  // ── list_sites ──
630
984
  if (resolvedAction === "list_sites") {
631
985
  const sites = await listSites();
@@ -710,6 +1064,46 @@ export function registerSearchTool(server: McpServer): void {
710
1064
  return textResult(formatPerformance(site_url, result, dims, startDate, endDate));
711
1065
  }
712
1066
 
1067
+ // ── gaps ──
1068
+ if (resolvedAction === "gaps") {
1069
+ if (!url) return textResult("Error: url is required for gap analysis.");
1070
+ if (!site_url) return textResult("Error: site_url is required for gap analysis.");
1071
+
1072
+ // Fetch page content and GSC queries in parallel
1073
+ const [pageRes, analyticsRes] = await Promise.all([
1074
+ fetch(url, {
1075
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
1076
+ redirect: "follow",
1077
+ }),
1078
+ querySearchAnalytics(site_url, {
1079
+ startDate: start_date ?? daysAgo(28),
1080
+ endDate: end_date ?? daysAgo(3),
1081
+ dimensions: ["query"],
1082
+ rowLimit: row_limit ?? 500,
1083
+ dimensionFilterGroups: [
1084
+ {
1085
+ groupType: "and" as const,
1086
+ filters: [{ dimension: "page", operator: "equals", expression: url } as SearchAnalyticsFilter],
1087
+ },
1088
+ ],
1089
+ }),
1090
+ ]);
1091
+
1092
+ if (!pageRes.ok) return textResult(`Error fetching ${url}: HTTP ${pageRes.status}`);
1093
+ const html = await pageRes.text();
1094
+ const pageText = extractPageText(html);
1095
+
1096
+ const queries = (analyticsRes.rows ?? []).map((row) => ({
1097
+ query: row.keys[0],
1098
+ clicks: row.clicks,
1099
+ impressions: row.impressions,
1100
+ ctr: row.ctr,
1101
+ position: row.position,
1102
+ }));
1103
+
1104
+ return textResult(formatGapAnalysis(url, site_url, queries, pageText));
1105
+ }
1106
+
713
1107
  return textResult(`Error: Unknown action "${resolvedAction}".`);
714
1108
  } catch (err) {
715
1109
  const msg = err instanceof Error ? err.message : String(err);
@@ -17,7 +17,33 @@ export function registerSetupTool(server: McpServer): void {
17
17
  async ({ action, client_id, client_secret, code }) => {
18
18
  if (action === "status") {
19
19
  const method = getAuthMethod();
20
- if (method === "none") {
20
+ if (method !== "none") {
21
+ return {
22
+ content: [
23
+ {
24
+ type: "text",
25
+ text: `=== Pagesight Auth Status ===\n\nStatus: CONFIGURED\nMethod: ${method}`,
26
+ },
27
+ ],
28
+ };
29
+ }
30
+
31
+ // Check for partial OAuth config
32
+ const hasClientId = !!process.env.GSC_CLIENT_ID;
33
+ const hasClientSecret = !!process.env.GSC_CLIENT_SECRET;
34
+ const hasPartial = hasClientId || hasClientSecret;
35
+
36
+ if (hasPartial) {
37
+ const present = [hasClientId && "GSC_CLIENT_ID", hasClientSecret && "GSC_CLIENT_SECRET"]
38
+ .filter(Boolean)
39
+ .join(", ");
40
+ const missing = [
41
+ !hasClientId && "GSC_CLIENT_ID",
42
+ !hasClientSecret && "GSC_CLIENT_SECRET",
43
+ "GSC_REFRESH_TOKEN",
44
+ ]
45
+ .filter(Boolean)
46
+ .join(", ");
21
47
  return {
22
48
  content: [
23
49
  {
@@ -25,34 +51,47 @@ export function registerSetupTool(server: McpServer): void {
25
51
  text: [
26
52
  "=== Pagesight Auth Status ===",
27
53
  "",
28
- "Status: NOT CONFIGURED",
54
+ "Status: PARTIAL",
55
+ ` Present: ${present}`,
56
+ ` Missing: ${missing}`,
29
57
  "",
30
- "To use Pagesight, configure one of:",
31
- "",
32
- "Option 1: OAuth 2.0 (recommended for personal use)",
33
- " 1. Create a Google Cloud project",
34
- " 2. Enable 'Google Search Console API'",
35
- " 3. Create OAuth 2.0 credentials (Desktop app)",
36
- " 4. Call: setup(action='get_auth_url', client_id='YOUR_ID')",
37
- " 5. Visit the URL, authorize, copy the code",
38
- " 6. Call: setup(action='exchange_code', client_id='YOUR_ID', client_secret='YOUR_SECRET', code='THE_CODE')",
39
- " 7. Set env vars: GSC_CLIENT_ID, GSC_CLIENT_SECRET, GSC_REFRESH_TOKEN",
40
- "",
41
- "Option 2: Service Account",
42
- " 1. Create a service account in Google Cloud",
43
- " 2. Download the JSON key file",
44
- " 3. Add the service account email as a user in Search Console",
45
- " 4. Set env var: GSC_SERVICE_ACCOUNT_KEY=/path/to/key.json",
58
+ "Complete OAuth setup:",
59
+ " 1. Call: setup(action='get_auth_url', client_id='YOUR_ID')",
60
+ " 2. Visit the URL, authorize, copy the code",
61
+ " 3. Call: setup(action='exchange_code', client_id='YOUR_ID', client_secret='YOUR_SECRET', code='THE_CODE')",
62
+ " 4. Set GSC_REFRESH_TOKEN from the response",
46
63
  ].join("\n"),
47
64
  },
48
65
  ],
49
66
  };
50
67
  }
68
+
51
69
  return {
52
70
  content: [
53
71
  {
54
72
  type: "text",
55
- text: `=== Pagesight Auth Status ===\n\nStatus: CONFIGURED\nMethod: ${method}`,
73
+ text: [
74
+ "=== Pagesight Auth Status ===",
75
+ "",
76
+ "Status: NOT CONFIGURED",
77
+ "",
78
+ "To use Pagesight, configure one of:",
79
+ "",
80
+ "Option 1: OAuth 2.0 (recommended for personal use)",
81
+ " 1. Create a Google Cloud project",
82
+ " 2. Enable 'Google Search Console API'",
83
+ " 3. Create OAuth 2.0 credentials (Desktop app)",
84
+ " 4. Call: setup(action='get_auth_url', client_id='YOUR_ID')",
85
+ " 5. Visit the URL, authorize, copy the code",
86
+ " 6. Call: setup(action='exchange_code', client_id='YOUR_ID', client_secret='YOUR_SECRET', code='THE_CODE')",
87
+ " 7. Set env vars: GSC_CLIENT_ID, GSC_CLIENT_SECRET, GSC_REFRESH_TOKEN",
88
+ "",
89
+ "Option 2: Service Account",
90
+ " 1. Create a service account in Google Cloud",
91
+ " 2. Download the JSON key file",
92
+ " 3. Add the service account email as a user in Search Console",
93
+ " 4. Set env var: GSC_SERVICE_ACCOUNT_KEY=/path/to/key.json",
94
+ ].join("\n"),
56
95
  },
57
96
  ],
58
97
  };