From f0cef7b8cc6b93b3ef1b3936bdc9783ee22f2ce0 Mon Sep 17 00:00:00 2001 From: Zackery Bartolome Date: Sun, 30 Aug 2026 12:12:17 -0400 Subject: [PATCH] Reconcile provider contracts and outputs (#488) (#498) (#499) (#500) (#501) (#502) (#503) (#504) (#509) --- documentation/cloudflare.md | 20 +- documentation/operations.md | 2 +- documentation/provider-conventions.md | 6 +- .../results/issue-350-evidence.md | 3 + records/provider-audit.md | 6 +- scripts/drift/cloudflare-endpoints.json | 8 +- src/providers/cloudflare.ts | 309 +++++++++++++--- test/cloudflare-provider.test.ts | 341 ++++++++++++++++++ test/notion-provider.test.ts | 28 ++ 9 files changed, 653 insertions(+), 70 deletions(-) diff --git a/documentation/cloudflare.md b/documentation/cloudflare.md index 639f92c..ee3eaf7 100644 --- a/documentation/cloudflare.md +++ b/documentation/cloudflare.md @@ -153,6 +153,18 @@ every constrained field, endpoint-specific pagination bounds, and a description on every property. `test/cloudflare-provider.test.ts` walks the surface and asserts those properties rather than leaving them as a claim. +The per-setting zone tools accept Cloudflare's current non-empty setting ids, +including `webmcp_enabled` and `webmcp_packs`. Cloudflare's +[rendered setting reference](https://developers.cloudflare.com/api/resources/zones/subresources/settings/) +and current OpenAPI now agree on both beta ids. Results preserve the value and +the actual `editable` flag Cloudflare returned; Connecta does not apply the +document's defaults locally. + +KV namespace jurisdiction is creation-only. `create_kv_namespace` accepts +`eu`, `fedramp`, or `us`, and namespace reads preserve the returned value. +R2 uses a different enum: `default`, `eu`, `us`, or `fedramp`, sent through +`cf-r2-jurisdiction` by all eight named R2 operations. + ### What the named surface deliberately leaves out A named tool is a permanent line item in every deployment's catalog, so the @@ -206,7 +218,7 @@ connection *calls*, dropping the tool drops the row — so rather than by a recorded exception. A path reached only through a hatch is named by the caller, so it was never a touched endpoint. -The surviving 47 named tools all refuse malformed arguments locally, which is +The surviving 48 named tools all refuse malformed arguments locally, which is the one thing no escape hatch can do: a hatch's path is an opaque string, so it can only check that a path is a path. @@ -318,6 +330,12 @@ renamed to `camelCase`. A zone comes back as `id`, `name`, `status`, `paused`, `type`, `accountId`, `accountName`, `plan`, `nameServers`, and timestamps — not the forty-field object Cloudflare sends. +Every named tool declares useful top-level output keys. Zone settings, Worker +settings and deployments, KV namespaces and bulk operations, rulesets, R2 CORS, +and Pages resources use maintained result schemas instead of an open +"Cloudflare object" declaration. The three raw escape hatches remain open by +design because their result fields depend on the caller-supplied endpoint. + Paginated lists add a `page` object derived from `result_info`: `{ page, perPage, count, totalCount, totalPages, hasMore }`. `hasMore` is the field to branch on. diff --git a/documentation/operations.md b/documentation/operations.md index cb3fa22..eef7817 100644 --- a/documentation/operations.md +++ b/documentation/operations.md @@ -233,7 +233,7 @@ in. | `catalog.test.ts` | lexical ranking and the compact schema renderer — `const`, `allOf` beside siblings, `$ref`, the depth limit, per-schema caching, and 2020-12 keyword compatibility | | `clerk.test.ts` | protected-resource metadata, the browser sign-in config, OAuth and session tokens, cached best-effort activity labels with their caps, the hand-applied `azp` rejection, and the `allowedDomains` allowlist including every lookalike that must not be repaired into a match | | `cloudflare-access-auth.test.ts` | trusted `ctx.access` human and service identities, absent/error fail-closed behavior, service-token MCP admission without operator mutation, human same-origin mutation, and the Clerk-to-ambient shell switch | -| `cloudflare-provider.test.ts` | `cloudflare()` construction, tool surface, request building, projections, typed failures, and credential test | +| `cloudflare-provider.test.ts` | `cloudflare()` construction, tool surface, current R2 and KV jurisdictions, useful output declarations, request building, projections including additive provider fields, typed failures, and credential test | | `code-first-surface.test.ts` | the seven-tool surface itself — an executor required, every removed option and top-level tool refused, compact always-loaded routing pinned below 1,000 characters, complete on-demand usage served, and `connecta.ui` findable before connector search | | `codemode-compat.test.ts` | the `Executor` seam staying structurally compatible with `@cloudflare/codemode`'s `DynamicWorkerExecutor`, enforced by `tsc` | | `config.test.ts` | the grouped `ConnectaConfig` boundary — each group forwarding to its internals, malformed admission bounds failing construction, and unknown own-properties rejected by their complete path before construction does work | diff --git a/documentation/provider-conventions.md b/documentation/provider-conventions.md index 6112587..45ebe32 100644 --- a/documentation/provider-conventions.md +++ b/documentation/provider-conventions.md @@ -693,8 +693,10 @@ Cloudflare keep/prune half of that judgment was made in [#350](https://github.com/zackbart/connecta/issues/350): 30 keep, 18 improve, 3 prune, measured per tool in [`eval/current-version/results/issue-350-evidence.md`](https://github.com/zackbart/connecta/blob/main/eval/current-version/results/issue-350-evidence.md). -Its eighteen `improve` rows are H8 and H9 misses on tools that clearly earn -their place, so they are this audit's work, not a second removal argument. +Its eighteen `improve` rows were H8 and H9 misses on tools that clearly earned +their place. Issue #488 resolved them with useful output keys and maintained +projections; the measurements remain the reason those tools stayed rather than +a second removal argument. Each provider's own guide ([Cloudflare](./cloudflare.md), [Linear](./linear.md), [Mixpanel](./mixpanel.md), [Notion](./notion.md), diff --git a/eval/current-version/results/issue-350-evidence.md b/eval/current-version/results/issue-350-evidence.md index 54916c5..6b21b3a 100644 --- a/eval/current-version/results/issue-350-evidence.md +++ b/eval/current-version/results/issue-350-evidence.md @@ -19,6 +19,9 @@ went with `set_r2_cors` as the other half of one policy pair, which the rule below states as its own ground so the record does not pretend it was a defect. The surviving surface is 48 named tools. +The eighteen `improve` rows are historical findings. Issue #488 later replaced +their empty output declarations with useful keys and maintained projections. + ## The measurement The retired deterministic lane used one representative operator request per diff --git a/records/provider-audit.md b/records/provider-audit.md index 06bbb61..8b3e41d 100644 --- a/records/provider-audit.md +++ b/records/provider-audit.md @@ -36,7 +36,7 @@ Two things this audit deliberately does not decide: ## Cloudflare — hand-written HTTP -Fifty-five tools over the v4 REST API: fifty-two named, three guarded escape +Fifty-one tools over the v4 REST API: forty-eight named, three guarded escape hatches. The largest surface here and the one with the most to get wrong. | Convention | Verdict | Notes | @@ -49,14 +49,14 @@ hatches. The largest surface here and the one with the most to get wrong. | H5 exception | recorded | the three escape hatches' request parts — `query[]`, `headers[]`, `fields[]`, `files[]` — keep undescribed `name`/`value` members, because H5 collides with H7 there. `query` and `headers` are one shared constant the renderer inlines into all three hatches, and `cloudflare_api_upload` sits at 1,007 of the 1,024-byte budget this same audit brought it back under; describing name/value pairs the parent property has already named as name/value pairs would truncate the whole tool in discovery. The 21 properties are listed by path in `test/provider-conventions.test.ts` and asserted exactly, so a new one fails and so does a stale entry | | H6 whose bound | meets | exemplary. `pagingInputProperties` carries a three-way `bounds` vocabulary — `cloudflare`, `clamped`, `undocumented` — and the description says which one applies | | H7 compact fit | **missed → fixed** | `cloudflare_api_upload` rendered to 1,297 bytes. The refused-header list, inlined once per hatch, moved to the usage guide; the remaining upload descriptions were cut to the fact each adds. Now 1,007 | -| H8 output schemas | meets | 52 of 52 declare one | +| H8 output schemas | meets | all 48 named tools declare useful top-level keys; the three endpoint-generic hatches alone keep open result objects | | H9 projection | meets | reads project and rename; `raw: true` wherever the projection drops something recoverable; `cloudflare_api_get` is the universal unprojected read | | H10 pagination | **missed → fixed** | two conventions live here, which H10 allows, but only the guide said so. Three of the four cursor tools had a bare `nextCursor: { type: "string" }` with no description. Both ends now state it: `cursor` says the endpoint pages by cursor, `nextCursor` says it is the only signal and no `page` object is coming | | H11 errors | meets | mapped by the caller's next move, including the 400-with-credential-code case that is `auth_required` rather than `invalid_args`; every mapped status has a test asserting code and retryability | | H12 credential | meets | labeled fields per authentication mode, `testCredential` on the token path (`/user/tokens/verify`) and `testCredentials` on the Global API Key pair (`/user`), each reporting who it authenticated as | | H13 guide | **missed → fixed** | the guide was a bare string, so its summary was derived from the first content line — the zone-scoping rule, which varies per deployment and reads as an instruction. Now structured with a declared summary. `required` stays unset, deliberately: every named schema is complete enough to call on its own and the scoping convention is repeated on each `zoneId` and `accountId` property, so forcing the guide into context before every operation would buy nothing | | H14 hatch shape | meets | split GET / JSON-mutate / upload, the split is Connecta's, the GET tool is annotated read-only, paths are provider-relative and confined | -| H14 keep/prune | **open — [#350](https://github.com/zackbart/connecta/issues/350)** | whether each of the named tools beats the hatch on schema, projection, or safety routing is a reading that needs usage evidence. Out of scope here by the issue's own terms | +| H14 keep/prune | meets | #350 measured 30 keep, 18 improve, and 3 prune; the three removals shipped, and #488 resolved the output misses on the tools that stayed | ## Notion — hand-written HTTP diff --git a/scripts/drift/cloudflare-endpoints.json b/scripts/drift/cloudflare-endpoints.json index b385035..eddad61 100644 --- a/scripts/drift/cloudflare-endpoints.json +++ b/scripts/drift/cloudflare-endpoints.json @@ -164,13 +164,13 @@ "method": "PUT", "path": "/accounts/{account_id}/storage/kv/namespaces/{namespace_id}/bulk", "specRevision": "4.0.0", - "contract": "sha256:90b888706e5235d55a60c848672de347e6b68728c3525a818936d027c7e0281d" + "contract": "sha256:999dc0b7711a4e8052e4c2305819f6cfe38eff0acf91b4979d63f42cd9f25073" }, { "method": "POST", "path": "/accounts/{account_id}/storage/kv/namespaces/{namespace_id}/bulk/delete", "specRevision": "4.0.0", - "contract": "sha256:511865ab33597f3effa82a513e01fa6fad33141c1b3a73434aaaf5a8eb07c43e" + "contract": "sha256:62bb7e37e596f18517040d859bfb727325e4e4e659c0fbeddcc017b45156269c" }, { "method": "POST", @@ -290,13 +290,13 @@ "method": "GET", "path": "/zones/{zone_id}/settings/{setting_id}", "specRevision": "4.0.0", - "contract": "sha256:55b55adc0ed04a368710e274615faceca0c8456dd59694b68edf075c52060b28" + "contract": "sha256:d40d6e69038efbcb47a4e211d4e2c293c557a943fb383975c838245ee9c96888" }, { "method": "PATCH", "path": "/zones/{zone_id}/settings/{setting_id}", "specRevision": "4.0.0", - "contract": "sha256:edb944622554aca746c8cde0e4efe68e2434eb050b09129cb5410e3bb9e65a27" + "contract": "sha256:131e78bf0692cc655846a3ab748ca1738522244117eb3733c75d7032847a6512" } ] } diff --git a/src/providers/cloudflare.ts b/src/providers/cloudflare.ts index e7433d3..2ae4f5c 100644 --- a/src/providers/cloudflare.ts +++ b/src/providers/cloudflare.ts @@ -571,6 +571,16 @@ function projectZone(value: unknown): JsonRecord { }); } +function projectZoneSetting(value: unknown): JsonRecord { + const setting = asRecord(value); + return compact({ + id: setting["id"], + value: setting["value"], + editable: setting["editable"], + modifiedOn: setting["modified_on"], + }); +} + function projectDnsRecord(value: unknown): JsonRecord { const record = asRecord(value); const comment = record["comment"] ? record["comment"] : undefined; @@ -602,12 +612,41 @@ function projectWorkerScript(value: unknown): JsonRecord { }); } +function projectWorkerSettings(value: unknown): JsonRecord { + const settings = asRecord(value); + return compact({ + compatibilityDate: settings["compatibility_date"], + compatibilityFlags: settings["compatibility_flags"], + bindings: settings["bindings"], + limits: settings["limits"], + observability: settings["observability"], + placement: settings["placement"], + usageModel: settings["usage_model"], + tailConsumers: settings["tail_consumers"], + logpush: settings["logpush"], + }); +} + function projectKvNamespace(value: unknown): JsonRecord { const namespace = asRecord(value); return compact({ id: namespace["id"], title: namespace["title"], supportsUrlEncoding: namespace["supports_url_encoding"], + jurisdiction: namespace["jurisdiction"], + }); +} + +function projectKvBulkValues(value: unknown): JsonRecord { + const result = asRecord(value); + return compact({ values: result["values"] }); +} + +function projectKvBulkResult(value: unknown): JsonRecord { + const result = asRecord(value); + return compact({ + successfulKeyCount: result["successful_key_count"], + unsuccessfulKeys: result["unsuccessful_keys"], }); } @@ -635,6 +674,11 @@ function projectR2Object(value: unknown): JsonRecord { }); } +function projectR2Cors(value: unknown): JsonRecord { + const cors = asRecord(value); + return compact({ rules: cors["rules"] }); +} + function projectKvKey(value: unknown): JsonRecord { const key = asRecord(value); return compact({ @@ -1136,10 +1180,147 @@ function uploadBody(args: JsonRecord): { }; } -const OPEN_OBJECT_OUTPUT_SCHEMA: JsonSchema = { +const ZONE_SETTING_SCHEMA: JsonSchema = { + type: "object", + properties: { + id: { type: "string" }, + value: { + type: ["string", "number", "boolean", "array", "object", "null"], + items: {}, + additionalProperties: true, + }, + editable: { type: "boolean" }, + modifiedOn: { type: "string" }, + }, + required: ["id", "value"], +}; + +const RULESET_SCHEMA: JsonSchema = { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + kind: { type: "string" }, + phase: { type: "string" }, + description: { type: "string" }, + version: { type: "string" }, + lastUpdated: { type: "string" }, + rules: { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + }, + required: ["id", "name"], +}; + +const WORKER_SETTINGS_SCHEMA: JsonSchema = { + type: "object", + properties: { + compatibilityDate: { type: "string" }, + compatibilityFlags: { type: "array", items: { type: "string" } }, + bindings: { type: "array", items: { type: "object", additionalProperties: true } }, + limits: { type: "object", additionalProperties: true }, + observability: { type: "object", additionalProperties: true }, + placement: { type: "object", additionalProperties: true }, + usageModel: { type: "string" }, + tailConsumers: { type: "array", items: { type: "object", additionalProperties: true } }, + logpush: { type: "boolean" }, + }, +}; + +const WORKER_DEPLOYMENT_SCHEMA: JsonSchema = { + type: "object", + properties: { + id: { type: "string" }, + createdOn: { type: "string" }, + source: { type: "string" }, + strategy: { type: "string" }, + versions: { type: "array", items: { type: "object", additionalProperties: true } }, + }, + required: ["id"], +}; + +const KV_NAMESPACE_SCHEMA: JsonSchema = { + type: "object", + properties: { + id: { type: "string" }, + title: { type: "string" }, + supportsUrlEncoding: { type: "boolean" }, + jurisdiction: { type: "string", enum: ["eu", "fedramp", "us"] }, + renamed: { type: "boolean" }, + namespaceId: { type: "string" }, + }, +}; + +const KV_BULK_VALUES_SCHEMA: JsonSchema = { + type: "object", + properties: { + values: { + type: "object", + additionalProperties: true, + }, + }, + required: ["values"], +}; + +const KV_BULK_RESULT_SCHEMA: JsonSchema = { + type: "object", + properties: { + successfulKeyCount: { type: "number" }, + unsuccessfulKeys: { type: "array", items: { type: "string" } }, + }, +}; + +const R2_CORS_SCHEMA: JsonSchema = { + type: "object", + properties: { + rules: { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + }, + required: ["rules"], +}; + +const PAGES_DEPLOYMENT_SCHEMA: JsonSchema = { + type: "object", + properties: { + id: { type: "string" }, + projectName: { type: "string" }, + environment: { type: "string" }, + url: { type: "string" }, + aliases: { type: "array", items: { type: "string" } }, + stage: { type: "object", additionalProperties: true }, + latestStage: { type: "object", additionalProperties: true }, + createdOn: { type: "string" }, + modifiedOn: { type: "string" }, + }, + required: ["id"], +}; + +const PAGES_DOMAIN_SCHEMA: JsonSchema = { type: "object", - description: "Cloudflare's result object. Its fields depend on the endpoint.", - additionalProperties: true, + properties: { + id: { type: "string" }, + name: { type: "string" }, + status: { type: "string" }, + verificationData: { type: "object", additionalProperties: true }, + createdOn: { type: "string" }, + }, + required: ["name"], +}; + +const PAGES_PROJECT_SCHEMA: JsonSchema = { + type: "object", + properties: { + name: { type: "string" }, + subdomain: { type: "string" }, + domains: { type: "array", items: { type: "string" } }, + productionBranch: { type: "string" }, + createdOn: { type: "string" }, + latestDeployment: PAGES_DEPLOYMENT_SCHEMA, + }, + required: ["name"], }; const QUERY_INPUT_PROPERTY: JsonSchema = { @@ -1175,9 +1356,16 @@ const HEADERS_INPUT_PROPERTY: JsonSchema = { const R2_JURISDICTION_PROPERTY: JsonSchema = { type: "string", - enum: ["default", "eu", "fedramp"], + enum: ["default", "eu", "us", "fedramp"], description: - "Bucket jurisdiction. Omit for ordinary buckets; set eu or fedramp for jurisdictional buckets.", + "Bucket jurisdiction. Omit for ordinary buckets; set eu, us, or fedramp for jurisdictional buckets.", +}; + +const KV_JURISDICTION_PROPERTY: JsonSchema = { + type: "string", + enum: ["eu", "fedramp", "us"], + description: + "Creation-only namespace jurisdiction. Omit for an ordinary namespace; it cannot be changed by rename_kv_namespace.", }; const R2_BUCKET_NAME_PROPERTY: JsonSchema = { @@ -1251,7 +1439,7 @@ const SETTING_ID_PROPERTY: JsonSchema = { type: "string", minLength: 1, description: - "Cloudflare zone setting id, such as ssl, brotli, http3, or min_tls_version.", + "Cloudflare zone setting id, such as ssl, brotli, webmcp_enabled, or webmcp_packs.", }; const RECORD_ID_PROPERTY: JsonSchema = { @@ -1265,7 +1453,7 @@ const R2_BUCKET_SCHEMA: JsonSchema = { name: { type: "string" }, location: { type: "string" }, storageClass: { type: "string" }, - jurisdiction: { type: "string" }, + jurisdiction: { type: "string", enum: ["default", "eu", "us", "fedramp"] }, creationDate: { type: "string" }, }, required: ["name"], @@ -1743,16 +1931,18 @@ function buildTools( "zoneId", scope.zoneId, { - settingId: SETTING_ID_PROPERTY + settingId: SETTING_ID_PROPERTY, + raw: RAW_INPUT_PROPERTY }, ["settingId"], - OPEN_OBJECT_OUTPUT_SCHEMA, + ZONE_SETTING_SCHEMA, getResult( send, (args) => ({ method: "GET", path: `/zones/${encodeURIComponent(zoneArg(args))}/settings/${encodeURIComponent(requireString(args, "settingId"))}`, }), + (result, args) => args["raw"] === true ? result : projectZoneSetting(result), ), ), cfTool( @@ -1771,7 +1961,7 @@ function buildTools( } }, ["settingId", "value"], - OPEN_OBJECT_OUTPUT_SCHEMA, + ZONE_SETTING_SCHEMA, async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, @@ -1782,7 +1972,7 @@ function buildTools( }, ctx, ); - return result; + return projectZoneSetting(result); }, ), cfTool( @@ -1804,7 +1994,7 @@ function buildTools( { type: "object", properties: { - rulesets: { type: "array", items: OPEN_OBJECT_OUTPUT_SCHEMA }, + rulesets: { type: "array", items: RULESET_SCHEMA }, nextCursor: NEXT_CURSOR_OUTPUT_PROPERTY, }, required: ["rulesets"], @@ -1843,7 +2033,7 @@ function buildTools( } }, ["rulesetId"], - OPEN_OBJECT_OUTPUT_SCHEMA, + RULESET_SCHEMA, getResult( send, (args) => ({ @@ -1978,16 +2168,18 @@ function buildTools( "accountId", scope.accountId, { - scriptName: SCRIPT_NAME_PROPERTY + scriptName: SCRIPT_NAME_PROPERTY, + raw: RAW_INPUT_PROPERTY }, ["scriptName"], - OPEN_OBJECT_OUTPUT_SCHEMA, + WORKER_SETTINGS_SCHEMA, getResult( send, (args) => ({ method: "GET", path: `/accounts/${encodeURIComponent(accountArg(args))}/workers/scripts/${encodeURIComponent(requireString(args, "scriptName"))}/settings`, }), + (result, args) => args["raw"] === true ? result : projectWorkerSettings(result), ), ), cfTool( @@ -2000,7 +2192,7 @@ function buildTools( scriptName: SCRIPT_NAME_PROPERTY }, ["scriptName"], - listOutputSchema("deployments", OPEN_OBJECT_OUTPUT_SCHEMA), + listOutputSchema("deployments", WORKER_DEPLOYMENT_SCHEMA), async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, @@ -2028,7 +2220,7 @@ function buildTools( deploymentId: WORKER_DEPLOYMENT_ID_PROPERTY }, ["scriptName", "deploymentId"], - OPEN_OBJECT_OUTPUT_SCHEMA, + WORKER_DEPLOYMENT_SCHEMA, getResult( send, (args) => ({ @@ -2089,6 +2281,7 @@ function buildTools( id: { type: "string" }, title: { type: "string" }, supportsUrlEncoding: { type: "boolean" }, + jurisdiction: { type: "string", enum: ["eu", "fedramp", "us"] }, }, required: ["id", "title"], }), @@ -2118,7 +2311,7 @@ function buildTools( namespaceId: NAMESPACE_ID_PROPERTY }, ["namespaceId"], - OPEN_OBJECT_OUTPUT_SCHEMA, + KV_NAMESPACE_SCHEMA, getResult( send, (args) => ({ @@ -2140,17 +2333,21 @@ function buildTools( minLength: 1, maxLength: 512, description: "Human-readable namespace title.", - } + }, + jurisdiction: KV_JURISDICTION_PROPERTY }, ["title"], - OPEN_OBJECT_OUTPUT_SCHEMA, + KV_NAMESPACE_SCHEMA, async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, { method: "POST", path: `/accounts/${encodeURIComponent(accountArg(args))}/storage/kv/namespaces`, - body: { title: requireString(args, "title") }, + body: compact({ + title: requireString(args, "title"), + jurisdiction: args["jurisdiction"], + }), }, ctx, ); @@ -2173,7 +2370,7 @@ function buildTools( } }, ["namespaceId", "title"], - OPEN_OBJECT_OUTPUT_SCHEMA, + KV_NAMESPACE_SCHEMA, async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, @@ -2184,7 +2381,9 @@ function buildTools( }, ctx, ); - return result ?? { renamed: true, namespaceId: args["namespaceId"] }; + return result + ? projectKvNamespace(result) + : { renamed: true, namespaceId: args["namespaceId"] }; }, ), cfTool( @@ -2239,7 +2438,18 @@ function buildTools( { type: "object", properties: { - keys: { type: "array", items: OPEN_OBJECT_OUTPUT_SCHEMA }, + keys: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + expiration: { type: "number" }, + metadata: {}, + }, + required: ["name"], + }, + }, nextCursor: NEXT_CURSOR_OUTPUT_PROPERTY, }, required: ["keys"], @@ -2291,7 +2501,7 @@ function buildTools( } }, ["namespaceId", "keys"], - OPEN_OBJECT_OUTPUT_SCHEMA, + KV_BULK_VALUES_SCHEMA, async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, @@ -2306,7 +2516,7 @@ function buildTools( }, ctx, ); - return asRecord(result); + return projectKvBulkValues(result); }, ), cfTool( @@ -2360,7 +2570,7 @@ function buildTools( } }, ["namespaceId", "entries"], - OPEN_OBJECT_OUTPUT_SCHEMA, + KV_BULK_RESULT_SCHEMA, async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, @@ -2371,7 +2581,7 @@ function buildTools( }, ctx, ); - return asRecord(result); + return projectKvBulkResult(result); }, ), cfTool( @@ -2391,7 +2601,7 @@ function buildTools( } }, ["namespaceId", "keys"], - OPEN_OBJECT_OUTPUT_SCHEMA, + KV_BULK_RESULT_SCHEMA, async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, @@ -2402,7 +2612,7 @@ function buildTools( }, ctx, ); - return asRecord(result); + return projectKvBulkResult(result); }, ), cfTool( @@ -2707,7 +2917,7 @@ function buildTools( jurisdiction: R2_JURISDICTION_PROPERTY }, ["bucketName"], - OPEN_OBJECT_OUTPUT_SCHEMA, + R2_CORS_SCHEMA, getResult( send, (args) => ({ @@ -2715,7 +2925,7 @@ function buildTools( path: `/accounts/${encodeURIComponent(accountArg(args))}/r2/buckets/${encodeURIComponent(requireString(args, "bucketName"))}/cors`, headers: r2Headers(args), }), - asRecord, + projectR2Cors, ), ), cfTool( @@ -2729,26 +2939,7 @@ function buildTools( raw: RAW_INPUT_PROPERTY }, [], - listOutputSchema("projects", { - type: "object", - properties: { - name: { type: "string" }, - subdomain: { type: "string" }, - domains: { type: "array", items: { type: "string" } }, - productionBranch: { type: "string" }, - createdOn: { type: "string" }, - latestDeployment: { - type: "object", - properties: { - id: { type: "string" }, - environment: { type: "string" }, - url: { type: "string" }, - createdOn: { type: "string" }, - }, - }, - }, - required: ["name"], - }), + listOutputSchema("projects", PAGES_PROJECT_SCHEMA), async (args: JsonRecord, ctx) => { const { result, resultInfo } = await callCloudflare( send, @@ -2776,7 +2967,7 @@ function buildTools( raw: RAW_INPUT_PROPERTY }, ["projectName"], - OPEN_OBJECT_OUTPUT_SCHEMA, + PAGES_PROJECT_SCHEMA, getResult( send, (args) => ({ @@ -2802,7 +2993,7 @@ function buildTools( ...pagingInputProperties(1, 100, { bounds: "undocumented" }) }, ["projectName"], - listOutputSchema("deployments", OPEN_OBJECT_OUTPUT_SCHEMA), + listOutputSchema("deployments", PAGES_DEPLOYMENT_SCHEMA), async (args: JsonRecord, ctx) => { const { result, resultInfo } = await callCloudflare( send, @@ -2835,7 +3026,7 @@ function buildTools( raw: RAW_INPUT_PROPERTY }, ["projectName", "deploymentId"], - OPEN_OBJECT_OUTPUT_SCHEMA, + PAGES_DEPLOYMENT_SCHEMA, getResult( send, (args) => ({ @@ -2856,7 +3047,7 @@ function buildTools( deploymentId: RETRY_DEPLOYMENT_ID_PROPERTY }, ["projectName", "deploymentId"], - OPEN_OBJECT_OUTPUT_SCHEMA, + PAGES_DEPLOYMENT_SCHEMA, async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, @@ -2880,7 +3071,7 @@ function buildTools( deploymentId: ROLLBACK_DEPLOYMENT_ID_PROPERTY }, ["projectName", "deploymentId"], - OPEN_OBJECT_OUTPUT_SCHEMA, + PAGES_DEPLOYMENT_SCHEMA, async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, @@ -2925,7 +3116,7 @@ function buildTools( projectName: PAGES_PROJECT_NAME_PROPERTY }, ["projectName"], - listOutputSchema("domains", OPEN_OBJECT_OUTPUT_SCHEMA), + listOutputSchema("domains", PAGES_DOMAIN_SCHEMA), async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, @@ -2949,7 +3140,7 @@ function buildTools( domain: { type: "string", minLength: 1, description: "Fully qualified custom domain to attach." } }, ["projectName", "domain"], - OPEN_OBJECT_OUTPUT_SCHEMA, + PAGES_DOMAIN_SCHEMA, async (args: JsonRecord, ctx) => { const { result } = await callCloudflare( send, diff --git a/test/cloudflare-provider.test.ts b/test/cloudflare-provider.test.ts index b4dd839..110e5d9 100644 --- a/test/cloudflare-provider.test.ts +++ b/test/cloudflare-provider.test.ts @@ -436,6 +436,13 @@ describe("cloudflare() tool surface", () => { expect(input["additionalProperties"], tool.name).toBe(false); expect(Array.isArray(input["required"]), tool.name).toBe(true); expect(tool.outputSchema, `${tool.name} needs an outputSchema`).toBeTruthy(); + const output = tool.outputSchema as Record; + if (!tool.name.startsWith("cloudflare_api_")) { + expect( + Object.keys((output["properties"] ?? {}) as Record), + `${tool.name} needs useful output keys`, + ).not.toHaveLength(0); + } const properties = input["properties"] as Record; for (const key of input["required"] as string[]) { expect(properties, `${tool.name}.${key} is required but undeclared`) @@ -488,6 +495,39 @@ describe("cloudflare() tool surface", () => { } }); + it("publishes the current R2 and KV jurisdiction values", async () => { + const tools = await connection().listTools(contextWithToken()); + const properties = (name: string) => + (toolNamed(tools, name).inputSchema as any).properties as Record< + string, + Record + >; + + for (const name of [ + "list_r2_buckets", + "get_r2_bucket", + "create_r2_bucket", + "update_r2_bucket", + "delete_r2_bucket", + "get_r2_cors", + "list_r2_objects", + "delete_r2_object", + ]) { + expect(properties(name)["jurisdiction"]?.["enum"], name).toEqual([ + "default", + "eu", + "us", + "fedramp", + ]); + } + expect(properties("create_kv_namespace")["jurisdiction"]?.["enum"]).toEqual([ + "eu", + "fedramp", + "us", + ]); + expect(properties("rename_kv_namespace")).not.toHaveProperty("jurisdiction"); + }); + it("requires a scope argument only when the deployment declares no default", async () => { const unscoped = await connection().listTools(contextWithToken()); expect( @@ -810,6 +850,27 @@ describe("cloudflare() request building", () => { ); }); + it("forwards the R2 us jurisdiction on reads and writes", async () => { + stubFetch( + { body: { success: true, result: { name: "assets", jurisdiction: "us" } } }, + { body: { success: true, result: { name: "archive", jurisdiction: "us" } } }, + ); + const connector = connection(); + await connector.callTool( + "get_r2_bucket", + { accountId: "acct-1", bucketName: "assets", jurisdiction: "us" }, + contextWithToken(), + ); + await connector.callTool( + "create_r2_bucket", + { accountId: "acct-1", bucketName: "archive", jurisdiction: "us" }, + contextWithToken(), + ); + for (const call of calls) { + expect(call.init.headers).toMatchObject({ "cf-r2-jurisdiction": "us" }); + } + }); + it("refuses R2 object keys whose dot segments would retarget deletion", async () => { const connector = connection(); for (const objectKey of ["..", "../cors", "folder/./item"]) { @@ -844,6 +905,44 @@ describe("cloudflare() request building", () => { ]); }); + it("sends KV jurisdiction only when namespace creation asks for it", async () => { + stubFetch( + ...["eu", "fedramp", "us", undefined].map((jurisdiction) => ({ + body: { + success: true, + result: { id: `ns-${jurisdiction ?? "default"}`, title: "Cache", jurisdiction }, + }, + })), + ); + const connector = connection(); + for (const jurisdiction of ["eu", "fedramp", "us", undefined]) { + await connector.callTool( + "create_kv_namespace", + { + accountId: "acct-1", + title: "Cache", + ...(jurisdiction ? { jurisdiction } : {}), + }, + contextWithToken(), + ); + } + expect(calls.map((_call, index) => bodyOf(index))).toEqual([ + { title: "Cache", jurisdiction: "eu" }, + { title: "Cache", jurisdiction: "fedramp" }, + { title: "Cache", jurisdiction: "us" }, + { title: "Cache" }, + ]); + + await expect( + connector.callTool( + "create_kv_namespace", + { accountId: "acct-1", title: "Cache", jurisdiction: "default" }, + contextWithToken(), + ), + ).rejects.toMatchObject({ code: "invalid_args" }); + expect(calls).toHaveLength(4); + }); + it("authenticates with a bearer token against the documented v4 base", async () => { stubFetch({ body: { success: true, result: { id: "tok", status: "active" } }, @@ -1013,6 +1112,248 @@ describe("cloudflare() request building", () => { }); describe("cloudflare() projections", () => { + it("projects zone settings without inventing provider defaults", async () => { + stubFetch( + { + body: { + success: true, + result: { + id: "proxy_read_timeout", + value: 100, + editable: false, + modified_on: "2026-08-30T00:00:00Z", + }, + }, + }, + { + body: { + success: true, + result: { + id: "webmcp_packs", + value: "dom,credentials", + editable: true, + }, + }, + }, + ); + const connector = connection(); + const current = await connector.callTool( + "get_zone_setting", + { zoneId: "zone-1", settingId: "proxy_read_timeout" }, + contextWithToken(), + ); + expect(current).toEqual({ + id: "proxy_read_timeout", + value: 100, + editable: false, + modifiedOn: "2026-08-30T00:00:00Z", + }); + + const updated = await connector.callTool( + "update_zone_setting", + { + zoneId: "zone-1", + settingId: "webmcp_packs", + value: "dom,credentials", + }, + contextWithToken(), + ); + expect(bodyOf(1)).toEqual({ value: "dom,credentials" }); + expect(updated).toEqual({ + id: "webmcp_packs", + value: "dom,credentials", + editable: true, + }); + }); + + it("preserves new ruleset and Worker settings fields in bounded results", async () => { + stubFetch( + { + body: { + success: true, + result: { + id: "ruleset-1", + name: "origin controls", + rules: [ + { + id: "rule-1", + action_parameters: { origin_range_requests: { mode: "on" } }, + }, + ], + }, + }, + }, + { + body: { + success: true, + result: { + compatibility_date: "2026-08-30", + bindings: [ + { + type: "vpc_network", + name: "NETWORK", + network_id: "cf1:network", + identity: "runtime-email-alpha", + }, + ], + observability: { + enabled: true, + redact_query_string: false, + traces: { propagation_policy: null }, + }, + }, + }, + }, + { + body: { + success: true, + result: [ + { + id: "worker-a", + observability: { + redact_query_string: false, + traces: { propagation_policy: null }, + }, + }, + ], + }, + }, + ); + const connector = connection(); + const ruleset = await connector.callTool( + "get_zone_ruleset", + { zoneId: "zone-1", rulesetId: "ruleset-1" }, + contextWithToken(), + ); + expect((ruleset as any).rules[0].action_parameters.origin_range_requests).toEqual({ + mode: "on", + }); + + const settings = await connector.callTool( + "get_worker_settings", + { accountId: "acct-1", scriptName: "worker-a" }, + contextWithToken(), + ); + expect((settings as any).bindings[0].identity).toBe("runtime-email-alpha"); + expect((settings as any).observability).toEqual({ + enabled: true, + redact_query_string: false, + traces: { propagation_policy: null }, + }); + + const raw = await connector.callTool( + "list_worker_scripts", + { accountId: "acct-1", raw: true }, + contextWithToken(), + ); + expect((raw as any).scripts[0].observability.traces.propagation_policy).toBeNull(); + }); + + it("projects namespace jurisdiction and bulk operation results", async () => { + stubFetch( + { + body: { + success: true, + result: [{ id: "ns-1", title: "Cache", jurisdiction: "us" }], + result_info: { page: 1, per_page: 20, count: 1, total_pages: 1 }, + }, + }, + { body: { success: true, result: { id: "ns-1", title: "Cache", jurisdiction: "us" } } }, + { body: { success: true, result: { id: "ns-2", title: "EU", jurisdiction: "eu" } } }, + { body: { success: true, result: { id: "ns-1", title: "Renamed", jurisdiction: "us" } } }, + { body: { success: true, result: { values: { feature: "on" } } } }, + { + body: { + success: true, + result: { successful_key_count: 2, unsuccessful_keys: ["later"] }, + }, + }, + { + body: { + success: true, + result: { successful_key_count: 1, unsuccessful_keys: [] }, + }, + }, + ); + const connector = connection(); + const listed = await connector.callTool( + "list_kv_namespaces", + { accountId: "acct-1" }, + contextWithToken(), + ); + const fetched = await connector.callTool( + "get_kv_namespace", + { accountId: "acct-1", namespaceId: "ns-1" }, + contextWithToken(), + ); + const created = await connector.callTool( + "create_kv_namespace", + { accountId: "acct-1", title: "EU", jurisdiction: "eu" }, + contextWithToken(), + ); + const renamed = await connector.callTool( + "rename_kv_namespace", + { accountId: "acct-1", namespaceId: "ns-1", title: "Renamed" }, + contextWithToken(), + ); + expect((listed as any).namespaces[0].jurisdiction).toBe("us"); + expect((fetched as any).jurisdiction).toBe("us"); + expect((created as any).jurisdiction).toBe("eu"); + expect((renamed as any).jurisdiction).toBe("us"); + + const values = await connector.callTool( + "bulk_get_kv_values", + { accountId: "acct-1", namespaceId: "ns-1", keys: ["feature"] }, + contextWithToken(), + ); + const written = await connector.callTool( + "bulk_write_kv_values", + { + accountId: "acct-1", + namespaceId: "ns-1", + entries: [{ key: "feature", value: "on" }], + }, + contextWithToken(), + ); + const deleted = await connector.callTool( + "bulk_delete_kv_values", + { accountId: "acct-1", namespaceId: "ns-1", keys: ["feature"] }, + contextWithToken(), + ); + expect(values).toEqual({ values: { feature: "on" } }); + expect(written).toEqual({ successfulKeyCount: 2, unsuccessfulKeys: ["later"] }); + expect(deleted).toEqual({ successfulKeyCount: 1, unsuccessfulKeys: [] }); + }); + + it("declares and returns the R2 CORS rule collection", async () => { + stubFetch({ + body: { + success: true, + result: { + rules: [ + { + id: "browser", + allowed: { methods: ["GET"], origins: ["https://example.com"] }, + }, + ], + }, + }, + }); + const result = await connection().callTool( + "get_r2_cors", + { accountId: "acct-1", bucketName: "assets" }, + contextWithToken(), + ); + expect(result).toEqual({ + rules: [ + { + id: "browser", + allowed: { methods: ["GET"], origins: ["https://example.com"] }, + }, + ], + }); + }); + it("unwraps the zone envelope and surfaces pagination", async () => { stubFetch({ body: { diff --git a/test/notion-provider.test.ts b/test/notion-provider.test.ts index 20a2c37..1b13128 100644 --- a/test/notion-provider.test.ts +++ b/test/notion-provider.test.ts @@ -793,6 +793,34 @@ describe("notion() pagination", () => { }); }); + it("passes every verification inequality value through unchanged", async () => { + const connector = build(); + for (const value of ["verified", "expired", "none"]) { + queue({ body: { results: [], has_more: false } }); + await call(connector, "query_data_source", { + data_source_id: "ds-1", + filter: { + property: "Verification", + verification: { does_not_equal: value }, + }, + }); + } + expect(calls.map((request) => request.body?.filter)).toEqual([ + { + property: "Verification", + verification: { does_not_equal: "verified" }, + }, + { + property: "Verification", + verification: { does_not_equal: "expired" }, + }, + { + property: "Verification", + verification: { does_not_equal: "none" }, + }, + ]); + }); + it("builds search filter and sort objects from flat arguments", async () => { queue({ body: { results: [], has_more: false } }); await call(build(), "search", {