@zackbart/connecta 0.30.0 → 0.32.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/CHANGELOG.md +66 -0
- package/README.md +21 -16
- package/bin/connecta.mjs +1 -0
- package/bin/version.mjs +1 -1
- package/dist/auth-elicitation.js +1 -1
- package/dist/call-recovery.js +25 -0
- package/dist/catalog-service.js +7 -2
- package/dist/config-schema.d.ts +14 -7
- package/dist/config-schema.js +29 -10
- package/dist/config-value-sources.js +6 -3
- package/dist/config.d.ts +26 -8
- package/dist/config.js +38 -16
- package/dist/connector-access.d.ts +2 -1
- package/dist/connectors/guarded-fetch.d.ts +18 -1
- package/dist/connectors/guarded-fetch.js +21 -2
- package/dist/describe-config.d.ts +6 -3
- package/dist/describe-config.js +3 -2
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +1 -0
- package/dist/execute.js +16 -14
- package/dist/index.js +4 -3
- package/dist/invocation.js +39 -4
- package/dist/meta-tools.js +17 -19
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -0
- package/dist/operator-ui/contract.d.ts +5 -4
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +1 -1
- package/dist/operator-ui/view.d.ts +9 -0
- package/dist/operator-ui/view.js +12 -0
- package/dist/program-writes.js +4 -49
- package/dist/provider.d.ts +6 -5
- package/dist/provider.js +3 -4
- package/dist/providers/_shared/google/workspace.js +1 -1
- package/dist/providers/_shared/rest/dispatch.js +41 -0
- package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
- package/dist/providers/_shared/rest/operation-index.js +527 -0
- package/dist/providers/_shared/rest/tools.d.ts +141 -0
- package/dist/providers/_shared/rest/tools.js +569 -0
- package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
- package/dist/providers/_shared/rest/value-safety.js +580 -0
- package/dist/providers/breeze/skill.generated.js +2 -2
- package/dist/providers/cloudflare/graphql.js +385 -0
- package/dist/providers/cloudflare/index.d.ts +47 -51
- package/dist/providers/cloudflare/index.js +188 -1997
- package/dist/providers/cloudflare/openapi.generated.js +3347 -0
- package/dist/providers/cloudflare/rest.d.ts +37 -0
- package/dist/providers/cloudflare/rest.js +1056 -0
- package/dist/providers/cloudflare/skill.generated.js +7 -4
- package/dist/providers/cloudflare/value-safety.js +1247 -0
- package/dist/providers/docs/index.d.ts +1 -1
- package/dist/providers/docs/index.js +2 -2
- package/dist/providers/drive/index.d.ts +1 -1
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/forms/index.d.ts +1 -1
- package/dist/providers/forms/index.js +2 -2
- package/dist/providers/github/skill.generated.js +2 -2
- package/dist/providers/gmail/index.d.ts +1 -1
- package/dist/providers/gmail/index.js +274 -23
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/linear/index.js +1 -1
- package/dist/providers/mixpanel/index.d.ts +27 -12
- package/dist/providers/mixpanel/index.js +58 -6
- package/dist/providers/mixpanel/operations.js +20 -0
- package/dist/providers/mixpanel/rest.js +603 -0
- package/dist/providers/mixpanel/skill.generated.js +3 -2
- package/dist/providers/mixpanel/value-safety.js +16 -0
- package/dist/providers/notion/index.d.ts +35 -31
- package/dist/providers/notion/index.js +76 -1499
- package/dist/providers/notion/openapi.generated.js +75 -0
- package/dist/providers/notion/rest.d.ts +24 -0
- package/dist/providers/notion/rest.js +1186 -0
- package/dist/providers/notion/skill.generated.js +5 -4
- package/dist/providers/notion/value-safety.js +31 -0
- package/dist/providers/planning-center/index.d.ts +27 -0
- package/dist/providers/planning-center/index.js +286 -24
- package/dist/providers/planning-center/skill.generated.js +24 -4
- package/dist/providers/sheets/index.d.ts +1 -1
- package/dist/providers/sheets/index.js +3 -3
- package/dist/providers/slides/index.d.ts +1 -1
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/stripe/index.d.ts +26 -20
- package/dist/providers/stripe/index.js +103 -130
- package/dist/providers/stripe/openapi.generated.js +649 -0
- package/dist/providers/stripe/rest.d.ts +14 -0
- package/dist/providers/stripe/rest.js +395 -0
- package/dist/providers/stripe/skill.generated.js +7 -5
- package/dist/providers/stripe/value-safety.js +705 -0
- package/dist/providers/vercel/index.d.ts +31 -22
- package/dist/providers/vercel/index.js +96 -853
- package/dist/providers/vercel/openapi.generated.js +456 -0
- package/dist/providers/vercel/rest.d.ts +20 -0
- package/dist/providers/vercel/rest.js +1002 -0
- package/dist/providers/vercel/skill.generated.js +4 -5
- package/dist/providers/vercel/value-safety.js +328 -0
- package/dist/providers/wispr-flow/index.d.ts +14 -0
- package/dist/providers/wispr-flow/index.js +117 -0
- package/dist/providers/wispr-flow/skill.generated.js +8 -0
- package/dist/routes/mcp.js +30 -10
- package/dist/routes/operator.js +14 -7
- package/dist/routes/ui-config.js +2 -2
- package/dist/server.js +3 -0
- package/dist/skills.js +5 -4
- package/dist/tool-safety.js +13 -5
- package/dist/types.d.ts +1 -1
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +1 -1
- package/dist/usage-guide.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +47 -0
- package/documentation/README.md +1 -0
- package/documentation/architecture.md +155 -28
- package/documentation/auth.md +86 -27
- package/documentation/code-mode.md +81 -72
- package/documentation/deploying.md +10 -5
- package/documentation/integrating.md +72 -9
- package/documentation/meta-tools.md +52 -38
- package/documentation/operating.md +11 -8
- package/documentation/operator-tests.md +2 -3
- package/documentation/operator-ui.md +9 -3
- package/documentation/provider-auth-migration.md +308 -0
- package/documentation/provider-migration-0.29.md +18 -6
- package/examples/worker/AGENTS.md +5 -0
- package/examples/worker/README.md +2 -2
- package/examples/worker/src/connecta.config.ts +5 -0
- package/package.json +7 -2
- package/templates/node/AGENTS.md +6 -4
- package/templates/node/README.md +6 -4
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +5 -0
- package/dist/providers/vercel/mcp-ownership.js +0 -212
- package/dist/tool-safety.d.ts +0 -9
package/dist/routes/mcp.js
CHANGED
|
@@ -294,7 +294,7 @@ function toolkitRetired(logger, sentSecrets) {
|
|
|
294
294
|
},
|
|
295
295
|
});
|
|
296
296
|
}
|
|
297
|
-
function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext,
|
|
297
|
+
function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, readOnly = false, pool, principalActor) {
|
|
298
298
|
const sentSecrets = sentSecretsForRequest(requestScope);
|
|
299
299
|
// Every McpServer the request builds is fresh and closes with its scope.
|
|
300
300
|
// The modern handler tears its own down after the exchange; the legacy
|
|
@@ -361,7 +361,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
361
361
|
client,
|
|
362
362
|
baseUrl,
|
|
363
363
|
requestScope,
|
|
364
|
-
|
|
364
|
+
readOnly,
|
|
365
365
|
canManageAuth,
|
|
366
366
|
oauthConnectUrl: (id, force) => oauthConnectUrl(opts, baseUrl, id, principalKey, force),
|
|
367
367
|
oauthConnectUnavailable: oauthConnectUnavailable(opts),
|
|
@@ -385,7 +385,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
385
385
|
requestSignal,
|
|
386
386
|
...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
|
|
387
387
|
...executeLimits(opts.config),
|
|
388
|
-
|
|
388
|
+
readOnly,
|
|
389
389
|
});
|
|
390
390
|
servers.push(server);
|
|
391
391
|
return server;
|
|
@@ -517,6 +517,8 @@ export function createMcpRoute(opts) {
|
|
|
517
517
|
};
|
|
518
518
|
function routeMcp(context) {
|
|
519
519
|
const { path, request, baseUrl, runtimeContext } = context;
|
|
520
|
+
// Protected-resource metadata stays public, including when a grant is
|
|
521
|
+
// configured, so a fresh OAuth client can discover how to authenticate.
|
|
520
522
|
if (path !== "/mcp" && !path.startsWith("/mcp/"))
|
|
521
523
|
return Effect.succeed(null);
|
|
522
524
|
// Admission, refusal bodies, SDK serialization and connector work share
|
|
@@ -644,7 +646,23 @@ export function createMcpRoute(opts) {
|
|
|
644
646
|
// one identical 404 so a credential never enumerates the other pools;
|
|
645
647
|
// the operator log is where the reason lives.
|
|
646
648
|
let access = authz;
|
|
647
|
-
let
|
|
649
|
+
let readOnly = opts.config.readOnly;
|
|
650
|
+
if (poolName === undefined && opts.config.grant) {
|
|
651
|
+
const denialReason = yield* Effect.promise(async () => {
|
|
652
|
+
try {
|
|
653
|
+
return (await opts.config.grant(authz.identity)) === true
|
|
654
|
+
? undefined
|
|
655
|
+
: "default_grant_denied";
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
return "default_grant_threw";
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
if (denialReason !== undefined) {
|
|
662
|
+
logFailure(opts.config.logger, "MCP default endpoint request denied", failureRecord({ reason: denialReason }));
|
|
663
|
+
return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
|
|
664
|
+
}
|
|
665
|
+
}
|
|
648
666
|
if (poolName !== undefined) {
|
|
649
667
|
const pool = opts.pools?.get(poolName);
|
|
650
668
|
const denialReason = !pool
|
|
@@ -662,7 +680,7 @@ export function createMcpRoute(opts) {
|
|
|
662
680
|
return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
|
|
663
681
|
}
|
|
664
682
|
access = intersectAccess(authz, pool.access);
|
|
665
|
-
|
|
683
|
+
readOnly = pool.readOnly;
|
|
666
684
|
}
|
|
667
685
|
let scopedRegistry;
|
|
668
686
|
try {
|
|
@@ -685,21 +703,23 @@ export function createMcpRoute(opts) {
|
|
|
685
703
|
if (current.subjectKey !== authz.subjectKey || current.principalKey !== authz.principalKey)
|
|
686
704
|
return false;
|
|
687
705
|
validateAuthPermissions(current, opts.registry);
|
|
706
|
+
if (poolName === undefined && opts.config.grant && (await opts.config.grant(current.identity)) !== true)
|
|
707
|
+
return false;
|
|
688
708
|
let currentAccess = current;
|
|
689
|
-
let
|
|
709
|
+
let currentReadOnly = opts.config.readOnly;
|
|
690
710
|
if (poolName !== undefined) {
|
|
691
711
|
const pool = opts.pools.get(poolName);
|
|
692
712
|
if (!pool || (await pool.grant(current.identity)) !== true)
|
|
693
713
|
return false;
|
|
694
714
|
currentAccess = intersectAccess(current, pool.access);
|
|
695
|
-
|
|
715
|
+
currentReadOnly = pool.readOnly;
|
|
696
716
|
}
|
|
697
717
|
const view = opts.registry.scoped({
|
|
698
718
|
...currentAccess,
|
|
699
719
|
...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
|
|
700
720
|
...(current.principalKey ? { principalKey: current.principalKey } : {}),
|
|
701
721
|
});
|
|
702
|
-
if (classification === "write" &&
|
|
722
|
+
if (classification === "write" && currentReadOnly)
|
|
703
723
|
return false;
|
|
704
724
|
if (address === undefined)
|
|
705
725
|
return true;
|
|
@@ -716,7 +736,7 @@ export function createMcpRoute(opts) {
|
|
|
716
736
|
return Boolean(tool &&
|
|
717
737
|
(access === "catalog" ||
|
|
718
738
|
(classification === "read" && tool.classification === "read") ||
|
|
719
|
-
|
|
739
|
+
!currentReadOnly));
|
|
720
740
|
}
|
|
721
741
|
finally {
|
|
722
742
|
await closeConnectorScope(resolved.connector, view.contextFor(resolved.connector.id, baseUrl, requestScope), runtimeContext?.waitUntil?.bind(runtimeContext));
|
|
@@ -740,7 +760,7 @@ export function createMcpRoute(opts) {
|
|
|
740
760
|
return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
|
|
741
761
|
const connector = scopedRegistry.getConnector(id);
|
|
742
762
|
return Boolean(connector && mayManageConnector(authz, connector));
|
|
743
|
-
}, authz.principalKey, runtimeContext,
|
|
763
|
+
}, authz.principalKey, runtimeContext, readOnly, poolName, authz.identity.principal
|
|
744
764
|
? {
|
|
745
765
|
kind: authz.actor.kind,
|
|
746
766
|
id: authz.identity.principal.id,
|
package/dist/routes/operator.js
CHANGED
|
@@ -62,13 +62,20 @@ export function scopeFor(authz, connectorIds = authz.connectorIds) {
|
|
|
62
62
|
* included, rather than quietly granting what remains.
|
|
63
63
|
*/
|
|
64
64
|
export function visibleRegistry({ opts }, authz) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
65
|
+
// Operator catalogs describe the default endpoint's view, never a pool view.
|
|
66
|
+
// Authentication alone cannot admit a caller that deployment code refuses.
|
|
67
|
+
const admission = opts.config.grant
|
|
68
|
+
? Effect.tryPromise(async () => (await opts.config.grant(authz.identity)) === true).pipe(Effect.orElseSucceed(() => false))
|
|
69
|
+
: Effect.succeed(true);
|
|
70
|
+
return Effect.flatMap(admission, (allowed) => allowed
|
|
71
|
+
? Effect.try({
|
|
72
|
+
try: () => {
|
|
73
|
+
validateAuthPermissions(authz, opts.registry);
|
|
74
|
+
return opts.registry.scoped(scopeFor(authz));
|
|
75
|
+
},
|
|
76
|
+
catch: (error) => new Answer(privateJson({ error: msg(error) }, { status: 403 })),
|
|
77
|
+
})
|
|
78
|
+
: refuse("not found", 404));
|
|
72
79
|
}
|
|
73
80
|
/**
|
|
74
81
|
* Run one route program to its Response.
|
package/dist/routes/ui-config.js
CHANGED
|
@@ -154,7 +154,7 @@ function configRead(context) {
|
|
|
154
154
|
pools.push({
|
|
155
155
|
name,
|
|
156
156
|
path: `/mcp/${name}`,
|
|
157
|
-
|
|
157
|
+
readOnly: pool.readOnly,
|
|
158
158
|
grants: grants(intersectAccess(authz, pool.access), ids),
|
|
159
159
|
});
|
|
160
160
|
}
|
|
@@ -199,7 +199,7 @@ function configRead(context) {
|
|
|
199
199
|
you: {
|
|
200
200
|
interactive: authz.identity.interactive,
|
|
201
201
|
grants: grants(authz, ids),
|
|
202
|
-
|
|
202
|
+
readOnly: opts.config.readOnly,
|
|
203
203
|
pools,
|
|
204
204
|
permissions: {
|
|
205
205
|
activity: activity === "available" || activity === "unavailable",
|
package/dist/server.js
CHANGED
|
@@ -88,6 +88,9 @@ export function createFetchHandler(opts) {
|
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
90
|
if (path.startsWith("/.well-known/")) {
|
|
91
|
+
const guardedMetadata = yield* routeMcp.handle(context);
|
|
92
|
+
if (guardedMetadata)
|
|
93
|
+
return guardedMetadata;
|
|
91
94
|
const response = yield* metadata(request, baseUrl);
|
|
92
95
|
if (response)
|
|
93
96
|
return response;
|
package/dist/skills.js
CHANGED
|
@@ -7,11 +7,12 @@ import { sentSecretsForRequest } from "./sent-secrets.js";
|
|
|
7
7
|
const ROUTE = "Choose a route before discovery. One known-address read uses call_tool; one known-address write uses call_destructive_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. Sample unfamiliar reads.";
|
|
8
8
|
const RECOVERY = 'When an auth error includes a handoff, give it to the user or operator. Use authorize_connector for a fresh link. Page resultId notices and format:"paged" handles with connecta.result(handleOrId, { page? }). Fetch skills({ name: "usage" }) only for missing guidance or program repair.';
|
|
9
9
|
/**
|
|
10
|
-
* The always-loaded MCP `instructions` string.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The always-loaded MCP `instructions` string. An endpoint is writable or
|
|
11
|
+
* read-only: a writable one lets programs and call_destructive_tool write,
|
|
12
|
+
* and the client owns approval; a read-only one omits write tools from search
|
|
13
|
+
* and describe and refuses every write call. Guides may still name them.
|
|
13
14
|
*/
|
|
14
|
-
export const CONNECTA_INSTRUCTIONS = `${ROUTE}
|
|
15
|
+
export const CONNECTA_INSTRUCTIONS = `${ROUTE} On a writable endpoint, programs and call_destructive_tool may write. On a read-only endpoint, search omits write tools and every write call is refused; guides may still name them. The execute_code description says which this endpoint is. Never repeat a write to recover its output. ${RECOVERY}`;
|
|
15
16
|
export { USAGE_SKILL } from "./usage-guide.js";
|
|
16
17
|
/** True when at least one of `connectors` carries a usage guide. */
|
|
17
18
|
export function hasConnectorGuides(connectors) {
|
package/dist/tool-safety.js
CHANGED
|
@@ -14,9 +14,17 @@ export function classifyTool(definition, override, review) {
|
|
|
14
14
|
? "read"
|
|
15
15
|
: "write";
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The only permission decision for a connector call. Approval is client-owned.
|
|
19
|
+
* A read-only endpoint refuses every write on every surface; a writable one
|
|
20
|
+
* sends writes through call_destructive_tool and programs, never call_tool.
|
|
21
|
+
*/
|
|
22
|
+
export function writeRefusal(classification, surface, readOnly = false) {
|
|
23
|
+
if (classification === "read")
|
|
24
|
+
return undefined;
|
|
25
|
+
if (readOnly)
|
|
26
|
+
return "read_only_endpoint";
|
|
27
|
+
return surface === "call_destructive_tool" || surface === "execute_code"
|
|
28
|
+
? undefined
|
|
29
|
+
: "destructive_tool_requires_approval";
|
|
22
30
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -95,7 +95,7 @@ export interface ToolDef {
|
|
|
95
95
|
* Standard MCP tool behavior hints plus provider-specific extensions.
|
|
96
96
|
* The registry classifies these after deployment overrides and provider
|
|
97
97
|
* review. Only a stored read verdict may use call_tool; program writes
|
|
98
|
-
* require a
|
|
98
|
+
* require a writable endpoint. Approval belongs to the host.
|
|
99
99
|
*/
|
|
100
100
|
annotations?: ToolAnnotations;
|
|
101
101
|
}
|
package/dist/ui.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { CONNECTA_FAVICON_SVG, resolveBranding, isSafeHttpUrl, isSafeHttpsUrl, i
|
|
|
9
9
|
export type OperatorPage = import("./operator-ui/view.js").OperatorPage;
|
|
10
10
|
export declare function operatorPageForPath(path: string): OperatorPage | undefined;
|
|
11
11
|
export declare function operatorPageTitle(page: OperatorPage, configuredTitle: string): string;
|
|
12
|
-
/** Report the verdict the registry stored, independent of
|
|
12
|
+
/** Report the verdict the registry stored, independent of whether an endpoint is read-only. */
|
|
13
13
|
export declare function uiToolSafety(definition: ToolDef): UiToolSafety;
|
|
14
14
|
/**
|
|
15
15
|
* The fix-prompt key for a connector that is not usable. Chosen from the
|
package/dist/ui.js
CHANGED
|
@@ -40,7 +40,7 @@ export function operatorPageForPath(path) {
|
|
|
40
40
|
export function operatorPageTitle(page, configuredTitle) {
|
|
41
41
|
return `${OPERATOR_PAGE_LABELS[page]} — ${configuredTitle}`;
|
|
42
42
|
}
|
|
43
|
-
/** Report the verdict the registry stored, independent of
|
|
43
|
+
/** Report the verdict the registry stored, independent of whether an endpoint is read-only. */
|
|
44
44
|
export function uiToolSafety(definition) {
|
|
45
45
|
if (definition.classification === "read")
|
|
46
46
|
return "runs_in_programs";
|
package/dist/usage-guide.js
CHANGED
|
@@ -16,9 +16,9 @@ description: How to route work between one execute_code program and Connecta's e
|
|
|
16
16
|
|
|
17
17
|
Read one account with call_tool: \`{ "address": "crm.get_account", "args": { "id": "acct_42" } }\`.
|
|
18
18
|
Discover and call together when schemas suffice. search_tools covers catalog
|
|
19
|
-
inspection
|
|
20
|
-
|
|
21
|
-
Unannotated tools fail closed to writes.
|
|
19
|
+
inspection. On a read-only endpoint, as execute_code's description says, search
|
|
20
|
+
omits writes (guides may name them) and each fails read_only_endpoint; tell the
|
|
21
|
+
user. Unannotated tools fail closed to writes.
|
|
22
22
|
|
|
23
23
|
## The global and the envelopes
|
|
24
24
|
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CONNECTA_VERSION = "0.
|
|
1
|
+
export declare const CONNECTA_VERSION = "0.32.0";
|
package/dist/version.js
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a write landed, as far as anyone can know.
|
|
3
|
+
*
|
|
4
|
+
* `unknown` is the outcome a result must never paper over: the call left
|
|
5
|
+
* connecta and no answer came back, so sending it again could do it twice and
|
|
6
|
+
* not sending it could leave it undone. That is a dispatched call that timed
|
|
7
|
+
* out, was cancelled, found the service unavailable, or failed any way that
|
|
8
|
+
* is not a verdict. A refusal code (`REFUSALS`) or a downstream tool's own
|
|
9
|
+
* `isError` is an answer, so it is `failed`. Response text does not decide
|
|
10
|
+
* whether an outcome is unknown. A call that was never dispatched is
|
|
11
|
+
* `failed`: nothing was sent.
|
|
12
|
+
* `result_processing_failed` means the downstream call completed.
|
|
13
|
+
*/
|
|
14
|
+
export function classifyWriteOutcome(outcome) {
|
|
15
|
+
if (outcome.ok)
|
|
16
|
+
return "ok";
|
|
17
|
+
if (!outcome.dispatched)
|
|
18
|
+
return "failed";
|
|
19
|
+
const code = outcome.error?.code;
|
|
20
|
+
if (code === "result_processing_failed")
|
|
21
|
+
return "ok";
|
|
22
|
+
if (code === "timeout" || code === "cancelled" || code === "unavailable") {
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
if (code !== undefined && REFUSALS.has(code))
|
|
26
|
+
return "failed";
|
|
27
|
+
return outcome.answered === true ? "failed" : "unknown";
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Codes that say the other side refused the call rather than acted on it:
|
|
31
|
+
* the credential, the arguments, the resource, the rate, or a base version
|
|
32
|
+
* someone else already moved past (`conflict`). Anything else a
|
|
33
|
+
* connector reports after dispatch — `connector_call_failed` from a response
|
|
34
|
+
* too large to read, a redirect it would not follow, a body it could not
|
|
35
|
+
* parse, a 5xx — may come after the write landed, so only a downstream
|
|
36
|
+
* tool's own `isError` answer makes it a known failure.
|
|
37
|
+
*/
|
|
38
|
+
const REFUSALS = new Set([
|
|
39
|
+
"auth_required",
|
|
40
|
+
"downstream_oauth_required",
|
|
41
|
+
"provider_permission_denied",
|
|
42
|
+
"invalid_args",
|
|
43
|
+
"not_found",
|
|
44
|
+
"rate_limited",
|
|
45
|
+
"input_required_unsupported",
|
|
46
|
+
"conflict",
|
|
47
|
+
]);
|
package/documentation/README.md
CHANGED
|
@@ -22,6 +22,7 @@ service, or change Connecta itself.
|
|
|
22
22
|
| [Operator UI](./operator-ui.md) | Optional UI setup, scoped configuration and live-data contract, activity disclosure, and UI implementation |
|
|
23
23
|
| [Operator UI tests](./operator-tests.md) | Browser state matrix, real-server flows, and snapshot maintenance |
|
|
24
24
|
| [Provider migration for 0.29](./provider-migration-0.29.md) | Upgrade actions for Notion, Vercel, and Cloudflare; provider reconciliation tables supply exact name mappings |
|
|
25
|
+
| [Provider auth migration](./provider-auth-migration.md) | Upgrade actions when `auth` selects hosted MCP or Connecta's REST connector, Stripe and Vercel |
|
|
25
26
|
|
|
26
27
|
Each long contract starts with task routes. Follow those section links before
|
|
27
28
|
reading the whole guide. Source and test pointers identify where to check a
|
|
@@ -85,20 +85,20 @@ table, security headers. Route _order_ is the contract — several routes would
|
|
|
85
85
|
behave differently if they were reachable in another order — so read the table
|
|
86
86
|
top to bottom.
|
|
87
87
|
|
|
88
|
-
| Order | Route | Notes
|
|
89
|
-
| ----- | --------------------------------------------------------- |
|
|
90
|
-
| 0 | MCP Origin check | A disallowed `Origin` on `/mcp*` is a fixed 403 before redirects, admission, auth, or preflight — costing no permit and no auth lookup. Originless requests are admitted.
|
|
91
|
-
| 0 | HTTPS upgrade | 308 to an HTTPS `publicUrl`, with path and query _assigned_ onto it rather than resolved against it, so a `//host` pathname cannot replace the origin. `/health` is exempt: a loopback probe must not need public DNS.
|
|
92
|
-
| 0 | Cloudflare Access (Worker, when enabled) | Edge admission ahead of this table; an admitted invocation carries trusted identity in `ctx.access`.
|
|
93
|
-
| 1 | `/oauth/client-metadata/<connectorId>` | Public GET-only CIMD for configured self-hosted OAuth connectors; fixed `publicUrl`, no auth or storage.
|
|
94
|
-
| 1 | Mounted UI routes | Before wildcard OPTIONS, so mutation routes refuse preflight rather than inheriting MCP CORS. No UI module, no routes.
|
|
95
|
-
| 1 | `/connect/<connectorId>`, `/oauth/callback/<connectorId>` | GET-only browser OAuth routes, before wildcard OPTIONS. Both verify the initiating user and management permission, independent of the UI.
|
|
96
|
-
| 2 | MCP preflight | Allowed `OPTIONS` on `/mcp*`: 204 without admission or auth.
|
|
97
|
-
| 2 | Other `OPTIONS` | Auth metadata first, otherwise compatibility CORS preflight.
|
|
98
|
-
| 3 | `/.well-known/*` |
|
|
99
|
-
| 4 | `/health` | Open and payload-free: health, executor, admission, and deployment metadata.
|
|
100
|
-
| 6 | `/mcp`, `/mcp/<pool>` | Admission, then auth, then a request-local MCP server. Body-confirmed modern listens skip admission and are refused after auth and SDK validation. An undeclared pool, a refusing grant, and a throwing grant are one identical 404; see [pools](./auth.md#pools). |
|
|
101
|
-
| 7 | Other paths | 404. Custom HTTP routes belong to the deployment.
|
|
88
|
+
| Order | Route | Notes |
|
|
89
|
+
| ----- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
90
|
+
| 0 | MCP Origin check | A disallowed `Origin` on `/mcp*` is a fixed 403 before redirects, admission, auth, or preflight — costing no permit and no auth lookup. Originless requests are admitted. |
|
|
91
|
+
| 0 | HTTPS upgrade | 308 to an HTTPS `publicUrl`, with path and query _assigned_ onto it rather than resolved against it, so a `//host` pathname cannot replace the origin. `/health` is exempt: a loopback probe must not need public DNS. |
|
|
92
|
+
| 0 | Cloudflare Access (Worker, when enabled) | Edge admission ahead of this table; an admitted invocation carries trusted identity in `ctx.access`. |
|
|
93
|
+
| 1 | `/oauth/client-metadata/<connectorId>` | Public GET-only CIMD for configured self-hosted OAuth connectors; fixed `publicUrl`, no auth or storage. |
|
|
94
|
+
| 1 | Mounted UI routes | Before wildcard OPTIONS, so mutation routes refuse preflight rather than inheriting MCP CORS. No UI module, no routes. |
|
|
95
|
+
| 1 | `/connect/<connectorId>`, `/oauth/callback/<connectorId>` | GET-only browser OAuth routes, before wildcard OPTIONS. Both verify the initiating user and management permission, independent of the UI. |
|
|
96
|
+
| 2 | MCP preflight | Allowed `OPTIONS` on `/mcp*`: 204 without admission or auth. |
|
|
97
|
+
| 2 | Other `OPTIONS` | Auth metadata first, otherwise compatibility CORS preflight. |
|
|
98
|
+
| 3 | `/.well-known/*` | Public auth metadata, or 404. Protected-resource discovery does not authenticate callers or evaluate endpoint grants. |
|
|
99
|
+
| 4 | `/health` | Open and payload-free: health, executor, admission, and deployment metadata. |
|
|
100
|
+
| 6 | `/mcp`, `/mcp/<pool>` | Admission, then auth, then a request-local MCP server. Body-confirmed modern listens skip admission and are refused after auth and SDK validation. An undeclared pool, a refusing endpoint grant, and a throwing endpoint grant are one identical 404; see [pools](./auth.md#pools). |
|
|
101
|
+
| 7 | Other paths | 404. Custom HTTP routes belong to the deployment. |
|
|
102
102
|
|
|
103
103
|
Every response leaves through `withSecurityHeaders`. HTML uses one common
|
|
104
104
|
security-header helper, including status and error pages. Operator shells
|
|
@@ -131,12 +131,17 @@ An admitted non-preflight `/mcp` request then takes five steps in
|
|
|
131
131
|
3. **Narrow to the pool.** On `/mcp/<pool>`, look the name up, run its grant
|
|
132
132
|
against the identity, then `intersectAccess` the pool with the identity's own
|
|
133
133
|
access. A pool can never widen a view; anything else is a 404 naming no pool.
|
|
134
|
+
The endpoint's `readOnly` is the pool's own value, else the deployment's;
|
|
135
|
+
plain `/mcp` uses the deployment's. It travels to the meta-tools and the
|
|
136
|
+
program as one boolean that drops writes from `CatalogService` search and
|
|
137
|
+
describe and makes `writeRefusal` (`src/tool-safety.ts`) refuse every write
|
|
138
|
+
call. Guides and skills are not filtered.
|
|
134
139
|
4. **Derive the registry view.** One `registry.scoped(...)` call with those
|
|
135
140
|
connector ids, exact `connector.tool` addresses when the identity declares a
|
|
136
141
|
narrower slice, and the subject and principal keys. Personal connectors use
|
|
137
142
|
the principal partition and result paging the subject partition with principal,
|
|
138
143
|
endpoint/pool, origin, connector, and tool bindings. Paging rechecks current
|
|
139
|
-
grants, pool membership, and
|
|
144
|
+
grants, pool membership, and the endpoint's `readOnly`; no caller parameter selects a partition (`test/identity-scope.test.ts`).
|
|
140
145
|
5. **Serve.** Refuse `?toolkit=` with a 404 — the toolkits are gone
|
|
141
146
|
([#178](https://github.com/zackbart/connecta/issues/178)) but their URLs were
|
|
142
147
|
handed out, and retiring a scoping boundary into fail-open is worse than any
|
|
@@ -217,13 +222,15 @@ or hands out, and a change usually belongs in exactly one of them:
|
|
|
217
222
|
| `src/invocation.ts` | One tool call: argument validation, call admission, one-attempt timeout, provider retry hints, result unwrapping, size capping, and the activity record. |
|
|
218
223
|
| `src/catalog.ts` | Ranking, description summarizing, and the compact and TypeScript schema renderers discovery shows. |
|
|
219
224
|
| `src/result-shapes.ts` | Bounded runtime-only inference and merging for output shapes learned from successful read-only calls whose providers declared none. |
|
|
220
|
-
| `src/program-writes.ts` |
|
|
225
|
+
| `src/program-writes.ts` | Program write accounting on writable endpoints: write budgets, dispatch draining, and known or unknown outcomes. |
|
|
221
226
|
|
|
222
227
|
`src/meta-tools.ts` and `src/execute.ts` are two front doors onto the same two
|
|
223
228
|
services, `CatalogService` and `InvocationService`. That is the point: a
|
|
224
229
|
program's `connecta.call` and a top-level `call_tool` reach
|
|
225
230
|
`InvocationService.invoke` by different routes and get the same admission, the
|
|
226
|
-
same credential resolution, and the same fail-closed read-only check
|
|
231
|
+
same credential resolution, and the same fail-closed read-only check: one
|
|
232
|
+
`writeRefusal` decision per call, from the stored classification, the calling
|
|
233
|
+
surface, and the endpoint's `readOnly`.
|
|
227
234
|
`test/execute.test.ts` asserts the parity directly, because a sandbox path that
|
|
228
235
|
quietly diverges is how generated code would mint a capability.
|
|
229
236
|
|
|
@@ -646,7 +653,7 @@ only: an array or a class instance there is refused by path, unread. String
|
|
|
646
653
|
maps (static `headers`, `api()`'s `authorizationParams` and
|
|
647
654
|
`tokenRequestHeaders`) are copied the same way and each value must be a
|
|
648
655
|
string. A discriminated union (`remoteMcp()`'s `auth.type`,
|
|
649
|
-
a provider's `
|
|
656
|
+
a provider's `auth.type`) with a missing or unrecognized discriminant throws
|
|
650
657
|
with the valid values. Only plain data is copied. Connectors, modules,
|
|
651
658
|
executors, storage, loggers, handlers, and schemas are opaque and never
|
|
652
659
|
entered. An `api()` tool carries its handler, so it is checked in place (own
|
|
@@ -712,7 +719,7 @@ call. The [provider guide](https://github.com/zackbart/connecta/blob/main/src/pr
|
|
|
712
719
|
value-read conventions.
|
|
713
720
|
|
|
714
721
|
A maintained provider is one `defineProvider()` call (`src/provider.ts`): a
|
|
715
|
-
name, title, kind (`"mcp"`, `"api"`, or `"
|
|
722
|
+
name, title, kind (`"mcp"`, `"api"`, `"composed"`, or `"dual"`), a maintained skill, an
|
|
716
723
|
optional reviewed classification, a closed `options` shape declared with
|
|
717
724
|
`optionsOf<O>()`, and a synchronous `create`. The root exports the option-shape
|
|
718
725
|
combinators and `PROVIDER_COMMON` so provider authors use the same validation
|
|
@@ -723,17 +730,137 @@ connection context `create` supplies, the maintained text, then deployment
|
|
|
723
730
|
instructions, which append and never replace. `src/provider.ts` imports neither
|
|
724
731
|
transport, so an `api()` provider gains no MCP client or Effect graph from it
|
|
725
732
|
(`test/purity.node.test.ts`). The factory carries its `definition`, which build and
|
|
726
|
-
check tools read instead of keeping provider lists.
|
|
727
|
-
live in their own folders.
|
|
733
|
+
check tools read instead of keeping provider lists. Providers
|
|
734
|
+
live in their own folders. Hosted implementations, including the
|
|
728
735
|
MCP branches of Notion, Vercel, and Cloudflare, use reviewed presets over
|
|
729
|
-
`remoteMcp({ classify })`. The
|
|
736
|
+
`remoteMcp({ classify })`. The migrated API-only factories retain an internal
|
|
730
737
|
adapter to the same definition and description-stamping path. GitHub composes hosted tools with a scope-enforced REST complement.
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
738
|
+
A `"dual"` provider selects one implementation at construction, usually through `auth`
|
|
739
|
+
([decision 0005](https://github.com/zackbart/connecta/blob/main/decisions/0005-auth-selects-implementation.md)):
|
|
740
|
+
`variants(["auth", "type"], …)` closes each case's options, and
|
|
741
|
+
`byAuth()` in `src/providers/_shared/rest/dispatch.ts` sends OAuth to
|
|
742
|
+
`hostedOAuth()` (`remoteMcp()`, OAuth only, the reviewed classification) and a
|
|
743
|
+
key to Connecta's REST connector. Stripe, Notion, Cloudflare, and Vercel require `auth`. Mixpanel is also dual,
|
|
744
|
+
with explicit `via: "mcp" | "api"` and MCP as the unchanged default: service accounts
|
|
745
|
+
reach both implementations, so auth cannot distinguish them. API mode requires
|
|
746
|
+
operator-managed service-account credentials and never falls back to MCP.
|
|
747
|
+
Mixpanel uses the same REST transport, operation matching and value-safety engine
|
|
748
|
+
for a narrow reviewed subset. Its official contracts are YAML and app identity
|
|
749
|
+
has no published response schema, so its guide and drift record carry explicit
|
|
750
|
+
manual review evidence instead of claiming a machine-checkable JSON pin.
|
|
751
|
+
|
|
752
|
+
The generic REST connector (`src/providers/_shared/rest/`) serves
|
|
753
|
+
`<vendor>_api_search`, `_api_details`, `_api_read`, and `_api_write` over an
|
|
754
|
+
`OperationIndex` that `npm run providers:spec` compiles offline from the
|
|
755
|
+
provider's pinned `openapi.source.json` into `openapi.generated.ts`.
|
|
756
|
+
Each call is matched to an operation and checked against its parameters before
|
|
757
|
+
transport; `_api_read` admits GET and the vendor's reviewed read-only POSTs in
|
|
758
|
+
its handler, since classification is per tool name. Vendors configure only a
|
|
759
|
+
transport, failure mapper, scope, framing, cursor extraction, a reviewed
|
|
760
|
+
value-safety table (required; see below), argument-dependent refusals, and an
|
|
761
|
+
idempotency header, plus optional hooks: `path` (fill default ids before
|
|
762
|
+
matching), `admit` (an awaited check in `callRest` every tool passes, such as
|
|
763
|
+
Cloudflare's account and zone pin), `result` (unwrap a vendor envelope;
|
|
764
|
+
redaction runs after it and before cursors and `select`), a reviewed
|
|
765
|
+
request-header allowlist, text bodies, and index `slashParams` that admit an
|
|
766
|
+
encoded `/` in a final key segment (`test/rest-connector.test.ts`).
|
|
767
|
+
Large documents opt into generator options that drop restated operation ids
|
|
768
|
+
and plain path parameters and cap one operation's details (`opBudget`).
|
|
769
|
+
`check:providers-generated` checks each index header against its source record
|
|
770
|
+
offline; `providers:check` reports a pin that differs from the live document.
|
|
771
|
+
No maintained provider selects an implementation with `surface` any more. See
|
|
772
|
+
the
|
|
773
|
+
[provider auth migration](./provider-auth-migration.md). Notion's token
|
|
774
|
+
connector keeps its `integration_*` projections and authoring helpers beside
|
|
775
|
+
the generic REST tools.
|
|
776
|
+
|
|
777
|
+
#### Value safety for REST vendors
|
|
778
|
+
|
|
779
|
+
The bar ([decision 0005](https://github.com/zackbart/connecta/blob/main/decisions/0005-auth-selects-implementation.md)):
|
|
780
|
+
no REST tool response, success or error, returns a credential that grants
|
|
781
|
+
access to the vendor or a third party, or a stored secret value, unless a
|
|
782
|
+
reviewed named tool exists to return it. The threat model is Infisical's
|
|
783
|
+
above: an honest server, with secrets people type into free text out of
|
|
784
|
+
scope. Field-by-field redaction over a whole vendor API does not converge, so
|
|
785
|
+
every vendor ships one reviewed table through one mechanism
|
|
786
|
+
(`src/providers/_shared/rest/value-safety.ts`):
|
|
787
|
+
|
|
788
|
+
1. **Candidates from the pinned spec.** `scripts/value-safety.mjs` flags every
|
|
789
|
+
operation whose success response names or describes a credential, keyed
|
|
790
|
+
value, header, environment, or binding container, or whose path, id,
|
|
791
|
+
summary, or description names a secret family (tokens, secrets, keys,
|
|
792
|
+
signing, webhooks, drains, transfer, claim, and invite codes). It is the
|
|
793
|
+
union of the detectors Cloudflare and Vercel shipped first, and
|
|
794
|
+
deliberately over-inclusive. `providers:spec` writes
|
|
795
|
+
`value-safety.candidates.json` beside the index when the source record sets
|
|
796
|
+
`options.valueSafety` (vendor `operationWords`; Stripe's `expansions: false`
|
|
797
|
+
skips id-or-object expansions, which are their own operations' candidates),
|
|
798
|
+
stamped with the pinned digest, the detector format, and its options. The
|
|
799
|
+
file is test evidence and never ships.
|
|
800
|
+
2. **One reviewed verdict per candidate**, in the vendor's `value-safety.ts`:
|
|
801
|
+
`refuse` (the generic tools refuse it before transport, naming the
|
|
802
|
+
operation and the reason), `redact` (reviewed field paths), or `safe`, each
|
|
803
|
+
with a reason. Paths are dot paths into the data a tool returns: a list on
|
|
804
|
+
the way is traversed, `[]` and `{}`/`*` address list items and map values,
|
|
805
|
+
`@keys` replaces secret map keys, `[?env]`, `[?credential]`, and
|
|
806
|
+
`[?header]` filter list items, `url:` sanitizes a URL, and `origin:` (or a
|
|
807
|
+
`#url` suffix) keeps only scheme and host. `keep` names reviewed metadata
|
|
808
|
+
the heuristic leaves (children are still checked). A vendor whose objects
|
|
809
|
+
repeat across operations (Stripe) reviews a field once in `fields`,
|
|
810
|
+
`redact` or `keep`, wherever it appears, and states `resources`: rules
|
|
811
|
+
keyed on the object's type discriminator (Stripe's `object`) that apply to
|
|
812
|
+
every such object wherever a response embeds it (an expansion, a list, an
|
|
813
|
+
event's `data.object` and its `previous_attributes`). A resource rule can
|
|
814
|
+
return a reviewed payer-facing URL verbatim only while a declarative guest
|
|
815
|
+
condition holds (deny by default: a Checkout page only when every field
|
|
816
|
+
that can bind the session to a Customer, an Account, or saved payment
|
|
817
|
+
methods is empty and the session only takes a payment) and withhold it
|
|
818
|
+
otherwise. `providers:spec` derives the binding fields from the pinned
|
|
819
|
+
schema, and the harness fails until the condition lists each one. Argument-dependent refusals (Stripe expansions,
|
|
820
|
+
Vercel `decrypt`) stay in the vendor's `refuse` hook.
|
|
821
|
+
3. **Redaction on every success body**, in `callRest` after `result` and
|
|
822
|
+
before cursors and `select`, so named tools, HEAD data, and uploads pass
|
|
823
|
+
through it too: the operation's reviewed paths, then the key-name
|
|
824
|
+
heuristic as defense in depth. The heuristic is the union of the former
|
|
825
|
+
vendor engines: a credential word anywhere in a non-metadata name removes
|
|
826
|
+
the value whole, a credential suffix removes the strings below it,
|
|
827
|
+
labelled or typed secret records lose their value, environment containers
|
|
828
|
+
lose their values, header, cookie, and query rules lose theirs, external
|
|
829
|
+
destinations keep their origin, and every URL loses userinfo and
|
|
830
|
+
credential-named query parameters or fragments. Exemptions are never
|
|
831
|
+
shared; each vendor states its own.
|
|
832
|
+
4. **Errors.** A reviewed operation, or an unreviewed one whose request
|
|
833
|
+
accepts a credential-named field, answers failures with the vendor's codes
|
|
834
|
+
and status but not its text, unless its verdict is wrapped in
|
|
835
|
+
`vendorErrors()` because a review found its errors cannot echo a stored
|
|
836
|
+
secret. Credentials Connecta sent are always removed by the sent-secrets
|
|
837
|
+
matcher.
|
|
838
|
+
|
|
839
|
+
`providers:spec` (run under tsx) also checks the table against the pinned
|
|
840
|
+
spec: every `redact` path and `keep` must name something the operation's
|
|
841
|
+
response schema can return (through `options.valueSafety.dataRoot` for an
|
|
842
|
+
envelope such as Cloudflare's `result`), and every resource rule path
|
|
843
|
+
something the resource's schema can return. It stamps the paths that resolve
|
|
844
|
+
into the candidates file and fails on the rest, unless
|
|
845
|
+
`value-safety.absent.json` acknowledges them (shared verdicts applied to
|
|
846
|
+
operations whose schema lacks a path, or fields a schema leaves undeclared;
|
|
847
|
+
such paths still apply at runtime).
|
|
848
|
+
|
|
849
|
+
`test/fixtures/value-safety.ts` is the shared harness each vendor's
|
|
850
|
+
`value-safety.node.test.ts` runs; `reviewProblems` is pure, and
|
|
851
|
+
`test/value-safety-harness.node.test.ts` proves wrong paths fail it. It fails
|
|
852
|
+
on candidates from another pin, format, or option set; an unreviewed
|
|
853
|
+
candidate; a verdict for an operation the index lacks; a table path the stamp
|
|
854
|
+
lacks (unresolved, or edited since `providers:spec`); a flagged field no path,
|
|
855
|
+
keep, field review, or scope map covers; a flagged field, built in the shape
|
|
856
|
+
the pinned schema gives it, that survives redaction or that no redact path
|
|
857
|
+
reaches; a keep that exempts anything below it or a vendor-wide keep of a
|
|
858
|
+
credential name; a redact path or resource rule that leaves its value at the
|
|
859
|
+
top level, in a list, an expansion, or an event; a refusal that reaches
|
|
860
|
+
transport; a reviewed operation that echoes vendor error text; and a moved
|
|
861
|
+
verdict count.
|
|
862
|
+
`RestVendor.valueSafety` is required, so a new REST vendor cannot ship without
|
|
863
|
+
a table.
|
|
737
864
|
|
|
738
865
|
`remoteMcp({ classify })` is the public way to declare what a downstream's
|
|
739
866
|
tools do: `{ tools: { name: "read" | "write" | "destructive" | { verdict,
|