@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.
- package/AGENTS.md +6 -5
- package/CHANGELOG.md +271 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +449 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +194 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/catalog-service.js +39 -29
- package/dist/catalog.js +42 -8
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- package/dist/connectors/remote-mcp.js +16 -2
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +427 -70
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +79 -14
- package/dist/executors/quickjs.js +4 -2
- package/dist/index.d.ts +62 -6
- package/dist/index.js +230 -6
- package/dist/invocation.js +56 -10
- package/dist/meta-tools.js +7 -2
- package/dist/module-contracts.d.ts +21 -1
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +62 -5
- package/dist/operator-ui/view.js +94 -5
- package/dist/program-source.js +116 -0
- package/dist/registry.d.ts +42 -6
- package/dist/registry.js +148 -47
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +6 -69
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +27 -5
- package/dist/routes/mcp.js +116 -59
- package/dist/routes/oauth-management.js +22 -9
- package/dist/routes/operator.js +1 -0
- package/dist/routes/shared.d.ts +23 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +2 -1
- package/dist/routes/ui.js +13 -4
- package/dist/run-journal.js +347 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/server.js +26 -0
- package/dist/skills.js +27 -8
- package/dist/timeout.js +26 -0
- package/dist/tool-safety.d.ts +35 -0
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +18 -0
- package/dist/ui.d.ts +12 -5
- package/dist/ui.js +32 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +196 -19
- package/documentation/auth.md +83 -2
- package/documentation/code-mode.md +221 -48
- package/documentation/meta-tools.md +44 -25
- package/ethos.md +12 -10
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +174 -10
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/index.ts +36 -1
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +5 -1
- package/package.json +6 -2
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
package/dist/catalog-service.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
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.
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
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
|
-
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
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) =>
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`...${
|
|
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
|
|
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.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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 {};
|
package/dist/connector-access.js
CHANGED
|
@@ -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.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
27
|
+
const plain = new Map();
|
|
28
|
+
const guarded = new Map();
|
|
26
29
|
for (const entry of value) {
|
|
27
|
-
if (typeof entry
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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"
|
|
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
|