@digestseo/mcp-gsc 0.4.21 → 0.4.23

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.23] - 2026-09-22
11
+
12
+ ### Fixed
13
+ - The OAuth proxy now requires an explicit, CSRF-bound consent step that identifies the requesting MCP client and requested permissions before forwarding the browser to Google, preventing cached upstream Google consent from silently authorizing an untrusted MCP client. User or Google denial is returned to the validated MCP client as a terminal OAuth error instead of stranding the connection flow on a local error page.
14
+
15
+ ## [0.4.22] - 2026-09-22
16
+
17
+ ### Added
18
+ - The repository's Cursor plugin now bundles native `gsc-weekly-review`, `gsc-indexing-triage`, and `gsc-search-opportunities` Agent Skills that compose existing MCP tools into evidence-safe Search Console workflows without adding a second tool surface.
19
+
20
+ ### Fixed
21
+ - The production Wrangler floor is now 4.136.3, and the Cloudflare Vitest plugin floor is 1.2.3, so runtime/test installs use Cloudflare's patched Wrangler line for the bundled `smol-toml` denial-of-service advisories GHSA-7w5x-hrqm-74c2 and GHSA-v3rj-xjv7-4jmq.
22
+
10
23
  ## [0.4.21] - 2026-09-22
11
24
 
12
25
  ### Fixed
package/README.md CHANGED
@@ -56,6 +56,8 @@ claude mcp add --transport http gsc http://127.0.0.1:8080/mcp
56
56
 
57
57
  [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=gsc&config=eyJ1cmwiOiJodHRwOi8vMTI3LjAuMC4xOjgwODAvbWNwIn0%3D)
58
58
 
59
+ When this repository is installed as a Cursor plugin, Cursor also auto-discovers three bundled Agent Skills that compose the MCP tools into evidence-safe workflows: `gsc-weekly-review`, `gsc-indexing-triage`, and `gsc-search-opportunities`. The **Add to Cursor** button above installs the MCP connection only; the bundled skills are part of the repository's Cursor plugin package.
60
+
59
61
  **Kiro**
60
62
 
