@axiom-lattice/pg-stores 5.1.2 → 5.2.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.
@@ -0,0 +1,62 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+ import {
4
+ PLATFORM_TENANT_ID,
5
+ PLATFORM_TENANT_PROTECTED_ERROR,
6
+ } from "@axiom-lattice/protocols";
7
+ import { createUserTenantLinkTable } from "../migrations/user_tenant_link_migrations";
8
+ import { PostgreSQLUserTenantLinkStore } from "../stores/PostgreSQLUserTenantLinkStore";
9
+
10
+ const databaseUrl = process.env.TEST_DATABASE_URL;
11
+ const describeIfDatabase = databaseUrl ? describe : describe.skip;
12
+
13
+ describeIfDatabase("PostgreSQLUserTenantLinkStore platform tenant guard", () => {
14
+ let pool: Pool;
15
+ let store: PostgreSQLUserTenantLinkStore;
16
+ const prefix = `utl-${randomUUID()}`;
17
+
18
+ beforeAll(async () => {
19
+ pool = new Pool({ connectionString: databaseUrl });
20
+ const client = await pool.connect();
21
+ try {
22
+ await createUserTenantLinkTable.up(client);
23
+ } finally {
24
+ client.release();
25
+ }
26
+ store = new PostgreSQLUserTenantLinkStore({ pool });
27
+ });
28
+
29
+ afterEach(async () => {
30
+ await pool.query(
31
+ `DELETE FROM lattice_user_tenant_links WHERE user_id LIKE $1`,
32
+ [`${prefix}%`],
33
+ );
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await pool.query(
38
+ `DELETE FROM lattice_user_tenant_links WHERE user_id LIKE $1`,
39
+ [`${prefix}%`],
40
+ );
41
+ await pool.end();
42
+ });
43
+
44
+ it("still creates links for ordinary tenants", async () => {
45
+ await expect(
46
+ store.createLink({ userId: `${prefix}-u1`, tenantId: "acme" }),
47
+ ).resolves.toMatchObject({ tenantId: "acme", role: "member" });
48
+ });
49
+
50
+ it("rejects creating a link to the hidden platform tenant", async () => {
51
+ await expect(
52
+ store.createLink({
53
+ userId: `${prefix}-u2`,
54
+ tenantId: PLATFORM_TENANT_ID,
55
+ }),
56
+ ).rejects.toMatchObject({ code: PLATFORM_TENANT_PROTECTED_ERROR });
57
+
58
+ await expect(
59
+ store.getLink(`${prefix}-u2`, PLATFORM_TENANT_ID),
60
+ ).resolves.toBeNull();
61
+ });
62
+ });
@@ -63,7 +63,7 @@ import { createChannelBindingsTable } from "./migrations/channel_bindings_migrat
63
63
  import { createDatabaseConfigsTable } from "./migrations/database_config_migrations";
64
64
  import { createConnectionConfigsTable, addConnectionSecretFields, reencryptLegacyConnectionSecrets } from "./migrations/connection_config_migrations";
65
65
  import { createMetricsConfigsTable } from "./migrations/metrics_config_migrations";
66
- import { createMcpServerConfigsTable } from "./migrations/mcp_server_config_migrations";
66
+ import { createMcpServerConfigsTable, createPersonalMcpServerConfigsTable } from "./migrations/mcp_server_config_migrations";
67
67
  import { registerThreadMessageQueueMigrations } from "./migrations/queue_migrations";
68
68
  import { createWorkflowTrackingTables, addStepThreadId, addWorkflowRunsTenantStatusUpdatedIndex } from "./migrations/workflow_tracking_migrations";
69
69
  import { evalMigrations } from "./migrations/eval_migrations";
@@ -83,6 +83,7 @@ import { createInviteLinksTable } from "./migrations/invite_link_migration";
83
83
  import { addOidcIdentityClaimInviteFields } from "./migrations/oidc_identity_claim_invite_migration";
