@digestseo/mcp-gsc 0.4.1 → 0.4.2

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/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.2] - 2026-09-21
11
+
12
+ ### Fixed
13
+ - Cannibalization totals and impression shares now expose their query/page-row aggregation scope explicitly so clients do not mistake summed page impressions for a true query-level Search Console total.
14
+ - Discover Search Analytics calls now reject unsupported query grouping/filtering before reaching Google, and query-centric/comparison tools no longer advertise Discover where their contracts depend on query or average-position data that Discover does not provide.
15
+ - Quick-win guidance now treats Search Console position as an aggregate average-position metric rather than a literal current rank, and metadata no longer implies CTR is an eligibility filter when the implementation does not filter on CTR.
16
+
10
17
  ## [0.4.1] - 2026-09-21
11
18
 
12
19
  ### Changed
package/README.md CHANGED
@@ -78,7 +78,7 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
78
78
  | **`urls.inspect`** | Read | Google's URL Inspection report for a single page. |
79
79
  | **`urls.inspect_many`** | Read | Inspect up to 10 URLs sequentially in one call. Each URL still consumes one Google URL Inspection request and one unit of the server's shared inspection safety budget. |
80
80
  | **`sitemaps.list`** / **`sitemaps.get`** | Read | List submitted sitemaps or retrieve one sitemap's details. |
81
- | **`insights.quick_wins`** / **`insights.cannibalization`** / **`insights.content_decay`** | Read | Surface striking-distance opportunities, query/page overlap, and evidence-ranked click declines. Content-decay results distinguish likely decay from weak evidence and improving visibility with click volatility. |
81
+ | **`insights.quick_wins`** / **`insights.cannibalization`** / **`insights.content_decay`** | Read | Surface average-position opportunity rows, query/page overlap, and evidence-ranked click declines. Quick-win candidates are observed query/page rows selected by aggregate Search Console average position, not proof of a stable current rank, and CTR is context rather than an eligibility filter. Cannibalization candidate totals/shares are scoped to observed query/page rows rather than true query-level property totals; content-decay results distinguish likely decay from weak evidence and improving visibility with click volatility. |
82
82
  | **`indexing.list_pages`** / **`analytics.compare`** | Read | Analyze pages receiving Search Console impressions and compare two periods, optionally using the same search type and query/page/country/device/search-appearance filters for both periods. `indexing.list_pages` is performance data, not index coverage: a missing URL may still be indexed; use `urls.inspect` / `urls.inspect_many` for URL-level index status. |
83
83
  | **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. |
84
84
  | **`sites.add`** / **`sites.delete`** | Write | Add or remove a Search Console property. |
@@ -98,7 +98,7 @@ Once connected, ask your assistant things like:
98
98
  - *"What are my top 20 queries by clicks in the last 28 days?"*
99
99
  - *"Compare impressions for example.com this month vs last month — which pages dropped?"*
100
100
  - *"Is `https://example.com/pricing` indexed? When was it last crawled?"*
101
- - *"Which queries does my blog rank position 5–15 for? Those are my quick wins."*
101
+ - *"Which high-impression query/page rows have an average position between 5 and 15? Show me those optimization opportunities."*
102
102
  - *"Give me a weekly digest for `sc-domain:example.com` ending today."*
103
103
  - *"Do any of my sitemaps have errors or warnings?"*
104
104
  - *"Split my clicks into brand vs non-brand using a regex on the query."*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
package/src/google.ts CHANGED
@@ -247,6 +247,8 @@ export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery)
247
247
  const filters = body.dimensionFilterGroups?.flatMap((group) => group.filters) ?? [];
248
248
  const hasPageGroupingOrFilter =
249
249
  body.dimensions.includes('page') || filters.some((filter) => filter.dimension === 'page');
250
+ const hasQueryGroupingOrFilter =
251
+ body.dimensions.includes('query') || filters.some((filter) => filter.dimension === 'query');
250
252
 
251
253
  for (const filter of filters) {
252
254
  if (filter.expression.length > 4096) {
@@ -267,6 +269,12 @@ export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery)
267
269
  }
