@digestseo/mcp-gsc 0.4.1 → 0.4.3

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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.3] - 2026-09-21
11
+
12
+ ### Changed
13
+ - The documented and npm-declared Node.js minimum is now 22+, matching the bundled Wrangler 4 runtime requirement instead of advertising unsupported Node.js 20 installs.
14
+
15
+ ### Fixed
16
+ - URL Inspection guidance now states that Google reports the version currently in its index rather than running a live URL test, and no longer presents the deprecated mobile-usability field as a current primary signal.
17
+
18
+ ## [0.4.2] - 2026-09-21
19
+
20
+ ### Fixed
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+
10
25
  ## [0.4.1] - 2026-09-21
11
26
 
12
27
  ### Changed
package/README.md CHANGED
@@ -75,10 +75,10 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
75
75
  | **`sites.get`** | Read | Retrieve one exact Search Console property and the connected account's permission level for it. |
76
76
  | **`analytics.query`** | Read | Impressions, clicks, CTR, and average position over a date range, with dimensions, filters, safe pagination, and selectable search type. Pass `dimensions: []` for true site totals; Google's aggregate row may omit `keys`. |
77
77
  | **`insights.page_queries`** / **`insights.query_pages`** | Read | Drill from one exact page to its Search Console queries, or from one exact query to the pages Google surfaced for it, with `row_limit` / `start_row` pagination. |
78
- | **`urls.inspect`** | Read | Google's URL Inspection report for a single page. |
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. |
78
+ | **`urls.inspect`** | Read | Google's URL Inspection report for the version of one page currently known in Google's index. It is not a live URL test; mobile-usability output is deprecated. |
79
+ | **`urls.inspect_many`** | Read | Inspect Google's indexed versions of 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; this does not run live URL tests. |
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. |
@@ -97,8 +97,8 @@ Once connected, ask your assistant things like:
97
97
 
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
- - *"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."*
100
+ - *"What does Google's indexed version of `https://example.com/pricing` show for index status and last crawl?"*
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/SETUP.md CHANGED
@@ -10,7 +10,7 @@ By the end you'll have a Worker at `https://<your-worker>.workers.dev/mcp` that
10
10
 
11
11
  - A [Cloudflare account](https://dash.cloudflare.com/sign-up) (the free Workers plan is enough to start).
12
12
  - A [Google account](https://accounts.google.com/) with access to the Search Console properties you want to query.
13
- - Node.js 20+ and npm.
13
+ - Node.js 22+ and npm. The bundled Wrangler 4 runtime requires Node 22 or newer.
14
14
  - The Wrangler CLI — installed for you by `npm install`; invoke it with `npx wrangler …`.
15
15
 
16
16
  Clone and install:
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.3",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
@@ -87,6 +87,6 @@
87
87
  "vitest": "^4.1.11"
88
88
  },
89
89
  "engines": {
90
- "node": ">=20.0.0"
90
+ "node": ">=22.0.0"
91
91
  }
92
92
  }
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
@@ -172,14 +172,19 @@ const SITE_DETAIL_OUTPUT_SCHEMA = {
172
172
  site: z.object(SITE_OUTPUT_SCHEMA),
173
173
  };
174
174
 
175
+ const URL_INSPECTION_SCOPE_NOTE =
176
+ "Google's URL Inspection API reports the version currently known in Google's index; it does not run a live URL test or prove current live-page indexability. The mobileUsabilityResult field is deprecated and may be absent.";
177
+
175
178
  const INSPECTION_OUTPUT_SCHEMA = {
176
179
  inspection_result: z.unknown(),
180
+ note: z.string(),
177
181
  };
178
182
 
