@zackbart/connecta 0.24.4 → 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 +43 -12
- package/CHANGELOG.md +524 -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/advertised-schema.js +53 -0
- 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/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +152 -106
- package/dist/catalog.js +297 -38
- package/dist/concurrency.js +24 -23
- 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/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +2 -2
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +330 -240
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +745 -282
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +145 -63
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +255 -212
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +72 -7
- package/dist/index.js +281 -26
- package/dist/invocation.js +371 -286
- package/dist/meta-tools.js +213 -94
- package/dist/module-contracts.d.ts +21 -1
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +103 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +128 -3
- package/dist/program-source.js +321 -0
- package/dist/providers/notion.js +2 -2
- package/dist/providers/revenuecat.js +32 -5
- package/dist/providers/stripe.d.ts +3 -2
- package/dist/providers/vercel.js +1 -1
- package/dist/registry.d.ts +43 -7
- package/dist/registry.js +434 -288
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +74 -154
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +196 -163
- package/dist/routes/mcp.js +228 -112
- package/dist/routes/oauth-management.js +73 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +83 -0
- package/dist/routes/shared.d.ts +25 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +211 -0
- package/dist/routes/ui.js +76 -35
- package/dist/run-journal.js +347 -0
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +137 -89
- package/dist/skills.js +28 -9
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +39 -43
- package/dist/tool-safety.d.ts +27 -1
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +45 -7
- package/dist/ui.d.ts +25 -4
- package/dist/ui.js +77 -167
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +367 -16
- package/documentation/auth.md +163 -2
- package/documentation/code-mode.md +274 -56
- package/documentation/meta-tools.md +200 -44
- package/ethos.md +46 -47
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +224 -14
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +42 -7
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +21 -5
- package/package.json +16 -4
- 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/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -96
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -188
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
package/dist/routes/mcp.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
|
|
2
|
+
import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
|
|
2
3
|
import { registerExecuteTool } from "../execute.js";
|
|
3
4
|
import { ExecutorAdmissionError, } from "../executor-admission.js";
|
|
4
5
|
import { registerMetaTools } from "../meta-tools.js";
|
|
6
|
+
import { registerResumeTool } from "../resumable.js";
|
|
5
7
|
import { intersectAccess } from "../connector-access.js";
|
|
6
8
|
import { instructionsFor } from "../skills.js";
|
|
7
9
|
import { msg } from "../errors.js";
|
|
10
|
+
import { detach } from "../runtime/run.js";
|
|
8
11
|
import { authorize, loggableValue, mayManageConnector, validateAuthPermissions, } from "./shared.js";
|
|
9
12
|
export const MCP_CORS_HEADERS = {
|
|
10
13
|
"Access-Control-Allow-Origin": "*",
|
|
@@ -73,11 +76,16 @@ function requestAdmissionFailure(error) {
|
|
|
73
76
|
}), { status: 503, headers });
|
|
74
77
|
}
|
|
75
78
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
+
* Hand a response the Scope that holds what its request acquired.
|
|
80
|
+
*
|
|
81
|
+
* A request owns its permit and its McpServer through the response body, not
|
|
82
|
+
* merely until the handler returns. `close` runs once, on whichever comes
|
|
83
|
+
* first: the body is read to its end or fails, its reader cancels it, or the
|
|
84
|
+
* request's signal aborts. This is what makes slow clients and response-stream
|
|
85
|
+
* failure part of the same bounded lifecycle as success, error, and
|
|
86
|
+
* cancellation.
|
|
79
87
|
*/
|
|
80
|
-
function
|
|
88
|
+
function closeWithBody(response, signal, close) {
|
|
81
89
|
let released = false;
|
|
82
90
|
let onAbort = () => { };
|
|
83
91
|
const release = () => {
|
|
@@ -85,7 +93,7 @@ function releaseAdmissionWithResponse(response, lease, signal) {
|
|
|
85
93
|
return;
|
|
86
94
|
released = true;
|
|
87
95
|
signal.removeEventListener("abort", onAbort);
|
|
88
|
-
|
|
96
|
+
close();
|
|
89
97
|
};
|
|
90
98
|
if (!response.body) {
|
|
91
99
|
release();
|
|
@@ -94,9 +102,9 @@ function releaseAdmissionWithResponse(response, lease, signal) {
|
|
|
94
102
|
const reader = response.body.getReader();
|
|
95
103
|
onAbort = () => {
|
|
96
104
|
// `cancel()` belongs to an operator/auth/SDK-provided stream and may
|
|
97
|
-
// reject.
|
|
98
|
-
|
|
99
|
-
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(() => { });
|
|
100
108
|
};
|
|
101
109
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
102
110
|
if (signal.aborted)
|
|
@@ -119,12 +127,8 @@ function releaseAdmissionWithResponse(response, lease, signal) {
|
|
|
119
127
|
}
|
|
120
128
|
},
|
|
121
129
|
async cancel(reason) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
finally {
|
|
126
|
-
release();
|
|
127
|
-
}
|
|
130
|
+
release();
|
|
131
|
+
await reader.cancel(reason);
|
|
128
132
|
},
|
|
129
133
|
});
|
|
130
134
|
return new Response(body, {
|
|
@@ -133,6 +137,52 @@ function releaseAdmissionWithResponse(response, lease, signal) {
|
|
|
133
137
|
headers: response.headers,
|
|
134
138
|
});
|
|
135
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Run a request's handler in a Scope that outlives it.
|
|
142
|
+
*
|
|
143
|
+
* The handler acquires into the scope and its response carries the scope
|
|
144
|
+
* out, for `closeWithBody` to close when the body ends. A handler that fails,
|
|
145
|
+
* or is interrupted because the request's signal aborted, closes it at once:
|
|
146
|
+
* nothing it acquired waits on a body that will never be read.
|
|
147
|
+
*/
|
|
148
|
+
function scopedToBody(handler, signal) {
|
|
149
|
+
return Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
150
|
+
const scope = yield* Scope.make();
|
|
151
|
+
const exit = yield* Effect.exit(restore(Scope.provide(scope)(handler)));
|
|
152
|
+
if (Exit.isFailure(exit)) {
|
|
153
|
+
yield* Scope.close(scope, exit);
|
|
154
|
+
return yield* exit;
|
|
155
|
+
}
|
|
156
|
+
// Every finalizer in the scope is synchronous, so the close has run by
|
|
157
|
+
// the time detach returns: the permit is back, and handed to the next
|
|
158
|
+
// queued request, inside the read or cancel that ended the body.
|
|
159
|
+
return closeWithBody(exit.value, signal, () => {
|
|
160
|
+
void detach(Scope.close(scope, Exit.void));
|
|
161
|
+
});
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The request's admission permit, owned by its Scope.
|
|
166
|
+
*
|
|
167
|
+
* Admission goes through the controller's Promise rather than its Effect
|
|
168
|
+
* program (`acquireScoped`), on purpose. The pool is shared by every request
|
|
169
|
+
* in the isolate, and a queued request is handed its permit from inside the
|
|
170
|
+
* request whose body just ended. A fiber resumed there would authorize and
|
|
171
|
+
* serve this request in that one's I/O context, which workerd refuses;
|
|
172
|
+
* awaiting `acquire()`'s promise resumes it in its own.
|
|
173
|
+
*
|
|
174
|
+
* The wait stays interruptible, and a permit granted just as it was
|
|
175
|
+
* interrupted still arrives, later, to be released on arrival.
|
|
176
|
+
*/
|
|
177
|
+
function admitted(controller, signal) {
|
|
178
|
+
return Effect.acquireRelease(Effect.suspend(() => {
|
|
179
|
+
const pending = controller.acquire({ signal });
|
|
180
|
+
return Effect.tryPromise({ try: () => pending, catch: (error) => error })
|
|
181
|
+
.pipe(Effect.onInterrupt(() => Effect.sync(() => {
|
|
182
|
+
pending.then((lease) => lease.release(), () => { });
|
|
183
|
+
})));
|
|
184
|
+
}), (lease) => Effect.sync(() => lease.release()), { interruptible: true });
|
|
185
|
+
}
|
|
136
186
|
/**
|
|
137
187
|
* 404 for any `?toolkit=` value, kept after the feature's retirement (#178).
|
|
138
188
|
*
|
|
@@ -167,10 +217,19 @@ function toolkitRetired(logger) {
|
|
|
167
217
|
},
|
|
168
218
|
});
|
|
169
219
|
}
|
|
170
|
-
|
|
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;
|
|
226
|
+
// Every McpServer the request builds is fresh and closes with its scope.
|
|
227
|
+
// The modern handler tears its own down after the exchange; the legacy
|
|
228
|
+
// transport never does, and neither may stay wired to an ended request.
|
|
229
|
+
const servers = [];
|
|
171
230
|
const createServer = () => {
|
|
172
231
|
const server = new McpServer(opts.serverInfo, {
|
|
173
|
-
instructions: instructionsFor(),
|
|
232
|
+
instructions: instructionsFor(resumable !== undefined),
|
|
174
233
|
cacheHints: {
|
|
175
234
|
"tools/list": {
|
|
176
235
|
ttlMs: 3_600_000,
|
|
@@ -208,17 +267,18 @@ async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth,
|
|
|
208
267
|
...(opts.discoveryConcurrency !== undefined
|
|
209
268
|
? { discoveryConcurrency: opts.discoveryConcurrency }
|
|
210
269
|
: {}),
|
|
211
|
-
requestSignal
|
|
270
|
+
requestSignal,
|
|
212
271
|
...(runtimeContext?.waitUntil
|
|
213
272
|
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
214
273
|
: {}),
|
|
274
|
+
approval: opts.approval,
|
|
215
275
|
});
|
|
216
|
-
registerExecuteTool(server, registry, {
|
|
276
|
+
const runner = registerExecuteTool(server, registry, {
|
|
217
277
|
baseUrl,
|
|
218
278
|
executor: opts.executor,
|
|
219
279
|
logger: opts.logger,
|
|
220
280
|
...(activity ? { activity } : {}),
|
|
221
|
-
requestSignal
|
|
281
|
+
requestSignal,
|
|
222
282
|
...(runtimeContext?.waitUntil
|
|
223
283
|
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
224
284
|
: {}),
|
|
@@ -240,26 +300,48 @@ async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth,
|
|
|
240
300
|
...(opts.hostCallTimeoutMs !== undefined
|
|
241
301
|
? { hostCallTimeoutMs: opts.hostCallTimeoutMs }
|
|
242
302
|
: {}),
|
|
303
|
+
...(opts.watchdogMs !== undefined
|
|
304
|
+
? { watchdogMs: opts.watchdogMs }
|
|
305
|
+
: {}),
|
|
306
|
+
resumable,
|
|
307
|
+
approval: opts.approval,
|
|
308
|
+
maxWrites: opts.maxWrites,
|
|
243
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,
|
|
318
|
+
});
|
|
319
|
+
servers.push(server);
|
|
244
320
|
return server;
|
|
245
321
|
};
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
322
|
+
const exchange = async () => {
|
|
323
|
+
// The v2 entry's built-in legacy fallback streams 2025 results as SSE.
|
|
324
|
+
// Connecta's established wire contract is JSON, so retain the documented
|
|
325
|
+
// user-land legacy branch with the same transport setting while the modern
|
|
326
|
+
// branch uses the fetch-native handler.
|
|
327
|
+
if (!(await isLegacyRequest(request))) {
|
|
328
|
+
return createMcpHandler(createServer, {
|
|
329
|
+
legacy: "reject",
|
|
330
|
+
onerror: (error) => opts.logger.error("[connecta] MCP handler error", error),
|
|
331
|
+
}).fetch(request);
|
|
332
|
+
}
|
|
333
|
+
// Fresh server + transport per legacy request, stateless and JSON-shaped.
|
|
334
|
+
const server = createServer();
|
|
335
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
336
|
+
enableJsonResponse: true,
|
|
337
|
+
});
|
|
338
|
+
await server.connect(transport);
|
|
339
|
+
return transport.handleRequest(request);
|
|
340
|
+
};
|
|
341
|
+
return Effect.addFinalizer(() => Effect.sync(() => {
|
|
342
|
+
for (const server of servers)
|
|
343
|
+
void server.close().catch(() => { });
|
|
344
|
+
})).pipe(Effect.andThen(Effect.promise(exchange)));
|
|
263
345
|
}
|
|
264
346
|
export function createMcpRoute(opts) {
|
|
265
347
|
const configuredOrigins = opts.allowedOrigins;
|
|
@@ -322,10 +404,10 @@ export function createMcpRoute(opts) {
|
|
|
322
404
|
lastAdmissionWarningAt = now;
|
|
323
405
|
suppressedAdmissionWarnings = 0;
|
|
324
406
|
};
|
|
325
|
-
|
|
407
|
+
function routeMcp(context) {
|
|
326
408
|
const { path, request, baseUrl, runtimeContext, } = context;
|
|
327
409
|
if (path !== "/mcp" && !path.startsWith("/mcp/"))
|
|
328
|
-
return null;
|
|
410
|
+
return Effect.succeed(null);
|
|
329
411
|
const poolName = path === "/mcp" ? undefined : path.slice("/mcp/".length);
|
|
330
412
|
const origin = request.headers.get("Origin");
|
|
331
413
|
const allowed = origin === null || allowsOrigin(origin);
|
|
@@ -334,90 +416,124 @@ export function createMcpRoute(opts) {
|
|
|
334
416
|
// local header check also guards OPTIONS before any provider metadata.
|
|
335
417
|
const refusal = rejectOrigin(request);
|
|
336
418
|
if (refusal)
|
|
337
|
-
return refusal;
|
|
338
|
-
if (request.method === "OPTIONS")
|
|
339
|
-
return cors(new Response(null, { status: 204 }));
|
|
340
|
-
let admission;
|
|
341
|
-
try {
|
|
342
|
-
admission = await opts.requestAdmission.acquire({
|
|
343
|
-
signal: request.signal,
|
|
344
|
-
});
|
|
345
|
-
if (admission.waitMs > 0) {
|
|
346
|
-
opts.logger.debug("[connecta] MCP request admitted after queue wait", {
|
|
347
|
-
waitMs: admission.waitMs,
|
|
348
|
-
active: opts.requestAdmission.activeCount,
|
|
349
|
-
queued: opts.requestAdmission.queuedCount,
|
|
350
|
-
});
|
|
351
|
-
}
|
|
419
|
+
return Effect.succeed(refusal);
|
|
420
|
+
if (request.method === "OPTIONS") {
|
|
421
|
+
return Effect.succeed(cors(new Response(null, { status: 204 })));
|
|
352
422
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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;
|
|
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
|
+
}));
|
|
440
|
+
const admission = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
|
|
441
|
+
if (Result.isFailure(admission)) {
|
|
442
|
+
const error = admission.failure;
|
|
443
|
+
if (!(error instanceof ExecutorAdmissionError)) {
|
|
444
|
+
return yield* Effect.fail(error);
|
|
445
|
+
}
|
|
446
|
+
if (error.code === "executor_cancelled") {
|
|
447
|
+
return yield* Effect.fail(request.signal.reason ?? error);
|
|
448
|
+
}
|
|
359
449
|
if (error.code === "executor_overloaded") {
|
|
360
450
|
warnAdmissionRejected(error);
|
|
361
451
|
}
|
|
362
452
|
return cors(requestAdmissionFailure(error));
|
|
363
453
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (!authz.ok) {
|
|
369
|
-
return releaseAdmissionWithResponse(cors(authz.response), admission, request.signal);
|
|
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 }));
|
|
370
458
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
let access = authz;
|
|
376
|
-
if (poolName !== undefined) {
|
|
377
|
-
const pool = opts.pools?.get(poolName);
|
|
378
|
-
let granted = false;
|
|
379
|
-
let reason = "undeclared";
|
|
380
|
-
if (pool) {
|
|
381
|
-
try {
|
|
382
|
-
granted = (await pool.grant(authz.identity)) === true;
|
|
383
|
-
reason = granted ? "granted" : "refused";
|
|
384
|
-
}
|
|
385
|
-
catch {
|
|
386
|
-
reason = "grant threw";
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
if (!pool || !granted) {
|
|
390
|
-
opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
|
|
391
|
-
(authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
|
|
392
|
-
return releaseAdmissionWithResponse(cors(new Response("Not Found", { status: 404 })), admission, request.signal);
|
|
393
|
-
}
|
|
394
|
-
access = intersectAccess(authz, pool.access);
|
|
459
|
+
if (remainingMs !== undefined) {
|
|
460
|
+
deadlineTimer = setTimeout(() => {
|
|
461
|
+
localAbort.abort(new Error("MCP request lifetime exceeded."));
|
|
462
|
+
}, remainingMs);
|
|
395
463
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
403
|
-
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
464
|
+
const localRequest = new Request(request, { signal: localAbort.signal });
|
|
465
|
+
if (admission.success.waitMs > 0) {
|
|
466
|
+
opts.logger.debug("[connecta] MCP request admitted after queue wait", {
|
|
467
|
+
waitMs: admission.success.waitMs,
|
|
468
|
+
active: opts.requestAdmission.activeCount,
|
|
469
|
+
queued: opts.requestAdmission.queuedCount,
|
|
404
470
|
});
|
|
405
471
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
472
|
+
// Promise code the fiber stops waiting on when the caller leaves. An
|
|
473
|
+
// Effect `authorize` would put Effect in the /activity bundle, which
|
|
474
|
+
// shares it, to save only the lookups an abandoned authorization
|
|
475
|
+
// finishes on its own.
|
|
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 }));
|
|
499
|
+
}
|
|
500
|
+
access = intersectAccess(authz, pool.access);
|
|
501
|
+
}
|
|
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);
|
|
421
537
|
}
|
|
422
538
|
return { handle: routeMcp, rejectOrigin };
|
|
423
539
|
}
|
|
@@ -1,70 +1,76 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect, Result } from "effect";
|
|
2
|
+
import { closeScope } from "../runtime/connector-scope.js";
|
|
2
3
|
import { isSafeHttpUrl } from "../ui.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (!authz.ok)
|
|
11
|
-
return authz.response;
|
|
12
|
-
let registry;
|
|
13
|
-
try {
|
|
14
|
-
validateAuthPermissions(authz, opts.registry);
|
|
15
|
-
registry = opts.registry.scoped({
|
|
16
|
-
connectorIds: authz.connectorIds,
|
|
17
|
-
...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
|
|
18
|
-
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
19
|
-
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
catch (error) {
|
|
23
|
-
return privateJson({ error: msg(error) }, { status: 403 });
|
|
24
|
-
}
|
|
25
|
-
const connector = registry.getConnector(connectorId);
|
|
26
|
-
if (!connector?.disconnectAuth || !connector.startAuth) {
|
|
27
|
-
return privateJson({ error: "unknown OAuth connector" }, { status: 404 });
|
|
28
|
-
}
|
|
29
|
-
if (!mayManageConnector(authz, connector))
|
|
30
|
-
return privateJson({ error: "credential management is not permitted" }, { status: 403 });
|
|
31
|
-
if (connector.authScope === "personal" && !authz.principalKey) {
|
|
32
|
-
return privateJson({ error: "forbidden" }, { status: 403 });
|
|
33
|
-
}
|
|
34
|
-
if (request.method !== "DELETE" && request.method !== "POST") {
|
|
35
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
36
|
-
}
|
|
37
|
-
const requestScope = {};
|
|
38
|
-
const ctx = registry.contextFor(connectorId, baseUrl, requestScope);
|
|
39
|
-
try {
|
|
40
|
-
let result;
|
|
41
|
-
let operationError;
|
|
42
|
-
try {
|
|
43
|
-
if (request.method === "DELETE") {
|
|
44
|
-
await connector.disconnectAuth(ctx);
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
result = await connector.startAuth(ctx, { force: true });
|
|
48
|
-
if (result.authorizationUrl) {
|
|
49
|
-
await registry.bindOAuthHandoff(connectorId, result.authorizationUrl);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
4
|
+
import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
|
|
5
|
+
import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
|
|
6
|
+
function oauthManagementRequest(context, connectorId) {
|
|
7
|
+
const { request, baseUrl, defer, opts } = context;
|
|
8
|
+
return Effect.gen(function* () {
|
|
9
|
+
if (!isSameOrigin(request, baseUrl)) {
|
|
10
|
+
return yield* refuse("same-origin request required", 403);
|
|
52
11
|
}
|
|
53
|
-
|
|
54
|
-
|
|
12
|
+
const authz = yield* authorizedPerson(context, "OAuth management");
|
|
13
|
+
const registry = yield* visibleRegistry(context, authz);
|
|
14
|
+
const connector = registry.getConnector(connectorId);
|
|
15
|
+
const disconnectAuth = connector?.disconnectAuth?.bind(connector);
|
|
16
|
+
const startAuth = connector?.startAuth?.bind(connector);
|
|
17
|
+
if (!connector || !disconnectAuth || !startAuth) {
|
|
18
|
+
return yield* refuse("unknown OAuth connector", 404);
|
|
55
19
|
}
|
|
20
|
+
if (!mayManageConnector(authz, connector)) {
|
|
21
|
+
return yield* refuse("credential management is not permitted", 403);
|
|
22
|
+
}
|
|
23
|
+
if (connector.authScope === "personal" && !authz.principalKey) {
|
|
24
|
+
return yield* refuse("forbidden", 403);
|
|
25
|
+
}
|
|
26
|
+
const disconnecting = request.method === "DELETE";
|
|
27
|
+
if (!disconnecting && request.method !== "POST") {
|
|
28
|
+
return yield* refuse("method not allowed", 405);
|
|
29
|
+
}
|
|
30
|
+
const ctx = registry.contextFor(connectorId, baseUrl, {});
|
|
31
|
+
// The connector scope this request opened ends with it, however it ends.
|
|
32
|
+
yield* Effect.addFinalizer(() => closeScope(connector, ctx, defer));
|
|
33
|
+
const operation = yield* Effect.result(Effect.tryPromise({
|
|
34
|
+
try: async () => {
|
|
35
|
+
if (disconnecting) {
|
|
36
|
+
await disconnectAuth(ctx);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const started = await startAuth(ctx, { force: true });
|
|
40
|
+
if (started.authorizationUrl) {
|
|
41
|
+
await registry.bindOAuthHandoff(connectorId, started.authorizationUrl);
|
|
42
|
+
}
|
|
43
|
+
return started;
|
|
44
|
+
},
|
|
45
|
+
catch: (error) => error,
|
|
46
|
+
}));
|
|
56
47
|
// The old grant and its cached catalog are invalid after either operation,
|
|
57
48
|
// including a partially failed physical cleanup whose epoch fence succeeded.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
49
|
+
const invalidated = yield* Effect.result(Effect.tryPromise({
|
|
50
|
+
try: () => registry.invalidateStored(connectorId),
|
|
51
|
+
catch: (error) => error,
|
|
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";
|
|
64
|
+
// A Result, not a caught value, decides whether it failed: a hook that
|
|
65
|
+
// rejects with no reason at all still failed.
|
|
66
|
+
if (Result.isFailure(operation)) {
|
|
67
|
+
return yield* failed(msg(operation.failure), couldNot, 400);
|
|
63
68
|
}
|
|
64
|
-
if (
|
|
65
|
-
return
|
|
69
|
+
if (Result.isFailure(invalidated)) {
|
|
70
|
+
return yield* failed(`stored-state invalidation: ${msg(invalidated.failure)}`, couldNot, 400);
|
|
66
71
|
}
|
|
67
|
-
|
|
72
|
+
const result = operation.success;
|
|
73
|
+
if (!result) {
|
|
68
74
|
return new Response(null, {
|
|
69
75
|
status: 204,
|
|
70
76
|
headers: {
|
|
@@ -77,23 +83,18 @@ async function handleOAuthManagementRequest(context, connectorId) {
|
|
|
77
83
|
? result.authorizationUrl
|
|
78
84
|
: undefined;
|
|
79
85
|
if (result.state === "error") {
|
|
80
|
-
return
|
|
86
|
+
return yield* failed(result.message || "the connector reported an error state", couldNot, 502);
|
|
81
87
|
}
|
|
82
88
|
if (result.state === "auth_required" && !authorizationUrl) {
|
|
83
|
-
return
|
|
84
|
-
error: result.message ||
|
|
85
|
-
"OAuth authorization requires consent but no safe URL is available",
|
|
86
|
-
}, { status: 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);
|
|
87
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.
|
|
88
93
|
return privateJson({
|
|
89
94
|
state: result.state,
|
|
90
|
-
...(result.message ? { message: result.message } : {}),
|
|
91
95
|
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
92
96
|
});
|
|
93
|
-
}
|
|
94
|
-
finally {
|
|
95
|
-
await closeConnectorScope(connector, ctx, defer);
|
|
96
|
-
}
|
|
97
|
+
}).pipe(Effect.scoped);
|
|
97
98
|
}
|
|
98
99
|
export async function routeOAuthManagement(context) {
|
|
99
100
|
const match = /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(context.path);
|
|
@@ -105,5 +106,6 @@ export async function routeOAuthManagement(context) {
|
|
|
105
106
|
if (context.request.method === "OPTIONS") {
|
|
106
107
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
107
108
|
}
|
|
108
|
-
|
|
109
|
+
// No signal: a disconnect that has started runs through to its invalidation.
|
|
110
|
+
return serveOperator(oauthManagementRequest(context, connectorId));
|
|
109
111
|
}
|