@digestseo/mcp-gsc 0.4.15 → 0.4.17

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.17] - 2026-09-22
11
+
12
+ ### Added
13
+ - `indexing.status` reads Google's latest successful Indexing API update/remove notification receipts for a previously submitted URL while explicitly distinguishing notification receipt from crawl, index coverage, indexing completion, or removal completion.
14
+
15
+ ### Fixed
16
+ - `sites.delete` now describes and reports Google's actual account-scoped effect: it removes the property from the connected user's Search Console site set rather than implying that the website itself or a global Search Console property is deleted.
17
+
18
+ ## [0.4.16] - 2026-09-22
19
+
20
+ ### Fixed
21
+ - High-level Search Analytics helpers now reserve their worst-case upstream request fan-out against the shared local safety budget, so paginated comparison/cannibalization/decay workflows cannot bypass the 10-minute limiter by hiding multiple Google API calls behind one MCP tool call.
22
+ - `indexing.request` now exposes Google's current Indexing API usage boundary: the default 200 publish requests/day/project is onboarding/testing capacity rather than ongoing-use approval, ongoing usage/resource provisioning requires approval, and all submissions are subject to spam detection.
23
+
10
24
  ## [0.4.15] - 2026-09-22
11
25
 
12
26
  ### Fixed
package/README.md CHANGED
@@ -72,7 +72,7 @@ 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 21 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 read-only tools.
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.
76
76
 
77
77
  | Tool | Access | What it does |
78
78
  |---|---|---|
@@ -87,17 +87,18 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
87
87
  | **`insights.quick_wins`** / **`insights.cannibalization`** / **`insights.content_decay`** | Read | Surface average-position opportunity rows, query/page overlap, and evidence-ranked click declines. Quick-win candidates are observed query/page rows selected by aggregate Search Console average position, not proof of a stable current rank, and CTR is context rather than an eligibility filter. Cannibalization candidate totals/shares are scoped to observed query/page rows rather than true query-level property totals; content-decay results compare only pages returned in both periods and do not turn one-sided row absence into zero traffic. |
88
88
  | **`indexing.list_pages`** / **`analytics.compare`** | Read | Analyze pages receiving Search Console impressions and compare two periods, optionally using the same search type and query/page/country/device/search-appearance filters for both periods. `analytics.compare` compares only dimension keys returned in both period responses; a key absent from one non-exhaustive Search Analytics response is not assumed to have zero metrics. Because its comparison contract includes average position, it excludes Discover and Google News. `indexing.list_pages` is performance data, not index coverage: a missing URL may still be indexed; use `urls.inspect` / `urls.inspect_many` for URL-level index status. |
89
89
  | **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. Query movers compare only rows returned in both weekly Search Analytics result sets; a query missing from one bounded response is not treated as zero because Google does not guarantee every data row. |
90
- | **`sites.add`** / **`sites.delete`** | Write | Add or remove a Search Console property. `sites.add` only adds the property to the connected account's Search Console site set; it returns `ownership_verification_performed: false` because ownership verification is a separate Google Site Verification/Search Console workflow. |
90
+ | **`sites.add`** / **`sites.delete`** | Write | Add or remove a property from the connected account's Search Console site set. `sites.add` returns `ownership_verification_performed: false` because ownership verification is a separate Google Site Verification/Search Console workflow. `sites.delete` removes the property from that account's Search Console site set; it does not delete the website itself. |
91
91
  | **`sitemaps.submit`** / **`sitemaps.delete`** | Write | Submit or remove a sitemap. |
92
- | **`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. |
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
+ | **`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. |
93
94
 
94
95
  > **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.
95
96
 
96
- 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. Read-write remains the default so existing deployments retain their current behavior; see [SETUP.md](SETUP.md) to select a mode before connecting users.
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.
97
98
 
98
99
  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.
99
100
 
100
- > **`indexing.request` eligibility.** 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. A successful submission is only an acknowledgment that Google received the notification — it does not guarantee the URL will be indexed.
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.
101
102
 
