mcp-google-gsc 1.1.2 → 1.2.0

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/README.md CHANGED
@@ -6,7 +6,7 @@ MCP server for Google Search Console -- search analytics, URL inspection, and si
6
6
 
7
7
  - **Search Analytics** -- Query clicks, impressions, CTR, and position with flexible dimension filters (query, page, device, country, date)
8
8
  - **URL Inspection** -- Check indexing status, mobile usability, and rich results for any URL
9
- - **Site Listing** -- List all verified Search Console properties accessible to your service account
9
+ - **Site Listing** -- List all verified Search Console properties accessible to your authenticated account (OAuth user or service account)
10
10
  - **Multi-Client Support** -- Manage multiple GSC properties with per-directory config mapping
11
11
 
12
12
  ## Installation
@@ -30,32 +30,74 @@ npm run build
30
30
 
31
31
  **Security:** Never share your `.mcp.json` file or commit it to git -- it may contain API credentials. Add `.mcp.json` to your `.gitignore`.
32
32
 
33
- ### Service Account Setup
33
+ mcp-gsc supports **two authentication modes**. Pick whichever fits your setup. Neither requires any file living at a hardcoded machine-local path -- credentials come from environment variables (or a `config.json` you create).
34
34
 
35
- 1. Create a Google Cloud service account with Search Console API access
36
- 2. Download the JSON key file
37
- 3. Add the service account email as a user in Google Search Console for each property
35
+ **Which mode to use:**
36
+ - **Service Account (Mode B) -- recommended for unattended / server / headless use.** A service account has no interactive login to expire or re-consent, so it is the right fit for always-on deployments. The one setup requirement is that the service account's email must be **granted access on each Search Console property** you want to query (see Mode B).
37
+ - **User OAuth (Mode A) -- for personal / interactive use**, where you want to authorize with your own Google login.
38
38
 
39
- ### Config File
39
+ **Precedence when both are configured:** if a service-account keyfile is explicitly set (`GOOGLE_APPLICATION_CREDENTIALS`, or `credentials_file` in `config.json`), it **wins** over any OAuth refresh token or stored OAuth credentials. If neither is configured, the server fails loudly at startup with an onboarding message rather than silently guessing -- there is no machine-local default and no silent runtime failover between modes.
40
40
 
41
- Create a `config.json` in the project root (see `config.example.json` for the full structure):
41
+ ### Mode A: User OAuth (bring your own Google account)
42
+
43
+ Use this if you want to authorize with your own Google login (the account that has Search Console access). Best for personal / interactive use.
44
+
45
+ 1. In the Google Cloud Console, create an **OAuth 2.0 Client ID** of type **Desktop app** and enable the **Search Console API**. (For a Desktop-app client, Google accepts any `http://localhost` loopback redirect -- you do not need to pre-register a port.)
46
+ 2. Export your client credentials:
47
+ ```bash
48
+ export GOOGLE_GSC_CLIENT_ID=...apps.googleusercontent.com
49
+ export GOOGLE_GSC_CLIENT_SECRET=...
50
+ ```
51
+ 3. Mint a refresh token (opens your browser, uses PKCE + `access_type=offline`):
52
+ ```bash
53
+ node get-refresh-token.cjs
54
+ ```
55
+ > Do **not** redirect this command's stdout to a shared log -- it prints the refresh token to stdout by design.
56
+ 4. Copy the printed line into your environment:
57
+ ```bash
58
+ export GOOGLE_GSC_REFRESH_TOKEN=...
59
+ ```
60
+
61
+ The server reads `GOOGLE_GSC_CLIENT_ID`, `GOOGLE_GSC_CLIENT_SECRET`, and `GOOGLE_GSC_REFRESH_TOKEN` from the environment at runtime.
62
+
63
+ Alternatively, run the guided helper `npx mcp-gsc-auth`, which performs the same PKCE OAuth flow, lets you pick a default Search Console property, and writes the result to a per-user credentials file.
64
+
65
+ ### Mode B: Service Account (recommended for unattended / server use)
66
+
67
+ Use this for server / headless / always-on contexts -- it is the recommended path when no human is present to complete or refresh an interactive login.
68
+
69
+ 1. Create a Google Cloud **service account** with Search Console API access and download its JSON key file.
70
+ 2. **Grant the service account's email access on each Search Console property** you want to query (add it as a user in Search Console). Without this grant on the property, the service account can authenticate but will see no sites.
71
+ 3. Point the server at the key file **via an environment variable** (no hardcoded path):
72
+ ```bash
73
+ export GOOGLE_APPLICATION_CREDENTIALS=/path/to/your/service-account-key.json
74
+ ```
75
+
76
+ ### OAuth scope
77
+
78
+ Both modes request a single, **read-only** scope: `https://www.googleapis.com/auth/webmasters.readonly`. All four tools are reads -- nothing writes -- so the server never asks for read/write access.
79
+
80
+ The scope is defined once in `config.json` under `oauth.scope` (see `config.example.json`). The OAuth helper and the runtime read the same value, so they never drift. If `config.json` is absent (e.g. a fresh install), the committed read-only default is used.
81
+
82
+ ### Multi-client config (optional)
83
+
84
+ To map working directories to Search Console properties, create a `config.json` in the project root (see `config.example.json`):
42
85
 
