@digestseo/mcp-gsc 0.4.7 → 0.4.8

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,12 @@ 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
+ ## [0.4.8] - 2026-09-21
9
+
10
+ ### Fixed
11
+ - 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.
12
+ - 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.
13
+
8
14
  ## [0.4.7] - 2026-09-21
9
15
 
10
16
  ### 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
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.8",
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}` },