@zackbart/connecta 0.25.0 → 0.26.1
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 +387 -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 +80 -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 +478 -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 +217 -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 +653 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +397 -48
- package/dist/auth/oauth-start-reset.js +20 -0
- package/dist/branding.d.ts +87 -0
- package/dist/branding.js +170 -1
- 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 +48 -9
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +431 -75
- 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/oauth-callback-outcome.js +43 -18
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +62 -5
- package/dist/operator-ui/view.js +372 -28
- package/dist/page-styles.js +7 -0
- package/dist/program-source.js +116 -0
- package/dist/registry.d.ts +42 -6
- package/dist/registry.js +164 -60
- 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 +120 -59
- package/dist/routes/oauth-management.js +87 -20
- package/dist/routes/oauth.js +54 -87
- 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 +19 -4
- package/dist/run-journal.js +347 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/server.js +30 -2
- package/dist/skills.js +30 -11
- 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 +23 -0
- package/dist/ui.d.ts +17 -5
- package/dist/ui.js +51 -68
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +214 -27
- package/documentation/auth.md +186 -2
- package/documentation/code-mode.md +236 -63
- package/documentation/meta-tools.md +55 -29
- 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,10 @@
|
|
|
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
|
+
import { notFoundResponse } from "../branding.js";
|
|
3
4
|
import { registerExecuteTool } from "../execute.js";
|
|
4
5
|
import { ExecutorAdmissionError, } from "../executor-admission.js";
|
|
5
6
|
import { registerMetaTools } from "../meta-tools.js";
|
|
7
|
+
import { registerResumeTool } from "../resumable.js";
|
|
6
8
|
import { intersectAccess } from "../connector-access.js";
|
|
7
9
|
import { instructionsFor } from "../skills.js";
|
|
8
10
|
import { msg } from "../errors.js";
|
|
@@ -101,9 +103,9 @@ function closeWithBody(response, signal, close) {
|
|
|
101
103
|
const reader = response.body.getReader();
|
|
102
104
|
onAbort = () => {
|
|
103
105
|
// `cancel()` belongs to an operator/auth/SDK-provided stream and may
|
|
104
|
-
// reject.
|
|
105
|
-
|
|
106
|
-
void reader.cancel(signal.reason).
|
|
106
|
+
// reject or never settle. Release now; consume either outcome separately.
|
|
107
|
+
release();
|
|
108
|
+
void reader.cancel(signal.reason).catch(() => { });
|
|
107
109
|
};
|
|
108
110
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
109
111
|
if (signal.aborted)
|
|
@@ -126,12 +128,8 @@ function closeWithBody(response, signal, close) {
|
|
|
126
128
|
}
|
|
127
129
|
},
|
|
128
130
|
async cancel(reason) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
finally {
|
|
133
|
-
release();
|
|
134
|
-
}
|
|
131
|
+
release();
|
|
132
|
+
await reader.cancel(reason);
|
|
135
133
|
},
|
|
136
134
|
});
|
|
137
135
|
return new Response(body, {
|
|
@@ -220,14 +218,19 @@ function toolkitRetired(logger) {
|
|
|
220
218
|
},
|
|
221
219
|
});
|
|
222
220
|
}
|
|
223
|
-
function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
|
|
221
|
+
function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canManageAuth, poolName, runtimeContext) {
|
|
222
|
+
// A paused run belongs to the endpoint it started on: resuming it through
|
|
223
|
+
// another pool's endpoint finds nothing, as another subject would.
|
|
224
|
+
const resumable = opts.resumable
|
|
225
|
+
? { ...opts.resumable, pool: poolName ?? null, approval: opts.approval }
|
|
226
|
+
: undefined;
|
|
224
227
|
// Every McpServer the request builds is fresh and closes with its scope.
|
|
225
228
|
// The modern handler tears its own down after the exchange; the legacy
|
|
226
229
|
// transport never does, and neither may stay wired to an ended request.
|
|
227
230
|
const servers = [];
|
|
228
231
|
const createServer = () => {
|
|
229
232
|
const server = new McpServer(opts.serverInfo, {
|
|
230
|
-
instructions: instructionsFor(),
|
|
233
|
+
instructions: instructionsFor(resumable !== undefined),
|
|
231
234
|
cacheHints: {
|
|
232
235
|
"tools/list": {
|
|
233
236
|
ttlMs: 3_600_000,
|
|
@@ -265,17 +268,18 @@ function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtim
|
|
|
265
268
|
...(opts.discoveryConcurrency !== undefined
|
|
266
269
|
? { discoveryConcurrency: opts.discoveryConcurrency }
|
|
267
270
|
: {}),
|
|
268
|
-
requestSignal
|
|
271
|
+
requestSignal,
|
|
269
272
|
...(runtimeContext?.waitUntil
|
|
270
273
|
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
271
274
|
: {}),
|
|
275
|
+
approval: opts.approval,
|
|
272
276
|
});
|
|
273
|
-
registerExecuteTool(server, registry, {
|
|
277
|
+
const runner = registerExecuteTool(server, registry, {
|
|
274
278
|
baseUrl,
|
|
275
279
|
executor: opts.executor,
|
|
276
280
|
logger: opts.logger,
|
|
277
281
|
...(activity ? { activity } : {}),
|
|
278
|
-
requestSignal
|
|
282
|
+
requestSignal,
|
|
279
283
|
...(runtimeContext?.waitUntil
|
|
280
284
|
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
281
285
|
: {}),
|
|
@@ -300,6 +304,18 @@ function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtim
|
|
|
300
304
|
...(opts.watchdogMs !== undefined
|
|
301
305
|
? { watchdogMs: opts.watchdogMs }
|
|
302
306
|
: {}),
|
|
307
|
+
resumable,
|
|
308
|
+
approval: opts.approval,
|
|
309
|
+
maxWrites: opts.maxWrites,
|
|
310
|
+
});
|
|
311
|
+
// Always registered, so the surface is the same eight tools on every
|
|
312
|
+
// deployment; without resumable writes it answers that there is nothing
|
|
313
|
+
// to resume.
|
|
314
|
+
registerResumeTool(server, registry, {
|
|
315
|
+
runner,
|
|
316
|
+
settings: resumable,
|
|
317
|
+
...(activity ? { activity } : {}),
|
|
318
|
+
requestSignal,
|
|
303
319
|
});
|
|
304
320
|
servers.push(server);
|
|
305
321
|
return server;
|
|
@@ -405,7 +421,23 @@ export function createMcpRoute(opts) {
|
|
|
405
421
|
if (request.method === "OPTIONS") {
|
|
406
422
|
return Effect.succeed(cors(new Response(null, { status: 204 })));
|
|
407
423
|
}
|
|
424
|
+
// This controller belongs to this request. Its signal reaches auth and
|
|
425
|
+
// every registered tool; a controller sweep never touches it.
|
|
426
|
+
const localAbort = new AbortController();
|
|
427
|
+
const onCallerAbort = () => localAbort.abort(request.signal.reason);
|
|
428
|
+
request.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
429
|
+
if (request.signal.aborted)
|
|
430
|
+
onCallerAbort();
|
|
431
|
+
let deadlineTimer;
|
|
408
432
|
return scopedToBody(Effect.gen(function* () {
|
|
433
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => {
|
|
434
|
+
if (!localAbort.signal.aborted) {
|
|
435
|
+
localAbort.abort(request.signal.reason ?? new Error("MCP request ended."));
|
|
436
|
+
}
|
|
437
|
+
request.signal.removeEventListener("abort", onCallerAbort);
|
|
438
|
+
if (deadlineTimer !== undefined)
|
|
439
|
+
clearTimeout(deadlineTimer);
|
|
440
|
+
}));
|
|
409
441
|
const admission = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
|
|
410
442
|
if (Result.isFailure(admission)) {
|
|
411
443
|
const error = admission.failure;
|
|
@@ -420,6 +452,17 @@ export function createMcpRoute(opts) {
|
|
|
420
452
|
}
|
|
421
453
|
return cors(requestAdmissionFailure(error));
|
|
422
454
|
}
|
|
455
|
+
const remainingMs = admission.success.remainingMs();
|
|
456
|
+
if (remainingMs !== undefined && remainingMs <= 0) {
|
|
457
|
+
admission.success.release();
|
|
458
|
+
return cors(new Response("MCP request lifetime exceeded.", { status: 504 }));
|
|
459
|
+
}
|
|
460
|
+
if (remainingMs !== undefined) {
|
|
461
|
+
deadlineTimer = setTimeout(() => {
|
|
462
|
+
localAbort.abort(new Error("MCP request lifetime exceeded."));
|
|
463
|
+
}, remainingMs);
|
|
464
|
+
}
|
|
465
|
+
const localRequest = new Request(request, { signal: localAbort.signal });
|
|
423
466
|
if (admission.success.waitMs > 0) {
|
|
424
467
|
opts.logger.debug("[connecta] MCP request admitted after queue wait", {
|
|
425
468
|
waitMs: admission.success.waitMs,
|
|
@@ -431,52 +474,70 @@ export function createMcpRoute(opts) {
|
|
|
431
474
|
// Effect `authorize` would put Effect in the /activity bundle, which
|
|
432
475
|
// shares it, to save only the lookups an abandoned authorization
|
|
433
476
|
// finishes on its own.
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
477
|
+
const handled = Effect.gen(function* () {
|
|
478
|
+
const authz = yield* Effect.promise(() => authorize(localRequest, baseUrl, opts.auth, runtimeContext, opts.identity));
|
|
479
|
+
if (!authz.ok)
|
|
480
|
+
return cors(authz.response);
|
|
481
|
+
// A pool endpoint narrows the identity's own view and nothing else. An
|
|
482
|
+
// undeclared name, a grant that refuses, and a grant that throws are
|
|
483
|
+
// one identical 404 so a credential never enumerates the other pools;
|
|
484
|
+
// the operator log is where the reason lives.
|
|
485
|
+
let access = authz;
|
|
486
|
+
if (poolName !== undefined) {
|
|
487
|
+
const pool = opts.pools?.get(poolName);
|
|
488
|
+
const reason = !pool ? "undeclared" : yield* Effect.promise(async () => {
|
|
489
|
+
try {
|
|
490
|
+
return (await pool.grant(authz.identity)) === true ? "granted" : "refused";
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return "grant threw";
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
if (!pool || reason !== "granted") {
|
|
497
|
+
opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
|
|
498
|
+
(authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
|
|
499
|
+
// The server's own 404, so a browser sees the page every unserved
|
|
500
|
+
// path shows and an MCP client (which never asks for text/html)
|
|
501
|
+
// still reads a plain "Not Found".
|
|
502
|
+
return cors(notFoundResponse(request, opts));
|
|
450
503
|
}
|
|
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 }));
|
|
504
|
+
access = intersectAccess(authz, pool.access);
|
|
456
505
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
506
|
+
let scopedRegistry;
|
|
507
|
+
try {
|
|
508
|
+
validateAuthPermissions(authz, opts.registry);
|
|
509
|
+
scopedRegistry = opts.registry.scoped({
|
|
510
|
+
connectorIds: access.connectorIds,
|
|
511
|
+
...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
|
|
512
|
+
...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}),
|
|
513
|
+
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
514
|
+
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
515
|
+
caller: {
|
|
516
|
+
identity: authz.identity,
|
|
517
|
+
...(poolName !== undefined ? { pool: poolName } : {}),
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
return cors(new Response(JSON.stringify({ error: msg(error) }), {
|
|
523
|
+
status: 403,
|
|
524
|
+
headers: { "Content-Type": "application/json" },
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
527
|
+
if (new URL(request.url).searchParams.has("toolkit")) {
|
|
528
|
+
return cors(toolkitRetired(opts.logger));
|
|
529
|
+
}
|
|
530
|
+
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));
|
|
531
|
+
});
|
|
532
|
+
if (remainingMs === undefined)
|
|
533
|
+
return yield* handled;
|
|
534
|
+
const bounded = yield* handled.pipe(Effect.timeoutOption(Duration.millis(remainingMs)));
|
|
535
|
+
if (Option.isNone(bounded))
|
|
536
|
+
localAbort.abort(new Error("MCP request lifetime exceeded."));
|
|
537
|
+
return Option.isSome(bounded)
|
|
538
|
+
? bounded.value
|
|
539
|
+
: cors(new Response("MCP request lifetime exceeded.", { status: 504 }));
|
|
540
|
+
}), localAbort.signal);
|
|
480
541
|
}
|
|
481
542
|
return { handle: routeMcp, rejectOrigin };
|
|
482
543
|
}
|