@zackbart/connecta 0.12.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +324 -0
- package/README.md +4 -1
- package/dist/catalog-service.d.ts +41 -0
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +127 -11
- package/dist/catalog-service.js.map +1 -1
- package/dist/connectors/api.d.ts +5 -4
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +5 -1
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +5 -4
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +12 -10
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +5 -4
- package/dist/meta-tools.js.map +1 -1
- package/dist/providers/cloudflare.d.ts +53 -0
- package/dist/providers/cloudflare.d.ts.map +1 -0
- package/dist/providers/cloudflare.js +1385 -0
- package/dist/providers/cloudflare.js.map +1 -0
- package/dist/providers/linear.d.ts +44 -0
- package/dist/providers/linear.d.ts.map +1 -0
- package/dist/providers/linear.js +243 -0
- package/dist/providers/linear.js.map +1 -0
- package/dist/providers/mixpanel.d.ts +21 -0
- package/dist/providers/mixpanel.d.ts.map +1 -0
- package/dist/providers/mixpanel.js +191 -0
- package/dist/providers/mixpanel.js.map +1 -0
- package/dist/providers/notion.d.ts +39 -0
- package/dist/providers/notion.d.ts.map +1 -0
- package/dist/providers/notion.js +1625 -0
- package/dist/providers/notion.js.map +1 -0
- package/dist/providers/stripe.d.ts +37 -0
- package/dist/providers/stripe.d.ts.map +1 -0
- package/dist/providers/stripe.js +232 -0
- package/dist/providers/stripe.js.map +1 -0
- package/dist/skills.d.ts +7 -9
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +58 -24
- package/dist/skills.js.map +1 -1
- package/dist/types.d.ts +26 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/cloudflare.md +268 -0
- package/documentation/code-mode.md +6 -6
- package/documentation/connectors.md +122 -4
- package/documentation/linear.md +144 -0
- package/documentation/meta-tools.md +91 -8
- package/documentation/mixpanel.md +77 -0
- package/documentation/notion.md +233 -0
- package/documentation/stripe.md +202 -0
- package/ethos.md +8 -3
- package/package.json +21 -1
- package/src/catalog-service.ts +174 -10
- package/src/connectors/api.ts +10 -4
- package/src/connectors/remote-mcp.ts +5 -3
- package/src/execute.ts +18 -10
- package/src/index.ts +1 -0
- package/src/meta-tools.ts +10 -4
- package/src/providers/cloudflare.ts +1696 -0
- package/src/providers/linear.ts +301 -0
- package/src/providers/mixpanel.ts +228 -0
- package/src/providers/notion.ts +1879 -0
- package/src/providers/stripe.ts +306 -0
- package/src/skills.ts +64 -23
- package/src/types.ts +27 -6
- package/src/version.ts +1 -1
- package/templates/node/package.json +1 -1
|
@@ -0,0 +1,1696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare's REST API as a deliberate, hand-written tool surface.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately SDK-free. The `cloudflare` npm package is a generated client
|
|
5
|
+
* whose value is typed wrappers and pagination helpers — both of which this
|
|
6
|
+
* connection re-projects anyway, because an agent needs a lean result shape,
|
|
7
|
+
* not Cloudflare's full response object. Every call here is `fetch` against
|
|
8
|
+
* documented paths with a Bearer token, which keeps the provider Workers-clean,
|
|
9
|
+
* adds no dependency (optional peer or otherwise), and leaves the published
|
|
10
|
+
* surface exactly where `ethos.md` puts it.
|
|
11
|
+
*
|
|
12
|
+
* The tools are hand-written rather than generated because a generated wrapper
|
|
13
|
+
* is what motivated this file: a compact schema that says `arguments?: {}[]`
|
|
14
|
+
* forces an agent to read operation documentation before it can call anything.
|
|
15
|
+
* Every tool below therefore carries a complete input schema, an accurate
|
|
16
|
+
* required-key list, and a declared output shape.
|
|
17
|
+
*/
|
|
18
|
+
import { api, type ApiTool } from "../connectors/api.js";
|
|
19
|
+
import { ConnectorCallError } from "../errors.js";
|
|
20
|
+
import type {
|
|
21
|
+
Connector,
|
|
22
|
+
ConnectorCallAdmissionPolicy,
|
|
23
|
+
ConnectorContext,
|
|
24
|
+
ConnectorCredentialConfig,
|
|
25
|
+
JsonSchema,
|
|
26
|
+
} from "../types.js";
|
|
27
|
+
|
|
28
|
+
/** Cloudflare's v4 REST base. Override only for a proxy or a test double. */
|
|
29
|
+
export const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every DNS record type the records API accepts, for filtering a list.
|
|
33
|
+
* Enumerated in the schema so an agent picks a legal type without reading
|
|
34
|
+
* Cloudflare's documentation.
|
|
35
|
+
*/
|
|
36
|
+
export const CLOUDFLARE_DNS_RECORD_TYPES = [
|
|
37
|
+
"A",
|
|
38
|
+
"AAAA",
|
|
39
|
+
"CAA",
|
|
40
|
+
"CERT",
|
|
41
|
+
"CNAME",
|
|
42
|
+
"DNSKEY",
|
|
43
|
+
"DS",
|
|
44
|
+
"HTTPS",
|
|
45
|
+
"LOC",
|
|
46
|
+
"MX",
|
|
47
|
+
"NAPTR",
|
|
48
|
+
"NS",
|
|
49
|
+
"OPENPGPKEY",
|
|
50
|
+
"PTR",
|
|
51
|
+
"SMIMEA",
|
|
52
|
+
"SRV",
|
|
53
|
+
"SSHFP",
|
|
54
|
+
"SVCB",
|
|
55
|
+
"TLSA",
|
|
56
|
+
"TXT",
|
|
57
|
+
"URI",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The record types whose value is a single `content` string — the eight this
|
|
62
|
+
* connection can create and update.
|
|
63
|
+
*
|
|
64
|
+
* The other thirteen (CAA, CERT, DNSKEY, DS, HTTPS, LOC, NAPTR, SMIMEA, SRV,
|
|
65
|
+
* SSHFP, SVCB, TLSA, URI) carry a per-type structured `data` object instead,
|
|
66
|
+
* each with its own field set. Accepting them here would mean either a
|
|
67
|
+
* free-form `data` passthrough — exactly the untyped `{}` this connection
|
|
68
|
+
* exists to avoid — or thirteen more hand-written schemas for record types
|
|
69
|
+
* that are rare in the day-to-day work this surface is for. They remain fully
|
|
70
|
+
* readable and filterable; only creating and updating them is out of scope.
|
|
71
|
+
*/
|
|
72
|
+
export const CLOUDFLARE_CONTENT_DNS_RECORD_TYPES = [
|
|
73
|
+
"A",
|
|
74
|
+
"AAAA",
|
|
75
|
+
"CNAME",
|
|
76
|
+
"MX",
|
|
77
|
+
"NS",
|
|
78
|
+
"OPENPGPKEY",
|
|
79
|
+
"PTR",
|
|
80
|
+
"TXT",
|
|
81
|
+
] as const;
|
|
82
|
+
|
|
83
|
+
export interface CloudflareOptions {
|
|
84
|
+
/** Human-readable display name; defaults to "Cloudflare". */
|
|
85
|
+
title?: string;
|
|
86
|
+
/** Which account/estate this connection administers, and for whom. */
|
|
87
|
+
purpose: string;
|
|
88
|
+
/**
|
|
89
|
+
* Default account id for account-scoped tools. When set, `accountId` becomes
|
|
90
|
+
* an optional argument; when omitted, agents must pass one and can find it
|
|
91
|
+
* with `list_accounts`.
|
|
92
|
+
*/
|
|
93
|
+
accountId?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Default zone id for zone-scoped tools. When set, `zoneId` becomes an
|
|
96
|
+
* optional argument; when omitted, agents must pass one and can find it with
|
|
97
|
+
* `list_zones`.
|
|
98
|
+
*/
|
|
99
|
+
zoneId?: string;
|
|
100
|
+
/** API base override for a proxy or a test double. Defaults to the v4 API. */
|
|
101
|
+
baseUrl?: string;
|
|
102
|
+
/** Credential presentation override; the token is always operator-managed. */
|
|
103
|
+
credential?: ConnectorCredentialConfig;
|
|
104
|
+
/** Account-specific conventions appended to the maintained provider guide. */
|
|
105
|
+
instructions?: string;
|
|
106
|
+
/** Connector-specific inline result limit; omit to inherit the deployment. */
|
|
107
|
+
maxResultBytes?: number;
|
|
108
|
+
/** Simultaneous downstream calls. Defaults to 6. */
|
|
109
|
+
maxConcurrency?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Cloudflare documents a global limit of 1,200 requests per five minutes per
|
|
114
|
+
* user, counted cumulatively across the dashboard, API keys, and API tokens.
|
|
115
|
+
* The budget mirrors that window; `maxConcurrency` is the part that actually
|
|
116
|
+
* protects a shared token, because a single `execute_code` program can fan out
|
|
117
|
+
* far faster than the window notices.
|
|
118
|
+
*/
|
|
119
|
+
function admissionPolicy(maxConcurrency: number): ConnectorCallAdmissionPolicy {
|
|
120
|
+
return {
|
|
121
|
+
rules: [
|
|
122
|
+
{
|
|
123
|
+
maxConcurrency,
|
|
124
|
+
budget: {
|
|
125
|
+
kind: "rolling-window",
|
|
126
|
+
maxCalls: 1200,
|
|
127
|
+
windowMs: 300_000,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const DEFAULT_CREDENTIAL: ConnectorCredentialConfig = {
|
|
135
|
+
label: "Cloudflare API token",
|
|
136
|
+
description:
|
|
137
|
+
"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\" and \"DNS Write\" for DNS work and \"Cache Purge\" for purges; account-scoped \"Workers Scripts Read\", \"Workers KV Storage Read\", \"Workers R2 Storage Read\", or \"Cloudflare Pages Read\" for the platform reads.",
|
|
138
|
+
placeholder: "Paste API token",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// --- Cloudflare's response envelope -----------------------------------------
|
|
142
|
+
|
|
143
|
+
interface CloudflareEnvelopeError {
|
|
144
|
+
code?: number;
|
|
145
|
+
message?: string;
|
|
146
|
+
error_chain?: CloudflareEnvelopeError[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface CloudflareResultInfo {
|
|
150
|
+
page?: number;
|
|
151
|
+
per_page?: number;
|
|
152
|
+
count?: number;
|
|
153
|
+
total_count?: number;
|
|
154
|
+
total_pages?: number;
|
|
155
|
+
/** Cursor-paginated endpoints (R2 buckets, KV keys) report this instead. */
|
|
156
|
+
cursor?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface CloudflareEnvelope {
|
|
160
|
+
success?: boolean;
|
|
161
|
+
errors?: CloudflareEnvelopeError[];
|
|
162
|
+
messages?: unknown[];
|
|
163
|
+
result?: unknown;
|
|
164
|
+
result_info?: CloudflareResultInfo;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
type JsonRecord = Record<string, unknown>;
|
|
168
|
+
|
|
169
|
+
function asRecord(value: unknown): JsonRecord {
|
|
170
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
171
|
+
? (value as JsonRecord)
|
|
172
|
+
: {};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function asArray(value: unknown): unknown[] {
|
|
176
|
+
return Array.isArray(value) ? value : [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Flatten Cloudflare's error array (and any nested chain) into one line. */
|
|
180
|
+
function describeErrors(errors: CloudflareEnvelopeError[]): string {
|
|
181
|
+
const parts: string[] = [];
|
|
182
|
+
const walk = (list: CloudflareEnvelopeError[]): void => {
|
|
183
|
+
for (const entry of list) {
|
|
184
|
+
const code = typeof entry.code === "number" ? entry.code : undefined;
|
|
185
|
+
const message =
|
|
186
|
+
typeof entry.message === "string" ? entry.message : "Unknown error";
|
|
187
|
+
parts.push(code === undefined ? message : `${code}: ${message}`);
|
|
188
|
+
if (Array.isArray(entry.error_chain)) walk(entry.error_chain);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
walk(errors);
|
|
192
|
+
return parts.length > 0 ? parts.join("; ") : "Cloudflare reported no detail.";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function errorCodes(errors: CloudflareEnvelopeError[]): Set<number> {
|
|
196
|
+
const codes = new Set<number>();
|
|
197
|
+
const walk = (list: CloudflareEnvelopeError[]): void => {
|
|
198
|
+
for (const entry of list) {
|
|
199
|
+
if (typeof entry.code === "number") codes.add(entry.code);
|
|
200
|
+
if (Array.isArray(entry.error_chain)) walk(entry.error_chain);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
walk(errors);
|
|
204
|
+
return codes;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Credential-shaped Cloudflare error codes that are *not* already implied by a
|
|
209
|
+
* 401 or 403: a missing or malformed `Authorization` header, and the legacy
|
|
210
|
+
* key/email headers. These arrive on HTTP 400, so status alone would misfile
|
|
211
|
+
* them as an argument problem the agent could repair.
|
|
212
|
+
*
|
|
213
|
+
* Provenance matters here: Cloudflare publishes no official code table, so this
|
|
214
|
+
* set comes from community reports and observed responses rather than
|
|
215
|
+
* documentation. It is a narrow, well-attested list, not an authoritative one —
|
|
216
|
+
* which is why status is the primary signal and these six only rescue the 400s
|
|
217
|
+
* status gets wrong.
|
|
218
|
+
*
|
|
219
|
+
* Deliberately excludes 10000. Cloudflare returns 10000 for "Authentication
|
|
220
|
+
* error" but has also been observed reusing it as a generic validation code
|
|
221
|
+
* ("domain_name is required", "Invalid pagination cursor"), so routing on it
|
|
222
|
+
* would risk telling an agent its token was broken when its arguments were.
|
|
223
|
+
* Genuine 10000 auth failures arrive with 401 or 403 and are caught by status.
|
|
224
|
+
*
|
|
225
|
+
* All of these route to `auth_required`, whose recovery mode resolves to
|
|
226
|
+
* `operator_config` because this connection declares an operator-managed
|
|
227
|
+
* credential rather than an OAuth flow.
|
|
228
|
+
*/
|
|
229
|
+
const AUTH_ERROR_CODES = new Set([1001, 6003, 6111, 9103, 9106, 9107]);
|
|
230
|
+
|
|
231
|
+
/** Seconds in a `retry-after` header, converted to the milliseconds the core wants. */
|
|
232
|
+
function retryAfterMs(headers: Headers): number | undefined {
|
|
233
|
+
const raw = headers.get("retry-after");
|
|
234
|
+
if (!raw) return undefined;
|
|
235
|
+
const seconds = Number(raw.trim());
|
|
236
|
+
if (!Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
237
|
+
return Math.trunc(seconds * 1000);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Turn a failed Cloudflare response into a typed connector failure.
|
|
242
|
+
*
|
|
243
|
+
* Status is the primary signal and the error codes refine it, because
|
|
244
|
+
* Cloudflare returns 403 for both "this token is invalid" and "this token
|
|
245
|
+
* cannot do that" — an agent needs to stop retrying either way, and the
|
|
246
|
+
* operator needs to know the token is the thing to fix.
|
|
247
|
+
*/
|
|
248
|
+
function failureFor(
|
|
249
|
+
status: number,
|
|
250
|
+
headers: Headers,
|
|
251
|
+
errors: CloudflareEnvelopeError[],
|
|
252
|
+
): ConnectorCallError {
|
|
253
|
+
const detail = describeErrors(errors);
|
|
254
|
+
const codes = errorCodes(errors);
|
|
255
|
+
// 429 is checked before the auth codes on purpose: Cloudflare reuses the
|
|
256
|
+
// generic 10000 code on throttled responses too, and reading a rate limit as
|
|
257
|
+
// an auth failure would tell an agent to stop when it should wait.
|
|
258
|
+
if (status === 429) {
|
|
259
|
+
const wait = retryAfterMs(headers);
|
|
260
|
+
return new ConnectorCallError(
|
|
261
|
+
"rate_limited",
|
|
262
|
+
`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.`,
|
|
263
|
+
// Cloudflare blocks the remainder of the five-minute window when the
|
|
264
|
+
// global limit trips, so the honest fallback is the whole window.
|
|
265
|
+
{ retryAfterMs: wait ?? 300_000 },
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const authCoded = [...codes].some((code) => AUTH_ERROR_CODES.has(code));
|
|
269
|
+
if (status === 401 || status === 403 || authCoded) {
|
|
270
|
+
return new ConnectorCallError(
|
|
271
|
+
"auth_required",
|
|
272
|
+
`Cloudflare rejected the API token (HTTP ${status}). ${detail} Check that the token is valid and carries the permission this call needs.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (status === 400 || status === 409 || status === 422) {
|
|
276
|
+
return new ConnectorCallError(
|
|
277
|
+
"invalid_args",
|
|
278
|
+
`Cloudflare rejected the request (HTTP ${status}). ${detail}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (status === 404) {
|
|
282
|
+
return new ConnectorCallError(
|
|
283
|
+
"connector_call_failed",
|
|
284
|
+
`Cloudflare found no such resource (HTTP 404). ${detail} Confirm the zone or account id with list_zones or list_accounts.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (status >= 500) {
|
|
288
|
+
return new ConnectorCallError(
|
|
289
|
+
"unavailable",
|
|
290
|
+
`Cloudflare is unavailable (HTTP ${status}). ${detail}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return new ConnectorCallError(
|
|
294
|
+
"connector_call_failed",
|
|
295
|
+
`Cloudflare request failed (HTTP ${status}). ${detail}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- The request path --------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
interface RequestSpec {
|
|
302
|
+
method: "GET" | "POST" | "PATCH" | "DELETE";
|
|
303
|
+
path: string;
|
|
304
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
305
|
+
body?: unknown;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
interface CloudflareResponse {
|
|
309
|
+
result: unknown;
|
|
310
|
+
resultInfo: CloudflareResultInfo | undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function readToken(ctx: ConnectorContext): Promise<string> {
|
|
314
|
+
const token = await ctx.credential?.get();
|
|
315
|
+
if (!token) {
|
|
316
|
+
throw new ConnectorCallError(
|
|
317
|
+
"auth_required",
|
|
318
|
+
"No Cloudflare API token is configured for this connector. An operator must add one before any call can run.",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return token;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function buildUrl(base: string, spec: RequestSpec): string {
|
|
325
|
+
const url = new URL(`${base.replace(/\/+$/, "")}${spec.path}`);
|
|
326
|
+
for (const [key, value] of Object.entries(spec.query ?? {})) {
|
|
327
|
+
if (value === undefined) continue;
|
|
328
|
+
url.searchParams.set(key, String(value));
|
|
329
|
+
}
|
|
330
|
+
return url.toString();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function callCloudflare(
|
|
334
|
+
base: string,
|
|
335
|
+
spec: RequestSpec,
|
|
336
|
+
ctx: ConnectorContext,
|
|
337
|
+
): Promise<CloudflareResponse> {
|
|
338
|
+
const token = await readToken(ctx);
|
|
339
|
+
let response: Response;
|
|
340
|
+
try {
|
|
341
|
+
response = await fetch(buildUrl(base, spec), {
|
|
342
|
+
method: spec.method,
|
|
343
|
+
headers: {
|
|
344
|
+
Authorization: `Bearer ${token}`,
|
|
345
|
+
Accept: "application/json",
|
|
346
|
+
...(spec.body !== undefined
|
|
347
|
+
? { "Content-Type": "application/json" }
|
|
348
|
+
: {}),
|
|
349
|
+
},
|
|
350
|
+
...(spec.body !== undefined
|
|
351
|
+
? { body: JSON.stringify(spec.body) }
|
|
352
|
+
: {}),
|
|
353
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
354
|
+
});
|
|
355
|
+
} catch (cause) {
|
|
356
|
+
throw new ConnectorCallError(
|
|
357
|
+
"unavailable",
|
|
358
|
+
`Could not reach the Cloudflare API: ${
|
|
359
|
+
cause instanceof Error ? cause.message : String(cause)
|
|
360
|
+
}`,
|
|
361
|
+
{ cause },
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let envelope: CloudflareEnvelope;
|
|
366
|
+
try {
|
|
367
|
+
envelope = (await response.json()) as CloudflareEnvelope;
|
|
368
|
+
} catch (cause) {
|
|
369
|
+
// A gateway error page, not JSON: the status is the only real signal left.
|
|
370
|
+
throw response.ok
|
|
371
|
+
? new ConnectorCallError(
|
|
372
|
+
"unavailable",
|
|
373
|
+
"Cloudflare returned a non-JSON body for a successful status.",
|
|
374
|
+
{ cause },
|
|
375
|
+
)
|
|
376
|
+
: failureFor(response.status, response.headers, []);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const errors = Array.isArray(envelope.errors) ? envelope.errors : [];
|
|
380
|
+
if (!response.ok || envelope.success === false) {
|
|
381
|
+
throw failureFor(response.status, response.headers, errors);
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
result: envelope.result,
|
|
385
|
+
resultInfo: envelope.result_info,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// --- Projections -------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
interface PageInfo {
|
|
392
|
+
page: number;
|
|
393
|
+
perPage: number;
|
|
394
|
+
count: number;
|
|
395
|
+
totalCount?: number;
|
|
396
|
+
totalPages?: number;
|
|
397
|
+
hasMore: boolean;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Cloudflare's `result_info` reshaped into the one question an agent actually
|
|
402
|
+
* asks — is there another page? — with the raw counters kept alongside it.
|
|
403
|
+
*/
|
|
404
|
+
function pageInfo(info: CloudflareResultInfo | undefined): PageInfo | undefined {
|
|
405
|
+
if (!info) return undefined;
|
|
406
|
+
// A cursor-only result_info carries no page counters; inventing them would
|
|
407
|
+
// report `hasMore: false` on a listing that has more.
|
|
408
|
+
if (
|
|
409
|
+
info.page === undefined &&
|
|
410
|
+
info.total_pages === undefined &&
|
|
411
|
+
info.count === undefined
|
|
412
|
+
) {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
const page = typeof info.page === "number" ? info.page : 1;
|
|
416
|
+
const totalPages =
|
|
417
|
+
typeof info.total_pages === "number" ? info.total_pages : undefined;
|
|
418
|
+
return {
|
|
419
|
+
page,
|
|
420
|
+
perPage: typeof info.per_page === "number" ? info.per_page : 0,
|
|
421
|
+
count: typeof info.count === "number" ? info.count : 0,
|
|
422
|
+
...(typeof info.total_count === "number"
|
|
423
|
+
? { totalCount: info.total_count }
|
|
424
|
+
: {}),
|
|
425
|
+
...(totalPages !== undefined ? { totalPages } : {}),
|
|
426
|
+
hasMore: totalPages !== undefined ? page < totalPages : false,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function projectAccount(value: unknown): JsonRecord {
|
|
431
|
+
const account = asRecord(value);
|
|
432
|
+
return {
|
|
433
|
+
id: account["id"],
|
|
434
|
+
name: account["name"],
|
|
435
|
+
...(account["type"] !== undefined ? { type: account["type"] } : {}),
|
|
436
|
+
...(account["created_on"] !== undefined
|
|
437
|
+
? { createdOn: account["created_on"] }
|
|
438
|
+
: {}),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function projectZone(value: unknown): JsonRecord {
|
|
443
|
+
const zone = asRecord(value);
|
|
444
|
+
const account = asRecord(zone["account"]);
|
|
445
|
+
const plan = asRecord(zone["plan"]);
|
|
446
|
+
return {
|
|
447
|
+
id: zone["id"],
|
|
448
|
+
name: zone["name"],
|
|
449
|
+
status: zone["status"],
|
|
450
|
+
paused: zone["paused"],
|
|
451
|
+
type: zone["type"],
|
|
452
|
+
accountId: account["id"],
|
|
453
|
+
accountName: account["name"],
|
|
454
|
+
...(plan["name"] !== undefined ? { plan: plan["name"] } : {}),
|
|
455
|
+
...(Array.isArray(zone["name_servers"])
|
|
456
|
+
? { nameServers: zone["name_servers"] }
|
|
457
|
+
: {}),
|
|
458
|
+
createdOn: zone["created_on"],
|
|
459
|
+
modifiedOn: zone["modified_on"],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function projectDnsRecord(value: unknown): JsonRecord {
|
|
464
|
+
const record = asRecord(value);
|
|
465
|
+
return {
|
|
466
|
+
id: record["id"],
|
|
467
|
+
name: record["name"],
|
|
468
|
+
type: record["type"],
|
|
469
|
+
content: record["content"],
|
|
470
|
+
ttl: record["ttl"],
|
|
471
|
+
...(record["proxied"] !== undefined
|
|
472
|
+
? { proxied: record["proxied"] }
|
|
473
|
+
: {}),
|
|
474
|
+
...(record["priority"] !== undefined
|
|
475
|
+
? { priority: record["priority"] }
|
|
476
|
+
: {}),
|
|
477
|
+
...(record["comment"] ? { comment: record["comment"] } : {}),
|
|
478
|
+
...(Array.isArray(record["tags"]) && record["tags"].length > 0
|
|
479
|
+
? { tags: record["tags"] }
|
|
480
|
+
: {}),
|
|
481
|
+
createdOn: record["created_on"],
|
|
482
|
+
modifiedOn: record["modified_on"],
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function projectWorkerScript(value: unknown): JsonRecord {
|
|
487
|
+
const script = asRecord(value);
|
|
488
|
+
return {
|
|
489
|
+
id: script["id"],
|
|
490
|
+
createdOn: script["created_on"],
|
|
491
|
+
modifiedOn: script["modified_on"],
|
|
492
|
+
...(script["usage_model"] !== undefined
|
|
493
|
+
? { usageModel: script["usage_model"] }
|
|
494
|
+
: {}),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function projectKvNamespace(value: unknown): JsonRecord {
|
|
499
|
+
const namespace = asRecord(value);
|
|
500
|
+
return {
|
|
501
|
+
id: namespace["id"],
|
|
502
|
+
title: namespace["title"],
|
|
503
|
+
...(namespace["supports_url_encoding"] !== undefined
|
|
504
|
+
? { supportsUrlEncoding: namespace["supports_url_encoding"] }
|
|
505
|
+
: {}),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function projectR2Bucket(value: unknown): JsonRecord {
|
|
510
|
+
const bucket = asRecord(value);
|
|
511
|
+
return {
|
|
512
|
+
name: bucket["name"],
|
|
513
|
+
...(bucket["location"] !== undefined
|
|
514
|
+
? { location: bucket["location"] }
|
|
515
|
+
: {}),
|
|
516
|
+
...(bucket["storage_class"] !== undefined
|
|
517
|
+
? { storageClass: bucket["storage_class"] }
|
|
518
|
+
: {}),
|
|
519
|
+
...(bucket["creation_date"] !== undefined
|
|
520
|
+
? { creationDate: bucket["creation_date"] }
|
|
521
|
+
: {}),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function projectPagesProject(value: unknown): JsonRecord {
|
|
526
|
+
const project = asRecord(value);
|
|
527
|
+
const latest = asRecord(project["latest_deployment"]);
|
|
528
|
+
return {
|
|
529
|
+
name: project["name"],
|
|
530
|
+
subdomain: project["subdomain"],
|
|
531
|
+
...(Array.isArray(project["domains"])
|
|
532
|
+
? { domains: project["domains"] }
|
|
533
|
+
: {}),
|
|
534
|
+
...(project["production_branch"] !== undefined
|
|
535
|
+
? { productionBranch: project["production_branch"] }
|
|
536
|
+
: {}),
|
|
537
|
+
createdOn: project["created_on"],
|
|
538
|
+
...(latest["id"] !== undefined
|
|
539
|
+
? {
|
|
540
|
+
latestDeployment: {
|
|
541
|
+
id: latest["id"],
|
|
542
|
+
environment: latest["environment"],
|
|
543
|
+
url: latest["url"],
|
|
544
|
+
createdOn: latest["created_on"],
|
|
545
|
+
},
|
|
546
|
+
}
|
|
547
|
+
: {}),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// --- Schema fragments --------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
const PAGE_OUTPUT_SCHEMA: JsonSchema = {
|
|
554
|
+
type: "object",
|
|
555
|
+
description:
|
|
556
|
+
"Pagination counters from Cloudflare's result_info. Absent when the endpoint does not paginate.",
|
|
557
|
+
properties: {
|
|
558
|
+
page: { type: "integer" },
|
|
559
|
+
perPage: { type: "integer" },
|
|
560
|
+
count: { type: "integer", description: "Items on this page." },
|
|
561
|
+
totalCount: { type: "integer" },
|
|
562
|
+
totalPages: { type: "integer" },
|
|
563
|
+
hasMore: {
|
|
564
|
+
type: "boolean",
|
|
565
|
+
description: "True when a further page exists; request page + 1.",
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
required: ["page", "perPage", "count", "hasMore"],
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const RAW_INPUT_PROPERTY: JsonSchema = {
|
|
572
|
+
type: "boolean",
|
|
573
|
+
description:
|
|
574
|
+
"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.",
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Cloudflare's per-page bounds and default differ per endpoint and it rejects
|
|
579
|
+
* an out-of-range value with a 400, so each caller passes its own. Encoding
|
|
580
|
+
* them in the schema turns a wasted round trip into a local repair — but only
|
|
581
|
+
* where the bound is really Cloudflare's. `bounds` records who chose the
|
|
582
|
+
* range, because `strictValidation` refuses an out-of-range `perPage` locally
|
|
583
|
+
* and an agent deserves to know whether the wall it hit is the API's or ours:
|
|
584
|
+
*
|
|
585
|
+
* - `"cloudflare"` — the schema's own documented minimum and maximum.
|
|
586
|
+
* - `"clamped"` — Cloudflare accepts more; this connection caps it lower.
|
|
587
|
+
* - `"undocumented"` — Cloudflare documents no bounds at all for the endpoint,
|
|
588
|
+
* so the range is entirely this connection's choice.
|
|
589
|
+
*/
|
|
590
|
+
function pagingInputProperties(
|
|
591
|
+
minPerPage: number,
|
|
592
|
+
maxPerPage: number,
|
|
593
|
+
options: {
|
|
594
|
+
defaultPerPage?: number;
|
|
595
|
+
bounds?: "cloudflare" | "clamped" | "undocumented";
|
|
596
|
+
} = {},
|
|
597
|
+
): Record<string, JsonSchema> {
|
|
598
|
+
const { defaultPerPage, bounds = "cloudflare" } = options;
|
|
599
|
+
const defaultNote =
|
|
600
|
+
defaultPerPage === undefined
|
|
601
|
+
? " Cloudflare chooses the default."
|
|
602
|
+
: ` Defaults to ${defaultPerPage}.`;
|
|
603
|
+
const boundsNote =
|
|
604
|
+
bounds === "clamped"
|
|
605
|
+
? ` The ${maxPerPage} ceiling is this connection's cap, not Cloudflare's limit.`
|
|
606
|
+
: bounds === "undocumented"
|
|
607
|
+
? " Cloudflare documents no bounds for this endpoint; the range is this connection's own."
|
|
608
|
+
: "";
|
|
609
|
+
return {
|
|
610
|
+
page: {
|
|
611
|
+
type: "integer",
|
|
612
|
+
minimum: 1,
|
|
613
|
+
description: "1-based page number. Defaults to 1.",
|
|
614
|
+
},
|
|
615
|
+
perPage: {
|
|
616
|
+
type: "integer",
|
|
617
|
+
minimum: minPerPage,
|
|
618
|
+
maximum: maxPerPage,
|
|
619
|
+
description: `Items per page, ${minPerPage} to ${maxPerPage}.${defaultNote}${boundsNote}`,
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function listOutputSchema(key: string, item: JsonSchema): JsonSchema {
|
|
625
|
+
return {
|
|
626
|
+
type: "object",
|
|
627
|
+
properties: { [key]: { type: "array", items: item }, page: PAGE_OUTPUT_SCHEMA },
|
|
628
|
+
required: [key],
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const ACCOUNT_SCHEMA: JsonSchema = {
|
|
633
|
+
type: "object",
|
|
634
|
+
properties: {
|
|
635
|
+
id: { type: "string" },
|
|
636
|
+
name: { type: "string" },
|
|
637
|
+
type: { type: "string" },
|
|
638
|
+
createdOn: { type: "string" },
|
|
639
|
+
},
|
|
640
|
+
required: ["id", "name"],
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const ZONE_SCHEMA: JsonSchema = {
|
|
644
|
+
type: "object",
|
|
645
|
+
properties: {
|
|
646
|
+
id: { type: "string", description: "Zone id — the argument every zone-scoped tool wants." },
|
|
647
|
+
name: { type: "string", description: "Apex domain, e.g. example.com." },
|
|
648
|
+
status: { type: "string" },
|
|
649
|
+
paused: { type: "boolean" },
|
|
650
|
+
type: { type: "string" },
|
|
651
|
+
accountId: { type: "string" },
|
|
652
|
+
accountName: { type: "string" },
|
|
653
|
+
plan: { type: "string" },
|
|
654
|
+
nameServers: { type: "array", items: { type: "string" } },
|
|
655
|
+
createdOn: { type: "string" },
|
|
656
|
+
modifiedOn: { type: "string" },
|
|
657
|
+
},
|
|
658
|
+
required: ["id", "name", "status"],
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const DNS_RECORD_SCHEMA: JsonSchema = {
|
|
662
|
+
type: "object",
|
|
663
|
+
properties: {
|
|
664
|
+
id: { type: "string" },
|
|
665
|
+
name: { type: "string", description: "Fully qualified record name." },
|
|
666
|
+
type: { type: "string", enum: [...CLOUDFLARE_DNS_RECORD_TYPES] },
|
|
667
|
+
content: { type: "string" },
|
|
668
|
+
ttl: { type: "integer", description: "Seconds; 1 means automatic." },
|
|
669
|
+
proxied: { type: "boolean" },
|
|
670
|
+
priority: { type: "integer" },
|
|
671
|
+
comment: { type: "string" },
|
|
672
|
+
tags: { type: "array", items: { type: "string" } },
|
|
673
|
+
createdOn: { type: "string" },
|
|
674
|
+
modifiedOn: { type: "string" },
|
|
675
|
+
},
|
|
676
|
+
required: ["id", "name", "type", "content", "ttl"],
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// --- Tool construction -------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
interface Scoping {
|
|
682
|
+
base: string;
|
|
683
|
+
accountId: string | undefined;
|
|
684
|
+
zoneId: string | undefined;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Resolve a scope id from the call or the deployment default.
|
|
689
|
+
*
|
|
690
|
+
* The second layer, not the first: when the deployment declares no default the
|
|
691
|
+
* schema already lists the key in `required`, so `api()` rejects an omitted id
|
|
692
|
+
* before the handler runs. This catches what a JSON Schema string cannot — a
|
|
693
|
+
* blank or whitespace-only id — and answers with the discovery tool's name
|
|
694
|
+
* rather than a Cloudflare round trip that would 404.
|
|
695
|
+
*/
|
|
696
|
+
function requireScope(
|
|
697
|
+
provided: unknown,
|
|
698
|
+
fallback: string | undefined,
|
|
699
|
+
kind: "zoneId" | "accountId",
|
|
700
|
+
): string {
|
|
701
|
+
const value = typeof provided === "string" ? provided.trim() : "";
|
|
702
|
+
if (value) return value;
|
|
703
|
+
if (fallback) return fallback;
|
|
704
|
+
const discovery = kind === "zoneId" ? "list_zones" : "list_accounts";
|
|
705
|
+
throw new ConnectorCallError(
|
|
706
|
+
"invalid_args",
|
|
707
|
+
`${kind} is required: this connector has no default ${kind}. Call ${discovery} to find it.`,
|
|
708
|
+
{
|
|
709
|
+
validation: {
|
|
710
|
+
issues: [
|
|
711
|
+
{
|
|
712
|
+
path: `/${kind}`,
|
|
713
|
+
code: "required",
|
|
714
|
+
expected: `a Cloudflare ${kind === "zoneId" ? "zone" : "account"} id`,
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** A scope argument is only required when the deployment declared no default. */
|
|
723
|
+
function scopeProperty(
|
|
724
|
+
kind: "zoneId" | "accountId",
|
|
725
|
+
fallback: string | undefined,
|
|
726
|
+
): JsonSchema {
|
|
727
|
+
const noun = kind === "zoneId" ? "Zone" : "Account";
|
|
728
|
+
const discovery = kind === "zoneId" ? "list_zones" : "list_accounts";
|
|
729
|
+
return {
|
|
730
|
+
type: "string",
|
|
731
|
+
minLength: 1,
|
|
732
|
+
description: fallback
|
|
733
|
+
? `${noun} id. Optional — defaults to this connector's configured ${kind}. Pass one to address a different ${noun.toLowerCase()}; ${discovery} lists them.`
|
|
734
|
+
: `${noun} id. Required — this connector declares no default; ${discovery} returns it.`,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function scopeRequired(
|
|
739
|
+
kind: "zoneId" | "accountId",
|
|
740
|
+
fallback: string | undefined,
|
|
741
|
+
): string[] {
|
|
742
|
+
return fallback ? [] : [kind];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function optionalString(args: JsonRecord, key: string): string | undefined {
|
|
746
|
+
const value = args[key];
|
|
747
|
+
return typeof value === "string" && value.trim() !== ""
|
|
748
|
+
? value.trim()
|
|
749
|
+
: undefined;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function optionalNumber(args: JsonRecord, key: string): number | undefined {
|
|
753
|
+
const value = args[key];
|
|
754
|
+
return typeof value === "number" ? value : undefined;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function buildTools(scope: Scoping): ApiTool[] {
|
|
758
|
+
const { base } = scope;
|
|
759
|
+
const zoneArg = (args: JsonRecord): string =>
|
|
760
|
+
requireScope(args["zoneId"], scope.zoneId, "zoneId");
|
|
761
|
+
const accountArg = (args: JsonRecord): string =>
|
|
762
|
+
requireScope(args["accountId"], scope.accountId, "accountId");
|
|
763
|
+
|
|
764
|
+
const readOnly = { readOnlyHint: true, destructiveHint: false } as const;
|
|
765
|
+
|
|
766
|
+
return [
|
|
767
|
+
{
|
|
768
|
+
name: "verify_api_token",
|
|
769
|
+
description:
|
|
770
|
+
"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.",
|
|
771
|
+
annotations: readOnly,
|
|
772
|
+
inputSchema: {
|
|
773
|
+
type: "object",
|
|
774
|
+
properties: {},
|
|
775
|
+
required: [],
|
|
776
|
+
additionalProperties: false,
|
|
777
|
+
},
|
|
778
|
+
outputSchema: {
|
|
779
|
+
type: "object",
|
|
780
|
+
properties: {
|
|
781
|
+
id: { type: "string" },
|
|
782
|
+
status: {
|
|
783
|
+
type: "string",
|
|
784
|
+
description: "\"active\" for a usable token.",
|
|
785
|
+
},
|
|
786
|
+
notBefore: { type: "string" },
|
|
787
|
+
expiresOn: { type: "string" },
|
|
788
|
+
},
|
|
789
|
+
required: ["status"],
|
|
790
|
+
},
|
|
791
|
+
handler: async (_args, ctx) => {
|
|
792
|
+
const { result } = await callCloudflare(
|
|
793
|
+
base,
|
|
794
|
+
{ method: "GET", path: "/user/tokens/verify" },
|
|
795
|
+
ctx,
|
|
796
|
+
);
|
|
797
|
+
const token = asRecord(result);
|
|
798
|
+
return {
|
|
799
|
+
id: token["id"],
|
|
800
|
+
status: token["status"],
|
|
801
|
+
...(token["not_before"] !== undefined
|
|
802
|
+
? { notBefore: token["not_before"] }
|
|
803
|
+
: {}),
|
|
804
|
+
...(token["expires_on"] !== undefined
|
|
805
|
+
? { expiresOn: token["expires_on"] }
|
|
806
|
+
: {}),
|
|
807
|
+
};
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
name: "list_accounts",
|
|
812
|
+
description:
|
|
813
|
+
"List Cloudflare accounts this token can see. Supplies the accountId that the Workers, KV, R2, and Pages tools need.",
|
|
814
|
+
annotations: readOnly,
|
|
815
|
+
inputSchema: {
|
|
816
|
+
type: "object",
|
|
817
|
+
properties: {
|
|
818
|
+
name: {
|
|
819
|
+
type: "string",
|
|
820
|
+
description: "Filter by exact account name.",
|
|
821
|
+
},
|
|
822
|
+
...pagingInputProperties(5, 50, { defaultPerPage: 20 }),
|
|
823
|
+
raw: RAW_INPUT_PROPERTY,
|
|
824
|
+
},
|
|
825
|
+
required: [],
|
|
826
|
+
additionalProperties: false,
|
|
827
|
+
},
|
|
828
|
+
outputSchema: listOutputSchema("accounts", ACCOUNT_SCHEMA),
|
|
829
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
830
|
+
const { result, resultInfo } = await callCloudflare(
|
|
831
|
+
base,
|
|
832
|
+
{
|
|
833
|
+
method: "GET",
|
|
834
|
+
path: "/accounts",
|
|
835
|
+
query: {
|
|
836
|
+
name: optionalString(args, "name"),
|
|
837
|
+
page: optionalNumber(args, "page"),
|
|
838
|
+
per_page: optionalNumber(args, "perPage"),
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
ctx,
|
|
842
|
+
);
|
|
843
|
+
if (args["raw"] === true) return { accounts: result, page: pageInfo(resultInfo) };
|
|
844
|
+
return {
|
|
845
|
+
accounts: asArray(result).map(projectAccount),
|
|
846
|
+
page: pageInfo(resultInfo),
|
|
847
|
+
};
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
name: "list_zones",
|
|
852
|
+
description:
|
|
853
|
+
"List zones (domains) this token can see, with their ids and status. This is the zoneId discovery step for every DNS and cache tool.",
|
|
854
|
+
annotations: readOnly,
|
|
855
|
+
inputSchema: {
|
|
856
|
+
type: "object",
|
|
857
|
+
properties: {
|
|
858
|
+
name: {
|
|
859
|
+
type: "string",
|
|
860
|
+
description: "Filter by zone name, e.g. example.com.",
|
|
861
|
+
},
|
|
862
|
+
accountId: {
|
|
863
|
+
type: "string",
|
|
864
|
+
description:
|
|
865
|
+
"Restrict to one account. Defaults to every account the token can see.",
|
|
866
|
+
},
|
|
867
|
+
status: {
|
|
868
|
+
type: "string",
|
|
869
|
+
enum: ["initializing", "pending", "active", "moved"],
|
|
870
|
+
description: "Filter by zone status.",
|
|
871
|
+
},
|
|
872
|
+
...pagingInputProperties(5, 50, { defaultPerPage: 20 }),
|
|
873
|
+
raw: RAW_INPUT_PROPERTY,
|
|
874
|
+
},
|
|
875
|
+
required: [],
|
|
876
|
+
additionalProperties: false,
|
|
877
|
+
},
|
|
878
|
+
outputSchema: listOutputSchema("zones", ZONE_SCHEMA),
|
|
879
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
880
|
+
const { result, resultInfo } = await callCloudflare(
|
|
881
|
+
base,
|
|
882
|
+
{
|
|
883
|
+
method: "GET",
|
|
884
|
+
path: "/zones",
|
|
885
|
+
query: {
|
|
886
|
+
name: optionalString(args, "name"),
|
|
887
|
+
// Deliberately not defaulted to `scope.accountId`. This is the
|
|
888
|
+
// discovery tool: a deployment default that silently narrowed
|
|
889
|
+
// what an agent can see would contradict the property's own
|
|
890
|
+
// description, and there would be no argument that escapes it.
|
|
891
|
+
"account.id": optionalString(args, "accountId"),
|
|
892
|
+
status: optionalString(args, "status"),
|
|
893
|
+
page: optionalNumber(args, "page"),
|
|
894
|
+
per_page: optionalNumber(args, "perPage"),
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
ctx,
|
|
898
|
+
);
|
|
899
|
+
if (args["raw"] === true) return { zones: result, page: pageInfo(resultInfo) };
|
|
900
|
+
return {
|
|
901
|
+
zones: asArray(result).map(projectZone),
|
|
902
|
+
page: pageInfo(resultInfo),
|
|
903
|
+
};
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
name: "get_zone",
|
|
908
|
+
description:
|
|
909
|
+
"Fetch one zone's settings summary by id: status, plan, name servers, and owning account.",
|
|
910
|
+
annotations: readOnly,
|
|
911
|
+
inputSchema: {
|
|
912
|
+
type: "object",
|
|
913
|
+
properties: {
|
|
914
|
+
zoneId: scopeProperty("zoneId", scope.zoneId),
|
|
915
|
+
raw: RAW_INPUT_PROPERTY,
|
|
916
|
+
},
|
|
917
|
+
required: scopeRequired("zoneId", scope.zoneId),
|
|
918
|
+
additionalProperties: false,
|
|
919
|
+
},
|
|
920
|
+
outputSchema: ZONE_SCHEMA,
|
|
921
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
922
|
+
const { result } = await callCloudflare(
|
|
923
|
+
base,
|
|
924
|
+
{ method: "GET", path: `/zones/${encodeURIComponent(zoneArg(args))}` },
|
|
925
|
+
ctx,
|
|
926
|
+
);
|
|
927
|
+
return args["raw"] === true ? result : projectZone(result);
|
|
928
|
+
},
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
name: "list_dns_records",
|
|
932
|
+
description:
|
|
933
|
+
"List DNS records in a zone, filtered by name, type, or content. Returns record ids, which update_dns_record and delete_dns_record require.",
|
|
934
|
+
annotations: readOnly,
|
|
935
|
+
inputSchema: {
|
|
936
|
+
type: "object",
|
|
937
|
+
properties: {
|
|
938
|
+
zoneId: scopeProperty("zoneId", scope.zoneId),
|
|
939
|
+
name: {
|
|
940
|
+
type: "string",
|
|
941
|
+
description:
|
|
942
|
+
"Exact record name, fully qualified, e.g. www.example.com.",
|
|
943
|
+
},
|
|
944
|
+
type: {
|
|
945
|
+
type: "string",
|
|
946
|
+
enum: [...CLOUDFLARE_DNS_RECORD_TYPES],
|
|
947
|
+
description: "Filter by record type.",
|
|
948
|
+
},
|
|
949
|
+
content: {
|
|
950
|
+
type: "string",
|
|
951
|
+
description: "Exact record content, e.g. an IP address.",
|
|
952
|
+
},
|
|
953
|
+
order: {
|
|
954
|
+
type: "string",
|
|
955
|
+
enum: ["type", "name", "content", "ttl", "proxied"],
|
|
956
|
+
description: "Sort field.",
|
|
957
|
+
},
|
|
958
|
+
direction: {
|
|
959
|
+
type: "string",
|
|
960
|
+
enum: ["asc", "desc"],
|
|
961
|
+
description: "Sort direction for `order`. Defaults to asc.",
|
|
962
|
+
},
|
|
963
|
+
// Cloudflare documents 1 to 5,000,000 here with a default of 100; the
|
|
964
|
+
// ceiling is nominal, so this connection caps it at a page size that
|
|
965
|
+
// actually returns.
|
|
966
|
+
...pagingInputProperties(1, 1000, {
|
|
967
|
+
defaultPerPage: 100,
|
|
968
|
+
bounds: "clamped",
|
|
969
|
+
}),
|
|
970
|
+
raw: RAW_INPUT_PROPERTY,
|
|
971
|
+
},
|
|
972
|
+
required: scopeRequired("zoneId", scope.zoneId),
|
|
973
|
+
additionalProperties: false,
|
|
974
|
+
},
|
|
975
|
+
outputSchema: listOutputSchema("records", DNS_RECORD_SCHEMA),
|
|
976
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
977
|
+
const { result, resultInfo } = await callCloudflare(
|
|
978
|
+
base,
|
|
979
|
+
{
|
|
980
|
+
method: "GET",
|
|
981
|
+
path: `/zones/${encodeURIComponent(zoneArg(args))}/dns_records`,
|
|
982
|
+
query: {
|
|
983
|
+
name: optionalString(args, "name"),
|
|
984
|
+
type: optionalString(args, "type"),
|
|
985
|
+
content: optionalString(args, "content"),
|
|
986
|
+
order: optionalString(args, "order"),
|
|
987
|
+
direction: optionalString(args, "direction"),
|
|
988
|
+
page: optionalNumber(args, "page"),
|
|
989
|
+
per_page: optionalNumber(args, "perPage"),
|
|
990
|
+
},
|
|
991
|
+
},
|
|
992
|
+
ctx,
|
|
993
|
+
);
|
|
994
|
+
if (args["raw"] === true)
|
|
995
|
+
return { records: result, page: pageInfo(resultInfo) };
|
|
996
|
+
return {
|
|
997
|
+
records: asArray(result).map(projectDnsRecord),
|
|
998
|
+
page: pageInfo(resultInfo),
|
|
999
|
+
};
|
|
1000
|
+
},
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
name: "get_dns_record",
|
|
1004
|
+
description: "Fetch one DNS record by its record id.",
|
|
1005
|
+
annotations: readOnly,
|
|
1006
|
+
inputSchema: {
|
|
1007
|
+
type: "object",
|
|
1008
|
+
properties: {
|
|
1009
|
+
zoneId: scopeProperty("zoneId", scope.zoneId),
|
|
1010
|
+
recordId: {
|
|
1011
|
+
type: "string",
|
|
1012
|
+
description: "DNS record id, from list_dns_records.",
|
|
1013
|
+
},
|
|
1014
|
+
raw: RAW_INPUT_PROPERTY,
|
|
1015
|
+
},
|
|
1016
|
+
required: [...scopeRequired("zoneId", scope.zoneId), "recordId"],
|
|
1017
|
+
additionalProperties: false,
|
|
1018
|
+
},
|
|
1019
|
+
outputSchema: DNS_RECORD_SCHEMA,
|
|
1020
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
1021
|
+
const { result } = await callCloudflare(
|
|
1022
|
+
base,
|
|
1023
|
+
{
|
|
1024
|
+
method: "GET",
|
|
1025
|
+
path: `/zones/${encodeURIComponent(zoneArg(args))}/dns_records/${encodeURIComponent(
|
|
1026
|
+
String(args["recordId"]),
|
|
1027
|
+
)}`,
|
|
1028
|
+
},
|
|
1029
|
+
ctx,
|
|
1030
|
+
);
|
|
1031
|
+
return args["raw"] === true ? result : projectDnsRecord(result);
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
name: "list_worker_scripts",
|
|
1036
|
+
description:
|
|
1037
|
+
"List Workers scripts deployed in an account, with their last-modified times.",
|
|
1038
|
+
annotations: readOnly,
|
|
1039
|
+
inputSchema: {
|
|
1040
|
+
type: "object",
|
|
1041
|
+
properties: {
|
|
1042
|
+
accountId: scopeProperty("accountId", scope.accountId),
|
|
1043
|
+
raw: RAW_INPUT_PROPERTY,
|
|
1044
|
+
},
|
|
1045
|
+
required: scopeRequired("accountId", scope.accountId),
|
|
1046
|
+
additionalProperties: false,
|
|
1047
|
+
},
|
|
1048
|
+
outputSchema: listOutputSchema("scripts", {
|
|
1049
|
+
type: "object",
|
|
1050
|
+
properties: {
|
|
1051
|
+
id: { type: "string", description: "Script name." },
|
|
1052
|
+
createdOn: { type: "string" },
|
|
1053
|
+
modifiedOn: { type: "string" },
|
|
1054
|
+
usageModel: { type: "string" },
|
|
1055
|
+
},
|
|
1056
|
+
required: ["id"],
|
|
1057
|
+
}),
|
|
1058
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
1059
|
+
const { result, resultInfo } = await callCloudflare(
|
|
1060
|
+
base,
|
|
1061
|
+
{
|
|
1062
|
+
method: "GET",
|
|
1063
|
+
path: `/accounts/${encodeURIComponent(accountArg(args))}/workers/scripts`,
|
|
1064
|
+
},
|
|
1065
|
+
ctx,
|
|
1066
|
+
);
|
|
1067
|
+
if (args["raw"] === true)
|
|
1068
|
+
return { scripts: result, page: pageInfo(resultInfo) };
|
|
1069
|
+
return {
|
|
1070
|
+
scripts: asArray(result).map(projectWorkerScript),
|
|
1071
|
+
page: pageInfo(resultInfo),
|
|
1072
|
+
};
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
name: "list_kv_namespaces",
|
|
1077
|
+
description:
|
|
1078
|
+
"List Workers KV namespaces in an account, with the namespace ids bindings refer to.",
|
|
1079
|
+
annotations: readOnly,
|
|
1080
|
+
inputSchema: {
|
|
1081
|
+
type: "object",
|
|
1082
|
+
properties: {
|
|
1083
|
+
accountId: scopeProperty("accountId", scope.accountId),
|
|
1084
|
+
...pagingInputProperties(1, 1000, { defaultPerPage: 20 }),
|
|
1085
|
+
raw: RAW_INPUT_PROPERTY,
|
|
1086
|
+
},
|
|
1087
|
+
required: scopeRequired("accountId", scope.accountId),
|
|
1088
|
+
additionalProperties: false,
|
|
1089
|
+
},
|
|
1090
|
+
outputSchema: listOutputSchema("namespaces", {
|
|
1091
|
+
type: "object",
|
|
1092
|
+
properties: {
|
|
1093
|
+
id: { type: "string" },
|
|
1094
|
+
title: { type: "string" },
|
|
1095
|
+
supportsUrlEncoding: { type: "boolean" },
|
|
1096
|
+
},
|
|
1097
|
+
required: ["id", "title"],
|
|
1098
|
+
}),
|
|
1099
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
1100
|
+
const { result, resultInfo } = await callCloudflare(
|
|
1101
|
+
base,
|
|
1102
|
+
{
|
|
1103
|
+
method: "GET",
|
|
1104
|
+
path: `/accounts/${encodeURIComponent(accountArg(args))}/storage/kv/namespaces`,
|
|
1105
|
+
query: {
|
|
1106
|
+
page: optionalNumber(args, "page"),
|
|
1107
|
+
per_page: optionalNumber(args, "perPage"),
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
ctx,
|
|
1111
|
+
);
|
|
1112
|
+
if (args["raw"] === true)
|
|
1113
|
+
return { namespaces: result, page: pageInfo(resultInfo) };
|
|
1114
|
+
return {
|
|
1115
|
+
namespaces: asArray(result).map(projectKvNamespace),
|
|
1116
|
+
page: pageInfo(resultInfo),
|
|
1117
|
+
};
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
name: "list_r2_buckets",
|
|
1122
|
+
description:
|
|
1123
|
+
"List R2 buckets in an account, with location and storage class.",
|
|
1124
|
+
annotations: readOnly,
|
|
1125
|
+
inputSchema: {
|
|
1126
|
+
type: "object",
|
|
1127
|
+
properties: {
|
|
1128
|
+
accountId: scopeProperty("accountId", scope.accountId),
|
|
1129
|
+
nameContains: {
|
|
1130
|
+
type: "string",
|
|
1131
|
+
description: "Filter to buckets whose name contains this string.",
|
|
1132
|
+
},
|
|
1133
|
+
perPage: {
|
|
1134
|
+
type: "integer",
|
|
1135
|
+
minimum: 1,
|
|
1136
|
+
maximum: 1000,
|
|
1137
|
+
description: "Buckets per request, 1 to 1000. Defaults to 20.",
|
|
1138
|
+
},
|
|
1139
|
+
cursor: {
|
|
1140
|
+
type: "string",
|
|
1141
|
+
description:
|
|
1142
|
+
"Opaque cursor from a previous call's nextCursor. R2 paginates by cursor, not page number.",
|
|
1143
|
+
},
|
|
1144
|
+
raw: RAW_INPUT_PROPERTY,
|
|
1145
|
+
},
|
|
1146
|
+
required: scopeRequired("accountId", scope.accountId),
|
|
1147
|
+
additionalProperties: false,
|
|
1148
|
+
},
|
|
1149
|
+
outputSchema: {
|
|
1150
|
+
type: "object",
|
|
1151
|
+
properties: {
|
|
1152
|
+
buckets: {
|
|
1153
|
+
type: "array",
|
|
1154
|
+
items: {
|
|
1155
|
+
type: "object",
|
|
1156
|
+
properties: {
|
|
1157
|
+
name: { type: "string" },
|
|
1158
|
+
location: { type: "string" },
|
|
1159
|
+
storageClass: { type: "string" },
|
|
1160
|
+
creationDate: { type: "string" },
|
|
1161
|
+
},
|
|
1162
|
+
required: ["name"],
|
|
1163
|
+
},
|
|
1164
|
+
},
|
|
1165
|
+
nextCursor: {
|
|
1166
|
+
type: "string",
|
|
1167
|
+
description:
|
|
1168
|
+
"Pass back as `cursor` to continue. Absent when the listing is complete.",
|
|
1169
|
+
},
|
|
1170
|
+
},
|
|
1171
|
+
required: ["buckets"],
|
|
1172
|
+
},
|
|
1173
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
1174
|
+
const { result, resultInfo } = await callCloudflare(
|
|
1175
|
+
base,
|
|
1176
|
+
{
|
|
1177
|
+
method: "GET",
|
|
1178
|
+
path: `/accounts/${encodeURIComponent(accountArg(args))}/r2/buckets`,
|
|
1179
|
+
query: {
|
|
1180
|
+
name_contains: optionalString(args, "nameContains"),
|
|
1181
|
+
per_page: optionalNumber(args, "perPage"),
|
|
1182
|
+
cursor: optionalString(args, "cursor"),
|
|
1183
|
+
},
|
|
1184
|
+
},
|
|
1185
|
+
ctx,
|
|
1186
|
+
);
|
|
1187
|
+
// R2 nests its list under `buckets` rather than returning a bare array,
|
|
1188
|
+
// and its result_info carries a cursor instead of page counters.
|
|
1189
|
+
const cursor = resultInfo?.cursor;
|
|
1190
|
+
const next =
|
|
1191
|
+
typeof cursor === "string" && cursor !== ""
|
|
1192
|
+
? { nextCursor: cursor }
|
|
1193
|
+
: {};
|
|
1194
|
+
if (args["raw"] === true) return { buckets: result, ...next };
|
|
1195
|
+
return {
|
|
1196
|
+
buckets: asArray(asRecord(result)["buckets"]).map(projectR2Bucket),
|
|
1197
|
+
...next,
|
|
1198
|
+
};
|
|
1199
|
+
},
|
|
1200
|
+
},
|
|
1201
|
+
{
|
|
1202
|
+
name: "list_pages_projects",
|
|
1203
|
+
description:
|
|
1204
|
+
"List Cloudflare Pages projects in an account, with their production branch and latest deployment.",
|
|
1205
|
+
annotations: readOnly,
|
|
1206
|
+
inputSchema: {
|
|
1207
|
+
type: "object",
|
|
1208
|
+
properties: {
|
|
1209
|
+
accountId: scopeProperty("accountId", scope.accountId),
|
|
1210
|
+
...pagingInputProperties(1, 100, { bounds: "undocumented" }),
|
|
1211
|
+
raw: RAW_INPUT_PROPERTY,
|
|
1212
|
+
},
|
|
1213
|
+
required: scopeRequired("accountId", scope.accountId),
|
|
1214
|
+
additionalProperties: false,
|
|
1215
|
+
},
|
|
1216
|
+
outputSchema: listOutputSchema("projects", {
|
|
1217
|
+
type: "object",
|
|
1218
|
+
properties: {
|
|
1219
|
+
name: { type: "string" },
|
|
1220
|
+
subdomain: { type: "string" },
|
|
1221
|
+
domains: { type: "array", items: { type: "string" } },
|
|
1222
|
+
productionBranch: { type: "string" },
|
|
1223
|
+
createdOn: { type: "string" },
|
|
1224
|
+
latestDeployment: {
|
|
1225
|
+
type: "object",
|
|
1226
|
+
properties: {
|
|
1227
|
+
id: { type: "string" },
|
|
1228
|
+
environment: { type: "string" },
|
|
1229
|
+
url: { type: "string" },
|
|
1230
|
+
createdOn: { type: "string" },
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1233
|
+
},
|
|
1234
|
+
required: ["name"],
|
|
1235
|
+
}),
|
|
1236
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
1237
|
+
const { result, resultInfo } = await callCloudflare(
|
|
1238
|
+
base,
|
|
1239
|
+
{
|
|
1240
|
+
method: "GET",
|
|
1241
|
+
path: `/accounts/${encodeURIComponent(accountArg(args))}/pages/projects`,
|
|
1242
|
+
query: {
|
|
1243
|
+
page: optionalNumber(args, "page"),
|
|
1244
|
+
per_page: optionalNumber(args, "perPage"),
|
|
1245
|
+
},
|
|
1246
|
+
},
|
|
1247
|
+
ctx,
|
|
1248
|
+
);
|
|
1249
|
+
if (args["raw"] === true)
|
|
1250
|
+
return { projects: result, page: pageInfo(resultInfo) };
|
|
1251
|
+
return {
|
|
1252
|
+
projects: asArray(result).map(projectPagesProject),
|
|
1253
|
+
page: pageInfo(resultInfo),
|
|
1254
|
+
};
|
|
1255
|
+
},
|
|
1256
|
+
},
|
|
1257
|
+
{
|
|
1258
|
+
// Additive: brings a record into being and destroys nothing, so
|
|
1259
|
+
// `destructiveHint` stays unset. `readOnlyHint: false` already routes it
|
|
1260
|
+
// through call_destructive_tool.
|
|
1261
|
+
name: "create_dns_record",
|
|
1262
|
+
description:
|
|
1263
|
+
"Create a content-based DNS record (A, AAAA, CNAME, MX, NS, OPENPGPKEY, PTR, TXT) in a zone. Check for an existing record with list_dns_records first: Cloudflare rejects a duplicate rather than replacing it. Record types that carry structured data, such as SRV and CAA, are readable here but not creatable.",
|
|
1264
|
+
annotations: { readOnlyHint: false },
|
|
1265
|
+
inputSchema: {
|
|
1266
|
+
type: "object",
|
|
1267
|
+
properties: {
|
|
1268
|
+
zoneId: scopeProperty("zoneId", scope.zoneId),
|
|
1269
|
+
type: {
|
|
1270
|
+
type: "string",
|
|
1271
|
+
enum: [...CLOUDFLARE_CONTENT_DNS_RECORD_TYPES],
|
|
1272
|
+
description: "Record type.",
|
|
1273
|
+
},
|
|
1274
|
+
name: {
|
|
1275
|
+
type: "string",
|
|
1276
|
+
description:
|
|
1277
|
+
"Record name. Use the apex domain for the root, or a fully qualified subdomain, e.g. www.example.com.",
|
|
1278
|
+
},
|
|
1279
|
+
content: {
|
|
1280
|
+
type: "string",
|
|
1281
|
+
description:
|
|
1282
|
+
"Record value: an IPv4 address for A, IPv6 for AAAA, a hostname for CNAME/MX/NS, or the text body for TXT.",
|
|
1283
|
+
},
|
|
1284
|
+
ttl: {
|
|
1285
|
+
type: "integer",
|
|
1286
|
+
minimum: 1,
|
|
1287
|
+
maximum: 86400,
|
|
1288
|
+
description:
|
|
1289
|
+
"Time to live in seconds. 1 means automatic, which is what a proxied record must use; any other value must be at least 60 (30 on Enterprise zones). Defaults to 1.",
|
|
1290
|
+
},
|
|
1291
|
+
proxied: {
|
|
1292
|
+
type: "boolean",
|
|
1293
|
+
description:
|
|
1294
|
+
"Route through Cloudflare's proxy. Only A, AAAA, and CNAME records are proxiable. Defaults to false.",
|
|
1295
|
+
},
|
|
1296
|
+
priority: {
|
|
1297
|
+
type: "integer",
|
|
1298
|
+
minimum: 0,
|
|
1299
|
+
maximum: 65535,
|
|
1300
|
+
description: "Mail-server preference. MX records only.",
|
|
1301
|
+
},
|
|
1302
|
+
comment: {
|
|
1303
|
+
type: "string",
|
|
1304
|
+
description: "Operator-facing note stored with the record.",
|
|
1305
|
+
},
|
|
1306
|
+
tags: {
|
|
1307
|
+
type: "array",
|
|
1308
|
+
items: { type: "string" },
|
|
1309
|
+
description: "Custom tags, available on paid plans.",
|
|
1310
|
+
},
|
|
1311
|
+
},
|
|
1312
|
+
required: [
|
|
1313
|
+
...scopeRequired("zoneId", scope.zoneId),
|
|
1314
|
+
"type",
|
|
1315
|
+
"name",
|
|
1316
|
+
"content",
|
|
1317
|
+
],
|
|
1318
|
+
additionalProperties: false,
|
|
1319
|
+
},
|
|
1320
|
+
outputSchema: DNS_RECORD_SCHEMA,
|
|
1321
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
1322
|
+
const { result } = await callCloudflare(
|
|
1323
|
+
base,
|
|
1324
|
+
{
|
|
1325
|
+
method: "POST",
|
|
1326
|
+
path: `/zones/${encodeURIComponent(zoneArg(args))}/dns_records`,
|
|
1327
|
+
body: {
|
|
1328
|
+
type: args["type"],
|
|
1329
|
+
name: args["name"],
|
|
1330
|
+
content: args["content"],
|
|
1331
|
+
ttl: optionalNumber(args, "ttl") ?? 1,
|
|
1332
|
+
...(args["proxied"] !== undefined
|
|
1333
|
+
? { proxied: args["proxied"] }
|
|
1334
|
+
: {}),
|
|
1335
|
+
...(args["priority"] !== undefined
|
|
1336
|
+
? { priority: args["priority"] }
|
|
1337
|
+
: {}),
|
|
1338
|
+
...(args["comment"] !== undefined
|
|
1339
|
+
? { comment: args["comment"] }
|
|
1340
|
+
: {}),
|
|
1341
|
+
...(args["tags"] !== undefined ? { tags: args["tags"] } : {}),
|
|
1342
|
+
},
|
|
1343
|
+
},
|
|
1344
|
+
ctx,
|
|
1345
|
+
);
|
|
1346
|
+
return projectDnsRecord(result);
|
|
1347
|
+
},
|
|
1348
|
+
},
|
|
1349
|
+
{
|
|
1350
|
+
// Destructive: it overwrites what a record already resolves to.
|
|
1351
|
+
name: "update_dns_record",
|
|
1352
|
+
description:
|
|
1353
|
+
"Update fields on an existing DNS record. Only the supplied fields change; everything else keeps its current value. Changing content on a live record repoints traffic immediately.",
|
|
1354
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1355
|
+
inputSchema: {
|
|
1356
|
+
type: "object",
|
|
1357
|
+
properties: {
|
|
1358
|
+
zoneId: scopeProperty("zoneId", scope.zoneId),
|
|
1359
|
+
recordId: {
|
|
1360
|
+
type: "string",
|
|
1361
|
+
description: "DNS record id, from list_dns_records.",
|
|
1362
|
+
},
|
|
1363
|
+
type: {
|
|
1364
|
+
type: "string",
|
|
1365
|
+
enum: [...CLOUDFLARE_CONTENT_DNS_RECORD_TYPES],
|
|
1366
|
+
description:
|
|
1367
|
+
"Record type. Send it whenever content changes; Cloudflare treats type and content as a pair.",
|
|
1368
|
+
},
|
|
1369
|
+
name: { type: "string", description: "Fully qualified record name." },
|
|
1370
|
+
content: { type: "string", description: "New record value." },
|
|
1371
|
+
ttl: {
|
|
1372
|
+
type: "integer",
|
|
1373
|
+
minimum: 1,
|
|
1374
|
+
maximum: 86400,
|
|
1375
|
+
description:
|
|
1376
|
+
"Seconds; 1 means automatic, otherwise at least 60 (30 on Enterprise zones).",
|
|
1377
|
+
},
|
|
1378
|
+
proxied: {
|
|
1379
|
+
type: "boolean",
|
|
1380
|
+
description:
|
|
1381
|
+
"Route through Cloudflare's proxy. Only A, AAAA, and CNAME records are proxiable, and a proxied record must use ttl 1.",
|
|
1382
|
+
},
|
|
1383
|
+
priority: {
|
|
1384
|
+
type: "integer",
|
|
1385
|
+
minimum: 0,
|
|
1386
|
+
maximum: 65535,
|
|
1387
|
+
description: "Mail-server preference. MX records only.",
|
|
1388
|
+
},
|
|
1389
|
+
comment: {
|
|
1390
|
+
type: "string",
|
|
1391
|
+
description:
|
|
1392
|
+
"Operator-facing note stored with the record. Replaces the existing note.",
|
|
1393
|
+
},
|
|
1394
|
+
tags: {
|
|
1395
|
+
type: "array",
|
|
1396
|
+
items: { type: "string" },
|
|
1397
|
+
description:
|
|
1398
|
+
"Custom tags, available on paid plans. Replaces the existing tag set rather than adding to it.",
|
|
1399
|
+
},
|
|
1400
|
+
},
|
|
1401
|
+
required: [...scopeRequired("zoneId", scope.zoneId), "recordId"],
|
|
1402
|
+
additionalProperties: false,
|
|
1403
|
+
},
|
|
1404
|
+
outputSchema: DNS_RECORD_SCHEMA,
|
|
1405
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
1406
|
+
const body: JsonRecord = {};
|
|
1407
|
+
for (const key of [
|
|
1408
|
+
"type",
|
|
1409
|
+
"name",
|
|
1410
|
+
"content",
|
|
1411
|
+
"ttl",
|
|
1412
|
+
"proxied",
|
|
1413
|
+
"priority",
|
|
1414
|
+
"comment",
|
|
1415
|
+
"tags",
|
|
1416
|
+
]) {
|
|
1417
|
+
if (args[key] !== undefined) body[key] = args[key];
|
|
1418
|
+
}
|
|
1419
|
+
if (Object.keys(body).length === 0) {
|
|
1420
|
+
throw new ConnectorCallError(
|
|
1421
|
+
"invalid_args",
|
|
1422
|
+
"update_dns_record needs at least one field to change besides zoneId and recordId.",
|
|
1423
|
+
{
|
|
1424
|
+
validation: {
|
|
1425
|
+
issues: [
|
|
1426
|
+
{
|
|
1427
|
+
path: "/",
|
|
1428
|
+
code: "anyOf",
|
|
1429
|
+
expected: "at least one of type, name, content, ttl, proxied, priority, comment, tags",
|
|
1430
|
+
},
|
|
1431
|
+
],
|
|
1432
|
+
},
|
|
1433
|
+
},
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
const { result } = await callCloudflare(
|
|
1437
|
+
base,
|
|
1438
|
+
{
|
|
1439
|
+
method: "PATCH",
|
|
1440
|
+
path: `/zones/${encodeURIComponent(zoneArg(args))}/dns_records/${encodeURIComponent(
|
|
1441
|
+
String(args["recordId"]),
|
|
1442
|
+
)}`,
|
|
1443
|
+
body,
|
|
1444
|
+
},
|
|
1445
|
+
ctx,
|
|
1446
|
+
);
|
|
1447
|
+
return projectDnsRecord(result);
|
|
1448
|
+
},
|
|
1449
|
+
},
|
|
1450
|
+
{
|
|
1451
|
+
name: "delete_dns_record",
|
|
1452
|
+
description:
|
|
1453
|
+
"Delete a DNS record by id. The record stops resolving immediately and Cloudflare keeps no undo.",
|
|
1454
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1455
|
+
inputSchema: {
|
|
1456
|
+
type: "object",
|
|
1457
|
+
properties: {
|
|
1458
|
+
zoneId: scopeProperty("zoneId", scope.zoneId),
|
|
1459
|
+
recordId: {
|
|
1460
|
+
type: "string",
|
|
1461
|
+
description: "DNS record id, from list_dns_records.",
|
|
1462
|
+
},
|
|
1463
|
+
},
|
|
1464
|
+
required: [...scopeRequired("zoneId", scope.zoneId), "recordId"],
|
|
1465
|
+
additionalProperties: false,
|
|
1466
|
+
},
|
|
1467
|
+
outputSchema: {
|
|
1468
|
+
type: "object",
|
|
1469
|
+
properties: {
|
|
1470
|
+
deleted: { type: "boolean" },
|
|
1471
|
+
recordId: { type: "string" },
|
|
1472
|
+
},
|
|
1473
|
+
required: ["deleted", "recordId"],
|
|
1474
|
+
},
|
|
1475
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
1476
|
+
const recordId = String(args["recordId"]);
|
|
1477
|
+
await callCloudflare(
|
|
1478
|
+
base,
|
|
1479
|
+
{
|
|
1480
|
+
method: "DELETE",
|
|
1481
|
+
path: `/zones/${encodeURIComponent(zoneArg(args))}/dns_records/${encodeURIComponent(recordId)}`,
|
|
1482
|
+
},
|
|
1483
|
+
ctx,
|
|
1484
|
+
);
|
|
1485
|
+
// Cloudflare answers a delete with `{ "result": { "id": ... } }` and
|
|
1486
|
+
// nothing else; the useful acknowledgement is the boolean.
|
|
1487
|
+
return { deleted: true, recordId };
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
{
|
|
1491
|
+
name: "purge_cache",
|
|
1492
|
+
description:
|
|
1493
|
+
"Purge Cloudflare's edge cache for a zone. Prefer files, tags, hosts, or prefixes; everything discards the entire zone cache and sends every subsequent request to the origin until the cache refills.",
|
|
1494
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1495
|
+
inputSchema: {
|
|
1496
|
+
type: "object",
|
|
1497
|
+
properties: {
|
|
1498
|
+
zoneId: scopeProperty("zoneId", scope.zoneId),
|
|
1499
|
+
everything: {
|
|
1500
|
+
type: "boolean",
|
|
1501
|
+
description:
|
|
1502
|
+
"Purge the entire zone cache. Mutually exclusive with the targeted options below, and a real load event for the origin.",
|
|
1503
|
+
},
|
|
1504
|
+
files: {
|
|
1505
|
+
type: "array",
|
|
1506
|
+
items: { type: "string" },
|
|
1507
|
+
minItems: 1,
|
|
1508
|
+
maxItems: 100,
|
|
1509
|
+
description:
|
|
1510
|
+
"Absolute URLs to purge, e.g. https://example.com/style.css. Up to 100 per request (500 on Enterprise).",
|
|
1511
|
+
},
|
|
1512
|
+
tags: {
|
|
1513
|
+
type: "array",
|
|
1514
|
+
items: { type: "string" },
|
|
1515
|
+
minItems: 1,
|
|
1516
|
+
maxItems: 100,
|
|
1517
|
+
description:
|
|
1518
|
+
"Cache-Tag values to purge. Up to 100 per request; available on every plan.",
|
|
1519
|
+
},
|
|
1520
|
+
hosts: {
|
|
1521
|
+
type: "array",
|
|
1522
|
+
items: { type: "string" },
|
|
1523
|
+
minItems: 1,
|
|
1524
|
+
maxItems: 100,
|
|
1525
|
+
description:
|
|
1526
|
+
"Hostnames to purge. Up to 100 per request; available on every plan.",
|
|
1527
|
+
},
|
|
1528
|
+
prefixes: {
|
|
1529
|
+
type: "array",
|
|
1530
|
+
items: { type: "string" },
|
|
1531
|
+
minItems: 1,
|
|
1532
|
+
maxItems: 100,
|
|
1533
|
+
description:
|
|
1534
|
+
"URL prefixes to purge, e.g. example.com/assets. Up to 100 per request; available on every plan.",
|
|
1535
|
+
},
|
|
1536
|
+
},
|
|
1537
|
+
required: scopeRequired("zoneId", scope.zoneId),
|
|
1538
|
+
additionalProperties: false,
|
|
1539
|
+
},
|
|
1540
|
+
outputSchema: {
|
|
1541
|
+
type: "object",
|
|
1542
|
+
properties: {
|
|
1543
|
+
purged: { type: "boolean" },
|
|
1544
|
+
zoneId: { type: "string" },
|
|
1545
|
+
scope: {
|
|
1546
|
+
type: "string",
|
|
1547
|
+
description:
|
|
1548
|
+
"Which variant ran: everything, files, tags, hosts, or prefixes.",
|
|
1549
|
+
},
|
|
1550
|
+
},
|
|
1551
|
+
required: ["purged", "zoneId", "scope"],
|
|
1552
|
+
},
|
|
1553
|
+
handler: async (args: JsonRecord, ctx) => {
|
|
1554
|
+
const zoneId = zoneArg(args);
|
|
1555
|
+
const targeted = (["files", "tags", "hosts", "prefixes"] as const).filter(
|
|
1556
|
+
(key) => Array.isArray(args[key]) && (args[key] as unknown[]).length > 0,
|
|
1557
|
+
);
|
|
1558
|
+
const everything = args["everything"] === true;
|
|
1559
|
+
// Cloudflare's purge body accepts exactly one variant. Refusing here
|
|
1560
|
+
// turns a confusing provider 400 into a schema-shaped failure.
|
|
1561
|
+
if (everything && targeted.length > 0) {
|
|
1562
|
+
throw new ConnectorCallError(
|
|
1563
|
+
"invalid_args",
|
|
1564
|
+
"purge_cache takes either everything: true or one targeted list, never both.",
|
|
1565
|
+
{
|
|
1566
|
+
validation: {
|
|
1567
|
+
issues: [
|
|
1568
|
+
{
|
|
1569
|
+
path: "/everything",
|
|
1570
|
+
code: "oneOf",
|
|
1571
|
+
expected: "everything: true alone, or exactly one of files, tags, hosts, prefixes",
|
|
1572
|
+
},
|
|
1573
|
+
],
|
|
1574
|
+
},
|
|
1575
|
+
},
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
if (!everything && targeted.length !== 1) {
|
|
1579
|
+
throw new ConnectorCallError(
|
|
1580
|
+
"invalid_args",
|
|
1581
|
+
targeted.length === 0
|
|
1582
|
+
? "purge_cache needs everything: true or one of files, tags, hosts, or prefixes."
|
|
1583
|
+
: `purge_cache takes exactly one targeted list; received ${targeted.join(", ")}.`,
|
|
1584
|
+
{
|
|
1585
|
+
validation: {
|
|
1586
|
+
issues: [
|
|
1587
|
+
{
|
|
1588
|
+
path: "/",
|
|
1589
|
+
code: "oneOf",
|
|
1590
|
+
expected: "everything: true, or exactly one of files, tags, hosts, prefixes",
|
|
1591
|
+
},
|
|
1592
|
+
],
|
|
1593
|
+
},
|
|
1594
|
+
},
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
const variant = everything ? "everything" : targeted[0]!;
|
|
1598
|
+
await callCloudflare(
|
|
1599
|
+
base,
|
|
1600
|
+
{
|
|
1601
|
+
method: "POST",
|
|
1602
|
+
path: `/zones/${encodeURIComponent(zoneId)}/purge_cache`,
|
|
1603
|
+
body: everything
|
|
1604
|
+
? { purge_everything: true }
|
|
1605
|
+
: { [variant]: args[variant] },
|
|
1606
|
+
},
|
|
1607
|
+
ctx,
|
|
1608
|
+
);
|
|
1609
|
+
return { purged: true, zoneId, scope: variant };
|
|
1610
|
+
},
|
|
1611
|
+
},
|
|
1612
|
+
];
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function usageGuide(
|
|
1616
|
+
purpose: string,
|
|
1617
|
+
scope: Scoping,
|
|
1618
|
+
instructions: string | undefined,
|
|
1619
|
+
): string {
|
|
1620
|
+
const accountInstructions = instructions?.trim();
|
|
1621
|
+
const zoneLine = scope.zoneId
|
|
1622
|
+
? `This connector defaults to zone \`${scope.zoneId}\`; omit \`zoneId\` unless the request names a different domain.`
|
|
1623
|
+
: "This connector declares no default zone. Start with `list_zones` (filter by `name`) and carry the returned `id` into every zone-scoped call.";
|
|
1624
|
+
const accountLine = scope.accountId
|
|
1625
|
+
? `It defaults to account \`${scope.accountId}\`; omit \`accountId\` unless the request names a different account.`
|
|
1626
|
+
: "It declares no default account. `list_accounts` supplies the `accountId` the Workers, KV, R2, and Pages tools need.";
|
|
1627
|
+
return `# Cloudflare usage
|
|
1628
|
+
|
|
1629
|
+
Account purpose: ${purpose}
|
|
1630
|
+
|
|
1631
|
+
- ${zoneLine}
|
|
1632
|
+
- ${accountLine}
|
|
1633
|
+
- Every tool's schema is complete. The arguments a call needs are in \`required\`, and the values a field accepts are in its \`enum\` — you do not need to read Cloudflare's API documentation to make a call here.
|
|
1634
|
+
- Lists paginate with \`page\` and \`perPage\` and return a \`page\` object; request the next page only when \`page.hasMore\` is true. Two exceptions: \`list_r2_buckets\` paginates by cursor — pass the returned \`nextCursor\` back as \`cursor\` until it is absent — and \`list_worker_scripts\` is unpaginated.
|
|
1635
|
+
- Results are projected to the fields that identify and describe a resource. Pass \`raw: true\` on a read when you genuinely need a field the projection drops.
|
|
1636
|
+
- The API token is operator-managed and scoped by permission, not by role. An \`auth_required\` failure means the token is missing, invalid, or lacks that call's permission — it is never fixed by retrying. Call \`verify_api_token\` to tell a dead token from a missing permission, then report which permission is needed rather than trying other tools.
|
|
1637
|
+
- A \`rate_limited\` failure carries the wait window. Cloudflare's limit is 1,200 requests per five minutes per user, counted across the dashboard and every token, so do not fan out speculatively; filter server-side with \`name\`, \`type\`, and \`content\` instead of listing everything and filtering locally.
|
|
1638
|
+
- Writes: \`create_dns_record\` is additive; \`update_dns_record\`, \`delete_dns_record\`, and \`purge_cache\` change or discard live state and are annotated destructive. Read the current record with \`list_dns_records\` before changing or deleting one, and prefer a targeted \`purge_cache\` over \`everything\`.
|
|
1639
|
+
${
|
|
1640
|
+
accountInstructions
|
|
1641
|
+
? `\n## Account instructions\n\n${accountInstructions}\n`
|
|
1642
|
+
: ""
|
|
1643
|
+
}`;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/** A maintained Cloudflare REST API connection. */
|
|
1647
|
+
export function cloudflare(id: string, options: CloudflareOptions): Connector {
|
|
1648
|
+
const purpose = options.purpose.trim();
|
|
1649
|
+
if (!purpose) {
|
|
1650
|
+
throw new Error("cloudflare() requires a non-empty account purpose.");
|
|
1651
|
+
}
|
|
1652
|
+
const maxConcurrency = options.maxConcurrency ?? 6;
|
|
1653
|
+
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
|
|
1654
|
+
throw new Error("cloudflare() maxConcurrency must be a positive integer.");
|
|
1655
|
+
}
|
|
1656
|
+
const scope: Scoping = {
|
|
1657
|
+
base: options.baseUrl?.trim() || CLOUDFLARE_API_BASE,
|
|
1658
|
+
accountId: options.accountId?.trim() || undefined,
|
|
1659
|
+
zoneId: options.zoneId?.trim() || undefined,
|
|
1660
|
+
};
|
|
1661
|
+
return api(id, {
|
|
1662
|
+
title: options.title ?? "Cloudflare",
|
|
1663
|
+
description: `Cloudflare zones, DNS, cache, and platform resources — ${purpose}`,
|
|
1664
|
+
credential: options.credential ?? DEFAULT_CREDENTIAL,
|
|
1665
|
+
callAdmission: admissionPolicy(maxConcurrency),
|
|
1666
|
+
usageGuide: usageGuide(purpose, scope, options.instructions),
|
|
1667
|
+
// The schemas are hand-written and closed; a schema that cannot be
|
|
1668
|
+
// enforced is a bug in this file, not input to pass through.
|
|
1669
|
+
strictValidation: true,
|
|
1670
|
+
...(options.maxResultBytes !== undefined
|
|
1671
|
+
? { maxResultBytes: options.maxResultBytes }
|
|
1672
|
+
: {}),
|
|
1673
|
+
tools: buildTools(scope),
|
|
1674
|
+
async testCredential(value, ctx) {
|
|
1675
|
+
try {
|
|
1676
|
+
const { result } = await callCloudflare(
|
|
1677
|
+
scope.base,
|
|
1678
|
+
{ method: "GET", path: "/user/tokens/verify" },
|
|
1679
|
+
{
|
|
1680
|
+
...ctx,
|
|
1681
|
+
credential: { get: async () => value, getAll: async () => ({ value }) },
|
|
1682
|
+
},
|
|
1683
|
+
);
|
|
1684
|
+
const status = asRecord(result)["status"];
|
|
1685
|
+
return status === "active"
|
|
1686
|
+
? { ok: true, message: "Token verified: active." }
|
|
1687
|
+
: { ok: false, message: `Token status is "${String(status)}".` };
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
return {
|
|
1690
|
+
ok: false,
|
|
1691
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
},
|
|
1695
|
+
});
|
|
1696
|
+
}
|