@zackbart/connecta 0.3.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 +143 -0
- package/README.md +34 -14
- package/SECURITY.md +1 -1
- 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 +10 -0
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +16 -39
- 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/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/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 +34 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -1
- 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 +34 -2
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +104 -15
- package/dist/meta-tools.js.map +1 -1
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +34 -6
- 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 +71 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +96 -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 +25 -50
- package/src/connectors/remote-mcp.ts +52 -0
- package/src/credentials.ts +6 -3
- package/src/errors.ts +45 -2
- package/src/executors/quickjs.ts +32 -4
- package/src/index.ts +94 -1
- package/src/json-schema.ts +11 -0
- package/src/meta-tools.ts +156 -28
- package/src/server.ts +39 -6
- package/src/storage/file.ts +18 -2
- package/src/ui.ts +13 -8
- package/src/validate.ts +154 -0
- package/src/version.ts +1 -1
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/executors/quickjs.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface QuickJsExecutorOptions {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const MAX_LOG_ENTRIES = 200;
|
|
35
|
+
// Cap each entry AND the cumulative buffer at capture time so untrusted guest
|
|
36
|
+
// code can't retain unbounded host memory: a single `console.log("x".repeat(N))`
|
|
37
|
+
// otherwise copies the whole N-char guest string into a host array we hold for
|
|
38
|
+
// the entire execution. 8k chars/entry is generous for glue-code logging (the
|
|
39
|
+
// join in execute.ts trims the assembled log to 4k anyway), and 256k total
|
|
40
|
+
// keeps the worst case — 200 maxed-out entries — bounded well under a MiB.
|
|
41
|
+
const MAX_LOG_ENTRY_CHARS = 8_000;
|
|
42
|
+
const MAX_LOG_TOTAL_CHARS = 256_000;
|
|
35
43
|
|
|
36
44
|
function msg(err: unknown): string {
|
|
37
45
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -123,12 +131,32 @@ function installBridge(
|
|
|
123
131
|
};
|
|
124
132
|
armWake(bridge);
|
|
125
133
|
|
|
134
|
+
// Running total of chars actually retained in `logs`; once the cumulative
|
|
135
|
+
// budget is spent we push one marker and drop the rest, so a flood of large
|
|
136
|
+
// entries can't grow the host array without bound.
|
|
137
|
+
let logTotalChars = 0;
|
|
138
|
+
let logBudgetSpent = false;
|
|
126
139
|
const logFn = ctx.newFunction("__log", (h) => {
|
|
127
|
-
if (logs.length
|
|
128
|
-
logs.
|
|
129
|
-
|
|
130
|
-
|
|
140
|
+
if (logs.length >= MAX_LOG_ENTRIES) {
|
|
141
|
+
if (logs.length === MAX_LOG_ENTRIES) {
|
|
142
|
+
logs.push(`[log truncated after ${MAX_LOG_ENTRIES} entries]`);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (logBudgetSpent) return;
|
|
147
|
+
// Cap the entry before retaining it: `getString` yields a transient copy,
|
|
148
|
+
// but slicing here keeps only a bounded string alive in `logs`.
|
|
149
|
+
let entry = ctx.getString(h);
|
|
150
|
+
if (entry.length > MAX_LOG_ENTRY_CHARS) {
|
|
151
|
+
entry = `${entry.slice(0, MAX_LOG_ENTRY_CHARS)}…[entry truncated]`;
|
|
152
|
+
}
|
|
153
|
+
if (logTotalChars + entry.length > MAX_LOG_TOTAL_CHARS) {
|
|
154
|
+
logs.push("[log truncated: size budget exceeded]");
|
|
155
|
+
logBudgetSpent = true;
|
|
156
|
+
return;
|
|
131
157
|
}
|
|
158
|
+
logs.push(entry);
|
|
159
|
+
logTotalChars += entry.length;
|
|
132
160
|
});
|
|
133
161
|
ctx.setProp(ctx.global, "__log", logFn);
|
|
134
162
|
logFn.dispose();
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,38 @@ 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;
|
|
80
|
+
/**
|
|
81
|
+
* Deadline (ms) applied to each individual downstream probe/catalog call that
|
|
82
|
+
* the discovery meta-tools fan out — `list_connectors` (with `probe`),
|
|
83
|
+
* `search_tools`, and `describe_tools` — so a single hung connector can no
|
|
84
|
+
* longer stall the whole meta-tool call. **Defaults to a generous 30_000**,
|
|
85
|
+
* chosen to trip only on a pathological hang, not on a realistically slow
|
|
86
|
+
* probe, so having it on by default will not break existing deployments.
|
|
87
|
+
* Bounds one downstream call, not the whole fan-out: a connector that outruns
|
|
88
|
+
* it degrades to an unavailable/errored entry while the rest are unaffected.
|
|
89
|
+
*
|
|
90
|
+
* Does NOT apply to `call_tool`/`batch_call` — those carry their own budget
|
|
91
|
+
* via `defaultToolTimeoutMs` or a per-call `timeoutMs`. Note this bounds the
|
|
92
|
+
* caller-facing wait only; the underlying fetch is not currently aborted, so
|
|
93
|
+
* real cancellation of the downstream request is a deferred follow-up.
|
|
94
|
+
*/
|
|
95
|
+
probeTimeoutMs?: number;
|
|
64
96
|
serverInfo?: {
|
|
65
97
|
name?: string;
|
|
66
98
|
version?: string;
|
|
@@ -106,6 +138,58 @@ function normalizeAuth(auth: ConnectaConfig["auth"]): InboundAuth[] {
|
|
|
106
138
|
});
|
|
107
139
|
}
|
|
108
140
|
|
|
141
|
+
/**
|
|
142
|
+
* One-time construction warnings for deployment shapes that run fine but are
|
|
143
|
+
* usually unintended. Warning-only — never throws and never changes behavior;
|
|
144
|
+
* each condition emits at most one `logger.warn`. Iterates connectors once.
|
|
145
|
+
*/
|
|
146
|
+
function warnInsecureConfig(
|
|
147
|
+
config: ConnectaConfig,
|
|
148
|
+
inboundAuth: InboundAuth[],
|
|
149
|
+
logger: Logger,
|
|
150
|
+
): void {
|
|
151
|
+
const oauthConnectors = config.connectors.filter((c) => c.finishAuth);
|
|
152
|
+
const hasCredentialConnector = config.connectors.some((c) => c.credential);
|
|
153
|
+
|
|
154
|
+
// Open mode (no inbound auth) with connectors that expose credentials or
|
|
155
|
+
// downstream OAuth: any caller reaches everything, including the vault.
|
|
156
|
+
if (
|
|
157
|
+
inboundAuth.length === 0 &&
|
|
158
|
+
(hasCredentialConnector || oauthConnectors.length > 0)
|
|
159
|
+
) {
|
|
160
|
+
logger.warn(
|
|
161
|
+
"[connecta] running with no inbound authentication: any caller can " +
|
|
162
|
+
"invoke every connector and read or overwrite stored credentials. " +
|
|
163
|
+
"Configure `auth` (for example bearerToken(...) or Clerk) to gate access.",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Unset publicUrl with OAuth connectors: the downstream redirect_uri is
|
|
168
|
+
// derived per-request from the attacker-influenced inbound Host header.
|
|
169
|
+
if (oauthConnectors.length > 0 && !config.publicUrl) {
|
|
170
|
+
logger.warn(
|
|
171
|
+
"[connecta] publicUrl is unset while OAuth connectors are configured: " +
|
|
172
|
+
"the downstream OAuth redirect_uri is derived per-request from the " +
|
|
173
|
+
"inbound Host header, so an attacker who controls that header can point " +
|
|
174
|
+
"it at their own host and capture the authorization code. Set " +
|
|
175
|
+
"`publicUrl` to a fixed https origin.",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// OAuth connectors whose callback performs no state/CSRF check: the public
|
|
180
|
+
// /oauth/callback/<id> route would exchange any delivered code.
|
|
181
|
+
for (const connector of oauthConnectors) {
|
|
182
|
+
if (!connector.verifyState) {
|
|
183
|
+
logger.warn(
|
|
184
|
+
`[connecta] connector "${connector.id}" has an OAuth callback with no ` +
|
|
185
|
+
`state/CSRF check: /oauth/callback/${connector.id} will exchange any ` +
|
|
186
|
+
"delivered code. Implement `verifyState` (the shipped remoteMcp " +
|
|
187
|
+
"connector already does).",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
109
193
|
export function createConnecta(config: ConnectaConfig): Connecta {
|
|
110
194
|
const storage = config.storage ?? memoryStorage();
|
|
111
195
|
const logger = config.logger ?? defaultLogger();
|
|
@@ -127,9 +211,11 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
127
211
|
toolCatalogStaleSeconds: config.toolCatalogStaleSeconds,
|
|
128
212
|
maxResultBytes: config.maxResultBytes,
|
|
129
213
|
});
|
|
214
|
+
const inboundAuth = normalizeAuth(config.auth);
|
|
215
|
+
warnInsecureConfig(config, inboundAuth, logger);
|
|
130
216
|
const handler = createFetchHandler({
|
|
131
217
|
registry,
|
|
132
|
-
auth:
|
|
218
|
+
auth: inboundAuth,
|
|
133
219
|
publicUrl: config.publicUrl,
|
|
134
220
|
serverInfo: {
|
|
135
221
|
...config.serverInfo,
|
|
@@ -141,6 +227,8 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
141
227
|
activityReadGate: config.activityReadGate,
|
|
142
228
|
activityDeploymentId: config.activityDeploymentId,
|
|
143
229
|
executor: config.executor,
|
|
230
|
+
defaultToolTimeoutMs: config.defaultToolTimeoutMs,
|
|
231
|
+
probeTimeoutMs: config.probeTimeoutMs,
|
|
144
232
|
credentialVault,
|
|
145
233
|
deploymentInfo: config.deploymentInfo,
|
|
146
234
|
branding: config.branding,
|
|
@@ -161,6 +249,11 @@ export { remoteMcp } from "./connectors/remote-mcp.js";
|
|
|
161
249
|
export { api } from "./connectors/api.js";
|
|
162
250
|
export { ConnectorCallError } from "./errors.js";
|
|
163
251
|
export type { ConnectorCallErrorCode, CallErrorDetails } from "./errors.js";
|
|
252
|
+
// The same argument validation api() performs, usable by connectors that
|
|
253
|
+
// implement the Connector interface directly. Returns the error rather than
|
|
254
|
+
// throwing so the caller decides what to do with it.
|
|
255
|
+
export { validateToolInput } from "./validate.js";
|
|
256
|
+
export type { ValidateToolInputOptions } from "./validate.js";
|
|
164
257
|
export { bearerToken } from "./auth/bearer.js";
|
|
165
258
|
export { memoryStorage } from "./storage/memory.js";
|
|
166
259
|
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
|
@@ -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";
|
|
@@ -53,6 +53,85 @@ 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
|
+
* 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
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How long to wait before the next attempt, or `undefined` for "don't retry".
|
|
113
|
+
*
|
|
114
|
+
* A connector that read a `Retry-After` header knows the window exactly, so it
|
|
115
|
+
* is honoured **exactly or not at all**: truncating an exponential *guess* is
|
|
116
|
+
* harmless, but truncating a *known* window means deliberately retrying inside
|
|
117
|
+
* a rate limit — the harm this channel exists to prevent. A window longer than
|
|
118
|
+
* `MAX_RETRY_BACKOFF_MS` therefore declines the retry rather than shortening
|
|
119
|
+
* it. (`retryAfterMs` is normalized non-negative, so `0` means "retry now".)
|
|
120
|
+
* Connectors that report no window keep the historical exponential guess.
|
|
121
|
+
*
|
|
122
|
+
* Waits are per attempt, matching the per-attempt `timeoutMs` race in
|
|
123
|
+
* `runCall`. Exported for direct testing.
|
|
124
|
+
*/
|
|
125
|
+
export function retryBackoffMs(
|
|
126
|
+
attempt: number,
|
|
127
|
+
retryAfterMs: number | undefined,
|
|
128
|
+
): number | undefined {
|
|
129
|
+
if (retryAfterMs === undefined) {
|
|
130
|
+
return Math.min(250 * 2 ** (attempt - 1), 1_000);
|
|
131
|
+
}
|
|
132
|
+
return retryAfterMs <= MAX_RETRY_BACKOFF_MS ? retryAfterMs : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
56
135
|
/** Details for failures that never reached a connector (no thrown value). */
|
|
57
136
|
function errorDetails(code: string, message: string): ErrorDetails {
|
|
58
137
|
return { code, message, retryable: messageLooksRetryable(message) };
|
|
@@ -260,18 +339,26 @@ export interface SkillArgs {
|
|
|
260
339
|
/**
|
|
261
340
|
* The nine meta-tool handlers over a registry. Exported for direct testing;
|
|
262
341
|
* registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
|
|
263
|
-
* overrides the registry's default result-size cap.
|
|
264
|
-
*
|
|
342
|
+
* overrides the registry's default result-size cap; `opts.defaultToolTimeoutMs`
|
|
343
|
+
* supplies a deadline for calls that don't carry one. (execute_code, the
|
|
344
|
+
* optional tenth tool, is registered separately by registerExecuteTool.)
|
|
265
345
|
*/
|
|
266
346
|
export function createMetaTools(
|
|
267
347
|
registry: Registry,
|
|
268
348
|
baseUrl: string,
|
|
269
349
|
opts: {
|
|
270
350
|
maxResultBytes?: number;
|
|
351
|
+
/** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
|
|
352
|
+
defaultToolTimeoutMs?: number;
|
|
353
|
+
/** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
|
|
354
|
+
probeTimeoutMs?: number;
|
|
271
355
|
activity?: ActivityRequestContext;
|
|
272
356
|
} = {},
|
|
273
357
|
) {
|
|
274
358
|
const cap = opts.maxResultBytes ?? registry.maxResultBytes;
|
|
359
|
+
const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
|
|
360
|
+
const probeTimeoutMs =
|
|
361
|
+
normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
275
362
|
// createMetaTools() is called once per inbound MCP request. Sharing this
|
|
276
363
|
// identity lets remote connectors reuse one downstream client inside that
|
|
277
364
|
// request without leaking request-bound I/O into the next one.
|
|
@@ -356,10 +443,9 @@ export function createMetaTools(
|
|
|
356
443
|
}
|
|
357
444
|
const results = registry.resultsStorage();
|
|
358
445
|
const fields = call.fields && call.fields.length > 0 ? call.fields : null;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
: undefined;
|
|
446
|
+
// An explicit per-call deadline always wins; the config default only fills
|
|
447
|
+
// the gap, and stays off entirely when the deployment sets none.
|
|
448
|
+
const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
|
|
363
449
|
const maxRetries = Math.min(
|
|
364
450
|
2,
|
|
365
451
|
Math.max(0, Math.trunc(call.maxRetries ?? 0)),
|
|
@@ -451,12 +537,18 @@ export function createMetaTools(
|
|
|
451
537
|
connectorMs += Date.now() - connectorStarted;
|
|
452
538
|
const details = classifyCallError(err);
|
|
453
539
|
if (attempts <= maxRetries && retrySafe && details.retryable) {
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
540
|
+
const wait = retryBackoffMs(attempts, details.retryAfterMs);
|
|
541
|
+
if (wait !== undefined) {
|
|
542
|
+
const backoffStarted = Date.now();
|
|
543
|
+
if (wait > 0) {
|
|
544
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
545
|
+
}
|
|
546
|
+
backoffMs += Date.now() - backoffStarted;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
// The reported window is longer than the engine will park a
|
|
550
|
+
// synchronous request for. Fall through to failure with
|
|
551
|
+
// retryAfterMs reported verbatim so the agent can re-issue.
|
|
460
552
|
}
|
|
461
553
|
registry.recordFailure(
|
|
462
554
|
resolved.connector.id,
|
|
@@ -570,25 +662,45 @@ export function createMetaTools(
|
|
|
570
662
|
const checkedAt = new Date().toISOString();
|
|
571
663
|
const statusStarted = Date.now();
|
|
572
664
|
const observed = registry.healthFor(c.id);
|
|
573
|
-
let status
|
|
574
|
-
|
|
575
|
-
:
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
+
}
|
|
585
693
|
let tools = registry.peekTools(c.id);
|
|
586
694
|
// An auth_required status may have just started OAuth. A second
|
|
587
695
|
// listTools probe would overwrite its state/verifier while returning
|
|
588
696
|
// the first (now stale) authorization URL.
|
|
589
697
|
if (probe && status.state === "ok") {
|
|
590
698
|
try {
|
|
591
|
-
tools = await
|
|
699
|
+
tools = await withTimeout(
|
|
700
|
+
registry.refreshTools(c.id, baseUrl, requestScope),
|
|
701
|
+
probeTimeoutMs,
|
|
702
|
+
`list_connectors catalog refresh of "${c.id}"`,
|
|
703
|
+
);
|
|
592
704
|
registry.recordSuccess(c.id, Date.now() - statusStarted);
|
|
593
705
|
} catch (err) {
|
|
594
706
|
status = { state: "error" as const, message: msg(err) };
|
|
@@ -635,7 +747,13 @@ export function createMetaTools(
|
|
|
635
747
|
order: number;
|
|
636
748
|
}> = [];
|
|
637
749
|
const catalogs = await Promise.allSettled(
|
|
638
|
-
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
|
+
),
|
|
639
757
|
);
|
|
640
758
|
let orderBase = 0;
|
|
641
759
|
catalogs.forEach((catalog, connectorIndex) => {
|
|
@@ -739,7 +857,13 @@ export function createMetaTools(
|
|
|
739
857
|
),
|
|
740
858
|
];
|
|
741
859
|
const loaded = await Promise.allSettled(
|
|
742
|
-
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
|
+
),
|
|
743
867
|
);
|
|
744
868
|
const catalogs = new Map<string, ToolDef[] | Error>();
|
|
745
869
|
loaded.forEach((result, index) => {
|
|
@@ -987,11 +1111,15 @@ export function registerMetaTools(
|
|
|
987
1111
|
ctx: {
|
|
988
1112
|
baseUrl: string;
|
|
989
1113
|
maxResultBytes?: number;
|
|
1114
|
+
defaultToolTimeoutMs?: number;
|
|
1115
|
+
probeTimeoutMs?: number;
|
|
990
1116
|
activity?: ActivityRequestContext;
|
|
991
1117
|
},
|
|
992
1118
|
): void {
|
|
993
1119
|
const mt = createMetaTools(registry, ctx.baseUrl, {
|
|
994
1120
|
maxResultBytes: ctx.maxResultBytes,
|
|
1121
|
+
defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
|
|
1122
|
+
probeTimeoutMs: ctx.probeTimeoutMs,
|
|
995
1123
|
activity: ctx.activity,
|
|
996
1124
|
});
|
|
997
1125
|
|
package/src/server.ts
CHANGED
|
@@ -47,6 +47,10 @@ 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;
|
|
52
|
+
/** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
|
|
53
|
+
probeTimeoutMs?: number;
|
|
50
54
|
/** When set, the execute_code meta-tool is registered on top of the nine. */
|
|
51
55
|
executor?: Executor;
|
|
52
56
|
/** Encrypted connector-credential storage backing the authenticated /ui controls. */
|
|
@@ -59,6 +63,14 @@ function msg(err: unknown): string {
|
|
|
59
63
|
return err instanceof Error ? err.message : String(err);
|
|
60
64
|
}
|
|
61
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
|
+
|
|
62
74
|
function escapeHtml(s: string): string {
|
|
63
75
|
return s
|
|
64
76
|
.replaceAll("&", "&")
|
|
@@ -229,9 +241,12 @@ function withSecurityHeaders(
|
|
|
229
241
|
headers.set("Strict-Transport-Security", "max-age=31536000");
|
|
230
242
|
}
|
|
231
243
|
if (path === "/ui") {
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
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
|
+
}
|
|
235
250
|
headers.set("X-Frame-Options", "DENY");
|
|
236
251
|
}
|
|
237
252
|
return new Response(response.body, {
|
|
@@ -517,7 +532,12 @@ async function serveMcp(
|
|
|
517
532
|
logger: opts.logger,
|
|
518
533
|
}
|
|
519
534
|
: undefined;
|
|
520
|
-
registerMetaTools(server, opts.registry, {
|
|
535
|
+
registerMetaTools(server, opts.registry, {
|
|
536
|
+
baseUrl,
|
|
537
|
+
activity,
|
|
538
|
+
defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
|
|
539
|
+
probeTimeoutMs: opts.probeTimeoutMs,
|
|
540
|
+
});
|
|
521
541
|
if (opts.executor) {
|
|
522
542
|
registerExecuteTool(server, opts.registry, {
|
|
523
543
|
baseUrl,
|
|
@@ -687,9 +707,22 @@ export function createFetchHandler(
|
|
|
687
707
|
// Open shell — carries no data; data comes only from the gated /ui/data.
|
|
688
708
|
const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
|
|
689
709
|
const mcpUrl = new URL("/mcp", baseUrl).toString();
|
|
690
|
-
|
|
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), {
|
|
691
718
|
status: 200,
|
|
692
|
-
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
|
+
},
|
|
693
726
|
});
|
|
694
727
|
}
|
|
695
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];
|