diff --git a/.gitignore b/.gitignore index 1170717..fc084cf 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,7 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Local MCP config +.cursor/ +insights.sqlite diff --git a/example.db-config.json b/example.db-config.json new file mode 100644 index 0000000..e9342d6 --- /dev/null +++ b/example.db-config.json @@ -0,0 +1,31 @@ +{ + "main_sqlserver": { + "type": "sqlserver", + "server": "localhost", + "port": 1433, + "database": "master", + "user": "sa", + "password": "12345678" + }, + "main_postgres": { + "type": "postgres", + "host": "localhost", + "port": 5432, + "user": "your_pg_user", + "password": "your_pg_password", + "database": "your_pg_db" + }, + "analytics_mysql": { + "type": "mysql", + "host": "localhost", + "port": 3306, + "user": "your_mysql_user", + "password": "your_mysql_password", + "database": "your_mysql_db" + }, + "local_sqlite": { + "type": "sqlite", + "path": "./data/local.sqlite" + }, + "insights_db": "./insights.sqlite" +} \ No newline at end of file diff --git a/example.mcp.json b/example.mcp.json new file mode 100644 index 0000000..0481c6a --- /dev/null +++ b/example.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "database_server": { + "command": "npx", + "args": [ + "-y", + "tsx", + "./src/index.ts", + "--config", + "./.cursor/db-config.json" + ] + } + } +} diff --git a/exercise-mcp-server.cjs b/exercise-mcp-server.cjs new file mode 100644 index 0000000..b621ed8 --- /dev/null +++ b/exercise-mcp-server.cjs @@ -0,0 +1,160 @@ +const { spawn } = require('child_process'); + +const serverCmd = 'npx'; +const serverArgs = [ + '-y', + 'tsx', + './src/index.ts', + '--config', + './.cursor/db-config.json' +]; + +// Start the MCP server as a child process +const server = spawn(serverCmd, serverArgs, { stdio: ['pipe', 'pipe', 'inherit'] }); +server.stdout.setEncoding('utf8'); + +// State for dynamic requests +let dbIds = []; +let currentRequestIndex = 0; +const pending = new Map(); +const TIMEOUT_MS = 4000; + +// Initial requests: list tools, then list databases +const requests = [ + { + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {} + }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "list_databases", + arguments: {} + } + } +]; + +let testInsightDbId = null; +let testInsightText = `Test insight at ${new Date().toISOString()}`; + +function sendNextRequest() { + if (currentRequestIndex >= requests.length) { + // If we have dbIds, queue up list_tables requests for each + if (dbIds.length > 0) { + // Start at 3 to avoid id collision + let id = 3; + dbIds.forEach((dbId) => { + requests.push({ + jsonrpc: "2.0", + id: id++, + method: "tools/call", + params: { + name: "list_tables", + arguments: { dbId } + } + }); + }); + // For insights test, pick the first dbId + testInsightDbId = dbIds[0]; + // Add append_insight request + requests.push({ + jsonrpc: "2.0", + id: id++, + method: "tools/call", + params: { + name: "append_insight", + arguments: { dbId: testInsightDbId, insight: testInsightText } + } + }); + // Add list_insights request + requests.push({ + jsonrpc: "2.0", + id: id++, + method: "tools/call", + params: { + name: "list_insights", + arguments: { dbId: testInsightDbId } + } + }); + dbIds = []; // Prevent re-adding + sendNextRequest(); + return; + } + // All requests sent, exit after a short delay + setTimeout(() => { + server.kill(); + process.exit(0); + }, 1000); + return; + } + const req = requests[currentRequestIndex]; + pending.set(req.id, setTimeout(() => { + console.error(`Timeout waiting for response to request id ${req.id}`); + sendNextRequest(); + }, TIMEOUT_MS)); + server.stdin.write(JSON.stringify(req) + '\n'); +} + +// Listen for responses +server.stdout.on('data', (data) => { + data.split('\n').filter(Boolean).forEach(line => { + let response; + try { + response = JSON.parse(line); + } catch (e) { + console.log('Non-JSON output:', line); + return; + } + if (response.id && pending.has(response.id)) { + clearTimeout(pending.get(response.id)); + pending.delete(response.id); + // Improved output formatting + if (response.result && Array.isArray(response.result.content)) { + response.result.content.forEach((item) => { + if (item.type === 'text' && typeof item.text === 'string') { + // Try to parse as JSON + try { + const parsed = JSON.parse(item.text); + // If this is the list_databases response, extract dbIds + if (response.id === 2 && parsed.databases) { + dbIds = parsed.databases.map(db => db.id); + } + console.log('Received (pretty):', JSON.stringify(parsed, null, 2)); + } catch (e) { + // Not JSON, print as-is + console.log('Received (text):', item.text); + } + } else { + console.log('Received (content):', JSON.stringify(item, null, 2)); + } + }); + } else { + console.log('Received:', JSON.stringify(response, null, 2)); + } + currentRequestIndex++; + sendNextRequest(); + } else { + // Notification or unexpected response + console.log('Received (no matching id):', JSON.stringify(response, null, 2)); + } + }); +}); + +server.on('error', (err) => { + console.error('Server process error:', err); + process.exit(1); +}); + +server.on('exit', (code, signal) => { + if (code !== 0) { + console.error(`Server exited with code ${code} (signal: ${signal})`); + process.exit(code); + } +}); + +// Start the sequence +sendNextRequest(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b8dce20..139f9ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@executeautomation/database-server", - "version": "1.0.2", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@executeautomation/database-server", - "version": "1.0.2", + "version": "1.1.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "1.9.0", + "@modelcontextprotocol/sdk": "^1.9.0", "mssql": "11.0.1", "mysql2": "^3.14.1", "pg": "^8.11.3", @@ -24,6 +24,7 @@ "@types/sqlite3": "5.1.0", "rimraf": "^5.0.5", "shx": "0.4.0", + "tsx": "^4.20.3", "typescript": "5.8.3" } }, @@ -286,6 +287,406 @@ "node": ">=16" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -1262,6 +1663,46 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1587,6 +2028,20 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "optional": true }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1717,6 +2172,18 @@ "node": ">=6" } }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -3356,6 +3823,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -4119,6 +4595,25 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsx": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", + "dev": true, + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 7a95a2d..ff41326 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "clean": "rimraf dist" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.9.0", + "@modelcontextprotocol/sdk": "^1.9.0", "mssql": "11.0.1", "mysql2": "^3.14.1", "pg": "^8.11.3", @@ -35,6 +35,7 @@ "@types/sqlite3": "5.1.0", "rimraf": "^5.0.5", "shx": "0.4.0", + "tsx": "^4.20.3", "typescript": "5.8.3" } } diff --git a/readme.md b/readme.md index 890f4ac..0fe42d5 100644 --- a/readme.md +++ b/readme.md @@ -1,24 +1,27 @@ -[![MseeP.ai Security Assessment Badge](https://mseep.net/pr/executeautomation-mcp-database-server-badge.png)](https://mseep.ai/app/executeautomation-mcp-database-server) - # MCP Database Server +[![MseeP.ai Security Assessment Badge](https://mseep.net/pr/executeautomation-mcp-database-server-badge.png)](https://mseep.ai/app/executeautomation-mcp-database-server) + This MCP (Model Context Protocol) server provides database access capabilities to Claude, supporting SQLite, SQL Server, PostgreSQL, and MySQL databases. ## Installation 1. Clone the repository: -``` + +```shell git clone https://github.com/executeautomation/database-server.git cd database-server ``` -2. Install dependencies: -``` +1. Install dependencies: + +```shell npm install ``` -3. Build the project: -``` +1. Build the project: + +```shell npm run build ``` @@ -52,23 +55,25 @@ If you want to modify the code or run from your local environment: To use with an SQLite database: -``` -node dist/src/index.js /path/to/your/database.db +```shell +node dist/src/index.js /path/to/your/database.db [--insights-db ] ``` ### SQL Server Database To use with a SQL Server database: -``` +```shell node dist/src/index.js --sqlserver --server --database [--user --password ] ``` Required parameters: + - `--server`: SQL Server host name or IP address - `--database`: Name of the database Optional parameters: + - `--user`: Username for SQL Server authentication (if not provided, Windows Authentication will be used) - `--password`: Password for SQL Server authentication - `--port`: Port number (default: 1433) @@ -77,15 +82,17 @@ Optional parameters: To use with a PostgreSQL database: -``` +```shell node dist/src/index.js --postgresql --host --database [--user --password ] ``` Required parameters: + - `--host`: PostgreSQL host name or IP address - `--database`: Name of the database Optional parameters: + - `--user`: Username for PostgreSQL authentication - `--password`: Password for PostgreSQL authentication - `--port`: Port number (default: 5432) @@ -96,135 +103,167 @@ Optional parameters: To use with a MySQL database: -``` -node dist/src/index.js --mysql --host --database --port [--user --password ] +```shell +node dist/src/index.js --mysql --host --database [--user --password --port ] ``` Required parameters: + - `--host`: MySQL host name or IP address - `--database`: Name of the database -- `--port`: Port number (default: 3306) Optional parameters: + - `--user`: Username for MySQL authentication - `--password`: Password for MySQL authentication - `--ssl`: Enable SSL connection (true/false or object) - `--connection-timeout`: Connection timeout in milliseconds (default: 30000) +- `--port`: Port number (default: 3306) + +## Multi-Database Configuration -## Configuring Claude Desktop +### Using a Config File for Multiple Databases -### Direct Usage Configuration +You can now start the MCP server with access to multiple databases by specifying a JSON config file via the `--config ` command line argument. This enables you to manage and query multiple databases simultaneously. -If you installed the package globally, configure Claude Desktop with: +- **Config file location:** You can use either an absolute or relative path for the config file. A common convention is to place it in your project root, e.g., `./db-config.json`. +- **Config file format:** See below for an example. Each key is a unique `dbId` for the database. + +#### Example Config File ```json { - "mcpServers": { - "sqlite": { - "command": "npx", - "args": [ - "-y", - "@executeautomation/database-server", - "/path/to/your/database.db" - ] - }, - "sqlserver": { - "command": "npx", - "args": [ - "-y", - "@executeautomation/database-server", - "--sqlserver", - "--server", "your-server-name", - "--database", "your-database-name", - "--user", "your-username", - "--password", "your-password" - ] - }, - "postgresql": { - "command": "npx", - "args": [ - "-y", - "@executeautomation/database-server", - "--postgresql", - "--host", "your-host-name", - "--database", "your-database-name", - "--user", "your-username", - "--password", "your-password" - ] - }, - "mysql": { - "command": "npx", - "args": [ - "-y", - "@executeautomation/database-server", - "--mysql", - "--host", "your-host-name", - "--database", "your-database-name", - "--port", "3306", - "--user", "your-username", - "--password", "your-password" - ] - } - } + "main_sqlite": { + "type": "sqlite", + "description": "Primary SQLite DB", + "path": "/data/main.db" + }, + "analytics_pg": { + "type": "postgresql", + "description": "Analytics PostgreSQL DB", + "host": "localhost", + "database": "analytics", + "user": "user", + "password": "pass" + }, + "insights_db": "./insights.sqlite" } ``` -### Local Development Configuration +#### Supported Database Types and Required Fields -For local development, configure Claude Desktop to use your locally built version: +| type | Required Fields | Optional Fields | +|-------------|-------------------------------------------------|---------------------| +| sqlite | path | description | +| sqlserver | server, database | user, password, port, description | +| postgresql | host, database | user, password, port, ssl, connectionTimeout, description | +| mysql | host, database | user, password, port, ssl, connectionTimeout, description | + +#### Starting the Server with Multiple Databases + +```shell +node dist/src/index.js --config path/to/config.json +``` + +#### Listing Available Databases + +A new tool, `list_databases`, is available to enumerate all configured databases by ID, type, and description. You can call this tool to discover which `dbId` values are available for use in subsequent requests. + +**Example tool call:** ```json { - "mcpServers": { - "sqlite": { - "command": "node", - "args": [ - "/absolute/path/to/mcp-database-server/dist/src/index.js", - "/path/to/your/database.db" - ] - }, - "sqlserver": { - "command": "node", - "args": [ - "/absolute/path/to/mcp-database-server/dist/src/index.js", - "--sqlserver", - "--server", "your-server-name", - "--database", "your-database-name", - "--user", "your-username", - "--password", "your-password" - ] - }, - "postgresql": { - "command": "node", - "args": [ - "/absolute/path/to/mcp-database-server/dist/src/index.js", - "--postgresql", - "--host", "your-host-name", - "--database", "your-database-name", - "--user", "your-username", - "--password", "your-password" - ] - }, - "mysql": { - "command": "node", - "args": [ - "/absolute/path/to/mcp-database-server/dist/src/index.js", - "--mysql", - "--host", "your-host-name", - "--database", "your-database-name", - "--port", "3306", - "--user", "your-username", - "--password", "your-password" - ] - } - } + "name": "list_databases", + "arguments": {} } ``` -The Claude Desktop configuration file is typically located at: -- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` -- Windows: `%APPDATA%\Claude\claude_desktop_config.json` -- Linux: `~/.config/Claude/claude_desktop_config.json` +**Response:** + +```json +{ + "databases": [ + { "id": "main_sqlite", "type": "sqlite", "description": "Primary SQLite DB" }, + { "id": "analytics_pg", "type": "postgresql", "description": "Analytics PostgreSQL DB" } + ] +} +``` + +#### Specifying dbId in Requests + +- For all resource and tool requests, you must now specify the `dbId` parameter to indicate which database to operate on. +- **Tool call example:** + + ```json + { + "dbId": "main_sqlite", + "query": "SELECT * FROM users" + } + ``` + +- **Resource request example:** + + ```json + { + "dbId": "main_sqlite", + "uri": "sqlite:///data/main.db/users/schema" + } + ``` + +- For resource requests, include `dbId` in the request parameters. + +#### Backward Compatibility + +If you do not use the `--config` option, the server will operate in single-database mode as before, and `dbId` will default to `default` internally. You can continue to use the CLI as before for single-database use cases. + +### Insights Database Configuration + +You can configure a separate SQLite database for storing business insights (used by the append_insight and list_insights tools). This is independent of your main data sources and is always writable. + +- **Config file:** Add a top-level field `"insights_db"` with the path to a SQLite file (e.g., `"./insights.sqlite"`). +- **CLI option:** In single-db mode, use `--insights-db ` to specify the insights database file. +- **Default:** If not set, the default is `./insights.sqlite` in the project root. +- **Ephemeral:** Use `:memory:` as the path for a non-persistent, in-memory insights database. + +This ensures insights are always writable and never stored in your main (possibly read-only) databases. + +## Environment Variables + +No environment variables are required by default. If you wish to use environment variables for secrets (e.g., DB passwords), you can reference them in your config file using your own scripting or config management approach. + +## Troubleshooting + +**Common errors and solutions:** + +- **Config file not found:** + - Ensure the path to your config file is correct and the file exists. +- **Invalid config file format:** + - The config file must be a valid JSON object mapping dbId to config objects. +- **Missing dbId in request:** + - All requests must specify a valid `dbId` when in multi-database mode. +- **Database connection errors:** + - Check your connection parameters (host, user, password, etc.) and ensure the database server is running and accessible. +- **Unsupported database type:** + - Ensure the `type` field in your config is one of: `sqlite`, `sqlserver`, `postgresql`, or `mysql`. + +## Running Tests + +If you have tests: + +```shell +npm test +``` + +If not, you can check your build and lint the code with: + +```shell +npm run build +npm run lint +``` + +## Contact / Support + +For help, questions, or to report bugs, please open an issue on the [GitHub Issues page](https://github.com/executeautomation/database-server/issues). ## Available Database Tools @@ -243,6 +282,8 @@ The MCP Database Server provides the following tools that Claude can use: | `append_insight` | Add a business insight to memo | `insight`: Text of insight | | `list_insights` | List all business insights | None | +> **Note:** The `append_insight` and `list_insights` tools are currently mock implementations and do not persist insights between requests. + For practical examples of how to use these tools with Claude, see [Usage Examples](docs/usage-examples.md). ## Additional Documentation @@ -255,13 +296,13 @@ For practical examples of how to use these tools with Claude, see [Usage Example To run the server in development mode: -``` +```shell npm run dev ``` To watch for changes during development: -``` +```shell npm run watch ``` diff --git a/src/db/index.ts b/src/db/index.ts index f883f2b..fe0b520 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,105 +1,94 @@ import { DbAdapter, createDbAdapter } from './adapter.js'; -// Store the active database adapter -let dbAdapter: DbAdapter | null = null; +// Store the active database adapters by ID +const dbAdapters: Record = {}; +const dbMetadatas: Record = {}; /** - * Initialize the database connection + * Initialize a database connection and register it by ID + * @param dbId Unique database identifier * @param connectionInfo Connection information object or SQLite path string - * @param dbType Database type ('sqlite' or 'sqlserver') + * @param dbType Database type ('sqlite', 'sqlserver', etc.) + * @param description Optional description for the database */ -export async function initDatabase(connectionInfo: any, dbType: string = 'sqlite'): Promise { +export async function initDatabase(dbId: string, connectionInfo: any, dbType: string = 'sqlite', description?: string): Promise { try { - // If connectionInfo is a string, assume it's a SQLite path if (typeof connectionInfo === 'string') { connectionInfo = { path: connectionInfo }; } - - // Create appropriate adapter based on database type - dbAdapter = createDbAdapter(dbType, connectionInfo); - - // Initialize the connection - await dbAdapter.init(); + const adapter = createDbAdapter(dbType, connectionInfo); + await adapter.init(); + dbAdapters[dbId] = adapter; + dbMetadatas[dbId] = { ...adapter.getMetadata(), description }; } catch (error) { - throw new Error(`Failed to initialize database: ${(error as Error).message}`); + throw new Error(`Failed to initialize database '${dbId}': ${(error as Error).message}`); } } /** - * Execute a SQL query and get all results - * @param query SQL query to execute - * @param params Query parameters - * @returns Promise with query results + * Get all registered database IDs and their metadata */ -export function dbAll(query: string, params: any[] = []): Promise { - if (!dbAdapter) { - throw new Error("Database not initialized"); - } - return dbAdapter.all(query, params); +export function listDatabases() { + return Object.entries(dbMetadatas).map(([id, meta]) => ({ id, ...meta })); } /** - * Execute a SQL query that modifies data - * @param query SQL query to execute - * @param params Query parameters - * @returns Promise with result info + * Execute a SQL query and get all results for a specific database */ -export function dbRun(query: string, params: any[] = []): Promise<{ changes: number, lastID: number }> { - if (!dbAdapter) { - throw new Error("Database not initialized"); - } - return dbAdapter.run(query, params); +export function dbAll(dbId: string, query: string, params: any[] = []): Promise { + const adapter = dbAdapters[dbId]; + if (!adapter) throw new Error(`Database '${dbId}' not initialized`); + return adapter.all(query, params); } /** - * Execute multiple SQL statements - * @param query SQL statements to execute - * @returns Promise that resolves when execution completes + * Execute a SQL query that modifies data for a specific database */ -export function dbExec(query: string): Promise { - if (!dbAdapter) { - throw new Error("Database not initialized"); - } - return dbAdapter.exec(query); +export function dbRun(dbId: string, query: string, params: any[] = []): Promise<{ changes: number, lastID: number }> { + const adapter = dbAdapters[dbId]; + if (!adapter) throw new Error(`Database '${dbId}' not initialized`); + return adapter.run(query, params); } /** - * Close the database connection + * Execute multiple SQL statements for a specific database */ -export function closeDatabase(): Promise { - if (!dbAdapter) { - return Promise.resolve(); - } - return dbAdapter.close(); +export function dbExec(dbId: string, query: string): Promise { + const adapter = dbAdapters[dbId]; + if (!adapter) throw new Error(`Database '${dbId}' not initialized`); + return adapter.exec(query); } /** - * Get database metadata + * Close all database connections */ -export function getDatabaseMetadata(): { name: string, type: string, path?: string, server?: string, database?: string } { - if (!dbAdapter) { - throw new Error("Database not initialized"); - } - return dbAdapter.getMetadata(); +export async function closeAllDatabases(): Promise { + await Promise.all(Object.values(dbAdapters).map(adapter => adapter.close())); } /** - * Get database-specific query for listing tables + * Get metadata for a specific database */ -export function getListTablesQuery(): string { - if (!dbAdapter) { - throw new Error("Database not initialized"); - } - return dbAdapter.getListTablesQuery(); +export function getDatabaseMetadata(dbId: string): { name: string, type: string, path?: string, server?: string, database?: string, description?: string } { + const meta = dbMetadatas[dbId]; + if (!meta) throw new Error(`Database '${dbId}' not initialized`); + return meta; } /** - * Get database-specific query for describing a table - * @param tableName Table name + * Get database-specific query for listing tables for a specific database */ -export function getDescribeTableQuery(tableName: string): string { - if (!dbAdapter) { - throw new Error("Database not initialized"); - } - return dbAdapter.getDescribeTableQuery(tableName); +export function getListTablesQuery(dbId: string): string { + const adapter = dbAdapters[dbId]; + if (!adapter) throw new Error(`Database '${dbId}' not initialized`); + return adapter.getListTablesQuery(); +} + +/** + * Get database-specific query for describing a table for a specific database + */ +export function getDescribeTableQuery(dbId: string, tableName: string): string { + const adapter = dbAdapters[dbId]; + if (!adapter) throw new Error(`Database '${dbId}' not initialized`); + return adapter.getDescribeTableQuery(tableName); } \ No newline at end of file diff --git a/src/handlers/resourceHandlers.ts b/src/handlers/resourceHandlers.ts index 100a9bd..ea07bef 100644 --- a/src/handlers/resourceHandlers.ts +++ b/src/handlers/resourceHandlers.ts @@ -1,12 +1,13 @@ import { dbAll, getListTablesQuery, getDescribeTableQuery, getDatabaseMetadata } from '../db/index.js'; /** - * Handle listing resources request + * Handle listing resources request for a specific database + * @param dbId Database identifier * @returns List of available resources */ -export async function handleListResources() { +export async function handleListResources(dbId: string) { try { - const dbInfo = getDatabaseMetadata(); + const dbInfo = getDatabaseMetadata(dbId); const dbType = dbInfo.type; let resourceBaseUrl: URL; @@ -22,8 +23,8 @@ export async function handleListResources() { const SCHEMA_PATH = "schema"; // Use adapter-specific query to list tables - const query = getListTablesQuery(); - const result = await dbAll(query); + const query = getListTablesQuery(dbId); + const result = await dbAll(dbId, query); return { resources: result.map((row: any) => ({ @@ -38,11 +39,12 @@ export async function handleListResources() { } /** - * Handle reading a specific resource + * Handle reading a specific resource for a specific database + * @param dbId Database identifier * @param uri URI of the resource to read * @returns Resource contents */ -export async function handleReadResource(uri: string) { +export async function handleReadResource(dbId: string, uri: string) { try { const resourceUrl = new URL(uri); const SCHEMA_PATH = "schema"; @@ -56,8 +58,8 @@ export async function handleReadResource(uri: string) { } // Use adapter-specific query to describe the table - const query = getDescribeTableQuery(tableName!); - const result = await dbAll(query); + const query = getDescribeTableQuery(dbId, tableName!); + const result = await dbAll(dbId, query); return { contents: [ diff --git a/src/handlers/toolHandlers.ts b/src/handlers/toolHandlers.ts index 463bcc3..77710da 100644 --- a/src/handlers/toolHandlers.ts +++ b/src/handlers/toolHandlers.ts @@ -1,7 +1,7 @@ import { formatErrorResponse } from '../utils/formatUtils.js'; // Import all tool implementations -import { readQuery, writeQuery, exportQuery } from '../tools/queryTools.js'; +import { readQuery, writeQuery, exportQuery, listDatabasesTool } from '../tools/queryTools.js'; import { createTable, alterTable, dropTable, listTables, describeTable } from '../tools/schemaTools.js'; import { appendInsight, listInsights } from '../tools/insightTools.js'; @@ -18,9 +18,10 @@ export function handleListTools() { inputSchema: { type: "object", properties: { + dbId: { type: "string" }, query: { type: "string" }, }, - required: ["query"], + required: ["dbId", "query"], }, }, { @@ -29,9 +30,10 @@ export function handleListTools() { inputSchema: { type: "object", properties: { + dbId: { type: "string" }, query: { type: "string" }, }, - required: ["query"], + required: ["dbId", "query"], }, }, { @@ -40,9 +42,10 @@ export function handleListTools() { inputSchema: { type: "object", properties: { + dbId: { type: "string" }, query: { type: "string" }, }, - required: ["query"], + required: ["dbId", "query"], }, }, { @@ -51,9 +54,10 @@ export function handleListTools() { inputSchema: { type: "object", properties: { + dbId: { type: "string" }, query: { type: "string" }, }, - required: ["query"], + required: ["dbId", "query"], }, }, { @@ -62,10 +66,11 @@ export function handleListTools() { inputSchema: { type: "object", properties: { + dbId: { type: "string" }, table_name: { type: "string" }, confirm: { type: "boolean" }, }, - required: ["table_name", "confirm"], + required: ["dbId", "table_name", "confirm"], }, }, { @@ -74,10 +79,11 @@ export function handleListTools() { inputSchema: { type: "object", properties: { + dbId: { type: "string" }, query: { type: "string" }, format: { type: "string", enum: ["csv", "json"] }, }, - required: ["query", "format"], + required: ["dbId", "query", "format"], }, }, { @@ -85,7 +91,10 @@ export function handleListTools() { description: "Get a list of all tables in the database", inputSchema: { type: "object", - properties: {}, + properties: { + dbId: { type: "string" }, + }, + required: ["dbId"], }, }, { @@ -94,9 +103,10 @@ export function handleListTools() { inputSchema: { type: "object", properties: { + dbId: { type: "string" }, table_name: { type: "string" }, }, - required: ["table_name"], + required: ["dbId", "table_name"], }, }, { @@ -105,9 +115,10 @@ export function handleListTools() { inputSchema: { type: "object", properties: { + dbId: { type: "string" }, insight: { type: "string" }, }, - required: ["insight"], + required: ["dbId", "insight"], }, }, { @@ -115,7 +126,20 @@ export function handleListTools() { description: "List all business insights in the memo", inputSchema: { type: "object", - properties: {}, + properties: { + dbId: { type: "string" }, + }, + required: ["dbId"], + }, + }, + { + name: "list_databases", + description: "List all available databases by ID and description.", + inputSchema: { + type: "object", + properties: { + dbId: { type: "string", description: "Optional database ID to filter results" } + }, }, }, ], @@ -132,35 +156,27 @@ export async function handleToolCall(name: string, args: any) { try { switch (name) { case "read_query": - return await readQuery(args.query); - + return await readQuery(args.dbId, args.query); case "write_query": - return await writeQuery(args.query); - + return await writeQuery(args.dbId, args.query); case "create_table": - return await createTable(args.query); - + return await createTable(args.dbId, args.query); case "alter_table": - return await alterTable(args.query); - + return await alterTable(args.dbId, args.query); case "drop_table": - return await dropTable(args.table_name, args.confirm); - + return await dropTable(args.dbId, args.table_name, args.confirm); case "export_query": - return await exportQuery(args.query, args.format); - + return await exportQuery(args.dbId, args.query, args.format); case "list_tables": - return await listTables(); - + return await listTables(args.dbId); case "describe_table": - return await describeTable(args.table_name); - + return await describeTable(args.dbId, args.table_name); case "append_insight": - return await appendInsight(args.insight); - + return await appendInsight(args.dbId, args.insight); case "list_insights": - return await listInsights(); - + return await listInsights(args.dbId); + case "list_databases": + return await listDatabasesTool(); default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/index.ts b/src/index.ts index b23d142..f8c3cdb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,12 +10,17 @@ import { } from "@modelcontextprotocol/sdk/types.js"; // Import database utils -import { initDatabase, closeDatabase, getDatabaseMetadata } from './db/index.js'; +import { initDatabase, closeAllDatabases, getDatabaseMetadata, listDatabases } from './db/index.js'; +import fs from 'fs'; +import path from 'path'; // Import handlers import { handleListResources, handleReadResource } from './handlers/resourceHandlers.js'; import { handleListTools, handleToolCall } from './handlers/toolHandlers.js'; +// Import insights database utils +import { initInsightsDb, closeInsightsDb } from './tools/insightsDb.js'; + // Setup a logger that uses stderr instead of stdout to avoid interfering with MCP communications const logger = { log: (...args: any[]) => console.error('[INFO]', ...args), @@ -40,12 +45,54 @@ const server = new Server( // Parse command line arguments const args = process.argv.slice(2); -if (args.length === 0) { + +// Check for --config argument +let configFile: string | null = null; +let configArgIndex = args.indexOf('--config'); +if (configArgIndex !== -1 && args[configArgIndex + 1]) { + configFile = args[configArgIndex + 1]; +} + +// Parse --insights-db argument (for single-db mode) +let insightsDbPath: string | undefined = undefined; +let insightsDbArgIndex = args.indexOf('--insights-db'); +if (insightsDbArgIndex !== -1 && args[insightsDbArgIndex + 1]) { + insightsDbPath = args[insightsDbArgIndex + 1]; +} + +let multiDbMode = false; +let dbConfigs: Record = {}; + +if (configFile) { + // Multi-database mode + multiDbMode = true; + const configPath = path.isAbsolute(configFile) ? configFile : path.join(process.cwd(), configFile); + if (!fs.existsSync(configPath)) { + logger.error(`Config file not found: ${configPath}`); + process.exit(1); + } + try { + const configRaw = fs.readFileSync(configPath, 'utf-8'); + dbConfigs = JSON.parse(configRaw); + if (typeof dbConfigs !== 'object' || Array.isArray(dbConfigs)) { + throw new Error('Config file must be an object mapping dbId to config'); + } + // Look for top-level insights_db field + if (typeof dbConfigs.insights_db === 'string') { + insightsDbPath = dbConfigs.insights_db; + } + } catch (e) { + logger.error('Failed to parse config file:', e); + process.exit(1); + } +} else if (args.length === 0) { logger.error("Please provide database connection information"); - logger.error("Usage for SQLite: node index.js "); + logger.error("Usage for SQLite: node index.js [--insights-db ]"); logger.error("Usage for SQL Server: node index.js --sqlserver --server --database [--user --password ]"); logger.error("Usage for PostgreSQL: node index.js --postgresql --host --database [--user --password --port ]"); logger.error("Usage for MySQL: node index.js --mysql --host --database [--user --password --port ]"); + logger.error("Or use --config for multiple databases"); + logger.error("Optional: --insights-db to specify insights database"); process.exit(1); } @@ -168,12 +215,17 @@ else if (args.includes('--mysql')) { } // Set up request handlers -server.setRequestHandler(ListResourcesRequestSchema, async () => { - return await handleListResources(); +server.setRequestHandler(ListResourcesRequestSchema, async (request) => { + // Require dbId in multiDbMode, use 'default' in single-db mode + const dbId: string = multiDbMode ? (request?.params?.dbId as string) : 'default'; + if (!dbId) throw new Error('dbId is required'); + return await handleListResources(dbId); }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - return await handleReadResource(request.params.uri); + const dbId: string = multiDbMode ? (request?.params?.dbId as string) : 'default'; + if (!dbId) throw new Error('dbId is required'); + return await handleReadResource(dbId, request.params.uri); }); server.setRequestHandler(ListToolsRequestSchema, async () => { @@ -187,13 +239,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // Handle shutdown gracefully process.on('SIGINT', async () => { logger.info('Shutting down gracefully...'); - await closeDatabase(); + await closeAllDatabases(); + await closeInsightsDb(); process.exit(0); }); process.on('SIGTERM', async () => { logger.info('Shutting down gracefully...'); - await closeDatabase(); + await closeAllDatabases(); + await closeInsightsDb(); process.exit(0); }); @@ -211,23 +265,37 @@ process.on('unhandledRejection', (reason, promise) => { */ async function runServer() { try { - logger.info(`Initializing ${dbType} database...`); - if (dbType === 'sqlite') { - logger.info(`Database path: ${connectionInfo}`); - } else if (dbType === 'sqlserver') { - logger.info(`Server: ${connectionInfo.server}, Database: ${connectionInfo.database}`); - } else if (dbType === 'postgresql') { - logger.info(`Host: ${connectionInfo.host}, Database: ${connectionInfo.database}`); - } else if (dbType === 'mysql') { - logger.info(`Host: ${connectionInfo.host}, Database: ${connectionInfo.database}`); + // Initialize insights database (always) + await initInsightsDb(insightsDbPath); + if (multiDbMode) { + logger.info(`Initializing databases from config file: ${configFile}`); + for (const [dbId, dbConfig] of Object.entries(dbConfigs)) { + // Skip the top-level insights_db field + if (dbId === 'insights_db') continue; + const { type, description, ...connectionInfo } = dbConfig; + logger.info(`Initializing [${dbId}] (${type}): ${description || ''}`); + await initDatabase(dbId, connectionInfo, type, description); + } + logger.info(`Initialized ${Object.keys(dbConfigs).length - (dbConfigs.insights_db ? 1 : 0)} databases.`); + } else { + logger.info(`Initializing ${dbType} database...`); + if (dbType === 'sqlite') { + logger.info(`Database path: ${connectionInfo}`); + } else if (dbType === 'sqlserver') { + logger.info(`Server: ${connectionInfo.server}, Database: ${connectionInfo.database}`); + } else if (dbType === 'postgresql') { + logger.info(`Host: ${connectionInfo.host}, Database: ${connectionInfo.database}`); + } else if (dbType === 'mysql') { + logger.info(`Host: ${connectionInfo.host}, Database: ${connectionInfo.database}`); + } + + // Initialize the database + await initDatabase('default', connectionInfo, dbType); + + const dbInfo = getDatabaseMetadata('default'); + logger.info(`Connected to ${dbInfo.name} database`); } - // Initialize the database - await initDatabase(connectionInfo, dbType); - - const dbInfo = getDatabaseMetadata(); - logger.info(`Connected to ${dbInfo.name} database`); - logger.info('Starting MCP server...'); const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/tools/insightTools.ts b/src/tools/insightTools.ts index 0221da1..cf0b24c 100644 --- a/src/tools/insightTools.ts +++ b/src/tools/insightTools.ts @@ -1,63 +1,38 @@ -import { dbAll, dbExec, dbRun } from '../db/index.js'; import { formatSuccessResponse } from '../utils/formatUtils.js'; +import { getInsightsDb } from './insightsDb.js'; /** - * Add a business insight to the memo - * @param insight Business insight text + * Add a business insight to the memo for a specific database + * @param dbId Database identifier + * @param insight The insight to add * @returns Result of the operation */ -export async function appendInsight(insight: string) { +export async function appendInsight(dbId: string, insight: string) { try { - if (!insight) { - throw new Error("Insight text is required"); - } - - // Create insights table if it doesn't exist - await dbExec(` - CREATE TABLE IF NOT EXISTS mcp_insights ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - insight TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Insert the insight - await dbRun( - "INSERT INTO mcp_insights (insight) VALUES (?)", - [insight] + const db = getInsightsDb(); + await db.run( + 'INSERT INTO mcp_insights (db_id, insight) VALUES (?, ?)', + [dbId, insight] ); - - return formatSuccessResponse({ success: true, message: "Insight added" }); + return formatSuccessResponse({ dbId, insight, message: 'Insight appended' }); } catch (error: any) { - throw new Error(`Error adding insight: ${error.message}`); + throw new Error(`Error appending insight: ${error.message}`); } } /** - * List all insights in the memo - * @returns Array of insights + * List all business insights in the memo for a specific database + * @param dbId Database identifier + * @returns List of insights */ -export async function listInsights() { +export async function listInsights(dbId: string) { try { - // Check if insights table exists - const tableExists = await dbAll( - "SELECT name FROM sqlite_master WHERE type='table' AND name = 'mcp_insights'" + const db = getInsightsDb(); + const insights = await db.all( + 'SELECT id, insight, created_at FROM mcp_insights WHERE db_id = ? ORDER BY created_at DESC', + [dbId] ); - - if (tableExists.length === 0) { - // Create table if it doesn't exist - await dbExec(` - CREATE TABLE IF NOT EXISTS mcp_insights ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - insight TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - return formatSuccessResponse([]); - } - - const insights = await dbAll("SELECT * FROM mcp_insights ORDER BY created_at DESC"); - return formatSuccessResponse(insights); + return formatSuccessResponse({ dbId, insights }); } catch (error: any) { throw new Error(`Error listing insights: ${error.message}`); } diff --git a/src/tools/insightsDb.ts b/src/tools/insightsDb.ts new file mode 100644 index 0000000..d8f9d4c --- /dev/null +++ b/src/tools/insightsDb.ts @@ -0,0 +1,42 @@ +import { SqliteAdapter } from '../db/sqlite-adapter.js'; + +let insightsDb: SqliteAdapter | null = null; +let insightsDbPath: string = './insights.sqlite'; + +/** + * Initialize the insights database (singleton) + * @param path Path to SQLite file (or ':memory:' for ephemeral) + */ +export async function initInsightsDb(path?: string) { + if (insightsDb) return; // Already initialized + insightsDbPath = path || './insights.sqlite'; + insightsDb = new SqliteAdapter(insightsDbPath); + await insightsDb.init(); + // Create the insights table if it doesn't exist + await insightsDb.exec(` + CREATE TABLE IF NOT EXISTS mcp_insights ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + db_id TEXT, + insight TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); +} + +/** + * Get the initialized insights database adapter + */ +export function getInsightsDb(): SqliteAdapter { + if (!insightsDb) throw new Error('Insights database not initialized'); + return insightsDb; +} + +/** + * Close the insights database connection + */ +export async function closeInsightsDb() { + if (insightsDb) { + await insightsDb.close(); + insightsDb = null; + } +} \ No newline at end of file diff --git a/src/tools/queryTools.ts b/src/tools/queryTools.ts index 8bb465a..38058d3 100644 --- a/src/tools/queryTools.ts +++ b/src/tools/queryTools.ts @@ -1,18 +1,20 @@ import { dbAll, dbRun, dbExec } from '../db/index.js'; import { formatErrorResponse, formatSuccessResponse, convertToCSV } from '../utils/formatUtils.js'; +import { listDatabases as coreListDatabases } from '../db/index.js'; /** * Execute a read-only SQL query + * @param dbId Database identifier * @param query SQL query to execute * @returns Query results */ -export async function readQuery(query: string) { +export async function readQuery(dbId: string, query: string) { try { if (!query.trim().toLowerCase().startsWith("select")) { throw new Error("Only SELECT queries are allowed with read_query"); } - const result = await dbAll(query); + const result = await dbAll(dbId, query); return formatSuccessResponse(result); } catch (error: any) { throw new Error(`SQL Error: ${error.message}`); @@ -21,10 +23,11 @@ export async function readQuery(query: string) { /** * Execute a data modification SQL query + * @param dbId Database identifier * @param query SQL query to execute * @returns Information about affected rows */ -export async function writeQuery(query: string) { +export async function writeQuery(dbId: string, query: string) { try { const lowerQuery = query.trim().toLowerCase(); @@ -36,7 +39,7 @@ export async function writeQuery(query: string) { throw new Error("Only INSERT, UPDATE, or DELETE operations are allowed with write_query"); } - const result = await dbRun(query); + const result = await dbRun(dbId, query); return formatSuccessResponse({ affected_rows: result.changes }); } catch (error: any) { throw new Error(`SQL Error: ${error.message}`); @@ -45,17 +48,18 @@ export async function writeQuery(query: string) { /** * Export query results to CSV or JSON format + * @param dbId Database identifier * @param query SQL query to execute * @param format Output format (csv or json) * @returns Formatted query results */ -export async function exportQuery(query: string, format: string) { +export async function exportQuery(dbId: string, query: string, format: string) { try { if (!query.trim().toLowerCase().startsWith("select")) { throw new Error("Only SELECT queries are allowed with export_query"); } - const result = await dbAll(query); + const result = await dbAll(dbId, query); if (format === "csv") { const csvData = convertToCSV(result); @@ -74,4 +78,10 @@ export async function exportQuery(query: string, format: string) { } catch (error: any) { throw new Error(`Export Error: ${error.message}`); } +} + +// List all available databases (tool version) +export async function listDatabasesTool() { + const databases = coreListDatabases(); + return formatSuccessResponse({ databases }); } \ No newline at end of file diff --git a/src/tools/schemaTools.ts b/src/tools/schemaTools.ts index 6a068d8..c10ac54 100644 --- a/src/tools/schemaTools.ts +++ b/src/tools/schemaTools.ts @@ -3,16 +3,16 @@ import { formatSuccessResponse } from '../utils/formatUtils.js'; /** * Create a new table in the database + * @param dbId Database identifier * @param query CREATE TABLE SQL statement * @returns Result of the operation */ -export async function createTable(query: string) { +export async function createTable(dbId: string, query: string) { try { if (!query.trim().toLowerCase().startsWith("create table")) { throw new Error("Only CREATE TABLE statements are allowed"); } - - await dbExec(query); + await dbExec(dbId, query); return formatSuccessResponse({ success: true, message: "Table created successfully" }); } catch (error: any) { throw new Error(`SQL Error: ${error.message}`); @@ -21,16 +21,16 @@ export async function createTable(query: string) { /** * Alter an existing table schema + * @param dbId Database identifier * @param query ALTER TABLE SQL statement * @returns Result of the operation */ -export async function alterTable(query: string) { +export async function alterTable(dbId: string, query: string) { try { if (!query.trim().toLowerCase().startsWith("alter table")) { throw new Error("Only ALTER TABLE statements are allowed"); } - - await dbExec(query); + await dbExec(dbId, query); return formatSuccessResponse({ success: true, message: "Table altered successfully" }); } catch (error: any) { throw new Error(`SQL Error: ${error.message}`); @@ -39,35 +39,31 @@ export async function alterTable(query: string) { /** * Drop a table from the database + * @param dbId Database identifier * @param tableName Name of the table to drop * @param confirm Safety confirmation flag * @returns Result of the operation */ -export async function dropTable(tableName: string, confirm: boolean) { +export async function dropTable(dbId: string, tableName: string, confirm: boolean) { try { if (!tableName) { throw new Error("Table name is required"); } - if (!confirm) { return formatSuccessResponse({ success: false, message: "Safety confirmation required. Set confirm=true to proceed with dropping the table." }); } - // First check if table exists by directly querying for tables - const query = getListTablesQuery(); - const tables = await dbAll(query); + const query = getListTablesQuery(dbId); + const tables = await dbAll(dbId, query); const tableNames = tables.map(t => t.name); - if (!tableNames.includes(tableName)) { throw new Error(`Table '${tableName}' does not exist`); } - // Drop the table - await dbExec(`DROP TABLE "${tableName}"`); - + await dbExec(dbId, `DROP TABLE "${tableName}"`); return formatSuccessResponse({ success: true, message: `Table '${tableName}' dropped successfully` @@ -79,13 +75,14 @@ export async function dropTable(tableName: string, confirm: boolean) { /** * List all tables in the database + * @param dbId Database identifier * @returns Array of table names */ -export async function listTables() { +export async function listTables(dbId: string) { try { // Use adapter-specific query for listing tables - const query = getListTablesQuery(); - const tables = await dbAll(query); + const query = getListTablesQuery(dbId); + const tables = await dbAll(dbId, query); return formatSuccessResponse(tables.map((t) => t.name)); } catch (error: any) { throw new Error(`Error listing tables: ${error.message}`); @@ -94,28 +91,25 @@ export async function listTables() { /** * Get schema information for a specific table + * @param dbId Database identifier * @param tableName Name of the table to describe * @returns Column definitions for the table */ -export async function describeTable(tableName: string) { +export async function describeTable(dbId: string, tableName: string) { try { if (!tableName) { throw new Error("Table name is required"); } - // First check if table exists by directly querying for tables - const query = getListTablesQuery(); - const tables = await dbAll(query); + const query = getListTablesQuery(dbId); + const tables = await dbAll(dbId, query); const tableNames = tables.map(t => t.name); - if (!tableNames.includes(tableName)) { throw new Error(`Table '${tableName}' does not exist`); } - // Use adapter-specific query for describing tables - const descQuery = getDescribeTableQuery(tableName); - const columns = await dbAll(descQuery); - + const descQuery = getDescribeTableQuery(dbId, tableName); + const columns = await dbAll(dbId, descQuery); return formatSuccessResponse(columns.map((col) => ({ name: col.name, type: col.type,