@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.
@@ -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 { 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
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
- reserveToolspaceCallForTurn,
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 toolListCache = new Map<string, { expiresAt: number; entries: ToolListingEntry[] }>();
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
- typeof grant.metadata?.sessionId === "string"
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 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
+ };
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
- let registryPromise: Promise<Map<string, McpServerConfig>> | null = null;
126
- const getRegistry = () =>
127
- (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
+ };
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
- activeTurnId: session.activeTurnId ?? null,
136
- getRegistry,
269
+ activeTurn,
270
+ getRegistry: () => getRegistry(attemptAuthority.attemptId),
137
271
  });
138
272
  const tools = listing.map((entry) =>
139
- toolspaceToolFor({ deps, grant, sessionId, rootSessionId, entry, getRegistry }),
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
- activeTurnId: string | null;
329
+ activeTurn: SessionTurn;
187
330
  getRegistry: () => Promise<Map<string, McpServerConfig>>;
188
331
  }): Promise<ToolListingEntry[]> {
189
- const { deps, grant, sessionId, rootSessionId, proxyableIds, activeTurnId, getRegistry } = input;
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
- 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) => {
213
358
  const config = registry.get(serverId);
214
359
  if (!config || !toolspaceCanProxyServer(config)) {
215
- continue;
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
- continue;
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
- for (const tool of listed.tools as McpTool[]) {
233
- if (!tool?.name || !allowedByConfig(config, tool.name)) {
234
- continue;
235
- }
236
- 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;
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
- const hit = toolListCache.get(key);
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
- if (toolListCache.size >= TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES) {
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(globalThis.fetch, input)
354
- : globalThis.fetch;
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
- 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
+ }
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
- sessionId: string;
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, sessionId, rootSessionId, entry, getRegistry } = input;
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
- if (approvalRequired) {
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, and re-check policy against that live config (the listing may
410
- // have been served from a slightly stale cache entry).
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
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
433
- {
434
- type: "agent.toolCall.created",
435
- turnId,
436
- producerId: grant.subjectId,
437
- payload: {
438
- id: callId,
439
- name,
440
- arguments: args,
441
- origin: "toolspace",
442
- subjectId: grant.subjectId,
443
- raw: {
444
- type: "toolspace_call",
445
- serverId,
446
- 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
+ },
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
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
453
- {
454
- type: "agent.toolCall.output",
455
- turnId,
456
- producerId: grant.subjectId,
457
- payload: {
458
- id: callId,
459
- output,
460
- origin: "toolspace",
461
- 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
+ },
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
- return (await server.client.callTool(
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 reserveActiveTurnCall(
738
+ async function reserveExactAttemptCall(
510
739
  deps: ApiRouteDeps,
511
- workspaceId: string,
512
- sessionId: string,
740
+ grant: AccessGrant,
741
+ authority: ToolspaceAttemptAuthority,
513
742
  ): Promise<ToolspaceReservation> {
514
- const session = await requireSession(deps.db, workspaceId, sessionId);
515
- if (!session.activeTurnId) {
516
- return { status: "no_active_turn" };
517
- }
518
- const reservation = await reserveToolspaceCallForTurn(
519
- deps.db,
520
- workspaceId,
521
- sessionId,
522
- session.activeTurnId,
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 { status: "budget_exhausted" };
753
+ return reservation.reason === "budget_exhausted"
754
+ ? { status: "budget_exhausted" }
755
+ : { status: "no_active_turn" };
527
756
  }
528
- const turn = await getSessionTurn(deps.db, workspaceId, session.activeTurnId);
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 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(
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
- return await baseFetch(
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(_input: string | URL, init?: RequestInit): Promise<McpRequestInfo> {
752
- 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
+ : "";
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
- _input: string | URL,
1045
+ input: string | URL | Request,
783
1046
  init: RequestInit | undefined,
784
1047
  authHeaders: Record<string, string>,
785
1048
  ): RequestInit {
786
- const headers = new Headers(init?.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 {