@zackbart/connecta 0.24.2 → 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 (57) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/dist/auth/bearer.js +2 -0
  3. package/dist/auth/downstream-oauth.d.ts +12 -1
  4. package/dist/auth/downstream-oauth.js +147 -35
  5. package/dist/call-admission.d.ts +4 -0
  6. package/dist/call-admission.js +26 -0
  7. package/dist/catalog-drift.js +9 -4
  8. package/dist/catalog-service.d.ts +2 -0
  9. package/dist/catalog-service.js +25 -8
  10. package/dist/catalog.d.ts +2 -0
  11. package/dist/catalog.js +246 -121
  12. package/dist/connectors/api.js +11 -1
  13. package/dist/connectors/guarded-fetch.d.ts +1 -1
  14. package/dist/connectors/guarded-fetch.js +27 -20
  15. package/dist/connectors/remote-mcp.js +84 -53
  16. package/dist/errors.d.ts +17 -0
  17. package/dist/errors.js +58 -0
  18. package/dist/execute.js +85 -23
  19. package/dist/executor-result.js +3 -1
  20. package/dist/executors/quickjs-child.js +5 -1
  21. package/dist/executors/quickjs-protocol.d.ts +4 -0
  22. package/dist/executors/quickjs-runtime.d.ts +1 -1
  23. package/dist/executors/quickjs-runtime.js +38 -21
  24. package/dist/executors/quickjs.js +68 -27
  25. package/dist/index.d.ts +14 -0
  26. package/dist/index.js +24 -3
  27. package/dist/invocation.js +134 -93
  28. package/dist/mcp-result.js +3 -2
  29. package/dist/meta-tools.js +118 -39
  30. package/dist/registry.d.ts +14 -2
  31. package/dist/registry.js +87 -13
  32. package/dist/routes/mcp.d.ts +4 -1
  33. package/dist/routes/mcp.js +84 -13
  34. package/dist/routes/oauth.js +4 -0
  35. package/dist/routes/shared.d.ts +1 -0
  36. package/dist/routes/shared.js +4 -4
  37. package/dist/server.js +15 -3
  38. package/dist/skills.js +6 -5
  39. package/dist/storage/file.d.ts +6 -2
  40. package/dist/storage/file.js +312 -34
  41. package/dist/storage/memory.js +12 -1
  42. package/dist/validate.js +3 -3
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/documentation/architecture.md +22 -6
  46. package/documentation/auth.md +42 -9
  47. package/documentation/call-admission.md +24 -8
  48. package/documentation/code-mode.md +34 -22
  49. package/documentation/connectors.md +47 -5
  50. package/documentation/meta-tools.md +74 -6
  51. package/documentation/operations.md +19 -19
  52. package/documentation/provider-conventions.md +7 -0
  53. package/documentation/request-admission.md +38 -4
  54. package/documentation/storage-and-credentials.md +54 -1
  55. package/documentation/upgrading.md +18 -4
  56. package/package.json +1 -1
  57. package/templates/node/package.json +1 -1
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,9 +118,13 @@ 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();
122
- /** `connector.tool` grants that matched nothing, warned once per isolate. */
126
+ callAdmissionClosed = false;
127
+ /** Bounded FIFO of absent grants already warned about. */
123
128
  warnedAbsentGrants = new Set();
124
129
  constructor(connectors, opts) {
125
130
  this.opts = opts;
@@ -174,18 +179,24 @@ export class Registry {
174
179
  this.personalRegistries.set(principalKey, existing);
175
180
  return existing;
176
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
+ }
177
191
  const registry = new Registry(this.configuredConnectors.filter((connector) => connector.authScope === "personal"), {
178
192
  ...this.opts,
179
193
  storage: namespaced(this.opts.storage, `principal:${principalKey}:`),
180
194
  credentialOwner: principalKey,
181
195
  constructionChecks: false,
182
196
  });
197
+ if (this.callAdmissionClosed)
198
+ registry.closeCallAdmission();
183
199
  this.personalRegistries.set(principalKey, registry);
184
- const oldest = this.personalRegistries.keys().next().value;
185
- if (this.personalRegistries.size > MAX_PERSONAL_REGISTRIES &&
186
- typeof oldest === "string") {
187
- this.personalRegistries.delete(oldest);
188
- }
189
200
  return registry;
190
201
  }
