pagesight 0.16.0 → 0.18.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.
Files changed (70) hide show
  1. package/README.md +17 -83
  2. package/docs/credentials.md +54 -0
  3. package/docs/diagnostics.md +85 -0
  4. package/docs/snapshots.md +123 -0
  5. package/docs/usage.md +130 -0
  6. package/package.json +26 -29
  7. package/src/api/bing.ts +83 -0
  8. package/src/api/compare-snapshots.ts +377 -0
  9. package/src/api/discover.ts +39 -0
  10. package/src/api/doctor.ts +35 -0
  11. package/src/api/evidence-schema.ts +78 -0
  12. package/src/api/evidence.ts +99 -0
  13. package/src/api/execute.ts +147 -0
  14. package/src/api/index.ts +5 -0
  15. package/src/api/reports.ts +90 -0
  16. package/src/api/schema.ts +207 -0
  17. package/src/api/snapshot.ts +183 -0
  18. package/src/api/ui-findings.ts +68 -0
  19. package/src/cli.ts +182 -0
  20. package/src/http.ts +39 -0
  21. package/src/index.ts +8 -25
  22. package/src/mcp-server.ts +27 -0
  23. package/src/mcp.ts +5 -0
  24. package/src/providers/bing.ts +48 -0
  25. package/src/{lib → providers}/crux.ts +4 -9
  26. package/src/providers/ga.ts +114 -0
  27. package/src/providers/google-tokens.ts +86 -0
  28. package/src/providers/gsc-auth.ts +93 -0
  29. package/src/{lib → providers}/gsc.ts +27 -24
  30. package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
  31. package/src/shared/dates.ts +32 -0
  32. package/src/shared/http.ts +63 -0
  33. package/src/tools/ai.ts +19 -27
  34. package/src/tools/audit.ts +26 -97
  35. package/src/tools/observe.ts +28 -0
  36. package/src/tools/page/analyze.ts +194 -0
  37. package/src/tools/page/batch.ts +163 -0
  38. package/src/tools/page/contrast.ts +128 -0
  39. package/src/tools/page/links.ts +200 -0
  40. package/src/tools/page/metadata.ts +225 -0
  41. package/src/tools/page/structured-data.ts +288 -0
  42. package/src/tools/page/tool.ts +48 -0
  43. package/src/tools/search/actions.ts +56 -0
  44. package/src/tools/search/analytics.ts +266 -0
  45. package/src/tools/search/coverage.ts +247 -0
  46. package/src/tools/search/gaps.ts +129 -0
  47. package/src/tools/search/inspection.ts +160 -0
  48. package/src/tools/search/result.ts +3 -0
  49. package/src/tools/search/sample.ts +110 -0
  50. package/src/tools/search/schema.ts +62 -0
  51. package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
  52. package/src/tools/search/sites.ts +86 -0
  53. package/src/tools/search/tool.ts +11 -0
  54. package/src/tools/setup.ts +59 -20
  55. package/src/tools/speed/analyze.ts +191 -0
  56. package/src/tools/speed/batch.ts +265 -0
  57. package/src/tools/speed/crux.ts +176 -0
  58. package/src/tools/speed/pagespeed.ts +273 -0
  59. package/src/tools/speed/schema.ts +39 -0
  60. package/src/tools/speed/tool.ts +11 -0
  61. package/src/web/fetch.ts +31 -0
  62. package/src/web/images.ts +62 -0
  63. package/src/web/page-observation.ts +69 -0
  64. package/src/{lib → web}/robots.ts +20 -12
  65. package/src/web/sitemap-inventory.ts +64 -0
  66. package/src/web/sitemap-parser.ts +59 -0
  67. package/src/lib/auth.ts +0 -187
  68. package/src/tools/page.ts +0 -1241
  69. package/src/tools/search.ts +0 -852
  70. package/src/tools/speed.ts +0 -956
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+ export const searchSchema = {
3
+ action: z
4
+ .enum(["inspect", "sample", "coverage", "sitemaps", "analytics", "gaps", "list_sites", "get_site", "get_sitemap"])
5
+ .optional()
6
+ .describe("Action to perform. Auto-detected from params when unambiguous."),
7
+ site_url: z.string().optional().describe("GSC property (e.g., 'sc-domain:example.com')."),
8
+ url: z.string().url().optional().describe("URL to inspect in Google's index."),
9
+ sitemap_url: z.string().url().optional().describe("Sitemap URL for sample/coverage inspection or get_sitemap."),
10
+ sample_size: z
11
+ .number()
12
+ .min(1)
13
+ .max(50)
14
+ .optional()
15
+ .describe("URLs to inspect (1-50). Default: 5 for sample, 20 for coverage."),
16
+ sample_strategy: z
17
+ .enum(["random", "first", "spread"])
18
+ .optional()
19
+ .describe("Sampling strategy. Default: 'spread' for coverage, 'random' for sample."),
20
+ filter: z
21
+ .enum([
22
+ "not_indexed",
23
+ "server_error",
24
+ "redirect",
25
+ "soft_404",
26
+ "blocked",
27
+ "duplicate",
28
+ "discovered",
29
+ "crawled_not_indexed",
30
+ ])
31
+ .optional()
32
+ .describe("Filter sample results by coverage issue type. Inspects more URLs internally to find matches."),
33
+ start_date: z.string().optional().describe("Start date (YYYY-MM-DD) for analytics. Default: 28 days ago."),
34
+ end_date: z.string().optional().describe("End date (YYYY-MM-DD) for analytics. Default: 3 days ago."),
35
+ dimensions: z
36
+ .array(z.enum(["query", "page", "country", "device", "date", "searchAppearance", "hour"]))
37
+ .optional()
38
+ .describe("Analytics dimensions. Default: auto."),
39
+ search_type: z
40
+ .enum(["web", "image", "video", "news", "discover", "googleNews"])
41
+ .optional()
42
+ .describe("Search type for analytics."),
43
+ data_state: z.enum(["all", "final", "hourly_all"]).optional().describe("Data freshness for analytics."),
44
+ aggregation_type: z
45
+ .enum(["auto", "byPage", "byProperty", "byNewsShowcasePanel"])
46
+ .optional()
47
+ .describe("Aggregation mode for analytics."),
48
+ filters: z
49
+ .array(
50
+ z.object({
51
+ dimension: z.enum(["query", "page", "country", "device", "searchAppearance"]),
52
+ operator: z.enum(["equals", "contains", "notEquals", "notContains", "includingRegex", "excludingRegex"]),
53
+ expression: z.string(),
54
+ }),
55
+ )
56
+ .optional()
57
+ .describe("Dimension filters for analytics."),
58
+ row_limit: z.number().optional().describe("Max rows for analytics (1-25000). Default: 1000."),
59
+ start_row: z.number().optional().describe("Pagination offset for analytics."),
60
+ compare: z.boolean().optional().describe("Compare current vs previous period for analytics."),
61
+ };
62
+ export type SearchOptions = z.infer<z.ZodObject<typeof searchSchema>>;
@@ -1,6 +1,4 @@
1
- import { inspectUrl } from "./gsc.js";
2
-
3
- export interface SitemapParseResult {
1
+ interface SitemapParseResult {
4
2
  urls: string[];
5
3
  isSitemapIndex: boolean;
6
4
  childSitemaps: string[];
@@ -70,45 +68,3 @@ export function sampleUrls(urls: string[], count: number, strategy: string): str
70
68
  }
71
69
  return shuffled.slice(0, count);
72
70
  }
