@zackbart/connecta 0.38.0 → 0.40.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 +91 -0
- package/README.md +3 -2
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/agent-routes.js +231 -0
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2382
- package/dist/auth/native.js +37 -0
- package/dist/auth/oauth-grant.js +13 -0
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth-elicitation.js +7 -1
- package/dist/authorization-handoff.js +11 -5
- package/dist/catalog-cache.js +219 -59
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +284 -47
- package/dist/catalog.js +18 -2
- package/dist/config.d.ts +16 -11
- package/dist/config.js +9 -6
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +7 -4
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +682 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +473 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +555 -0
- package/dist/connectors/remote-mcp.js +62 -2863
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +2 -0
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +152 -114
- package/dist/executor-result.js +3 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +60 -19
- package/dist/guest-types.d.ts +19 -5
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/errors.js +5 -4
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/oauth.js +3 -2
- package/dist/http/provider.js +32 -4
- package/dist/http/safety.js +8 -0
- package/dist/http/tools.js +44 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation.js +26 -8
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +54 -31
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +5 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +135 -13
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/contract.d.ts +10 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/_shared/rest/operation-index.js +48 -50
- package/dist/providers/_shared/rest/value-safety.js +80 -0
- package/dist/providers/breeze/api.js +7 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +4 -3
- package/dist/providers/ccb/api.js +2 -1
- package/dist/providers/ccb/index.js +5 -0
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +2 -4
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +11 -16
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +10 -7
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +7 -5
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +7 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/api.js +3 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +13 -14
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +15 -13
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +3 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +4 -3
- package/dist/registry.d.ts +10 -4
- package/dist/registry.js +41 -13
- package/dist/routes/credentials.js +129 -60
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +30 -14
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +6 -1
- package/dist/routes/shared.js +7 -1
- package/dist/routes/ui-config.js +2 -2
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +16 -0
- package/dist/runtime/call.js +7 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +207 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/storage.js +12 -0
- package/dist/skills.js +37 -20
- package/dist/storage/keys.js +53 -3
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +53 -1
- package/dist/storage/sql.js +12 -2
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/types.d.ts +33 -0
- package/dist/usage-guide.js +16 -35
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +13 -13
- package/documentation/architecture.md +385 -20
- package/documentation/auth.md +233 -64
- package/documentation/code-mode.md +122 -39
- package/documentation/deploying.md +6 -3
- package/documentation/integrating.md +16 -2
- package/documentation/meta-tools.md +240 -49
- package/documentation/migration-0.33.md +5 -1
- package/documentation/migration-0.34.md +3 -1
- package/documentation/operator-tests.md +1 -1
- package/documentation/operator-ui.md +105 -12
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +10 -10
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +2 -1
- package/guides/how-it-works.md +4 -2
- package/package.json +1 -1
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
|
@@ -1,693 +1,39 @@
|
|
|
1
1
|
import { wrapViewCredentials } from "../runtime/call.js";
|
|
2
|
-
import { sha256Hex, utf8Length } from "../bytes.js";
|
|
3
2
|
import { withStoredAuth } from "../auth/stored-observation.js";
|
|
4
|
-
import { authorizationParamsFor } from "../auth/oauth-authorization-params.js";
|
|
5
3
|
import { authView } from "../auth/native-call.js";
|
|
6
4
|
import { authFlow, authCall, authInvoke, authValue, authMethod, authCatch } from "../auth/native.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { AuthorizationServerMismatchError, Client, InsecureTokenEndpointError, InsufficientScopeError, IssuerMismatchError, MissingRequiredClientCapabilityError, OAuthClientFlowError, OAuthError, ProtocolError, RegistrationRejectedError, ResourceNotFoundError, SdkError, SdkErrorCode, SdkHttpError, SseError, UnsupportedProtocolVersionError, UrlElicitationRequiredError, isInputRequiredResult, fromJsonSchema, isJSONRPCErrorResponse, isJSONRPCNotification, extractWWWAuthenticateParams, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
12
|
-
import { Deferred, Duration, Effect, Exit, Scope } from "effect";
|
|
13
|
-
import { assertOAuthScope, authorizingContext, codeExchangeResponse, KvOAuthProvider, OAuthRequestNotSentError, refreshCoordinatorsByPartition, } from "../auth/downstream-oauth.js";
|
|
14
|
-
import { trackOAuthStartReset } from "../auth/oauth-start-reset.js";
|
|
5
|
+
import { viewServices, viewSignal } from "../runtime/call.js";
|
|
6
|
+
import { declareSelfHostedClient } from "../auth/downstream-client-metadata.js";
|
|
7
|
+
import { isInputRequiredResult } from "@modelcontextprotocol/client";
|
|
8
|
+
import { Duration, Effect, Exit, Scope } from "effect";
|
|
15
9
|
import { byteReadResponse } from "../byte-read-response.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { catalogClientOptions, catalogItems, observeCompletedCatalogRefresh, closeCatalogCacheScope, observeCatalogChange, observeCatalogFetch, observeCatalogExpiry, } from "../catalog-cache.js";
|
|
10
|
+
import { redactSentSecrets, requestSentSecrets, sentSecretsFor } from "../sent-secrets.js";
|
|
11
|
+
import { closeCatalogCacheScope } from "../catalog-cache.js";
|
|
19
12
|
import { connectorScopeCleanupClaimed } from "../connector-scope.js";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { CONNECTA_VERSION } from "../version.js";
|
|
23
|
-
import { learnedUrlRefusal } from "../url-safety.js";
|
|
24
|
-
import { oauthSealerFor } from "../oauth-sealing.js";
|
|
13
|
+
import { ConnectorCallError, msg, unavailableCallError } from "../errors.js";
|
|
14
|
+
import { failureStatus, labelErrorClass, ownStatus } from "../operator-record.js";
|
|
25
15
|
import { oauthPartitionFor, retainOAuthPartition } from "../oauth-partition.js";
|
|
26
16
|
import { RequestScope } from "../runtime/request-scope.js";
|
|
27
17
|
import { registerCallEntry } from "../runtime/call-entry.js";
|
|
28
18
|
import { Context } from "effect";
|
|
29
19
|
import { registerInvocationAuth } from "../invocation-auth.js";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
20
|
+
import { assertDownstreamOutputSafe } from "../downstream-input-context.js";
|
|
21
|
+
import { runEdge } from "../runtime/run.js";
|
|
22
|
+
import { credentialProbe } from "../runtime/credential-probe.js";
|
|
23
|
+
import { credentialFailureClass } from "../credential-verification.js";
|
|
24
|
+
import { tagPrincipalForwarding } from "../request-principal.js";
|
|
34
25
|
import { describedEndpoint, describedUrl } from "../described.js";
|
|
35
|
-
import { retryAfterMs } from "../http-policy.js";
|
|
36
|
-
import { resourceUriMatchesTemplates } from "./resource-uri.js";
|
|
37
26
|
import { callerOf } from "../connector-caller.js";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
const REMOTE_MCP_OPTIONS = optionsOf()({
|
|
48
|
-
...keys("url", "title", "description", "authScope", "maxResultBytes", "versionNegotiation", "redirects", "requireHttps", "logger", "fetch", "_transportFactory", "skills"),
|
|
49
|
-
readOnly: READ_ONLY,
|
|
50
|
-
callAdmission: CALL_ADMISSION,
|
|
51
|
-
usageGuide: USAGE_GUIDE,
|
|
52
|
-
auth: REMOTE_MCP_AUTH,
|
|
53
|
-
headers: strings(),
|
|
54
|
-
});
|
|
55
|
-
/** Extra headers are immutable code configuration, not another auth channel. */
|
|
56
|
-
function extraResourceHeaders(id, opts) {
|
|
57
|
-
if (opts.headers === undefined)
|
|
58
|
-
return undefined;
|
|
59
|
-
const refuse = () => new Error(`remoteMcp(${JSON.stringify(id)}).headers requires non-secret resource headers without reserved or auth-owned names; use auth for credentials.`);
|
|
60
|
-
if (opts.auth?.type === "headers")
|
|
61
|
-
throw refuse();
|
|
62
|
-
const authNames = new Set([
|
|
63
|
-
"authorization",
|
|
64
|
-
...(opts.auth?.type === "credential" ? [opts.auth.header?.trim().toLowerCase() || "authorization"] : []),
|
|
65
|
-
...(opts.auth?.type === "request" ? Object.keys(opts.auth.headers ?? {}).map((name) => name.toLowerCase()) : []),
|
|
66
|
-
]);
|
|
67
|
-
const normalized = Object.create(null);
|
|
68
|
-
for (const [name, value] of Object.entries(opts.headers)) {
|
|
69
|
-
const lower = name.toLowerCase();
|
|
70
|
-
if (!HEADER_NAME_TOKEN.test(name) ||
|
|
71
|
-
typeof value !== "string" ||
|
|
72
|
-
/[\r\n]/.test(value) ||
|
|
73
|
-
Object.hasOwn(normalized, lower) ||
|
|
74
|
-
authNames.has(lower) ||
|
|
75
|
-
/^(?:accept|cookie|host|origin|connection|keep-alive|proxy-authenticate|proxy-authorization|te|trailer|transfer-encoding|upgrade|last-event-id|dpop|www-authenticate)$/.test(lower) ||
|
|
76
|
-
/^(?:content-|mcp-)/.test(lower) ||
|
|
77
|
-
/(?:api[-_]?key|token|secret|password|credential|authorization|signature|session)/.test(lower))
|
|
78
|
-
throw refuse();
|
|
79
|
-
try {
|
|
80
|
-
normalized[lower] = new Headers({ [lower]: value }).get(lower);
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
throw refuse();
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return Object.freeze(Object.fromEntries(Object.entries(normalized).sort(([a], [b]) => a.localeCompare(b))));
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* How long a downstream gets to answer the session-termination DELETE before
|
|
90
|
-
* teardown stops waiting. This is a network round-trip budget, deliberately
|
|
91
|
-
* independent of the core's 100 ms caller-facing scope-close window: an
|
|
92
|
-
* already-established cross-internet connection avoids setup, but 50 ms is
|
|
93
|
-
* still too short for an ordinary round trip plus modest provider scheduling.
|
|
94
|
-
* The bounded tail is deferred on runtimes that can keep it alive after the
|
|
95
|
-
* response, while callers continue to wait at most 100 ms.
|
|
96
|
-
*/
|
|
97
|
-
const TERMINATE_SESSION_BUDGET_MS = 1_000;
|
|
98
|
-
/**
|
|
99
|
-
* How long the local close gets after the DELETE: the second of the two
|
|
100
|
-
* seconds the core's deferred scope-close tail allows (see
|
|
101
|
-
* `src/runtime/connector-scope.ts`). The SDK's own transports close at once;
|
|
102
|
-
* this bounds one that never does.
|
|
103
|
-
*/
|
|
104
|
-
const LOCAL_CLOSE_BUDGET_MS = 1_000;
|
|
105
|
-
function unadvertisedResource() {
|
|
106
|
-
return new ConnectorCallError("not_found", "The resource URI is not advertised by this connector.", {
|
|
107
|
-
retryable: false,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
/** SDK aggregation cap; intake also refuses loops and non-progress (INV-8). */
|
|
111
|
-
const MAX_TOOL_PAGES = 10_000;
|
|
112
|
-
const SKILLS_EXTENSION = "io.modelcontextprotocol/skills";
|
|
113
|
-
const MAX_SKILLS = 10_000;
|
|
114
|
-
const MAX_SKILL_FILES = 512;
|
|
115
|
-
const MAX_SKILL_BYTES = 16 * 1024 * 1024;
|
|
116
|
-
// A valid UTF-8 text file can expand sixfold when JSON escapes control bytes.
|
|
117
|
-
// Leave room for the envelope while accepting every file up to the spec limit.
|
|
118
|
-
const MAX_SKILL_READ_RPC_BYTES = 6 * MAX_SKILL_BYTES + 1024 * 1024;
|
|
119
|
-
/** Count UTF-8 without allocating another full copy of a bounded response. */
|
|
120
|
-
function skillObject(value) {
|
|
121
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
122
|
-
}
|
|
123
|
-
function skillJsonBytes(value) {
|
|
124
|
-
return utf8Length(JSON.stringify(value));
|
|
125
|
-
}
|
|
126
|
-
function validSkill(value) {
|
|
127
|
-
if (!skillObject(value) ||
|
|
128
|
-
typeof value.uri !== "string" ||
|
|
129
|
-
!value.uri ||
|
|
130
|
-
!skillObject(value.frontmatter) ||
|
|
131
|
-
typeof value.frontmatter.name !== "string" ||
|
|
132
|
-
!value.frontmatter.name ||
|
|
133
|
-
typeof value.frontmatter.description !== "string" ||
|
|
134
|
-
!value.frontmatter.description)
|
|
135
|
-
return false;
|
|
136
|
-
if (value.resources === "dynamic")
|
|
137
|
-
return true;
|
|
138
|
-
if (!Array.isArray(value.resources) || !value.resources.length || value.resources.length > MAX_SKILL_FILES)
|
|
139
|
-
return false;
|
|
140
|
-
const uris = new Set();
|
|
141
|
-
let bytes = 0;
|
|
142
|
-
for (const resource of value.resources) {
|
|
143
|
-
if (!skillObject(resource) ||
|
|
144
|
-
typeof resource.uri !== "string" ||
|
|
145
|
-
!resource.uri ||
|
|
146
|
-
uris.has(resource.uri) ||
|
|
147
|
-
typeof resource.digest !== "string" ||
|
|
148
|
-
!/^sha256:[a-f0-9]{64}$/.test(resource.digest) ||
|
|
149
|
-
typeof resource.size !== "number" ||
|
|
150
|
-
!Number.isSafeInteger(resource.size) ||
|
|
151
|
-
resource.size < 0)
|
|
152
|
-
return false;
|
|
153
|
-
uris.add(resource.uri);
|
|
154
|
-
bytes += resource.size;
|
|
155
|
-
if (bytes > MAX_SKILL_BYTES)
|
|
156
|
-
return false;
|
|
157
|
-
}
|
|
158
|
-
return uris.has(value.uri);
|
|
159
|
-
}
|
|
160
|
-
function completeSkillResult(value) {
|
|
161
|
-
// The modern SDK codec rejects non-complete wire results and removes
|
|
162
|
-
// resultType before invoking an explicit result schema.
|
|
163
|
-
return (skillObject(value) &&
|
|
164
|
-
typeof value.ttlMs === "number" &&
|
|
165
|
-
Number.isSafeInteger(value.ttlMs) &&
|
|
166
|
-
value.ttlMs >= 0 &&
|
|
167
|
-
(value.cacheScope === "public" || value.cacheScope === "private"));
|
|
168
|
-
}
|
|
169
|
-
const SkillPageSchema = {
|
|
170
|
-
"~standard": {
|
|
171
|
-
version: 1,
|
|
172
|
-
vendor: "connecta",
|
|
173
|
-
validate(value) {
|
|
174
|
-
if (!completeSkillResult(value) ||
|
|
175
|
-
!Array.isArray(value.skills) ||
|
|
176
|
-
value.skills.length > MAX_SKILLS ||
|
|
177
|
-
!value.skills.every(validSkill) ||
|
|
178
|
-
(value.nextCursor !== undefined && typeof value.nextCursor !== "string")) {
|
|
179
|
-
return { issues: [{ message: "Invalid or incomplete Skills listing." }] };
|
|
180
|
-
}
|
|
181
|
-
const bytes = skillJsonBytes(value);
|
|
182
|
-
if (bytes > MAX_SERIALIZED_CATALOG_BYTES)
|
|
183
|
-
return { issues: [{ message: "Skills listing exceeds the byte limit." }] };
|
|
184
|
-
return {
|
|
185
|
-
value: {
|
|
186
|
-
skills: value.skills,
|
|
187
|
-
bytes,
|
|
188
|
-
...(typeof value.nextCursor === "string" ? { nextCursor: value.nextCursor } : {}),
|
|
189
|
-
},
|
|
190
|
-
};
|
|
191
|
-
},
|
|
192
|
-
},
|
|
193
|
-
};
|
|
194
|
-
function skillReadSchema(uri) {
|
|
195
|
-
return {
|
|
196
|
-
"~standard": {
|
|
197
|
-
version: 1,
|
|
198
|
-
vendor: "connecta",
|
|
199
|
-
validate(value) {
|
|
200
|
-
if (!completeSkillResult(value) ||
|
|
201
|
-
!Array.isArray(value.contents) ||
|
|
202
|
-
value.contents.length !== 1 ||
|
|
203
|
-
skillJsonBytes(value) > MAX_SKILL_READ_RPC_BYTES) {
|
|
204
|
-
return { issues: [{ message: "Invalid or incomplete skill resource response." }] };
|
|
205
|
-
}
|
|
206
|
-
const content = value.contents[0];
|
|
207
|
-
if (!skillObject(content) ||
|
|
208
|
-
content.uri !== uri ||
|
|
209
|
-
(content.mimeType !== undefined && typeof content.mimeType !== "string")) {
|
|
210
|
-
return { issues: [{ message: "Unexpected skill resource contents." }] };
|
|
211
|
-
}
|
|
212
|
-
const mimeType = typeof content.mimeType === "string" ? { mimeType: content.mimeType } : {};
|
|
213
|
-
if (typeof content.text === "string" && !("blob" in content) && utf8Length(content.text) <= MAX_SKILL_BYTES) {
|
|
214
|
-
return { value: [{ uri, ...mimeType, text: content.text }] };
|
|
215
|
-
}
|
|
216
|
-
if (typeof content.blob === "string" &&
|
|
217
|
-
!("text" in content) &&
|
|
218
|
-
content.blob.length % 4 === 0 &&
|
|
219
|
-
!/[^A-Za-z0-9+/]/.test(content.blob.replace(/={1,2}$/, ""))) {
|
|
220
|
-
const bytes = (content.blob.length / 4) * 3 - (content.blob.endsWith("==") ? 2 : content.blob.endsWith("=") ? 1 : 0);
|
|
221
|
-
if (bytes <= MAX_SKILL_BYTES)
|
|
222
|
-
return { value: [{ uri, ...mimeType, blob: content.blob }] };
|
|
223
|
-
}
|
|
224
|
-
return { issues: [{ message: "Invalid or oversized skill resource bytes." }] };
|
|
225
|
-
},
|
|
226
|
-
},
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
/** Bound the body before SDK consumption, including its detached SSE reader. */
|
|
230
|
-
async function boundedSkillResponse(response, limit, rpcId, signal) {
|
|
231
|
-
const exceeded = () => new ConnectorCallError("connector_call_failed", "Downstream Skills response exceeds the RPC byte limit.");
|
|
232
|
-
const declared = response.headers.get("content-length");
|
|
233
|
-
if (declared !== null && Number(declared) > limit) {
|
|
234
|
-
await response.body?.cancel().catch(() => { });
|
|
235
|
-
throw exceeded();
|
|
236
|
-
}
|
|
237
|
-
if (!response.body)
|
|
238
|
-
return response;
|
|
239
|
-
const reader = response.body.getReader();
|
|
240
|
-
const abort = () => {
|
|
241
|
-
void reader.cancel().catch(() => { });
|
|
242
|
-
};
|
|
243
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
244
|
-
let bytes = 0;
|
|
245
|
-
const chunks = [];
|
|
246
|
-
let buffer;
|
|
247
|
-
let buffered = 0;
|
|
248
|
-
const sse = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() === "text/event-stream";
|
|
249
|
-
// Decode an entire SSE message once. Its byte buffer has no per-chunk or
|
|
250
|
-
// per-line objects, and preserves embedded resource BOMs on workerd too.
|
|
251
|
-
const decoder = new TextDecoder("utf-8", { ignoreBOM: true, fatal: false });
|
|
252
|
-
let data = new Uint8Array(0);
|
|
253
|
-
let dataBytes = 0;
|
|
254
|
-
let hasData = false;
|
|
255
|
-
const newline = new Uint8Array([10]);
|
|
256
|
-
const field = [100, 97, 116, 97, 58]; // data:
|
|
257
|
-
const bom = [239, 187, 191];
|
|
258
|
-
let bomOffset = 0;
|
|
259
|
-
let fieldOffset = 0;
|
|
260
|
-
// 0: field prefix, 1: optional value space, 2: data value, 3: ignored line.
|
|
261
|
-
let state = 0;
|
|
262
|
-
let lineBytes = false;
|
|
263
|
-
let skipLf = false;
|
|
264
|
-
const append = (value) => {
|
|
265
|
-
const needed = dataBytes + value.byteLength;
|
|
266
|
-
if (needed > limit)
|
|
267
|
-
throw exceeded();
|
|
268
|
-
if (needed > data.length) {
|
|
269
|
-
const next = new Uint8Array(Math.min(limit, Math.max(65_536, needed, data.length * 2)));
|
|
270
|
-
next.set(data.subarray(0, dataBytes));
|
|
271
|
-
data = next;
|
|
272
|
-
}
|
|
273
|
-
data.set(value, dataBytes);
|
|
274
|
-
dataBytes = needed;
|
|
275
|
-
};
|
|
276
|
-
const beginData = () => {
|
|
277
|
-
if (hasData)
|
|
278
|
-
append(newline);
|
|
279
|
-
hasData = true;
|
|
280
|
-
};
|
|
281
|
-
const consume = (chunk) => {
|
|
282
|
-
for (let offset = 0; offset < chunk.length;) {
|
|
283
|
-
const byte = chunk[offset];
|
|
284
|
-
if (skipLf) {
|
|
285
|
-
skipLf = false;
|
|
286
|
-
if (byte === 10) {
|
|
287
|
-
offset++;
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
// Strip exactly one initial UTF-8 BOM, even across single-byte chunks.
|
|
292
|
-
if (bomOffset >= 0) {
|
|
293
|
-
if (byte === bom[bomOffset]) {
|
|
294
|
-
if (++bomOffset === bom.length)
|
|
295
|
-
bomOffset = -1;
|
|
296
|
-
offset++;
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
if (bomOffset) {
|
|
300
|
-
lineBytes = true;
|
|
301
|
-
state = 3;
|
|
302
|
-
}
|
|
303
|
-
bomOffset = -1;
|
|
304
|
-
}
|
|
305
|
-
if (byte === 10 || byte === 13) {
|
|
306
|
-
if (lineBytes) {
|
|
307
|
-
if (state === 0 && fieldOffset === 4)
|
|
308
|
-
beginData();
|
|
309
|
-
}
|
|
310
|
-
else {
|
|
311
|
-
const json = hasData && dataBytes ? decoder.decode(data.subarray(0, dataBytes)) : "";
|
|
312
|
-
dataBytes = 0;
|
|
313
|
-
hasData = false;
|
|
314
|
-
if (json) {
|
|
315
|
-
const message = JSON.parse(json);
|
|
316
|
-
if (skillObject(message) && message.id === rpcId && ("result" in message || "error" in message)) {
|
|
317
|
-
data = new Uint8Array(0);
|
|
318
|
-
return json;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
state = 0;
|
|
323
|
-
fieldOffset = 0;
|
|
324
|
-
lineBytes = false;
|
|
325
|
-
skipLf = byte === 13;
|
|
326
|
-
offset++;
|
|
327
|
-
continue;
|
|
328
|
-
}
|
|
329
|
-
lineBytes = true;
|
|
330
|
-
if (state === 0) {
|
|
331
|
-
if (byte === field[fieldOffset]) {
|
|
332
|
-
if (++fieldOffset === field.length) {
|
|
333
|
-
beginData();
|
|
334
|
-
state = 1;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
else
|
|
338
|
-
state = 3;
|
|
339
|
-
offset++;
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
if (state === 1) {
|
|
343
|
-
state = 2;
|
|
344
|
-
if (byte === 32) {
|
|
345
|
-
offset++;
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
let end = offset;
|
|
350
|
-
while (end < chunk.length && chunk[end] !== 13 && chunk[end] !== 10)
|
|
351
|
-
end++;
|
|
352
|
-
if (state === 2)
|
|
353
|
-
append(chunk.subarray(offset, end));
|
|
354
|
-
offset = end;
|
|
355
|
-
}
|
|
356
|
-
};
|
|
357
|
-
const terminalResponse = async (json) => {
|
|
358
|
-
// The SDK's detached SSE consumer does not settle body-read errors. Hand
|
|
359
|
-
// it the bounded terminal answer and close a server-kept-open stream.
|
|
360
|
-
await reader.cancel().catch(() => { });
|
|
361
|
-
const headers = new Headers(response.headers);
|
|
362
|
-
headers.set("content-type", "application/json");
|
|
363
|
-
headers.delete("content-length");
|
|
364
|
-
return new Response(json, { status: response.status, statusText: response.statusText, headers });
|
|
365
|
-
};
|
|
366
|
-
try {
|
|
367
|
-
while (true) {
|
|
368
|
-
if (signal?.aborted)
|
|
369
|
-
throw signal.reason;
|
|
370
|
-
const chunk = await reader.read();
|
|
371
|
-
if (signal?.aborted)
|
|
372
|
-
throw signal.reason;
|
|
373
|
-
if (chunk.done) {
|
|
374
|
-
break;
|
|
375
|
-
}
|
|
376
|
-
bytes += chunk.value.byteLength;
|
|
377
|
-
if (bytes > limit)
|
|
378
|
-
throw exceeded();
|
|
379
|
-
if (!sse) {
|
|
380
|
-
// Network chunk count and backing-buffer size are untrusted. Retain
|
|
381
|
-
// only owned fixed-size blocks, never one object per incoming byte.
|
|
382
|
-
for (let offset = 0; offset < chunk.value.byteLength;) {
|
|
383
|
-
buffer ??= new Uint8Array(65_536);
|
|
384
|
-
const size = Math.min(buffer.length - buffered, chunk.value.byteLength - offset);
|
|
385
|
-
buffer.set(chunk.value.subarray(offset, offset + size), buffered);
|
|
386
|
-
offset += size;
|
|
387
|
-
buffered += size;
|
|
388
|
-
if (buffered === buffer.length) {
|
|
389
|
-
chunks.push(buffer);
|
|
390
|
-
buffer = undefined;
|
|
391
|
-
buffered = 0;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
continue;
|
|
395
|
-
}
|
|
396
|
-
const terminal = consume(chunk.value);
|
|
397
|
-
if (terminal !== undefined)
|
|
398
|
-
return await terminalResponse(terminal);
|
|
399
|
-
}
|
|
400
|
-
if (sse)
|
|
401
|
-
throw new ConnectorCallError("connector_call_failed", "Downstream Skills stream ended without a terminal RPC response.");
|
|
402
|
-
if (buffer && buffered)
|
|
403
|
-
chunks.push(buffer.subarray(0, buffered));
|
|
404
|
-
return new Response(new ReadableStream({
|
|
405
|
-
pull(controller) {
|
|
406
|
-
const chunk = chunks.shift();
|
|
407
|
-
if (chunk)
|
|
408
|
-
controller.enqueue(chunk);
|
|
409
|
-
else
|
|
410
|
-
controller.close();
|
|
411
|
-
},
|
|
412
|
-
}), { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
413
|
-
}
|
|
414
|
-
catch (error) {
|
|
415
|
-
await reader.cancel().catch(() => { });
|
|
416
|
-
throw error;
|
|
417
|
-
}
|
|
418
|
-
finally {
|
|
419
|
-
signal?.removeEventListener("abort", abort);
|
|
420
|
-
reader.releaseLock();
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
/**
|
|
424
|
-
* Compatibility concession for hand-rolled servers that serialize
|
|
425
|
-
* end-of-pagination as `null`. Only the cursor is widened; every tool and every
|
|
426
|
-
* other result field still passes through the SDK's pinned schema.
|
|
427
|
-
*/
|
|
428
|
-
const CompatibleListToolsResultSchema = {
|
|
429
|
-
"~standard": {
|
|
430
|
-
version: 1,
|
|
431
|
-
vendor: "connecta",
|
|
432
|
-
validate(value) {
|
|
433
|
-
const normalized = typeof value === "object" && value !== null && "nextCursor" in value && value.nextCursor === null
|
|
434
|
-
? (() => {
|
|
435
|
-
const copy = { ...value };
|
|
436
|
-
delete copy.nextCursor;
|
|
437
|
-
return copy;
|
|
438
|
-
})()
|
|
439
|
-
: value;
|
|
440
|
-
return specTypeSchemas.ListToolsResult["~standard"].validate(normalized);
|
|
441
|
-
},
|
|
442
|
-
},
|
|
443
|
-
};
|
|
444
|
-
/**
|
|
445
|
-
* True for a result-parse failure caused by the page's `nextCursor` itself.
|
|
446
|
-
* `null` is accepted deliberately; other non-string values remain a named
|
|
447
|
-
* downstream nonconformance instead of surfacing as a raw validation dump.
|
|
448
|
-
* Duck-typed rather than `instanceof ZodError`: the SDK may parse with its own
|
|
449
|
-
* zod instance, and cross-instance `instanceof` is a coin flip.
|
|
450
|
-
*/
|
|
451
|
-
function isCursorShapeError(err) {
|
|
452
|
-
const issues = err?.issues;
|
|
453
|
-
if (Array.isArray(issues) &&
|
|
454
|
-
issues.some((issue) => {
|
|
455
|
-
const path = issue.path;
|
|
456
|
-
return Array.isArray(path) && path[0] === "nextCursor";
|
|
457
|
-
})) {
|
|
458
|
-
return true;
|
|
459
|
-
}
|
|
460
|
-
// SDK v2 wraps Standard Schema failures in a ProtocolError and preserves the
|
|
461
|
-
// failing path in the message rather than exposing the validator's issues.
|
|
462
|
-
return msg(err).startsWith("Invalid result for tools/list: nextCursor:");
|
|
463
|
-
}
|
|
464
|
-
/**
|
|
465
|
-
* An `authorize_connector` start message for a failure: its text, which has
|
|
466
|
-
* passed the MCP and OAuth boundaries, and the origin and errno connecta
|
|
467
|
-
* derived for it, which a message alone would drop.
|
|
468
|
-
*/
|
|
469
|
-
/** An authorization start's failure for authorize_connector: no write's, so its advice stays. */
|
|
470
|
-
function startMessage(err) {
|
|
471
|
-
const details = err instanceof ConnectorCallError ? err.details : undefined;
|
|
472
|
-
const facts = [details?.host, details?.code].filter(Boolean).join(", ");
|
|
473
|
-
return withResendAdvice(facts ? `${msg(err)} (${facts})` : msg(err), resendAdviceOf(err));
|
|
474
|
-
}
|
|
475
|
-
/**
|
|
476
|
-
* End the downstream's session before the connection is torn down.
|
|
477
|
-
*
|
|
478
|
-
* `Client.close()` only unwinds our side — it aborts the transport's controller
|
|
479
|
-
* and fires `onclose`. Spec session termination is a separate DELETE carrying
|
|
480
|
-
* `Mcp-Session-Id`, and without it a stateful provider keeps the session alive
|
|
481
|
-
* until its own (often hour-long) timeout, accumulating abandoned sessions.
|
|
482
|
-
*
|
|
483
|
-
* Ordering is load-bearing: the SDK sends that DELETE on the transport's
|
|
484
|
-
* AbortSignal, so calling this *after* close would abort the request on issue
|
|
485
|
-
* and silently do nothing. Everything else is best-effort — a transport with no
|
|
486
|
-
* `terminateSession` (a custom one, or an older SDK), a downstream that refuses
|
|
487
|
-
* (405 is a legal answer), errors, or never replies all fall through to the
|
|
488
|
-
* close with the session left to age out as it did before.
|
|
489
|
-
*/
|
|
490
|
-
function terminateSession(transport, logger, connectorId) {
|
|
491
|
-
// SDK v2's Client.close() does not send the legacy session DELETE on our
|
|
492
|
-
// behalf. Connecta's own endpoint creates no protocol session, but a stateful
|
|
493
|
-
// legacy downstream can still issue `Mcp-Session-Id`, and every path that
|
|
494
|
-
// abandons one — scope teardown, credential rotation, OAuth retirement, an
|
|
495
|
-
// abandoned connect — owes it this best-effort, one-second DELETE.
|
|
496
|
-
const terminate = transport.terminateSession;
|
|
497
|
-
if (typeof terminate !== "function")
|
|
498
|
-
return Effect.void;
|
|
499
|
-
const warn = (log) => Effect.sync(() => {
|
|
500
|
-
try {
|
|
501
|
-
log();
|
|
502
|
-
}
|
|
503
|
-
catch {
|
|
504
|
-
// A diagnostic sink cannot make best-effort teardown observable to the
|
|
505
|
-
// caller in the one way this contract forbids: by replacing its result.
|
|
506
|
-
}
|
|
507
|
-
});
|
|
508
|
-
// Whichever finishes first wins and the other is interrupted: an answer
|
|
509
|
-
// clears the timer, and a failure that lands after the timeout is consumed
|
|
510
|
-
// by the interrupted wait rather than warned about a second time.
|
|
511
|
-
return Effect.raceAllFirst([
|
|
512
|
-
// The SDK issues no request at all when no `mcp-session-id` was captured,
|
|
513
|
-
// so a stateless downstream never sees a spurious DELETE.
|
|
514
|
-
promised(authMethod(() => authInvoke(Reflect, "apply", [terminate, transport, []]))).pipe(Effect.catch((error) =>
|
|
515
|
-
// The SDK's error quotes the downstream's status text, and its `data`
|
|
516
|
-
// the body, so the record keeps only the status it answered with.
|
|
517
|
-
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))))),
|
|
518
|
-
Effect.sleep(Duration.millis(TERMINATE_SESSION_BUDGET_MS)).pipe(Effect.andThen(warn(() => logger.warn(`[connecta] connector "${connectorId}" session termination was not ` +
|
|
519
|
-
`acknowledged within ${TERMINATE_SESSION_BUDGET_MS} ms; the ` +
|
|
520
|
-
"downstream may still finish the headers-only DELETE, otherwise " +
|
|
521
|
-
"the session will remain until its provider timeout.")))),
|
|
522
|
-
]);
|
|
523
|
-
}
|
|
524
|
-
/**
|
|
525
|
-
* The local half of a close: the SDK client's close once a client owns the
|
|
526
|
-
* transport, the bare transport's until then. Never fails, and stops waiting
|
|
527
|
-
* after LOCAL_CLOSE_BUDGET_MS — a transport whose close never settles would
|
|
528
|
-
* otherwise hold every caller that awaits the scope close, the credential Test
|
|
529
|
-
* action among them.
|
|
530
|
-
*/
|
|
531
|
-
function closeLocally(client, transport) {
|
|
532
|
-
if (!client && !transport)
|
|
533
|
-
return Effect.void;
|
|
534
|
-
return Effect.raceAllFirst([
|
|
535
|
-
promised(authMethod(() => (client ? authInvoke(client, "close", []) : authValue(() => transport?.close())))).pipe(Effect.ignore),
|
|
536
|
-
Effect.sleep(Duration.millis(LOCAL_CLOSE_BUDGET_MS)),
|
|
537
|
-
]);
|
|
538
|
-
}
|
|
539
|
-
/**
|
|
540
|
-
* One Promise step of an Effect program. It fails with exactly what the
|
|
541
|
-
* promise rejected with, never a wrapper, because callers check the SDK's and
|
|
542
|
-
* connecta's own error classes. `evaluate` takes no signal, so the step
|
|
543
|
-
* allocates no AbortController.
|
|
544
|
-
*/
|
|
545
|
-
function promised(evaluate) {
|
|
546
|
-
return authCall(evaluate, []);
|
|
547
|
-
}
|
|
548
|
-
/** A retained cause chain must not smuggle an SDK payload past the boundary. */
|
|
549
|
-
function hasSdkPayload(error) {
|
|
550
|
-
const seen = new Set();
|
|
551
|
-
for (let current = error; current instanceof Error && !seen.has(current); current = current.cause) {
|
|
552
|
-
if (current instanceof ProtocolError || current instanceof SdkError)
|
|
553
|
-
return true;
|
|
554
|
-
seen.add(current);
|
|
555
|
-
}
|
|
556
|
-
return false;
|
|
557
|
-
}
|
|
558
|
-
/** Cancellation can bypass an inner SDK catch and return the signal's reason. */
|
|
559
|
-
function payloadFree(run) {
|
|
560
|
-
return authMethod((...args) => authFlow(function* () {
|
|
561
|
-
try {
|
|
562
|
-
return (yield authCall(run, [...args]));
|
|
563
|
-
}
|
|
564
|
-
catch (error) {
|
|
565
|
-
throw hasSdkPayload(error) ? carryFailureFacts(error, downstreamCallError(error)) : error;
|
|
566
|
-
}
|
|
567
|
-
}));
|
|
568
|
-
}
|
|
569
|
-
/** Both the SDK's auth signal and its classified form invalidate a live client. */
|
|
570
|
-
function requiresAuthorization(error) {
|
|
571
|
-
return (error instanceof UnauthorizedError ||
|
|
572
|
-
(error instanceof ConnectorCallError &&
|
|
573
|
-
(error.code === "auth_required" || error.code === "downstream_oauth_required")));
|
|
574
|
-
}
|
|
575
|
-
/** Classify SDK/runtime facts, dropping every SDK payload and cause chain. */
|
|
576
|
-
function downstreamCallError(error, httpStatus, wait, oauthStep, secrets) {
|
|
577
|
-
if (error instanceof ConnectorCallError) {
|
|
578
|
-
return hasSdkPayload(error.cause) ? carryFailureFacts(error, withheldAs(error.message, error)) : error;
|
|
579
|
-
}
|
|
580
|
-
if (error instanceof WithheldTextError || error instanceof UnauthorizedError)
|
|
581
|
-
return error;
|
|
582
|
-
const network = !(error instanceof SdkError) && !(error instanceof OAuthClientFlowError) ? networkErrorCode(error) : undefined;
|
|
583
|
-
if (network) {
|
|
584
|
-
return network === "timeout" || network === "ETIMEDOUT" || network.endsWith("_TIMEOUT")
|
|
585
|
-
? new ConnectorCallError("timeout", "The downstream request timed out.")
|
|
586
|
-
: unavailableCallError(error);
|
|
587
|
-
}
|
|
588
|
-
const status = error instanceof SdkHttpError || error instanceof RegistrationRejectedError ? error.status : httpStatus;
|
|
589
|
-
if (error instanceof InsufficientScopeError ||
|
|
590
|
-
(status === 403 && !(error instanceof RegistrationRejectedError)) ||
|
|
591
|
-
(error instanceof OAuthError && ["insufficient_scope", "invalid_scope", "access_denied"].includes(error.code))) {
|
|
592
|
-
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.");
|
|
593
|
-
}
|
|
594
|
-
// Token endpoints return typed OAuth recovery codes on HTTP 400. Other
|
|
595
|
-
// OAuth steps, especially registration, keep their HTTP status verdict.
|
|
596
|
-
if (error instanceof OAuthError &&
|
|
597
|
-
OAUTH_ERROR_CODES.has(error.code) &&
|
|
598
|
-
(status === undefined || oauthStep === "token request")) {
|
|
599
|
-
return new ConnectorCallError(["invalid_client", "invalid_grant", "invalid_token", "unauthorized_client"].includes(error.code)
|
|
600
|
-
? "downstream_oauth_required"
|
|
601
|
-
: error.code === "too_many_requests"
|
|
602
|
-
? "rate_limited"
|
|
603
|
-
: ["server_error", "temporarily_unavailable"].includes(error.code)
|
|
604
|
-
? "unavailable"
|
|
605
|
-
: "connector_call_failed", `OAuth failed with error ${error.code}.`);
|
|
606
|
-
}
|
|
607
|
-
if (status !== undefined && status >= 400) {
|
|
608
|
-
let message = `The downstream service answered HTTP ${status}.`;
|
|
609
|
-
if (error instanceof SdkHttpError && status < 500 && typeof error.data.text === "string") {
|
|
610
|
-
message = error.message;
|
|
611
|
-
try {
|
|
612
|
-
const body = JSON.parse(error.data.text);
|
|
613
|
-
const detail = body?.message ?? body?.error?.message ?? body?.error_description;
|
|
614
|
-
if (typeof detail === "string")
|
|
615
|
-
message = detail;
|
|
616
|
-
}
|
|
617
|
-
catch {
|
|
618
|
-
// Non-JSON MCP refusals retain only a bounded diagnostic.
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
return new ConnectorCallError(status === 401 && !(error instanceof RegistrationRejectedError)
|
|
622
|
-
? "auth_required"
|
|
623
|
-
: status === 429
|
|
624
|
-
? "rate_limited"
|
|
625
|
-
: status === 408 && !(error instanceof RegistrationRejectedError)
|
|
626
|
-
? "timeout"
|
|
627
|
-
: "connector_call_failed", boundedEchoText(secrets?.text(message) ?? message), {
|
|
628
|
-
retryable: [429, 502, 503, 504].includes(status) || (status === 408 && !(error instanceof RegistrationRejectedError)),
|
|
629
|
-
...(wait !== undefined ? { retryAfterMs: wait } : {}),
|
|
630
|
-
});
|
|
631
|
-
}
|
|
632
|
-
if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) {
|
|
633
|
-
return new ConnectorCallError("timeout", "The downstream request timed out.");
|
|
634
|
-
}
|
|
635
|
-
// ProtocolError.data is server-chosen even when its message is allowed.
|
|
636
|
-
if (error instanceof ProtocolError) {
|
|
637
|
-
return new ConnectorCallError(error.code === -32602 ? "invalid_args" : "connector_call_failed", boundedEchoText(secrets?.text(error.message) ?? error.message));
|
|
638
|
-
}
|
|
639
|
-
return new ConnectorCallError("connector_call_failed", "The downstream operation failed.");
|
|
640
|
-
}
|
|
641
|
-
const encoder = new TextEncoder();
|
|
642
|
-
/** Base64 of a UTF-8 string, Web-API only so the core still runs on workerd. */
|
|
643
|
-
function base64Utf8(value) {
|
|
644
|
-
let binary = "";
|
|
645
|
-
for (const byte of encoder.encode(value)) {
|
|
646
|
-
binary += String.fromCharCode(byte);
|
|
647
|
-
}
|
|
648
|
-
return btoa(binary);
|
|
649
|
-
}
|
|
650
|
-
/**
|
|
651
|
-
* Assemble the one header a credential-auth connector sends.
|
|
652
|
-
*
|
|
653
|
-
* A scheme ending in `Basic` names HTTP Basic credentials wherever a provider
|
|
654
|
-
* nests it, so the `user:secret` it frames is base64-encoded — that is what
|
|
655
|
-
* makes plain `Basic` and nested `Bearer Basic` one rule instead of two.
|
|
656
|
-
*/
|
|
657
|
-
function credentialHeaderValue(scheme, value) {
|
|
658
|
-
if (scheme === null)
|
|
659
|
-
return value;
|
|
660
|
-
return /(?:^|\s)basic$/i.test(scheme) ? `${scheme} ${base64Utf8(value)}` : `${scheme} ${value}`;
|
|
661
|
-
}
|
|
662
|
-
/**
|
|
663
|
-
* True when a stored credential carries a character a header cannot.
|
|
664
|
-
*
|
|
665
|
-
* The fetch specification refuses NUL, CR, and LF outright, and the runtime
|
|
666
|
-
* that refuses them says so in a `TypeError` that quotes the whole offending
|
|
667
|
-
* value — a message that would otherwise travel to the agent. The remaining C0
|
|
668
|
-
* controls and DEL are refused here too: no real API key contains one, and a
|
|
669
|
-
* paste that picked one up is a paste to redo rather than a request to send.
|
|
670
|
-
* Leading and trailing whitespace is already gone by the time this runs.
|
|
671
|
-
*
|
|
672
|
-
* A scan rather than a regular expression, because a character class over the
|
|
673
|
-
* control range is exactly what `no-control-regex` exists to flag, and the
|
|
674
|
-
* suppression would be less readable than the loop it suppressed.
|
|
675
|
-
*/
|
|
676
|
-
function carriesIllegalHeaderChar(value) {
|
|
677
|
-
for (let index = 0; index < value.length; index++) {
|
|
678
|
-
const code = value.charCodeAt(index);
|
|
679
|
-
if (code <= 0x1f || code === 0x7f)
|
|
680
|
-
return true;
|
|
681
|
-
}
|
|
682
|
-
return false;
|
|
683
|
-
}
|
|
684
|
-
/** RFC 9110 field-name token, checked once at construction. */
|
|
685
|
-
const HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
686
|
-
function isLoopbackHost(hostname) {
|
|
687
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
688
|
-
}
|
|
27
|
+
import { providerBoundary } from "../provider-call.js";
|
|
28
|
+
import { downstreamCallError, hasSdkPayload, payloadFree, remoteMcpErrors } from "./remote-mcp/errors.js";
|
|
29
|
+
import { LOCAL_CLOSE_BUDGET_MS, REDIRECT_STATUSES, remoteMcpTransport } from "./remote-mcp/transport.js";
|
|
30
|
+
import { remoteMcpConfig } from "./remote-mcp/config.js";
|
|
31
|
+
import { remoteMcpConnection } from "./remote-mcp/connection.js";
|
|
32
|
+
import { remoteMcpOAuth } from "./remote-mcp/oauth.js";
|
|
33
|
+
import { remoteMcpCatalog } from "./remote-mcp/catalog.js";
|
|
34
|
+
import { remoteMcpResources } from "./remote-mcp/resources.js";
|
|
35
|
+
import { remoteMcpCall } from "./remote-mcp/call.js";
|
|
689
36
|
export const MAX_REMOTE_REDIRECT_HOPS = 5;
|
|
690
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
691
37
|
const BODY_HEADERS = [
|
|
692
38
|
"content-encoding",
|
|
693
39
|
"content-language",
|
|
@@ -786,267 +132,6 @@ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetc
|
|
|
786
132
|
}
|
|
787
133
|
}));
|
|
788
134
|
}
|
|
789
|
-
class RemoteMcpDestinationError extends OAuthRequestNotSentError {
|
|
790
|
-
constructor(connectorId, reason) {
|
|
791
|
-
super("connector_call_failed", `Connector "${connectorId}" refused an OAuth URL the downstream advertised: ${reason}.`);
|
|
792
|
-
this.name = "RemoteMcpDestinationError";
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
labelErrorClass(RemoteMcpDestinationError, "RemoteMcpDestinationError");
|
|
796
|
-
/** A connection whose request scope ended while it was being made. */
|
|
797
|
-
class ScopeEndedError extends Error {
|
|
798
|
-
name = "ScopeEndedError";
|
|
799
|
-
}
|
|
800
|
-
labelErrorClass(ScopeEndedError, "ScopeEndedError");
|
|
801
|
-
/** The MCP client's transport lacks the OAuth flow seams this release is pinned against. */
|
|
802
|
-
class UnboundOAuthFlowsError extends Error {
|
|
803
|
-
name = "UnboundOAuthFlowsError";
|
|
804
|
-
}
|
|
805
|
-
labelErrorClass(UnboundOAuthFlowsError, "UnboundOAuthFlowsError");
|
|
806
|
-
// The pinned SDK's exported errors, labelled by identity for records and
|
|
807
|
-
// connecta's fixed text.
|
|
808
|
-
for (const [ctor, label] of [
|
|
809
|
-
[SdkError, "SdkError"],
|
|
810
|
-
[SdkHttpError, "SdkHttpError"],
|
|
811
|
-
[ProtocolError, "ProtocolError"],
|
|
812
|
-
[MissingRequiredClientCapabilityError, "MissingRequiredClientCapabilityError"],
|
|
813
|
-
[ResourceNotFoundError, "ResourceNotFoundError"],
|
|
814
|
-
[UnsupportedProtocolVersionError, "UnsupportedProtocolVersionError"],
|
|
815
|
-
[UrlElicitationRequiredError, "UrlElicitationRequiredError"],
|
|
816
|
-
[SseError, "SseError"],
|
|
817
|
-
[OAuthError, "OAuthError"],
|
|
818
|
-
[UnauthorizedError, "UnauthorizedError"],
|
|
819
|
-
[OAuthClientFlowError, "OAuthClientFlowError"],
|
|
820
|
-
[AuthorizationServerMismatchError, "AuthorizationServerMismatchError"],
|
|
821
|
-
[InsecureTokenEndpointError, "InsecureTokenEndpointError"],
|
|
822
|
-
[InsufficientScopeError, "InsufficientScopeError"],
|
|
823
|
-
[IssuerMismatchError, "IssuerMismatchError"],
|
|
824
|
-
[RegistrationRejectedError, "RegistrationRejectedError"],
|
|
825
|
-
]) {
|
|
826
|
-
labelErrorClass(ctor, label);
|
|
827
|
-
}
|
|
828
|
-
/**
|
|
829
|
-
* Refuse, before any request leaves, a URL the downstream taught the OAuth
|
|
830
|
-
* flow that points somewhere connecta must not go (see `url-safety.ts`).
|
|
831
|
-
*
|
|
832
|
-
* The SDK learns authorization-server, token, and registration URLs from the
|
|
833
|
-
* downstream's own metadata and fetches them through the transport's fetch,
|
|
834
|
-
* so this sits below the refresh coordinator on both send paths. Resource
|
|
835
|
-
* redirects stay same-origin; credential-bearing token requests bypass the
|
|
836
|
-
* redirect wrapper. The typed local refusal proves that this guard sent nothing;
|
|
837
|
-
* failures after an HTTP dispatch keep their permanent ambiguous verdict.
|
|
838
|
-
*/
|
|
839
|
-
function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
|
|
840
|
-
return authMethod((input, init) => authFlow(function* () {
|
|
841
|
-
const reason = learnedUrlRefusal(configured, new URL(input));
|
|
842
|
-
if (reason !== undefined) {
|
|
843
|
-
throw new RemoteMcpDestinationError(connectorId, reason);
|
|
844
|
-
}
|
|
845
|
-
return byteReadResponse((yield authCall(baseFetch, [input, init])));
|
|
846
|
-
}));
|
|
847
|
-
}
|
|
848
|
-
/** The JSON-RPC error answers each transport delivered, as `code\0message`. */
|
|
849
|
-
const wireErrors = new WeakMap();
|
|
850
|
-
/** Answers remembered per transport; one request scope rarely sees more. */
|
|
851
|
-
const MAX_WIRE_ERRORS = 64;
|
|
852
|
-
function wireErrorKey(code, message) {
|
|
853
|
-
return `${String(code)}\u0000${String(message)}`;
|
|
854
|
-
}
|
|
855
|
-
/**
|
|
856
|
-
* Remember every JSON-RPC error response `transport` delivers, so that a
|
|
857
|
-
* `ProtocolError` can be told apart as the downstream's own answer. The SDK
|
|
858
|
-
* builds its local refusals (an output schema it cannot compile, a result
|
|
859
|
-
* that fails it) from the same class, with its own text about what the
|
|
860
|
-
* downstream sent; only one whose code and message arrived on the wire is
|
|
861
|
-
* the answer. Installed before `Client.connect`, whose protocol calls the
|
|
862
|
-
* `onmessage` it finds there ahead of its own for every message, and whose
|
|
863
|
-
* version probe restores it when the probe is done.
|
|
864
|
-
*/
|
|
865
|
-
function recordWireErrors(transport, catalogChanged) {
|
|
866
|
-
const seen = new Set();
|
|
867
|
-
wireErrors.set(transport, seen);
|
|
868
|
-
transport.onmessage = (message) => {
|
|
869
|
-
// The public transport observer runs before SDK 2.3.1's delete-based
|
|
870
|
-
// eviction. Ordinary opposite-scope cleanup never rotates this fence.
|
|
871
|
-
if (isJSONRPCNotification(message) &&
|
|
872
|
-
(message.method === "notifications/tools/list_changed" ||
|
|
873
|
-
message.method === "notifications/resources/list_changed"))
|
|
874
|
-
catalogChanged();
|
|
875
|
-
if (isJSONRPCErrorResponse(message) && seen.size < MAX_WIRE_ERRORS) {
|
|
876
|
-
seen.add(wireErrorKey(message.error.code, message.error.message));
|
|
877
|
-
}
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
/**
|
|
881
|
-
* Whether an error leaving the SDK may keep its own text. The boundary is an
|
|
882
|
-
* allow-list: a JSON-RPC error the downstream answered with, an HTTP 4xx
|
|
883
|
-
* refusal from the MCP endpoint, and errors already in connecta's words (its
|
|
884
|
-
* own classes, an OAuth error the token responses were rebuilt into, and
|
|
885
|
-
* `UnauthorizedError`, whose class is the whole verdict and whose text is
|
|
886
|
-
* never shown). The request's own abort reason is checked by identity before
|
|
887
|
-
* this. Everything else is withheld.
|
|
888
|
-
*/
|
|
889
|
-
function keepsItsText(err, transport) {
|
|
890
|
-
if (err instanceof ConnectorCallError ||
|
|
891
|
-
err instanceof WithheldTextError ||
|
|
892
|
-
err instanceof UnauthorizedError ||
|
|
893
|
-
err instanceof OAuthError) {
|
|
894
|
-
return true;
|
|
895
|
-
}
|
|
896
|
-
if (err instanceof SdkHttpError)
|
|
897
|
-
return err.status >= 400 && err.status < 500;
|
|
898
|
-
if (err instanceof ProtocolError) {
|
|
899
|
-
return transport !== undefined && wireErrors.get(transport)?.has(wireErrorKey(err.code, err.message)) === true;
|
|
900
|
-
}
|
|
901
|
-
return false;
|
|
902
|
-
}
|
|
903
|
-
/**
|
|
904
|
-
* The SDK's own checks of a tool's output schema, recognized by how the
|
|
905
|
-
* pinned SDK (2.3.1) begins their messages, and told in connecta's words.
|
|
906
|
-
* Recognition reads the SDK's text only to choose connecta's: none of it, nor
|
|
907
|
-
* the validator's account that follows, is repeated.
|
|
908
|
-
*/
|
|
909
|
-
const SDK_OUTPUT_SCHEMA_CHECKS = [
|
|
910
|
-
[
|
|
911
|
-
/^Structured content does not match the tool's output schema/,
|
|
912
|
-
"the result did not match the tool's declared output schema",
|
|
913
|
-
],
|
|
914
|
-
[
|
|
915
|
-
/^Tool .* has an output schema but did not return structured content/s,
|
|
916
|
-
"the result did not return structured content although the tool declares an output schema",
|
|
917
|
-
],
|
|
918
|
-
[/^Tool .* has an invalid outputSchema/s, "the tool's declared output schema could not be compiled"],
|
|
919
|
-
];
|
|
920
|
-
function sdkCheckFailed(err) {
|
|
921
|
-
if (err instanceof SdkError && err.code === SdkErrorCode.RequestTimeout)
|
|
922
|
-
return ": the request timed out";
|
|
923
|
-
if (!(err instanceof ProtocolError))
|
|
924
|
-
return "";
|
|
925
|
-
const check = SDK_OUTPUT_SCHEMA_CHECKS.find(([pattern]) => pattern.test(err.message));
|
|
926
|
-
return check ? `: ${check[1]}` : "";
|
|
927
|
-
}
|
|
928
|
-
/** The first error in `err`'s cause chain already told in connecta's words. */
|
|
929
|
-
function connectaErrorWithin(err) {
|
|
930
|
-
const seen = new Set();
|
|
931
|
-
for (let current = err instanceof Error ? err.cause : undefined; current instanceof Error && !seen.has(current); current = current.cause) {
|
|
932
|
-
seen.add(current);
|
|
933
|
-
if (current instanceof ConnectorCallError || current instanceof WithheldTextError)
|
|
934
|
-
return current;
|
|
935
|
-
}
|
|
936
|
-
return undefined;
|
|
937
|
-
}
|
|
938
|
-
/** The request's own abort reason, by identity, never by name or class. */
|
|
939
|
-
function ownAbortReason(err, signals) {
|
|
940
|
-
return signals.some((signal) => signal?.aborted === true && err === signal.reason);
|
|
941
|
-
}
|
|
942
|
-
/**
|
|
943
|
-
* The request's own abort reason as the SDK hands it back: a request it
|
|
944
|
-
* cancels rejects with `new SdkError(RequestTimeout, String(reason))` unless
|
|
945
|
-
* the reason already is one (pinned against client 2.3.1). Its text is the
|
|
946
|
-
* caller's, so it passes as the SDK wrote it.
|
|
947
|
-
*/
|
|
948
|
-
function ownAbortReasonAsSdkReports(err, signals) {
|
|
949
|
-
return (err instanceof SdkError &&
|
|
950
|
-
err.code === SdkErrorCode.RequestTimeout &&
|
|
951
|
-
signals.some((signal) => signal?.aborted === true && err.message === String(signal.reason)));
|
|
952
|
-
}
|
|
953
|
-
/** An error's class name, when it is a plain identifier worth naming. */
|
|
954
|
-
function errorKind(err) {
|
|
955
|
-
const label = errorLabel(err);
|
|
956
|
-
return label && label !== "Error" ? ` (${label})` : "";
|
|
957
|
-
}
|
|
958
|
-
/**
|
|
959
|
-
* Replace text while keeping a verdict already derived from structured facts.
|
|
960
|
-
* Never retain the original SDK error as a cause.
|
|
961
|
-
*/
|
|
962
|
-
function withheldAs(message, verdict) {
|
|
963
|
-
if (verdict instanceof ConnectorCallError) {
|
|
964
|
-
return new ConnectorCallError(verdict.code, message, {
|
|
965
|
-
retryable: verdict.retryable,
|
|
966
|
-
...(verdict.retryAfterMs !== undefined ? { retryAfterMs: verdict.retryAfterMs } : {}),
|
|
967
|
-
...(verdict.details ? { details: verdict.details } : {}),
|
|
968
|
-
...(verdict.validation ? { validation: verdict.validation } : {}),
|
|
969
|
-
...(verdict.current ? { current: verdict.current } : {}),
|
|
970
|
-
...(verdict.writeOutcome ? { writeOutcome: verdict.writeOutcome } : {}),
|
|
971
|
-
});
|
|
972
|
-
}
|
|
973
|
-
return new WithheldTextError(message, verdict);
|
|
974
|
-
}
|
|
975
|
-
/**
|
|
976
|
-
* Record each OAuth request on `trail` before sending it. Pinned against
|
|
977
|
-
* `@modelcontextprotocol/client` 2.3.1: every request the SDK sends anywhere
|
|
978
|
-
* but the MCP endpoint is a discovery GET, a token request (a form carrying
|
|
979
|
-
* `grant_type`), or the JSON POST of dynamic client registration.
|
|
980
|
-
*/
|
|
981
|
-
function tracedOAuthFetch(trail, endpoint, baseFetch) {
|
|
982
|
-
return authMethod((input, init) => authFlow(function* () {
|
|
983
|
-
const url = new URL(input);
|
|
984
|
-
if (url.href === endpoint.href) {
|
|
985
|
-
trail.last = undefined;
|
|
986
|
-
}
|
|
987
|
-
else {
|
|
988
|
-
const body = init?.body;
|
|
989
|
-
const step = (init?.method ?? "GET").toUpperCase() === "GET"
|
|
990
|
-
? "discovery"
|
|
991
|
-
: body instanceof URLSearchParams ||
|
|
992
|
-
(typeof body === "string" && new URLSearchParams(body).has("grant_type"))
|
|
993
|
-
? "token request"
|
|
994
|
-
: "client registration";
|
|
995
|
-
trail.last = { step, host: url.origin };
|
|
996
|
-
if (step === "client registration")
|
|
997
|
-
trail.registration = url.origin;
|
|
998
|
-
}
|
|
999
|
-
const leg = trail.last;
|
|
1000
|
-
const response = (yield authCall(baseFetch, [input, init]));
|
|
1001
|
-
if (leg) {
|
|
1002
|
-
leg.httpStatus = response.status;
|
|
1003
|
-
const wait = retryAfterMs(response.headers);
|
|
1004
|
-
if (wait !== undefined)
|
|
1005
|
-
leg.retryAfterMs = wait;
|
|
1006
|
-
}
|
|
1007
|
-
return byteReadResponse(response);
|
|
1008
|
-
}));
|
|
1009
|
-
}
|
|
1010
|
-
function registrationErrorCode(body) {
|
|
1011
|
-
try {
|
|
1012
|
-
const code = JSON.parse(body)?.error;
|
|
1013
|
-
return typeof code === "string" && OAUTH_ERROR_CODES.has(code) ? code : undefined;
|
|
1014
|
-
}
|
|
1015
|
-
catch {
|
|
1016
|
-
return undefined;
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
/**
|
|
1020
|
-
* The key a `tools/call`'s params carry its dispatch under. Object spread
|
|
1021
|
-
* copies a symbol-keyed property, and the pinned SDK only spreads a request's
|
|
1022
|
-
* params (to add a progress token or its `_meta` envelope); `JSON.stringify`
|
|
1023
|
-
* skips symbol keys, so nothing of it reaches the wire.
|
|
1024
|
-
*/
|
|
1025
|
-
const DISPATCH = Symbol("connecta.dispatch");
|
|
1026
|
-
function newDispatch(write, sourceWrite) {
|
|
1027
|
-
return { write, sourceWrite, sent: false, unauthorized: false, redirected: false, resendRefused: false };
|
|
1028
|
-
}
|
|
1029
|
-
function withDispatch(params, dispatch) {
|
|
1030
|
-
Object.defineProperty(params, DISPATCH, { value: dispatch, enumerable: true });
|
|
1031
|
-
return params;
|
|
1032
|
-
}
|
|
1033
|
-
function dispatchOf(params) {
|
|
1034
|
-
return params !== null && typeof params === "object"
|
|
1035
|
-
? params[DISPATCH]
|
|
1036
|
-
: undefined;
|
|
1037
|
-
}
|
|
1038
|
-
/** A further request for a write that already left, stopped before it does. */
|
|
1039
|
-
function resendRefused(dispatch) {
|
|
1040
|
-
dispatch.resendRefused = true;
|
|
1041
|
-
return new ConnectorCallError("connector_call_failed", "This write was already sent once; it was not sent again.", {
|
|
1042
|
-
retryable: false,
|
|
1043
|
-
writeOutcome: "unknown",
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
/** Lost tracking cannot prove whether an earlier message already sent a write. */
|
|
1047
|
-
function missingDispatch() {
|
|
1048
|
-
return new ConnectorCallError("connector_call_failed", "The downstream call lost its dispatch record and cannot be proven read-only. It was not sent; whether an earlier write took effect is unknown. Check the target before repeating it.", { retryable: false, writeOutcome: "unknown" });
|
|
1049
|
-
}
|
|
1050
135
|
/**
|
|
1051
136
|
* Proxy a downstream remote MCP server. SDK clients and transports are scoped
|
|
1052
137
|
* to one inbound request: reused by calls within a batch/execute_code run, but
|
|
@@ -1058,1463 +143,19 @@ function missingDispatch() {
|
|
|
1058
143
|
* server or hide other connectors).
|
|
1059
144
|
*/
|
|
1060
145
|
export function remoteMcp(id, opts) {
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
const authorizationParams = authorizationParamsFor(opts.auth?.type === "oauth" ? opts.auth.authorizationParams : undefined, `remoteMcp(${JSON.stringify(id)}).auth.authorizationParams`);
|
|
1075
|
-
const staticClient = opts.auth?.type === "oauth" && opts.auth.client ? { ...opts.auth.client } : undefined;
|
|
1076
|
-
assertRemoteOAuthClient(id, staticClient);
|
|
1077
|
-
if (staticClient && clientMetadataUrl !== undefined)
|
|
1078
|
-
throw new Error(`[connecta] connector "${id}" OAuth client and clientMetadataUrl are mutually exclusive.`);
|
|
1079
|
-
if (clientMetadataUrl !== undefined) {
|
|
1080
|
-
let valid = false;
|
|
1081
|
-
try {
|
|
1082
|
-
const url = new URL(clientMetadataUrl);
|
|
1083
|
-
valid = url.protocol === "https:" && url.pathname !== "/" && !url.username && !url.password && !url.hash;
|
|
1084
|
-
}
|
|
1085
|
-
catch {
|
|
1086
|
-
/* Invalid configuration is rejected below without echoing its value. */
|
|
1087
|
-
}
|
|
1088
|
-
if (!valid)
|
|
1089
|
-
throw new Error(`[connecta] connector "${id}" clientMetadataUrl must be an HTTPS URL with a non-root path, without credentials or a fragment.`);
|
|
1090
|
-
}
|
|
1091
|
-
if (opts.authScope === "personal" && opts.auth?.type === "headers") {
|
|
1092
|
-
throw new Error(`[connecta] connector "${id}" cannot combine authScope "personal" ` +
|
|
1093
|
-
"with static headers. Use credential or OAuth auth so each principal " +
|
|
1094
|
-
"can own a different grant.");
|
|
1095
|
-
}
|
|
1096
|
-
// Weak keys ensure a completed request does not leave its SDK client,
|
|
1097
|
-
// transport, response bodies, AbortSignals, or connect attempt reachable
|
|
1098
|
-
// from the isolate singleton. Those are request-bound in Cloudflare Workers.
|
|
1099
|
-
// A closed scope keeps its (emptied) entry, so a late or future lookup finds
|
|
1100
|
-
// it closed rather than recreating an ownerless connection under it.
|
|
1101
|
-
const states = new WeakMap();
|
|
1102
|
-
// Post-connect auth checks keep the exact provider that bound this client,
|
|
1103
|
-
// even if another attempt has since replaced the request scope's state.
|
|
1104
|
-
const connectedProviders = new WeakMap();
|
|
1105
|
-
const cacheOwner = {};
|
|
1106
|
-
// A cached transport may dispatch concurrent calls and listings. Register
|
|
1107
|
-
// its actual sent auth with every operation using that client, including
|
|
1108
|
-
// handshake/discovery requests and OAuth rotations. Remove settled users.
|
|
1109
|
-
const activeContexts = new WeakMap();
|
|
1110
|
-
// Resource headers are non-secret context by contract. They never join the
|
|
1111
|
-
// sent set, so arguments and results equal to them pass unchanged.
|
|
1112
|
-
// An argument mirrored as `Mcp-Param-*` carrying a resource value is the
|
|
1113
|
-
// same context under a name the tracker may read as credential-shaped.
|
|
1114
|
-
const resourceValues = new Set(Object.values(extraHeaders ?? {}));
|
|
1115
|
-
const withoutResourceHeaders = (input, init) => {
|
|
1116
|
-
if (!extraHeaders)
|
|
1117
|
-
return init;
|
|
1118
|
-
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
1119
|
-
for (const [name, value] of Object.entries(extraHeaders))
|
|
1120
|
-
if (headers.get(name) === value)
|
|
1121
|
-
headers.delete(name);
|
|
1122
|
-
const mirrored = [];
|
|
1123
|
-
for (const [name, value] of headers)
|
|
1124
|
-
if (name.startsWith("mcp-param-") && resourceValues.has(value))
|
|
1125
|
-
mirrored.push(name);
|
|
1126
|
-
for (const name of mirrored)
|
|
1127
|
-
headers.delete(name);
|
|
1128
|
-
return { ...init, headers };
|
|
1129
|
-
};
|
|
1130
|
-
const trackSentRequest = (ctx, input, init) => {
|
|
1131
|
-
const active = activeContexts.get(entryFor(ctx));
|
|
1132
|
-
const recipients = new Set([sentSecretsFor(ctx), ...[...(active ?? [])].map(sentSecretsFor)]);
|
|
1133
|
-
const tracked = withoutResourceHeaders(input, init);
|
|
1134
|
-
for (const secrets of recipients) {
|
|
1135
|
-
if (opts.auth?.type === "oauth")
|
|
1136
|
-
trackRemoteClientRequest(secrets, input, tracked);
|
|
1137
|
-
else
|
|
1138
|
-
secrets.request(input, tracked);
|
|
1139
|
-
}
|
|
1140
|
-
};
|
|
1141
|
-
const connectingWaiters = new WeakMap();
|
|
1142
|
-
const isOauth = opts.auth?.type === "oauth";
|
|
1143
|
-
// Long-lived enough for distinct request scopes in this connector runtime to
|
|
1144
|
-
// join one token redemption. It owns no client, transport, or request state.
|
|
1145
|
-
const refreshCoordinatorFor = refreshCoordinatorsByPartition();
|
|
1146
|
-
const logger = opts.logger ?? console;
|
|
1147
|
-
const warnShortSecret = shortSecretWarning();
|
|
1148
|
-
warnShortSecret(staticClient?.clientSecret, logger);
|
|
1149
|
-
if (opts.auth?.type === "headers") {
|
|
1150
|
-
for (const [name, value] of Object.entries(opts.auth.headers)) {
|
|
1151
|
-
if (!/key|token|secret|password|auth|signature|session/i.test(name))
|
|
1152
|
-
continue;
|
|
1153
|
-
const basic = /^Basic\s+(.+)$/i.exec(value);
|
|
1154
|
-
if (basic) {
|
|
1155
|
-
try {
|
|
1156
|
-
const decoded = atob(basic[1]);
|
|
1157
|
-
const colon = decoded.indexOf(":");
|
|
1158
|
-
if (colon !== -1)
|
|
1159
|
-
warnShortSecret(decoded.slice(colon + 1), logger);
|
|
1160
|
-
}
|
|
1161
|
-
catch {
|
|
1162
|
-
/* Invalid Basic configuration is not a short password. */
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
else
|
|
1166
|
-
warnShortSecret(value.replace(/^(?:Bearer|token)\s+/i, ""), logger);
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
|
|
1170
|
-
if (credentialAuth?.credential?.fields?.length) {
|
|
1171
|
-
throw new Error(`[connecta] connector "${id}" credential auth declares named fields; ` +
|
|
1172
|
-
"this shape sends one secret in one header and reads the reserved " +
|
|
1173
|
-
"`value` field only.");
|
|
1174
|
-
}
|
|
1175
|
-
// Both request shapes, read field by field; a mixture is refused below.
|
|
1176
|
-
const requestAuth = opts.auth?.type === "request" ? opts.auth : undefined;
|
|
1177
|
-
// Opt-in only: without it the resolver keeps receiving the context alone.
|
|
1178
|
-
// `principal` replaces `token`; a malformed or mixed shape has no resolver.
|
|
1179
|
-
const forwarding = requestAuth?.principal;
|
|
1180
|
-
const requestToken = forwarding
|
|
1181
|
-
? requestAuth?.token === undefined && (forwarding.accept === "human" || forwarding.accept === "human-or-agent")
|
|
1182
|
-
? forwarding.token
|
|
1183
|
-
: undefined
|
|
1184
|
-
: requestAuth?.token;
|
|
1185
|
-
if (requestAuth && (typeof requestToken !== "function" || opts.authScope === "personal")) {
|
|
1186
|
-
throw new Error(`remoteMcp(${JSON.stringify(id)}) requires a request token callback and shared authScope.`);
|
|
1187
|
-
}
|
|
1188
|
-
const principalPolicy = forwarding?.accept;
|
|
1189
|
-
const customFetch = opts.fetch;
|
|
1190
|
-
// OAuth discovery, registration and token requests reach authorization
|
|
1191
|
-
// servers the downstream names, through the owned credential sender. A
|
|
1192
|
-
// custom fetch would split that traffic between two transports.
|
|
1193
|
-
if (customFetch !== undefined && (typeof customFetch !== "function" || opts.auth?.type === "oauth")) {
|
|
1194
|
-
throw new Error(`remoteMcp(${JSON.stringify(id)}).fetch must be a function, without OAuth auth.`);
|
|
1195
|
-
}
|
|
1196
|
-
// No delayed dispatch: a write counts as sent once it is handed to the function.
|
|
1197
|
-
const customSender = customFetch && { send: customFetch };
|
|
1198
|
-
if (requestAuth?.headers) {
|
|
1199
|
-
try {
|
|
1200
|
-
if (Object.keys(requestAuth.headers).some((name) => name.toLowerCase() === "authorization"))
|
|
1201
|
-
throw new Error();
|
|
1202
|
-
new Headers(requestAuth.headers);
|
|
1203
|
-
}
|
|
1204
|
-
catch {
|
|
1205
|
-
throw new Error(`remoteMcp(${JSON.stringify(id)}) requires valid request auth headers without Authorization.`);
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
const credentialConfig = credentialAuth?.credential ?? {
|
|
1209
|
-
label: "API key",
|
|
1210
|
-
};
|
|
1211
|
-
const credentialHeader = credentialAuth?.header?.trim() || "Authorization";
|
|
1212
|
-
// A structural mistake in the deployment file, beside the `fields` refusal
|
|
1213
|
-
// above: an unsendable header name is not worth discovering on the first
|
|
1214
|
-
// request, where only a runtime error can report it.
|
|
1215
|
-
if (credentialAuth && !HEADER_NAME_TOKEN.test(credentialHeader)) {
|
|
1216
|
-
throw new Error(`[connecta] connector "${id}" credential auth declares header ` +
|
|
1217
|
-
`"${credentialHeader}", which is not a valid HTTP field name.`);
|
|
1218
|
-
}
|
|
1219
|
-
// `undefined` means "not stated" and takes the bearer default; `null` and
|
|
1220
|
-
// `""` both mean "send the stored value verbatim", which some providers
|
|
1221
|
-
// require for a bare API key.
|
|
1222
|
-
const credentialScheme = credentialAuth === undefined || credentialAuth.scheme === undefined
|
|
1223
|
-
? "Bearer"
|
|
1224
|
-
: (credentialAuth.scheme?.trim() ?? "") || null;
|
|
1225
|
-
// Check the destination scheme once at construction: buildTransport (and the
|
|
1226
|
-
// SDK's fetch) attach any static credentials to every request, so an http://
|
|
1227
|
-
// endpoint sends bearer tokens / API keys in cleartext. Loopback is exempt
|
|
1228
|
-
// for local development.
|
|
1229
|
-
const destination = new URL(opts.url);
|
|
1230
|
-
// Only a web URL reaches a remote MCP server. Another scheme cannot, and a
|
|
1231
|
-
// wrapping one (`blob:https://…`) would also carry its inner URL, userinfo
|
|
1232
|
-
// and all, into every place the endpoint is reported; the value is not echoed.
|
|
1233
|
-
if (destination.protocol !== "https:" && destination.protocol !== "http:") {
|
|
1234
|
-
throw new Error(`[connecta] connector "${id}" url must be an http(s) URL.`);
|
|
1235
|
-
}
|
|
1236
|
-
const insecureDestination = destination.protocol !== "https:" && !isLoopbackHost(destination.hostname);
|
|
1237
|
-
if (insecureDestination) {
|
|
1238
|
-
if (opts.requireHttps || requestAuth) {
|
|
1239
|
-
throw new Error(`[connecta] connector "${id}" url ${opts.url} is not https:// (and not loopback) — refusing to connect (requireHttps).`);
|
|
1240
|
-
}
|
|
1241
|
-
// Both static shapes send a secret on every request; that an operator
|
|
1242
|
-
// typed one into the operator UI rather than a deployment file changes
|
|
1243
|
-
// who owns it, not whether the wire carries it in the clear.
|
|
1244
|
-
if (opts.auth?.type === "headers" || credentialAuth) {
|
|
1245
|
-
logger.warn(`[connecta] connector "${id}" sends static credentials to ${opts.url} over a non-https:// connection — those tokens will be transmitted in cleartext.`);
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
/**
|
|
1249
|
-
* Typed per-call auth signal. The `UnauthorizedError` behind it is not kept
|
|
1250
|
-
* as cause: the class is the whole verdict, and a logger rendering the
|
|
1251
|
-
* chain would render whatever text the error carried.
|
|
1252
|
-
*/
|
|
1253
|
-
const authRequiredError = () => new ConnectorCallError(isOauth ? "downstream_oauth_required" : "auth_required", `Connector "${id}" requires authorization — call authorize_connector({ connector: "${id}" }) and open the returned URL.`);
|
|
1254
|
-
/**
|
|
1255
|
-
* Tell a failure of one OAuth flow in connecta's words.
|
|
1256
|
-
*
|
|
1257
|
-
* The SDK builds these errors from what a downstream or its authorization
|
|
1258
|
-
* server sent: a refused registration quotes the response body whole, an
|
|
1259
|
-
* issuer mismatch quotes the issuers, a discovery failure the URL or the
|
|
1260
|
-
* metadata it could not accept. Any of them can echo what the request
|
|
1261
|
-
* carried, or text the server planted for an agent to read. What survives
|
|
1262
|
-
* is the step and host of the flow's last request, the classification, and
|
|
1263
|
-
* for a registration the status and a registered OAuth error code.
|
|
1264
|
-
* Anything not already in connecta's words is withheld, including a store's
|
|
1265
|
-
* error while the flow saves what it discovered, which can quote it.
|
|
1266
|
-
*/
|
|
1267
|
-
const withoutAuthorizationServerText = authMethod((ctx, err, trail, signals) => authFlow(function* () {
|
|
1268
|
-
const safeOrigin = authMethod((host) => authFlow(function* () {
|
|
1269
|
-
if (!host || sentSecretsFor(ctx).contains(host, true))
|
|
1270
|
-
return undefined;
|
|
1271
|
-
if (host === destination.origin || (staticClient && host === new URL(staticClient.issuer).origin))
|
|
1272
|
-
return host;
|
|
1273
|
-
try {
|
|
1274
|
-
return host === (yield authInvoke(newProvider(ctx), "validatedIssuerOrigin", []))
|
|
1275
|
-
? host
|
|
1276
|
-
: undefined;
|
|
1277
|
-
}
|
|
1278
|
-
catch {
|
|
1279
|
-
return undefined;
|
|
1280
|
-
}
|
|
1281
|
-
}));
|
|
1282
|
-
if (ownAbortReason(err, signals) && !hasSdkPayload(err))
|
|
1283
|
-
return err;
|
|
1284
|
-
if (err instanceof RegistrationRejectedError) {
|
|
1285
|
-
const code = registrationErrorCode(err.body);
|
|
1286
|
-
const origin = (yield authCall(safeOrigin, [trail.registration]));
|
|
1287
|
-
const facts = {
|
|
1288
|
-
step: "OAuth client registration",
|
|
1289
|
-
...(origin ? { origin } : {}),
|
|
1290
|
-
httpStatus: err.status,
|
|
1291
|
-
...(code ? { oauthError: code } : {}),
|
|
1292
|
-
};
|
|
1293
|
-
return attachFailureFacts(carryFailureFacts(err, resendAdvice(withheldAs(`Connector "${id}" could not register an OAuth client with ` +
|
|
1294
|
-
`${origin ?? "its authorization server"}: the ` +
|
|
1295
|
-
`registration endpoint answered HTTP ${err.status}` +
|
|
1296
|
-
`${code ? ` with OAuth error ${code}` : ""}. Its response is ` +
|
|
1297
|
-
"withheld because it can quote the request or anything the server " +
|
|
1298
|
-
"chose to add. Check the server's client registration policy.", downstreamCallError(err, undefined, trail.last?.retryAfterMs)), ", then retry authorization")), facts);
|
|
1299
|
-
}
|
|
1300
|
-
const leg = trail.last;
|
|
1301
|
-
const origin = (yield authCall(safeOrigin, [leg?.host]));
|
|
1302
|
-
const facts = leg
|
|
1303
|
-
? {
|
|
1304
|
-
step: `OAuth ${leg.step}`,
|
|
1305
|
-
...(origin ? { origin } : {}),
|
|
1306
|
-
...(leg.httpStatus ? { httpStatus: leg.httpStatus } : {}),
|
|
1307
|
-
}
|
|
1308
|
-
: { step: "OAuth flow" };
|
|
1309
|
-
if (err instanceof UnauthorizedError || err instanceof ConnectorCallError || err instanceof WithheldTextError) {
|
|
1310
|
-
return attachFailureFacts(downstreamCallError(err), facts);
|
|
1311
|
-
}
|
|
1312
|
-
const classified = downstreamCallError(err, leg?.httpStatus, leg?.retryAfterMs, leg?.step);
|
|
1313
|
-
if (err instanceof OAuthError ||
|
|
1314
|
-
(classified instanceof ConnectorCallError && classified.code === "provider_permission_denied")) {
|
|
1315
|
-
return attachFailureFacts(carryFailureFacts(err, classified), facts);
|
|
1316
|
-
}
|
|
1317
|
-
return attachFailureFacts(carryFailureFacts(err, resendAdvice(withheldAs(`Connector "${id}" OAuth ${leg ? `${leg.step}${origin ? ` with ${origin}` : ""}` : "flow"} ` +
|
|
1318
|
-
`failed${errorKind(err)}. The error is withheld because its text can ` +
|
|
1319
|
-
"quote what the server sent. Check the server's OAuth metadata.", classified), ", then retry authorization")), facts);
|
|
1320
|
-
}));
|
|
1321
|
-
const endpointOrigin = new URL(opts.url).origin;
|
|
1322
|
-
/**
|
|
1323
|
-
* The MCP boundary, where an error leaves the SDK at the handshake, a
|
|
1324
|
-
* `tools/list` page, or a `tools/call`. The request's own abort reason
|
|
1325
|
-
* passes first, by identity, then what `keepsItsText` allows. Anything else
|
|
1326
|
-
* (a parser's or validator's account, a non-4xx body, a runtime's message)
|
|
1327
|
-
* is told as the step, the endpoint's origin, the HTTP status if there was
|
|
1328
|
-
* one, and the error's class, classified as `verdict` would have been.
|
|
1329
|
-
*/
|
|
1330
|
-
const atMcpBoundary = (ctx, err, step, transport, signals) => {
|
|
1331
|
-
// An OAuth flow already attached the step and origin it failed at. The
|
|
1332
|
-
// outer MCP operation must not replace them with the endpoint's facts.
|
|
1333
|
-
if (failureRecord({}, err).step?.startsWith("OAuth "))
|
|
1334
|
-
return downstreamCallError(err);
|
|
1335
|
-
const httpStatus = err instanceof SdkHttpError ? err.status : undefined;
|
|
1336
|
-
// Skills operations retain the existing closed operator-record vocabulary.
|
|
1337
|
-
const facts = {
|
|
1338
|
-
...(step === "skills/list" || step === "resources/read" ? {} : { step }),
|
|
1339
|
-
origin: endpointOrigin,
|
|
1340
|
-
...(httpStatus ? { httpStatus } : {}),
|
|
1341
|
-
};
|
|
1342
|
-
const classified = downstreamCallError(redactSentSecrets(ctx, err), undefined, undefined, undefined, requestSentSecrets(ctx));
|
|
1343
|
-
const verdict = isOauth && classified instanceof ConnectorCallError && classified.code === "auth_required"
|
|
1344
|
-
? carryFailureFacts(err, authRequiredError())
|
|
1345
|
-
: classified;
|
|
1346
|
-
if (ownAbortReasonAsSdkReports(err, signals)) {
|
|
1347
|
-
return attachFailureFacts(carryFailureFacts(err, withheldAs(msg(err), verdict)), facts);
|
|
1348
|
-
}
|
|
1349
|
-
if (err instanceof InsufficientScopeError) {
|
|
1350
|
-
return attachFailureFacts(carryFailureFacts(err, verdict), facts);
|
|
1351
|
-
}
|
|
1352
|
-
if (requestAuth && httpStatus === 401) {
|
|
1353
|
-
return attachFailureFacts(new ConnectorCallError("auth_required", "The downstream rejected this request's Bearer token."), facts);
|
|
1354
|
-
}
|
|
1355
|
-
if (ownAbortReason(err, signals) || keepsItsText(err, transport)) {
|
|
1356
|
-
return attachFailureFacts(carryFailureFacts(err, redactSentSecrets(ctx, err instanceof UnauthorizedError || (ownAbortReason(err, signals) && !hasSdkPayload(err)) ? err : verdict)), facts);
|
|
1357
|
-
}
|
|
1358
|
-
// The SDK wraps a failure of connecta's own fetch (a refused redirect, an
|
|
1359
|
-
// unreachable host) in an error of its own, such as the version probe's;
|
|
1360
|
-
// that failure's words are connecta's, and they say what happened.
|
|
1361
|
-
const ours = connectaErrorWithin(err);
|
|
1362
|
-
if (ours) {
|
|
1363
|
-
return attachFailureFacts(carryFailureFacts(ours, withheldAs(ours.message, ours)), facts);
|
|
1364
|
-
}
|
|
1365
|
-
const status = httpStatus ? ` with HTTP ${httpStatus}` : "";
|
|
1366
|
-
return attachFailureFacts(carryFailureFacts(err, withheldAs(`Connector "${id}" ${step} with ${endpointOrigin} failed${status}` +
|
|
1367
|
-
`${errorKind(err)}${sdkCheckFailed(err)}. The error is withheld because ` +
|
|
1368
|
-
"its text can quote what the server sent.", verdict)), facts);
|
|
1369
|
-
};
|
|
1370
|
-
/**
|
|
1371
|
-
* Run every OAuth flow the SDK starts on `transport` inside a boundary of
|
|
1372
|
-
* its own: a trail only that flow's requests write to, and
|
|
1373
|
-
* `withoutAuthorizationServerText` on whatever the flow throws. Calls on
|
|
1374
|
-
* one client can each start a flow at once, so no flow's failure is ever
|
|
1375
|
-
* told by another flow's last request.
|
|
1376
|
-
*
|
|
1377
|
-
* Pinned against `@modelcontextprotocol/client` 2.3.1, whose transport
|
|
1378
|
-
* offers no public seam for this. A flow starts in exactly two places, each
|
|
1379
|
-
* handing `auth()` the fetch to use: the adapted provider's
|
|
1380
|
-
* `onUnauthorized` (a 401, at connect or on a live client), given the fetch
|
|
1381
|
-
* in its context, and `_stepUpAuthorize` (a 403 `insufficient_scope`),
|
|
1382
|
-
* which reads `this._fetchWithInit`. The callback's code exchange runs
|
|
1383
|
-
* outside both, and the callback route repeats nothing it throws. If an
|
|
1384
|
-
* upgrade moves either seam, this refuses to build the transport rather
|
|
1385
|
-
* than run flows unbounded.
|
|
1386
|
-
*/
|
|
1387
|
-
const boundOAuthFlows = (ctx, transport, endpoint,
|
|
1388
|
-
/** The request's and connection's signals: only their reasons pass as written. */
|
|
1389
|
-
signals, renewalSignal) => {
|
|
1390
|
-
const internals = transport;
|
|
1391
|
-
const adapted = internals._authProvider;
|
|
1392
|
-
const onUnauthorized = adapted?.onUnauthorized;
|
|
1393
|
-
const stepUp = internals._stepUpAuthorize;
|
|
1394
|
-
if (!adapted || typeof onUnauthorized !== "function" || typeof stepUp !== "function") {
|
|
1395
|
-
// Its own class, so that a status record, which carries no message, says so.
|
|
1396
|
-
throw new UnboundOAuthFlowsError(`[connecta] connector "${id}": the MCP client's transport no longer ` +
|
|
1397
|
-
"has the OAuth flow seams this release is pinned against, so its " +
|
|
1398
|
-
"flows cannot be bounded. Pin @modelcontextprotocol/client to the " +
|
|
1399
|
-
"version this release ships with.");
|
|
1400
|
-
}
|
|
1401
|
-
const flow = authMethod((start, signal) => authFlow(function* () {
|
|
1402
|
-
const trail = {};
|
|
1403
|
-
try {
|
|
1404
|
-
return (yield authCall(start, [
|
|
1405
|
-
(fetchFn) => tracedOAuthFetch(trail, endpoint, authMethod((input, init) => authFlow(function* () {
|
|
1406
|
-
trackSentRequest(ctx, input, init);
|
|
1407
|
-
return (yield authCall(fetchFn, [
|
|
1408
|
-
input,
|
|
1409
|
-
signal
|
|
1410
|
-
? {
|
|
1411
|
-
...init,
|
|
1412
|
-
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
|
1413
|
-
}
|
|
1414
|
-
: init,
|
|
1415
|
-
]));
|
|
1416
|
-
}))),
|
|
1417
|
-
]));
|
|
1418
|
-
}
|
|
1419
|
-
catch (err) {
|
|
1420
|
-
throw (yield authCall(withoutAuthorizationServerText, [ctx, err, trail, [...signals, signal]]));
|
|
1421
|
-
}
|
|
1422
|
-
}));
|
|
1423
|
-
adapted.onUnauthorized = authMethod((ctx) => authCall(flow, [
|
|
1424
|
-
authMethod((trace) => authFlow(function* () {
|
|
1425
|
-
// This seam receives every 401, during status, starts, and live
|
|
1426
|
-
// calls. Use the SDK's parser so the check covers exactly the
|
|
1427
|
-
// challenged URL it would honor. The provider separately drops
|
|
1428
|
-
// foreign cached resource metadata on every SDK discovery read,
|
|
1429
|
-
// including callbacks and flows without a challenge.
|
|
1430
|
-
const { resourceMetadataUrl } = extractWWWAuthenticateParams(ctx.response);
|
|
1431
|
-
if (resourceMetadataUrl && resourceMetadataUrl.origin !== destination.origin) {
|
|
1432
|
-
throw new RemoteMcpDestinationError(id, "resource metadata outside the configured resource origin is not allowed");
|
|
1433
|
-
}
|
|
1434
|
-
yield authInvoke(Reflect, "apply", [
|
|
1435
|
-
onUnauthorized,
|
|
1436
|
-
adapted,
|
|
1437
|
-
[{ ...ctx, fetchFn: trace(ctx.fetchFn ?? fetch) }],
|
|
1438
|
-
]);
|
|
1439
|
-
})),
|
|
1440
|
-
renewalSignal(ctx.response),
|
|
1441
|
-
]));
|
|
1442
|
-
internals._stepUpAuthorize = authMethod((challenge, retries) => authCall(flow, [
|
|
1443
|
-
authMethod((trace) => authFlow(function* () {
|
|
1444
|
-
const traced = trace(internals._fetchWithInit ?? fetch);
|
|
1445
|
-
const self = new Proxy(transport, {
|
|
1446
|
-
get: (target, key) => key === "_fetchWithInit" ? traced : Reflect.get(target, key, target),
|
|
1447
|
-
set: (target, key, value) => Reflect.set(target, key, value, target),
|
|
1448
|
-
});
|
|
1449
|
-
return (yield authInvoke(Reflect, "apply", [stepUp, self, [challenge, retries]]));
|
|
1450
|
-
})),
|
|
1451
|
-
]));
|
|
1452
|
-
};
|
|
1453
|
-
class OperatorDisconnectedError extends ConnectorCallError {
|
|
1454
|
-
constructor() {
|
|
1455
|
-
super("downstream_oauth_required", `Connector "${id}" was disconnected by an operator — explicitly start authorization to reconnect it.`);
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
const operatorDisconnectedError = () => new OperatorDisconnectedError();
|
|
1459
|
-
/**
|
|
1460
|
-
* The credential slot is declared but the vault has nothing in it (or has no
|
|
1461
|
-
* key to read it with). Deliberately the same `auth_required` code an absent
|
|
1462
|
-
* OAuth grant produces: the agent's next move is `authorize_connector`
|
|
1463
|
-
* either way, and that tool reads `connector.credential` to hand the
|
|
1464
|
-
* operator the connection in the UI when available instead of a consent URL.
|
|
1465
|
-
*/
|
|
1466
|
-
class CredentialRequiredError extends ConnectorCallError {
|
|
1467
|
-
constructor(message) {
|
|
1468
|
-
super("auth_required", message);
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
/**
|
|
1472
|
-
* A request resolver threw something untyped. Calls still fail with
|
|
1473
|
-
* `auth_required`, but status reports an error: the resolver's own outage
|
|
1474
|
-
* is not an authorization the operator can grant.
|
|
1475
|
-
*/
|
|
1476
|
-
class UnresolvedTokenError extends ConnectorCallError {
|
|
1477
|
-
constructor() {
|
|
1478
|
-
super("auth_required", "The request token could not be resolved.");
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
/**
|
|
1482
|
-
* Read this request's credential. Called before every connect and before
|
|
1483
|
-
* trusting any cached client, so an operator's replacement is picked up
|
|
1484
|
-
* without a redeploy. The value stays in the caller's local scope.
|
|
1485
|
-
*/
|
|
1486
|
-
const readCredential = authMethod((ctx, principal) => authFlow(function* () {
|
|
1487
|
-
if (requestAuth) {
|
|
1488
|
-
let value;
|
|
1489
|
-
try {
|
|
1490
|
-
// A fresh frozen input per resolution: one resolver cannot alter another's.
|
|
1491
|
-
value = (yield authInvoke(forwarding ?? requestAuth, "token", principal ? [ctx, Object.freeze({ principal })] : [ctx]));
|
|
1492
|
-
}
|
|
1493
|
-
catch (error) {
|
|
1494
|
-
if (error instanceof ConnectorCallError)
|
|
1495
|
-
throw error;
|
|
1496
|
-
throw new UnresolvedTokenError();
|
|
1497
|
-
}
|
|
1498
|
-
ctx.signal?.throwIfAborted();
|
|
1499
|
-
if (typeof value !== "string" ||
|
|
1500
|
-
!value.trim() ||
|
|
1501
|
-
Array.from(value).some((char) => {
|
|
1502
|
-
const code = char.charCodeAt(0);
|
|
1503
|
-
return code <= 32 || (code >= 127 && code <= 159);
|
|
1504
|
-
})) {
|
|
1505
|
-
throw new ConnectorCallError("auth_required", "The request token is empty or cannot be sent as a Bearer header.");
|
|
1506
|
-
}
|
|
1507
|
-
return value;
|
|
1508
|
-
}
|
|
1509
|
-
if (!ctx.credential) {
|
|
1510
|
-
throw new CredentialRequiredError(`Connector "${id}" needs an operator-managed credential, but ` +
|
|
1511
|
-
"credential storage is not configured. Configure vault in deployment code and redeploy. Call authorize_connector for recovery options.");
|
|
1512
|
-
}
|
|
1513
|
-
const value = (yield authInvoke(ctx.credential, "get", []))?.trim();
|
|
1514
|
-
if (!value) {
|
|
1515
|
-
throw new CredentialRequiredError(`Connector "${id}" has no stored credential — call ` +
|
|
1516
|
-
`authorize_connector({ connector: "${id}" }) and follow the ` +
|
|
1517
|
-
"operator handoff it returns.");
|
|
1518
|
-
}
|
|
1519
|
-
if (carriesIllegalHeaderChar(value)) {
|
|
1520
|
-
throw new CredentialRequiredError(`Connector "${id}"'s stored credential contains a character a header ` +
|
|
1521
|
-
"cannot carry (a line break or other control character). Call " +
|
|
1522
|
-
"authorize_connector for recovery options. When available, re-enter " +
|
|
1523
|
-
"it in this connection in the operator UI. The value is not shown or logged.");
|
|
1524
|
-
}
|
|
1525
|
-
return value;
|
|
1526
|
-
}));
|
|
1527
|
-
/**
|
|
1528
|
-
* Replace, never edit.
|
|
1529
|
-
*
|
|
1530
|
-
* Any error whose message quotes the credential — the raw value or the
|
|
1531
|
-
* framed header it becomes — is discarded whole and replaced with a fixed
|
|
1532
|
-
* sentence. Nothing is substringed, masked, or truncated out of the original:
|
|
1533
|
-
* a redaction that keeps part of a secret is still a leak, and the original
|
|
1534
|
-
* error is not worth one. This is defense in depth behind the validation
|
|
1535
|
-
* above, which is what keeps an unsendable value from reaching a transport
|
|
1536
|
-
* at all.
|
|
1537
|
-
*/
|
|
1538
|
-
const withoutCredential = (err, ...secrets) => {
|
|
1539
|
-
const quoted = secrets.filter((secret) => typeof secret === "string" && secret !== "");
|
|
1540
|
-
if (quoted.length === 0)
|
|
1541
|
-
return err;
|
|
1542
|
-
const seen = new Set();
|
|
1543
|
-
let current = err;
|
|
1544
|
-
while (current instanceof Error && !seen.has(current)) {
|
|
1545
|
-
seen.add(current);
|
|
1546
|
-
// Decided already, beneath the transport, and wrapped by the SDK on
|
|
1547
|
-
// the way up (the version probe's error, say).
|
|
1548
|
-
if (current instanceof CredentialRequiredError)
|
|
1549
|
-
return current;
|
|
1550
|
-
if (quoted.some((secret) => current instanceof Error && current.message.includes(secret))) {
|
|
1551
|
-
return new CredentialRequiredError(`Connector "${id}" could not send its stored credential as a ` +
|
|
1552
|
-
"header. Call authorize_connector for recovery options. When available, " +
|
|
1553
|
-
"re-enter it in this connection in the operator UI. " +
|
|
1554
|
-
"The value is not shown or logged.");
|
|
1555
|
-
}
|
|
1556
|
-
current = current.cause;
|
|
1557
|
-
}
|
|
1558
|
-
return err;
|
|
1559
|
-
};
|
|
1560
|
-
// Its own class, so that a status record, which carries no message, still says so.
|
|
1561
|
-
const scopeEndedError = () => new ScopeEndedError(`Connector "${id}" scope ended during connection.`);
|
|
1562
|
-
const requestOptions = (ctx) => ctx.timeoutMs || ctx.signal
|
|
1563
|
-
? {
|
|
1564
|
-
...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
|
|
1565
|
-
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
1566
|
-
}
|
|
1567
|
-
: undefined;
|
|
1568
|
-
/** Wrap the public request seam, before listTools aggregates or auto-caches.
|
|
1569
|
-
* SDK 2.3.1 treats an identical repeated page as completion. Refuse it here:
|
|
1570
|
-
* an advertised successor is never proof of a complete catalog (INV-8). */
|
|
1571
|
-
const listingContexts = new WeakMap();
|
|
1572
|
-
const installCatalogIntake = (client, ctx, cache) => {
|
|
1573
|
-
let toolPages = 0;
|
|
1574
|
-
const listingContext = (options) => {
|
|
1575
|
-
const { signal: _signal, timeoutMs: _timeout, ...base } = ctx;
|
|
1576
|
-
return ((options && listingContexts.get(options)) ?? {
|
|
1577
|
-
...base,
|
|
1578
|
-
requestScope: ctx.requestScope ?? ctx,
|
|
1579
|
-
...(options?.signal ? { signal: options.signal } : {}),
|
|
1580
|
-
...(options?.timeout ? { timeoutMs: options.timeout } : {}),
|
|
1581
|
-
});
|
|
1582
|
-
};
|
|
1583
|
-
const listTools = client.listTools.bind(client);
|
|
1584
|
-
client.listTools = authMethod((params, options) => authInvoke(cache, "withListing", [
|
|
1585
|
-
listingContext(options),
|
|
1586
|
-
authMethod(() => authFlow(function* () {
|
|
1587
|
-
const before = toolPages;
|
|
1588
|
-
let result;
|
|
1589
|
-
try {
|
|
1590
|
-
result = (yield authCall(listTools, [params, options]));
|
|
1591
|
-
}
|
|
1592
|
-
catch (error) {
|
|
1593
|
-
const bounded = atMcpBoundary(cache.currentContext(), error, "tools/list", client.transport, [
|
|
1594
|
-
cache.currentContext().signal,
|
|
1595
|
-
]);
|
|
1596
|
-
const transient = (error instanceof SdkHttpError && [502, 503, 504].includes(error.status)) ||
|
|
1597
|
-
(bounded instanceof ConnectorCallError && bounded.code === "unavailable");
|
|
1598
|
-
const fallback = transient && toolPages === before
|
|
1599
|
-
? (yield authInvoke(cache, "fallbackTools", []))
|
|
1600
|
-
: undefined;
|
|
1601
|
-
if (!fallback)
|
|
1602
|
-
throw error;
|
|
1603
|
-
return fallback;
|
|
1604
|
-
}
|
|
1605
|
-
if (toolPages !== before && params?.cursor === undefined) {
|
|
1606
|
-
const refresh = (yield authInvoke(cache, "completeCatalogRefresh", [result]));
|
|
1607
|
-
if (refresh) {
|
|
1608
|
-
try {
|
|
1609
|
-
(yield authCall(observeCompletedCatalogRefresh, [cache.currentContext(), refresh]));
|
|
1610
|
-
}
|
|
1611
|
-
catch (error) {
|
|
1612
|
-
logFailure(cache.currentContext().logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
return result;
|
|
1617
|
-
})),
|
|
1618
|
-
]));
|
|
1619
|
-
const listResources = client.listResources.bind(client);
|
|
1620
|
-
client.listResources = authMethod((params, options) => authInvoke(cache, "withListing", [
|
|
1621
|
-
listingContext(options),
|
|
1622
|
-
authMethod(() => authCall(listResources, [params, options])),
|
|
1623
|
-
]));
|
|
1624
|
-
const listResourceTemplates = client.listResourceTemplates.bind(client);
|
|
1625
|
-
client.listResourceTemplates = authMethod((params, options) => authInvoke(cache, "withListing", [
|
|
1626
|
-
listingContext(options),
|
|
1627
|
-
authMethod(() => authCall(listResourceTemplates, [params, options])),
|
|
1628
|
-
]));
|
|
1629
|
-
const request = client.request.bind(client);
|
|
1630
|
-
const walks = new WeakMap();
|
|
1631
|
-
client.request = authMethod((...args) => authFlow(function* () {
|
|
1632
|
-
const message = args[0];
|
|
1633
|
-
if (message.method !== "tools/list" &&
|
|
1634
|
-
message.method !== "resources/list" &&
|
|
1635
|
-
message.method !== "resources/templates/list")
|
|
1636
|
-
return yield authCall(request, args, client);
|
|
1637
|
-
const method = message.method;
|
|
1638
|
-
const ctx = cache.currentContext();
|
|
1639
|
-
const options = (args.length === 3 ? args[2] : args[1]);
|
|
1640
|
-
if (isClosed(entryFor(ctx)))
|
|
1641
|
-
throw scopeEndedError();
|
|
1642
|
-
if (options?.signal?.aborted)
|
|
1643
|
-
throw options.signal.reason;
|
|
1644
|
-
const key = options ?? message;
|
|
1645
|
-
let walk = walks.get(key);
|
|
1646
|
-
if (message.params?.cursor === undefined || !walk) {
|
|
1647
|
-
walk = { names: new Set(), cursors: new Set(), barren: 0, bytes: 0 };
|
|
1648
|
-
walks.set(key, walk);
|
|
1649
|
-
}
|
|
1650
|
-
let page;
|
|
1651
|
-
try {
|
|
1652
|
-
page = (yield authCall(request, [
|
|
1653
|
-
message,
|
|
1654
|
-
method === "tools/list"
|
|
1655
|
-
? CompatibleListToolsResultSchema
|
|
1656
|
-
: method === "resources/list"
|
|
1657
|
-
? specTypeSchemas.ListResourcesResult
|
|
1658
|
-
: specTypeSchemas.ListResourceTemplatesResult,
|
|
1659
|
-
options,
|
|
1660
|
-
]));
|
|
1661
|
-
}
|
|
1662
|
-
catch (error) {
|
|
1663
|
-
if (isCursorShapeError(error))
|
|
1664
|
-
throw new ConnectorCallError("connector_call_failed", "Downstream catalog nextCursor must be a string, null, or absent.", { retryable: false });
|
|
1665
|
-
throw error;
|
|
1666
|
-
}
|
|
1667
|
-
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, page);
|
|
1668
|
-
const clean = cache.intake(ctx, method, page);
|
|
1669
|
-
if (method === "tools/list")
|
|
1670
|
-
toolPages++;
|
|
1671
|
-
if (message.params?.cursor === undefined)
|
|
1672
|
-
observeCatalogFetch(ctx, Date.now(), method);
|
|
1673
|
-
const tools = catalogItems(method, clean).filter((item) => {
|
|
1674
|
-
const name = method === "tools/list"
|
|
1675
|
-
? item.name
|
|
1676
|
-
: method === "resources/list"
|
|
1677
|
-
? item.uri
|
|
1678
|
-
: item.uriTemplate;
|
|
1679
|
-
if (walk.names.has(name))
|
|
1680
|
-
return false;
|
|
1681
|
-
walk.names.add(name);
|
|
1682
|
-
return true;
|
|
1683
|
-
});
|
|
1684
|
-
walk.bytes += new TextEncoder().encode(JSON.stringify(tools)).byteLength;
|
|
1685
|
-
if (walk.names.size > MAX_CATALOG_TOOLS || walk.bytes > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
1686
|
-
throw new ConnectorCallError("connector_call_failed", "Downstream catalog exceeds the complete-catalog ceiling.", { retryable: false });
|
|
1687
|
-
}
|
|
1688
|
-
if (clean.nextCursor !== undefined) {
|
|
1689
|
-
if (walk.cursors.has(clean.nextCursor))
|
|
1690
|
-
throw new ConnectorCallError("connector_call_failed", "Downstream catalog pagination chain loops.", {
|
|
1691
|
-
retryable: false,
|
|
1692
|
-
});
|
|
1693
|
-
if (tools.length === 0 && ++walk.barren > 1)
|
|
1694
|
-
throw new ConnectorCallError("connector_call_failed", "Downstream catalog pagination is not advancing.", {
|
|
1695
|
-
retryable: false,
|
|
1696
|
-
});
|
|
1697
|
-
if (tools.length > 0)
|
|
1698
|
-
walk.barren = 0;
|
|
1699
|
-
walk.cursors.add(clean.nextCursor);
|
|
1700
|
-
}
|
|
1701
|
-
const field = method === "tools/list" ? "tools" : method === "resources/list" ? "resources" : "resourceTemplates";
|
|
1702
|
-
const result = { ...clean, [field]: tools };
|
|
1703
|
-
if (!walk.first)
|
|
1704
|
-
walk.first = result;
|
|
1705
|
-
else {
|
|
1706
|
-
walk.first.ttlMs = Math.min(walk.first.ttlMs, clean.ttlMs);
|
|
1707
|
-
if (clean.cacheScope !== "public")
|
|
1708
|
-
walk.first.cacheScope = "private";
|
|
1709
|
-
}
|
|
1710
|
-
observeCatalogExpiry(ctx, method, walk.first.ttlMs);
|
|
1711
|
-
return result;
|
|
1712
|
-
}));
|
|
1713
|
-
};
|
|
1714
|
-
/** This request scope's entry, open or closed, created on first sight. */
|
|
1715
|
-
const entryFor = (ctx) => {
|
|
1716
|
-
const owner = Context.get(viewServices(ctx), RequestScope);
|
|
1717
|
-
const key = owner;
|
|
1718
|
-
let state = states.get(key);
|
|
1719
|
-
if (!state) {
|
|
1720
|
-
state = {
|
|
1721
|
-
scope: Scope.makeUnsafe(),
|
|
1722
|
-
lease: null,
|
|
1723
|
-
client: null,
|
|
1724
|
-
transport: null,
|
|
1725
|
-
toolDefinitions: new Map(),
|
|
1726
|
-
dispatches: new Map(),
|
|
1727
|
-
connecting: null,
|
|
1728
|
-
authRequired: false,
|
|
1729
|
-
provider: null,
|
|
1730
|
-
connectedGeneration: null,
|
|
1731
|
-
attemptProvider: null,
|
|
1732
|
-
credentialDigest: null,
|
|
1733
|
-
};
|
|
1734
|
-
states.set(key, state);
|
|
1735
|
-
const owned = state;
|
|
1736
|
-
owner.own(() => {
|
|
1737
|
-
const closing = Scope.closeUnsafe(owned.scope, Exit.void);
|
|
1738
|
-
reset(owned);
|
|
1739
|
-
if (closing)
|
|
1740
|
-
detach(closing);
|
|
1741
|
-
});
|
|
1742
|
-
}
|
|
1743
|
-
return state;
|
|
1744
|
-
};
|
|
1745
|
-
const isClosed = (state) => state.scope.state._tag === "Closed";
|
|
1746
|
-
const stateFor = (ctx) => {
|
|
1747
|
-
const state = entryFor(ctx);
|
|
1748
|
-
if (isClosed(state))
|
|
1749
|
-
throw scopeEndedError();
|
|
1750
|
-
return state;
|
|
1751
|
-
};
|
|
1752
|
-
const newProvider = (ctx, state, signal = viewSignal(ctx, true)) => {
|
|
1753
|
-
if (state?.provider)
|
|
1754
|
-
return state.provider;
|
|
1755
|
-
const metadataUrl = staticClient ? undefined : (clientMetadataUrl ?? selfHostedClientUrl(ctx.publicUrl, id));
|
|
1756
|
-
const redirectUri = downstreamRedirectUri(ctx.baseUrl, ctx.publicUrl, id);
|
|
1757
|
-
const provider = new KvOAuthProvider(id, ctx.storage, redirectUri, refreshCoordinatorFor(ctx), ctx.allowAuthorization === true, oauthSealerFor(ctx), signal, JSON.stringify({
|
|
1758
|
-
url: new URL(opts.url).href,
|
|
1759
|
-
redirectUri,
|
|
1760
|
-
clientMetadata: downstreamClientMetadata(redirectUri, oauthScope, ctx.oauthClientName, staticClient ? remoteClientAuthMethod(staticClient) : "none"),
|
|
1761
|
-
...(authorizationParams.length ? { authorizationParams } : {}),
|
|
1762
|
-
authScope: opts.authScope ?? "shared",
|
|
1763
|
-
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
1764
|
-
redirects: opts.redirects ?? "none",
|
|
1765
|
-
clientMetadataUrl: metadataUrl,
|
|
1766
|
-
...(staticClient
|
|
1767
|
-
? {
|
|
1768
|
-
client: {
|
|
1769
|
-
issuer: staticClient.issuer,
|
|
1770
|
-
clientId: staticClient.clientId,
|
|
1771
|
-
tokenEndpointAuthMethod: staticClient.tokenEndpointAuthMethod,
|
|
1772
|
-
},
|
|
1773
|
-
}
|
|
1774
|
-
: {}),
|
|
1775
|
-
}), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), metadataUrl, oauthScope, undefined, {
|
|
1776
|
-
name: ctx.oauthClientName,
|
|
1777
|
-
client: staticClient,
|
|
1778
|
-
secrets: sentSecretsFor(ctx),
|
|
1779
|
-
authorizationParams,
|
|
1780
|
-
resourceOrigin: destination.origin,
|
|
1781
|
-
});
|
|
1782
|
-
if (state)
|
|
1783
|
-
state.provider = provider;
|
|
1784
|
-
return provider;
|
|
1785
|
-
};
|
|
1786
|
-
const buildTransport = (ctx, provider,
|
|
1787
|
-
/**
|
|
1788
|
-
* This attempt's assembled header value, for credential auth only — framed
|
|
1789
|
-
* by the caller so the raw secret is not passed around twice. Never
|
|
1790
|
-
* retained past the transport it configures.
|
|
1791
|
-
*/
|
|
1792
|
-
credentialFramed = null, signal = viewSignal(ctx, true),
|
|
1793
|
-
/** The raw value behind `credentialFramed`, under the same terms. */
|
|
1794
|
-
credentialValue = null,
|
|
1795
|
-
/** A code exchange never transfers ownership to the refresh coordinator. */
|
|
1796
|
-
exchangeOnly = false,
|
|
1797
|
-
/** The admitted principal `credentialValue` was resolved for, under the same terms. */
|
|
1798
|
-
principal = undefined) => {
|
|
1799
|
-
const resourceInit = (input, init) => {
|
|
1800
|
-
if (!extraHeaders)
|
|
1801
|
-
return init;
|
|
1802
|
-
const target = new URL(input instanceof Request ? input.url : String(input));
|
|
1803
|
-
const resource = new URL(opts.url);
|
|
1804
|
-
if (target.origin !== resource.origin || target.pathname !== resource.pathname)
|
|
1805
|
-
return init;
|
|
1806
|
-
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
1807
|
-
for (const [name, value] of Object.entries(extraHeaders))
|
|
1808
|
-
headers.set(name, value);
|
|
1809
|
-
return { ...init, headers };
|
|
1810
|
-
};
|
|
1811
|
-
const responseOwner = Context.get(viewServices(ctx), RequestScope);
|
|
1812
|
-
const initiatingSignal = ctx.signal;
|
|
1813
|
-
ctx = deriveContext(operationContext(ctx), signal ? { signal } : {});
|
|
1814
|
-
if (opts._transportFactory)
|
|
1815
|
-
return opts._transportFactory(ctx);
|
|
1816
|
-
const url = new URL(opts.url);
|
|
1817
|
-
// Missing records never acquire a fresh write attempt. Only active calls
|
|
1818
|
-
// for this exact tool, all classified read, can prove a recordless message
|
|
1819
|
-
// safe. Catalog hints alone cannot prove the invocation's served verdict.
|
|
1820
|
-
const untrackedRead = (params) => {
|
|
1821
|
-
if (!skillObject(params) || typeof params.name !== "string")
|
|
1822
|
-
return false;
|
|
1823
|
-
let read = false;
|
|
1824
|
-
for (const [dispatch, name] of entryFor(ctx).dispatches) {
|
|
1825
|
-
if (name !== params.name)
|
|
1826
|
-
continue;
|
|
1827
|
-
if (dispatch.write)
|
|
1828
|
-
return false;
|
|
1829
|
-
read = true;
|
|
1830
|
-
}
|
|
1831
|
-
return read;
|
|
1832
|
-
};
|
|
1833
|
-
// This transport's calls on the wire, by JSON-RPC id, while each is sent.
|
|
1834
|
-
const flights = new Map();
|
|
1835
|
-
const trackedFetch = authMethod((input, init) => authFlow(function* () {
|
|
1836
|
-
let rpc;
|
|
1837
|
-
if (typeof init?.body === "string") {
|
|
1838
|
-
try {
|
|
1839
|
-
rpc = JSON.parse(init.body);
|
|
1840
|
-
}
|
|
1841
|
-
catch { }
|
|
1842
|
-
}
|
|
1843
|
-
const toolCall = init?.method === "POST" && skillObject(rpc) && rpc.method === "tools/call" ? rpc : undefined;
|
|
1844
|
-
const writeContinuation = downstreamWriteContinuation(ctx.requestScope ?? ctx) && toolCall !== undefined;
|
|
1845
|
-
const flight = toolCall && (typeof toolCall.id === "string" || typeof toolCall.id === "number")
|
|
1846
|
-
? flights.get(toolCall.id)
|
|
1847
|
-
: undefined;
|
|
1848
|
-
if (toolCall && !flight && !untrackedRead(toolCall.params))
|
|
1849
|
-
throw missingDispatch();
|
|
1850
|
-
const credentialPost = isOauth && (init?.method ?? "GET").toUpperCase() === "POST" && (!skillObject(rpc) || rpc.jsonrpc !== "2.0");
|
|
1851
|
-
// A custom transport reaches only the configured origin; redirects are
|
|
1852
|
-
// already same-origin, and OAuth cannot be combined with it.
|
|
1853
|
-
if (customFetch && new URL(input instanceof Request ? input.url : String(input)).origin !== destination.origin)
|
|
1854
|
-
throw new ConnectorCallError("connector_call_failed", `Connector "${id}" refused a request outside its origin; nothing was sent.`, { retryable: false });
|
|
1855
|
-
// The deployment's fetch owns the wire: each write is handed over once.
|
|
1856
|
-
const sender = flight?.write ? (customSender ?? guardedTransport(true)) : undefined;
|
|
1857
|
-
if (flight?.write) {
|
|
1858
|
-
if (flight.sent) {
|
|
1859
|
-
if (flight.request && flight.authorization)
|
|
1860
|
-
renewedAfterUnauthorized(flight.request.entry, flight.authorization, new Headers(init?.headers).get("authorization") ?? flight.authorization);
|
|
1861
|
-
throw resendRefused(flight);
|
|
1862
|
-
}
|
|
1863
|
-
flight.authorization = new Headers(init?.headers).get("authorization");
|
|
1864
|
-
}
|
|
1865
|
-
const request = flight?.request;
|
|
1866
|
-
if (request?.entry.dispatched)
|
|
1867
|
-
request.entry = providerRequest(request.ctx, !flight?.write, request.entry.provider, {
|
|
1868
|
-
method: init?.method ?? "GET",
|
|
1869
|
-
});
|
|
1870
|
-
const entry = request?.entry;
|
|
1871
|
-
const dispatch = () => {
|
|
1872
|
-
if (flight?.write) {
|
|
1873
|
-
if (flight.sent)
|
|
1874
|
-
throw resendRefused(flight);
|
|
1875
|
-
flight.sent = true;
|
|
1876
|
-
}
|
|
1877
|
-
if (request && entry) {
|
|
1878
|
-
if (flight?.sourceWrite)
|
|
1879
|
-
recordRequestField(entry, "sourceWrite", true);
|
|
1880
|
-
dispatchProviderRequest(request.ctx, entry, init?.headers, init?.method ?? "GET");
|
|
1881
|
-
}
|
|
1882
|
-
};
|
|
1883
|
-
trackSentRequest(ctx, input, init);
|
|
1884
|
-
// Attach retained settlement facts at the physical Promise leaf.
|
|
1885
|
-
// Cancellation may end the native waiter before it resumes; neither
|
|
1886
|
-
// the status nor a 401 verdict may disappear with that live authority.
|
|
1887
|
-
const response = (yield authValue(() => {
|
|
1888
|
-
const pending = credentialPost
|
|
1889
|
-
? sendCredentialPost(input, init ?? {}, dispatch)
|
|
1890
|
-
: sender
|
|
1891
|
-
? sendGuarded(sender, new URL(input instanceof Request ? input.url : String(input)), { ...init, redirect: "manual" }, dispatch)
|
|
1892
|
-
: (() => {
|
|
1893
|
-
dispatch();
|
|
1894
|
-
const send = customFetch ?? fetch;
|
|
1895
|
-
return writeContinuation ? send(input, { ...init, redirect: "manual" }) : send(input, init);
|
|
1896
|
-
})();
|
|
1897
|
-
return pending.then((response) => {
|
|
1898
|
-
// RPC fetch adapters can return a disposable Response. Own it at
|
|
1899
|
-
// the physical Promise leaf, including a late answer after close,
|
|
1900
|
-
// and keep its body live until the existing request boundary.
|
|
1901
|
-
const dispose = response[Symbol.dispose];
|
|
1902
|
-
if (dispose)
|
|
1903
|
-
responseOwner.own(() => dispose.call(response));
|
|
1904
|
-
if (entry)
|
|
1905
|
-
recordProviderResponse(entry, response.status);
|
|
1906
|
-
if (flight?.write) {
|
|
1907
|
-
if (response.status === 401)
|
|
1908
|
-
flight.unauthorized = true;
|
|
1909
|
-
else if (REDIRECT_STATUSES.has(response.status))
|
|
1910
|
-
flight.redirected = true;
|
|
1911
|
-
}
|
|
1912
|
-
if (init?.signal?.aborted || request?.ctx.signal?.aborted)
|
|
1913
|
-
void response.body?.cancel().catch(() => { });
|
|
1914
|
-
return response;
|
|
1915
|
-
});
|
|
1916
|
-
}));
|
|
1917
|
-
if (response.status === 401 && requestToken?.invalidate && credentialValue !== null) {
|
|
1918
|
-
// Invalidate the exact bytes this transport sent, never a newer token.
|
|
1919
|
-
// Preserve the original 401 even if an operator hook fails. No replay.
|
|
1920
|
-
(yield authCatch(authInvoke(requestToken, "invalidate", principal ? [credentialValue, ctx, Object.freeze({ principal })] : [credentialValue, ctx]), () => { }));
|
|
1921
|
-
}
|
|
1922
|
-
if (credentialPost && !isOAuthCredentialRequest(init) && REDIRECT_STATUSES.has(response.status)) {
|
|
1923
|
-
(yield authCatch(authValue(() => response.body?.cancel()), () => { }));
|
|
1924
|
-
throw new ConnectorCallError("connector_call_failed", "OAuth registration endpoint redirected; the request was not repeated.", { retryable: false });
|
|
1925
|
-
}
|
|
1926
|
-
if (writeContinuation && (response.status === 401 || (response.status >= 300 && response.status < 400))) {
|
|
1927
|
-
(yield authCatch(authValue(() => response.body?.cancel()), () => { }));
|
|
1928
|
-
if (response.status === 401)
|
|
1929
|
-
throw authRequiredError();
|
|
1930
|
-
throw new ConnectorCallError("connector_call_failed", "The downstream redirected a write continuation; it was not repeated.", { retryable: false, writeOutcome: "unknown" });
|
|
1931
|
-
}
|
|
1932
|
-
if (flight?.redirected) {
|
|
1933
|
-
(yield authCatch(authValue(() => response.body?.cancel()), () => { }));
|
|
1934
|
-
throw new ConnectorCallError("connector_call_failed", "The downstream answered this write with a redirect; it was not sent again.", { retryable: false, writeOutcome: "unknown" });
|
|
1935
|
-
}
|
|
1936
|
-
if (skillsEnabled && skillObject(rpc) && (rpc.method === "skills/list" || rpc.method === "resources/read")) {
|
|
1937
|
-
return (yield authCall(boundedSkillResponse, [
|
|
1938
|
-
response,
|
|
1939
|
-
rpc.method === "skills/list" ? MAX_SERIALIZED_CATALOG_BYTES : MAX_SKILL_READ_RPC_BYTES,
|
|
1940
|
-
rpc.id,
|
|
1941
|
-
init?.signal,
|
|
1942
|
-
]));
|
|
1943
|
-
}
|
|
1944
|
-
return response;
|
|
1945
|
-
}));
|
|
1946
|
-
// A runtime refusing the assembled header quotes it, and the transport
|
|
1947
|
-
// error below keeps none of what the runtime said. So whether the
|
|
1948
|
-
// rejection quoted the credential is decided here, first, and survives
|
|
1949
|
-
// as the class of the error rather than as its text.
|
|
1950
|
-
const guardedFetch = redirectSafeFetch(id, opts.redirects, credentialFramed === null
|
|
1951
|
-
? trackedFetch
|
|
1952
|
-
: authMethod((input, init) => authFlow(function* () {
|
|
1953
|
-
try {
|
|
1954
|
-
return (yield authCall(trackedFetch, [input, init]));
|
|
1955
|
-
}
|
|
1956
|
-
catch (err) {
|
|
1957
|
-
throw withoutCredential(err, credentialValue, credentialFramed);
|
|
1958
|
-
}
|
|
1959
|
-
})));
|
|
1960
|
-
if (opts.auth?.type === "oauth") {
|
|
1961
|
-
const oauthProvider = provider ?? newProvider(ctx, undefined, signal);
|
|
1962
|
-
// The session/provider belong to the request. A renewal belongs to the
|
|
1963
|
-
// exact call whose HTTP response challenged it, until claim submission.
|
|
1964
|
-
// Carry that ownership on the response the SDK passes to onUnauthorized;
|
|
1965
|
-
// a shared mutable "current call" would borrow a concurrent call's lease.
|
|
1966
|
-
const renewalSignals = new WeakMap();
|
|
1967
|
-
const exchangeFetch = (send) => exchangeOnly
|
|
1968
|
-
? authMethod((input, init) => authView(ctx, authMethod((requestSignal) => authFlow(function* () {
|
|
1969
|
-
const response = (yield authCall(send, [
|
|
1970
|
-
input,
|
|
1971
|
-
{
|
|
1972
|
-
...init,
|
|
1973
|
-
signal: init?.signal ? AbortSignal.any([requestSignal, init.signal]) : requestSignal,
|
|
1974
|
-
},
|
|
1975
|
-
]));
|
|
1976
|
-
if (requestSignal.aborted) {
|
|
1977
|
-
void response.body?.cancel().catch(() => { });
|
|
1978
|
-
throw requestSignal.reason;
|
|
1979
|
-
}
|
|
1980
|
-
return codeExchangeResponse(response, requestSignal);
|
|
1981
|
-
})), true))
|
|
1982
|
-
: send;
|
|
1983
|
-
const coordinatedFetch = refreshCoordinatorFor(ctx).coordinatedFetch(oauthProvider, exchangeFetch(learnedUrlSafeFetch(id, url, trackedFetch)), signal, ctx.defer, exchangeFetch(learnedUrlSafeFetch(id, url, authMethod((input, init) => authCall(guardedFetch, [input, resourceInit(input, init)])))), () => {
|
|
1984
|
-
guardedTransport(true);
|
|
1985
|
-
});
|
|
1986
|
-
const transport = new StreamableHTTPClientTransport(url, {
|
|
1987
|
-
authProvider: oauthProvider,
|
|
1988
|
-
onInsufficientScope: "throw",
|
|
1989
|
-
fetch: authMethod((input, init) => authFlow(function* () {
|
|
1990
|
-
const response = (yield authCall(coordinatedFetch, [input, init]));
|
|
1991
|
-
if (response.status === 401) {
|
|
1992
|
-
let owner;
|
|
1993
|
-
if (typeof init?.body === "string") {
|
|
1994
|
-
try {
|
|
1995
|
-
const rpc = JSON.parse(init.body);
|
|
1996
|
-
if (skillObject(rpc)) {
|
|
1997
|
-
if (rpc.method === "initialize" || rpc.method === "server/discover")
|
|
1998
|
-
owner = initiatingSignal;
|
|
1999
|
-
else if (typeof rpc.id === "string" || typeof rpc.id === "number")
|
|
2000
|
-
owner = flights.get(rpc.id)?.request?.ctx.signal;
|
|
2001
|
-
}
|
|
2002
|
-
}
|
|
2003
|
-
catch { }
|
|
2004
|
-
}
|
|
2005
|
-
const ownedSignal = owner && init?.signal ? AbortSignal.any([owner, init.signal]) : (owner ?? init?.signal);
|
|
2006
|
-
if (ownedSignal)
|
|
2007
|
-
renewalSignals.set(response, ownedSignal);
|
|
2008
|
-
}
|
|
2009
|
-
return response;
|
|
2010
|
-
})),
|
|
2011
|
-
});
|
|
2012
|
-
boundOAuthFlows(ctx, transport, url, [signal, ctx.signal], (response) => renewalSignals.get(response));
|
|
2013
|
-
return sendingEachWriteOnce(transport, flights, untrackedRead);
|
|
2014
|
-
}
|
|
2015
|
-
const headers = opts.auth?.type === "headers"
|
|
2016
|
-
? opts.auth.headers
|
|
2017
|
-
: (credentialAuth || requestAuth) && credentialFramed !== null
|
|
2018
|
-
? { ...requestAuth?.headers, [credentialHeader]: credentialFramed }
|
|
2019
|
-
: undefined;
|
|
2020
|
-
return sendingEachWriteOnce(new StreamableHTTPClientTransport(url, {
|
|
2021
|
-
...(headers ? { requestInit: { headers } } : {}),
|
|
2022
|
-
fetch: authMethod((input, init) => authCall(guardedFetch, [input, resourceInit(input, init)])),
|
|
2023
|
-
}), flights, untrackedRead);
|
|
2024
|
-
};
|
|
2025
|
-
/**
|
|
2026
|
-
* Admit a write's call to the wire once, whatever JSON-RPC id a later
|
|
2027
|
-
* message for it carries, and show `trackedFetch` which call each message
|
|
2028
|
-
* belongs to while it is sent. A missing record fails closed unless active
|
|
2029
|
-
* calls prove the tool is read (INV-1, INV-9).
|
|
2030
|
-
*/
|
|
2031
|
-
const sendingEachWriteOnce = (transport, flights, untrackedRead) => {
|
|
2032
|
-
const send = transport.send.bind(transport);
|
|
2033
|
-
transport.send = authMethod((message, options) => authFlow(function* () {
|
|
2034
|
-
const request = !Array.isArray(message) && "method" in message && message.method === "tools/call" && "id" in message
|
|
2035
|
-
? message
|
|
2036
|
-
: undefined;
|
|
2037
|
-
if (!request)
|
|
2038
|
-
return (yield authCall(send, [message, options]));
|
|
2039
|
-
const dispatch = dispatchOf(request.params);
|
|
2040
|
-
if (!dispatch) {
|
|
2041
|
-
if (!untrackedRead(request.params))
|
|
2042
|
-
throw missingDispatch();
|
|
2043
|
-
return (yield authCall(send, [message, options]));
|
|
2044
|
-
}
|
|
2045
|
-
if (dispatch.write && dispatch.sent)
|
|
2046
|
-
throw resendRefused(dispatch);
|
|
2047
|
-
flights.set(request.id, dispatch);
|
|
2048
|
-
try {
|
|
2049
|
-
return (yield authCall(send, [message, options]));
|
|
2050
|
-
}
|
|
2051
|
-
finally {
|
|
2052
|
-
flights.delete(request.id);
|
|
2053
|
-
}
|
|
2054
|
-
}));
|
|
2055
|
-
return transport;
|
|
2056
|
-
};
|
|
2057
|
-
const reset = (state) => {
|
|
2058
|
-
state.lease = null;
|
|
2059
|
-
state.client = null;
|
|
2060
|
-
state.transport = null;
|
|
2061
|
-
state.toolDefinitions.clear();
|
|
2062
|
-
state.connecting = null;
|
|
2063
|
-
state.authRequired = false;
|
|
2064
|
-
state.provider = null;
|
|
2065
|
-
state.connectedGeneration = null;
|
|
2066
|
-
state.credentialDigest = null;
|
|
2067
|
-
// `scope` is deliberately left as it is — see ConnectionState.
|
|
2068
|
-
};
|
|
2069
|
-
// Detached exits start this bounded best-effort tail immediately;
|
|
2070
|
-
// closeScope awaits its own tail so the core
|
|
2071
|
-
// can pass it to the runtime's deferred channel. Terminating and closing are
|
|
2072
|
-
// each bounded, so no close waits more than two seconds in all.
|
|
2073
|
-
const closingSessions = new WeakMap();
|
|
2074
|
-
const closeConnection = (client, transport, logger) => Effect.suspend(() => {
|
|
2075
|
-
const previous = transport && closingSessions.get(transport);
|
|
2076
|
-
if (previous)
|
|
2077
|
-
return Deferred.await(previous);
|
|
2078
|
-
const closed = Deferred.makeUnsafe();
|
|
2079
|
-
// A connect can acquire a session after an early close. Deduplicate only
|
|
2080
|
-
// once that session exists, so its late abandonment still sends DELETE.
|
|
2081
|
-
if (transport?.sessionId)
|
|
2082
|
-
closingSessions.set(transport, closed);
|
|
2083
|
-
return (transport ? terminateSession(transport, logger, id) : Effect.void).pipe(Effect.andThen(closeLocally(client, transport)), Effect.ensuring(Deferred.done(closed, Exit.void)));
|
|
2084
|
-
});
|
|
2085
|
-
/**
|
|
2086
|
-
* Forget the live connection and close its lease without waiting. A connect
|
|
2087
|
-
* still in flight is unpublished with it, and sees at its next check that it
|
|
2088
|
-
* has been abandoned.
|
|
2089
|
-
*/
|
|
2090
|
-
const closeHalf = (state) => {
|
|
2091
|
-
const lease = state.lease;
|
|
2092
|
-
reset(state);
|
|
2093
|
-
const release = lease && Scope.closeUnsafe(lease, Exit.void);
|
|
2094
|
-
if (release)
|
|
2095
|
-
detach(release);
|
|
2096
|
-
};
|
|
2097
|
-
const waitForConnection = (attempt, signal) => runEdge(Deferred.await(attempt), signal ? { signal } : {});
|
|
2098
|
-
const ensureConnected = authMethod((ctx, state) => authFlow(function* () {
|
|
2099
|
-
let joined = state.connecting;
|
|
2100
|
-
if (joined)
|
|
2101
|
-
connectingWaiters.set(joined, (connectingWaiters.get(joined) ?? 0) + 1);
|
|
2102
|
-
const leave = () => {
|
|
2103
|
-
if (!joined)
|
|
2104
|
-
return;
|
|
2105
|
-
const remaining = (connectingWaiters.get(joined) ?? 1) - 1;
|
|
2106
|
-
if (remaining > 0)
|
|
2107
|
-
connectingWaiters.set(joined, remaining);
|
|
2108
|
-
else {
|
|
2109
|
-
connectingWaiters.delete(joined);
|
|
2110
|
-
if (ctx.signal?.aborted && state.connecting === joined)
|
|
2111
|
-
closeHalf(state);
|
|
2112
|
-
}
|
|
2113
|
-
};
|
|
2114
|
-
try {
|
|
2115
|
-
if (state.authRequired) {
|
|
2116
|
-
throw authRequiredError();
|
|
2117
|
-
}
|
|
2118
|
-
let oauthGeneration;
|
|
2119
|
-
if (isOauth && (state.client || state.connecting)) {
|
|
2120
|
-
const provider = newProvider(ctx, state, openRequest(ctx.requestScope ?? ctx).signal);
|
|
2121
|
-
oauthGeneration = (yield authView(ctx, authMethod(() => authInvoke(provider, "liveEpoch", []))));
|
|
2122
|
-
if (provider.isOperatorDisconnectedEpoch(oauthGeneration)) {
|
|
2123
|
-
closeHalf(state);
|
|
2124
|
-
throw operatorDisconnectedError();
|
|
2125
|
-
}
|
|
2126
|
-
}
|
|
2127
|
-
if (state.client && oauthGeneration !== undefined && state.connectedGeneration !== null) {
|
|
2128
|
-
if (isClosed(state))
|
|
2129
|
-
throw scopeEndedError();
|
|
2130
|
-
if (oauthGeneration !== state.connectedGeneration) {
|
|
2131
|
-
closeHalf(state);
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
let credentialValue = null;
|
|
2135
|
-
let credentialFramed = null;
|
|
2136
|
-
let credentialDigest = null;
|
|
2137
|
-
// Only the admitted caller on this operation's context, never an argument.
|
|
2138
|
-
// Refused before the resolver runs, so no token is minted for anyone else.
|
|
2139
|
-
const principal = principalPolicy
|
|
2140
|
-
? requestPrincipalFor(principalPolicy, ctx, (reason) => new CredentialRequiredError(`Connector "${id}" ${reason}.`))
|
|
2141
|
-
: undefined;
|
|
2142
|
-
if (credentialAuth || requestAuth) {
|
|
2143
|
-
credentialValue = (yield authCall(readCredential, [ctx, principal]));
|
|
2144
|
-
credentialFramed = credentialHeaderValue(credentialScheme, credentialValue);
|
|
2145
|
-
// The principal joins the client's identity: another principal never
|
|
2146
|
-
// reuses this client, even if its resolver returns the same token.
|
|
2147
|
-
credentialDigest = (yield authCall(sha256Hex, [
|
|
2148
|
-
principal ? JSON.stringify([principal, credentialValue]) : credentialValue,
|
|
2149
|
-
]));
|
|
2150
|
-
if ((state.client || state.connecting) &&
|
|
2151
|
-
state.credentialDigest !== null &&
|
|
2152
|
-
state.credentialDigest !== credentialDigest) {
|
|
2153
|
-
const observedConnection = state.client ?? state.connecting;
|
|
2154
|
-
const currentCredential = (yield authCall(readCredential, [ctx, principal]));
|
|
2155
|
-
if (isClosed(state))
|
|
2156
|
-
throw scopeEndedError();
|
|
2157
|
-
if (currentCredential !== credentialValue || (state.client ?? state.connecting) !== observedConnection) {
|
|
2158
|
-
throw resendAdvice(new ConnectorCallError("connector_call_failed", "The downstream credential changed while connecting.", {
|
|
2159
|
-
retryable: true,
|
|
2160
|
-
}), "; retry the operation");
|
|
2161
|
-
}
|
|
2162
|
-
closeHalf(state);
|
|
2163
|
-
}
|
|
2164
|
-
}
|
|
2165
|
-
viewSignal(ctx).throwIfAborted();
|
|
2166
|
-
if (isClosed(state))
|
|
2167
|
-
throw scopeEndedError();
|
|
2168
|
-
if (state.client)
|
|
2169
|
-
return state.client;
|
|
2170
|
-
const attempt = state.connecting ?? startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest, principal);
|
|
2171
|
-
if (joined !== attempt) {
|
|
2172
|
-
leave();
|
|
2173
|
-
joined = attempt;
|
|
2174
|
-
connectingWaiters.set(attempt, (connectingWaiters.get(attempt) ?? 0) + 1);
|
|
2175
|
-
}
|
|
2176
|
-
const client = (yield authCall(waitForConnection, [attempt, ctx.signal]));
|
|
2177
|
-
if (isClosed(state))
|
|
2178
|
-
throw scopeEndedError();
|
|
2179
|
-
return client;
|
|
2180
|
-
}
|
|
2181
|
-
finally {
|
|
2182
|
-
leave();
|
|
2183
|
-
}
|
|
2184
|
-
}));
|
|
2185
|
-
/**
|
|
2186
|
-
* Start this scope's connect attempt, publishing it before any of it runs.
|
|
2187
|
-
* The attempt completes its Deferred itself, and every caller — the one that
|
|
2188
|
-
* started it included — waits on that; nothing awaits the run directly,
|
|
2189
|
-
* which is why handing it to `detach` orphans nothing.
|
|
2190
|
-
*/
|
|
2191
|
-
const startConnect = (ctx, state, credentialValue, credentialFramed, credentialDigest, principal) => {
|
|
2192
|
-
const attempt = Deferred.makeUnsafe();
|
|
2193
|
-
state.connecting = attempt;
|
|
2194
|
-
// Published with the attempt, not with its result: a rotation that lands
|
|
2195
|
-
// while this connect is in flight has to be visible to the next caller,
|
|
2196
|
-
// which would otherwise wait on a client bound to the older key.
|
|
2197
|
-
state.credentialDigest = credentialDigest;
|
|
2198
|
-
detach(connect(ctx, state, attempt, credentialValue, credentialFramed, principal).pipe(
|
|
2199
|
-
// The connection belongs to this request, without the first waiter's
|
|
2200
|
-
// call/credential/budget authority. Refresh coordinators stay separate.
|
|
2201
|
-
Effect.provideService(RequestScope, Context.get(viewServices(ctx), RequestScope)), Effect.onExit((exit) => Effect.sync(() => {
|
|
2202
|
-
// Force reset may have abandoned this attempt and installed a new
|
|
2203
|
-
// one in the same request scope. An old completion must not erase
|
|
2204
|
-
// the new attempt and allow a third concurrent connect.
|
|
2205
|
-
if (state.connecting === attempt)
|
|
2206
|
-
state.connecting = null;
|
|
2207
|
-
// Last: a Deferred resumes its waiters inside this call.
|
|
2208
|
-
Deferred.doneUnsafe(attempt, exit);
|
|
2209
|
-
}))));
|
|
2210
|
-
return attempt;
|
|
2211
|
-
};
|
|
2212
|
-
const connect = (ctx, state, attempt, credentialValue, credentialFramed, principal) => {
|
|
2213
|
-
// Force reset, rotation, and teardown all abandon an attempt the same way:
|
|
2214
|
-
// they unpublish it and close the lease it holds, if it holds one yet.
|
|
2215
|
-
const owned = () => state.connecting === attempt && !isClosed(state);
|
|
2216
|
-
return Effect.gen(function* () {
|
|
2217
|
-
// The transport and its OAuth provider are shared by every call in this
|
|
2218
|
-
// scope. A call's deadline signal is aborted even on success, so retaining
|
|
2219
|
-
// the first caller's signal here would abort its siblings and later calls.
|
|
2220
|
-
// The provider uses the connection's lifetime. Each challenged response
|
|
2221
|
-
// separately binds renewal preparation to the call that initiated it.
|
|
2222
|
-
const connectionAbort = new AbortController();
|
|
2223
|
-
// Own cancellation before beginFlow's storage reads.
|
|
2224
|
-
// The lease first owns just the signal, then the transport it creates.
|
|
2225
|
-
const lease = Scope.forkUnsafe(state.scope);
|
|
2226
|
-
const handshakeAbort = new AbortController();
|
|
2227
|
-
const held = {
|
|
2228
|
-
client: null,
|
|
2229
|
-
transport: null,
|
|
2230
|
-
};
|
|
2231
|
-
yield* Scope.addFinalizer(lease, Effect.sync(() => handshakeAbort.abort()).pipe(Effect.andThen(Effect.suspend(() => {
|
|
2232
|
-
if (!held.transport)
|
|
2233
|
-
connectionAbort.abort();
|
|
2234
|
-
return closeConnection(held.client, held.transport, ctx.logger);
|
|
2235
|
-
})),
|
|
2236
|
-
// Let session termination use the OAuth fetch wrapper before ending
|
|
2237
|
-
// its lifetime. Local transport close aborts all active MCP fetches.
|
|
2238
|
-
Effect.ensuring(Effect.sync(() => connectionAbort.abort()))));
|
|
2239
|
-
state.lease = lease;
|
|
2240
|
-
return yield* Effect.gen(function* () {
|
|
2241
|
-
// A provider belongs to exactly one connect attempt. A force reset can
|
|
2242
|
-
// abandon that attempt while its transport still holds the provider;
|
|
2243
|
-
// the replacement must never mutate the abandoned provider's epoch.
|
|
2244
|
-
const provider = isOauth ? newProvider(ctx, undefined, connectionAbort.signal) : null;
|
|
2245
|
-
// Every OAuth run of this attempt — a 401's refresh or consent, a
|
|
2246
|
-
// step-up — happens inside the SDK, bound to the epoch live now: a
|
|
2247
|
-
// reset meanwhile fails the attempt rather than handing it the next
|
|
2248
|
-
// epoch's grant.
|
|
2249
|
-
const genAtStart = provider
|
|
2250
|
-
? yield* promised(authMethod(() => authInvoke(provider, "beginFlow", [])))
|
|
2251
|
-
: "";
|
|
2252
|
-
if (!owned())
|
|
2253
|
-
return yield* Effect.fail(scopeEndedError());
|
|
2254
|
-
if (provider)
|
|
2255
|
-
state.attemptProvider = provider;
|
|
2256
|
-
if (provider?.isOperatorDisconnectedEpoch(genAtStart)) {
|
|
2257
|
-
return yield* Effect.fail(operatorDisconnectedError());
|
|
2258
|
-
}
|
|
2259
|
-
// SDK v2 selects its validator by runtime export condition: AJV on
|
|
2260
|
-
// Node and @cfworker/json-schema under workerd. The Workers-safe path
|
|
2261
|
-
// no longer needs Connecta-specific wiring.
|
|
2262
|
-
const oauthConfig = isOauth
|
|
2263
|
-
? {
|
|
2264
|
-
client: staticClient,
|
|
2265
|
-
clientMetadataUrl: staticClient
|
|
2266
|
-
? undefined
|
|
2267
|
-
: (clientMetadataUrl ?? selfHostedClientUrl(ctx.publicUrl, id)),
|
|
2268
|
-
redirectUri: downstreamRedirectUri(ctx.baseUrl, ctx.publicUrl, id),
|
|
2269
|
-
clientName: ctx.oauthClientName,
|
|
2270
|
-
}
|
|
2271
|
-
: undefined;
|
|
2272
|
-
const negotiationDigest = yield* promised(authMethod(() => authCall(sha256Hex, [
|
|
2273
|
-
JSON.stringify([
|
|
2274
|
-
opts.url,
|
|
2275
|
-
opts.versionNegotiation ?? "auto",
|
|
2276
|
-
opts.auth?.type,
|
|
2277
|
-
opts.auth?.type === "headers" ? opts.auth.headers : requestAuth?.headers,
|
|
2278
|
-
...(extraHeaders ? [extraHeaders] : []),
|
|
2279
|
-
credentialHeader,
|
|
2280
|
-
credentialScheme,
|
|
2281
|
-
opts.authScope ?? "shared",
|
|
2282
|
-
opts.redirects ?? "none",
|
|
2283
|
-
oauthConfig,
|
|
2284
|
-
oauthScope,
|
|
2285
|
-
state.credentialDigest,
|
|
2286
|
-
genAtStart,
|
|
2287
|
-
callerOf(ctx),
|
|
2288
|
-
skillsEnabled === true,
|
|
2289
|
-
]),
|
|
2290
|
-
])));
|
|
2291
|
-
const prior = opts.versionNegotiation === "legacy"
|
|
2292
|
-
? undefined
|
|
2293
|
-
: yield* promised(authMethod(() => authCall(readNegotiation, [ctx, negotiationDigest])));
|
|
2294
|
-
if (!owned())
|
|
2295
|
-
return yield* Effect.fail(scopeEndedError());
|
|
2296
|
-
const cacheOptions = yield* promised(authMethod(() => authCall(catalogClientOptions, [
|
|
2297
|
-
ctx,
|
|
2298
|
-
id,
|
|
2299
|
-
JSON.stringify({
|
|
2300
|
-
url: new URL(opts.url).href,
|
|
2301
|
-
auth: opts.auth?.type ?? null,
|
|
2302
|
-
headers: opts.auth?.type === "headers" ? opts.auth.headers : requestAuth?.headers,
|
|
2303
|
-
...(extraHeaders ? { extraHeaders } : {}),
|
|
2304
|
-
credentialHeader,
|
|
2305
|
-
credentialScheme,
|
|
2306
|
-
authScope: opts.authScope ?? "shared",
|
|
2307
|
-
redirects: opts.redirects ?? "none",
|
|
2308
|
-
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
2309
|
-
oauthConfig,
|
|
2310
|
-
oauthScope,
|
|
2311
|
-
}),
|
|
2312
|
-
JSON.stringify([genAtStart, state.credentialDigest]),
|
|
2313
|
-
connectionAbort.signal,
|
|
2314
|
-
cacheOwner,
|
|
2315
|
-
requestAuth || opts.authScope === "personal" ? "private" : "shared",
|
|
2316
|
-
provider
|
|
2317
|
-
? authMethod(() => authFlow(function* () {
|
|
2318
|
-
const tokens = yield authInvoke(provider, "tokens", []);
|
|
2319
|
-
return yield authCall(sha256Hex, [JSON.stringify(tokens)]);
|
|
2320
|
-
}))
|
|
2321
|
-
: undefined,
|
|
2322
|
-
])));
|
|
2323
|
-
if (!owned())
|
|
2324
|
-
return yield* Effect.fail(scopeEndedError());
|
|
2325
|
-
const makeClient = () => {
|
|
2326
|
-
const { intake: _intake, completeCatalogRefresh: _refresh, withListing: _listing, currentContext: _context, ...clientOptions } = cacheOptions;
|
|
2327
|
-
const client = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
|
|
2328
|
-
...clientOptions,
|
|
2329
|
-
listMaxPages: MAX_TOOL_PAGES,
|
|
2330
|
-
versionNegotiation: {
|
|
2331
|
-
mode: opts.versionNegotiation ?? "auto",
|
|
2332
|
-
},
|
|
2333
|
-
capabilities: {
|
|
2334
|
-
...downstreamInputCapabilities(ctx.requestScope ?? ctx),
|
|
2335
|
-
...(skillsEnabled ? { extensions: { [SKILLS_EXTENSION]: {} } } : {}),
|
|
2336
|
-
},
|
|
2337
|
-
// The host owns each sealed continuation; never auto-retry a write.
|
|
2338
|
-
inputRequired: { autoFulfill: false },
|
|
2339
|
-
});
|
|
2340
|
-
installCatalogIntake(client, ctx, cacheOptions);
|
|
2341
|
-
return client;
|
|
2342
|
-
};
|
|
2343
|
-
let c = makeClient();
|
|
2344
|
-
let t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
|
|
2345
|
-
if (!owned()) {
|
|
2346
|
-
held.transport = t;
|
|
2347
|
-
return yield* Effect.fail(scopeEndedError());
|
|
2348
|
-
}
|
|
2349
|
-
state.transport = t;
|
|
2350
|
-
held.transport = t;
|
|
2351
|
-
recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
|
|
2352
|
-
yield* promised(authMethod(() => authFlow(function* () {
|
|
2353
|
-
try {
|
|
2354
|
-
(yield authInvoke(c, "connect", [
|
|
2355
|
-
t,
|
|
2356
|
-
{ signal: handshakeAbort.signal, ...(prior ? { prior } : {}) },
|
|
2357
|
-
]));
|
|
2358
|
-
}
|
|
2359
|
-
catch (error) {
|
|
2360
|
-
if (prior ||
|
|
2361
|
-
opts.versionNegotiation === "legacy" ||
|
|
2362
|
-
!(error instanceof SdkHttpError) ||
|
|
2363
|
-
error.code !== SdkErrorCode.EraNegotiationFailed ||
|
|
2364
|
-
error.status < 500 ||
|
|
2365
|
-
error.status >= 600 ||
|
|
2366
|
-
!owned() ||
|
|
2367
|
-
handshakeAbort.signal.aborted)
|
|
2368
|
-
throw error;
|
|
2369
|
-
c = makeClient();
|
|
2370
|
-
t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
|
|
2371
|
-
state.transport = held.transport = t;
|
|
2372
|
-
recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
|
|
2373
|
-
(yield authInvoke(c, "connect", [
|
|
2374
|
-
t,
|
|
2375
|
-
{ signal: handshakeAbort.signal, prior: { kind: "legacy" } },
|
|
2376
|
-
]));
|
|
2377
|
-
}
|
|
2378
|
-
}))).pipe(Effect.mapError((err) => atMcpBoundary(ctx,
|
|
2379
|
-
// First, while the runtime's text can still be read for it.
|
|
2380
|
-
withoutCredential(err, credentialValue, credentialFramed), "MCP handshake", t, [ctx.signal, connectionAbort.signal, handshakeAbort.signal])));
|
|
2381
|
-
held.client = c;
|
|
2382
|
-
// A probe deadline can end its scope while connect is still in flight.
|
|
2383
|
-
// The transport is closed immediately by closeScope; if connect wins
|
|
2384
|
-
// that race anyway, close the resulting client rather than resurrecting
|
|
2385
|
-
// a session in the detached state object.
|
|
2386
|
-
if (!owned())
|
|
2387
|
-
return yield* Effect.fail(scopeEndedError());
|
|
2388
|
-
// A force re-auth that landed WHILE we were connecting wiped the
|
|
2389
|
-
// credentials this client just bound to. Discard it rather than cache
|
|
2390
|
-
// a stale-isolate connection.
|
|
2391
|
-
if (provider) {
|
|
2392
|
-
const generation = yield* promised(authMethod(() => authInvoke(provider, "liveEpoch", []))).pipe(Effect.mapError((error) => (owned() ? error : scopeEndedError())));
|
|
2393
|
-
// closeScope can land while the epoch read is pending, after
|
|
2394
|
-
// connect succeeded but before this client is cached. Discard the
|
|
2395
|
-
// client on that side of the await too.
|
|
2396
|
-
if (!owned())
|
|
2397
|
-
return yield* Effect.fail(scopeEndedError());
|
|
2398
|
-
if (generation !== genAtStart) {
|
|
2399
|
-
return yield* Effect.fail(new UnauthorizedError("Connector was re-authorized during connect; reconnect required."));
|
|
2400
|
-
}
|
|
2401
|
-
}
|
|
2402
|
-
state.client = c;
|
|
2403
|
-
if (provider)
|
|
2404
|
-
connectedProviders.set(c, provider);
|
|
2405
|
-
state.connectedGeneration = genAtStart;
|
|
2406
|
-
state.authRequired = false;
|
|
2407
|
-
const discover = c.getDiscoverResult();
|
|
2408
|
-
if (!prior)
|
|
2409
|
-
yield* promised(authMethod(() => authCall(storeNegotiation, [
|
|
2410
|
-
ctx,
|
|
2411
|
-
negotiationDigest,
|
|
2412
|
-
discover ? { kind: "modern", discover } : { kind: "legacy" },
|
|
2413
|
-
])));
|
|
2414
|
-
if (!owned())
|
|
2415
|
-
return yield* Effect.fail(scopeEndedError());
|
|
2416
|
-
return c;
|
|
2417
|
-
}).pipe(Effect.catchDefect(Effect.fail), Effect.catch((err) => {
|
|
2418
|
-
// Close the failed connection through its client, without waiting.
|
|
2419
|
-
if (owned()) {
|
|
2420
|
-
state.lease = null;
|
|
2421
|
-
state.transport = null;
|
|
2422
|
-
// Only a real 401/UnauthorizedError means auth is the problem —
|
|
2423
|
-
// a network error on an oauth connector must surface as "error",
|
|
2424
|
-
// not "auth_required".
|
|
2425
|
-
if (requiresAuthorization(err))
|
|
2426
|
-
state.authRequired = true;
|
|
2427
|
-
const release = Scope.closeUnsafe(lease, Exit.void);
|
|
2428
|
-
if (release)
|
|
2429
|
-
detach(release);
|
|
2430
|
-
}
|
|
2431
|
-
else {
|
|
2432
|
-
// Whoever abandoned this attempt closed the lease already, while
|
|
2433
|
-
// the connect was in flight. A session it acquired after that
|
|
2434
|
-
// close still owes its DELETE.
|
|
2435
|
-
detach(closeConnection(held.client, held.transport, ctx.logger));
|
|
2436
|
-
}
|
|
2437
|
-
if (err instanceof UnauthorizedError) {
|
|
2438
|
-
return Effect.fail(carryFailureFacts(err, authRequiredError()));
|
|
2439
|
-
}
|
|
2440
|
-
// Defense in depth for the one error class that can quote the
|
|
2441
|
-
// credential: a runtime refusing the assembled header.
|
|
2442
|
-
// `readCredential` already rejects a value that cannot ride one, so
|
|
2443
|
-
// reaching this is a gap in that check rather than a routine
|
|
2444
|
-
// outcome.
|
|
2445
|
-
return Effect.fail(withoutCredential(err, credentialValue, credentialFramed));
|
|
2446
|
-
}));
|
|
2447
|
-
});
|
|
2448
|
-
};
|
|
2449
|
-
const disconnectAuthorization = authMethod((ctx, state, operatorDisconnected = false, preserveClient = false) => authFlow(function* () {
|
|
2450
|
-
const provider = newProvider(ctx, state);
|
|
2451
|
-
const reset = operatorDisconnected
|
|
2452
|
-
? authInvoke(provider, "disconnectAuthorization", [
|
|
2453
|
-
learnedUrlSafeFetch(id, new URL(opts.url), authMethod((input, init) => authFlow(function* () {
|
|
2454
|
-
trackSentRequest(ctx, input, init);
|
|
2455
|
-
return (yield authCall(sendCredentialPost, [input, init ?? {}]));
|
|
2456
|
-
}))),
|
|
2457
|
-
])
|
|
2458
|
-
: authInvoke(provider, "resetAuthorization", [false, preserveClient]);
|
|
2459
|
-
try {
|
|
2460
|
-
yield reset;
|
|
2461
|
-
}
|
|
2462
|
-
finally {
|
|
2463
|
-
closeHalf(state);
|
|
2464
|
-
}
|
|
2465
|
-
}));
|
|
2466
|
-
/**
|
|
2467
|
-
* Whether the owner's grant holds tokens, read the way the invocation gate
|
|
2468
|
-
* reads it. A downstream may answer its handshake and listing without
|
|
2469
|
-
* credentials, so a healthy connection proves nothing about the grant.
|
|
2470
|
-
* Read after connecting: connect has bound the epoch, migrated an older
|
|
2471
|
-
* layout, and refused a disconnected one within the scope's lifetime.
|
|
2472
|
-
*/
|
|
2473
|
-
const granted = authMethod((provider) => authFlow(function* () {
|
|
2474
|
-
return Boolean(yield authInvoke(provider, "tokens", []));
|
|
2475
|
-
}));
|
|
2476
|
-
/**
|
|
2477
|
-
* Start consent for a downstream that answered without challenging. The
|
|
2478
|
-
* transport's bound 401 seam runs the SDK flow exactly as a challenge
|
|
2479
|
-
* without `resource_metadata` would: RFC 9728 discovery at the resource's
|
|
2480
|
-
* well-known URLs, the same issuer and resource checks, consent state, and
|
|
2481
|
-
* CIMD/DCR/static client order. Nothing is sent to the MCP endpoint.
|
|
2482
|
-
*/
|
|
2483
|
-
const startUnchallenged = authMethod((ctx, connectedProvider) => authFlow(function* () {
|
|
2484
|
-
const required = {
|
|
2485
|
-
state: "auth_required",
|
|
2486
|
-
message: "Authorization required \u2014 open the URL to connect.",
|
|
2487
|
-
};
|
|
2488
|
-
// A test transport carries no OAuth flow to start.
|
|
2489
|
-
if (opts._transportFactory)
|
|
2490
|
-
return required;
|
|
2491
|
-
// A cached connection may belong to a passive status or call. Give this
|
|
2492
|
-
// explicit start its own authorization-capable provider without widening
|
|
2493
|
-
// the transport's authority or adopting a Restart's replacement epoch.
|
|
2494
|
-
const epoch = (yield authInvoke(connectedProvider, "flowEpoch", []));
|
|
2495
|
-
const provider = newProvider(ctx);
|
|
2496
|
-
(yield authInvoke(provider, "beginFlow", [epoch]));
|
|
2497
|
-
// Never started, this transport holds no session or stream to close.
|
|
2498
|
-
const transport = buildTransport(ctx, provider);
|
|
2499
|
-
const internals = transport;
|
|
2500
|
-
try {
|
|
2501
|
-
(yield authInvoke(internals._authProvider, "onUnauthorized", [
|
|
2502
|
-
{
|
|
2503
|
-
response: new Response(null, { status: 401 }),
|
|
2504
|
-
serverUrl: new URL(opts.url),
|
|
2505
|
-
fetchFn: internals._fetchWithInit,
|
|
2506
|
-
},
|
|
2507
|
-
]));
|
|
2508
|
-
// Authorized without consent: nothing is pending to hand back.
|
|
2509
|
-
return { state: "ok", message: "Already authorized \u2014 connection is healthy." };
|
|
2510
|
-
}
|
|
2511
|
-
catch (err) {
|
|
2512
|
-
if (!(err instanceof UnauthorizedError))
|
|
2513
|
-
throw err;
|
|
2514
|
-
}
|
|
2515
|
-
const authorizationUrl = (yield authInvoke(provider, "consentUrl", [viewSignal(ctx, true)]));
|
|
2516
|
-
return { ...required, ...(authorizationUrl !== undefined ? { authorizationUrl } : {}) };
|
|
2517
|
-
}));
|
|
146
|
+
// Each seam's factory contributes to one runtime, in this order; none calls
|
|
147
|
+
// another seam until an operation runs, after construction completes.
|
|
148
|
+
const rt = remoteMcpConfig(id, opts);
|
|
149
|
+
opts = rt.opts;
|
|
150
|
+
const { clientMetadataUrl, requestAuth, credentialAuth, credentialHeader, credentialScheme, isOauth, authorizationParams, oauthScope, extraHeaders, skillsEnabled, credentialConfig, principalPolicy, staticClient, } = rt;
|
|
151
|
+
Object.assign(rt, remoteMcpErrors(rt));
|
|
152
|
+
Object.assign(rt, remoteMcpTransport(rt));
|
|
153
|
+
Object.assign(rt, remoteMcpConnection(rt));
|
|
154
|
+
Object.assign(rt, remoteMcpOAuth(rt));
|
|
155
|
+
Object.assign(rt, remoteMcpCatalog(rt));
|
|
156
|
+
Object.assign(rt, remoteMcpResources(rt));
|
|
157
|
+
Object.assign(rt, remoteMcpCall(rt));
|
|
158
|
+
const { listSkills, readSkill, liveListings, listToolsOperation, callToolOperation, readResourceOperation, entryFor, cacheOwner, reset, stateFor, newProvider, resourceTemplateRefusals, ensureConnected, granted, connectedProviders, CredentialRequiredError, OperatorDisconnectedError, UnresolvedTokenError, oauthSurface, activeContexts, states, isClosed, scopeEndedError, operatorDisconnectedError, authRequiredError, renewBeforeCall, } = rt;
|
|
2518
159
|
// Built once from construction-time values: names, framing, and public
|
|
2519
160
|
// URLs only. A static header's value and a stored credential never reach
|
|
2520
161
|
// it, and a URL keeps origin and path: a CIMD document's query may carry a
|
|
@@ -2555,39 +196,6 @@ export function remoteMcp(id, opts) {
|
|
|
2555
196
|
opts[key] === undefined ? "default" : "config",
|
|
2556
197
|
])),
|
|
2557
198
|
});
|
|
2558
|
-
const resourceTemplateRefusals = new Set();
|
|
2559
|
-
const skillScopeOpen = (ctx, state) => {
|
|
2560
|
-
if (isClosed(state))
|
|
2561
|
-
throw scopeEndedError();
|
|
2562
|
-
if (ctx.signal?.aborted)
|
|
2563
|
-
throw ctx.signal.reason instanceof Error ? ctx.signal.reason : scopeEndedError();
|
|
2564
|
-
};
|
|
2565
|
-
const withSkillsClient = authMethod((ctx, method, run) => authFlow(function* () {
|
|
2566
|
-
const state = stateFor(ctx);
|
|
2567
|
-
const client = (yield authCall(ensureConnected, [ctx, state]));
|
|
2568
|
-
try {
|
|
2569
|
-
skillScopeOpen(ctx, state);
|
|
2570
|
-
const capabilities = client.getServerCapabilities();
|
|
2571
|
-
const extension = capabilities?.extensions?.[SKILLS_EXTENSION];
|
|
2572
|
-
if (client.getProtocolEra() !== "modern" ||
|
|
2573
|
-
!skillObject(capabilities?.resources) ||
|
|
2574
|
-
!skillObject(extension) ||
|
|
2575
|
-
(extension.directoryRead !== undefined && typeof extension.directoryRead !== "boolean")) {
|
|
2576
|
-
throw new ConnectorCallError("connector_call_failed", "The downstream did not declare a valid Skills extension and resources capability.");
|
|
2577
|
-
}
|
|
2578
|
-
const result = (yield authCall(run, [client, state]));
|
|
2579
|
-
skillScopeOpen(ctx, state);
|
|
2580
|
-
return result;
|
|
2581
|
-
}
|
|
2582
|
-
catch (error) {
|
|
2583
|
-
if (ownAbortReason(error, [ctx.signal]) && !hasSdkPayload(error))
|
|
2584
|
-
throw error;
|
|
2585
|
-
const classified = atMcpBoundary(ctx, error, method, client.transport, [ctx.signal]);
|
|
2586
|
-
if (requiresAuthorization(classified) && state.client === client)
|
|
2587
|
-
state.authRequired = true;
|
|
2588
|
-
throw carryFailureFacts(classified, withheldAs(`Connector "${id}" downstream ${method} failed.`, classified));
|
|
2589
|
-
}
|
|
2590
|
-
}));
|
|
2591
199
|
const connector = {
|
|
2592
200
|
id,
|
|
2593
201
|
...(opts.title !== undefined ? { title: opts.title } : {}),
|
|
@@ -2612,52 +220,8 @@ export function remoteMcp(id, opts) {
|
|
|
2612
220
|
...(skillsEnabled
|
|
2613
221
|
? {
|
|
2614
222
|
downstreamSkills: {
|
|
2615
|
-
list:
|
|
2616
|
-
|
|
2617
|
-
"skills/list",
|
|
2618
|
-
authMethod((client, state) => authFlow(function* () {
|
|
2619
|
-
const listed = [];
|
|
2620
|
-
const uris = new Set();
|
|
2621
|
-
const cursors = new Set();
|
|
2622
|
-
let cursor;
|
|
2623
|
-
let bytes = 0;
|
|
2624
|
-
for (let page = 0; page < MAX_SKILLS; page++) {
|
|
2625
|
-
skillScopeOpen(ctx, state);
|
|
2626
|
-
const result = (yield authInvoke(client, "request", [
|
|
2627
|
-
{ method: "skills/list", ...(cursor === undefined ? {} : { params: { cursor } }) },
|
|
2628
|
-
SkillPageSchema,
|
|
2629
|
-
requestOptions(ctx),
|
|
2630
|
-
]));
|
|
2631
|
-
bytes += result.bytes;
|
|
2632
|
-
if (bytes > MAX_SERIALIZED_CATALOG_BYTES || listed.length + result.skills.length > MAX_SKILLS) {
|
|
2633
|
-
throw new ConnectorCallError("connector_call_failed", "Downstream Skills listing exceeds the aggregate limit.");
|
|
2634
|
-
}
|
|
2635
|
-
for (const skill of result.skills) {
|
|
2636
|
-
if (uris.has(skill.uri))
|
|
2637
|
-
throw new ConnectorCallError("connector_call_failed", "Downstream Skills listing contains duplicate skill URIs.");
|
|
2638
|
-
uris.add(skill.uri);
|
|
2639
|
-
listed.push(skill);
|
|
2640
|
-
}
|
|
2641
|
-
if (result.nextCursor === undefined)
|
|
2642
|
-
return listed;
|
|
2643
|
-
if (!result.skills.length || cursors.has(result.nextCursor)) {
|
|
2644
|
-
throw new ConnectorCallError("connector_call_failed", "Downstream Skills pagination loops or makes no progress.");
|
|
2645
|
-
}
|
|
2646
|
-
cursors.add(result.nextCursor);
|
|
2647
|
-
cursor = result.nextCursor;
|
|
2648
|
-
}
|
|
2649
|
-
throw new ConnectorCallError("connector_call_failed", "Downstream Skills listing exceeds the page limit.");
|
|
2650
|
-
})),
|
|
2651
|
-
])),
|
|
2652
|
-
read: authMethod((uri, ctx) => authCall(withSkillsClient, [
|
|
2653
|
-
ctx,
|
|
2654
|
-
"resources/read",
|
|
2655
|
-
authMethod((client) => authInvoke(client, "request", [
|
|
2656
|
-
{ method: "resources/read", params: { uri } },
|
|
2657
|
-
skillReadSchema(uri),
|
|
2658
|
-
requestOptions(ctx),
|
|
2659
|
-
])),
|
|
2660
|
-
])),
|
|
223
|
+
list: listSkills,
|
|
224
|
+
read: readSkill,
|
|
2661
225
|
},
|
|
2662
226
|
}
|
|
2663
227
|
: {}),
|
|
@@ -2670,289 +234,35 @@ export function remoteMcp(id, opts) {
|
|
|
2670
234
|
credential: credentialConfig,
|
|
2671
235
|
/**
|
|
2672
236
|
* The honest test for a proxy is the catalog: connect with the
|
|
2673
|
-
*
|
|
237
|
+
* given value and count what the downstream serves. Nothing else
|
|
2674
238
|
* here is connecta's to verify — the credential's scope, project,
|
|
2675
|
-
* and mode are the provider's answer, not ours.
|
|
239
|
+
* and mode are the provider's answer, not ours. The value may be a
|
|
240
|
+
* candidate the operator has not saved: the probe's view sends it,
|
|
241
|
+
* never the stored one, and nothing about it is written.
|
|
2676
242
|
*/
|
|
2677
|
-
testCredential:
|
|
243
|
+
testCredential: (value, ctx) => credentialProbe(ctx, { value }, (view) => runEdge(authFlow(function* () {
|
|
2678
244
|
try {
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
return {
|
|
2682
|
-
ok: false,
|
|
2683
|
-
message: "This connector tests the credential that is currently " +
|
|
2684
|
-
"saved. Save the value first, then test it.",
|
|
2685
|
-
};
|
|
2686
|
-
}
|
|
2687
|
-
const tools = (yield authInvoke(connector, "listTools", [ctx]));
|
|
245
|
+
liveListings.add(view);
|
|
246
|
+
const tools = (yield authInvoke(connector, "listTools", [view]));
|
|
2688
247
|
return {
|
|
2689
248
|
ok: true,
|
|
2690
249
|
message: `Connected — the downstream served ${tools.length} tool${tools.length === 1 ? "" : "s"}.`,
|
|
2691
250
|
};
|
|
2692
251
|
}
|
|
2693
252
|
catch (err) {
|
|
2694
|
-
return { ok: false, message: msg(err) };
|
|
253
|
+
return { ok: false, message: msg(err), failure: credentialFailureClass(err) };
|
|
2695
254
|
}
|
|
2696
255
|
finally {
|
|
256
|
+
liveListings.delete(view);
|
|
2697
257
|
if (!connectorScopeCleanupClaimed(ctx, id))
|
|
2698
|
-
(yield authCall(connector.closeScope, [
|
|
258
|
+
(yield authCall(connector.closeScope, [view], connector));
|
|
2699
259
|
}
|
|
2700
|
-
})),
|
|
260
|
+
}))),
|
|
2701
261
|
}
|
|
2702
262
|
: {}),
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
const state = stateFor(ctx);
|
|
2707
|
-
const client = (yield authCall(ensureConnected, [ctx, state]));
|
|
2708
|
-
let clean;
|
|
2709
|
-
const options = requestOptions(ctx) ?? {};
|
|
2710
|
-
listingContexts.set(options, ctx);
|
|
2711
|
-
try {
|
|
2712
|
-
const result = (yield authInvoke(client, "listTools", [undefined, options]));
|
|
2713
|
-
clean = redactCatalog(ctx, result.tools);
|
|
2714
|
-
}
|
|
2715
|
-
catch (err) {
|
|
2716
|
-
if (ownAbortReason(err, [ctx.signal]) && !hasSdkPayload(err))
|
|
2717
|
-
throw err;
|
|
2718
|
-
const bounded = atMcpBoundary(ctx, err, "tools/list", client.transport, [ctx.signal]);
|
|
2719
|
-
if (requiresAuthorization(bounded)) {
|
|
2720
|
-
if (state.client === client)
|
|
2721
|
-
state.authRequired = true;
|
|
2722
|
-
if (bounded instanceof UnauthorizedError)
|
|
2723
|
-
throw carryFailureFacts(bounded, authRequiredError());
|
|
2724
|
-
}
|
|
2725
|
-
throw bounded;
|
|
2726
|
-
}
|
|
2727
|
-
finally {
|
|
2728
|
-
listingContexts.delete(options);
|
|
2729
|
-
}
|
|
2730
|
-
state.toolDefinitions = new Map(clean.map((tool) => [tool.name, tool]));
|
|
2731
|
-
return clean.map((t) => ({
|
|
2732
|
-
name: t.name,
|
|
2733
|
-
...(t.title !== undefined ? { title: t.title } : {}),
|
|
2734
|
-
...(t.icons !== undefined
|
|
2735
|
-
? {
|
|
2736
|
-
icons: t.icons.filter((icon) => !/^data:/i.test(icon.src)),
|
|
2737
|
-
}
|
|
2738
|
-
: {}),
|
|
2739
|
-
...(t.execution !== undefined ? { execution: t.execution } : {}),
|
|
2740
|
-
...(t.description !== undefined ? { description: t.description } : {}),
|
|
2741
|
-
...(t.inputSchema !== undefined
|
|
2742
|
-
? {
|
|
2743
|
-
inputSchema: t.inputSchema,
|
|
2744
|
-
}
|
|
2745
|
-
: {}),
|
|
2746
|
-
...(t.outputSchema !== undefined
|
|
2747
|
-
? {
|
|
2748
|
-
outputSchema: t.outputSchema,
|
|
2749
|
-
}
|
|
2750
|
-
: {}),
|
|
2751
|
-
...(t.annotations !== undefined
|
|
2752
|
-
? {
|
|
2753
|
-
annotations: t.annotations,
|
|
2754
|
-
}
|
|
2755
|
-
: {}),
|
|
2756
|
-
}));
|
|
2757
|
-
})),
|
|
2758
|
-
callTool: authMethod((name, args, ctx, options) => authFlow(function* () {
|
|
2759
|
-
enterProviderBoundary(ctx, connector);
|
|
2760
|
-
let active;
|
|
2761
|
-
try {
|
|
2762
|
-
return (yield authView(ctx, authMethod(() => authFlow(function* () {
|
|
2763
|
-
const state = stateFor(ctx);
|
|
2764
|
-
const client = (yield authCatch(authCall(ensureConnected, [ctx, state]), (error) => {
|
|
2765
|
-
throw knownProviderFailure(error);
|
|
2766
|
-
}));
|
|
2767
|
-
const definition = options?.definition;
|
|
2768
|
-
const facts = definition ?? state.toolDefinitions.get(name) ?? { name };
|
|
2769
|
-
const sourceFacts = definition ?? {
|
|
2770
|
-
name,
|
|
2771
|
-
...(facts.annotations
|
|
2772
|
-
? { annotations: facts.annotations }
|
|
2773
|
-
: {}),
|
|
2774
|
-
};
|
|
2775
|
-
const source = sourceClassification(sourceFacts);
|
|
2776
|
-
const serverWrite = source === "write";
|
|
2777
|
-
const write = serverWrite || (callClassification(ctx) ?? definition?.classification ?? source) === "write";
|
|
2778
|
-
const dispatch = newDispatch(write, serverWrite);
|
|
2779
|
-
dispatch.request = {
|
|
2780
|
-
entry: providerRequest(ctx, !write, `Connector "${id}"'s MCP server`, { method: "POST" }),
|
|
2781
|
-
ctx,
|
|
2782
|
-
};
|
|
2783
|
-
active = { state, client, dispatch, write };
|
|
2784
|
-
state.dispatches.set(dispatch, name);
|
|
2785
|
-
const toolDefinition = definition
|
|
2786
|
-
? {
|
|
2787
|
-
...definition,
|
|
2788
|
-
inputSchema: (definition.inputSchema ?? { type: "object" }),
|
|
2789
|
-
}
|
|
2790
|
-
: state.toolDefinitions.get(name);
|
|
2791
|
-
if (toolDefinition?.execution?.taskSupport === "required") {
|
|
2792
|
-
throw new ConnectorCallError("connector_call_failed", `Tool "${name}" requires task-based execution, which Connecta does not support.`);
|
|
2793
|
-
}
|
|
2794
|
-
let output;
|
|
2795
|
-
const outputError = (code, message) => atMcpBoundary(ctx, new ProtocolError(code, message), "tools/call", client.transport, [ctx.signal]);
|
|
2796
|
-
if (toolDefinition?.outputSchema) {
|
|
2797
|
-
try {
|
|
2798
|
-
output = fromJsonSchema(toolDefinition.outputSchema);
|
|
2799
|
-
}
|
|
2800
|
-
catch {
|
|
2801
|
-
throw outputError(-32602, `Tool '${name}' has an invalid outputSchema: schema could not be compiled`);
|
|
2802
|
-
}
|
|
2803
|
-
}
|
|
2804
|
-
const result = (yield authCatch(authInvoke(client, "callTool", [
|
|
2805
|
-
withDispatch({
|
|
2806
|
-
name,
|
|
2807
|
-
arguments: (args ?? {}),
|
|
2808
|
-
...options?.input,
|
|
2809
|
-
}, dispatch),
|
|
2810
|
-
{
|
|
2811
|
-
...requestOptions(ctx),
|
|
2812
|
-
allowInputRequired: true,
|
|
2813
|
-
...(toolDefinition
|
|
2814
|
-
? { toolDefinition: { ...toolDefinition, outputSchema: undefined } }
|
|
2815
|
-
: write
|
|
2816
|
-
? { toolDefinition: { name, inputSchema: { type: "object" } } }
|
|
2817
|
-
: {}),
|
|
2818
|
-
},
|
|
2819
|
-
]), (err) => {
|
|
2820
|
-
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, err);
|
|
2821
|
-
throw atMcpBoundary(ctx, err, "tools/call", client.transport, [ctx.signal]);
|
|
2822
|
-
}));
|
|
2823
|
-
if (isInputRequiredResult(result)) {
|
|
2824
|
-
return result;
|
|
2825
|
-
}
|
|
2826
|
-
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, result);
|
|
2827
|
-
if (output && !result.isError) {
|
|
2828
|
-
if (result.structuredContent === undefined)
|
|
2829
|
-
throw outputError(-32600, `Tool ${name} has an output schema but did not return structured content`);
|
|
2830
|
-
const validation = (yield authInvoke(output["~standard"], "validate", [
|
|
2831
|
-
result.structuredContent,
|
|
2832
|
-
]));
|
|
2833
|
-
if (validation.issues)
|
|
2834
|
-
throw outputError(-32602, "Structured content does not match the tool's output schema: validation failed");
|
|
2835
|
-
}
|
|
2836
|
-
return result;
|
|
2837
|
-
}))));
|
|
2838
|
-
}
|
|
2839
|
-
catch (err) {
|
|
2840
|
-
if (!active)
|
|
2841
|
-
throw knownProviderFailure(err);
|
|
2842
|
-
const { state, client, dispatch, write } = active;
|
|
2843
|
-
const direct = write && callClassification(ctx) === undefined;
|
|
2844
|
-
const ownAbort = ownAbortReason(err, [ctx.signal]) && !hasSdkPayload(err);
|
|
2845
|
-
// The SDK can reject its wait synchronously from the abort event,
|
|
2846
|
-
// before the physical response's Promise observer records its facts.
|
|
2847
|
-
// Yield only settlement; no closed caller regains live authority.
|
|
2848
|
-
if (ownAbort)
|
|
2849
|
-
yield Effect.yieldNow;
|
|
2850
|
-
if (ownAbort && !direct)
|
|
2851
|
-
throw err;
|
|
2852
|
-
if (requiresAuthorization(err) && state.client === client)
|
|
2853
|
-
state.authRequired = true;
|
|
2854
|
-
const failure = ownAbort
|
|
2855
|
-
? err
|
|
2856
|
-
: err instanceof UnauthorizedError
|
|
2857
|
-
? carryFailureFacts(err, authRequiredError())
|
|
2858
|
-
: downstreamCallError(err, undefined, undefined, undefined, requestSentSecrets(ctx));
|
|
2859
|
-
if (!write)
|
|
2860
|
-
throw failure;
|
|
2861
|
-
const renewal = dispatch.request?.entry.renewed
|
|
2862
|
-
? undefined
|
|
2863
|
-
: dispatch.resendRefused
|
|
2864
|
-
? authRequiredError()
|
|
2865
|
-
: failure;
|
|
2866
|
-
const unsent = (entry) => {
|
|
2867
|
-
const verdict = unsentAfterUnauthorized(`Connector "${id}"'s MCP server`, renewal, isOauth ? "downstream_oauth_required" : "auth_required");
|
|
2868
|
-
return entry ? vendorRefusal(verdict, entry) : verdict;
|
|
2869
|
-
};
|
|
2870
|
-
const verdict = dispatch.unauthorized
|
|
2871
|
-
? unsent(dispatch.request?.entry)
|
|
2872
|
-
: dispatch.redirected || dispatch.resendRefused
|
|
2873
|
-
? new ConnectorCallError("connector_call_failed", `Connector "${id}"'s MCP server ` +
|
|
2874
|
-
(dispatch.redirected
|
|
2875
|
-
? "answered this write with a redirect, and the write was not sent again, "
|
|
2876
|
-
: "was sent this write, and a second send of it was stopped, ") +
|
|
2877
|
-
"so whether it took effect is unknown: check the target before repeating it.", { retryable: false, writeOutcome: "unknown" })
|
|
2878
|
-
: failure;
|
|
2879
|
-
const vendor = { title: `connector "${id}"` };
|
|
2880
|
-
const status = failureRecord({}, err).httpStatus;
|
|
2881
|
-
const settled = providerCallFailure(ctx, vendor, verdict, {
|
|
2882
|
-
dispatched: dispatch.sent,
|
|
2883
|
-
...(status === undefined ? {} : { status }),
|
|
2884
|
-
fallback: "write",
|
|
2885
|
-
request: dispatch.request?.entry,
|
|
2886
|
-
});
|
|
2887
|
-
throw settled === failure ? failure : carryFailureFacts(failure, settled);
|
|
2888
|
-
}
|
|
2889
|
-
finally {
|
|
2890
|
-
if (active)
|
|
2891
|
-
active.state.dispatches.delete(active.dispatch);
|
|
2892
|
-
}
|
|
2893
|
-
})),
|
|
2894
|
-
readResource: authMethod((uri, ctx) => authFlow(function* () {
|
|
2895
|
-
const state = stateFor(ctx);
|
|
2896
|
-
const client = (yield authCall(ensureConnected, [ctx, state]));
|
|
2897
|
-
try {
|
|
2898
|
-
const options = requestOptions(ctx) ?? {};
|
|
2899
|
-
listingContexts.set(options, ctx);
|
|
2900
|
-
try {
|
|
2901
|
-
if (!client.getServerCapabilities()?.resources)
|
|
2902
|
-
throw unadvertisedResource();
|
|
2903
|
-
const resources = (yield authInvoke(client, "listResources", [undefined, options]));
|
|
2904
|
-
const templates = (yield authInvoke(client, "listResourceTemplates", [
|
|
2905
|
-
undefined,
|
|
2906
|
-
options,
|
|
2907
|
-
]));
|
|
2908
|
-
const exact = resources.resources.some((resource) => resource.uri === uri);
|
|
2909
|
-
if (!exact) {
|
|
2910
|
-
const match = resourceUriMatchesTemplates(uri, templates.resourceTemplates, (code) => resourceTemplateRefusals.add(code));
|
|
2911
|
-
if (!match.matched) {
|
|
2912
|
-
if (match.refusal)
|
|
2913
|
-
throw new ConnectorCallError(match.refusal, match.refusal === "resource_template_ambiguous"
|
|
2914
|
-
? "The advertised resource templates have ambiguous expression boundaries."
|
|
2915
|
-
: "Resource template matching exceeds the read work limit.", { retryable: false });
|
|
2916
|
-
throw unadvertisedResource();
|
|
2917
|
-
}
|
|
2918
|
-
}
|
|
2919
|
-
}
|
|
2920
|
-
catch (err) {
|
|
2921
|
-
throw atMcpBoundary(ctx, err, "resources/read", client.transport, [ctx.signal]);
|
|
2922
|
-
}
|
|
2923
|
-
finally {
|
|
2924
|
-
listingContexts.delete(options);
|
|
2925
|
-
}
|
|
2926
|
-
const result = (yield authCatch(authInvoke(client, "readResource", [
|
|
2927
|
-
{ uri },
|
|
2928
|
-
{
|
|
2929
|
-
...requestOptions(ctx),
|
|
2930
|
-
cacheMode: "bypass",
|
|
2931
|
-
allowInputRequired: true,
|
|
2932
|
-
},
|
|
2933
|
-
]), (err) => {
|
|
2934
|
-
if (err instanceof ResourceNotFoundError) {
|
|
2935
|
-
throw new ConnectorCallError("not_found", "The downstream resource does not exist.");
|
|
2936
|
-
}
|
|
2937
|
-
throw atMcpBoundary(ctx, err, "resources/read", client.transport, [ctx.signal]);
|
|
2938
|
-
}));
|
|
2939
|
-
if (isInputRequiredResult(result)) {
|
|
2940
|
-
throw new ConnectorCallError("input_required_unsupported", "Resource reads inside programs cannot request mid-call input.");
|
|
2941
|
-
}
|
|
2942
|
-
return result;
|
|
2943
|
-
}
|
|
2944
|
-
catch (err) {
|
|
2945
|
-
if (ownAbortReason(err, [ctx.signal]) && !hasSdkPayload(err))
|
|
2946
|
-
throw err;
|
|
2947
|
-
if (requiresAuthorization(err)) {
|
|
2948
|
-
if (state.client === client)
|
|
2949
|
-
state.authRequired = true;
|
|
2950
|
-
if (err instanceof UnauthorizedError)
|
|
2951
|
-
throw carryFailureFacts(err, authRequiredError());
|
|
2952
|
-
}
|
|
2953
|
-
throw downstreamCallError(err, undefined, undefined, undefined, requestSentSecrets(ctx));
|
|
2954
|
-
}
|
|
2955
|
-
})),
|
|
263
|
+
listTools: listToolsOperation,
|
|
264
|
+
callTool: callToolOperation,
|
|
265
|
+
readResource: readResourceOperation,
|
|
2956
266
|
closeScope: authMethod((ctx) => authFlow(function* () {
|
|
2957
267
|
// Revocation and all finalizers start before any caller guard. Cleanup
|
|
2958
268
|
// retains its own bounded settlement even after the request has closed.
|
|
@@ -3022,127 +332,9 @@ export function remoteMcp(id, opts) {
|
|
|
3022
332
|
}
|
|
3023
333
|
})),
|
|
3024
334
|
};
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
const provider = newProvider(ctx, state);
|
|
3029
|
-
const consent = callbackParams?.get("state") ?? null;
|
|
3030
|
-
if (!consent || !(yield authInvoke(provider, "verifyCallbackState", [consent]))) {
|
|
3031
|
-
throw new ConnectorCallError("connector_call_failed", `Connector "${id}" authorization callback matches no pending consent; nothing was exchanged.`);
|
|
3032
|
-
}
|
|
3033
|
-
provider.validateCallbackIssuer(callbackParams?.get("iss") ?? null);
|
|
3034
|
-
(yield authInvoke(provider, "bindFlow", []));
|
|
3035
|
-
signal.throwIfAborted();
|
|
3036
|
-
const t = buildTransport(ctx, provider, null, signal, null, true);
|
|
3037
|
-
const trail = {};
|
|
3038
|
-
const internals = t;
|
|
3039
|
-
const traced = tracedOAuthFetch(trail, new URL(opts.url), internals._fetchWithInit ?? fetch);
|
|
3040
|
-
const exchange = new Proxy(t, {
|
|
3041
|
-
get: (target, key) => key === "_fetchWithInit" ? traced : Reflect.get(target, key, target),
|
|
3042
|
-
set: (target, key, value) => Reflect.set(target, key, value, target),
|
|
3043
|
-
});
|
|
3044
|
-
try {
|
|
3045
|
-
(yield authInvoke(exchange, "finishAuth", [callbackParams]));
|
|
3046
|
-
closeHalf(state);
|
|
3047
|
-
}
|
|
3048
|
-
catch (err) {
|
|
3049
|
-
throw (yield authCall(withoutAuthorizationServerText, [
|
|
3050
|
-
ctx,
|
|
3051
|
-
err,
|
|
3052
|
-
trail,
|
|
3053
|
-
[signal, ctx.signal],
|
|
3054
|
-
]));
|
|
3055
|
-
}
|
|
3056
|
-
finally {
|
|
3057
|
-
detach(closeConnection(null, t, ctx.logger));
|
|
3058
|
-
}
|
|
3059
|
-
})), true));
|
|
3060
|
-
connector.verifyState = authMethod((oauthState, ctx) => authFlow(function* () {
|
|
3061
|
-
const state = stateFor(ctx);
|
|
3062
|
-
return (yield authInvoke(newProvider(ctx, state), "verifyState", [oauthState]));
|
|
3063
|
-
}));
|
|
3064
|
-
connector.verifyCallbackIssuer = authMethod((issuer, ctx) => Effect.try({
|
|
3065
|
-
try: () => {
|
|
3066
|
-
newProvider(ctx, stateFor(ctx)).validateCallbackIssuer(issuer);
|
|
3067
|
-
return true;
|
|
3068
|
-
},
|
|
3069
|
-
catch: (error) => error,
|
|
3070
|
-
}));
|
|
3071
|
-
connector.consumeAuthError = authMethod((ctx) => authFlow(function* () {
|
|
3072
|
-
(yield authInvoke(newProvider(ctx, stateFor(ctx)), "consumeAuthError", []));
|
|
3073
|
-
}));
|
|
3074
|
-
connector.disconnectAuth = authMethod((ctx) => authFlow(function* () {
|
|
3075
|
-
(yield authCall(disconnectAuthorization, [ctx, stateFor(ctx), true]));
|
|
3076
|
-
}));
|
|
3077
|
-
connector.startAuth = authMethod((ctx, startOpts) => authFlow(function* () {
|
|
3078
|
-
ctx = authorizingContext(ctx);
|
|
3079
|
-
const state = stateFor(ctx);
|
|
3080
|
-
state.provider = null;
|
|
3081
|
-
const p = newProvider(ctx, state);
|
|
3082
|
-
if (ctx.signal?.aborted)
|
|
3083
|
-
throw ctx.signal.reason;
|
|
3084
|
-
const disconnected = startOpts?.force
|
|
3085
|
-
? false
|
|
3086
|
-
: (yield authInvoke(p, "operatorDisconnected", []));
|
|
3087
|
-
if (ctx.signal?.aborted)
|
|
3088
|
-
throw ctx.signal.reason;
|
|
3089
|
-
if (startOpts?.force || disconnected) {
|
|
3090
|
-
(yield authCall(disconnectAuthorization, [ctx, state, false, startOpts?.force === true]));
|
|
3091
|
-
}
|
|
3092
|
-
else {
|
|
3093
|
-
const pending = (yield authInvoke(p, "reusablePendingAuthorizationUrl", []));
|
|
3094
|
-
if (pending) {
|
|
3095
|
-
return {
|
|
3096
|
-
state: "auth_required",
|
|
3097
|
-
authorizationUrl: pending,
|
|
3098
|
-
authorizationReused: true,
|
|
3099
|
-
message: "Authorization required \u2014 open the URL to connect.",
|
|
3100
|
-
};
|
|
3101
|
-
}
|
|
3102
|
-
}
|
|
3103
|
-
if (ctx.signal?.aborted)
|
|
3104
|
-
throw ctx.signal.reason;
|
|
3105
|
-
try {
|
|
3106
|
-
// A passive call's refusal stays latched for status and calls. A
|
|
3107
|
-
// successful connection with no tokens still permits explicit
|
|
3108
|
-
// consent, bound to that connection rather than the live grant.
|
|
3109
|
-
if (state.authRequired && state.client) {
|
|
3110
|
-
const provider = connectedProviders.get(state.client);
|
|
3111
|
-
if (!(yield authCall(granted, [provider])))
|
|
3112
|
-
return (yield authCall(startUnchallenged, [ctx, provider]));
|
|
3113
|
-
}
|
|
3114
|
-
// The handshake runs first, so a 401 challenge's
|
|
3115
|
-
// `resource_metadata` starts consent as it always has.
|
|
3116
|
-
const client = (yield authCall(ensureConnected, [ctx, state]));
|
|
3117
|
-
const provider = connectedProviders.get(client);
|
|
3118
|
-
if (!(yield authCall(granted, [provider])))
|
|
3119
|
-
return (yield authCall(startUnchallenged, [ctx, provider]));
|
|
3120
|
-
return {
|
|
3121
|
-
state: "ok",
|
|
3122
|
-
message: "Already authorized \u2014 connection is healthy.",
|
|
3123
|
-
};
|
|
3124
|
-
}
|
|
3125
|
-
catch (err) {
|
|
3126
|
-
if (state.authRequired && requiresAuthorization(err)) {
|
|
3127
|
-
let authorizationUrl;
|
|
3128
|
-
try {
|
|
3129
|
-
authorizationUrl = (yield state.attemptProvider
|
|
3130
|
-
? authInvoke(state.attemptProvider, "consentUrl", [viewSignal(ctx, true)])
|
|
3131
|
-
: Effect.succeed(undefined));
|
|
3132
|
-
}
|
|
3133
|
-
catch (readErr) {
|
|
3134
|
-
return { state: "error", message: startMessage(readErr) };
|
|
3135
|
-
}
|
|
3136
|
-
return {
|
|
3137
|
-
state: "auth_required",
|
|
3138
|
-
...(authorizationUrl !== undefined ? { authorizationUrl } : {}),
|
|
3139
|
-
message: "Authorization required \u2014 open the URL to connect.",
|
|
3140
|
-
};
|
|
3141
|
-
}
|
|
3142
|
-
return { state: "error", message: startMessage(err) };
|
|
3143
|
-
}
|
|
3144
|
-
}));
|
|
3145
|
-
}
|
|
335
|
+
rt.connector = connector;
|
|
336
|
+
if (isOauth)
|
|
337
|
+
oauthSurface(connector);
|
|
3146
338
|
const retainOperation = (operation, contextIndex) => authMethod((...args) => authFlow(function* () {
|
|
3147
339
|
const release = retainOAuthPartition(oauthPartitionFor(args[contextIndex]));
|
|
3148
340
|
try {
|
|
@@ -3289,8 +481,15 @@ export function remoteMcp(id, opts) {
|
|
|
3289
481
|
const provider = newProvider(ctx, stateFor(ctx));
|
|
3290
482
|
if ((yield authInvoke(provider, "operatorDisconnected", [])))
|
|
3291
483
|
throw operatorDisconnectedError();
|
|
484
|
+
// Keep this separate from the disconnect read: invalidation and
|
|
485
|
+
// refresh can remove or replace tokens without changing the epoch.
|
|
486
|
+
// The SDK may otherwise send anonymously when its tokens() is empty.
|
|
3292
487
|
if (!(yield authInvoke(provider, "tokens", [])))
|
|
3293
|
-
throw authRequiredError();
|
|
488
|
+
throw authRequiredError(ctx);
|
|
489
|
+
// That read also decides whether the token is about to expire.
|
|
490
|
+
const due = provider.renewalDue();
|
|
491
|
+
if (due !== undefined)
|
|
492
|
+
(yield authCall(renewBeforeCall, [ctx, due]));
|
|
3294
493
|
})), 0), 0));
|
|
3295
494
|
if (isOauth)
|
|
3296
495
|
withStoredAuth(connector, (ctx) => newProvider(ctx).storedAuthorization());
|