@zackbart/connecta 0.18.1 → 0.18.3

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 (38) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +4 -0
  3. package/dist/catalog-service.d.ts +4 -0
  4. package/dist/catalog-service.js +22 -6
  5. package/dist/connectors/remote-mcp.d.ts +49 -2
  6. package/dist/connectors/remote-mcp.js +302 -7
  7. package/dist/invocation.js +9 -3
  8. package/dist/providers/linear.d.ts +7 -1
  9. package/dist/providers/linear.js +12 -2
  10. package/dist/providers/mixpanel.d.ts +5 -1
  11. package/dist/providers/mixpanel.js +13 -2
  12. package/dist/providers/revenuecat.d.ts +3 -1
  13. package/dist/providers/revenuecat.js +12 -3
  14. package/dist/providers/stripe.d.ts +9 -3
  15. package/dist/providers/stripe.js +24 -5
  16. package/dist/registry.d.ts +7 -0
  17. package/dist/registry.js +9 -0
  18. package/dist/result-shapes.d.ts +13 -0
  19. package/dist/result-shapes.js +331 -0
  20. package/dist/skills.js +3 -3
  21. package/dist/version.d.ts +1 -1
  22. package/dist/version.js +1 -1
  23. package/documentation/architecture.md +5 -2
  24. package/documentation/code-mode.md +6 -6
  25. package/documentation/connectors.md +35 -0
  26. package/documentation/linear.md +25 -4
  27. package/documentation/meta-tools.md +22 -3
  28. package/documentation/mixpanel.md +19 -0
  29. package/documentation/operations.md +3 -1
  30. package/documentation/provider-conventions.md +37 -21
  31. package/documentation/revenuecat.md +23 -1
  32. package/documentation/storage-and-credentials.md +55 -0
  33. package/documentation/stripe.md +22 -2
  34. package/documentation/upgrading.md +32 -4
  35. package/ethos.md +3 -3
  36. package/examples/worker/src/index.ts +5 -0
  37. package/package.json +1 -1
  38. package/templates/node/package.json +1 -1
@@ -113,6 +113,10 @@ export interface RegistryView {
113
113
  observedSuccessAt(id: string): string | undefined;
114
114
  /** Local declared-vs-stored credential mismatch, with no downstream I/O. */
115
115
  credentialDriftFor(id: string): Promise<string | undefined>;
116
+ /** Value-free shape learned from successful calls, never a provider declaration. */
117
+ observedOutputSchema(connectorId: string, definition: ToolDef): ToolDef["outputSchema"] | undefined;
118
+ /** Passively learn one successful unwrapped result; failures stay isolated. */
119
+ observeOutputShape(connectorId: string, definition: ToolDef, value: unknown): void;
116
120
  statusFor(id: string, baseUrl: string, requestScope?: object, callOptions?: ConnectorOperationOptions): Promise<ConnectorStatus>;
117
121
  invalidateStored(id: string): Promise<void>;
118
122
  }