43
86
  ```json
44
87
  {
45
- "default_credentials": "/path/to/service-account-key.json",
88
+ "oauth": {
89
+ "scope": "https://www.googleapis.com/auth/webmasters.readonly"
90
+ },
46
91
  "clients": {
47
92
  "my-project": {
48
- "site_url": "sc-domain:example.com",
49
- "credentials": "/path/to/service-account-key.json"
93
+ "name": "My Project",
94
+ "folder": "/path/to/project",
95
+ "site_url": "https://example.com/"
50
96
  }
51
97
  }
52
98
  }
53
99
  ```
54
100
 
55
- ### Environment Variable
56
-
57
- Alternatively, set `GOOGLE_APPLICATION_CREDENTIALS` to the path of your service account key file. The config file takes precedence when present.
58
-
59
101
  ## Usage
60
102
 
61
103
  Add to your Claude Code `.mcp.json`:
@@ -91,7 +133,7 @@ Or if installed globally:
91
133
  | Tool | Description |
92
134
  |------|-------------|
93
135
  | `gsc_get_client_context` | Detect the GSC property from your working directory based on config mapping |
94
- | `gsc_list_sites` | List all verified Search Console properties accessible to the service account |
136
+ | `gsc_list_sites` | List all verified Search Console properties accessible to the authenticated account |
95
137
  | `gsc_search_analytics` | Query search performance data (clicks, impressions, CTR, position) with dimension and filter support |
96
138
  | `gsc_inspection` | Inspect a URL for indexing status, mobile usability, and rich results |
97
139
 
