@zackbart/connecta 0.2.0 → 0.3.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 +47 -0
- package/dist/connectors/api.d.ts +14 -0
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +51 -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 +33 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +51 -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 +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +18 -22
- 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 -4
- package/dist/server.d.ts.map +1 -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/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
- package/src/connectors/api.ts +70 -1
- package/src/connectors/remote-mcp.ts +32 -13
- package/src/credentials.ts +4 -1
- package/src/errors.ts +83 -0
- package/src/execute.ts +13 -6
- package/src/index.ts +13 -1
- package/src/meta-tools.ts +37 -35
- package/src/node.ts +1 -0
- package/src/server.ts +3 -1
- package/src/storage/file.ts +12 -3
- package/src/version.ts +1 -1
package/src/connectors/api.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Validator } from "@cfworker/json-schema";
|
|
2
|
+
import { ConnectorCallError } from "../errors.js";
|
|
1
3
|
import type {
|
|
2
4
|
Connector,
|
|
3
5
|
ConnectorCredentialConfig,
|
|
@@ -40,6 +42,14 @@ export interface ApiOptions {
|
|
|
40
42
|
values: ConnectorCredentialValues,
|
|
41
43
|
ctx: ConnectorContext,
|
|
42
44
|
) => Promise<CredentialTestResult>;
|
|
45
|
+
/**
|
|
46
|
+
* Validate call arguments against each tool's `inputSchema` before invoking
|
|
47
|
+
* the handler (default true). Mismatches fail with a non-retryable
|
|
48
|
+
* `invalid_args` ConnectorCallError instead of reaching the handler. Set
|
|
49
|
+
* false to restore the pre-validation pass-through for deployments relying
|
|
50
|
+
* on loose coercion.
|
|
51
|
+
*/
|
|
52
|
+
validateArgs?: boolean;
|
|
43
53
|
tools: ApiTool[];
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -47,6 +57,12 @@ export interface ApiOptions {
|
|
|
47
57
|
* A connector defined entirely in code: static tool defs + fetch handlers.
|
|
48
58
|
* Tool inputs are plain JSON Schema objects (bring your own zod-to-json-schema
|
|
49
59
|
* conversion if you prefer zod). call_tool JSON-wraps the handler's return.
|
|
60
|
+
*
|
|
61
|
+
* Arguments are validated against `inputSchema` before the handler runs
|
|
62
|
+
* (disable with `validateArgs: false`). This is deliberately asymmetric with
|
|
63
|
+
* remote MCP connectors, which stay pass-through: the downstream server is
|
|
64
|
+
* authoritative for its own schemas, and re-validating with our JSON Schema
|
|
65
|
+
* draft/format semantics could reject calls the downstream would accept.
|
|
50
66
|
*/
|
|
51
67
|
export function api(id: string, opts: ApiOptions): Connector {
|
|
52
68
|
const defs: ToolDef[] = opts.tools.map((t) => ({
|
|
@@ -57,6 +73,35 @@ export function api(id: string, opts: ApiOptions): Connector {
|
|
|
57
73
|
annotations: t.annotations,
|
|
58
74
|
}));
|
|
59
75
|
const byName = new Map(opts.tools.map((t) => [t.name, t]));
|
|
76
|
+
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
|
+
};
|
|
60
105
|
return {
|
|
61
106
|
id,
|
|
62
107
|
title: opts.title,
|
|
@@ -74,7 +119,31 @@ export function api(id: string, opts: ApiOptions): Connector {
|
|
|
74
119
|
if (!tool) {
|
|
75
120
|
throw new Error(`Unknown tool "${name}" on connector "${id}"`);
|
|
76
121
|
}
|
|
77
|
-
|
|
122
|
+
const input = args ?? {};
|
|
123
|
+
if (validateArgs && tool.inputSchema) {
|
|
124
|
+
const validator = validatorFor(tool, ctx);
|
|
125
|
+
let result;
|
|
126
|
+
try {
|
|
127
|
+
result = validator?.validate(input);
|
|
128
|
+
} catch (err) {
|
|
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
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return tool.handler(input, ctx);
|
|
78
147
|
},
|
|
79
148
|
};
|
|
80
149
|
}
|
|
@@ -4,6 +4,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
4
4
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
5
5
|
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
|
|
6
6
|
import { KvOAuthProvider } from "../auth/downstream-oauth.js";
|
|
7
|
+
import { ConnectorCallError } from "../errors.js";
|
|
7
8
|
import { CONNECTA_VERSION } from "../version.js";
|
|
8
9
|
import type {
|
|
9
10
|
Connector,
|
|
@@ -60,6 +61,14 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
60
61
|
const states = new WeakMap<object, ConnectionState>();
|
|
61
62
|
const isOauth = opts.auth?.type === "oauth";
|
|
62
63
|
|
|
64
|
+
/** Typed per-call auth signal; the SDK's UnauthorizedError stays as cause. */
|
|
65
|
+
const authRequiredError = (cause: unknown) =>
|
|
66
|
+
new ConnectorCallError(
|
|
67
|
+
"auth_required",
|
|
68
|
+
`Connector "${id}" requires authorization — call authorize_connector({ connector: "${id}" }) and open the returned URL.`,
|
|
69
|
+
{ cause },
|
|
70
|
+
);
|
|
71
|
+
|
|
63
72
|
const stateFor = (ctx: ConnectorContext): ConnectionState => {
|
|
64
73
|
const scope = ctx.requestScope ?? ctx;
|
|
65
74
|
let state = states.get(scope);
|
|
@@ -176,6 +185,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
176
185
|
// "auth_required".
|
|
177
186
|
if (err instanceof UnauthorizedError) {
|
|
178
187
|
state.authRequired = true;
|
|
188
|
+
throw authRequiredError(err);
|
|
179
189
|
}
|
|
180
190
|
throw err;
|
|
181
191
|
} finally {
|
|
@@ -209,19 +219,28 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
209
219
|
async callTool(name, args, ctx) {
|
|
210
220
|
const state = stateFor(ctx);
|
|
211
221
|
await ensureConnected(ctx, state);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
try {
|
|
223
|
+
return await state.client!.callTool(
|
|
224
|
+
{
|
|
225
|
+
name,
|
|
226
|
+
arguments: (args ?? {}) as Record<string, unknown>,
|
|
227
|
+
},
|
|
228
|
+
undefined,
|
|
229
|
+
ctx.timeoutMs || ctx.signal
|
|
230
|
+
? {
|
|
231
|
+
...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
|
|
232
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
233
|
+
}
|
|
234
|
+
: undefined,
|
|
235
|
+
);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// A grant revoked after connect surfaces here, not in ensureConnected.
|
|
238
|
+
if (err instanceof UnauthorizedError) {
|
|
239
|
+
state.authRequired = true;
|
|
240
|
+
throw authRequiredError(err);
|
|
241
|
+
}
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
225
244
|
},
|
|
226
245
|
|
|
227
246
|
async status(ctx): Promise<ConnectorStatus> {
|
package/src/credentials.ts
CHANGED
|
@@ -219,7 +219,10 @@ export class CredentialVault {
|
|
|
219
219
|
value: string,
|
|
220
220
|
updatedBy: string,
|
|
221
221
|
): Promise<CredentialMetadata> {
|
|
222
|
-
|
|
222
|
+
// `await` (not a bare promise return) so a validation throw inside setAll
|
|
223
|
+
// never sits handler-less for the thenable-adoption microtask — workerd
|
|
224
|
+
// reports that gap as an unhandled rejection.
|
|
225
|
+
return await this.setAll(connectorId, { value }, updatedBy);
|
|
223
226
|
}
|
|
224
227
|
|
|
225
228
|
async setAll(
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Typed failure contract for connector tool calls. Web-API only — no node:
|
|
2
|
+
// imports here.
|
|
3
|
+
|
|
4
|
+
/** Machine-readable classification of a failed connector tool call. */
|
|
5
|
+
export type ConnectorCallErrorCode =
|
|
6
|
+
| "timeout"
|
|
7
|
+
| "auth_required"
|
|
8
|
+
| "rate_limited"
|
|
9
|
+
| "unavailable"
|
|
10
|
+
| "invalid_args"
|
|
11
|
+
| "connector_call_failed";
|
|
12
|
+
|
|
13
|
+
const RETRYABLE_BY_CODE: Record<ConnectorCallErrorCode, boolean> = {
|
|
14
|
+
timeout: true,
|
|
15
|
+
rate_limited: true,
|
|
16
|
+
unavailable: true,
|
|
17
|
+
auth_required: false,
|
|
18
|
+
invalid_args: false,
|
|
19
|
+
connector_call_failed: false,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Throw from `Connector.callTool` (or anything beneath it) to classify a
|
|
24
|
+
* failure exactly. Untyped errors fall back to a message-text heuristic, so a
|
|
25
|
+
* connector whose legitimate error text mentions "timeout" is misread as a
|
|
26
|
+
* retryable timeout — this class is the escape hatch. `retryable` defaults per
|
|
27
|
+
* code (timeout, rate_limited, and unavailable retry; the rest do not) and may
|
|
28
|
+
* be overridden.
|
|
29
|
+
*/
|
|
30
|
+
export class ConnectorCallError extends Error {
|
|
31
|
+
readonly code: ConnectorCallErrorCode;
|
|
32
|
+
readonly retryable: boolean;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
code: ConnectorCallErrorCode,
|
|
36
|
+
message: string,
|
|
37
|
+
opts: { retryable?: boolean; cause?: unknown } = {},
|
|
38
|
+
) {
|
|
39
|
+
super(
|
|
40
|
+
message,
|
|
41
|
+
opts.cause !== undefined ? { cause: opts.cause } : undefined,
|
|
42
|
+
);
|
|
43
|
+
this.name = "ConnectorCallError";
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.retryable = opts.retryable ?? RETRYABLE_BY_CODE[code];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The `error` object surfaced in call_tool/batch_call value-mode results. */
|
|
50
|
+
export interface CallErrorDetails {
|
|
51
|
+
code: string;
|
|
52
|
+
message: string;
|
|
53
|
+
retryable: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const RETRYABLE_MESSAGE_RE =
|
|
57
|
+
/timeout|timed out|econnreset|econnrefused|temporar|rate.?limit|429|502|503|504|refcountedcanceler|different request/i;
|
|
58
|
+
const TIMEOUT_MESSAGE_RE = /timed out|timeout/i;
|
|
59
|
+
|
|
60
|
+
/** Message-text fallback used when an error carries no typed classification. */
|
|
61
|
+
export function messageLooksRetryable(message: string): boolean {
|
|
62
|
+
return RETRYABLE_MESSAGE_RE.test(message);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Classify a value thrown by a connector call. A `ConnectorCallError` is
|
|
67
|
+
* authoritative; anything else falls back to the historical message-text
|
|
68
|
+
* heuristic.
|
|
69
|
+
*/
|
|
70
|
+
export function classifyCallError(
|
|
71
|
+
err: unknown,
|
|
72
|
+
fallbackCode = "connector_call_failed",
|
|
73
|
+
): CallErrorDetails {
|
|
74
|
+
if (err instanceof ConnectorCallError) {
|
|
75
|
+
return { code: err.code, message: err.message, retryable: err.retryable };
|
|
76
|
+
}
|
|
77
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
78
|
+
return {
|
|
79
|
+
code: TIMEOUT_MESSAGE_RE.test(message) ? "timeout" : fallbackCode,
|
|
80
|
+
message,
|
|
81
|
+
retryable: RETRYABLE_MESSAGE_RE.test(message),
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/execute.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { compactSchema, rankTools, summarizeDescription } from "./catalog.js";
|
|
4
4
|
import { recordToolActivity, type ActivityRequestContext } from "./activity.js";
|
|
5
5
|
import { errorResult, jsonResult, type ToolResult } from "./meta-tools.js";
|
|
6
|
+
import { classifyCallError, ConnectorCallError } from "./errors.js";
|
|
6
7
|
import { unwrapMcpResult } from "./mcp-result.js";
|
|
7
8
|
import type { Registry } from "./registry.js";
|
|
8
9
|
import type {
|
|
@@ -191,7 +192,10 @@ export async function buildSandboxProviders(
|
|
|
191
192
|
try {
|
|
192
193
|
timer = setTimeout(() => {
|
|
193
194
|
controller.abort(
|
|
194
|
-
new
|
|
195
|
+
new ConnectorCallError(
|
|
196
|
+
"timeout",
|
|
197
|
+
`Tool call timed out after ${hostCallTimeoutMs}ms`,
|
|
198
|
+
),
|
|
195
199
|
);
|
|
196
200
|
}, hostCallTimeoutMs);
|
|
197
201
|
const pending = resolved.connector.callTool(
|
|
@@ -216,16 +220,16 @@ export async function buildSandboxProviders(
|
|
|
216
220
|
return value;
|
|
217
221
|
} catch (err) {
|
|
218
222
|
registry.recordFailure(resolved.connector.id, Date.now() - started, err);
|
|
219
|
-
const
|
|
223
|
+
const details = classifyCallError(err);
|
|
220
224
|
recordToolActivity(activity, {
|
|
221
225
|
connectorId: resolved.connector.id,
|
|
222
226
|
toolName: resolved.toolName,
|
|
223
227
|
address: `${resolved.connector.id}.${resolved.toolName}`,
|
|
224
228
|
source: "execute_code",
|
|
225
|
-
outcome:
|
|
229
|
+
outcome: details.code === "timeout" ? "timeout" : "error",
|
|
226
230
|
durationMs: Date.now() - started,
|
|
227
231
|
attempts: 1,
|
|
228
|
-
errorCode:
|
|
232
|
+
errorCode: details.code,
|
|
229
233
|
});
|
|
230
234
|
throw err;
|
|
231
235
|
} finally {
|
|
@@ -269,8 +273,11 @@ export async function buildSandboxProviders(
|
|
|
269
273
|
);
|
|
270
274
|
continue;
|
|
271
275
|
}
|
|
276
|
+
// `await` (not a bare promise return) so a synchronous throw inside
|
|
277
|
+
// callAddress never sits handler-less for the thenable-adoption
|
|
278
|
+
// microtask — workerd reports that gap as an unhandled rejection.
|
|
272
279
|
fns[key] = async (args: unknown) =>
|
|
273
|
-
callAddress(`${connector.id}.${t.name}`, args);
|
|
280
|
+
await callAddress(`${connector.id}.${t.name}`, args);
|
|
274
281
|
}
|
|
275
282
|
if (Object.keys(fns).length > 0) {
|
|
276
283
|
providers.push({ name: ns, fns });
|
|
@@ -288,7 +295,7 @@ export async function buildSandboxProviders(
|
|
|
288
295
|
`connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS} calls`,
|
|
289
296
|
);
|
|
290
297
|
}
|
|
291
|
-
return Promise.all(
|
|
298
|
+
return await Promise.all(
|
|
292
299
|
calls.map(async (call) => {
|
|
293
300
|
const item = call as { address?: unknown; args?: unknown };
|
|
294
301
|
try {
|
package/src/index.ts
CHANGED
|
@@ -61,7 +61,16 @@ export interface ConnectaConfig {
|
|
|
61
61
|
* stash the full text for get_result paging. Default 50_000.
|
|
62
62
|
*/
|
|
63
63
|
maxResultBytes?: number;
|
|
64
|
-
serverInfo?: {
|
|
64
|
+
serverInfo?: {
|
|
65
|
+
name?: string;
|
|
66
|
+
version?: string;
|
|
67
|
+
/** Human-readable name clients may show instead of `name`. */
|
|
68
|
+
title?: string;
|
|
69
|
+
/** Homepage clients may link from the server listing. */
|
|
70
|
+
websiteUrl?: string;
|
|
71
|
+
/** MCP icons-spec entries; clients render these instead of a scraped favicon. */
|
|
72
|
+
icons?: Array<{ src: string; mimeType?: string; sizes?: string[] }>;
|
|
73
|
+
};
|
|
65
74
|
/** Deployment metadata exposed by /health (for example a Worker version). */
|
|
66
75
|
deploymentInfo?: Record<string, unknown>;
|
|
67
76
|
/**
|
|
@@ -123,6 +132,7 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
123
132
|
auth: normalizeAuth(config.auth),
|
|
124
133
|
publicUrl: config.publicUrl,
|
|
125
134
|
serverInfo: {
|
|
135
|
+
...config.serverInfo,
|
|
126
136
|
name: config.serverInfo?.name ?? "connecta",
|
|
127
137
|
version: config.serverInfo?.version ?? CONNECTA_VERSION,
|
|
128
138
|
},
|
|
@@ -149,6 +159,8 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
149
159
|
|
|
150
160
|
export { remoteMcp } from "./connectors/remote-mcp.js";
|
|
151
161
|
export { api } from "./connectors/api.js";
|
|
162
|
+
export { ConnectorCallError } from "./errors.js";
|
|
163
|
+
export type { ConnectorCallErrorCode, CallErrorDetails } from "./errors.js";
|
|
152
164
|
export { bearerToken } from "./auth/bearer.js";
|
|
153
165
|
export { memoryStorage } from "./storage/memory.js";
|
|
154
166
|
export { CONNECTA_VERSION } from "./version.js";
|
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,11 @@ const DEFAULT_SEARCH_LIMIT = 25;
|
|
|
45
51
|
const enc = new TextEncoder();
|
|
46
52
|
const dec = new TextDecoder();
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
code: string;
|
|
50
|
-
message: string;
|
|
51
|
-
retryable: boolean;
|
|
52
|
-
}
|
|
54
|
+
type ErrorDetails = CallErrorDetails;
|
|
53
55
|
|
|
56
|
+
/** Details for failures that never reached a connector (no thrown value). */
|
|
54
57
|
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
|
-
};
|
|
58
|
+
return { code, message, retryable: messageLooksRetryable(message) };
|
|
63
59
|
}
|
|
64
60
|
|
|
65
61
|
/** True if `b` is a UTF-8 continuation byte (0b10xxxxxx). */
|
|
@@ -332,11 +328,10 @@ export function createMetaTools(
|
|
|
332
328
|
...(errorCode ? { errorCode } : {}),
|
|
333
329
|
});
|
|
334
330
|
};
|
|
335
|
-
const failed = (
|
|
331
|
+
const failed = (error: ErrorDetails): RunCallOutcome => {
|
|
336
332
|
const durationMs = Date.now() - started;
|
|
337
|
-
const error = errorDetails(code, message);
|
|
338
333
|
const diagnostics = timing();
|
|
339
|
-
record(code === "timeout" ? "timeout" : "error", code);
|
|
334
|
+
record(error.code === "timeout" ? "timeout" : "error", error.code);
|
|
340
335
|
return {
|
|
341
336
|
toolResult:
|
|
342
337
|
call.resultMode === "value"
|
|
@@ -347,7 +342,7 @@ export function createMetaTools(
|
|
|
347
342
|
attempts,
|
|
348
343
|
...(call.diagnostics ? { timing: diagnostics } : {}),
|
|
349
344
|
})
|
|
350
|
-
: errorResult(message),
|
|
345
|
+
: errorResult(error.message),
|
|
351
346
|
durationMs,
|
|
352
347
|
attempts,
|
|
353
348
|
timing: diagnostics,
|
|
@@ -355,7 +350,9 @@ export function createMetaTools(
|
|
|
355
350
|
};
|
|
356
351
|
};
|
|
357
352
|
if (!resolved) {
|
|
358
|
-
return failed(
|
|
353
|
+
return failed(
|
|
354
|
+
errorDetails("unknown_address", `Unknown address "${call.address}"`),
|
|
355
|
+
);
|
|
359
356
|
}
|
|
360
357
|
const results = registry.resultsStorage();
|
|
361
358
|
const fields = call.fields && call.fields.length > 0 ? call.fields : null;
|
|
@@ -375,13 +372,17 @@ export function createMetaTools(
|
|
|
375
372
|
).find((tool) => tool.name === resolved.toolName);
|
|
376
373
|
} catch (err) {
|
|
377
374
|
catalogMs += Date.now() - catalogStarted;
|
|
378
|
-
|
|
375
|
+
// classifyCallError so a typed auth_required thrown while listing tools
|
|
376
|
+
// (e.g. a revoked downstream OAuth grant) keeps its code.
|
|
377
|
+
return failed(classifyCallError(err, "catalog_lookup_failed"));
|
|
379
378
|
}
|
|
380
379
|
catalogMs += Date.now() - catalogStarted;
|
|
381
380
|
if (!definition) {
|
|
382
381
|
return failed(
|
|
383
|
-
|
|
384
|
-
|
|
382
|
+
errorDetails(
|
|
383
|
+
"unknown_tool",
|
|
384
|
+
`Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
|
|
385
|
+
),
|
|
385
386
|
);
|
|
386
387
|
}
|
|
387
388
|
const explicitlyReadOnly =
|
|
@@ -389,8 +390,10 @@ export function createMetaTools(
|
|
|
389
390
|
definition.annotations?.destructiveHint !== true;
|
|
390
391
|
if (!explicitlyReadOnly && !options.allowDestructive) {
|
|
391
392
|
return failed(
|
|
392
|
-
|
|
393
|
-
|
|
393
|
+
errorDetails(
|
|
394
|
+
"destructive_tool_requires_approval",
|
|
395
|
+
`Tool "${call.address}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`,
|
|
396
|
+
),
|
|
394
397
|
);
|
|
395
398
|
}
|
|
396
399
|
const retrySafe =
|
|
@@ -420,7 +423,12 @@ export function createMetaTools(
|
|
|
420
423
|
pending,
|
|
421
424
|
new Promise<never>((_, reject) => {
|
|
422
425
|
timer = setTimeout(() => {
|
|
423
|
-
reject(
|
|
426
|
+
reject(
|
|
427
|
+
new ConnectorCallError(
|
|
428
|
+
"timeout",
|
|
429
|
+
`Tool call timed out after ${timeoutMs}ms`,
|
|
430
|
+
),
|
|
431
|
+
);
|
|
424
432
|
controller?.abort();
|
|
425
433
|
}, timeoutMs);
|
|
426
434
|
}),
|
|
@@ -441,7 +449,7 @@ export function createMetaTools(
|
|
|
441
449
|
} catch (err) {
|
|
442
450
|
// Includes connector setup, downstream execution, and timeout wait.
|
|
443
451
|
connectorMs += Date.now() - connectorStarted;
|
|
444
|
-
const details =
|
|
452
|
+
const details = classifyCallError(err);
|
|
445
453
|
if (attempts <= maxRetries && retrySafe && details.retryable) {
|
|
446
454
|
const backoffStarted = Date.now();
|
|
447
455
|
await new Promise((resolve) =>
|
|
@@ -455,12 +463,7 @@ export function createMetaTools(
|
|
|
455
463
|
Date.now() - started,
|
|
456
464
|
err,
|
|
457
465
|
);
|
|
458
|
-
return failed(
|
|
459
|
-
/timed out|timeout/i.test(msg(err))
|
|
460
|
-
? "timeout"
|
|
461
|
-
: "connector_call_failed",
|
|
462
|
-
msg(err),
|
|
463
|
-
);
|
|
466
|
+
return failed(details);
|
|
464
467
|
} finally {
|
|
465
468
|
if (timer) clearTimeout(timer);
|
|
466
469
|
}
|
|
@@ -531,7 +534,7 @@ export function createMetaTools(
|
|
|
531
534
|
};
|
|
532
535
|
} catch (err) {
|
|
533
536
|
resultProcessingMs += Date.now() - processingStarted;
|
|
534
|
-
return failed("result_processing_failed", msg(err));
|
|
537
|
+
return failed(errorDetails("result_processing_failed", msg(err)));
|
|
535
538
|
}
|
|
536
539
|
}
|
|
537
540
|
|
|
@@ -846,12 +849,11 @@ export function createMetaTools(
|
|
|
846
849
|
const results = settled.map((s, i) => {
|
|
847
850
|
const address = args.calls[i].address;
|
|
848
851
|
if (s.status === "rejected") {
|
|
849
|
-
const message = msg(s.reason);
|
|
850
852
|
return {
|
|
851
853
|
address,
|
|
852
854
|
ok: false,
|
|
853
|
-
error:
|
|
854
|
-
errorDetails:
|
|
855
|
+
error: msg(s.reason),
|
|
856
|
+
errorDetails: classifyCallError(s.reason, "batch_call_failed"),
|
|
855
857
|
};
|
|
856
858
|
}
|
|
857
859
|
const r = s.value;
|
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
|
@@ -39,7 +39,9 @@ export interface ServerOptions {
|
|
|
39
39
|
registry: Registry;
|
|
40
40
|
auth: InboundAuth[];
|
|
41
41
|
publicUrl?: string;
|
|
42
|
-
|
|
42
|
+
// The SDK's Implementation shape: name/version plus optional title,
|
|
43
|
+
// websiteUrl, and icons (MCP icons spec) that clients may render.
|
|
44
|
+
serverInfo: ConstructorParameters<typeof McpServer>[0];
|
|
43
45
|
logger: Logger;
|
|
44
46
|
activity?: ActivityStore;
|
|
45
47
|
activityReadGate?: ActivityReadGate;
|
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/version.ts
CHANGED