@digestseo/mcp-gsc 0.3.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 ADDED
@@ -0,0 +1,1202 @@
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
+
68
+ const SITE_URL_DESCRIPTION =
69
+ "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
+
71
+ const METRIC_OUTPUT_SCHEMA = {
72
+ clicks: z.number(),
73
+ impressions: z.number(),
74
+ ctr: z.number(),
75
+ position: z.number(),
76
+ };
77
+
78
+ const SEARCH_ROW_OUTPUT_SCHEMA = {
79
+ keys: z.array(z.string()),
80
+ ...METRIC_OUTPUT_SCHEMA,
81
+ };
82
+
83
+ const SITE_OUTPUT_SCHEMA = {
84
+ siteUrl: z.string(),
85
+ permissionLevel: z.string(),
86
+ };
87
+
88
+ const SITEMAP_OUTPUT_SCHEMA = z
89
+ .object({
90
+ path: z.string(),
91
+ lastSubmitted: z.string().nullable().optional(),
92
+ isPending: z.boolean().nullable().optional(),
93
+ isSitemapsIndex: z.boolean().nullable().optional(),
94
+ type: z.string().nullable().optional(),
95
+ lastDownloaded: z.string().nullable().optional(),
96
+ warnings: z.string().nullable().optional(),
97
+ errors: z.string().nullable().optional(),
98
+ contents: z
99
+ .array(
100
+ z.object({
101
+ type: z.string(),
102
+ submitted: z.string(),
103
+ indexed: z.string(),
104
+ }),
105
+ )
106
+ .nullable()
107
+ .optional(),
108
+ })
109
+ .passthrough();
110
+
111
+ const CAPABILITIES_OUTPUT_SCHEMA = {
112
+ server: z.string(),
113
+ version: z.string(),
114
+ auth_status: z.enum(['connected', 'not_connected', 'unknown']),
115
+ tools: z.array(
116
+ z.object({
117
+ name: z.string(),
118
+ description: z.string(),
119
+ }),
120
+ ),
121
+ hint: z.string(),
122
+ };
123
+
124
+ const SITES_OUTPUT_SCHEMA = {
125
+ sites: z.array(z.object(SITE_OUTPUT_SCHEMA)),
126
+ };
127
+
128
+ const INSPECTION_OUTPUT_SCHEMA = {
129
+ inspection_result: z.unknown(),
130
+ };
131
+
132
+ const SITEMAPS_OUTPUT_SCHEMA = {
133
+ sitemaps: z.array(SITEMAP_OUTPUT_SCHEMA),
134
+ };
135
+
136
+ const SEARCH_ANALYTICS_OUTPUT_SCHEMA = {
137
+ row_count: z.number().int().nonnegative(),
138
+ start_row: z.number().int().nonnegative(),
139
+ rows: z.array(z.object(SEARCH_ROW_OUTPUT_SCHEMA)),
140
+ next_start_row: z.number().int().nonnegative().optional(),
141
+ };
142
+
143
+ const MESSAGE_OUTPUT_SCHEMA = {
144
+ message: z.string(),
145
+ };
146
+
147
+ const QUICK_WIN_OUTPUT_SCHEMA = {
148
+ quick_wins: z.array(
149
+ z.object({
150
+ query: z.string(),
151
+ page: z.string(),
152
+ ...METRIC_OUTPUT_SCHEMA,
153
+ }),
154
+ ),
155
+ };
156
+
157
+ const CANNIBALIZATION_OUTPUT_SCHEMA = {
158
+ candidates: z.array(
159
+ z.object({
160
+ query: z.string(),
161
+ total_clicks: z.number(),
162
+ total_impressions: z.number(),
163
+ pages: z.array(
164
+ z.object({
165
+ page: z.string(),
166
+ ...METRIC_OUTPUT_SCHEMA,
167
+ impression_share: z.number(),
168
+ }),
169
+ ),
170
+ }),
171
+ ),
172
+ };
173
+
174
+ const CONTENT_DECAY_OUTPUT_SCHEMA = {
175
+ comparison_periods: z.object({
176
+ recent: z.object({ start: z.string(), end: z.string() }),
177
+ previous: z.object({ start: z.string(), end: z.string() }),
178
+ }),
179
+ decay_count: z.number().int().nonnegative(),
180
+ decay_results: z.array(
181
+ z.object({
182
+ page: z.string(),
183
+ previous_clicks: z.number(),
184
+ recent_clicks: z.number(),
185
+ click_difference: z.number(),
186
+ click_decay_percentage: z.number(),
187
+ previous_impressions: z.number(),
188
+ recent_impressions: z.number(),
189
+ impression_difference: z.number(),
190
+ previous_position: z.number(),
191
+ recent_position: z.number(),
192
+ }),
193
+ ),
194
+ };
195
+
196
+ const INDEXING_OUTPUT_SCHEMA = {
197
+ result: z.unknown(),
198
+ };
199
+
200
+ const INDEXED_PAGES_OUTPUT_SCHEMA = {
201
+ pages: z.array(z.object({ page: z.string(), ...METRIC_OUTPUT_SCHEMA })),
202
+ };
203
+
204
+ const PERFORMANCE_COMPARISON_OUTPUT_SCHEMA = {
205
+ comparisons: z.array(
206
+ z.object({
207
+ key: z.string(),
208
+ period_a: z.object(METRIC_OUTPUT_SCHEMA),
209
+ period_b: z.object(METRIC_OUTPUT_SCHEMA),
210
+ diff: z.object({
211
+ clicks: z.number(),
212
+ clicks_percentage: z.number(),
213
+ impressions: z.number(),
214
+ impressions_percentage: z.number(),
215
+ ctr: z.number(),
216
+ position: z.number(),
217
+ }),
218
+ }),
219
+ ),
220
+ };
221
+
222
+ const WEEKLY_DIGEST_OUTPUT_SCHEMA = {
223
+ markdown: z.string(),
224
+ };
225
+
226
+ function toolResponse<T extends Record<string, unknown>>(
227
+ text: string,
228
+ structuredContent: T,
229
+ ) {
230
+ return {
231
+ content: [{ type: 'text' as const, text }],
232
+ structuredContent,
233
+ };
234
+ }
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
+ {
240
+ name: 'sites.list',
241
+ description:
242
+ 'List the Google Search Console properties (sites) the connected Google account can access.',
243
+ },
244
+ {
245
+ name: 'sites.add',
246
+ description:
247
+ 'Add a new website property to your Google Search Console account.',
248
+ },
249
+ {
250
+ name: 'sites.delete',
251
+ description:
252
+ 'Remove an existing website property from your Google Search Console account.',
253
+ },
254
+ {
255
+ 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
+ {
260
+ 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
+ {
265
+ name: 'sitemaps.list',
266
+ description:
267
+ 'List all sitemaps submitted for a property, with submission/processing status and warning and error counts.',
268
+ },
269
+ {
270
+ name: 'sitemaps.submit',
271
+ description:
272
+ 'Submit a new sitemap to your Google Search Console account.',
273
+ },
274
+ {
275
+ name: 'sitemaps.delete',
276
+ description:
277
+ 'Remove/delete a submitted sitemap from your Google Search Console account.',
278
+ },
279
+ {
280
+ name: 'sitemaps.get',
281
+ description:
282
+ 'Get status and details of a single sitemap submitted to Google Search Console.',
283
+ },
284
+ {
285
+ 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
+ {
290
+ 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
+ {
295
+ 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
+ {
300
+ name: 'indexing.request',
301
+ description:
302
+ 'Request Google to index or update a URL using the Google Indexing API.',
303
+ },
304
+ {
305
+ 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
+ {
310
+ 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
+ {
315
+ name: 'reports.weekly_digest',
316
+ description:
317
+ 'Generate a plain-language weekly SEO report for one Google Search Console property.',
318
+ },
319
+ {
320
+ 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(
382
+ 'server.capabilities',
383
+ {
384
+ title: 'Get server capabilities and auth status',
385
+ 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.",
387
+ inputSchema: {},
388
+ outputSchema: CAPABILITIES_OUTPUT_SCHEMA,
389
+ 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
+ };
414
+ return toolResponse(JSON.stringify(capabilities, null, 2), capabilities);
415
+ },
416
+ );
417
+
418
+ this.server.registerTool(
419
+ 'sites.list',
420
+ {
421
+ title: 'List Search Console properties',
422
+ description:
423
+ "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
+ inputSchema: {},
425
+ outputSchema: SITES_OUTPUT_SCHEMA,
426
+ 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);
432
+ return toolResponse(JSON.stringify(sites, null, 2), { sites });
433
+ },
434
+ );
435
+
436
+ this.server.registerTool(
437
+ '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.`,
441
+ 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'.",
453
+ ),
454
+ },
455
+ outputSchema: INSPECTION_OUTPUT_SCHEMA,
456
+ 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
+ );
467
+ return toolResponse(JSON.stringify(result, null, 2), {
468
+ inspection_result: result,
469
+ });
470
+ },
471
+ );
472
+
473
+ this.server.registerTool(
474
+ '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.',
479
+ inputSchema: {
480
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
481
+ },
482
+ outputSchema: SITEMAPS_OUTPUT_SCHEMA,
483
+ 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);
489
+ return toolResponse(JSON.stringify(sitemaps, null, 2), { sitemaps });
490
+ },
491
+ );
492
+
493
+ this.server.registerTool(
494
+ '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
+ ),
624
+ },
625
+ outputSchema: SEARCH_ANALYTICS_OUTPUT_SCHEMA,
626
+ 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.
665
+ return toolResponse(JSON.stringify(payload), payload);
666
+ },
667
+ );
668
+
669
+ this.server.registerTool(
670
+ '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/).',
674
+ inputSchema: {
675
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
676
+ },
677
+ 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);
684
+ const message = `Successfully added site property: ${site_url}`;
685
+ return toolResponse(message, { message });
686
+ },
687
+ );
688
+
689
+ this.server.registerTool(
690
+ 'sites.delete',
691
+ {
692
+ title: 'Delete Search Console property',
693
+ description: 'Remove an existing website property from your Google Search Console account.',
694
+ inputSchema: {
695
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
696
+ },
697
+ 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);
704
+ const message = `Successfully deleted site property: ${site_url}`;
705
+ return toolResponse(message, { message });
706
+ },
707
+ );
708
+
709
+ this.server.registerTool(
710
+ 'sitemaps.submit',
711
+ {
712
+ title: 'Submit sitemap',
713
+ description: 'Submit a new sitemap to your Google Search Console account.',
714
+ inputSchema: {
715
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
716
+ feedpath: z.string().describe('The full URL of the sitemap file to submit, e.g. https://example.com/sitemap.xml'),
717
+ },
718
+ 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);
725
+ const message = `Successfully submitted sitemap: ${feedpath} for site: ${site_url}`;
726
+ return toolResponse(message, { message });
727
+ },
728
+ );
729
+
730
+ this.server.registerTool(
731
+ 'sitemaps.delete',
732
+ {
733
+ title: 'Delete sitemap',
734
+ description: 'Remove/delete a submitted sitemap from your Google Search Console account.',
735
+ inputSchema: {
736
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
737
+ feedpath: z.string().describe('The full URL of the sitemap file to delete, e.g. https://example.com/sitemap.xml'),
738
+ },
739
+ 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);
746
+ const message = `Successfully deleted sitemap: ${feedpath} for site: ${site_url}`;
747
+ return toolResponse(message, { message });
748
+ },
749
+ );
750
+
751
+ this.server.registerTool(
752
+ 'sitemaps.get',
753
+ {
754
+ title: 'Get sitemap details',
755
+ description: 'Get status and details of a single sitemap submitted to Google Search Console.',
756
+ inputSchema: {
757
+ site_url: z.string().describe(SITE_URL_DESCRIPTION),
758
+ feedpath: z.string().describe('The full URL of the sitemap file, e.g. https://example.com/sitemap.xml'),
759
+ },
760
+ outputSchema: { sitemap: SITEMAP_OUTPUT_SCHEMA },
761
+ 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);
767
+ return toolResponse(JSON.stringify(details, null, 2), { sitemap: details });
768
+ },
769
+ );
770
+
771
+ this.server.registerTool(
772
+ '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,
785
+ 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,
800
+ });
801
+ },
802
+ );
803
+
804
+ this.server.registerTool(
805
+ '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
+ },
816
+ outputSchema: CANNIBALIZATION_OUTPUT_SCHEMA,
817
+ 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,
832
+ });
833
+ },
834
+ );
835
+
836
+ this.server.registerTool(
837
+ '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
+ },
845
+ outputSchema: CONTENT_DECAY_OUTPUT_SCHEMA,
846
+ 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
+
882
+ const payload = {
883
+ comparison_periods: {
884
+ recent: { start: recentStart, end: recentEnd },
885
+ previous: { start: previousStart, end: previousEnd },
886
+ },
887
+ decay_count: decayResults.length,
888
+ decay_results: decayResults,
889
+ };
890
+ return toolResponse(JSON.stringify(payload, null, 2), payload);
891
+ },
892
+ );
893
+
894
+ this.server.registerTool(
895
+ '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
+ },
902
+ 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(
914
+ '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
+ },
924
+ outputSchema: INDEXED_PAGES_OUTPUT_SCHEMA,
925
+ 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(
962
+ '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
+ },
974
+ outputSchema: PERFORMANCE_COMPARISON_OUTPUT_SCHEMA,
975
+ 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,
998
+ });
999
+ },
1000
+ );
1001
+
1002
+ this.server.registerTool(
1003
+ '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
+ },
1015
+ outputSchema: WEEKLY_DIGEST_OUTPUT_SCHEMA,
1016
+ 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
+ );
1035
+ 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 = {
1052
+ async fetch(request: Request, env: Env): Promise<Response> {
1053
+ const url = new URL(request.url);
1054
+
1055
+ if (url.pathname === '/ping' && request.method === 'GET') {
1056
+ return new Response('pong', {
1057
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1058
+ });
1059
+ }
1060
+
1061
+ 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
+ });