@digestseo/mcp-gsc 0.4.22 → 0.4.24

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.24] - 2026-09-23
11
+
12
+ ### Added
13
+ - `indexing.remove` publishes Google's supported `URL_DELETED` notification for previously eligible Indexing API pages after a bounded preflight confirms the URL already returns HTTP 404/410 or exposes a robots `noindex` meta directive. A successful notification is reported only as receipt acknowledgment, never as proof that Google completed removal.
14
+
15
+ ### Changed
16
+ - Search Analytics date inputs now explicitly state Google's Pacific Time calendar (`America/Los_Angeles`, UTC-8/UTC-7 depending on daylight saving time), matching the server's existing Pacific-Time default/date-range calculations and preventing agents from interpreting YYYY-MM-DD inputs in the caller's local timezone or UTC.
17
+
18
+ ## [0.4.23] - 2026-09-22
19
+
20
+ ### Fixed
21
+ - 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.
22
+
10
23
  ## [0.4.22] - 2026-09-22
11
24
 
12
25
  ### Added
package/README.md CHANGED
@@ -26,7 +26,7 @@ The npm launcher starts a loopback-only **Streamable HTTP** Worker on `127.0.0.1
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
 
29
- The local launcher defaults to `GSC_ACCESS_MODE=readwrite`. For least-privilege analytics/reporting-only use, set `GSC_ACCESS_MODE=readonly` in the environment before running `npx`; the launcher forwards that value into the local Worker so OAuth requests only the read-only Search Console scope and the five mutation tools stay unregistered.
29
+ The local launcher defaults to `GSC_ACCESS_MODE=readwrite`. For least-privilege analytics/reporting-only use, set `GSC_ACCESS_MODE=readonly` in the environment before running `npx`; the launcher forwards that value into the local Worker so OAuth requests only the read-only Search Console scope and the six mutation tools stay unregistered.
30
30
 
31
31
  For Google OAuth and Cloudflare deployment configuration, follow [SETUP.md](SETUP.md).
32
32
 
@@ -68,11 +68,11 @@ Start the npm launcher before using either install button. Complete its Google O
68
68
 
69
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.
70
70
 
71
- 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.
72
72
 
73
73
  ## Tools
74
74
 
75
- By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 22 tools. Read-only analytics and reporting tools are marked with MCP's `readOnlyHint`; the write tools below can change Search Console properties, sitemaps, or indexing state. Set `GSC_ACCESS_MODE=readonly` to request only the Search Console read-only scope and expose the 16 tools that do not require either write access or the separate Indexing API scope.
75
+ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 23 tools. Read-only analytics and reporting tools are marked with MCP's `readOnlyHint`; the write tools below can change Search Console properties, sitemaps, or indexing state. Set `GSC_ACCESS_MODE=readonly` to request only the Search Console read-only scope and expose the 16 tools that do not require either write access or the separate Indexing API scope.
76
76
 
77
77
  | Tool | Access | What it does |
78
78
  |---|---|---|
@@ -91,14 +91,17 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 22 tools. Read-onl
91
91
  | **`sitemaps.submit`** / **`sitemaps.delete`** | Write | Submit or remove a sitemap. |
92
92
  | **`indexing.status`** | Read | Read Google's latest successful Indexing API `URL_UPDATED` / `URL_DELETED` notification receipts for a previously submitted URL. This is notification metadata only, not index coverage, crawl status, indexing completion, or removal completion. It requires `GSC_ACCESS_MODE=readwrite` because Google's metadata endpoint uses the Indexing API OAuth scope. |
93
93
  | **`indexing.request`** | Write | Requests indexing through Google's Indexing API. Google currently restricts this API to pages containing `JobPosting` structured data or livestream pages containing `BroadcastEvent` inside `VideoObject`. It is not available for general webpage submission. Google's default 200 publish requests/day/project is onboarding/testing capacity rather than ongoing-use approval; additional approval is required for usage/resource provisioning, and submissions are spam-screened. |
94
+ | **`indexing.remove`** | Write | Request a `URL_DELETED` notification for a previously eligible Indexing API page. Before removal, the URL must already return HTTP 404/410 or expose a robots `noindex` meta directive. Google's receipt does not prove removal completed; use URL Inspection for indexed-state evidence. |
95
+
96
+ Search Analytics calendar dates (`start_date`, `end_date`, and comparison-period dates) use Google's Pacific Time calendar (`America/Los_Angeles`, UTC-8/UTC-7 depending on daylight saving time), not the caller's local timezone or UTC. This also governs the server's generated/default Search Analytics date ranges.
94
97
 
95
98
  > **Platform properties.** Search Console now supports platform properties for social/video accounts such as Instagram, TikTok, X, and YouTube in its UI. Google's current Search Console API documentation still defines `siteUrl` using URL-prefix and `sc-domain:` website-property forms and does not publish a platform-property API identifier contract. `sites.list` therefore preserves every identifier Google returns and marks whether it matches the documented API grammar; the other tools deliberately reject undocumented forms instead of inventing an identifier. Use the Search Console UI/export for a platform property until Google documents API support.
96
99
 
97
- Read-write mode requests the Google Search Console read-write and Indexing API scopes. Read-only mode requests only `https://www.googleapis.com/auth/webmasters.readonly` (plus `openid` and `email`) and does not register the five write tools or the read-only `indexing.status` lookup, because that lookup still requires Google's separate Indexing API scope. Read-write remains the default so existing deployments retain their current behavior; see [SETUP.md](SETUP.md) to select a mode before connecting users.
100
+ Read-write mode requests the Google Search Console read-write and Indexing API scopes. Read-only mode requests only `https://www.googleapis.com/auth/webmasters.readonly` (plus `openid` and `email`) and does not register the six write tools or the read-only `indexing.status` lookup, because that lookup still requires Google's separate Indexing API scope. Read-write remains the default so existing deployments retain their current behavior; see [SETUP.md](SETUP.md) to select a mode before connecting users.
98
101
 
99
102
  Large analytics responses are bounded at the MCP boundary instead of being generated and then discarded by clients with structured-content limits. List-style tools expose `start_row` / `next_start_row` where applicable; ranked analytical tools expose `limit`, `start_row`, and `result_page` metadata including `has_more`, `truncated`, and `byte_limit_reached`. Continue paging while `has_more` is true rather than assuming one response is complete.
100
103
 
