@digestseo/mcp-gsc 0.4.3 → 0.4.5
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 +11 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/google.ts +17 -0
- package/src/index.ts +55 -33
- package/src/quick-wins-schema.ts +3 -2
- package/src/search-console-property-schema.ts +45 -0
- package/src/sitemap-url-schema.ts +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,17 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.4.5] - 2026-09-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Search Console property inputs now reject malformed, relative, unsupported-scheme, credential-bearing, and malformed `sc-domain:` identifiers locally across all tools instead of deferring obvious property-identifier errors to Google.
|
|
12
|
+
- Sitemap get/list-filter/submit/delete inputs now reject malformed, relative, and non-HTTP(S) sitemap URLs locally instead of deferring obvious contract errors to Google.
|
|
13
|
+
|
|
14
|
+
## [0.4.4] - 2026-09-21
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Search Analytics requests grouped by `hour` now require the documented `hourly_all` data state and reject hourly windows longer than Google's documented 10-day limit before calling the provider.
|
|
18
|
+
- Search Analytics outputs now distinguish local pagination completeness from Google's provider-level limitation that only top rows are guaranteed, preventing agents from treating a fully fetched local window as an exhaustive dataset.
|
|
9
19
|
|
|
10
20
|
## [0.4.3] - 2026-09-21
|
|
11
21
|
|
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
|
|
|
73
73
|
| **`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. |
|
|
74
74
|
| **`sites.list`** | Read | List the Search Console properties the connected Google account can access (`siteUrl`, `permissionLevel`). |
|
|
75
75
|
| **`sites.get`** | Read | Retrieve one exact Search Console property and the connected account's permission level for it. |
|
|
76
|
-
| **`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`. |
|
|
76
|
+
| **`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. |
|
|
77
77
|
| **`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. |
|
|
78
78
|
| **`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. |
|
|
79
79
|
| **`urls.inspect_many`** | Read | Inspect Google's indexed versions of up to 10 URLs sequentially in one call. 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. |
|
package/package.json
CHANGED
package/src/google.ts
CHANGED
|
@@ -239,11 +239,28 @@ export interface SearchAnalyticsQuery {
|
|
|
239
239
|
dimensionFilterGroups?: DimensionFilterGroup[];
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
function inclusiveCalendarDayCount(startDate: string, endDate: string): number {
|
|
243
|
+
const [startYear, startMonth, startDay] = startDate.split('-').map(Number);
|
|
244
|
+
const [endYear, endMonth, endDay] = endDate.split('-').map(Number);
|
|
245
|
+
const startUtc = Date.UTC(startYear, startMonth - 1, startDay);
|
|
246
|
+
const endUtc = Date.UTC(endYear, endMonth - 1, endDay);
|
|
247
|
+
return Math.floor((endUtc - startUtc) / 86_400_000) + 1;
|
|
248
|
+
}
|
|
249
|
+
|
|
242
250
|
export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery): void {
|
|
243
251
|
if (new Set(body.dimensions).size !== body.dimensions.length) {
|
|
244
252
|
throw new Error('Search Analytics dimensions must not contain duplicates.');
|
|
245
253
|
}
|
|
246
254
|
|
|
255
|
+
const hasHourDimension = body.dimensions.includes('hour');
|
|
256
|
+
const usesHourlyData = body.dataState === 'hourly_all';
|
|
257
|
+
if (hasHourDimension && !usesHourlyData) {
|
|
258
|
+
throw new Error('Search Analytics hour dimension requires dataState hourly_all.');
|
|
259
|
+
}
|
|
260
|
+
if (hasHourDimension && inclusiveCalendarDayCount(body.startDate, body.endDate) > 10) {
|
|
261
|
+
throw new Error('Search Analytics hourly queries support at most 10 inclusive calendar days.');
|
|
262
|
+
}
|
|
263
|
+
|
|
247
264
|
const filters = body.dimensionFilterGroups?.flatMap((group) => group.filters) ?? [];
|
|
248
265
|
const hasPageGroupingOrFilter =
|
|
249
266
|
body.dimensions.includes('page') || filters.some((filter) => filter.dimension === 'page');
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,11 @@ import {
|
|
|
56
56
|
import { CONTENT_DECAY_COMPARE_DAYS_SCHEMA } from './content-decay-schema';
|
|
57
57
|
import { resolveIndexedPagesDateRange } from './indexed-pages-range';
|
|
58
58
|
import { createQuickWinsInputSchema } from './quick-wins-schema';
|
|
59
|
+
import { SITEMAP_URL_SCHEMA } from './sitemap-url-schema';
|
|
60
|
+
import {
|
|
61
|
+
SEARCH_CONSOLE_PROPERTY_DESCRIPTION,
|
|
62
|
+
SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
63
|
+
} from './search-console-property-schema';
|
|
59
64
|
import { WRITE_TOOL_ANNOTATIONS } from './write-tool-annotations';
|
|
60
65
|
import {
|
|
61
66
|
getToolCatalogForAccessMode,
|
|
@@ -104,9 +109,6 @@ const READ_ONLY_ANNOTATIONS = {
|
|
|
104
109
|
openWorldHint: true,
|
|
105
110
|
} as const;
|
|
106
111
|
|
|
107
|
-
const SITE_URL_DESCRIPTION =
|
|
108
|
-
"The Search Console property identifier, exactly as returned by sites.list. Two formats exist: domain properties use 'sc-domain:example.com'; URL-prefix properties use the full URL including protocol and trailing slash, e.g. 'https://www.example.com/'. Passing the wrong format returns a permission error even when the user owns the site — call sites.list first if unsure.";
|
|
109
|
-
|
|
110
112
|
const METRIC_OUTPUT_SCHEMA = {
|
|
111
113
|
clicks: z.number(),
|
|
112
114
|
impressions: z.number(),
|
|
@@ -202,6 +204,10 @@ const SEARCH_ANALYTICS_OUTPUT_SCHEMA = {
|
|
|
202
204
|
row_count: z.number().int().nonnegative(),
|
|
203
205
|
start_row: z.number().int().nonnegative(),
|
|
204
206
|
rows: z.array(z.object(SEARCH_ROW_OUTPUT_SCHEMA)),
|
|
207
|
+
provider_exhaustiveness_guaranteed: z.literal(false).describe(
|
|
208
|
+
'False because the Search Analytics API does not guarantee every data row; Google can return only top rows even after local pagination is exhausted.',
|
|
209
|
+
),
|
|
210
|
+
provider_note: z.string(),
|
|
205
211
|
next_start_row: z.number().int().nonnegative().optional(),
|
|
206
212
|
has_more: z.boolean(),
|
|
207
213
|
truncated: z.boolean(),
|
|
@@ -232,6 +238,10 @@ const SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA = z.object({
|
|
|
232
238
|
rows_fetched: z.number().int().nonnegative(),
|
|
233
239
|
pages_fetched: z.number().int().positive(),
|
|
234
240
|
local_limit_reached: z.boolean(),
|
|
241
|
+
provider_exhaustiveness_guaranteed: z.literal(false).describe(
|
|
242
|
+
'False because Google does not guarantee that Search Analytics exposes every data row.',
|
|
243
|
+
),
|
|
244
|
+
provider_note: z.string(),
|
|
235
245
|
});
|
|
236
246
|
|
|
237
247
|
const RESULT_PAGE_OUTPUT_SCHEMA = z.object({
|
|
@@ -425,11 +435,16 @@ function toolResponse<T extends Record<string, unknown>>(
|
|
|
425
435
|
};
|
|
426
436
|
}
|
|
427
437
|
|
|
438
|
+
const SEARCH_ANALYTICS_PROVIDER_NOTE =
|
|
439
|
+
'Google Search Analytics does not guarantee all data rows and can return only top rows. Local pagination fields describe what this server fetched or bounded; they do not prove provider-level exhaustiveness.';
|
|
440
|
+
|
|
428
441
|
function paginationMetadata(result: PaginatedSearchAnalyticsResult) {
|
|
429
442
|
return {
|
|
430
443
|
rows_fetched: result.rows.length,
|
|
431
444
|
pages_fetched: result.pagesFetched,
|
|
432
445
|
local_limit_reached: result.localLimitReached,
|
|
446
|
+
provider_exhaustiveness_guaranteed: false as const,
|
|
447
|
+
provider_note: SEARCH_ANALYTICS_PROVIDER_NOTE,
|
|
433
448
|
};
|
|
434
449
|
}
|
|
435
450
|
|
|
@@ -513,7 +528,7 @@ const TOOL_CATALOG = [
|
|
|
513
528
|
{
|
|
514
529
|
name: 'analytics.query',
|
|
515
530
|
description:
|
|
516
|
-
'Query Search Console search analytics (impressions, clicks, CTR, average position) over a date range, broken down by query, page, country, device, date, or search appearance. Supports filters and pagination.',
|
|
531
|
+
'Query Search Console search analytics (impressions, clicks, CTR, average position) over a date range, broken down by query, page, country, device, date, or search appearance. Supports filters and pagination. Google does not guarantee every data row and can return only top rows.',
|
|
517
532
|
},
|
|
518
533
|
{
|
|
519
534
|
name: 'insights.page_queries',
|
|
@@ -707,7 +722,7 @@ class GscMcpRuntime {
|
|
|
707
722
|
description:
|
|
708
723
|
"Retrieve one exact Google Search Console property and the connected account's permission level for it. Use this when the user has already named a property and you need to confirm that exact property or its access level without listing every property first.",
|
|
709
724
|
inputSchema: {
|
|
710
|
-
site_url:
|
|
725
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
711
726
|
},
|
|
712
727
|
outputSchema: SITE_DETAIL_OUTPUT_SCHEMA,
|
|
713
728
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
@@ -726,7 +741,7 @@ class GscMcpRuntime {
|
|
|
726
741
|
title: 'Inspect URL index status',
|
|
727
742
|
description: `Inspect the version of a single URL currently known in Google's index. Returns Google's index-status analysis, last crawl, page-fetch/indexing state, canonicals, and rich-results/AMP analysis where available. This API does not test the live URL or prove current live-page indexability, and its mobile-usability result is deprecated. Use this when the user asks 'is X indexed?' or wants Google's indexed-state evidence for one page. For a bounded group of 2-10 URLs, prefer urls.inspect_many; Google still processes one URL Inspection request per URL and applies the same quota semantics.`,
|
|
728
743
|
inputSchema: {
|
|
729
|
-
site_url:
|
|
744
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
730
745
|
inspection_url: z
|
|
731
746
|
.string()
|
|
732
747
|
.describe(
|
|
@@ -766,7 +781,7 @@ class GscMcpRuntime {
|
|
|
766
781
|
title: 'Inspect multiple URLs',
|
|
767
782
|
description: `Inspect the versions of up to 10 URLs currently known in Google's index from one Search Console property. This API does not run live URL tests. Google still processes one URL Inspection request per URL, so every requested URL consumes one quota unit and one unit of this server's shared URL-inspection safety budget. Requests are sent sequentially to avoid unnecessary bursts. Use this for a small group of important or debugging-target URLs; do not use it to crawl an entire site.`,
|
|
768
783
|
inputSchema: {
|
|
769
|
-
site_url:
|
|
784
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
770
785
|
inspection_urls: z
|
|
771
786
|
.array(z.string().url())
|
|
772
787
|
.min(1)
|
|
@@ -829,10 +844,8 @@ class GscMcpRuntime {
|
|
|
829
844
|
description:
|
|
830
845
|
'List sitemaps submitted for a Search Console property. Optionally filter to entries included by one sitemap index. Returns sitemap URLs, last submitted/downloaded dates, submitted URL counts, warning and error counts, and sitemap status. Google\'s deprecated sitemap indexed count is intentionally omitted. Use this when the user asks about sitemap health, submission status, wants to audit which sitemaps are working, or needs the child sitemaps belonging to a specific sitemap index.',
|
|
831
846
|
inputSchema: {
|
|
832
|
-
site_url:
|
|
833
|
-
sitemap_index:
|
|
834
|
-
.string()
|
|
835
|
-
.url()
|
|
847
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
848
|
+
sitemap_index: SITEMAP_URL_SCHEMA
|
|
836
849
|
.optional()
|
|
837
850
|
.describe(
|
|
838
851
|
'Optional sitemap index URL. When supplied, Google returns sitemap entries included in that index.',
|
|
@@ -855,13 +868,18 @@ class GscMcpRuntime {
|
|
|
855
868
|
title: 'Query search analytics',
|
|
856
869
|
description: [
|
|
857
870
|
'Query Google Search Console search analytics data. Returns',
|
|
858
|
-
'{ row_count, start_row, rows,
|
|
871
|
+
'{ row_count, start_row, rows, provider_exhaustiveness_guaranteed,',
|
|
872
|
+
' provider_note, has_more, truncated, byte_limit_reached }',
|
|
859
873
|
'where dimensioned rows have keys plus clicks, impressions, ctr, and',
|
|
860
874
|
'position. Aggregate rows from dimensions: [] may omit keys because',
|
|
861
875
|
'Google itself omits that field. When has_more is true, the response',
|
|
862
876
|
'includes next_start_row — pass it back as start_row to fetch the next',
|
|
863
877
|
'safe page. Large requested row_limit values are automatically split',
|
|
864
878
|
'into bounded MCP responses rather than failing structured output.',
|
|
879
|
+
'Google does not guarantee that Search Analytics exposes every data',
|
|
880
|
+
'row; it can return only top rows. Local has_more/pagination fields',
|
|
881
|
+
'therefore describe this server\'s fetch window, not provider-level',
|
|
882
|
+
'exhaustiveness.',
|
|
865
883
|
'When Google provides',
|
|
866
884
|
'them, response_aggregation_type and metadata are also included;',
|
|
867
885
|
'metadata may identify the first incomplete date or hour.',
|
|
@@ -898,7 +916,9 @@ class GscMcpRuntime {
|
|
|
898
916
|
' "NEWS_SHOWCASE" filter. Do not group/filter by page or add',
|
|
899
917
|
' another searchAppearance filter for that aggregation mode.',
|
|
900
918
|
'- For hourly breakdowns, include "hour" in dimensions and set',
|
|
901
|
-
' data_state to "hourly_all".
|
|
919
|
+
' data_state to "hourly_all". The hourly date range can span at',
|
|
920
|
+
' most 10 inclusive days, and hourly data',
|
|
921
|
+
' is preliminary.',
|
|
902
922
|
'- Use dimension_filter_groups to filter by country, device, query',
|
|
903
923
|
' content, page URL, or search feature. includingRegex and',
|
|
904
924
|
' excludingRegex use RE2 syntax. For brand vs non-brand splits,',
|
|
@@ -908,7 +928,7 @@ class GscMcpRuntime {
|
|
|
908
928
|
' non-preliminary data.',
|
|
909
929
|
].join('\n'),
|
|
910
930
|
inputSchema: {
|
|
911
|
-
site_url:
|
|
931
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
912
932
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
913
933
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
|
|
914
934
|
'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day data lag: the most recent complete date is usually 3 days ago.',
|
|
@@ -927,7 +947,7 @@ class GscMcpRuntime {
|
|
|
927
947
|
)
|
|
928
948
|
.default(['query'])
|
|
929
949
|
.describe(
|
|
930
|
-
|
|
950
|
+
"Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals. The 'hour' dimension requires data_state 'hourly_all' and an inclusive date range of at most 10 days. Google Discover does not support the query dimension.",
|
|
931
951
|
),
|
|
932
952
|
row_limit: z
|
|
933
953
|
.number()
|
|
@@ -950,7 +970,7 @@ class GscMcpRuntime {
|
|
|
950
970
|
.enum(['all', 'final', 'hourly_all'])
|
|
951
971
|
.default('all')
|
|
952
972
|
.describe(
|
|
953
|
-
"'all' includes fresh (preliminary) data and matches the GSC dashboard; 'final' returns only finalized data; 'hourly_all'
|
|
973
|
+
"'all' includes fresh (preliminary) data and matches the GSC dashboard; 'final' returns only finalized data; 'hourly_all' includes hourly preliminary data and must be used when grouping by the 'hour' dimension. Hour-grouped requests can span at most 10 inclusive days.",
|
|
954
974
|
),
|
|
955
975
|
search_type: z
|
|
956
976
|
.enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
|
|
@@ -1039,6 +1059,8 @@ class GscMcpRuntime {
|
|
|
1039
1059
|
row_count: bounded.items.length,
|
|
1040
1060
|
start_row,
|
|
1041
1061
|
rows: bounded.items,
|
|
1062
|
+
provider_exhaustiveness_guaranteed: false,
|
|
1063
|
+
provider_note: SEARCH_ANALYTICS_PROVIDER_NOTE,
|
|
1042
1064
|
has_more: bounded.resultPage.has_more,
|
|
1043
1065
|
truncated: bounded.resultPage.truncated,
|
|
1044
1066
|
byte_limit_reached: bounded.resultPage.byte_limit_reached,
|
|
@@ -1064,7 +1086,7 @@ class GscMcpRuntime {
|
|
|
1064
1086
|
title: 'Find queries for a page',
|
|
1065
1087
|
description: 'For one exact page URL, return the Search Console queries that produced impressions for it over a date range. This wraps an exact page dimension filter so agents do not need to construct analytics.query filter groups manually. Exact page matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results; Search Console can still omit anonymized queries. Google Discover is intentionally unavailable because Discover does not expose query data.',
|
|
1066
1088
|
inputSchema: {
|
|
1067
|
-
site_url:
|
|
1089
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1068
1090
|
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.'),
|
|
1069
1091
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1070
1092
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
@@ -1142,7 +1164,7 @@ class GscMcpRuntime {
|
|
|
1142
1164
|
title: 'Find pages for a query',
|
|
1143
1165
|
description: 'For one exact search query, return the site pages that received impressions for it over a date range. This wraps an exact query dimension filter so agents do not need to construct analytics.query filter groups manually. Exact query matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results and verify which URL Google is surfacing before diagnosing cannibalization or content targeting. Google Discover is intentionally unavailable because Discover does not expose query data.',
|
|
1144
1166
|
inputSchema: {
|
|
1145
|
-
site_url:
|
|
1167
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1146
1168
|
query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
|
|
1147
1169
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1148
1170
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
@@ -1221,7 +1243,7 @@ class GscMcpRuntime {
|
|
|
1221
1243
|
title: 'Add Search Console property',
|
|
1222
1244
|
description: 'Add a new website property to your Google Search Console account. Note: Domain properties require sc-domain prefix (e.g., sc-domain:example.com), URL-prefix properties require full URL (e.g., https://example.com/).',
|
|
1223
1245
|
inputSchema: {
|
|
1224
|
-
site_url:
|
|
1246
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1225
1247
|
},
|
|
1226
1248
|
outputSchema: MESSAGE_OUTPUT_SCHEMA,
|
|
1227
1249
|
annotations: WRITE_TOOL_ANNOTATIONS['sites.add'],
|
|
@@ -1243,7 +1265,7 @@ class GscMcpRuntime {
|
|
|
1243
1265
|
title: 'Delete Search Console property',
|
|
1244
1266
|
description: 'Remove an existing website property from your Google Search Console account.',
|
|
1245
1267
|
inputSchema: {
|
|
1246
|
-
site_url:
|
|
1268
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1247
1269
|
},
|
|
1248
1270
|
outputSchema: MESSAGE_OUTPUT_SCHEMA,
|
|
1249
1271
|
annotations: WRITE_TOOL_ANNOTATIONS['sites.delete'],
|
|
@@ -1265,8 +1287,8 @@ class GscMcpRuntime {
|
|
|
1265
1287
|
title: 'Submit sitemap',
|
|
1266
1288
|
description: 'Submit a new sitemap to your Google Search Console account.',
|
|
1267
1289
|
inputSchema: {
|
|
1268
|
-
site_url:
|
|
1269
|
-
feedpath:
|
|
1290
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1291
|
+
feedpath: SITEMAP_URL_SCHEMA.describe('The full HTTP/HTTPS URL of the sitemap file to submit, e.g. https://example.com/sitemap.xml'),
|
|
1270
1292
|
},
|
|
1271
1293
|
outputSchema: MESSAGE_OUTPUT_SCHEMA,
|
|
1272
1294
|
annotations: WRITE_TOOL_ANNOTATIONS['sitemaps.submit'],
|
|
@@ -1288,8 +1310,8 @@ class GscMcpRuntime {
|
|
|
1288
1310
|
title: 'Delete sitemap',
|
|
1289
1311
|
description: 'Remove/delete a submitted sitemap from your Google Search Console account.',
|
|
1290
1312
|
inputSchema: {
|
|
1291
|
-
site_url:
|
|
1292
|
-
feedpath:
|
|
1313
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1314
|
+
feedpath: SITEMAP_URL_SCHEMA.describe('The full HTTP/HTTPS URL of the sitemap file to delete, e.g. https://example.com/sitemap.xml'),
|
|
1293
1315
|
},
|
|
1294
1316
|
outputSchema: MESSAGE_OUTPUT_SCHEMA,
|
|
1295
1317
|
annotations: WRITE_TOOL_ANNOTATIONS['sitemaps.delete'],
|
|
@@ -1312,8 +1334,8 @@ class GscMcpRuntime {
|
|
|
1312
1334
|
title: 'Get sitemap details',
|
|
1313
1335
|
description: 'Get status and details of a single sitemap submitted to Google Search Console.',
|
|
1314
1336
|
inputSchema: {
|
|
1315
|
-
site_url:
|
|
1316
|
-
feedpath:
|
|
1337
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1338
|
+
feedpath: SITEMAP_URL_SCHEMA.describe('The full HTTP/HTTPS URL of the sitemap file, e.g. https://example.com/sitemap.xml'),
|
|
1317
1339
|
},
|
|
1318
1340
|
outputSchema: { sitemap: SITEMAP_OUTPUT_SCHEMA },
|
|
1319
1341
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
@@ -1331,7 +1353,7 @@ class GscMcpRuntime {
|
|
|
1331
1353
|
{
|
|
1332
1354
|
title: 'Identify SEO Quick Wins',
|
|
1333
1355
|
description: 'Find observed query/page Search Analytics rows with at least the requested impressions whose average position falls in a configurable opportunity range (8-20 by default). Average position is an aggregate Search Console metric, not a literal current rank; CTR is returned for context and is not an eligibility filter. Results are ordered deterministically by impressions, then bounded with limit/start_row and explicit result_page metadata. Source pagination separately flags the local 100,000-row safety ceiling.',
|
|
1334
|
-
inputSchema: createQuickWinsInputSchema(
|
|
1356
|
+
inputSchema: createQuickWinsInputSchema(),
|
|
1335
1357
|
outputSchema: z.object(QUICK_WIN_OUTPUT_SCHEMA),
|
|
1336
1358
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
1337
1359
|
},
|
|
@@ -1365,7 +1387,7 @@ class GscMcpRuntime {
|
|
|
1365
1387
|
title: 'Detect Keyword Cannibalization',
|
|
1366
1388
|
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.',
|
|
1367
1389
|
inputSchema: {
|
|
1368
|
-
site_url:
|
|
1390
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1369
1391
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1370
1392
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
1371
1393
|
min_impressions: CANNIBALIZATION_MIN_IMPRESSIONS_SCHEMA,
|
|
@@ -1423,7 +1445,7 @@ class GscMcpRuntime {
|
|
|
1423
1445
|
title: 'Detect Content Decay',
|
|
1424
1446
|
description: 'Assess page-level click declines across two contiguous periods without treating every small click change as content decay. Each result is classified as likely_decay, weak_insufficient_evidence, or improving_visibility_with_click_volatility using deterministic click-volume, impression, and average-position signals. This is a heuristic assessment, not statistical proof. Results are bounded with limit/start_row; source pagination is reported separately.',
|
|
1425
1447
|
inputSchema: {
|
|
1426
|
-
site_url:
|
|
1448
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1427
1449
|
compare_days: CONTENT_DECAY_COMPARE_DAYS_SCHEMA,
|
|
1428
1450
|
limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
|
|
1429
1451
|
start_row: RESULT_START_ROW_SCHEMA,
|
|
@@ -1497,7 +1519,7 @@ class GscMcpRuntime {
|
|
|
1497
1519
|
title: 'Request Indexing',
|
|
1498
1520
|
description: "Requests indexing through Google's Indexing API for a URL within an owner-level Search Console property accessible to the connected Google account. Google currently restricts this API to pages containing JobPosting structured data or livestream pages containing BroadcastEvent inside VideoObject. It is not available for general webpage submission.",
|
|
1499
1521
|
inputSchema: {
|
|
1500
|
-
site_url:
|
|
1522
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA.describe(`${SEARCH_CONSOLE_PROPERTY_DESCRIPTION} The connected Google account must be an owner of this property.`),
|
|
1501
1523
|
url: z.string().superRefine((value, ctx) => {
|
|
1502
1524
|
try {
|
|
1503
1525
|
assertIndexingRequestUrl(value);
|
|
@@ -1535,7 +1557,7 @@ class GscMcpRuntime {
|
|
|
1535
1557
|
title: 'List Search-Visible Pages',
|
|
1536
1558
|
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.`,
|
|
1537
1559
|
inputSchema: {
|
|
1538
|
-
site_url:
|
|
1560
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1539
1561
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.optional().describe('Start date (inclusive) in YYYY-MM-DD format. If end_date is omitted, the generated end date is 29 days later, capped at the latest complete date.'),
|
|
1540
1562
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.optional().describe('End date (inclusive) in YYYY-MM-DD format. If start_date is omitted, the generated start date is 29 days earlier. Defaults to 3 days ago. Note the 2-3 day data lag.'),
|
|
1541
1563
|
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.`),
|
|
@@ -1595,7 +1617,7 @@ class GscMcpRuntime {
|
|
|
1595
1617
|
title: 'Compare Performance Between Periods',
|
|
1596
1618
|
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). 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 the baseline is zero and the comparison value differs. Discover is intentionally unavailable because this comparison contract includes average position, which the Discover report does not support.',
|
|
1597
1619
|
inputSchema: {
|
|
1598
|
-
site_url:
|
|
1620
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1599
1621
|
start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
|
|
1600
1622
|
end_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period A (recent, YYYY-MM-DD)'),
|
|
1601
1623
|
start_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period B (previous, YYYY-MM-DD)'),
|
|
@@ -1701,7 +1723,7 @@ class GscMcpRuntime {
|
|
|
1701
1723
|
description:
|
|
1702
1724
|
'Generate a plain-language weekly SEO report for one Google Search Console property. Returns a markdown digest covering the 7 days ending on end_date, with week-over-week comparison, top pages, queries gaining or losing traction, and one specific action item. Defaults end_date to 3 days ago so the report uses the latest usually-complete Search Console data; pass end_date explicitly to include fresher preliminary data.',
|
|
1703
1725
|
inputSchema: {
|
|
1704
|
-
site_url:
|
|
1726
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1705
1727
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA
|
|
1706
1728
|
.optional()
|
|
1707
1729
|
.describe('End date (inclusive) in YYYY-MM-DD format. Defaults to 3 days ago, which is usually the latest complete Search Console date. Pass a more recent date explicitly to include preliminary data.'),
|
package/src/quick-wins-schema.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { SEARCH_CONSOLE_DATE_SCHEMA } from './date-validation';
|
|
3
|
+
import { SEARCH_CONSOLE_PROPERTY_SCHEMA } from './search-console-property-schema';
|
|
3
4
|
import {
|
|
4
5
|
DEFAULT_ANALYSIS_RESULT_LIMIT,
|
|
5
6
|
MAX_ANALYSIS_RESULT_LIMIT,
|
|
@@ -16,10 +17,10 @@ const QUICK_WINS_THRESHOLDS_SHAPE = {
|
|
|
16
17
|
max_position: z.number().positive().default(20).describe('Maximum Search Console average position for the opportunity range (inclusive). This is an aggregate metric, not a literal current rank. Must be greater than 0. Default is 20.'),
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
export function createQuickWinsInputSchema(
|
|
20
|
+
export function createQuickWinsInputSchema() {
|
|
20
21
|
return z
|
|
21
22
|
.object({
|
|
22
|
-
site_url:
|
|
23
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
23
24
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
24
25
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
25
26
|
...QUICK_WINS_THRESHOLDS_SHAPE,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const SEARCH_CONSOLE_PROPERTY_DESCRIPTION =
|
|
4
|
+
"The Search Console property identifier, exactly as returned by sites.list. Domain properties use 'sc-domain:example.com'; URL-prefix properties use an HTTP or HTTPS URL, typically including the trailing slash, e.g. 'https://www.example.com/'. Passing the wrong property form can return a permission error even when the user owns the site - call sites.list first if unsure.";
|
|
5
|
+
|
|
6
|
+
function isDomainPropertyIdentifier(value: string): boolean {
|
|
7
|
+
if (!value.startsWith('sc-domain:')) return false;
|
|
8
|
+
const domain = value.slice('sc-domain:'.length);
|
|
9
|
+
return (
|
|
10
|
+
domain.length > 0 &&
|
|
11
|
+
!/\s/.test(domain) &&
|
|
12
|
+
!domain.includes('/') &&
|
|
13
|
+
!domain.includes('?') &&
|
|
14
|
+
!domain.includes('#') &&
|
|
15
|
+
!domain.includes('@') &&
|
|
16
|
+
!domain.includes(':')
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isUrlPrefixPropertyIdentifier(value: string): boolean {
|
|
21
|
+
if (value !== value.trim() || /\s/.test(value)) return false;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(value);
|
|
24
|
+
return (
|
|
25
|
+
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
|
26
|
+
parsed.hostname.length > 0 &&
|
|
27
|
+
parsed.username === '' &&
|
|
28
|
+
parsed.password === ''
|
|
29
|
+
);
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isSearchConsolePropertyIdentifier(value: string): boolean {
|
|
36
|
+
return isDomainPropertyIdentifier(value) || isUrlPrefixPropertyIdentifier(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const SEARCH_CONSOLE_PROPERTY_SCHEMA = z
|
|
40
|
+
.string()
|
|
41
|
+
.refine(isSearchConsolePropertyIdentifier, {
|
|
42
|
+
message:
|
|
43
|
+
"Expected a Search Console property identifier such as 'sc-domain:example.com' or an HTTP/HTTPS URL-prefix property such as 'https://www.example.com/'.",
|
|
44
|
+
})
|
|
45
|
+
.describe(SEARCH_CONSOLE_PROPERTY_DESCRIPTION);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const SITEMAP_URL_SCHEMA = z
|
|
4
|
+
.string()
|
|
5
|
+
.url()
|
|
6
|
+
.refine((value) => {
|
|
7
|
+
try {
|
|
8
|
+
const protocol = new URL(value).protocol;
|
|
9
|
+
return protocol === 'http:' || protocol === 'https:';
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}, 'Sitemap URL must use http:// or https://.');
|