pagesight 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,189 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { type PsiAudit, type PsiCategoryType, type PsiResult, runPagespeed } from "../lib/psi.js";
4
+
5
+ function scoreLabel(score: number | null): string {
6
+ if (score === null) return "N/A";
7
+ const pct = Math.round(score * 100);
8
+ if (pct >= 90) return `${pct} (good)`;
9
+ if (pct >= 50) return `${pct} (needs improvement)`;
10
+ return `${pct} (poor)`;
11
+ }
12
+
13
+ function cwvRating(category: string): string {
14
+ if (category === "FAST") return "good";
15
+ if (category === "AVERAGE") return "needs improvement";
16
+ if (category === "SLOW") return "poor";
17
+ return category;
18
+ }
19
+
20
+ function formatLoadingExperience(label: string, exp: PsiResult["loadingExperience"]): string[] {
21
+ if (!exp?.metrics || Object.keys(exp.metrics).length === 0) return [];
22
+
23
+ const lines: string[] = [`--- ${label} (CrUX Field Data) ---`, ""];
24
+ lines.push(`Overall: ${cwvRating(exp.overall_category)}`, "");
25
+
26
+ const metricNames: Record<string, string> = {
27
+ CUMULATIVE_LAYOUT_SHIFT_SCORE: "CLS",
28
+ EXPERIMENTAL_TIME_TO_FIRST_BYTE: "TTFB",
29
+ FIRST_CONTENTFUL_PAINT_MS: "FCP",
30
+ FIRST_INPUT_DELAY_MS: "FID",
31
+ INTERACTION_TO_NEXT_PAINT: "INP",
32
+ LARGEST_CONTENTFUL_PAINT_MS: "LCP",
33
+ };
34
+
35
+ for (const [key, metric] of Object.entries(exp.metrics)) {
36
+ const name = metricNames[key] ?? key;
37
+ const unit = key.includes("LAYOUT_SHIFT") ? "" : "ms";
38
+ const value = key.includes("LAYOUT_SHIFT") ? (metric.percentile / 100).toFixed(2) : `${metric.percentile}${unit}`;
39
+ lines.push(`${name}: ${value} (${cwvRating(metric.category)})`);
40
+ }
41
+
42
+ return lines;
43
+ }
44
+
45
+ function formatOpportunities(audits: Record<string, PsiAudit>): string[] {
46
+ const opportunities: Array<{ title: string; savings: string; score: number }> = [];
47
+
48
+ for (const audit of Object.values(audits)) {
49
+ if (audit.score !== null && audit.score < 1 && audit.numericValue && audit.numericValue > 0) {
50
+ if (audit.scoreDisplayMode === "numeric" || audit.scoreDisplayMode === "binary") {
51
+ const unit = audit.numericUnit === "millisecond" ? "ms" : audit.numericUnit === "byte" ? " bytes" : "";
52
+ const savings = audit.displayValue ?? `${Math.round(audit.numericValue)}${unit}`;
53
+ opportunities.push({ title: audit.title, savings, score: audit.score });
54
+ }
55
+ }
56
+ }
57
+
58
+ if (opportunities.length === 0) return [];
59
+
60
+ opportunities.sort((a, b) => a.score - b.score);
61
+
62
+ const lines: string[] = ["--- Opportunities ---", ""];
63
+ for (const opp of opportunities.slice(0, 10)) {
64
+ const severity = opp.score < 0.5 ? "HIGH" : opp.score < 0.9 ? "MEDIUM" : "LOW";
65
+ lines.push(`${severity} ${opp.title}`);
66
+ lines.push(` Potential savings: ${opp.savings}`);
67
+ }
68
+
69
+ return lines;
70
+ }
71
+
72
+ function formatDiagnostics(audits: Record<string, PsiAudit>): string[] {
73
+ const failing: Array<{ title: string; displayValue: string }> = [];
74
+
75
+ for (const audit of Object.values(audits)) {
76
+ if (audit.score !== null && audit.score < 0.5 && audit.scoreDisplayMode === "numeric" && audit.displayValue) {
77
+ failing.push({ title: audit.title, displayValue: audit.displayValue });
78
+ }
79
+ }
80
+
81
+ if (failing.length === 0) return [];
82
+
83
+ const lines: string[] = ["--- Diagnostics ---", ""];
84
+ for (const item of failing.slice(0, 10)) {
85
+ lines.push(`${item.title}: ${item.displayValue}`);
86
+ }
87
+
88
+ return lines;
89
+ }
90
+
91
+ function formatPagespeed(url: string, result: PsiResult): string {
92
+ const lhr = result.lighthouseResult;
93
+ const lines: string[] = [
94
+ `=== PageSpeed: ${url} ===`,
95
+ `Strategy: ${lhr.configSettings.emulatedFormFactor}`,
96
+ `Lighthouse: ${lhr.lighthouseVersion}`,
97
+ `Analyzed: ${result.analysisUTCTimestamp}`,
98
+ "",
99
+ ];
100
+
101
+ // Runtime errors
102
+ if (lhr.runtimeError) {
103
+ lines.push(`ERROR: ${lhr.runtimeError.code} — ${lhr.runtimeError.message}`, "");
104
+ }
105
+
106
+ // Warnings
107
+ if (lhr.runWarnings && lhr.runWarnings.length > 0) {
108
+ for (const w of lhr.runWarnings) {
109
+ lines.push(`WARNING: ${w}`);
110
+ }
111
+ lines.push("");
112
+ }
113
+
114
+ // Category scores
115
+ lines.push("--- Scores ---", "");
116
+ for (const cat of Object.values(lhr.categories)) {
117
+ lines.push(`${cat.title}: ${scoreLabel(cat.score)}`);
118
+ }
119
+ lines.push("");
120
+
121
+ // Core Web Vitals from audits
122
+ const cwvIds = [
123
+ "first-contentful-paint",
124
+ "largest-contentful-paint",
125
+ "total-blocking-time",
126
+ "cumulative-layout-shift",
127
+ "speed-index",
128
+ "interactive",
129
+ ];
130
+ const cwvLines: string[] = [];
131
+ for (const id of cwvIds) {
132
+ const audit = lhr.audits[id];
133
+ if (audit?.displayValue) {
134
+ cwvLines.push(`${audit.title}: ${audit.displayValue} ${scoreLabel(audit.score)}`);
135
+ }
136
+ }
137
+ if (cwvLines.length > 0) {
138
+ lines.push("--- Core Web Vitals (Lab) ---", "", ...cwvLines, "");
139
+ }
140
+
141
+ // CrUX field data
142
+ const pageExp = formatLoadingExperience("Page", result.loadingExperience);
143
+ if (pageExp.length > 0) lines.push(...pageExp, "");
144
+
145
+ const originExp = formatLoadingExperience("Origin", result.originLoadingExperience);
146
+ if (originExp.length > 0) lines.push(...originExp, "");
147
+
148
+ // Opportunities
149
+ const opps = formatOpportunities(lhr.audits);
150
+ if (opps.length > 0) lines.push(...opps, "");
151
+
152
+ // Diagnostics
153
+ const diags = formatDiagnostics(lhr.audits);
154
+ if (diags.length > 0) lines.push(...diags, "");
155
+
156
+ // Timing
157
+ lines.push(`Analysis took ${(lhr.timing.total / 1000).toFixed(1)}s`);
158
+
159
+ return lines.join("\n");
160
+ }
161
+
162
+ export function registerPagespeedTool(server: McpServer): void {
163
+ server.tool(
164
+ "pagespeed",
165
+ "Analyze a page's performance using Google PageSpeed Insights API. Returns Lighthouse scores, Core Web Vitals (lab + field), opportunities, and diagnostics.",
166
+ {
167
+ url: z.string().url().describe("The URL to analyze."),
168
+ strategy: z.enum(["mobile", "desktop"]).optional().describe("Device strategy. Default: 'mobile'."),
169
+ categories: z
170
+ .array(z.enum(["performance", "accessibility", "best-practices", "seo"]))
171
+ .optional()
172
+ .describe("Lighthouse categories to run. Default: all four."),
173
+ locale: z.string().optional().describe("Locale for localized results (e.g., 'pt-BR', 'en')."),
174
+ },
175
+ async ({ url, strategy, categories, locale }) => {
176
+ try {
177
+ const result = await runPagespeed(url, {
178
+ strategy: strategy as "mobile" | "desktop" | undefined,
179
+ categories: categories as PsiCategoryType[] | undefined,
180
+ locale,
181
+ });
182
+ return { content: [{ type: "text", text: formatPagespeed(url, result) }] };
183
+ } catch (err) {
184
+ const msg = err instanceof Error ? err.message : String(err);
185
+ return { content: [{ type: "text", text: `Error running PageSpeed analysis: ${msg}` }] };
186
+ }
187
+ },
188
+ );
189
+ }
@@ -0,0 +1,167 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { querySearchAnalytics, type SearchAnalyticsFilter, type SearchAnalyticsResponse } from "../lib/gsc.js";
4
+
5
+ function formatPerformance(
6
+ siteUrl: string,
7
+ result: SearchAnalyticsResponse,
8
+ dimensions: string[],
9
+ startDate: string,
10
+ endDate: string,
11
+ ): string {
12
+ const rows = result.rows ?? [];
13
+ const lines: string[] = [
14
+ `=== Search Performance: ${siteUrl} ===`,
15
+ `Period: ${startDate} to ${endDate}`,
16
+ `Dimensions: ${dimensions.join(", ")}`,
17
+ `Aggregation: ${result.responseAggregationType ?? "auto"}`,
18
+ `Results: ${rows.length}`,
19
+ "",
20
+ ];
21
+
22
+ if (result.metadata) {
23
+ if (result.metadata.first_incomplete_date)
24
+ lines.push(`Data incomplete from: ${result.metadata.first_incomplete_date}`);
25
+ if (result.metadata.first_incomplete_hour)
26
+ lines.push(`Hourly data incomplete from: ${result.metadata.first_incomplete_hour}`);
27
+ lines.push("");
28
+ }
29
+
30
+ if (rows.length === 0) {
31
+ lines.push("No data found for this period and filters.");
32
+ return lines.join("\n");
33
+ }
34
+
35
+ const totalClicks = rows.reduce((sum, r) => sum + r.clicks, 0);
36
+ const totalImpressions = rows.reduce((sum, r) => sum + r.impressions, 0);
37
+ const avgCtr = totalImpressions > 0 ? totalClicks / totalImpressions : 0;
38
+ const avgPosition =
39
+ totalImpressions > 0 ? rows.reduce((sum, r) => sum + r.position * r.impressions, 0) / totalImpressions : 0;
40
+
41
+ lines.push(
42
+ "--- Totals ---",
43
+ "",
44
+ `Clicks: ${totalClicks.toLocaleString()}`,
45
+ `Impressions: ${totalImpressions.toLocaleString()}`,
46
+ `Avg CTR: ${(avgCtr * 100).toFixed(1)}%`,
47
+ `Avg Position: ${avgPosition.toFixed(1)}`,
48
+ "",
49
+ "--- Top Results ---",
50
+ "",
51
+ );
52
+
53
+ const top = rows.slice(0, 25);
54
+ for (const row of top) {
55
+ const keys = row.keys.join(" | ");
56
+ lines.push(`${keys}`);
57
+ lines.push(
58
+ ` Clicks: ${row.clicks} | Impressions: ${row.impressions} | CTR: ${(row.ctr * 100).toFixed(1)}% | Position: ${row.position.toFixed(1)}`,
59
+ );
60
+ }
61
+
62
+ if (rows.length > 25) {
63
+ lines.push("", `... and ${rows.length - 25} more rows`);
64
+ }
65
+
66
+ return lines.join("\n");
67
+ }
68
+
69
+ function daysAgo(n: number): string {
70
+ const d = new Date();
71
+ d.setDate(d.getDate() - n);
72
+ return d.toISOString().split("T")[0];
73
+ }
74
+
75
+ export function registerPerformanceTool(server: McpServer): void {
76
+ server.tool(
77
+ "performance",
78
+ "Query Google Search Console search analytics. Returns clicks, impressions, CTR, and position data. Supports all dimensions, search types, filter operators, aggregation modes, and pagination.",
79
+ {
80
+ site_url: z.string().describe("The GSC property (e.g., 'https://example.com/' or 'sc-domain:example.com')"),
81
+ start_date: z.string().optional().describe("Start date (YYYY-MM-DD). Default: 28 days ago."),
82
+ end_date: z.string().optional().describe("End date (YYYY-MM-DD). Default: 3 days ago."),
83
+ dimensions: z
84
+ .array(z.enum(["query", "page", "country", "device", "date", "searchAppearance", "hour"]))
85
+ .optional()
86
+ .describe(
87
+ "Dimensions to group by. Default: ['query', 'page']. Use 'hour' for hourly data (requires dataState='hourly_all').",
88
+ ),
89
+ search_type: z
90
+ .enum(["web", "image", "video", "news", "discover", "googleNews"])
91
+ .optional()
92
+ .describe("Filter by search type. Default: 'web'."),
93
+ data_state: z
94
+ .enum(["all", "final", "hourly_all"])
95
+ .optional()
96
+ .describe(
97
+ "Data freshness. 'all' includes fresh data, 'final' only finalized, 'hourly_all' for hourly granularity. Default: 'all'.",
98
+ ),
99
+ aggregation_type: z
100
+ .enum(["auto", "byPage", "byProperty", "byNewsShowcasePanel"])
101
+ .optional()
102
+ .describe("How to aggregate results. Default: 'auto'."),
103
+ filters: z
104
+ .array(
105
+ z.object({
106
+ dimension: z
107
+ .enum(["query", "page", "country", "device", "searchAppearance"])
108
+ .describe("Dimension to filter on."),
109
+ operator: z
110
+ .enum(["equals", "contains", "notEquals", "notContains", "includingRegex", "excludingRegex"])
111
+ .describe("Filter operator."),
112
+ expression: z
113
+ .string()
114
+ .describe(
115
+ "Filter value. For country use ISO 3166-1 alpha-3 (e.g., 'FRA'). For device: 'DESKTOP', 'MOBILE', 'TABLET'.",
116
+ ),
117
+ }),
118
+ )
119
+ .optional()
120
+ .describe("Dimension filters. Combined with AND logic."),
121
+ row_limit: z.number().optional().describe("Max rows (1-25000). Default: 1000."),
122
+ start_row: z.number().optional().describe("Zero-based offset for pagination. Default: 0."),
123
+ },
124
+ async ({
125
+ site_url,
126
+ start_date,
127
+ end_date,
128
+ dimensions,
129
+ search_type,
130
+ data_state,
131
+ aggregation_type,
132
+ filters,
133
+ row_limit,
134
+ start_row,
135
+ }) => {
136
+ const startDate = start_date ?? daysAgo(28);
137
+ const endDate = end_date ?? daysAgo(3);
138
+ const dims = dimensions ?? ["query", "page"];
139
+
140
+ const filterGroups =
141
+ filters && filters.length > 0
142
+ ? [{ groupType: "and" as const, filters: filters as SearchAnalyticsFilter[] }]
143
+ : undefined;
144
+
145
+ try {
146
+ const result = await querySearchAnalytics(site_url, {
147
+ startDate,
148
+ endDate,
149
+ dimensions: dims,
150
+ type: search_type,
151
+ dataState: data_state,
152
+ aggregationType: aggregation_type,
153
+ rowLimit: row_limit ?? 1000,
154
+ startRow: start_row,
155
+ dimensionFilterGroups: filterGroups,
156
+ });
157
+
158
+ return {
159
+ content: [{ type: "text", text: formatPerformance(site_url, result, dims, startDate, endDate) }],
160
+ };
161
+ } catch (err) {
162
+ const msg = err instanceof Error ? err.message : String(err);
163
+ return { content: [{ type: "text", text: `Error querying search analytics: ${msg}` }] };
164
+ }
165
+ },
166
+ );
167
+ }
@@ -0,0 +1,131 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { auditAiCrawlers, type CrawlerStatus, fetchRobotsTxt, isAllowed, type RobotsTxt } from "../lib/robots.js";
4
+
5
+ function formatCrawlerStatus(statuses: CrawlerStatus[]): string {
6
+ const lines: string[] = [];
7
+
8
+ // Group by category
9
+ const categories = new Map<string, CrawlerStatus[]>();
10
+ for (const s of statuses) {
11
+ const cat = s.category;
12
+ if (!categories.has(cat)) categories.set(cat, []);
13
+ categories.get(cat)?.push(s);
14
+ }
15
+
16
+ for (const [cat, bots] of categories) {
17
+ lines.push(`--- ${cat} (${bots.length}) ---`, "");
18
+
19
+ for (const bot of bots) {
20
+ const status = bot.allowed ? "ALLOWED" : "BLOCKED";
21
+ lines.push(` ${status} ${bot.name} (${bot.company})`);
22
+ if (bot.matchedRule) {
23
+ lines.push(` Rule: ${bot.matchedRule.type}: ${bot.matchedRule.path} (group: ${bot.matchedGroup})`);
24
+ }
25
+ }
26
+ lines.push("");
27
+ }
28
+
29
+ return lines.join("\n").trimEnd();
30
+ }
31
+
32
+ function formatRobotsAudit(origin: string, robots: RobotsTxt, statusCode: number, crawlers: CrawlerStatus[]): string {
33
+ const lines: string[] = [`=== robots.txt: ${origin} ===`, `Status: ${statusCode}`, ""];
34
+
35
+ const totalRules = robots.groups.reduce((sum, g) => sum + g.rules.length, 0);
36
+ lines.push(`Groups: ${robots.groups.length}`);
37
+ lines.push(`Rules: ${totalRules}`);
38
+ lines.push(`Sitemaps: ${robots.sitemaps.length}`);
39
+
40
+ if (robots.errors.length > 0) {
41
+ lines.push(`Parse errors: ${robots.errors.length}`, "");
42
+ lines.push("--- Parse Errors ---", "");
43
+ for (const err of robots.errors) {
44
+ lines.push(` ${err}`);
45
+ }
46
+ }
47
+
48
+ if (robots.sitemaps.length > 0) {
49
+ lines.push("", "--- Sitemaps ---", "");
50
+ for (const sm of robots.sitemaps) {
51
+ lines.push(` ${sm}`);
52
+ }
53
+ }
54
+
55
+ if (robots.groups.length > 0) {
56
+ lines.push("", "--- User-Agent Groups ---", "");
57
+ for (const group of robots.groups) {
58
+ const agents = group.userAgents.join(", ");
59
+ const allows = group.rules.filter((r) => r.type === "allow").length;
60
+ const disallows = group.rules.filter((r) => r.type === "disallow").length;
61
+ lines.push(` ${agents}: ${disallows} disallow, ${allows} allow`);
62
+ }
63
+ }
64
+
65
+ // AI Crawler audit
66
+ const allowed = crawlers.filter((c) => c.allowed);
67
+ const blocked = crawlers.filter((c) => !c.allowed);
68
+
69
+ lines.push(
70
+ "",
71
+ `--- AI Crawlers: ${blocked.length} blocked, ${allowed.length} allowed (of ${crawlers.length} known) ---`,
72
+ "",
73
+ `Source: github.com/ai-robots-txt/ai.robots.txt (${crawlers.length} bots)`,
74
+ "",
75
+ );
76
+ lines.push(formatCrawlerStatus(crawlers));
77
+
78
+ return lines.join("\n");
79
+ }
80
+
81
+ export function registerRobotsTool(server: McpServer): void {
82
+ server.tool(
83
+ "robots",
84
+ "Fetch and analyze a site's robots.txt. Validates syntax per RFC 9309, audits AI crawler access (130+ bots from ai-robots-txt registry), lists sitemaps, and reports blocked vs allowed bots by category.",
85
+ {
86
+ url: z
87
+ .string()
88
+ .describe("Site URL or origin (e.g., 'https://example.com'). Fetches /robots.txt from this origin."),
89
+ check_path: z
90
+ .string()
91
+ .optional()
92
+ .describe("Optional: check if a specific path is allowed/blocked for a given user-agent."),
93
+ user_agent: z
94
+ .string()
95
+ .optional()
96
+ .describe("Optional: user-agent to check path access for. Default: 'Googlebot'."),
97
+ },
98
+ async ({ url, check_path, user_agent }) => {
99
+ try {
100
+ const origin = new URL(url).origin;
101
+ const { robotsTxt, statusCode } = await fetchRobotsTxt(origin);
102
+
103
+ if (check_path) {
104
+ const ua = user_agent ?? "Googlebot";
105
+ const result = isAllowed(robotsTxt, ua, check_path);
106
+ const status = result.allowed ? "ALLOWED" : "BLOCKED";
107
+ const lines = [
108
+ "=== robots.txt path check ===",
109
+ `Origin: ${origin}`,
110
+ `User-Agent: ${ua}`,
111
+ `Path: ${check_path}`,
112
+ `Result: ${status}`,
113
+ ];
114
+ if (result.matchedRule) {
115
+ lines.push(`Matched rule: ${result.matchedRule.type}: ${result.matchedRule.path}`);
116
+ lines.push(`Matched group: ${result.matchedGroup}`);
117
+ } else {
118
+ lines.push("No matching rule found (default: allowed)");
119
+ }
120
+ return { content: [{ type: "text", text: lines.join("\n") }] };
121
+ }
122
+
123
+ const crawlers = await auditAiCrawlers(robotsTxt);
124
+ return { content: [{ type: "text", text: formatRobotsAudit(origin, robotsTxt, statusCode, crawlers) }] };
125
+ } catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err);
127
+ return { content: [{ type: "text", text: `Error analyzing robots.txt: ${msg}` }] };
128
+ }
129
+ },
130
+ );
131
+ }
@@ -0,0 +1,114 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { exchangeCodeForToken, getAuthMethod, getOAuthSetupUrl } from "../lib/auth.js";
4
+
5
+ export function registerSetupTool(server: McpServer): void {
6
+ server.tool(
7
+ "setup",
8
+ "Check authentication status or get OAuth setup instructions for Google Search Console.",
9
+ {
10
+ action: z
11
+ .enum(["status", "get_auth_url", "exchange_code"])
12
+ .describe("'status' to check auth, 'get_auth_url' to start OAuth flow, 'exchange_code' to complete it."),
13
+ client_id: z.string().optional().describe("Google OAuth client ID (for get_auth_url and exchange_code)."),
14
+ client_secret: z.string().optional().describe("Google OAuth client secret (for exchange_code)."),
15
+ code: z.string().optional().describe("Authorization code from Google (for exchange_code)."),
16
+ },
17
+ async ({ action, client_id, client_secret, code }) => {
18
+ if (action === "status") {
19
+ const method = getAuthMethod();
20
+ if (method === "none") {
21
+ return {
22
+ content: [
23
+ {
24
+ type: "text",
25
+ text: [
26
+ "=== Pagesight Auth Status ===",
27
+ "",
28
+ "Status: NOT CONFIGURED",
29
+ "",
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",
46
+ ].join("\n"),
47
+ },
48
+ ],
49
+ };
50
+ }
51
+ return {
52
+ content: [
53
+ {
54
+ type: "text",
55
+ text: `=== Pagesight Auth Status ===\n\nStatus: CONFIGURED\nMethod: ${method}`,
56
+ },
57
+ ],
58
+ };
59
+ }
60
+
61
+ if (action === "get_auth_url") {
62
+ if (!client_id) {
63
+ return { content: [{ type: "text", text: "Error: client_id is required for get_auth_url." }] };
64
+ }
65
+ const url = getOAuthSetupUrl(client_id);
66
+ return {
67
+ content: [
68
+ {
69
+ type: "text",
70
+ text: [
71
+ "=== OAuth Setup ===",
72
+ "",
73
+ "1. Open this URL in your browser:",
74
+ "",
75
+ url,
76
+ "",
77
+ "2. Sign in and authorize access to Search Console",
78
+ "3. Copy the authorization code",
79
+ "4. Call: setup(action='exchange_code', client_id='...', client_secret='...', code='THE_CODE')",
80
+ ].join("\n"),
81
+ },
82
+ ],
83
+ };
84
+ }
85
+
86
+ if (action === "exchange_code") {
87
+ if (!client_id || !client_secret || !code) {
88
+ return { content: [{ type: "text", text: "Error: client_id, client_secret, and code are all required." }] };
89
+ }
90
+ const tokens = await exchangeCodeForToken(client_id, client_secret, code);
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: [
96
+ "=== OAuth Setup Complete ===",
97
+ "",
98
+ "Add these environment variables to your MCP server config:",
99
+ "",
100
+ `GSC_CLIENT_ID=${client_id}`,
101
+ `GSC_CLIENT_SECRET=${client_secret}`,
102
+ `GSC_REFRESH_TOKEN=${tokens.refreshToken}`,
103
+ "",
104
+ "Then restart Pagesight.",
105
+ ].join("\n"),
106
+ },
107
+ ],
108
+ };
109
+ }
110
+
111
+ return { content: [{ type: "text", text: "Unknown action." }] };
112
+ },
113
+ );
114
+ }