@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,7 +1,10 @@
|
|
|
1
1
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
|
-
import { claimConnectorScopeCleanup
|
|
3
|
+
import { claimConnectorScopeCleanup } from "../connector-scope.js";
|
|
4
4
|
import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape } from "../credential-rules.js";
|
|
5
|
+
import { credentialFailureClass, credentialTestOutcome, writeVerification, } from "../credential-verification.js";
|
|
6
|
+
import { closeScope } from "../runtime/connector-scope.js";
|
|
7
|
+
import { withDeadlineEffect } from "../runtime/run.js";
|
|
5
8
|
import { authorizedPerson, refuse, serveOperator, managementRegistry } from "./operator.js";
|
|
6
9
|
import { boundedText, mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
|
|
7
10
|
/** The longest body, in characters, a credential write accepts. */
|
|
@@ -51,9 +54,19 @@ function readCredentialInput(request, config) {
|
|
|
51
54
|
// `null` parses, but a credential cannot be read from it.
|
|
52
55
|
if (body === null)
|
|
53
56
|
return yield* refuse("invalid JSON body", 400);
|
|
54
|
-
|
|
57
|
+
if (body.verify !== undefined && body.verify !== "test" && body.verify !== "skip") {
|
|
58
|
+
return yield* refuse('verify must be "test" or "skip"', 400);
|
|
59
|
+
}
|
|
60
|
+
const input = yield* credentialInput(body, config);
|
|
61
|
+
return { input, verify: body.verify ?? "skip" };
|
|
55
62
|
});
|
|
56
63
|
}
|
|
64
|
+
/** The test hook's refusal when the declared shape picks none, in the page's words. */
|
|
65
|
+
function untestable(mismatch) {
|
|
66
|
+
return refuse(mismatch
|
|
67
|
+
? "this connector cannot test its credential: " + describeCredentialTestMismatch(mismatch)
|
|
68
|
+
: "this connector does not support credential testing", 400);
|
|
69
|
+
}
|
|
57
70
|
function credentialRequest(context, connectorId, action) {
|
|
58
71
|
const { request, baseUrl, opts } = context;
|
|
59
72
|
return Effect.gen(function* () {
|
|
@@ -79,6 +92,64 @@ function credentialRequest(context, connectorId, action) {
|
|
|
79
92
|
const updatedBy = authz.identity.principal
|
|
80
93
|
? `${authz.identity.principal.namespace}:${authz.identity.principal.id}`
|
|
81
94
|
: (authz.actor.id ?? authz.actor.kind);
|
|
95
|
+
/**
|
|
96
|
+
* Run the connector's test hook on `values` — the stored credential or an
|
|
97
|
+
* unsaved candidate, which stays in this request's memory — under the
|
|
98
|
+
* deployment's probe deadline. Settles to an outcome class and never
|
|
99
|
+
* fails: whatever goes wrong is the test's result, not the route's. Each
|
|
100
|
+
* run gets its own context and request scope, so concurrent tests share
|
|
101
|
+
* no connection.
|
|
102
|
+
*
|
|
103
|
+
* Only the class leaves. A hook's message, and anything it throws, can
|
|
104
|
+
* quote the downstream's reply, and that reply can quote the credential
|
|
105
|
+
* it just rejected — so neither the page nor the log repeats it (INV-6):
|
|
106
|
+
* the page says fixed copy for the class, and the log records a
|
|
107
|
+
* failure's class.
|
|
108
|
+
*/
|
|
109
|
+
const runTest = (mode, values) => {
|
|
110
|
+
const timeoutError = new Error("credential test timed out");
|
|
111
|
+
return withDeadlineEffect((signal) => {
|
|
112
|
+
const ctx = registry.contextFor(connectorId, baseUrl, {}, { signal });
|
|
113
|
+
const releaseCleanup = claimConnectorScopeCleanup(ctx, connectorId);
|
|
114
|
+
return Effect.tryPromise({
|
|
115
|
+
try: async () => {
|
|
116
|
+
const result = mode === "multiple"
|
|
117
|
+
? await connector.testCredentials(values, ctx)
|
|
118
|
+
: // The single-value shape guarantees this key.
|
|
119
|
+
await connector.testCredential(values.value, ctx);
|
|
120
|
+
// A hook that turned the deadline's abort into a result still ran out of time.
|
|
121
|
+
return signal.reason === timeoutError ? "timeout" : credentialTestOutcome(result);
|
|
122
|
+
},
|
|
123
|
+
catch: (error) => (signal.reason === timeoutError ? timeoutError : error),
|
|
124
|
+
}).pipe(
|
|
125
|
+
// The scope closes here, once, however the test ends: a hook that
|
|
126
|
+
// never settles is interrupted by the deadline, and a cleanup in
|
|
127
|
+
// its Promise would never run (INV-7).
|
|
128
|
+
Effect.ensuring(Effect.suspend(() => {
|
|
129
|
+
releaseCleanup();
|
|
130
|
+
return closeScope(connector, ctx, context.defer);
|
|
131
|
+
})));
|
|
132
|
+
}, { timeoutMs: opts.config.discovery.probeTimeoutMs, timeoutError }).pipe(Effect.catch((error) => {
|
|
133
|
+
if (error === timeoutError)
|
|
134
|
+
return Effect.succeed("timeout");
|
|
135
|
+
logFailure(opts.config.logger, "credential test threw", failureRecord({ connector: connectorId }, error));
|
|
136
|
+
return Effect.succeed(credentialFailureClass(error));
|
|
137
|
+
}), Effect.map((outcome) => {
|
|
138
|
+
if (outcome !== "ok")
|
|
139
|
+
logFailure(opts.config.logger, "credential test failed", failureRecord({ connector: connectorId }));
|
|
140
|
+
return outcome;
|
|
141
|
+
}));
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Record what was observed about the save with `revision`, or (`null`)
|
|
145
|
+
* forget it, in the connector's own owner-partitioned storage. A save
|
|
146
|
+
* without a revision (a vault that keeps none) is never recorded. Best
|
|
147
|
+
* effort: a lost record only leaves status saying the credential is not
|
|
148
|
+
* verified, so it never fails the request.
|
|
149
|
+
*/
|
|
150
|
+
const record = (outcome, revision) => Effect.promise(async () => outcome && !revision
|
|
151
|
+
? undefined
|
|
152
|
+
: writeVerification(registry.contextFor(connectorId, baseUrl).storage, outcome, revision).catch((error) => void logFailure(opts.config.logger, "credential verification record failed", failureRecord({ connector: connectorId }, error))));
|
|
82
153
|
if (action === "test") {
|
|
83
154
|
if (request.method !== "POST")
|
|
84
155
|
return yield* refuse("method not allowed", 405);
|
|
@@ -86,74 +157,69 @@ function credentialRequest(context, connectorId, action) {
|
|
|
86
157
|
// Credentials page asks for its Test affordance, so a shown button reaches
|
|
87
158
|
// that reads the shape the credential was stored in.
|
|
88
159
|
const { mode, mismatch } = credentialTestRule(connector);
|
|
89
|
-
if (!mode)
|
|
90
|
-
return yield*
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// rejected — so neither the page nor the log repeats it (INV-6): the
|
|
99
|
-
// page says a fixed sentence for the outcome, and the log records a
|
|
100
|
-
// failure's class.
|
|
101
|
-
return yield* Effect.tryPromise({
|
|
102
|
-
try: async () => {
|
|
103
|
-
const values = await vault.getAll(connectorId, owner);
|
|
104
|
-
const shape = storedCredentialShape(credential, values);
|
|
105
|
-
// Both refusals are the route's own words, and each carries the
|
|
106
|
-
// problem kind the page keys its copy and fix prompt off.
|
|
107
|
-
if (shape.state === "missing") {
|
|
108
|
-
return privateJson({
|
|
109
|
-
error: mode === "multiple"
|
|
110
|
-
? "configure the credentials before testing them"
|
|
111
|
-
: "configure the credential before testing it",
|
|
112
|
-
problem: "credential_required",
|
|
113
|
-
}, { status: 409 });
|
|
114
|
-
}
|
|
115
|
-
if (shape.state === "mismatch") {
|
|
116
|
-
return privateJson({ error: shape.message, problem: "credential_mismatch" }, { status: 409 });
|
|
117
|
-
}
|
|
118
|
-
const storedValues = values;
|
|
119
|
-
const ctx = registry.contextFor(connectorId, baseUrl);
|
|
120
|
-
const releaseCleanup = claimConnectorScopeCleanup(ctx, connectorId);
|
|
121
|
-
try {
|
|
122
|
-
const result = mode === "multiple"
|
|
123
|
-
? await connector.testCredentials(storedValues, ctx)
|
|
124
|
-
: await connector.testCredential(
|
|
125
|
-
// The single-value shape check above guarantees this key.
|
|
126
|
-
storedValues.value, ctx);
|
|
127
|
-
const ok = result?.ok === true;
|
|
128
|
-
if (!ok)
|
|
129
|
-
logFailure(opts.config.logger, "credential test failed", failureRecord({ connector: connectorId }, undefined));
|
|
130
|
-
return privateJson({ ok });
|
|
131
|
-
}
|
|
132
|
-
finally {
|
|
133
|
-
releaseCleanup();
|
|
134
|
-
await closeConnectorScope(connector, ctx, context.defer);
|
|
135
|
-
}
|
|
136
|
-
},
|
|
160
|
+
if (!mode)
|
|
161
|
+
return yield* untestable(mismatch);
|
|
162
|
+
// One envelope read gives both the values tested and the revision of
|
|
163
|
+
// the save they came from, so the record can never vouch for another
|
|
164
|
+
// save. A vault without snapshots is tested but never recorded.
|
|
165
|
+
const stored = yield* Effect.tryPromise({
|
|
166
|
+
try: async () => vault.snapshot
|
|
167
|
+
? ((await vault.snapshot(connectorId, owner)) ?? { values: null })
|
|
168
|
+
: { values: await vault.getAll(connectorId, owner) },
|
|
137
169
|
catch: (error) => error,
|
|
138
170
|
}).pipe(Effect.catch((error) => {
|
|
139
171
|
logFailure(opts.config.logger, "credential test threw", failureRecord({ connector: connectorId }, error));
|
|
140
|
-
return Effect.succeed(
|
|
172
|
+
return Effect.succeed(undefined);
|
|
141
173
|
}));
|
|
174
|
+
if (!stored)
|
|
175
|
+
return privateJson({ ok: false, failure: "failed" });
|
|
176
|
+
const shape = storedCredentialShape(credential, stored.values);
|
|
177
|
+
// Both refusals are the route's own words, and each carries the
|
|
178
|
+
// problem kind the page keys its copy and fix prompt off.
|
|
179
|
+
if (shape.state === "missing") {
|
|
180
|
+
return privateJson({
|
|
181
|
+
error: mode === "multiple"
|
|
182
|
+
? "configure the credentials before testing them"
|
|
183
|
+
: "configure the credential before testing it",
|
|
184
|
+
problem: "credential_required",
|
|
185
|
+
}, { status: 409 });
|
|
186
|
+
}
|
|
187
|
+
if (shape.state === "mismatch") {
|
|
188
|
+
return privateJson({ error: shape.message, problem: "credential_mismatch" }, { status: 409 });
|
|
189
|
+
}
|
|
190
|
+
const outcome = yield* runTest(mode, stored.values);
|
|
191
|
+
if ("metadata" in stored)
|
|
192
|
+
yield* record(outcome, stored.metadata.revision);
|
|
193
|
+
return privateJson(outcome === "ok" ? { ok: true } : { ok: false, failure: outcome });
|
|
142
194
|
}
|
|
143
195
|
if (action)
|
|
144
196
|
return yield* refuse("not found", 404);
|
|
145
197
|
if (request.method === "PUT") {
|
|
146
|
-
const input = yield* readCredentialInput(request, credential);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
198
|
+
const { input, verify } = yield* readCredentialInput(request, credential);
|
|
199
|
+
const values = input.kind === "single" ? { value: input.value } : input.values;
|
|
200
|
+
if (verify === "test") {
|
|
201
|
+
// Test then save: the candidate is tested in memory and stored only
|
|
202
|
+
// if the test passes. A failure stores nothing and names its class;
|
|
203
|
+
// the operator may still save it unverified with `verify: "skip"`,
|
|
204
|
+
// so an outage at the vendor never blocks a replacement.
|
|
205
|
+
const { mode, mismatch } = credentialTestRule(connector);
|
|
206
|
+
if (!mode)
|
|
207
|
+
return yield* untestable(mismatch);
|
|
208
|
+
const outcome = yield* runTest(mode, values);
|
|
209
|
+
if (outcome !== "ok")
|
|
210
|
+
return privateJson({ saved: false, failure: outcome });
|
|
211
|
+
}
|
|
212
|
+
const metadata = yield* Effect.tryPromise({
|
|
213
|
+
try: () => input.kind === "single"
|
|
214
|
+
? vault.set(connectorId, input.value, updatedBy, owner)
|
|
215
|
+
: vault.setAll(connectorId, input.values, updatedBy, owner),
|
|
155
216
|
catch: (error) => error,
|
|
156
217
|
}).pipe(Effect.catch((error) => refuse(msg(error), 400)));
|
|
218
|
+
// The write's own revision: a concurrent save cannot lend it one.
|
|
219
|
+
const { revision, ...saved } = metadata;
|
|
220
|
+
const verification = yield* record(verify === "test" ? "ok" : "unverified", revision);
|
|
221
|
+
yield* Effect.promise(() => registry.invalidateStored(connectorId));
|
|
222
|
+
return privateJson({ saved: true, credential: saved, ...(verification ? { verification } : {}) });
|
|
157
223
|
}
|
|
158
224
|
if (request.method === "DELETE") {
|
|
159
225
|
// Clearing nothing is refused, as testing nothing is, so the route
|
|
@@ -168,6 +234,9 @@ function credentialRequest(context, connectorId, action) {
|
|
|
168
234
|
return privateJson({ error: "no credential is stored to clear", problem: "credential_required" }, { status: 409 });
|
|
169
235
|
}
|
|
170
236
|
yield* Effect.promise(() => vault.delete(connectorId, owner));
|
|
237
|
+
// A record about a value no longer stored would match nothing anyway;
|
|
238
|
+
// removing it keeps the partition clean.
|
|
239
|
+
yield* record(null);
|
|
171
240
|
yield* Effect.promise(() => registry.invalidateStored(connectorId));
|
|
172
241
|
return new Response(null, {
|
|
173
242
|
status: 204,
|
package/dist/routes/dispatch.js
CHANGED
|
@@ -28,7 +28,11 @@ export function createRouteDispatcher(mcp, metadata, health) {
|
|
|
28
28
|
});
|
|
29
29
|
const afterUi = (program) => (context) => Effect.gen(function* () {
|
|
30
30
|
const ui = context.opts.config.ui;
|
|
31
|
-
|
|
31
|
+
// The operator surface is a Promise edge that owns the request's
|
|
32
|
+
// signal: when the caller leaves, its routes stop on their own. Wait
|
|
33
|
+
// for it to settle rather than abandoning it, so writes it has
|
|
34
|
+
// already started end inside this request (INV-7).
|
|
35
|
+
const response = ui ? yield* Effect.uninterruptible(Effect.promise(() => ui.handle(context))) : null;
|
|
32
36
|
if (response?.status === 401) {
|
|
33
37
|
const authorizationHeader = authorizationCredential(context.request).kind;
|
|
34
38
|
logFailure(context.opts.config.logger, "operator_refused", failureRecord({
|
package/dist/routes/mcp.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { routeNames } from "../agent-routes.js";
|
|
1
2
|
import { upwardStep } from "../upward.js";
|
|
3
|
+
import { bindDiscoveryEndpoint } from "../discovery-health.js";
|
|
2
4
|
import { authCall, authInvoke, authFlow, authMethod } from "../auth/native.js";
|
|
3
5
|
import { registryProgram } from "../runtime/registry.js";
|
|
4
6
|
import { requestOwner, RequestScope } from "../runtime/request-scope.js";
|
|
@@ -20,7 +22,7 @@ import { failureRecord, logFailure } from "../operator-record.js";
|
|
|
20
22
|
import { redactAgentOutput, sentSecretsForRequest } from "../sent-secrets.js";
|
|
21
23
|
import { detach } from "../runtime/run.js";
|
|
22
24
|
import { agentEndpointPool, isAgentPath } from "./agent-path.js";
|
|
23
|
-
import { admitsMaintainer, agentPool, authorize, authorizeAgent, mayManageConnector, memberPool, } from "./shared.js";
|
|
25
|
+
import { admitsMaintainer, agentPool, authorize, authorizeAgent, mayManageConnector, memberPool, personPools, } from "./shared.js";
|
|
24
26
|
/**
|
|
25
27
|
* Core checks admission.requests.maxBodyBytes after authentication and endpoint
|
|
26
28
|
* admission, before any SDK/body-hint reader. The SDK's separate 4 MiB default
|
|
@@ -396,8 +398,11 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
396
398
|
...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
|
|
397
399
|
});
|
|
398
400
|
const diagnostics = native ? { ...native, catalogErrors: [] } : undefined;
|
|
401
|
+
// A native pool's text uses the native contract's wording.
|
|
402
|
+
const routes = native ? routeNames(native.executeCode ? "native-code" : "native") : opts.routes;
|
|
399
403
|
const nativeRoute = registerMetaTools(server, registry, {
|
|
400
404
|
native: diagnostics,
|
|
405
|
+
routes,
|
|
401
406
|
authElicitation,
|
|
402
407
|
client,
|
|
403
408
|
baseUrl,
|
|
@@ -430,6 +435,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
430
435
|
...executeLimits(opts.config),
|
|
431
436
|
readOnly,
|
|
432
437
|
native: native !== undefined,
|
|
438
|
+
routes,
|
|
433
439
|
});
|
|
434
440
|
}
|
|
435
441
|
if (native && nativeRoute && hint) {
|
|
@@ -597,6 +603,9 @@ export function createMcpRoute(opts) {
|
|
|
597
603
|
const requestScope = {};
|
|
598
604
|
const sentSecrets = sentSecretsForRequest(requestScope, sentSecretsForRequest(request));
|
|
599
605
|
const poolName = endpoint.kind === "pool" || endpoint.kind === "agent" ? endpoint.pool : undefined;
|
|
606
|
+
// Discovery health is kept per admitted endpoint: the same person and
|
|
607
|
+
// pool at `/mcp` and `/mcp/<pool>` keep separate records.
|
|
608
|
+
bindDiscoveryEndpoint(requestScope, JSON.stringify([endpoint.kind, poolName ?? null]));
|
|
600
609
|
const agentEndpoint = endpoint.kind === "agent";
|
|
601
610
|
// Access attaches `ctx.access` even to a path it bypasses; an agent
|
|
602
611
|
// endpoint never reads it, so nothing past this point receives it.
|
|
@@ -719,13 +728,17 @@ export function createMcpRoute(opts) {
|
|
|
719
728
|
false,
|
|
720
729
|
opts.config.logger,
|
|
721
730
|
]));
|
|
722
|
-
// Plain /mcp
|
|
731
|
+
// Plain /mcp serves maintainers the whole catalog and other people their unique pool; a pool
|
|
723
732
|
// admits its members, people at /mcp/<pool> and agents at
|
|
724
733
|
// /agent/<pool>, and serves exactly its tools (INV-4).
|
|
725
734
|
const scopeFor = (admitted) => {
|
|
726
735
|
if (poolName === undefined) {
|
|
727
|
-
|
|
728
|
-
|
|
736
|
+
if (admitsMaintainer(opts, admitted))
|
|
737
|
+
return { access: { connectorIds: "all" }, readOnly: opts.config.readOnly };
|
|
738
|
+
// Exactly one membership routes; none or several fail closed.
|
|
739
|
+
const [match, ...more] = personPools(opts, admitted.identity);
|
|
740
|
+
return match && !more.length
|
|
741
|
+
? { pool: match[0], access: match[1].access, readOnly: match[1].readOnly }
|
|
729
742
|
: undefined;
|
|
730
743
|
}
|
|
731
744
|
const pool = agentEndpoint
|
|
@@ -735,6 +748,7 @@ export function createMcpRoute(opts) {
|
|
|
735
748
|
// same caller facts (#837 P5: agents act as the pool owner).
|
|
736
749
|
return pool
|
|
737
750
|
? {
|
|
751
|
+
pool: poolName,
|
|
738
752
|
access: pool.access,
|
|
739
753
|
readOnly: pool.readOnly,
|
|
740
754
|
...(pool.owner !== undefined ? { poolOwner: pool.owner } : {}),
|
|
@@ -780,14 +794,16 @@ export function createMcpRoute(opts) {
|
|
|
780
794
|
? "MCP agent endpoint request denied"
|
|
781
795
|
: "MCP pool request denied", failureRecord({
|
|
782
796
|
reason: poolName === undefined
|
|
783
|
-
?
|
|
797
|
+
? personPools(opts, authz.identity).length > 1
|
|
798
|
+
? "ambiguous_pool"
|
|
799
|
+
: "not_maintainer"
|
|
784
800
|
: opts.pools.has(poolName)
|
|
785
801
|
? "not_pool_member"
|
|
786
802
|
: "pool_not_declared",
|
|
787
803
|
}));
|
|
788
804
|
return notFound();
|
|
789
805
|
}
|
|
790
|
-
const { access, readOnly
|
|
806
|
+
const { access, readOnly } = scope;
|
|
791
807
|
// Admission, not completed-call activity. This fact carries no live
|
|
792
808
|
// identity or credential and never changes the admission verdict.
|
|
793
809
|
if (agentEndpoint && authz.identity.agent && opts.config.agents.includes(authz.identity.agent)) {
|
|
@@ -812,15 +828,15 @@ export function createMcpRoute(opts) {
|
|
|
812
828
|
}
|
|
813
829
|
// The caller rides on the view, and the paging recheck builds the
|
|
814
830
|
// same one: an agent's Workspace visibility depends on the owner.
|
|
815
|
-
const callerFor = (identity,
|
|
831
|
+
const callerFor = (identity, admittedScope) => ({
|
|
816
832
|
identity,
|
|
817
833
|
// `authorize` admits an open deployment's every request as the
|
|
818
834
|
// anonymous actor; only a provider's `ok` is an authentication.
|
|
819
835
|
authenticated: opts.config.auth.length > 0,
|
|
820
836
|
// Both pool endpoints build the caller here from the same
|
|
821
837
|
// admitted scope, so an agent at /agent/<pool> carries the owner.
|
|
822
|
-
...(
|
|
823
|
-
...(
|
|
838
|
+
...(admittedScope.pool !== undefined ? { pool: admittedScope.pool } : {}),
|
|
839
|
+
...(admittedScope.poolOwner !== undefined ? { poolOwner: admittedScope.poolOwner } : {}),
|
|
824
840
|
});
|
|
825
841
|
let scopedRegistry;
|
|
826
842
|
try {
|
|
@@ -844,14 +860,14 @@ export function createMcpRoute(opts) {
|
|
|
844
860
|
return false;
|
|
845
861
|
// Paging re-admits the caller at this endpoint, as a new request would.
|
|
846
862
|
const currentScope = scopeFor(current);
|
|
847
|
-
if (!currentScope)
|
|
863
|
+
if (!currentScope || currentScope.pool !== scope.pool)
|
|
848
864
|
return false;
|
|
849
|
-
const { access: currentAccess, readOnly: currentReadOnly
|
|
865
|
+
const { access: currentAccess, readOnly: currentReadOnly } = currentScope;
|
|
850
866
|
const view = opts.registry.scoped({
|
|
851
867
|
...currentAccess,
|
|
852
868
|
...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
|
|
853
869
|
...(current.principalKey ? { principalKey: current.principalKey } : {}),
|
|
854
|
-
caller: callerFor(current.identity,
|
|
870
|
+
caller: callerFor(current.identity, currentScope),
|
|
855
871
|
});
|
|
856
872
|
if (classification === "write" && currentReadOnly)
|
|
857
873
|
return false;
|
|
@@ -898,7 +914,7 @@ export function createMcpRoute(opts) {
|
|
|
898
914
|
}
|
|
899
915
|
}
|
|
900
916
|
})),
|
|
901
|
-
caller: callerFor(authz.identity,
|
|
917
|
+
caller: callerFor(authz.identity, scope),
|
|
902
918
|
});
|
|
903
919
|
}
|
|
904
920
|
catch {
|
|
@@ -910,7 +926,7 @@ export function createMcpRoute(opts) {
|
|
|
910
926
|
return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
|
|
911
927
|
const connector = scopedRegistry.getConnector(id);
|
|
912
928
|
return Boolean(connector && mayManageConnector(opts, authz, connector));
|
|
913
|
-
}, authz.principalKey, runtimeContext, readOnly,
|
|
929
|
+
}, authz.principalKey, runtimeContext, readOnly, scope.pool, authz.identity.principal
|
|
914
930
|
? {
|
|
915
931
|
kind: authz.actor.kind,
|
|
916
932
|
id: authz.identity.principal.id,
|
package/dist/routes/operator.js
CHANGED
|
@@ -93,12 +93,12 @@ export function operatorViewerKey(context, authz) {
|
|
|
93
93
|
}).pipe(Effect.catch(() => refuse("operator identity could not be read", 503)));
|
|
94
94
|
}
|
|
95
95
|
/** The partition an identity carries into a registry view of the given connectors. */
|
|
96
|
-
export function scopeFor(authz, authenticated, connectorIds = "all") {
|
|
96
|
+
export function scopeFor(authz, authenticated, connectorIds = "all", pool) {
|
|
97
97
|
return {
|
|
98
98
|
connectorIds,
|
|
99
99
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
100
100
|
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
101
|
-
operatorCaller: { identity: authz.identity, authenticated },
|
|
101
|
+
operatorCaller: { identity: authz.identity, authenticated, ...(pool !== undefined ? { pool } : {}) },
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
/**
|
package/dist/routes/shared.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AgentRouteNames } from "../types.js";
|
|
1
2
|
import type { ActivityActor } from "../activity.js";
|
|
2
3
|
import type { DeferredWork } from "../connector-scope.js";
|
|
3
4
|
import type { AdmissionController } from "../executor-admission.js";
|
|
@@ -28,6 +29,8 @@ export interface ServerOptions {
|
|
|
28
29
|
executorName?: string | undefined;
|
|
29
30
|
/** Global FIFO boundary for all non-preflight `/mcp` requests. */
|
|
30
31
|
requestAdmission: AdmissionController;
|
|
32
|
+
/** Route names agent text uses outside native pools: meta, or program form for the eval code arm only. */
|
|
33
|
+
routes: AgentRouteNames;
|
|
31
34
|
}
|
|
32
35
|
export interface RuntimeExecutionContext extends InboundAuthRuntimeContext {
|
|
33
36
|
waitUntil(promise: Promise<unknown>): void;
|
|
@@ -93,7 +96,7 @@ type AuthorizedIdentity = Extract<Awaited<ReturnType<typeof authorize>>, {
|
|
|
93
96
|
ok: true;
|
|
94
97
|
}>;
|
|
95
98
|
/**
|
|
96
|
-
*
|
|
99
|
+
* Every operator route admits a maintainer. An open
|
|
97
100
|
* deployment has no inbound auth and so no maintainers: its one anonymous
|
|
98
101
|
* caller keeps the whole default endpoint, as the startup warning says.
|
|
99
102
|
*/
|
|
@@ -103,6 +106,8 @@ export declare function admitsMaintainer(opts: ServerOptions, authz: AuthorizedI
|
|
|
103
106
|
* same 404. An agent is never admitted here, even when listed.
|
|
104
107
|
*/
|
|
105
108
|
export declare function memberPool(opts: ServerOptions, name: string, identity: Readonly<AuthenticatedIdentity>): ResolvedPool | undefined;
|
|
109
|
+
/** Human memberships, matched only against the resolved namespaced principal. */
|
|
110
|
+
export declare function personPools(opts: ServerOptions, identity: Readonly<AuthenticatedIdentity>): Array<[string, ResolvedPool]>;
|
|
106
111
|
/** The declared pool `/agent/<name>` serves to this agent, or undefined for the same 404. */
|
|
107
112
|
export declare function agentPool(opts: ServerOptions, name: string, identity: Readonly<AuthenticatedIdentity>): ResolvedPool | undefined;
|
|
108
113
|
/**
|
package/dist/routes/shared.js
CHANGED
|
@@ -392,7 +392,7 @@ export function withSecurityHeaders(response, requestUrl) {
|
|
|
392
392
|
});
|
|
393
393
|
}
|
|
394
394
|
/**
|
|
395
|
-
*
|
|
395
|
+
* Every operator route admits a maintainer. An open
|
|
396
396
|
* deployment has no inbound auth and so no maintainers: its one anonymous
|
|
397
397
|
* caller keeps the whole default endpoint, as the startup warning says.
|
|
398
398
|
*/
|
|
@@ -409,6 +409,12 @@ export function memberPool(opts, name, identity) {
|
|
|
409
409
|
const pool = opts.pools.get(name);
|
|
410
410
|
return pool?.members.some((member) => isMember(member, identity)) ? pool : undefined;
|
|
411
411
|
}
|
|
412
|
+
/** Human memberships, matched only against the resolved namespaced principal. */
|
|
413
|
+
export function personPools(opts, identity) {
|
|
414
|
+
if (identity.agent !== undefined)
|
|
415
|
+
return [];
|
|
416
|
+
return [...opts.pools].filter(([, pool]) => pool.members.some((member) => isMember(member, identity)));
|
|
417
|
+
}
|
|
412
418
|
/** The declared pool `/agent/<name>` serves to this agent, or undefined for the same 404. */
|
|
413
419
|
export function agentPool(opts, name, identity) {
|
|
414
420
|
if (identity.agent === undefined)
|
package/dist/routes/ui-config.js
CHANGED
|
@@ -553,7 +553,7 @@ function memberRead(context, authz, pools) {
|
|
|
553
553
|
return Effect.gen(function* () {
|
|
554
554
|
const allIds = opts.registry.listConnectors().map((c) => c.id);
|
|
555
555
|
const ids = new Set(allIds.filter((id) => pools.some(([, pool]) => servesConnector(pool, id))));
|
|
556
|
-
const registry = opts.registry.scoped(scopeFor(authz, opts.config.auth.length > 0, [...ids]));
|
|
556
|
+
const registry = opts.registry.scoped(scopeFor(authz, opts.config.auth.length > 0, [...ids], pools.length === 1 ? pools[0][0] : undefined));
|
|
557
557
|
const selected = context.url.searchParams.get("connector");
|
|
558
558
|
const visible = registry
|
|
559
559
|
.listConnectors()
|
|
@@ -571,7 +571,7 @@ function memberRead(context, authz, pools) {
|
|
|
571
571
|
root: null,
|
|
572
572
|
pools: pools.map(([name, pool]) => ({
|
|
573
573
|
name,
|
|
574
|
-
humanPath: `/mcp/${name}`,
|
|
574
|
+
humanPath: pools.length === 1 ? "/mcp" : `/mcp/${name}`,
|
|
575
575
|
agentPath: `/agent/${name}`,
|
|
576
576
|
readOnly: pool.readOnly,
|
|
577
577
|
surface: pool.native ? "native" : "meta",
|