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