@zackbart/connecta 0.24.1 → 0.24.3
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 +169 -0
- package/dist/auth/bearer.js +2 -0
- package/dist/auth/clerk.d.ts +0 -5
- package/dist/auth/clerk.js +21 -8
- package/dist/auth/downstream-oauth.d.ts +12 -1
- package/dist/auth/downstream-oauth.js +147 -35
- package/dist/call-admission.d.ts +4 -0
- package/dist/call-admission.js +26 -0
- package/dist/catalog-drift.js +9 -4
- package/dist/catalog-service.d.ts +2 -0
- package/dist/catalog-service.js +25 -8
- package/dist/catalog.d.ts +2 -0
- package/dist/catalog.js +246 -121
- package/dist/connector-access.d.ts +32 -0
- package/dist/connector-access.js +79 -0
- package/dist/connectors/api.js +11 -1
- package/dist/connectors/guarded-fetch.d.ts +1 -1
- package/dist/connectors/guarded-fetch.js +27 -20
- package/dist/connectors/remote-mcp.js +84 -53
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +58 -0
- package/dist/execute.js +85 -23
- package/dist/executor-result.js +3 -1
- package/dist/executors/quickjs-child.js +5 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -0
- package/dist/executors/quickjs-runtime.d.ts +1 -1
- package/dist/executors/quickjs-runtime.js +38 -21
- package/dist/executors/quickjs.js +68 -27
- package/dist/index.d.ts +37 -1
- package/dist/index.js +89 -3
- package/dist/invocation.js +134 -93
- package/dist/mcp-result.js +3 -2
- package/dist/meta-tools.js +118 -39
- package/dist/registry.d.ts +29 -1
- package/dist/registry.js +122 -15
- package/dist/routes/credentials.js +1 -0
- package/dist/routes/mcp.d.ts +4 -1
- package/dist/routes/mcp.js +112 -12
- package/dist/routes/oauth-management.js +1 -0
- package/dist/routes/oauth.js +4 -0
- package/dist/routes/shared.d.ts +7 -1
- package/dist/routes/shared.js +12 -13
- package/dist/routes/ui.js +2 -1
- package/dist/server.js +15 -3
- package/dist/skills.js +6 -5
- package/dist/storage/file.d.ts +6 -2
- package/dist/storage/file.js +312 -34
- package/dist/storage/memory.js +12 -1
- package/dist/validate.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +30 -9
- package/documentation/auth.md +110 -6
- package/documentation/call-admission.md +24 -8
- package/documentation/code-mode.md +34 -22
- package/documentation/connectors.md +47 -5
- package/documentation/meta-tools.md +74 -6
- package/documentation/operations.md +20 -19
- package/documentation/provider-conventions.md +7 -0
- package/documentation/request-admission.md +38 -4
- package/documentation/storage-and-credentials.md +54 -1
- package/documentation/upgrading.md +21 -5
- package/ethos.md +1 -1
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { credentialTestRule, describeCredentialTestMismatch, } from "./credential-rules.js";
|
|
2
2
|
import { Registry } from "./registry.js";
|
|
3
|
+
import { parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
|
|
3
4
|
import { createFetchHandler } from "./server.js";
|
|
4
5
|
import { droppedBrandingUrls, droppedUiAuthUrls } from "./branding.js";
|
|
5
6
|
import { memoryStorage } from "./storage/memory.js";
|
|
@@ -56,8 +57,10 @@ const CONFIG_SCHEMA = {
|
|
|
56
57
|
credentialAdministration: null,
|
|
57
58
|
personalConnection: null,
|
|
58
59
|
},
|
|
60
|
+
pools: null,
|
|
59
61
|
storage: null,
|
|
60
62
|
publicUrl: null,
|
|
63
|
+
allowedOrigins: null,
|
|
61
64
|
activity: null,
|
|
62
65
|
vault: null,
|
|
63
66
|
ui: null,
|
|
@@ -68,6 +71,10 @@ const CONFIG_SCHEMA = {
|
|
|
68
71
|
staleCatalogSeconds: null,
|
|
69
72
|
probeTimeoutMs: null,
|
|
70
73
|
},
|
|
74
|
+
results: {
|
|
75
|
+
maxStashBytes: null,
|
|
76
|
+
maxStashEntries: null,
|
|
77
|
+
},
|
|
71
78
|
calls: {
|
|
72
79
|
defaultTimeoutMs: null,
|
|
73
80
|
maxResultBytes: null,
|
|
@@ -133,6 +140,17 @@ function rejectUnknownOptions(paths) {
|
|
|
133
140
|
/** Reject JavaScript typos and removed options before construction does work. */
|
|
134
141
|
function assertKnownConfig(config) {
|
|
135
142
|
rejectUnknownOptions(unknownOptionPaths(config, "ConnectaConfig", CONFIG_SCHEMA));
|
|
143
|
+
if (config.results !== undefined) {
|
|
144
|
+
if (!config.results || typeof config.results !== "object" || Array.isArray(config.results)) {
|
|
145
|
+
throw new Error("ConnectaConfig.results must be an object");
|
|
146
|
+
}
|
|
147
|
+
for (const key of ["maxStashBytes", "maxStashEntries"]) {
|
|
148
|
+
const value = config.results[key];
|
|
149
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
|
|
150
|
+
throw new Error(`ConnectaConfig.results.${key} must be a non-negative safe integer`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
136
154
|
if (config.vault && ["get", "getAll", "set", "setAll", "metadata", "delete"].some(key => typeof config.vault[key] !== "function"))
|
|
137
155
|
throw new Error("ConnectaConfig.vault must implement CredentialVault");
|
|
138
156
|
if (config.ui && (typeof config.ui.handle !== "function" || typeof config.ui.credentialHandoffUrl !== "function" || !Array.isArray(config.ui.reservedPaths)))
|
|
@@ -145,6 +163,67 @@ function assertKnownConfig(config) {
|
|
|
145
163
|
throw new Error("ConnectaConfig.activity must be created with activityHistory(...)");
|
|
146
164
|
}
|
|
147
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Validate declared pools against the connector set. Everything checkable at
|
|
168
|
+
* construction throws here: a malformed name, an unparseable grant, an
|
|
169
|
+
* unknown connector id, a tool address on an `api()` connector whose static
|
|
170
|
+
* catalog lacks it. Remote catalogs load lazily, so their addresses are
|
|
171
|
+
* checked at catalog load instead and stay unreachable until they match.
|
|
172
|
+
*/
|
|
173
|
+
function resolvePools(pools, registry) {
|
|
174
|
+
const resolved = new Map();
|
|
175
|
+
if (!pools)
|
|
176
|
+
return resolved;
|
|
177
|
+
if (typeof pools !== "object" || Array.isArray(pools)) {
|
|
178
|
+
throw new Error("ConnectaConfig.pools must be an object keyed by pool name");
|
|
179
|
+
}
|
|
180
|
+
for (const [name, pool] of Object.entries(pools)) {
|
|
181
|
+
if (!POOL_NAME_RE.test(name)) {
|
|
182
|
+
throw new Error(`ConnectaConfig.pools: pool name "${name}" must match [a-z0-9_-]+`);
|
|
183
|
+
}
|
|
184
|
+
if (!pool || typeof pool !== "object" || !Array.isArray(pool.tools)) {
|
|
185
|
+
throw new Error(`ConnectaConfig.pools.${name}: tools must be an array of connector ids or connector.tool addresses`);
|
|
186
|
+
}
|
|
187
|
+
if (pool.grant !== undefined && typeof pool.grant !== "function") {
|
|
188
|
+
throw new Error(`ConnectaConfig.pools.${name}: grant must be a function`);
|
|
189
|
+
}
|
|
190
|
+
// A misspelled `grant` would otherwise boot as a deny-all pool with only a
|
|
191
|
+
// per-request log line to say so; that is fail-closed, but the rule here
|
|
192
|
+
// is that structural mistakes refuse to boot.
|
|
193
|
+
for (const key of Object.keys(pool)) {
|
|
194
|
+
if (key !== "tools" && key !== "grant") {
|
|
195
|
+
throw new Error(`ConnectaConfig.pools.${name}: unknown option "${key}"`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
let access;
|
|
199
|
+
try {
|
|
200
|
+
access = parseConnectorAccess(pool.tools);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
throw new Error(`ConnectaConfig.pools.${name}: tools must be connector ids or connector.tool addresses`);
|
|
204
|
+
}
|
|
205
|
+
if (access.connectorIds === "all" || access.connectorIds.length === 0) {
|
|
206
|
+
throw new Error(`ConnectaConfig.pools.${name}: a pool must name at least one connector or tool`);
|
|
207
|
+
}
|
|
208
|
+
for (const id of access.connectorIds) {
|
|
209
|
+
const connector = registry.getConnector(id);
|
|
210
|
+
if (!connector) {
|
|
211
|
+
throw new Error(`ConnectaConfig.pools.${name}: unknown connector "${id}"`);
|
|
212
|
+
}
|
|
213
|
+
const granted = access.toolAccess?.get(id);
|
|
214
|
+
if (!granted || !connector.staticTools)
|
|
215
|
+
continue;
|
|
216
|
+
const known = new Set(connector.staticTools.map((tool) => tool.name));
|
|
217
|
+
for (const tool of granted) {
|
|
218
|
+
if (!known.has(tool)) {
|
|
219
|
+
throw new Error(`ConnectaConfig.pools.${name}: connector "${id}" has no tool "${tool}"`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
resolved.set(name, { access, grant: pool.grant ?? (() => false) });
|
|
224
|
+
}
|
|
225
|
+
return resolved;
|
|
226
|
+
}
|
|
148
227
|
/**
|
|
149
228
|
* One-time construction warnings for deployment shapes that run fine but are
|
|
150
229
|
* usually unintended. Warning-only — never throws and never changes behavior;
|
|
@@ -154,12 +233,15 @@ function assertKnownConfig(config) {
|
|
|
154
233
|
function warnInsecureConfig(config, inboundAuth, logger) {
|
|
155
234
|
const oauthConnectors = config.connectors.filter((c) => c.finishAuth);
|
|
156
235
|
const hasCredentialConnector = config.connectors.some((c) => c.credential);
|
|
157
|
-
//
|
|
158
|
-
//
|
|
236
|
+
// Static API headers can carry secrets without declaring credential hooks.
|
|
237
|
+
// Any configured connector warrants the open-deployment warning.
|
|
159
238
|
if (inboundAuth.length === 0 &&
|
|
160
|
-
|
|
239
|
+
config.connectors.length > 0) {
|
|
161
240
|
logger.warn("[connecta] running with no inbound authentication: any caller can " +
|
|
162
241
|
"invoke every shared connector. " +
|
|
242
|
+
(hasCredentialConnector || oauthConnectors.length > 0
|
|
243
|
+
? "Configured credentials and downstream OAuth grants are exposed to those calls. "
|
|
244
|
+
: "") +
|
|
163
245
|
"Configure `auth` (for example bearerToken(...) or Clerk) to gate access.");
|
|
164
246
|
}
|
|
165
247
|
// Unset publicUrl with OAuth connectors: the downstream redirect_uri is
|
|
@@ -267,8 +349,10 @@ export function createConnecta(config) {
|
|
|
267
349
|
persistToolCatalog: config.discovery?.persistCatalog,
|
|
268
350
|
toolCatalogStaleSeconds: config.discovery?.staleCatalogSeconds,
|
|
269
351
|
maxResultBytes: config.calls?.maxResultBytes,
|
|
352
|
+
results: config.results,
|
|
270
353
|
});
|
|
271
354
|
const inboundAuth = configuredAuth;
|
|
355
|
+
const pools = resolvePools(config.pools, registry);
|
|
272
356
|
warnInsecureConfig(config, inboundAuth, logger);
|
|
273
357
|
const requestAdmission = admissionController(config.admission?.requests, REQUEST_ADMISSION_DEFAULTS);
|
|
274
358
|
const configuredCodeAdmission = admissionController(config.admission?.code, CODE_ADMISSION_DEFAULTS);
|
|
@@ -290,7 +374,9 @@ export function createConnecta(config) {
|
|
|
290
374
|
registry,
|
|
291
375
|
auth: inboundAuth,
|
|
292
376
|
identity: config.identity,
|
|
377
|
+
pools,
|
|
293
378
|
publicUrl: config.publicUrl,
|
|
379
|
+
allowedOrigins: config.allowedOrigins,
|
|
294
380
|
serverInfo,
|
|
295
381
|
logger,
|
|
296
382
|
activity: config.activity?.store,
|
package/dist/invocation.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isCallAdmissionError } from "./call-admission.js";
|
|
2
|
-
import { classifyCallError, ConnectorCallError, echoedCallArgs, framingError, } from "./errors.js";
|
|
2
|
+
import { boundedEchoText, classifyCallError, ConnectorCallError, echoedCallArgs, framingError, } from "./errors.js";
|
|
3
3
|
import { unwrapMcpResult } from "./mcp-result.js";
|
|
4
4
|
import { splitAddress } from "./registry.js";
|
|
5
5
|
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
@@ -43,10 +43,10 @@ function assertRawMcpSuccess(kind, result) {
|
|
|
43
43
|
const mcpResult = result;
|
|
44
44
|
if (!mcpResult.isError)
|
|
45
45
|
return;
|
|
46
|
-
throw new Error(mcpResult.content
|
|
46
|
+
throw new Error(boundedEchoText(mcpResult.content
|
|
47
47
|
?.filter((block) => block.type === "text")
|
|
48
48
|
.map((block) => block.text ?? "")
|
|
49
|
-
.join("") || "Downstream tool call failed");
|
|
49
|
+
.join("") || "Downstream tool call failed"));
|
|
50
50
|
}
|
|
51
51
|
export class InvocationFailure extends Error {
|
|
52
52
|
details;
|
|
@@ -196,6 +196,8 @@ export class InvocationService {
|
|
|
196
196
|
tool: target.toolName,
|
|
197
197
|
source: context.source,
|
|
198
198
|
code: details.code,
|
|
199
|
+
// Sanitized transport diagnostics (origin and errno only, #539).
|
|
200
|
+
...(details.details ? { details: details.details } : {}),
|
|
199
201
|
attempts,
|
|
200
202
|
durationMs: Date.now() - started,
|
|
201
203
|
message: String(details.message ?? "").slice(0, 300),
|
|
@@ -215,111 +217,150 @@ export class InvocationService {
|
|
|
215
217
|
error: details,
|
|
216
218
|
};
|
|
217
219
|
};
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
activityTarget = {
|
|
223
|
-
connector: resolution.connector,
|
|
224
|
-
toolName: resolution.toolName,
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
if (resolution.cause && context.requestSignal?.aborted) {
|
|
228
|
-
return failed(callerCancelledDetails());
|
|
229
|
-
}
|
|
230
|
-
return failed(resolution.error);
|
|
231
|
-
}
|
|
232
|
-
resolved = resolution.resolved;
|
|
233
|
-
activityTarget = resolved;
|
|
234
|
-
if (!isExplicitlyReadOnly(resolved.definition) && !context.allowDestructive) {
|
|
235
|
-
const canonicalAddress = `${resolved.connector.id}.${resolved.toolName}`;
|
|
236
|
-
return failed(framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`));
|
|
237
|
-
}
|
|
238
|
-
// Remote MCP tools advertise their input schema in the catalog. Validate
|
|
239
|
-
// against that same request-local definition before admission or provider
|
|
240
|
-
// dispatch, so a predictable mismatch stays structured instead of being
|
|
241
|
-
// flattened into provider-specific error prose. Unsupported schemas retain
|
|
242
|
-
// validateToolInput's fail-open behavior and reach the downstream normally.
|
|
243
|
-
if (resolved.connector.kind === "mcp" &&
|
|
244
|
-
resolved.definition.inputSchema) {
|
|
245
|
-
const invalid = validateToolInput(resolved.definition.inputSchema, args ?? {}, {
|
|
246
|
-
address: `${resolved.connector.id}.${resolved.toolName}`,
|
|
247
|
-
logger: this.registry.contextFor(resolved.connector.id, this.catalog.baseUrl, this.catalog.requestScope).logger,
|
|
248
|
-
});
|
|
249
|
-
if (invalid)
|
|
250
|
-
return failed(classifyCallError(invalid));
|
|
251
|
-
}
|
|
252
|
-
try {
|
|
253
|
-
context.beforeDispatch?.();
|
|
254
|
-
}
|
|
255
|
-
catch (error) {
|
|
256
|
-
return failed(error instanceof InvocationFailure
|
|
257
|
-
? error.details
|
|
258
|
-
: classifyCallError(error));
|
|
220
|
+
if (context.requestSignal?.aborted) {
|
|
221
|
+
// Preserve the cancelled admission-attempt count without starting discovery.
|
|
222
|
+
attempts = 1;
|
|
223
|
+
return failed(callerCancelledDetails());
|
|
259
224
|
}
|
|
260
225
|
let result;
|
|
261
226
|
let observedResult;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
227
|
+
// One deadline owns discovery, queue admission, and provider dispatch.
|
|
228
|
+
// Only the caller of this function records the final outcome, so a late
|
|
229
|
+
// cancellation cannot append a second activity event after the deadline.
|
|
230
|
+
const dispatch = async (callSignal) => {
|
|
231
|
+
const resolution = await this.catalog.resolveTool(address, defined({ signal: callSignal }));
|
|
232
|
+
catalogMs += resolution.catalogMs;
|
|
233
|
+
if (callSignal?.aborted)
|
|
234
|
+
throw callSignal.reason;
|
|
235
|
+
if (!resolution.ok) {
|
|
236
|
+
if (resolution.connector && resolution.toolName) {
|
|
237
|
+
activityTarget = {
|
|
238
|
+
connector: resolution.connector,
|
|
239
|
+
toolName: resolution.toolName,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (resolution.cause && context.requestSignal?.aborted) {
|
|
243
|
+
return callerCancelledDetails();
|
|
244
|
+
}
|
|
245
|
+
return resolution.error;
|
|
246
|
+
}
|
|
247
|
+
const target = resolution.resolved;
|
|
248
|
+
resolved = target;
|
|
249
|
+
activityTarget = target;
|
|
250
|
+
if (!isExplicitlyReadOnly(target.definition) && !context.allowDestructive) {
|
|
251
|
+
const canonicalAddress = `${target.connector.id}.${target.toolName}`;
|
|
252
|
+
return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`);
|
|
253
|
+
}
|
|
254
|
+
// Remote MCP tools advertise their input schema in the catalog. Validate
|
|
255
|
+
// against that same request-local definition before admission or provider
|
|
256
|
+
// dispatch, so a predictable mismatch stays structured instead of being
|
|
257
|
+
// flattened into provider-specific error prose. Unsupported schemas retain
|
|
258
|
+
// validateToolInput's fail-open behavior and reach the downstream normally.
|
|
259
|
+
if (target.connector.kind === "mcp" &&
|
|
260
|
+
target.definition.inputSchema) {
|
|
261
|
+
const invalid = validateToolInput(target.definition.inputSchema, args ?? {}, {
|
|
262
|
+
address: `${target.connector.id}.${target.toolName}`,
|
|
263
|
+
logger: this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope).logger,
|
|
264
|
+
});
|
|
265
|
+
if (invalid)
|
|
266
|
+
return classifyCallError(invalid);
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
context.beforeDispatch?.();
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
return error instanceof InvocationFailure
|
|
273
|
+
? error.details
|
|
274
|
+
: classifyCallError(error);
|
|
275
|
+
}
|
|
276
|
+
if (callSignal?.aborted)
|
|
277
|
+
throw callSignal.reason;
|
|
278
|
+
attempts = 1;
|
|
279
|
+
let permit;
|
|
280
|
+
let attemptError;
|
|
281
|
+
let attemptFailed = false;
|
|
282
|
+
try {
|
|
283
|
+
permit = await timed((elapsed) => { admissionMs += elapsed; }, () => this.registry.admitCall(target.connector.id, {
|
|
284
|
+
toolName: target.toolName,
|
|
285
|
+
args: args ?? {},
|
|
286
|
+
...defined({ signal: callSignal }),
|
|
287
|
+
}));
|
|
288
|
+
const raw = await timed((elapsed) => { connectorMs += elapsed; }, () => {
|
|
289
|
+
const call = () => {
|
|
290
|
+
const connectorContext = this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs }));
|
|
291
|
+
if (target.connector.credential &&
|
|
292
|
+
!connectorContext.credential) {
|
|
293
|
+
throw new ConnectorCallError("auth_required", "Operator-managed credential storage is not configured. Call " +
|
|
294
|
+
`authorize_connector({ connector: "${target.connector.id}" }).`);
|
|
295
|
+
}
|
|
296
|
+
// Cancellation can arrive during admission or context construction.
|
|
297
|
+
if (callSignal?.aborted)
|
|
298
|
+
throw callSignal.reason;
|
|
299
|
+
return target.connector.callTool(target.toolName, args ?? {}, connectorContext);
|
|
300
|
+
};
|
|
301
|
+
// Race cancellation here too, so an uncooperative connector cannot
|
|
302
|
+
// retain its admission permit after the enclosing deadline expires.
|
|
303
|
+
return callSignal
|
|
304
|
+
? withDeadline(call, {
|
|
305
|
+
signal: callSignal,
|
|
306
|
+
timeoutError: new ConnectorCallError("timeout", "Tool call timed out"),
|
|
307
|
+
})
|
|
308
|
+
: call();
|
|
309
|
+
});
|
|
310
|
+
// isError is checked here for BOTH result shapes so every adapter
|
|
311
|
+
// reports the same downstream-failure wording, and the throw lands
|
|
312
|
+
// inside the attempt where it feeds health.
|
|
313
|
+
assertRawMcpSuccess(target.connector.kind, raw);
|
|
314
|
+
observedResult = unwrapMcpResult(target.connector.kind, raw);
|
|
315
|
+
result = context.unwrapResult ? observedResult : raw;
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
attemptFailed = true;
|
|
319
|
+
attemptError = error;
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
permit?.release();
|
|
323
|
+
}
|
|
324
|
+
if (attemptFailed) {
|
|
325
|
+
if (callSignal?.aborted)
|
|
326
|
+
throw callSignal.reason;
|
|
327
|
+
const callerCancelled = isCallerCancellation(attemptError, context.requestSignal);
|
|
328
|
+
const details = callerCancelled
|
|
329
|
+
? callerCancelledDetails()
|
|
330
|
+
: classifyCallError(attemptError);
|
|
331
|
+
return details;
|
|
332
|
+
}
|
|
333
|
+
return undefined;
|
|
334
|
+
};
|
|
266
335
|
try {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
args: args ?? {},
|
|
270
|
-
...defined({ signal: context.requestSignal }),
|
|
271
|
-
}));
|
|
272
|
-
const raw = await timed((elapsed) => { connectorMs += elapsed; }, () => {
|
|
273
|
-
const call = (callSignal) => {
|
|
274
|
-
const connectorContext = this.registry.contextFor(resolved.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs }));
|
|
275
|
-
if (resolved.connector.credential &&
|
|
276
|
-
!connectorContext.credential) {
|
|
277
|
-
throw new ConnectorCallError("auth_required", "Operator-managed credential storage is not configured. Call " +
|
|
278
|
-
`authorize_connector({ connector: "${resolved.connector.id}" }).`);
|
|
279
|
-
}
|
|
280
|
-
// Cancellation can arrive during admission or context construction.
|
|
281
|
-
if (callSignal?.aborted)
|
|
282
|
-
throw callSignal.reason;
|
|
283
|
-
return resolved.connector.callTool(resolved.toolName, args ?? {}, connectorContext);
|
|
284
|
-
};
|
|
285
|
-
if (!context.timeoutMs && !context.requestSignal)
|
|
286
|
-
return call();
|
|
287
|
-
return withDeadline(call, {
|
|
336
|
+
const error = context.timeoutMs || context.requestSignal
|
|
337
|
+
? await withDeadline(dispatch, {
|
|
288
338
|
...defined({
|
|
289
339
|
timeoutMs: context.timeoutMs,
|
|
290
340
|
signal: context.requestSignal,
|
|
291
341
|
}),
|
|
292
342
|
timeoutError: new ConnectorCallError("timeout", `Tool call timed out after ${context.timeoutMs}ms`),
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
// inside the attempt where it feeds health.
|
|
298
|
-
assertRawMcpSuccess(resolved.connector.kind, raw);
|
|
299
|
-
observedResult = unwrapMcpResult(resolved.connector.kind, raw);
|
|
300
|
-
result = context.unwrapResult ? observedResult : raw;
|
|
343
|
+
})
|
|
344
|
+
: await dispatch();
|
|
345
|
+
if (error)
|
|
346
|
+
return failed(error);
|
|
301
347
|
}
|
|
302
348
|
catch (error) {
|
|
303
|
-
|
|
304
|
-
attemptError = error;
|
|
305
|
-
}
|
|
306
|
-
finally {
|
|
307
|
-
permit?.release();
|
|
308
|
-
}
|
|
309
|
-
if (attemptFailed) {
|
|
310
|
-
const callerCancelled = isCallerCancellation(attemptError, context.requestSignal);
|
|
311
|
-
const details = callerCancelled
|
|
349
|
+
return failed(context.requestSignal?.aborted
|
|
312
350
|
? callerCancelledDetails()
|
|
313
|
-
: classifyCallError(
|
|
314
|
-
return failed(details);
|
|
351
|
+
: classifyCallError(error));
|
|
315
352
|
}
|
|
353
|
+
// A dispatch that returned no refusal resolved a concrete tool.
|
|
354
|
+
const completed = resolved;
|
|
355
|
+
if (!completed)
|
|
356
|
+
throw new Error("Invocation completed without a resolved tool");
|
|
316
357
|
try {
|
|
317
358
|
const value = await timed((elapsed) => { resultProcessingMs += elapsed; }, async () => {
|
|
318
359
|
const processed = context.processResult
|
|
319
|
-
? await context.processResult(result,
|
|
360
|
+
? await context.processResult(result, completed)
|
|
320
361
|
: result;
|
|
321
362
|
try {
|
|
322
|
-
this.registry.observeOutputShape(
|
|
363
|
+
this.registry.observeOutputShape(completed.connector.id, completed.definition, observedResult);
|
|
323
364
|
}
|
|
324
365
|
catch {
|
|
325
366
|
// Shape learning is advisory. It cannot change a completed call.
|
|
@@ -332,14 +373,14 @@ export class InvocationService {
|
|
|
332
373
|
return {
|
|
333
374
|
ok: true,
|
|
334
375
|
value,
|
|
335
|
-
resolved,
|
|
376
|
+
resolved: completed,
|
|
336
377
|
durationMs: Date.now() - started,
|
|
337
378
|
attempts,
|
|
338
379
|
timing: diagnostics,
|
|
339
380
|
};
|
|
340
381
|
}
|
|
341
|
-
catch
|
|
342
|
-
return failed(framingError("result_processing_failed",
|
|
382
|
+
catch {
|
|
383
|
+
return failed(framingError("result_processing_failed", "The downstream call completed, but its result could not be processed. Do not repeat the call to recover its result."));
|
|
343
384
|
}
|
|
344
385
|
}
|
|
345
386
|
}
|
package/dist/mcp-result.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { boundedEchoText } from "./errors.js";
|
|
1
2
|
/**
|
|
2
3
|
* Unwrap an MCP CallToolResult into an ordinary JavaScript value:
|
|
3
4
|
* `toolResult` wins when present, then `structuredContent`; all-text content is
|
|
@@ -17,9 +18,9 @@ export function unwrapMcpResult(kind, result) {
|
|
|
17
18
|
.filter((c) => c.type === "text")
|
|
18
19
|
.map((c) => c.text ?? "")
|
|
19
20
|
.join("\n");
|
|
20
|
-
throw new Error(text || "Tool call failed");
|
|
21
|
+
throw new Error(boundedEchoText(text || "Tool call failed"));
|
|
21
22
|
}
|
|
22
|
-
if (r.structuredContent
|
|
23
|
+
if (r.structuredContent !== undefined)
|
|
23
24
|
return r.structuredContent;
|
|
24
25
|
if (content.length > 0 && content.every((c) => c.type === "text")) {
|
|
25
26
|
const text = content.map((c) => c.text ?? "").join("\n");
|