@digestseo/mcp-gsc 0.4.0 → 0.4.1

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,6 +229,34 @@ 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 = {
218
261
  quick_wins: z.array(
219
262
  z.object({
@@ -223,6 +266,7 @@ const QUICK_WIN_OUTPUT_SCHEMA = {
223
266
  }),
224
267
  ),
225
268
  pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
269
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
226
270
  };
227
271
 
228
272
  const PAGE_QUERIES_OUTPUT_SCHEMA = {
@@ -234,6 +278,7 @@ const PAGE_QUERIES_OUTPUT_SCHEMA = {
234
278
  }),
235
279
  ),
236
280
  pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
281
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
237
282
  };
238
283
 
239
284
  const QUERY_PAGES_OUTPUT_SCHEMA = {
@@ -245,6 +290,7 @@ const QUERY_PAGES_OUTPUT_SCHEMA = {
245
290
  }),
246
291
  ),
247
292
  pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
293
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
248
294
  };
249
295
 
250
296
  const CANNIBALIZATION_OUTPUT_SCHEMA = {
@@ -253,6 +299,8 @@ const CANNIBALIZATION_OUTPUT_SCHEMA = {
253
299
  query: z.string(),
254
300
  total_clicks: z.number(),
255
301
  total_impressions: z.number(),
302
+ page_count: z.number().int().nonnegative(),
303
+ pages_truncated: z.boolean(),
256
304
  pages: z.array(
257
305
  z.object({
258
306
  page: z.string(),
@@ -263,6 +311,7 @@ const CANNIBALIZATION_OUTPUT_SCHEMA = {
263
311
  }),
264
312
  ),
265
313
  pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
314
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
266
315
  };
267
316
 
268
317
  const CONTENT_DECAY_OUTPUT_SCHEMA = {
@@ -271,9 +320,16 @@ const CONTENT_DECAY_OUTPUT_SCHEMA = {
271
320
  previous: z.object({ start: z.string(), end: z.string() }),
272
321
  }),
273
322
  decay_count: z.number().int().nonnegative(),
323
+ assessment_count: z.number().int().nonnegative(),
274
324
  decay_results: z.array(
275
325
  z.object({
276
326
  page: z.string(),
327
+ classification: z.enum([
328
+ 'likely_decay',
329
+ 'weak_insufficient_evidence',
330
+ 'improving_visibility_with_click_volatility',
331
+ ]),
332
+ evidence: z.string(),
277
333
  previous_clicks: z.number(),
278
334
  recent_clicks: z.number(),
279
335
  click_difference: z.number(),
@@ -281,14 +337,17 @@ const CONTENT_DECAY_OUTPUT_SCHEMA = {
281
337
  previous_impressions: z.number(),
282
338
  recent_impressions: z.number(),
283
339
  impression_difference: z.number(),
340
+ impression_change_percentage: z.number().nullable(),
284
341
  previous_position: z.number(),
285
342
  recent_position: z.number(),
343
+ position_change: z.number().nullable(),
286
344
  }),
287
345
  ),
288
346
  pagination: z.object({
289
347
  recent: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
290
348
  previous: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
291
349
  }),
350
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
292
351
  };
293
352
 
294
353
  const INDEXING_OUTPUT_SCHEMA = {
@@ -298,8 +357,16 @@ const INDEXING_OUTPUT_SCHEMA = {
298
357
 
299
358
  const INDEXED_PAGES_OUTPUT_SCHEMA = {
300
359
  pages: z.array(z.object({ page: z.string(), ...METRIC_OUTPUT_SCHEMA })),
360
+ note: z.string(),
361
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
301
362
  };
302
363
 
364
+ const SEARCH_VISIBLE_PAGES_DESCRIPTION =
365
+ '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.';
366
+
367
+ const SEARCH_VISIBLE_PAGES_NOTE =
368
+ '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.';
369
+
303
370
  const PERFORMANCE_COMPARISON_OUTPUT_SCHEMA = {
304
371
  comparisons: z.array(
305
372
  z.object({
@@ -326,6 +393,7 @@ const PERFORMANCE_COMPARISON_OUTPUT_SCHEMA = {
326
393
  period_a: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
327
394
  period_b: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
328
395
  }),
396
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
329
397
  };
330
398
 
331
399
  const WEEKLY_DIGEST_OUTPUT_SCHEMA = {
@@ -350,6 +418,53 @@ function paginationMetadata(result: PaginatedSearchAnalyticsResult) {
350
418
  };
351
419
  }
352
420
 
421
+ function windowKnownResults<T>(
422
+ items: readonly T[],
423
+ startRow: number,
424
+ limit: number,
425
+ ) {
426
+ const requested = items.slice(startRow, startRow + limit);
427
+ const bounded = takeBoundedItems(requested, limit);
428
+ const returnedCount = bounded.items.length;
429
+ const hasMore =
430
+ startRow + returnedCount < items.length || bounded.byteLimitReached;
431
+ return {
432
+ items: bounded.items,
433
+ resultPage: resultPageMetadata({
434
+ startRow,
435
+ limit,
436
+ returnedCount,
437
+ totalCount: items.length,
438
+ hasMore,
439
+ byteLimitReached: bounded.byteLimitReached,
440
+ }),
441
+ };
442
+ }
443
+
444
+ function windowSourceRows<T>(
445
+ items: readonly T[],
446
+ sourceStartRow: number,
447
+ requestedLimit: number,
448
+ sourceMayHaveMore: boolean,
449
+ ) {
450
+ const bounded = takeBoundedItems(items, requestedLimit);
451
+ const returnedCount = bounded.items.length;
452
+ const hasMore =
453
+ bounded.byteLimitReached ||
454
+ returnedCount < items.length ||
455
+ sourceMayHaveMore;
456
+ return {
457
+ items: bounded.items,
458
+ resultPage: resultPageMetadata({
459
+ startRow: sourceStartRow,
460
+ limit: requestedLimit,
461
+ returnedCount,
462
+ hasMore,
463
+ byteLimitReached: bounded.byteLimitReached,
464
+ }),
465
+ };
466
+ }
467
+
353
468
  function toolError(message: string) {
354
469
  return {
355
470
  content: [{ type: 'text' as const, text: message }],
@@ -438,7 +553,7 @@ const TOOL_CATALOG = [
438
553
  {
439
554
  name: 'insights.content_decay',
440
555
  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.',
556
+ 'Assess page-level click declines across two contiguous periods and distinguish likely decay from weak evidence or improving visibility with click volatility.',
442
557
  },
443
558
  {
444
559
  name: 'indexing.request',
@@ -447,8 +562,7 @@ const TOOL_CATALOG = [
447
562
  },
448
563
  {
449
564
  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.',
565
+ description: SEARCH_VISIBLE_PAGES_DESCRIPTION,
452
566
  },
453
567
  {
454
568
  name: 'analytics.compare',
@@ -724,10 +838,14 @@ class GscMcpRuntime {
724
838
  title: 'Query search analytics',
725
839
  description: [
726
840
  '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',
841
+ '{ row_count, start_row, rows, has_more, truncated, byte_limit_reached }',
842
+ 'where dimensioned rows have keys plus clicks, impressions, ctr, and',
843
+ 'position. Aggregate rows from dimensions: [] may omit keys because',
844
+ 'Google itself omits that field. When has_more is true, the response',
845
+ 'includes next_start_row — pass it back as start_row to fetch the next',
846
+ 'safe page. Large requested row_limit values are automatically split',
847
+ 'into bounded MCP responses rather than failing structured output.',
848
+ 'When Google provides',
731
849
  'them, response_aggregation_type and metadata are also included;',
732
850
  'metadata may identify the first incomplete date or hour.',
733
851
  '',
@@ -797,7 +915,7 @@ class GscMcpRuntime {
797
915
  .max(25000)
798
916
  .default(100)
799
917
  .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.',
918
+ `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
919
  ),
802
920
  start_row: z
803
921
  .number()
@@ -874,11 +992,12 @@ class GscMcpRuntime {
874
992
  const rateLimitError = await this.rateLimitError(googleId, 'analytics.query');
875
993
  if (rateLimitError) return rateLimitError;
876
994
  const accessToken = await this.getAccessToken(googleId);
995
+ const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
877
996
  const response = await querySearchAnalytics(accessToken, site_url, {
878
997
  startDate: start_date,
879
998
  endDate: end_date,
880
999
  dimensions,
881
- rowLimit: row_limit,
1000
+ rowLimit: sourceRowLimit,
882
1001
  startRow: start_row,
883
1002
  dataState: data_state,
884
1003
  type: search_type,
@@ -887,11 +1006,21 @@ class GscMcpRuntime {
887
1006
  ? { dimensionFilterGroups: dimension_filter_groups }
888
1007
  : {}),
889
1008
  });
890
- const { rows } = response;
1009
+ const sourceMayHaveMore =
1010
+ dimensions.length > 0 && response.rows.length === sourceRowLimit;
1011
+ const bounded = windowSourceRows(
1012
+ response.rows,
1013
+ start_row,
1014
+ row_limit,
1015
+ sourceMayHaveMore,
1016
+ );
891
1017
  const payload: Record<string, unknown> = {
892
- row_count: rows.length,
1018
+ row_count: bounded.items.length,
893
1019
  start_row,
894
- rows,
1020
+ rows: bounded.items,
1021
+ has_more: bounded.resultPage.has_more,
1022
+ truncated: bounded.resultPage.truncated,
1023
+ byte_limit_reached: bounded.resultPage.byte_limit_reached,
895
1024
  };
896
1025
  if (response.responseAggregationType !== undefined) {
897
1026
  payload.response_aggregation_type = response.responseAggregationType;
@@ -899,8 +1028,8 @@ class GscMcpRuntime {
899
1028
  if (response.metadata !== undefined) {
900
1029
  payload.metadata = response.metadata;
901
1030
  }
902
- if (rows.length === row_limit) {
903
- payload.next_start_row = start_row + row_limit;
1031
+ if (bounded.resultPage.next_start_row !== undefined) {
1032
+ payload.next_start_row = bounded.resultPage.next_start_row;
904
1033
  }
905
1034
  // Compact JSON on purpose: analytics responses are the largest this
906
1035
  // server produces, and pretty-printing them costs ~3x the tokens.
@@ -912,7 +1041,7 @@ class GscMcpRuntime {
912
1041
  'insights.page_queries',
913
1042
  {
914
1043
  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.',
1044
+ description: 'For one exact page URL, return the Search Console queries that produced impressions for it over a date range. This wraps an exact page dimension filter so agents do not need to construct analytics.query filter groups manually. Exact page matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results; Search Console can still omit anonymized queries.',
916
1045
  inputSchema: {
917
1046
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
918
1047
  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.'),
@@ -922,20 +1051,35 @@ class GscMcpRuntime {
922
1051
  .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
923
1052
  .default('web')
924
1053
  .describe('Which search index to query. Defaults to web.'),
1054
+ row_limit: z
1055
+ .number()
1056
+ .int()
1057
+ .min(1)
1058
+ .max(25000)
1059
+ .default(100)
1060
+ .describe('Maximum query rows requested for this response. Continue with result_page.next_start_row while result_page.has_more is true.'),
1061
+ start_row: z
1062
+ .number()
1063
+ .int()
1064
+ .min(0)
1065
+ .default(0)
1066
+ .describe('Zero-based Search Analytics row offset for pagination.'),
925
1067
  },
926
1068
  outputSchema: PAGE_QUERIES_OUTPUT_SCHEMA,
927
1069
  annotations: READ_ONLY_ANNOTATIONS,
928
1070
  },
929
- async ({ site_url, page_url, start_date, end_date, search_type }) => {
1071
+ async ({ site_url, page_url, start_date, end_date, search_type, row_limit = 100, start_row = 0 }) => {
930
1072
  assertDateRange(start_date, end_date);
931
1073
  const googleId = this.requireGoogleId();
932
1074
  const rateLimitError = await this.rateLimitError(googleId, 'insights.page_queries');
933
1075
  if (rateLimitError) return rateLimitError;
934
1076
  const accessToken = await this.getAccessToken(googleId);
1077
+ const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
935
1078
  const source = await querySearchAnalyticsPaginated(accessToken, site_url, {
936
1079
  startDate: start_date,
937
1080
  endDate: end_date,
938
1081
  dimensions: ['query'],
1082
+ startRow: start_row,
939
1083
  type: search_type,
940
1084
  dimensionFilterGroups: [
941
1085
  {
@@ -945,21 +1089,29 @@ class GscMcpRuntime {
945
1089
  ],
946
1090
  },
947
1091
  ],
948
- });
1092
+ }, { maxRows: sourceRowLimit });
1093
+ const rows = source.rows
1094
+ .filter((row) => (row.keys?.length ?? 0) > 0)
1095
+ .map((row) => ({
1096
+ query: row.keys![0],
1097
+ clicks: row.clicks,
1098
+ impressions: row.impressions,
1099
+ ctr: row.ctr,
1100
+ position: row.position,
1101
+ }));
1102
+ const bounded = windowSourceRows(
1103
+ rows,
1104
+ start_row,
1105
+ row_limit,
1106
+ source.localLimitReached,
1107
+ );
949
1108
  const payload = {
950
1109
  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
- })),
1110
+ queries: bounded.items,
960
1111
  pagination: paginationMetadata(source),
1112
+ result_page: bounded.resultPage,
961
1113
  };
962
- return toolResponse(JSON.stringify(payload, null, 2), payload);
1114
+ return toolResponse(JSON.stringify(payload), payload);
963
1115
  },
964
1116
  );
965
1117
 
@@ -967,7 +1119,7 @@ class GscMcpRuntime {
967
1119
  'insights.query_pages',
968
1120
  {
969
1121
  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.',
1122
+ description: 'For one exact search query, return the site pages that received impressions for it over a date range. This wraps an exact query dimension filter so agents do not need to construct analytics.query filter groups manually. Exact query matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results and verify which URL Google is surfacing before diagnosing cannibalization or content targeting.',
971
1123
  inputSchema: {
972
1124
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
973
1125
  query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
@@ -977,20 +1129,35 @@ class GscMcpRuntime {
977
1129
  .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
978
1130
  .default('web')
979
1131
  .describe('Which search index to query. Defaults to web.'),
1132
+ row_limit: z
1133
+ .number()
1134
+ .int()
1135
+ .min(1)
1136
+ .max(25000)
1137
+ .default(100)
1138
+ .describe('Maximum page rows requested for this response. Continue with result_page.next_start_row while result_page.has_more is true.'),
1139
+ start_row: z
1140
+ .number()
1141
+ .int()
1142
+ .min(0)
1143
+ .default(0)
1144
+ .describe('Zero-based Search Analytics row offset for pagination.'),
980
1145
  },
981
1146
  outputSchema: QUERY_PAGES_OUTPUT_SCHEMA,
982
1147
  annotations: READ_ONLY_ANNOTATIONS,
983
1148
  },
984
- async ({ site_url, query, start_date, end_date, search_type }) => {
1149
+ async ({ site_url, query, start_date, end_date, search_type, row_limit = 100, start_row = 0 }) => {
985
1150
  assertDateRange(start_date, end_date);
986
1151
  const googleId = this.requireGoogleId();
987
1152
  const rateLimitError = await this.rateLimitError(googleId, 'insights.query_pages');
988
1153
  if (rateLimitError) return rateLimitError;
989
1154
  const accessToken = await this.getAccessToken(googleId);
1155
+ const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
990
1156
  const source = await querySearchAnalyticsPaginated(accessToken, site_url, {
991
1157
  startDate: start_date,
992
1158
  endDate: end_date,
993
1159
  dimensions: ['page'],
1160
+ startRow: start_row,
994
1161
  type: search_type,
995
1162
  dimensionFilterGroups: [
996
1163
  {
@@ -1000,21 +1167,29 @@ class GscMcpRuntime {
1000
1167
  ],
1001
1168
  },
1002
1169
  ],
1003
- });
1170
+ }, { maxRows: sourceRowLimit });
1171
+ const rows = source.rows
1172
+ .filter((row) => (row.keys?.length ?? 0) > 0)
1173
+ .map((row) => ({
1174
+ page: row.keys![0],
1175
+ clicks: row.clicks,
1176
+ impressions: row.impressions,
1177
+ ctr: row.ctr,
1178
+ position: row.position,
1179
+ }));
1180
+ const bounded = windowSourceRows(
1181
+ rows,
1182
+ start_row,
1183
+ row_limit,
1184
+ source.localLimitReached,
1185
+ );
1004
1186
  const payload = {
1005
1187
  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
- })),
1188
+ pages: bounded.items,
1015
1189
  pagination: paginationMetadata(source),
1190
+ result_page: bounded.resultPage,
1016
1191
  };
1017
- return toolResponse(JSON.stringify(payload, null, 2), payload);
1192
+ return toolResponse(JSON.stringify(payload), payload);
1018
1193
  },
1019
1194
  );
1020
1195
 
@@ -1134,12 +1309,12 @@ class GscMcpRuntime {
1134
1309
  'insights.quick_wins',
1135
1310
  {
1136
1311
  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.',
1312
+ 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. Results are ranked 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
1313
  inputSchema: createQuickWinsInputSchema(SITE_URL_DESCRIPTION),
1139
1314
  outputSchema: z.object(QUICK_WIN_OUTPUT_SCHEMA),
1140
1315
  annotations: READ_ONLY_ANNOTATIONS,
1141
1316
  },
1142
- async ({ site_url, start_date, end_date, min_impressions, min_position, max_position }) => {
1317
+ async ({ site_url, start_date, end_date, min_impressions, min_position, max_position, limit = DEFAULT_ANALYSIS_RESULT_LIMIT, start_row = 0 }) => {
1143
1318
  assertDateRange(start_date, end_date);
1144
1319
  const googleId = this.requireGoogleId();
1145
1320
  const rateLimitError = await this.rateLimitError(googleId, 'insights.quick_wins');
@@ -1152,11 +1327,13 @@ class GscMcpRuntime {
1152
1327
  });
1153
1328
 
1154
1329
  const quickWins = processQuickWins(source.rows, min_impressions, min_position, max_position);
1330
+ const bounded = windowKnownResults(quickWins, start_row, limit);
1155
1331
  const payload = {
1156
- quick_wins: quickWins,
1332
+ quick_wins: bounded.items,
1157
1333
  pagination: paginationMetadata(source),
1334
+ result_page: bounded.resultPage,
1158
1335
  };
1159
- return toolResponse(JSON.stringify(payload, null, 2), payload);
1336
+ return toolResponse(JSON.stringify(payload), payload);
1160
1337
  },
1161
1338
  );
1162
1339
 
@@ -1164,18 +1341,20 @@ class GscMcpRuntime {
1164
1341
  'insights.cannibalization',
1165
1342
  {
1166
1343
  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.',
1344
+ description: 'Analyze query/page Search Analytics to find queries split across multiple pages. Candidates are ranked deterministically by total impressions, each candidate bounds its page list, and limit/start_row plus result_page provide safe pagination. 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
1345
  inputSchema: {
1169
1346
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1170
1347
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1171
1348
  end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1172
1349
  min_impressions: CANNIBALIZATION_MIN_IMPRESSIONS_SCHEMA,
1173
1350
  min_page_percentage: CANNIBALIZATION_MIN_PAGE_PERCENTAGE_SCHEMA,
1351
+ limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
1352
+ start_row: RESULT_START_ROW_SCHEMA,
1174
1353
  },
1175
1354
  outputSchema: CANNIBALIZATION_OUTPUT_SCHEMA,
1176
1355
  annotations: READ_ONLY_ANNOTATIONS,
1177
1356
  },
1178
- async ({ site_url, start_date, end_date, min_impressions, min_page_percentage }) => {
1357
+ async ({ site_url, start_date, end_date, min_impressions, min_page_percentage, limit = DEFAULT_ANALYSIS_RESULT_LIMIT, start_row = 0 }) => {
1179
1358
  assertDateRange(start_date, end_date);
1180
1359
  const googleId = this.requireGoogleId();
1181
1360
  const rateLimitError = await this.rateLimitError(googleId, 'insights.cannibalization');
@@ -1187,12 +1366,32 @@ class GscMcpRuntime {
1187
1366
  dimensions: ['query', 'page'],
1188
1367
  });
1189
1368
 
1190
- const cannibalizationCandidates = processCannibalization(source.rows, min_impressions, min_page_percentage);
1369
+ const cannibalizationCandidates = processCannibalization(
1370
+ source.rows,
1371
+ min_impressions,
1372
+ min_page_percentage,
1373
+ ).map((candidate) => {
1374
+ const boundedPages = takeBoundedItems(candidate.pages, 10, 12_000);
1375
+ return {
1376
+ ...candidate,
1377
+ page_count: candidate.pages.length,
1378
+ pages_truncated:
1379
+ boundedPages.byteLimitReached ||
1380
+ boundedPages.items.length < candidate.pages.length,
1381
+ pages: boundedPages.items,
1382
+ };
1383
+ });
1384
+ const bounded = windowKnownResults(
1385
+ cannibalizationCandidates,
1386
+ start_row,
1387
+ limit,
1388
+ );
1191
1389
  const payload = {
1192
- candidates: cannibalizationCandidates,
1390
+ candidates: bounded.items,
1193
1391
  pagination: paginationMetadata(source),
1392
+ result_page: bounded.resultPage,
1194
1393
  };
1195
- return toolResponse(JSON.stringify(payload, null, 2), payload);
1394
+ return toolResponse(JSON.stringify(payload), payload);
1196
1395
  },
1197
1396
  );
1198
1397
 
@@ -1200,15 +1399,17 @@ class GscMcpRuntime {
1200
1399
  'insights.content_decay',
1201
1400
  {
1202
1401
  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.',
1402
+ 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
1403
  inputSchema: {
1205
1404
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1206
1405
  compare_days: CONTENT_DECAY_COMPARE_DAYS_SCHEMA,
1406
+ limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
1407
+ start_row: RESULT_START_ROW_SCHEMA,
1207
1408
  },
1208
1409
  outputSchema: CONTENT_DECAY_OUTPUT_SCHEMA,
1209
1410
  annotations: READ_ONLY_ANNOTATIONS,
1210
1411
  },
1211
- async ({ site_url, compare_days }) => {
1412
+ async ({ site_url, compare_days, limit = DEFAULT_ANALYSIS_RESULT_LIMIT, start_row = 0 }) => {
1212
1413
  const googleId = this.requireGoogleId();
1213
1414
  const rateLimitError = await this.rateLimitError(googleId, 'insights.content_decay');
1214
1415
  if (rateLimitError) return rateLimitError;
@@ -1241,21 +1442,29 @@ class GscMcpRuntime {
1241
1442
  dimensions: ['page'],
1242
1443
  });
1243
1444
 
1244
- const decayResults = processContentDecay(recentSource.rows, previousSource.rows);
1445
+ const decayResults = processContentDecay(
1446
+ recentSource.rows,
1447
+ previousSource.rows,
1448
+ );
1449
+ const bounded = windowKnownResults(decayResults, start_row, limit);
1245
1450
 
1246
1451
  const payload = {
1247
1452
  comparison_periods: {
1248
1453
  recent: { start: recentStart, end: recentEnd },
1249
1454
  previous: { start: previousStart, end: previousEnd },
1250
1455
  },
1251
- decay_count: decayResults.length,
1252
- decay_results: decayResults,
1456
+ decay_count: decayResults.filter(
1457
+ (result) => result.classification === 'likely_decay',
1458
+ ).length,
1459
+ assessment_count: decayResults.length,
1460
+ decay_results: bounded.items,
1253
1461
  pagination: {
1254
1462
  recent: paginationMetadata(recentSource),
1255
1463
  previous: paginationMetadata(previousSource),
1256
1464
  },
1465
+ result_page: bounded.resultPage,
1257
1466
  };
1258
- return toolResponse(JSON.stringify(payload, null, 2), payload);
1467
+ return toolResponse(JSON.stringify(payload), payload);
1259
1468
  },
1260
1469
  );
1261
1470
 
@@ -1301,18 +1510,19 @@ class GscMcpRuntime {
1301
1510
  this.server.registerTool(
1302
1511
  'indexing.list_pages',
1303
1512
  {
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.',
1513
+ title: 'List Search-Visible Pages',
1514
+ 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
1515
  inputSchema: {
1307
1516
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1308
1517
  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
1518
  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.'),
1519
+ 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.`),
1520
+ start_row: z.number().int().min(0).default(0).describe('Zero-based page-row offset for pagination.'),
1311
1521
  },
1312
1522
  outputSchema: INDEXED_PAGES_OUTPUT_SCHEMA,
1313
1523
  annotations: READ_ONLY_ANNOTATIONS,
1314
1524
  },
1315
- async ({ site_url, start_date, end_date, row_limit }) => {
1525
+ async ({ site_url, start_date, end_date, row_limit, start_row = 0 }) => {
1316
1526
  const { startDate, endDate } = resolveIndexedPagesDateRange(
1317
1527
  start_date,
1318
1528
  end_date,
@@ -1323,22 +1533,37 @@ class GscMcpRuntime {
1323
1533
  if (rateLimitError) return rateLimitError;
1324
1534
  const accessToken = await this.getAccessToken(googleId);
1325
1535
 
1536
+ const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
1326
1537
  const response = await querySearchAnalytics(accessToken, site_url, {
1327
1538
  startDate,
1328
1539
  endDate,
1329
1540
  dimensions: ['page'],
1330
- rowLimit: row_limit,
1541
+ rowLimit: sourceRowLimit,
1542
+ startRow: start_row,
1331
1543
  });
1332
1544
 
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
- }));
1545
+ const pages = response.rows
1546
+ .filter((row) => (row.keys?.length ?? 0) > 0)
1547
+ .map((row) => ({
1548
+ page: row.keys![0],
1549
+ clicks: row.clicks,
1550
+ impressions: row.impressions,
1551
+ ctr: row.ctr,
1552
+ position: row.position,
1553
+ }));
1554
+ const bounded = windowSourceRows(
1555
+ pages,
1556
+ start_row,
1557
+ row_limit,
1558
+ response.rows.length === sourceRowLimit,
1559
+ );
1340
1560
 
1341
- return toolResponse(JSON.stringify(pages, null, 2), { pages });
1561
+ const payload = {
1562
+ pages: bounded.items,
1563
+ result_page: bounded.resultPage,
1564
+ note: SEARCH_VISIBLE_PAGES_NOTE,
1565
+ };
1566
+ return toolResponse(JSON.stringify(payload), payload);
1342
1567
  },
1343
1568
  );
1344
1569
 
@@ -1346,7 +1571,7 @@ class GscMcpRuntime {
1346
1571
  'analytics.compare',
1347
1572
  {
1348
1573
  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.',
1574
+ description: 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension (query, page, country, device). Apply the same search type and optional dimension filters to both periods. Results are ranked by largest absolute click change, then impression change, and safely paged with limit/start_row. Percentage change is null when the baseline is zero and the comparison value differs.',
1350
1575
  inputSchema: {
1351
1576
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
1352
1577
  start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
@@ -1388,6 +1613,8 @@ class GscMcpRuntime {
1388
1613
  .describe(
1389
1614
  "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
1615
  ),
1616
+ limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
1617
+ start_row: RESULT_START_ROW_SCHEMA,
1391
1618
  },
1392
1619
  outputSchema: PERFORMANCE_COMPARISON_OUTPUT_SCHEMA,
1393
1620
  annotations: READ_ONLY_ANNOTATIONS,
@@ -1401,6 +1628,8 @@ class GscMcpRuntime {
1401
1628
  dimension,
1402
1629
  search_type,
1403
1630
  dimension_filter_groups,
1631
+ limit = DEFAULT_ANALYSIS_RESULT_LIMIT,
1632
+ start_row = 0,
1404
1633
  }) => {
1405
1634
  assertDateRange(start_date_a, end_date_a, 'start_date_a', 'end_date_a');
1406
1635
  assertDateRange(start_date_b, end_date_b, 'start_date_b', 'end_date_b');
@@ -1430,14 +1659,16 @@ class GscMcpRuntime {
1430
1659
  });
1431
1660
 
1432
1661
  const comparison = processPerformanceComparison(sourceA.rows, sourceB.rows);
1662
+ const bounded = windowKnownResults(comparison, start_row, limit);
1433
1663
  const payload = {
1434
- comparisons: comparison,
1664
+ comparisons: bounded.items,
1435
1665
  pagination: {
1436
1666
  period_a: paginationMetadata(sourceA),
1437
1667
  period_b: paginationMetadata(sourceB),
1438
1668
  },
1669
+ result_page: bounded.resultPage,
1439
1670
  };
1440
- return toolResponse(JSON.stringify(payload, null, 2), payload);
1671
+ return toolResponse(JSON.stringify(payload), payload);
1441
1672
  },
1442
1673
  );
1443
1674