@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 +6 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/scripts/glama-start.mjs +1 -0
- package/src/google.ts +64 -7
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
package/scripts/glama-start.mjs
CHANGED
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}` },
|