@opengeni/db 0.2.1 → 0.3.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.
@@ -81,9 +81,66 @@ export function sanitizeEventPayload<T>(payload: T): T {
81
81
  }
82
82
  if (payload && typeof payload === "object") {
83
83
  const entries = Object.entries(payload as Record<string, unknown>).map(
84
- ([key, value]) => [sanitizeEventString(key), sanitizeEventPayload(value)] as const,
84
+ ([key, value]) => [sanitizeEventString(key), sanitizeSensitiveEventField(key, value)] as const,
85
85
  );
86
86
  return Object.fromEntries(entries) as unknown as T;
87
87
  }
88
88
  return payload;
89
89
  }
90
+
91
+ function sanitizeSensitiveEventField(key: string, value: unknown): unknown {
92
+ if (key === "mcpServers") {
93
+ return sanitizeSessionMcpServerList(value);
94
+ }
95
+ if (key === "mcpCredentialUpdates") {
96
+ return sanitizeMcpCredentialUpdateList(value);
97
+ }
98
+ return sanitizeEventPayload(value);
99
+ }
100
+
101
+ function sanitizeSessionMcpServerList(value: unknown): unknown {
102
+ if (!Array.isArray(value)) {
103
+ return sanitizeEventPayload(value);
104
+ }
105
+ return value.map((item) => {
106
+ if (!isPlainObject(item)) {
107
+ return sanitizeEventPayload(item);
108
+ }
109
+ const { headers, headersEncrypted, ...rest } = item;
110
+ const cleaned = sanitizeEventPayload(rest) as Record<string, unknown>;
111
+ const headerNames = safeHeaderNames(headers) ?? safeHeaderNames(headersEncrypted);
112
+ if (headerNames) {
113
+ cleaned.headerNames = headerNames;
114
+ }
115
+ return cleaned;
116
+ });
117
+ }
118
+
119
+ function sanitizeMcpCredentialUpdateList(value: unknown): unknown {
120
+ if (!Array.isArray(value)) {
121
+ return sanitizeEventPayload(value);
122
+ }
123
+ return value.map((item) => {
124
+ if (!isPlainObject(item)) {
125
+ return sanitizeEventPayload(item);
126
+ }
127
+ const { headers, headersEncrypted, ...rest } = item;
128
+ const cleaned = sanitizeEventPayload(rest) as Record<string, unknown>;
129
+ const headerNames = safeHeaderNames(headers) ?? safeHeaderNames(headersEncrypted);
130
+ if (headerNames) {
131
+ cleaned.headerNames = headerNames;
132
+ }
133
+ return cleaned;
134
+ });
135
+ }
136
+
137
+ function safeHeaderNames(value: unknown): string[] | null {
138
+ if (!isPlainObject(value)) {
139
+ return null;
140
+ }
141
+ return Object.keys(value).map(sanitizeEventString).sort();
142
+ }
143
+
144
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
145
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
146
+ }
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ import type {
34
34
  SessionGoal,
35
35
  SessionGoalCreatedBy,
36
36
  SessionGoalStatus,
37
+ SessionMcpServerMetadata,
37
38
  SessionStatus,
38
39
  SessionTurn,
39
40
  SessionTurnSource,
@@ -348,6 +349,7 @@ export const allWorkspacePermissions: Permission[] = [
348
349
  "api_keys:manage",
349
350
  "environments:manage",
350
351
  "environments:use",
352
+ "mcp_servers:attach",
351
353
  "goals:manage",
352
354
  "enrollments:read",
353
355
  "enrollments:manage",
@@ -1200,6 +1202,33 @@ export type EnabledMcpCapabilityServer = {
1200
1202
  headersEncrypted?: Record<string, string>;
1201
1203
  };
1202
1204
 
1205
+ export type CreateSessionMcpServerInput = {
1206
+ id: string;
1207
+ name?: string | null;
1208
+ url: string;
1209
+ allowedTools?: string[] | null;
1210
+ timeoutMs?: number | null;
1211
+ cacheToolsList?: boolean | null;
1212
+ headersEncrypted?: Record<string, string>;
1213
+ };
1214
+
1215
+ export type UpdateSessionMcpServerCredentialsInput = {
1216
+ id: string;
1217
+ headersEncrypted: Record<string, string>;
1218
+ };
1219
+
1220
+ export type UpdateSessionMcpServerCredentialsResult = {
1221
+ servers: SessionMcpServerMetadata[];
1222
+ missingIds: string[];
1223
+ };
1224
+
1225
+ export type SessionMcpServerForRun = SessionMcpServerMetadata & {
1226
+ allowedTools?: string[];
1227
+ timeoutMs?: number;
1228
+ cacheToolsList?: boolean;
1229
+ headers: Record<string, string>;
1230
+ };
1231
+
1203
1232
  export type EnqueueSessionTurnInput = {
1204
1233
  accountId: string;
1205
1234
  workspaceId: string;
@@ -3085,6 +3114,155 @@ function mapWorkspaceEnvironmentVariableMetadata(row: {
3085
3114
  };
3086
3115
  }
3087
3116
 
3117
+ function mapSessionMcpServerMetadata(row: typeof schema.sessionMcpServers.$inferSelect): SessionMcpServerMetadata {
3118
+ return {
3119
+ id: row.serverId,
3120
+ name: row.name ?? null,
3121
+ url: row.url,
3122
+ headerNames: Object.keys(row.headersEncrypted ?? {}).sort(),
3123
+ credentialVersion: Number(row.credentialVersion),
3124
+ };
3125
+ }
3126
+
3127
+ async function sessionMcpServerMetadataForSessions(
3128
+ db: Database,
3129
+ workspaceId: string,
3130
+ sessionIds: string[],
3131
+ ): Promise<Map<string, SessionMcpServerMetadata[]>> {
3132
+ const grouped = new Map<string, SessionMcpServerMetadata[]>();
3133
+ if (sessionIds.length === 0) {
3134
+ return grouped;
3135
+ }
3136
+ const rows = await db.select().from(schema.sessionMcpServers)
3137
+ .where(and(
3138
+ eq(schema.sessionMcpServers.workspaceId, workspaceId),
3139
+ inArray(schema.sessionMcpServers.sessionId, sessionIds),
3140
+ ))
3141
+ .orderBy(asc(schema.sessionMcpServers.createdAt), asc(schema.sessionMcpServers.serverId));
3142
+ for (const row of rows) {
3143
+ const list = grouped.get(row.sessionId) ?? [];
3144
+ list.push(mapSessionMcpServerMetadata(row));
3145
+ grouped.set(row.sessionId, list);
3146
+ }
3147
+ return grouped;
3148
+ }
3149
+
3150
+ async function insertSessionMcpServers(db: Database, input: {
3151
+ accountId: string;
3152
+ workspaceId: string;
3153
+ sessionId: string;
3154
+ servers: CreateSessionMcpServerInput[];
3155
+ }): Promise<SessionMcpServerMetadata[]> {
3156
+ if (input.servers.length === 0) {
3157
+ return [];
3158
+ }
3159
+ const rows = await db.insert(schema.sessionMcpServers).values(input.servers.map((server) => ({
3160
+ accountId: input.accountId,
3161
+ workspaceId: input.workspaceId,
3162
+ sessionId: input.sessionId,
3163
+ serverId: server.id,
3164
+ name: server.name ?? null,
3165
+ url: server.url,
3166
+ allowedTools: server.allowedTools ?? null,
3167
+ timeoutMs: server.timeoutMs ?? null,
3168
+ cacheToolsList: server.cacheToolsList ?? false,
3169
+ headersEncrypted: server.headersEncrypted ?? {},
3170
+ }))).returning();
3171
+ return rows.map(mapSessionMcpServerMetadata);
3172
+ }
3173
+
3174
+ export async function createSessionMcpServers(db: Database, input: {
3175
+ accountId: string;
3176
+ workspaceId: string;
3177
+ sessionId: string;
3178
+ servers: CreateSessionMcpServerInput[];
3179
+ }): Promise<SessionMcpServerMetadata[]> {
3180
+ return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) =>
3181
+ await insertSessionMcpServers(scopedDb, input)
3182
+ );
3183
+ }
3184
+
3185
+ export async function listSessionMcpServerMetadata(db: Database, workspaceId: string, sessionId: string): Promise<SessionMcpServerMetadata[]> {
3186
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3187
+ const grouped = await sessionMcpServerMetadataForSessions(scopedDb, workspaceId, [sessionId]);
3188
+ return grouped.get(sessionId) ?? [];
3189
+ });
3190
+ }
3191
+
3192
+ export async function updateSessionMcpServerCredentials(db: Database, input: {
3193
+ workspaceId: string;
3194
+ sessionId: string;
3195
+ updates: UpdateSessionMcpServerCredentialsInput[];
3196
+ }): Promise<UpdateSessionMcpServerCredentialsResult> {
3197
+ return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => await scopedDb.transaction(async (tx) =>
3198
+ await updateSessionMcpServerCredentialsInTransaction(tx, input)
3199
+ ));
3200
+ }
3201
+
3202
+ async function updateSessionMcpServerCredentialsInTransaction(
3203
+ tx: Pick<Database, "update">,
3204
+ input: {
3205
+ workspaceId: string;
3206
+ sessionId: string;
3207
+ updates: UpdateSessionMcpServerCredentialsInput[];
3208
+ },
3209
+ ): Promise<UpdateSessionMcpServerCredentialsResult> {
3210
+ const servers: SessionMcpServerMetadata[] = [];
3211
+ const missingIds: string[] = [];
3212
+ for (const update of input.updates) {
3213
+ const [row] = await tx.update(schema.sessionMcpServers)
3214
+ .set({
3215
+ headersEncrypted: update.headersEncrypted,
3216
+ credentialVersion: sql`${schema.sessionMcpServers.credentialVersion} + 1`,
3217
+ updatedAt: new Date(),
3218
+ })
3219
+ .where(and(
3220
+ eq(schema.sessionMcpServers.workspaceId, input.workspaceId),
3221
+ eq(schema.sessionMcpServers.sessionId, input.sessionId),
3222
+ eq(schema.sessionMcpServers.serverId, update.id),
3223
+ ))
3224
+ .returning();
3225
+ if (!row) {
3226
+ missingIds.push(update.id);
3227
+ } else {
3228
+ servers.push(mapSessionMcpServerMetadata(row));
3229
+ }
3230
+ }
3231
+ return { servers, missingIds };
3232
+ }
3233
+
3234
+ export async function listSessionMcpServersForRun(
3235
+ db: Database,
3236
+ workspaceId: string,
3237
+ sessionId: string,
3238
+ encryptionKey: Uint8Array,
3239
+ ): Promise<SessionMcpServerForRun[]> {
3240
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3241
+ const rows = await scopedDb.select().from(schema.sessionMcpServers)
3242
+ .where(and(
3243
+ eq(schema.sessionMcpServers.workspaceId, workspaceId),
3244
+ eq(schema.sessionMcpServers.sessionId, sessionId),
3245
+ ))
3246
+ .orderBy(asc(schema.sessionMcpServers.createdAt), asc(schema.sessionMcpServers.serverId));
3247
+ return rows.map((row) => {
3248
+ let headers: Record<string, string>;
3249
+ try {
3250
+ headers = Object.fromEntries(Object.entries(row.headersEncrypted ?? {})
3251
+ .map(([name, stored]) => [name, decryptEnvironmentValue(encryptionKey, stored)]));
3252
+ } catch {
3253
+ throw new Error("session MCP server credential decryption failed");
3254
+ }
3255
+ return {
3256
+ ...mapSessionMcpServerMetadata(row),
3257
+ ...(row.allowedTools ? { allowedTools: row.allowedTools } : {}),
3258
+ ...(row.timeoutMs ? { timeoutMs: row.timeoutMs } : {}),
3259
+ ...(row.cacheToolsList ? { cacheToolsList: row.cacheToolsList } : {}),
3260
+ headers,
3261
+ };
3262
+ });
3263
+ });
3264
+ }
3265
+
3088
3266
  export async function createSession(db: Database, input: {
3089
3267
  accountId: string;
3090
3268
  workspaceId: string;
@@ -3103,6 +3281,7 @@ export async function createSession(db: Database, input: {
3103
3281
  // shared spawn passes the parent's sandboxGroupId so both run in ONE box.
3104
3282
  sandboxGroupId?: string | null;
3105
3283
  sandboxOs?: SandboxOs;
3284
+ mcpServers?: CreateSessionMcpServerInput[];
3106
3285
  }): Promise<Session> {
3107
3286
  // Generate the id up front so the same uuid can seed sandbox_group_id for a
3108
3287
  // singleton group (sandbox_group_id cannot SQL-default to id).
@@ -3129,7 +3308,13 @@ export async function createSession(db: Database, input: {
3129
3308
  if (!row) {
3130
3309
  throw new Error("Failed to create session");
3131
3310
  }
3132
- return mapSession(row);
3311
+ const mcpServers = await insertSessionMcpServers(scopedDb, {
3312
+ accountId: input.accountId,
3313
+ workspaceId: input.workspaceId,
3314
+ sessionId: row.id,
3315
+ servers: input.mcpServers ?? [],
3316
+ });
3317
+ return mapSession(row, mcpServers);
3133
3318
  });
3134
3319
  }
3135
3320
 
@@ -3160,6 +3345,7 @@ export async function createSessionWithIdempotencyKey(db: Database, input: {
3160
3345
  // (group === the new row's own id); a shared spawn passes the parent's group.
3161
3346
  sandboxGroupId?: string | null;
3162
3347
  sandboxOs?: SandboxOs;
3348
+ mcpServers?: CreateSessionMcpServerInput[];
3163
3349
  }): Promise<{ session: Session; created: boolean }> {
3164
3350
  // Generate the id up front so the same uuid can seed sandbox_group_id for a
3165
3351
  // singleton group (sandbox_group_id cannot SQL-default to id).
@@ -3187,7 +3373,13 @@ export async function createSessionWithIdempotencyKey(db: Database, input: {
3187
3373
  where: sql`${schema.sessions.createIdempotencyKey} is not null`,
3188
3374
  }).returning();
3189
3375
  if (inserted) {
3190
- return { session: mapSession(inserted), created: true };
3376
+ const mcpServers = await insertSessionMcpServers(scopedDb, {
3377
+ accountId: input.accountId,
3378
+ workspaceId: input.workspaceId,
3379
+ sessionId: inserted.id,
3380
+ servers: input.mcpServers ?? [],
3381
+ });
3382
+ return { session: mapSession(inserted, mcpServers), created: true };
3191
3383
  }
3192
3384
  const [existing] = await scopedDb.select().from(schema.sessions).where(and(
3193
3385
  eq(schema.sessions.workspaceId, input.workspaceId),
@@ -3199,7 +3391,8 @@ export async function createSessionWithIdempotencyKey(db: Database, input: {
3199
3391
  // than silently returning a phantom.
3200
3392
  throw new Error("Failed to create session under idempotency key");
3201
3393
  }
3202
- return { session: mapSession(existing), created: false };
3394
+ const grouped = await sessionMcpServerMetadataForSessions(scopedDb, input.workspaceId, [existing.id]);
3395
+ return { session: mapSession(existing, grouped.get(existing.id) ?? []), created: false };
3203
3396
  });
3204
3397
  }
3205
3398
 
@@ -3209,14 +3402,18 @@ export async function getSessionByCreateIdempotencyKey(db: Database, workspaceId
3209
3402
  eq(schema.sessions.workspaceId, workspaceId),
3210
3403
  eq(schema.sessions.createIdempotencyKey, createIdempotencyKey),
3211
3404
  )).limit(1);
3212
- return row ? mapSession(row) : null;
3405
+ if (!row) return null;
3406
+ const grouped = await sessionMcpServerMetadataForSessions(scopedDb, workspaceId, [row.id]);
3407
+ return mapSession(row, grouped.get(row.id) ?? []);
3213
3408
  });
