@digestseo/mcp-gsc 0.4.10 → 0.4.12

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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.12] - 2026-09-22
11
+
12
+ ### Fixed
13
+ - `urls.inspect_many` now runs URL Inspection requests with bounded concurrency of three instead of strictly sequentially, reducing batch latency while preserving per-URL safety-budget accounting, input-order results, per-URL failures, and batch-fatal Google-access revocation.
14
+ - Search Analytics now rejects `searchAppearance` combined with another grouping dimension and documents Google's required two-step appearance discovery/filter workflow instead of sending invalid grouped requests to the provider.
15
+ - Brand/non-brand Search Analytics guidance now distinguishes caller-supplied regex segmentation from Search Console's AI-assisted native Branded/Non-branded filter, which the Search Analytics API does not expose.
16
+
17
+ ## [0.4.11] - 2026-09-22
18
+
19
+ ### Fixed
20
+ - Sitemap read tools now explain Google's `lastSubmitted` and `lastDownloaded` timestamp semantics so agents do not mistake Search Console submission/download times for sitemap-file modification, page-crawl, or indexing timestamps.
21
+ - Google News Search Analytics now rejects unsupported query grouping/filtering, marks average position unavailable, and removes Google News from query-centric and average-position comparison helpers while preserving supported page/country/date/device/appearance and News Showcase reporting.
22
+
10
23
  ## [0.4.10] - 2026-09-21
11
24
 
12
25
  ### Fixed
package/README.md CHANGED
@@ -75,13 +75,13 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
75
75
  | **`server.capabilities`** | Read | List every tool this server exposes and report whether your Google connection is currently authenticated (`connected` / `not_connected`). Takes no arguments — a good first call for discovery. |
76
76
  | **`sites.list`** | Read | List the Search Console properties the connected Google account can access (`siteUrl`, `permissionLevel`). |
77
77
  | **`sites.get`** | Read | Retrieve one exact Search Console property and the connected account's permission level for it. |
