@digestseo/mcp-gsc 0.4.23 → 0.4.25

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,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.25] - 2026-09-23
11
+
12
+ ### Fixed
13
+ - Access-mode onboarding and Official MCP Registry metadata now identify all six read-write mutation tools after `indexing.remove` became part of the public tool surface.
14
+ - `analytics.query` now accepts provider-valid Discover and Google News rows with no `position` field instead of failing MCP structured-output validation; Search Console does not record average position for those surfaces.
15
+
16
+ ## [0.4.24] - 2026-09-23
17
+
18
+ ### Added
19
+ - `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.
20
+
21
+ ### Changed
22
+ - 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.
23
+
10
24
  ## [0.4.23] - 2026-09-22
11
25
 
12
26
  ### Fixed
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
 
@@ -72,14 +72,14 @@ It runs on [Cloudflare Workers](https://workers.cloudflare.com/) and ships with
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
  |---|---|---|
79
79
  | **`server.capabilities`** | Read | List every tool this server exposes and report local Google credential state. `connected` means a stored refresh credential is present; the response also exposes `auth_status_basis` and `provider_auth_live_verified: false` because this discovery call does not probe Google. A later Google tool call can still require reconnection if the credential expired or was revoked. Takes no arguments. |
80
80
  | **`sites.list`** | Read | List the Search Console properties the connected Google account can access (`siteUrl`, `permissionLevel`). |
81
81
  | **`sites.get`** | Read | Retrieve one exact Search Console property and the connected account's permission level for it. |
82
- | **`analytics.query`** | Read | Impressions, clicks, CTR, and average position over a date range, with dimensions, filters, safe pagination, and selectable search type. Pass `dimensions: []` for true site totals; Google's aggregate row may omit `keys`. Search Analytics does not guarantee every data row and can return only top rows, so exhausting local pagination is not proof that the provider dataset is exhaustive. `data_state: "all"` can include fresh preliminary data, while `final` returns only finalized data; responses echo `data_state` plus `preliminary_data_possible`, and date/hour groupings preserve Google's incomplete-data boundary metadata when returned. Search appearance discovery is a two-step workflow: group by `searchAppearance` alone to discover the exact values Google returns for the property, then filter to one returned value in a separate query when grouping by page/query/country/device/date/hour. For `search_type: "discover"` or `"googleNews"`, `position_supported` is `false`; those reports expose clicks, impressions, and CTR but not average position, and query grouping/filtering is unavailable. Search Console's dedicated Generative AI performance reports are not exposed by the current documented Search Analytics API as a dedicated search type or filter selector; `generative_ai_report_isolatable` is therefore `false`. |
82
+ | **`analytics.query`** | Read | Impressions, clicks, CTR, and average position over a date range, with dimensions, filters, safe pagination, and selectable search type. Pass `dimensions: []` for true site totals; Google's aggregate row may omit `keys`. Search Analytics does not guarantee every data row and can return only top rows, so exhausting local pagination is not proof that the provider dataset is exhaustive. `data_state: "all"` can include fresh preliminary data, while `final` returns only finalized data; responses echo `data_state` plus `preliminary_data_possible`, and date/hour groupings preserve Google's incomplete-data boundary metadata when returned. Search appearance discovery is a two-step workflow: group by `searchAppearance` alone to discover the exact values Google returns for the property, then filter to one returned value in a separate query when grouping by page/query/country/device/date/hour. For `search_type: "discover"` or `"googleNews"`, `position_supported` is `false`; those reports expose clicks, impressions, and CTR but not average position, so returned rows can omit `position`, and query grouping/filtering is unavailable. Search Console's dedicated Generative AI performance reports are not exposed by the current documented Search Analytics API as a dedicated search type or filter selector; `generative_ai_report_isolatable` is therefore `false`. |
83
83
  | **`insights.page_queries`** / **`insights.query_pages`** | Read | Drill from one exact page to its Search Console queries, or from one exact query to the pages Google surfaced for it, with `row_limit` / `start_row` pagination. These query-centric helpers support web, image, video, and the Search results News tab; Discover and the Google News report are excluded because they do not expose query data. |
84
84
  | **`urls.inspect`** | Read | Google's URL Inspection report for the version of one page currently known in Google's index. It is not a live URL test; mobile-usability output is deprecated. |
85
85
  | **`urls.inspect_many`** | Read | Inspect Google's indexed versions of up to 10 URLs with bounded concurrency of 3 in one call while preserving input-order results. Each URL still consumes one Google URL Inspection request and one unit of the server's shared inspection safety budget; this does not run live URL tests. |
@@ -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.23",
3
+ "version": "0.4.25",
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/digest.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { querySearchAnalytics, type SearchAnalyticsRow } from './google';
1
+ import {
2
+ hasRecordedPosition,
3
+ querySearchAnalytics,
4
+ type SearchAnalyticsRow,
5
+ } from './google';
2
6
  import type { GoogleAccessTokenProvider } from './access-token-lifecycle';
3
7
  import { getSearchConsoleCalendarDate } from './date-validation';
4
8
 
@@ -180,13 +184,15 @@ function totalsFromRow(row: SearchAnalyticsRow | undefined): SiteTotals {
180
184
  }
181
185
 
182
186
  function rowsToQueries(rows: SearchAnalyticsRow[]): QueryRow[] {
183
- return rows.map((r) => ({
184
- query: r.keys?.[0] ?? '',
185
- clicks: r.clicks,
186
- impressions: r.impressions,
187
- ctr: r.ctr,
188
- position: r.position,
189
- }));
187
+ return rows
188
+ .filter(hasRecordedPosition)
189
+ .map((r) => ({
190
+ query: r.keys?.[0] ?? '',
191
+ clicks: r.clicks,
192
+ impressions: r.impressions,
193
+ ctr: r.ctr,
194
+ position: r.position,
195
+ }));
190
196
  }
191
197
 
192
198
  function rowsToPages(rows: SearchAnalyticsRow[]): PageRow[] {
package/src/google.ts CHANGED
@@ -399,7 +399,20 @@ export interface SearchAnalyticsRow {
399
399
  clicks: number;
400
400
  impressions: number;
401
401
  ctr: number;
402
- position: number;
402
+ /**
403
+ * Average position when Google records it. Search Console does not record
404
+ * position for Discover or the Google News app/news.google.com, so those
405
+ * Search Analytics rows can legitimately omit this field.
406
+ */
407
+ position?: number;
408
+ }
409
+
410
+ export type PositionedSearchAnalyticsRow = SearchAnalyticsRow & { position: number };
411
+
412
+ export function hasRecordedPosition(
413
+ row: SearchAnalyticsRow,
414
+ ): row is PositionedSearchAnalyticsRow {
415
+ return row.position !== undefined;
403
416
  }
404
417
 
405
418
  export interface SearchAnalyticsResponseMetadata {
@@ -812,6 +825,7 @@ export function processQuickWins(
812
825
  maxPosition: number,
813
826
  ): QuickWinResult[] {
814
827
  return rows
828
+ .filter(hasRecordedPosition)
815
829
  .filter(
816
830
  (row) =>
817
831
  (row.keys?.length ?? 0) >= 2 &&
@@ -860,7 +874,7 @@ export function processCannibalization(
860
874
  ): CannibalizationResult[] {
861
875
  const queryGroups = new Map<string, Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>>();
862
876
  for (const row of rows) {
863
- if ((row.keys?.length ?? 0) < 2) continue;
877
+ if ((row.keys?.length ?? 0) < 2 || row.position === undefined) continue;
864
878
  const query = row.keys![0];
865
879
  const page = row.keys![1];
866
880
  if (!queryGroups.has(query)) {
@@ -1010,7 +1024,7 @@ export function processContentDecay(
1010
1024
  ): DecayPageResult[] {
1011
1025
  const recentMap = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
1012
1026
  for (const row of recentRows) {
1013
- if ((row.keys?.length ?? 0) < 1) continue;
1027
+ if ((row.keys?.length ?? 0) < 1 || row.position === undefined) continue;
1014
1028
  recentMap.set(row.keys![0], {
1015
1029
  clicks: row.clicks,
1016
1030
  impressions: row.impressions,
@@ -1022,7 +1036,7 @@ export function processContentDecay(
1022
1036
  const decayCandidates: DecayPageResult[] = [];
1023
1037
 
1024
1038
  for (const row of previousRows) {
1025
- if ((row.keys?.length ?? 0) < 1) continue;
1039
+ if ((row.keys?.length ?? 0) < 1 || row.position === undefined) continue;
1026
1040
  const page = row.keys![0];
1027
1041
  const prevClicks = row.clicks;
1028
1042
  const prevImps = row.impressions;
@@ -1458,15 +1472,124 @@ export async function checkIndexingEligibility(
1458
1472
  };
1459
1473
  }
1460
1474
 
1461
- export async function requestIndexing(
1462
- accessToken: string,
1475
+ function hasRobotsNoindexMeta(html: string): boolean {
1476
+ // HTML permits an omitted </head>, so stop at </head>, <body>, or EOF.
1477
+ const head = /<head\b[^>]*>([\s\S]*?)(?:<\/head\s*>|<body\b|$)/i.exec(html)?.[1];
1478
+ if (!head) return false;
1479
+
1480
+ const metaPattern = /<meta\b([^>]*)>/gi;
1481
+ let match: RegExpExecArray | null;
1482
+ while ((match = metaPattern.exec(head)) !== null) {
1483
+ const name = htmlAttribute(match[1], 'name')?.trim().toLowerCase();
1484
+ if (name !== 'robots') continue;
1485
+ const content = htmlAttribute(match[1], 'content')?.trim().toLowerCase();
1486
+ if (!content) continue;
1487
+ const directives = content.split(/[\s,]+/).filter(Boolean);
1488
+ if (directives.includes('noindex')) return true;
1489
+ }
1490
+
1491
+ return false;
1492
+ }
1493
+
1494
+ const INDEXING_REMOVAL_REQUIREMENT_MESSAGE =
1495
+ 'Before requesting removal, Google requires the URL to return HTTP 404 or 410, or the page to contain a robots noindex meta directive.';
1496
+
1497
+ /**
1498
+ * Verifies Google's documented precondition for an Indexing API URL_DELETED
1499
+ * notification without requiring the page to keep its former structured data.
1500
+ */
1501
+ export async function checkIndexingRemovalEligibility(
1463
1502
  url: string,
1464
- ): Promise<unknown> {
1465
- const eligibility = await checkIndexingEligibility(url);
1466
- if (!eligibility.eligible) {
1467
- throw new Error(eligibility.reason);
1503
+ { timeoutMs = INDEXING_ELIGIBILITY_TIMEOUT_MS }: IndexingEligibilityOptions = {},
1504
+ ): Promise<IndexingEligibility> {
1505
+ if (!isHttpUrl(url)) {
1506
+ return {
1507
+ eligible: false,
1508
+ reason: `${url} must be a valid HTTP or HTTPS URL. ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1509
+ };
1468
1510
  }
