@zackbart/connecta 0.4.0 → 0.5.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 (82) hide show
  1. package/CHANGELOG.md +252 -0
  2. package/README.md +72 -17
  3. package/SECURITY.md +10 -6
  4. package/dist/activity.d.ts +8 -0
  5. package/dist/activity.d.ts.map +1 -1
  6. package/dist/activity.js +1 -0
  7. package/dist/activity.js.map +1 -1
  8. package/dist/connectors/api.d.ts +23 -0
  9. package/dist/connectors/api.d.ts.map +1 -1
  10. package/dist/connectors/api.js +13 -1
  11. package/dist/connectors/api.js.map +1 -1
  12. package/dist/connectors/remote-mcp.d.ts +27 -1
  13. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  14. package/dist/connectors/remote-mcp.js +31 -0
  15. package/dist/connectors/remote-mcp.js.map +1 -1
  16. package/dist/credentials.d.ts +2 -1
  17. package/dist/credentials.d.ts.map +1 -1
  18. package/dist/credentials.js +4 -2
  19. package/dist/credentials.js.map +1 -1
  20. package/dist/execute.d.ts +4 -4
  21. package/dist/execute.d.ts.map +1 -1
  22. package/dist/execute.js.map +1 -1
  23. package/dist/executors/quickjs.d.ts.map +1 -1
  24. package/dist/executors/quickjs.js +32 -4
  25. package/dist/executors/quickjs.js.map +1 -1
  26. package/dist/index.d.ts +51 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +67 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/meta-tools.d.ts +25 -4
  31. package/dist/meta-tools.d.ts.map +1 -1
  32. package/dist/meta-tools.js +138 -24
  33. package/dist/meta-tools.js.map +1 -1
  34. package/dist/registry.d.ts +183 -2
  35. package/dist/registry.d.ts.map +1 -1
  36. package/dist/registry.js +293 -27
  37. package/dist/registry.js.map +1 -1
  38. package/dist/server.d.ts +9 -1
  39. package/dist/server.d.ts.map +1 -1
  40. package/dist/server.js +96 -12
  41. package/dist/server.js.map +1 -1
  42. package/dist/skills.d.ts +52 -1
  43. package/dist/skills.d.ts.map +1 -1
  44. package/dist/skills.js +161 -1
  45. package/dist/skills.js.map +1 -1
  46. package/dist/storage/file.d.ts.map +1 -1
  47. package/dist/storage/file.js +19 -3
  48. package/dist/storage/file.js.map +1 -1
  49. package/dist/toolkits.d.ts +44 -0
  50. package/dist/toolkits.d.ts.map +1 -0
  51. package/dist/toolkits.js +134 -0
  52. package/dist/toolkits.js.map +1 -0
  53. package/dist/types.d.ts +20 -1
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/ui.d.ts +29 -1
  56. package/dist/ui.d.ts.map +1 -1
  57. package/dist/ui.js +100 -15
  58. package/dist/ui.js.map +1 -1
  59. package/dist/validate.d.ts +33 -1
  60. package/dist/validate.d.ts.map +1 -1
  61. package/dist/validate.js +32 -2
  62. package/dist/validate.js.map +1 -1
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/package.json +5 -2
  66. package/src/activity.ts +9 -0
  67. package/src/connectors/api.ts +36 -1
  68. package/src/connectors/remote-mcp.ts +67 -0
  69. package/src/credentials.ts +6 -3
  70. package/src/execute.ts +4 -4
  71. package/src/executors/quickjs.ts +32 -4
  72. package/src/index.ts +141 -2
  73. package/src/meta-tools.ts +215 -40
  74. package/src/registry.ts +416 -29
  75. package/src/server.ts +130 -12
  76. package/src/skills.ts +184 -1
  77. package/src/storage/file.ts +18 -2
  78. package/src/toolkits.ts +215 -0
  79. package/src/types.ts +20 -1
  80. package/src/ui.ts +103 -14
  81. package/src/validate.ts +60 -2
  82. package/src/version.ts +1 -1
