@digestseo/mcp-gsc 0.4.17 → 0.4.19

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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.19] - 2026-09-22
11
+
12
+ ### Fixed
13
+ - Search Analytics freshness metadata now normalizes Google's camelCase `firstIncompleteDate` / `firstIncompleteHour` wire fields into the stable MCP `first_incomplete_date` / `first_incomplete_hour` response contract.
14
+ - The npm launcher now exits deterministically after its Wrangler child terminates by signal instead of re-signalling itself while its own `SIGINT`/`SIGTERM` listeners are installed, preventing wrapper processes from lingering after Ctrl+C or supervisor shutdown on POSIX systems.
15
+ - ChatGPT local-launcher guidance now explicitly requires Secure MCP Tunnel for the loopback `127.0.0.1` endpoint, or a separately deployed remote Worker, instead of implying that ChatGPT can connect directly to localhost.
16
+
17
+ ## [0.4.18] - 2026-09-22
18
+
19
+ ### Fixed
20
+ - The published npm launcher now keeps `GOOGLE_CLIENT_SECRET` and `TOKEN_ENCRYPTION_KEY` out of Wrangler `--var` command-line arguments; Wrangler loads those declared local secrets directly from the child process environment instead.
21
+ - `server.capabilities` now distinguishes stored local Google credentials from live provider authorization, exposing the credential-state basis and explicitly reporting that the discovery call does not probe Google before claiming a connection is usable.
22
+
10
23
  ## [0.4.17] - 2026-09-22
11
24
 
12
25
  ### 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). 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 keeps `GOOGLE_CLIENT_SECRET` and `TOKEN_ENCRYPTION_KEY` 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
 
@@ -62,7 +62,7 @@ claude mcp add --transport http gsc http://127.0.0.1:8080/mcp
62
62
 
63
63
  Start the npm launcher before using either install button. Complete its Google OAuth flow on first use.
64
64
 
