search-console-mcp 1.13.3 → 1.13.5

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.
@@ -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
  }
@@ -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
+ }
@@ -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
+ }
@@ -85,7 +85,7 @@ export async function getGA4Client(propertyId, accountId) {
85
85
  // Determine Property ID to use
86
86
  // If account has a specific property ID, use it.
87
87
  // If not, and propertyId argument was passed, use that (assuming account has access).
88
- const targetPropertyId = account.ga4PropertyId || propertyId;
88
+ const targetPropertyId = propertyId || account.ga4PropertyId;
89
89
  if (!targetPropertyId) {
90
90
  throw new Error(`No Property ID found for account ${account.alias}.`);
91
91
  }
@@ -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.
@@ -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) {
package/dist/index.js CHANGED
@@ -48,6 +48,8 @@ import { dirname } from 'path';
48
48
  import { getStartedHandler, getStartedToolName, getStartedToolDescription, getStartedToolSchema } from "./common/tools/get-started.js";
49
49
  import { registerPrompts } from "./prompts/index.js";
50
50
  import { jsonToCsv } from "./common/utils/csv.js";
51
+ import { runDiagnostics } from "./common/diagnostics.js";
52
+ import { logger } from "./utils/logger.js";
51
53
  const __filename = fileURLToPath(import.meta.url);
52
54
  const __dirname = dirname(__filename);
53
55
  // Load version from package.json
@@ -91,6 +93,7 @@ server.tool("sites_list", "List all verified sites or properties across all auth
91
93
  else {
92
94
  results = [];
93
95
  }
96
+ const rawCount = results.length;
94
97
  // Boundary Filtering: Honor the website in the config
95
98
  if (account.websites && account.websites.length > 0) {
96
99
  results = results.filter(site => {
@@ -103,12 +106,20 @@ server.tool("sites_list", "List all verified sites or properties across all auth
103
106
  }
104
107
  try {
105
108
  const normalizedSite = normalizeWebsite(url).value;
106
- return account.websites.some(w => normalizeWebsite(w).value === normalizedSite);
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;
107
114
  }
108
115
  catch {
109
116
  return account.websites.includes(url);
110
117
  }
111
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).`);
112
123
  }
113
124
  return {
114
125
  account: account.alias,
@@ -117,6 +128,7 @@ server.tool("sites_list", "List all verified sites or properties across all auth
117
128
  };
118
129
  }
119
130
  catch (e) {
131
+ logger.error(`Failed to list sites for account ${account.alias}:`, e.message);
120
132
  return {
121
133
  account: account.alias,
122
134
  accountId: account.id,
@@ -1358,10 +1370,11 @@ server.tool("analytics_page_performance", "Get detailed page performance metrics
1358
1370
  endDate: z.string().describe("End date (YYYY-MM-DD)"),
1359
1371
  pagePath: z.string().optional().describe("Filter by specific page path"),
1360
1372
  limit: z.number().optional().describe("Max rows (default 50)"),
1373
+ offset: z.number().optional().describe("Starting row for pagination (0-based)"),
1361
1374
  format: z.enum(["json", "csv"]).optional().describe("Output format (default: json)")
1362
- }, async ({ propertyId, accountId, startDate, endDate, pagePath, limit, format }) => {
1375
+ }, async ({ propertyId, accountId, startDate, endDate, pagePath, limit, offset, format }) => {
1363
1376
  try {
1364
- const result = await ga4Analytics.getPagePerformance(propertyId, startDate, endDate, pagePath, limit, accountId);
1377
+ const result = await ga4Analytics.getPagePerformance(propertyId, startDate, endDate, pagePath, limit, accountId, offset);
1365
1378
  if (format === 'csv') {
1366
1379
  return {
1367
1380
  content: [{ type: "text", text: jsonToCsv(result) }]
@@ -1381,10 +1394,11 @@ server.tool("analytics_traffic_sources", "Analyze traffic sources (Channel, Sour
1381
1394
  startDate: z.string().describe("Start date (YYYY-MM-DD)"),
1382
1395
  endDate: z.string().describe("End date (YYYY-MM-DD)"),
1383
1396
  channelGroup: z.string().optional().describe("Filter by Channel Group (e.g. 'Organic Search')"),
1384
- limit: z.number().optional().describe("Max rows (default 50)")
1385
- }, 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 }) => {
1386
1400
  try {
1387
- const result = await ga4Analytics.getTrafficSources(propertyId, startDate, endDate, channelGroup, limit, accountId);
1401
+ const result = await ga4Analytics.getTrafficSources(propertyId, startDate, endDate, channelGroup, limit, accountId, offset);
1388
1402
  return {
1389
1403
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1390
1404
  };
@@ -1398,10 +1412,11 @@ server.tool("analytics_organic_landing_pages", "Get performance of organic landi
1398
1412
  accountId: z.string().optional().describe("GA4 account ID for multi-account setups"),
1399
1413
  startDate: z.string().describe("Start date (YYYY-MM-DD)"),
1400
1414
  endDate: z.string().describe("End date (YYYY-MM-DD)"),
1401
- limit: z.number().optional().describe("Max rows (default 50)")
1402
- }, 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 }) => {
1403
1418
  try {
1404
- const result = await ga4Analytics.getOrganicLandingPages(propertyId, startDate, endDate, limit, accountId);
1419
+ const result = await ga4Analytics.getOrganicLandingPages(propertyId, startDate, endDate, limit, accountId, offset);
1405
1420
  return {
1406
1421
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1407
1422
  };
@@ -1415,10 +1430,11 @@ server.tool("analytics_content_performance", "Analyze content performance by Con
1415
1430
  accountId: z.string().optional().describe("GA4 account ID for multi-account setups"),
1416
1431
  startDate: z.string().describe("Start date (YYYY-MM-DD)"),
1417
1432
  endDate: z.string().describe("End date (YYYY-MM-DD)"),
1418
- limit: z.number().optional().describe("Max rows (default 50)")
1419
- }, 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 }) => {
1420
1436
  try {
1421
- const result = await ga4Analytics.getContentPerformance(propertyId, startDate, endDate, limit, accountId);
1437
+ const result = await ga4Analytics.getContentPerformance(propertyId, startDate, endDate, limit, accountId, offset);
1422
1438
  return {
1423
1439
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1424
1440
  };
@@ -1432,10 +1448,11 @@ server.tool("analytics_ecommerce", "Get ecommerce performance (products, revenue
1432
1448
  accountId: z.string().optional().describe("GA4 account ID for multi-account setups"),
1433
1449
  startDate: z.string().describe("Start date (YYYY-MM-DD)"),
1434
1450
  endDate: z.string().describe("End date (YYYY-MM-DD)"),
1435
- limit: z.number().optional().describe("Max rows (default 50)")
1436
- }, 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 }) => {
1437
1454
  try {
1438
- const result = await ga4Analytics.getEcommerce(propertyId, startDate, endDate, limit, accountId);
1455
+ const result = await ga4Analytics.getEcommerce(propertyId, startDate, endDate, limit, accountId, offset);
1439
1456
  return {
1440
1457
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1441
1458
  };
@@ -1976,8 +1993,19 @@ If any site has a 'critical' or 'warning' status:
1976
1993
  }]
1977
1994
  };
1978
1995
  });
1979
- // Register additional prompts
1980
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
+ });
1981
2009
  async function main() {
1982
2010
  const command = process.argv[2];
1983
2011
  // Handle standalone commands
@@ -2001,6 +2029,16 @@ async function main() {
2001
2029
  await login();
2002
2030
  return;
2003
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
+ }
2004
2042
  // Check for credentials
2005
2043
  const config = await loadConfig();
2006
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",
3
+ "version": "1.13.5",
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",
@@ -52,4 +52,4 @@
52
52
  "typescript": "^5.9.3",
53
53
  "vitest": "^4.0.18"
54
54
  }
55
- }
55
+ }