@digestseo/mcp-gsc 0.4.11 → 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,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.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
+
10
17
  ## [0.4.11] - 2026-09-22
11
18
 
12
19
  ### Fixed
package/README.md CHANGED
@@ -75,10 +75,10 @@ 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"` or `"googleNews"`, `position_supported` is `false`; those reports expose clicks, impressions, and CTR but not average position, and query grouping/filtering is unavailable. |
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
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. |
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
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
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. |
@@ -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.11",
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) {
@@ -467,7 +473,9 @@ export interface UrlInspectionBatchResult {
467
473
  error?: string;
468
474
  }
469
475
 
470
- export async function inspectUrlsSequentially(
476
+ export const URL_INSPECTION_BATCH_CONCURRENCY = 3;
477
+
478
+ export async function inspectUrlsBoundedConcurrently(
471
479
  accessToken: string,
472
480
  siteUrl: string,
473
481
  inspectionUrls: readonly string[],
@@ -475,31 +483,53 @@ export async function inspectUrlsSequentially(
475
483
  ): Promise<UrlInspectionBatchResult[]> {
476
484
  const results: UrlInspectionBatchResult[] = [];
477
485
 
478
- for (const inspectionUrl of inspectionUrls) {
479
- try {
480
- results.push({
481
- inspectionUrl,
482
- inspectionResult: await inspectUrl(
483
- accessToken,
484
- siteUrl,
485
- inspectionUrl,
486
- languageCode,
487
- ),
488
- });
489
- } catch (error) {
490
- if (error instanceof Error && error.message === GSC_ACCESS_REVOKED_MESSAGE) {
491
- throw error;
492
- }
493
- results.push({
494
- inspectionUrl,
495
- error: error instanceof Error ? error.message : 'URL inspection failed.',
496
- });
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;
497
519
  }
520
+ results.push(
521
+ ...chunkResults.filter(
522
+ (result): result is UrlInspectionBatchResult => !(result instanceof Error),
523
+ ),
524
+ );
498
525
  }
499
526
 
500
527
  return results;
501
528
  }
502
529
 
530
+ // Kept for compatibility with callers that imported the original helper directly.
531
+ export const inspectUrlsSequentially = inspectUrlsBoundedConcurrently;
532
+
503
533
  export interface SitemapEntry {
504
534
  path: string;
505
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,
@@ -589,7 +590,7 @@ const TOOL_CATALOG = [
589
590
  {
590
591
  name: 'urls.inspect_many',
591
592
  description:
592
- "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.`,
593
594
  },
594
595
  {
595
596
  name: 'sitemaps.list',
@@ -836,7 +837,7 @@ class GscMcpRuntime {
836
837
  'urls.inspect_many',
837
838
  {
838
839
  title: 'Inspect multiple URLs',
839
- 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.`,
840
841
  inputSchema: {
841
842
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
842
843
  inspection_urls: z
@@ -873,7 +874,7 @@ class GscMcpRuntime {
873
874
  if (rateLimitError) return rateLimitError;
874
875
 
875
876
  const accessToken = await this.getAccessToken(googleId);
876
- const batchResults = await inspectUrlsSequentially(
877
+ const batchResults = await inspectUrlsBoundedConcurrently(
877
878
  accessToken,
878
879
  site_url,
879
880
  inspection_urls,
@@ -985,10 +986,22 @@ class GscMcpRuntime {
985
986
  ' data_state to "hourly_all". The hourly date range can span at',
986
987
  ' most 10 inclusive days, and hourly data',
987
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.',
988
996
  '- Use dimension_filter_groups to filter by country, device, query',
989
997
  ' content, page URL, or search feature. includingRegex and',
990
- ' excludingRegex use RE2 syntax. For brand vs non-brand splits,',
991
- " 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.',
992
1005
  "- data_state defaults to 'all' which matches the GSC dashboard.",
993
1006
  " Pass 'final' only when the user explicitly asks for stable,",
994
1007
  ' non-preliminary data.',
@@ -1013,7 +1026,7 @@ class GscMcpRuntime {
1013
1026
  )
1014
1027
  .default(['query'])
1015
1028
  .describe(
1016
- "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 and Google News do 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.",
1017
1030
  ),
1018
1031
  row_limit: z
1019
1032
  .number()
@@ -1732,7 +1745,7 @@ class GscMcpRuntime {
1732
1745
  )
1733
1746
  .optional()
1734
1747
  .describe(
1735
- "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.",
1736
1749
  ),
1737
1750
  limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
1738
1751
  start_row: RESULT_START_ROW_SCHEMA,