search-console-mcp 1.13.0 → 1.13.1

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
@@ -179,7 +179,7 @@ This MCP server implements a multi-layered security architecture:
179
179
  | `analytics_drop_attribution` | **[NEW]** Attribute traffic drops to mobile/desktop or correlate with known Google Algorithm Updates. |
180
180
  | `analytics_time_series` | **[NEW]** Advanced time series with rolling averages, seasonality detection, and forecasting. |
181
181
  | `analytics_compare_periods` | Compare two date ranges (e.g., WoW, MoM). |
182
- | `seo_brand_vs_nonbrand` | **[NEW]** Analyze performance split between Brand vs Non-Brand traffic. |
182
+ | `seo_brand_vs_nonbrand` | **[NEW]** Analyze performance split between Brand vs Non-Brand traffic. (Supports Google & Bing). |
183
183
 
184
184
  ### SEO Opportunities (Opinionated)
185
185
  | Tool | Description |
@@ -188,7 +188,7 @@ This MCP server implements a multi-layered security architecture:
188
188
  | `seo_striking_distance` | **[NEW]** Find keywords ranking 8-15 (Quickest ROI wins). |
189
189
  | `seo_low_ctr_opportunities` | **[NEW]** Find top ranking queries (pos 1-10) with poor CTR. |
190
190
  | `seo_cannibalization` | **[Enhanced]** Detect pages competing for the same query with traffic conflict. |
191
- | `seo_lost_queries` | **[NEW]** Identify queries that lost all traffic in the last 28 days. |
191
+ | `seo_lost_queries` | **[NEW]** Identify queries that lost all traffic in the last 28 days. (Supports Google & Bing). |
192
192
 
193
193
  ### SEO Primitives (Atoms for Agents)
194
194
  These are low-level tools designed to be used by other AI agents to build complex logic.
@@ -226,6 +226,8 @@ These are low-level tools designed to be used by other AI agents to build comple
226
226
  | `bing_crawl_issues` | List crawl issues detected by Bing. |
227
227
  | `bing_analytics_detect_anomalies` | Detect daily spikes or drops in Bing traffic. |
228
228
  | `bing_analytics_time_series` | Advanced time series analysis for Bing. |
229
+ | `bing_seo_lost_queries` | Identify queries that lost significant traffic on Bing. |
230
+ | `bing_brand_analysis` | Analyze performance split between Brand vs Non-Brand traffic on Bing. |
229
231
  | `bing_sitemaps_list` / `bing_sitemaps_submit` | Manage sitemaps in Bing. |
230
232
 
231
233
  ### Google Analytics 4 (GA4)
@@ -1,4 +1,5 @@
1
1
  import { getQueryStats, getQueryPageStats } from './analytics.js';
2
+ import { safeTestBatch } from '../../common/utils/regex.js';
2
3
  /**
3
4
  * Find "low-hanging fruit" keywords in Bing.
4
5
  */
