@zackbart/connecta 0.3.0 → 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 +60 -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.map +1 -1
- package/dist/connectors/api.js +7 -40
- package/dist/connectors/api.js.map +1 -1
- package/dist/errors.d.ts +20 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +39 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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 +57 -9
- package/dist/meta-tools.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/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 +5 -1
- package/src/catalog.ts +61 -0
- package/src/connectors/api.ts +6 -51
- package/src/errors.ts +45 -2
- package/src/index.ts +22 -0
- package/src/json-schema.ts +11 -0
- package/src/meta-tools.ts +66 -12
- package/src/server.ts +7 -1
- package/src/validate.ts +96 -0
- package/src/version.ts +1 -1
package/src/connectors/api.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { ConnectorCallError } from "../errors.js";
|
|
1
|
+
import { validateToolInput } from "../validate.js";
|
|
3
2
|
import type {
|
|
4
3
|
Connector,
|
|
5
4
|
ConnectorCredentialConfig,
|
|
@@ -74,34 +73,6 @@ export function api(id: string, opts: ApiOptions): Connector {
|
|
|
74
73
|
}));
|
|
75
74
|
const byName = new Map(opts.tools.map((t) => [t.name, t]));
|
|
76
75
|
const validateArgs = opts.validateArgs ?? true;
|
|
77
|
-
// Lazy per-tool validator cache; null marks a schema the validator rejected
|
|
78
|
-
// (warned once, then passed through rather than breaking a working tool).
|
|
79
|
-
const validators = new Map<string, Validator | null>();
|
|
80
|
-
const disableValidation = (
|
|
81
|
-
tool: ApiTool,
|
|
82
|
-
ctx: ConnectorContext,
|
|
83
|
-
err: unknown,
|
|
84
|
-
) => {
|
|
85
|
-
validators.set(tool.name, null);
|
|
86
|
-
ctx.logger.warn(
|
|
87
|
-
`[connecta] tool "${id}.${tool.name}" has an inputSchema the validator cannot use (${
|
|
88
|
-
err instanceof Error ? err.message : String(err)
|
|
89
|
-
}) — arguments are not validated`,
|
|
90
|
-
);
|
|
91
|
-
};
|
|
92
|
-
const validatorFor = (tool: ApiTool, ctx: ConnectorContext) => {
|
|
93
|
-
let validator = validators.get(tool.name);
|
|
94
|
-
if (validator === undefined) {
|
|
95
|
-
try {
|
|
96
|
-
validator = new Validator(tool.inputSchema as never, "2020-12", false);
|
|
97
|
-
validators.set(tool.name, validator);
|
|
98
|
-
} catch (err) {
|
|
99
|
-
disableValidation(tool, ctx, err);
|
|
100
|
-
validator = null;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return validator;
|
|
104
|
-
};
|
|
105
76
|
return {
|
|
106
77
|
id,
|
|
107
78
|
title: opts.title,
|
|
@@ -121,27 +92,11 @@ export function api(id: string, opts: ApiOptions): Connector {
|
|
|
121
92
|
}
|
|
122
93
|
const input = args ?? {};
|
|
123
94
|
if (validateArgs && tool.inputSchema) {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// e.g. an unresolvable $ref — surfaces on first validate, not compile.
|
|
130
|
-
disableValidation(tool, ctx, err);
|
|
131
|
-
}
|
|
132
|
-
if (result && !result.valid) {
|
|
133
|
-
const units = result.errors.filter(
|
|
134
|
-
(u) => u.instanceLocation !== "#",
|
|
135
|
-
);
|
|
136
|
-
const detail = (units.length > 0 ? units : result.errors)
|
|
137
|
-
.slice(0, 3)
|
|
138
|
-
.map((u) => `${u.instanceLocation}: ${u.error}`)
|
|
139
|
-
.join("; ");
|
|
140
|
-
throw new ConnectorCallError(
|
|
141
|
-
"invalid_args",
|
|
142
|
-
`Invalid arguments for "${id}.${name}": ${detail || "input does not match the tool's inputSchema"}`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
95
|
+
const invalid = validateToolInput(tool.inputSchema, input, {
|
|
96
|
+
address: `${id}.${name}`,
|
|
97
|
+
logger: ctx.logger,
|
|
98
|
+
});
|
|
99
|
+
if (invalid) throw invalid;
|
|
145
100
|
}
|
|
146
101
|
return tool.handler(input, ctx);
|
|
147
102
|
},
|
package/src/errors.ts
CHANGED
|
@@ -19,6 +19,13 @@ const RETRYABLE_BY_CODE: Record<ConnectorCallErrorCode, boolean> = {
|
|
|
19
19
|
connector_call_failed: false,
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
/** Non-negative integer milliseconds, or undefined for anything else. */
|
|
23
|
+
function normalizeRetryAfterMs(value: number | undefined): number | undefined {
|
|
24
|
+
if (value === undefined) return undefined;
|
|
25
|
+
if (!Number.isFinite(value) || value < 0) return undefined;
|
|
26
|
+
return Math.trunc(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
/**
|
|
23
30
|
* Throw from `Connector.callTool` (or anything beneath it) to classify a
|
|
24
31
|
* failure exactly. Untyped errors fall back to a message-text heuristic, so a
|
|
@@ -26,15 +33,28 @@ const RETRYABLE_BY_CODE: Record<ConnectorCallErrorCode, boolean> = {
|
|
|
26
33
|
* retryable timeout — this class is the escape hatch. `retryable` defaults per
|
|
27
34
|
* code (timeout, rate_limited, and unavailable retry; the rest do not) and may
|
|
28
35
|
* be overridden.
|
|
36
|
+
*
|
|
37
|
+
* `retryAfterMs` carries a wait window the connector already knows — a
|
|
38
|
+
* `Retry-After` header, say — so the engine can wait that long instead of
|
|
39
|
+
* guessing, and so an agent that receives the failure can decide when to
|
|
40
|
+
* re-issue.
|
|
29
41
|
*/
|
|
30
42
|
export class ConnectorCallError extends Error {
|
|
31
43
|
readonly code: ConnectorCallErrorCode;
|
|
32
44
|
readonly retryable: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Connector-known wait window in ms before this call is worth repeating,
|
|
47
|
+
* or undefined when the connector reported none. Always an own property —
|
|
48
|
+
* under ES2022 class fields the declaration itself defines it, so guarding
|
|
49
|
+
* the assignment would not keep it off the instance. Keeping the window out
|
|
50
|
+
* of the wire format is `classifyCallError`'s job, not this constructor's.
|
|
51
|
+
*/
|
|
52
|
+
readonly retryAfterMs?: number;
|
|
33
53
|
|
|
34
54
|
constructor(
|
|
35
55
|
code: ConnectorCallErrorCode,
|
|
36
56
|
message: string,
|
|
37
|
-
opts: { retryable?: boolean; cause?: unknown } = {},
|
|
57
|
+
opts: { retryable?: boolean; retryAfterMs?: number; cause?: unknown } = {},
|
|
38
58
|
) {
|
|
39
59
|
super(
|
|
40
60
|
message,
|
|
@@ -43,6 +63,7 @@ export class ConnectorCallError extends Error {
|
|
|
43
63
|
this.name = "ConnectorCallError";
|
|
44
64
|
this.code = code;
|
|
45
65
|
this.retryable = opts.retryable ?? RETRYABLE_BY_CODE[code];
|
|
66
|
+
this.retryAfterMs = normalizeRetryAfterMs(opts.retryAfterMs);
|
|
46
67
|
}
|
|
47
68
|
}
|
|
48
69
|
|
|
@@ -51,6 +72,12 @@ export interface CallErrorDetails {
|
|
|
51
72
|
code: string;
|
|
52
73
|
message: string;
|
|
53
74
|
retryable: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Connector-reported wait window in ms, when known. Reported verbatim — the
|
|
77
|
+
* engine bounds how long it will itself wait, but the caller sees the real
|
|
78
|
+
* window so it can schedule a re-issue.
|
|
79
|
+
*/
|
|
80
|
+
retryAfterMs?: number;
|
|
54
81
|
}
|
|
55
82
|
|
|
56
83
|
const RETRYABLE_MESSAGE_RE =
|
|
@@ -72,7 +99,23 @@ export function classifyCallError(
|
|
|
72
99
|
fallbackCode = "connector_call_failed",
|
|
73
100
|
): CallErrorDetails {
|
|
74
101
|
if (err instanceof ConnectorCallError) {
|
|
75
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
code: err.code,
|
|
104
|
+
message: err.message,
|
|
105
|
+
retryable: err.retryable,
|
|
106
|
+
...(err.retryAfterMs !== undefined
|
|
107
|
+
? { retryAfterMs: err.retryAfterMs }
|
|
108
|
+
: {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// An aborted fetch rejects with a DOMException named "AbortError" whose
|
|
112
|
+
// message ("The operation was aborted", and variants across runtimes) matches
|
|
113
|
+
// neither heuristic below — so a call the engine itself cancelled would read
|
|
114
|
+
// as a non-retryable failure, the opposite of the truth. Note this also
|
|
115
|
+
// covers an abort the connector triggered for its own reasons; running out of
|
|
116
|
+
// time is by far the likelier cause and retryable/timeout is the safer read.
|
|
117
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
118
|
+
return { code: "timeout", message: err.message, retryable: true };
|
|
76
119
|
}
|
|
77
120
|
const message = err instanceof Error ? err.message : String(err);
|
|
78
121
|
return {
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,22 @@ export interface ConnectaConfig {
|
|
|
61
61
|
* stash the full text for get_result paging. Default 50_000.
|
|
62
62
|
*/
|
|
63
63
|
maxResultBytes?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Deadline (ms) applied to call_tool/batch_call calls that pass no
|
|
66
|
+
* `timeoutMs`, giving the connector both a budget (`ctx.timeoutMs`) and a
|
|
67
|
+
* cancellation signal (`ctx.signal`). An explicit per-call `timeoutMs` always
|
|
68
|
+
* wins. **Opt-in — undefined by default**, because switching it on globally
|
|
69
|
+
* would put a deadline on every call in an existing deployment and the
|
|
70
|
+
* failure mode is a working long-running call starting to time out.
|
|
71
|
+
* `execute_code` host calls are unaffected; they already carry a 15 s bound.
|
|
72
|
+
*
|
|
73
|
+
* Bounds a single attempt, not the whole call — the same as an explicit
|
|
74
|
+
* `timeoutMs` has always done. A call that also passes `maxRetries` can
|
|
75
|
+
* therefore run to roughly `(maxRetries + 1)` times this value plus backoff.
|
|
76
|
+
* `maxRetries` defaults to 0, so this is the total for every call that does
|
|
77
|
+
* not explicitly ask to retry.
|
|
78
|
+
*/
|
|
79
|
+
defaultToolTimeoutMs?: number;
|
|
64
80
|
serverInfo?: {
|
|
65
81
|
name?: string;
|
|
66
82
|
version?: string;
|
|
@@ -141,6 +157,7 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
141
157
|
activityReadGate: config.activityReadGate,
|
|
142
158
|
activityDeploymentId: config.activityDeploymentId,
|
|
143
159
|
executor: config.executor,
|
|
160
|
+
defaultToolTimeoutMs: config.defaultToolTimeoutMs,
|
|
144
161
|
credentialVault,
|
|
145
162
|
deploymentInfo: config.deploymentInfo,
|
|
146
163
|
branding: config.branding,
|
|
@@ -161,6 +178,11 @@ export { remoteMcp } from "./connectors/remote-mcp.js";
|
|
|
161
178
|
export { api } from "./connectors/api.js";
|
|
162
179
|
export { ConnectorCallError } from "./errors.js";
|
|
163
180
|
export type { ConnectorCallErrorCode, CallErrorDetails } from "./errors.js";
|
|
181
|
+
// The same argument validation api() performs, usable by connectors that
|
|
182
|
+
// implement the Connector interface directly. Returns the error rather than
|
|
183
|
+
// throwing so the caller decides what to do with it.
|
|
184
|
+
export { validateToolInput } from "./validate.js";
|
|
185
|
+
export type { ValidateToolInputOptions } from "./validate.js";
|
|
164
186
|
export { bearerToken } from "./auth/bearer.js";
|
|
165
187
|
export { memoryStorage } from "./storage/memory.js";
|
|
166
188
|
export { CONNECTA_VERSION } from "./version.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Public re-export of the JSON Schema validator connecta itself uses, so
|
|
2
|
+
// downstream code that validates at build time (a manifest generator asserting
|
|
3
|
+
// its own output, say) resolves the same implementation and version through an
|
|
4
|
+
// explicit subpath rather than through npm hoisting.
|
|
5
|
+
export { Validator } from "@cfworker/json-schema";
|
|
6
|
+
export type {
|
|
7
|
+
OutputUnit,
|
|
8
|
+
Schema,
|
|
9
|
+
SchemaDraft,
|
|
10
|
+
ValidationResult,
|
|
11
|
+
} from "@cfworker/json-schema";
|
package/src/meta-tools.ts
CHANGED
|
@@ -53,6 +53,49 @@ const dec = new TextDecoder();
|
|
|
53
53
|
|
|
54
54
|
type ErrorDetails = CallErrorDetails;
|
|
55
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;
|
|
97
|
+
}
|
|
98
|
+
|
|
56
99
|
/** Details for failures that never reached a connector (no thrown value). */
|
|
57
100
|
function errorDetails(code: string, message: string): ErrorDetails {
|
|
58
101
|
return { code, message, retryable: messageLooksRetryable(message) };
|
|
@@ -260,18 +303,22 @@ export interface SkillArgs {
|
|
|
260
303
|
/**
|
|
261
304
|
* The nine meta-tool handlers over a registry. Exported for direct testing;
|
|
262
305
|
* registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
|
|
263
|
-
* overrides the registry's default result-size cap.
|
|
264
|
-
*
|
|
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.)
|
|
265
309
|
*/
|
|
266
310
|
export function createMetaTools(
|
|
267
311
|
registry: Registry,
|
|
268
312
|
baseUrl: string,
|
|
269
313
|
opts: {
|
|
270
314
|
maxResultBytes?: number;
|
|
315
|
+
/** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
|
|
316
|
+
defaultToolTimeoutMs?: number;
|
|
271
317
|
activity?: ActivityRequestContext;
|
|
272
318
|
} = {},
|
|
273
319
|
) {
|
|
274
320
|
const cap = opts.maxResultBytes ?? registry.maxResultBytes;
|
|
321
|
+
const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
|
|
275
322
|
// createMetaTools() is called once per inbound MCP request. Sharing this
|
|
276
323
|
// identity lets remote connectors reuse one downstream client inside that
|
|
277
324
|
// request without leaking request-bound I/O into the next one.
|
|
@@ -356,10 +403,9 @@ export function createMetaTools(
|
|
|
356
403
|
}
|
|
357
404
|
const results = registry.resultsStorage();
|
|
358
405
|
const fields = call.fields && call.fields.length > 0 ? call.fields : null;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
: 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;
|
|
363
409
|
const maxRetries = Math.min(
|
|
364
410
|
2,
|
|
365
411
|
Math.max(0, Math.trunc(call.maxRetries ?? 0)),
|
|
@@ -451,12 +497,18 @@ export function createMetaTools(
|
|
|
451
497
|
connectorMs += Date.now() - connectorStarted;
|
|
452
498
|
const details = classifyCallError(err);
|
|
453
499
|
if (attempts <= maxRetries && retrySafe && details.retryable) {
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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.
|
|
460
512
|
}
|
|
461
513
|
registry.recordFailure(
|
|
462
514
|
resolved.connector.id,
|
|
@@ -987,11 +1039,13 @@ export function registerMetaTools(
|
|
|
987
1039
|
ctx: {
|
|
988
1040
|
baseUrl: string;
|
|
989
1041
|
maxResultBytes?: number;
|
|
1042
|
+
defaultToolTimeoutMs?: number;
|
|
990
1043
|
activity?: ActivityRequestContext;
|
|
991
1044
|
},
|
|
992
1045
|
): void {
|
|
993
1046
|
const mt = createMetaTools(registry, ctx.baseUrl, {
|
|
994
1047
|
maxResultBytes: ctx.maxResultBytes,
|
|
1048
|
+
defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
|
|
995
1049
|
activity: ctx.activity,
|
|
996
1050
|
});
|
|
997
1051
|
|
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/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