package/src/registry.ts CHANGED
@@ -7,11 +7,53 @@ import type {
7
7
  ToolDef,
8
8
  } from "./types.js";
9
9
  import type { CredentialVault } from "./credentials.js";
10
+ import { splitAddress, type Toolkit } from "./toolkits.js";
10
11
 
11
12
  const ID_RE = /^[a-z0-9_-]+$/;
12
13
  const DEFAULT_TTL_SECONDS = 300;
13
14
  const DEFAULT_STALE_SECONDS = 3600;
14
- const DEFAULT_MAX_RESULT_BYTES = 50_000;
15
+ export const DEFAULT_MAX_RESULT_BYTES = 50_000;
16
+
17
+ /**
18
+ * Smallest accepted inline-result cap. One byte is pathological but harmless:
19
+ * `alignEndToCharBoundary` widens a window narrower than the codepoint at the
20
+ * offset, so even a 1-byte cap still truncates sanely and still pages. Caps
21
+ * that small already ship in the test suite (4 and 5), so the floor is placed
22
+ * where it excludes only values that are *broken* rather than merely tiny.
23
+ */
24
+ export const MIN_MAX_RESULT_BYTES = 1;
25
+
26
+ /**
27
+ * The one definition of a usable `maxResultBytes`: a finite whole number of at
28
+ * least {@link MIN_MAX_RESULT_BYTES} bytes. Shared by all three intake points
29
+ * — deployment config, the per-connector override, and `get_result`'s
30
+ * `maxBytes` argument — so a value that is valid at one is valid at all.
31
+ *
32
+ * Everything else is rejected rather than coerced, because each rejected shape
33
+ * silently does something *worse* than the default: `0`/`NaN` serve an empty
34
+ * head (`slice(0, 0)`) and make paging fail to advance, negatives serve a
35
+ * LARGER head than asked for (`slice(0, -1)` counts from the end) while still
36
+ * claiming truncation, and `Infinity` disables the guard with no notice.
37
+ */
38
+ export function isValidMaxResultBytes(value: number): boolean {
39
+ return Number.isInteger(value) && value >= MIN_MAX_RESULT_BYTES;
40
+ }
41
+
42
+ /**
43
+ * Resolve a configured cap against the value it inherits, dropping anything
44
+ * `isValidMaxResultBytes` rejects. Operator-facing surfaces pair this with a
45
+ * startup warning (see `Registry.checkResultCaps`) so the fallback is never
46
+ * silent; the resolution itself stays total so no call site has to cope with
47
+ * a broken cap.
48
+ */
49
+ export function resolveMaxResultBytes(
50
+ value: number | undefined,
51
+ inherited: number,
52
+ ): number {
53
+ return value !== undefined && isValidMaxResultBytes(value)
54
+ ? value
55
+ : inherited;
56
+ }
15
57
 
