@digestseo/mcp-gsc 0.4.7 → 0.4.9

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
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.4.9] - 2026-09-21
11
+
12
+ ### Changed
13
+ - `sites.list` now marks whether each returned property identifier matches the URL-prefix / `sc-domain:` forms currently documented by the Search Console API, and the docs explicitly avoid guessing an undocumented API identifier for Search Console platform properties.
14
+
15
+ ### Fixed
16
+ - `analytics.query` now exposes `position_supported: false` plus an explicit note for Google Discover responses so agents do not interpret the generic row `position` field as a supported Discover metric.
17
+
18
+ ## [0.4.8] - 2026-09-21
19
+
20
+ ### Fixed
21
+ - Safe Google read operations now make a tightly bounded retry on transient `408`, `429`, and `5xx` provider failures while leaving Search Console writes and OAuth/token mutation paths single-attempt; long `Retry-After` windows are surfaced immediately instead of stalling an MCP call.
22
+ - The published `npx` launcher now forwards `GSC_ACCESS_MODE`, and MCP Registry package metadata exposes the same setting, so local self-hosters can actually select least-privilege `readonly` mode instead of silently inheriting the template's `readwrite` binding.
23
+
8
24
  ## [0.4.7] - 2026-09-21
9
25
 
10
26
  ### Fixed
package/README.md CHANGED
@@ -24,6 +24,8 @@ npx -y @digestseo/mcp-gsc
24
24
 
25
25
  The npm launcher starts a loopback-only **Streamable HTTP** Worker on `127.0.0.1` (port `8080` by default); it is not a stdio MCP process. Clients that launch the package should connect to `http://127.0.0.1:8080/mcp` after supplying the Google OAuth and token-encryption environment variables described in [SETUP.md](SETUP.md). Before the first OAuth sign-in, authorize the exact callback `http://127.0.0.1:8080/google/callback` in Google Cloud (or use the same custom `PORT` you launch with); the launcher prints both URLs at startup.
26
26
 
27
+ The local launcher defaults to `GSC_ACCESS_MODE=readwrite`. For least-privilege analytics/reporting-only use, set `GSC_ACCESS_MODE=readonly` in the environment before running `npx`; the launcher forwards that value into the local Worker so OAuth requests only the read-only Search Console scope and the five mutation tools stay unregistered.
28
+
27
29
  For Google OAuth and Cloudflare deployment configuration, follow [SETUP.md](SETUP.md).
28
30
 
29
31
  ### Connect in 30 seconds
@@ -73,7 +75,7 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
73
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. |
74
76
  | **`sites.list`** | Read | List the Search Console properties the connected Google account can access (`siteUrl`, `permissionLevel`). |
75
77
  | **`sites.get`** | Read | Retrieve one exact Search Console property and the connected account's permission level for it. |
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`. 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. |
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. |
77
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
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. |
79
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. |
@@ -85,6 +87,8 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
85
87
  | **`sitemaps.submit`** / **`sitemaps.delete`** | Write | Submit or remove a sitemap. |
86
88
  | **`indexing.request`** | Write | Requests indexing through Google's Indexing API. Google currently restricts this API to pages containing `JobPosting` structured data or livestream pages containing `BroadcastEvent` inside `VideoObject`. It is not available for general webpage submission. |
87
89
 
90
+ > **Platform properties.** Search Console now supports platform properties for social/video accounts such as Instagram, TikTok, X, and YouTube in its UI. Google's current Search Console API documentation still defines `siteUrl` using URL-prefix and `sc-domain:` website-property forms and does not publish a platform-property API identifier contract. `sites.list` therefore preserves every identifier Google returns and marks whether it matches the documented API grammar; the other tools deliberately reject undocumented forms instead of inventing an identifier. Use the Search Console UI/export for a platform property until Google documents API support.
91
+
88
92
  Read-write mode requests the Google Search Console read-write and Indexing API scopes. Read-only mode requests only `https://www.googleapis.com/auth/webmasters.readonly` (plus `openid` and `email`) and does not register the five write tools. Read-write remains the default so existing deployments retain their current behavior; see [SETUP.md](SETUP.md) to select a mode before connecting users.
