@zackbart/connecta 0.25.0 → 0.26.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 (93) hide show
  1. package/AGENTS.md +6 -5
  2. package/CHANGELOG.md +271 -0
  3. package/README.md +32 -15
  4. package/SECURITY.md +6 -5
  5. package/bin/connecta.mjs +5 -1
  6. package/dist/activity.d.ts +10 -3
  7. package/dist/activity.js +10 -1
  8. package/dist/artifacts/connector.d.ts +55 -0
  9. package/dist/artifacts/connector.js +724 -0
  10. package/dist/artifacts/document.js +76 -0
  11. package/dist/artifacts/guide.js +140 -0
  12. package/dist/artifacts/html-scan.js +329 -0
  13. package/dist/artifacts/json.js +68 -0
  14. package/dist/artifacts/kv-store.d.ts +20 -0
  15. package/dist/artifacts/kv-store.js +140 -0
  16. package/dist/artifacts/markdown.js +449 -0
  17. package/dist/artifacts/module.d.ts +37 -0
  18. package/dist/artifacts/module.js +60 -0
  19. package/dist/artifacts/operations.d.ts +195 -0
  20. package/dist/artifacts/operations.js +944 -0
  21. package/dist/artifacts/refresh.d.ts +53 -0
  22. package/dist/artifacts/refresh.js +235 -0
  23. package/dist/artifacts/routes.js +194 -0
  24. package/dist/artifacts/types.d.ts +214 -0
  25. package/dist/artifacts/types.js +22 -0
  26. package/dist/artifacts/validate.d.ts +62 -0
  27. package/dist/artifacts/validate.js +652 -0
  28. package/dist/artifacts.d.ts +6 -0
  29. package/dist/artifacts.js +9 -0
  30. package/dist/catalog-service.js +39 -29
  31. package/dist/catalog.js +42 -8
  32. package/dist/connector-access.d.ts +18 -4
  33. package/dist/connector-access.js +65 -20
  34. package/dist/connector-caller.d.ts +8 -0
  35. package/dist/connector-caller.js +19 -0
  36. package/dist/connectors/remote-mcp.js +16 -2
  37. package/dist/errors.d.ts +16 -1
  38. package/dist/errors.js +28 -0
  39. package/dist/execute.js +427 -70
  40. package/dist/executor-admission.d.ts +11 -1
  41. package/dist/executor-admission.js +79 -14
  42. package/dist/executors/quickjs.js +4 -2
  43. package/dist/index.d.ts +62 -6
  44. package/dist/index.js +230 -6
  45. package/dist/invocation.js +56 -10
  46. package/dist/meta-tools.js +7 -2
  47. package/dist/module-contracts.d.ts +21 -1
  48. package/dist/operator-ui/generated.js +2 -2
  49. package/dist/operator-ui/model.d.ts +62 -5
  50. package/dist/operator-ui/view.js +94 -5
  51. package/dist/program-source.js +116 -0
  52. package/dist/registry.d.ts +42 -6
  53. package/dist/registry.js +148 -47
  54. package/dist/resumable.js +1291 -0
  55. package/dist/routes/activity.js +6 -69
  56. package/dist/routes/actor-labels.js +114 -0
  57. package/dist/routes/credentials.js +27 -5
  58. package/dist/routes/mcp.js +116 -59
  59. package/dist/routes/oauth-management.js +22 -9
  60. package/dist/routes/operator.js +1 -0
  61. package/dist/routes/shared.d.ts +23 -1
  62. package/dist/routes/shared.js +20 -2
  63. package/dist/routes/ui-data.js +2 -1
  64. package/dist/routes/ui.js +13 -4
  65. package/dist/run-journal.js +347 -0
  66. package/dist/runtime/shared-read.js +106 -0
  67. package/dist/server.js +26 -0
  68. package/dist/skills.js +27 -8
  69. package/dist/timeout.js +26 -0
  70. package/dist/tool-safety.d.ts +35 -0
  71. package/dist/tool-safety.js +31 -0
  72. package/dist/types.d.ts +18 -0
  73. package/dist/ui.d.ts +12 -5
  74. package/dist/ui.js +32 -6
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/documentation/architecture.md +196 -19
  78. package/documentation/auth.md +83 -2
  79. package/documentation/code-mode.md +221 -48
  80. package/documentation/meta-tools.md +44 -25
  81. package/ethos.md +12 -10
  82. package/examples/worker/AGENTS.md +6 -0
  83. package/examples/worker/README.md +174 -10
  84. package/examples/worker/src/d1-activity-row.ts +14 -2
  85. package/examples/worker/src/d1-activity.ts +4 -2
  86. package/examples/worker/src/index.ts +36 -1
  87. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  88. package/examples/worker/wrangler.jsonc +5 -1
  89. package/package.json +6 -2
  90. package/templates/node/AGENTS.md +9 -2
  91. package/templates/node/README.md +31 -3
  92. package/templates/node/package.json +1 -1
  93. package/templates/node/src/index.ts +27 -0