16
58
  interface CacheEntry {
17
59
  tools: ToolDef[];
@@ -34,6 +76,48 @@ export interface HealthObservation {
34
76
  lastError?: string;
35
77
  }
36
78
 
79
+ /**
80
+ * Recent real-call outcomes per connector, as observed by ONE view.
81
+ *
82
+ * The deployment keeps one log, and each toolkit keeps its own. That split is a
83
+ * scope boundary, not bookkeeping: `lastError` is a downstream error string
84
+ * that routinely names the tool that failed, and `list_connectors` returns the
85
+ * observation verbatim. Sharing one log would let a toolkit read back the tools
86
+ * and failures of a sibling toolkit through a connector they happen to share —
87
+ * and let a sibling's failures flip this view's reported status. A scoped view
88
+ * reads back only the calls it made itself.
89
+ */
90
+ export class HealthLog {
91
+ private readonly observations = new Map<string, HealthObservation>();
92
+
93
+ recordSuccess(id: string, latencyMs: number): void {
94
+ const previous = this.observations.get(id);
95
+ this.observations.set(id, {
96
+ ...previous,
97
+ lastSuccessAt: new Date().toISOString(),
98
+ lastLatencyMs: latencyMs,
99
+ consecutiveFailures: 0,
100
+ lastError: undefined,
101
+ });
102
+ }
103
+
104
+ recordFailure(id: string, latencyMs: number, error: unknown): void {
105
+ const previous = this.observations.get(id);
106
+ this.observations.set(id, {
107
+ ...previous,
108
+ lastFailureAt: new Date().toISOString(),
109
+ lastLatencyMs: latencyMs,
110
+ consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
111
+ lastError: msg(error),
112
+ });
113
+ }
114
+
115
+ get(id: string): HealthObservation | undefined {
116
+ const observation = this.observations.get(id);
117
+ return observation ? { ...observation } : undefined;
118
+ }
119
+ }
120
+
37
121
  export interface RegistryOptions {
38
122
  storage: KVStorage;
39
123
  logger: Logger;
@@ -41,7 +125,11 @@ export interface RegistryOptions {
41
125
  toolCacheTtlSeconds?: number;
42
126
  persistToolCatalog?: boolean;
43
127
  toolCatalogStaleSeconds?: number;
44
- /** Cap on inline result size before truncation + get_result paging. Default 50_000. */
128
+ /**
129
+ * Cap on inline result size before truncation + get_result paging. Must be a
130
+ * whole number of bytes >= 1; anything else warns at startup and falls back
131
+ * to the default 50_000.
132
+ */
45
133
  maxResultBytes?: number;
46
134
  }
47
135
 
@@ -57,16 +145,67 @@ function msg(err: unknown): string {
57
145
  return err instanceof Error ? err.message : String(err);
58
146
  }
59
147
 
148
+ /**
149
+ * The registry surface a per-connection MCP server consumes: every meta-tool
150
+ * (`src/meta-tools.ts`) and the `execute_code` sandbox bridge (`src/execute.ts`)
151
+ * is typed against THIS, never against the concrete `Registry`.
152
+ *
153
+ * That indirection is the toolkit boundary (issue #22): a scoped connection is
154
+ * handed a `ScopedRegistry` instead of the registry, and every meta-tool
155
+ * inherits the scope from one place. Reaching for a registry method that isn't
156
+ * here is a compile error, and adding one here is a compile error until
157
+ * `ScopedRegistry` filters it — so a new meta-tool cannot quietly step around
158
+ * the boundary.
159
+ */
160
+ export interface RegistryView {
161
+ /** Deployment-wide result-size cap threaded to the meta-tools. */
162
+ readonly maxResultBytes: number;
163
+ listConnectors(): Connector[];
164
+ getConnector(id: string): Connector | undefined;
165
+ resolveAddress(
166
+ address: string,
167
+ ): { connector: Connector; toolName: string } | null;
168
+ getTools(
169
+ id: string,
170
+ baseUrl: string,
171
+ requestScope?: object,
172
+ ): Promise<ToolDef[]>;
173
+ refreshTools(
174
+ id: string,
175
+ baseUrl: string,
176
+ requestScope?: object,
177
+ ): Promise<ToolDef[]>;
178
+ peekTools(id: string): ToolDef[] | undefined;
179
+ contextFor(
180
+ id: string,
181
+ baseUrl: string,
182
+ requestScope?: object,
183
+ callOptions?: { signal?: AbortSignal; timeoutMs?: number },
184
+ ): ConnectorContext;
185
+ resultsStorage(): KVStorage;
186
+ recordSuccess(id: string, latencyMs: number): void;
187
+ recordFailure(id: string, latencyMs: number, error: unknown): void;
188
+ healthFor(id: string): HealthObservation | undefined;
189
+ hasObservedSuccess(id: string): boolean;
190
+ statusFor(
191
+ id: string,
192
+ baseUrl: string,
193
+ requestScope?: object,
194
+ ): Promise<ConnectorStatus>;
195
+ invalidateStored(id: string): Promise<void>;
196
+ }
197
+
60
198
  /**
61
199
  * Holds the connector set, resolves addresses, and caches per-connector tool
62
200
  * lists in memory with a TTL. Connector failures are isolated: a broken
63
201
  * connector surfaces status "error"; the rest keep working.
64
202
  */
65
- export class Registry {
203
+ export class Registry implements RegistryView {
66
204
  private readonly connectors = new Map<string, Connector>();
67
205
  private readonly cache = new Map<string, CacheEntry>();
68
206
  private readonly invalidated = new Set<string>();
69
- private readonly health = new Map<string, HealthObservation>();
207
+ /** Deployment-wide observations every call, whatever view made it. */
208
+ private readonly health = new HealthLog();
70
209
  private readonly ttlMs: number;
71
210
  private readonly staleMs: number;
72
211
  private readonly persistToolCatalog: boolean;
@@ -82,7 +221,10 @@ export class Registry {
82
221
  this.staleMs =
83
222
  (opts.toolCatalogStaleSeconds ?? DEFAULT_STALE_SECONDS) * 1000;
84
223
  this.persistToolCatalog = opts.persistToolCatalog ?? true;
85
- this.maxResultBytes = opts.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES;
224
+ this.maxResultBytes = resolveMaxResultBytes(
225
+ opts.maxResultBytes,
226
+ DEFAULT_MAX_RESULT_BYTES,
227
+ );
86
228
  for (const c of connectors) {
87
229
  if (!ID_RE.test(c.id)) {
88
230
  throw new Error(
@@ -95,6 +237,47 @@ export class Registry {
95
237
  this.connectors.set(c.id, c);
96
238
  }
97
239
  this.checkConventions(opts.logger);
240
+ this.checkResultCaps(opts.logger, opts.maxResultBytes);
241
+ }
242
+
243
+ /**
244
+ * Warn once per unusable result cap, at construction time — the same
245
+ * "runs fine but is surely unintended" channel as the insecure-config
246
+ * warnings in `createConnecta`. A rejected cap can't be honoured, and
247
+ * honouring it *approximately* is exactly the inversion issue #32 is about,
248
+ * so the value is dropped in favour of what it inherits and the operator is
249
+ * told which one is actually in force.
250
+ */
251
+ private checkResultCaps(
252
+ logger: Logger,
253
+ configured: number | undefined,
254
+ ): void {
255
+ if (configured !== undefined && !isValidMaxResultBytes(configured)) {
256
+ logger.warn(
257
+ `[connecta] maxResultBytes ${configured} is not a whole number of ` +
258
+ `bytes >= ${MIN_MAX_RESULT_BYTES}: it would serve an empty, ` +
259
+ "oversized, or unguarded result instead of truncating. Using the " +
260
+ `default ${DEFAULT_MAX_RESULT_BYTES} instead.`,
261
+ );
262
+ }
263
+ for (const c of this.connectors.values()) {
264
+ if (
265
+ c.maxResultBytes !== undefined &&
266
+ !isValidMaxResultBytes(c.maxResultBytes)
267
+ ) {
268
+ // The number quoted here is `this.maxResultBytes` because that is
269
+ // literally what a call falls back to: meta-tools reads the inherited
270
+ // cap off `RegistryView.maxResultBytes`, so the warned value and the
271
+ // runtime value are the same field rather than two copies of it.
272
+ logger.warn(
273
+ `[connecta] connector "${c.id}" sets maxResultBytes ` +
274
+ `${c.maxResultBytes}, which is not a whole number of bytes >= ` +
275
+ `${MIN_MAX_RESULT_BYTES}. Ignoring the override — the connector ` +
276
+ `inherits the deployment-wide cap calls fall back to ` +
277
+ `(${this.maxResultBytes}).`,
278
+ );
279
+ }
280
+ }
98
281
  }
99
282
 
100
283
  /**
@@ -169,13 +352,11 @@ export class Registry {
169
352
  resolveAddress(
170
353
  address: string,
171
354
  ): { connector: Connector; toolName: string } | null {
172
- const dot = address.indexOf(".");
173
- if (dot <= 0 || dot === address.length - 1) return null;
174
- const id = address.slice(0, dot);
175
- const toolName = address.slice(dot + 1);
176
- const connector = this.connectors.get(id);
355
+ const parts = splitAddress(address);
356
+ if (!parts) return null;
357
+ const connector = this.connectors.get(parts.connectorId);
177
358
  if (!connector) return null;
178
- return { connector, toolName };
359
+ return { connector, toolName: parts.toolName };
179
360
  }
180
361
 
181
362
  private catalogKey(id: string): string {
@@ -320,30 +501,27 @@ export class Registry {
320
501
  }
321
502
 
322
503
  recordSuccess(id: string, latencyMs: number): void {
323
- const previous = this.health.get(id);
324
- this.health.set(id, {
325
- ...previous,
326
- lastSuccessAt: new Date().toISOString(),
327
- lastLatencyMs: latencyMs,
328
- consecutiveFailures: 0,
329
- lastError: undefined,
330
- });
504
+ this.health.recordSuccess(id, latencyMs);
331
505
  }
332
506
 
333
507
  recordFailure(id: string, latencyMs: number, error: unknown): void {
334
- const previous = this.health.get(id);
335
- this.health.set(id, {
336
- ...previous,
337
- lastFailureAt: new Date().toISOString(),
338
- lastLatencyMs: latencyMs,
339
- consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
340
- lastError: msg(error),
341
- });
508
+ this.health.recordFailure(id, latencyMs, error);
342
509
  }
343
510
 
344
511
  healthFor(id: string): HealthObservation | undefined {
345
- const observation = this.health.get(id);
346
- return observation ? { ...observation } : undefined;
512
+ return this.health.get(id);
513
+ }
514
+
515
+ /**
516
+ * Whether ANY view of this deployment has seen a successful call to `id` —
517
+ * a bare boolean, never the observation. Connector liveness (reachable,
518
+ * credentials still valid) is a deployment-level fact, not a per-view one, so
519
+ * `list_connectors` may classify a connector as ok/unknown from it. The
520
+ * observation itself — `lastError` above all, which names the tool that
521
+ * failed — stays strictly per view.
522
+ */
523
+ hasObservedSuccess(id: string): boolean {
524
+ return this.health.get(id)?.lastSuccessAt !== undefined;
347
525
  }
348
526
 
349
527
  /** Best-effort connector status for list_connectors. */
@@ -398,3 +576,212 @@ export class Registry {
398
576
  }
399
577
  }
400
578
  }
579
+
580
+ /**
581
+ * Per-toolkit HealthLogs, keyed by registry and then by toolkit name.
582
+ *
583
+ * Module-scoped rather than a `Registry` member on purpose: `Registry`'s type
584
+ * is part of the public API surface (`Connecta.registry`), and this is internal
585
+ * factoring only `ScopedRegistry` may touch. The WeakMap keeps the logs alive
586
+ * exactly as long as their registry, and the inner map is bounded by the
587
+ * number of configured toolkits.
588
+ */
589
+ const toolkitHealthLogs = new WeakMap<Registry, Map<string, HealthLog>>();
590
+
591
+ /** The long-lived log one toolkit records into; created on first use. */
592
+ function toolkitHealthLog(base: Registry, toolkitName: string): HealthLog {
593
+ let logs = toolkitHealthLogs.get(base);
594
+ if (!logs) {
595
+ logs = new Map<string, HealthLog>();
596
+ toolkitHealthLogs.set(base, logs);
597
+ }
598
+ let log = logs.get(toolkitName);
599
+ if (!log) {
600
+ log = new HealthLog();
601
+ logs.set(toolkitName, log);
602
+ }
603
+ return log;
604
+ }
605
+
606
+ /**
607
+ * THE toolkit enforcement point (issue #22).
608
+ *
609
+ * A filtered VIEW of one long-lived `Registry`: same connectors, same tool
610
+ * caches, same health — narrowed to the connectors and tool addresses one
611
+ * toolkit selects. `serveMcp` builds it once per scoped connection and hands it
612
+ * to `registerMetaTools`/`registerExecuteTool`, so `list_connectors`,
613
+ * `search_tools`, `describe_tools`, `call_tool`, `call_destructive_tool`,
614
+ * `batch_call`, `authorize_connector`, `get_result`, `skills`, and the
615
+ * `execute_code` host bridge all inherit the boundary from here instead of
616
+ * re-implementing nine checks.
617
+ *
618
+ * Two invariants make it reviewable:
619
+ *
620
+ * 1. **Indistinguishability.** Out-of-scope input fails through the SAME code
621
+ * path, with the same error class and message, as input naming something
622
+ * that does not exist at all. An out-of-scope CONNECTOR disappears from
623
+ * `resolveAddress`/`getConnector` (→ `Unknown address` / `Unknown
624
+ * connector`); an out-of-scope TOOL disappears from the catalog this view
625
+ * returns (→ `Unknown tool "<t>" on connector "<c>"`), which is exactly the
626
+ * error a misspelled tool name already produced. Scoping deliberately does
627
+ * NOT reject at `resolveAddress` for tools: that would answer with the
628
+ * connector-level message and make "hidden here" distinguishable from
629
+ * "never existed".
630
+ * 2. **It filters views, never state.** Every read delegates to the shared
631
+ * registry and filters the returned array, so the tool cache, the persisted
632
+ * catalog, and the health map stay whole and shared across scopes.
633
+ */
634
+ export class ScopedRegistry implements RegistryView {
635
+ /** This toolkit's own health observations — see HealthLog. */
636
+ private readonly health: HealthLog;
637
+
638
+ constructor(
639
+ private readonly base: Registry,
640
+ private readonly toolkit: Toolkit,
641
+ ) {
642
+ this.health = toolkitHealthLog(base, toolkit.name);
643
+ }
644
+
645
+ get maxResultBytes(): number {
646
+ return this.base.maxResultBytes;
647
+ }
648
+
649
+ /** In scope AND actually registered. */
650
+ private visible(id: string): boolean {
651
+ return (
652
+ this.toolkit.hasConnector(id) && this.base.getConnector(id) !== undefined
653
+ );
654
+ }
655
+
656
+ /** Byte-identical to what the unscoped registry throws for an unknown id. */
657
+ private unknownConnector(id: string): Error {
658
+ return new Error(`Unknown connector "${id}"`);
659
+ }
660
+
661
+ private inScopeTools(id: string, tools: ToolDef[]): ToolDef[] {
662
+ return tools.filter((tool) => this.toolkit.hasTool(id, tool.name));
663
+ }
664
+
665
+ listConnectors(): Connector[] {
666
+ return this.base
667
+ .listConnectors()
668
+ .filter((connector) => this.toolkit.hasConnector(connector.id));
669
+ }
670
+
671
+ getConnector(id: string): Connector | undefined {
672
+ return this.visible(id) ? this.base.getConnector(id) : undefined;
673
+ }
674
+
675
+ resolveAddress(
676
+ address: string,
677
+ ): { connector: Connector; toolName: string } | null {
678
+ const resolved = this.base.resolveAddress(address);
679
+ if (!resolved) return null;
680
+ // Connector-level only — see invariant 1 above.
681
+ return this.toolkit.hasConnector(resolved.connector.id) ? resolved : null;
682
+ }
683
+
684
+ async getTools(
685
+ id: string,
686
+ baseUrl: string,
687
+ requestScope?: object,
688
+ ): Promise<ToolDef[]> {
689
+ if (!this.visible(id)) throw this.unknownConnector(id);
690
+ return this.inScopeTools(
691
+ id,
692
+ await this.base.getTools(id, baseUrl, requestScope),
693
+ );
694
+ }
695
+
696
+ async refreshTools(
697
+ id: string,
698
+ baseUrl: string,
699
+ requestScope?: object,
700
+ ): Promise<ToolDef[]> {
701
+ if (!this.visible(id)) throw this.unknownConnector(id);
702
+ return this.inScopeTools(
703
+ id,
704
+ await this.base.refreshTools(id, baseUrl, requestScope),
705
+ );
706
+ }
707
+
708
+ peekTools(id: string): ToolDef[] | undefined {
709
+ if (!this.visible(id)) return undefined;
710
+ const tools = this.base.peekTools(id);
711
+ return tools ? this.inScopeTools(id, tools) : undefined;
712
+ }
713
+
714
+ contextFor(
715
+ id: string,
716
+ baseUrl: string,
717
+ requestScope: object = {},
718
+ callOptions: { signal?: AbortSignal; timeoutMs?: number } = {},
719
+ ): ConnectorContext {
720
+ // Unreachable through the meta-tools (they resolve first), so a throw here
721
+ // is a loud backstop rather than a silent grant of connector storage and
722
+ // credentials to a scope that may not see the connector.
723
+ if (!this.visible(id)) throw this.unknownConnector(id);
724
+ return this.base.contextFor(id, baseUrl, requestScope, callOptions);
725
+ }
726
+
727
+ /**
728
+ * Stashed oversized results are bound to the scope that produced them: a
729
+ * scoped session cannot page a result it could not have produced, and an id
730
+ * from another scope reads back as the ordinary "Unknown or expired result
731
+ * id". Unscoped sessions keep the historical `results:` prefix untouched.
732
+ */
733
+ resultsStorage(): KVStorage {
734
+ return namespaced(
735
+ this.base.resultsStorage(),
736
+ `toolkit:${this.toolkit.name}:`,
737
+ );
738
+ }
739
+
740
+ // Outcomes are recorded twice on purpose: the deployment-wide log keeps the
741
+ // operator surfaces complete, while the toolkit's own log is the ONLY one
742
+ // this view reads back — so `list_connectors` here never reports a sibling
743
+ // toolkit's failures, or the tool names their error strings carry.
744
+ recordSuccess(id: string, latencyMs: number): void {
745
+ if (!this.visible(id)) return;
746
+ this.base.recordSuccess(id, latencyMs);
747
+ this.health.recordSuccess(id, latencyMs);
748
+ }
749
+
750
+ recordFailure(id: string, latencyMs: number, error: unknown): void {
751
+ if (!this.visible(id)) return;
752
+ this.base.recordFailure(id, latencyMs, error);
753
+ this.health.recordFailure(id, latencyMs, error);
754
+ }
755
+
756
+ healthFor(id: string): HealthObservation | undefined {
757
+ return this.visible(id) ? this.health.get(id) : undefined;
758
+ }
759
+
760
+ /**
761
+ * Deliberately NOT per view: whether the connector has ever answered is a
762
+ * fact about the connector, not about a team's traffic, and withholding it
763
+ * would report every remote connector as "unknown" to a scoped session that
764
+ * has not called it yet. It carries no tool name, error text, or count — the
765
+ * per-view isolation of those, above, is unchanged.
766
+ */
767
+ hasObservedSuccess(id: string): boolean {
768
+ return this.visible(id) ? this.base.hasObservedSuccess(id) : false;
769
+ }
770
+
771
+ async statusFor(
772
+ id: string,
773
+ baseUrl: string,
774
+ requestScope: object = {},
775
+ ): Promise<ConnectorStatus> {
776
+ // Same shape the unscoped registry returns for an unregistered id.
777
+ if (!this.visible(id)) {
778
+ return { state: "error", message: "Unknown connector" };
779
+ }
780
+ return this.base.statusFor(id, baseUrl, requestScope);
781
+ }
782
+
783
+ async invalidateStored(id: string): Promise<void> {
784
+ if (!this.visible(id)) return;
785
+ await this.base.invalidateStored(id);
786
+ }
787
+ }