search-console-mcp 1.13.2 → 1.13.4
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/advanced-analytics.js +11 -8
- package/dist/bing/tools/analytics.js +21 -20
- package/dist/bing/tools/crawl.js +1 -1
- package/dist/bing/tools/keywords.js +2 -2
- package/dist/common/auth/resolver.js +17 -7
- package/dist/common/diagnostics.js +63 -0
- package/dist/common/utils/csv.js +9 -3
- package/dist/common/utils/dates.js +32 -0
- package/dist/ga4/tools/analytics.js +14 -6
- package/dist/ga4/tools/pagespeed.js +17 -19
- package/dist/ga4/tools/properties.js +28 -0
- package/dist/google/client.js +7 -1
- package/dist/google/tools/analytics.js +14 -2
- package/dist/google/tools/inspection.js +1 -1
- package/dist/google/tools/sitemaps.js +4 -4
- package/dist/index.js +138 -26
- package/dist/utils/logger.js +39 -0
- package/package.json +4 -4
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
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { detectAnomalies, getRankAndTrafficStats } from './analytics.js';
|
|
2
|
+
import { parseMicrosoftDate, startOfDay, endOfDay } from '../../common/utils/dates.js';
|
|
2
3
|
/**
|
|
3
4
|
* Historical Bing Algorithm Update dates (recent notable ones)
|
|
4
5
|
*/
|
|
@@ -35,8 +36,8 @@ export async function analyzeDropAttribution(siteUrl, options = {}) {
|
|
|
35
36
|
const impacts = { mobile: 0, desktop: 0, tablet: 0 };
|
|
36
37
|
// Check for algorithm updates within 2 days of the drop
|
|
37
38
|
const possibleUpdate = ALGORITHM_UPDATES.find(u => {
|
|
38
|
-
const uDate = new Date(u.date);
|
|
39
|
-
const dDate =
|
|
39
|
+
const uDate = startOfDay(new Date(u.date));
|
|
40
|
+
const dDate = startOfDay(parseMicrosoftDate(dropDate));
|
|
40
41
|
const diff = Math.abs(dDate.getTime() - uDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
41
42
|
return diff <= 2;
|
|
42
43
|
});
|
|
@@ -66,12 +67,13 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
66
67
|
const dimensions = ['date'];
|
|
67
68
|
const rawRows = await getRankAndTrafficStats(siteUrl);
|
|
68
69
|
// Sort rows by date ascending
|
|
69
|
-
const rows = rawRows.sort((a, b) =>
|
|
70
|
+
const rows = rawRows.sort((a, b) => parseMicrosoftDate(a.Date).getTime() - parseMicrosoftDate(b.Date).getTime());
|
|
70
71
|
// Filter by date range if provided
|
|
71
72
|
let startIndex = 0;
|
|
72
73
|
let endIndex = rows.length;
|
|
73
74
|
if (options.startDate) {
|
|
74
|
-
|
|
75
|
+
const s = startOfDay(new Date(options.startDate)).getTime();
|
|
76
|
+
startIndex = rows.findIndex(r => parseMicrosoftDate(r.Date).getTime() >= s);
|
|
75
77
|
// If no date is >= startDate, then all dates are < startDate (since sorted ascending)
|
|
76
78
|
if (startIndex === -1)
|
|
77
79
|
startIndex = rows.length;
|
|
@@ -80,8 +82,9 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
80
82
|
startIndex = Math.max(0, rows.length - options.days);
|
|
81
83
|
}
|
|
82
84
|
if (options.endDate) {
|
|
85
|
+
const e = endOfDay(new Date(options.endDate)).getTime();
|
|
83
86
|
// find index where date > endDate
|
|
84
|
-
endIndex = rows.findIndex(r => r.Date >
|
|
87
|
+
endIndex = rows.findIndex(r => parseMicrosoftDate(r.Date).getTime() > e);
|
|
85
88
|
if (endIndex === -1)
|
|
86
89
|
endIndex = rows.length;
|
|
87
90
|
}
|
|
@@ -113,7 +116,7 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
113
116
|
if (granularity === 'weekly') {
|
|
114
117
|
const weeklyData = {};
|
|
115
118
|
data.forEach(d => {
|
|
116
|
-
const date =
|
|
119
|
+
const date = parseMicrosoftDate(d.date);
|
|
117
120
|
const day = date.getUTCDay();
|
|
118
121
|
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1); // Adjust to Monday
|
|
119
122
|
const monday = new Date(date);
|
|
@@ -185,7 +188,7 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
185
188
|
const firstMetric = metrics[0];
|
|
186
189
|
const dowStats = [[], [], [], [], [], [], []];
|
|
187
190
|
data.forEach(d => {
|
|
188
|
-
const day =
|
|
191
|
+
const day = parseMicrosoftDate(d.date).getDay();
|
|
189
192
|
dowStats[day].push(d.metrics[firstMetric]);
|
|
190
193
|
});
|
|
191
194
|
const dowAverages = dowStats.map(vals => vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0);
|
|
@@ -195,7 +198,7 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
195
198
|
seasonalityStrength = parseFloat(Math.min(1, stdDev / (globalAvg || 1)).toFixed(2));
|
|
196
199
|
const peakDay = dowAverages.indexOf(Math.max(...dowAverages));
|
|
197
200
|
history.forEach(h => {
|
|
198
|
-
if (h.date &&
|
|
201
|
+
if (h.date && parseMicrosoftDate(h.date).getDay() === peakDay)
|
|
199
202
|
h.isSeasonalPeak = true;
|
|
200
203
|
});
|
|
201
204
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getBingClient } from '../client.js';
|
|
2
|
+
import { parseMicrosoftDate, startOfDay, endOfDay } from '../../common/utils/dates.js';
|
|
2
3
|
/**
|
|
3
4
|
* Get query performance stats for a Bing site with optional date filtering.
|
|
4
5
|
*/
|
|
@@ -7,10 +8,10 @@ export async function getQueryStats(siteUrl, startDate, endDate) {
|
|
|
7
8
|
const stats = await client.getQueryStats(siteUrl);
|
|
8
9
|
if (!startDate && !endDate)
|
|
9
10
|
return stats;
|
|
10
|
-
const start = startDate ? new Date(startDate) : new Date(0);
|
|
11
|
-
const end = endDate ? new Date(endDate) : new Date();
|
|
11
|
+
const start = startDate ? startOfDay(new Date(startDate)) : new Date(0);
|
|
12
|
+
const end = endDate ? endOfDay(new Date(endDate)) : new Date();
|
|
12
13
|
return stats.filter(row => {
|
|
13
|
-
const d =
|
|
14
|
+
const d = parseMicrosoftDate(row.Date);
|
|
14
15
|
return d >= start && d <= end;
|
|
15
16
|
});
|
|
16
17
|
}
|
|
@@ -22,10 +23,10 @@ export async function getPageStats(siteUrl, startDate, endDate) {
|
|
|
22
23
|
const stats = await client.getPageStats(siteUrl);
|
|
23
24
|
if (!startDate && !endDate)
|
|
24
25
|
return stats;
|
|
25
|
-
const start = startDate ? new Date(startDate) : new Date(0);
|
|
26
|
-
const end = endDate ? new Date(endDate) : new Date();
|
|
26
|
+
const start = startDate ? startOfDay(new Date(startDate)) : new Date(0);
|
|
27
|
+
const end = endDate ? endOfDay(new Date(endDate)) : new Date();
|
|
27
28
|
return stats.filter(row => {
|
|
28
|
-
const d =
|
|
29
|
+
const d = parseMicrosoftDate(row.Date);
|
|
29
30
|
return d >= start && d <= end;
|
|
30
31
|
});
|
|
31
32
|
}
|
|
@@ -37,10 +38,10 @@ export async function getPageQueryStats(siteUrl, pageUrl, startDate, endDate) {
|
|
|
37
38
|
const stats = await client.getPageQueryStats(siteUrl, pageUrl);
|
|
38
39
|
if (!startDate && !endDate)
|
|
39
40
|
return stats;
|
|
40
|
-
const start = startDate ? new Date(startDate) : new Date(0);
|
|
41
|
-
const end = endDate ? new Date(endDate) : new Date();
|
|
41
|
+
const start = startDate ? startOfDay(new Date(startDate)) : new Date(0);
|
|
42
|
+
const end = endDate ? endOfDay(new Date(endDate)) : new Date();
|
|
42
43
|
return stats.filter(row => {
|
|
43
|
-
const d =
|
|
44
|
+
const d = parseMicrosoftDate(row.Date);
|
|
44
45
|
return d >= start && d <= end;
|
|
45
46
|
});
|
|
46
47
|
}
|
|
@@ -52,10 +53,10 @@ export async function getQueryPageStats(siteUrl, startDate, endDate) {
|
|
|
52
53
|
const stats = await client.getQueryPageStats(siteUrl);
|
|
53
54
|
if (!startDate && !endDate)
|
|
54
55
|
return stats;
|
|
55
|
-
const start = startDate ? new Date(startDate) : new Date(0);
|
|
56
|
-
const end = endDate ? new Date(endDate) : new Date();
|
|
56
|
+
const start = startDate ? startOfDay(new Date(startDate)) : new Date(0);
|
|
57
|
+
const end = endDate ? endOfDay(new Date(endDate)) : new Date();
|
|
57
58
|
return stats.filter(row => {
|
|
58
|
-
const d =
|
|
59
|
+
const d = parseMicrosoftDate(row.Date);
|
|
59
60
|
return d >= start && d <= end;
|
|
60
61
|
});
|
|
61
62
|
}
|
|
@@ -72,10 +73,10 @@ export async function getRankAndTrafficStats(siteUrl) {
|
|
|
72
73
|
export async function comparePeriods(siteUrl, startDate1, endDate1, startDate2, endDate2) {
|
|
73
74
|
const stats = await getRankAndTrafficStats(siteUrl);
|
|
74
75
|
const getSummary = (start, end) => {
|
|
75
|
-
const s = new Date(start);
|
|
76
|
-
const e = new Date(end);
|
|
76
|
+
const s = startOfDay(new Date(start));
|
|
77
|
+
const e = endOfDay(new Date(end));
|
|
77
78
|
const filtered = stats.filter((row) => {
|
|
78
|
-
const d =
|
|
79
|
+
const d = parseMicrosoftDate(row.Date);
|
|
79
80
|
return d >= s && d <= e;
|
|
80
81
|
});
|
|
81
82
|
const clicks = filtered.reduce((acc, row) => acc + row.Clicks, 0);
|
|
@@ -127,7 +128,7 @@ export async function detectAnomalies(siteUrl, options = {}) {
|
|
|
127
128
|
const threshold = options.threshold || 2.5;
|
|
128
129
|
const stats = await getRankAndTrafficStats(siteUrl);
|
|
129
130
|
// Sort by date ascending
|
|
130
|
-
const sorted = [...stats].sort((a, b) =>
|
|
131
|
+
const sorted = [...stats].sort((a, b) => parseMicrosoftDate(a.Date).getTime() - parseMicrosoftDate(b.Date).getTime());
|
|
131
132
|
if (sorted.length < 5)
|
|
132
133
|
return [];
|
|
133
134
|
const anomalies = [];
|
|
@@ -157,11 +158,11 @@ export async function detectAnomalies(siteUrl, options = {}) {
|
|
|
157
158
|
export async function getPerformanceSummary(siteUrl, days = 28) {
|
|
158
159
|
const stats = await getRankAndTrafficStats(siteUrl);
|
|
159
160
|
// Bing typically has historical data; filter for last N days
|
|
160
|
-
const endDate = new Date();
|
|
161
|
-
const startDate = new Date();
|
|
161
|
+
const endDate = endOfDay(new Date());
|
|
162
|
+
const startDate = startOfDay(new Date());
|
|
162
163
|
startDate.setDate(endDate.getDate() - days);
|
|
163
164
|
const filtered = stats.filter((row) => {
|
|
164
|
-
const d =
|
|
165
|
+
const d = parseMicrosoftDate(row.Date);
|
|
165
166
|
return d >= startDate && d <= endDate;
|
|
166
167
|
});
|
|
167
168
|
const clicks = filtered.reduce((acc, row) => acc + row.Clicks, 0);
|
|
@@ -203,7 +204,7 @@ export async function detectTrends(siteUrl, options = {}) {
|
|
|
203
204
|
const filterStats = (start, end) => {
|
|
204
205
|
const map = new Map();
|
|
205
206
|
allStats.forEach(stat => {
|
|
206
|
-
const d =
|
|
207
|
+
const d = parseMicrosoftDate(stat.Date);
|
|
207
208
|
if (d >= start && d <= end) {
|
|
208
209
|
map.set(stat.Query, (map.get(stat.Query) || 0) + stat.Clicks);
|
|
209
210
|
}
|
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
|
}
|
|
@@ -36,19 +36,29 @@ export async function resolveAccount(siteUrl, engine) {
|
|
|
36
36
|
return exactMatch;
|
|
37
37
|
// 2. Domain Match (if target is a URL prefix and we have a domain boundary)
|
|
38
38
|
if (normalizedTarget.type === 'url-prefix') {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
try {
|
|
40
|
+
const targetHost = new URL(siteUrl).hostname.toLowerCase();
|
|
41
|
+
const domainMatch = accounts.find(a => a.websites?.some(w => {
|
|
42
|
+
const nw = normalizeWebsite(w);
|
|
43
|
+
return nw.type === 'domain' && (targetHost === nw.value || targetHost.endsWith('.' + nw.value));
|
|
44
|
+
}));
|
|
45
|
+
if (domainMatch)
|
|
46
|
+
return domainMatch;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
// If the URL is invalid, we can't do a domain match.
|
|
50
|
+
// Just skip to the next resolution step.
|
|
51
|
+
}
|
|
46
52
|
}
|
|
47
53
|
// 3. Global Accounts (Empty websites list)
|
|
48
54
|
const globalAccounts = accounts.filter(a => !a.websites || a.websites.length === 0);
|
|
49
55
|
if (globalAccounts.length === 1) {
|
|
50
56
|
return globalAccounts[0];
|
|
51
57
|
}
|
|
58
|
+
// Single account fallback: If no siteUrl requested and only one account exists, use it.
|
|
59
|
+
if (!siteUrl && accounts.length === 1) {
|
|
60
|
+
return accounts[0];
|
|
61
|
+
}
|
|
52
62
|
if (globalAccounts.length > 1) {
|
|
53
63
|
const error = new Error(`Multiple ${engine} accounts found. Please specify an account boundary or remove unused accounts.`);
|
|
54
64
|
error.code = 'AMBIGUOUS';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { loadConfig } from './auth/config.js';
|
|
2
|
+
import { getSearchConsoleClient } from '../google/client.js';
|
|
3
|
+
import { getBingClient } from '../bing/client.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
/**
|
|
6
|
+
* Runs a set of diagnostic checks to verify API connectivity and account health.
|
|
7
|
+
*/
|
|
8
|
+
export async function runDiagnostics() {
|
|
9
|
+
const config = await loadConfig();
|
|
10
|
+
const accounts = Object.values(config.accounts);
|
|
11
|
+
const results = [];
|
|
12
|
+
logger.info(`Starting diagnostics for ${accounts.length} accounts...`);
|
|
13
|
+
logger.info(`System Time: ${new Date().toISOString()}`);
|
|
14
|
+
logger.info(`Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`);
|
|
15
|
+
for (const account of accounts) {
|
|
16
|
+
logger.debug(`Checking account: ${account.alias} (${account.engine})`);
|
|
17
|
+
try {
|
|
18
|
+
if (account.engine === 'google') {
|
|
19
|
+
const client = await getSearchConsoleClient(undefined, account.id);
|
|
20
|
+
// Test call: list sites (limited to 1 for quick check)
|
|
21
|
+
const res = await client.sites.list();
|
|
22
|
+
results.push({
|
|
23
|
+
engine: 'google',
|
|
24
|
+
account: account.alias,
|
|
25
|
+
status: 'ok',
|
|
26
|
+
message: `Successfully connected. Account has access to ${res.data.siteEntry?.length || 0} sites.`,
|
|
27
|
+
details: { sitesCount: res.data.siteEntry?.length || 0 }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
else if (account.engine === 'bing') {
|
|
31
|
+
const client = await getBingClient(undefined, account.id);
|
|
32
|
+
const res = await client.getSiteList();
|
|
33
|
+
results.push({
|
|
34
|
+
engine: 'bing',
|
|
35
|
+
account: account.alias,
|
|
36
|
+
status: 'ok',
|
|
37
|
+
message: `Successfully connected. Account has access to ${res.length} sites.`,
|
|
38
|
+
details: { sitesCount: res.length }
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// Add GA4 diagnostics if needed
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
const error = e;
|
|
45
|
+
logger.error(`Diagnostic failed for ${account.alias}: ${error.message}`);
|
|
46
|
+
results.push({
|
|
47
|
+
engine: account.engine,
|
|
48
|
+
account: account.alias,
|
|
49
|
+
status: 'error',
|
|
50
|
+
message: error.message
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (accounts.length === 0) {
|
|
55
|
+
results.push({
|
|
56
|
+
engine: 'system',
|
|
57
|
+
account: 'none',
|
|
58
|
+
status: 'error',
|
|
59
|
+
message: 'No accounts configured. Run setup first.'
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return results;
|
|
63
|
+
}
|
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, '""');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a Microsoft-style JSON date string (e.g., "/Date(1731225600000-0800)/")
|
|
3
|
+
* into a standard JavaScript Date object.
|
|
4
|
+
*/
|
|
5
|
+
export function parseMicrosoftDate(dateStr) {
|
|
6
|
+
if (!dateStr)
|
|
7
|
+
return new Date(0);
|
|
8
|
+
// Extract the timestamp digits
|
|
9
|
+
const match = dateStr.match(/\/Date\((\d+)([+-]\d+)?\)\//);
|
|
10
|
+
if (match) {
|
|
11
|
+
const timestamp = parseInt(match[1], 10);
|
|
12
|
+
return new Date(timestamp);
|
|
13
|
+
}
|
|
14
|
+
// Fallback to standard Date parsing if format doesn't match
|
|
15
|
+
return new Date(dateStr);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes a date to the start of the day (00:00:00.000) in local time.
|
|
19
|
+
*/
|
|
20
|
+
export function startOfDay(date) {
|
|
21
|
+
const d = new Date(date);
|
|
22
|
+
d.setHours(0, 0, 0, 0);
|
|
23
|
+
return d;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Normalizes a date to the end of the day (23:59:59.999) in local time.
|
|
27
|
+
*/
|
|
28
|
+
export function endOfDay(date) {
|
|
29
|
+
const d = new Date(date);
|
|
30
|
+
d.setHours(23, 59, 59, 999);
|
|
31
|
+
return d;
|
|
32
|
+
}
|
|
@@ -9,9 +9,12 @@ export function clearAnalyticsCache() {
|
|
|
9
9
|
function generateCacheKey(options) {
|
|
10
10
|
// Recursive stable stringify to handle nested object key ordering
|
|
11
11
|
const sortObject = (obj) => {
|
|
12
|
-
if (obj === null || typeof obj !== 'object'
|
|
12
|
+
if (obj === null || typeof obj !== 'object') {
|
|
13
13
|
return obj;
|
|
14
14
|
}
|
|
15
|
+
if (Array.isArray(obj)) {
|
|
16
|
+
return obj.map(sortObject);
|
|
17
|
+
}
|
|
15
18
|
const sorted = {};
|
|
16
19
|
Object.keys(obj).sort().forEach(key => {
|
|
17
20
|
sorted[key] = sortObject(obj[key]);
|
|
@@ -121,7 +124,7 @@ export async function queryAnalytics(options) {
|
|
|
121
124
|
analyticsCache.set(cacheKey, { type: 'pending', promise: fetchPromise });
|
|
122
125
|
return fetchPromise;
|
|
123
126
|
}
|
|
124
|
-
export async function getPagePerformance(propertyId, startDate, endDate, pagePath, limit = 50, accountId) {
|
|
127
|
+
export async function getPagePerformance(propertyId, startDate, endDate, pagePath, limit = 50, accountId, offset) {
|
|
125
128
|
const dimensionFilter = pagePath ? {
|
|
126
129
|
filter: {
|
|
127
130
|
fieldName: 'pagePath',
|
|
@@ -148,11 +151,12 @@ export async function getPagePerformance(propertyId, startDate, endDate, pagePat
|
|
|
148
151
|
],
|
|
149
152
|
dimensionFilter,
|
|
150
153
|
limit,
|
|
154
|
+
offset,
|
|
151
155
|
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }]
|
|
152
156
|
});
|
|
153
157
|
return formatRows(response);
|
|
154
158
|
}
|
|
155
|
-
export async function getTrafficSources(propertyId, startDate, endDate, channelGroup, limit = 50, accountId) {
|
|
159
|
+
export async function getTrafficSources(propertyId, startDate, endDate, channelGroup, limit = 50, accountId, offset) {
|
|
156
160
|
const dimensionFilter = channelGroup ? {
|
|
157
161
|
filter: {
|
|
158
162
|
fieldName: 'sessionDefaultChannelGroup',
|
|
@@ -172,11 +176,12 @@ export async function getTrafficSources(propertyId, startDate, endDate, channelG
|
|
|
172
176
|
metrics: ['sessions'],
|
|
173
177
|
dimensionFilter,
|
|
174
178
|
limit,
|
|
179
|
+
offset,
|
|
175
180
|
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }]
|
|
176
181
|
});
|
|
177
182
|
return formatRows(response);
|
|
178
183
|
}
|
|
179
|
-
export async function getOrganicLandingPages(propertyId, startDate, endDate, limit = 50, accountId) {
|
|
184
|
+
export async function getOrganicLandingPages(propertyId, startDate, endDate, limit = 50, accountId, offset) {
|
|
180
185
|
const response = await queryAnalytics({
|
|
181
186
|
propertyId,
|
|
182
187
|
accountId,
|
|
@@ -201,11 +206,12 @@ export async function getOrganicLandingPages(propertyId, startDate, endDate, lim
|
|
|
201
206
|
}
|
|
202
207
|
},
|
|
203
208
|
limit,
|
|
209
|
+
offset,
|
|
204
210
|
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }]
|
|
205
211
|
});
|
|
206
212
|
return formatRows(response);
|
|
207
213
|
}
|
|
208
|
-
export async function getContentPerformance(propertyId, startDate, endDate, limit = 50, accountId) {
|
|
214
|
+
export async function getContentPerformance(propertyId, startDate, endDate, limit = 50, accountId, offset) {
|
|
209
215
|
// Try to query content group first?
|
|
210
216
|
// Not sure if contentGroup is standard. 'contentGroup' is a standard dimension.
|
|
211
217
|
// If not used, it might return (not set).
|
|
@@ -217,6 +223,7 @@ export async function getContentPerformance(propertyId, startDate, endDate, limi
|
|
|
217
223
|
dimensions: ['contentGroup'], // Fallback to pagePath if needed? No, separate tool or option.
|
|
218
224
|
metrics: ['sessions', 'averageSessionDuration', 'conversions'],
|
|
219
225
|
limit,
|
|
226
|
+
offset,
|
|
220
227
|
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }]
|
|
221
228
|
});
|
|
222
229
|
const rows = formatRows(response);
|
|
@@ -228,7 +235,7 @@ export async function getContentPerformance(propertyId, startDate, endDate, limi
|
|
|
228
235
|
}
|
|
229
236
|
return rows;
|
|
230
237
|
}
|
|
231
|
-
export async function getEcommerce(propertyId, startDate, endDate, limit = 50, accountId) {
|
|
238
|
+
export async function getEcommerce(propertyId, startDate, endDate, limit = 50, accountId, offset) {
|
|
232
239
|
const response = await queryAnalytics({
|
|
233
240
|
propertyId,
|
|
234
241
|
accountId,
|
|
@@ -237,6 +244,7 @@ export async function getEcommerce(propertyId, startDate, endDate, limit = 50, a
|
|
|
237
244
|
dimensions: ['itemName'],
|
|
238
245
|
metrics: ['itemRevenue', 'itemsPurchased', 'ecommercePurchases'],
|
|
239
246
|
limit,
|
|
247
|
+
offset,
|
|
240
248
|
orderBys: [{ metric: { metricName: 'itemRevenue' }, desc: true }]
|
|
241
249
|
});
|
|
242
250
|
// Check if we have data. If total revenue is 0 across all rows, maybe return warning.
|
|
@@ -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
|
+
}
|
package/dist/google/client.js
CHANGED
|
@@ -2,6 +2,7 @@ import { google } from 'googleapis';
|
|
|
2
2
|
import nodeMachineId from 'node-machine-id';
|
|
3
3
|
import { loadConfig, updateAccount, removeAccount } from '../common/auth/config.js';
|
|
4
4
|
import { resolveAccount } from '../common/auth/resolver.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
5
6
|
const { machineIdSync } = nodeMachineId;
|
|
6
7
|
const SCOPES = [
|
|
7
8
|
'https://www.googleapis.com/auth/webmasters.readonly',
|
|
@@ -32,8 +33,10 @@ export async function getSearchConsoleClient(siteUrl, accountId) {
|
|
|
32
33
|
}
|
|
33
34
|
const cacheKey = account.id;
|
|
34
35
|
if (cachedClientMap[cacheKey]) {
|
|
36
|
+
logger.debug(`Using cached client for account: ${account.alias} (${account.id})`);
|
|
35
37
|
return cachedClientMap[cacheKey];
|
|
36
38
|
}
|
|
39
|
+
logger.debug(`Initializing Search Console client for ${account.alias} (ID: ${account.id}, Engine: ${account.engine})`);
|
|
37
40
|
// 2. Load Tokens
|
|
38
41
|
const tokens = await loadTokensForAccount(account);
|
|
39
42
|
if (tokens) {
|
|
@@ -42,16 +45,18 @@ export async function getSearchConsoleClient(siteUrl, accountId) {
|
|
|
42
45
|
oauth2Client.setCredentials(tokens);
|
|
43
46
|
// Check for expiry (refresh if needed)
|
|
44
47
|
if (tokens.expiry_date && tokens.expiry_date <= Date.now()) {
|
|
48
|
+
logger.debug(`Tokens expired for ${account.alias}, refreshing...`);
|
|
45
49
|
const { credentials } = await oauth2Client.refreshAccessToken();
|
|
46
50
|
await saveTokensForAccount(account, credentials);
|
|
47
51
|
oauth2Client.setCredentials(credentials);
|
|
48
52
|
}
|
|
49
53
|
const client = google.searchconsole({ version: 'v1', auth: oauth2Client });
|
|
54
|
+
logger.debug(`Client successfully initialized with OAuth2 for ${account.alias}`);
|
|
50
55
|
cachedClientMap[cacheKey] = client;
|
|
51
56
|
return client;
|
|
52
57
|
}
|
|
53
58
|
catch (error) {
|
|
54
|
-
|
|
59
|
+
logger.error(`Failed to use tokens for account ${account.alias}:`, error.message);
|
|
55
60
|
}
|
|
56
61
|
}
|
|
57
62
|
// 3. Support Service Account Path (Multi-Account)
|
|
@@ -62,6 +67,7 @@ export async function getSearchConsoleClient(siteUrl, accountId) {
|
|
|
62
67
|
});
|
|
63
68
|
const client = google.searchconsole({ version: 'v1', auth });
|
|
64
69
|
cachedClientMap[cacheKey] = client;
|
|
70
|
+
logger.debug(`Client initialized with Service Account Path for ${account.alias}`);
|
|
65
71
|
return client;
|
|
66
72
|
}
|
|
67
73
|
// 4. Fallback to Service Account (Environment Variables) - Only if no specific account was resolved or it was a legacy fallback
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSearchConsoleClient } from '../client.js';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
2
3
|
const CACHE_TTL_MS = 60 * 1000; // 60 seconds
|
|
3
4
|
const MAX_CACHE_SIZE = 100;
|
|
4
5
|
const analyticsCache = new Map();
|
|
@@ -32,9 +33,11 @@ export async function queryAnalytics(options) {
|
|
|
32
33
|
const cached = analyticsCache.get(cacheKey);
|
|
33
34
|
if (cached) {
|
|
34
35
|
if ('then' in cached) {
|
|
36
|
+
logger.debug(`Cache hit (pending) for ${options.siteUrl}`);
|
|
35
37
|
return cached;
|
|
36
38
|
}
|
|
37
39
|
if (now - cached.timestamp < CACHE_TTL_MS) {
|
|
40
|
+
logger.debug(`Cache hit (fresh) for ${options.siteUrl}`);
|
|
38
41
|
// LRU: Refresh key position
|
|
39
42
|
analyticsCache.delete(cacheKey);
|
|
40
43
|
analyticsCache.set(cacheKey, cached);
|
|
@@ -48,7 +51,7 @@ export async function queryAnalytics(options) {
|
|
|
48
51
|
const requestBody = {
|
|
49
52
|
startDate: options.startDate,
|
|
50
53
|
endDate: options.endDate,
|
|
51
|
-
dimensions: options.dimensions
|
|
54
|
+
dimensions: (options.dimensions && options.dimensions.length > 0) ? options.dimensions : undefined,
|
|
52
55
|
type: options.type || 'web',
|
|
53
56
|
aggregationType: options.aggregationType || 'auto',
|
|
54
57
|
dataState: options.dataState || 'final',
|
|
@@ -70,11 +73,18 @@ export async function queryAnalytics(options) {
|
|
|
70
73
|
}]
|
|
71
74
|
}));
|
|
72
75
|
}
|
|
76
|
+
logger.debug(`Fetching analytics for ${options.siteUrl}`, {
|
|
77
|
+
startDate: options.startDate,
|
|
78
|
+
endDate: options.endDate,
|
|
79
|
+
dimensions: options.dimensions,
|
|
80
|
+
filters: options.filters?.length || 0
|
|
81
|
+
});
|
|
73
82
|
const res = await client.searchanalytics.query({
|
|
74
83
|
siteUrl: options.siteUrl,
|
|
75
84
|
requestBody
|
|
76
85
|
});
|
|
77
86
|
const rows = res.data.rows || [];
|
|
87
|
+
logger.debug(`Received ${rows.length} rows for ${options.siteUrl}`);
|
|
78
88
|
analyticsCache.set(cacheKey, {
|
|
79
89
|
data: rows,
|
|
80
90
|
timestamp: Date.now()
|
|
@@ -88,6 +98,7 @@ export async function queryAnalytics(options) {
|
|
|
88
98
|
return rows;
|
|
89
99
|
}
|
|
90
100
|
catch (error) {
|
|
101
|
+
logger.error(`Error fetching analytics for ${options.siteUrl}:`, error.message);
|
|
91
102
|
analyticsCache.delete(cacheKey);
|
|
92
103
|
throw error;
|
|
93
104
|
}
|
|
@@ -104,7 +115,7 @@ export async function queryAnalytics(options) {
|
|
|
104
115
|
* @returns Combined metrics for the requested period.
|
|
105
116
|
*/
|
|
106
117
|
export async function getPerformanceSummary(siteUrl, days = 28) {
|
|
107
|
-
const DATA_DELAY_DAYS =
|
|
118
|
+
const DATA_DELAY_DAYS = 1; // Use 1 day delay with 'all' dataState (fresh data)
|
|
108
119
|
const endDate = new Date();
|
|
109
120
|
endDate.setDate(endDate.getDate() - DATA_DELAY_DAYS);
|
|
110
121
|
const startDate = new Date(endDate);
|
|
@@ -115,6 +126,7 @@ export async function getPerformanceSummary(siteUrl, days = 28) {
|
|
|
115
126
|
siteUrl,
|
|
116
127
|
startDate: startDateStr,
|
|
117
128
|
endDate: endDateStr,
|
|
129
|
+
dataState: 'all',
|
|
118
130
|
limit: 1
|
|
119
131
|
});
|
|
120
132
|
if (rows.length > 0) {
|
|
@@ -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";
|
|
@@ -45,6 +48,8 @@ import { dirname } from 'path';
|
|
|
45
48
|
import { getStartedHandler, getStartedToolName, getStartedToolDescription, getStartedToolSchema } from "./common/tools/get-started.js";
|
|
46
49
|
import { registerPrompts } from "./prompts/index.js";
|
|
47
50
|
import { jsonToCsv } from "./common/utils/csv.js";
|
|
51
|
+
import { runDiagnostics } from "./common/diagnostics.js";
|
|
52
|
+
import { logger } from "./utils/logger.js";
|
|
48
53
|
const __filename = fileURLToPath(import.meta.url);
|
|
49
54
|
const __dirname = dirname(__filename);
|
|
50
55
|
// Load version from package.json
|
|
@@ -64,7 +69,7 @@ const server = new McpServer({
|
|
|
64
69
|
// Get Started Tool
|
|
65
70
|
server.tool(getStartedToolName, getStartedToolDescription, getStartedToolSchema, getStartedHandler);
|
|
66
71
|
// 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" }) => {
|
|
72
|
+
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
73
|
try {
|
|
69
74
|
const config = await loadConfig();
|
|
70
75
|
const accounts = Object.values(config.accounts).filter(a => a.engine === engine);
|
|
@@ -73,26 +78,64 @@ server.tool("sites_list", "List all verified sites across all authorized account
|
|
|
73
78
|
const results = await bingSites.listSites();
|
|
74
79
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
75
80
|
}
|
|
76
|
-
const allResults =
|
|
77
|
-
for (const account of accounts) {
|
|
81
|
+
const allResults = await limitConcurrency(accounts, 5, async (account) => {
|
|
78
82
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
let results;
|
|
84
|
+
if (engine === "google") {
|
|
85
|
+
results = await sites.listSites(account.id);
|
|
86
|
+
}
|
|
87
|
+
else if (engine === "bing") {
|
|
88
|
+
results = await bingSites.listSites(account.id);
|
|
89
|
+
}
|
|
90
|
+
else if (engine === "ga4") {
|
|
91
|
+
results = await ga4Properties.listProperties(account.id);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
results = [];
|
|
95
|
+
}
|
|
96
|
+
const rawCount = results.length;
|
|
97
|
+
// Boundary Filtering: Honor the website in the config
|
|
98
|
+
if (account.websites && account.websites.length > 0) {
|
|
99
|
+
results = results.filter(site => {
|
|
100
|
+
const url = engine === "ga4" ? (site.propertyId) : (site.siteUrl || site.Url);
|
|
101
|
+
if (!url)
|
|
102
|
+
return false;
|
|
103
|
+
// If it's a numeric property ID (GA4), check direct inclusion
|
|
104
|
+
if (engine === "ga4" && /^\d+$/.test(url)) {
|
|
105
|
+
return account.websites.includes(url);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const normalizedSite = normalizeWebsite(url).value;
|
|
109
|
+
const isMatch = account.websites.some(w => normalizeWebsite(w).value === normalizedSite);
|
|
110
|
+
if (!isMatch) {
|
|
111
|
+
logger.debug(`Filtered out site ${url} for account ${account.alias} (not in whitelist)`);
|
|
112
|
+
}
|
|
113
|
+
return isMatch;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return account.websites.includes(url);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
logger.debug(`Account ${account.alias}: ${results.length}/${rawCount} sites kept after filtering.`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
logger.debug(`Account ${account.alias}: Found ${results.length} sites (no filtering).`);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
83
125
|
account: account.alias,
|
|
84
126
|
accountId: account.id,
|
|
85
127
|
sites: results
|
|
86
|
-
}
|
|
128
|
+
};
|
|
87
129
|
}
|
|
88
130
|
catch (e) {
|
|
89
|
-
|
|
131
|
+
logger.error(`Failed to list sites for account ${account.alias}:`, e.message);
|
|
132
|
+
return {
|
|
90
133
|
account: account.alias,
|
|
91
134
|
accountId: account.id,
|
|
92
135
|
error: e.message
|
|
93
|
-
}
|
|
136
|
+
};
|
|
94
137
|
}
|
|
95
|
-
}
|
|
138
|
+
});
|
|
96
139
|
return {
|
|
97
140
|
content: [{ type: "text", text: JSON.stringify(allResults, null, 2) }]
|
|
98
141
|
};
|
|
@@ -101,6 +144,49 @@ server.tool("sites_list", "List all verified sites across all authorized account
|
|
|
101
144
|
return formatError(error);
|
|
102
145
|
}
|
|
103
146
|
});
|
|
147
|
+
server.tool("bing_seo_cannibalization", "Detect pages competing for the same query in Bing.", {
|
|
148
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
149
|
+
minImpressions: z.number().optional().describe("Minimum impressions threshold (default 50)")
|
|
150
|
+
}, async ({ siteUrl, minImpressions }) => {
|
|
151
|
+
try {
|
|
152
|
+
const results = await bingSeoInsights.detectCannibalization(siteUrl, { minImpressions });
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
return formatError(error);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
server.tool("bing_seo_lost_queries", "Identify queries that lost significant traffic on Bing compared to the previous period.", {
|
|
162
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
163
|
+
days: z.number().optional().describe("Lookback period in days (default 28)")
|
|
164
|
+
}, async ({ siteUrl, days }) => {
|
|
165
|
+
try {
|
|
166
|
+
const results = await bingSeoInsights.findLostQueries(siteUrl, { days });
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
return formatError(error);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
server.tool("bing_brand_analysis", "Analyze Brand vs Non-Brand performance on Bing.", {
|
|
176
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
177
|
+
brandRegex: z.string().describe("Regex matching brand terms (e.g. 'acme|acmecorp')"),
|
|
178
|
+
days: z.number().optional().describe("Number of days to analyze (default 28)")
|
|
179
|
+
}, async ({ siteUrl, brandRegex, days }) => {
|
|
180
|
+
try {
|
|
181
|
+
const results = await bingSeoInsights.analyzeBrandVsNonBrand(siteUrl, brandRegex, { days });
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
return formatError(error);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
104
190
|
server.tool("bing_analytics_trends", "Detect rising or declining trends in Bing query performance", {
|
|
105
191
|
siteUrl: z.string().describe("The URL of the site"),
|
|
106
192
|
days: z.number().optional().describe("Number of days to analyze (default 28)"),
|
|
@@ -1284,10 +1370,11 @@ server.tool("analytics_page_performance", "Get detailed page performance metrics
|
|
|
1284
1370
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1285
1371
|
pagePath: z.string().optional().describe("Filter by specific page path"),
|
|
1286
1372
|
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1373
|
+
offset: z.number().optional().describe("Starting row for pagination (0-based)"),
|
|
1287
1374
|
format: z.enum(["json", "csv"]).optional().describe("Output format (default: json)")
|
|
1288
|
-
}, async ({ propertyId, accountId, startDate, endDate, pagePath, limit, format }) => {
|
|
1375
|
+
}, async ({ propertyId, accountId, startDate, endDate, pagePath, limit, offset, format }) => {
|
|
1289
1376
|
try {
|
|
1290
|
-
const result = await ga4Analytics.getPagePerformance(propertyId, startDate, endDate, pagePath, limit, accountId);
|
|
1377
|
+
const result = await ga4Analytics.getPagePerformance(propertyId, startDate, endDate, pagePath, limit, accountId, offset);
|
|
1291
1378
|
if (format === 'csv') {
|
|
1292
1379
|
return {
|
|
1293
1380
|
content: [{ type: "text", text: jsonToCsv(result) }]
|
|
@@ -1307,10 +1394,11 @@ server.tool("analytics_traffic_sources", "Analyze traffic sources (Channel, Sour
|
|
|
1307
1394
|
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
1308
1395
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1309
1396
|
channelGroup: z.string().optional().describe("Filter by Channel Group (e.g. 'Organic Search')"),
|
|
1310
|
-
limit: z.number().optional().describe("Max rows (default 50)")
|
|
1311
|
-
|
|
1397
|
+
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1398
|
+
offset: z.number().optional().describe("Starting row for pagination (0-based)")
|
|
1399
|
+
}, async ({ propertyId, accountId, startDate, endDate, channelGroup, limit, offset }) => {
|
|
1312
1400
|
try {
|
|
1313
|
-
const result = await ga4Analytics.getTrafficSources(propertyId, startDate, endDate, channelGroup, limit, accountId);
|
|
1401
|
+
const result = await ga4Analytics.getTrafficSources(propertyId, startDate, endDate, channelGroup, limit, accountId, offset);
|
|
1314
1402
|
return {
|
|
1315
1403
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1316
1404
|
};
|
|
@@ -1324,10 +1412,11 @@ server.tool("analytics_organic_landing_pages", "Get performance of organic landi
|
|
|
1324
1412
|
accountId: z.string().optional().describe("GA4 account ID for multi-account setups"),
|
|
1325
1413
|
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
1326
1414
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1327
|
-
limit: z.number().optional().describe("Max rows (default 50)")
|
|
1328
|
-
|
|
1415
|
+
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1416
|
+
offset: z.number().optional().describe("Starting row for pagination (0-based)")
|
|
1417
|
+
}, async ({ propertyId, accountId, startDate, endDate, limit, offset }) => {
|
|
1329
1418
|
try {
|
|
1330
|
-
const result = await ga4Analytics.getOrganicLandingPages(propertyId, startDate, endDate, limit, accountId);
|
|
1419
|
+
const result = await ga4Analytics.getOrganicLandingPages(propertyId, startDate, endDate, limit, accountId, offset);
|
|
1331
1420
|
return {
|
|
1332
1421
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1333
1422
|
};
|
|
@@ -1341,10 +1430,11 @@ server.tool("analytics_content_performance", "Analyze content performance by Con
|
|
|
1341
1430
|
accountId: z.string().optional().describe("GA4 account ID for multi-account setups"),
|
|
1342
1431
|
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
1343
1432
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1344
|
-
limit: z.number().optional().describe("Max rows (default 50)")
|
|
1345
|
-
|
|
1433
|
+
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1434
|
+
offset: z.number().optional().describe("Starting row for pagination (0-based)")
|
|
1435
|
+
}, async ({ propertyId, accountId, startDate, endDate, limit, offset }) => {
|
|
1346
1436
|
try {
|
|
1347
|
-
const result = await ga4Analytics.getContentPerformance(propertyId, startDate, endDate, limit, accountId);
|
|
1437
|
+
const result = await ga4Analytics.getContentPerformance(propertyId, startDate, endDate, limit, accountId, offset);
|
|
1348
1438
|
return {
|
|
1349
1439
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1350
1440
|
};
|
|
@@ -1358,10 +1448,11 @@ server.tool("analytics_ecommerce", "Get ecommerce performance (products, revenue
|
|
|
1358
1448
|
accountId: z.string().optional().describe("GA4 account ID for multi-account setups"),
|
|
1359
1449
|
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
1360
1450
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1361
|
-
limit: z.number().optional().describe("Max rows (default 50)")
|
|
1362
|
-
|
|
1451
|
+
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1452
|
+
offset: z.number().optional().describe("Starting row for pagination (0-based)")
|
|
1453
|
+
}, async ({ propertyId, accountId, startDate, endDate, limit, offset }) => {
|
|
1363
1454
|
try {
|
|
1364
|
-
const result = await ga4Analytics.getEcommerce(propertyId, startDate, endDate, limit, accountId);
|
|
1455
|
+
const result = await ga4Analytics.getEcommerce(propertyId, startDate, endDate, limit, accountId, offset);
|
|
1365
1456
|
return {
|
|
1366
1457
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1367
1458
|
};
|
|
@@ -1902,8 +1993,19 @@ If any site has a 'critical' or 'warning' status:
|
|
|
1902
1993
|
}]
|
|
1903
1994
|
};
|
|
1904
1995
|
});
|
|
1905
|
-
// Register additional prompts
|
|
1906
1996
|
registerPrompts(server);
|
|
1997
|
+
// Diagnostics Tool
|
|
1998
|
+
server.tool("diagnostics", "Run connectivity diagnostics for all connected accounts. Use this to troubleshoot '0 results' or authentication issues.", {}, async () => {
|
|
1999
|
+
try {
|
|
2000
|
+
const results = await runDiagnostics();
|
|
2001
|
+
return {
|
|
2002
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
catch (error) {
|
|
2006
|
+
return formatError(error);
|
|
2007
|
+
}
|
|
2008
|
+
});
|
|
1907
2009
|
async function main() {
|
|
1908
2010
|
const command = process.argv[2];
|
|
1909
2011
|
// Handle standalone commands
|
|
@@ -1927,6 +2029,16 @@ async function main() {
|
|
|
1927
2029
|
await login();
|
|
1928
2030
|
return;
|
|
1929
2031
|
}
|
|
2032
|
+
if (command === 'diagnostics') {
|
|
2033
|
+
const results = await runDiagnostics();
|
|
2034
|
+
console.log(JSON.stringify(results, null, 2));
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
if (command === 'sites') {
|
|
2038
|
+
const { main: accountsMain } = await import('./accounts.js');
|
|
2039
|
+
await accountsMain(['list']);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
1930
2042
|
// Check for credentials
|
|
1931
2043
|
const config = await loadConfig();
|
|
1932
2044
|
const accounts = Object.values(config.accounts);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging utility for Search Console MCP.
|
|
3
|
+
* Since this is a stdio-based MCP server, we must log to stderr
|
|
4
|
+
* to avoid interfering with the MCP protocol on stdout.
|
|
5
|
+
*/
|
|
6
|
+
import { colors } from './ui.js';
|
|
7
|
+
const isDebugEnabled = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
|
|
8
|
+
export const logger = {
|
|
9
|
+
/**
|
|
10
|
+
* Log a debug message. Only visible if DEBUG=true or NODE_ENV=development.
|
|
11
|
+
*/
|
|
12
|
+
debug(message, ...args) {
|
|
13
|
+
if (isDebugEnabled) {
|
|
14
|
+
const timestamp = new Date().toISOString();
|
|
15
|
+
console.error(`${colors.dim}[${timestamp}]${colors.reset} ${colors.blue}[DEBUG]${colors.reset} ${message}`, ...args);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
/**
|
|
19
|
+
* Log an error message. Always visible.
|
|
20
|
+
*/
|
|
21
|
+
error(message, error) {
|
|
22
|
+
const timestamp = new Date().toISOString();
|
|
23
|
+
console.error(`${colors.dim}[${timestamp}]${colors.reset} ${colors.red}[ERROR]${colors.reset} ${message}`, error || '');
|
|
24
|
+
},
|
|
25
|
+
/**
|
|
26
|
+
* Log an info message. Always visible.
|
|
27
|
+
*/
|
|
28
|
+
info(message, ...args) {
|
|
29
|
+
const timestamp = new Date().toISOString();
|
|
30
|
+
console.error(`${colors.dim}[${timestamp}]${colors.reset} ${colors.green}[INFO]${colors.reset} ${message}`, ...args);
|
|
31
|
+
},
|
|
32
|
+
/**
|
|
33
|
+
* Log a warning message. Always visible.
|
|
34
|
+
*/
|
|
35
|
+
warn(message, ...args) {
|
|
36
|
+
const timestamp = new Date().toISOString();
|
|
37
|
+
console.error(`${colors.dim}[${timestamp}]${colors.reset} ${colors.yellow}[WARN]${colors.reset} ${message}`, ...args);
|
|
38
|
+
}
|
|
39
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "search-console-mcp",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.4",
|
|
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
40
|
"dotenv": "^17.3.1",
|
|
41
|
-
"googleapis": "^171.
|
|
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
|
+
}
|