@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/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.array(z.string()),
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
- total_impressions: z.number(),
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 search queries with at least the requested impressions that rank in a configurable striking-distance position range (8-20 by default). Returns clicks, impressions, CTR, and average position; CTR is context, not an eligibility filter.',
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 search analytics to detect instances of keyword cannibalization, where multiple pages on your site compete for the same query.',
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
- 'Identify content decay by comparing search clicks for your site pages between two contiguous periods and finding the pages with the largest traffic drops.',
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 } where each row has keys (dimension',
728
- 'values), clicks, impressions, ctr, and position. When row_count',
729
- 'equals row_limit, the response includes next_start_row — pass it',
730
- 'back as start_row to fetch the next page. When Google provides',
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
- 'Maximum rows to return (1-25000). Defaults to 100, which is plenty for most questions; raise it only for bulk exports and page through with start_row.',
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: row_limit,
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 { rows } = response;
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: rows.length,
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 (rows.length === row_limit) {
903
- payload.next_start_row = start_row + row_limit;
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. Pagination metadata flags when the local 100,000-row safety ceiling stopped fetching; Search Console can still omit anonymized queries.',
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', 'discover', 'googleNews'])
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: source.rows
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, null, 2), 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 this to verify which URL Google is surfacing for a target query before diagnosing cannibalization or content targeting. Pagination metadata flags when the local 100,000-row safety ceiling stopped fetching.',
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', 'discover', 'googleNews'])
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: source.rows
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, null, 2), 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 search queries with at least the requested impressions that rank in a configurable striking-distance position range (8-20 by default). Returns clicks, impressions, CTR, and average position; CTR is context, not an eligibility filter. Pagination metadata flags when the local 100,000-row safety ceiling stopped fetching; a false flag does not guarantee Search Console returned every underlying row.',
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
- quick_wins: quickWins,
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, null, 2), 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 search analytics to detect instances of keyword cannibalization, where multiple pages on your site compete for the same query. Pagination metadata flags when the local 100,000-row safety ceiling stopped fetching; a false flag does not guarantee Search Console returned every underlying row.',
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(source.rows, min_impressions, min_page_percentage);
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: cannibalizationCandidates,
1405
+ candidates: bounded.items,
1193
1406
  pagination: paginationMetadata(source),
1407
+ result_page: bounded.resultPage,
1194
1408
  };
1195
- return toolResponse(JSON.stringify(payload, null, 2), 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: 'Identify content decay by comparing search clicks for your site pages between two contiguous periods and finding the pages with the largest traffic drops. Pagination metadata flags when either source period reached the local 100,000-row safety ceiling; Search Console itself may still return only top data.',
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(recentSource.rows, previousSource.rows);
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.length,
1252
- decay_results: decayResults,
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, null, 2), 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 Indexed Pages',
1305
- description: '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.',
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('Maximum pages to retrieve (1-25000). Default is 1000.'),
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: row_limit,
1556
+ rowLimit: sourceRowLimit,
1557
+ startRow: start_row,
1331
1558
  });
1332
1559
 
1333
- const pages = response.rows.map((row) => ({
1334
- page: row.keys[0],
1335
- clicks: row.clicks,
1336
- impressions: row.impressions,
1337
- ctr: row.ctr,
1338
- position: row.position,
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
- return toolResponse(JSON.stringify(pages, null, 2), { pages });
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 for segmented comparisons such as brand vs non-brand, country/device/page subsets, or Discover/News/Image traffic. Pagination metadata flags when either period reached the local 100,000-row safety ceiling; Search Console itself may still return only top data.',
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', 'discover', 'googleNews'])
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: comparison,
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, null, 2), payload);
1686
+ return toolResponse(JSON.stringify(payload), payload);
1441
1687
  },
1442
1688
  );
1443
1689