@zackbart/connecta 0.38.0 → 0.40.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 +91 -0
- package/README.md +3 -2
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/agent-routes.js +231 -0
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2382
- package/dist/auth/native.js +37 -0
- package/dist/auth/oauth-grant.js +13 -0
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth-elicitation.js +7 -1
- package/dist/authorization-handoff.js +11 -5
- package/dist/catalog-cache.js +219 -59
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +284 -47
- package/dist/catalog.js +18 -2
- package/dist/config.d.ts +16 -11
- package/dist/config.js +9 -6
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +7 -4
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +682 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +473 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +555 -0
- package/dist/connectors/remote-mcp.js +62 -2863
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +2 -0
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +152 -114
- package/dist/executor-result.js +3 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +60 -19
- package/dist/guest-types.d.ts +19 -5
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/errors.js +5 -4
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/oauth.js +3 -2
- package/dist/http/provider.js +32 -4
- package/dist/http/safety.js +8 -0
- package/dist/http/tools.js +44 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation.js +26 -8
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +54 -31
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +5 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +135 -13
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/contract.d.ts +10 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/_shared/rest/operation-index.js +48 -50
- package/dist/providers/_shared/rest/value-safety.js +80 -0
- package/dist/providers/breeze/api.js +7 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +4 -3
- package/dist/providers/ccb/api.js +2 -1
- package/dist/providers/ccb/index.js +5 -0
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +2 -4
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +11 -16
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +10 -7
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +7 -5
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +7 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/api.js +3 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +13 -14
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +15 -13
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +3 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +4 -3
- package/dist/registry.d.ts +10 -4
- package/dist/registry.js +41 -13
- package/dist/routes/credentials.js +129 -60
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +30 -14
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +6 -1
- package/dist/routes/shared.js +7 -1
- package/dist/routes/ui-config.js +2 -2
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +16 -0
- package/dist/runtime/call.js +7 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +207 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/storage.js +12 -0
- package/dist/skills.js +37 -20
- package/dist/storage/keys.js +53 -3
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +53 -1
- package/dist/storage/sql.js +12 -2
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/types.d.ts +33 -0
- package/dist/usage-guide.js +16 -35
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +13 -13
- package/documentation/architecture.md +385 -20
- package/documentation/auth.md +233 -64
- package/documentation/code-mode.md +122 -39
- package/documentation/deploying.md +6 -3
- package/documentation/integrating.md +16 -2
- package/documentation/meta-tools.md +240 -49
- package/documentation/migration-0.33.md +5 -1
- package/documentation/migration-0.34.md +3 -1
- package/documentation/operator-tests.md +1 -1
- package/documentation/operator-ui.md +105 -12
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +10 -10
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +2 -1
- package/guides/how-it-works.md +4 -2
- package/package.json +1 -1
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
package/dist/config.js
CHANGED
|
@@ -69,8 +69,10 @@ const discovery = {
|
|
|
69
69
|
/**
|
|
70
70
|
* Deadline (ms) for each downstream probe/catalog call fanned out by
|
|
71
71
|
* `search_tools` and by `connecta.search`/`connecta.describe` inside
|
|
72
|
-
* `execute_code`. Defaults to 30_000.
|
|
73
|
-
*
|
|
72
|
+
* `execute_code`. Defaults to 30_000. An unscoped search caps each probe at
|
|
73
|
+
* 5_000 and its whole fan-out at 9_000, and skips a connector whose
|
|
74
|
+
* discovery recently timed out for up to 60 seconds. A timed-out connector
|
|
75
|
+
* degrades independently; this does not apply to tool calls. Catalog walks receive the
|
|
74
76
|
* same cancellation signal, which aborts an in-flight page where supported and
|
|
75
77
|
* prevents another from starting.
|
|
76
78
|
*/
|
|
@@ -237,8 +239,9 @@ const pool = {
|
|
|
237
239
|
/**
|
|
238
240
|
* Who may open `/mcp/<name>`: principal ids in the namespace of the one
|
|
239
241
|
* interactive auth provider, `{ namespace, id }`, or `"agent:<name>"` for a
|
|
240
|
-
* declared agent.
|
|
241
|
-
* `tools` to these members and
|
|
242
|
+
* declared agent. All members must be people or all must be agents. Required
|
|
243
|
+
* and non-empty; the pool serves exactly its `tools` to these members and
|
|
244
|
+
* the same 404 to everyone else.
|
|
242
245
|
*/
|
|
243
246
|
members: required(opaque({ check: assertMemberList }), (path) => `${path} is required: list who may open this pool, as principal ids, { namespace, id }, or "agent:<name>".`),
|
|
244
247
|
/**
|
|
@@ -246,8 +249,8 @@ const pool = {
|
|
|
246
249
|
* connector whose `subject` is a mapping function. Humans always act as
|
|
247
250
|
* themselves through that mapping, and a fixed `subject` address applies to
|
|
248
251
|
* everyone. Without an owner, a mapping-subject Workspace connector is
|
|
249
|
-
* absent for this pool's agents, as a personal connector is
|
|
250
|
-
* pool
|
|
252
|
+
* absent for this pool's agents, as a personal connector is. People use a
|
|
253
|
+
* separate pool and always act as themselves.
|
|
251
254
|
*/
|
|
252
255
|
owner: opaque({
|
|
253
256
|
check: (value, path) => {
|
|
@@ -9,7 +9,7 @@ export interface ConnectorCaller {
|
|
|
9
9
|
* with or without a subject, a Connecta-issued access token.
|
|
10
10
|
*/
|
|
11
11
|
authenticated: boolean;
|
|
12
|
-
/** The pool
|
|
12
|
+
/** The admitted pool: routed at `/mcp` or selected at `/mcp/<pool>` or `/agent/<pool>`. */
|
|
13
13
|
pool?: string;
|
|
14
14
|
/**
|
|
15
15
|
* That pool's configured `owner`, the Workspace address an agent acts as
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Where one connector's bounded health facts are stored for a registry view:
|
|
2
|
+
// the catalog cache's store and owner partition (principal, subject, caller
|
|
3
|
+
// identity and pool, origin) and the admitted endpoint, with the connector's
|
|
4
|
+
// described configuration and the package version, through the deployment's
|
|
5
|
+
// keyed digest (a described endpoint keeps its path), beside the catalog
|
|
6
|
+
// invalidation generation those facts must match (INV-4, INV-5). Shared by
|
|
7
|
+
// discovery health and the operator page's status observations.
|
|
8
|
+
import { Effect } from "effect";
|
|
9
|
+
import { catalogOwnerPartition } from "./catalog-policy.js";
|
|
10
|
+
import { canonicalJson } from "./canonical-json.js";
|
|
11
|
+
import { secretDigest } from "./storage-digest.js";
|
|
12
|
+
import { responseCacheKeys } from "./storage/keys.js";
|
|
13
|
+
import { CONNECTA_VERSION } from "./version.js";
|
|
14
|
+
// A connector's described configuration, serialized once per connector object.
|
|
15
|
+
// A change `describe()` cannot see, such as a static header rotated in
|
|
16
|
+
// deployment code, takes effect on redeploy and leaves an earlier record to
|
|
17
|
+
// lapse within its TTL.
|
|
18
|
+
const configs = new WeakMap();
|
|
19
|
+
function describedConfig(connector) {
|
|
20
|
+
let config = configs.get(connector);
|
|
21
|
+
if (config === undefined) {
|
|
22
|
+
let described = null;
|
|
23
|
+
try {
|
|
24
|
+
described = connector.describe?.() ?? null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
described = null;
|
|
28
|
+
}
|
|
29
|
+
config = canonicalJson([CONNECTA_VERSION, connector.kind ?? null, connector.authScope ?? null, described]);
|
|
30
|
+
configs.set(connector, config);
|
|
31
|
+
}
|
|
32
|
+
return config;
|
|
33
|
+
}
|
|
34
|
+
// The endpoint a request was admitted at (plain `/mcp`, a pool, an agent
|
|
35
|
+
// path). The same person and pool can reach one connector through several,
|
|
36
|
+
// and each keeps its own records. The operator page binds none.
|
|
37
|
+
const endpoints = new WeakMap();
|
|
38
|
+
/** Bind the admitted endpoint to a request scope, before any discovery runs in it. */
|
|
39
|
+
export function bindDiscoveryEndpoint(requestScope, endpoint) {
|
|
40
|
+
endpoints.set(requestScope, endpoint);
|
|
41
|
+
}
|
|
42
|
+
/** Where one connector's records sit in this view's owner partition. */
|
|
43
|
+
export function healthAddress(registry, id, baseUrl, requestScope) {
|
|
44
|
+
return Effect.suspend(() => {
|
|
45
|
+
const connector = registry.getConnector(id);
|
|
46
|
+
if (!connector)
|
|
47
|
+
return Effect.succeed(undefined);
|
|
48
|
+
const ctx = registry.contextFor(id, baseUrl, requestScope);
|
|
49
|
+
const { storage, partition } = catalogOwnerPartition(ctx);
|
|
50
|
+
if (!storage)
|
|
51
|
+
return Effect.succeed(undefined);
|
|
52
|
+
return Effect.promise(async () => ({
|
|
53
|
+
storage,
|
|
54
|
+
digest: await secretDigest(ctx, JSON.stringify([partition, endpoints.get(requestScope) ?? null, describedConfig(connector)])),
|
|
55
|
+
generationKey: responseCacheKeys.generation(id),
|
|
56
|
+
logger: ctx.logger,
|
|
57
|
+
}));
|
|
58
|
+
}).pipe(Effect.catchCause(() => Effect.succeed(undefined)));
|
|
59
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The operator page's stored connector status (#1173 item 2.3).
|
|
2
|
+
//
|
|
3
|
+
// A live detail read records what it saw, one observation per connector and
|
|
4
|
+
// viewer partition, beside discovery health in the connector-health family and
|
|
5
|
+
// under the same owner partition and catalog generation (INV-4, INV-5). The
|
|
6
|
+
// detail route can then answer at once from storage, labelled with its age,
|
|
7
|
+
// and the browser asks for a live read separately. An observation holds only
|
|
8
|
+
// a state, a problem code, a tool count and its time (INV-6); the tools stay
|
|
9
|
+
// in the complete catalog cache (INV-8). It is never deferred work: a live
|
|
10
|
+
// read writes it before its response, and nothing refreshes it afterwards.
|
|
11
|
+
import { Effect } from "effect";
|
|
12
|
+
import { healthAddress } from "./connector-health.js";
|
|
13
|
+
import { failureRecord, logFailure } from "./operator-record.js";
|
|
14
|
+
import { causeError } from "./runtime/run.js";
|
|
15
|
+
import { Storage } from "./runtime/services.js";
|
|
16
|
+
import { requestReads } from "./runtime/request-scope.js";
|
|
17
|
+
import { storageGet, storageGetMany, storageSet } from "./runtime/storage.js";
|
|
18
|
+
import { connectorHealthKeys } from "./storage/keys.js";
|
|
19
|
+
/** How long the operator page shows a stored status before only a live read will do. */
|
|
20
|
+
const STATUS_OBSERVATION_TTL_MS = 120_000;
|
|
21
|
+
const STATUS_STATES = new Set([
|
|
22
|
+
"ok",
|
|
23
|
+
"per_caller",
|
|
24
|
+
"auth_required",
|
|
25
|
+
"credential_required",
|
|
26
|
+
"error",
|
|
27
|
+
]);
|
|
28
|
+
const STATUS_PROBLEMS = new Set([
|
|
29
|
+
"connector_unavailable",
|
|
30
|
+
"connector_timeout",
|
|
31
|
+
"oauth_required",
|
|
32
|
+
"credential_required",
|
|
33
|
+
"auth_required",
|
|
34
|
+
"caller_refused",
|
|
35
|
+
"credential_mismatch",
|
|
36
|
+
"catalog_failed",
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* This viewer's current observation of one connector, and the slot a live
|
|
40
|
+
* read writes its own to when there is none. A record from another generation, past its TTL or
|
|
41
|
+
* of an unknown shape is absent. A storage failure is absent too.
|
|
42
|
+
*/
|
|
43
|
+
export function readStatusObservation(registry, id, baseUrl, requestScope) {
|
|
44
|
+
return Effect.gen(function* () {
|
|
45
|
+
const address = yield* healthAddress(registry, id, baseUrl, requestScope);
|
|
46
|
+
if (!address)
|
|
47
|
+
return {};
|
|
48
|
+
const key = connectorHealthKeys.status(id, address.digest);
|
|
49
|
+
const values = yield* storageGetMany([key, address.generationKey]).pipe(Effect.provideService(Storage, address.storage), Effect.catchCause((cause) => Effect.sync(() => {
|
|
50
|
+
logFailure(address.logger, "discovery health read failed", failureRecord({}, causeError(cause)));
|
|
51
|
+
return undefined;
|
|
52
|
+
})));
|
|
53
|
+
if (!values)
|
|
54
|
+
return {};
|
|
55
|
+
const slot = { storage: address.storage, key, logger: address.logger, generation: values[1] ?? null };
|
|
56
|
+
try {
|
|
57
|
+
const value = JSON.parse(values[0] ?? "null");
|
|
58
|
+
if (value?.v === 1 &&
|
|
59
|
+
value.generation === slot.generation &&
|
|
60
|
+
typeof value.state === "string" &&
|
|
61
|
+
STATUS_STATES.has(value.state) &&
|
|
62
|
+
(value.problem === undefined || STATUS_PROBLEMS.has(value.problem)) &&
|
|
63
|
+
Number.isSafeInteger(value.toolCount) &&
|
|
64
|
+
value.toolCount >= 0 &&
|
|
65
|
+
Number.isFinite(value.observedAt) &&
|
|
66
|
+
Date.now() - value.observedAt < STATUS_OBSERVATION_TTL_MS)
|
|
67
|
+
return {
|
|
68
|
+
slot,
|
|
69
|
+
observation: {
|
|
70
|
+
state: value.state,
|
|
71
|
+
...(value.problem ? { problem: value.problem } : {}),
|
|
72
|
+
toolCount: value.toolCount,
|
|
73
|
+
observedAt: value.observedAt,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* An unreadable record is absent. */
|
|
79
|
+
}
|
|
80
|
+
return { slot };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Where a live read will record its observation, without reading the stored
|
|
85
|
+
* one. The generation is read through the request's read memo: a live read's
|
|
86
|
+
* catalog listing locates its cache entry by that same key, so the listing
|
|
87
|
+
* reuses this read instead of paying another round trip. A generation is never
|
|
88
|
+
* reused once replaced, so a memoized value can only lapse an observation
|
|
89
|
+
* early, never keep one past an invalidation that preceded this request.
|
|
90
|
+
*/
|
|
91
|
+
export function prepareStatusSlot(registry, id, baseUrl, requestScope) {
|
|
92
|
+
return Effect.gen(function* () {
|
|
93
|
+
const address = yield* healthAddress(registry, id, baseUrl, requestScope);
|
|
94
|
+
if (!address)
|
|
95
|
+
return undefined;
|
|
96
|
+
const generation = yield* requestReads(requestScope)
|
|
97
|
+
.read(address.storage, address.generationKey, storageGet(address.generationKey))
|
|
98
|
+
.pipe(Effect.provideService(Storage, address.storage), Effect.catchCause((cause) => Effect.sync(() => {
|
|
99
|
+
logFailure(address.logger, "discovery health read failed", failureRecord({}, causeError(cause)));
|
|
100
|
+
return undefined;
|
|
101
|
+
})));
|
|
102
|
+
if (generation === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
return {
|
|
105
|
+
storage: address.storage,
|
|
106
|
+
key: connectorHealthKeys.status(id, address.digest),
|
|
107
|
+
logger: address.logger,
|
|
108
|
+
generation,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Record what a live read saw. A failed write is logged; the page already has its answer. */
|
|
113
|
+
export function storeStatusObservation(slot, observation) {
|
|
114
|
+
return storageSet(slot.key, JSON.stringify({ v: 1, ...observation, generation: slot.generation }), {
|
|
115
|
+
expiresAtMs: Math.ceil(observation.observedAt + STATUS_OBSERVATION_TTL_MS),
|
|
116
|
+
}).pipe(Effect.provideService(Storage, slot.storage), Effect.catchCause((cause) => Effect.sync(() => logFailure(slot.logger, "discovery health write failed", failureRecord({}, causeError(cause))))));
|
|
117
|
+
}
|
|
@@ -204,8 +204,8 @@ export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, u
|
|
|
204
204
|
credential: import("../config-schema.js").Field<unknown, unknown, true> & {
|
|
205
205
|
readonly shape: {
|
|
206
206
|
fields: import("../config-schema.js").Field<unknown[], readonly unknown[] | undefined, true>;
|
|
207
|
-
label: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
208
207
|
description: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
208
|
+
label: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
209
209
|
placeholder: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
210
210
|
};
|
|
211
211
|
};
|
|
@@ -228,9 +228,9 @@ export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, u
|
|
|
228
228
|
};
|
|
229
229
|
tools: import("../config-schema.js").Field<unknown[], readonly unknown[] | undefined, true>;
|
|
230
230
|
title: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
231
|
+
description: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
231
232
|
authScope: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
232
233
|
maxResultBytes: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
233
|
-
description: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
234
234
|
testCredential: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
235
235
|
testCredentials: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
236
236
|
validateArgs: import("../config-schema.js").Field<unknown, unknown, true>;
|
|
@@ -244,6 +244,11 @@ export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, u
|
|
|
244
244
|
*/
|
|
245
245
|
export interface ApiOAuthHooks {
|
|
246
246
|
status(ctx: ConnectorContext): Promise<ConnectorStatus>;
|
|
247
|
+
/**
|
|
248
|
+
* `status` for a call about to enter a handler, which also renews a token
|
|
249
|
+
* within a minute of its stored expiry. A failed renewal rejects.
|
|
250
|
+
*/
|
|
251
|
+
prepareCall(ctx: ConnectorContext): Promise<ConnectorStatus>;
|
|
247
252
|
startAuth(ctx: ConnectorContext, opts?: {
|
|
248
253
|
force?: boolean;
|
|
249
254
|
}): Promise<ConnectorStatus>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { declaresRouteDescriptions, routePhrases } from "../agent-routes.js";
|
|
1
2
|
import { wrapViewCredentials } from "../runtime/call.js";
|
|
2
3
|
import { storedAuthObservation, withStoredAuth } from "../auth/stored-observation.js";
|
|
3
4
|
import { Effect, Exit } from "effect";
|
|
@@ -296,13 +297,15 @@ export function apiConnector(id, opts, oauth) {
|
|
|
296
297
|
// This boundary's identity in a logical call, fixed now: this object with
|
|
297
298
|
// this function, never whatever the property or a copy holds by then.
|
|
298
299
|
providerBoundary(connector);
|
|
300
|
+
// Its catalog is authored here, so declared route descriptions follow each request's surface.
|
|
301
|
+
declaresRouteDescriptions(connector);
|
|
299
302
|
registerCallEntry(connector, core);
|
|
300
303
|
if (oauth)
|
|
301
304
|
registerInvocationAuth(connector, async (ctx) => {
|
|
302
|
-
// The
|
|
303
|
-
//
|
|
304
|
-
if ((await settleOnClose(ctx, () => oauth.
|
|
305
|
-
throw new ConnectorCallError("downstream_oauth_required", `Connector "${id}" requires authorization.
|
|
305
|
+
// The stored grant, as status reads it. Only a token about to expire is
|
|
306
|
+
// renewed, before any handler runs; nothing is sent to the API.
|
|
307
|
+
if ((await settleOnClose(ctx, () => oauth.prepareCall(ctx))).state === "auth_required") {
|
|
308
|
+
throw new ConnectorCallError("downstream_oauth_required", `Connector "${id}" requires authorization. ${routePhrases(ctx).authorizeCall()}`);
|
|
306
309
|
}
|
|
307
310
|
});
|
|
308
311
|
registerConnectorVocabulary(connector, opts.tools);
|
|
@@ -4,7 +4,8 @@ import { isSpecType } from "@modelcontextprotocol/client";
|
|
|
4
4
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
5
5
|
import { sentSecretsForRequest } from "../sent-secrets.js";
|
|
6
6
|
import { assertDownstreamOutputSafe } from "../downstream-input-context.js";
|
|
7
|
-
import { negotiationKeys, NEGOTIATION_TTL_SECONDS } from "../storage/keys.js";
|
|
7
|
+
import { LEGACY_NEGOTIATION_TTL_SECONDS, negotiationKeys, NEGOTIATION_TTL_SECONDS } from "../storage/keys.js";
|
|
8
|
+
import { viewReads } from "../runtime/call.js";
|
|
8
9
|
/** Verdicts carry data only. Clients, signals and secrets remain request-local. */
|
|
9
10
|
export function readNegotiation(ctx, digest) {
|
|
10
11
|
return runEdge(authProgram(readNegotiationNative, [ctx, digest]));
|
|
@@ -12,7 +13,11 @@ export function readNegotiation(ctx, digest) {
|
|
|
12
13
|
const readNegotiationNative = /* @__PURE__ */ registerAuth(readNegotiation, (ctx, digest) => authFlow(function* () {
|
|
13
14
|
let prior;
|
|
14
15
|
try {
|
|
15
|
-
|
|
16
|
+
// Memoized per request: a verdict only orders the versions a handshake
|
|
17
|
+
// tries, so one read earlier in this request at worst costs a fallback.
|
|
18
|
+
// This request's own storeNegotiation is a write, which clears the memo.
|
|
19
|
+
const key = negotiationKeys.verdict(digest);
|
|
20
|
+
const raw = (yield viewReads(ctx).read(ctx.storage, key, authStorage(ctx.storage, "get", [key])));
|
|
16
21
|
if (!raw)
|
|
17
22
|
return;
|
|
18
23
|
const value = JSON.parse(raw);
|
|
@@ -40,17 +45,30 @@ const storeNegotiationNative = /* @__PURE__ */ registerAuth(storeNegotiation, (c
|
|
|
40
45
|
const clean = sentSecretsForRequest(ctx.requestScope ?? ctx).redactCredentials(prior);
|
|
41
46
|
if (clean.kind === "modern" && !isSpecType.DiscoverResult(clean.discover))
|
|
42
47
|
return;
|
|
48
|
+
const ttlSeconds = clean.kind === "legacy" ? LEGACY_NEGOTIATION_TTL_SECONDS : NEGOTIATION_TTL_SECONDS;
|
|
43
49
|
try {
|
|
44
50
|
(yield authStorage(ctx.storage, "set", [
|
|
45
51
|
negotiationKeys.verdict(digest),
|
|
46
52
|
JSON.stringify({
|
|
47
53
|
prior: clean,
|
|
48
|
-
expiresAt: Date.now() +
|
|
54
|
+
expiresAt: Date.now() + ttlSeconds * 1000,
|
|
49
55
|
}),
|
|
50
|
-
{ ttlSeconds
|
|
56
|
+
{ ttlSeconds },
|
|
51
57
|
]));
|
|
52
58
|
}
|
|
53
59
|
catch (error) {
|
|
54
60
|
logFailure(ctx.logger, "negotiation cache write failed", failureRecord({}, error));
|
|
55
61
|
}
|
|
56
62
|
}));
|
|
63
|
+
/** Drop a verdict the downstream has contradicted, so the next connect negotiates. */
|
|
64
|
+
export function forgetNegotiation(ctx, digest) {
|
|
65
|
+
return runEdge(authProgram(forgetNegotiationNative, [ctx, digest]));
|
|
66
|
+
}
|
|
67
|
+
const forgetNegotiationNative = /* @__PURE__ */ registerAuth(forgetNegotiation, (ctx, digest) => authFlow(function* () {
|
|
68
|
+
try {
|
|
69
|
+
(yield authStorage(ctx.storage, "delete", [negotiationKeys.verdict(digest)]));
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
logFailure(ctx.logger, "negotiation cache write failed", failureRecord({}, error));
|
|
73
|
+
}
|
|
74
|
+
}));
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// tools/call: classification, write-once dispatch and the write's outcome
|
|
2
|
+
// when it fails.
|
|
3
|
+
import { authView } from "../../auth/native-call.js";
|
|
4
|
+
import { authFlow, authCall, authInvoke, authMethod, authCatch } from "../../auth/native.js";
|
|
5
|
+
import { ProtocolError, isInputRequiredResult, fromJsonSchema, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
6
|
+
import { Effect } from "effect";
|
|
7
|
+
import { requestSentSecrets } from "../../sent-secrets.js";
|
|
8
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
9
|
+
import { carryFailureFacts, failureRecord } from "../../operator-record.js";
|
|
10
|
+
import { assertDownstreamOutputSafe } from "../../downstream-input-context.js";
|
|
11
|
+
import { callClassification } from "../../call-classification.js";
|
|
12
|
+
import { sourceClassification } from "../../tool-safety.js";
|
|
13
|
+
import { enterProviderBoundary, knownProviderFailure, providerRequest, vendorRefusal, } from "../../provider-call.js";
|
|
14
|
+
import { providerCallFailure, unsentAfterUnauthorized } from "../../write-outcome.js";
|
|
15
|
+
import { newDispatch, withDispatch } from "./transport.js";
|
|
16
|
+
import { downstreamCallError, hasSdkPayload, ownAbortReason, requiresAuthorization } from "./errors.js";
|
|
17
|
+
/** One connector's callTool operation, before the entry's wrappers. */
|
|
18
|
+
export function remoteMcpCall(rt) {
|
|
19
|
+
const { isOauth, id } = rt;
|
|
20
|
+
const callToolOperation = authMethod((name, args, ctx, options) => authFlow(function* () {
|
|
21
|
+
enterProviderBoundary(ctx, rt.connector);
|
|
22
|
+
let active;
|
|
23
|
+
try {
|
|
24
|
+
return (yield authView(ctx, authMethod(() => authFlow(function* () {
|
|
25
|
+
const state = rt.stateFor(ctx);
|
|
26
|
+
const client = (yield authCatch(authCall(rt.ensureConnected, [ctx, state]), (error) => {
|
|
27
|
+
throw knownProviderFailure(error);
|
|
28
|
+
}));
|
|
29
|
+
if (isOauth)
|
|
30
|
+
(yield authCatch(authCall(rt.renewBeforeDispatch, [ctx, state, client]), (error) => {
|
|
31
|
+
throw knownProviderFailure(error);
|
|
32
|
+
}));
|
|
33
|
+
const definition = options?.definition;
|
|
34
|
+
const facts = definition ?? state.toolDefinitions.get(name) ?? { name };
|
|
35
|
+
const sourceFacts = definition ?? {
|
|
36
|
+
name,
|
|
37
|
+
...(facts.annotations
|
|
38
|
+
? { annotations: facts.annotations }
|
|
39
|
+
: {}),
|
|
40
|
+
};
|
|
41
|
+
const source = sourceClassification(sourceFacts);
|
|
42
|
+
const serverWrite = source === "write";
|
|
43
|
+
const write = serverWrite || (callClassification(ctx) ?? definition?.classification ?? source) === "write";
|
|
44
|
+
const dispatch = newDispatch(write, serverWrite);
|
|
45
|
+
dispatch.request = {
|
|
46
|
+
entry: providerRequest(ctx, !write, `Connector "${id}"'s MCP server`, { method: "POST" }),
|
|
47
|
+
ctx,
|
|
48
|
+
};
|
|
49
|
+
active = { state, client, dispatch, write };
|
|
50
|
+
state.dispatches.set(dispatch, name);
|
|
51
|
+
const toolDefinition = definition
|
|
52
|
+
? {
|
|
53
|
+
...definition,
|
|
54
|
+
inputSchema: (definition.inputSchema ?? { type: "object" }),
|
|
55
|
+
}
|
|
56
|
+
: state.toolDefinitions.get(name);
|
|
57
|
+
if (toolDefinition?.execution?.taskSupport === "required") {
|
|
58
|
+
throw new ConnectorCallError("connector_call_failed", `Tool "${name}" requires task-based execution, which Connecta does not support.`);
|
|
59
|
+
}
|
|
60
|
+
let output;
|
|
61
|
+
const outputError = (code, message) => rt.atMcpBoundary(ctx, new ProtocolError(code, message), "tools/call", client.transport, [ctx.signal]);
|
|
62
|
+
if (toolDefinition?.outputSchema) {
|
|
63
|
+
try {
|
|
64
|
+
output = fromJsonSchema(toolDefinition.outputSchema);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw outputError(-32602, `Tool '${name}' has an invalid outputSchema: schema could not be compiled`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const result = (yield authCatch(authInvoke(client, "callTool", [
|
|
71
|
+
withDispatch({
|
|
72
|
+
name,
|
|
73
|
+
arguments: (args ?? {}),
|
|
74
|
+
...options?.input,
|
|
75
|
+
}, dispatch),
|
|
76
|
+
{
|
|
77
|
+
...rt.requestOptions(ctx),
|
|
78
|
+
allowInputRequired: true,
|
|
79
|
+
...(toolDefinition
|
|
80
|
+
? { toolDefinition: { ...toolDefinition, outputSchema: undefined } }
|
|
81
|
+
: write
|
|
82
|
+
? { toolDefinition: { name, inputSchema: { type: "object" } } }
|
|
83
|
+
: {}),
|
|
84
|
+
},
|
|
85
|
+
]), (err) => {
|
|
86
|
+
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, err);
|
|
87
|
+
throw rt.atMcpBoundary(ctx, err, "tools/call", client.transport, [ctx.signal]);
|
|
88
|
+
}));
|
|
89
|
+
if (isInputRequiredResult(result)) {
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, result);
|
|
93
|
+
if (output && !result.isError) {
|
|
94
|
+
if (result.structuredContent === undefined)
|
|
95
|
+
throw outputError(-32600, `Tool ${name} has an output schema but did not return structured content`);
|
|
96
|
+
const validation = (yield authInvoke(output["~standard"], "validate", [
|
|
97
|
+
result.structuredContent,
|
|
98
|
+
]));
|
|
99
|
+
if (validation.issues)
|
|
100
|
+
throw outputError(-32602, "Structured content does not match the tool's output schema: validation failed");
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}))));
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
if (!active)
|
|
107
|
+
throw knownProviderFailure(err);
|
|
108
|
+
const { state, client, dispatch, write } = active;
|
|
109
|
+
const direct = write && callClassification(ctx) === undefined;
|
|
110
|
+
const ownAbort = ownAbortReason(err, [ctx.signal]) && !hasSdkPayload(err);
|
|
111
|
+
// The SDK can reject its wait synchronously from the abort event,
|
|
112
|
+
// before the physical response's Promise observer records its facts.
|
|
113
|
+
// Yield only settlement; no closed caller regains live authority.
|
|
114
|
+
if (ownAbort)
|
|
115
|
+
yield Effect.yieldNow;
|
|
116
|
+
if (ownAbort && !direct)
|
|
117
|
+
throw err;
|
|
118
|
+
if (requiresAuthorization(err) && state.client === client)
|
|
119
|
+
state.authRequired = true;
|
|
120
|
+
const failure = ownAbort
|
|
121
|
+
? err
|
|
122
|
+
: err instanceof UnauthorizedError
|
|
123
|
+
? carryFailureFacts(err, rt.authRequiredError(ctx))
|
|
124
|
+
: downstreamCallError(err, undefined, undefined, undefined, requestSentSecrets(ctx));
|
|
125
|
+
if (!write)
|
|
126
|
+
throw failure;
|
|
127
|
+
const renewal = dispatch.request?.entry.renewed
|
|
128
|
+
? undefined
|
|
129
|
+
: dispatch.resendRefused
|
|
130
|
+
? rt.authRequiredError(ctx)
|
|
131
|
+
: failure;
|
|
132
|
+
const unsent = (entry) => {
|
|
133
|
+
const verdict = unsentAfterUnauthorized(`Connector "${id}"'s MCP server`, renewal, isOauth ? "downstream_oauth_required" : "auth_required");
|
|
134
|
+
return entry ? vendorRefusal(verdict, entry) : verdict;
|
|
135
|
+
};
|
|
136
|
+
const verdict = dispatch.unauthorized
|
|
137
|
+
? unsent(dispatch.request?.entry)
|
|
138
|
+
: dispatch.redirected || dispatch.resendRefused
|
|
139
|
+
? new ConnectorCallError("connector_call_failed", `Connector "${id}"'s MCP server ` +
|
|
140
|
+
(dispatch.redirected
|
|
141
|
+
? "answered this write with a redirect, and the write was not sent again, "
|
|
142
|
+
: "was sent this write, and a second send of it was stopped, ") +
|
|
143
|
+
"so whether it took effect is unknown: check the target before repeating it.", { retryable: false, writeOutcome: "unknown" })
|
|
144
|
+
: dispatch.ambiguous && failure instanceof ConnectorCallError
|
|
145
|
+
? new ConnectorCallError(failure.code, `${failure.message} Connector "${id}"'s MCP server answered this write with an HTTP error ` +
|
|
146
|
+
"and no MCP error, so whether it took effect is unknown: check the target before repeating it.", { retryable: false, writeOutcome: "unknown" })
|
|
147
|
+
: failure;
|
|
148
|
+
const vendor = { title: `connector "${id}"` };
|
|
149
|
+
const status = failureRecord({}, err).httpStatus;
|
|
150
|
+
const settled = providerCallFailure(ctx, vendor, verdict, {
|
|
151
|
+
dispatched: dispatch.sent,
|
|
152
|
+
...(status === undefined ? {} : { status }),
|
|
153
|
+
fallback: "write",
|
|
154
|
+
request: dispatch.request?.entry,
|
|
155
|
+
});
|
|
156
|
+
throw settled === failure ? failure : carryFailureFacts(failure, settled);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
if (active)
|
|
160
|
+
active.state.dispatches.delete(active.dispatch);
|
|
161
|
+
}
|
|
162
|
+
}));
|
|
163
|
+
return { callToolOperation };
|
|
164
|
+
}
|