@digestseo/mcp-gsc 0.3.1 → 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
@@ -1,70 +1,109 @@
1
- import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
2
- import { McpAgent } from 'agents/mcp';
3
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
- import { z } from 'zod';
5
- import pkg from '../package.json';
6
- import {
7
- buildAuthUrl,
8
- exchangeCodeForTokens,
9
- fetchGoogleUserInfo,
10
- GoogleRefreshTokenRevokedError,
11
- GSC_ACCESS_REVOKED_MESSAGE,
12
- inspectUrl,
13
- listSitemaps,
14
- listSites,
15
- querySearchAnalytics,
16
- refreshAccessToken,
17
- addSite,
18
- deleteSite,
19
- submitSitemap,
20
- deleteSitemap,
21
- getSitemap,
22
- processQuickWins,
23
- processCannibalization,
24
- processContentDecay,
25
- requestIndexing,
26
- processPerformanceComparison,
27
- } from './google';
28
- import {
29
- deleteUser,
30
- getDecryptedRefreshToken,
31
- popPendingAuth,
32
- saveUser,
33
- stashPendingAuth,
34
- } from './storage';
35
- import { generateWeeklyDigest } from './digest';
36
-
37
- export interface Env {
38
- OAUTH_KV: KVNamespace;
39
- USER_KV: KVNamespace;
40
- MCP_OBJECT: DurableObjectNamespace;
41
- OAUTH_PROVIDER: any;
42
- GOOGLE_CLIENT_ID: string;
43
- GOOGLE_CLIENT_SECRET: string;
44
- TOKEN_ENCRYPTION_KEY: string;
45
- }
46
-
47
- interface AgentProps extends Record<string, unknown> {
48
- google_id: string;
49
- email: string;
50
- }
51
-
52
- const SERVER_NAME = 'mcp-gsc';
53
- const SERVER_VERSION = pkg.version;
54
-
55
- const NOT_AUTHENTICATED_MESSAGE =
56
- 'Not authenticated. Please reconnect this server in your MCP client (e.g. Claude.ai → Settings → Connectors).';
57
-
58
- // Annotation utilities for read-only vs write actions.
59
- const READ_ONLY_ANNOTATIONS = {
60
- readOnlyHint: true,
61
- openWorldHint: true,
62
- } as const;
63
-
64
- const WRITE_ANNOTATIONS = {
65
- openWorldHint: true,
66
- } as const;
67
-
1
+ import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
2
+ import { DurableObject } from 'cloudflare:workers';
3
+ import { createMcpHandler, getMcpAuthContext } from 'agents/mcp/server';
4
+ import { McpServer } from '@modelcontextprotocol/server';
5
+ import { z } from 'zod';
6
+ import pkg from '../package.json';
7
+ import {
8
+ buildAuthUrl,
9
+ exchangeCodeForTokens,
10
+ fetchGoogleUserInfo,
11
+ GoogleRefreshTokenRevokedError,
12
+ GSC_ACCESS_REVOKED_MESSAGE,
13
+ MCP_RECONNECT_INSTRUCTION,
14
+ inspectUrl,
15
+ inspectUrlsSequentially,
16
+ listSitemaps,
17
+ listSites,
18
+ getSite,
19
+ querySearchAnalytics,
20
+ querySearchAnalyticsPaginated,
21
+ addSite,
22
+ deleteSite,
23
+ submitSitemap,
24
+ deleteSitemap,
25
+ getSitemap,
26
+ processQuickWins,
27
+ processCannibalization,
28
+ processContentDecay,
29
+ requestIndexing,
30
+ processPerformanceComparison,
31
+ type PaginatedSearchAnalyticsResult,
32
+ } from './google';
33
+ import {
34
+ getDecryptedRefreshToken,
35
+ saveUser,
36
+ } from './storage';
37
+ import { GoogleAccessTokenLifecycle } from './access-token-lifecycle';
38
+ import {
39
+ consumePendingAuth,
40
+ PendingAuthStateStore,
41
+ stashPendingAuth,
42
+ } from './pending-auth-state';
43
+ import { enforceToolRateLimit, type RateLimitedToolName } from './tool-rate-limit';
44
+ export { ToolRateLimiter } from './tool-rate-limiter-do';
45
+ import { generateWeeklyDigest, resolveWeeklyDigestEndDate } from './digest';
46
+ import {
47
+ CANNIBALIZATION_MIN_IMPRESSIONS_SCHEMA,
48
+ CANNIBALIZATION_MIN_PAGE_PERCENTAGE_SCHEMA,
49
+ } from './cannibalization-schema';
50
+ import {
51
+ assertDateNotInFuture,
52
+ assertDateRange,
53
+ getSearchConsoleCalendarDate,
54
+ SEARCH_CONSOLE_DATE_SCHEMA,
55
+ } from './date-validation';
56
+ import { CONTENT_DECAY_COMPARE_DAYS_SCHEMA } from './content-decay-schema';
57
+ import { resolveIndexedPagesDateRange } from './indexed-pages-range';
58
+ import { createQuickWinsInputSchema } from './quick-wins-schema';
59
+ import { WRITE_TOOL_ANNOTATIONS } from './write-tool-annotations';
60
+ import {
61
+ getToolCatalogForAccessMode,
62
+ resolveGscAccessMode,
63
+ type GscAccessMode,
64
+ } from './access-mode';
65
+ import {
66
+ assertIndexingRequestUrl,
67
+ assertIndexingUrlAuthorized,
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';
76
+
77
+ export interface Env {
78
+ OAUTH_KV: KVNamespace;
79
+ USER_KV: KVNamespace;
80
+ MCP_OBJECT: DurableObjectNamespace;
81
+ PENDING_AUTH_STATE: DurableObjectNamespace;
82
+ TOOL_RATE_LIMITER: DurableObjectNamespace;
83
+ OAUTH_PROVIDER: any;
84
+ GOOGLE_CLIENT_ID: string;
85
+ GOOGLE_CLIENT_SECRET: string;
86
+ TOKEN_ENCRYPTION_KEY: string;
87
+ GSC_ACCESS_MODE?: string;
88
+ }
89
+
90
+ export interface AgentProps extends Record<string, unknown> {
91
+ google_id: string;
92
+ email: string;
93
+ }
94
+
95
+ const SERVER_NAME = 'mcp-gsc';
96
+ const SERVER_VERSION = pkg.version;
97
+
98
+ const NOT_AUTHENTICATED_MESSAGE =
99
+ `Not authenticated. ${MCP_RECONNECT_INSTRUCTION}`;
100
+
101
+ // Annotation utilities for read-only vs write actions.
102
+ const READ_ONLY_ANNOTATIONS = {
103
+ readOnlyHint: true,
104
+ openWorldHint: true,
105
+ } as const;
106
+
68
107
  const SITE_URL_DESCRIPTION =
69
108
  "The Search Console property identifier, exactly as returned by sites.list. Two formats exist: domain properties use 'sc-domain:example.com'; URL-prefix properties use the full URL including protocol and trailing slash, e.g. 'https://www.example.com/'. Passing the wrong format returns a permission error even when the user owns the site — call sites.list first if unsure.";
70
109
 
@@ -76,7 +115,12 @@ const METRIC_OUTPUT_SCHEMA = {
76
115
  };
77
116
 
78
117
  const SEARCH_ROW_OUTPUT_SCHEMA = {
79
- 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
+ ),
80
124
  ...METRIC_OUTPUT_SCHEMA,
81
125
  };
82
126
 
@@ -100,17 +144,16 @@ const SITEMAP_OUTPUT_SCHEMA = z
100
144
  z.object({
101
145
  type: z.string(),
102
146
  submitted: z.string(),
103
- indexed: z.string(),
104
147
  }),
105
148
  )
106
149
  .nullable()
107
150
  .optional(),
108
- })
109
- .passthrough();
151
+ });
110
152
 
