@getmcpads/google-search-console-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +304 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +3073 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +3055 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3073 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
// src/core/logger.ts
|
|
10
|
+
var LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
11
|
+
var currentLevel = process.env["LOG_LEVEL"] ?? "info";
|
|
12
|
+
function log(level, platform, msg, data) {
|
|
13
|
+
if (LEVEL_ORDER[level] < LEVEL_ORDER[currentLevel]) return;
|
|
14
|
+
console.error(JSON.stringify({
|
|
15
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
16
|
+
level,
|
|
17
|
+
...platform && { platform },
|
|
18
|
+
msg,
|
|
19
|
+
...data !== void 0 && { data }
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
var logger = {
|
|
23
|
+
debug: (p, m, d) => log("debug", p, m, d),
|
|
24
|
+
info: (p, m, d) => log("info", p, m, d),
|
|
25
|
+
warn: (p, m, d) => log("warn", p, m, d),
|
|
26
|
+
error: (p, m, d) => log("error", p, m, d),
|
|
27
|
+
system: (m, d) => log("info", null, m, d),
|
|
28
|
+
setLevel: (l) => {
|
|
29
|
+
currentLevel = l;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// src/config.ts
|
|
34
|
+
var configSchema = z.object({
|
|
35
|
+
clientId: z.string().optional(),
|
|
36
|
+
clientSecret: z.string().optional(),
|
|
37
|
+
refreshToken: z.string().optional(),
|
|
38
|
+
accessToken: z.string().optional(),
|
|
39
|
+
defaultSiteUrl: z.string().optional(),
|
|
40
|
+
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info")
|
|
41
|
+
});
|
|
42
|
+
function loadConfig() {
|
|
43
|
+
const raw = {
|
|
44
|
+
clientId: process.env["GSC_CLIENT_ID"] || void 0,
|
|
45
|
+
clientSecret: process.env["GSC_CLIENT_SECRET"] || void 0,
|
|
46
|
+
refreshToken: process.env["GSC_REFRESH_TOKEN"] || void 0,
|
|
47
|
+
accessToken: process.env["GSC_ACCESS_TOKEN"] || void 0,
|
|
48
|
+
defaultSiteUrl: process.env["GSC_SITE_URL"] || void 0,
|
|
49
|
+
logLevel: process.env["LOG_LEVEL"] ?? "info"
|
|
50
|
+
};
|
|
51
|
+
const result = configSchema.safeParse(raw);
|
|
52
|
+
if (!result.success) {
|
|
53
|
+
const missing = result.error.issues.map((issue) => issue.message).join(", ");
|
|
54
|
+
logger.error("config", `Invalid GSC config: ${missing}`);
|
|
55
|
+
throw new Error(`Invalid GSC config: ${missing}`);
|
|
56
|
+
}
|
|
57
|
+
const config = result.data;
|
|
58
|
+
const canRefresh = Boolean(config.refreshToken && config.clientId && config.clientSecret);
|
|
59
|
+
if (!config.accessToken && !canRefresh) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"Missing GSC credentials: set GSC_ACCESS_TOKEN, or set GSC_CLIENT_ID, GSC_CLIENT_SECRET, and GSC_REFRESH_TOKEN."
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return config;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/server.ts
|
|
68
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
69
|
+
|
|
70
|
+
// src/platforms/gsc/tools.ts
|
|
71
|
+
import { z as z2 } from "zod";
|
|
72
|
+
|
|
73
|
+
// src/core/errors.ts
|
|
74
|
+
var PlatformApiError = class extends Error {
|
|
75
|
+
constructor(platform, code, message, isRateLimit = false, isAuth = false, isPermission = false, suggestion = "", retryAfter) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.platform = platform;
|
|
78
|
+
this.code = code;
|
|
79
|
+
this.isRateLimit = isRateLimit;
|
|
80
|
+
this.isAuth = isAuth;
|
|
81
|
+
this.isPermission = isPermission;
|
|
82
|
+
this.suggestion = suggestion;
|
|
83
|
+
this.retryAfter = retryAfter;
|
|
84
|
+
this.name = "PlatformApiError";
|
|
85
|
+
}
|
|
86
|
+
platform;
|
|
87
|
+
code;
|
|
88
|
+
isRateLimit;
|
|
89
|
+
isAuth;
|
|
90
|
+
isPermission;
|
|
91
|
+
suggestion;
|
|
92
|
+
retryAfter;
|
|
93
|
+
toMcpError() {
|
|
94
|
+
return {
|
|
95
|
+
error: this.message,
|
|
96
|
+
platform: this.platform,
|
|
97
|
+
code: this.code,
|
|
98
|
+
isRateLimit: this.isRateLimit,
|
|
99
|
+
isAuth: this.isAuth,
|
|
100
|
+
isPermission: this.isPermission,
|
|
101
|
+
suggestion: this.suggestion,
|
|
102
|
+
...this.retryAfter !== void 0 && { retryAfter: this.retryAfter }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
function formatMcpToolError(error) {
|
|
107
|
+
if (error instanceof PlatformApiError) {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text", text: JSON.stringify(error.toMcpError(), null, 2) }],
|
|
110
|
+
isError: true
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const apiError = error;
|
|
114
|
+
if (typeof apiError?.code === "number" || apiError?.status) {
|
|
115
|
+
return {
|
|
116
|
+
content: [{
|
|
117
|
+
type: "text",
|
|
118
|
+
text: JSON.stringify({
|
|
119
|
+
error: apiError.message ?? "Google Search Console API error",
|
|
120
|
+
platform: "gsc",
|
|
121
|
+
code: apiError.code,
|
|
122
|
+
status: apiError.status,
|
|
123
|
+
isAuth: Boolean(apiError.isAuthError),
|
|
124
|
+
isRateLimit: Boolean(apiError.isRateLimitError || apiError.isQuotaError),
|
|
125
|
+
suggestion: apiError.suggestion
|
|
126
|
+
}, null, 2)
|
|
127
|
+
}],
|
|
128
|
+
isError: true
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
132
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: msg }, null, 2) }], isError: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/core/rate-limiter.ts
|
|
136
|
+
var RateLimiter = class {
|
|
137
|
+
timestamps = [];
|
|
138
|
+
async acquire() {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
this.timestamps = this.timestamps.filter((t) => now - t < 6e4);
|
|
141
|
+
const lastSecond = this.timestamps.filter((t) => now - t < 1e3);
|
|
142
|
+
if (lastSecond.length >= 10) {
|
|
143
|
+
const waitMs = 1e3 - (now - lastSecond[0]) + 50;
|
|
144
|
+
logger.debug("gsc", `Rate limit wait: ${waitMs}ms`);
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
146
|
+
}
|
|
147
|
+
if (this.timestamps.length >= 600) {
|
|
148
|
+
const waitMs = 6e4 - (now - this.timestamps[0]) + 100;
|
|
149
|
+
logger.debug("gsc", `Minute rate limit wait: ${waitMs}ms`);
|
|
150
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
151
|
+
}
|
|
152
|
+
this.timestamps.push(Date.now());
|
|
153
|
+
}
|
|
154
|
+
async execute(fn) {
|
|
155
|
+
for (let i = 0; i <= 3; i++) {
|
|
156
|
+
await this.acquire();
|
|
157
|
+
try {
|
|
158
|
+
return await fn();
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const message = error instanceof Error ? error.message.toLowerCase() : "";
|
|
161
|
+
const code = typeof error?.code === "number" ? error.code : void 0;
|
|
162
|
+
const isRateLimit = code === 429 || message.includes("rate limit") || message.includes("quota");
|
|
163
|
+
if (isRateLimit && i < 3) {
|
|
164
|
+
const waitMs = Math.min(1e3 * Math.pow(2, i) + Math.floor(Math.random() * 500), 3e4);
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new Error("Rate limiter exhausted retries");
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// src/platforms/gsc/types.ts
|
|
176
|
+
var GSCApiException = class extends Error {
|
|
177
|
+
code;
|
|
178
|
+
status;
|
|
179
|
+
constructor(message, code, status) {
|
|
180
|
+
super(message);
|
|
181
|
+
this.name = "GSCApiException";
|
|
182
|
+
this.code = code;
|
|
183
|
+
this.status = status || "UNKNOWN";
|
|
184
|
+
}
|
|
185
|
+
get isAuthError() {
|
|
186
|
+
return this.code === 401 || this.code === 403;
|
|
187
|
+
}
|
|
188
|
+
get isRateLimitError() {
|
|
189
|
+
return this.code === 429;
|
|
190
|
+
}
|
|
191
|
+
get isQuotaError() {
|
|
192
|
+
return this.code === 429 || this.status === "RESOURCE_EXHAUSTED";
|
|
193
|
+
}
|
|
194
|
+
get suggestion() {
|
|
195
|
+
if (this.isAuthError) return "Re-authenticate with Google Search Console";
|
|
196
|
+
if (this.isRateLimitError) return "Wait and retry. Rate limit exceeded";
|
|
197
|
+
if (this.isQuotaError) return "Quota exceeded. Reduce query frequency";
|
|
198
|
+
return "Check the error details for more information";
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
var GSC_API_BASE = "https://searchconsole.googleapis.com";
|
|
202
|
+
var GSC_WEBMASTERS_API_BASE = "https://www.googleapis.com/webmasters/v3";
|
|
203
|
+
var GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
204
|
+
var GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
205
|
+
var GSC_OAUTH_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
|
|
206
|
+
function resolveDatePreset(preset) {
|
|
207
|
+
const now = /* @__PURE__ */ new Date();
|
|
208
|
+
const today = now.toISOString().split("T")[0];
|
|
209
|
+
const daysAgo = (n) => {
|
|
210
|
+
const d = new Date(now);
|
|
211
|
+
d.setDate(d.getDate() - n);
|
|
212
|
+
return d.toISOString().split("T")[0];
|
|
213
|
+
};
|
|
214
|
+
switch (preset) {
|
|
215
|
+
case "today":
|
|
216
|
+
return { startDate: today, endDate: today };
|
|
217
|
+
case "yesterday":
|
|
218
|
+
return { startDate: daysAgo(1), endDate: daysAgo(1) };
|
|
219
|
+
case "last7days":
|
|
220
|
+
return { startDate: daysAgo(6), endDate: today };
|
|
221
|
+
case "last28days":
|
|
222
|
+
return { startDate: daysAgo(27), endDate: today };
|
|
223
|
+
case "last3months":
|
|
224
|
+
return { startDate: daysAgo(89), endDate: today };
|
|
225
|
+
case "last6months":
|
|
226
|
+
return { startDate: daysAgo(179), endDate: today };
|
|
227
|
+
case "last12months":
|
|
228
|
+
return { startDate: daysAgo(364), endDate: today };
|
|
229
|
+
case "last16months":
|
|
230
|
+
return { startDate: daysAgo(489), endDate: today };
|
|
231
|
+
default:
|
|
232
|
+
return { startDate: daysAgo(30), endDate: today };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function flattenSearchAnalyticsResponse(response, dimensions) {
|
|
236
|
+
if (!response.rows || response.rows.length === 0) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
return response.rows.map((row) => {
|
|
240
|
+
const flat = {};
|
|
241
|
+
if (dimensions && row.keys) {
|
|
242
|
+
for (let i = 0; i < dimensions.length; i++) {
|
|
243
|
+
flat[dimensions[i]] = row.keys[i] ?? null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
flat.clicks = row.clicks;
|
|
247
|
+
flat.impressions = row.impressions;
|
|
248
|
+
flat.ctr = Math.round(row.ctr * 1e4) / 100;
|
|
249
|
+
flat.position = Math.round(row.position * 100) / 100;
|
|
250
|
+
return flat;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/platforms/gsc/gscClient.ts
|
|
255
|
+
function getAuthorizationUrl(clientId, redirectUri, state) {
|
|
256
|
+
const params = new URLSearchParams({
|
|
257
|
+
client_id: clientId,
|
|
258
|
+
redirect_uri: redirectUri,
|
|
259
|
+
response_type: "code",
|
|
260
|
+
scope: GSC_OAUTH_SCOPE,
|
|
261
|
+
access_type: "offline",
|
|
262
|
+
prompt: "consent",
|
|
263
|
+
state
|
|
264
|
+
});
|
|
265
|
+
return `${GOOGLE_OAUTH_AUTH_URL}?${params.toString()}`;
|
|
266
|
+
}
|
|
267
|
+
async function exchangeCodeForToken(code, clientId, clientSecret, redirectUri) {
|
|
268
|
+
const response = await fetch(GOOGLE_OAUTH_TOKEN_URL, {
|
|
269
|
+
method: "POST",
|
|
270
|
+
redirect: "error",
|
|
271
|
+
headers: {
|
|
272
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
273
|
+
},
|
|
274
|
+
body: new URLSearchParams({
|
|
275
|
+
grant_type: "authorization_code",
|
|
276
|
+
code,
|
|
277
|
+
client_id: clientId,
|
|
278
|
+
client_secret: clientSecret,
|
|
279
|
+
redirect_uri: redirectUri
|
|
280
|
+
})
|
|
281
|
+
});
|
|
282
|
+
if (!response.ok) {
|
|
283
|
+
const error = await response.json().catch(() => ({}));
|
|
284
|
+
throw new GSCApiException(
|
|
285
|
+
error.error_description || error.error || "Failed to exchange code for token",
|
|
286
|
+
response.status,
|
|
287
|
+
error.error
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return response.json();
|
|
291
|
+
}
|
|
292
|
+
async function refreshAccessToken(refreshToken, clientId, clientSecret) {
|
|
293
|
+
const response = await fetch(GOOGLE_OAUTH_TOKEN_URL, {
|
|
294
|
+
method: "POST",
|
|
295
|
+
redirect: "error",
|
|
296
|
+
headers: {
|
|
297
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
298
|
+
},
|
|
299
|
+
body: new URLSearchParams({
|
|
300
|
+
grant_type: "refresh_token",
|
|
301
|
+
refresh_token: refreshToken,
|
|
302
|
+
client_id: clientId,
|
|
303
|
+
client_secret: clientSecret
|
|
304
|
+
})
|
|
305
|
+
});
|
|
306
|
+
if (!response.ok) {
|
|
307
|
+
const error = await response.json().catch(() => ({}));
|
|
308
|
+
throw new GSCApiException(
|
|
309
|
+
error.error_description || error.error || "Failed to refresh token",
|
|
310
|
+
response.status,
|
|
311
|
+
error.error
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return response.json();
|
|
315
|
+
}
|
|
316
|
+
var GSCClient = class {
|
|
317
|
+
accessToken;
|
|
318
|
+
constructor(accessToken) {
|
|
319
|
+
this.accessToken = accessToken;
|
|
320
|
+
}
|
|
321
|
+
// Static methods
|
|
322
|
+
static getAuthorizationUrl = getAuthorizationUrl;
|
|
323
|
+
static exchangeCodeForToken = exchangeCodeForToken;
|
|
324
|
+
static refreshAccessToken = refreshAccessToken;
|
|
325
|
+
// ============================================
|
|
326
|
+
// PRIVATE METHODS
|
|
327
|
+
// ============================================
|
|
328
|
+
async request(url, options = {}) {
|
|
329
|
+
const controller = new AbortController();
|
|
330
|
+
const timeout = setTimeout(() => controller.abort(), 9e4);
|
|
331
|
+
try {
|
|
332
|
+
const response = await fetch(url, {
|
|
333
|
+
...options,
|
|
334
|
+
signal: controller.signal,
|
|
335
|
+
// Forced after the spread: once a bearer token is attached, a redirect
|
|
336
|
+
// must never be followed, or the credential would be forwarded to
|
|
337
|
+
// whatever host the redirect names.
|
|
338
|
+
redirect: "error",
|
|
339
|
+
headers: {
|
|
340
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
341
|
+
"Content-Type": "application/json",
|
|
342
|
+
...options.headers
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
if (!response.ok) {
|
|
346
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
347
|
+
const errorDetails = errorBody?.error;
|
|
348
|
+
console.error("[GSCClient] API Error:", response.status, JSON.stringify(errorBody, null, 2));
|
|
349
|
+
const detailedMessage = errorDetails?.message || `Request failed: ${response.statusText}`;
|
|
350
|
+
throw new GSCApiException(
|
|
351
|
+
detailedMessage,
|
|
352
|
+
response.status,
|
|
353
|
+
errorDetails?.status
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return response.json();
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
359
|
+
throw new GSCApiException(
|
|
360
|
+
"GSC API request timed out after 90 seconds",
|
|
361
|
+
408,
|
|
362
|
+
"TIMEOUT"
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
throw error;
|
|
366
|
+
} finally {
|
|
367
|
+
clearTimeout(timeout);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// ============================================
|
|
371
|
+
// SITE MANAGEMENT
|
|
372
|
+
// ============================================
|
|
373
|
+
/**
|
|
374
|
+
* List all verified sites accessible to the authenticated user
|
|
375
|
+
* GET https://www.googleapis.com/webmasters/v3/sites
|
|
376
|
+
*/
|
|
377
|
+
async listSites() {
|
|
378
|
+
const response = await this.request(
|
|
379
|
+
`${GSC_WEBMASTERS_API_BASE}/sites`
|
|
380
|
+
);
|
|
381
|
+
return (response.siteEntry || []).map((entry) => ({
|
|
382
|
+
siteUrl: entry.siteUrl,
|
|
383
|
+
permissionLevel: entry.permissionLevel
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
/** Get a single Search Console property and its permission level. */
|
|
387
|
+
async getSite(siteUrl) {
|
|
388
|
+
const encodedSiteUrl = encodeURIComponent(siteUrl);
|
|
389
|
+
const entry = await this.request(
|
|
390
|
+
`${GSC_WEBMASTERS_API_BASE}/sites/${encodedSiteUrl}`
|
|
391
|
+
);
|
|
392
|
+
return {
|
|
393
|
+
siteUrl: entry.siteUrl,
|
|
394
|
+
permissionLevel: entry.permissionLevel
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// ============================================
|
|
398
|
+
// SEARCH ANALYTICS
|
|
399
|
+
// ============================================
|
|
400
|
+
/**
|
|
401
|
+
* Query search analytics data with automatic pagination
|
|
402
|
+
* POST https://searchconsole.googleapis.com/webmasters/v3/sites/{siteUrl}/searchAnalytics/query
|
|
403
|
+
* Max 25,000 rows per request
|
|
404
|
+
*/
|
|
405
|
+
async querySearchAnalytics(siteUrl, request) {
|
|
406
|
+
const encodedSiteUrl = encodeURIComponent(siteUrl);
|
|
407
|
+
const url = `${GSC_API_BASE}/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`;
|
|
408
|
+
const MAX_ROWS_PER_REQUEST = 25e3;
|
|
409
|
+
const rowLimit = request.rowLimit ?? MAX_ROWS_PER_REQUEST;
|
|
410
|
+
const initialOffset = request.startRow ?? 0;
|
|
411
|
+
const firstResponse = await this.request(url, {
|
|
412
|
+
method: "POST",
|
|
413
|
+
body: JSON.stringify({
|
|
414
|
+
...request,
|
|
415
|
+
rowLimit: Math.min(rowLimit, MAX_ROWS_PER_REQUEST),
|
|
416
|
+
startRow: initialOffset
|
|
417
|
+
})
|
|
418
|
+
});
|
|
419
|
+
const returnedRows = firstResponse.rows?.length ?? 0;
|
|
420
|
+
if (request.rowLimit || returnedRows < MAX_ROWS_PER_REQUEST) {
|
|
421
|
+
return firstResponse;
|
|
422
|
+
}
|
|
423
|
+
const allRows = [...firstResponse.rows ?? []];
|
|
424
|
+
let currentOffset = initialOffset + returnedRows;
|
|
425
|
+
while (true) {
|
|
426
|
+
const pageResponse = await this.request(url, {
|
|
427
|
+
method: "POST",
|
|
428
|
+
body: JSON.stringify({
|
|
429
|
+
...request,
|
|
430
|
+
rowLimit: MAX_ROWS_PER_REQUEST,
|
|
431
|
+
startRow: currentOffset
|
|
432
|
+
})
|
|
433
|
+
});
|
|
434
|
+
const pageRows = pageResponse.rows?.length ?? 0;
|
|
435
|
+
if (pageRows === 0) break;
|
|
436
|
+
allRows.push(...pageResponse.rows ?? []);
|
|
437
|
+
currentOffset += pageRows;
|
|
438
|
+
if (pageRows < MAX_ROWS_PER_REQUEST) break;
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
...firstResponse,
|
|
442
|
+
rows: allRows
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
// ============================================
|
|
446
|
+
// URL INSPECTION
|
|
447
|
+
// ============================================
|
|
448
|
+
/**
|
|
449
|
+
* Inspect a URL for indexing status
|
|
450
|
+
* POST https://searchconsole.googleapis.com/v1/urlInspection/index:inspect
|
|
451
|
+
* Quota: 2000 requests/day/property
|
|
452
|
+
*/
|
|
453
|
+
async inspectUrl(inspectionUrl, siteUrl, languageCode) {
|
|
454
|
+
const url = `${GSC_API_BASE}/v1/urlInspection/index:inspect`;
|
|
455
|
+
const body = {
|
|
456
|
+
inspectionUrl,
|
|
457
|
+
siteUrl,
|
|
458
|
+
...languageCode && { languageCode }
|
|
459
|
+
};
|
|
460
|
+
return this.request(url, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
body: JSON.stringify(body)
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
// ============================================
|
|
466
|
+
// SITEMAPS
|
|
467
|
+
// ============================================
|
|
468
|
+
/**
|
|
469
|
+
* List sitemaps for a site
|
|
470
|
+
* GET https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/sitemaps
|
|
471
|
+
*/
|
|
472
|
+
async listSitemaps(siteUrl, sitemapIndex) {
|
|
473
|
+
const encodedSiteUrl = encodeURIComponent(siteUrl);
|
|
474
|
+
const params = sitemapIndex ? `?sitemapIndex=${encodeURIComponent(sitemapIndex)}` : "";
|
|
475
|
+
const url = `${GSC_WEBMASTERS_API_BASE}/sites/${encodedSiteUrl}/sitemaps${params}`;
|
|
476
|
+
return this.request(url);
|
|
477
|
+
}
|
|
478
|
+
/** Get one submitted sitemap by its full feed path. */
|
|
479
|
+
async getSitemap(siteUrl, feedpath) {
|
|
480
|
+
const encodedSiteUrl = encodeURIComponent(siteUrl);
|
|
481
|
+
const encodedFeedpath = encodeURIComponent(feedpath);
|
|
482
|
+
return this.request(
|
|
483
|
+
`${GSC_WEBMASTERS_API_BASE}/sites/${encodedSiteUrl}/sitemaps/${encodedFeedpath}`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// src/platforms/gsc/dimensionCatalog.ts
|
|
489
|
+
var GSC_DIMENSION_CATALOG = [
|
|
490
|
+
{
|
|
491
|
+
key: "query",
|
|
492
|
+
name: "Search Query",
|
|
493
|
+
description: "The search query (keyword) that triggered an impression. Not available for Discover and Google News search types.",
|
|
494
|
+
category: "query",
|
|
495
|
+
apiField: "query"
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
key: "page",
|
|
499
|
+
name: "Page URL",
|
|
500
|
+
description: "The final URL of the page that appeared in search results",
|
|
501
|
+
category: "page",
|
|
502
|
+
apiField: "page"
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
key: "country",
|
|
506
|
+
name: "Country",
|
|
507
|
+
description: "Country where the search originated. Uses ISO 3166-1 alpha-3 codes (e.g., USA, FRA, GBR, DEU, JPN)",
|
|
508
|
+
category: "geography",
|
|
509
|
+
apiField: "country"
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
key: "device",
|
|
513
|
+
name: "Device",
|
|
514
|
+
description: "Device type used for the search: DESKTOP, MOBILE, or TABLET",
|
|
515
|
+
category: "device",
|
|
516
|
+
apiField: "device"
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
key: "date",
|
|
520
|
+
name: "Date",
|
|
521
|
+
description: "Date of the search impression in YYYY-MM-DD format",
|
|
522
|
+
category: "time",
|
|
523
|
+
apiField: "date"
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
key: "hour",
|
|
527
|
+
name: "Hour",
|
|
528
|
+
description: "Hour of the search impression. Use with dataState=hourly_all; recent hourly rows can be incomplete.",
|
|
529
|
+
category: "time",
|
|
530
|
+
apiField: "hour"
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
key: "searchAppearance",
|
|
534
|
+
name: "Search Appearance",
|
|
535
|
+
description: "Special search result features: AMP_ARTICLE, INSTANT_APP, PRODUCT_LISTING, REVIEW_SNIPPET, SEARCH_ACTION, etc. One URL can have multiple appearances, inflating row count.",
|
|
536
|
+
category: "appearance",
|
|
537
|
+
apiField: "searchAppearance"
|
|
538
|
+
}
|
|
539
|
+
];
|
|
540
|
+
function getDimensionByKey(key) {
|
|
541
|
+
return GSC_DIMENSION_CATALOG.find((d) => d.key === key);
|
|
542
|
+
}
|
|
543
|
+
function validateDimensions(keys) {
|
|
544
|
+
const valid = [];
|
|
545
|
+
const invalid = [];
|
|
546
|
+
for (const key of keys) {
|
|
547
|
+
const dim = getDimensionByKey(key);
|
|
548
|
+
if (dim) {
|
|
549
|
+
valid.push(dim.key);
|
|
550
|
+
} else {
|
|
551
|
+
invalid.push(key);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return { valid, invalid };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/platforms/gsc/filterCatalog.ts
|
|
558
|
+
var GSC_FILTER_OPERATORS = [
|
|
559
|
+
"contains",
|
|
560
|
+
"equals",
|
|
561
|
+
"notContains",
|
|
562
|
+
"notEquals",
|
|
563
|
+
"includingRegex",
|
|
564
|
+
"excludingRegex"
|
|
565
|
+
];
|
|
566
|
+
var GSC_FILTER_CATALOG = [
|
|
567
|
+
{
|
|
568
|
+
key: "query",
|
|
569
|
+
name: "Search Query",
|
|
570
|
+
description: "Filter by search query/keyword",
|
|
571
|
+
dimension: "query",
|
|
572
|
+
operators: GSC_FILTER_OPERATORS
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
key: "page",
|
|
576
|
+
name: "Page URL",
|
|
577
|
+
description: "Filter by page URL",
|
|
578
|
+
dimension: "page",
|
|
579
|
+
operators: GSC_FILTER_OPERATORS
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
key: "country",
|
|
583
|
+
name: "Country",
|
|
584
|
+
description: "Filter by country (ISO 3166-1 alpha-3 code, e.g., USA, FRA, GBR)",
|
|
585
|
+
dimension: "country",
|
|
586
|
+
operators: ["equals", "notEquals"]
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
key: "device",
|
|
590
|
+
name: "Device",
|
|
591
|
+
description: "Filter by device type: DESKTOP, MOBILE, TABLET",
|
|
592
|
+
dimension: "device",
|
|
593
|
+
operators: ["equals", "notEquals"]
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
key: "searchAppearance",
|
|
597
|
+
name: "Search Appearance",
|
|
598
|
+
description: "Filter by search appearance type",
|
|
599
|
+
dimension: "searchAppearance",
|
|
600
|
+
operators: ["equals", "notEquals"]
|
|
601
|
+
}
|
|
602
|
+
];
|
|
603
|
+
function buildFiltersFromParams(filters) {
|
|
604
|
+
return filters.filter((f) => f.field && f.operator && f.value != null).map((f) => ({
|
|
605
|
+
dimension: f.field,
|
|
606
|
+
operator: f.operator,
|
|
607
|
+
expression: String(f.value)
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/platforms/gsc/calculatedMetrics.ts
|
|
612
|
+
var GSC_CALCULATED_METRIC_DEFINITIONS = [
|
|
613
|
+
{
|
|
614
|
+
key: "ctrOpportunity",
|
|
615
|
+
name: "CTR Opportunity",
|
|
616
|
+
description: "Potential CTR improvement: pages with high impressions but low CTR (positions 1-10). Score = impressions * (expectedCTR - actualCTR). Higher score = bigger opportunity.",
|
|
617
|
+
category: "calculated",
|
|
618
|
+
format: "number",
|
|
619
|
+
apiField: "",
|
|
620
|
+
type: "calculated",
|
|
621
|
+
formula: "impressions * (expectedCTR(position) - ctr)",
|
|
622
|
+
dependencies: ["impressions", "ctr", "position"]
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
key: "clicksPerImpression",
|
|
626
|
+
name: "Clicks per 1K Impressions",
|
|
627
|
+
description: "Number of clicks per 1,000 impressions. Alternative view of CTR.",
|
|
628
|
+
category: "calculated",
|
|
629
|
+
format: "number",
|
|
630
|
+
apiField: "",
|
|
631
|
+
type: "calculated",
|
|
632
|
+
formula: "(clicks / impressions) * 1000",
|
|
633
|
+
dependencies: ["clicks", "impressions"]
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
key: "impressionShare",
|
|
637
|
+
name: "Impression Share %",
|
|
638
|
+
description: "This row's share of total impressions (percentage). Useful for distribution analysis.",
|
|
639
|
+
category: "calculated",
|
|
640
|
+
format: "percent",
|
|
641
|
+
apiField: "",
|
|
642
|
+
type: "calculated",
|
|
643
|
+
formula: "(rowImpressions / totalImpressions) * 100",
|
|
644
|
+
dependencies: ["impressions"]
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
key: "clickShare",
|
|
648
|
+
name: "Click Share %",
|
|
649
|
+
description: "This row's share of total clicks (percentage). Useful for distribution analysis.",
|
|
650
|
+
category: "calculated",
|
|
651
|
+
format: "percent",
|
|
652
|
+
apiField: "",
|
|
653
|
+
type: "calculated",
|
|
654
|
+
formula: "(rowClicks / totalClicks) * 100",
|
|
655
|
+
dependencies: ["clicks"]
|
|
656
|
+
}
|
|
657
|
+
];
|
|
658
|
+
function expectedCTRByPosition(position) {
|
|
659
|
+
if (position <= 1) return 30;
|
|
660
|
+
if (position <= 2) return 15;
|
|
661
|
+
if (position <= 3) return 10;
|
|
662
|
+
if (position <= 4) return 7;
|
|
663
|
+
if (position <= 5) return 5;
|
|
664
|
+
if (position <= 6) return 4;
|
|
665
|
+
if (position <= 7) return 3;
|
|
666
|
+
if (position <= 8) return 2.5;
|
|
667
|
+
if (position <= 9) return 2;
|
|
668
|
+
if (position <= 10) return 1.5;
|
|
669
|
+
return 0.5;
|
|
670
|
+
}
|
|
671
|
+
function calcCtrOpportunity(row) {
|
|
672
|
+
const impressions = row.impressions;
|
|
673
|
+
const ctr = row.ctr;
|
|
674
|
+
const position = row.position;
|
|
675
|
+
if (typeof impressions !== "number" || typeof ctr !== "number" || typeof position !== "number") return null;
|
|
676
|
+
if (position > 20) return 0;
|
|
677
|
+
const expected = expectedCTRByPosition(position);
|
|
678
|
+
const gap = expected - ctr;
|
|
679
|
+
if (gap <= 0) return 0;
|
|
680
|
+
return Math.round(impressions * gap / 100);
|
|
681
|
+
}
|
|
682
|
+
function calcClicksPerImpression(row) {
|
|
683
|
+
const clicks = row.clicks;
|
|
684
|
+
const impressions = row.impressions;
|
|
685
|
+
if (typeof clicks !== "number" || typeof impressions !== "number" || impressions === 0) return null;
|
|
686
|
+
return clicks / impressions * 1e3;
|
|
687
|
+
}
|
|
688
|
+
function calculateMetrics(row) {
|
|
689
|
+
return {
|
|
690
|
+
ctrOpportunity: calcCtrOpportunity(row),
|
|
691
|
+
clicksPerImpression: calcClicksPerImpression(row)
|
|
692
|
+
// impressionShare and clickShare need totals, computed in enrichWithCalculatedMetrics
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
function enrichWithCalculatedMetrics(rows, requestedCalculated) {
|
|
696
|
+
if (rows.length === 0) return rows;
|
|
697
|
+
let totalImpressions = 0;
|
|
698
|
+
let totalClicks = 0;
|
|
699
|
+
for (const row of rows) {
|
|
700
|
+
if (typeof row.impressions === "number") totalImpressions += row.impressions;
|
|
701
|
+
if (typeof row.clicks === "number") totalClicks += row.clicks;
|
|
702
|
+
}
|
|
703
|
+
return rows.map((row) => {
|
|
704
|
+
const calculated = calculateMetrics(row);
|
|
705
|
+
if (typeof row.impressions === "number" && totalImpressions > 0) {
|
|
706
|
+
calculated.impressionShare = row.impressions / totalImpressions * 100;
|
|
707
|
+
}
|
|
708
|
+
if (typeof row.clicks === "number" && totalClicks > 0) {
|
|
709
|
+
calculated.clickShare = row.clicks / totalClicks * 100;
|
|
710
|
+
}
|
|
711
|
+
const enriched = { ...row };
|
|
712
|
+
for (const [key, value] of Object.entries(calculated)) {
|
|
713
|
+
if (value === null) continue;
|
|
714
|
+
if (requestedCalculated && !requestedCalculated.includes(key)) continue;
|
|
715
|
+
enriched[key] = Math.round(value * 100) / 100;
|
|
716
|
+
}
|
|
717
|
+
return enriched;
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
function isCalculatedMetric(key) {
|
|
721
|
+
return GSC_CALCULATED_METRIC_DEFINITIONS.some((m) => m.key === key);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/platforms/gsc/compatibilityRules.ts
|
|
725
|
+
var MAX_DIMENSIONS = 5;
|
|
726
|
+
var MAX_HISTORY_DAYS = 490;
|
|
727
|
+
var SEARCH_TYPES_WITHOUT_QUERY = /* @__PURE__ */ new Set([
|
|
728
|
+
"discover",
|
|
729
|
+
"googleNews"
|
|
730
|
+
]);
|
|
731
|
+
function validateGSCQuerySelection(dimensions, searchType, dateRange) {
|
|
732
|
+
const errors = [];
|
|
733
|
+
const warnings = [];
|
|
734
|
+
if (dimensions.length > MAX_DIMENSIONS) {
|
|
735
|
+
errors.push(
|
|
736
|
+
`GSC supports a maximum of ${MAX_DIMENSIONS} dimensions per request. You selected ${dimensions.length}.`
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
if (searchType && SEARCH_TYPES_WITHOUT_QUERY.has(searchType) && dimensions.includes("query")) {
|
|
740
|
+
errors.push(
|
|
741
|
+
`The 'query' dimension is not available for '${searchType}' search type. Discover and Google News do not expose search queries.`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (dimensions.includes("searchAppearance")) {
|
|
745
|
+
warnings.push(
|
|
746
|
+
"The 'searchAppearance' dimension can inflate results: a single page URL may appear multiple times with different search features (e.g., AMP, review snippet). Totals may exceed what you see without this dimension."
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
if (dimensions.includes("query") && dimensions.includes("page")) {
|
|
750
|
+
warnings.push(
|
|
751
|
+
"Combining 'query' and 'page' dimensions produces high-cardinality results (each unique query-page pair is a row). Consider using a limit or filtering to keep results manageable."
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
if (dateRange) {
|
|
755
|
+
const start = new Date(dateRange.startDate);
|
|
756
|
+
const end = new Date(dateRange.endDate);
|
|
757
|
+
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1e3 * 60 * 60 * 24));
|
|
758
|
+
if (daysDiff > MAX_HISTORY_DAYS) {
|
|
759
|
+
errors.push(
|
|
760
|
+
`GSC data is available for a maximum of ~16 months. Your date range spans ${daysDiff} days (max ${MAX_HISTORY_DAYS}).`
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
if (start > end) {
|
|
764
|
+
errors.push("Start date must be before end date.");
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (!dimensions.includes("date") && !dimensions.includes("hour")) {
|
|
768
|
+
if (dimensions.length > 0) {
|
|
769
|
+
warnings.push(
|
|
770
|
+
"No 'date' dimension selected. Results will be aggregated over the entire date range."
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
valid: errors.length === 0,
|
|
776
|
+
errors,
|
|
777
|
+
warnings
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/platforms/gsc/queryPlanner.ts
|
|
782
|
+
function planGSCQuery(params) {
|
|
783
|
+
const warnings = [];
|
|
784
|
+
const errors = [];
|
|
785
|
+
const requestedMetrics = params.metrics || ["clicks", "impressions", "ctr", "position"];
|
|
786
|
+
const calculatedMetrics = requestedMetrics.filter(isCalculatedMetric);
|
|
787
|
+
let resolvedDimensions = [];
|
|
788
|
+
if (params.dimensions && params.dimensions.length > 0) {
|
|
789
|
+
const { valid, invalid } = validateDimensions(params.dimensions);
|
|
790
|
+
resolvedDimensions = valid;
|
|
791
|
+
if (invalid.length > 0) {
|
|
792
|
+
warnings.push(`Unknown dimensions ignored: ${invalid.join(", ")}. Valid: query, page, country, device, date, hour, searchAppearance.`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const searchType = params.searchType;
|
|
796
|
+
const validSearchTypes = ["web", "image", "video", "news", "discover", "googleNews"];
|
|
797
|
+
if (searchType && !validSearchTypes.includes(searchType)) {
|
|
798
|
+
warnings.push(`Unknown search type '${searchType}'. Valid: ${validSearchTypes.join(", ")}. Defaulting to 'web'.`);
|
|
799
|
+
}
|
|
800
|
+
const validation = validateGSCQuerySelection(
|
|
801
|
+
resolvedDimensions,
|
|
802
|
+
searchType,
|
|
803
|
+
params.dateRange
|
|
804
|
+
);
|
|
805
|
+
errors.push(...validation.errors);
|
|
806
|
+
warnings.push(...validation.warnings);
|
|
807
|
+
let dateRange;
|
|
808
|
+
if (params.dateRange) {
|
|
809
|
+
dateRange = params.dateRange;
|
|
810
|
+
} else if (params.datePreset) {
|
|
811
|
+
dateRange = resolveDatePreset(params.datePreset);
|
|
812
|
+
} else {
|
|
813
|
+
dateRange = resolveDatePreset("last28days");
|
|
814
|
+
}
|
|
815
|
+
const gscFilters = params.filters ? buildFiltersFromParams(params.filters) : [];
|
|
816
|
+
const request = {
|
|
817
|
+
startDate: dateRange.startDate,
|
|
818
|
+
endDate: dateRange.endDate,
|
|
819
|
+
...resolvedDimensions.length > 0 && { dimensions: resolvedDimensions },
|
|
820
|
+
...searchType && { type: searchType },
|
|
821
|
+
...gscFilters.length > 0 && {
|
|
822
|
+
dimensionFilterGroups: [
|
|
823
|
+
{
|
|
824
|
+
groupType: "and",
|
|
825
|
+
filters: gscFilters
|
|
826
|
+
}
|
|
827
|
+
]
|
|
828
|
+
},
|
|
829
|
+
...params.limit && { rowLimit: params.limit },
|
|
830
|
+
...params.aggregationType && { aggregationType: params.aggregationType },
|
|
831
|
+
dataState: params.dataState ?? "all"
|
|
832
|
+
// Include fresh (non-final) data by default
|
|
833
|
+
};
|
|
834
|
+
const requestFilters = request.dimensionFilterGroups?.flatMap((group) => group.filters) ?? [];
|
|
835
|
+
const filtersPage = requestFilters.some((filter) => filter.dimension === "page");
|
|
836
|
+
if (request.aggregationType === "byProperty" && (request.dimensions?.includes("page") || filtersPage)) {
|
|
837
|
+
errors.push("aggregationType=byProperty cannot be used when grouping or filtering by page; use auto or byPage.");
|
|
838
|
+
}
|
|
839
|
+
if (request.aggregationType === "byProperty" && (request.type === "discover" || request.type === "googleNews")) {
|
|
840
|
+
errors.push("aggregationType=byProperty is not supported for Discover or Google News.");
|
|
841
|
+
}
|
|
842
|
+
if (request.aggregationType === "byNewsShowcasePanel") {
|
|
843
|
+
const searchAppearanceFilters = requestFilters.filter((filter) => filter.dimension === "searchAppearance");
|
|
844
|
+
const isNewsShowcaseFilter = (filter) => filter.operator === "equals" && filter.expression === "NEWS_SHOWCASE";
|
|
845
|
+
if (request.type !== "discover" && request.type !== "googleNews") {
|
|
846
|
+
errors.push("aggregationType=byNewsShowcasePanel requires searchType=discover or googleNews.");
|
|
847
|
+
}
|
|
848
|
+
if (request.dimensions?.includes("page") || filtersPage) {
|
|
849
|
+
errors.push("aggregationType=byNewsShowcasePanel cannot be used when grouping or filtering by page.");
|
|
850
|
+
}
|
|
851
|
+
if (!searchAppearanceFilters.some(isNewsShowcaseFilter)) {
|
|
852
|
+
errors.push("aggregationType=byNewsShowcasePanel requires a searchAppearance equals NEWS_SHOWCASE filter.");
|
|
853
|
+
}
|
|
854
|
+
if (searchAppearanceFilters.some((filter) => !isNewsShowcaseFilter(filter))) {
|
|
855
|
+
errors.push("aggregationType=byNewsShowcasePanel cannot filter to a searchAppearance other than NEWS_SHOWCASE.");
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (request.dataState === "hourly_all" && !request.dimensions?.includes("hour")) {
|
|
859
|
+
warnings.push("dataState=hourly_all is most useful with the hour dimension; add hour to receive hourly breakdown and incomplete-hour metadata.");
|
|
860
|
+
}
|
|
861
|
+
if (request.dimensions?.includes("hour") && request.dataState !== "hourly_all") {
|
|
862
|
+
errors.push("The hour dimension requires dataState=hourly_all.");
|
|
863
|
+
}
|
|
864
|
+
if (request.dataState === "hourly_all") {
|
|
865
|
+
const start = /* @__PURE__ */ new Date(`${dateRange.startDate}T00:00:00Z`);
|
|
866
|
+
const end = /* @__PURE__ */ new Date(`${dateRange.endDate}T00:00:00Z`);
|
|
867
|
+
const inclusiveDays = Math.floor((end.getTime() - start.getTime()) / 864e5) + 1;
|
|
868
|
+
if (Number.isFinite(inclusiveDays) && inclusiveDays > 10) {
|
|
869
|
+
errors.push(`Hourly Search Analytics is available for at most 10 days; this range spans ${inclusiveDays} days.`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return {
|
|
873
|
+
request,
|
|
874
|
+
siteUrl: params.siteUrl,
|
|
875
|
+
warnings,
|
|
876
|
+
errors,
|
|
877
|
+
calculatedMetrics,
|
|
878
|
+
requestedMetrics
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// src/platforms/gsc/tools.ts
|
|
883
|
+
var siteUrlSchema = z2.string().optional().describe("Search Console property URL, e.g. https://example.com/ or sc-domain:example.com. Uses GSC_SITE_URL when omitted.");
|
|
884
|
+
var datePresetSchema = z2.enum(["today", "yesterday", "last7days", "last28days", "last3months", "last6months", "last12months", "last16months"]);
|
|
885
|
+
var searchTypeSchema = z2.enum(["web", "image", "video", "news", "discover", "googleNews"]);
|
|
886
|
+
var dataStateSchema = z2.enum(["final", "all", "hourly_all"]);
|
|
887
|
+
var aggregationTypeSchema = z2.enum(["auto", "byPage", "byProperty", "byNewsShowcasePanel"]);
|
|
888
|
+
var limitSchema = z2.number().int().min(1).max(25e3).optional().default(1e3);
|
|
889
|
+
var dateRangeSchema = z2.object({
|
|
890
|
+
startDate: z2.string().describe("Start date YYYY-MM-DD"),
|
|
891
|
+
endDate: z2.string().describe("End date YYYY-MM-DD")
|
|
892
|
+
});
|
|
893
|
+
var comparisonDimensionSchema = z2.enum(["query", "page"]);
|
|
894
|
+
var sitemapBaselineSchema = z2.object({
|
|
895
|
+
path: z2.string().describe("Sitemap URL/path returned by GSC."),
|
|
896
|
+
submitted: z2.number().int().min(0).optional().default(0),
|
|
897
|
+
indexed: z2.number().int().min(0).optional().default(0),
|
|
898
|
+
warnings: z2.number().int().min(0).optional().default(0),
|
|
899
|
+
errors: z2.number().int().min(0).optional().default(0),
|
|
900
|
+
lastSubmitted: z2.string().optional(),
|
|
901
|
+
lastDownloaded: z2.string().optional()
|
|
902
|
+
});
|
|
903
|
+
var watchlistUrlSchema = z2.object({
|
|
904
|
+
url: z2.string().url(),
|
|
905
|
+
label: z2.string().optional(),
|
|
906
|
+
priority: z2.enum(["low", "medium", "high", "critical"]).optional().default("medium"),
|
|
907
|
+
tags: z2.array(z2.string()).optional().default([]),
|
|
908
|
+
expectedVerdict: z2.enum(["PASS", "PARTIAL", "FAIL", "NEUTRAL", "VERDICT_UNSPECIFIED"]).optional().default("PASS"),
|
|
909
|
+
expectedCanonical: z2.string().url().optional(),
|
|
910
|
+
expectedCoverageState: z2.string().optional()
|
|
911
|
+
});
|
|
912
|
+
var samplingObjectiveSchema = z2.enum(["topTraffic", "lowCtr", "declining", "sitemapRisk", "staleSitemaps"]);
|
|
913
|
+
var BULK_URL_INSPECTION_LIMIT = 10;
|
|
914
|
+
var URL_INSPECTION_DAILY_QUOTA = 2e3;
|
|
915
|
+
var DEFAULT_FRESHNESS_LAG_WARNING_DAYS = 3;
|
|
916
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
917
|
+
"a",
|
|
918
|
+
"about",
|
|
919
|
+
"after",
|
|
920
|
+
"and",
|
|
921
|
+
"are",
|
|
922
|
+
"as",
|
|
923
|
+
"at",
|
|
924
|
+
"avec",
|
|
925
|
+
"be",
|
|
926
|
+
"by",
|
|
927
|
+
"comment",
|
|
928
|
+
"dans",
|
|
929
|
+
"de",
|
|
930
|
+
"des",
|
|
931
|
+
"du",
|
|
932
|
+
"en",
|
|
933
|
+
"est",
|
|
934
|
+
"et",
|
|
935
|
+
"for",
|
|
936
|
+
"from",
|
|
937
|
+
"how",
|
|
938
|
+
"in",
|
|
939
|
+
"is",
|
|
940
|
+
"la",
|
|
941
|
+
"le",
|
|
942
|
+
"les",
|
|
943
|
+
"of",
|
|
944
|
+
"on",
|
|
945
|
+
"ou",
|
|
946
|
+
"pour",
|
|
947
|
+
"que",
|
|
948
|
+
"qui",
|
|
949
|
+
"the",
|
|
950
|
+
"to",
|
|
951
|
+
"un",
|
|
952
|
+
"une",
|
|
953
|
+
"what",
|
|
954
|
+
"when",
|
|
955
|
+
"where",
|
|
956
|
+
"why",
|
|
957
|
+
"with"
|
|
958
|
+
]);
|
|
959
|
+
var QUESTION_TOKENS = /* @__PURE__ */ new Set([
|
|
960
|
+
"comment",
|
|
961
|
+
"combien",
|
|
962
|
+
"est-ce",
|
|
963
|
+
"how",
|
|
964
|
+
"ou",
|
|
965
|
+
"pourquoi",
|
|
966
|
+
"quand",
|
|
967
|
+
"que",
|
|
968
|
+
"quel",
|
|
969
|
+
"quelle",
|
|
970
|
+
"quelles",
|
|
971
|
+
"quels",
|
|
972
|
+
"qui",
|
|
973
|
+
"quoi",
|
|
974
|
+
"what",
|
|
975
|
+
"when",
|
|
976
|
+
"where",
|
|
977
|
+
"which",
|
|
978
|
+
"who",
|
|
979
|
+
"why"
|
|
980
|
+
]);
|
|
981
|
+
var COMMERCIAL_TOKENS = /* @__PURE__ */ new Set([
|
|
982
|
+
"avis",
|
|
983
|
+
"best",
|
|
984
|
+
"buy",
|
|
985
|
+
"comparatif",
|
|
986
|
+
"compare",
|
|
987
|
+
"coupon",
|
|
988
|
+
"discount",
|
|
989
|
+
"meilleur",
|
|
990
|
+
"price",
|
|
991
|
+
"prix",
|
|
992
|
+
"review",
|
|
993
|
+
"tarif",
|
|
994
|
+
"vs"
|
|
995
|
+
]);
|
|
996
|
+
var NAVIGATIONAL_TOKENS = /* @__PURE__ */ new Set(["account", "app", "connexion", "dashboard", "login", "sign", "signin"]);
|
|
997
|
+
var GSC_RESPONSE_API_VERSION = "searchconsole_v1/webmasters_v3";
|
|
998
|
+
function isAgentResponseRecord(value) {
|
|
999
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1000
|
+
}
|
|
1001
|
+
function hasOwnField(value, field) {
|
|
1002
|
+
return Object.prototype.hasOwnProperty.call(value, field);
|
|
1003
|
+
}
|
|
1004
|
+
function getDebugRecord(payload) {
|
|
1005
|
+
return isAgentResponseRecord(payload["debug"]) ? payload["debug"] : {};
|
|
1006
|
+
}
|
|
1007
|
+
function getRequestCount(payload) {
|
|
1008
|
+
const debug = getDebugRecord(payload);
|
|
1009
|
+
const requestCount = debug["requestCount"];
|
|
1010
|
+
return typeof requestCount === "number" ? requestCount : 1;
|
|
1011
|
+
}
|
|
1012
|
+
function getWarnings(payload) {
|
|
1013
|
+
if (Array.isArray(payload["warnings"])) return payload["warnings"];
|
|
1014
|
+
const debug = getDebugRecord(payload);
|
|
1015
|
+
return Array.isArray(debug["warnings"]) ? debug["warnings"] : [];
|
|
1016
|
+
}
|
|
1017
|
+
function withAgentResponseContract(data) {
|
|
1018
|
+
if (!isAgentResponseRecord(data)) return data;
|
|
1019
|
+
return {
|
|
1020
|
+
...data,
|
|
1021
|
+
warnings: hasOwnField(data, "warnings") ? data["warnings"] : getWarnings(data),
|
|
1022
|
+
limitations: hasOwnField(data, "limitations") ? data["limitations"] : [],
|
|
1023
|
+
nextActions: hasOwnField(data, "nextActions") ? data["nextActions"] : [],
|
|
1024
|
+
debug: {
|
|
1025
|
+
...getDebugRecord(data),
|
|
1026
|
+
source: "google_search_console",
|
|
1027
|
+
apiVersion: GSC_RESPONSE_API_VERSION,
|
|
1028
|
+
requestCount: getRequestCount(data)
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
function ok(data) {
|
|
1033
|
+
return { content: [{ type: "text", text: JSON.stringify(withAgentResponseContract(data), null, 2) }] };
|
|
1034
|
+
}
|
|
1035
|
+
var AuthenticatedGSCClient = class {
|
|
1036
|
+
constructor(config) {
|
|
1037
|
+
this.config = config;
|
|
1038
|
+
this.accessToken = config.accessToken;
|
|
1039
|
+
if (config.accessToken) {
|
|
1040
|
+
this.tokenExpiresAt = Date.now() + 45 * 60 * 1e3;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
config;
|
|
1044
|
+
accessToken;
|
|
1045
|
+
tokenExpiresAt = 0;
|
|
1046
|
+
rateLimiter = new RateLimiter();
|
|
1047
|
+
async ensureAccessToken() {
|
|
1048
|
+
if (this.accessToken && Date.now() < this.tokenExpiresAt) {
|
|
1049
|
+
return this.accessToken;
|
|
1050
|
+
}
|
|
1051
|
+
if (!this.config.refreshToken || !this.config.clientId || !this.config.clientSecret) {
|
|
1052
|
+
throw new Error("No valid GSC access token and refresh credentials are incomplete.");
|
|
1053
|
+
}
|
|
1054
|
+
const tokens = await this.rateLimiter.execute(
|
|
1055
|
+
() => refreshAccessToken(this.config.refreshToken, this.config.clientId, this.config.clientSecret)
|
|
1056
|
+
);
|
|
1057
|
+
this.accessToken = tokens.access_token;
|
|
1058
|
+
this.tokenExpiresAt = Date.now() + ((tokens.expires_in ?? 3600) - 60) * 1e3;
|
|
1059
|
+
return this.accessToken;
|
|
1060
|
+
}
|
|
1061
|
+
async get() {
|
|
1062
|
+
return new GSCClient(await this.ensureAccessToken());
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
function resolveSiteUrl(inputSiteUrl, config) {
|
|
1066
|
+
const siteUrl = inputSiteUrl || config.defaultSiteUrl;
|
|
1067
|
+
if (!siteUrl) {
|
|
1068
|
+
throw new Error("Missing siteUrl. Pass siteUrl, or set GSC_SITE_URL in Claude Desktop config.");
|
|
1069
|
+
}
|
|
1070
|
+
return siteUrl;
|
|
1071
|
+
}
|
|
1072
|
+
function roundMetric(value, decimals = 2) {
|
|
1073
|
+
if (!Number.isFinite(value)) return 0;
|
|
1074
|
+
const factor = 10 ** decimals;
|
|
1075
|
+
return Math.round(value * factor) / factor;
|
|
1076
|
+
}
|
|
1077
|
+
function parseCount(value) {
|
|
1078
|
+
if (value === void 0) return 0;
|
|
1079
|
+
const parsed = Number(value);
|
|
1080
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
1081
|
+
}
|
|
1082
|
+
function formatDate(date) {
|
|
1083
|
+
return date.toISOString().slice(0, 10);
|
|
1084
|
+
}
|
|
1085
|
+
function todayUtc() {
|
|
1086
|
+
const now = /* @__PURE__ */ new Date();
|
|
1087
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
1088
|
+
}
|
|
1089
|
+
function addUtcDays(date, days) {
|
|
1090
|
+
const copy = new Date(date);
|
|
1091
|
+
copy.setUTCDate(copy.getUTCDate() + days);
|
|
1092
|
+
return copy;
|
|
1093
|
+
}
|
|
1094
|
+
function parseDateStrict(value, label) {
|
|
1095
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
1096
|
+
throw new Error(`${label} must use YYYY-MM-DD format.`);
|
|
1097
|
+
}
|
|
1098
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
1099
|
+
if (Number.isNaN(parsed.getTime()) || formatDate(parsed) !== value) {
|
|
1100
|
+
throw new Error(`${label} is not a valid date.`);
|
|
1101
|
+
}
|
|
1102
|
+
return parsed;
|
|
1103
|
+
}
|
|
1104
|
+
function validateDateRange(dateRange) {
|
|
1105
|
+
const start = parseDateStrict(dateRange.startDate, "startDate");
|
|
1106
|
+
const end = parseDateStrict(dateRange.endDate, "endDate");
|
|
1107
|
+
if (start > end) {
|
|
1108
|
+
throw new Error("startDate must be before or equal to endDate.");
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
function inclusiveDayCount(dateRange) {
|
|
1112
|
+
const start = parseDateStrict(dateRange.startDate, "startDate");
|
|
1113
|
+
const end = parseDateStrict(dateRange.endDate, "endDate");
|
|
1114
|
+
return Math.floor((end.getTime() - start.getTime()) / 864e5) + 1;
|
|
1115
|
+
}
|
|
1116
|
+
function derivePreviousDateRange(dateRange) {
|
|
1117
|
+
const start = parseDateStrict(dateRange.startDate, "startDate");
|
|
1118
|
+
const days = inclusiveDayCount(dateRange);
|
|
1119
|
+
const previousEnd = addUtcDays(start, -1);
|
|
1120
|
+
const previousStart = addUtcDays(previousEnd, -(days - 1));
|
|
1121
|
+
return { startDate: formatDate(previousStart), endDate: formatDate(previousEnd) };
|
|
1122
|
+
}
|
|
1123
|
+
function daysBetween(fromDate, toDate) {
|
|
1124
|
+
const from = parseDateStrict(fromDate, "date");
|
|
1125
|
+
return Math.floor((toDate.getTime() - from.getTime()) / 864e5);
|
|
1126
|
+
}
|
|
1127
|
+
function safeDaysBetweenDatePrefix(fromDate, toDate) {
|
|
1128
|
+
if (!fromDate) return null;
|
|
1129
|
+
try {
|
|
1130
|
+
return daysBetween(fromDate.slice(0, 10), toDate);
|
|
1131
|
+
} catch {
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
function rowMetrics(row) {
|
|
1136
|
+
if (!row) {
|
|
1137
|
+
return { clicks: 0, impressions: 0, ctr: 0, position: null };
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
clicks: roundMetric(row.clicks, 0),
|
|
1141
|
+
impressions: roundMetric(row.impressions, 0),
|
|
1142
|
+
ctr: roundMetric(row.ctr * 100),
|
|
1143
|
+
position: roundMetric(row.position)
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
function summarizeRows(rows) {
|
|
1147
|
+
const clicks = rows.reduce((sum, row) => sum + row.clicks, 0);
|
|
1148
|
+
const impressions = rows.reduce((sum, row) => sum + row.impressions, 0);
|
|
1149
|
+
const weightedPosition = rows.reduce((sum, row) => sum + row.position * row.impressions, 0);
|
|
1150
|
+
return {
|
|
1151
|
+
clicks: roundMetric(clicks, 0),
|
|
1152
|
+
impressions: roundMetric(impressions, 0),
|
|
1153
|
+
ctr: impressions > 0 ? roundMetric(clicks / impressions * 100) : 0,
|
|
1154
|
+
position: impressions > 0 ? roundMetric(weightedPosition / impressions) : 0
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
function percentageChange(current, previous) {
|
|
1158
|
+
if (previous === 0) return current === 0 ? 0 : null;
|
|
1159
|
+
return roundMetric((current - previous) / previous * 100);
|
|
1160
|
+
}
|
|
1161
|
+
function keyRowsByDimension(rows) {
|
|
1162
|
+
const keyed = /* @__PURE__ */ new Map();
|
|
1163
|
+
for (const row of rows) {
|
|
1164
|
+
const key = row.keys?.[0];
|
|
1165
|
+
if (key) keyed.set(key, row);
|
|
1166
|
+
}
|
|
1167
|
+
return keyed;
|
|
1168
|
+
}
|
|
1169
|
+
function normalizeText(value) {
|
|
1170
|
+
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
|
1171
|
+
}
|
|
1172
|
+
function uniqueValues(values) {
|
|
1173
|
+
return [...new Set(values.filter(Boolean))];
|
|
1174
|
+
}
|
|
1175
|
+
function tokenizeQuery(query) {
|
|
1176
|
+
return uniqueValues(
|
|
1177
|
+
normalizeText(query).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).map((token) => token.replace(/^-+|-+$/g, "")).filter((token) => token.length > 1 && !STOP_WORDS.has(token))
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
function deriveBrandTerms(siteUrl) {
|
|
1181
|
+
let host = siteUrl;
|
|
1182
|
+
if (siteUrl.startsWith("sc-domain:")) {
|
|
1183
|
+
host = siteUrl.replace("sc-domain:", "");
|
|
1184
|
+
} else {
|
|
1185
|
+
try {
|
|
1186
|
+
host = new URL(siteUrl).hostname;
|
|
1187
|
+
} catch {
|
|
1188
|
+
host = siteUrl;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const ignored = /* @__PURE__ */ new Set(["www", "com", "fr", "io", "ai", "net", "org", "co", "uk"]);
|
|
1192
|
+
return uniqueValues(
|
|
1193
|
+
normalizeText(host).split(/[.-]/).filter((part) => part.length > 2 && !ignored.has(part))
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
function expandBrandTerms(terms) {
|
|
1197
|
+
const expanded = /* @__PURE__ */ new Set();
|
|
1198
|
+
for (const term of terms.map(normalizeText).filter((value) => value.length > 1)) {
|
|
1199
|
+
expanded.add(term);
|
|
1200
|
+
const compacted = term.replace(/[\s-]+/g, "");
|
|
1201
|
+
expanded.add(compacted);
|
|
1202
|
+
const words = term.replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter(Boolean);
|
|
1203
|
+
if (words.length > 1 && words[0].endsWith("s")) {
|
|
1204
|
+
expanded.add([words[0].slice(0, -1), ...words.slice(1)].join(" "));
|
|
1205
|
+
}
|
|
1206
|
+
if (compacted === "galerieslafayette" || compacted === "galerielafayette") {
|
|
1207
|
+
expanded.add("galeries lafayette");
|
|
1208
|
+
expanded.add("galerie lafayette");
|
|
1209
|
+
expanded.add("gallery lafayette");
|
|
1210
|
+
expanded.add("gallerie lafayette");
|
|
1211
|
+
expanded.add("lafayette");
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return uniqueValues([...expanded]);
|
|
1215
|
+
}
|
|
1216
|
+
function classifyQuery(query, tokens, brandTerms) {
|
|
1217
|
+
const normalized = normalizeText(query);
|
|
1218
|
+
const compacted = normalized.replace(/[\s-]+/g, "");
|
|
1219
|
+
const normalizedWords = normalized.replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter(Boolean);
|
|
1220
|
+
const isBrand = brandTerms.some((term) => {
|
|
1221
|
+
const normalizedTerm = normalizeText(term);
|
|
1222
|
+
const compactedTerm = normalizedTerm.replace(/[\s-]+/g, "");
|
|
1223
|
+
return normalizedTerm.length > 1 && (normalized.includes(normalizedTerm) || compacted.includes(compactedTerm));
|
|
1224
|
+
});
|
|
1225
|
+
const isQuestion = normalizedWords.some((token) => QUESTION_TOKENS.has(token)) || normalized.endsWith("?");
|
|
1226
|
+
const hasCommercialIntent = tokens.some((token) => COMMERCIAL_TOKENS.has(token));
|
|
1227
|
+
const hasNavigationalIntent = tokens.some((token) => NAVIGATIONAL_TOKENS.has(token));
|
|
1228
|
+
return {
|
|
1229
|
+
brand: isBrand ? "brand" : "nonBrand",
|
|
1230
|
+
question: isQuestion ? "question" : "nonQuestion",
|
|
1231
|
+
intent: isQuestion ? "question" : hasCommercialIntent ? "commercial" : hasNavigationalIntent ? "navigational" : "informational"
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
function resolveQueryCategory(query, tokens, categoryRules) {
|
|
1235
|
+
const normalized = normalizeText(query);
|
|
1236
|
+
for (const [category, terms] of Object.entries(categoryRules ?? {})) {
|
|
1237
|
+
const normalizedTerms = terms.map(normalizeText).filter(Boolean);
|
|
1238
|
+
if (normalizedTerms.some((term) => tokens.includes(term) || normalized.includes(term))) {
|
|
1239
|
+
return category;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return tokens[0] ? `topic:${tokens[0]}` : "uncategorized";
|
|
1243
|
+
}
|
|
1244
|
+
function createMetricBucket() {
|
|
1245
|
+
return { clicks: 0, impressions: 0, weightedPosition: 0, queryCount: 0, topQueries: [] };
|
|
1246
|
+
}
|
|
1247
|
+
function addQueryToBucket(bucket, query, row) {
|
|
1248
|
+
bucket.clicks += row.clicks;
|
|
1249
|
+
bucket.impressions += row.impressions;
|
|
1250
|
+
bucket.weightedPosition += row.position * row.impressions;
|
|
1251
|
+
bucket.queryCount += 1;
|
|
1252
|
+
bucket.topQueries.push({
|
|
1253
|
+
query,
|
|
1254
|
+
clicks: roundMetric(row.clicks, 0),
|
|
1255
|
+
impressions: roundMetric(row.impressions, 0),
|
|
1256
|
+
ctr: roundMetric(row.ctr * 100),
|
|
1257
|
+
position: roundMetric(row.position)
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
function getOrCreateBucket(map, key) {
|
|
1261
|
+
const existing = map.get(key);
|
|
1262
|
+
if (existing) return existing;
|
|
1263
|
+
const created = createMetricBucket();
|
|
1264
|
+
map.set(key, created);
|
|
1265
|
+
return created;
|
|
1266
|
+
}
|
|
1267
|
+
function finalizeBucket(label, bucket, topQueryLimit = 10) {
|
|
1268
|
+
return {
|
|
1269
|
+
cluster: label,
|
|
1270
|
+
queryCount: bucket.queryCount,
|
|
1271
|
+
clicks: roundMetric(bucket.clicks, 0),
|
|
1272
|
+
impressions: roundMetric(bucket.impressions, 0),
|
|
1273
|
+
ctr: bucket.impressions > 0 ? roundMetric(bucket.clicks / bucket.impressions * 100) : 0,
|
|
1274
|
+
position: bucket.impressions > 0 ? roundMetric(bucket.weightedPosition / bucket.impressions) : 0,
|
|
1275
|
+
topQueries: bucket.topQueries.sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions).slice(0, topQueryLimit)
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
function finalizeBuckets(map, topQueryLimit = 10) {
|
|
1279
|
+
return [...map.entries()].map(([label, bucket]) => finalizeBucket(label, bucket, topQueryLimit)).sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions);
|
|
1280
|
+
}
|
|
1281
|
+
function normalizeInspectionResult(url, response) {
|
|
1282
|
+
const inspection = response.inspectionResult;
|
|
1283
|
+
const indexStatus = inspection.indexStatusResult;
|
|
1284
|
+
const mobileIssues = inspection.mobileUsabilityResult?.issues ?? [];
|
|
1285
|
+
const richIssues = (inspection.richResultsResult?.detectedItems ?? []).flatMap(
|
|
1286
|
+
(detectedItem) => (detectedItem.items ?? []).flatMap(
|
|
1287
|
+
(item) => (item.issues ?? []).map((issue) => ({
|
|
1288
|
+
richResultType: detectedItem.richResultType,
|
|
1289
|
+
itemName: item.name,
|
|
1290
|
+
message: issue.issueMessage,
|
|
1291
|
+
severity: issue.severity
|
|
1292
|
+
}))
|
|
1293
|
+
)
|
|
1294
|
+
);
|
|
1295
|
+
const ampIssues = inspection.ampResult?.issues ?? [];
|
|
1296
|
+
return {
|
|
1297
|
+
url,
|
|
1298
|
+
inspectionResultLink: inspection.inspectionResultLink,
|
|
1299
|
+
indexStatus: {
|
|
1300
|
+
verdict: indexStatus?.verdict,
|
|
1301
|
+
coverageState: indexStatus?.coverageState,
|
|
1302
|
+
indexingState: indexStatus?.indexingState,
|
|
1303
|
+
pageFetchState: indexStatus?.pageFetchState,
|
|
1304
|
+
robotsTxtState: indexStatus?.robotsTxtState,
|
|
1305
|
+
lastCrawlTime: indexStatus?.lastCrawlTime,
|
|
1306
|
+
crawledAs: indexStatus?.crawledAs,
|
|
1307
|
+
googleCanonical: indexStatus?.googleCanonical,
|
|
1308
|
+
userCanonical: indexStatus?.userCanonical
|
|
1309
|
+
},
|
|
1310
|
+
mobileUsability: {
|
|
1311
|
+
verdict: inspection.mobileUsabilityResult?.verdict,
|
|
1312
|
+
issueCount: mobileIssues.length,
|
|
1313
|
+
issues: mobileIssues
|
|
1314
|
+
},
|
|
1315
|
+
richResults: {
|
|
1316
|
+
verdict: inspection.richResultsResult?.verdict,
|
|
1317
|
+
itemCount: inspection.richResultsResult?.detectedItems?.length ?? 0,
|
|
1318
|
+
issueCount: richIssues.length,
|
|
1319
|
+
issues: richIssues
|
|
1320
|
+
},
|
|
1321
|
+
amp: {
|
|
1322
|
+
verdict: inspection.ampResult?.verdict,
|
|
1323
|
+
ampUrl: inspection.ampResult?.ampUrl,
|
|
1324
|
+
ampIndexStatusVerdict: inspection.ampResult?.ampIndexStatusVerdict,
|
|
1325
|
+
issueCount: ampIssues.length,
|
|
1326
|
+
issues: ampIssues
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
function normalizePerUrlError(error) {
|
|
1331
|
+
const apiError = error;
|
|
1332
|
+
return {
|
|
1333
|
+
error: apiError.message ?? (error instanceof Error ? error.message : String(error)),
|
|
1334
|
+
code: apiError.code,
|
|
1335
|
+
status: apiError.status,
|
|
1336
|
+
isRateLimit: Boolean(apiError.isRateLimitError || apiError.isQuotaError),
|
|
1337
|
+
suggestion: apiError.suggestion
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
function summarizeSitemapForP2(sitemap) {
|
|
1341
|
+
const warningCount = parseCount(sitemap.warnings);
|
|
1342
|
+
const errorCount = parseCount(sitemap.errors);
|
|
1343
|
+
const contents = (sitemap.contents || []).map((content) => {
|
|
1344
|
+
const submitted2 = parseCount(content.submitted);
|
|
1345
|
+
const indexed2 = parseCount(content.indexed);
|
|
1346
|
+
return {
|
|
1347
|
+
type: content.type,
|
|
1348
|
+
submitted: submitted2,
|
|
1349
|
+
indexed: indexed2,
|
|
1350
|
+
indexRate: submitted2 > 0 ? roundMetric(indexed2 / submitted2 * 100) : null
|
|
1351
|
+
};
|
|
1352
|
+
});
|
|
1353
|
+
const submitted = contents.reduce((sum, content) => sum + content.submitted, 0);
|
|
1354
|
+
const indexed = contents.reduce((sum, content) => sum + content.indexed, 0);
|
|
1355
|
+
const status = sitemap.isPending ? "pending" : errorCount > 0 ? "error" : warningCount > 0 ? "warning" : "healthy";
|
|
1356
|
+
return {
|
|
1357
|
+
path: sitemap.path,
|
|
1358
|
+
status,
|
|
1359
|
+
lastSubmitted: sitemap.lastSubmitted,
|
|
1360
|
+
lastDownloaded: sitemap.lastDownloaded,
|
|
1361
|
+
isPending: Boolean(sitemap.isPending),
|
|
1362
|
+
isSitemapsIndex: Boolean(sitemap.isSitemapsIndex),
|
|
1363
|
+
type: sitemap.type,
|
|
1364
|
+
warnings: warningCount,
|
|
1365
|
+
errors: errorCount,
|
|
1366
|
+
contents,
|
|
1367
|
+
totals: {
|
|
1368
|
+
submitted,
|
|
1369
|
+
indexed,
|
|
1370
|
+
indexRate: submitted > 0 ? roundMetric(indexed / submitted * 100) : null
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
function summarizeSitemapCollection(sitemaps) {
|
|
1375
|
+
const submitted = sitemaps.reduce((sum, sitemap) => sum + sitemap.totals.submitted, 0);
|
|
1376
|
+
const indexed = sitemaps.reduce((sum, sitemap) => sum + sitemap.totals.indexed, 0);
|
|
1377
|
+
const statusCounts = {
|
|
1378
|
+
healthy: sitemaps.filter((sitemap) => sitemap.status === "healthy").length,
|
|
1379
|
+
pending: sitemaps.filter((sitemap) => sitemap.status === "pending").length,
|
|
1380
|
+
warning: sitemaps.filter((sitemap) => sitemap.status === "warning").length,
|
|
1381
|
+
error: sitemaps.filter((sitemap) => sitemap.status === "error").length
|
|
1382
|
+
};
|
|
1383
|
+
return {
|
|
1384
|
+
count: sitemaps.length,
|
|
1385
|
+
statusCounts,
|
|
1386
|
+
warnings: sitemaps.reduce((sum, sitemap) => sum + sitemap.warnings, 0),
|
|
1387
|
+
errors: sitemaps.reduce((sum, sitemap) => sum + sitemap.errors, 0),
|
|
1388
|
+
sitemapIndexes: sitemaps.filter((sitemap) => sitemap.isSitemapsIndex).length,
|
|
1389
|
+
submitted,
|
|
1390
|
+
indexed,
|
|
1391
|
+
indexRate: submitted > 0 ? roundMetric(indexed / submitted * 100) : null,
|
|
1392
|
+
newestLastSubmitted: latestStringDate(sitemaps.map((sitemap) => sitemap.lastSubmitted)),
|
|
1393
|
+
newestLastDownloaded: latestStringDate(sitemaps.map((sitemap) => sitemap.lastDownloaded))
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
function latestStringDate(values) {
|
|
1397
|
+
const sorted = values.filter((value) => Boolean(value)).sort((a, b) => a.localeCompare(b));
|
|
1398
|
+
return sorted[sorted.length - 1] ?? null;
|
|
1399
|
+
}
|
|
1400
|
+
function sitemapSnapshot(summary) {
|
|
1401
|
+
return {
|
|
1402
|
+
path: summary.path,
|
|
1403
|
+
submitted: summary.totals.submitted,
|
|
1404
|
+
indexed: summary.totals.indexed,
|
|
1405
|
+
warnings: summary.warnings,
|
|
1406
|
+
errors: summary.errors,
|
|
1407
|
+
lastSubmitted: summary.lastSubmitted,
|
|
1408
|
+
lastDownloaded: summary.lastDownloaded
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
function analyzeDailyFreshness(rows, endDate, lagWarningDays) {
|
|
1412
|
+
const sortedRows = rows.filter((row) => row.keys?.[0]).sort((a, b) => String(a.keys?.[0]).localeCompare(String(b.keys?.[0])));
|
|
1413
|
+
const rowsWithData = sortedRows.filter((row) => row.clicks > 0 || row.impressions > 0);
|
|
1414
|
+
const latestRow = rowsWithData[rowsWithData.length - 1];
|
|
1415
|
+
const latestDataDate = latestRow?.keys?.[0] ?? null;
|
|
1416
|
+
const dataLagDays = latestDataDate ? daysBetween(latestDataDate, endDate) : null;
|
|
1417
|
+
const status = latestDataDate === null ? "noData" : dataLagDays !== null && dataLagDays <= lagWarningDays ? "fresh" : dataLagDays !== null && dataLagDays <= lagWarningDays + 2 ? "delayed" : "stale";
|
|
1418
|
+
return {
|
|
1419
|
+
latestDataDate,
|
|
1420
|
+
dataLagDays,
|
|
1421
|
+
status,
|
|
1422
|
+
hasRecentData: dataLagDays !== null && dataLagDays <= lagWarningDays,
|
|
1423
|
+
rowCount: sortedRows.length,
|
|
1424
|
+
dailyRows: sortedRows.map((row) => ({
|
|
1425
|
+
date: row.keys?.[0],
|
|
1426
|
+
...rowMetrics(row)
|
|
1427
|
+
}))
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
function assessInspectionResult(result, watch) {
|
|
1431
|
+
const issues = [];
|
|
1432
|
+
const verdict = result.indexStatus.verdict;
|
|
1433
|
+
const coverageState = result.indexStatus.coverageState;
|
|
1434
|
+
const pageFetchState = result.indexStatus.pageFetchState;
|
|
1435
|
+
const robotsTxtState = result.indexStatus.robotsTxtState;
|
|
1436
|
+
const expectedVerdict = watch?.expectedVerdict ?? "PASS";
|
|
1437
|
+
if (!verdict) {
|
|
1438
|
+
issues.push("URL Inspection did not return an index status verdict.");
|
|
1439
|
+
} else if (verdict !== expectedVerdict) {
|
|
1440
|
+
issues.push(`Expected index verdict ${expectedVerdict}, got ${verdict}.`);
|
|
1441
|
+
}
|
|
1442
|
+
if (watch?.expectedCoverageState && coverageState !== watch.expectedCoverageState) {
|
|
1443
|
+
issues.push(`Expected coverage state '${watch.expectedCoverageState}', got '${coverageState ?? "unknown"}'.`);
|
|
1444
|
+
}
|
|
1445
|
+
if (pageFetchState && !/successful/i.test(pageFetchState)) {
|
|
1446
|
+
issues.push(`Page fetch state is '${pageFetchState}'.`);
|
|
1447
|
+
}
|
|
1448
|
+
if (robotsTxtState && /disallow|blocked/i.test(robotsTxtState)) {
|
|
1449
|
+
issues.push(`robots.txt state is '${robotsTxtState}'.`);
|
|
1450
|
+
}
|
|
1451
|
+
if (watch?.expectedCanonical && result.indexStatus.googleCanonical && result.indexStatus.googleCanonical !== watch.expectedCanonical) {
|
|
1452
|
+
issues.push(`Google canonical differs from expected canonical '${watch.expectedCanonical}'.`);
|
|
1453
|
+
}
|
|
1454
|
+
if (result.mobileUsability.verdict && !["PASS", "NEUTRAL"].includes(result.mobileUsability.verdict)) {
|
|
1455
|
+
issues.push(`Mobile usability verdict is ${result.mobileUsability.verdict}.`);
|
|
1456
|
+
}
|
|
1457
|
+
if (result.richResults.issueCount > 0) {
|
|
1458
|
+
issues.push(`${result.richResults.issueCount} rich result issue(s) detected.`);
|
|
1459
|
+
}
|
|
1460
|
+
const priority = watch?.priority ?? "medium";
|
|
1461
|
+
const status = issues.length === 0 ? "ok" : priority === "critical" || priority === "high" ? "alert" : "review";
|
|
1462
|
+
return {
|
|
1463
|
+
status,
|
|
1464
|
+
indexed: verdict === "PASS",
|
|
1465
|
+
priority,
|
|
1466
|
+
issues
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function rowCompositeKey(row, dimensionCount) {
|
|
1470
|
+
return JSON.stringify((row.keys ?? []).slice(0, dimensionCount));
|
|
1471
|
+
}
|
|
1472
|
+
function dedupeUrlList(urls, limit) {
|
|
1473
|
+
return uniqueValues(urls).slice(0, limit);
|
|
1474
|
+
}
|
|
1475
|
+
function registerGSCTools(server, config) {
|
|
1476
|
+
const clientFactory = new AuthenticatedGSCClient(config);
|
|
1477
|
+
server.tool(
|
|
1478
|
+
"gsc_list_sites",
|
|
1479
|
+
"List all Google Search Console properties accessible with the current credentials.",
|
|
1480
|
+
{},
|
|
1481
|
+
async () => {
|
|
1482
|
+
try {
|
|
1483
|
+
const client = await clientFactory.get();
|
|
1484
|
+
const sites = await client.listSites();
|
|
1485
|
+
return ok({ sites, count: sites.length });
|
|
1486
|
+
} catch (e) {
|
|
1487
|
+
return formatMcpToolError(e);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
);
|
|
1491
|
+
server.tool(
|
|
1492
|
+
"gsc_get_site",
|
|
1493
|
+
"Get one Search Console property and its exact permission level (read-only sites.get).",
|
|
1494
|
+
{ siteUrl: siteUrlSchema },
|
|
1495
|
+
async ({ siteUrl }) => {
|
|
1496
|
+
try {
|
|
1497
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
1498
|
+
const client = await clientFactory.get();
|
|
1499
|
+
const site = await client.getSite(resolvedSiteUrl);
|
|
1500
|
+
return ok({ site });
|
|
1501
|
+
} catch (e) {
|
|
1502
|
+
return formatMcpToolError(e);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
);
|
|
1506
|
+
server.tool(
|
|
1507
|
+
"gsc_health_check",
|
|
1508
|
+
"Verify Google Search Console read-only authentication, visible properties, configured default site, and permission levels without exposing tokens.",
|
|
1509
|
+
{ siteUrl: siteUrlSchema },
|
|
1510
|
+
async ({ siteUrl }) => {
|
|
1511
|
+
try {
|
|
1512
|
+
const warnings = [];
|
|
1513
|
+
const targetSiteUrl = siteUrl || config.defaultSiteUrl;
|
|
1514
|
+
const hasRefreshCredentials = Boolean(config.refreshToken && config.clientId && config.clientSecret);
|
|
1515
|
+
const client = await clientFactory.get();
|
|
1516
|
+
const sites = await client.listSites();
|
|
1517
|
+
const visibleSites = sites.map((site) => ({
|
|
1518
|
+
siteUrl: site.siteUrl,
|
|
1519
|
+
permissionLevel: site.permissionLevel,
|
|
1520
|
+
status: site.permissionLevel === "siteUnverifiedUser" ? "unverified" : "verified",
|
|
1521
|
+
canRead: site.permissionLevel !== "siteUnverifiedUser"
|
|
1522
|
+
}));
|
|
1523
|
+
const targetSite = targetSiteUrl ? visibleSites.find((site) => site.siteUrl === targetSiteUrl) : void 0;
|
|
1524
|
+
if (sites.length === 0) {
|
|
1525
|
+
warnings.push("No Search Console properties are visible to these credentials.");
|
|
1526
|
+
}
|
|
1527
|
+
if (!targetSiteUrl) {
|
|
1528
|
+
warnings.push("No default site is configured. Set GSC_SITE_URL or pass siteUrl to property-specific tools.");
|
|
1529
|
+
}
|
|
1530
|
+
if (targetSiteUrl && !targetSite) {
|
|
1531
|
+
warnings.push(`Configured/requested site '${targetSiteUrl}' is not visible in listSites(). Check exact URL/property format and permissions.`);
|
|
1532
|
+
}
|
|
1533
|
+
if (targetSite?.permissionLevel === "siteUnverifiedUser") {
|
|
1534
|
+
warnings.push(`Site '${targetSite.siteUrl}' is visible but unverified for this account.`);
|
|
1535
|
+
}
|
|
1536
|
+
if (targetSite?.permissionLevel === "siteRestrictedUser") {
|
|
1537
|
+
warnings.push(`Site '${targetSite.siteUrl}' has restricted permissions; some GSC reports may be unavailable.`);
|
|
1538
|
+
}
|
|
1539
|
+
if (!hasRefreshCredentials && config.accessToken) {
|
|
1540
|
+
warnings.push("Using a static access token without refresh credentials; re-authentication may be required when it expires.");
|
|
1541
|
+
}
|
|
1542
|
+
return ok({
|
|
1543
|
+
ok: true,
|
|
1544
|
+
authentication: {
|
|
1545
|
+
status: "authenticated",
|
|
1546
|
+
credentialMode: config.accessToken ? hasRefreshCredentials ? "access_token_with_refresh_fallback" : "access_token" : "refresh_token",
|
|
1547
|
+
requiredScope: GSC_OAUTH_SCOPE
|
|
1548
|
+
},
|
|
1549
|
+
defaultSite: {
|
|
1550
|
+
configured: Boolean(config.defaultSiteUrl),
|
|
1551
|
+
requestedSiteUrl: targetSiteUrl,
|
|
1552
|
+
found: Boolean(targetSite),
|
|
1553
|
+
permissionLevel: targetSite?.permissionLevel,
|
|
1554
|
+
status: targetSite?.status
|
|
1555
|
+
},
|
|
1556
|
+
sites: {
|
|
1557
|
+
count: visibleSites.length,
|
|
1558
|
+
entries: visibleSites
|
|
1559
|
+
},
|
|
1560
|
+
warnings
|
|
1561
|
+
});
|
|
1562
|
+
} catch (e) {
|
|
1563
|
+
return formatMcpToolError(e);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
);
|
|
1567
|
+
server.tool(
|
|
1568
|
+
"gsc_query_search_analytics",
|
|
1569
|
+
`Query Google Search Console Search Analytics. Returns clicks, impressions, CTR, and average position for queries, pages, countries, devices, dates, and search appearances.
|
|
1570
|
+
Use gsc://metrics, gsc://dimensions, gsc://filters, and gsc://compatibility for available fields and rules.`,
|
|
1571
|
+
{
|
|
1572
|
+
siteUrl: siteUrlSchema,
|
|
1573
|
+
metrics: z2.array(z2.string()).optional().describe("Native: clicks, impressions, ctr, position. Calculated: ctrOpportunity, clicksPerImpression, impressionShare, clickShare."),
|
|
1574
|
+
dimensions: z2.array(z2.string()).max(5).optional().describe("Up to 5 dimensions selected from query, page, country, device, date, hour, searchAppearance."),
|
|
1575
|
+
filters: z2.array(z2.object({
|
|
1576
|
+
field: z2.string(),
|
|
1577
|
+
operator: z2.enum(["contains", "equals", "notContains", "notEquals", "includingRegex", "excludingRegex"]),
|
|
1578
|
+
value: z2.union([z2.string(), z2.number(), z2.boolean()])
|
|
1579
|
+
})).optional(),
|
|
1580
|
+
searchType: searchTypeSchema.optional().default("web"),
|
|
1581
|
+
dataState: dataStateSchema.optional().default("all").describe("final=stable data, all=fresh daily data, hourly_all=fresh hourly data (use hour dimension)."),
|
|
1582
|
+
aggregationType: aggregationTypeSchema.optional().default("auto").describe("Official GSC aggregation. byProperty is incompatible with page grouping/filtering and Discover/Google News. byNewsShowcasePanel requires discover/googleNews plus searchAppearance equals NEWS_SHOWCASE, and forbids page grouping/filtering or another searchAppearance filter."),
|
|
1583
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
1584
|
+
dateRange: z2.object({
|
|
1585
|
+
startDate: z2.string().describe("Start date YYYY-MM-DD"),
|
|
1586
|
+
endDate: z2.string().describe("End date YYYY-MM-DD")
|
|
1587
|
+
}).optional(),
|
|
1588
|
+
orderBy: z2.enum(["clicks", "impressions", "ctr", "position", "ctrOpportunity", "clicksPerImpression", "impressionShare", "clickShare"]).optional().default("clicks"),
|
|
1589
|
+
orderDirection: z2.enum(["ASC", "DESC"]).optional().default("DESC"),
|
|
1590
|
+
limit: limitSchema,
|
|
1591
|
+
startRow: z2.number().int().min(0).optional().describe("Optional 0-based row offset for explicit pagination."),
|
|
1592
|
+
autoPaginate: z2.boolean().optional().default(false).describe("When true, omit rowLimit so the GSC client auto-paginates until exhaustion.")
|
|
1593
|
+
},
|
|
1594
|
+
async ({ siteUrl, metrics, dimensions, filters, searchType, dataState, aggregationType, datePreset, dateRange, orderBy, orderDirection, limit, startRow, autoPaginate }) => {
|
|
1595
|
+
try {
|
|
1596
|
+
const startTime = Date.now();
|
|
1597
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
1598
|
+
const plan = planGSCQuery({
|
|
1599
|
+
siteUrl: resolvedSiteUrl,
|
|
1600
|
+
metrics,
|
|
1601
|
+
dimensions,
|
|
1602
|
+
filters,
|
|
1603
|
+
searchType,
|
|
1604
|
+
datePreset,
|
|
1605
|
+
dateRange,
|
|
1606
|
+
orderBy,
|
|
1607
|
+
limit: autoPaginate ? void 0 : limit,
|
|
1608
|
+
aggregationType,
|
|
1609
|
+
dataState
|
|
1610
|
+
});
|
|
1611
|
+
if (typeof startRow === "number") plan.request.startRow = startRow;
|
|
1612
|
+
if (plan.errors.length > 0) {
|
|
1613
|
+
return ok({ error: "Query validation failed", errors: plan.errors, warnings: plan.warnings });
|
|
1614
|
+
}
|
|
1615
|
+
const client = await clientFactory.get();
|
|
1616
|
+
const response = await client.querySearchAnalytics(plan.siteUrl, plan.request);
|
|
1617
|
+
const flatData = flattenSearchAnalyticsResponse(response, plan.request.dimensions);
|
|
1618
|
+
const enrichedData = plan.calculatedMetrics.length > 0 ? enrichWithCalculatedMetrics(flatData, plan.calculatedMetrics) : flatData;
|
|
1619
|
+
const data = enrichedData;
|
|
1620
|
+
if (orderBy && data.length > 0) {
|
|
1621
|
+
data.sort((a, b) => {
|
|
1622
|
+
const aVal = Number(a[orderBy]) || 0;
|
|
1623
|
+
const bVal = Number(b[orderBy]) || 0;
|
|
1624
|
+
const asc = orderDirection === "ASC" || orderBy === "position";
|
|
1625
|
+
return asc ? aVal - bVal : bVal - aVal;
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
return ok({
|
|
1629
|
+
data,
|
|
1630
|
+
rowCount: data.length,
|
|
1631
|
+
responseAggregationType: response.responseAggregationType,
|
|
1632
|
+
metadata: response.metadata,
|
|
1633
|
+
debug: {
|
|
1634
|
+
executionTimeMs: Date.now() - startTime,
|
|
1635
|
+
warnings: plan.warnings,
|
|
1636
|
+
calculatedMetrics: plan.calculatedMetrics,
|
|
1637
|
+
requestedMetrics: plan.requestedMetrics,
|
|
1638
|
+
pagination: {
|
|
1639
|
+
mode: autoPaginate ? "auto_startRow" : "single_page",
|
|
1640
|
+
autoPaginate,
|
|
1641
|
+
startRow: startRow ?? 0,
|
|
1642
|
+
limit: autoPaginate ? null : limit
|
|
1643
|
+
},
|
|
1644
|
+
dataState,
|
|
1645
|
+
aggregationType
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
} catch (e) {
|
|
1649
|
+
return formatMcpToolError(e);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
);
|
|
1653
|
+
server.tool(
|
|
1654
|
+
"gsc_inspect_url",
|
|
1655
|
+
"Inspect a URL for Google indexing status, crawl info, mobile usability, AMP, and rich results. Quota: 2000 requests/day/property.",
|
|
1656
|
+
{
|
|
1657
|
+
siteUrl: siteUrlSchema,
|
|
1658
|
+
url: z2.string().url().describe("Full URL to inspect."),
|
|
1659
|
+
languageCode: z2.string().optional().describe("Optional IETF language tag, e.g. en-US or fr-FR.")
|
|
1660
|
+
},
|
|
1661
|
+
async ({ siteUrl, url, languageCode }) => {
|
|
1662
|
+
try {
|
|
1663
|
+
const client = await clientFactory.get();
|
|
1664
|
+
const result = await client.inspectUrl(url, resolveSiteUrl(siteUrl, config), languageCode);
|
|
1665
|
+
const inspection = result.inspectionResult;
|
|
1666
|
+
return ok({
|
|
1667
|
+
url,
|
|
1668
|
+
inspectionResultLink: inspection.inspectionResultLink,
|
|
1669
|
+
indexStatus: inspection.indexStatusResult,
|
|
1670
|
+
mobileUsability: inspection.mobileUsabilityResult,
|
|
1671
|
+
richResults: inspection.richResultsResult,
|
|
1672
|
+
amp: inspection.ampResult
|
|
1673
|
+
});
|
|
1674
|
+
} catch (e) {
|
|
1675
|
+
return formatMcpToolError(e);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
);
|
|
1679
|
+
server.tool(
|
|
1680
|
+
"gsc_bulk_inspect_urls",
|
|
1681
|
+
"Inspect multiple URLs with a conservative sequential limit and normalized URL Inspection results. Quota: 2000 requests/day/property.",
|
|
1682
|
+
{
|
|
1683
|
+
siteUrl: siteUrlSchema,
|
|
1684
|
+
urls: z2.array(z2.string().url()).min(1).max(BULK_URL_INSPECTION_LIMIT).describe(`Full URLs to inspect. Maximum ${BULK_URL_INSPECTION_LIMIT} per call to protect URL Inspection quota.`),
|
|
1685
|
+
languageCode: z2.string().optional().describe("Optional IETF language tag, e.g. en-US or fr-FR."),
|
|
1686
|
+
continueOnError: z2.boolean().optional().default(true).describe("When true, returns per-URL errors instead of failing the entire batch.")
|
|
1687
|
+
},
|
|
1688
|
+
async ({ siteUrl, urls, languageCode, continueOnError }) => {
|
|
1689
|
+
try {
|
|
1690
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
1691
|
+
const client = await clientFactory.get();
|
|
1692
|
+
const uniqueUrls = uniqueValues(urls);
|
|
1693
|
+
const warnings = [
|
|
1694
|
+
`URL Inspection API quota is limited to ${URL_INSPECTION_DAILY_QUOTA} requests/day/property; this tool caps each call at ${BULK_URL_INSPECTION_LIMIT} URLs and runs sequentially.`
|
|
1695
|
+
];
|
|
1696
|
+
if (uniqueUrls.length < urls.length) {
|
|
1697
|
+
warnings.push(`${urls.length - uniqueUrls.length} duplicate URL(s) were skipped before inspection.`);
|
|
1698
|
+
}
|
|
1699
|
+
const results = [];
|
|
1700
|
+
for (const url of uniqueUrls) {
|
|
1701
|
+
try {
|
|
1702
|
+
const response = await client.inspectUrl(url, resolvedSiteUrl, languageCode);
|
|
1703
|
+
results.push({ ok: true, ...normalizeInspectionResult(url, response) });
|
|
1704
|
+
} catch (error) {
|
|
1705
|
+
if (!continueOnError) throw error;
|
|
1706
|
+
results.push({ ok: false, url, ...normalizePerUrlError(error) });
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
return ok({
|
|
1710
|
+
siteUrl: resolvedSiteUrl,
|
|
1711
|
+
inspectedCount: results.length,
|
|
1712
|
+
failedCount: results.filter((result) => !result.ok).length,
|
|
1713
|
+
warnings,
|
|
1714
|
+
results
|
|
1715
|
+
});
|
|
1716
|
+
} catch (e) {
|
|
1717
|
+
return formatMcpToolError(e);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
);
|
|
1721
|
+
server.tool(
|
|
1722
|
+
"gsc_list_sitemaps",
|
|
1723
|
+
"List sitemaps submitted for a Search Console property, including processing status and submitted/indexed counts.",
|
|
1724
|
+
{
|
|
1725
|
+
siteUrl: siteUrlSchema,
|
|
1726
|
+
sitemapIndex: z2.string().url().optional().describe("Optional sitemap index URL used to return only its child sitemaps.")
|
|
1727
|
+
},
|
|
1728
|
+
async ({ siteUrl, sitemapIndex }) => {
|
|
1729
|
+
try {
|
|
1730
|
+
const client = await clientFactory.get();
|
|
1731
|
+
const result = await client.listSitemaps(resolveSiteUrl(siteUrl, config), sitemapIndex);
|
|
1732
|
+
const sitemaps = (result.sitemap || []).map((s) => ({
|
|
1733
|
+
path: s.path,
|
|
1734
|
+
lastSubmitted: s.lastSubmitted,
|
|
1735
|
+
isPending: s.isPending,
|
|
1736
|
+
isSitemapsIndex: s.isSitemapsIndex,
|
|
1737
|
+
type: s.type,
|
|
1738
|
+
lastDownloaded: s.lastDownloaded,
|
|
1739
|
+
warnings: s.warnings,
|
|
1740
|
+
errors: s.errors,
|
|
1741
|
+
contents: s.contents
|
|
1742
|
+
}));
|
|
1743
|
+
return ok({ sitemaps, count: sitemaps.length });
|
|
1744
|
+
} catch (e) {
|
|
1745
|
+
return formatMcpToolError(e);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
);
|
|
1749
|
+
server.tool(
|
|
1750
|
+
"gsc_get_sitemap",
|
|
1751
|
+
"Get one submitted sitemap by full feed path, including type, fetch state, warnings/errors, and submitted/indexed content totals.",
|
|
1752
|
+
{
|
|
1753
|
+
siteUrl: siteUrlSchema,
|
|
1754
|
+
feedpath: z2.string().url().describe("Full sitemap URL exactly as submitted to Search Console.")
|
|
1755
|
+
},
|
|
1756
|
+
async ({ siteUrl, feedpath }) => {
|
|
1757
|
+
try {
|
|
1758
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
1759
|
+
const client = await clientFactory.get();
|
|
1760
|
+
const sitemap = await client.getSitemap(resolvedSiteUrl, feedpath);
|
|
1761
|
+
return ok({ siteUrl: resolvedSiteUrl, sitemap });
|
|
1762
|
+
} catch (e) {
|
|
1763
|
+
return formatMcpToolError(e);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
);
|
|
1767
|
+
server.tool(
|
|
1768
|
+
"gsc_get_sitemap_health",
|
|
1769
|
+
"Return an enriched read-only sitemap health summary with status totals, submitted/indexed content counts, warnings, and errors.",
|
|
1770
|
+
{ siteUrl: siteUrlSchema },
|
|
1771
|
+
async ({ siteUrl }) => {
|
|
1772
|
+
try {
|
|
1773
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
1774
|
+
const client = await clientFactory.get();
|
|
1775
|
+
const result = await client.listSitemaps(resolvedSiteUrl);
|
|
1776
|
+
const sitemaps = (result.sitemap || []).map((sitemap) => {
|
|
1777
|
+
const warningCount = parseCount(sitemap.warnings);
|
|
1778
|
+
const errorCount = parseCount(sitemap.errors);
|
|
1779
|
+
const submitted = (sitemap.contents || []).reduce((sum, content) => sum + parseCount(content.submitted), 0);
|
|
1780
|
+
const indexed = (sitemap.contents || []).reduce((sum, content) => sum + parseCount(content.indexed), 0);
|
|
1781
|
+
const status = sitemap.isPending ? "pending" : errorCount > 0 ? "error" : warningCount > 0 ? "warning" : "healthy";
|
|
1782
|
+
return {
|
|
1783
|
+
path: sitemap.path,
|
|
1784
|
+
status,
|
|
1785
|
+
lastSubmitted: sitemap.lastSubmitted,
|
|
1786
|
+
lastDownloaded: sitemap.lastDownloaded,
|
|
1787
|
+
isPending: Boolean(sitemap.isPending),
|
|
1788
|
+
isSitemapsIndex: Boolean(sitemap.isSitemapsIndex),
|
|
1789
|
+
type: sitemap.type,
|
|
1790
|
+
warnings: warningCount,
|
|
1791
|
+
errors: errorCount,
|
|
1792
|
+
contents: sitemap.contents || [],
|
|
1793
|
+
totals: {
|
|
1794
|
+
submitted,
|
|
1795
|
+
indexed,
|
|
1796
|
+
indexRate: submitted > 0 ? roundMetric(indexed / submitted * 100) : null
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
});
|
|
1800
|
+
const totals = {
|
|
1801
|
+
sitemaps: sitemaps.length,
|
|
1802
|
+
healthy: sitemaps.filter((sitemap) => sitemap.status === "healthy").length,
|
|
1803
|
+
pending: sitemaps.filter((sitemap) => sitemap.status === "pending").length,
|
|
1804
|
+
warnings: sitemaps.reduce((sum, sitemap) => sum + sitemap.warnings, 0),
|
|
1805
|
+
errors: sitemaps.reduce((sum, sitemap) => sum + sitemap.errors, 0),
|
|
1806
|
+
sitemapIndexes: sitemaps.filter((sitemap) => sitemap.isSitemapsIndex).length,
|
|
1807
|
+
submitted: sitemaps.reduce((sum, sitemap) => sum + sitemap.totals.submitted, 0),
|
|
1808
|
+
indexed: sitemaps.reduce((sum, sitemap) => sum + sitemap.totals.indexed, 0)
|
|
1809
|
+
};
|
|
1810
|
+
const overallStatus = totals.errors > 0 ? "error" : totals.pending > 0 ? "pending" : totals.warnings > 0 ? "warning" : "healthy";
|
|
1811
|
+
return ok({
|
|
1812
|
+
siteUrl: resolvedSiteUrl,
|
|
1813
|
+
overallStatus,
|
|
1814
|
+
totals: {
|
|
1815
|
+
...totals,
|
|
1816
|
+
indexRate: totals.submitted > 0 ? roundMetric(totals.indexed / totals.submitted * 100) : null
|
|
1817
|
+
},
|
|
1818
|
+
sitemaps,
|
|
1819
|
+
warnings: sitemaps.length === 0 ? ["No submitted sitemaps returned for this property."] : []
|
|
1820
|
+
});
|
|
1821
|
+
} catch (e) {
|
|
1822
|
+
return formatMcpToolError(e);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
);
|
|
1826
|
+
server.tool(
|
|
1827
|
+
"gsc_compare_search_types",
|
|
1828
|
+
"Compare performance across Search Console search types: web, image, video, news, discover, and Google News.",
|
|
1829
|
+
{
|
|
1830
|
+
siteUrl: siteUrlSchema,
|
|
1831
|
+
searchTypes: z2.array(searchTypeSchema).optional().default(["web", "image", "video", "discover"]),
|
|
1832
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
1833
|
+
dateRange: z2.object({ startDate: z2.string(), endDate: z2.string() }).optional()
|
|
1834
|
+
},
|
|
1835
|
+
async ({ siteUrl, searchTypes, datePreset, dateRange }) => {
|
|
1836
|
+
try {
|
|
1837
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
1838
|
+
const resolvedDateRange = dateRange || resolveDatePreset(datePreset);
|
|
1839
|
+
const client = await clientFactory.get();
|
|
1840
|
+
const data = await Promise.all(
|
|
1841
|
+
searchTypes.map(async (type) => {
|
|
1842
|
+
try {
|
|
1843
|
+
const response = await client.querySearchAnalytics(resolvedSiteUrl, {
|
|
1844
|
+
startDate: resolvedDateRange.startDate,
|
|
1845
|
+
endDate: resolvedDateRange.endDate,
|
|
1846
|
+
type,
|
|
1847
|
+
dataState: "all"
|
|
1848
|
+
});
|
|
1849
|
+
const rows = response.rows || [];
|
|
1850
|
+
const clicks = rows.reduce((sum, row) => sum + row.clicks, 0);
|
|
1851
|
+
const impressions = rows.reduce((sum, row) => sum + row.impressions, 0);
|
|
1852
|
+
const weightedPosition = rows.reduce((sum, row) => sum + row.position * row.impressions, 0);
|
|
1853
|
+
return {
|
|
1854
|
+
searchType: type,
|
|
1855
|
+
clicks,
|
|
1856
|
+
impressions,
|
|
1857
|
+
ctr: impressions > 0 ? Math.round(clicks / impressions * 1e4) / 100 : 0,
|
|
1858
|
+
position: impressions > 0 ? Math.round(weightedPosition / impressions * 100) / 100 : 0
|
|
1859
|
+
};
|
|
1860
|
+
} catch (error) {
|
|
1861
|
+
return {
|
|
1862
|
+
searchType: type,
|
|
1863
|
+
clicks: 0,
|
|
1864
|
+
impressions: 0,
|
|
1865
|
+
ctr: 0,
|
|
1866
|
+
position: 0,
|
|
1867
|
+
error: error instanceof Error ? error.message : "No data available"
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
})
|
|
1871
|
+
);
|
|
1872
|
+
return ok({ data, rowCount: data.length });
|
|
1873
|
+
} catch (e) {
|
|
1874
|
+
return formatMcpToolError(e);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
);
|
|
1878
|
+
server.tool(
|
|
1879
|
+
"gsc_get_data_freshness",
|
|
1880
|
+
"Detect the most recent date with Search Analytics data by querying recent daily rows.",
|
|
1881
|
+
{
|
|
1882
|
+
siteUrl: siteUrlSchema,
|
|
1883
|
+
searchType: searchTypeSchema.optional().default("web"),
|
|
1884
|
+
lookbackDays: z2.number().int().min(3).max(30).optional().default(14).describe("Recent days to scan for daily data. Max 30.")
|
|
1885
|
+
},
|
|
1886
|
+
async ({ siteUrl, searchType, lookbackDays }) => {
|
|
1887
|
+
try {
|
|
1888
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
1889
|
+
const end = todayUtc();
|
|
1890
|
+
const dateRange = {
|
|
1891
|
+
startDate: formatDate(addUtcDays(end, -(lookbackDays - 1))),
|
|
1892
|
+
endDate: formatDate(end)
|
|
1893
|
+
};
|
|
1894
|
+
const validation = validateGSCQuerySelection(["date"], searchType, dateRange);
|
|
1895
|
+
if (validation.errors.length > 0) {
|
|
1896
|
+
return ok({ error: "Query validation failed", errors: validation.errors, warnings: validation.warnings });
|
|
1897
|
+
}
|
|
1898
|
+
const client = await clientFactory.get();
|
|
1899
|
+
const response = await client.querySearchAnalytics(resolvedSiteUrl, {
|
|
1900
|
+
startDate: dateRange.startDate,
|
|
1901
|
+
endDate: dateRange.endDate,
|
|
1902
|
+
dimensions: ["date"],
|
|
1903
|
+
type: searchType,
|
|
1904
|
+
dataState: "all",
|
|
1905
|
+
rowLimit: lookbackDays
|
|
1906
|
+
});
|
|
1907
|
+
const rows = (response.rows || []).filter((row) => row.keys?.[0]).sort((a, b) => String(a.keys?.[0]).localeCompare(String(b.keys?.[0])));
|
|
1908
|
+
const rowsWithData = rows.filter((row) => row.clicks > 0 || row.impressions > 0);
|
|
1909
|
+
const latestRow = rowsWithData[rowsWithData.length - 1];
|
|
1910
|
+
const latestDataDate = latestRow?.keys?.[0] ?? null;
|
|
1911
|
+
const dataLagDays = latestDataDate ? daysBetween(latestDataDate, end) : null;
|
|
1912
|
+
const warnings = [...validation.warnings];
|
|
1913
|
+
if (!latestDataDate) {
|
|
1914
|
+
warnings.push(`No daily rows with clicks or impressions were found in the last ${lookbackDays} days.`);
|
|
1915
|
+
} else if (dataLagDays !== null && dataLagDays > 3) {
|
|
1916
|
+
warnings.push(`Latest visible data is ${dataLagDays} days old; GSC normally lags by about 2-3 days.`);
|
|
1917
|
+
}
|
|
1918
|
+
return ok({
|
|
1919
|
+
siteUrl: resolvedSiteUrl,
|
|
1920
|
+
searchType,
|
|
1921
|
+
checkedRange: dateRange,
|
|
1922
|
+
latestDataDate,
|
|
1923
|
+
dataLagDays,
|
|
1924
|
+
hasRecentData: dataLagDays !== null && dataLagDays <= 3,
|
|
1925
|
+
rowCount: rows.length,
|
|
1926
|
+
dailyRows: rows.map((row) => ({
|
|
1927
|
+
date: row.keys?.[0],
|
|
1928
|
+
...rowMetrics(row)
|
|
1929
|
+
})),
|
|
1930
|
+
warnings
|
|
1931
|
+
});
|
|
1932
|
+
} catch (e) {
|
|
1933
|
+
return formatMcpToolError(e);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
);
|
|
1937
|
+
server.tool(
|
|
1938
|
+
"gsc_monitor_indexation_freshness",
|
|
1939
|
+
"Read-only monitoring snapshot for Search Analytics freshness, sitemap indexation totals, and an optional small URL Inspection sample.",
|
|
1940
|
+
{
|
|
1941
|
+
siteUrl: siteUrlSchema,
|
|
1942
|
+
searchTypes: z2.array(searchTypeSchema).min(1).max(6).optional().default(["web"]),
|
|
1943
|
+
lookbackDays: z2.number().int().min(3).max(30).optional().default(14).describe("Recent days to scan for Search Analytics freshness."),
|
|
1944
|
+
freshnessLagWarningDays: z2.number().int().min(1).max(14).optional().default(DEFAULT_FRESHNESS_LAG_WARNING_DAYS).describe("Maximum acceptable lag in days before freshness is marked delayed/stale."),
|
|
1945
|
+
inspectUrls: z2.array(z2.string().url()).max(BULK_URL_INSPECTION_LIMIT).optional().default([]).describe(`Optional URLs to inspect. Maximum ${BULK_URL_INSPECTION_LIMIT} to protect URL Inspection quota.`),
|
|
1946
|
+
languageCode: z2.string().optional().describe("Optional IETF language tag for URL Inspection, e.g. en-US or fr-FR.")
|
|
1947
|
+
},
|
|
1948
|
+
async ({ siteUrl, searchTypes, lookbackDays, freshnessLagWarningDays, inspectUrls, languageCode }) => {
|
|
1949
|
+
try {
|
|
1950
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
1951
|
+
const client = await clientFactory.get();
|
|
1952
|
+
const end = todayUtc();
|
|
1953
|
+
const checkedRange = {
|
|
1954
|
+
startDate: formatDate(addUtcDays(end, -(lookbackDays - 1))),
|
|
1955
|
+
endDate: formatDate(end)
|
|
1956
|
+
};
|
|
1957
|
+
const uniqueSearchTypes = uniqueValues(searchTypes);
|
|
1958
|
+
const sitemapPromise = client.listSitemaps(resolvedSiteUrl);
|
|
1959
|
+
const freshnessPromise = Promise.all(uniqueSearchTypes.map(async (type) => {
|
|
1960
|
+
const validation = validateGSCQuerySelection(["date"], type, checkedRange);
|
|
1961
|
+
if (validation.errors.length > 0) {
|
|
1962
|
+
return {
|
|
1963
|
+
searchType: type,
|
|
1964
|
+
checkedRange,
|
|
1965
|
+
status: "error",
|
|
1966
|
+
errors: validation.errors,
|
|
1967
|
+
warnings: validation.warnings
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
try {
|
|
1971
|
+
const response = await client.querySearchAnalytics(resolvedSiteUrl, {
|
|
1972
|
+
startDate: checkedRange.startDate,
|
|
1973
|
+
endDate: checkedRange.endDate,
|
|
1974
|
+
dimensions: ["date"],
|
|
1975
|
+
type,
|
|
1976
|
+
dataState: "all",
|
|
1977
|
+
rowLimit: lookbackDays
|
|
1978
|
+
});
|
|
1979
|
+
const analysis = analyzeDailyFreshness(response.rows || [], end, freshnessLagWarningDays);
|
|
1980
|
+
const warnings = [...validation.warnings];
|
|
1981
|
+
if (analysis.status === "noData") {
|
|
1982
|
+
warnings.push(`No ${type} daily rows with clicks or impressions were found in the last ${lookbackDays} days.`);
|
|
1983
|
+
} else if (analysis.status === "stale" || analysis.status === "delayed") {
|
|
1984
|
+
warnings.push(`Latest ${type} data is ${analysis.dataLagDays} days old; threshold is ${freshnessLagWarningDays} day(s).`);
|
|
1985
|
+
}
|
|
1986
|
+
return {
|
|
1987
|
+
searchType: type,
|
|
1988
|
+
checkedRange,
|
|
1989
|
+
...analysis,
|
|
1990
|
+
warnings
|
|
1991
|
+
};
|
|
1992
|
+
} catch (error) {
|
|
1993
|
+
return {
|
|
1994
|
+
searchType: type,
|
|
1995
|
+
checkedRange,
|
|
1996
|
+
status: "error",
|
|
1997
|
+
error: normalizePerUrlError(error)
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
}));
|
|
2001
|
+
const [sitemapResult, freshness] = await Promise.all([sitemapPromise, freshnessPromise]);
|
|
2002
|
+
const sitemapSummaries = (sitemapResult.sitemap || []).map(summarizeSitemapForP2);
|
|
2003
|
+
const sitemapTotals = summarizeSitemapCollection(sitemapSummaries);
|
|
2004
|
+
const urlsToInspect = dedupeUrlList(inspectUrls, BULK_URL_INSPECTION_LIMIT);
|
|
2005
|
+
const inspections = [];
|
|
2006
|
+
let inspectionAlertCount = 0;
|
|
2007
|
+
for (const url of urlsToInspect) {
|
|
2008
|
+
try {
|
|
2009
|
+
const response = await client.inspectUrl(url, resolvedSiteUrl, languageCode);
|
|
2010
|
+
const normalized = normalizeInspectionResult(url, response);
|
|
2011
|
+
const assessment = assessInspectionResult(normalized);
|
|
2012
|
+
if (assessment.status === "alert") inspectionAlertCount += 1;
|
|
2013
|
+
inspections.push({ ok: true, ...normalized, assessment });
|
|
2014
|
+
} catch (error) {
|
|
2015
|
+
inspectionAlertCount += 1;
|
|
2016
|
+
inspections.push({ ok: false, url, ...normalizePerUrlError(error) });
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
const freshnessAlertCount = freshness.filter((item) => ["noData", "stale", "error"].includes(String(item.status))).length;
|
|
2020
|
+
const sitemapAlertCount = sitemapTotals.statusCounts.error + sitemapTotals.statusCounts.pending;
|
|
2021
|
+
const overallStatus = freshnessAlertCount > 0 || sitemapAlertCount > 0 || inspectionAlertCount > 0 ? "attention" : sitemapTotals.statusCounts.warning > 0 ? "warning" : "ok";
|
|
2022
|
+
return ok({
|
|
2023
|
+
siteUrl: resolvedSiteUrl,
|
|
2024
|
+
overallStatus,
|
|
2025
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2026
|
+
freshness,
|
|
2027
|
+
sitemapIndexation: {
|
|
2028
|
+
totals: sitemapTotals,
|
|
2029
|
+
atRiskSitemaps: sitemapSummaries.filter((sitemap) => sitemap.status !== "healthy").sort((a, b) => b.errors - a.errors || b.warnings - a.warnings).slice(0, 25)
|
|
2030
|
+
},
|
|
2031
|
+
optionalUrlInspectionSample: {
|
|
2032
|
+
requestedCount: inspectUrls.length,
|
|
2033
|
+
inspectedCount: inspections.length,
|
|
2034
|
+
skippedDuplicates: inspectUrls.length - urlsToInspect.length,
|
|
2035
|
+
dailyQuotaNote: `${URL_INSPECTION_DAILY_QUOTA} requests/day/property; this monitor caps optional samples at ${BULK_URL_INSPECTION_LIMIT} URLs.`,
|
|
2036
|
+
results: inspections
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
} catch (e) {
|
|
2040
|
+
return formatMcpToolError(e);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
);
|
|
2044
|
+
server.tool(
|
|
2045
|
+
"gsc_track_sitemap_deltas",
|
|
2046
|
+
"Read-only sitemap delta tracker. Fetches the current sitemap snapshot and compares it to an agent-provided baseline; no state is written.",
|
|
2047
|
+
{
|
|
2048
|
+
siteUrl: siteUrlSchema,
|
|
2049
|
+
baseline: z2.array(sitemapBaselineSchema).optional().default([]).describe("Previous snapshot entries from this tool's snapshot output."),
|
|
2050
|
+
includeUnchanged: z2.boolean().optional().default(false),
|
|
2051
|
+
minAbsIndexedDelta: z2.number().int().min(0).optional().default(1).describe("Minimum absolute indexed URL delta to include otherwise unchanged sitemaps.")
|
|
2052
|
+
},
|
|
2053
|
+
async ({ siteUrl, baseline, includeUnchanged, minAbsIndexedDelta }) => {
|
|
2054
|
+
try {
|
|
2055
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
2056
|
+
const client = await clientFactory.get();
|
|
2057
|
+
const result = await client.listSitemaps(resolvedSiteUrl);
|
|
2058
|
+
const current = (result.sitemap || []).map(summarizeSitemapForP2);
|
|
2059
|
+
const currentSnapshot = current.map(sitemapSnapshot);
|
|
2060
|
+
const baselineMap = new Map(baseline.map((entry) => [entry.path, entry]));
|
|
2061
|
+
const currentPaths = new Set(current.map((sitemap) => sitemap.path));
|
|
2062
|
+
const changes = [];
|
|
2063
|
+
for (const sitemap of current) {
|
|
2064
|
+
const previous = baselineMap.get(sitemap.path);
|
|
2065
|
+
if (!previous) {
|
|
2066
|
+
changes.push({
|
|
2067
|
+
path: sitemap.path,
|
|
2068
|
+
changeType: "new",
|
|
2069
|
+
current: sitemapSnapshot(sitemap),
|
|
2070
|
+
previous: null,
|
|
2071
|
+
delta: {
|
|
2072
|
+
submitted: sitemap.totals.submitted,
|
|
2073
|
+
indexed: sitemap.totals.indexed,
|
|
2074
|
+
warnings: sitemap.warnings,
|
|
2075
|
+
errors: sitemap.errors
|
|
2076
|
+
}
|
|
2077
|
+
});
|
|
2078
|
+
continue;
|
|
2079
|
+
}
|
|
2080
|
+
const currentEntry = sitemapSnapshot(sitemap);
|
|
2081
|
+
const delta = {
|
|
2082
|
+
submitted: currentEntry.submitted - previous.submitted,
|
|
2083
|
+
indexed: currentEntry.indexed - previous.indexed,
|
|
2084
|
+
warnings: currentEntry.warnings - previous.warnings,
|
|
2085
|
+
errors: currentEntry.errors - previous.errors
|
|
2086
|
+
};
|
|
2087
|
+
const dateChanged = currentEntry.lastSubmitted !== previous.lastSubmitted || currentEntry.lastDownloaded !== previous.lastDownloaded;
|
|
2088
|
+
const changed = dateChanged || Math.abs(delta.indexed) >= minAbsIndexedDelta || delta.submitted !== 0 || delta.warnings !== 0 || delta.errors !== 0;
|
|
2089
|
+
if (includeUnchanged || changed) {
|
|
2090
|
+
changes.push({
|
|
2091
|
+
path: sitemap.path,
|
|
2092
|
+
changeType: changed ? "changed" : "unchanged",
|
|
2093
|
+
current: currentEntry,
|
|
2094
|
+
previous,
|
|
2095
|
+
delta,
|
|
2096
|
+
dateChanged
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
for (const previous of baseline) {
|
|
2101
|
+
if (currentPaths.has(previous.path)) continue;
|
|
2102
|
+
changes.push({
|
|
2103
|
+
path: previous.path,
|
|
2104
|
+
changeType: "missing",
|
|
2105
|
+
current: null,
|
|
2106
|
+
previous,
|
|
2107
|
+
delta: {
|
|
2108
|
+
submitted: -previous.submitted,
|
|
2109
|
+
indexed: -previous.indexed,
|
|
2110
|
+
warnings: -previous.warnings,
|
|
2111
|
+
errors: -previous.errors
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
const totals = summarizeSitemapCollection(current);
|
|
2116
|
+
return ok({
|
|
2117
|
+
siteUrl: resolvedSiteUrl,
|
|
2118
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2119
|
+
baselineCount: baseline.length,
|
|
2120
|
+
currentCount: current.length,
|
|
2121
|
+
totals,
|
|
2122
|
+
changeSummary: {
|
|
2123
|
+
new: changes.filter((change) => change.changeType === "new").length,
|
|
2124
|
+
changed: changes.filter((change) => change.changeType === "changed").length,
|
|
2125
|
+
missing: changes.filter((change) => change.changeType === "missing").length,
|
|
2126
|
+
unchangedIncluded: changes.filter((change) => change.changeType === "unchanged").length
|
|
2127
|
+
},
|
|
2128
|
+
changes,
|
|
2129
|
+
snapshot: currentSnapshot,
|
|
2130
|
+
usage: "Persist snapshot in the calling agent/workflow if you want the next read-only delta comparison."
|
|
2131
|
+
});
|
|
2132
|
+
} catch (e) {
|
|
2133
|
+
return formatMcpToolError(e);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
);
|
|
2137
|
+
server.tool(
|
|
2138
|
+
"gsc_analyze_search_appearance_trends",
|
|
2139
|
+
"Analyze structured data and rich-result trend signals via the Search Analytics searchAppearance dimension when GSC exposes it.",
|
|
2140
|
+
{
|
|
2141
|
+
siteUrl: siteUrlSchema,
|
|
2142
|
+
searchType: searchTypeSchema.optional().default("web"),
|
|
2143
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
2144
|
+
currentDateRange: dateRangeSchema.optional().describe("Current period. Defaults to datePreset."),
|
|
2145
|
+
previousDateRange: dateRangeSchema.optional().describe("Previous period. Defaults to the immediately preceding period with the same length."),
|
|
2146
|
+
includePages: z2.boolean().optional().default(false).describe("When true, groups by searchAppearance + page for page-level rich result trends."),
|
|
2147
|
+
limit: z2.number().int().min(1).max(25e3).optional().default(5e3),
|
|
2148
|
+
topN: z2.number().int().min(1).max(100).optional().default(50),
|
|
2149
|
+
minImpressions: z2.number().int().min(0).optional().default(0),
|
|
2150
|
+
sortBy: z2.enum(["currentClicks", "currentImpressions", "clicksDelta", "impressionsDelta", "positionDelta"]).optional().default("impressionsDelta")
|
|
2151
|
+
},
|
|
2152
|
+
async ({ siteUrl, searchType, datePreset, currentDateRange, previousDateRange, includePages, limit, topN, minImpressions, sortBy }) => {
|
|
2153
|
+
try {
|
|
2154
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
2155
|
+
const currentRange = currentDateRange || resolveDatePreset(datePreset);
|
|
2156
|
+
const previousRange = previousDateRange || derivePreviousDateRange(currentRange);
|
|
2157
|
+
validateDateRange(currentRange);
|
|
2158
|
+
validateDateRange(previousRange);
|
|
2159
|
+
const dimensions = includePages ? ["searchAppearance", "page"] : ["searchAppearance"];
|
|
2160
|
+
const validation = validateGSCQuerySelection(dimensions, searchType, currentRange);
|
|
2161
|
+
if (validation.errors.length > 0) {
|
|
2162
|
+
return ok({ error: "Query validation failed", errors: validation.errors, warnings: validation.warnings });
|
|
2163
|
+
}
|
|
2164
|
+
const client = await clientFactory.get();
|
|
2165
|
+
const buildRequest = (dateRange) => ({
|
|
2166
|
+
startDate: dateRange.startDate,
|
|
2167
|
+
endDate: dateRange.endDate,
|
|
2168
|
+
dimensions,
|
|
2169
|
+
type: searchType,
|
|
2170
|
+
dataState: "all",
|
|
2171
|
+
rowLimit: limit
|
|
2172
|
+
});
|
|
2173
|
+
const [currentResponse, previousResponse] = await Promise.all([
|
|
2174
|
+
client.querySearchAnalytics(resolvedSiteUrl, buildRequest(currentRange)),
|
|
2175
|
+
client.querySearchAnalytics(resolvedSiteUrl, buildRequest(previousRange))
|
|
2176
|
+
]);
|
|
2177
|
+
const currentRows = currentResponse.rows || [];
|
|
2178
|
+
const previousRows = previousResponse.rows || [];
|
|
2179
|
+
const currentByKey = new Map(currentRows.map((row) => [rowCompositeKey(row, dimensions.length), row]));
|
|
2180
|
+
const previousByKey = new Map(previousRows.map((row) => [rowCompositeKey(row, dimensions.length), row]));
|
|
2181
|
+
const allKeys = uniqueValues([...currentByKey.keys(), ...previousByKey.keys()]);
|
|
2182
|
+
const warnings = [...validation.warnings];
|
|
2183
|
+
if (currentRows.length === 0 && previousRows.length === 0) {
|
|
2184
|
+
warnings.push("No searchAppearance rows were returned. This property/search type/date range may not expose structured data or rich-result appearance data.");
|
|
2185
|
+
}
|
|
2186
|
+
if (currentRows.length === limit || previousRows.length === limit) {
|
|
2187
|
+
warnings.push("At least one period reached the row limit; lower-volume search appearance rows may be omitted.");
|
|
2188
|
+
}
|
|
2189
|
+
const trends = allKeys.map((key) => {
|
|
2190
|
+
const keys = JSON.parse(key);
|
|
2191
|
+
const current = rowMetrics(currentByKey.get(key));
|
|
2192
|
+
const previous = rowMetrics(previousByKey.get(key));
|
|
2193
|
+
const clicksDelta = current.clicks - previous.clicks;
|
|
2194
|
+
const impressionsDelta = current.impressions - previous.impressions;
|
|
2195
|
+
const ctrDelta = roundMetric(current.ctr - previous.ctr);
|
|
2196
|
+
const positionDelta = current.position !== null && previous.position !== null ? roundMetric(current.position - previous.position) : null;
|
|
2197
|
+
return {
|
|
2198
|
+
searchAppearance: keys[0] ?? "unknown",
|
|
2199
|
+
page: includePages ? keys[1] ?? null : void 0,
|
|
2200
|
+
current,
|
|
2201
|
+
previous,
|
|
2202
|
+
delta: {
|
|
2203
|
+
clicks: clicksDelta,
|
|
2204
|
+
impressions: impressionsDelta,
|
|
2205
|
+
ctr: ctrDelta,
|
|
2206
|
+
position: positionDelta,
|
|
2207
|
+
positionDirection: positionDelta === null ? "unknown" : positionDelta < 0 ? "improved" : positionDelta > 0 ? "declined" : "unchanged"
|
|
2208
|
+
},
|
|
2209
|
+
changePercent: {
|
|
2210
|
+
clicks: percentageChange(current.clicks, previous.clicks),
|
|
2211
|
+
impressions: percentageChange(current.impressions, previous.impressions)
|
|
2212
|
+
}
|
|
2213
|
+
};
|
|
2214
|
+
}).filter((row) => row.current.impressions >= minImpressions || row.previous.impressions >= minImpressions);
|
|
2215
|
+
const sortTrendRows = (rows) => rows.sort((a, b) => {
|
|
2216
|
+
switch (sortBy) {
|
|
2217
|
+
case "currentClicks":
|
|
2218
|
+
return b.current.clicks - a.current.clicks;
|
|
2219
|
+
case "currentImpressions":
|
|
2220
|
+
return b.current.impressions - a.current.impressions;
|
|
2221
|
+
case "clicksDelta":
|
|
2222
|
+
return Math.abs(b.delta.clicks) - Math.abs(a.delta.clicks);
|
|
2223
|
+
case "positionDelta":
|
|
2224
|
+
return Math.abs(b.delta.position ?? 0) - Math.abs(a.delta.position ?? 0);
|
|
2225
|
+
case "impressionsDelta":
|
|
2226
|
+
default:
|
|
2227
|
+
return Math.abs(b.delta.impressions) - Math.abs(a.delta.impressions);
|
|
2228
|
+
}
|
|
2229
|
+
});
|
|
2230
|
+
return ok({
|
|
2231
|
+
siteUrl: resolvedSiteUrl,
|
|
2232
|
+
searchType,
|
|
2233
|
+
dimensions,
|
|
2234
|
+
currentRange,
|
|
2235
|
+
previousRange,
|
|
2236
|
+
fetchedRows: {
|
|
2237
|
+
current: currentRows.length,
|
|
2238
|
+
previous: previousRows.length
|
|
2239
|
+
},
|
|
2240
|
+
totals: {
|
|
2241
|
+
current: summarizeRows(currentRows),
|
|
2242
|
+
previous: summarizeRows(previousRows)
|
|
2243
|
+
},
|
|
2244
|
+
trends: sortTrendRows(trends).slice(0, topN),
|
|
2245
|
+
warnings
|
|
2246
|
+
});
|
|
2247
|
+
} catch (e) {
|
|
2248
|
+
return formatMcpToolError(e);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
);
|
|
2252
|
+
server.tool(
|
|
2253
|
+
"gsc_plan_large_site_sampling",
|
|
2254
|
+
"Build a read-only URL Inspection sampling plan for large sites from sitemap risk signals and Search Analytics page performance.",
|
|
2255
|
+
{
|
|
2256
|
+
siteUrl: siteUrlSchema,
|
|
2257
|
+
searchType: searchTypeSchema.optional().default("web"),
|
|
2258
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
2259
|
+
dateRange: dateRangeSchema.optional(),
|
|
2260
|
+
objectives: z2.array(samplingObjectiveSchema).min(1).max(5).optional().default(["topTraffic", "lowCtr", "declining", "sitemapRisk", "staleSitemaps"]),
|
|
2261
|
+
pageLimit: z2.number().int().min(1).max(25e3).optional().default(5e3),
|
|
2262
|
+
maxInspectionUrls: z2.number().int().min(1).max(BULK_URL_INSPECTION_LIMIT).optional().default(BULK_URL_INSPECTION_LIMIT),
|
|
2263
|
+
minImpressionsForLowCtr: z2.number().int().min(1).optional().default(100),
|
|
2264
|
+
staleSitemapDays: z2.number().int().min(1).max(180).optional().default(14)
|
|
2265
|
+
},
|
|
2266
|
+
async ({ siteUrl, searchType, datePreset, dateRange, objectives, pageLimit, maxInspectionUrls, minImpressionsForLowCtr, staleSitemapDays }) => {
|
|
2267
|
+
try {
|
|
2268
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
2269
|
+
const resolvedDateRange = dateRange || resolveDatePreset(datePreset);
|
|
2270
|
+
const previousRange = derivePreviousDateRange(resolvedDateRange);
|
|
2271
|
+
validateDateRange(resolvedDateRange);
|
|
2272
|
+
const validation = validateGSCQuerySelection(["page"], searchType, resolvedDateRange);
|
|
2273
|
+
if (validation.errors.length > 0) {
|
|
2274
|
+
return ok({ error: "Query validation failed", errors: validation.errors, warnings: validation.warnings });
|
|
2275
|
+
}
|
|
2276
|
+
const client = await clientFactory.get();
|
|
2277
|
+
const [sitemapResult, currentResponse, previousResponse] = await Promise.all([
|
|
2278
|
+
client.listSitemaps(resolvedSiteUrl),
|
|
2279
|
+
client.querySearchAnalytics(resolvedSiteUrl, {
|
|
2280
|
+
startDate: resolvedDateRange.startDate,
|
|
2281
|
+
endDate: resolvedDateRange.endDate,
|
|
2282
|
+
dimensions: ["page"],
|
|
2283
|
+
type: searchType,
|
|
2284
|
+
dataState: "all",
|
|
2285
|
+
rowLimit: pageLimit
|
|
2286
|
+
}),
|
|
2287
|
+
client.querySearchAnalytics(resolvedSiteUrl, {
|
|
2288
|
+
startDate: previousRange.startDate,
|
|
2289
|
+
endDate: previousRange.endDate,
|
|
2290
|
+
dimensions: ["page"],
|
|
2291
|
+
type: searchType,
|
|
2292
|
+
dataState: "all",
|
|
2293
|
+
rowLimit: pageLimit
|
|
2294
|
+
})
|
|
2295
|
+
]);
|
|
2296
|
+
const sitemapSummaries = (sitemapResult.sitemap || []).map(summarizeSitemapForP2);
|
|
2297
|
+
const previousByPage = keyRowsByDimension(previousResponse.rows || []);
|
|
2298
|
+
const pageRows = (currentResponse.rows || []).flatMap((row) => {
|
|
2299
|
+
const page = row.keys?.[0];
|
|
2300
|
+
if (!page) return [];
|
|
2301
|
+
const previous = page ? rowMetrics(previousByPage.get(page)) : rowMetrics(void 0);
|
|
2302
|
+
const current = rowMetrics(row);
|
|
2303
|
+
return [{
|
|
2304
|
+
page,
|
|
2305
|
+
current,
|
|
2306
|
+
previous,
|
|
2307
|
+
delta: {
|
|
2308
|
+
clicks: current.clicks - previous.clicks,
|
|
2309
|
+
impressions: current.impressions - previous.impressions,
|
|
2310
|
+
ctr: roundMetric(current.ctr - previous.ctr),
|
|
2311
|
+
position: current.position !== null && previous.position !== null ? roundMetric(current.position - previous.position) : null
|
|
2312
|
+
}
|
|
2313
|
+
}];
|
|
2314
|
+
});
|
|
2315
|
+
const selectedObjectives = objectives;
|
|
2316
|
+
const buckets = [];
|
|
2317
|
+
const addBucket = (objective, rationale, candidateUrls, sitemapPaths = []) => {
|
|
2318
|
+
if (!selectedObjectives.includes(objective)) return;
|
|
2319
|
+
buckets.push({
|
|
2320
|
+
objective,
|
|
2321
|
+
rationale,
|
|
2322
|
+
candidateUrls: dedupeUrlList(candidateUrls, maxInspectionUrls),
|
|
2323
|
+
sitemapPaths
|
|
2324
|
+
});
|
|
2325
|
+
};
|
|
2326
|
+
addBucket(
|
|
2327
|
+
"topTraffic",
|
|
2328
|
+
"Protect the pages carrying the most organic clicks.",
|
|
2329
|
+
pageRows.slice().sort((a, b) => b.current.clicks - a.current.clicks || b.current.impressions - a.current.impressions).map((row) => row.page)
|
|
2330
|
+
);
|
|
2331
|
+
addBucket(
|
|
2332
|
+
"lowCtr",
|
|
2333
|
+
"Sample high-impression pages with weak CTR that may have snippet or rich-result problems.",
|
|
2334
|
+
pageRows.filter((row) => row.current.impressions >= minImpressionsForLowCtr).sort((a, b) => a.current.ctr - b.current.ctr || b.current.impressions - a.current.impressions).map((row) => row.page)
|
|
2335
|
+
);
|
|
2336
|
+
addBucket(
|
|
2337
|
+
"declining",
|
|
2338
|
+
"Sample pages with the largest click losses versus the previous equivalent period.",
|
|
2339
|
+
pageRows.filter((row) => row.delta.clicks < 0 || row.delta.impressions < 0).sort((a, b) => a.delta.clicks - b.delta.clicks || a.delta.impressions - b.delta.impressions).map((row) => row.page)
|
|
2340
|
+
);
|
|
2341
|
+
addBucket(
|
|
2342
|
+
"sitemapRisk",
|
|
2343
|
+
"Inspect representative URLs from sitemaps that currently report errors, warnings, pending status, or low index rates.",
|
|
2344
|
+
[],
|
|
2345
|
+
sitemapSummaries.filter((sitemap) => sitemap.status !== "healthy" || sitemap.totals.indexRate !== null && sitemap.totals.indexRate < 90).sort((a, b) => b.errors - a.errors || b.warnings - a.warnings || (a.totals.indexRate ?? 100) - (b.totals.indexRate ?? 100)).map((sitemap) => sitemap.path).slice(0, 25)
|
|
2346
|
+
);
|
|
2347
|
+
addBucket(
|
|
2348
|
+
"staleSitemaps",
|
|
2349
|
+
`Inspect representative URLs from sitemaps not downloaded in the last ${staleSitemapDays} day(s).`,
|
|
2350
|
+
[],
|
|
2351
|
+
sitemapSummaries.filter((sitemap) => {
|
|
2352
|
+
const daysSinceDownload = safeDaysBetweenDatePrefix(sitemap.lastDownloaded, todayUtc());
|
|
2353
|
+
return daysSinceDownload === null || daysSinceDownload > staleSitemapDays;
|
|
2354
|
+
}).map((sitemap) => sitemap.path).slice(0, 25)
|
|
2355
|
+
);
|
|
2356
|
+
const recommendedInspectionUrls = dedupeUrlList(
|
|
2357
|
+
buckets.flatMap((bucket) => bucket.candidateUrls),
|
|
2358
|
+
maxInspectionUrls
|
|
2359
|
+
);
|
|
2360
|
+
const warnings = [...validation.warnings];
|
|
2361
|
+
if ((currentResponse.rows || []).length === pageLimit) {
|
|
2362
|
+
warnings.push("The page query reached pageLimit; sampling is based on the highest-returned Search Analytics rows.");
|
|
2363
|
+
}
|
|
2364
|
+
if (buckets.some((bucket) => bucket.sitemapPaths.length > 0 && bucket.candidateUrls.length === 0)) {
|
|
2365
|
+
warnings.push("Sitemaps API exposes sitemap paths and counts, not child URLs. Use these sitemap paths to choose representative URLs from your own sitemap inventory before calling URL Inspection.");
|
|
2366
|
+
}
|
|
2367
|
+
return ok({
|
|
2368
|
+
siteUrl: resolvedSiteUrl,
|
|
2369
|
+
searchType,
|
|
2370
|
+
dateRange: resolvedDateRange,
|
|
2371
|
+
previousRange,
|
|
2372
|
+
pageRowsAnalyzed: pageRows.length,
|
|
2373
|
+
sitemapTotals: summarizeSitemapCollection(sitemapSummaries),
|
|
2374
|
+
inspectionBudget: {
|
|
2375
|
+
dailyQuota: URL_INSPECTION_DAILY_QUOTA,
|
|
2376
|
+
maxPerToolCall: BULK_URL_INSPECTION_LIMIT,
|
|
2377
|
+
recommendedNow: recommendedInspectionUrls.length
|
|
2378
|
+
},
|
|
2379
|
+
recommendedInspectionUrls,
|
|
2380
|
+
samplingBuckets: buckets,
|
|
2381
|
+
warnings
|
|
2382
|
+
});
|
|
2383
|
+
} catch (e) {
|
|
2384
|
+
return formatMcpToolError(e);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
);
|
|
2388
|
+
server.tool(
|
|
2389
|
+
"gsc_indexation_watchlist",
|
|
2390
|
+
"Read-only URL Inspection watchlist for critical URLs. Inspects up to 10 URLs and returns alert/review/ok assessments without storing state.",
|
|
2391
|
+
{
|
|
2392
|
+
siteUrl: siteUrlSchema,
|
|
2393
|
+
urls: z2.array(watchlistUrlSchema).min(1).max(BULK_URL_INSPECTION_LIMIT).describe(`Watchlist entries. Maximum ${BULK_URL_INSPECTION_LIMIT} per call to protect URL Inspection quota.`),
|
|
2394
|
+
languageCode: z2.string().optional().describe("Optional IETF language tag, e.g. en-US or fr-FR."),
|
|
2395
|
+
continueOnError: z2.boolean().optional().default(true)
|
|
2396
|
+
},
|
|
2397
|
+
async ({ siteUrl, urls, languageCode, continueOnError }) => {
|
|
2398
|
+
try {
|
|
2399
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
2400
|
+
const client = await clientFactory.get();
|
|
2401
|
+
const dedupedWatchlist = [];
|
|
2402
|
+
const seenUrls = /* @__PURE__ */ new Set();
|
|
2403
|
+
for (const entry of urls) {
|
|
2404
|
+
if (seenUrls.has(entry.url)) continue;
|
|
2405
|
+
seenUrls.add(entry.url);
|
|
2406
|
+
dedupedWatchlist.push(entry);
|
|
2407
|
+
}
|
|
2408
|
+
const results = [];
|
|
2409
|
+
const summary = { ok: 0, review: 0, alert: 0, failed: 0 };
|
|
2410
|
+
for (const entry of dedupedWatchlist) {
|
|
2411
|
+
try {
|
|
2412
|
+
const response = await client.inspectUrl(entry.url, resolvedSiteUrl, languageCode);
|
|
2413
|
+
const normalized = normalizeInspectionResult(entry.url, response);
|
|
2414
|
+
const assessment = assessInspectionResult(normalized, entry);
|
|
2415
|
+
summary[assessment.status] += 1;
|
|
2416
|
+
results.push({
|
|
2417
|
+
ok: true,
|
|
2418
|
+
label: entry.label,
|
|
2419
|
+
tags: entry.tags,
|
|
2420
|
+
expected: {
|
|
2421
|
+
verdict: entry.expectedVerdict,
|
|
2422
|
+
canonical: entry.expectedCanonical,
|
|
2423
|
+
coverageState: entry.expectedCoverageState
|
|
2424
|
+
},
|
|
2425
|
+
...normalized,
|
|
2426
|
+
assessment
|
|
2427
|
+
});
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
summary.failed += 1;
|
|
2430
|
+
if (!continueOnError) throw error;
|
|
2431
|
+
results.push({ ok: false, url: entry.url, label: entry.label, priority: entry.priority, tags: entry.tags, ...normalizePerUrlError(error) });
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
return ok({
|
|
2435
|
+
siteUrl: resolvedSiteUrl,
|
|
2436
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2437
|
+
inspectedCount: results.length,
|
|
2438
|
+
skippedDuplicates: urls.length - dedupedWatchlist.length,
|
|
2439
|
+
summary,
|
|
2440
|
+
quota: {
|
|
2441
|
+
dailyQuota: URL_INSPECTION_DAILY_QUOTA,
|
|
2442
|
+
maxPerToolCall: BULK_URL_INSPECTION_LIMIT,
|
|
2443
|
+
note: "URL Inspection is read-only but quota-limited; run watchlists in small batches."
|
|
2444
|
+
},
|
|
2445
|
+
results
|
|
2446
|
+
});
|
|
2447
|
+
} catch (e) {
|
|
2448
|
+
return formatMcpToolError(e);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
);
|
|
2452
|
+
server.tool(
|
|
2453
|
+
"gsc_find_losses_gains",
|
|
2454
|
+
"Compare two periods for query or page performance and return click/impression/CTR/position deltas.",
|
|
2455
|
+
{
|
|
2456
|
+
siteUrl: siteUrlSchema,
|
|
2457
|
+
dimension: comparisonDimensionSchema.optional().default("query"),
|
|
2458
|
+
searchType: searchTypeSchema.optional().default("web"),
|
|
2459
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
2460
|
+
currentDateRange: dateRangeSchema.optional().describe("Current period. Defaults to datePreset."),
|
|
2461
|
+
previousDateRange: dateRangeSchema.optional().describe("Previous period. Defaults to the immediately preceding period with the same length."),
|
|
2462
|
+
limit: z2.number().int().min(1).max(25e3).optional().default(5e3).describe("Rows to fetch for each period."),
|
|
2463
|
+
topN: z2.number().int().min(1).max(100).optional().default(25),
|
|
2464
|
+
minAbsClickDelta: z2.number().min(0).optional().default(0).describe("Filter out rows with smaller absolute click delta."),
|
|
2465
|
+
sortBy: z2.enum(["clicksDelta", "impressionsDelta", "ctrDelta", "positionDelta"]).optional().default("clicksDelta")
|
|
2466
|
+
},
|
|
2467
|
+
async ({ siteUrl, dimension, searchType, datePreset, currentDateRange, previousDateRange, limit, topN, minAbsClickDelta, sortBy }) => {
|
|
2468
|
+
try {
|
|
2469
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
2470
|
+
const currentRange = currentDateRange || resolveDatePreset(datePreset);
|
|
2471
|
+
const previousRange = previousDateRange || derivePreviousDateRange(currentRange);
|
|
2472
|
+
validateDateRange(currentRange);
|
|
2473
|
+
validateDateRange(previousRange);
|
|
2474
|
+
const validation = validateGSCQuerySelection([dimension], searchType, currentRange);
|
|
2475
|
+
if (validation.errors.length > 0) {
|
|
2476
|
+
return ok({ error: "Query validation failed", errors: validation.errors, warnings: validation.warnings });
|
|
2477
|
+
}
|
|
2478
|
+
const client = await clientFactory.get();
|
|
2479
|
+
const buildRequest = (dateRange) => ({
|
|
2480
|
+
startDate: dateRange.startDate,
|
|
2481
|
+
endDate: dateRange.endDate,
|
|
2482
|
+
dimensions: [dimension],
|
|
2483
|
+
type: searchType,
|
|
2484
|
+
dataState: "all",
|
|
2485
|
+
rowLimit: limit
|
|
2486
|
+
});
|
|
2487
|
+
const [currentResponse, previousResponse] = await Promise.all([
|
|
2488
|
+
client.querySearchAnalytics(resolvedSiteUrl, buildRequest(currentRange)),
|
|
2489
|
+
client.querySearchAnalytics(resolvedSiteUrl, buildRequest(previousRange))
|
|
2490
|
+
]);
|
|
2491
|
+
const currentRows = currentResponse.rows || [];
|
|
2492
|
+
const previousRows = previousResponse.rows || [];
|
|
2493
|
+
const currentByKey = keyRowsByDimension(currentRows);
|
|
2494
|
+
const previousByKey = keyRowsByDimension(previousRows);
|
|
2495
|
+
const allKeys = uniqueValues([...currentByKey.keys(), ...previousByKey.keys()]);
|
|
2496
|
+
const deltas = allKeys.map((key) => {
|
|
2497
|
+
const current = rowMetrics(currentByKey.get(key));
|
|
2498
|
+
const previous = rowMetrics(previousByKey.get(key));
|
|
2499
|
+
const clicksDelta = current.clicks - previous.clicks;
|
|
2500
|
+
const impressionsDelta = current.impressions - previous.impressions;
|
|
2501
|
+
const ctrDelta = roundMetric(current.ctr - previous.ctr);
|
|
2502
|
+
const positionDelta = current.position !== null && previous.position !== null ? roundMetric(current.position - previous.position) : null;
|
|
2503
|
+
return {
|
|
2504
|
+
entity: { type: dimension, value: key },
|
|
2505
|
+
current,
|
|
2506
|
+
previous,
|
|
2507
|
+
delta: {
|
|
2508
|
+
clicks: clicksDelta,
|
|
2509
|
+
impressions: impressionsDelta,
|
|
2510
|
+
ctr: ctrDelta,
|
|
2511
|
+
position: positionDelta,
|
|
2512
|
+
positionDirection: positionDelta === null ? "unknown" : positionDelta < 0 ? "improved" : positionDelta > 0 ? "declined" : "unchanged"
|
|
2513
|
+
},
|
|
2514
|
+
changePercent: {
|
|
2515
|
+
clicks: percentageChange(current.clicks, previous.clicks),
|
|
2516
|
+
impressions: percentageChange(current.impressions, previous.impressions)
|
|
2517
|
+
}
|
|
2518
|
+
};
|
|
2519
|
+
}).filter((row) => Math.abs(row.delta.clicks) >= minAbsClickDelta);
|
|
2520
|
+
const getSortValue = (row) => {
|
|
2521
|
+
switch (sortBy) {
|
|
2522
|
+
case "clicksDelta":
|
|
2523
|
+
return row.delta.clicks;
|
|
2524
|
+
case "impressionsDelta":
|
|
2525
|
+
return row.delta.impressions;
|
|
2526
|
+
case "ctrDelta":
|
|
2527
|
+
return row.delta.ctr;
|
|
2528
|
+
case "positionDelta":
|
|
2529
|
+
return row.delta.position ?? 0;
|
|
2530
|
+
default:
|
|
2531
|
+
return row.delta.clicks;
|
|
2532
|
+
}
|
|
2533
|
+
};
|
|
2534
|
+
const sortDeltaRows = (rows, direction) => rows.sort((a, b) => {
|
|
2535
|
+
const aValue = getSortValue(a);
|
|
2536
|
+
const bValue = getSortValue(b);
|
|
2537
|
+
if (sortBy === "positionDelta") {
|
|
2538
|
+
return direction === "gain" ? aValue - bValue : bValue - aValue;
|
|
2539
|
+
}
|
|
2540
|
+
return direction === "gain" ? Number(bValue) - Number(aValue) : Number(aValue) - Number(bValue);
|
|
2541
|
+
});
|
|
2542
|
+
const gains = sortDeltaRows(
|
|
2543
|
+
deltas.filter((row) => row.delta.clicks > 0 || row.delta.impressions > 0),
|
|
2544
|
+
"gain"
|
|
2545
|
+
).slice(0, topN);
|
|
2546
|
+
const losses = sortDeltaRows(
|
|
2547
|
+
deltas.filter((row) => row.delta.clicks < 0 || row.delta.impressions < 0),
|
|
2548
|
+
"loss"
|
|
2549
|
+
).slice(0, topN);
|
|
2550
|
+
return ok({
|
|
2551
|
+
siteUrl: resolvedSiteUrl,
|
|
2552
|
+
dimension,
|
|
2553
|
+
searchType,
|
|
2554
|
+
currentRange,
|
|
2555
|
+
previousRange,
|
|
2556
|
+
fetchedRows: {
|
|
2557
|
+
current: currentRows.length,
|
|
2558
|
+
previous: previousRows.length
|
|
2559
|
+
},
|
|
2560
|
+
totals: {
|
|
2561
|
+
current: summarizeRows(currentRows),
|
|
2562
|
+
previous: summarizeRows(previousRows)
|
|
2563
|
+
},
|
|
2564
|
+
gains,
|
|
2565
|
+
losses,
|
|
2566
|
+
warnings: validation.warnings
|
|
2567
|
+
});
|
|
2568
|
+
} catch (e) {
|
|
2569
|
+
return formatMcpToolError(e);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
);
|
|
2573
|
+
server.tool(
|
|
2574
|
+
"gsc_cluster_queries",
|
|
2575
|
+
"Cluster Search Console queries by simple tokens and intent signals: brand/non-brand, question, category, and topic tokens.",
|
|
2576
|
+
{
|
|
2577
|
+
siteUrl: siteUrlSchema,
|
|
2578
|
+
searchType: searchTypeSchema.optional().default("web"),
|
|
2579
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
2580
|
+
dateRange: dateRangeSchema.optional(),
|
|
2581
|
+
brandTerms: z2.array(z2.string()).optional().default([]).describe("Optional brand terms. Defaults to terms derived from the property domain."),
|
|
2582
|
+
categoryRules: z2.record(z2.array(z2.string())).optional().describe("Optional map of category name to matching tokens/phrases."),
|
|
2583
|
+
limit: z2.number().int().min(1).max(25e3).optional().default(5e3),
|
|
2584
|
+
maxClusters: z2.number().int().min(1).max(100).optional().default(25),
|
|
2585
|
+
topQueriesPerCluster: z2.number().int().min(1).max(25).optional().default(10)
|
|
2586
|
+
},
|
|
2587
|
+
async ({ siteUrl, searchType, datePreset, dateRange, brandTerms, categoryRules, limit, maxClusters, topQueriesPerCluster }) => {
|
|
2588
|
+
try {
|
|
2589
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
2590
|
+
const resolvedDateRange = dateRange || resolveDatePreset(datePreset);
|
|
2591
|
+
validateDateRange(resolvedDateRange);
|
|
2592
|
+
const validation = validateGSCQuerySelection(["query"], searchType, resolvedDateRange);
|
|
2593
|
+
if (validation.errors.length > 0) {
|
|
2594
|
+
return ok({ error: "Query validation failed", errors: validation.errors, warnings: validation.warnings });
|
|
2595
|
+
}
|
|
2596
|
+
const client = await clientFactory.get();
|
|
2597
|
+
const response = await client.querySearchAnalytics(resolvedSiteUrl, {
|
|
2598
|
+
startDate: resolvedDateRange.startDate,
|
|
2599
|
+
endDate: resolvedDateRange.endDate,
|
|
2600
|
+
dimensions: ["query"],
|
|
2601
|
+
type: searchType,
|
|
2602
|
+
dataState: "all",
|
|
2603
|
+
rowLimit: limit
|
|
2604
|
+
});
|
|
2605
|
+
const rows = response.rows || [];
|
|
2606
|
+
const resolvedBrandTerms = expandBrandTerms(brandTerms.length > 0 ? brandTerms : deriveBrandTerms(resolvedSiteUrl));
|
|
2607
|
+
const brandClusters = /* @__PURE__ */ new Map();
|
|
2608
|
+
const questionClusters = /* @__PURE__ */ new Map();
|
|
2609
|
+
const intentClusters = /* @__PURE__ */ new Map();
|
|
2610
|
+
const categoryClusters = /* @__PURE__ */ new Map();
|
|
2611
|
+
const tokenClusters = /* @__PURE__ */ new Map();
|
|
2612
|
+
const warnings = [...validation.warnings];
|
|
2613
|
+
if (resolvedBrandTerms.length === 0) {
|
|
2614
|
+
warnings.push("No brand terms were provided or derived; all queries may be classified as nonBrand.");
|
|
2615
|
+
}
|
|
2616
|
+
if (rows.length === limit) {
|
|
2617
|
+
warnings.push("The response reached the row limit; lower-volume queries may be missing from clusters.");
|
|
2618
|
+
}
|
|
2619
|
+
for (const row of rows) {
|
|
2620
|
+
const query = row.keys?.[0];
|
|
2621
|
+
if (!query) continue;
|
|
2622
|
+
const tokens = tokenizeQuery(query);
|
|
2623
|
+
const classification = classifyQuery(query, tokens, resolvedBrandTerms);
|
|
2624
|
+
const category = resolveQueryCategory(query, tokens, categoryRules);
|
|
2625
|
+
addQueryToBucket(getOrCreateBucket(brandClusters, classification.brand), query, row);
|
|
2626
|
+
addQueryToBucket(getOrCreateBucket(questionClusters, classification.question), query, row);
|
|
2627
|
+
addQueryToBucket(getOrCreateBucket(intentClusters, classification.intent), query, row);
|
|
2628
|
+
addQueryToBucket(getOrCreateBucket(categoryClusters, category), query, row);
|
|
2629
|
+
for (const token of tokens) {
|
|
2630
|
+
addQueryToBucket(getOrCreateBucket(tokenClusters, token), query, row);
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
return ok({
|
|
2634
|
+
siteUrl: resolvedSiteUrl,
|
|
2635
|
+
searchType,
|
|
2636
|
+
dateRange: resolvedDateRange,
|
|
2637
|
+
rowCount: rows.length,
|
|
2638
|
+
brandTerms: resolvedBrandTerms,
|
|
2639
|
+
totals: summarizeRows(rows),
|
|
2640
|
+
clusters: {
|
|
2641
|
+
brand: finalizeBuckets(brandClusters, topQueriesPerCluster),
|
|
2642
|
+
question: finalizeBuckets(questionClusters, topQueriesPerCluster),
|
|
2643
|
+
intent: finalizeBuckets(intentClusters, topQueriesPerCluster),
|
|
2644
|
+
category: finalizeBuckets(categoryClusters, topQueriesPerCluster).slice(0, maxClusters),
|
|
2645
|
+
tokens: finalizeBuckets(tokenClusters, topQueriesPerCluster).slice(0, maxClusters)
|
|
2646
|
+
},
|
|
2647
|
+
warnings
|
|
2648
|
+
});
|
|
2649
|
+
} catch (e) {
|
|
2650
|
+
return formatMcpToolError(e);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
);
|
|
2654
|
+
server.tool(
|
|
2655
|
+
"gsc_detect_cannibalization",
|
|
2656
|
+
"Detect queries where multiple pages compete for clicks/impressions in Search Analytics query-page rows.",
|
|
2657
|
+
{
|
|
2658
|
+
siteUrl: siteUrlSchema,
|
|
2659
|
+
searchType: searchTypeSchema.optional().default("web"),
|
|
2660
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
2661
|
+
dateRange: dateRangeSchema.optional(),
|
|
2662
|
+
limit: z2.number().int().min(1).max(25e3).optional().default(5e3),
|
|
2663
|
+
minPages: z2.number().int().min(2).max(10).optional().default(2),
|
|
2664
|
+
minImpressions: z2.number().int().min(0).optional().default(100),
|
|
2665
|
+
maxTopPageClickShare: z2.number().min(0.1).max(0.99).optional().default(0.8).describe("Only flag queries where the top page owns less than or equal to this click share."),
|
|
2666
|
+
topN: z2.number().int().min(1).max(100).optional().default(25)
|
|
2667
|
+
},
|
|
2668
|
+
async ({ siteUrl, searchType, datePreset, dateRange, limit, minPages, minImpressions, maxTopPageClickShare, topN }) => {
|
|
2669
|
+
try {
|
|
2670
|
+
const resolvedSiteUrl = resolveSiteUrl(siteUrl, config);
|
|
2671
|
+
const resolvedDateRange = dateRange || resolveDatePreset(datePreset);
|
|
2672
|
+
validateDateRange(resolvedDateRange);
|
|
2673
|
+
const validation = validateGSCQuerySelection(["query", "page"], searchType, resolvedDateRange);
|
|
2674
|
+
if (validation.errors.length > 0) {
|
|
2675
|
+
return ok({ error: "Query validation failed", errors: validation.errors, warnings: validation.warnings });
|
|
2676
|
+
}
|
|
2677
|
+
const client = await clientFactory.get();
|
|
2678
|
+
const response = await client.querySearchAnalytics(resolvedSiteUrl, {
|
|
2679
|
+
startDate: resolvedDateRange.startDate,
|
|
2680
|
+
endDate: resolvedDateRange.endDate,
|
|
2681
|
+
dimensions: ["query", "page"],
|
|
2682
|
+
type: searchType,
|
|
2683
|
+
dataState: "all",
|
|
2684
|
+
rowLimit: limit
|
|
2685
|
+
});
|
|
2686
|
+
const rows = response.rows || [];
|
|
2687
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
2688
|
+
const warnings = [...validation.warnings];
|
|
2689
|
+
if (rows.length === limit) {
|
|
2690
|
+
warnings.push("The query-page response reached the row limit; cannibalization candidates may be incomplete.");
|
|
2691
|
+
}
|
|
2692
|
+
for (const row of rows) {
|
|
2693
|
+
const query = row.keys?.[0];
|
|
2694
|
+
const page = row.keys?.[1];
|
|
2695
|
+
if (!query || !page) continue;
|
|
2696
|
+
const pageMap = grouped.get(query) || /* @__PURE__ */ new Map();
|
|
2697
|
+
const pageStats = pageMap.get(page) || { page, clicks: 0, impressions: 0, weightedPosition: 0 };
|
|
2698
|
+
pageStats.clicks += row.clicks;
|
|
2699
|
+
pageStats.impressions += row.impressions;
|
|
2700
|
+
pageStats.weightedPosition += row.position * row.impressions;
|
|
2701
|
+
pageMap.set(page, pageStats);
|
|
2702
|
+
grouped.set(query, pageMap);
|
|
2703
|
+
}
|
|
2704
|
+
const candidates = [];
|
|
2705
|
+
for (const [query, pageMap] of grouped.entries()) {
|
|
2706
|
+
const pages = [...pageMap.values()].map((pageStats) => ({
|
|
2707
|
+
page: pageStats.page,
|
|
2708
|
+
clicks: roundMetric(pageStats.clicks, 0),
|
|
2709
|
+
impressions: roundMetric(pageStats.impressions, 0),
|
|
2710
|
+
ctr: pageStats.impressions > 0 ? roundMetric(pageStats.clicks / pageStats.impressions * 100) : 0,
|
|
2711
|
+
position: pageStats.impressions > 0 ? roundMetric(pageStats.weightedPosition / pageStats.impressions) : 0
|
|
2712
|
+
})).sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions);
|
|
2713
|
+
const totalClicks = pages.reduce((sum, page) => sum + page.clicks, 0);
|
|
2714
|
+
const totalImpressions = pages.reduce((sum, page) => sum + page.impressions, 0);
|
|
2715
|
+
if (pages.length < minPages || totalImpressions < minImpressions) continue;
|
|
2716
|
+
const topPage = pages[0];
|
|
2717
|
+
if (!topPage) continue;
|
|
2718
|
+
const topClickShare = totalClicks > 0 ? topPage.clicks / totalClicks : 0;
|
|
2719
|
+
const topImpressionShare = totalImpressions > 0 ? topPage.impressions / totalImpressions : 0;
|
|
2720
|
+
const competitionShare = totalClicks > 0 ? topClickShare : topImpressionShare;
|
|
2721
|
+
if (competitionShare > maxTopPageClickShare) continue;
|
|
2722
|
+
const dispersion = 1 - competitionShare;
|
|
2723
|
+
const pageFactor = Math.min(pages.length / 5, 1);
|
|
2724
|
+
const volumeFactor = Math.min(totalImpressions / 1e4, 1);
|
|
2725
|
+
const severityScore = roundMetric(dispersion * 70 + pageFactor * 20 + volumeFactor * 10);
|
|
2726
|
+
candidates.push({
|
|
2727
|
+
query,
|
|
2728
|
+
pageCount: pages.length,
|
|
2729
|
+
totalClicks: roundMetric(totalClicks, 0),
|
|
2730
|
+
totalImpressions: roundMetric(totalImpressions, 0),
|
|
2731
|
+
ctr: totalImpressions > 0 ? roundMetric(totalClicks / totalImpressions * 100) : 0,
|
|
2732
|
+
topPage: topPage.page,
|
|
2733
|
+
topPageClickShare: roundMetric(topClickShare * 100),
|
|
2734
|
+
topPageImpressionShare: roundMetric(topImpressionShare * 100),
|
|
2735
|
+
severityScore,
|
|
2736
|
+
competingPages: pages.slice(0, 10)
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
return ok({
|
|
2740
|
+
siteUrl: resolvedSiteUrl,
|
|
2741
|
+
searchType,
|
|
2742
|
+
dateRange: resolvedDateRange,
|
|
2743
|
+
analyzedRows: rows.length,
|
|
2744
|
+
candidateCount: candidates.length,
|
|
2745
|
+
candidates: candidates.sort((a, b) => b.severityScore - a.severityScore || b.totalImpressions - a.totalImpressions).slice(0, topN),
|
|
2746
|
+
thresholds: {
|
|
2747
|
+
minPages,
|
|
2748
|
+
minImpressions,
|
|
2749
|
+
maxTopPageClickShare: roundMetric(maxTopPageClickShare * 100)
|
|
2750
|
+
},
|
|
2751
|
+
warnings
|
|
2752
|
+
});
|
|
2753
|
+
} catch (e) {
|
|
2754
|
+
return formatMcpToolError(e);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
);
|
|
2758
|
+
server.tool(
|
|
2759
|
+
"gsc_validate_query",
|
|
2760
|
+
"Validate a Search Console metric/dimension/search type combination before executing it.",
|
|
2761
|
+
{
|
|
2762
|
+
dimensions: z2.array(z2.string()).optional().describe("Dimensions to validate."),
|
|
2763
|
+
searchType: searchTypeSchema.optional().default("web"),
|
|
2764
|
+
dateRange: z2.object({ startDate: z2.string(), endDate: z2.string() }).optional()
|
|
2765
|
+
},
|
|
2766
|
+
async ({ dimensions, searchType, dateRange }) => {
|
|
2767
|
+
try {
|
|
2768
|
+
const result = validateGSCQuerySelection(
|
|
2769
|
+
dimensions ?? [],
|
|
2770
|
+
searchType,
|
|
2771
|
+
dateRange
|
|
2772
|
+
);
|
|
2773
|
+
return ok(result);
|
|
2774
|
+
} catch (e) {
|
|
2775
|
+
return formatMcpToolError(e);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
);
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
// src/platforms/gsc/metricCatalog.ts
|
|
2782
|
+
var GSC_METRIC_CATALOG = [
|
|
2783
|
+
// ============================================
|
|
2784
|
+
// PERFORMANCE METRICS (always returned by API)
|
|
2785
|
+
// ============================================
|
|
2786
|
+
{
|
|
2787
|
+
key: "clicks",
|
|
2788
|
+
name: "Clicks",
|
|
2789
|
+
description: "Number of clicks from Google Search results to your site",
|
|
2790
|
+
category: "performance",
|
|
2791
|
+
format: "number",
|
|
2792
|
+
apiField: "clicks",
|
|
2793
|
+
type: "api"
|
|
2794
|
+
},
|
|
2795
|
+
{
|
|
2796
|
+
key: "impressions",
|
|
2797
|
+
name: "Impressions",
|
|
2798
|
+
description: "Number of times your site appeared in Google Search results",
|
|
2799
|
+
category: "performance",
|
|
2800
|
+
format: "number",
|
|
2801
|
+
apiField: "impressions",
|
|
2802
|
+
type: "api"
|
|
2803
|
+
},
|
|
2804
|
+
{
|
|
2805
|
+
key: "ctr",
|
|
2806
|
+
name: "CTR",
|
|
2807
|
+
description: "Click-through rate: clicks / impressions (percentage)",
|
|
2808
|
+
category: "performance",
|
|
2809
|
+
format: "percent",
|
|
2810
|
+
apiField: "ctr",
|
|
2811
|
+
type: "api"
|
|
2812
|
+
},
|
|
2813
|
+
{
|
|
2814
|
+
key: "position",
|
|
2815
|
+
name: "Average Position",
|
|
2816
|
+
description: "Average ranking position in Google Search results (1 = top)",
|
|
2817
|
+
category: "performance",
|
|
2818
|
+
format: "position",
|
|
2819
|
+
apiField: "position",
|
|
2820
|
+
type: "api"
|
|
2821
|
+
}
|
|
2822
|
+
];
|
|
2823
|
+
|
|
2824
|
+
// src/platforms/gsc/resources.ts
|
|
2825
|
+
var GSC_MANIFEST = {
|
|
2826
|
+
platform: "google-search-console",
|
|
2827
|
+
package: "@getmcpads/google-search-console-mcp-server",
|
|
2828
|
+
access: "read-only",
|
|
2829
|
+
oauthScope: GSC_OAUTH_SCOPE,
|
|
2830
|
+
defaultPropertyEnv: "GSC_SITE_URL",
|
|
2831
|
+
quotaNotes: {
|
|
2832
|
+
searchAnalytics: "Read-only Search Analytics queries. Maximum 25,000 rows per request.",
|
|
2833
|
+
urlInspection: "Read-only URL Inspection. Google quota is limited; bulk inspection is capped at 10 URLs per call.",
|
|
2834
|
+
p2Monitoring: "P2 tools are read-only orchestration helpers. Delta tracking is caller-managed: pass prior snapshots back in; no server-side state is written."
|
|
2835
|
+
},
|
|
2836
|
+
tools: [
|
|
2837
|
+
{ name: "gsc_health_check", category: "diagnostics", description: "Verify auth, visible sites, default property, permissions, and warnings." },
|
|
2838
|
+
{ name: "gsc_list_sites", category: "properties", description: "List visible Search Console properties." },
|
|
2839
|
+
{ name: "gsc_get_site", category: "properties", description: "Get one property and its exact permission level." },
|
|
2840
|
+
{ name: "gsc_query_search_analytics", category: "analytics", description: "Query clicks, impressions, CTR, and position across supported dimensions." },
|
|
2841
|
+
{ name: "gsc_get_data_freshness", category: "analytics", description: "Find the latest recent date with Search Analytics data." },
|
|
2842
|
+
{ name: "gsc_monitor_indexation_freshness", category: "monitoring", description: "Monitor Search Analytics freshness, sitemap indexation totals, and optional small URL Inspection samples." },
|
|
2843
|
+
{ name: "gsc_track_sitemap_deltas", category: "sitemaps", description: "Compare current sitemap counts/statuses to an agent-provided baseline snapshot." },
|
|
2844
|
+
{ name: "gsc_analyze_search_appearance_trends", category: "analytics", description: "Trend structured data/rich result visibility via searchAppearance when available." },
|
|
2845
|
+
{ name: "gsc_plan_large_site_sampling", category: "planning", description: "Build a large-site URL Inspection sampling plan from sitemaps and page analytics." },
|
|
2846
|
+
{ name: "gsc_indexation_watchlist", category: "indexing", description: "Inspect up to 10 watchlist URLs and classify alert/review/ok indexation status." },
|
|
2847
|
+
{ name: "gsc_find_losses_gains", category: "analytics", description: "Compare query/page performance between two periods." },
|
|
2848
|
+
{ name: "gsc_cluster_queries", category: "analytics", description: "Cluster queries by brand, question intent, category, and tokens." },
|
|
2849
|
+
{ name: "gsc_detect_cannibalization", category: "analytics", description: "Detect queries where multiple pages compete." },
|
|
2850
|
+
{ name: "gsc_inspect_url", category: "indexing", description: "Inspect one URL for indexing, mobile usability, AMP, and rich result status." },
|
|
2851
|
+
{ name: "gsc_bulk_inspect_urls", category: "indexing", description: "Inspect up to 10 URLs sequentially with normalized results." },
|
|
2852
|
+
{ name: "gsc_list_sitemaps", category: "sitemaps", description: "List submitted sitemaps." },
|
|
2853
|
+
{ name: "gsc_get_sitemap", category: "sitemaps", description: "Get one submitted sitemap by feed path." },
|
|
2854
|
+
{ name: "gsc_get_sitemap_health", category: "sitemaps", description: "Summarize sitemap status, warnings, errors, submitted, and indexed counts." },
|
|
2855
|
+
{ name: "gsc_compare_search_types", category: "analytics", description: "Compare web, image, video, news, Discover, and Google News performance." },
|
|
2856
|
+
{ name: "gsc_validate_query", category: "planning", description: "Validate dimensions/search type/date compatibility." }
|
|
2857
|
+
],
|
|
2858
|
+
resources: [
|
|
2859
|
+
"gsc://manifest",
|
|
2860
|
+
"gsc://recipes",
|
|
2861
|
+
"gsc://metrics",
|
|
2862
|
+
"gsc://dimensions",
|
|
2863
|
+
"gsc://filters",
|
|
2864
|
+
"gsc://compatibility",
|
|
2865
|
+
"gsc://p2-readonly-playbooks"
|
|
2866
|
+
]
|
|
2867
|
+
};
|
|
2868
|
+
var GSC_RECIPES = [
|
|
2869
|
+
{
|
|
2870
|
+
name: "preflight",
|
|
2871
|
+
goal: "Confirm the MCP can read the right GSC property before analysis.",
|
|
2872
|
+
steps: [
|
|
2873
|
+
{ tool: "gsc_health_check", args: { siteUrl: "optional property URL" } },
|
|
2874
|
+
{ tool: "gsc_get_data_freshness", args: { searchType: "web", lookbackDays: 14 } }
|
|
2875
|
+
]
|
|
2876
|
+
},
|
|
2877
|
+
{
|
|
2878
|
+
name: "weekly-performance-review",
|
|
2879
|
+
goal: "Find query or page winners and losers between two equal periods.",
|
|
2880
|
+
steps: [
|
|
2881
|
+
{ tool: "gsc_find_losses_gains", args: { dimension: "query", datePreset: "last28days", topN: 25 } },
|
|
2882
|
+
{ tool: "gsc_find_losses_gains", args: { dimension: "page", datePreset: "last28days", topN: 25 } }
|
|
2883
|
+
]
|
|
2884
|
+
},
|
|
2885
|
+
{
|
|
2886
|
+
name: "indexing-and-sitemap-triage",
|
|
2887
|
+
goal: "Check sitemap health, then inspect a small set of URLs without burning URL Inspection quota.",
|
|
2888
|
+
steps: [
|
|
2889
|
+
{ tool: "gsc_get_sitemap_health", args: {} },
|
|
2890
|
+
{ tool: "gsc_bulk_inspect_urls", args: { urls: ["https://example.com/page"], continueOnError: true } }
|
|
2891
|
+
],
|
|
2892
|
+
caution: "Keep URL Inspection batches small; bulk calls are capped at 10 URLs."
|
|
2893
|
+
},
|
|
2894
|
+
{
|
|
2895
|
+
name: "p2-readonly-monitoring",
|
|
2896
|
+
goal: "Run a no-write monitoring pass for freshness, sitemap indexation, and optional critical URL checks.",
|
|
2897
|
+
steps: [
|
|
2898
|
+
{ tool: "gsc_monitor_indexation_freshness", args: { searchTypes: ["web"], lookbackDays: 14, inspectUrls: [] } },
|
|
2899
|
+
{ tool: "gsc_indexation_watchlist", args: { urls: [{ url: "https://example.com/important-page", priority: "high" }] } }
|
|
2900
|
+
],
|
|
2901
|
+
caution: "URL Inspection quota is limited; keep watchlists capped at 10 URLs per call."
|
|
2902
|
+
},
|
|
2903
|
+
{
|
|
2904
|
+
name: "p2-sitemap-delta-loop",
|
|
2905
|
+
goal: "Compare sitemap submitted/indexed counts against a previous agent-held snapshot.",
|
|
2906
|
+
steps: [
|
|
2907
|
+
{ tool: "gsc_track_sitemap_deltas", args: { baseline: [] } },
|
|
2908
|
+
{ tool: "gsc_track_sitemap_deltas", args: { baseline: [{ path: "https://example.com/sitemap.xml", submitted: 1e3, indexed: 950, warnings: 0, errors: 0 }] } }
|
|
2909
|
+
],
|
|
2910
|
+
caution: "This server does not persist snapshots. Store snapshots in the calling workflow if deltas are needed later."
|
|
2911
|
+
},
|
|
2912
|
+
{
|
|
2913
|
+
name: "p2-rich-result-trends",
|
|
2914
|
+
goal: "Track structured data and rich-result visibility via Search Analytics searchAppearance rows.",
|
|
2915
|
+
steps: [
|
|
2916
|
+
{ tool: "gsc_analyze_search_appearance_trends", args: { datePreset: "last28days", includePages: false } },
|
|
2917
|
+
{ tool: "gsc_analyze_search_appearance_trends", args: { datePreset: "last28days", includePages: true, topN: 25 } }
|
|
2918
|
+
],
|
|
2919
|
+
caution: "searchAppearance only returns rows when the property/date/search type has appearance data available."
|
|
2920
|
+
},
|
|
2921
|
+
{
|
|
2922
|
+
name: "p2-large-site-sampling",
|
|
2923
|
+
goal: "Create an inspection sample before spending URL Inspection quota on a large site.",
|
|
2924
|
+
steps: [
|
|
2925
|
+
{ tool: "gsc_plan_large_site_sampling", args: { datePreset: "last28days", maxInspectionUrls: 10 } },
|
|
2926
|
+
{ tool: "gsc_indexation_watchlist", args: { urls: [{ url: "https://example.com/page-from-recommendedInspectionUrls", priority: "medium" }] } }
|
|
2927
|
+
]
|
|
2928
|
+
},
|
|
2929
|
+
{
|
|
2930
|
+
name: "content-opportunity-map",
|
|
2931
|
+
goal: "Cluster demand and detect query-page overlap.",
|
|
2932
|
+
steps: [
|
|
2933
|
+
{ tool: "gsc_cluster_queries", args: { datePreset: "last28days", limit: 5e3 } },
|
|
2934
|
+
{ tool: "gsc_detect_cannibalization", args: { datePreset: "last28days", minImpressions: 100 } }
|
|
2935
|
+
]
|
|
2936
|
+
}
|
|
2937
|
+
];
|
|
2938
|
+
var GSC_P2_READONLY_PLAYBOOKS = {
|
|
2939
|
+
scope: "P2 read-only agent workflows for monitoring freshness/indexation, sitemap deltas, rich-result trends, large-site sampling, and indexation watchlists.",
|
|
2940
|
+
invariants: [
|
|
2941
|
+
"No tool writes to Search Console or local state.",
|
|
2942
|
+
"Sitemap delta tracking compares against caller-provided baselines only.",
|
|
2943
|
+
"URL Inspection is read-only but quota-limited; all P2 inspection tools cap calls at 10 URLs.",
|
|
2944
|
+
"Use Search Analytics searchAppearance only when rows are available for the selected property/date/search type."
|
|
2945
|
+
],
|
|
2946
|
+
workflows: [
|
|
2947
|
+
{
|
|
2948
|
+
name: "freshness-indexation-monitor",
|
|
2949
|
+
primaryTool: "gsc_monitor_indexation_freshness",
|
|
2950
|
+
followUps: ["gsc_track_sitemap_deltas", "gsc_indexation_watchlist"],
|
|
2951
|
+
outputUse: "Alert on stale Search Analytics data, sitemap errors/pending states, or unexpected URL Inspection verdicts."
|
|
2952
|
+
},
|
|
2953
|
+
{
|
|
2954
|
+
name: "sitemap-delta-tracking",
|
|
2955
|
+
primaryTool: "gsc_track_sitemap_deltas",
|
|
2956
|
+
followUps: ["gsc_get_sitemap_health"],
|
|
2957
|
+
outputUse: "Persist the returned snapshot externally, then pass it as baseline on the next run."
|
|
2958
|
+
},
|
|
2959
|
+
{
|
|
2960
|
+
name: "structured-data-trends",
|
|
2961
|
+
primaryTool: "gsc_analyze_search_appearance_trends",
|
|
2962
|
+
followUps: ["gsc_query_search_analytics"],
|
|
2963
|
+
outputUse: "Find searchAppearance winners/losses and optionally page-level feature changes."
|
|
2964
|
+
},
|
|
2965
|
+
{
|
|
2966
|
+
name: "large-site-indexation-sampling",
|
|
2967
|
+
primaryTool: "gsc_plan_large_site_sampling",
|
|
2968
|
+
followUps: ["gsc_indexation_watchlist", "gsc_bulk_inspect_urls"],
|
|
2969
|
+
outputUse: "Spend URL Inspection quota on representative top-traffic, low-CTR, declining, and sitemap-risk URLs."
|
|
2970
|
+
}
|
|
2971
|
+
]
|
|
2972
|
+
};
|
|
2973
|
+
function registerGSCResources(server) {
|
|
2974
|
+
server.resource("gsc-manifest", "gsc://manifest", async () => ({
|
|
2975
|
+
contents: [{
|
|
2976
|
+
uri: "gsc://manifest",
|
|
2977
|
+
mimeType: "application/json",
|
|
2978
|
+
text: JSON.stringify(GSC_MANIFEST, null, 2)
|
|
2979
|
+
}]
|
|
2980
|
+
}));
|
|
2981
|
+
server.resource("gsc-recipes", "gsc://recipes", async () => ({
|
|
2982
|
+
contents: [{
|
|
2983
|
+
uri: "gsc://recipes",
|
|
2984
|
+
mimeType: "application/json",
|
|
2985
|
+
text: JSON.stringify(GSC_RECIPES, null, 2)
|
|
2986
|
+
}]
|
|
2987
|
+
}));
|
|
2988
|
+
server.resource("gsc-metrics", "gsc://metrics", async () => ({
|
|
2989
|
+
contents: [{
|
|
2990
|
+
uri: "gsc://metrics",
|
|
2991
|
+
mimeType: "application/json",
|
|
2992
|
+
text: JSON.stringify([...GSC_METRIC_CATALOG, ...GSC_CALCULATED_METRIC_DEFINITIONS], null, 2)
|
|
2993
|
+
}]
|
|
2994
|
+
}));
|
|
2995
|
+
server.resource("gsc-dimensions", "gsc://dimensions", async () => ({
|
|
2996
|
+
contents: [{
|
|
2997
|
+
uri: "gsc://dimensions",
|
|
2998
|
+
mimeType: "application/json",
|
|
2999
|
+
text: JSON.stringify(GSC_DIMENSION_CATALOG, null, 2)
|
|
3000
|
+
}]
|
|
3001
|
+
}));
|
|
3002
|
+
server.resource("gsc-filters", "gsc://filters", async () => ({
|
|
3003
|
+
contents: [{
|
|
3004
|
+
uri: "gsc://filters",
|
|
3005
|
+
mimeType: "application/json",
|
|
3006
|
+
text: JSON.stringify(GSC_FILTER_CATALOG, null, 2)
|
|
3007
|
+
}]
|
|
3008
|
+
}));
|
|
3009
|
+
server.resource("gsc-compatibility", "gsc://compatibility", async () => ({
|
|
3010
|
+
contents: [{
|
|
3011
|
+
uri: "gsc://compatibility",
|
|
3012
|
+
mimeType: "application/json",
|
|
3013
|
+
text: JSON.stringify({
|
|
3014
|
+
description: "Google Search Console Search Analytics constraints.",
|
|
3015
|
+
maxDimensions: 5,
|
|
3016
|
+
dimensions: ["query", "page", "country", "device", "date", "hour", "searchAppearance"],
|
|
3017
|
+
searchTypes: ["web", "image", "video", "news", "discover", "googleNews"],
|
|
3018
|
+
queryDimensionUnsupportedFor: ["discover", "googleNews"],
|
|
3019
|
+
dataStates: ["final", "all", "hourly_all"],
|
|
3020
|
+
aggregationTypes: ["auto", "byPage", "byProperty", "byNewsShowcasePanel"],
|
|
3021
|
+
aggregationRules: {
|
|
3022
|
+
byProperty: "Cannot group or filter by page and is unsupported for discover/googleNews.",
|
|
3023
|
+
byNewsShowcasePanel: "Requires type=discover or type=googleNews and searchAppearance equals NEWS_SHOWCASE; cannot group/filter by page or filter to another searchAppearance."
|
|
3024
|
+
},
|
|
3025
|
+
dataFreshness: "final is stable; all includes incomplete daily data; hourly_all requires hour, is limited to 10 days, and can return firstIncompleteHour metadata.",
|
|
3026
|
+
rowLimitMax: 25e3
|
|
3027
|
+
}, null, 2)
|
|
3028
|
+
}]
|
|
3029
|
+
}));
|
|
3030
|
+
server.resource("gsc-p2-readonly-playbooks", "gsc://p2-readonly-playbooks", async () => ({
|
|
3031
|
+
contents: [{
|
|
3032
|
+
uri: "gsc://p2-readonly-playbooks",
|
|
3033
|
+
mimeType: "application/json",
|
|
3034
|
+
text: JSON.stringify(GSC_P2_READONLY_PLAYBOOKS, null, 2)
|
|
3035
|
+
}]
|
|
3036
|
+
}));
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
// src/platforms/gsc/index.ts
|
|
3040
|
+
function registerGSC(server, config) {
|
|
3041
|
+
registerGSCTools(server, config);
|
|
3042
|
+
registerGSCResources(server);
|
|
3043
|
+
logger.info("gsc", "Registered 20 read tools and 7 resources");
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
// src/server.ts
|
|
3047
|
+
var PACKAGE_VERSION = "1.0.0";
|
|
3048
|
+
function createServer(config) {
|
|
3049
|
+
const server = new McpServer(
|
|
3050
|
+
{ name: "google-search-console-mcp", version: PACKAGE_VERSION },
|
|
3051
|
+
{ capabilities: { tools: { listChanged: true }, resources: { subscribe: false, listChanged: true } } }
|
|
3052
|
+
);
|
|
3053
|
+
registerGSC(server, config);
|
|
3054
|
+
logger.system(`google-search-console-mcp v${PACKAGE_VERSION} ready, read-only`);
|
|
3055
|
+
return server;
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
// src/cli.ts
|
|
3059
|
+
async function main() {
|
|
3060
|
+
try {
|
|
3061
|
+
const config = loadConfig();
|
|
3062
|
+
logger.setLevel(config.logLevel);
|
|
3063
|
+
const server = createServer(config);
|
|
3064
|
+
const transport = new StdioServerTransport();
|
|
3065
|
+
await server.connect(transport);
|
|
3066
|
+
logger.system("Stdio transport connected - waiting for MCP client");
|
|
3067
|
+
} catch (error) {
|
|
3068
|
+
logger.error("cli", "Fatal error", error instanceof Error ? error.message : error);
|
|
3069
|
+
process.exit(1);
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
main();
|
|
3073
|
+
//# sourceMappingURL=cli.js.map
|