101
- > **`indexing.request` eligibility and provider approval.** Google's Indexing API is not a general-purpose page submission tool — as of this writing, Google's own documentation limits it to two content types: pages with `JobPosting` structured data, and livestream pages with `BroadcastEvent` structured data nested inside `VideoObject`. Before submitting, the server fetches the target URL and checks static JSON-LD, Microdata, and RDFa markup for one of those two types; if neither is present (or the page can't be fetched), it returns an error explaining why the URL is ineligible instead of calling the Indexing API. Structured data injected only after client-side JavaScript runs cannot be confirmed by this bounded preflight and therefore fails closed. Google's default 200 publish requests/day/project is for onboarding and submission testing; using the API beyond that onboarding/testing context requires Google's usage/resource-provisioning approval. Google also subjects every submission to spam detection and warns that abuse or attempts to exceed quotas through multiple accounts or other means can revoke access. A successful submission is only an acknowledgment that Google received the notification — it does not guarantee the URL will be indexed.
104
+ > **Indexing API eligibility and provider approval.** Google's Indexing API is not a general-purpose page submission tool — as of this writing, Google's own documentation limits it to two content types: pages with `JobPosting` structured data, and livestream pages with `BroadcastEvent` structured data nested inside `VideoObject`. Before `indexing.request`, the server fetches the target URL and checks static JSON-LD, Microdata, and RDFa markup for one of those two types; if neither is present (or the page can't be fetched), it returns an error explaining why the URL is ineligible instead of calling the Indexing API. Structured data injected only after client-side JavaScript runs cannot be confirmed by this bounded preflight and therefore fails closed. Before `indexing.remove`, the server instead confirms Google's removal precondition: the URL must already return HTTP 404/410 or expose a robots `noindex` meta directive. Update and removal notifications share the same publish quota. Google's default 200 publish requests/day/project is for onboarding and submission testing; using the API beyond that onboarding/testing context requires Google's usage/resource-provisioning approval. Google also subjects every submission to spam detection and warns that abuse or attempts to exceed quotas through multiple accounts or other means can revoke access. A successful notification is only an acknowledgment that Google received it — it does not guarantee indexing or removal completed.
102
105
 
103
106
  ## What you can ask
104
107
 
package/SECURITY.md CHANGED
@@ -32,9 +32,10 @@ Notes:
32
32
  - With `GSC_ACCESS_MODE=readonly`, the server requests only `openid`, `email`,
33
33
  and `https://www.googleapis.com/auth/webmasters.readonly`. It does not
34
34
  register `sites.add`, `sites.delete`, `sitemaps.submit`, `sitemaps.delete`,
35
- or `indexing.request`; the remaining tool suite uses APIs that accept the
35
+ `indexing.request`, or `indexing.remove`; the remaining tool suite uses APIs that accept the
36
36
  read-only Search Console scope.
37
- - The Indexing API is requested only in read-write mode for `indexing.request`.
37
+ - The Indexing API is requested only in read-write mode for `indexing.request`,
38
+ `indexing.remove`, and the otherwise read-only `indexing.status` lookup.
38
39
  Google restricts that API to `JobPosting` pages or livestream pages with a
39
40
  `BroadcastEvent` inside a `VideoObject`; it is not a general page-submission
40
41
  API.
@@ -60,7 +61,7 @@ delay and does not disclose another user's usage.
60
61
  | Search Analytics | `analytics.query`, `insights.quick_wins`, `insights.cannibalization`, `insights.content_decay`, `indexing.list_pages`, `analytics.compare` | 30 per user / 10 minutes |
61
62
  | URL inspection | `urls.inspect`, `urls.inspect_many` | 20 inspected URLs per user / 24 hours; batch requests reserve one unit per URL and run at most 3 inspections concurrently |
62
63
  | Search Console writes | `sites.add`, `sites.delete`, `sitemaps.submit`, `sitemaps.delete` | 10 per user / hour |
63
- | Indexing publish | `indexing.request` | 2 per user / 24 hours; 200 per deployment / 24 hours |
64
+ | Indexing publish | `indexing.request`, `indexing.remove` | 2 per user / 24 hours; 200 per deployment / 24 hours |
64
65
  | Indexing metadata | `indexing.status` | 30 per user / minute; 180 per deployment / minute |
65
66
  | Weekly digest | `reports.weekly_digest` | 6 per user / hour |
66
67
 
package/SETUP.md CHANGED
@@ -61,7 +61,7 @@ npx wrangler login
61
61
 
62
62
  `readwrite` preserves the full tool surface: it grants Search Console read-write access and the Indexing API scope required to manage sites, sitemaps, and request URL crawling. `readonly` requests only the Search Console read-only scope and omits the mutation tools.
63
63
 
64
- Note that the Indexing API itself is narrow: Google currently restricts it to pages containing `JobPosting` structured data or livestream pages containing `BroadcastEvent` inside `VideoObject`. It is not available for general webpage submission — the `indexing.request` tool checks a page's structured data before submitting and returns an error for ineligible URLs. Google's default **200 publish requests/day/project** is explicitly for onboarding and submission testing; ongoing usage/resource provisioning requires additional Google approval. All submissions are subject to spam detection, and Google warns that abuse or attempts to exceed quotas through multiple accounts or other means can result in revoked access. The server's own lower safety limits do not constitute Google approval or increase provider quota.
64
+ Note that the Indexing API itself is narrow: Google currently restricts it to pages containing `JobPosting` structured data or livestream pages containing `BroadcastEvent` inside `VideoObject`. It is not available for general webpage submission - the `indexing.request` tool checks a page's structured data before submitting and returns an error for ineligible URLs. `indexing.remove` is for previously eligible pages and requires the URL to already return HTTP 404/410 or expose a robots `noindex` meta directive before it sends Google's `URL_DELETED` notification. Google's default **200 publish requests/day/project** is shared by update and removal notifications and is explicitly for onboarding and submission testing; ongoing usage/resource provisioning requires additional Google approval. All submissions are subject to spam detection, and Google warns that abuse or attempts to exceed quotas through multiple accounts or other means can result in revoked access. The server's own lower safety limits do not constitute Google approval or increase provider quota.
65
65
  5. Save the Data Access changes. In **Google Auth platform → Audience**, leave **Publishing status** as **Testing** for now — see [Step 7](#step-7--important-google-verification).
66
66
 
67
67
  ---
@@ -154,7 +154,7 @@ The template also sets `GSC_ACCESS_MODE` to `readwrite`, preserving the historic
154
154
  }
155
155
  ```
156
156
 
157
- Read-only deployments request `webmasters.readonly`, omit the Indexing API scope, and do not register `sites.add`, `sites.delete`, `sitemaps.submit`, `sitemaps.delete`, `indexing.request`, or the otherwise read-only `indexing.status` lookup. Changing modes affects OAuth grants for future connections; reconnect users after changing the mode so Google grants the matching scope set.
157
+ Read-only deployments request `webmasters.readonly`, omit the Indexing API scope, and do not register `sites.add`, `sites.delete`, `sitemaps.submit`, `sitemaps.delete`, `indexing.request`, `indexing.remove`, or the otherwise read-only `indexing.status` lookup. Changing modes affects OAuth grants for future connections; reconnect users after changing the mode so Google grants the matching scope set.
158
158
 
159
159
  `wrangler.jsonc` is gitignored because it contains your account's namespace ids. The `wrangler.example.jsonc` template stays in git.
160
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.22",
3
+ "version": "0.4.24",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
@@ -23,6 +23,7 @@ export const WRITE_TOOL_NAMES = [
23
23
  'sitemaps.submit',
24
24
  'sitemaps.delete',
25
25
  'indexing.request',
26
+ 'indexing.remove',
26
27
  ] as const;
27
28
 
28
29
  const INDEXING_SCOPE_READ_TOOL_NAMES = ['indexing.status'] as const;
@@ -1,7 +1,13 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  const YYYY_MM_DD = /^(\d{4})-(\d{2})-(\d{2})$/;
4
- const SEARCH_CONSOLE_TIME_ZONE = 'America/Los_Angeles';
4
+ export const SEARCH_CONSOLE_TIME_ZONE = 'America/Los_Angeles';
5
+ export const SEARCH_CONSOLE_DATE_TIME_ZONE_NOTE =
6
+ 'Google Search Console interprets this calendar date in Pacific Time (America/Los_Angeles; UTC-8/UTC-7 depending on daylight saving time).';
7
+
8
+ export function searchConsoleDateDescription(base: string): string {
9
+ return `${base} ${SEARCH_CONSOLE_DATE_TIME_ZONE_NOTE}`;
10
+ }
5
11
 
6
12
  export function getSearchConsoleCalendarDate(now: Date = new Date()): string {
7
13
  const parts = new Intl.DateTimeFormat('en-US', {
package/src/google.ts CHANGED
@@ -1458,15 +1458,124 @@ export async function checkIndexingEligibility(
1458
1458
  };
1459
1459
  }
1460
1460
 
1461
- export async function requestIndexing(
1462
- accessToken: string,
1461
+ function hasRobotsNoindexMeta(html: string): boolean {
1462
+ // HTML permits an omitted </head>, so stop at </head>, <body>, or EOF.
1463
+ const head = /<head\b[^>]*>([\s\S]*?)(?:<\/head\s*>|<body\b|$)/i.exec(html)?.[1];
1464
+ if (!head) return false;
1465
+
1466
+ const metaPattern = /<meta\b([^>]*)>/gi;
1467
+ let match: RegExpExecArray | null;
1468
+ while ((match = metaPattern.exec(head)) !== null) {
1469
+ const name = htmlAttribute(match[1], 'name')?.trim().toLowerCase();
1470
+ if (name !== 'robots') continue;
1471
+ const content = htmlAttribute(match[1], 'content')?.trim().toLowerCase();
1472
+ if (!content) continue;
1473
+ const directives = content.split(/[\s,]+/).filter(Boolean);
1474
+ if (directives.includes('noindex')) return true;
1475
+ }
1476
+
1477
+ return false;
1478
+ }
1479
+
1480
+ const INDEXING_REMOVAL_REQUIREMENT_MESSAGE =
1481
+ 'Before requesting removal, Google requires the URL to return HTTP 404 or 410, or the page to contain a robots noindex meta directive.';
1482
+
1483
+ /**
1484
+ * Verifies Google's documented precondition for an Indexing API URL_DELETED
1485
+ * notification without requiring the page to keep its former structured data.
1486
+ */
1487
+ export async function checkIndexingRemovalEligibility(
1463
1488
  url: string,
1464
- ): Promise<unknown> {
1465
- const eligibility = await checkIndexingEligibility(url);
1466
- if (!eligibility.eligible) {
1467
- throw new Error(eligibility.reason);
1489
+ { timeoutMs = INDEXING_ELIGIBILITY_TIMEOUT_MS }: IndexingEligibilityOptions = {},
1490
+ ): Promise<IndexingEligibility> {
1491
+ if (!isHttpUrl(url)) {
1492
+ return {
1493
+ eligible: false,
1494
+ reason: `${url} must be a valid HTTP or HTTPS URL. ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1495
+ };
1468
1496
  }
