storage-mcp.test.ts
1 import { describe, it, before, after } from 'node:test' 2 import assert from 'node:assert/strict' 3 import Database from 'better-sqlite3' 4 import os from 'node:os' 5 import path from 'node:path' 6 import fs from 'node:fs' 7 8 /** 9 * Tests for the MCP server storage operations (loadMcpServers, saveMcpServers, deleteMcpServer). 10 * 11 * Since storage.ts uses a hardcoded DB path with module-level initialization, 12 * we replicate the same SQL pattern against a temporary SQLite database. 13 */ 14 15 const TABLE = 'mcp_servers' 16 17 let dbPath: string 18 let db: InstanceType<typeof Database> 19 20 // --- Replicated storage helpers (mirror storage.ts logic) --- 21 22 function loadMcpServers(): Record<string, any> { 23 const rows = db.prepare(`SELECT id, data FROM ${TABLE}`).all() as { id: string; data: string }[] 24 const result: Record<string, any> = {} 25 for (const row of rows) { 26 try { 27 result[row.id] = JSON.parse(row.data) 28 } catch { 29 // skip malformed 30 } 31 } 32 return result 33 } 34 35 function saveMcpServers(m: Record<string, any>) { 36 const existingRows = db.prepare(`SELECT id FROM ${TABLE}`).all() as { id: string }[] 37 const nextIds = new Set(Object.keys(m)) 38 const toDelete = existingRows.map((r) => r.id).filter((id) => !nextIds.has(id)) 39 const upsert = db.prepare(`INSERT OR REPLACE INTO ${TABLE} (id, data) VALUES (?, ?)`) 40 const del = db.prepare(`DELETE FROM ${TABLE} WHERE id = ?`) 41 const transaction = db.transaction(() => { 42 for (const id of toDelete) { 43 del.run(id) 44 } 45 for (const [id, val] of Object.entries(m)) { 46 upsert.run(id, JSON.stringify(val)) 47 } 48 }) 49 transaction() 50 } 51 52 function deleteMcpServer(id: string) { 53 db.prepare(`DELETE FROM ${TABLE} WHERE id = ?`).run(id) 54 } 55 56 // --- Test setup / teardown --- 57 58 before(() => { 59 dbPath = path.join(os.tmpdir(), `test-storage-mcp-${Date.now()}.db`) 60 db = new Database(dbPath) 61 db.pragma('journal_mode = WAL') 62 db.exec(`CREATE TABLE IF NOT EXISTS ${TABLE} (id TEXT PRIMARY KEY, data TEXT NOT NULL)`) 63 }) 64 65 after(() => { 66 db.close() 67 try { fs.unlinkSync(dbPath) } catch { /* ignore */ } 68 try { fs.unlinkSync(dbPath + '-wal') } catch { /* ignore */ } 69 try { fs.unlinkSync(dbPath + '-shm') } catch { /* ignore */ } 70 }) 71 72 // --- Tests --- 73 74 describe('MCP server storage', () => { 75 it('saveMcpServers saves a config and it is retrievable', () => { 76 const config = { id: 'srv-1', name: 'Test MCP', url: 'http://localhost:9000' } 77 saveMcpServers({ 'srv-1': config }) 78 79 const row = db.prepare(`SELECT data FROM ${TABLE} WHERE id = ?`).get('srv-1') as { data: string } | undefined 80 assert.ok(row, 'row should exist after save') 81 assert.deepStrictEqual(JSON.parse(row.data), config) 82 }) 83 84 it('loadMcpServers returns all saved configs', () => { 85 saveMcpServers({ 86 'srv-1': { id: 'srv-1', name: 'First' }, 87 'srv-2': { id: 'srv-2', name: 'Second' }, 88 }) 89 90 const all = loadMcpServers() 91 assert.ok('srv-1' in all) 92 assert.ok('srv-2' in all) 93 assert.equal(all['srv-2'].name, 'Second') 94 }) 95 96 it('loadMcpServers returns empty object when table is empty', () => { 97 db.exec(`DELETE FROM ${TABLE}`) 98 const all = loadMcpServers() 99 assert.deepStrictEqual(all, {}) 100 }) 101 102 it('saveMcpServers with same id updates the record', () => { 103 saveMcpServers({ 'srv-u': { id: 'srv-u', name: 'Original' } }) 104 saveMcpServers({ 'srv-u': { id: 'srv-u', name: 'Updated' } }) 105 106 const all = loadMcpServers() 107 assert.equal(all['srv-u'].name, 'Updated') 108 109 // only one row for that id 110 const count = (db.prepare(`SELECT COUNT(*) as c FROM ${TABLE} WHERE id = ?`).get('srv-u') as { c: number }).c 111 assert.equal(count, 1) 112 }) 113 114 it('saveMcpServers removes records omitted from the next save payload', () => { 115 saveMcpServers({ 116 'srv-a': { id: 'srv-a', name: 'A' }, 117 'srv-b': { id: 'srv-b', name: 'B' }, 118 }) 119 saveMcpServers({ 120 'srv-b': { id: 'srv-b', name: 'B2' }, 121 }) 122 123 const all = loadMcpServers() 124 assert.equal('srv-a' in all, false) 125 assert.equal(all['srv-b'].name, 'B2') 126 }) 127 128 it('deleteMcpServer removes the record', () => { 129 saveMcpServers({ 'srv-d': { id: 'srv-d', name: 'ToDelete' } }) 130 deleteMcpServer('srv-d') 131 132 const row = db.prepare(`SELECT data FROM ${TABLE} WHERE id = ?`).get('srv-d') 133 assert.equal(row, undefined) 134 }) 135 136 it('deleteMcpServer does not throw for nonexistent id', () => { 137 assert.doesNotThrow(() => { 138 deleteMcpServer('nonexistent-id-xyz') 139 }) 140 }) 141 142 it('round-trip: save multiple, load all, verify count and data', () => { 143 db.exec(`DELETE FROM ${TABLE}`) 144 145 const configs: Record<string, any> = {} 146 for (let i = 0; i < 5; i++) { 147 configs[`rt-${i}`] = { id: `rt-${i}`, name: `Server ${i}`, port: 3000 + i } 148 } 149 saveMcpServers(configs) 150 151 const all = loadMcpServers() 152 assert.equal(Object.keys(all).length, 5) 153 for (let i = 0; i < 5; i++) { 154 assert.equal(all[`rt-${i}`].port, 3000 + i) 155 } 156 }) 157 158 it('data integrity: saved JSON is correctly parsed back with all fields', () => { 159 db.exec(`DELETE FROM ${TABLE}`) 160 161 const config = { 162 id: 'integrity-1', 163 name: 'Full Config', 164 url: 'https://mcp.example.com', 165 transport: 'stdio', 166 command: 'npx', 167 args: ['-y', '@modelcontextprotocol/server-filesystem'], 168 env: { HOME: '/tmp' }, 169 enabled: true, 170 createdAt: 1700000000000, 171 tags: ['production', 'filesystem'], 172 nested: { deep: { value: 42 } }, 173 } 174 175 saveMcpServers({ 'integrity-1': config }) 176 const loaded = loadMcpServers() 177 178 assert.deepStrictEqual(loaded['integrity-1'], config) 179 assert.ok(Array.isArray(loaded['integrity-1'].args)) 180 assert.equal(loaded['integrity-1'].args.length, 2) 181 assert.equal(loaded['integrity-1'].nested.deep.value, 42) 182 assert.equal(typeof loaded['integrity-1'].enabled, 'boolean') 183 }) 184 })