@@ -141,6 +145,7 @@ export declare class Registry implements RegistryView {
141
145
  private readonly health;
142
146
  /** Last drift counts reported to activity, per connector, in this runtime. */
143
147
  private readonly reportedDrift;
148
+ private readonly observedOutputSchemas;
144
149
  private readonly ttlMs;
145
150
  private readonly staleMs;
146
151
  private readonly persistToolCatalog;
@@ -203,6 +208,8 @@ export declare class Registry implements RegistryView {
203
208
  * separate from any connector's `conn:<id>:` namespace. Backs get_result.
204
209
  */
205
210
  resultsStorage(): KVStorage;
211
+ observedOutputSchema(connectorId: string, definition: ToolDef): ToolDef["outputSchema"] | undefined;
212
+ observeOutputShape(connectorId: string, definition: ToolDef, value: unknown): void;
206
213
  /** Resolve "<connectorId>.<toolName>" → connector + tool name. */
207
214
  resolveAddress(address: string): {
208
215
  connector: Connector;
package/dist/registry.js CHANGED
@@ -7,6 +7,7 @@ import { boundedCatalogDrift } from "./catalog-drift.js";
7
7
  import { fingerprintSerializedCatalog, snapshotCatalog, } from "./catalog-fingerprint.js";
8
8
  import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES, } from "./catalog-limits.js";
9
9
  import { mapSettledWithConcurrency } from "./concurrency.js";
10
+ import { ObservedOutputSchemas } from "./result-shapes.js";
10
11
  import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary, } from "./skills.js";
11
12
  import { withAbortableTimeout } from "./timeout.js";
12
13
  const ID_RE = /^[a-z0-9_-]+$/;
@@ -133,6 +134,7 @@ export class Registry {
133
134
  health = new HealthLog();
134
135
  /** Last drift counts reported to activity, per connector, in this runtime. */
135
136
  reportedDrift = new Map();
137
+ observedOutputSchemas;
136
138
  ttlMs;
137
139
  staleMs;
138
140
  persistToolCatalog;
@@ -140,6 +142,7 @@ export class Registry {
140
142
  maxResultBytes;
141
143
  constructor(connectors, opts) {
142
144
  this.opts = opts;
145
+ this.observedOutputSchemas = new ObservedOutputSchemas();
143
146
  this.ttlMs =
144
147
  (opts.toolCacheTtlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
145
148
  this.staleMs =
@@ -342,6 +345,12 @@ export class Registry {
342
345
  resultsStorage() {
343
346
  return namespaced(this.opts.storage, "results:");
344
347
  }
348
+ observedOutputSchema(connectorId, definition) {
349
+ return this.observedOutputSchemas.get(connectorId, definition);
350
+ }
351
+ observeOutputShape(connectorId, definition, value) {
352
+ this.observedOutputSchemas.observe(connectorId, definition, value);
353
+ }
345
354
  /** Resolve "<connectorId>.<toolName>" → connector + tool name. */
346
355
  resolveAddress(address) {
347
356
  const parts = splitAddress(address);
@@ -0,0 +1,13 @@
1
+ import type { JsonSchema, ToolDef } from "./types.js";
2
+ /**
3
+ * Passive, process-local output-shape learning. Provider declarations always
4
+ * win; observations are an open, optional-field routing aid and never a
5
+ * replacement contract.
6
+ */
7
+ export declare class ObservedOutputSchemas {
8
+ private readonly entries;
9
+ private cacheKey;
10
+ private set;
11
+ get(connectorId: string, definition: ToolDef): JsonSchema | undefined;
12
+ observe(connectorId: string, definition: ToolDef, value: unknown): void;
13
+ }
@@ -0,0 +1,331 @@
1
+ import { isExplicitlyReadOnly } from "./tool-safety.js";
2
+ const OBSERVATION_TTL_MS = 24 * 60 * 60 * 1000;
3
+ const MAX_CACHED_SHAPES = 256;
4
+ const MAX_DEFINITION_BYTES = 64 * 1024;
5
+ const MAX_SCHEMA_BYTES = 16 * 1024;
6
+ const MAX_SCHEMA_DEPTH = 6;
7
+ const MAX_SCHEMA_NODES = 128;
8
+ const MAX_OBJECT_PROPERTIES = 48;
9
+ const MAX_ARRAY_ITEMS = 32;
10
+ const MAX_PROPERTY_NAME_BYTES = 128;
11
+ const UNSAFE_PROPERTY_NAMES = new Set([
12
+ "__proto__",
13
+ "constructor",
14
+ "prototype",
15
+ ]);
16
+ const encoder = new TextEncoder();
17
+ function broadType(value) {
18
+ if (value === null)
19
+ return "null";
20
+ if (Array.isArray(value))
21
+ return "array";
22
+ switch (typeof value) {
23
+ case "string":
24
+ case "boolean":
25
+ return typeof value;
26
+ case "number":
27
+ return Number.isFinite(value) ? "number" : undefined;
28
+ case "object":
29
+ return "object";
30
+ default:
31
+ return undefined;
32
+ }
33
+ }
34
+ function serializedSchema(schema) {
35
+ try {
36
+ const text = JSON.stringify(schema);
37
+ return encoder.encode(text).byteLength <= MAX_SCHEMA_BYTES
38
+ ? text
39
+ : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ function cloneSchema(schema) {
46
+ const serialized = serializedSchema(schema);
47
+ if (!serialized)
48
+ return undefined;
49
+ try {
50
+ return JSON.parse(serialized);
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
56
+ function definitionIdentity(definition) {
57
+ try {
58
+ const serialized = JSON.stringify(definition);
59
+ return encoder.encode(serialized).byteLength <= MAX_DEFINITION_BYTES
60
+ ? serialized
61
+ : undefined;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ function schemaType(schema) {
68
+ return typeof schema.type === "string" ? schema.type : undefined;
69
+ }
70
+ function safePropertyName(name) {
71
+ return (!UNSAFE_PROPERTY_NAMES.has(name) &&
72
+ encoder.encode(name).byteLength <= MAX_PROPERTY_NAME_BYTES);
73
+ }
74
+ function boundedPropertyNames(value) {
75
+ const names = [];
76
+ for (const name in value) {
77
+ if (!Object.hasOwn(value, name) || !safePropertyName(name))
78
+ continue;
79
+ names.push(name);
80
+ if (names.length >= MAX_OBJECT_PROPERTIES)
81
+ break;
82
+ }
83
+ return names.sort();
84
+ }
85
+ function unionBranches(schema) {
86
+ return Array.isArray(schema.anyOf)
87
+ ? schema.anyOf
88
+ : [schema];
89
+ }
90
+ function branchOrder(schema) {
91
+ return ["null", "boolean", "number", "string", "array", "object"].indexOf(schemaType(schema) ?? "");
92
+ }
93
+ function mergeSchemas(left, right) {
94
+ const leftType = schemaType(left);
95
+ const rightType = schemaType(right);
96
+ if (leftType === "object" && rightType === "object") {
97
+ const leftProperties = left.properties && typeof left.properties === "object"
98
+ ? left.properties
99
+ : {};
100
+ const rightProperties = right.properties && typeof right.properties === "object"
101
+ ? right.properties
102
+ : {};
103
+ const properties = Object.create(null);
104
+ for (const key of [...new Set([
105
+ ...Object.keys(leftProperties),
106
+ ...Object.keys(rightProperties),
107
+ ])].sort()) {
108
+ const leftProperty = leftProperties[key];
109
+ const rightProperty = rightProperties[key];
110
+ properties[key] =
111
+ leftProperty && rightProperty
112
+ ? mergeSchemas(leftProperty, rightProperty)
113
+ : (leftProperty ?? rightProperty);
114
+ }
115
+ return { type: "object", properties };
116
+ }
117
+ if (leftType === "array" && rightType === "array") {
118
+ const leftItems = left.items && typeof left.items === "object"
119
+ ? left.items
120
+ : undefined;
121
+ const rightItems = right.items && typeof right.items === "object"
122
+ ? right.items
123
+ : undefined;
124
+ return {
125
+ type: "array",
126
+ ...(leftItems || rightItems
127
+ ? {
128
+ items: leftItems && rightItems
129
+ ? mergeSchemas(leftItems, rightItems)
130
+ : (leftItems ?? rightItems),
131
+ }
132
+ : {}),
133
+ };
134
+ }
135
+ if (leftType && leftType === rightType)
136
+ return left;
137
+ const byType = new Map();
138
+ for (const branch of [...unionBranches(left), ...unionBranches(right)]) {
139
+ const type = schemaType(branch);
140
+ if (!type)
141
+ continue;
142
+ const existing = byType.get(type);
143
+ byType.set(type, existing ? mergeSchemas(existing, branch) : branch);
144
+ }
145
+ const branches = [...byType.values()].sort((a, b) => branchOrder(a) - branchOrder(b));
146
+ return branches.length === 1 ? branches[0] : { anyOf: branches };
147
+ }
148
+ function inferSchema(value, budget, depth = 0) {
149
+ const type = broadType(value);
150
+ if (!type || depth > MAX_SCHEMA_DEPTH || budget.nodes >= MAX_SCHEMA_NODES) {
151
+ return undefined;
152
+ }
153
+ budget.nodes++;
154
+ if (type !== "object" && type !== "array")
155
+ return { type };
156
+ const object = value;
157
+ if (budget.seen.has(object))
158
+ return undefined;
159
+ budget.seen.add(object);
160
+ try {
161
+ if (type === "array") {
162
+ let items;
163
+ for (const item of value.slice(0, MAX_ARRAY_ITEMS)) {
164
+ const inferred = inferSchema(item, budget, depth + 1);
165
+ if (inferred)
166
+ items = items ? mergeSchemas(items, inferred) : inferred;
167
+ if (budget.nodes >= MAX_SCHEMA_NODES)
168
+ break;
169
+ }
170
+ return { type: "array", ...(items ? { items } : {}) };
171
+ }
172
+ const properties = Object.create(null);
173
+ const record = value;
174
+ for (const key of boundedPropertyNames(record)) {
175
+ const inferred = inferSchema(record[key], budget, depth + 1);
176
+ if (inferred)
177
+ properties[key] = inferred;
178
+ if (budget.nodes >= MAX_SCHEMA_NODES)
179
+ break;
180
+ }
181
+ return { type: "object", properties };
182
+ }
183
+ finally {
184
+ budget.seen.delete(object);
185
+ }
186
+ }
187
+ function boundSchema(schema, budget, depth = 0) {
188
+ if (depth > MAX_SCHEMA_DEPTH || budget.nodes >= MAX_SCHEMA_NODES) {
189
+ return undefined;
190
+ }
191
+ budget.nodes++;
192
+ if (Array.isArray(schema.anyOf)) {
193
+ const branches = schema.anyOf
194
+ .slice(0, 6)
195
+ .map((branch) => boundSchema(branch, budget, depth + 1))
196
+ .filter((branch) => Boolean(branch));
197
+ return branches.length >= 2 ? { anyOf: branches } : branches[0];
198
+ }
199
+ const type = schemaType(schema);
200
+ if (!type)
201
+ return undefined;
202
+ if (type === "array") {
203
+ const items = schema.items && typeof schema.items === "object"
204
+ ? boundSchema(schema.items, budget, depth + 1)
205
+ : undefined;
206
+ return { type: "array", ...(items ? { items } : {}) };
207
+ }
208
+ if (type === "object") {
209
+ const source = schema.properties && typeof schema.properties === "object"
210
+ ? schema.properties
211
+ : {};
212
+ const properties = Object.create(null);
213
+ let included = 0;
214
+ for (const key of Object.keys(source).sort()) {
215
+ if (included >= MAX_OBJECT_PROPERTIES ||
216
+ !safePropertyName(key)) {
217
+ continue;
218
+ }
219
+ const property = boundSchema(source[key], budget, depth + 1);
220
+ if (property) {
221
+ properties[key] = property;
222
+ included++;
223
+ }
224
+ if (budget.nodes >= MAX_SCHEMA_NODES)
225
+ break;
226
+ }
227
+ return { type: "object", properties };
228
+ }
229
+ return { type };
230
+ }
231
+ function observedSchema(value) {
232
+ try {
233
+ const inferred = inferSchema(value, { nodes: 0, seen: new WeakSet() });
234
+ const schema = inferred
235
+ ? boundSchema(inferred, { nodes: 0 })
236
+ : undefined;
237
+ return schema && serializedSchema(schema) ? schema : undefined;
238
+ }
239
+ catch {
240
+ return undefined;
241
+ }
242
+ }
243
+ /**
244
+ * Passive, process-local output-shape learning. Provider declarations always
245
+ * win; observations are an open, optional-field routing aid and never a
246
+ * replacement contract.
247
+ */
248
+ export class ObservedOutputSchemas {
249
+ entries = new Map();
250
+ cacheKey(connectorId, definition) {
251
+ return JSON.stringify([connectorId, definition.name]);
252
+ }
253
+ set(key, entry) {
254
+ this.entries.delete(key);
255
+ this.entries.set(key, entry);
256
+ while (this.entries.size > MAX_CACHED_SHAPES) {
257
+ const oldest = this.entries.keys().next().value;
258
+ if (oldest === undefined)
259
+ break;
260
+ this.entries.delete(oldest);
261
+ }
262
+ }
263
+ get(connectorId, definition) {
264
+ try {
265
+ const key = this.cacheKey(connectorId, definition);
266
+ if (definition.outputSchema) {
267
+ this.entries.delete(key);
268
+ return undefined;
269
+ }
270
+ if (!isExplicitlyReadOnly(definition)) {
271
+ this.entries.delete(key);
272
+ return undefined;
273
+ }
274
+ const identity = definitionIdentity(definition);
275
+ if (!identity) {
276
+ this.entries.delete(key);
277
+ return undefined;
278
+ }
279
+ const entry = this.entries.get(key);
280
+ if (!entry)
281
+ return undefined;
282
+ if (entry.definition !== identity || entry.expiresAt <= Date.now()) {
283
+ this.entries.delete(key);
284
+ return undefined;
285
+ }
286
+ this.entries.delete(key);
287
+ this.entries.set(key, entry);
288
+ return cloneSchema(entry.schema);
289
+ }
290
+ catch {
291
+ return undefined;
292
+ }
293
+ }
294
+ observe(connectorId, definition, value) {
295
+ try {
296
+ const key = this.cacheKey(connectorId, definition);
297
+ if (definition.outputSchema) {
298
+ this.entries.delete(key);
299
+ return;
300
+ }
301
+ if (!isExplicitlyReadOnly(definition)) {
302
+ this.entries.delete(key);
303
+ return;
304
+ }
305
+ const identity = definitionIdentity(definition);
306
+ const inferred = observedSchema(value);
307
+ if (!identity) {
308
+ this.entries.delete(key);
309
+ return;
310
+ }
311
+ if (!inferred)
312
+ return;
313
+ const current = this.entries.get(key);
314
+ const merged = boundSchema(current &&
315
+ current.definition === identity &&
316
+ current.expiresAt > Date.now()
317
+ ? mergeSchemas(current.schema, inferred)
318
+ : inferred, { nodes: 0 });
319
+ if (!merged || !serializedSchema(merged))
320
+ return;
321
+ this.set(key, {
322
+ definition: identity,
323
+ expiresAt: Date.now() + OBSERVATION_TTL_MS,
324
+ schema: merged,
325
+ });
326
+ }
327
+ catch {
328
+ // Observation is an optimization. A provider success stays successful.
329
+ }
330
+ }
331
+ }
package/dist/skills.js CHANGED
@@ -23,13 +23,13 @@ The minimum guest API is:
23
23
 
24
24
  Search inside the run and finish the task there. A discovery-only program wastes a round trip. Use 2–4 distinctive action/object terms, not the full request. Use separate short searches for distinct operations.
25
25
 
26
- For top-level \`search_tools\`, omit \`limit\` initially (the default is 10), then page with a limit up to 50 if needed. Empty or whitespace-only queries browse all tools. A non-empty query with no ASCII terms returns no matches; mixed input searches with its ASCII terms. \`includeSchemas: "compact"\` adds bounded input and declared output shapes. Plain objects expose \`inputKeys\`, \`requiredInputKeys\`, and \`outputKeys\`; truncation flags mark incomplete shapes; matches also carry declared annotations.
26
+ For top-level \`search_tools\`, omit \`limit\` initially (the default is 10), then page with a limit up to 50 if needed. Empty or whitespace-only queries browse all tools. A non-empty query with no ASCII terms returns no matches; mixed input searches with its ASCII terms. \`includeSchemas: "compact"\` adds bounded input and available output shapes. An observed shape carries \`outputSchemaSource: "observed"\`; treat it as routing evidence rather than a provider contract. Plain objects expose \`inputKeys\`, \`requiredInputKeys\`, and \`outputKeys\`; truncation flags mark incomplete shapes; matches also carry declared annotations.
27
27
 
28
28
  - \`connecta.search({})\` loads all catalogs. Pass \`connector: "<id>"\` when the integration is obvious. Use \`safety: "readOnly"\` for program calls. These inputs filter discovery; they grant no authority.
29
- - Request \`includeSchemas: "compact"\`. Check address, purpose, annotations, required inputs, truncation, safety, and declared outputs. Never select only because a result ranks first or has fewer required inputs.
29
+ - Request \`includeSchemas: "compact"\`. Check address, purpose, annotations, required inputs, truncation, safety, and available outputs. Never select only because a result ranks first or has fewer required inputs.
30
30
  - Supply every \`requiredInputKey\` from the task or a prior result. For dependencies, match the earlier \`outputKey\` to the later required key. An empty required-key list does not permit invented arguments. Missing \`outputKeys\` means inspect \`outputSchema\`.
31
31
  - Use \`connecta.describe({ address })\` or \`{ addresses }\` when a compact schema is truncated or insufficient. Use \`format: "json"\` only for exact constraints. Write the property names the schema displays; never guess positions or aliases.
32
- - Reduce through declared output keys. Do not guess collection roots such as \`items\` or \`results\`. If a match or result key is missing, inspect, re-search, or describe inside the same run instead of returning discovery for another call.
32
+ - Reduce through available output keys. Treat an observed key as a hint, since later results may omit it or add others. Do not guess collection roots such as \`items\` or \`results\`. If a match or result key is missing, inspect, re-search, or describe inside the same run instead of returning discovery for another call.
33
33
 
34
34
  Only tools explicitly annotated \`readOnlyHint: true\` are reachable. The catalog, credential, admission, and read-only gates run below the sandbox; code cannot widen its authority.
35
35
 
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export declare const CONNECTA_VERSION = "0.18.1";
7
+ export declare const CONNECTA_VERSION = "0.18.3";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.18.1";
7
+ export const CONNECTA_VERSION = "0.18.3";
@@ -14,8 +14,9 @@ stating before anything else.
14
14
 
15
15
  **Per isolate, built once.** `createConnecta(config)` returns
16
16
  `{ fetch, registry, close }`. The `Registry` owns the connector set, address
17
- resolution, catalog caches, connector health, and the per-connector call
18
- limiters. It is constructed once and lives as long as the isolate or process —
17
+ resolution, catalog caches, observed output schemas, connector health, and the
18
+ per-connector call limiters. It is constructed once and lives as long as the
19
+ isolate or process —
19
20
  on Workers that means a lazy module-scope singleton, which is why both
20
21
  deployment shapes build it outside the request handler.
21
22
 
@@ -94,6 +95,7 @@ owns or hands out, and a change usually belongs in exactly one of them:
94
95
  | `src/catalog-service.ts` | Request-local tool listing, search, and describe. It coalesces reads inside one request and opts agent reads into the runtime's deferred catalog channel when one exists. |
95
96
  | `src/invocation.ts` | One tool call: argument validation, call admission, per-attempt timeout, retry with the connector's own `Retry-After` honoured exactly or declined, result unwrapping, size capping, and the activity record. |
96
97
  | `src/catalog.ts` | Ranking, description summarizing, and the compact schema renderer discovery shows. |
98
+ | `src/result-shapes.ts` | Bounded runtime-only inference and merging for output shapes learned from successful read-only calls whose providers declared none. |
97
99
 
98
100
  `src/meta-tools.ts` and `src/execute.ts` are two front doors onto the same
99
101
  three services. That is the point: a program's `connecta.call` and a top-level
@@ -142,6 +144,7 @@ src/
142
144
  registry.ts connector set, addresses, health, call limiters
143
145
  catalog-service.ts request-local catalog access, search, and describe
144
146
  catalog.ts ranking, summaries, compact schema rendering
147
+ result-shapes.ts passive runtime-only observed output schemas
145
148
  invocation.ts one tool call, end to end
146
149
  catalog-drift.ts vetted manifests and the counts a refresh produces
147
150
  credentials.ts the AES-GCM connector vault over KVStorage
@@ -149,10 +149,7 @@ a cycle, a `BigInt`, a function, a class instance — never round-trips: it eith
149
149
  ends the run with an error or is converted lossily, executor's choice (`X9`).
150
150
  Return JSON-shaped data and the question does not arise.
151
151
 
152
- **P4.** Nothing survives an execution. There is no module scope, cache, or
153
- scratch storage carried to the next program, and no request-bound object outlives
154
- the request that created it. Within one execution, host calls share one
155
- downstream request scope.
152
+ **P4.** Nothing survives an execution. There is no module scope, cache, or scratch storage carried to the next program, and no request-bound object outlives the request that created it. Within one execution, host calls share one downstream request scope. `S9`'s host-owned output observation is catalog metadata, not guest memory: a later program receives no prior value or object, only a labeled field/type schema through discovery.
156
153
 
157
154
  **P5.** Plain JavaScript only. TypeScript syntax is a syntax error. Portable code
158
155
  does not import: QuickJS blocks imports, while Dynamic Workers expose the `X5`
@@ -223,7 +220,7 @@ const page = await connecta.search({
223
220
  });
224
221
  ```
225
222
 
226
- **S1.** Returns one flat page: `{ tools, total, offset, limit, hasMore }`, plus `nextOffset` when more remains and `matchMode: "partial"` when no tool matched every term. Top-level `search_tools` is different: it returns `{ connectors: [{ id, tools }], total, offset, limit, hasMore }`. Complete matches normally precede partial matches, but a partial candidate whose complete normalized tool name occurs in the normalized raw query competes by score; conversational cleanup applies only to scoring terms. Other candidates covering at least two terms fill the page after every complete match; when no complete match exists, the existing any-term fallback remains. Each entry in `tools` carries `address`, `name`, and — when requested — `description`, `inputSchema`, `outputSchema`, `annotations`, and the connector's `guide`. Tool rows expose neither lexical scores nor per-result coverage. An empty or whitespace-only query browses. Non-empty input with no ASCII lexical terms returns no tools and bounded no-match analysis; mixed input searches with its ASCII terms. Compact shapes omit property prose, put required fields first, and cap each shape at 1,024 UTF-8 bytes. Each enum node gets 256 of those bytes. About three near-cap enum nodes can therefore coexist while leaving the final quarter for surrounding syntax; the unchanged global fallback still applies above 1,024 bytes. A capped enum preserves whole values before `unknown` and an exact omitted-value count, while an empty enum renders as `never`. Either cap carries `inputSchemaTruncated` or `outputSchemaTruncated`; a shape-wide cap remains structurally valid with `unknown` types plus `/* truncated */`. Small enums remain complete. Use `connecta.describe` (or JSON search) for omitted exact constraints.
223
+ **S1.** Returns one flat page: `{ tools, total, offset, limit, hasMore }`, plus `nextOffset` when more remains and `matchMode: "partial"` when no tool matched every term. Top-level `search_tools` is different: it returns `{ connectors: [{ id, tools }], total, offset, limit, hasMore }`. Complete matches normally precede partial matches, but a partial candidate whose complete normalized tool name occurs in the normalized raw query competes by score; conversational cleanup applies only to scoring terms. Other candidates covering at least two terms fill the page after every complete match; when no complete match exists, the existing any-term fallback remains. Each entry in `tools` carries `address`, `name`, and — when requested — `description`, `inputSchema`, `outputSchema`, `annotations`, and the connector's `guide`. An output shape learned under `S9` also carries `outputSchemaSource: "observed"`; provider declarations carry no source marker. Tool rows expose neither lexical scores nor per-result coverage. An empty or whitespace-only query browses. Non-empty input with no ASCII lexical terms returns no tools and bounded no-match analysis; mixed input searches with its ASCII terms. Compact shapes omit property prose, put required fields first, and cap each shape at 1,024 UTF-8 bytes. Each enum node gets 256 of those bytes. About three near-cap enum nodes can therefore coexist while leaving the final quarter for surrounding syntax; the unchanged global fallback still applies above 1,024 bytes. A capped enum preserves whole values before `unknown` and an exact omitted-value count, while an empty enum renders as `never`. Either cap carries `inputSchemaTruncated` or `outputSchemaTruncated`; a shape-wide cap remains structurally valid with `unknown` types plus `/* truncated */`. Small enums remain complete. Use `connecta.describe` (or JSON search) for omitted exact constraints.
227
224
 
228
225
  **S1a.** `connector` loads only the named catalog; omit it only when the integration is ambiguous, because an unscoped search fans out across every configured connector. `safety: "readOnly"` returns exactly the tools available through `connecta.call`, connector shortcuts, and `connecta.batch`; `"approvalRequired"` returns the complementary fail-closed class, including false, missing, and contradictory annotations. Omitted or `"all"` preserves the complete catalog. These filters grant no authority and change no admission decision.
229
226
 
@@ -265,7 +262,7 @@ carry a route-aware `nextAction`; a close miss may add three canonical `suggesti
265
262
  Catalog failures add only `retryAfterMs` when known. One bad address never fails the whole call. Each failed entry clamps its
266
263
  caller-authored `address` to 512 UTF-8 bytes with an `…` marker. Entry order
267
264
  correlates a clipped address with its request; successes keep canonical addresses. More than 100
268
- addresses is `invalid_args`; the same 256,000-byte ceiling applies.
265
+ addresses is `invalid_args`; the same 256,000-byte ceiling applies. A success whose output shape came from `S9` carries `outputSchemaSource: "observed"` beside the rendered schema.
269
266
 
270
267
  ### connecta.call
271
268
 
@@ -304,6 +301,8 @@ the batch, and more than ten calls throws.
304
301
 
305
302
  **S8.** Batch and thrown failures share one vocabulary (`E1`): an entry's `errorDetails.code` and `retryable` equal the fields on the error the same call would throw. Use batch for independent concurrency, not to recover lost type.
306
303
 
304
+ **S9.** A successful explicitly read-only call whose provider declared no `outputSchema` passively learns one from the unwrapped result. The observation retains field names and broad JSON types only: no arguments, scalar values, raw results, code, credentials, or errors. Property names may be user-authored. Objects stay open, every field stays optional, and search or describe labels the shape `outputSchemaSource: "observed"` so a model cannot mistake runtime evidence for a provider contract. Later observations merge fields and types in a process-local 256-entry LRU; a provider declaration always wins. Inference stops at depth 6, 128 schema nodes, 48 properties per object, 32 inspected array items, and 128 UTF-8 bytes per property name; `__proto__`, `constructor`, and `prototype` names are discarded. A tool definition over 64 KiB or an observed schema over 16 KiB is ignored. An entry expires after 24 hours and carries the exact serialized tool definition, so a changed catalog entry, process restart, or Worker isolate eviction starts cold. A failed call or failed result-processing step learns nothing, and any observation failure is discarded without changing a successful call. No discovery read, timer, refresh, background job, or storage adapter executes or persists work for this cache: the result-sampling refusal in [#282](https://github.com/zackbart/connecta/issues/282) stands.
305
+
307
306
  ### connecta.emit
308
307
 
309
308
  ```js
@@ -847,6 +846,7 @@ the upstream `Executor` shape assignable.
847
846
  | `S6` | `test/execute.test.ts` (fail-closed annotations, activity parity) |
848
847
  | `S7` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (batch cap) |
849
848
  | `S8`, `E1`, `X11` | both guest-contract executors (caught call, namespace, discovery, utility, batch-validation, budget, and forgery cases; typed batch equivalence) |
849
+ | `S9` | `test/result-shapes.test.ts` (value exclusion, bounds, merging, LRU and time expiry, runtime isolation, read-only admission, declared precedence, definition invalidation, unwrapped MCP results, discovery provenance, copy isolation, and failure isolation) |
850
850
  | `E2`, `E8` | `test/guest-api-contract.test.ts` (code → `retryable`, caught, batch, and uncaught validation recovery), `test/meta-tools.test.ts` (direct, destructive, batch, provider fallback), `test/validate.test.ts` (bounded payload-free findings), `test/errors.test.ts` |
851
851
  | `E3` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (`auth_required`) |
852
852
  | `E4` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (destructive) |
@@ -268,6 +268,16 @@ Tool calls must use the shared invocation path. That keeps direct calls, batch
268
268
  children, and code-mode host calls aligned on safety, retries, admission,
269
269
  timeouts, validation, result guards, and typed failures.
270
270
 
271
+ That path also learns an observed output schema after a successful explicitly
272
+ read-only call when the catalog declared none. The observation retains field
273
+ names and broad JSON types rather than a result. Object fields stay optional,
274
+ additional fields stay allowed, discovery labels the source `observed`, and any
275
+ later provider declaration wins. Property names can be user-authored data, so
276
+ the bounded cache stays in this process or Worker isolate and expires entries
277
+ after 24 hours. An exact tool-definition comparison rejects stale shapes. This
278
+ does not weaken the catalog completeness rule or the refusal of result sampling:
279
+ no catalog read executes a tool.
280
+
271
281
  Connector usage guides are configuration too. `usageGuide` accepts the
272
282
  historical markdown string or `{ content, summary?, required? }`; the latter
273
283
  lets discovery explain what the guide covers without loading it. The summary
@@ -294,3 +304,28 @@ server issuer discovered and validated by the SDK; see
294
304
  [storage and credentials](./storage-and-credentials.md#downstream-oauth).
295
305
  The callback route validates `state` before passing the complete callback query
296
306
  to the SDK so RFC 9207 `iss` validation is not lost.
307
+
308
+ A remote MCP connector that authenticates with a static key has two ways to
309
+ receive one. `{ type: "headers", headers }` bakes the literal value into the
310
+ deployment file, which suits a secret the runtime already holds.
311
+ `{ type: "credential" }` declares the slot instead and lets an operator paste
312
+ the key at `/credentials`, where it is encrypted at rest and rotatable without
313
+ a redeploy:
314
+
315
+ ```ts
316
+ remoteMcp("revenuecat_bepresent", {
317
+ url: "https://mcp.revenuecat.ai/mcp",
318
+ auth: {
319
+ type: "credential",
320
+ credential: { label: "API v2 secret key" },
321
+ },
322
+ });
323
+ ```
324
+
325
+ Header name and framing are configurable — `header` defaults to
326
+ `Authorization`, `scheme` to `Bearer`, `scheme: null` sends the value bare, and
327
+ a `Basic` framing base64-encodes a `user:secret` pair. Every maintained hosted
328
+ connection takes the shape through its existing `auth` option and fills in its
329
+ own label and framing. The full behavior, including rotation and the empty-slot
330
+ state, is in
331
+ [storage and credentials](./storage-and-credentials.md#a-remote-mcp-connectors-static-credential).
@@ -65,9 +65,12 @@ connection; it now answers 404.
65
65
  ## Authentication
66
66
 
67
67
  OAuth 2.1 with dynamic client registration is the default and keeps each
68
- connector instance's flow and tokens in its connector-scoped storage. Linear
69
- also accepts a bearer token or a personal API key passed directly in the
70
- `Authorization` header, which suits a headless deployment:
68
+ connector instance's flow and tokens in its connector-scoped storage. Linear's
69
+ MCP server also "supports passing OAuth token and API keys directly in the
70
+ `Authorization: Bearer <yourtoken>` header instead of using the interactive
71
+ authentication flow" ([Linear MCP docs](https://linear.app/docs/mcp)), which
72
+ suits a headless deployment. Note the framing: the bare-`Authorization`
73
+ convention is Linear's *GraphQL* API, and this endpoint is not that.
71
74
 
72
75
  ```ts
73
76
  linear("automation_tracker", {
@@ -75,7 +78,7 @@ linear("automation_tracker", {
75
78
  access: "read-only",
76
79
  auth: {
77
80
  type: "headers",
78
- headers: { Authorization: env.LINEAR_API_KEY },
81
+ headers: { Authorization: `Bearer ${env.LINEAR_API_KEY}` },
79
82
  },
80
83
  });
81
84
  ```
@@ -85,6 +88,24 @@ configuration. A personal API key carries the acting user's full workspace
85
88
  permissions, so pair it with `access: "read-only"` unless the deployment
86
89
  genuinely writes.
87
90
 
91
+ The same key can arrive from `/credentials` instead, which is what a deployment
92
+ with no secret store — or an operator who rotates keys without a redeploy —
93
+ wants:
94
+
95
+ ```ts
96
+ linear("automation_tracker", {
97
+ purpose: "Headless release reporting",
98
+ access: "read-only",
99
+ auth: { type: "credential" },
100
+ });
101
+ ```
102
+
103
+ The slot renders as "Personal API key" and Connecta sends the stored value as
104
+ `Authorization: Bearer <key>`, the framing Linear's MCP page documents; pass
105
+ `credential` or `scheme` to override either. Until the operator saves a value
106
+ the connector is present and reports `auth_required`. See
107
+ [storage and credentials](./storage-and-credentials.md#a-remote-mcp-connectors-static-credential).
108
+
88
109
  ## Safety classification
89
110
 
90
111
  The wrapper classifies Linear's documented `list_*`, `get_*`, and