@digestseo/mcp-gsc 0.4.26 → 0.4.28
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 +19 -0
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/google.ts +45 -4
- package/src/index.ts +37 -17
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.28] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- GitHub Copilot CLI users can now register this repository as the `digestseo` plugin marketplace, discover/install `mcp-gsc` through the marketplace workflow, and still use the supported direct-repository install path.
|
|
14
|
+
- OpenAI Codex CLI and IDE users now have a verified one-command Streamable HTTP setup for the local `mcp-gsc` launcher.
|
|
15
|
+
- MCP initialization now supplies concise server-level workflow instructions so compatible agents preserve Search Console pagination, evidence, and Indexing API semantics across tools.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Search Analytics `quotaExceeded` load failures now return provider-specific recovery guidance instead of an opaque raw 403 payload, while remaining outside the short transient retry loop because Google's documented recovery window is measured in minutes rather than seconds.
|
|
19
|
+
|
|
20
|
+
## [0.4.27] - 2026-09-23
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- 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.
|
|
27
|
+
- 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.
|
|
28
|
+
|
|
10
29
|
## [0.4.26] - 2026-09-23
|
|
11
30
|
|
|
12
31
|
### Fixed
|
package/README.md
CHANGED
|
@@ -52,17 +52,34 @@ Self-hosted Cloudflare Worker deployments continue to use `https://<your-worker>
|
|
|
52
52
|
claude mcp add --transport http gsc http://127.0.0.1:8080/mcp
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
**OpenAI Codex CLI / IDE**
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
codex mcp add gsc --url http://127.0.0.1:8080/mcp
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Codex CLI and the Codex IDE extension share MCP configuration, so registering the server once makes the same local connection available to both clients. Start the npm launcher before using the connection; Codex will open the server's OAuth flow when authentication is required.
|
|
62
|
+
|
|
55
63
|
**Cursor**
|
|
56
64
|
|
|
57
65
|
[](https://cursor.com/en/install-mcp?name=gsc&config=eyJ1cmwiOiJodHRwOi8vMTI3LjAuMC4xOjgwODAvbWNwIn0%3D)
|
|
58
66
|
|
|
59
67
|
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
68
|
|
|
69
|
+
**GitHub Copilot CLI**
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
copilot plugin marketplace add AKzar1el/mcp-gsc
|
|
73
|
+
copilot plugin install mcp-gsc@digestseo
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The marketplace path makes the plugin discoverable and updateable through Copilot's marketplace workflow. Direct repository install also remains supported with `copilot plugin install AKzar1el/mcp-gsc`. 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.
|
|
77
|
+
|
|
61
78
|
**Kiro**
|
|
62
79
|
|
|
63
80
|
[](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
81
|
|
|
65
|
-
Start the npm launcher before using either install button. Complete its Google OAuth flow on first use.
|
|
82
|
+
Start the npm launcher before using either install button or the Copilot plugin. Complete its Google OAuth flow on first use.
|
|
66
83
|
|
|
67
84
|
**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
85
|
|
package/package.json
CHANGED
package/src/google.ts
CHANGED
|
@@ -52,10 +52,47 @@ 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
|
+
function googleErrorReasonsFromText(text: string): string[] {
|
|
66
|
+
try {
|
|
67
|
+
const data = JSON.parse(text) as {
|
|
68
|
+
error?: { errors?: Array<{ reason?: unknown }> };
|
|
69
|
+
};
|
|
70
|
+
return (data.error?.errors ?? [])
|
|
71
|
+
.map((entry) => entry.reason)
|
|
72
|
+
.filter((reason): reason is string => typeof reason === 'string');
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function isGoogleReadRetryableResponse(response: Response): Promise<boolean> {
|
|
79
|
+
if (GOOGLE_READ_RETRYABLE_STATUSES.has(response.status)) return true;
|
|
80
|
+
if (response.status !== 403) return false;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const data = (await response.clone().json()) as {
|
|
84
|
+
error?: { errors?: Array<{ reason?: unknown }> };
|
|
85
|
+
};
|
|
86
|
+
return data.error?.errors?.some(
|
|
87
|
+
(entry) =>
|
|
88
|
+
typeof entry.reason === 'string' &&
|
|
89
|
+
GOOGLE_READ_RETRYABLE_403_REASONS.has(entry.reason),
|
|
90
|
+
) ?? false;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
59
96
|
function googleReadRetryDelayMs(response: Response, retryNumber: number): number | null {
|
|
60
97
|
const retryAfter = response.headers.get('retry-after')?.trim();
|
|
61
98
|
if (retryAfter) {
|
|
@@ -79,10 +116,8 @@ async function fetchGoogleRead(url: string, init?: RequestInit): Promise<Respons
|
|
|
79
116
|
for (let attempt = 1; attempt <= GOOGLE_READ_MAX_ATTEMPTS; attempt += 1) {
|
|
80
117
|
try {
|
|
81
118
|
const response = await fetch(url, init);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
attempt === GOOGLE_READ_MAX_ATTEMPTS
|
|
85
|
-
) {
|
|
119
|
+
const retryable = await isGoogleReadRetryableResponse(response);
|
|
120
|
+
if (!retryable || attempt === GOOGLE_READ_MAX_ATTEMPTS) {
|
|
86
121
|
return response;
|
|
87
122
|
}
|
|
88
123
|
|
|
@@ -623,6 +658,12 @@ export async function querySearchAnalytics(
|
|
|
623
658
|
}
|
|
624
659
|
if (!resp.ok) {
|
|
625
660
|
const text = await resp.text();
|
|
661
|
+
const reasons = googleErrorReasonsFromText(text);
|
|
662
|
+
if (resp.status === 403 && reasons.includes('quotaExceeded')) {
|
|
663
|
+
throw new Error(
|
|
664
|
+
'Search Analytics load quota exceeded. Google recommends waiting about 15 minutes before retrying a short-term load-quota failure. If the error persists, reduce expensive page/query grouping or filtering, shorten the date range, and avoid repeatedly requesting the same data.',
|
|
665
|
+
);
|
|
666
|
+
}
|
|
626
667
|
throw new Error(`Search analytics query failed: ${resp.status} ${text}`);
|
|
627
668
|
}
|
|
628
669
|
const data = (await resp.json()) as {
|
package/src/index.ts
CHANGED
|
@@ -111,6 +111,8 @@ export interface AgentProps extends Record<string, unknown> {
|
|
|
111
111
|
|
|
112
112
|
const SERVER_NAME = 'mcp-gsc';
|
|
113
113
|
const SERVER_VERSION = pkg.version;
|
|
114
|
+
const SERVER_INSTRUCTIONS =
|
|
115
|
+
'If the exact Search Console site_url is unknown, call sites.list before property-scoped tools. Search Analytics can omit rows: page while has_more is true, and never treat a row missing from one bounded period as zero traffic. indexing.list_pages reports Search performance visibility, not index coverage; use urls.inspect or urls.inspect_many for indexed-state evidence. The Indexing API is restricted to eligible JobPosting/livestream pages; a receipt is not proof of indexing or removal.';
|
|
114
116
|
|
|
115
117
|
const NOT_AUTHENTICATED_MESSAGE =
|
|
116
118
|
`Not authenticated. ${MCP_RECONNECT_INSTRUCTION}`;
|
|
@@ -611,22 +613,30 @@ function windowSourceRows<T>(
|
|
|
611
613
|
sourceStartRow: number,
|
|
612
614
|
requestedLimit: number,
|
|
613
615
|
sourceMayHaveMore: boolean,
|
|
616
|
+
sourceNextStartRow?: number,
|
|
614
617
|
) {
|
|
615
618
|
const bounded = takeBoundedItems(items, requestedLimit);
|
|
616
619
|
const returnedCount = bounded.items.length;
|
|
617
|
-
const
|
|
618
|
-
bounded.byteLimitReached ||
|
|
619
|
-
|
|
620
|
-
|
|
620
|
+
const localContinuation =
|
|
621
|
+
bounded.byteLimitReached || returnedCount < items.length;
|
|
622
|
+
const hasMore = localContinuation || sourceMayHaveMore;
|
|
623
|
+
const resultPage = resultPageMetadata({
|
|
624
|
+
startRow: sourceStartRow,
|
|
625
|
+
limit: requestedLimit,
|
|
626
|
+
returnedCount,
|
|
627
|
+
hasMore,
|
|
628
|
+
byteLimitReached: bounded.byteLimitReached,
|
|
629
|
+
});
|
|
630
|
+
if (
|
|
631
|
+
!localContinuation &&
|
|
632
|
+
sourceMayHaveMore &&
|
|
633
|
+
sourceNextStartRow !== undefined
|
|
634
|
+
) {
|
|
635
|
+
resultPage.next_start_row = sourceNextStartRow;
|
|
636
|
+
}
|
|
621
637
|
return {
|
|
622
638
|
items: bounded.items,
|
|
623
|
-
resultPage
|
|
624
|
-
startRow: sourceStartRow,
|
|
625
|
-
limit: requestedLimit,
|
|
626
|
-
returnedCount,
|
|
627
|
-
hasMore,
|
|
628
|
-
byteLimitReached: bounded.byteLimitReached,
|
|
629
|
-
}),
|
|
639
|
+
resultPage,
|
|
630
640
|
};
|
|
631
641
|
}
|
|
632
642
|
|
|
@@ -757,10 +767,15 @@ const TOOL_CATALOG = [
|
|
|
757
767
|
] as const;
|
|
758
768
|
|
|
759
769
|
class GscMcpRuntime {
|
|
760
|
-
server = new McpServer(
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
770
|
+
server = new McpServer(
|
|
771
|
+
{
|
|
772
|
+
name: SERVER_NAME,
|
|
773
|
+
version: SERVER_VERSION,
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
777
|
+
},
|
|
778
|
+
);
|
|
764
779
|
|
|
765
780
|
constructor(
|
|
766
781
|
private readonly env: Env,
|
|
@@ -1268,13 +1283,17 @@ class GscMcpRuntime {
|
|
|
1268
1283
|
? { dimensionFilterGroups: dimension_filter_groups }
|
|
1269
1284
|
: {}),
|
|
1270
1285
|
});
|
|
1286
|
+
// Search Analytics pagination is complete only after Google returns an
|
|
1287
|
+
// explicit empty page. A short non-empty page can still be followed by
|
|
1288
|
+
// later rows, so preserve a continuation for dimensioned queries.
|
|
1271
1289
|
const sourceMayHaveMore =
|
|
1272
|
-
dimensions.length > 0 && response.rows.length
|
|
1290
|
+
dimensions.length > 0 && response.rows.length > 0;
|
|
1273
1291
|
const bounded = windowSourceRows(
|
|
1274
1292
|
response.rows,
|
|
1275
1293
|
start_row,
|
|
1276
1294
|
row_limit,
|
|
1277
1295
|
sourceMayHaveMore,
|
|
1296
|
+
start_row + sourceRowLimit,
|
|
1278
1297
|
);
|
|
1279
1298
|
const payload: Record<string, unknown> = {
|
|
1280
1299
|
row_count: bounded.items.length,
|
|
@@ -1961,7 +1980,8 @@ class GscMcpRuntime {
|
|
|
1961
1980
|
pages,
|
|
1962
1981
|
start_row,
|
|
1963
1982
|
row_limit,
|
|
1964
|
-
response.rows.length
|
|
1983
|
+
response.rows.length > 0,
|
|
1984
|
+
start_row + sourceRowLimit,
|
|
1965
1985
|
);
|
|
1966
1986
|
|
|
1967
1987
|
const payload = {
|