84
84
  import { createPlatformSettingsTable } from "./migrations/platform_settings_migration";
85
85
  import { addOidcProviderAllowedHosts } from "./migrations/oidc_provider_allowed_hosts_migration";
86
+ import { createPlatformAuditTable } from "./migrations/platform_audit_migration";
86
87
  import { taskMigrations } from "./migrations/task_migration";
87
88
  import { createMenuItemsTable } from "./migrations/menu_items_migration";
88
89
  import { addFileContentType } from "./migrations/menu_items_add_file_type";
@@ -141,6 +142,7 @@ import { PostgreSQLOutboxStore } from "./stores/PostgreSQLOutboxStore";
141
142
  import { PostgreSQLPluginDeploymentStore } from "./stores/PostgreSQLPluginDeploymentStore";
142
143
  import { PostgreSQLAgentRunLedger } from "./stores/PostgreSQLAgentRunLedger";
143
144
  import { PostgreSQLPlatformSettingsStore } from "./stores/PostgreSQLPlatformSettingsStore";
145
+ import { PostgreSQLPlatformAuditStore } from "./stores/PostgreSQLPlatformAuditStore";
144
146
  import { PostgreSQLChannelReplyJobStore } from "./stores/PostgreSQLChannelReplyJobStore";
145
147
  import { PostgreSQLCheckpointStorage } from "./stores/PostgreSQLCheckpointStorage";
146
148
  import { PostgreSQLStore } from "./stores/PostgreSQLStore";
