@zackbart/connecta 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -0
- package/README.md +31 -55
- package/SECURITY.md +5 -11
- package/dist/activity.d.ts +0 -8
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js +0 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/bearer.d.ts +1 -7
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +2 -23
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +2 -11
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +31 -46
- package/dist/auth/clerk.js.map +1 -1
- package/dist/auth/downstream-oauth.d.ts +15 -6
- package/dist/auth/downstream-oauth.d.ts.map +1 -1
- package/dist/auth/downstream-oauth.js +60 -11
- package/dist/auth/downstream-oauth.js.map +1 -1
- package/dist/call-admission.d.ts.map +1 -1
- package/dist/call-admission.js +0 -1
- package/dist/call-admission.js.map +1 -1
- package/dist/catalog-service.d.ts +1 -2
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +12 -18
- package/dist/catalog-service.js.map +1 -1
- package/dist/catalog.d.ts +6 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +51 -0
- package/dist/catalog.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +73 -71
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/errors.d.ts +19 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +1 -0
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +4 -9
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +41 -20
- package/dist/execute.js.map +1 -1
- package/dist/executor-result.js +1 -1
- package/dist/executor-result.js.map +1 -1
- package/dist/index.d.ts +3 -82
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -90
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts +5 -0
- package/dist/invocation.d.ts.map +1 -1
- package/dist/invocation.js +46 -10
- package/dist/invocation.js.map +1 -1
- package/dist/meta-tools.d.ts +4 -12
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +119 -169
- package/dist/meta-tools.js.map +1 -1
- package/dist/operator-ui/generated.d.ts +2 -2
- package/dist/operator-ui/generated.d.ts.map +1 -1
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/generated.js.map +1 -1
- package/dist/operator-ui/model.d.ts +1 -22
- package/dist/operator-ui/model.d.ts.map +1 -1
- package/dist/operator-ui/model.js.map +1 -1
- package/dist/registry.d.ts +11 -178
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +52 -330
- package/dist/registry.js.map +1 -1
- package/dist/retired-toolkits.d.ts +3 -0
- package/dist/retired-toolkits.d.ts.map +1 -0
- package/dist/retired-toolkits.js +9 -0
- package/dist/retired-toolkits.js.map +1 -0
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/activity.js +2 -5
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/credentials.js +1 -12
- package/dist/routes/credentials.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +94 -188
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/oauth.d.ts.map +1 -1
- package/dist/routes/oauth.js +3 -8
- package/dist/routes/oauth.js.map +1 -1
- package/dist/routes/shared.d.ts +9 -30
- package/dist/routes/shared.d.ts.map +1 -1
- package/dist/routes/shared.js +10 -69
- package/dist/routes/shared.js.map +1 -1
- package/dist/routes/ui.d.ts.map +1 -1
- package/dist/routes/ui.js +4 -10
- package/dist/routes/ui.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -31
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +3 -6
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +9 -38
- package/dist/skills.js.map +1 -1
- package/dist/timeout.d.ts.map +1 -1
- package/dist/timeout.js +2 -4
- package/dist/timeout.js.map +1 -1
- package/dist/types.d.ts +8 -61
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +1 -2
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +5 -35
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/src/activity.ts +0 -9
- package/src/auth/bearer.ts +3 -32
- package/src/auth/clerk.ts +35 -48
- package/src/auth/downstream-oauth.ts +106 -24
- package/src/call-admission.ts +0 -1
- package/src/catalog-service.ts +23 -23
- package/src/catalog.ts +55 -0
- package/src/connectors/remote-mcp.ts +106 -86
- package/src/errors.ts +22 -0
- package/src/execute.ts +43 -24
- package/src/executor-result.ts +1 -1
- package/src/index.ts +24 -208
- package/src/invocation.ts +63 -10
- package/src/meta-tools.ts +139 -187
- package/src/operator-ui/browser.css +1 -54
- package/src/operator-ui/browser.ts +16 -150
- package/src/operator-ui/generated.ts +2 -2
- package/src/operator-ui/model.ts +1 -23
- package/src/registry.ts +59 -429
- package/src/retired-toolkits.ts +14 -0
- package/src/routes/activity.ts +1 -6
- package/src/routes/credentials.ts +0 -12
- package/src/routes/mcp.ts +110 -230
- package/src/routes/oauth.ts +2 -8
- package/src/routes/shared.ts +10 -100
- package/src/routes/ui.ts +2 -12
- package/src/server.ts +1 -31
- package/src/skills.ts +9 -38
- package/src/timeout.ts +2 -4
- package/src/types.ts +11 -62
- package/src/ui.ts +9 -46
- package/src/version.ts +1 -1
- package/dist/credential-health.d.ts +0 -205
- package/dist/credential-health.d.ts.map +0 -1
- package/dist/credential-health.js +0 -610
- package/dist/credential-health.js.map +0 -1
- package/dist/toolkits.d.ts +0 -124
- package/dist/toolkits.d.ts.map +0 -1
- package/dist/toolkits.js +0 -322
- package/dist/toolkits.js.map +0 -1
- package/src/credential-health.ts +0 -835
- package/src/toolkits.ts +0 -461
package/src/routes/mcp.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
createMcpHandler,
|
|
3
|
+
isLegacyRequest,
|
|
4
|
+
McpServer,
|
|
5
|
+
WebStandardStreamableHTTPServerTransport,
|
|
6
|
+
} from "@modelcontextprotocol/server";
|
|
3
7
|
import type { ActivityActor, ActivityRequestContext } from "../activity.js";
|
|
4
8
|
import { registerExecuteTool } from "../execute.js";
|
|
5
9
|
import {
|
|
@@ -7,14 +11,11 @@ import {
|
|
|
7
11
|
type AdmissionLease,
|
|
8
12
|
} from "../executor-admission.js";
|
|
9
13
|
import { registerMetaTools } from "../meta-tools.js";
|
|
10
|
-
import
|
|
14
|
+
import type { RegistryView } from "../registry.js";
|
|
11
15
|
import { CONNECTA_INSTRUCTIONS } from "../skills.js";
|
|
12
|
-
import {
|
|
13
|
-
import type { Logger, ToolkitBinding } from "../types.js";
|
|
16
|
+
import type { Logger } from "../types.js";
|
|
14
17
|
import {
|
|
15
|
-
MAX_ECHOED_TOOLKIT_NAME,
|
|
16
18
|
authorize,
|
|
17
|
-
loggableValue,
|
|
18
19
|
type RouteContext,
|
|
19
20
|
type RuntimeExecutionContext,
|
|
20
21
|
type ServerOptions,
|
|
@@ -24,7 +25,7 @@ export const MCP_CORS_HEADERS = {
|
|
|
24
25
|
"Access-Control-Allow-Origin": "*",
|
|
25
26
|
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
26
27
|
"Access-Control-Allow-Headers":
|
|
27
|
-
"Content-Type, Authorization, mcp-protocol-version, mcp-session-id",
|
|
28
|
+
"Content-Type, Authorization, mcp-protocol-version, mcp-session-id, mcp-method, mcp-name",
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
// Browser-based MCP clients call /mcp cross-origin. Without CORS on every
|
|
@@ -142,167 +143,45 @@ function releaseAdmissionWithResponse(
|
|
|
142
143
|
});
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
/** What one MCP connection may see: the full registry, or one toolkit's view. */
|
|
146
|
-
interface McpScope {
|
|
147
|
-
registry: RegistryView;
|
|
148
|
-
/** Set only under `?toolkit=`; recorded on activity events. */
|
|
149
|
-
toolkitId?: string;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** The one refusal a bound identity ever sees. Constant on purpose — see below. */
|
|
153
|
-
const TOOLKIT_FORBIDDEN_BODY = JSON.stringify({
|
|
154
|
-
jsonrpc: "2.0",
|
|
155
|
-
id: null,
|
|
156
|
-
error: {
|
|
157
|
-
code: -32600,
|
|
158
|
-
message:
|
|
159
|
-
"Not permitted to use the requested toolkit. This credential is bound " +
|
|
160
|
-
"to a specific toolkit — check the ?toolkit= value in this deployment's " +
|
|
161
|
-
"MCP endpoint URL with the operator.",
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
|
|
165
146
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* A bound identity asking for a toolkit it may not open, for a toolkit that does
|
|
169
|
-
* not exist, or for no toolkit at all gets byte-identical responses, so a team
|
|
170
|
-
* credential cannot be used to enumerate the org's other teams — the boundary
|
|
171
|
-
* would leak the very structure it exists to hide. The operator log below is
|
|
172
|
-
* where the three cases are told apart.
|
|
173
|
-
*/
|
|
174
|
-
function toolkitForbidden(): Response {
|
|
175
|
-
return new Response(TOOLKIT_FORBIDDEN_BODY, {
|
|
176
|
-
status: 403,
|
|
177
|
-
headers: {
|
|
178
|
-
"Content-Type": "application/json",
|
|
179
|
-
"Cache-Control": "no-store",
|
|
180
|
-
},
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** How a rejected connection is named in the operator log. */
|
|
185
|
-
function identityLabel(actor: ActivityActor): string {
|
|
186
|
-
return actor.id ? `${actor.kind} ${loggableValue(actor.id)}` : actor.kind;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Resolve `?toolkit=<name>` into the registry view this connection may see,
|
|
191
|
-
* enforcing the caller's toolkit binding (documentation/toolkits.md) on the way.
|
|
192
|
-
*
|
|
193
|
-
* For an UNBOUND identity (no binding configured — the pre-#37 shape):
|
|
194
|
-
*
|
|
195
|
-
* - absent → the full registry, byte-identical to a deployment with no toolkits
|
|
196
|
-
* - known → a `ScopedRegistry` over that toolkit (the one visibility boundary)
|
|
197
|
-
* - anything else, including `?toolkit=` with an empty value → an explicit
|
|
198
|
-
* 404. Never a silent fallback to the full registry.
|
|
199
|
-
*
|
|
200
|
-
* For a BOUND identity, membership is checked FIRST and refusal is a flat 403:
|
|
201
|
-
* a toolkit outside the binding, an unknown name, and (without `unscoped`) an
|
|
202
|
-
* omitted `?toolkit=` are all refused before any `ScopedRegistry` is built, and
|
|
203
|
-
* all three produce the same response.
|
|
204
|
-
*
|
|
205
|
-
* Neither error enumerates the configured toolkits: the name selects a scope, so
|
|
206
|
-
* a wrong guess gets a flat refusal, not a directory.
|
|
147
|
+
* 404 for any `?toolkit=` value, kept after the feature's retirement (#178).
|
|
207
148
|
*
|
|
208
|
-
*
|
|
209
|
-
* endpoint
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
149
|
+
* Toolkits are gone, but the URLs that named them are not: clients were handed
|
|
150
|
+
* MCP endpoint URLs with `?toolkit=` baked in, and nothing about upgrading the
|
|
151
|
+
* server rotates them. Silently serving those clients the full registry would
|
|
152
|
+
* turn a former scoping boundary into fail-open — so a request still sending
|
|
153
|
+
* the param gets the same explicit 404 an unknown toolkit got before, plus an
|
|
154
|
+
* operator-side log line, which (as ever — issue #47) is the channel that
|
|
155
|
+
* actually reaches a human.
|
|
213
156
|
*/
|
|
214
|
-
function
|
|
215
|
-
url: URL,
|
|
216
|
-
registry: Registry,
|
|
217
|
-
toolkits: ReadonlyMap<string, Toolkit> | undefined,
|
|
218
|
-
logger: Logger,
|
|
219
|
-
identity: { actor: ActivityActor; binding?: ToolkitBinding },
|
|
220
|
-
):
|
|
221
|
-
| { ok: true; scope: McpScope }
|
|
222
|
-
| { ok: false; response: Response } {
|
|
223
|
-
const requested = url.searchParams.get("toolkit");
|
|
224
|
-
const binding = identity.binding;
|
|
225
|
-
const scopeFor = (toolkit: Toolkit) => ({
|
|
226
|
-
ok: true as const,
|
|
227
|
-
scope: {
|
|
228
|
-
registry: new ScopedRegistry(registry, toolkit),
|
|
229
|
-
toolkitId: toolkit.name,
|
|
230
|
-
},
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
if (binding) {
|
|
234
|
-
const who = identityLabel(identity.actor);
|
|
235
|
-
const bound = `Bound toolkits: ${binding.toolkits.join(", ") || "(none)"}${
|
|
236
|
-
binding.unscoped ? ", plus unscoped access" : ""
|
|
237
|
-
}.`;
|
|
238
|
-
if (requested === null) {
|
|
239
|
-
if (binding.unscoped) return { ok: true, scope: { registry } };
|
|
240
|
-
logger.warn(
|
|
241
|
-
`[connecta] refused an unscoped /mcp connection from ${who} with 403: ` +
|
|
242
|
-
"its toolkit binding does not allow the full registry. " +
|
|
243
|
-
bound +
|
|
244
|
-
" The client sees a transport-level failure and never the reason, so " +
|
|
245
|
-
"give it an MCP endpoint URL with a ?toolkit= value it is bound to.",
|
|
246
|
-
);
|
|
247
|
-
return { ok: false, response: toolkitForbidden() };
|
|
248
|
-
}
|
|
249
|
-
const permitted = binding.toolkits.includes(requested);
|
|
250
|
-
const toolkit = permitted ? toolkits?.get(requested) : undefined;
|
|
251
|
-
if (toolkit) return scopeFor(toolkit);
|
|
252
|
-
logger.warn(
|
|
253
|
-
`[connecta] refused an /mcp connection from ${who} with 403: it asked ` +
|
|
254
|
-
`for toolkit ${loggableValue(requested)}, which ` +
|
|
255
|
-
(permitted
|
|
256
|
-
? "its binding allows but this deployment does not configure"
|
|
257
|
-
: "its toolkit binding does not include") +
|
|
258
|
-
". " +
|
|
259
|
-
bound +
|
|
260
|
-
" The client sees a transport-level failure and never the reason, so " +
|
|
261
|
-
"check the ?toolkit= value in its MCP endpoint URL.",
|
|
262
|
-
);
|
|
263
|
-
return { ok: false, response: toolkitForbidden() };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (requested === null) return { ok: true, scope: { registry } };
|
|
267
|
-
const toolkit = toolkits?.get(requested);
|
|
268
|
-
if (toolkit) return scopeFor(toolkit);
|
|
269
|
-
const configured = toolkits && toolkits.size > 0 ? [...toolkits.keys()] : [];
|
|
157
|
+
function toolkitRetired(logger: Logger): Response {
|
|
270
158
|
logger.warn(
|
|
271
|
-
"[connecta] rejected an /mcp connection
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
" The client sees a transport-level failure and never the reason, so " +
|
|
277
|
-
"check the ?toolkit= value in its MCP endpoint URL.",
|
|
159
|
+
"[connecta] rejected an /mcp connection carrying ?toolkit= with 404: " +
|
|
160
|
+
"toolkits were retired in issue #178 (see ethos.md) — one deployment " +
|
|
161
|
+
"serves one audience. The client sees a transport-level failure and " +
|
|
162
|
+
"never the reason, so remove the ?toolkit= value from its MCP endpoint " +
|
|
163
|
+
"URL, or point it at the deployment for its audience.",
|
|
278
164
|
);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
id: null,
|
|
290
|
-
error: {
|
|
291
|
-
code: -32600,
|
|
292
|
-
message:
|
|
293
|
-
`Unknown toolkit ${label}. Check the ?toolkit= value in this ` +
|
|
294
|
-
"deployment's MCP endpoint URL with the operator.",
|
|
295
|
-
},
|
|
296
|
-
}),
|
|
297
|
-
{
|
|
298
|
-
status: 404,
|
|
299
|
-
headers: {
|
|
300
|
-
"Content-Type": "application/json",
|
|
301
|
-
"Cache-Control": "no-store",
|
|
302
|
-
},
|
|
165
|
+
return new Response(
|
|
166
|
+
JSON.stringify({
|
|
167
|
+
jsonrpc: "2.0",
|
|
168
|
+
id: null,
|
|
169
|
+
error: {
|
|
170
|
+
code: -32600,
|
|
171
|
+
message:
|
|
172
|
+
"This deployment does not accept ?toolkit=. Toolkits were retired " +
|
|
173
|
+
"in issue #178 — remove the ?toolkit= value from the MCP endpoint " +
|
|
174
|
+
"URL, or ask the operator for the deployment serving this audience.",
|
|
303
175
|
},
|
|
304
|
-
),
|
|
305
|
-
|
|
176
|
+
}),
|
|
177
|
+
{
|
|
178
|
+
status: 404,
|
|
179
|
+
headers: {
|
|
180
|
+
"Content-Type": "application/json",
|
|
181
|
+
"Cache-Control": "no-store",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
);
|
|
306
185
|
}
|
|
307
186
|
|
|
308
187
|
async function serveMcp(
|
|
@@ -310,61 +189,79 @@ async function serveMcp(
|
|
|
310
189
|
opts: ServerOptions,
|
|
311
190
|
baseUrl: string,
|
|
312
191
|
actor: ActivityActor,
|
|
313
|
-
|
|
192
|
+
registry: RegistryView,
|
|
314
193
|
runtimeContext?: RuntimeExecutionContext,
|
|
315
194
|
): Promise<Response> {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
:
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
...(opts.defaultToolTimeoutMs !== undefined
|
|
343
|
-
? { defaultToolTimeoutMs: opts.defaultToolTimeoutMs }
|
|
344
|
-
: {}),
|
|
345
|
-
...(opts.probeTimeoutMs !== undefined
|
|
346
|
-
? { probeTimeoutMs: opts.probeTimeoutMs }
|
|
347
|
-
: {}),
|
|
348
|
-
...(opts.discoveryConcurrency !== undefined
|
|
349
|
-
? { discoveryConcurrency: opts.discoveryConcurrency }
|
|
350
|
-
: {}),
|
|
351
|
-
requestSignal: request.signal,
|
|
352
|
-
...(runtimeContext
|
|
353
|
-
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
354
|
-
: {}),
|
|
355
|
-
});
|
|
356
|
-
if (opts.executor) {
|
|
357
|
-
registerExecuteTool(server, registry, {
|
|
195
|
+
const createServer = (): McpServer => {
|
|
196
|
+
const server = new McpServer(opts.serverInfo, {
|
|
197
|
+
instructions: CONNECTA_INSTRUCTIONS,
|
|
198
|
+
cacheHints: {
|
|
199
|
+
"tools/list": {
|
|
200
|
+
ttlMs: 3_600_000,
|
|
201
|
+
cacheScope: "private",
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
const activity: ActivityRequestContext | undefined = opts.activity
|
|
206
|
+
? {
|
|
207
|
+
sink: opts.activity,
|
|
208
|
+
actor,
|
|
209
|
+
requestId: crypto.randomUUID(),
|
|
210
|
+
serverInfo: opts.serverInfo,
|
|
211
|
+
...(opts.activityDeploymentId
|
|
212
|
+
? { deploymentId: opts.activityDeploymentId }
|
|
213
|
+
: {}),
|
|
214
|
+
...(runtimeContext?.waitUntil
|
|
215
|
+
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
216
|
+
: {}),
|
|
217
|
+
logger: opts.logger,
|
|
218
|
+
}
|
|
219
|
+
: undefined;
|
|
220
|
+
registerMetaTools(server, registry, {
|
|
358
221
|
baseUrl,
|
|
359
|
-
executor: opts.executor,
|
|
360
|
-
logger: opts.logger,
|
|
361
222
|
...(activity ? { activity } : {}),
|
|
362
|
-
|
|
223
|
+
...(opts.defaultToolTimeoutMs !== undefined
|
|
224
|
+
? { defaultToolTimeoutMs: opts.defaultToolTimeoutMs }
|
|
225
|
+
: {}),
|
|
226
|
+
...(opts.probeTimeoutMs !== undefined
|
|
227
|
+
? { probeTimeoutMs: opts.probeTimeoutMs }
|
|
228
|
+
: {}),
|
|
363
229
|
...(opts.discoveryConcurrency !== undefined
|
|
364
230
|
? { discoveryConcurrency: opts.discoveryConcurrency }
|
|
365
231
|
: {}),
|
|
232
|
+
requestSignal: request.signal,
|
|
233
|
+
...(runtimeContext
|
|
234
|
+
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
235
|
+
: {}),
|
|
366
236
|
});
|
|
237
|
+
if (opts.executor) {
|
|
238
|
+
registerExecuteTool(server, registry, {
|
|
239
|
+
baseUrl,
|
|
240
|
+
executor: opts.executor,
|
|
241
|
+
logger: opts.logger,
|
|
242
|
+
...(activity ? { activity } : {}),
|
|
243
|
+
requestSignal: request.signal,
|
|
244
|
+
...(opts.discoveryConcurrency !== undefined
|
|
245
|
+
? { discoveryConcurrency: opts.discoveryConcurrency }
|
|
246
|
+
: {}),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return server;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// The v2 entry's built-in legacy fallback streams 2025 results as SSE.
|
|
253
|
+
// Connecta's established wire contract is JSON, so retain the documented
|
|
254
|
+
// user-land legacy branch with the same transport setting while the modern
|
|
255
|
+
// branch uses the fetch-native handler.
|
|
256
|
+
if (!(await isLegacyRequest(request))) {
|
|
257
|
+
return createMcpHandler(createServer, {
|
|
258
|
+
legacy: "reject",
|
|
259
|
+
onerror: (error) => opts.logger.error("[connecta] MCP handler error", error),
|
|
260
|
+
}).fetch(request);
|
|
367
261
|
}
|
|
262
|
+
|
|
263
|
+
// Fresh server + transport per legacy request, stateless and JSON-shaped.
|
|
264
|
+
const server = createServer();
|
|
368
265
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
369
266
|
enableJsonResponse: true,
|
|
370
267
|
});
|
|
@@ -399,10 +296,8 @@ export function createMcpRoute(
|
|
|
399
296
|
const {
|
|
400
297
|
path,
|
|
401
298
|
request,
|
|
402
|
-
url,
|
|
403
299
|
baseUrl,
|
|
404
300
|
runtimeContext,
|
|
405
|
-
sweepCredentials,
|
|
406
301
|
} = context;
|
|
407
302
|
if (path !== "/mcp") return null;
|
|
408
303
|
let admission: AdmissionLease;
|
|
@@ -433,9 +328,7 @@ export function createMcpRoute(
|
|
|
433
328
|
throw error;
|
|
434
329
|
}
|
|
435
330
|
try {
|
|
436
|
-
|
|
437
|
-
// must not be able to probe which toolkit names exist.
|
|
438
|
-
const authz = await authorize(request, baseUrl, opts.auth, opts.logger);
|
|
331
|
+
const authz = await authorize(request, baseUrl, opts.auth);
|
|
439
332
|
if (!authz.ok) {
|
|
440
333
|
return releaseAdmissionWithResponse(
|
|
441
334
|
withMcpCors(authz.response),
|
|
@@ -443,26 +336,13 @@ export function createMcpRoute(
|
|
|
443
336
|
request.signal,
|
|
444
337
|
);
|
|
445
338
|
}
|
|
446
|
-
|
|
447
|
-
url,
|
|
448
|
-
opts.registry,
|
|
449
|
-
opts.toolkits,
|
|
450
|
-
opts.logger,
|
|
451
|
-
{
|
|
452
|
-
actor: authz.actor,
|
|
453
|
-
...(authz.toolkitBinding
|
|
454
|
-
? { binding: authz.toolkitBinding }
|
|
455
|
-
: {}),
|
|
456
|
-
},
|
|
457
|
-
);
|
|
458
|
-
if (!selected.ok) {
|
|
339
|
+
if (new URL(request.url).searchParams.has("toolkit")) {
|
|
459
340
|
return releaseAdmissionWithResponse(
|
|
460
|
-
withMcpCors(
|
|
341
|
+
withMcpCors(toolkitRetired(opts.logger)),
|
|
461
342
|
admission,
|
|
462
343
|
request.signal,
|
|
463
344
|
);
|
|
464
345
|
}
|
|
465
|
-
sweepCredentials();
|
|
466
346
|
return releaseAdmissionWithResponse(
|
|
467
347
|
withMcpCors(
|
|
468
348
|
await serveMcp(
|
|
@@ -470,7 +350,7 @@ export function createMcpRoute(
|
|
|
470
350
|
opts,
|
|
471
351
|
baseUrl,
|
|
472
352
|
authz.actor,
|
|
473
|
-
|
|
353
|
+
opts.registry,
|
|
474
354
|
runtimeContext,
|
|
475
355
|
),
|
|
476
356
|
),
|
package/src/routes/oauth.ts
CHANGED
|
@@ -30,7 +30,6 @@ async function handleOAuthManagementRequest(
|
|
|
30
30
|
request,
|
|
31
31
|
baseUrl,
|
|
32
32
|
opts.auth,
|
|
33
|
-
opts.logger,
|
|
34
33
|
"OAuth management",
|
|
35
34
|
);
|
|
36
35
|
if (!admin.ok) return admin.response;
|
|
@@ -61,11 +60,10 @@ async function handleOAuthManagementRequest(
|
|
|
61
60
|
operationError = error;
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
// The old grant and its catalog
|
|
63
|
+
// The old grant and its cached catalog are invalid after either operation,
|
|
65
64
|
// including a partially failed physical cleanup whose epoch fence succeeded.
|
|
66
65
|
try {
|
|
67
66
|
await opts.registry.invalidateStored(connectorId);
|
|
68
|
-
await opts.registry.clearCredentialHealth(connectorId);
|
|
69
67
|
} catch (error) {
|
|
70
68
|
operationError ??= error;
|
|
71
69
|
}
|
|
@@ -311,12 +309,8 @@ export async function routeOAuthCallback(
|
|
|
311
309
|
return refused();
|
|
312
310
|
}
|
|
313
311
|
try {
|
|
314
|
-
await connector.finishAuth(code, connectorContext);
|
|
312
|
+
await connector.finishAuth(code, connectorContext, url.searchParams);
|
|
315
313
|
await opts.registry.invalidateStored(id);
|
|
316
|
-
// Recovery, without a restart: the grant this connector was reported dead
|
|
317
|
-
// for has just been replaced, so drop the verdict rather than let a stale
|
|
318
|
-
// `auth_required` survive until the next scheduled check.
|
|
319
|
-
await opts.registry.clearCredentialHealth(id);
|
|
320
314
|
return html(
|
|
321
315
|
`Connected "${id}". You can close this window.`,
|
|
322
316
|
200,
|
package/src/routes/shared.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Implementation } from "@modelcontextprotocol/server";
|
|
2
2
|
import type { ActivityActor, ActivityReadGate, ActivityStore } from "../activity.js";
|
|
3
3
|
import type { CredentialVault } from "../credentials.js";
|
|
4
4
|
import type { DeferredWork } from "../connector-scope.js";
|
|
5
5
|
import type { AdmissionController } from "../executor-admission.js";
|
|
6
6
|
import type { Registry } from "../registry.js";
|
|
7
|
-
import { resolveIdentityBinding, type Toolkit } from "../toolkits.js";
|
|
8
7
|
import type {
|
|
9
8
|
ConnectaBranding,
|
|
10
9
|
Executor,
|
|
11
10
|
InboundAuth,
|
|
12
11
|
Logger,
|
|
13
|
-
ToolkitBinding,
|
|
14
12
|
} from "../types.js";
|
|
15
13
|
import { operatorPageForPath } from "../ui.js";
|
|
16
14
|
|
|
@@ -20,7 +18,7 @@ export interface ServerOptions {
|
|
|
20
18
|
publicUrl?: string;
|
|
21
19
|
// The SDK's Implementation shape: name/version plus optional title,
|
|
22
20
|
// websiteUrl, and icons (MCP icons spec) that clients may render.
|
|
23
|
-
serverInfo:
|
|
21
|
+
serverInfo: Implementation;
|
|
24
22
|
logger: Logger;
|
|
25
23
|
activity?: ActivityStore;
|
|
26
24
|
activityReadGate?: ActivityReadGate;
|
|
@@ -40,11 +38,6 @@ export interface ServerOptions {
|
|
|
40
38
|
credentialVault?: CredentialVault;
|
|
41
39
|
/** Optional browser UI and OAuth result-page labels. */
|
|
42
40
|
branding?: ConnectaBranding;
|
|
43
|
-
/**
|
|
44
|
-
* Validated named scopes, selected per connection with `?toolkit=<name>` on
|
|
45
|
-
* `/mcp`. Omit (or leave empty) and every connection sees the full registry.
|
|
46
|
-
*/
|
|
47
|
-
toolkits?: ReadonlyMap<string, Toolkit>;
|
|
48
41
|
}
|
|
49
42
|
|
|
50
43
|
export interface RuntimeExecutionContext {
|
|
@@ -59,7 +52,6 @@ export interface RouteContext {
|
|
|
59
52
|
opts: ServerOptions;
|
|
60
53
|
defer: DeferredWork | undefined;
|
|
61
54
|
runtimeContext: RuntimeExecutionContext | undefined;
|
|
62
|
-
sweepCredentials(): void;
|
|
63
55
|
}
|
|
64
56
|
|
|
65
57
|
export function msg(err: unknown): string {
|
|
@@ -80,13 +72,13 @@ export function privateJson(
|
|
|
80
72
|
const MAX_LOGGED_VALUE_LENGTH = 64;
|
|
81
73
|
|
|
82
74
|
/**
|
|
83
|
-
* Bounded, escaped form of a caller-influenced value (
|
|
84
|
-
*
|
|
75
|
+
* Bounded, escaped form of a caller-influenced value (an identity id or OAuth
|
|
76
|
+
* callback connector id) for the operator log. Goes
|
|
85
77
|
* through JSON.stringify so a caller-controlled newline or control character
|
|
86
78
|
* cannot forge a log line, plus a hand-rolled escape for U+2028/U+2029, which
|
|
87
79
|
* JSON.stringify leaves raw even though a log reader treats them as line
|
|
88
|
-
* terminators. Truncated to a small shared cap
|
|
89
|
-
*
|
|
80
|
+
* terminators. Truncated to a small shared cap so an oversized value cannot
|
|
81
|
+
* flood the log either.
|
|
90
82
|
*/
|
|
91
83
|
export function loggableValue(requested: string): string {
|
|
92
84
|
const bounded = requested.slice(0, MAX_LOGGED_VALUE_LENGTH);
|
|
@@ -97,9 +89,6 @@ export function loggableValue(requested: string): string {
|
|
|
97
89
|
return escaped + (bounded.length < requested.length ? " (truncated)" : "");
|
|
98
90
|
}
|
|
99
91
|
|
|
100
|
-
/** Length beyond which a rejected toolkit name is not echoed back. */
|
|
101
|
-
export const MAX_ECHOED_TOOLKIT_NAME = MAX_LOGGED_VALUE_LENGTH;
|
|
102
|
-
|
|
103
92
|
const ACTIVITY_ACTOR_NAMESPACE_RE = /^[\x21-\x7e]{1,256}$/;
|
|
104
93
|
|
|
105
94
|
export function activityActorNamespace(
|
|
@@ -111,29 +100,16 @@ export function activityActorNamespace(
|
|
|
111
100
|
: undefined;
|
|
112
101
|
}
|
|
113
102
|
|
|
114
|
-
/**
|
|
115
|
-
* Refusal for an identity whose toolkit binding cannot be trusted — a malformed
|
|
116
|
-
* declaration, or a malformed per-identity binding out of `authorize`. The
|
|
117
|
-
* caller is authenticated, so this is a 403, and it is deliberately opaque: the
|
|
118
|
-
* cause is an operator bug, and the operator reads it in the log, not the client.
|
|
119
|
-
*/
|
|
120
|
-
function unusableBinding(): Response {
|
|
121
|
-
return privateJson({ error: "forbidden" }, { status: 403 });
|
|
122
|
-
}
|
|
123
|
-
|
|
124
103
|
export async function authorize(
|
|
125
104
|
request: Request,
|
|
126
105
|
baseUrl: string,
|
|
127
106
|
auth: InboundAuth[],
|
|
128
|
-
logger: Logger,
|
|
129
107
|
): Promise<
|
|
130
108
|
| {
|
|
131
109
|
ok: true;
|
|
132
110
|
actor: ActivityActor;
|
|
133
111
|
/** True only when the admitting provider can also authorize UI mutation. */
|
|
134
112
|
uiAdminEligible?: boolean;
|
|
135
|
-
/** The admitting identity's toolkit binding (documentation/toolkits.md). */
|
|
136
|
-
toolkitBinding?: ToolkitBinding;
|
|
137
113
|
}
|
|
138
114
|
| { ok: false; response: Response }
|
|
139
115
|
> {
|
|
@@ -145,24 +121,6 @@ export async function authorize(
|
|
|
145
121
|
const result = await provider.authorize(request, baseUrl);
|
|
146
122
|
if (result.ok) {
|
|
147
123
|
const subjectId = result.subjectId ?? result.userId;
|
|
148
|
-
// Re-validate both halves and cap the per-identity one by the provider's
|
|
149
|
-
// declaration (see resolveIdentityBinding). A binding that does not
|
|
150
|
-
// type-check at runtime refuses the request rather than evaporating:
|
|
151
|
-
// dropping it would hand the caller the full registry, which is the one
|
|
152
|
-
// outcome a binding exists to prevent.
|
|
153
|
-
const binding = resolveIdentityBinding(
|
|
154
|
-
provider.toolkitBinding,
|
|
155
|
-
result.toolkitBinding,
|
|
156
|
-
);
|
|
157
|
-
if (!binding.ok) {
|
|
158
|
-
logger.warn(
|
|
159
|
-
`[connecta] refused a request admitted by inbound auth provider ` +
|
|
160
|
-
`"${provider.kind}" with 403: ${binding.reason}. Until it is fixed ` +
|
|
161
|
-
"this provider cannot admit anyone, because connecta cannot tell " +
|
|
162
|
-
"which toolkits the identity may use.",
|
|
163
|
-
);
|
|
164
|
-
return { ok: false, response: unusableBinding() };
|
|
165
|
-
}
|
|
166
124
|
const actorNamespace = activityActorNamespace(provider);
|
|
167
125
|
return {
|
|
168
126
|
ok: true,
|
|
@@ -176,7 +134,6 @@ export async function authorize(
|
|
|
176
134
|
...(result.userId && provider.uiAuth?.kind === "clerk"
|
|
177
135
|
? { uiAdminEligible: true }
|
|
178
136
|
: {}),
|
|
179
|
-
...(binding.binding ? { toolkitBinding: binding.binding } : {}),
|
|
180
137
|
};
|
|
181
138
|
}
|
|
182
139
|
lastResponse = result.response;
|
|
@@ -195,49 +152,20 @@ export async function authorize(
|
|
|
195
152
|
};
|
|
196
153
|
}
|
|
197
154
|
|
|
198
|
-
/**
|
|
199
|
-
* True when this identity is confined to one or more toolkits — bound, without
|
|
200
|
-
* `unscoped`. Such a credential belongs to a team's agent, not to the operator
|
|
201
|
-
* running the deployment, so the deployment-wide operator surfaces (`/ui/data`,
|
|
202
|
-
* `/ui/activity`, the credential API) refuse it: their payloads describe every
|
|
203
|
-
* connector in the org, which is exactly what the binding exists to withhold.
|
|
204
|
-
*/
|
|
205
|
-
export function isToolkitRestricted(
|
|
206
|
-
binding: ToolkitBinding | undefined,
|
|
207
|
-
): boolean {
|
|
208
|
-
return Boolean(binding && !binding.unscoped);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** The refusal the deployment-wide operator surfaces give a bound identity. */
|
|
212
|
-
export function restrictedOperatorSurface(): Response {
|
|
213
|
-
return privateJson(
|
|
214
|
-
{
|
|
215
|
-
error:
|
|
216
|
-
"this credential is bound to a toolkit and may not read " +
|
|
217
|
-
"deployment-wide operator data",
|
|
218
|
-
},
|
|
219
|
-
{ status: 403 },
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
155
|
export async function authorizeUiAdmin(
|
|
224
156
|
request: Request,
|
|
225
157
|
baseUrl: string,
|
|
226
158
|
auth: InboundAuth[],
|
|
227
|
-
logger: Logger,
|
|
228
159
|
purpose = "credential management",
|
|
229
160
|
): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
|
|
230
161
|
// Operator mutation is intentionally narrower than /mcp and /ui/data: only
|
|
231
162
|
// an interactive Clerk provider may admit it. A static bearer token is useful
|
|
232
163
|
// for headless tool calls but must not become a deployment-admin key.
|
|
233
164
|
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
// would refuse the operator outright, and a refusal here — a failed gate, a
|
|
239
|
-
// missing user, a toolkit-bound identity — is exactly the case where a later
|
|
240
|
-
// provider is the one meant to admit. The last refusal is returned if none do.
|
|
165
|
+
// Every Clerk provider gets a turn, the way the /mcp gate does. Stopping at
|
|
166
|
+
// the first would make admission depend on config order: a failed gate or
|
|
167
|
+
// missing user may simply mean a later provider is the one meant to admit.
|
|
168
|
+
// The last refusal is returned if none do.
|
|
241
169
|
const providers = auth.filter(
|
|
242
170
|
(candidate) => candidate.uiAuth?.kind === "clerk",
|
|
243
171
|
);
|
|
@@ -264,24 +192,6 @@ export async function authorizeUiAdmin(
|
|
|
264
192
|
);
|
|
265
193
|
continue;
|
|
266
194
|
}
|
|
267
|
-
const binding = resolveIdentityBinding(
|
|
268
|
-
provider.toolkitBinding,
|
|
269
|
-
result.toolkitBinding,
|
|
270
|
-
);
|
|
271
|
-
if (!binding.ok) {
|
|
272
|
-
logger.warn(
|
|
273
|
-
`[connecta] refused an operator-mutation request admitted by inbound auth ` +
|
|
274
|
-
`provider "${provider.kind}" with 403: ${binding.reason}.`,
|
|
275
|
-
);
|
|
276
|
-
lastResponse = unusableBinding();
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
// A toolkit-bound identity is a team's credential, not a vault admin key:
|
|
280
|
-
// credentials are deployment-wide, so writing one reaches every toolkit.
|
|
281
|
-
if (isToolkitRestricted(binding.binding)) {
|
|
282
|
-
lastResponse = restrictedOperatorSurface();
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
195
|
return { ok: true, userId: result.userId };
|
|
286
196
|
}
|
|
287
197
|
return {
|