@zackbart/connecta 0.36.1 → 0.37.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 +36 -0
- package/bin/version.mjs +1 -1
- package/dist/auth/downstream-oauth.js +34 -12
- package/dist/auth/oauth-authorization-params.js +52 -0
- package/dist/auth/static-oauth.js +4 -6
- package/dist/auth/stored-observation.js +8 -0
- package/dist/call-admission.js +27 -14
- package/dist/connectors/api-connector.js +4 -0
- package/dist/connectors/option-shapes.js +6 -1
- package/dist/connectors/remote-mcp.d.ts +32 -3
- package/dist/connectors/remote-mcp.js +152 -21
- package/dist/execute.js +5 -5
- package/dist/executor-admission.js +27 -13
- package/dist/executors/quickjs.js +4 -1
- package/dist/google.d.ts +13 -0
- package/dist/google.js +42 -0
- package/dist/index.d.ts +2 -1
- package/dist/invocation.js +6 -7
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +2 -0
- package/dist/providers/_shared/google/service-account.d.ts +1 -1
- package/dist/providers/_shared/google/service-account.js +20 -8
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/request-principal.js +34 -0
- package/dist/request-token.d.ts +53 -0
- package/dist/request-token.js +1 -0
- package/dist/routes/mcp.js +6 -6
- package/dist/routes/ui-config.js +38 -9
- package/dist/routes/ui-data.js +30 -5
- package/dist/routes/ui.js +10 -2
- package/dist/runtime/settlement.js +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +4 -1
- package/documentation/README.md +13 -12
- package/documentation/architecture.md +91 -26
- package/documentation/auth.md +317 -12
- package/documentation/code-mode.md +25 -5
- package/documentation/integrating.md +18 -4
- package/documentation/operator-tests.md +19 -0
- package/documentation/operator-ui.md +57 -46
- package/examples/worker/README.md +5 -1
- package/guides/connecting-agents.md +6 -0
- package/guides/safety-and-trust.md +1 -0
- package/package.json +5 -1
- package/templates/node/package.json +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { withStoredAuth } from "../auth/stored-observation.js";
|
|
2
|
+
import { authorizationParamsFor } from "../auth/oauth-authorization-params.js";
|
|
1
3
|
import { authView } from "../auth/native-call.js";
|
|
2
4
|
import { authFlow, authCall, authInvoke, authValue, authMethod, authCatch } from "../auth/native.js";
|
|
3
5
|
import { isOAuthCredentialRequest } from "../auth/oauth-token-response.js";
|
|
@@ -26,6 +28,7 @@ import { registerInvocationAuth } from "../invocation-auth.js";
|
|
|
26
28
|
import { downstreamInputCapabilities, assertDownstreamOutputSafe, downstreamWriteContinuation, } from "../downstream-input-context.js";
|
|
27
29
|
import { detach, runEdge } from "../runtime/run.js";
|
|
28
30
|
import { assertKnownOptions, keys, optionsOf, strings } from "../config-schema.js";
|
|
31
|
+
import { requestPrincipalFor } from "../request-principal.js";
|
|
29
32
|
import { describedEndpoint, describedUrl } from "../described.js";
|
|
30
33
|
import { retryAfterMs } from "../http-policy.js";
|
|
31
34
|
import { resourceUriMatchesTemplates } from "./resource-uri.js";
|
|
@@ -40,7 +43,7 @@ import { readNegotiation, storeNegotiation } from "./negotiation-cache.js";
|
|
|
40
43
|
import { READ_ONLY, CALL_ADMISSION, REMOTE_MCP_AUTH, USAGE_GUIDE } from "./option-shapes.js";
|
|
41
44
|
/** The closed options remoteMcp() accepts; see `assertKnownOptions`. */
|
|
42
45
|
const REMOTE_MCP_OPTIONS = optionsOf()({
|
|
43
|
-
...keys("url", "title", "description", "authScope", "maxResultBytes", "versionNegotiation", "redirects", "requireHttps", "logger", "_transportFactory", "skills"),
|
|
46
|
+
...keys("url", "title", "description", "authScope", "maxResultBytes", "versionNegotiation", "redirects", "requireHttps", "logger", "fetch", "_transportFactory", "skills"),
|
|
44
47
|
readOnly: READ_ONLY,
|
|
45
48
|
callAdmission: CALL_ADMISSION,
|
|
46
49
|
usageGuide: USAGE_GUIDE,
|
|
@@ -1101,6 +1104,7 @@ export function remoteMcp(id, opts) {
|
|
|
1101
1104
|
const clientMetadataUrl = opts.auth?.type === "oauth" ? opts.auth.clientMetadataUrl : undefined;
|
|
1102
1105
|
const oauthScope = opts.auth?.type === "oauth" ? opts.auth.scope : undefined;
|
|
1103
1106
|
assertOAuthScope(id, oauthScope);
|
|
1107
|
+
const authorizationParams = authorizationParamsFor(opts.auth?.type === "oauth" ? opts.auth.authorizationParams : undefined, `remoteMcp(${JSON.stringify(id)}).auth.authorizationParams`);
|
|
1104
1108
|
const staticClient = opts.auth?.type === "oauth" && opts.auth.client ? { ...opts.auth.client } : undefined;
|
|
1105
1109
|
assertRemoteOAuthClient(id, staticClient);
|
|
1106
1110
|
if (staticClient && clientMetadataUrl !== undefined)
|
|
@@ -1133,14 +1137,35 @@ export function remoteMcp(id, opts) {
|
|
|
1133
1137
|
// its actual sent auth with every operation using that client, including
|
|
1134
1138
|
// handshake/discovery requests and OAuth rotations. Remove settled users.
|
|
1135
1139
|
const activeContexts = new WeakMap();
|
|
1140
|
+
// Resource headers are non-secret context by contract. They never join the
|
|
1141
|
+
// sent set, so arguments and results equal to them pass unchanged (#1108).
|
|
1142
|
+
// An argument mirrored as `Mcp-Param-*` carrying a resource value is the
|
|
1143
|
+
// same context under a name the tracker may read as credential-shaped.
|
|
1144
|
+
const resourceValues = new Set(Object.values(extraHeaders ?? {}));
|
|
1145
|
+
const withoutResourceHeaders = (input, init) => {
|
|
1146
|
+
if (!extraHeaders)
|
|
1147
|
+
return init;
|
|
1148
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
1149
|
+
for (const [name, value] of Object.entries(extraHeaders))
|
|
1150
|
+
if (headers.get(name) === value)
|
|
1151
|
+
headers.delete(name);
|
|
1152
|
+
const mirrored = [];
|
|
1153
|
+
for (const [name, value] of headers)
|
|
1154
|
+
if (name.startsWith("mcp-param-") && resourceValues.has(value))
|
|
1155
|
+
mirrored.push(name);
|
|
1156
|
+
for (const name of mirrored)
|
|
1157
|
+
headers.delete(name);
|
|
1158
|
+
return { ...init, headers };
|
|
1159
|
+
};
|
|
1136
1160
|
const trackSentRequest = (ctx, input, init) => {
|
|
1137
1161
|
const active = activeContexts.get(entryFor(ctx));
|
|
1138
1162
|
const recipients = new Set([sentSecretsFor(ctx), ...[...(active ?? [])].map(sentSecretsFor)]);
|
|
1163
|
+
const tracked = withoutResourceHeaders(input, init);
|
|
1139
1164
|
for (const secrets of recipients) {
|
|
1140
1165
|
if (opts.auth?.type === "oauth")
|
|
1141
|
-
trackRemoteClientRequest(secrets, input,
|
|
1166
|
+
trackRemoteClientRequest(secrets, input, tracked);
|
|
1142
1167
|
else
|
|
1143
|
-
secrets.request(input,
|
|
1168
|
+
secrets.request(input, tracked);
|
|
1144
1169
|
}
|
|
1145
1170
|
};
|
|
1146
1171
|
const connectingWaiters = new WeakMap();
|
|
@@ -1177,10 +1202,29 @@ export function remoteMcp(id, opts) {
|
|
|
1177
1202
|
"this shape sends one secret in one header and reads the reserved " +
|
|
1178
1203
|
"`value` field only.");
|
|
1179
1204
|
}
|
|
1205
|
+
// Both request shapes, read field by field; a mixture is refused below.
|
|
1180
1206
|
const requestAuth = opts.auth?.type === "request" ? opts.auth : undefined;
|
|
1181
|
-
|
|
1207
|
+
// Opt-in only: without it the resolver keeps receiving the context alone.
|
|
1208
|
+
// `principal` replaces `token`; a malformed or mixed shape has no resolver.
|
|
1209
|
+
const forwarding = requestAuth?.principal;
|
|
1210
|
+
const requestToken = forwarding
|
|
1211
|
+
? requestAuth?.token === undefined && (forwarding.accept === "human" || forwarding.accept === "human-or-agent")
|
|
1212
|
+
? forwarding.token
|
|
1213
|
+
: undefined
|
|
1214
|
+
: requestAuth?.token;
|
|
1215
|
+
if (requestAuth && (typeof requestToken !== "function" || opts.authScope === "personal")) {
|
|
1182
1216
|
throw new Error(`remoteMcp(${JSON.stringify(id)}) requires a request token callback and shared authScope.`);
|
|
1183
1217
|
}
|
|
1218
|
+
const principalPolicy = forwarding?.accept;
|
|
1219
|
+
const customFetch = opts.fetch;
|
|
1220
|
+
// OAuth discovery, registration and token requests reach authorization
|
|
1221
|
+
// servers the downstream names, through the owned credential sender. A
|
|
1222
|
+
// custom fetch would split that traffic between two transports.
|
|
1223
|
+
if (customFetch !== undefined && (typeof customFetch !== "function" || opts.auth?.type === "oauth")) {
|
|
1224
|
+
throw new Error(`remoteMcp(${JSON.stringify(id)}).fetch must be a function, without OAuth auth.`);
|
|
1225
|
+
}
|
|
1226
|
+
// No delayed dispatch: a write counts as sent once it is handed to the function.
|
|
1227
|
+
const customSender = customFetch && { send: customFetch };
|
|
1184
1228
|
if (requestAuth?.headers) {
|
|
1185
1229
|
try {
|
|
1186
1230
|
if (Object.keys(requestAuth.headers).some((name) => name.toLowerCase() === "authorization"))
|
|
@@ -1448,11 +1492,12 @@ export function remoteMcp(id, opts) {
|
|
|
1448
1492
|
* trusting any cached client, so an operator's replacement is picked up
|
|
1449
1493
|
* without a redeploy. The value stays in the caller's local scope.
|
|
1450
1494
|
*/
|
|
1451
|
-
const readCredential = authMethod((ctx) => authFlow(function* () {
|
|
1495
|
+
const readCredential = authMethod((ctx, principal) => authFlow(function* () {
|
|
1452
1496
|
if (requestAuth) {
|
|
1453
1497
|
let value;
|
|
1454
1498
|
try {
|
|
1455
|
-
|
|
1499
|
+
// A fresh frozen input per resolution: one resolver cannot alter another's.
|
|
1500
|
+
value = (yield authInvoke(forwarding ?? requestAuth, "token", principal ? [ctx, Object.freeze({ principal })] : [ctx]));
|
|
1456
1501
|
}
|
|
1457
1502
|
catch (error) {
|
|
1458
1503
|
if (error instanceof ConnectorCallError)
|
|
@@ -1722,6 +1767,7 @@ export function remoteMcp(id, opts) {
|
|
|
1722
1767
|
url: new URL(opts.url).href,
|
|
1723
1768
|
redirectUri,
|
|
1724
1769
|
clientMetadata: downstreamClientMetadata(redirectUri, oauthScope, ctx.oauthClientName, staticClient ? remoteClientAuthMethod(staticClient) : "none"),
|
|
1770
|
+
...(authorizationParams.length ? { authorizationParams } : {}),
|
|
1725
1771
|
authScope: opts.authScope ?? "shared",
|
|
1726
1772
|
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
1727
1773
|
redirects: opts.redirects ?? "none",
|
|
@@ -1735,7 +1781,7 @@ export function remoteMcp(id, opts) {
|
|
|
1735
1781
|
},
|
|
1736
1782
|
}
|
|
1737
1783
|
: {}),
|
|
1738
|
-
}), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), metadataUrl, oauthScope, undefined, { name: ctx.oauthClientName, client: staticClient, secrets: sentSecretsFor(ctx) });
|
|
1784
|
+
}), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), metadataUrl, oauthScope, undefined, { name: ctx.oauthClientName, client: staticClient, secrets: sentSecretsFor(ctx), authorizationParams });
|
|
1739
1785
|
if (state)
|
|
1740
1786
|
state.provider = provider;
|
|
1741
1787
|
return provider;
|
|
@@ -1750,7 +1796,9 @@ export function remoteMcp(id, opts) {
|
|
|
1750
1796
|
/** The raw value behind `credentialFramed`, under the same terms. */
|
|
1751
1797
|
credentialValue = null,
|
|
1752
1798
|
/** A code exchange never transfers ownership to the refresh coordinator. */
|
|
1753
|
-
exchangeOnly = false
|
|
1799
|
+
exchangeOnly = false,
|
|
1800
|
+
/** The admitted principal `credentialValue` was resolved for, under the same terms. */
|
|
1801
|
+
principal = undefined) => {
|
|
1754
1802
|
const resourceInit = (input, init) => {
|
|
1755
1803
|
if (!extraHeaders)
|
|
1756
1804
|
return init;
|
|
@@ -1802,7 +1850,12 @@ export function remoteMcp(id, opts) {
|
|
|
1802
1850
|
if (toolCall && !flight && !untrackedRead(toolCall.params))
|
|
1803
1851
|
throw missingDispatch();
|
|
1804
1852
|
const credentialPost = isOauth && (init?.method ?? "GET").toUpperCase() === "POST" && (!skillObject(rpc) || rpc.jsonrpc !== "2.0");
|
|
1805
|
-
|
|
1853
|
+
// A custom transport reaches only the configured origin; redirects are
|
|
1854
|
+
// already same-origin, and OAuth cannot be combined with it.
|
|
1855
|
+
if (customFetch && new URL(input instanceof Request ? input.url : String(input)).origin !== destination.origin)
|
|
1856
|
+
throw new ConnectorCallError("connector_call_failed", `Connector "${id}" refused a request outside its origin; nothing was sent.`, { retryable: false });
|
|
1857
|
+
// The deployment's fetch owns the wire: each write is handed over once.
|
|
1858
|
+
const sender = flight?.write ? (customSender ?? guardedTransport(true)) : undefined;
|
|
1806
1859
|
if (flight?.write) {
|
|
1807
1860
|
if (flight.sent) {
|
|
1808
1861
|
if (flight.request && flight.authorization)
|
|
@@ -1840,7 +1893,8 @@ export function remoteMcp(id, opts) {
|
|
|
1840
1893
|
? sendGuarded(sender, new URL(input instanceof Request ? input.url : String(input)), { ...init, redirect: "manual" }, dispatch)
|
|
1841
1894
|
: (() => {
|
|
1842
1895
|
dispatch();
|
|
1843
|
-
|
|
1896
|
+
const send = customFetch ?? fetch;
|
|
1897
|
+
return writeContinuation ? send(input, { ...init, redirect: "manual" }) : send(input, init);
|
|
1844
1898
|
})();
|
|
1845
1899
|
return pending.then((response) => {
|
|
1846
1900
|
if (entry)
|
|
@@ -1856,6 +1910,11 @@ export function remoteMcp(id, opts) {
|
|
|
1856
1910
|
return response;
|
|
1857
1911
|
});
|
|
1858
1912
|
}));
|
|
1913
|
+
if (response.status === 401 && requestToken?.invalidate && credentialValue !== null) {
|
|
1914
|
+
// Invalidate the exact bytes this transport sent, never a newer token.
|
|
1915
|
+
// Preserve the original 401 even if an operator hook fails. No replay.
|
|
1916
|
+
(yield authCatch(authInvoke(requestToken, "invalidate", principal ? [credentialValue, ctx, Object.freeze({ principal })] : [credentialValue, ctx]), () => { }));
|
|
1917
|
+
}
|
|
1859
1918
|
if (credentialPost && !isOAuthCredentialRequest(init) && REDIRECT_STATUSES.has(response.status)) {
|
|
1860
1919
|
(yield authCatch(authValue(() => response.body?.cancel()), () => { }));
|
|
1861
1920
|
throw new ConnectorCallError("connector_call_failed", "OAuth registration endpoint redirected; the request was not repeated.", { retryable: false });
|
|
@@ -2071,15 +2130,24 @@ export function remoteMcp(id, opts) {
|
|
|
2071
2130
|
let credentialValue = null;
|
|
2072
2131
|
let credentialFramed = null;
|
|
2073
2132
|
let credentialDigest = null;
|
|
2133
|
+
// Only the admitted caller on this operation's context, never an argument.
|
|
2134
|
+
// Refused before the resolver runs, so no token is minted for anyone else.
|
|
2135
|
+
const principal = principalPolicy
|
|
2136
|
+
? requestPrincipalFor(principalPolicy, ctx, (reason) => new CredentialRequiredError(`Connector "${id}" ${reason}.`))
|
|
2137
|
+
: undefined;
|
|
2074
2138
|
if (credentialAuth || requestAuth) {
|
|
2075
|
-
credentialValue = (yield authCall(readCredential, [ctx]));
|
|
2139
|
+
credentialValue = (yield authCall(readCredential, [ctx, principal]));
|
|
2076
2140
|
credentialFramed = credentialHeaderValue(credentialScheme, credentialValue);
|
|
2077
|
-
|
|
2141
|
+
// The principal joins the client's identity: another principal never
|
|
2142
|
+
// reuses this client, even if its resolver returns the same token.
|
|
2143
|
+
credentialDigest = (yield authCall(digestOf, [
|
|
2144
|
+
principal ? JSON.stringify([principal, credentialValue]) : credentialValue,
|
|
2145
|
+
]));
|
|
2078
2146
|
if ((state.client || state.connecting) &&
|
|
2079
2147
|
state.credentialDigest !== null &&
|
|
2080
2148
|
state.credentialDigest !== credentialDigest) {
|
|
2081
2149
|
const observedConnection = state.client ?? state.connecting;
|
|
2082
|
-
const currentCredential = (yield authCall(readCredential, [ctx]));
|
|
2150
|
+
const currentCredential = (yield authCall(readCredential, [ctx, principal]));
|
|
2083
2151
|
if (isClosed(state))
|
|
2084
2152
|
throw scopeEndedError();
|
|
2085
2153
|
if (currentCredential !== credentialValue || (state.client ?? state.connecting) !== observedConnection) {
|
|
@@ -2095,7 +2163,7 @@ export function remoteMcp(id, opts) {
|
|
|
2095
2163
|
throw scopeEndedError();
|
|
2096
2164
|
if (state.client)
|
|
2097
2165
|
return state.client;
|
|
2098
|
-
const attempt = state.connecting ?? startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest);
|
|
2166
|
+
const attempt = state.connecting ?? startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest, principal);
|
|
2099
2167
|
if (joined !== attempt) {
|
|
2100
2168
|
leave();
|
|
2101
2169
|
joined = attempt;
|
|
@@ -2116,14 +2184,14 @@ export function remoteMcp(id, opts) {
|
|
|
2116
2184
|
* started it included — waits on that; nothing awaits the run directly,
|
|
2117
2185
|
* which is why handing it to `detach` orphans nothing.
|
|
2118
2186
|
*/
|
|
2119
|
-
const startConnect = (ctx, state, credentialValue, credentialFramed, credentialDigest) => {
|
|
2187
|
+
const startConnect = (ctx, state, credentialValue, credentialFramed, credentialDigest, principal) => {
|
|
2120
2188
|
const attempt = Deferred.makeUnsafe();
|
|
2121
2189
|
state.connecting = attempt;
|
|
2122
2190
|
// Published with the attempt, not with its result: a rotation that lands
|
|
2123
2191
|
// while this connect is in flight has to be visible to the next caller,
|
|
2124
2192
|
// which would otherwise wait on a client bound to the older key.
|
|
2125
2193
|
state.credentialDigest = credentialDigest;
|
|
2126
|
-
detach(connect(ctx, state, attempt, credentialValue, credentialFramed).pipe(
|
|
2194
|
+
detach(connect(ctx, state, attempt, credentialValue, credentialFramed, principal).pipe(
|
|
2127
2195
|
// The connection belongs to this request, without the first waiter's
|
|
2128
2196
|
// call/credential/budget authority. Refresh coordinators stay separate.
|
|
2129
2197
|
Effect.provideService(RequestScope, Context.get(viewServices(ctx), RequestScope)), Effect.onExit((exit) => Effect.sync(() => {
|
|
@@ -2137,7 +2205,7 @@ export function remoteMcp(id, opts) {
|
|
|
2137
2205
|
}))));
|
|
2138
2206
|
return attempt;
|
|
2139
2207
|
};
|
|
2140
|
-
const connect = (ctx, state, attempt, credentialValue, credentialFramed) => {
|
|
2208
|
+
const connect = (ctx, state, attempt, credentialValue, credentialFramed, principal) => {
|
|
2141
2209
|
// Force reset, rotation, and teardown all abandon an attempt the same way:
|
|
2142
2210
|
// they unpublish it and close the lease it holds, if it holds one yet.
|
|
2143
2211
|
const owned = () => state.connecting === attempt && !isClosed(state);
|
|
@@ -2269,7 +2337,7 @@ export function remoteMcp(id, opts) {
|
|
|
2269
2337
|
return client;
|
|
2270
2338
|
};
|
|
2271
2339
|
let c = makeClient();
|
|
2272
|
-
let t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue);
|
|
2340
|
+
let t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
|
|
2273
2341
|
if (!owned()) {
|
|
2274
2342
|
held.transport = t;
|
|
2275
2343
|
return yield* Effect.fail(scopeEndedError());
|
|
@@ -2295,7 +2363,7 @@ export function remoteMcp(id, opts) {
|
|
|
2295
2363
|
handshakeAbort.signal.aborted)
|
|
2296
2364
|
throw error;
|
|
2297
2365
|
c = makeClient();
|
|
2298
|
-
t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue);
|
|
2366
|
+
t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
|
|
2299
2367
|
state.transport = held.transport = t;
|
|
2300
2368
|
recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
|
|
2301
2369
|
(yield authInvoke(c, "connect", [
|
|
@@ -2389,6 +2457,56 @@ export function remoteMcp(id, opts) {
|
|
|
2389
2457
|
closeHalf(state);
|
|
2390
2458
|
}
|
|
2391
2459
|
}));
|
|
2460
|
+
/**
|
|
2461
|
+
* Whether the owner's grant holds tokens, read the way the invocation gate
|
|
2462
|
+
* reads it. A downstream may answer its handshake and listing without
|
|
2463
|
+
* credentials, so a healthy connection proves nothing about the grant.
|
|
2464
|
+
* Read after connecting: connect has bound the epoch, migrated an older
|
|
2465
|
+
* layout, and refused a disconnected one within the scope's lifetime.
|
|
2466
|
+
*/
|
|
2467
|
+
const granted = authMethod((ctx) => authFlow(function* () {
|
|
2468
|
+
return Boolean(yield authInvoke(newProvider(ctx), "tokens", []));
|
|
2469
|
+
}));
|
|
2470
|
+
/**
|
|
2471
|
+
* Start consent for a downstream that answered without challenging. The
|
|
2472
|
+
* transport's bound 401 seam runs the SDK flow exactly as a challenge
|
|
2473
|
+
* without `resource_metadata` would: RFC 9728 discovery at the resource's
|
|
2474
|
+
* well-known URLs, the same issuer and resource checks, consent state, and
|
|
2475
|
+
* CIMD/DCR/static client order. Nothing is sent to the MCP endpoint.
|
|
2476
|
+
*/
|
|
2477
|
+
const startUnchallenged = authMethod((ctx) => authFlow(function* () {
|
|
2478
|
+
const required = {
|
|
2479
|
+
state: "auth_required",
|
|
2480
|
+
message: "Authorization required \u2014 open the URL to connect.",
|
|
2481
|
+
};
|
|
2482
|
+
// A test transport carries no OAuth flow to start.
|
|
2483
|
+
if (opts._transportFactory)
|
|
2484
|
+
return required;
|
|
2485
|
+
// Bound to the live epoch, which the connect just found connected: a
|
|
2486
|
+
// reset or disconnect meanwhile fails this flow's writes.
|
|
2487
|
+
const provider = newProvider(ctx);
|
|
2488
|
+
(yield authInvoke(provider, "beginFlow", []));
|
|
2489
|
+
// Never started, this transport holds no session or stream to close.
|
|
2490
|
+
const transport = buildTransport(ctx, provider);
|
|
2491
|
+
const internals = transport;
|
|
2492
|
+
try {
|
|
2493
|
+
(yield authInvoke(internals._authProvider, "onUnauthorized", [
|
|
2494
|
+
{
|
|
2495
|
+
response: new Response(null, { status: 401 }),
|
|
2496
|
+
serverUrl: new URL(opts.url),
|
|
2497
|
+
fetchFn: internals._fetchWithInit,
|
|
2498
|
+
},
|
|
2499
|
+
]));
|
|
2500
|
+
// Authorized without consent: nothing is pending to hand back.
|
|
2501
|
+
return { state: "ok", message: "Already authorized \u2014 connection is healthy." };
|
|
2502
|
+
}
|
|
2503
|
+
catch (err) {
|
|
2504
|
+
if (!(err instanceof UnauthorizedError))
|
|
2505
|
+
throw err;
|
|
2506
|
+
}
|
|
2507
|
+
const authorizationUrl = (yield authInvoke(provider, "consentUrl", [viewSignal(ctx, true)]));
|
|
2508
|
+
return { ...required, ...(authorizationUrl !== undefined ? { authorizationUrl } : {}) };
|
|
2509
|
+
}));
|
|
2392
2510
|
// Built once from construction-time values: names, framing, and public
|
|
2393
2511
|
// URLs only. A static header's value and a stored credential never reach
|
|
2394
2512
|
// it, and a URL keeps origin and path: a CIMD document's query may carry a
|
|
@@ -2408,6 +2526,9 @@ export function remoteMcp(id, opts) {
|
|
|
2408
2526
|
: isOauth
|
|
2409
2527
|
? {
|
|
2410
2528
|
mode: "oauth",
|
|
2529
|
+
...(authorizationParams.length
|
|
2530
|
+
? { authorizationParamNames: authorizationParams.map(([name]) => name) }
|
|
2531
|
+
: {}),
|
|
2411
2532
|
...(oauthScope !== undefined ? { scope: oauthScope } : {}),
|
|
2412
2533
|
...(describedClientMetadataUrl !== undefined ? { clientMetadataUrl: describedClientMetadataUrl } : {}),
|
|
2413
2534
|
}
|
|
@@ -2859,6 +2980,12 @@ export function remoteMcp(id, opts) {
|
|
|
2859
2980
|
}));
|
|
2860
2981
|
try {
|
|
2861
2982
|
(yield authCall(ensureConnected, [ctx, state]));
|
|
2983
|
+
// The grant decides, as it does for a call: a downstream that
|
|
2984
|
+
// answers without credentials is not connected for this owner.
|
|
2985
|
+
if (isOauth && !(yield authCall(granted, [ctx])))
|
|
2986
|
+
return (yield authCall(report, [
|
|
2987
|
+
{ state: "auth_required", message: "Authorization required \u2014 open the URL to connect." },
|
|
2988
|
+
]));
|
|
2862
2989
|
return (yield authCall(report, [{ state: "ok" }]));
|
|
2863
2990
|
}
|
|
2864
2991
|
catch (err) {
|
|
@@ -2963,7 +3090,11 @@ export function remoteMcp(id, opts) {
|
|
|
2963
3090
|
if (ctx.signal?.aborted)
|
|
2964
3091
|
throw ctx.signal.reason;
|
|
2965
3092
|
try {
|
|
3093
|
+
// The handshake runs first, so a 401 challenge's
|
|
3094
|
+
// `resource_metadata` starts consent as it always has.
|
|
2966
3095
|
(yield authCall(ensureConnected, [ctx, state]));
|
|
3096
|
+
if (!(yield authCall(granted, [ctx])))
|
|
3097
|
+
return (yield authCall(startUnchallenged, [ctx]));
|
|
2967
3098
|
return {
|
|
2968
3099
|
state: "ok",
|
|
2969
3100
|
message: "Already authorized \u2014 connection is healthy.",
|
|
@@ -3006,8 +3137,6 @@ export function remoteMcp(id, opts) {
|
|
|
3006
3137
|
for (const value of Object.values(opts.auth.headers))
|
|
3007
3138
|
secrets.header(value);
|
|
3008
3139
|
}
|
|
3009
|
-
for (const value of Object.values(extraHeaders ?? {}))
|
|
3010
|
-
secrets.header(value);
|
|
3011
3140
|
const state = entryFor(ctx);
|
|
3012
3141
|
let active = activeContexts.get(state);
|
|
3013
3142
|
if (!active) {
|
|
@@ -3141,5 +3270,7 @@ export function remoteMcp(id, opts) {
|
|
|
3141
3270
|
if (!(yield authInvoke(provider, "tokens", [])))
|
|
3142
3271
|
throw authRequiredError();
|
|
3143
3272
|
})), 0), 0));
|
|
3273
|
+
if (isOauth)
|
|
3274
|
+
withStoredAuth(connector, (ctx) => newProvider(ctx).storedAuthorization());
|
|
3144
3275
|
return connector;
|
|
3145
3276
|
}
|
package/dist/execute.js
CHANGED
|
@@ -1012,11 +1012,11 @@ function runSignal(caller) {
|
|
|
1012
1012
|
/**
|
|
1013
1013
|
* An admitting executor's lease, released when the run's scope closes.
|
|
1014
1014
|
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
* this request's providers and run
|
|
1019
|
-
* which workerd refuses
|
|
1015
|
+
* A queued request is handed its lease from inside whichever request released
|
|
1016
|
+
* one. The built-in queues wake it through a Promise its own request created
|
|
1017
|
+
* (src/runtime/settlement.ts), and `acquire()` is awaited as a Promise for any
|
|
1018
|
+
* executor, so this request's providers and program run here, never in the
|
|
1019
|
+
* releasing request, which workerd refuses.
|
|
1020
1020
|
*/
|
|
1021
1021
|
function leased(executor, signal) {
|
|
1022
1022
|
return Effect.acquireRelease(awaitExecutor(() => executor.acquire({ signal, wait: true }), signal, {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Duration, Effect, Exit } from "effect";
|
|
2
2
|
import { admit, provideAdmissionProgram } from "./runtime/admission.js";
|
|
3
3
|
import { fromSignal, runEdge } from "./runtime/run.js";
|
|
4
|
+
import { Settlement } from "./runtime/settlement.js";
|
|
4
5
|
import { keepErrorName } from "./redaction-identity.js";
|
|
5
6
|
/**
|
|
6
7
|
* A stable, machine-readable admission failure. Overload is retryable; caller
|
|
@@ -119,7 +120,7 @@ export class AdmissionController {
|
|
|
119
120
|
this.closed = true;
|
|
120
121
|
for (const waiter of this.waiters.splice(0)) {
|
|
121
122
|
this.closedTotal++;
|
|
122
|
-
|
|
123
|
+
waiter.outcome.settle(Exit.fail(new ExecutorAdmissionError("executor_closed", "Executor is shutting down.")));
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
overloaded(message) {
|
|
@@ -164,7 +165,7 @@ export class AdmissionController {
|
|
|
164
165
|
this.queueWaitTotalMs += waitMs;
|
|
165
166
|
this.queueWaitMaxMs = Math.max(this.queueWaitMaxMs, waitMs);
|
|
166
167
|
this.active++;
|
|
167
|
-
|
|
168
|
+
waiter.outcome.settle(Exit.succeed(this.makeLease(waitMs)));
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
pruneExpiredWaiters() {
|
|
@@ -175,7 +176,7 @@ export class AdmissionController {
|
|
|
175
176
|
continue;
|
|
176
177
|
this.waiters.splice(index, 1);
|
|
177
178
|
this.rejectedTotal++;
|
|
178
|
-
|
|
179
|
+
waiter.outcome.settle(Exit.fail(this.overloaded(`Executor admission timed out after ${this.queueTimeoutMs}ms.`)));
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
182
|
reapExpired() {
|
|
@@ -219,9 +220,11 @@ export class AdmissionController {
|
|
|
219
220
|
this.cancelledTotal++;
|
|
220
221
|
return Effect.void;
|
|
221
222
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
return Effect.sync(() => {
|
|
224
|
+
const outcome = waiter.outcome.outcome;
|
|
225
|
+
if (outcome && Exit.isSuccess(outcome))
|
|
226
|
+
outcome.value.release();
|
|
227
|
+
});
|
|
225
228
|
}
|
|
226
229
|
// The admission program, handed to src/runtime/admission.ts. It lives in a
|
|
227
230
|
// static block because the class declaration ships, private member names
|
|
@@ -254,22 +257,22 @@ export class AdmissionController {
|
|
|
254
257
|
}
|
|
255
258
|
const waiter = {
|
|
256
259
|
queuedAt: Date.now(),
|
|
257
|
-
outcome:
|
|
260
|
+
outcome: new Settlement(),
|
|
258
261
|
};
|
|
259
262
|
controller.waiters.push(waiter);
|
|
260
263
|
controller.queuedTotal++;
|
|
261
264
|
// A timeout or abort settles the waiter only if it takes it out of the
|
|
262
|
-
// queue first. Otherwise release() or close() got there
|
|
263
|
-
//
|
|
265
|
+
// queue first. Otherwise release() or close() got there first, and the
|
|
266
|
+
// outcome they recorded is the answer, rechecked once heard.
|
|
264
267
|
const giveUp = (count, error) => Effect.suspend(() => {
|
|
265
268
|
if (!controller.remove(waiter))
|
|
266
|
-
return
|
|
269
|
+
return waiter.outcome.wait();
|
|
267
270
|
count();
|
|
268
271
|
return Effect.fail(error());
|
|
269
272
|
});
|
|
270
273
|
// One flat race; each contender is a forked fiber.
|
|
271
274
|
const contenders = [
|
|
272
|
-
|
|
275
|
+
waiter.outcome.wait(),
|
|
273
276
|
Effect.sleep(Duration.millis(controller.queueTimeoutMs)).pipe(Effect.andThen(giveUp(() => controller.rejectedTotal++, () => controller.overloaded(`Executor admission timed out after ${controller.queueTimeoutMs}ms.`)))),
|
|
274
277
|
];
|
|
275
278
|
if (controller.maxDurationMs !== undefined) {
|
|
@@ -278,7 +281,18 @@ export class AdmissionController {
|
|
|
278
281
|
if (signal) {
|
|
279
282
|
contenders.push(fromSignal(signal).pipe(Effect.catch(() => giveUp(() => controller.cancelledTotal++, () => new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled while queued.")))));
|
|
280
283
|
}
|
|
281
|
-
|
|
284
|
+
// A grant is recorded in the releasing request and heard here a turn
|
|
285
|
+
// later. A shutdown in that gap wins, so nothing granted before
|
|
286
|
+
// close() starts after it: give the slot back. A grant that beat the
|
|
287
|
+
// caller's abort stays the caller's to release, as it always has.
|
|
288
|
+
const received = (lease) => Effect.suspend(() => {
|
|
289
|
+
if (!controller.closed)
|
|
290
|
+
return Effect.succeed(lease);
|
|
291
|
+
lease.release();
|
|
292
|
+
controller.closedTotal++;
|
|
293
|
+
return Effect.fail(new ExecutorAdmissionError("executor_closed", "Executor is shutting down."));
|
|
294
|
+
});
|
|
295
|
+
return Effect.raceAllFirst(contenders).pipe(Effect.flatMap(received), Effect.onInterrupt(() => controller.cleanup(waiter)));
|
|
282
296
|
}));
|
|
283
297
|
}
|
|
284
298
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// Respawn after a crash waits out a Schedule. The IPC protocol stays plain:
|
|
11
11
|
// the child's messages arrive as Node events, and host calls are the
|
|
12
12
|
// Promise-shaped provider functions execute.ts hands over.
|
|
13
|
+
import { AsyncResource } from "node:async_hooks";
|
|
13
14
|
import { Buffer } from "node:buffer";
|
|
14
15
|
import { fork } from "node:child_process";
|
|
15
16
|
import { existsSync } from "node:fs";
|
|
@@ -273,6 +274,7 @@ class QuickJsChildPool {
|
|
|
273
274
|
providers: providerMap,
|
|
274
275
|
outcome: Deferred.makeUnsafe(),
|
|
275
276
|
hosts: Scope.makeUnsafe(),
|
|
277
|
+
owner: new AsyncResource("connecta.quickjs.run"),
|
|
276
278
|
};
|
|
277
279
|
slot.active = active;
|
|
278
280
|
try {
|
|
@@ -485,7 +487,8 @@ class QuickJsChildPool {
|
|
|
485
487
|
if (message.type === "host-call") {
|
|
486
488
|
if (message.jobId !== active.id)
|
|
487
489
|
return;
|
|
488
|
-
|
|
490
|
+
// Dispatch in the program's own request, never the spawner's.
|
|
491
|
+
active.owner.runInAsyncScope(() => void detach(forkOwned(this.handleHostCall(slot, child.process, active, message), active.hosts)));
|
|
489
492
|
return;
|
|
490
493
|
}
|
|
491
494
|
if ((message.type !== "result" && message.type !== "result-chunk") || message.jobId !== active.id)
|
package/dist/google.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RequestToken } from "./request-token.js";
|
|
2
|
+
import { type GoogleServiceAccount } from "./providers/_shared/google/service-account.js";
|
|
3
|
+
export type { GoogleServiceAccount } from "./providers/_shared/google/service-account.js";
|
|
4
|
+
export interface GoogleServiceAccountTokenOptions {
|
|
5
|
+
/** The downloaded JSON key text, or its camelCase fields. Kept in deployment memory only. */
|
|
6
|
+
serviceAccount: GoogleServiceAccount | string;
|
|
7
|
+
/** Non-empty HTTPS OAuth scope URLs for the target API. */
|
|
8
|
+
scopes: readonly string[];
|
|
9
|
+
/** Optional Workspace user for domain-wide delegation; omitted means the account itself. */
|
|
10
|
+
subject?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Mint Google OAuth access tokens for remoteMcp's shared request auth on Node or Workers. */
|
|
13
|
+
export declare function googleServiceAccountToken(options: GoogleServiceAccountTokenOptions): RequestToken;
|
package/dist/google.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { assertKnownOptions, ConfigError, keys, optionsOf } from "./config-schema.js";
|
|
2
|
+
import { delegatedToken, forgetDelegatedToken, parseServiceAccount, } from "./providers/_shared/google/service-account.js";
|
|
3
|
+
const OPTIONS = optionsOf()(keys("serviceAccount", "scopes", "subject"));
|
|
4
|
+
const ACCOUNT = optionsOf()(keys("clientEmail", "privateKey", "clientId"));
|
|
5
|
+
/** Mint Google OAuth access tokens for remoteMcp's shared request auth on Node or Workers. */
|
|
6
|
+
export function googleServiceAccountToken(options) {
|
|
7
|
+
const opts = assertKnownOptions(options, "googleServiceAccountToken", OPTIONS);
|
|
8
|
+
let input = opts.serviceAccount;
|
|
9
|
+
if (typeof input === "object" && input !== null) {
|
|
10
|
+
input = assertKnownOptions(input, "googleServiceAccountToken.serviceAccount", ACCOUNT);
|
|
11
|
+
}
|
|
12
|
+
let account;
|
|
13
|
+
try {
|
|
14
|
+
account = parseServiceAccount("googleServiceAccountToken", input);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// The legacy Workspace parser may quote a JSON credential's type. Never
|
|
18
|
+
// reflect arbitrary credential-file fields through this public helper.
|
|
19
|
+
throw new ConfigError("googleServiceAccountToken.serviceAccount requires a valid Google service-account JSON key or { clientEmail, privateKey, clientId? } with a PKCS#8 RSA key of at least 2048 bits.");
|
|
20
|
+
}
|
|
21
|
+
if (!Array.isArray(opts.scopes) || opts.scopes.length === 0 || !Array.from(opts.scopes).every(validScope)) {
|
|
22
|
+
throw new ConfigError("googleServiceAccountToken.scopes must be a non-empty array of HTTPS OAuth scope URLs.");
|
|
23
|
+
}
|
|
24
|
+
if (opts.subject !== undefined && (typeof opts.subject !== "string" || !/^[^\s@]+@[^\s@]+$/.test(opts.subject))) {
|
|
25
|
+
throw new ConfigError("googleServiceAccountToken.subject must be a Workspace email address when provided.");
|
|
26
|
+
}
|
|
27
|
+
const request = { account, subject: opts.subject, scopes: [...new Set(opts.scopes)] };
|
|
28
|
+
const token = async (ctx) => await delegatedToken(request, ctx);
|
|
29
|
+
token.invalidate = async (rejected) => await forgetDelegatedToken(request, rejected);
|
|
30
|
+
return token;
|
|
31
|
+
}
|
|
32
|
+
function validScope(value) {
|
|
33
|
+
if (typeof value !== "string" || /\s/.test(value))
|
|
34
|
+
return false;
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(value);
|
|
37
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.hash;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -38,7 +38,8 @@ export { memoryStorage } from "./storage/memory.js";
|
|
|
38
38
|
export { CONNECTA_VERSION } from "./version.js";
|
|
39
39
|
export type { GuestApi, GuestResourceResult, GuestResult, GuestResultPage, GuestBlock, GuestNativeBlock, } from "./guest-types.js";
|
|
40
40
|
export type { Registry } from "./registry.js";
|
|
41
|
-
export type { RemoteMcpOptions, RemoteMcpAuth, RemoteMcpRedirectPolicy } from "./connectors/remote-mcp.js";
|
|
41
|
+
export type { RemoteMcpOptions, RemoteMcpAuth, RemoteMcpRequestAuth, RemoteMcpRedirectPolicy, } from "./connectors/remote-mcp.js";
|
|
42
|
+
export type { AgentRequestPrincipal, HumanRequestPrincipal, PrincipalForwarding, PrincipalRequestToken, RequestPrincipal, RequestPrincipalPolicy, RequestToken, RequestTokenInput, } from "./request-token.js";
|
|
42
43
|
export type { ApiHandlerContext, ApiOAuthAccess, ApiOAuthClientAuthentication, ApiOAuthConfig, ApiOptions, ApiTool, } from "./connectors/api.js";
|
|
43
44
|
export type { ConnectaBranding, ConnectaTheme, Connector, ConnectorCallAdmissionInput, ConnectorCallAdmissionPolicy, ConnectorCallAdmissionRule, ConnectorRollingWindowBudget, ConnectorCredentialAccess, ConnectorCredentialConfig, ConnectorCredentialFieldConfig, ConnectorCredentialValues, ConnectorContext, ConnectorAuthDescription, ConnectorDescription, ConnectorToolDescription, ConnectorUsageGuide, ConnectorSkill, ConnectorSkillResourceContents, DescribedEndpoint, ConnectorStatus, CredentialTestResult, AdmittingExecutor, AdmissionSnapshot, ExecuteResult, Executor, ExecutorLease, ExecutorProvider, InboundAuth, InboundAuthRuntimeContext, UiAuthConfig, AuthResult, AuthenticatedIdentity, IdentityReference, JsonSchema, KVStorage, Logger, ToolDef, ToolAnnotations, } from "./types.js";
|
|
44
45
|
export type { ActivityActor, ActivityCatalogChange, ActivityCallSource, ActivityOutcome, ActivityPage, ActivityReadActor, ActivityReadEvent, ActivityReader, ActivityReadGate, ActivityReadPage, ActivitySink, ActivityStore, AgentFriction, ToolCallActivityEvent, } from "./activity.js";
|
package/dist/invocation.js
CHANGED
|
@@ -33,13 +33,12 @@ export function timed(bucket, effect) {
|
|
|
33
33
|
/**
|
|
34
34
|
* The connector's call permit, owned by the enclosing Scope.
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* this fiber
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* admitCall's promise resumes it in its own request instead.
|
|
36
|
+
* The permit queue is shared by every request in the isolate, and a queued
|
|
37
|
+
* waiter is handed its permit from inside the request that released the
|
|
38
|
+
* slot. The queue wakes it through a Promise its own request created
|
|
39
|
+
* (src/runtime/settlement.ts), and this fiber awaits `registry.admitCall`'s
|
|
40
|
+
* Promise, so this call's downstream I/O runs in its own request, never the
|
|
41
|
+
* releasing one, which workerd refuses.
|
|
43
42
|
*
|
|
44
43
|
* The wait stays interruptible, so a deadline ends it. A permit granted just
|
|
45
44
|
* as the wait was interrupted still arrives, later, and is released on
|