@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 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, 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
 
@@ -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.24",
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",
@@ -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 { querySearchAnalytics, type SearchAnalyticsRow } from './google';
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.map((r) => ({
184
- query: r.keys?.[0] ?? '',
185
- clicks: r.clicks,
186
- impressions: r.impressions,
187
- ctr: r.ctr,
188
- position: r.position,
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
- position: number;
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 < requestedRows) {
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 += page.rows.length;
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
- ...METRIC_OUTPUT_SCHEMA,
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,