102
103
  ## What you can ask
103
104
 
package/SECURITY.md CHANGED
@@ -51,8 +51,8 @@ Notes:
51
51
  ## Tool rate limits
52
52
 
53
53
  The server enforces the following fixed-window limits before making upstream
54
- requests. They are per authenticated user and tool category; `indexing.request`
55
- also has a deployment-wide project guard. A rejected invocation returns a retry
54
+ requests. They are per authenticated user and tool category; the Indexing API
55
+ publish and metadata-read categories also have deployment-wide project guards. A rejected invocation returns a retry
56
56
  delay and does not disclose another user's usage.
57
57
 
58
58
  | Category | Tools | Limit |
@@ -61,9 +61,11 @@ delay and does not disclose another user's usage.
61
61
  | 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
62
  | Search Console writes | `sites.add`, `sites.delete`, `sitemaps.submit`, `sitemaps.delete` | 10 per user / hour |
63
63
  | Indexing publish | `indexing.request` | 2 per user / 24 hours; 200 per deployment / 24 hours |
64
+ | Indexing metadata | `indexing.status` | 30 per user / minute; 180 per deployment / minute |
64
65
  | Weekly digest | `reports.weekly_digest` | 6 per user / hour |
65
66
 
66
- The Indexing API's default publish quota is 200 requests per project per day.
67
+ The Indexing API's default publish quota is 200 requests per project per day,
68
+ and its default metadata-read quota is 180 requests per project per minute.
67
69
  Search Analytics and URL Inspection have separate load and per-site quotas, so
68
70
  these limits are intentionally conservative rather than a replacement for
69
71
  monitoring the Google Cloud project quota.
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.
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.
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
  ---
@@ -155,7 +155,7 @@ The template also sets `GSC_ACCESS_MODE` to `readwrite`, preserving the historic
155
155
  }
