@zackbart/connecta 0.24.1 → 0.24.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 (65) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/auth/bearer.js +2 -0
  3. package/dist/auth/clerk.d.ts +0 -5
  4. package/dist/auth/clerk.js +21 -8
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/call-admission.d.ts +4 -0
  8. package/dist/call-admission.js +26 -0
  9. package/dist/catalog-drift.js +9 -4
  10. package/dist/catalog-service.d.ts +2 -0
  11. package/dist/catalog-service.js +25 -8
  12. package/dist/catalog.d.ts +2 -0
  13. package/dist/catalog.js +246 -121
  14. package/dist/connector-access.d.ts +32 -0
  15. package/dist/connector-access.js +79 -0
  16. package/dist/connectors/api.js +11 -1
  17. package/dist/connectors/guarded-fetch.d.ts +1 -1
  18. package/dist/connectors/guarded-fetch.js +27 -20
  19. package/dist/connectors/remote-mcp.js +84 -53
  20. package/dist/errors.d.ts +17 -0
  21. package/dist/errors.js +58 -0
  22. package/dist/execute.js +85 -23
  23. package/dist/executor-result.js +3 -1
  24. package/dist/executors/quickjs-child.js +5 -1
  25. package/dist/executors/quickjs-protocol.d.ts +4 -0
  26. package/dist/executors/quickjs-runtime.d.ts +1 -1
  27. package/dist/executors/quickjs-runtime.js +38 -21
  28. package/dist/executors/quickjs.js +68 -27
  29. package/dist/index.d.ts +37 -1
  30. package/dist/index.js +89 -3
  31. package/dist/invocation.js +134 -93
  32. package/dist/mcp-result.js +3 -2
  33. package/dist/meta-tools.js +118 -39
  34. package/dist/registry.d.ts +29 -1
  35. package/dist/registry.js +122 -15
  36. package/dist/routes/credentials.js +1 -0
  37. package/dist/routes/mcp.d.ts +4 -1
  38. package/dist/routes/mcp.js +112 -12
  39. package/dist/routes/oauth-management.js +1 -0
  40. package/dist/routes/oauth.js +4 -0
  41. package/dist/routes/shared.d.ts +7 -1
  42. package/dist/routes/shared.js +12 -13
  43. package/dist/routes/ui.js +2 -1
  44. package/dist/server.js +15 -3
  45. package/dist/skills.js +6 -5
  46. package/dist/storage/file.d.ts +6 -2
  47. package/dist/storage/file.js +312 -34
  48. package/dist/storage/memory.js +12 -1
  49. package/dist/validate.js +3 -3
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.js +1 -1
  52. package/documentation/architecture.md +30 -9
  53. package/documentation/auth.md +110 -6
  54. package/documentation/call-admission.md +24 -8
  55. package/documentation/code-mode.md +34 -22
  56. package/documentation/connectors.md +47 -5
  57. package/documentation/meta-tools.md +74 -6
  58. package/documentation/operations.md +20 -19
  59. package/documentation/provider-conventions.md +7 -0
  60. package/documentation/request-admission.md +38 -4
  61. package/documentation/storage-and-credentials.md +54 -1
  62. package/documentation/upgrading.md +21 -5
  63. package/ethos.md +1 -1
  64. package/package.json +1 -1
  65. package/templates/node/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { boundedDiscoveryText, CatalogService, DEFAULT_SEARCH_LIMIT, DiscoveryPolicyError, groupedSearchResult, MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, } from "./catalog-service.js";
3
3
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
4
- import { msg } from "./errors.js";
4
+ import { boundedEchoText, msg } from "./errors.js";
5
5
  import { serializeResultText } from "./executor-result.js";
6
6
  import { InvocationService, } from "./invocation.js";
7
7
  import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
