@zackbart/connecta 0.6.1 → 0.7.1
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 +294 -0
- package/README.md +24 -20
- package/dist/auth/bearer.d.ts +4 -3
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +10 -8
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +8 -7
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +27 -8
- package/dist/auth/clerk.js.map +1 -1
- package/dist/connector-scope.d.ts +13 -0
- package/dist/connector-scope.d.ts.map +1 -0
- package/dist/connector-scope.js +35 -0
- package/dist/connector-scope.js.map +1 -0
- package/dist/connectors/api.d.ts +5 -5
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +27 -4
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +400 -19
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +8 -5
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +99 -51
- package/dist/credential-health.js.map +1 -1
- package/dist/credentials.d.ts +51 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +68 -3
- package/dist/credentials.js.map +1 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +10 -8
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +84 -83
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +74 -24
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +28 -3
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +131 -16
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +3 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -3
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +152 -52
- package/dist/server.js.map +1 -1
- package/dist/toolkits.js +1 -1
- package/dist/types.d.ts +41 -27
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +24 -10
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +594 -172
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/src/auth/bearer.ts +10 -8
- package/src/auth/clerk.ts +28 -9
- package/src/connector-scope.ts +41 -0
- package/src/connectors/api.ts +5 -5
- package/src/connectors/remote-mcp.ts +489 -35
- package/src/credential-health.ts +120 -57
- package/src/credentials.ts +96 -3
- package/src/execute.ts +18 -7
- package/src/index.ts +174 -107
- package/src/meta-tools.ts +173 -24
- package/src/registry.ts +4 -3
- package/src/server.ts +191 -70
- package/src/toolkits.ts +1 -1
- package/src/types.ts +41 -27
- package/src/ui.ts +631 -170
- package/src/version.ts +1 -1
package/src/index.ts
CHANGED
|
@@ -28,6 +28,84 @@ import type {
|
|
|
28
28
|
Logger,
|
|
29
29
|
} from "./types.js";
|
|
30
30
|
|
|
31
|
+
/** Payload-free activity storage and operator-read policy. */
|
|
32
|
+
export interface ConnectaActivityConfig {
|
|
33
|
+
/**
|
|
34
|
+
* Privacy-minimal downstream tool activity storage. Writes are best-effort
|
|
35
|
+
* and never change tool results. Implement `list` to enable the Activity UI.
|
|
36
|
+
*/
|
|
37
|
+
store: ActivityStore;
|
|
38
|
+
/**
|
|
39
|
+
* Optional authorization gate for the Activity read API. MCP authentication
|
|
40
|
+
* is still required first. Omit to admit every authenticated actor.
|
|
41
|
+
*/
|
|
42
|
+
readGate?: ActivityReadGate;
|
|
43
|
+
/** Stable deployment label included in activity events, e.g. "production". */
|
|
44
|
+
deploymentId?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Operator-vault encryption and proactive credential-health tuning. */
|
|
48
|
+
export interface ConnectaCredentialsConfig {
|
|
49
|
+
/**
|
|
50
|
+
* Base64-encoded 32-byte AES key for credentials managed on /credentials.
|
|
51
|
+
* Keep this in the runtime's secret store, never in KV or source control.
|
|
52
|
+
*/
|
|
53
|
+
encryptionKey?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Tuning for proactive credential liveness checks that let a connector's
|
|
56
|
+
* status flip to `auth_required` before an agent's call fails. Defaults: one
|
|
57
|
+
* check per connector per 15 minutes, four in flight, 30 seconds each,
|
|
58
|
+
* triggered opportunistically by inbound authenticated traffic.
|
|
59
|
+
*
|
|
60
|
+
* Optional even without an encryption key because downstream OAuth connectors
|
|
61
|
+
* manage their own grants. `Connecta.checkCredentials()` runs the same checks
|
|
62
|
+
* on demand for a Worker cron trigger or Node interval.
|
|
63
|
+
*/
|
|
64
|
+
health?: CredentialHealthConfig;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
|
|
68
|
+
export interface ConnectaDiscoveryConfig {
|
|
69
|
+
/** Tool-list cache TTL (seconds). Default 300. */
|
|
70
|
+
catalogTtlSeconds?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Persist serializable remote tool catalogs in storage so cold isolates can
|
|
73
|
+
* discover tools without a downstream handshake. Default true.
|
|
74
|
+
*/
|
|
75
|
+
persistCatalog?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* How long an expired persisted catalog remains available as a fallback
|
|
78
|
+
* when a live refresh fails. Default 3600 seconds.
|
|
79
|
+
*/
|
|
80
|
+
staleCatalogSeconds?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Deadline (ms) for each downstream probe/catalog call fanned out by
|
|
83
|
+
* `list_connectors`, `search_tools`, and `describe_tools`. Defaults to
|
|
84
|
+
* 30_000. A timed-out connector degrades independently; this does not apply
|
|
85
|
+
* to tool calls or currently abort the underlying fetch.
|
|
86
|
+
*/
|
|
87
|
+
probeTimeoutMs?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Deployment-wide call deadlines and inline-result paging threshold. */
|
|
91
|
+
export interface ConnectaCallsConfig {
|
|
92
|
+
/**
|
|
93
|
+
* Deadline (ms) for `call_tool`/`batch_call` calls that pass no `timeoutMs`.
|
|
94
|
+
* An explicit per-call value wins. Opt-in: unset by default, so existing
|
|
95
|
+
* long-running calls gain no surprise deadline.
|
|
96
|
+
*
|
|
97
|
+
* This bounds one attempt, not all retries. `execute_code` host calls are
|
|
98
|
+
* unaffected because they already carry their own bound.
|
|
99
|
+
*/
|
|
100
|
+
defaultTimeoutMs?: number;
|
|
101
|
+
/**
|
|
102
|
+
* Max inline result size (bytes) before truncation and `get_result` paging.
|
|
103
|
+
* Must be a finite whole number >= 1; invalid values warn and fall back to
|
|
104
|
+
* 50_000. Connectors may override it individually.
|
|
105
|
+
*/
|
|
106
|
+
maxResultBytes?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
31
109
|
export interface ConnectaConfig {
|
|
32
110
|
connectors: Connector[];
|
|
33
111
|
/**
|
|
@@ -62,18 +140,6 @@ export interface ConnectaConfig {
|
|
|
62
140
|
* address, or an address naming no tool on an in-code connector all throw.
|
|
63
141
|
*/
|
|
64
142
|
toolkits?: ToolkitConfig;
|
|
65
|
-
/**
|
|
66
|
-
* Privacy-minimal downstream tool activity storage. Writes are best-effort
|
|
67
|
-
* and never change tool results. Implement `list` to enable the Activity UI.
|
|
68
|
-
*/
|
|
69
|
-
activity?: ActivityStore;
|
|
70
|
-
/**
|
|
71
|
-
* Optional authorization gate for the Activity read API. MCP authentication
|
|
72
|
-
* is still required first. Omit to admit every authenticated actor.
|
|
73
|
-
*/
|
|
74
|
-
activityReadGate?: ActivityReadGate;
|
|
75
|
-
/** Stable deployment label included in activity events, e.g. "production". */
|
|
76
|
-
activityDeploymentId?: string;
|
|
77
143
|
/** Inbound auth adapters. Includes bearerToken(...); omit for open (dev). */
|
|
78
144
|
auth?: InboundAuth | InboundAuth[];
|
|
79
145
|
/** KVStorage impl. Defaults to memoryStorage(). */
|
|
@@ -83,78 +149,17 @@ export interface ConnectaConfig {
|
|
|
83
149
|
* HTTPS URL also redirects matching inbound HTTP requests to HTTPS.
|
|
84
150
|
*/
|
|
85
151
|
publicUrl?: string;
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
152
|
+
/** Payload-free tool activity storage and operator-read policy. */
|
|
153
|
+
activity?: ConnectaActivityConfig;
|
|
154
|
+
/** Operator credential vault and proactive liveness-check settings. */
|
|
155
|
+
credentials?: ConnectaCredentialsConfig;
|
|
156
|
+
/** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
|
|
157
|
+
discovery?: ConnectaDiscoveryConfig;
|
|
158
|
+
/** Deployment-wide call deadlines and result paging threshold. */
|
|
159
|
+
calls?: ConnectaCallsConfig;
|
|
91
160
|
/** Optional browser UI and OAuth result-page labels. */
|
|
92
161
|
branding?: ConnectaBranding;
|
|
93
162
|
logger?: Logger;
|
|
94
|
-
/** Tool-list cache TTL (seconds). Default 300. */
|
|
95
|
-
toolCacheTtlSeconds?: number;
|
|
96
|
-
/**
|
|
97
|
-
* Persist serializable remote tool catalogs in storage so cold isolates can
|
|
98
|
-
* discover tools without a downstream handshake. Default true.
|
|
99
|
-
*/
|
|
100
|
-
persistToolCatalog?: boolean;
|
|
101
|
-
/**
|
|
102
|
-
* How long an expired persisted catalog remains available as a fallback
|
|
103
|
-
* when a live refresh fails. Default 3600 seconds.
|
|
104
|
-
*/
|
|
105
|
-
toolCatalogStaleSeconds?: number;
|
|
106
|
-
/**
|
|
107
|
-
* Max inline result size (bytes) before call_tool/batch_call truncate and
|
|
108
|
-
* stash the full text for get_result paging. Must be a whole number of bytes
|
|
109
|
-
* >= 1; anything else (0, negative, fractional, NaN, Infinity) warns at
|
|
110
|
-
* startup and falls back to the default 50_000.
|
|
111
|
-
*/
|
|
112
|
-
maxResultBytes?: number;
|
|
113
|
-
/**
|
|
114
|
-
* Deadline (ms) applied to call_tool/batch_call calls that pass no
|
|
115
|
-
* `timeoutMs`, giving the connector both a budget (`ctx.timeoutMs`) and a
|
|
116
|
-
* cancellation signal (`ctx.signal`). An explicit per-call `timeoutMs` always
|
|
117
|
-
* wins. **Opt-in — undefined by default**, because switching it on globally
|
|
118
|
-
* would put a deadline on every call in an existing deployment and the
|
|
119
|
-
* failure mode is a working long-running call starting to time out.
|
|
120
|
-
* `execute_code` host calls are unaffected; they already carry a 15 s bound.
|
|
121
|
-
*
|
|
122
|
-
* Bounds a single attempt, not the whole call — the same as an explicit
|
|
123
|
-
* `timeoutMs` has always done. A call that also passes `maxRetries` can
|
|
124
|
-
* therefore run to roughly `(maxRetries + 1)` times this value plus backoff.
|
|
125
|
-
* `maxRetries` defaults to 0, so this is the total for every call that does
|
|
126
|
-
* not explicitly ask to retry.
|
|
127
|
-
*/
|
|
128
|
-
defaultToolTimeoutMs?: number;
|
|
129
|
-
/**
|
|
130
|
-
* Deadline (ms) applied to each individual downstream probe/catalog call that
|
|
131
|
-
* the discovery meta-tools fan out — `list_connectors` (with `probe`),
|
|
132
|
-
* `search_tools`, and `describe_tools` — so a single hung connector can no
|
|
133
|
-
* longer stall the whole meta-tool call. **Defaults to a generous 30_000**,
|
|
134
|
-
* chosen to trip only on a pathological hang, not on a realistically slow
|
|
135
|
-
* probe, so having it on by default will not break existing deployments.
|
|
136
|
-
* Bounds one downstream call, not the whole fan-out: a connector that outruns
|
|
137
|
-
* it degrades to an unavailable/errored entry while the rest are unaffected.
|
|
138
|
-
*
|
|
139
|
-
* Does NOT apply to `call_tool`/`batch_call` — those carry their own budget
|
|
140
|
-
* via `defaultToolTimeoutMs` or a per-call `timeoutMs`. Note this bounds the
|
|
141
|
-
* caller-facing wait only; the underlying fetch is not currently aborted, so
|
|
142
|
-
* real cancellation of the downstream request is a deferred follow-up.
|
|
143
|
-
*/
|
|
144
|
-
probeTimeoutMs?: number;
|
|
145
|
-
/**
|
|
146
|
-
* Tuning for the proactive credential liveness checks (issue #24) that let a
|
|
147
|
-
* connector's status flip to `auth_required` *before* an agent's call fails.
|
|
148
|
-
* Defaults are safe to leave alone: at most one check per connector per 15
|
|
149
|
-
* minutes, four in flight, 30 s each, triggered opportunistically by inbound
|
|
150
|
-
* authenticated traffic. Only connectors holding a credential connecta stores
|
|
151
|
-
* — an operator-managed `credential`, or a downstream-OAuth grant — are ever
|
|
152
|
-
* checked, and a check never calls a downstream tool.
|
|
153
|
-
*
|
|
154
|
-
* `Connecta.checkCredentials()` is the same check on demand, for a Worker cron
|
|
155
|
-
* trigger or a Node interval.
|
|
156
|
-
*/
|
|
157
|
-
credentialHealth?: CredentialHealthConfig;
|
|
158
163
|
serverInfo?: {
|
|
159
164
|
name?: string;
|
|
160
165
|
version?: string;
|
|
@@ -193,7 +198,7 @@ export interface Connecta {
|
|
|
193
198
|
*
|
|
194
199
|
* Returns one outcome per connector considered, including why a connector was
|
|
195
200
|
* skipped (`fresh` is the rate limit: a connector checked less than
|
|
196
|
-
* `
|
|
201
|
+
* `credentials.health.intervalSeconds` ago is not re-checked unless `force`).
|
|
197
202
|
* Never rejects on a connector failure — a broken connector becomes an `error`
|
|
198
203
|
* verdict. Needs a base URL for connector contexts: `publicUrl` supplies it,
|
|
199
204
|
* or pass one.
|
|
@@ -223,6 +228,59 @@ function normalizeAuth(auth: ConnectaConfig["auth"]): InboundAuth[] {
|
|
|
223
228
|
});
|
|
224
229
|
}
|
|
225
230
|
|
|
231
|
+
const LEGACY_CONFIG_MIGRATIONS = [
|
|
232
|
+
["activityReadGate", "activity.readGate"],
|
|
233
|
+
["activityDeploymentId", "activity.deploymentId"],
|
|
234
|
+
["credentialEncryptionKey", "credentials.encryptionKey"],
|
|
235
|
+
["credentialHealth", "credentials.health"],
|
|
236
|
+
["toolCacheTtlSeconds", "discovery.catalogTtlSeconds"],
|
|
237
|
+
["persistToolCatalog", "discovery.persistCatalog"],
|
|
238
|
+
["toolCatalogStaleSeconds", "discovery.staleCatalogSeconds"],
|
|
239
|
+
["probeTimeoutMs", "discovery.probeTimeoutMs"],
|
|
240
|
+
["defaultToolTimeoutMs", "calls.defaultTimeoutMs"],
|
|
241
|
+
["maxResultBytes", "calls.maxResultBytes"],
|
|
242
|
+
] as const;
|
|
243
|
+
|
|
244
|
+
const hasOwn = (value: object, key: PropertyKey): boolean =>
|
|
245
|
+
Object.prototype.hasOwnProperty.call(value, key);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Fail closed at the public boundary: a JavaScript caller on the v0.6 shape
|
|
249
|
+
* must receive one complete migration error, never silently lose an option to
|
|
250
|
+
* a default. This runs before createConnecta reads any other config field.
|
|
251
|
+
*/
|
|
252
|
+
function assertNoLegacyConfig(config: ConnectaConfig): void {
|
|
253
|
+
const candidate = config as unknown as Record<PropertyKey, unknown>;
|
|
254
|
+
const found: Array<readonly [string, string]> = [];
|
|
255
|
+
if (hasOwn(candidate, "activity")) {
|
|
256
|
+
const activity = candidate.activity;
|
|
257
|
+
const isObject =
|
|
258
|
+
typeof activity === "object" && activity !== null;
|
|
259
|
+
// A valid v0.6 ActivityStore can itself own a backend field named `store`.
|
|
260
|
+
// Its required `record` method (including a prototype method) therefore
|
|
261
|
+
// takes precedence over the otherwise-new wrapper shape.
|
|
262
|
+
const hasLegacyRecord =
|
|
263
|
+
isObject &&
|
|
264
|
+
typeof (activity as { record?: unknown }).record === "function";
|
|
265
|
+
if (
|
|
266
|
+
activity !== undefined &&
|
|
267
|
+
(!isObject ||
|
|
268
|
+
hasLegacyRecord ||
|
|
269
|
+
!hasOwn(activity, "store"))
|
|
270
|
+
) {
|
|
271
|
+
found.push(["activity", "activity.store"]);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
|
|
275
|
+
if (hasOwn(candidate, migration[0])) found.push(migration);
|
|
276
|
+
}
|
|
277
|
+
if (found.length === 0) return;
|
|
278
|
+
throw new Error(
|
|
279
|
+
"Unsupported v0.6.x ConnectaConfig options. Migrate each path for v0.7.0:\n" +
|
|
280
|
+
found.map(([oldPath, newPath]) => `- ${oldPath} -> ${newPath}`).join("\n"),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
226
284
|
/**
|
|
227
285
|
* One-time construction warnings for deployment shapes that run fine but are
|
|
228
286
|
* usually unintended. Warning-only — never throws and never changes behavior;
|
|
@@ -333,12 +391,12 @@ function warnInsecureConfig(
|
|
|
333
391
|
);
|
|
334
392
|
}
|
|
335
393
|
|
|
336
|
-
//
|
|
337
|
-
// offers one,
|
|
394
|
+
// Operator shells render exactly one provider's browser sign-in config — the
|
|
395
|
+
// first that offers one, matching the server route's `find` — and that
|
|
338
396
|
// provider's URLs reach the browser: frontendApiUrl as the loader's
|
|
339
397
|
// `<script src>`, signInUrl/signUpUrl as the addresses ClerkJS navigates to.
|
|
340
398
|
// Gate-or-drop like a branding href: rendering drops a rejected value and the
|
|
341
|
-
//
|
|
399
|
+
// operator page then reports that Clerk could not load or quietly signs in
|
|
342
400
|
// through Clerk's defaults — both confusing symptoms without this line naming
|
|
343
401
|
// the cause. Checking only the rendered provider keeps the claim true — a
|
|
344
402
|
// later provider's uiAuth never reaches the page, so there is nothing there
|
|
@@ -351,7 +409,7 @@ function warnInsecureConfig(
|
|
|
351
409
|
`${droppedUiAuth.join(", ")} dropped: every uiAuth URL reaches the ` +
|
|
352
410
|
"browser — as the sign-in loader's source, or as a place Clerk sends " +
|
|
353
411
|
"the operator — so each must be an absolute https URL. A dropped " +
|
|
354
|
-
"value reaches no part of the page: without frontendApiUrl
|
|
412
|
+
"value reaches no part of the page: without frontendApiUrl the operator shell renders " +
|
|
355
413
|
"no loader and cannot start a sign-in, and without signInUrl/signUpUrl " +
|
|
356
414
|
"it signs in through Clerk's defaults.",
|
|
357
415
|
);
|
|
@@ -359,7 +417,7 @@ function warnInsecureConfig(
|
|
|
359
417
|
|
|
360
418
|
// A credential test hook that cannot test the declared credential shape.
|
|
361
419
|
// The shape picks the hook (see `credentialTestRule`) and the other one is
|
|
362
|
-
// never substituted, so the connector is simply not testable: /
|
|
420
|
+
// never substituted, so the connector is simply not testable: /credentials offers no
|
|
363
421
|
// Test action and the route answers 400. Without this line the only way to
|
|
364
422
|
// discover the mistake is to click a button that isn't there.
|
|
365
423
|
for (const connector of config.connectors) {
|
|
@@ -367,20 +425,23 @@ function warnInsecureConfig(
|
|
|
367
425
|
if (!mismatch) continue;
|
|
368
426
|
logger.warn(
|
|
369
427
|
`[connecta] connector "${connector.id}" cannot test its credential: ` +
|
|
370
|
-
`${describeCredentialTestMismatch(mismatch)}. /
|
|
428
|
+
`${describeCredentialTestMismatch(mismatch)}. /credentials offers no Test ` +
|
|
371
429
|
`action and POST /ui/credentials/${connector.id}/test answers 400 ` +
|
|
372
430
|
"until the matching hook is implemented.",
|
|
373
431
|
);
|
|
374
432
|
}
|
|
375
433
|
|
|
376
|
-
// OAuth connectors whose callback
|
|
377
|
-
//
|
|
434
|
+
// OAuth connectors whose callback cannot perform a state/CSRF check. The
|
|
435
|
+
// public route refuses every callback for these connectors rather than hand
|
|
436
|
+
// an unverified code to finishAuth, so this warning explains why auth cannot
|
|
437
|
+
// complete instead of describing a vulnerability the server permits.
|
|
378
438
|
for (const connector of oauthConnectors) {
|
|
379
439
|
if (!connector.verifyState) {
|
|
380
440
|
logger.warn(
|
|
381
441
|
`[connecta] connector "${connector.id}" has an OAuth callback with no ` +
|
|
382
|
-
`state/CSRF check: /oauth/callback/${connector.id}
|
|
383
|
-
"
|
|
442
|
+
`state/CSRF check: /oauth/callback/${connector.id} refuses every ` +
|
|
443
|
+
"callback rather than exchange an unverified code. Implement " +
|
|
444
|
+
"`verifyState` to complete authorization (the shipped remoteMcp " +
|
|
384
445
|
"connector already does).",
|
|
385
446
|
);
|
|
386
447
|
}
|
|
@@ -388,26 +449,28 @@ function warnInsecureConfig(
|
|
|
388
449
|
}
|
|
389
450
|
|
|
390
451
|
export function createConnecta(config: ConnectaConfig): Connecta {
|
|
452
|
+
assertNoLegacyConfig(config);
|
|
391
453
|
const storage = config.storage ?? memoryStorage();
|
|
392
454
|
const logger = config.logger ?? defaultLogger();
|
|
393
455
|
const credentialConnectors = config.connectors.filter((c) => c.credential);
|
|
394
|
-
|
|
456
|
+
const encryptionKey = config.credentials?.encryptionKey;
|
|
457
|
+
if (credentialConnectors.length > 0 && !encryptionKey) {
|
|
395
458
|
throw new Error(
|
|
396
|
-
`
|
|
459
|
+
`credentials.encryptionKey is required by connector credentials: ${credentialConnectors.map((c) => c.id).join(", ")}`,
|
|
397
460
|
);
|
|
398
461
|
}
|
|
399
|
-
const credentialVault =
|
|
400
|
-
? new CredentialVault(storage,
|
|
462
|
+
const credentialVault = encryptionKey
|
|
463
|
+
? new CredentialVault(storage, encryptionKey)
|
|
401
464
|
: undefined;
|
|
402
465
|
const registry = new Registry(config.connectors, {
|
|
403
466
|
storage,
|
|
404
467
|
logger,
|
|
405
468
|
credentialVault,
|
|
406
|
-
toolCacheTtlSeconds: config.
|
|
407
|
-
persistToolCatalog: config.
|
|
408
|
-
toolCatalogStaleSeconds: config.
|
|
409
|
-
maxResultBytes: config.maxResultBytes,
|
|
410
|
-
credentialHealth: config.
|
|
469
|
+
toolCacheTtlSeconds: config.discovery?.catalogTtlSeconds,
|
|
470
|
+
persistToolCatalog: config.discovery?.persistCatalog,
|
|
471
|
+
toolCatalogStaleSeconds: config.discovery?.staleCatalogSeconds,
|
|
472
|
+
maxResultBytes: config.calls?.maxResultBytes,
|
|
473
|
+
credentialHealth: config.credentials?.health,
|
|
411
474
|
});
|
|
412
475
|
// Throws on every structural mistake it can see (see resolveToolkits): a
|
|
413
476
|
// typo must not become a scope the operator never wrote. Note this is about
|
|
@@ -430,12 +493,12 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
430
493
|
version: config.serverInfo?.version ?? CONNECTA_VERSION,
|
|
431
494
|
},
|
|
432
495
|
logger,
|
|
433
|
-
activity: config.activity,
|
|
434
|
-
activityReadGate: config.
|
|
435
|
-
activityDeploymentId: config.
|
|
496
|
+
activity: config.activity?.store,
|
|
497
|
+
activityReadGate: config.activity?.readGate,
|
|
498
|
+
activityDeploymentId: config.activity?.deploymentId,
|
|
436
499
|
executor: config.executor,
|
|
437
|
-
defaultToolTimeoutMs: config.
|
|
438
|
-
probeTimeoutMs: config.probeTimeoutMs,
|
|
500
|
+
defaultToolTimeoutMs: config.calls?.defaultTimeoutMs,
|
|
501
|
+
probeTimeoutMs: config.discovery?.probeTimeoutMs,
|
|
439
502
|
credentialVault,
|
|
440
503
|
deploymentInfo: config.deploymentInfo,
|
|
441
504
|
branding: config.branding,
|
|
@@ -511,7 +574,11 @@ export type {
|
|
|
511
574
|
CredentialHealthRecord,
|
|
512
575
|
} from "./credential-health.js";
|
|
513
576
|
|
|
514
|
-
export type {
|
|
577
|
+
export type {
|
|
578
|
+
RemoteMcpOptions,
|
|
579
|
+
RemoteMcpAuth,
|
|
580
|
+
RemoteMcpRedirectPolicy,
|
|
581
|
+
} from "./connectors/remote-mcp.js";
|
|
515
582
|
export type { ApiOptions, ApiTool } from "./connectors/api.js";
|
|
516
583
|
export type {
|
|
517
584
|
Connector,
|
package/src/meta-tools.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type ActivityCallSource,
|
|
7
7
|
type ActivityRequestContext,
|
|
8
8
|
} from "./activity.js";
|
|
9
|
+
import { closeConnectorScope } from "./connector-scope.js";
|
|
9
10
|
import { unwrapMcpResult } from "./mcp-result.js";
|
|
10
11
|
import {
|
|
11
12
|
classifyCallError,
|
|
@@ -64,12 +65,126 @@ function msg(err: unknown): string {
|
|
|
64
65
|
return err instanceof Error ? err.message : String(err);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
const DEFAULT_SEARCH_LIMIT = 25;
|
|
68
|
+
export const DEFAULT_SEARCH_LIMIT = 25;
|
|
69
|
+
/**
|
|
70
|
+
* A discovery page is for choosing the next tool, not exporting the catalog.
|
|
71
|
+
* One hundred leaves room for broad browsing while keeping each deliberate
|
|
72
|
+
* page far below the catalog sizes Connecta supports.
|
|
73
|
+
*/
|
|
74
|
+
export const MAX_SEARCH_LIMIT = 100;
|
|
75
|
+
/** Same one-request work bound for address-based discovery. */
|
|
76
|
+
export const MAX_DESCRIBE_ADDRESSES = 100;
|
|
77
|
+
/**
|
|
78
|
+
* Final UTF-8 ceiling for a generated search/describe response. The count
|
|
79
|
+
* limits are the ordinary guard; this catches unusually large full schemas or
|
|
80
|
+
* descriptions that make even a bounded page expensive.
|
|
81
|
+
*/
|
|
82
|
+
export const MAX_DISCOVERY_RESULT_BYTES = 256_000;
|
|
68
83
|
const enc = new TextEncoder();
|
|
69
84
|
const dec = new TextDecoder();
|
|
70
85
|
|
|
71
86
|
type ErrorDetails = CallErrorDetails;
|
|
72
87
|
|
|
88
|
+
export class DiscoveryPolicyError extends Error {
|
|
89
|
+
constructor(
|
|
90
|
+
readonly code: "invalid_args" | "result_too_large",
|
|
91
|
+
message: string,
|
|
92
|
+
) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "DiscoveryPolicyError";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Validate before ranking so a huge page request does no proportional work. */
|
|
99
|
+
export function discoverySearchLimit(value: unknown): number {
|
|
100
|
+
if (value === undefined) return DEFAULT_SEARCH_LIMIT;
|
|
101
|
+
if (
|
|
102
|
+
typeof value !== "number" ||
|
|
103
|
+
!Number.isInteger(value) ||
|
|
104
|
+
value < 1 ||
|
|
105
|
+
value > MAX_SEARCH_LIMIT
|
|
106
|
+
) {
|
|
107
|
+
throw new DiscoveryPolicyError(
|
|
108
|
+
"invalid_args",
|
|
109
|
+
`limit must be a whole number from 1 through ${MAX_SEARCH_LIMIT}. Page through larger catalogs with offset.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Validate the raw list so duplicate addresses consume the same bound. */
|
|
116
|
+
export function discoveryAddresses(value: unknown): unknown[] {
|
|
117
|
+
if (!Array.isArray(value)) {
|
|
118
|
+
throw new DiscoveryPolicyError(
|
|
119
|
+
"invalid_args",
|
|
120
|
+
"addresses must be an array.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (value.length > MAX_DESCRIBE_ADDRESSES) {
|
|
124
|
+
throw new DiscoveryPolicyError(
|
|
125
|
+
"invalid_args",
|
|
126
|
+
`addresses must contain at most ${MAX_DESCRIBE_ADDRESSES} entries. Split a larger list across describe_tools calls.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Serialize once and count the exact bytes jsonResult would emit. */
|
|
133
|
+
function boundedDiscoveryText(
|
|
134
|
+
value: unknown,
|
|
135
|
+
hint: string,
|
|
136
|
+
): string {
|
|
137
|
+
const text = JSON.stringify(value, null, 2);
|
|
138
|
+
if (text === undefined) {
|
|
139
|
+
throw new TypeError("Discovery result is not JSON-serializable.");
|
|
140
|
+
}
|
|
141
|
+
const bytes = enc.encode(text).length;
|
|
142
|
+
if (bytes > MAX_DISCOVERY_RESULT_BYTES) {
|
|
143
|
+
throw new DiscoveryPolicyError(
|
|
144
|
+
"result_too_large",
|
|
145
|
+
`Discovery result is ${bytes} UTF-8 bytes, over the ${MAX_DISCOVERY_RESULT_BYTES}-byte ceiling. ${hint}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return text;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Apply the same final result guard to code-mode discovery helpers. */
|
|
152
|
+
export function assertDiscoveryResultSize(
|
|
153
|
+
value: unknown,
|
|
154
|
+
hint: string,
|
|
155
|
+
): void {
|
|
156
|
+
boundedDiscoveryText(value, hint);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function discoveryErrorResult(error: DiscoveryPolicyError): ToolResult {
|
|
160
|
+
const result = jsonResult({
|
|
161
|
+
error: {
|
|
162
|
+
code: error.code,
|
|
163
|
+
message: error.message,
|
|
164
|
+
retryable: false,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
result.isError = true;
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function discoveryResult(value: unknown, hint: string): ToolResult {
|
|
172
|
+
try {
|
|
173
|
+
const text = boundedDiscoveryText(value, hint);
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text", text }],
|
|
176
|
+
...(value !== null && typeof value === "object" && !Array.isArray(value)
|
|
177
|
+
? { structuredContent: value as Record<string, unknown> }
|
|
178
|
+
: {}),
|
|
179
|
+
};
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (err instanceof DiscoveryPolicyError) {
|
|
182
|
+
return discoveryErrorResult(err);
|
|
183
|
+
}
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
73
188
|
/**
|
|
74
189
|
* The longest the engine will park a synchronous inbound request in *waiting
|
|
75
190
|
* alone*. The engine already treats ~15 s as the outer bound of one reasonable
|
|
@@ -458,9 +573,9 @@ export interface SkillArgs {
|
|
|
458
573
|
* optional tenth tool, is registered separately by registerExecuteTool.)
|
|
459
574
|
*
|
|
460
575
|
* The deployment-wide result-size cap is read off the registry view rather than
|
|
461
|
-
* passed in: `ConnectaConfig.maxResultBytes` and the per-connector
|
|
462
|
-
* the only places a cap is set, so there is one answer to where a
|
|
463
|
-
* sets it (issue #44).
|
|
576
|
+
* passed in: `ConnectaConfig.calls.maxResultBytes` and the per-connector
|
|
577
|
+
* override are the only places a cap is set, so there is one answer to where a
|
|
578
|
+
* deployment sets it (issue #44).
|
|
464
579
|
*/
|
|
465
580
|
export function createMetaTools(
|
|
466
581
|
registry: RegistryView,
|
|
@@ -788,8 +903,12 @@ export function createMetaTools(
|
|
|
788
903
|
|
|
789
904
|
async listConnectors(args: ListArgs = {}): Promise<ToolResult> {
|
|
790
905
|
const probe = args.probe ?? true;
|
|
906
|
+
// Live inventory owns a short-lived scope separate from the request's
|
|
907
|
+
// call scope. Closing it cannot defeat call_tool/batch/execute_code reuse.
|
|
908
|
+
const connectors = registry.listConnectors();
|
|
909
|
+
const scope = probe ? {} : requestScope;
|
|
791
910
|
const out = await Promise.all(
|
|
792
|
-
|
|
911
|
+
connectors.map(async (c) => {
|
|
793
912
|
const statusStarted = Date.now();
|
|
794
913
|
const observed = registry.healthFor(c.id);
|
|
795
914
|
const verdict = await registry.credentialHealthFor(c.id);
|
|
@@ -799,7 +918,7 @@ export function createMetaTools(
|
|
|
799
918
|
if (probe) {
|
|
800
919
|
try {
|
|
801
920
|
status = await withTimeout(
|
|
802
|
-
registry.statusFor(c.id, baseUrl,
|
|
921
|
+
registry.statusFor(c.id, baseUrl, scope),
|
|
803
922
|
probeTimeoutMs,
|
|
804
923
|
`list_connectors probe of "${c.id}"`,
|
|
805
924
|
);
|
|
@@ -886,7 +1005,7 @@ export function createMetaTools(
|
|
|
886
1005
|
if (probe && status.state === "ok") {
|
|
887
1006
|
try {
|
|
888
1007
|
tools = await withTimeout(
|
|
889
|
-
registry.refreshTools(c.id, baseUrl,
|
|
1008
|
+
registry.refreshTools(c.id, baseUrl, scope),
|
|
890
1009
|
probeTimeoutMs,
|
|
891
1010
|
`list_connectors catalog refresh of "${c.id}"`,
|
|
892
1011
|
);
|
|
@@ -918,13 +1037,31 @@ export function createMetaTools(
|
|
|
918
1037
|
...(status.message ? { message: status.message } : {}),
|
|
919
1038
|
};
|
|
920
1039
|
}),
|
|
921
|
-
)
|
|
1040
|
+
).finally(async () => {
|
|
1041
|
+
if (!probe) return;
|
|
1042
|
+
await Promise.all(
|
|
1043
|
+
connectors.map((connector) =>
|
|
1044
|
+
closeConnectorScope(
|
|
1045
|
+
connector,
|
|
1046
|
+
registry.contextFor(connector.id, baseUrl, scope),
|
|
1047
|
+
),
|
|
1048
|
+
),
|
|
1049
|
+
);
|
|
1050
|
+
});
|
|
922
1051
|
return jsonResult({ connectors: out });
|
|
923
1052
|
},
|
|
924
1053
|
|
|
925
1054
|
async searchTools(args: SearchArgs): Promise<ToolResult> {
|
|
926
1055
|
const q = args.query ?? "";
|
|
927
|
-
|
|
1056
|
+
let limit: number;
|
|
1057
|
+
try {
|
|
1058
|
+
limit = discoverySearchLimit(args.limit);
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
if (err instanceof DiscoveryPolicyError) {
|
|
1061
|
+
return discoveryErrorResult(err);
|
|
1062
|
+
}
|
|
1063
|
+
throw err;
|
|
1064
|
+
}
|
|
928
1065
|
const offset = Math.max(0, Math.trunc(args.offset ?? 0));
|
|
929
1066
|
const conns = args.connector
|
|
930
1067
|
? [registry.getConnector(args.connector)].filter(
|
|
@@ -1035,17 +1172,28 @@ export function createMetaTools(
|
|
|
1035
1172
|
offset + page.length < matches.length
|
|
1036
1173
|
? offset + page.length
|
|
1037
1174
|
: undefined;
|
|
1038
|
-
return
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1175
|
+
return discoveryResult(
|
|
1176
|
+
{
|
|
1177
|
+
connectors: groups,
|
|
1178
|
+
total: matches.length,
|
|
1179
|
+
offset,
|
|
1180
|
+
limit,
|
|
1181
|
+
hasMore: nextOffset !== undefined,
|
|
1182
|
+
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
1183
|
+
},
|
|
1184
|
+
"Request a smaller limit, omit fullDescriptions, or use compact schemas.",
|
|
1185
|
+
);
|
|
1046
1186
|
},
|
|
1047
1187
|
|
|
1048
1188
|
async describeTools(args: DescribeArgs): Promise<ToolResult> {
|
|
1189
|
+
try {
|
|
1190
|
+
discoveryAddresses(args.addresses);
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
if (err instanceof DiscoveryPolicyError) {
|
|
1193
|
+
return discoveryErrorResult(err);
|
|
1194
|
+
}
|
|
1195
|
+
throw err;
|
|
1196
|
+
}
|
|
1049
1197
|
const format = args.format ?? "compact";
|
|
1050
1198
|
const resolved = args.addresses.map((address) => ({
|
|
1051
1199
|
address,
|
|
@@ -1116,7 +1264,10 @@ export function createMetaTools(
|
|
|
1116
1264
|
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
1117
1265
|
};
|
|
1118
1266
|
});
|
|
1119
|
-
return
|
|
1267
|
+
return discoveryResult(
|
|
1268
|
+
{ tools: out },
|
|
1269
|
+
'Split the address list or use format: "compact".',
|
|
1270
|
+
);
|
|
1120
1271
|
},
|
|
1121
1272
|
|
|
1122
1273
|
async callTool(args: CallArgs): Promise<ToolResult> {
|
|
@@ -1312,10 +1463,8 @@ export function createMetaTools(
|
|
|
1312
1463
|
|
|
1313
1464
|
const LIST_DESC =
|
|
1314
1465
|
"List connectors with status, cached tool count, and recent real-call health. Use probe=false for a fast inventory; use probe=true (default) only to diagnose live health or authorization.";
|
|
1315
|
-
const SEARCH_DESC =
|
|
1316
|
-
|
|
1317
|
-
const DESCRIBE_DESC =
|
|
1318
|
-
'Inspect known tool addresses when search_tools did not include a sufficient schema. Returns descriptions, input/output schemas, and behavior annotations; format "compact" is the default.';
|
|
1466
|
+
const SEARCH_DESC = `Start here when a tool address is unknown. Exact/name matches rank above description matches; an empty query browses all. Pages contain at most ${MAX_SEARCH_LIMIT} tools. includeSchemas="compact" usually removes the describe_tools round trip.`;
|
|
1467
|
+
const DESCRIBE_DESC = `Inspect up to ${MAX_DESCRIBE_ADDRESSES} known tool addresses when search_tools did not include a sufficient schema. Returns descriptions, input/output schemas, and behavior annotations; format "compact" is the default.`;
|
|
1319
1468
|
const CALL_DESC =
|
|
1320
1469
|
'Use for one tool explicitly annotated readOnlyHint: true. For 2–10 independent read-only calls use batch_call; for dependent steps or data reduction use execute_code when available. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths, resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
|
|
1321
1470
|
const CALL_DESTRUCTIVE_DESC =
|
|
@@ -1429,7 +1578,7 @@ export function registerMetaTools(
|
|
|
1429
1578
|
inputSchema: {
|
|
1430
1579
|
query: z.string().optional(),
|
|
1431
1580
|
connector: z.string().optional(),
|
|
1432
|
-
limit: z.number().int().positive().optional(),
|
|
1581
|
+
limit: z.number().int().positive().max(MAX_SEARCH_LIMIT).optional(),
|
|
1433
1582
|
offset: z.number().int().nonnegative().optional(),
|
|
1434
1583
|
fullDescriptions: z.boolean().optional(),
|
|
1435
1584
|
includeSchemas: z.enum(["compact", "json"]).optional(),
|
|
@@ -1444,7 +1593,7 @@ export function registerMetaTools(
|
|
|
1444
1593
|
{
|
|
1445
1594
|
description: describedFor(registry, DESCRIBE_DESC, "describe"),
|
|
1446
1595
|
inputSchema: {
|
|
1447
|
-
addresses: z.array(z.string()),
|
|
1596
|
+
addresses: z.array(z.string()).max(MAX_DESCRIBE_ADDRESSES),
|
|
1448
1597
|
format: z.enum(["compact", "json"]).optional(),
|
|
1449
1598
|
fullDescriptions: z.boolean().optional(),
|
|
1450
1599
|
},
|