@zackbart/connecta 0.40.0 → 0.42.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 +148 -0
- package/bin/connecta.mjs +26 -17
- package/bin/meta-tool-names.d.mts +3 -1
- package/bin/meta-tool-names.mjs +4 -1
- package/bin/version.mjs +1 -1
- package/dist/activity.d.ts +32 -5
- package/dist/activity.js +32 -6
- package/dist/advertised-schema.js +15 -0
- package/dist/agent-observation.js +24 -2
- package/dist/agent-routes.js +33 -44
- package/dist/auth/clerk.d.ts +6 -1
- package/dist/auth/clerk.js +16 -0
- package/dist/auth/cloudflare-access.js +49 -7
- package/dist/auth/downstream-oauth/consent.js +79 -29
- package/dist/auth/downstream-oauth/grant-store.js +58 -7
- package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
- package/dist/auth/downstream-oauth/refresh-records.js +6 -5
- package/dist/auth/downstream-oauth.js +15 -4
- package/dist/auth/oauth-request-not-sent.js +4 -0
- package/dist/auth/static-oauth.js +77 -39
- package/dist/auth-elicitation.js +118 -61
- package/dist/authorization-handoff.js +36 -3
- package/dist/catalog-cache.js +352 -43
- package/dist/catalog-freshness.js +25 -0
- package/dist/catalog-service.js +408 -78
- package/dist/catalog.js +216 -62
- package/dist/config-defaults.d.ts +2 -1
- package/dist/config-defaults.js +4 -1
- package/dist/config.d.ts +53 -21
- package/dist/config.js +56 -17
- package/dist/connectors/remote-mcp/call.js +1 -1
- package/dist/connectors/remote-mcp/catalog.js +52 -28
- package/dist/connectors/remote-mcp/connection.js +113 -22
- package/dist/connectors/remote-mcp/oauth.js +2 -2
- package/dist/connectors/remote-mcp/resources.js +5 -2
- package/dist/connectors/remote-mcp/transport.js +24 -6
- package/dist/connectors/remote-mcp.js +14 -5
- package/dist/credential-contract.d.ts +3 -1
- package/dist/describe-config.d.ts +4 -2
- package/dist/describe-config.js +1 -0
- package/dist/discovery-health.js +26 -11
- package/dist/downstream-input.js +11 -2
- package/dist/errors.d.ts +60 -4
- package/dist/errors.js +88 -3
- package/dist/execute.js +491 -202
- package/dist/executor-admission.js +2 -0
- package/dist/executor-contract.js +6 -0
- package/dist/executor-result.js +3 -3
- package/dist/executors/quickjs-child.js +14 -5
- package/dist/executors/quickjs-runtime.d.ts +2 -0
- package/dist/executors/quickjs-runtime.js +37 -20
- package/dist/executors/quickjs.js +24 -7
- package/dist/guarded-transport.js +2 -0
- package/dist/guest-functions.js +5 -4
- package/dist/guest-runtime.js +37 -1
- package/dist/guest-types.d.ts +18 -0
- package/dist/http/client.js +14 -2
- package/dist/http/errors.js +9 -4
- package/dist/http/fields.js +17 -5
- package/dist/http/hatch.js +11 -3
- package/dist/http/node-transport.js +110 -4
- package/dist/http/oauth.js +17 -6
- package/dist/http/paging.js +1 -1
- package/dist/http/provider.js +2 -6
- package/dist/http/vendor.js +6 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +15 -21
- package/dist/invocation-failure.js +28 -0
- package/dist/invocation.js +167 -64
- package/dist/membership.d.ts +1 -1
- package/dist/meta-output.js +24 -18
- package/dist/meta-tool-names.d.ts +1 -1
- package/dist/meta-tool-names.js +1 -1
- package/dist/meta-tools.js +286 -73
- package/dist/module-contracts.d.ts +3 -1
- package/dist/native-recovery.js +4 -1
- package/dist/native-tools.js +177 -55
- package/dist/node.d.ts +7 -0
- package/dist/node.js +85 -4
- package/dist/oauth-handoff.js +53 -43
- package/dist/operation-activity.js +104 -0
- package/dist/operation-vocabulary.js +6 -0
- package/dist/operator-record.d.ts +9 -1
- package/dist/operator-record.js +16 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/program-result.js +2 -0
- package/dist/program-source.js +1 -1
- package/dist/program-writes.js +112 -19
- package/dist/provider.d.ts +1 -14
- package/dist/provider.js +0 -3
- package/dist/providers/_shared/google/kit.js +2 -2
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/breeze/api.js +2 -2
- package/dist/providers/breeze/index.js +1 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/ccb/api.js +2 -2
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +2 -2
- package/dist/providers/cloudflare/index.js +3 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/index.js +1 -1
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/forms/index.js +2 -1
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/github/api.js +98 -6
- package/dist/providers/github/index.js +8 -3
- package/dist/providers/github/lib/auth.d.ts +3 -1
- package/dist/providers/github/lib/auth.js +43 -12
- package/dist/providers/github/lib/discovery.js +11 -1
- package/dist/providers/github/lib/operations.js +8 -0
- package/dist/providers/github/safety.js +39 -0
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +12 -5
- package/dist/providers/gmail/index.js +2 -1
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/granola/index.js +1 -1
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/index.js +3 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/index.js +12 -6
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/api.js +28 -25
- package/dist/providers/quickbooks/index.js +3 -6
- package/dist/providers/quickbooks/lib/writes.js +55 -0
- package/dist/providers/quickbooks/safety.js +17 -5
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/index.js +2 -1
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/stripe/index.js +2 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/index.js +2 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +3 -2
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/registry.d.ts +36 -4
- package/dist/registry.js +142 -30
- package/dist/resource-read.js +21 -2
- package/dist/result-preview.js +210 -0
- package/dist/result-seal.js +168 -0
- package/dist/route-names.js +53 -0
- package/dist/routes/activity-context.js +1 -0
- package/dist/routes/activity-disclosure.js +26 -4
- package/dist/routes/activity.js +2 -1
- package/dist/routes/connect.js +21 -13
- package/dist/routes/mcp.js +83 -35
- package/dist/routes/shared.d.ts +6 -0
- package/dist/routes/ui-config.js +1 -1
- package/dist/routes/ui-data.js +3 -2
- package/dist/runtime/call.js +15 -10
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +66 -14
- package/dist/runtime/request-scope.js +38 -2
- package/dist/runtime/run.js +4 -1
- package/dist/sent-secrets.d.ts +20 -0
- package/dist/sent-secrets.js +49 -1
- package/dist/skill-origin.js +16 -0
- package/dist/skills.js +213 -57
- package/dist/storage/d1-driver.js +33 -5
- package/dist/storage/keys.js +16 -7
- package/dist/storage/sql-native.js +60 -3
- package/dist/storage/sql.js +6 -0
- package/dist/timing-trace.js +84 -0
- package/dist/tool-safety.d.ts +33 -0
- package/dist/tool-safety.js +51 -15
- package/dist/types.d.ts +36 -0
- package/dist/url-safety.js +38 -20
- package/dist/usage-guide.js +14 -13
- package/dist/utility-recovery.js +33 -0
- package/dist/validate.js +29 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +60 -15
- package/documentation/architecture.md +152 -43
- package/documentation/auth.md +160 -53
- package/documentation/code-mode.md +396 -119
- package/documentation/deploying.md +35 -10
- package/documentation/hosted-mcp-migration.md +9 -3
- package/documentation/integrating.md +27 -5
- package/documentation/meta-tools.md +761 -157
- package/documentation/operating.md +91 -4
- package/documentation/operator-ui.md +51 -2
- package/documentation/providers.md +34 -20
- package/examples/worker/README.md +3 -3
- package/guides/how-it-works.md +2 -1
- package/guides/safety-and-trust.md +1 -3
- package/package.json +10 -8
- package/templates/node/AGENTS.md +3 -3
- package/templates/node/README.md +1 -1
- package/templates/node/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConnectaBranding } from "./types.js";
|
|
2
|
-
import type { ActivityStore, ActivityReadGate, recordToolActivity, recordCatalogChangeActivity } from "./activity.js";
|
|
2
|
+
import type { ActivityStore, ActivityReadGate, recordToolActivity, recordCatalogChangeActivity, recordOperationActivity } from "./activity.js";
|
|
3
3
|
import type { RouteContext } from "./routes/shared.js";
|
|
4
4
|
/** Construction-time UI contract. Core never imports the implementation. */
|
|
5
5
|
export interface OperatorSurface {
|
|
@@ -10,6 +10,8 @@ export interface OperatorSurface {
|
|
|
10
10
|
}
|
|
11
11
|
/** Optional recording and reading callbacks supplied by /activity. */
|
|
12
12
|
export interface ActivityModule {
|
|
13
|
+
readonly operations?: boolean;
|
|
14
|
+
readonly recordOperation?: typeof recordOperationActivity;
|
|
13
15
|
readonly store: ActivityStore;
|
|
14
16
|
readonly deploymentId?: string;
|
|
15
17
|
readonly readGate?: ActivityReadGate;
|
package/dist/native-recovery.js
CHANGED
|
@@ -20,11 +20,14 @@ export function nativeRecovery(result, wireName) {
|
|
|
20
20
|
: { operator: "Check connector authentication in deployment configuration." };
|
|
21
21
|
}
|
|
22
22
|
else if (action?.tool === "call_tool" || action?.tool === "call_destructive_tool") {
|
|
23
|
+
const purpose = error.code === "destructive_tool_requires_approval"
|
|
24
|
+
? "Nothing was sent. Refresh the host's tool list, then call this tool only if the write is still wanted; the host asks for approval."
|
|
25
|
+
: (action.purpose ?? "Retry the original native call.");
|
|
23
26
|
error.nextAction = wireName
|
|
24
27
|
? {
|
|
25
28
|
tool: wireName,
|
|
26
29
|
arguments: action.arguments?.args ?? {},
|
|
27
|
-
purpose: `${
|
|
30
|
+
purpose: `${purpose}${action.arguments?.argsRedacted ? " Resend the original arguments; sensitive fields were omitted." : ""}`,
|
|
28
31
|
}
|
|
29
32
|
: { host: "search", purpose: action.purpose };
|
|
30
33
|
}
|
package/dist/native-tools.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolvedConnectorGuides } from "./skills.js";
|
|
1
2
|
import { bytesToHex } from "./bytes.js";
|
|
2
3
|
import { routeNames } from "./agent-routes.js";
|
|
3
4
|
import { duringViewEffect } from "./runtime/call.js";
|
|
@@ -11,6 +12,7 @@ import { withAuthorizationHandoff } from "./authorization-handoff.js";
|
|
|
11
12
|
import { classifyCallError } from "./errors.js";
|
|
12
13
|
import { nativeRecovery } from "./native-recovery.js";
|
|
13
14
|
import { CatalogService } from "./catalog-service.js";
|
|
15
|
+
import { catalogStaleness } from "./catalog-freshness.js";
|
|
14
16
|
import { jsonResult } from "./meta-tools.js";
|
|
15
17
|
import { failureRecord, logFailure } from "./operator-record.js";
|
|
16
18
|
/**
|
|
@@ -23,8 +25,19 @@ export const NATIVE_NAME_MAX = 48;
|
|
|
23
25
|
const MAX_NATIVE_TOOLS = 1_000;
|
|
24
26
|
/** Native `tools/list` cache hint. Shorter than the meta surface's hour: the list follows downstream catalogs. */
|
|
25
27
|
export const NATIVE_TOOLS_LIST_TTL_MS = 300_000;
|
|
28
|
+
/** The hint when a listing omits a connector or lists one stale: about when that connector is next asked. */
|
|
29
|
+
export const NATIVE_DEGRADED_TOOLS_LIST_TTL_MS = 60_000;
|
|
26
30
|
const HASH_CHARS = 6;
|
|
27
31
|
const SEPARATOR = "__";
|
|
32
|
+
/**
|
|
33
|
+
* What every write's name carries. It sits before the tool, because Eve displays
|
|
34
|
+
* a name's last `__` segment and a suffix there would show every write as "write".
|
|
35
|
+
*/
|
|
36
|
+
const WRITE_SEGMENT = `${SEPARATOR}WRITE${SEPARATOR}`;
|
|
37
|
+
/** How every hashed read ends, and so what no write's name may end in. See {@link nativeToolName}. */
|
|
38
|
+
const READ_HASH_TAIL = /_[0-9a-f]{6}$/;
|
|
39
|
+
/** A hashed write keeps this much of its connector, leaving the rest of the budget to its tool. */
|
|
40
|
+
const WRITE_CONNECTOR_HEAD = 24;
|
|
28
41
|
/** No `__`, no leading `_`: the last `__` of a plain name is then always its separator. */
|
|
29
42
|
const PLAIN_TOOL = /^[A-Za-z0-9-](?:[A-Za-z0-9-]|_(?!_))*$/;
|
|
30
43
|
const NATIVE_NAME = new RegExp(`^[A-Za-z][A-Za-z0-9_-]{0,${NATIVE_NAME_MAX - 1}}$`);
|
|
@@ -48,28 +61,82 @@ const shortHashNative = /* @__PURE__ */ registerAuth(shortHash, function (text)
|
|
|
48
61
|
function connectorPart(connectorId) {
|
|
49
62
|
return /^[a-z]/.test(connectorId) ? connectorId : `C${connectorId}`;
|
|
50
63
|
}
|
|
64
|
+
/** The classification a wire name is bound to. A name never changes what it is bound to. */
|
|
65
|
+
function boundClassification(name) {
|
|
66
|
+
return name.includes(WRITE_SEGMENT) && !READ_HASH_TAIL.test(name) ? "write" : "read";
|
|
67
|
+
}
|
|
51
68
|
/**
|
|
52
|
-
* A pure function of the connector id
|
|
53
|
-
* stable across catalog refreshes
|
|
54
|
-
*
|
|
55
|
-
* original pair.
|
|
69
|
+
* A pure function of the connector id, downstream tool name and classification,
|
|
70
|
+
* so it is stable across catalog refreshes until the classification changes. A
|
|
71
|
+
* read is `<connector>__<tool>` when that is exact, legal and short, otherwise its
|
|
72
|
+
* head plus `_` and a six-hex SHA-256 of the original pair. A write is
|
|
73
|
+
* `<connector>__WRITE__<tool>` under the same conditions and when that does not end
|
|
74
|
+
* like a hashed read, otherwise a connector head, the marker, a tool head, `_w` and
|
|
75
|
+
* the same hash.
|
|
76
|
+
*
|
|
77
|
+
* No read's name is ever a write's, including a name a host cached before writes
|
|
78
|
+
* had their own, because a read's name has not changed. A read can carry the marker
|
|
79
|
+
* only when hashed: connector ids have no uppercase, so `WRITE` and the `__` after
|
|
80
|
+
* it would fall inside the tool, and a plain tool has no `__`. Every hashed read
|
|
81
|
+
* ends in `_` and six hex digits. Every write carries the marker and never ends so.
|
|
56
82
|
*/
|
|
57
|
-
export function nativeToolName(connectorId, toolName) {
|
|
58
|
-
return runEdge(nativeToolNameNative(connectorId, toolName));
|
|
83
|
+
export function nativeToolName(connectorId, toolName, classification) {
|
|
84
|
+
return runEdge(nativeToolNameNative(connectorId, toolName, classification));
|
|
59
85
|
}
|
|
60
|
-
const nativeToolNameNative = /* @__PURE__ */ registerAuth(nativeToolName, function (connectorId, toolName) {
|
|
86
|
+
const nativeToolNameNative = /* @__PURE__ */ registerAuth(nativeToolName, function (connectorId, toolName, classification) {
|
|
61
87
|
return authFlow(function* () {
|
|
88
|
+
const connector = connectorPart(connectorId);
|
|
62
89
|
const tool = toolName.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
63
|
-
const
|
|
64
|
-
|
|
90
|
+
const write = classification === "write";
|
|
91
|
+
const plain = `${connector}${write ? WRITE_SEGMENT : SEPARATOR}${tool}`;
|
|
92
|
+
if (tool === toolName &&
|
|
93
|
+
PLAIN_TOOL.test(tool) &&
|
|
94
|
+
plain.length <= NATIVE_NAME_MAX &&
|
|
95
|
+
!(write && READ_HASH_TAIL.test(plain)))
|
|
65
96
|
return plain;
|
|
66
|
-
const
|
|
67
|
-
|
|
97
|
+
const hash = yield* upwardStep(authCall(shortHash, [`${connectorId}.${toolName}`]));
|
|
98
|
+
if (write) {
|
|
99
|
+
const owner = connector.slice(0, WRITE_CONNECTOR_HEAD);
|
|
100
|
+
const suffix = `_w${hash}`;
|
|
101
|
+
const room = NATIVE_NAME_MAX - suffix.length - owner.length - WRITE_SEGMENT.length;
|
|
102
|
+
return `${owner}${WRITE_SEGMENT}${tool.slice(0, room).replace(/_+$/, "")}${suffix}`;
|
|
103
|
+
}
|
|
104
|
+
return `${plain.slice(0, NATIVE_NAME_MAX - HASH_CHARS - 1).replace(/_+$/, "")}_${hash}`;
|
|
68
105
|
});
|
|
69
106
|
});
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Whether any tool of a connector could hold a name: every shape in
|
|
109
|
+
* {@link nativeToolName}, including truncated connector prefixes.
|
|
110
|
+
*/
|
|
111
|
+
function couldClaim(connector, name) {
|
|
112
|
+
const part = connectorPart(connector.id);
|
|
113
|
+
const prefix = `${part}${SEPARATOR}`;
|
|
114
|
+
// A hashed name can truncate even the connector prefix, and strips trailing
|
|
115
|
+
// underscores from its head. Such a name is exactly `${head}_` + six hex digits.
|
|
116
|
+
const head = prefix.slice(0, NATIVE_NAME_MAX - HASH_CHARS - 1).replace(/_+$/, "");
|
|
117
|
+
return (name.startsWith(prefix) ||
|
|
118
|
+
// A hashed write keeps only the head of a long connector id.
|
|
119
|
+
name.startsWith(`${part.slice(0, WRITE_CONNECTOR_HEAD)}${WRITE_SEGMENT}`) ||
|
|
120
|
+
(name.length === head.length + 1 + HASH_CHARS &&
|
|
121
|
+
name.startsWith(`${head}_`) &&
|
|
122
|
+
/^[0-9a-f]+$/.test(name.slice(-HASH_CHARS))));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The name recovery should give: what the refusing verdict selects, because a
|
|
126
|
+
* catalog can change between resolving a name and invoking it.
|
|
127
|
+
*/
|
|
128
|
+
function recoveryName(result, called, names) {
|
|
129
|
+
const error = result?.structuredContent?.error;
|
|
130
|
+
const route = error?.code === "destructive_tool_requires_approval" ? "call_destructive_tool" : error?.nextAction?.tool;
|
|
131
|
+
return route === "call_destructive_tool" ? names.write : route === "call_tool" ? names.read : called;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Read or write, from the served classification only. A stale listing cannot
|
|
135
|
+
* vouch for a read: the connector may have reclassified the tool since, so it
|
|
136
|
+
* fails closed (INV-1). The call's own verdict comes from a live catalog.
|
|
137
|
+
*/
|
|
138
|
+
function annotationsFor(tool, stale) {
|
|
139
|
+
return tool.classification === "read" && !stale
|
|
73
140
|
? { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
74
141
|
: { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
|
|
75
142
|
}
|
|
@@ -129,10 +196,12 @@ function passThroughSchema(schema) {
|
|
|
129
196
|
},
|
|
130
197
|
};
|
|
131
198
|
}
|
|
132
|
-
function describe(entry, executeCode) {
|
|
199
|
+
function describe(entry, executeCode, guideName) {
|
|
133
200
|
const lines = [entry.tool.description?.trim() || entry.tool.title || entry.tool.name];
|
|
134
201
|
if (executeCode)
|
|
135
202
|
lines.push(`Program address: ${entry.connector.id}.${entry.tool.name}`);
|
|
203
|
+
if (guideName)
|
|
204
|
+
lines.push(`Guide: skills ${guideName}`);
|
|
136
205
|
return lines.join("\n\n");
|
|
137
206
|
}
|
|
138
207
|
export class NativeSurface {
|
|
@@ -157,10 +226,13 @@ export class NativeSurface {
|
|
|
157
226
|
/**
|
|
158
227
|
* Every listable native tool. A connector whose catalog fails is omitted
|
|
159
228
|
* whole, never partially, with an operator warning; two tools claiming one
|
|
160
|
-
* name are both omitted and stay reachable by address in execute_code.
|
|
229
|
+
* name are both omitted and stay reachable by address in execute_code. A tool
|
|
230
|
+
* claims its read and write names alike, whichever it is now, so a collision
|
|
231
|
+
* does not come and go with a classification and a name cached under the other
|
|
232
|
+
* classification still finds its tool.
|
|
161
233
|
*/
|
|
162
|
-
claims(connectors = this.registry.listConnectors()) {
|
|
163
|
-
return runEdge(authOwnMethod(NativeSurface.prototype, this, "claims", [connectors]));
|
|
234
|
+
claims(connectors = this.registry.listConnectors(), allowStale = false) {
|
|
235
|
+
return runEdge(authOwnMethod(NativeSurface.prototype, this, "claims", [connectors, allowStale]));
|
|
164
236
|
}
|
|
165
237
|
list() {
|
|
166
238
|
return runEdge(authOwnMethod(NativeSurface.prototype, this, "list", []));
|
|
@@ -173,20 +245,38 @@ export class NativeSurface {
|
|
|
173
245
|
resolve(name) {
|
|
174
246
|
return runEdge(authOwnMethod(NativeSurface.prototype, this, "resolve", [name]));
|
|
175
247
|
}
|
|
176
|
-
/**
|
|
248
|
+
/**
|
|
249
|
+
* A native call: the existing route the name is bound to, never one chosen from
|
|
250
|
+
* the current verdict. A read name takes the read route, which refuses a current
|
|
251
|
+
* write before dispatch, so a host's replay of a cached read cannot reach a write.
|
|
252
|
+
*/
|
|
177
253
|
call(name, args, request) {
|
|
178
254
|
return runEdge(authOwnMethod(NativeSurface.prototype, this, "call", [name, args, request]));
|
|
179
255
|
}
|
|
180
|
-
registerTool(server, name, entry) {
|
|
256
|
+
registerTool(server, name, entry, guideName) {
|
|
181
257
|
server.registerTool(name, {
|
|
182
258
|
...(entry?.tool.title ? { title: entry.tool.title } : {}),
|
|
183
|
-
...(entry ? { description: describe(entry, this.options.executeCode) } : {}),
|
|
259
|
+
...(entry ? { description: describe(entry, this.options.executeCode, guideName) } : {}),
|
|
184
260
|
inputSchema: passThroughSchema(entry?.tool.inputSchema),
|
|
185
|
-
...(entry ? { annotations: annotationsFor(entry.tool) } : {}),
|
|
186
|
-
...(entry
|
|
261
|
+
...(entry ? { annotations: annotationsFor(entry.tool, entry.stale !== undefined) } : {}),
|
|
262
|
+
...(entry
|
|
263
|
+
? {
|
|
264
|
+
_meta: {
|
|
265
|
+
"dev.connecta/address": `${entry.connector.id}.${entry.tool.name}`,
|
|
266
|
+
...(entry.stale
|
|
267
|
+
? {
|
|
268
|
+
"dev.connecta/stale": {
|
|
269
|
+
code: entry.stale.code,
|
|
270
|
+
ageMs: entry.stale.ageMs,
|
|
271
|
+
...(entry.stale.retryAfterMs === undefined ? {} : { retryAfterMs: entry.stale.retryAfterMs }),
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
: {}),
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
: {}),
|
|
187
278
|
}, authMethod((args, request) => Effect.gen({ self: this }, function* () {
|
|
188
|
-
|
|
189
|
-
return nativeRecovery(result, name);
|
|
279
|
+
return yield* authInvoke(this, "call", [name, args ?? {}, request]);
|
|
190
280
|
})));
|
|
191
281
|
}
|
|
192
282
|
/**
|
|
@@ -220,8 +310,12 @@ export class NativeSurface {
|
|
|
220
310
|
});
|
|
221
311
|
return;
|
|
222
312
|
}
|
|
313
|
+
const guides = yield* upwardStep(authCall(resolvedConnectorGuides, [
|
|
314
|
+
self.registry.listConnectors(),
|
|
315
|
+
]));
|
|
316
|
+
const guideNames = new Map(guides.map(({ connector, reference }) => [connector.id, reference]));
|
|
223
317
|
for (const entry of tools)
|
|
224
|
-
self.registerTool(server, entry.name, entry);
|
|
318
|
+
self.registerTool(server, entry.name, entry, guideNames.get(entry.connector.id));
|
|
225
319
|
})).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
|
|
226
320
|
}, NativeSurface.prototype.register);
|
|
227
321
|
registerAuthMethod(NativeSurface.prototype, "call", function (name, args, request) {
|
|
@@ -259,14 +353,15 @@ export class NativeSurface {
|
|
|
259
353
|
error: yield* upwardStep(authCall(withAuthorizationHandoff, [self.options.requestScope, error])),
|
|
260
354
|
});
|
|
261
355
|
result.isError = true;
|
|
262
|
-
return result;
|
|
356
|
+
return nativeRecovery(result, name);
|
|
263
357
|
}
|
|
264
358
|
const address = `${target.entry.connector.id}.${target.entry.tool.name}`;
|
|
265
|
-
|
|
266
|
-
|
|
359
|
+
const result = yield* upwardStep(authInvoke(self.options, "route", [
|
|
360
|
+
boundClassification(name) === "read" ? "call_tool" : "call_destructive_tool",
|
|
267
361
|
{ address, args },
|
|
268
362
|
request,
|
|
269
363
|
]));
|
|
364
|
+
return nativeRecovery(result, recoveryName(result, name, target.entry.names));
|
|
270
365
|
})).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
|
|
271
366
|
}, NativeSurface.prototype.call);
|
|
272
367
|
registerAuthMethod(NativeSurface.prototype, "resolve", function (name) {
|
|
@@ -275,19 +370,20 @@ export class NativeSurface {
|
|
|
275
370
|
requestScope: this.options.requestScope,
|
|
276
371
|
signal: this.options.requestSignal,
|
|
277
372
|
}, () => authFlow(function* () {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
// A
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
373
|
+
const possible = (...names) => self.registry.listConnectors().filter((connector) => names.some((held) => couldClaim(connector, held)));
|
|
374
|
+
const prefixed = possible(name);
|
|
375
|
+
let { loaded, byName } = yield* upwardStep(authInvoke(self, "claims", [prefixed]));
|
|
376
|
+
let found = byName.get(name);
|
|
377
|
+
if (found) {
|
|
378
|
+
// A tool that loses either name loses both, as listing omits it, so the
|
|
379
|
+
// other name's claimants decide this one too. One step is enough: only a
|
|
380
|
+
// tool sharing one of these two names can take them from this tool.
|
|
381
|
+
const every = possible(found.names.read, found.names.write);
|
|
382
|
+
if (every.length > prefixed.length) {
|
|
383
|
+
({ loaded, byName } = yield* upwardStep(authInvoke(self, "claims", [every])));
|
|
384
|
+
found = byName.get(name);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
291
387
|
if (found === null)
|
|
292
388
|
return { kind: "ambiguous" };
|
|
293
389
|
// An unavailable possible claimant cannot prove uniqueness.
|
|
@@ -303,8 +399,10 @@ export class NativeSurface {
|
|
|
303
399
|
requestScope: this.options.requestScope,
|
|
304
400
|
signal: this.options.requestSignal,
|
|
305
401
|
}, () => authFlow(function* () {
|
|
306
|
-
const { loaded, byName } = yield* upwardStep(authInvoke(self, "claims", []));
|
|
402
|
+
const { loaded, byName } = yield* upwardStep(authInvoke(self, "claims", [undefined, true]));
|
|
307
403
|
for (const entry of loaded) {
|
|
404
|
+
if ("tools" in entry && catalogStaleness(entry.tools))
|
|
405
|
+
self.options.staleCatalogs.push(entry.connector.id);
|
|
308
406
|
// Validate the whole connector before registering any of its tools.
|
|
309
407
|
const tool = "tools" in entry
|
|
310
408
|
? entry.tools.find((tool) => tool.inputSchema !== undefined && !nativeInputSchema(tool.inputSchema))
|
|
@@ -314,32 +412,56 @@ export class NativeSurface {
|
|
|
314
412
|
self.options.catalogErrors.push(entry.connector.id);
|
|
315
413
|
logFailure(self.options.logger, "native catalog unavailable", failureRecord({ connector: entry.connector.id, ...(tool ? { tool } : {}) }, "error" in entry ? entry.error : undefined));
|
|
316
414
|
}
|
|
317
|
-
|
|
415
|
+
if (self.options.catalogErrors.length || self.options.staleCatalogs.length)
|
|
416
|
+
self.options.onDegradedListing?.();
|
|
417
|
+
return [...byName].flatMap(([name, entry]) => entry !== null &&
|
|
418
|
+
entry.name === name &&
|
|
318
419
|
!self.options.catalogErrors.includes(entry.connector.id) &&
|
|
319
|
-
(!(self.options.readOnly || entry.connector.readOnly) || entry.tool.classification === "read")
|
|
420
|
+
(!(self.options.readOnly || entry.connector.readOnly) || entry.tool.classification === "read")
|
|
421
|
+
? [entry]
|
|
422
|
+
: []);
|
|
320
423
|
})).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
|
|
321
424
|
}, NativeSurface.prototype.list);
|
|
322
|
-
registerAuthMethod(NativeSurface.prototype, "claims", function (connectors) {
|
|
425
|
+
registerAuthMethod(NativeSurface.prototype, "claims", function (connectors, allowStale = false) {
|
|
323
426
|
const self = this;
|
|
324
427
|
return duringViewEffect({
|
|
325
428
|
requestScope: this.options.requestScope,
|
|
326
429
|
signal: this.options.requestSignal,
|
|
327
430
|
}, () => authFlow(function* () {
|
|
328
|
-
const loaded = yield* upwardStep(authInvoke(self.catalog, "listCatalogs", [true, connectors ?? self.registry.listConnectors()]));
|
|
329
|
-
const
|
|
431
|
+
const loaded = yield* upwardStep(authInvoke(self.catalog, "listCatalogs", [true, connectors ?? self.registry.listConnectors(), allowStale]));
|
|
432
|
+
const claimed = [];
|
|
433
|
+
const claimants = new Map();
|
|
330
434
|
for (const entry of loaded) {
|
|
331
435
|
if (!("tools" in entry))
|
|
332
436
|
continue;
|
|
333
437
|
for (const tool of entry.tools) {
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
438
|
+
const read = yield* upwardStep(authCall(nativeToolName, [entry.connector.id, tool.name, "read"]));
|
|
439
|
+
const write = yield* upwardStep(authCall(nativeToolName, [entry.connector.id, tool.name, "write"]));
|
|
440
|
+
const name = tool.classification === "read" ? read : write;
|
|
441
|
+
const stale = catalogStaleness(entry.tools);
|
|
442
|
+
claimed.push({
|
|
443
|
+
names: [read, write],
|
|
444
|
+
claim: {
|
|
445
|
+
name,
|
|
446
|
+
names: { read, write },
|
|
447
|
+
connector: entry.connector,
|
|
448
|
+
tool,
|
|
449
|
+
...(stale ? { stale } : {}),
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
for (const held of [read, write])
|
|
453
|
+
claimants.set(held, (claimants.get(held) ?? 0) + 1);
|
|
341
454
|
}
|
|
342
455
|
}
|
|
456
|
+
const byName = new Map();
|
|
457
|
+
for (const { names, claim } of claimed) {
|
|
458
|
+
// A tool that loses either name loses both: it is omitted, not half listed.
|
|
459
|
+
const collides = names.some((name) => claimants.get(name) > 1);
|
|
460
|
+
if (collides)
|
|
461
|
+
logFailure(self.options.logger, "native name collision", failureRecord({ connector: claim.connector.id, tool: claim.tool }));
|
|
462
|
+
for (const name of names)
|
|
463
|
+
byName.set(name, collides ? null : claim);
|
|
464
|
+
}
|
|
343
465
|
return { loaded, byName };
|
|
344
466
|
})).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
|
|
345
467
|
}, NativeSurface.prototype.claims);
|
package/dist/node.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface ListenOptions {
|
|
|
8
8
|
host?: string;
|
|
9
9
|
/** Pre-auth buffer cap, independent of the authenticated MCP cap. Default 4 MiB. */
|
|
10
10
|
maxBodyBytes?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Cap on the body bytes all in-flight requests hold together, counted as
|
|
13
|
+
* each arrives and released when its response finishes or its client leaves.
|
|
14
|
+
* Past it a request is answered 503 with `Retry-After`. Default 64 MiB, or
|
|
15
|
+
* `maxBodyBytes` when larger. May not be below `maxBodyBytes`.
|
|
16
|
+
*/
|
|
17
|
+
maxInFlightBodyBytes?: number;
|
|
11
18
|
/**
|
|
12
19
|
* Stop accepting connections on SIGTERM/SIGINT, let in-flight requests
|
|
13
20
|
* finish, drain deferred work, then exit. Default true — Docker sends
|
package/dist/node.js
CHANGED
|
@@ -5,13 +5,40 @@ import { failureRecord, logFailure } from "./operator-record.js";
|
|
|
5
5
|
import { configureGuardedTransport } from "./guarded-transport.js";
|
|
6
6
|
import { createNodeGuardedTransport } from "./http/node-transport.js";
|
|
7
7
|
import { CONFIG_DEFAULTS } from "./config-defaults.js";
|
|
8
|
+
import { ConfigError } from "./config-schema.js";
|
|
8
9
|
/** The adapter buffers before auth, bounded by the same default as the core. */
|
|
9
10
|
const DEFAULT_MAX_BODY_BYTES = CONFIG_DEFAULTS.admission.requests.maxBodyBytes;
|
|
11
|
+
/** Sixteen full default bodies; well above ordinary MCP traffic, whose bodies are small. */
|
|
12
|
+
const DEFAULT_MAX_IN_FLIGHT_BODY_BYTES = 64 * 1024 * 1024;
|
|
10
13
|
const BODY_TOO_LARGE = Symbol("body-too-large");
|
|
11
|
-
|
|
14
|
+
const BODIES_AT_CAPACITY = Symbol("bodies-at-capacity");
|
|
15
|
+
/** A name, IPv4 or bracketed IPv6 host with an optional port, and nothing else. */
|
|
16
|
+
const REQUEST_HOST = /^(?:[a-z0-9._-]+|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i;
|
|
17
|
+
/** `Host`, `X-Forwarded-Proto` or the request target is not a URL part; answered 400. */
|
|
18
|
+
class InvalidRequestUrl extends Error {
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The request's URL, read from values a client chooses. Each is checked on its
|
|
22
|
+
* own and the origin is parsed before the target joins it, so no value can move
|
|
23
|
+
* the boundary between authority, path and query for the routes behind it.
|
|
24
|
+
*/
|
|
25
|
+
function requestUrl(req) {
|
|
12
26
|
const host = req.headers.host ?? "localhost";
|
|
13
27
|
const proto = req.headers["x-forwarded-proto"] ?? "http";
|
|
14
|
-
|
|
28
|
+
// Origin-form only. Absolute-form and `*` have no path to route.
|
|
29
|
+
const target = req.url ?? "/";
|
|
30
|
+
if (!REQUEST_HOST.test(host) || typeof proto !== "string" || !/^https?$/i.test(proto) || !target.startsWith("/")) {
|
|
31
|
+
throw new InvalidRequestUrl();
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return `${new URL(`${proto}://${host}`).origin}${target}`;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new InvalidRequestUrl();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function toRequest(req, maxBodyBytes, holdBodyBytes, signal) {
|
|
41
|
+
const url = requestUrl(req);
|
|
15
42
|
const method = req.method ?? "GET";
|
|
16
43
|
const headers = new Headers();
|
|
17
44
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
@@ -29,6 +56,8 @@ async function toRequest(req, maxBodyBytes, signal) {
|
|
|
29
56
|
// rest of a body we have already decided to reject.
|
|
30
57
|
if (size > maxBodyBytes)
|
|
31
58
|
return BODY_TOO_LARGE;
|
|
59
|
+
if (!holdBodyBytes(chunk.length))
|
|
60
|
+
return BODIES_AT_CAPACITY;
|
|
32
61
|
chunks.push(chunk);
|
|
33
62
|
}
|
|
34
63
|
if (chunks.length)
|
|
@@ -64,6 +93,13 @@ export function listen(connecta, portOrOptions) {
|
|
|
64
93
|
const opts = typeof portOrOptions === "number" ? { port: portOrOptions } : portOrOptions;
|
|
65
94
|
configureGuardedTransport(createNodeGuardedTransport(opts.proxy));
|
|
66
95
|
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
96
|
+
const maxInFlightBodyBytes = opts.maxInFlightBodyBytes === undefined
|
|
97
|
+
? Math.max(DEFAULT_MAX_IN_FLIGHT_BODY_BYTES, maxBodyBytes)
|
|
98
|
+
: opts.maxInFlightBodyBytes;
|
|
99
|
+
if (!Number.isSafeInteger(maxInFlightBodyBytes) || maxInFlightBodyBytes < 1 || maxInFlightBodyBytes < maxBodyBytes) {
|
|
100
|
+
throw new ConfigError("listen: maxInFlightBodyBytes must be a whole number of bytes, at least maxBodyBytes.");
|
|
101
|
+
}
|
|
102
|
+
let inFlightBodyBytes = 0;
|
|
67
103
|
// Node has no ExecutionContext, so deferred work (activity sinks) would
|
|
68
104
|
// otherwise be an untracked floating promise. Track it here and drain it on
|
|
69
105
|
// shutdown so both runtimes behave the same.
|
|
@@ -78,6 +114,8 @@ export function listen(connecta, portOrOptions) {
|
|
|
78
114
|
const activeResponses = new Map();
|
|
79
115
|
let shuttingDown = false;
|
|
80
116
|
const server = createServer((req, res) => {
|
|
117
|
+
// Before the body: a client's timer has been running through its upload too.
|
|
118
|
+
const requestArrivedAt = Date.now();
|
|
81
119
|
awaitingRequest.delete(req.socket);
|
|
82
120
|
const socket = req.socket;
|
|
83
121
|
activeResponses.set(socket, (activeResponses.get(socket) ?? 0) + 1);
|
|
@@ -101,7 +139,19 @@ export function listen(connecta, portOrOptions) {
|
|
|
101
139
|
res.once("close", finished);
|
|
102
140
|
void (async () => {
|
|
103
141
|
const controller = new AbortController();
|
|
142
|
+
let heldBodyBytes = 0;
|
|
143
|
+
let bodyBytesReleased = false;
|
|
144
|
+
// Pipeline teardown can wait forever on a response stream's cancel().
|
|
145
|
+
// Release on the HTTP lifecycle too, and forbid late upload reservations.
|
|
146
|
+
const releaseBodyBytes = () => {
|
|
147
|
+
if (bodyBytesReleased)
|
|
148
|
+
return;
|
|
149
|
+
bodyBytesReleased = true;
|
|
150
|
+
inFlightBodyBytes -= heldBodyBytes;
|
|
151
|
+
heldBodyBytes = 0;
|
|
152
|
+
};
|
|
104
153
|
const abortRequest = () => {
|
|
154
|
+
releaseBodyBytes();
|
|
105
155
|
if (!controller.signal.aborted) {
|
|
106
156
|
controller.abort(new Error("HTTP client disconnected."));
|
|
107
157
|
}
|
|
@@ -117,8 +167,28 @@ export function listen(connecta, portOrOptions) {
|
|
|
117
167
|
req.once("aborted", abortRequest);
|
|
118
168
|
req.once("close", abortIncompleteRequest);
|
|
119
169
|
res.once("close", abortIncompleteResponse);
|
|
170
|
+
res.once("finish", releaseBodyBytes);
|
|
171
|
+
res.once("close", releaseBodyBytes);
|
|
172
|
+
// A queued pipelined response gets neither finish nor close when the
|
|
173
|
+
// socket goes away, and its completed upload's request is already closed.
|
|
174
|
+
socket.once("close", abortRequest);
|
|
175
|
+
const holdBodyBytes = (bytes) => {
|
|
176
|
+
if (bodyBytesReleased || inFlightBodyBytes + bytes > maxInFlightBodyBytes)
|
|
177
|
+
return false;
|
|
178
|
+
inFlightBodyBytes += bytes;
|
|
179
|
+
heldBodyBytes += bytes;
|
|
180
|
+
return true;
|
|
181
|
+
};
|
|
120
182
|
try {
|
|
121
|
-
const request = await toRequest(req, maxBodyBytes, controller.signal);
|
|
183
|
+
const request = await toRequest(req, maxBodyBytes, holdBodyBytes, controller.signal);
|
|
184
|
+
if (request === BODIES_AT_CAPACITY) {
|
|
185
|
+
res.statusCode = 503;
|
|
186
|
+
res.setHeader("Retry-After", "1");
|
|
187
|
+
// Abandoned mid-body, like the 413 below.
|
|
188
|
+
res.setHeader("Connection", "close");
|
|
189
|
+
res.end("Service Unavailable");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
122
192
|
if (request === BODY_TOO_LARGE) {
|
|
123
193
|
res.statusCode = 413;
|
|
124
194
|
// The request stream was abandoned mid-body, so this connection
|
|
@@ -128,10 +198,17 @@ export function listen(connecta, portOrOptions) {
|
|
|
128
198
|
res.end("Payload Too Large");
|
|
129
199
|
return;
|
|
130
200
|
}
|
|
131
|
-
const response = await connecta.fetch(request, undefined, ctx);
|
|
201
|
+
const response = await connecta.fetch(request, undefined, { ...ctx, requestArrivedAt });
|
|
132
202
|
await writeResponse(res, response);
|
|
133
203
|
}
|
|
134
204
|
catch (error) {
|
|
205
|
+
if (error instanceof InvalidRequestUrl) {
|
|
206
|
+
res.statusCode = 400;
|
|
207
|
+
// Refused before reading the body, so the connection cannot be reused.
|
|
208
|
+
res.setHeader("Connection", "close");
|
|
209
|
+
res.end("Bad Request");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
135
212
|
// A client that deliberately went away can make either the request
|
|
136
213
|
// handler or response pipeline reject. The request's abort signal is
|
|
137
214
|
// the authoritative classification: there is no caller left to answer,
|
|
@@ -152,6 +229,10 @@ export function listen(connecta, portOrOptions) {
|
|
|
152
229
|
res.end("Internal Server Error");
|
|
153
230
|
}
|
|
154
231
|
finally {
|
|
232
|
+
releaseBodyBytes();
|
|
233
|
+
res.removeListener("finish", releaseBodyBytes);
|
|
234
|
+
res.removeListener("close", releaseBodyBytes);
|
|
235
|
+
socket.removeListener("close", abortRequest);
|
|
155
236
|
req.removeListener("aborted", abortRequest);
|
|
156
237
|
req.removeListener("close", abortIncompleteRequest);
|
|
157
238
|
res.removeListener("close", abortIncompleteResponse);
|