65
- **ChatGPT** — add it as a custom MCP app in developer mode; see [Connect in your AI client](#connect-in-your-ai-client) below.
65
+ **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.
66
66
 
67
67
  A self-hostable [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **Google Search Console**. Connect it to Claude.ai, Cursor, ChatGPT, or any MCP-compatible client and ask your AI assistant about your site's organic search performance — impressions, clicks, top queries, index status, and sitemap health — straight from your own Google account.
68
68
 
@@ -76,7 +76,7 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 22 tools. Read-onl
76
76
 
77
77
  | Tool | Access | What it does |
78
78
  |---|---|---|
79
- | **`server.capabilities`** | Read | List every tool this server exposes and report whether your Google connection is currently authenticated (`connected` / `not_connected`). Takes no arguments — a good first call for discovery. |
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
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`. |
package/SETUP.md CHANGED
@@ -112,7 +112,7 @@ openssl rand -base64 32
112
112
 
113
113
  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.
114
114
 
115
- For **local development**, put the same three values in `.dev.vars` instead — copy `.dev.vars.example` to `.dev.vars` (it's gitignored):
115
+ 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:
116
116
 
117
117
  ```
118
118
  GOOGLE_CLIENT_ID=...
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
- import { homedir } from 'node:os';
3
+ import { constants, homedir } from 'node:os';
4
4
  import { resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
@@ -16,8 +16,6 @@ const stateDir = process.env.MCP_GSC_STATE_DIR
16
16
  : resolve(homedir(), '.mcp-gsc', 'state');
17
17
  const forwardedVariables = [
18
18
  'GOOGLE_CLIENT_ID',
19
- 'GOOGLE_CLIENT_SECRET',
20
- 'TOKEN_ENCRYPTION_KEY',
21
19
  'GSC_ACCESS_MODE',
22
20
  'GLAMA_INSPECTION_MODE',
23
21
  ];
@@ -53,7 +51,8 @@ process.on('SIGTERM', () => forwardSignal('SIGTERM'));
53
51
 
54
52
  child.on('exit', (code, signal) => {
55
53
  if (signal) {
56
- process.kill(process.pid, signal);
54
+ const signalNumber = constants.signals[signal];
55
+ process.exit(signalNumber === undefined ? 1 : 128 + signalNumber);
57
56
  } else {
58
57
  process.exit(code ?? 1);
59
58
  }
package/src/google.ts CHANGED
@@ -407,6 +407,11 @@ export interface SearchAnalyticsResponseMetadata {
407
407
  first_incomplete_hour?: string;
408
408
  }
409
409
 
410
+ interface GoogleSearchAnalyticsResponseMetadata {
411
+ firstIncompleteDate?: string;
412
+ firstIncompleteHour?: string;
413
+ }
414
+
410
415
  export interface SearchAnalyticsResponse {
411
416
  rows: SearchAnalyticsRow[];
412
417
  responseAggregationType?: string;
@@ -610,14 +615,25 @@ export async function querySearchAnalytics(
610
615
  const data = (await resp.json()) as {
611
616
  rows?: SearchAnalyticsRow[];
612
617
  responseAggregationType?: string;
613
- metadata?: SearchAnalyticsResponseMetadata;
618
+ metadata?: GoogleSearchAnalyticsResponseMetadata;
614
619
  };
620
+ const metadata: SearchAnalyticsResponseMetadata | undefined =
621
+ data.metadata === undefined
622
+ ? undefined
623
+ : {
624
+ ...(data.metadata.firstIncompleteDate !== undefined
625
+ ? { first_incomplete_date: data.metadata.firstIncompleteDate }
626
+ : {}),
627
+ ...(data.metadata.firstIncompleteHour !== undefined
628
+ ? { first_incomplete_hour: data.metadata.firstIncompleteHour }
629
+ : {}),
630
+ };
615
631
  return {
616
632
  rows: data.rows ?? [],
617
633
  ...(data.responseAggregationType !== undefined
618
634
  ? { responseAggregationType: data.responseAggregationType }
619
635
  : {}),
620
- ...(data.metadata !== undefined ? { metadata: data.metadata } : {}),
636
+ ...(metadata !== undefined ? { metadata } : {}),
621
637
  };
622
638
  }
623
639
 
package/src/index.ts CHANGED
@@ -191,7 +191,18 @@ const CAPABILITIES_OUTPUT_SCHEMA = {
191
191
  server: z.string(),
192
192
  version: z.string(),
193
193
  access_mode: z.enum(['readonly', 'readwrite']),
194
- auth_status: z.enum(['connected', 'not_connected', 'unknown']),
194
+ auth_status: z.enum(['connected', 'not_connected', 'unknown']).describe(
195
+ "Local credential state: 'connected' means a stored Google refresh credential is present; it does not live-verify that Google still accepts it.",
196
+ ),
197
+ auth_status_basis: z.enum([
198
+ 'stored_refresh_token',
199
+ 'no_stored_refresh_token',
200
+ 'local_state_unavailable',
201
+ ]),
202
+ provider_auth_live_verified: z.literal(false).describe(
203
+ 'Always false because server.capabilities does not make a Google token or API request merely to probe authorization.',
204
+ ),
205
+ auth_note: z.string(),
195
206
  tools: z.array(
196
207
  z.object({
197
208
  name: z.string(),
@@ -719,7 +730,7 @@ const TOOL_CATALOG = [
719
730
  {
720
731
  name: 'server.capabilities',
721
732
  description:
722
- "List every tool this server exposes and report whether the user's Google Search Console connection is currently authenticated.",
733
+ "List every tool this server exposes and report local Google credential state. A connected status means a stored refresh credential is present; it is not a live Google authorization check.",
723
734
  },
724
735
  ] as const;
725
736
 
@@ -776,32 +787,49 @@ class GscMcpRuntime {
776
787
  {
777
788
  title: 'Get server capabilities and auth status',
778
789
  description:
779
- "List every tool this server exposes, its configured access mode, and whether the user's Google Search Console connection is currently authenticated. Call this first if you're unsure what tools are available, whether the deployment is read-only, or whether the user is connected. Returns the tool catalog plus an access mode and auth status. Takes no arguments.",
790
+ "List every tool this server exposes, its configured access mode, and local Google credential state. Call this first if you're unsure what tools are available, whether the deployment is read-only, or whether stored Google credentials are configured. auth_status='connected' means a stored refresh credential is present; this tool does not make a Google token/API request and therefore does not live-verify that Google still accepts it. Takes no arguments.",
780
791
  inputSchema: {},
781
792
  outputSchema: CAPABILITIES_OUTPUT_SCHEMA,
782
793
  annotations: READ_ONLY_ANNOTATIONS,
783
794
  },
784
795
  async () => {
785
796
  let authStatus: 'connected' | 'not_connected' | 'unknown';
797
+ let authStatusBasis:
798
+ | 'stored_refresh_token'
799
+ | 'no_stored_refresh_token'
800
+ | 'local_state_unavailable';
786
801
  try {
787
802
  const googleId = this.props?.google_id;
788
803
  if (!googleId) {
789
804
  authStatus = 'not_connected';
805
+ authStatusBasis = 'no_stored_refresh_token';
790
806
  } else {
791
807
  const refreshToken = await getDecryptedRefreshToken(
792
808
  this.env,
793
809
  googleId,
794
810
  );
795
811
  authStatus = refreshToken ? 'connected' : 'not_connected';
812
+ authStatusBasis = refreshToken
813
+ ? 'stored_refresh_token'
814
+ : 'no_stored_refresh_token';
796
815
  }
797
816
  } catch {
798
817
  authStatus = 'unknown';
818
+ authStatusBasis = 'local_state_unavailable';
799
819
  }
820
+ const authNote = authStatus === 'connected'
821
+ ? "Stored Google refresh credentials are present, but server.capabilities does not live-verify them with Google. Refresh tokens can expire or be revoked; a Google tool call may still require reconnection."
822
+ : authStatus === 'not_connected'
823
+ ? 'No stored Google refresh credential is available. Reconnect this server in your MCP client to sign in with Google.'
824
+ : 'Local Google credential state could not be read. Provider authorization was not checked.';
800
825
  const capabilities = {
801
826
  server: SERVER_NAME,
802
827
  version: SERVER_VERSION,
803
828
  access_mode: accessMode,
804
829
  auth_status: authStatus,
830
+ auth_status_basis: authStatusBasis,
831
+ provider_auth_live_verified: false as const,
832
+ auth_note: authNote,
805
833
  tools: getToolCatalogForAccessMode(TOOL_CATALOG, accessMode),
806
834
  provider_limits: {
807
835
  generative_ai_performance_report: {
@@ -809,7 +837,9 @@ class GscMcpRuntime {
809
837
  note: GENERATIVE_AI_REPORT_API_NOTE,
810
838
  },
811
839
  },
812
- hint: "If auth_status is not 'connected', the user should reconnect this server in their MCP client to sign in with Google.",
840
+ hint: authStatus === 'connected'
841
+ ? 'Stored Google credentials are configured. If a Google tool later reports revoked access, reconnect this server in your MCP client.'
842
+ : 'Reconnect this server in your MCP client to sign in with Google.',
813
843
  };
814
844
  return toolResponse(JSON.stringify(capabilities, null, 2), capabilities);
815
845
  },
@@ -8,6 +8,9 @@
8
8
  "compatibility_date": "2026-05-19",
9
9
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
10
10
  "observability": { "enabled": true },
11
+ "secrets": {
12
+ "required": ["GOOGLE_CLIENT_SECRET", "TOKEN_ENCRYPTION_KEY"]
13
+ },
11
14
  "vars": {
12
15
  "GSC_ACCESS_MODE": "readwrite"
13
16
  },