89
93
 
90
94
  Large analytics responses are bounded at the MCP boundary instead of being generated and then discarded by clients with structured-content limits. List-style tools expose `start_row` / `next_start_row` where applicable; ranked analytical tools expose `limit`, `start_row`, and `result_page` metadata including `has_more`, `truncated`, and `byte_limit_reached`. Continue paging while `has_more` is true rather than assuming one response is complete.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
@@ -9,6 +9,7 @@ const forwardedVariables = [
9
9
  'GOOGLE_CLIENT_ID',
10
10
  'GOOGLE_CLIENT_SECRET',
11
11
  'TOKEN_ENCRYPTION_KEY',
12
+ 'GSC_ACCESS_MODE',
12
13
  'GLAMA_INSPECTION_MODE',
13
14
  ];
14
15
  const args = [
package/src/google.ts CHANGED
@@ -51,6 +51,63 @@ export class GoogleRefreshTokenRevokedError extends Error {
51
51
  }
52
52
  }
53
53
 
54
+ const GOOGLE_READ_RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
55
+ const GOOGLE_READ_MAX_ATTEMPTS = 3;
56
+ const GOOGLE_READ_BASE_DELAY_MS = 1_000;
57
+ const GOOGLE_READ_MAX_DELAY_MS = 5_000;
58
+
59
+ function googleReadRetryDelayMs(response: Response, retryNumber: number): number | null {
60
+ const retryAfter = response.headers.get('retry-after')?.trim();
61
+ if (retryAfter) {
62
+ const seconds = Number(retryAfter);
63
+ const requestedDelay = Number.isFinite(seconds)
64
+ ? Math.max(0, seconds * 1_000)
65
+ : Math.max(0, Date.parse(retryAfter) - Date.now());
66
+ if (Number.isFinite(requestedDelay)) {
67
+ return requestedDelay <= GOOGLE_READ_MAX_DELAY_MS ? requestedDelay : null;
68
+ }
69
+ }
70
+
71
+ const exponentialDelay = GOOGLE_READ_BASE_DELAY_MS * 2 ** (retryNumber - 1);
72
+ const jitterMs = Math.floor(Math.random() * 250);
73
+ return Math.min(exponentialDelay + jitterMs, GOOGLE_READ_MAX_DELAY_MS);
74
+ }
75
+
76
+ async function fetchGoogleRead(url: string, init?: RequestInit): Promise<Response> {
77
+ let lastNetworkError: unknown;
78
+
79
+ for (let attempt = 1; attempt <= GOOGLE_READ_MAX_ATTEMPTS; attempt += 1) {
80
+ try {
81
+ const response = await fetch(url, init);
82
+ if (
83
+ !GOOGLE_READ_RETRYABLE_STATUSES.has(response.status) ||
84
+ attempt === GOOGLE_READ_MAX_ATTEMPTS
85
+ ) {
86
+ return response;
87
+ }
88
+
89
+ const delayMs = googleReadRetryDelayMs(response, attempt);
90
+ if (delayMs === null) return response;
91
+ try {
92
+ await response.body?.cancel();
93
+ } catch {
94
+ // Best-effort cleanup before retrying a transient provider response.
95
+ }
96
+ if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
97
+ } catch (error) {
98
+ lastNetworkError = error;
99
+ if (attempt === GOOGLE_READ_MAX_ATTEMPTS) throw error;
100
+ const delayMs = Math.min(
101
+ GOOGLE_READ_BASE_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * 250),
102
+ GOOGLE_READ_MAX_DELAY_MS,
103
+ );
104
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
105
+ }
106
+ }
107
+
108
+ throw lastNetworkError;
109
+ }
110
+
54
111
  export async function exchangeCodeForTokens(
55
112
  code: string,
56
113
  clientId: string,
@@ -89,7 +146,7 @@ export interface GoogleUserInfo {
89
146
  export async function fetchGoogleUserInfo(
90
147
  accessToken: string,
91
148
  ): Promise<GoogleUserInfo> {
92
- const resp = await fetch(GOOGLE_USERINFO_URL, {
149
+ const resp = await fetchGoogleRead(GOOGLE_USERINFO_URL, {
93
150
  headers: { authorization: `Bearer ${accessToken}` },
94
151
  });
95
152
  if (!resp.ok) {
@@ -141,7 +198,7 @@ export interface SiteEntry {
141
198
  }
142
199
 
143
200
  export async function listSites(accessToken: string): Promise<SiteEntry[]> {
144
- const resp = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
201
+ const resp = await fetchGoogleRead('https://www.googleapis.com/webmasters/v3/sites', {
145
202
  headers: { authorization: `Bearer ${accessToken}` },
146
203
  });
147
204
  if (resp.status === 401) {
@@ -160,7 +217,7 @@ export async function getSite(
160
217
  siteUrl: string,
161
218
  ): Promise<SiteEntry> {
162
219
  const encoded = encodeURIComponent(siteUrl);
163
- const resp = await fetch(
220
+ const resp = await fetchGoogleRead(
164
221
  `https://www.googleapis.com/webmasters/v3/sites/${encoded}`,
165
222
  {
166
223
  headers: { authorization: `Bearer ${accessToken}` },
@@ -381,7 +438,7 @@ export async function inspectUrl(
381
438
  siteUrl,
382
439
  };
383
440
  if (languageCode) body.languageCode = languageCode;
384
- const resp = await fetch(
441
+ const resp = await fetchGoogleRead(
385
442
  'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',
386
443
  {
387
444
  method: 'POST',
@@ -477,7 +534,7 @@ export async function listSitemaps(
477
534
  const query = sitemapIndex
478
535
  ? `?sitemapIndex=${encodeURIComponent(sitemapIndex)}`
479
536
  : '';
480
- const resp = await fetch(
537
+ const resp = await fetchGoogleRead(
481
538
  `https://www.googleapis.com/webmasters/v3/sites/${encoded}/sitemaps${query}`,
482
539
  {
483
540
  headers: { authorization: `Bearer ${accessToken}` },
@@ -501,7 +558,7 @@ export async function querySearchAnalytics(
501
558
  ): Promise<SearchAnalyticsResponse> {
502
559
  assertSearchAnalyticsQueryCompatible(body);
503
560
  const encoded = encodeURIComponent(siteUrl);
504
- const resp = await fetch(
561
+ const resp = await fetchGoogleRead(
505
562
  `https://www.googleapis.com/webmasters/v3/sites/${encoded}/searchAnalytics/query`,
506
563
  {
507
564
  method: 'POST',
@@ -676,7 +733,7 @@ export async function getSitemap(
676
733
  ): Promise<SitemapEntry> {
677
734
  const encodedSite = encodeURIComponent(siteUrl);
678
735
  const encodedFeed = encodeURIComponent(feedpath);
679
- const resp = await fetch(
736
+ const resp = await fetchGoogleRead(
680
737
  `https://www.googleapis.com/webmasters/v3/sites/${encodedSite}/sitemaps/${encodedFeed}`,
681
738
  {
682
739
  headers: { authorization: `Bearer ${accessToken}` },
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ import { resolveIndexedPagesDateRange } from './indexed-pages-range';
58
58
  import { createQuickWinsInputSchema } from './quick-wins-schema';
59
59
  import { SITEMAP_URL_SCHEMA } from './sitemap-url-schema';
60
60
  import {
61
+ classifySearchConsolePropertyIdentifier,
61
62
  SEARCH_CONSOLE_PROPERTY_DESCRIPTION,
62
63
  SEARCH_CONSOLE_PROPERTY_SCHEMA,
63
64
  } from './search-console-property-schema';
@@ -132,6 +133,16 @@ const SITE_OUTPUT_SCHEMA = {
132
133
  permissionLevel: z.string(),
133
134
  };
134
135
 
136
+ const LISTED_SITE_OUTPUT_SCHEMA = {
137
+ ...SITE_OUTPUT_SCHEMA,
138
+ api_identifier_kind: z.enum(['domain', 'url_prefix', 'undocumented']),
139
+ mcp_site_url_accepted: z.boolean(),
140
+ unsupported_reason: z.string().optional(),
141
+ };
142
+
143
+ const PLATFORM_PROPERTY_API_NOTE =
144
+ "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
+
135
146
  const SITEMAP_OUTPUT_SCHEMA = z
136
147
  .object({
137
148
  path: z.string(),
@@ -168,7 +179,7 @@ const CAPABILITIES_OUTPUT_SCHEMA = {
168
179
  };
169
180
 
170
181
  const SITES_OUTPUT_SCHEMA = {
171
- sites: z.array(z.object(SITE_OUTPUT_SCHEMA)),
182
+ sites: z.array(z.object(LISTED_SITE_OUTPUT_SCHEMA)),
172
183
  };
173
184
 
174
185
  const SITE_DETAIL_OUTPUT_SCHEMA = {
@@ -205,6 +216,10 @@ const SEARCH_ANALYTICS_OUTPUT_SCHEMA = {
205
216
  row_count: z.number().int().nonnegative(),
206
217
  start_row: z.number().int().nonnegative(),
207
218
  rows: z.array(z.object(SEARCH_ROW_OUTPUT_SCHEMA)),
219
+ position_supported: z.boolean().describe(
220
+ 'Whether average position is supported for the selected Search Analytics search type. False for Google Discover.',
221
+ ),
222
+ position_note: z.string().optional(),
208
223
  provider_exhaustiveness_guaranteed: z.literal(false).describe(
209
224
  'False because the Search Analytics API does not guarantee every data row; Google can return only top rows even after local pagination is exhausted.',
210
225
  ),
@@ -516,7 +531,7 @@ const TOOL_CATALOG = [
516
531
  {
517
532
  name: 'sites.list',
518
533
  description:
519
- 'List the Google Search Console properties (sites) the connected Google account can access.',
534
+ 'List the Google Search Console properties (sites) the connected Google account can access, including whether each returned identifier matches the website-property siteUrl forms currently documented by the Search Console API.',
520
535
  },
521
536
  {
522
537
  name: 'sites.get',
@@ -710,7 +725,7 @@ class GscMcpRuntime {
710
725
  {
711
726
  title: 'List Search Console properties',
712
727
  description:
713
- "List the Google Search Console properties (sites) the connected Google account has access to. Returns an array of { siteUrl, permissionLevel }. Call this when the user asks 'what sites do I have?' or 'what properties are connected?', or when the user asks about SEO for a site and hasn't specified which property. Also useful as a discovery step before calling other tools that require a site_url argument.",
728
+ "List the Google Search Console properties (sites) the connected Google account has access to. Each entry preserves Google's siteUrl and permissionLevel and adds api_identifier_kind plus mcp_site_url_accepted. Google now exposes platform properties for social/video accounts in the Search Console UI, but its current API documentation still defines siteUrl using URL-prefix and sc-domain website-property forms; identifiers outside those documented forms are preserved here but marked unsupported rather than guessed. Call this when the user asks 'what sites do I have?' or 'what properties are connected?', or before another tool that requires site_url.",
714
729
  inputSchema: {},
715
730
  outputSchema: SITES_OUTPUT_SCHEMA,
716
731
  annotations: READ_ONLY_ANNOTATIONS,
@@ -718,7 +733,18 @@ class GscMcpRuntime {
718
733
  async () => {
719
734
  const googleId = this.requireGoogleId();
720
735
  const accessToken = await this.getAccessToken(googleId);
721
- const sites = await listSites(accessToken);
736
+ const sites = (await listSites(accessToken)).map((site) => {
737
+ const apiIdentifierKind = classifySearchConsolePropertyIdentifier(site.siteUrl);
738
+ const mcpSiteUrlAccepted = apiIdentifierKind !== 'undocumented';
739
+ return {
740
+ ...site,
741
+ api_identifier_kind: apiIdentifierKind,
742
+ mcp_site_url_accepted: mcpSiteUrlAccepted,
743
+ ...(!mcpSiteUrlAccepted
744
+ ? { unsupported_reason: PLATFORM_PROPERTY_API_NOTE }
745
+ : {}),
746
+ };
747
+ });
722
748
  return toolResponse(JSON.stringify(sites, null, 2), { sites });
723
749
  },
724
750
  );
@@ -1079,6 +1105,13 @@ class GscMcpRuntime {
1079
1105
  row_count: bounded.items.length,
1080
1106
  start_row,
1081
1107
  rows: bounded.items,
1108
+ position_supported: search_type !== 'discover',
1109
+ ...(search_type === 'discover'
1110
+ ? {
1111
+ position_note:
1112
+ "Google Discover does not support average position. Do not interpret rows[].position for search_type='discover'.",
1113
+ }
1114
+ : {}),
1082
1115
  provider_exhaustiveness_guaranteed: false,
1083
1116
  provider_note: SEARCH_ANALYTICS_PROVIDER_NOTE,
1084
1117
  has_more: bounded.resultPage.has_more,
@@ -1,7 +1,12 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  export const SEARCH_CONSOLE_PROPERTY_DESCRIPTION =
4
- "The Search Console property identifier, exactly as returned by sites.list. Domain properties use 'sc-domain:example.com'; URL-prefix properties use an HTTP or HTTPS URL, typically including the trailing slash, e.g. 'https://www.example.com/'. Passing the wrong property form can return a permission error even when the user owns the site - call sites.list first if unsure.";
4
+ "The Search Console property identifier, exactly as returned by sites.list. The current Search Console API documents Domain properties as 'sc-domain:example.com' and URL-prefix properties as an HTTP or HTTPS URL, typically including the trailing slash, e.g. 'https://www.example.com/'. Search Console platform properties for social/video accounts are available in the Search Console UI, but Google has not documented an API siteUrl identifier contract for them; mcp-gsc therefore does not guess one. Call sites.list first if unsure.";
5
+
6
+ export type SearchConsolePropertyIdentifierKind =
7
+ | 'domain'
8
+ | 'url_prefix'
9
+ | 'undocumented';
5
10
 
6
11
  function isDomainPropertyIdentifier(value: string): boolean {
7
12
  if (!value.startsWith('sc-domain:')) return false;
@@ -36,10 +41,18 @@ export function isSearchConsolePropertyIdentifier(value: string): boolean {
36
41
  return isDomainPropertyIdentifier(value) || isUrlPrefixPropertyIdentifier(value);
37
42
  }
38
43
 
44
+ export function classifySearchConsolePropertyIdentifier(
45
+ value: string,
46
+ ): SearchConsolePropertyIdentifierKind {
47
+ if (isDomainPropertyIdentifier(value)) return 'domain';
48
+ if (isUrlPrefixPropertyIdentifier(value)) return 'url_prefix';
49
+ return 'undocumented';
50
+ }
51
+
39
52
  export const SEARCH_CONSOLE_PROPERTY_SCHEMA = z
40
53
  .string()
41
54
  .refine(isSearchConsolePropertyIdentifier, {
42
55
  message:
43
- "Expected a Search Console property identifier such as 'sc-domain:example.com' or an HTTP/HTTPS URL-prefix property such as 'https://www.example.com/'.",
56
+ "Expected a Search Console API property identifier such as 'sc-domain:example.com' or an HTTP/HTTPS URL-prefix property such as 'https://www.example.com/'. Platform-property identifiers are not accepted until Google documents their API siteUrl contract.",
44
57
  })
45
58
  .describe(SEARCH_CONSOLE_PROPERTY_DESCRIPTION);