1469
1497
 
1498
+ const controller = new AbortController();
1499
+ let timedOut = false;
1500
+ const timeout = setTimeout(() => {
1501
+ timedOut = true;
1502
+ controller.abort();
1503
+ }, timeoutMs);
1504
+
1505
+ try {
1506
+ const resp = await fetch(url, {
1507
+ signal: controller.signal,
1508
+ redirect: 'manual',
1509
+ headers: {
1510
+ 'user-agent':
1511
+ 'Mozilla/5.0 (compatible; DigestSEO-GSC-MCP/1.0; +https://github.com/AKzar1el/mcp-gsc)',
1512
+ },
1513
+ });
1514
+
1515
+ if (resp.status === 404 || resp.status === 410) {
1516
+ await cancelResponseBody(resp);
1517
+ return { eligible: true };
1518
+ }
1519
+
1520
+ if (!resp.ok) {
1521
+ await cancelResponseBody(resp);
1522
+ return {
1523
+ eligible: false,
1524
+ reason: `Could not confirm indexing-removal eligibility for ${url} (HTTP ${resp.status}). ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1525
+ };
1526
+ }
1527
+
1528
+ const contentType = resp.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
1529
+ if (
1530
+ contentType &&
1531
+ contentType !== 'text/html' &&
1532
+ contentType !== 'application/xhtml+xml'
1533
+ ) {
1534
+ await cancelResponseBody(resp);
1535
+ return {
1536
+ eligible: false,
1537
+ reason: `Could not confirm indexing-removal eligibility for ${url} (expected HTML, received ${contentType}). ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1538
+ };
1539
+ }
1540
+
1541
+ const contentLength = Number(resp.headers.get('content-length'));
1542
+ if (
1543
+ Number.isFinite(contentLength) &&
1544
+ contentLength > INDEXING_ELIGIBILITY_MAX_RESPONSE_BYTES
1545
+ ) {
1546
+ await cancelResponseBody(resp);
1547
+ return {
1548
+ eligible: false,
1549
+ reason: `Could not confirm indexing-removal eligibility for ${url} (response exceeds the ${INDEXING_ELIGIBILITY_MAX_RESPONSE_BYTES}-byte limit). ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1550
+ };
1551
+ }
1552
+
1553
+ const html = await readBoundedResponseText(resp);
1554
+ if (hasRobotsNoindexMeta(html)) return { eligible: true };
1555
+
1556
+ return {
1557
+ eligible: false,
1558
+ reason: `${url} does not currently return HTTP 404/410 and does not expose a robots noindex meta directive. ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1559
+ };
1560
+ } catch (err) {
1561
+ const detail = timedOut
1562
+ ? `timed out after ${timeoutMs}ms`
1563
+ : (err as Error).message;
1564
+ return {
1565
+ eligible: false,
1566
+ reason: `Could not confirm indexing-removal eligibility for ${url} (${detail}). ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1567
+ };
1568
+ } finally {
1569
+ clearTimeout(timeout);
1570
+ }
1571
+ }
1572
+
1573
+ async function publishIndexingNotification(
1574
+ accessToken: string,
1575
+ url: string,
1576
+ type: 'URL_UPDATED' | 'URL_DELETED',
1577
+ actionLabel: string,
1578
+ ): Promise<unknown> {
1470
1579
  const resp = await fetch(
1471
1580
  'https://indexing.googleapis.com/v3/urlNotifications:publish',
1472
1581
  {
@@ -1475,10 +1584,7 @@ export async function requestIndexing(
1475
1584
  authorization: `Bearer ${accessToken}`,
1476
1585
  'content-type': 'application/json',
1477
1586
  },
1478
- body: JSON.stringify({
1479
- url,
1480
- type: 'URL_UPDATED',
1481
- }),
1587
+ body: JSON.stringify({ url, type }),
1482
1588
  },
1483
1589
  );
1484
1590
  if (resp.status === 401) {
@@ -1486,11 +1592,38 @@ export async function requestIndexing(
1486
1592
  }
1487
1593
  if (!resp.ok) {
1488
1594
  const text = await resp.text();
1489
- throw new Error(`Request indexing failed: ${resp.status} ${text}`);
1595
+ throw new Error(`${actionLabel} failed: ${resp.status} ${text}`);
1490
1596
  }
1491
1597
  return await resp.json();
1492
1598
  }
1493
1599
 
1600
+ export async function requestIndexing(
1601
+ accessToken: string,
1602
+ url: string,
1603
+ ): Promise<unknown> {
1604
+ const eligibility = await checkIndexingEligibility(url);
1605
+ if (!eligibility.eligible) {
1606
+ throw new Error(eligibility.reason);
1607
+ }
1608
+ return publishIndexingNotification(accessToken, url, 'URL_UPDATED', 'Request indexing');
1609
+ }
1610
+
1611
+ export async function requestIndexingRemoval(
1612
+ accessToken: string,
1613
+ url: string,
1614
+ ): Promise<unknown> {
1615
+ const eligibility = await checkIndexingRemovalEligibility(url);
1616
+ if (!eligibility.eligible) {
1617
+ throw new Error(eligibility.reason);
1618
+ }
1619
+ return publishIndexingNotification(
1620
+ accessToken,
1621
+ url,
1622
+ 'URL_DELETED',
1623
+ 'Request indexing removal',
1624
+ );
1625
+ }
1626
+
1494
1627
  export interface IndexingNotification {
1495
1628
  url?: string;
1496
1629
  type?: 'URL_UPDATED' | 'URL_DELETED' | string;
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';
@@ -28,6 +28,7 @@ import {
28
28
  processCannibalization,
29
29
  processContentDecay,
30
30
  requestIndexing,
31
+ requestIndexingRemoval,
31
32
  getIndexingNotificationMetadata,
32
33
  processPerformanceComparison,
33
34
  type PaginatedSearchAnalyticsResult,
@@ -58,6 +59,7 @@ import {
58
59
  assertDateRange,
59
60
  getSearchConsoleCalendarDate,
60
61
  SEARCH_CONSOLE_DATE_SCHEMA,
62
+ searchConsoleDateDescription,
61
63
  } from './date-validation';
62
64
  import { CONTENT_DECAY_COMPARE_DAYS_SCHEMA } from './content-decay-schema';
63
65
  import { resolveIndexedPagesDateRange } from './indexed-pages-range';
@@ -71,6 +73,7 @@ import {
71
73
  import { WRITE_TOOL_ANNOTATIONS } from './write-tool-annotations';
72
74
  import {
73
75
  getToolCatalogForAccessMode,
76
+ getGoogleOAuthScopes,
74
77
  resolveGscAccessMode,
75
78
  type GscAccessMode,
76
79
  } from './access-mode';
@@ -472,6 +475,11 @@ const INDEXING_OUTPUT_SCHEMA = {
472
475
  provider_usage_note: z.string(),
473
476
  };
474
477
 
478
+ const INDEXING_REMOVE_OUTPUT_SCHEMA = {
479
+ ...INDEXING_OUTPUT_SCHEMA,
480
+ removal_completion_proven: z.literal(false),
481
+ };
482
+
475
483
  const INDEXING_NOTIFICATION_SCHEMA = z.object({
476
484
  url: z.string().optional(),
477
485
  type: z.string().optional(),
@@ -708,6 +716,11 @@ const TOOL_CATALOG = [
708
716
  description:
709
717
  'Request an eligible JobPosting or livestream URL update through Google\'s restricted Indexing API; this is not a general webpage submission tool. Google treats the default publish quota as onboarding/testing capacity, requires approval for ongoing usage/resource provisioning, and spam-screens submissions.',
710
718
  },
719
+ {
720
+ name: 'indexing.remove',
721
+ description:
722
+ "Request a URL_DELETED notification for a previously eligible JobPosting or livestream URL through Google's restricted Indexing API. The URL must already return HTTP 404/410 or expose a robots noindex meta directive; notification receipt does not prove removal completed.",
723
+ },
711
724
  {
712
725
  name: 'indexing.status',
713
726
  description:
@@ -1124,9 +1137,13 @@ class GscMcpRuntime {
1124
1137
  ].join('\n'),
1125
1138
  inputSchema: {
1126
1139
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1127
- start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1140
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1141
+ searchConsoleDateDescription('Start date (inclusive) in YYYY-MM-DD format.'),
1142
+ ),
1128
1143
  end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1129
- 'End date (inclusive) in YYYY-MM-DD format. Recent dates are allowed: data_state all/hourly_all can include preliminary data, while final returns only finalized data.',
1144
+ searchConsoleDateDescription(
1145
+ 'End date (inclusive) in YYYY-MM-DD format. Recent dates are allowed: data_state all/hourly_all can include preliminary data, while final returns only finalized data.',
1146
+ ),
1130
1147
  ),
1131
1148
  dimensions: z
1132
1149
  .array(
@@ -1295,8 +1312,14 @@ class GscMcpRuntime {
1295
1312
  inputSchema: {
1296
1313
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1297
1314
  page_url: z.string().url().describe('Exact fully-qualified page URL to filter on, e.g. https://example.com/guides/seo/. Search Console exact page filters are case-sensitive.'),
1298
- start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1299
- end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1315
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1316
+ searchConsoleDateDescription('Start date (inclusive) in YYYY-MM-DD format.'),
1317
+ ),
1318
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1319
+ searchConsoleDateDescription(
1320
+ 'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.',
1321
+ ),
1322
+ ),
1300
1323
  search_type: z
1301
1324
  .enum(['web', 'image', 'video', 'news'])
1302
1325
  .default('web')
@@ -1373,8 +1396,14 @@ class GscMcpRuntime {
1373
1396
  inputSchema: {
1374
1397
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1375
1398
  query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
1376
- start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1377
- end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1399
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1400
+ searchConsoleDateDescription('Start date (inclusive) in YYYY-MM-DD format.'),
1401
+ ),
1402
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1403
+ searchConsoleDateDescription(
1404
+ 'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.',
1405
+ ),
1406
+ ),
1378
1407
  search_type: z
1379
1408
  .enum(['web', 'image', 'video', 'news'])
1380
1409
  .default('web')
@@ -1607,8 +1636,14 @@ class GscMcpRuntime {
1607
1636
  description: 'Analyze query/page Search Analytics to find queries split across multiple pages. Candidates are ranked deterministically by the observed query/page impression sum, each candidate bounds its page list, and limit/start_row plus result_page provide safe pagination. total_clicks, total_impressions, and impression_share are calculated from observed query/page rows and are not true query-level property aggregates; multiple pages can make that row sum exceed the query-level Search Console total. Multiple ranking URLs can also reflect legitimate locale or intent differences, so treat candidates as evidence to investigate rather than proof of harmful cannibalization.',
1608
1637
  inputSchema: {
1609
1638
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1610
- start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1611
- end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1639
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1640
+ searchConsoleDateDescription('Start date (inclusive) in YYYY-MM-DD format.'),
1641
+ ),
1642
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1643
+ searchConsoleDateDescription(
1644
+ 'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.',
1645
+ ),
1646
+ ),
1612
1647
  min_impressions: CANNIBALIZATION_MIN_IMPRESSIONS_SCHEMA,
1613
1648
  min_page_percentage: CANNIBALIZATION_MIN_PAGE_PERCENTAGE_SCHEMA,
1614
1649
  limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
@@ -1817,6 +1852,48 @@ class GscMcpRuntime {
1817
1852
  return toolResponse(JSON.stringify(payload, null, 2), payload);
1818
1853
  },
1819
1854
  );
1855
+
1856
+ this.server.registerTool(
1857
+ 'indexing.remove',
1858
+ {
1859
+ title: 'Request Indexing API removal',
1860
+ description: "Requests a URL_DELETED notification through Google's Indexing API for a URL within an owner-level Search Console property accessible to the connected Google account. Use only for pages that were eligible for the restricted Indexing API (JobPosting or livestream BroadcastEvent in VideoObject). Before removal Google requires the URL to return HTTP 404/410 or contain a robots noindex meta directive. A successful notification is only receipt acknowledgment and does not prove Google removed the URL from its index.",
1861
+ inputSchema: {
1862
+ site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA.describe(`${SEARCH_CONSOLE_PROPERTY_DESCRIPTION} The connected Google account must be an owner of this property.`),
1863
+ url: z.string().superRefine((value, ctx) => {
1864
+ try {
1865
+ assertIndexingRequestUrl(value);
1866
+ } catch (error) {
1867
+ ctx.addIssue({
1868
+ code: 'custom',
1869
+ message: (error as Error).message,
1870
+ });
1871
+ }
1872
+ }).describe('The fully qualified HTTP/HTTPS URL to request removal for. It must fall within site_url, must have been eligible for the restricted Indexing API, and must currently return HTTP 404/410 or contain a robots noindex meta directive.'),
1873
+ },
1874
+ outputSchema: INDEXING_REMOVE_OUTPUT_SCHEMA,
1875
+ annotations: WRITE_TOOL_ANNOTATIONS['indexing.remove'],
1876
+ },
1877
+ async ({ site_url, url }) => {
1878
+ const googleId = this.requireGoogleId();
1879
+ const rateLimitError = await this.rateLimitError(googleId, 'indexing.remove');
1880
+ if (rateLimitError) return rateLimitError;
1881
+ const accessToken = await this.getAccessToken(googleId);
1882
+ const sites = await listSites(accessToken);
1883
+ assertIndexingUrlAuthorized(url, site_url, sites);
1884
+ const result = await requestIndexingRemoval(accessToken, url);
1885
+ const payload = {
1886
+ result,
1887
+ note: "Google accepting this URL_DELETED notification only confirms receipt. It does not prove the URL was removed from Google's index; use URL Inspection for indexed-state evidence.",
1888
+ provider_default_quota_for_testing_only: true as const,
1889
+ provider_usage_approval_required: true as const,
1890
+ provider_spam_detection_applies: true as const,
1891
+ provider_usage_note: INDEXING_PROVIDER_USAGE_NOTE,
1892
+ removal_completion_proven: false as const,
1893
+ };
1894
+ return toolResponse(JSON.stringify(payload, null, 2), payload);
1895
+ },
1896
+ );
1820
1897
  }
1821
1898
 
1822
1899
  this.server.registerTool(
@@ -1826,8 +1903,16 @@ class GscMcpRuntime {
1826
1903
  description: `${SEARCH_VISIBLE_PAGES_DESCRIPTION} When one date boundary is omitted, the server derives the other to target an inclusive 30-day range; generated end dates are capped at the latest complete date. Responses are bounded and pageable with row_limit/start_row.`,
1827
1904
  inputSchema: {
1828
1905
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1829
- start_date: SEARCH_CONSOLE_DATE_SCHEMA.optional().describe('Start date (inclusive) in YYYY-MM-DD format. If end_date is omitted, the generated end date is 29 days later, capped at the latest complete date.'),
1830
- end_date: SEARCH_CONSOLE_DATE_SCHEMA.optional().describe('End date (inclusive) in YYYY-MM-DD format. If start_date is omitted, the generated start date is 29 days earlier. Defaults to 3 days ago. Note the 2-3 day data lag.'),
1906
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.optional().describe(
1907
+ searchConsoleDateDescription(
1908
+ 'Start date (inclusive) in YYYY-MM-DD format. If end_date is omitted, the generated end date is 29 days later, capped at the latest complete date.',
1909
+ ),
1910
+ ),
1911
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.optional().describe(
1912
+ searchConsoleDateDescription(
1913
+ 'End date (inclusive) in YYYY-MM-DD format. If start_date is omitted, the generated start date is 29 days earlier. Defaults to 3 days ago. Note the 2-3 day data lag.',
1914
+ ),
1915
+ ),
1831
1916
  row_limit: z.number().int().min(1).max(25000).default(1000).describe(`Maximum pages requested for this logical response (1-25000). Output is safely bounded; continue with result_page.next_start_row while result_page.has_more is true.`),
1832
1917
  start_row: z.number().int().min(0).default(0).describe('Zero-based page-row offset for pagination.'),
1833
1918
  },
@@ -1886,10 +1971,18 @@ class GscMcpRuntime {
1886
1971
  description: 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension (query, page, country, device). Only dimension keys returned in both Search Analytics period responses are compared; a key missing from one response is not treated as zero because Google does not guarantee every data row. Apply the same search type and optional dimension filters to both periods. Results are ranked by largest absolute click change, then impression change, and safely paged with limit/start_row. Percentage change is null when an explicitly returned baseline row has zero and the comparison value differs. Discover and Google News are intentionally unavailable because this comparison contract includes average position, which those reports do not support.',
1887
1972
  inputSchema: {
1888
1973
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1889
- start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
1890
- end_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period A (recent, YYYY-MM-DD)'),
1891
- start_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period B (previous, YYYY-MM-DD)'),
1892
- end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period B (previous, YYYY-MM-DD)'),
1974
+ start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1975
+ searchConsoleDateDescription('Start date of Period A (recent, YYYY-MM-DD).'),
1976
+ ),
1977
+ end_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1978
+ searchConsoleDateDescription('End date of Period A (recent, YYYY-MM-DD).'),
1979
+ ),
1980
+ start_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1981
+ searchConsoleDateDescription('Start date of Period B (previous, YYYY-MM-DD).'),
1982
+ ),
1983
+ end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1984
+ searchConsoleDateDescription('End date of Period B (previous, YYYY-MM-DD).'),
1985
+ ),
1893
1986
  dimension: z.enum(['query', 'page', 'country', 'device']).default('query').describe('The dimension to compare performance for. Defaults to query.'),
1894
1987
  search_type: z
1895
1988
  .enum(['web', 'image', 'video', 'news'])
@@ -1996,7 +2089,11 @@ class GscMcpRuntime {
1996
2089
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1997
2090
  end_date: SEARCH_CONSOLE_DATE_SCHEMA
1998
2091
  .optional()
1999
- .describe('End date (inclusive) in YYYY-MM-DD format. Defaults to 3 days ago, which is usually the latest complete Search Console date. Pass a more recent date explicitly to include preliminary data.'),
2092
+ .describe(
2093
+ searchConsoleDateDescription(
2094
+ 'End date (inclusive) in YYYY-MM-DD format. Defaults to 3 days ago, which is usually the latest complete Search Console date. Pass a more recent date explicitly to include preliminary data.',
2095
+ ),
2096
+ ),
2000
2097
  },
2001
2098
  outputSchema: WEEKLY_DIGEST_OUTPUT_SCHEMA,
2002
2099
  annotations: READ_ONLY_ANNOTATIONS,
@@ -2134,6 +2231,154 @@ export class PendingAuthState extends DurableObject<Env> {
2134
2231
  }
2135
2232
  }
2136
2233
 
2234
+ const CONSENT_COOKIE_HTTPS = '__Host-MCP_GSC_CONSENT';
2235
+ const CONSENT_COOKIE_LOOPBACK = 'MCP_GSC_CONSENT';
2236
+
2237
+ interface PendingConsentPayload {
2238
+ kind: 'mcp-gsc-consent';
2239
+ authRequest: AuthRequest;
2240
+ }
2241
+
2242
+ function isLoopbackHttp(url: URL): boolean {
2243
+ return (
2244
+ url.protocol === 'http:' &&
2245
+ (url.hostname === '127.0.0.1' ||
2246
+ url.hostname === 'localhost' ||
2247
+ url.hostname === '[::1]')
2248
+ );
2249
+ }
2250
+
2251
+ function consentCookieName(url: URL): string {
2252
+ return isLoopbackHttp(url) ? CONSENT_COOKIE_LOOPBACK : CONSENT_COOKIE_HTTPS;
2253
+ }
2254
+
2255
+ function consentCookie(url: URL, value: string, maxAgeSeconds = 600): string {
2256
+ const secure = isLoopbackHttp(url) ? '' : '; Secure';
2257
+ return `${consentCookieName(url)}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}${secure}`;
2258
+ }
2259
+
2260
+ function clearConsentCookie(url: URL): string {
2261
+ return consentCookie(url, '', 0);
2262
+ }
2263
+
2264
+ function getCookie(request: Request, name: string): string | null {
2265
+ const cookie = request.headers.get('cookie');
2266
+ if (!cookie) return null;
2267
+ for (const entry of cookie.split(';')) {
2268
+ const separator = entry.indexOf('=');
2269
+ if (separator < 0) continue;
2270
+ if (entry.slice(0, separator).trim() !== name) continue;
2271
+ try {
2272
+ return decodeURIComponent(entry.slice(separator + 1).trim());
2273
+ } catch {
2274
+ return null;
2275
+ }
2276
+ }
2277
+ return null;
2278
+ }
2279
+
2280
+ async function constantTimeStringEqual(left: string, right: string): Promise<boolean> {
2281
+ const encoder = new TextEncoder();
2282
+ const [leftHash, rightHash] = await Promise.all([
2283
+ crypto.subtle.digest('SHA-256', encoder.encode(left)),
2284
+ crypto.subtle.digest('SHA-256', encoder.encode(right)),
2285
+ ]);
2286
+ const leftBytes = new Uint8Array(leftHash);
2287
+ const rightBytes = new Uint8Array(rightHash);
2288
+ let difference = 0;
2289
+ for (let index = 0; index < leftBytes.length; index += 1) {
2290
+ difference |= leftBytes[index] ^ rightBytes[index];
2291
+ }
2292
+ return difference === 0;
2293
+ }
2294
+
2295
+ function escapeHtml(value: string): string {
2296
+ return value
2297
+ .replace(/&/g, '&amp;')
2298
+ .replace(/</g, '&lt;')
2299
+ .replace(/>/g, '&gt;')
2300
+ .replace(/"/g, '&quot;')
2301
+ .replace(/'/g, '&#039;');
2302
+ }
2303
+
2304
+ function isParsedAuthorizationRequest(value: unknown): value is AuthRequest {
2305
+ if (!value || typeof value !== 'object') return false;
2306
+ const candidate = value as Partial<AuthRequest>;
2307
+ return (
2308
+ typeof candidate.responseType === 'string' &&
2309
+ typeof candidate.clientId === 'string' &&
2310
+ typeof candidate.redirectUri === 'string' &&
2311
+ Array.isArray(candidate.scope) &&
2312
+ candidate.scope.every((scope) => typeof scope === 'string') &&
2313
+ typeof candidate.state === 'string'
2314
+ );
2315
+ }
2316
+
2317
+ function isPendingConsentPayload(value: unknown): value is PendingConsentPayload {
2318
+ if (!value || typeof value !== 'object') return false;
2319
+ const candidate = value as Partial<PendingConsentPayload>;
2320
+ return candidate.kind === 'mcp-gsc-consent' && isParsedAuthorizationRequest(candidate.authRequest);
2321
+ }
2322
+
2323
+ function oauthErrorRedirect(
2324
+ authRequest: AuthRequest,
2325
+ code: 'access_denied' | 'server_error',
2326
+ description?: string,
2327
+ ): Response {
2328
+ const redirect = new URL(authRequest.redirectUri);
2329
+ redirect.searchParams.set('error', code);
2330
+ if (description) redirect.searchParams.set('error_description', description);
2331
+ redirect.searchParams.set('state', authRequest.state);
2332
+ if (authRequest.issuer) redirect.searchParams.set('iss', authRequest.issuer);
2333
+ return new Response(null, {
2334
+ status: 302,
2335
+ headers: { location: redirect.toString() },
2336
+ });
2337
+ }
2338
+
2339
+ function consentHtml(input: {
2340
+ clientName: string;
2341
+ clientId: string;
2342
+ consentNonce: string;
2343
+ mcpScopes: readonly string[];
2344
+ googleScopes: readonly string[];
2345
+ accessMode: GscAccessMode;
2346
+ }): string {
2347
+ const list = (values: readonly string[]) =>
2348
+ values.length > 0
2349
+ ? values.map((value) => `<li><code>${escapeHtml(value)}</code></li>`).join('')
2350
+ : '<li><em>No additional scopes requested</em></li>';
2351
+ return `<!doctype html>
2352
+ <html lang="en">
2353
+ <head>
2354
+ <meta charset="utf-8">
2355
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2356
+ <title>Authorize Google Search Console MCP</title>
2357
+ <style>
2358
+ 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}
2359
+ </style>
2360
+ </head>
2361
+ <body>
2362
+ <main>
2363
+ <h1>Authorize Google Search Console access</h1>
2364
+ <p><strong>${escapeHtml(input.clientName)}</strong> is requesting access through mcp-gsc.</p>
2365
+ <p class="muted">Client ID: <code>${escapeHtml(input.clientId)}</code></p>
2366
+ <h2>MCP scopes requested</h2>
2367
+ <ul>${list(input.mcpScopes)}</ul>
2368
+ <h2>Google scopes mcp-gsc will request</h2>
2369
+ <p>Configured access mode: <strong>${escapeHtml(input.accessMode)}</strong></p>
2370
+ <ul>${list(input.googleScopes)}</ul>
2371
+ <p>Continue only if you recognize and trust the requesting MCP client.</p>
2372
+ <form method="post" action="/authorize">
2373
+ <input type="hidden" name="consent_nonce" value="${escapeHtml(input.consentNonce)}">
2374
+ <button type="submit" name="decision" value="allow">Allow and continue to Google</button>
2375
+ <button type="submit" name="decision" value="deny">Deny</button>
2376
+ </form>
2377
+ </main>
2378
+ </body>
2379
+ </html>`;
2380
+ }
2381
+
2137
2382
  function googleRedirectUri(request: Request): string {
2138
2383
  return new URL('/google/callback', request.url).toString();
2139
2384
  }
@@ -2169,7 +2414,7 @@ export const defaultHandler = {
2169
2414
  });
2170
2415
  }
2171
2416
 
2172
- if (url.pathname === '/authorize') {
2417
+ if (url.pathname === '/authorize' && request.method === 'GET') {
2173
2418
  let claudeAuthRequest;
2174
2419
  try {
2175
2420
  claudeAuthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
@@ -2185,6 +2430,9 @@ export const defaultHandler = {
2185
2430
  },
2186
2431
  );
2187
2432
  }
2433
+ if (!isParsedAuthorizationRequest(claudeAuthRequest)) {
2434
+ return new Response('Invalid OAuth authorization request.', { status: 400 });
2435
+ }
2188
2436
  let accessMode: GscAccessMode;
2189
2437
  try {
2190
2438
  accessMode = resolveGscAccessMode(env.GSC_ACCESS_MODE);
@@ -2194,16 +2442,106 @@ export const defaultHandler = {
2194
2442
  { status: 500 },
2195
2443
  );
2196
2444
  }
2197
- const nonce = crypto.randomUUID();
2198
- await stashPendingAuth(env, nonce, claudeAuthRequest);
2199
- const redirectUri = googleRedirectUri(request);
2445
+ let client;
2446
+ try {
2447
+ client = await env.OAUTH_PROVIDER.lookupClient(claudeAuthRequest.clientId);
2448
+ } catch (err) {
2449
+ console.warn('OAuth client lookup failed', {
2450
+ message: (err as Error).message,
2451
+ });
2452
+ return new Response('Could not verify the requesting OAuth client.', { status: 400 });
2453
+ }
2454
+ if (!client) {
2455
+ return new Response('Unknown OAuth client.', { status: 400 });
2456
+ }
2457
+
2458
+ const consentNonce = crypto.randomUUID();
2459
+ const pendingConsent: PendingConsentPayload = {
2460
+ kind: 'mcp-gsc-consent',
2461
+ authRequest: claudeAuthRequest,
2462
+ };
2463
+ await stashPendingAuth(env, consentNonce, pendingConsent);
2464
+ return new Response(
2465
+ consentHtml({
2466
+ clientName: client.clientName ?? claudeAuthRequest.clientId,
2467
+ clientId: claudeAuthRequest.clientId,
2468
+ consentNonce,
2469
+ mcpScopes: claudeAuthRequest.scope,
2470
+ googleScopes: getGoogleOAuthScopes(accessMode),
2471
+ accessMode,
2472
+ }),
2473
+ {
2474
+ headers: {
2475
+ 'content-type': 'text/html; charset=utf-8',
2476
+ 'cache-control': 'no-store',
2477
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
2478
+ 'referrer-policy': 'no-referrer',
2479
+ 'x-content-type-options': 'nosniff',
2480
+ 'x-frame-options': 'DENY',
2481
+ 'set-cookie': consentCookie(url, consentNonce),
2482
+ },
2483
+ },
2484
+ );
2485
+ }
2486
+
2487
+ if (url.pathname === '/authorize' && request.method === 'POST') {
2488
+ const contentType = request.headers.get('content-type')?.toLowerCase() ?? '';
2489
+ if (!contentType.startsWith('application/x-www-form-urlencoded')) {
2490
+ return new Response('Unsupported Media Type', { status: 415 });
2491
+ }
2492
+ const contentLength = Number(request.headers.get('content-length') ?? '0');
2493
+ if (Number.isFinite(contentLength) && contentLength > 4096) {
2494
+ return new Response('Authorization response too large', { status: 413 });
2495
+ }
2496
+ const body = await request.formData();
2497
+ const consentNonceValue = body.get('consent_nonce');
2498
+ const decisionValue = body.get('decision');
2499
+ const consentNonce =
2500
+ typeof consentNonceValue === 'string' ? consentNonceValue : null;
2501
+ const decision = typeof decisionValue === 'string' ? decisionValue : null;
2502
+ const cookieNonce = getCookie(request, consentCookieName(url));
2503
+ if (!consentNonce || !cookieNonce || !(await constantTimeStringEqual(consentNonce, cookieNonce))) {
2504
+ return new Response('Invalid or expired authorization consent.', { status: 400 });
2505
+ }
2506
+
2507
+ const pending = await consumePendingAuth(env, consentNonce);
2508
+ if (!pending || !isPendingConsentPayload(pending.claudeAuthRequest)) {
2509
+ return new Response('Authorization consent expired or invalid.', { status: 400 });
2510
+ }
2511
+ const authRequest = pending.claudeAuthRequest.authRequest;
2512
+ if (decision === 'deny') {
2513
+ const denied = oauthErrorRedirect(authRequest, 'access_denied', 'The user denied access.');
2514
+ denied.headers.append('set-cookie', clearConsentCookie(url));
2515
+ return denied;
2516
+ }
2517
+ if (decision !== 'allow') {
2518
+ return new Response('Invalid authorization decision.', { status: 400 });
2519
+ }
2520
+
2521
+ let accessMode: GscAccessMode;
2522
+ try {
2523
+ accessMode = resolveGscAccessMode(env.GSC_ACCESS_MODE);
2524
+ } catch {
2525
+ return new Response(
2526
+ 'Server configuration error: GSC_ACCESS_MODE must be "readonly" or "readwrite".',
2527
+ { status: 500 },
2528
+ );
2529
+ }
2530
+ const googleNonce = crypto.randomUUID();
2531
+ await stashPendingAuth(env, googleNonce, authRequest);
2200
2532
  const googleUrl = buildAuthUrl(
2201
2533
  env.GOOGLE_CLIENT_ID,
2202
- redirectUri,
2203
- nonce,
2534
+ googleRedirectUri(request),
2535
+ googleNonce,
2204
2536
  accessMode,
2205
2537
  );
2206
- return Response.redirect(googleUrl, 302);
2538
+ return new Response(null, {
2539
+ status: 302,
2540
+ headers: {
2541
+ location: googleUrl,
2542
+ 'set-cookie': clearConsentCookie(url),
2543
+ },
2544
+ });
2207
2545
  }
2208
2546
 
2209
2547
  if (url.pathname === '/google/callback') {
@@ -2211,14 +2549,7 @@ export const defaultHandler = {
2211
2549
  const state = url.searchParams.get('state');
2212
2550
  const googleError = url.searchParams.get('error');
2213
2551
 
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) {
2552
+ if (!state || (!code && !googleError)) {
2222
2553
  console.error('Missing code or state on /google/callback', {
2223
2554
  has_code: !!code,
2224
2555
  has_state: !!state,
@@ -2232,6 +2563,27 @@ export const defaultHandler = {
2232
2563
  return new Response('Auth request expired or invalid', { status: 400 });
2233
2564
  }
2234
2565
 
2566
+ const claudeAuthReq = pending.claudeAuthRequest;
2567
+ if (!isParsedAuthorizationRequest(claudeAuthReq)) {
2568
+ console.error('Pending auth had an invalid shape');
2569
+ return new Response('Auth request expired or invalid', { status: 400 });
2570
+ }
2571
+
2572
+ if (googleError) {
2573
+ console.warn('Google OAuth returned error', { error: googleError });
2574
+ return oauthErrorRedirect(
2575
+ claudeAuthReq,
2576
+ googleError === 'access_denied' ? 'access_denied' : 'server_error',
2577
+ googleError === 'access_denied'
2578
+ ? 'Google authorization was denied.'
2579
+ : 'Google authorization failed.',
2580
+ );
2581
+ }
2582
+
2583
+ if (!code) {
2584
+ return new Response('Missing authorization code', { status: 400 });
2585
+ }
2586
+
2235
2587
  const redirectUri = googleRedirectUri(request);
2236
2588
 
2237
2589
  let tokens;
@@ -2269,7 +2621,6 @@ export const defaultHandler = {
2269
2621
  return new Response('Save user failed', { status: 500 });
2270
2622
  }
2271
2623
 
2272
- const claudeAuthReq = pending.claudeAuthRequest as any;
2273
2624
  let redirectTo: string;
2274
2625
  try {
2275
2626
  ({ redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
@@ -1,5 +1,8 @@
1
1
  import { z } from 'zod';
2
- import { SEARCH_CONSOLE_DATE_SCHEMA } from './date-validation';
2
+ import {
3
+ SEARCH_CONSOLE_DATE_SCHEMA,
4
+ searchConsoleDateDescription,
5
+ } from './date-validation';
3
6
  import { SEARCH_CONSOLE_PROPERTY_SCHEMA } from './search-console-property-schema';
4
7
  import {
5
8
  DEFAULT_ANALYSIS_RESULT_LIMIT,
@@ -21,8 +24,14 @@ export function createQuickWinsInputSchema() {
21
24
  return z
22
25
  .object({
23
26
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
24
- start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
25
- end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
27
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
28
+ searchConsoleDateDescription('Start date (inclusive) in YYYY-MM-DD format.'),
29
+ ),
30
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
31
+ searchConsoleDateDescription(
32
+ 'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.',
33
+ ),
34
+ ),
26
35
  ...QUICK_WINS_THRESHOLDS_SHAPE,
27
36
  limit: z
28
37
  .number()
@@ -59,7 +59,7 @@ export const TOOL_RATE_LIMIT_POLICIES = {
59
59
  },
60
60
  indexing_request: {
61
61
  category: 'indexing-request',
62
- tools: ['indexing.request'],
62
+ tools: ['indexing.request', 'indexing.remove'],
63
63
  // Google's 200 publish/day default is onboarding/testing quota and does
64
64
  // not itself authorize ongoing use. Mirror that number only as a local
65
65
  // safety ceiling; two per user reserves it for up to 100 users.
@@ -4,10 +4,10 @@
4
4
  * MCP defines destructiveHint=false for non-read-only tools that perform only
5
5
  * additive updates. Search Console's site addition and sitemap submission are
6
6
  * additive PUT operations; the corresponding DELETE operations remain
7
- * destructive. Indexing publishes a URL_UPDATED notification with POST; each
8
- * call can consume quota and change Google's indexing state, so it remains
9
- * conservative (destructive + non-idempotent). Tool annotations are client
10
- * hints, not an enforcement or confirmation mechanism.
7
+ * destructive. Indexing update/removal notifications use POST; each call can
8
+ * consume quota and change Google's indexing state, so both remain conservative
9
+ * (destructive + non-idempotent). Tool annotations are client hints, not an
10
+ * enforcement or confirmation mechanism.
11
11
  */
12
12
  export const WRITE_TOOL_ANNOTATIONS = {
13
13
  'sites.add': {
@@ -35,7 +35,12 @@ export const WRITE_TOOL_ANNOTATIONS = {
35
35
  openWorldHint: true,
36
36
  },
37
37
  'indexing.request': {
38
- // The implementation only publishes URL_UPDATED, never URL_DELETED.
38
+ readOnlyHint: false,
39
+ destructiveHint: true,
40
+ idempotentHint: false,
41
+ openWorldHint: true,
42
+ },
43
+ 'indexing.remove': {
39
44
  readOnlyHint: false,
40
45
  destructiveHint: true,
41
46
  idempotentHint: false,