search-console-mcp 1.9.3 → 1.10.1
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/README.md +47 -61
- package/dist/google-client.js +231 -13
- package/dist/index.js +27 -7
- package/dist/setup.js +119 -94
- package/dist/tools/analytics.js +81 -27
- package/dist/tools/schema-validator.js +1 -1
- package/dist/tools/seo-insights.js +2 -7
- package/dist/tools/sites-health.js +23 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
|
|
1
2
|
# Google Search Console MCP Server
|
|
2
3
|
|
|
3
4
|
A Model Context Protocol (MCP) server that transforms how you interact with Google Search Console. Stop exporting CSVs and start asking questions.
|
|
@@ -6,10 +7,6 @@ A Model Context Protocol (MCP) server that transforms how you interact with Goog
|
|
|
6
7
|
|
|
7
8
|
---
|
|
8
9
|
|
|
9
|
-
### [🏠 Overview](#) | [🎯 Prompts](#-magic-prompts) | [🚀 Quick Start](#-quick-start) | [🛠️ Tools](#-tools-reference)
|
|
10
|
-
|
|
11
|
-
---
|
|
12
|
-
|
|
13
10
|
[](https://opensource.org/licenses/MIT)
|
|
14
11
|
[](https://github.com/saurabhsharma2u/search-console-mcp/actions/workflows/ci.yml)
|
|
15
12
|
|
|
@@ -28,7 +25,6 @@ A Model Context Protocol (MCP) server that transforms how you interact with Goog
|
|
|
28
25
|
> "Find low-hanging fruit keywords (positions 11-20) with high impressions that I should optimize."
|
|
29
26
|
|
|
30
27
|
---
|
|
31
|
-
|
|
32
28
|
## 🎯 Magic Prompts
|
|
33
29
|
|
|
34
30
|
Copy and paste these into your MCP client (Claude Desktop, etc.) to see the intelligence engine in action:
|
|
@@ -53,69 +49,62 @@ Copy and paste these into your MCP client (Claude Desktop, etc.) to see the inte
|
|
|
53
49
|
|
|
54
50
|
---
|
|
55
51
|
|
|
56
|
-
##
|
|
52
|
+
## 🔐 Authentication (Desktop Flow)
|
|
53
|
+
|
|
54
|
+
Search Console MCP uses a **Secure Desktop Flow**. This provides high-security, professional grade authentication for your Google account:
|
|
55
|
+
- **Multi-Account Support**: Automatically detects and stores separate tokens for different Google accounts based on your email.
|
|
56
|
+
- **System Keychain Primary**: Tokens are stored in your OS's native credential manager (macOS Keychain, Windows Credential Manager, or Linux Secret Service).
|
|
57
|
+
- **AES-256-GCM Hardware-Bound Encryption**: Fallback storage is encrypted with AES-256-GCM using a key derived from your unique hardware machine ID. Tokens stolen from your machine cannot be decrypted on another computer.
|
|
58
|
+
- **Silent Background Refresh**: Tokens auto-refresh silently when they expire.
|
|
59
|
+
|
|
60
|
+
### 🚀 Step 1 — Initiate Login
|
|
61
|
+
Run the following command to start the authorization process:
|
|
62
|
+
```bash
|
|
63
|
+
npx search-console-mcp setup
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The CLI will:
|
|
67
|
+
1. Briefly start a secure local server to handle the redirect.
|
|
68
|
+
2. Open your default web browser to the Google Authorization page.
|
|
69
|
+
3. Automatically fetch your email after authorization to label your credentials securely.
|
|
57
70
|
|
|
58
|
-
|
|
71
|
+
### 🔑 Step 2 — Logout & Management
|
|
72
|
+
To wipe your credentials from both the keychain and the disk:
|
|
73
|
+
```bash
|
|
74
|
+
# Logout of the default account
|
|
75
|
+
npx search-console-mcp logout
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* **Direct Communication**: All API calls go directly from your machine to Google's servers (`googleapis.com`). There is no middleman or proxy server.
|
|
64
|
-
* **Open Source**: The code is fully open source. You can audit exactly how your credentials are used in `src/google-client.ts`.
|
|
77
|
+
# Logout of a specific account
|
|
78
|
+
npx search-console-mcp logout user@gmail.com
|
|
79
|
+
```
|
|
65
80
|
|
|
66
81
|
---
|
|
67
82
|
|
|
68
|
-
##
|
|
83
|
+
## 🔑 Alternative: Service Account (Advanced)
|
|
69
84
|
|
|
70
|
-
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
- **Schema Validation**: Validate JSON-LD structured data.
|
|
85
|
+
For server-side environments or automated tasks where interactive login isn't possible, you can use a Google Cloud Service Account.
|
|
86
|
+
|
|
87
|
+
### Setup:
|
|
88
|
+
1. **Create Service Account**: Go to the [Google Cloud Console](https://console.cloud.google.com/iam-admin/serviceaccounts) and create a service account.
|
|
89
|
+
2. **Generate Key**: Click "Keys" > "Add Key" > "Create new key" (JSON). Download this file.
|
|
90
|
+
3. **Share Access**: In Google Search Console, add the service account's email address (e.g., `account@project.iam.gserviceaccount.com`) as a user with at least "Full" or "Restricted" permissions.
|
|
91
|
+
4. **Configure**: Point the server to your key file:
|
|
92
|
+
```bash
|
|
93
|
+
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/key.json"
|
|
94
|
+
```
|
|
81
95
|
|
|
82
96
|
---
|
|
83
97
|
|
|
84
|
-
## Quick Start
|
|
85
98
|
|
|
86
|
-
|
|
87
|
-
First, use the interactive wizard to validate your credentials and get your configuration:
|
|
99
|
+
## 🛡️ Fort Knox Security
|
|
88
100
|
|
|
89
|
-
|
|
90
|
-
npx -y search-console-mcp setup
|
|
91
|
-
```
|
|
101
|
+
This MCP server implements a multi-layered security architecture:
|
|
92
102
|
|
|
93
|
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
### 2. Connect to an MCP Client
|
|
100
|
-
|
|
101
|
-
**Note:** The server uses `stdio` to communicate. Do not run `npx search-console-mcp` directly in your terminal; it is meant to be run by an MCP client like Claude Desktop or Cursor.
|
|
102
|
-
|
|
103
|
-
#### Claude Desktop Configuration
|
|
104
|
-
Add this to your `claude_desktop_config.json`:
|
|
105
|
-
|
|
106
|
-
```json
|
|
107
|
-
{
|
|
108
|
-
"mcpServers": {
|
|
109
|
-
"google-search-console": {
|
|
110
|
-
"command": "npx",
|
|
111
|
-
"args": ["-y", "search-console-mcp"],
|
|
112
|
-
"env": {
|
|
113
|
-
"GOOGLE_APPLICATION_CREDENTIALS": "/absolute/path/to/your/service-account-key.json"
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
```
|
|
103
|
+
* **Keychain Integration**: Primarily uses the **macOS Keychain**, **Windows Credential Manager**, or **libsecret (Linux)** to store tokens.
|
|
104
|
+
* **Hardware-Bound Vault**: Fallback tokens are stored in `~/.search-console-mcp-tokens.enc` and encrypted with **AES-256-GCM**.
|
|
105
|
+
* **Machine Fingerprinting**: The encryption key is derived from your unique hardware UUID and OS user. The encrypted file is useless if moved to another machine.
|
|
106
|
+
* **Minimalist Storage**: Only the `refresh_token` and `expiry_date` are stored.
|
|
107
|
+
* **Strict Unix Permissions**: The fallback file is created with `mode 600` (read/write only by your user).
|
|
119
108
|
|
|
120
109
|
---
|
|
121
110
|
|
|
@@ -165,12 +154,9 @@ These are low-level tools designed to be used by other AI agents to build comple
|
|
|
165
154
|
| `pagespeed_analyze` | Lighthouse scores & Core Web Vitals. |
|
|
166
155
|
| `schema_validate` | Validate Structured Data (JSON-LD). |
|
|
167
156
|
|
|
168
|
-
---
|
|
169
|
-
|
|
170
|
-
## Contributing
|
|
171
157
|
|
|
172
|
-
We love contributions! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
|
|
173
158
|
|
|
174
159
|
## License
|
|
175
160
|
|
|
176
|
-
MIT
|
|
161
|
+
[MIT](LICENSE.md)
|
|
162
|
+
[Contributing](CONTRIBUTING.md)
|
package/dist/google-client.js
CHANGED
|
@@ -1,16 +1,76 @@
|
|
|
1
1
|
import { google } from 'googleapis';
|
|
2
|
-
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { createCipheriv, createDecipheriv, scryptSync, randomBytes } from 'crypto';
|
|
6
|
+
import nodeMachineId from 'node-machine-id';
|
|
7
|
+
const { machineIdSync } = nodeMachineId;
|
|
8
|
+
const SCOPES = [
|
|
9
|
+
'https://www.googleapis.com/auth/webmasters.readonly',
|
|
10
|
+
'https://www.googleapis.com/auth/userinfo.email'
|
|
11
|
+
];
|
|
12
|
+
const TOKEN_PATH = join(homedir(), '.search-console-mcp-tokens.enc');
|
|
13
|
+
const SERVICE_NAME = 'io.github.saurabhsharma2u.search-console-mcp';
|
|
14
|
+
const DEFAULT_ACCOUNT = 'default';
|
|
15
|
+
// Default Client ID for Desktop Flow
|
|
16
|
+
export const DEFAULT_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '347626597503-dr6t24m0i3g1nl1suam86rs650t3fhau.apps.googleusercontent.com';
|
|
17
|
+
export const DEFAULT_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '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';
|
|
18
|
+
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
|
19
|
+
function getEncryptionKey() {
|
|
20
|
+
const mId = machineIdSync();
|
|
21
|
+
const salt = process.env.USER || 'sc-mcp-salt';
|
|
22
|
+
return scryptSync(mId, salt, 32);
|
|
23
|
+
}
|
|
24
|
+
function encrypt(text) {
|
|
25
|
+
const iv = randomBytes(12);
|
|
26
|
+
const key = getEncryptionKey();
|
|
27
|
+
const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
|
28
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
29
|
+
encrypted += cipher.final('hex');
|
|
30
|
+
const authTag = cipher.getAuthTag().toString('hex');
|
|
31
|
+
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
|
|
32
|
+
}
|
|
33
|
+
function decrypt(data) {
|
|
34
|
+
const [ivHex, authTagHex, encryptedHex] = data.split(':');
|
|
35
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
36
|
+
const authTag = Buffer.from(authTagHex, 'hex');
|
|
37
|
+
const key = getEncryptionKey();
|
|
38
|
+
const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
|
39
|
+
decipher.setAuthTag(authTag);
|
|
40
|
+
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
|
|
41
|
+
decrypted += decipher.final('utf8');
|
|
42
|
+
return decrypted;
|
|
43
|
+
}
|
|
3
44
|
let cachedClient = null;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* Supports both file-based credentials (GOOGLE_APPLICATION_CREDENTIALS)
|
|
7
|
-
* and environment variable credentials (GOOGLE_CLIENT_EMAIL + GOOGLE_PRIVATE_KEY).
|
|
8
|
-
*/
|
|
9
|
-
export async function getSearchConsoleClient() {
|
|
10
|
-
if (cachedClient) {
|
|
45
|
+
export async function getSearchConsoleClient(targetEmail) {
|
|
46
|
+
if (cachedClient && !targetEmail) {
|
|
11
47
|
return cachedClient;
|
|
12
48
|
}
|
|
13
|
-
//
|
|
49
|
+
// 1. Load Tokens (Keychain first, then File)
|
|
50
|
+
const tokens = await loadTokens(targetEmail);
|
|
51
|
+
if (tokens) {
|
|
52
|
+
try {
|
|
53
|
+
const oauth2Client = new google.auth.OAuth2(process.env.GOOGLE_CLIENT_ID || DEFAULT_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET || DEFAULT_CLIENT_SECRET);
|
|
54
|
+
oauth2Client.setCredentials(tokens);
|
|
55
|
+
// Check for expiry (refresh if needed)
|
|
56
|
+
if (tokens.expiry_date && tokens.expiry_date <= Date.now()) {
|
|
57
|
+
const { credentials } = await oauth2Client.refreshAccessToken();
|
|
58
|
+
// Since we refresh, we need the email to save back.
|
|
59
|
+
// If we don't have targetEmail, we try to fetch it from the new credentials or previous data
|
|
60
|
+
const email = targetEmail || await getUserEmail(credentials);
|
|
61
|
+
await saveTokens(credentials, email);
|
|
62
|
+
oauth2Client.setCredentials(credentials);
|
|
63
|
+
}
|
|
64
|
+
const client = google.searchconsole({ version: 'v1', auth: oauth2Client });
|
|
65
|
+
if (!targetEmail)
|
|
66
|
+
cachedClient = client;
|
|
67
|
+
return client;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
console.error('Failed to use stored OAuth2 tokens:', error.message);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// 2. Fallback to Service Account (Environment Variables)
|
|
14
74
|
if (!process.env.GOOGLE_APPLICATION_CREDENTIALS && process.env.GOOGLE_CLIENT_EMAIL && process.env.GOOGLE_PRIVATE_KEY) {
|
|
15
75
|
const jwtClient = new google.auth.JWT({
|
|
16
76
|
email: process.env.GOOGLE_CLIENT_EMAIL,
|
|
@@ -21,11 +81,169 @@ export async function getSearchConsoleClient() {
|
|
|
21
81
|
cachedClient = google.searchconsole({ version: 'v1', auth: jwtClient });
|
|
22
82
|
return cachedClient;
|
|
23
83
|
}
|
|
24
|
-
//
|
|
84
|
+
// 3. Fallback to File-based credentials
|
|
25
85
|
const auth = new google.auth.GoogleAuth({
|
|
26
86
|
scopes: SCOPES,
|
|
27
87
|
});
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
88
|
+
try {
|
|
89
|
+
const client = await auth.getClient();
|
|
90
|
+
cachedClient = google.searchconsole({ version: 'v1', auth: client });
|
|
91
|
+
return cachedClient;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new Error('No valid authentication found. Please run "search-console-mcp login" to authorize.');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function getUserEmail(tokens) {
|
|
98
|
+
const oauth2Client = new google.auth.OAuth2(process.env.GOOGLE_CLIENT_ID || DEFAULT_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET || DEFAULT_CLIENT_SECRET);
|
|
99
|
+
oauth2Client.setCredentials(tokens);
|
|
100
|
+
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
|
|
101
|
+
const userInfo = await oauth2.userinfo.get();
|
|
102
|
+
return userInfo.data.email || DEFAULT_ACCOUNT;
|
|
103
|
+
}
|
|
104
|
+
export async function logout(email) {
|
|
105
|
+
const target = email || DEFAULT_ACCOUNT;
|
|
106
|
+
// 1. Try Keychain
|
|
107
|
+
try {
|
|
108
|
+
const { Entry } = await import('@napi-rs/keyring');
|
|
109
|
+
const entry = new Entry(SERVICE_NAME, target);
|
|
110
|
+
await entry.deletePassword();
|
|
111
|
+
}
|
|
112
|
+
catch (e) { }
|
|
113
|
+
// 2. Clear from file
|
|
114
|
+
if (existsSync(TOKEN_PATH)) {
|
|
115
|
+
try {
|
|
116
|
+
const encryptedData = readFileSync(TOKEN_PATH, 'utf-8');
|
|
117
|
+
const decrypted = decrypt(encryptedData);
|
|
118
|
+
const allTokens = JSON.parse(decrypted);
|
|
119
|
+
delete allTokens[target];
|
|
120
|
+
if (Object.keys(allTokens).length === 0) {
|
|
121
|
+
// Just delete the file if no accounts left
|
|
122
|
+
import('fs').then(fs => fs.unlinkSync(TOKEN_PATH)).catch(() => { });
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const updatedEncrypted = encrypt(JSON.stringify(allTokens));
|
|
126
|
+
writeFileSync(TOKEN_PATH, updatedEncrypted, { mode: 0o600 });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (e) { }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function loadTokens(email) {
|
|
133
|
+
const target = email || DEFAULT_ACCOUNT;
|
|
134
|
+
// 1. Try Keychain
|
|
135
|
+
try {
|
|
136
|
+
const { Entry } = await import('@napi-rs/keyring');
|
|
137
|
+
const entry = new Entry(SERVICE_NAME, target);
|
|
138
|
+
const secret = await entry.getPassword();
|
|
139
|
+
if (secret) {
|
|
140
|
+
return JSON.parse(secret);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (e) { }
|
|
144
|
+
// 2. Try Encrypted File
|
|
145
|
+
if (existsSync(TOKEN_PATH)) {
|
|
146
|
+
try {
|
|
147
|
+
const encryptedData = readFileSync(TOKEN_PATH, 'utf-8');
|
|
148
|
+
const decrypted = decrypt(encryptedData);
|
|
149
|
+
const allTokens = JSON.parse(decrypted);
|
|
150
|
+
return allTokens[target] || (email ? null : Object.values(allTokens)[0]);
|
|
151
|
+
}
|
|
152
|
+
catch (e) { }
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
export async function saveTokens(tokens, email) {
|
|
157
|
+
const target = email || DEFAULT_ACCOUNT;
|
|
158
|
+
// Only store what we need
|
|
159
|
+
const minimalTokens = {
|
|
160
|
+
refresh_token: tokens.refresh_token,
|
|
161
|
+
expiry_date: tokens.expiry_date,
|
|
162
|
+
access_token: tokens.access_token // Needed for immediate use, but refresh_token is the key
|
|
163
|
+
};
|
|
164
|
+
const tokenStr = JSON.stringify(minimalTokens);
|
|
165
|
+
// 1. Try Keychain
|
|
166
|
+
let keychainSuccess = false;
|
|
167
|
+
try {
|
|
168
|
+
const { Entry } = await import('@napi-rs/keyring');
|
|
169
|
+
const entry = new Entry(SERVICE_NAME, target);
|
|
170
|
+
await entry.setPassword(tokenStr);
|
|
171
|
+
keychainSuccess = true;
|
|
172
|
+
}
|
|
173
|
+
catch (e) { }
|
|
174
|
+
// 2. Always write to encrypted file as a fallback
|
|
175
|
+
try {
|
|
176
|
+
let allTokens = {};
|
|
177
|
+
if (existsSync(TOKEN_PATH)) {
|
|
178
|
+
try {
|
|
179
|
+
const encryptedData = readFileSync(TOKEN_PATH, 'utf-8');
|
|
180
|
+
const decrypted = decrypt(encryptedData);
|
|
181
|
+
allTokens = JSON.parse(decrypted);
|
|
182
|
+
}
|
|
183
|
+
catch (e) { }
|
|
184
|
+
}
|
|
185
|
+
allTokens[target] = minimalTokens;
|
|
186
|
+
const encrypted = encrypt(JSON.stringify(allTokens));
|
|
187
|
+
writeFileSync(TOKEN_PATH, encrypted, { mode: 0o600 });
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
if (!keychainSuccess)
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export async function initiateDeviceFlow(clientId) {
|
|
195
|
+
const response = await fetch('https://oauth2.googleapis.com/device/code', {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
198
|
+
body: new URLSearchParams({
|
|
199
|
+
client_id: clientId,
|
|
200
|
+
scope: SCOPES.join(' ')
|
|
201
|
+
})
|
|
202
|
+
});
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
const error = await response.text();
|
|
205
|
+
throw new Error(`Failed to initiate device flow: ${error}`);
|
|
206
|
+
}
|
|
207
|
+
return await response.json();
|
|
208
|
+
}
|
|
209
|
+
export async function pollForTokens(clientId, clientSecret, deviceCode, interval) {
|
|
210
|
+
// This is now deprecated as Device Flow doesn't support Search Console scopes
|
|
211
|
+
throw new Error("Device Flow is not supported for Search Console API.");
|
|
212
|
+
}
|
|
213
|
+
export async function startLocalFlow(clientId, clientSecret) {
|
|
214
|
+
const { createServer } = await import('http');
|
|
215
|
+
const { google } = await import('googleapis');
|
|
216
|
+
const open = (await import('open')).default;
|
|
217
|
+
const REDIRECT_URI = 'http://localhost:3000/oauth2callback';
|
|
218
|
+
const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, REDIRECT_URI);
|
|
219
|
+
const authUrl = oauth2Client.generateAuthUrl({
|
|
220
|
+
access_type: 'offline',
|
|
221
|
+
scope: SCOPES,
|
|
222
|
+
prompt: 'consent'
|
|
223
|
+
});
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
const server = createServer(async (req, res) => {
|
|
226
|
+
try {
|
|
227
|
+
if (req.url?.startsWith('/oauth2callback')) {
|
|
228
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
229
|
+
const code = url.searchParams.get('code');
|
|
230
|
+
if (code) {
|
|
231
|
+
const { tokens } = await oauth2Client.getToken(code);
|
|
232
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
233
|
+
res.end('<h1>Authentication Successful!</h1><p>You can close this tab now and return to your terminal.</p>');
|
|
234
|
+
server.close();
|
|
235
|
+
resolve(tokens);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
res.writeHead(500);
|
|
241
|
+
res.end('<h1>Authentication Failed</h1>');
|
|
242
|
+
server.close();
|
|
243
|
+
reject(e);
|
|
244
|
+
}
|
|
245
|
+
}).listen(3000);
|
|
246
|
+
console.log('\nOpening your browser to authorize Search Console access...');
|
|
247
|
+
open(authUrl);
|
|
248
|
+
});
|
|
31
249
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import 'dotenv/config';
|
|
2
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
5
|
import { z } from "zod";
|
|
@@ -13,6 +14,9 @@ import * as schemaValidator from "./tools/schema-validator.js";
|
|
|
13
14
|
import * as advancedAnalytics from "./tools/advanced-analytics.js";
|
|
14
15
|
import * as sitesHealth from "./tools/sites-health.js";
|
|
15
16
|
import { formatError } from "./errors.js";
|
|
17
|
+
import { existsSync } from "fs";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { homedir } from "os";
|
|
16
20
|
const server = new McpServer({
|
|
17
21
|
name: "search-console-mcp",
|
|
18
22
|
version: "1.0.0",
|
|
@@ -783,22 +787,38 @@ End with an overall portfolio summary and the single most important action to ta
|
|
|
783
787
|
}]
|
|
784
788
|
}));
|
|
785
789
|
async function main() {
|
|
786
|
-
|
|
787
|
-
|
|
790
|
+
const command = process.argv[2];
|
|
791
|
+
// Handle standalone commands
|
|
792
|
+
if (command === 'setup') {
|
|
788
793
|
const { main: setupMain } = await import('./setup.js');
|
|
789
794
|
await setupMain();
|
|
790
795
|
return;
|
|
791
796
|
}
|
|
792
|
-
if (
|
|
797
|
+
if (command === 'logout') {
|
|
798
|
+
const { runLogout } = await import('./setup.js');
|
|
799
|
+
await runLogout();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (command === 'login') {
|
|
803
|
+
const { login } = await import('./setup.js');
|
|
804
|
+
await login();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
// Check for credentials
|
|
808
|
+
const hasServiceAccount = !!process.env.GOOGLE_APPLICATION_CREDENTIALS || (!!process.env.GOOGLE_CLIENT_EMAIL && !!process.env.GOOGLE_PRIVATE_KEY);
|
|
809
|
+
const tokenPath = join(homedir(), '.search-console-mcp-tokens.enc');
|
|
810
|
+
const hasOAuthTokens = existsSync(tokenPath);
|
|
811
|
+
if (!hasServiceAccount && !hasOAuthTokens) {
|
|
793
812
|
console.error('\n╔══════════════════════════════════════════════════════════════╗');
|
|
794
813
|
console.error('║ 🚀 Google Search Console MCP Server ║');
|
|
795
814
|
console.error('╚══════════════════════════════════════════════════════════════╝\n');
|
|
796
|
-
console.error('❌
|
|
797
|
-
console.error('💡
|
|
815
|
+
console.error('❌ No authentication found.\n');
|
|
816
|
+
console.error('💡 Please authorize with your Google Account:');
|
|
817
|
+
console.error(' npx -y search-console-mcp login\n');
|
|
818
|
+
console.error('Alternatively, run the setup wizard for other options:');
|
|
798
819
|
console.error(' npx -y search-console-mcp setup\n');
|
|
799
|
-
console.error('Alternatively, set the variable manually:');
|
|
800
|
-
console.error(' export GOOGLE_APPLICATION_CREDENTIALS=/path/to/your/key.json\n');
|
|
801
820
|
console.error('─'.repeat(64) + '\n');
|
|
821
|
+
// We don't exit here because the transport might still be needed or the user might be piping output
|
|
802
822
|
}
|
|
803
823
|
const transport = new StdioServerTransport();
|
|
804
824
|
await server.connect(transport);
|
package/dist/setup.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import 'dotenv/config';
|
|
2
3
|
import { readFileSync, existsSync, statSync } from 'fs';
|
|
3
4
|
import { resolve, dirname, extname } from 'path';
|
|
4
5
|
import { createInterface } from 'readline';
|
|
5
6
|
import { homedir } from 'os';
|
|
6
7
|
import { fileURLToPath } from 'url';
|
|
7
8
|
import { execSync } from 'child_process';
|
|
9
|
+
import { startLocalFlow, saveTokens, getUserEmail, logout, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET } from './google-client.js';
|
|
8
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
11
|
const __dirname = dirname(__filename);
|
|
10
12
|
const rl = createInterface({
|
|
@@ -35,7 +37,7 @@ function printError(text) {
|
|
|
35
37
|
function printInfo(text) {
|
|
36
38
|
console.log(`ℹ️ ${text}`);
|
|
37
39
|
}
|
|
38
|
-
function validateKeyFile(path) {
|
|
40
|
+
export function validateKeyFile(path) {
|
|
39
41
|
try {
|
|
40
42
|
const sanitizedPath = path.trim().replace(/\0/g, '');
|
|
41
43
|
const expandedPath = sanitizedPath.startsWith('~')
|
|
@@ -55,10 +57,6 @@ function validateKeyFile(path) {
|
|
|
55
57
|
printError(`Invalid file type. Please provide a .json file.`);
|
|
56
58
|
return null;
|
|
57
59
|
}
|
|
58
|
-
if (stats.size > 1024 * 1024) {
|
|
59
|
-
printError(`File too large. Service account keys are typically small JSON files.`);
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
60
|
const content = readFileSync(fullPath, 'utf-8');
|
|
63
61
|
const key = JSON.parse(content);
|
|
64
62
|
const required = ['type', 'project_id', 'client_email', 'private_key'];
|
|
@@ -78,7 +76,7 @@ function validateKeyFile(path) {
|
|
|
78
76
|
return null;
|
|
79
77
|
}
|
|
80
78
|
}
|
|
81
|
-
async function testConnection(keyPath) {
|
|
79
|
+
export async function testConnection(keyPath) {
|
|
82
80
|
try {
|
|
83
81
|
process.env.GOOGLE_APPLICATION_CREDENTIALS = resolve(keyPath.replace('~', homedir()));
|
|
84
82
|
const { google } = await import('googleapis');
|
|
@@ -93,36 +91,19 @@ async function testConnection(keyPath) {
|
|
|
93
91
|
return false;
|
|
94
92
|
}
|
|
95
93
|
}
|
|
96
|
-
function showConfigSnippets(credentialsPath) {
|
|
94
|
+
export function showConfigSnippets(credentialsPath) {
|
|
97
95
|
console.log('\nAdd this to your MCP client configuration:\n');
|
|
98
|
-
console.log('For Claude Desktop (~/.config/claude/claude_desktop_config.json):');
|
|
99
|
-
console.log('─'.repeat(60));
|
|
100
96
|
console.log(JSON.stringify({
|
|
101
97
|
mcpServers: {
|
|
102
98
|
"search-console": {
|
|
103
99
|
command: "npx",
|
|
104
|
-
args: ["search-console-mcp"],
|
|
100
|
+
args: ["-y", "search-console-mcp"],
|
|
105
101
|
env: {
|
|
106
102
|
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath
|
|
107
103
|
}
|
|
108
104
|
}
|
|
109
105
|
}
|
|
110
106
|
}, null, 2));
|
|
111
|
-
console.log('─'.repeat(60));
|
|
112
|
-
console.log('\nFor VS Code Copilot (.vscode/mcp.json):');
|
|
113
|
-
console.log('─'.repeat(60));
|
|
114
|
-
console.log(JSON.stringify({
|
|
115
|
-
servers: {
|
|
116
|
-
"search-console": {
|
|
117
|
-
command: "npx",
|
|
118
|
-
args: ["search-console-mcp"],
|
|
119
|
-
env: {
|
|
120
|
-
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}, null, 2));
|
|
125
|
-
console.log('─'.repeat(60));
|
|
126
107
|
}
|
|
127
108
|
export function resolveRepo(dirname) {
|
|
128
109
|
let repo = '';
|
|
@@ -141,107 +122,151 @@ export function resolveRepo(dirname) {
|
|
|
141
122
|
repo = pkg.repository.url.replace(/.*github\.com\//, '').replace(/\.git$/, '');
|
|
142
123
|
}
|
|
143
124
|
else if (pkg.mcpName && pkg.mcpName.includes('/')) {
|
|
144
|
-
// Handle io.github.owner/repo or owner/repo
|
|
145
125
|
repo = pkg.mcpName.replace(/^io\.github\./, '').split('/').slice(-2).join('/');
|
|
146
126
|
}
|
|
147
127
|
}
|
|
148
128
|
}
|
|
149
129
|
return repo;
|
|
150
130
|
}
|
|
151
|
-
export async function
|
|
131
|
+
export async function login() {
|
|
152
132
|
printHeader();
|
|
153
|
-
|
|
154
|
-
console.log('
|
|
155
|
-
|
|
133
|
+
printStep(1, 'Browser Authorization');
|
|
134
|
+
console.log('Using Secure Desktop Flow.');
|
|
135
|
+
console.log('Note: We will automatically fetch your email to support multiple accounts.');
|
|
136
|
+
// Use centralized defaults
|
|
137
|
+
const clientId = DEFAULT_CLIENT_ID;
|
|
138
|
+
const clientSecret = DEFAULT_CLIENT_SECRET;
|
|
139
|
+
try {
|
|
140
|
+
const tokens = await startLocalFlow(clientId, clientSecret);
|
|
141
|
+
printInfo('Fetching account information...');
|
|
142
|
+
const email = await getUserEmail(tokens);
|
|
143
|
+
await saveTokens(tokens, email);
|
|
144
|
+
printSuccess(`Successfully authenticated as ${email}!`);
|
|
145
|
+
printInfo('Tokens are stored securely in your system keychain.');
|
|
146
|
+
printStep(2, 'Configure your MCP client');
|
|
147
|
+
showOAuth2ConfigSnippets(clientId, clientSecret);
|
|
148
|
+
await supportProject();
|
|
149
|
+
rl.close();
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
printError(`Authentication failed: ${error.message}`);
|
|
153
|
+
console.log('\nTip: Ensure you are using a "Desktop Application" Client ID type in the Cloud Console.');
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function runLogout() {
|
|
158
|
+
printHeader();
|
|
159
|
+
printInfo('Logging out and clearing secure credentials...');
|
|
160
|
+
// Get email from CLI args if provided: search-console-mcp logout user@gmail.com
|
|
161
|
+
const email = process.argv[3];
|
|
162
|
+
try {
|
|
163
|
+
await logout(email);
|
|
164
|
+
if (email) {
|
|
165
|
+
printSuccess(`Successfully logged out and removed credentials for ${email}.`);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
printSuccess('Successfully logged out from default account.');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
printError(`Logout failed: ${error.message}`);
|
|
173
|
+
}
|
|
174
|
+
rl.close();
|
|
175
|
+
}
|
|
176
|
+
function showOAuth2ConfigSnippets(clientId, clientSecret) {
|
|
177
|
+
console.log('\nAdd this to your MCP client configuration:\n');
|
|
178
|
+
console.log(JSON.stringify({
|
|
179
|
+
mcpServers: {
|
|
180
|
+
"search-console": {
|
|
181
|
+
command: "npx",
|
|
182
|
+
args: ["-y", "search-console-mcp"],
|
|
183
|
+
env: {
|
|
184
|
+
GOOGLE_CLIENT_ID: clientId,
|
|
185
|
+
GOOGLE_CLIENT_SECRET: clientSecret
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}, null, 2));
|
|
190
|
+
}
|
|
191
|
+
async function setupServiceAccount() {
|
|
156
192
|
printStep(1, 'Locate your service account JSON key file');
|
|
157
193
|
console.log('If you don\'t have one yet, follow these steps:');
|
|
158
194
|
console.log(' 1. Go to https://console.cloud.google.com/iam-admin/serviceaccounts');
|
|
159
195
|
console.log(' 2. Create a new service account (or select existing)');
|
|
160
196
|
console.log(' 3. Click "Keys" > "Add Key" > "Create new key" > "JSON"');
|
|
161
197
|
console.log(' 4. Save the downloaded JSON file\n');
|
|
162
|
-
const keyPath = await ask('Enter the path to your JSON key file
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
console.log(' 7. Set permission to "Full" (or "Restricted" for read-only)');
|
|
190
|
-
console.log(' 8. Click "Add"\n');
|
|
191
|
-
await ask('Press Enter when you\'ve added the service account to Search Console...');
|
|
192
|
-
// Step 3: Test connection
|
|
193
|
-
printStep(3, 'Test connection');
|
|
194
|
-
console.log('Testing authentication with Google APIs...');
|
|
195
|
-
const connected = await testConnection(keyPath);
|
|
196
|
-
if (connected) {
|
|
197
|
-
printSuccess('Authentication successful!');
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
printError('Authentication failed. Please check your credentials and try again.');
|
|
201
|
-
rl.close();
|
|
202
|
-
process.exit(1);
|
|
203
|
-
}
|
|
204
|
-
// Step 4: Show configuration
|
|
205
|
-
printStep(4, 'Configure your MCP client');
|
|
206
|
-
showConfigSnippets(credentialsPath);
|
|
207
|
-
console.log('\n🎉 Setup complete! You can now use Search Console MCP.\n');
|
|
198
|
+
const keyPath = await ask('Enter the path to your JSON key file: ');
|
|
199
|
+
if (!keyPath) {
|
|
200
|
+
printError('No credentials file provided.');
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
const key = validateKeyFile(keyPath);
|
|
204
|
+
if (!key) {
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
printSuccess('JSON key file is valid!');
|
|
208
|
+
const serviceAccountEmail = key.client_email;
|
|
209
|
+
const credentialsPath = resolve(keyPath.replace('~', homedir()));
|
|
210
|
+
printStep(2, 'Add service account to Google Search Console');
|
|
211
|
+
console.log('You need to add this email as a user in Google Search Console:\n');
|
|
212
|
+
console.log(` 📧 ${serviceAccountEmail}\n`);
|
|
213
|
+
console.log('Steps:');
|
|
214
|
+
console.log(' 1. Go to https://search.google.com/search-console');
|
|
215
|
+
console.log(' 2. Select your property');
|
|
216
|
+
console.log(' 3. Click "Settings" > "Users and permissions" > "Add user"');
|
|
217
|
+
console.log(` 4. Enter: ${serviceAccountEmail}`);
|
|
218
|
+
console.log(' 5. Set permission to "Full" or "Restricted" and click "Add"\n');
|
|
219
|
+
await ask('Press Enter when you\'ve added the service account to Search Console...');
|
|
220
|
+
printStep(3, 'Test connection');
|
|
221
|
+
console.log('Testing authentication with Google APIs...');
|
|
222
|
+
const connected = await testConnection(credentialsPath);
|
|
223
|
+
if (connected) {
|
|
224
|
+
printSuccess('Authentication successful!');
|
|
208
225
|
}
|
|
209
226
|
else {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
printStep(5, 'Support this project');
|
|
221
|
-
const answer = await ask('Would you like to star the repo on GitHub? (Y/n): ');
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
printStep(4, 'Configure your MCP client');
|
|
230
|
+
showConfigSnippets(credentialsPath);
|
|
231
|
+
console.log('\n🎉 Setup complete! You can now use Search Console MCP.\n');
|
|
232
|
+
await supportProject();
|
|
233
|
+
rl.close();
|
|
234
|
+
}
|
|
235
|
+
async function supportProject() {
|
|
236
|
+
const answer = await ask('\nWould you like to star the repo on GitHub? (Y/n): ');
|
|
222
237
|
if (answer === '' || answer.toLowerCase().startsWith('y')) {
|
|
223
238
|
try {
|
|
224
239
|
const repo = resolveRepo(__dirname);
|
|
225
|
-
// Harden against command injection: ensure repo is in 'owner/repo' format
|
|
226
240
|
if (repo && /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repo)) {
|
|
227
241
|
execSync(`gh api -X PUT /user/starred/${repo}`, { stdio: 'ignore' });
|
|
228
242
|
printSuccess('Thanks for your support! ⭐');
|
|
229
243
|
}
|
|
230
244
|
else {
|
|
231
|
-
|
|
245
|
+
console.log('🔗 https://github.com/saurabhsharma2u/search-console-mcp');
|
|
232
246
|
}
|
|
233
247
|
}
|
|
234
248
|
catch (error) {
|
|
235
|
-
console.log('\nCould not star automatically. Please star us manually if you like:');
|
|
236
249
|
console.log('🔗 https://github.com/saurabhsharma2u/search-console-mcp');
|
|
237
250
|
}
|
|
238
251
|
}
|
|
252
|
+
}
|
|
253
|
+
export async function main() {
|
|
254
|
+
printHeader();
|
|
255
|
+
console.log('Choose your authentication method:');
|
|
256
|
+
console.log('\n1. OAuth 2.0 Desktop Flow (Recommended)');
|
|
257
|
+
console.log(' - Pros: Automatic authorization, uses your direct Google Account.');
|
|
258
|
+
console.log(' - Requirement: Opens a browser window and starts a brief local server.');
|
|
259
|
+
console.log('\n2. Service Account (Advanced)');
|
|
260
|
+
console.log(' - Pros: Fixed credentials, ideal for server environments and automated tasks.');
|
|
261
|
+
console.log(' - Cons: Requires creating a Google Cloud Project and manual Search Console sharing.');
|
|
262
|
+
const choice = await ask('\nEnter your choice (1 or 2, default is 1): ');
|
|
263
|
+
if (choice === '2') {
|
|
264
|
+
await setupServiceAccount();
|
|
265
|
+
}
|
|
239
266
|
else {
|
|
240
|
-
|
|
267
|
+
await login();
|
|
241
268
|
}
|
|
242
|
-
rl.close();
|
|
243
269
|
}
|
|
244
|
-
// Run if called directly
|
|
245
270
|
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
246
271
|
if (isMain) {
|
|
247
272
|
main().catch((error) => {
|
package/dist/tools/analytics.js
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import { getSearchConsoleClient } from '../google-client.js';
|
|
2
|
+
const CACHE_TTL_MS = 60 * 1000; // 60 seconds
|
|
3
|
+
const MAX_CACHE_SIZE = 100;
|
|
4
|
+
const analyticsCache = new Map();
|
|
5
|
+
export function clearAnalyticsCache() {
|
|
6
|
+
analyticsCache.clear();
|
|
7
|
+
}
|
|
8
|
+
function generateCacheKey(options) {
|
|
9
|
+
const clone = { ...options };
|
|
10
|
+
// Dimensions order matters because it determines the order of keys in the response.
|
|
11
|
+
// We should NOT sort them.
|
|
12
|
+
/*
|
|
13
|
+
if (clone.dimensions) {
|
|
14
|
+
clone.dimensions = [...clone.dimensions].sort();
|
|
15
|
+
}
|
|
16
|
+
*/
|
|
17
|
+
if (clone.filters) {
|
|
18
|
+
clone.filters = [...clone.filters].sort((a, b) => (a.dimension + a.operator + a.expression).localeCompare(b.dimension + b.operator + b.expression));
|
|
19
|
+
}
|
|
20
|
+
// Sort object keys
|
|
21
|
+
return JSON.stringify(clone, Object.keys(clone).sort());
|
|
22
|
+
}
|
|
2
23
|
/**
|
|
3
24
|
* Executes a raw query against the Google Search Console Search Analytics API.
|
|
4
25
|
* This is a low-level function used by many other tools in the project.
|
|
@@ -7,34 +28,67 @@ import { getSearchConsoleClient } from '../google-client.js';
|
|
|
7
28
|
* @returns A promise resolving to an array of data rows from GSC.
|
|
8
29
|
*/
|
|
9
30
|
export async function queryAnalytics(options) {
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (options.startRow !== undefined && options.startRow > 0) {
|
|
22
|
-
requestBody.startRow = options.startRow;
|
|
23
|
-
}
|
|
24
|
-
if (options.filters && options.filters.length > 0) {
|
|
25
|
-
requestBody.dimensionFilterGroups = [{
|
|
26
|
-
filters: options.filters.map(f => ({
|
|
27
|
-
dimension: f.dimension,
|
|
28
|
-
operator: f.operator,
|
|
29
|
-
expression: f.expression
|
|
30
|
-
}))
|
|
31
|
-
}];
|
|
31
|
+
const cacheKey = generateCacheKey(options);
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const cached = analyticsCache.get(cacheKey);
|
|
34
|
+
if (cached) {
|
|
35
|
+
if ('then' in cached) {
|
|
36
|
+
return cached;
|
|
37
|
+
}
|
|
38
|
+
if (now - cached.timestamp < CACHE_TTL_MS) {
|
|
39
|
+
return cached.data;
|
|
40
|
+
}
|
|
41
|
+
analyticsCache.delete(cacheKey);
|
|
32
42
|
}
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
const fetchPromise = (async () => {
|
|
44
|
+
try {
|
|
45
|
+
const client = await getSearchConsoleClient();
|
|
46
|
+
const requestBody = {
|
|
47
|
+
startDate: options.startDate,
|
|
48
|
+
endDate: options.endDate,
|
|
49
|
+
dimensions: options.dimensions || [],
|
|
50
|
+
type: options.type || 'web',
|
|
51
|
+
aggregationType: options.aggregationType || 'auto',
|
|
52
|
+
dataState: options.dataState || 'final',
|
|
53
|
+
rowLimit: Math.min(options.limit || 1000, 25000),
|
|
54
|
+
};
|
|
55
|
+
// Add pagination support
|
|
56
|
+
if (options.startRow !== undefined && options.startRow > 0) {
|
|
57
|
+
requestBody.startRow = options.startRow;
|
|
58
|
+
}
|
|
59
|
+
if (options.filters && options.filters.length > 0) {
|
|
60
|
+
requestBody.dimensionFilterGroups = [{
|
|
61
|
+
filters: options.filters.map(f => ({
|
|
62
|
+
dimension: f.dimension,
|
|
63
|
+
operator: f.operator,
|
|
64
|
+
expression: f.expression
|
|
65
|
+
}))
|
|
66
|
+
}];
|
|
67
|
+
}
|
|
68
|
+
const res = await client.searchanalytics.query({
|
|
69
|
+
siteUrl: options.siteUrl,
|
|
70
|
+
requestBody
|
|
71
|
+
});
|
|
72
|
+
const rows = res.data.rows || [];
|
|
73
|
+
analyticsCache.set(cacheKey, {
|
|
74
|
+
data: rows,
|
|
75
|
+
timestamp: Date.now()
|
|
76
|
+
});
|
|
77
|
+
// Simple LRU-like eviction: remove oldest if over limit
|
|
78
|
+
if (analyticsCache.size > MAX_CACHE_SIZE) {
|
|
79
|
+
const firstKey = analyticsCache.keys().next().value;
|
|
80
|
+
if (firstKey)
|
|
81
|
+
analyticsCache.delete(firstKey);
|
|
82
|
+
}
|
|
83
|
+
return rows;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
analyticsCache.delete(cacheKey);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
analyticsCache.set(cacheKey, fetchPromise);
|
|
91
|
+
return fetchPromise;
|
|
38
92
|
}
|
|
39
93
|
/**
|
|
40
94
|
* Get aggregate performance metrics for the last N days.
|
|
@@ -40,8 +40,8 @@ export async function validateSchema(input, type) {
|
|
|
40
40
|
if (schemas.length === 0) {
|
|
41
41
|
return { valid: false, errors: ["No structured data (JSON-LD) found"], schemas: [] };
|
|
42
42
|
}
|
|
43
|
+
const validator = new Validator();
|
|
43
44
|
const validationPromises = schemas.map(async (schema) => {
|
|
44
|
-
const validator = new Validator();
|
|
45
45
|
try {
|
|
46
46
|
const result = await validator.validate(schema);
|
|
47
47
|
if (result && Array.isArray(result) && result.length > 0) {
|
|
@@ -66,12 +66,7 @@ export async function detectCannibalization(siteUrl, options = {}) {
|
|
|
66
66
|
startDate: startDate.toISOString().split('T')[0],
|
|
67
67
|
endDate: endDate.toISOString().split('T')[0],
|
|
68
68
|
dimensions: ['query', 'page'],
|
|
69
|
-
limit: 10000
|
|
70
|
-
filters: [{
|
|
71
|
-
dimension: 'position',
|
|
72
|
-
operator: 'smallerThan',
|
|
73
|
-
expression: '20' // Only care about cannibalization on first 2 pages
|
|
74
|
-
}]
|
|
69
|
+
limit: 10000
|
|
75
70
|
});
|
|
76
71
|
// Group by query
|
|
77
72
|
const queryMap = new Map();
|
|
@@ -79,7 +74,7 @@ export async function detectCannibalization(siteUrl, options = {}) {
|
|
|
79
74
|
const query = row.keys?.[0] ?? '';
|
|
80
75
|
const page = row.keys?.[1] ?? '';
|
|
81
76
|
const impressions = row.impressions ?? 0;
|
|
82
|
-
if (impressions < minImpressions)
|
|
77
|
+
if (impressions < minImpressions || (row.position ?? 100) > 20)
|
|
83
78
|
continue;
|
|
84
79
|
if (!queryMap.has(query)) {
|
|
85
80
|
queryMap.set(query, []);
|
|
@@ -128,8 +128,30 @@ export async function healthCheck(siteUrl) {
|
|
|
128
128
|
if (allSites.length === 0) {
|
|
129
129
|
return [];
|
|
130
130
|
}
|
|
131
|
-
const reports = await
|
|
131
|
+
const reports = await limitConcurrency(allSites, 5, site => checkSite(site.siteUrl));
|
|
132
132
|
// Sort: critical first, then warning, then healthy
|
|
133
133
|
const order = { critical: 0, warning: 1, healthy: 2 };
|
|
134
134
|
return reports.sort((a, b) => order[a.status] - order[b.status]);
|
|
135
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Limit the number of concurrent executions of an async mapping function.
|
|
138
|
+
*
|
|
139
|
+
* @param items - The array of items to process.
|
|
140
|
+
* @param limit - The maximum number of concurrent executions.
|
|
141
|
+
* @param fn - The async function to execute for each item.
|
|
142
|
+
* @returns A promise that resolves to an array of results.
|
|
143
|
+
*/
|
|
144
|
+
async function limitConcurrency(items, limit, fn) {
|
|
145
|
+
const results = [];
|
|
146
|
+
const executing = new Set();
|
|
147
|
+
for (const item of items) {
|
|
148
|
+
const p = fn(item);
|
|
149
|
+
results.push(p);
|
|
150
|
+
const e = p.then(() => { executing.delete(e); }, () => { executing.delete(e); });
|
|
151
|
+
executing.add(e);
|
|
152
|
+
if (executing.size >= limit) {
|
|
153
|
+
await Promise.race(executing);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return Promise.all(results);
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "search-console-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"mcpName": "io.github.saurabhsharma2u/search-console-mcp",
|
|
5
5
|
"description": "MCP server for Google Search Console API",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,12 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@adobe/structured-data-validator": "^1.6.0",
|
|
33
33
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
34
|
+
"@napi-rs/keyring": "^1.2.0",
|
|
34
35
|
"cheerio": "^1.2.0",
|
|
35
36
|
"dotenv": "^17.2.3",
|
|
36
37
|
"googleapis": "^171.0.0",
|
|
38
|
+
"node-machine-id": "^1.1.12",
|
|
39
|
+
"open": "^11.0.0",
|
|
37
40
|
"re2": "^1.23.3",
|
|
38
41
|
"zod": "^4.3.6"
|
|
39
42
|
},
|
|
@@ -45,4 +48,4 @@
|
|
|
45
48
|
"typescript": "^5.9.3",
|
|
46
49
|
"vitest": "^4.0.18"
|
|
47
50
|
}
|
|
48
|
-
}
|
|
51
|
+
}
|