156
156
  ```
157
157
 
158
- Read-only deployments request `webmasters.readonly`, omit the Indexing API scope, and do not register `sites.add`, `sites.delete`, `sitemaps.submit`, `sitemaps.delete`, or `indexing.request`. Changing modes affects OAuth grants for future connections; reconnect users after changing the mode so Google grants the matching scope set.
158
+ 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.
159
159
 
160
160
  `wrangler.jsonc` is gitignored because it contains your account's namespace ids. The `wrangler.example.jsonc` template stays in git.
161
161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
@@ -25,6 +25,13 @@ export const WRITE_TOOL_NAMES = [
25
25
  'indexing.request',
26
26
  ] as const;
27
27
 
28
+ const INDEXING_SCOPE_READ_TOOL_NAMES = ['indexing.status'] as const;
29
+
30
+ const READWRITE_ONLY_TOOL_NAMES = [
31
+ ...WRITE_TOOL_NAMES,
32
+ ...INDEXING_SCOPE_READ_TOOL_NAMES,
33
+ ] as const;
34
+
28
35
  export function resolveGscAccessMode(value: string | undefined): GscAccessMode {
29
36
  if (value === undefined || value === '') {
30
37
  return DEFAULT_GSC_ACCESS_MODE;
@@ -54,6 +61,6 @@ export function getToolCatalogForAccessMode<T extends { name: string }>(
54
61
  }
55
62
 
56
63
  return tools.filter(
57
- (tool) => !(WRITE_TOOL_NAMES as readonly string[]).includes(tool.name),
64
+ (tool) => !(READWRITE_ONLY_TOOL_NAMES as readonly string[]).includes(tool.name),
58
65
  );
59
66
  }
package/src/google.ts CHANGED
@@ -1475,6 +1475,42 @@ export async function requestIndexing(
1475
1475
  return await resp.json();
1476
1476
  }
1477
1477
 
1478
+ export interface IndexingNotification {
1479
+ url?: string;
1480
+ type?: 'URL_UPDATED' | 'URL_DELETED' | string;
1481
+ notifyTime?: string;
1482
+ }
1483
+
1484
+ export interface IndexingNotificationMetadata {
1485
+ url?: string;
1486
+ latestUpdate?: IndexingNotification;
1487
+ latestRemove?: IndexingNotification;
1488
+ }
1489
+
1490
+ export async function getIndexingNotificationMetadata(
1491
+ accessToken: string,
1492
+ url: string,
1493
+ ): Promise<IndexingNotificationMetadata> {
1494
+ const endpoint = new URL(
1495
+ 'https://indexing.googleapis.com/v3/urlNotifications/metadata',
1496
+ );
1497
+ endpoint.searchParams.set('url', url);
1498
+
1499
+ const resp = await fetchGoogleRead(endpoint.toString(), {
1500
+ headers: { authorization: `Bearer ${accessToken}` },
1501
+ });
1502
+ if (resp.status === 401) {
1503
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
1504
+ }
1505
+ if (!resp.ok) {
1506
+ const text = await resp.text();
1507
+ throw new Error(
1508
+ `Get indexing notification status failed: ${resp.status} ${text}`,
1509
+ );
1510
+ }
1511
+ return (await resp.json()) as IndexingNotificationMetadata;
1512
+ }
1513
+
1478
1514
  export interface PerformanceComparisonRow {
1479
1515
  key: string;
1480
1516
  period_a: { clicks: number; impressions: number; ctr: number; position: number };
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  processCannibalization,
29
29
  processContentDecay,
30
30
  requestIndexing,
31
+ getIndexingNotificationMetadata,
31
32
  processPerformanceComparison,
32
33
  type PaginatedSearchAnalyticsResult,
33
34
  } from './google';
@@ -41,7 +42,11 @@ import {
41
42
  PendingAuthStateStore,
42
43
  stashPendingAuth,
43
44
  } from './pending-auth-state';
44
- import { enforceToolRateLimit, type RateLimitedToolName } from './tool-rate-limit';
45
+ import {
46
+ enforceToolRateLimit,
47
+ getToolRateLimitUnits,
48
+ type RateLimitedToolName,
49
+ } from './tool-rate-limit';
45
50
  export { ToolRateLimiter } from './tool-rate-limiter-do';
46
51
  import { generateWeeklyDigest, resolveWeeklyDigestEndDate } from './digest';
47
52
  import {
@@ -213,6 +218,9 @@ const SITE_DETAIL_OUTPUT_SCHEMA = {
213
218
  const SITE_ADD_VERIFICATION_NOTE =
214
219
  "Google Search Console Sites.add only adds the property to the user's Search Console site set; it does not verify ownership. Ownership verification is a separate Google Site Verification/Search Console workflow.";
215
220
 
221
+ const INDEXING_PROVIDER_USAGE_NOTE =
222
+ "Google describes the Indexing API's default 200 publish-requests-per-day project quota as onboarding/testing capacity, not approval for ongoing usage. Additional approval is required for usage/resource provisioning. Google also applies spam detection to all submissions and warns that abuse or attempts to exceed quotas through multiple accounts or other means can result in revoked access.";
223
+
216
224
  const URL_INSPECTION_SCOPE_NOTE =
217
225
  "Google's URL Inspection API reports the version currently known in Google's index; it does not run a live URL test or prove current live-page indexability. The mobileUsabilityResult field is deprecated and may be absent.";
218
226
 
@@ -294,6 +302,13 @@ const SITE_ADD_OUTPUT_SCHEMA = {
294
302
  ownership_verification_note: z.string(),
295
303
  };
296
304
 
305
+ const SITE_DELETE_OUTPUT_SCHEMA = {
306
+ message: z.string(),
307
+ removed_from_connected_account_site_set: z.literal(true),
308
+ website_content_deleted: z.literal(false),
309
+ provider_scope_note: z.string(),
310
+ };
311
+
297
312
  const SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA = z.object({
298
313
  rows_fetched: z.number().int().nonnegative(),
299
314
  pages_fetched: z.number().int().positive(),
@@ -440,6 +455,28 @@ const CONTENT_DECAY_OUTPUT_SCHEMA = {
440
455
  const INDEXING_OUTPUT_SCHEMA = {
441
456
  result: z.unknown(),
442
457
  note: z.string(),
458
+ provider_default_quota_for_testing_only: z.literal(true),
459
+ provider_usage_approval_required: z.literal(true),
460
+ provider_spam_detection_applies: z.literal(true),
461
+ provider_usage_note: z.string(),
462
+ };
463
+
464
+ const INDEXING_NOTIFICATION_SCHEMA = z.object({
465
+ url: z.string().optional(),
466
+ type: z.string().optional(),
467
+ notifyTime: z.string().optional(),
468
+ });
469
+
470
+ const INDEXING_STATUS_OUTPUT_SCHEMA = {
471
+ notification_metadata: z.object({
472
+ url: z.string().optional(),
473
+ latestUpdate: INDEXING_NOTIFICATION_SCHEMA.optional(),
474
+ latestRemove: INDEXING_NOTIFICATION_SCHEMA.optional(),
475
+ }),
476
+ notification_receipt_only: z.literal(true),
477
+ index_coverage_report: z.literal(false),
478
+ indexing_or_removal_completion_proven: z.literal(false),
479
+ note: z.string(),
443
480
  };
444
481
 
445
482
  const INDEXED_PAGES_OUTPUT_SCHEMA = {
@@ -593,7 +630,7 @@ const TOOL_CATALOG = [
593
630
  {
594
631
  name: 'sites.delete',
595
632
  description:
596
- 'Remove an existing website property from your Google Search Console account.',
633
+ "Remove an existing website property from the connected Google account's Search Console site set. This changes that account's Search Console membership for the property; it does not delete the website itself.",
597
634
  },
598
635
  {
599
636
  name: 'analytics.query',
@@ -658,7 +695,12 @@ const TOOL_CATALOG = [
658
695
  {
659
696
  name: 'indexing.request',
660
697
  description:
661
- 'Request an eligible JobPosting or livestream URL update through Google\'s restricted Indexing API; this is not a general webpage submission tool.',
698
+ '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.',
699
+ },
700
+ {
701
+ name: 'indexing.status',
702
+ description:
703
+ 'Read the latest successful Indexing API update/remove notifications Google received for a previously submitted URL. This reports notification receipt only, not crawl, index coverage, indexing completion, or removal completion.',
662
704
  },
663
705
  {
664
706
  name: 'indexing.list_pages',
@@ -704,7 +746,7 @@ class GscMcpRuntime {
704
746
  private async rateLimitError(
705
747
  googleId: string,
706
748
  toolName: RateLimitedToolName,
707
- units = 1,
749
+ units = getToolRateLimitUnits(toolName),
708
750
  ) {
709
751
  const result = await enforceToolRateLimit(this.env, googleId, toolName, units);
710
752
  if (result.allowed) return null;
@@ -1402,12 +1444,12 @@ class GscMcpRuntime {
1402
1444
  this.server.registerTool(
1403
1445
  'sites.delete',
1404
1446
  {
1405
- title: 'Delete Search Console property',
1406
- description: 'Remove an existing website property from your Google Search Console account.',
1447
+ title: 'Remove Search Console property from account',
1448
+ description: "Remove an existing website property from the connected Google account's Search Console site set. Google documents this operation as removing the site from the user's Search Console sites; it does not delete website content.",
1407
1449
  inputSchema: {
1408
1450
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1409
1451
  },
1410
- outputSchema: MESSAGE_OUTPUT_SCHEMA,
1452
+ outputSchema: SITE_DELETE_OUTPUT_SCHEMA,
1411
1453
  annotations: WRITE_TOOL_ANNOTATIONS['sites.delete'],
1412
1454
  },
1413
1455
  async ({ site_url }) => {
@@ -1416,8 +1458,14 @@ class GscMcpRuntime {
1416
1458
  if (rateLimitError) return rateLimitError;
1417
1459
  const accessToken = await this.getAccessToken(googleId);
1418
1460
  await deleteSite(accessToken, site_url);
1419
- const message = `Successfully deleted site property: ${site_url}`;
1420
- return toolResponse(message, { message });
1461
+ const message = `Successfully removed site property from the connected account's Search Console site set: ${site_url}`;
1462
+ const payload = {
1463
+ message,
1464
+ removed_from_connected_account_site_set: true as const,
1465
+ website_content_deleted: false as const,
1466
+ provider_scope_note: "Google's Sites.delete method removes the property from the connected user's Search Console site set. It does not delete the website itself.",
1467
+ };
1468
+ return toolResponse(`${message}\n\n${payload.provider_scope_note}`, payload);
1421
1469
  },
