@digestseo/mcp-gsc 0.4.24 → 0.4.26
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 +12 -0
- package/README.md +2 -2
- package/SETUP.md +2 -0
- package/package.json +3 -2
- package/scripts/glama-start.mjs +13 -0
- package/src/digest.ts +14 -8
- package/src/google.ts +24 -8
- package/src/index.ts +13 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.26] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
## [0.4.25] - 2026-09-23
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Access-mode onboarding and Official MCP Registry metadata now identify all six read-write mutation tools after `indexing.remove` became part of the public tool surface.
|
|
20
|
+
- `analytics.query` now accepts provider-valid Discover and Google News rows with no `position` field instead of failing MCP structured-output validation; Search Console does not record average position for those surfaces.
|
|
21
|
+
|
|
10
22
|
## [0.4.24] - 2026-09-23
|
|
11
23
|
|
|
12
24
|
### Added
|
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
|
|
|
@@ -79,7 +79,7 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 23 tools. Read-onl
|
|
|
79
79
|
| **`server.capabilities`** | Read | List every tool this server exposes and report local Google credential state. `connected` means a stored refresh credential is present; the response also exposes `auth_status_basis` and `provider_auth_live_verified: false` because this discovery call does not probe Google. A later Google tool call can still require reconnection if the credential expired or was revoked. Takes no arguments. |
|
|
80
80
|
| **`sites.list`** | Read | List the Search Console properties the connected Google account can access (`siteUrl`, `permissionLevel`). |
|
|
81
81
|
| **`sites.get`** | Read | Retrieve one exact Search Console property and the connected account's permission level for it. |
|
|
82
|
-
| **`analytics.query`** | Read | Impressions, clicks, CTR, and average position over a date range, with dimensions, filters, safe pagination, and selectable search type. Pass `dimensions: []` for true site totals; Google's aggregate row may omit `keys`. Search Analytics does not guarantee every data row and can return only top rows, so exhausting local pagination is not proof that the provider dataset is exhaustive. `data_state: "all"` can include fresh preliminary data, while `final` returns only finalized data; responses echo `data_state` plus `preliminary_data_possible`, and date/hour groupings preserve Google's incomplete-data boundary metadata when returned. Search appearance discovery is a two-step workflow: group by `searchAppearance` alone to discover the exact values Google returns for the property, then filter to one returned value in a separate query when grouping by page/query/country/device/date/hour. For `search_type: "discover"` or `"googleNews"`, `position_supported` is `false`; those reports expose clicks, impressions, and CTR but not average position, and query grouping/filtering is unavailable. Search Console's dedicated Generative AI performance reports are not exposed by the current documented Search Analytics API as a dedicated search type or filter selector; `generative_ai_report_isolatable` is therefore `false`. |
|
|
82
|
+
| **`analytics.query`** | Read | Impressions, clicks, CTR, and average position over a date range, with dimensions, filters, safe pagination, and selectable search type. Pass `dimensions: []` for true site totals; Google's aggregate row may omit `keys`. Search Analytics does not guarantee every data row and can return only top rows, so exhausting local pagination is not proof that the provider dataset is exhaustive. `data_state: "all"` can include fresh preliminary data, while `final` returns only finalized data; responses echo `data_state` plus `preliminary_data_possible`, and date/hour groupings preserve Google's incomplete-data boundary metadata when returned. Search appearance discovery is a two-step workflow: group by `searchAppearance` alone to discover the exact values Google returns for the property, then filter to one returned value in a separate query when grouping by page/query/country/device/date/hour. For `search_type: "discover"` or `"googleNews"`, `position_supported` is `false`; those reports expose clicks, impressions, and CTR but not average position, so returned rows can omit `position`, and query grouping/filtering is unavailable. Search Console's dedicated Generative AI performance reports are not exposed by the current documented Search Analytics API as a dedicated search type or filter selector; `generative_ai_report_isolatable` is therefore `false`. |
|
|
83
83
|
| **`insights.page_queries`** / **`insights.query_pages`** | Read | Drill from one exact page to its Search Console queries, or from one exact query to the pages Google surfaced for it, with `row_limit` / `start_row` pagination. These query-centric helpers support web, image, video, and the Search results News tab; Discover and the Google News report are excluded because they do not expose query data. |
|
|
84
84
|
| **`urls.inspect`** | Read | Google's URL Inspection report for the version of one page currently known in Google's index. It is not a live URL test; mobile-usability output is deprecated. |
|
|
85
85
|
| **`urls.inspect_many`** | Read | Inspect Google's indexed versions of up to 10 URLs with bounded concurrency of 3 in one call while preserving input-order results. Each URL still consumes one Google URL Inspection request and one unit of the server's shared inspection safety budget; this does not run live URL tests. |
|
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.26",
|
|
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/digest.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
hasRecordedPosition,
|
|
3
|
+
querySearchAnalytics,
|
|
4
|
+
type SearchAnalyticsRow,
|
|
5
|
+
} from './google';
|
|
2
6
|
import type { GoogleAccessTokenProvider } from './access-token-lifecycle';
|
|
3
7
|
import { getSearchConsoleCalendarDate } from './date-validation';
|
|
4
8
|
|
|
@@ -180,13 +184,15 @@ function totalsFromRow(row: SearchAnalyticsRow | undefined): SiteTotals {
|
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
function rowsToQueries(rows: SearchAnalyticsRow[]): QueryRow[] {
|
|
183
|
-
return rows
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
return rows
|
|
188
|
+
.filter(hasRecordedPosition)
|
|
189
|
+
.map((r) => ({
|
|
190
|
+
query: r.keys?.[0] ?? '',
|
|
191
|
+
clicks: r.clicks,
|
|
192
|
+
impressions: r.impressions,
|
|
193
|
+
ctr: r.ctr,
|
|
194
|
+
position: r.position,
|
|
195
|
+
}));
|
|
190
196
|
}
|
|
191
197
|
|
|
192
198
|
function rowsToPages(rows: SearchAnalyticsRow[]): PageRow[] {
|
package/src/google.ts
CHANGED
|
@@ -399,7 +399,20 @@ export interface SearchAnalyticsRow {
|
|
|
399
399
|
clicks: number;
|
|
400
400
|
impressions: number;
|
|
401
401
|
ctr: number;
|
|
402
|
-
|
|
402
|
+
/**
|
|
403
|
+
* Average position when Google records it. Search Console does not record
|
|
404
|
+
* position for Discover or the Google News app/news.google.com, so those
|
|
405
|
+
* Search Analytics rows can legitimately omit this field.
|
|
406
|
+
*/
|
|
407
|
+
position?: number;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export type PositionedSearchAnalyticsRow = SearchAnalyticsRow & { position: number };
|
|
411
|
+
|
|
412
|
+
export function hasRecordedPosition(
|
|
413
|
+
row: SearchAnalyticsRow,
|
|
414
|
+
): row is PositionedSearchAnalyticsRow {
|
|
415
|
+
return row.position !== undefined;
|
|
403
416
|
}
|
|
404
417
|
|
|
405
418
|
export interface SearchAnalyticsResponseMetadata {
|
|
@@ -639,6 +652,8 @@ export async function querySearchAnalytics(
|
|
|
639
652
|
|
|
640
653
|
/**
|
|
641
654
|
* Fetches Search Analytics rows in 25,000-row pages for higher-level tools.
|
|
655
|
+
* Google's documented pagination contract uses an explicit empty response as
|
|
656
|
+
* the terminal page, so a short non-empty page is not treated as exhaustion.
|
|
642
657
|
* A full final page conservatively reports localLimitReached because the API
|
|
643
658
|
* can have more rows even when Search Console's own internal limits apply.
|
|
644
659
|
*/
|
|
@@ -672,14 +687,14 @@ export async function querySearchAnalyticsPaginated(
|
|
|
672
687
|
|
|
673
688
|
const acceptedRows = page.rows.slice(0, requestedRows);
|
|
674
689
|
rows.push(...acceptedRows);
|
|
675
|
-
if (page.rows.length
|
|
690
|
+
if (page.rows.length === 0) {
|
|
676
691
|
return { rows, pagesFetched, localLimitReached: false };
|
|
677
692
|
}
|
|
678
693
|
if (rows.length >= maximumRows || page.rows.length > requestedRows) {
|
|
679
694
|
return { rows, pagesFetched, localLimitReached: true };
|
|
680
695
|
}
|
|
681
696
|
|
|
682
|
-
startRow +=
|
|
697
|
+
startRow += requestedRows;
|
|
683
698
|
}
|
|
684
699
|
|
|
685
700
|
return { rows, pagesFetched, localLimitReached: true };
|
|
@@ -812,6 +827,7 @@ export function processQuickWins(
|
|
|
812
827
|
maxPosition: number,
|
|
813
828
|
): QuickWinResult[] {
|
|
814
829
|
return rows
|
|
830
|
+
.filter(hasRecordedPosition)
|
|
815
831
|
.filter(
|
|
816
832
|
(row) =>
|
|
817
833
|
(row.keys?.length ?? 0) >= 2 &&
|
|
@@ -860,7 +876,7 @@ export function processCannibalization(
|
|
|
860
876
|
): CannibalizationResult[] {
|
|
861
877
|
const queryGroups = new Map<string, Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>>();
|
|
862
878
|
for (const row of rows) {
|
|
863
|
-
if ((row.keys?.length ?? 0) < 2) continue;
|
|
879
|
+
if ((row.keys?.length ?? 0) < 2 || row.position === undefined) continue;
|
|
864
880
|
const query = row.keys![0];
|
|
865
881
|
const page = row.keys![1];
|
|
866
882
|
if (!queryGroups.has(query)) {
|
|
@@ -1010,7 +1026,7 @@ export function processContentDecay(
|
|
|
1010
1026
|
): DecayPageResult[] {
|
|
1011
1027
|
const recentMap = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
|
|
1012
1028
|
for (const row of recentRows) {
|
|
1013
|
-
if ((row.keys?.length ?? 0) < 1) continue;
|
|
1029
|
+
if ((row.keys?.length ?? 0) < 1 || row.position === undefined) continue;
|
|
1014
1030
|
recentMap.set(row.keys![0], {
|
|
1015
1031
|
clicks: row.clicks,
|
|
1016
1032
|
impressions: row.impressions,
|
|
@@ -1022,7 +1038,7 @@ export function processContentDecay(
|
|
|
1022
1038
|
const decayCandidates: DecayPageResult[] = [];
|
|
1023
1039
|
|
|
1024
1040
|
for (const row of previousRows) {
|
|
1025
|
-
if ((row.keys?.length ?? 0) < 1) continue;
|
|
1041
|
+
if ((row.keys?.length ?? 0) < 1 || row.position === undefined) continue;
|
|
1026
1042
|
const page = row.keys![0];
|
|
1027
1043
|
const prevClicks = row.clicks;
|
|
1028
1044
|
const prevImps = row.impressions;
|
|
@@ -1688,7 +1704,7 @@ export function processPerformanceComparison(
|
|
|
1688
1704
|
): PerformanceComparisonRow[] {
|
|
1689
1705
|
const mapB = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
|
|
1690
1706
|
for (const row of rowsB) {
|
|
1691
|
-
if ((row.keys?.length ?? 0) < 1) continue;
|
|
1707
|
+
if ((row.keys?.length ?? 0) < 1 || row.position === undefined) continue;
|
|
1692
1708
|
mapB.set(row.keys![0], {
|
|
1693
1709
|
clicks: row.clicks,
|
|
1694
1710
|
impressions: row.impressions,
|
|
@@ -1700,7 +1716,7 @@ export function processPerformanceComparison(
|
|
|
1700
1716
|
const comparison: PerformanceComparisonRow[] = [];
|
|
1701
1717
|
|
|
1702
1718
|
for (const row of rowsA) {
|
|
1703
|
-
if ((row.keys?.length ?? 0) < 1) continue;
|
|
1719
|
+
if ((row.keys?.length ?? 0) < 1 || row.position === undefined) continue;
|
|
1704
1720
|
const key = row.keys![0];
|
|
1705
1721
|
const clicksA = row.clicks;
|
|
1706
1722
|
const impsA = row.impressions;
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
submitSitemap,
|
|
25
25
|
deleteSitemap,
|
|
26
26
|
getSitemap,
|
|
27
|
+
hasRecordedPosition,
|
|
27
28
|
processQuickWins,
|
|
28
29
|
processCannibalization,
|
|
29
30
|
processContentDecay,
|
|
@@ -134,7 +135,15 @@ const SEARCH_ROW_OUTPUT_SCHEMA = {
|
|
|
134
135
|
.describe(
|
|
135
136
|
'Dimension values returned by Google. Aggregate queries with dimensions: [] may legitimately omit this field.',
|
|
136
137
|
),
|
|
137
|
-
|
|
138
|
+
clicks: z.number(),
|
|
139
|
+
impressions: z.number(),
|
|
140
|
+
ctr: z.number(),
|
|
141
|
+
position: z
|
|
142
|
+
.number()
|
|
143
|
+
.optional()
|
|
144
|
+
.describe(
|
|
145
|
+
'Average position when Google records it. Omitted for Discover and Google News, where Search Console does not record position.',
|
|
146
|
+
),
|
|
138
147
|
};
|
|
139
148
|
|
|
140
149
|
const SITE_OUTPUT_SCHEMA = {
|
|
@@ -1364,7 +1373,7 @@ class GscMcpRuntime {
|
|
|
1364
1373
|
],
|
|
1365
1374
|
}, { maxRows: sourceRowLimit });
|
|
1366
1375
|
const rows = source.rows
|
|
1367
|
-
.filter((row) => (row.keys?.length ?? 0) > 0)
|
|
1376
|
+
.filter((row) => (row.keys?.length ?? 0) > 0 && hasRecordedPosition(row))
|
|
1368
1377
|
.map((row) => ({
|
|
1369
1378
|
query: row.keys![0],
|
|
1370
1379
|
clicks: row.clicks,
|
|
@@ -1448,7 +1457,7 @@ class GscMcpRuntime {
|
|
|
1448
1457
|
],
|
|
1449
1458
|
}, { maxRows: sourceRowLimit });
|
|
1450
1459
|
const rows = source.rows
|
|
1451
|
-
.filter((row) => (row.keys?.length ?? 0) > 0)
|
|
1460
|
+
.filter((row) => (row.keys?.length ?? 0) > 0 && hasRecordedPosition(row))
|
|
1452
1461
|
.map((row) => ({
|
|
1453
1462
|
page: row.keys![0],
|
|
1454
1463
|
clicks: row.clicks,
|
|
@@ -1940,7 +1949,7 @@ class GscMcpRuntime {
|
|
|
1940
1949
|
});
|
|
1941
1950
|
|
|
1942
1951
|
const pages = response.rows
|
|
1943
|
-
.filter((row) => (row.keys?.length ?? 0) > 0)
|
|
1952
|
+
.filter((row) => (row.keys?.length ?? 0) > 0 && hasRecordedPosition(row))
|
|
1944
1953
|
.map((row) => ({
|
|
1945
1954
|
page: row.keys![0],
|
|
1946
1955
|
clicks: row.clicks,
|