@digestseo/mcp-gsc 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ const MINUTE_MS = 60 * 1000;
2
+ const HOUR_MS = 60 * MINUTE_MS;
3
+ const DAY_MS = 24 * HOUR_MS;
4
+
5
+ export interface ToolRateLimitPolicy {
6
+ /** The coordination bucket shared by the tools in this policy. */
7
+ category: string;
8
+ tools: readonly string[];
9
+ windowMs: number;
10
+ userLimit: number;
11
+ /**
12
+ * A project-wide cap is used only where Google publishes a small, shared
13
+ * project quota. It is intentionally absent for ordinary GSC operations.
14
+ */
15
+ projectLimit?: number;
16
+ }
17
+
18
+ /**
19
+ * Limits apply before an upstream request is made. The values are intentionally
20
+ * conservative relative to Google's published quotas, because a self-hosted
21
+ * deployment can have multiple authenticated users sharing one Cloud project.
22
+ *
23
+ * Sources:
24
+ * - https://developers.google.com/webmaster-tools/limits
25
+ * - https://developers.google.com/search/apis/indexing-api/v3/quota-pricing
26
+ */
27
+ export const TOOL_RATE_LIMIT_POLICIES = {
28
+ search_analytics: {
29
+ category: 'search-analytics',
30
+ tools: [
31
+ 'analytics.query',
32
+ 'insights.page_queries',
33
+ 'insights.query_pages',
34
+ 'insights.quick_wins',
35
+ 'insights.cannibalization',
36
+ 'insights.content_decay',
37
+ 'indexing.list_pages',
38
+ 'analytics.compare',
39
+ ],
40
+ // GSC measures Search Analytics short-term load in 10-minute windows.
41
+ windowMs: 10 * MINUTE_MS,
42
+ userLimit: 30,
43
+ },
44
+ url_inspection: {
45
+ category: 'url-inspection',
46
+ tools: ['urls.inspect', 'urls.inspect_many'],
47
+ // This is one percent of GSC's published 2,000
48
+ // inspections-per-site-per-day quota for each user.
49
+ windowMs: DAY_MS,
50
+ userLimit: 20,
51
+ },
52
+ search_console_writes: {
53
+ category: 'search-console-write',
54
+ tools: ['sites.add', 'sites.delete', 'sitemaps.submit', 'sitemaps.delete'],
55
+ // Mutation safety guard, far below GSC's 200 requests-per-minute
56
+ // per-user limit for non-Search-Analytics resources.
57
+ windowMs: HOUR_MS,
58
+ userLimit: 10,
59
+ },
60
+ indexing_request: {
61
+ category: 'indexing-request',
62
+ tools: ['indexing.request'],
63
+ // Google defaults to 200 publish requests per project per day. Two per
64
+ // user reserves the default quota for up to 100 authenticated users.
65
+ windowMs: DAY_MS,
66
+ userLimit: 2,
67
+ projectLimit: 200,
68
+ },
69
+ weekly_digest: {
70
+ category: 'weekly-digest',
71
+ tools: ['reports.weekly_digest'],
72
+ // A digest makes five Search Analytics requests, so it has its own bucket.
73
+ windowMs: HOUR_MS,
74
+ userLimit: 6,
75
+ },
76
+ } as const satisfies Record<string, ToolRateLimitPolicy>;
77
+
78
+ export type ToolRateLimitCategory = keyof typeof TOOL_RATE_LIMIT_POLICIES;
79
+ export type RateLimitedToolName =
80
+ (typeof TOOL_RATE_LIMIT_POLICIES)[ToolRateLimitCategory]['tools'][number];
81
+
82
+ const POLICY_BY_TOOL = new Map<string, ToolRateLimitPolicy>(
83
+ Object.values(TOOL_RATE_LIMIT_POLICIES).flatMap((policy) =>
84
+ policy.tools.map((tool) => [tool, policy] as const),
85
+ ),
86
+ );
87
+
88
+ interface RateLimitWindow {
89
+ window_started_at: number;
90
+ count: number;
91
+ }
92
+
93
+ export interface ToolRateLimitResult {
94
+ allowed: boolean;
95
+ retry_after_ms?: number;
96
+ }
97
+
98
+ interface ToolRateLimiterEnv {
99
+ TOOL_RATE_LIMITER: DurableObjectNamespace;
100
+ }
101
+
102
+ interface ToolRateLimiterStub {
103
+ take(
104
+ userBucket: string,
105
+ policy: ToolRateLimitPolicy,
106
+ units?: number,
107
+ ): Promise<ToolRateLimitResult>;
108
+ }
109
+
110
+ function currentWindow(
111
+ value: RateLimitWindow | undefined,
112
+ now: number,
113
+ windowMs: number,
114
+ ): RateLimitWindow {
115
+ if (!value || now >= value.window_started_at + windowMs) {
116
+ return { window_started_at: now, count: 0 };
117
+ }
118
+ return value;
119
+ }
120
+
121
+ function retryAfterMs(window: RateLimitWindow, now: number, windowMs: number): number {
122
+ return Math.max(0, window.window_started_at + windowMs - now);
123
+ }
124
+
125
+ /**
126
+ * Coordinates one policy category. Ordinary categories are sharded by the
127
+ * authenticated user's hashed bucket. Indexing uses one category instance so
128
+ * its small Google project quota can be enforced together with user limits.
129
+ */
130
+ export class ToolRateLimiterCore {
131
+ constructor(
132
+ private readonly state: DurableObjectState,
133
+ _env: unknown,
134
+ private readonly now: () => number = Date.now,
135
+ ) {}
136
+
137
+ async take(
138
+ userBucket: string,
139
+ policy: ToolRateLimitPolicy,
140
+ units = 1,
141
+ ): Promise<ToolRateLimitResult> {
142
+ if (!Number.isInteger(units) || units < 1) {
143
+ throw new Error('Rate-limit units must be a positive integer.');
144
+ }
145
+ return this.state.storage.transaction(async (txn) => {
146
+ const now = this.now();
147
+ const userKey = `user:${userBucket}`;
148
+ const userWindow = currentWindow(
149
+ await txn.get<RateLimitWindow>(userKey),
150
+ now,
151
+ policy.windowMs,
152
+ );
153
+ const projectWindow = policy.projectLimit === undefined
154
+ ? undefined
155
+ : currentWindow(
156
+ await txn.get<RateLimitWindow>('project'),
157
+ now,
158
+ policy.windowMs,
159
+ );
160
+
161
+ const userExceeded = userWindow.count + units > policy.userLimit;
162
+ const projectExceeded = projectWindow !== undefined
163
+ && projectWindow.count + units > policy.projectLimit!;
164
+ if (userExceeded || projectExceeded) {
165
+ const waits = [
166
+ ...(userExceeded
167
+ ? [retryAfterMs(userWindow, now, policy.windowMs)]
168
+ : []),
169
+ ...(projectExceeded && projectWindow
170
+ ? [retryAfterMs(projectWindow, now, policy.windowMs)]
171
+ : []),
172
+ ];
173
+ return {
174
+ allowed: false,
175
+ // If both limits apply, waiting for the longer window guarantees the
176
+ // retry will not immediately encounter the other limit.
177
+ retry_after_ms: Math.max(...waits),
178
+ };
179
+ }
180
+
181
+ await txn.put(userKey, { ...userWindow, count: userWindow.count + units });
182
+ if (projectWindow) {
183
+ await txn.put('project', {
184
+ ...projectWindow,
185
+ count: projectWindow.count + units,
186
+ });
187
+ }
188
+ return { allowed: true };
189
+ });
190
+ }
191
+ }
192
+
193
+ export function getToolRateLimitPolicy(toolName: string): ToolRateLimitPolicy | undefined {
194
+ return POLICY_BY_TOOL.get(toolName);
195
+ }
196
+
197
+ async function userBucketFor(googleId: string): Promise<string> {
198
+ const digest = await crypto.subtle.digest(
199
+ 'SHA-256',
200
+ new TextEncoder().encode(googleId),
201
+ );
202
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
203
+ }
204
+
205
+ function getToolRateLimiterStub(
206
+ env: ToolRateLimiterEnv,
207
+ policy: ToolRateLimitPolicy,
208
+ userBucket: string,
209
+ ): ToolRateLimiterStub {
210
+ // The Indexing API's low project-wide quota is the coordination atom for
211
+ // that category. All other policies are sharded by user and category.
212
+ const name = policy.projectLimit === undefined
213
+ ? `${policy.category}:${userBucket}`
214
+ : policy.category;
215
+ return env.TOOL_RATE_LIMITER.get(
216
+ env.TOOL_RATE_LIMITER.idFromName(name),
217
+ ) as unknown as ToolRateLimiterStub;
218
+ }
219
+
220
+ export async function enforceToolRateLimit(
221
+ env: ToolRateLimiterEnv,
222
+ googleId: string,
223
+ toolName: string,
224
+ units = 1,
225
+ ): Promise<ToolRateLimitResult> {
226
+ const policy = getToolRateLimitPolicy(toolName);
227
+ if (!policy) return { allowed: true };
228
+
229
+ const userBucket = await userBucketFor(googleId);
230
+ return getToolRateLimiterStub(env, policy, userBucket).take(userBucket, policy, units);
231
+ }
@@ -0,0 +1,23 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import {
3
+ ToolRateLimiterCore,
4
+ type ToolRateLimitPolicy,
5
+ type ToolRateLimitResult,
6
+ } from './tool-rate-limit';
7
+
8
+ export class ToolRateLimiter extends DurableObject<Record<string, unknown>> {
9
+ private readonly core: ToolRateLimiterCore;
10
+
11
+ constructor(ctx: DurableObjectState, env: Record<string, unknown>) {
12
+ super(ctx, env);
13
+ this.core = new ToolRateLimiterCore(ctx, env);
14
+ }
15
+
16
+ take(
17
+ userBucket: string,
18
+ policy: ToolRateLimitPolicy,
19
+ units = 1,
20
+ ): Promise<ToolRateLimitResult> {
21
+ return this.core.take(userBucket, policy, units);
22
+ }
23
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * MCP hints for each mutation exposed by this server.
3
+ *
4
+ * Search Console's sites and sitemaps APIs use PUT for additions and DELETE
5
+ * for removals, so repeating the same request has the same intended effect.
6
+ * Indexing publishes a URL_UPDATED notification with POST; each call can
7
+ * consume quota and update Google's notification state, so it is not marked
8
+ * idempotent. Every mutation is marked destructive so MCP clients require
9
+ * explicit confirmation before changing external Google state.
10
+ */
11
+ export const WRITE_TOOL_ANNOTATIONS = {
12
+ 'sites.add': {
13
+ readOnlyHint: false,
14
+ destructiveHint: true,
15
+ idempotentHint: true,
16
+ openWorldHint: true,
17
+ },
18
+ 'sites.delete': {
19
+ readOnlyHint: false,
20
+ destructiveHint: true,
21
+ idempotentHint: true,
22
+ openWorldHint: true,
23
+ },
24
+ 'sitemaps.submit': {
25
+ readOnlyHint: false,
26
+ destructiveHint: true,
27
+ idempotentHint: true,
28
+ openWorldHint: true,
29
+ },
30
+ 'sitemaps.delete': {
31
+ readOnlyHint: false,
32
+ destructiveHint: true,
33
+ idempotentHint: true,
34
+ openWorldHint: true,
35
+ },
36
+ 'indexing.request': {
37
+ // The implementation only publishes URL_UPDATED, never URL_DELETED.
38
+ readOnlyHint: false,
39
+ destructiveHint: true,
40
+ idempotentHint: false,
41
+ openWorldHint: true,
42
+ },
43
+ } as const;
@@ -1,35 +1,54 @@
1
- // Cloudflare Worker configuration for mcp-gsc.
2
- //
3
- // Copy this file to `wrangler.jsonc` (gitignored) and fill in your own
4
- // values before deploying. See SETUP.md for the full walkthrough.
5
- {
6
- "name": "mcp-gsc",
7
- "main": "src/index.ts",
8
- "compatibility_date": "2026-05-19",
9
- "compatibility_flags": ["nodejs_compat"],
10
- "observability": { "enabled": true },
11
- "kv_namespaces": [
12
- {
13
- "binding": "OAUTH_KV",
14
- "id": "YOUR_OAUTH_KV_ID"
15
- },
16
- {
17
- "binding": "USER_KV",
18
- "id": "YOUR_USER_KV_ID"
19
- }
20
- ],
21
- "durable_objects": {
22
- "bindings": [
23
- {
24
- "name": "MCP_OBJECT",
25
- "class_name": "GscMcpAgent"
26
- }
27
- ]
28
- },
29
- "migrations": [
30
- {
31
- "tag": "v1",
32
- "new_sqlite_classes": ["GscMcpAgent"]
33
- }
34
- ]
35
- }
1
+ // Cloudflare Worker configuration for mcp-gsc.
2
+ //
3
+ // Copy this file to `wrangler.jsonc` (gitignored) and fill in your own
4
+ // values before deploying. See SETUP.md for the full walkthrough.
5
+ {
6
+ "name": "mcp-gsc",
7
+ "main": "src/entrypoint.ts",
8
+ "compatibility_date": "2026-05-19",
9
+ "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
10
+ "observability": { "enabled": true },
11
+ "vars": {
12
+ "GSC_ACCESS_MODE": "readwrite"
13
+ },
14
+ "kv_namespaces": [
15
+ {
16
+ "binding": "OAUTH_KV",
17
+ "id": "YOUR_OAUTH_KV_ID"
18
+ },
19
+ {
20
+ "binding": "USER_KV",
21
+ "id": "YOUR_USER_KV_ID"
22
+ }
23
+ ],
24
+ "durable_objects": {
25
+ "bindings": [
26
+ {
27
+ "name": "MCP_OBJECT",
28
+ "class_name": "GscMcpAgent"
29
+ },
30
+ {
31
+ "name": "PENDING_AUTH_STATE",
32
+ "class_name": "PendingAuthState"
33
+ },
34
+ {
35
+ "name": "TOOL_RATE_LIMITER",
36
+ "class_name": "ToolRateLimiter"
37
+ }
38
+ ]
39
+ },
40
+ "migrations": [
41
+ {
42
+ "tag": "v1",
43
+ "new_sqlite_classes": ["GscMcpAgent"]
44
+ },
45
+ {
46
+ "tag": "v2",
47
+ "new_sqlite_classes": ["PendingAuthState"]
48
+ },
49
+ {
50
+ "tag": "v3",
51
+ "new_sqlite_classes": ["ToolRateLimiter"]
52
+ }
53
+ ]
54
+ }