@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 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, naming only the missing variables 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.
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
  [![Add to Kiro](https://kiro.dev/images/add-to-kiro.svg)](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.25",
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",
@@ -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
- if (
83
- !GOOGLE_READ_RETRYABLE_STATUSES.has(response.status) ||
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 < requestedRows) {
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 += page.rows.length;
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 hasMore =
618
- bounded.byteLimitReached ||
619
- returnedCount < items.length ||
620
- sourceMayHaveMore;
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: resultPageMetadata({
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 === sourceRowLimit;
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 === sourceRowLimit,
1976
+ response.rows.length > 0,
1977
+ start_row + sourceRowLimit,
1965
1978
  );
1966
1979
 
1967
1980
  const payload = {