@zackbart/connecta 0.7.8 → 0.8.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 +94 -0
- package/README.md +67 -144
- package/dist/activity.d.ts +1 -1
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/bearer.d.ts +1 -1
- package/dist/auth/bearer.js +4 -4
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +1 -1
- package/dist/auth/clerk.js +1 -1
- package/dist/call-admission.d.ts +81 -0
- package/dist/call-admission.d.ts.map +1 -0
- package/dist/call-admission.js +345 -0
- package/dist/call-admission.js.map +1 -0
- package/dist/catalog-fingerprint.d.ts +20 -0
- package/dist/catalog-fingerprint.d.ts.map +1 -0
- package/dist/catalog-fingerprint.js +34 -0
- package/dist/catalog-fingerprint.js.map +1 -0
- package/dist/catalog-limits.d.ts +10 -0
- package/dist/catalog-limits.d.ts.map +1 -0
- package/dist/catalog-limits.js +10 -0
- package/dist/catalog-limits.js.map +1 -0
- package/dist/catalog-service.d.ts +138 -0
- package/dist/catalog-service.d.ts.map +1 -0
- package/dist/catalog-service.js +407 -0
- package/dist/catalog-service.js.map +1 -0
- package/dist/catalog.js +2 -2
- package/dist/catalog.js.map +1 -1
- package/dist/concurrency.d.ts +4 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +31 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/connectors/api.d.ts +3 -1
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +22 -11
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +3 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +44 -18
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +0 -22
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +14 -10
- package/dist/credential-health.js.map +1 -1
- package/dist/credentials.d.ts +3 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +9 -8
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +144 -291
- package/dist/execute.js.map +1 -1
- package/dist/executor-result.d.ts +0 -3
- package/dist/executor-result.d.ts.map +1 -1
- package/dist/executor-result.js +2 -2
- package/dist/executor-result.js.map +1 -1
- package/dist/executors/quickjs-child.js +3 -3
- package/dist/executors/quickjs-child.js.map +1 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -1
- package/dist/executors/quickjs-protocol.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.js +7 -2
- package/dist/executors/quickjs-runtime.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +4 -1
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/favicon.js +2 -2
- package/dist/favicon.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -17
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts +96 -0
- package/dist/invocation.d.ts.map +1 -0
- package/dist/invocation.js +369 -0
- package/dist/invocation.js.map +1 -0
- package/dist/meta-tools.d.ts +11 -86
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +145 -485
- package/dist/meta-tools.js.map +1 -1
- package/dist/operator-ui/generated.d.ts +3 -0
- package/dist/operator-ui/generated.d.ts.map +1 -0
- package/dist/operator-ui/generated.js +5 -0
- package/dist/operator-ui/generated.js.map +1 -0
- package/dist/operator-ui/model.d.ts +94 -0
- package/dist/operator-ui/model.d.ts.map +1 -0
- package/dist/operator-ui/model.js +28 -0
- package/dist/operator-ui/model.js.map +1 -0
- package/dist/registry.d.ts +47 -6
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +356 -35
- package/dist/registry.js.map +1 -1
- package/dist/routes/activity.d.ts +3 -0
- package/dist/routes/activity.d.ts.map +1 -0
- package/dist/routes/activity.js +173 -0
- package/dist/routes/activity.js.map +1 -0
- package/dist/routes/credentials.d.ts +3 -0
- package/dist/routes/credentials.d.ts.map +1 -0
- package/dist/routes/credentials.js +181 -0
- package/dist/routes/credentials.js.map +1 -0
- package/dist/routes/mcp.d.ts +8 -0
- package/dist/routes/mcp.d.ts.map +1 -0
- package/dist/routes/mcp.js +383 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/oauth.d.ts +4 -0
- package/dist/routes/oauth.d.ts.map +1 -0
- package/dist/routes/oauth.js +268 -0
- package/dist/routes/oauth.js.map +1 -0
- package/dist/routes/shared.d.ts +97 -0
- package/dist/routes/shared.d.ts.map +1 -0
- package/dist/routes/shared.js +199 -0
- package/dist/routes/shared.js.map +1 -0
- package/dist/routes/ui.d.ts +3 -0
- package/dist/routes/ui.d.ts.map +1 -0
- package/dist/routes/ui.js +122 -0
- package/dist/routes/ui.js.map +1 -0
- package/dist/server.d.ts +9 -40
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +67 -1292
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +0 -15
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +6 -5
- package/dist/skills.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +3 -1
- package/dist/storage/file.js.map +1 -1
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +3 -1
- package/dist/storage/memory.js.map +1 -1
- package/dist/toolkits.d.ts +0 -22
- package/dist/toolkits.d.ts.map +1 -1
- package/dist/toolkits.js +2 -2
- package/dist/toolkits.js.map +1 -1
- package/dist/types.d.ts +64 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +3 -93
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +26 -1389
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +15 -8
- package/src/activity.ts +5 -1
- package/src/auth/bearer.ts +4 -4
- package/src/auth/clerk.ts +1 -1
- package/src/call-admission.ts +522 -0
- package/src/catalog-fingerprint.ts +52 -0
- package/src/catalog-limits.ts +11 -0
- package/src/catalog-service.ts +601 -0
- package/src/catalog.ts +2 -2
- package/src/concurrency.ts +39 -0
- package/src/connectors/api.ts +25 -11
- package/src/connectors/remote-mcp.ts +55 -21
- package/src/credential-health.ts +13 -10
- package/src/credentials.ts +2 -2
- package/src/errors.ts +1 -1
- package/src/execute.ts +194 -377
- package/src/executor-result.ts +2 -2
- package/src/executors/quickjs-child.ts +8 -4
- package/src/executors/quickjs-protocol.ts +1 -1
- package/src/executors/quickjs-runtime.ts +5 -1
- package/src/executors/quickjs.ts +11 -8
- package/src/favicon.ts +2 -2
- package/src/index.ts +54 -17
- package/src/invocation.ts +527 -0
- package/src/meta-tools.ts +369 -797
- package/src/operator-ui/browser.css +413 -0
- package/src/operator-ui/browser.ts +1121 -0
- package/src/operator-ui/generated.ts +4 -0
- package/src/operator-ui/model.ts +127 -0
- package/src/registry.ts +489 -44
- package/src/routes/activity.ts +220 -0
- package/src/routes/credentials.ts +266 -0
- package/src/routes/mcp.ts +485 -0
- package/src/routes/oauth.ts +328 -0
- package/src/routes/shared.ts +330 -0
- package/src/routes/ui.ts +153 -0
- package/src/server.ts +70 -1702
- package/src/skills.ts +6 -5
- package/src/storage/file.ts +3 -1
- package/src/storage/memory.ts +3 -1
- package/src/toolkits.ts +2 -2
- package/src/types.ts +70 -2
- package/src/ui.ts +52 -1489
- package/src/version.ts +1 -1
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
|
+
import type { ActivityActor, ActivityRequestContext } from "../activity.js";
|
|
4
|
+
import { registerExecuteTool } from "../execute.js";
|
|
5
|
+
import {
|
|
6
|
+
ExecutorAdmissionError,
|
|
7
|
+
type AdmissionLease,
|
|
8
|
+
} from "../executor-admission.js";
|
|
9
|
+
import { registerMetaTools } from "../meta-tools.js";
|
|
10
|
+
import { ScopedRegistry, type Registry, type RegistryView } from "../registry.js";
|
|
11
|
+
import { CONNECTA_INSTRUCTIONS } from "../skills.js";
|
|
12
|
+
import { TOOLKIT_NAME_RE, type Toolkit } from "../toolkits.js";
|
|
13
|
+
import type { Logger, ToolkitBinding } from "../types.js";
|
|
14
|
+
import {
|
|
15
|
+
MAX_ECHOED_TOOLKIT_NAME,
|
|
16
|
+
authorize,
|
|
17
|
+
loggableValue,
|
|
18
|
+
type RouteContext,
|
|
19
|
+
type RuntimeExecutionContext,
|
|
20
|
+
type ServerOptions,
|
|
21
|
+
} from "./shared.js";
|
|
22
|
+
|
|
23
|
+
export const MCP_CORS_HEADERS = {
|
|
24
|
+
"Access-Control-Allow-Origin": "*",
|
|
25
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
26
|
+
"Access-Control-Allow-Headers":
|
|
27
|
+
"Content-Type, Authorization, mcp-protocol-version, mcp-session-id",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Browser-based MCP clients call /mcp cross-origin. Without CORS on every
|
|
31
|
+
// response — errors included — the browser hides the 401, the client cannot
|
|
32
|
+
// read WWW-Authenticate, and OAuth discovery silently never starts.
|
|
33
|
+
function withMcpCors(response: Response): Response {
|
|
34
|
+
const headers = new Headers(response.headers);
|
|
35
|
+
for (const [name, value] of Object.entries(MCP_CORS_HEADERS)) {
|
|
36
|
+
headers.set(name, value);
|
|
37
|
+
}
|
|
38
|
+
headers.set(
|
|
39
|
+
"Access-Control-Expose-Headers",
|
|
40
|
+
"WWW-Authenticate, Retry-After, mcp-session-id, mcp-protocol-version",
|
|
41
|
+
);
|
|
42
|
+
return new Response(response.body, {
|
|
43
|
+
status: response.status,
|
|
44
|
+
statusText: response.statusText,
|
|
45
|
+
headers,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requestAdmissionFailure(error: ExecutorAdmissionError): Response {
|
|
50
|
+
const overloaded = error.code === "executor_overloaded";
|
|
51
|
+
const data = {
|
|
52
|
+
code: overloaded ? "server_overloaded" : "server_shutting_down",
|
|
53
|
+
retryable: overloaded,
|
|
54
|
+
...(overloaded && error.retryAfterMs !== undefined
|
|
55
|
+
? { retryAfterMs: error.retryAfterMs }
|
|
56
|
+
: {}),
|
|
57
|
+
};
|
|
58
|
+
const headers = new Headers({
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
"Cache-Control": "no-store",
|
|
61
|
+
});
|
|
62
|
+
if (overloaded && error.retryAfterMs !== undefined) {
|
|
63
|
+
headers.set(
|
|
64
|
+
"Retry-After",
|
|
65
|
+
String(Math.max(1, Math.ceil(error.retryAfterMs / 1_000))),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return new Response(
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
jsonrpc: "2.0",
|
|
71
|
+
id: null,
|
|
72
|
+
error: {
|
|
73
|
+
code: overloaded ? -32001 : -32002,
|
|
74
|
+
message: overloaded
|
|
75
|
+
? "Server capacity is exhausted. Retry later."
|
|
76
|
+
: "Server is shutting down.",
|
|
77
|
+
data,
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
{ status: 503, headers },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A request owns its permit through the response body, not merely until the
|
|
86
|
+
* handler returns. This is what makes slow clients and response-stream failure
|
|
87
|
+
* part of the same bounded lifecycle as success, error, and cancellation.
|
|
88
|
+
*/
|
|
89
|
+
function releaseAdmissionWithResponse(
|
|
90
|
+
response: Response,
|
|
91
|
+
lease: AdmissionLease,
|
|
92
|
+
signal: AbortSignal,
|
|
93
|
+
): Response {
|
|
94
|
+
let released = false;
|
|
95
|
+
let onAbort = () => {};
|
|
96
|
+
const release = () => {
|
|
97
|
+
if (released) return;
|
|
98
|
+
released = true;
|
|
99
|
+
signal.removeEventListener("abort", onAbort);
|
|
100
|
+
lease.release();
|
|
101
|
+
};
|
|
102
|
+
if (!response.body) {
|
|
103
|
+
release();
|
|
104
|
+
return response;
|
|
105
|
+
}
|
|
106
|
+
const reader = response.body.getReader();
|
|
107
|
+
onAbort = () => {
|
|
108
|
+
// `cancel()` belongs to an operator/auth/SDK-provided stream and may
|
|
109
|
+
// reject. Consume both outcomes: `.finally(release)` would release the
|
|
110
|
+
// permit but preserve the rejection as an unhandled promise.
|
|
111
|
+
void reader.cancel(signal.reason).then(release, release);
|
|
112
|
+
};
|
|
113
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
114
|
+
if (signal.aborted) onAbort();
|
|
115
|
+
const body = new ReadableStream<Uint8Array>({
|
|
116
|
+
async pull(controller) {
|
|
117
|
+
try {
|
|
118
|
+
const next = await reader.read();
|
|
119
|
+
if (next.done) {
|
|
120
|
+
release();
|
|
121
|
+
controller.close();
|
|
122
|
+
} else {
|
|
123
|
+
controller.enqueue(next.value);
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
release();
|
|
127
|
+
controller.error(error);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
async cancel(reason) {
|
|
131
|
+
try {
|
|
132
|
+
await reader.cancel(reason);
|
|
133
|
+
} finally {
|
|
134
|
+
release();
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
return new Response(body, {
|
|
139
|
+
status: response.status,
|
|
140
|
+
statusText: response.statusText,
|
|
141
|
+
headers: response.headers,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
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
|
+
/**
|
|
166
|
+
* 403 for every binding refusal, with a body that does not depend on WHY.
|
|
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.
|
|
207
|
+
*
|
|
208
|
+
* Because of that — and because SDK clients treat a 404/403 on the transport
|
|
209
|
+
* endpoint as a transport failure and discard the body — every rejection is also
|
|
210
|
+
* logged operator-side (issue #47), which is the channel that actually reaches a
|
|
211
|
+
* human. The log line may name the configured or bound toolkits; the response
|
|
212
|
+
* still may not.
|
|
213
|
+
*/
|
|
214
|
+
function resolveToolkitScope(
|
|
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()] : [];
|
|
270
|
+
logger.warn(
|
|
271
|
+
"[connecta] rejected an /mcp connection asking for unknown toolkit " +
|
|
272
|
+
`${loggableValue(requested)} with 404. ` +
|
|
273
|
+
(configured.length > 0
|
|
274
|
+
? `Configured toolkits: ${configured.join(", ")}.`
|
|
275
|
+
: "This deployment configures no toolkits, so no ?toolkit= value is accepted.") +
|
|
276
|
+
" The client sees a transport-level failure and never the reason, so " +
|
|
277
|
+
"check the ?toolkit= value in its MCP endpoint URL.",
|
|
278
|
+
);
|
|
279
|
+
const label =
|
|
280
|
+
requested.length <= MAX_ECHOED_TOOLKIT_NAME &&
|
|
281
|
+
TOOLKIT_NAME_RE.test(requested)
|
|
282
|
+
? `"${requested}"`
|
|
283
|
+
: "requested";
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
response: new Response(
|
|
287
|
+
JSON.stringify({
|
|
288
|
+
jsonrpc: "2.0",
|
|
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
|
+
},
|
|
303
|
+
},
|
|
304
|
+
),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function serveMcp(
|
|
309
|
+
request: Request,
|
|
310
|
+
opts: ServerOptions,
|
|
311
|
+
baseUrl: string,
|
|
312
|
+
actor: ActivityActor,
|
|
313
|
+
scope: McpScope,
|
|
314
|
+
runtimeContext?: RuntimeExecutionContext,
|
|
315
|
+
): Promise<Response> {
|
|
316
|
+
// Fresh McpServer + transport per request (SDK ≥1.26 requirement), stateless.
|
|
317
|
+
const server = new McpServer(opts.serverInfo, {
|
|
318
|
+
instructions: CONNECTA_INSTRUCTIONS,
|
|
319
|
+
});
|
|
320
|
+
const activity: ActivityRequestContext | undefined = opts.activity
|
|
321
|
+
? {
|
|
322
|
+
sink: opts.activity,
|
|
323
|
+
actor,
|
|
324
|
+
requestId: crypto.randomUUID(),
|
|
325
|
+
serverInfo: opts.serverInfo,
|
|
326
|
+
...(opts.activityDeploymentId
|
|
327
|
+
? { deploymentId: opts.activityDeploymentId }
|
|
328
|
+
: {}),
|
|
329
|
+
...(scope.toolkitId ? { toolkitId: scope.toolkitId } : {}),
|
|
330
|
+
...(runtimeContext?.waitUntil
|
|
331
|
+
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
332
|
+
: {}),
|
|
333
|
+
logger: opts.logger,
|
|
334
|
+
}
|
|
335
|
+
: undefined;
|
|
336
|
+
// `scope.registry` is the connection's VIEW — the full registry, or one
|
|
337
|
+
// toolkit's ScopedRegistry. Nothing below may reach for `opts.registry`.
|
|
338
|
+
const registry = scope.registry;
|
|
339
|
+
registerMetaTools(server, registry, {
|
|
340
|
+
baseUrl,
|
|
341
|
+
...(activity ? { activity } : {}),
|
|
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, {
|
|
358
|
+
baseUrl,
|
|
359
|
+
executor: opts.executor,
|
|
360
|
+
logger: opts.logger,
|
|
361
|
+
...(activity ? { activity } : {}),
|
|
362
|
+
requestSignal: request.signal,
|
|
363
|
+
...(opts.discoveryConcurrency !== undefined
|
|
364
|
+
? { discoveryConcurrency: opts.discoveryConcurrency }
|
|
365
|
+
: {}),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
369
|
+
enableJsonResponse: true,
|
|
370
|
+
});
|
|
371
|
+
await server.connect(transport);
|
|
372
|
+
return transport.handleRequest(request);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function createMcpRoute(
|
|
376
|
+
opts: ServerOptions,
|
|
377
|
+
): (context: RouteContext) => Promise<Response | null> {
|
|
378
|
+
let lastAdmissionWarningAt = 0;
|
|
379
|
+
let suppressedAdmissionWarnings = 0;
|
|
380
|
+
const warnAdmissionRejected = (error: ExecutorAdmissionError): void => {
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
if (now - lastAdmissionWarningAt < 1_000) {
|
|
383
|
+
suppressedAdmissionWarnings++;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
opts.logger.warn("[connecta] MCP request admission rejected", {
|
|
387
|
+
retryAfterMs: error.retryAfterMs,
|
|
388
|
+
active: opts.requestAdmission.activeCount,
|
|
389
|
+
queued: opts.requestAdmission.queuedCount,
|
|
390
|
+
suppressedSinceLastWarning: suppressedAdmissionWarnings,
|
|
391
|
+
});
|
|
392
|
+
lastAdmissionWarningAt = now;
|
|
393
|
+
suppressedAdmissionWarnings = 0;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return async function routeMcp(
|
|
397
|
+
context: RouteContext,
|
|
398
|
+
): Promise<Response | null> {
|
|
399
|
+
const {
|
|
400
|
+
path,
|
|
401
|
+
request,
|
|
402
|
+
url,
|
|
403
|
+
baseUrl,
|
|
404
|
+
runtimeContext,
|
|
405
|
+
sweepCredentials,
|
|
406
|
+
} = context;
|
|
407
|
+
if (path !== "/mcp") return null;
|
|
408
|
+
let admission: AdmissionLease;
|
|
409
|
+
try {
|
|
410
|
+
admission = await opts.requestAdmission.acquire({
|
|
411
|
+
signal: request.signal,
|
|
412
|
+
});
|
|
413
|
+
if (admission.waitMs > 0) {
|
|
414
|
+
opts.logger.debug("[connecta] MCP request admitted after queue wait", {
|
|
415
|
+
waitMs: admission.waitMs,
|
|
416
|
+
active: opts.requestAdmission.activeCount,
|
|
417
|
+
queued: opts.requestAdmission.queuedCount,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (
|
|
422
|
+
error instanceof ExecutorAdmissionError &&
|
|
423
|
+
error.code === "executor_cancelled"
|
|
424
|
+
) {
|
|
425
|
+
throw request.signal.reason ?? error;
|
|
426
|
+
}
|
|
427
|
+
if (error instanceof ExecutorAdmissionError) {
|
|
428
|
+
if (error.code === "executor_overloaded") {
|
|
429
|
+
warnAdmissionRejected(error);
|
|
430
|
+
}
|
|
431
|
+
return withMcpCors(requestAdmissionFailure(error));
|
|
432
|
+
}
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
// Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
|
|
437
|
+
// must not be able to probe which toolkit names exist.
|
|
438
|
+
const authz = await authorize(request, baseUrl, opts.auth, opts.logger);
|
|
439
|
+
if (!authz.ok) {
|
|
440
|
+
return releaseAdmissionWithResponse(
|
|
441
|
+
withMcpCors(authz.response),
|
|
442
|
+
admission,
|
|
443
|
+
request.signal,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const selected = resolveToolkitScope(
|
|
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) {
|
|
459
|
+
return releaseAdmissionWithResponse(
|
|
460
|
+
withMcpCors(selected.response),
|
|
461
|
+
admission,
|
|
462
|
+
request.signal,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
sweepCredentials();
|
|
466
|
+
return releaseAdmissionWithResponse(
|
|
467
|
+
withMcpCors(
|
|
468
|
+
await serveMcp(
|
|
469
|
+
request,
|
|
470
|
+
opts,
|
|
471
|
+
baseUrl,
|
|
472
|
+
authz.actor,
|
|
473
|
+
selected.scope,
|
|
474
|
+
runtimeContext,
|
|
475
|
+
),
|
|
476
|
+
),
|
|
477
|
+
admission,
|
|
478
|
+
request.signal,
|
|
479
|
+
);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
admission.release();
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
}
|