191
202
  /** Build the only connector view an authenticated request receives. */
@@ -214,6 +225,11 @@ export class Registry {
214
225
  if (this.warnedAbsentGrants.has(key))
215
226
  return;
216
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
+ }
217
233
  // Grant names are operator data but may carry any non-control character;
218
234
  // quote them so a line terminator a log reader honours cannot forge a line.
219
235
  const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
@@ -345,11 +361,19 @@ export class Registry {
345
361
  return admission.acquire(input);
346
362
  return Promise.resolve({ waitMs: 0, release() { } });
347
363
  }
348
- /** Payload-free aggregate state for the open health endpoint. */
364
+ /** Connector totals across root and personal controllers; health removes ids. */
349
365
  callAdmissionSnapshot() {
350
- return Object.fromEntries([...this.callAdmission].map(([id, admission]) => [
351
- id,
352
- 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),
353
377
  ]));
354
378
  }
355
379
  /**
@@ -416,8 +440,54 @@ export class Registry {
416
440
  }
417
441
  /** Reject queued/future downstream admission; active permits release safely. */
418
442
  closeCallAdmission() {
443
+ this.callAdmissionClosed = true;
419
444
  for (const admission of this.callAdmission.values())
420
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
+ }
421
491
  }
422
492
  /**
423
493
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
@@ -1125,10 +1195,14 @@ class ScopedRegistryView {
1125
1195
  return registry.contextFor(...args);
1126
1196
  }
1127
1197
  admitCall(...args) {
1128
- if (!this.registryFor(args[0])) {
1198
+ const registry = this.registryFor(args[0]);
1199
+ if (!registry) {
1129
1200
  return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
1130
1201
  }
1131
- 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:");
1132
1206
  }
1133
1207
  resultsStorage() {
1134
1208
  return this.scope.subjectKey
@@ -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
+ };
@@ -14,11 +14,26 @@ export const MCP_CORS_HEADERS = {
14
14
  // Browser-based MCP clients call /mcp cross-origin. Without CORS on every
15
15
  // response — errors included — the browser hides the 401, the client cannot
16
16
  // read WWW-Authenticate, and OAuth discovery silently never starts.
17
- function withMcpCors(response) {
17
+ function withMcpCors(response, request, allowedOrigin) {
18
18
  const headers = new Headers(response.headers);
19
19
  for (const [name, value] of Object.entries(MCP_CORS_HEADERS)) {
20
20
  headers.set(name, value);
21
21
  }
22
+ headers.delete("Access-Control-Allow-Origin");
23
+ if (allowedOrigin !== null)
24
+ headers.set("Access-Control-Allow-Origin", allowedOrigin);
25
+ headers.append("Vary", "Origin");
26
+ if (request.method === "OPTIONS") {
27
+ // Browsers do not interpret a prefix wildcard in Allow-Headers. Echo only
28
+ // valid SEP-2243 field names; unrelated requested headers stay disallowed.
29
+ const paramHeaders = (request.headers.get("Access-Control-Request-Headers") ?? "")
30
+ .toLowerCase().split(",").map(name => name.trim())
31
+ .filter(name => /^mcp-param-[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name));
32
+ if (paramHeaders.length) {
33
+ headers.append("Access-Control-Allow-Headers", [...new Set(paramHeaders)].join(", "));
34
+ }
35
+ headers.append("Vary", "Access-Control-Request-Headers");
36
+ }
22
37
  headers.set("Access-Control-Expose-Headers", "WWW-Authenticate, Retry-After, mcp-session-id, mcp-protocol-version");
23
38
  return new Response(response.body, {
24
39
  status: response.status,
@@ -46,7 +61,10 @@ function requestAdmissionFailure(error) {
46
61
  jsonrpc: "2.0",
47
62
  id: null,
48
63
  error: {
49
- code: overloaded ? -32001 : -32002,
64
+ // MCP 2026-07-28 basic#error-codes forbids new allocations in the
65
+ // legacy -32000..-32019 range. Use application codes outside the
66
+ // JSON-RPC reserved range, avoiding retired protocol meanings.
67
+ code: overloaded ? -31001 : -31002,
50
68
  message: overloaded
51
69
  ? "Server capacity is exhausted. Retry later."
52
70
  : "Server is shutting down.",
@@ -244,6 +262,49 @@ async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth,
244
262
  return transport.handleRequest(request);
245
263
  }
246
264
  export function createMcpRoute(opts) {
265
+ const configuredOrigins = opts.allowedOrigins;
266
+ const isExactOrigin = (value) => {
267
+ if (typeof value !== "string")
268
+ return false;
269
+ try {
270
+ const url = new URL(value);
271
+ return (url.protocol === "http:" || url.protocol === "https:") && url.origin === value;
272
+ }
273
+ catch {
274
+ return false;
275
+ }
276
+ };
277
+ if (configuredOrigins !== undefined && configuredOrigins !== "*" &&
278
+ (!Array.isArray(configuredOrigins) || !configuredOrigins.every(isExactOrigin))) {
279
+ throw new TypeError('ConnectaConfig.allowedOrigins must be an array of exact HTTP(S) origins or "*".');
280
+ }
281
+ const origins = new Set(configuredOrigins === undefined
282
+ ? opts.publicUrl ? [new URL(opts.publicUrl).origin] : []
283
+ : configuredOrigins === "*" ? [] : configuredOrigins);
284
+ const allowsOrigin = (origin) => {
285
+ if (configuredOrigins === "*")
286
+ return true;
287
+ if (!isExactOrigin(origin))
288
+ return false;
289
+ if (origins.has(origin))
290
+ return true;
291
+ if (configuredOrigins !== undefined)
292
+ return false;
293
+ const hostname = new URL(origin).hostname;
294
+ return hostname === "localhost" || hostname === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(hostname);
295
+ };
296
+ const rejectOrigin = (request) => {
297
+ const path = new URL(request.url).pathname;
298
+ if (path !== "/mcp" && !path.startsWith("/mcp/"))
299
+ return null;
300
+ const origin = request.headers.get("Origin");
301
+ if (origin === null || allowsOrigin(origin))
302
+ return null;
303
+ return withMcpCors(new Response('{"error":"origin not allowed"}', {
304
+ status: 403,
305
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
306
+ }), request, null);
307
+ };
247
308
  let lastAdmissionWarningAt = 0;
248
309
  let suppressedAdmissionWarnings = 0;
249
310
  const warnAdmissionRejected = (error) => {
@@ -261,12 +322,21 @@ export function createMcpRoute(opts) {
261
322
  lastAdmissionWarningAt = now;
262
323
  suppressedAdmissionWarnings = 0;
263
324
  };
264
- return async function routeMcp(context) {
325
+ async function routeMcp(context) {
265
326
  const { path, request, baseUrl, runtimeContext, } = context;
266
- const poolPath = /^\/mcp\/([a-z0-9_-]+)$/.exec(path);
267
- if (path !== "/mcp" && !poolPath)
327
+ if (path !== "/mcp" && !path.startsWith("/mcp/"))
268
328
  return null;
269
- const poolName = poolPath?.[1];
329
+ const poolName = path === "/mcp" ? undefined : path.slice("/mcp/".length);
330
+ const origin = request.headers.get("Origin");
331
+ const allowed = origin === null || allowsOrigin(origin);
332
+ const cors = (response) => withMcpCors(response, request, configuredOrigins === "*" ? "*" : allowed ? origin : null);
333
+ // DNS-rebinding refusals cost neither a permit nor an auth lookup. This
334
+ // local header check also guards OPTIONS before any provider metadata.
335
+ const refusal = rejectOrigin(request);
336
+ if (refusal)
337
+ return refusal;
338
+ if (request.method === "OPTIONS")
339
+ return cors(new Response(null, { status: 204 }));
270
340
  let admission;
271
341
  try {
272
342
  admission = await opts.requestAdmission.acquire({
@@ -289,14 +359,14 @@ export function createMcpRoute(opts) {
289
359
  if (error.code === "executor_overloaded") {
290
360
  warnAdmissionRejected(error);
291
361
  }
292
- return withMcpCors(requestAdmissionFailure(error));
362
+ return cors(requestAdmissionFailure(error));
293
363
  }
294
364
  throw error;
295
365
  }
296
366
  try {
297
367
  const authz = await authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity);
298
368
  if (!authz.ok) {
299
- return releaseAdmissionWithResponse(withMcpCors(authz.response), admission, request.signal);
369
+ return releaseAdmissionWithResponse(cors(authz.response), admission, request.signal);
300
370
  }
301
371
  // A pool endpoint narrows the identity's own view and nothing else. An
302
372
  // undeclared name, a grant that refuses, and a grant that throws are
@@ -319,7 +389,7 @@ export function createMcpRoute(opts) {
319
389
  if (!pool || !granted) {
320
390
  opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
321
391
  (authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
322
- return releaseAdmissionWithResponse(withMcpCors(new Response("Not Found", { status: 404 })), admission, request.signal);
392
+ return releaseAdmissionWithResponse(cors(new Response("Not Found", { status: 404 })), admission, request.signal);
323
393
  }
324
394
  access = intersectAccess(authz, pool.access);
325
395
  }
@@ -334,19 +404,20 @@ export function createMcpRoute(opts) {
334
404
  });
335
405
  }
336
406
  catch (error) {
337
- return releaseAdmissionWithResponse(withMcpCors(new Response(JSON.stringify({ error: msg(error) }), {
407
+ return releaseAdmissionWithResponse(cors(new Response(JSON.stringify({ error: msg(error) }), {
338
408
  status: 403,
339
409
  headers: { "Content-Type": "application/json" },
340
410
  })), admission, request.signal);
341
411
  }
342
412
  if (new URL(request.url).searchParams.has("toolkit")) {
343
- return releaseAdmissionWithResponse(withMcpCors(toolkitRetired(opts.logger)), admission, request.signal);
413
+ return releaseAdmissionWithResponse(cors(toolkitRetired(opts.logger)), admission, request.signal);
344
414
  }
345
- return releaseAdmissionWithResponse(withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext)), admission, request.signal);
415
+ return releaseAdmissionWithResponse(cors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext)), admission, request.signal);
346
416
  }
347
417
  catch (error) {
348
418
  admission.release();
349
419
  throw error;
350
420
  }
351
- };
421
+ }
422
+ return { handle: routeMcp, rejectOrigin };
352
423
  }
@@ -137,6 +137,10 @@ export async function routeOAuthCallback(context) {
137
137
  return refused();
138
138
  }
139
139
  const expectedPrincipalKey = callbackTarget?.principalKey;
140
+ // A browser returning from consent normally has no MCP Authorization
141
+ // header. An interactive bearer provider therefore answers 401 here; state
142
+ // and the saved state-to-principal handoff still prove ownership below.
143
+ // Rejecting 401 would break that callback. A 403 is an explicit denial.
140
144
  const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
141
145
  if (browserIdentity.ok) {
142
146
  try {
@@ -16,6 +16,7 @@ export interface ServerOptions {
16
16
  /** Validated named pools served at `/mcp/<name>`; empty when none declared. */
