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 +57 -15
- package/config.example.json +3 -1
- package/dist/auth-cli.d.ts +10 -0
- package/dist/auth-cli.js +54 -15
- package/dist/authMode.d.ts +22 -0
- package/dist/authMode.js +14 -0
- package/dist/build-info.json +2 -2
- package/dist/credentials.js +4 -4
- package/dist/index.d.ts +52 -0
- package/dist/index.js +61 -14
- package/dist/oauthScope.d.ts +18 -0
- package/dist/oauthScope.js +25 -0
- package/dist/pkce.d.ts +9 -0
- package/dist/pkce.js +24 -0
- package/dist/resilience.js +41 -23
- package/get-refresh-token.cjs +296 -0
- package/package.json +18 -6
- package/dist/auth-cli.js.map +0 -7
- package/dist/credentials.js.map +0 -7
- package/dist/embedded-secrets.js.map +0 -7
- package/dist/errors.js.map +0 -7
- package/dist/index.js.map +0 -7
- package/dist/platform.js.map +0 -7
- package/dist/resilience.js.map +0 -7
- package/dist/tools.js.map +0 -7
- package/dist/updateNotifier.d.ts +0 -7
- package/dist/updateNotifier.js +0 -53
- package/dist/updateNotifier.js.map +0 -7
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
88
|
+
"oauth": {
|
|
89
|
+
"scope": "https://www.googleapis.com/auth/webmasters.readonly"
|
|
90
|
+
},
|
|
46
91
|
"clients": {
|
|
47
92
|
"my-project": {
|
|
48
|
-
"
|
|
49
|
-
"
|
|
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
|
|
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
|
|
package/config.example.json
CHANGED
package/dist/auth-cli.d.ts
CHANGED
|
@@ -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(
|
|
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
|
|
70
|
+
scope,
|
|
50
71
|
access_type: "offline",
|
|
72
|
+
// REQUIRED for Google to return a refresh_token
|
|
51
73
|
prompt: "consent",
|
|
52
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 =
|
|
263
|
+
const redirectUri = buildLoopbackRedirectUri(port);
|
|
264
|
+
const scope = resolveAuthScope();
|
|
236
265
|
const state = randomState();
|
|
237
|
-
const
|
|
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: [
|
|
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
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
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
|
-
|
|
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;
|
package/dist/authMode.js
ADDED
|
@@ -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
|
package/dist/build-info.json
CHANGED
package/dist/credentials.js
CHANGED
|
@@ -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
|
-
` ${
|
|
66
|
+
` ${credsFilePath}`
|
|
67
67
|
];
|
|
68
68
|
if (hasFile) {
|
|
69
69
|
lines.push(
|
|
70
70
|
``,
|
|
71
|
-
`A credentials file exists at ${
|
|
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 "
|
|
24
|
+
import { checkForUpdate } from "mcp-updatenotifier";
|
|
22
25
|
import v8 from "v8";
|
|
23
|
-
const __cliPkg = JSON.parse(readFileSync(join(dirname(
|
|
26
|
+
const __cliPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
|
|
24
27
|
try {
|
|
25
|
-
const __buildInfoDir = dirname(
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
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: [
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|