3214
3409
  }
3215
3410
 
3216
3411
  export async function getSession(db: Database, workspaceId: string, sessionId: string): Promise<Session | null> {
3217
3412
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3218
3413
  const [row] = await scopedDb.select().from(schema.sessions).where(and(eq(schema.sessions.workspaceId, workspaceId), eq(schema.sessions.id, sessionId))).limit(1);
3219
- return row ? mapSession(row) : null;
3414
+ if (!row) return null;
3415
+ const grouped = await sessionMcpServerMetadataForSessions(scopedDb, workspaceId, [row.id]);
3416
+ return mapSession(row, grouped.get(row.id) ?? []);
3220
3417
  });
3221
3418
  }
3222
3419
 
@@ -3262,7 +3459,8 @@ export async function listSessions(db: Database, workspaceId: string, limit = 50
3262
3459
  .where(eq(schema.sessions.workspaceId, workspaceId))
3263
3460
  .orderBy(desc(schema.sessions.createdAt), desc(schema.sessions.id))
3264
3461
  .limit(limit);
3265
- return rows.map(mapSession);
3462
+ const grouped = await sessionMcpServerMetadataForSessions(scopedDb, workspaceId, rows.map((row) => row.id));
3463
+ return rows.map((row) => mapSession(row, grouped.get(row.id) ?? []));
3266
3464
  });
