@zackbart/connecta 0.31.0 → 0.32.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 (95) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +15 -13
  3. package/bin/connecta.mjs +1 -0
  4. package/bin/version.mjs +1 -1
  5. package/dist/auth-elicitation.js +1 -1
  6. package/dist/catalog-service.js +7 -2
  7. package/dist/config-value-sources.js +6 -3
  8. package/dist/config.d.ts +26 -8
  9. package/dist/config.js +38 -16
  10. package/dist/connector-access.d.ts +2 -1
  11. package/dist/connectors/guarded-fetch.d.ts +9 -0
  12. package/dist/connectors/guarded-fetch.js +7 -0
  13. package/dist/describe-config.d.ts +6 -3
  14. package/dist/describe-config.js +3 -2
  15. package/dist/errors.d.ts +1 -1
  16. package/dist/errors.js +1 -0
  17. package/dist/execute.js +16 -14
  18. package/dist/index.js +4 -3
  19. package/dist/invocation.js +7 -4
  20. package/dist/meta-tools.js +17 -19
  21. package/dist/operator-record.d.ts +2 -2
  22. package/dist/operator-record.js +2 -0
  23. package/dist/operator-ui/contract.d.ts +5 -4
  24. package/dist/operator-ui/generated.js +2 -2
  25. package/dist/operator-ui/model.d.ts +1 -1
  26. package/dist/operator-ui/view.js +1 -0
  27. package/dist/program-writes.js +2 -2
  28. package/dist/provider.d.ts +2 -3
  29. package/dist/providers/_shared/google/workspace.js +1 -1
  30. package/dist/providers/breeze/skill.generated.js +2 -2
  31. package/dist/providers/cloudflare/skill.generated.js +1 -1
  32. package/dist/providers/docs/index.d.ts +1 -1
  33. package/dist/providers/docs/index.js +2 -2
  34. package/dist/providers/drive/index.d.ts +1 -1
  35. package/dist/providers/drive/index.js +1 -1
  36. package/dist/providers/forms/index.d.ts +1 -1
  37. package/dist/providers/forms/index.js +2 -2
  38. package/dist/providers/github/skill.generated.js +2 -2
  39. package/dist/providers/gmail/index.d.ts +1 -1
  40. package/dist/providers/gmail/index.js +274 -23
  41. package/dist/providers/gmail/skill.generated.js +2 -2
  42. package/dist/providers/linear/index.js +1 -1
  43. package/dist/providers/mixpanel/index.d.ts +27 -12
  44. package/dist/providers/mixpanel/index.js +58 -6
  45. package/dist/providers/mixpanel/operations.js +20 -0
  46. package/dist/providers/mixpanel/rest.js +603 -0
  47. package/dist/providers/mixpanel/skill.generated.js +3 -2
  48. package/dist/providers/mixpanel/value-safety.js +16 -0
  49. package/dist/providers/notion/index.js +16 -0
  50. package/dist/providers/notion/rest.js +138 -19
  51. package/dist/providers/notion/skill.generated.js +4 -4
  52. package/dist/providers/planning-center/index.d.ts +27 -0
  53. package/dist/providers/planning-center/index.js +286 -24
  54. package/dist/providers/planning-center/skill.generated.js +24 -4
  55. package/dist/providers/sheets/index.d.ts +1 -1
  56. package/dist/providers/sheets/index.js +3 -3
  57. package/dist/providers/slides/index.d.ts +1 -1
  58. package/dist/providers/slides/index.js +3 -3
  59. package/dist/providers/stripe/skill.generated.js +2 -2
  60. package/dist/providers/vercel/index.js +2 -2
  61. package/dist/providers/vercel/skill.generated.js +2 -2
  62. package/dist/providers/wispr-flow/index.d.ts +14 -0
  63. package/dist/providers/wispr-flow/index.js +117 -0
  64. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  65. package/dist/routes/mcp.js +30 -10
  66. package/dist/routes/operator.js +14 -7
  67. package/dist/routes/ui-config.js +2 -2
  68. package/dist/server.js +3 -0
  69. package/dist/skills.js +5 -4
  70. package/dist/tool-safety.js +13 -5
  71. package/dist/types.d.ts +1 -1
  72. package/dist/ui.d.ts +1 -1
  73. package/dist/ui.js +1 -1
  74. package/dist/usage-guide.js +3 -3
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/documentation/architecture.md +38 -23
  78. package/documentation/auth.md +86 -27
  79. package/documentation/code-mode.md +81 -72
  80. package/documentation/deploying.md +10 -5
  81. package/documentation/integrating.md +56 -4
  82. package/documentation/meta-tools.md +44 -37
  83. package/documentation/operating.md +11 -8
  84. package/documentation/operator-tests.md +2 -3
  85. package/documentation/operator-ui.md +9 -3
  86. package/documentation/provider-auth-migration.md +5 -4
  87. package/documentation/provider-migration-0.29.md +1 -1
  88. package/examples/worker/AGENTS.md +5 -0
  89. package/examples/worker/src/connecta.config.ts +5 -0
  90. package/package.json +5 -1
  91. package/templates/node/AGENTS.md +6 -4
  92. package/templates/node/README.md +4 -2
  93. package/templates/node/package.json +1 -1
  94. package/templates/node/src/connecta.config.ts +5 -0
  95. package/dist/tool-safety.d.ts +0 -9
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.32.0 — 2026-10-14
6
+
7
+ Connecta 0.32.0 makes each endpoint simply writable or read-only, and lets a deployment keep chosen callers, such as an agent's token, off plain `/mcp` (#820, #821). A deployment can then give an agent one token and per-person pools, and the agent sees only the pool its client picks for each turn.
8
+
9
+ Every deployment must now choose `readOnly`, and this breaks existing configurations (#822). `readOnly` replaces the pool `trust` setting. The deployment's value applies to `/mcp`, and each pool inherits it unless it sets its own. A deployment that sets neither `readOnly` nor `trust` fails at startup and asks the operator to choose. One that still sets `trust` anywhere fails with a message naming `readOnly`. Replace `trust: "trusted"` with `readOnly: false`, and `trust: "read-only"` with `readOnly: true`. On a read-only endpoint, write tools are left out of search and describe, and every write is refused with `read_only_endpoint` before validation or dispatch, whether it comes from `call_tool`, `call_destructive_tool` or a program. That includes `call_destructive_tool`, which a read-only pool used to allow with host approval. On a writable endpoint, `call_destructive_tool` and programs write, and the MCP client owns approval. Guides are documentation and are not filtered, so a guide may name a tool the endpoint refuses. A deployment that set no `trust` used to let programs read only and allow host-approved direct writes. Choose `readOnly: true` to stop all writes, or `readOnly: false` to let programs write too. A pool that relied on the old read-only default now inherits the deployment's value, so set `readOnly: true` on every pool that must stay read-only. The Node template and Worker example set `readOnly: false`.
10
+
11
+ A new top-level `grant(identity)` admits callers to plain `/mcp` the way pool grants admit callers to pools (#824). Without it, everyone admitted today is still admitted. Only a literal `true` admits. A refused caller gets the same 404 as a refused or undeclared pool, and can still open any pool whose own grant admits it. Result paging and the operator UI's catalog and config reads apply the same grant, so a refused token cannot read the full catalog through `/ui`. OAuth protected-resource metadata stays public, so MCP clients can still discover where to sign in. Deployments that don't set `grant` need no changes.
12
+
13
+ `mixpanel()` gains a REST implementation, `via: "api"`, for what the hosted MCP server cannot answer (#825). It covers raw event export with a size bound, one user's activity stream, profile queries, saved Insights and Funnels reports, retention queries and event import. It authenticates with an operator-managed service account, takes `project_id` per call (with optional `defaultProjectId`) and lists the projects the account can reach. Profile updates are left out because Mixpanel's profile endpoint requires the project token, which service-account credentials alone don't provide. The hosted MCP implementation stays the default, so existing Mixpanel configurations are unchanged. A deployment that wants both declares two connections.
14
+
15
+ The release also adds `excludeApps` to `planningCenter()`, Gmail `get_attachment` and a maintained Wispr Flow provider, and updates the Notion connector to Notion's current limits and errors. CI now runs its checks in parallel, and the redaction tests no longer depend on wall-clock time.
16
+
17
+ ### Added
18
+
19
+ - Let deployments configure a grant for plain `/mcp`, independently of named pools. Refusals use the same 404 as refused pools, result paging and operator catalog reads respect the grant while OAuth metadata discovery stays public, and the operator UI and configuration snapshot report whether it is configured.
20
+ - Read Gmail attachment text and small binary files with `get_attachment`. Attachment listings now expose immutable `partId` identifiers for reads across rotated download ids and for inline attachments. Binary content uses standard base64 for Drive uploads; downloads and whole results have explicit size limits.
21
+
22
+ Parsed forwarded attachments include their headers and readable body after checking descendant sizes. MIME charset parameters accept whitespace and quoted values, unsupported charsets are reported, and inconsistent attachment byte counts are refused.
23
+ - Let `mixpanel()` use service-account REST APIs with `via: "api"` for reachable projects, bounded raw event export, profile activity and queries, Insights and Funnels reports, retention queries and event import. Existing hosted MCP configurations remain the default. Retention queries cap intervals at 90 only for day units, as Mixpanel documents.
24
+ - `planningCenter()` accepts `excludeApps`, a list of Planning Center products the connection never reaches, such as `["giving"]` for a deployment that keeps agents away from donor and financial data. An excluded product's named tools leave the catalog; the description, usage guide, and version pins stop naming it; and `pco_api_get` and `pco_api_mutate` refuse its paths with `provider_permission_denied` before sending, checked against the exact URL the transport would fetch. While any product is excluded, a hatch path must be literal (no dot segments, empty segments, backslashes, whitespace, or percent-encoded delimiters), and the connection also removes the People list tools and refuses People lists, list results, rules, and conditions by path or `include`, since a list's conditions can draw on any product; refuses webhook event and delivery payloads and any change to an existing webhook subscription; and accepts a new subscription only to a reachable product's event. Any product except People can be excluded; an unknown name or People throws at construction. The default excludes nothing and is unchanged. Exclusion does not hide records copied into a reachable product by the church's automations or staff, such as People workflow cards a Giving automation creates for first-time donors, notes, or custom fields; for a hard boundary, use a personal access token from a user with No Access in the excluded product, since Planning Center sets permissions per product for each user, and treat `excludeApps` as defense in depth.
25
+ - Add a maintained Wispr Flow hosted-MCP provider for OAuth access to Notetaker meetings, Scratchpad notes, calendar events and tasks, with reviewed tool classification and account-specific usage guidance.
26
+
27
+ ### Changed
28
+
29
+ - Run CI checks in parallel, cancel superseded PR runs, and request snapshot updates explicitly while preserving the full check gate.
30
+ - **Breaking:** Each endpoint is now writable or read-only, set by a plain `readOnly` boolean that replaces pool `trust`. The deployment's `readOnly` sets `/mcp` and is required for this release: a deployment that sets neither `readOnly` nor the removed `trust` fails at startup with a configuration error asking the operator to choose `readOnly: true` or `readOnly: false`. Each pool inherits the deployment value unless it sets its own, in either direction. The client owns approval. A writable endpoint (`false`) behaves as a `trusted` pool did: `call_destructive_tool` and `execute_code` programs write (program writes stay capped by `execute.maxWrites`), `call_tool` still refuses writes, and `execute_code` is annotated `readOnlyHint: false, destructiveHint: true`. On a read-only endpoint (`true`), write tools are absent from `search_tools` results, `connecta.search`, and `connecta.describe`, and every write call through `call_tool`, `call_destructive_tool`, or a program is refused before validation or dispatch with the new code `read_only_endpoint` (`retryable: false`, no `nextAction`); reads still run, and `execute_code` is annotated `readOnlyHint: true, destructiveHint: false`. Guides are documentation and are not filtered: provider `SKILL.md` and usage guides, surfaced through `queryAnalysis.guideSummary`, `skills`, `connecta.skill`, and skill `resources/read`, may still name tools a read-only endpoint refuses. `tools/list` stays six tools on both. Resource reads, grants, `connectorAccess` (including guarded `requireReadOnly` grants), classification, and `execute.maxWrites` are unchanged. `describeConfig()` and the operator UI report `readOnly` instead of `trust`, and programs no longer receive `destructive_tool_requires_approval`, which remains `call_tool`'s refusal of a write on a writable endpoint.
31
+
32
+ To migrate, set `readOnly` explicitly in deployment code and remove every `trust`; a config that still sets `trust`, on the deployment or a pool, fails at construction with a message naming `readOnly`, and one that sets no `readOnly` fails too. Replace `trust: "trusted"` with `readOnly: false`. Replace `trust: "read-only"` with `readOnly: true`, and note that a read-only endpoint now also refuses writes through `call_destructive_tool`; a pool that should still allow host-approved direct writes but no program writes has no equivalent and must choose. A deployment that set no `trust` was read-only for programs and allowed host-approved direct writes; choose `readOnly: true` to keep it from writing at all, or `readOnly: false` to let programs write too. Pools that relied on the old read-only default inherit the deployment value, so set `readOnly: true` on each pool that must stay read-only under a writable deployment. The Node template and Worker example now set `readOnly: false`, and `connecta init` asks operators to choose.
33
+
34
+ ### Fixed
35
+
36
+ - Make redaction regression tests robust under machine load with CPU scaling, exact output and low-heap checks, without adding production instrumentation.
37
+ - `notion()` over an integration token now splits text longer than Notion's 2,000-character rich-text run limit into consecutive runs in `integration_create_page` and `integration_append_blocks`, instead of sending a request Notion rejects, and refuses text no single value can carry. Failures follow Notion's current contract: a 429 whose reason is `public_api_request_blocked` is no longer retryable, the retry window falls back to the body's `additional_data.retry_after`, a 403 names the workspace block limit, a 5xx on a write that Notion reports as committed names the created object or appended blocks so the caller reads them instead of repeating the write, and a 5xx on a write that carries Notion's `retry_guidance` relays it, unless the operation withholds Notion's error text, and is not retryable. A data source query stopped at Notion's 10,000-result cap reports `incomplete: true` from `integration_query_data_source` and `page.hasMore` without `page.next` from the generic read. Projections name native Notion icons and custom emoji and read an `unsupported` formula or rollup value as `null`. The OAuth connector classifies `notion-ai-search`, `notion-get-self`, `notion-get-tool-access`, and `notion-download-skill` as reads. The guide routes whole-page reads and Markdown writes through the page Markdown endpoints.
38
+
5
39
  ## 0.31.0 — 2026-10-09
6
40
 
7
41
  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.
package/README.md CHANGED
@@ -65,7 +65,7 @@ across them in one task. Changing your connected services does not require
65
65
  changing the agent's connection. Your deployment decides which services and
66
66
  operations each caller can reach.
67
67
 
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 -->.
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, Vercel, and Wispr Flow<!-- providers:end -->.
69
69
  Each connection has its own setup guide.
70
70
  Stripe, Notion, Cloudflare, and Vercel use their hosted MCP servers when you
71
71
  sign in with OAuth, and Connecta's own API connections when you use an API key,
@@ -76,8 +76,9 @@ HTTP API.
76
76
 
77
77
  Google Workspace connections use the account your deployment assigns to each
78
78
  signed-in person. A Workspace administrator authorizes them for your domain.
79
- Gmail reads mail and writes drafts, but does not send mail. Google Drive can
80
- read, write, and share files, but does not permanently delete them. Available
79
+ Gmail reads mail and attachments and writes drafts, but does not send mail.
80
+ Google Drive can read, write, and share files, but does not permanently delete
81
+ them. Available
81
82
  operations and account access depend on the integration and your deployment.
82
83
 
83
84
  Large results need not fill the conversation. The agent can filter and
@@ -85,19 +86,20 @@ summarize them in its program, or read a large result in smaller pieces.
85
86
 
86
87
  ## Writes, credentials, and access
87
88
 
88
- Connecta distinguishes reads from writes. By default, programs can read, and
89
- each change uses a separate visible tool call. Your agent's host controls
90
- whether it asks for approval. You can explicitly allow programs to make
91
- changes too; the host then sees the program tool as a write. This choice does
92
- not give a caller access to additional services.
89
+ Connecta distinguishes reads from writes, and each endpoint is either writable
90
+ or read-only. By default an endpoint is writable: your agent can make a change
91
+ with one visible tool call or inside a program, and your agent's host decides
92
+ whether to ask for approval; it sees the program tool as a write. You can mark
93
+ the whole deployment or a single endpoint read-only, and then nothing there can
94
+ change anything: search leaves write tools out and every write is refused. You
95
+ make this choice explicitly for each deployment. Neither choice gives a caller
96
+ access to additional services.
93
97
 
94
98
  ```mermaid
95
99
  flowchart TB
96
- Change["Your agent wants to change something"] --> Choice{"Are programs allowed<br/>to make changes?"}
97
- Choice -->|"Default: no"| Separate["A separate visible call for each change"]
98
- Choice -->|"Explicit choice: yes"| Program["A program that can make changes"]
99
- Separate --> Host["Your agent's host handles approval"]
100
- Program --> Host
100
+ Change["Your agent wants to change something"] --> Choice{"Is this endpoint<br/>read-only?"}
101
+ Choice -->|"Explicit choice: yes"| Refused["Refused; nothing is sent"]
102
+ Choice -->|"Default: no"| Host["Your agent's host handles approval"]
101
103
  Host --> Access["Connecta checks the caller's access"]
102
104
  Access --> Services["The connected service receives the change"]
103
105
 
package/bin/connecta.mjs CHANGED
@@ -77,6 +77,7 @@ async function init() {
77
77
  console.log("Next:");
78
78
  console.log(` ${shellCd(target)}`);
79
79
  console.log(" npm install");
80
+ console.log(" # Choose readOnly in src/connecta.config.ts: false writes with client approval, true refuses writes.");
80
81
  console.log(' npm run --silent provision-token -- "local-machine"');
81
82
  console.log(" # Save the returned cta_ token privately for your MCP client and doctor.");
82
83
  console.log(" npm start");
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.31.0";
3
+ export const CONNECTA_VERSION = "0.32.0";
@@ -148,7 +148,7 @@ export class AuthElicitation {
148
148
  requestSignal: AbortSignal.any([context.mcpReq.signal, this.options.requestSignal]),
149
149
  });
150
150
  // Recheck every entered read before restarting any part of a program.
151
- // A trusted pool cannot authorize replay of a newly classified write.
151
+ // A writable endpoint cannot authorize replay of a newly classified write.
152
152
  for (const read of previous.reads) {
153
153
  const current = await catalog.resolveTool(read.address, { signal: context.mcpReq.signal });
154
154
  if (!current.ok ||
@@ -377,6 +377,7 @@ export class CatalogService {
377
377
  concurrency;
378
378
  searchRoute;
379
379
  requestSignal;
380
+ readOnly;
380
381
  // The request-scoped catalog cache: one shared read per connector asked
381
382
  // about, started by the first asker and joined by every later one. A
382
383
  // success stays for the rest of the request, so a search and the call after
@@ -401,6 +402,7 @@ export class CatalogService {
401
402
  this.searchRoute = options.searchRoute ?? "search_tools";
402
403
  this.requestSignal = options.requestSignal;
403
404
  this.defer = options.defer;
405
+ this.readOnly = options.readOnly === true;
404
406
  }
405
407
  /**
406
408
  * Send a caller back to discovery through the surface it can actually reach.
@@ -460,9 +462,12 @@ export class CatalogService {
460
462
  // each settling in its input slot: an unavailable catalog is a Failure
461
463
  // there, not a failed discovery. `route` names the surface in a timeout.
462
464
  // A probe also ends with its request, so no listing outlives the caller
463
- // that asked for it.
465
+ // that asked for it. On a read-only endpoint a write is absent from search
466
+ // and describe, as if the connector never had it. Guides are not filtered.
464
467
  discoveryCatalogs(ids, route) {
465
- return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.catalog(id, { signal }), {
468
+ return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.readOnly
469
+ ? Effect.map(this.catalog(id, { signal }), (tools) => tools.filter((tool) => tool.classification === "read"))
470
+ : this.catalog(id, { signal }), {
466
471
  ...(this.requestSignal ? { signal: this.requestSignal } : {}),
467
472
  timeoutMs: this.probeTimeoutMs,
468
473
  timeoutError: new ConnectorCallError("timeout", `${route} probe of "${id}" timed out after ${this.probeTimeoutMs}ms`),
@@ -56,8 +56,10 @@ export function describeConfigSources(snapshot, raw) {
56
56
  source = provided(raw.serverInfo?.[key]);
57
57
  else if (group === "urls")
58
58
  source = key === "mcpPath" ? "default" : provided(raw[key]);
59
- else if (group === "trust")
60
- source = provided(raw.trust);
59
+ else if (group === "hasGrant")
60
+ source = provided(raw.grant);
61
+ else if (group === "readOnly")
62
+ source = provided(raw.readOnly);
61
63
  else if (group === "identity")
62
64
  source = provided(raw.identity?.[key]);
63
65
  else if (group === "storage")
@@ -67,7 +69,8 @@ export function describeConfigSources(snapshot, raw) {
67
69
  else if (group === "classification")
68
70
  source = provided(raw.classification);
69
71
  else if (group === "pools")
70
- source = field === "trust" ? provided(raw.pools?.[key]?.trust) : provided(raw.pools);
72
+ // An inherited value comes from wherever the deployment's came from.
73
+ source = field === "readOnly" ? provided(raw.pools?.[key]?.readOnly ?? raw.readOnly) : provided(raw.pools);
71
74
  else if (group === "modules") {
72
75
  const module = key === "ui" ? raw.ui : key === "vault" ? raw.vault : key === "activity" ? raw.activity : raw.accessTokens;
73
76
  const factory = moduleSources[key];
package/dist/config.d.ts CHANGED
@@ -76,7 +76,7 @@ declare const execute: {
76
76
  */
77
77
  watchdogMs: import("./config-schema.js").Field<number, number, true>;
78
78
  /**
79
- * Trusted-pool writes one program may send, on top of the host-call
79
+ * Writes one program may send on a writable endpoint, on top of the host-call
80
80
  * budget every call already spends. Default 10.
81
81
  */
82
82
  maxWrites: import("./config-schema.js").Field<number, number, true>;
@@ -183,8 +183,13 @@ declare const identity: {
183
183
  personalConnection: import("./config-schema.js").Field<(identity: Readonly<AuthenticatedIdentity>) => ConnectorPermission | Promise<ConnectorPermission>, ((identity: Readonly<AuthenticatedIdentity>) => ConnectorPermission | Promise<ConnectorPermission>) | undefined, true>;
184
184
  };
185
185
  declare const pool: {
186
- /** Programs may write only in trusted pools. Default read-only. */
187
- trust: import("./config-schema.js").Field<"trusted" | "read-only", "trusted" | "read-only", true>;
186
+ /**
187
+ * Whether this pool's endpoint refuses every write: write tools are absent
188
+ * from search_tools, tool listings, and describe, and every write call is
189
+ * refused. Inherits the deployment's `readOnly` unless set here, in either
190
+ * direction.
191
+ */
192
+ readOnly: import("./config-schema.js").Field<boolean, boolean | undefined, true>;
188
193
  /** Connector ids and exact `connector.tool` addresses in this pool. */
189
194
  tools: import("./config-schema.js").Field<readonly string[], readonly string[], false>;
190
195
  /**
@@ -233,18 +238,22 @@ declare const connectaConfig: {
233
238
  personalConnection: import("./config-schema.js").Field<(identity: Readonly<AuthenticatedIdentity>) => ConnectorPermission | Promise<ConnectorPermission>, ((identity: Readonly<AuthenticatedIdentity>) => ConnectorPermission | Promise<ConnectorPermission>) | undefined, true>;
234
239
  };
235
240
  };
241
+ /**
242
+ * Whether this admitted identity may open plain `/mcp`. Omitted admits
243
+ * everyone; only literal true admits when configured. Other returns and
244
+ * throws receive the same 404 as a refused or undeclared pool.
245
+ */
246
+ grant: import("./config-schema.js").Field<(identity: Readonly<AuthenticatedIdentity>) => boolean | Promise<boolean>, ((identity: Readonly<AuthenticatedIdentity>) => boolean | Promise<boolean>) | undefined, true>;
236
247
  /** Named tool pools, each served at `/mcp/<name>` to identities its grant admits. */
237
248
  pools: import("./config-schema.js").Field<Record<string, {
238
- trust?: "trusted" | "read-only" | undefined;
249
+ readOnly?: boolean | undefined;
239
250
  grant?: ((identity: Readonly<AuthenticatedIdentity>) => boolean | Promise<boolean>) | undefined;
240
251
  tools: readonly string[];
241
252
  }>, Readonly<Record<string, {
242
- readonly trust: "trusted" | "read-only";
253
+ readonly readOnly: boolean | undefined;
243
254
  readonly tools: readonly string[];
244
255
  readonly grant: ((identity: Readonly<AuthenticatedIdentity>) => boolean | Promise<boolean>) | undefined;
245
256
  }>> | undefined, true>;
246
- /** Trust of the default /mcp endpoint. Default read-only. */
247
- trust: import("./config-schema.js").Field<"trusted" | "read-only", "trusted" | "read-only", true>;
248
257
  /** Exact connector id -> tool name -> verdict. Unknown names fail at catalog publication. */
249
258
  classification: import("./config-schema.js").Field<Record<string, Record<string, "read" | "write">>, Readonly<Record<string, Readonly<Record<string, "read" | "write">>>> | undefined, true>;
250
259
  /**
@@ -394,7 +403,7 @@ declare const connectaConfig: {
394
403
  */
395
404
  watchdogMs: import("./config-schema.js").Field<number, number, true>;
396
405
  /**
397
- * Trusted-pool writes one program may send, on top of the host-call
406
+ * Writes one program may send on a writable endpoint, on top of the host-call
398
407
  * budget every call already spends. Default 10.
399
408
  */
400
409
  maxWrites: import("./config-schema.js").Field<number, number, true>;
@@ -548,6 +557,15 @@ declare const connectaConfig: {
548
557
  * `customExecutor(myExecutor, { lifecycle: "self-managed" })`.
549
558
  */
550
559
  executor: import("./config-schema.js").Field<Executor, Executor, false>;
560
+ /**
561
+ * Required. Whether the default /mcp endpoint refuses every write. `true`:
562
+ * write tools are absent from search_tools, tool listings, and describe,
563
+ * and call_tool, call_destructive_tool, and program writes are refused.
564
+ * `false`: call_destructive_tool and programs may write, and the MCP
565
+ * client owns approval. Pools inherit it unless they set their own. It has
566
+ * no default while deployments migrate from `trust` (#820).
567
+ */
568
+ readOnly: import("./config-schema.js").Field<boolean, boolean, false>;
551
569
  };
552
570
  /** A deployment's whole configuration, passed to `createConnecta`. */
553
571
  export type ConnectaConfig = ConfigInput<typeof connectaConfig>;
package/dist/config.js CHANGED
@@ -134,7 +134,7 @@ const execute = {
134
134
  */
135
135
  watchdogMs: whole({ min: 1, default: D.execute.watchdogMs }),
136
136
  /**
137
- * Trusted-pool writes one program may send, on top of the host-call
137
+ * Writes one program may send on a writable endpoint, on top of the host-call
138
138
  * budget every call already spends. Default 10.
139
139
  */
140
140
  maxWrites: whole({ min: 1, default: D.execute.maxWrites }),
@@ -195,17 +195,21 @@ const identity = {
195
195
  /** Connecting or changing the caller's personal account. Defaults to none. */
196
196
  personalConnection: fn(),
197
197
  };
198
- const trust = () => opaque({
199
- check: (value, path) => {
200
- if (value !== "trusted" && value !== "read-only") {
201
- throw new ConfigError(`${path} must be "trusted" or "read-only".`);
202
- }
203
- },
204
- resolve: (value) => value ?? "read-only",
205
- });
198
+ /** The choice every deployment makes explicitly while `trust` migrates (#820). */
199
+ const READ_ONLY_REQUIRED = (path) => `${path} is required. Choose readOnly: true to refuse every write on /mcp, or readOnly: false to let ` +
200
+ "call_destructive_tool and programs write with MCP client approval. Pools inherit it unless they set their own.";
201
+ function assertFlag(value, path) {
202
+ if (typeof value !== "boolean")
203
+ throw new ConfigError(`${path} must be true or false.`);
204
+ }
206
205
  const pool = {
207
- /** Programs may write only in trusted pools. Default read-only. */
208
- trust: trust(),
206
+ /**
207
+ * Whether this pool's endpoint refuses every write: write tools are absent
208
+ * from search_tools, tool listings, and describe, and every write call is
209
+ * refused. Inherits the deployment's `readOnly` unless set here, in either
210
+ * direction.
211
+ */
212
+ readOnly: opaque({ check: assertFlag }),
209
213
  /** Connector ids and exact `connector.tool` addresses in this pool. */
210
214
  tools: required(opaque({
211
215
  check: (value, path) => {
@@ -240,7 +244,7 @@ const connectaConfig = {
240
244
  connectors: required(array(opaque({
241
245
  check: (value, path) => {
242
246
  if (isObject(value) && Object.hasOwn(value, "approval")) {
243
- throw new ConfigError(`${path}.approval was removed; configure pool trust instead.`);
247
+ throw new ConfigError(`${path}.approval was removed; the MCP client owns approval. Set readOnly: true to refuse writes.`);
244
248
  }
245
249
  if (!isObject(value) ||
246
250
  typeof value.id !== "string" ||
@@ -274,10 +278,14 @@ const connectaConfig = {
274
278
  }),
275
279
  /** Code-derived connection visibility and independent management permissions. */
276
280
  identity: object(identity),
281
+ /**
282
+ * Whether this admitted identity may open plain `/mcp`. Omitted admits
283
+ * everyone; only literal true admits when configured. Other returns and
284
+ * throws receive the same 404 as a refused or undeclared pool.
285
+ */
286
+ grant: fn(),
277
287
  /** Named tool pools, each served at `/mcp/<name>` to identities its grant admits. */
278
288
  pools: record(object(pool)),
279
- /** Trust of the default /mcp endpoint. Default read-only. */
280
- trust: trust(),
281
289
  /** Exact connector id -> tool name -> verdict. Unknown names fail at catalog publication. */
282
290
  classification: record(record(opaque({
283
291
  check: (value, path) => {
@@ -415,10 +423,21 @@ const connectaConfig = {
415
423
  '"@zackbart/connecta/quickjs" on Node, or ' +
416
424
  "workerExecutor({ loader: env.LOADER }) from " +
417
425
  '"@zackbart/connecta/worker" around DynamicWorkerExecutor on Workers.'),
426
+ /**
427
+ * Required. Whether the default /mcp endpoint refuses every write. `true`:
428
+ * write tools are absent from search_tools, tool listings, and describe,
429
+ * and call_tool, call_destructive_tool, and program writes are refused.
430
+ * `false`: call_destructive_tool and programs may write, and the MCP
431
+ * client owns approval. Pools inherit it unless they set their own. It has
432
+ * no default while deployments migrate from `trust` (#820).
433
+ */
434
+ readOnly: required(opaque({ check: assertFlag }), (path) => READ_ONLY_REQUIRED(path)),
418
435
  };
419
436
  const CONFIG = object(connectaConfig);
420
437
  /** Pause-only options #672 removed with `resume_execution`. */
421
438
  const RETIRED_PAUSE_OPTIONS = ["ConnectaConfig.execute.resumableWrites", "ConnectaConfig.execute.pausedRunTtlSeconds"];
439
+ /** `trust`, on the deployment or a pool, was replaced by `readOnly` (#820). */
440
+ const isRetiredTrust = (path) => path === "ConnectaConfig.trust" || (path.startsWith("ConnectaConfig.pools.") && path.endsWith(".trust"));
422
441
  function rejectUnknownOptions(paths) {
423
442
  if (paths.length === 0)
424
443
  return;
@@ -431,10 +450,13 @@ function rejectUnknownOptions(paths) {
431
450
  ? "\nMove branding into ui: operatorUi({ branding }) from @zackbart/connecta/ui."
432
451
  : "") +
433
452
  (paths.includes("ConnectaConfig.execute.approval")
434
- ? '\nexecute.approval was removed. Set trust: "trusted" on a pool to allow program writes; the default is read-only.'
453
+ ? "\nexecute.approval was removed. The MCP client owns approval; set readOnly: true on the deployment or a pool to refuse writes."
435
454
  : "") +
436
455
  (paths.some((path) => RETIRED_PAUSE_OPTIONS.includes(path))
437
- ? "\nPrograms no longer pause at writes, so there is nothing to configure: read-only pools route writes through call_destructive_tool; trusted pools allow program writes (issue #672). Delete the option."
456
+ ? "\nPrograms no longer pause at writes, so there is nothing to configure: writable endpoints let programs and call_destructive_tool write; readOnly endpoints refuse every write (issue #672). Delete the option."
457
+ : "") +
458
+ (paths.some(isRetiredTrust)
459
+ ? '\ntrust was removed (#820); set readOnly, which every deployment now chooses explicitly. trust: "read-only" becomes readOnly: true, which now also refuses call_destructive_tool. trust: "trusted" becomes readOnly: false. A pool inherits the deployment readOnly unless it sets its own.'
438
460
  : ""));
439
461
  }
440
462
  /** Checks that span fields; each value is already individually valid. */
@@ -2,7 +2,8 @@ import type { ToolAccess } from "./registry.js";
2
2
  import type { AuthenticatedIdentity } from "./types.js";
3
3
  /** A declared pool after construction-time validation. */
4
4
  export interface ResolvedPool {
5
- trust: import("./tool-safety.js").PoolTrust;
5
+ /** Resolved: the pool's own `readOnly`, else the deployment's. */
6
+ readOnly: boolean;
6
7
  access: ConnectorAccess;
7
8
  grant(identity: Readonly<AuthenticatedIdentity>): boolean | Promise<boolean>;
8
9
  }
@@ -56,6 +56,8 @@ interface GuardedResponse {
56
56
  readonly status: number;
57
57
  readonly ok: boolean;
58
58
  readonly headers: Headers;
59
+ /** Cancel an unread body without waiting for the vendor to emit a chunk. */
60
+ cancel(): Promise<void>;
59
61
  /** The body as bytes. */
60
62
  bytes(): Promise<Uint8Array>;
61
63
  /** The body decoded as UTF-8. */
@@ -124,6 +126,13 @@ export interface GuardedFetchOptions {
124
126
  * absurd response is a fact about the API, not about HTTP.
125
127
  */
126
128
  maxResponseBytes: number;
129
+ /**
130
+ * Inspect the exact URL about to be fetched — confined, normalized, with its
131
+ * query encoded — and throw to refuse it. A policy that reads this URL reads
132
+ * what the downstream server will route, not a spelling of it. Runs before
133
+ * authentication, so a refused request never resolves a credential.
134
+ */
135
+ admit?: (url: URL, request: GuardedRequest) => void;
127
136
  }
128
137
  /** Sends one already-guarded request on behalf of one call's context. */
129
138
  type GuardedFetcher = (url: string, init: RequestInit, ctx: ConnectorContext) => Promise<Response>;
@@ -181,6 +181,9 @@ function boundedResponse(provider, response, limit) {
181
181
  status: response.status,
182
182
  ok: response.ok,
183
183
  headers: response.headers,
184
+ cancel: async () => {
185
+ await response.body?.cancel();
186
+ },
184
187
  bytes: async () => new Uint8Array(),
185
188
  text: async () => "",
186
189
  json: emptyJson,
@@ -228,6 +231,9 @@ function boundedResponse(provider, response, limit) {
228
231
  status: response.status,
229
232
  ok: response.ok,
230
233
  headers: response.headers,
234
+ cancel: async () => {
235
+ await response.body?.cancel();
236
+ },
231
237
  bytes,
232
238
  text,
233
239
  json,
@@ -307,6 +313,7 @@ export function guardedFetch(options) {
307
313
  throw new Error(`A ${provider} request cannot carry both a JSON body and a raw body.`);
308
314
  }
309
315
  const url = confinedUrl(provider, base, basePath, request);
316
+ options.admit?.(url, request);
310
317
  const headers = { ...constantHeaders };
311
318
  for (const [name, value] of Object.entries(request.headers ?? {})) {
312
319
  if (value !== undefined)
@@ -86,8 +86,10 @@ export interface ConnectaConfigDescription {
86
86
  execute: Record<keyof typeof CONFIG_DEFAULTS.execute, DescribedLimit>;
87
87
  requests: Record<keyof typeof CONFIG_DEFAULTS.admission.requests, DescribedLimit>;
88
88
  };
89
- /** Trust and exact classification overrides from deployment code. */
90
- trust: "trusted" | "read-only";
89
+ /** Whether the default /mcp endpoint refuses every write, and exact classification overrides from deployment code. */
90
+ readOnly: boolean;
91
+ /** Whether deployment code configures a grant for plain `/mcp`. */
92
+ hasGrant: boolean;
91
93
  classification: Record<string, Record<string, "read" | "write">>;
92
94
  /** Inbound auth providers in the order they are tried. */
93
95
  auth: Array<{
@@ -96,12 +98,13 @@ export interface ConnectaConfigDescription {
96
98
  ui?: string;
97
99
  }>;
98
100
  identity: Record<"connectorAccess" | "activityAccess" | "credentialAdministration" | "accessTokenManagement" | "personalConnection", "default" | "custom">;
101
+ /** `readOnly` is resolved: the pool's own value, else the deployment's. */
99
102
  pools: Array<{
100
103
  name: string;
101
104
  path: string;
102
105
  tools: string[];
103
106
  hasGrant: boolean;
104
- trust: "trusted" | "read-only";
107
+ readOnly: boolean;
105
108
  }>;
106
109
  modules: {
107
110
  ui: {
@@ -240,7 +240,8 @@ export function describeConfig(input) {
240
240
  execute: limitGroup(config.execute, ownValue(raw, "execute"), Object.keys(D.execute)),
241
241
  requests: limitGroup(config.admission.requests, ownValue(ownValue(raw, "admission"), "requests"), Object.keys(D.admission.requests)),
242
242
  },
243
- trust: config.trust,
243
+ readOnly: config.readOnly,
244
+ hasGrant: typeof config.grant === "function",
244
245
  classification: structuredClone(config.classification ?? {}),
245
246
  auth: config.auth.map((provider) => ({
246
247
  kind: String(provider.kind),
@@ -249,7 +250,7 @@ export function describeConfig(input) {
249
250
  })),
250
251
  identity: Object.fromEntries(IDENTITY_KEYS.map((key) => [key, typeof config.identity[key] === "function" ? "custom" : "default"])),
251
252
  pools: Object.entries(config.pools ?? {}).map(([name, pool]) => ({
252
- trust: pool.trust,
253
+ readOnly: pool.readOnly ?? config.readOnly,
253
254
  name,
254
255
  path: `/mcp/${name}`,
255
256
  tools: pool.tools.map(String),
package/dist/errors.d.ts CHANGED
@@ -33,7 +33,7 @@ export type ConnectorCallErrorCode = "timeout" | "auth_required" | "downstream_o
33
33
  * Adding a member fails typecheck until {@link CLASSIFICATION_CODE_TABLE}
34
34
  * lists it.
35
35
  */
36
- export type ClassificationCode = ConnectorCallErrorCode | ExecutorAdmissionErrorCode | "cancelled" | "unknown_address" | "unknown_tool" | "ambiguous_tool_alias" | "catalog_lookup_failed" | "result_too_large" | "destructive_tool_requires_approval" | "auth_replay_refused" | "write_outcome_unknown" | "result_processing_failed" | "budget_exceeded" | "executor_failed" | "program_error";
36
+ export type ClassificationCode = ConnectorCallErrorCode | ExecutorAdmissionErrorCode | "cancelled" | "unknown_address" | "unknown_tool" | "ambiguous_tool_alias" | "catalog_lookup_failed" | "result_too_large" | "destructive_tool_requires_approval" | "read_only_endpoint" | "auth_replay_refused" | "write_outcome_unknown" | "result_processing_failed" | "budget_exceeded" | "executor_failed" | "program_error";
37
37
  /** `value` when it is a code connecta assigns, else undefined. */
38
38
  export declare function classificationCode(value: unknown): ClassificationCode | undefined;
39
39
  /** One bounded, payload-free explanation of an input-schema mismatch. */
package/dist/errors.js CHANGED
@@ -28,6 +28,7 @@ const CLASSIFICATION_CODE_TABLE = {
28
28
  catalog_lookup_failed: true,
29
29
  result_too_large: true,
30
30
  destructive_tool_requires_approval: true,
31
+ read_only_endpoint: true,
31
32
  auth_replay_refused: true,
32
33
  write_outcome_unknown: true,
33
34
  result_processing_failed: true,
package/dist/execute.js CHANGED
@@ -412,6 +412,8 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
412
412
  const catalog = new CatalogService(registry, baseUrl, {
413
413
  requestScope,
414
414
  requestSignal: hostAccessSignal,
415
+ // connecta.search and connecta.describe never show a read-only endpoint's writes.
416
+ readOnly: limits.readOnly,
415
417
  // A program that just missed an address cannot call search_tools.
416
418
  searchRoute: "connecta.search",
417
419
  concurrency: limits.discoveryConcurrency,
@@ -455,7 +457,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
455
457
  }
456
458
  return value;
457
459
  };
458
- /** Budget and account for writes admitted by the pool trust decision. */
460
+ /** Budget and account for writes a writable endpoint admits. */
459
461
  const invocationContext = (sending, timeoutMs) => ({
460
462
  source: "execute_code",
461
463
  sentSecrets,
@@ -463,9 +465,9 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
463
465
  ...(signal !== undefined ? { requestSignal: signal } : {}),
464
466
  ...(limits.dispatchController ? { dispatchSignal: limits.dispatchController.signal } : {}),
465
467
  unwrapResult: true,
466
- trust: limits.trust,
468
+ readOnly: limits.readOnly,
467
469
  processResult: async (value, resolved, secrets, format, raw) => {
468
- const page = (data, valueFormat, wireBytes) => pageProgramResult(registry, baseUrl, resolved, data, valueFormat, secrets, limits.trust, requestScope, wireBytes);
470
+ const page = (data, valueFormat, wireBytes) => pageProgramResult(registry, baseUrl, resolved, data, valueFormat, secrets, requestScope, wireBytes);
469
471
  const envelope = resolved.connector.kind === "mcp" && raw !== null && typeof raw === "object"
470
472
  ? raw
471
473
  : undefined;
@@ -605,7 +607,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
605
607
  normalizeTimeoutMs(limits.defaultToolTimeoutMs) ??
606
608
  hostCallTimeoutMs;
607
609
  const timeoutMs = Math.min(requestedMs, Math.max(1, (limits.programDeadlineAt ?? Infinity) - Date.now()));
608
- // A trusted-pool write settles here — unknown if the call never returned an
610
+ // A program write settles here — unknown if the call never returned an
609
611
  // outcome. Credentials are filtered from arguments; submitted private values are not.
610
612
  const sending = {};
611
613
  const outcome = yield* invocation
@@ -656,7 +658,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
656
658
  : Effect.raceAllFirst([reading, fromSignal(hostAccessSignal).pipe(Effect.mapError(cancelled))])).pipe(Effect.onExit((exit) => Effect.sync(() => diagnostics?.recordCatalog(operation, Date.now() - started, Exit.isSuccess(exit), Exit.isSuccess(exit) ? exit.value : undefined))));
657
659
  });
658
660
  const meta = createMetaTools(registry, baseUrl, {
659
- trust: limits.trust,
661
+ readOnly: limits.readOnly,
660
662
  requestScope,
661
663
  onResultRead: (dependencies) => limits.resultDependencies?.push(...dependencies),
662
664
  });
@@ -1077,7 +1079,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
1077
1079
  defaultToolTimeoutMs: config.defaultToolTimeoutMs,
1078
1080
  programDeadlineAt: Date.now() + watchdog.ms,
1079
1081
  defer: config.defer,
1080
- trust: config.trust,
1082
+ readOnly: config.readOnly,
1081
1083
  maxWrites: config.maxWrites,
1082
1084
  programWrites,
1083
1085
  resultDependencies,
@@ -1087,7 +1089,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
1087
1089
  return yield* Effect.fail(new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled during sandbox setup."));
1088
1090
  }
1089
1091
  const admitted = lease;
1090
- // A trusted-pool write may still be on the wire when the program settles:
1092
+ // A program write may still be on the wire when the program settles:
1091
1093
  // close, so a write gated after this is not sent, then let the ones
1092
1094
  // already dispatched finish before the scope aborts them — an
1093
1095
  // abandoned write would be one whose outcome nobody knows.
@@ -1114,7 +1116,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
1114
1116
  emitted,
1115
1117
  diagnostics,
1116
1118
  program,
1117
- guardResult: (value, maxChars) => guardProgramReturn(value, registry, baseUrl, [...new Map(resultDependencies.map((dependency) => [JSON.stringify(dependency), dependency])).values()], sentSecrets, config.trust, logger, maxChars, programWrites.hasSucceeded),
1119
+ guardResult: (value, maxChars) => guardProgramReturn(value, registry, baseUrl, [...new Map(resultDependencies.map((dependency) => [JSON.stringify(dependency), dependency])).values()], sentSecrets, logger, maxChars, programWrites.hasSucceeded),
1118
1120
  };
1119
1121
  const exited = Effect.exit(Effect.scoped(run)).pipe(Effect.flatMap((exit) =>
1120
1122
  // Releasing a QuickJS lease ends its child and rejects execute() with
@@ -1336,7 +1338,7 @@ function connectorInventory(connectors) {
1336
1338
  return `${prefix}${shown.join(", ")}.`;
1337
1339
  return `${prefix}${shown.join(", ")}${shown.length > 0 ? "; " : ""}+${omitted} more.`;
1338
1340
  }
1339
- const executeDescription = (emitBudgets, hostLimits, connectorGuides, connectors, maxWrites, trust) => `One known read: call_tool. One known write: call_destructive_tool. Everything else: execute_code. ${trust === "trusted" ? "Trusted pool: programs may read and write; the host approves the program as a write." : "Read-only pool: programs read; writes use call_destructive_tool."} Limits: ${hostLimits.maxHostCalls} host calls, ${maxWrites} writes, ${hostLimits.hostCallTimeoutMs / 1_000}s/host call.
1341
+ const executeDescription = (emitBudgets, hostLimits, connectorGuides, connectors, maxWrites, readOnly) => `${readOnly ? "One known read: call_tool. Everything else: execute_code. Read-only endpoint: search and describe omit write tools, and every write call is refused." : "One known read: call_tool. One known write: call_destructive_tool. Everything else: execute_code. Writable endpoint: programs may read and write; the client approves."} Limits: ${hostLimits.maxHostCalls} host calls, ${readOnly ? "" : `${maxWrites} writes, `}${hostLimits.hostCallTimeoutMs / 1_000}s/host call.
1340
1342
  ${connectorInventory(connectors)}
1341
1343
 
1342
1344
  Use async () => { ... }, no arguments. No portable ambient capabilities. API:
@@ -1383,7 +1385,7 @@ export function registerExecuteTool(server, registry, ctx) {
1383
1385
  hostCallTimeoutMs: hostLimits.hostCallTimeoutMs,
1384
1386
  watchdogMs: ctx.watchdogMs,
1385
1387
  defer: ctx.defer,
1386
- trust: ctx.trust,
1388
+ readOnly: ctx.readOnly,
1387
1389
  maxWrites,
1388
1390
  requestScope: ctx.requestScope,
1389
1391
  });
@@ -1391,13 +1393,13 @@ export function registerExecuteTool(server, registry, ctx) {
1391
1393
  description: executeDescription(emitBudgets, {
1392
1394
  ...hostLimits,
1393
1395
  hostCallTimeoutMs: normalizeTimeoutMs(ctx.defaultToolTimeoutMs) ?? hostLimits.hostCallTimeoutMs,
1394
- }, hasConnectorGuides(connectors), connectors, maxWrites, ctx.trust ?? "read-only"),
1396
+ }, hasConnectorGuides(connectors), connectors, maxWrites, ctx.readOnly === true),
1395
1397
  inputSchema: EXECUTE_INPUT,
1396
1398
  outputSchema: EXECUTE_OUTPUT,
1397
- // The host sees execute_code as a write only on a trusted endpoint.
1399
+ // The client sees execute_code as a write exactly when the endpoint writes.
1398
1400
  annotations: {
1399
- readOnlyHint: ctx.trust !== "trusted",
1400
- destructiveHint: ctx.trust === "trusted",
1401
+ readOnlyHint: ctx.readOnly === true,
1402
+ destructiveHint: ctx.readOnly !== true,
1401
1403
  openWorldHint: true,
1402
1404
  },
1403
1405
  }, (args, extra) => {