78
- | **`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`. Search Analytics does not guarantee every data row and can return only top rows, so exhausting local pagination is not proof that the provider dataset is exhaustive. For `search_type: "discover"`, `position_supported` is `false` because Google Discover does not support average position. |
79
- | **`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
+ | **`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`. Search Analytics does not guarantee every data row and can return only top rows, so exhausting local pagination is not proof that the provider dataset is exhaustive. Search appearance discovery is a two-step workflow: group by `searchAppearance` alone to discover the exact values Google returns for the property, then filter to one returned value in a separate query when grouping by page/query/country/device/date/hour. For `search_type: "discover"` or `"googleNews"`, `position_supported` is `false`; those reports expose clicks, impressions, and CTR but not average position, and query grouping/filtering is unavailable. |
79
+ | **`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. These query-centric helpers support web, image, video, and the Search results News tab; Discover and the Google News report are excluded because they do not expose query data. |
80
80
  | **`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. |
81
- | **`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. |
82
- | **`sitemaps.list`** / **`sitemaps.get`** | Read | List submitted sitemaps or retrieve one sitemap's details. |
81
+ | **`urls.inspect_many`** | Read | Inspect Google's indexed versions of up to 10 URLs with bounded concurrency of 3 in one call while preserving input-order results. 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. |
82
+ | **`sitemaps.list`** / **`sitemaps.get`** | Read | List submitted sitemaps or retrieve one sitemap's details. `lastSubmitted` is when the sitemap was submitted to Search Console; `lastDownloaded` is when Google last downloaded the sitemap. Neither value is the sitemap file's modification/deploy time or a page crawl/indexing timestamp. |
83
83
  | **`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 compare only pages returned in both periods and do not turn one-sided row absence into zero traffic. |
84
- | **`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. `analytics.compare` compares only dimension keys returned in both period responses; a key absent from one non-exhaustive Search Analytics response is not assumed to have zero metrics. `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. |
84
+ | **`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. `analytics.compare` compares only dimension keys returned in both period responses; a key absent from one non-exhaustive Search Analytics response is not assumed to have zero metrics. Because its comparison contract includes average position, it excludes Discover and Google News. `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. |
85
85
  | **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. Query movers compare only rows returned in both weekly Search Analytics result sets; a query missing from one bounded response is not treated as zero because Google does not guarantee every data row. |
86
86
  | **`sites.add`** / **`sites.delete`** | Write | Add or remove a Search Console property. |
87
87
  | **`sitemaps.submit`** / **`sitemaps.delete`** | Write | Submit or remove a sitemap. |
@@ -105,9 +105,11 @@ Once connected, ask your assistant things like:
105
105
  - *"Which high-impression query/page rows have an average position between 5 and 15? Show me those optimization opportunities."*
106
106
  - *"Give me a weekly digest for `sc-domain:example.com` ending today."*
107
107
  - *"Do any of my sitemaps have errors or warnings?"*
108
- - *"Split my clicks into brand vs non-brand using a regex on the query."*
108
+ - *"Using this brand regex I provide, approximate my clicks as brand vs non-brand."*
109
109
  - *"How is my site doing in Google Discover vs regular web search?"*
110
110
 
111
+ Query regexes are a manual approximation only. Search Console's native Branded/Non-branded filter uses Google's own AI-assisted classification (including language variants, typos, and associated products/services), and that native classifier is not exposed as a Search Analytics API filter.
112
+
111
113
  ## Connect in your AI client
112
114
 
113
115
  Once you've deployed the server (see **[SETUP.md](SETUP.md)**), connect it by pasting your Worker's `/mcp` URL into your client:
package/SECURITY.md CHANGED
@@ -58,7 +58,7 @@ delay and does not disclose another user's usage.
58
58
  | Category | Tools | Limit |
59
59
  |---|---|---|
60
60
  | Search Analytics | `analytics.query`, `insights.quick_wins`, `insights.cannibalization`, `insights.content_decay`, `indexing.list_pages`, `analytics.compare` | 30 per user / 10 minutes |
61
- | URL inspection | `urls.inspect`, `urls.inspect_many` | 20 inspected URLs per user / 24 hours; batch requests reserve one unit per URL |
61
+ | URL inspection | `urls.inspect`, `urls.inspect_many` | 20 inspected URLs per user / 24 hours; batch requests reserve one unit per URL and run at most 3 inspections concurrently |
62
62
  | Search Console writes | `sites.add`, `sites.delete`, `sitemaps.submit`, `sitemaps.delete` | 10 per user / hour |
63
63
  | Indexing publish | `indexing.request` | 2 per user / 24 hours; 200 per deployment / 24 hours |
64
64
  | Weekly digest | `reports.weekly_digest` | 6 per user / hour |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
package/src/google.ts CHANGED
@@ -309,6 +309,12 @@ export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery)
309
309
  throw new Error('Search Analytics dimensions must not contain duplicates.');
310
310
  }
311
311
 
312
+ if (body.dimensions.includes('searchAppearance') && body.dimensions.length > 1) {
313
+ throw new Error(
314
+ 'Search Analytics searchAppearance must be the only grouping dimension. Discover an appearance value with dimensions ["searchAppearance"], then filter by that value in a separate query when grouping by page, query, country, device, date, or hour.',
315
+ );
316
+ }
317
+
312
318
  const hasHourDimension = body.dimensions.includes('hour');
313
319
  const usesHourlyData = body.dataState === 'hourly_all';
314
320
  if (hasHourDimension && !usesHourlyData) {
@@ -343,9 +349,10 @@ export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery)
343
349
  }
344
350
  }
345
351
 
346
- if (body.type === 'discover' && hasQueryGroupingOrFilter) {
352
+ if ((body.type === 'discover' || body.type === 'googleNews') && hasQueryGroupingOrFilter) {
353
+ const surfaceName = body.type === 'discover' ? 'Google Discover' : 'Google News';
347
354
  throw new Error(
348
- 'Google Discover does not support the query dimension or query filters in Search Analytics.',
355
+ `${surfaceName} does not support the query dimension or query filters in Search Analytics.`,
349
356
  );
350
357
  }
351
358
 
@@ -466,7 +473,9 @@ export interface UrlInspectionBatchResult {
466
473
  error?: string;
467
474
  }
468
475
 
469
- export async function inspectUrlsSequentially(
476
+ export const URL_INSPECTION_BATCH_CONCURRENCY = 3;
477
+
478
+ export async function inspectUrlsBoundedConcurrently(
470
479
  accessToken: string,
471
480
  siteUrl: string,
472
481
  inspectionUrls: readonly string[],
@@ -474,31 +483,53 @@ export async function inspectUrlsSequentially(
474
483
  ): Promise<UrlInspectionBatchResult[]> {
475
484
  const results: UrlInspectionBatchResult[] = [];
476
485
 
477
- for (const inspectionUrl of inspectionUrls) {
478
- try {
479
- results.push({
480
- inspectionUrl,
481
- inspectionResult: await inspectUrl(
482
- accessToken,
483
- siteUrl,
484
- inspectionUrl,
485
- languageCode,
486
- ),
487
- });
488
- } catch (error) {
489
- if (error instanceof Error && error.message === GSC_ACCESS_REVOKED_MESSAGE) {
490
- throw error;
491
- }
492
- results.push({
493
- inspectionUrl,
494
- error: error instanceof Error ? error.message : 'URL inspection failed.',
495
- });
486
+ for (
487
+ let offset = 0;
488
+ offset < inspectionUrls.length;
489
+ offset += URL_INSPECTION_BATCH_CONCURRENCY
490
+ ) {
491
+ const chunk = inspectionUrls.slice(offset, offset + URL_INSPECTION_BATCH_CONCURRENCY);
492
+ const chunkResults = await Promise.all(
493
+ chunk.map(async (inspectionUrl): Promise<UrlInspectionBatchResult | Error> => {
494
+ try {
495
+ return {
496
+ inspectionUrl,
497
+ inspectionResult: await inspectUrl(
498
+ accessToken,
499
+ siteUrl,
500
+ inspectionUrl,
501
+ languageCode,
502
+ ),
503
+ };
504
+ } catch (error) {
505
+ if (error instanceof Error && error.message === GSC_ACCESS_REVOKED_MESSAGE) {
506
+ return error;
507
+ }
508
+ return {
509
+ inspectionUrl,
510
+ error: error instanceof Error ? error.message : 'URL inspection failed.',
511
+ };
512
+ }
513
+ }),
514
+ );
515
+
516
+ const accessRevoked = chunkResults.find((result): result is Error => result instanceof Error);
517
+ if (accessRevoked) {
518
+ throw accessRevoked;
496
519
  }
520
+ results.push(
521
+ ...chunkResults.filter(
522
+ (result): result is UrlInspectionBatchResult => !(result instanceof Error),
523
+ ),
524
+ );
497
525
  }
498
526
 
499
527
  return results;
500
528
  }
501
529
 
530
+ // Kept for compatibility with callers that imported the original helper directly.
531
+ export const inspectUrlsSequentially = inspectUrlsBoundedConcurrently;
532
+
502
533
  export interface SitemapEntry {
503
534
  path: string;
504
535
  lastSubmitted?: string | null;
package/src/index.ts CHANGED
@@ -12,7 +12,8 @@ import {
12
12
  GSC_ACCESS_REVOKED_MESSAGE,
13
13
  MCP_RECONNECT_INSTRUCTION,
14
14
  inspectUrl,
15
- inspectUrlsSequentially,
15
+ inspectUrlsBoundedConcurrently,
16
+ URL_INSPECTION_BATCH_CONCURRENCY,
16
17
  listSitemaps,
17
18
  listSites,
18
19
  getSite,
@@ -143,21 +144,38 @@ const LISTED_SITE_OUTPUT_SCHEMA = {
143
144
  const PLATFORM_PROPERTY_API_NOTE =
144
145
  "Google Search Console now has platform properties for social/video accounts in its UI, but the current Search Console API documentation still defines siteUrl using URL-prefix and sc-domain website-property forms. mcp-gsc preserves any other identifier returned by sites.list but will not pass an undocumented property identifier to API tools until Google publishes that contract.";
145
146
 
147
+ const SITEMAP_PROVIDER_NOTE =
148
+ "Google's lastSubmitted is the time the sitemap was submitted to Search Console; it is not the sitemap file's generation, deployment, or last-modified time. lastDownloaded is the time Google last downloaded the sitemap; it is not a page crawl or indexing timestamp.";
149
+
146
150
  const SITEMAP_OUTPUT_SCHEMA = z
147
151
  .object({
148
152
  path: z.string(),
149
- lastSubmitted: z.string().nullable().optional(),
153
+ lastSubmitted: z
154
+ .string()
155
+ .nullable()
156
+ .optional()
157
+ .describe(
158
+ 'Provider-reported time the sitemap was submitted to Search Console; not the sitemap file generation, deployment, or modification time.',
159
+ ),
150
160
  isPending: z.boolean().nullable().optional(),
151
161
  isSitemapsIndex: z.boolean().nullable().optional(),
152
162
  type: z.string().nullable().optional(),
153
- lastDownloaded: z.string().nullable().optional(),
163
+ lastDownloaded: z
164
+ .string()
165
+ .nullable()
166
+ .optional()
167
+ .describe(
168
+ 'Provider-reported time Google last downloaded the sitemap; not a page crawl or page indexing timestamp.',
169
+ ),
154
170
  warnings: z.string().nullable().optional(),
155
171
  errors: z.string().nullable().optional(),
156
172
  contents: z
157
173
  .array(
158
174
  z.object({
159
175
  type: z.string(),
160
- submitted: z.string(),
176
+ submitted: z
177
+ .string()
178
+ .describe('Provider-reported submitted URL count for this sitemap content type.'),
161
179
  }),
162
180
  )
163
181
  .nullable()
@@ -210,6 +228,7 @@ const INSPECTION_BATCH_OUTPUT_SCHEMA = {
210
228
 
211
229
  const SITEMAPS_OUTPUT_SCHEMA = {
212
230
  sitemaps: z.array(SITEMAP_OUTPUT_SCHEMA),
231
+ provider_note: z.string(),
213
232
  };
214
233
 
215
234
  const SEARCH_ANALYTICS_OUTPUT_SCHEMA = {
@@ -217,7 +236,7 @@ const SEARCH_ANALYTICS_OUTPUT_SCHEMA = {
217
236
  start_row: z.number().int().nonnegative(),
218
237
  rows: z.array(z.object(SEARCH_ROW_OUTPUT_SCHEMA)),
219
238
  position_supported: z.boolean().describe(
220
- 'Whether average position is supported for the selected Search Analytics search type. False for Google Discover.',
239
+ 'Whether average position is supported for the selected Search Analytics search type. False for Google Discover and Google News.',
221
240
  ),
222
241
  position_note: z.string().optional(),
223
242
  provider_exhaustiveness_guaranteed: z.literal(false).describe(
@@ -571,7 +590,7 @@ const TOOL_CATALOG = [
571
590
  {
572
591
  name: 'urls.inspect_many',
573
592
  description:
574
- "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.",
593
+ `Inspect Google's indexed versions of up to 10 URLs with bounded concurrency of ${URL_INSPECTION_BATCH_CONCURRENCY} in one MCP call while charging the same URL Inspection safety budget per URL. This does not run live URL tests.`,
575
594
  },