1469
1511
 
1512
+ const controller = new AbortController();
1513
+ let timedOut = false;
1514
+ const timeout = setTimeout(() => {
1515
+ timedOut = true;
1516
+ controller.abort();
1517
+ }, timeoutMs);
1518
+
1519
+ try {
1520
+ const resp = await fetch(url, {
1521
+ signal: controller.signal,
1522
+ redirect: 'manual',
1523
+ headers: {
1524
+ 'user-agent':
1525
+ 'Mozilla/5.0 (compatible; DigestSEO-GSC-MCP/1.0; +https://github.com/AKzar1el/mcp-gsc)',
1526
+ },
1527
+ });
1528
+
1529
+ if (resp.status === 404 || resp.status === 410) {
1530
+ await cancelResponseBody(resp);
1531
+ return { eligible: true };
1532
+ }
1533
+
1534
+ if (!resp.ok) {
1535
+ await cancelResponseBody(resp);
1536
+ return {
1537
+ eligible: false,
1538
+ reason: `Could not confirm indexing-removal eligibility for ${url} (HTTP ${resp.status}). ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1539
+ };
1540
+ }
1541
+
1542
+ const contentType = resp.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
1543
+ if (
1544
+ contentType &&
1545
+ contentType !== 'text/html' &&
1546
+ contentType !== 'application/xhtml+xml'
1547
+ ) {
1548
+ await cancelResponseBody(resp);
1549
+ return {
1550
+ eligible: false,
1551
+ reason: `Could not confirm indexing-removal eligibility for ${url} (expected HTML, received ${contentType}). ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1552
+ };
1553
+ }
1554
+
1555
+ const contentLength = Number(resp.headers.get('content-length'));
1556
+ if (
1557
+ Number.isFinite(contentLength) &&
1558
+ contentLength > INDEXING_ELIGIBILITY_MAX_RESPONSE_BYTES
1559
+ ) {
1560
+ await cancelResponseBody(resp);
1561
+ return {
1562
+ eligible: false,
1563
+ reason: `Could not confirm indexing-removal eligibility for ${url} (response exceeds the ${INDEXING_ELIGIBILITY_MAX_RESPONSE_BYTES}-byte limit). ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1564
+ };
1565
+ }
1566
+
1567
+ const html = await readBoundedResponseText(resp);
1568
+ if (hasRobotsNoindexMeta(html)) return { eligible: true };
1569
+
1570
+ return {
1571
+ eligible: false,
1572
+ reason: `${url} does not currently return HTTP 404/410 and does not expose a robots noindex meta directive. ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1573
+ };
1574
+ } catch (err) {
1575
+ const detail = timedOut
1576
+ ? `timed out after ${timeoutMs}ms`
1577
+ : (err as Error).message;
1578
+ return {
1579
+ eligible: false,
1580
+ reason: `Could not confirm indexing-removal eligibility for ${url} (${detail}). ${INDEXING_REMOVAL_REQUIREMENT_MESSAGE}`,
1581
+ };
1582
+ } finally {
1583
+ clearTimeout(timeout);
1584
+ }
1585
+ }
1586
+
1587
+ async function publishIndexingNotification(
1588
+ accessToken: string,
1589
+ url: string,
1590
+ type: 'URL_UPDATED' | 'URL_DELETED',
1591
+ actionLabel: string,
1592
+ ): Promise<unknown> {
1470
1593
  const resp = await fetch(
1471
1594
  'https://indexing.googleapis.com/v3/urlNotifications:publish',
1472
1595
  {
@@ -1475,10 +1598,7 @@ export async function requestIndexing(
1475
1598
  authorization: `Bearer ${accessToken}`,
1476
1599
  'content-type': 'application/json',
1477
1600
  },
1478
- body: JSON.stringify({
1479
- url,
1480
- type: 'URL_UPDATED',
1481
- }),
1601
+ body: JSON.stringify({ url, type }),
1482
1602
  },
1483
1603
  );
1484
1604
  if (resp.status === 401) {
@@ -1486,11 +1606,38 @@ export async function requestIndexing(
1486
1606
  }
1487
1607
  if (!resp.ok) {
1488
1608
  const text = await resp.text();
1489
- throw new Error(`Request indexing failed: ${resp.status} ${text}`);
1609
+ throw new Error(`${actionLabel} failed: ${resp.status} ${text}`);
1490
1610
  }
1491
1611
  return await resp.json();
1492
1612
  }
1493
1613
 
1614
+ export async function requestIndexing(
1615
+ accessToken: string,
1616
+ url: string,
1617
+ ): Promise<unknown> {
1618
+ const eligibility = await checkIndexingEligibility(url);
1619
+ if (!eligibility.eligible) {
1620
+ throw new Error(eligibility.reason);
1621
+ }
1622
+ return publishIndexingNotification(accessToken, url, 'URL_UPDATED', 'Request indexing');
1623
+ }
1624
+
1625
+ export async function requestIndexingRemoval(
1626
+ accessToken: string,
1627
+ url: string,
1628
+ ): Promise<unknown> {
1629
+ const eligibility = await checkIndexingRemovalEligibility(url);
1630
+ if (!eligibility.eligible) {
1631
+ throw new Error(eligibility.reason);
1632
+ }
1633
+ return publishIndexingNotification(
1634
+ accessToken,
1635
+ url,
1636
+ 'URL_DELETED',
1637
+ 'Request indexing removal',
1638
+ );
1639
+ }
1640
+
1494
1641
  export interface IndexingNotification {
1495
1642
  url?: string;
1496
1643
  type?: 'URL_UPDATED' | 'URL_DELETED' | string;
@@ -1555,7 +1702,7 @@ export function processPerformanceComparison(
1555
1702
  ): PerformanceComparisonRow[] {
1556
1703
  const mapB = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
1557
1704
  for (const row of rowsB) {
1558
- if ((row.keys?.length ?? 0) < 1) continue;
1705
+ if ((row.keys?.length ?? 0) < 1 || row.position === undefined) continue;
1559
1706
  mapB.set(row.keys![0], {
1560
1707
  clicks: row.clicks,
1561
1708
  impressions: row.impressions,
@@ -1567,7 +1714,7 @@ export function processPerformanceComparison(
1567
1714
  const comparison: PerformanceComparisonRow[] = [];
1568
1715
 
1569
1716
  for (const row of rowsA) {
1570
- if ((row.keys?.length ?? 0) < 1) continue;
1717
+ if ((row.keys?.length ?? 0) < 1 || row.position === undefined) continue;
1571
1718
  const key = row.keys![0];
1572
1719
  const clicksA = row.clicks;
1573
1720
  const impsA = row.impressions;
package/src/index.ts CHANGED
@@ -24,10 +24,12 @@ import {
24
24
  submitSitemap,
25
25
  deleteSitemap,
26
26
  getSitemap,
27
+ hasRecordedPosition,
27
28
  processQuickWins,
28
29
  processCannibalization,
29
30
  processContentDecay,
30
31
  requestIndexing,
32
+ requestIndexingRemoval,
31
33
  getIndexingNotificationMetadata,
32
34
  processPerformanceComparison,
33
35
  type PaginatedSearchAnalyticsResult,
@@ -58,6 +60,7 @@ import {
58
60
  assertDateRange,
59
61
  getSearchConsoleCalendarDate,
60
62
  SEARCH_CONSOLE_DATE_SCHEMA,
63
+ searchConsoleDateDescription,
61
64
  } from './date-validation';
62
65
  import { CONTENT_DECAY_COMPARE_DAYS_SCHEMA } from './content-decay-schema';
63
66
  import { resolveIndexedPagesDateRange } from './indexed-pages-range';
@@ -132,7 +135,15 @@ const SEARCH_ROW_OUTPUT_SCHEMA = {
132
135
  .describe(
133
136
  'Dimension values returned by Google. Aggregate queries with dimensions: [] may legitimately omit this field.',
134
137
  ),
135
- ...METRIC_OUTPUT_SCHEMA,
138
+ clicks: z.number(),
139
+ impressions: z.number(),
140
+ ctr: z.number(),
141
+ position: z
142
+ .number()
143
+ .optional()
144
+ .describe(
145
+ 'Average position when Google records it. Omitted for Discover and Google News, where Search Console does not record position.',
146
+ ),
136
147
  };
137
148
 
138
149
  const SITE_OUTPUT_SCHEMA = {
@@ -473,6 +484,11 @@ const INDEXING_OUTPUT_SCHEMA = {
473
484
  provider_usage_note: z.string(),
474
485
  };
475
486
 
487
+ const INDEXING_REMOVE_OUTPUT_SCHEMA = {
488
+ ...INDEXING_OUTPUT_SCHEMA,
489
+ removal_completion_proven: z.literal(false),
490
+ };
491
+
476
492
  const INDEXING_NOTIFICATION_SCHEMA = z.object({
477
493
  url: z.string().optional(),
478
494
  type: z.string().optional(),
@@ -709,6 +725,11 @@ const TOOL_CATALOG = [
709
725
  description:
710
726
  '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.',
711
727
  },
728
+ {
729
+ name: 'indexing.remove',
730
+ description:
731
+ "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.",
732
+ },
712
733
  {
713
734
  name: 'indexing.status',
714
735
  description:
@@ -1125,9 +1146,13 @@ class GscMcpRuntime {
1125
1146
  ].join('\n'),
1126
1147
  inputSchema: {
1127
1148
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1128
- start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1149
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1150
+ searchConsoleDateDescription('Start date (inclusive) in YYYY-MM-DD format.'),
1151
+ ),
1129
1152
  end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1130
- '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.',
1153
+ searchConsoleDateDescription(
1154
+ '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.',
1155
+ ),
1131
1156
  ),
1132
1157
  dimensions: z
1133
1158
  .array(
@@ -1296,8 +1321,14 @@ class GscMcpRuntime {
1296
1321
  inputSchema: {
1297
1322
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1298
1323
  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.'),
1299
- start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1300
- end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1324
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1325
+ searchConsoleDateDescription('Start date (inclusive) in YYYY-MM-DD format.'),
1326
+ ),
1327
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1328
+ searchConsoleDateDescription(
1329
+ 'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.',
1330
+ ),
1331
+ ),
1301
1332
  search_type: z
1302
1333
  .enum(['web', 'image', 'video', 'news'])
1303
1334
  .default('web')
@@ -1342,7 +1373,7 @@ class GscMcpRuntime {
1342
1373
  ],
1343
1374
  }, { maxRows: sourceRowLimit });
1344
1375
  const rows = source.rows
1345
- .filter((row) => (row.keys?.length ?? 0) > 0)
1376
+ .filter((row) => (row.keys?.length ?? 0) > 0 && hasRecordedPosition(row))
1346
1377
  .map((row) => ({
1347
1378
  query: row.keys![0],
1348
1379
  clicks: row.clicks,
@@ -1374,8 +1405,14 @@ class GscMcpRuntime {
1374
1405
  inputSchema: {
1375
1406
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1376
1407
  query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
1377
- start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1378
- end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1408
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1409
+ searchConsoleDateDescription('Start date (inclusive) in YYYY-MM-DD format.'),
1410
+ ),
1411
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1412
+ searchConsoleDateDescription(
1413
+ 'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.',
1414
+ ),
1415
+ ),
1379
1416
  search_type: z
1380
1417
  .enum(['web', 'image', 'video', 'news'])
1381
1418
  .default('web')
@@ -1420,7 +1457,7 @@ class GscMcpRuntime {
1420
1457
  ],
1421
1458
  }, { maxRows: sourceRowLimit });
1422
1459
  const rows = source.rows
1423
- .filter((row) => (row.keys?.length ?? 0) > 0)
1460
+ .filter((row) => (row.keys?.length ?? 0) > 0 && hasRecordedPosition(row))
1424
1461
  .map((row) => ({
1425
1462
  page: row.keys![0],
1426
1463
  clicks: row.clicks,
@@ -1608,8 +1645,14 @@ class GscMcpRuntime {
1608
1645
  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.',
1609
1646
  inputSchema: {
1610
1647
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1611
- start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1612
- end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1648
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1649
+ searchConsoleDateDescription('Start date (inclusive) in YYYY-MM-DD format.'),
1650
+ ),
1651
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1652
+ searchConsoleDateDescription(
1653
+ 'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.',
1654
+ ),
1655
+ ),
1613
1656
  min_impressions: CANNIBALIZATION_MIN_IMPRESSIONS_SCHEMA,
1614
1657
  min_page_percentage: CANNIBALIZATION_MIN_PAGE_PERCENTAGE_SCHEMA,
1615
1658
  limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
@@ -1818,6 +1861,48 @@ class GscMcpRuntime {
1818
1861
  return toolResponse(JSON.stringify(payload, null, 2), payload);
1819
1862
  },
1820
1863
  );
1864
+
1865
+ this.server.registerTool(
1866
+ 'indexing.remove',
1867
+ {
1868
+ title: 'Request Indexing API removal',
1869
+ 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.",
1870
+ inputSchema: {
1871
+ site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA.describe(`${SEARCH_CONSOLE_PROPERTY_DESCRIPTION} The connected Google account must be an owner of this property.`),
1872
+ url: z.string().superRefine((value, ctx) => {
1873
+ try {
1874
+ assertIndexingRequestUrl(value);
1875
+ } catch (error) {
1876
+ ctx.addIssue({
1877
+ code: 'custom',
1878
+ message: (error as Error).message,
1879
+ });
1880
+ }
1881
+ }).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.'),
1882
+ },
1883
+ outputSchema: INDEXING_REMOVE_OUTPUT_SCHEMA,
1884
+ annotations: WRITE_TOOL_ANNOTATIONS['indexing.remove'],
1885
+ },
1886
+ async ({ site_url, url }) => {
1887
+ const googleId = this.requireGoogleId();
1888
+ const rateLimitError = await this.rateLimitError(googleId, 'indexing.remove');
1889
+ if (rateLimitError) return rateLimitError;
1890
+ const accessToken = await this.getAccessToken(googleId);
1891
+ const sites = await listSites(accessToken);
1892
+ assertIndexingUrlAuthorized(url, site_url, sites);
1893
+ const result = await requestIndexingRemoval(accessToken, url);
1894
+ const payload = {
1895
+ result,
1896
+ 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.",
1897
+ provider_default_quota_for_testing_only: true as const,
1898
+ provider_usage_approval_required: true as const,
1899
+ provider_spam_detection_applies: true as const,
1900
+ provider_usage_note: INDEXING_PROVIDER_USAGE_NOTE,
1901
+ removal_completion_proven: false as const,
1902
+ };
1903
+ return toolResponse(JSON.stringify(payload, null, 2), payload);
1904
+ },
1905
+ );
1821
1906
  }
1822
1907
 
1823
1908
  this.server.registerTool(
@@ -1827,8 +1912,16 @@ class GscMcpRuntime {
1827
1912
  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.`,
1828
1913
  inputSchema: {
1829
1914
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1830
- 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.'),
1831
- 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.'),
1915
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.optional().describe(
1916
+ searchConsoleDateDescription(
1917
+ '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.',
1918
+ ),
1919
+ ),
1920
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.optional().describe(
1921
+ searchConsoleDateDescription(
1922
+ '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.',
1923
+ ),
1924
+ ),
1832
1925
  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.`),
1833
1926
  start_row: z.number().int().min(0).default(0).describe('Zero-based page-row offset for pagination.'),
1834
1927
  },
@@ -1856,7 +1949,7 @@ class GscMcpRuntime {
1856
1949
  });
1857
1950
 
1858
1951
  const pages = response.rows
1859
- .filter((row) => (row.keys?.length ?? 0) > 0)
1952
+ .filter((row) => (row.keys?.length ?? 0) > 0 && hasRecordedPosition(row))
1860
1953
  .map((row) => ({
1861
1954
  page: row.keys![0],
1862
1955
  clicks: row.clicks,
@@ -1887,10 +1980,18 @@ class GscMcpRuntime {
1887
1980
  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.',
1888
1981
  inputSchema: {
1889
1982
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1890
- start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
1891
- end_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period A (recent, YYYY-MM-DD)'),
1892
- start_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period B (previous, YYYY-MM-DD)'),
1893
- end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period B (previous, YYYY-MM-DD)'),
1983
+ start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1984
+ searchConsoleDateDescription('Start date of Period A (recent, YYYY-MM-DD).'),
1985
+ ),
1986
+ end_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1987
+ searchConsoleDateDescription('End date of Period A (recent, YYYY-MM-DD).'),
1988
+ ),
1989
+ start_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1990
+ searchConsoleDateDescription('Start date of Period B (previous, YYYY-MM-DD).'),
1991
+ ),
1992
+ end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1993
+ searchConsoleDateDescription('End date of Period B (previous, YYYY-MM-DD).'),
1994
+ ),
1894
1995
  dimension: z.enum(['query', 'page', 'country', 'device']).default('query').describe('The dimension to compare performance for. Defaults to query.'),
1895
1996
  search_type: z
1896
1997
  .enum(['web', 'image', 'video', 'news'])
@@ -1997,7 +2098,11 @@ class GscMcpRuntime {
1997
2098
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1998
2099
  end_date: SEARCH_CONSOLE_DATE_SCHEMA
1999
2100
  .optional()
2000
- .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.'),
2101
+ .describe(
2102
+ searchConsoleDateDescription(
2103
+ '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.',
2104
+ ),
2105
+ ),
2001
2106
  },
2002
2107
  outputSchema: WEEKLY_DIGEST_OUTPUT_SCHEMA,
2003
2108
  annotations: READ_ONLY_ANNOTATIONS,
@@ -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,