1422
1470
  );
1423
1471
 
@@ -1656,11 +1704,54 @@ class GscMcpRuntime {
1656
1704
  );
1657
1705
 
1658
1706
  if (accessMode === 'readwrite') {
1707
+ this.server.registerTool(
1708
+ 'indexing.status',
1709
+ {
1710
+ title: 'Get Indexing API notification status',
1711
+ description: "Read the latest successful Indexing API URL_UPDATED and URL_DELETED notifications Google received for a previously submitted URL within an owner-level Search Console property accessible to the connected Google account. This is a read-only notification-receipt lookup, not an index-coverage report and not proof that Google crawled, indexed, or removed the URL. It is available only in readwrite access mode because Google's metadata endpoint requires the Indexing API OAuth scope.",
1712
+ inputSchema: {
1713
+ site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA.describe(`${SEARCH_CONSOLE_PROPERTY_DESCRIPTION} The connected Google account must be an owner of this property.`),
1714
+ url: z.string().superRefine((value, ctx) => {
1715
+ try {
1716
+ assertIndexingRequestUrl(value);
1717
+ } catch (error) {
1718
+ ctx.addIssue({
1719
+ code: 'custom',
1720
+ message: (error as Error).message,
1721
+ });
1722
+ }
1723
+ }).describe('A fully qualified HTTP/HTTPS URL previously submitted successfully through the Google Indexing API. It must fall within site_url.'),
1724
+ },
1725
+ outputSchema: INDEXING_STATUS_OUTPUT_SCHEMA,
1726
+ annotations: READ_ONLY_ANNOTATIONS,
1727
+ },
1728
+ async ({ site_url, url }) => {
1729
+ const googleId = this.requireGoogleId();
1730
+ const rateLimitError = await this.rateLimitError(googleId, 'indexing.status');
1731
+ if (rateLimitError) return rateLimitError;
1732
+ const accessToken = await this.getAccessToken(googleId);
1733
+ const sites = await listSites(accessToken);
1734
+ assertIndexingUrlAuthorized(url, site_url, sites);
1735
+ const notificationMetadata = await getIndexingNotificationMetadata(
1736
+ accessToken,
1737
+ url,
1738
+ );
1739
+ const payload = {
1740
+ notification_metadata: notificationMetadata,
1741
+ notification_receipt_only: true as const,
1742
+ index_coverage_report: false as const,
1743
+ indexing_or_removal_completion_proven: false as const,
1744
+ note: "This reports the latest successful Indexing API notification receipt(s) Google recorded for the URL. It does not report whether Google crawled, indexed, or removed the URL; use URL Inspection for indexed-state evidence.",
1745
+ };
1746
+ return toolResponse(JSON.stringify(payload, null, 2), payload);
1747
+ },
1748
+ );
1749
+
1659
1750
  this.server.registerTool(
1660
1751
  'indexing.request',
1661
1752
  {
1662
1753
  title: 'Request Indexing',
1663
- description: "Requests indexing through Google's Indexing API for a URL within an owner-level Search Console property accessible to the connected Google account. 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.",
1754
+ description: "Requests indexing through Google's Indexing API for a URL within an owner-level Search Console property accessible to the connected Google account. 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-per-day project quota is for onboarding/testing rather than ongoing-use approval; additional approval is required for usage/resource provisioning, and all submissions are subject to spam detection.",
1664
1755
  inputSchema: {
1665
1756
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA.describe(`${SEARCH_CONSOLE_PROPERTY_DESCRIPTION} The connected Google account must be an owner of this property.`),
1666
1757
  url: z.string().superRefine((value, ctx) => {
@@ -1688,6 +1779,10 @@ class GscMcpRuntime {
1688
1779
  const payload = {
1689
1780
  result,
1690
1781
  note: "Google accepting this notification does not guarantee the URL will be indexed. Indexing remains at Google's discretion.",
1782
+ provider_default_quota_for_testing_only: true as const,
1783
+ provider_usage_approval_required: true as const,
1784
+ provider_spam_detection_applies: true as const,
1785
+ provider_usage_note: INDEXING_PROVIDER_USAGE_NOTE,
1691
1786
  };
1692
1787
  return toolResponse(JSON.stringify(payload, null, 2), payload);
1693
1788
  },
@@ -60,12 +60,22 @@ export const TOOL_RATE_LIMIT_POLICIES = {
60
60
  indexing_request: {
61
61
  category: 'indexing-request',
62
62
  tools: ['indexing.request'],
63
- // Google defaults to 200 publish requests per project per day. Two per
64
- // user reserves the default quota for up to 100 authenticated users.
63
+ // Google's 200 publish/day default is onboarding/testing quota and does
64
+ // not itself authorize ongoing use. Mirror that number only as a local
65
+ // safety ceiling; two per user reserves it for up to 100 users.
65
66
  windowMs: DAY_MS,
66
67
  userLimit: 2,
67
68
  projectLimit: 200,
68
69
  },
70
+ indexing_metadata: {
71
+ category: 'indexing-metadata',
72
+ tools: ['indexing.status'],
73
+ // Google publishes a default metadata-read quota of 180 requests per
74
+ // minute per project. Keep per-user headroom inside that shared ceiling.
75
+ windowMs: MINUTE_MS,
76
+ userLimit: 30,
77
+ projectLimit: 180,
78
+ },
69
79
  weekly_digest: {
70
80
  category: 'weekly-digest',
71
81
  tools: ['reports.weekly_digest'],
@@ -79,6 +89,16 @@ export type ToolRateLimitCategory = keyof typeof TOOL_RATE_LIMIT_POLICIES;
79
89
  export type RateLimitedToolName =
80
90
  (typeof TOOL_RATE_LIMIT_POLICIES)[ToolRateLimitCategory]['tools'][number];
81
91
 
92
+ const TOOL_RATE_LIMIT_UNITS: Partial<Record<RateLimitedToolName, number>> = {
93
+ // These helpers can fan one MCP call out into several Search Analytics
94
+ // requests. Reserve the worst-case upstream call count before starting so
95
+ // a single helper cannot bypass the shared 10-minute safety budget.
96
+ 'insights.quick_wins': 4,
97
+ 'insights.cannibalization': 4,
98
+ 'insights.content_decay': 8,
99
+ 'analytics.compare': 8,
100
+ };
101
+
82
102
  const POLICY_BY_TOOL = new Map<string, ToolRateLimitPolicy>(
83
103
  Object.values(TOOL_RATE_LIMIT_POLICIES).flatMap((policy) =>
84
104
  policy.tools.map((tool) => [tool, policy] as const),
@@ -194,6 +214,10 @@ export function getToolRateLimitPolicy(toolName: string): ToolRateLimitPolicy |
194
214
  return POLICY_BY_TOOL.get(toolName);
195
215
  }
196
216
 
217
+ export function getToolRateLimitUnits(toolName: RateLimitedToolName): number {
218
+ return TOOL_RATE_LIMIT_UNITS[toolName] ?? 1;
219
+ }
220
+
197
221
  async function userBucketFor(googleId: string): Promise<string> {
198
222
  const digest = await crypto.subtle.digest(
199
223
  'SHA-256',