@digestseo/mcp-gsc 0.4.16 → 0.4.18

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.18] - 2026-09-22
11
+
12
+ ### Fixed
13
+ - The published npm launcher now keeps `GOOGLE_CLIENT_SECRET` and `TOKEN_ENCRYPTION_KEY` out of Wrangler `--var` command-line arguments; Wrangler loads those declared local secrets directly from the child process environment instead.
14
+ - `server.capabilities` now distinguishes stored local Google credentials from live provider authorization, exposing the credential-state basis and explicitly reporting that the discovery call does not probe Google before claiming a connection is usable.
15
+
16
+ ## [0.4.17] - 2026-09-22
17
+
18
+ ### Added
19
+ - `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.
20
+
21
+ ### Fixed
22
+ - `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.
23
+
10
24
  ## [0.4.16] - 2026-09-22
11
25
 
12
26
  ### Fixed
package/README.md CHANGED
@@ -22,7 +22,7 @@ The self-hosting launcher is available as [`@digestseo/mcp-gsc`](https://www.npm
22
22
  npx -y @digestseo/mcp-gsc
23
23
  ```
24
24
 
25
- The npm launcher starts a loopback-only **Streamable HTTP** Worker on `127.0.0.1` (port `8080` by default); it is not a stdio MCP process. You can run the `npx` command from any directory because the launcher resolves its bundled Worker configuration from the installed package. Clients that launch the package should connect to `http://127.0.0.1:8080/mcp` after supplying the Google OAuth and token-encryption environment variables described in [SETUP.md](SETUP.md). Before the first OAuth sign-in, authorize the exact callback `http://127.0.0.1:8080/google/callback` in Google Cloud (or use the same custom `PORT` you launch with); the launcher prints both URLs at startup.
25
+ The npm launcher starts a loopback-only **Streamable HTTP** Worker on `127.0.0.1` (port `8080` by default); it is not a stdio MCP process. You can run the `npx` command from any directory because the launcher resolves its bundled Worker configuration from the installed package. Clients that launch the package should connect to `http://127.0.0.1:8080/mcp` after supplying the Google OAuth and token-encryption environment variables described in [SETUP.md](SETUP.md). The launcher keeps `GOOGLE_CLIENT_SECRET` and `TOKEN_ENCRYPTION_KEY` in the child process environment and Wrangler loads them through declared local secrets; their values are not copied into Wrangler `--var` command-line arguments. Before the first OAuth sign-in, authorize the exact callback `http://127.0.0.1:8080/google/callback` in Google Cloud (or use the same custom `PORT` you launch with); the launcher prints both URLs at startup.
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
 
@@ -72,11 +72,11 @@ 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
  |---|---|---|
79
- | **`server.capabilities`** | Read | List every tool this server exposes and report whether your Google connection is currently authenticated (`connected` / `not_connected`). Takes no arguments — a good first call for discovery. |
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
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`. |
@@ -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
@@ -112,7 +112,7 @@ openssl rand -base64 32
112
112
 
113
113
  Paste that value when prompted. Keep it safe — if you lose it, the refresh tokens already in KV become undecryptable and every user has to reconnect.
114
114
 
115
- For **local development**, put the same three values in `.dev.vars` instead — copy `.dev.vars.example` to `.dev.vars` (it's gitignored):
115
+ For **local development**, put the same three values in `.dev.vars` instead — copy `.dev.vars.example` to `.dev.vars` (it's gitignored). The published npm launcher can also read them from the process environment; its bundled Wrangler config declares `GOOGLE_CLIENT_SECRET` and `TOKEN_ENCRYPTION_KEY` as required local secrets so those sensitive values do not need to be serialized into `--var` command-line arguments:
116
116
 
117
117
  ```
118
118
  GOOGLE_CLIENT_ID=...
@@ -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.18",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
@@ -16,8 +16,6 @@ const stateDir = process.env.MCP_GSC_STATE_DIR
16
16
  : resolve(homedir(), '.mcp-gsc', 'state');
17
17
  const forwardedVariables = [
18
18
  'GOOGLE_CLIENT_ID',
19
- 'GOOGLE_CLIENT_SECRET',
20
- 'TOKEN_ENCRYPTION_KEY',
21
19
  'GSC_ACCESS_MODE',
22
20
  'GLAMA_INSPECTION_MODE',
23
21
  ];
@@ -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';
@@ -190,7 +191,18 @@ const CAPABILITIES_OUTPUT_SCHEMA = {
190
191
  server: z.string(),
191
192
  version: z.string(),
192
193
  access_mode: z.enum(['readonly', 'readwrite']),
193
- auth_status: z.enum(['connected', 'not_connected', 'unknown']),
194
+ auth_status: z.enum(['connected', 'not_connected', 'unknown']).describe(
195
+ "Local credential state: 'connected' means a stored Google refresh credential is present; it does not live-verify that Google still accepts it.",
196
+ ),
197
+ auth_status_basis: z.enum([
198
+ 'stored_refresh_token',
199
+ 'no_stored_refresh_token',
200
+ 'local_state_unavailable',
201
+ ]),
202
+ provider_auth_live_verified: z.literal(false).describe(
203
+ 'Always false because server.capabilities does not make a Google token or API request merely to probe authorization.',
204
+ ),
205
+ auth_note: z.string(),
194
206
  tools: z.array(
195
207
  z.object({
196
208
  name: z.string(),
@@ -301,6 +313,13 @@ const SITE_ADD_OUTPUT_SCHEMA = {
301
313
  ownership_verification_note: z.string(),
302
314
  };
303
315
 
316
+ const SITE_DELETE_OUTPUT_SCHEMA = {
317
+ message: z.string(),
318
+ removed_from_connected_account_site_set: z.literal(true),
319
+ website_content_deleted: z.literal(false),
320
+ provider_scope_note: z.string(),
321
+ };
322
+
304
323
  const SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA = z.object({
305
324
  rows_fetched: z.number().int().nonnegative(),
306
325
  pages_fetched: z.number().int().positive(),
@@ -453,6 +472,24 @@ const INDEXING_OUTPUT_SCHEMA = {
453
472
  provider_usage_note: z.string(),
454
473
  };
455
474
 
475
+ const INDEXING_NOTIFICATION_SCHEMA = z.object({
476
+ url: z.string().optional(),
477
+ type: z.string().optional(),
478
+ notifyTime: z.string().optional(),
479
+ });
480
+
481
+ const INDEXING_STATUS_OUTPUT_SCHEMA = {
482
+ notification_metadata: z.object({
483
+ url: z.string().optional(),
484
+ latestUpdate: INDEXING_NOTIFICATION_SCHEMA.optional(),
485
+ latestRemove: INDEXING_NOTIFICATION_SCHEMA.optional(),
486
+ }),
487
+ notification_receipt_only: z.literal(true),
488
+ index_coverage_report: z.literal(false),
489
+ indexing_or_removal_completion_proven: z.literal(false),
490
+ note: z.string(),
491
+ };
492
+
456
493
  const INDEXED_PAGES_OUTPUT_SCHEMA = {
457
494
  pages: z.array(z.object({ page: z.string(), ...METRIC_OUTPUT_SCHEMA })),
458
495
  note: z.string(),
@@ -604,7 +641,7 @@ const TOOL_CATALOG = [
604
641
  {
605
642
  name: 'sites.delete',
606
643
  description:
607
- 'Remove an existing website property from your Google Search Console account.',
644
+ "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
645
  },
609
646
  {
610
647
  name: 'analytics.query',
@@ -671,6 +708,11 @@ const TOOL_CATALOG = [
671
708
  description:
672
709
  '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
710
  },
711
+ {
712
+ name: 'indexing.status',
713
+ description:
714
+ '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.',
715
+ },
674
716
  {
675
717
  name: 'indexing.list_pages',
676
718
  description: SEARCH_VISIBLE_PAGES_DESCRIPTION,
@@ -688,7 +730,7 @@ const TOOL_CATALOG = [
688
730
  {
689
731
  name: 'server.capabilities',
690
732
  description:
691
- "List every tool this server exposes and report whether the user's Google Search Console connection is currently authenticated.",
733
+ "List every tool this server exposes and report local Google credential state. A connected status means a stored refresh credential is present; it is not a live Google authorization check.",
692
734
  },
693
735
  ] as const;
694
736
 
@@ -745,32 +787,49 @@ class GscMcpRuntime {
745
787
  {
746
788
  title: 'Get server capabilities and auth status',
747
789
  description:
748
- "List every tool this server exposes, its configured access mode, and whether the user's Google Search Console connection is currently authenticated. Call this first if you're unsure what tools are available, whether the deployment is read-only, or whether the user is connected. Returns the tool catalog plus an access mode and auth status. Takes no arguments.",
790
+ "List every tool this server exposes, its configured access mode, and local Google credential state. Call this first if you're unsure what tools are available, whether the deployment is read-only, or whether stored Google credentials are configured. auth_status='connected' means a stored refresh credential is present; this tool does not make a Google token/API request and therefore does not live-verify that Google still accepts it. Takes no arguments.",
749
791
  inputSchema: {},
750
792
  outputSchema: CAPABILITIES_OUTPUT_SCHEMA,
751
793
  annotations: READ_ONLY_ANNOTATIONS,
752
794
  },
753
795
  async () => {
754
796
  let authStatus: 'connected' | 'not_connected' | 'unknown';
797
+ let authStatusBasis:
798
+ | 'stored_refresh_token'
799
+ | 'no_stored_refresh_token'
800
+ | 'local_state_unavailable';
755
801
  try {
756
802
  const googleId = this.props?.google_id;
757
803
  if (!googleId) {
758
804
  authStatus = 'not_connected';
805
+ authStatusBasis = 'no_stored_refresh_token';
759
806
  } else {
760
807
  const refreshToken = await getDecryptedRefreshToken(
761
808
  this.env,
762
809
  googleId,
763
810
  );
764
811
  authStatus = refreshToken ? 'connected' : 'not_connected';
812
+ authStatusBasis = refreshToken
813
+ ? 'stored_refresh_token'
814
+ : 'no_stored_refresh_token';
765
815
  }
766
816
  } catch {
767
817
  authStatus = 'unknown';
818
+ authStatusBasis = 'local_state_unavailable';
768
819
  }
820
+ const authNote = authStatus === 'connected'
821
+ ? "Stored Google refresh credentials are present, but server.capabilities does not live-verify them with Google. Refresh tokens can expire or be revoked; a Google tool call may still require reconnection."
822
+ : authStatus === 'not_connected'
823
+ ? 'No stored Google refresh credential is available. Reconnect this server in your MCP client to sign in with Google.'
824
+ : 'Local Google credential state could not be read. Provider authorization was not checked.';
769
825
  const capabilities = {
770
826
  server: SERVER_NAME,
771
827
  version: SERVER_VERSION,
772
828
  access_mode: accessMode,
773
829
  auth_status: authStatus,
830
+ auth_status_basis: authStatusBasis,
831
+ provider_auth_live_verified: false as const,
832
+ auth_note: authNote,
774
833
  tools: getToolCatalogForAccessMode(TOOL_CATALOG, accessMode),
775
834
  provider_limits: {
776
835
  generative_ai_performance_report: {
@@ -778,7 +837,9 @@ class GscMcpRuntime {
778
837
  note: GENERATIVE_AI_REPORT_API_NOTE,
779
838
  },
780
839
  },
781
- hint: "If auth_status is not 'connected', the user should reconnect this server in their MCP client to sign in with Google.",
840
+ hint: authStatus === 'connected'
841
+ ? 'Stored Google credentials are configured. If a Google tool later reports revoked access, reconnect this server in your MCP client.'
842
+ : 'Reconnect this server in your MCP client to sign in with Google.',
782
843
  };
783
844
  return toolResponse(JSON.stringify(capabilities, null, 2), capabilities);
784
845
  },
@@ -1413,12 +1474,12 @@ class GscMcpRuntime {
1413
1474
  this.server.registerTool(
1414
1475
  'sites.delete',
1415
1476
  {
1416
- title: 'Delete Search Console property',
1417
- description: 'Remove an existing website property from your Google Search Console account.',
1477
+ title: 'Remove Search Console property from account',
1478
+ 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
1479
  inputSchema: {
1419
1480
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1420
1481
  },
1421
- outputSchema: MESSAGE_OUTPUT_SCHEMA,
1482
+ outputSchema: SITE_DELETE_OUTPUT_SCHEMA,
1422
1483
  annotations: WRITE_TOOL_ANNOTATIONS['sites.delete'],
1423
1484
  },
1424
1485
  async ({ site_url }) => {
@@ -1427,8 +1488,14 @@ class GscMcpRuntime {
1427
1488
  if (rateLimitError) return rateLimitError;
1428
1489
  const accessToken = await this.getAccessToken(googleId);
1429
1490
  await deleteSite(accessToken, site_url);
1430
- const message = `Successfully deleted site property: ${site_url}`;
1431
- return toolResponse(message, { message });
1491
+ const message = `Successfully removed site property from the connected account's Search Console site set: ${site_url}`;
1492
+ const payload = {
1493
+ message,
1494
+ removed_from_connected_account_site_set: true as const,
1495
+ website_content_deleted: false as const,
1496
+ 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.",
1497
+ };
1498
+ return toolResponse(`${message}\n\n${payload.provider_scope_note}`, payload);
1432
1499
  },
1433
1500
  );
1434
1501
 
@@ -1667,6 +1734,49 @@ class GscMcpRuntime {
1667
1734
  );
1668
1735
 
1669
1736
  if (accessMode === 'readwrite') {
1737
+ this.server.registerTool(
1738
+ 'indexing.status',
1739
+ {
1740
+ title: 'Get Indexing API notification status',
1741
+ 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.",
1742
+ inputSchema: {
1743
+ site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA.describe(`${SEARCH_CONSOLE_PROPERTY_DESCRIPTION} The connected Google account must be an owner of this property.`),
1744
+ url: z.string().superRefine((value, ctx) => {
1745
+ try {
1746
+ assertIndexingRequestUrl(value);
1747
+ } catch (error) {
1748
+ ctx.addIssue({
1749
+ code: 'custom',
1750
+ message: (error as Error).message,
1751
+ });
1752
+ }
1753
+ }).describe('A fully qualified HTTP/HTTPS URL previously submitted successfully through the Google Indexing API. It must fall within site_url.'),
1754
+ },
1755
+ outputSchema: INDEXING_STATUS_OUTPUT_SCHEMA,
1756
+ annotations: READ_ONLY_ANNOTATIONS,
1757
+ },
1758
+ async ({ site_url, url }) => {
1759
+ const googleId = this.requireGoogleId();
1760
+ const rateLimitError = await this.rateLimitError(googleId, 'indexing.status');
1761
+ if (rateLimitError) return rateLimitError;
1762
+ const accessToken = await this.getAccessToken(googleId);
1763
+ const sites = await listSites(accessToken);
1764
+ assertIndexingUrlAuthorized(url, site_url, sites);
1765
+ const notificationMetadata = await getIndexingNotificationMetadata(
1766
+ accessToken,
1767
+ url,
1768
+ );
1769
+ const payload = {
1770
+ notification_metadata: notificationMetadata,
1771
+ notification_receipt_only: true as const,
1772
+ index_coverage_report: false as const,
1773
+ indexing_or_removal_completion_proven: false as const,
1774
+ 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.",
1775
+ };
1776
+ return toolResponse(JSON.stringify(payload, null, 2), payload);
1777
+ },
1778
+ );
1779
+
1670
1780
  this.server.registerTool(
1671
1781
  'indexing.request',
1672
1782
  {
@@ -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'],
@@ -8,6 +8,9 @@
8
8
  "compatibility_date": "2026-05-19",
9
9
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
10
10
  "observability": { "enabled": true },
11
+ "secrets": {
12
+ "required": ["GOOGLE_CLIENT_SECRET", "TOKEN_ENCRYPTION_KEY"]
13
+ },
11
14
  "vars": {
12
15
  "GSC_ACCESS_MODE": "readwrite"
13
16
  },