73
-
74
- export interface InspectionSummary {
75
- url: string;
76
- verdict: string;
77
- coverageState: string;
78
- pageFetchState: string;
79
- robotsTxtState: string;
80
- indexingState: string;
81
- lastCrawlTime: string | null;
82
- googleCanonical: string | null;
83
- error: string | null;
84
- }
85
-
86
- export async function inspectSingle(url: string, siteUrl: string): Promise<InspectionSummary> {
87
- try {
88
- const r = await inspectUrl(url, siteUrl);
89
- const idx = r.indexStatusResult;
90
- return {
91
- url,
92
- verdict: idx.verdict,
93
- coverageState: idx.coverageState,
94
- pageFetchState: idx.pageFetchState,
95
- robotsTxtState: idx.robotsTxtState,
96
- indexingState: idx.indexingState,
97
- lastCrawlTime: idx.lastCrawlTime ?? null,
98
- googleCanonical: idx.googleCanonical ?? null,
99
- error: null,
100
- };
101
- } catch (err) {
102
- return {
103
- url,
104
- verdict: "ERROR",
105
- coverageState: "ERROR",
106
- pageFetchState: "ERROR",
107
- robotsTxtState: "ERROR",
108
- indexingState: "ERROR",
109
- lastCrawlTime: null,
110
- googleCanonical: null,
111
- error: err instanceof Error ? err.message : String(err),
112
- };
113
- }
114
- }
@@ -0,0 +1,86 @@
1
+ import { type GscSite, type GscSitemap, getSite, getSitemap, listSitemaps, listSites } from "../../providers/gsc.js";
2
+ import { type SearchOptions } from "./schema.js";
3
+ import { textResult } from "./result.js";
4
+ function formatSites(sites: GscSite[]): string {
5
+ if (sites.length === 0) return "No Search Console properties found.";
6
+
7
+ const lines: string[] = [`=== GSC Properties (${sites.length}) ===`, ""];
8
+ for (const site of sites) {
9
+ lines.push(`${site.siteUrl} (${site.permissionLevel})`);
10
+ }
11
+ return lines.join("\n");
12
+ }
13
+
14
+ function formatSite(site: GscSite): string {
15
+ return [`=== Site: ${site.siteUrl} ===`, "", `Permission: ${site.permissionLevel}`].join("\n");
16
+ }
17
+
18
+ function formatSitemapDetail(sm: GscSitemap): string {
19
+ const lines: string[] = [`=== Sitemap: ${sm.path} ===`, ""];
20
+ if (sm.type) lines.push(`Type: ${sm.type}`);
21
+ if (sm.lastSubmitted) lines.push(`Submitted: ${sm.lastSubmitted}`);
22
+ if (sm.lastDownloaded) lines.push(`Downloaded: ${sm.lastDownloaded}`);
23
+ lines.push(`Pending: ${sm.isPending}`);
24
+ lines.push(`Index: ${sm.isSitemapsIndex}`);
25
+ if (sm.warnings) lines.push(`Warnings: ${sm.warnings}`);
26
+ if (sm.errors) lines.push(`Errors: ${sm.errors}`);
27
+ if (sm.contents && sm.contents.length > 0) {
28
+ lines.push("", "Contents:");
29
+ for (const c of sm.contents) {
30
+ lines.push(` ${c.type}: ${c.submitted ?? "?"} submitted; indexed count unavailable (deprecated API field)`);
31
+ }
32
+ }
33
+ return lines.join("\n");
34
+ }
35
+
36
+ function formatSitemaps(siteUrl: string, sitemaps: GscSitemap[]): string {
37
+ if (sitemaps.length === 0) return `No sitemaps found for ${siteUrl}.`;
38
+
39
+ const lines: string[] = [`=== Sitemaps: ${siteUrl} (${sitemaps.length}) ===`, ""];
40
+
41
+ for (const sm of sitemaps) {
42
+ lines.push(`${sm.path}`);
43
+ if (sm.type) lines.push(` Type: ${sm.type}`);
44
+ if (sm.lastSubmitted) lines.push(` Submitted: ${sm.lastSubmitted}`);
45
+ if (sm.lastDownloaded) lines.push(` Downloaded: ${sm.lastDownloaded}`);
46
+ lines.push(` Pending: ${sm.isPending}`);
47
+ lines.push(` Index: ${sm.isSitemapsIndex}`);
48
+ if (sm.warnings) lines.push(` Warnings: ${sm.warnings}`);
49
+ if (sm.errors) lines.push(` Errors: ${sm.errors}`);
50
+ if (sm.contents) {
51
+ for (const c of sm.contents) {
52
+ lines.push(` ${c.type}: ${c.submitted ?? "?"} submitted; indexed count unavailable (deprecated API field)`);
53
+ }
54
+ }
55
+ lines.push("");
56
+ }
57
+
58
+ return lines.join("\n").trimEnd();
59
+ }
60
+
61
+ export async function runListSites() {
62
+ const sites = await listSites();
63
+ return textResult(formatSites(sites));
64
+ }
65
+
66
+ export async function runGetSite(options: SearchOptions) {
67
+ const { site_url } = options;
68
+ if (!site_url) return textResult("Error: site_url is required for get_site.");
69
+ const site = await getSite(site_url);
70
+ return textResult(formatSite(site));
71
+ }
72
+
73
+ export async function runGetSitemap(options: SearchOptions) {
74
+ const { site_url, sitemap_url } = options;
75
+ if (!site_url) return textResult("Error: site_url is required for get_sitemap.");
76
+ if (!sitemap_url) return textResult("Error: sitemap_url is required for get_sitemap.");
77
+ const sm = await getSitemap(site_url, sitemap_url);
78
+ return textResult(formatSitemapDetail(sm));
79
+ }
80
+
81
+ export async function runSitemaps(options: SearchOptions) {
82
+ const { site_url } = options;
83
+ if (!site_url) return textResult("Error: site_url is required for sitemaps.");
84
+ const sitemaps = await listSitemaps(site_url);
85
+ return textResult(formatSitemaps(site_url, sitemaps));
86
+ }
@@ -0,0 +1,11 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { searchSchema } from "./schema.js";
3
+ import { runSearch } from "./actions.js";
4
+ export function registerSearchTool(server: McpServer): void {
5
+ server.tool(
6
+ "search",
7
+ "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.",
8
+ searchSchema,
9
+ runSearch,
10
+ );
11
+ }
@@ -1,6 +1,6 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { exchangeCodeForToken, getAuthMethod, getOAuthSetupUrl } from "../lib/auth.js";
3
+ import { exchangeCodeForToken, getAuthMethod, getOAuthSetupUrl } from "../providers/gsc-auth.js";
4
4
 
