@zackbart/connecta 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +62 -0
- package/dist/catalog.js.map +1 -1
- package/dist/connectors/api.d.ts +14 -0
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +18 -1
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +22 -8
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +4 -1
- package/dist/credentials.js.map +1 -1
- package/dist/errors.d.ts +53 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +89 -0
- package/dist/errors.js.map +1 -0
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +10 -6
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/json-schema.d.ts +3 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +6 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/meta-tools.d.ts +31 -2
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +75 -31
- package/dist/meta-tools.js.map +1 -1
- package/dist/node.d.ts +1 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +5 -1
- package/dist/server.js.map +1 -1
- package/dist/storage/file.d.ts +6 -2
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +3 -2
- package/dist/storage/file.js.map +1 -1
- package/dist/validate.d.ts +39 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +66 -0
- package/dist/validate.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +8 -1
- package/src/catalog.ts +61 -0
- package/src/connectors/api.ts +25 -1
- package/src/connectors/remote-mcp.ts +32 -13
- package/src/credentials.ts +4 -1
- package/src/errors.ts +126 -0
- package/src/execute.ts +13 -6
- package/src/index.ts +24 -0
- package/src/json-schema.ts +11 -0
- package/src/meta-tools.ts +102 -46
- package/src/node.ts +1 -0
- package/src/server.ts +7 -1
- package/src/storage/file.ts +12 -3
- package/src/validate.ts +96 -0
- package/src/version.ts +1 -1
package/src/meta-tools.ts
CHANGED
|
@@ -7,6 +7,12 @@ import {
|
|
|
7
7
|
type ActivityRequestContext,
|
|
8
8
|
} from "./activity.js";
|
|
9
9
|
import { unwrapMcpResult } from "./mcp-result.js";
|
|
10
|
+
import {
|
|
11
|
+
classifyCallError,
|
|
12
|
+
ConnectorCallError,
|
|
13
|
+
messageLooksRetryable,
|
|
14
|
+
type CallErrorDetails,
|
|
15
|
+
} from "./errors.js";
|
|
10
16
|
import type { Registry } from "./registry.js";
|
|
11
17
|
import { AVAILABLE_SKILLS } from "./skills.js";
|
|
12
18
|
import type { KVStorage, ToolDef } from "./types.js";
|
|
@@ -45,21 +51,54 @@ const DEFAULT_SEARCH_LIMIT = 25;
|
|
|
45
51
|
const enc = new TextEncoder();
|
|
46
52
|
const dec = new TextDecoder();
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
type ErrorDetails = CallErrorDetails;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The longest the engine will park a synchronous inbound request in *waiting
|
|
58
|
+
* alone*. The engine already treats ~15 s as the outer bound of one reasonable
|
|
59
|
+
* connector call (EXECUTE_HOST_CALL_TIMEOUT_MS), so sleeping for minutes trades
|
|
60
|
+
* a fast, informative failure for a hung one. A connector-reported window this
|
|
61
|
+
* long isn't truncated — it's declined (see `retryBackoffMs`) and reported
|
|
62
|
+
* verbatim as `error.retryAfterMs`, so the agent, which can afford to wait,
|
|
63
|
+
* decides when to re-issue.
|
|
64
|
+
*/
|
|
65
|
+
export const MAX_RETRY_BACKOFF_MS = 10_000;
|
|
66
|
+
|
|
67
|
+
/** A finite, positive integer number of milliseconds, or undefined. */
|
|
68
|
+
function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
|
69
|
+
if (value === undefined || !Number.isFinite(value) || !(value > 0)) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return Math.max(1, Math.trunc(value));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How long to wait before the next attempt, or `undefined` for "don't retry".
|
|
77
|
+
*
|
|
78
|
+
* A connector that read a `Retry-After` header knows the window exactly, so it
|
|
79
|
+
* is honoured **exactly or not at all**: truncating an exponential *guess* is
|
|
80
|
+
* harmless, but truncating a *known* window means deliberately retrying inside
|
|
81
|
+
* a rate limit — the harm this channel exists to prevent. A window longer than
|
|
82
|
+
* `MAX_RETRY_BACKOFF_MS` therefore declines the retry rather than shortening
|
|
83
|
+
* it. (`retryAfterMs` is normalized non-negative, so `0` means "retry now".)
|
|
84
|
+
* Connectors that report no window keep the historical exponential guess.
|
|
85
|
+
*
|
|
86
|
+
* Waits are per attempt, matching the per-attempt `timeoutMs` race in
|
|
87
|
+
* `runCall`. Exported for direct testing.
|
|
88
|
+
*/
|
|
89
|
+
export function retryBackoffMs(
|
|
90
|
+
attempt: number,
|
|
91
|
+
retryAfterMs: number | undefined,
|
|
92
|
+
): number | undefined {
|
|
93
|
+
if (retryAfterMs === undefined) {
|
|
94
|
+
return Math.min(250 * 2 ** (attempt - 1), 1_000);
|
|
95
|
+
}
|
|
96
|
+
return retryAfterMs <= MAX_RETRY_BACKOFF_MS ? retryAfterMs : undefined;
|
|
52
97
|
}
|
|
53
98
|
|
|
99
|
+
/** Details for failures that never reached a connector (no thrown value). */
|
|
54
100
|
function errorDetails(code: string, message: string): ErrorDetails {
|
|
55
|
-
return {
|
|
56
|
-
code,
|
|
57
|
-
message,
|
|
58
|
-
retryable:
|
|
59
|
-
/timeout|timed out|econnreset|econnrefused|temporar|rate.?limit|429|502|503|504|refcountedcanceler|different request/i.test(
|
|
60
|
-
message,
|
|
61
|
-
),
|
|
62
|
-
};
|
|
101
|
+
return { code, message, retryable: messageLooksRetryable(message) };
|
|
63
102
|
}
|
|
64
103
|
|
|
65
104
|
/** True if `b` is a UTF-8 continuation byte (0b10xxxxxx). */
|
|
@@ -264,18 +303,22 @@ export interface SkillArgs {
|
|
|
264
303
|
/**
|
|
265
304
|
* The nine meta-tool handlers over a registry. Exported for direct testing;
|
|
266
305
|
* registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
|
|
267
|
-
* overrides the registry's default result-size cap.
|
|
268
|
-
*
|
|
306
|
+
* overrides the registry's default result-size cap; `opts.defaultToolTimeoutMs`
|
|
307
|
+
* supplies a deadline for calls that don't carry one. (execute_code, the
|
|
308
|
+
* optional tenth tool, is registered separately by registerExecuteTool.)
|
|
269
309
|
*/
|
|
270
310
|
export function createMetaTools(
|
|
271
311
|
registry: Registry,
|
|
272
312
|
baseUrl: string,
|
|
273
313
|
opts: {
|
|
274
314
|
maxResultBytes?: number;
|
|
315
|
+
/** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
|
|
316
|
+
defaultToolTimeoutMs?: number;
|
|
275
317
|
activity?: ActivityRequestContext;
|
|
276
318
|
} = {},
|
|
277
319
|
) {
|
|
278
320
|
const cap = opts.maxResultBytes ?? registry.maxResultBytes;
|
|
321
|
+
const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
|
|
279
322
|
// createMetaTools() is called once per inbound MCP request. Sharing this
|
|
280
323
|
// identity lets remote connectors reuse one downstream client inside that
|
|
281
324
|
// request without leaking request-bound I/O into the next one.
|
|
@@ -332,11 +375,10 @@ export function createMetaTools(
|
|
|
332
375
|
...(errorCode ? { errorCode } : {}),
|
|
333
376
|
});
|
|
334
377
|
};
|
|
335
|
-
const failed = (
|
|
378
|
+
const failed = (error: ErrorDetails): RunCallOutcome => {
|
|
336
379
|
const durationMs = Date.now() - started;
|
|
337
|
-
const error = errorDetails(code, message);
|
|
338
380
|
const diagnostics = timing();
|
|
339
|
-
record(code === "timeout" ? "timeout" : "error", code);
|
|
381
|
+
record(error.code === "timeout" ? "timeout" : "error", error.code);
|
|
340
382
|
return {
|
|
341
383
|
toolResult:
|
|
342
384
|
call.resultMode === "value"
|
|
@@ -347,7 +389,7 @@ export function createMetaTools(
|
|
|
347
389
|
attempts,
|
|
348
390
|
...(call.diagnostics ? { timing: diagnostics } : {}),
|
|
349
391
|
})
|
|
350
|
-
: errorResult(message),
|
|
392
|
+
: errorResult(error.message),
|
|
351
393
|
durationMs,
|
|
352
394
|
attempts,
|
|
353
395
|
timing: diagnostics,
|
|
@@ -355,14 +397,15 @@ export function createMetaTools(
|
|
|
355
397
|
};
|
|
356
398
|
};
|
|
357
399
|
if (!resolved) {
|
|
358
|
-
return failed(
|
|
400
|
+
return failed(
|
|
401
|
+
errorDetails("unknown_address", `Unknown address "${call.address}"`),
|
|
402
|
+
);
|
|
359
403
|
}
|
|
360
404
|
const results = registry.resultsStorage();
|
|
361
405
|
const fields = call.fields && call.fields.length > 0 ? call.fields : null;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
: undefined;
|
|
406
|
+
// An explicit per-call deadline always wins; the config default only fills
|
|
407
|
+
// the gap, and stays off entirely when the deployment sets none.
|
|
408
|
+
const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
|
|
366
409
|
const maxRetries = Math.min(
|
|
367
410
|
2,
|
|
368
411
|
Math.max(0, Math.trunc(call.maxRetries ?? 0)),
|
|
@@ -375,13 +418,17 @@ export function createMetaTools(
|
|
|
375
418
|
).find((tool) => tool.name === resolved.toolName);
|
|
376
419
|
} catch (err) {
|
|
377
420
|
catalogMs += Date.now() - catalogStarted;
|
|
378
|
-
|
|
421
|
+
// classifyCallError so a typed auth_required thrown while listing tools
|
|
422
|
+
// (e.g. a revoked downstream OAuth grant) keeps its code.
|
|
423
|
+
return failed(classifyCallError(err, "catalog_lookup_failed"));
|
|
379
424
|
}
|
|
380
425
|
catalogMs += Date.now() - catalogStarted;
|
|
381
426
|
if (!definition) {
|
|
382
427
|
return failed(
|
|
383
|
-
|
|
384
|
-
|
|
428
|
+
errorDetails(
|
|
429
|
+
"unknown_tool",
|
|
430
|
+
`Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
|
|
431
|
+
),
|
|
385
432
|
);
|
|
386
433
|
}
|
|
387
434
|
const explicitlyReadOnly =
|
|
@@ -389,8 +436,10 @@ export function createMetaTools(
|
|
|
389
436
|
definition.annotations?.destructiveHint !== true;
|
|
390
437
|
if (!explicitlyReadOnly && !options.allowDestructive) {
|
|
391
438
|
return failed(
|
|
392
|
-
|
|
393
|
-
|
|
439
|
+
errorDetails(
|
|
440
|
+
"destructive_tool_requires_approval",
|
|
441
|
+
`Tool "${call.address}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`,
|
|
442
|
+
),
|
|
394
443
|
);
|
|
395
444
|
}
|
|
396
445
|
const retrySafe =
|
|
@@ -420,7 +469,12 @@ export function createMetaTools(
|
|
|
420
469
|
pending,
|
|
421
470
|
new Promise<never>((_, reject) => {
|
|
422
471
|
timer = setTimeout(() => {
|
|
423
|
-
reject(
|
|
472
|
+
reject(
|
|
473
|
+
new ConnectorCallError(
|
|
474
|
+
"timeout",
|
|
475
|
+
`Tool call timed out after ${timeoutMs}ms`,
|
|
476
|
+
),
|
|
477
|
+
);
|
|
424
478
|
controller?.abort();
|
|
425
479
|
}, timeoutMs);
|
|
426
480
|
}),
|
|
@@ -441,26 +495,27 @@ export function createMetaTools(
|
|
|
441
495
|
} catch (err) {
|
|
442
496
|
// Includes connector setup, downstream execution, and timeout wait.
|
|
443
497
|
connectorMs += Date.now() - connectorStarted;
|
|
444
|
-
const details =
|
|
498
|
+
const details = classifyCallError(err);
|
|
445
499
|
if (attempts <= maxRetries && retrySafe && details.retryable) {
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
500
|
+
const wait = retryBackoffMs(attempts, details.retryAfterMs);
|
|
501
|
+
if (wait !== undefined) {
|
|
502
|
+
const backoffStarted = Date.now();
|
|
503
|
+
if (wait > 0) {
|
|
504
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
505
|
+
}
|
|
506
|
+
backoffMs += Date.now() - backoffStarted;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
// The reported window is longer than the engine will park a
|
|
510
|
+
// synchronous request for. Fall through to failure with
|
|
511
|
+
// retryAfterMs reported verbatim so the agent can re-issue.
|
|
452
512
|
}
|
|
453
513
|
registry.recordFailure(
|
|
454
514
|
resolved.connector.id,
|
|
455
515
|
Date.now() - started,
|
|
456
516
|
err,
|
|
457
517
|
);
|
|
458
|
-
return failed(
|
|
459
|
-
/timed out|timeout/i.test(msg(err))
|
|
460
|
-
? "timeout"
|
|
461
|
-
: "connector_call_failed",
|
|
462
|
-
msg(err),
|
|
463
|
-
);
|
|
518
|
+
return failed(details);
|
|
464
519
|
} finally {
|
|
465
520
|
if (timer) clearTimeout(timer);
|
|
466
521
|
}
|
|
@@ -531,7 +586,7 @@ export function createMetaTools(
|
|
|
531
586
|
};
|
|
532
587
|
} catch (err) {
|
|
533
588
|
resultProcessingMs += Date.now() - processingStarted;
|
|
534
|
-
return failed("result_processing_failed", msg(err));
|
|
589
|
+
return failed(errorDetails("result_processing_failed", msg(err)));
|
|
535
590
|
}
|
|
536
591
|
}
|
|
537
592
|
|
|
@@ -846,12 +901,11 @@ export function createMetaTools(
|
|
|
846
901
|
const results = settled.map((s, i) => {
|
|
847
902
|
const address = args.calls[i].address;
|
|
848
903
|
if (s.status === "rejected") {
|
|
849
|
-
const message = msg(s.reason);
|
|
850
904
|
return {
|
|
851
905
|
address,
|
|
852
906
|
ok: false,
|
|
853
|
-
error:
|
|
854
|
-
errorDetails:
|
|
907
|
+
error: msg(s.reason),
|
|
908
|
+
errorDetails: classifyCallError(s.reason, "batch_call_failed"),
|
|
855
909
|
};
|
|
856
910
|
}
|
|
857
911
|
const r = s.value;
|
|
@@ -985,11 +1039,13 @@ export function registerMetaTools(
|
|
|
985
1039
|
ctx: {
|
|
986
1040
|
baseUrl: string;
|
|
987
1041
|
maxResultBytes?: number;
|
|
1042
|
+
defaultToolTimeoutMs?: number;
|
|
988
1043
|
activity?: ActivityRequestContext;
|
|
989
1044
|
},
|
|
990
1045
|
): void {
|
|
991
1046
|
const mt = createMetaTools(registry, ctx.baseUrl, {
|
|
992
1047
|
maxResultBytes: ctx.maxResultBytes,
|
|
1048
|
+
defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
|
|
993
1049
|
activity: ctx.activity,
|
|
994
1050
|
});
|
|
995
1051
|
|
package/src/node.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
5
5
|
import type { Connecta } from "./index.js";
|
|
6
6
|
|
|
7
7
|
export { fileStorage } from "./storage/file.js";
|
|
8
|
+
export type { FileStorageOptions } from "./storage/file.js";
|
|
8
9
|
|
|
9
10
|
/** 10 MiB. Tool arguments are JSON; nothing legitimate approaches this. */
|
|
10
11
|
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
|
package/src/server.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface ServerOptions {
|
|
|
47
47
|
activityReadGate?: ActivityReadGate;
|
|
48
48
|
activityDeploymentId?: string;
|
|
49
49
|
deploymentInfo?: Record<string, unknown>;
|
|
50
|
+
/** Deadline for call_tool/batch_call calls that pass no timeoutMs. Off when unset. */
|
|
51
|
+
defaultToolTimeoutMs?: number;
|
|
50
52
|
/** When set, the execute_code meta-tool is registered on top of the nine. */
|
|
51
53
|
executor?: Executor;
|
|
52
54
|
/** Encrypted connector-credential storage backing the authenticated /ui controls. */
|
|
@@ -517,7 +519,11 @@ async function serveMcp(
|
|
|
517
519
|
logger: opts.logger,
|
|
518
520
|
}
|
|
519
521
|
: undefined;
|
|
520
|
-
registerMetaTools(server, opts.registry, {
|
|
522
|
+
registerMetaTools(server, opts.registry, {
|
|
523
|
+
baseUrl,
|
|
524
|
+
activity,
|
|
525
|
+
defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
|
|
526
|
+
});
|
|
521
527
|
if (opts.executor) {
|
|
522
528
|
registerExecuteTool(server, opts.registry, {
|
|
523
529
|
baseUrl,
|
package/src/storage/file.ts
CHANGED
|
@@ -6,19 +6,28 @@ import {
|
|
|
6
6
|
writeFileSync,
|
|
7
7
|
} from "node:fs";
|
|
8
8
|
import { dirname } from "node:path";
|
|
9
|
-
import type { KVStorage } from "../types.js";
|
|
9
|
+
import type { KVStorage, Logger } from "../types.js";
|
|
10
10
|
|
|
11
11
|
interface Entry {
|
|
12
12
|
value: string;
|
|
13
13
|
exp?: number; // epoch ms
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface FileStorageOptions {
|
|
17
|
+
/** Destination for the corrupt-state-file recovery report. Default console. */
|
|
18
|
+
logger?: Logger;
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
/**
|
|
17
22
|
* JSON-file-backed KVStorage for Node. Loads once, persists on every write via
|
|
18
23
|
* a temp-file + rename (atomic-ish). Only reachable via the "@zackbart/connecta/node"
|
|
19
24
|
* subpath so the main entry stays Workers-clean.
|
|
20
25
|
*/
|
|
21
|
-
export function fileStorage(
|
|
26
|
+
export function fileStorage(
|
|
27
|
+
path: string,
|
|
28
|
+
opts: FileStorageOptions = {},
|
|
29
|
+
): KVStorage {
|
|
30
|
+
const logger: Logger = opts.logger ?? console;
|
|
22
31
|
let data: Record<string, Entry> = {};
|
|
23
32
|
if (existsSync(path)) {
|
|
24
33
|
try {
|
|
@@ -39,7 +48,7 @@ export function fileStorage(path: string): KVStorage {
|
|
|
39
48
|
`than overwrite it. Move or repair the file, then restart.`,
|
|
40
49
|
);
|
|
41
50
|
}
|
|
42
|
-
|
|
51
|
+
logger.error(
|
|
43
52
|
`[connecta] state file ${path} is not valid JSON ` +
|
|
44
53
|
`(${error instanceof Error ? error.message : String(error)}) — ` +
|
|
45
54
|
`moved to ${quarantine}, starting from empty state. Downstream ` +
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Validator } from "@cfworker/json-schema";
|
|
2
|
+
import { ConnectorCallError } from "./errors.js";
|
|
3
|
+
import type { JsonSchema, Logger } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface ValidateToolInputOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Tool address used in the error and warning text, conventionally
|
|
8
|
+
* `"connectorId.toolName"`.
|
|
9
|
+
*/
|
|
10
|
+
address: string;
|
|
11
|
+
/**
|
|
12
|
+
* Destination for the one-time warning emitted when a schema turns out to be
|
|
13
|
+
* unusable. Default console.
|
|
14
|
+
*/
|
|
15
|
+
logger?: Logger;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Lazy validator cache keyed by the schema object itself; null marks a schema
|
|
19
|
+
// the validator rejected (warned once, then passed through rather than
|
|
20
|
+
// breaking a working tool). A WeakMap so schemas belonging to a discarded
|
|
21
|
+
// connector are collectable, the same pattern compactSchema uses.
|
|
22
|
+
const validators = new WeakMap<JsonSchema, Validator | null>();
|
|
23
|
+
|
|
24
|
+
function disableValidation(
|
|
25
|
+
schema: JsonSchema,
|
|
26
|
+
address: string,
|
|
27
|
+
logger: Logger,
|
|
28
|
+
err: unknown,
|
|
29
|
+
): void {
|
|
30
|
+
validators.set(schema, null);
|
|
31
|
+
logger.warn(
|
|
32
|
+
`[connecta] tool "${address}" has an inputSchema the validator cannot use (${
|
|
33
|
+
err instanceof Error ? err.message : String(err)
|
|
34
|
+
}) — arguments are not validated`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate call arguments against a tool's JSON Schema.
|
|
40
|
+
*
|
|
41
|
+
* Returns a non-retryable `invalid_args` ConnectorCallError describing the
|
|
42
|
+
* mismatch, or null when the arguments are acceptable. It deliberately returns
|
|
43
|
+
* rather than throws: the caller decides what to do with the failure, which is
|
|
44
|
+
* what lets a connector own its error prose, or strip connector-wide
|
|
45
|
+
* convention arguments (a `confirm` flag on writes, say) that individual tool
|
|
46
|
+
* schemas do not declare before deciding the call is really invalid.
|
|
47
|
+
*
|
|
48
|
+
* A schema the validator cannot compile (or that only fails on first use, e.g.
|
|
49
|
+
* an unresolvable `$ref`) is warned about once and then passed through — a
|
|
50
|
+
* broken schema should not break an otherwise working tool.
|
|
51
|
+
*
|
|
52
|
+
* The compiled validator is cached by **schema object identity**, so pass a
|
|
53
|
+
* stable object: hold the parsed manifest and hand the same schema back on
|
|
54
|
+
* every call. A schema rebuilt per call is a cache miss every time — it still
|
|
55
|
+
* validates correctly, but recompiles the validator on each call, silently and
|
|
56
|
+
* with nothing to show for it but latency.
|
|
57
|
+
*
|
|
58
|
+
* `api()` uses this internally; it is exported for connectors that implement
|
|
59
|
+
* the `Connector` interface directly.
|
|
60
|
+
*/
|
|
61
|
+
export function validateToolInput(
|
|
62
|
+
schema: JsonSchema,
|
|
63
|
+
args: unknown,
|
|
64
|
+
opts: ValidateToolInputOptions,
|
|
65
|
+
): ConnectorCallError | null {
|
|
66
|
+
const logger = opts.logger ?? console;
|
|
67
|
+
let validator = validators.get(schema);
|
|
68
|
+
if (validator === undefined) {
|
|
69
|
+
try {
|
|
70
|
+
validator = new Validator(schema as never, "2020-12", false);
|
|
71
|
+
validators.set(schema, validator);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
disableValidation(schema, opts.address, logger, err);
|
|
74
|
+
validator = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = validator?.validate(args);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// e.g. an unresolvable $ref — surfaces on first validate, not compile.
|
|
82
|
+
disableValidation(schema, opts.address, logger, err);
|
|
83
|
+
}
|
|
84
|
+
if (result && !result.valid) {
|
|
85
|
+
const units = result.errors.filter((u) => u.instanceLocation !== "#");
|
|
86
|
+
const detail = (units.length > 0 ? units : result.errors)
|
|
87
|
+
.slice(0, 3)
|
|
88
|
+
.map((u) => `${u.instanceLocation}: ${u.error}`)
|
|
89
|
+
.join("; ");
|
|
90
|
+
return new ConnectorCallError(
|
|
91
|
+
"invalid_args",
|
|
92
|
+
`Invalid arguments for "${opts.address}": ${detail || "input does not match the tool's inputSchema"}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
package/src/version.ts
CHANGED