576
595
  {
577
596
  name: 'sitemaps.list',
@@ -818,7 +837,7 @@ class GscMcpRuntime {
818
837
  'urls.inspect_many',
819
838
  {
820
839
  title: 'Inspect multiple URLs',
821
- 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.`,
840
+ 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 use bounded concurrency of ${URL_INSPECTION_BATCH_CONCURRENCY}; results preserve input order, and a Google-access revocation stops later chunks from starting. Use this for a small group of important or debugging-target URLs; do not use it to crawl an entire site.`,
822
841
  inputSchema: {
823
842
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
824
843
  inspection_urls: z
@@ -855,7 +874,7 @@ class GscMcpRuntime {
855
874
  if (rateLimitError) return rateLimitError;
856
875
 
857
876
  const accessToken = await this.getAccessToken(googleId);
858
- const batchResults = await inspectUrlsSequentially(
877
+ const batchResults = await inspectUrlsBoundedConcurrently(
859
878
  accessToken,
860
879
  site_url,
861
880
  inspection_urls,
@@ -888,7 +907,7 @@ class GscMcpRuntime {
888
907
  {
889
908
  title: 'List submitted sitemaps',
890
909
  description:
891
- 'List sitemaps submitted for a Search Console property. Optionally filter to entries included by one sitemap index. Returns sitemap URLs, last submitted/downloaded dates, submitted URL counts, warning and error counts, and sitemap status. Google\'s deprecated sitemap indexed count is intentionally omitted. Use this when the user asks about sitemap health, submission status, wants to audit which sitemaps are working, or needs the child sitemaps belonging to a specific sitemap index.',
910
+ 'List sitemaps submitted for a Search Console property. Optionally filter to entries included by one sitemap index. Returns sitemap URLs, provider-reported submission/download timestamps, submitted URL counts, warning and error counts, and sitemap status. lastSubmitted is when the sitemap was submitted to Search Console; lastDownloaded is when Google last downloaded the sitemap. Neither timestamp is a sitemap-file modification time or a page crawl/indexing time. Google\'s deprecated sitemap indexed count is intentionally omitted. Use this when the user asks about sitemap health, submission status, wants to audit which sitemaps are working, or needs the child sitemaps belonging to a specific sitemap index.',
892
911
  inputSchema: {
893
912
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
894
913
  sitemap_index: SITEMAP_URL_SCHEMA
@@ -904,7 +923,8 @@ class GscMcpRuntime {
904
923
  const googleId = this.requireGoogleId();
905
924
  const accessToken = await this.getAccessToken(googleId);
906
925
  const sitemaps = await listSitemaps(accessToken, site_url, sitemap_index);
907
- return toolResponse(JSON.stringify(sitemaps, null, 2), { sitemaps });
926
+ const payload = { sitemaps, provider_note: SITEMAP_PROVIDER_NOTE };
927
+ return toolResponse(JSON.stringify(payload, null, 2), payload);
908
928
  },
909
929
  );
910
930
 
@@ -952,10 +972,11 @@ class GscMcpRuntime {
952
972
  '- CTR in the response is a 0–1 fraction. Multiply by 100 for percent.',
953
973
  '- Use search_type to query image/video/news/discover indexes',
954
974
  ' separately from web.',
955
- '- Google Discover does not support query grouping/filtering or',
956
- ' average position. For Discover, use page/country/device/date/',
957
- ' searchAppearance dimensions and interpret clicks, impressions,',
958
- ' and CTR; query dimensions/filters are rejected before the API call.',
975
+ '- Google Discover and Google News do not support query grouping/',
976
+ ' filtering or average position. For those surfaces, use page/',
977
+ ' country/device/date/searchAppearance dimensions and interpret',
978
+ ' clicks, impressions, and CTR; query dimensions/filters are',
979
+ ' rejected before the API call.',
959
980
  '- News Showcase panel reporting: set aggregation_type to',
960
981
  ' "byNewsShowcasePanel", use search_type "discover" or',
961
982
  ' "googleNews", and include a searchAppearance equals',
@@ -965,10 +986,22 @@ class GscMcpRuntime {
965
986
  ' data_state to "hourly_all". The hourly date range can span at',
966
987
  ' most 10 inclusive days, and hourly data',
967
988
  ' is preliminary.',
989
+ '- SEARCH APPEARANCE uses a two-step workflow. To discover the',
990
+ ' property-specific appearance values Google currently exposes,',
991
+ ' call with dimensions: ["searchAppearance"] and no other grouping',
992
+ ' dimension. Then filter to one exact returned searchAppearance',
993
+ ' value in a separate query and group by page/query/country/device/',
994
+ ' date/hour as needed. Do not guess new appearance identifiers;',
995
+ ' discover the values Google returns for the property first.',
968
996
  '- Use dimension_filter_groups to filter by country, device, query',
969
997
  ' content, page URL, or search feature. includingRegex and',
970
- ' excludingRegex use RE2 syntax. For brand vs non-brand splits,',
971
- " pass a single regex filter on the 'query' dimension.",
998
+ ' excludingRegex use RE2 syntax. A query regex can provide a manual',
999
+ ' brand/non-brand approximation when the caller supplies the brand',
1000
+ " pattern. It is not equivalent to Search Console's native Branded/",
1001
+ ' Non-branded filter: Google describes that classification as',
1002
+ ' AI-assisted (including language variants, typos, and associated',
1003
+ ' products/services), and the Search Analytics API does not expose',
1004
+ ' that native classifier as a filter.',
972
1005
  "- data_state defaults to 'all' which matches the GSC dashboard.",
973
1006
  " Pass 'final' only when the user explicitly asks for stable,",
974
1007
  ' non-preliminary data.',
@@ -993,7 +1026,7 @@ class GscMcpRuntime {
993
1026
  )
994
1027
  .default(['query'])
995
1028
  .describe(
996
- "Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals. The 'hour' dimension requires data_state 'hourly_all' and an inclusive date range of at most 10 days. Google Discover does not support the query dimension.",
1029
+ "Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals. searchAppearance must be the only grouping dimension; discover an appearance value with ['searchAppearance'], then filter to that value in a separate query when grouping by another dimension. The 'hour' dimension requires data_state 'hourly_all' and an inclusive date range of at most 10 days. Google Discover and Google News do not support the query dimension.",
997
1030
  ),
998
1031
  row_limit: z
999
1032
  .number()
@@ -1021,7 +1054,7 @@ class GscMcpRuntime {
1021
1054
  search_type: z
1022
1055
  .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
1023
1056
  .default('web')
1024
- .describe('Which search index to query. Defaults to web. Google Discover does not support query grouping/filtering or average position.'),
1057
+ .describe('Which search index to query. Defaults to web. Google Discover and Google News do not support query grouping/filtering or average position.'),
1025
1058
  aggregation_type: z
1026
1059
  .enum(['auto', 'byNewsShowcasePanel', 'byPage', 'byProperty'])
1027
1060
  .default('auto')
@@ -1056,7 +1089,7 @@ class GscMcpRuntime {
1056
1089
  )
1057
1090
  .optional()
1058
1091
  .describe(
1059
- "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.",
1092
+ "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 or googleNews.",
1060
1093
  ),
1061
1094
  },
1062
1095
  outputSchema: SEARCH_ANALYTICS_OUTPUT_SCHEMA,
@@ -1105,11 +1138,12 @@ class GscMcpRuntime {
1105
1138
  row_count: bounded.items.length,
1106
1139
  start_row,
1107
1140
  rows: bounded.items,
1108
- position_supported: search_type !== 'discover',
1109
- ...(search_type === 'discover'
1141
+ position_supported:
1142
+ search_type !== 'discover' && search_type !== 'googleNews',
1143
+ ...(search_type === 'discover' || search_type === 'googleNews'
1110
1144
  ? {
1111
1145
  position_note:
1112
- "Google Discover does not support average position. Do not interpret rows[].position for search_type='discover'.",
1146
+ `${search_type === 'discover' ? 'Google Discover' : 'Google News'} does not support average position. Do not interpret rows[].position for search_type='${search_type}'.`,
1113
1147
  }
1114
1148
  : {}),
1115
1149
  provider_exhaustiveness_guaranteed: false,
@@ -1137,16 +1171,16 @@ class GscMcpRuntime {
1137
1171
  'insights.page_queries',
1138
1172
  {
1139
1173
  title: 'Find queries for a page',
1140
- 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.',
1174
+ 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 and Google News are intentionally unavailable because those reports do not expose query data.',
1141
1175
  inputSchema: {
1142
1176
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1143
1177
  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.'),
1144
1178
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1145
1179
  end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1146
1180
  search_type: z
1147
- .enum(['web', 'image', 'video', 'news', 'googleNews'])
1181
+ .enum(['web', 'image', 'video', 'news'])
1148
1182
  .default('web')
1149
- .describe('Which search index to query. Defaults to web. Discover is not supported because this tool groups by query.'),
1183
+ .describe('Which search index to query. Defaults to web. Discover and Google News are not supported because this tool groups by query.'),
1150
1184
  row_limit: z
1151
1185
  .number()
1152
1186
  .int()
@@ -1215,16 +1249,16 @@ class GscMcpRuntime {
1215
1249
  'insights.query_pages',
1216
1250
  {
1217
1251
  title: 'Find pages for a query',
1218
- 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.',
1252
+ 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 and Google News are intentionally unavailable because those reports do not expose query data.',
1219
1253
  inputSchema: {
1220
1254
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1221
1255
  query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
1222
1256
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1223
1257
  end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1224
1258
  search_type: z
1225
- .enum(['web', 'image', 'video', 'news', 'googleNews'])
1259
+ .enum(['web', 'image', 'video', 'news'])
1226
1260
  .default('web')
1227
- .describe('Which search index to query. Defaults to web. Discover is not supported because this tool filters by query.'),
1261
+ .describe('Which search index to query. Defaults to web. Discover and Google News are not supported because this tool filters by query.'),
1228
1262
  row_limit: z
1229
1263
  .number()
1230
1264
  .int()
@@ -1385,19 +1419,20 @@ class GscMcpRuntime {
1385
1419
  'sitemaps.get',
1386
1420
  {
1387
1421
  title: 'Get sitemap details',
1388
- description: 'Get status and details of a single sitemap submitted to Google Search Console.',
1422
+ description: 'Get status and details of a single sitemap submitted to Google Search Console. lastSubmitted is when the sitemap was submitted to Search Console; lastDownloaded is when Google last downloaded the sitemap. Neither timestamp is a sitemap-file modification time or a page crawl/indexing time.',
1389
1423
  inputSchema: {
1390
1424
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1391
1425
  feedpath: SITEMAP_URL_SCHEMA.describe('The full HTTP/HTTPS URL of the sitemap file, e.g. https://example.com/sitemap.xml'),
1392
1426
  },
1393
- outputSchema: { sitemap: SITEMAP_OUTPUT_SCHEMA },
1427
+ outputSchema: { sitemap: SITEMAP_OUTPUT_SCHEMA, provider_note: z.string() },
1394
1428
  annotations: READ_ONLY_ANNOTATIONS,
1395
1429
  },
1396
1430
  async ({ site_url, feedpath }) => {
1397
1431
  const googleId = this.requireGoogleId();
1398
1432
  const accessToken = await this.getAccessToken(googleId);
1399
1433
  const details = await getSitemap(accessToken, site_url, feedpath);
1400
- return toolResponse(JSON.stringify(details, null, 2), { sitemap: details });
1434
+ const payload = { sitemap: details, provider_note: SITEMAP_PROVIDER_NOTE };
1435
+ return toolResponse(JSON.stringify(payload, null, 2), payload);
1401
1436
  },
1402
1437
  );
1403
1438
 
@@ -1670,7 +1705,7 @@ class GscMcpRuntime {
1670
1705
  'analytics.compare',
1671
1706
  {
1672
1707
  title: 'Compare Performance Between Periods',
1673
- 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). Only dimension keys returned in both Search Analytics period responses are compared; a key missing from one response is not treated as zero because Google does not guarantee every data row. 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 an explicitly returned baseline row has zero and the comparison value differs. Discover is intentionally unavailable because this comparison contract includes average position, which the Discover report does not support.',
1708
+ 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). Only dimension keys returned in both Search Analytics period responses are compared; a key missing from one response is not treated as zero because Google does not guarantee every data row. 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 an explicitly returned baseline row has zero and the comparison value differs. Discover and Google News are intentionally unavailable because this comparison contract includes average position, which those reports do not support.',
1674
1709
  inputSchema: {
1675
1710
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1676
1711
  start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
@@ -1679,9 +1714,9 @@ class GscMcpRuntime {
1679
1714
  end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period B (previous, YYYY-MM-DD)'),
1680
1715
  dimension: z.enum(['query', 'page', 'country', 'device']).default('query').describe('The dimension to compare performance for. Defaults to query.'),
1681
1716
  search_type: z
1682
- .enum(['web', 'image', 'video', 'news', 'googleNews'])
1717
+ .enum(['web', 'image', 'video', 'news'])
1683
1718
  .default('web')
1684
- .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.'),
1719
+ .describe('Which Search Console search index to compare. The same search type is used for both periods. Discover and Google News are excluded because this tool returns average-position comparisons.'),
1685
1720
  dimension_filter_groups: z
1686
1721
  .array(
1687
1722
  z.object({
@@ -1710,7 +1745,7 @@ class GscMcpRuntime {
1710
1745
  )
1711
1746
  .optional()
1712
1747
  .describe(
1713
- "Optional Search Console filters applied identically to both periods. Use query regex filters for brand/non-brand comparisons; countries use ISO 3166-1 alpha-3 codes.",
1748
+ "Optional Search Console filters applied identically to both periods. A caller-supplied query regex can approximate a manual brand/non-brand split, but it is not equivalent to Search Console's AI-assisted native Branded/Non-branded filter, which the Search Analytics API does not expose. Countries use ISO 3166-1 alpha-3 codes.",
1714
1749
  ),
1715
1750
  limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
1716
1751
  start_row: RESULT_START_ROW_SCHEMA,