5
5
  export function registerSetupTool(server: McpServer): void {
6
6
  server.tool(
@@ -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
  };
@@ -0,0 +1,191 @@
1
+ import type { SpeedOptions } from "./schema.js";
2
+ import { hasApiKey, type PsiCategoryType, type PsiResult, runPagespeed } from "../../providers/pagespeed.js";
3
+ import { type CruxFormFactor, queryCrux, queryCruxHistory } from "../../providers/crux.js";
4
+ import { QUOTA_NOTE, formatPagespeed } from "./pagespeed.js";
5
+ import { formatBatchCompare, formatBatchTable, runBatch } from "./batch.js";
6
+ import { formatCrux, formatCruxHistory } from "./crux.js";
7
+
8
+ export async function analyzeSpeed({
9
+ action,
10
+ url,
11
+ urls,
12
+ strategy,
13
+ categories,
14
+ locale,
15
+ origin,
16
+ form_factor,
17
+ metrics,
18
+ periods,
19
+ }: SpeedOptions) {
20
+ // Determine which action to run
21
+ let resolvedAction = action;
22
+ if (!resolvedAction) {
23
+ if (urls) {
24
+ resolvedAction = "pagespeed";
25
+ } else if (origin && !url) {
26
+ resolvedAction = "crux";
27
+ } else if (periods) {
28
+ resolvedAction = "crux_history";
29
+ } else {
30
+ resolvedAction = "pagespeed";
31
+ }
32
+ }
33
+
34
+ // --- PageSpeed action ---
35
+ if (resolvedAction === "pagespeed") {
36
+ const strat = (strategy as "mobile" | "desktop") ?? "mobile";
37
+ const cats = categories as PsiCategoryType[] | undefined;
38
+ const opts = { strategy: strat, categories: cats, locale };
39
+
40
+ if (url && urls) {
41
+ return {
42
+ content: [{ type: "text" as const, text: "Error: provide either 'url' (single) or 'urls' (batch), not both." }],
43
+ };
44
+ }
45
+ if (!url && !urls) {
46
+ return {
47
+ content: [
48
+ { type: "text" as const, text: "Error: provide 'url' for single analysis or 'urls' for batch analysis." },
49
+ ],
50
+ };
51
+ }
52
+
53
+ // Single URL
54
+ if (url) {
55
+ try {
56
+ const result = await runPagespeed(url, opts);
57
+ const text = formatPagespeed(url, result) + (hasApiKey() ? "" : QUOTA_NOTE);
58
+ return { content: [{ type: "text" as const, text }] };
59
+ } catch (err) {
60
+ const msg = err instanceof Error ? err.message : String(err);
61
+ return { content: [{ type: "text" as const, text: `Error running PageSpeed analysis: ${msg}` }] };
62
+ }
63
+ }
64
+
65
+ // Batch mode
66
+ const batchUrls = urls as string[];
67
+ const results = await runBatch(batchUrls, opts);
68
+
69
+ const successes = results.filter((r): r is { url: string; result: PsiResult } => !!r.result);
70
+ const failures = results.filter((r): r is { url: string; error: string } => !!r.error);
71
+
72
+ if (successes.length === 0) {
73
+ const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
74
+ return { content: [{ type: "text" as const, text: `All URLs failed:\n${errorLines.join("\n")}` }] };
75
+ }
76
+
77
+ let output: string;
78
+ if (successes.length === 2) {
79
+ output = formatBatchCompare(successes, strat);
80
+ } else {
81
+ output = formatBatchTable(successes, strat);
82
+ }
83
+
84
+ if (failures.length > 0) {
85
+ const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
86
+ output += `\n\n--- Errors ---\n${errorLines.join("\n")}`;
87
+ }
88
+
89
+ if (!hasApiKey()) output += QUOTA_NOTE;
90
+
91
+ return { content: [{ type: "text" as const, text: output }] };
92
+ }
93
+
94
+ // --- CrUX action ---
95
+ if (resolvedAction === "crux") {
96
+ const cruxUrl = url;
97
+ const cruxOrigin = origin;
98
+
99
+ if (!cruxUrl && !cruxOrigin) {
100
+ return { content: [{ type: "text" as const, text: "Error: provide url or origin for CrUX data." }] };
101
+ }
102
+ if (cruxUrl && cruxOrigin) {
103
+ return { content: [{ type: "text" as const, text: "Error: provide url or origin, not both." }] };
104
+ }
105
+
106
+ try {
107
+ const result = await queryCrux({
108
+ url: cruxUrl,
109
+ origin: cruxOrigin,
110
+ formFactor: form_factor as CruxFormFactor | undefined,
111
+ metrics,
112
+ });
113
+ return { content: [{ type: "text" as const, text: formatCrux(cruxUrl ?? cruxOrigin ?? "", result) }] };
114
+ } catch (err) {
115
+ const msg = err instanceof Error ? err.message : String(err);
116
+ if (msg.includes("404")) {
117
+ const target = cruxUrl ?? cruxOrigin ?? "";
118
+ const lines = [`No CrUX data for ${target}.`, ""];
119
+ lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
120
+ if (cruxUrl) {
121
+ const originUrl = new URL(cruxUrl).origin;
122
+ lines.push(`Try origin-level data instead: origin "${originUrl}"`);
123
+ }
124
+ lines.push("For lab metrics without traffic requirements, use speed with a url instead.");
125
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
126
+ }
127
+ if (msg.includes("SERVICE_DISABLED") || msg.includes("API_KEY_SERVICE_BLOCKED")) {
128
+ return {
129
+ content: [
130
+ {
131
+ type: "text" as const,
132
+ text: "Chrome UX Report API is not enabled or the API key doesn't have access. Enable the API at: https://console.cloud.google.com/apis/library/chromeuxreport.googleapis.com — and ensure your API key allows it (Credentials > API key > API restrictions).",
133
+ },
134
+ ],
135
+ };
136
+ }
137
+ return { content: [{ type: "text" as const, text: `Error querying CrUX: ${msg}` }] };
138
+ }
139
+ }
140
+
141
+ // --- CrUX History action ---
142
+ if (resolvedAction === "crux_history") {
143
+ const histUrl = url;
144
+ const histOrigin = origin;
145
+
146
+ if (!histUrl && !histOrigin) {
147
+ return { content: [{ type: "text" as const, text: "Error: provide url or origin for CrUX history." }] };
148
+ }
149
+ if (histUrl && histOrigin) {
150
+ return { content: [{ type: "text" as const, text: "Error: provide url or origin, not both." }] };
151
+ }
152
+
153
+ try {
154
+ const result = await queryCruxHistory({
155
+ url: histUrl,
156
+ origin: histOrigin,
157
+ formFactor: form_factor as CruxFormFactor | undefined,
158
+ metrics,
159
+ collectionPeriodCount: periods,
160
+ });
161
+ return { content: [{ type: "text" as const, text: formatCruxHistory(histUrl ?? histOrigin ?? "", result) }] };
162
+ } catch (err) {
163
+ const msg = err instanceof Error ? err.message : String(err);
164
+ if (msg.includes("404")) {
165
+ const target = histUrl ?? histOrigin ?? "";
166
+ const lines = [`No CrUX history data for ${target}.`, ""];
167
+ lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
168
+ if (histUrl) {
169
+ const originUrl = new URL(histUrl).origin;
170
+ lines.push(`Try origin-level data instead: origin "${originUrl}"`);
171
+ }
172
+ lines.push("For lab metrics without traffic requirements, use speed with a url instead.");
173
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
174
+ }
175
+ if (msg.includes("SERVICE_DISABLED")) {
176
+ return {
177
+ content: [
178
+ {
179
+ type: "text" as const,
180
+ text: "Chrome UX Report API is not enabled. Enable it at: https://console.cloud.google.com/apis/library/chromeuxreport.googleapis.com",
181
+ },
182
+ ],
183
+ };
184
+ }
185
+ return { content: [{ type: "text" as const, text: `Error querying CrUX History: ${msg}` }] };
186
+ }
187
+ }
188
+
189
+ const unhandled: never = resolvedAction;
190
+ return { content: [{ type: "text" as const, text: `Unknown action: ${String(unhandled)}` }] };
191
+ }