@zackbart/connecta 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +6 -3
  3. package/bin/version.mjs +1 -1
  4. package/dist/call-recovery.js +25 -0
  5. package/dist/config-schema.d.ts +14 -7
  6. package/dist/config-schema.js +29 -10
  7. package/dist/connectors/guarded-fetch.d.ts +9 -1
  8. package/dist/connectors/guarded-fetch.js +14 -2
  9. package/dist/errors.d.ts +2 -0
  10. package/dist/invocation.js +32 -0
  11. package/dist/operator-ui/generated.js +2 -2
  12. package/dist/operator-ui/view.d.ts +9 -0
  13. package/dist/operator-ui/view.js +11 -0
  14. package/dist/program-writes.js +2 -47
  15. package/dist/provider.d.ts +7 -5
  16. package/dist/provider.js +3 -4
  17. package/dist/providers/_shared/rest/dispatch.js +41 -0
  18. package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
  19. package/dist/providers/_shared/rest/operation-index.js +527 -0
  20. package/dist/providers/_shared/rest/tools.d.ts +141 -0
  21. package/dist/providers/_shared/rest/tools.js +569 -0
  22. package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
  23. package/dist/providers/_shared/rest/value-safety.js +580 -0
  24. package/dist/providers/cloudflare/graphql.js +385 -0
  25. package/dist/providers/cloudflare/index.d.ts +47 -51
  26. package/dist/providers/cloudflare/index.js +188 -1997
  27. package/dist/providers/cloudflare/openapi.generated.js +3347 -0
  28. package/dist/providers/cloudflare/rest.d.ts +37 -0
  29. package/dist/providers/cloudflare/rest.js +1056 -0
  30. package/dist/providers/cloudflare/skill.generated.js +7 -4
  31. package/dist/providers/cloudflare/value-safety.js +1247 -0
  32. package/dist/providers/notion/index.d.ts +35 -31
  33. package/dist/providers/notion/index.js +60 -1499
  34. package/dist/providers/notion/openapi.generated.js +75 -0
  35. package/dist/providers/notion/rest.d.ts +24 -0
  36. package/dist/providers/notion/rest.js +1067 -0
  37. package/dist/providers/notion/skill.generated.js +5 -4
  38. package/dist/providers/notion/value-safety.js +31 -0
  39. package/dist/providers/stripe/index.d.ts +26 -20
  40. package/dist/providers/stripe/index.js +103 -130
  41. package/dist/providers/stripe/openapi.generated.js +649 -0
  42. package/dist/providers/stripe/rest.d.ts +14 -0
  43. package/dist/providers/stripe/rest.js +395 -0
  44. package/dist/providers/stripe/skill.generated.js +7 -5
  45. package/dist/providers/stripe/value-safety.js +705 -0
  46. package/dist/providers/vercel/index.d.ts +31 -22
  47. package/dist/providers/vercel/index.js +96 -853
  48. package/dist/providers/vercel/openapi.generated.js +456 -0
  49. package/dist/providers/vercel/rest.d.ts +20 -0
  50. package/dist/providers/vercel/rest.js +1002 -0
  51. package/dist/providers/vercel/skill.generated.js +4 -5
  52. package/dist/providers/vercel/value-safety.js +328 -0
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/dist/write-outcome.js +47 -0
  56. package/documentation/README.md +1 -0
  57. package/documentation/architecture.md +120 -8
  58. package/documentation/integrating.md +16 -5
  59. package/documentation/meta-tools.md +9 -2
  60. package/documentation/provider-auth-migration.md +307 -0
  61. package/documentation/provider-migration-0.29.md +17 -5
  62. package/examples/worker/README.md +2 -2
  63. package/package.json +3 -2
  64. package/templates/node/README.md +2 -2
  65. package/templates/node/package.json +1 -1
  66. package/dist/providers/vercel/mcp-ownership.js +0 -212
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.31.0 — 2026-10-09
6
+
7
+ Connecta 0.31.0 lets the auth mode choose how Stripe, Notion, Cloudflare and Vercel connect (#801). `auth: { type: "oauth" }` connects the vendor's hosted MCP server, OAuth only. A key connects Connecta's own REST connector with an operator-managed credential: Stripe `apiKey`, Notion `token`, Cloudflare `apiToken` or `globalApiKey`, and Vercel `token`. Each implementation is now complete on its own. This replaces the 0.29 split, in which the hosted server owned writes and the REST surface refused them, so a Cloudflare API token could not change a DNS record. Decision record 0005 explains the choice. Deployments that use none of these four providers need no changes.
8
+
9
+ The timing comes from Stripe. Its hosted MCP server stops accepting `sk_` secret keys and untagged `rk_` restricted keys on 2026-10-31, so a deployment that sends one of those keys to `mcp.stripe.com` loses Stripe on that date. Upgrade before then. Stripe still documents agent-tagged restricted keys for its MCP server, but Connecta's hosted Stripe connection is now OAuth only, and every Stripe key, tagged or not, uses the REST connector. For these four providers, Connecta no longer sends a bearer key, literal header or operator credential to the hosted MCP server. Other hosted providers, such as RevenueCat, Mixpanel and GitHub, are unchanged.
10
+
11
+ Every key connector serves the same four tools. `<vendor>_api_search` and `<vendor>_api_details` read an operation index compiled from the vendor's pinned OpenAPI document. `<vendor>_api_read` and `<vendor>_api_write` check each call's path and parameters against that index before sending it, and return `{ status, data, page? }`. The read tool admits GET plus reviewed read-only POSTs such as Stripe's invoice preview, Cloudflare analytics and Vercel observability queries. Each vendor keeps a few named tools. Stripe POST writes send the caller's `idempotencyKey`, or a generated one, as `Idempotency-Key` and return it; DELETE writes send none. When a deadline interrupts one of those POST writes, or its outcome is otherwise unknown, `uncertainCall.recovery` carries the generated key, or a caller-supplied key of at most 128 characters. A longer caller key is still sent to Stripe but not echoed there, since the caller already has it. Every Stripe request, v1 and v2, sends `Stripe-Version: 2026-09-30.endive`, the pinned index's version, whatever the account's default API version. Response shapes and accepted parameters follow that version, so check the fields programs rely on with `stripe_api_details`. Stripe's `connectedAccount` now works with an operator-managed key. `npm run providers:check` reports when a pinned index falls behind the vendor's live document.
12
+
13
+ Most existing configurations of these providers need edits. `auth` is now required, with no default, and omitting it throws an error that lists the valid types. `surface` is removed from Notion, Vercel and Cloudflare, along with Cloudflare `authentication` and its `credential` override. Stripe loses literal `headers` auth and hosted `credential` auth. Cloudflare bearer-token auth to `mcp.cloudflare.com` is removed, so those deployments move to `apiToken` and its REST tools. A credential stored under the same connector id carries over; run its Test action after upgrading.
14
+
15
+ Tool names change on the key paths. Cloudflare's `cloudflare_api_get` and `cloudflare_api_mutate` become `cloudflare_api_read` and `cloudflare_api_write`. `verify_api_token` and `verify_global_api_key` become `verify_credential`. Cloudflare's 23 hand-written reads give way to `cloudflare_api_read`, which returns unprojected results. Vercel's `vercel_api_get` and `vercel_api_mutate` become `vercel_api_read` and `vercel_api_write`, and `verify_project_domain`, `remove_project_domain` and `delete_deployment` are removed. Notion's token connector drops eight `integration_*` tools in favor of the generic ones. Stripe keeps its tool names with Connecta's contracts, but natural-language Sigma, metrics, docs search, the planner, reports and feedback exist only on the OAuth connection.
16
+
17
+ Several exports are removed. Cloudflare loses `CloudflareApiOptions`, `CloudflareMcpOptions`, `CloudflareConnectionOptions` and `CloudflareAuthentication`, plus `CLOUDFLARE_DNS_RECORD_TYPES` and `CLOUDFLARE_CONTENT_DNS_RECORD_TYPES`. Notion and Vercel lose their `ApiOptions`, `McpOptions` and `ConnectionOptions` types, and Stripe loses `StripeHeaderOptions`. Each `<Vendor>Options` is now a union of per-auth types, such as `StripeApiKeyOptions`, `NotionTokenOptions`, `VercelTokenOptions` and `CloudflareGlobalApiKeyOptions`.
18
+
19
+ A Cloudflare Global API Key reaches every account and zone its user can reach. `auth: { type: "globalApiKey" }` therefore requires `pin: { accountIds, zoneIds }` unless the configuration states `unpinned: true`. It never writes `/user` or `/memberships`. Pins deny by default and also apply to GraphQL tags and list results. An operation that names no account or zone is refused unless it was reviewed as safe. Pins are optional for scoped API tokens. A Global API Key cannot reach R2.
20
+
21
+ Every key connector applies one reviewed value-safety table. No REST tool response, success or error, returns a credential that grants access to the vendor or a third party, or a stored secret value. Operations that mint or hand out credentials are refused before they are sent. Stored secrets read as `"[redacted]"`. URLs that act as capabilities keep only their scheme and host, or lose their signing parameters. Reviewed operations withhold the vendor's error text unless their review keeps it. Results therefore lose fields they used to return. Stripe withholds client secrets, webhook signing secrets, Issuing card numbers and CVCs, and Checkout Session URLs other than those of guest payment sessions. Notion strips the signatures from pre-signed file URLs, so a returned file URL no longer downloads. Vercel returns `"[redacted]"` for environment values and header rule values. Cloudflare redacts Hyperdrive passwords, dispatch binding values and transform-rule header values. The guarantee assumes an honest vendor server and covers only the REST key connectors. Secrets that people type into free text, names or identifiers are out of scope. Hosted OAuth connections fall outside it: they return what the vendor's server returns, under Connecta's existing credential and `writeOnly` redaction.
22
+
23
+ The four provider entries now carry both transports and a pinned index. Measured gzip, Stripe grows from 171 KB to 243 KB, Notion from 191 KB to 216 KB, Vercel from 190 KB to 231 KB and Cloudflare from 193 KB to 338 KB, all within their stated caps. Vercel admits only reviewed and approved MCP clients to its hosted server. If it refuses authorization for that reason, the deployment needs Vercel's approval or a token connector. Vercel's hosted connection now lists its four environment variable tools. The two that can return decrypted values are classified as writes. Cloudflare's hosted `execute` is always a write, so a read-only pool on a Cloudflare OAuth connector reaches only `search`. Use an API token for read-only API access. Agent evals against fake vendors for all four providers are planned. `documentation/provider-auth-migration.md` gives the step-by-step migration for each provider.
24
+
25
+ ### Changed
26
+
27
+ - **Breaking:** `cloudflare()` now requires `auth`, which selects the implementation. `auth: { type: "oauth" }` connects Cloudflare's hosted MCP server (`search` and `execute`), OAuth only; bearer-token and credential auth to `mcp.cloudflare.com` are removed. `auth: { type: "apiToken" }` and `auth: { type: "globalApiKey" }` connect Connecta's own REST connector to the v4 API with an operator-managed credential: `cloudflare_api_search` and `cloudflare_api_details` read a pinned Cloudflare API index (3,336 operations, deprecated ones dropped), `cloudflare_api_read` (GET plus 47 reviewed read-only POSTs such as analytics, Analytics Engine and Log Explorer SQL, and KV bulk get) and `cloudflare_api_write` check every call against that index before sending it and return `{ status, data, page? }` with the envelope unwrapped, and the named tools are `verify_credential`, `list_accounts`, `list_zones`, `graphql_query` (read-only, refuses mutations), and `cloudflare_api_upload`. `accountId` and `zoneId` fill path placeholders; `pin: { accountIds, zoneIds }` restricts every call by default deny (an operation naming no account or zone is refused unless reviewed safe), including GraphQL tags and list results, and is required for a Global API Key unless `unpinned: true`. A reviewed value-safety table derived from the pinned spec gives every credential-bearing operation a verdict: credential producers (API tokens, Access service tokens, R2 temporary credentials, signing keys, direct-upload URLs, and more) and payment secrets are refused, stored and embedded secrets and environment and plain-text binding values are redacted on every method and tool, and operations that take or return credentials withhold vendor error text; URL Scanner captures are refused; a pinned connector's GraphQL documents hold one query and only fragments it uses, and a key-name, typed-secret, and URL-credential heuristic backs it up; a Global API Key never writes `/user` or `/memberships`. `surface`, `authentication`, `credential` overrides, the API-token mutation refusals, `verify_api_token`, `verify_global_api_key` (now `verify_credential`), `cloudflare_api_get`, `cloudflare_api_mutate`, the 23 hand-written reads from `get_zone` and `list_dns_records` to `list_pages_domains` (use `cloudflare_api_read`), `CLOUDFLARE_DNS_RECORD_TYPES`, `CLOUDFLARE_CONTENT_DNS_RECORD_TYPES`, and the `CloudflareApiOptions`, `CloudflareMcpOptions`, `CloudflareConnectionOptions`, and `CloudflareAuthentication` types are removed. See the provider auth migration guide.
28
+ - Record that the auth mode selects the provider implementation: OAuth uses the vendor's hosted MCP and keys use a Connecta REST connector, superseding the hosted MCP ownership rule for Stripe, Vercel, Cloudflare and Notion (decision record 0005). Each provider's change is its own entry.
29
+ - **Breaking:** `notion()` now requires `auth`, which selects the implementation, and `surface` is removed. `auth: { type: "oauth" }` connects Notion's hosted MCP server as the signed-in user, OAuth only, with the same reviewed catalog. `auth: { type: "token" }` connects Connecta's REST connector to `api.notion.com` as an internal integration with an operator-managed token: `notion_api_search`, `notion_api_details`, `notion_api_read`, and `notion_api_write` reach every operation in a pinned Notion API index (with the sent `Notion-Version` pinned to it), check each call against it before sending, admit Notion's read-only POST queries on the read tool, and return `{ status, data, page? }`, where `page.in: "body"` marks a body-borne cursor, `page.path` names a view query's continuation, and `hasMore` without `next` marks a cursorless cut-off such as meeting notes' limit. The token connector keeps `integration_search`, `integration_get_page`, `integration_get_page_content`, `integration_get_data_source_schema`, `integration_query_data_source`, `integration_create_page`, and `integration_append_blocks` (which now also takes `checklist`); `integration_get_page_property`, `integration_get_database`, `integration_list_users`, `integration_get_self`, `integration_list_comments`, `integration_update_page_properties`, `integration_trash_page`, and `integration_add_comment` are removed in favor of the generic tools. Notion's OAuth token endpoints and multipart file sends are refused. Notion's reconciliation table is retired. `npm run providers:spec` accepts `options.versionHeader` for documents that carry their API version in a header parameter. See the provider auth migration guide.
30
+ - **Breaking:** `stripe()` now requires `auth`, which selects the implementation. `auth: { type: "oauth" }` connects Stripe's hosted MCP server exactly as before, OAuth only. `auth: { type: "apiKey" }` with `mode` (and optional `connectedAccount`) connects Connecta's own REST connector to `api.stripe.com` with an operator-managed secret or restricted key: `stripe_api_search` and `stripe_api_details` read a pinned Stripe API index, `stripe_api_read` and `stripe_api_write` check every call against that index before sending it, form-encode v1 bodies, send v2 bodies as JSON, send `Stripe-Version: 2026-09-30.endive` (the pinned index's version) on every v1 and v2 request whatever the account's default API version, so response shapes and accepted parameters follow it (check them with `stripe_api_details`), return `{ status, data, page? }` with optional `select`, and on every POST write send the caller's `idempotencyKey` or a generated one as `Idempotency-Key` and return it (DELETE sends none); `get_stripe_account_info` and `get_balance_summary` are named reads. A key whose prefix contradicts `mode` is refused before any request, and `connectedAccount` now works with an operator-managed key. Literal `headers` and hosted `credential` auth are removed; omitting `auth` is refused with the valid types. Providers gain the `"dual"` kind, `variants()` accepts a nested discriminant such as `["auth", "type"]`, the operator UI shows whether a provider connector uses hosted MCP or REST, and `npm run providers:spec` compiles pinned OpenAPI documents into operation indexes that `check:providers-generated` checks offline and `providers:check` compares with the live document. A POST write's key also reaches the caller when the response body is lost or the call deadline interrupts it: `write_outcome_unknown` now carries `uncertainCall.recovery` with the generated key, or a caller-supplied key of at most 128 characters (a longer caller key is still sent to Stripe but not echoed, since the caller already has it). See the provider auth migration guide.
31
+ - **Breaking:** `vercel()` now requires `auth`, which selects the implementation, and `surface` is removed. `auth: { type: "oauth" }` connects Vercel's hosted MCP server as before, OAuth only; it now also lists `filter_project_envs`, `get_project_env`, `create_project_env`, and `edit_project_env`, with the two value-returning reads reviewed as writes. `auth: { type: "token" }` with optional `teamId` and `baseUrl` connects Connecta's own REST connector to `api.vercel.com` with an operator-managed access token, complete on its own: `vercel_api_search` and `vercel_api_details` read Vercel's OpenAPI document pinned by content hash, `vercel_api_read` (GET plus reviewed read-only POSTs such as `POST /v2/observability/query`) and `vercel_api_write` check every call against that index before sending it, apply the default team to operations that accept one (`teamId: null` for the personal account), and return `{ status, data, page? }` with `until` or `cursor` pagination. Named tools are `list_teams`, the value-safe environment variable tools, `get_deployment_build_logs`, `get_runtime_logs` (bounded by `waitMs` and `maxRows`), and `vercel_api_upload` for octet-stream operations. Value safety follows a reviewed per-operation table derived from the pinned spec (`scripts/value-safety.mjs` flags every operation whose response may carry a credential or secret value; a test fails on any without a verdict): credential minting, Connect authorization, project transfer requests, decrypted environment values, Global Config items and tokens, a domain move-out, KMS signing, and domain transfer codes are refused; environment values, protection-bypass secrets, deploy hook URLs, drain and webhook headers, signing secrets, and destination URLs beyond their origin, external route and redirect destinations beyond their origin, the args and values of every header, cookie, and query rule (route transforms and conditions, firewall conditions), team invite codes, synced Global Config items, and URL credentials are redacted on every path; a key-name heuristic covers the rest; and secret-family failures carry fixed messages. The 0.29 ownership refusals, reconciliation tables, `vercel_api_get`, `vercel_api_mutate`, `verify_project_domain`, `remove_project_domain`, `delete_deployment`, and the no-op `defaultPageSize` option are removed. See the provider auth migration guide.
32
+
33
+ ### Security
34
+
35
+ - **Breaking:** Every key connector (Stripe, Notion, Cloudflare, Vercel) now applies one reviewed value-safety mechanism: one detector over each pinned spec (the union of the former Cloudflare and Vercel detectors), one per-vendor table of `refuse`, `redact`, and `safe` verdicts, one redaction engine on every result, and one error policy. Stripe refuses operations that hand out a credential (ephemeral keys, Terminal connection tokens and onboarding links, v1 and v2 account links, login links, account, customer, and billing portal sessions, file link creation, Financial Connections session creation, and the meter event session), refuses Issuing `number` and `cvc` expansions on every Issuing path, and redacts client secrets, webhook and event destination signing secrets, Apps secret payloads, app-install authorization codes, Terminal Wi-Fi passwords, forwarded header values, and Issuing card numbers and CVCs; webhook, file link, and pre-signed import URLs keep only their origin. Resource rules keyed on Stripe's `object` apply these wherever an object appears (expansions, lists, events and their `previous_attributes`). Identity verification URLs are unchanged; a Checkout Session's URL is returned whole only for a provably guest payment session (no `customer`, `customer_account`, customer creation, or saved payment method options) and otherwise keeps only its origin. Notion withholds the signature parameters of pre-signed file URLs and refuses its OAuth endpoints through the table. Cloudflare's review grows by 259 operations, newly redacting Hyperdrive passwords, RealtimeKit storage credentials, transform-rule header values, dispatch binding values, and webhook destinations beyond their origin. Vercel replaces environment values with `"[redacted]"` instead of dropping them, and redacts audit-event invite codes, integration drain headers, and route header values. Where the former engines differed the stricter rule holds: a credential-named field loses its whole value, and every reviewed operation (or one whose request takes a credential-named field) withholds the vendor's error text unless its review keeps it. `providers:spec` (now run under tsx) checks every reviewed path against the pinned response schema. `RestVendor.valueSafety` is required and the `redact` hook is removed; Cloudflare's generated index no longer carries `secrets`. See the provider auth migration guide.
36
+
5
37
  ## 0.30.0 — 2026-10-09
6
38
 
7
39
  Connecta 0.30.0 makes large results, sign-in and rich output dependable for agents, on both direct calls and programs. The six-tool surface from 0.29.0 stays. We measured the alternative, folding everything into `execute_code`, against it on Sonnet 5.5 and GPT-6-Luna (#765). The code-only surface scored lower and made a duplicate billable write, so it was not adopted.
package/README.md CHANGED
@@ -67,9 +67,12 @@ operations each caller can reach.
67
67
 
68
68
  Connecta includes maintained connections for <!-- providers:start -->Basecamp, Breeze ChMS, Church Community Builder, Cloudflare, GitHub, Gmail, Google Docs, Google Drive, Google Forms, Google Sheets, Google Slides, Infisical, Linear, Mixpanel, Notion, Overflow, Planning Center, RevenueCat, Stripe, Tithe.ly, and Vercel<!-- providers:end -->.
69
69
  Each connection has its own setup guide.
70
- Notion, Vercel, and Cloudflare offer hosted MCP connections and selected HTTP
71
- API capabilities. You can also connect other remote MCP servers or define the
72
- operations you need from an HTTP API.
70
+ Stripe, Notion, Cloudflare, and Vercel use their hosted MCP servers when you
71
+ sign in with OAuth, and Connecta's own API connections when you use an API key,
72
+ a Cloudflare API token or Global API Key, a Notion integration token, or a
73
+ Vercel access token. You can also
74
+ connect other remote MCP servers or define the operations you need from an
75
+ HTTP API.
73
76
 
74
77
  Google Workspace connections use the account your deployment assigns to each
75
78
  signed-in person. A Workspace administrator authorizes them for your domain.
package/bin/version.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated from package.json by scripts/generate-version.mjs.
2
2
  // Workers-safe: no runtime filesystem read or deployment override.
3
- export const CONNECTA_VERSION = "0.30.0";
3
+ export const CONNECTA_VERSION = "0.31.0";
@@ -0,0 +1,25 @@
1
+ const MAX_FACTS = 4;
2
+ const MAX_TEXT = 128;
3
+ const NAME = /^[A-Za-z][A-Za-z0-9]*$/;
4
+ const facts = new WeakMap();
5
+ /**
6
+ * Record facts that let the caller reconcile or safely retry this call's
7
+ * write. Bounded: at most four string facts with short camelCase names; a fact
8
+ * outside the bound is dropped rather than truncated.
9
+ */
10
+ export function recordRecovery(ctx, recovery) {
11
+ const kept = { ...facts.get(ctx) };
12
+ for (const [name, value] of Object.entries(recovery)) {
13
+ if (Object.keys(kept).length >= MAX_FACTS && !Object.hasOwn(kept, name))
14
+ break;
15
+ if (!NAME.test(name) || name.length > 32 || typeof value !== "string" || value.length > MAX_TEXT)
16
+ continue;
17
+ kept[name] = value;
18
+ }
19
+ facts.set(ctx, Object.freeze(kept));
20
+ }
21
+ /** The facts a connector recorded on this call's context, if any. */
22
+ export function recoveryFor(ctx) {
23
+ const recorded = ctx ? facts.get(ctx) : undefined;
24
+ return recorded && Object.keys(recorded).length > 0 ? recorded : undefined;
25
+ }
@@ -25,9 +25,13 @@ type AnyField = Field<any, any, boolean>;
25
25
  export type Shape = {
26
26
  readonly [key: string]: AnyField;
27
27
  };
28
- /** A discriminated union of closed objects: `cases[value[key]]`, else `cases[otherwise]`. */
28
+ /**
29
+ * A discriminated union of closed objects: `cases[value[key[0]][key[1]]…]`,
30
+ * else `cases[otherwise]`. Each case is the whole object's shape, including
31
+ * the nested objects on the key path.
32
+ */
29
33
  interface Variants {
30
- readonly key: string;
34
+ readonly key: readonly string[];
31
35
  readonly cases: Readonly<Record<string, Shape>>;
32
36
  readonly otherwise?: string;
33
37
  }
@@ -116,13 +120,16 @@ export declare function instance<F extends AnyField>(field: F): F & {
116
120
  };
117
121
  /**
118
122
  * A closed object whose allowed keys depend on a discriminant, such as
119
- * `remoteMcp({ auth: { type: "headers", headers } })`. An omitted
123
+ * `remoteMcp({ auth: { type: "headers", headers } })`. The discriminant may
124
+ * be nested: `variants(["auth", "type"], cases)` selects the whole options
125
+ * shape from `options.auth.type`, so `{ auth: { type: "apiKey" }, mode }` and
126
+ * `{ auth: { type: "oauth" } }` accept different top-level keys. An omitted
120
127
  * discriminant selects `otherwise` when given; an omitted one without it, an
121
- * unrecognized one, or a value that is not an object throws with its path and
122
- * the valid values, so no union falls through to "no case". Only the walk
123
- * reads variants; the factory validates the remaining values itself.
128
+ * unrecognized one, or a value on the path that is not an object throws with
129
+ * its path and the valid values, so no union falls through to "no case". Only
130
+ * the walk reads variants; the factory validates the remaining values itself.
124
131
  */
125
- export declare function variants(key: string, cases: Readonly<Record<string, Shape>>, otherwise?: string): Field<unknown, unknown, true>;
132
+ export declare function variants(key: string | readonly string[], cases: Readonly<Record<string, Shape>>, otherwise?: string): Field<unknown, unknown, true>;
126
133
  /**
127
134
  * A closed object for the unknown-key walk only: each named key may hold
128
135
  * anything, which its factory validates. For the options of a built-in
@@ -183,16 +183,22 @@ export function instance(field) {
183
183
  }
184
184
  /**
185
185
  * A closed object whose allowed keys depend on a discriminant, such as
186
- * `remoteMcp({ auth: { type: "headers", headers } })`. An omitted
186
+ * `remoteMcp({ auth: { type: "headers", headers } })`. The discriminant may
187
+ * be nested: `variants(["auth", "type"], cases)` selects the whole options
188
+ * shape from `options.auth.type`, so `{ auth: { type: "apiKey" }, mode }` and
189
+ * `{ auth: { type: "oauth" } }` accept different top-level keys. An omitted
187
190
  * discriminant selects `otherwise` when given; an omitted one without it, an
188
- * unrecognized one, or a value that is not an object throws with its path and
189
- * the valid values, so no union falls through to "no case". Only the walk
190
- * reads variants; the factory validates the remaining values itself.
191
+ * unrecognized one, or a value on the path that is not an object throws with
192
+ * its path and the valid values, so no union falls through to "no case". Only
193
+ * the walk reads variants; the factory validates the remaining values itself.
191
194
  */
192
195
  export function variants(key, cases, otherwise) {
196
+ const path = typeof key === "string" ? [key] : [...key];
197
+ if (path.length === 0)
198
+ throw new Error("variants() requires a discriminant key.");
193
199
  return {
194
200
  ...opaque(),
195
- variants: { key, cases, ...(otherwise !== undefined ? { otherwise } : {}) },
201
+ variants: { key: path, cases, ...(otherwise !== undefined ? { otherwise } : {}) },
196
202
  };
197
203
  }
198
204
  /**
@@ -309,14 +315,27 @@ function inspectRecord(value, path, field) {
309
315
  * is never echoed.
310
316
  */
311
317
  function selectShape(inspected, path, variants) {
312
- const at = `${path}.${variants.key}`;
318
+ const at = `${path}.${variants.key.join(".")}`;
313
319
  const valid = Object.keys(variants.cases)
314
320
  .map((name) => JSON.stringify(name))
315
321
  .join(", ");
316
- const descriptor = inspected.own.find(([key]) => key === variants.key)?.[1];
317
- if (descriptor && isAccessor(descriptor))
318
- fail(`${at} must be a plain value, not a getter or setter.`);
319
- const discriminant = descriptor?.value;
322
+ // Walk the key path one plain object at a time, reading descriptors only.
323
+ let holder = inspected;
324
+ let discriminant;
325
+ for (const [depth, key] of variants.key.entries()) {
326
+ const step = `${path}.${variants.key.slice(0, depth + 1).join(".")}`;
327
+ const descriptor = holder.own.find(([own]) => own === key)?.[1];
328
+ if (descriptor && isAccessor(descriptor))
329
+ fail(`${step} must be a plain value, not a getter or setter.`);
330
+ discriminant = descriptor?.value;
331
+ if (depth === variants.key.length - 1 || discriminant === undefined)
332
+ break;
333
+ if (typeof discriminant !== "object" || discriminant === null || isArrayAt(discriminant, step)) {
334
+ fail(`${step} must be an object.`);
335
+ }
336
+ holder = inspectRecord(discriminant, step, OPAQUE);
337
+ discriminant = undefined;
338
+ }
320
339
  if (discriminant === undefined) {
321
340
  if (variants.otherwise !== undefined)
322
341
  return variants.cases[variants.otherwise];
@@ -1,6 +1,6 @@
1
1
  import type { ConnectorContext } from "../types.js";
2
2
  /** The methods a hand-written provider surface actually uses. */
3
- type GuardedMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
3
+ type GuardedMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE";
4
4
  export interface GuardedRequest {
5
5
  method: GuardedMethod;
6
6
  /**
@@ -21,6 +21,14 @@ export interface GuardedRequest {
21
21
  * shadow it.
22
22
  */
23
23
  headers?: Record<string, string | undefined>;
24
+ /**
25
+ * Headers whose values the tool returns to its caller by contract, such as
26
+ * an idempotency key. They are added after the request's sent secrets are
27
+ * registered, so a name like `Idempotency-Key` does not mark the value a
28
+ * credential and redact it from the result that must carry it. They may not
29
+ * collide with any other header.
30
+ */
31
+ visibleHeaders?: Record<string, string>;
24
32
  /** JSON request body. Serialized here, with the `Content-Type` to match. */
25
33
  body?: unknown;
26
34
  /**
@@ -343,6 +343,13 @@ export function guardedFetch(options) {
343
343
  ...(ctx.signal ? { signal: ctx.signal } : {}),
344
344
  };
345
345
  sentSecretsFor(ctx).request(url, init);
346
+ for (const [name, value] of Object.entries(request.visibleHeaders ?? {})) {
347
+ const shadow = hasHeader(headers, name);
348
+ if (shadow) {
349
+ throw new ConnectorCallError("invalid_args", `A ${provider} request sets the ${shadow} header twice.`);
350
+ }
351
+ headers[name] = value;
352
+ }
346
353
  response = await send(url.toString(), init, ctx);
347
354
  }
348
355
  catch (cause) {
@@ -356,8 +363,13 @@ export function guardedFetch(options) {
356
363
  }
357
364
  const declared = Number(response.headers.get("content-length"));
358
365
  // A prefix read takes only what it was asked for, so a long body is not
359
- // a reason to refuse it; the ceiling still bounds what is read.
360
- if (Number.isFinite(declared) && declared > limit && !(request.prefixOnly && response.ok)) {
366
+ // a reason to refuse it; the ceiling still bounds what is read. A HEAD
367
+ // response has no body: its Content-Length describes the GET
368
+ // representation, which is exactly what a HEAD asks about.
369
+ if (Number.isFinite(declared) &&
370
+ declared > limit &&
371
+ request.method !== "HEAD" &&
372
+ !(request.prefixOnly && response.ok)) {
361
373
  await response.body?.cancel().catch(() => { });
362
374
  throw oversized(provider, limit, `a declared ${declared} bytes`);
363
375
  }
package/dist/errors.d.ts CHANGED
@@ -282,6 +282,8 @@ export interface CallErrorDetails extends Partial<AuthorizationHandoff> {
282
282
  args?: unknown;
283
283
  argsOmitted?: true;
284
284
  argsRedacted?: true;
285
+ /** Facts the connector recorded before dispatch, such as a generated `idempotencyKey`. */
286
+ recovery?: Readonly<Record<string, string>>;
285
287
  };
286
288
  /** Explicit retry guidance; recovery never retries or mutates by itself. */
287
289
  retry?: string;
@@ -10,6 +10,8 @@ import { splitAddress } from "./registry.js";
10
10
  import { runEdge, withDeadlineEffect } from "./runtime/run.js";
11
11
  import { validateCatalogToolInput } from "./validate.js";
12
12
  import { sentSecretsFor, sentSecretsForRequest, trackCredentialReads } from "./sent-secrets.js";
13
+ import { recoveryFor } from "./call-recovery.js";
14
+ import { classifyWriteOutcome } from "./write-outcome.js";
13
15
  import { classificationDigest, recordAuthFailure, recordCallEntry, replayClassificationDigest, resolveInvocationAuth, } from "./invocation-auth.js";
14
16
  import { downstreamContinuation, recordDownstreamArgumentEcho, isDownstreamInputResult, assertDownstreamOutputSafe, } from "./downstream-input-context.js";
15
17
  function defined(values) {
@@ -166,6 +168,9 @@ export class InvocationService {
166
168
  let attempts = 0;
167
169
  let resultBytes;
168
170
  let dispatchedToConnector = false;
171
+ // The context a dispatched call ran with, read for recovery facts the
172
+ // connector recorded before an interrupting deadline (call-recovery.ts).
173
+ let dispatchContext;
169
174
  let preInvocationAuthFailure = false;
170
175
  let answered = false;
171
176
  const sentSecrets = sentSecretsForRequest(this.catalog.requestScope);
@@ -217,6 +222,10 @@ export class InvocationService {
217
222
  const enrich = (error, target) => {
218
223
  if (!target)
219
224
  return error;
225
+ const recovery = recoveryFor(dispatchContext);
226
+ const recoveryRetry = recovery?.["idempotencyKey"]
227
+ ? " A deliberate retry with uncertainCall.recovery.idempotencyKey and the exact original arguments cannot repeat the write while the vendor retains that key (Stripe keeps v1 keys at least 24 hours); after that, look the object up before retrying."
228
+ : "";
220
229
  if (isTimeoutFailure(error) && dispatchedToConnector && resolved?.definition.classification === "write") {
221
230
  const echoed = argumentEcho;
222
231
  return {
@@ -230,8 +239,10 @@ export class InvocationService {
230
239
  address: `${target.connector.id}.${target.toolName}`,
231
240
  ...echoed,
232
241
  ...("args" in echoed ? {} : { argsOmitted: true }),
242
+ ...(recovery ? { recovery } : {}),
233
243
  },
234
244
  retry: "Do not retry automatically. Check whether the write took effect first." +
245
+ recoveryRetry +
235
246
  (echoed.argsRedacted
236
247
  ? " Sensitive fields are omitted; use the original arguments if reconciliation requires another call."
237
248
  : "args" in echoed
@@ -239,6 +250,26 @@ export class InvocationService {
239
250
  : " The arguments could not be echoed safely; use the exact arguments you sent."),
240
251
  };
241
252
  }
253
+ // Any other dispatched write whose outcome is unknown (a lost response
254
+ // body, a 5xx, an unreadable reply) keeps the facts its connector
255
+ // recorded before dispatch, here and through program write accounting.
256
+ if (recovery &&
257
+ !error.uncertainCall &&
258
+ dispatchedToConnector &&
259
+ resolved?.definition.classification === "write" &&
260
+ classifyWriteOutcome({ ok: false, dispatched: true, answered, error }) === "unknown") {
261
+ const echoed = argumentEcho;
262
+ error = {
263
+ ...error,
264
+ uncertainCall: {
265
+ address: `${target.connector.id}.${target.toolName}`,
266
+ ...echoed,
267
+ ...("args" in echoed ? {} : { argsOmitted: true }),
268
+ recovery,
269
+ },
270
+ retry: error.retry ?? `Do not retry automatically. Check whether the write took effect first.${recoveryRetry}`,
271
+ };
272
+ }
242
273
  if (error.code === "auth_required" && target.connector.startAuth) {
243
274
  error = { ...error, code: "downstream_oauth_required" };
244
275
  }
@@ -464,6 +495,7 @@ export class InvocationService {
464
495
  const connectorContext = this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs, defer: this.catalog.defer }));
465
496
  trackCredentialReads(connectorContext);
466
497
  sentSecrets.include(sentSecretsFor(connectorContext));
498
+ dispatchContext = connectorContext;
467
499
  try {
468
500
  if (target.connector.credential) {
469
501
  if (!connectorContext.credential) {