@zackbart/connecta 0.29.0 → 0.31.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +126 -134
  3. package/bin/connecta.mjs +13 -2
  4. package/bin/version.mjs +1 -1
  5. package/dist/argument-redaction.js +230 -0
  6. package/dist/auth-elicitation.js +14 -9
  7. package/dist/authorization-handoff.js +111 -0
  8. package/dist/call-recovery.js +25 -0
  9. package/dist/catalog-cache.js +2 -2
  10. package/dist/catalog-service.js +103 -63
  11. package/dist/config-schema.d.ts +14 -7
  12. package/dist/config-schema.js +29 -10
  13. package/dist/config.d.ts +2 -0
  14. package/dist/config.js +12 -7
  15. package/dist/connectors/guarded-fetch.d.ts +9 -1
  16. package/dist/connectors/guarded-fetch.js +17 -5
  17. package/dist/connectors/negotiation-cache.js +1 -1
  18. package/dist/downstream-input-context.js +9 -0
  19. package/dist/downstream-input.js +13 -11
  20. package/dist/errors.d.ts +38 -12
  21. package/dist/errors.js +19 -9
  22. package/dist/execute.js +375 -43
  23. package/dist/executor-result.js +11 -9
  24. package/dist/executors/quickjs-child.js +12 -5
  25. package/dist/executors/quickjs-protocol.js +5 -6
  26. package/dist/executors/quickjs-runtime.js +6 -1
  27. package/dist/executors/quickjs.js +20 -4
  28. package/dist/guest-runtime.js +52 -2
  29. package/dist/guest-types.d.ts +47 -5
  30. package/dist/index.d.ts +1 -1
  31. package/dist/invocation.js +72 -14
  32. package/dist/meta-output.js +0 -15
  33. package/dist/meta-tools.js +166 -95
  34. package/dist/oauth-handoff.js +88 -11
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +9 -0
  37. package/dist/operator-ui/view.js +11 -0
  38. package/dist/program-result.js +5 -0
  39. package/dist/program-writes.js +7 -48
  40. package/dist/provider.d.ts +8 -6
  41. package/dist/provider.js +3 -4
  42. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  43. package/dist/providers/_shared/request-token.js +83 -0
  44. package/dist/providers/_shared/rest/dispatch.js +41 -0
  45. package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
  46. package/dist/providers/_shared/rest/operation-index.js +527 -0
  47. package/dist/providers/_shared/rest/tools.d.ts +141 -0
  48. package/dist/providers/_shared/rest/tools.js +569 -0
  49. package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
  50. package/dist/providers/_shared/rest/value-safety.js +580 -0
  51. package/dist/providers/cloudflare/graphql.js +385 -0
  52. package/dist/providers/cloudflare/index.d.ts +47 -51
  53. package/dist/providers/cloudflare/index.js +188 -1997
  54. package/dist/providers/cloudflare/openapi.generated.js +3347 -0
  55. package/dist/providers/cloudflare/rest.d.ts +37 -0
  56. package/dist/providers/cloudflare/rest.js +1056 -0
  57. package/dist/providers/cloudflare/skill.generated.js +7 -4
  58. package/dist/providers/cloudflare/value-safety.js +1247 -0
  59. package/dist/providers/infisical/index.d.ts +7 -0
  60. package/dist/providers/infisical/index.js +713 -0
  61. package/dist/providers/infisical/skill.generated.js +8 -0
  62. package/dist/providers/notion/index.d.ts +35 -31
  63. package/dist/providers/notion/index.js +60 -1499
  64. package/dist/providers/notion/openapi.generated.js +75 -0
  65. package/dist/providers/notion/rest.d.ts +24 -0
  66. package/dist/providers/notion/rest.js +1067 -0
  67. package/dist/providers/notion/skill.generated.js +5 -4
  68. package/dist/providers/notion/value-safety.js +31 -0
  69. package/dist/providers/stripe/index.d.ts +26 -20
  70. package/dist/providers/stripe/index.js +103 -130
  71. package/dist/providers/stripe/openapi.generated.js +649 -0
  72. package/dist/providers/stripe/rest.d.ts +14 -0
  73. package/dist/providers/stripe/rest.js +395 -0
  74. package/dist/providers/stripe/skill.generated.js +7 -5
  75. package/dist/providers/stripe/value-safety.js +705 -0
  76. package/dist/providers/vercel/index.d.ts +31 -22
  77. package/dist/providers/vercel/index.js +96 -853
  78. package/dist/providers/vercel/openapi.generated.js +456 -0
  79. package/dist/providers/vercel/rest.d.ts +20 -0
  80. package/dist/providers/vercel/rest.js +1002 -0
  81. package/dist/providers/vercel/skill.generated.js +4 -5
  82. package/dist/providers/vercel/value-safety.js +328 -0
  83. package/dist/registry.d.ts +8 -6
  84. package/dist/registry.js +134 -61
  85. package/dist/resource-read.js +4 -1
  86. package/dist/routes/mcp.js +11 -2
  87. package/dist/sent-secrets.d.ts +54 -13
  88. package/dist/sent-secrets.js +770 -231
  89. package/dist/skills.js +26 -16
  90. package/dist/sqlite.js +21 -6
  91. package/dist/storage/keys.js +20 -5
  92. package/dist/storage/memory.js +9 -4
  93. package/dist/storage/sql.js +18 -9
  94. package/dist/types.d.ts +17 -3
  95. package/dist/usage-guide.js +49 -40
  96. package/dist/version.d.ts +1 -1
  97. package/dist/version.js +1 -1
  98. package/dist/worker.js +2 -1
  99. package/dist/write-outcome.js +47 -0
  100. package/documentation/README.md +64 -0
  101. package/documentation/architecture.md +241 -97
  102. package/documentation/auth.md +138 -5
  103. package/documentation/code-mode.md +224 -107
  104. package/documentation/deploying.md +146 -0
  105. package/documentation/integrating.md +170 -0
  106. package/documentation/meta-tools.md +142 -30
  107. package/documentation/operating.md +135 -0
  108. package/documentation/operator-tests.md +4 -0
  109. package/documentation/operator-ui.md +132 -0
  110. package/documentation/provider-auth-migration.md +307 -0
  111. package/documentation/provider-migration-0.29.md +22 -5
  112. package/examples/worker/README.md +9 -2
  113. package/package.json +9 -2
  114. package/templates/node/README.md +9 -2
  115. package/templates/node/package.json +1 -1
  116. package/dist/providers/vercel/mcp-ownership.js +0 -212
@@ -1,107 +1,49 @@
1
1
  import { skill } from "./skill.generated.js";
2
2
  /**
3
- * No official `cloudflare` SDK on purpose. What the SDK sells — typed request
4
- * wrappers and pagination helpers — is what this connection replaces: an agent
5
- * gets a projected result and a `page.hasMore` boolean, so the SDK's types
6
- * would be re-projected away at the boundary, and the dependency would cost an
7
- * optional peer, an install step, and an import that never belongs in the root
8
- * graph. Cloudflare's v4 API is authenticated `fetch` over a uniform
9
- * `{ success, errors, messages, result, result_info }` envelope, so Web APIs
10
- * alone keep this provider Workers-clean. `test/package-surface.node.test.ts` pins
11
- * it: no `cloudflare` package in any dependency field, every import relative.
3
+ * No official `cloudflare` SDK on purpose. Cloudflare's v4 API is
4
+ * authenticated `fetch` over a uniform `{ success, errors, result,
5
+ * result_info }` envelope, and the key connector validates every call against
6
+ * a pinned operation index instead of typed wrappers, so Web APIs alone keep
7
+ * this provider Workers-clean. `test/package-surface.node.test.ts` pins it: no
8
+ * `cloudflare` package in any dependency field, every import relative.
12
9
  */
13
- import { apiConnector as api } from "../../connectors/api-connector.js";
14
- import { remoteMcp, withCredentialDefaults } from "../../connectors/remote-mcp.js";
10
+ import { apiConnector } from "../../connectors/api-connector.js";
15
11
  import { reviewedCatalog } from "../../catalog-drift.js";
16
- import { guardedFetch, retryAfterMs, } from "../../connectors/guarded-fetch.js";
17
- import { ConnectorCallError } from "../../errors.js";
18
12
  import { keys, optionsOf, variants } from "../../config-schema.js";
19
- import { CREDENTIAL, PROVIDER_COMMON, REMOTE_MCP_AUTH } from "../../connectors/option-shapes.js";
20
13
  import { defineProvider } from "../../provider.js";
21
- /** Cloudflare's v4 REST base. Override only for a proxy or a test double. */
22
- export const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4";
14
+ import { byAuth, hostedOAuth } from "../_shared/rest/dispatch.js";
15
+ import { restTools } from "../_shared/rest/tools.js";
16
+ import { CLOUDFLARE_API_BASE, cloudflareRest } from "./rest.js";
17
+ export { CLOUDFLARE_API_BASE } from "./rest.js";
23
18
  /** Cloudflare's official whole-API hosted MCP endpoint. */
24
19
  export const CLOUDFLARE_MCP_ENDPOINT = "https://mcp.cloudflare.com/mcp";
25
- /**
26
- * The 21 record types Cloudflare accepts. Eight carry a single `content`
27
- * string; the other thirteen carry a per-type structured `data` object.
28
- * `list_dns_records` filters on all 21 because reading them costs nothing.
29
- */
30
- export const CLOUDFLARE_DNS_RECORD_TYPES = [
31
- "A",
32
- "AAAA",
33
- "CAA",
34
- "CERT",
35
- "CNAME",
36
- "DNSKEY",
37
- "DS",
38
- "HTTPS",
39
- "LOC",
40
- "MX",
41
- "NAPTR",
42
- "NS",
43
- "OPENPGPKEY",
44
- "PTR",
45
- "SMIMEA",
46
- "SRV",
47
- "SSHFP",
48
- "SVCB",
49
- "TLSA",
50
- "TXT",
51
- "URI",
52
- ];
53
- /**
54
- * Content-valued types only, and the only types `create_dns_record` and
55
- * `update_dns_record` accept. Covering the thirteen structured-`data` types
56
- * would mean either a free-form `data` passthrough — the untyped body this
57
- * connection exists to avoid — or thirteen more hand-written schemas for record
58
- * types that are rare in day-to-day zone administration. They stay fully
59
- * readable; writing one goes through the approval-gated raw mutation tool with
60
- * Cloudflare's documented per-type `data` body.
61
- */
62
- export const CLOUDFLARE_CONTENT_DNS_RECORD_TYPES = [
63
- "A",
64
- "AAAA",
65
- "CNAME",
66
- "MX",
67
- "NS",
68
- "OPENPGPKEY",
69
- "PTR",
70
- "TXT",
71
- ];
72
20
  /**
73
21
  * Cloudflare documents a global limit of 1,200 requests per five minutes per
74
22
  * user, counted cumulatively across the dashboard, API keys, and API tokens
75
23
  * (developers.cloudflare.com/fundamentals/api/reference/limits/). The matching
76
- * rolling window here is a best-effort approximation, not an enforcement: each
77
- * runtime keeps its own counter, so N isolates or processes can each admit
78
- * 1,200, and a human's dashboard traffic counts for Cloudflare but not for us.
79
- * `maxConcurrency` is the bound that actually protects a shared credential,
80
- * because one `execute_code` program can fan out faster than the window
81
- * notices.
24
+ * rolling window is a best-effort per-runtime approximation, not an
25
+ * enforcement: N isolates can each admit 1,200, and dashboard traffic counts
26
+ * for Cloudflare but not here. `maxConcurrency` is Connecta's own choice and
27
+ * the bound that protects a shared credential from one program's fan-out.
82
28
  */
83
29
  function admissionPolicy(maxConcurrency) {
84
30
  return {
85
31
  rules: [
86
32
  {
87
33
  maxConcurrency,
88
- budget: {
89
- kind: "rolling-window",
90
- maxCalls: 1200,
91
- windowMs: 300_000,
92
- },
34
+ budget: { kind: "rolling-window", maxCalls: 1200, windowMs: 300_000 },
93
35
  },
94
36
  ],
95
37
  };
96
38
  }
97
39
  const API_TOKEN_CREDENTIAL = {
98
40
  label: "Cloudflare API token",
99
- description: 'A scoped API token (My Profile → API Tokens → Create Token), not a Global API Key. Grant only the permissions the deployment needs: zone-scoped "Zone Read", "Zone Settings Write", "DNS Write", "Cache Purge", and the phase-specific Rules product Read permissions as needed; account-scoped "Workers Scripts Read/Write", "Workers KV Storage Read/Write", "Workers R2 Storage Read/Write", or "Cloudflare Pages Read/Write" for the platform tools.',
41
+ description: "A scoped API token (user or account token), not a Global API Key. Grant only the permissions this connector needs, on only the accounts and zones it serves. It is sent only to api.cloudflare.com, stored encrypted, and never displayed.",
100
42
  placeholder: "Paste API token",
101
43
  };
