@zackbart/connecta 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/README.md +34 -14
- package/SECURITY.md +1 -1
- package/dist/connectors/api.d.ts +10 -0
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +11 -1
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +14 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +29 -0
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credentials.d.ts +2 -1
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +4 -2
- package/dist/credentials.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +32 -4
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -1
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +3 -0
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +47 -6
- 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 +29 -5
- package/dist/server.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +19 -3
- package/dist/storage/file.js.map +1 -1
- package/dist/ui.d.ts +1 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +13 -9
- package/dist/ui.js.map +1 -1
- package/dist/validate.d.ts +33 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +32 -2
- package/dist/validate.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/connectors/api.ts +21 -1
- package/src/connectors/remote-mcp.ts +52 -0
- package/src/credentials.ts +6 -3
- package/src/executors/quickjs.ts +32 -4
- package/src/index.ts +72 -1
- package/src/meta-tools.ts +90 -16
- package/src/server.ts +32 -5
- package/src/storage/file.ts +18 -2
- package/src/ui.ts +13 -8
- package/src/validate.ts +60 -2
- package/src/version.ts +1 -1
package/src/meta-tools.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "./errors.js";
|
|
16
16
|
import type { Registry } from "./registry.js";
|
|
17
17
|
import { AVAILABLE_SKILLS } from "./skills.js";
|
|
18
|
-
import type { KVStorage, ToolDef } from "./types.js";
|
|
18
|
+
import type { ConnectorStatus, KVStorage, ToolDef } from "./types.js";
|
|
19
19
|
|
|
20
20
|
interface TextContent {
|
|
21
21
|
type: "text";
|
|
@@ -72,6 +72,42 @@ function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
|
|
72
72
|
return Math.max(1, Math.trunc(value));
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Generous default bound for a single downstream probe/catalog call in the
|
|
77
|
+
* list/search/describe fan-out. High enough to trip only on a pathological
|
|
78
|
+
* hang, not a realistically slow probe.
|
|
79
|
+
*/
|
|
80
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reject `promise` after `ms` if it has not settled, so one hung downstream
|
|
84
|
+
* cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
|
|
85
|
+
* the registry probe methods take no AbortSignal, so the underlying fetch is
|
|
86
|
+
* NOT cancelled and keeps running in the background. Real cancellation
|
|
87
|
+
* (AbortSignal plumbed through the registry) is a deferred follow-up.
|
|
88
|
+
*/
|
|
89
|
+
function withTimeout<T>(
|
|
90
|
+
promise: Promise<T>,
|
|
91
|
+
ms: number,
|
|
92
|
+
label: string,
|
|
93
|
+
): Promise<T> {
|
|
94
|
+
return new Promise<T>((resolve, reject) => {
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
97
|
+
}, ms);
|
|
98
|
+
promise.then(
|
|
99
|
+
(value) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
resolve(value);
|
|
102
|
+
},
|
|
103
|
+
(err) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
reject(err);
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
75
111
|
/**
|
|
76
112
|
* How long to wait before the next attempt, or `undefined` for "don't retry".
|
|
77
113
|
*
|
|
@@ -314,11 +350,15 @@ export function createMetaTools(
|
|
|
314
350
|
maxResultBytes?: number;
|
|
315
351
|
/** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
|
|
316
352
|
defaultToolTimeoutMs?: number;
|
|
353
|
+
/** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
|
|
354
|
+
probeTimeoutMs?: number;
|
|
317
355
|
activity?: ActivityRequestContext;
|
|
318
356
|
} = {},
|
|
319
357
|
) {
|
|
320
358
|
const cap = opts.maxResultBytes ?? registry.maxResultBytes;
|
|
321
359
|
const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
|
|
360
|
+
const probeTimeoutMs =
|
|
361
|
+
normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
322
362
|
// createMetaTools() is called once per inbound MCP request. Sharing this
|
|
323
363
|
// identity lets remote connectors reuse one downstream client inside that
|
|
324
364
|
// request without leaking request-bound I/O into the next one.
|
|
@@ -622,25 +662,45 @@ export function createMetaTools(
|
|
|
622
662
|
const checkedAt = new Date().toISOString();
|
|
623
663
|
const statusStarted = Date.now();
|
|
624
664
|
const observed = registry.healthFor(c.id);
|
|
625
|
-
let status
|
|
626
|
-
|
|
627
|
-
:
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
665
|
+
let status:
|
|
666
|
+
| ConnectorStatus
|
|
667
|
+
| { state: "ok" | "error" | "unknown"; message?: string };
|
|
668
|
+
if (probe) {
|
|
669
|
+
try {
|
|
670
|
+
status = await withTimeout(
|
|
671
|
+
registry.statusFor(c.id, baseUrl, requestScope),
|
|
672
|
+
probeTimeoutMs,
|
|
673
|
+
`list_connectors probe of "${c.id}"`,
|
|
674
|
+
);
|
|
675
|
+
} catch (err) {
|
|
676
|
+
// A probe that outran probeTimeoutMs (or otherwise threw)
|
|
677
|
+
// degrades this connector to an error status rather than
|
|
678
|
+
// hanging the whole list_connectors call.
|
|
679
|
+
status = { state: "error", message: msg(err) };
|
|
680
|
+
}
|
|
681
|
+
} else {
|
|
682
|
+
status = {
|
|
683
|
+
state:
|
|
684
|
+
observed?.consecutiveFailures &&
|
|
685
|
+
observed.consecutiveFailures > 0
|
|
686
|
+
? ("error" as const)
|
|
687
|
+
: observed?.lastSuccessAt || c.kind === "api"
|
|
688
|
+
? ("ok" as const)
|
|
689
|
+
: ("unknown" as const),
|
|
690
|
+
...(observed?.lastError ? { message: observed.lastError } : {}),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
637
693
|
let tools = registry.peekTools(c.id);
|
|
638
694
|
// An auth_required status may have just started OAuth. A second
|
|
639
695
|
// listTools probe would overwrite its state/verifier while returning
|
|
640
696
|
// the first (now stale) authorization URL.
|
|
641
697
|
if (probe && status.state === "ok") {
|
|
642
698
|
try {
|
|
643
|
-
tools = await
|
|
699
|
+
tools = await withTimeout(
|
|
700
|
+
registry.refreshTools(c.id, baseUrl, requestScope),
|
|
701
|
+
probeTimeoutMs,
|
|
702
|
+
`list_connectors catalog refresh of "${c.id}"`,
|
|
703
|
+
);
|
|
644
704
|
registry.recordSuccess(c.id, Date.now() - statusStarted);
|
|
645
705
|
} catch (err) {
|
|
646
706
|
status = { state: "error" as const, message: msg(err) };
|
|
@@ -687,7 +747,13 @@ export function createMetaTools(
|
|
|
687
747
|
order: number;
|
|
688
748
|
}> = [];
|
|
689
749
|
const catalogs = await Promise.allSettled(
|
|
690
|
-
conns.map((c) =>
|
|
750
|
+
conns.map((c) =>
|
|
751
|
+
withTimeout(
|
|
752
|
+
registry.getTools(c.id, baseUrl, requestScope),
|
|
753
|
+
probeTimeoutMs,
|
|
754
|
+
`search_tools probe of "${c.id}"`,
|
|
755
|
+
),
|
|
756
|
+
),
|
|
691
757
|
);
|
|
692
758
|
let orderBase = 0;
|
|
693
759
|
catalogs.forEach((catalog, connectorIndex) => {
|
|
@@ -791,7 +857,13 @@ export function createMetaTools(
|
|
|
791
857
|
),
|
|
792
858
|
];
|
|
793
859
|
const loaded = await Promise.allSettled(
|
|
794
|
-
connectorIds.map((id) =>
|
|
860
|
+
connectorIds.map((id) =>
|
|
861
|
+
withTimeout(
|
|
862
|
+
registry.getTools(id, baseUrl, requestScope),
|
|
863
|
+
probeTimeoutMs,
|
|
864
|
+
`describe_tools probe of "${id}"`,
|
|
865
|
+
),
|
|
866
|
+
),
|
|
795
867
|
);
|
|
796
868
|
const catalogs = new Map<string, ToolDef[] | Error>();
|
|
797
869
|
loaded.forEach((result, index) => {
|
|
@@ -1040,12 +1112,14 @@ export function registerMetaTools(
|
|
|
1040
1112
|
baseUrl: string;
|
|
1041
1113
|
maxResultBytes?: number;
|
|
1042
1114
|
defaultToolTimeoutMs?: number;
|
|
1115
|
+
probeTimeoutMs?: number;
|
|
1043
1116
|
activity?: ActivityRequestContext;
|
|
1044
1117
|
},
|
|
1045
1118
|
): void {
|
|
1046
1119
|
const mt = createMetaTools(registry, ctx.baseUrl, {
|
|
1047
1120
|
maxResultBytes: ctx.maxResultBytes,
|
|
1048
1121
|
defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
|
|
1122
|
+
probeTimeoutMs: ctx.probeTimeoutMs,
|
|
1049
1123
|
activity: ctx.activity,
|
|
1050
1124
|
});
|
|
1051
1125
|
|
package/src/server.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface ServerOptions {
|
|
|
49
49
|
deploymentInfo?: Record<string, unknown>;
|
|
50
50
|
/** Deadline for call_tool/batch_call calls that pass no timeoutMs. Off when unset. */
|
|
51
51
|
defaultToolTimeoutMs?: number;
|
|
52
|
+
/** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
|
|
53
|
+
probeTimeoutMs?: number;
|
|
52
54
|
/** When set, the execute_code meta-tool is registered on top of the nine. */
|
|
53
55
|
executor?: Executor;
|
|
54
56
|
/** Encrypted connector-credential storage backing the authenticated /ui controls. */
|
|
@@ -61,6 +63,14 @@ function msg(err: unknown): string {
|
|
|
61
63
|
return err instanceof Error ? err.message : String(err);
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/** Per-request base64 nonce for the /ui page's inline scripts (Node 20+ and Workers). */
|
|
67
|
+
function uiScriptNonce(): string {
|
|
68
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
69
|
+
let binary = "";
|
|
70
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
71
|
+
return btoa(binary);
|
|
72
|
+
}
|
|
73
|
+
|
|
64
74
|
function escapeHtml(s: string): string {
|
|
65
75
|
return s
|
|
66
76
|
.replaceAll("&", "&")
|
|
@@ -231,9 +241,12 @@ function withSecurityHeaders(
|
|
|
231
241
|
headers.set("Strict-Transport-Security", "max-age=31536000");
|
|
232
242
|
}
|
|
233
243
|
if (path === "/ui") {
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
244
|
+
// The /ui GET response ships its own nonce-based script CSP (which already
|
|
245
|
+
// includes frame-ancestors 'none'); only fall back to the framing-only
|
|
246
|
+
// directive when no CSP is present (e.g. HTTPS redirects, error responses).
|
|
247
|
+
if (!headers.has("Content-Security-Policy")) {
|
|
248
|
+
headers.set("Content-Security-Policy", "frame-ancestors 'none'");
|
|
249
|
+
}
|
|
237
250
|
headers.set("X-Frame-Options", "DENY");
|
|
238
251
|
}
|
|
239
252
|
return new Response(response.body, {
|
|
@@ -523,6 +536,7 @@ async function serveMcp(
|
|
|
523
536
|
baseUrl,
|
|
524
537
|
activity,
|
|
525
538
|
defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
|
|
539
|
+
probeTimeoutMs: opts.probeTimeoutMs,
|
|
526
540
|
});
|
|
527
541
|
if (opts.executor) {
|
|
528
542
|
registerExecuteTool(server, opts.registry, {
|
|
@@ -693,9 +707,22 @@ export function createFetchHandler(
|
|
|
693
707
|
// Open shell — carries no data; data comes only from the gated /ui/data.
|
|
694
708
|
const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
|
|
695
709
|
const mcpUrl = new URL("/mcp", baseUrl).toString();
|
|
696
|
-
|
|
710
|
+
// Nonce the page's inline script (and the Clerk loader). 'strict-dynamic'
|
|
711
|
+
// lets scripts the nonced Clerk loader injects at runtime execute; the
|
|
712
|
+
// https:/'unsafe-inline' fallbacks are ignored by CSP3 browsers that
|
|
713
|
+
// honour the nonce and only cover legacy ones. No default-src, so Clerk's
|
|
714
|
+
// style/font/network needs and the page's inline <style> stay unrestricted
|
|
715
|
+
// — only script execution, the XSS sink, is gated.
|
|
716
|
+
const nonce = uiScriptNonce();
|
|
717
|
+
return new Response(renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce), {
|
|
697
718
|
status: 200,
|
|
698
|
-
headers: {
|
|
719
|
+
headers: {
|
|
720
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
721
|
+
"Content-Security-Policy":
|
|
722
|
+
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
|
|
723
|
+
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
724
|
+
"X-Content-Type-Options": "nosniff",
|
|
725
|
+
},
|
|
699
726
|
});
|
|
700
727
|
}
|
|
701
728
|
|
package/src/storage/file.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
chmodSync,
|
|
2
3
|
existsSync,
|
|
3
4
|
mkdirSync,
|
|
4
5
|
readFileSync,
|
|
@@ -28,8 +29,20 @@ export function fileStorage(
|
|
|
28
29
|
opts: FileStorageOptions = {},
|
|
29
30
|
): KVStorage {
|
|
30
31
|
const logger: Logger = opts.logger ?? console;
|
|
32
|
+
// The state file holds downstream OAuth access/refresh tokens in cleartext,
|
|
33
|
+
// so keep it owner-only. Repair is best-effort: chmod is a no-op or throws on
|
|
34
|
+
// non-POSIX filesystems, and a loose mode must never keep the store from
|
|
35
|
+
// starting.
|
|
36
|
+
const tighten = () => {
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(path, 0o600);
|
|
39
|
+
} catch {
|
|
40
|
+
// Non-POSIX filesystem or a race on the file — leave the mode as-is.
|
|
41
|
+
}
|
|
42
|
+
};
|
|
31
43
|
let data: Record<string, Entry> = {};
|
|
32
44
|
if (existsSync(path)) {
|
|
45
|
+
tighten();
|
|
33
46
|
try {
|
|
34
47
|
data = JSON.parse(readFileSync(path, "utf8")) as Record<string, Entry>;
|
|
35
48
|
} catch (error) {
|
|
@@ -60,10 +73,13 @@ export function fileStorage(
|
|
|
60
73
|
}
|
|
61
74
|
const persist = () => {
|
|
62
75
|
const dir = dirname(path);
|
|
63
|
-
if (dir) mkdirSync(dir, { recursive: true });
|
|
76
|
+
if (dir) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
64
77
|
const tmp = `${path}.tmp`;
|
|
65
|
-
|
|
78
|
+
// 0o600 on the tmp file; the atomic rename below preserves it, so the live
|
|
79
|
+
// state file is never briefly world-readable.
|
|
80
|
+
writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
66
81
|
renameSync(tmp, path);
|
|
82
|
+
tighten();
|
|
67
83
|
};
|
|
68
84
|
const fresh = (key: string): Entry | null => {
|
|
69
85
|
const e = data[key];
|
package/src/ui.ts
CHANGED
|
@@ -29,15 +29,16 @@ export function resolveBranding(
|
|
|
29
29
|
): ResolvedBranding {
|
|
30
30
|
const productName = branding?.productName?.trim() || "Connecta";
|
|
31
31
|
const ownerName = branding?.ownerName?.trim();
|
|
32
|
+
// Operator branding URLs become masthead/callback hrefs, so a non-http(s)
|
|
33
|
+
// scheme (javascript:, data:) is dropped the same as an unset URL — the
|
|
34
|
+
// callers already render a <span> instead of an <a> when it is absent.
|
|
35
|
+
const productUrl = branding?.productUrl?.trim();
|
|
36
|
+
const ownerUrl = branding?.ownerUrl?.trim();
|
|
32
37
|
return {
|
|
33
38
|
productName,
|
|
34
|
-
...(
|
|
35
|
-
? { productUrl: branding.productUrl.trim() }
|
|
36
|
-
: {}),
|
|
39
|
+
...(productUrl && isSafeHttpUrl(productUrl) ? { productUrl } : {}),
|
|
37
40
|
...(ownerName ? { ownerName } : {}),
|
|
38
|
-
...(
|
|
39
|
-
? { ownerUrl: branding.ownerUrl.trim() }
|
|
40
|
-
: {}),
|
|
41
|
+
...(ownerUrl && isSafeHttpUrl(ownerUrl) ? { ownerUrl } : {}),
|
|
41
42
|
description:
|
|
42
43
|
branding?.description?.trim() ||
|
|
43
44
|
`Manage the services this ${productName} instance makes available to agents.`,
|
|
@@ -302,10 +303,14 @@ export function renderUiHtml(
|
|
|
302
303
|
uiAuth?: UiAuthConfig,
|
|
303
304
|
mcpUrl = "/mcp",
|
|
304
305
|
branding?: ConnectaBranding,
|
|
306
|
+
nonce?: string,
|
|
305
307
|
): string {
|
|
306
308
|
const auth = uiAuth ?? { kind: "bearer" as const };
|
|
307
309
|
const brand = resolveBranding(branding);
|
|
308
310
|
const title = brand.pageTitle;
|
|
311
|
+
// When the /ui response ships a nonce-based CSP, every <script> it emits must
|
|
312
|
+
// carry that nonce to run; without a nonce the markup is unchanged.
|
|
313
|
+
const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
|
|
309
314
|
// Top-left corner. With an owner set it reads "<owner> <product>"; without
|
|
310
315
|
// one the product label stands alone. Either half links out when the
|
|
311
316
|
// matching URL is configured.
|
|
@@ -323,7 +328,7 @@ export function renderUiHtml(
|
|
|
323
328
|
: "";
|
|
324
329
|
const clerkScript =
|
|
325
330
|
uiAuth?.kind === "clerk"
|
|
326
|
-
? `<script defer crossorigin="anonymous" data-clerk-publishable-key="${escapeHtmlAttr(uiAuth.publishableKey)}" src="${escapeHtmlAttr(uiAuth.frontendApiUrl)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js"></script>`
|
|
331
|
+
? `<script${nonceAttr} defer crossorigin="anonymous" data-clerk-publishable-key="${escapeHtmlAttr(uiAuth.publishableKey)}" src="${escapeHtmlAttr(uiAuth.frontendApiUrl)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js"></script>`
|
|
327
332
|
: "";
|
|
328
333
|
|
|
329
334
|
return `<!doctype html>
|
|
@@ -683,7 +688,7 @@ ${clerkScript}
|
|
|
683
688
|
</section>
|
|
684
689
|
</main>
|
|
685
690
|
|
|
686
|
-
<script>
|
|
691
|
+
<script${nonceAttr}>
|
|
687
692
|
const AUTH = ${jsonForInlineScript(auth)};
|
|
688
693
|
const MCP_URL = ${jsonForInlineScript(mcpUrl)};
|
|
689
694
|
const filterUiConnectors = ${filterUiConnectors.toString()};
|
package/src/validate.ts
CHANGED
|
@@ -13,6 +13,28 @@ export interface ValidateToolInputOptions {
|
|
|
13
13
|
* unusable. Default console.
|
|
14
14
|
*/
|
|
15
15
|
logger?: Logger;
|
|
16
|
+
/**
|
|
17
|
+
* Fail-closed on a schema the validator cannot evaluate (default false =
|
|
18
|
+
* today's fail-open behavior). When true, a schema that cannot be compiled —
|
|
19
|
+
* or that only fails on first use, e.g. an unresolvable `$ref` — yields a
|
|
20
|
+
* non-retryable `invalid_args` error instead of passing the raw arguments
|
|
21
|
+
* through, so unvalidated input is never silently admitted. The happy path
|
|
22
|
+
* (a schema that compiles and validates) is unaffected.
|
|
23
|
+
*/
|
|
24
|
+
failClosed?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PrecompileValidatorOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Tool address used in the warning text, conventionally
|
|
30
|
+
* `"connectorId.toolName"`.
|
|
31
|
+
*/
|
|
32
|
+
address: string;
|
|
33
|
+
/**
|
|
34
|
+
* Destination for the warning emitted when the schema cannot be compiled.
|
|
35
|
+
* Default console.
|
|
36
|
+
*/
|
|
37
|
+
logger?: Logger;
|
|
16
38
|
}
|
|
17
39
|
|
|
18
40
|
// Lazy validator cache keyed by the schema object itself; null marks a schema
|
|
@@ -21,6 +43,13 @@ export interface ValidateToolInputOptions {
|
|
|
21
43
|
// connector are collectable, the same pattern compactSchema uses.
|
|
22
44
|
const validators = new WeakMap<JsonSchema, Validator | null>();
|
|
23
45
|
|
|
46
|
+
function unevaluableSchema(address: string): ConnectorCallError {
|
|
47
|
+
return new ConnectorCallError(
|
|
48
|
+
"invalid_args",
|
|
49
|
+
`Cannot validate arguments for "${address}": its inputSchema could not be evaluated`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
24
53
|
function disableValidation(
|
|
25
54
|
schema: JsonSchema,
|
|
26
55
|
address: string,
|
|
@@ -47,7 +76,9 @@ function disableValidation(
|
|
|
47
76
|
*
|
|
48
77
|
* A schema the validator cannot compile (or that only fails on first use, e.g.
|
|
49
78
|
* an unresolvable `$ref`) is warned about once and then passed through — a
|
|
50
|
-
* broken schema should not break an otherwise working tool.
|
|
79
|
+
* broken schema should not break an otherwise working tool. Pass
|
|
80
|
+
* `failClosed: true` to instead reject such calls with `invalid_args`, for
|
|
81
|
+
* callers that would rather refuse a call than forward unvalidated arguments.
|
|
51
82
|
*
|
|
52
83
|
* The compiled validator is cached by **schema object identity**, so pass a
|
|
53
84
|
* stable object: hold the parsed manifest and hand the same schema back on
|
|
@@ -74,12 +105,18 @@ export function validateToolInput(
|
|
|
74
105
|
validator = null;
|
|
75
106
|
}
|
|
76
107
|
}
|
|
108
|
+
// A schema the validator could not compile (or that a prior call disabled):
|
|
109
|
+
// pass through by default, refuse when the caller opted into fail-closed.
|
|
110
|
+
if (validator === null) {
|
|
111
|
+
return opts.failClosed ? unevaluableSchema(opts.address) : null;
|
|
112
|
+
}
|
|
77
113
|
let result;
|
|
78
114
|
try {
|
|
79
|
-
result = validator
|
|
115
|
+
result = validator.validate(args);
|
|
80
116
|
} catch (err) {
|
|
81
117
|
// e.g. an unresolvable $ref — surfaces on first validate, not compile.
|
|
82
118
|
disableValidation(schema, opts.address, logger, err);
|
|
119
|
+
return opts.failClosed ? unevaluableSchema(opts.address) : null;
|
|
83
120
|
}
|
|
84
121
|
if (result && !result.valid) {
|
|
85
122
|
const units = result.errors.filter((u) => u.instanceLocation !== "#");
|
|
@@ -94,3 +131,24 @@ export function validateToolInput(
|
|
|
94
131
|
}
|
|
95
132
|
return null;
|
|
96
133
|
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Eagerly compile and cache a tool's inputSchema so a schema the validator
|
|
137
|
+
* cannot use surfaces once at connector construction rather than silently on
|
|
138
|
+
* the first call. Reuses the same module-level cache `validateToolInput` reads,
|
|
139
|
+
* so the runtime path hits the cache. Warning-only: it never throws and never
|
|
140
|
+
* changes call behavior. A schema that only fails on first `validate()` (e.g.
|
|
141
|
+
* an unresolvable `$ref`) still slips through here and is caught at call time.
|
|
142
|
+
*/
|
|
143
|
+
export function precompileValidator(
|
|
144
|
+
schema: JsonSchema,
|
|
145
|
+
opts: PrecompileValidatorOptions,
|
|
146
|
+
): void {
|
|
147
|
+
if (validators.has(schema)) return;
|
|
148
|
+
const logger = opts.logger ?? console;
|
|
149
|
+
try {
|
|
150
|
+
validators.set(schema, new Validator(schema as never, "2020-12", false));
|
|
151
|
+
} catch (err) {
|
|
152
|
+
disableValidation(schema, opts.address, logger, err);
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/version.ts
CHANGED