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