mcp-google-gsc 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 drak-marketing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # mcp-gsc
2
+
3
+ MCP server for Google Search Console -- search analytics, URL inspection, and site management via Claude.
4
+
5
+ ## Features
6
+
7
+ - **Search Analytics** -- Query clicks, impressions, CTR, and position with flexible dimension filters (query, page, device, country, date)
8
+ - **URL Inspection** -- Check indexing status, mobile usability, and rich results for any URL
9
+ - **Site Listing** -- List all verified Search Console properties accessible to your service account
10
+ - **Multi-Client Support** -- Manage multiple GSC properties with per-directory config mapping
11
+
12
+ ## Installation
13
+
14
+ ### From npm
15
+
16
+ ```bash
17
+ npm install mcp-gsc
18
+ ```
19
+
20
+ ### From source
21
+
22
+ ```bash
23
+ git clone https://github.com/drak-marketing/mcp-gsc.git
24
+ cd mcp-gsc
25
+ npm install
26
+ npm run build
27
+ ```
28
+
29
+ ## Configuration
30
+
31
+ ### Service Account Setup
32
+
33
+ 1. Create a Google Cloud service account with Search Console API access
34
+ 2. Download the JSON key file
35
+ 3. Add the service account email as a user in Google Search Console for each property
36
+
37
+ ### Config File
38
+
39
+ Create a `config.json` in the project root (see `config.example.json` for the full structure):
40
+
41
+ ```json
42
+ {
43
+ "default_credentials": "/path/to/service-account-key.json",
44
+ "clients": {
45
+ "my-project": {
46
+ "site_url": "sc-domain:example.com",
47
+ "credentials": "/path/to/service-account-key.json"
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ ### Environment Variable
54
+
55
+ Alternatively, set `GOOGLE_APPLICATION_CREDENTIALS` to the path of your service account key file. The config file takes precedence when present.
56
+
57
+ ## Usage
58
+
59
+ Add to your Claude Code `.mcp.json`:
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "gsc": {
65
+ "command": "node",
66
+ "args": ["/path/to/mcp-gsc/dist/index.js"]
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ Or if installed globally:
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "gsc": {
78
+ "command": "npx",
79
+ "args": ["mcp-gsc"]
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ ## Tools
86
+
87
+ | Tool | Description |
88
+ |------|-------------|
89
+ | `gsc_get_client_context` | Detect the GSC property from your working directory based on config mapping |
90
+ | `gsc_list_sites` | List all verified Search Console properties accessible to the service account |
91
+ | `gsc_search_analytics` | Query search performance data (clicks, impressions, CTR, position) with dimension and filter support |
92
+ | `gsc_inspection` | Inspect a URL for indexing status, mobile usability, and rich results |
93
+
94
+ ### gsc_search_analytics
95
+
96
+ Supports dimensions: `query`, `page`, `device`, `country`, `date`. Filter by any dimension with operators like `equals`, `contains`, `notContains`. Date range defaults to the last 28 days.
97
+
98
+ ### gsc_inspection
99
+
100
+ Returns index coverage, crawl status, mobile usability verdict, and rich result details for a specific URL within a property.
101
+
102
+ ## Architecture
103
+
104
+ - **Resilience** -- Uses cockatiel for retry with exponential backoff and circuit breaker patterns on all Google API calls
105
+ - **Logging** -- Structured logging via pino with configurable log levels
106
+ - **Response Handling** -- Responses truncated at 200KB to stay within MCP transport limits
107
+
108
+ ## License
109
+
110
+ MIT -- see [LICENSE](LICENSE).
111
+
112
+ ---
113
+
114
+ Built by Mark Harnett / [drak-marketing](https://github.com/drak-marketing)
@@ -0,0 +1,10 @@
1
+ {
2
+ "credentials_file": "/path/to/service-account.json",
3
+ "clients": {
4
+ "my-client": {
5
+ "name": "My Client",
6
+ "folder": "/path/to/project",
7
+ "site_url": "https://example.com/"
8
+ }
9
+ }
10
+ }
@@ -0,0 +1 @@
1
+ {"sha":"65b772d","builtAt":"2026-04-03T23:26:46.310Z"}
@@ -0,0 +1,17 @@
1
+ export declare class GscAuthError extends Error {
2
+ readonly cause?: unknown | undefined;
3
+ constructor(message: string, cause?: unknown | undefined);
4
+ }
5
+ export declare class GscRateLimitError extends Error {
6
+ readonly retryAfterMs: number;
7
+ constructor(retryAfterMs: number, cause?: unknown);
8
+ }
9
+ export declare class GscServiceError extends Error {
10
+ readonly cause?: unknown | undefined;
11
+ constructor(message: string, cause?: unknown | undefined);
12
+ }
13
+ export declare function validateCredentials(credentialsFile: string): {
14
+ valid: boolean;
15
+ missing: string[];
16
+ };
17
+ export declare function classifyError(error: any): Error;
package/dist/errors.js ADDED
@@ -0,0 +1,58 @@
1
+ // ============================================
2
+ // TYPED ERRORS
3
+ // ============================================
4
+ export class GscAuthError extends Error {
5
+ cause;
6
+ constructor(message, cause) {
7
+ super(message);
8
+ this.cause = cause;
9
+ this.name = "GscAuthError";
10
+ }
11
+ }
12
+ export class GscRateLimitError extends Error {
13
+ retryAfterMs;
14
+ constructor(retryAfterMs, cause) {
15
+ super(`Rate limited, retry after ${retryAfterMs}ms`);
16
+ this.retryAfterMs = retryAfterMs;
17
+ this.name = "GscRateLimitError";
18
+ this.cause = cause;
19
+ }
20
+ }
21
+ export class GscServiceError extends Error {
22
+ cause;
23
+ constructor(message, cause) {
24
+ super(message);
25
+ this.cause = cause;
26
+ this.name = "GscServiceError";
27
+ }
28
+ }
29
+ // ============================================
30
+ // STARTUP CREDENTIAL VALIDATION
31
+ // ============================================
32
+ export function validateCredentials(credentialsFile) {
33
+ const missing = [];
34
+ if (!credentialsFile || credentialsFile.trim() === "") {
35
+ missing.push("credentials_file (in config.json or GOOGLE_APPLICATION_CREDENTIALS env var)");
36
+ }
37
+ return { valid: missing.length === 0, missing };
38
+ }
39
+ export function classifyError(error) {
40
+ const message = error?.message || String(error);
41
+ const status = error?.code || error?.status;
42
+ if (status === 401 ||
43
+ status === 403 ||
44
+ message.includes("invalid_grant") ||
45
+ message.includes("PERMISSION_DENIED") ||
46
+ message.includes("access_denied") ||
47
+ message.includes("Invalid credentials")) {
48
+ return new GscAuthError(`Auth failed: ${message}. Check service account credentials and permissions.`, error);
49
+ }
50
+ if (status === 429 || message.includes("rateLimitExceeded") || message.includes("RESOURCE_EXHAUSTED")) {
51
+ const retryMs = 60_000;
52
+ return new GscRateLimitError(retryMs, error);
53
+ }
54
+ if (status >= 500 || message.includes("INTERNAL") || message.includes("UNAVAILABLE")) {
55
+ return new GscServiceError(`GSC API server error: ${message}`, error);
56
+ }
57
+ return error;
58
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { readFileSync, existsSync } from "fs";
6
+ import { join, dirname } from "path";
7
+ import { google } from "googleapis";
8
+ import { GscAuthError, GscRateLimitError, GscServiceError, classifyError, validateCredentials, } from "./errors.js";
9
+ import { tools } from "./tools.js";
10
+ import { withResilience, safeResponse, logger } from "./resilience.js";
11
+ // Log build fingerprint at startup
12
+ try {
13
+ const __buildInfoDir = dirname(new URL(import.meta.url).pathname);
14
+ const buildInfo = JSON.parse(readFileSync(join(__buildInfoDir, "build-info.json"), "utf-8"));
15
+ console.error(`[build] SHA: ${buildInfo.sha} (${buildInfo.builtAt})`);
16
+ }
17
+ catch {
18
+ // build-info.json not present (dev mode)
19
+ }
20
+ function loadConfig() {
21
+ const configPath = join(dirname(new URL(import.meta.url).pathname), "..", "config.json");
22
+ if (!existsSync(configPath)) {
23
+ throw new Error(`Config file not found at ${configPath}. Copy config.example.json to config.json and fill in your credentials.`);
24
+ }
25
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"));
26
+ return {
27
+ credentials_file: raw.credentials_file || process.env.GOOGLE_APPLICATION_CREDENTIALS || "",
28
+ clients: raw.clients || {},
29
+ };
30
+ }
31
+ function getClientFromWorkingDir(config, cwd) {
32
+ for (const [key, client] of Object.entries(config.clients)) {
33
+ if (cwd.startsWith(client.folder) || cwd.includes(key)) {
34
+ return client;
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+ function getDefaultSiteUrl(config) {
40
+ const clients = Object.values(config.clients);
41
+ return clients.length > 0 ? clients[0].site_url : null;
42
+ }
43
+ // ============================================
44
+ // DATE HELPERS
45
+ // ============================================
46
+ function resolveDate(dateStr) {
47
+ const today = new Date();
48
+ if (dateStr === "today") {
49
+ return today.toISOString().slice(0, 10);
50
+ }
51
+ const match = dateStr.match(/^(\d+)daysAgo$/);
52
+ if (match) {
53
+ const days = parseInt(match[1], 10);
54
+ const d = new Date(today);
55
+ d.setDate(d.getDate() - days);
56
+ return d.toISOString().slice(0, 10);
57
+ }
58
+ return dateStr; // assume YYYY-MM-DD
59
+ }
60
+ function parseDimensionFilter(filterStr) {
61
+ if (!filterStr)
62
+ return null;
63
+ const operators = [
64
+ "includingRegex", "excludingRegex",
65
+ "notContains", "notEquals",
66
+ "contains", "equals",
67
+ ];
68
+ for (const op of operators) {
69
+ const parts = filterStr.split(` ${op} `, 2);
70
+ if (parts.length === 2) {
71
+ return {
72
+ dimension: parts[0].trim(),
73
+ operator: op,
74
+ expression: parts[1].trim(),
75
+ };
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ // ============================================
81
+ // GOOGLE SEARCH CONSOLE API CLIENT
82
+ // ============================================
83
+ class GscManager {
84
+ config;
85
+ service = null;
86
+ constructor(config) {
87
+ this.config = config;
88
+ const creds = validateCredentials(config.credentials_file);
89
+ if (!creds.valid) {
90
+ const msg = `[STARTUP ERROR] Missing required credentials: ${creds.missing.join(", ")}. MCP will not function.`;
91
+ console.error(msg);
92
+ throw new GscAuthError(msg);
93
+ }
94
+ }
95
+ getService() {
96
+ if (!this.service) {
97
+ const auth = new google.auth.GoogleAuth({
98
+ keyFile: this.config.credentials_file,
99
+ scopes: ["https://www.googleapis.com/auth/webmasters.readonly"],
100
+ });
101
+ this.service = google.searchconsole({ version: "v1", auth });
102
+ console.error(`[startup] Service account loaded from: ${this.config.credentials_file}`);
103
+ }
104
+ return this.service;
105
+ }
106
+ async listSites() {
107
+ const svc = this.getService();
108
+ return withResilience(async () => {
109
+ const resp = await svc.sites.list();
110
+ const sites = (resp.data.siteEntry || []).map((entry) => ({
111
+ site_url: entry.siteUrl || "",
112
+ permission_level: entry.permissionLevel || "",
113
+ }));
114
+ return { sites, count: sites.length };
115
+ }, "gsc_list_sites");
116
+ }
117
+ async searchAnalytics(options) {
118
+ const svc = this.getService();
119
+ const siteUrl = options.siteUrl || getDefaultSiteUrl(this.config);
120
+ if (!siteUrl) {
121
+ return { error: "No site_url provided and none found in config" };
122
+ }
123
+ const rowLimit = Math.min(Math.max(1, options.rowLimit), 25000);
124
+ const startDate = resolveDate(options.startDate);
125
+ const endDate = resolveDate(options.endDate);
126
+ const requestBody = {
127
+ startDate,
128
+ endDate,
129
+ dimensions: options.dimensions,
130
+ type: options.searchType,
131
+ rowLimit,
132
+ aggregationType: options.aggregationType,
133
+ };
134
+ const parsed = parseDimensionFilter(options.dimensionFilter);
135
+ if (parsed) {
136
+ requestBody.dimensionFilterGroups = [{
137
+ filters: [{
138
+ dimension: parsed.dimension,
139
+ operator: parsed.operator,
140
+ expression: parsed.expression,
141
+ }],
142
+ }];
143
+ }
144
+ return withResilience(async () => {
145
+ const resp = await svc.searchanalytics.query({
146
+ siteUrl,
147
+ requestBody,
148
+ });
149
+ const rows = (resp.data.rows || []).map((row) => {
150
+ const r = {};
151
+ for (let i = 0; i < options.dimensions.length; i++) {
152
+ if (row.keys && i < row.keys.length) {
153
+ r[options.dimensions[i]] = row.keys[i];
154
+ }
155
+ }
156
+ r.clicks = row.clicks || 0;
157
+ r.impressions = row.impressions || 0;
158
+ r.ctr = Math.round((row.ctr || 0) * 10000) / 10000;
159
+ r.position = Math.round((row.position || 0) * 10) / 10;
160
+ return r;
161
+ });
162
+ return {
163
+ rows,
164
+ row_count: rows.length,
165
+ date_range: `${startDate} to ${endDate}`,
166
+ site_url: siteUrl,
167
+ };
168
+ }, "gsc_search_analytics");
169
+ }
170
+ async inspection(url, siteUrl) {
171
+ const svc = this.getService();
172
+ const resolvedSiteUrl = siteUrl || getDefaultSiteUrl(this.config);
173
+ if (!resolvedSiteUrl) {
174
+ return { error: "No site_url provided and none found in config" };
175
+ }
176
+ return withResilience(async () => {
177
+ try {
178
+ const resp = await svc.urlInspection.index.inspect({
179
+ requestBody: {
180
+ inspectionUrl: url,
181
+ siteUrl: resolvedSiteUrl,
182
+ },
183
+ });
184
+ const result = resp.data.inspectionResult || {};
185
+ const indexStatus = result.indexStatusResult || {};
186
+ const mobile = result.mobileUsabilityResult || {};
187
+ const rich = result.richResultsResult || {};
188
+ return {
189
+ url,
190
+ site_url: resolvedSiteUrl,
191
+ index_status: {
192
+ verdict: indexStatus.verdict || "UNKNOWN",
193
+ coverage_state: indexStatus.coverageState || "",
194
+ indexing_state: indexStatus.indexingState || "",
195
+ last_crawl_time: indexStatus.lastCrawlTime || "",
196
+ page_fetch_state: indexStatus.pageFetchState || "",
197
+ robots_txt_state: indexStatus.robotsTxtState || "",
198
+ crawled_as: indexStatus.crawledAs || "",
199
+ referring_urls: indexStatus.referringUrls || [],
200
+ },
201
+ mobile_usability: {
202
+ verdict: mobile.verdict || "UNKNOWN",
203
+ issues: (mobile.issues || []).map((i) => i.issueType || ""),
204
+ },
205
+ rich_results: {
206
+ verdict: rich.verdict || "UNKNOWN",
207
+ },
208
+ };
209
+ }
210
+ catch (err) {
211
+ return { error: String(err), url, site_url: resolvedSiteUrl };
212
+ }
213
+ }, "gsc_inspection");
214
+ }
215
+ }
216
+ // ============================================
217
+ // MCP SERVER
218
+ // ============================================
219
+ const config = loadConfig();
220
+ const gscManager = new GscManager(config);
221
+ const server = new Server({
222
+ name: "mcp-gsc",
223
+ version: "1.0.0",
224
+ }, {
225
+ capabilities: {
226
+ tools: {},
227
+ },
228
+ });
229
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
230
+ return { tools };
231
+ });
232
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
233
+ const { name, arguments: args } = request.params;
234
+ try {
235
+ switch (name) {
236
+ case "gsc_get_client_context": {
237
+ const cwd = args?.working_directory;
238
+ const client = getClientFromWorkingDir(config, cwd);
239
+ if (!client) {
240
+ return {
241
+ content: [{
242
+ type: "text",
243
+ text: JSON.stringify({
244
+ error: "No client found for working directory",
245
+ working_directory: cwd,
246
+ available_clients: Object.entries(config.clients).map(([k, v]) => ({
247
+ key: k,
248
+ name: v.name,
249
+ folder: v.folder,
250
+ })),
251
+ }, null, 2),
252
+ }],
253
+ };
254
+ }
255
+ return {
256
+ content: [{
257
+ type: "text",
258
+ text: JSON.stringify({
259
+ client_name: client.name,
260
+ site_url: client.site_url,
261
+ folder: client.folder,
262
+ }, null, 2),
263
+ }],
264
+ };
265
+ }
266
+ case "gsc_list_sites": {
267
+ const result = await gscManager.listSites();
268
+ return {
269
+ content: [{
270
+ type: "text",
271
+ text: JSON.stringify(safeResponse(result, "listSites"), null, 2),
272
+ }],
273
+ };
274
+ }
275
+ case "gsc_search_analytics": {
276
+ const dimensions = (args?.dimensions || "query")
277
+ .split(",")
278
+ .map((d) => d.trim())
279
+ .filter(Boolean);
280
+ const result = await gscManager.searchAnalytics({
281
+ startDate: args?.start_date || "90daysAgo",
282
+ endDate: args?.end_date || "today",
283
+ dimensions,
284
+ searchType: args?.search_type || "web",
285
+ dimensionFilter: args?.dimension_filter || "",
286
+ rowLimit: args?.row_limit || 100,
287
+ aggregationType: args?.aggregation_type || "auto",
288
+ siteUrl: args?.site_url || "",
289
+ });
290
+ return {
291
+ content: [{
292
+ type: "text",
293
+ text: JSON.stringify(safeResponse(result, "searchAnalytics"), null, 2),
294
+ }],
295
+ };
296
+ }
297
+ case "gsc_inspection": {
298
+ const result = await gscManager.inspection(args?.url, args?.site_url || "");
299
+ return {
300
+ content: [{
301
+ type: "text",
302
+ text: JSON.stringify(result, null, 2),
303
+ }],
304
+ };
305
+ }
306
+ default:
307
+ throw new Error(`Unknown tool: ${name}`);
308
+ }
309
+ }
310
+ catch (rawError) {
311
+ const error = classifyError(rawError);
312
+ logger.error({ error_type: error.name, message: error.message }, "Tool call failed");
313
+ const response = {
314
+ error: true,
315
+ error_type: error.name,
316
+ message: error.message,
317
+ };
318
+ if (error instanceof GscAuthError) {
319
+ response.action_required = "Check service account credentials and Search Console permissions.";
320
+ }
321
+ else if (error instanceof GscRateLimitError) {
322
+ response.retry_after_ms = error.retryAfterMs;
323
+ response.action_required = `Rate limited. Retry after ${Math.ceil(error.retryAfterMs / 1000)} seconds.`;
324
+ }
325
+ else if (error instanceof GscServiceError) {
326
+ response.action_required = "Google Search Console API server error. This is transient - retry in a few minutes.";
327
+ }
328
+ else {
329
+ response.details = rawError.stack;
330
+ }
331
+ return {
332
+ content: [{
333
+ type: "text",
334
+ text: JSON.stringify(response, null, 2),
335
+ }],
336
+ isError: true,
337
+ };
338
+ }
339
+ });
340
+ // Start server
341
+ async function main() {
342
+ try {
343
+ await gscManager.listSites();
344
+ console.error("[startup] Auth verified: GSC API call succeeded");
345
+ }
346
+ catch (err) {
347
+ console.error(`[STARTUP WARNING] Auth check FAILED: ${err.message}`);
348
+ console.error(`[STARTUP WARNING] MCP will start but API calls may fail until auth is fixed.`);
349
+ }
350
+ const transport = new StdioServerTransport();
351
+ await server.connect(transport);
352
+ console.error("[startup] MCP GSC server running");
353
+ }
354
+ main().catch(console.error);
@@ -0,0 +1,3 @@
1
+ export declare const logger: import("pino").Logger<never>;
2
+ export declare function safeResponse<T>(data: T, context: string): T;
3
+ export declare function withResilience<T>(fn: () => Promise<T>, operationName: string): Promise<T>;
@@ -0,0 +1,76 @@
1
+ import { retry, circuitBreaker, wrap, handleAll, timeout, TimeoutStrategy, ExponentialBackoff, ConsecutiveBreaker, } from "cockatiel";
2
+ import pino from "pino";
3
+ // ============================================
4
+ // LOGGER
5
+ // ============================================
6
+ export const logger = pino({
7
+ level: process.env.LOG_LEVEL || "info",
8
+ ...(process.env.NODE_ENV !== "test" && {
9
+ transport: {
10
+ target: "pino-pretty",
11
+ options: {
12
+ colorize: true,
13
+ singleLine: true,
14
+ translateTime: "SYS:standard",
15
+ },
16
+ },
17
+ }),
18
+ });
19
+ // ============================================
20
+ // SAFE RESPONSE (Response Size Limiting)
21
+ // ============================================
22
+ const MAX_RESPONSE_SIZE = 200_000; // 200KB
23
+ export function safeResponse(data, context) {
24
+ const jsonStr = JSON.stringify(data);
25
+ const sizeBytes = Buffer.byteLength(jsonStr, "utf-8");
26
+ if (sizeBytes > MAX_RESPONSE_SIZE) {
27
+ logger.warn({ sizeBytes, maxSize: MAX_RESPONSE_SIZE, context }, `Response exceeds size limit, truncating`);
28
+ if (Array.isArray(data)) {
29
+ const truncated = data.slice(0, Math.max(1, Math.floor(data.length * 0.5)));
30
+ return truncated;
31
+ }
32
+ if (typeof data === "object" && data !== null) {
33
+ const obj = data;
34
+ for (const key of ["items", "results", "data", "rows"]) {
35
+ if (Array.isArray(obj[key])) {
36
+ obj[key] = obj[key].slice(0, Math.max(1, Math.floor(obj[key].length * 0.5)));
37
+ return obj;
38
+ }
39
+ }
40
+ }
41
+ }
42
+ return data;
43
+ }
44
+ // ============================================
45
+ // RETRY + CIRCUIT BREAKER + TIMEOUT
46
+ // ============================================
47
+ const backoff = new ExponentialBackoff({
48
+ initialDelay: 100,
49
+ maxDelay: 5_000,
50
+ });
51
+ const retryPolicy = retry(handleAll, {
52
+ maxAttempts: 3,
53
+ backoff,
54
+ });
55
+ const circuitBreakerPolicy = circuitBreaker(handleAll, {
56
+ halfOpenAfter: 60_000,
57
+ breaker: new ConsecutiveBreaker(5),
58
+ });
59
+ const timeoutPolicy = timeout(30_000, TimeoutStrategy.Cooperative);
60
+ const policy = wrap(timeoutPolicy, circuitBreakerPolicy, retryPolicy);
61
+ // ============================================
62
+ // WRAPPED API CALL WITH LOGGING
63
+ // ============================================
64
+ export async function withResilience(fn, operationName) {
65
+ try {
66
+ logger.debug({ operation: operationName }, "Starting API call");
67
+ const result = await policy.execute(() => fn());
68
+ logger.debug({ operation: operationName }, "API call succeeded");
69
+ return result;
70
+ }
71
+ catch (err) {
72
+ const error = err instanceof Error ? err : new Error(String(err));
73
+ logger.error({ operation: operationName, error: error.message, stack: error.stack }, "API call failed after retries");
74
+ throw error;
75
+ }
76
+ }
@@ -0,0 +1,2 @@
1
+ import { Tool } from "@modelcontextprotocol/sdk/types.js";
2
+ export declare const tools: Tool[];
package/dist/tools.js ADDED
@@ -0,0 +1,83 @@
1
+ export const tools = [
2
+ {
3
+ name: "gsc_get_client_context",
4
+ description: "Get the current GSC client context based on working directory. Call this first to confirm which Search Console property you're working with.",
5
+ inputSchema: {
6
+ type: "object",
7
+ properties: {
8
+ working_directory: {
9
+ type: "string",
10
+ description: "The current working directory",
11
+ },
12
+ },
13
+ required: ["working_directory"],
14
+ },
15
+ },
16
+ {
17
+ name: "gsc_list_sites",
18
+ description: "List all verified sites/properties in Google Search Console.",
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: {},
22
+ },
23
+ },
24
+ {
25
+ name: "gsc_search_analytics",
26
+ description: 'Query Google Search Console search analytics data. Returns clicks, impressions, CTR, and position for queries, pages, devices, countries, or dates.',
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ start_date: {
31
+ type: "string",
32
+ description: 'Start date (YYYY-MM-DD or relative like "90daysAgo", "30daysAgo", "7daysAgo", "today")',
33
+ },
34
+ end_date: {
35
+ type: "string",
36
+ description: 'End date (YYYY-MM-DD or relative like "today")',
37
+ },
38
+ dimensions: {
39
+ type: "string",
40
+ description: "Comma-separated dimensions: query, page, device, country, date",
41
+ },
42
+ search_type: {
43
+ type: "string",
44
+ description: 'Search type: web, image, video, news (default "web")',
45
+ },
46
+ dimension_filter: {
47
+ type: "string",
48
+ description: 'Optional filter (e.g., "query contains nonprofit", "page contains /blog/", "country equals USA")',
49
+ },
50
+ row_limit: {
51
+ type: "number",
52
+ description: "Max rows to return (default 100, max 25000)",
53
+ },
54
+ aggregation_type: {
55
+ type: "string",
56
+ description: 'Aggregation: auto, byPage, byProperty (default "auto")',
57
+ },
58
+ site_url: {
59
+ type: "string",
60
+ description: "Site URL (optional - auto-detected from working directory config)",
61
+ },
62
+ },
63
+ },
64
+ },
65
+ {
66
+ name: "gsc_inspection",
67
+ description: "Inspect a URL to check if it is indexed in Google Search. Returns index status, mobile usability, and rich results data.",
68
+ inputSchema: {
69
+ type: "object",
70
+ properties: {
71
+ url: {
72
+ type: "string",
73
+ description: 'The fully qualified URL to inspect (e.g., "https://www.example.com/blog/post")',
74
+ },
75
+ site_url: {
76
+ type: "string",
77
+ description: "Site URL / property (optional - auto-detected from config)",
78
+ },
79
+ },
80
+ required: ["url"],
81
+ },
82
+ },
83
+ ];
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "mcp-google-gsc",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Google Search Console API with multi-client support, search analytics, and URL inspection.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE",
18
+ "config.example.json"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc && node -e \"fs=require('fs');cp=require('child_process');sha=cp.execSync('git rev-parse --short HEAD 2>/dev/null||echo unknown').toString().trim();fs.writeFileSync('dist/build-info.json',JSON.stringify({sha,builtAt:new Date().toISOString()}))\"",
22
+ "start": "node dist/index.js",
23
+ "dev": "tsx src/index.ts",
24
+ "test": "vitest run"
25
+ },
26
+ "author": "drak-marketing",
27
+ "license": "MIT",
28
+ "engines": {
29
+ "node": ">=18.0.0"
30
+ },
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^0.5.0",
33
+ "cockatiel": "^3.2.1",
34
+ "googleapis": "^144.0.0",
35
+ "pino": "^8.21.0",
36
+ "pino-pretty": "^13.1.3"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^20.10.0",
40
+ "tsx": "^4.7.0",
41
+ "typescript": "^5.3.0",
42
+ "vitest": "^4.0.18"
43
+ }
44
+ }