17
17
  pools?: ReadonlyMap<string, ResolvedPool> | undefined;
18
18
  publicUrl?: string | undefined;
19
+ allowedOrigins?: readonly string[] | "*" | undefined;
19
20
  serverInfo: Implementation;
20
21
  logger: Logger;
21
22
  activity?: ActivityStore | undefined;
@@ -52,20 +52,20 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
52
52
  if (result.ok) {
53
53
  const subjectId = result.subjectId ?? result.userId;
54
54
  const actorNamespace = activityActorNamespace(provider);
55
- const subject = subjectId && actorNamespace
56
- ? { namespace: actorNamespace, id: subjectId }
57
- : undefined;
58
55
  const derivedPrincipal = result.userId && actorNamespace
59
56
  ? { namespace: actorNamespace, id: result.userId }
60
57
  : undefined;
61
58
  const principal = validIdentityReference(result.principal)
62
59
  ? result.principal
63
60
  : derivedPrincipal;
61
+ const subject = subjectId
62
+ ? { namespace: actorNamespace ?? `connecta:auth:${provider.kind}`, id: subjectId }
63
+ : principal;
64
64
  const interactive = Boolean(result.userId && provider.interactiveOperator);
65
65
  const actor = {
66
66
  kind: provider.kind,
67
67
  ...(subjectId ? { id: subjectId } : {}),
68
- ...(subject ? { namespace: subject.namespace } : {}),
68
+ ...(subjectId && actorNamespace ? { namespace: actorNamespace } : {}),
69
69
  };
70
70
  const identity = {
71
71
  actor,
package/dist/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { aggregateCallAdmissionSnapshots } from "./call-admission.js";
1
2
  import { isAdmittingExecutor } from "./executor-admission.js";
2
3
  import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
3
4
  import { routeOAuthCallback, } from "./routes/oauth.js";
@@ -18,6 +19,9 @@ export function createFetchHandler(opts) {
18
19
  const defer = runtimeContext
19
20
  ? runtimeContext.waitUntil.bind(runtimeContext)
20
21
  : undefined;
22
+ const originRefusal = routeMcp.rejectOrigin(request);
23
+ if (originRefusal)
24
+ return withSecurityHeaders(originRefusal, url, path);
21
25
  // Container and orchestrator probes reach /health over plain HTTP on
22
26
  // loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
23
27
  // the public origin would make an internal liveness check depend on
@@ -59,6 +63,9 @@ export function createFetchHandler(opts) {
59
63
  if (uiResponse)
60
64
  return uiResponse;
61
65
  if (request.method === "OPTIONS") {
66
+ const preflight = await routeMcp.handle(context);
67
+ if (preflight)
68
+ return preflight;
62
69
  for (const provider of auth) {
63
70
  if (provider.handleMetadata) {
64
71
  const response = await provider.handleMetadata(request, baseUrl);
@@ -102,14 +109,19 @@ export function createFetchHandler(opts) {
102
109
  // Counts only, from refreshes that already happened — the endpoint
103
110
  // asks no downstream anything, and `connecta doctor` reads it to
104
111
  // report a stale allowlist without a probe of its own (#343).
105
- catalogDrift: registry.catalogDriftSnapshot(),
112
+ // Stable 64-bit hashes preserve that shape without publishing ids.
113
+ catalogDrift: Object.fromEntries(await Promise.all(Object.entries(registry.catalogDriftSnapshot()).map(async ([id, report]) => {
114
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id)));
115
+ const key = Array.from(hash.subarray(0, 8), byte => byte.toString(16).padStart(2, "0")).join("");
116
+ return [key, report];
117
+ }))),
106
118
  admission: {
107
119
  policy: "global-fifo",
108
120
  requests: opts.requestAdmission.snapshot(),
109
121
  code: codeAdmission ?? { managedByExecutor: true },
110
122
  downstreamCalls: {
111
123
  policy: "connector-partitioned-per-runtime",
112
- connectors: registry.callAdmissionSnapshot(),
124
+ aggregate: aggregateCallAdmissionSnapshots(Object.values(registry.callAdmissionSnapshot())),
113
125
  },
114
126
  reservedRoutes: [
115
127
  "/health",
@@ -123,7 +135,7 @@ export function createFetchHandler(opts) {
123
135
  const oauthCallback = await routeOAuthCallback(context);
124
136
  if (oauthCallback)
125
137
  return oauthCallback;
126
- const mcp = await routeMcp(context);
138
+ const mcp = await routeMcp.handle(context);
127
139
  if (mcp)
128
140
  return mcp;
129
141
  return new Response("Not Found", { status: 404 });
package/dist/skills.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { boundedEchoText } from "./errors.js";
1
2
  export const CONNECTA_INSTRUCTIONS = 'Choose a route before discovery. A known-address read needs only call_tool. Unknown-address read-only work starts with execute_code to discover, call, and return the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Inspect unfamiliar result shapes with a small sample before proceeding. Only readOnlyHint: true tools run there. Keep catalog inspection and unannotated, write-capable, or destructive work top level: search_tools then call_destructive_tool when a call is needed. After auth_required use authorize_connector. After a truncated direct result use get_result. Guidance is on demand: fetch skills({ name: "usage" }) only when these instructions and the tool description are insufficient or a run needs repair.';
2
3
  const USAGE_SKILL_BASE = `# Connecta usage
3
4
 
@@ -423,14 +424,14 @@ export function resolveSkill(name, connectors) {
423
424
  if (!connector) {
424
425
  return {
425
426
  found: false,
426
- message: `Unknown connector "${id}". Available skills: ${available()}.`,
427
+ message: `Unknown connector "${boundedEchoText(id)}". Available skills: ${available()}.`,
427
428
  };
428
429
  }
429
430
  const guide = connectorGuide(connector);
430
431
  if (!guide) {
431
432
  return {
432
433
  found: false,
433
- message: `Connector "${id}" has no usage guide. Available skills: ${available()}.`,
434
+ message: `Connector "${boundedEchoText(id)}" has no usage guide. Available skills: ${available()}.`,
434
435
  };
435
436
  }
436
437
  return { found: true, content: guide };
@@ -440,12 +441,12 @@ export function resolveSkill(name, connectors) {
440
441
  return {
441
442
  found: false,
442
443
  message: connectorGuide(bare)
443
- ? `Unknown skill "${name}". Connector guides are fetched as "${connectorSkillName(name)}". Available skills: ${available()}.`
444
- : `Connector "${name}" has no usage guide. Available skills: ${available()}.`,
444
+ ? `Unknown skill "${boundedEchoText(name)}". Connector guides are fetched as "${boundedEchoText(connectorSkillName(name))}". Available skills: ${available()}.`
445
+ : `Connector "${boundedEchoText(name)}" has no usage guide. Available skills: ${available()}.`,
445
446
  };
446
447
  }
447
448
  return {
448
449
  found: false,
449
- message: `Unknown skill "${name}". Available skills: ${available()}.`,
450
+ message: `Unknown skill "${boundedEchoText(name)}". Available skills: ${available()}.`,
450
451
  };
451
452
  }
@@ -5,7 +5,11 @@ export interface FileStorageOptions {
5
5
  }
6
6
  /**
7
7
  * JSON-file-backed KVStorage for Node. Loads once, persists on every write via
8
- * a temp-file + rename (atomic-ish). Only reachable via the "@zackbart/connecta/node"
8
+ * an exclusive temp-file + rename. Refuses a second holder of the same path.
9
+ * Call close() when finished to release its lock; process exit also releases it.
10
+ * Only reachable via the "@zackbart/connecta/node"
9
11
  * subpath so the main entry stays Workers-clean.
10
12
  */
11
- export declare function fileStorage(path: string, opts?: FileStorageOptions): KVStorage;
13
+ export declare function fileStorage(path: string, opts?: FileStorageOptions): KVStorage & {
14
+ close(): void;
15
+ };