@zackbart/connecta 0.24.2 → 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 +141 -0
- package/dist/auth/bearer.js +2 -0
- 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/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 +14 -0
- package/dist/index.js +24 -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 +14 -2
- package/dist/registry.js +87 -13
- package/dist/routes/mcp.d.ts +4 -1
- package/dist/routes/mcp.js +84 -13
- package/dist/routes/oauth.js +4 -0
- package/dist/routes/shared.d.ts +1 -0
- package/dist/routes/shared.js +4 -4
- 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 +22 -6
- package/documentation/auth.md +42 -9
- 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 +19 -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 +18 -4
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
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");
|
package/dist/meta-tools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { boundedDiscoveryText, CatalogService, DEFAULT_SEARCH_LIMIT, DiscoveryPolicyError, groupedSearchResult, MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, } from "./catalog-service.js";
|
|
3
3
|
import { resolveDiscoveryConcurrency } from "./concurrency.js";
|
|
4
|
-
import { msg } from "./errors.js";
|
|
4
|
+
import { boundedEchoText, msg } from "./errors.js";
|
|
5
5
|
import { serializeResultText } from "./executor-result.js";
|
|
6
6
|
import { InvocationService, } from "./invocation.js";
|
|
7
7
|
import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
|
|
@@ -37,7 +37,12 @@ async function discoveryResult(operation, hint) {
|
|
|
37
37
|
try {
|
|
38
38
|
const value = await operation();
|
|
39
39
|
const text = boundedDiscoveryText(value, hint);
|
|
40
|
-
|
|
40
|
+
const result = jsonResult(value, text);
|
|
41
|
+
const bytes = enc.encode(JSON.stringify(result)).length;
|
|
42
|
+
if (bytes > MAX_DISCOVERY_RESULT_BYTES) {
|
|
43
|
+
throw new DiscoveryPolicyError("result_too_large", `Discovery result is ${bytes} UTF-8 bytes, over the ${MAX_DISCOVERY_RESULT_BYTES}-byte ceiling. ${hint}`);
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
41
46
|
}
|
|
42
47
|
catch (err) {
|
|
43
48
|
if (err instanceof DiscoveryPolicyError) {
|
|
@@ -94,29 +99,37 @@ export function alignEndToCharBoundary(bytes, offset, end, total) {
|
|
|
94
99
|
}
|
|
95
100
|
return e;
|
|
96
101
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* text for whatever JSON can represent, and `String(value)` for the returns
|
|
101
|
-
* JSON renders as `undefined` — a handler that returns nothing, a function, or
|
|
102
|
-
* a Symbol. `JSON.stringify` is *typed* as returning `string` while actually
|
|
103
|
-
* returning `undefined` for those, which is how a handler returning `undefined`
|
|
104
|
-
* reached clients as a `{"type":"text"}` block carrying no `text` at all: the
|
|
105
|
-
* size guard measured `enc.encode(undefined)` — the empty string, per the
|
|
106
|
-
* WebIDL default — and emitted the non-string unchanged (issue #42). `null`
|
|
107
|
-
* needs no special case; JSON renders it as `"null"`.
|
|
108
|
-
*
|
|
109
|
-
* Shared by `guardText`, `guardValue`, and execute_code's `guardResultValue` so
|
|
110
|
-
* the three give one answer to the same question. A value JSON cannot serialize
|
|
111
|
-
* at all (a BigInt) still throws, as before, and is reported as a failure.
|
|
112
|
-
*/
|
|
113
|
-
/**
|
|
114
|
-
* Stash `text` under `result:<uuid>` (ttl 900s) and describe it as the
|
|
115
|
-
* truncation notice every over-cap path hands back.
|
|
116
|
-
*/
|
|
117
|
-
async function stashResult(text, results, totalBytes) {
|
|
102
|
+
/** Stash a completed result, or return a notice without a paging route. */
|
|
103
|
+
async function stashResult(bytes, results) {
|
|
104
|
+
const totalBytes = bytes.length;
|
|
118
105
|
const id = crypto.randomUUID();
|
|
119
|
-
|
|
106
|
+
try {
|
|
107
|
+
// Base64 permits byte-range decoding after a KV read, without scanning or
|
|
108
|
+
// re-encoding all preceding text. Each chunk is a multiple of three bytes.
|
|
109
|
+
const chunks = [];
|
|
110
|
+
for (let offset = 0; offset < bytes.length; offset += 12_288) {
|
|
111
|
+
chunks.push(btoa(String.fromCharCode(...bytes.subarray(offset, offset + 12_288))));
|
|
112
|
+
}
|
|
113
|
+
const stored = `connecta-result-v1:${totalBytes}:${chunks.join("")}`;
|
|
114
|
+
if (!await results.set(`result:${id}`, stored, RESULT_TTL_SECONDS)) {
|
|
115
|
+
throw new Error("Result stash capacity exhausted");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Paging is advisory after a completed call, including an approved write.
|
|
120
|
+
// Neither backend prose nor a retry hint belongs in this successful result.
|
|
121
|
+
try {
|
|
122
|
+
results.warn();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Logging cannot change the call either.
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
truncated: true,
|
|
129
|
+
totalBytes,
|
|
130
|
+
hint: "Paging is unavailable. Use execute_code to reduce read-only results before returning them. Do not repeat a completed write to recover its result.",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
120
133
|
return {
|
|
121
134
|
truncated: true,
|
|
122
135
|
resultId: id,
|
|
@@ -141,7 +154,7 @@ async function guardEncoded(text, bytes, results, cap) {
|
|
|
141
154
|
truncated: false,
|
|
142
155
|
};
|
|
143
156
|
}
|
|
144
|
-
const notice = await stashResult(
|
|
157
|
+
const notice = await stashResult(bytes, results);
|
|
145
158
|
const head = dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)));
|
|
146
159
|
return {
|
|
147
160
|
result: {
|
|
@@ -166,8 +179,12 @@ async function guardValue(value, results, cap) {
|
|
|
166
179
|
const bytes = enc.encode(text);
|
|
167
180
|
if (bytes.length <= cap)
|
|
168
181
|
return { result: value, truncated: false };
|
|
182
|
+
const notice = await stashResult(bytes, results);
|
|
169
183
|
return {
|
|
170
|
-
result:
|
|
184
|
+
result: notice.resultId ? notice : {
|
|
185
|
+
...notice,
|
|
186
|
+
preview: dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length))),
|
|
187
|
+
},
|
|
171
188
|
truncated: true,
|
|
172
189
|
};
|
|
173
190
|
}
|
|
@@ -210,7 +227,7 @@ async function guardContent(content, results, cap) {
|
|
|
210
227
|
if (content.every((b) => b.type === "text")) {
|
|
211
228
|
return guardEncoded(text, bytes, results, cap);
|
|
212
229
|
}
|
|
213
|
-
const notice = await stashResult(
|
|
230
|
+
const notice = await stashResult(bytes, results);
|
|
214
231
|
return {
|
|
215
232
|
result: { content: [{ type: "text", text: JSON.stringify(notice) }] },
|
|
216
233
|
truncated: true,
|
|
@@ -259,7 +276,6 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
259
276
|
const invocation = new InvocationService(registry, catalog, opts.activity);
|
|
260
277
|
/** MCP adapter: shared invocation semantics plus MCP-only result shaping. */
|
|
261
278
|
async function runCall(call, source, options = {}) {
|
|
262
|
-
const results = registry.resultsStorage();
|
|
263
279
|
const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
|
|
264
280
|
const outcome = await invocation.invoke(call.address, call.args ?? {}, {
|
|
265
281
|
source,
|
|
@@ -272,6 +288,13 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
272
288
|
: {}),
|
|
273
289
|
unwrapResult: call.resultMode === "value",
|
|
274
290
|
processResult: async (result, resolved) => {
|
|
291
|
+
const results = {
|
|
292
|
+
set: (key, value, ttlSeconds) => registry.stashResult(key, value, ttlSeconds),
|
|
293
|
+
warn: () => registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger.warn("[connecta] result paging unavailable", {
|
|
294
|
+
connector: resolved.connector.id,
|
|
295
|
+
tool: resolved.toolName,
|
|
296
|
+
}),
|
|
297
|
+
};
|
|
275
298
|
// Result-size cap for THIS call: the connector's own override wins,
|
|
276
299
|
// then the deployment-wide value, then the built-in default (already
|
|
277
300
|
// folded into `globalCap`). Resolved per call so one request can
|
|
@@ -292,7 +315,14 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
292
315
|
}
|
|
293
316
|
if (resolved.connector.kind === "mcp") {
|
|
294
317
|
const mcpResult = result;
|
|
295
|
-
|
|
318
|
+
let content = mcpResult?.content ?? [];
|
|
319
|
+
if (!content.some((block) => block.type === "text") &&
|
|
320
|
+
mcpResult?.structuredContent !== undefined) {
|
|
321
|
+
content = [...content, {
|
|
322
|
+
type: "text",
|
|
323
|
+
text: JSON.stringify(mcpResult.structuredContent),
|
|
324
|
+
}];
|
|
325
|
+
}
|
|
296
326
|
const guarded = await guardContent(content, results, cap);
|
|
297
327
|
return processed(guarded.result, guarded.truncated);
|
|
298
328
|
}
|
|
@@ -305,12 +335,15 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
305
335
|
if (!outcome.ok) {
|
|
306
336
|
const structuredRecovery = outcome.error.nextAction !== undefined;
|
|
307
337
|
const recoveryRequired = structuredRecovery ||
|
|
338
|
+
// Sanitized `unavailable` diagnostics ride the structured shape too;
|
|
339
|
+
// the plain-text path would drop them (#539).
|
|
340
|
+
outcome.error.details !== undefined ||
|
|
308
341
|
[
|
|
309
342
|
"auth_required",
|
|
310
343
|
"invalid_args",
|
|
311
344
|
"input_required_unsupported",
|
|
312
345
|
].includes(outcome.error.code);
|
|
313
|
-
const
|
|
346
|
+
const makeFailedResult = () => recoveryRequired ||
|
|
314
347
|
call.resultMode === "value"
|
|
315
348
|
? jsonResult({
|
|
316
349
|
ok: false,
|
|
@@ -320,6 +353,18 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
320
353
|
...(call.diagnostics ? { timing: outcome.timing } : {}),
|
|
321
354
|
})
|
|
322
355
|
: errorResult(outcome.error.message);
|
|
356
|
+
let failedResult = makeFailedResult();
|
|
357
|
+
// Value mode repeats the error in text and structuredContent. Account for
|
|
358
|
+
// both copies and JSON escaping when the bounded provider reason is large.
|
|
359
|
+
if (!recoveryRequired) {
|
|
360
|
+
const cap = resolveMaxResultBytes(outcome.resolved?.connector.maxResultBytes, globalCap);
|
|
361
|
+
let budget = 512;
|
|
362
|
+
while (enc.encode(JSON.stringify(failedResult)).length > cap && budget > 0) {
|
|
363
|
+
budget = Math.floor(budget / 2);
|
|
364
|
+
outcome.error.message = boundedEchoText(outcome.error.message, budget);
|
|
365
|
+
failedResult = makeFailedResult();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
323
368
|
if (recoveryRequired) {
|
|
324
369
|
failedResult.isError = true;
|
|
325
370
|
}
|
|
@@ -372,6 +417,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
372
417
|
return { content: [{ type: "text", text: skill.content }] };
|
|
373
418
|
},
|
|
374
419
|
async searchTools(args) {
|
|
420
|
+
if (args.connector !== undefined && enc.encode(args.connector).length > 512) {
|
|
421
|
+
return discoveryErrorResult(new DiscoveryPolicyError("invalid_args", "connector must be at most 512 UTF-8 bytes."));
|
|
422
|
+
}
|
|
375
423
|
return discoveryResult(async () => groupedSearchResult(await catalog.search({
|
|
376
424
|
...args,
|
|
377
425
|
includeSchemaKeys: args.includeSchemas !== undefined,
|
|
@@ -401,29 +449,60 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
401
449
|
`>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
|
|
402
450
|
}
|
|
403
451
|
const results = registry.resultsStorage();
|
|
404
|
-
|
|
452
|
+
let stored;
|
|
453
|
+
try {
|
|
454
|
+
stored = await results.get(`result:${args.id}`);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return {
|
|
458
|
+
...jsonResult({
|
|
459
|
+
error: {
|
|
460
|
+
code: "unavailable",
|
|
461
|
+
message: "Result paging storage is unavailable.",
|
|
462
|
+
retryable: true,
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
isError: true,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
405
468
|
if (stored === null || stored === undefined) {
|
|
406
|
-
return errorResult(`Unknown or expired result id "${args.id}"`);
|
|
469
|
+
return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
|
|
470
|
+
}
|
|
471
|
+
const requestedOffset = args.offset ?? 0;
|
|
472
|
+
const maxBytes = args.maxBytes ?? globalCap;
|
|
473
|
+
// Decode only this page plus UTF-8 boundary lookaround. Legacy raw-text
|
|
474
|
+
// entries remain readable for their short TTL after an upgrade.
|
|
475
|
+
const header = /^connecta-result-v1:(\d+):/.exec(stored.slice(0, 64));
|
|
476
|
+
let bytes;
|
|
477
|
+
let total;
|
|
478
|
+
let start = 0;
|
|
479
|
+
if (header) {
|
|
480
|
+
total = Number(header[1]);
|
|
481
|
+
start = Math.floor(Math.max(0, Math.min(requestedOffset, total) - 3) / 3) * 3;
|
|
482
|
+
const end = Math.min(total, requestedOffset + maxBytes + 4);
|
|
483
|
+
const binary = atob(stored.slice(header[0].length + start / 3 * 4, header[0].length + Math.ceil(end / 3) * 4));
|
|
484
|
+
bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
bytes = enc.encode(stored);
|
|
488
|
+
total = bytes.length;
|
|
407
489
|
}
|
|
408
|
-
const bytes = enc.encode(stored);
|
|
409
|
-
const total = bytes.length;
|
|
410
490
|
// Validated above, so no coercion is needed here — only alignment. A
|
|
411
491
|
// client that computes its own offsets can land inside a multi-byte
|
|
412
492
|
// character, which would decode as U+FFFD; the offset actually served is
|
|
413
493
|
// the boundary at or before it, and it is what the response reports back
|
|
414
494
|
// as `offset` (issue #38).
|
|
415
|
-
const offset = alignStartToCharBoundary(bytes,
|
|
495
|
+
const offset = start + alignStartToCharBoundary(bytes, requestedOffset - start);
|
|
416
496
|
// Page size only: a stashed result carries no connector identity, so
|
|
417
497
|
// get_result keeps the deployment-wide default when none is requested.
|
|
418
498
|
// Both sides are validated by now — the argument above, `globalCap` at
|
|
419
499
|
// intake — so `offset + maxBytes` always reaches past `offset`.
|
|
420
|
-
const maxBytes = args.maxBytes ?? globalCap;
|
|
421
500
|
// Align the slice end to a codepoint boundary so a multi-byte char is
|
|
422
501
|
// never split across pages (which would emit U+FFFD on both sides).
|
|
423
502
|
// `nextOffset` is this aligned end, so it is a valid boundary for the
|
|
424
503
|
// next call and paging reassembles the original byte-for-byte.
|
|
425
|
-
const end = alignEndToCharBoundary(bytes, offset, offset + maxBytes, total);
|
|
426
|
-
const slice = dec.decode(bytes.
|
|
504
|
+
const end = start + alignEndToCharBoundary(bytes, offset - start, offset - start + maxBytes, total - start);
|
|
505
|
+
const slice = dec.decode(bytes.subarray(offset - start, end - start));
|
|
427
506
|
const nextOffset = end < total ? end : undefined;
|
|
428
507
|
return jsonResult({
|
|
429
508
|
offset,
|
|
@@ -435,7 +514,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
435
514
|
async authorizeConnector(args) {
|
|
436
515
|
const connector = registry.getConnector(args.connector);
|
|
437
516
|
if (!connector) {
|
|
438
|
-
return errorResult(`Unknown connector "${args.connector}"`);
|
|
517
|
+
return errorResult(`Unknown connector "${boundedEchoText(args.connector)}"`);
|
|
439
518
|
}
|
|
440
519
|
if (!connector.startAuth) {
|
|
441
520
|
if (!connector.credential) {
|
package/dist/registry.d.ts
CHANGED
|
@@ -61,6 +61,10 @@ export interface RegistryOptions {
|
|
|
61
61
|
* to the default 50_000.
|
|
62
62
|
*/
|
|
63
63
|
maxResultBytes?: number | undefined;
|
|
64
|
+
results?: {
|
|
65
|
+
maxStashBytes?: number;
|
|
66
|
+
maxStashEntries?: number;
|
|
67
|
+
} | undefined;
|
|
64
68
|
/**
|
|
65
69
|
* Where payload-free catalog-drift observations go. Present only when the
|
|
66
70
|
* deployment configured an activity store; drift is reported through
|
|
@@ -103,6 +107,8 @@ export interface RegistryView {
|
|
|
103
107
|
signal?: AbortSignal;
|
|
104
108
|
}): Promise<CallAdmissionPermit>;
|
|
105
109
|
resultsStorage(): KVStorage;
|
|
110
|
+
/** Reserve runtime-wide capacity before writing a paging envelope. */
|
|
111
|
+
stashResult(key: string, value: string, ttlSeconds: number): Promise<boolean>;
|
|
106
112
|
/** Local declared-vs-stored credential mismatch, with no downstream I/O. */
|
|
107
113
|
credentialDriftFor(id: string): Promise<string | undefined>;
|
|
108
114
|
/** Value-free shape learned from successful calls, never a provider declaration. */
|
|
@@ -156,9 +162,13 @@ export declare class Registry implements RegistryView {
|
|
|
156
162
|
private readonly persistToolCatalog;
|
|
157
163
|
/** Result-size guard cap threaded to the meta-tools. */
|
|
158
164
|
readonly maxResultBytes: number;
|
|
165
|
+
/** Only keys, byte counts, and expiry survive requests; never write promises. */
|
|
166
|
+
private readonly resultStash;
|
|
167
|
+
private resultStashBytes;
|
|
159
168
|
private readonly configuredConnectors;
|
|
160
169
|
private readonly personalRegistries;
|
|
161
|
-
|
|
170
|
+
private callAdmissionClosed;
|
|
171
|
+
/** Bounded FIFO of absent grants already warned about. */
|
|
162
172
|
private readonly warnedAbsentGrants;
|
|
163
173
|
constructor(connectors: Connector[], opts: RegistryOptions);
|
|
164
174
|
personalRegistry(principalKey: string): Registry;
|
|
@@ -202,7 +212,7 @@ export declare class Registry implements RegistryView {
|
|
|
202
212
|
args: unknown;
|
|
203
213
|
signal?: AbortSignal;
|
|
204
214
|
}): Promise<CallAdmissionPermit>;
|
|
205
|
-
/**
|
|
215
|
+
/** Connector totals across root and personal controllers; health removes ids. */
|
|
206
216
|
callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot>;
|
|
207
217
|
/**
|
|
208
218
|
* Payload-free drift counts for the open health endpoint, so `connecta
|
|
@@ -230,6 +240,8 @@ export declare class Registry implements RegistryView {
|
|
|
230
240
|
private observeCatalogDrift;
|
|
231
241
|
/** Reject queued/future downstream admission; active permits release safely. */
|
|
232
242
|
closeCallAdmission(): void;
|
|
243
|
+
/** Reserve capacity and write one ASCII paging envelope in this runtime. */
|
|
244
|
+
stashResult(key: string, value: string, ttlSeconds: number, prefix?: string): Promise<boolean>;
|
|
233
245
|
/**
|
|
234
246
|
* Storage namespaced to the meta-tool result store (`results:` prefix), kept
|
|
235
247
|
* separate from any connector's `conn:<id>:` namespace. Backs get_result.
|