@digestseo/mcp-gsc 0.4.0 → 0.4.2
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 +21 -0
- package/README.md +7 -5
- package/package.json +1 -1
- package/src/digest.ts +60 -24
- package/src/google.ts +166 -27
- package/src/index.ts +332 -86
- package/src/quick-wins-schema.ts +20 -3
- package/src/result-bounds.ts +91 -0
package/src/index.ts
CHANGED
|
@@ -66,6 +66,13 @@ import {
|
|
|
66
66
|
assertIndexingRequestUrl,
|
|
67
67
|
assertIndexingUrlAuthorized,
|
|
68
68
|
} from './indexing-property-authorization';
|
|
69
|
+
import {
|
|
70
|
+
DEFAULT_ANALYSIS_RESULT_LIMIT,
|
|
71
|
+
MAX_ANALYSIS_RESULT_LIMIT,
|
|
72
|
+
MAX_DIRECT_SOURCE_ROWS,
|
|
73
|
+
resultPageMetadata,
|
|
74
|
+
takeBoundedItems,
|
|
75
|
+
} from './result-bounds';
|
|
69
76
|
|
|
70
77
|
export interface Env {
|
|
71
78
|
OAUTH_KV: KVNamespace;
|
|
@@ -108,7 +115,12 @@ const METRIC_OUTPUT_SCHEMA = {
|
|
|
108
115
|
};
|
|
109
116
|
|
|
110
117
|
const SEARCH_ROW_OUTPUT_SCHEMA = {
|
|
111
|
-
keys: z
|
|
118
|
+
keys: z
|
|
119
|
+
.array(z.string())
|
|
120
|
+
.optional()
|
|
121
|
+
.describe(
|
|
122
|
+
'Dimension values returned by Google. Aggregate queries with dimensions: [] may legitimately omit this field.',
|
|
123
|
+
),
|
|
112
124
|
...METRIC_OUTPUT_SCHEMA,
|
|
113
125
|
};
|
|
114
126
|
|
|
@@ -186,6 +198,9 @@ const SEARCH_ANALYTICS_OUTPUT_SCHEMA = {
|
|
|
186
198
|
start_row: z.number().int().nonnegative(),
|
|
187
199
|
rows: z.array(z.object(SEARCH_ROW_OUTPUT_SCHEMA)),
|
|
188
200
|
next_start_row: z.number().int().nonnegative().optional(),
|
|
201
|
+
has_more: z.boolean(),
|
|
202
|
+
truncated: z.boolean(),
|
|
203
|
+
byte_limit_reached: z.boolean(),
|
|
189
204
|
response_aggregation_type: z
|
|
190
205
|
.string()
|
|
191
206
|
.optional()
|
|
@@ -214,7 +229,36 @@ const SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA = z.object({
|
|
|
214
229
|
local_limit_reached: z.boolean(),
|
|
215
230
|
});
|
|
216
231
|
|
|
232
|
+
const RESULT_PAGE_OUTPUT_SCHEMA = z.object({
|
|
233
|
+
start_row: z.number().int().nonnegative(),
|
|
234
|
+
limit: z.number().int().positive(),
|
|
235
|
+
returned_count: z.number().int().nonnegative(),
|
|
236
|
+
total_count: z.number().int().nonnegative().optional(),
|
|
237
|
+
has_more: z.boolean(),
|
|
238
|
+
truncated: z.boolean(),
|
|
239
|
+
byte_limit_reached: z.boolean(),
|
|
240
|
+
next_start_row: z.number().int().nonnegative().optional(),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const ANALYSIS_RESULT_LIMIT_SCHEMA = z
|
|
244
|
+
.number()
|
|
245
|
+
.int()
|
|
246
|
+
.min(1)
|
|
247
|
+
.max(MAX_ANALYSIS_RESULT_LIMIT)
|
|
248
|
+
.default(DEFAULT_ANALYSIS_RESULT_LIMIT)
|
|
249
|
+
.describe(
|
|
250
|
+
`Maximum ranked results to return in this response (1-${MAX_ANALYSIS_RESULT_LIMIT}). Responses are also byte-bounded for MCP structured-content safety.`,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const RESULT_START_ROW_SCHEMA = z
|
|
254
|
+
.number()
|
|
255
|
+
.int()
|
|
256
|
+
.min(0)
|
|
257
|
+
.default(0)
|
|
258
|
+
.describe('Zero-based offset into the deterministically ranked result list.');
|
|
259
|
+
|
|
217
260
|
const QUICK_WIN_OUTPUT_SCHEMA = {
|
|
261
|
+
note: z.string(),
|
|
218
262
|
quick_wins: z.array(
|
|
219
263
|
z.object({
|
|
220
264
|
query: z.string(),
|
|
@@ -223,6 +267,7 @@ const QUICK_WIN_OUTPUT_SCHEMA = {
|
|
|
223
267
|
}),
|
|
224
268
|
),
|
|
225
269
|
pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
|
|
270
|
+
result_page: RESULT_PAGE_OUTPUT_SCHEMA,
|
|
226
271
|
};
|
|
227
272
|
|
|
228
273
|
const PAGE_QUERIES_OUTPUT_SCHEMA = {
|
|
@@ -234,6 +279,7 @@ const PAGE_QUERIES_OUTPUT_SCHEMA = {
|
|
|
234
279
|
}),
|
|
235
280
|
),
|
|
236
281
|
pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
|
|
282
|
+
result_page: RESULT_PAGE_OUTPUT_SCHEMA,
|
|
237
283
|
};
|
|
238
284
|
|
|
239
285
|
const QUERY_PAGES_OUTPUT_SCHEMA = {
|
|
@@ -245,24 +291,37 @@ const QUERY_PAGES_OUTPUT_SCHEMA = {
|
|
|
245
291
|
}),
|
|
246
292
|
),
|
|
247
293
|
pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
|
|
294
|
+
result_page: RESULT_PAGE_OUTPUT_SCHEMA,
|
|
248
295
|
};
|
|
249
296
|
|
|
250
297
|
const CANNIBALIZATION_OUTPUT_SCHEMA = {
|
|
251
298
|
candidates: z.array(
|
|
252
299
|
z.object({
|
|
253
300
|
query: z.string(),
|
|
254
|
-
total_clicks: z.number()
|
|
255
|
-
|
|
301
|
+
total_clicks: z.number().describe(
|
|
302
|
+
'Sum of clicks across the observed query/page Search Analytics rows for this query. This is not a separately queried query-level aggregate.',
|
|
303
|
+
),
|
|
304
|
+
total_impressions: z.number().describe(
|
|
305
|
+
'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.',
|
|
306
|
+
),
|
|
307
|
+
aggregation_scope: z.literal('observed_query_page_rows').describe(
|
|
308
|
+
'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.',
|
|
309
|
+
),
|
|
310
|
+
page_count: z.number().int().nonnegative(),
|
|
311
|
+
pages_truncated: z.boolean(),
|
|
256
312
|
pages: z.array(
|
|
257
313
|
z.object({
|
|
258
314
|
page: z.string(),
|
|
259
315
|
...METRIC_OUTPUT_SCHEMA,
|
|
260
|
-
impression_share: z.number()
|
|
316
|
+
impression_share: z.number().describe(
|
|
317
|
+
'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.',
|
|
318
|
+
),
|
|
261
319
|
}),
|
|
262
320
|
),
|
|
263
321
|
}),
|
|
264
322
|
),
|
|
265
323
|
pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
|
|
324
|
+
result_page: RESULT_PAGE_OUTPUT_SCHEMA,
|
|
266
325
|
};
|
|
267
326
|
|
|
268
327
|
const CONTENT_DECAY_OUTPUT_SCHEMA = {
|
|
@@ -271,9 +330,16 @@ const CONTENT_DECAY_OUTPUT_SCHEMA = {
|
|
|
271
330
|
previous: z.object({ start: z.string(), end: z.string() }),
|
|
272
331
|
}),
|
|
273
332
|
decay_count: z.number().int().nonnegative(),
|
|
333
|
+
assessment_count: z.number().int().nonnegative(),
|
|
274
334
|
decay_results: z.array(
|
|
275
335
|
z.object({
|
|
276
336
|
page: z.string(),
|
|
337
|
+
classification: z.enum([
|
|
338
|
+
'likely_decay',
|
|
339
|
+
'weak_insufficient_evidence',
|
|
340
|
+
'improving_visibility_with_click_volatility',
|
|
341
|
+
]),
|
|
342
|
+
evidence: z.string(),
|
|
277
343
|
previous_clicks: z.number(),
|
|
278
344
|
recent_clicks: z.number(),
|
|
279
345
|
click_difference: z.number(),
|
|
@@ -281,14 +347,17 @@ const CONTENT_DECAY_OUTPUT_SCHEMA = {
|
|
|
281
347
|
previous_impressions: z.number(),
|
|
282
348
|
recent_impressions: z.number(),
|
|
283
349
|
impression_difference: z.number(),
|
|
350
|
+
impression_change_percentage: z.number().nullable(),
|
|
284
351
|
previous_position: z.number(),
|
|
285
352
|
recent_position: z.number(),
|
|
353
|
+
position_change: z.number().nullable(),
|
|
286
354
|
}),
|
|
287
355
|
),
|
|
288
356
|
pagination: z.object({
|
|
289
357
|
recent: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
|
|
290
358
|
previous: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
|
|
291
359
|
}),
|
|
360
|
+
result_page: RESULT_PAGE_OUTPUT_SCHEMA,
|
|
292
361
|
};
|
|
293
362
|
|
|
294
363
|
const INDEXING_OUTPUT_SCHEMA = {
|
|
@@ -298,8 +367,16 @@ const INDEXING_OUTPUT_SCHEMA = {
|
|
|
298
367
|
|
|
299
368
|
const INDEXED_PAGES_OUTPUT_SCHEMA = {
|
|
300
369
|
pages: z.array(z.object({ page: z.string(), ...METRIC_OUTPUT_SCHEMA })),
|
|
370
|
+
note: z.string(),
|
|
371
|
+
result_page: RESULT_PAGE_OUTPUT_SCHEMA,
|
|
301
372
|
};
|
|
302
373
|
|
|
374
|
+
const SEARCH_VISIBLE_PAGES_DESCRIPTION =
|
|
375
|
+
'List pages that produced Search Console impressions in the requested period. This is performance data, not an index-coverage inventory: a URL can be indexed even when it is absent here, and Search Analytics can omit some page-level detail. Use urls.inspect or urls.inspect_many for URL-level index status.';
|
|
376
|
+
|
|
377
|
+
const SEARCH_VISIBLE_PAGES_NOTE =
|
|
378
|
+
'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.';
|
|
379
|
+
|
|
303
380
|
const PERFORMANCE_COMPARISON_OUTPUT_SCHEMA = {
|
|
304
381
|
comparisons: z.array(
|
|
305
382
|
z.object({
|
|
@@ -326,6 +403,7 @@ const PERFORMANCE_COMPARISON_OUTPUT_SCHEMA = {
|
|
|
326
403
|
period_a: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
|
|
327
404
|
period_b: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
|
|
328
405
|
}),
|
|
406
|
+
result_page: RESULT_PAGE_OUTPUT_SCHEMA,
|
|
329
407
|
};
|
|
330
408
|
|
|
331
409
|
const WEEKLY_DIGEST_OUTPUT_SCHEMA = {
|
|
@@ -350,6 +428,53 @@ function paginationMetadata(result: PaginatedSearchAnalyticsResult) {
|
|
|
350
428
|
};
|
|
351
429
|
}
|
|
352
430
|
|
|
431
|
+
function windowKnownResults<T>(
|
|
432
|
+
items: readonly T[],
|
|
433
|
+
startRow: number,
|
|
434
|
+
limit: number,
|
|
435
|
+
) {
|
|
436
|
+
const requested = items.slice(startRow, startRow + limit);
|
|
437
|
+
const bounded = takeBoundedItems(requested, limit);
|
|
438
|
+
const returnedCount = bounded.items.length;
|
|
439
|
+
const hasMore =
|
|
440
|
+
startRow + returnedCount < items.length || bounded.byteLimitReached;
|
|
441
|
+
return {
|
|
442
|
+
items: bounded.items,
|
|
443
|
+
resultPage: resultPageMetadata({
|
|
444
|
+
startRow,
|
|
445
|
+
limit,
|
|
446
|
+
returnedCount,
|
|
447
|
+
totalCount: items.length,
|
|
448
|
+
hasMore,
|
|
449
|
+
byteLimitReached: bounded.byteLimitReached,
|
|
450
|
+
}),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function windowSourceRows<T>(
|
|
455
|
+
items: readonly T[],
|
|
456
|
+
sourceStartRow: number,
|
|
457
|
+
requestedLimit: number,
|
|
458
|
+
sourceMayHaveMore: boolean,
|
|
459
|
+
) {
|
|
460
|
+
const bounded = takeBoundedItems(items, requestedLimit);
|
|
461
|
+
const returnedCount = bounded.items.length;
|
|
462
|
+
const hasMore =
|
|
463
|
+
bounded.byteLimitReached ||
|
|
464
|
+
returnedCount < items.length ||
|
|
465
|
+
sourceMayHaveMore;
|
|
466
|
+
return {
|
|
467
|
+
items: bounded.items,
|
|
468
|
+
resultPage: resultPageMetadata({
|
|
469
|
+
startRow: sourceStartRow,
|
|
470
|
+
limit: requestedLimit,
|
|
471
|
+
returnedCount,
|
|
472
|
+
hasMore,
|
|
473
|
+
byteLimitReached: bounded.byteLimitReached,
|
|
474
|
+
}),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
353
478
|
function toolError(message: string) {
|
|
354
479
|
return {
|
|
355
480
|
content: [{ type: 'text' as const, text: message }],
|
|
@@ -428,17 +553,17 @@ const TOOL_CATALOG = [
|
|
|
428
553
|
{
|
|
429
554
|
name: 'insights.quick_wins',
|
|
430
555
|
description:
|
|
431
|
-
'Find
|
|
556
|
+
'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.',
|
|
432
557
|
},
|
|
433
558
|
{
|
|
434
559
|
name: 'insights.cannibalization',
|
|
435
560
|
description:
|
|
436
|
-
'Analyze
|
|
561
|
+
'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.',
|
|
437
562
|
},
|
|
438
563
|
{
|
|
439
564
|
name: 'insights.content_decay',
|
|
440
565
|
description:
|
|
441
|
-
'
|
|
566
|
+
'Assess page-level click declines across two contiguous periods and distinguish likely decay from weak evidence or improving visibility with click volatility.',
|
|
442
567
|
},
|
|
443
568
|
{
|
|
444
569
|
name: 'indexing.request',
|
|
@@ -447,8 +572,7 @@ const TOOL_CATALOG = [
|
|
|
447
572
|
},
|
|
448
573
|
{
|
|
449
574
|
name: 'indexing.list_pages',
|
|
450
|
-
description:
|
|
451
|
-
'Retrieve a list of site pages that have received search impressions, serving as a proxy list of indexed pages on the site. 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.',
|
|
575
|
+
description: SEARCH_VISIBLE_PAGES_DESCRIPTION,
|
|
452
576
|
},
|
|
453
577
|
{
|
|
454
578
|
name: 'analytics.compare',
|
|
@@ -724,10 +848,14 @@ class GscMcpRuntime {
|
|
|
724
848
|
title: 'Query search analytics',
|
|
725
849
|
description: [
|
|
726
850
|
'Query Google Search Console search analytics data. Returns',
|
|
727
|
-
'{ row_count, start_row, rows
|
|
728
|
-
'
|
|
729
|
-
'
|
|
730
|
-
'
|
|
851
|
+
'{ row_count, start_row, rows, has_more, truncated, byte_limit_reached }',
|
|
852
|
+
'where dimensioned rows have keys plus clicks, impressions, ctr, and',
|
|
853
|
+
'position. Aggregate rows from dimensions: [] may omit keys because',
|
|
854
|
+
'Google itself omits that field. When has_more is true, the response',
|
|
855
|
+
'includes next_start_row — pass it back as start_row to fetch the next',
|
|
856
|
+
'safe page. Large requested row_limit values are automatically split',
|
|
857
|
+
'into bounded MCP responses rather than failing structured output.',
|
|
858
|
+
'When Google provides',
|
|
731
859
|
'them, response_aggregation_type and metadata are also included;',
|
|
732
860
|
'metadata may identify the first incomplete date or hour.',
|
|
733
861
|
'',
|
|
@@ -753,6 +881,10 @@ class GscMcpRuntime {
|
|
|
753
881
|
'- CTR in the response is a 0–1 fraction. Multiply by 100 for percent.',
|
|
754
882
|
'- Use search_type to query image/video/news/discover indexes',
|
|
755
883
|
' separately from web.',
|
|
884
|
+
'- Google Discover does not support query grouping/filtering or',
|
|
885
|
+
' average position. For Discover, use page/country/device/date/',
|
|
886
|
+
' searchAppearance dimensions and interpret clicks, impressions,',
|
|
887
|
+
' and CTR; query dimensions/filters are rejected before the API call.',
|
|
756
888
|
'- News Showcase panel reporting: set aggregation_type to',
|
|
757
889
|
' "byNewsShowcasePanel", use search_type "discover" or',
|
|
758
890
|
' "googleNews", and include a searchAppearance equals',
|
|
@@ -788,7 +920,7 @@ class GscMcpRuntime {
|
|
|
788
920
|
)
|
|
789
921
|
.default(['query'])
|
|
790
922
|
.describe(
|
|
791
|
-
'Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals.',
|
|
923
|
+
'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.',
|
|
792
924
|
),
|
|
793
925
|
row_limit: z
|
|
794
926
|
.number()
|
|
@@ -797,7 +929,7 @@ class GscMcpRuntime {
|
|
|
797
929
|
.max(25000)
|
|
798
930
|
.default(100)
|
|
799
931
|
.describe(
|
|
800
|
-
|
|
932
|
+
`Maximum rows requested for this logical page (1-25000). Defaults to 100. MCP responses are additionally capped to at most ${MAX_DIRECT_SOURCE_ROWS} source rows and a structured-content byte budget; use next_start_row while has_more is true for bulk exports.`,
|
|
801
933
|
),
|
|
802
934
|
start_row: z
|
|
803
935
|
.number()
|
|
@@ -816,7 +948,7 @@ class GscMcpRuntime {
|
|
|
816
948
|
search_type: z
|
|
817
949
|
.enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
|
|
818
950
|
.default('web')
|
|
819
|
-
.describe('Which search index to query. Defaults to web.'),
|
|
951
|
+
.describe('Which search index to query. Defaults to web. Google Discover does not support query grouping/filtering or average position.'),
|
|
820
952
|
aggregation_type: z
|
|
821
953
|
.enum(['auto', 'byNewsShowcasePanel', 'byPage', 'byProperty'])
|
|
822
954
|
.default('auto')
|
|
@@ -851,7 +983,7 @@ class GscMcpRuntime {
|
|
|
851
983
|
)
|
|
852
984
|
.optional()
|
|
853
985
|
.describe(
|
|
854
|
-
"Optional filters ANDed together, e.g. [{ groupType: 'and', filters: [{ dimension: 'country', operator: 'equals', expression: 'usa' }] }]. Countries use ISO 3166-1 alpha-3 codes.",
|
|
986
|
+
"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.",
|
|
855
987
|
),
|
|
856
988
|
},
|
|
857
989
|
outputSchema: SEARCH_ANALYTICS_OUTPUT_SCHEMA,
|
|
@@ -874,11 +1006,12 @@ class GscMcpRuntime {
|
|
|
874
1006
|
const rateLimitError = await this.rateLimitError(googleId, 'analytics.query');
|
|
875
1007
|
if (rateLimitError) return rateLimitError;
|
|
876
1008
|
const accessToken = await this.getAccessToken(googleId);
|
|
1009
|
+
const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
|
|
877
1010
|
const response = await querySearchAnalytics(accessToken, site_url, {
|
|
878
1011
|
startDate: start_date,
|
|
879
1012
|
endDate: end_date,
|
|
880
1013
|
dimensions,
|
|
881
|
-
rowLimit:
|
|
1014
|
+
rowLimit: sourceRowLimit,
|
|
882
1015
|
startRow: start_row,
|
|
883
1016
|
dataState: data_state,
|
|
884
1017
|
type: search_type,
|
|
@@ -887,11 +1020,21 @@ class GscMcpRuntime {
|
|
|
887
1020
|
? { dimensionFilterGroups: dimension_filter_groups }
|
|
888
1021
|
: {}),
|
|
889
1022
|
});
|
|
890
|
-
const
|
|
1023
|
+
const sourceMayHaveMore =
|
|
1024
|
+
dimensions.length > 0 && response.rows.length === sourceRowLimit;
|
|
1025
|
+
const bounded = windowSourceRows(
|
|
1026
|
+
response.rows,
|
|
1027
|
+
start_row,
|
|
1028
|
+
row_limit,
|
|
1029
|
+
sourceMayHaveMore,
|
|
1030
|
+
);
|
|
891
1031
|
const payload: Record<string, unknown> = {
|
|
892
|
-
row_count:
|
|
1032
|
+
row_count: bounded.items.length,
|
|
893
1033
|
start_row,
|
|
894
|
-
rows,
|
|
1034
|
+
rows: bounded.items,
|
|
1035
|
+
has_more: bounded.resultPage.has_more,
|
|
1036
|
+
truncated: bounded.resultPage.truncated,
|
|
1037
|
+
byte_limit_reached: bounded.resultPage.byte_limit_reached,
|
|
895
1038
|
};
|
|
896
1039
|
if (response.responseAggregationType !== undefined) {
|
|
897
1040
|
payload.response_aggregation_type = response.responseAggregationType;
|
|
@@ -899,8 +1042,8 @@ class GscMcpRuntime {
|
|
|
899
1042
|
if (response.metadata !== undefined) {
|
|
900
1043
|
payload.metadata = response.metadata;
|
|
901
1044
|
}
|
|
902
|
-
if (
|
|
903
|
-
payload.next_start_row =
|
|
1045
|
+
if (bounded.resultPage.next_start_row !== undefined) {
|
|
1046
|
+
payload.next_start_row = bounded.resultPage.next_start_row;
|
|
904
1047
|
}
|
|
905
1048
|
// Compact JSON on purpose: analytics responses are the largest this
|
|
906
1049
|
// server produces, and pretty-printing them costs ~3x the tokens.
|
|
@@ -912,30 +1055,45 @@ class GscMcpRuntime {
|
|
|
912
1055
|
'insights.page_queries',
|
|
913
1056
|
{
|
|
914
1057
|
title: 'Find queries for a page',
|
|
915
|
-
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.
|
|
1058
|
+
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.',
|
|
916
1059
|
inputSchema: {
|
|
917
1060
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
918
1061
|
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.'),
|
|
919
1062
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
920
1063
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
921
1064
|
search_type: z
|
|
922
|
-
.enum(['web', 'image', 'video', 'news', '
|
|
1065
|
+
.enum(['web', 'image', 'video', 'news', 'googleNews'])
|
|
923
1066
|
.default('web')
|
|
924
|
-
.describe('Which search index to query. Defaults to web.'),
|
|
1067
|
+
.describe('Which search index to query. Defaults to web. Discover is not supported because this tool groups by query.'),
|
|
1068
|
+
row_limit: z
|
|
1069
|
+
.number()
|
|
1070
|
+
.int()
|
|
1071
|
+
.min(1)
|
|
1072
|
+
.max(25000)
|
|
1073
|
+
.default(100)
|
|
1074
|
+
.describe('Maximum query rows requested for this response. Continue with result_page.next_start_row while result_page.has_more is true.'),
|
|
1075
|
+
start_row: z
|
|
1076
|
+
.number()
|
|
1077
|
+
.int()
|
|
1078
|
+
.min(0)
|
|
1079
|
+
.default(0)
|
|
1080
|
+
.describe('Zero-based Search Analytics row offset for pagination.'),
|
|
925
1081
|
},
|
|
926
1082
|
outputSchema: PAGE_QUERIES_OUTPUT_SCHEMA,
|
|
927
1083
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
928
1084
|
},
|
|
929
|
-
async ({ site_url, page_url, start_date, end_date, search_type }) => {
|
|
1085
|
+
async ({ site_url, page_url, start_date, end_date, search_type, row_limit = 100, start_row = 0 }) => {
|
|
930
1086
|
assertDateRange(start_date, end_date);
|
|
931
1087
|
const googleId = this.requireGoogleId();
|
|
932
1088
|
const rateLimitError = await this.rateLimitError(googleId, 'insights.page_queries');
|
|
933
1089
|
if (rateLimitError) return rateLimitError;
|
|
934
1090
|
const accessToken = await this.getAccessToken(googleId);
|
|
1091
|
+
const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
|
|
935
1092
|
const source = await querySearchAnalyticsPaginated(accessToken, site_url, {
|
|
936
1093
|
startDate: start_date,
|
|
937
1094
|
endDate: end_date,
|
|
938
1095
|
dimensions: ['query'],
|
|
1096
|
+
startRow: start_row,
|
|
939
1097
|
type: search_type,
|
|
940
1098
|
dimensionFilterGroups: [
|
|
941
1099
|
{
|
|
@@ -945,21 +1103,29 @@ class GscMcpRuntime {
|
|
|
945
1103
|
],
|
|
946
1104
|
},
|
|
947
1105
|
],
|
|
948
|
-
});
|
|
1106
|
+
}, { maxRows: sourceRowLimit });
|
|
1107
|
+
const rows = source.rows
|
|
1108
|
+
.filter((row) => (row.keys?.length ?? 0) > 0)
|
|
1109
|
+
.map((row) => ({
|
|
1110
|
+
query: row.keys![0],
|
|
1111
|
+
clicks: row.clicks,
|
|
1112
|
+
impressions: row.impressions,
|
|
1113
|
+
ctr: row.ctr,
|
|
1114
|
+
position: row.position,
|
|
1115
|
+
}));
|
|
1116
|
+
const bounded = windowSourceRows(
|
|
1117
|
+
rows,
|
|
1118
|
+
start_row,
|
|
1119
|
+
row_limit,
|
|
1120
|
+
source.localLimitReached,
|
|
1121
|
+
);
|
|
949
1122
|
const payload = {
|
|
950
1123
|
page: page_url,
|
|
951
|
-
queries:
|
|
952
|
-
.filter((row) => row.keys.length > 0)
|
|
953
|
-
.map((row) => ({
|
|
954
|
-
query: row.keys[0],
|
|
955
|
-
clicks: row.clicks,
|
|
956
|
-
impressions: row.impressions,
|
|
957
|
-
ctr: row.ctr,
|
|
958
|
-
position: row.position,
|
|
959
|
-
})),
|
|
1124
|
+
queries: bounded.items,
|
|
960
1125
|
pagination: paginationMetadata(source),
|
|
1126
|
+
result_page: bounded.resultPage,
|
|
961
1127
|
};
|
|
962
|
-
return toolResponse(JSON.stringify(payload
|
|
1128
|
+
return toolResponse(JSON.stringify(payload), payload);
|
|
963
1129
|
},
|
|
964
1130
|
);
|
|
965
1131
|
|
|
@@ -967,30 +1133,45 @@ class GscMcpRuntime {
|
|
|
967
1133
|
'insights.query_pages',
|
|
968
1134
|
{
|
|
969
1135
|
title: 'Find pages for a query',
|
|
970
|
-
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
|
|
1136
|
+
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.',
|
|
971
1137
|
inputSchema: {
|
|
972
1138
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
973
1139
|
query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
|
|
974
1140
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
975
1141
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
976
1142
|
search_type: z
|
|
977
|
-
.enum(['web', 'image', 'video', 'news', '
|
|
1143
|
+
.enum(['web', 'image', 'video', 'news', 'googleNews'])
|
|
978
1144
|
.default('web')
|
|
979
|
-
.describe('Which search index to query. Defaults to web.'),
|
|
1145
|
+
.describe('Which search index to query. Defaults to web. Discover is not supported because this tool filters by query.'),
|
|
1146
|
+
row_limit: z
|
|
1147
|
+
.number()
|
|
1148
|
+
.int()
|
|
1149
|
+
.min(1)
|
|
1150
|
+
.max(25000)
|
|
1151
|
+
.default(100)
|
|
1152
|
+
.describe('Maximum page rows requested for this response. Continue with result_page.next_start_row while result_page.has_more is true.'),
|
|
1153
|
+
start_row: z
|
|
1154
|
+
.number()
|
|
1155
|
+
.int()
|
|
1156
|
+
.min(0)
|
|
1157
|
+
.default(0)
|
|
1158
|
+
.describe('Zero-based Search Analytics row offset for pagination.'),
|
|
980
1159
|
},
|
|
981
1160
|
outputSchema: QUERY_PAGES_OUTPUT_SCHEMA,
|
|
982
1161
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
983
1162
|
},
|
|
984
|
-
async ({ site_url, query, start_date, end_date, search_type }) => {
|
|
1163
|
+
async ({ site_url, query, start_date, end_date, search_type, row_limit = 100, start_row = 0 }) => {
|
|
985
1164
|
assertDateRange(start_date, end_date);
|
|
986
1165
|
const googleId = this.requireGoogleId();
|
|
987
1166
|
const rateLimitError = await this.rateLimitError(googleId, 'insights.query_pages');
|
|
988
1167
|
if (rateLimitError) return rateLimitError;
|
|
989
1168
|
const accessToken = await this.getAccessToken(googleId);
|
|
1169
|
+
const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
|
|
990
1170
|
const source = await querySearchAnalyticsPaginated(accessToken, site_url, {
|
|
991
1171
|
startDate: start_date,
|
|
992
1172
|
endDate: end_date,
|
|
993
1173
|
dimensions: ['page'],
|
|
1174
|
+
startRow: start_row,
|
|
994
1175
|
type: search_type,
|
|
995
1176
|
dimensionFilterGroups: [
|
|
996
1177
|
{
|
|
@@ -1000,21 +1181,29 @@ class GscMcpRuntime {
|
|
|
1000
1181
|
],
|
|
1001
1182
|
},
|
|
1002
1183
|
],
|
|
1003
|
-
});
|
|
1184
|
+
}, { maxRows: sourceRowLimit });
|
|
1185
|
+
const rows = source.rows
|
|
1186
|
+
.filter((row) => (row.keys?.length ?? 0) > 0)
|
|
1187
|
+
.map((row) => ({
|
|
1188
|
+
page: row.keys![0],
|
|
1189
|
+
clicks: row.clicks,
|
|
1190
|
+
impressions: row.impressions,
|
|
1191
|
+
ctr: row.ctr,
|
|
1192
|
+
position: row.position,
|
|
1193
|
+
}));
|
|
1194
|
+
const bounded = windowSourceRows(
|
|
1195
|
+
rows,
|
|
1196
|
+
start_row,
|
|
1197
|
+
row_limit,
|
|
1198
|
+
source.localLimitReached,
|
|
1199
|
+
);
|
|
1004
1200
|
const payload = {
|
|
1005
1201
|
query,
|
|
1006
|
-
pages:
|
|
1007
|
-
.filter((row) => row.keys.length > 0)
|
|
1008
|
-
.map((row) => ({
|
|
1009
|
-
page: row.keys[0],
|
|
1010
|
-
clicks: row.clicks,
|
|
1011
|
-
impressions: row.impressions,
|
|
1012
|
-
ctr: row.ctr,
|
|
1013
|
-
position: row.position,
|
|
1014
|
-
})),
|
|
1202
|
+
pages: bounded.items,
|
|
1015
1203
|
pagination: paginationMetadata(source),
|
|
1204
|
+
result_page: bounded.resultPage,
|
|
1016
1205
|
};
|
|
1017
|
-
return toolResponse(JSON.stringify(payload
|
|
1206
|
+
return toolResponse(JSON.stringify(payload), payload);
|
|
1018
1207
|
},
|
|
1019
1208
|
);
|
|
1020
1209
|
|
|
@@ -1134,12 +1323,12 @@ class GscMcpRuntime {
|
|
|
1134
1323
|
'insights.quick_wins',
|
|
1135
1324
|
{
|
|
1136
1325
|
title: 'Identify SEO Quick Wins',
|
|
1137
|
-
description: 'Find
|
|
1326
|
+
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.',
|
|
1138
1327
|
inputSchema: createQuickWinsInputSchema(SITE_URL_DESCRIPTION),
|
|
1139
1328
|
outputSchema: z.object(QUICK_WIN_OUTPUT_SCHEMA),
|
|
1140
1329
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
1141
1330
|
},
|
|
1142
|
-
async ({ site_url, start_date, end_date, min_impressions, min_position, max_position }) => {
|
|
1331
|
+
async ({ site_url, start_date, end_date, min_impressions, min_position, max_position, limit = DEFAULT_ANALYSIS_RESULT_LIMIT, start_row = 0 }) => {
|
|
1143
1332
|
assertDateRange(start_date, end_date);
|
|
1144
1333
|
const googleId = this.requireGoogleId();
|
|
1145
1334
|
const rateLimitError = await this.rateLimitError(googleId, 'insights.quick_wins');
|
|
@@ -1152,11 +1341,14 @@ class GscMcpRuntime {
|
|
|
1152
1341
|
});
|
|
1153
1342
|
|
|
1154
1343
|
const quickWins = processQuickWins(source.rows, min_impressions, min_position, max_position);
|
|
1344
|
+
const bounded = windowKnownResults(quickWins, start_row, limit);
|
|
1155
1345
|
const payload = {
|
|
1156
|
-
|
|
1346
|
+
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.',
|
|
1347
|
+
quick_wins: bounded.items,
|
|
1157
1348
|
pagination: paginationMetadata(source),
|
|
1349
|
+
result_page: bounded.resultPage,
|
|
1158
1350
|
};
|
|
1159
|
-
return toolResponse(JSON.stringify(payload
|
|
1351
|
+
return toolResponse(JSON.stringify(payload), payload);
|
|
1160
1352
|
},
|
|
1161
1353
|
);
|
|
1162
1354
|
|
|
@@ -1164,18 +1356,20 @@ class GscMcpRuntime {
|
|
|
1164
1356
|
'insights.cannibalization',
|
|
1165
1357
|
{
|
|
1166
1358
|
title: 'Detect Keyword Cannibalization',
|
|
1167
|
-
description: 'Analyze
|
|
1359
|
+
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.',
|
|
1168
1360
|
inputSchema: {
|
|
1169
1361
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1170
1362
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1171
1363
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
1172
1364
|
min_impressions: CANNIBALIZATION_MIN_IMPRESSIONS_SCHEMA,
|
|
1173
1365
|
min_page_percentage: CANNIBALIZATION_MIN_PAGE_PERCENTAGE_SCHEMA,
|
|
1366
|
+
limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
|
|
1367
|
+
start_row: RESULT_START_ROW_SCHEMA,
|
|
1174
1368
|
},
|
|
1175
1369
|
outputSchema: CANNIBALIZATION_OUTPUT_SCHEMA,
|
|
1176
1370
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
1177
1371
|
},
|
|
1178
|
-
async ({ site_url, start_date, end_date, min_impressions, min_page_percentage }) => {
|
|
1372
|
+
async ({ site_url, start_date, end_date, min_impressions, min_page_percentage, limit = DEFAULT_ANALYSIS_RESULT_LIMIT, start_row = 0 }) => {
|
|
1179
1373
|
assertDateRange(start_date, end_date);
|
|
1180
1374
|
const googleId = this.requireGoogleId();
|
|
1181
1375
|
const rateLimitError = await this.rateLimitError(googleId, 'insights.cannibalization');
|
|
@@ -1187,12 +1381,32 @@ class GscMcpRuntime {
|
|
|
1187
1381
|
dimensions: ['query', 'page'],
|
|
1188
1382
|
});
|
|
1189
1383
|
|
|
1190
|
-
const cannibalizationCandidates = processCannibalization(
|
|
1384
|
+
const cannibalizationCandidates = processCannibalization(
|
|
1385
|
+
source.rows,
|
|
1386
|
+
min_impressions,
|
|
1387
|
+
min_page_percentage,
|
|
1388
|
+
).map((candidate) => {
|
|
1389
|
+
const boundedPages = takeBoundedItems(candidate.pages, 10, 12_000);
|
|
1390
|
+
return {
|
|
1391
|
+
...candidate,
|
|
1392
|
+
page_count: candidate.pages.length,
|
|
1393
|
+
pages_truncated:
|
|
1394
|
+
boundedPages.byteLimitReached ||
|
|
1395
|
+
boundedPages.items.length < candidate.pages.length,
|
|
1396
|
+
pages: boundedPages.items,
|
|
1397
|
+
};
|
|
1398
|
+
});
|
|
1399
|
+
const bounded = windowKnownResults(
|
|
1400
|
+
cannibalizationCandidates,
|
|
1401
|
+
start_row,
|
|
1402
|
+
limit,
|
|
1403
|
+
);
|
|
1191
1404
|
const payload = {
|
|
1192
|
-
candidates:
|
|
1405
|
+
candidates: bounded.items,
|
|
1193
1406
|
pagination: paginationMetadata(source),
|
|
1407
|
+
result_page: bounded.resultPage,
|
|
1194
1408
|
};
|
|
1195
|
-
return toolResponse(JSON.stringify(payload
|
|
1409
|
+
return toolResponse(JSON.stringify(payload), payload);
|
|
1196
1410
|
},
|
|
1197
1411
|
);
|
|
1198
1412
|
|
|
@@ -1200,15 +1414,17 @@ class GscMcpRuntime {
|
|
|
1200
1414
|
'insights.content_decay',
|
|
1201
1415
|
{
|
|
1202
1416
|
title: 'Detect Content Decay',
|
|
1203
|
-
description: '
|
|
1417
|
+
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.',
|
|
1204
1418
|
inputSchema: {
|
|
1205
1419
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1206
1420
|
compare_days: CONTENT_DECAY_COMPARE_DAYS_SCHEMA,
|
|
1421
|
+
limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
|
|
1422
|
+
start_row: RESULT_START_ROW_SCHEMA,
|
|
1207
1423
|
},
|
|
1208
1424
|
outputSchema: CONTENT_DECAY_OUTPUT_SCHEMA,
|
|
1209
1425
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
1210
1426
|
},
|
|
1211
|
-
async ({ site_url, compare_days }) => {
|
|
1427
|
+
async ({ site_url, compare_days, limit = DEFAULT_ANALYSIS_RESULT_LIMIT, start_row = 0 }) => {
|
|
1212
1428
|
const googleId = this.requireGoogleId();
|
|
1213
1429
|
const rateLimitError = await this.rateLimitError(googleId, 'insights.content_decay');
|
|
1214
1430
|
if (rateLimitError) return rateLimitError;
|
|
@@ -1241,21 +1457,29 @@ class GscMcpRuntime {
|
|
|
1241
1457
|
dimensions: ['page'],
|
|
1242
1458
|
});
|
|
1243
1459
|
|
|
1244
|
-
const decayResults = processContentDecay(
|
|
1460
|
+
const decayResults = processContentDecay(
|
|
1461
|
+
recentSource.rows,
|
|
1462
|
+
previousSource.rows,
|
|
1463
|
+
);
|
|
1464
|
+
const bounded = windowKnownResults(decayResults, start_row, limit);
|
|
1245
1465
|
|
|
1246
1466
|
const payload = {
|
|
1247
1467
|
comparison_periods: {
|
|
1248
1468
|
recent: { start: recentStart, end: recentEnd },
|
|
1249
1469
|
previous: { start: previousStart, end: previousEnd },
|
|
1250
1470
|
},
|
|
1251
|
-
decay_count: decayResults.
|
|
1252
|
-
|
|
1471
|
+
decay_count: decayResults.filter(
|
|
1472
|
+
(result) => result.classification === 'likely_decay',
|
|
1473
|
+
).length,
|
|
1474
|
+
assessment_count: decayResults.length,
|
|
1475
|
+
decay_results: bounded.items,
|
|
1253
1476
|
pagination: {
|
|
1254
1477
|
recent: paginationMetadata(recentSource),
|
|
1255
1478
|
previous: paginationMetadata(previousSource),
|
|
1256
1479
|
},
|
|
1480
|
+
result_page: bounded.resultPage,
|
|
1257
1481
|
};
|
|
1258
|
-
return toolResponse(JSON.stringify(payload
|
|
1482
|
+
return toolResponse(JSON.stringify(payload), payload);
|
|
1259
1483
|
},
|
|
1260
1484
|
);
|
|
1261
1485
|
|
|
@@ -1301,18 +1525,19 @@ class GscMcpRuntime {
|
|
|
1301
1525
|
this.server.registerTool(
|
|
1302
1526
|
'indexing.list_pages',
|
|
1303
1527
|
{
|
|
1304
|
-
title: 'List
|
|
1305
|
-
description:
|
|
1528
|
+
title: 'List Search-Visible Pages',
|
|
1529
|
+
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.`,
|
|
1306
1530
|
inputSchema: {
|
|
1307
1531
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1308
1532
|
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.'),
|
|
1309
1533
|
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.'),
|
|
1310
|
-
row_limit: z.number().int().min(1).max(25000).default(1000).describe(
|
|
1534
|
+
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.`),
|
|
1535
|
+
start_row: z.number().int().min(0).default(0).describe('Zero-based page-row offset for pagination.'),
|
|
1311
1536
|
},
|
|
1312
1537
|
outputSchema: INDEXED_PAGES_OUTPUT_SCHEMA,
|
|
1313
1538
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
1314
1539
|
},
|
|
1315
|
-
async ({ site_url, start_date, end_date, row_limit }) => {
|
|
1540
|
+
async ({ site_url, start_date, end_date, row_limit, start_row = 0 }) => {
|
|
1316
1541
|
const { startDate, endDate } = resolveIndexedPagesDateRange(
|
|
1317
1542
|
start_date,
|
|
1318
1543
|
end_date,
|
|
@@ -1323,22 +1548,37 @@ class GscMcpRuntime {
|
|
|
1323
1548
|
if (rateLimitError) return rateLimitError;
|
|
1324
1549
|
const accessToken = await this.getAccessToken(googleId);
|
|
1325
1550
|
|
|
1551
|
+
const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
|
|
1326
1552
|
const response = await querySearchAnalytics(accessToken, site_url, {
|
|
1327
1553
|
startDate,
|
|
1328
1554
|
endDate,
|
|
1329
1555
|
dimensions: ['page'],
|
|
1330
|
-
rowLimit:
|
|
1556
|
+
rowLimit: sourceRowLimit,
|
|
1557
|
+
startRow: start_row,
|
|
1331
1558
|
});
|
|
1332
1559
|
|
|
1333
|
-
const pages = response.rows
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1560
|
+
const pages = response.rows
|
|
1561
|
+
.filter((row) => (row.keys?.length ?? 0) > 0)
|
|
1562
|
+
.map((row) => ({
|
|
1563
|
+
page: row.keys![0],
|
|
1564
|
+
clicks: row.clicks,
|
|
1565
|
+
impressions: row.impressions,
|
|
1566
|
+
ctr: row.ctr,
|
|
1567
|
+
position: row.position,
|
|
1568
|
+
}));
|
|
1569
|
+
const bounded = windowSourceRows(
|
|
1570
|
+
pages,
|
|
1571
|
+
start_row,
|
|
1572
|
+
row_limit,
|
|
1573
|
+
response.rows.length === sourceRowLimit,
|
|
1574
|
+
);
|
|
1340
1575
|
|
|
1341
|
-
|
|
1576
|
+
const payload = {
|
|
1577
|
+
pages: bounded.items,
|
|
1578
|
+
result_page: bounded.resultPage,
|
|
1579
|
+
note: SEARCH_VISIBLE_PAGES_NOTE,
|
|
1580
|
+
};
|
|
1581
|
+
return toolResponse(JSON.stringify(payload), payload);
|
|
1342
1582
|
},
|
|
1343
1583
|
);
|
|
1344
1584
|
|
|
@@ -1346,7 +1586,7 @@ class GscMcpRuntime {
|
|
|
1346
1586
|
'analytics.compare',
|
|
1347
1587
|
{
|
|
1348
1588
|
title: 'Compare Performance Between Periods',
|
|
1349
|
-
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
|
|
1589
|
+
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.',
|
|
1350
1590
|
inputSchema: {
|
|
1351
1591
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1352
1592
|
start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
|
|
@@ -1355,9 +1595,9 @@ class GscMcpRuntime {
|
|
|
1355
1595
|
end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period B (previous, YYYY-MM-DD)'),
|
|
1356
1596
|
dimension: z.enum(['query', 'page', 'country', 'device']).default('query').describe('The dimension to compare performance for. Defaults to query.'),
|
|
1357
1597
|
search_type: z
|
|
1358
|
-
.enum(['web', 'image', 'video', 'news', '
|
|
1598
|
+
.enum(['web', 'image', 'video', 'news', 'googleNews'])
|
|
1359
1599
|
.default('web')
|
|
1360
|
-
.describe('Which Search Console search index to compare. The same search type is used for both periods.'),
|
|
1600
|
+
.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.'),
|
|
1361
1601
|
dimension_filter_groups: z
|
|
1362
1602
|
.array(
|
|
1363
1603
|
z.object({
|
|
@@ -1388,6 +1628,8 @@ class GscMcpRuntime {
|
|
|
1388
1628
|
.describe(
|
|
1389
1629
|
"Optional Search Console filters applied identically to both periods. Use query regex filters for brand/non-brand comparisons; countries use ISO 3166-1 alpha-3 codes.",
|
|
1390
1630
|
),
|
|
1631
|
+
limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
|
|
1632
|
+
start_row: RESULT_START_ROW_SCHEMA,
|
|
1391
1633
|
},
|
|
1392
1634
|
outputSchema: PERFORMANCE_COMPARISON_OUTPUT_SCHEMA,
|
|
1393
1635
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
@@ -1401,6 +1643,8 @@ class GscMcpRuntime {
|
|
|
1401
1643
|
dimension,
|
|
1402
1644
|
search_type,
|
|
1403
1645
|
dimension_filter_groups,
|
|
1646
|
+
limit = DEFAULT_ANALYSIS_RESULT_LIMIT,
|
|
1647
|
+
start_row = 0,
|
|
1404
1648
|
}) => {
|
|
1405
1649
|
assertDateRange(start_date_a, end_date_a, 'start_date_a', 'end_date_a');
|
|
1406
1650
|
assertDateRange(start_date_b, end_date_b, 'start_date_b', 'end_date_b');
|
|
@@ -1430,14 +1674,16 @@ class GscMcpRuntime {
|
|
|
1430
1674
|
});
|
|
1431
1675
|
|
|
1432
1676
|
const comparison = processPerformanceComparison(sourceA.rows, sourceB.rows);
|
|
1677
|
+
const bounded = windowKnownResults(comparison, start_row, limit);
|
|
1433
1678
|
const payload = {
|
|
1434
|
-
comparisons:
|
|
1679
|
+
comparisons: bounded.items,
|
|
1435
1680
|
pagination: {
|
|
1436
1681
|
period_a: paginationMetadata(sourceA),
|
|
1437
1682
|
period_b: paginationMetadata(sourceB),
|
|
1438
1683
|
},
|
|
1684
|
+
result_page: bounded.resultPage,
|
|
1439
1685
|
};
|
|
1440
|
-
return toolResponse(JSON.stringify(payload
|
|
1686
|
+
return toolResponse(JSON.stringify(payload), payload);
|
|
1441
1687
|
},
|
|
1442
1688
|
);
|
|
1443
1689
|
|