@opengeni/api-router 0.7.3 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +1 -1
- package/dist/{chunk-EYYTFA7N.js → chunk-7PQKPKW5.js} +4117 -1863
- package/dist/chunk-7PQKPKW5.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +12 -11
- package/src/app.ts +37 -15
- package/src/codex-redemption-security.ts +96 -0
- package/src/integrations/oauth-client.ts +147 -61
- package/src/mcp/server.ts +151 -83
- package/src/mcp/session-view.ts +102 -1
- package/src/mcp/toolspace.ts +389 -124
- package/src/model-catalog.ts +337 -0
- package/src/routes/codex.ts +866 -14
- package/src/routes/files.ts +153 -0
- package/src/routes/machines.ts +22 -2
- package/src/routes/sessions.ts +637 -152
- package/src/routes/workspaces.ts +36 -2
- package/src/sandbox/channel-a.ts +54 -59
- package/src/sandbox/machines.ts +7 -0
- package/src/sandbox/rematerialize.ts +287 -0
- package/src/sandbox/viewer.ts +207 -129
- package/dist/chunk-EYYTFA7N.js.map +0 -1
package/src/mcp/toolspace.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
2
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
4
4
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import { environmentsEncryptionKeyBytes, type McpServerConfig } from "@opengeni/config";
|
|
6
6
|
import {
|
|
@@ -17,15 +17,35 @@ import {
|
|
|
17
17
|
import {
|
|
18
18
|
buildConnectionTokenResolver,
|
|
19
19
|
buildHostConnectionTokenResolver,
|
|
20
|
+
clearPendingSessionToolspaceCall,
|
|
21
|
+
getActiveSessionTurnForExecution,
|
|
20
22
|
getSessionRootId,
|
|
21
|
-
getSessionTurn,
|
|
22
23
|
listSessionMcpServerMetadata,
|
|
23
24
|
listSessionMcpServersForRun,
|
|
25
|
+
registerPendingSessionToolCall,
|
|
24
26
|
requireSession,
|
|
25
|
-
|
|
27
|
+
reserveToolspaceCallForAttempt,
|
|
26
28
|
type ResolveConnectionCredentialResult,
|
|
27
29
|
} from "@opengeni/db";
|
|
28
|
-
import { appendAndPublishEvents } from "@opengeni/events";
|
|
30
|
+
import { appendAndPublishEvents, appendAndPublishTurnEventsFenced } from "@opengeni/events";
|
|
31
|
+
import { undiciFetch, type FetchLike } from "@opengeni/network";
|
|
32
|
+
import {
|
|
33
|
+
MCP_MAX_AGGREGATE_TOOL_LIST_BYTES,
|
|
34
|
+
MCP_MAX_AGGREGATE_TOOL_LIST_ENTRIES,
|
|
35
|
+
MCP_MAX_CONCURRENT_SERVER_OPERATIONS,
|
|
36
|
+
MCP_MAX_TOOL_RESULT_BYTES,
|
|
37
|
+
McpAggregateToolListBudget,
|
|
38
|
+
McpPayloadTooLargeError,
|
|
39
|
+
assertMcpPayloadWithinBytes,
|
|
40
|
+
assertMcpServerSelectionWithinBounds,
|
|
41
|
+
assertMcpToolListWithinBounds,
|
|
42
|
+
boundedParallelMap,
|
|
43
|
+
cancelMcpResponseBody,
|
|
44
|
+
guardedMcpFetch,
|
|
45
|
+
mcpSerializedSizeBytes,
|
|
46
|
+
} from "@opengeni/runtime/mcp-network";
|
|
47
|
+
import { Buffer } from "node:buffer";
|
|
48
|
+
import { createHash } from "node:crypto";
|
|
29
49
|
|
|
30
50
|
export type ToolspaceCallResult = CallToolResult;
|
|
31
51
|
|
|
@@ -73,19 +93,109 @@ const FIRST_PARTY_PROXY_IDS = new Set(["files", "docs"]);
|
|
|
73
93
|
// to every upstream on every call.
|
|
74
94
|
const TOOLSPACE_TOOL_LIST_TTL_MS = 30_000;
|
|
75
95
|
const TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES = 2_000;
|
|
76
|
-
const
|
|
96
|
+
const TOOLSPACE_TOOL_LIST_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
|
77
97
|
|
|
78
|
-
type ToolListingEntry = {
|
|
98
|
+
export type ToolListingEntry = {
|
|
79
99
|
serverId: string;
|
|
80
100
|
tool: McpTool;
|
|
81
101
|
requireApproval: McpServerConfig["requireApproval"];
|
|
82
102
|
};
|
|
83
103
|
|
|
104
|
+
type ToolListCacheValue = {
|
|
105
|
+
expiresAt: number;
|
|
106
|
+
entries: ToolListingEntry[];
|
|
107
|
+
sizeBytes: number;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Deterministic LRU bounded by both key count and serialized retained bytes. */
|
|
111
|
+
export class ToolspaceToolListCache {
|
|
112
|
+
private readonly values = new Map<string, ToolListCacheValue>();
|
|
113
|
+
private retainedBytes = 0;
|
|
114
|
+
|
|
115
|
+
constructor(
|
|
116
|
+
private readonly maxEntries = TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES,
|
|
117
|
+
private readonly maxBytes = TOOLSPACE_TOOL_LIST_CACHE_MAX_BYTES,
|
|
118
|
+
private readonly ttlMs = TOOLSPACE_TOOL_LIST_TTL_MS,
|
|
119
|
+
) {
|
|
120
|
+
if (maxEntries < 1 || maxBytes < 1 || ttlMs < 1) {
|
|
121
|
+
throw new Error("toolspace cache limits must be positive");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
read(key: string, now = Date.now()): ToolListingEntry[] | null {
|
|
126
|
+
const hit = this.values.get(key);
|
|
127
|
+
if (!hit) return null;
|
|
128
|
+
if (hit.expiresAt <= now) {
|
|
129
|
+
this.delete(key);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
this.values.delete(key);
|
|
133
|
+
this.values.set(key, hit);
|
|
134
|
+
return hit.entries;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
write(key: string, entries: ToolListingEntry[], now = Date.now()): boolean {
|
|
138
|
+
const sizeBytes = Buffer.byteLength(key) + mcpSerializedSizeBytes(entries);
|
|
139
|
+
if (sizeBytes > this.maxBytes) return false;
|
|
140
|
+
// A rejected replacement is a no-op: preserve the current safe value
|
|
141
|
+
// until the candidate has passed its own size check.
|
|
142
|
+
this.delete(key);
|
|
143
|
+
for (const [existingKey, value] of this.values) {
|
|
144
|
+
if (value.expiresAt <= now) this.delete(existingKey);
|
|
145
|
+
}
|
|
146
|
+
while (this.values.size >= this.maxEntries || this.retainedBytes + sizeBytes > this.maxBytes) {
|
|
147
|
+
const oldestKey = this.values.keys().next().value as string | undefined;
|
|
148
|
+
if (oldestKey === undefined) break;
|
|
149
|
+
this.delete(oldestKey);
|
|
150
|
+
}
|
|
151
|
+
this.values.set(key, { expiresAt: now + this.ttlMs, entries, sizeBytes });
|
|
152
|
+
this.retainedBytes += sizeBytes;
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
clear(): void {
|
|
157
|
+
this.values.clear();
|
|
158
|
+
this.retainedBytes = 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
snapshot(): { entries: number; bytes: number; keys: string[] } {
|
|
162
|
+
return {
|
|
163
|
+
entries: this.values.size,
|
|
164
|
+
bytes: this.retainedBytes,
|
|
165
|
+
keys: [...this.values.keys()],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private delete(key: string): void {
|
|
170
|
+
const existing = this.values.get(key);
|
|
171
|
+
if (!existing) return;
|
|
172
|
+
this.values.delete(key);
|
|
173
|
+
this.retainedBytes -= existing.sizeBytes;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const toolListCache = new ToolspaceToolListCache();
|
|
178
|
+
|
|
179
|
+
type ToolspaceAuthority = {
|
|
180
|
+
sessionId: string;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
type ToolspaceAttemptAuthority = ToolspaceAuthority & {
|
|
184
|
+
turnId: string;
|
|
185
|
+
attemptId: string;
|
|
186
|
+
executionGeneration: number;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
function toolspaceAuthorityForGrant(grant: AccessGrant): ToolspaceAuthority | null {
|
|
190
|
+
const sessionId = grant.metadata?.sessionId;
|
|
191
|
+
return typeof sessionId === "string" ? { sessionId } : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
84
194
|
export function isToolspaceGrant(settings: ApiRouteDeps["settings"], grant: AccessGrant): boolean {
|
|
85
195
|
return (
|
|
86
196
|
settings.toolspaceEnabled &&
|
|
87
197
|
hasPermission(grant.permissions, "toolspace:call") &&
|
|
88
|
-
|
|
198
|
+
toolspaceAuthorityForGrant(grant) !== null
|
|
89
199
|
);
|
|
90
200
|
}
|
|
91
201
|
|
|
@@ -97,7 +207,24 @@ export async function prepareToolspaceMcpSurface(input: {
|
|
|
97
207
|
if (!isToolspaceGrant(deps.settings, grant)) {
|
|
98
208
|
return null;
|
|
99
209
|
}
|
|
100
|
-
const
|
|
210
|
+
const authority = toolspaceAuthorityForGrant(grant);
|
|
211
|
+
if (!authority) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const { sessionId } = authority;
|
|
215
|
+
const activeTurn = await getActiveSessionTurnForExecution(deps.db, grant.workspaceId, sessionId);
|
|
216
|
+
// Recovering/waiting-capacity attempts retain ownership pointers, but they
|
|
217
|
+
// are not currently executing model code. Do not even enumerate upstream
|
|
218
|
+
// tools until the turn has returned to the running state.
|
|
219
|
+
if (!activeTurn?.activeAttemptId || activeTurn.status !== "running") {
|
|
220
|
+
return emptyToolspaceSurface(sessionId, grant.subjectId);
|
|
221
|
+
}
|
|
222
|
+
const attemptAuthority: ToolspaceAttemptAuthority = {
|
|
223
|
+
sessionId,
|
|
224
|
+
turnId: activeTurn.id,
|
|
225
|
+
attemptId: activeTurn.activeAttemptId,
|
|
226
|
+
executionGeneration: activeTurn.executionGeneration,
|
|
227
|
+
};
|
|
101
228
|
const session = await requireSession(deps.db, grant.workspaceId, sessionId);
|
|
102
229
|
let rootSessionId = sessionId;
|
|
103
230
|
if (deps.connectionCredentials?.mcpCredentials) {
|
|
@@ -114,17 +241,24 @@ export async function prepareToolspaceMcpSurface(input: {
|
|
|
114
241
|
// Proxyable ids: everything selected except the first-party OpenGeni tool
|
|
115
242
|
// server and the first-party MCP proxies, both of which would re-enter /mcp.
|
|
116
243
|
const proxyableIds = [...selectedIds].filter((id) => toolspaceCanProxyServerId(id));
|
|
244
|
+
assertMcpServerSelectionWithinBounds(proxyableIds);
|
|
117
245
|
if (proxyableIds.length === 0) {
|
|
118
246
|
return emptyToolspaceSurface(sessionId, grant.subjectId);
|
|
119
247
|
}
|
|
120
|
-
|
|
121
248
|
// The registry (decrypted session servers + capability/pack expansion) is a
|
|
122
249
|
// handful of DB reads with no upstream dials. Build it at most once per
|
|
123
250
|
// request, and only when we actually need it (a cache-miss listing or a real
|
|
124
251
|
// tools/call), so a cache-hit request does no registry work.
|
|
125
|
-
|
|
126
|
-
const getRegistry = () =>
|
|
127
|
-
|
|
252
|
+
const registryPromises = new Map<string, Promise<Map<string, McpServerConfig>>>();
|
|
253
|
+
const getRegistry = (attemptId: string) => {
|
|
254
|
+
const existing = registryPromises.get(attemptId);
|
|
255
|
+
if (existing) {
|
|
256
|
+
return existing;
|
|
257
|
+
}
|
|
258
|
+
const created = buildToolspaceRegistry(deps, grant.workspaceId, sessionId, attemptId);
|
|
259
|
+
registryPromises.set(attemptId, created);
|
|
260
|
+
return created;
|
|
261
|
+
};
|
|
128
262
|
|
|
129
263
|
const listing = await resolveToolListing({
|
|
130
264
|
deps,
|
|
@@ -132,11 +266,18 @@ export async function prepareToolspaceMcpSurface(input: {
|
|
|
132
266
|
sessionId,
|
|
133
267
|
rootSessionId,
|
|
134
268
|
proxyableIds,
|
|
135
|
-
|
|
136
|
-
getRegistry,
|
|
269
|
+
activeTurn,
|
|
270
|
+
getRegistry: () => getRegistry(attemptAuthority.attemptId),
|
|
137
271
|
});
|
|
138
272
|
const tools = listing.map((entry) =>
|
|
139
|
-
toolspaceToolFor({
|
|
273
|
+
toolspaceToolFor({
|
|
274
|
+
deps,
|
|
275
|
+
grant,
|
|
276
|
+
authority: attemptAuthority,
|
|
277
|
+
rootSessionId,
|
|
278
|
+
entry,
|
|
279
|
+
getRegistry,
|
|
280
|
+
}),
|
|
140
281
|
);
|
|
141
282
|
|
|
142
283
|
return {
|
|
@@ -157,6 +298,7 @@ async function buildToolspaceRegistry(
|
|
|
157
298
|
deps: ApiRouteDeps,
|
|
158
299
|
workspaceId: string,
|
|
159
300
|
sessionId: string,
|
|
301
|
+
attemptId: string,
|
|
160
302
|
): Promise<Map<string, McpServerConfig>> {
|
|
161
303
|
const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
|
|
162
304
|
deps.db,
|
|
@@ -167,6 +309,7 @@ async function buildToolspaceRegistry(
|
|
|
167
309
|
deps,
|
|
168
310
|
workspaceId,
|
|
169
311
|
sessionId,
|
|
312
|
+
attemptId,
|
|
170
313
|
runtimeSettings,
|
|
171
314
|
);
|
|
172
315
|
return new Map(withSessionServers.mcpServers.map((server) => [server.id, server]));
|
|
@@ -183,17 +326,10 @@ async function resolveToolListing(input: {
|
|
|
183
326
|
sessionId: string;
|
|
184
327
|
rootSessionId: string;
|
|
185
328
|
proxyableIds: string[];
|
|
186
|
-
|
|
329
|
+
activeTurn: SessionTurn;
|
|
187
330
|
getRegistry: () => Promise<Map<string, McpServerConfig>>;
|
|
188
331
|
}): Promise<ToolListingEntry[]> {
|
|
189
|
-
const { deps, grant, sessionId, rootSessionId, proxyableIds,
|
|
190
|
-
if (!activeTurnId) {
|
|
191
|
-
return [];
|
|
192
|
-
}
|
|
193
|
-
const activeTurn = await getSessionTurn(deps.db, grant.workspaceId, activeTurnId);
|
|
194
|
-
if (!activeTurn || activeTurn.sessionId !== sessionId) {
|
|
195
|
-
return [];
|
|
196
|
-
}
|
|
332
|
+
const { deps, grant, sessionId, rootSessionId, proxyableIds, activeTurn, getRegistry } = input;
|
|
197
333
|
// Host credentials can be initiator-specific. A prior turn's tool list must
|
|
198
334
|
// never be reused under a different frozen authority in the same session.
|
|
199
335
|
const cacheKey = await toolListCacheKey(
|
|
@@ -208,11 +344,21 @@ async function resolveToolListing(input: {
|
|
|
208
344
|
return cached;
|
|
209
345
|
}
|
|
210
346
|
const registry = await getRegistry();
|
|
347
|
+
const aggregateBudget = new McpAggregateToolListBudget(
|
|
348
|
+
"aggregate Toolspace tool list",
|
|
349
|
+
MCP_MAX_AGGREGATE_TOOL_LIST_ENTRIES,
|
|
350
|
+
MCP_MAX_AGGREGATE_TOOL_LIST_BYTES,
|
|
351
|
+
);
|
|
211
352
|
const entries: ToolListingEntry[] = [];
|
|
212
|
-
|
|
353
|
+
// Each mapper commits its source contribution synchronously immediately
|
|
354
|
+
// after the upstream result is bounded. No per-provider result arrays are
|
|
355
|
+
// retained by boundedParallelMap, and a failed aggregate replacement never
|
|
356
|
+
// reaches the cache or the exposed MCP surface.
|
|
357
|
+
await boundedParallelMap(proxyableIds, MCP_MAX_CONCURRENT_SERVER_OPERATIONS, async (serverId) => {
|
|
213
358
|
const config = registry.get(serverId);
|
|
214
359
|
if (!config || !toolspaceCanProxyServer(config)) {
|
|
215
|
-
|
|
360
|
+
aggregateBudget.replace(serverId, []);
|
|
361
|
+
return;
|
|
216
362
|
}
|
|
217
363
|
const connection = await connectToolspaceServer({
|
|
218
364
|
deps,
|
|
@@ -223,22 +369,37 @@ async function resolveToolListing(input: {
|
|
|
223
369
|
turn: activeTurn,
|
|
224
370
|
}).catch(() => null);
|
|
225
371
|
if (!connection) {
|
|
226
|
-
|
|
372
|
+
aggregateBudget.replace(serverId, []);
|
|
373
|
+
return;
|
|
227
374
|
}
|
|
228
375
|
try {
|
|
229
376
|
const listed = await connection.client
|
|
230
377
|
.listTools(undefined, toolspaceRequestOptions(config))
|
|
231
378
|
.catch(() => ({ tools: [] }));
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
379
|
+
let boundedTools: readonly McpTool[];
|
|
380
|
+
try {
|
|
381
|
+
boundedTools = assertMcpToolListWithinBounds(listed.tools as McpTool[]) as McpTool[];
|
|
382
|
+
} catch (error) {
|
|
383
|
+
deps.observability?.warn("toolspace upstream tool list exceeded safety limit", {
|
|
384
|
+
serverId,
|
|
385
|
+
errorClass: error instanceof Error ? error.name : typeof error,
|
|
386
|
+
});
|
|
387
|
+
aggregateBudget.replace(serverId, []);
|
|
388
|
+
return;
|
|
237
389
|
}
|
|
390
|
+
const sourceEntries = boundedTools
|
|
391
|
+
.filter((tool) => Boolean(tool?.name) && allowedByConfig(config, tool.name))
|
|
392
|
+
.map((tool) => ({
|
|
393
|
+
serverId,
|
|
394
|
+
tool,
|
|
395
|
+
requireApproval: config.requireApproval,
|
|
396
|
+
}));
|
|
397
|
+
aggregateBudget.replace(serverId, sourceEntries);
|
|
398
|
+
entries.push(...sourceEntries);
|
|
238
399
|
} finally {
|
|
239
400
|
await connection.close();
|
|
240
401
|
}
|
|
241
|
-
}
|
|
402
|
+
});
|
|
242
403
|
writeToolListCache(cacheKey, entries);
|
|
243
404
|
return entries;
|
|
244
405
|
}
|
|
@@ -260,42 +421,25 @@ async function toolListCacheKey(
|
|
|
260
421
|
const authority = JSON.stringify({
|
|
261
422
|
turnId: turn.id,
|
|
262
423
|
executionGeneration: turn.executionGeneration,
|
|
424
|
+
attemptId: turn.activeAttemptId,
|
|
263
425
|
initiator: turn.initiator,
|
|
264
426
|
});
|
|
265
427
|
return `${workspaceId}:${sessionId}:${signature}:${authority}`;
|
|
266
428
|
}
|
|
267
429
|
|
|
268
430
|
function readToolListCache(key: string): ToolListingEntry[] | null {
|
|
269
|
-
|
|
270
|
-
if (!hit) {
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
if (hit.expiresAt <= Date.now()) {
|
|
274
|
-
toolListCache.delete(key);
|
|
275
|
-
return null;
|
|
276
|
-
}
|
|
277
|
-
return hit.entries;
|
|
431
|
+
return toolListCache.read(key);
|
|
278
432
|
}
|
|
279
433
|
|
|
280
434
|
function writeToolListCache(key: string, entries: ToolListingEntry[]): void {
|
|
281
|
-
|
|
282
|
-
const now = Date.now();
|
|
283
|
-
for (const [existingKey, value] of toolListCache) {
|
|
284
|
-
if (value.expiresAt <= now) {
|
|
285
|
-
toolListCache.delete(existingKey);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
if (toolListCache.size >= TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES) {
|
|
289
|
-
toolListCache.clear();
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
toolListCache.set(key, { expiresAt: Date.now() + TOOLSPACE_TOOL_LIST_TTL_MS, entries });
|
|
435
|
+
toolListCache.write(key, entries);
|
|
293
436
|
}
|
|
294
437
|
|
|
295
438
|
async function settingsWithSessionMcpServersForToolspace(
|
|
296
439
|
deps: ApiRouteDeps,
|
|
297
440
|
workspaceId: string,
|
|
298
441
|
sessionId: string,
|
|
442
|
+
attemptId: string,
|
|
299
443
|
settings: ApiRouteDeps["settings"],
|
|
300
444
|
): Promise<ApiRouteDeps["settings"]> {
|
|
301
445
|
const encryptionKey = environmentsEncryptionKeyBytes(settings);
|
|
@@ -314,6 +458,7 @@ async function settingsWithSessionMcpServersForToolspace(
|
|
|
314
458
|
deps.db,
|
|
315
459
|
workspaceId,
|
|
316
460
|
sessionId,
|
|
461
|
+
attemptId,
|
|
317
462
|
encryptionKey ?? null,
|
|
318
463
|
);
|
|
319
464
|
if (servers.length === 0) {
|
|
@@ -349,9 +494,10 @@ async function connectToolspaceServer(input: {
|
|
|
349
494
|
rootSessionId: string;
|
|
350
495
|
turn: SessionTurn;
|
|
351
496
|
}): Promise<ConnectedToolspaceServer> {
|
|
497
|
+
const guardedFetch = guardedMcpFetch(input.deps.settings, undiciFetch);
|
|
352
498
|
const baseFetch: FetchLike = input.config.connectionRef
|
|
353
|
-
? connectionBrokerFetch(
|
|
354
|
-
:
|
|
499
|
+
? connectionBrokerFetch(guardedFetch, input)
|
|
500
|
+
: guardedFetch;
|
|
355
501
|
const client = new Client(
|
|
356
502
|
{ name: `opengeni-toolspace-${input.config.id}`, version: "1.0.0" },
|
|
357
503
|
{ capabilities: {} },
|
|
@@ -362,7 +508,12 @@ async function connectToolspaceServer(input: {
|
|
|
362
508
|
headers: toolspaceServerHeaders(input.config),
|
|
363
509
|
},
|
|
364
510
|
});
|
|
365
|
-
|
|
511
|
+
try {
|
|
512
|
+
await client.connect(transport as unknown as Transport, toolspaceRequestOptions(input.config));
|
|
513
|
+
} catch (error) {
|
|
514
|
+
await client.close().catch(() => undefined);
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
366
517
|
return {
|
|
367
518
|
config: input.config,
|
|
368
519
|
client,
|
|
@@ -375,12 +526,13 @@ async function connectToolspaceServer(input: {
|
|
|
375
526
|
function toolspaceToolFor(input: {
|
|
376
527
|
deps: ApiRouteDeps;
|
|
377
528
|
grant: AccessGrant;
|
|
378
|
-
|
|
529
|
+
authority: ToolspaceAttemptAuthority;
|
|
379
530
|
rootSessionId: string;
|
|
380
531
|
entry: ToolListingEntry;
|
|
381
|
-
getRegistry: () => Promise<Map<string, McpServerConfig>>;
|
|
532
|
+
getRegistry: (attemptId: string) => Promise<Map<string, McpServerConfig>>;
|
|
382
533
|
}): ToolspaceRegisteredTool {
|
|
383
|
-
const { deps, grant,
|
|
534
|
+
const { deps, grant, authority, rootSessionId, entry, getRegistry } = input;
|
|
535
|
+
const { sessionId } = authority;
|
|
384
536
|
const { serverId, tool } = entry;
|
|
385
537
|
const name = prefixedMcpToolName(serverId, tool.name);
|
|
386
538
|
const approvalRequired = mcpToolRequiresApproval(entry.requireApproval, tool.name);
|
|
@@ -392,10 +544,7 @@ function toolspaceToolFor(input: {
|
|
|
392
544
|
...(description ? { description } : {}),
|
|
393
545
|
...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
|
|
394
546
|
call: async (args) => {
|
|
395
|
-
|
|
396
|
-
return mcpError(APPROVAL_REQUIRED_MESSAGE);
|
|
397
|
-
}
|
|
398
|
-
const reservation = await reserveActiveTurnCall(deps, grant.workspaceId, sessionId);
|
|
547
|
+
const reservation = await reserveExactAttemptCall(deps, grant, authority);
|
|
399
548
|
if (reservation.status === "no_active_turn") {
|
|
400
549
|
return mcpError(TOOLSPACE_NO_ACTIVE_TURN_MESSAGE);
|
|
401
550
|
}
|
|
@@ -406,9 +555,9 @@ function toolspaceToolFor(input: {
|
|
|
406
555
|
}
|
|
407
556
|
const turnId = reservation.turn.id;
|
|
408
557
|
// Dial only the ONE server this tool belongs to, from the freshly-built
|
|
409
|
-
// registry
|
|
410
|
-
//
|
|
411
|
-
const registry = await getRegistry();
|
|
558
|
+
// exact-attempt registry. Listing policy is descriptive only; a stale MCP
|
|
559
|
+
// surface can never decide authorization for a successor attempt.
|
|
560
|
+
const registry = await getRegistry(authority.attemptId);
|
|
412
561
|
const config = registry.get(serverId);
|
|
413
562
|
if (!config || !toolspaceCanProxyServer(config) || !allowedByConfig(config, tool.name)) {
|
|
414
563
|
return mcpError(`upstream tool failed: ${name}`);
|
|
@@ -429,39 +578,91 @@ function toolspaceToolFor(input: {
|
|
|
429
578
|
}
|
|
430
579
|
try {
|
|
431
580
|
const callId = crypto.randomUUID();
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
581
|
+
const receipt = {
|
|
582
|
+
accountId: grant.accountId,
|
|
583
|
+
workspaceId: grant.workspaceId,
|
|
584
|
+
sessionId,
|
|
585
|
+
turnId,
|
|
586
|
+
executionGeneration: authority.executionGeneration,
|
|
587
|
+
attemptId: authority.attemptId,
|
|
588
|
+
callId,
|
|
589
|
+
};
|
|
590
|
+
const registered = await registerPendingSessionToolCall(deps.db, {
|
|
591
|
+
...receipt,
|
|
592
|
+
callType: "toolspace_call",
|
|
593
|
+
callItem: {
|
|
594
|
+
type: "toolspace_call",
|
|
595
|
+
id: callId,
|
|
596
|
+
name,
|
|
597
|
+
arguments: toolspaceAuditSummary(args),
|
|
598
|
+
serverId,
|
|
599
|
+
toolName: tool.name,
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
if (!registered.accepted || !registered.registered) {
|
|
603
|
+
return mcpError(TOOLSPACE_NO_ACTIVE_TURN_MESSAGE);
|
|
604
|
+
}
|
|
605
|
+
const created = await appendAndPublishTurnEventsFenced(
|
|
606
|
+
deps.db,
|
|
607
|
+
deps.bus,
|
|
608
|
+
grant.workspaceId,
|
|
609
|
+
sessionId,
|
|
610
|
+
turnId,
|
|
611
|
+
authority.executionGeneration,
|
|
612
|
+
authority.attemptId,
|
|
613
|
+
[
|
|
614
|
+
{
|
|
615
|
+
type: "agent.toolCall.created",
|
|
616
|
+
turnId,
|
|
617
|
+
turnGeneration: authority.executionGeneration,
|
|
618
|
+
turnAttemptId: authority.attemptId,
|
|
619
|
+
producerId: grant.subjectId,
|
|
620
|
+
payload: {
|
|
621
|
+
id: callId,
|
|
622
|
+
name,
|
|
623
|
+
arguments: args,
|
|
624
|
+
origin: "toolspace",
|
|
625
|
+
subjectId: grant.subjectId,
|
|
626
|
+
raw: {
|
|
627
|
+
type: "toolspace_call",
|
|
628
|
+
serverId,
|
|
629
|
+
toolName: tool.name,
|
|
630
|
+
},
|
|
447
631
|
},
|
|
448
632
|
},
|
|
449
|
-
|
|
450
|
-
|
|
633
|
+
],
|
|
634
|
+
);
|
|
635
|
+
if (!created.accepted) {
|
|
636
|
+
return mcpError(TOOLSPACE_NO_ACTIVE_TURN_MESSAGE);
|
|
637
|
+
}
|
|
451
638
|
const output = await callRemoteTool(deps, connection, tool.name, args);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
639
|
+
const completed = await appendAndPublishTurnEventsFenced(
|
|
640
|
+
deps.db,
|
|
641
|
+
deps.bus,
|
|
642
|
+
grant.workspaceId,
|
|
643
|
+
sessionId,
|
|
644
|
+
turnId,
|
|
645
|
+
authority.executionGeneration,
|
|
646
|
+
authority.attemptId,
|
|
647
|
+
[
|
|
648
|
+
{
|
|
649
|
+
type: "agent.toolCall.output",
|
|
650
|
+
turnId,
|
|
651
|
+
turnGeneration: authority.executionGeneration,
|
|
652
|
+
turnAttemptId: authority.attemptId,
|
|
653
|
+
producerId: grant.subjectId,
|
|
654
|
+
payload: {
|
|
655
|
+
id: callId,
|
|
656
|
+
output: toolspaceAuditSummary(output),
|
|
657
|
+
origin: "toolspace",
|
|
658
|
+
subjectId: grant.subjectId,
|
|
659
|
+
},
|
|
462
660
|
},
|
|
463
|
-
|
|
464
|
-
|
|
661
|
+
],
|
|
662
|
+
);
|
|
663
|
+
if (completed.accepted) {
|
|
664
|
+
await clearPendingSessionToolspaceCall(deps.db, receipt);
|
|
665
|
+
}
|
|
465
666
|
return output;
|
|
466
667
|
} finally {
|
|
467
668
|
await connection.close();
|
|
@@ -477,7 +678,7 @@ async function callRemoteTool(
|
|
|
477
678
|
args: Record<string, unknown>,
|
|
478
679
|
): Promise<ToolspaceCallResult> {
|
|
479
680
|
try {
|
|
480
|
-
|
|
681
|
+
const output = (await server.client.callTool(
|
|
481
682
|
{
|
|
482
683
|
name: toolName,
|
|
483
684
|
arguments: args,
|
|
@@ -485,7 +686,17 @@ async function callRemoteTool(
|
|
|
485
686
|
undefined,
|
|
486
687
|
toolspaceRequestOptions(server.config),
|
|
487
688
|
)) as ToolspaceCallResult;
|
|
689
|
+
assertMcpPayloadWithinBytes(output, MCP_MAX_TOOL_RESULT_BYTES, "MCP tool result");
|
|
690
|
+
return output;
|
|
488
691
|
} catch (error) {
|
|
692
|
+
if (error instanceof McpPayloadTooLargeError) {
|
|
693
|
+
deps.observability?.warn("toolspace upstream tool result exceeded safety limit", {
|
|
694
|
+
serverId: server.config.id,
|
|
695
|
+
toolName,
|
|
696
|
+
errorClass: error.name,
|
|
697
|
+
});
|
|
698
|
+
return mcpError("upstream tool result exceeded the safety limit");
|
|
699
|
+
}
|
|
489
700
|
if (isToolspaceAuthNeededError(error)) {
|
|
490
701
|
return mcpError(TOOLSPACE_AUTH_NEEDED_MESSAGE);
|
|
491
702
|
}
|
|
@@ -501,34 +712,49 @@ async function callRemoteTool(
|
|
|
501
712
|
}
|
|
502
713
|
}
|
|
503
714
|
|
|
715
|
+
function toolspaceAuditSummary(value: unknown): {
|
|
716
|
+
redacted: true;
|
|
717
|
+
sizeBytes: number;
|
|
718
|
+
sha256: string;
|
|
719
|
+
} {
|
|
720
|
+
let serialized: string;
|
|
721
|
+
try {
|
|
722
|
+
serialized = JSON.stringify(value) ?? "null";
|
|
723
|
+
} catch {
|
|
724
|
+
serialized = "[unserializable]";
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
redacted: true,
|
|
728
|
+
sizeBytes: Buffer.byteLength(serialized),
|
|
729
|
+
sha256: createHash("sha256").update(serialized).digest("hex"),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
504
733
|
type ToolspaceReservation =
|
|
505
734
|
| { status: "ok"; turn: SessionTurn }
|
|
506
735
|
| { status: "no_active_turn" }
|
|
507
736
|
| { status: "budget_exhausted" };
|
|
508
737
|
|
|
509
|
-
async function
|
|
738
|
+
async function reserveExactAttemptCall(
|
|
510
739
|
deps: ApiRouteDeps,
|
|
511
|
-
|
|
512
|
-
|
|
740
|
+
grant: AccessGrant,
|
|
741
|
+
authority: ToolspaceAttemptAuthority,
|
|
513
742
|
): Promise<ToolspaceReservation> {
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
deps.settings.toolspaceMaxCallsPerTurn,
|
|
524
|
-
);
|
|
743
|
+
const reservation = await reserveToolspaceCallForAttempt(deps.db, {
|
|
744
|
+
accountId: grant.accountId,
|
|
745
|
+
workspaceId: grant.workspaceId,
|
|
746
|
+
sessionId: authority.sessionId,
|
|
747
|
+
turnId: authority.turnId,
|
|
748
|
+
executionGeneration: authority.executionGeneration,
|
|
749
|
+
attemptId: authority.attemptId,
|
|
750
|
+
limit: deps.settings.toolspaceMaxCallsPerTurn,
|
|
751
|
+
});
|
|
525
752
|
if (!reservation.reserved) {
|
|
526
|
-
return
|
|
753
|
+
return reservation.reason === "budget_exhausted"
|
|
754
|
+
? { status: "budget_exhausted" }
|
|
755
|
+
: { status: "no_active_turn" };
|
|
527
756
|
}
|
|
528
|
-
|
|
529
|
-
return turn && turn.sessionId === sessionId
|
|
530
|
-
? { status: "ok", turn }
|
|
531
|
-
: { status: "no_active_turn" };
|
|
757
|
+
return { status: "ok", turn: reservation.turn };
|
|
532
758
|
}
|
|
533
759
|
|
|
534
760
|
function selectedMcpServerIds(tools: ToolRef[], sessionServerIds: string[]): Set<string> {
|
|
@@ -599,7 +825,11 @@ type McpRequestInfo = {
|
|
|
599
825
|
toolName?: string;
|
|
600
826
|
};
|
|
601
827
|
|
|
602
|
-
function
|
|
828
|
+
function mcpRequestDestinationUrl(input: string | URL | Request): string {
|
|
829
|
+
return new URL(input instanceof Request ? input.url : input.toString()).toString();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function connectionBrokerFetch(
|
|
603
833
|
baseFetch: FetchLike,
|
|
604
834
|
input: {
|
|
605
835
|
deps: ApiRouteDeps;
|
|
@@ -630,10 +860,12 @@ function connectionBrokerFetch(
|
|
|
630
860
|
: buildConnectionTokenResolver(input.deps.db, input.deps.settings);
|
|
631
861
|
return async (requestInput, init) => {
|
|
632
862
|
const request = await mcpRequestInfo(requestInput, init);
|
|
863
|
+
const destinationUrl = mcpRequestDestinationUrl(requestInput);
|
|
633
864
|
const first = await resolveCredential({
|
|
634
865
|
workspaceId: input.grant.workspaceId,
|
|
635
866
|
serverId: input.config.id,
|
|
636
867
|
connectionRef,
|
|
868
|
+
destinationUrl,
|
|
637
869
|
forceRefresh: false,
|
|
638
870
|
...(request.toolName ? { toolName: request.toolName } : {}),
|
|
639
871
|
subjectId: input.grant.subjectId,
|
|
@@ -646,10 +878,12 @@ function connectionBrokerFetch(
|
|
|
646
878
|
withConnectionHeaders(requestInput, init, first.headers),
|
|
647
879
|
);
|
|
648
880
|
if (response.status === 401) {
|
|
881
|
+
await cancelMcpResponseBody(response);
|
|
649
882
|
const refreshed = await resolveCredential({
|
|
650
883
|
workspaceId: input.grant.workspaceId,
|
|
651
884
|
serverId: input.config.id,
|
|
652
885
|
connectionRef,
|
|
886
|
+
destinationUrl,
|
|
653
887
|
forceRefresh: true,
|
|
654
888
|
...(request.toolName ? { toolName: request.toolName } : {}),
|
|
655
889
|
subjectId: input.grant.subjectId,
|
|
@@ -657,12 +891,30 @@ function connectionBrokerFetch(
|
|
|
657
891
|
if (refreshed.status === "auth_needed") {
|
|
658
892
|
return await authNeededFetchResponse(input, request, refreshed);
|
|
659
893
|
}
|
|
660
|
-
|
|
894
|
+
const retry = await baseFetch(
|
|
661
895
|
fetchInputForAttempt(requestInput),
|
|
662
896
|
withConnectionHeaders(requestInput, init, refreshed.headers),
|
|
663
897
|
);
|
|
898
|
+
if (retry.status === 401) {
|
|
899
|
+
await cancelMcpResponseBody(retry);
|
|
900
|
+
return await authNeededFetchResponse(
|
|
901
|
+
input,
|
|
902
|
+
request,
|
|
903
|
+
authNeededFromStatus(input.config, refreshed, "expired"),
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
if (retry.status === 403) {
|
|
907
|
+
await cancelMcpResponseBody(retry);
|
|
908
|
+
return await authNeededFetchResponse(
|
|
909
|
+
input,
|
|
910
|
+
request,
|
|
911
|
+
authNeededFromStatus(input.config, refreshed, "insufficient_scope"),
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
return retry;
|
|
664
915
|
}
|
|
665
916
|
if (response.status === 403) {
|
|
917
|
+
await cancelMcpResponseBody(response);
|
|
666
918
|
return await authNeededFetchResponse(
|
|
667
919
|
input,
|
|
668
920
|
request,
|
|
@@ -748,8 +1000,19 @@ async function authNeededFetchResponse(
|
|
|
748
1000
|
return new Response("Authentication required for MCP server connection", { status: 401 });
|
|
749
1001
|
}
|
|
750
1002
|
|
|
751
|
-
async function mcpRequestInfo(
|
|
752
|
-
|
|
1003
|
+
async function mcpRequestInfo(
|
|
1004
|
+
input: string | URL | Request,
|
|
1005
|
+
init?: RequestInit,
|
|
1006
|
+
): Promise<McpRequestInfo> {
|
|
1007
|
+
const body =
|
|
1008
|
+
typeof init?.body === "string"
|
|
1009
|
+
? init.body
|
|
1010
|
+
: input instanceof Request && (init?.method ?? input.method).toUpperCase() === "POST"
|
|
1011
|
+
? await input
|
|
1012
|
+
.clone()
|
|
1013
|
+
.text()
|
|
1014
|
+
.catch(() => "")
|
|
1015
|
+
: "";
|
|
753
1016
|
if (!body) {
|
|
754
1017
|
return {};
|
|
755
1018
|
}
|
|
@@ -779,19 +1042,21 @@ async function mcpRequestInfo(_input: string | URL, init?: RequestInit): Promise
|
|
|
779
1042
|
}
|
|
780
1043
|
|
|
781
1044
|
function withConnectionHeaders(
|
|
782
|
-
|
|
1045
|
+
input: string | URL | Request,
|
|
783
1046
|
init: RequestInit | undefined,
|
|
784
1047
|
authHeaders: Record<string, string>,
|
|
785
1048
|
): RequestInit {
|
|
786
|
-
const headers = new Headers(
|
|
1049
|
+
const headers = new Headers(
|
|
1050
|
+
init?.headers ?? (input instanceof Request ? input.headers : undefined),
|
|
1051
|
+
);
|
|
787
1052
|
for (const [name, value] of Object.entries(authHeaders)) {
|
|
788
1053
|
headers.set(name, value);
|
|
789
1054
|
}
|
|
790
1055
|
return { ...init, headers };
|
|
791
1056
|
}
|
|
792
1057
|
|
|
793
|
-
function fetchInputForAttempt(input: string | URL): string | URL {
|
|
794
|
-
return input;
|
|
1058
|
+
function fetchInputForAttempt(input: string | URL | Request): string | URL | Request {
|
|
1059
|
+
return input instanceof Request ? input.clone() : input;
|
|
795
1060
|
}
|
|
796
1061
|
|
|
797
1062
|
function isToolspaceAuthNeededError(error: unknown): boolean {
|