3267
3465
  }
3268
3466
 
@@ -3294,16 +3492,63 @@ export async function requireSession(db: Database, workspaceId: string, sessionI
3294
3492
  return session;
3295
3493
  }
3296
3494
 
3297
- export async function listSessionEvents(db: Database, workspaceId: string, sessionId: string, after = 0, limit = 500): Promise<SessionEvent[]> {
3495
+ export type ListSessionEventsOptions = {
3496
+ after?: number;
3497
+ before?: number;
3498
+ limit?: number;
3499
+ };
3500
+
3501
+ const POSTGRES_INT_MAX = 2_147_483_647;
3502
+
3503
+ export async function listSessionEvents(db: Database, workspaceId: string, sessionId: string): Promise<SessionEvent[]>;
3504
+ export async function listSessionEvents(db: Database, workspaceId: string, sessionId: string, after: number, limit?: number): Promise<SessionEvent[]>;
3505
+ export async function listSessionEvents(db: Database, workspaceId: string, sessionId: string, options: ListSessionEventsOptions): Promise<SessionEvent[]>;
3506
+ export async function listSessionEvents(
3507
+ db: Database,
3508
+ workspaceId: string,
3509
+ sessionId: string,
3510
+ afterOrOptions: number | ListSessionEventsOptions = 0,
3511
+ legacyLimit = 500,
3512
+ ): Promise<SessionEvent[]> {
3513
+ const options = typeof afterOrOptions === "number"
3514
+ ? { after: afterOrOptions, limit: legacyLimit }
3515
+ : afterOrOptions;
3516
+ const after = normalizeEventSequence(options.after, 0);
3517
+ const limit = normalizeEventLimit(options.limit, 500);
3518
+ const hasBefore = options.before !== undefined && Number.isFinite(options.before);
3519
+ const before = hasBefore ? Math.floor(options.before as number) : undefined;
3520
+
3298
3521
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3522
+ const filters: SQL[] = [
3523
+ eq(schema.sessionEvents.workspaceId, workspaceId),
3524
+ eq(schema.sessionEvents.sessionId, sessionId),
3525
+ gt(schema.sessionEvents.sequence, after),
3526
+ ];
3527
+ if (before !== undefined && before <= POSTGRES_INT_MAX) {
3528
+ filters.push(lt(schema.sessionEvents.sequence, before));
3529
+ }
3299
3530
  const rows = await scopedDb.select().from(schema.sessionEvents)
3300
- .where(and(eq(schema.sessionEvents.workspaceId, workspaceId), eq(schema.sessionEvents.sessionId, sessionId), gt(schema.sessionEvents.sequence, after)))
3301
- .orderBy(asc(schema.sessionEvents.sequence))
3531
+ .where(and(...filters))
3532
+ .orderBy(hasBefore ? desc(schema.sessionEvents.sequence) : asc(schema.sessionEvents.sequence))
3302
3533
  .limit(limit);
3303
- return rows.map(mapEvent);
3534
+ return (hasBefore ? rows.reverse() : rows).map(mapEvent);
3304
3535
  });
