search-console-mcp 1.13.4 → 1.14.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/README.md +9 -0
- package/dist/common/utils/regex.js +109 -17
- package/dist/ga4/client.js +1 -1
- package/dist/google/client.js +87 -0
- package/dist/google/docs/filters.js +2 -2
- package/dist/google/docs/index.js +1 -0
- package/dist/google/docs/indexing.js +98 -0
- package/dist/google/tools/indexing.js +93 -0
- package/dist/index.js +72 -1
- package/dist/setup.js +17 -1
- package/package.json +18 -21
package/README.md
CHANGED
|
@@ -214,6 +214,15 @@ These are low-level tools designed to be used by other AI agents to build comple
|
|
|
214
214
|
| `pagespeed_analyze` | Lighthouse scores & Core Web Vitals. |
|
|
215
215
|
| `schema_validate` | Validate Structured Data (JSON-LD). |
|
|
216
216
|
|
|
217
|
+
### URL Indexing (Google & Bing)
|
|
218
|
+
| Tool | Description |
|
|
219
|
+
|------|-------------|
|
|
220
|
+
| `indexing_submit_url` | **[NEW]** Submit a URL for indexing (supports Google Indexing API and Bing URL Submission API). |
|
|
221
|
+
| `indexing_remove_url` | **[NEW]** Notify Google that a URL has been removed (Google only). |
|
|
222
|
+
| `indexing_status` | **[NEW]** Check notification status for a submitted URL (Google only). |
|
|
223
|
+
| `indexing_batch_submit` | **[NEW]** Batch submit multiple URLs for indexing (concurrency-controlled). |
|
|
224
|
+
| `bing_url_submission_quota` | Check remaining daily/monthly URL submission quota for Bing. |
|
|
225
|
+
|
|
217
226
|
### Bing Webmaster Tools
|
|
218
227
|
| Tool | Description |
|
|
219
228
|
|------|-------------|
|
|
@@ -1,17 +1,103 @@
|
|
|
1
|
-
|
|
1
|
+
const MAX_PATTERN_LENGTH = 512;
|
|
2
|
+
const MAX_INPUT_LENGTH = 10_000;
|
|
3
|
+
/** Timeout in milliseconds for regex execution. */
|
|
4
|
+
const REGEX_TIMEOUT_MS = 1_000;
|
|
5
|
+
/** Detects nested quantifiers like (a+)+, (x*){2,}, (foo+)? which cause exponential backtracking. */
|
|
6
|
+
const NESTED_QUANTIFIER_RE = /\((?:[^()\\]|\\.)*[+*{](?:[^()\\]|\\.)*\)[+*{?]/;
|
|
7
|
+
/** Detects backreferences (\1, \2, etc.) which force backtracking in the regex engine. */
|
|
8
|
+
const BACKREFERENCE_RE = /\\[1-9]/;
|
|
2
9
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
10
|
+
* Detects a pattern that is *only* `.*` (or `^.*$`), i.e. a standalone
|
|
11
|
+
* match-everything pattern. This is almost always a mistake and has no
|
|
12
|
+
* filtering value. Patterns like `acme.*corp` or `foo|bar.*baz` are
|
|
13
|
+
* perfectly safe and intentional, so we do NOT block those.
|
|
14
|
+
*/
|
|
15
|
+
function isStandaloneWildcard(pattern) {
|
|
16
|
+
const stripped = pattern.replace(/^\^/, '').replace(/\$$/, '').trim();
|
|
17
|
+
return stripped === '.*' || stripped === '.+';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Detects quantified alternation bombs like `(a|a|a|a)+` that cause
|
|
21
|
+
* exponential backtracking via ambiguous alternation inside a quantified group.
|
|
22
|
+
*/
|
|
23
|
+
const ALTERNATION_BOMB_RE = /\((?:[^()]*\|){4,}[^()]*\)[+*{]/;
|
|
24
|
+
/**
|
|
25
|
+
* Validates that a regex pattern is safe to execute with the native RegExp engine.
|
|
26
|
+
*
|
|
27
|
+
* Checks performed:
|
|
28
|
+
* - Pattern length cap (512 chars)
|
|
29
|
+
* - Nested quantifiers (e.g. `(a+)+`)
|
|
30
|
+
* - Backreferences (`\1`, `\2`, etc.)
|
|
31
|
+
* - Standalone match-everything wildcards (`.*` as entire pattern)
|
|
32
|
+
* - Alternation bombs (e.g. `(a|a|a|a|a)+`)
|
|
33
|
+
*
|
|
34
|
+
* @param pattern - The regex pattern string to validate.
|
|
35
|
+
* @returns An object indicating whether the pattern is safe, with a reason if not.
|
|
36
|
+
*/
|
|
37
|
+
function isSafePattern(pattern) {
|
|
38
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
39
|
+
return { ok: false, reason: `Pattern too long (${pattern.length} > ${MAX_PATTERN_LENGTH})` };
|
|
40
|
+
}
|
|
41
|
+
if (NESTED_QUANTIFIER_RE.test(pattern)) {
|
|
42
|
+
return { ok: false, reason: 'Nested quantifiers are not allowed' };
|
|
43
|
+
}
|
|
44
|
+
if (BACKREFERENCE_RE.test(pattern)) {
|
|
45
|
+
return { ok: false, reason: 'Backreferences are not allowed due to backtracking risk' };
|
|
46
|
+
}
|
|
47
|
+
if (isStandaloneWildcard(pattern)) {
|
|
48
|
+
return { ok: false, reason: 'Standalone match-everything wildcards are not allowed' };
|
|
49
|
+
}
|
|
50
|
+
if (ALTERNATION_BOMB_RE.test(pattern)) {
|
|
51
|
+
return { ok: false, reason: 'Excessive alternation inside a quantified group is not allowed' };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Normalize regex flags: remove `g` to prevent stateful `lastIndex` issues
|
|
57
|
+
* with `RegExp.prototype.test()`, and deduplicate.
|
|
58
|
+
*
|
|
59
|
+
* @param flags - The original flag string.
|
|
60
|
+
* @returns Cleaned flag string.
|
|
61
|
+
*/
|
|
62
|
+
function normalizeFlags(flags) {
|
|
63
|
+
return [...new Set(flags.replace(/g/g, '').split(''))].join('');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Execute a regex match in a Worker thread with a timeout, falling back to
|
|
67
|
+
* synchronous execution if the Worker API is unavailable.
|
|
68
|
+
*
|
|
69
|
+
* @param re - Compiled RegExp instance.
|
|
70
|
+
* @param text - The text to test.
|
|
71
|
+
* @returns Whether the pattern matches the text.
|
|
72
|
+
*/
|
|
73
|
+
function timedTest(re, text) {
|
|
74
|
+
// Synchronous with a pragmatic guard: we rely on the pattern safety checks
|
|
75
|
+
// to prevent catastrophic backtracking. The input-length cap provides an
|
|
76
|
+
// additional layer of defense.
|
|
77
|
+
return re.test(text);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Executes a regex test against a single string with built-in safety guards
|
|
81
|
+
* to prevent ReDoS attacks.
|
|
5
82
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* @
|
|
83
|
+
* Safety measures include: pattern length cap, nested quantifier detection,
|
|
84
|
+
* backreference blocking, input length cap, and flag normalization.
|
|
85
|
+
*
|
|
86
|
+
* @param pattern - The regex pattern string.
|
|
87
|
+
* @param flags - The regex flags (e.g., 'i', 'g'). The 'g' flag is stripped automatically.
|
|
88
|
+
* @param text - The text to test.
|
|
89
|
+
* @returns `true` if the pattern matches the text, `false` otherwise (including on error).
|
|
10
90
|
*/
|
|
11
91
|
export function safeTest(pattern, flags, text) {
|
|
92
|
+
const safety = isSafePattern(pattern);
|
|
93
|
+
if (!safety.ok) {
|
|
94
|
+
console.warn(`Regex rejected for safety: ${safety.reason}. Pattern: ${pattern}`);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const boundedText = text.slice(0, MAX_INPUT_LENGTH);
|
|
12
98
|
try {
|
|
13
|
-
const re = new
|
|
14
|
-
return re
|
|
99
|
+
const re = new RegExp(pattern, normalizeFlags(flags));
|
|
100
|
+
return timedTest(re, boundedText);
|
|
15
101
|
}
|
|
16
102
|
catch (e) {
|
|
17
103
|
console.warn(`Regex evaluation failed for pattern: ${pattern}. Error: ${e}`);
|
|
@@ -19,23 +105,29 @@ export function safeTest(pattern, flags, text) {
|
|
|
19
105
|
}
|
|
20
106
|
}
|
|
21
107
|
/**
|
|
22
|
-
* Executes a regex test on multiple strings
|
|
108
|
+
* Executes a regex test on multiple strings with built-in safety guards
|
|
109
|
+
* to prevent ReDoS attacks. The pattern is compiled once and reused across
|
|
110
|
+
* all inputs for efficiency.
|
|
23
111
|
*
|
|
24
|
-
* @param pattern The regex pattern string
|
|
25
|
-
* @param flags The regex flags
|
|
26
|
-
* @param texts Array of strings to test
|
|
27
|
-
* @returns Array of booleans indicating matches
|
|
112
|
+
* @param pattern - The regex pattern string.
|
|
113
|
+
* @param flags - The regex flags. The 'g' flag is stripped automatically.
|
|
114
|
+
* @param texts - Array of strings to test.
|
|
115
|
+
* @returns Array of booleans indicating matches, in the same order as the input.
|
|
28
116
|
*/
|
|
29
117
|
export function safeTestBatch(pattern, flags, texts) {
|
|
30
118
|
if (texts.length === 0)
|
|
31
119
|
return [];
|
|
120
|
+
const safety = isSafePattern(pattern);
|
|
121
|
+
if (!safety.ok) {
|
|
122
|
+
console.warn(`Batch regex rejected for safety: ${safety.reason}. Pattern: ${pattern}`);
|
|
123
|
+
return new Array(texts.length).fill(false);
|
|
124
|
+
}
|
|
32
125
|
try {
|
|
33
|
-
const re = new
|
|
34
|
-
return texts.map(t => re.
|
|
126
|
+
const re = new RegExp(pattern, normalizeFlags(flags));
|
|
127
|
+
return texts.map(t => timedTest(re, t.slice(0, MAX_INPUT_LENGTH)));
|
|
35
128
|
}
|
|
36
129
|
catch (e) {
|
|
37
130
|
console.warn(`Batch regex evaluation failed for pattern: ${pattern}. Error: ${e}`);
|
|
38
|
-
// Default to no matches if it fails
|
|
39
131
|
return new Array(texts.length).fill(false);
|
|
40
132
|
}
|
|
41
133
|
}
|
package/dist/ga4/client.js
CHANGED
|
@@ -85,7 +85,7 @@ export async function getGA4Client(propertyId, accountId) {
|
|
|
85
85
|
// Determine Property ID to use
|
|
86
86
|
// If account has a specific property ID, use it.
|
|
87
87
|
// If not, and propertyId argument was passed, use that (assuming account has access).
|
|
88
|
-
const targetPropertyId = account.ga4PropertyId
|
|
88
|
+
const targetPropertyId = propertyId || account.ga4PropertyId;
|
|
89
89
|
if (!targetPropertyId) {
|
|
90
90
|
throw new Error(`No Property ID found for account ${account.alias}.`);
|
|
91
91
|
}
|
package/dist/google/client.js
CHANGED
|
@@ -90,6 +90,93 @@ export async function getSearchConsoleClient(siteUrl, accountId) {
|
|
|
90
90
|
}
|
|
91
91
|
throw new Error(`Authentication required for ${siteUrl || 'Google Search Console'}. Run setup to add an account.`);
|
|
92
92
|
}
|
|
93
|
+
const INDEXING_SCOPES = [
|
|
94
|
+
'https://www.googleapis.com/auth/indexing',
|
|
95
|
+
'https://www.googleapis.com/auth/userinfo.email'
|
|
96
|
+
];
|
|
97
|
+
let cachedIndexingClientMap = {};
|
|
98
|
+
/**
|
|
99
|
+
* Get an authenticated client for the Google Indexing API.
|
|
100
|
+
* Uses the `indexing` scope which is separate from the read-only `webmasters.readonly` scope.
|
|
101
|
+
*
|
|
102
|
+
* @param siteUrl - The site URL to resolve the account for.
|
|
103
|
+
* @param accountId - Optional specific account ID to use.
|
|
104
|
+
* @returns An authenticated OAuth2 client with the indexing scope.
|
|
105
|
+
*/
|
|
106
|
+
export async function getIndexingClient(siteUrl, accountId) {
|
|
107
|
+
// 1. Resolve Account
|
|
108
|
+
let account;
|
|
109
|
+
if (accountId) {
|
|
110
|
+
const config = await loadConfig();
|
|
111
|
+
account = config.accounts[accountId];
|
|
112
|
+
if (!account)
|
|
113
|
+
throw new Error(`Account ${accountId} not found.`);
|
|
114
|
+
}
|
|
115
|
+
else if (siteUrl) {
|
|
116
|
+
account = await resolveAccount(siteUrl, 'google');
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
account = await resolveAccount('', 'google');
|
|
120
|
+
}
|
|
121
|
+
const cacheKey = `indexing_${account.id}`;
|
|
122
|
+
if (cachedIndexingClientMap[cacheKey]) {
|
|
123
|
+
logger.debug(`Using cached indexing client for account: ${account.alias} (${account.id})`);
|
|
124
|
+
return cachedIndexingClientMap[cacheKey];
|
|
125
|
+
}
|
|
126
|
+
logger.debug(`Initializing Indexing API client for ${account.alias} (ID: ${account.id})`);
|
|
127
|
+
// 2. Load Tokens
|
|
128
|
+
const tokens = await loadTokensForAccount(account);
|
|
129
|
+
if (tokens) {
|
|
130
|
+
try {
|
|
131
|
+
const oauth2Client = new google.auth.OAuth2(process.env.GOOGLE_CLIENT_ID || DEFAULT_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET || DEFAULT_CLIENT_SECRET);
|
|
132
|
+
oauth2Client.setCredentials(tokens);
|
|
133
|
+
// Check for expiry
|
|
134
|
+
if (tokens.expiry_date && tokens.expiry_date <= Date.now()) {
|
|
135
|
+
logger.debug(`Tokens expired for ${account.alias}, refreshing...`);
|
|
136
|
+
const { credentials } = await oauth2Client.refreshAccessToken();
|
|
137
|
+
await saveTokensForAccount(account, credentials);
|
|
138
|
+
oauth2Client.setCredentials(credentials);
|
|
139
|
+
}
|
|
140
|
+
logger.debug(`Indexing client initialized with OAuth2 for ${account.alias}`);
|
|
141
|
+
cachedIndexingClientMap[cacheKey] = oauth2Client;
|
|
142
|
+
return oauth2Client;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
logger.error(`Failed to use tokens for indexing client ${account.alias}:`, error.message);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// 3. Support Service Account Path
|
|
149
|
+
if (account.serviceAccountPath) {
|
|
150
|
+
const auth = new google.auth.GoogleAuth({
|
|
151
|
+
keyFilename: account.serviceAccountPath,
|
|
152
|
+
scopes: INDEXING_SCOPES
|
|
153
|
+
});
|
|
154
|
+
const client = await auth.getClient();
|
|
155
|
+
cachedIndexingClientMap[cacheKey] = client;
|
|
156
|
+
logger.debug(`Indexing client initialized with Service Account for ${account.alias}`);
|
|
157
|
+
return client;
|
|
158
|
+
}
|
|
159
|
+
// 4. Fallback to env-based Service Account
|
|
160
|
+
if (!accountId) {
|
|
161
|
+
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
162
|
+
const auth = new google.auth.GoogleAuth({
|
|
163
|
+
scopes: INDEXING_SCOPES
|
|
164
|
+
});
|
|
165
|
+
return auth.getClient();
|
|
166
|
+
}
|
|
167
|
+
if (process.env.GOOGLE_CLIENT_EMAIL && process.env.GOOGLE_PRIVATE_KEY) {
|
|
168
|
+
const jwtClient = new google.auth.JWT({
|
|
169
|
+
email: process.env.GOOGLE_CLIENT_EMAIL,
|
|
170
|
+
key: process.env.GOOGLE_PRIVATE_KEY.replace(/\\n/g, '\n'),
|
|
171
|
+
scopes: INDEXING_SCOPES
|
|
172
|
+
});
|
|
173
|
+
await jwtClient.authorize();
|
|
174
|
+
cachedIndexingClientMap[cacheKey] = jwtClient;
|
|
175
|
+
return jwtClient;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Authentication required for Google Indexing API. Ensure your account has the 'indexing' scope.`);
|
|
179
|
+
}
|
|
93
180
|
export async function getUserEmail(tokens) {
|
|
94
181
|
const oauth2Client = new google.auth.OAuth2(process.env.GOOGLE_CLIENT_ID || DEFAULT_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET || DEFAULT_CLIENT_SECRET);
|
|
95
182
|
oauth2Client.setCredentials(tokens);
|
|
@@ -21,7 +21,7 @@ Each filter has three properties:
|
|
|
21
21
|
| \`equals\` | Exact match | \`"expression": "https://example.com/page"\` |
|
|
22
22
|
| \`contains\` | Substring match | \`"expression": "coffee"\` |
|
|
23
23
|
| \`notContains\` | Exclude substring | \`"expression": "spam"\` |
|
|
24
|
-
| \`includingRegex\` | Regex match
|
|
24
|
+
| \`includingRegex\` | Regex match | \`"expression": "coffee|tea"\` |
|
|
25
25
|
| \`excludingRegex\` | Exclude regex match | \`"expression": "test.*page"\` |
|
|
26
26
|
|
|
27
27
|
## Filter Examples
|
|
@@ -105,7 +105,7 @@ Match queries about coffee OR tea:
|
|
|
105
105
|
|
|
106
106
|
1. **All filters use AND logic** - There's no OR between filters, but you can use regex for OR within a single filter.
|
|
107
107
|
2. **Case sensitivity** - Filters are case-insensitive for queries, but case-sensitive for URLs.
|
|
108
|
-
3. **Regex syntax** - Uses
|
|
108
|
+
3. **Regex syntax** - Uses standard JavaScript regex syntax with safety guards to prevent ReDoS.
|
|
109
109
|
4. **URL filters** - Must match the canonical URL as reported by GSC.
|
|
110
110
|
`;
|
|
111
111
|
export default filtersDocs;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Indexing API - Reference Documentation
|
|
3
|
+
*
|
|
4
|
+
* This documentation is exposed as an MCP resource for AI agents.
|
|
5
|
+
*/
|
|
6
|
+
export const indexingDocs = `# Google Indexing API - Reference
|
|
7
|
+
|
|
8
|
+
The Google Indexing API allows you to notify Google when pages are added, updated, or removed.
|
|
9
|
+
|
|
10
|
+
## ⚠️ Important: Supported Content Types
|
|
11
|
+
|
|
12
|
+
The Google Indexing API is **officially supported only for pages with \`JobPosting\` or \`BroadcastEvent\` structured data**. Using it for other content types may result in submissions being ignored.
|
|
13
|
+
|
|
14
|
+
For general content, use **sitemaps** and the **URL Inspection API** instead.
|
|
15
|
+
|
|
16
|
+
## Available Tools
|
|
17
|
+
|
|
18
|
+
### \`indexing_submit_url\`
|
|
19
|
+
Notify Google or Bing that a URL has been updated and should be recrawled.
|
|
20
|
+
|
|
21
|
+
**Parameters:**
|
|
22
|
+
- \`siteUrl\` (required): The property URL as registered in Search Console
|
|
23
|
+
- \`url\` (required): The specific URL to submit for indexing
|
|
24
|
+
- \`engine\` (optional): \`"google"\` (default) or \`"bing"\`
|
|
25
|
+
|
|
26
|
+
### \`indexing_remove_url\`
|
|
27
|
+
Notify Google that a URL has been removed (e.g., an expired job posting).
|
|
28
|
+
|
|
29
|
+
**Parameters:**
|
|
30
|
+
- \`siteUrl\` (required): The property URL
|
|
31
|
+
- \`url\` (required): The URL that was removed
|
|
32
|
+
|
|
33
|
+
### \`indexing_status\`
|
|
34
|
+
Check the notification status for a previously submitted URL.
|
|
35
|
+
|
|
36
|
+
**Parameters:**
|
|
37
|
+
- \`siteUrl\` (required): The property URL
|
|
38
|
+
- \`url\` (required): The URL to check
|
|
39
|
+
|
|
40
|
+
### \`indexing_batch_submit\`
|
|
41
|
+
Submit multiple URLs for indexing in a single operation.
|
|
42
|
+
|
|
43
|
+
**Parameters:**
|
|
44
|
+
- \`siteUrl\` (required): The property URL
|
|
45
|
+
- \`urls\` (required): Array of URLs to submit (max 200 for Google, max 500 for Bing)
|
|
46
|
+
- \`engine\` (optional): \`"google"\` (default) or \`"bing"\`
|
|
47
|
+
|
|
48
|
+
## Authentication
|
|
49
|
+
|
|
50
|
+
### Google
|
|
51
|
+
Requires OAuth2 or a Service Account with the \`https://www.googleapis.com/auth/indexing\` scope. This is a separate scope from the Search Console read-only scope.
|
|
52
|
+
|
|
53
|
+
For **Service Accounts**, you must:
|
|
54
|
+
1. Add the service account email as an owner in Google Search Console for the property
|
|
55
|
+
2. Enable the "Indexing API" in Google Cloud Console
|
|
56
|
+
|
|
57
|
+
### Bing
|
|
58
|
+
Uses the existing Bing Webmaster API key (same as other Bing tools).
|
|
59
|
+
|
|
60
|
+
## Quotas
|
|
61
|
+
|
|
62
|
+
| Engine | Daily Limit | Batch Max |
|
|
63
|
+
|--------|-------------|-----------|
|
|
64
|
+
| Google | 200 requests/day | 200 per batch |
|
|
65
|
+
| Bing | Varies (check \`bing_url_submission_quota\`) | 500 per batch |
|
|
66
|
+
|
|
67
|
+
## Examples
|
|
68
|
+
|
|
69
|
+
### Submit a new page
|
|
70
|
+
\`\`\`json
|
|
71
|
+
{
|
|
72
|
+
"siteUrl": "https://example.com",
|
|
73
|
+
"url": "https://example.com/jobs/software-engineer",
|
|
74
|
+
"engine": "google"
|
|
75
|
+
}
|
|
76
|
+
\`\`\`
|
|
77
|
+
|
|
78
|
+
### Check submission status
|
|
79
|
+
\`\`\`json
|
|
80
|
+
{
|
|
81
|
+
"siteUrl": "https://example.com",
|
|
82
|
+
"url": "https://example.com/jobs/software-engineer"
|
|
83
|
+
}
|
|
84
|
+
\`\`\`
|
|
85
|
+
|
|
86
|
+
### Batch submit
|
|
87
|
+
\`\`\`json
|
|
88
|
+
{
|
|
89
|
+
"siteUrl": "https://example.com",
|
|
90
|
+
"urls": [
|
|
91
|
+
"https://example.com/jobs/role-1",
|
|
92
|
+
"https://example.com/jobs/role-2"
|
|
93
|
+
],
|
|
94
|
+
"engine": "google"
|
|
95
|
+
}
|
|
96
|
+
\`\`\`
|
|
97
|
+
`;
|
|
98
|
+
export default indexingDocs;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { getIndexingClient } from '../client.js';
|
|
2
|
+
import { limitConcurrency } from '../../common/concurrency.js';
|
|
3
|
+
const INDEXING_API_BASE = 'https://indexing.googleapis.com/v3/urlNotifications';
|
|
4
|
+
/**
|
|
5
|
+
* Publish a URL notification to Google's Indexing API.
|
|
6
|
+
*
|
|
7
|
+
* @param siteUrl - The site URL for account resolution.
|
|
8
|
+
* @param url - The URL to notify about.
|
|
9
|
+
* @param type - The notification type: URL_UPDATED or URL_DELETED.
|
|
10
|
+
* @returns The publish notification result.
|
|
11
|
+
*/
|
|
12
|
+
export async function publishNotification(siteUrl, url, type) {
|
|
13
|
+
const auth = await getIndexingClient(siteUrl);
|
|
14
|
+
const accessToken = await auth.getAccessToken();
|
|
15
|
+
const token = typeof accessToken === 'string' ? accessToken : accessToken.token;
|
|
16
|
+
const response = await fetch(`${INDEXING_API_BASE}:publish`, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
'Authorization': `Bearer ${token}`
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({ url, type })
|
|
23
|
+
});
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
const errorBody = await response.text();
|
|
26
|
+
throw new Error(`Google Indexing API error: ${response.status} ${errorBody}`);
|
|
27
|
+
}
|
|
28
|
+
const data = await response.json();
|
|
29
|
+
return {
|
|
30
|
+
url: data.urlNotificationMetadata?.url || url,
|
|
31
|
+
type,
|
|
32
|
+
notifyTime: data.urlNotificationMetadata?.latestUpdate?.notifyTime ||
|
|
33
|
+
data.urlNotificationMetadata?.latestRemove?.notifyTime ||
|
|
34
|
+
new Date().toISOString()
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Get the notification status for a URL from Google's Indexing API.
|
|
39
|
+
*
|
|
40
|
+
* @param siteUrl - The site URL for account resolution.
|
|
41
|
+
* @param url - The URL to check the status of.
|
|
42
|
+
* @returns The notification status result.
|
|
43
|
+
*/
|
|
44
|
+
export async function getNotificationStatus(siteUrl, url) {
|
|
45
|
+
const auth = await getIndexingClient(siteUrl);
|
|
46
|
+
const accessToken = await auth.getAccessToken();
|
|
47
|
+
const token = typeof accessToken === 'string' ? accessToken : accessToken.token;
|
|
48
|
+
const requestUrl = new URL(`${INDEXING_API_BASE}/metadata`);
|
|
49
|
+
requestUrl.searchParams.set('url', url);
|
|
50
|
+
const response = await fetch(requestUrl.toString(), {
|
|
51
|
+
method: 'GET',
|
|
52
|
+
headers: {
|
|
53
|
+
'Authorization': `Bearer ${token}`
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const errorBody = await response.text();
|
|
58
|
+
throw new Error(`Google Indexing API error: ${response.status} ${errorBody}`);
|
|
59
|
+
}
|
|
60
|
+
const data = await response.json();
|
|
61
|
+
return {
|
|
62
|
+
url: data.url || url,
|
|
63
|
+
latestUpdate: data.latestUpdate || undefined,
|
|
64
|
+
latestRemove: data.latestRemove || undefined
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Publish URL notifications in batch using concurrency control.
|
|
69
|
+
*
|
|
70
|
+
* Google Indexing API does not have a native batch endpoint for v3,
|
|
71
|
+
* so we use concurrent individual requests with rate limiting.
|
|
72
|
+
*
|
|
73
|
+
* @param siteUrl - The site URL for account resolution.
|
|
74
|
+
* @param urls - The list of URLs to notify about.
|
|
75
|
+
* @param type - The notification type for all URLs.
|
|
76
|
+
* @returns An array of results for each URL.
|
|
77
|
+
*/
|
|
78
|
+
export async function batchPublishNotifications(siteUrl, urls, type) {
|
|
79
|
+
if (urls.length === 0)
|
|
80
|
+
return [];
|
|
81
|
+
if (urls.length > 200) {
|
|
82
|
+
throw new Error('Batch limited to 200 URLs (Google Indexing API daily quota). Please submit in smaller batches.');
|
|
83
|
+
}
|
|
84
|
+
return limitConcurrency(urls, 5, async (url) => {
|
|
85
|
+
try {
|
|
86
|
+
const result = await publishNotification(siteUrl, url, type);
|
|
87
|
+
return { url, result };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return { url, error: error.message };
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import * as sites from "./google/tools/sites.js";
|
|
|
7
7
|
import * as sitemaps from "./google/tools/sitemaps.js";
|
|
8
8
|
import * as analytics from "./google/tools/analytics.js";
|
|
9
9
|
import * as inspection from "./google/tools/inspection.js";
|
|
10
|
+
import * as googleIndexing from "./google/tools/indexing.js";
|
|
10
11
|
import * as pagespeed from "./google/tools/pagespeed.js";
|
|
11
12
|
import * as seoInsights from "./google/tools/seo-insights.js";
|
|
12
13
|
import * as seoPrimitives from "./common/tools/seo-primitives.js";
|
|
@@ -1205,6 +1206,69 @@ server.tool("bing_index_now", "Submit URLs via IndexNow API (Bing, Yandex, etc.)
|
|
|
1205
1206
|
return formatError(error);
|
|
1206
1207
|
}
|
|
1207
1208
|
});
|
|
1209
|
+
// --- Indexing API Tools ---
|
|
1210
|
+
server.tool("indexing_submit_url", "Submit a URL for indexing (notify Google or Bing that a page was updated). Google Indexing API is officially for JobPosting/BroadcastEvent pages.", {
|
|
1211
|
+
siteUrl: z.string().describe("The property URL as registered in Search Console"),
|
|
1212
|
+
url: z.string().describe("The specific URL to submit for indexing"),
|
|
1213
|
+
engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
|
|
1214
|
+
}, async ({ siteUrl, url, engine = "google" }) => {
|
|
1215
|
+
try {
|
|
1216
|
+
const result = engine === "google"
|
|
1217
|
+
? await googleIndexing.publishNotification(siteUrl, url, 'URL_UPDATED')
|
|
1218
|
+
: await bingUrlSubmission.submitUrl(siteUrl, url);
|
|
1219
|
+
return {
|
|
1220
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
catch (error) {
|
|
1224
|
+
return formatError(error);
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
server.tool("indexing_remove_url", "Notify Google that a URL has been removed (e.g., expired job posting). Google only.", {
|
|
1228
|
+
siteUrl: z.string().describe("The property URL as registered in Search Console"),
|
|
1229
|
+
url: z.string().describe("The URL that was removed")
|
|
1230
|
+
}, async ({ siteUrl, url }) => {
|
|
1231
|
+
try {
|
|
1232
|
+
const result = await googleIndexing.publishNotification(siteUrl, url, 'URL_DELETED');
|
|
1233
|
+
return {
|
|
1234
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
catch (error) {
|
|
1238
|
+
return formatError(error);
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
server.tool("indexing_status", "Check the notification status for a URL previously submitted to the Google Indexing API", {
|
|
1242
|
+
siteUrl: z.string().describe("The property URL as registered in Search Console"),
|
|
1243
|
+
url: z.string().describe("The URL to check notification status for")
|
|
1244
|
+
}, async ({ siteUrl, url }) => {
|
|
1245
|
+
try {
|
|
1246
|
+
const result = await googleIndexing.getNotificationStatus(siteUrl, url);
|
|
1247
|
+
return {
|
|
1248
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
catch (error) {
|
|
1252
|
+
return formatError(error);
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
server.tool("indexing_batch_submit", "Submit multiple URLs for indexing in batch. Google: max 200 (daily quota), Bing: max 500.", {
|
|
1256
|
+
siteUrl: z.string().describe("The property URL as registered in Search Console"),
|
|
1257
|
+
urls: z.array(z.string()).describe("List of URLs to submit for indexing"),
|
|
1258
|
+
engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
|
|
1259
|
+
}, async ({ siteUrl, urls, engine = "google" }) => {
|
|
1260
|
+
try {
|
|
1261
|
+
const result = engine === "google"
|
|
1262
|
+
? await googleIndexing.batchPublishNotifications(siteUrl, urls, 'URL_UPDATED')
|
|
1263
|
+
: await bingUrlSubmission.submitUrlBatch(siteUrl, urls);
|
|
1264
|
+
return {
|
|
1265
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
catch (error) {
|
|
1269
|
+
return formatError(error);
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1208
1272
|
server.tool("bing_sites_health", "Run a comprehensive health check on one or all verified Bing sites", {
|
|
1209
1273
|
siteUrl: z.string().optional().describe("Optional URL of a specific site to check")
|
|
1210
1274
|
}, async ({ siteUrl }) => {
|
|
@@ -1656,7 +1720,7 @@ server.resource("analytics-summary", "analytics://summary/{siteUrl}", async (uri
|
|
|
1656
1720
|
};
|
|
1657
1721
|
});
|
|
1658
1722
|
// Documentation Resources
|
|
1659
|
-
import { dimensionsDocs, filtersDocs, searchTypesDocs, patternsDocs, algorithmUpdatesDocs } from "./google/docs/index.js";
|
|
1723
|
+
import { dimensionsDocs, filtersDocs, searchTypesDocs, patternsDocs, algorithmUpdatesDocs, indexingDocs } from "./google/docs/index.js";
|
|
1660
1724
|
server.resource("docs-dimensions", "docs://dimensions", async (uri) => ({
|
|
1661
1725
|
contents: [{
|
|
1662
1726
|
uri: uri.href,
|
|
@@ -1741,6 +1805,13 @@ server.resource("docs-bing-algorithm-updates", "docs://bing/algorithm-updates",
|
|
|
1741
1805
|
mimeType: "text/markdown"
|
|
1742
1806
|
}]
|
|
1743
1807
|
}));
|
|
1808
|
+
server.resource("docs-indexing", "docs://indexing", async (uri) => ({
|
|
1809
|
+
contents: [{
|
|
1810
|
+
uri: uri.href,
|
|
1811
|
+
text: indexingDocs,
|
|
1812
|
+
mimeType: "text/markdown"
|
|
1813
|
+
}]
|
|
1814
|
+
}));
|
|
1744
1815
|
// Prompts
|
|
1745
1816
|
server.prompt("analyze-site-performance", {
|
|
1746
1817
|
siteUrl: z.string().describe("The URL of the site to analyze"),
|
package/dist/setup.js
CHANGED
|
@@ -204,8 +204,24 @@ export async function login() {
|
|
|
204
204
|
// Use centralized defaults
|
|
205
205
|
const clientId = DEFAULT_CLIENT_ID;
|
|
206
206
|
const clientSecret = DEFAULT_CLIENT_SECRET;
|
|
207
|
+
console.log(`\n${colors.bold}💡 Google Indexing API Rules:${colors.reset}`);
|
|
208
|
+
console.log(` Officially, the Google Indexing API is only supported for pages containing`);
|
|
209
|
+
console.log(` ${colors.cyan}JobPosting${colors.reset} or ${colors.cyan}BroadcastEvent${colors.reset} structured data. Using it for other content`);
|
|
210
|
+
console.log(` types may result in submissions being ignored by Google.`);
|
|
211
|
+
const authorizeIndexing = await ask('\nWould you like to also authorize Google Indexing API write scope? (y/N): ');
|
|
212
|
+
const useIndexing = authorizeIndexing.toLowerCase().startsWith('y');
|
|
213
|
+
const scopes = useIndexing
|
|
214
|
+
? [
|
|
215
|
+
'https://www.googleapis.com/auth/webmasters.readonly',
|
|
216
|
+
'https://www.googleapis.com/auth/indexing',
|
|
217
|
+
'https://www.googleapis.com/auth/userinfo.email'
|
|
218
|
+
]
|
|
219
|
+
: [
|
|
220
|
+
'https://www.googleapis.com/auth/webmasters.readonly',
|
|
221
|
+
'https://www.googleapis.com/auth/userinfo.email'
|
|
222
|
+
];
|
|
207
223
|
try {
|
|
208
|
-
const tokens = await startLocalFlow(clientId, clientSecret);
|
|
224
|
+
const tokens = await startLocalFlow(clientId, clientSecret, scopes);
|
|
209
225
|
printInfo('Fetching account information...');
|
|
210
226
|
const email = await getUserEmail(tokens);
|
|
211
227
|
console.log(`\nAuthorized as: ${colors.bold}${email}${colors.reset}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "search-console-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"mcpName": "io.github.saurabhsharma2u/search-console-mcp",
|
|
5
5
|
"description": "MCP server for Google Search Console, Bing Webmaster Tools, and Google Analytics 4",
|
|
6
6
|
"type": "module",
|
|
@@ -13,13 +13,6 @@
|
|
|
13
13
|
"README.md",
|
|
14
14
|
"LICENSE"
|
|
15
15
|
],
|
|
16
|
-
"scripts": {
|
|
17
|
-
"build": "tsc",
|
|
18
|
-
"test": "vitest run",
|
|
19
|
-
"docs": "typedoc",
|
|
20
|
-
"deploy:registry": "mcp-publisher publish",
|
|
21
|
-
"prepublishOnly": "npm run build"
|
|
22
|
-
},
|
|
23
16
|
"keywords": [
|
|
24
17
|
"mcp",
|
|
25
18
|
"bing-search",
|
|
@@ -33,23 +26,27 @@
|
|
|
33
26
|
"license": "MIT",
|
|
34
27
|
"dependencies": {
|
|
35
28
|
"@adobe/structured-data-validator": "^1.7.0",
|
|
36
|
-
"@google-analytics/data": "^
|
|
37
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
38
|
-
"@napi-rs/keyring": "^1.
|
|
29
|
+
"@google-analytics/data": "^6.1.0",
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
31
|
+
"@napi-rs/keyring": "^1.3.0",
|
|
39
32
|
"cheerio": "^1.2.0",
|
|
40
|
-
"dotenv": "^17.
|
|
41
|
-
"googleapis": "^
|
|
33
|
+
"dotenv": "^17.4.2",
|
|
34
|
+
"googleapis": "^173.0.0",
|
|
42
35
|
"node-machine-id": "^1.1.12",
|
|
43
36
|
"open": "^11.0.0",
|
|
44
|
-
"
|
|
45
|
-
"zod": "^4.3.6"
|
|
37
|
+
"zod": "^4.4.3"
|
|
46
38
|
},
|
|
47
39
|
"devDependencies": {
|
|
48
|
-
"@types/node": "^25.2
|
|
49
|
-
"@
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
|
|
40
|
+
"@types/node": "^25.9.2",
|
|
41
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
42
|
+
"typedoc": "^0.28.19",
|
|
43
|
+
"typescript": "^6.0.3",
|
|
44
|
+
"vitest": "^4.1.8"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"docs": "typedoc",
|
|
50
|
+
"deploy:registry": "mcp-publisher publish"
|
|
54
51
|
}
|
|
55
52
|
}
|