@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
package/dist/config.js
CHANGED
|
@@ -82,7 +82,8 @@ const calls = {
|
|
|
82
82
|
/**
|
|
83
83
|
* Deadline (ms) for `call_tool`/`call_destructive_tool` calls that pass no
|
|
84
84
|
* `timeoutMs`. An explicit per-call value wins. Unset by default, when those
|
|
85
|
-
* calls fall back to `execute.hostCallTimeoutMs`.
|
|
85
|
+
* calls fall back to `execute.hostCallTimeoutMs`. `execute.watchdogMs` caps
|
|
86
|
+
* every one of them.
|
|
86
87
|
*
|
|
87
88
|
* Each call makes one attempt. `connecta.call` inside `execute_code` uses
|
|
88
89
|
* it too, ahead of `execute.hostCallTimeoutMs`; `connecta.result`,
|
|
@@ -118,16 +119,19 @@ const execute = {
|
|
|
118
119
|
* `connecta.read`, in milliseconds. Default 15_000. Discovery uses
|
|
119
120
|
* `discovery.probeTimeoutMs`. `connecta.call` and the direct call tools use it only when
|
|
120
121
|
* `calls.defaultTimeoutMs` is unset, and an explicit per-call `timeoutMs`
|
|
121
|
-
*
|
|
122
|
-
* such as analytics queries.
|
|
122
|
+
* wins, up to `execute.watchdogMs`. Raise it for providers whose legitimate
|
|
123
|
+
* calls run longer, such as analytics queries.
|
|
123
124
|
*/
|
|
124
125
|
hostCallTimeoutMs: whole({ min: 1, default: D.execute.hostCallTimeoutMs }),
|
|
125
126
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
127
|
+
* Ceiling on one `execute_code` or direct call response, in milliseconds,
|
|
128
|
+
* counted from the request's arrival and enforced outside the sandbox.
|
|
129
|
+
* Default 55_000: MCP clients commonly abandon a request at 60_000, and only
|
|
130
|
+
* a response sent before that reaches the agent as a typed result. It sits
|
|
131
|
+
* above both executors' own deadlines (QuickJS 30_000, the Dynamic Worker
|
|
132
|
+
* 50_000), so a run ends here only if its executor never settled, or if
|
|
133
|
+
* queueing spent its time. An explicit `timeoutMs` above it is cut to it. Raise it,
|
|
134
|
+
* and any executor deadline with it, only for clients whose own limit is raised.
|
|
131
135
|
*/
|
|
132
136
|
watchdogMs: whole({ min: 1, default: D.execute.watchdogMs }),
|
|
133
137
|
/**
|
|
@@ -143,6 +147,15 @@ const execute = {
|
|
|
143
147
|
* model step. From 4_000 to 1_000_000.
|
|
144
148
|
*/
|
|
145
149
|
maxResultChars: whole({ min: 4_000, max: 1_000_000, default: D.execute.maxResultChars }),
|
|
150
|
+
/**
|
|
151
|
+
* `false` stops listing `execute_read_code` on writable human endpoints. A
|
|
152
|
+
* read program runs unprompted on hosts that auto-approve reads, so a deployment
|
|
153
|
+
* that wants a person to see every program can turn it off. Default true.
|
|
154
|
+
*/
|
|
155
|
+
readRoute: opaque({
|
|
156
|
+
check: assertFlag,
|
|
157
|
+
resolve: (value) => value ?? D.execute.readRoute,
|
|
158
|
+
}),
|
|
146
159
|
};
|
|
147
160
|
function admissionPool(defaults) {
|
|
148
161
|
return {
|
|
@@ -189,7 +202,7 @@ function assertFlag(value, path) {
|
|
|
189
202
|
if (typeof value !== "boolean")
|
|
190
203
|
throw new ConfigError(`${path} must be true or false.`);
|
|
191
204
|
}
|
|
192
|
-
/** Longest pool `description`, in
|
|
205
|
+
/** Longest pool `description`, in UTF-16 code units, after trimming. */
|
|
193
206
|
const MAX_POOL_DESCRIPTION_LENGTH = 500;
|
|
194
207
|
/**
|
|
195
208
|
* A pool description is one line of prose for the model. C0 and C1 controls
|
|
@@ -209,9 +222,9 @@ function assertPoolDescription(value, path) {
|
|
|
209
222
|
const trimmed = typeof value === "string" ? value.trim() : undefined;
|
|
210
223
|
if (trimmed === undefined ||
|
|
211
224
|
trimmed.length === 0 ||
|
|
212
|
-
|
|
225
|
+
trimmed.length > MAX_POOL_DESCRIPTION_LENGTH ||
|
|
213
226
|
hasLineBreakOrControlCharacter(trimmed)) {
|
|
214
|
-
throw new ConfigError(`${path} must be text of 1 to ${MAX_POOL_DESCRIPTION_LENGTH}
|
|
227
|
+
throw new ConfigError(`${path} must be text of 1 to ${MAX_POOL_DESCRIPTION_LENGTH} UTF-16 code units after trimming, with no line breaks or control characters.`);
|
|
215
228
|
}
|
|
216
229
|
}
|
|
217
230
|
/** Shape only; each reference resolves against auth and agents in createConnecta. */
|
|
@@ -263,7 +276,7 @@ const pool = {
|
|
|
263
276
|
* What this pool is for, in plain words for the model: appended to the MCP
|
|
264
277
|
* `initialize` instructions at `/mcp/<name>` and `/agent/<name>`, so a
|
|
265
278
|
* caller learns its scope ("Team tier: shared tools only."). Trimmed, 1 to
|
|
266
|
-
* 500
|
|
279
|
+
* 500 UTF-16 code units, no line breaks or control characters. It grants and hides nothing; plain
|
|
267
280
|
* `/mcp` never shows it.
|
|
268
281
|
*/
|
|
269
282
|
description: opaque({ check: assertPoolDescription }),
|
|
@@ -359,11 +372,14 @@ const connectaConfig = {
|
|
|
359
372
|
/** Named tool pools, each served at `/mcp/<name>` to its members only. */
|
|
360
373
|
pools: record(object(pool)),
|
|
361
374
|
/** Exact connector id -> tool name -> verdict. Hidden tools cannot be discovered or called.
|
|
375
|
+
* `"read"` applies only where the source is silent, and asserts the call is safe to repeat; a
|
|
376
|
+
* source's declared write stays a write and is reported. `"read-repeatable"` also serves a
|
|
377
|
+
* declared write as a read: the deployment has checked it and accepts that hosts may repeat it.
|
|
362
378
|
* Unknown static names fail construction; absent runtime names stay dormant and are reported. */
|
|
363
379
|
classification: record(record(opaque({
|
|
364
380
|
check: (value, path) => {
|
|
365
|
-
if (value !== "read" && value !== "write" && value !== "hidden") {
|
|
366
|
-
throw new ConfigError(`${path} must be "read", "write", or "hidden".`);
|
|
381
|
+
if (value !== "read" && value !== "read-repeatable" && value !== "write" && value !== "hidden") {
|
|
382
|
+
throw new ConfigError(`${path} must be "read", "read-repeatable", "write", or "hidden".`);
|
|
367
383
|
}
|
|
368
384
|
},
|
|
369
385
|
}))),
|
|
@@ -396,8 +412,9 @@ const connectaConfig = {
|
|
|
396
412
|
resolve: (value) => value ?? memoryStorage(),
|
|
397
413
|
}),
|
|
398
414
|
/**
|
|
399
|
-
* Public base URL. Defaults to the request origin per-request
|
|
400
|
-
*
|
|
415
|
+
* Public base URL. Defaults to the request origin per-request, except that
|
|
416
|
+
* clerkAuth requires it here or as its own `publicUrl`. Configuring an HTTPS
|
|
417
|
+
* URL also redirects matching inbound HTTP requests to HTTPS.
|
|
401
418
|
*/
|
|
402
419
|
publicUrl: opaque({ check: httpUrl }),
|
|
403
420
|
/**
|
|
@@ -419,7 +436,10 @@ const connectaConfig = {
|
|
|
419
436
|
}
|
|
420
437
|
},
|
|
421
438
|
}),
|
|
422
|
-
/**
|
|
439
|
+
/**
|
|
440
|
+
* Replaceable owner-partitioned credential storage. Omit for config-owned
|
|
441
|
+
* secrets. With any OAuth connector it must implement `seal` and `open`.
|
|
442
|
+
*/
|
|
423
443
|
vault: opaque({
|
|
424
444
|
check: (value) => {
|
|
425
445
|
if (!hasMethods(value, ["get", "getAll", "set", "setAll", "metadata", "delete"]) ||
|
|
@@ -464,6 +484,14 @@ const connectaConfig = {
|
|
|
464
484
|
* saturation.
|
|
465
485
|
*/
|
|
466
486
|
admission: object(admission),
|
|
487
|
+
/** Payload-free request timing logs; 0 disables tracing, 1 samples every MCP request. */
|
|
488
|
+
timingTraceSampleRate: opaque({
|
|
489
|
+
check: (value, path) => {
|
|
490
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1)
|
|
491
|
+
throw new ConfigError(`${path} must be a finite number between 0 and 1.`);
|
|
492
|
+
},
|
|
493
|
+
resolve: (value) => value ?? 0,
|
|
494
|
+
}),
|
|
467
495
|
/** Diagnostic output. Use "silent" to disable all diagnostic logging. */
|
|
468
496
|
logger: opaque({
|
|
469
497
|
check: (value, path) => {
|
|
@@ -528,10 +556,21 @@ function assertCoherent(config) {
|
|
|
528
556
|
config.discovery.catalogMaxTtlSeconds > 86_400) {
|
|
529
557
|
throw new ConfigError("ConnectaConfig.discovery requires catalogMinTtlSeconds <= catalogMaxTtlSeconds <= 86400.");
|
|
530
558
|
}
|
|
559
|
+
const { vault } = config;
|
|
560
|
+
const oauth = config.connectors.find((connector) => connector.startAuth);
|
|
561
|
+
if (oauth && vault && (typeof vault.seal !== "function" || typeof vault.open !== "function")) {
|
|
562
|
+
throw new ConfigError(`ConnectaConfig.vault must implement both seal and open: connector "${oauth.id}" stores downstream OAuth ` +
|
|
563
|
+
"tokens, client registration and PKCE verifiers through it. Use encryptedCredentialVault(storage, key) " +
|
|
564
|
+
"from @zackbart/connecta/credentials, or add both methods.");
|
|
565
|
+
}
|
|
531
566
|
if (config.agents.some((agent) => !agent.vercel) && !config.accessTokens) {
|
|
532
567
|
throw new ConfigError("ConnectaConfig.agents needs accessTokens: accessTokens(storage) from @zackbart/connecta/auth/access-tokens " +
|
|
533
568
|
"holds each agent's token slot.");
|
|
534
569
|
}
|
|
570
|
+
if (!config.publicUrl && config.auth.some((provider) => provider.requiresPublicUrl)) {
|
|
571
|
+
throw new ConfigError("ConnectaConfig.publicUrl is required with clerkAuth, which checks tokens and sessions against it: " +
|
|
572
|
+
"set it, or clerkAuth({ publicUrl }), to this deployment's URL, http://localhost:<port> for local development.");
|
|
573
|
+
}
|
|
535
574
|
}
|
|
536
575
|
/**
|
|
537
576
|
* Validate a configuration and apply every default. Unknown keys are refused
|
|
@@ -55,7 +55,7 @@ export function remoteMcpCall(rt) {
|
|
|
55
55
|
}
|
|
56
56
|
: state.toolDefinitions.get(name);
|
|
57
57
|
if (toolDefinition?.execution?.taskSupport === "required") {
|
|
58
|
-
throw new ConnectorCallError("
|
|
58
|
+
throw new ConnectorCallError("unsupported_capability", `Tool "${name}" requires task-based execution, which Connecta does not support.`);
|
|
59
59
|
}
|
|
60
60
|
let output;
|
|
61
61
|
const outputError = (code, message) => rt.atMcpBoundary(ctx, new ProtocolError(code, message), "tools/call", client.transport, [ctx.signal]);
|
|
@@ -5,7 +5,7 @@ import { SdkHttpError, specTypeSchemas, UnauthorizedError } from "@modelcontextp
|
|
|
5
5
|
import { redactCatalog } from "../../sent-secrets.js";
|
|
6
6
|
import { MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "../../catalog-limits.js";
|
|
7
7
|
import { catalogItems, observeCompletedCatalogRefresh, observeCatalogFetch, observeCatalogExpiry, } from "../../catalog-cache.js";
|
|
8
|
-
import { ConnectorCallError, msg } from "../../errors.js";
|
|
8
|
+
import { ConnectorCallError, isAccessRefusal, msg } from "../../errors.js";
|
|
9
9
|
import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
|
|
10
10
|
import { assertDownstreamOutputSafe } from "../../downstream-input-context.js";
|
|
11
11
|
import { hasSdkPayload, ownAbortReason, requiresAuthorization } from "./errors.js";
|
|
@@ -78,6 +78,10 @@ export function remoteMcpCatalog(rt) {
|
|
|
78
78
|
listingContext(options),
|
|
79
79
|
authMethod(() => authFlow(function* () {
|
|
80
80
|
const before = toolPages;
|
|
81
|
+
// The one write that follows no cache read, which is what
|
|
82
|
+
// otherwise captures the fence this listing may lift.
|
|
83
|
+
if (options?.cacheMode === "refresh")
|
|
84
|
+
(yield authInvoke(cache, "captureFence", []));
|
|
81
85
|
let result;
|
|
82
86
|
try {
|
|
83
87
|
result = (yield authCall(listTools, [params, options]));
|
|
@@ -86,6 +90,9 @@ export function remoteMcpCatalog(rt) {
|
|
|
86
90
|
const bounded = rt.atMcpBoundary(cache.currentContext(), error, "tools/list", client.transport, [
|
|
87
91
|
cache.currentContext().signal,
|
|
88
92
|
]);
|
|
93
|
+
// Fenced as this listing was bound, whatever is current by now.
|
|
94
|
+
if (requiresAuthorization(bounded) || isAccessRefusal(bounded))
|
|
95
|
+
(yield authInvoke(cache, "fenceListing", []));
|
|
89
96
|
const transient = (error instanceof SdkHttpError && [502, 503, 504].includes(error.status)) ||
|
|
90
97
|
(bounded instanceof ConnectorCallError && bounded.code === "unavailable");
|
|
91
98
|
const fallback = transient && toolPages === before
|
|
@@ -247,32 +254,49 @@ export function remoteMcpCatalog(rt) {
|
|
|
247
254
|
}
|
|
248
255
|
}
|
|
249
256
|
state.toolDefinitions = new Map(clean.map((tool) => [tool.name, tool]));
|
|
250
|
-
return clean
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
:
|
|
275
|
-
|
|
257
|
+
return toolDefinitions(clean);
|
|
258
|
+
}));
|
|
259
|
+
// Served by discovery only, marked by the caller; the listing above stays
|
|
260
|
+
// the only source a call resolves through.
|
|
261
|
+
const peekCatalogOperation = authMethod((ctx) => authFlow(function* () {
|
|
262
|
+
if (liveListings.has(ctx))
|
|
263
|
+
return undefined;
|
|
264
|
+
const entry = (yield authCall(rt.peekCatalog, [ctx]));
|
|
265
|
+
if (!entry)
|
|
266
|
+
return undefined;
|
|
267
|
+
return {
|
|
268
|
+
tools: toolDefinitions(redactCatalog(ctx, entry.result.tools)),
|
|
269
|
+
fetchedAt: entry.fetchedAt,
|
|
270
|
+
expiresAt: entry.expiresAt,
|
|
271
|
+
};
|
|
272
|
+
}));
|
|
273
|
+
return { installCatalogIntake, listingContexts, liveListings, listToolsOperation, peekCatalogOperation };
|
|
274
|
+
}
|
|
275
|
+
function toolDefinitions(tools) {
|
|
276
|
+
return tools.map((t) => ({
|
|
277
|
+
name: t.name,
|
|
278
|
+
...(t.title !== undefined ? { title: t.title } : {}),
|
|
279
|
+
...(t.icons !== undefined
|
|
280
|
+
? {
|
|
281
|
+
icons: t.icons.filter((icon) => !/^data:/i.test(icon.src)),
|
|
282
|
+
}
|
|
283
|
+
: {}),
|
|
284
|
+
...(t.execution !== undefined ? { execution: t.execution } : {}),
|
|
285
|
+
...(t.description !== undefined ? { description: t.description } : {}),
|
|
286
|
+
...(t.inputSchema !== undefined
|
|
287
|
+
? {
|
|
288
|
+
inputSchema: t.inputSchema,
|
|
289
|
+
}
|
|
290
|
+
: {}),
|
|
291
|
+
...(t.outputSchema !== undefined
|
|
292
|
+
? {
|
|
293
|
+
outputSchema: t.outputSchema,
|
|
294
|
+
}
|
|
295
|
+
: {}),
|
|
296
|
+
...(t.annotations !== undefined
|
|
297
|
+
? {
|
|
298
|
+
annotations: t.annotations,
|
|
299
|
+
}
|
|
300
|
+
: {}),
|
|
276
301
|
}));
|
|
277
|
-
return { installCatalogIntake, listingContexts, liveListings, listToolsOperation };
|
|
278
302
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// served without connecting (INV-10).
|
|
4
4
|
import { routePhrases } from "../../agent-routes.js";
|
|
5
5
|
import { authView } from "../../auth/native-call.js";
|
|
6
|
-
import { authFlow, authCall, authInvoke, authMethod
|
|
7
|
-
import { openRequest, viewServices, viewSignal
|
|
6
|
+
import { authFlow, authCall, authInvoke, authMethod } from "../../auth/native.js";
|
|
7
|
+
import { openRequest, viewServices, viewSignal } from "../../runtime/call.js";
|
|
8
8
|
import { secretDigest } from "../../storage-digest.js";
|
|
9
9
|
import { downstreamClientMetadata, downstreamRedirectUri, remoteClientAuthMethod, selfHostedClientUrl, } from "../../auth/downstream-client-metadata.js";
|
|
10
10
|
import { Client, SdkErrorCode, SdkHttpError, UnauthorizedError } from "@modelcontextprotocol/client";
|
|
@@ -12,8 +12,8 @@ import { Deferred, Effect, Exit, Scope } from "effect";
|
|
|
12
12
|
import { KvOAuthProvider, refreshCoordinatorsByPartition } from "../../auth/downstream-oauth.js";
|
|
13
13
|
import { trackOAuthStartReset } from "../../auth/oauth-start-reset.js";
|
|
14
14
|
import { sentSecretsFor } from "../../sent-secrets.js";
|
|
15
|
-
import { catalogClientOptions, observeCatalogChange } from "../../catalog-cache.js";
|
|
16
|
-
import { ConnectorCallError, resendAdvice } from "../../errors.js";
|
|
15
|
+
import { catalogClientOptions, observeCatalogChange, observeCatalogExpiry, observeCatalogFetch, prefetchCatalog, retainedCatalogIdentity, } from "../../catalog-cache.js";
|
|
16
|
+
import { ConnectorCallError, isAccessRefusal, resendAdvice } from "../../errors.js";
|
|
17
17
|
import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
|
|
18
18
|
import { CONNECTA_VERSION } from "../../version.js";
|
|
19
19
|
import { oauthSealerFor } from "../../oauth-sealing.js";
|
|
@@ -23,13 +23,42 @@ import { downstreamInputCapabilities } from "../../downstream-input-context.js";
|
|
|
23
23
|
import { detach, runEdge } from "../../runtime/run.js";
|
|
24
24
|
import { requestPrincipalFor } from "../../request-principal.js";
|
|
25
25
|
import { callerOf } from "../../connector-caller.js";
|
|
26
|
-
import {
|
|
27
|
-
import { negotiationKeys, responseCacheKeys } from "../../storage/keys.js";
|
|
26
|
+
import { negotiationKeys } from "../../storage/keys.js";
|
|
28
27
|
import { forgetNegotiation, readNegotiation, storeNegotiation } from "../negotiation-cache.js";
|
|
29
28
|
import { carriesIllegalHeaderChar, credentialHeaderValue } from "./config.js";
|
|
30
29
|
import { AUTH_OR_THROTTLE, promised, recordWireErrors, requiresAuthorization } from "./errors.js";
|
|
31
30
|
import { MAX_TOOL_PAGES } from "./catalog.js";
|
|
32
31
|
import { SKILLS_EXTENSION } from "./resources.js";
|
|
32
|
+
/** Retain every abandoned lease release until the final scope close joins it. */
|
|
33
|
+
export function retainCleanup(state, release) {
|
|
34
|
+
const pending = detach(release);
|
|
35
|
+
state.cleanup.add(pending);
|
|
36
|
+
if (state.closing)
|
|
37
|
+
state.closing = Promise.all([state.closing, pending]).then(() => { });
|
|
38
|
+
// Register at the release itself as well: a late connect can acquire a
|
|
39
|
+
// session after the owner's first close has already settled.
|
|
40
|
+
try {
|
|
41
|
+
state.defer?.(pending);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Runtime hooks are best-effort; the bounded cleanup is already running.
|
|
45
|
+
}
|
|
46
|
+
void pending.then(() => state.cleanup.delete(pending));
|
|
47
|
+
return pending;
|
|
48
|
+
}
|
|
49
|
+
/** Scope.closeUnsafe returns nothing on a second close, even during release. */
|
|
50
|
+
export function closeConnectionScope(state) {
|
|
51
|
+
if (!state.closing) {
|
|
52
|
+
const release = Scope.closeUnsafe(state.scope, Exit.void);
|
|
53
|
+
if (release)
|
|
54
|
+
retainCleanup(state, release);
|
|
55
|
+
state.closing = (async () => {
|
|
56
|
+
while (state.cleanup.size)
|
|
57
|
+
await Promise.all(state.cleanup);
|
|
58
|
+
})();
|
|
59
|
+
}
|
|
60
|
+
return state.closing;
|
|
61
|
+
}
|
|
33
62
|
/** One connector's connection states, credential reads and connect attempts. */
|
|
34
63
|
export function remoteMcpConnection(rt) {
|
|
35
64
|
const { requestAuth, forwarding, id, staticClient, clientMetadataUrl, opts, oauthScope, authorizationParams, destination, isOauth, extraHeaders, credentialHeader, credentialScheme, skillsEnabled, principalPolicy, credentialAuth, } = rt;
|
|
@@ -105,6 +134,9 @@ export function remoteMcpConnection(rt) {
|
|
|
105
134
|
if (!state) {
|
|
106
135
|
state = {
|
|
107
136
|
scope: Scope.makeUnsafe(),
|
|
137
|
+
closing: null,
|
|
138
|
+
cleanup: new Set(),
|
|
139
|
+
defer: ctx.defer,
|
|
108
140
|
lease: null,
|
|
109
141
|
client: null,
|
|
110
142
|
transport: null,
|
|
@@ -120,10 +152,8 @@ export function remoteMcpConnection(rt) {
|
|
|
120
152
|
states.set(key, state);
|
|
121
153
|
const owned = state;
|
|
122
154
|
owner.own(() => {
|
|
123
|
-
|
|
155
|
+
void closeConnectionScope(owned);
|
|
124
156
|
reset(owned);
|
|
125
|
-
if (closing)
|
|
126
|
-
detach(closing);
|
|
127
157
|
});
|
|
128
158
|
}
|
|
129
159
|
return state;
|
|
@@ -191,7 +221,7 @@ export function remoteMcpConnection(rt) {
|
|
|
191
221
|
reset(state);
|
|
192
222
|
const release = lease && Scope.closeUnsafe(lease, Exit.void);
|
|
193
223
|
if (release)
|
|
194
|
-
|
|
224
|
+
retainCleanup(state, release);
|
|
195
225
|
};
|
|
196
226
|
const waitForConnection = (attempt, signal) => runEdge(Deferred.await(attempt), signal ? { signal } : {});
|
|
197
227
|
const oauthConfigFor = (ctx) => isOauth
|
|
@@ -258,7 +288,7 @@ export function remoteMcpConnection(rt) {
|
|
|
258
288
|
* same cache through the SDK, so it skips this read. What was already read
|
|
259
289
|
* is handed to that connect, so a resolver runs once per listing.
|
|
260
290
|
*/
|
|
261
|
-
const
|
|
291
|
+
const readCatalog = authMethod((ctx, use) => authFlow(function* () {
|
|
262
292
|
const principal = principalPolicy
|
|
263
293
|
? requestPrincipalFor(principalPolicy, ctx, (reason) => new rt.CredentialRequiredError(`Connector "${id}" ${reason}.`))
|
|
264
294
|
: undefined;
|
|
@@ -274,6 +304,7 @@ export function remoteMcpConnection(rt) {
|
|
|
274
304
|
try {
|
|
275
305
|
let epoch = "";
|
|
276
306
|
let identity;
|
|
307
|
+
let pinnedIdentity;
|
|
277
308
|
if (isOauth) {
|
|
278
309
|
// Read-only grant snapshots: no in-place sealing, epoch binding or
|
|
279
310
|
// refresh. Every identity check during cache acceptance re-reads the
|
|
@@ -284,6 +315,7 @@ export function remoteMcpConnection(rt) {
|
|
|
284
315
|
epoch = snapshot.epoch;
|
|
285
316
|
if (provider.isOperatorDisconnectedEpoch(epoch) || !snapshot.tokens)
|
|
286
317
|
return preread;
|
|
318
|
+
pinnedIdentity = yield authCall(secretDigest, [ctx, JSON.stringify(snapshot.tokens)]);
|
|
287
319
|
const bound = epoch;
|
|
288
320
|
identity = authMethod(() => authFlow(function* () {
|
|
289
321
|
const current = (yield authInvoke(provider, "peekGrant", []));
|
|
@@ -297,16 +329,18 @@ export function remoteMcpConnection(rt) {
|
|
|
297
329
|
const digest = (yield authCall(negotiationKey, [ctx, credentialDigest, epoch]));
|
|
298
330
|
// Both reads only locate cached data. Acceptance and dispatch keep
|
|
299
331
|
// their fresh generation and credential checks.
|
|
300
|
-
yield
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
332
|
+
yield authCall(prefetchCatalog, [
|
|
333
|
+
ctx,
|
|
334
|
+
id,
|
|
335
|
+
(yield authCall(catalogConfigFor, [ctx])),
|
|
336
|
+
JSON.stringify([epoch, credentialDigest]),
|
|
337
|
+
catalogSharing,
|
|
338
|
+
pinnedIdentity,
|
|
339
|
+
negotiationKeys.verdict(digest),
|
|
340
|
+
]);
|
|
307
341
|
const prior = (yield authCall(readNegotiation, [ctx, digest]));
|
|
308
342
|
preread.negotiation = { digest, prior };
|
|
309
|
-
if (prior?.kind === "modern")
|
|
343
|
+
if (prior?.kind === "modern" && use === "list")
|
|
310
344
|
return preread;
|
|
311
345
|
}
|
|
312
346
|
const cache = (yield authCall(catalogClientOptions, [
|
|
@@ -320,6 +354,10 @@ export function remoteMcpConnection(rt) {
|
|
|
320
354
|
identity,
|
|
321
355
|
true,
|
|
322
356
|
]));
|
|
357
|
+
if (use === "peek") {
|
|
358
|
+
const entry = (yield authInvoke(cache, "peekTools", [ctx]));
|
|
359
|
+
return entry ? { ...preread, peek: entry } : preread;
|
|
360
|
+
}
|
|
323
361
|
const result = (yield authInvoke(cache, "cachedTools", [ctx]));
|
|
324
362
|
if (result)
|
|
325
363
|
return { tools: result.tools };
|
|
@@ -331,6 +369,34 @@ export function remoteMcpConnection(rt) {
|
|
|
331
369
|
}
|
|
332
370
|
return preread;
|
|
333
371
|
}));
|
|
372
|
+
// A peek is the first half of the listing that may follow it: that listing
|
|
373
|
+
// takes what the peek read, once, so a discovery that peeks first still reads
|
|
374
|
+
// its stored entry and runs a credential resolver once. Nothing is taken if
|
|
375
|
+
// the catalog was invalidated or the credential changed in between, and the
|
|
376
|
+
// whole hand-off lives and dies with the request scope.
|
|
377
|
+
const peeks = new WeakMap();
|
|
378
|
+
const peekCatalog = authMethod((ctx) => authFlow(function* () {
|
|
379
|
+
// A connector known to need authorization is never listed from a cache.
|
|
380
|
+
if (stateFor(ctx).authRequired)
|
|
381
|
+
return undefined;
|
|
382
|
+
const read = (yield authCall(readCatalog, [ctx, "peek"]));
|
|
383
|
+
peeks.set(ctx.requestScope ?? ctx, { identity: retainedCatalogIdentity(ctx), read });
|
|
384
|
+
return read.peek;
|
|
385
|
+
}));
|
|
386
|
+
const cachedCatalog = authMethod((ctx) => authFlow(function* () {
|
|
387
|
+
const scope = ctx.requestScope ?? ctx;
|
|
388
|
+
const handed = peeks.get(scope);
|
|
389
|
+
peeks.delete(scope);
|
|
390
|
+
if (!handed || handed.identity !== retainedCatalogIdentity(ctx))
|
|
391
|
+
return (yield authCall(readCatalog, [ctx, "list"]));
|
|
392
|
+
const { peek, ...preread } = handed.read;
|
|
393
|
+
if (!peek || peek.expiresAt <= Date.now())
|
|
394
|
+
return preread;
|
|
395
|
+
// The listing's own view keeps the provenance a direct read would record.
|
|
396
|
+
observeCatalogFetch(ctx, peek.fetchedAt);
|
|
397
|
+
observeCatalogExpiry(ctx, "tools/list", typeof peek.result.ttlMs === "number" ? peek.result.ttlMs : peek.expiresAt - peek.fetchedAt, peek.expiresAt);
|
|
398
|
+
return { tools: peek.result.tools };
|
|
399
|
+
}));
|
|
334
400
|
const ensureConnected = authMethod((ctx, state, preread) => authFlow(function* () {
|
|
335
401
|
let joined = state.connecting;
|
|
336
402
|
if (joined)
|
|
@@ -520,6 +586,25 @@ export function remoteMcpConnection(rt) {
|
|
|
520
586
|
])));
|
|
521
587
|
if (!owned())
|
|
522
588
|
return yield* Effect.fail(rt.scopeEndedError());
|
|
589
|
+
// The generation a refused handshake is about, as it is now. Read from
|
|
590
|
+
// this connect's memo, so a connect that is not refused costs no I/O.
|
|
591
|
+
const handshakeBinding = yield* promised(authMethod(() => authInvoke(cacheOptions, "bindListing", [ctx, { credentialIdentity: undefined }]))).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
592
|
+
if (!owned())
|
|
593
|
+
return yield* Effect.fail(rt.scopeEndedError());
|
|
594
|
+
// What the attempt sent, in memory: never the grant as it is by now. A
|
|
595
|
+
// provider that read no tokens in this epoch sent none and has no catalog.
|
|
596
|
+
const fenceHandshake = authMethod(() => authFlow(function* () {
|
|
597
|
+
let credentialIdentity;
|
|
598
|
+
if (provider) {
|
|
599
|
+
if (!provider.observedTokens || provider.observed?.epoch !== genAtStart)
|
|
600
|
+
return;
|
|
601
|
+
credentialIdentity = yield authCall(secretDigest, [ctx, JSON.stringify(provider.observedTokens)]);
|
|
602
|
+
}
|
|
603
|
+
(yield authInvoke(cacheOptions, "fenceTools", [
|
|
604
|
+
ctx,
|
|
605
|
+
{ ...handshakeBinding, credentialIdentity },
|
|
606
|
+
]));
|
|
607
|
+
}));
|
|
523
608
|
const makeClient = () => {
|
|
524
609
|
const { intake: _intake, completeCatalogRefresh: _refresh, withListing: _listing, currentContext: _context, cachedTools: _cached, ...clientOptions } = cacheOptions;
|
|
525
610
|
const client = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
|
|
@@ -587,7 +672,12 @@ export function remoteMcpConnection(rt) {
|
|
|
587
672
|
}
|
|
588
673
|
}))).pipe(Effect.mapError((err) => rt.atMcpBoundary(ctx,
|
|
589
674
|
// First, while the runtime's text can still be read for it.
|
|
590
|
-
rt.withoutCredential(ctx, err, credentialValue, credentialFramed), "MCP handshake", t, [ctx.signal, connectionAbort.signal, handshakeAbort.signal]))
|
|
675
|
+
rt.withoutCredential(ctx, err, credentialValue, credentialFramed), "MCP handshake", t, [ctx.signal, connectionAbort.signal, handshakeAbort.signal])),
|
|
676
|
+
// Before the lease closes the signal this write runs under. Waiters see
|
|
677
|
+
// the failure after it, and it never replaces the refusal.
|
|
678
|
+
Effect.tapError((error) => handshakeBinding && (requiresAuthorization(error) || isAccessRefusal(error))
|
|
679
|
+
? promised(fenceHandshake).pipe(Effect.catch(() => Effect.void))
|
|
680
|
+
: Effect.void));
|
|
591
681
|
held.client = c;
|
|
592
682
|
// A probe deadline can end its scope while connect is still in flight.
|
|
593
683
|
// The transport is closed immediately by closeScope; if connect wins
|
|
@@ -644,13 +734,13 @@ export function remoteMcpConnection(rt) {
|
|
|
644
734
|
state.authRequired = true;
|
|
645
735
|
const release = Scope.closeUnsafe(lease, Exit.void);
|
|
646
736
|
if (release)
|
|
647
|
-
|
|
737
|
+
retainCleanup(state, release);
|
|
648
738
|
}
|
|
649
739
|
else {
|
|
650
740
|
// Whoever abandoned this attempt closed the lease already, while
|
|
651
741
|
// the connect was in flight. A session it acquired after that
|
|
652
742
|
// close still owes its DELETE.
|
|
653
|
-
|
|
743
|
+
retainCleanup(state, rt.closeConnection(held.client, held.transport, ctx.logger));
|
|
654
744
|
}
|
|
655
745
|
if (err instanceof UnauthorizedError) {
|
|
656
746
|
return Effect.fail(carryFailureFacts(err, rt.authRequiredError(ctx)));
|
|
@@ -675,6 +765,7 @@ export function remoteMcpConnection(rt) {
|
|
|
675
765
|
ensureConnected,
|
|
676
766
|
requestOptions,
|
|
677
767
|
cachedCatalog,
|
|
768
|
+
peekCatalog,
|
|
678
769
|
cacheOwner,
|
|
679
770
|
reset,
|
|
680
771
|
states,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The downstream OAuth surface: bounded SDK flows, early renewal before a
|
|
2
2
|
// call, unchallenged consent starts, and the connector's auth operations.
|
|
3
|
+
import { retainCleanup } from "./connection.js";
|
|
3
4
|
import { authView } from "../../auth/native-call.js";
|
|
4
5
|
import { authFlow, authCall, authInvoke, authMethod } from "../../auth/native.js";
|
|
5
6
|
import { viewSignal } from "../../runtime/call.js";
|
|
@@ -10,7 +11,6 @@ import { byteReadResponse } from "../../byte-read-response.js";
|
|
|
10
11
|
import { requestSentSecrets, sentSecretsFor } from "../../sent-secrets.js";
|
|
11
12
|
import { ConnectorCallError, resendAdvice, WithheldTextError } from "../../errors.js";
|
|
12
13
|
import { attachFailureFacts, carryFailureFacts, OAUTH_ERROR_CODES } from "../../operator-record.js";
|
|
13
|
-
import { detach } from "../../runtime/run.js";
|
|
14
14
|
import { retryAfterMs } from "../../http-policy.js";
|
|
15
15
|
import { sendCredentialPost } from "../../guarded-transport.js";
|
|
16
16
|
import { UnboundOAuthFlowsError, downstreamCallError, errorKind, hasSdkPayload, ownAbortReason, requiresAuthorization, startMessage, withheldAs, } from "./errors.js";
|
|
@@ -376,7 +376,7 @@ export function remoteMcpOAuth(rt) {
|
|
|
376
376
|
]));
|
|
377
377
|
}
|
|
378
378
|
finally {
|
|
379
|
-
|
|
379
|
+
retainCleanup(state, rt.closeConnection(null, t, ctx.logger));
|
|
380
380
|
}
|
|
381
381
|
})), true));
|
|
382
382
|
connector.verifyState = authMethod((oauthState, ctx) => authFlow(function* () {
|
|
@@ -345,8 +345,11 @@ export function remoteMcpResources(rt) {
|
|
|
345
345
|
if (client.getProtocolEra() !== "modern" ||
|
|
346
346
|
!skillObject(capabilities?.resources) ||
|
|
347
347
|
!skillObject(extension) ||
|
|
348
|
+
// The released Beta specification defines no revision setting.
|
|
349
|
+
// Never enable a future breaking revision under the same ID.
|
|
350
|
+
extension.revision !== undefined ||
|
|
348
351
|
(extension.directoryRead !== undefined && typeof extension.directoryRead !== "boolean")) {
|
|
349
|
-
throw new ConnectorCallError("
|
|
352
|
+
throw new ConnectorCallError("unsupported_capability", "The downstream did not declare a valid Skills extension and resources capability.");
|
|
350
353
|
}
|
|
351
354
|
const result = (yield authCall(run, [client, state]));
|
|
352
355
|
skillScopeOpen(ctx, state);
|
|
@@ -415,7 +418,7 @@ export function remoteMcpResources(rt) {
|
|
|
415
418
|
rt.listingContexts.set(options, ctx);
|
|
416
419
|
try {
|
|
417
420
|
if (!client.getServerCapabilities()?.resources)
|
|
418
|
-
throw
|
|
421
|
+
throw new ConnectorCallError("unsupported_capability", "The downstream did not declare resources support.");
|
|
419
422
|
const resources = (yield authInvoke(client, "listResources", [undefined, options]));
|
|
420
423
|
const templates = (yield authInvoke(client, "listResourceTemplates", [
|
|
421
424
|
undefined,
|
|
@@ -18,7 +18,7 @@ import { learnedUrlRefusal } from "../../url-safety.js";
|
|
|
18
18
|
import { RequestScope } from "../../runtime/request-scope.js";
|
|
19
19
|
import { Context } from "effect";
|
|
20
20
|
import { downstreamWriteContinuation } from "../../downstream-input-context.js";
|
|
21
|
-
import { guardedTransport, sendGuarded, sendCredentialPost } from "../../guarded-transport.js";
|
|
21
|
+
import { learnedDestination, guardedTransport, sendGuarded, sendCredentialPost, } from "../../guarded-transport.js";
|
|
22
22
|
import { dispatchProviderRequest, recordProviderResponse, knownProviderFailure, providerRequest, renewedAfterUnauthorized, } from "../../provider-call.js";
|
|
23
23
|
import { recordRequestField } from "../../provider-call-facts.js";
|
|
24
24
|
import { AUTH_OR_THROTTLE, errorBodyText, jsonRpcErrorBody, keepsModernVerdict, promised } from "./errors.js";
|
|
@@ -128,11 +128,25 @@ labelErrorClass(RemoteMcpDestinationError, "RemoteMcpDestinationError");
|
|
|
128
128
|
*/
|
|
129
129
|
export function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
|
|
130
130
|
return authMethod((input, init) => authFlow(function* () {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
let target = new URL(input);
|
|
132
|
+
for (let hop = 0; hop < 10; hop++) {
|
|
133
|
+
const reason = learnedUrlRefusal(configured, target);
|
|
134
|
+
if (reason !== undefined)
|
|
135
|
+
throw new RemoteMcpDestinationError(connectorId, reason);
|
|
136
|
+
const marked = { ...init, redirect: "manual", [learnedDestination]: configured };
|
|
137
|
+
const response = (yield authCall(baseFetch, [target, marked]));
|
|
138
|
+
if (!REDIRECT_STATUSES.has(response.status) ||
|
|
139
|
+
!response.headers.has("location") ||
|
|
140
|
+
(init?.method ?? "GET").toUpperCase() !== "GET")
|
|
141
|
+
return byteReadResponse(response);
|
|
142
|
+
const location = response.headers.get("location");
|
|
143
|
+
void response.body?.cancel().catch(() => { });
|
|
144
|
+
const redirected = new URL(location, target);
|
|
145
|
+
if (redirected.origin !== target.origin)
|
|
146
|
+
throw new RemoteMcpDestinationError(connectorId, "cross-origin redirect refused");
|
|
147
|
+
target = redirected;
|
|
134
148
|
}
|
|
135
|
-
|
|
149
|
+
throw new RemoteMcpDestinationError(connectorId, "redirect limit exceeded");
|
|
136
150
|
}));
|
|
137
151
|
}
|
|
138
152
|
/**
|
|
@@ -289,7 +303,11 @@ export function remoteMcpTransport(rt) {
|
|
|
289
303
|
if (customFetch && new URL(input instanceof Request ? input.url : String(input)).origin !== destination.origin)
|
|
290
304
|
throw new ConnectorCallError("connector_call_failed", `Connector "${id}" refused a request outside its origin; nothing was sent.`, { retryable: false });
|
|
291
305
|
// The deployment's fetch owns the wire: each write is handed over once.
|
|
292
|
-
const sender = flight?.write
|
|
306
|
+
const sender = flight?.write
|
|
307
|
+
? (customSender ?? guardedTransport(true))
|
|
308
|
+
: init?.[learnedDestination]
|
|
309
|
+
? guardedTransport(false)
|
|
310
|
+
: undefined;
|
|
293
311
|
if (flight?.write) {
|
|
294
312
|
if (flight.sent) {
|
|
295
313
|
if (flight.request && flight.authorization)
|