@@ -1,5 +1,7 @@
1
1
  {
2
- "credentials_file": "/path/to/service-account.json",
2
+ "oauth": {
3
+ "scope": "https://www.googleapis.com/auth/webmasters.readonly"
4
+ },
3
5
  "clients": {
4
6
  "my-client": {
5
7
  "name": "My Client",
@@ -1,2 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ /** Resolve the OAuth scope from config.json (falls back to the read-only default). */
3
+ export declare function resolveAuthScope(configPath?: string): string;
4
+ export interface BuildAuthUrlOptions {
5
+ clientId: string;
6
+ redirectUri: string;
7
+ scope: string;
8
+ state: string;
9
+ codeChallenge: string;
10
+ }
11
+ export declare function buildAuthUrl({ clientId, redirectUri, scope, state, codeChallenge, }: BuildAuthUrlOptions): string;
2
12
  export declare function run(argv?: string[]): Promise<void>;
package/dist/auth-cli.js CHANGED
@@ -1,17 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  import { google } from "googleapis";
3
3
  import http from "http";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
4
6
  import promptsImport from "prompts";
5
7
  import { URL } from "url";
6
8
  import { writeStoredCredentials, credentialsFilePath, CREDENTIALS_FILE_VERSION } from "./credentials.js";
7
9
  import { EMBEDDED_CLIENT_ID, EMBEDDED_CLIENT_SECRET } from "./embedded-secrets.js";
8
10
  import { classifyError, GscAuthError } from "./errors.js";
9
11
  import { findFreeLoopbackPort, openBrowser } from "./platform.js";
12
+ import { loadOAuthScopeFromFile } from "./oauthScope.js";
13
+ import {
14
+ computeCodeChallenge,
15
+ generateCodeVerifier,
16
+ buildLoopbackRedirectUri
17
+ } from "./pkce.js";
10
18
  import { logger, withResilience } from "./resilience.js";
11
19
  const prompts = promptsImport.default ?? promptsImport;
12
- const OAUTH_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
13
20
  const OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
14
21
  const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
22
+ const DEFAULT_CONFIG_PATH = path.join(
23
+ path.dirname(fileURLToPath(import.meta.url)),
24
+ "..",
25
+ "config.json"
26
+ );
27
+ function resolveAuthScope(configPath = DEFAULT_CONFIG_PATH) {
28
+ return loadOAuthScopeFromFile(configPath);
29
+ }
15
30
  function parseArgs(argv) {
16
31
  const args = { help: false };
17
32
  for (let i = 0; i < argv.length; i++) {
@@ -41,15 +56,26 @@ function printHelp() {
41
56
  ].join("\n")
42
57
  );
43
58
  }
44
- function buildAuthUrl(clientId, redirectUri, state) {
59
+ function buildAuthUrl({
60
+ clientId,
61
+ redirectUri,
62
+ scope,
63
+ state,
64
+ codeChallenge
65
+ }) {
45
66
  const params = new URLSearchParams({
46
67
  client_id: clientId,
47
68
  redirect_uri: redirectUri,
48
69
  response_type: "code",
49
- scope: OAUTH_SCOPE,
70
+ scope,
50
71
  access_type: "offline",
72
+ // REQUIRED for Google to return a refresh_token
51
73
  prompt: "consent",
52
- state
74
+ // force a refresh_token even on re-consent
75
+ state,
76
+ code_challenge: codeChallenge,
77
+ // PKCE S256
78
+ code_challenge_method: "S256"
53
79
  });
54
80
  return `${OAUTH_AUTH_URL}?${params.toString()}`;
55
81
  }
@@ -135,14 +161,16 @@ function renderPage(title, body) {
135
161
  function escapeHtml(s) {
136
162
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
137
163
  }
138
- async function exchangeCodeForTokens(code, clientId, clientSecret, redirectUri) {
164
+ async function exchangeCodeForTokens(code, clientId, clientSecret, redirectUri, codeVerifier) {
139
165
  return withResilience(async () => {
140
166
  const body = new URLSearchParams({
141
167
  code,
142
168
  client_id: clientId,
143
169
  client_secret: clientSecret,
144
170
  redirect_uri: redirectUri,
145
- grant_type: "authorization_code"
171
+ grant_type: "authorization_code",
172
+ code_verifier: codeVerifier
173
+ // PKCE proof — sent on exchange
146
174
  });
147
175
  const res = await fetch(OAUTH_TOKEN_URL, {
148
176
  method: "POST",
@@ -232,13 +260,16 @@ async function run(argv = process.argv.slice(2)) {
232
260
  process.exit(2);
233
261
  }
234
262
  const port = await findFreeLoopbackPort();
235
- const redirectUri = `http://127.0.0.1:${port}`;
263
+ const redirectUri = buildLoopbackRedirectUri(port);
264
+ const scope = resolveAuthScope();
236
265
  const state = randomState();
237
- const authUrl = buildAuthUrl(clientId, redirectUri, state);
266
+ const codeVerifier = generateCodeVerifier();
267
+ const codeChallenge = computeCodeChallenge(codeVerifier);
268
+ const authUrl = buildAuthUrl({ clientId, redirectUri, scope, state, codeChallenge });
238
269
  process.stderr.write("\n=== mcp-gsc authentication ===\n");
239
270
  const { code } = await waitForAuthorizationCode(port, state, authUrl);
240
271
  process.stderr.write("Authorization code received. Exchanging for tokens...\n");
241
- const tokens = await exchangeCodeForTokens(code, clientId, clientSecret, redirectUri);
272
+ const tokens = await exchangeCodeForTokens(code, clientId, clientSecret, redirectUri, codeVerifier);
242
273
  if (!tokens.refresh_token) {
243
274
  throw new GscAuthError(
244
275
  "Google did not return a refresh token. This can happen if you previously granted consent to this app. Revoke access at https://myaccount.google.com/permissions and try again."
@@ -253,7 +284,7 @@ async function run(argv = process.argv.slice(2)) {
253
284
  site_urls: sites.map((s) => s.siteUrl),
254
285
  primary_site_url: chosen.siteUrl,
255
286
  obtained_at: (/* @__PURE__ */ new Date()).toISOString(),
256
- scopes: [OAUTH_SCOPE]
287
+ scopes: [scope]
257
288
  };
258
289
  writeStoredCredentials(stored);
259
290
  process.stderr.write(
@@ -276,14 +307,22 @@ function randomState() {
276
307
  globalThis.crypto.getRandomValues(bytes);
277
308
  return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
278
309
  }
279
- run().catch((err) => {
280
- const classified = classifyError(err);
281
- process.stderr.write(`
310
+ function isEntryPoint() {
311
+ const entry = process.argv[1] || "";
312
+ return /(^|[\\/])auth-cli(\.[cm]?js)?$/.test(entry);
313
+ }
314
+ if (isEntryPoint()) {
315
+ run().catch((err) => {
316
+ const classified = classifyError(err);
317
+ process.stderr.write(`
282
318
  ${classified.message}
283
319
  `);
284
- process.exit(1);
285
- });
320
+ process.exit(1);
321
+ });
322
+ }
286
323
  export {
324
+ buildAuthUrl,
325
+ resolveAuthScope,
287
326
  run
288
327
  };
289
328
  //# sourceMappingURL=auth-cli.js.map
@@ -0,0 +1,22 @@
1
+ export interface AuthModeInputs {
2
+ /** Absolute service-account keyfile path (from GOOGLE_APPLICATION_CREDENTIALS or config.json). "" if none. */
3
+ credentialsFile: string;
4
+ /** User-OAuth refresh token from env (GOOGLE_GSC_REFRESH_TOKEN). "" if none. */
5
+ refreshToken: string;
6
+ /** True if a stored OAuth credentials file (auth-helper output) exists on disk. */
7
+ hasStoredCreds: boolean;
8
+ }
9
+ export type AuthMode = "service_account" | "oauth" | "unconfigured";
10
+ export interface AuthModeSelection {
11
+ mode: AuthMode;
12
+ /** Echoed back for service_account so the caller can load the keyfile. */
13
+ credentialsFile: string;
14
+ }
15
+ /**
16
+ * Decide the auth mode. Precedence:
17
+ * 1. service_account — an explicit keyfile path wins (matches historical
18
+ * loadConfig precedence and is the most explicit signal).
19
+ * 2. oauth — a refresh token (env) or a stored credentials file.
20
+ * 3. unconfigured — no signal; caller must surface an onboarding error.
21
+ */
22
+ export declare function selectAuthMode(inputs: AuthModeInputs): AuthModeSelection;
@@ -0,0 +1,14 @@
1
+ function selectAuthMode(inputs) {
2
+ const credentialsFile = (inputs.credentialsFile || "").trim();
3
+ if (credentialsFile) {
4
+ return { mode: "service_account", credentialsFile };
5
+ }
6
+ if ((inputs.refreshToken || "").trim() || inputs.hasStoredCreds) {
7
+ return { mode: "oauth", credentialsFile: "" };
8
+ }
9
+ return { mode: "unconfigured", credentialsFile: "" };
10
+ }
11
+ export {
12
+ selectAuthMode
13
+ };
14
+ //# sourceMappingURL=authMode.js.map
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "e1f706c",
3
- "builtAt": "2026-04-18T18:17:03.690Z",
2
+ "sha": "6fd2d02",
3
+ "builtAt": "2026-07-09T14:24:13.759Z",
4
4
  "embeddedSecrets": true
5
5
  }
@@ -49,11 +49,11 @@ function resolveOAuthCredentials(credsFilePath = credentialsFilePath) {
49
49
  if (!client_secret) missing.push("client_secret");
50
50
  if (!refresh_token) missing.push("refresh_token");
51
51
  if (missing.length > 0) {
52
- throw new Error(buildMissingCredentialsMessage(missing, Boolean(stored)));
52
+ throw new Error(buildMissingCredentialsMessage(missing, Boolean(stored), credsFilePath));
53
53
  }
54
54
  return { client_id, client_secret, refresh_token, site_urls, primary_site_url, source };
55
55
  }
56
- function buildMissingCredentialsMessage(missing, hasFile) {
56
+ function buildMissingCredentialsMessage(missing, hasFile, credsFilePath = credentialsFilePath) {
57
57
  const runAuth = "npx mcp-gsc-auth";
58
58
  const lines = [
59
59
  `Missing GSC OAuth credentials: ${missing.join(", ")}.`,
@@ -63,12 +63,12 @@ function buildMissingCredentialsMessage(missing, hasFile) {
63
63
  ``,
64
64
  `This will open your browser, walk you through Google sign-in, let you pick which`,
65
65
  `Search Console property to use, and save the result to:`,
66
- ` ${credentialsFilePath}`
66
+ ` ${credsFilePath}`
67
67
  ];
68
68
  if (hasFile) {
69
69
  lines.push(
70
70
  ``,
71
- `A credentials file exists at ${credentialsFilePath} but is missing required fields.`,
71
+ `A credentials file exists at ${credsFilePath} but is missing required fields.`,
72
72
  `Re-run the auth helper to refresh it.`
73
73
  );
74
74
  }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,54 @@
1
1
  #!/usr/bin/env node
2
+ import { type AuthModeSelection } from "./authMode.js";
3
+ interface ClientConfig {
4
+ name: string;
5
+ folder: string;
6
+ site_url: string;
7
+ }
8
+ interface Config {
9
+ credentials_file: string;
10
+ oauth_credentials_file: string;
11
+ oauth_scope: string;
12
+ clients: Record<string, ClientConfig>;
13
+ }
14
+ /**
15
+ * Decide the runtime auth mode through the shared selectAuthMode() decision
16
+ * point (NOT an inline `if (credentials_file)` branch). Signals:
17
+ * - credentialsFile: SA keyfile path (config.json / GOOGLE_APPLICATION_CREDENTIALS)
18
+ * - refreshToken: env GOOGLE_GSC_REFRESH_TOKEN
19
+ * - hasStoredCreds: a StoredCredentials file (auth-helper output) exists
20
+ * Returns "service_account" | "oauth" | "unconfigured". Never silently defaults
21
+ * to service_account when nothing is configured.
22
+ */
23
+ export interface RuntimeAuthModeOptions {
24
+ /** Path checked for an existing StoredCredentials file. Defaults to the
25
+ * per-config path (config.oauth_credentials_file) or the global default.
26
+ * Injectable so tests can isolate from a real credentials file on the box. */
27
+ storedCredsPath?: string;
28
+ /** Env-like source for the refresh token. Defaults to process.env. */
29
+ env?: NodeJS.ProcessEnv;
30
+ }
31
+ export declare function resolveRuntimeAuthMode(config: Config, opts?: RuntimeAuthModeOptions): AuthModeSelection;
32
+ /** Construct a GscManager, surfacing the onboarding error for the unconfigured case. */
33
+ export declare function buildGscManager(config: Config, opts?: RuntimeAuthModeOptions): GscManager;
34
+ declare class GscManager {
35
+ private config;
36
+ private service;
37
+ private authMode;
38
+ constructor(config: Config, authOpts?: RuntimeAuthModeOptions);
39
+ private oauthCredsPath;
40
+ private getService;
41
+ listSites(): Promise<any>;
42
+ searchAnalytics(options: {
43
+ startDate: string;
44
+ endDate: string;
45
+ dimensions: string[];
46
+ searchType: string;
47
+ dimensionFilter: string;
48
+ rowLimit: number;
49
+ aggregationType: string;
50
+ siteUrl: string;
51
+ }): Promise<any>;
52
+ inspection(url: string, siteUrl: string): Promise<any>;
53
+ }
2
54
  export {};
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  } from "@modelcontextprotocol/sdk/types.js";
8
8
  import { readFileSync, existsSync } from "fs";
9
9
  import { join, dirname, resolve, isAbsolute } from "path";
10
+ import { fileURLToPath } from "url";
10
11
  import { google } from "googleapis";
11
12
  import {
12
13
  GscAuthError,
@@ -15,14 +16,16 @@ import {
15
16
  classifyError,
16
17
  validateCredentials
17
18
  } from "./errors.js";
18
- import { resolveOAuthCredentials } from "./credentials.js";
19
+ import { resolveOAuthCredentials, credentialsFilePath } from "./credentials.js";
20
+ import { loadOAuthScopeFromFile } from "./oauthScope.js";
21
+ import { selectAuthMode } from "./authMode.js";
19
22
  import { tools } from "./tools.js";
20
23
  import { withResilience, safeResponse, logger } from "./resilience.js";
21
- import { checkForUpdate } from "./updateNotifier.js";
24
+ import { checkForUpdate } from "mcp-updatenotifier";
22
25
  import v8 from "v8";
23
- const __cliPkg = JSON.parse(readFileSync(join(dirname(new URL(import.meta.url).pathname), "..", "package.json"), "utf-8"));
26
+ const __cliPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
24
27
  try {
25
- const __buildInfoDir = dirname(new URL(import.meta.url).pathname);
28
+ const __buildInfoDir = dirname(fileURLToPath(import.meta.url));
26
29
  const buildInfo = JSON.parse(readFileSync(join(__buildInfoDir, "build-info.json"), "utf-8"));
27
30
  console.error(`[build] SHA: ${buildInfo.sha} (${buildInfo.builtAt})`);
28
31
  } catch {
@@ -68,12 +71,17 @@ if (heapLimit < 256 * 1024 * 1024) {
68
71
  }
69
72
  const envTrimmed = (key) => (process.env[key] || "").trim().replace(/^["']|["']$/g, "");
70
73
  function loadConfig() {
71
- const configPath = join(dirname(new URL(import.meta.url).pathname), "..", "config.json");
74
+ const defaultConfigPath = join(dirname(fileURLToPath(import.meta.url)), "..", "config.json");
75
+ const configPath = envTrimmed("MCP_GSC_CONFIG_PATH") || defaultConfigPath;
76
+ const oauth_scope = loadOAuthScopeFromFile(configPath);
72
77
  if (existsSync(configPath)) {
73
78
  const raw = JSON.parse(readFileSync(configPath, "utf-8"));
74
79
  const rawCf = raw.credentials_file || envTrimmed("GOOGLE_APPLICATION_CREDENTIALS");
80
+ const rawOauth = raw.oauth_credentials_file || "";
75
81
  return {
76
82
  credentials_file: rawCf && !isAbsolute(rawCf) ? resolve(rawCf) : rawCf,
83
+ oauth_credentials_file: rawOauth && !isAbsolute(rawOauth) ? resolve(rawOauth) : rawOauth,
84
+ oauth_scope,
77
85
  clients: raw.clients || {}
78
86
  };
79
87
  }
@@ -82,11 +90,15 @@ function loadConfig() {
82
90
  if (credsFile) {
83
91
  return {
84
92
  credentials_file: credsFile,
93
+ oauth_credentials_file: "",
94
+ oauth_scope,
85
95
  clients: {}
86
96
  };
87
97
  }
88
98
  return {
89
99
  credentials_file: "",
100
+ oauth_credentials_file: "",
101
+ oauth_scope,
90
102
  clients: {}
91
103
  };
92
104
  }
@@ -138,14 +150,33 @@ function parseDimensionFilter(filterStr) {
138
150
  }
139
151
  return null;
140
152
  }
153
+ function resolveRuntimeAuthMode(config2, opts = {}) {
154
+ const credsPath = opts.storedCredsPath ?? (config2.oauth_credentials_file || credentialsFilePath);
155
+ const env = opts.env ?? process.env;
156
+ const refreshToken = (env.GOOGLE_GSC_REFRESH_TOKEN || "").trim().replace(/^["']|["']$/g, "");
157
+ return selectAuthMode({
158
+ credentialsFile: config2.credentials_file,
159
+ refreshToken,
160
+ hasStoredCreds: existsSync(credsPath)
161
+ });
162
+ }
163
+ function buildGscManager(config2, opts = {}) {
164
+ return new GscManager(config2, opts);
165
+ }
141
166
  class GscManager {
142
167
  config;
143
168
  service = null;
144
169
  authMode = "service_account";
145
- constructor(config2) {
170
+ constructor(config2, authOpts = {}) {
146
171
  this.config = config2;
147
- if (config2.credentials_file) {
148
- const creds = validateCredentials(config2.credentials_file);
172
+ const selection = resolveRuntimeAuthMode(config2, authOpts);
173
+ if (selection.mode === "unconfigured") {
174
+ const msg = "No GSC credentials configured. Choose one auth mode:\n - User OAuth: run `npx mcp-gsc-auth` (or `node get-refresh-token.cjs`) to mint a refresh token, then set GOOGLE_GSC_CLIENT_ID / GOOGLE_GSC_CLIENT_SECRET / GOOGLE_GSC_REFRESH_TOKEN.\n - Service account: set GOOGLE_APPLICATION_CREDENTIALS to a JSON key file path.";
175
+ console.error(`[STARTUP ERROR] ${msg}`);
176
+ throw new GscAuthError(msg);
177
+ }
178
+ if (selection.mode === "service_account") {
179
+ const creds = validateCredentials(selection.credentialsFile);
149
180
  if (!creds.valid) {
150
181
  const msg = `[STARTUP ERROR] Missing required credentials: ${creds.missing.join(", ")}. MCP will not function.`;
151
182
  console.error(msg);
@@ -153,21 +184,25 @@ class GscManager {
153
184
  }
154
185
  this.authMode = "service_account";
155
186
  } else {
156
- resolveOAuthCredentials();
187
+ resolveOAuthCredentials(this.oauthCredsPath());
157
188
  this.authMode = "oauth";
158
189
  }
159
190
  }
191
+ // Per-config OAuth token path when set, else undefined (global default).
192
+ oauthCredsPath() {
193
+ return this.config.oauth_credentials_file || void 0;
194
+ }
160
195
  getService() {
161
196
  if (!this.service) {
162
197
  if (this.authMode === "service_account") {
163
198
  const auth = new google.auth.GoogleAuth({
164
199
  keyFile: this.config.credentials_file,
165
- scopes: ["https://www.googleapis.com/auth/webmasters.readonly"]
200
+ scopes: [this.config.oauth_scope]
166
201
  });
167
202
  this.service = google.searchconsole({ version: "v1", auth });
168
203
  console.error(`[startup] Service account loaded from: ${this.config.credentials_file}`);
169
204
  } else {
170
- const resolved = resolveOAuthCredentials();
205
+ const resolved = resolveOAuthCredentials(this.oauthCredsPath());
171
206
  const oauth2Client = new google.auth.OAuth2(
172
207
  resolved.client_id,
173
208
  resolved.client_secret
@@ -292,8 +327,8 @@ class GscManager {
292
327
  }, "gsc_inspection");
293
328
  }
294
329
  }
295
- const config = loadConfig();
296
- const gscManager = new GscManager(config);
330
+ let config;
331
+ let gscManager;
297
332
  const server = new Server(
298
333
  {
299
334
  name: __cliPkg.name,
@@ -415,6 +450,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
415
450
  }
416
451
  });
417
452
  async function main() {
453
+ config = loadConfig();
454
+ gscManager = new GscManager(config);
418
455
  try {
419
456
  await gscManager.listSites();
420
457
  console.error("[startup] Auth verified: GSC API call succeeded");
@@ -439,5 +476,15 @@ process.on("SIGPIPE", () => {
439
476
  process.on("unhandledRejection", (reason) => {
440
477
  console.error("[error] Unhandled promise rejection:", reason);
441
478
  });
442
- main().catch(console.error);
479
+ function isEntryPoint() {
480
+ const entry = process.argv[1] || "";
481
+ return /(^|[\\/])index(\.[cm]?js)?$/.test(entry) || /[\\/]mcp-gsc$/.test(entry);
482
+ }
483
+ if (isEntryPoint()) {
484
+ main().catch(console.error);
485
+ }
486
+ export {
487
+ buildGscManager,
488
+ resolveRuntimeAuthMode
489
+ };
443
490
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Minimum scope this MCP needs: READ-ONLY Search Console. All four tools
3
+ * (list sites, search analytics, URL inspection, client context) are reads;
4
+ * nothing writes, so we never request `webmasters` (read/write).
5
+ */
6
+ export declare const DEFAULT_GSC_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
7
+ /** Normalize a comma/space/newline-separated scope list to space-separated. */
8
+ export declare function normalizeScope(raw: string): string;
9
+ /**
10
+ * Resolve the OAuth scope from an already-parsed config object.
11
+ * Falls back to DEFAULT_GSC_SCOPE when oauth.scope is absent/empty.
12
+ */
13
+ export declare function resolveOAuthScope(config: unknown): string;
14
+ /**
15
+ * Read the OAuth scope from a config file on disk (config.json). Returns the
16
+ * committed default if the file is missing or unparseable.
17
+ */
18
+ export declare function loadOAuthScopeFromFile(filePath: string): string;
@@ -0,0 +1,25 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ const DEFAULT_GSC_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
3
+ function normalizeScope(raw) {
4
+ return raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean).join(" ");
5
+ }
6
+ function resolveOAuthScope(config) {
7
+ const scope = config && typeof config === "object" && "oauth" in config && config.oauth && typeof config.oauth.scope === "string" ? config.oauth.scope : "";
8
+ const normalized = normalizeScope(scope);
9
+ return normalized || DEFAULT_GSC_SCOPE;
10
+ }
11
+ function loadOAuthScopeFromFile(filePath) {
12
+ if (!existsSync(filePath)) return DEFAULT_GSC_SCOPE;
13
+ try {
14
+ return resolveOAuthScope(JSON.parse(readFileSync(filePath, "utf-8")));
15
+ } catch {
16
+ return DEFAULT_GSC_SCOPE;
17
+ }
18
+ }
19
+ export {
20
+ DEFAULT_GSC_SCOPE,
21
+ loadOAuthScopeFromFile,
22
+ normalizeScope,
23
+ resolveOAuthScope
24
+ };
25
+ //# sourceMappingURL=oauthScope.js.map
package/dist/pkce.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** base64url with no padding (RFC 4648 §5). */
2
+ export declare function base64url(buf: Buffer): string;
3
+ /** 43–128 chars of the unreserved set. 32 random bytes -> 43 base64url chars. */
4
+ export declare function generateCodeVerifier(): string;
5
+ /** code_challenge = base64url(SHA256(code_verifier)), S256. */
6
+ export declare function computeCodeChallenge(verifier: string): string;
7
+ export declare const LOOPBACK_HOST = "localhost";
8
+ export declare const LOOPBACK_PATH = "/callback";
9
+ export declare function buildLoopbackRedirectUri(port: number): string;
package/dist/pkce.js ADDED
@@ -0,0 +1,24 @@
1
+ import { createHash, randomBytes } from "crypto";
2
+ function base64url(buf) {
3
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4
+ }
5
+ function generateCodeVerifier() {
6
+ return base64url(randomBytes(32));
7
+ }
8
+ function computeCodeChallenge(verifier) {
9
+ return base64url(createHash("sha256").update(verifier, "ascii").digest());
10
+ }
11
+ const LOOPBACK_HOST = "localhost";
12
+ const LOOPBACK_PATH = "/callback";
13
+ function buildLoopbackRedirectUri(port) {
14
+ return `http://${LOOPBACK_HOST}:${port}${LOOPBACK_PATH}`;
15
+ }
16
+ export {
17
+ LOOPBACK_HOST,
18
+ LOOPBACK_PATH,
19
+ base64url,
20
+ buildLoopbackRedirectUri,
21
+ computeCodeChallenge,
22
+ generateCodeVerifier
23
+ };
24
+ //# sourceMappingURL=pkce.js.map