@digestseo/mcp-gsc 0.4.26 → 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,15 @@ 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
+
10
19
  ## [0.4.26] - 2026-09-23
11
20
 
12
21
  ### Fixed
package/README.md CHANGED
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.26",
3
+ "version": "0.4.27",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
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
 
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 = {