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 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
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
14
11
  [![Tests](https://github.com/saurabhsharma2u/search-console-mcp/actions/workflows/ci.yml/badge.svg)](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
- ## 🔒 Security & Privacy
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
- We take your data security seriously. This tool is designed to be **local-first** and **secure by default**.
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
- * **Credentials Never Logged**: Your Google Service Account keys are used *only* in memory to authenticate with Google APIs. They are never written to disk, logs, or sent to any third-party server.
61
- * **Local Execution**: The code runs entirely on your local machine (or wherever you host your MCP server).
62
- * **Path Traversal Protection**: The setup wizard implements strict validation for file paths, including null-byte removal, extension enforcement (.json), and size limits (1MB) to prevent unauthorized file access.
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
- ## Features
83
+ ## 🔑 Alternative: Service Account (Advanced)
69
84
 
70
- - **Advanced Analytics**: Query performance data with powerful filters (Regex supported).
71
- - *New*: Support for 'News', 'Discover', 'Image' search types.
72
- - *New*: 'Fresh' data support for real-time monitoring.
73
- - *New*: **Drop Attribution** to identify device-level traffic losses.
74
- - *New*: **Time Series Insights** with rolling averages and trend forecasting.
75
- - **Trend Detection**: Automatically identify rising or falling trends in your traffic.
76
- - **Anomaly Detection**: Spot unusual spikes or drops that need attention.
77
- - **Sitemaps Management**: List, submit, and validte sitemaps.
78
- - **URL Inspection**: Check real-time indexing status and mobile usability.
79
- - **PageSpeed Insights**: Integrated performance and Core Web Vitals analysis.
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
- ### 1. Run the Setup Wizard
87
- First, use the interactive wizard to validate your credentials and get your configuration:
99
+ ## 🛡️ Fort Knox Security
88
100
 
89
- ```bash
90
- npx -y search-console-mcp setup
91
- ```
101
+ This MCP server implements a multi-layered security architecture:
92
102
 
93
- The wizard will guide you through:
94
- - Creating a Google Service Account.
95
- - Adding the account to Search Console.
96
- - Validating your JSON key file.
97
- - Generating the configuration for your MCP client.
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)
@@ -1,16 +1,76 @@
1
1
  import { google } from 'googleapis';
2
- const SCOPES = ['https://www.googleapis.com/auth/webmasters'];
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
- * Get an authenticated Google Search Console API client.
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
- // Option 1: Environment variables (for serverless/Cloudflare)
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
- // Option 2: File-based credentials (default)
84
+ // 3. Fallback to File-based credentials
25
85
  const auth = new google.auth.GoogleAuth({
26
86
  scopes: SCOPES,
27
87
  });
28
- const client = await auth.getClient();
29
- cachedClient = google.searchconsole({ version: 'v1', auth: client });
30
- return cachedClient;
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
- // Allow `npx search-console-mcp setup` to launch the setup wizard
787
- if (process.argv[2] === 'setup') {
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 (!process.env.GOOGLE_APPLICATION_CREDENTIALS) {
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('❌ GOOGLE_APPLICATION_CREDENTIALS environment variable is not set.\n');
797
- console.error('💡 To set up the server, run the setup wizard:');
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 main() {
131
+ export async function login() {
152
132
  printHeader();
153
- console.log('This wizard will help you set up Search Console MCP.');
154
- console.log('You will need a Google Cloud service account with Search Console access.\n');
155
- // Step 1: Check for existing credentials
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 (or press Enter to see config examples): ');
163
- // Default values for when no path provided
164
- let serviceAccountEmail = '<your-service-account>@<project>.iam.gserviceaccount.com';
165
- let credentialsPath = '/path/to/your/service-account-key.json';
166
- if (keyPath) {
167
- // Validate the key file
168
- const key = validateKeyFile(keyPath);
169
- if (!key) {
170
- rl.close();
171
- process.exit(1);
172
- }
173
- printSuccess('JSON key file is valid!');
174
- printInfo(`Project: ${key.project_id}`);
175
- printInfo(`Service Account: ${key.client_email}`);
176
- serviceAccountEmail = key.client_email;
177
- credentialsPath = resolve(keyPath.replace('~', homedir()));
178
- // Step 2: Show service account email
179
- printStep(2, 'Add service account to Google Search Console');
180
- console.log('You need to add this email as a user in Google Search Console:\n');
181
- console.log(` 📧 ${serviceAccountEmail}\n`);
182
- console.log('Steps:');
183
- console.log(' 1. Go to https://search.google.com/search-console');
184
- console.log(' 2. Select your property (or add one if needed)');
185
- console.log(' 3. Click "Settings" (gear icon) in the sidebar');
186
- console.log(' 4. Click "Users and permissions"');
187
- console.log(' 5. Click "Add user"');
188
- console.log(` 6. Enter: ${serviceAccountEmail}`);
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
- // No path provided - just show config examples
211
- printInfo('No credentials file provided. Showing example configuration...\n');
212
- printStep(2, 'Add service account to Google Search Console');
213
- console.log('After creating your service account, add this email to Search Console:');
214
- console.log(` 📧 ${serviceAccountEmail}\n`);
215
- printStep(3, 'Configure your MCP client');
216
- showConfigSnippets(credentialsPath);
217
- console.log('\n💡 Run this wizard again with your JSON file path for validation.\n');
218
- }
219
- // Step 5: Support the project
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
- throw new Error('Invalid repository format');
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
- console.log('No problem! Enjoy using Search Console MCP.');
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) => {
@@ -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 client = await getSearchConsoleClient();
11
- const requestBody = {
12
- startDate: options.startDate,
13
- endDate: options.endDate,
14
- dimensions: options.dimensions || [],
15
- type: options.type || 'web',
16
- aggregationType: options.aggregationType || 'auto',
17
- dataState: options.dataState || 'final',
18
- rowLimit: Math.min(options.limit || 1000, 25000),
19
- };
20
- // Add pagination support
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 res = await client.searchanalytics.query({
34
- siteUrl: options.siteUrl,
35
- requestBody
36
- });
37
- return res.data.rows || [];
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 Promise.all(allSites.map(site => checkSite(site.siteUrl)));
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.9.3",
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
+ }