179
183
  const INSPECTION_BATCH_OUTPUT_SCHEMA = {
180
184
  requested_count: z.number().int().positive(),
181
185
  succeeded_count: z.number().int().nonnegative(),
182
186
  failed_count: z.number().int().nonnegative(),
187
+ note: z.string(),
183
188
  results: z.array(
184
189
  z.object({
185
190
  inspection_url: z.string(),
@@ -258,6 +263,7 @@ const RESULT_START_ROW_SCHEMA = z
258
263
  .describe('Zero-based offset into the deterministically ranked result list.');
259
264
 
260
265
  const QUICK_WIN_OUTPUT_SCHEMA = {
266
+ note: z.string(),
261
267
  quick_wins: z.array(
262
268
  z.object({
263
269
  query: z.string(),
@@ -297,15 +303,24 @@ const CANNIBALIZATION_OUTPUT_SCHEMA = {
297
303
  candidates: z.array(
298
304
  z.object({
299
305
  query: z.string(),
300
- total_clicks: z.number(),
301
- total_impressions: z.number(),
306
+ total_clicks: z.number().describe(
307
+ 'Sum of clicks across the observed query/page Search Analytics rows for this query. This is not a separately queried query-level aggregate.',
308
+ ),
309
+ total_impressions: z.number().describe(
310
+ '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.',
311
+ ),
312
+ aggregation_scope: z.literal('observed_query_page_rows').describe(
313
+ '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.',
314
+ ),
302
315
  page_count: z.number().int().nonnegative(),
303
316
  pages_truncated: z.boolean(),
304
317
  pages: z.array(
305
318
  z.object({
306
319
  page: z.string(),
307
320
  ...METRIC_OUTPUT_SCHEMA,
308
- impression_share: z.number(),
321
+ impression_share: z.number().describe(
322
+ '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.',
323
+ ),
309
324
  }),
310
325
  ),
311
326
  }),
@@ -513,12 +528,12 @@ const TOOL_CATALOG = [
513
528
  {
514
529
  name: 'urls.inspect',
515
530
  description:
516
- "Inspect a single URL's index status in Google: indexed state, last crawl, mobile usability, and rich-results eligibility. Use urls.inspect_many for 2-10 URLs.",
531
+ "Inspect Google's indexed version of a single URL: index status, last crawl, page-fetch/indexing state, canonicals, and rich-results/AMP analysis where available. This is not a live URL test; mobile usability is a deprecated response field. Use urls.inspect_many for 2-10 URLs.",
517
532
  },
518
533
  {
519
534
  name: 'urls.inspect_many',
520
535
  description:
521
- 'Inspect up to 10 URLs sequentially in one MCP call while charging the same URL Inspection safety budget per URL.',
536
+ "Inspect Google's indexed versions of up to 10 URLs sequentially in one MCP call while charging the same URL Inspection safety budget per URL. This does not run live URL tests.",
522
537
  },
523
538
  {
524
539
  name: 'sitemaps.list',
@@ -543,12 +558,12 @@ const TOOL_CATALOG = [
543
558
  {
544
559
  name: 'insights.quick_wins',
545
560
  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.',
561
+ '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
562
  },
548
563
  {
549
564
  name: 'insights.cannibalization',
550
565
  description:
551
- 'Analyze search analytics to detect instances of keyword cannibalization, where multiple pages on your site compete for the same query.',
566
+ '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
567
  },
553
568
  {
554
569
  name: 'insights.content_decay',
@@ -709,7 +724,7 @@ class GscMcpRuntime {
709
724
  'urls.inspect',
710
725
  {
711
726
  title: 'Inspect URL index status',
712
- description: `Inspect a single URL's index status in Google. Returns: whether the URL is indexed, last crawl date, indexing state, mobile usability, rich-results eligibility, and any AMP results. Use this when the user asks 'is X indexed?', 'why isn't X showing in Google?', or wants a deep look at one specific page. For a bounded group of 2-10 URLs, prefer urls.inspect_many; Google still processes one URL Inspection request per URL and applies the same quota semantics.`,
727
+ description: `Inspect the version of a single URL currently known in Google's index. Returns Google's index-status analysis, last crawl, page-fetch/indexing state, canonicals, and rich-results/AMP analysis where available. This API does not test the live URL or prove current live-page indexability, and its mobile-usability result is deprecated. Use this when the user asks 'is X indexed?' or wants Google's indexed-state evidence for one page. For a bounded group of 2-10 URLs, prefer urls.inspect_many; Google still processes one URL Inspection request per URL and applies the same quota semantics.`,
713
728
  inputSchema: {
714
729
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
715
730
  inspection_url: z
@@ -738,8 +753,9 @@ class GscMcpRuntime {
738
753
  inspection_url,
739
754
  language_code,
740
755
  );
741
- return toolResponse(JSON.stringify(result, null, 2), {
756
+ return toolResponse(JSON.stringify({ note: URL_INSPECTION_SCOPE_NOTE, inspection_result: result }, null, 2), {
742
757
  inspection_result: result,
758
+ note: URL_INSPECTION_SCOPE_NOTE,
743
759
  });
744
760
  },
745
761
  );
@@ -748,7 +764,7 @@ class GscMcpRuntime {
748
764
  'urls.inspect_many',
749
765
  {
750
766
  title: 'Inspect multiple URLs',
751
- description: `Inspect up to 10 URLs from one Search Console property in a single MCP call. Google still processes one URL Inspection request per URL, so every requested URL consumes one quota unit and one unit of this server's shared URL-inspection safety budget. Requests are sent sequentially to avoid unnecessary bursts. Use this for a small group of important, recently changed, or debugging-target URLs; do not use it to crawl an entire site.`,
767
+ description: `Inspect the versions of up to 10 URLs currently known in Google's index from one Search Console property. This API does not run live URL tests. Google still processes one URL Inspection request per URL, so every requested URL consumes one quota unit and one unit of this server's shared URL-inspection safety budget. Requests are sent sequentially to avoid unnecessary bursts. Use this for a small group of important or debugging-target URLs; do not use it to crawl an entire site.`,
752
768
  inputSchema: {
753
769
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
754
770
  inspection_urls: z
@@ -799,6 +815,7 @@ class GscMcpRuntime {
799
815
  requested_count: inspection_urls.length,
800
816
  succeeded_count: succeededCount,
801
817
  failed_count: results.length - succeededCount,
818
+ note: URL_INSPECTION_SCOPE_NOTE,
802
819
  results,
803
820
  };
804
821
  return toolResponse(JSON.stringify(payload, null, 2), payload);
@@ -871,6 +888,10 @@ class GscMcpRuntime {
871
888
  '- CTR in the response is a 0–1 fraction. Multiply by 100 for percent.',
872
889
  '- Use search_type to query image/video/news/discover indexes',
873
890
  ' separately from web.',
891
+ '- Google Discover does not support query grouping/filtering or',
892
+ ' average position. For Discover, use page/country/device/date/',
893
+ ' searchAppearance dimensions and interpret clicks, impressions,',
894
+ ' and CTR; query dimensions/filters are rejected before the API call.',
874
895
  '- News Showcase panel reporting: set aggregation_type to',
875
896
  ' "byNewsShowcasePanel", use search_type "discover" or',
876
897
  ' "googleNews", and include a searchAppearance equals',
@@ -906,7 +927,7 @@ class GscMcpRuntime {
906
927
  )
907
928
  .default(['query'])
908
929
  .describe(
909
- 'Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals.',
930
+ '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
931
  ),
911
932
  row_limit: z
912
933
  .number()
@@ -934,7 +955,7 @@ class GscMcpRuntime {
934
955
  search_type: z
935
956
  .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
936
957
  .default('web')
937
- .describe('Which search index to query. Defaults to web.'),
958
+ .describe('Which search index to query. Defaults to web. Google Discover does not support query grouping/filtering or average position.'),
938
959
  aggregation_type: z
939
960
  .enum(['auto', 'byNewsShowcasePanel', 'byPage', 'byProperty'])
940
961
  .default('auto')
@@ -969,7 +990,7 @@ class GscMcpRuntime {
969
990
  )
970
991
  .optional()
971
992
  .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.",
993
+ "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
994
  ),
974
995
  },
975
996
  outputSchema: SEARCH_ANALYTICS_OUTPUT_SCHEMA,
@@ -1041,16 +1062,16 @@ class GscMcpRuntime {
1041
1062
  'insights.page_queries',
1042
1063
  {
1043
1064
  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.',
1065
+ 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
1066
  inputSchema: {
1046
1067
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1047
1068
  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
1069
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1049
1070
  end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1050
1071
  search_type: z
1051
- .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
1072
+ .enum(['web', 'image', 'video', 'news', 'googleNews'])
1052
1073
  .default('web')
1053
- .describe('Which search index to query. Defaults to web.'),
1074
+ .describe('Which search index to query. Defaults to web. Discover is not supported because this tool groups by query.'),
1054
1075
  row_limit: z
1055
1076
  .number()
1056
1077
  .int()
@@ -1119,16 +1140,16 @@ class GscMcpRuntime {
1119
1140
  'insights.query_pages',
1120
1141
  {
1121
1142
  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.',
1143
+ 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
1144
  inputSchema: {
1124
1145
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1125
1146
  query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
1126
1147
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1127
1148
  end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1128
1149
  search_type: z
1129
- .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
1150
+ .enum(['web', 'image', 'video', 'news', 'googleNews'])
1130
1151
  .default('web')
1131
- .describe('Which search index to query. Defaults to web.'),
1152
+ .describe('Which search index to query. Defaults to web. Discover is not supported because this tool filters by query.'),
1132
1153
  row_limit: z
1133
1154
  .number()
1134
1155
  .int()
@@ -1309,7 +1330,7 @@ class GscMcpRuntime {
1309
1330
  'insights.quick_wins',
1310
1331
  {
1311
1332
  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.',
1333
+ 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
1334
  inputSchema: createQuickWinsInputSchema(SITE_URL_DESCRIPTION),
1314
1335
  outputSchema: z.object(QUICK_WIN_OUTPUT_SCHEMA),
1315
1336
  annotations: READ_ONLY_ANNOTATIONS,
@@ -1329,6 +1350,7 @@ class GscMcpRuntime {
1329
1350
  const quickWins = processQuickWins(source.rows, min_impressions, min_position, max_position);
1330
1351
  const bounded = windowKnownResults(quickWins, start_row, limit);
1331
1352
  const payload = {
1353
+ 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
1354
  quick_wins: bounded.items,
1333
1355
  pagination: paginationMetadata(source),
1334
1356
  result_page: bounded.resultPage,
@@ -1341,7 +1363,7 @@ class GscMcpRuntime {
1341
1363
  'insights.cannibalization',
1342
1364
  {
1343
1365
  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.',
1366
+ 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
1367
  inputSchema: {
1346
1368
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1347
1369
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
@@ -1571,7 +1593,7 @@ class GscMcpRuntime {
1571
1593
  'analytics.compare',
1572
1594
  {
1573
1595
  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.',
1596
+ 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
1597
  inputSchema: {
1576
1598
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1577
1599
  start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
@@ -1580,9 +1602,9 @@ class GscMcpRuntime {
1580
1602
  end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period B (previous, YYYY-MM-DD)'),
1581
1603
  dimension: z.enum(['query', 'page', 'country', 'device']).default('query').describe('The dimension to compare performance for. Defaults to query.'),
1582
1604
  search_type: z
1583
- .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
1605
+ .enum(['web', 'image', 'video', 'news', 'googleNews'])
1584
1606
  .default('web')
1585
- .describe('Which Search Console search index to compare. The same search type is used for both periods.'),
1607
+ .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
1608
  dimension_filter_groups: z
1587
1609
  .array(
1588
1610
  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,