@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 +14 -0
- package/README.md +8 -5
- package/SECURITY.md +4 -3
- package/SETUP.md +2 -2
- package/package.json +1 -1
- package/src/access-mode.ts +1 -0
- package/src/date-validation.ts +7 -1
- package/src/digest.ts +14 -8
- package/src/google.ts +164 -17
- package/src/index.ts +124 -19
- package/src/quick-wins-schema.ts +12 -3
- package/src/tool-rate-limit.ts +1 -1
- package/src/write-tool-annotations.ts +10 -5
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
|
|
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
|
|
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
|
|
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
|
-
>
|
|
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.
|
|
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
|
|
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
package/src/access-mode.ts
CHANGED
package/src/date-validation.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1462
|
-
|
|
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
|
-
|
|
1465
|
-
|
|
1466
|
-
if (!
|
|
1467
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1300
|
-
|
|
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(
|
|
1378
|
-
|
|
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(
|
|
1612
|
-
|
|
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(
|
|
1831
|
-
|
|
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(
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
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(
|
|
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,
|
package/src/quick-wins-schema.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
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(
|
|
25
|
-
|
|
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()
|
package/src/tool-rate-limit.ts
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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,
|