@@ -71,12 +72,12 @@ export async function findLowCTROpportunities(siteUrl, options = {}) {
71
72
  /**
72
73
  * Detect keyword cannibalization in Bing where multiple pages compete for the same query.
73
74
  */
74
- export async function detectCannibalization(siteUrl, options = {}) {
75
- const { minImpressions = 50, limit = 30 } = options;
76
- const rows = await getQueryPageStats(siteUrl);
75
+ export async function detectCannibalization(siteUrl, options = {}, rows) {
76
+ const { minImpressions = 50, limit = 30, startDate, endDate } = options;
77
+ const stats = rows || await getQueryPageStats(siteUrl, startDate, endDate);
77
78
  // Group by query
78
79
  const queryMap = new Map();
79
- for (const row of rows) {
80
+ for (const row of stats) {
80
81
  if (row.Impressions < minImpressions)
81
82
  continue;
82
83
  if (!queryMap.has(row.Query)) {
@@ -114,17 +115,105 @@ export async function detectCannibalization(siteUrl, options = {}) {
114
115
  .sort((a, b) => (b.totalClicks * b.clickShareConflict) - (a.totalClicks * a.clickShareConflict))
115
116
  .slice(0, limit);
116
117
  }
118
+ /**
119
+ * Identify queries that have lost significant visibility or clicks compared to the previous period.
120
+ */
121
+ export async function findLostQueries(siteUrl, options = {}) {
122
+ const { days = 28, limit = 50 } = options;
123
+ const endDate = new Date();
124
+ endDate.setDate(endDate.getDate() - 2); // Account for data delay
125
+ const midDate = new Date(endDate);
126
+ midDate.setDate(midDate.getDate() - days);
127
+ const startDate = new Date(midDate);
128
+ startDate.setDate(startDate.getDate() - days);
129
+ const [currentStats, previousStats] = await Promise.all([
130
+ getQueryStats(siteUrl, midDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0]),
131
+ getQueryStats(siteUrl, startDate.toISOString().split('T')[0], midDate.toISOString().split('T')[0])
132
+ ]);
133
+ const currentMap = new Map();
134
+ for (const row of currentStats) {
135
+ currentMap.set(row.Query, row);
136
+ }
137
+ const lostQueries = [];
138
+ for (const prev of previousStats) {
139
+ if (prev.Clicks < 5)
140
+ continue; // Ignore low volume noise
141
+ const curr = currentMap.get(prev.Query);
142
+ const currClicks = curr ? curr.Clicks : 0;
143
+ // Definition of lost: >80% drop or zero
144
+ if (currClicks === 0 || (currClicks / prev.Clicks) < 0.2) {
145
+ lostQueries.push({
146
+ query: prev.Query,
147
+ previousClicks: prev.Clicks,
148
+ previousImpressions: prev.Impressions,
149
+ previousPosition: prev.AvgPosition,
150
+ currentClicks: currClicks,
151
+ currentImpressions: curr ? curr.Impressions : 0,
152
+ currentPosition: curr ? curr.AvgPosition : 0,
153
+ lostClicks: prev.Clicks - currClicks
154
+ });
155
+ }
156
+ }
157
+ return lostQueries
158
+ .sort((a, b) => b.lostClicks - a.lostClicks)
159
+ .slice(0, limit);
160
+ }
161
+ /**
162
+ * Segment search performance into Brand and Non-Brand categories.
163
+ */
164
+ export async function analyzeBrandVsNonBrand(siteUrl, brandRegexString, options = {}) {
165
+ const { days = 28 } = options;
166
+ const endDate = new Date();
167
+ endDate.setDate(endDate.getDate() - 2);
168
+ const startDate = new Date(endDate);
169
+ startDate.setDate(startDate.getDate() - days);
170
+ const rows = await getQueryStats(siteUrl, startDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0]);
171
+ const queries = rows.map(r => r.Query);
172
+ const isBrandResults = safeTestBatch(brandRegexString, 'i', queries);
173
+ const brandStats = { clicks: 0, impressions: 0, weightedPos: 0, queryCount: 0 };
174
+ const nonBrandStats = { clicks: 0, impressions: 0, weightedPos: 0, queryCount: 0 };
175
+ rows.forEach((row, index) => {
176
+ const isBrand = isBrandResults[index];
177
+ const stats = isBrand ? brandStats : nonBrandStats;
178
+ stats.clicks += row.Clicks;
179
+ stats.impressions += row.Impressions;
180
+ stats.weightedPos += row.AvgPosition * row.Impressions;
181
+ stats.queryCount++;
182
+ });
183
+ const calc = (stats, segment) => ({
184
+ segment,
185
+ clicks: stats.clicks,
186
+ impressions: stats.impressions,
187
+ ctr: stats.impressions > 0 ? stats.clicks / stats.impressions : 0,
188
+ position: stats.impressions > 0 ? stats.weightedPos / stats.impressions : 0,
189
+ queryCount: stats.queryCount
190
+ });
191
+ return [
192
+ calc(brandStats, 'Brand'),
193
+ calc(nonBrandStats, 'Non-Brand')
194
+ ];
195
+ }
117
196
  /**
118
197
  * Generate prioritized Bing SEO recommendations.
119
198
  */
120
- export async function generateRecommendations(siteUrl) {
199
+ export async function generateRecommendations(siteUrl, options = {}) {
200
+ const { days = 28 } = options;
121
201
  const insights = [];
122
- const queryStatsPromise = getQueryStats(siteUrl);
202
+ const endDate = new Date();
203
+ endDate.setDate(endDate.getDate() - 2);
204
+ const startDate = new Date(endDate);
205
+ startDate.setDate(startDate.getDate() - days);
206
+ const sDate = startDate.toISOString().split('T')[0];
207
+ const eDate = endDate.toISOString().split('T')[0];
208
+ const [queryStats, queryPageStats] = await Promise.all([
209
+ getQueryStats(siteUrl, sDate, eDate),
210
+ getQueryPageStats(siteUrl, sDate, eDate)
211
+ ]);
123
212
  const [lowHangingFruit, strikingDistance, lowCTR, cannibalization] = await Promise.all([
124
- queryStatsPromise.then(stats => findLowHangingFruit(siteUrl, { limit: 10, queryStats: stats })),
125
- queryStatsPromise.then(stats => findStrikingDistance(siteUrl, { limit: 10, queryStats: stats })),
126
- queryStatsPromise.then(stats => findLowCTROpportunities(siteUrl, { limit: 10, queryStats: stats })),
127
- detectCannibalization(siteUrl, { limit: 10 })
213
+ findLowHangingFruit(siteUrl, { limit: 10, queryStats: queryStats }),
214
+ findStrikingDistance(siteUrl, { limit: 10, queryStats: queryStats }),
215
+ findLowCTROpportunities(siteUrl, { limit: 10, queryStats: queryStats }),
216
+ detectCannibalization(siteUrl, { limit: 10, startDate: sDate, endDate: eDate }, queryPageStats)
128
217
  ]);
129
218
  if (lowHangingFruit.length > 0) {
130
219
  const totalPotential = lowHangingFruit.reduce((sum, l) => sum + l.potentialClicks, 0);
@@ -7,6 +7,10 @@ const { machineIdSync } = nodeMachineId;
7
7
  const CONFIG_PATH = join(homedir(), '.search-console-mcp-config.enc');
8
8
  const LEGACY_TOKEN_PATH = join(homedir(), '.search-console-mcp-tokens.enc');
9
9
  const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
10
+ let cachedConfig = null;
11
+ export function resetConfigCache() {
12
+ cachedConfig = null;
13
+ }
10
14
  function getEncryptionKey() {
11
15
  const mId = machineIdSync();
12
16
  const salt = process.env.USER || 'sc-mcp-salt';
@@ -36,6 +40,9 @@ function decrypt(data) {
36
40
  * Load the unified configuration, including lazy migration from legacy Google tokens.
37
41
  */
38
42
  export async function loadConfig() {
43
+ if (cachedConfig) {
44
+ return cachedConfig;
45
+ }
39
46
  let config = { accounts: {} };
40
47
  // 1. Try to load from new unified config
41
48
  if (existsSync(CONFIG_PATH)) {
@@ -125,12 +132,14 @@ export async function loadConfig() {
125
132
  };
126
133
  }
127
134
  }
135
+ cachedConfig = config;
128
136
  return config;
129
137
  }
130
138
  export async function saveConfig(config) {
131
139
  try {
132
140
  const encrypted = encrypt(JSON.stringify(config));
133
141
  writeFileSync(CONFIG_PATH, encrypted, { mode: 0o600 });
142
+ cachedConfig = config;
134
143
  }
135
144
  catch (e) {
136
145
  console.error('Failed to save config:', e.message);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Convert an array of flat JSON objects to a CSV string.
3
+ * Automatically handles escaping of double quotes and commas.
4
+ */
5
+ export function jsonToCsv(data) {
6
+ if (!data || data.length === 0) {
7
+ return "";
8
+ }
9
+ // Get headers from the first object
10
+ const headers = Object.keys(data[0]);
11
+ const csvRows = [headers.join(',')];
12
+ for (const row of data) {
13
+ const values = headers.map(header => {
14
+ const val = row[header];
15
+ const stringVal = val === undefined || val === null ? '' : String(val);
16
+ // Escape double quotes by doubling them
17
+ const escaped = stringVal.replace(/"/g, '""');
18
+ // Wrap in quotes if it contains comma, quote or newline
19
+ if (escaped.includes(',') || escaped.includes('"') || escaped.includes('\n')) {
20
+ return `"${escaped}"`;
21
+ }
22
+ return escaped;
23
+ });
24
+ csvRows.push(values.join(','));
25
+ }
26
+ return csvRows.join('\n');
27
+ }
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ import { fileURLToPath } from 'url';
44
44
  import { dirname } from 'path';
45
45
  import { getStartedHandler, getStartedToolName, getStartedToolDescription, getStartedToolSchema } from "./common/tools/get-started.js";
46
46
  import { registerPrompts } from "./prompts/index.js";
47
+ import { jsonToCsv } from "./common/utils/csv.js";
47
48
  const __filename = fileURLToPath(import.meta.url);
48
49
  const __dirname = dirname(__filename);
49
50
  // Load version from package.json
@@ -229,10 +230,29 @@ server.tool("analytics_query", "Query search analytics data with optional pagina
229
230
  dimension: z.string(),
230
231
  operator: z.string(),
231
232
  expression: z.string()
232
- })).optional().describe("Filters (dimension: query/page/country/device, operator: equals/contains/notContains/includingRegex/excludingRegex)")
233
+ })).optional().describe("Filters (dimension: query/page/country/device, operator: equals/contains/notContains/includingRegex/excludingRegex)"),
234
+ format: z.enum(["json", "csv"]).optional().describe("Output format (default: json)")
233
235
  }, async (args) => {
234
236
  try {
235
237
  const result = await analytics.queryAnalytics(args);
238
+ if (args.format === 'csv') {
239
+ const flatData = result.map(row => {
240
+ const newRow = { ...row };
241
+ if (row.keys && Array.isArray(row.keys)) {
242
+ row.keys.forEach((keyVal, idx) => {
243
+ const dimName = args.dimensions && args.dimensions[idx]
244
+ ? args.dimensions[idx]
245
+ : `dimension_${idx + 1}`;
246
+ newRow[dimName] = keyVal;
247
+ });
248
+ delete newRow.keys;
249
+ }
250
+ return newRow;
251
+ });
252
+ return {
253
+ content: [{ type: "text", text: jsonToCsv(flatData) }]
254
+ };
255
+ }
236
256
  return {
237
257
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
238
258
  };
@@ -514,10 +534,13 @@ server.tool("pagespeed_core_web_vitals", "Get Core Web Vitals for both mobile an
514
534
  // SEO Insights Tools
515
535
  server.tool("seo_recommendations", "Generate SEO recommendations based on site performance data", {
516
536
  siteUrl: z.string().describe("The site URL (e.g., https://example.com)"),
517
- days: z.number().optional().describe("Number of days to analyze (default: 28)")
518
- }, async ({ siteUrl, days }) => {
537
+ days: z.number().optional().describe("Number of days to analyze (default: 28)"),
538
+ engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
539
+ }, async ({ siteUrl, days, engine = "google" }) => {
519
540
  try {
520
- const result = await seoInsights.generateRecommendations(siteUrl, { days });
541
+ const result = engine === "google"
542
+ ? await seoInsights.generateRecommendations(siteUrl, { days })
543
+ : await bingSeoInsights.generateRecommendations(siteUrl, { days });
521
544
  return {
522
545
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
523
546
  };
@@ -604,10 +627,13 @@ server.tool("seo_striking_distance", "Find keywords ranking in positions 8-15. T
604
627
  server.tool("seo_lost_queries", "Identify queries that lost all traffic (or dropped >80%) compared to the previous period.", {
605
628
  siteUrl: z.string().describe("The site URL"),
606
629
  days: z.number().optional().describe("Number of days to compare (default: 28)"),
607
- limit: z.number().optional().describe("Max results to return (default: 50)")
608
- }, async ({ siteUrl, days, limit }) => {
630
+ limit: z.number().optional().describe("Max results to return (default: 50)"),
631
+ engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
632
+ }, async ({ siteUrl, days, limit, engine = "google" }) => {
609
633
  try {
610
- const result = await seoInsights.findLostQueries(siteUrl, { days, limit });
634
+ const result = engine === "google"
635
+ ? await seoInsights.findLostQueries(siteUrl, { days, limit })
636
+ : await bingSeoInsights.findLostQueries(siteUrl, { days, limit });
611
637
  return {
612
638
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
613
639
  };
@@ -619,10 +645,13 @@ server.tool("seo_lost_queries", "Identify queries that lost all traffic (or drop
619
645
  server.tool("seo_brand_vs_nonbrand", "Analyze performance split between Brand and Non-Brand queries using a regex.", {
620
646
  siteUrl: z.string().describe("The site URL"),
621
647
  brandRegex: z.string().describe("Regex to match brand keywords (e.g. 'acme|acme corp')"),
622
- days: z.number().optional().describe("Number of days to analyze (default: 28)")
623
- }, async ({ siteUrl, brandRegex, days }) => {
648
+ days: z.number().optional().describe("Number of days to analyze (default: 28)"),
649
+ engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
650
+ }, async ({ siteUrl, brandRegex, days, engine = "google" }) => {
624
651
  try {
625
- const result = await seoInsights.analyzeBrandVsNonBrand(siteUrl, brandRegex, { days });
652
+ const result = engine === "google"
653
+ ? await seoInsights.analyzeBrandVsNonBrand(siteUrl, brandRegex, { days })
654
+ : await bingSeoInsights.analyzeBrandVsNonBrand(siteUrl, brandRegex, { days });
626
655
  return {
627
656
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
628
657
  };
@@ -850,10 +879,22 @@ server.tool("bing_sitemaps_delete", "Remove a sitemap from Bing Webmaster Tools"
850
879
  }
851
880
  });
852
881
  server.tool("bing_analytics_query", "Get query performance stats from Bing Webmaster Tools (Top Queries)", {
853
- siteUrl: z.string().describe("The URL of the site")
854
- }, async ({ siteUrl }) => {
882
+ siteUrl: z.string().describe("The URL of the site"),
883
+ startDate: z.string().optional().describe("Start date (YYYY-MM-DD)"),
884
+ endDate: z.string().optional().describe("End date (YYYY-MM-DD)"),
885
+ limit: z.number().optional().describe("Max rows to return (default: 1000)"),
886
+ format: z.enum(["json", "csv"]).optional().describe("Output format (default: json)")
887
+ }, async ({ siteUrl, startDate, endDate, limit, format }) => {
855
888
  try {
856
- const results = await bingAnalytics.getQueryStats(siteUrl);
889
+ let results = await bingAnalytics.getQueryStats(siteUrl, startDate, endDate);
890
+ if (limit) {
891
+ results = results.slice(0, limit);
892
+ }
893
+ if (format === 'csv') {
894
+ return {
895
+ content: [{ type: "text", text: jsonToCsv(results) }]
896
+ };
897
+ }
857
898
  return {
858
899
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
859
900
  };
@@ -1226,10 +1267,16 @@ server.tool("analytics_page_performance", "Get detailed page performance metrics
1226
1267
  startDate: z.string().describe("Start date (YYYY-MM-DD)"),
1227
1268
  endDate: z.string().describe("End date (YYYY-MM-DD)"),
1228
1269
  pagePath: z.string().optional().describe("Filter by specific page path"),
1229
- limit: z.number().optional().describe("Max rows (default 50)")
1230
- }, async ({ propertyId, accountId, startDate, endDate, pagePath, limit }) => {
1270
+ limit: z.number().optional().describe("Max rows (default 50)"),
1271
+ format: z.enum(["json", "csv"]).optional().describe("Output format (default: json)")
1272
+ }, async ({ propertyId, accountId, startDate, endDate, pagePath, limit, format }) => {
1231
1273
  try {
1232
1274
  const result = await ga4Analytics.getPagePerformance(propertyId, startDate, endDate, pagePath, limit, accountId);
1275
+ if (format === 'csv') {
1276
+ return {
1277
+ content: [{ type: "text", text: jsonToCsv(result) }]
1278
+ };
1279
+ }
1233
1280
  return {
1234
1281
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1235
1282
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "search-console-mcp",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
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",