111
153
  const CAPABILITIES_OUTPUT_SCHEMA = {
112
154
  server: z.string(),
113
155
  version: z.string(),
156
+ access_mode: z.enum(['readonly', 'readwrite']),
114
157
  auth_status: z.enum(['connected', 'not_connected', 'unknown']),
115
158
  tools: z.array(
116
159
  z.object({
@@ -125,10 +168,27 @@ const SITES_OUTPUT_SCHEMA = {
125
168
  sites: z.array(z.object(SITE_OUTPUT_SCHEMA)),
126
169
  };
127
170
 
171
+ const SITE_DETAIL_OUTPUT_SCHEMA = {
172
+ site: z.object(SITE_OUTPUT_SCHEMA),
173
+ };
174
+
128
175
  const INSPECTION_OUTPUT_SCHEMA = {
129
176
  inspection_result: z.unknown(),
130
177
  };
131
178
 
179
+ const INSPECTION_BATCH_OUTPUT_SCHEMA = {
180
+ requested_count: z.number().int().positive(),
181
+ succeeded_count: z.number().int().nonnegative(),
182
+ failed_count: z.number().int().nonnegative(),
183
+ results: z.array(
184
+ z.object({
185
+ inspection_url: z.string(),
186
+ inspection_result: z.unknown().optional(),
187
+ error: z.string().optional(),
188
+ }),
189
+ ),
190
+ };
191
+
132
192
  const SITEMAPS_OUTPUT_SCHEMA = {
133
193
  sitemaps: z.array(SITEMAP_OUTPUT_SCHEMA),
134
194
  };
@@ -138,12 +198,65 @@ const SEARCH_ANALYTICS_OUTPUT_SCHEMA = {
138
198
  start_row: z.number().int().nonnegative(),
139
199
  rows: z.array(z.object(SEARCH_ROW_OUTPUT_SCHEMA)),
140
200
  next_start_row: z.number().int().nonnegative().optional(),
201
+ has_more: z.boolean(),
202
+ truncated: z.boolean(),
203
+ byte_limit_reached: z.boolean(),
204
+ response_aggregation_type: z
205
+ .string()
206
+ .optional()
207
+ .describe('Google response aggregation type, included only when returned by Google.'),
208
+ metadata: z.object({
209
+ first_incomplete_date: z
210
+ .string()
211
+ .optional()
212
+ .describe('First incomplete date, included only when returned by Google.'),
213
+ first_incomplete_hour: z
214
+ .string()
215
+ .optional()
216
+ .describe('First incomplete hour, included only when returned by Google.'),
217
+ })
218
+ .optional()
219
+ .describe('Google data-completeness metadata, included only when returned by Google.'),
141
220
  };
142
221
 
143
222
  const MESSAGE_OUTPUT_SCHEMA = {
144
223
  message: z.string(),
145
224
  };
146
225
 
226
+ const SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA = z.object({
227
+ rows_fetched: z.number().int().nonnegative(),
228
+ pages_fetched: z.number().int().positive(),
229
+ local_limit_reached: z.boolean(),
230
+ });
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
+
147
260
  const QUICK_WIN_OUTPUT_SCHEMA = {
148
261
  quick_wins: z.array(
149
262
  z.object({
@@ -152,6 +265,32 @@ const QUICK_WIN_OUTPUT_SCHEMA = {
152
265
  ...METRIC_OUTPUT_SCHEMA,
153
266
  }),
154
267
  ),
268
+ pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
269
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
270
+ };
271
+
272
+ const PAGE_QUERIES_OUTPUT_SCHEMA = {
273
+ page: z.string(),
274
+ queries: z.array(
275
+ z.object({
276
+ query: z.string(),
277
+ ...METRIC_OUTPUT_SCHEMA,
278
+ }),
279
+ ),
280
+ pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
281
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
282
+ };
283
+
284
+ const QUERY_PAGES_OUTPUT_SCHEMA = {
285
+ query: z.string(),
286
+ pages: z.array(
287
+ z.object({
288
+ page: z.string(),
289
+ ...METRIC_OUTPUT_SCHEMA,
290
+ }),
291
+ ),
292
+ pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
293
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
155
294
  };
156
295
 
157
296
  const CANNIBALIZATION_OUTPUT_SCHEMA = {
@@ -160,6 +299,8 @@ const CANNIBALIZATION_OUTPUT_SCHEMA = {
160
299
  query: z.string(),
161
300
  total_clicks: z.number(),
162
301
  total_impressions: z.number(),
302
+ page_count: z.number().int().nonnegative(),
303
+ pages_truncated: z.boolean(),
163
304
  pages: z.array(
164
305
  z.object({
165
306
  page: z.string(),
@@ -169,6 +310,8 @@ const CANNIBALIZATION_OUTPUT_SCHEMA = {
169
310
  ),
170
311
  }),
171
312
  ),
313
+ pagination: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
314
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
172
315
  };
173
316
 
174
317
  const CONTENT_DECAY_OUTPUT_SCHEMA = {
@@ -177,9 +320,16 @@ const CONTENT_DECAY_OUTPUT_SCHEMA = {
177
320
  previous: z.object({ start: z.string(), end: z.string() }),
178
321
  }),
179
322
  decay_count: z.number().int().nonnegative(),
323
+ assessment_count: z.number().int().nonnegative(),
180
324
  decay_results: z.array(
181
325
  z.object({
182
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(),
183
333
  previous_clicks: z.number(),
184
334
  recent_clicks: z.number(),
185
335
  click_difference: z.number(),
@@ -187,20 +337,36 @@ const CONTENT_DECAY_OUTPUT_SCHEMA = {
187
337
  previous_impressions: z.number(),
188
338
  recent_impressions: z.number(),
189
339
  impression_difference: z.number(),
340
+ impression_change_percentage: z.number().nullable(),
190
341
  previous_position: z.number(),
191
342
  recent_position: z.number(),
343
+ position_change: z.number().nullable(),
192
344
  }),
193
345
  ),
346
+ pagination: z.object({
347
+ recent: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
348
+ previous: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
349
+ }),
350
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
194
351
  };
195
352
 
196
353
  const INDEXING_OUTPUT_SCHEMA = {
197
354
  result: z.unknown(),
355
+ note: z.string(),
198
356
  };
199
357
 
200
358
  const INDEXED_PAGES_OUTPUT_SCHEMA = {
201
359
  pages: z.array(z.object({ page: z.string(), ...METRIC_OUTPUT_SCHEMA })),
360
+ note: z.string(),
361
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
202
362
  };
203
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
+
204
370
  const PERFORMANCE_COMPARISON_OUTPUT_SCHEMA = {
205
371
  comparisons: z.array(
206
372
  z.object({
@@ -209,14 +375,25 @@ const PERFORMANCE_COMPARISON_OUTPUT_SCHEMA = {
209
375
  period_b: z.object(METRIC_OUTPUT_SCHEMA),
210
376
  diff: z.object({
211
377
  clicks: z.number(),
212
- clicks_percentage: z.number(),
378
+ clicks_percentage: z
379
+ .number()
380
+ .nullable()
381
+ .describe('Percentage change from period_b clicks. Null when period_b is zero and period_a differs; zero when both are zero.'),
213
382
  impressions: z.number(),
214
- impressions_percentage: z.number(),
383
+ impressions_percentage: z
384
+ .number()
385
+ .nullable()
386
+ .describe('Percentage change from period_b impressions. Null when period_b is zero and period_a differs; zero when both are zero.'),
215
387
  ctr: z.number(),
216
388
  position: z.number(),
217
389
  }),
218
390
  }),
219
391
  ),
392
+ pagination: z.object({
393
+ period_a: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
394
+ period_b: SEARCH_ANALYTICS_PAGINATION_OUTPUT_SCHEMA,
395
+ }),
396
+ result_page: RESULT_PAGE_OUTPUT_SCHEMA,
220
397
  };
221
398
 
222
399
  const WEEKLY_DIGEST_OUTPUT_SCHEMA = {
@@ -232,823 +409,1385 @@ function toolResponse<T extends Record<string, unknown>>(
232
409
  structuredContent,
233
410
  };
234
411
  }
235
-
236
- // One-line summaries surfaced by get_capabilities. Keep in sync with the
237
- // registerTool calls below (names are asserted by the smoke tests).
238
- const TOOL_CATALOG = [
239
- {
412
+
413
+ function paginationMetadata(result: PaginatedSearchAnalyticsResult) {
414
+ return {
415
+ rows_fetched: result.rows.length,
416
+ pages_fetched: result.pagesFetched,
417
+ local_limit_reached: result.localLimitReached,
418
+ };
419
+ }
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
+
468
+ function toolError(message: string) {
469
+ return {
470
+ content: [{ type: 'text' as const, text: message }],
471
+ isError: true,
472
+ };
473
+ }
474
+
475
+ // One-line summaries surfaced by get_capabilities. Keep in sync with the
476
+ // registerTool calls below (names are asserted by the smoke tests).
477
+ const TOOL_CATALOG = [
478
+ {
240
479
  name: 'sites.list',
241
- description:
242
- 'List the Google Search Console properties (sites) the connected Google account can access.',
243
- },
244
- {
480
+ description:
481
+ 'List the Google Search Console properties (sites) the connected Google account can access.',
482
+ },
483
+ {
484
+ name: 'sites.get',
485
+ description:
486
+ 'Get one exact Search Console property and the connected account\'s permission level for it.',
487
+ },
488
+ {
245
489
  name: 'sites.add',
246
- description:
247
- 'Add a new website property to your Google Search Console account.',
248
- },
249
- {
490
+ description:
491
+ 'Add a new website property to your Google Search Console account.',
492
+ },
493
+ {
250
494
  name: 'sites.delete',
251
- description:
252
- 'Remove an existing website property from your Google Search Console account.',
253
- },
254
- {
495
+ description:
496
+ 'Remove an existing website property from your Google Search Console account.',
497
+ },
498
+ {
255
499
  name: 'analytics.query',
256
- description:
257
- 'Query Search Console search analytics (impressions, clicks, CTR, average position) over a date range, broken down by query, page, country, device, date, or search appearance. Supports filters and pagination.',
258
- },
259
- {
500
+ description:
501
+ 'Query Search Console search analytics (impressions, clicks, CTR, average position) over a date range, broken down by query, page, country, device, date, or search appearance. Supports filters and pagination.',
502
+ },
503
+ {
504
+ name: 'insights.page_queries',
505
+ description:
506
+ 'For one exact page URL, list the Search Console queries that produced impressions for it, with clicks, impressions, CTR, average position, search type, and bounded pagination.',
507
+ },
508
+ {
509
+ name: 'insights.query_pages',
510
+ description:
511
+ 'For one exact search query, list the site pages that received impressions for it, with clicks, impressions, CTR, average position, search type, and bounded pagination.',
512
+ },
513
+ {
260
514
  name: 'urls.inspect',
261
- description:
262
- "Inspect a single URL's index status in Google: indexed state, last crawl, mobile usability, and rich-results eligibility.",
263
- },
264
- {
515
+ description:
516
+ "Inspect a single URL's index status in Google: indexed state, last crawl, mobile usability, and rich-results eligibility. Use urls.inspect_many for 2-10 URLs.",
517
+ },
518
+ {
519
+ name: 'urls.inspect_many',
520
+ description:
521
+ 'Inspect up to 10 URLs sequentially in one MCP call while charging the same URL Inspection safety budget per URL.',
522
+ },
523
+ {
265
524
  name: 'sitemaps.list',
266
- description:
267
- 'List all sitemaps submitted for a property, with submission/processing status and warning and error counts.',
268
- },
269
- {
525
+ description:
526
+ 'List all sitemaps submitted for a property, with submission/processing status, submitted URL counts, and warning/error counts. Google\'s deprecated sitemap indexed count is intentionally omitted.',
527
+ },
528
+ {
270
529
  name: 'sitemaps.submit',
271
- description:
272
- 'Submit a new sitemap to your Google Search Console account.',
273
- },
274
- {
530
+ description:
531
+ 'Submit a new sitemap to your Google Search Console account.',
532
+ },
533
+ {
275
534
  name: 'sitemaps.delete',
276
- description:
277
- 'Remove/delete a submitted sitemap from your Google Search Console account.',
278
- },
279
- {
535
+ description:
536
+ 'Remove/delete a submitted sitemap from your Google Search Console account.',
537
+ },
538
+ {
280
539
  name: 'sitemaps.get',
281
- description:
282
- 'Get status and details of a single sitemap submitted to Google Search Console.',
283
- },
284
- {
540
+ description:
541
+ 'Get status and details of a single sitemap submitted to Google Search Console.',
542
+ },
543
+ {
285
544
  name: 'insights.quick_wins',
286
- description:
287
- 'Find search queries that rank in positions 8-20 (page 2 / lower page 1) with high impressions but low CTR. These are ideal SEO optimization targets.',
288
- },
289
- {
545
+ description:
546
+ '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.',
547
+ },
548
+ {
290
549
  name: 'insights.cannibalization',
291
- description:
292
- 'Analyze search analytics to detect instances of keyword cannibalization, where multiple pages on your site compete for the same query.',
293
- },
294
- {
550
+ description:
551
+ 'Analyze search analytics to detect instances of keyword cannibalization, where multiple pages on your site compete for the same query.',
552
+ },
553
+ {
295
554
  name: 'insights.content_decay',
296
- description:
297
- 'Identify content decay by comparing search clicks for your site pages between two contiguous periods and finding the pages with the largest traffic drops.',
298
- },
299
- {
555
+ description:
556
+ 'Assess page-level click declines across two contiguous periods and distinguish likely decay from weak evidence or improving visibility with click volatility.',
557
+ },
558
+ {
300
559
  name: 'indexing.request',
301
- description:
302
- 'Request Google to index or update a URL using the Google Indexing API.',
303
- },
304
- {
560
+ description:
561
+ 'Request Google to index or update a URL using the Google Indexing API.',
562
+ },
563
+ {
305
564
  name: 'indexing.list_pages',
306
- description:
307
- 'Retrieve a list of site pages that have received search impressions, serving as a proxy list of indexed pages on the site.',
308
- },
309
- {
565
+ description: SEARCH_VISIBLE_PAGES_DESCRIPTION,
566
+ },
567
+ {
310
568
  name: 'analytics.compare',
311
- description:
312
- 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension.',
313
- },
314
- {
569
+ description:
570
+ 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension.',
571
+ },
572
+ {
315
573
  name: 'reports.weekly_digest',
316
- description:
317
- 'Generate a plain-language weekly SEO report for one Google Search Console property.',
318
- },
319
- {
574
+ description:
575
+ 'Generate a plain-language weekly SEO report for one Google Search Console property.',
576
+ },
577
+ {
320
578
  name: 'server.capabilities',
321
- description:
322
- "List every tool this server exposes and report whether the user's Google Search Console connection is currently authenticated.",
323
- },
324
- ] as const;
325
-
326
- export class GscMcpAgent extends McpAgent<Env, unknown, AgentProps> {
327
- server = new McpServer({
328
- name: SERVER_NAME,
329
- version: SERVER_VERSION,
330
- });
331
-
332
- private accessTokenCache = new Map<
333
- string,
334
- { token: string; expires_at: number }
335
- >();
336
-
337
- private requireGoogleId(): string {
338
- const googleId = this.props?.google_id;
339
- if (!googleId) {
340
- throw new Error(NOT_AUTHENTICATED_MESSAGE);
341
- }
342
- return googleId;
343
- }
344
-
345
- private async getAccessToken(googleId: string): Promise<string> {
346
- const now = Date.now();
347
- const cached = this.accessTokenCache.get(googleId);
348
- if (cached && cached.expires_at > now + 60_000) {
349
- return cached.token;
350
- }
351
- const refreshToken = await getDecryptedRefreshToken(this.env, googleId);
352
- if (!refreshToken) {
353
- console.warn('No refresh token record for user', { google_id: googleId });
354
- throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
355
- }
356
- let tokens;
357
- try {
358
- tokens = await refreshAccessToken(
359
- refreshToken,
360
- this.env.GOOGLE_CLIENT_ID,
361
- this.env.GOOGLE_CLIENT_SECRET,
362
- );
363
- } catch (err) {
364
- if (err instanceof GoogleRefreshTokenRevokedError) {
365
- this.accessTokenCache.delete(googleId);
366
- await deleteUser(this.env, googleId);
367
- console.warn('Refresh token revoked, deleted user record', {
368
- google_id: googleId,
369
- });
370
- }
371
- throw err;
372
- }
373
- this.accessTokenCache.set(googleId, {
374
- token: tokens.access_token,
375
- expires_at: now + tokens.expires_in * 1000,
376
- });
377
- return tokens.access_token;
378
- }
379
-
380
- async init() {
381
- this.server.registerTool(
579
+ description:
580
+ "List every tool this server exposes and report whether the user's Google Search Console connection is currently authenticated.",
581
+ },
582
+ ] as const;
583
+
584
+ class GscMcpRuntime {
585
+ server = new McpServer({
586
+ name: SERVER_NAME,
587
+ version: SERVER_VERSION,
588
+ });
589
+
590
+ constructor(
591
+ private readonly env: Env,
592
+ private readonly props?: AgentProps,
593
+ private readonly accessTokens = getSharedAccessTokenLifecycle(env),
594
+ ) {}
595
+
596
+ private requireGoogleId(): string {
597
+ const googleId = this.props?.google_id;
598
+ if (!googleId) {
599
+ throw new Error(NOT_AUTHENTICATED_MESSAGE);
600
+ }
601
+ return googleId;
602
+ }
603
+
604
+ private async rateLimitError(
605
+ googleId: string,
606
+ toolName: RateLimitedToolName,
607
+ units = 1,
608
+ ) {
609
+ const result = await enforceToolRateLimit(this.env, googleId, toolName, units);
610
+ if (result.allowed) return null;
611
+
612
+ const retryAfterSeconds = Math.max(
613
+ 1,
614
+ Math.ceil((result.retry_after_ms ?? 0) / 1000),
615
+ );
616
+ return toolError(
617
+ `Rate limit reached for ${toolName}. Retry after ${retryAfterSeconds} seconds.`,
618
+ );
619
+ }
620
+
621
+ private async getAccessToken(googleId: string): Promise<string> {
622
+ return this.getAccessTokenLifecycle().getAccessToken(googleId);
623
+ }
624
+
625
+ private getAccessTokenLifecycle(): GoogleAccessTokenLifecycle {
626
+ return this.accessTokens;
627
+ }
628
+
629
+ async init() {
630
+ const accessMode = resolveGscAccessMode(this.env.GSC_ACCESS_MODE);
631
+
632
+ this.server.registerTool(
382
633
  'server.capabilities',
383
- {
384
- title: 'Get server capabilities and auth status',
634
+ {
635
+ title: 'Get server capabilities and auth status',
385
636
  description:
386
- "List every tool this server exposes and report whether the user's Google Search Console connection is currently authenticated. Call this first if you're unsure what tools are available or whether the user is connected. Returns the tool catalog plus an auth status of `connected` or `not_connected`. Takes no arguments.",
637
+ "List every tool this server exposes, its configured access mode, and whether the user's Google Search Console connection is currently authenticated. Call this first if you're unsure what tools are available, whether the deployment is read-only, or whether the user is connected. Returns the tool catalog plus an access mode and auth status. Takes no arguments.",
387
638
  inputSchema: {},
388
639
  outputSchema: CAPABILITIES_OUTPUT_SCHEMA,
389
640
  annotations: READ_ONLY_ANNOTATIONS,
390
- },
391
- async () => {
392
- let authStatus: 'connected' | 'not_connected' | 'unknown';
393
- try {
394
- const googleId = this.props?.google_id;
395
- if (!googleId) {
396
- authStatus = 'not_connected';
397
- } else {
398
- const refreshToken = await getDecryptedRefreshToken(
399
- this.env,
400
- googleId,
401
- );
402
- authStatus = refreshToken ? 'connected' : 'not_connected';
403
- }
404
- } catch {
405
- authStatus = 'unknown';
406
- }
407
- const capabilities = {
408
- server: SERVER_NAME,
409
- version: SERVER_VERSION,
410
- auth_status: authStatus,
411
- tools: TOOL_CATALOG,
412
- hint: "If auth_status is not 'connected', the user should reconnect this server in their MCP client to sign in with Google.",
413
- };
641
+ },
642
+ async () => {
643
+ let authStatus: 'connected' | 'not_connected' | 'unknown';
644
+ try {
645
+ const googleId = this.props?.google_id;
646
+ if (!googleId) {
647
+ authStatus = 'not_connected';
648
+ } else {
649
+ const refreshToken = await getDecryptedRefreshToken(
650
+ this.env,
651
+ googleId,
652
+ );
653
+ authStatus = refreshToken ? 'connected' : 'not_connected';
654
+ }
655
+ } catch {
656
+ authStatus = 'unknown';
657
+ }
658
+ const capabilities = {
659
+ server: SERVER_NAME,
660
+ version: SERVER_VERSION,
661
+ access_mode: accessMode,
662
+ auth_status: authStatus,
663
+ tools: getToolCatalogForAccessMode(TOOL_CATALOG, accessMode),
664
+ hint: "If auth_status is not 'connected', the user should reconnect this server in their MCP client to sign in with Google.",
665
+ };
414
666
  return toolResponse(JSON.stringify(capabilities, null, 2), capabilities);
415
- },
416
- );
417
-
418
- this.server.registerTool(
667
+ },
668
+ );
669
+
670
+ this.server.registerTool(
419
671
  'sites.list',
420
- {
421
- title: 'List Search Console properties',
672
+ {
673
+ title: 'List Search Console properties',
422
674
  description:
423
675
  "List the Google Search Console properties (sites) the connected Google account has access to. Returns an array of { siteUrl, permissionLevel }. Call this when the user asks 'what sites do I have?' or 'what properties are connected?', or when the user asks about SEO for a site and hasn't specified which property. Also useful as a discovery step before calling other tools that require a site_url argument.",
424
676
  inputSchema: {},
425
677
  outputSchema: SITES_OUTPUT_SCHEMA,
426
678
  annotations: READ_ONLY_ANNOTATIONS,
427
- },
428
- async () => {
429
- const googleId = this.requireGoogleId();
430
- const accessToken = await this.getAccessToken(googleId);
431
- const sites = await listSites(accessToken);
679
+ },
680
+ async () => {
681
+ const googleId = this.requireGoogleId();
682
+ const accessToken = await this.getAccessToken(googleId);
683
+ const sites = await listSites(accessToken);
432
684
  return toolResponse(JSON.stringify(sites, null, 2), { sites });
433
- },
434
- );
435
-
436
- this.server.registerTool(
685
+ },
686
+ );
687
+
688
+ this.server.registerTool(
689
+ 'sites.get',
690
+ {
691
+ title: 'Get Search Console property',
692
+ description:
693
+ "Retrieve one exact Google Search Console property and the connected account's permission level for it. Use this when the user has already named a property and you need to confirm that exact property or its access level without listing every property first.",
694
+ inputSchema: {
695
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
696
+ },
697
+ outputSchema: SITE_DETAIL_OUTPUT_SCHEMA,
698
+ annotations: READ_ONLY_ANNOTATIONS,
699
+ },
700
+ async ({ site_url }) => {
701
+ const googleId = this.requireGoogleId();
702
+ const accessToken = await this.getAccessToken(googleId);
703
+ const site = await getSite(accessToken, site_url);
704
+ return toolResponse(JSON.stringify(site, null, 2), { site });
705
+ },
706
+ );
707
+
708
+ this.server.registerTool(
437
709
  'urls.inspect',
438
- {
439
- title: 'Inspect URL index status',
440
- description: `Inspect a single URL's index status in Google. Returns: whether the URL is indexed, last crawl date, indexing state, mobile usability, rich-results eligibility, and any AMP results. Use this when the user asks 'is X indexed?', 'why isn't X showing in Google?', or wants a deep look at one specific page. For bulk checks across many URLs, call this tool repeatedly — there is no batch endpoint — but note Google caps URL inspection at roughly 2,000 calls per property per day.`,
710
+ {
711
+ title: 'Inspect URL index status',
712
+ description: `Inspect a single URL's index status in Google. Returns: whether the URL is indexed, last crawl date, indexing state, mobile usability, rich-results eligibility, and any AMP results. Use this when the user asks 'is X indexed?', 'why isn't X showing in Google?', or wants a deep look at one specific page. For a bounded group of 2-10 URLs, prefer urls.inspect_many; Google still processes one URL Inspection request per URL and applies the same quota semantics.`,
441
713
  inputSchema: {
442
- site_url: z.string().describe(SITE_URL_DESCRIPTION),
443
- inspection_url: z
444
- .string()
445
- .describe(
446
- 'The fully-qualified URL to inspect. Must belong to the site_url property: same domain for sc-domain properties, same URL prefix for URL-prefix properties.',
447
- ),
448
- language_code: z
449
- .string()
450
- .default('en-US')
451
- .describe(
452
- "BCP-47 language code for translatable strings in the result, e.g. 'en-US' or 'de-DE'.",
714
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
715
+ inspection_url: z
716
+ .string()
717
+ .describe(
718
+ 'The fully-qualified URL to inspect. Must belong to the site_url property: same domain for sc-domain properties, same URL prefix for URL-prefix properties.',
719
+ ),
720
+ language_code: z
721
+ .string()
722
+ .default('en-US')
723
+ .describe(
724
+ "BCP-47 language code for translatable strings in the result, e.g. 'en-US' or 'de-DE'.",
453
725
  ),
454
726
  },
455
727
  outputSchema: INSPECTION_OUTPUT_SCHEMA,
456
728
  annotations: READ_ONLY_ANNOTATIONS,
457
- },
458
- async ({ site_url, inspection_url, language_code }) => {
459
- const googleId = this.requireGoogleId();
460
- const accessToken = await this.getAccessToken(googleId);
461
- const result = await inspectUrl(
462
- accessToken,
463
- site_url,
464
- inspection_url,
465
- language_code,
466
- );
729
+ },
730
+ async ({ site_url, inspection_url, language_code }) => {
731
+ const googleId = this.requireGoogleId();
732
+ const rateLimitError = await this.rateLimitError(googleId, 'urls.inspect');
733
+ if (rateLimitError) return rateLimitError;
734
+ const accessToken = await this.getAccessToken(googleId);
735
+ const result = await inspectUrl(
736
+ accessToken,
737
+ site_url,
738
+ inspection_url,
739
+ language_code,
740
+ );
467
741
  return toolResponse(JSON.stringify(result, null, 2), {
468
742
  inspection_result: result,
469
743
  });
470
- },
471
- );
472
-
473
- this.server.registerTool(
744
+ },
745
+ );
746
+
747
+ this.server.registerTool(
748
+ 'urls.inspect_many',
749
+ {
750
+ title: 'Inspect multiple URLs',
751
+ description: `Inspect up to 10 URLs from one Search Console property in a single MCP call. Google still processes one URL Inspection request per URL, so every requested URL consumes one quota unit and one unit of this server's shared URL-inspection safety budget. Requests are sent sequentially to avoid unnecessary bursts. Use this for a small group of important, recently changed, or debugging-target URLs; do not use it to crawl an entire site.`,
752
+ inputSchema: {
753
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
754
+ inspection_urls: z
755
+ .array(z.string().url())
756
+ .min(1)
757
+ .max(10)
758
+ .describe(
759
+ 'Between 1 and 10 fully-qualified URLs to inspect. Each URL must belong to the site_url property.',
760
+ ),
761
+ language_code: z
762
+ .string()
763
+ .default('en-US')
764
+ .describe(
765
+ "BCP-47 language code for translatable strings in the results, e.g. 'en-US' or 'de-DE'.",
766
+ ),
767
+ },
768
+ outputSchema: INSPECTION_BATCH_OUTPUT_SCHEMA,
769
+ annotations: READ_ONLY_ANNOTATIONS,
770
+ },
771
+ async ({ site_url, inspection_urls, language_code }) => {
772
+ const googleId = this.requireGoogleId();
773
+ const rateLimitError = await this.rateLimitError(
774
+ googleId,
775
+ 'urls.inspect_many',
776
+ inspection_urls.length,
777
+ );
778
+ if (rateLimitError) return rateLimitError;
779
+
780
+ const accessToken = await this.getAccessToken(googleId);
781
+ const batchResults = await inspectUrlsSequentially(
782
+ accessToken,
783
+ site_url,
784
+ inspection_urls,
785
+ language_code,
786
+ );
787
+ const results = batchResults.map((result) => ({
788
+ inspection_url: result.inspectionUrl,
789
+ ...(result.inspectionResult !== undefined
790
+ ? { inspection_result: result.inspectionResult }
791
+ : {}),
792
+ ...(result.error !== undefined ? { error: result.error } : {}),
793
+ }));
794
+
795
+ const succeededCount = results.filter(
796
+ (result) => result.inspection_result !== undefined,
797
+ ).length;
798
+ const payload = {
799
+ requested_count: inspection_urls.length,
800
+ succeeded_count: succeededCount,
801
+ failed_count: results.length - succeededCount,
802
+ results,
803
+ };
804
+ return toolResponse(JSON.stringify(payload, null, 2), payload);
805
+ },
806
+ );
807
+
808
+ this.server.registerTool(
474
809
  'sitemaps.list',
475
- {
476
- title: 'List submitted sitemaps',
477
- description:
478
- 'List all sitemaps submitted for a Search Console property. Returns sitemap URLs, last submitted/downloaded dates, indexed URL counts, warning and error counts, and sitemap status. Use this when the user asks about sitemap health, submission status, or wants to audit which sitemaps are working.',
810
+ {
811
+ title: 'List submitted sitemaps',
812
+ description:
813
+ 'List sitemaps submitted for a Search Console property. Optionally filter to entries included by one sitemap index. Returns sitemap URLs, last submitted/downloaded dates, submitted URL counts, warning and error counts, and sitemap status. Google\'s deprecated sitemap indexed count is intentionally omitted. Use this when the user asks about sitemap health, submission status, wants to audit which sitemaps are working, or needs the child sitemaps belonging to a specific sitemap index.',
479
814
  inputSchema: {
480
815
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
816
+ sitemap_index: z
817
+ .string()
818
+ .url()
819
+ .optional()
820
+ .describe(
821
+ 'Optional sitemap index URL. When supplied, Google returns sitemap entries included in that index.',
822
+ ),
481
823
  },
482
824
  outputSchema: SITEMAPS_OUTPUT_SCHEMA,
483
825
  annotations: READ_ONLY_ANNOTATIONS,
484
- },
485
- async ({ site_url }) => {
486
- const googleId = this.requireGoogleId();
487
- const accessToken = await this.getAccessToken(googleId);
488
- const sitemaps = await listSitemaps(accessToken, site_url);
826
+ },
827
+ async ({ site_url, sitemap_index }) => {
828
+ const googleId = this.requireGoogleId();
829
+ const accessToken = await this.getAccessToken(googleId);
830
+ const sitemaps = await listSitemaps(accessToken, site_url, sitemap_index);
489
831
  return toolResponse(JSON.stringify(sitemaps, null, 2), { sitemaps });
490
- },
491
- );
492
-
493
- this.server.registerTool(
832
+ },
833
+ );
834
+
835
+ this.server.registerTool(
494
836
  'analytics.query',
495
- {
496
- title: 'Query search analytics',
497
- description: [
498
- 'Query Google Search Console search analytics data. Returns',
499
- '{ row_count, start_row, rows } where each row has keys (dimension',
500
- 'values), clicks, impressions, ctr, and position. When row_count',
501
- 'equals row_limit, the response includes next_start_row — pass it',
502
- 'back as start_row to fetch the next page.',
503
- '',
504
- 'IMPORTANT BEHAVIORS — read before calling:',
505
- '- For SITE TOTALS (total impressions, total clicks, overall CTR,',
506
- ' overall average position), call with dimensions: []. This returns',
507
- ' a single row containing the true site-level totals. Do NOT call',
508
- ' with dimensions: ["query"] and sum the rows — Google omits',
509
- ' anonymized low-volume queries from per-dimension responses, so',
510
- ' summing per-query rows will undercount.',
511
- '- For TOP QUERIES / PAGES / COUNTRIES / DEVICES, call with the',
512
- ' matching dimension. Expect the returned rows to cover only a',
513
- ' subset of total impressions; this is normal Google behavior, not',
514
- ' a data error.',
515
- '- DATA FRESHNESS: Search Console data lags about 2-3 days behind',
516
- ' real time. If the user asks about "today" or "yesterday", expect',
517
- ' empty or partial rows for the most recent days; the latest',
518
- ' reliably-complete date is usually 3 days ago.',
519
- '- AVERAGE POSITION is impression-weighted. To compute an overall',
520
- ' position across multiple rows, use',
521
- ' sum(position * impressions) / sum(impressions). Never plain-average',
522
- ' the position column across rows.',
523
- '- CTR in the response is a 0–1 fraction. Multiply by 100 for percent.',
524
- '- Use search_type to query image/video/news/discover indexes',
525
- ' separately from web.',
526
- '- Use dimension_filter_groups to filter by country, device, query',
527
- ' content, page URL, or search feature. Filters with includingRegex',
528
- ' accept JavaScript regex syntax. For brand vs non-brand splits,',
529
- " pass a single regex filter on the 'query' dimension.",
530
- "- data_state defaults to 'all' which matches the GSC dashboard.",
531
- " Pass 'final' only when the user explicitly asks for stable,",
532
- ' non-preliminary data.',
533
- ].join('\n'),
534
- inputSchema: {
535
- site_url: z.string().describe(SITE_URL_DESCRIPTION),
536
- start_date: z
537
- .string()
538
- .regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD format')
539
- .describe('Start date (inclusive) in YYYY-MM-DD format.'),
540
- end_date: z
541
- .string()
542
- .regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD format')
543
- .describe(
544
- 'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day data lag: the most recent complete date is usually 3 days ago.',
545
- ),
546
- dimensions: z
547
- .array(
548
- z.enum([
549
- 'query',
550
- 'page',
551
- 'country',
552
- 'device',
553
- 'date',
554
- 'searchAppearance',
555
- ]),
556
- )
557
- .default(['query'])
558
- .describe(
559
- 'Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals.',
560
- ),
561
- row_limit: z
562
- .number()
563
- .int()
564
- .min(1)
565
- .max(25000)
566
- .default(100)
567
- .describe(
568
- '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.',
569
- ),
570
- start_row: z
571
- .number()
572
- .int()
573
- .min(0)
574
- .default(0)
575
- .describe(
576
- 'Zero-based row offset for pagination. When a response contains next_start_row, pass it here to fetch the next page.',
577
- ),
578
- data_state: z
579
- .enum(['all', 'final'])
580
- .default('all')
581
- .describe(
582
- "'all' includes fresh (preliminary) data and matches the GSC dashboard; 'final' returns only finalized data.",
583
- ),
584
- search_type: z
585
- .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
586
- .default('web')
587
- .describe('Which search index to query. Defaults to web.'),
588
- aggregation_type: z
589
- .enum(['auto', 'byPage', 'byProperty'])
590
- .default('auto')
591
- .describe(
592
- "How Google aggregates metrics. Leave as 'auto' unless you specifically need byPage or byProperty semantics.",
593
- ),
594
- dimension_filter_groups: z
595
- .array(
596
- z.object({
597
- groupType: z.literal('and').default('and'),
598
- filters: z.array(
599
- z.object({
600
- dimension: z.enum([
601
- 'query',
602
- 'page',
603
- 'country',
604
- 'device',
605
- 'searchAppearance',
606
- ]),
607
- operator: z.enum([
608
- 'equals',
609
- 'notEquals',
610
- 'contains',
611
- 'notContains',
612
- 'includingRegex',
613
- 'excludingRegex',
614
- ]),
615
- expression: z.string(),
616
- }),
617
- ),
618
- }),
619
- )
620
- .optional()
621
- .describe(
622
- "Optional filters ANDed together, e.g. [{ groupType: 'and', filters: [{ dimension: 'country', operator: 'equals', expression: 'usa' }] }]. Countries use ISO 3166-1 alpha-3 codes.",
623
- ),
837
+ {
838
+ title: 'Query search analytics',
839
+ description: [
840
+ 'Query Google Search Console search analytics data. Returns',
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',
849
+ 'them, response_aggregation_type and metadata are also included;',
850
+ 'metadata may identify the first incomplete date or hour.',
851
+ '',
852
+ 'IMPORTANT BEHAVIORS — read before calling:',
853
+ '- For SITE TOTALS (total impressions, total clicks, overall CTR,',
854
+ ' overall average position), call with dimensions: []. This returns',
855
+ ' a single row containing the true site-level totals. Do NOT call',
856
+ ' with dimensions: ["query"] and sum the rows — Google omits',
857
+ ' anonymized low-volume queries from per-dimension responses, so',
858
+ ' summing per-query rows will undercount.',
859
+ '- For TOP QUERIES / PAGES / COUNTRIES / DEVICES, call with the',
860
+ ' matching dimension. Expect the returned rows to cover only a',
861
+ ' subset of total impressions; this is normal Google behavior, not',
862
+ ' a data error.',
863
+ '- DATA FRESHNESS: Search Console data lags about 2-3 days behind',
864
+ ' real time. If the user asks about "today" or "yesterday", expect',
865
+ ' empty or partial rows for the most recent days; the latest',
866
+ ' reliably-complete date is usually 3 days ago.',
867
+ '- AVERAGE POSITION is impression-weighted. To compute an overall',
868
+ ' position across multiple rows, use',
869
+ ' sum(position * impressions) / sum(impressions). Never plain-average',
870
+ ' the position column across rows.',
871
+ '- CTR in the response is a 0–1 fraction. Multiply by 100 for percent.',
872
+ '- Use search_type to query image/video/news/discover indexes',
873
+ ' separately from web.',
874
+ '- News Showcase panel reporting: set aggregation_type to',
875
+ ' "byNewsShowcasePanel", use search_type "discover" or',
876
+ ' "googleNews", and include a searchAppearance equals',
877
+ ' "NEWS_SHOWCASE" filter. Do not group/filter by page or add',
878
+ ' another searchAppearance filter for that aggregation mode.',
879
+ '- For hourly breakdowns, include "hour" in dimensions and set',
880
+ ' data_state to "hourly_all". Hourly data is preliminary.',
881
+ '- Use dimension_filter_groups to filter by country, device, query',
882
+ ' content, page URL, or search feature. includingRegex and',
883
+ ' excludingRegex use RE2 syntax. For brand vs non-brand splits,',
884
+ " pass a single regex filter on the 'query' dimension.",
885
+ "- data_state defaults to 'all' which matches the GSC dashboard.",
886
+ " Pass 'final' only when the user explicitly asks for stable,",
887
+ ' non-preliminary data.',
888
+ ].join('\n'),
889
+ inputSchema: {
890
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
891
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
892
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
893
+ 'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day data lag: the most recent complete date is usually 3 days ago.',
894
+ ),
895
+ dimensions: z
896
+ .array(
897
+ z.enum([
898
+ 'query',
899
+ 'page',
900
+ 'country',
901
+ 'device',
902
+ 'date',
903
+ 'hour',
904
+ 'searchAppearance',
905
+ ]),
906
+ )
907
+ .default(['query'])
908
+ .describe(
909
+ 'Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals.',
910
+ ),
911
+ row_limit: z
912
+ .number()
913
+ .int()
914
+ .min(1)
915
+ .max(25000)
916
+ .default(100)
917
+ .describe(
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.`,
919
+ ),
920
+ start_row: z
921
+ .number()
922
+ .int()
923
+ .min(0)
924
+ .default(0)
925
+ .describe(
926
+ 'Zero-based row offset for pagination. When a response contains next_start_row, pass it here to fetch the next page.',
927
+ ),
928
+ data_state: z
929
+ .enum(['all', 'final', 'hourly_all'])
930
+ .default('all')
931
+ .describe(
932
+ "'all' includes fresh (preliminary) data and matches the GSC dashboard; 'final' returns only finalized data; 'hourly_all' returns hourly preliminary data and should be paired with the 'hour' dimension.",
933
+ ),
934
+ search_type: z
935
+ .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
936
+ .default('web')
937
+ .describe('Which search index to query. Defaults to web.'),
938
+ aggregation_type: z
939
+ .enum(['auto', 'byNewsShowcasePanel', 'byPage', 'byProperty'])
940
+ .default('auto')
941
+ .describe(
942
+ "How Google aggregates metrics. Leave as 'auto' unless specific semantics are needed. 'byProperty' cannot be used with page grouping/filtering or search_type discover/googleNews. 'byNewsShowcasePanel' requires search_type discover/googleNews plus a searchAppearance equals NEWS_SHOWCASE filter, and cannot be combined with page grouping/filtering or another searchAppearance filter.",
943
+ ),
944
+ dimension_filter_groups: z
945
+ .array(
946
+ z.object({
947
+ groupType: z.literal('and').default('and'),
948
+ filters: z.array(
949
+ z.object({
950
+ dimension: z.enum([
951
+ 'query',
952
+ 'page',
953
+ 'country',
954
+ 'device',
955
+ 'searchAppearance',
956
+ ]),
957
+ operator: z.enum([
958
+ 'equals',
959
+ 'notEquals',
960
+ 'contains',
961
+ 'notContains',
962
+ 'includingRegex',
963
+ 'excludingRegex',
964
+ ]),
965
+ expression: z.string().max(4096),
966
+ }),
967
+ ),
968
+ }),
969
+ )
970
+ .optional()
971
+ .describe(
972
+ "Optional filters ANDed together, e.g. [{ groupType: 'and', filters: [{ dimension: 'country', operator: 'equals', expression: 'usa' }] }]. Countries use ISO 3166-1 alpha-3 codes.",
973
+ ),
624
974
  },
625
975
  outputSchema: SEARCH_ANALYTICS_OUTPUT_SCHEMA,
626
976
  annotations: READ_ONLY_ANNOTATIONS,
627
- },
628
- async ({
629
- site_url,
630
- start_date,
631
- end_date,
632
- dimensions,
633
- row_limit,
634
- start_row,
635
- data_state,
636
- search_type,
637
- aggregation_type,
638
- dimension_filter_groups,
639
- }) => {
640
- const googleId = this.requireGoogleId();
641
- const accessToken = await this.getAccessToken(googleId);
642
- const rows = await querySearchAnalytics(accessToken, site_url, {
643
- startDate: start_date,
644
- endDate: end_date,
645
- dimensions,
646
- rowLimit: row_limit,
647
- startRow: start_row,
648
- dataState: data_state,
649
- type: search_type,
650
- aggregationType: aggregation_type,
651
- ...(dimension_filter_groups !== undefined
652
- ? { dimensionFilterGroups: dimension_filter_groups }
653
- : {}),
654
- });
655
- const payload: Record<string, unknown> = {
656
- row_count: rows.length,
657
- start_row,
658
- rows,
659
- };
660
- if (rows.length === row_limit) {
661
- payload.next_start_row = start_row + row_limit;
662
- }
663
- // Compact JSON on purpose: analytics responses are the largest this
664
- // server produces, and pretty-printing them costs ~3x the tokens.
977
+ },
978
+ async ({
979
+ site_url,
980
+ start_date,
981
+ end_date,
982
+ dimensions,
983
+ row_limit,
984
+ start_row,
985
+ data_state,
986
+ search_type,
987
+ aggregation_type,
988
+ dimension_filter_groups,
989
+ }) => {
990
+ assertDateRange(start_date, end_date);
991
+ const googleId = this.requireGoogleId();
992
+ const rateLimitError = await this.rateLimitError(googleId, 'analytics.query');
993
+ if (rateLimitError) return rateLimitError;
994
+ const accessToken = await this.getAccessToken(googleId);
995
+ const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
996
+ const response = await querySearchAnalytics(accessToken, site_url, {
997
+ startDate: start_date,
998
+ endDate: end_date,
999
+ dimensions,
1000
+ rowLimit: sourceRowLimit,
1001
+ startRow: start_row,
1002
+ dataState: data_state,
1003
+ type: search_type,
1004
+ aggregationType: aggregation_type,
1005
+ ...(dimension_filter_groups !== undefined
1006
+ ? { dimensionFilterGroups: dimension_filter_groups }
1007
+ : {}),
1008
+ });
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
+ );
1017
+ const payload: Record<string, unknown> = {
1018
+ row_count: bounded.items.length,
1019
+ start_row,
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,
1024
+ };
1025
+ if (response.responseAggregationType !== undefined) {
1026
+ payload.response_aggregation_type = response.responseAggregationType;
1027
+ }
1028
+ if (response.metadata !== undefined) {
1029
+ payload.metadata = response.metadata;
1030
+ }
1031
+ if (bounded.resultPage.next_start_row !== undefined) {
1032
+ payload.next_start_row = bounded.resultPage.next_start_row;
1033
+ }
1034
+ // Compact JSON on purpose: analytics responses are the largest this
1035
+ // server produces, and pretty-printing them costs ~3x the tokens.
665
1036
  return toolResponse(JSON.stringify(payload), payload);
666
- },
667
- );
668
-
669
- this.server.registerTool(
1037
+ },
1038
+ );
1039
+
1040
+ this.server.registerTool(
1041
+ 'insights.page_queries',
1042
+ {
1043
+ title: 'Find queries for a page',
1044
+ description: 'For one exact page URL, return the Search Console queries that produced impressions for it over a date range. This wraps an exact page dimension filter so agents do not need to construct analytics.query filter groups manually. Exact page matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results; Search Console can still omit anonymized queries.',
1045
+ inputSchema: {
1046
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
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.'),
1048
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1049
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1050
+ search_type: z
1051
+ .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
1052
+ .default('web')
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.'),
1067
+ },
1068
+ outputSchema: PAGE_QUERIES_OUTPUT_SCHEMA,
1069
+ annotations: READ_ONLY_ANNOTATIONS,
1070
+ },
1071
+ async ({ site_url, page_url, start_date, end_date, search_type, row_limit = 100, start_row = 0 }) => {
1072
+ assertDateRange(start_date, end_date);
1073
+ const googleId = this.requireGoogleId();
1074
+ const rateLimitError = await this.rateLimitError(googleId, 'insights.page_queries');
1075
+ if (rateLimitError) return rateLimitError;
1076
+ const accessToken = await this.getAccessToken(googleId);
1077
+ const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
1078
+ const source = await querySearchAnalyticsPaginated(accessToken, site_url, {
1079
+ startDate: start_date,
1080
+ endDate: end_date,
1081
+ dimensions: ['query'],
1082
+ startRow: start_row,
1083
+ type: search_type,
1084
+ dimensionFilterGroups: [
1085
+ {
1086
+ groupType: 'and',
1087
+ filters: [
1088
+ { dimension: 'page', operator: 'equals', expression: page_url },
1089
+ ],
1090
+ },
1091
+ ],
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
+ );
1108
+ const payload = {
1109
+ page: page_url,
1110
+ queries: bounded.items,
1111
+ pagination: paginationMetadata(source),
1112
+ result_page: bounded.resultPage,
1113
+ };
1114
+ return toolResponse(JSON.stringify(payload), payload);
1115
+ },
1116
+ );
1117
+
1118
+ this.server.registerTool(
1119
+ 'insights.query_pages',
1120
+ {
1121
+ title: 'Find pages for a query',
1122
+ description: 'For one exact search query, return the site pages that received impressions for it over a date range. This wraps an exact query dimension filter so agents do not need to construct analytics.query filter groups manually. Exact query matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results and verify which URL Google is surfacing before diagnosing cannibalization or content targeting.',
1123
+ inputSchema: {
1124
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
1125
+ query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
1126
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1127
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1128
+ search_type: z
1129
+ .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
1130
+ .default('web')
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.'),
1145
+ },
1146
+ outputSchema: QUERY_PAGES_OUTPUT_SCHEMA,
1147
+ annotations: READ_ONLY_ANNOTATIONS,
1148
+ },
1149
+ async ({ site_url, query, start_date, end_date, search_type, row_limit = 100, start_row = 0 }) => {
1150
+ assertDateRange(start_date, end_date);
1151
+ const googleId = this.requireGoogleId();
1152
+ const rateLimitError = await this.rateLimitError(googleId, 'insights.query_pages');
1153
+ if (rateLimitError) return rateLimitError;
1154
+ const accessToken = await this.getAccessToken(googleId);
1155
+ const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
1156
+ const source = await querySearchAnalyticsPaginated(accessToken, site_url, {
1157
+ startDate: start_date,
1158
+ endDate: end_date,
1159
+ dimensions: ['page'],
1160
+ startRow: start_row,
1161
+ type: search_type,
1162
+ dimensionFilterGroups: [
1163
+ {
1164
+ groupType: 'and',
1165
+ filters: [
1166
+ { dimension: 'query', operator: 'equals', expression: query },
1167
+ ],
1168
+ },
1169
+ ],
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
+ );
1186
+ const payload = {
1187
+ query,
1188
+ pages: bounded.items,
1189
+ pagination: paginationMetadata(source),
1190
+ result_page: bounded.resultPage,
1191
+ };
1192
+ return toolResponse(JSON.stringify(payload), payload);
1193
+ },
1194
+ );
1195
+
1196
+ if (accessMode === 'readwrite') {
1197
+ this.server.registerTool(
670
1198
  'sites.add',
671
- {
672
- title: 'Add Search Console property',
673
- description: 'Add a new website property to your Google Search Console account. Note: Domain properties require sc-domain prefix (e.g., sc-domain:example.com), URL-prefix properties require full URL (e.g., https://example.com/).',
1199
+ {
1200
+ title: 'Add Search Console property',
1201
+ description: 'Add a new website property to your Google Search Console account. Note: Domain properties require sc-domain prefix (e.g., sc-domain:example.com), URL-prefix properties require full URL (e.g., https://example.com/).',
674
1202
  inputSchema: {
675
1203
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
676
1204
  },
677
1205
  outputSchema: MESSAGE_OUTPUT_SCHEMA,
678
- annotations: WRITE_ANNOTATIONS,
679
- },
680
- async ({ site_url }) => {
681
- const googleId = this.requireGoogleId();
682
- const accessToken = await this.getAccessToken(googleId);
683
- await addSite(accessToken, site_url);
1206
+ annotations: WRITE_TOOL_ANNOTATIONS['sites.add'],
1207
+ },
1208
+ async ({ site_url }) => {
1209
+ const googleId = this.requireGoogleId();
1210
+ const rateLimitError = await this.rateLimitError(googleId, 'sites.add');
1211
+ if (rateLimitError) return rateLimitError;
1212
+ const accessToken = await this.getAccessToken(googleId);
1213
+ await addSite(accessToken, site_url);
684
1214
  const message = `Successfully added site property: ${site_url}`;
685
1215
  return toolResponse(message, { message });
686
- },
687
- );
688
-
689
- this.server.registerTool(
1216
+ },
1217
+ );
1218
+
1219
+ this.server.registerTool(
690
1220
  'sites.delete',
691
- {
692
- title: 'Delete Search Console property',
693
- description: 'Remove an existing website property from your Google Search Console account.',
1221
+ {
1222
+ title: 'Delete Search Console property',
1223
+ description: 'Remove an existing website property from your Google Search Console account.',
694
1224
  inputSchema: {
695
1225
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
696
1226
  },
697
1227
  outputSchema: MESSAGE_OUTPUT_SCHEMA,
698
- annotations: WRITE_ANNOTATIONS,
699
- },
700
- async ({ site_url }) => {
701
- const googleId = this.requireGoogleId();
702
- const accessToken = await this.getAccessToken(googleId);
703
- await deleteSite(accessToken, site_url);
1228
+ annotations: WRITE_TOOL_ANNOTATIONS['sites.delete'],
1229
+ },
1230
+ async ({ site_url }) => {
1231
+ const googleId = this.requireGoogleId();
1232
+ const rateLimitError = await this.rateLimitError(googleId, 'sites.delete');
1233
+ if (rateLimitError) return rateLimitError;
1234
+ const accessToken = await this.getAccessToken(googleId);
1235
+ await deleteSite(accessToken, site_url);
704
1236
  const message = `Successfully deleted site property: ${site_url}`;
705
1237
  return toolResponse(message, { message });
706
- },
707
- );
708
-
709
- this.server.registerTool(
1238
+ },
1239
+ );
1240
+
1241
+ this.server.registerTool(
710
1242
  'sitemaps.submit',
711
- {
712
- title: 'Submit sitemap',
713
- description: 'Submit a new sitemap to your Google Search Console account.',
1243
+ {
1244
+ title: 'Submit sitemap',
1245
+ description: 'Submit a new sitemap to your Google Search Console account.',
714
1246
  inputSchema: {
715
1247
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
716
1248
  feedpath: z.string().describe('The full URL of the sitemap file to submit, e.g. https://example.com/sitemap.xml'),
717
1249
  },
718
1250
  outputSchema: MESSAGE_OUTPUT_SCHEMA,
719
- annotations: WRITE_ANNOTATIONS,
720
- },
721
- async ({ site_url, feedpath }) => {
722
- const googleId = this.requireGoogleId();
723
- const accessToken = await this.getAccessToken(googleId);
724
- await submitSitemap(accessToken, site_url, feedpath);
1251
+ annotations: WRITE_TOOL_ANNOTATIONS['sitemaps.submit'],
1252
+ },
1253
+ async ({ site_url, feedpath }) => {
1254
+ const googleId = this.requireGoogleId();
1255
+ const rateLimitError = await this.rateLimitError(googleId, 'sitemaps.submit');
1256
+ if (rateLimitError) return rateLimitError;
1257
+ const accessToken = await this.getAccessToken(googleId);
1258
+ await submitSitemap(accessToken, site_url, feedpath);
725
1259
  const message = `Successfully submitted sitemap: ${feedpath} for site: ${site_url}`;
726
1260
  return toolResponse(message, { message });
727
- },
728
- );
729
-
730
- this.server.registerTool(
1261
+ },
1262
+ );
1263
+
1264
+ this.server.registerTool(
731
1265
  'sitemaps.delete',
732
- {
733
- title: 'Delete sitemap',
734
- description: 'Remove/delete a submitted sitemap from your Google Search Console account.',
1266
+ {
1267
+ title: 'Delete sitemap',
1268
+ description: 'Remove/delete a submitted sitemap from your Google Search Console account.',
735
1269
  inputSchema: {
736
1270
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
737
1271
  feedpath: z.string().describe('The full URL of the sitemap file to delete, e.g. https://example.com/sitemap.xml'),
738
1272
  },
739
1273
  outputSchema: MESSAGE_OUTPUT_SCHEMA,
740
- annotations: WRITE_ANNOTATIONS,
741
- },
742
- async ({ site_url, feedpath }) => {
743
- const googleId = this.requireGoogleId();
744
- const accessToken = await this.getAccessToken(googleId);
745
- await deleteSitemap(accessToken, site_url, feedpath);
1274
+ annotations: WRITE_TOOL_ANNOTATIONS['sitemaps.delete'],
1275
+ },
1276
+ async ({ site_url, feedpath }) => {
1277
+ const googleId = this.requireGoogleId();
1278
+ const rateLimitError = await this.rateLimitError(googleId, 'sitemaps.delete');
1279
+ if (rateLimitError) return rateLimitError;
1280
+ const accessToken = await this.getAccessToken(googleId);
1281
+ await deleteSitemap(accessToken, site_url, feedpath);
746
1282
  const message = `Successfully deleted sitemap: ${feedpath} for site: ${site_url}`;
747
1283
  return toolResponse(message, { message });
748
- },
749
- );
750
-
751
- this.server.registerTool(
1284
+ },
1285
+ );
1286
+ }
1287
+
1288
+ this.server.registerTool(
752
1289
  'sitemaps.get',
753
- {
754
- title: 'Get sitemap details',
755
- description: 'Get status and details of a single sitemap submitted to Google Search Console.',
1290
+ {
1291
+ title: 'Get sitemap details',
1292
+ description: 'Get status and details of a single sitemap submitted to Google Search Console.',
756
1293
  inputSchema: {
757
1294
  site_url: z.string().describe(SITE_URL_DESCRIPTION),
758
1295
  feedpath: z.string().describe('The full URL of the sitemap file, e.g. https://example.com/sitemap.xml'),
759
1296
  },
760
1297
  outputSchema: { sitemap: SITEMAP_OUTPUT_SCHEMA },
761
1298
  annotations: READ_ONLY_ANNOTATIONS,
762
- },
763
- async ({ site_url, feedpath }) => {
764
- const googleId = this.requireGoogleId();
765
- const accessToken = await this.getAccessToken(googleId);
766
- const details = await getSitemap(accessToken, site_url, feedpath);
1299
+ },
1300
+ async ({ site_url, feedpath }) => {
1301
+ const googleId = this.requireGoogleId();
1302
+ const accessToken = await this.getAccessToken(googleId);
1303
+ const details = await getSitemap(accessToken, site_url, feedpath);
767
1304
  return toolResponse(JSON.stringify(details, null, 2), { sitemap: details });
768
- },
769
- );
770
-
771
- this.server.registerTool(
1305
+ },
1306
+ );
1307
+
1308
+ this.server.registerTool(
772
1309
  'insights.quick_wins',
773
- {
774
- title: 'Identify SEO Quick Wins',
775
- description: 'Find search queries that rank in positions 8-20 (page 2 / lower page 1) with high impressions but low CTR. These are ideal SEO optimization targets.',
776
- inputSchema: {
777
- site_url: z.string().describe(SITE_URL_DESCRIPTION),
778
- start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Start date (inclusive) in YYYY-MM-DD format.'),
779
- end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
780
- min_impressions: z.number().int().default(100).describe('Minimum impressions required to consider a query. Default is 100.'),
781
- min_position: z.number().default(8).describe('Minimum average position to target (inclusive). Default is 8.'),
782
- max_position: z.number().default(20).describe('Maximum average position to target (inclusive). Default is 20.'),
783
- },
784
- outputSchema: QUICK_WIN_OUTPUT_SCHEMA,
1310
+ {
1311
+ title: 'Identify SEO Quick Wins',
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.',
1313
+ inputSchema: createQuickWinsInputSchema(SITE_URL_DESCRIPTION),
1314
+ outputSchema: z.object(QUICK_WIN_OUTPUT_SCHEMA),
785
1315
  annotations: READ_ONLY_ANNOTATIONS,
786
- },
787
- async ({ site_url, start_date, end_date, min_impressions, min_position, max_position }) => {
788
- const googleId = this.requireGoogleId();
789
- const accessToken = await this.getAccessToken(googleId);
790
- const rows = await querySearchAnalytics(accessToken, site_url, {
791
- startDate: start_date,
792
- endDate: end_date,
793
- dimensions: ['query', 'page'],
794
- rowLimit: 25000,
795
- });
796
-
797
- const quickWins = processQuickWins(rows, min_impressions, min_position, max_position);
798
- return toolResponse(JSON.stringify(quickWins, null, 2), {
799
- quick_wins: quickWins,
1316
+ },
1317
+ async ({ site_url, start_date, end_date, min_impressions, min_position, max_position, limit = DEFAULT_ANALYSIS_RESULT_LIMIT, start_row = 0 }) => {
1318
+ assertDateRange(start_date, end_date);
1319
+ const googleId = this.requireGoogleId();
1320
+ const rateLimitError = await this.rateLimitError(googleId, 'insights.quick_wins');
1321
+ if (rateLimitError) return rateLimitError;
1322
+ const accessToken = await this.getAccessToken(googleId);
1323
+ const source = await querySearchAnalyticsPaginated(accessToken, site_url, {
1324
+ startDate: start_date,
1325
+ endDate: end_date,
1326
+ dimensions: ['query', 'page'],
800
1327
  });
801
- },
802
- );
803
-
804
- this.server.registerTool(
1328
+
1329
+ const quickWins = processQuickWins(source.rows, min_impressions, min_position, max_position);
1330
+ const bounded = windowKnownResults(quickWins, start_row, limit);
1331
+ const payload = {
1332
+ quick_wins: bounded.items,
1333
+ pagination: paginationMetadata(source),
1334
+ result_page: bounded.resultPage,
1335
+ };
1336
+ return toolResponse(JSON.stringify(payload), payload);
1337
+ },
1338
+ );
1339
+
1340
+ this.server.registerTool(
805
1341
  'insights.cannibalization',
806
- {
807
- title: 'Detect Keyword Cannibalization',
808
- description: 'Analyze search analytics to detect instances of keyword cannibalization, where multiple pages on your site compete for the same query.',
809
- inputSchema: {
810
- site_url: z.string().describe(SITE_URL_DESCRIPTION),
811
- start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Start date (inclusive) in YYYY-MM-DD format.'),
812
- end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
813
- min_impressions: z.number().int().default(50).describe('Minimum impressions for a page-query pair to be considered. Default is 50.'),
814
- min_page_percentage: z.number().default(10).describe('Minimum percentage of total query impressions a page must have to count as a cannibalizing page. Default is 10%.'),
815
- },
1342
+ {
1343
+ title: 'Detect Keyword Cannibalization',
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.',
1345
+ inputSchema: {
1346
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
1347
+ start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1348
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
1349
+ min_impressions: CANNIBALIZATION_MIN_IMPRESSIONS_SCHEMA,
1350
+ min_page_percentage: CANNIBALIZATION_MIN_PAGE_PERCENTAGE_SCHEMA,
1351
+ limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
1352
+ start_row: RESULT_START_ROW_SCHEMA,
1353
+ },
816
1354
  outputSchema: CANNIBALIZATION_OUTPUT_SCHEMA,
817
1355
  annotations: READ_ONLY_ANNOTATIONS,
818
- },
819
- async ({ site_url, start_date, end_date, min_impressions, min_page_percentage }) => {
820
- const googleId = this.requireGoogleId();
821
- const accessToken = await this.getAccessToken(googleId);
822
- const rows = await querySearchAnalytics(accessToken, site_url, {
823
- startDate: start_date,
824
- endDate: end_date,
825
- dimensions: ['query', 'page'],
826
- rowLimit: 25000,
827
- });
828
-
829
- const cannibalizationCandidates = processCannibalization(rows, min_impressions, min_page_percentage);
830
- return toolResponse(JSON.stringify(cannibalizationCandidates, null, 2), {
831
- candidates: cannibalizationCandidates,
1356
+ },
1357
+ async ({ site_url, start_date, end_date, min_impressions, min_page_percentage, limit = DEFAULT_ANALYSIS_RESULT_LIMIT, start_row = 0 }) => {
1358
+ assertDateRange(start_date, end_date);
1359
+ const googleId = this.requireGoogleId();
1360
+ const rateLimitError = await this.rateLimitError(googleId, 'insights.cannibalization');
1361
+ if (rateLimitError) return rateLimitError;
1362
+ const accessToken = await this.getAccessToken(googleId);
1363
+ const source = await querySearchAnalyticsPaginated(accessToken, site_url, {
1364
+ startDate: start_date,
1365
+ endDate: end_date,
1366
+ dimensions: ['query', 'page'],
832
1367
  });
833
- },
834
- );
835
-
836
- this.server.registerTool(
1368
+
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
+ );
1389
+ const payload = {
1390
+ candidates: bounded.items,
1391
+ pagination: paginationMetadata(source),
1392
+ result_page: bounded.resultPage,
1393
+ };
1394
+ return toolResponse(JSON.stringify(payload), payload);
1395
+ },
1396
+ );
1397
+
1398
+ this.server.registerTool(
837
1399
  'insights.content_decay',
838
- {
839
- title: 'Detect Content Decay',
840
- 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.',
841
- inputSchema: {
842
- site_url: z.string().describe(SITE_URL_DESCRIPTION),
843
- compare_days: z.number().int().default(30).describe('Number of days to compare (recent period vs previous period). Default is 30.'),
844
- },
1400
+ {
1401
+ title: 'Detect Content Decay',
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.',
1403
+ inputSchema: {
1404
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
1405
+ compare_days: CONTENT_DECAY_COMPARE_DAYS_SCHEMA,
1406
+ limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
1407
+ start_row: RESULT_START_ROW_SCHEMA,
1408
+ },
845
1409
  outputSchema: CONTENT_DECAY_OUTPUT_SCHEMA,
846
1410
  annotations: READ_ONLY_ANNOTATIONS,
847
- },
848
- async ({ site_url, compare_days }) => {
849
- const googleId = this.requireGoogleId();
850
- const accessToken = await this.getAccessToken(googleId);
851
-
852
- const formatDate = (d: Date) => d.toISOString().split('T')[0];
853
-
854
- const now = new Date();
855
- const endRecentDate = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
856
- const startRecentDate = new Date(endRecentDate.getTime() - (compare_days - 1) * 24 * 60 * 60 * 1000);
857
-
858
- const endPreviousDate = new Date(startRecentDate.getTime() - 24 * 60 * 60 * 1000);
859
- const startPreviousDate = new Date(endPreviousDate.getTime() - (compare_days - 1) * 24 * 60 * 60 * 1000);
860
-
861
- const recentStart = formatDate(startRecentDate);
862
- const recentEnd = formatDate(endRecentDate);
863
- const previousStart = formatDate(startPreviousDate);
864
- const previousEnd = formatDate(endPreviousDate);
865
-
866
- const recentRows = await querySearchAnalytics(accessToken, site_url, {
867
- startDate: recentStart,
868
- endDate: recentEnd,
869
- dimensions: ['page'],
870
- rowLimit: 25000,
871
- });
872
-
873
- const previousRows = await querySearchAnalytics(accessToken, site_url, {
874
- startDate: previousStart,
875
- endDate: previousEnd,
876
- dimensions: ['page'],
877
- rowLimit: 25000,
878
- });
879
-
880
- const decayResults = processContentDecay(recentRows, previousRows);
881
-
1411
+ },
1412
+ async ({ site_url, compare_days, limit = DEFAULT_ANALYSIS_RESULT_LIMIT, start_row = 0 }) => {
1413
+ const googleId = this.requireGoogleId();
1414
+ const rateLimitError = await this.rateLimitError(googleId, 'insights.content_decay');
1415
+ if (rateLimitError) return rateLimitError;
1416
+ const accessToken = await this.getAccessToken(googleId);
1417
+
1418
+ const formatDate = (d: Date) => d.toISOString().split('T')[0];
1419
+
1420
+ const today = getSearchConsoleCalendarDate();
1421
+ const endRecentDate = new Date(`${today}T00:00:00Z`);
1422
+ endRecentDate.setUTCDate(endRecentDate.getUTCDate() - 3);
1423
+ const startRecentDate = new Date(endRecentDate.getTime() - (compare_days - 1) * 24 * 60 * 60 * 1000);
1424
+
1425
+ const endPreviousDate = new Date(startRecentDate.getTime() - 24 * 60 * 60 * 1000);
1426
+ const startPreviousDate = new Date(endPreviousDate.getTime() - (compare_days - 1) * 24 * 60 * 60 * 1000);
1427
+
1428
+ const recentStart = formatDate(startRecentDate);
1429
+ const recentEnd = formatDate(endRecentDate);
1430
+ const previousStart = formatDate(startPreviousDate);
1431
+ const previousEnd = formatDate(endPreviousDate);
1432
+
1433
+ const recentSource = await querySearchAnalyticsPaginated(accessToken, site_url, {
1434
+ startDate: recentStart,
1435
+ endDate: recentEnd,
1436
+ dimensions: ['page'],
1437
+ });
1438
+
1439
+ const previousSource = await querySearchAnalyticsPaginated(accessToken, site_url, {
1440
+ startDate: previousStart,
1441
+ endDate: previousEnd,
1442
+ dimensions: ['page'],
1443
+ });
1444
+
1445
+ const decayResults = processContentDecay(
1446
+ recentSource.rows,
1447
+ previousSource.rows,
1448
+ );
1449
+ const bounded = windowKnownResults(decayResults, start_row, limit);
1450
+
882
1451
  const payload = {
883
1452
  comparison_periods: {
884
1453
  recent: { start: recentStart, end: recentEnd },
885
1454
  previous: { start: previousStart, end: previousEnd },
886
1455
  },
887
- decay_count: decayResults.length,
888
- 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,
1461
+ pagination: {
1462
+ recent: paginationMetadata(recentSource),
1463
+ previous: paginationMetadata(previousSource),
1464
+ },
1465
+ result_page: bounded.resultPage,
889
1466
  };
890
- return toolResponse(JSON.stringify(payload, null, 2), payload);
891
- },
892
- );
893
-
894
- this.server.registerTool(
1467
+ return toolResponse(JSON.stringify(payload), payload);
1468
+ },
1469
+ );
1470
+
1471
+ if (accessMode === 'readwrite') {
1472
+ this.server.registerTool(
895
1473
  'indexing.request',
896
- {
897
- title: 'Request Indexing',
898
- description: 'Request Google to index or update a URL using the Google Indexing API. Note: The Indexing API must be enabled in your Google Cloud Project, and typically is officially supported for pages containing JobPosting or BroadcastEvent markup.',
899
- inputSchema: {
900
- url: z.string().describe('The URL to submit for indexing/reindexing. Must belong to your property.'),
901
- },
1474
+ {
1475
+ title: 'Request Indexing',
1476
+ description: "Requests indexing through Google's Indexing API for a URL within an owner-level Search Console property accessible to the connected Google account. Google currently restricts this API to pages containing JobPosting structured data or livestream pages containing BroadcastEvent inside VideoObject. It is not available for general webpage submission.",
1477
+ inputSchema: {
1478
+ site_url: z.string().describe(`${SITE_URL_DESCRIPTION} The connected Google account must be an owner of this property.`),
1479
+ url: z.string().superRefine((value, ctx) => {
1480
+ try {
1481
+ assertIndexingRequestUrl(value);
1482
+ } catch (error) {
1483
+ ctx.addIssue({
1484
+ code: 'custom',
1485
+ message: (error as Error).message,
1486
+ });
1487
+ }
1488
+ }).describe('The fully qualified HTTP/HTTPS URL to submit. It must fall within site_url and contain JobPosting structured data, or be a livestream page with BroadcastEvent inside VideoObject.'),
1489
+ },
902
1490
  outputSchema: INDEXING_OUTPUT_SCHEMA,
903
- annotations: WRITE_ANNOTATIONS,
904
- },
905
- async ({ url }) => {
906
- const googleId = this.requireGoogleId();
907
- const accessToken = await this.getAccessToken(googleId);
908
- const result = await requestIndexing(accessToken, url);
909
- return toolResponse(JSON.stringify(result, null, 2), { result });
910
- },
911
- );
912
-
913
- this.server.registerTool(
1491
+ annotations: WRITE_TOOL_ANNOTATIONS['indexing.request'],
1492
+ },
1493
+ async ({ site_url, url }) => {
1494
+ const googleId = this.requireGoogleId();
1495
+ const rateLimitError = await this.rateLimitError(googleId, 'indexing.request');
1496
+ if (rateLimitError) return rateLimitError;
1497
+ const accessToken = await this.getAccessToken(googleId);
1498
+ const sites = await listSites(accessToken);
1499
+ assertIndexingUrlAuthorized(url, site_url, sites);
1500
+ const result = await requestIndexing(accessToken, url);
1501
+ const payload = {
1502
+ result,
1503
+ note: "Google accepting this notification does not guarantee the URL will be indexed. Indexing remains at Google's discretion.",
1504
+ };
1505
+ return toolResponse(JSON.stringify(payload, null, 2), payload);
1506
+ },
1507
+ );
1508
+ }
1509
+
1510
+ this.server.registerTool(
914
1511
  'indexing.list_pages',
915
- {
916
- title: 'List Indexed Pages',
917
- description: 'Retrieve a list of site pages that have received search impressions, serving as a proxy list of indexed pages on the site.',
918
- inputSchema: {
919
- site_url: z.string().describe(SITE_URL_DESCRIPTION),
920
- start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (inclusive) in YYYY-MM-DD format. Defaults to 30 days ago.'),
921
- end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (inclusive) in YYYY-MM-DD format. Defaults to 3 days ago. Note the 2-3 day data lag.'),
922
- row_limit: z.number().int().min(1).max(25000).default(1000).describe('Maximum pages to retrieve (1-25000). Default is 1000.'),
923
- },
1512
+ {
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.`,
1515
+ inputSchema: {
1516
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
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.'),
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.'),
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.'),
1521
+ },
924
1522
  outputSchema: INDEXED_PAGES_OUTPUT_SCHEMA,
925
1523
  annotations: READ_ONLY_ANNOTATIONS,
926
- },
927
- async ({ site_url, start_date, end_date, row_limit }) => {
928
- const googleId = this.requireGoogleId();
929
- const accessToken = await this.getAccessToken(googleId);
930
-
931
- let start = start_date;
932
- let end = end_date;
933
- if (!start || !end) {
934
- const formatDate = (d: Date) => d.toISOString().split('T')[0];
935
- const now = new Date();
936
- const endRecentDate = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
937
- const startRecentDate = new Date(endRecentDate.getTime() - 29 * 24 * 60 * 60 * 1000);
938
- if (!start) start = formatDate(startRecentDate);
939
- if (!end) end = formatDate(endRecentDate);
940
- }
941
-
942
- const rows = await querySearchAnalytics(accessToken, site_url, {
943
- startDate: start,
944
- endDate: end,
945
- dimensions: ['page'],
946
- rowLimit: row_limit,
947
- });
948
-
949
- const pages = rows.map((row) => ({
950
- page: row.keys[0],
951
- clicks: row.clicks,
952
- impressions: row.impressions,
953
- ctr: row.ctr,
954
- position: row.position,
955
- }));
956
-
957
- return toolResponse(JSON.stringify(pages, null, 2), { pages });
958
- },
959
- );
960
-
961
- this.server.registerTool(
1524
+ },
1525
+ async ({ site_url, start_date, end_date, row_limit, start_row = 0 }) => {
1526
+ const { startDate, endDate } = resolveIndexedPagesDateRange(
1527
+ start_date,
1528
+ end_date,
1529
+ getSearchConsoleCalendarDate(),
1530
+ );
1531
+ const googleId = this.requireGoogleId();
1532
+ const rateLimitError = await this.rateLimitError(googleId, 'indexing.list_pages');
1533
+ if (rateLimitError) return rateLimitError;
1534
+ const accessToken = await this.getAccessToken(googleId);
1535
+
1536
+ const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
1537
+ const response = await querySearchAnalytics(accessToken, site_url, {
1538
+ startDate,
1539
+ endDate,
1540
+ dimensions: ['page'],
1541
+ rowLimit: sourceRowLimit,
1542
+ startRow: start_row,
1543
+ });
1544
+
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
+ );
1560
+
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);
1567
+ },
1568
+ );
1569
+
1570
+ this.server.registerTool(
962
1571
  'analytics.compare',
963
- {
964
- title: 'Compare Performance Between Periods',
965
- 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).',
966
- inputSchema: {
967
- site_url: z.string().describe(SITE_URL_DESCRIPTION),
968
- start_date_a: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Start date of Period A (recent, YYYY-MM-DD)'),
969
- end_date_a: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('End date of Period A (recent, YYYY-MM-DD)'),
970
- start_date_b: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Start date of Period B (previous, YYYY-MM-DD)'),
971
- end_date_b: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('End date of Period B (previous, YYYY-MM-DD)'),
972
- dimension: z.enum(['query', 'page', 'country', 'device']).default('query').describe('The dimension to compare performance for. Defaults to query.'),
973
- },
1572
+ {
1573
+ title: 'Compare Performance Between Periods',
1574
+ description: 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension (query, page, country, device). Apply the same search type and optional dimension filters to both periods. Results are ranked by largest absolute click change, then impression change, and safely paged with limit/start_row. Percentage change is null when the baseline is zero and the comparison value differs.',
1575
+ inputSchema: {
1576
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
1577
+ start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
1578
+ end_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period A (recent, YYYY-MM-DD)'),
1579
+ start_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period B (previous, YYYY-MM-DD)'),
1580
+ end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period B (previous, YYYY-MM-DD)'),
1581
+ dimension: z.enum(['query', 'page', 'country', 'device']).default('query').describe('The dimension to compare performance for. Defaults to query.'),
1582
+ search_type: z
1583
+ .enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
1584
+ .default('web')
1585
+ .describe('Which Search Console search index to compare. The same search type is used for both periods.'),
1586
+ dimension_filter_groups: z
1587
+ .array(
1588
+ z.object({
1589
+ groupType: z.literal('and').default('and'),
1590
+ filters: z.array(
1591
+ z.object({
1592
+ dimension: z.enum([
1593
+ 'query',
1594
+ 'page',
1595
+ 'country',
1596
+ 'device',
1597
+ 'searchAppearance',
1598
+ ]),
1599
+ operator: z.enum([
1600
+ 'equals',
1601
+ 'notEquals',
1602
+ 'contains',
1603
+ 'notContains',
1604
+ 'includingRegex',
1605
+ 'excludingRegex',
1606
+ ]),
1607
+ expression: z.string(),
1608
+ }),
1609
+ ),
1610
+ }),
1611
+ )
1612
+ .optional()
1613
+ .describe(
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.",
1615
+ ),
1616
+ limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
1617
+ start_row: RESULT_START_ROW_SCHEMA,
1618
+ },
974
1619
  outputSchema: PERFORMANCE_COMPARISON_OUTPUT_SCHEMA,
975
1620
  annotations: READ_ONLY_ANNOTATIONS,
976
- },
977
- async ({ site_url, start_date_a, end_date_a, start_date_b, end_date_b, dimension }) => {
978
- const googleId = this.requireGoogleId();
979
- const accessToken = await this.getAccessToken(googleId);
980
-
981
- const rowsA = await querySearchAnalytics(accessToken, site_url, {
982
- startDate: start_date_a,
983
- endDate: end_date_a,
984
- dimensions: [dimension],
985
- rowLimit: 25000,
986
- });
987
-
988
- const rowsB = await querySearchAnalytics(accessToken, site_url, {
989
- startDate: start_date_b,
990
- endDate: end_date_b,
991
- dimensions: [dimension],
992
- rowLimit: 25000,
993
- });
994
-
995
- const comparison = processPerformanceComparison(rowsA, rowsB);
996
- return toolResponse(JSON.stringify(comparison, null, 2), {
997
- comparisons: comparison,
1621
+ },
1622
+ async ({
1623
+ site_url,
1624
+ start_date_a,
1625
+ end_date_a,
1626
+ start_date_b,
1627
+ end_date_b,
1628
+ dimension,
1629
+ search_type,
1630
+ dimension_filter_groups,
1631
+ limit = DEFAULT_ANALYSIS_RESULT_LIMIT,
1632
+ start_row = 0,
1633
+ }) => {
1634
+ assertDateRange(start_date_a, end_date_a, 'start_date_a', 'end_date_a');
1635
+ assertDateRange(start_date_b, end_date_b, 'start_date_b', 'end_date_b');
1636
+ const googleId = this.requireGoogleId();
1637
+ const rateLimitError = await this.rateLimitError(googleId, 'analytics.compare');
1638
+ if (rateLimitError) return rateLimitError;
1639
+ const accessToken = await this.getAccessToken(googleId);
1640
+
1641
+ const sourceA = await querySearchAnalyticsPaginated(accessToken, site_url, {
1642
+ startDate: start_date_a,
1643
+ endDate: end_date_a,
1644
+ dimensions: [dimension],
1645
+ type: search_type,
1646
+ ...(dimension_filter_groups !== undefined
1647
+ ? { dimensionFilterGroups: dimension_filter_groups }
1648
+ : {}),
998
1649
  });
999
- },
1000
- );
1001
-
1002
- this.server.registerTool(
1650
+
1651
+ const sourceB = await querySearchAnalyticsPaginated(accessToken, site_url, {
1652
+ startDate: start_date_b,
1653
+ endDate: end_date_b,
1654
+ dimensions: [dimension],
1655
+ type: search_type,
1656
+ ...(dimension_filter_groups !== undefined
1657
+ ? { dimensionFilterGroups: dimension_filter_groups }
1658
+ : {}),
1659
+ });
1660
+
1661
+ const comparison = processPerformanceComparison(sourceA.rows, sourceB.rows);
1662
+ const bounded = windowKnownResults(comparison, start_row, limit);
1663
+ const payload = {
1664
+ comparisons: bounded.items,
1665
+ pagination: {
1666
+ period_a: paginationMetadata(sourceA),
1667
+ period_b: paginationMetadata(sourceB),
1668
+ },
1669
+ result_page: bounded.resultPage,
1670
+ };
1671
+ return toolResponse(JSON.stringify(payload), payload);
1672
+ },
1673
+ );
1674
+
1675
+ this.server.registerTool(
1003
1676
  'reports.weekly_digest',
1004
- {
1005
- title: 'Weekly GSC Performance Digest',
1006
- description:
1007
- 'Generate a plain-language weekly SEO report for one Google Search Console property. Returns a markdown digest covering the 7 days ending on end_date, with week-over-week comparison, top pages, queries gaining or losing traction, and one specific action item. Defaults end_date to today if omitted.',
1008
- inputSchema: {
1009
- site_url: z.string().describe(SITE_URL_DESCRIPTION),
1010
- end_date: z
1011
- .string()
1012
- .optional()
1013
- .describe('End date (inclusive) in YYYY-MM-DD format. Defaults to today.'),
1014
- },
1677
+ {
1678
+ title: 'Weekly GSC Performance Digest',
1679
+ description:
1680
+ 'Generate a plain-language weekly SEO report for one Google Search Console property. Returns a markdown digest covering the 7 days ending on end_date, with week-over-week comparison, top pages, queries gaining or losing traction, and one specific action item. Defaults end_date to 3 days ago so the report uses the latest usually-complete Search Console data; pass end_date explicitly to include fresher preliminary data.',
1681
+ inputSchema: {
1682
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
1683
+ end_date: SEARCH_CONSOLE_DATE_SCHEMA
1684
+ .optional()
1685
+ .describe('End date (inclusive) in YYYY-MM-DD format. Defaults to 3 days ago, which is usually the latest complete Search Console date. Pass a more recent date explicitly to include preliminary data.'),
1686
+ },
1015
1687
  outputSchema: WEEKLY_DIGEST_OUTPUT_SCHEMA,
1016
1688
  annotations: READ_ONLY_ANNOTATIONS,
1017
- },
1018
- async ({ site_url, end_date }) => {
1019
- const googleId = this.requireGoogleId();
1020
- const today = new Date().toISOString().slice(0, 10);
1021
- const resolvedEndDate = end_date ?? today;
1022
- if (resolvedEndDate > today) {
1023
- throw new Error(
1024
- 'End date must be today or earlier. Google Search Console has no data for dates that have not happened yet.',
1025
- );
1026
- }
1027
-
1028
- try {
1029
- const markdown = await generateWeeklyDigest(
1030
- this.env,
1031
- googleId,
1032
- site_url,
1033
- resolvedEndDate,
1034
- );
1689
+ },
1690
+ async ({ site_url, end_date }) => {
1691
+ const googleId = this.requireGoogleId();
1692
+ const rateLimitError = await this.rateLimitError(googleId, 'reports.weekly_digest');
1693
+ if (rateLimitError) return rateLimitError;
1694
+ const today = getSearchConsoleCalendarDate();
1695
+ const resolvedEndDate = resolveWeeklyDigestEndDate(end_date, today);
1696
+ assertDateNotInFuture(resolvedEndDate, today);
1697
+
1698
+ try {
1699
+ const markdown = await generateWeeklyDigest(
1700
+ this.getAccessTokenLifecycle(),
1701
+ googleId,
1702
+ site_url,
1703
+ resolvedEndDate,
1704
+ );
1035
1705
  return toolResponse(markdown, { markdown });
1036
- } catch (err) {
1037
- if (err instanceof GoogleRefreshTokenRevokedError) {
1038
- throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
1039
- }
1040
- throw err;
1041
- }
1042
- },
1043
- );
1044
- }
1045
- }
1046
-
1047
- function googleRedirectUri(request: Request): string {
1048
- return new URL('/google/callback', request.url).toString();
1049
- }
1050
-
1051
- const defaultHandler = {
1706
+ } catch (err) {
1707
+ if (err instanceof GoogleRefreshTokenRevokedError) {
1708
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
1709
+ }
1710
+ throw err;
1711
+ }
1712
+ },
1713
+ );
1714
+ }
1715
+ }
1716
+
1717
+ let sharedAccessTokens: GoogleAccessTokenLifecycle | undefined;
1718
+
1719
+ function getSharedAccessTokenLifecycle(env: Env): GoogleAccessTokenLifecycle {
1720
+ sharedAccessTokens ??= new GoogleAccessTokenLifecycle(env);
1721
+ return sharedAccessTokens;
1722
+ }
1723
+
1724
+ function currentAgentProps(): AgentProps | undefined {
1725
+ const props = getMcpAuthContext()?.props;
1726
+ if (!props) return undefined;
1727
+ if (typeof props.google_id !== 'string' || typeof props.email !== 'string') {
1728
+ return undefined;
1729
+ }
1730
+ return { google_id: props.google_id, email: props.email };
1731
+ }
1732
+
1733
+ export async function createGscMcpServer(
1734
+ env: Env,
1735
+ props: AgentProps | undefined = currentAgentProps(),
1736
+ accessTokens = getSharedAccessTokenLifecycle(env),
1737
+ ): Promise<McpServer> {
1738
+ const runtime = new GscMcpRuntime(env, props, accessTokens);
1739
+ await runtime.init();
1740
+ return runtime.server;
1741
+ }
1742
+
1743
+ export const mcpApiHandler = {
1744
+ fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1745
+ const handler = createMcpHandler(() => createGscMcpServer(env), {
1746
+ route: '/mcp',
1747
+ });
1748
+ return handler(request, env, ctx);
1749
+ },
1750
+ };
1751
+
1752
+ /**
1753
+ * Compatibility shell for the existing Durable Object binding. The MCP
1754
+ * transport no longer routes through this class, but retaining the class and
1755
+ * binding avoids a destructive Durable Object deletion migration.
1756
+ */
1757
+ export class GscMcpAgent extends DurableObject<Env> {
1758
+ fetch(): Response {
1759
+ return new Response('Legacy MCP transport is no longer routed here.', {
1760
+ status: 410,
1761
+ });
1762
+ }
1763
+ }
1764
+
1765
+ /**
1766
+ * OAuth nonces are addressed one Durable Object per nonce. Extending the
1767
+ * platform base class is required for the store/consume methods to be exposed
1768
+ * through Durable Object RPC.
1769
+ */
1770
+ export class PendingAuthState extends DurableObject<Env> {
1771
+ private readonly pending = new PendingAuthStateStore(this.ctx.storage);
1772
+
1773
+ store(claudeAuthRequest: unknown): Promise<void> {
1774
+ return this.pending.store(claudeAuthRequest);
1775
+ }
1776
+
1777
+ consume() {
1778
+ return this.pending.consume();
1779
+ }
1780
+
1781
+ alarm(): Promise<void> {
1782
+ return this.pending.alarm();
1783
+ }
1784
+ }
1785
+
1786
+ function googleRedirectUri(request: Request): string {
1787
+ return new URL('/google/callback', request.url).toString();
1788
+ }
1789
+
1790
+ export const defaultHandler = {
1052
1791
  async fetch(request: Request, env: Env): Promise<Response> {
1053
1792
  const url = new URL(request.url);
1054
1793
 
@@ -1059,144 +1798,159 @@ const defaultHandler = {
1059
1798
  }
1060
1799
 
1061
1800
  if (url.pathname === '/') {
1062
- if (request.method !== 'GET') {
1063
- return new Response('Method Not Allowed', {
1064
- status: 405,
1065
- headers: { Allow: 'GET' },
1066
- });
1067
- }
1068
- return new Response(
1069
- 'mcp-gsc — Hosted MCP server for Google Search Console.\n' +
1070
- 'Connect this URL as a custom MCP connector in Claude.ai:\n' +
1071
- `${url.origin}/mcp\n`,
1072
- { headers: { 'content-type': 'text/plain; charset=utf-8' } },
1073
- );
1074
- }
1075
-
1076
- if (url.pathname === '/healthz' && request.method === 'GET') {
1077
- return new Response('ok', {
1078
- headers: { 'content-type': 'text/plain; charset=utf-8' },
1079
- });
1080
- }
1081
-
1082
- if (url.pathname === '/authorize') {
1083
- let claudeAuthRequest;
1084
- try {
1085
- claudeAuthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
1086
- } catch (err) {
1087
- console.warn('Invalid /authorize request', {
1088
- message: (err as Error).message,
1089
- });
1090
- return new Response(
1091
- 'Invalid OAuth authorization request. This endpoint is called by MCP clients during the connection flow, not directly in a browser.',
1092
- {
1093
- status: 400,
1094
- headers: { 'content-type': 'text/plain; charset=utf-8' },
1095
- },
1096
- );
1097
- }
1098
- const nonce = crypto.randomUUID();
1099
- await stashPendingAuth(env, nonce, claudeAuthRequest);
1100
- const redirectUri = googleRedirectUri(request);
1101
- const googleUrl = buildAuthUrl(env.GOOGLE_CLIENT_ID, redirectUri, nonce);
1102
- return Response.redirect(googleUrl, 302);
1103
- }
1104
-
1105
- if (url.pathname === '/google/callback') {
1106
- const code = url.searchParams.get('code');
1107
- const state = url.searchParams.get('state');
1108
- const googleError = url.searchParams.get('error');
1109
-
1110
- if (googleError) {
1111
- console.error('Google OAuth returned error', { error: googleError });
1112
- return new Response(`Google OAuth error: ${googleError}`, {
1113
- status: 400,
1114
- });
1115
- }
1116
-
1117
- if (!code || !state) {
1118
- console.error('Missing code or state on /google/callback', {
1119
- has_code: !!code,
1120
- has_state: !!state,
1121
- });
1122
- return new Response('Missing code or state', { status: 400 });
1123
- }
1124
-
1125
- const pending = await popPendingAuth(env, state);
1126
- if (!pending) {
1127
- console.error('Pending auth not found or expired', { state });
1128
- return new Response('Auth request expired or invalid', { status: 400 });
1129
- }
1130
-
1131
- const redirectUri = googleRedirectUri(request);
1132
-
1133
- let tokens;
1134
- try {
1135
- tokens = await exchangeCodeForTokens(
1136
- code,
1137
- env.GOOGLE_CLIENT_ID,
1138
- env.GOOGLE_CLIENT_SECRET,
1139
- redirectUri,
1140
- );
1141
- } catch (err) {
1142
- console.error('Token exchange failed', {
1143
- message: (err as Error).message,
1144
- });
1145
- return new Response('Token exchange failed', { status: 500 });
1146
- }
1147
-
1148
- let userinfo;
1149
- try {
1150
- userinfo = await fetchGoogleUserInfo(tokens.access_token);
1151
- } catch (err) {
1152
- console.error('Userinfo fetch failed', {
1153
- message: (err as Error).message,
1154
- });
1155
- return new Response('Userinfo fetch failed', { status: 500 });
1156
- }
1157
-
1158
- try {
1159
- await saveUser(env, userinfo.id, userinfo.email, tokens.refresh_token);
1160
- } catch (err) {
1161
- console.error('Save user failed', {
1162
- google_id: userinfo.id,
1163
- message: (err as Error).message,
1164
- });
1165
- return new Response('Save user failed', { status: 500 });
1166
- }
1167
-
1168
- const claudeAuthReq = pending.claudeAuthRequest as any;
1169
- let redirectTo: string;
1170
- try {
1171
- ({ redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
1172
- request: claudeAuthReq,
1173
- userId: userinfo.id,
1174
- metadata: { email: userinfo.email },
1175
- scope: claudeAuthReq.scope,
1176
- props: { google_id: userinfo.id, email: userinfo.email },
1177
- }));
1178
- } catch (err) {
1179
- console.error('completeAuthorization failed', {
1180
- google_id: userinfo.id,
1181
- message: (err as Error).message,
1182
- });
1183
- return new Response('Failed to complete authorization', {
1184
- status: 500,
1185
- });
1186
- }
1187
- return Response.redirect(redirectTo, 302);
1188
- }
1189
-
1190
- return new Response('Not found', { status: 404 });
1191
- },
1192
- };
1193
-
1194
- export default new OAuthProvider({
1195
- apiHandlers: {
1196
- '/mcp': GscMcpAgent.serve('/mcp', { transport: 'auto' }),
1197
- },
1198
- defaultHandler: defaultHandler as any,
1199
- authorizeEndpoint: '/authorize',
1200
- tokenEndpoint: '/token',
1201
- clientRegistrationEndpoint: '/register',
1202
- });
1801
+ if (request.method !== 'GET') {
1802
+ return new Response('Method Not Allowed', {
1803
+ status: 405,
1804
+ headers: { Allow: 'GET' },
1805
+ });
1806
+ }
1807
+ return new Response(
1808
+ 'mcp-gsc — Hosted MCP server for Google Search Console.\n' +
1809
+ 'Connect this URL as a custom MCP connector in Claude.ai:\n' +
1810
+ `${url.origin}/mcp\n`,
1811
+ { headers: { 'content-type': 'text/plain; charset=utf-8' } },
1812
+ );
1813
+ }
1814
+
1815
+ if (url.pathname === '/healthz' && request.method === 'GET') {
1816
+ return new Response('ok', {
1817
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1818
+ });
1819
+ }
1820
+
1821
+ if (url.pathname === '/authorize') {
1822
+ let claudeAuthRequest;
1823
+ try {
1824
+ claudeAuthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
1825
+ } catch (err) {
1826
+ console.warn('Invalid /authorize request', {
1827
+ message: (err as Error).message,
1828
+ });
1829
+ return new Response(
1830
+ 'Invalid OAuth authorization request. This endpoint is called by MCP clients during the connection flow, not directly in a browser.',
1831
+ {
1832
+ status: 400,
1833
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1834
+ },
1835
+ );
1836
+ }
1837
+ let accessMode: GscAccessMode;
1838
+ try {
1839
+ accessMode = resolveGscAccessMode(env.GSC_ACCESS_MODE);
1840
+ } catch {
1841
+ return new Response(
1842
+ 'Server configuration error: GSC_ACCESS_MODE must be "readonly" or "readwrite".',
1843
+ { status: 500 },
1844
+ );
1845
+ }
1846
+ const nonce = crypto.randomUUID();
1847
+ await stashPendingAuth(env, nonce, claudeAuthRequest);
1848
+ const redirectUri = googleRedirectUri(request);
1849
+ const googleUrl = buildAuthUrl(
1850
+ env.GOOGLE_CLIENT_ID,
1851
+ redirectUri,
1852
+ nonce,
1853
+ accessMode,
1854
+ );
1855
+ return Response.redirect(googleUrl, 302);
1856
+ }
1857
+
1858
+ if (url.pathname === '/google/callback') {
1859
+ const code = url.searchParams.get('code');
1860
+ const state = url.searchParams.get('state');
1861
+ const googleError = url.searchParams.get('error');
1862
+
1863
+ if (googleError) {
1864
+ console.error('Google OAuth returned error', { error: googleError });
1865
+ return new Response(`Google OAuth error: ${googleError}`, {
1866
+ status: 400,
1867
+ });
1868
+ }
1869
+
1870
+ if (!code || !state) {
1871
+ console.error('Missing code or state on /google/callback', {
1872
+ has_code: !!code,
1873
+ has_state: !!state,
1874
+ });
1875
+ return new Response('Missing code or state', { status: 400 });
1876
+ }
1877
+
1878
+ const pending = await consumePendingAuth(env, state);
1879
+ if (!pending) {
1880
+ console.error('Pending auth not found or expired');
1881
+ return new Response('Auth request expired or invalid', { status: 400 });
1882
+ }
1883
+
1884
+ const redirectUri = googleRedirectUri(request);
1885
+
1886
+ let tokens;
1887
+ try {
1888
+ tokens = await exchangeCodeForTokens(
1889
+ code,
1890
+ env.GOOGLE_CLIENT_ID,
1891
+ env.GOOGLE_CLIENT_SECRET,
1892
+ redirectUri,
1893
+ );
1894
+ } catch (err) {
1895
+ console.error('Token exchange failed', {
1896
+ message: (err as Error).message,
1897
+ });
1898
+ return new Response('Token exchange failed', { status: 500 });
1899
+ }
1900
+
1901
+ let userinfo;
1902
+ try {
1903
+ userinfo = await fetchGoogleUserInfo(tokens.access_token);
1904
+ } catch (err) {
1905
+ console.error('Userinfo fetch failed', {
1906
+ message: (err as Error).message,
1907
+ });
1908
+ return new Response('Userinfo fetch failed', { status: 500 });
1909
+ }
1910
+
1911
+ try {
1912
+ await saveUser(env, userinfo.id, userinfo.email, tokens.refresh_token);
1913
+ } catch (err) {
1914
+ console.error('Save user failed', {
1915
+ google_id: userinfo.id,
1916
+ message: (err as Error).message,
1917
+ });
1918
+ return new Response('Save user failed', { status: 500 });
1919
+ }
1920
+
1921
+ const claudeAuthReq = pending.claudeAuthRequest as any;
1922
+ let redirectTo: string;
1923
+ try {
1924
+ ({ redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
1925
+ request: claudeAuthReq,
1926
+ userId: userinfo.id,
1927
+ metadata: { email: userinfo.email },
1928
+ scope: claudeAuthReq.scope,
1929
+ props: { google_id: userinfo.id, email: userinfo.email },
1930
+ }));
1931
+ } catch (err) {
1932
+ console.error('completeAuthorization failed', {
1933
+ google_id: userinfo.id,
1934
+ message: (err as Error).message,
1935
+ });
1936
+ return new Response('Failed to complete authorization', {
1937
+ status: 500,
1938
+ });
1939
+ }
1940
+ return Response.redirect(redirectTo, 302);
1941
+ }
1942
+
1943
+ return new Response('Not found', { status: 404 });
1944
+ },
1945
+ };
1946
+
1947
+ export default new OAuthProvider({
1948
+ apiHandlers: {
1949
+ '/mcp': mcpApiHandler,
1950
+ },
1951
+ defaultHandler: defaultHandler as any,
1952
+ authorizeEndpoint: '/authorize',
1953
+ tokenEndpoint: '/token',
1954
+ clientIdMetadataDocumentEnabled: true,
1955
+ clientRegistrationEndpoint: '/register',
1956
+ });