@digestseo/mcp-gsc 0.4.6 → 0.4.8
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 +12 -0
- package/README.md +5 -3
- package/package.json +1 -1
- package/scripts/glama-start.mjs +1 -0
- package/src/digest.ts +14 -13
- package/src/google.ts +70 -41
- package/src/index.ts +15 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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
|
+
## [0.4.8] - 2026-09-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Safe Google read operations now make a tightly bounded retry on transient `408`, `429`, and `5xx` provider failures while leaving Search Console writes and OAuth/token mutation paths single-attempt; long `Retry-After` windows are surfaced immediately instead of stalling an MCP call.
|
|
12
|
+
- The published `npx` launcher now forwards `GSC_ACCESS_MODE`, and MCP Registry package metadata exposes the same setting, so local self-hosters can actually select least-privilege `readonly` mode instead of silently inheriting the template's `readwrite` binding.
|
|
13
|
+
|
|
14
|
+
## [0.4.7] - 2026-09-21
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Weekly-digest query movers no longer treat a query missing from one bounded top-query response as zero traffic; comparisons now require the query to be present in both weekly result sets and explain that Search Analytics does not guarantee every data row.
|
|
18
|
+
- Period comparison and content-decay analysis no longer fabricate zero metrics for dimension keys or pages missing from one non-exhaustive Search Analytics response; they now compare only rows returned in both periods and expose that scope explicitly.
|
|
19
|
+
|
|
8
20
|
## [0.4.6] - 2026-09-21
|
|
9
21
|
|
|
10
22
|
### Fixed
|
package/README.md
CHANGED
|
@@ -24,6 +24,8 @@ npx -y @digestseo/mcp-gsc
|
|
|
24
24
|
|
|
25
25
|
The npm launcher starts a loopback-only **Streamable HTTP** Worker on `127.0.0.1` (port `8080` by default); it is not a stdio MCP process. Clients that launch the package should connect to `http://127.0.0.1:8080/mcp` after supplying the Google OAuth and token-encryption environment variables described in [SETUP.md](SETUP.md). Before the first OAuth sign-in, authorize the exact callback `http://127.0.0.1:8080/google/callback` in Google Cloud (or use the same custom `PORT` you launch with); the launcher prints both URLs at startup.
|
|
26
26
|
|
|
27
|
+
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 five mutation tools stay unregistered.
|
|
28
|
+
|
|
27
29
|
For Google OAuth and Cloudflare deployment configuration, follow [SETUP.md](SETUP.md).
|
|
28
30
|
|
|
29
31
|
### Connect in 30 seconds
|
|
@@ -78,9 +80,9 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
|
|
|
78
80
|
| **`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
81
|
| **`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
82
|
| **`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 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
|
|
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
|
-
| **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. |
|
|
83
|
+
| **`insights.quick_wins`** / **`insights.cannibalization`** / **`insights.content_decay`** | Read | Surface average-position opportunity rows, query/page overlap, and evidence-ranked click declines. Quick-win candidates are observed query/page rows selected by aggregate Search Console average position, not proof of a stable current rank, and CTR is context rather than an eligibility filter. Cannibalization candidate totals/shares are scoped to observed query/page rows rather than true query-level property totals; content-decay results compare only pages returned in both periods and do not turn one-sided row absence into zero traffic. |
|
|
84
|
+
| **`indexing.list_pages`** / **`analytics.compare`** | Read | Analyze pages receiving Search Console impressions and compare two periods, optionally using the same search type and query/page/country/device/search-appearance filters for both periods. `analytics.compare` compares only dimension keys returned in both period responses; a key absent from one non-exhaustive Search Analytics response is not assumed to have zero metrics. `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. |
|
|
85
|
+
| **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. Query movers compare only rows returned in both weekly Search Analytics result sets; a query missing from one bounded response is not treated as zero because Google does not guarantee every data row. |
|
|
84
86
|
| **`sites.add`** / **`sites.delete`** | Write | Add or remove a Search Console property. |
|
|
85
87
|
| **`sitemaps.submit`** / **`sitemaps.delete`** | Write | Submit or remove a sitemap. |
|
|
86
88
|
| **`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. |
|
package/package.json
CHANGED
package/scripts/glama-start.mjs
CHANGED
package/src/digest.ts
CHANGED
|
@@ -199,25 +199,22 @@ function rowsToPages(rows: SearchAnalyticsRow[]): PageRow[] {
|
|
|
199
199
|
|
|
200
200
|
function computeMovers(current: QueryRow[], prev: QueryRow[]): Movers {
|
|
201
201
|
const prevByQuery = new Map(prev.map((q) => [q.query, q]));
|
|
202
|
-
const currentByQuery = new Map(current.map((q) => [q.query, q]));
|
|
203
|
-
const allQueries = new Set<string>([
|
|
204
|
-
...current.map((q) => q.query),
|
|
205
|
-
...prev.map((q) => q.query),
|
|
206
|
-
]);
|
|
207
202
|
|
|
208
203
|
const rising: Mover[] = [];
|
|
209
204
|
const falling: Mover[] = [];
|
|
210
205
|
const newImpressions: Mover[] = [];
|
|
211
206
|
|
|
212
|
-
for (const
|
|
213
|
-
const
|
|
207
|
+
for (const c of current) {
|
|
208
|
+
const query = c.query;
|
|
214
209
|
const p = prevByQuery.get(query);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
const
|
|
210
|
+
if (!p) continue;
|
|
211
|
+
|
|
212
|
+
const cClicks = c.clicks;
|
|
213
|
+
const pClicks = p.clicks;
|
|
214
|
+
const cImpr = c.impressions;
|
|
215
|
+
const pImpr = p.impressions;
|
|
216
|
+
const cPos = c.position;
|
|
217
|
+
const pPos = p.position;
|
|
221
218
|
|
|
222
219
|
if (cClicks - pClicks >= 5 && cClicks >= 2 * Math.max(pClicks, 1)) {
|
|
223
220
|
rising.push({
|
|
@@ -403,6 +400,10 @@ function renderMarkdown(data: {
|
|
|
403
400
|
|
|
404
401
|
lines.push('## What changed this week');
|
|
405
402
|
lines.push('');
|
|
403
|
+
lines.push(
|
|
404
|
+
'> Query movers compare only query rows returned in both weekly Search Console result sets. A query missing from one bounded response is not treated as zero because Search Analytics returns top rows and does not guarantee every data row.',
|
|
405
|
+
);
|
|
406
|
+
lines.push('');
|
|
406
407
|
|
|
407
408
|
const hasAnyMovers =
|
|
408
409
|
movers.rising.length > 0 ||
|
package/src/google.ts
CHANGED
|
@@ -51,6 +51,63 @@ export class GoogleRefreshTokenRevokedError extends Error {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const GOOGLE_READ_RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
55
|
+
const GOOGLE_READ_MAX_ATTEMPTS = 3;
|
|
56
|
+
const GOOGLE_READ_BASE_DELAY_MS = 1_000;
|
|
57
|
+
const GOOGLE_READ_MAX_DELAY_MS = 5_000;
|
|
58
|
+
|
|
59
|
+
function googleReadRetryDelayMs(response: Response, retryNumber: number): number | null {
|
|
60
|
+
const retryAfter = response.headers.get('retry-after')?.trim();
|
|
61
|
+
if (retryAfter) {
|
|
62
|
+
const seconds = Number(retryAfter);
|
|
63
|
+
const requestedDelay = Number.isFinite(seconds)
|
|
64
|
+
? Math.max(0, seconds * 1_000)
|
|
65
|
+
: Math.max(0, Date.parse(retryAfter) - Date.now());
|
|
66
|
+
if (Number.isFinite(requestedDelay)) {
|
|
67
|
+
return requestedDelay <= GOOGLE_READ_MAX_DELAY_MS ? requestedDelay : null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const exponentialDelay = GOOGLE_READ_BASE_DELAY_MS * 2 ** (retryNumber - 1);
|
|
72
|
+
const jitterMs = Math.floor(Math.random() * 250);
|
|
73
|
+
return Math.min(exponentialDelay + jitterMs, GOOGLE_READ_MAX_DELAY_MS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function fetchGoogleRead(url: string, init?: RequestInit): Promise<Response> {
|
|
77
|
+
let lastNetworkError: unknown;
|
|
78
|
+
|
|
79
|
+
for (let attempt = 1; attempt <= GOOGLE_READ_MAX_ATTEMPTS; attempt += 1) {
|
|
80
|
+
try {
|
|
81
|
+
const response = await fetch(url, init);
|
|
82
|
+
if (
|
|
83
|
+
!GOOGLE_READ_RETRYABLE_STATUSES.has(response.status) ||
|
|
84
|
+
attempt === GOOGLE_READ_MAX_ATTEMPTS
|
|
85
|
+
) {
|
|
86
|
+
return response;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const delayMs = googleReadRetryDelayMs(response, attempt);
|
|
90
|
+
if (delayMs === null) return response;
|
|
91
|
+
try {
|
|
92
|
+
await response.body?.cancel();
|
|
93
|
+
} catch {
|
|
94
|
+
// Best-effort cleanup before retrying a transient provider response.
|
|
95
|
+
}
|
|
96
|
+
if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
lastNetworkError = error;
|
|
99
|
+
if (attempt === GOOGLE_READ_MAX_ATTEMPTS) throw error;
|
|
100
|
+
const delayMs = Math.min(
|
|
101
|
+
GOOGLE_READ_BASE_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * 250),
|
|
102
|
+
GOOGLE_READ_MAX_DELAY_MS,
|
|
103
|
+
);
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
throw lastNetworkError;
|
|
109
|
+
}
|
|
110
|
+
|
|
54
111
|
export async function exchangeCodeForTokens(
|
|
55
112
|
code: string,
|
|
56
113
|
clientId: string,
|
|
@@ -89,7 +146,7 @@ export interface GoogleUserInfo {
|
|
|
89
146
|
export async function fetchGoogleUserInfo(
|
|
90
147
|
accessToken: string,
|
|
91
148
|
): Promise<GoogleUserInfo> {
|
|
92
|
-
const resp = await
|
|
149
|
+
const resp = await fetchGoogleRead(GOOGLE_USERINFO_URL, {
|
|
93
150
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
94
151
|
});
|
|
95
152
|
if (!resp.ok) {
|
|
@@ -141,7 +198,7 @@ export interface SiteEntry {
|
|
|
141
198
|
}
|
|
142
199
|
|
|
143
200
|
export async function listSites(accessToken: string): Promise<SiteEntry[]> {
|
|
144
|
-
const resp = await
|
|
201
|
+
const resp = await fetchGoogleRead('https://www.googleapis.com/webmasters/v3/sites', {
|
|
145
202
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
146
203
|
});
|
|
147
204
|
if (resp.status === 401) {
|
|
@@ -160,7 +217,7 @@ export async function getSite(
|
|
|
160
217
|
siteUrl: string,
|
|
161
218
|
): Promise<SiteEntry> {
|
|
162
219
|
const encoded = encodeURIComponent(siteUrl);
|
|
163
|
-
const resp = await
|
|
220
|
+
const resp = await fetchGoogleRead(
|
|
164
221
|
`https://www.googleapis.com/webmasters/v3/sites/${encoded}`,
|
|
165
222
|
{
|
|
166
223
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
@@ -381,7 +438,7 @@ export async function inspectUrl(
|
|
|
381
438
|
siteUrl,
|
|
382
439
|
};
|
|
383
440
|
if (languageCode) body.languageCode = languageCode;
|
|
384
|
-
const resp = await
|
|
441
|
+
const resp = await fetchGoogleRead(
|
|
385
442
|
'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',
|
|
386
443
|
{
|
|
387
444
|
method: 'POST',
|
|
@@ -477,7 +534,7 @@ export async function listSitemaps(
|
|
|
477
534
|
const query = sitemapIndex
|
|
478
535
|
? `?sitemapIndex=${encodeURIComponent(sitemapIndex)}`
|
|
479
536
|
: '';
|
|
480
|
-
const resp = await
|
|
537
|
+
const resp = await fetchGoogleRead(
|
|
481
538
|
`https://www.googleapis.com/webmasters/v3/sites/${encoded}/sitemaps${query}`,
|
|
482
539
|
{
|
|
483
540
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
@@ -501,7 +558,7 @@ export async function querySearchAnalytics(
|
|
|
501
558
|
): Promise<SearchAnalyticsResponse> {
|
|
502
559
|
assertSearchAnalyticsQueryCompatible(body);
|
|
503
560
|
const encoded = encodeURIComponent(siteUrl);
|
|
504
|
-
const resp = await
|
|
561
|
+
const resp = await fetchGoogleRead(
|
|
505
562
|
`https://www.googleapis.com/webmasters/v3/sites/${encoded}/searchAnalytics/query`,
|
|
506
563
|
{
|
|
507
564
|
method: 'POST',
|
|
@@ -676,7 +733,7 @@ export async function getSitemap(
|
|
|
676
733
|
): Promise<SitemapEntry> {
|
|
677
734
|
const encodedSite = encodeURIComponent(siteUrl);
|
|
678
735
|
const encodedFeed = encodeURIComponent(feedpath);
|
|
679
|
-
const resp = await
|
|
736
|
+
const resp = await fetchGoogleRead(
|
|
680
737
|
`https://www.googleapis.com/webmasters/v3/sites/${encodedSite}/sitemaps/${encodedFeed}`,
|
|
681
738
|
{
|
|
682
739
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
@@ -925,9 +982,10 @@ export function processContentDecay(
|
|
|
925
982
|
const prevPos = row.position;
|
|
926
983
|
|
|
927
984
|
const recent = recentMap.get(page);
|
|
928
|
-
|
|
929
|
-
const
|
|
930
|
-
const
|
|
985
|
+
if (!recent) continue;
|
|
986
|
+
const recClicks = recent.clicks;
|
|
987
|
+
const recImps = recent.impressions;
|
|
988
|
+
const recPos = recent.position;
|
|
931
989
|
|
|
932
990
|
const clickDiff = recClicks - prevClicks;
|
|
933
991
|
const impDiff = recImps - prevImps;
|
|
@@ -1433,7 +1491,8 @@ export function processPerformanceComparison(
|
|
|
1433
1491
|
const ctrA = row.ctr;
|
|
1434
1492
|
const posA = row.position;
|
|
1435
1493
|
|
|
1436
|
-
const b = mapB.get(key)
|
|
1494
|
+
const b = mapB.get(key);
|
|
1495
|
+
if (!b) continue;
|
|
1437
1496
|
const clicksB = b.clicks;
|
|
1438
1497
|
const impsB = b.impressions;
|
|
1439
1498
|
const ctrB = b.ctr;
|
|
@@ -1457,36 +1516,6 @@ export function processPerformanceComparison(
|
|
|
1457
1516
|
});
|
|
1458
1517
|
}
|
|
1459
1518
|
|
|
1460
|
-
const mapAKeys = new Set(
|
|
1461
|
-
rowsA
|
|
1462
|
-
.map((r) => r.keys?.[0])
|
|
1463
|
-
.filter((key): key is string => key !== undefined),
|
|
1464
|
-
);
|
|
1465
|
-
for (const row of rowsB) {
|
|
1466
|
-
if ((row.keys?.length ?? 0) < 1) continue;
|
|
1467
|
-
const key = row.keys![0];
|
|
1468
|
-
if (mapAKeys.has(key)) continue;
|
|
1469
|
-
|
|
1470
|
-
const clicksB = row.clicks;
|
|
1471
|
-
const impsB = row.impressions;
|
|
1472
|
-
const ctrB = row.ctr;
|
|
1473
|
-
const posB = row.position;
|
|
1474
|
-
|
|
1475
|
-
comparison.push({
|
|
1476
|
-
key,
|
|
1477
|
-
period_a: { clicks: 0, impressions: 0, ctr: 0, position: 0 },
|
|
1478
|
-
period_b: { clicks: clicksB, impressions: impsB, ctr: ctrB, position: posB },
|
|
1479
|
-
diff: {
|
|
1480
|
-
clicks: -clicksB,
|
|
1481
|
-
clicks_percentage: calculatePercentageChange(0, clicksB),
|
|
1482
|
-
impressions: -impsB,
|
|
1483
|
-
impressions_percentage: calculatePercentageChange(0, impsB),
|
|
1484
|
-
ctr: -ctrB,
|
|
1485
|
-
position: -posB,
|
|
1486
|
-
},
|
|
1487
|
-
});
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
1519
|
return comparison.sort(
|
|
1491
1520
|
(a, b) =>
|
|
1492
1521
|
Math.abs(b.diff.clicks) - Math.abs(a.diff.clicks) ||
|
package/src/index.ts
CHANGED
|
@@ -345,6 +345,8 @@ const CONTENT_DECAY_OUTPUT_SCHEMA = {
|
|
|
345
345
|
recent: z.object({ start: z.string(), end: z.string() }),
|
|
346
346
|
previous: z.object({ start: z.string(), end: z.string() }),
|
|
347
347
|
}),
|
|
348
|
+
comparison_scope: z.literal('common_returned_rows_only'),
|
|
349
|
+
comparison_note: z.string(),
|
|
348
350
|
decay_count: z.number().int().nonnegative(),
|
|
349
351
|
assessment_count: z.number().int().nonnegative(),
|
|
350
352
|
decay_results: z.array(
|
|
@@ -393,7 +395,12 @@ const SEARCH_VISIBLE_PAGES_DESCRIPTION =
|
|
|
393
395
|
const SEARCH_VISIBLE_PAGES_NOTE =
|
|
394
396
|
'These are Search Analytics performance rows for pages with recorded impressions in the requested period, not a complete list of indexed URLs. Absence does not mean a URL is unindexed. Use urls.inspect or urls.inspect_many for URL-level index status.';
|
|
395
397
|
|
|
398
|
+
const COMMON_RETURNED_ROWS_COMPARISON_NOTE =
|
|
399
|
+
'Comparisons use only dimension keys returned in both period responses. A key missing from one Search Analytics response is not treated as zero because Google does not guarantee every data row.';
|
|
400
|
+
|
|
396
401
|
const PERFORMANCE_COMPARISON_OUTPUT_SCHEMA = {
|
|
402
|
+
comparison_scope: z.literal('common_returned_rows_only'),
|
|
403
|
+
comparison_note: z.string(),
|
|
397
404
|
comparisons: z.array(
|
|
398
405
|
z.object({
|
|
399
406
|
key: z.string(),
|
|
@@ -584,7 +591,7 @@ const TOOL_CATALOG = [
|
|
|
584
591
|
{
|
|
585
592
|
name: 'insights.content_decay',
|
|
586
593
|
description:
|
|
587
|
-
'Assess page-level click declines across two contiguous periods
|
|
594
|
+
'Assess page-level click declines across two contiguous periods for pages returned in both Search Analytics responses, without treating one-sided row absence as zero traffic.',
|
|
588
595
|
},
|
|
589
596
|
{
|
|
590
597
|
name: 'indexing.request',
|
|
@@ -598,7 +605,7 @@ const TOOL_CATALOG = [
|
|
|
598
605
|
{
|
|
599
606
|
name: 'analytics.compare',
|
|
600
607
|
description:
|
|
601
|
-
'Compare Search Console performance metrics
|
|
608
|
+
'Compare Search Console performance metrics between two date ranges for dimension keys returned in both Search Analytics responses; one-sided row absence is not treated as zero.',
|
|
602
609
|
},
|
|
603
610
|
{
|
|
604
611
|
name: 'reports.weekly_digest',
|
|
@@ -1456,7 +1463,7 @@ class GscMcpRuntime {
|
|
|
1456
1463
|
'insights.content_decay',
|
|
1457
1464
|
{
|
|
1458
1465
|
title: 'Detect Content Decay',
|
|
1459
|
-
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.',
|
|
1466
|
+
description: 'Assess page-level click declines across two contiguous periods without treating every small click change as content decay. Only pages returned in both Search Analytics period responses are compared; a page missing from one response is not treated as zero because Google does not guarantee every data row. 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.',
|
|
1460
1467
|
inputSchema: {
|
|
1461
1468
|
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1462
1469
|
compare_days: CONTENT_DECAY_COMPARE_DAYS_SCHEMA,
|
|
@@ -1510,6 +1517,8 @@ class GscMcpRuntime {
|
|
|
1510
1517
|
recent: { start: recentStart, end: recentEnd },
|
|
1511
1518
|
previous: { start: previousStart, end: previousEnd },
|
|
1512
1519
|
},
|
|
1520
|
+
comparison_scope: 'common_returned_rows_only' as const,
|
|
1521
|
+
comparison_note: COMMON_RETURNED_ROWS_COMPARISON_NOTE,
|
|
1513
1522
|
decay_count: decayResults.filter(
|
|
1514
1523
|
(result) => result.classification === 'likely_decay',
|
|
1515
1524
|
).length,
|
|
@@ -1628,7 +1637,7 @@ class GscMcpRuntime {
|
|
|
1628
1637
|
'analytics.compare',
|
|
1629
1638
|
{
|
|
1630
1639
|
title: 'Compare Performance Between Periods',
|
|
1631
|
-
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
|
|
1640
|
+
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 is intentionally unavailable because this comparison contract includes average position, which the Discover report does not support.',
|
|
1632
1641
|
inputSchema: {
|
|
1633
1642
|
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1634
1643
|
start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
|
|
@@ -1718,6 +1727,8 @@ class GscMcpRuntime {
|
|
|
1718
1727
|
const comparison = processPerformanceComparison(sourceA.rows, sourceB.rows);
|
|
1719
1728
|
const bounded = windowKnownResults(comparison, start_row, limit);
|
|
1720
1729
|
const payload = {
|
|
1730
|
+
comparison_scope: 'common_returned_rows_only' as const,
|
|
1731
|
+
comparison_note: COMMON_RETURNED_ROWS_COMPARISON_NOTE,
|
|
1721
1732
|
comparisons: bounded.items,
|
|
1722
1733
|
pagination: {
|
|
1723
1734
|
period_a: paginationMetadata(sourceA),
|