@digestseo/mcp-gsc 0.4.25 → 0.4.27
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 +15 -0
- package/README.md +10 -2
- package/SETUP.md +2 -0
- package/package.json +3 -2
- package/scripts/glama-start.mjs +13 -0
- package/src/google.ts +30 -6
- package/src/index.ts +26 -13
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.27] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- GitHub Copilot CLI can now install this repository directly as a plugin, bundling the existing `gsc-weekly-review`, `gsc-indexing-triage`, and `gsc-search-opportunities` Agent Skills with the verified local loopback MCP configuration.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Direct Search Analytics-backed pagination in `analytics.query` and `indexing.list_pages` now treats short non-empty provider pages as potentially continuable and advances `next_start_row` by Google's requested page size, stopping only after an explicit empty provider page when no local response truncation remains.
|
|
17
|
+
- Idempotent Google read calls now apply the existing bounded retry policy when Search Console returns documented transient 403 rate-limit reasons, while permission/configuration 403 responses and write calls remain non-retried.
|
|
18
|
+
|
|
19
|
+
## [0.4.26] - 2026-09-23
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- The published `npx` launcher now rejects malformed `TOKEN_ENCRYPTION_KEY` values before starting Wrangler, requiring valid base64 that decodes to exactly the documented 32-byte AES-256 key without logging the secret value.
|
|
23
|
+
- Higher-level Search Analytics pagination now follows Google's documented empty-page termination contract instead of stopping on any short non-empty page, preventing premature truncation when another provider page is still available.
|
|
24
|
+
|
|
10
25
|
## [0.4.25] - 2026-09-23
|
|
11
26
|
|
|
12
27
|
### Fixed
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ The self-hosting launcher is available as [`@digestseo/mcp-gsc`](https://www.npm
|
|
|
22
22
|
npx -y @digestseo/mcp-gsc
|
|
23
23
|
```
|
|
24
24
|
|
|
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. You can run the `npx` command from any directory because the launcher resolves its bundled Worker configuration from the installed package. 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). The launcher exits before starting Wrangler if `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, or `TOKEN_ENCRYPTION_KEY` is missing or blank,
|
|
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. You can run the `npx` command from any directory because the launcher resolves its bundled Worker configuration from the installed package. 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). The launcher exits before starting Wrangler if `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, or `TOKEN_ENCRYPTION_KEY` is missing or blank, and it also rejects a nonblank `TOKEN_ENCRYPTION_KEY` unless it is valid base64 that decodes to exactly 32 bytes. Errors identify only the invalid or missing setting, never its secret value, so a broken OAuth session is not started. It keeps secret values in the child process environment and Wrangler loads them through declared local secrets; their values are not copied into Wrangler `--var` command-line arguments. 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
27
|
Local KV and Durable Object state, including the encrypted Google session, is stored in `~/.mcp-gsc/state` by default instead of inside the versioned npm cache. That keeps the local connection available across package upgrades and ordinary npm cache cleanup. Set `MCP_GSC_STATE_DIR` to use another directory; keep the same `TOKEN_ENCRYPTION_KEY` when reusing existing state because stored refresh tokens are encrypted with that key. The launcher prints the effective state directory at startup. The first release with this stable path may require one Google reconnect because older launcher releases stored local state under the npm package cache.
|
|
28
28
|
|
|
@@ -58,11 +58,19 @@ claude mcp add --transport http gsc http://127.0.0.1:8080/mcp
|
|
|
58
58
|
|
|
59
59
|
When this repository is installed as a Cursor plugin, Cursor also auto-discovers three bundled Agent Skills that compose the MCP tools into evidence-safe workflows: `gsc-weekly-review`, `gsc-indexing-triage`, and `gsc-search-opportunities`. The **Add to Cursor** button above installs the MCP connection only; the bundled skills are part of the repository's Cursor plugin package.
|
|
60
60
|
|
|
61
|
+
**GitHub Copilot CLI**
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
copilot plugin install AKzar1el/mcp-gsc
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The Copilot plugin bundles the same three Agent Skills plus the local loopback MCP configuration. Start the npm launcher before using the plugin's MCP server; the plugin does not start or deploy mcp-gsc for you.
|
|
68
|
+
|
|
61
69
|
**Kiro**
|
|
62
70
|
|
|
63
71
|
[](https://kiro.dev/launch/mcp/add?name=mcp-gsc&config=%7B%22url%22%3A%22http%3A%2F%2F127.0.0.1%3A8080%2Fmcp%22%2C%22disabled%22%3Afalse%2C%22autoApprove%22%3A%5B%5D%7D)
|
|
64
72
|
|
|
65
|
-
Start the npm launcher before using either install button. Complete its Google OAuth flow on first use.
|
|
73
|
+
Start the npm launcher before using either install button or the Copilot plugin. Complete its Google OAuth flow on first use.
|
|
66
74
|
|
|
67
75
|
**ChatGPT** — ChatGPT cannot connect directly to this loopback `127.0.0.1` endpoint. Use OpenAI's **Secure MCP Tunnel** for the local launcher, or deploy your own remote Cloudflare Worker and add that `/mcp` URL as a custom MCP app in Developer mode; see [Connect in your AI client](#connect-in-your-ai-client) below.
|
|
68
76
|
|
package/SETUP.md
CHANGED
|
@@ -111,6 +111,8 @@ openssl rand -base64 32
|
|
|
111
111
|
|
|
112
112
|
Paste that value when prompted. Keep it safe — if you lose it, the refresh tokens already in KV become undecryptable and every user has to reconnect.
|
|
113
113
|
|
|
114
|
+
The published `npx` launcher validates this exact key format before starting Wrangler. A malformed base64 value or a value that does not decode to exactly 32 bytes is rejected without printing the secret value.
|
|
115
|
+
|
|
114
116
|
For **local development**, put the same three values in `.dev.vars` instead — copy `.dev.vars.example` to `.dev.vars` (it's gitignored). The published npm launcher can also read them from the process environment; its bundled Wrangler config declares `GOOGLE_CLIENT_SECRET` and `TOKEN_ENCRYPTION_KEY` as required local secrets so those sensitive values do not need to be serialized into `--var` command-line arguments:
|
|
115
117
|
|
|
116
118
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@digestseo/mcp-gsc",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.27",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "scripts/glama-start.mjs",
|
|
6
6
|
"bin": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"build": "npm run typecheck",
|
|
51
51
|
"start": "node scripts/glama-start.mjs",
|
|
52
52
|
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test:launcher": "node --test tests/launcher.test.mjs",
|
|
53
54
|
"test:oauth-state": "tsx --test tests/pending-auth-state.test.ts",
|
|
54
55
|
"test:comparison": "tsx --test tests/performance-comparison.test.ts",
|
|
55
56
|
"test:annotations": "tsx --test tests/write-tool-annotations.test.ts",
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
"test:indexing-eligibility": "tsx --test tests/indexing-eligibility.test.ts",
|
|
62
63
|
"test:insight-pagination": "tsx --test tests/insight-pagination.test.ts",
|
|
63
64
|
"test:tool-rate-limits": "tsx --test tests/tool-rate-limit.test.ts",
|
|
64
|
-
"test": "npm run typecheck && npm run test:metadata && npm run test:oauth-state && npm run test:comparison && npm run test:annotations && npm run test:worker && npm run test:access-mode && npm run test:access-token-lifecycle && npm run test:analytics-response-metadata && npm run test:indexing-authorization && npm run test:indexing-eligibility && npm run test:insight-pagination && npm run test:tool-rate-limits && npm run test:unit",
|
|
65
|
+
"test": "npm run typecheck && npm run test:metadata && npm run test:launcher && npm run test:oauth-state && npm run test:comparison && npm run test:annotations && npm run test:worker && npm run test:access-mode && npm run test:access-token-lifecycle && npm run test:analytics-response-metadata && npm run test:indexing-authorization && npm run test:indexing-eligibility && npm run test:insight-pagination && npm run test:tool-rate-limits && npm run test:unit",
|
|
65
66
|
"test:metadata": "tsx --test tests/metadata.test.ts",
|
|
66
67
|
"test:unit": "tsx --test tests/unit.test.ts",
|
|
67
68
|
"test:smoke": "node --test tests/smoke.test.mjs",
|
package/scripts/glama-start.mjs
CHANGED
|
@@ -19,6 +19,19 @@ if (missingRequiredEnvironmentVariables.length > 0) {
|
|
|
19
19
|
process.exit(1);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const tokenEncryptionKey = process.env.TOKEN_ENCRYPTION_KEY;
|
|
23
|
+
const tokenEncryptionKeyPattern =
|
|
24
|
+
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
25
|
+
if (
|
|
26
|
+
!tokenEncryptionKeyPattern.test(tokenEncryptionKey)
|
|
27
|
+
|| Buffer.from(tokenEncryptionKey, 'base64').byteLength !== 32
|
|
28
|
+
) {
|
|
29
|
+
console.error(
|
|
30
|
+
'[mcp-gsc] Invalid TOKEN_ENCRYPTION_KEY: expected a valid base64-encoded 32-byte (256-bit) AES key. See SETUP.md for local launcher configuration.',
|
|
31
|
+
);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
const port = process.env.PORT || '8080';
|
|
23
36
|
const localBaseUrl = `http://127.0.0.1:${port}`;
|
|
24
37
|
const mcpUrl = `${localBaseUrl}/mcp`;
|
package/src/google.ts
CHANGED
|
@@ -52,10 +52,34 @@ export class GoogleRefreshTokenRevokedError extends Error {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const GOOGLE_READ_RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
55
|
+
const GOOGLE_READ_RETRYABLE_403_REASONS = new Set([
|
|
56
|
+
'concurrentLimitExceeded',
|
|
57
|
+
'rateLimitExceeded',
|
|
58
|
+
'servingLimitExceeded',
|
|
59
|
+
'userRateLimitExceeded',
|
|
60
|
+
]);
|
|
55
61
|
const GOOGLE_READ_MAX_ATTEMPTS = 3;
|
|
56
62
|
const GOOGLE_READ_BASE_DELAY_MS = 1_000;
|
|
57
63
|
const GOOGLE_READ_MAX_DELAY_MS = 5_000;
|
|
58
64
|
|
|
65
|
+
async function isGoogleReadRetryableResponse(response: Response): Promise<boolean> {
|
|
66
|
+
if (GOOGLE_READ_RETRYABLE_STATUSES.has(response.status)) return true;
|
|
67
|
+
if (response.status !== 403) return false;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const data = (await response.clone().json()) as {
|
|
71
|
+
error?: { errors?: Array<{ reason?: unknown }> };
|
|
72
|
+
};
|
|
73
|
+
return data.error?.errors?.some(
|
|
74
|
+
(entry) =>
|
|
75
|
+
typeof entry.reason === 'string' &&
|
|
76
|
+
GOOGLE_READ_RETRYABLE_403_REASONS.has(entry.reason),
|
|
77
|
+
) ?? false;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
59
83
|
function googleReadRetryDelayMs(response: Response, retryNumber: number): number | null {
|
|
60
84
|
const retryAfter = response.headers.get('retry-after')?.trim();
|
|
61
85
|
if (retryAfter) {
|
|
@@ -79,10 +103,8 @@ async function fetchGoogleRead(url: string, init?: RequestInit): Promise<Respons
|
|
|
79
103
|
for (let attempt = 1; attempt <= GOOGLE_READ_MAX_ATTEMPTS; attempt += 1) {
|
|
80
104
|
try {
|
|
81
105
|
const response = await fetch(url, init);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
attempt === GOOGLE_READ_MAX_ATTEMPTS
|
|
85
|
-
) {
|
|
106
|
+
const retryable = await isGoogleReadRetryableResponse(response);
|
|
107
|
+
if (!retryable || attempt === GOOGLE_READ_MAX_ATTEMPTS) {
|
|
86
108
|
return response;
|
|
87
109
|
}
|
|
88
110
|
|
|
@@ -652,6 +674,8 @@ export async function querySearchAnalytics(
|
|
|
652
674
|
|
|
653
675
|
/**
|
|
654
676
|
* Fetches Search Analytics rows in 25,000-row pages for higher-level tools.
|
|
677
|
+
* Google's documented pagination contract uses an explicit empty response as
|
|
678
|
+
* the terminal page, so a short non-empty page is not treated as exhaustion.
|
|
655
679
|
* A full final page conservatively reports localLimitReached because the API
|
|
656
680
|
* can have more rows even when Search Console's own internal limits apply.
|
|
657
681
|
*/
|
|
@@ -685,14 +709,14 @@ export async function querySearchAnalyticsPaginated(
|
|
|
685
709
|
|
|
686
710
|
const acceptedRows = page.rows.slice(0, requestedRows);
|
|
687
711
|
rows.push(...acceptedRows);
|
|
688
|
-
if (page.rows.length
|
|
712
|
+
if (page.rows.length === 0) {
|
|
689
713
|
return { rows, pagesFetched, localLimitReached: false };
|
|
690
714
|
}
|
|
691
715
|
if (rows.length >= maximumRows || page.rows.length > requestedRows) {
|
|
692
716
|
return { rows, pagesFetched, localLimitReached: true };
|
|
693
717
|
}
|
|
694
718
|
|
|
695
|
-
startRow +=
|
|
719
|
+
startRow += requestedRows;
|
|
696
720
|
}
|
|
697
721
|
|
|
698
722
|
return { rows, pagesFetched, localLimitReached: true };
|
package/src/index.ts
CHANGED
|
@@ -611,22 +611,30 @@ function windowSourceRows<T>(
|
|
|
611
611
|
sourceStartRow: number,
|
|
612
612
|
requestedLimit: number,
|
|
613
613
|
sourceMayHaveMore: boolean,
|
|
614
|
+
sourceNextStartRow?: number,
|
|
614
615
|
) {
|
|
615
616
|
const bounded = takeBoundedItems(items, requestedLimit);
|
|
616
617
|
const returnedCount = bounded.items.length;
|
|
617
|
-
const
|
|
618
|
-
bounded.byteLimitReached ||
|
|
619
|
-
|
|
620
|
-
|
|
618
|
+
const localContinuation =
|
|
619
|
+
bounded.byteLimitReached || returnedCount < items.length;
|
|
620
|
+
const hasMore = localContinuation || sourceMayHaveMore;
|
|
621
|
+
const resultPage = resultPageMetadata({
|
|
622
|
+
startRow: sourceStartRow,
|
|
623
|
+
limit: requestedLimit,
|
|
624
|
+
returnedCount,
|
|
625
|
+
hasMore,
|
|
626
|
+
byteLimitReached: bounded.byteLimitReached,
|
|
627
|
+
});
|
|
628
|
+
if (
|
|
629
|
+
!localContinuation &&
|
|
630
|
+
sourceMayHaveMore &&
|
|
631
|
+
sourceNextStartRow !== undefined
|
|
632
|
+
) {
|
|
633
|
+
resultPage.next_start_row = sourceNextStartRow;
|
|
634
|
+
}
|
|
621
635
|
return {
|
|
622
636
|
items: bounded.items,
|
|
623
|
-
resultPage
|
|
624
|
-
startRow: sourceStartRow,
|
|
625
|
-
limit: requestedLimit,
|
|
626
|
-
returnedCount,
|
|
627
|
-
hasMore,
|
|
628
|
-
byteLimitReached: bounded.byteLimitReached,
|
|
629
|
-
}),
|
|
637
|
+
resultPage,
|
|
630
638
|
};
|
|
631
639
|
}
|
|
632
640
|
|
|
@@ -1268,13 +1276,17 @@ class GscMcpRuntime {
|
|
|
1268
1276
|
? { dimensionFilterGroups: dimension_filter_groups }
|
|
1269
1277
|
: {}),
|
|
1270
1278
|
});
|
|
1279
|
+
// Search Analytics pagination is complete only after Google returns an
|
|
1280
|
+
// explicit empty page. A short non-empty page can still be followed by
|
|
1281
|
+
// later rows, so preserve a continuation for dimensioned queries.
|
|
1271
1282
|
const sourceMayHaveMore =
|
|
1272
|
-
dimensions.length > 0 && response.rows.length
|
|
1283
|
+
dimensions.length > 0 && response.rows.length > 0;
|
|
1273
1284
|
const bounded = windowSourceRows(
|
|
1274
1285
|
response.rows,
|
|
1275
1286
|
start_row,
|
|
1276
1287
|
row_limit,
|
|
1277
1288
|
sourceMayHaveMore,
|
|
1289
|
+
start_row + sourceRowLimit,
|
|
1278
1290
|
);
|
|
1279
1291
|
const payload: Record<string, unknown> = {
|
|
1280
1292
|
row_count: bounded.items.length,
|
|
@@ -1961,7 +1973,8 @@ class GscMcpRuntime {
|
|
|
1961
1973
|
pages,
|
|
1962
1974
|
start_row,
|
|
1963
1975
|
row_limit,
|
|
1964
|
-
response.rows.length
|
|
1976
|
+
response.rows.length > 0,
|
|
1977
|
+
start_row + sourceRowLimit,
|
|
1965
1978
|
);
|
|
1966
1979
|
|
|
1967
1980
|
const payload = {
|