102
44
  const GLOBAL_API_KEY_CREDENTIAL = {
103
45
  label: "Cloudflare Global API Key",
104
- description: "Legacy user-scoped authentication. The key has the same access as its Cloudflare user across every account and zone that user can reach. Prefer a scoped API token when possible.",
46
+ description: "Legacy user-scoped authentication with the same access as its Cloudflare user across every account and zone that user can reach. Prefer a scoped API token.",
105
47
  fields: [
106
48
  {
107
49
  name: "email",
@@ -119,1959 +61,208 @@ const GLOBAL_API_KEY_CREDENTIAL = {
119
61
  },
120
62
  ],
121
63
  };
122
- function credentialConfig(authentication, override) {
123
- if (authentication === "apiToken") {
124
- const credential = override ?? API_TOKEN_CREDENTIAL;
125
- if (credential.fields?.length) {
126
- throw new Error("cloudflare() API token authentication requires a single-value credential.");
127
- }
128
- return credential;
129
- }
130
- const credential = override
131
- ? {
132
- ...GLOBAL_API_KEY_CREDENTIAL,
133
- ...override,
134
- fields: override.fields ?? GLOBAL_API_KEY_CREDENTIAL.fields,
135
- }
136
- : GLOBAL_API_KEY_CREDENTIAL;
137
- const fields = credential.fields?.map((field) => field.name).sort();
138
- if (fields?.join(",") !== "apiKey,email") {
139
- throw new Error('cloudflare() Global API Key authentication requires credential fields named "email" and "apiKey".');
140
- }
141
- return credential;
142
- }
143
- function asRecord(value) {
144
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
145
- }
146
- function asArray(value) {
147
- return Array.isArray(value) ? value : [];
148
- }
149
- function compact(value) {
150
- return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
151
- }
152
- /** Flatten Cloudflare's error array (and any nested chain) into one line. */
153
- function describeErrors(errors) {
154
- const parts = [];
155
- const walk = (list) => {
156
- for (const entry of list) {
157
- const code = typeof entry.code === "number" ? entry.code : undefined;
158
- const message = typeof entry.message === "string" ? entry.message : "Unknown error";
159
- parts.push(code === undefined ? message : `${code}: ${message}`);
160
- if (Array.isArray(entry.error_chain))
161
- walk(entry.error_chain);
162
- }
163
- };
164
- walk(errors);
165
- return parts.length > 0 ? parts.join("; ") : "Cloudflare reported no detail.";
166
- }
167
- function errorCodes(errors) {
168
- const codes = new Set();
169
- const walk = (list) => {
170
- for (const entry of list) {
171
- if (typeof entry.code === "number")
172
- codes.add(entry.code);
173
- if (Array.isArray(entry.error_chain))
174
- walk(entry.error_chain);
175
- }
176
- };
177
- walk(errors);
178
- return codes;
179
- }
180
- /**
181
- * Credential-shaped Cloudflare error codes that are *not* already implied by a
182
- * 401 or 403: a missing or malformed `Authorization` header, and the legacy
183
- * key/email headers. These arrive on HTTP 400, so status alone would misfile
184
- * them as an argument problem the agent could repair.
185
- *
186
- * Deliberately excludes 10000. Cloudflare returns 10000 for "Authentication
187
- * error" but has also been observed reusing it as a generic validation code
188
- * ("domain_name is required", "Invalid pagination cursor"), so routing on it
189
- * would risk telling an agent its token was broken when its arguments were.
190
- * Genuine 10000 auth failures arrive with 401 or 403 and are caught by status.
191
- *
192
- * Provenance worth knowing before editing this set: Cloudflare publishes no
193
- * official table mapping error codes to causes, so these six — and the 10000
194
- * observation above — come from community reports and probing rather than
195
- * documentation. They are well-supported readings Cloudflare could invalidate
196
- * without notice; prefer `verify_api_token` or `verify_global_api_key` when a
197
- * diagnosis actually matters.
198
- *
199
- * Cloudflare also rate-limits authentication failures separately from the
200
- * global limit: a few requests with a bad token return 429 with code 10502.
201
- * That is one more reason to diagnose a broken token once with a verify tool
202
- * rather than by retrying real calls.
203
- */
204
- const AUTH_ERROR_CODES = new Set([1001, 6003, 6111, 9103, 9106, 9107]);
205
64
  /**
206
- * Turn a failed Cloudflare response into a typed connector failure.
207
- *
208
- * Status is the primary signal and the error codes refine it, because
209
- * Cloudflare returns 403 for both "this token is invalid" and "this token
210
- * cannot do that" — an agent needs to stop retrying either way, and the
211
- * operator needs to know the token is the thing to fix.
212
- */
213
- function failureFor(status, headers, errors) {
214
- const detail = describeErrors(errors);
215
- const codes = errorCodes(errors);
216
- // 429 is checked before the authentication codes on purpose: Cloudflare
217
- // reuses the generic 10000 code on throttled responses, and reading a rate
218
- // limit as an auth failure would tell an agent to stop when it should wait.
219
- if (status === 429) {
220
- const wait = retryAfterMs(headers);
221
- return new ConnectorCallError("rate_limited", `Cloudflare rate limit reached (HTTP 429). ${detail} The documented limit is 1,200 requests per five minutes per user, counted across the dashboard and every token.`,
222
- // Cloudflare blocks the remainder of the five-minute window when the
223
- // global limit trips, so the honest fallback is the whole window.
224
- { retryAfterMs: wait ?? 300_000 });
225
- }
226
- const authCoded = [...codes].some((code) => AUTH_ERROR_CODES.has(code));
227
- if (status === 401 || authCoded) {
228
- return new ConnectorCallError("auth_required", `Cloudflare rejected the configured credential (HTTP ${status}). ${detail} Check that it is valid and has permission to access this resource.`);
229
- }
230
- if (status === 403) {
231
- return new ConnectorCallError("provider_permission_denied", `Cloudflare refused access to this resource (HTTP 403). ${detail} Verify the configured credential first; if valid, ask the account administrator to grant the required resource permission and token scope.`);
232
- }
233
- if (status === 400 || status === 409 || status === 422) {
234
- return new ConnectorCallError("invalid_args", `Cloudflare rejected the request (HTTP ${status}). ${detail}`);
235
- }
236
- // Cloudflare refuses a token that may not touch a resource with 401 or 403,
237
- // so a 404 here is a real absence rather than a permission gap wearing a
238
- // miss — the unambiguous case `not_found` exists for, and something an agent
239
- // can act on by re-running discovery for the id.
240
- if (status === 404) {
241
- return new ConnectorCallError("not_found", `Cloudflare found no such resource (HTTP 404). ${detail} Confirm the zone or account id with list_zones or list_accounts.`);
242
- }
243
- if (status >= 500) {
244
- return new ConnectorCallError("unavailable", `Cloudflare is unavailable (HTTP ${status}). ${detail}`);
245
- }
246
- return new ConnectorCallError("connector_call_failed", `Cloudflare request failed (HTTP ${status}). ${detail}`);
247
- }
248
- // --- The request path --------------------------------------------------------
249
- /**
250
- * The largest response this connection will read.
251
- *
252
- * Generous rather than tight, because `cloudflare_api_get` legitimately
253
- * downloads Worker scripts and R2 objects. It is a ceiling on absurdity, not a
254
- * quota: anything approaching it is already far past whatever `maxResultBytes`
255
- * the deployment set, so the caller was never going to see it whole.
256
- */
257
- const CLOUDFLARE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
258
- async function readAuthenticationHeaders(ctx, authentication) {
259
- if (authentication === "globalApiKey") {
260
- const values = await ctx.credential?.getAll();
261
- const email = values?.["email"];
262
- const apiKey = values?.["apiKey"];
263
- if (!email || !apiKey) {
264
- throw new ConnectorCallError("auth_required", "No Cloudflare Global API Key and account email are configured for this connector. An operator must add both before any call can run.");
265
- }
266
- return { "X-Auth-Email": email, "X-Auth-Key": apiKey };
267
- }
268
- const token = await ctx.credential?.get();
269
- if (!token) {
270
- throw new ConnectorCallError("auth_required", "No Cloudflare API token is configured for this connector. An operator must add one before any call can run.");
271
- }
272
- return { Authorization: `Bearer ${token}` };
273
- }
274
- /**
275
- * The one transport every Cloudflare tool goes through.
276
- *
277
- * URL confinement, `ctx.signal`, redirect refusal, bounded reads, and the
278
- * "could not reach the provider" normalization all live in the shared helper.
279
- * What stays here is what only Cloudflare knows: which headers prove identity,
280
- * and what a status code means once it arrives.
281
- */
282
- function cloudflareTransport(baseUrl, authentication) {
283
- return guardedFetch({
284
- provider: "Cloudflare",
285
- baseUrl,
286
- headers: { Accept: "application/json" },
287
- maxResponseBytes: CLOUDFLARE_MAX_RESPONSE_BYTES,
288
- authenticate: (ctx) => readAuthenticationHeaders(ctx, authentication),
289
- });
290
- }
291
- async function callCloudflare(send, spec, ctx) {
292
- return await send(spec, ctx, async (response) => {
293
- const parsed = await response.jsonResult();
294
- const envelope = "value" in parsed ? parsed.value : undefined;
295
- if (envelope === undefined) {
296
- // A gateway error page or an empty body, not an envelope: the status is
297
- // the only real signal left.
298
- // No cause: the parser's error quotes the body it could not read.
299
- throw response.ok
300
- ? new ConnectorCallError("unavailable", "Cloudflare returned a non-JSON body for a successful status.")
301
- : failureFor(response.status, response.headers, []);
302
- }
303
- const errors = Array.isArray(envelope.errors) ? envelope.errors : [];
304
- if (!response.ok || envelope.success === false) {
305
- throw failureFor(response.status, response.headers, errors);
306
- }
307
- const isV4Envelope = "success" in envelope || "result" in envelope || "result_info" in envelope || "messages" in envelope;
308
- return {
309
- // `/graphql` and a small number of product APIs return ordinary JSON
310
- // instead of the standard v4 envelope. Preserve that document whole so
311
- // the raw tools cover them too.
312
- result: isV4Envelope ? envelope.result : envelope,
313
- resultInfo: isV4Envelope ? envelope.result_info : undefined,
314
- };
315
- });
316
- }
317
- async function testCloudflareCredential(send, spec, ctx, success) {
318
- try {
319
- const { result } = await callCloudflare(send, spec, ctx);
320
- return success(result);
321
- }
322
- catch (error) {
323
- return {
324
- ok: false,
325
- message: error instanceof Error ? error.message : String(error),
326
- };
327
- }
328
- }
329
- function base64FromBytes(bytes) {
330
- let binary = "";
331
- for (let offset = 0; offset < bytes.length; offset += 0x8000) {
332
- binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
333
- }
334
- return btoa(binary);
335
- }
336
- async function callCloudflareContent(send, spec, ctx, responseType) {
337
- return await send(spec, ctx, async (response) => {
338
- if (!response.ok) {
339
- let envelope;
340
- try {
341
- const parsed = await response.jsonResult();
342
- envelope = "value" in parsed ? parsed.value : undefined;
343
- }
344
- catch {
345
- // Content reads classify an already-failed status even when its error
346
- // body exceeds the transport ceiling.
347
- }
348
- const errors = envelope && Array.isArray(envelope.errors) ? envelope.errors : [];
349
- throw failureFor(response.status, response.headers, errors);
350
- }
351
- const common = compact({
352
- contentType: response.headers.get("content-type") ?? "application/octet-stream",
353
- etag: response.headers.get("etag") ?? undefined,
354
- });
355
- if (responseType === "text") {
356
- return { ...common, text: await response.text() };
357
- }
358
- return { ...common, base64: base64FromBytes(await response.bytes()) };
359
- });
360
- }
361
- /**
362
- * Cloudflare's `result_info` reshaped into the one question an agent actually
363
- * asks — is there another page? — with the raw counters kept alongside it.
364
- */
365
- function pageInfo(info) {
366
- if (!info)
367
- return undefined;
368
- // A cursor-only result_info carries no page counters; inventing them would
369
- // report `hasMore: false` on a listing that has more.
370
- if (info.page === undefined && info.total_pages === undefined && info.count === undefined) {
371
- return undefined;
372
- }
373
- const page = typeof info.page === "number" ? info.page : 1;
374
- const totalPages = typeof info.total_pages === "number" ? info.total_pages : undefined;
375
- return compact({
376
- page,
377
- perPage: typeof info.per_page === "number" ? info.per_page : 0,
378
- count: typeof info.count === "number" ? info.count : 0,
379
- totalCount: typeof info.total_count === "number" ? info.total_count : undefined,
380
- totalPages,
381
- hasMore: totalPages !== undefined ? page < totalPages : false,
382
- });
383
- }
384
- function pagedList(key, project) {
385
- return (result, resultInfo, raw) => ({
386
- [key]: raw ? result : asArray(result).map(project),
387
- page: pageInfo(resultInfo),
388
- });
389
- }
390
- function cursorResult(cursor) {
391
- return typeof cursor === "string" && cursor !== "" ? { nextCursor: cursor } : {};
392
- }
393
- function projectAccount(value) {
394
- const account = asRecord(value);
395
- return compact({
396
- id: account["id"],
397
- name: account["name"],
398
- type: account["type"],
399
- createdOn: account["created_on"],
400
- });
401
- }
402
- function projectZone(value) {
403
- const zone = asRecord(value);
404
- const account = asRecord(zone["account"]);
405
- const plan = asRecord(zone["plan"]);
406
- const nameServers = Array.isArray(zone["name_servers"]) ? zone["name_servers"] : undefined;
407
- return compact({
408
- id: zone["id"],
409
- name: zone["name"],
410
- status: zone["status"],
411
- paused: zone["paused"],
412
- type: zone["type"],
413
- accountId: account["id"],
414
- accountName: account["name"],
415
- plan: plan["name"],
416
- nameServers,
417
- createdOn: zone["created_on"],
418
- modifiedOn: zone["modified_on"],
419
- });
420
- }
421
- function projectZoneSetting(value) {
422
- const setting = asRecord(value);
423
- return compact({
424
- id: setting["id"],
425
- value: setting["value"],
426
- editable: setting["editable"],
427
- modifiedOn: setting["modified_on"],
428
- });
429
- }
430
- function projectDnsRecord(value) {
431
- const record = asRecord(value);
432
- const comment = record["comment"] ? record["comment"] : undefined;
433
- const tags = Array.isArray(record["tags"]) && record["tags"].length > 0 ? record["tags"] : undefined;
434
- return compact({
435
- id: record["id"],
436
- name: record["name"],
437
- type: record["type"],
438
- content: record["content"],
439
- ttl: record["ttl"],
440
- proxied: record["proxied"],
441
- priority: record["priority"],
442
- comment,
443
- tags,
444
- createdOn: record["created_on"],
445
- modifiedOn: record["modified_on"],
446
- });
447
- }
448
- function projectWorkerScript(value) {
449
- const script = asRecord(value);
450
- return compact({
451
- id: script["id"],
452
- createdOn: script["created_on"],
453
- modifiedOn: script["modified_on"],
454
- usageModel: script["usage_model"],
455
- });
456
- }
457
- function projectWorkerSettings(value) {
458
- const settings = asRecord(value);
459
- return compact({
460
- compatibilityDate: settings["compatibility_date"],
461
- compatibilityFlags: settings["compatibility_flags"],
462
- bindings: settings["bindings"],
463
- limits: settings["limits"],
464
- observability: settings["observability"],
465
- placement: settings["placement"],
466
- usageModel: settings["usage_model"],
467
- tailConsumers: settings["tail_consumers"],
468
- logpush: settings["logpush"],
469
- });
470
- }
471
- function projectKvNamespace(value) {
472
- const namespace = asRecord(value);
473
- return compact({
474
- id: namespace["id"],
475
- title: namespace["title"],
476
- supportsUrlEncoding: namespace["supports_url_encoding"],
477
- jurisdiction: namespace["jurisdiction"],
478
- });
479
- }
480
- function projectKvBulkValues(value) {
481
- const result = asRecord(value);
482
- return compact({ values: result["values"] });
483
- }
484
- function projectR2Bucket(value) {
485
- const bucket = asRecord(value);
486
- return compact({
487
- name: bucket["name"],
488
- location: bucket["location"],
489
- storageClass: bucket["storage_class"],
490
- jurisdiction: bucket["jurisdiction"],
491
- creationDate: bucket["creation_date"],
492
- });
493
- }
494
- function projectR2Object(value) {
495
- const object = asRecord(value);
496
- return compact({
497
- key: object["key"],
498
- size: object["size"],
499
- etag: object["etag"],
500
- lastModified: object["last_modified"],
501
- storageClass: object["storage_class"],
502
- httpMetadata: object["http_metadata"],
503
- customMetadata: object["custom_metadata"],
504
- });
505
- }
506
- function projectR2Cors(value) {
507
- const cors = asRecord(value);
508
- return compact({ rules: cors["rules"] });
509
- }
510
- function projectKvKey(value) {
511
- const key = asRecord(value);
512
- return compact({
513
- name: key["name"],
514
- expiration: key["expiration"],
515
- metadata: key["metadata"],
516
- });
517
- }
518
- function projectWorkerDeployment(value) {
519
- const deployment = asRecord(value);
520
- return compact({
521
- id: deployment["id"],
522
- createdOn: deployment["created_on"],
523
- source: deployment["source"],
524
- strategy: deployment["strategy"],
525
- versions: deployment["versions"],
526
- });
527
- }
528
- function projectPagesDeployment(value) {
529
- const deployment = asRecord(value);
530
- return compact({
531
- id: deployment["id"],
532
- projectName: deployment["project_name"],
533
- environment: deployment["environment"],
534
- url: deployment["url"],
535
- aliases: deployment["aliases"],
536
- stage: deployment["stage"],
537
- latestStage: deployment["latest_stage"],
538
- createdOn: deployment["created_on"],
539
- modifiedOn: deployment["modified_on"],
540
- });
541
- }
542
- function projectPagesDomain(value) {
543
- const domain = asRecord(value);
544
- return compact({
545
- id: domain["id"],
546
- name: domain["name"],
547
- status: domain["status"],
548
- verificationData: domain["verification_data"],
549
- createdOn: domain["created_on"],
550
- });
551
- }
552
- function projectRuleset(value) {
553
- const ruleset = asRecord(value);
554
- return compact({
555
- id: ruleset["id"],
556
- name: ruleset["name"],
557
- kind: ruleset["kind"],
558
- phase: ruleset["phase"],
559
- description: ruleset["description"],
560
- version: ruleset["version"],
561
- lastUpdated: ruleset["last_updated"],
562
- rules: ruleset["rules"],
563
- });
564
- }
565
- function projectPagesProject(value) {
566
- const project = asRecord(value);
567
- const latest = asRecord(project["latest_deployment"]);
568
- const domains = Array.isArray(project["domains"]) ? project["domains"] : undefined;
569
- const latestDeployment = latest["id"] === undefined
570
- ? undefined
571
- : compact({
572
- id: latest["id"],
573
- environment: latest["environment"],
574
- url: latest["url"],
575
- createdOn: latest["created_on"],
576
- });
577
- return compact({
578
- name: project["name"],
579
- subdomain: project["subdomain"],
580
- domains,
581
- productionBranch: project["production_branch"],
582
- createdOn: project["created_on"],
583
- latestDeployment,
584
- });
585
- }
586
- // --- Schema fragments --------------------------------------------------------
587
- const PAGE_OUTPUT_SCHEMA = {
588
- type: "object",
589
- description: "Pagination counters from Cloudflare's result_info. Absent when the endpoint does not paginate.",
590
- properties: {
591
- page: { type: "integer" },
592
- perPage: { type: "integer" },
593
- count: { type: "integer", description: "Items on this page." },
594
- totalCount: { type: "integer" },
595
- totalPages: { type: "integer" },
596
- hasMore: {
597
- type: "boolean",
598
- description: "True when a further page exists; request page + 1.",
599
- },
600
- },
601
- required: ["page", "perPage", "count", "hasMore"],
602
- };
603
- const RAW_INPUT_PROPERTY = {
604
- type: "boolean",
605
- description: "Return Cloudflare's unprojected result instead of the lean shape. Use only when a field the projection drops is genuinely needed; the raw shape is much larger.",
606
- };
607
- /**
608
- * Local enforcement of a `perPage` range is only a favor when the bound is
609
- * really Cloudflare's, so `bounds` decides what the description admits to.
610
- * `list_accounts`/`list_zones` (5–50) and `list_kv_namespaces` (1–1000) are
611
- * Cloudflare's documented bounds. `list_dns_records` takes Cloudflare's
612
- * minimum but is `clamped` at 1,000: Cloudflare's schema documents `per_page`
613
- * on `/zones/{id}/dns_records` up to 5,000,000, a nominal ceiling no listing
614
- * will honor, and a local cap an agent is told about beats a page size that
615
- * fails somewhere inside Cloudflare. `list_pages_projects` is `undocumented` —
616
- * Cloudflare publishes no bounds and no default for it, so 1–100 is ours.
65
+ * Reviewed in #705's provider audit against https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/.
66
+ * Classifies the hosted OAuth catalog only; the REST connector annotates
67
+ * each tool it authors.
617
68
  */
618
- function pagingInputProperties(minPerPage, maxPerPage, options = {}) {
619
- const { defaultPerPage, bounds = "cloudflare" } = options;
620
- const defaultNote = defaultPerPage === undefined ? " Cloudflare chooses the default." : ` Defaults to ${defaultPerPage}.`;
621
- const boundsNote = bounds === "clamped"
622
- ? ` The ${maxPerPage} ceiling is this connection's cap, not Cloudflare's limit.`
623
- : bounds === "undocumented"
624
- ? " Cloudflare documents no bounds for this endpoint; the range is this connection's own."
625
- : "";
626
- return {
627
- page: {
628
- type: "integer",
629
- minimum: 1,
630
- description: "1-based page number. Defaults to 1.",
69
+ const CLOUDFLARE_MCP_CLASSIFICATION = {
70
+ tools: {
71
+ search: {
72
+ verdict: "read",
73
+ reason: "Searches the Cloudflare OpenAPI contract without executing API methods.",
631
74
  },
632
- perPage: {
633
- type: "integer",
634
- minimum: minPerPage,
635
- maximum: maxPerPage,
636
- description: `Items per page, ${minPerPage} to ${maxPerPage}.${defaultNote}${boundsNote}`,
75
+ execute: {
76
+ verdict: "destructive",
77
+ reason: "Can mix HTTP methods across the Cloudflare API; no input schema proves a program only reads.",
637
78
  },
638
- };
639
- }
640
- /**
641
- * Four endpoints — `list_zone_rulesets`, `list_r2_buckets`, `list_r2_objects`,
642
- * `list_kv_keys` — page by cursor and get no `page` object. Both the argument
643
- * and the result say so, so the loop condition is legible from either end of
644
- * one tool rather than only to a reader who compared all of them.
645
- */
646
- const CURSOR_INPUT_PROPERTY = {
647
- type: "string",
648
- description: "Opaque cursor from a previous call's nextCursor. This endpoint pages by cursor, not page number.",
649
- };
650
- const NEXT_CURSOR_OUTPUT_PROPERTY = {
651
- type: "string",
652
- description: "Pass back as `cursor` to continue. Absent when the listing is complete — this is the only signal; there is no page object.",
653
- };
654
- function listOutputSchema(key, item) {
655
- return {
656
- type: "object",
657
- properties: { [key]: { type: "array", items: item }, page: PAGE_OUTPUT_SCHEMA },
658
- required: [key],
659
- };
660
- }
661
- const ACCOUNT_SCHEMA = {
662
- type: "object",
663
- properties: {
664
- id: { type: "string" },
665
- name: { type: "string" },
666
- type: { type: "string" },
667
- createdOn: { type: "string" },
668
- },
669
- required: ["id", "name"],
670
- };
671
- const ZONE_SCHEMA = {
672
- type: "object",
673
- properties: {
674
- id: { type: "string", description: "Zone id — the argument every zone-scoped tool wants." },
675
- name: { type: "string", description: "Apex domain, e.g. example.com." },
676
- status: { type: "string" },
677
- paused: { type: "boolean" },
678
- type: { type: "string" },
679
- accountId: { type: "string" },
680
- accountName: { type: "string" },
681
- plan: { type: "string" },
682
- nameServers: { type: "array", items: { type: "string" } },
683
- createdOn: { type: "string" },
684
- modifiedOn: { type: "string" },
685
79
  },
686
- required: ["id", "name", "status"],
687
80
  };
688
- const DNS_RECORD_SCHEMA = {
689
- type: "object",
690
- properties: {
691
- id: { type: "string" },
692
- name: { type: "string", description: "Fully qualified record name." },
693
- type: { type: "string", enum: [...CLOUDFLARE_DNS_RECORD_TYPES] },
694
- content: { type: "string" },
695
- ttl: { type: "integer", description: "Seconds; 1 means automatic." },
696
- proxied: { type: "boolean" },
697
- priority: { type: "integer" },
698
- comment: { type: "string" },
699
- tags: { type: "array", items: { type: "string" } },
700
- createdOn: { type: "string" },
701
- modifiedOn: { type: "string" },
702
- },
703
- required: ["id", "name", "type", "content", "ttl"],
704
- };
705
- /**
706
- * Resolve a scope id from the call or the deployment default.
707
- *
708
- * The second layer, not the first: when the deployment declares no default the
709
- * schema already lists the key in `required`, so `api()` rejects an omitted id
710
- * before the handler runs. This catches what a JSON Schema string cannot — a
711
- * blank or whitespace-only id — and answers with the discovery tool's name
712
- * rather than a Cloudflare round trip that would 404.
713
- */
714
- function requireScope(provided, fallback, kind) {
715
- const value = typeof provided === "string" ? provided.trim() : "";
716
- if (value)
717
- return value;
718
- if (fallback)
719
- return fallback;
720
- const discovery = kind === "zoneId" ? "list_zones" : "list_accounts";
721
- throw new ConnectorCallError("invalid_args", `${kind} is required: this connector has no default ${kind}. Call ${discovery} to find it.`, {
722
- validation: {
723
- issues: [
724
- {
725
- path: `/${kind}`,
726
- code: "required",
727
- expected: `a Cloudflare ${kind === "zoneId" ? "zone" : "account"} id`,
728
- },
729
- ],
81
+ function instructionsSection(instructions) {
82
+ const text = instructions?.trim();
83
+ return text ? `\n## ${skill.instructionsHeading}\n\n${text}\n` : "";
84
+ }
85
+ function oauthUsageGuide(purpose, instructions) {
86
+ const { oauth, shared, oauth_tail } = skill.fragments;
87
+ return `# Cloudflare usage
88
+
89
+ Hosted MCP over OAuth: \`search\` and \`execute\`. Connector purpose: ${purpose}${oauth}${shared}${oauth_tail}${instructionsSection(instructions)}`;
90
+ }
91
+ function idList(ids) {
92
+ return ids?.length ? ids.map((id) => `\`${id}\``).join(", ") : "none";
93
+ }
94
+ function keyUsageGuide(auth, options) {
95
+ const { key, global_warning, shared, key_tail } = skill.fragments;
96
+ const scheme = auth === "apiToken" ? "a scoped API token" : "a Global API Key";
97
+ const pinned = options.pin
98
+ ? [
99
+ options.pin.accountIds?.length ? `accounts ${idList(options.pin.accountIds)} and every zone in them` : "",
100
+ options.pin.zoneIds?.length ? `zones ${idList(options.pin.zoneIds)}` : "",
101
+ ].filter(Boolean)
102
+ : [];
103
+ const pin = options.pin
104
+ ? `\n\nPinned to ${pinned.join(", plus ")}. Calls naming any other account or zone are refused before they are sent, and so is every operation that names neither, except the credential's own user, the filtered account, zone, and membership lists, zone creation in a pinned account, and public data.`
105
+ : auth === "globalApiKey"
106
+ ? "\n\nUnpinned: this connector reaches every account and zone the key's user can."
107
+ : "";
108
+ const defaults = [
109
+ options.accountId ? `\`{account_id}\` in a path fills with \`${options.accountId}\`` : "",
110
+ options.zoneId ? `\`{zone_id}\` fills with \`${options.zoneId}\`` : "",
111
+ ].filter(Boolean);
112
+ const defaultLine = defaults.length
113
+ ? `\n\nDefaults: ${defaults.join("; ")}. Pass another id in the path when the request names a different one.`
114
+ : "";
115
+ return `# Cloudflare usage
116
+
117
+ REST with ${scheme}. Connector purpose: ${options.purpose}${auth === "globalApiKey" ? global_warning.trimEnd() : ""}${pin}${defaultLine}${key}${shared}${key_tail}${instructionsSection(options.instructions)}`;
118
+ }
119
+ const COMMON = keys("title", "authScope", "purpose", "instructions", "maxResultBytes");
120
+ const KEY_COMMON = keys("accountId", "zoneId", "baseUrl", "maxConcurrency");
121
+ const PIN = optionsOf()(keys("accountIds", "zoneIds"));
122
+ /** The closed options cloudflare() accepts, selected by `auth.type`; see `assertKnownOptions`. */
123
+ const CLOUDFLARE_OPTIONS = variants(["auth", "type"], {
124
+ oauth: optionsOf()({
125
+ ...COMMON,
126
+ ...keys("callAdmission"),
127
+ auth: optionsOf()(keys("type")),
128
+ }).shape,
129
+ apiToken: optionsOf()({
130
+ ...COMMON,
131
+ ...KEY_COMMON,
132
+ pin: PIN,
133
+ auth: optionsOf()(keys("type")),
134
+ }).shape,
135
+ globalApiKey: optionsOf()({
136
+ ...COMMON,
137
+ ...KEY_COMMON,
138
+ ...keys("unpinned"),
139
+ pin: PIN,
140
+ auth: optionsOf()(keys("type")),
141
+ }).shape,
142
+ });
143
+ function cloudflareOAuth(id, options, provider) {
144
+ return hostedOAuth(id, provider, {
145
+ url: CLOUDFLARE_MCP_ENDPOINT,
146
+ title: options.title ?? "Cloudflare (MCP)",
147
+ description: `Cloudflare's official whole-API MCP interface: ${options.purpose.trim()}`,
148
+ ...(options.callAdmission ? { callAdmission: options.callAdmission } : {}),
149
+ usageGuide: {
150
+ content: oauthUsageGuide(options.purpose.trim(), options.instructions),
151
+ summary: "Hosted MCP over OAuth. Search the OpenAPI document; execute programs always classify as writes.",
152
+ required: true,
730
153
  },
731
154
  });
732
155
  }
733
- /** A scope argument is only required when the deployment declared no default. */
734
- function scopeProperty(kind, fallback) {
735
- const noun = kind === "zoneId" ? "Zone" : "Account";
736
- const discovery = kind === "zoneId" ? "list_zones" : "list_accounts";
737
- return {
738
- type: "string",
739
- minLength: 1,
740
- description: fallback
741
- ? `${noun} id. Optional — defaults to this connector's configured ${kind}. Pass one to address a different ${noun.toLowerCase()}; ${discovery} lists them.`
742
- : `${noun} id. Required — this connector declares no default; ${discovery} returns it.`,
743
- };
744
- }
745
- function scopeRequired(kind, fallback) {
746
- return fallback ? [] : [kind];
747
- }
748
- function optionalString(args, key) {
749
- const value = args[key];
750
- return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
751
- }
752
- function optionalNumber(args, key) {
753
- const value = args[key];
754
- return typeof value === "number" ? value : undefined;
755
- }
756
- function requireString(args, key) {
757
- const value = optionalString(args, key);
758
- if (value)
759
- return value;
760
- throw new ConnectorCallError("invalid_args", `${key} must not be blank.`);
761
- }
762
- function cloudflareApiPath(value) {
763
- if (typeof value !== "string") {
764
- throw new ConnectorCallError("invalid_args", "path must be a string.");
765
- }
766
- const path = value.trim();
767
- if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\")) {
768
- throw new ConnectorCallError("invalid_args", "path must be a relative Cloudflare v4 path beginning with one slash and containing no backslashes.");
769
- }
770
- if (path.includes("?") || path.includes("#")) {
771
- throw new ConnectorCallError("invalid_args", "Put query parameters in the query array; path cannot contain '?' or '#'.");
772
- }
773
- for (const segment of path.split("/")) {
774
- let decoded = segment;
775
- let stable = false;
776
- for (let pass = 0; pass < 20; pass += 1) {
777
- let next;
778
- try {
779
- next = decodeURIComponent(decoded);
780
- }
781
- catch {
782
- throw new ConnectorCallError("invalid_args", "path contains invalid percent encoding.");
783
- }
784
- if (next === decoded) {
785
- stable = true;
786
- break;
787
- }
788
- decoded = next;
789
- }
790
- if (!stable) {
791
- throw new ConnectorCallError("invalid_args", "path contains too many layers of percent encoding.");
792
- }
793
- if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) {
794
- throw new ConnectorCallError("invalid_args", "path cannot contain encoded or literal traversal, slash, or backslash segments.");
795
- }
796
- }
797
- const normalized = new URL(`https://connecta.invalid/client/v4${path}`);
798
- if (!normalized.pathname.startsWith("/client/v4/")) {
799
- throw new ConnectorCallError("invalid_args", "path normalization escaped the Cloudflare v4 API base.");
156
+ function idsOf(id, value, path) {
157
+ if (value === undefined)
158
+ return undefined;
159
+ if (!Array.isArray(value) ||
160
+ value.length === 0 ||
161
+ !value.every((item) => typeof item === "string" && item.trim() !== "")) {
162
+ throw new Error(`cloudflare("${id}") ${path} must be a non-empty list of ids.`);
800
163
  }
801
- return path;
164
+ return value.map((item) => item.trim());
802
165
  }
803
- function queryFromArgs(value) {
804
- if (!Array.isArray(value) || value.length === 0)
166
+ function optionalId(id, value, path) {
167
+ if (value === undefined)
805
168
  return undefined;
806
- const query = {};
807
- for (const item of value) {
808
- const entry = asRecord(item);
809
- query[String(entry["name"])] = String(entry["value"]);
169
+ if (typeof value !== "string" || value.trim() === "" || /[/?#\s]/.test(value.trim())) {
170
+ throw new Error(`cloudflare("${id}") ${path} must be a Cloudflare id.`);
810
171
  }
811
- return query;
172
+ return value.trim();
812
173
  }
813
- function headersFromArgs(value) {
814
- if (!Array.isArray(value) || value.length === 0)
815
- return undefined;
816
- const headers = {};
817
- const forbidden = new Set([
818
- "authorization",
819
- "x-auth-email",
820
- "x-auth-key",
821
- "cookie",
822
- "host",
823
- "content-length",
824
- "content-type",
825
- "transfer-encoding",
826
- ]);
827
- for (const item of value) {
828
- const entry = asRecord(item);
829
- const name = String(entry["name"]).trim();
830
- if (forbidden.has(name.toLowerCase())) {
831
- throw new ConnectorCallError("invalid_args", `The raw Cloudflare tools do not allow the ${name} header. Authentication and request framing are connector-owned; use contentType for a raw upload body.`);
174
+ function cloudflareKey(auth) {
175
+ return (id, options, provider) => {
176
+ const maxConcurrency = options.maxConcurrency ?? 6;
177
+ if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
178
+ throw new Error(`cloudflare("${id}") maxConcurrency must be a positive integer.`);
832
179
  }
833
- const value = String(entry["value"]);
834
- if (name.toLowerCase() === "cf-r2-storage-class" && value !== "Standard" && value !== "InfrequentAccess") {
835
- throw new ConnectorCallError("invalid_args", "cf-r2-storage-class must be Standard or InfrequentAccess.");
180
+ let pin;
181
+ if (options.pin !== undefined) {
182
+ const accountIds = idsOf(id, options.pin.accountIds, "pin.accountIds");
183
+ const zoneIds = idsOf(id, options.pin.zoneIds, "pin.zoneIds");
184
+ if (!accountIds && !zoneIds)
185
+ throw new Error(`cloudflare("${id}") pin names accountIds, zoneIds, or both.`);
186
+ pin = {
187
+ ...(accountIds ? { accountIds } : {}),
188
+ ...(zoneIds ? { zoneIds } : {}),
189
+ };
836
190
  }
837
- headers[name] = value;
838
- }
839
- return headers;
840
- }
841
- function rawSpec(args) {
842
- return compact({
843
- path: cloudflareApiPath(args["path"]),
844
- query: queryFromArgs(args["query"]),
845
- headers: headersFromArgs(args["headers"]),
846
- });
847
- }
848
- // The hosted helper cannot send R2 jurisdiction or bucket-edit storage headers.
849
- // Ordinary headers such as Accept are not a reason to restore JSON duplicates.
850
- function needsMutationHeaders(method, spec) {
851
- const headers = Object.entries(spec.headers ?? {});
852
- if (method === "PATCH" &&
853
- /^\/accounts\/[^/]+\/r2\/buckets\/[^/]+\/?$/.test(spec.path) &&
854
- headers.some(([name, value]) => name.toLowerCase() === "cf-r2-storage-class" && (value === "Standard" || value === "InfrequentAccess")))
855
- return true;
856
- return (/^\/accounts\/[^/]+\/r2\/buckets(?:\/|$)/.test(spec.path) &&
857
- headers.some(([name, value]) => name.toLowerCase() === "cf-r2-jurisdiction" && typeof value === "string" && value.trim().length > 0));
858
- }
859
- // Reviewed raw-body families, rather than a Content-Type assertion that could
860
- // send an ordinary JSON DNS/configuration mutation through the upload tool.
861
- function isUploadEndpoint(method, path) {
862
- const match = /^\/accounts\/[^/]+(\/.*)$/.exec(path);
863
- if (!match)
864
- return false;
865
- const accountPath = match[1];
866
- if (method === "PUT") {
867
- return /^(?:\/workers\/scripts\/[^/]+|\/storage\/kv\/namespaces\/[^/]+\/values\/.+|\/r2\/buckets\/[^/]+\/objects\/.+)\/?$/.test(accountPath);
868
- }
869
- if (method === "POST") {
870
- return /^(?:\/workers\/scripts\/[^/]+\/versions|\/images\/v1|\/stream|\/pages\/assets\/upload|\/pages\/projects\/[^/]+\/deployments)\/?$/.test(accountPath);
871
- }
872
- return false;
873
- }
874
- function r2Headers(args) {
875
- return { "cf-r2-jurisdiction": optionalString(args, "jurisdiction") };
876
- }
877
- function bytesFromBase64(value) {
878
- try {
879
- const binary = atob(value);
880
- return Uint8Array.from(binary, (character) => character.charCodeAt(0));
881
- }
882
- catch (cause) {
883
- throw new ConnectorCallError("invalid_args", "base64Body and multipart file base64 values must be valid base64.", {
884
- cause,
885
- });
886
- }
887
- }
888
- function uploadBody(args) {
889
- const fields = asArray(args["fields"]);
890
- const files = asArray(args["files"]);
891
- const hasMultipart = fields.length > 0 || files.length > 0;
892
- const textBody = typeof args["textBody"] === "string" ? args["textBody"] : undefined;
893
- const base64Body = typeof args["base64Body"] === "string" ? args["base64Body"] : undefined;
894
- const rawCount = Number(textBody !== undefined) + Number(base64Body !== undefined);
895
- if ((hasMultipart && rawCount > 0) || (!hasMultipart && rawCount !== 1)) {
896
- throw new ConnectorCallError("invalid_args", "cloudflare_api_upload needs exactly one body shape: textBody, base64Body, or multipart fields/files.");
897
- }
898
- if (hasMultipart) {
899
- const form = new FormData();
900
- for (const value of fields) {
901
- const field = asRecord(value);
902
- const name = String(field["name"]);
903
- const contentType = optionalString(field, "contentType");
904
- if (contentType) {
905
- form.append(name, new Blob([String(field["value"])], { type: contentType }), optionalString(field, "fileName") ?? name);
191
+ if (auth === "globalApiKey") {
192
+ const unpinned = options.unpinned;
193
+ if (unpinned !== undefined && unpinned !== true) {
194
+ throw new Error(`cloudflare("${id}") unpinned must be true when set.`);
906
195
  }
907
- else {
908
- form.append(name, String(field["value"]));
196
+ if (pin && unpinned)
197
+ throw new Error(`cloudflare("${id}") takes pin or unpinned: true, not both.`);
198
+ if (!pin && !unpinned) {
199
+ throw new Error(`cloudflare("${id}") requires pin: { accountIds, zoneIds } with globalApiKey auth, or unpinned: true: a Global API Key reaches every account and zone its user can.`);
909
200
  }
910
201
  }
911
- for (const value of files) {
912
- const file = asRecord(value);
913
- const text = typeof file["text"] === "string" ? file["text"] : undefined;
914
- const base64 = typeof file["base64"] === "string" ? file["base64"] : undefined;
915
- if (Number(text !== undefined) + Number(base64 !== undefined) !== 1) {
916
- throw new ConnectorCallError("invalid_args", "Each multipart file needs exactly one of text or base64.");
917
- }
918
- const blob = new Blob([text ?? bytesFromBase64(base64)], {
919
- type: String(file["contentType"] ?? "application/octet-stream"),
920
- });
921
- form.append(String(file["name"]), blob, String(file["fileName"]));
202
+ const accountId = optionalId(id, options.accountId, "accountId");
203
+ const zoneId = optionalId(id, options.zoneId, "zoneId");
204
+ if (pin && accountId && pin.accountIds && !pin.accountIds.includes(accountId)) {
205
+ throw new Error(`cloudflare("${id}") accountId ${accountId} is outside its pin.`);
922
206
  }
923
- return { rawBody: form };
924
- }
925
- return {
926
- rawBody: textBody ?? bytesFromBase64(base64Body),
927
- headers: {
928
- "Content-Type": optionalString(args, "contentType") ??
929
- (textBody !== undefined ? "text/plain; charset=utf-8" : "application/octet-stream"),
930
- },
931
- };
932
- }
933
- const ZONE_SETTING_SCHEMA = {
934
- type: "object",
935
- properties: {
936
- id: { type: "string" },
937
- value: {
938
- type: ["string", "number", "boolean", "array", "object", "null"],
939
- items: {},
940
- additionalProperties: true,
941
- },
942
- editable: { type: "boolean" },
943
- modifiedOn: { type: "string" },
944
- },
945
- required: ["id", "value"],
946
- };
947
- const RULESET_SCHEMA = {
948
- type: "object",
949
- properties: {
950
- id: { type: "string" },
951
- name: { type: "string" },
952
- kind: { type: "string" },
953
- phase: { type: "string" },
954
- description: { type: "string" },
955
- version: { type: "string" },
956
- lastUpdated: { type: "string" },
957
- rules: {
958
- type: "array",
959
- items: { type: "object", additionalProperties: true },
960
- },
961
- },
962
- required: ["id", "name"],
963
- };
964
- const WORKER_SETTINGS_SCHEMA = {
965
- type: "object",
966
- properties: {
967
- compatibilityDate: { type: "string" },
968
- compatibilityFlags: { type: "array", items: { type: "string" } },
969
- bindings: { type: "array", items: { type: "object", additionalProperties: true } },
970
- limits: { type: "object", additionalProperties: true },
971
- observability: { type: "object", additionalProperties: true },
972
- placement: { type: "object", additionalProperties: true },
973
- usageModel: { type: "string" },
974
- tailConsumers: { type: "array", items: { type: "object", additionalProperties: true } },
975
- logpush: { type: "boolean" },
976
- },
977
- };
978
- const WORKER_DEPLOYMENT_SCHEMA = {
979
- type: "object",
980
- properties: {
981
- id: { type: "string" },
982
- createdOn: { type: "string" },
983
- source: { type: "string" },
984
- strategy: { type: "string" },
985
- versions: { type: "array", items: { type: "object", additionalProperties: true } },
986
- },
987
- required: ["id"],
988
- };
989
- const KV_NAMESPACE_SCHEMA = {
990
- type: "object",
991
- properties: {
992
- id: { type: "string" },
993
- title: { type: "string" },
994
- supportsUrlEncoding: { type: "boolean" },
995
- jurisdiction: { type: "string", enum: ["eu", "fedramp", "us"] },
996
- renamed: { type: "boolean" },
997
- namespaceId: { type: "string" },
998
- },
999
- };
1000
- const KV_BULK_VALUES_SCHEMA = {
1001
- type: "object",
1002
- properties: {
1003
- values: {
1004
- type: "object",
1005
- additionalProperties: true,
1006
- },
1007
- },
1008
- required: ["values"],
1009
- };
1010
- const R2_CORS_SCHEMA = {
1011
- type: "object",
1012
- properties: {
1013
- rules: {
1014
- type: "array",
1015
- items: { type: "object", additionalProperties: true },
1016
- },
1017
- },
1018
- required: ["rules"],
1019
- };
1020
- const PAGES_DEPLOYMENT_SCHEMA = {
1021
- type: "object",
1022
- properties: {
1023
- id: { type: "string" },
1024
- projectName: { type: "string" },
1025
- environment: { type: "string" },
1026
- url: { type: "string" },
1027
- aliases: { type: "array", items: { type: "string" } },
1028
- stage: { type: "object", additionalProperties: true },
1029
- latestStage: { type: "object", additionalProperties: true },
1030
- createdOn: { type: "string" },
1031
- modifiedOn: { type: "string" },
1032
- },
1033
- required: ["id"],
1034
- };
1035
- const PAGES_DOMAIN_SCHEMA = {
1036
- type: "object",
1037
- properties: {
1038
- id: { type: "string" },
1039
- name: { type: "string" },
1040
- status: { type: "string" },
1041
- verificationData: { type: "object", additionalProperties: true },
1042
- createdOn: { type: "string" },
1043
- },
1044
- required: ["name"],
1045
- };
1046
- const PAGES_PROJECT_SCHEMA = {
1047
- type: "object",
1048
- properties: {
1049
- name: { type: "string" },
1050
- subdomain: { type: "string" },
1051
- domains: { type: "array", items: { type: "string" } },
1052
- productionBranch: { type: "string" },
1053
- createdOn: { type: "string" },
1054
- latestDeployment: PAGES_DEPLOYMENT_SCHEMA,
1055
- },
1056
- required: ["name"],
1057
- };
1058
- const QUERY_INPUT_PROPERTY = {
1059
- type: "array",
1060
- description: "Query parameters as name/value pairs; each name may appear once.",
1061
- items: {
1062
- type: "object",
1063
- properties: {
1064
- name: { type: "string", minLength: 1 },
1065
- value: { type: ["string", "number", "boolean"] },
1066
- },
1067
- required: ["name", "value"],
1068
- additionalProperties: false,
1069
- },
1070
- };
1071
- // Connector-owned and refused: Authorization, Cookie, Host, Content-Length,
1072
- // Content-Type, Transfer-Encoding — authentication, host selection, content
1073
- // type, and request framing are not the caller's to set. That list stays out of
1074
- // this description on purpose: the compact renderer inlines a shared property
1075
- // description once per tool, and spelling the six out three times pushed
1076
- // cloudflare_api_upload's compact input past the 1,024-byte discovery budget.
1077
- const HEADERS_INPUT_PROPERTY = {
1078
- type: "array",
1079
- description: "Endpoint headers as name/value pairs, e.g. cf-r2-jurisdiction, cf-r2-storage-class (Standard or InfrequentAccess), or Range. Connector-owned headers are refused.",
1080
- items: {
1081
- type: "object",
1082
- properties: {
1083
- name: { type: "string", minLength: 1 },
1084
- value: { type: "string" },
1085
- },
1086
- required: ["name", "value"],
1087
- additionalProperties: false,
1088
- },
1089
- };
1090
- const R2_JURISDICTION_PROPERTY = {
1091
- type: "string",
1092
- enum: ["default", "eu", "us", "fedramp"],
1093
- description: "Bucket jurisdiction. Omit for ordinary buckets; set eu, us, or fedramp for jurisdictional buckets.",
1094
- };
1095
- const R2_BUCKET_NAME_PROPERTY = {
1096
- type: "string",
1097
- minLength: 3,
1098
- maxLength: 64,
1099
- description: "R2 bucket name.",
1100
- };
1101
- const SCRIPT_NAME_PROPERTY = {
1102
- type: "string",
1103
- minLength: 1,
1104
- description: "Worker script name from list_worker_scripts.",
1105
- };
1106
- const NAMESPACE_ID_PROPERTY = {
1107
- type: "string",
1108
- minLength: 1,
1109
- description: "KV namespace id from list_kv_namespaces.",
1110
- };
1111
- const PROJECT_NAME_PROPERTY = {
1112
- type: "string",
1113
- minLength: 1,
1114
- description: "Pages project name from list_pages_projects.",
1115
- };
1116
- const PAGES_PROJECT_NAME_PROPERTY = {
1117
- type: "string",
1118
- minLength: 1,
1119
- description: "Pages project name.",
1120
- };
1121
- const DEPLOYMENT_ID_PROPERTY = {
1122
- type: "string",
1123
- minLength: 1,
1124
- description: "Deployment id from list_pages_deployments.",
1125
- };
1126
- const WORKER_DEPLOYMENT_ID_PROPERTY = {
1127
- type: "string",
1128
- minLength: 1,
1129
- description: "Deployment id from list_worker_deployments.",
1130
- };
1131
- const SETTING_ID_PROPERTY = {
1132
- type: "string",
1133
- minLength: 1,
1134
- description: "Cloudflare zone setting id, such as ssl, brotli, webmcp_enabled, or webmcp_packs.",
1135
- };
1136
- const RECORD_ID_PROPERTY = {
1137
- type: "string",
1138
- description: "DNS record id, from list_dns_records.",
1139
- };
1140
- const R2_BUCKET_SCHEMA = {
1141
- type: "object",
1142
- properties: {
1143
- name: { type: "string" },
1144
- location: { type: "string" },
1145
- storageClass: { type: "string" },
1146
- jurisdiction: { type: "string", enum: ["default", "eu", "us", "fedramp"] },
1147
- creationDate: { type: "string" },
1148
- },
1149
- required: ["name"],
1150
- };
1151
- const R2_OBJECT_SCHEMA = {
1152
- type: "object",
1153
- properties: {
1154
- key: { type: "string" },
1155
- size: { type: "number" },
1156
- etag: { type: "string" },
1157
- lastModified: { type: "string" },
1158
- storageClass: { type: "string" },
1159
- httpMetadata: { type: "object" },
1160
- customMetadata: { type: "object" },
1161
- },
1162
- required: ["key"],
1163
- };
1164
- function cfTool(name, description, annotations, scopeKind, scopeFallback, properties, required, outputSchema, handler) {
1165
- return {
1166
- name,
1167
- description,
1168
- annotations,
1169
- inputSchema: {
1170
- type: "object",
1171
- properties: scopeKind
1172
- ? {
1173
- [scopeKind]: scopeProperty(scopeKind, scopeFallback),
1174
- ...properties,
1175
- }
1176
- : properties,
1177
- required: scopeKind ? [...scopeRequired(scopeKind, scopeFallback), ...required] : required,
1178
- additionalProperties: false,
1179
- },
1180
- outputSchema,
1181
- handler,
1182
- };
1183
- }
1184
- function getResult(send, request, project = (result) => result) {
1185
- return async (args, ctx) => {
1186
- const { result } = await callCloudflare(send, request(args), ctx);
1187
- return project(result, args);
1188
- };
1189
- }
1190
- function buildTools(scope, authentication) {
1191
- const { send } = scope;
1192
- const zoneArg = (args) => requireScope(args["zoneId"], scope.zoneId, "zoneId");
1193
- const accountArg = (args) => requireScope(args["accountId"], scope.accountId, "accountId");
1194
- const readOnly = { readOnlyHint: true, destructiveHint: false };
1195
- const tools = [
1196
- authentication === "apiToken"
1197
- ? cfTool("verify_api_token", "Verify the configured Cloudflare API token and report its status. Use this first when any other tool fails with an authentication error, to separate a bad token from a missing permission.", readOnly, undefined, undefined, {}, [], {
1198
- type: "object",
1199
- properties: {
1200
- id: { type: "string" },
1201
- status: {
1202
- type: "string",
1203
- description: '"active" for a usable token.',
1204
- },
1205
- notBefore: { type: "string" },
1206
- expiresOn: { type: "string" },
1207
- },
1208
- required: ["status"],
1209
- }, async (_args, ctx) => {
1210
- const { result } = await callCloudflare(send, { method: "GET", path: "/user/tokens/verify" }, ctx);
1211
- const token = asRecord(result);
1212
- return compact({
1213
- id: token["id"],
1214
- status: token["status"],
1215
- notBefore: token["not_before"],
1216
- expiresOn: token["expires_on"],
1217
- });
1218
- })
1219
- : cfTool("verify_global_api_key", "Verify the configured Cloudflare Global API Key and account email by retrieving the authenticated user. Use this first when another tool fails with an authentication error.", readOnly, undefined, undefined, {}, [], {
1220
- type: "object",
1221
- properties: {
1222
- id: { type: "string" },
1223
- email: { type: "string" },
1224
- status: {
1225
- type: "string",
1226
- description: '"active" when Cloudflare accepts the email and key.',
1227
- },
1228
- },
1229
- required: ["email", "status"],
1230
- }, async (_args, ctx) => {
1231
- const { result } = await callCloudflare(send, { method: "GET", path: "/user" }, ctx);
1232
- const user = asRecord(result);
1233
- return { id: user["id"], email: user["email"], status: "active" };
1234
- }),
1235
- cfTool("cloudflare_api_get", "Call any GET endpoint under Cloudflare's v4 API with this connector's credential. Prefer a named tool when one exists; this read-only hatch covers the products the named surface does not reach, such as Images, Stream, D1, and Queues.", readOnly, undefined, undefined, {
1236
- path: {
1237
- type: "string",
1238
- minLength: 1,
1239
- description: "Relative path below /client/v4, beginning with '/', for example /accounts/<id>/images/v1 or /zones/<id>/email/routing/rules. Do not include a query string.",
1240
- },
1241
- query: QUERY_INPUT_PROPERTY,
1242
- headers: HEADERS_INPUT_PROPERTY,
1243
- responseType: {
1244
- type: "string",
1245
- enum: ["json", "text", "base64"],
1246
- description: "How to read a successful response. Defaults to json; use text or base64 for object, log, script, and media downloads.",
1247
- },
1248
- }, ["path"], {
1249
- type: "object",
1250
- properties: {
1251
- result: {
1252
- description: "Cloudflare's unprojected result for the endpoint.",
1253
- },
1254
- resultInfo: {
1255
- type: "object",
1256
- description: "Cloudflare's unprojected pagination metadata, when the endpoint returns it.",
1257
- },
1258
- text: { type: "string", description: "Text response body when responseType is text." },
1259
- base64: { type: "string", description: "Base64 response bytes when responseType is base64." },
1260
- contentType: { type: "string", description: "Response Content-Type for text/base64 reads." },
1261
- etag: { type: "string", description: "Response ETag when Cloudflare supplies one." },
1262
- },
1263
- required: [],
1264
- }, async (args, ctx) => {
1265
- const raw = rawSpec(args);
1266
- const responseType = optionalString(args, "responseType") ?? "json";
1267
- const spec = compact({
1268
- method: "GET",
1269
- ...raw,
1270
- });
1271
- if (responseType === "text" || responseType === "base64") {
1272
- return await callCloudflareContent(send, spec, ctx, responseType);
1273
- }
1274
- const { result, resultInfo } = await callCloudflare(send, spec, ctx);
1275
- return compact({
1276
- result,
1277
- resultInfo,
1278
- });
1279
- }),
1280
- cfTool("cloudflare_api_mutate", "Call JSON mutations for Global API Key identity or R2 jurisdiction/storage-class header gaps. Ordinary API-token writes use hosted execute. Bucket PATCH accepts cf-r2-storage-class: Standard or InfrequentAccess; no uploads.", { readOnlyHint: false, destructiveHint: true }, undefined, undefined, {
1281
- method: {
1282
- type: "string",
1283
- enum: ["POST", "PUT", "PATCH", "DELETE"],
1284
- description: "HTTP mutation method required by the Cloudflare endpoint.",
1285
- },
1286
- path: {
1287
- type: "string",
1288
- minLength: 1,
1289
- description: "Relative path below /client/v4, beginning with '/'. Do not include a query string.",
1290
- },
1291
- query: QUERY_INPUT_PROPERTY,
1292
- headers: HEADERS_INPUT_PROPERTY,
1293
- body: {
1294
- type: ["object", "array", "string", "number", "boolean", "null"],
1295
- description: "JSON request body exactly as documented by Cloudflare. Omit for endpoints with no body.",
1296
- },
1297
- }, ["method", "path"], {
1298
- type: "object",
1299
- properties: {
1300
- result: {
1301
- description: "Cloudflare's unprojected result for the endpoint.",
1302
- },
1303
- resultInfo: {
1304
- type: "object",
1305
- description: "Cloudflare's unprojected pagination metadata, when the endpoint returns it.",
1306
- },
1307
- },
1308
- required: ["result"],
1309
- }, async (args, ctx) => {
1310
- const method = String(args["method"]);
1311
- const spec = rawSpec(args);
1312
- if (authentication === "apiToken" && !needsMutationHeaders(method, spec)) {
1313
- throw new ConnectorCallError("invalid_args", "Use the Cloudflare MCP execute tool for JSON mutations with an API token. This REST tool is reserved for Global API Key identity, R2 bucket operations requiring cf-r2-jurisdiction, or bucket PATCH with cf-r2-storage-class set to Standard or InfrequentAccess.");
1314
- }
1315
- const { result, resultInfo } = await callCloudflare(send, compact({
1316
- method,
1317
- ...spec,
1318
- body: args["body"],
1319
- }), ctx);
1320
- return compact({
1321
- result,
1322
- resultInfo,
1323
- });
1324
- }),
1325
- cfTool("cloudflare_api_upload", "Upload raw text, base64 bytes, or multipart form data to a Cloudflare v4 POST or PUT endpoint. Covers Worker modules, R2/KV objects, Images, Stream, and Pages upload endpoints. Reads no local files; content must be supplied explicitly.", { readOnlyHint: false, destructiveHint: true }, undefined, undefined, {
1326
- method: {
1327
- type: "string",
1328
- enum: ["POST", "PUT"],
1329
- description: "Upload method the Cloudflare endpoint requires.",
1330
- },
1331
- path: {
1332
- type: "string",
1333
- minLength: 1,
1334
- description: "Path below /client/v4, beginning with '/'. No query string.",
1335
- },
1336
- query: QUERY_INPUT_PROPERTY,
1337
- headers: HEADERS_INPUT_PROPERTY,
1338
- contentType: {
1339
- type: "string",
1340
- minLength: 1,
1341
- description: "Content-Type for a raw text or base64 body. Omit for multipart.",
1342
- },
1343
- textBody: {
1344
- type: "string",
1345
- description: "Raw UTF-8 body. Exclusive with base64Body and fields/files.",
1346
- },
1347
- base64Body: {
1348
- type: "string",
1349
- description: "Base64-encoded body bytes. Exclusive with textBody and fields/files.",
1350
- },
1351
- fields: {
1352
- type: "array",
1353
- description: "String fields of a multipart/form-data request.",
1354
- items: {
1355
- type: "object",
1356
- properties: {
1357
- name: { type: "string", minLength: 1 },
1358
- value: { type: "string" },
1359
- contentType: { type: "string", minLength: 1 },
1360
- fileName: { type: "string", minLength: 1 },
1361
- },
1362
- required: ["name", "value"],
1363
- additionalProperties: false,
1364
- },
1365
- },
1366
- files: {
1367
- type: "array",
1368
- description: "Multipart file parts. Each needs exactly one of text or base64.",
1369
- items: {
1370
- type: "object",
1371
- properties: {
1372
- name: { type: "string", minLength: 1 },
1373
- fileName: { type: "string", minLength: 1 },
1374
- contentType: { type: "string", minLength: 1 },
1375
- text: { type: "string" },
1376
- base64: { type: "string" },
1377
- },
1378
- required: ["name", "fileName", "contentType"],
1379
- additionalProperties: false,
1380
- },
1381
- },
1382
- }, ["method", "path"], {
1383
- type: "object",
1384
- properties: {
1385
- result: {
1386
- description: "Cloudflare's unprojected upload result.",
1387
- },
1388
- },
1389
- required: ["result"],
1390
- }, async (args, ctx) => {
1391
- const spec = rawSpec(args);
1392
- if (authentication === "apiToken" && !isUploadEndpoint(args["method"], spec.path)) {
1393
- throw new ConnectorCallError("invalid_args", "Use the Cloudflare MCP execute tool for this mutation. REST uploads are confined to Workers, KV values, R2 objects, Images, Stream and Pages upload endpoints.");
1394
- }
1395
- const upload = uploadBody(args);
1396
- const { result } = await callCloudflare(send, compact({
1397
- method: String(args["method"]),
1398
- ...spec,
1399
- headers: spec.headers !== undefined || upload.headers !== undefined
1400
- ? { ...spec.headers, ...upload.headers }
1401
- : undefined,
1402
- rawBody: upload.rawBody,
1403
- }), ctx);
1404
- return { result };
1405
- }),
1406
- cfTool("list_accounts", "List Cloudflare accounts this token can see. Supplies the accountId that the Workers, KV, R2, and Pages tools need.", readOnly, undefined, undefined, {
1407
- name: {
1408
- type: "string",
1409
- description: "Filter by exact account name.",
1410
- },
1411
- ...pagingInputProperties(5, 50, { defaultPerPage: 20 }),
1412
- raw: RAW_INPUT_PROPERTY,
1413
- }, [], listOutputSchema("accounts", ACCOUNT_SCHEMA), async (args, ctx) => {
1414
- const { result, resultInfo } = await callCloudflare(send, {
1415
- method: "GET",
1416
- path: "/accounts",
1417
- query: {
1418
- name: optionalString(args, "name"),
1419
- page: optionalNumber(args, "page"),
1420
- per_page: optionalNumber(args, "perPage"),
1421
- },
1422
- }, ctx);
1423
- return pagedList("accounts", projectAccount)(result, resultInfo, args["raw"] === true);
1424
- }),
1425
- cfTool("list_zones", "List zones (domains) this token can see, with their ids and status. This is the zoneId discovery step for every DNS and cache tool.", readOnly, undefined, undefined, {
1426
- name: {
1427
- type: "string",
1428
- description: "Filter by zone name, e.g. example.com.",
1429
- },
1430
- accountId: {
1431
- type: "string",
1432
- description: "Restrict to one account. Defaults to every account the token can see.",
1433
- },
1434
- status: {
1435
- type: "string",
1436
- enum: ["initializing", "pending", "active", "moved"],
1437
- description: "Filter by zone status.",
1438
- },
1439
- ...pagingInputProperties(5, 50, { defaultPerPage: 20 }),
1440
- raw: RAW_INPUT_PROPERTY,
1441
- }, [], listOutputSchema("zones", ZONE_SCHEMA), async (args, ctx) => {
1442
- const { result, resultInfo } = await callCloudflare(send, {
1443
- method: "GET",
1444
- path: "/zones",
1445
- query: {
1446
- name: optionalString(args, "name"),
1447
- // Undefaulted on purpose: list_zones is the discovery step,
1448
- // and quietly filtering it by a configured accountId would
1449
- // be a restriction in all but name — one with no argument
1450
- // that escapes it, since an empty accountId would fall back
1451
- // to the default again.
1452
- "account.id": optionalString(args, "accountId"),
1453
- status: optionalString(args, "status"),
1454
- page: optionalNumber(args, "page"),
1455
- per_page: optionalNumber(args, "perPage"),
1456
- },
1457
- }, ctx);
1458
- return pagedList("zones", projectZone)(result, resultInfo, args["raw"] === true);
1459
- }),
1460
- cfTool("get_zone", "Fetch one zone's settings summary by id: status, plan, name servers, and owning account.", readOnly, "zoneId", scope.zoneId, {
1461
- raw: RAW_INPUT_PROPERTY,
1462
- }, [], ZONE_SCHEMA, getResult(send, (args) => ({ method: "GET", path: `/zones/${encodeURIComponent(zoneArg(args))}` }), (result, args) => (args["raw"] === true ? result : projectZone(result)))),
1463
- // No bulk `list_zone_settings` on purpose (#361): Cloudflare's published
1464
- // document marks GET /zones/{zoneId}/settings and its PATCH sibling
1465
- // deprecated with no bulk replacement, and the tool projected nothing while
1466
- // *growing* the payload 22.7% by wrapping an unpaginated settings array in a
1467
- // page object. Read one setting here; an operator who genuinely wants the
1468
- // whole set names /zones/{zoneId}/settings through cloudflare_api_get, at
1469
- // the caller's risk rather than promised by connecta's catalog.
1470
- cfTool("get_zone_setting", "Get one zone setting by its Cloudflare setting id, such as ssl, always_use_https, min_tls_version, brotli, or development_mode.", readOnly, "zoneId", scope.zoneId, {
1471
- settingId: SETTING_ID_PROPERTY,
1472
- raw: RAW_INPUT_PROPERTY,
1473
- }, ["settingId"], ZONE_SETTING_SCHEMA, getResult(send, (args) => ({
1474
- method: "GET",
1475
- path: `/zones/${encodeURIComponent(zoneArg(args))}/settings/${encodeURIComponent(requireString(args, "settingId"))}`,
1476
- }), (result, args) => (args["raw"] === true ? result : projectZoneSetting(result)))),
1477
- cfTool("list_zone_rulesets", "List zone rulesets for WAF, redirects, transforms, cache rules, configuration rules, and other Ruleset Engine phases.", readOnly, "zoneId", scope.zoneId, {
1478
- perPage: {
1479
- type: "integer",
1480
- minimum: 1,
1481
- maximum: 50,
1482
- description: "Rulesets per request, 1 to 50.",
1483
- },
1484
- cursor: CURSOR_INPUT_PROPERTY,
1485
- }, [], {
1486
- type: "object",
1487
- properties: {
1488
- rulesets: { type: "array", items: RULESET_SCHEMA },
1489
- nextCursor: NEXT_CURSOR_OUTPUT_PROPERTY,
1490
- },
1491
- required: ["rulesets"],
1492
- }, async (args, ctx) => {
1493
- const { result, resultInfo } = await callCloudflare(send, {
1494
- method: "GET",
1495
- path: `/zones/${encodeURIComponent(zoneArg(args))}/rulesets`,
1496
- query: {
1497
- per_page: optionalNumber(args, "perPage"),
1498
- cursor: optionalString(args, "cursor"),
1499
- },
1500
- }, ctx);
1501
- const cursor = resultInfo?.cursors?.after;
1502
- return {
1503
- rulesets: asArray(result).map(projectRuleset),
1504
- ...cursorResult(cursor),
1505
- };
1506
- }),
1507
- cfTool("get_zone_ruleset", "Get one zone ruleset including its ordered rules, expressions, actions, parameters, and enabled state.", readOnly, "zoneId", scope.zoneId, {
1508
- rulesetId: {
1509
- type: "string",
1510
- minLength: 1,
1511
- description: "Ruleset id from list_zone_rulesets.",
1512
- },
1513
- }, ["rulesetId"], RULESET_SCHEMA, getResult(send, (args) => ({
1514
- method: "GET",
1515
- path: `/zones/${encodeURIComponent(zoneArg(args))}/rulesets/${encodeURIComponent(requireString(args, "rulesetId"))}`,
1516
- }), projectRuleset)),
1517
- cfTool("list_dns_records", "List DNS records in a zone, filtered by name, type, or content. Returns record ids, which update_dns_record and delete_dns_record require.", readOnly, "zoneId", scope.zoneId, {
1518
- name: {
1519
- type: "string",
1520
- description: "Exact record name, fully qualified, e.g. www.example.com.",
1521
- },
1522
- type: {
1523
- type: "string",
1524
- enum: [...CLOUDFLARE_DNS_RECORD_TYPES],
1525
- description: "Filter by record type.",
1526
- },
1527
- content: {
1528
- type: "string",
1529
- description: "Exact record content, e.g. an IP address.",
1530
- },
1531
- order: {
1532
- type: "string",
1533
- enum: ["type", "name", "content", "ttl", "proxied"],
1534
- description: "Sort field.",
1535
- },
1536
- direction: {
1537
- type: "string",
1538
- enum: ["asc", "desc"],
1539
- description: "Sort direction for `order`. Defaults to asc.",
1540
- },
1541
- // Cloudflare documents 1 to 5,000,000 here with a default of 100; the
1542
- // ceiling is nominal, so this connection caps it at a page size that
1543
- // actually returns.
1544
- ...pagingInputProperties(1, 1000, {
1545
- defaultPerPage: 100,
1546
- bounds: "clamped",
1547
- }),
1548
- raw: RAW_INPUT_PROPERTY,
1549
- }, [], listOutputSchema("records", DNS_RECORD_SCHEMA), async (args, ctx) => {
1550
- const { result, resultInfo } = await callCloudflare(send, {
1551
- method: "GET",
1552
- path: `/zones/${encodeURIComponent(zoneArg(args))}/dns_records`,
1553
- query: {
1554
- name: optionalString(args, "name"),
1555
- type: optionalString(args, "type"),
1556
- content: optionalString(args, "content"),
1557
- order: optionalString(args, "order"),
1558
- direction: optionalString(args, "direction"),
1559
- page: optionalNumber(args, "page"),
1560
- per_page: optionalNumber(args, "perPage"),
1561
- },
1562
- }, ctx);
1563
- return pagedList("records", projectDnsRecord)(result, resultInfo, args["raw"] === true);
1564
- }),
1565
- cfTool("get_dns_record", "Fetch one DNS record by its record id.", readOnly, "zoneId", scope.zoneId, {
1566
- recordId: RECORD_ID_PROPERTY,
1567
- raw: RAW_INPUT_PROPERTY,
1568
- }, ["recordId"], DNS_RECORD_SCHEMA, getResult(send, (args) => ({
1569
- method: "GET",
1570
- path: `/zones/${encodeURIComponent(zoneArg(args))}/dns_records/${encodeURIComponent(String(args["recordId"]))}`,
1571
- }), (result, args) => (args["raw"] === true ? result : projectDnsRecord(result)))),
1572
- cfTool("list_worker_scripts", "List Workers scripts deployed in an account, with their last-modified times.", readOnly, "accountId", scope.accountId, {
1573
- raw: RAW_INPUT_PROPERTY,
1574
- }, [], listOutputSchema("scripts", {
1575
- type: "object",
1576
- properties: {
1577
- id: { type: "string", description: "Script name." },
1578
- createdOn: { type: "string" },
1579
- modifiedOn: { type: "string" },
1580
- usageModel: { type: "string" },
1581
- },
1582
- required: ["id"],
1583
- }), async (args, ctx) => {
1584
- const { result, resultInfo } = await callCloudflare(send, {
1585
- method: "GET",
1586
- path: `/accounts/${encodeURIComponent(accountArg(args))}/workers/scripts`,
1587
- }, ctx);
1588
- return pagedList("scripts", projectWorkerScript)(result, resultInfo, args["raw"] === true);
1589
- }),
1590
- cfTool("get_worker_settings", "Get a Worker's compatibility date and flags, bindings, limits, observability, placement, usage model, and other script settings.", readOnly, "accountId", scope.accountId, {
1591
- scriptName: SCRIPT_NAME_PROPERTY,
1592
- raw: RAW_INPUT_PROPERTY,
1593
- }, ["scriptName"], WORKER_SETTINGS_SCHEMA, getResult(send, (args) => ({
1594
- method: "GET",
1595
- path: `/accounts/${encodeURIComponent(accountArg(args))}/workers/scripts/${encodeURIComponent(requireString(args, "scriptName"))}/settings`,
1596
- }), (result, args) => (args["raw"] === true ? result : projectWorkerSettings(result)))),
1597
- cfTool("list_worker_deployments", "List deployments of a Worker script, including version traffic allocations and deployment strategy.", readOnly, "accountId", scope.accountId, {
1598
- scriptName: SCRIPT_NAME_PROPERTY,
1599
- }, ["scriptName"], listOutputSchema("deployments", WORKER_DEPLOYMENT_SCHEMA), async (args, ctx) => {
1600
- const { result } = await callCloudflare(send, {
1601
- method: "GET",
1602
- path: `/accounts/${encodeURIComponent(accountArg(args))}/workers/scripts/${encodeURIComponent(requireString(args, "scriptName"))}/deployments`,
1603
- }, ctx);
1604
- const record = asRecord(result);
1605
- const deployments = Array.isArray(result) ? result : asArray(record["deployments"]);
1606
- return { deployments: deployments.map(projectWorkerDeployment) };
1607
- }),
1608
- cfTool("get_worker_deployment", "Get one Worker deployment and its version traffic allocations.", readOnly, "accountId", scope.accountId, {
1609
- scriptName: SCRIPT_NAME_PROPERTY,
1610
- deploymentId: WORKER_DEPLOYMENT_ID_PROPERTY,
1611
- }, ["scriptName", "deploymentId"], WORKER_DEPLOYMENT_SCHEMA, getResult(send, (args) => ({
1612
- method: "GET",
1613
- path: `/accounts/${encodeURIComponent(accountArg(args))}/workers/scripts/${encodeURIComponent(requireString(args, "scriptName"))}/deployments/${encodeURIComponent(requireString(args, "deploymentId"))}`,
1614
- }), projectWorkerDeployment)),
1615
- cfTool("list_kv_namespaces", "List Workers KV namespaces in an account, with the namespace ids bindings refer to.", readOnly, "accountId", scope.accountId, {
1616
- ...pagingInputProperties(1, 1000, { defaultPerPage: 20 }),
1617
- raw: RAW_INPUT_PROPERTY,
1618
- }, [], listOutputSchema("namespaces", {
1619
- type: "object",
1620
- properties: {
1621
- id: { type: "string" },
1622
- title: { type: "string" },
1623
- supportsUrlEncoding: { type: "boolean" },
1624
- jurisdiction: { type: "string", enum: ["eu", "fedramp", "us"] },
1625
- },
1626
- required: ["id", "title"],
1627
- }), async (args, ctx) => {
1628
- const { result, resultInfo } = await callCloudflare(send, {
1629
- method: "GET",
1630
- path: `/accounts/${encodeURIComponent(accountArg(args))}/storage/kv/namespaces`,
1631
- query: {
1632
- page: optionalNumber(args, "page"),
1633
- per_page: optionalNumber(args, "perPage"),
1634
- },
1635
- }, ctx);
1636
- return pagedList("namespaces", projectKvNamespace)(result, resultInfo, args["raw"] === true);
1637
- }),
1638
- cfTool("get_kv_namespace", "Get one Workers KV namespace by id.", readOnly, "accountId", scope.accountId, {
1639
- namespaceId: NAMESPACE_ID_PROPERTY,
1640
- }, ["namespaceId"], KV_NAMESPACE_SCHEMA, getResult(send, (args) => ({
1641
- method: "GET",
1642
- path: `/accounts/${encodeURIComponent(accountArg(args))}/storage/kv/namespaces/${encodeURIComponent(requireString(args, "namespaceId"))}`,
1643
- }), projectKvNamespace)),
1644
- cfTool("list_kv_keys", "List keys and metadata in a Workers KV namespace by prefix, using cursor pagination.", readOnly, "accountId", scope.accountId, {
1645
- namespaceId: NAMESPACE_ID_PROPERTY,
1646
- prefix: {
1647
- type: "string",
1648
- description: "Return only keys beginning with this prefix.",
1649
- },
1650
- limit: {
1651
- type: "integer",
1652
- minimum: 10,
1653
- maximum: 1000,
1654
- description: "Keys per request, 10 to 1000. Defaults to 1000.",
1655
- },
1656
- cursor: CURSOR_INPUT_PROPERTY,
1657
- }, ["namespaceId"], {
1658
- type: "object",
1659
- properties: {
1660
- keys: {
1661
- type: "array",
1662
- items: {
1663
- type: "object",
1664
- properties: {
1665
- name: { type: "string" },
1666
- expiration: { type: "number" },
1667
- metadata: {},
1668
- },
1669
- required: ["name"],
1670
- },
1671
- },
1672
- nextCursor: NEXT_CURSOR_OUTPUT_PROPERTY,
1673
- },
1674
- required: ["keys"],
1675
- }, async (args, ctx) => {
1676
- const { result, resultInfo } = await callCloudflare(send, {
1677
- method: "GET",
1678
- path: `/accounts/${encodeURIComponent(accountArg(args))}/storage/kv/namespaces/${encodeURIComponent(requireString(args, "namespaceId"))}/keys`,
1679
- query: {
1680
- prefix: optionalString(args, "prefix"),
1681
- limit: optionalNumber(args, "limit"),
1682
- cursor: optionalString(args, "cursor"),
1683
- },
1684
- }, ctx);
1685
- const cursor = resultInfo?.cursor;
1686
- return {
1687
- keys: asArray(result).map(projectKvKey),
1688
- ...cursorResult(cursor),
1689
- };
1690
- }),
1691
- cfTool("bulk_get_kv_values", "Read up to 100 Workers KV values in one request. This JSON endpoint is suitable for text and JSON values; use the raw API for specialized response types.", readOnly, "accountId", scope.accountId, {
1692
- namespaceId: NAMESPACE_ID_PROPERTY,
1693
- keys: {
1694
- type: "array",
1695
- minItems: 1,
1696
- maxItems: 100,
1697
- items: { type: "string", minLength: 1, maxLength: 512 },
1698
- description: "Key names to retrieve, up to 100.",
1699
- },
1700
- withMetadata: {
1701
- type: "boolean",
1702
- description: "Include each key's metadata and expiration when true.",
1703
- },
1704
- type: {
1705
- type: "string",
1706
- enum: ["text", "json"],
1707
- description: "Return strings as stored, or parse JSON values before returning them.",
1708
- },
1709
- }, ["namespaceId", "keys"], KV_BULK_VALUES_SCHEMA, async (args, ctx) => {
1710
- const { result } = await callCloudflare(send, {
1711
- method: "POST",
1712
- path: `/accounts/${encodeURIComponent(accountArg(args))}/storage/kv/namespaces/${encodeURIComponent(requireString(args, "namespaceId"))}/bulk/get`,
1713
- body: compact({
1714
- keys: args["keys"],
1715
- withMetadata: args["withMetadata"],
1716
- type: args["type"],
207
+ if (pin && zoneId && pin.zoneIds && !pin.accountIds && !pin.zoneIds.includes(zoneId)) {
208
+ throw new Error(`cloudflare("${id}") zoneId ${zoneId} is outside its pin.`);
209
+ }
210
+ const purpose = options.purpose.trim();
211
+ const rest = cloudflareRest({
212
+ auth,
213
+ baseUrl: options.baseUrl?.trim() || CLOUDFLARE_API_BASE,
214
+ accountId,
215
+ zoneId,
216
+ pin,
217
+ });
218
+ return apiConnector(id, {
219
+ ...provider.connectorOptions,
220
+ title: options.title ?? (auth === "globalApiKey" ? "Cloudflare (Global API Key)" : "Cloudflare"),
221
+ description: `Cloudflare v4 API over REST (${auth === "globalApiKey" ? "Global API Key" : "API token"}${pin ? ", pinned" : ""}) — ${purpose}`,
222
+ credential: auth === "globalApiKey" ? GLOBAL_API_KEY_CREDENTIAL : API_TOKEN_CREDENTIAL,
223
+ ...(auth === "globalApiKey"
224
+ ? { testCredentials: rest.testCredentials }
225
+ : { testCredential: rest.testCredential }),
226
+ callAdmission: admissionPolicy(maxConcurrency),
227
+ usageGuide: {
228
+ content: keyUsageGuide(auth, {
229
+ purpose,
230
+ ...(accountId ? { accountId } : {}),
231
+ ...(zoneId ? { zoneId } : {}),
232
+ ...(pin ? { pin } : {}),
233
+ ...(options.instructions !== undefined ? { instructions: options.instructions } : {}),
1717
234
  }),
1718
- }, ctx);
1719
- return projectKvBulkValues(result);
1720
- }),
1721
- cfTool("list_r2_buckets", "List R2 buckets in an account, with location and storage class.", readOnly, "accountId", scope.accountId, {
1722
- nameContains: {
1723
- type: "string",
1724
- description: "Filter to buckets whose name contains this string.",
1725
- },
1726
- perPage: {
1727
- type: "integer",
1728
- minimum: 1,
1729
- maximum: 1000,
1730
- description: "Buckets per request, 1 to 1000. Defaults to 20.",
1731
- },
1732
- cursor: CURSOR_INPUT_PROPERTY,
1733
- jurisdiction: R2_JURISDICTION_PROPERTY,
1734
- raw: RAW_INPUT_PROPERTY,
1735
- }, [], {
1736
- type: "object",
1737
- properties: {
1738
- buckets: {
1739
- type: "array",
1740
- items: R2_BUCKET_SCHEMA,
1741
- },
1742
- nextCursor: NEXT_CURSOR_OUTPUT_PROPERTY,
1743
- },
1744
- required: ["buckets"],
1745
- }, async (args, ctx) => {
1746
- const { result, resultInfo } = await callCloudflare(send, {
1747
- method: "GET",
1748
- path: `/accounts/${encodeURIComponent(accountArg(args))}/r2/buckets`,
1749
- query: {
1750
- name_contains: optionalString(args, "nameContains"),
1751
- per_page: optionalNumber(args, "perPage"),
1752
- cursor: optionalString(args, "cursor"),
1753
- },
1754
- headers: r2Headers(args),
1755
- }, ctx);
1756
- // R2 nests its list under `buckets` rather than returning a bare array,
1757
- // and its result_info carries a cursor instead of page counters.
1758
- const cursor = resultInfo?.cursor;
1759
- const next = cursorResult(cursor);
1760
- if (args["raw"] === true)
1761
- return { buckets: result, ...next };
1762
- return {
1763
- buckets: asArray(asRecord(result)["buckets"]).map(projectR2Bucket),
1764
- ...next,
1765
- };
1766
- }),
1767
- cfTool("get_r2_bucket", "Get one R2 bucket's location, jurisdiction, storage class, and creation time.", readOnly, "accountId", scope.accountId, {
1768
- bucketName: R2_BUCKET_NAME_PROPERTY,
1769
- jurisdiction: R2_JURISDICTION_PROPERTY,
1770
- }, ["bucketName"], R2_BUCKET_SCHEMA, getResult(send, (args) => ({
1771
- method: "GET",
1772
- path: `/accounts/${encodeURIComponent(accountArg(args))}/r2/buckets/${encodeURIComponent(requireString(args, "bucketName"))}`,
1773
- headers: r2Headers(args),
1774
- }), projectR2Bucket)),
1775
- cfTool("list_r2_objects", "List object keys and metadata in an R2 bucket by prefix, with delimiter grouping and cursor pagination.", readOnly, "accountId", scope.accountId, {
1776
- bucketName: R2_BUCKET_NAME_PROPERTY,
1777
- jurisdiction: R2_JURISDICTION_PROPERTY,
1778
- prefix: {
1779
- type: "string",
1780
- description: "Return only object keys beginning with this prefix.",
1781
- },
1782
- delimiter: {
1783
- type: "string",
1784
- minLength: 1,
1785
- maxLength: 1,
1786
- description: "One character used to group path-like keys, usually '/'.",
1787
- },
1788
- startAfter: {
1789
- type: "string",
1790
- description: "Begin after this key in lexicographic order.",
1791
- },
1792
- perPage: {
1793
- type: "integer",
1794
- minimum: 1,
1795
- maximum: 1000,
1796
- description: "Objects per request, 1 to 1000.",
235
+ summary: auth === "globalApiKey"
236
+ ? "GLOBAL API KEY: the user's whole estate, limited by pin. Search operations, read details, then read or write."
237
+ : "Scoped API token over REST. Search operations, read details, then read or write; uploads and GraphQL are named tools.",
1797
238
  },
1798
- cursor: CURSOR_INPUT_PROPERTY,
1799
- }, ["bucketName"], {
1800
- type: "object",
1801
- properties: {
1802
- objects: { type: "array", items: R2_OBJECT_SCHEMA },
1803
- commonPrefixes: { type: "array", items: { type: "string" } },
1804
- nextCursor: NEXT_CURSOR_OUTPUT_PROPERTY,
1805
- truncated: { type: "boolean" },
1806
- },
1807
- required: ["objects", "truncated"],
1808
- }, async (args, ctx) => {
1809
- const { result, resultInfo } = await callCloudflare(send, {
1810
- method: "GET",
1811
- path: `/accounts/${encodeURIComponent(accountArg(args))}/r2/buckets/${encodeURIComponent(requireString(args, "bucketName"))}/objects`,
1812
- headers: r2Headers(args),
1813
- query: {
1814
- prefix: optionalString(args, "prefix"),
1815
- delimiter: optionalString(args, "delimiter"),
1816
- start_after: optionalString(args, "startAfter"),
1817
- per_page: optionalNumber(args, "perPage"),
1818
- cursor: optionalString(args, "cursor"),
1819
- },
1820
- }, ctx);
1821
- const cursor = resultInfo?.cursor;
1822
- return {
1823
- objects: asArray(result).map(projectR2Object),
1824
- ...(Array.isArray(resultInfo?.delimited) ? { commonPrefixes: resultInfo.delimited } : {}),
1825
- ...cursorResult(cursor),
1826
- truncated: resultInfo?.is_truncated === true,
1827
- };
1828
- }),
1829
- // No `get_r2_metrics`, `set_r2_cors`, or `delete_r2_cors` on purpose (#350).
1830
- // A named tool is a permanent line item in every deployment's catalog, and
1831
- // these lost the comparison against the escape hatches: `get_r2_metrics`
1832
- // took an account id, put it in a path, and returned the response untouched,
1833
- // which `cloudflare_api_get` at /accounts/{accountId}/r2/metrics already
1834
- // does. `set_r2_cors` declared its rule list as free-form objects — the
1835
- // untyped body refused everywhere else — and returned Cloudflare's response
1836
- // unprojected, so it beat the raw route on nothing. `delete_r2_cors`
1837
- // validated fine and went anyway, because naming only the delete would mean
1838
- // one CORS policy is set through the raw route and cleared through a named
1839
- // tool. Read with `get_r2_cors`; write with `cloudflare_api_mutate` at
1840
- // PUT/DELETE /accounts/{accountId}/r2/buckets/{bucketName}/cors.
1841
- cfTool("get_r2_cors", "Get the browser CORS rules configured on an R2 bucket.", readOnly, "accountId", scope.accountId, {
1842
- bucketName: R2_BUCKET_NAME_PROPERTY,
1843
- jurisdiction: R2_JURISDICTION_PROPERTY,
1844
- }, ["bucketName"], R2_CORS_SCHEMA, getResult(send, (args) => ({
1845
- method: "GET",
1846
- path: `/accounts/${encodeURIComponent(accountArg(args))}/r2/buckets/${encodeURIComponent(requireString(args, "bucketName"))}/cors`,
1847
- headers: r2Headers(args),
1848
- }), projectR2Cors)),
1849
- cfTool("list_pages_projects", "List Cloudflare Pages projects in an account, with their production branch and latest deployment.", readOnly, "accountId", scope.accountId, {
1850
- ...pagingInputProperties(1, 100, { bounds: "undocumented" }),
1851
- raw: RAW_INPUT_PROPERTY,
1852
- }, [], listOutputSchema("projects", PAGES_PROJECT_SCHEMA), async (args, ctx) => {
1853
- const { result, resultInfo } = await callCloudflare(send, {
1854
- method: "GET",
1855
- path: `/accounts/${encodeURIComponent(accountArg(args))}/pages/projects`,
1856
- query: {
1857
- page: optionalNumber(args, "page"),
1858
- per_page: optionalNumber(args, "perPage"),
1859
- },
1860
- }, ctx);
1861
- return pagedList("projects", projectPagesProject)(result, resultInfo, args["raw"] === true);
1862
- }),
1863
- cfTool("get_pages_project", "Get one Pages project, including build configuration, deployment configuration, domains, and latest deployment.", readOnly, "accountId", scope.accountId, {
1864
- projectName: PROJECT_NAME_PROPERTY,
1865
- raw: RAW_INPUT_PROPERTY,
1866
- }, ["projectName"], PAGES_PROJECT_SCHEMA, getResult(send, (args) => ({
1867
- method: "GET",
1868
- path: `/accounts/${encodeURIComponent(accountArg(args))}/pages/projects/${encodeURIComponent(requireString(args, "projectName"))}`,
1869
- }), (result, args) => (args["raw"] === true ? result : projectPagesProject(result)))),
1870
- cfTool("list_pages_deployments", "List production and preview deployments for a Pages project.", readOnly, "accountId", scope.accountId, {
1871
- projectName: PROJECT_NAME_PROPERTY,
1872
- env: {
1873
- type: "string",
1874
- enum: ["production", "preview"],
1875
- description: "Optional deployment environment filter.",
1876
- },
1877
- ...pagingInputProperties(1, 100, { bounds: "undocumented" }),
1878
- }, ["projectName"], listOutputSchema("deployments", PAGES_DEPLOYMENT_SCHEMA), async (args, ctx) => {
1879
- const { result, resultInfo } = await callCloudflare(send, {
1880
- method: "GET",
1881
- path: `/accounts/${encodeURIComponent(accountArg(args))}/pages/projects/${encodeURIComponent(requireString(args, "projectName"))}/deployments`,
1882
- query: {
1883
- env: optionalString(args, "env"),
1884
- page: optionalNumber(args, "page"),
1885
- per_page: optionalNumber(args, "perPage"),
1886
- },
1887
- }, ctx);
1888
- return {
1889
- deployments: asArray(result).map(projectPagesDeployment),
1890
- page: pageInfo(resultInfo),
1891
- };
1892
- }),
1893
- cfTool("get_pages_deployment", "Get one Pages deployment including its environment, URLs, stages, source, and build configuration.", readOnly, "accountId", scope.accountId, {
1894
- projectName: PROJECT_NAME_PROPERTY,
1895
- deploymentId: DEPLOYMENT_ID_PROPERTY,
1896
- raw: RAW_INPUT_PROPERTY,
1897
- }, ["projectName", "deploymentId"], PAGES_DEPLOYMENT_SCHEMA, getResult(send, (args) => ({
1898
- method: "GET",
1899
- path: `/accounts/${encodeURIComponent(accountArg(args))}/pages/projects/${encodeURIComponent(requireString(args, "projectName"))}/deployments/${encodeURIComponent(requireString(args, "deploymentId"))}`,
1900
- }), (result, args) => (args["raw"] === true ? result : projectPagesDeployment(result)))),
1901
- cfTool("list_pages_domains", "List custom domains attached to a Pages project and their validation status.", readOnly, "accountId", scope.accountId, {
1902
- projectName: PAGES_PROJECT_NAME_PROPERTY,
1903
- }, ["projectName"], listOutputSchema("domains", PAGES_DOMAIN_SCHEMA), async (args, ctx) => {
1904
- const { result } = await callCloudflare(send, {
1905
- method: "GET",
1906
- path: `/accounts/${encodeURIComponent(accountArg(args))}/pages/projects/${encodeURIComponent(requireString(args, "projectName"))}/domains`,
1907
- }, ctx);
1908
- return { domains: asArray(result).map(projectPagesDomain) };
1909
- }),
1910
- ];
1911
- return tools;
1912
- }
1913
- function apiUsageGuide(purpose, scope, instructions, authentication) {
1914
- const accountInstructions = instructions?.trim();
1915
- const zoneLine = scope.zoneId
1916
- ? `This connector defaults to zone \`${scope.zoneId}\`; omit \`zoneId\` unless the request names a different domain.`
1917
- : "This connector declares no default zone. Start with `list_zones` (filter by `name`) and carry the returned `id` into every zone-scoped call.";
1918
- const accountLine = scope.accountId
1919
- ? `It defaults to account \`${scope.accountId}\`; omit \`accountId\` unless the request names a different account.`
1920
- : "It declares no default account. `list_accounts` supplies the `accountId` the Workers, KV, R2, and Pages tools need.";
1921
- const authenticationLine = authentication === "apiToken"
1922
- ? "The API token is operator-managed and scoped by permission. An `auth_required` failure means the token is missing or invalid. A `provider_permission_denied` failure asks an administrator to grant resource permission and token scope. Call `verify_api_token` first."
1923
- : "The Global API Key and account email are operator-managed. The key has the same access as its Cloudflare user. An `auth_required` failure means one field is missing or the pair is invalid. A `provider_permission_denied` failure asks an administrator to grant the user access. Call `verify_global_api_key` first.";
1924
- return `# Cloudflare usage
1925
-
1926
- Account purpose: ${purpose}
1927
-
1928
- - ${zoneLine}
1929
- - ${accountLine}${skill.fragments.guide_0}${authenticationLine}${skill.fragments.guide_1}${accountInstructions ? `\n## ${skill.instructionsHeading}\n\n${accountInstructions}\n` : ""}`;
239
+ tools: [...restTools(rest.vendor), ...rest.tools],
240
+ });
241
+ };
1930
242
  }
1931
243
  /**
1932
- * Reviewed in #705's provider audit against https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/.
1933
- * Retains the release-reviewed inventory, including names absent from today's
1934
- * public reference. Live annotations were not reverified without credentials.
1935
- * No schema digest is asserted without a captured schema to review.
244
+ * A maintained Cloudflare connection. `auth` selects the implementation:
245
+ * OAuth reaches Cloudflare's hosted MCP server; an API token or a Global API
246
+ * Key reaches Connecta's REST connector to the v4 API.
1936
247
  */
1937
- const CLOUDFLARE_MCP_CLASSIFICATION = {
1938
- tools: {
1939
- "search": {
1940
- "verdict": "read",
1941
- "reason": "Searches the Cloudflare OpenAPI contract without executing API methods.",
1942
- },
1943
- "execute": {
1944
- "verdict": "destructive",
1945
- "reason": "Can mix HTTP methods across the Cloudflare API; no input schema proves a program only reads.",
1946
- },
1947
- },
1948
- };
1949
- function mcpUsageGuide(purpose, instructions) {
1950
- const accountInstructions = instructions?.trim();
1951
- return `# Cloudflare MCP usage
1952
-
1953
- Official whole-API MCP interface: ${purpose}${skill.fragments.guide_2}${accountInstructions ? `\n## ${skill.instructionsHeading}\n\n${accountInstructions}\n` : ""}`;
1954
- }
1955
- function cloudflareMcp(id, purpose, options, provider) {
1956
- const connector = remoteMcp(id, {
1957
- url: CLOUDFLARE_MCP_ENDPOINT,
1958
- ...provider.connectorOptions,
1959
- title: options.title ?? "Cloudflare (MCP)",
1960
- description: `Cloudflare's official whole-API MCP interface: ${purpose}`,
1961
- auth: withCredentialDefaults(options.auth ?? { type: "oauth" }, {
1962
- credential: {
1963
- label: "Cloudflare API token",
1964
- description: "A scoped Cloudflare API token. Connecta sends it as a bearer token to mcp.cloudflare.com and stores it encrypted.",
1965
- placeholder: "Paste Cloudflare API token",
1966
- },
1967
- }),
1968
- requireHttps: true,
1969
- classify: provider.classify,
1970
- usageGuide: {
1971
- content: mcpUsageGuide(purpose, options.instructions),
1972
- summary: "Official whole-API MCP. Search the OpenAPI document; execute programs always classify as writes.",
1973
- required: true,
1974
- },
1975
- });
1976
- return connector;
1977
- }
1978
- function cloudflareApi(id, purpose, options) {
1979
- const maxConcurrency = options.maxConcurrency ?? 6;
1980
- if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
1981
- throw new Error("cloudflare() maxConcurrency must be a positive integer.");
1982
- }
1983
- const authentication = options.authentication ?? "apiToken";
1984
- if (authentication !== "apiToken" && authentication !== "globalApiKey") {
1985
- throw new Error('cloudflare() authentication must be "apiToken" or "globalApiKey".');
1986
- }
1987
- const scope = {
1988
- send: cloudflareTransport(options.baseUrl?.trim() || CLOUDFLARE_API_BASE, authentication),
1989
- accountId: options.accountId?.trim() || undefined,
1990
- zoneId: options.zoneId?.trim() || undefined,
1991
- };
1992
- return api(id, {
1993
- ...(options.authScope ? { authScope: options.authScope } : {}),
1994
- title: options.title ?? "Cloudflare",
1995
- description: `Cloudflare control-plane access for zones, DNS, Workers, KV, R2, Pages, media, email, and other v4 APIs — ${purpose}`,
1996
- credential: credentialConfig(authentication, options.credential),
1997
- callAdmission: admissionPolicy(maxConcurrency),
1998
- usageGuide: {
1999
- content: apiUsageGuide(purpose, scope, options.instructions, authentication),
2000
- // Explicit rather than derived: the first content line is the zone
2001
- // scoping rule, which varies per deployment and reads as an instruction
2002
- // rather than as the routing fact a browsing agent needs.
2003
- summary: "Zone and account scoping, named-vs-raw routing, two pagination shapes, and lean-vs-raw results.",
2004
- // Deliberately not `required`. Every named tool's schema is complete
2005
- // enough to call it correctly on its own, and the scoping convention the
2006
- // guide carries is repeated on each `zoneId` and `accountId` property —
2007
- // so forcing the guide into context before every operation would spend
2008
- // tokens on a sequence the schemas already express.
2009
- },
2010
- ...(options.maxResultBytes !== undefined ? { maxResultBytes: options.maxResultBytes } : {}),
2011
- tools: buildTools(scope, authentication),
2012
- ...(authentication === "apiToken"
2013
- ? {
2014
- async testCredential(value, ctx) {
2015
- return await testCloudflareCredential(scope.send, { method: "GET", path: "/user/tokens/verify" }, {
2016
- ...ctx,
2017
- credential: {
2018
- get: async () => value,
2019
- getAll: async () => ({ value }),
2020
- },
2021
- }, (result) => {
2022
- const status = asRecord(result)["status"];
2023
- return status === "active"
2024
- ? { ok: true, message: "Token verified: active." }
2025
- : { ok: false, message: `Token status is "${String(status)}".` };
2026
- });
2027
- },
2028
- }
2029
- : {
2030
- async testCredentials(values, ctx) {
2031
- return await testCloudflareCredential(scope.send, { method: "GET", path: "/user" }, {
2032
- ...ctx,
2033
- credential: {
2034
- get: async (field) => (field ? (values[field] ?? null) : null),
2035
- getAll: async () => values,
2036
- },
2037
- }, (result) => ({
2038
- ok: true,
2039
- message: `Global API Key verified for ${String(asRecord(result)["email"])}.`,
2040
- }));
2041
- },
2042
- }),
2043
- });
2044
- }
2045
- /** The closed options cloudflare() accepts; see `assertKnownOptions`. */
2046
- const CLOUDFLARE_OPTIONS = variants("surface", {
2047
- api: optionsOf()({
2048
- ...keys("title", "authScope", "purpose", "instructions", "maxResultBytes"),
2049
- ...keys("surface", "accountId", "zoneId", "baseUrl", "authentication", "maxConcurrency"),
2050
- credential: CREDENTIAL,
2051
- }).shape,
2052
- mcp: optionsOf()({
2053
- ...PROVIDER_COMMON,
2054
- ...keys("surface"),
2055
- auth: REMOTE_MCP_AUTH,
2056
- }).shape,
2057
- }, "mcp");
2058
- /** A maintained Cloudflare connection using the selected provider interface. */
2059
248
  export const cloudflare = defineProvider({
2060
249
  name: "cloudflare",
2061
250
  title: "Cloudflare",
2062
- kind: "composed",
251
+ kind: "dual",
2063
252
  readme: "Cloudflare",
2064
- bundle: { "baselineGzip": 151186, "maxGzip": 211186 },
253
+ bundle: {
254
+ baselineGzip: 334055,
255
+ maxGzip: 394055,
256
+ note: "./providers/cloudflare remeasures at 334,055 B gzip (#801), from 151,186 B on main: auth now selects the implementation, so the entry carries the hosted MCP client and Connecta's REST connector (api(), the schema validator, the shared REST module, about 22 KB gzip as in ./providers/stripe) plus the pinned Cloudflare operation index: 3,336 non-deprecated operations from a 27 MB document. The index was shrunk before the cap moved, from 157 KB to 125 KB gzip as source: request details to depth 2 without descriptions or enums over 50 values, no operation ids (they restate summaries), no plain-string path parameters (the template names them), and a 6,000-character per-operation budget that lowers one sprawling operation's depth. Depth 1 would save about 18 KB more at the cost of nested validation such as DNS record alternatives. Removing 23 hand-written reads offsets part of the growth; the reviewed value-safety table adds about 12 KB. The value-safety consolidation (#801) moved the spec-derived candidate record out of the index and grew the table by 259 verdicts, a net +3,646 B (337,701 B), inside the cap. The cap uses the existing baseline + 60,000 B policy.",
257
+ },
2065
258
  skill,
2066
259
  options: CLOUDFLARE_OPTIONS,
2067
260
  classify: CLOUDFLARE_MCP_CLASSIFICATION,
2068
- create: cloudflareConnector,
261
+ create: byAuth({
262
+ oauth: cloudflareOAuth,
263
+ apiToken: cloudflareKey("apiToken"),
264
+ globalApiKey: cloudflareKey("globalApiKey"),
265
+ }),
2069
266
  });
2070
- function cloudflareConnector(id, options, provider) {
2071
- const purpose = options.purpose.trim();
2072
- return options.surface === "api"
2073
- ? cloudflareApi(id, purpose, options)
2074
- : cloudflareMcp(id, purpose, options, provider);
2075
- }
2076
267
  /** @deprecated Read `cloudflare.definition.classify` instead. Kept for existing imports. */
2077
268
  export const CLOUDFLARE_MCP_VETTED_CATALOG = reviewedCatalog(cloudflare.definition.classify, 'defineProvider("cloudflare")');