@@ -1,11 +1,12 @@
1
- import { Deferred, Effect, Exit, Result } from "effect";
1
+ import { Effect, Result } from "effect";
2
2
  import { compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, summarizeDiscoveryDescription, summarizeDescription, typescriptSignature, } from "./catalog.js";
3
3
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
4
4
  import { boundedEchoText, classifyCallError, framingError, } from "./errors.js";
5
5
  import { connectorGuide, connectorGuideRequired, connectorGuideSummary, connectorSkillName, } from "./skills.js";
6
6
  import { runEdge, withDeadlineEffect } from "./runtime/run.js";
7
+ import { SharedRead } from "./runtime/shared-read.js";
7
8
  import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, } from "./timeout.js";
8
- import { isExplicitlyReadOnly } from "./tool-safety.js";
9
+ import { isApprovalExempt, isExplicitlyReadOnly, NO_EXEMPTIONS, } from "./tool-safety.js";
9
10
  export const DEFAULT_SEARCH_LIMIT = 8;
10
11
  export const MAX_SEARCH_LIMIT = 100;
11
12
  export const MAX_DESCRIBE_ADDRESSES = 100;
@@ -202,12 +203,13 @@ export class CatalogService {
202
203
  searchRoute;
203
204
  readOptions;
204
205
  requestSignal;
205
- // The request-scoped catalog cache: one Deferred per connector asked about,
206
- // completed by the first asker's registry read and joined by every later
207
- // one. A success stays for the rest of the request, so a search and the call
208
- // after it see one catalog; a failure is dropped so the next ask reads
209
- // again. The service lives and dies with one request, so no Deferred here is
210
- // ever awaited from another.
206
+ approval;
207
+ // The request-scoped catalog cache: one shared read per connector asked
208
+ // about, started by the first asker and joined by every later one. A
209
+ // success stays for the rest of the request, so a search and the call after
210
+ // it see one catalog; a failure, or a read cancelled because every asker
211
+ // left it, is dropped so the next ask reads again. The service lives and
212
+ // dies with one request, so no read here is ever awaited from another.
211
213
  //
212
214
  // Registry.getTools also joins concurrent reads in one request scope, which
213
215
  // the operator UI and status reads rely on. This map overlaps it only for
@@ -218,6 +220,7 @@ export class CatalogService {
218
220
  constructor(registry, baseUrl, options = {}) {
219
221
  this.registry = registry;
220
222
  this.baseUrl = baseUrl;
223
+ this.approval = options.approval ?? NO_EXEMPTIONS;
221
224
  this.requestScope = options.requestScope ?? {};
222
225
  this.probeTimeoutMs =
223
226
  normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
@@ -256,28 +259,27 @@ export class CatalogService {
256
259
  return runEdge(this.catalog(id, callOptions));
257
260
  }
258
261
  // One connector's catalog from the request-scoped cache, reading it through
259
- // the registry when this request has not yet. The first asker's options
260
- // govern the read and a joiner shares its outcome, as the registry's own
261
- // flight below does. The read settles the Deferred itself, not the asker's
262
- // fiber, so a probe deadline ends one asker's wait and never the read.
262
+ // the registry when this request has not yet. No asker's options govern the
263
+ // read: it carries its own signal and the catalog deadline, the probe
264
+ // timeout, whichever asker starts it. Each asker waits under its own signal
265
+ // and deadline, so a short-deadline call that starts the read and a search
266
+ // that joins it each end on their own terms, and the read is cancelled only
267
+ // once every asker has gone (#571).
263
268
  catalog(id, callOptions) {
264
269
  return Effect.suspend(() => {
265
- const cached = this.catalogs.get(id);
266
- if (cached)
267
- return Deferred.await(cached);
268
- const read = Deferred.makeUnsafe();
269
- this.catalogs.set(id, read);
270
- // The executor turns a synchronous throw from getTools into a rejection.
271
- new Promise((resolve) => resolve(this.registry.getTools(id, this.baseUrl, this.requestScope, callOptions, this.readOptions))).then((tools) => {
272
- Deferred.doneUnsafe(read, Exit.succeed(tools));
273
- }, (cause) => {
274
- // Evicted before the Deferred resumes anyone, so an asker that
275
- // retries on hearing of the failure starts a fresh read.
276
- if (this.catalogs.get(id) === read)
277
- this.catalogs.delete(id);
278
- Deferred.doneUnsafe(read, Exit.fail(cause));
279
- });
280
- return Deferred.await(read);
270
+ let read = this.catalogs.get(id);
271
+ if (!read) {
272
+ const started = new SharedRead((signal) => this.registry.getTools(id, this.baseUrl, this.requestScope, { signal, timeoutMs: this.probeTimeoutMs }, this.readOptions), (succeeded) => {
273
+ // Evicted before the read resumes anyone, so an asker that
274
+ // retries on hearing of the failure starts a fresh read.
275
+ if (!succeeded && this.catalogs.get(id) === started) {
276
+ this.catalogs.delete(id);
277
+ }
278
+ });
279
+ this.catalogs.set(id, started);
280
+ read = started;
281
+ }
282
+ return read.join(callOptions.signal);
281
283
  });
282
284
  }
283
285
  // Every named catalog under its own probe deadline, `concurrency` at a time,
@@ -286,7 +288,7 @@ export class CatalogService {
286
288
  // A probe also ends with its request, so no listing outlives the caller
287
289
  // that asked for it.
288
290
  discoveryCatalogs(ids, route) {
289
- return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.catalog(id, { signal, timeoutMs: this.probeTimeoutMs }), {
291
+ return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.catalog(id, { signal }), {
290
292
  ...(this.requestSignal ? { signal: this.requestSignal } : {}),
291
293
  timeoutMs: this.probeTimeoutMs,
292
294
  timeoutError: new Error(`${route} probe of "${id}" timed out after ${this.probeTimeoutMs}ms`),
@@ -536,6 +538,11 @@ export class CatalogService {
536
538
  ...(match.tool.annotations
537
539
  ? { annotations: match.tool.annotations }
538
540
  : {}),
541
+ // Config lets a program call this write without pausing (#566).
542
+ // A marker, not a class: the tool stays approval-required.
543
+ ...(isApprovalExempt(this.approval, match.connector, match.tool.name, match.tool)
544
+ ? { approval: "exempt" }
545
+ : {}),
539
546
  ...(requiredReasons
540
547
  ? {
541
548
  guideRequired: true,
@@ -847,6 +854,9 @@ export class CatalogService {
847
854
  : {}),
848
855
  ...(output.source ? { outputSchemaSource: output.source } : {}),
849
856
  ...(tool.annotations ? { annotations: tool.annotations } : {}),
857
+ ...(isApprovalExempt(this.approval, addressResolution.connector, tool.name, tool)
858
+ ? { approval: "exempt" }
859
+ : {}),
850
860
  };
851
861
  });
852
862
  }
package/dist/catalog.js CHANGED
@@ -14,6 +14,12 @@ class SchemaWork {
14
14
  remaining = MAX_SCHEMA_WORK;
15
15
  truncated = false;
16
16
  refs = new Map();
17
+ /**
18
+ * Renderings whose top level is a bare `|` or `&`, recorded as the walk
19
+ * produces them. Compact carries raw keys and `//` prose, so unlike the
20
+ * TypeScript dialect its text cannot be rescanned for a top-level operator.
21
+ */
22
+ ungrouped = new Set();
17
23
  constructor(byteLimit = MAX_COMPACT_DISCOVERY_SCHEMA_BYTES) {
18
24
  this.byteLimit = byteLimit;
19
25
  }
@@ -435,8 +441,11 @@ function renderEnum(values, work, byteLimit, onTruncated) {
435
441
  const full = prefix.join(" | ");
436
442
  if (schemaEncoder.encode(full).length > limit)
437
443
  break;
438
- if (index === values.length - 1)
444
+ if (index === values.length - 1) {
445
+ if (index > 0)
446
+ work.ungrouped.add(full);
439
447
  return full;
448
+ }
440
449
  const omitted = values.length - prefix.length;
441
450
  const candidate = `(${full} | ${marker(omitted)})`;
442
451
  if (schemaEncoder.encode(candidate).length <= limit)
@@ -582,6 +591,12 @@ function renderSchema(schema, defs, seen, depth, options) {
582
591
  function renderSchemaNode(schema, defs, seen, depth, options) {
583
592
  const ts = options.typescript === true;
584
593
  const group = ts ? groupedType : grouped;
594
+ const ungrouped = options.work.ungrouped;
595
+ // A `[]` suffix binds tighter than `|` and `&`, so an element rendered at
596
+ // operator level needs parentheses or it reads as its last member's array.
597
+ const element = (rendered) => ts
598
+ ? groupedType(rendered)
599
+ : ungrouped.has(rendered) ? `(${rendered})` : rendered;
585
600
  if (depth > 4) {
586
601
  if (!ts)
587
602
  return "…";
@@ -607,9 +622,17 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
607
622
  : "unknown";
608
623
  return rendered === "unknown" ? rendered : `${rendered} | null`;
609
624
  }
610
- const constrain = (rendered) => options.renderConstraints
611
- ? renderConstraints(rendered, s, options.constraintByteLimit, options.onConstraintTruncated)
612
- : rendered;
625
+ const constrain = (rendered) => {
626
+ if (!options.renderConstraints)
627
+ return rendered;
628
+ const result = renderConstraints(rendered, s, options.constraintByteLimit, options.onConstraintTruncated);
629
+ // Constraints parenthesize a union before their comment but leave an
630
+ // intersection bare, and a comment does not group what precedes it.
631
+ if (ungrouped.has(rendered) && !result.startsWith(`(${rendered})`)) {
632
+ ungrouped.add(result);
633
+ }
634
+ return result;
635
+ };
613
636
  // Conditions cannot be expressed by a single static shape. Preserve the
614
637
  // base and send callers to the exact schema instead of hiding the rules.
615
638
  if (s.dependentSchemas !== undefined || s.if !== undefined ||
@@ -623,7 +646,10 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
623
646
  const rendered = declaresShape(base)
624
647
  ? renderSchema(base, defs, seen, depth, options)
625
648
  : "unknown";
626
- return `${rendered} /* conditional */`;
649
+ const result = `${rendered} /* conditional */`;
650
+ if (ungrouped.has(rendered))
651
+ ungrouped.add(result);
652
+ return result;
627
653
  }
628
654
  // allOf composes rather than replaces: it is checked before every other
629
655
  // shape keyword, and renders the schema's own shape alongside its members instead
@@ -653,7 +679,10 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
653
679
  }
654
680
  if (parts.length === 0)
655
681
  return "unknown";
656
- return parts.finish();
682
+ const rendered = parts.finish();
683
+ if (parts.length > 1)
684
+ ungrouped.add(rendered);
685
+ return rendered;
657
686
  }
658
687
  const reference = s.$ref ?? s.$dynamicRef;
659
688
  if (typeof reference === "string") {
@@ -693,6 +722,8 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
693
722
  parts.add(renderSchema(member, defs, seen, depth + 1, options));
694
723
  }
695
724
  const rendered = parts.finish() || "unknown";
725
+ if (parts.length > 1)
726
+ ungrouped.add(rendered);
696
727
  return constrain(rendered);
697
728
  }
698
729
  if (Array.isArray(s.enum)) {
@@ -743,7 +774,7 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
743
774
  const rest = s.items === undefined || s.items === true
744
775
  ? "unknown"
745
776
  : renderSchema(s.items, defs, seen, depth + 1, options);
746
- parts.add(`...${group(rest)}[]`);
777
+ parts.add(`...${element(rest)}[]`);
747
778
  }
748
779
  return parts.finish();
749
780
  }
@@ -751,7 +782,7 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
751
782
  const items = s.items
752
783
  ? renderSchema(s.items, defs, seen, depth + 1, options)
753
784
  : "unknown";
754
- return ts ? `${groupedType(items)}[]` : `${items}[]`;
785
+ return `${element(items)}[]`;
755
786
  }
756
787
  if (type === "object" || s.properties) {
757
788
  const props = (s.properties ?? {});
@@ -809,6 +840,8 @@ function renderSchemaNode(schema, defs, seen, depth, options) {
809
840
  parts.add(options.work.text(String(item)));
810
841
  }
811
842
  const rendered = parts.finish();
843
+ if (parts.length > 1)
844
+ ungrouped.add(rendered);
812
845
  return constrain(rendered);
813
846
  }
814
847
  if (options.renderConstraints && constraintEntries(s).length > 0) {
@@ -959,6 +992,7 @@ function boundedCompactSchema(schema, description, typescript = false) {
959
992
  // are memoized only within each pass because their text includes constraints.
960
993
  if (error === schemaSizeExceeded && !description) {
961
994
  work.refs.clear();
995
+ work.ungrouped.clear();
962
996
  try {
963
997
  return {
964
998
  text: renderSchema(schema, schema, new Set(), 0, {
@@ -9,20 +9,33 @@ export declare const POOL_NAME_RE: RegExp;
9
9
  /**
10
10
  * One derived view: which connectors, and for connectors granted by address
11
11
  * only, which tools. A connector absent from `toolAccess` is visible whole.
12
+ * `guardedToolAccess` is a subset of `toolAccess` checked against the loaded
13
+ * catalog each time tools are discovered or invoked.
12
14
  */
13
15
  export interface ConnectorAccess {
14
16
  connectorIds: "all" | readonly string[];
15
17
  toolAccess?: ToolAccess;
18
+ /** Exact tool grants that also require a current explicit read-only annotation. */
19
+ guardedToolAccess?: ToolAccess;
16
20
  }
21
+ interface ReadOnlyToolGrant {
22
+ tool: string;
23
+ requireReadOnly: true;
24
+ }
25
+ export type ConnectorGrant = string | ReadOnlyToolGrant;
17
26
  /**
18
27
  * Normalize a grant list. A bare connector id grants every tool on that
19
28
  * connector; a `connector.tool` address grants one tool. Grants are additive,
20
29
  * so a bare id beside addresses for the same connector means the whole
21
- * connector. Anything else — an unknown shape, an empty tool name, a
22
- * non-string — throws, and the caller decides whether that is a construction
23
- * failure or a 403: a grant that cannot be parsed must never fail open.
30
+ * connector. Identity resolution can opt into `{ tool, requireReadOnly: true }`
31
+ * for a fixed address that also needs a current explicit read-only annotation;
32
+ * pool declarations remain strings. Anything else throws, and the caller
33
+ * decides whether that is a construction failure or a 403: a grant that
34
+ * cannot be parsed must never fail open.
24
35
  */
25
- export declare function parseConnectorAccess(value: unknown): ConnectorAccess;
36
+ export declare function parseConnectorAccess(value: unknown, options?: {
37
+ allowReadOnly?: boolean;
38
+ }): ConnectorAccess;
26
39
  /**
27
40
  * The view a pool endpoint serves: the pool's grants, never wider than the
28
41
  * identity's own. A connector or tool outside either side is gone; a
@@ -30,3 +43,4 @@ export declare function parseConnectorAccess(value: unknown): ConnectorAccess;
30
43
  * only narrow what the identity resolver already allowed.
31
44
  */
32
45
  export declare function intersectAccess(ceiling: ConnectorAccess, pool: ConnectorAccess): ConnectorAccess;
46
+ export {};
@@ -12,38 +12,69 @@ const hasControlCharacter = (value) => [...value].some((ch) => {
12
12
  * Normalize a grant list. A bare connector id grants every tool on that
13
13
  * connector; a `connector.tool` address grants one tool. Grants are additive,
14
14
  * so a bare id beside addresses for the same connector means the whole
15
- * connector. Anything else — an unknown shape, an empty tool name, a
16
- * non-string — throws, and the caller decides whether that is a construction
17
- * failure or a 403: a grant that cannot be parsed must never fail open.
15
+ * connector. Identity resolution can opt into `{ tool, requireReadOnly: true }`
16
+ * for a fixed address that also needs a current explicit read-only annotation;
17
+ * pool declarations remain strings. Anything else throws, and the caller
18
+ * decides whether that is a construction failure or a 403: a grant that
19
+ * cannot be parsed must never fail open.
18
20
  */
19
- export function parseConnectorAccess(value) {
21
+ export function parseConnectorAccess(value, options = {}) {
20
22
  if (value === "all")
21
23
  return { connectorIds: "all" };
22
24
  if (!Array.isArray(value))
23
25
  throw new Error("invalid connector permission");
24
26
  const whole = new Set();
25
- const partial = new Map();
27
+ const plain = new Map();
28
+ const guarded = new Map();
26
29
  for (const entry of value) {
27
- if (typeof entry !== "string")
28
- throw new Error("invalid connector permission");
29
- if (CONNECTOR_ID_RE.test(entry)) {
30
+ if (typeof entry === "string" && CONNECTOR_ID_RE.test(entry)) {
30
31
  whole.add(entry);
31
32
  continue;
32
33
  }
33
- if (!TOOL_ADDRESS_RE.test(entry) || hasControlCharacter(entry))
34
+ const isGuarded = typeof entry !== "string";
35
+ if (isGuarded && (!options.allowReadOnly || entry === null ||
36
+ typeof entry !== "object" || Array.isArray(entry) ||
37
+ !Object.hasOwn(entry, "tool") || !Object.hasOwn(entry, "requireReadOnly") ||
38
+ Object.keys(entry).length !== 2 || entry.requireReadOnly !== true)) {
39
+ throw new Error("invalid connector permission");
40
+ }
41
+ const address = isGuarded ? entry.tool : entry;
42
+ if (typeof address !== "string" || !TOOL_ADDRESS_RE.test(address) || hasControlCharacter(address)) {
34
43
  throw new Error("invalid connector permission");
35
- const dot = entry.indexOf(".");
36
- const connectorId = entry.slice(0, dot);
37
- const tools = partial.get(connectorId) ?? new Set();
38
- tools.add(entry.slice(dot + 1));
39
- partial.set(connectorId, tools);
44
+ }
45
+ const dot = address.indexOf(".");
46
+ const connectorId = address.slice(0, dot);
47
+ const target = isGuarded ? guarded : plain;
48
+ const tools = target.get(connectorId) ?? new Set();
49
+ tools.add(address.slice(dot + 1));
50
+ target.set(connectorId, tools);
51
+ }
52
+ for (const id of whole) {
53
+ plain.delete(id);
54
+ guarded.delete(id);
55
+ }
56
+ for (const [id, names] of plain) {
57
+ const restricted = guarded.get(id);
58
+ if (!restricted)
59
+ continue;
60
+ for (const name of names)
61
+ restricted.delete(name);
62
+ if (restricted.size === 0)
63
+ guarded.delete(id);
64
+ }
65
+ const partial = new Map(plain);
66
+ for (const [id, names] of guarded) {
67
+ const combined = partial.get(id) ?? new Set();
68
+ for (const name of names)
69
+ combined.add(name);
70
+ partial.set(id, combined);
40
71
  }
41
- for (const id of whole)
42
- partial.delete(id);
43
72
  const connectorIds = [...new Set([...whole, ...partial.keys()])];
44
- return partial.size > 0
45
- ? { connectorIds, toolAccess: partial }
46
- : { connectorIds };
73
+ return {
74
+ connectorIds,
75
+ ...(partial.size > 0 ? { toolAccess: partial } : {}),
76
+ ...(guarded.size > 0 ? { guardedToolAccess: guarded } : {}),
77
+ };
47
78
  }
48
79
  /**
49
80
  * The view a pool endpoint serves: the pool's grants, never wider than the
@@ -59,6 +90,7 @@ export function intersectAccess(ceiling, pool) {
59
90
  : new Set(ceiling.connectorIds);
60
91
  const connectorIds = [];
61
92
  const toolAccess = new Map();
93
+ const guardedToolAccess = new Map();
62
94
  for (const id of pool.connectorIds) {
63
95
  if (allowedIds && !allowedIds.has(id))
64
96
  continue;
@@ -73,7 +105,20 @@ export function intersectAccess(ceiling, pool) {
73
105
  else if (fromPool ?? fromCeiling) {
74
106
  toolAccess.set(id, (fromPool ?? fromCeiling));
75
107
  }
108
+ const guarded = ceiling.guardedToolAccess?.get(id);
109
+ if (guarded) {
110
+ const allowed = toolAccess.get(id);
111
+ const retained = allowed
112
+ ? new Set([...guarded].filter((name) => allowed.has(name)))
113
+ : guarded;
114
+ if (retained.size > 0)
115
+ guardedToolAccess.set(id, retained);
116
+ }
76
117
  connectorIds.push(id);
77
118
  }
78
- return toolAccess.size > 0 ? { connectorIds, toolAccess } : { connectorIds };
119
+ return {
120
+ connectorIds,
121
+ ...(toolAccess.size > 0 ? { toolAccess } : {}),
122
+ ...(guardedToolAccess.size > 0 ? { guardedToolAccess } : {}),
123
+ };
79
124
  }
@@ -0,0 +1,8 @@
1
+ import type { AuthenticatedIdentity, ConnectorContext } from "./types.js";
2
+ export interface ConnectorCaller {
3
+ identity: AuthenticatedIdentity;
4
+ /** The `/mcp/<pool>` the call arrived on, when it was a pool. */
5
+ pool?: string;
6
+ }
7
+ export declare function attachCaller(ctx: ConnectorContext, caller: ConnectorCaller | undefined): ConnectorContext;
8
+ export declare function callerOf(ctx: ConnectorContext): ConnectorCaller | undefined;
@@ -0,0 +1,19 @@
1
+ // Who is calling, for the connectors connecta ships and nothing else.
2
+ //
3
+ // `ConnectorContext` deliberately carries no identity: a deployment's own
4
+ // connector acts for the deployment, and one that could read its caller would
5
+ // invite per-user behavior the access rules never saw. The built-in artifacts
6
+ // connector is the exception — every version it writes records who wrote it —
7
+ // so the identity rides beside the context, the way the OAuth sealer does,
8
+ // where only in-repo code can read it. Core sets it from the authorization
9
+ // that admitted the request, on the scoped registry view that request
10
+ // receives; no argument, header, or program can name it.
11
+ const callers = new WeakMap();
12
+ export function attachCaller(ctx, caller) {
13
+ if (caller)
14
+ callers.set(ctx, caller);
15
+ return ctx;
16
+ }
17
+ export function callerOf(ctx) {
18
+ return callers.get(ctx);
19
+ }
@@ -775,6 +775,7 @@ export function remoteMcp(id, opts) {
775
775
  // SDK v2 selects its validator by runtime export condition: AJV on
776
776
  // Node and @cfworker/json-schema under workerd. The Workers-safe path
777
777
  // no longer needs Connecta-specific wiring.
778
+ const handshakeAbort = new AbortController();
778
779
  const c = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
779
780
  versionNegotiation: {
780
781
  mode: opts.versionNegotiation ?? "auto",
@@ -783,6 +784,19 @@ export function remoteMcp(id, opts) {
783
784
  // below as one structured, non-retryable connector failure.
784
785
  inputRequired: { autoFulfill: false },
785
786
  });
787
+ // @modelcontextprotocol/client 2.0.0 can attempt the legacy initialized
788
+ // notification after we close its transport. Its rejected Promise is
789
+ // unhandled under workerd even though connect() has a caller. This
790
+ // connection is already abandoned, so skip only that final handshake
791
+ // notification after our abort. Remove when the SDK owns this race:
792
+ // https://github.com/modelcontextprotocol/typescript-sdk/issues/2864
793
+ const notify = c.notification.bind(c);
794
+ c.notification = (message, options) => {
795
+ if (handshakeAbort.signal.aborted && message.method === "notifications/initialized") {
796
+ return Promise.resolve();
797
+ }
798
+ return notify(message, options);
799
+ };
786
800
  const t = buildTransport(ctx, provider, credentialFramed);
787
801
  if (!owned()) {
788
802
  detach(closeConnection(null, t, ctx.logger));
@@ -794,11 +808,11 @@ export function remoteMcp(id, opts) {
794
808
  // what aborts a connect still in flight.
795
809
  const lease = Scope.forkUnsafe(state.scope);
796
810
  const held = { client: null };
797
- yield* Scope.addFinalizer(lease, Effect.suspend(() => closeConnection(held.client, t, ctx.logger)));
811
+ yield* Scope.addFinalizer(lease, Effect.sync(() => handshakeAbort.abort()).pipe(Effect.andThen(Effect.suspend(() => closeConnection(held.client, t, ctx.logger)))));
798
812
  state.lease = lease;
799
813
  state.transport = t;
800
814
  return yield* Effect.gen(function* () {
801
- yield* promised(() => c.connect(t));
815
+ yield* promised(() => c.connect(t, { signal: handshakeAbort.signal }));
802
816
  // A probe deadline can end its scope while connect is still in flight.
803
817
  // The transport is closed immediately by closeScope; if connect wins
804
818
  // that race anyway, close the resulting client rather than resurrecting
package/dist/errors.d.ts CHANGED
@@ -15,7 +15,14 @@ export type ConnectorCallErrorCode = "timeout" | "auth_required" | "rate_limited
15
15
  * code stays `connector_call_failed` or `auth_required` with a message that
16
16
  * states the ambiguity (H11).
17
17
  */
18
- | "not_found" | "input_required_unsupported" | "connector_call_failed";
18
+ | "not_found"
19
+ /**
20
+ * The write named a base version or revision that is no longer current:
21
+ * someone else changed the thing first. The caller re-reads, reapplies its
22
+ * change, and retries with the base `current` reports — repeating the same
23
+ * call can never succeed, and waiting will not help either.
24
+ */
25
+ | "conflict" | "input_required_unsupported" | "connector_call_failed";
19
26
  /** One bounded, payload-free explanation of an input-schema mismatch. */
20
27
  export interface ArgumentValidationIssue {
21
28
  /** JSON Pointer into the submitted arguments; "/" means the root value. */
@@ -88,12 +95,18 @@ export declare class ConnectorCallError extends Error {
88
95
  readonly validation: ArgumentValidationDetails | undefined;
89
96
  /** Sanitized transport diagnostics for `unavailable` only. */
90
97
  readonly details: UnavailableDetails | undefined;
98
+ /**
99
+ * For `conflict` only: where each thing the write touched stands now, e.g.
100
+ * `{ revision: 12, view: 7 }`. Bounded to 20 whole-number entries.
101
+ */
102
+ readonly current: Readonly<Record<string, number>> | undefined;
91
103
  constructor(code: ConnectorCallErrorCode, message: string, opts?: {
92
104
  retryable?: boolean;
93
105
  retryAfterMs?: number;
94
106
  cause?: unknown;
95
107
  validation?: ArgumentValidationDetails;
96
108
  details?: UnavailableDetails;
109
+ current?: Readonly<Record<string, number>>;
97
110
  });
98
111
  }
99
112
  /** The `error` object surfaced in value-mode call results and rejected promises. */
@@ -107,6 +120,8 @@ export interface CallErrorDetails {
107
120
  retryAfterMs?: number;
108
121
  /** Bounded input-schema findings; paths and expectations, never values. */
109
122
  validation?: ArgumentValidationDetails;
123
+ /** On a `conflict`: where each thing the write touched stands now. */
124
+ current?: Readonly<Record<string, number>>;
110
125
  /** Connector whose failed operation needs recovery. */
111
126
  connector?: string;
112
127
  /** Canonical downstream address the agent may retry after recovery. */
package/dist/errors.js CHANGED
@@ -162,9 +162,30 @@ const RETRYABLE_BY_CODE = {
162
162
  auth_required: false,
163
163
  invalid_args: false,
164
164
  not_found: false,
165
+ conflict: false,
165
166
  input_required_unsupported: false,
166
167
  connector_call_failed: false,
167
168
  };
169
+ /** Entries a conflict's `current` map may carry. */
170
+ const MAX_CURRENT_ENTRIES = 20;
171
+ const MAX_CURRENT_KEY_CHARS = 128;
172
+ /**
173
+ * Where each thing a conflicting write touched stands now: short names to
174
+ * whole non-negative numbers, at most 20 of them. Anything else is dropped
175
+ * whole rather than clipped, because a truncated key names a different thing.
176
+ */
177
+ function boundedCurrent(current) {
178
+ if (!current || typeof current !== "object")
179
+ return undefined;
180
+ const entries = Object.entries(current).filter(([key, value]) => key.length > 0 &&
181
+ key.length <= MAX_CURRENT_KEY_CHARS &&
182
+ key !== "__proto__" &&
183
+ Number.isSafeInteger(value) &&
184
+ value >= 0);
185
+ if (entries.length === 0)
186
+ return undefined;
187
+ return Object.freeze(Object.fromEntries(entries.slice(0, MAX_CURRENT_ENTRIES)));
188
+ }
168
189
  /** Non-negative integer milliseconds, or undefined for anything else. */
169
190
  function normalizeRetryAfterMs(value) {
170
191
  if (value === undefined)
@@ -201,6 +222,11 @@ export class ConnectorCallError extends Error {
201
222
  validation;
202
223
  /** Sanitized transport diagnostics for `unavailable` only. */
203
224
  details;
225
+ /**
226
+ * For `conflict` only: where each thing the write touched stands now, e.g.
227
+ * `{ revision: 12, view: 7 }`. Bounded to 20 whole-number entries.
228
+ */
229
+ current;
204
230
  constructor(code, message, opts = {}) {
205
231
  super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
206
232
  this.name = "ConnectorCallError";
@@ -211,6 +237,7 @@ export class ConnectorCallError extends Error {
211
237
  code === "unavailable" ? sanitizedUnavailableDetails(opts.details) : undefined;
212
238
  this.validation =
213
239
  code === "invalid_args" ? boundedValidation(opts.validation) : undefined;
240
+ this.current = code === "conflict" ? boundedCurrent(opts.current) : undefined;
214
241
  }
215
242
  }
216
243
  /**
@@ -262,6 +289,7 @@ export function classifyCallError(err, fallbackCode = "connector_call_failed") {
262
289
  : {}),
263
290
  ...(err.validation ? { validation: err.validation } : {}),
264
291
  ...(err.details ? { details: err.details } : {}),
292
+ ...(err.current ? { current: err.current } : {}),
265
293
  };
266
294
  }
267
295
  // An aborted fetch rejects with a DOMException named "AbortError" whose