61
63
  [![Add to Kiro](https://kiro.dev/images/add-to-kiro.svg)](https://kiro.dev/launch/mcp/add?name=mcp-gsc&config=%7B%22url%22%3A%22http%3A%2F%2F127.0.0.1%3A8080%2Fmcp%22%2C%22disabled%22%3Afalse%2C%22autoApprove%22%3A%5B%5D%7D)
@@ -66,7 +68,7 @@ Start the npm launcher before using either install button. Complete its Google O
66
68
 
67
69
  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
70
 
69
- It runs on [Cloudflare Workers](https://workers.cloudflare.com/) and ships with one-click Google OAuth onboarding: connect the server in your client, sign in with Google once, grant the requested Google Search Console permissions, and you're done. No API keys to copy around and no service-account JSON to manage.
71
+ It runs on [Cloudflare Workers](https://workers.cloudflare.com/) and ships with Google OAuth onboarding: connect the server in your client, review the mcp-gsc consent page that identifies the requesting MCP client and permissions, then sign in with Google and grant the requested Google Search Console permissions. No API keys to copy around and no service-account JSON to manage.
70
72
 
71
73
  ## Tools
72
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
@@ -76,11 +76,11 @@
76
76
  "@cloudflare/workers-oauth-provider": "^0.10.3",
77
77
  "@modelcontextprotocol/server": "2.0.0",
78
78
  "agents": "^0.24.0",
79
- "wrangler": "^4.135.0",
79
+ "wrangler": "^4.136.3",
80
80
  "zod": "^4.4.3"
81
81
  },
82
82
  "devDependencies": {
83
- "@cloudflare/vitest-plugin": "^1.1.13",
83
+ "@cloudflare/vitest-plugin": "^1.2.3",
84
84
  "@cloudflare/workers-types": "^5.20260919.1",
85
85
  "tsx": "^4.20.0",
86
86
  "typescript": "^6.0.3",
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
1
+ import { OAuthProvider, type AuthRequest } from '@cloudflare/workers-oauth-provider';
2
2
  import { DurableObject } from 'cloudflare:workers';
3
3
  import { createMcpHandler, getMcpAuthContext } from 'agents/mcp/server';
4
4
  import { McpServer } from '@modelcontextprotocol/server';
@@ -71,6 +71,7 @@ import {
71
71
  import { WRITE_TOOL_ANNOTATIONS } from './write-tool-annotations';
72
72
  import {
73
73
  getToolCatalogForAccessMode,
74
+ getGoogleOAuthScopes,
74
75
  resolveGscAccessMode,
75
76
  type GscAccessMode,
76
77
  } from './access-mode';
@@ -2134,6 +2135,154 @@ export class PendingAuthState extends DurableObject<Env> {
2134
2135
  }
2135
2136
  }
2136
2137
 
2138
+ const CONSENT_COOKIE_HTTPS = '__Host-MCP_GSC_CONSENT';
2139
+ const CONSENT_COOKIE_LOOPBACK = 'MCP_GSC_CONSENT';
2140
+
2141
+ interface PendingConsentPayload {
2142
+ kind: 'mcp-gsc-consent';
2143
+ authRequest: AuthRequest;
2144
+ }
2145
+
2146
+ function isLoopbackHttp(url: URL): boolean {
2147
+ return (
2148
+ url.protocol === 'http:' &&
2149
+ (url.hostname === '127.0.0.1' ||
2150
+ url.hostname === 'localhost' ||
2151
+ url.hostname === '[::1]')
2152
+ );
2153
+ }
2154
+
2155
+ function consentCookieName(url: URL): string {
2156
+ return isLoopbackHttp(url) ? CONSENT_COOKIE_LOOPBACK : CONSENT_COOKIE_HTTPS;
2157
+ }
2158
+
2159
+ function consentCookie(url: URL, value: string, maxAgeSeconds = 600): string {
2160
+ const secure = isLoopbackHttp(url) ? '' : '; Secure';
2161
+ return `${consentCookieName(url)}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}${secure}`;
2162
+ }
2163
+
2164
+ function clearConsentCookie(url: URL): string {
2165
+ return consentCookie(url, '', 0);
2166
+ }
2167
+
2168
+ function getCookie(request: Request, name: string): string | null {
2169
+ const cookie = request.headers.get('cookie');
2170
+ if (!cookie) return null;
2171
+ for (const entry of cookie.split(';')) {
2172
+ const separator = entry.indexOf('=');
2173
+ if (separator < 0) continue;
2174
+ if (entry.slice(0, separator).trim() !== name) continue;
2175
+ try {
2176
+ return decodeURIComponent(entry.slice(separator + 1).trim());
2177
+ } catch {
2178
+ return null;
2179
+ }
2180
+ }
2181
+ return null;
2182
+ }
2183
+
2184
+ async function constantTimeStringEqual(left: string, right: string): Promise<boolean> {
2185
+ const encoder = new TextEncoder();
2186
+ const [leftHash, rightHash] = await Promise.all([
2187
+ crypto.subtle.digest('SHA-256', encoder.encode(left)),
2188
+ crypto.subtle.digest('SHA-256', encoder.encode(right)),
2189
+ ]);
2190
+ const leftBytes = new Uint8Array(leftHash);
2191
+ const rightBytes = new Uint8Array(rightHash);
2192
+ let difference = 0;
2193
+ for (let index = 0; index < leftBytes.length; index += 1) {
2194
+ difference |= leftBytes[index] ^ rightBytes[index];
2195
+ }
2196
+ return difference === 0;
2197
+ }
2198
+
2199
+ function escapeHtml(value: string): string {
2200
+ return value
2201
+ .replace(/&/g, '&amp;')
2202
+ .replace(/</g, '&lt;')
2203
+ .replace(/>/g, '&gt;')
2204
+ .replace(/"/g, '&quot;')
2205
+ .replace(/'/g, '&#039;');
2206
+ }
2207
+
2208
+ function isParsedAuthorizationRequest(value: unknown): value is AuthRequest {
2209
+ if (!value || typeof value !== 'object') return false;
2210
+ const candidate = value as Partial<AuthRequest>;
2211
+ return (
2212
+ typeof candidate.responseType === 'string' &&
2213
+ typeof candidate.clientId === 'string' &&
2214
+ typeof candidate.redirectUri === 'string' &&
2215
+ Array.isArray(candidate.scope) &&
2216
+ candidate.scope.every((scope) => typeof scope === 'string') &&
2217
+ typeof candidate.state === 'string'
2218
+ );
2219
+ }
2220
+
2221
+ function isPendingConsentPayload(value: unknown): value is PendingConsentPayload {
2222
+ if (!value || typeof value !== 'object') return false;
2223
+ const candidate = value as Partial<PendingConsentPayload>;
2224
+ return candidate.kind === 'mcp-gsc-consent' && isParsedAuthorizationRequest(candidate.authRequest);
2225
+ }
2226
+
2227
+ function oauthErrorRedirect(
2228
+ authRequest: AuthRequest,
2229
+ code: 'access_denied' | 'server_error',
2230
+ description?: string,
2231
+ ): Response {
2232
+ const redirect = new URL(authRequest.redirectUri);
2233
+ redirect.searchParams.set('error', code);
2234
+ if (description) redirect.searchParams.set('error_description', description);
2235
+ redirect.searchParams.set('state', authRequest.state);
2236
+ if (authRequest.issuer) redirect.searchParams.set('iss', authRequest.issuer);
2237
+ return new Response(null, {
2238
+ status: 302,
2239
+ headers: { location: redirect.toString() },
2240
+ });
2241
+ }
2242
+
2243
+ function consentHtml(input: {
2244
+ clientName: string;
2245
+ clientId: string;
2246
+ consentNonce: string;
2247
+ mcpScopes: readonly string[];
2248
+ googleScopes: readonly string[];
2249
+ accessMode: GscAccessMode;
2250
+ }): string {
2251
+ const list = (values: readonly string[]) =>
2252
+ values.length > 0
2253
+ ? values.map((value) => `<li><code>${escapeHtml(value)}</code></li>`).join('')
2254
+ : '<li><em>No additional scopes requested</em></li>';
2255
+ return `<!doctype html>
2256
+ <html lang="en">
2257
+ <head>
2258
+ <meta charset="utf-8">
2259
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2260
+ <title>Authorize Google Search Console MCP</title>
2261
+ <style>
2262
+ body{font-family:system-ui,sans-serif;max-width:760px;margin:3rem auto;padding:0 1rem;line-height:1.5;color:#111}main{border:1px solid #ddd;border-radius:12px;padding:1.5rem}code{overflow-wrap:anywhere}button{padding:.65rem 1rem;margin-right:.5rem;font:inherit}button[name="decision"][value="allow"]{font-weight:700}.muted{color:#555}
2263
+ </style>
2264
+ </head>
2265
+ <body>
2266
+ <main>
2267
+ <h1>Authorize Google Search Console access</h1>
2268
+ <p><strong>${escapeHtml(input.clientName)}</strong> is requesting access through mcp-gsc.</p>
2269
+ <p class="muted">Client ID: <code>${escapeHtml(input.clientId)}</code></p>
2270
+ <h2>MCP scopes requested</h2>
2271
+ <ul>${list(input.mcpScopes)}</ul>
2272
+ <h2>Google scopes mcp-gsc will request</h2>
2273
+ <p>Configured access mode: <strong>${escapeHtml(input.accessMode)}</strong></p>
2274
+ <ul>${list(input.googleScopes)}</ul>
2275
+ <p>Continue only if you recognize and trust the requesting MCP client.</p>
2276
+ <form method="post" action="/authorize">
2277
+ <input type="hidden" name="consent_nonce" value="${escapeHtml(input.consentNonce)}">
2278
+ <button type="submit" name="decision" value="allow">Allow and continue to Google</button>
2279
+ <button type="submit" name="decision" value="deny">Deny</button>
2280
+ </form>
2281
+ </main>
2282
+ </body>
2283
+ </html>`;
2284
+ }
2285
+
2137
2286
  function googleRedirectUri(request: Request): string {
2138
2287
  return new URL('/google/callback', request.url).toString();
2139
2288
  }
@@ -2169,7 +2318,7 @@ export const defaultHandler = {
2169
2318
  });
2170
2319
  }
2171
2320
 
2172
- if (url.pathname === '/authorize') {
2321
+ if (url.pathname === '/authorize' && request.method === 'GET') {
2173
2322
  let claudeAuthRequest;
2174
2323
  try {
2175
2324
  claudeAuthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
@@ -2185,6 +2334,9 @@ export const defaultHandler = {
2185
2334
  },
2186
2335
  );
2187
2336
  }
2337
+ if (!isParsedAuthorizationRequest(claudeAuthRequest)) {
2338
+ return new Response('Invalid OAuth authorization request.', { status: 400 });
2339
+ }
2188
2340
  let accessMode: GscAccessMode;
2189
2341
  try {
2190
2342
  accessMode = resolveGscAccessMode(env.GSC_ACCESS_MODE);
@@ -2194,16 +2346,106 @@ export const defaultHandler = {
2194
2346
  { status: 500 },
2195
2347
  );
2196
2348
  }
2197
- const nonce = crypto.randomUUID();
2198
- await stashPendingAuth(env, nonce, claudeAuthRequest);
2199
- const redirectUri = googleRedirectUri(request);
2349
+ let client;
2350
+ try {
2351
+ client = await env.OAUTH_PROVIDER.lookupClient(claudeAuthRequest.clientId);
2352
+ } catch (err) {
2353
+ console.warn('OAuth client lookup failed', {
2354
+ message: (err as Error).message,
2355
+ });
2356
+ return new Response('Could not verify the requesting OAuth client.', { status: 400 });
2357
+ }
2358
+ if (!client) {
2359
+ return new Response('Unknown OAuth client.', { status: 400 });
2360
+ }
2361
+
2362
+ const consentNonce = crypto.randomUUID();
2363
+ const pendingConsent: PendingConsentPayload = {
2364
+ kind: 'mcp-gsc-consent',
2365
+ authRequest: claudeAuthRequest,
2366
+ };
2367
+ await stashPendingAuth(env, consentNonce, pendingConsent);
2368
+ return new Response(
2369
+ consentHtml({
2370
+ clientName: client.clientName ?? claudeAuthRequest.clientId,
2371
+ clientId: claudeAuthRequest.clientId,
2372
+ consentNonce,
2373
+ mcpScopes: claudeAuthRequest.scope,
2374
+ googleScopes: getGoogleOAuthScopes(accessMode),
2375
+ accessMode,
2376
+ }),
2377
+ {
2378
+ headers: {
2379
+ 'content-type': 'text/html; charset=utf-8',
2380
+ 'cache-control': 'no-store',
2381
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
2382
+ 'referrer-policy': 'no-referrer',
2383
+ 'x-content-type-options': 'nosniff',
2384
+ 'x-frame-options': 'DENY',
2385
+ 'set-cookie': consentCookie(url, consentNonce),
2386
+ },
2387
+ },
2388
+ );
2389
+ }
2390
+
2391
+ if (url.pathname === '/authorize' && request.method === 'POST') {
2392
+ const contentType = request.headers.get('content-type')?.toLowerCase() ?? '';
2393
+ if (!contentType.startsWith('application/x-www-form-urlencoded')) {
2394
+ return new Response('Unsupported Media Type', { status: 415 });
2395
+ }
2396
+ const contentLength = Number(request.headers.get('content-length') ?? '0');
2397
+ if (Number.isFinite(contentLength) && contentLength > 4096) {
2398
+ return new Response('Authorization response too large', { status: 413 });
2399
+ }
2400
+ const body = await request.formData();
2401
+ const consentNonceValue = body.get('consent_nonce');
2402
+ const decisionValue = body.get('decision');
2403
+ const consentNonce =
2404
+ typeof consentNonceValue === 'string' ? consentNonceValue : null;
2405
+ const decision = typeof decisionValue === 'string' ? decisionValue : null;
2406
+ const cookieNonce = getCookie(request, consentCookieName(url));
2407
+ if (!consentNonce || !cookieNonce || !(await constantTimeStringEqual(consentNonce, cookieNonce))) {
2408
+ return new Response('Invalid or expired authorization consent.', { status: 400 });
2409
+ }
2410
+
2411
+ const pending = await consumePendingAuth(env, consentNonce);
2412
+ if (!pending || !isPendingConsentPayload(pending.claudeAuthRequest)) {
2413
+ return new Response('Authorization consent expired or invalid.', { status: 400 });
2414
+ }
2415
+ const authRequest = pending.claudeAuthRequest.authRequest;
2416
+ if (decision === 'deny') {
2417
+ const denied = oauthErrorRedirect(authRequest, 'access_denied', 'The user denied access.');
2418
+ denied.headers.append('set-cookie', clearConsentCookie(url));
2419
+ return denied;
2420
+ }
2421
+ if (decision !== 'allow') {
2422
+ return new Response('Invalid authorization decision.', { status: 400 });
2423
+ }
2424
+
2425
+ let accessMode: GscAccessMode;
2426
+ try {
2427
+ accessMode = resolveGscAccessMode(env.GSC_ACCESS_MODE);
2428
+ } catch {
2429
+ return new Response(
2430
+ 'Server configuration error: GSC_ACCESS_MODE must be "readonly" or "readwrite".',
2431
+ { status: 500 },
2432
+ );
2433
+ }
2434
+ const googleNonce = crypto.randomUUID();
2435
+ await stashPendingAuth(env, googleNonce, authRequest);
2200
2436
  const googleUrl = buildAuthUrl(
2201
2437
  env.GOOGLE_CLIENT_ID,
2202
- redirectUri,
2203
- nonce,
2438
+ googleRedirectUri(request),
2439
+ googleNonce,
2204
2440
  accessMode,
2205
2441
  );
2206
- return Response.redirect(googleUrl, 302);
2442
+ return new Response(null, {
2443
+ status: 302,
2444
+ headers: {
2445
+ location: googleUrl,
2446
+ 'set-cookie': clearConsentCookie(url),
2447
+ },
2448
+ });
2207
2449
  }
2208
2450
 
2209
2451
  if (url.pathname === '/google/callback') {
@@ -2211,14 +2453,7 @@ export const defaultHandler = {
2211
2453
  const state = url.searchParams.get('state');
2212
2454
  const googleError = url.searchParams.get('error');
2213
2455
 
2214
- if (googleError) {
2215
- console.error('Google OAuth returned error', { error: googleError });
2216
- return new Response(`Google OAuth error: ${googleError}`, {
2217
- status: 400,
2218
- });
2219
- }
2220
-
2221
- if (!code || !state) {
2456
+ if (!state || (!code && !googleError)) {
2222
2457
  console.error('Missing code or state on /google/callback', {
2223
2458
  has_code: !!code,
2224
2459
  has_state: !!state,
@@ -2232,6 +2467,27 @@ export const defaultHandler = {
2232
2467
  return new Response('Auth request expired or invalid', { status: 400 });
2233
2468
  }
2234
2469
 
2470
+ const claudeAuthReq = pending.claudeAuthRequest;
2471
+ if (!isParsedAuthorizationRequest(claudeAuthReq)) {
2472
+ console.error('Pending auth had an invalid shape');
2473
+ return new Response('Auth request expired or invalid', { status: 400 });
2474
+ }
2475
+
2476
+ if (googleError) {
2477
+ console.warn('Google OAuth returned error', { error: googleError });
2478
+ return oauthErrorRedirect(
2479
+ claudeAuthReq,
2480
+ googleError === 'access_denied' ? 'access_denied' : 'server_error',
2481
+ googleError === 'access_denied'
2482
+ ? 'Google authorization was denied.'
2483
+ : 'Google authorization failed.',
2484
+ );
2485
+ }
2486
+
2487
+ if (!code) {
2488
+ return new Response('Missing authorization code', { status: 400 });
2489
+ }
2490
+
2235
2491
  const redirectUri = googleRedirectUri(request);
2236
2492
 
2237
2493
  let tokens;
@@ -2269,7 +2525,6 @@ export const defaultHandler = {
2269
2525
  return new Response('Save user failed', { status: 500 });
2270
2526
  }
2271
2527
 
2272
- const claudeAuthReq = pending.claudeAuthRequest as any;
2273
2528
  let redirectTo: string;
2274
2529
  try {
2275
2530
  ({ redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({