3305
3536
  }
3306
3537
 
3538
+ function normalizeEventSequence(value: number | undefined, fallback: number): number {
3539
+ if (value === undefined || !Number.isFinite(value)) {
3540
+ return fallback;
3541
+ }
3542
+ return Math.floor(value);
3543
+ }
3544
+
3545
+ function normalizeEventLimit(value: number | undefined, fallback: number): number {
3546
+ if (value === undefined || !Number.isFinite(value)) {
3547
+ return fallback;
3548
+ }
3549
+ return Math.max(0, Math.floor(value));
3550
+ }
3551
+
3307
3552
  export async function getSessionEvent(db: Database, workspaceId: string, eventId: string): Promise<SessionEvent | null> {
3308
3553
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3309
3554
  const [row] = await scopedDb.select().from(schema.sessionEvents).where(and(eq(schema.sessionEvents.workspaceId, workspaceId), eq(schema.sessionEvents.id, eventId))).limit(1);
@@ -3384,8 +3629,8 @@ export async function getSessionHistoryItems(db: Database, workspaceId: string,
3384
3629
 
3385
3630
  /**
3386
3631
  * The LIVE conversation-truth read path: only active rows, position-ordered.
3387
- * After a client-side context compaction this returns [active summary,
3388
- * ...active recent tail]; with no compaction yet it equals
3632
+ * After a client-side context compaction this returns [retained user messages,
3633
+ * active summary]; with no compaction yet it equals
3389
3634
  * getSessionHistoryItems. The model-facing read path uses this so superseded
3390
3635
  * (summarized-away) prefix rows are excluded while the full transcript stays in
3391
3636
  * the table as an audit trail.
@@ -3555,8 +3800,15 @@ export async function applyContextCompaction(db: Database, input: {
3555
3800
  turnId?: string | null;
3556
3801
  /** Active prefix rows with position < boundaryPosition get superseded. */
3557
3802
  boundaryPosition: number;
3558
- /** Fractional position for the new summary row (must be < boundaryPosition). */
3803
+ /** Position for the new summary row. Old boundary mode uses a fractional half-step before the kept tail. */
3559
3804
  summaryPosition: number;
3805
+ /**
3806
+ * Optional replacement rows inserted after superseding the old active set.
3807
+ * Used by Codex-parity client compaction to rebuild active history as retained
3808
+ * user messages plus one summary. These rows are synthetic replay rows, so
3809
+ * they intentionally do not inherit the current compaction turn id.
3810
+ */
3811
+ replacementItems?: Array<{ position: number; item: Record<string, unknown> }>;
3560
3812
  summaryItem: Record<string, unknown>;
3561
3813
  }): Promise<void> {
3562
3814
  await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
@@ -3569,6 +3821,20 @@ export async function applyContextCompaction(db: Database, input: {
3569
3821
  eq(schema.sessionHistoryItems.active, true),
3570
3822
  lt(schema.sessionHistoryItems.position, input.boundaryPosition),
3571
3823
  ));
3824
+ if (input.replacementItems && input.replacementItems.length > 0) {
3825
+ await tx.insert(schema.sessionHistoryItems).values(input.replacementItems.map((entry) => ({
3826
+ accountId: input.accountId,
3827
+ workspaceId: input.workspaceId,
3828
+ sessionId: input.sessionId,
3829
+ turnId: null,
3830
+ position: entry.position,
3831
+ item: sanitizeEventPayload(entry.item),
3832
+ active: true,
3833
+ }))).onConflictDoUpdate({
3834
+ target: [schema.sessionHistoryItems.workspaceId, schema.sessionHistoryItems.sessionId, schema.sessionHistoryItems.position],
3835
+ set: { active: true },
3836
+ });
3837
+ }
3572
3838
  // Insert the summary at its FRACTIONAL position. The supersede step above
3573
3839
  // also sets active=false for any rows with position < boundaryPosition —
3574
3840
  // which on a RETRY includes the summary itself (it sits below the
@@ -7275,7 +7541,11 @@ export async function appendSessionEventsAndUpdateSession(db: Database, workspac
7275
7541
  }));