@@ -37,7 +37,12 @@ async function discoveryResult(operation, hint) {
37
37
  try {
38
38
  const value = await operation();
39
39
  const text = boundedDiscoveryText(value, hint);
40
- return jsonResult(value, text);
40
+ const result = jsonResult(value, text);
41
+ const bytes = enc.encode(JSON.stringify(result)).length;
42
+ if (bytes > MAX_DISCOVERY_RESULT_BYTES) {
43
+ throw new DiscoveryPolicyError("result_too_large", `Discovery result is ${bytes} UTF-8 bytes, over the ${MAX_DISCOVERY_RESULT_BYTES}-byte ceiling. ${hint}`);
44
+ }
45
+ return result;
41
46
  }
42
47
  catch (err) {
43
48
  if (err instanceof DiscoveryPolicyError) {
@@ -94,29 +99,37 @@ export function alignEndToCharBoundary(bytes, offset, end, total) {
94
99
  }
95
100
  return e;
96
101
  }
97
- // --- result-size guard + get_result (feature 1) ---------------------------
98
- /**
99
- * The one serialization every result guard measures, stashes, and pages: JSON
100
- * text for whatever JSON can represent, and `String(value)` for the returns
101
- * JSON renders as `undefined` — a handler that returns nothing, a function, or
102
- * a Symbol. `JSON.stringify` is *typed* as returning `string` while actually
103
- * returning `undefined` for those, which is how a handler returning `undefined`
104
- * reached clients as a `{"type":"text"}` block carrying no `text` at all: the
105
- * size guard measured `enc.encode(undefined)` — the empty string, per the
106
- * WebIDL default — and emitted the non-string unchanged (issue #42). `null`
107
- * needs no special case; JSON renders it as `"null"`.
108
- *
109
- * Shared by `guardText`, `guardValue`, and execute_code's `guardResultValue` so
110
- * the three give one answer to the same question. A value JSON cannot serialize
111
- * at all (a BigInt) still throws, as before, and is reported as a failure.
112
- */
113
- /**
114
- * Stash `text` under `result:<uuid>` (ttl 900s) and describe it as the
115
- * truncation notice every over-cap path hands back.
116
- */
117
- async function stashResult(text, results, totalBytes) {
102
+ /** Stash a completed result, or return a notice without a paging route. */
103
+ async function stashResult(bytes, results) {
104
+ const totalBytes = bytes.length;
118
105
  const id = crypto.randomUUID();
119
- await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
106
+ try {
107
+ // Base64 permits byte-range decoding after a KV read, without scanning or
108
+ // re-encoding all preceding text. Each chunk is a multiple of three bytes.
109
+ const chunks = [];
110
+ for (let offset = 0; offset < bytes.length; offset += 12_288) {
111
+ chunks.push(btoa(String.fromCharCode(...bytes.subarray(offset, offset + 12_288))));
112
+ }
113
+ const stored = `connecta-result-v1:${totalBytes}:${chunks.join("")}`;
114
+ if (!await results.set(`result:${id}`, stored, RESULT_TTL_SECONDS)) {
115
+ throw new Error("Result stash capacity exhausted");
116
+ }
117
+ }
118
+ catch {
119
+ // Paging is advisory after a completed call, including an approved write.
120
+ // Neither backend prose nor a retry hint belongs in this successful result.
121
+ try {
122
+ results.warn();
123
+ }
124
+ catch {
125
+ // Logging cannot change the call either.
126
+ }
127
+ return {
128
+ truncated: true,
129
+ totalBytes,
130
+ hint: "Paging is unavailable. Use execute_code to reduce read-only results before returning them. Do not repeat a completed write to recover its result.",
131
+ };
132
+ }
120
133
  return {
121
134
  truncated: true,
122
135
  resultId: id,
@@ -141,7 +154,7 @@ async function guardEncoded(text, bytes, results, cap) {
141
154
  truncated: false,
142
155
  };
143
156
  }
144
- const notice = await stashResult(text, results, bytes.length);
157
+ const notice = await stashResult(bytes, results);
145
158
  const head = dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)));
146
159
  return {
147
160
  result: {
@@ -166,8 +179,12 @@ async function guardValue(value, results, cap) {
166
179
  const bytes = enc.encode(text);
167
180
  if (bytes.length <= cap)
168
181
  return { result: value, truncated: false };
182
+ const notice = await stashResult(bytes, results);
169
183
  return {
170
- result: await stashResult(text, results, bytes.length),
184
+ result: notice.resultId ? notice : {
185
+ ...notice,
186
+ preview: dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length))),
187
+ },
171
188
  truncated: true,
172
189
  };
173
190
  }
