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 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.mintlify.app/)
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 = new Date(dropDate);
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) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
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
- startIndex = rows.findIndex(r => r.Date >= options.startDate);
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 > options.endDate);
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 = new Date(d.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 = new Date(d.date).getDay();
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 && new Date(h.date).getDay() === peakDay)
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 = new Date(row.Date);
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 = new Date(row.Date);
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 = new Date(row.Date);
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 = new Date(row.Date);
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 = new Date(row.Date);
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) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
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 = new Date(row.Date);
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 = new Date(stat.Date);
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
  }
@@ -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
- const targetHost = new URL(siteUrl).hostname.toLowerCase();
40
- const domainMatch = accounts.find(a => a.websites?.some(w => {
41
- const nw = normalizeWebsite(w);
42
- return nw.type === 'domain' && (targetHost === nw.value || targetHost.endsWith('.' + nw.value));
43
- }));
44
- if (domainMatch)
45
- return domainMatch;
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
+ }
@@ -6,12 +6,18 @@ export function jsonToCsv(data) {
6
6
  if (!data || data.length === 0) {
7
7
  return "";
8
8
  }
9
- // Get headers from the first object
10
- const headers = Object.keys(data[0]);
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' || Array.isArray(obj)) {
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 Promise.allSettled(pages.map(async (page) => {
16
- const path = page.landingPagePlusQueryString || page.pagePath;
17
- if (!path)
18
- throw new Error("No page path found");
19
- const fullUrl = `${baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
20
- const psiResult = await analyzePageSpeed(fullUrl, strategy);
21
- return {
22
- ...page,
23
- pageSpeed: psiResult
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
- else {
28
+ catch (error) {
32
29
  return {
33
- ...pages[index],
34
- pageSpeedError: res.reason?.message || "Failed to analyze"
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
+ }
@@ -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
- console.error(`Failed to use tokens for account ${account.alias}:`, error.message);
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 = 3;
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
- const results = engine === "google"
80
- ? await sites.listSites(account.id)
81
- : await bingSites.listSites(account.id);
82
- allResults.push({
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
- allResults.push({
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
- }, async ({ propertyId, accountId, startDate, endDate, channelGroup, limit }) => {
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
- }, async ({ propertyId, accountId, startDate, endDate, limit }) => {
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
- }, async ({ propertyId, accountId, startDate, endDate, limit }) => {
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
- }, async ({ propertyId, accountId, startDate, endDate, limit }) => {
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.2",
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.6.0",
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.0.0",
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
+ }