7276
7542
  }
7277
7543
 
7278
- export async function appendSessionEventsWithLockedSessionUpdate(db: Database, workspaceId: string, sessionId: string, build: (session: Session) => {
7544
+ type LockedSessionUpdateContext = {
7545
+ updateSessionMcpServerCredentials: (updates: UpdateSessionMcpServerCredentialsInput[]) => Promise<UpdateSessionMcpServerCredentialsResult>;
7546
+ };
7547
+
7548
+ type LockedSessionUpdateResult = {
7279
7549
  events: AppendEventInput[];
7280
7550
  update?: {
7281
7551
  resources?: ResourceRef[];
@@ -7285,13 +7555,22 @@ export async function appendSessionEventsWithLockedSessionUpdate(db: Database, w
7285
7555
  status?: SessionStatus;
7286
7556
  activeTurnId?: string | null;
7287
7557
  };
7288
- }): Promise<SessionEvent[]> {
7558
+ };
7559
+
7560
+ export async function appendSessionEventsWithLockedSessionUpdate(
7561
+ db: Database,
7562
+ workspaceId: string,
7563
+ sessionId: string,
7564
+ build: (session: Session, context: LockedSessionUpdateContext) => LockedSessionUpdateResult | Promise<LockedSessionUpdateResult>,
7565
+ ): Promise<SessionEvent[]> {
7289
7566
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => await scopedDb.transaction(async (tx) => {
7290
7567
  const [sessionRow] = await tx.select().from(schema.sessions).where(and(eq(schema.sessions.workspaceId, workspaceId), eq(schema.sessions.id, sessionId))).for("update").limit(1);
7291
7568
  if (!sessionRow) {
7292
7569
  throw new Error(`Session not found: ${sessionId}`);
7293
7570
  }
7294
- const built = build(mapSession(sessionRow));
7571
+ const built = await build(mapSession(sessionRow), {
7572
+ updateSessionMcpServerCredentials: async (updates) => await updateSessionMcpServerCredentialsInTransaction(tx, { workspaceId, sessionId, updates }),
7573
+ });
7295
7574
  if (built.events.length === 0) {
7296
7575
  return [];
7297
7576
  }
@@ -7330,7 +7609,7 @@ export function sessionSubject(workspaceId: string, sessionId: string): string {
7330
7609
  return `workspaces.${workspaceId}.sessions.${sessionId}.events`;
7331
7610
  }
7332
7611
 
7333
- function mapSession(row: typeof schema.sessions.$inferSelect): Session {
7612
+ function mapSession(row: typeof schema.sessions.$inferSelect, mcpServers: SessionMcpServerMetadata[] = []): Session {
7334
7613
  return {
7335
7614
  id: row.id,
7336
7615
  accountId: row.accountId,
@@ -7353,6 +7632,7 @@ function mapSession(row: typeof schema.sessions.$inferSelect): Session {
7353
7632
  activeEpoch: Number(row.activeEpoch),
7354
7633
  environmentId: row.environmentId,
7355
7634
  firstPartyMcpPermissions: (row.firstPartyMcpPermissions as Permission[] | null) ?? null,
7635
+ mcpServers,
7356
7636
  parentSessionId: row.parentSessionId ?? null,
7357
7637
  createIdempotencyKey: row.createIdempotencyKey ?? null,
7358
7638
  temporalWorkflowId: row.temporalWorkflowId,
package/src/schema.ts CHANGED
@@ -283,6 +283,28 @@ export const sessions = pgTable("sessions", {
283
283
  createIdempotency: uniqueIndex("sessions_workspace_create_idempotency_idx").on(table.workspaceId, table.createIdempotencyKey).where(sql`${table.createIdempotencyKey} is not null`),
284
284
  }));
285
285
 
286
+ export const sessionMcpServers = pgTable("session_mcp_servers", {
287
+ id: uuid("id").primaryKey().defaultRandom(),
288
+ accountId: uuid("account_id").notNull().references(() => managedAccounts.id, { onDelete: "cascade" }),
289
+ workspaceId: uuid("workspace_id").notNull().references(() => workspaces.id, { onDelete: "cascade" }),
290
+ sessionId: uuid("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
291
+ serverId: text("server_id").notNull(),
292
+ name: text("name"),
293
+ url: text("url").notNull(),
294
+ allowedTools: jsonb("allowed_tools").$type<string[]>(),
295
+ timeoutMs: integer("timeout_ms"),
296
+ cacheToolsList: boolean("cache_tools_list").notNull().default(false),
297
+ // Map of header name -> AES-GCM ciphertext. Values are decrypted only by the
298
+ // worker's run-preparation path and never returned by API helpers.
299
+ headersEncrypted: jsonb("headers_encrypted").$type<Record<string, string>>().notNull().default({}),
300
+ credentialVersion: integer("credential_version").notNull().default(1),
301
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
302
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
303
+ }, (table) => ({
304
+ sessionServer: uniqueIndex("session_mcp_servers_session_server_idx").on(table.workspaceId, table.sessionId, table.serverId),
305
+ session: index("session_mcp_servers_session_idx").on(table.workspaceId, table.sessionId),
306
+ }));
307
+
286
308
  export const files = pgTable("files", {
287
309
  id: uuid("id").primaryKey().defaultRandom(),
288
310
  accountId: uuid("account_id").notNull().references(() => managedAccounts.id, { onDelete: "cascade" }),