@@ -210,7 +227,7 @@ async function guardContent(content, results, cap) {
210
227
  if (content.every((b) => b.type === "text")) {
211
228
  return guardEncoded(text, bytes, results, cap);
212
229
  }
213
- const notice = await stashResult(text, results, bytes.length);
230
+ const notice = await stashResult(bytes, results);
214
231
  return {
215
232
  result: { content: [{ type: "text", text: JSON.stringify(notice) }] },
216
233
  truncated: true,
@@ -259,7 +276,6 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
259
276
  const invocation = new InvocationService(registry, catalog, opts.activity);
260
277
  /** MCP adapter: shared invocation semantics plus MCP-only result shaping. */
261
278
  async function runCall(call, source, options = {}) {
262
- const results = registry.resultsStorage();
263
279
  const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
264
280
  const outcome = await invocation.invoke(call.address, call.args ?? {}, {
265
281
  source,
@@ -272,6 +288,13 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
272
288
  : {}),
273
289
  unwrapResult: call.resultMode === "value",
274
290
  processResult: async (result, resolved) => {
291
+ const results = {
292
+ set: (key, value, ttlSeconds) => registry.stashResult(key, value, ttlSeconds),
293
+ warn: () => registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger.warn("[connecta] result paging unavailable", {
294
+ connector: resolved.connector.id,
295
+ tool: resolved.toolName,
296
+ }),
297
+ };
275
298
  // Result-size cap for THIS call: the connector's own override wins,
276
299
  // then the deployment-wide value, then the built-in default (already
277
300
  // folded into `globalCap`). Resolved per call so one request can
@@ -292,7 +315,14 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
292
315
  }
293
316
  if (resolved.connector.kind === "mcp") {
294
317
  const mcpResult = result;
295
- const content = mcpResult?.content ?? [];
318
+ let content = mcpResult?.content ?? [];
319
+ if (!content.some((block) => block.type === "text") &&
320
+ mcpResult?.structuredContent !== undefined) {
321
+ content = [...content, {
322
+ type: "text",
323
+ text: JSON.stringify(mcpResult.structuredContent),
324
+ }];
325
+ }
296
326
  const guarded = await guardContent(content, results, cap);
297
327
  return processed(guarded.result, guarded.truncated);
298
328
  }
@@ -305,12 +335,15 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
305
335
  if (!outcome.ok) {
306
336
  const structuredRecovery = outcome.error.nextAction !== undefined;
307
337
  const recoveryRequired = structuredRecovery ||
338
+ // Sanitized `unavailable` diagnostics ride the structured shape too;
339
+ // the plain-text path would drop them (#539).
340
+ outcome.error.details !== undefined ||
308
341
  [
309
342
  "auth_required",
310
343
  "invalid_args",
311
344
  "input_required_unsupported",
312
345
  ].includes(outcome.error.code);
313
- const failedResult = recoveryRequired ||
346
+ const makeFailedResult = () => recoveryRequired ||
314
347
  call.resultMode === "value"
315
348
  ? jsonResult({
316
349
  ok: false,
@@ -320,6 +353,18 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
320
353
  ...(call.diagnostics ? { timing: outcome.timing } : {}),
321
354
  })
322
355
  : errorResult(outcome.error.message);
356
+ let failedResult = makeFailedResult();
357
+ // Value mode repeats the error in text and structuredContent. Account for
358
+ // both copies and JSON escaping when the bounded provider reason is large.
359
+ if (!recoveryRequired) {
360
+ const cap = resolveMaxResultBytes(outcome.resolved?.connector.maxResultBytes, globalCap);
361
+ let budget = 512;
362
+ while (enc.encode(JSON.stringify(failedResult)).length > cap && budget > 0) {
363
+ budget = Math.floor(budget / 2);
364
+ outcome.error.message = boundedEchoText(outcome.error.message, budget);
365
+ failedResult = makeFailedResult();
366
+ }
367
+ }
323
368
  if (recoveryRequired) {
324
369
  failedResult.isError = true;
325
370
  }
@@ -372,6 +417,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
372
417
  return { content: [{ type: "text", text: skill.content }] };
373
418
  },
374
419
  async searchTools(args) {
420
+ if (args.connector !== undefined && enc.encode(args.connector).length > 512) {
421
+ return discoveryErrorResult(new DiscoveryPolicyError("invalid_args", "connector must be at most 512 UTF-8 bytes."));
422
+ }
375
423
  return discoveryResult(async () => groupedSearchResult(await catalog.search({
376
424
  ...args,
377
425
  includeSchemaKeys: args.includeSchemas !== undefined,
@@ -401,29 +449,60 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
401
449
  `>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
402
450
  }
403
451
  const results = registry.resultsStorage();
404
- const stored = await results.get(`result:${args.id}`);
452
+ let stored;
453
+ try {
454
+ stored = await results.get(`result:${args.id}`);
455
+ }
456
+ catch {
457
+ return {
458
+ ...jsonResult({
459
+ error: {
460
+ code: "unavailable",
461
+ message: "Result paging storage is unavailable.",
462
+ retryable: true,
463
+ },
464
+ }),
465
+ isError: true,
466
+ };
467
+ }
405
468
  if (stored === null || stored === undefined) {
406
- return errorResult(`Unknown or expired result id "${args.id}"`);
469
+ return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
470
+ }
471
+ const requestedOffset = args.offset ?? 0;
472
+ const maxBytes = args.maxBytes ?? globalCap;
473
+ // Decode only this page plus UTF-8 boundary lookaround. Legacy raw-text
474
+ // entries remain readable for their short TTL after an upgrade.
475
+ const header = /^connecta-result-v1:(\d+):/.exec(stored.slice(0, 64));
476
+ let bytes;
477
+ let total;
478
+ let start = 0;
479
+ if (header) {
480
+ total = Number(header[1]);
481
+ start = Math.floor(Math.max(0, Math.min(requestedOffset, total) - 3) / 3) * 3;
482
+ const end = Math.min(total, requestedOffset + maxBytes + 4);
483
+ const binary = atob(stored.slice(header[0].length + start / 3 * 4, header[0].length + Math.ceil(end / 3) * 4));
484
+ bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
485
+ }
486
+ else {
487
+ bytes = enc.encode(stored);
488
+ total = bytes.length;
407
489
  }
408
- const bytes = enc.encode(stored);
409
- const total = bytes.length;
410
490
  // Validated above, so no coercion is needed here — only alignment. A
411
491
  // client that computes its own offsets can land inside a multi-byte
412
492
  // character, which would decode as U+FFFD; the offset actually served is
413
493
  // the boundary at or before it, and it is what the response reports back
414
494
  // as `offset` (issue #38).
415
- const offset = alignStartToCharBoundary(bytes, args.offset ?? 0);
495
+ const offset = start + alignStartToCharBoundary(bytes, requestedOffset - start);
416
496
  // Page size only: a stashed result carries no connector identity, so
417
497
  // get_result keeps the deployment-wide default when none is requested.
418
498
  // Both sides are validated by now — the argument above, `globalCap` at
419
499
  // intake — so `offset + maxBytes` always reaches past `offset`.
420
- const maxBytes = args.maxBytes ?? globalCap;
421
500
  // Align the slice end to a codepoint boundary so a multi-byte char is
422
501
  // never split across pages (which would emit U+FFFD on both sides).
423
502
  // `nextOffset` is this aligned end, so it is a valid boundary for the
424
503
  // next call and paging reassembles the original byte-for-byte.
425
- const end = alignEndToCharBoundary(bytes, offset, offset + maxBytes, total);
426
- const slice = dec.decode(bytes.slice(offset, end));
504
+ const end = start + alignEndToCharBoundary(bytes, offset - start, offset - start + maxBytes, total - start);
505
+ const slice = dec.decode(bytes.subarray(offset - start, end - start));
427
506
  const nextOffset = end < total ? end : undefined;
428
507
  return jsonResult({
429
508
  offset,
@@ -435,7 +514,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
435
514
  async authorizeConnector(args) {
436
515
  const connector = registry.getConnector(args.connector);
437
516
  if (!connector) {
438
- return errorResult(`Unknown connector "${args.connector}"`);
517
+ return errorResult(`Unknown connector "${boundedEchoText(args.connector)}"`);
439
518
  }
440
519
  if (!connector.startAuth) {
441
520
  if (!connector.credential) {
@@ -61,6 +61,10 @@ export interface RegistryOptions {
61
61
  * to the default 50_000.
62
62
  */
63
63
  maxResultBytes?: number | undefined;
64
+ results?: {
65
+ maxStashBytes?: number;
66
+ maxStashEntries?: number;
67
+ } | undefined;
64
68
  /**
65
69
  * Where payload-free catalog-drift observations go. Present only when the
66
70
  * deployment configured an activity store; drift is reported through
@@ -103,6 +107,8 @@ export interface RegistryView {
103
107
  signal?: AbortSignal;
104
108
  }): Promise<CallAdmissionPermit>;
105
109
  resultsStorage(): KVStorage;
110
+ /** Reserve runtime-wide capacity before writing a paging envelope. */
111
+ stashResult(key: string, value: string, ttlSeconds: number): Promise<boolean>;
106
112
  /** Local declared-vs-stored credential mismatch, with no downstream I/O. */
107
113
  credentialDriftFor(id: string): Promise<string | undefined>;
108
114
  /** Value-free shape learned from successful calls, never a provider declaration. */
@@ -114,8 +120,15 @@ export interface RegistryView {
114
120
  /** Bind returned OAuth state to this view's personal storage partition. */
115
121
  bindOAuthHandoff(id: string, authorizationUrl: string): Promise<void>;
116
122
  }
123
+ /**
124
+ * Connector id → the only tool names this view may see on it. A connector
125
+ * absent from the map is visible whole. Derived from `connectorAccess`
126
+ * addresses at the auth gate; never from caller input.
127
+ */
128
+ export type ToolAccess = ReadonlyMap<string, ReadonlySet<string>>;
117
129
  export interface RegistryScope {
118
130
  connectorIds: "all" | readonly string[];
131
+ toolAccess?: ToolAccess;
119
132
  subjectKey?: string;
120
133
  principalKey?: string;
121
134
  }
@@ -149,13 +162,26 @@ export declare class Registry implements RegistryView {
149
162
  private readonly persistToolCatalog;
150
163
  /** Result-size guard cap threaded to the meta-tools. */
151
164
  readonly maxResultBytes: number;
165
+ /** Only keys, byte counts, and expiry survive requests; never write promises. */
166
+ private readonly resultStash;
167
+ private resultStashBytes;
152
168
  private readonly configuredConnectors;
153
169
  private readonly personalRegistries;
170
+ private callAdmissionClosed;
171
+ /** Bounded FIFO of absent grants already warned about. */
172
+ private readonly warnedAbsentGrants;
154
173
  constructor(connectors: Connector[], opts: RegistryOptions);
155
174
  personalRegistry(principalKey: string): Registry;
156
175
  /** Build the only connector view an authenticated request receives. */
157
176
  scoped(scope: RegistryScope): RegistryView;
158
177
  scopedStorage(subjectKey: string): KVStorage;
178
+ /**
179
+ * A granted `connector.tool` address the live catalog does not contain is
180
+ * unreachable, which is the fail-closed outcome; this only makes the
181
+ * misconfiguration visible. Remote catalogs load lazily, so construction
182
+ * cannot check it, and a catalog that drifts later cannot widen a grant.
183
+ */
184
+ noteAbsentGrant(connectorId: string, toolName: string): void;
159
185
  private oauthHandoffKey;
160
186
  storeOAuthHandoff(connectorId: string, state: string, principalKey: string): Promise<void>;
161
187
  oauthCallbackView(connectorId: string, state: string | null): Promise<{
@@ -186,7 +212,7 @@ export declare class Registry implements RegistryView {
186
212
  args: unknown;
187
213
  signal?: AbortSignal;
188
214
  }): Promise<CallAdmissionPermit>;
189
- /** Payload-free aggregate state for the open health endpoint. */
215
+ /** Connector totals across root and personal controllers; health removes ids. */
190
216
  callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot>;
191
217
  /**
192
218
  * Payload-free drift counts for the open health endpoint, so `connecta
@@ -214,6 +240,8 @@ export declare class Registry implements RegistryView {
214
240
  private observeCatalogDrift;
215
241
  /** Reject queued/future downstream admission; active permits release safely. */
216
242
  closeCallAdmission(): void;
243
+ /** Reserve capacity and write one ASCII paging envelope in this runtime. */
244
+ stashResult(key: string, value: string, ttlSeconds: number, prefix?: string): Promise<boolean>;
217
245
  /**
218
246
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
219
247
  * separate from any connector's `conn:<id>:` namespace. Backs get_result.
package/dist/registry.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { closeConnectorScope, } from "./connector-scope.js";
2
2
  import { storedCredentialShape, } from "./credential-rules.js";
3
3
  import { ConnectorCallError, msg } from "./errors.js";
4
- import { ConnectorCallAdmissionController, } from "./call-admission.js";
4
+ import { ConnectorCallAdmissionController, aggregateCallAdmissionSnapshots, } from "./call-admission.js";
5
5
  import { boundedCatalogDrift } from "./catalog-drift.js";
6
6
  import { fingerprintSerializedCatalog, snapshotCatalog, } from "./catalog-fingerprint.js";
7
7
  import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES, } from "./catalog-limits.js";
@@ -80,6 +80,7 @@ function namespaced(storage, prefix) {
80
80
  };
81
81
  }
82
82
  const MAX_PERSONAL_REGISTRIES = 1_024;
83
+ const MAX_ABSENT_GRANT_WARNINGS = 1_024;
83
84
  const OAUTH_HANDOFF_TTL_SECONDS = 15 * 60;
84
85
  async function sha256Hex(value) {
85
86
  const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
@@ -117,8 +118,14 @@ export class Registry {
117
118
  persistToolCatalog;
118
119
  /** Result-size guard cap threaded to the meta-tools. */
119
120
  maxResultBytes;
121
+ /** Only keys, byte counts, and expiry survive requests; never write promises. */
122
+ resultStash = new Map();
123
+ resultStashBytes = 0;
120
124
  configuredConnectors;
121
125
  personalRegistries = new Map();
126
+ callAdmissionClosed = false;
127
+ /** Bounded FIFO of absent grants already warned about. */
128
+ warnedAbsentGrants = new Set();
122
129
  constructor(connectors, opts) {
123
130
  this.opts = opts;
124
131
  this.configuredConnectors = [...connectors];
@@ -172,18 +179,24 @@ export class Registry {
172
179
  this.personalRegistries.set(principalKey, existing);
173
180
  return existing;
174
181
  }
182
+ if (this.personalRegistries.size >= MAX_PERSONAL_REGISTRIES) {
183
+ // Eviction must not reset a live rolling budget or orphan queued calls.
184
+ const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()));
185
+ if (!idle) {
186
+ throw new Error("Personal connector capacity is exhausted; retry after calls and rolling budgets drain.");
187
+ }
188
+ idle[1].closeCallAdmission();
189
+ this.personalRegistries.delete(idle[0]);
190
+ }
175
191
  const registry = new Registry(this.configuredConnectors.filter((connector) => connector.authScope === "personal"), {
176
192
  ...this.opts,
177
193
  storage: namespaced(this.opts.storage, `principal:${principalKey}:`),
178
194
  credentialOwner: principalKey,
179
195
  constructionChecks: false,
180
196
  });
197
+ if (this.callAdmissionClosed)
198
+ registry.closeCallAdmission();
181
199
  this.personalRegistries.set(principalKey, registry);
182
- const oldest = this.personalRegistries.keys().next().value;
183
- if (this.personalRegistries.size > MAX_PERSONAL_REGISTRIES &&
184
- typeof oldest === "string") {
185
- this.personalRegistries.delete(oldest);
186
- }
187
200
  return registry;
188
201
  }
189
202
  /** Build the only connector view an authenticated request receives. */
@@ -201,6 +214,27 @@ export class Registry {
201
214
  scopedStorage(subjectKey) {
202
215
  return namespaced(this.opts.storage, `subject:${subjectKey}:`);
203
216
  }
217
+ /**
218
+ * A granted `connector.tool` address the live catalog does not contain is
219
+ * unreachable, which is the fail-closed outcome; this only makes the
220
+ * misconfiguration visible. Remote catalogs load lazily, so construction
221
+ * cannot check it, and a catalog that drifts later cannot widen a grant.
222
+ */
223
+ noteAbsentGrant(connectorId, toolName) {
224
+ const key = `${connectorId}.${toolName}`;
225
+ if (this.warnedAbsentGrants.has(key))
226
+ return;
227
+ this.warnedAbsentGrants.add(key);
228
+ if (this.warnedAbsentGrants.size > MAX_ABSENT_GRANT_WARNINGS) {
229
+ const oldest = this.warnedAbsentGrants.values().next().value;
230
+ if (oldest !== undefined)
231
+ this.warnedAbsentGrants.delete(oldest);
232
+ }
233
+ // Grant names are operator data but may carry any non-control character;
234
+ // quote them so a line terminator a log reader honours cannot forge a line.
235
+ const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
236
+ this.opts.logger.warn(`connectorAccess grants ${quoted} but connector "${connectorId}" lists no such tool; the grant is unreachable`);
237
+ }
204
238
  oauthHandoffKey(connectorId, stateHash) {
205
239
  return `oauth-handoff:v1:${connectorId}:${stateHash}`;
206
240
  }
@@ -327,11 +361,19 @@ export class Registry {
327
361
  return admission.acquire(input);
328
362
  return Promise.resolve({ waitMs: 0, release() { } });
329
363
  }
330
- /** Payload-free aggregate state for the open health endpoint. */
364
+ /** Connector totals across root and personal controllers; health removes ids. */
331
365
  callAdmissionSnapshot() {
332
- return Object.fromEntries([...this.callAdmission].map(([id, admission]) => [
333
- id,
334
- admission.snapshot(),
366
+ const snapshots = new Map();
367
+ for (const [id, admission] of this.callAdmission) {
368
+ snapshots.set(id, [admission.snapshot()]);
369
+ }
370
+ for (const registry of this.personalRegistries.values()) {
371
+ for (const [id, admission] of registry.callAdmission) {
372
+ snapshots.get(id)?.push(admission.snapshot());
373
+ }
374
+ }
375
+ return Object.fromEntries([...snapshots].map(([id, values]) => [
376
+ id, aggregateCallAdmissionSnapshots(values),
335
377
  ]));
336
378
  }
337
379
  /**
@@ -398,8 +440,54 @@ export class Registry {
398
440
  }
399
441
  /** Reject queued/future downstream admission; active permits release safely. */
400
442
  closeCallAdmission() {
443
+ this.callAdmissionClosed = true;
401
444
  for (const admission of this.callAdmission.values())
402
445
  admission.close();
446
+ for (const registry of this.personalRegistries.values())
447
+ registry.closeCallAdmission();
448
+ }
449
+ /** Reserve capacity and write one ASCII paging envelope in this runtime. */
450
+ async stashResult(key, value, ttlSeconds, prefix = "results:") {
451
+ const maxBytes = this.opts.results?.maxStashBytes ?? 8 * 1024 * 1024;
452
+ const maxEntries = this.opts.results?.maxStashEntries ?? 64;
453
+ // The paging envelope is ASCII, so its string length is its stored byte count.
454
+ const bytes = value.length;
455
+ if (bytes > maxBytes || maxEntries === 0)
456
+ return false;
457
+ const now = Date.now();
458
+ for (const [oldKey, entry] of this.resultStash) {
459
+ if (entry.busy || entry.expiresAt > now)
460
+ continue;
461
+ entry.busy = true;
462
+ try {
463
+ // TTL alone cannot reclaim a lazy backend. Keep the charge until deletion
464
+ // succeeds, including writes which persisted before throwing.
465
+ await this.opts.storage.delete(oldKey);
466
+ this.resultStash.delete(oldKey);
467
+ this.resultStashBytes -= entry.bytes;
468
+ }
469
+ finally {
470
+ entry.busy = false;
471
+ }
472
+ }
473
+ if (this.resultStash.size >= maxEntries || this.resultStashBytes + bytes > maxBytes)
474
+ return false;
475
+ const fullKey = prefix + key;
476
+ const entry = { bytes, expiresAt: Infinity, busy: true };
477
+ this.resultStash.set(fullKey, entry);
478
+ this.resultStashBytes += bytes;
479
+ try {
480
+ await this.opts.storage.set(fullKey, value, { ttlSeconds });
481
+ entry.expiresAt = Date.now() + ttlSeconds * 1000;
482
+ return true;
483
+ }
484
+ catch (error) {
485
+ entry.expiresAt = 0;
486
+ throw error;
487
+ }
488
+ finally {
489
+ entry.busy = false;
490
+ }
403
491
  }
404
492
  /**
405
493
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
@@ -1078,12 +1166,27 @@ class ScopedRegistryView {
1078
1166
  const connector = this.getConnector(parsed.connectorId);
1079
1167
  return connector ? { connector, toolName: parsed.toolName } : null;
1080
1168
  }
1081
- getTools(...args) {
1169
+ async getTools(...args) {
1082
1170
  const registry = this.registryFor(args[0]);
1083
1171
  if (!registry) {
1084
- return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
1172
+ throw new Error(`Unknown connector "${args[0]}"`);
1173
+ }
1174
+ const tools = await registry.getTools(...args);
1175
+ const granted = this.scope.toolAccess?.get(args[0]);
1176
+ if (!granted)
1177
+ return tools;
1178
+ // Every consumer — search, describe, call_tool, and a program's
1179
+ // connecta.call — resolves through this list, so an ungranted tool is
1180
+ // indistinguishable from one the connector never had.
1181
+ const visible = tools.filter((tool) => granted.has(tool.name));
1182
+ if (visible.length < granted.size) {
1183
+ const present = new Set(visible.map((tool) => tool.name));
1184
+ for (const name of granted) {
1185
+ if (!present.has(name))
1186
+ this.root.noteAbsentGrant(args[0], name);
1187
+ }
1085
1188
  }
1086
- return registry.getTools(...args);
1189
+ return visible;
1087
1190
  }
1088
1191
  contextFor(...args) {
1089
1192
  const registry = this.registryFor(args[0]);
@@ -1092,10 +1195,14 @@ class ScopedRegistryView {
1092
1195
  return registry.contextFor(...args);
1093
1196
  }
1094
1197
  admitCall(...args) {
1095
- if (!this.registryFor(args[0])) {
1198
+ const registry = this.registryFor(args[0]);
1199
+ if (!registry) {
1096
1200
  return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
1097
1201
  }
1098
- return this.root.admitCall(...args);
1202
+ return registry.admitCall(...args);
1203
+ }
1204
+ stashResult(key, value, ttlSeconds) {
1205
+ return this.root.stashResult(key, value, ttlSeconds, this.scope.subjectKey ? `subject:${this.scope.subjectKey}:` : "results:");
1099
1206
  }
1100
1207
  resultsStorage() {
1101
1208
  return this.scope.subjectKey
@@ -81,6 +81,7 @@ async function handleCredentialRequest(context, connectorId, action) {
81
81
  validateAuthPermissions(authz, opts.registry);
82
82
  registry = opts.registry.scoped({
83
83
  connectorIds: authz.connectorIds,
84
+ ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
84
85
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
85
86
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
86
87
  });
@@ -4,4 +4,7 @@ export declare const MCP_CORS_HEADERS: {
4
4
  "Access-Control-Allow-Methods": string;
5
5
  "Access-Control-Allow-Headers": string;
6
6
  };
7
- export declare function createMcpRoute(opts: ServerOptions): (context: RouteContext) => Promise<Response | null>;
7
+ export declare function createMcpRoute(opts: ServerOptions): {
8
+ handle(context: RouteContext): Promise<Response | null>;
9
+ rejectOrigin(request: Request): Response | null;
10
+ };