@@ -253,6 +255,8 @@ export async function createPgStoreConfig(connectionString: string) {
253
255
  mm.register(addOidcIdentityClaimInviteFields); // v216
254
256
  mm.register(createPlatformSettingsTable); // v217
255
257
  mm.register(addOidcProviderAllowedHosts); // v218
258
+ mm.register(createPlatformAuditTable); // v219
259
+ mm.register(createPersonalMcpServerConfigsTable); // v220
256
260
 
257
261
  await mm.migrate();
258
262
 
@@ -281,6 +285,7 @@ export async function createPgStoreConfig(connectionString: string) {
281
285
  oidcIdentityClaim: new PostgreSQLOidcIdentityClaimStore(opts),
282
286
  inviteLink: new PostgreSQLInviteLinkStore(opts),
283
287
  platformSettings: new PostgreSQLPlatformSettingsStore(opts),
288
+ platformAudit: new PostgreSQLPlatformAuditStore(opts),
284
289
  channelBinding: new ChannelBindingStore(opts),
285
290
  channelInstallation: new PostgreSQLChannelInstallationStore(opts),
286
291
  thread: new PostgreSQLThreadStore(opts),
package/src/index.ts CHANGED
@@ -40,6 +40,8 @@ export * from "./migrations/oidc_identity_claim_invite_migration";
40
40
  export * from "./stores/PostgreSQLInviteLinkStore";
41
41
  export * from "./migrations/platform_settings_migration";
42
42
  export * from "./stores/PostgreSQLPlatformSettingsStore";
43
+ export * from "./migrations/platform_audit_migration";
44
+ export * from "./stores/PostgreSQLPlatformAuditStore";
43
45
  export * from "./migrations/migration";
44
46
  export * from "./migrations/thread_migrations";
45
47
  export * from "./migrations/thread_tenant_migration";
@@ -53,3 +53,50 @@ export const createMcpServerConfigsTable: Migration = {
53
53
  await client.query('DROP TABLE IF EXISTS lattice_mcp_server_configs');
54
54
  },
55
55
  };
56
+
57
+ /**
58
+ * Migration: create the personal MCP server config table.
59
+ *
60
+ * Personal configs are deliberately stored outside
61
+ * `lattice_mcp_server_configs` so tenant-only code paths (and older replicas
62
+ * during a rolling upgrade) can never read or register them.
63
+ */
64
+ export const createPersonalMcpServerConfigsTable: Migration = {
65
+ version: 220,
66
+ name: 'create_personal_mcp_server_configs_table',
67
+ up: async (client: PoolClient) => {
68
+ await client.query(`
69
+ CREATE TABLE IF NOT EXISTS lattice_personal_mcp_server_configs (
70
+ id VARCHAR(255) NOT NULL,
71
+ tenant_id VARCHAR(255) NOT NULL,
72
+ owner_assistant_id VARCHAR(255) NOT NULL,
73
+ key VARCHAR(255) NOT NULL,
74
+ name VARCHAR(255),
75
+ description TEXT,
76
+ config JSONB NOT NULL,
77
+ selected_tools JSONB NOT NULL DEFAULT '[]',
78
+ is_env_encrypted BOOLEAN NOT NULL DEFAULT FALSE,
79
+ status VARCHAR(50) NOT NULL DEFAULT 'disconnected',
80
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
81
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
82
+
83
+ PRIMARY KEY (tenant_id, owner_assistant_id, id)
84
+ )
85
+ `);
86
+
87
+ await client.query(`
88
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_lattice_personal_mcp_assistant_key
89
+ ON lattice_personal_mcp_server_configs(tenant_id, owner_assistant_id, key)
90
+ `);
91
+
92
+ await client.query(`
93
+ CREATE INDEX IF NOT EXISTS idx_lattice_personal_mcp_assistant
94
+ ON lattice_personal_mcp_server_configs(tenant_id, owner_assistant_id)
95
+ `);
96
+ },
97
+ down: async (client: PoolClient) => {
98
+ await client.query('DROP INDEX IF EXISTS idx_lattice_personal_mcp_assistant_key');
99
+ await client.query('DROP INDEX IF EXISTS idx_lattice_personal_mcp_assistant');
100
+ await client.query('DROP TABLE IF EXISTS lattice_personal_mcp_server_configs');
101
+ },
102
+ };
@@ -0,0 +1,45 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /** Creates the append-only platform-level audit log table. */
4
+ export const createPlatformAuditTable: Migration = {
5
+ version: 219,
6
+ name: "create_platform_audit_table",
7
+ up: async (client) => {
8
+ await client.query(`
9
+ CREATE TABLE IF NOT EXISTS lattice_platform_audit (
10
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
11
+ initiator_user_id VARCHAR(255),
12
+ initiator_role VARCHAR(16),
13
+ initiator JSONB NOT NULL,
14
+ source VARCHAR(32) NOT NULL,
15
+ executor JSONB NOT NULL,
16
+ tenant_id VARCHAR(255),
17
+ action VARCHAR(16) NOT NULL,
18
+ status VARCHAR(16) NOT NULL,
19
+ summary JSONB NOT NULL,
20
+ before JSONB,
21
+ after JSONB,
22
+ error_code VARCHAR(64),
23
+ duration_ms INTEGER NOT NULL,
24
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
25
+ )
26
+ `);
27
+
28
+ await client.query(`
29
+ CREATE INDEX IF NOT EXISTS idx_lattice_platform_audit_created
30
+ ON lattice_platform_audit(created_at DESC, id DESC)
31
+ `);
32
+ await client.query(`
33
+ CREATE INDEX IF NOT EXISTS idx_lattice_platform_audit_initiator
34
+ ON lattice_platform_audit(initiator_user_id, created_at DESC)
35
+ `);
36
+ await client.query(`
37
+ CREATE INDEX IF NOT EXISTS idx_lattice_platform_audit_tenant
38
+ ON lattice_platform_audit(tenant_id, created_at DESC)
39
+ WHERE tenant_id IS NOT NULL
40
+ `);
41
+ },
42
+ down: async (client) => {
43
+ await client.query("DROP TABLE IF EXISTS lattice_platform_audit");
44
+ },
45
+ };
@@ -7,12 +7,17 @@ import type { PoolConfig } from 'pg';
7
7
  import {
8
8
  McpServerConfigStore,
9
9
  McpServerConfigEntry,
10
+ PersonalMcpServerConfigStore,
11
+ PersonalMcpServerConfigEntry,
10
12
  CreateMcpServerConfigRequest,
11
13
  UpdateMcpServerConfigRequest,
12
14
  McpServerConfig,
13
15
  } from '@axiom-lattice/protocols';
14
16
  import { MigrationManager } from '../migrations/migration';
15
- import { createMcpServerConfigsTable } from '../migrations/mcp_server_config_migrations';
17
+ import {
18
+ createMcpServerConfigsTable,
19
+ createPersonalMcpServerConfigsTable,
20
+ } from '../migrations/mcp_server_config_migrations';
16
21
  import { encrypt, safeDecrypt } from '@axiom-lattice/core';
17
22
 
18
23
  /**
@@ -38,6 +43,26 @@ export interface PostgreSQLMcpServerConfigStoreOptions {
38
43
  autoMigrate?: boolean;
39
44
  }
40
45
 
46
+ /** Row shape shared by the tenant and personal MCP config tables. */
47
+ interface McpConfigRow {
48
+ id: string;
49
+ tenant_id: string;
50
+ key: string;
51
+ name: string | null;
52
+ description: string | null;
53
+ config: any;
54
+ selected_tools: string[];
55
+ is_env_encrypted: boolean;
56
+ status: string;
57
+ created_at: Date;
58
+ updated_at: Date;
59
+ }
60
+
61
+ /** Row shape for `lattice_personal_mcp_server_configs`. */
62
+ interface PersonalMcpConfigRow extends McpConfigRow {
63
+ owner_assistant_id: string;
64
+ }
65
+
41
66
  /**
42
67
  * PostgreSQL implementation of McpServerConfigStore
43
68
  *
@@ -46,7 +71,8 @@ export interface PostgreSQLMcpServerConfigStoreOptions {
46
71
  * - Automatic env encryption/decryption
47
72
  * - Unique constraint on (tenant_id, key)
48
73
  */
49
- export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
74
+ export class PostgreSQLMcpServerConfigStore
75
+ implements McpServerConfigStore, PersonalMcpServerConfigStore {
50
76
  private pool: Pool;
51
77
  private migrationManager!: MigrationManager;
52
78
  private initialized: boolean = false;
@@ -73,6 +99,7 @@ export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
73
99
 
74
100
  this.migrationManager = new MigrationManager(this.pool);
75
101
  this.migrationManager.register(createMcpServerConfigsTable);
102
+ this.migrationManager.register(createPersonalMcpServerConfigsTable);
76
103
 
77
104
  // Auto-migrate by default
78
105
  if (options.autoMigrate !== false) {
@@ -411,6 +438,309 @@ export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
411
438
  return result.rows.length > 0;
412
439
  }
413
440
 
441
+ /**
442
+ * Get all personal MCP server configurations owned by one assistant.
443
+ */
444
+ async getAllConfigsForAssistant(
445
+ tenantId: string,
446
+ ownerAssistantId: string,
447
+ ): Promise<PersonalMcpServerConfigEntry[]> {
448
+ await this.ensureInitialized();
449
+
450
+ const result = await this.pool.query<PersonalMcpConfigRow>(
451
+ `
452
+ SELECT id, tenant_id, owner_assistant_id, key, name, description, config, selected_tools, is_env_encrypted, status, created_at, updated_at
453
+ FROM lattice_personal_mcp_server_configs
454
+ WHERE tenant_id = $1 AND owner_assistant_id = $2
455
+ ORDER BY created_at DESC
456
+ `,
457
+ [tenantId, ownerAssistantId],
458
+ );
459
+
460
+ return result.rows.map((row) => this.mapPersonalRowToEntry(row));
461
+ }
462
+
463
+ /**
464
+ * Get every personal MCP server configuration across tenants.
465
+ *
466
+ * Operations/tests only; startup restoration must not call this.
467
+ */
468
+ async getAllPersonalConfigsWithoutTenant(): Promise<PersonalMcpServerConfigEntry[]> {
469
+ await this.ensureInitialized();
470
+
471
+ const result = await this.pool.query<PersonalMcpConfigRow>(
472
+ `
473
+ SELECT id, tenant_id, owner_assistant_id, key, name, description, config, selected_tools, is_env_encrypted, status, created_at, updated_at
474
+ FROM lattice_personal_mcp_server_configs
475
+ ORDER BY created_at DESC
476
+ `,
477
+ );
478
+
479
+ return result.rows.map((row) => this.mapPersonalRowToEntry(row));
480
+ }
481
+
482
+ /**
483
+ * Get one personal MCP server configuration by id.
484
+ */
485
+ async getConfigByIdForAssistant(
486
+ tenantId: string,
487
+ ownerAssistantId: string,
488
+ id: string,
489
+ ): Promise<PersonalMcpServerConfigEntry | null> {
490
+ await this.ensureInitialized();
491
+
492
+ const result = await this.pool.query<PersonalMcpConfigRow>(
493
+ `
494
+ SELECT id, tenant_id, owner_assistant_id, key, name, description, config, selected_tools, is_env_encrypted, status, created_at, updated_at
495
+ FROM lattice_personal_mcp_server_configs
496
+ WHERE tenant_id = $1 AND owner_assistant_id = $2 AND id = $3
497
+ `,
498
+ [tenantId, ownerAssistantId, id],
499
+ );
500
+
501
+ if (result.rows.length === 0) {
502
+ return null;
503
+ }
504
+
505
+ return this.mapPersonalRowToEntry(result.rows[0]);
506
+ }
507
+
508
+ /**
509
+ * Get one personal MCP server configuration by business key.
510
+ */
511
+ async getConfigByKeyForAssistant(
512
+ tenantId: string,
513
+ ownerAssistantId: string,
514
+ key: string,
515
+ ): Promise<PersonalMcpServerConfigEntry | null> {
516
+ await this.ensureInitialized();
517
+
518
+ const result = await this.pool.query<PersonalMcpConfigRow>(
519
+ `
520
+ SELECT id, tenant_id, owner_assistant_id, key, name, description, config, selected_tools, is_env_encrypted, status, created_at, updated_at
521
+ FROM lattice_personal_mcp_server_configs
522
+ WHERE tenant_id = $1 AND owner_assistant_id = $2 AND key = $3
523
+ `,
524
+ [tenantId, ownerAssistantId, key],
525
+ );
526
+
527
+ if (result.rows.length === 0) {
528
+ return null;
529
+ }
530
+
531
+ return this.mapPersonalRowToEntry(result.rows[0]);
532
+ }
533
+
534
+ /**
535
+ * Create a personal MCP server configuration.
536
+ */
537
+ async createConfigForAssistant(
538
+ tenantId: string,
539
+ ownerAssistantId: string,
540
+ id: string,
541
+ data: CreateMcpServerConfigRequest,
542
+ ): Promise<PersonalMcpServerConfigEntry> {
543
+ await this.ensureInitialized();
544
+
545
+ const now = new Date();
546
+ const nowString = now.toISOString();
547
+ const { config: configWithEncryptedEnv, isEnvEncrypted } =
548
+ this.encryptEnvInConfig(data.config);
549
+
550
+ await this.pool.query(
551
+ `
552
+ INSERT INTO lattice_personal_mcp_server_configs (id, tenant_id, owner_assistant_id, key, name, description, config, selected_tools, is_env_encrypted, status, created_at, updated_at)
553
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::timestamp, $12::timestamp)
554
+ ON CONFLICT (tenant_id, owner_assistant_id, id) DO UPDATE SET
555
+ key = EXCLUDED.key,
556
+ name = EXCLUDED.name,
557
+ description = EXCLUDED.description,
558
+ config = EXCLUDED.config,
559
+ selected_tools = EXCLUDED.selected_tools,
560
+ is_env_encrypted = EXCLUDED.is_env_encrypted,
561
+ status = EXCLUDED.status,
562
+ updated_at = EXCLUDED.updated_at
563
+ `,
564
+ [
565
+ id,
566
+ tenantId,
567
+ ownerAssistantId,
568
+ data.key,
569
+ data.name || null,
570
+ data.description || null,
571
+ JSON.stringify(configWithEncryptedEnv),
572
+ JSON.stringify(data.selectedTools || []),
573
+ isEnvEncrypted,
574
+ 'disconnected',
575
+ nowString,
576
+ nowString,
577
+ ],
578
+ );
579
+
580
+ return {
581
+ id,
582
+ tenantId,
583
+ ownerAssistantId,
584
+ key: data.key,
585
+ config: data.config,
586
+ selectedTools: data.selectedTools || [],
587
+ isEnvEncrypted,
588
+ status: 'disconnected',
589
+ name: data.name,
590
+ description: data.description,
591
+ createdAt: now,
592
+ updatedAt: now,
593
+ };
594
+ }
595
+
596
+ /**
597
+ * Update a personal MCP server configuration.
598
+ */
599
+ async updateConfigForAssistant(
600
+ tenantId: string,
601
+ ownerAssistantId: string,
602
+ id: string,
603
+ updates: Partial<UpdateMcpServerConfigRequest>,
604
+ ): Promise<PersonalMcpServerConfigEntry | null> {
605
+ await this.ensureInitialized();
606
+
607
+ const existing = await this.getConfigByIdForAssistant(
608
+ tenantId,
609
+ ownerAssistantId,
610
+ id,
611
+ );
612
+ if (!existing) {
613
+ return null;
614
+ }
615
+
616
+ const updateData: Record<string, any> = {};
617
+
618
+ if (updates.key !== undefined) {
619
+ updateData.key = updates.key;
620
+ }
621
+
622
+ if (updates.name !== undefined) {
623
+ updateData.name = updates.name || null;
624
+ }
625
+
626
+ if (updates.description !== undefined) {
627
+ updateData.description = updates.description || null;
628
+ }
629
+
630
+ if (updates.config !== undefined) {
631
+ const { config: configWithEncryptedEnv, isEnvEncrypted } =
632
+ this.encryptEnvInConfig(updates.config);
633
+ updateData.config = JSON.stringify(configWithEncryptedEnv);
634
+ updateData.is_env_encrypted = isEnvEncrypted;
635
+ }
636
+
637
+ if (updates.selectedTools !== undefined) {
638
+ updateData.selected_tools = JSON.stringify(updates.selectedTools);
639
+ }
640
+
641
+ if (updates.status !== undefined) {
642
+ updateData.status = updates.status;
643
+ }
644
+
645
+ if (Object.keys(updateData).length === 0) {
646
+ return existing;
647
+ }
648
+
649
+ updateData.updated_at = new Date().toISOString();
650
+
651
+ const fields = Object.keys(updateData);
652
+ const values = Object.values(updateData);
653
+ values.push(tenantId);
654
+ values.push(ownerAssistantId);
655
+ values.push(id);
656
+
657
+ const setClauses = fields.map((field, index) => {
658
+ if (field === 'updated_at') {
659
+ return `${field} = $${index + 1}::timestamp`;
660
+ }
661
+ return `${field} = $${index + 1}`;
662
+ });
663
+
664
+ const whereTenantIndex = fields.length + 1;
665
+ const whereOwnerIndex = fields.length + 2;
666
+ const whereIdIndex = fields.length + 3;
667
+
668
+ const sql = `
669
+ UPDATE lattice_personal_mcp_server_configs
670
+ SET ${setClauses.join(', ')}
671
+ WHERE tenant_id = $${whereTenantIndex}
672
+ AND owner_assistant_id = $${whereOwnerIndex}
673
+ AND id = $${whereIdIndex}
674
+ `;
675
+
676
+ await this.pool.query(sql, values);
677
+
678
+ return this.getConfigByIdForAssistant(tenantId, ownerAssistantId, id);
679
+ }
680
+
681
+ /**
682
+ * Delete one personal MCP server configuration by id.
683
+ */
684
+ async deleteConfigForAssistant(
685
+ tenantId: string,
686
+ ownerAssistantId: string,
687
+ id: string,
688
+ ): Promise<boolean> {
689
+ await this.ensureInitialized();
690
+
691
+ const result = await this.pool.query(
692
+ `
693
+ DELETE FROM lattice_personal_mcp_server_configs
694
+ WHERE tenant_id = $1 AND owner_assistant_id = $2 AND id = $3
695
+ `,
696
+ [tenantId, ownerAssistantId, id],
697
+ );
698
+
699
+ return result.rowCount !== null && result.rowCount > 0;
700
+ }
701
+
702
+ /**
703
+ * Delete every personal MCP server configuration owned by one assistant.
704
+ */
705
+ async deleteConfigsForAssistant(
706
+ tenantId: string,
707
+ ownerAssistantId: string,
708
+ ): Promise<number> {
709
+ await this.ensureInitialized();
710
+
711
+ const result = await this.pool.query(
712
+ `
713
+ DELETE FROM lattice_personal_mcp_server_configs
714
+ WHERE tenant_id = $1 AND owner_assistant_id = $2
715
+ `,
716
+ [tenantId, ownerAssistantId],
717
+ );
718
+
719
+ return result.rowCount ?? 0;
720
+ }
721
+
722
+ /**
723
+ * Check whether a personal MCP server configuration exists.
724
+ */
725
+ async hasConfigForAssistant(
726
+ tenantId: string,
727
+ ownerAssistantId: string,
728
+ id: string,
729
+ ): Promise<boolean> {
730
+ await this.ensureInitialized();
731
+
732
+ const result = await this.pool.query(
733
+ `
734
+ SELECT 1 FROM lattice_personal_mcp_server_configs
735
+ WHERE tenant_id = $1 AND owner_assistant_id = $2 AND id = $3
736
+ LIMIT 1
737
+ `,
738
+ [tenantId, ownerAssistantId, id],
739
+ );
740
+
741
+ return result.rows.length > 0;
742
+ }
743
+
414
744
  /**
415
745
  * Dispose resources and close the connection pool
416
746
  */
@@ -433,19 +763,7 @@ export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
433
763
  * Map database row to McpServerConfigEntry object
434
764
  * Automatically decrypts env values if present
435
765
  */
436
- private mapRowToEntry(row: {
437
- id: string;
438
- tenant_id: string;
439
- key: string;
440
- name: string | null;
441
- description: string | null;
442
- config: any;
443
- selected_tools: string[];
444
- is_env_encrypted: boolean;
445
- status: string;
446
- created_at: Date;
447
- updated_at: Date;
448
- }): McpServerConfigEntry {
766
+ private mapRowToEntry(row: McpConfigRow): McpServerConfigEntry {
449
767
  const config: McpServerConfig = typeof row.config === 'string'
450
768
  ? JSON.parse(row.config)
451
769
  : row.config;
@@ -483,6 +801,16 @@ export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
483
801
  };
484
802
  }
485
803
 
804
+ /**
805
+ * Map a personal table row, tagging the owning assistant.
806
+ */
807
+ private mapPersonalRowToEntry(row: PersonalMcpConfigRow): PersonalMcpServerConfigEntry {
808
+ return {
809
+ ...this.mapRowToEntry(row),
810
+ ownerAssistantId: row.owner_assistant_id,
811
+ };
812
+ }
813
+
486
814
  /**
487
815
  * Encrypt env values in config before storing
488
816
  */