@digestseo/mcp-gsc 0.4.16 → 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,14 @@ 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
+
10
18
  ## [0.4.16] - 2026-09-22
11
19
 
12
20
  ### 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,13 +87,14 @@ 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.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. |
92
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
 
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
@@ -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.16",
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';
@@ -301,6 +302,13 @@ const SITE_ADD_OUTPUT_SCHEMA = {
301
302
  ownership_verification_note: z.string(),
302
303
  };
303
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
+
304
312
  const SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA = z.object({
305
313
  rows_fetched: z.number().int().nonnegative(),
306
314
  pages_fetched: z.number().int().positive(),
@@ -453,6 +461,24 @@ const INDEXING_OUTPUT_SCHEMA = {
453
461
  provider_usage_note: z.string(),
454
462
  };
455
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(),
480
+ };
481
+
456
482
  const INDEXED_PAGES_OUTPUT_SCHEMA = {
457
483
  pages: z.array(z.object({ page: z.string(), ...METRIC_OUTPUT_SCHEMA })),
458
484
  note: z.string(),
@@ -604,7 +630,7 @@ const TOOL_CATALOG = [
604
630
  {
605
631
  name: 'sites.delete',
606
632
  description:
607
- '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.",
608
634
  },
609
635
  {
610
636
  name: 'analytics.query',
@@ -671,6 +697,11 @@ const TOOL_CATALOG = [
671
697
  description:
672
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.',
673
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.',
704
+ },
674
705
  {
675
706
  name: 'indexing.list_pages',
676
707
  description: SEARCH_VISIBLE_PAGES_DESCRIPTION,
@@ -1413,12 +1444,12 @@ class GscMcpRuntime {
1413
1444
  this.server.registerTool(
1414
1445
  'sites.delete',
1415
1446
  {
1416
- title: 'Delete Search Console property',
1417
- 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.",
1418
1449
  inputSchema: {
1419
1450
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1420
1451
  },
1421
- outputSchema: MESSAGE_OUTPUT_SCHEMA,
1452
+ outputSchema: SITE_DELETE_OUTPUT_SCHEMA,
1422
1453
  annotations: WRITE_TOOL_ANNOTATIONS['sites.delete'],
1423
1454
  },
1424
1455
  async ({ site_url }) => {
@@ -1427,8 +1458,14 @@ class GscMcpRuntime {
1427
1458
  if (rateLimitError) return rateLimitError;
1428
1459
  const accessToken = await this.getAccessToken(googleId);
1429
1460
  await deleteSite(accessToken, site_url);
1430
- const message = `Successfully deleted site property: ${site_url}`;
1431
- 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);
1432
1469
  },
1433
1470
  );
1434
1471
 
@@ -1667,6 +1704,49 @@ class GscMcpRuntime {
1667
1704
  );
1668
1705
 
1669
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
+
1670
1750
  this.server.registerTool(
1671
1751
  'indexing.request',
1672
1752
  {
@@ -67,6 +67,15 @@ export const TOOL_RATE_LIMIT_POLICIES = {
67
67
  userLimit: 2,
68
68
  projectLimit: 200,
69
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
+ },
70
79
  weekly_digest: {
71
80
  category: 'weekly-digest',
72
81
  tools: ['reports.weekly_digest'],