@opengeni/api-router 0.5.7 → 0.7.3

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.
@@ -3,7 +3,12 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
3
3
  import type { FetchLike, 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,6 +16,9 @@ import {
11
16
  } from "@opengeni/core";
12
17
  import {
13
18
  buildConnectionTokenResolver,
19
+ buildHostConnectionTokenResolver,
20
+ getSessionRootId,
21
+ getSessionTurn,
14
22
  listSessionMcpServerMetadata,
15
23
  listSessionMcpServersForRun,
16
24
  requireSession,
@@ -91,6 +99,14 @@ export async function prepareToolspaceMcpSurface(input: {
91
99
  }
92
100
  const sessionId = grant.metadata!.sessionId as string;
93
101
  const session = await requireSession(deps.db, grant.workspaceId, sessionId);
102
+ let rootSessionId = sessionId;
103
+ if (deps.connectionCredentials?.mcpCredentials) {
104
+ const resolvedRootSessionId = await getSessionRootId(deps.db, grant.workspaceId, sessionId);
105
+ if (!resolvedRootSessionId) {
106
+ throw new Error(`cannot resolve host MCP credentials for missing session ${sessionId}`);
107
+ }
108
+ rootSessionId = resolvedRootSessionId;
109
+ }
94
110
  const selectedIds = selectedMcpServerIds(
95
111
  session.tools,
96
112
  session.mcpServers.map((server) => server.id),
@@ -114,12 +130,13 @@ export async function prepareToolspaceMcpSurface(input: {
114
130
  deps,
115
131
  grant,
116
132
  sessionId,
133
+ rootSessionId,
117
134
  proxyableIds,
118
135
  activeTurnId: session.activeTurnId ?? null,
119
136
  getRegistry,
120
137
  });
121
138
  const tools = listing.map((entry) =>
122
- toolspaceToolFor({ deps, grant, sessionId, entry, getRegistry }),
139
+ toolspaceToolFor({ deps, grant, sessionId, rootSessionId, entry, getRegistry }),
123
140
  );
124
141
 
125
142
  return {
@@ -164,19 +181,32 @@ async function resolveToolListing(input: {
164
181
  deps: ApiRouteDeps;
165
182
  grant: AccessGrant;
166
183
  sessionId: string;
184
+ rootSessionId: string;
167
185
  proxyableIds: string[];
168
186
  activeTurnId: string | null;
169
187
  getRegistry: () => Promise<Map<string, McpServerConfig>>;
170
188
  }): Promise<ToolListingEntry[]> {
171
- const { deps, grant, sessionId, proxyableIds, activeTurnId, getRegistry } = input;
172
- const cacheKey = await toolListCacheKey(deps, grant.workspaceId, sessionId, proxyableIds);
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
+ }
197
+ // Host credentials can be initiator-specific. A prior turn's tool list must
198
+ // never be reused under a different frozen authority in the same session.
199
+ const cacheKey = await toolListCacheKey(
200
+ deps,
201
+ grant.workspaceId,
202
+ sessionId,
203
+ proxyableIds,
204
+ activeTurn,
205
+ );
173
206
  const cached = readToolListCache(cacheKey);
174
207
  if (cached) {
175
208
  return cached;
176
209
  }
177
- if (!activeTurnId) {
178
- return [];
179
- }
180
210
  const registry = await getRegistry();
181
211
  const entries: ToolListingEntry[] = [];
182
212
  for (const serverId of proxyableIds) {
@@ -184,9 +214,14 @@ async function resolveToolListing(input: {
184
214
  if (!config || !toolspaceCanProxyServer(config)) {
185
215
  continue;
186
216
  }
187
- const connection = await connectToolspaceServer({ deps, grant, config, sessionId }).catch(
188
- () => null,
189
- );
217
+ const connection = await connectToolspaceServer({
218
+ deps,
219
+ grant,
220
+ config,
221
+ sessionId,
222
+ rootSessionId,
223
+ turn: activeTurn,
224
+ }).catch(() => null);
190
225
  if (!connection) {
191
226
  continue;
192
227
  }
@@ -213,6 +248,7 @@ async function toolListCacheKey(
213
248
  workspaceId: string,
214
249
  sessionId: string,
215
250
  proxyableIds: string[],
251
+ turn: SessionTurn,
216
252
  ): Promise<string> {
217
253
  const metadata = await listSessionMcpServerMetadata(deps.db, workspaceId, sessionId);
218
254
  const versions = new Map(metadata.map((server) => [server.id, server.credentialVersion]));
@@ -221,7 +257,12 @@ async function toolListCacheKey(
221
257
  .sort()
222
258
  .map((id) => `${id}@${versions.get(id) ?? 0}`)
223
259
  .join(",");
224
- return `${workspaceId}:${sessionId}:${signature}`;
260
+ const authority = JSON.stringify({
261
+ turnId: turn.id,
262
+ executionGeneration: turn.executionGeneration,
263
+ initiator: turn.initiator,
264
+ });
265
+ return `${workspaceId}:${sessionId}:${signature}:${authority}`;
225
266
  }
226
267
 
227
268
  function readToolListCache(key: string): ToolListingEntry[] | null {
@@ -263,9 +304,18 @@ async function settingsWithSessionMcpServersForToolspace(
263
304
  if (metadata.length === 0) {
264
305
  return settings;
265
306
  }
266
- throw new Error("session MCP server credentials require OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY");
307
+ if (metadata.some((server) => server.headerNames.length > 0)) {
308
+ throw new Error(
309
+ "session MCP server credentials require OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY",
310
+ );
311
+ }
267
312
  }
268
- const servers = await listSessionMcpServersForRun(deps.db, workspaceId, sessionId, encryptionKey);
313
+ const servers = await listSessionMcpServersForRun(
314
+ deps.db,
315
+ workspaceId,
316
+ sessionId,
317
+ encryptionKey ?? null,
318
+ );
269
319
  if (servers.length === 0) {
270
320
  return settings;
271
321
  }
@@ -284,6 +334,7 @@ async function settingsWithSessionMcpServersForToolspace(
284
334
  ...(server.requireApproval !== undefined
285
335
  ? { requireApproval: server.requireApproval }
286
336
  : {}),
337
+ ...(server.connectionRef ? { connectionRef: server.connectionRef } : {}),
287
338
  headers: server.headers,
288
339
  })),
289
340
  ],
@@ -295,6 +346,8 @@ async function connectToolspaceServer(input: {
295
346
  grant: AccessGrant;
296
347
  config: McpServerConfig;
297
348
  sessionId: string;
349
+ rootSessionId: string;
350
+ turn: SessionTurn;
298
351
  }): Promise<ConnectedToolspaceServer> {
299
352
  const baseFetch: FetchLike = input.config.connectionRef
300
353
  ? connectionBrokerFetch(globalThis.fetch, input)
@@ -323,10 +376,11 @@ function toolspaceToolFor(input: {
323
376
  deps: ApiRouteDeps;
324
377
  grant: AccessGrant;
325
378
  sessionId: string;
379
+ rootSessionId: string;
326
380
  entry: ToolListingEntry;
327
381
  getRegistry: () => Promise<Map<string, McpServerConfig>>;
328
382
  }): ToolspaceRegisteredTool {
329
- const { deps, grant, sessionId, entry, getRegistry } = input;
383
+ const { deps, grant, sessionId, rootSessionId, entry, getRegistry } = input;
330
384
  const { serverId, tool } = entry;
331
385
  const name = prefixedMcpToolName(serverId, tool.name);
332
386
  const approvalRequired = mcpToolRequiresApproval(entry.requireApproval, tool.name);
@@ -350,7 +404,7 @@ function toolspaceToolFor(input: {
350
404
  `toolspace call budget exhausted (${deps.settings.toolspaceMaxCallsPerTurn}/turn)`,
351
405
  );
352
406
  }
353
- const turnId = reservation.turnId;
407
+ const turnId = reservation.turn.id;
354
408
  // Dial only the ONE server this tool belongs to, from the freshly-built
355
409
  // registry, and re-check policy against that live config (the listing may
356
410
  // have been served from a slightly stale cache entry).
@@ -362,9 +416,14 @@ function toolspaceToolFor(input: {
362
416
  if (mcpToolRequiresApproval(config.requireApproval, tool.name)) {
363
417
  return mcpError(APPROVAL_REQUIRED_MESSAGE);
364
418
  }
365
- const connection = await connectToolspaceServer({ deps, grant, config, sessionId }).catch(
366
- () => null,
367
- );
419
+ const connection = await connectToolspaceServer({
420
+ deps,
421
+ grant,
422
+ config,
423
+ sessionId,
424
+ rootSessionId,
425
+ turn: reservation.turn,
426
+ }).catch(() => null);
368
427
  if (!connection) {
369
428
  return mcpError(`upstream tool failed: ${name}`);
370
429
  }
@@ -443,7 +502,7 @@ async function callRemoteTool(
443
502
  }
444
503
 
445
504
  type ToolspaceReservation =
446
- | { status: "ok"; turnId: string }
505
+ | { status: "ok"; turn: SessionTurn }
447
506
  | { status: "no_active_turn" }
448
507
  | { status: "budget_exhausted" };
449
508
 
@@ -463,9 +522,13 @@ async function reserveActiveTurnCall(
463
522
  session.activeTurnId,
464
523
  deps.settings.toolspaceMaxCallsPerTurn,
465
524
  );
466
- return reservation.reserved
467
- ? { status: "ok", turnId: session.activeTurnId }
468
- : { status: "budget_exhausted" };
525
+ if (!reservation.reserved) {
526
+ return { status: "budget_exhausted" };
527
+ }
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" };
469
532
  }
470
533
 
471
534
  function selectedMcpServerIds(tools: ToolRef[], sessionServerIds: string[]): Set<string> {
@@ -543,13 +606,28 @@ function connectionBrokerFetch(
543
606
  grant: AccessGrant;
544
607
  config: McpServerConfig;
545
608
  sessionId: string;
609
+ rootSessionId: string;
610
+ turn: SessionTurn;
546
611
  },
547
612
  ): FetchLike {
548
613
  const connectionRef = input.config.connectionRef;
549
614
  if (!connectionRef) {
550
615
  return baseFetch;
551
616
  }
552
- const resolveCredential = buildConnectionTokenResolver(input.deps.db, input.deps.settings);
617
+ const resolveCredential = input.deps.connectionCredentials?.mcpCredentials
618
+ ? buildHostConnectionTokenResolver(input.deps.connectionCredentials.mcpCredentials, {
619
+ accountId: input.grant.accountId,
620
+ workspaceId: input.grant.workspaceId,
621
+ sessionId: input.sessionId,
622
+ rootSessionId: input.rootSessionId,
623
+ turnId: input.turn.id,
624
+ attemptId: input.turn.activeAttemptId,
625
+ executionGeneration: input.turn.executionGeneration,
626
+ initiator: input.turn.initiator,
627
+ initiatorContext: input.turn.initiatorContext,
628
+ surface: "toolspace",
629
+ })
630
+ : buildConnectionTokenResolver(input.deps.db, input.deps.settings);
553
631
  return async (requestInput, init) => {
554
632
  const request = await mcpRequestInfo(requestInput, init);
555
633
  const first = await resolveCredential({
@@ -557,7 +635,7 @@ function connectionBrokerFetch(
557
635
  serverId: input.config.id,
558
636
  connectionRef,
559
637
  forceRefresh: false,
560
- ...(request.toolName ? { toolId: request.toolName } : {}),
638
+ ...(request.toolName ? { toolName: request.toolName } : {}),
561
639
  subjectId: input.grant.subjectId,
562
640
  });
563
641
  if (first.status === "auth_needed") {
@@ -573,7 +651,7 @@ function connectionBrokerFetch(
573
651
  serverId: input.config.id,
574
652
  connectionRef,
575
653
  forceRefresh: true,
576
- ...(request.toolName ? { toolId: request.toolName } : {}),
654
+ ...(request.toolName ? { toolName: request.toolName } : {}),
577
655
  subjectId: input.grant.subjectId,
578
656
  });
579
657
  if (refreshed.status === "auth_needed") {
@@ -605,9 +683,13 @@ function authNeededFromStatus(
605
683
  status: "auth_needed",
606
684
  reason,
607
685
  providerDomain: connectionRef.providerDomain,
686
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
608
687
  connectionId: first.connectionId,
609
688
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
610
689
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
690
+ ...(connectionRef.selectedResources
691
+ ? { selectedResources: connectionRef.selectedResources }
692
+ : {}),
611
693
  };
612
694
  }
613
695
 
@@ -617,6 +699,7 @@ async function authNeededFetchResponse(
617
699
  grant: AccessGrant;
618
700
  config: McpServerConfig;
619
701
  sessionId: string;
702
+ turn: SessionTurn;
620
703
  },
621
704
  request: McpRequestInfo,
622
705
  auth: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>,
@@ -634,10 +717,12 @@ async function authNeededFetchResponse(
634
717
  serverId: input.config.id,
635
718
  toolName: request.toolName ?? null,
636
719
  providerDomain: auth.providerDomain,
720
+ ...(auth.provider ? { provider: auth.provider } : {}),
637
721
  reason: auth.reason,
638
722
  ...(auth.connectionId ? { connectionId: auth.connectionId } : {}),
639
723
  ...(auth.scopes ? { scopes: auth.scopes } : {}),
640
724
  ...(auth.resource ? { resource: auth.resource } : {}),
725
+ ...(auth.selectedResources ? { selectedResources: auth.selectedResources } : {}),
641
726
  ...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
642
727
  subjectId: input.grant.subjectId,
643
728
  },
@@ -42,10 +42,8 @@ import {
42
42
  updateCodexRotationSettings,
43
43
  upsertCodexSubscriptionCredential,
44
44
  withCodexCapacityMutation,
45
- CODEX_ROTATION_STRATEGIES,
46
45
  type CodexAccountStatus,
47
46
  type CodexCapacityWakeTarget,
48
- type CodexRotationStrategy,
49
47
  } from "@opengeni/db";
50
48
 
51
49
  // The picker surfaces codex models under their own "no credits" provider group so
@@ -369,7 +367,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
369
367
  activeAccountId,
370
368
  settings: {
371
369
  rotationEnabled: rotation?.rotationEnabled ?? false,
372
- rotationStrategy: rotation?.rotationStrategy ?? "most_remaining",
370
+ // sharded-rotation policy: rotation-enabled always behaves as sticky-sharded; report the
371
+ // effective truth, never the stored legacy residue.
372
+ rotationStrategy: "sharded",
373
373
  activeCredentialId: activeAccountId,
374
374
  },
375
375
  });
@@ -397,8 +397,11 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
397
397
  return c.json({ activated: true, accountId });
398
398
  });
399
399
 
400
- // P3: update rotation settings (enable auto-rotation + pick the strategy). admin access.
401
- // ensureCodexRotationSettings guarantees the row exists, then a one-cell patch.
400
+ // Update rotation settings. admin access. sharded-rotation policy: the strategy picker is GONE —
401
+ // rotation-enabled always behaves as sticky-sharded (worker-side
402
+ // effectiveRotationStrategy normalization). `rotationStrategy` in the body is
403
+ // ACCEPTED-BUT-IGNORED so no existing SDK/UI caller breaks (deprecation), and
404
+ // the stored column is only legacy residue kept for old-binary rollback.
402
405
  app.patch("/v1/workspaces/:workspaceId/codex/settings", async (c) => {
403
406
  const workspaceId = c.req.param("workspaceId");
404
407
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
@@ -406,19 +409,18 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
406
409
  rotationEnabled?: unknown;
407
410
  rotationStrategy?: unknown;
408
411
  };
409
- const patch: { rotationEnabled?: boolean; rotationStrategy?: CodexRotationStrategy } = {};
412
+ const patch: { rotationEnabled?: boolean } = {};
410
413
  if (typeof body.rotationEnabled === "boolean") {
411
414
  patch.rotationEnabled = body.rotationEnabled;
412
415
  }
413
- if (typeof body.rotationStrategy === "string") {
414
- if (!CODEX_ROTATION_STRATEGIES.includes(body.rotationStrategy as CodexRotationStrategy)) {
415
- throw new HTTPException(400, { message: "invalid rotation strategy" });
416
- }
417
- patch.rotationStrategy = body.rotationStrategy as CodexRotationStrategy;
418
- }
419
- if (patch.rotationEnabled === undefined && patch.rotationStrategy === undefined) {
416
+ if (patch.rotationEnabled === undefined && body.rotationStrategy === undefined) {
420
417
  throw new HTTPException(400, { message: "no settings to update" });
421
418
  }
419
+ if (patch.rotationEnabled === undefined) {
420
+ // Strategy-only writes are a deprecated no-op (no db touch): report the
421
+ // (only) truth. Callers that also flip rotationEnabled fall through.
422
+ return c.json({ rotationStrategy: "sharded", rotationStrategyDeprecated: true });
423
+ }
422
424
  await ensureCodexRotationSettings(db, grant.accountId, workspaceId);
423
425
  const mutation = await withCodexCapacityMutation(
424
426
  db,
@@ -435,7 +437,8 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
435
437
  await signalCodexCapacityTargets(deps, mutation.wakeTargets);
436
438
  return c.json({
437
439
  rotationEnabled: updated.rotationEnabled,
438
- rotationStrategy: updated.rotationStrategy,
440
+ // sharded-rotation policy: sharded is the only behavior; the stored column is residue.
441
+ rotationStrategy: "sharded",
439
442
  activeCredentialId: updated.activeCredentialId,
440
443
  });
441
444
  });
@@ -1,5 +1,5 @@
1
1
  // apps/api/src/routes/enrollments.ts — the bring-your-own-compute enrollment
2
- // device-flow routes (M5; dossier §10.2 + §18). Mirrors the other sandbox route
2
+ // device-flow routes (M5). Mirrors the other sandbox route
3
3
  // modules (registerSessionRoutes / registerApiKeyRoutes): a thin route over a
4
4
  // focused service (../sandbox/enrollment.ts), requireAccessGrant BEFORE any Zod
5
5
  // parse on the USER-authenticated routes, explicit HTTPException(400) on a parse
@@ -69,7 +69,7 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
69
69
  }
70
70
 
71
71
  // A tiny in-process IP token-bucket for the UNAUTHENTICATED agent routes (start/
72
- // poll). The relay tier owns the heavy stream rate-limiting (dossier §10.5); this
72
+ // poll). The relay tier owns the heavy stream rate-limiting; this
73
73
  // is the application-tier abuse cap on the device-flow endpoints. Per-IP buckets
74
74
  // are pruned lazily. Not a distributed limiter (one replica per bucket) — that is
75
75
  // acceptable for a bounded, access-key-gated, short-TTL flow.