@opengeni/api-router 0.5.7 → 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.
@@ -1,9 +1,14 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
- import type { FetchLike, Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
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
- import { prefixedMcpToolName, type AccessGrant, type ToolRef } from "@opengeni/contracts";
6
+ import {
7
+ prefixedMcpToolName,
8
+ type AccessGrant,
9
+ type SessionTurn,
10
+ type ToolRef,
11
+ } from "@opengeni/contracts";
7
12
  import {
8
13
  hasPermission,
9
14
  settingsWithEnabledCapabilityMcpServers,
@@ -11,13 +16,36 @@ import {
11
16
  } from "@opengeni/core";
12
17
  import {
13
18
  buildConnectionTokenResolver,
19
+ buildHostConnectionTokenResolver,
20
+ clearPendingSessionToolspaceCall,
21
+ getActiveSessionTurnForExecution,
22
+ getSessionRootId,
14
23
  listSessionMcpServerMetadata,
15
24
  listSessionMcpServersForRun,
25
+ registerPendingSessionToolCall,
16
26
  requireSession,
17
- reserveToolspaceCallForTurn,
27
+ reserveToolspaceCallForAttempt,
18
28
  type ResolveConnectionCredentialResult,
19
29
  } from "@opengeni/db";
20
- 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";
21
49
 
22
50
  export type ToolspaceCallResult = CallToolResult;
23
51
 
@@ -65,19 +93,109 @@ const FIRST_PARTY_PROXY_IDS = new Set(["files", "docs"]);
65
93
  // to every upstream on every call.
66
94
  const TOOLSPACE_TOOL_LIST_TTL_MS = 30_000;
67
95
  const TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES = 2_000;
68
- const toolListCache = new Map<string, { expiresAt: number; entries: ToolListingEntry[] }>();
96
+ const TOOLSPACE_TOOL_LIST_CACHE_MAX_BYTES = 64 * 1024 * 1024;
69
97
 
70
- type ToolListingEntry = {
98
+ export type ToolListingEntry = {
71
99
  serverId: string;
72
100
  tool: McpTool;
73
101
  requireApproval: McpServerConfig["requireApproval"];
74
102
  };
75
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
+
76
194
  export function isToolspaceGrant(settings: ApiRouteDeps["settings"], grant: AccessGrant): boolean {
77
195
  return (
78
196
  settings.toolspaceEnabled &&
79
197
  hasPermission(grant.permissions, "toolspace:call") &&
80
- typeof grant.metadata?.sessionId === "string"
198
+ toolspaceAuthorityForGrant(grant) !== null
81
199
  );
82
200
  }
83
201
 
@@ -89,8 +207,33 @@ export async function prepareToolspaceMcpSurface(input: {
89
207
  if (!isToolspaceGrant(deps.settings, grant)) {
90
208
  return null;
91
209
  }
92
- const sessionId = grant.metadata!.sessionId as string;
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
+ };
93
228
  const session = await requireSession(deps.db, grant.workspaceId, sessionId);
229
+ let rootSessionId = sessionId;
230
+ if (deps.connectionCredentials?.mcpCredentials) {
231
+ const resolvedRootSessionId = await getSessionRootId(deps.db, grant.workspaceId, sessionId);
232
+ if (!resolvedRootSessionId) {
233
+ throw new Error(`cannot resolve host MCP credentials for missing session ${sessionId}`);
234
+ }
235
+ rootSessionId = resolvedRootSessionId;
236
+ }
94
237
  const selectedIds = selectedMcpServerIds(
95
238
  session.tools,
96
239
  session.mcpServers.map((server) => server.id),
@@ -98,28 +241,43 @@ export async function prepareToolspaceMcpSurface(input: {
98
241
  // Proxyable ids: everything selected except the first-party OpenGeni tool
99
242
  // server and the first-party MCP proxies, both of which would re-enter /mcp.
100
243
  const proxyableIds = [...selectedIds].filter((id) => toolspaceCanProxyServerId(id));
244
+ assertMcpServerSelectionWithinBounds(proxyableIds);
101
245
  if (proxyableIds.length === 0) {
102
246
  return emptyToolspaceSurface(sessionId, grant.subjectId);
103
247
  }
104
-
105
248
  // The registry (decrypted session servers + capability/pack expansion) is a
106
249
  // handful of DB reads with no upstream dials. Build it at most once per
107
250
  // request, and only when we actually need it (a cache-miss listing or a real
108
251
  // tools/call), so a cache-hit request does no registry work.
109
- let registryPromise: Promise<Map<string, McpServerConfig>> | null = null;
110
- const getRegistry = () =>
111
- (registryPromise ??= buildToolspaceRegistry(deps, grant.workspaceId, sessionId));
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
+ };
112
262
 
113
263
  const listing = await resolveToolListing({
114
264
  deps,
115
265
  grant,
116
266
  sessionId,
267
+ rootSessionId,
117
268
  proxyableIds,
118
- activeTurnId: session.activeTurnId ?? null,
119
- getRegistry,
269
+ activeTurn,
270
+ getRegistry: () => getRegistry(attemptAuthority.attemptId),
120
271
  });
121
272
  const tools = listing.map((entry) =>
122
- toolspaceToolFor({ deps, grant, sessionId, entry, getRegistry }),
273
+ toolspaceToolFor({
274
+ deps,
275
+ grant,
276
+ authority: attemptAuthority,
277
+ rootSessionId,
278
+ entry,
279
+ getRegistry,
280
+ }),
123
281
  );
124
282
 
125
283
  return {
@@ -140,6 +298,7 @@ async function buildToolspaceRegistry(
140
298
  deps: ApiRouteDeps,
141
299
  workspaceId: string,
142
300
  sessionId: string,
301
+ attemptId: string,
143
302
  ): Promise<Map<string, McpServerConfig>> {
144
303
  const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
145
304
  deps.db,
@@ -150,6 +309,7 @@ async function buildToolspaceRegistry(
150
309
  deps,
151
310
  workspaceId,
152
311
  sessionId,
312
+ attemptId,
153
313
  runtimeSettings,
154
314
  );
155
315
  return new Map(withSessionServers.mcpServers.map((server) => [server.id, server]));
@@ -164,46 +324,82 @@ async function resolveToolListing(input: {
164
324
  deps: ApiRouteDeps;
165
325
  grant: AccessGrant;
166
326
  sessionId: string;
327
+ rootSessionId: string;
167
328
  proxyableIds: string[];
168
- activeTurnId: string | null;
329
+ activeTurn: SessionTurn;
169
330
  getRegistry: () => Promise<Map<string, McpServerConfig>>;
170
331
  }): Promise<ToolListingEntry[]> {
171
- const { deps, grant, sessionId, proxyableIds, activeTurnId, getRegistry } = input;
172
- const cacheKey = await toolListCacheKey(deps, grant.workspaceId, sessionId, proxyableIds);
332
+ const { deps, grant, sessionId, rootSessionId, proxyableIds, activeTurn, getRegistry } = input;
333
+ // Host credentials can be initiator-specific. A prior turn's tool list must
334
+ // never be reused under a different frozen authority in the same session.
335
+ const cacheKey = await toolListCacheKey(
336
+ deps,
337
+ grant.workspaceId,
338
+ sessionId,
339
+ proxyableIds,
340
+ activeTurn,
341
+ );
173
342
  const cached = readToolListCache(cacheKey);
174
343
  if (cached) {
175
344
  return cached;
176
345
  }
177
- if (!activeTurnId) {
178
- return [];
179
- }
180
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
+ );
181
352
  const entries: ToolListingEntry[] = [];
182
- for (const serverId of proxyableIds) {
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) => {
183
358
  const config = registry.get(serverId);
184
359
  if (!config || !toolspaceCanProxyServer(config)) {
185
- continue;
360
+ aggregateBudget.replace(serverId, []);
361
+ return;
186
362
  }
187
- const connection = await connectToolspaceServer({ deps, grant, config, sessionId }).catch(
188
- () => null,
189
- );
363
+ const connection = await connectToolspaceServer({
364
+ deps,
365
+ grant,
366
+ config,
367
+ sessionId,
368
+ rootSessionId,
369
+ turn: activeTurn,
370
+ }).catch(() => null);
190
371
  if (!connection) {
191
- continue;
372
+ aggregateBudget.replace(serverId, []);
373
+ return;
192
374
  }
193
375
  try {
194
376
  const listed = await connection.client
195
377
  .listTools(undefined, toolspaceRequestOptions(config))
196
378
  .catch(() => ({ tools: [] }));
197
- for (const tool of listed.tools as McpTool[]) {
198
- if (!tool?.name || !allowedByConfig(config, tool.name)) {
199
- continue;
200
- }
201
- entries.push({ serverId, tool, requireApproval: config.requireApproval });
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;
202
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);
203
399
  } finally {
204
400
  await connection.close();
205
401
  }
206
- }
402
+ });
207
403
  writeToolListCache(cacheKey, entries);
208
404
  return entries;
209
405
  }
@@ -213,6 +409,7 @@ async function toolListCacheKey(
213
409
  workspaceId: string,
214
410
  sessionId: string,
215
411
  proxyableIds: string[],
412
+ turn: SessionTurn,
216
413
  ): Promise<string> {
217
414
  const metadata = await listSessionMcpServerMetadata(deps.db, workspaceId, sessionId);
218
415
  const versions = new Map(metadata.map((server) => [server.id, server.credentialVersion]));
@@ -221,40 +418,28 @@ async function toolListCacheKey(
221
418
  .sort()
222
419
  .map((id) => `${id}@${versions.get(id) ?? 0}`)
223
420
  .join(",");
224
- return `${workspaceId}:${sessionId}:${signature}`;
421
+ const authority = JSON.stringify({
422
+ turnId: turn.id,
423
+ executionGeneration: turn.executionGeneration,
424
+ attemptId: turn.activeAttemptId,
425
+ initiator: turn.initiator,
426
+ });
427
+ return `${workspaceId}:${sessionId}:${signature}:${authority}`;
225
428
  }
226
429
 
227
430
  function readToolListCache(key: string): ToolListingEntry[] | null {
228
- const hit = toolListCache.get(key);
229
- if (!hit) {
230
- return null;
231
- }
232
- if (hit.expiresAt <= Date.now()) {
233
- toolListCache.delete(key);
234
- return null;
235
- }
236
- return hit.entries;
431
+ return toolListCache.read(key);
237
432
  }
238
433
 
239
434
  function writeToolListCache(key: string, entries: ToolListingEntry[]): void {
240
- if (toolListCache.size >= TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES) {
241
- const now = Date.now();
242
- for (const [existingKey, value] of toolListCache) {
243
- if (value.expiresAt <= now) {
244
- toolListCache.delete(existingKey);
245
- }
246
- }
247
- if (toolListCache.size >= TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES) {
248
- toolListCache.clear();
249
- }
250
- }
251
- toolListCache.set(key, { expiresAt: Date.now() + TOOLSPACE_TOOL_LIST_TTL_MS, entries });
435
+ toolListCache.write(key, entries);
252
436
  }
253
437
 
254
438
  async function settingsWithSessionMcpServersForToolspace(
255
439
  deps: ApiRouteDeps,
256
440
  workspaceId: string,
257
441
  sessionId: string,
442
+ attemptId: string,
258
443
  settings: ApiRouteDeps["settings"],
259
444
  ): Promise<ApiRouteDeps["settings"]> {
260
445
  const encryptionKey = environmentsEncryptionKeyBytes(settings);
@@ -263,9 +448,19 @@ async function settingsWithSessionMcpServersForToolspace(
263
448
  if (metadata.length === 0) {
264
449
  return settings;
265
450
  }
266
- throw new Error("session MCP server credentials require OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY");
451
+ if (metadata.some((server) => server.headerNames.length > 0)) {
452
+ throw new Error(
453
+ "session MCP server credentials require OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY",
454
+ );
455
+ }
267
456
  }
268
- const servers = await listSessionMcpServersForRun(deps.db, workspaceId, sessionId, encryptionKey);
457
+ const servers = await listSessionMcpServersForRun(
458
+ deps.db,
459
+ workspaceId,
460
+ sessionId,
461
+ attemptId,
462
+ encryptionKey ?? null,
463
+ );
269
464
  if (servers.length === 0) {
270
465
  return settings;
271
466
  }
@@ -284,6 +479,7 @@ async function settingsWithSessionMcpServersForToolspace(
284
479
  ...(server.requireApproval !== undefined
285
480
  ? { requireApproval: server.requireApproval }
286
481
  : {}),
482
+ ...(server.connectionRef ? { connectionRef: server.connectionRef } : {}),
287
483
  headers: server.headers,
288
484
  })),
289
485
  ],
@@ -295,10 +491,13 @@ async function connectToolspaceServer(input: {
295
491
  grant: AccessGrant;
296
492
  config: McpServerConfig;
297
493
  sessionId: string;
494
+ rootSessionId: string;
495
+ turn: SessionTurn;
298
496
  }): Promise<ConnectedToolspaceServer> {
497
+ const guardedFetch = guardedMcpFetch(input.deps.settings, undiciFetch);
299
498
  const baseFetch: FetchLike = input.config.connectionRef
300
- ? connectionBrokerFetch(globalThis.fetch, input)
301
- : globalThis.fetch;
499
+ ? connectionBrokerFetch(guardedFetch, input)
500
+ : guardedFetch;
302
501
  const client = new Client(
303
502
  { name: `opengeni-toolspace-${input.config.id}`, version: "1.0.0" },
304
503
  { capabilities: {} },
@@ -309,7 +508,12 @@ async function connectToolspaceServer(input: {
309
508
  headers: toolspaceServerHeaders(input.config),
310
509
  },
311
510
  });
312
- await client.connect(transport as unknown as Transport, toolspaceRequestOptions(input.config));
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
+ }
313
517
  return {
314
518
  config: input.config,
315
519
  client,
@@ -322,11 +526,13 @@ async function connectToolspaceServer(input: {
322
526
  function toolspaceToolFor(input: {
323
527
  deps: ApiRouteDeps;
324
528
  grant: AccessGrant;
325
- sessionId: string;
529
+ authority: ToolspaceAttemptAuthority;
530
+ rootSessionId: string;
326
531
  entry: ToolListingEntry;
327
- getRegistry: () => Promise<Map<string, McpServerConfig>>;
532
+ getRegistry: (attemptId: string) => Promise<Map<string, McpServerConfig>>;
328
533
  }): ToolspaceRegisteredTool {
329
- const { deps, grant, sessionId, entry, getRegistry } = input;
534
+ const { deps, grant, authority, rootSessionId, entry, getRegistry } = input;
535
+ const { sessionId } = authority;
330
536
  const { serverId, tool } = entry;
331
537
  const name = prefixedMcpToolName(serverId, tool.name);
332
538
  const approvalRequired = mcpToolRequiresApproval(entry.requireApproval, tool.name);
@@ -338,10 +544,7 @@ function toolspaceToolFor(input: {
338
544
  ...(description ? { description } : {}),
339
545
  ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
340
546
  call: async (args) => {
341
- if (approvalRequired) {
342
- return mcpError(APPROVAL_REQUIRED_MESSAGE);
343
- }
344
- const reservation = await reserveActiveTurnCall(deps, grant.workspaceId, sessionId);
547
+ const reservation = await reserveExactAttemptCall(deps, grant, authority);
345
548
  if (reservation.status === "no_active_turn") {
346
549
  return mcpError(TOOLSPACE_NO_ACTIVE_TURN_MESSAGE);
347
550
  }
@@ -350,11 +553,11 @@ function toolspaceToolFor(input: {
350
553
  `toolspace call budget exhausted (${deps.settings.toolspaceMaxCallsPerTurn}/turn)`,
351
554
  );
352
555
  }
353
- const turnId = reservation.turnId;
556
+ const turnId = reservation.turn.id;
354
557
  // Dial only the ONE server this tool belongs to, from the freshly-built
355
- // registry, and re-check policy against that live config (the listing may
356
- // have been served from a slightly stale cache entry).
357
- 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);
358
561
  const config = registry.get(serverId);
359
562
  if (!config || !toolspaceCanProxyServer(config) || !allowedByConfig(config, tool.name)) {
360
563
  return mcpError(`upstream tool failed: ${name}`);
@@ -362,47 +565,104 @@ function toolspaceToolFor(input: {
362
565
  if (mcpToolRequiresApproval(config.requireApproval, tool.name)) {
363
566
  return mcpError(APPROVAL_REQUIRED_MESSAGE);
364
567
  }
365
- const connection = await connectToolspaceServer({ deps, grant, config, sessionId }).catch(
366
- () => null,
367
- );
568
+ const connection = await connectToolspaceServer({
569
+ deps,
570
+ grant,
571
+ config,
572
+ sessionId,
573
+ rootSessionId,
574
+ turn: reservation.turn,
575
+ }).catch(() => null);
368
576
  if (!connection) {
369
577
  return mcpError(`upstream tool failed: ${name}`);
370
578
  }
371
579
  try {
372
580
  const callId = crypto.randomUUID();
373
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
374
- {
375
- type: "agent.toolCall.created",
376
- turnId,
377
- producerId: grant.subjectId,
378
- payload: {
379
- id: callId,
380
- name,
381
- arguments: args,
382
- origin: "toolspace",
383
- subjectId: grant.subjectId,
384
- raw: {
385
- type: "toolspace_call",
386
- serverId,
387
- toolName: tool.name,
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
+ },
388
631
  },
389
632
  },
390
- },
391
- ]);
633
+ ],
634
+ );
635
+ if (!created.accepted) {
636
+ return mcpError(TOOLSPACE_NO_ACTIVE_TURN_MESSAGE);
637
+ }
392
638
  const output = await callRemoteTool(deps, connection, tool.name, args);
393
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
394
- {
395
- type: "agent.toolCall.output",
396
- turnId,
397
- producerId: grant.subjectId,
398
- payload: {
399
- id: callId,
400
- output,
401
- origin: "toolspace",
402
- subjectId: grant.subjectId,
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
+ },
403
660
  },
404
- },
405
- ]);
661
+ ],
662
+ );
663
+ if (completed.accepted) {
664
+ await clearPendingSessionToolspaceCall(deps.db, receipt);
665
+ }
406
666
  return output;
407
667
  } finally {
408
668
  await connection.close();
@@ -418,7 +678,7 @@ async function callRemoteTool(
418
678
  args: Record<string, unknown>,
419
679
  ): Promise<ToolspaceCallResult> {
420
680
  try {
421
- return (await server.client.callTool(
681
+ const output = (await server.client.callTool(
422
682
  {
423
683
  name: toolName,
424
684
  arguments: args,
@@ -426,7 +686,17 @@ async function callRemoteTool(
426
686
  undefined,
427
687
  toolspaceRequestOptions(server.config),
428
688
  )) as ToolspaceCallResult;
689
+ assertMcpPayloadWithinBytes(output, MCP_MAX_TOOL_RESULT_BYTES, "MCP tool result");
690
+ return output;
429
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
+ }
430
700
  if (isToolspaceAuthNeededError(error)) {
431
701
  return mcpError(TOOLSPACE_AUTH_NEEDED_MESSAGE);
432
702
  }
@@ -442,30 +712,49 @@ async function callRemoteTool(
442
712
  }
443
713
  }
444
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
+
445
733
  type ToolspaceReservation =
446
- | { status: "ok"; turnId: string }
734
+ | { status: "ok"; turn: SessionTurn }
447
735
  | { status: "no_active_turn" }
448
736
  | { status: "budget_exhausted" };
449
737
 
450
- async function reserveActiveTurnCall(
738
+ async function reserveExactAttemptCall(
451
739
  deps: ApiRouteDeps,
452
- workspaceId: string,
453
- sessionId: string,
740
+ grant: AccessGrant,
741
+ authority: ToolspaceAttemptAuthority,
454
742
  ): Promise<ToolspaceReservation> {
455
- const session = await requireSession(deps.db, workspaceId, sessionId);
456
- if (!session.activeTurnId) {
457
- return { status: "no_active_turn" };
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
+ });
752
+ if (!reservation.reserved) {
753
+ return reservation.reason === "budget_exhausted"
754
+ ? { status: "budget_exhausted" }
755
+ : { status: "no_active_turn" };
458
756
  }
459
- const reservation = await reserveToolspaceCallForTurn(
460
- deps.db,
461
- workspaceId,
462
- sessionId,
463
- session.activeTurnId,
464
- deps.settings.toolspaceMaxCallsPerTurn,
465
- );
466
- return reservation.reserved
467
- ? { status: "ok", turnId: session.activeTurnId }
468
- : { status: "budget_exhausted" };
757
+ return { status: "ok", turn: reservation.turn };
469
758
  }
470
759
 
471
760
  function selectedMcpServerIds(tools: ToolRef[], sessionServerIds: string[]): Set<string> {
@@ -536,28 +825,49 @@ type McpRequestInfo = {
536
825
  toolName?: string;
537
826
  };
538
827
 
539
- function connectionBrokerFetch(
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(
540
833
  baseFetch: FetchLike,
541
834
  input: {
542
835
  deps: ApiRouteDeps;
543
836
  grant: AccessGrant;
544
837
  config: McpServerConfig;
545
838
  sessionId: string;
839
+ rootSessionId: string;
840
+ turn: SessionTurn;
546
841
  },
547
842
  ): FetchLike {
548
843
  const connectionRef = input.config.connectionRef;
549
844
  if (!connectionRef) {
550
845
  return baseFetch;
551
846
  }
552
- const resolveCredential = buildConnectionTokenResolver(input.deps.db, input.deps.settings);
847
+ const resolveCredential = input.deps.connectionCredentials?.mcpCredentials
848
+ ? buildHostConnectionTokenResolver(input.deps.connectionCredentials.mcpCredentials, {
849
+ accountId: input.grant.accountId,
850
+ workspaceId: input.grant.workspaceId,
851
+ sessionId: input.sessionId,
852
+ rootSessionId: input.rootSessionId,
853
+ turnId: input.turn.id,
854
+ attemptId: input.turn.activeAttemptId,
855
+ executionGeneration: input.turn.executionGeneration,
856
+ initiator: input.turn.initiator,
857
+ initiatorContext: input.turn.initiatorContext,
858
+ surface: "toolspace",
859
+ })
860
+ : buildConnectionTokenResolver(input.deps.db, input.deps.settings);
553
861
  return async (requestInput, init) => {
554
862
  const request = await mcpRequestInfo(requestInput, init);
863
+ const destinationUrl = mcpRequestDestinationUrl(requestInput);
555
864
  const first = await resolveCredential({
556
865
  workspaceId: input.grant.workspaceId,
557
866
  serverId: input.config.id,
558
867
  connectionRef,
868
+ destinationUrl,
559
869
  forceRefresh: false,
560
- ...(request.toolName ? { toolId: request.toolName } : {}),
870
+ ...(request.toolName ? { toolName: request.toolName } : {}),
561
871
  subjectId: input.grant.subjectId,
562
872
  });
563
873
  if (first.status === "auth_needed") {
@@ -568,23 +878,43 @@ function connectionBrokerFetch(
568
878
  withConnectionHeaders(requestInput, init, first.headers),
569
879
  );
570
880
  if (response.status === 401) {
881
+ await cancelMcpResponseBody(response);
571
882
  const refreshed = await resolveCredential({
572
883
  workspaceId: input.grant.workspaceId,
573
884
  serverId: input.config.id,
574
885
  connectionRef,
886
+ destinationUrl,
575
887
  forceRefresh: true,
576
- ...(request.toolName ? { toolId: request.toolName } : {}),
888
+ ...(request.toolName ? { toolName: request.toolName } : {}),
577
889
  subjectId: input.grant.subjectId,
578
890
  });
579
891
  if (refreshed.status === "auth_needed") {
580
892
  return await authNeededFetchResponse(input, request, refreshed);
581
893
  }
582
- return await baseFetch(
894
+ const retry = await baseFetch(
583
895
  fetchInputForAttempt(requestInput),
584
896
  withConnectionHeaders(requestInput, init, refreshed.headers),
585
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;
586
915
  }
587
916
  if (response.status === 403) {
917
+ await cancelMcpResponseBody(response);
588
918
  return await authNeededFetchResponse(
589
919
  input,
590
920
  request,
@@ -605,9 +935,13 @@ function authNeededFromStatus(
605
935
  status: "auth_needed",
606
936
  reason,
607
937
  providerDomain: connectionRef.providerDomain,
938
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
608
939
  connectionId: first.connectionId,
609
940
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
610
941
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
942
+ ...(connectionRef.selectedResources
943
+ ? { selectedResources: connectionRef.selectedResources }
944
+ : {}),
611
945
  };
612
946
  }
613
947
 
@@ -617,6 +951,7 @@ async function authNeededFetchResponse(
617
951
  grant: AccessGrant;
618
952
  config: McpServerConfig;
619
953
  sessionId: string;
954
+ turn: SessionTurn;
620
955
  },
621
956
  request: McpRequestInfo,
622
957
  auth: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>,
@@ -634,10 +969,12 @@ async function authNeededFetchResponse(
634
969
  serverId: input.config.id,
635
970
  toolName: request.toolName ?? null,
636
971
  providerDomain: auth.providerDomain,
972
+ ...(auth.provider ? { provider: auth.provider } : {}),
637
973
  reason: auth.reason,
638
974
  ...(auth.connectionId ? { connectionId: auth.connectionId } : {}),
639
975
  ...(auth.scopes ? { scopes: auth.scopes } : {}),
640
976
  ...(auth.resource ? { resource: auth.resource } : {}),
977
+ ...(auth.selectedResources ? { selectedResources: auth.selectedResources } : {}),
641
978
  ...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
642
979
  subjectId: input.grant.subjectId,
643
980
  },
@@ -663,8 +1000,19 @@ async function authNeededFetchResponse(
663
1000
  return new Response("Authentication required for MCP server connection", { status: 401 });
664
1001
  }
665
1002
 
666
- async function mcpRequestInfo(_input: string | URL, init?: RequestInit): Promise<McpRequestInfo> {
667
- const body = typeof init?.body === "string" ? init.body : "";
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
+ : "";
668
1016
  if (!body) {
669
1017
  return {};
670
1018
  }
@@ -694,19 +1042,21 @@ async function mcpRequestInfo(_input: string | URL, init?: RequestInit): Promise
694
1042
  }
695
1043
 
696
1044
  function withConnectionHeaders(
697
- _input: string | URL,
1045
+ input: string | URL | Request,
698
1046
  init: RequestInit | undefined,
699
1047
  authHeaders: Record<string, string>,
700
1048
  ): RequestInit {
701
- const headers = new Headers(init?.headers);
1049
+ const headers = new Headers(
1050
+ init?.headers ?? (input instanceof Request ? input.headers : undefined),
1051
+ );
702
1052
  for (const [name, value] of Object.entries(authHeaders)) {
703
1053
  headers.set(name, value);
704
1054
  }
705
1055
  return { ...init, headers };
706
1056
  }
707
1057
 
708
- function fetchInputForAttempt(input: string | URL): string | URL {
709
- return input;
1058
+ function fetchInputForAttempt(input: string | URL | Request): string | URL | Request {
1059
+ return input instanceof Request ? input.clone() : input;
710
1060
  }
711
1061
 
712
1062
  function isToolspaceAuthNeededError(error: unknown): boolean {