@zackbart/connecta 0.25.0 → 0.26.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/AGENTS.md +6 -5
- package/CHANGELOG.md +271 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +449 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +194 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/catalog-service.js +39 -29
- package/dist/catalog.js +42 -8
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- package/dist/connectors/remote-mcp.js +16 -2
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +427 -70
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +79 -14
- package/dist/executors/quickjs.js +4 -2
- package/dist/index.d.ts +62 -6
- package/dist/index.js +230 -6
- package/dist/invocation.js +56 -10
- package/dist/meta-tools.js +7 -2
- package/dist/module-contracts.d.ts +21 -1
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +62 -5
- package/dist/operator-ui/view.js +94 -5
- package/dist/program-source.js +116 -0
- package/dist/registry.d.ts +42 -6
- package/dist/registry.js +148 -47
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +6 -69
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +27 -5
- package/dist/routes/mcp.js +116 -59
- package/dist/routes/oauth-management.js +22 -9
- package/dist/routes/operator.js +1 -0
- package/dist/routes/shared.d.ts +23 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +2 -1
- package/dist/routes/ui.js +13 -4
- package/dist/run-journal.js +347 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/server.js +26 -0
- package/dist/skills.js +27 -8
- package/dist/timeout.js +26 -0
- package/dist/tool-safety.d.ts +35 -0
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +18 -0
- package/dist/ui.d.ts +12 -5
- package/dist/ui.js +32 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +196 -19
- package/documentation/auth.md +83 -2
- package/documentation/code-mode.md +221 -48
- package/documentation/meta-tools.md +44 -25
- package/ethos.md +12 -10
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +174 -10
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/index.ts +36 -1
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +5 -1
- package/package.json +6 -2
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
package/dist/routes/activity.js
CHANGED
|
@@ -1,71 +1,8 @@
|
|
|
1
1
|
import { Duration, Effect } from "effect";
|
|
2
2
|
import { InvalidActivityCursorError, } from "../activity.js";
|
|
3
|
+
import { ACTOR_LABEL_BUDGET_MS, ACTOR_LABEL_CONCURRENCY, actorKey, cleanActorLabel, labelLookups, } from "./actor-labels.js";
|
|
3
4
|
import { authorized, refuse, serveOperator } from "./operator.js";
|
|
4
|
-
import {
|
|
5
|
-
const ACTIVITY_LABEL_CONCURRENCY = 8;
|
|
6
|
-
const ACTIVITY_LABEL_PAGE_BUDGET_MS = 1_500;
|
|
7
|
-
const ACTIVITY_LABEL_MAX_LENGTH = 160;
|
|
8
|
-
function cleanActivityActorLabel(value) {
|
|
9
|
-
if (typeof value !== "string")
|
|
10
|
-
return undefined;
|
|
11
|
-
const compact = value.replace(/\s+/gu, " ").trim();
|
|
12
|
-
if (!compact)
|
|
13
|
-
return undefined;
|
|
14
|
-
return Array.from(compact).slice(0, ACTIVITY_LABEL_MAX_LENGTH).join("");
|
|
15
|
-
}
|
|
16
|
-
function actorKey(actor) {
|
|
17
|
-
return JSON.stringify([actor.kind, actor.namespace, actor.id]);
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* One lookup per distinct actor on the page, each against the single provider
|
|
21
|
-
* that owns the actor's directory. An actor no provider can claim unambiguously
|
|
22
|
-
* gets no lookup at all.
|
|
23
|
-
*/
|
|
24
|
-
function labelLookups(page, auth) {
|
|
25
|
-
const identities = new Map();
|
|
26
|
-
for (const event of page.events) {
|
|
27
|
-
if (!event.actor.id)
|
|
28
|
-
continue;
|
|
29
|
-
identities.set(actorKey(event.actor), {
|
|
30
|
-
kind: event.actor.kind,
|
|
31
|
-
id: event.actor.id,
|
|
32
|
-
...(event.actor.namespace ? { namespace: event.actor.namespace } : {}),
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
const lookups = [];
|
|
36
|
-
for (const [key, identity] of identities) {
|
|
37
|
-
const sameKindProviders = auth
|
|
38
|
-
.map((provider, index) => ({ provider, index }))
|
|
39
|
-
.filter(({ provider }) => provider.kind === identity.kind);
|
|
40
|
-
const candidates = sameKindProviders.filter(({ provider }) => Boolean(provider.activityActorLabel));
|
|
41
|
-
const eligible = identity.namespace
|
|
42
|
-
? candidates.filter(({ provider }) => activityActorNamespace(provider) === identity.namespace)
|
|
43
|
-
: (() => {
|
|
44
|
-
const directoryKey = ({ provider, index, }) => {
|
|
45
|
-
const namespace = activityActorNamespace(provider);
|
|
46
|
-
return namespace === undefined
|
|
47
|
-
? `provider:${index}`
|
|
48
|
-
: `namespace:${namespace}`;
|
|
49
|
-
};
|
|
50
|
-
// Every same-kind provider participates in the ambiguity check,
|
|
51
|
-
// even if it cannot resolve labels. Otherwise a legacy ID owned
|
|
52
|
-
// by a provider without a resolver could be disclosed to a
|
|
53
|
-
// different provider that happens to have one.
|
|
54
|
-
const directories = new Set(sameKindProviders.map(directoryKey));
|
|
55
|
-
if (directories.size !== 1)
|
|
56
|
-
return [];
|
|
57
|
-
const [directory] = directories;
|
|
58
|
-
return candidates.filter((candidate) => directoryKey(candidate) === directory);
|
|
59
|
-
})();
|
|
60
|
-
// One namespace is one directory. Use its first configured resolver so
|
|
61
|
-
// duplicate gate adapters over the same Clerk instance do not multiply
|
|
62
|
-
// the provider-level concurrency cap.
|
|
63
|
-
const provider = eligible[0]?.provider;
|
|
64
|
-
if (provider)
|
|
65
|
-
lookups.push({ key, id: identity.id, provider });
|
|
66
|
-
}
|
|
67
|
-
return lookups;
|
|
68
|
-
}
|
|
5
|
+
import { privateJson } from "./shared.js";
|
|
69
6
|
/**
|
|
70
7
|
* Add display-only actor labels to one authorized activity page. Resolution is
|
|
71
8
|
* best-effort, bounded, and read-time only: stored events retain stable ids and
|
|
@@ -78,14 +15,14 @@ function labelLookups(page, auth) {
|
|
|
78
15
|
function enrichActivityActorLabels(page, auth) {
|
|
79
16
|
return Effect.suspend(() => {
|
|
80
17
|
const labels = new Map();
|
|
81
|
-
const resolve = ({ key, id, provider }) => Effect.tryPromise(() => Promise.resolve(provider.activityActorLabel(id))).pipe(Effect.map(
|
|
18
|
+
const resolve = ({ key, id, provider }) => Effect.tryPromise(() => Promise.resolve(provider.activityActorLabel(id))).pipe(Effect.map(cleanActorLabel), Effect.orElseSucceed(() => undefined), Effect.map((label) => {
|
|
82
19
|
if (label)
|
|
83
20
|
labels.set(key, label);
|
|
84
21
|
}));
|
|
85
|
-
return Effect.forEach(labelLookups(page, auth), resolve, {
|
|
86
|
-
concurrency:
|
|
22
|
+
return Effect.forEach(labelLookups(page.events.map((event) => event.actor), auth), resolve, {
|
|
23
|
+
concurrency: ACTOR_LABEL_CONCURRENCY,
|
|
87
24
|
discard: true,
|
|
88
|
-
}).pipe(Effect.timeoutOption(Duration.millis(
|
|
25
|
+
}).pipe(Effect.timeoutOption(Duration.millis(ACTOR_LABEL_BUDGET_MS)), Effect.map(() => ({
|
|
89
26
|
...page,
|
|
90
27
|
events: page.events.map((event) => {
|
|
91
28
|
const resolved = event.actor.id
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Display labels for stored actors, shared by the activity page and the
|
|
2
|
+
// artifact library. An actor is stored as a stable id; a label is resolved at
|
|
3
|
+
// read time from the one auth provider that owns the actor's directory, and
|
|
4
|
+
// only on an authorized read path. Nothing here is persisted, and a label a
|
|
5
|
+
// store happens to carry is never trusted or echoed.
|
|
6
|
+
import { activityActorNamespace } from "./shared.js";
|
|
7
|
+
export const ACTOR_LABEL_CONCURRENCY = 8;
|
|
8
|
+
export const ACTOR_LABEL_BUDGET_MS = 1_500;
|
|
9
|
+
const ACTOR_LABEL_MAX_LENGTH = 160;
|
|
10
|
+
export function cleanActorLabel(value) {
|
|
11
|
+
if (typeof value !== "string")
|
|
12
|
+
return undefined;
|
|
13
|
+
const compact = value.replace(/\s+/gu, " ").trim();
|
|
14
|
+
if (!compact)
|
|
15
|
+
return undefined;
|
|
16
|
+
return Array.from(compact).slice(0, ACTOR_LABEL_MAX_LENGTH).join("");
|
|
17
|
+
}
|
|
18
|
+
export function actorKey(actor) {
|
|
19
|
+
return JSON.stringify([actor.kind, actor.namespace, actor.id]);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* One lookup per distinct actor, each against the single provider that owns
|
|
23
|
+
* the actor's directory. An actor no provider can claim unambiguously gets no
|
|
24
|
+
* lookup at all.
|
|
25
|
+
*/
|
|
26
|
+
export function labelLookups(actors, auth) {
|
|
27
|
+
const identities = new Map();
|
|
28
|
+
for (const actor of actors) {
|
|
29
|
+
if (!actor.id)
|
|
30
|
+
continue;
|
|
31
|
+
identities.set(actorKey(actor), {
|
|
32
|
+
kind: actor.kind,
|
|
33
|
+
id: actor.id,
|
|
34
|
+
...(actor.namespace ? { namespace: actor.namespace } : {}),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const lookups = [];
|
|
38
|
+
for (const [key, identity] of identities) {
|
|
39
|
+
const sameKindProviders = auth
|
|
40
|
+
.map((provider, index) => ({ provider, index }))
|
|
41
|
+
.filter(({ provider }) => provider.kind === identity.kind);
|
|
42
|
+
const candidates = sameKindProviders.filter(({ provider }) => Boolean(provider.activityActorLabel));
|
|
43
|
+
const eligible = identity.namespace
|
|
44
|
+
? candidates.filter(({ provider }) => activityActorNamespace(provider) === identity.namespace)
|
|
45
|
+
: (() => {
|
|
46
|
+
const directoryKey = ({ provider, index, }) => {
|
|
47
|
+
const namespace = activityActorNamespace(provider);
|
|
48
|
+
return namespace === undefined
|
|
49
|
+
? `provider:${index}`
|
|
50
|
+
: `namespace:${namespace}`;
|
|
51
|
+
};
|
|
52
|
+
// Every same-kind provider participates in the ambiguity check,
|
|
53
|
+
// even if it cannot resolve labels. Otherwise a legacy ID owned
|
|
54
|
+
// by a provider without a resolver could be disclosed to a
|
|
55
|
+
// different provider that happens to have one.
|
|
56
|
+
const directories = new Set(sameKindProviders.map(directoryKey));
|
|
57
|
+
if (directories.size !== 1)
|
|
58
|
+
return [];
|
|
59
|
+
const [directory] = directories;
|
|
60
|
+
return candidates.filter((candidate) => directoryKey(candidate) === directory);
|
|
61
|
+
})();
|
|
62
|
+
// One namespace is one directory. Use its first configured resolver so
|
|
63
|
+
// duplicate gate adapters over the same Clerk instance do not multiply
|
|
64
|
+
// the provider-level concurrency cap.
|
|
65
|
+
const provider = eligible[0]?.provider;
|
|
66
|
+
if (provider)
|
|
67
|
+
lookups.push({ key, id: identity.id, provider });
|
|
68
|
+
}
|
|
69
|
+
return lookups;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve labels for `actors`, best-effort: at most eight lookups at once,
|
|
73
|
+
* 1.5 seconds in all, and whatever has resolved by then is the answer. A
|
|
74
|
+
* provider outage yields no labels, never a failed read.
|
|
75
|
+
*/
|
|
76
|
+
export async function resolveActorLabels(actors, auth) {
|
|
77
|
+
const labels = new Map();
|
|
78
|
+
const queue = labelLookups(actors, auth);
|
|
79
|
+
let cursor = 0;
|
|
80
|
+
let expired = false;
|
|
81
|
+
const worker = async () => {
|
|
82
|
+
while (!expired && cursor < queue.length) {
|
|
83
|
+
const lookup = queue[cursor++];
|
|
84
|
+
if (!lookup)
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
const label = cleanActorLabel(await Promise.resolve(lookup.provider.activityActorLabel?.(lookup.id)));
|
|
88
|
+
if (label && !expired)
|
|
89
|
+
labels.set(lookup.key, label);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// A failed lookup leaves the actor unlabeled.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
let timer;
|
|
97
|
+
const budget = new Promise((resolve) => {
|
|
98
|
+
timer = setTimeout(() => {
|
|
99
|
+
expired = true;
|
|
100
|
+
resolve();
|
|
101
|
+
}, ACTOR_LABEL_BUDGET_MS);
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
await Promise.race([
|
|
105
|
+
Promise.all(Array.from({ length: Math.min(ACTOR_LABEL_CONCURRENCY, queue.length) }, worker)),
|
|
106
|
+
budget,
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
if (timer !== undefined)
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
return new Map(labels);
|
|
114
|
+
}
|
|
@@ -131,31 +131,53 @@ function credentialRequest(context, connectorId, action) {
|
|
|
131
131
|
: "this connector does not support credential testing", 400);
|
|
132
132
|
}
|
|
133
133
|
// Whatever goes wrong past this point is the test's result, not the
|
|
134
|
-
// route's: the page shows it beside the Test button.
|
|
134
|
+
// route's: the page shows it beside the Test button. Only `ok` leaves
|
|
135
|
+
// the host. A hook's message, and anything it throws, can quote the
|
|
136
|
+
// downstream's reply, and that reply can quote the credential it just
|
|
137
|
+
// rejected — so the text goes to the deployment's log and the page
|
|
138
|
+
// says a fixed sentence for the outcome instead.
|
|
139
|
+
const logged = (ok, detail) => {
|
|
140
|
+
if (!detail)
|
|
141
|
+
return;
|
|
142
|
+
const line = `[connecta] connector "${connectorId}" credential test ${ok ? "passed" : "failed"}: ${detail}`;
|
|
143
|
+
if (ok)
|
|
144
|
+
opts.logger.info(line);
|
|
145
|
+
else
|
|
146
|
+
opts.logger.warn(line);
|
|
147
|
+
};
|
|
135
148
|
return yield* Effect.tryPromise({
|
|
136
149
|
try: async () => {
|
|
137
150
|
const values = await vault.getAll(connectorId, owner);
|
|
138
151
|
const shape = storedCredentialShape(credential, values);
|
|
152
|
+
// Both refusals are the route's own words, and each carries the
|
|
153
|
+
// problem kind the page keys its copy and fix prompt off.
|
|
139
154
|
if (shape.state === "missing") {
|
|
140
155
|
return privateJson({
|
|
141
156
|
error: mode === "multiple"
|
|
142
157
|
? "configure the credentials before testing them"
|
|
143
158
|
: "configure the credential before testing it",
|
|
159
|
+
problem: "credential_required",
|
|
144
160
|
}, { status: 409 });
|
|
145
161
|
}
|
|
146
162
|
if (shape.state === "mismatch") {
|
|
147
|
-
return privateJson({ error: shape.message }, { status: 409 });
|
|
163
|
+
return privateJson({ error: shape.message, problem: "credential_mismatch" }, { status: 409 });
|
|
148
164
|
}
|
|
149
165
|
const storedValues = values;
|
|
150
166
|
const ctx = registry.contextFor(connectorId, baseUrl);
|
|
151
|
-
|
|
167
|
+
const result = mode === "multiple"
|
|
152
168
|
? await connector.testCredentials(storedValues, ctx)
|
|
153
169
|
: await connector.testCredential(
|
|
154
170
|
// The single-value shape check above guarantees this key.
|
|
155
|
-
storedValues.value, ctx)
|
|
171
|
+
storedValues.value, ctx);
|
|
172
|
+
const ok = result?.ok === true;
|
|
173
|
+
logged(ok, result?.message);
|
|
174
|
+
return privateJson({ ok });
|
|
156
175
|
},
|
|
157
176
|
catch: (error) => error,
|
|
158
|
-
}).pipe(Effect.catch((error) =>
|
|
177
|
+
}).pipe(Effect.catch((error) => {
|
|
178
|
+
logged(false, msg(error));
|
|
179
|
+
return Effect.succeed(privateJson({ ok: false }));
|
|
180
|
+
}));
|
|
159
181
|
}
|
|
160
182
|
if (action)
|
|
161
183
|
return yield* refuse("not found", 404);
|
package/dist/routes/mcp.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
|
|
2
|
-
import { Effect, Exit, Result, Scope } from "effect";
|
|
2
|
+
import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
|
|
3
3
|
import { registerExecuteTool } from "../execute.js";
|
|
4
4
|
import { ExecutorAdmissionError, } from "../executor-admission.js";
|
|
5
5
|
import { registerMetaTools } from "../meta-tools.js";
|
|
6
|
+
import { registerResumeTool } from "../resumable.js";
|
|
6
7
|
import { intersectAccess } from "../connector-access.js";
|
|
7
8
|
import { instructionsFor } from "../skills.js";
|
|
8
9
|
import { msg } from "../errors.js";
|
|
@@ -101,9 +102,9 @@ function closeWithBody(response, signal, close) {
|
|
|
101
102
|
const reader = response.body.getReader();
|
|
102
103
|
onAbort = () => {
|
|
103
104
|
// `cancel()` belongs to an operator/auth/SDK-provided stream and may
|
|
104
|
-
// reject.
|
|
105
|
-
|
|
106
|
-
void reader.cancel(signal.reason).
|
|
105
|
+
// reject or never settle. Release now; consume either outcome separately.
|
|
106
|
+
release();
|
|
107
|
+
void reader.cancel(signal.reason).catch(() => { });
|
|
107
108
|
};
|
|
108
109
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
109
110
|
if (signal.aborted)
|
|
@@ -126,12 +127,8 @@ function closeWithBody(response, signal, close) {
|
|
|
126
127
|
}
|
|
127
128
|
},
|
|
128
129
|
async cancel(reason) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
finally {
|
|
133
|
-
release();
|
|
134
|
-
}
|
|
130
|
+
release();
|
|
131
|
+
await reader.cancel(reason);
|
|
135
132
|
},
|
|
136
133
|
});
|
|
137
134
|
return new Response(body, {
|
|
@@ -220,14 +217,19 @@ function toolkitRetired(logger) {
|
|
|
220
217
|
},
|
|
221
218
|
});
|
|
222
219
|
}
|
|
223
|
-
function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
|
|
220
|
+
function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canManageAuth, poolName, runtimeContext) {
|
|
221
|
+
// A paused run belongs to the endpoint it started on: resuming it through
|
|
222
|
+
// another pool's endpoint finds nothing, as another subject would.
|
|
223
|
+
const resumable = opts.resumable
|
|
224
|
+
? { ...opts.resumable, pool: poolName ?? null, approval: opts.approval }
|
|
225
|
+
: undefined;
|
|
224
226
|
// Every McpServer the request builds is fresh and closes with its scope.
|
|
225
227
|
// The modern handler tears its own down after the exchange; the legacy
|
|
226
228
|
// transport never does, and neither may stay wired to an ended request.
|
|
227
229
|
const servers = [];
|
|
228
230
|
const createServer = () => {
|
|
229
231
|
const server = new McpServer(opts.serverInfo, {
|
|
230
|
-
instructions: instructionsFor(),
|
|
232
|
+
instructions: instructionsFor(resumable !== undefined),
|
|
231
233
|
cacheHints: {
|
|
232
234
|
"tools/list": {
|
|
233
235
|
ttlMs: 3_600_000,
|
|
@@ -265,17 +267,18 @@ function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtim
|
|
|
265
267
|
...(opts.discoveryConcurrency !== undefined
|
|
266
268
|
? { discoveryConcurrency: opts.discoveryConcurrency }
|
|
267
269
|
: {}),
|
|
268
|
-
requestSignal
|
|
270
|
+
requestSignal,
|
|
269
271
|
...(runtimeContext?.waitUntil
|
|
270
272
|
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
271
273
|
: {}),
|
|
274
|
+
approval: opts.approval,
|
|
272
275
|
});
|
|
273
|
-
registerExecuteTool(server, registry, {
|
|
276
|
+
const runner = registerExecuteTool(server, registry, {
|
|
274
277
|
baseUrl,
|
|
275
278
|
executor: opts.executor,
|
|
276
279
|
logger: opts.logger,
|
|
277
280
|
...(activity ? { activity } : {}),
|
|
278
|
-
requestSignal
|
|
281
|
+
requestSignal,
|
|
279
282
|
...(runtimeContext?.waitUntil
|
|
280
283
|
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
281
284
|
: {}),
|
|
@@ -300,6 +303,18 @@ function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtim
|
|
|
300
303
|
...(opts.watchdogMs !== undefined
|
|
301
304
|
? { watchdogMs: opts.watchdogMs }
|
|
302
305
|
: {}),
|
|
306
|
+
resumable,
|
|
307
|
+
approval: opts.approval,
|
|
308
|
+
maxWrites: opts.maxWrites,
|
|
309
|
+
});
|
|
310
|
+
// Always registered, so the surface is the same eight tools on every
|
|
311
|
+
// deployment; without resumable writes it answers that there is nothing
|
|
312
|
+
// to resume.
|
|
313
|
+
registerResumeTool(server, registry, {
|
|
314
|
+
runner,
|
|
315
|
+
settings: resumable,
|
|
316
|
+
...(activity ? { activity } : {}),
|
|
317
|
+
requestSignal,
|
|
303
318
|
});
|
|
304
319
|
servers.push(server);
|
|
305
320
|
return server;
|
|
@@ -405,7 +420,23 @@ export function createMcpRoute(opts) {
|
|
|
405
420
|
if (request.method === "OPTIONS") {
|
|
406
421
|
return Effect.succeed(cors(new Response(null, { status: 204 })));
|
|
407
422
|
}
|
|
423
|
+
// This controller belongs to this request. Its signal reaches auth and
|
|
424
|
+
// every registered tool; a controller sweep never touches it.
|
|
425
|
+
const localAbort = new AbortController();
|
|
426
|
+
const onCallerAbort = () => localAbort.abort(request.signal.reason);
|
|
427
|
+
request.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
428
|
+
if (request.signal.aborted)
|
|
429
|
+
onCallerAbort();
|
|
430
|
+
let deadlineTimer;
|
|
408
431
|
return scopedToBody(Effect.gen(function* () {
|
|
432
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => {
|
|
433
|
+
if (!localAbort.signal.aborted) {
|
|
434
|
+
localAbort.abort(request.signal.reason ?? new Error("MCP request ended."));
|
|
435
|
+
}
|
|
436
|
+
request.signal.removeEventListener("abort", onCallerAbort);
|
|
437
|
+
if (deadlineTimer !== undefined)
|
|
438
|
+
clearTimeout(deadlineTimer);
|
|
439
|
+
}));
|
|
409
440
|
const admission = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
|
|
410
441
|
if (Result.isFailure(admission)) {
|
|
411
442
|
const error = admission.failure;
|
|
@@ -420,6 +451,17 @@ export function createMcpRoute(opts) {
|
|
|
420
451
|
}
|
|
421
452
|
return cors(requestAdmissionFailure(error));
|
|
422
453
|
}
|
|
454
|
+
const remainingMs = admission.success.remainingMs();
|
|
455
|
+
if (remainingMs !== undefined && remainingMs <= 0) {
|
|
456
|
+
admission.success.release();
|
|
457
|
+
return cors(new Response("MCP request lifetime exceeded.", { status: 504 }));
|
|
458
|
+
}
|
|
459
|
+
if (remainingMs !== undefined) {
|
|
460
|
+
deadlineTimer = setTimeout(() => {
|
|
461
|
+
localAbort.abort(new Error("MCP request lifetime exceeded."));
|
|
462
|
+
}, remainingMs);
|
|
463
|
+
}
|
|
464
|
+
const localRequest = new Request(request, { signal: localAbort.signal });
|
|
423
465
|
if (admission.success.waitMs > 0) {
|
|
424
466
|
opts.logger.debug("[connecta] MCP request admitted after queue wait", {
|
|
425
467
|
waitMs: admission.success.waitMs,
|
|
@@ -431,52 +473,67 @@ export function createMcpRoute(opts) {
|
|
|
431
473
|
// Effect `authorize` would put Effect in the /activity bundle, which
|
|
432
474
|
// shares it, to save only the lookups an abandoned authorization
|
|
433
475
|
// finishes on its own.
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
476
|
+
const handled = Effect.gen(function* () {
|
|
477
|
+
const authz = yield* Effect.promise(() => authorize(localRequest, baseUrl, opts.auth, runtimeContext, opts.identity));
|
|
478
|
+
if (!authz.ok)
|
|
479
|
+
return cors(authz.response);
|
|
480
|
+
// A pool endpoint narrows the identity's own view and nothing else. An
|
|
481
|
+
// undeclared name, a grant that refuses, and a grant that throws are
|
|
482
|
+
// one identical 404 so a credential never enumerates the other pools;
|
|
483
|
+
// the operator log is where the reason lives.
|
|
484
|
+
let access = authz;
|
|
485
|
+
if (poolName !== undefined) {
|
|
486
|
+
const pool = opts.pools?.get(poolName);
|
|
487
|
+
const reason = !pool ? "undeclared" : yield* Effect.promise(async () => {
|
|
488
|
+
try {
|
|
489
|
+
return (await pool.grant(authz.identity)) === true ? "granted" : "refused";
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return "grant threw";
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
if (!pool || reason !== "granted") {
|
|
496
|
+
opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
|
|
497
|
+
(authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
|
|
498
|
+
return cors(new Response("Not Found", { status: 404 }));
|
|
450
499
|
}
|
|
451
|
-
|
|
452
|
-
if (!pool || reason !== "granted") {
|
|
453
|
-
opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
|
|
454
|
-
(authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
|
|
455
|
-
return cors(new Response("Not Found", { status: 404 }));
|
|
500
|
+
access = intersectAccess(authz, pool.access);
|
|
456
501
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
502
|
+
let scopedRegistry;
|
|
503
|
+
try {
|
|
504
|
+
validateAuthPermissions(authz, opts.registry);
|
|
505
|
+
scopedRegistry = opts.registry.scoped({
|
|
506
|
+
connectorIds: access.connectorIds,
|
|
507
|
+
...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
|
|
508
|
+
...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}),
|
|
509
|
+
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
510
|
+
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
511
|
+
caller: {
|
|
512
|
+
identity: authz.identity,
|
|
513
|
+
...(poolName !== undefined ? { pool: poolName } : {}),
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
return cors(new Response(JSON.stringify({ error: msg(error) }), {
|
|
519
|
+
status: 403,
|
|
520
|
+
headers: { "Content-Type": "application/json" },
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
if (new URL(request.url).searchParams.has("toolkit")) {
|
|
524
|
+
return cors(toolkitRetired(opts.logger));
|
|
525
|
+
}
|
|
526
|
+
return cors(yield* serveMcp(localRequest, localAbort.signal, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, poolName, runtimeContext));
|
|
527
|
+
});
|
|
528
|
+
if (remainingMs === undefined)
|
|
529
|
+
return yield* handled;
|
|
530
|
+
const bounded = yield* handled.pipe(Effect.timeoutOption(Duration.millis(remainingMs)));
|
|
531
|
+
if (Option.isNone(bounded))
|
|
532
|
+
localAbort.abort(new Error("MCP request lifetime exceeded."));
|
|
533
|
+
return Option.isSome(bounded)
|
|
534
|
+
? bounded.value
|
|
535
|
+
: cors(new Response("MCP request lifetime exceeded.", { status: 504 }));
|
|
536
|
+
}), localAbort.signal);
|
|
480
537
|
}
|
|
481
538
|
return { handle: routeMcp, rejectOrigin };
|
|
482
539
|
}
|
|
@@ -4,7 +4,7 @@ import { isSafeHttpUrl } from "../ui.js";
|
|
|
4
4
|
import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
|
|
5
5
|
import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
|
|
6
6
|
function oauthManagementRequest(context, connectorId) {
|
|
7
|
-
const { request, baseUrl, defer } = context;
|
|
7
|
+
const { request, baseUrl, defer, opts } = context;
|
|
8
8
|
return Effect.gen(function* () {
|
|
9
9
|
if (!isSameOrigin(request, baseUrl)) {
|
|
10
10
|
return yield* refuse("same-origin request required", 403);
|
|
@@ -50,12 +50,25 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
50
50
|
try: () => registry.invalidateStored(connectorId),
|
|
51
51
|
catch: (error) => error,
|
|
52
52
|
}));
|
|
53
|
+
// Every failure below answers in the route's own fixed words. A hook's
|
|
54
|
+
// rejection and a status message can quote a token endpoint's error body
|
|
55
|
+
// or a provider's refusal, either of which can quote the secret it was
|
|
56
|
+
// sent, so that text goes to the deployment's log and nowhere else.
|
|
57
|
+
const failed = (detail, fixed, status) => {
|
|
58
|
+
opts.logger.warn(`[connecta] connector "${connectorId}" OAuth ${disconnecting ? "disconnect" : "restart"} failed: ${detail}`);
|
|
59
|
+
return refuse(fixed, status);
|
|
60
|
+
};
|
|
61
|
+
const couldNot = disconnecting
|
|
62
|
+
? "OAuth disconnect failed"
|
|
63
|
+
: "OAuth authorization could not start";
|
|
53
64
|
// A Result, not a caught value, decides whether it failed: a hook that
|
|
54
65
|
// rejects with no reason at all still failed.
|
|
55
|
-
if (Result.isFailure(operation))
|
|
56
|
-
return yield*
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
if (Result.isFailure(operation)) {
|
|
67
|
+
return yield* failed(msg(operation.failure), couldNot, 400);
|
|
68
|
+
}
|
|
69
|
+
if (Result.isFailure(invalidated)) {
|
|
70
|
+
return yield* failed(`stored-state invalidation: ${msg(invalidated.failure)}`, couldNot, 400);
|
|
71
|
+
}
|
|
59
72
|
const result = operation.success;
|
|
60
73
|
if (!result) {
|
|
61
74
|
return new Response(null, {
|
|
@@ -70,15 +83,15 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
70
83
|
? result.authorizationUrl
|
|
71
84
|
: undefined;
|
|
72
85
|
if (result.state === "error") {
|
|
73
|
-
return yield*
|
|
86
|
+
return yield* failed(result.message || "the connector reported an error state", couldNot, 502);
|
|
74
87
|
}
|
|
75
88
|
if (result.state === "auth_required" && !authorizationUrl) {
|
|
76
|
-
return yield*
|
|
77
|
-
"OAuth authorization requires consent but no safe URL is available", 502);
|
|
89
|
+
return yield* failed(result.message || "consent is required but no safe authorization URL was returned", "OAuth authorization requires consent but no safe URL is available", 502);
|
|
78
90
|
}
|
|
91
|
+
// No message: a status message is the same text the Connections page
|
|
92
|
+
// stopped shipping, and the state and the link are the whole answer.
|
|
79
93
|
return privateJson({
|
|
80
94
|
state: result.state,
|
|
81
|
-
...(result.message ? { message: result.message } : {}),
|
|
82
95
|
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
83
96
|
});
|
|
84
97
|
}).pipe(Effect.scoped);
|
package/dist/routes/operator.js
CHANGED
|
@@ -51,6 +51,7 @@ export function scopeFor(authz, connectorIds = authz.connectorIds) {
|
|
|
51
51
|
return {
|
|
52
52
|
connectorIds,
|
|
53
53
|
...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
|
|
54
|
+
...(authz.guardedToolAccess ? { guardedToolAccess: authz.guardedToolAccess } : {}),
|
|
54
55
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
55
56
|
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
56
57
|
};
|