search-console-mcp 1.13.1 → 1.13.3
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 +1 -1
- package/dist/bing/tools/analytics.js +86 -0
- package/dist/bing/tools/crawl.js +1 -1
- package/dist/bing/tools/keywords.js +2 -2
- package/dist/bing/tools/sites-health.js +2 -1
- package/dist/common/utils/csv.js +9 -3
- package/dist/ga4/tools/analytics.js +6 -0
- package/dist/ga4/tools/pagespeed.js +17 -19
- package/dist/ga4/tools/properties.js +28 -0
- package/dist/google/tools/advanced-analytics.js +44 -20
- package/dist/google/tools/analytics.js +31 -23
- package/dist/google/tools/inspection.js +1 -1
- package/dist/google/tools/sitemaps.js +4 -4
- package/dist/index.js +101 -11
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
A Model Context Protocol (MCP) server that transforms how you interact with **Google Search Console**, **Bing Webmaster Tools**, and **Google Analytics 4**. Stop exporting CSVs and start asking questions.
|
|
5
5
|
|
|
6
|
-
[📚 View Documentation](https://searchconsolemcp.
|
|
6
|
+
[📚 View Documentation](https://searchconsolemcp.saurabh.app/)
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -177,3 +177,89 @@ export async function getPerformanceSummary(siteUrl, days = 28) {
|
|
|
177
177
|
endDate: endDate.toISOString().split('T')[0]
|
|
178
178
|
};
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Detect rising or declining trends in Bing query performance.
|
|
182
|
+
*
|
|
183
|
+
* @param siteUrl - The URL of the site to analyze.
|
|
184
|
+
* @param options - Configuration including lookback period and click thresholds.
|
|
185
|
+
* @returns A list of queries showing significant momentum.
|
|
186
|
+
*/
|
|
187
|
+
export async function detectTrends(siteUrl, options = {}) {
|
|
188
|
+
const days = options.days || 28;
|
|
189
|
+
const threshold = options.threshold || 10;
|
|
190
|
+
const minClicks = options.minClicks || 100;
|
|
191
|
+
const limit = options.limit || 20;
|
|
192
|
+
const midPoint = Math.floor(days / 2);
|
|
193
|
+
const endDate = new Date();
|
|
194
|
+
endDate.setDate(endDate.getDate() - 2); // Data delay
|
|
195
|
+
const currentEndDate = new Date(endDate);
|
|
196
|
+
const currentStartDate = new Date(currentEndDate);
|
|
197
|
+
currentStartDate.setDate(currentStartDate.getDate() - midPoint + 1);
|
|
198
|
+
const previousEndDate = new Date(currentStartDate);
|
|
199
|
+
previousEndDate.setDate(previousEndDate.getDate() - 1);
|
|
200
|
+
const previousStartDate = new Date(previousEndDate);
|
|
201
|
+
previousStartDate.setDate(previousStartDate.getDate() - midPoint + 1);
|
|
202
|
+
const allStats = await getQueryStats(siteUrl);
|
|
203
|
+
const filterStats = (start, end) => {
|
|
204
|
+
const map = new Map();
|
|
205
|
+
allStats.forEach(stat => {
|
|
206
|
+
const d = new Date(stat.Date);
|
|
207
|
+
if (d >= start && d <= end) {
|
|
208
|
+
map.set(stat.Query, (map.get(stat.Query) || 0) + stat.Clicks);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
return map;
|
|
212
|
+
};
|
|
213
|
+
const currentMap = filterStats(currentStartDate, currentEndDate);
|
|
214
|
+
const previousMap = filterStats(previousStartDate, previousEndDate);
|
|
215
|
+
const trends = [];
|
|
216
|
+
for (const [query, currClicks] of currentMap.entries()) {
|
|
217
|
+
const prevClicks = previousMap.get(query) || 0;
|
|
218
|
+
if (currClicks < minClicks && prevClicks < minClicks)
|
|
219
|
+
continue;
|
|
220
|
+
if (prevClicks > 0) {
|
|
221
|
+
const change = currClicks - prevClicks;
|
|
222
|
+
const percent = (change / prevClicks) * 100;
|
|
223
|
+
if (Math.abs(percent) >= threshold) {
|
|
224
|
+
trends.push({
|
|
225
|
+
key: query,
|
|
226
|
+
metric: 'clicks',
|
|
227
|
+
change,
|
|
228
|
+
changePercent: parseFloat(percent.toFixed(2)),
|
|
229
|
+
trend: percent > 0 ? 'rising' : 'declining',
|
|
230
|
+
currentValue: currClicks,
|
|
231
|
+
previousValue: prevClicks
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else if (currClicks >= minClicks) {
|
|
236
|
+
// Zero to Hero
|
|
237
|
+
trends.push({
|
|
238
|
+
key: query,
|
|
239
|
+
metric: 'clicks',
|
|
240
|
+
change: currClicks,
|
|
241
|
+
changePercent: 100,
|
|
242
|
+
trend: 'rising',
|
|
243
|
+
currentValue: currClicks,
|
|
244
|
+
previousValue: 0
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Also check for queries that dropped to zero
|
|
249
|
+
for (const [query, prevClicks] of previousMap.entries()) {
|
|
250
|
+
if (!currentMap.has(query) && prevClicks >= minClicks) {
|
|
251
|
+
trends.push({
|
|
252
|
+
key: query,
|
|
253
|
+
metric: 'clicks',
|
|
254
|
+
change: -prevClicks,
|
|
255
|
+
changePercent: -100,
|
|
256
|
+
trend: 'declining',
|
|
257
|
+
currentValue: 0,
|
|
258
|
+
previousValue: prevClicks
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return trends
|
|
263
|
+
.sort((a, b) => Math.abs(b.change) - Math.abs(a.change))
|
|
264
|
+
.slice(0, limit);
|
|
265
|
+
}
|
package/dist/bing/tools/crawl.js
CHANGED
|
@@ -3,7 +3,7 @@ import { getBingClient } from '../client.js';
|
|
|
3
3
|
* Get crawl issues for a site.
|
|
4
4
|
*/
|
|
5
5
|
export async function getCrawlIssues(siteUrl) {
|
|
6
|
-
const client = await getBingClient();
|
|
6
|
+
const client = await getBingClient(siteUrl);
|
|
7
7
|
return client.getCrawlIssues(siteUrl);
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
@@ -3,13 +3,13 @@ import { getBingClient } from '../client.js';
|
|
|
3
3
|
* Get historical stats for a keyword.
|
|
4
4
|
*/
|
|
5
5
|
export async function getKeywordStats(q, country, language) {
|
|
6
|
-
const client = await getBingClient();
|
|
6
|
+
const client = await getBingClient(); // No siteUrl required for keyword research
|
|
7
7
|
return client.getKeywordStats(q, country, language);
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
10
|
* Get related keywords and search volume.
|
|
11
11
|
*/
|
|
12
12
|
export async function getRelatedKeywords(q, country, language) {
|
|
13
|
-
const client = await getBingClient();
|
|
13
|
+
const client = await getBingClient(); // No siteUrl required for keyword research
|
|
14
14
|
return client.getRelatedKeywords(q, country, language);
|
|
15
15
|
}
|
|
@@ -2,6 +2,7 @@ import * as bingSites from './sites.js';
|
|
|
2
2
|
import * as bingSitemaps from './sitemaps.js';
|
|
3
3
|
import * as bingAnalytics from './analytics.js';
|
|
4
4
|
import * as bingCrawl from './crawl.js';
|
|
5
|
+
import { limitConcurrency } from '../../common/concurrency.js';
|
|
5
6
|
/**
|
|
6
7
|
* Run a health check on a single Bing site.
|
|
7
8
|
*/
|
|
@@ -103,7 +104,7 @@ export async function healthCheck(siteUrl) {
|
|
|
103
104
|
if (allSites.length === 0) {
|
|
104
105
|
return [];
|
|
105
106
|
}
|
|
106
|
-
const reports = await
|
|
107
|
+
const reports = await limitConcurrency(allSites, 5, (site) => checkSite(site.Url));
|
|
107
108
|
const order = { critical: 0, warning: 1, healthy: 2 };
|
|
108
109
|
return reports.sort((a, b) => (order[a.status] || 0) - (order[b.status] || 0));
|
|
109
110
|
}
|
package/dist/common/utils/csv.js
CHANGED
|
@@ -6,12 +6,18 @@ export function jsonToCsv(data) {
|
|
|
6
6
|
if (!data || data.length === 0) {
|
|
7
7
|
return "";
|
|
8
8
|
}
|
|
9
|
-
// Get headers from
|
|
10
|
-
const
|
|
9
|
+
// Get all unique headers from all objects
|
|
10
|
+
const headerSet = new Set();
|
|
11
|
+
for (const row of data) {
|
|
12
|
+
if (row && typeof row === 'object') {
|
|
13
|
+
Object.keys(row).forEach(key => headerSet.add(key));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const headers = Array.from(headerSet);
|
|
11
17
|
const csvRows = [headers.join(',')];
|
|
12
18
|
for (const row of data) {
|
|
13
19
|
const values = headers.map(header => {
|
|
14
|
-
const val = row[header];
|
|
20
|
+
const val = row && typeof row === 'object' ? row[header] : undefined;
|
|
15
21
|
const stringVal = val === undefined || val === null ? '' : String(val);
|
|
16
22
|
// Escape double quotes by doubling them
|
|
17
23
|
const escaped = stringVal.replace(/"/g, '""');
|
|
@@ -29,6 +29,9 @@ export async function batchQueryAnalytics(options) {
|
|
|
29
29
|
return cached.promise;
|
|
30
30
|
}
|
|
31
31
|
if (now - cached.timestamp < CACHE_TTL_MS) {
|
|
32
|
+
// LRU: Refresh key position
|
|
33
|
+
analyticsCache.delete(cacheKey);
|
|
34
|
+
analyticsCache.set(cacheKey, cached);
|
|
32
35
|
return cached.data;
|
|
33
36
|
}
|
|
34
37
|
analyticsCache.delete(cacheKey);
|
|
@@ -77,6 +80,9 @@ export async function queryAnalytics(options) {
|
|
|
77
80
|
return cached.promise;
|
|
78
81
|
}
|
|
79
82
|
if (now - cached.timestamp < CACHE_TTL_MS) {
|
|
83
|
+
// LRU: Refresh key position
|
|
84
|
+
analyticsCache.delete(cacheKey);
|
|
85
|
+
analyticsCache.set(cacheKey, cached);
|
|
80
86
|
return cached.data;
|
|
81
87
|
}
|
|
82
88
|
analyticsCache.delete(cacheKey);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getOrganicLandingPages } from './analytics.js';
|
|
2
2
|
import { analyzePageSpeed } from '../../google/tools/pagespeed.js';
|
|
3
|
+
import { limitConcurrency } from '../../common/concurrency.js';
|
|
3
4
|
export async function getPageSpeedCorrelation(propertyId, domain, startDate, endDate, limit = 5, strategy = 'mobile', accountId) {
|
|
4
5
|
// Normalize domain
|
|
5
6
|
let baseUrl = domain.trim();
|
|
@@ -11,28 +12,25 @@ export async function getPageSpeedCorrelation(propertyId, domain, startDate, end
|
|
|
11
12
|
}
|
|
12
13
|
// 1. Get top organic landing pages
|
|
13
14
|
const pages = await getOrganicLandingPages(propertyId, startDate, endDate, limit, accountId);
|
|
14
|
-
// 2. Run PageSpeed on each
|
|
15
|
-
const results = await
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// 3. Merge
|
|
27
|
-
return results.map((res, index) => {
|
|
28
|
-
if (res.status === 'fulfilled') {
|
|
29
|
-
return res.value;
|
|
15
|
+
// 2. Run PageSpeed on each with concurrency limit
|
|
16
|
+
const results = await limitConcurrency(pages, 5, async (page) => {
|
|
17
|
+
try {
|
|
18
|
+
const path = page.landingPagePlusQueryString || page.pagePath;
|
|
19
|
+
if (!path)
|
|
20
|
+
throw new Error("No page path found");
|
|
21
|
+
const fullUrl = `${baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
|
22
|
+
const psiResult = await analyzePageSpeed(fullUrl, strategy);
|
|
23
|
+
return {
|
|
24
|
+
...page,
|
|
25
|
+
pageSpeed: psiResult
|
|
26
|
+
};
|
|
30
27
|
}
|
|
31
|
-
|
|
28
|
+
catch (error) {
|
|
32
29
|
return {
|
|
33
|
-
...
|
|
34
|
-
pageSpeedError:
|
|
30
|
+
...page,
|
|
31
|
+
pageSpeedError: error.message || "Failed to analyze"
|
|
35
32
|
};
|
|
36
33
|
}
|
|
37
34
|
});
|
|
35
|
+
return results;
|
|
38
36
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { loadConfig } from '../../common/auth/config.js';
|
|
2
|
+
/**
|
|
3
|
+
* List configured GA4 properties.
|
|
4
|
+
* Since GA4 properties are configured explicitly during setup,
|
|
5
|
+
* we return the configured properties.
|
|
6
|
+
*/
|
|
7
|
+
export async function listProperties(accountId) {
|
|
8
|
+
const config = await loadConfig();
|
|
9
|
+
const accounts = Object.values(config.accounts).filter(a => a.engine === 'ga4');
|
|
10
|
+
if (accountId) {
|
|
11
|
+
const account = accounts.find(a => a.id === accountId);
|
|
12
|
+
if (!account) {
|
|
13
|
+
throw new Error(`GA4 account ${accountId} not found.`);
|
|
14
|
+
}
|
|
15
|
+
return [{
|
|
16
|
+
id: account.id,
|
|
17
|
+
alias: account.alias,
|
|
18
|
+
propertyId: account.ga4PropertyId,
|
|
19
|
+
siteUrl: account.ga4PropertyId // Alias for sites_list consistency
|
|
20
|
+
}];
|
|
21
|
+
}
|
|
22
|
+
return accounts.map(a => ({
|
|
23
|
+
id: a.id,
|
|
24
|
+
alias: a.alias,
|
|
25
|
+
propertyId: a.ga4PropertyId,
|
|
26
|
+
siteUrl: a.ga4PropertyId // Alias for sites_list consistency
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
@@ -158,7 +158,8 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
158
158
|
return {
|
|
159
159
|
date: r.keys?.[dimensions.indexOf('date')] || '',
|
|
160
160
|
dimensions: dimObj,
|
|
161
|
-
metrics: metricObj
|
|
161
|
+
metrics: metricObj,
|
|
162
|
+
original: r
|
|
162
163
|
};
|
|
163
164
|
}).sort((a, b) => a.date.localeCompare(b.date));
|
|
164
165
|
// Support weekly granularity
|
|
@@ -166,33 +167,56 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
166
167
|
const weeklyData = {};
|
|
167
168
|
data.forEach(d => {
|
|
168
169
|
const date = new Date(d.date);
|
|
169
|
-
const day = date.
|
|
170
|
-
const diff = date.
|
|
171
|
-
const monday = new Date(date
|
|
170
|
+
const day = date.getUTCDay();
|
|
171
|
+
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1); // Adjust to Monday
|
|
172
|
+
const monday = new Date(date);
|
|
173
|
+
monday.setUTCDate(diff);
|
|
172
174
|
const weekKey = monday.toISOString().split('T')[0];
|
|
173
175
|
if (!weeklyData[weekKey]) {
|
|
176
|
+
const initMetrics = {};
|
|
177
|
+
metrics.forEach(m => initMetrics[m] = 0);
|
|
174
178
|
weeklyData[weekKey] = {
|
|
175
179
|
date: weekKey,
|
|
176
180
|
dimensions: d.dimensions,
|
|
177
|
-
metrics:
|
|
181
|
+
metrics: initMetrics,
|
|
182
|
+
accumulators: { clicks: 0, impressions: 0, weightedPos: 0, count: 0 }
|
|
178
183
|
};
|
|
179
184
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
weeklyData[weekKey].metrics[m] += d.metrics[m];
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
}
|
|
185
|
+
// Accumulate raw values
|
|
186
|
+
const entry = weeklyData[weekKey];
|
|
187
|
+
const r = d.original;
|
|
188
|
+
const clicks = r.clicks ?? 0;
|
|
189
|
+
const impressions = r.impressions ?? 0;
|
|
190
|
+
const position = r.position ?? 0;
|
|
191
|
+
entry.accumulators.clicks += clicks;
|
|
192
|
+
entry.accumulators.impressions += impressions;
|
|
193
|
+
entry.accumulators.weightedPos += (position * impressions);
|
|
194
|
+
entry.accumulators.count += 1;
|
|
194
195
|
});
|
|
195
|
-
|
|
196
|
+
// Compute final weekly metrics
|
|
197
|
+
data = Object.values(weeklyData).map(w => {
|
|
198
|
+
const m = w.metrics;
|
|
199
|
+
const acc = w.accumulators;
|
|
200
|
+
const finalClicks = acc.clicks;
|
|
201
|
+
const finalImpressions = acc.impressions;
|
|
202
|
+
const finalCtr = finalImpressions > 0 ? finalClicks / finalImpressions : 0;
|
|
203
|
+
const finalPos = finalImpressions > 0 ? acc.weightedPos / finalImpressions : 0;
|
|
204
|
+
metrics.forEach(metric => {
|
|
205
|
+
if (metric === 'clicks')
|
|
206
|
+
m[metric] = finalClicks;
|
|
207
|
+
else if (metric === 'impressions')
|
|
208
|
+
m[metric] = finalImpressions;
|
|
209
|
+
else if (metric === 'ctr')
|
|
210
|
+
m[metric] = finalCtr;
|
|
211
|
+
else if (metric === 'position')
|
|
212
|
+
m[metric] = parseFloat(finalPos.toFixed(2));
|
|
213
|
+
});
|
|
214
|
+
return {
|
|
215
|
+
date: w.date,
|
|
216
|
+
dimensions: w.dimensions,
|
|
217
|
+
metrics: m
|
|
218
|
+
};
|
|
219
|
+
}).sort((a, b) => a.date.localeCompare(b.date));
|
|
196
220
|
}
|
|
197
221
|
// 1. Calculate Rolling Averages for EACH metric
|
|
198
222
|
const history = data.map((d, i) => {
|
|
@@ -7,18 +7,17 @@ export function clearAnalyticsCache() {
|
|
|
7
7
|
}
|
|
8
8
|
function generateCacheKey(options) {
|
|
9
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
10
|
if (clone.filters) {
|
|
18
11
|
clone.filters = [...clone.filters].sort((a, b) => (a.dimension + a.operator + a.expression).localeCompare(b.dimension + b.operator + b.expression));
|
|
19
12
|
}
|
|
20
|
-
//
|
|
21
|
-
|
|
13
|
+
// We need a stable JSON string for the cache key.
|
|
14
|
+
// Instead of the broken JSON.stringify(clone, keys) which wipes nested objects,
|
|
15
|
+
// we manually sort the top-level keys.
|
|
16
|
+
const sorted = {};
|
|
17
|
+
Object.keys(clone).sort().forEach(key => {
|
|
18
|
+
sorted[key] = clone[key];
|
|
19
|
+
});
|
|
20
|
+
return JSON.stringify(sorted);
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
23
|
* Executes a raw query against the Google Search Console Search Analytics API.
|
|
@@ -36,6 +35,9 @@ export async function queryAnalytics(options) {
|
|
|
36
35
|
return cached;
|
|
37
36
|
}
|
|
38
37
|
if (now - cached.timestamp < CACHE_TTL_MS) {
|
|
38
|
+
// LRU: Refresh key position
|
|
39
|
+
analyticsCache.delete(cacheKey);
|
|
40
|
+
analyticsCache.set(cacheKey, cached);
|
|
39
41
|
return cached.data;
|
|
40
42
|
}
|
|
41
43
|
analyticsCache.delete(cacheKey);
|
|
@@ -57,13 +59,16 @@ export async function queryAnalytics(options) {
|
|
|
57
59
|
requestBody.startRow = options.startRow;
|
|
58
60
|
}
|
|
59
61
|
if (options.filters && options.filters.length > 0) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
// We use separate dimensionFilterGroups for each filter to ensure they are joined by AND.
|
|
63
|
+
// GSC API joins multiple filters within the same group by OR if they share the same dimension.
|
|
64
|
+
// By putting them in separate groups, we guarantee strict AND behavior for all filters.
|
|
65
|
+
requestBody.dimensionFilterGroups = options.filters.map(f => ({
|
|
66
|
+
filters: [{
|
|
62
67
|
dimension: f.dimension,
|
|
63
68
|
operator: f.operator,
|
|
64
69
|
expression: f.expression
|
|
65
|
-
}
|
|
66
|
-
|
|
70
|
+
}]
|
|
71
|
+
}));
|
|
67
72
|
}
|
|
68
73
|
const res = await client.searchanalytics.query({
|
|
69
74
|
siteUrl: options.siteUrl,
|
|
@@ -356,24 +361,27 @@ export async function detectTrends(siteUrl, options = {}) {
|
|
|
356
361
|
const limit = options.limit ?? 20;
|
|
357
362
|
// Split the period into two halves
|
|
358
363
|
const midPoint = Math.floor(days / 2);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
364
|
+
// Calculate periods ensuring no overlap
|
|
365
|
+
const currentEndDate = new Date();
|
|
366
|
+
currentEndDate.setDate(currentEndDate.getDate() - DATA_DELAY_DAYS);
|
|
367
|
+
const currentStartDate = new Date(currentEndDate);
|
|
368
|
+
currentStartDate.setDate(currentStartDate.getDate() - midPoint + 1);
|
|
369
|
+
const previousEndDate = new Date(currentStartDate);
|
|
370
|
+
previousEndDate.setDate(previousEndDate.getDate() - 1);
|
|
371
|
+
const previousStartDate = new Date(previousEndDate);
|
|
372
|
+
previousStartDate.setDate(previousStartDate.getDate() - midPoint + 1);
|
|
365
373
|
const [currentPeriod, previousPeriod] = await Promise.all([
|
|
366
374
|
queryAnalytics({
|
|
367
375
|
siteUrl,
|
|
368
|
-
startDate:
|
|
369
|
-
endDate:
|
|
376
|
+
startDate: currentStartDate.toISOString().split('T')[0],
|
|
377
|
+
endDate: currentEndDate.toISOString().split('T')[0],
|
|
370
378
|
dimensions: [dimension],
|
|
371
379
|
limit: 5000 // Get enough data to find trends
|
|
372
380
|
}),
|
|
373
381
|
queryAnalytics({
|
|
374
382
|
siteUrl,
|
|
375
|
-
startDate:
|
|
376
|
-
endDate:
|
|
383
|
+
startDate: previousStartDate.toISOString().split('T')[0],
|
|
384
|
+
endDate: previousEndDate.toISOString().split('T')[0],
|
|
377
385
|
dimensions: [dimension],
|
|
378
386
|
limit: 5000
|
|
379
387
|
})
|
|
@@ -9,7 +9,7 @@ import { limitConcurrency } from '../../common/concurrency.js';
|
|
|
9
9
|
* @returns Comprehensive indexing and status information for the URL.
|
|
10
10
|
*/
|
|
11
11
|
export async function inspectUrl(siteUrl, inspectionUrl, languageCode = 'en-US') {
|
|
12
|
-
const client = await getSearchConsoleClient();
|
|
12
|
+
const client = await getSearchConsoleClient(siteUrl);
|
|
13
13
|
const res = await client.urlInspection.index.inspect({
|
|
14
14
|
requestBody: {
|
|
15
15
|
inspectionUrl,
|
|
@@ -6,7 +6,7 @@ import { getSearchConsoleClient } from '../client.js';
|
|
|
6
6
|
* @returns A list of sitemap metadata objects.
|
|
7
7
|
*/
|
|
8
8
|
export async function listSitemaps(siteUrl) {
|
|
9
|
-
const client = await getSearchConsoleClient();
|
|
9
|
+
const client = await getSearchConsoleClient(siteUrl);
|
|
10
10
|
const res = await client.sitemaps.list({ siteUrl });
|
|
11
11
|
return res.data.sitemap || [];
|
|
12
12
|
}
|
|
@@ -18,7 +18,7 @@ export async function listSitemaps(siteUrl) {
|
|
|
18
18
|
* @returns A success message.
|
|
19
19
|
*/
|
|
20
20
|
export async function submitSitemap(siteUrl, feedpath) {
|
|
21
|
-
const client = await getSearchConsoleClient();
|
|
21
|
+
const client = await getSearchConsoleClient(siteUrl);
|
|
22
22
|
await client.sitemaps.submit({ siteUrl, feedpath });
|
|
23
23
|
return `Successfully submitted sitemap: ${feedpath} for ${siteUrl}`;
|
|
24
24
|
}
|
|
@@ -30,7 +30,7 @@ export async function submitSitemap(siteUrl, feedpath) {
|
|
|
30
30
|
* @returns A success message.
|
|
31
31
|
*/
|
|
32
32
|
export async function deleteSitemap(siteUrl, feedpath) {
|
|
33
|
-
const client = await getSearchConsoleClient();
|
|
33
|
+
const client = await getSearchConsoleClient(siteUrl);
|
|
34
34
|
await client.sitemaps.delete({ siteUrl, feedpath });
|
|
35
35
|
return `Successfully deleted sitemap: ${feedpath} from ${siteUrl}`;
|
|
36
36
|
}
|
|
@@ -42,7 +42,7 @@ export async function deleteSitemap(siteUrl, feedpath) {
|
|
|
42
42
|
* @returns Sitemap details including status and item counts.
|
|
43
43
|
*/
|
|
44
44
|
export async function getSitemap(siteUrl, feedpath) {
|
|
45
|
-
const client = await getSearchConsoleClient();
|
|
45
|
+
const client = await getSearchConsoleClient(siteUrl);
|
|
46
46
|
const res = await client.sitemaps.get({ siteUrl, feedpath });
|
|
47
47
|
return res.data;
|
|
48
48
|
}
|
package/dist/index.js
CHANGED
|
@@ -32,7 +32,10 @@ import * as ga4Behavior from "./ga4/tools/behavior.js";
|
|
|
32
32
|
import * as ga4PageSpeed from "./ga4/tools/pagespeed.js";
|
|
33
33
|
import * as ga4GscComparator from "./common/tools/compare-engines/ga4-gsc-comparator.js";
|
|
34
34
|
import * as ga4GscBingComparator from "./common/tools/compare-engines/ga4-gsc-bing-comparator.js";
|
|
35
|
+
import * as ga4Properties from "./ga4/tools/properties.js";
|
|
35
36
|
import { loadConfig, removeAccount, updateAccount } from './common/auth/config.js';
|
|
37
|
+
import { normalizeWebsite } from './common/auth/resolver.js';
|
|
38
|
+
import { limitConcurrency } from './common/concurrency.js';
|
|
36
39
|
import { bingApiDocs, indexNowDocs, dimensionsDocs as bingDimensionsDocs, filtersDocs as bingFiltersDocs, searchTypesDocs as bingSearchTypesDocs, patternsDocs as bingPatternsDocs, algorithmUpdatesDocs as bingAlgorithmUpdatesDocs } from "./bing/docs/index.js";
|
|
37
40
|
import { formatError } from "./common/errors.js";
|
|
38
41
|
import { existsSync } from "fs";
|
|
@@ -64,7 +67,7 @@ const server = new McpServer({
|
|
|
64
67
|
// Get Started Tool
|
|
65
68
|
server.tool(getStartedToolName, getStartedToolDescription, getStartedToolSchema, getStartedHandler);
|
|
66
69
|
// Sites Tools
|
|
67
|
-
server.tool("sites_list", "List all verified sites across all authorized accounts", { engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)") }, async ({ engine = "google" }) => {
|
|
70
|
+
server.tool("sites_list", "List all verified sites or properties across all authorized accounts", { engine: z.enum(["google", "bing", "ga4"]).optional().describe("The search engine (default: google)") }, async ({ engine = "google" }) => {
|
|
68
71
|
try {
|
|
69
72
|
const config = await loadConfig();
|
|
70
73
|
const accounts = Object.values(config.accounts).filter(a => a.engine === engine);
|
|
@@ -73,26 +76,54 @@ server.tool("sites_list", "List all verified sites across all authorized account
|
|
|
73
76
|
const results = await bingSites.listSites();
|
|
74
77
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
75
78
|
}
|
|
76
|
-
const allResults =
|
|
77
|
-
for (const account of accounts) {
|
|
79
|
+
const allResults = await limitConcurrency(accounts, 5, async (account) => {
|
|
78
80
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
let results;
|
|
82
|
+
if (engine === "google") {
|
|
83
|
+
results = await sites.listSites(account.id);
|
|
84
|
+
}
|
|
85
|
+
else if (engine === "bing") {
|
|
86
|
+
results = await bingSites.listSites(account.id);
|
|
87
|
+
}
|
|
88
|
+
else if (engine === "ga4") {
|
|
89
|
+
results = await ga4Properties.listProperties(account.id);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
results = [];
|
|
93
|
+
}
|
|
94
|
+
// Boundary Filtering: Honor the website in the config
|
|
95
|
+
if (account.websites && account.websites.length > 0) {
|
|
96
|
+
results = results.filter(site => {
|
|
97
|
+
const url = engine === "ga4" ? (site.propertyId) : (site.siteUrl || site.Url);
|
|
98
|
+
if (!url)
|
|
99
|
+
return false;
|
|
100
|
+
// If it's a numeric property ID (GA4), check direct inclusion
|
|
101
|
+
if (engine === "ga4" && /^\d+$/.test(url)) {
|
|
102
|
+
return account.websites.includes(url);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const normalizedSite = normalizeWebsite(url).value;
|
|
106
|
+
return account.websites.some(w => normalizeWebsite(w).value === normalizedSite);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return account.websites.includes(url);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
83
114
|
account: account.alias,
|
|
84
115
|
accountId: account.id,
|
|
85
116
|
sites: results
|
|
86
|
-
}
|
|
117
|
+
};
|
|
87
118
|
}
|
|
88
119
|
catch (e) {
|
|
89
|
-
|
|
120
|
+
return {
|
|
90
121
|
account: account.alias,
|
|
91
122
|
accountId: account.id,
|
|
92
123
|
error: e.message
|
|
93
|
-
}
|
|
124
|
+
};
|
|
94
125
|
}
|
|
95
|
-
}
|
|
126
|
+
});
|
|
96
127
|
return {
|
|
97
128
|
content: [{ type: "text", text: JSON.stringify(allResults, null, 2) }]
|
|
98
129
|
};
|
|
@@ -101,6 +132,65 @@ server.tool("sites_list", "List all verified sites across all authorized account
|
|
|
101
132
|
return formatError(error);
|
|
102
133
|
}
|
|
103
134
|
});
|
|
135
|
+
server.tool("bing_seo_cannibalization", "Detect pages competing for the same query in Bing.", {
|
|
136
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
137
|
+
minImpressions: z.number().optional().describe("Minimum impressions threshold (default 50)")
|
|
138
|
+
}, async ({ siteUrl, minImpressions }) => {
|
|
139
|
+
try {
|
|
140
|
+
const results = await bingSeoInsights.detectCannibalization(siteUrl, { minImpressions });
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
return formatError(error);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
server.tool("bing_seo_lost_queries", "Identify queries that lost significant traffic on Bing compared to the previous period.", {
|
|
150
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
151
|
+
days: z.number().optional().describe("Lookback period in days (default 28)")
|
|
152
|
+
}, async ({ siteUrl, days }) => {
|
|
153
|
+
try {
|
|
154
|
+
const results = await bingSeoInsights.findLostQueries(siteUrl, { days });
|
|
155
|
+
return {
|
|
156
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
return formatError(error);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
server.tool("bing_brand_analysis", "Analyze Brand vs Non-Brand performance on Bing.", {
|
|
164
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
165
|
+
brandRegex: z.string().describe("Regex matching brand terms (e.g. 'acme|acmecorp')"),
|
|
166
|
+
days: z.number().optional().describe("Number of days to analyze (default 28)")
|
|
167
|
+
}, async ({ siteUrl, brandRegex, days }) => {
|
|
168
|
+
try {
|
|
169
|
+
const results = await bingSeoInsights.analyzeBrandVsNonBrand(siteUrl, brandRegex, { days });
|
|
170
|
+
return {
|
|
171
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
return formatError(error);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
server.tool("bing_analytics_trends", "Detect rising or declining trends in Bing query performance", {
|
|
179
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
180
|
+
days: z.number().optional().describe("Number of days to analyze (default 28)"),
|
|
181
|
+
threshold: z.number().optional().describe("Minimum percentage change (default 10)"),
|
|
182
|
+
minClicks: z.number().optional().describe("Minimum clicks required (default 100)")
|
|
183
|
+
}, async ({ siteUrl, days, threshold, minClicks }) => {
|
|
184
|
+
try {
|
|
185
|
+
const results = await bingAnalytics.detectTrends(siteUrl, { days, threshold, minClicks });
|
|
186
|
+
return {
|
|
187
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
return formatError(error);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
104
194
|
server.tool("sites_add", "Add a new site to Search Console or Bing Webmaster Tools", {
|
|
105
195
|
siteUrl: z.string().describe("The URL of the site to add"),
|
|
106
196
|
engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "search-console-mcp",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.3",
|
|
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",
|
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
"author": "",
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@adobe/structured-data-validator": "^1.
|
|
35
|
+
"@adobe/structured-data-validator": "^1.7.0",
|
|
36
36
|
"@google-analytics/data": "^5.2.1",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
38
38
|
"@napi-rs/keyring": "^1.2.0",
|
|
39
39
|
"cheerio": "^1.2.0",
|
|
40
|
-
"dotenv": "^17.
|
|
41
|
-
"googleapis": "^171.
|
|
40
|
+
"dotenv": "^17.3.1",
|
|
41
|
+
"googleapis": "^171.4.0",
|
|
42
42
|
"node-machine-id": "^1.1.12",
|
|
43
43
|
"open": "^11.0.0",
|
|
44
44
|
"re2": "^1.23.3",
|
|
@@ -52,4 +52,4 @@
|
|
|
52
52
|
"typescript": "^5.9.3",
|
|
53
53
|
"vitest": "^4.0.18"
|
|
54
54
|
}
|
|
55
|
-
}
|
|
55
|
+
}
|