@digestseo/mcp-gsc 0.4.1 → 0.4.3
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 +15 -0
- package/README.md +5 -5
- package/SETUP.md +1 -1
- package/package.json +2 -2
- package/src/google.ts +10 -0
- package/src/index.ts +46 -24
- package/src/quick-wins-schema.ts +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.3] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- The documented and npm-declared Node.js minimum is now 22+, matching the bundled Wrangler 4 runtime requirement instead of advertising unsupported Node.js 20 installs.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- URL Inspection guidance now states that Google reports the version currently in its index rather than running a live URL test, and no longer presents the deprecated mobile-usability field as a current primary signal.
|
|
17
|
+
|
|
18
|
+
## [0.4.2] - 2026-09-21
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- Cannibalization totals and impression shares now expose their query/page-row aggregation scope explicitly so clients do not mistake summed page impressions for a true query-level Search Console total.
|
|
22
|
+
- Discover Search Analytics calls now reject unsupported query grouping/filtering before reaching Google, and query-centric/comparison tools no longer advertise Discover where their contracts depend on query or average-position data that Discover does not provide.
|
|
23
|
+
- Quick-win guidance now treats Search Console position as an aggregate average-position metric rather than a literal current rank, and metadata no longer implies CTR is an eligibility filter when the implementation does not filter on CTR.
|
|
24
|
+
|
|
10
25
|
## [0.4.1] - 2026-09-21
|
|
11
26
|
|
|
12
27
|
### Changed
|
package/README.md
CHANGED
|
@@ -75,10 +75,10 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
|
|
|
75
75
|
| **`sites.get`** | Read | Retrieve one exact Search Console property and the connected account's permission level for it. |
|
|
76
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`. |
|
|
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
|
-
| **`urls.inspect`** | Read | Google's URL Inspection report for
|
|
79
|
-
| **`urls.inspect_many`** | Read | Inspect 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. |
|
|
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
|
+
| **`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. |
|
|
80
80
|
| **`sitemaps.list`** / **`sitemaps.get`** | Read | List submitted sitemaps or retrieve one sitemap's details. |
|
|
81
|
-
| **`insights.quick_wins`** / **`insights.cannibalization`** / **`insights.content_decay`** | Read | Surface
|
|
81
|
+
| **`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 distinguish likely decay from weak evidence and improving visibility with click volatility. |
|
|
82
82
|
| **`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. `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. |
|
|
83
83
|
| **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. |
|
|
84
84
|
| **`sites.add`** / **`sites.delete`** | Write | Add or remove a Search Console property. |
|
|
@@ -97,8 +97,8 @@ Once connected, ask your assistant things like:
|
|
|
97
97
|
|
|
98
98
|
- *"What are my top 20 queries by clicks in the last 28 days?"*
|
|
99
99
|
- *"Compare impressions for example.com this month vs last month — which pages dropped?"*
|
|
100
|
-
- *"
|
|
101
|
-
- *"Which
|
|
100
|
+
- *"What does Google's indexed version of `https://example.com/pricing` show for index status and last crawl?"*
|
|
101
|
+
- *"Which high-impression query/page rows have an average position between 5 and 15? Show me those optimization opportunities."*
|
|
102
102
|
- *"Give me a weekly digest for `sc-domain:example.com` ending today."*
|
|
103
103
|
- *"Do any of my sitemaps have errors or warnings?"*
|
|
104
104
|
- *"Split my clicks into brand vs non-brand using a regex on the query."*
|
package/SETUP.md
CHANGED
|
@@ -10,7 +10,7 @@ By the end you'll have a Worker at `https://<your-worker>.workers.dev/mcp` that
|
|
|
10
10
|
|
|
11
11
|
- A [Cloudflare account](https://dash.cloudflare.com/sign-up) (the free Workers plan is enough to start).
|
|
12
12
|
- A [Google account](https://accounts.google.com/) with access to the Search Console properties you want to query.
|
|
13
|
-
- Node.js
|
|
13
|
+
- Node.js 22+ and npm. The bundled Wrangler 4 runtime requires Node 22 or newer.
|
|
14
14
|
- The Wrangler CLI — installed for you by `npm install`; invoke it with `npx wrangler …`.
|
|
15
15
|
|
|
16
16
|
Clone and install:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@digestseo/mcp-gsc",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "scripts/glama-start.mjs",
|
|
6
6
|
"bin": {
|
|
@@ -87,6 +87,6 @@
|
|
|
87
87
|
"vitest": "^4.1.11"
|
|
88
88
|
},
|
|
89
89
|
"engines": {
|
|
90
|
-
"node": ">=
|
|
90
|
+
"node": ">=22.0.0"
|
|
91
91
|
}
|
|
92
92
|
}
|
package/src/google.ts
CHANGED
|
@@ -247,6 +247,8 @@ export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery)
|
|
|
247
247
|
const filters = body.dimensionFilterGroups?.flatMap((group) => group.filters) ?? [];
|
|
248
248
|
const hasPageGroupingOrFilter =
|
|
249
249
|
body.dimensions.includes('page') || filters.some((filter) => filter.dimension === 'page');
|
|
250
|
+
const hasQueryGroupingOrFilter =
|
|
251
|
+
body.dimensions.includes('query') || filters.some((filter) => filter.dimension === 'query');
|
|
250
252
|
|
|
251
253
|
for (const filter of filters) {
|
|
252
254
|
if (filter.expression.length > 4096) {
|
|
@@ -267,6 +269,12 @@ export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery)
|
|
|
267
269
|
}
|
|
268
270
|
}
|
|
269
271
|
|
|
272
|
+
if (body.type === 'discover' && hasQueryGroupingOrFilter) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
'Google Discover does not support the query dimension or query filters in Search Analytics.',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
270
278
|
if (body.aggregationType === 'byNewsShowcasePanel') {
|
|
271
279
|
if (body.type !== 'discover' && body.type !== 'googleNews') {
|
|
272
280
|
throw new Error(
|
|
@@ -720,6 +728,7 @@ export interface CannibalizationResult {
|
|
|
720
728
|
query: string;
|
|
721
729
|
total_clicks: number;
|
|
722
730
|
total_impressions: number;
|
|
731
|
+
aggregation_scope: 'observed_query_page_rows';
|
|
723
732
|
pages: CannibalizationPage[];
|
|
724
733
|
}
|
|
725
734
|
|
|
@@ -763,6 +772,7 @@ export function processCannibalization(
|
|
|
763
772
|
query,
|
|
764
773
|
total_clicks: totalClicks,
|
|
765
774
|
total_impressions: totalImpressions,
|
|
775
|
+
aggregation_scope: 'observed_query_page_rows',
|
|
766
776
|
pages: competingPages.sort(
|
|
767
777
|
(a, b) => b.impressions - a.impressions || a.page.localeCompare(b.page),
|
|
768
778
|
),
|
package/src/index.ts
CHANGED
|
@@ -172,14 +172,19 @@ const SITE_DETAIL_OUTPUT_SCHEMA = {
|
|
|
172
172
|
site: z.object(SITE_OUTPUT_SCHEMA),
|
|
173
173
|
};
|
|
174
174
|
|
|
175
|
+
const URL_INSPECTION_SCOPE_NOTE =
|
|
176
|
+
"Google's URL Inspection API reports the version currently known in Google's index; it does not run a live URL test or prove current live-page indexability. The mobileUsabilityResult field is deprecated and may be absent.";
|
|
177
|
+
|
|
175
178
|
const INSPECTION_OUTPUT_SCHEMA = {
|
|
176
179
|
inspection_result: z.unknown(),
|
|
180
|
+
note: z.string(),
|
|
177
181
|
};
|
|
178
182
|
|
|
179
183
|
const INSPECTION_BATCH_OUTPUT_SCHEMA = {
|
|
180
184
|
requested_count: z.number().int().positive(),
|
|
181
185
|
succeeded_count: z.number().int().nonnegative(),
|
|
182
186
|
failed_count: z.number().int().nonnegative(),
|
|
187
|
+
note: z.string(),
|
|
183
188
|
results: z.array(
|
|
184
189
|
z.object({
|
|
185
190
|
inspection_url: z.string(),
|
|
@@ -258,6 +263,7 @@ const RESULT_START_ROW_SCHEMA = z
|
|
|
258
263
|
.describe('Zero-based offset into the deterministically ranked result list.');
|
|
259
264
|
|
|
260
265
|
const QUICK_WIN_OUTPUT_SCHEMA = {
|
|
266
|
+
note: z.string(),
|
|
261
267
|
quick_wins: z.array(
|
|
262
268
|
z.object({
|
|
263
269
|
query: z.string(),
|
|
@@ -297,15 +303,24 @@ const CANNIBALIZATION_OUTPUT_SCHEMA = {
|
|
|
297
303
|
candidates: z.array(
|
|
298
304
|
z.object({
|
|
299
305
|
query: z.string(),
|
|
300
|
-
total_clicks: z.number()
|
|
301
|
-
|
|
306
|
+
total_clicks: z.number().describe(
|
|
307
|
+
'Sum of clicks across the observed query/page Search Analytics rows for this query. This is not a separately queried query-level aggregate.',
|
|
308
|
+
),
|
|
309
|
+
total_impressions: z.number().describe(
|
|
310
|
+
'Sum of impressions across the observed query/page Search Analytics rows for this query. This can exceed the query-level property aggregate when multiple pages appear for the same search, and it can be incomplete when source pagination is truncated.',
|
|
311
|
+
),
|
|
312
|
+
aggregation_scope: z.literal('observed_query_page_rows').describe(
|
|
313
|
+
'Makes explicit that total_clicks, total_impressions, and impression_share are calculated from observed query/page rows rather than true query-level Search Console totals.',
|
|
314
|
+
),
|
|
302
315
|
page_count: z.number().int().nonnegative(),
|
|
303
316
|
pages_truncated: z.boolean(),
|
|
304
317
|
pages: z.array(
|
|
305
318
|
z.object({
|
|
306
319
|
page: z.string(),
|
|
307
320
|
...METRIC_OUTPUT_SCHEMA,
|
|
308
|
-
impression_share: z.number()
|
|
321
|
+
impression_share: z.number().describe(
|
|
322
|
+
'Percentage of the observed query/page impression sum represented by this page; not a share of unique searches or the true query-level property aggregate.',
|
|
323
|
+
),
|
|
309
324
|
}),
|
|
310
325
|
),
|
|
311
326
|
}),
|
|
@@ -513,12 +528,12 @@ const TOOL_CATALOG = [
|
|
|
513
528
|
{
|
|
514
529
|
name: 'urls.inspect',
|
|
515
530
|
description:
|
|
516
|
-
"Inspect a single URL
|
|
531
|
+
"Inspect Google's indexed version of a single URL: index status, last crawl, page-fetch/indexing state, canonicals, and rich-results/AMP analysis where available. This is not a live URL test; mobile usability is a deprecated response field. Use urls.inspect_many for 2-10 URLs.",
|
|
517
532
|
},
|
|
518
533
|
{
|
|
519
534
|
name: 'urls.inspect_many',
|
|
520
535
|
description:
|
|
521
|
-
|
|
536
|
+
"Inspect Google's indexed versions of up to 10 URLs sequentially in one MCP call while charging the same URL Inspection safety budget per URL. This does not run live URL tests.",
|
|
522
537
|
},
|
|
523
538
|
{
|
|
524
539
|
name: 'sitemaps.list',
|
|
@@ -543,12 +558,12 @@ const TOOL_CATALOG = [
|
|
|
543
558
|
{
|
|
544
559
|
name: 'insights.quick_wins',
|
|
545
560
|
description:
|
|
546
|
-
'Find
|
|
561
|
+
'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 metric, not a literal current rank; CTR is context, not an eligibility filter.',
|
|
547
562
|
},
|
|
548
563
|
{
|
|
549
564
|
name: 'insights.cannibalization',
|
|
550
565
|
description:
|
|
551
|
-
'Analyze
|
|
566
|
+
'Analyze query/page Search Analytics for queries surfaced by multiple pages. Candidate totals and impression shares are explicitly scoped to observed query/page rows, not true query-level property totals.',
|
|
552
567
|
},
|
|
553
568
|
{
|
|
554
569
|
name: 'insights.content_decay',
|
|
@@ -709,7 +724,7 @@ class GscMcpRuntime {
|
|
|
709
724
|
'urls.inspect',
|
|
710
725
|
{
|
|
711
726
|
title: 'Inspect URL index status',
|
|
712
|
-
description: `Inspect a single URL
|
|
727
|
+
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.`,
|
|
713
728
|
inputSchema: {
|
|
714
729
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
715
730
|
inspection_url: z
|
|
@@ -738,8 +753,9 @@ class GscMcpRuntime {
|
|
|
738
753
|
inspection_url,
|
|
739
754
|
language_code,
|
|
740
755
|
);
|
|
741
|
-
return toolResponse(JSON.stringify(result, null, 2), {
|
|
756
|
+
return toolResponse(JSON.stringify({ note: URL_INSPECTION_SCOPE_NOTE, inspection_result: result }, null, 2), {
|
|
742
757
|
inspection_result: result,
|
|
758
|
+
note: URL_INSPECTION_SCOPE_NOTE,
|
|
743
759
|
});
|
|
744
760
|
},
|
|
745
761
|
);
|
|
@@ -748,7 +764,7 @@ class GscMcpRuntime {
|
|
|
748
764
|
'urls.inspect_many',
|
|
749
765
|
{
|
|
750
766
|
title: 'Inspect multiple URLs',
|
|
751
|
-
description: `Inspect up to 10 URLs from one Search Console property
|
|
767
|
+
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.`,
|
|
752
768
|
inputSchema: {
|
|
753
769
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
754
770
|
inspection_urls: z
|
|
@@ -799,6 +815,7 @@ class GscMcpRuntime {
|
|
|
799
815
|
requested_count: inspection_urls.length,
|
|
800
816
|
succeeded_count: succeededCount,
|
|
801
817
|
failed_count: results.length - succeededCount,
|
|
818
|
+
note: URL_INSPECTION_SCOPE_NOTE,
|
|
802
819
|
results,
|
|
803
820
|
};
|
|
804
821
|
return toolResponse(JSON.stringify(payload, null, 2), payload);
|
|
@@ -871,6 +888,10 @@ class GscMcpRuntime {
|
|
|
871
888
|
'- CTR in the response is a 0–1 fraction. Multiply by 100 for percent.',
|
|
872
889
|
'- Use search_type to query image/video/news/discover indexes',
|
|
873
890
|
' separately from web.',
|
|
891
|
+
'- Google Discover does not support query grouping/filtering or',
|
|
892
|
+
' average position. For Discover, use page/country/device/date/',
|
|
893
|
+
' searchAppearance dimensions and interpret clicks, impressions,',
|
|
894
|
+
' and CTR; query dimensions/filters are rejected before the API call.',
|
|
874
895
|
'- News Showcase panel reporting: set aggregation_type to',
|
|
875
896
|
' "byNewsShowcasePanel", use search_type "discover" or',
|
|
876
897
|
' "googleNews", and include a searchAppearance equals',
|
|
@@ -906,7 +927,7 @@ class GscMcpRuntime {
|
|
|
906
927
|
)
|
|
907
928
|
.default(['query'])
|
|
908
929
|
.describe(
|
|
909
|
-
'Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals.',
|
|
930
|
+
'Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals. Google Discover does not support the query dimension.',
|
|
910
931
|
),
|
|
911
932
|
row_limit: z
|
|
912
933
|
.number()
|
|
@@ -934,7 +955,7 @@ class GscMcpRuntime {
|
|
|
934
955
|
search_type: z
|
|
935
956
|
.enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
|
|
936
957
|
.default('web')
|
|
937
|
-
.describe('Which search index to query. Defaults to web.'),
|
|
958
|
+
.describe('Which search index to query. Defaults to web. Google Discover does not support query grouping/filtering or average position.'),
|
|
938
959
|
aggregation_type: z
|
|
939
960
|
.enum(['auto', 'byNewsShowcasePanel', 'byPage', 'byProperty'])
|
|
940
961
|
.default('auto')
|
|
@@ -969,7 +990,7 @@ class GscMcpRuntime {
|
|
|
969
990
|
)
|
|
970
991
|
.optional()
|
|
971
992
|
.describe(
|
|
972
|
-
"Optional filters ANDed together, e.g. [{ groupType: 'and', filters: [{ dimension: 'country', operator: 'equals', expression: 'usa' }] }]. Countries use ISO 3166-1 alpha-3 codes.",
|
|
993
|
+
"Optional filters ANDed together, e.g. [{ groupType: 'and', filters: [{ dimension: 'country', operator: 'equals', expression: 'usa' }] }]. Countries use ISO 3166-1 alpha-3 codes. Query filters are not supported when search_type is discover.",
|
|
973
994
|
),
|
|
974
995
|
},
|
|
975
996
|
outputSchema: SEARCH_ANALYTICS_OUTPUT_SCHEMA,
|
|
@@ -1041,16 +1062,16 @@ class GscMcpRuntime {
|
|
|
1041
1062
|
'insights.page_queries',
|
|
1042
1063
|
{
|
|
1043
1064
|
title: 'Find queries for a page',
|
|
1044
|
-
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.',
|
|
1065
|
+
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.',
|
|
1045
1066
|
inputSchema: {
|
|
1046
1067
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1047
1068
|
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.'),
|
|
1048
1069
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1049
1070
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
1050
1071
|
search_type: z
|
|
1051
|
-
.enum(['web', 'image', 'video', 'news', '
|
|
1072
|
+
.enum(['web', 'image', 'video', 'news', 'googleNews'])
|
|
1052
1073
|
.default('web')
|
|
1053
|
-
.describe('Which search index to query. Defaults to web.'),
|
|
1074
|
+
.describe('Which search index to query. Defaults to web. Discover is not supported because this tool groups by query.'),
|
|
1054
1075
|
row_limit: z
|
|
1055
1076
|
.number()
|
|
1056
1077
|
.int()
|
|
@@ -1119,16 +1140,16 @@ class GscMcpRuntime {
|
|
|
1119
1140
|
'insights.query_pages',
|
|
1120
1141
|
{
|
|
1121
1142
|
title: 'Find pages for a query',
|
|
1122
|
-
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.',
|
|
1143
|
+
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.',
|
|
1123
1144
|
inputSchema: {
|
|
1124
1145
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1125
1146
|
query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
|
|
1126
1147
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1127
1148
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
1128
1149
|
search_type: z
|
|
1129
|
-
.enum(['web', 'image', 'video', 'news', '
|
|
1150
|
+
.enum(['web', 'image', 'video', 'news', 'googleNews'])
|
|
1130
1151
|
.default('web')
|
|
1131
|
-
.describe('Which search index to query. Defaults to web.'),
|
|
1152
|
+
.describe('Which search index to query. Defaults to web. Discover is not supported because this tool filters by query.'),
|
|
1132
1153
|
row_limit: z
|
|
1133
1154
|
.number()
|
|
1134
1155
|
.int()
|
|
@@ -1309,7 +1330,7 @@ class GscMcpRuntime {
|
|
|
1309
1330
|
'insights.quick_wins',
|
|
1310
1331
|
{
|
|
1311
1332
|
title: 'Identify SEO Quick Wins',
|
|
1312
|
-
description: 'Find
|
|
1333
|
+
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.',
|
|
1313
1334
|
inputSchema: createQuickWinsInputSchema(SITE_URL_DESCRIPTION),
|
|
1314
1335
|
outputSchema: z.object(QUICK_WIN_OUTPUT_SCHEMA),
|
|
1315
1336
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
@@ -1329,6 +1350,7 @@ class GscMcpRuntime {
|
|
|
1329
1350
|
const quickWins = processQuickWins(source.rows, min_impressions, min_position, max_position);
|
|
1330
1351
|
const bounded = windowKnownResults(quickWins, start_row, limit);
|
|
1331
1352
|
const payload = {
|
|
1353
|
+
note: 'Candidates are observed query/page rows selected by Search Console average position, not proof of a stable or current rank. CTR is reported for context and does not affect eligibility.',
|
|
1332
1354
|
quick_wins: bounded.items,
|
|
1333
1355
|
pagination: paginationMetadata(source),
|
|
1334
1356
|
result_page: bounded.resultPage,
|
|
@@ -1341,7 +1363,7 @@ class GscMcpRuntime {
|
|
|
1341
1363
|
'insights.cannibalization',
|
|
1342
1364
|
{
|
|
1343
1365
|
title: 'Detect Keyword Cannibalization',
|
|
1344
|
-
description: 'Analyze query/page Search Analytics to find queries split across multiple pages. Candidates are ranked deterministically by
|
|
1366
|
+
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.',
|
|
1345
1367
|
inputSchema: {
|
|
1346
1368
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1347
1369
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
@@ -1571,7 +1593,7 @@ class GscMcpRuntime {
|
|
|
1571
1593
|
'analytics.compare',
|
|
1572
1594
|
{
|
|
1573
1595
|
title: 'Compare Performance Between Periods',
|
|
1574
|
-
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.',
|
|
1596
|
+
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.',
|
|
1575
1597
|
inputSchema: {
|
|
1576
1598
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1577
1599
|
start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
|
|
@@ -1580,9 +1602,9 @@ class GscMcpRuntime {
|
|
|
1580
1602
|
end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period B (previous, YYYY-MM-DD)'),
|
|
1581
1603
|
dimension: z.enum(['query', 'page', 'country', 'device']).default('query').describe('The dimension to compare performance for. Defaults to query.'),
|
|
1582
1604
|
search_type: z
|
|
1583
|
-
.enum(['web', 'image', 'video', 'news', '
|
|
1605
|
+
.enum(['web', 'image', 'video', 'news', 'googleNews'])
|
|
1584
1606
|
.default('web')
|
|
1585
|
-
.describe('Which Search Console search index to compare. The same search type is used for both periods.'),
|
|
1607
|
+
.describe('Which Search Console search index to compare. The same search type is used for both periods. Discover is excluded because this tool returns average-position comparisons.'),
|
|
1586
1608
|
dimension_filter_groups: z
|
|
1587
1609
|
.array(
|
|
1588
1610
|
z.object({
|
package/src/quick-wins-schema.ts
CHANGED
|
@@ -11,9 +11,9 @@ const POSITION_RANGE_ERROR = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
const QUICK_WINS_THRESHOLDS_SHAPE = {
|
|
14
|
-
min_impressions: z.number().int().min(0).default(100).describe('Minimum impressions required to consider
|
|
15
|
-
min_position: z.number().positive().default(8).describe('Minimum average position
|
|
16
|
-
max_position: z.number().positive().default(20).describe('Maximum average position
|
|
14
|
+
min_impressions: z.number().int().min(0).default(100).describe('Minimum impressions required to consider an observed query/page row. Must be at least 0. Default is 100.'),
|
|
15
|
+
min_position: z.number().positive().default(8).describe('Minimum 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 8.'),
|
|
16
|
+
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
17
|
};
|
|
18
18
|
|
|
19
19
|
export function createQuickWinsInputSchema(siteUrlDescription: string) {
|
|
@@ -29,13 +29,13 @@ export function createQuickWinsInputSchema(siteUrlDescription: string) {
|
|
|
29
29
|
.min(1)
|
|
30
30
|
.max(MAX_ANALYSIS_RESULT_LIMIT)
|
|
31
31
|
.default(DEFAULT_ANALYSIS_RESULT_LIMIT)
|
|
32
|
-
.describe('Maximum
|
|
32
|
+
.describe('Maximum ordered quick-win results to return in this response.'),
|
|
33
33
|
start_row: z
|
|
34
34
|
.number()
|
|
35
35
|
.int()
|
|
36
36
|
.min(0)
|
|
37
37
|
.default(0)
|
|
38
|
-
.describe('Zero-based offset into the
|
|
38
|
+
.describe('Zero-based offset into the ordered quick-win result list.'),
|
|
39
39
|
})
|
|
40
40
|
.refine(
|
|
41
41
|
({ min_position, max_position }) => min_position <= max_position,
|