@zackbart/connecta 0.28.0 → 0.29.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 +961 -5
- package/README.md +62 -39
- package/bin/connecta.mjs +110 -139
- package/bin/meta-tool-names.d.mts +2 -0
- package/bin/meta-tool-names.mjs +9 -0
- package/bin/version.mjs +3 -0
- package/dist/access-tokens.js +53 -63
- package/dist/activity-request.js +8 -0
- package/dist/activity.d.ts +76 -42
- package/dist/activity.js +110 -42
- package/dist/auth/clerk-sdk/client.d.ts +65 -0
- package/dist/auth/clerk-sdk/client.js +5 -0
- package/dist/auth/clerk-sdk/generated.js +1845 -0
- package/dist/auth/clerk-transport.js +19 -0
- package/dist/auth/clerk.d.ts +17 -7
- package/dist/auth/clerk.js +189 -107
- package/dist/auth/cloudflare-access.js +25 -26
- package/dist/auth/downstream-client-metadata.d.ts +29 -0
- package/dist/auth/downstream-client-metadata.js +133 -0
- package/dist/auth/downstream-oauth.js +1632 -1509
- package/dist/auth/downstream-revocation.js +106 -0
- package/dist/auth/oauth-token-response.js +231 -0
- package/dist/auth/oauth-v2-migration.js +116 -0
- package/dist/auth/static-oauth.js +526 -0
- package/dist/auth-elicitation.js +283 -0
- package/dist/branding.d.ts +8 -16
- package/dist/branding.js +26 -59
- package/dist/byte-read-response.js +39 -0
- package/dist/call-admission.js +12 -16
- package/dist/catalog-cache.js +739 -0
- package/dist/catalog-drift.d.ts +50 -15
- package/dist/catalog-drift.js +365 -128
- package/dist/catalog-fingerprint.js +2 -4
- package/dist/catalog-freshness.js +13 -0
- package/dist/catalog-limits.js +2 -2
- package/dist/catalog-service.js +368 -224
- package/dist/catalog.js +43 -84
- package/dist/concurrency.js +3 -4
- package/dist/config-defaults.d.ts +39 -0
- package/dist/config-defaults.js +46 -0
- package/dist/config-schema.d.ts +181 -0
- package/dist/config-schema.js +435 -0
- package/dist/config-value-sources.js +102 -0
- package/dist/config.d.ts +631 -0
- package/dist/config.js +503 -0
- package/dist/connector-access.d.ts +1 -0
- package/dist/connector-access.js +13 -15
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +15 -6
- package/dist/connector-scope.js +1 -1
- package/dist/connectors/api-connector.d.ts +220 -0
- package/dist/connectors/api-connector.js +214 -0
- package/dist/connectors/api.d.ts +8 -71
- package/dist/connectors/api.js +13 -99
- package/dist/connectors/guarded-fetch.d.ts +144 -0
- package/dist/connectors/guarded-fetch.js +108 -25
- package/dist/connectors/negotiation-cache.js +46 -0
- package/dist/connectors/option-shapes.js +27 -0
- package/dist/connectors/remote-mcp.d.ts +30 -4
- package/dist/connectors/remote-mcp.js +1780 -351
- package/dist/connectors/resource-uri.js +335 -0
- package/dist/credential-contract.d.ts +10 -0
- package/dist/credential-rules.js +1 -3
- package/dist/credential-url.js +108 -0
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +27 -18
- package/dist/d1.d.ts +130 -0
- package/dist/d1.js +290 -0
- package/dist/describe-config.d.ts +165 -0
- package/dist/describe-config.js +315 -0
- package/dist/described.js +106 -0
- package/dist/downstream-input-context.js +119 -0
- package/dist/downstream-input.js +323 -0
- package/dist/errors.d.ts +97 -20
- package/dist/errors.js +158 -80
- package/dist/execute.js +453 -305
- package/dist/executor-admission.d.ts +1 -0
- package/dist/executor-admission.js +11 -15
- package/dist/executor-contract.d.ts +20 -0
- package/dist/executor-contract.js +48 -0
- package/dist/executor-result.js +24 -9
- package/dist/executors/quickjs-child.js +3 -4
- package/dist/executors/quickjs-runtime.js +147 -59
- package/dist/executors/quickjs.js +29 -35
- package/dist/favicon.js +1 -2
- package/dist/fix-prompt.js +1 -3
- package/dist/guest-runtime.js +188 -0
- package/dist/guest-types.d.ts +176 -0
- package/dist/guest-types.js +1 -0
- package/dist/html-security.js +24 -0
- package/dist/identity.js +1 -3
- package/dist/inbound-credential.js +52 -0
- package/dist/index.d.ts +25 -257
- package/dist/index.js +65 -478
- package/dist/invocation-auth.js +69 -0
- package/dist/invocation.js +230 -73
- package/dist/json-schema.d.ts +1 -1
- package/dist/mcp-client-context.js +35 -0
- package/dist/mcp-result.js +11 -7
- package/dist/meta-output.js +76 -0
- package/dist/meta-tool-names.d.ts +2 -0
- package/dist/meta-tool-names.js +3 -0
- package/dist/meta-tools.js +285 -209
- package/dist/module-contracts.d.ts +8 -23
- package/dist/node.d.ts +0 -2
- package/dist/node.js +2 -2
- package/dist/oauth-callback-outcome.js +1 -3
- package/dist/oauth-handoff.js +117 -0
- package/dist/oauth-partition.js +17 -0
- package/dist/oauth-sealing.js +20 -0
- package/dist/operator-record.d.ts +137 -0
- package/dist/operator-record.js +420 -0
- package/dist/operator-ui/assets.js +31 -0
- package/dist/operator-ui/contract.d.ts +72 -0
- package/dist/operator-ui/contract.js +1 -0
- package/dist/operator-ui/fix-prompts.d.ts +18 -0
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/model.d.ts +12 -87
- package/dist/operator-ui/model.js +2 -9
- package/dist/operator-ui/setup-commands.js +1 -3
- package/dist/operator-ui/view.d.ts +323 -0
- package/dist/operator-ui/view.js +62 -189
- package/dist/page-styles.js +2 -3
- package/dist/program-source.js +14 -2
- package/dist/{exempt-writes.js → program-writes.js} +55 -18
- package/dist/provider.d.ts +142 -0
- package/dist/provider.js +187 -0
- package/dist/providers/_shared/google/result-size.js +57 -0
- package/dist/providers/_shared/google/service-account.d.ts +58 -0
- package/dist/providers/_shared/google/service-account.js +500 -0
- package/dist/providers/_shared/google/workspace.d.ts +276 -0
- package/dist/providers/_shared/google/workspace.js +518 -0
- package/dist/providers/basecamp/index.d.ts +43 -0
- package/dist/providers/basecamp/index.js +997 -0
- package/dist/providers/basecamp/skill.generated.js +9 -0
- package/dist/providers/breeze/index.d.ts +34 -0
- package/dist/providers/breeze/index.js +1446 -0
- package/dist/providers/breeze/skill.generated.js +9 -0
- package/dist/providers/ccb/index.d.ts +103 -0
- package/dist/providers/ccb/index.js +1725 -0
- package/dist/providers/ccb/skill.generated.js +9 -0
- package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
- package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
- package/dist/providers/cloudflare/skill.generated.js +10 -0
- package/dist/providers/docs/index.d.ts +54 -0
- package/dist/providers/docs/index.js +1237 -0
- package/dist/providers/docs/skill.generated.js +9 -0
- package/dist/providers/drive/index.d.ts +53 -0
- package/dist/providers/drive/index.js +1523 -0
- package/dist/providers/drive/skill.generated.js +9 -0
- package/dist/providers/forms/index.d.ts +54 -0
- package/dist/providers/forms/index.js +1217 -0
- package/dist/providers/forms/skill.generated.js +11 -0
- package/dist/providers/github/auth.d.ts +32 -0
- package/dist/providers/github/auth.js +361 -0
- package/dist/providers/github/hosted.js +218 -0
- package/dist/providers/github/index.d.ts +13 -0
- package/dist/providers/github/index.js +140 -0
- package/dist/providers/github/key.js +100 -0
- package/dist/providers/github/rest.js +321 -0
- package/dist/providers/github/scope.d.ts +29 -0
- package/dist/providers/github/scope.js +124 -0
- package/dist/providers/github/skill.generated.js +8 -0
- package/dist/providers/gmail/index.d.ts +51 -0
- package/dist/providers/gmail/index.js +1436 -0
- package/dist/providers/gmail/skill.generated.js +9 -0
- package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
- package/dist/providers/linear/index.js +352 -0
- package/dist/providers/linear/skill.generated.js +8 -0
- package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
- package/dist/providers/mixpanel/index.js +416 -0
- package/dist/providers/mixpanel/skill.generated.js +9 -0
- package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
- package/dist/providers/{notion.js → notion/index.js} +226 -239
- package/dist/providers/notion/skill.generated.js +10 -0
- package/dist/providers/overflow/index.d.ts +60 -0
- package/dist/providers/overflow/index.js +1535 -0
- package/dist/providers/overflow/skill.generated.js +8 -0
- package/dist/providers/planning-center/index.d.ts +59 -0
- package/dist/providers/planning-center/index.js +2199 -0
- package/dist/providers/planning-center/skill.generated.js +10 -0
- package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
- package/dist/providers/revenuecat/index.js +557 -0
- package/dist/providers/revenuecat/skill.generated.js +11 -0
- package/dist/providers/sheets/index.d.ts +54 -0
- package/dist/providers/sheets/index.js +1237 -0
- package/dist/providers/sheets/skill.generated.js +10 -0
- package/dist/providers/slides/index.d.ts +53 -0
- package/dist/providers/slides/index.js +2645 -0
- package/dist/providers/slides/skill.generated.js +9 -0
- package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
- package/dist/providers/stripe/index.js +266 -0
- package/dist/providers/stripe/skill.generated.js +11 -0
- package/dist/providers/tithely/index.d.ts +49 -0
- package/dist/providers/tithely/index.js +1003 -0
- package/dist/providers/tithely/skill.generated.js +8 -0
- package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
- package/dist/providers/vercel/index.js +1038 -0
- package/dist/providers/vercel/mcp-ownership.js +212 -0
- package/dist/providers/vercel/skill.generated.js +11 -0
- package/dist/registry.d.ts +108 -171
- package/dist/registry.js +513 -925
- package/dist/request-state.js +21 -0
- package/dist/resource-read.js +100 -0
- package/dist/result-shapes.js +17 -49
- package/dist/retired-toolkits.js +1 -1
- package/dist/routes/access-tokens.js +3 -6
- package/dist/routes/activity-context.js +21 -0
- package/dist/routes/activity-disclosure.js +40 -0
- package/dist/routes/activity.js +59 -21
- package/dist/routes/actor-labels.js +3 -50
- package/dist/routes/connect.js +221 -0
- package/dist/routes/credentials.js +15 -27
- package/dist/routes/mcp.js +356 -130
- package/dist/routes/oauth-client-metadata.js +15 -0
- package/dist/routes/oauth-management.js +45 -107
- package/dist/routes/oauth.js +136 -57
- package/dist/routes/operator.js +3 -3
- package/dist/routes/shared.d.ts +25 -64
- package/dist/routes/shared.js +160 -80
- package/dist/routes/skills.js +30 -0
- package/dist/routes/ui-config.js +225 -0
- package/dist/routes/ui-data.js +40 -35
- package/dist/routes/ui.js +66 -56
- package/dist/runtime/admission.js +4 -2
- package/dist/runtime/call-admission.js +3 -1
- package/dist/runtime/connector-scope.js +1 -4
- package/dist/runtime/run.js +10 -39
- package/dist/runtime/services.js +12 -117
- package/dist/runtime/shared-read.js +1 -3
- package/dist/runtime/storage.js +8 -1
- package/dist/sent-secrets.d.ts +52 -0
- package/dist/sent-secrets.js +474 -0
- package/dist/server.js +28 -71
- package/dist/skills.js +406 -174
- package/dist/sqlite.d.ts +48 -0
- package/dist/sqlite.js +166 -0
- package/dist/storage/keys.js +428 -0
- package/dist/storage/memory.js +7 -0
- package/dist/storage/sql.d.ts +66 -0
- package/dist/storage/sql.js +534 -0
- package/dist/timeout.js +4 -2
- package/dist/tool-name.js +17 -0
- package/dist/tool-safety.d.ts +9 -35
- package/dist/tool-safety.js +20 -40
- package/dist/types.d.ts +320 -54
- package/dist/types.js +0 -1
- package/dist/ui.d.ts +7 -19
- package/dist/ui.js +76 -90
- package/dist/url-safety.js +3 -6
- package/dist/usage-guide.js +137 -0
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +309 -47
- package/dist/version.d.ts +1 -7
- package/dist/version.js +3 -7
- package/dist/worker.d.ts +11 -0
- package/dist/worker.js +434 -0
- package/documentation/architecture.md +665 -230
- package/documentation/auth.md +962 -458
- package/documentation/code-mode.md +501 -312
- package/documentation/meta-tools.md +449 -200
- package/documentation/operator-tests.md +93 -0
- package/documentation/provider-migration-0.29.md +60 -0
- package/examples/worker/AGENTS.md +34 -12
- package/examples/worker/README.md +286 -255
- package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
- package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
- package/examples/worker/src/connecta.config.ts +151 -0
- package/examples/worker/src/index.ts +14 -197
- package/examples/worker/wrangler.jsonc +15 -35
- package/package.json +125 -35
- package/templates/node/.dockerignore +1 -2
- package/templates/node/.env.example +20 -21
- package/templates/node/AGENTS.md +21 -17
- package/templates/node/Dockerfile +3 -5
- package/templates/node/README.md +133 -58
- package/templates/node/docker-compose.yml +11 -12
- package/templates/node/package.json +4 -2
- package/templates/node/src/connecta.config.ts +119 -0
- package/templates/node/src/index.ts +9 -154
- package/templates/node/src/provision-token.ts +17 -0
- package/AGENTS.md +0 -170
- package/dist/artifacts/connector.d.ts +0 -58
- package/dist/artifacts/connector.js +0 -730
- package/dist/artifacts/document.js +0 -86
- package/dist/artifacts/guide.js +0 -140
- package/dist/artifacts/html-scan.js +0 -329
- package/dist/artifacts/json.js +0 -78
- package/dist/artifacts/kv-store.d.ts +0 -20
- package/dist/artifacts/kv-store.js +0 -140
- package/dist/artifacts/markdown.js +0 -478
- package/dist/artifacts/module.d.ts +0 -37
- package/dist/artifacts/module.js +0 -66
- package/dist/artifacts/operations.d.ts +0 -197
- package/dist/artifacts/operations.js +0 -1002
- package/dist/artifacts/refresh.d.ts +0 -55
- package/dist/artifacts/refresh.js +0 -236
- package/dist/artifacts/routes.js +0 -206
- package/dist/artifacts/types.d.ts +0 -214
- package/dist/artifacts/types.js +0 -22
- package/dist/artifacts/validate.d.ts +0 -62
- package/dist/artifacts/validate.js +0 -651
- package/dist/artifacts.d.ts +0 -6
- package/dist/artifacts.js +0 -9
- package/dist/auth/bearer.d.ts +0 -11
- package/dist/auth/bearer.js +0 -50
- package/dist/providers/linear.js +0 -217
- package/dist/providers/mixpanel.js +0 -247
- package/dist/providers/revenuecat.js +0 -317
- package/dist/providers/stripe.js +0 -253
- package/dist/providers/vercel.js +0 -1253
- package/dist/storage/file.d.ts +0 -15
- package/dist/storage/file.js +0 -455
- package/ethos.md +0 -93
- package/examples/worker/src/cloudflare-kv.ts +0 -43
- package/examples/worker/src/d1-activity-row.ts +0 -155
- package/examples/worker/src/d1-activity.ts +0 -147
- package/examples/worker/src/d1-storage.ts +0 -138
- package/examples/worker/src/r2-artifact-blobs.ts +0 -29
- package/templates/node/src/file-activity.ts +0 -182
|
@@ -1,15 +1,30 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assertRemoteOAuthClient, declareSelfHostedClient, downstreamClientMetadata, downstreamRedirectUri, remoteClientAuthMethod, selfHostedClientUrl, trackRemoteClientRequest, } from "../auth/downstream-client-metadata.js";
|
|
2
|
+
import { AuthorizationServerMismatchError, Client, InsecureTokenEndpointError, InsufficientScopeError, IssuerMismatchError, MissingRequiredClientCapabilityError, OAuthClientFlowError, OAuthError, ProtocolError, RegistrationRejectedError, ResourceNotFoundError, SdkError, SdkErrorCode, SdkHttpError, SseError, UnsupportedProtocolVersionError, UrlElicitationRequiredError, isInputRequiredResult, fromJsonSchema, isJSONRPCErrorResponse, isJSONRPCNotification, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
2
3
|
import { Deferred, Duration, Effect, Exit, Scope } from "effect";
|
|
3
|
-
import { KvOAuthProvider,
|
|
4
|
+
import { assertOAuthScope, authorizingContext, KvOAuthProvider, OAuthRequestNotSentError, refreshCoordinatorsByPartition, } from "../auth/downstream-oauth.js";
|
|
4
5
|
import { trackOAuthStartReset } from "../auth/oauth-start-reset.js";
|
|
5
|
-
import {
|
|
6
|
+
import { byteReadResponse } from "../byte-read-response.js";
|
|
7
|
+
import { redactCatalog, redactSentSecrets, sentSecretsFor, shortSecretWarning, trackCredentialReads, } from "../sent-secrets.js";
|
|
8
|
+
import { MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "../catalog-limits.js";
|
|
9
|
+
import { catalogClientOptions, catalogItems, observeCompletedCatalogRefresh, closeCatalogCacheScope, observeCatalogChange, observeCatalogFetch, observeCatalogExpiry, } from "../catalog-cache.js";
|
|
10
|
+
import { reviewedClassification } from "../catalog-drift.js";
|
|
6
11
|
import { connectorScopeCleanupClaimed } from "../connector-scope.js";
|
|
7
|
-
import { boundedEchoText, ConnectorCallError, msg, unavailableCallError, } from "../errors.js";
|
|
12
|
+
import { boundedEchoText, ConnectorCallError, msg, networkErrorCode, unavailableCallError, WithheldTextError, } from "../errors.js";
|
|
13
|
+
import { attachFailureFacts, carryFailureFacts, errorLabel, failureRecord, failureStatus, labelErrorClass, logFailure, OAUTH_ERROR_CODES, ownStatus, } from "../operator-record.js";
|
|
8
14
|
import { CONNECTA_VERSION } from "../version.js";
|
|
9
15
|
import { learnedUrlRefusal } from "../url-safety.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
16
|
+
import { oauthSealerFor } from "../oauth-sealing.js";
|
|
17
|
+
import { retainingOAuthPartition } from "../oauth-partition.js";
|
|
18
|
+
import { registerInvocationAuth } from "../invocation-auth.js";
|
|
19
|
+
import { downstreamInputCapabilities, assertDownstreamOutputSafe, downstreamWriteContinuation, } from "../downstream-input-context.js";
|
|
12
20
|
import { detach, runEdge } from "../runtime/run.js";
|
|
21
|
+
import { assertKnownOptions, keys, optionsOf } from "../config-schema.js";
|
|
22
|
+
import { describedEndpoint, describedUrl } from "../described.js";
|
|
23
|
+
import { retryAfterMs } from "./guarded-fetch.js";
|
|
24
|
+
import { resourceUriMatchesTemplates } from "./resource-uri.js";
|
|
25
|
+
import { callerOf } from "../connector-caller.js";
|
|
26
|
+
import { readNegotiation, storeNegotiation } from "./negotiation-cache.js";
|
|
27
|
+
import { CALL_ADMISSION, REMOTE_MCP_AUTH, USAGE_GUIDE } from "./option-shapes.js";
|
|
13
28
|
/**
|
|
14
29
|
* Apply a maintained provider's slot copy and header framing to credential
|
|
15
30
|
* auth the deployment left bare.
|
|
@@ -25,11 +40,16 @@ export function withCredentialDefaults(auth, defaults) {
|
|
|
25
40
|
return {
|
|
26
41
|
...auth,
|
|
27
42
|
credential: auth.credential ?? defaults.credential,
|
|
28
|
-
...(auth.scheme === undefined && defaults.scheme !== undefined
|
|
29
|
-
? { scheme: defaults.scheme }
|
|
30
|
-
: {}),
|
|
43
|
+
...(auth.scheme === undefined && defaults.scheme !== undefined ? { scheme: defaults.scheme } : {}),
|
|
31
44
|
};
|
|
32
45
|
}
|
|
46
|
+
/** The closed options remoteMcp() accepts; see `assertKnownOptions`. */
|
|
47
|
+
const REMOTE_MCP_OPTIONS = optionsOf()({
|
|
48
|
+
...keys("url", "title", "description", "authScope", "maxResultBytes", "versionNegotiation", "redirects", "requireHttps", "logger", "_transportFactory", "classify", "skills"),
|
|
49
|
+
callAdmission: CALL_ADMISSION,
|
|
50
|
+
usageGuide: USAGE_GUIDE,
|
|
51
|
+
auth: REMOTE_MCP_AUTH,
|
|
52
|
+
});
|
|
33
53
|
/**
|
|
34
54
|
* How long a downstream gets to answer the session-termination DELETE before
|
|
35
55
|
* teardown stops waiting. This is a network round-trip budget, deliberately
|
|
@@ -47,18 +67,347 @@ const TERMINATE_SESSION_BUDGET_MS = 1_000;
|
|
|
47
67
|
* this bounds one that never does.
|
|
48
68
|
*/
|
|
49
69
|
const LOCAL_CLOSE_BUDGET_MS = 1_000;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* going nowhere, and MAX_CATALOG_TOOLS caps what any of it can accumulate.
|
|
57
|
-
* This exists only so the loop is finite even if a downstream somehow
|
|
58
|
-
* satisfies all three forever on a path with no discovery deadline. Set high
|
|
59
|
-
* enough that no honest server reaches it.
|
|
60
|
-
*/
|
|
70
|
+
function unadvertisedResource() {
|
|
71
|
+
return new ConnectorCallError("not_found", "The resource URI is not advertised by this connector.", {
|
|
72
|
+
retryable: false,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** SDK aggregation cap; intake also refuses loops and non-progress (INV-8). */
|
|
61
76
|
const MAX_TOOL_PAGES = 10_000;
|
|
77
|
+
const SKILLS_EXTENSION = "io.modelcontextprotocol/skills";
|
|
78
|
+
const MAX_SKILLS = 10_000;
|
|
79
|
+
const MAX_SKILL_FILES = 512;
|
|
80
|
+
const MAX_SKILL_BYTES = 16 * 1024 * 1024;
|
|
81
|
+
// A valid UTF-8 text file can expand sixfold when JSON escapes control bytes.
|
|
82
|
+
// Leave room for the envelope while accepting every file up to the spec limit.
|
|
83
|
+
const MAX_SKILL_READ_RPC_BYTES = 6 * MAX_SKILL_BYTES + 1024 * 1024;
|
|
84
|
+
/** Count UTF-8 without allocating another full copy of a bounded response. */
|
|
85
|
+
function skillTextBytes(value) {
|
|
86
|
+
let bytes = 0;
|
|
87
|
+
for (let i = 0; i < value.length; i++) {
|
|
88
|
+
const code = value.charCodeAt(i);
|
|
89
|
+
if (code < 0x80)
|
|
90
|
+
bytes++;
|
|
91
|
+
else if (code < 0x800)
|
|
92
|
+
bytes += 2;
|
|
93
|
+
else if (code >= 0xd800 &&
|
|
94
|
+
code <= 0xdbff &&
|
|
95
|
+
i + 1 < value.length &&
|
|
96
|
+
value.charCodeAt(i + 1) >= 0xdc00 &&
|
|
97
|
+
value.charCodeAt(i + 1) <= 0xdfff) {
|
|
98
|
+
bytes += 4;
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
else
|
|
102
|
+
bytes += 3;
|
|
103
|
+
}
|
|
104
|
+
return bytes;
|
|
105
|
+
}
|
|
106
|
+
function skillObject(value) {
|
|
107
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
108
|
+
}
|
|
109
|
+
function skillJsonBytes(value) {
|
|
110
|
+
return skillTextBytes(JSON.stringify(value));
|
|
111
|
+
}
|
|
112
|
+
function validSkill(value) {
|
|
113
|
+
if (!skillObject(value) ||
|
|
114
|
+
typeof value.uri !== "string" ||
|
|
115
|
+
!value.uri ||
|
|
116
|
+
!skillObject(value.frontmatter) ||
|
|
117
|
+
typeof value.frontmatter.name !== "string" ||
|
|
118
|
+
!value.frontmatter.name ||
|
|
119
|
+
typeof value.frontmatter.description !== "string" ||
|
|
120
|
+
!value.frontmatter.description)
|
|
121
|
+
return false;
|
|
122
|
+
if (value.resources === "dynamic")
|
|
123
|
+
return true;
|
|
124
|
+
if (!Array.isArray(value.resources) || !value.resources.length || value.resources.length > MAX_SKILL_FILES)
|
|
125
|
+
return false;
|
|
126
|
+
const uris = new Set();
|
|
127
|
+
let bytes = 0;
|
|
128
|
+
for (const resource of value.resources) {
|
|
129
|
+
if (!skillObject(resource) ||
|
|
130
|
+
typeof resource.uri !== "string" ||
|
|
131
|
+
!resource.uri ||
|
|
132
|
+
uris.has(resource.uri) ||
|
|
133
|
+
typeof resource.digest !== "string" ||
|
|
134
|
+
!/^sha256:[a-f0-9]{64}$/.test(resource.digest) ||
|
|
135
|
+
typeof resource.size !== "number" ||
|
|
136
|
+
!Number.isSafeInteger(resource.size) ||
|
|
137
|
+
resource.size < 0)
|
|
138
|
+
return false;
|
|
139
|
+
uris.add(resource.uri);
|
|
140
|
+
bytes += resource.size;
|
|
141
|
+
if (bytes > MAX_SKILL_BYTES)
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return uris.has(value.uri);
|
|
145
|
+
}
|
|
146
|
+
function completeSkillResult(value) {
|
|
147
|
+
// The modern SDK codec rejects non-complete wire results and removes
|
|
148
|
+
// resultType before invoking an explicit result schema.
|
|
149
|
+
return (skillObject(value) &&
|
|
150
|
+
typeof value.ttlMs === "number" &&
|
|
151
|
+
Number.isSafeInteger(value.ttlMs) &&
|
|
152
|
+
value.ttlMs >= 0 &&
|
|
153
|
+
(value.cacheScope === "public" || value.cacheScope === "private"));
|
|
154
|
+
}
|
|
155
|
+
const SkillPageSchema = {
|
|
156
|
+
"~standard": {
|
|
157
|
+
version: 1,
|
|
158
|
+
vendor: "connecta",
|
|
159
|
+
validate(value) {
|
|
160
|
+
if (!completeSkillResult(value) ||
|
|
161
|
+
!Array.isArray(value.skills) ||
|
|
162
|
+
value.skills.length > MAX_SKILLS ||
|
|
163
|
+
!value.skills.every(validSkill) ||
|
|
164
|
+
(value.nextCursor !== undefined && typeof value.nextCursor !== "string")) {
|
|
165
|
+
return { issues: [{ message: "Invalid or incomplete Skills listing." }] };
|
|
166
|
+
}
|
|
167
|
+
const bytes = skillJsonBytes(value);
|
|
168
|
+
if (bytes > MAX_SERIALIZED_CATALOG_BYTES)
|
|
169
|
+
return { issues: [{ message: "Skills listing exceeds the byte limit." }] };
|
|
170
|
+
return {
|
|
171
|
+
value: {
|
|
172
|
+
skills: value.skills,
|
|
173
|
+
bytes,
|
|
174
|
+
...(typeof value.nextCursor === "string" ? { nextCursor: value.nextCursor } : {}),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
function skillReadSchema(uri) {
|
|
181
|
+
return {
|
|
182
|
+
"~standard": {
|
|
183
|
+
version: 1,
|
|
184
|
+
vendor: "connecta",
|
|
185
|
+
validate(value) {
|
|
186
|
+
if (!completeSkillResult(value) ||
|
|
187
|
+
!Array.isArray(value.contents) ||
|
|
188
|
+
value.contents.length !== 1 ||
|
|
189
|
+
skillJsonBytes(value) > MAX_SKILL_READ_RPC_BYTES) {
|
|
190
|
+
return { issues: [{ message: "Invalid or incomplete skill resource response." }] };
|
|
191
|
+
}
|
|
192
|
+
const content = value.contents[0];
|
|
193
|
+
if (!skillObject(content) ||
|
|
194
|
+
content.uri !== uri ||
|
|
195
|
+
(content.mimeType !== undefined && typeof content.mimeType !== "string")) {
|
|
196
|
+
return { issues: [{ message: "Unexpected skill resource contents." }] };
|
|
197
|
+
}
|
|
198
|
+
const mimeType = typeof content.mimeType === "string" ? { mimeType: content.mimeType } : {};
|
|
199
|
+
if (typeof content.text === "string" &&
|
|
200
|
+
!("blob" in content) &&
|
|
201
|
+
skillTextBytes(content.text) <= MAX_SKILL_BYTES) {
|
|
202
|
+
return { value: [{ uri, ...mimeType, text: content.text }] };
|
|
203
|
+
}
|
|
204
|
+
if (typeof content.blob === "string" &&
|
|
205
|
+
!("text" in content) &&
|
|
206
|
+
content.blob.length % 4 === 0 &&
|
|
207
|
+
!/[^A-Za-z0-9+/]/.test(content.blob.replace(/={1,2}$/, ""))) {
|
|
208
|
+
const bytes = (content.blob.length / 4) * 3 - (content.blob.endsWith("==") ? 2 : content.blob.endsWith("=") ? 1 : 0);
|
|
209
|
+
if (bytes <= MAX_SKILL_BYTES)
|
|
210
|
+
return { value: [{ uri, ...mimeType, blob: content.blob }] };
|
|
211
|
+
}
|
|
212
|
+
return { issues: [{ message: "Invalid or oversized skill resource bytes." }] };
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/** Bound the body before SDK consumption, including its detached SSE reader. */
|
|
218
|
+
async function boundedSkillResponse(response, limit, rpcId, signal) {
|
|
219
|
+
const exceeded = () => new ConnectorCallError("connector_call_failed", "Downstream Skills response exceeds the RPC byte limit.");
|
|
220
|
+
const declared = response.headers.get("content-length");
|
|
221
|
+
if (declared !== null && Number(declared) > limit) {
|
|
222
|
+
await response.body?.cancel().catch(() => { });
|
|
223
|
+
throw exceeded();
|
|
224
|
+
}
|
|
225
|
+
if (!response.body)
|
|
226
|
+
return response;
|
|
227
|
+
const reader = response.body.getReader();
|
|
228
|
+
const abort = () => {
|
|
229
|
+
void reader.cancel().catch(() => { });
|
|
230
|
+
};
|
|
231
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
232
|
+
let bytes = 0;
|
|
233
|
+
const chunks = [];
|
|
234
|
+
let buffer;
|
|
235
|
+
let buffered = 0;
|
|
236
|
+
const sse = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() === "text/event-stream";
|
|
237
|
+
// Decode an entire SSE message once. Its byte buffer has no per-chunk or
|
|
238
|
+
// per-line objects, and preserves embedded resource BOMs on workerd too.
|
|
239
|
+
const decoder = new TextDecoder("utf-8", { ignoreBOM: true, fatal: false });
|
|
240
|
+
let data = new Uint8Array(0);
|
|
241
|
+
let dataBytes = 0;
|
|
242
|
+
let hasData = false;
|
|
243
|
+
const newline = new Uint8Array([10]);
|
|
244
|
+
const field = [100, 97, 116, 97, 58]; // data:
|
|
245
|
+
const bom = [239, 187, 191];
|
|
246
|
+
let bomOffset = 0;
|
|
247
|
+
let fieldOffset = 0;
|
|
248
|
+
// 0: field prefix, 1: optional value space, 2: data value, 3: ignored line.
|
|
249
|
+
let state = 0;
|
|
250
|
+
let lineBytes = false;
|
|
251
|
+
let skipLf = false;
|
|
252
|
+
const append = (value) => {
|
|
253
|
+
const needed = dataBytes + value.byteLength;
|
|
254
|
+
if (needed > limit)
|
|
255
|
+
throw exceeded();
|
|
256
|
+
if (needed > data.length) {
|
|
257
|
+
const next = new Uint8Array(Math.min(limit, Math.max(65_536, needed, data.length * 2)));
|
|
258
|
+
next.set(data.subarray(0, dataBytes));
|
|
259
|
+
data = next;
|
|
260
|
+
}
|
|
261
|
+
data.set(value, dataBytes);
|
|
262
|
+
dataBytes = needed;
|
|
263
|
+
};
|
|
264
|
+
const beginData = () => {
|
|
265
|
+
if (hasData)
|
|
266
|
+
append(newline);
|
|
267
|
+
hasData = true;
|
|
268
|
+
};
|
|
269
|
+
const consume = (chunk) => {
|
|
270
|
+
for (let offset = 0; offset < chunk.length;) {
|
|
271
|
+
const byte = chunk[offset];
|
|
272
|
+
if (skipLf) {
|
|
273
|
+
skipLf = false;
|
|
274
|
+
if (byte === 10) {
|
|
275
|
+
offset++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Strip exactly one initial UTF-8 BOM, even across single-byte chunks.
|
|
280
|
+
if (bomOffset >= 0) {
|
|
281
|
+
if (byte === bom[bomOffset]) {
|
|
282
|
+
if (++bomOffset === bom.length)
|
|
283
|
+
bomOffset = -1;
|
|
284
|
+
offset++;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (bomOffset) {
|
|
288
|
+
lineBytes = true;
|
|
289
|
+
state = 3;
|
|
290
|
+
}
|
|
291
|
+
bomOffset = -1;
|
|
292
|
+
}
|
|
293
|
+
if (byte === 10 || byte === 13) {
|
|
294
|
+
if (lineBytes) {
|
|
295
|
+
if (state === 0 && fieldOffset === 4)
|
|
296
|
+
beginData();
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const json = hasData && dataBytes ? decoder.decode(data.subarray(0, dataBytes)) : "";
|
|
300
|
+
dataBytes = 0;
|
|
301
|
+
hasData = false;
|
|
302
|
+
if (json) {
|
|
303
|
+
const message = JSON.parse(json);
|
|
304
|
+
if (skillObject(message) && message.id === rpcId && ("result" in message || "error" in message)) {
|
|
305
|
+
data = new Uint8Array(0);
|
|
306
|
+
return json;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
state = 0;
|
|
311
|
+
fieldOffset = 0;
|
|
312
|
+
lineBytes = false;
|
|
313
|
+
skipLf = byte === 13;
|
|
314
|
+
offset++;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
lineBytes = true;
|
|
318
|
+
if (state === 0) {
|
|
319
|
+
if (byte === field[fieldOffset]) {
|
|
320
|
+
if (++fieldOffset === field.length) {
|
|
321
|
+
beginData();
|
|
322
|
+
state = 1;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
else
|
|
326
|
+
state = 3;
|
|
327
|
+
offset++;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (state === 1) {
|
|
331
|
+
state = 2;
|
|
332
|
+
if (byte === 32) {
|
|
333
|
+
offset++;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
let end = offset;
|
|
338
|
+
while (end < chunk.length && chunk[end] !== 13 && chunk[end] !== 10)
|
|
339
|
+
end++;
|
|
340
|
+
if (state === 2)
|
|
341
|
+
append(chunk.subarray(offset, end));
|
|
342
|
+
offset = end;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
const terminalResponse = async (json) => {
|
|
346
|
+
// The SDK's detached SSE consumer does not settle body-read errors. Hand
|
|
347
|
+
// it the bounded terminal answer and close a server-kept-open stream.
|
|
348
|
+
await reader.cancel().catch(() => { });
|
|
349
|
+
const headers = new Headers(response.headers);
|
|
350
|
+
headers.set("content-type", "application/json");
|
|
351
|
+
headers.delete("content-length");
|
|
352
|
+
return new Response(json, { status: response.status, statusText: response.statusText, headers });
|
|
353
|
+
};
|
|
354
|
+
try {
|
|
355
|
+
while (true) {
|
|
356
|
+
if (signal?.aborted)
|
|
357
|
+
throw signal.reason;
|
|
358
|
+
const chunk = await reader.read();
|
|
359
|
+
if (signal?.aborted)
|
|
360
|
+
throw signal.reason;
|
|
361
|
+
if (chunk.done) {
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
bytes += chunk.value.byteLength;
|
|
365
|
+
if (bytes > limit)
|
|
366
|
+
throw exceeded();
|
|
367
|
+
if (!sse) {
|
|
368
|
+
// Network chunk count and backing-buffer size are untrusted. Retain
|
|
369
|
+
// only owned fixed-size blocks, never one object per incoming byte.
|
|
370
|
+
for (let offset = 0; offset < chunk.value.byteLength;) {
|
|
371
|
+
buffer ??= new Uint8Array(65_536);
|
|
372
|
+
const size = Math.min(buffer.length - buffered, chunk.value.byteLength - offset);
|
|
373
|
+
buffer.set(chunk.value.subarray(offset, offset + size), buffered);
|
|
374
|
+
offset += size;
|
|
375
|
+
buffered += size;
|
|
376
|
+
if (buffered === buffer.length) {
|
|
377
|
+
chunks.push(buffer);
|
|
378
|
+
buffer = undefined;
|
|
379
|
+
buffered = 0;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const terminal = consume(chunk.value);
|
|
385
|
+
if (terminal !== undefined)
|
|
386
|
+
return await terminalResponse(terminal);
|
|
387
|
+
}
|
|
388
|
+
if (sse)
|
|
389
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream Skills stream ended without a terminal RPC response.");
|
|
390
|
+
if (buffer && buffered)
|
|
391
|
+
chunks.push(buffer.subarray(0, buffered));
|
|
392
|
+
return new Response(new ReadableStream({
|
|
393
|
+
pull(controller) {
|
|
394
|
+
const chunk = chunks.shift();
|
|
395
|
+
if (chunk)
|
|
396
|
+
controller.enqueue(chunk);
|
|
397
|
+
else
|
|
398
|
+
controller.close();
|
|
399
|
+
},
|
|
400
|
+
}), { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
await reader.cancel().catch(() => { });
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
signal?.removeEventListener("abort", abort);
|
|
408
|
+
reader.releaseLock();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
62
411
|
/**
|
|
63
412
|
* Compatibility concession for hand-rolled servers that serialize
|
|
64
413
|
* end-of-pagination as `null`. Only the cursor is widened; every tool and every
|
|
@@ -69,10 +418,7 @@ const CompatibleListToolsResultSchema = {
|
|
|
69
418
|
version: 1,
|
|
70
419
|
vendor: "connecta",
|
|
71
420
|
validate(value) {
|
|
72
|
-
const normalized = typeof value === "object" &&
|
|
73
|
-
value !== null &&
|
|
74
|
-
"nextCursor" in value &&
|
|
75
|
-
value.nextCursor === null
|
|
421
|
+
const normalized = typeof value === "object" && value !== null && "nextCursor" in value && value.nextCursor === null
|
|
76
422
|
? (() => {
|
|
77
423
|
const copy = { ...value };
|
|
78
424
|
delete copy.nextCursor;
|
|
@@ -103,6 +449,16 @@ function isCursorShapeError(err) {
|
|
|
103
449
|
// failing path in the message rather than exposing the validator's issues.
|
|
104
450
|
return msg(err).startsWith("Invalid result for tools/list: nextCursor:");
|
|
105
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* An `authorize_connector` start message for a failure: its text, which has
|
|
454
|
+
* passed the MCP and OAuth boundaries, and the origin and errno connecta
|
|
455
|
+
* derived for it, which a message alone would drop.
|
|
456
|
+
*/
|
|
457
|
+
function startMessage(err) {
|
|
458
|
+
const details = err instanceof ConnectorCallError ? err.details : undefined;
|
|
459
|
+
const facts = [details?.host, details?.code].filter(Boolean).join(", ");
|
|
460
|
+
return facts ? `${msg(err)} (${facts})` : msg(err);
|
|
461
|
+
}
|
|
106
462
|
/**
|
|
107
463
|
* End the downstream's session before the connection is torn down.
|
|
108
464
|
*
|
|
@@ -127,12 +483,9 @@ function terminateSession(transport, logger, connectorId) {
|
|
|
127
483
|
const terminate = transport.terminateSession;
|
|
128
484
|
if (typeof terminate !== "function")
|
|
129
485
|
return Effect.void;
|
|
130
|
-
const warn = (
|
|
486
|
+
const warn = (log) => Effect.sync(() => {
|
|
131
487
|
try {
|
|
132
|
-
|
|
133
|
-
logger.warn(message);
|
|
134
|
-
else
|
|
135
|
-
logger.warn(message, error);
|
|
488
|
+
log();
|
|
136
489
|
}
|
|
137
490
|
catch {
|
|
138
491
|
// A diagnostic sink cannot make best-effort teardown observable to the
|
|
@@ -145,13 +498,14 @@ function terminateSession(transport, logger, connectorId) {
|
|
|
145
498
|
return Effect.raceAllFirst([
|
|
146
499
|
// The SDK issues no request at all when no `mcp-session-id` was captured,
|
|
147
500
|
// so a stateless downstream never sees a spurious DELETE.
|
|
148
|
-
promised(() => Promise.resolve(terminate.call(transport))).pipe(Effect.catch((error) =>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
501
|
+
promised(() => Promise.resolve(terminate.call(transport))).pipe(Effect.catch((error) =>
|
|
502
|
+
// The SDK's error quotes the downstream's status text, and its `data`
|
|
503
|
+
// the body, so the record keeps only the status it answered with.
|
|
504
|
+
warn(() => logFailure(logger, "session termination refused or failed; the downstream session may remain until its provider timeout", failureRecord({ connector: connectorId }, error instanceof SdkHttpError ? attachFailureFacts(error, { httpStatus: error.status }) : error))))),
|
|
505
|
+
Effect.sleep(Duration.millis(TERMINATE_SESSION_BUDGET_MS)).pipe(Effect.andThen(warn(() => logger.warn(`[connecta] connector "${connectorId}" session termination was not ` +
|
|
152
506
|
`acknowledged within ${TERMINATE_SESSION_BUDGET_MS} ms; the ` +
|
|
153
507
|
"downstream may still finish the headers-only DELETE, otherwise " +
|
|
154
|
-
"the session will remain until its provider timeout."))),
|
|
508
|
+
"the session will remain until its provider timeout.")))),
|
|
155
509
|
]);
|
|
156
510
|
}
|
|
157
511
|
/**
|
|
@@ -178,14 +532,69 @@ function closeLocally(client, transport) {
|
|
|
178
532
|
function promised(evaluate) {
|
|
179
533
|
return Effect.tryPromise({ try: evaluate, catch: (error) => error });
|
|
180
534
|
}
|
|
181
|
-
/**
|
|
182
|
-
function
|
|
183
|
-
|
|
184
|
-
|
|
535
|
+
/** A retained cause chain must not smuggle an SDK payload past the boundary. */
|
|
536
|
+
function hasSdkPayload(error) {
|
|
537
|
+
const seen = new Set();
|
|
538
|
+
for (let current = error; current instanceof Error && !seen.has(current); current = current.cause) {
|
|
539
|
+
if (current instanceof ProtocolError || current instanceof SdkError)
|
|
540
|
+
return true;
|
|
541
|
+
seen.add(current);
|
|
542
|
+
}
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
/** Cancellation can bypass an inner SDK catch and return the signal's reason. */
|
|
546
|
+
function payloadFree(run) {
|
|
547
|
+
return async (...args) => {
|
|
548
|
+
try {
|
|
549
|
+
return await run(...args);
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
throw hasSdkPayload(error) ? carryFailureFacts(error, downstreamCallError(error)) : error;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/** Both the SDK's auth signal and its classified form invalidate a live client. */
|
|
557
|
+
function requiresAuthorization(error) {
|
|
558
|
+
return (error instanceof UnauthorizedError ||
|
|
559
|
+
(error instanceof ConnectorCallError &&
|
|
560
|
+
(error.code === "auth_required" || error.code === "downstream_oauth_required")));
|
|
561
|
+
}
|
|
562
|
+
/** Classify SDK/runtime facts, dropping every SDK payload and cause chain. */
|
|
563
|
+
function downstreamCallError(error, httpStatus, wait, oauthStep, secrets) {
|
|
564
|
+
if (error instanceof ConnectorCallError) {
|
|
565
|
+
return hasSdkPayload(error.cause) ? carryFailureFacts(error, withheldAs(error.message, error)) : error;
|
|
566
|
+
}
|
|
567
|
+
if (error instanceof WithheldTextError || error instanceof UnauthorizedError)
|
|
568
|
+
return error;
|
|
569
|
+
const network = !(error instanceof SdkError) && !(error instanceof OAuthClientFlowError) ? networkErrorCode(error) : undefined;
|
|
570
|
+
if (network) {
|
|
571
|
+
return network === "timeout" || network === "ETIMEDOUT" || network.endsWith("_TIMEOUT")
|
|
572
|
+
? new ConnectorCallError("timeout", "The downstream request timed out.")
|
|
573
|
+
: unavailableCallError(error);
|
|
574
|
+
}
|
|
575
|
+
const status = error instanceof SdkHttpError || error instanceof RegistrationRejectedError ? error.status : httpStatus;
|
|
576
|
+
if (error instanceof InsufficientScopeError ||
|
|
577
|
+
(status === 403 && !(error instanceof RegistrationRejectedError)) ||
|
|
578
|
+
(error instanceof OAuthError && ["insufficient_scope", "invalid_scope", "access_denied"].includes(error.code))) {
|
|
579
|
+
return new ConnectorCallError("provider_permission_denied", "The provider denied this operation. Check the account's permissions and the tool's required access with the provider or an administrator.");
|
|
185
580
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
581
|
+
// Token endpoints return typed OAuth recovery codes on HTTP 400. Other
|
|
582
|
+
// OAuth steps, especially registration, keep their HTTP status verdict.
|
|
583
|
+
if (error instanceof OAuthError &&
|
|
584
|
+
OAUTH_ERROR_CODES.has(error.code) &&
|
|
585
|
+
(status === undefined || oauthStep === "token request")) {
|
|
586
|
+
return new ConnectorCallError(["invalid_client", "invalid_grant", "invalid_token", "unauthorized_client"].includes(error.code)
|
|
587
|
+
? "downstream_oauth_required"
|
|
588
|
+
: error.code === "too_many_requests"
|
|
589
|
+
? "rate_limited"
|
|
590
|
+
: ["server_error", "temporarily_unavailable"].includes(error.code)
|
|
591
|
+
? "unavailable"
|
|
592
|
+
: "connector_call_failed", `OAuth failed with error ${error.code}.`);
|
|
593
|
+
}
|
|
594
|
+
if (status !== undefined && status >= 400) {
|
|
595
|
+
let message = `The downstream service answered HTTP ${status}.`;
|
|
596
|
+
if (error instanceof SdkHttpError && status < 500 && typeof error.data.text === "string") {
|
|
597
|
+
message = error.message;
|
|
189
598
|
try {
|
|
190
599
|
const body = JSON.parse(error.data.text);
|
|
191
600
|
const detail = body?.message ?? body?.error?.message ?? body?.error_description;
|
|
@@ -193,12 +602,28 @@ function downstreamCallError(error) {
|
|
|
193
602
|
message = detail;
|
|
194
603
|
}
|
|
195
604
|
catch {
|
|
196
|
-
// Non-JSON refusals
|
|
605
|
+
// Non-JSON MCP refusals retain only a bounded diagnostic.
|
|
197
606
|
}
|
|
198
607
|
}
|
|
199
|
-
return new ConnectorCallError(
|
|
608
|
+
return new ConnectorCallError(status === 401 && !(error instanceof RegistrationRejectedError)
|
|
609
|
+
? "auth_required"
|
|
610
|
+
: status === 429
|
|
611
|
+
? "rate_limited"
|
|
612
|
+
: status === 408 && !(error instanceof RegistrationRejectedError)
|
|
613
|
+
? "timeout"
|
|
614
|
+
: "connector_call_failed", boundedEchoText(secrets?.text(message) ?? message), {
|
|
615
|
+
retryable: [429, 502, 503, 504].includes(status) || (status === 408 && !(error instanceof RegistrationRejectedError)),
|
|
616
|
+
...(wait !== undefined ? { retryAfterMs: wait } : {}),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) {
|
|
620
|
+
return new ConnectorCallError("timeout", "The downstream request timed out.");
|
|
200
621
|
}
|
|
201
|
-
|
|
622
|
+
// ProtocolError.data is server-chosen even when its message is allowed.
|
|
623
|
+
if (error instanceof ProtocolError) {
|
|
624
|
+
return new ConnectorCallError(error.code === -32602 ? "invalid_args" : "connector_call_failed", boundedEchoText(secrets?.text(error.message) ?? error.message));
|
|
625
|
+
}
|
|
626
|
+
return new ConnectorCallError("connector_call_failed", "The downstream operation failed.");
|
|
202
627
|
}
|
|
203
628
|
const encoder = new TextEncoder();
|
|
204
629
|
/** Base64 of a UTF-8 string, Web-API only so the core still runs on workerd. */
|
|
@@ -231,9 +656,7 @@ async function digestOf(value) {
|
|
|
231
656
|
function credentialHeaderValue(scheme, value) {
|
|
232
657
|
if (scheme === null)
|
|
233
658
|
return value;
|
|
234
|
-
return /(?:^|\s)basic$/i.test(scheme)
|
|
235
|
-
? `${scheme} ${base64Utf8(value)}`
|
|
236
|
-
: `${scheme} ${value}`;
|
|
659
|
+
return /(?:^|\s)basic$/i.test(scheme) ? `${scheme} ${base64Utf8(value)}` : `${scheme} ${value}`;
|
|
237
660
|
}
|
|
238
661
|
/**
|
|
239
662
|
* True when a stored credential carries a character a header cannot.
|
|
@@ -260,10 +683,7 @@ function carriesIllegalHeaderChar(value) {
|
|
|
260
683
|
/** RFC 9110 field-name token, checked once at construction. */
|
|
261
684
|
const HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
262
685
|
function isLoopbackHost(hostname) {
|
|
263
|
-
return
|
|
264
|
-
hostname === "127.0.0.1" ||
|
|
265
|
-
hostname === "[::1]" ||
|
|
266
|
-
hostname === "::1");
|
|
686
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
267
687
|
}
|
|
268
688
|
export const MAX_REMOTE_REDIRECT_HOPS = 5;
|
|
269
689
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
@@ -281,6 +701,7 @@ export class RemoteMcpRedirectError extends ConnectorCallError {
|
|
|
281
701
|
this.name = "RemoteMcpRedirectError";
|
|
282
702
|
}
|
|
283
703
|
}
|
|
704
|
+
labelErrorClass(RemoteMcpRedirectError, "RemoteMcpRedirectError");
|
|
284
705
|
function redirectedInit(init, status) {
|
|
285
706
|
const method = (init.method ?? "GET").toUpperCase();
|
|
286
707
|
const becomesGet = (status === 303 && method !== "GET" && method !== "HEAD") ||
|
|
@@ -319,11 +740,13 @@ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetc
|
|
|
319
740
|
}
|
|
320
741
|
catch (cause) {
|
|
321
742
|
if (cause instanceof ConnectorCallError)
|
|
322
|
-
throw cause;
|
|
323
|
-
throw unavailableCallError(cause, current.href);
|
|
743
|
+
throw downstreamCallError(cause);
|
|
744
|
+
throw unavailableCallError(cause, current.href, undefined, hasSdkPayload(cause) ? undefined : (init.signal ?? undefined));
|
|
324
745
|
}
|
|
746
|
+
// Every MCP and OAuth exchange reads its answer through here, and the
|
|
747
|
+
// SDK reads bodies with `.text()` and `.json()`: as bytes, never text.
|
|
325
748
|
if (!REDIRECT_STATUSES.has(response.status))
|
|
326
|
-
return response;
|
|
749
|
+
return byteReadResponse(response);
|
|
327
750
|
const location = response.headers.get("location");
|
|
328
751
|
await response.body?.cancel().catch(() => { });
|
|
329
752
|
if (!location) {
|
|
@@ -361,22 +784,55 @@ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetc
|
|
|
361
784
|
}
|
|
362
785
|
};
|
|
363
786
|
}
|
|
364
|
-
class RemoteMcpDestinationError extends
|
|
787
|
+
class RemoteMcpDestinationError extends OAuthRequestNotSentError {
|
|
365
788
|
constructor(connectorId, reason) {
|
|
366
789
|
super("connector_call_failed", `Connector "${connectorId}" refused an OAuth URL the downstream advertised: ${reason}.`);
|
|
367
790
|
this.name = "RemoteMcpDestinationError";
|
|
368
791
|
}
|
|
369
792
|
}
|
|
793
|
+
labelErrorClass(RemoteMcpDestinationError, "RemoteMcpDestinationError");
|
|
794
|
+
/** A connection whose request scope ended while it was being made. */
|
|
795
|
+
class ScopeEndedError extends Error {
|
|
796
|
+
name = "ScopeEndedError";
|
|
797
|
+
}
|
|
798
|
+
labelErrorClass(ScopeEndedError, "ScopeEndedError");
|
|
799
|
+
/** The MCP client's transport lacks the OAuth flow seams this release is pinned against. */
|
|
800
|
+
class UnboundOAuthFlowsError extends Error {
|
|
801
|
+
name = "UnboundOAuthFlowsError";
|
|
802
|
+
}
|
|
803
|
+
labelErrorClass(UnboundOAuthFlowsError, "UnboundOAuthFlowsError");
|
|
804
|
+
// The pinned SDK's exported errors, labelled by identity for records and
|
|
805
|
+
// connecta's fixed text.
|
|
806
|
+
for (const [ctor, label] of [
|
|
807
|
+
[SdkError, "SdkError"],
|
|
808
|
+
[SdkHttpError, "SdkHttpError"],
|
|
809
|
+
[ProtocolError, "ProtocolError"],
|
|
810
|
+
[MissingRequiredClientCapabilityError, "MissingRequiredClientCapabilityError"],
|
|
811
|
+
[ResourceNotFoundError, "ResourceNotFoundError"],
|
|
812
|
+
[UnsupportedProtocolVersionError, "UnsupportedProtocolVersionError"],
|
|
813
|
+
[UrlElicitationRequiredError, "UrlElicitationRequiredError"],
|
|
814
|
+
[SseError, "SseError"],
|
|
815
|
+
[OAuthError, "OAuthError"],
|
|
816
|
+
[UnauthorizedError, "UnauthorizedError"],
|
|
817
|
+
[OAuthClientFlowError, "OAuthClientFlowError"],
|
|
818
|
+
[AuthorizationServerMismatchError, "AuthorizationServerMismatchError"],
|
|
819
|
+
[InsecureTokenEndpointError, "InsecureTokenEndpointError"],
|
|
820
|
+
[InsufficientScopeError, "InsufficientScopeError"],
|
|
821
|
+
[IssuerMismatchError, "IssuerMismatchError"],
|
|
822
|
+
[RegistrationRejectedError, "RegistrationRejectedError"],
|
|
823
|
+
]) {
|
|
824
|
+
labelErrorClass(ctor, label);
|
|
825
|
+
}
|
|
370
826
|
/**
|
|
371
827
|
* Refuse, before any request leaves, a URL the downstream taught the OAuth
|
|
372
828
|
* flow that points somewhere connecta must not go (see `url-safety.ts`).
|
|
373
829
|
*
|
|
374
830
|
* The SDK learns authorization-server, token, and registration URLs from the
|
|
375
831
|
* downstream's own metadata and fetches them through the transport's fetch,
|
|
376
|
-
* so this sits
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
832
|
+
* so this sits below the refresh coordinator on both send paths. Resource
|
|
833
|
+
* redirects stay same-origin; credential-bearing token requests bypass the
|
|
834
|
+
* redirect wrapper. The typed local refusal proves that this guard sent nothing;
|
|
835
|
+
* failures after an HTTP dispatch keep their permanent ambiguous verdict.
|
|
380
836
|
*/
|
|
381
837
|
function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
|
|
382
838
|
return async (input, init) => {
|
|
@@ -384,9 +840,178 @@ function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
|
|
|
384
840
|
if (reason !== undefined) {
|
|
385
841
|
throw new RemoteMcpDestinationError(connectorId, reason);
|
|
386
842
|
}
|
|
387
|
-
return await baseFetch(input, init);
|
|
843
|
+
return byteReadResponse(await baseFetch(input, init));
|
|
388
844
|
};
|
|
389
845
|
}
|
|
846
|
+
/** The JSON-RPC error answers each transport delivered, as `code\0message`. */
|
|
847
|
+
const wireErrors = new WeakMap();
|
|
848
|
+
/** Answers remembered per transport; one request scope rarely sees more. */
|
|
849
|
+
const MAX_WIRE_ERRORS = 64;
|
|
850
|
+
function wireErrorKey(code, message) {
|
|
851
|
+
return `${String(code)}\u0000${String(message)}`;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Remember every JSON-RPC error response `transport` delivers, so that a
|
|
855
|
+
* `ProtocolError` can be told apart as the downstream's own answer. The SDK
|
|
856
|
+
* builds its local refusals (an output schema it cannot compile, a result
|
|
857
|
+
* that fails it) from the same class, with its own text about what the
|
|
858
|
+
* downstream sent; only one whose code and message arrived on the wire is
|
|
859
|
+
* the answer. Installed before `Client.connect`, whose protocol calls the
|
|
860
|
+
* `onmessage` it finds there ahead of its own for every message, and whose
|
|
861
|
+
* version probe restores it when the probe is done.
|
|
862
|
+
*/
|
|
863
|
+
function recordWireErrors(transport, catalogChanged) {
|
|
864
|
+
const seen = new Set();
|
|
865
|
+
wireErrors.set(transport, seen);
|
|
866
|
+
transport.onmessage = (message) => {
|
|
867
|
+
// The public transport observer runs before SDK 2.3.1's delete-based
|
|
868
|
+
// eviction. Ordinary opposite-scope cleanup never rotates this fence.
|
|
869
|
+
if (isJSONRPCNotification(message) &&
|
|
870
|
+
(message.method === "notifications/tools/list_changed" ||
|
|
871
|
+
message.method === "notifications/resources/list_changed"))
|
|
872
|
+
catalogChanged();
|
|
873
|
+
if (isJSONRPCErrorResponse(message) && seen.size < MAX_WIRE_ERRORS) {
|
|
874
|
+
seen.add(wireErrorKey(message.error.code, message.error.message));
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Whether an error leaving the SDK may keep its own text. The boundary is an
|
|
880
|
+
* allow-list: a JSON-RPC error the downstream answered with, an HTTP 4xx
|
|
881
|
+
* refusal from the MCP endpoint, and errors already in connecta's words (its
|
|
882
|
+
* own classes, an OAuth error the token responses were rebuilt into, and
|
|
883
|
+
* `UnauthorizedError`, whose class is the whole verdict and whose text is
|
|
884
|
+
* never shown). The request's own abort reason is checked by identity before
|
|
885
|
+
* this. Everything else is withheld.
|
|
886
|
+
*/
|
|
887
|
+
function keepsItsText(err, transport) {
|
|
888
|
+
if (err instanceof ConnectorCallError ||
|
|
889
|
+
err instanceof WithheldTextError ||
|
|
890
|
+
err instanceof UnauthorizedError ||
|
|
891
|
+
err instanceof OAuthError) {
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
if (err instanceof SdkHttpError)
|
|
895
|
+
return err.status >= 400 && err.status < 500;
|
|
896
|
+
if (err instanceof ProtocolError) {
|
|
897
|
+
return transport !== undefined && wireErrors.get(transport)?.has(wireErrorKey(err.code, err.message)) === true;
|
|
898
|
+
}
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* The SDK's own checks of a tool's output schema, recognized by how the
|
|
903
|
+
* pinned SDK (2.3.1) begins their messages, and told in connecta's words.
|
|
904
|
+
* Recognition reads the SDK's text only to choose connecta's: none of it, nor
|
|
905
|
+
* the validator's account that follows, is repeated.
|
|
906
|
+
*/
|
|
907
|
+
const SDK_OUTPUT_SCHEMA_CHECKS = [
|
|
908
|
+
[
|
|
909
|
+
/^Structured content does not match the tool's output schema/,
|
|
910
|
+
"the result did not match the tool's declared output schema",
|
|
911
|
+
],
|
|
912
|
+
[
|
|
913
|
+
/^Tool .* has an output schema but did not return structured content/s,
|
|
914
|
+
"the result did not return structured content although the tool declares an output schema",
|
|
915
|
+
],
|
|
916
|
+
[/^Tool .* has an invalid outputSchema/s, "the tool's declared output schema could not be compiled"],
|
|
917
|
+
];
|
|
918
|
+
function sdkCheckFailed(err) {
|
|
919
|
+
if (err instanceof SdkError && err.code === SdkErrorCode.RequestTimeout)
|
|
920
|
+
return ": the request timed out";
|
|
921
|
+
if (!(err instanceof ProtocolError))
|
|
922
|
+
return "";
|
|
923
|
+
const check = SDK_OUTPUT_SCHEMA_CHECKS.find(([pattern]) => pattern.test(err.message));
|
|
924
|
+
return check ? `: ${check[1]}` : "";
|
|
925
|
+
}
|
|
926
|
+
/** The first error in `err`'s cause chain already told in connecta's words. */
|
|
927
|
+
function connectaErrorWithin(err) {
|
|
928
|
+
const seen = new Set();
|
|
929
|
+
for (let current = err instanceof Error ? err.cause : undefined; current instanceof Error && !seen.has(current); current = current.cause) {
|
|
930
|
+
seen.add(current);
|
|
931
|
+
if (current instanceof ConnectorCallError || current instanceof WithheldTextError)
|
|
932
|
+
return current;
|
|
933
|
+
}
|
|
934
|
+
return undefined;
|
|
935
|
+
}
|
|
936
|
+
/** The request's own abort reason, by identity, never by name or class. */
|
|
937
|
+
function ownAbortReason(err, signals) {
|
|
938
|
+
return signals.some((signal) => signal?.aborted === true && err === signal.reason);
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* The request's own abort reason as the SDK hands it back: a request it
|
|
942
|
+
* cancels rejects with `new SdkError(RequestTimeout, String(reason))` unless
|
|
943
|
+
* the reason already is one (pinned against client 2.3.1). Its text is the
|
|
944
|
+
* caller's, so it passes as the SDK wrote it.
|
|
945
|
+
*/
|
|
946
|
+
function ownAbortReasonAsSdkReports(err, signals) {
|
|
947
|
+
return (err instanceof SdkError &&
|
|
948
|
+
err.code === SdkErrorCode.RequestTimeout &&
|
|
949
|
+
signals.some((signal) => signal?.aborted === true && err.message === String(signal.reason)));
|
|
950
|
+
}
|
|
951
|
+
/** An error's class name, when it is a plain identifier worth naming. */
|
|
952
|
+
function errorKind(err) {
|
|
953
|
+
const label = errorLabel(err);
|
|
954
|
+
return label && label !== "Error" ? ` (${label})` : "";
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Replace text while keeping a verdict already derived from structured facts.
|
|
958
|
+
* Never retain the original SDK error as a cause.
|
|
959
|
+
*/
|
|
960
|
+
function withheldAs(message, verdict) {
|
|
961
|
+
if (verdict instanceof ConnectorCallError) {
|
|
962
|
+
return new ConnectorCallError(verdict.code, message, {
|
|
963
|
+
retryable: verdict.retryable,
|
|
964
|
+
...(verdict.retryAfterMs !== undefined ? { retryAfterMs: verdict.retryAfterMs } : {}),
|
|
965
|
+
...(verdict.details ? { details: verdict.details } : {}),
|
|
966
|
+
...(verdict.validation ? { validation: verdict.validation } : {}),
|
|
967
|
+
...(verdict.current ? { current: verdict.current } : {}),
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
return new WithheldTextError(message, verdict);
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Record each OAuth request on `trail` before sending it. Pinned against
|
|
974
|
+
* `@modelcontextprotocol/client` 2.3.1: every request the SDK sends anywhere
|
|
975
|
+
* but the MCP endpoint is a discovery GET, a token request (a form carrying
|
|
976
|
+
* `grant_type`), or the JSON POST of dynamic client registration.
|
|
977
|
+
*/
|
|
978
|
+
function tracedOAuthFetch(trail, endpoint, baseFetch) {
|
|
979
|
+
return async (input, init) => {
|
|
980
|
+
const url = new URL(input);
|
|
981
|
+
if (url.href === endpoint.href) {
|
|
982
|
+
trail.last = undefined;
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
const body = init?.body;
|
|
986
|
+
const step = (init?.method ?? "GET").toUpperCase() === "GET"
|
|
987
|
+
? "discovery"
|
|
988
|
+
: body instanceof URLSearchParams || (typeof body === "string" && new URLSearchParams(body).has("grant_type"))
|
|
989
|
+
? "token request"
|
|
990
|
+
: "client registration";
|
|
991
|
+
trail.last = { step, host: url.origin };
|
|
992
|
+
if (step === "client registration")
|
|
993
|
+
trail.registration = url.origin;
|
|
994
|
+
}
|
|
995
|
+
const leg = trail.last;
|
|
996
|
+
const response = await baseFetch(input, init);
|
|
997
|
+
if (leg) {
|
|
998
|
+
leg.httpStatus = response.status;
|
|
999
|
+
const wait = retryAfterMs(response.headers);
|
|
1000
|
+
if (wait !== undefined)
|
|
1001
|
+
leg.retryAfterMs = wait;
|
|
1002
|
+
}
|
|
1003
|
+
return byteReadResponse(response);
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
function registrationErrorCode(body) {
|
|
1007
|
+
try {
|
|
1008
|
+
const code = JSON.parse(body)?.error;
|
|
1009
|
+
return typeof code === "string" && OAUTH_ERROR_CODES.has(code) ? code : undefined;
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
return undefined;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
390
1015
|
/**
|
|
391
1016
|
* Proxy a downstream remote MCP server. SDK clients and transports are scoped
|
|
392
1017
|
* to one inbound request: reused by calls within a batch/execute_code run, but
|
|
@@ -398,19 +1023,28 @@ function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
|
|
|
398
1023
|
* server or hide other connectors).
|
|
399
1024
|
*/
|
|
400
1025
|
export function remoteMcp(id, opts) {
|
|
1026
|
+
opts = assertKnownOptions(opts, `remoteMcp(${JSON.stringify(id)})`, REMOTE_MCP_OPTIONS);
|
|
1027
|
+
const skillsEnabled = opts.skills;
|
|
1028
|
+
if (skillsEnabled !== undefined && typeof skillsEnabled !== "boolean") {
|
|
1029
|
+
throw new Error(`[connecta] connector "${id}" skills must be a boolean.`);
|
|
1030
|
+
}
|
|
1031
|
+
const classification = opts.classify === undefined ? undefined : reviewedClassification(opts.classify, `connector "${id}"`);
|
|
401
1032
|
const clientMetadataUrl = opts.auth?.type === "oauth" ? opts.auth.clientMetadataUrl : undefined;
|
|
402
1033
|
const oauthScope = opts.auth?.type === "oauth" ? opts.auth.scope : undefined;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
1034
|
+
assertOAuthScope(id, oauthScope);
|
|
1035
|
+
const staticClient = opts.auth?.type === "oauth" && opts.auth.client ? { ...opts.auth.client } : undefined;
|
|
1036
|
+
assertRemoteOAuthClient(id, staticClient);
|
|
1037
|
+
if (staticClient && clientMetadataUrl !== undefined)
|
|
1038
|
+
throw new Error(`[connecta] connector "${id}" OAuth client and clientMetadataUrl are mutually exclusive.`);
|
|
406
1039
|
if (clientMetadataUrl !== undefined) {
|
|
407
1040
|
let valid = false;
|
|
408
1041
|
try {
|
|
409
1042
|
const url = new URL(clientMetadataUrl);
|
|
410
|
-
valid = url.protocol === "https:" && url.pathname !== "/" &&
|
|
411
|
-
|
|
1043
|
+
valid = url.protocol === "https:" && url.pathname !== "/" && !url.username && !url.password && !url.hash;
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
/* Invalid configuration is rejected below without echoing its value. */
|
|
412
1047
|
}
|
|
413
|
-
catch { /* Invalid configuration is rejected below without echoing its value. */ }
|
|
414
1048
|
if (!valid)
|
|
415
1049
|
throw new Error(`[connecta] connector "${id}" clientMetadataUrl must be an HTTPS URL with a non-root path, without credentials or a fragment.`);
|
|
416
1050
|
}
|
|
@@ -425,28 +1059,69 @@ export function remoteMcp(id, opts) {
|
|
|
425
1059
|
// A closed scope keeps its (emptied) entry, so a late or future lookup finds
|
|
426
1060
|
// it closed rather than recreating an ownerless connection under it.
|
|
427
1061
|
const states = new WeakMap();
|
|
1062
|
+
const cacheOwner = {};
|
|
1063
|
+
// A cached transport may dispatch concurrent calls and listings. Register
|
|
1064
|
+
// its actual sent auth with every operation using that client, including
|
|
1065
|
+
// handshake/discovery requests and OAuth rotations. Remove settled users.
|
|
1066
|
+
const activeContexts = new WeakMap();
|
|
1067
|
+
const trackSentRequest = (ctx, input, init) => {
|
|
1068
|
+
const active = activeContexts.get(entryFor(ctx));
|
|
1069
|
+
const recipients = new Set([sentSecretsFor(ctx), ...[...(active ?? [])].map(sentSecretsFor)]);
|
|
1070
|
+
for (const secrets of recipients) {
|
|
1071
|
+
if (opts.auth?.type === "oauth")
|
|
1072
|
+
trackRemoteClientRequest(secrets, input, init);
|
|
1073
|
+
else
|
|
1074
|
+
secrets.request(input, init);
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
const connectingWaiters = new WeakMap();
|
|
428
1078
|
const isOauth = opts.auth?.type === "oauth";
|
|
429
1079
|
// Long-lived enough for distinct request scopes in this connector runtime to
|
|
430
1080
|
// join one token redemption. It owns no client, transport, or request state.
|
|
431
|
-
const
|
|
432
|
-
const refreshCoordinatorFor = (ctx) => {
|
|
433
|
-
// Production contexts carry a stable registry partition. Hand-written
|
|
434
|
-
// contexts can coordinate by their shared storage object instead.
|
|
435
|
-
const partition = oauthPartitionFor(ctx) ?? ctx.storage;
|
|
436
|
-
let coordinator = refreshCoordinators.get(partition);
|
|
437
|
-
if (!coordinator) {
|
|
438
|
-
coordinator = new OAuthRefreshCoordinator(() => retainOAuthPartition(partition));
|
|
439
|
-
refreshCoordinators.set(partition, coordinator);
|
|
440
|
-
}
|
|
441
|
-
return coordinator;
|
|
442
|
-
};
|
|
1081
|
+
const refreshCoordinatorFor = refreshCoordinatorsByPartition();
|
|
443
1082
|
const logger = opts.logger ?? console;
|
|
1083
|
+
const warnShortSecret = shortSecretWarning();
|
|
1084
|
+
warnShortSecret(staticClient?.clientSecret, logger);
|
|
1085
|
+
if (opts.auth?.type === "headers") {
|
|
1086
|
+
for (const [name, value] of Object.entries(opts.auth.headers)) {
|
|
1087
|
+
if (!/key|token|secret|password|auth|signature|session/i.test(name))
|
|
1088
|
+
continue;
|
|
1089
|
+
const basic = /^Basic\s+(.+)$/i.exec(value);
|
|
1090
|
+
if (basic) {
|
|
1091
|
+
try {
|
|
1092
|
+
const decoded = atob(basic[1]);
|
|
1093
|
+
const colon = decoded.indexOf(":");
|
|
1094
|
+
if (colon !== -1)
|
|
1095
|
+
warnShortSecret(decoded.slice(colon + 1), logger);
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
/* Invalid Basic configuration is not a short password. */
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
else
|
|
1102
|
+
warnShortSecret(value.replace(/^(?:Bearer|token)\s+/i, ""), logger);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
444
1105
|
const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
|
|
445
1106
|
if (credentialAuth?.credential?.fields?.length) {
|
|
446
1107
|
throw new Error(`[connecta] connector "${id}" credential auth declares named fields; ` +
|
|
447
1108
|
"this shape sends one secret in one header and reads the reserved " +
|
|
448
1109
|
"`value` field only.");
|
|
449
1110
|
}
|
|
1111
|
+
const requestAuth = opts.auth?.type === "request" ? opts.auth : undefined;
|
|
1112
|
+
if (requestAuth && (typeof requestAuth.token !== "function" || opts.authScope === "personal")) {
|
|
1113
|
+
throw new Error(`remoteMcp(${JSON.stringify(id)}) requires a request token callback and shared authScope.`);
|
|
1114
|
+
}
|
|
1115
|
+
if (requestAuth?.headers) {
|
|
1116
|
+
try {
|
|
1117
|
+
if (Object.keys(requestAuth.headers).some((name) => name.toLowerCase() === "authorization"))
|
|
1118
|
+
throw new Error();
|
|
1119
|
+
new Headers(requestAuth.headers);
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
throw new Error(`remoteMcp(${JSON.stringify(id)}) requires valid request auth headers without Authorization.`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
450
1125
|
const credentialConfig = credentialAuth?.credential ?? {
|
|
451
1126
|
label: "API key",
|
|
452
1127
|
};
|
|
@@ -469,9 +1144,15 @@ export function remoteMcp(id, opts) {
|
|
|
469
1144
|
// endpoint sends bearer tokens / API keys in cleartext. Loopback is exempt
|
|
470
1145
|
// for local development.
|
|
471
1146
|
const destination = new URL(opts.url);
|
|
1147
|
+
// Only a web URL reaches a remote MCP server. Another scheme cannot, and a
|
|
1148
|
+
// wrapping one (`blob:https://…`) would also carry its inner URL, userinfo
|
|
1149
|
+
// and all, into every place the endpoint is reported; the value is not echoed.
|
|
1150
|
+
if (destination.protocol !== "https:" && destination.protocol !== "http:") {
|
|
1151
|
+
throw new Error(`[connecta] connector "${id}" url must be an http(s) URL.`);
|
|
1152
|
+
}
|
|
472
1153
|
const insecureDestination = destination.protocol !== "https:" && !isLoopbackHost(destination.hostname);
|
|
473
1154
|
if (insecureDestination) {
|
|
474
|
-
if (opts.requireHttps) {
|
|
1155
|
+
if (opts.requireHttps || requestAuth) {
|
|
475
1156
|
throw new Error(`[connecta] connector "${id}" url ${opts.url} is not https:// (and not loopback) — refusing to connect (requireHttps).`);
|
|
476
1157
|
}
|
|
477
1158
|
// Both static shapes send a secret on every request; that an operator
|
|
@@ -481,11 +1162,186 @@ export function remoteMcp(id, opts) {
|
|
|
481
1162
|
logger.warn(`[connecta] connector "${id}" sends static credentials to ${opts.url} over a non-https:// connection — those tokens will be transmitted in cleartext.`);
|
|
482
1163
|
}
|
|
483
1164
|
}
|
|
484
|
-
/**
|
|
485
|
-
|
|
1165
|
+
/**
|
|
1166
|
+
* Typed per-call auth signal. The `UnauthorizedError` behind it is not kept
|
|
1167
|
+
* as cause: the class is the whole verdict, and a logger rendering the
|
|
1168
|
+
* chain would render whatever text the error carried.
|
|
1169
|
+
*/
|
|
1170
|
+
const authRequiredError = () => new ConnectorCallError(isOauth ? "downstream_oauth_required" : "auth_required", `Connector "${id}" requires authorization — call authorize_connector({ connector: "${id}" }) and open the returned URL.`);
|
|
1171
|
+
/**
|
|
1172
|
+
* Tell a failure of one OAuth flow in connecta's words.
|
|
1173
|
+
*
|
|
1174
|
+
* The SDK builds these errors from what a downstream or its authorization
|
|
1175
|
+
* server sent: a refused registration quotes the response body whole, an
|
|
1176
|
+
* issuer mismatch quotes the issuers, a discovery failure the URL or the
|
|
1177
|
+
* metadata it could not accept. Any of them can echo what the request
|
|
1178
|
+
* carried, or text the server planted for an agent to read. What survives
|
|
1179
|
+
* is the step and host of the flow's last request, the classification, and
|
|
1180
|
+
* for a registration the status and a registered OAuth error code.
|
|
1181
|
+
* Anything not already in connecta's words is withheld, including a store's
|
|
1182
|
+
* error while the flow saves what it discovered, which can quote it.
|
|
1183
|
+
*/
|
|
1184
|
+
const withoutAuthorizationServerText = async (ctx, err, trail, signals) => {
|
|
1185
|
+
const safeOrigin = async (host) => {
|
|
1186
|
+
if (!host || sentSecretsFor(ctx).contains(host, true))
|
|
1187
|
+
return undefined;
|
|
1188
|
+
if (host === destination.origin || (staticClient && host === new URL(staticClient.issuer).origin))
|
|
1189
|
+
return host;
|
|
1190
|
+
try {
|
|
1191
|
+
return host === (await newProvider(ctx).validatedIssuerOrigin()) ? host : undefined;
|
|
1192
|
+
}
|
|
1193
|
+
catch {
|
|
1194
|
+
return undefined;
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
if (ownAbortReason(err, signals) && !hasSdkPayload(err))
|
|
1198
|
+
return err;
|
|
1199
|
+
if (err instanceof RegistrationRejectedError) {
|
|
1200
|
+
const code = registrationErrorCode(err.body);
|
|
1201
|
+
const origin = await safeOrigin(trail.registration);
|
|
1202
|
+
const facts = {
|
|
1203
|
+
step: "OAuth client registration",
|
|
1204
|
+
...(origin ? { origin } : {}),
|
|
1205
|
+
httpStatus: err.status,
|
|
1206
|
+
...(code ? { oauthError: code } : {}),
|
|
1207
|
+
};
|
|
1208
|
+
return attachFailureFacts(carryFailureFacts(err, withheldAs(`Connector "${id}" could not register an OAuth client with ` +
|
|
1209
|
+
`${origin ?? "its authorization server"}: the ` +
|
|
1210
|
+
`registration endpoint answered HTTP ${err.status}` +
|
|
1211
|
+
`${code ? ` with OAuth error ${code}` : ""}. Its response is ` +
|
|
1212
|
+
"withheld because it can quote the request or anything the server " +
|
|
1213
|
+
"chose to add. Check the server's client registration policy, then " +
|
|
1214
|
+
"retry authorization.", downstreamCallError(err, undefined, trail.last?.retryAfterMs))), facts);
|
|
1215
|
+
}
|
|
1216
|
+
const leg = trail.last;
|
|
1217
|
+
const origin = await safeOrigin(leg?.host);
|
|
1218
|
+
const facts = leg
|
|
1219
|
+
? {
|
|
1220
|
+
step: `OAuth ${leg.step}`,
|
|
1221
|
+
...(origin ? { origin } : {}),
|
|
1222
|
+
...(leg.httpStatus ? { httpStatus: leg.httpStatus } : {}),
|
|
1223
|
+
}
|
|
1224
|
+
: { step: "OAuth flow" };
|
|
1225
|
+
if (err instanceof UnauthorizedError || err instanceof ConnectorCallError || err instanceof WithheldTextError) {
|
|
1226
|
+
return attachFailureFacts(downstreamCallError(err), facts);
|
|
1227
|
+
}
|
|
1228
|
+
const classified = downstreamCallError(err, leg?.httpStatus, leg?.retryAfterMs, leg?.step);
|
|
1229
|
+
if (err instanceof OAuthError ||
|
|
1230
|
+
(classified instanceof ConnectorCallError && classified.code === "provider_permission_denied")) {
|
|
1231
|
+
return attachFailureFacts(carryFailureFacts(err, classified), facts);
|
|
1232
|
+
}
|
|
1233
|
+
return attachFailureFacts(carryFailureFacts(err, withheldAs(`Connector "${id}" OAuth ${leg ? `${leg.step}${origin ? ` with ${origin}` : ""}` : "flow"} ` +
|
|
1234
|
+
`failed${errorKind(err)}. The error is withheld because its text can ` +
|
|
1235
|
+
"quote what the server sent. Check the server's OAuth metadata, then " +
|
|
1236
|
+
"retry authorization.", classified)), facts);
|
|
1237
|
+
};
|
|
1238
|
+
const endpointOrigin = new URL(opts.url).origin;
|
|
1239
|
+
/**
|
|
1240
|
+
* The MCP boundary, where an error leaves the SDK at the handshake, a
|
|
1241
|
+
* `tools/list` page, or a `tools/call`. The request's own abort reason
|
|
1242
|
+
* passes first, by identity, then what `keepsItsText` allows. Anything else
|
|
1243
|
+
* (a parser's or validator's account, a non-4xx body, a runtime's message)
|
|
1244
|
+
* is told as the step, the endpoint's origin, the HTTP status if there was
|
|
1245
|
+
* one, and the error's class, classified as `verdict` would have been.
|
|
1246
|
+
*/
|
|
1247
|
+
const atMcpBoundary = (ctx, err, step, transport, signals) => {
|
|
1248
|
+
// An OAuth flow already attached the step and origin it failed at. The
|
|
1249
|
+
// outer MCP operation must not replace them with the endpoint's facts.
|
|
1250
|
+
if (failureRecord({}, err).step?.startsWith("OAuth "))
|
|
1251
|
+
return downstreamCallError(err);
|
|
1252
|
+
const httpStatus = err instanceof SdkHttpError ? err.status : undefined;
|
|
1253
|
+
// Skills operations retain the existing closed operator-record vocabulary.
|
|
1254
|
+
const facts = {
|
|
1255
|
+
...(step === "skills/list" || step === "resources/read" ? {} : { step }),
|
|
1256
|
+
origin: endpointOrigin,
|
|
1257
|
+
...(httpStatus ? { httpStatus } : {}),
|
|
1258
|
+
};
|
|
1259
|
+
const classified = downstreamCallError(redactSentSecrets(ctx, err), undefined, undefined, undefined, sentSecretsFor(ctx));
|
|
1260
|
+
const verdict = isOauth && classified instanceof ConnectorCallError && classified.code === "auth_required"
|
|
1261
|
+
? carryFailureFacts(err, authRequiredError())
|
|
1262
|
+
: classified;
|
|
1263
|
+
if (ownAbortReasonAsSdkReports(err, signals)) {
|
|
1264
|
+
return attachFailureFacts(carryFailureFacts(err, withheldAs(msg(err), verdict)), facts);
|
|
1265
|
+
}
|
|
1266
|
+
if (err instanceof InsufficientScopeError) {
|
|
1267
|
+
return attachFailureFacts(carryFailureFacts(err, verdict), facts);
|
|
1268
|
+
}
|
|
1269
|
+
if (requestAuth && httpStatus === 401) {
|
|
1270
|
+
return attachFailureFacts(new ConnectorCallError("auth_required", "The downstream rejected this request's Bearer token."), facts);
|
|
1271
|
+
}
|
|
1272
|
+
if (ownAbortReason(err, signals) || keepsItsText(err, transport)) {
|
|
1273
|
+
return attachFailureFacts(carryFailureFacts(err, redactSentSecrets(ctx, err instanceof UnauthorizedError || (ownAbortReason(err, signals) && !hasSdkPayload(err)) ? err : verdict)), facts);
|
|
1274
|
+
}
|
|
1275
|
+
// The SDK wraps a failure of connecta's own fetch (a refused redirect, an
|
|
1276
|
+
// unreachable host) in an error of its own, such as the version probe's;
|
|
1277
|
+
// that failure's words are connecta's, and they say what happened.
|
|
1278
|
+
const ours = connectaErrorWithin(err);
|
|
1279
|
+
if (ours) {
|
|
1280
|
+
return attachFailureFacts(carryFailureFacts(ours, withheldAs(ours.message, ours)), facts);
|
|
1281
|
+
}
|
|
1282
|
+
const status = httpStatus ? ` with HTTP ${httpStatus}` : "";
|
|
1283
|
+
return attachFailureFacts(carryFailureFacts(err, withheldAs(`Connector "${id}" ${step} with ${endpointOrigin} failed${status}` +
|
|
1284
|
+
`${errorKind(err)}${sdkCheckFailed(err)}. The error is withheld because ` +
|
|
1285
|
+
"its text can quote what the server sent.", verdict)), facts);
|
|
1286
|
+
};
|
|
1287
|
+
/**
|
|
1288
|
+
* Run every OAuth flow the SDK starts on `transport` inside a boundary of
|
|
1289
|
+
* its own: a trail only that flow's requests write to, and
|
|
1290
|
+
* `withoutAuthorizationServerText` on whatever the flow throws. Calls on
|
|
1291
|
+
* one client can each start a flow at once, so no flow's failure is ever
|
|
1292
|
+
* told by another flow's last request.
|
|
1293
|
+
*
|
|
1294
|
+
* Pinned against `@modelcontextprotocol/client` 2.3.1, whose transport
|
|
1295
|
+
* offers no public seam for this. A flow starts in exactly two places, each
|
|
1296
|
+
* handing `auth()` the fetch to use: the adapted provider's
|
|
1297
|
+
* `onUnauthorized` (a 401, at connect or on a live client), given the fetch
|
|
1298
|
+
* in its context, and `_stepUpAuthorize` (a 403 `insufficient_scope`),
|
|
1299
|
+
* which reads `this._fetchWithInit`. The callback's code exchange runs
|
|
1300
|
+
* outside both, and the callback route repeats nothing it throws. If an
|
|
1301
|
+
* upgrade moves either seam, this refuses to build the transport rather
|
|
1302
|
+
* than run flows unbounded.
|
|
1303
|
+
*/
|
|
1304
|
+
const boundOAuthFlows = (ctx, transport, endpoint,
|
|
1305
|
+
/** The request's and connection's signals: only their reasons pass as written. */
|
|
1306
|
+
signals) => {
|
|
1307
|
+
const internals = transport;
|
|
1308
|
+
const adapted = internals._authProvider;
|
|
1309
|
+
const onUnauthorized = adapted?.onUnauthorized;
|
|
1310
|
+
const stepUp = internals._stepUpAuthorize;
|
|
1311
|
+
if (!adapted || typeof onUnauthorized !== "function" || typeof stepUp !== "function") {
|
|
1312
|
+
// Its own class, so that a status record, which carries no message, says so.
|
|
1313
|
+
throw new UnboundOAuthFlowsError(`[connecta] connector "${id}": the MCP client's transport no longer ` +
|
|
1314
|
+
"has the OAuth flow seams this release is pinned against, so its " +
|
|
1315
|
+
"flows cannot be bounded. Pin @modelcontextprotocol/client to the " +
|
|
1316
|
+
"version this release ships with.");
|
|
1317
|
+
}
|
|
1318
|
+
const flow = async (start) => {
|
|
1319
|
+
const trail = {};
|
|
1320
|
+
try {
|
|
1321
|
+
return await start((fetchFn) => tracedOAuthFetch(trail, endpoint, async (input, init) => {
|
|
1322
|
+
trackSentRequest(ctx, input, init);
|
|
1323
|
+
return await fetchFn(input, init);
|
|
1324
|
+
}));
|
|
1325
|
+
}
|
|
1326
|
+
catch (err) {
|
|
1327
|
+
throw await withoutAuthorizationServerText(ctx, err, trail, signals);
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
adapted.onUnauthorized = (ctx) => flow((trace) => onUnauthorized.call(adapted, { ...ctx, fetchFn: trace(ctx.fetchFn ?? fetch) }));
|
|
1331
|
+
internals._stepUpAuthorize = (challenge, retries) => flow((trace) => {
|
|
1332
|
+
const traced = trace(internals._fetchWithInit ?? fetch);
|
|
1333
|
+
// The step-up reads its fetch from `this`; everything else it reads
|
|
1334
|
+
// and writes stays the transport's own.
|
|
1335
|
+
const self = new Proxy(transport, {
|
|
1336
|
+
get: (target, key) => (key === "_fetchWithInit" ? traced : Reflect.get(target, key, target)),
|
|
1337
|
+
set: (target, key, value) => Reflect.set(target, key, value, target),
|
|
1338
|
+
});
|
|
1339
|
+
return stepUp.call(self, challenge, retries);
|
|
1340
|
+
});
|
|
1341
|
+
};
|
|
486
1342
|
class OperatorDisconnectedError extends ConnectorCallError {
|
|
487
1343
|
constructor() {
|
|
488
|
-
super("
|
|
1344
|
+
super("downstream_oauth_required", `Connector "${id}" was disconnected by an operator — explicitly start authorization to reconnect it.`);
|
|
489
1345
|
}
|
|
490
1346
|
}
|
|
491
1347
|
const operatorDisconnectedError = () => new OperatorDisconnectedError();
|
|
@@ -507,6 +1363,27 @@ export function remoteMcp(id, opts) {
|
|
|
507
1363
|
* without a redeploy. The value stays in the caller's local scope.
|
|
508
1364
|
*/
|
|
509
1365
|
const readCredential = async (ctx) => {
|
|
1366
|
+
if (requestAuth) {
|
|
1367
|
+
let value;
|
|
1368
|
+
try {
|
|
1369
|
+
value = await requestAuth.token(ctx);
|
|
1370
|
+
}
|
|
1371
|
+
catch (error) {
|
|
1372
|
+
if (error instanceof ConnectorCallError)
|
|
1373
|
+
throw error;
|
|
1374
|
+
throw new ConnectorCallError("auth_required", "The request token could not be resolved.");
|
|
1375
|
+
}
|
|
1376
|
+
ctx.signal?.throwIfAborted();
|
|
1377
|
+
if (typeof value !== "string" ||
|
|
1378
|
+
!value.trim() ||
|
|
1379
|
+
Array.from(value).some((char) => {
|
|
1380
|
+
const code = char.charCodeAt(0);
|
|
1381
|
+
return code <= 32 || (code >= 127 && code <= 159);
|
|
1382
|
+
})) {
|
|
1383
|
+
throw new ConnectorCallError("auth_required", "The request token is empty or cannot be sent as a Bearer header.");
|
|
1384
|
+
}
|
|
1385
|
+
return value;
|
|
1386
|
+
}
|
|
510
1387
|
if (!ctx.credential) {
|
|
511
1388
|
throw new CredentialRequiredError(`Connector "${id}" needs an operator-managed credential, but ` +
|
|
512
1389
|
"credential storage is not configured. Configure vault in deployment code and redeploy. Call authorize_connector for recovery options.");
|
|
@@ -551,6 +1428,10 @@ export function remoteMcp(id, opts) {
|
|
|
551
1428
|
let current = err;
|
|
552
1429
|
while (current instanceof Error && !seen.has(current)) {
|
|
553
1430
|
seen.add(current);
|
|
1431
|
+
// Decided already, beneath the transport, and wrapped by the SDK on
|
|
1432
|
+
// the way up (the version probe's error, say).
|
|
1433
|
+
if (current instanceof CredentialRequiredError)
|
|
1434
|
+
return current;
|
|
554
1435
|
if (quoted.some((secret) => current instanceof Error && current.message.includes(secret))) {
|
|
555
1436
|
return new CredentialRequiredError(`Connector "${id}" could not send its stored credential as a ` +
|
|
556
1437
|
"header. Call authorize_connector for recovery options. When available, " +
|
|
@@ -561,30 +1442,146 @@ export function remoteMcp(id, opts) {
|
|
|
561
1442
|
}
|
|
562
1443
|
return err;
|
|
563
1444
|
};
|
|
564
|
-
|
|
1445
|
+
// Its own class, so that a status record, which carries no message, still says so.
|
|
1446
|
+
const scopeEndedError = () => new ScopeEndedError(`Connector "${id}" scope ended during connection.`);
|
|
565
1447
|
const requestOptions = (ctx) => ctx.timeoutMs || ctx.signal
|
|
566
1448
|
? {
|
|
567
1449
|
...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
|
|
568
1450
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
569
1451
|
}
|
|
570
1452
|
: undefined;
|
|
571
|
-
/**
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
...
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1453
|
+
/** Wrap the public request seam, before listTools aggregates or auto-caches.
|
|
1454
|
+
* SDK 2.3.1 treats an identical repeated page as completion. Refuse it here:
|
|
1455
|
+
* an advertised successor is never proof of a complete catalog (INV-8). */
|
|
1456
|
+
const listingContexts = new WeakMap();
|
|
1457
|
+
const installCatalogIntake = (client, ctx, cache) => {
|
|
1458
|
+
let toolPages = 0;
|
|
1459
|
+
const listingContext = (options) => {
|
|
1460
|
+
const { signal: _signal, timeoutMs: _timeout, ...base } = ctx;
|
|
1461
|
+
return ((options && listingContexts.get(options)) ?? {
|
|
1462
|
+
...base,
|
|
1463
|
+
requestScope: ctx.requestScope ?? ctx,
|
|
1464
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
1465
|
+
...(options?.timeout ? { timeoutMs: options.timeout } : {}),
|
|
1466
|
+
});
|
|
1467
|
+
};
|
|
1468
|
+
const listTools = client.listTools.bind(client);
|
|
1469
|
+
client.listTools = (params, options) => cache.withListing(listingContext(options), async () => {
|
|
1470
|
+
const before = toolPages;
|
|
1471
|
+
let result;
|
|
1472
|
+
try {
|
|
1473
|
+
result = await listTools(params, options);
|
|
1474
|
+
}
|
|
1475
|
+
catch (error) {
|
|
1476
|
+
const bounded = atMcpBoundary(cache.currentContext(), error, "tools/list", client.transport, [
|
|
1477
|
+
cache.currentContext().signal,
|
|
1478
|
+
]);
|
|
1479
|
+
const transient = (error instanceof SdkHttpError && [502, 503, 504].includes(error.status)) ||
|
|
1480
|
+
(bounded instanceof ConnectorCallError && bounded.code === "unavailable");
|
|
1481
|
+
const fallback = transient && toolPages === before ? await cache.fallbackTools() : undefined;
|
|
1482
|
+
if (!fallback)
|
|
1483
|
+
throw error;
|
|
1484
|
+
return fallback;
|
|
1485
|
+
}
|
|
1486
|
+
if (toolPages !== before && params?.cursor === undefined) {
|
|
1487
|
+
const refresh = await cache.completeCatalogRefresh(result);
|
|
1488
|
+
if (refresh) {
|
|
1489
|
+
try {
|
|
1490
|
+
await observeCompletedCatalogRefresh(cache.currentContext(), refresh);
|
|
1491
|
+
}
|
|
1492
|
+
catch (error) {
|
|
1493
|
+
logFailure(cache.currentContext().logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return result;
|
|
1498
|
+
});
|
|
1499
|
+
const listResources = client.listResources.bind(client);
|
|
1500
|
+
client.listResources = (params, options) => cache.withListing(listingContext(options), () => listResources(params, options));
|
|
1501
|
+
const listResourceTemplates = client.listResourceTemplates.bind(client);
|
|
1502
|
+
client.listResourceTemplates = (params, options) => cache.withListing(listingContext(options), () => listResourceTemplates(params, options));
|
|
1503
|
+
const request = client.request.bind(client);
|
|
1504
|
+
const walks = new WeakMap();
|
|
1505
|
+
client.request = (async (...args) => {
|
|
1506
|
+
const message = args[0];
|
|
1507
|
+
if (message.method !== "tools/list" &&
|
|
1508
|
+
message.method !== "resources/list" &&
|
|
1509
|
+
message.method !== "resources/templates/list")
|
|
1510
|
+
return Reflect.apply(request, client, args);
|
|
1511
|
+
const method = message.method;
|
|
1512
|
+
const ctx = cache.currentContext();
|
|
1513
|
+
const options = (args.length === 3 ? args[2] : args[1]);
|
|
1514
|
+
if (isClosed(entryFor(ctx)))
|
|
1515
|
+
throw scopeEndedError();
|
|
1516
|
+
if (options?.signal?.aborted)
|
|
1517
|
+
throw options.signal.reason;
|
|
1518
|
+
const key = options ?? message;
|
|
1519
|
+
let walk = walks.get(key);
|
|
1520
|
+
if (message.params?.cursor === undefined || !walk) {
|
|
1521
|
+
walk = { names: new Set(), cursors: new Set(), barren: 0, bytes: 0 };
|
|
1522
|
+
walks.set(key, walk);
|
|
1523
|
+
}
|
|
1524
|
+
let page;
|
|
1525
|
+
try {
|
|
1526
|
+
page = await request(message, method === "tools/list"
|
|
1527
|
+
? CompatibleListToolsResultSchema
|
|
1528
|
+
: method === "resources/list"
|
|
1529
|
+
? specTypeSchemas.ListResourcesResult
|
|
1530
|
+
: specTypeSchemas.ListResourceTemplatesResult, options);
|
|
1531
|
+
}
|
|
1532
|
+
catch (error) {
|
|
1533
|
+
if (isCursorShapeError(error))
|
|
1534
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream catalog nextCursor must be a string, null, or absent.", { retryable: false });
|
|
1535
|
+
throw error;
|
|
1536
|
+
}
|
|
1537
|
+
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, page);
|
|
1538
|
+
const clean = cache.intake(ctx, method, page);
|
|
1539
|
+
if (method === "tools/list")
|
|
1540
|
+
toolPages++;
|
|
1541
|
+
if (message.params?.cursor === undefined)
|
|
1542
|
+
observeCatalogFetch(ctx, Date.now(), method);
|
|
1543
|
+
const tools = catalogItems(method, clean).filter((item) => {
|
|
1544
|
+
const name = method === "tools/list"
|
|
1545
|
+
? item.name
|
|
1546
|
+
: method === "resources/list"
|
|
1547
|
+
? item.uri
|
|
1548
|
+
: item.uriTemplate;
|
|
1549
|
+
if (walk.names.has(name))
|
|
1550
|
+
return false;
|
|
1551
|
+
walk.names.add(name);
|
|
1552
|
+
return true;
|
|
1553
|
+
});
|
|
1554
|
+
walk.bytes += new TextEncoder().encode(JSON.stringify(tools)).byteLength;
|
|
1555
|
+
if (walk.names.size > MAX_CATALOG_TOOLS || walk.bytes > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
1556
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream catalog exceeds the complete-catalog ceiling.", { retryable: false });
|
|
1557
|
+
}
|
|
1558
|
+
if (clean.nextCursor !== undefined) {
|
|
1559
|
+
if (walk.cursors.has(clean.nextCursor))
|
|
1560
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream catalog pagination chain loops.", {
|
|
1561
|
+
retryable: false,
|
|
1562
|
+
});
|
|
1563
|
+
if (tools.length === 0 && ++walk.barren > 1)
|
|
1564
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream catalog pagination is not advancing.", {
|
|
1565
|
+
retryable: false,
|
|
1566
|
+
});
|
|
1567
|
+
if (tools.length > 0)
|
|
1568
|
+
walk.barren = 0;
|
|
1569
|
+
walk.cursors.add(clean.nextCursor);
|
|
1570
|
+
}
|
|
1571
|
+
const field = method === "tools/list" ? "tools" : method === "resources/list" ? "resources" : "resourceTemplates";
|
|
1572
|
+
const result = { ...clean, [field]: tools };
|
|
1573
|
+
if (!walk.first)
|
|
1574
|
+
walk.first = result;
|
|
1575
|
+
else {
|
|
1576
|
+
// SDK aggregation retains page-one metadata. A later page may only
|
|
1577
|
+
// narrow its reuse grant, never broaden the complete catalog's scope.
|
|
1578
|
+
walk.first.ttlMs = Math.min(walk.first.ttlMs, clean.ttlMs);
|
|
1579
|
+
if (clean.cacheScope !== "public")
|
|
1580
|
+
walk.first.cacheScope = "private";
|
|
1581
|
+
}
|
|
1582
|
+
observeCatalogExpiry(ctx, method, walk.first.ttlMs);
|
|
1583
|
+
return result;
|
|
1584
|
+
});
|
|
588
1585
|
};
|
|
589
1586
|
/** This request scope's entry, open or closed, created on first sight. */
|
|
590
1587
|
const entryFor = (ctx) => {
|
|
@@ -601,6 +1598,7 @@ export function remoteMcp(id, opts) {
|
|
|
601
1598
|
authRequired: false,
|
|
602
1599
|
provider: null,
|
|
603
1600
|
connectedGeneration: null,
|
|
1601
|
+
attemptProvider: null,
|
|
604
1602
|
credentialDigest: null,
|
|
605
1603
|
};
|
|
606
1604
|
states.set(key, state);
|
|
@@ -614,25 +1612,29 @@ export function remoteMcp(id, opts) {
|
|
|
614
1612
|
throw scopeEndedError();
|
|
615
1613
|
return state;
|
|
616
1614
|
};
|
|
617
|
-
const newProvider = (ctx, state) => {
|
|
1615
|
+
const newProvider = (ctx, state, signal = ctx.signal) => {
|
|
618
1616
|
if (state?.provider)
|
|
619
1617
|
return state.provider;
|
|
620
|
-
const
|
|
1618
|
+
const metadataUrl = staticClient ? undefined : (clientMetadataUrl ?? selfHostedClientUrl(ctx.publicUrl, id));
|
|
1619
|
+
const redirectUri = downstreamRedirectUri(ctx.baseUrl, ctx.publicUrl, id);
|
|
1620
|
+
const provider = new KvOAuthProvider(id, ctx.storage, redirectUri, refreshCoordinatorFor(ctx), ctx.allowAuthorization === true, oauthSealerFor(ctx), signal, JSON.stringify({
|
|
621
1621
|
url: new URL(opts.url).href,
|
|
622
|
-
redirectUri
|
|
623
|
-
clientMetadata:
|
|
624
|
-
redirect_uris: [`${ctx.baseUrl}/oauth/callback/${id}`],
|
|
625
|
-
client_name: "connecta",
|
|
626
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
627
|
-
response_types: ["code"],
|
|
628
|
-
token_endpoint_auth_method: "none",
|
|
629
|
-
...(oauthScope !== undefined ? { scope: oauthScope } : {}),
|
|
630
|
-
},
|
|
1622
|
+
redirectUri,
|
|
1623
|
+
clientMetadata: downstreamClientMetadata(redirectUri, oauthScope, ctx.oauthClientName, staticClient ? remoteClientAuthMethod(staticClient) : "none"),
|
|
631
1624
|
authScope: opts.authScope ?? "shared",
|
|
632
1625
|
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
633
1626
|
redirects: opts.redirects ?? "none",
|
|
634
|
-
clientMetadataUrl,
|
|
635
|
-
|
|
1627
|
+
clientMetadataUrl: metadataUrl,
|
|
1628
|
+
...(staticClient
|
|
1629
|
+
? {
|
|
1630
|
+
client: {
|
|
1631
|
+
issuer: staticClient.issuer,
|
|
1632
|
+
clientId: staticClient.clientId,
|
|
1633
|
+
tokenEndpointAuthMethod: staticClient.tokenEndpointAuthMethod,
|
|
1634
|
+
},
|
|
1635
|
+
}
|
|
1636
|
+
: {}),
|
|
1637
|
+
}), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), metadataUrl, oauthScope, undefined, { name: ctx.oauthClientName, client: staticClient, secrets: sentSecretsFor(ctx) });
|
|
636
1638
|
if (state)
|
|
637
1639
|
state.provider = provider;
|
|
638
1640
|
return provider;
|
|
@@ -643,22 +1645,71 @@ export function remoteMcp(id, opts) {
|
|
|
643
1645
|
* by the caller so the raw secret is not passed around twice. Never
|
|
644
1646
|
* retained past the transport it configures.
|
|
645
1647
|
*/
|
|
646
|
-
credentialFramed = null
|
|
1648
|
+
credentialFramed = null, signal = ctx.signal,
|
|
1649
|
+
/** The raw value behind `credentialFramed`, under the same terms. */
|
|
1650
|
+
credentialValue = null) => {
|
|
647
1651
|
if (opts._transportFactory)
|
|
648
1652
|
return opts._transportFactory(ctx);
|
|
649
1653
|
const url = new URL(opts.url);
|
|
650
|
-
const
|
|
1654
|
+
const trackedFetch = async (input, init) => {
|
|
1655
|
+
trackSentRequest(ctx, input, init);
|
|
1656
|
+
let rpc;
|
|
1657
|
+
if (typeof init?.body === "string") {
|
|
1658
|
+
try {
|
|
1659
|
+
rpc = JSON.parse(init.body);
|
|
1660
|
+
}
|
|
1661
|
+
catch {
|
|
1662
|
+
/* OAuth exchanges can send form-encoded bodies. */
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
const writeContinuation = downstreamWriteContinuation(ctx.requestScope ?? ctx) &&
|
|
1666
|
+
init?.method === "POST" &&
|
|
1667
|
+
skillObject(rpc) &&
|
|
1668
|
+
rpc.method === "tools/call";
|
|
1669
|
+
const response = writeContinuation
|
|
1670
|
+
? await fetch(input, { ...init, redirect: "manual" })
|
|
1671
|
+
: await fetch(input, init);
|
|
1672
|
+
if (writeContinuation && (response.status === 401 || (response.status >= 300 && response.status < 400))) {
|
|
1673
|
+
await response.body?.cancel().catch(() => { });
|
|
1674
|
+
// Stop before the SDK can refresh auth or follow a redirect and resend
|
|
1675
|
+
// this write. The host's one-use continuation nonce remains spent.
|
|
1676
|
+
if (response.status === 401)
|
|
1677
|
+
throw authRequiredError();
|
|
1678
|
+
throw new ConnectorCallError("connector_call_failed", "The downstream redirected a write continuation; it was not repeated.", { retryable: false });
|
|
1679
|
+
}
|
|
1680
|
+
if (skillsEnabled && skillObject(rpc) && (rpc.method === "skills/list" || rpc.method === "resources/read")) {
|
|
1681
|
+
return boundedSkillResponse(response, rpc.method === "skills/list" ? MAX_SERIALIZED_CATALOG_BYTES : MAX_SKILL_READ_RPC_BYTES, rpc.id, init?.signal);
|
|
1682
|
+
}
|
|
1683
|
+
return response;
|
|
1684
|
+
};
|
|
1685
|
+
// A runtime refusing the assembled header quotes it, and the transport
|
|
1686
|
+
// error below keeps none of what the runtime said. So whether the
|
|
1687
|
+
// rejection quoted the credential is decided here, first, and survives
|
|
1688
|
+
// as the class of the error rather than as its text.
|
|
1689
|
+
const guardedFetch = redirectSafeFetch(id, opts.redirects, credentialFramed === null
|
|
1690
|
+
? trackedFetch
|
|
1691
|
+
: async (input, init) => {
|
|
1692
|
+
try {
|
|
1693
|
+
return await trackedFetch(input, init);
|
|
1694
|
+
}
|
|
1695
|
+
catch (err) {
|
|
1696
|
+
throw withoutCredential(err, credentialValue, credentialFramed);
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
651
1699
|
if (opts.auth?.type === "oauth") {
|
|
652
|
-
const oauthProvider = provider ?? newProvider(ctx);
|
|
653
|
-
|
|
1700
|
+
const oauthProvider = provider ?? newProvider(ctx, undefined, signal);
|
|
1701
|
+
const transport = new StreamableHTTPClientTransport(url, {
|
|
654
1702
|
authProvider: oauthProvider,
|
|
655
|
-
|
|
1703
|
+
onInsufficientScope: "throw",
|
|
1704
|
+
fetch: refreshCoordinatorFor(ctx).coordinatedFetch(oauthProvider, learnedUrlSafeFetch(id, url, trackedFetch), signal, ctx.defer, learnedUrlSafeFetch(id, url, guardedFetch)),
|
|
656
1705
|
});
|
|
1706
|
+
boundOAuthFlows(ctx, transport, url, [signal, ctx.signal]);
|
|
1707
|
+
return transport;
|
|
657
1708
|
}
|
|
658
1709
|
const headers = opts.auth?.type === "headers"
|
|
659
1710
|
? opts.auth.headers
|
|
660
|
-
: credentialAuth && credentialFramed !== null
|
|
661
|
-
? { [credentialHeader]: credentialFramed }
|
|
1711
|
+
: (credentialAuth || requestAuth) && credentialFramed !== null
|
|
1712
|
+
? { ...requestAuth?.headers, [credentialHeader]: credentialFramed }
|
|
662
1713
|
: undefined;
|
|
663
1714
|
return new StreamableHTTPClientTransport(url, {
|
|
664
1715
|
...(headers ? { requestInit: { headers } } : {}),
|
|
@@ -677,8 +1728,8 @@ export function remoteMcp(id, opts) {
|
|
|
677
1728
|
state.credentialDigest = null;
|
|
678
1729
|
// `scope` is deliberately left as it is — see ConnectionState.
|
|
679
1730
|
};
|
|
680
|
-
//
|
|
681
|
-
//
|
|
1731
|
+
// Detached exits start this bounded best-effort tail immediately;
|
|
1732
|
+
// closeScope awaits its own tail so the core
|
|
682
1733
|
// can pass it to the runtime's deferred channel. Terminating and closing are
|
|
683
1734
|
// each bounded, so no close waits more than two seconds in all.
|
|
684
1735
|
const closingSessions = new WeakMap();
|
|
@@ -710,7 +1761,7 @@ export function remoteMcp(id, opts) {
|
|
|
710
1761
|
// for the one call that observed it. Do not let the still-cached client make
|
|
711
1762
|
// a later status or call in the same scope report healthy.
|
|
712
1763
|
if (state.authRequired) {
|
|
713
|
-
throw authRequiredError(
|
|
1764
|
+
throw authRequiredError();
|
|
714
1765
|
}
|
|
715
1766
|
// Read the OAuth epoch before trusting either a cached client or starting a
|
|
716
1767
|
// transport. A disconnected epoch is a durable operator instruction, not
|
|
@@ -719,14 +1770,14 @@ export function remoteMcp(id, opts) {
|
|
|
719
1770
|
let oauthGeneration;
|
|
720
1771
|
if (isOauth && (state.client || state.connecting)) {
|
|
721
1772
|
const provider = newProvider(ctx, state);
|
|
722
|
-
oauthGeneration = await provider.
|
|
723
|
-
if (provider.
|
|
1773
|
+
oauthGeneration = await provider.liveEpoch();
|
|
1774
|
+
if (provider.isOperatorDisconnectedEpoch(oauthGeneration)) {
|
|
724
1775
|
closeHalf(state);
|
|
725
1776
|
throw operatorDisconnectedError();
|
|
726
1777
|
}
|
|
727
1778
|
}
|
|
728
|
-
// Cross-isolate force re-auth: another isolate
|
|
729
|
-
//
|
|
1779
|
+
// Cross-isolate force re-auth: another isolate replaced the grant's epoch,
|
|
1780
|
+
// and the grant with it. This request's cached client still speaks the old
|
|
730
1781
|
// token — drop it so the next connect runs against current state.
|
|
731
1782
|
if (state.client && oauthGeneration !== undefined && state.connectedGeneration !== null) {
|
|
732
1783
|
if (isClosed(state))
|
|
@@ -743,7 +1794,7 @@ export function remoteMcp(id, opts) {
|
|
|
743
1794
|
let credentialValue = null;
|
|
744
1795
|
let credentialFramed = null;
|
|
745
1796
|
let credentialDigest = null;
|
|
746
|
-
if (credentialAuth) {
|
|
1797
|
+
if (credentialAuth || requestAuth) {
|
|
747
1798
|
credentialValue = await readCredential(ctx);
|
|
748
1799
|
credentialFramed = credentialHeaderValue(credentialScheme, credentialValue);
|
|
749
1800
|
credentialDigest = await digestOf(credentialValue);
|
|
@@ -754,8 +1805,16 @@ export function remoteMcp(id, opts) {
|
|
|
754
1805
|
if ((state.client || state.connecting) &&
|
|
755
1806
|
state.credentialDigest !== null &&
|
|
756
1807
|
state.credentialDigest !== credentialDigest) {
|
|
1808
|
+
// A slow vault/token read or digest can resume after a newer caller
|
|
1809
|
+
// connected with the replacement. Never let that stale read tear down
|
|
1810
|
+
// the newer connection and install the rotated-away credential.
|
|
1811
|
+
const observedConnection = state.client ?? state.connecting;
|
|
1812
|
+
const currentCredential = await readCredential(ctx);
|
|
757
1813
|
if (isClosed(state))
|
|
758
1814
|
throw scopeEndedError();
|
|
1815
|
+
if (currentCredential !== credentialValue || (state.client ?? state.connecting) !== observedConnection) {
|
|
1816
|
+
throw new ConnectorCallError("connector_call_failed", "The downstream credential changed while connecting; retry the operation.", { retryable: true });
|
|
1817
|
+
}
|
|
759
1818
|
closeHalf(state);
|
|
760
1819
|
}
|
|
761
1820
|
}
|
|
@@ -763,13 +1822,28 @@ export function remoteMcp(id, opts) {
|
|
|
763
1822
|
throw scopeEndedError();
|
|
764
1823
|
if (state.client)
|
|
765
1824
|
return state.client;
|
|
766
|
-
const attempt = state.connecting ??
|
|
767
|
-
startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest);
|
|
1825
|
+
const attempt = state.connecting ?? startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest);
|
|
768
1826
|
// A Promise edge of its own, as architecture.md's "Effect inside" requires
|
|
769
1827
|
// of every shared wait: whatever this caller does next with the client is
|
|
770
1828
|
// its own I/O, so it resumes in its own continuation rather than inside the
|
|
771
1829
|
// call that completed the attempt.
|
|
772
|
-
|
|
1830
|
+
connectingWaiters.set(attempt, (connectingWaiters.get(attempt) ?? 0) + 1);
|
|
1831
|
+
let client;
|
|
1832
|
+
try {
|
|
1833
|
+
client = await runEdge(Deferred.await(attempt), { signal: ctx.signal });
|
|
1834
|
+
}
|
|
1835
|
+
finally {
|
|
1836
|
+
const remaining = (connectingWaiters.get(attempt) ?? 1) - 1;
|
|
1837
|
+
if (remaining > 0)
|
|
1838
|
+
connectingWaiters.set(attempt, remaining);
|
|
1839
|
+
else {
|
|
1840
|
+
connectingWaiters.delete(attempt);
|
|
1841
|
+
// A cancelled waiter leaves its siblings' handshake alone. Once every
|
|
1842
|
+
// waiter has left, abandon the attempt and its OAuth refresh too.
|
|
1843
|
+
if (ctx.signal?.aborted && state.connecting === attempt)
|
|
1844
|
+
closeHalf(state);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
773
1847
|
// Teardown can land between the attempt completing and this caller
|
|
774
1848
|
// resuming. The client it would hand back is being closed, and this scope
|
|
775
1849
|
// is over.
|
|
@@ -806,58 +1880,150 @@ export function remoteMcp(id, opts) {
|
|
|
806
1880
|
// they unpublish it and close the lease it holds, if it holds one yet.
|
|
807
1881
|
const owned = () => state.connecting === attempt && !isClosed(state);
|
|
808
1882
|
return Effect.gen(function* () {
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
// the
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
}
|
|
819
|
-
provider?.captureGeneration(genAtStart);
|
|
820
|
-
// SDK v2 selects its validator by runtime export condition: AJV on
|
|
821
|
-
// Node and @cfworker/json-schema under workerd. The Workers-safe path
|
|
822
|
-
// no longer needs Connecta-specific wiring.
|
|
1883
|
+
// The transport and its OAuth provider are shared by every call in this
|
|
1884
|
+
// scope. A call's deadline signal is aborted even on success, so retaining
|
|
1885
|
+
// the first caller's signal here would abort its siblings and later calls.
|
|
1886
|
+
// Keep OAuth work cancellable by the connection's own lifetime instead;
|
|
1887
|
+
// requestOptions still passes each call's signal to the SDK separately.
|
|
1888
|
+
const connectionAbort = new AbortController();
|
|
1889
|
+
// Own cancellation before beginFlow's storage reads.
|
|
1890
|
+
// The lease first owns just the signal, then the transport it creates.
|
|
1891
|
+
const lease = Scope.forkUnsafe(state.scope);
|
|
823
1892
|
const handshakeAbort = new AbortController();
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
},
|
|
828
|
-
// Connecta has no interactive relay. Surface the result manually
|
|
829
|
-
// below as one structured, non-retryable connector failure.
|
|
830
|
-
inputRequired: { autoFulfill: false },
|
|
831
|
-
});
|
|
832
|
-
// @modelcontextprotocol/client 2.0.0 can attempt the legacy initialized
|
|
833
|
-
// notification after we close its transport. Its rejected Promise is
|
|
834
|
-
// unhandled under workerd even though connect() has a caller. This
|
|
835
|
-
// connection is already abandoned, so skip only that final handshake
|
|
836
|
-
// notification after our abort. Remove when the SDK owns this race:
|
|
837
|
-
// https://github.com/modelcontextprotocol/typescript-sdk/issues/2864
|
|
838
|
-
const notify = c.notification.bind(c);
|
|
839
|
-
c.notification = (message, options) => {
|
|
840
|
-
if (handshakeAbort.signal.aborted && message.method === "notifications/initialized") {
|
|
841
|
-
return Promise.resolve();
|
|
842
|
-
}
|
|
843
|
-
return notify(message, options);
|
|
1893
|
+
const held = {
|
|
1894
|
+
client: null,
|
|
1895
|
+
transport: null,
|
|
844
1896
|
};
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
return
|
|
849
|
-
}
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
// what aborts a connect still in flight.
|
|
854
|
-
const lease = Scope.forkUnsafe(state.scope);
|
|
855
|
-
const held = { client: null };
|
|
856
|
-
yield* Scope.addFinalizer(lease, Effect.sync(() => handshakeAbort.abort()).pipe(Effect.andThen(Effect.suspend(() => closeConnection(held.client, t, ctx.logger)))));
|
|
1897
|
+
yield* Scope.addFinalizer(lease, Effect.sync(() => handshakeAbort.abort()).pipe(Effect.andThen(Effect.suspend(() => {
|
|
1898
|
+
if (!held.transport)
|
|
1899
|
+
connectionAbort.abort();
|
|
1900
|
+
return closeConnection(held.client, held.transport, ctx.logger);
|
|
1901
|
+
})),
|
|
1902
|
+
// Let session termination use the OAuth fetch wrapper before ending
|
|
1903
|
+
// its lifetime. Local transport close aborts all active MCP fetches.
|
|
1904
|
+
Effect.ensuring(Effect.sync(() => connectionAbort.abort()))));
|
|
857
1905
|
state.lease = lease;
|
|
858
|
-
state.transport = t;
|
|
859
1906
|
return yield* Effect.gen(function* () {
|
|
860
|
-
|
|
1907
|
+
// A provider belongs to exactly one connect attempt. A force reset can
|
|
1908
|
+
// abandon that attempt while its transport still holds the provider;
|
|
1909
|
+
// the replacement must never mutate the abandoned provider's epoch.
|
|
1910
|
+
const provider = isOauth ? newProvider(ctx, undefined, connectionAbort.signal) : null;
|
|
1911
|
+
// Every OAuth run of this attempt — a 401's refresh or consent, a
|
|
1912
|
+
// step-up — happens inside the SDK, bound to the epoch live now: a
|
|
1913
|
+
// reset meanwhile fails the attempt rather than handing it the next
|
|
1914
|
+
// epoch's grant.
|
|
1915
|
+
const genAtStart = provider ? yield* promised(() => provider.beginFlow()) : "";
|
|
1916
|
+
if (!owned())
|
|
1917
|
+
return yield* Effect.fail(scopeEndedError());
|
|
1918
|
+
if (provider)
|
|
1919
|
+
state.attemptProvider = provider;
|
|
1920
|
+
if (provider?.isOperatorDisconnectedEpoch(genAtStart)) {
|
|
1921
|
+
return yield* Effect.fail(operatorDisconnectedError());
|
|
1922
|
+
}
|
|
1923
|
+
// SDK v2 selects its validator by runtime export condition: AJV on
|
|
1924
|
+
// Node and @cfworker/json-schema under workerd. The Workers-safe path
|
|
1925
|
+
// no longer needs Connecta-specific wiring.
|
|
1926
|
+
const oauthConfig = isOauth
|
|
1927
|
+
? {
|
|
1928
|
+
client: staticClient,
|
|
1929
|
+
clientMetadataUrl: staticClient
|
|
1930
|
+
? undefined
|
|
1931
|
+
: (clientMetadataUrl ?? selfHostedClientUrl(ctx.publicUrl, id)),
|
|
1932
|
+
redirectUri: downstreamRedirectUri(ctx.baseUrl, ctx.publicUrl, id),
|
|
1933
|
+
clientName: ctx.oauthClientName,
|
|
1934
|
+
}
|
|
1935
|
+
: undefined;
|
|
1936
|
+
const negotiationDigest = yield* promised(() => digestOf(JSON.stringify([
|
|
1937
|
+
opts.url,
|
|
1938
|
+
opts.versionNegotiation ?? "auto",
|
|
1939
|
+
opts.auth?.type,
|
|
1940
|
+
opts.auth?.type === "headers" ? opts.auth.headers : requestAuth?.headers,
|
|
1941
|
+
credentialHeader,
|
|
1942
|
+
credentialScheme,
|
|
1943
|
+
opts.authScope ?? "shared",
|
|
1944
|
+
opts.redirects ?? "none",
|
|
1945
|
+
oauthConfig,
|
|
1946
|
+
oauthScope,
|
|
1947
|
+
state.credentialDigest,
|
|
1948
|
+
genAtStart,
|
|
1949
|
+
callerOf(ctx),
|
|
1950
|
+
skillsEnabled === true,
|
|
1951
|
+
])));
|
|
1952
|
+
const prior = opts.versionNegotiation === "legacy"
|
|
1953
|
+
? undefined
|
|
1954
|
+
: yield* promised(() => readNegotiation(ctx, negotiationDigest));
|
|
1955
|
+
if (!owned())
|
|
1956
|
+
return yield* Effect.fail(scopeEndedError());
|
|
1957
|
+
const cacheOptions = yield* promised(() => catalogClientOptions(ctx, id, JSON.stringify({
|
|
1958
|
+
url: new URL(opts.url).href,
|
|
1959
|
+
auth: opts.auth?.type ?? null,
|
|
1960
|
+
headers: opts.auth?.type === "headers" ? opts.auth.headers : requestAuth?.headers,
|
|
1961
|
+
credentialHeader,
|
|
1962
|
+
credentialScheme,
|
|
1963
|
+
authScope: opts.authScope ?? "shared",
|
|
1964
|
+
redirects: opts.redirects ?? "none",
|
|
1965
|
+
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
1966
|
+
oauthConfig,
|
|
1967
|
+
oauthScope,
|
|
1968
|
+
}), JSON.stringify([genAtStart, state.credentialDigest]), connectionAbort.signal, cacheOwner, requestAuth || opts.authScope === "personal" ? "private" : "shared", provider ? async () => digestOf(JSON.stringify(await provider.tokens())) : undefined));
|
|
1969
|
+
if (!owned())
|
|
1970
|
+
return yield* Effect.fail(scopeEndedError());
|
|
1971
|
+
const makeClient = () => {
|
|
1972
|
+
const { intake: _intake, completeCatalogRefresh: _refresh, withListing: _listing, currentContext: _context, ...clientOptions } = cacheOptions;
|
|
1973
|
+
const client = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
|
|
1974
|
+
...clientOptions,
|
|
1975
|
+
listMaxPages: MAX_TOOL_PAGES,
|
|
1976
|
+
versionNegotiation: {
|
|
1977
|
+
mode: opts.versionNegotiation ?? "auto",
|
|
1978
|
+
},
|
|
1979
|
+
capabilities: {
|
|
1980
|
+
...downstreamInputCapabilities(ctx.requestScope ?? ctx),
|
|
1981
|
+
...(skillsEnabled ? { extensions: { [SKILLS_EXTENSION]: {} } } : {}),
|
|
1982
|
+
},
|
|
1983
|
+
// The host owns each sealed continuation; never auto-retry a write.
|
|
1984
|
+
inputRequired: { autoFulfill: false },
|
|
1985
|
+
});
|
|
1986
|
+
installCatalogIntake(client, ctx, cacheOptions);
|
|
1987
|
+
return client;
|
|
1988
|
+
};
|
|
1989
|
+
let c = makeClient();
|
|
1990
|
+
let t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue);
|
|
1991
|
+
if (!owned()) {
|
|
1992
|
+
held.transport = t;
|
|
1993
|
+
return yield* Effect.fail(scopeEndedError());
|
|
1994
|
+
}
|
|
1995
|
+
state.transport = t;
|
|
1996
|
+
held.transport = t;
|
|
1997
|
+
recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
|
|
1998
|
+
yield* promised(async () => {
|
|
1999
|
+
try {
|
|
2000
|
+
await c.connect(t, { signal: handshakeAbort.signal, ...(prior ? { prior } : {}) });
|
|
2001
|
+
}
|
|
2002
|
+
catch (error) {
|
|
2003
|
+
// Some legacy servers crash on an unknown pre-initialize method.
|
|
2004
|
+
// Only an auto probe's typed HTTP 5xx permits a fresh legacy
|
|
2005
|
+
// handshake. An auth failure, timeout or initialize failure does not.
|
|
2006
|
+
if (prior ||
|
|
2007
|
+
opts.versionNegotiation === "legacy" ||
|
|
2008
|
+
!(error instanceof SdkHttpError) ||
|
|
2009
|
+
error.code !== SdkErrorCode.EraNegotiationFailed ||
|
|
2010
|
+
error.status < 500 ||
|
|
2011
|
+
error.status >= 600 ||
|
|
2012
|
+
!owned() ||
|
|
2013
|
+
handshakeAbort.signal.aborted)
|
|
2014
|
+
throw error;
|
|
2015
|
+
// The SDK closed the failed probe transport. Its replacement owns
|
|
2016
|
+
// a new connection, with the same credential and OAuth generation.
|
|
2017
|
+
c = makeClient();
|
|
2018
|
+
t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue);
|
|
2019
|
+
state.transport = held.transport = t;
|
|
2020
|
+
recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
|
|
2021
|
+
await c.connect(t, { signal: handshakeAbort.signal, prior: { kind: "legacy" } });
|
|
2022
|
+
}
|
|
2023
|
+
}).pipe(Effect.mapError((err) => atMcpBoundary(ctx,
|
|
2024
|
+
// First, while the runtime's text can still be read for it.
|
|
2025
|
+
withoutCredential(err, credentialValue, credentialFramed), "MCP handshake", t, [ctx.signal, connectionAbort.signal, handshakeAbort.signal])));
|
|
2026
|
+
held.client = c;
|
|
861
2027
|
// A probe deadline can end its scope while connect is still in flight.
|
|
862
2028
|
// The transport is closed immediately by closeScope; if connect wins
|
|
863
2029
|
// that race anyway, close the resulting client rather than resurrecting
|
|
@@ -868,8 +2034,8 @@ export function remoteMcp(id, opts) {
|
|
|
868
2034
|
// credentials this client just bound to. Discard it rather than cache
|
|
869
2035
|
// a stale-isolate connection.
|
|
870
2036
|
if (provider) {
|
|
871
|
-
const generation = yield* promised(() => provider.
|
|
872
|
-
// closeScope can land while the
|
|
2037
|
+
const generation = yield* promised(() => provider.liveEpoch());
|
|
2038
|
+
// closeScope can land while the epoch read is pending, after
|
|
873
2039
|
// connect succeeded but before this client is cached. Discard the
|
|
874
2040
|
// client on that side of the await too.
|
|
875
2041
|
if (!owned())
|
|
@@ -878,21 +2044,24 @@ export function remoteMcp(id, opts) {
|
|
|
878
2044
|
return yield* Effect.fail(new UnauthorizedError("Connector was re-authorized during connect; reconnect required."));
|
|
879
2045
|
}
|
|
880
2046
|
}
|
|
881
|
-
held.client = c;
|
|
882
2047
|
state.client = c;
|
|
883
2048
|
state.connectedGeneration = genAtStart;
|
|
884
2049
|
state.authRequired = false;
|
|
2050
|
+
const discover = c.getDiscoverResult();
|
|
2051
|
+
if (!prior)
|
|
2052
|
+
yield* promised(() => storeNegotiation(ctx, negotiationDigest, discover ? { kind: "modern", discover } : { kind: "legacy" }));
|
|
2053
|
+
if (!owned())
|
|
2054
|
+
return yield* Effect.fail(scopeEndedError());
|
|
885
2055
|
return c;
|
|
886
2056
|
}).pipe(Effect.catch((err) => {
|
|
887
2057
|
// Close the failed connection through its client, without waiting.
|
|
888
|
-
held.client = c;
|
|
889
2058
|
if (owned()) {
|
|
890
2059
|
state.lease = null;
|
|
891
2060
|
state.transport = null;
|
|
892
2061
|
// Only a real 401/UnauthorizedError means auth is the problem —
|
|
893
2062
|
// a network error on an oauth connector must surface as "error",
|
|
894
2063
|
// not "auth_required".
|
|
895
|
-
if (err
|
|
2064
|
+
if (requiresAuthorization(err))
|
|
896
2065
|
state.authRequired = true;
|
|
897
2066
|
const release = Scope.closeUnsafe(lease, Exit.void);
|
|
898
2067
|
if (release)
|
|
@@ -902,10 +2071,10 @@ export function remoteMcp(id, opts) {
|
|
|
902
2071
|
// Whoever abandoned this attempt closed the lease already, while
|
|
903
2072
|
// the connect was in flight. A session it acquired after that
|
|
904
2073
|
// close still owes its DELETE.
|
|
905
|
-
detach(closeConnection(
|
|
2074
|
+
detach(closeConnection(held.client, held.transport, ctx.logger));
|
|
906
2075
|
}
|
|
907
2076
|
if (err instanceof UnauthorizedError) {
|
|
908
|
-
return Effect.fail(authRequiredError(
|
|
2077
|
+
return Effect.fail(carryFailureFacts(err, authRequiredError()));
|
|
909
2078
|
}
|
|
910
2079
|
// Defense in depth for the one error class that can quote the
|
|
911
2080
|
// credential: a runtime refusing the assembled header.
|
|
@@ -921,32 +2090,156 @@ export function remoteMcp(id, opts) {
|
|
|
921
2090
|
// Publish the replacement epoch before waiting on or closing any
|
|
922
2091
|
// request-local transport. A hung connect therefore cannot delay the
|
|
923
2092
|
// fence, and every late OAuth write stays in the older namespace.
|
|
924
|
-
const reset =
|
|
2093
|
+
const reset = operatorDisconnected
|
|
2094
|
+
? provider.disconnectAuthorization(learnedUrlSafeFetch(id, new URL(opts.url), async (input, init) => {
|
|
2095
|
+
trackSentRequest(ctx, input, init);
|
|
2096
|
+
return await fetch(input, init);
|
|
2097
|
+
}))
|
|
2098
|
+
: provider.resetAuthorization(false, preserveClient);
|
|
925
2099
|
try {
|
|
926
2100
|
await reset;
|
|
927
2101
|
}
|
|
928
2102
|
finally {
|
|
929
2103
|
// Abandon any connect in flight and close whichever half of the
|
|
930
|
-
// client/transport exists. Reset is unconditional because
|
|
2104
|
+
// client/transport exists. Reset is unconditional because storage may already
|
|
931
2105
|
// be fenced behind a newer epoch after a cleanup error.
|
|
932
2106
|
closeHalf(state);
|
|
933
2107
|
}
|
|
934
2108
|
};
|
|
2109
|
+
// Built once from construction-time values: names, framing, and public
|
|
2110
|
+
// URLs only. A static header's value and a stored credential never reach
|
|
2111
|
+
// it, and a URL keeps origin and path: a CIMD document's query may carry a
|
|
2112
|
+
// token even though the document itself is public.
|
|
2113
|
+
const describedClientMetadataUrl = describedUrl(clientMetadataUrl);
|
|
2114
|
+
const authDescription = requestAuth
|
|
2115
|
+
? {
|
|
2116
|
+
mode: "request",
|
|
2117
|
+
header: "Authorization",
|
|
2118
|
+
scheme: "Bearer",
|
|
2119
|
+
...(requestAuth.headers ? { headerNames: Object.keys(requestAuth.headers) } : {}),
|
|
2120
|
+
}
|
|
2121
|
+
: opts.auth?.type === "headers"
|
|
2122
|
+
? { mode: "headers", headerNames: Object.keys(opts.auth.headers) }
|
|
2123
|
+
: credentialAuth
|
|
2124
|
+
? { mode: "credential", header: credentialHeader, scheme: credentialScheme }
|
|
2125
|
+
: isOauth
|
|
2126
|
+
? {
|
|
2127
|
+
mode: "oauth",
|
|
2128
|
+
...(oauthScope !== undefined ? { scope: oauthScope } : {}),
|
|
2129
|
+
...(describedClientMetadataUrl !== undefined ? { clientMetadataUrl: describedClientMetadataUrl } : {}),
|
|
2130
|
+
}
|
|
2131
|
+
: { mode: "none" };
|
|
2132
|
+
const endpoint = describedEndpoint(opts.url);
|
|
2133
|
+
const optionSources = Object.freeze({
|
|
2134
|
+
"source.kind": "default",
|
|
2135
|
+
"auth.mode": opts.auth === undefined ? "default" : "config",
|
|
2136
|
+
"auth.header": credentialAuth?.header === undefined ? "default" : "config",
|
|
2137
|
+
"auth.scheme": credentialAuth?.scheme === undefined ? "default" : "config",
|
|
2138
|
+
"credential.label": credentialAuth?.credential === undefined ? "default" : "config",
|
|
2139
|
+
...Object.fromEntries(["versionNegotiation", "redirects", "requireHttps"].map((key) => [
|
|
2140
|
+
`transport.${key}`,
|
|
2141
|
+
opts[key] === undefined ? "default" : "config",
|
|
2142
|
+
])),
|
|
2143
|
+
});
|
|
2144
|
+
const resourceTemplateRefusals = new Set();
|
|
2145
|
+
const skillScopeOpen = (ctx, state) => {
|
|
2146
|
+
if (isClosed(state))
|
|
2147
|
+
throw scopeEndedError();
|
|
2148
|
+
if (ctx.signal?.aborted)
|
|
2149
|
+
throw ctx.signal.reason instanceof Error ? ctx.signal.reason : scopeEndedError();
|
|
2150
|
+
};
|
|
2151
|
+
const withSkillsClient = async (ctx, method, run) => {
|
|
2152
|
+
const state = stateFor(ctx);
|
|
2153
|
+
const client = await ensureConnected(ctx, state);
|
|
2154
|
+
try {
|
|
2155
|
+
skillScopeOpen(ctx, state);
|
|
2156
|
+
const capabilities = client.getServerCapabilities();
|
|
2157
|
+
const extension = capabilities?.extensions?.[SKILLS_EXTENSION];
|
|
2158
|
+
if (client.getProtocolEra() !== "modern" ||
|
|
2159
|
+
!skillObject(capabilities?.resources) ||
|
|
2160
|
+
!skillObject(extension) ||
|
|
2161
|
+
(extension.directoryRead !== undefined && typeof extension.directoryRead !== "boolean")) {
|
|
2162
|
+
throw new ConnectorCallError("connector_call_failed", "The downstream did not declare a valid Skills extension and resources capability.");
|
|
2163
|
+
}
|
|
2164
|
+
const result = await run(client, state);
|
|
2165
|
+
skillScopeOpen(ctx, state);
|
|
2166
|
+
return result;
|
|
2167
|
+
}
|
|
2168
|
+
catch (error) {
|
|
2169
|
+
if (ownAbortReason(error, [ctx.signal]) && !hasSdkPayload(error))
|
|
2170
|
+
throw error;
|
|
2171
|
+
const classified = atMcpBoundary(ctx, error, method, client.transport, [ctx.signal]);
|
|
2172
|
+
if (requiresAuthorization(classified) && state.client === client)
|
|
2173
|
+
state.authRequired = true;
|
|
2174
|
+
// The SDK and downstream can quote whole manifests or bodies in errors.
|
|
2175
|
+
// Keep their typed verdict and operator facts, never the quoted payload.
|
|
2176
|
+
throw carryFailureFacts(classified, withheldAs(`Connector "${id}" downstream ${method} failed.`, classified));
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
935
2179
|
const connector = {
|
|
936
2180
|
id,
|
|
937
2181
|
...(opts.title !== undefined ? { title: opts.title } : {}),
|
|
938
2182
|
kind: "mcp",
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
: {}
|
|
2183
|
+
describe: () => ({
|
|
2184
|
+
optionSources,
|
|
2185
|
+
source: { kind: "remote-mcp" },
|
|
2186
|
+
...(endpoint ? { endpoint } : {}),
|
|
2187
|
+
auth: authDescription,
|
|
2188
|
+
transport: {
|
|
2189
|
+
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
2190
|
+
redirects: opts.redirects ?? "none",
|
|
2191
|
+
requireHttps: opts.requireHttps ?? false,
|
|
2192
|
+
},
|
|
2193
|
+
}),
|
|
2194
|
+
...(opts.description !== undefined ? { description: opts.description } : {}),
|
|
942
2195
|
...(opts.authScope !== undefined ? { authScope: opts.authScope } : {}),
|
|
943
|
-
...(opts.maxResultBytes !== undefined
|
|
944
|
-
|
|
945
|
-
: {}),
|
|
946
|
-
...(opts.callAdmission !== undefined
|
|
947
|
-
? { callAdmission: opts.callAdmission }
|
|
948
|
-
: {}),
|
|
2196
|
+
...(opts.maxResultBytes !== undefined ? { maxResultBytes: opts.maxResultBytes } : {}),
|
|
2197
|
+
...(opts.callAdmission !== undefined ? { callAdmission: opts.callAdmission } : {}),
|
|
949
2198
|
...(opts.usageGuide !== undefined ? { usageGuide: opts.usageGuide } : {}),
|
|
2199
|
+
...(skillsEnabled
|
|
2200
|
+
? {
|
|
2201
|
+
downstreamSkills: {
|
|
2202
|
+
list: (ctx) => withSkillsClient(ctx, "skills/list", async (client, state) => {
|
|
2203
|
+
const listed = [];
|
|
2204
|
+
const uris = new Set();
|
|
2205
|
+
const cursors = new Set();
|
|
2206
|
+
let cursor;
|
|
2207
|
+
let bytes = 0;
|
|
2208
|
+
for (let page = 0; page < MAX_SKILLS; page++) {
|
|
2209
|
+
skillScopeOpen(ctx, state);
|
|
2210
|
+
const result = await client.request({ method: "skills/list", ...(cursor === undefined ? {} : { params: { cursor } }) }, SkillPageSchema, requestOptions(ctx));
|
|
2211
|
+
bytes += result.bytes;
|
|
2212
|
+
if (bytes > MAX_SERIALIZED_CATALOG_BYTES || listed.length + result.skills.length > MAX_SKILLS) {
|
|
2213
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream Skills listing exceeds the aggregate limit.");
|
|
2214
|
+
}
|
|
2215
|
+
for (const skill of result.skills) {
|
|
2216
|
+
if (uris.has(skill.uri))
|
|
2217
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream Skills listing contains duplicate skill URIs.");
|
|
2218
|
+
uris.add(skill.uri);
|
|
2219
|
+
listed.push(skill);
|
|
2220
|
+
}
|
|
2221
|
+
if (result.nextCursor === undefined)
|
|
2222
|
+
return listed;
|
|
2223
|
+
if (!result.skills.length || cursors.has(result.nextCursor)) {
|
|
2224
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream Skills pagination loops or makes no progress.");
|
|
2225
|
+
}
|
|
2226
|
+
cursors.add(result.nextCursor);
|
|
2227
|
+
cursor = result.nextCursor;
|
|
2228
|
+
}
|
|
2229
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream Skills listing exceeds the page limit.");
|
|
2230
|
+
}),
|
|
2231
|
+
read: (uri, ctx) => withSkillsClient(ctx, "resources/read", async (client) => {
|
|
2232
|
+
// The registry admits only advertised manifest URIs. An explicit
|
|
2233
|
+
// request bypasses the SDK readResource response cache entirely and
|
|
2234
|
+
// validates the raw result before its resource union drops fields.
|
|
2235
|
+
return client.request({ method: "resources/read", params: { uri } }, skillReadSchema(uri), requestOptions(ctx));
|
|
2236
|
+
}),
|
|
2237
|
+
},
|
|
2238
|
+
}
|
|
2239
|
+
: {}),
|
|
2240
|
+
// Data the registry classifies every read with; listTools below returns
|
|
2241
|
+
// the downstream's listing unclassified.
|
|
2242
|
+
...(classification !== undefined ? { classification } : {}),
|
|
950
2243
|
// Declaring the slot is what makes the rest of the operator surface work:
|
|
951
2244
|
// the connection's credential form renders it, the shape check compares
|
|
952
2245
|
// against it, and authorize_connector returns the operator handoff rather than
|
|
@@ -996,117 +2289,48 @@ export function remoteMcp(id, opts) {
|
|
|
996
2289
|
},
|
|
997
2290
|
}
|
|
998
2291
|
: {}),
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
// Client.listTools() returns without following. Collect the whole chain
|
|
1002
|
-
// here, because a half-collected catalog is indistinguishable from a small
|
|
1003
|
-
// one — later-page tools would simply appear not to exist, unsearchable and
|
|
1004
|
-
// unaddressable, with nothing anywhere saying why.
|
|
1005
|
-
//
|
|
1006
|
-
// All pages ride the one request-scoped client already connected above, and
|
|
1007
|
-
// the accumulator is returned rather than stored: a cursor is opaque and
|
|
1008
|
-
// session-bound, so nothing here may outlive this call.
|
|
2292
|
+
// The SDK walks the whole chain and writes one complete result. Intake
|
|
2293
|
+
// runs on its public request seam before any page can enter that cache.
|
|
1009
2294
|
async listTools(ctx) {
|
|
1010
|
-
// The catalog contract: a downstream catalog is complete or it is a
|
|
1011
|
-
// failure. Follow every page to the end of the cursor chain, preserve
|
|
1012
|
-
// schemas and annotations, and never cache or serve a partial walk.
|
|
1013
2295
|
const state = stateFor(ctx);
|
|
1014
|
-
// The client this call connected or joined, bound once so the whole walk
|
|
1015
|
-
// provably rides one session — a cursor is only meaningful to the
|
|
1016
|
-
// connection that issued it. Never re-read from `state`: a force reset
|
|
1017
|
-
// or rotation can null it before this call resumes, and teardown is
|
|
1018
|
-
// caught instead by the `closed` check before every page.
|
|
1019
2296
|
const client = await ensureConnected(ctx, state);
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const names = new Set();
|
|
1024
|
-
const spent = new Set();
|
|
1025
|
-
let cursor;
|
|
1026
|
-
/** Consecutive pages that advertised a successor but added nothing. */
|
|
1027
|
-
let barren = 0;
|
|
1028
|
-
let complete = false;
|
|
2297
|
+
let clean;
|
|
2298
|
+
const options = requestOptions(ctx) ?? {};
|
|
2299
|
+
listingContexts.set(options, ctx);
|
|
1029
2300
|
try {
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
// rather than keep paging into a transport that is being closed.
|
|
1033
|
-
if (isClosed(state))
|
|
1034
|
-
throw scopeEndedError();
|
|
1035
|
-
// A discovery deadline uses the same signal for the whole chain.
|
|
1036
|
-
// Check it before issuing each page as well as passing it to the
|
|
1037
|
-
// in-flight SDK request, so expiry never starts one more round trip.
|
|
1038
|
-
if (ctx.signal?.aborted) {
|
|
1039
|
-
throw ctx.signal.reason instanceof Error
|
|
1040
|
-
? ctx.signal.reason
|
|
1041
|
-
: new Error(`Connector "${id}" catalog deadline expired.`);
|
|
1042
|
-
}
|
|
1043
|
-
// Page one sends no params at all, so a non-paginated server sees
|
|
1044
|
-
// exactly the request it saw before pagination existed.
|
|
1045
|
-
const res = await listPage(client, cursor, ctx);
|
|
1046
|
-
let added = 0;
|
|
1047
|
-
for (const t of res.tools) {
|
|
1048
|
-
// First page wins. An unstable cursor can serve the same tool on
|
|
1049
|
-
// two pages — a duplicate would inflate `toolCount`, double the
|
|
1050
|
-
// `search_tools` row, and churn catalog persistence.
|
|
1051
|
-
if (names.has(t.name))
|
|
1052
|
-
continue;
|
|
1053
|
-
names.add(t.name);
|
|
1054
|
-
listed.push(t);
|
|
1055
|
-
added++;
|
|
1056
|
-
}
|
|
1057
|
-
// Pagination ends when `nextCursor` is absent or null — never merely
|
|
1058
|
-
// falsy. Empty string is present and means "keep going".
|
|
1059
|
-
const next = res.nextCursor;
|
|
1060
|
-
if (next === undefined || next === null) {
|
|
1061
|
-
complete = true;
|
|
1062
|
-
break;
|
|
1063
|
-
}
|
|
1064
|
-
// A page that adds nothing and still claims a successor made no
|
|
1065
|
-
// progress. Allow exactly one: the widespread idiom is to advertise
|
|
1066
|
-
// a cursor whenever a page came back full and then serve one empty
|
|
1067
|
-
// page to terminate. Two in a row is a downstream going nowhere.
|
|
1068
|
-
if (added === 0 && ++barren > 1) {
|
|
1069
|
-
throw new Error(`Connector "${id}" returned two consecutive tools/list pages that added no tools and still advertised another — the catalog is not advancing.`);
|
|
1070
|
-
}
|
|
1071
|
-
if (added > 0)
|
|
1072
|
-
barren = 0;
|
|
1073
|
-
// A cursor handed back a second time is a loop, not a slow server.
|
|
1074
|
-
if (spent.has(next)) {
|
|
1075
|
-
throw new Error(`Connector "${id}" handed back a tools/list cursor it had already issued — the pagination chain loops.`);
|
|
1076
|
-
}
|
|
1077
|
-
// Checked here rather than on arrival: this bounds what a *walk* may
|
|
1078
|
-
// accumulate; a one-page server was always free to send its page.
|
|
1079
|
-
if (listed.length > MAX_CATALOG_TOOLS) {
|
|
1080
|
-
throw new Error(`Connector "${id}" advertised further tools/list pages past ${listed.length} tools, over the ${MAX_CATALOG_TOOLS}-tool ceiling one catalog refresh will collect.`);
|
|
1081
|
-
}
|
|
1082
|
-
// Opaque by contract: handed straight back, never parsed, rewritten,
|
|
1083
|
-
// or persisted.
|
|
1084
|
-
spent.add(next);
|
|
1085
|
-
cursor = next;
|
|
1086
|
-
}
|
|
2301
|
+
const result = await client.listTools(undefined, options);
|
|
2302
|
+
clean = redactCatalog(ctx, result.tools);
|
|
1087
2303
|
}
|
|
1088
2304
|
catch (err) {
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
if (
|
|
2305
|
+
if (ownAbortReason(err, [ctx.signal]) && !hasSdkPayload(err))
|
|
2306
|
+
throw err;
|
|
2307
|
+
const bounded = atMcpBoundary(ctx, err, "tools/list", client.transport, [ctx.signal]);
|
|
2308
|
+
if (requiresAuthorization(bounded)) {
|
|
1093
2309
|
if (state.client === client)
|
|
1094
2310
|
state.authRequired = true;
|
|
1095
|
-
|
|
2311
|
+
if (bounded instanceof UnauthorizedError)
|
|
2312
|
+
throw carryFailureFacts(bounded, authRequiredError());
|
|
1096
2313
|
}
|
|
1097
|
-
throw
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
}
|
|
1105
|
-
// Publish definitions only after the full walk succeeds. A later-page
|
|
1106
|
-
// failure must not leave a partial validation/header view behind.
|
|
1107
|
-
state.toolDefinitions = new Map(listed.map((tool) => [tool.name, tool]));
|
|
1108
|
-
return listed.map((t) => ({
|
|
2314
|
+
throw bounded;
|
|
2315
|
+
}
|
|
2316
|
+
finally {
|
|
2317
|
+
listingContexts.delete(options);
|
|
2318
|
+
}
|
|
2319
|
+
state.toolDefinitions = new Map(clean.map((tool) => [tool.name, tool]));
|
|
2320
|
+
return clean.map((t) => ({
|
|
1109
2321
|
name: t.name,
|
|
2322
|
+
...(t.title !== undefined ? { title: t.title } : {}),
|
|
2323
|
+
...(t.icons !== undefined
|
|
2324
|
+
? {
|
|
2325
|
+
// Inline images can consume the catalog ceiling and storage chunks.
|
|
2326
|
+
// The SDK needs no icons for validation or parameter mirroring.
|
|
2327
|
+
icons: t.icons.filter((icon) => !/^data:/i.test(icon.src)),
|
|
2328
|
+
}
|
|
2329
|
+
: {}),
|
|
2330
|
+
...(t.execution !== undefined ? { execution: t.execution } : {}),
|
|
2331
|
+
// SEP-2243 declarations live in inputSchema. SDK validation and header
|
|
2332
|
+
// mirroring need no _meta keys, so arbitrary downstream metadata stays
|
|
2333
|
+
// out of the persisted catalog.
|
|
1110
2334
|
...(t.description !== undefined ? { description: t.description } : {}),
|
|
1111
2335
|
...(t.inputSchema !== undefined
|
|
1112
2336
|
? {
|
|
@@ -1125,37 +2349,132 @@ export function remoteMcp(id, opts) {
|
|
|
1125
2349
|
: {}),
|
|
1126
2350
|
}));
|
|
1127
2351
|
},
|
|
1128
|
-
async callTool(name, args, ctx) {
|
|
2352
|
+
async callTool(name, args, ctx, options) {
|
|
2353
|
+
if (classification?.unlisted === "hide" && !Object.hasOwn(classification.tools, name)) {
|
|
2354
|
+
throw new ConnectorCallError("invalid_args", "This tool is not in the connector allowlist.");
|
|
2355
|
+
}
|
|
1129
2356
|
const state = stateFor(ctx);
|
|
1130
2357
|
const client = await ensureConnected(ctx, state);
|
|
1131
2358
|
try {
|
|
1132
|
-
const
|
|
2359
|
+
const definition = options?.definition;
|
|
2360
|
+
const toolDefinition = definition
|
|
2361
|
+
? {
|
|
2362
|
+
...definition,
|
|
2363
|
+
// Older stored catalogs and custom connectors may omit inputSchema.
|
|
2364
|
+
inputSchema: (definition.inputSchema ?? { type: "object" }),
|
|
2365
|
+
}
|
|
2366
|
+
: state.toolDefinitions.get(name);
|
|
1133
2367
|
if (toolDefinition?.execution?.taskSupport === "required") {
|
|
1134
|
-
throw new
|
|
2368
|
+
throw new ConnectorCallError("connector_call_failed", `Tool "${name}" requires task-based execution, which Connecta does not support.`);
|
|
1135
2369
|
}
|
|
1136
|
-
|
|
2370
|
+
let output;
|
|
2371
|
+
const outputError = (code, message) => atMcpBoundary(ctx, new ProtocolError(code, message), "tools/call", client.transport, [ctx.signal]);
|
|
2372
|
+
if (toolDefinition?.outputSchema) {
|
|
2373
|
+
try {
|
|
2374
|
+
output = fromJsonSchema(toolDefinition.outputSchema);
|
|
2375
|
+
}
|
|
2376
|
+
catch {
|
|
2377
|
+
throw outputError(-32602, `Tool '${name}' has an invalid outputSchema: schema could not be compiled`);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
const result = await client
|
|
2381
|
+
.callTool({
|
|
1137
2382
|
name,
|
|
1138
2383
|
arguments: (args ?? {}),
|
|
2384
|
+
...options?.input,
|
|
1139
2385
|
}, {
|
|
1140
2386
|
...requestOptions(ctx),
|
|
1141
2387
|
allowInputRequired: true,
|
|
1142
|
-
|
|
2388
|
+
// SDK 2.3.1's callTool checks outputSchema even for a suspension.
|
|
2389
|
+
// Retain its header mirroring, then validate only final results.
|
|
2390
|
+
...(toolDefinition ? { toolDefinition: { ...toolDefinition, outputSchema: undefined } } : {}),
|
|
2391
|
+
})
|
|
2392
|
+
.catch((err) => {
|
|
2393
|
+
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, err);
|
|
2394
|
+
throw atMcpBoundary(ctx, err, "tools/call", client.transport, [ctx.signal]);
|
|
1143
2395
|
});
|
|
1144
2396
|
if (isInputRequiredResult(result)) {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
2397
|
+
return result;
|
|
2398
|
+
}
|
|
2399
|
+
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, result);
|
|
2400
|
+
if (output && !result.isError) {
|
|
2401
|
+
if (result.structuredContent === undefined)
|
|
2402
|
+
throw outputError(-32600, `Tool ${name} has an output schema but did not return structured content`);
|
|
2403
|
+
const validation = await output["~standard"].validate(result.structuredContent);
|
|
2404
|
+
if (validation.issues)
|
|
2405
|
+
throw outputError(-32602, "Structured content does not match the tool's output schema: validation failed");
|
|
1148
2406
|
}
|
|
1149
2407
|
return result;
|
|
1150
2408
|
}
|
|
1151
2409
|
catch (err) {
|
|
1152
2410
|
// A grant revoked after connect surfaces here, not in ensureConnected.
|
|
1153
|
-
if (err
|
|
2411
|
+
if (ownAbortReason(err, [ctx.signal]) && !hasSdkPayload(err))
|
|
2412
|
+
throw err;
|
|
2413
|
+
if (requiresAuthorization(err)) {
|
|
2414
|
+
if (state.client === client)
|
|
2415
|
+
state.authRequired = true;
|
|
2416
|
+
if (err instanceof UnauthorizedError)
|
|
2417
|
+
throw carryFailureFacts(err, authRequiredError());
|
|
2418
|
+
}
|
|
2419
|
+
throw downstreamCallError(err, undefined, undefined, undefined, sentSecretsFor(ctx));
|
|
2420
|
+
}
|
|
2421
|
+
},
|
|
2422
|
+
async readResource(uri, ctx) {
|
|
2423
|
+
const state = stateFor(ctx);
|
|
2424
|
+
const client = await ensureConnected(ctx, state);
|
|
2425
|
+
try {
|
|
2426
|
+
const options = requestOptions(ctx) ?? {};
|
|
2427
|
+
listingContexts.set(options, ctx);
|
|
2428
|
+
try {
|
|
2429
|
+
if (!client.getServerCapabilities()?.resources)
|
|
2430
|
+
throw unadvertisedResource();
|
|
2431
|
+
const resources = await client.listResources(undefined, options);
|
|
2432
|
+
const templates = await client.listResourceTemplates(undefined, options);
|
|
2433
|
+
const exact = resources.resources.some((resource) => resource.uri === uri);
|
|
2434
|
+
if (!exact) {
|
|
2435
|
+
const match = resourceUriMatchesTemplates(uri, templates.resourceTemplates, (code) => resourceTemplateRefusals.add(code));
|
|
2436
|
+
if (!match.matched) {
|
|
2437
|
+
if (match.refusal)
|
|
2438
|
+
throw new ConnectorCallError(match.refusal, match.refusal === "resource_template_ambiguous"
|
|
2439
|
+
? "The advertised resource templates have ambiguous expression boundaries."
|
|
2440
|
+
: "Resource template matching exceeds the read work limit.", { retryable: false });
|
|
2441
|
+
throw unadvertisedResource();
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
catch (err) {
|
|
2446
|
+
throw atMcpBoundary(ctx, err, "resources/read", client.transport, [ctx.signal]);
|
|
2447
|
+
}
|
|
2448
|
+
finally {
|
|
2449
|
+
listingContexts.delete(options);
|
|
2450
|
+
}
|
|
2451
|
+
const result = await client
|
|
2452
|
+
.readResource({ uri }, {
|
|
2453
|
+
...requestOptions(ctx),
|
|
2454
|
+
cacheMode: "bypass",
|
|
2455
|
+
allowInputRequired: true,
|
|
2456
|
+
})
|
|
2457
|
+
.catch((err) => {
|
|
2458
|
+
if (err instanceof ResourceNotFoundError) {
|
|
2459
|
+
throw new ConnectorCallError("not_found", "The downstream resource does not exist.");
|
|
2460
|
+
}
|
|
2461
|
+
throw atMcpBoundary(ctx, err, "resources/read", client.transport, [ctx.signal]);
|
|
2462
|
+
});
|
|
2463
|
+
if (isInputRequiredResult(result)) {
|
|
2464
|
+
throw new ConnectorCallError("input_required_unsupported", "Resource reads inside programs cannot request mid-call input.");
|
|
2465
|
+
}
|
|
2466
|
+
return result;
|
|
2467
|
+
}
|
|
2468
|
+
catch (err) {
|
|
2469
|
+
if (ownAbortReason(err, [ctx.signal]) && !hasSdkPayload(err))
|
|
2470
|
+
throw err;
|
|
2471
|
+
if (requiresAuthorization(err)) {
|
|
1154
2472
|
if (state.client === client)
|
|
1155
2473
|
state.authRequired = true;
|
|
1156
|
-
|
|
2474
|
+
if (err instanceof UnauthorizedError)
|
|
2475
|
+
throw carryFailureFacts(err, authRequiredError());
|
|
1157
2476
|
}
|
|
1158
|
-
throw downstreamCallError(err);
|
|
2477
|
+
throw downstreamCallError(err, undefined, undefined, undefined, sentSecretsFor(ctx));
|
|
1159
2478
|
}
|
|
1160
2479
|
},
|
|
1161
2480
|
async closeScope(ctx) {
|
|
@@ -1166,68 +2485,95 @@ export function remoteMcp(id, opts) {
|
|
|
1166
2485
|
// from here on sees the scope ended. A duplicate teardown finds it
|
|
1167
2486
|
// closed and has nothing to run.
|
|
1168
2487
|
const release = Scope.closeUnsafe(state.scope, Exit.void);
|
|
2488
|
+
const cacheClosing = closeCatalogCacheScope(ctx, id, cacheOwner);
|
|
2489
|
+
// Storage cannot cancel an already-started write. Bound the join in
|
|
2490
|
+
// parallel with transport cleanup so it adds no unbounded teardown tail.
|
|
2491
|
+
const cacheClosed = cacheClosing &&
|
|
2492
|
+
runEdge(Effect.raceAllFirst([
|
|
2493
|
+
Effect.promise(() => cacheClosing),
|
|
2494
|
+
Effect.sleep(Duration.millis(LOCAL_CLOSE_BUDGET_MS)),
|
|
2495
|
+
]));
|
|
1169
2496
|
reset(state);
|
|
1170
2497
|
// Runs the live connection's lease finalizer, if there is one.
|
|
1171
2498
|
if (release)
|
|
1172
2499
|
await runEdge(release);
|
|
2500
|
+
await cacheClosed;
|
|
1173
2501
|
},
|
|
1174
2502
|
async status(ctx) {
|
|
1175
2503
|
const state = stateFor(ctx);
|
|
2504
|
+
const report = async (status) => {
|
|
2505
|
+
const path = isOauth ? await newProvider(ctx, state).registrationPath() : undefined;
|
|
2506
|
+
return ownStatus({
|
|
2507
|
+
...status,
|
|
2508
|
+
...(path ? { registrationPath: path } : {}),
|
|
2509
|
+
...(resourceTemplateRefusals.size ? { resourceTemplateRefusals: [...resourceTemplateRefusals] } : {}),
|
|
2510
|
+
});
|
|
2511
|
+
};
|
|
1176
2512
|
try {
|
|
1177
2513
|
await ensureConnected(ctx, state);
|
|
1178
|
-
return { state: "ok" };
|
|
2514
|
+
return report({ state: "ok" });
|
|
1179
2515
|
}
|
|
1180
2516
|
catch (err) {
|
|
1181
2517
|
// An empty slot, or one with no vault behind it, is reported the way a
|
|
1182
2518
|
// missing grant is: present, unauthenticated, and repairable — never a
|
|
1183
2519
|
// boot failure and never a silently absent connector.
|
|
1184
2520
|
if (err instanceof CredentialRequiredError) {
|
|
1185
|
-
return { state: "auth_required", message: err.message };
|
|
2521
|
+
return report({ state: "auth_required", message: err.message });
|
|
2522
|
+
}
|
|
2523
|
+
if (err instanceof OperatorDisconnectedError) {
|
|
2524
|
+
return report({ state: "auth_required", message: err.message });
|
|
1186
2525
|
}
|
|
1187
2526
|
if (state.authRequired) {
|
|
1188
2527
|
// Only an OAuth connector has a pending consent URL to offer. A
|
|
1189
2528
|
// credential connector's downstream 401 is repaired in the operator
|
|
1190
2529
|
// UI connection, so do not reach into OAuth storage to look for one.
|
|
1191
|
-
return {
|
|
2530
|
+
return report({
|
|
1192
2531
|
state: "auth_required",
|
|
1193
2532
|
message: credentialAuth
|
|
1194
2533
|
? "Authorization required — the downstream rejected this connector's stored credential."
|
|
1195
2534
|
: "Authorization required — open the URL to connect.",
|
|
1196
|
-
};
|
|
2535
|
+
});
|
|
1197
2536
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1201
|
-
return { state: "error", message: msg(err) };
|
|
2537
|
+
// An operator surface: the record, never the error's own text.
|
|
2538
|
+
return failureStatus(id, err);
|
|
1202
2539
|
}
|
|
1203
2540
|
},
|
|
1204
2541
|
async finishAuth(code, ctx, callbackParams) {
|
|
1205
2542
|
const state = stateFor(ctx);
|
|
1206
2543
|
const provider = newProvider(ctx, state);
|
|
1207
|
-
// verifyState ran on this request-scoped provider first and
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
2544
|
+
// verifyState ran on this request-scoped provider first and found the
|
|
2545
|
+
// consent; a programmatic exchange names it by its callback's state.
|
|
2546
|
+
const consent = callbackParams?.get("state") ?? null;
|
|
2547
|
+
if (!consent || !(await provider.verifyCallbackState(consent))) {
|
|
2548
|
+
throw new ConnectorCallError("connector_call_failed", `Connector "${id}" authorization callback matches no pending consent; nothing was exchanged.`);
|
|
2549
|
+
}
|
|
2550
|
+
// The exchange reads and writes only the epoch its consent was written
|
|
2551
|
+
// in; it decides nothing about the grant there.
|
|
2552
|
+
provider.validateCallbackIssuer(callbackParams?.get("iss") ?? null);
|
|
2553
|
+
await provider.bindFlow();
|
|
2554
|
+
// Always a transport of the exchange's own, over the provider that
|
|
2555
|
+
// verified the state: that provider holds the consent's claim, and the
|
|
2556
|
+
// fence before the token request is its to cross. A connection this
|
|
2557
|
+
// scope opened earlier speaks for its connect attempt, not this callback.
|
|
2558
|
+
const t = buildTransport(ctx, provider);
|
|
2559
|
+
const trail = {};
|
|
2560
|
+
const internals = t;
|
|
2561
|
+
const traced = tracedOAuthFetch(trail, new URL(opts.url), internals._fetchWithInit ?? fetch);
|
|
2562
|
+
const exchange = new Proxy(t, {
|
|
2563
|
+
get: (target, key) => (key === "_fetchWithInit" ? traced : Reflect.get(target, key, target)),
|
|
2564
|
+
set: (target, key, value) => Reflect.set(target, key, value, target),
|
|
2565
|
+
});
|
|
1215
2566
|
try {
|
|
1216
|
-
|
|
1217
|
-
await t.finishAuth(callbackParams);
|
|
1218
|
-
}
|
|
1219
|
-
else {
|
|
1220
|
-
await t.finishAuth(code);
|
|
1221
|
-
}
|
|
1222
|
-
await provider.clearPending();
|
|
2567
|
+
await exchange.finishAuth(callbackParams);
|
|
1223
2568
|
// Reset so the next use reconnects with the freshly stored tokens.
|
|
1224
2569
|
closeHalf(state);
|
|
1225
2570
|
}
|
|
2571
|
+
catch (err) {
|
|
2572
|
+
throw await withoutAuthorizationServerText(ctx, err, trail, [ctx.signal]);
|
|
2573
|
+
}
|
|
1226
2574
|
finally {
|
|
1227
2575
|
// This exchange-only transport has no lease in the request scope.
|
|
1228
|
-
|
|
1229
|
-
if (!leased)
|
|
1230
|
-
detach(closeConnection(null, t, ctx.logger));
|
|
2576
|
+
detach(closeConnection(null, t, ctx.logger));
|
|
1231
2577
|
}
|
|
1232
2578
|
},
|
|
1233
2579
|
};
|
|
@@ -1236,15 +2582,18 @@ export function remoteMcp(id, opts) {
|
|
|
1236
2582
|
const state = stateFor(ctx);
|
|
1237
2583
|
return newProvider(ctx, state).verifyState(oauthState);
|
|
1238
2584
|
};
|
|
2585
|
+
connector.verifyCallbackIssuer = async (issuer, ctx) => {
|
|
2586
|
+
newProvider(ctx, stateFor(ctx)).validateCallbackIssuer(issuer);
|
|
2587
|
+
return true;
|
|
2588
|
+
};
|
|
2589
|
+
connector.consumeAuthError = async (ctx) => {
|
|
2590
|
+
await newProvider(ctx, stateFor(ctx)).consumeAuthError();
|
|
2591
|
+
};
|
|
1239
2592
|
connector.disconnectAuth = async (ctx) => {
|
|
1240
2593
|
await disconnectAuthorization(ctx, stateFor(ctx), true);
|
|
1241
2594
|
};
|
|
1242
2595
|
connector.startAuth = async (ctx, startOpts) => {
|
|
1243
|
-
ctx =
|
|
1244
|
-
...ctx,
|
|
1245
|
-
requestScope: ctx.requestScope ?? ctx,
|
|
1246
|
-
allowAuthorization: true,
|
|
1247
|
-
}), oauthPartitionFor(ctx));
|
|
2596
|
+
ctx = authorizingContext(ctx);
|
|
1248
2597
|
const state = stateFor(ctx);
|
|
1249
2598
|
state.provider = null;
|
|
1250
2599
|
const p = newProvider(ctx, state);
|
|
@@ -1283,36 +2632,116 @@ export function remoteMcp(id, opts) {
|
|
|
1283
2632
|
}
|
|
1284
2633
|
catch (err) {
|
|
1285
2634
|
if (state.authRequired) {
|
|
1286
|
-
|
|
2635
|
+
// The consent this start's attempt stored, while its epoch is live:
|
|
2636
|
+
// one a later reset published belongs to another flow, and this
|
|
2637
|
+
// start fails rather than hand it out.
|
|
2638
|
+
let authorizationUrl;
|
|
2639
|
+
try {
|
|
2640
|
+
authorizationUrl = await state.attemptProvider?.consentUrl();
|
|
2641
|
+
}
|
|
2642
|
+
catch (readErr) {
|
|
2643
|
+
return { state: "error", message: startMessage(readErr) };
|
|
2644
|
+
}
|
|
1287
2645
|
return {
|
|
1288
2646
|
state: "auth_required",
|
|
1289
2647
|
...(authorizationUrl !== undefined ? { authorizationUrl } : {}),
|
|
1290
2648
|
message: "Authorization required — open the URL to connect.",
|
|
1291
2649
|
};
|
|
1292
2650
|
}
|
|
1293
|
-
return { state: "error", message:
|
|
2651
|
+
return { state: "error", message: startMessage(err) };
|
|
1294
2652
|
}
|
|
1295
2653
|
};
|
|
1296
2654
|
}
|
|
2655
|
+
const withActiveSecrets = async (ctx, run, redactResult = true, preserveInput = false) => {
|
|
2656
|
+
trackCredentialReads(ctx);
|
|
2657
|
+
const secrets = sentSecretsFor(ctx);
|
|
2658
|
+
if (opts.auth?.type === "headers") {
|
|
2659
|
+
for (const value of Object.values(opts.auth.headers))
|
|
2660
|
+
secrets.header(value);
|
|
2661
|
+
}
|
|
2662
|
+
const state = entryFor(ctx);
|
|
2663
|
+
let active = activeContexts.get(state);
|
|
2664
|
+
if (!active) {
|
|
2665
|
+
active = new Set();
|
|
2666
|
+
activeContexts.set(state, active);
|
|
2667
|
+
}
|
|
2668
|
+
active.add(ctx);
|
|
2669
|
+
try {
|
|
2670
|
+
const result = await run();
|
|
2671
|
+
// Opaque state must reach the sealing handler byte-exact. Invocation
|
|
2672
|
+
// intercepts it before ordinary result processing or guest exposure.
|
|
2673
|
+
if (!preserveInput || !isInputRequiredResult(result))
|
|
2674
|
+
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, result);
|
|
2675
|
+
return (preserveInput && isInputRequiredResult(result)) || !redactResult
|
|
2676
|
+
? result
|
|
2677
|
+
: redactSentSecrets(ctx, result);
|
|
2678
|
+
}
|
|
2679
|
+
catch (error) {
|
|
2680
|
+
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, error);
|
|
2681
|
+
throw redactSentSecrets(ctx, error);
|
|
2682
|
+
}
|
|
2683
|
+
finally {
|
|
2684
|
+
active.delete(ctx);
|
|
2685
|
+
}
|
|
2686
|
+
};
|
|
2687
|
+
const callTool = connector.callTool;
|
|
2688
|
+
connector.callTool = (name, args, ctx, options) => withActiveSecrets(ctx, () => callTool(name, args, ctx, options), true, true);
|
|
2689
|
+
const listTools = connector.listTools;
|
|
2690
|
+
connector.listTools = (ctx) => withActiveSecrets(ctx, () => listTools(ctx));
|
|
2691
|
+
const readResource = connector.readResource;
|
|
2692
|
+
connector.readResource = (uri, ctx) => withActiveSecrets(ctx, () => readResource(uri, ctx));
|
|
2693
|
+
if (connector.downstreamSkills) {
|
|
2694
|
+
const { list, read } = connector.downstreamSkills;
|
|
2695
|
+
connector.downstreamSkills.list = (ctx) => withActiveSecrets(ctx, () => list(ctx), false);
|
|
2696
|
+
connector.downstreamSkills.read = (uri, ctx) => withActiveSecrets(ctx, () => read(uri, ctx), false);
|
|
2697
|
+
}
|
|
2698
|
+
if (isOauth && !staticClient && clientMetadataUrl === undefined)
|
|
2699
|
+
declareSelfHostedClient(connector, oauthScope);
|
|
1297
2700
|
if (isOauth) {
|
|
2701
|
+
const start = connector.startAuth;
|
|
2702
|
+
connector.startAuth = async (...args) => {
|
|
2703
|
+
const status = await start(...args);
|
|
2704
|
+
const path = await newProvider(args[0], stateFor(args[0])).registrationPath();
|
|
2705
|
+
return { ...status, ...(path ? { registrationPath: path } : {}) };
|
|
2706
|
+
};
|
|
1298
2707
|
// Pin before the first asynchronous storage/discovery read, not only once
|
|
1299
2708
|
// a refresh flight exists. These operations do not take call admission.
|
|
1300
|
-
const retain =
|
|
1301
|
-
const release = retainOAuthPartition(oauthPartitionFor(args[contextIndex]));
|
|
1302
|
-
try {
|
|
1303
|
-
return await operation(...args);
|
|
1304
|
-
}
|
|
1305
|
-
finally {
|
|
1306
|
-
release();
|
|
1307
|
-
}
|
|
1308
|
-
};
|
|
2709
|
+
const retain = retainingOAuthPartition;
|
|
1309
2710
|
connector.listTools = retain(connector.listTools, 0);
|
|
1310
2711
|
connector.callTool = retain(connector.callTool, 2);
|
|
2712
|
+
connector.readResource = retain(connector.readResource, 1);
|
|
2713
|
+
if (connector.downstreamSkills) {
|
|
2714
|
+
connector.downstreamSkills.list = retain(connector.downstreamSkills.list, 0);
|
|
2715
|
+
connector.downstreamSkills.read = retain(connector.downstreamSkills.read, 1);
|
|
2716
|
+
}
|
|
1311
2717
|
connector.status = retain(connector.status, 0);
|
|
1312
2718
|
connector.startAuth = retain(connector.startAuth, 0);
|
|
1313
2719
|
connector.disconnectAuth = retain(connector.disconnectAuth, 0);
|
|
1314
2720
|
connector.verifyState = retain(connector.verifyState, 1);
|
|
2721
|
+
connector.verifyCallbackIssuer = retain(connector.verifyCallbackIssuer, 1);
|
|
2722
|
+
connector.consumeAuthError = retain(connector.consumeAuthError, 0);
|
|
1315
2723
|
connector.finishAuth = retain(connector.finishAuth, 1);
|
|
1316
2724
|
}
|
|
2725
|
+
connector.listTools = payloadFree(connector.listTools);
|
|
2726
|
+
connector.callTool = payloadFree(connector.callTool);
|
|
2727
|
+
connector.readResource = payloadFree(connector.readResource);
|
|
2728
|
+
if (connector.downstreamSkills) {
|
|
2729
|
+
connector.downstreamSkills.list = payloadFree(connector.downstreamSkills.list);
|
|
2730
|
+
connector.downstreamSkills.read = payloadFree(connector.downstreamSkills.read);
|
|
2731
|
+
}
|
|
2732
|
+
if (connector.startAuth)
|
|
2733
|
+
connector.startAuth = payloadFree(connector.startAuth);
|
|
2734
|
+
if (connector.finishAuth)
|
|
2735
|
+
connector.finishAuth = payloadFree(connector.finishAuth);
|
|
2736
|
+
if (isOauth)
|
|
2737
|
+
registerInvocationAuth(connector, retainingOAuthPartition(async (ctx) => {
|
|
2738
|
+
// Only inspect local grant state here. Discovery, refresh, and MCP
|
|
2739
|
+
// transports remain inside callTool and cannot establish retry safety.
|
|
2740
|
+
const provider = newProvider(ctx, stateFor(ctx));
|
|
2741
|
+
if (await provider.operatorDisconnected())
|
|
2742
|
+
throw operatorDisconnectedError();
|
|
2743
|
+
if (!(await provider.tokens()))
|
|
2744
|
+
throw authRequiredError();
|
|
2745
|
+
}, 0));
|
|
1317
2746
|
return connector;
|
|
1318
2747
|
}
|