268
270
  }
269
271
 
272
+ if (body.type === 'discover' && hasQueryGroupingOrFilter) {
273
+ throw new Error(
274
+ 'Google Discover does not support the query dimension or query filters in Search Analytics.',
275
+ );
276
+ }
277
+
270
278
  if (body.aggregationType === 'byNewsShowcasePanel') {
271
279
  if (body.type !== 'discover' && body.type !== 'googleNews') {
272
280
  throw new Error(
@@ -720,6 +728,7 @@ export interface CannibalizationResult {
720
728
  query: string;
721
729
  total_clicks: number;
722
730
  total_impressions: number;
731
+ aggregation_scope: 'observed_query_page_rows';
723
732
  pages: CannibalizationPage[];
724
733
  }
725
734
 
@@ -763,6 +772,7 @@ export function processCannibalization(
763
772
  query,
764
773
  total_clicks: totalClicks,
765
774
  total_impressions: totalImpressions,
775
+ aggregation_scope: 'observed_query_page_rows',
766
776
  pages: competingPages.sort(
767
777
  (a, b) => b.impressions - a.impressions || a.page.localeCompare(b.page),
768
778
  ),
package/src/index.ts CHANGED
@@ -258,6 +258,7 @@ const RESULT_START_ROW_SCHEMA = z
258
258
  .describe('Zero-based offset into the deterministically ranked result list.');
259
259
 
260
260
  const QUICK_WIN_OUTPUT_SCHEMA = {
261
+ note: z.string(),
261
262
  quick_wins: z.array(
262
263
  z.object({
263
264
  query: z.string(),
@@ -297,15 +298,24 @@ const CANNIBALIZATION_OUTPUT_SCHEMA = {
297
298
  candidates: z.array(
298
299
  z.object({
299
300
  query: z.string(),
300
- total_clicks: z.number(),
301
- total_impressions: z.number(),
301
+ total_clicks: z.number().describe(
302
+ 'Sum of clicks across the observed query/page Search Analytics rows for this query. This is not a separately queried query-level aggregate.',
303
+ ),
304
+ total_impressions: z.number().describe(
305
+ 'Sum of impressions across the observed query/page Search Analytics rows for this query. This can exceed the query-level property aggregate when multiple pages appear for the same search, and it can be incomplete when source pagination is truncated.',
306
+ ),
307
+ aggregation_scope: z.literal('observed_query_page_rows').describe(
308
+ 'Makes explicit that total_clicks, total_impressions, and impression_share are calculated from observed query/page rows rather than true query-level Search Console totals.',
309
+ ),
302
310
  page_count: z.number().int().nonnegative(),
303
311
  pages_truncated: z.boolean(),
304
312
  pages: z.array(
305
313
  z.object({
306
314
  page: z.string(),
307
315
  ...METRIC_OUTPUT_SCHEMA,
308
- impression_share: z.number(),
316
+ impression_share: z.number().describe(
317
+ 'Percentage of the observed query/page impression sum represented by this page; not a share of unique searches or the true query-level property aggregate.',
318
+ ),
309
319
  }),
310
320
  ),
311
321
  }),
@@ -543,12 +553,12 @@ const TOOL_CATALOG = [
543
553
  {
544
554
  name: 'insights.quick_wins',
545
555
  description:
546
- 'Find search queries with at least the requested impressions that rank in a configurable striking-distance position range (8-20 by default). Returns clicks, impressions, CTR, and average position; CTR is context, not an eligibility filter.',
556
+ 'Find observed query/page Search Analytics rows with at least the requested impressions whose average position falls in a configurable opportunity range (8-20 by default). Average position is an aggregate metric, not a literal current rank; CTR is context, not an eligibility filter.',
547
557
  },
548
558
  {
549
559
  name: 'insights.cannibalization',
550
560
  description:
551
- 'Analyze search analytics to detect instances of keyword cannibalization, where multiple pages on your site compete for the same query.',
561
+ 'Analyze query/page Search Analytics for queries surfaced by multiple pages. Candidate totals and impression shares are explicitly scoped to observed query/page rows, not true query-level property totals.',
552
562
  },
553
563
  {
554
564
  name: 'insights.content_decay',
@@ -871,6 +881,10 @@ class GscMcpRuntime {
871
881
  '- CTR in the response is a 0–1 fraction. Multiply by 100 for percent.',
872
882
  '- Use search_type to query image/video/news/discover indexes',
873
883
  ' separately from web.',
884
+ '- Google Discover does not support query grouping/filtering or',
885
+ ' average position. For Discover, use page/country/device/date/',
886
+ ' searchAppearance dimensions and interpret clicks, impressions,',
887
+ ' and CTR; query dimensions/filters are rejected before the API call.',
874
888
  '- News Showcase panel reporting: set aggregation_type to',
875
889
  ' "byNewsShowcasePanel", use search_type "discover" or',
876
890
  ' "googleNews", and include a searchAppearance equals',
@@ -906,7 +920,7 @@ class GscMcpRuntime {
906
920
  )
907
921
  .default(['query'])
908
922
  .describe(
909
- 'Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals.',
923
+ 'Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals. Google Discover does not support the query dimension.',
910
924
  ),
911
925
  row_limit: z
912
926
  .number()
@@ -934,7 +948,7 @@ class GscMcpRuntime {
934
948
  search_type: z
935
949
  .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
936
950
  .default('web')
937
- .describe('Which search index to query. Defaults to web.'),
951
+ .describe('Which search index to query. Defaults to web. Google Discover does not support query grouping/filtering or average position.'),
938
952
  aggregation_type: z
939
953
  .enum(['auto', 'byNewsShowcasePanel', 'byPage', 'byProperty'])
940
954
  .default('auto')
@@ -969,7 +983,7 @@ class GscMcpRuntime {
969
983
  )
970
984
  .optional()
971
985
  .describe(
972
- "Optional filters ANDed together, e.g. [{ groupType: 'and', filters: [{ dimension: 'country', operator: 'equals', expression: 'usa' }] }]. Countries use ISO 3166-1 alpha-3 codes.",
986
+ "Optional filters ANDed together, e.g. [{ groupType: 'and', filters: [{ dimension: 'country', operator: 'equals', expression: 'usa' }] }]. Countries use ISO 3166-1 alpha-3 codes. Query filters are not supported when search_type is discover.",
973
987
  ),
974
988
  },
975
989
  outputSchema: SEARCH_ANALYTICS_OUTPUT_SCHEMA,
@@ -1041,16 +1055,16 @@ class GscMcpRuntime {
1041
1055
  'insights.page_queries',
1042
1056
  {
1043
1057
  title: 'Find queries for a page',
1044
- description: 'For one exact page URL, return the Search Console queries that produced impressions for it over a date range. This wraps an exact page dimension filter so agents do not need to construct analytics.query filter groups manually. Exact page matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results; Search Console can still omit anonymized queries.',
1058
+ description: 'For one exact page URL, return the Search Console queries that produced impressions for it over a date range. This wraps an exact page dimension filter so agents do not need to construct analytics.query filter groups manually. Exact page matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results; Search Console can still omit anonymized queries. Google Discover is intentionally unavailable because Discover does not expose query data.',
1045
1059
  inputSchema: {
1046
1060
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1047
1061
  page_url: z.string().url().describe('Exact fully-qualified page URL to filter on, e.g. https://example.com/guides/seo/. Search Console exact page filters are case-sensitive.'),
1048
1062
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1049
1063
  end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1050
1064
  search_type: z
1051
- .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
1065
+ .enum(['web', 'image', 'video', 'news', 'googleNews'])
1052
1066
  .default('web')
1053
- .describe('Which search index to query. Defaults to web.'),
1067
+ .describe('Which search index to query. Defaults to web. Discover is not supported because this tool groups by query.'),
1054
1068
  row_limit: z
1055
1069
  .number()
1056
1070
  .int()
@@ -1119,16 +1133,16 @@ class GscMcpRuntime {
1119
1133
  'insights.query_pages',
1120
1134
  {
1121
1135
  title: 'Find pages for a query',
1122
- description: 'For one exact search query, return the site pages that received impressions for it over a date range. This wraps an exact query dimension filter so agents do not need to construct analytics.query filter groups manually. Exact query matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results and verify which URL Google is surfacing before diagnosing cannibalization or content targeting.',
1136
+ description: 'For one exact search query, return the site pages that received impressions for it over a date range. This wraps an exact query dimension filter so agents do not need to construct analytics.query filter groups manually. Exact query matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results and verify which URL Google is surfacing before diagnosing cannibalization or content targeting. Google Discover is intentionally unavailable because Discover does not expose query data.',
1123
1137
  inputSchema: {
1124
1138
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1125
1139
  query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
1126
1140
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1127
1141
  end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1128
1142
  search_type: z
1129
- .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
1143
+ .enum(['web', 'image', 'video', 'news', 'googleNews'])
1130
1144
  .default('web')
1131
- .describe('Which search index to query. Defaults to web.'),
1145
+ .describe('Which search index to query. Defaults to web. Discover is not supported because this tool filters by query.'),
1132
1146
  row_limit: z
1133
1147
  .number()
1134
1148
  .int()
@@ -1309,7 +1323,7 @@ class GscMcpRuntime {
1309
1323
  'insights.quick_wins',
1310
1324
  {
1311
1325
  title: 'Identify SEO Quick Wins',
1312
- description: 'Find search queries with at least the requested impressions that rank in a configurable striking-distance position range (8-20 by default). Returns clicks, impressions, CTR, and average position; CTR is context, not an eligibility filter. Results are ranked deterministically by impressions, then bounded with limit/start_row and explicit result_page metadata. Source pagination separately flags the local 100,000-row safety ceiling.',
1326
+ description: 'Find observed query/page Search Analytics rows with at least the requested impressions whose average position falls in a configurable opportunity range (8-20 by default). Average position is an aggregate Search Console metric, not a literal current rank; CTR is returned for context and is not an eligibility filter. Results are ordered deterministically by impressions, then bounded with limit/start_row and explicit result_page metadata. Source pagination separately flags the local 100,000-row safety ceiling.',
1313
1327
  inputSchema: createQuickWinsInputSchema(SITE_URL_DESCRIPTION),
1314
1328
  outputSchema: z.object(QUICK_WIN_OUTPUT_SCHEMA),
1315
1329
  annotations: READ_ONLY_ANNOTATIONS,
@@ -1329,6 +1343,7 @@ class GscMcpRuntime {
1329
1343
  const quickWins = processQuickWins(source.rows, min_impressions, min_position, max_position);
1330
1344
  const bounded = windowKnownResults(quickWins, start_row, limit);
1331
1345
  const payload = {
1346
+ note: 'Candidates are observed query/page rows selected by Search Console average position, not proof of a stable or current rank. CTR is reported for context and does not affect eligibility.',
1332
1347
  quick_wins: bounded.items,
1333
1348
  pagination: paginationMetadata(source),
1334
1349
  result_page: bounded.resultPage,
@@ -1341,7 +1356,7 @@ class GscMcpRuntime {
1341
1356
  'insights.cannibalization',
1342
1357
  {
1343
1358
  title: 'Detect Keyword Cannibalization',
1344
- description: 'Analyze query/page Search Analytics to find queries split across multiple pages. Candidates are ranked deterministically by total impressions, each candidate bounds its page list, and limit/start_row plus result_page provide safe pagination. Multiple ranking URLs can also reflect legitimate locale or intent differences, so treat candidates as evidence to investigate rather than proof of harmful cannibalization.',
1359
+ description: 'Analyze query/page Search Analytics to find queries split across multiple pages. Candidates are ranked deterministically by the observed query/page impression sum, each candidate bounds its page list, and limit/start_row plus result_page provide safe pagination. total_clicks, total_impressions, and impression_share are calculated from observed query/page rows and are not true query-level property aggregates; multiple pages can make that row sum exceed the query-level Search Console total. Multiple ranking URLs can also reflect legitimate locale or intent differences, so treat candidates as evidence to investigate rather than proof of harmful cannibalization.',
1345
1360
  inputSchema: {
1346
1361
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1347
1362
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
@@ -1571,7 +1586,7 @@ class GscMcpRuntime {
1571
1586
  'analytics.compare',
1572
1587
  {
1573
1588
  title: 'Compare Performance Between Periods',
1574
- description: 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension (query, page, country, device). Apply the same search type and optional dimension filters to both periods. Results are ranked by largest absolute click change, then impression change, and safely paged with limit/start_row. Percentage change is null when the baseline is zero and the comparison value differs.',
1589
+ description: 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension (query, page, country, device). Apply the same search type and optional dimension filters to both periods. Results are ranked by largest absolute click change, then impression change, and safely paged with limit/start_row. Percentage change is null when the baseline is zero and the comparison value differs. Discover is intentionally unavailable because this comparison contract includes average position, which the Discover report does not support.',
1575
1590
  inputSchema: {
1576
1591
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1577
1592
  start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
@@ -1580,9 +1595,9 @@ class GscMcpRuntime {
1580
1595
  end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period B (previous, YYYY-MM-DD)'),
1581
1596
  dimension: z.enum(['query', 'page', 'country', 'device']).default('query').describe('The dimension to compare performance for. Defaults to query.'),
1582
1597
  search_type: z
1583
- .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
1598
+ .enum(['web', 'image', 'video', 'news', 'googleNews'])
1584
1599
  .default('web')
1585
- .describe('Which Search Console search index to compare. The same search type is used for both periods.'),
1600
+ .describe('Which Search Console search index to compare. The same search type is used for both periods. Discover is excluded because this tool returns average-position comparisons.'),
1586
1601
  dimension_filter_groups: z
1587
1602
  .array(
1588
1603
  z.object({
@@ -11,9 +11,9 @@ const POSITION_RANGE_ERROR = {
11
11
  };
12
12
 
13
13
  const QUICK_WINS_THRESHOLDS_SHAPE = {
14
- min_impressions: z.number().int().min(0).default(100).describe('Minimum impressions required to consider a query. Must be at least 0. Default is 100.'),
15
- min_position: z.number().positive().default(8).describe('Minimum average position to target (inclusive). Must be greater than 0. Default is 8.'),
16
- max_position: z.number().positive().default(20).describe('Maximum average position to target (inclusive). Must be greater than 0. Default is 20.'),
14
+ min_impressions: z.number().int().min(0).default(100).describe('Minimum impressions required to consider an observed query/page row. Must be at least 0. Default is 100.'),
15
+ min_position: z.number().positive().default(8).describe('Minimum Search Console average position for the opportunity range (inclusive). This is an aggregate metric, not a literal current rank. Must be greater than 0. Default is 8.'),
16
+ max_position: z.number().positive().default(20).describe('Maximum Search Console average position for the opportunity range (inclusive). This is an aggregate metric, not a literal current rank. Must be greater than 0. Default is 20.'),
17
17
  };
18
18
 
19
19
  export function createQuickWinsInputSchema(siteUrlDescription: string) {
@@ -29,13 +29,13 @@ export function createQuickWinsInputSchema(siteUrlDescription: string) {
29
29
  .min(1)
30
30
  .max(MAX_ANALYSIS_RESULT_LIMIT)
31
31
  .default(DEFAULT_ANALYSIS_RESULT_LIMIT)
32
- .describe('Maximum ranked quick-win results to return in this response.'),
32
+ .describe('Maximum ordered quick-win results to return in this response.'),
33
33
  start_row: z
34
34
  .number()
35
35
  .int()
36
36
  .min(0)
37
37
  .default(0)
38
- .describe('Zero-based offset into the ranked quick-win result list.'),
38
+ .describe('Zero-based offset into the ordered quick-win result list.'),
39
39
  })
40
40
  .refine(
41
41
  ({ min_position, max_position }) => min_position <= max_position,