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 +21 -0
- package/README.md +114 -0
- package/config.example.json +10 -0
- package/dist/build-info.json +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +354 -0
- package/dist/resilience.d.ts +3 -0
- package/dist/resilience.js +76 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +83 -0
- package/package.json +44 -0
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 @@
|
|
|
1
|
+
{"sha":"65b772d","builtAt":"2026-04-03T23:26:46.310Z"}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
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,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
|
+
}
|
package/dist/tools.d.ts
ADDED
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
|
+
}
|