From 084db749b61c31ac8e88e322ccebc19f07a349e0 Mon Sep 17 00:00:00 2001 From: noxymon Date: Tue, 17 Mar 2026 03:38:41 +0700 Subject: [PATCH 1/2] Add multi-database configuration support Allow a single MCP server instance to connect to multiple databases simultaneously via a JSON config file (--config flag), enabling companies with multiple microservices to use one server for all DBs. Key changes: - New config loader with validation (src/config.ts) - Database registry replacing singleton adapter (src/db/index.ts) - New list_databases tool and database parameter on all DB tools - Multi-DB resource listing with db://{name}/{table}/schema URIs - Insights converted to in-memory storage for DB-agnostic operation - Full backward compatibility with single-database CLI mode Co-Authored-By: Claude Opus 4.6 --- readme.md | 72 ++++++- src/config.ts | 148 ++++++++++++++ src/db/index.ts | 171 +++++++++------- src/handlers/resourceHandlers.ts | 71 ++++--- src/handlers/toolHandlers.ts | 70 ++++--- src/index.ts | 329 +++++++++++++++++-------------- src/tools/databaseTools.ts | 10 + src/tools/insightTools.ts | 51 ++--- src/tools/queryTools.ts | 35 ++-- src/tools/schemaTools.ts | 75 +++---- 10 files changed, 645 insertions(+), 387 deletions(-) create mode 100644 src/config.ts create mode 100644 src/tools/databaseTools.ts diff --git a/readme.md b/readme.md index 24e8991..4f20cc9 100644 --- a/readme.md +++ b/readme.md @@ -24,10 +24,11 @@ npm run build ## Usage Options -There are two ways to use this MCP server with Claude: +There are three ways to use this MCP server with Claude: 1. **Direct usage**: Install the package globally and use it directly 2. **Local development**: Run from your local development environment +3. **Multi-database mode**: Connect to multiple databases simultaneously using a config file ### Direct Usage with NPM Package @@ -138,6 +139,53 @@ Required parameters: Note: SSL is automatically enabled for AWS IAM authentication +### Multi-Database Mode + +To connect to multiple databases simultaneously, create a JSON config file and use the `--config` flag: + +``` +node dist/src/index.js --config /path/to/databases.json +``` + +The config file format: + +```json +{ + "databases": [ + { + "name": "users-service", + "type": "postgresql", + "host": "localhost", + "port": 5432, + "database": "users_db", + "user": "admin", + "password": "secret" + }, + { + "name": "orders-service", + "type": "mysql", + "host": "localhost", + "port": 3306, + "database": "orders_db", + "user": "admin", + "password": "secret" + }, + { + "name": "analytics", + "type": "sqlite", + "path": "/data/analytics.db" + } + ] +} +``` + +Each database entry requires: +- `name`: A unique alias for the database +- `type`: One of `sqlite`, `sqlserver`, `postgresql`, `mysql` +- Connection fields depending on type (same as the CLI parameters above) + +When multiple databases are configured, pass the `database` parameter in tool calls to specify which database to target. Use the `list_databases` tool to see all available connections. + ## Configuring Claude Desktop ### Direct Usage Configuration @@ -209,6 +257,25 @@ If you installed the package globally, configure Claude Desktop with: } ``` +### Multi-Database Configuration + +To connect to multiple databases from a single MCP server (ideal for microservices environments): + +```json +{ + "mcpServers": { + "company-databases": { + "command": "npx", + "args": [ + "-y", + "@executeautomation/database-server", + "--config", "/path/to/databases.json" + ] + } + } +} +``` + ### Local Development Configuration For local development, configure Claude Desktop to use your locally built version: @@ -284,6 +351,7 @@ The MCP Database Server provides the following tools that Claude can use: | Tool | Description | Required Parameters | |------|-------------|---------------------| +| `list_databases` | List all configured database connections | None | | `read_query` | Execute SELECT queries to read data | `query`: SQL SELECT statement | | `write_query` | Execute INSERT, UPDATE, or DELETE queries | `query`: SQL modification statement | | `create_table` | Create new tables in the database | `query`: CREATE TABLE statement | @@ -295,6 +363,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 | +All database tools (except `append_insight`, `list_insights`, `list_databases`) accept an optional `database` parameter to specify which database to target. This parameter is required when multiple databases are configured. + For practical examples of how to use these tools with Claude, see [Usage Examples](docs/usage-examples.md). ## Additional Documentation diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e9c9da0 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,148 @@ +import { readFileSync } from 'fs'; + +export interface DatabaseConfig { + name: string; + type: string; + path?: string; + host?: string; + server?: string; + database?: string; + user?: string; + password?: string; + port?: number; + ssl?: boolean | string; + connectionTimeout?: number; + awsIamAuth?: boolean; + awsRegion?: string; +} + +export interface MultiDbConfig { + databases: DatabaseConfig[]; +} + +const VALID_TYPES = new Set(['sqlite', 'sqlserver', 'postgresql', 'postgres', 'mysql']); + +export function validateDatabaseConfig(db: DatabaseConfig, index: number): void { + if (!db.name || typeof db.name !== 'string') { + throw new Error(`databases[${index}]: "name" is required and must be a string`); + } + + if (!db.type || !VALID_TYPES.has(db.type.toLowerCase())) { + throw new Error(`databases[${index}] ("${db.name}"): "type" must be one of: sqlite, sqlserver, postgresql, mysql`); + } + + const type = db.type.toLowerCase(); + + if (type === 'sqlite') { + if (!db.path) { + throw new Error(`databases[${index}] ("${db.name}"): sqlite requires "path"`); + } + } else if (type === 'sqlserver') { + if (!db.server || !db.database) { + throw new Error(`databases[${index}] ("${db.name}"): sqlserver requires "server" and "database"`); + } + } else if (type === 'postgresql' || type === 'postgres') { + if (!db.host || !db.database) { + throw new Error(`databases[${index}] ("${db.name}"): postgresql requires "host" and "database"`); + } + } else if (type === 'mysql') { + if (!db.host || !db.database) { + throw new Error(`databases[${index}] ("${db.name}"): mysql requires "host" and "database"`); + } + if (db.awsIamAuth) { + if (!db.user) { + throw new Error(`databases[${index}] ("${db.name}"): AWS IAM authentication requires "user"`); + } + if (!db.awsRegion) { + throw new Error(`databases[${index}] ("${db.name}"): AWS IAM authentication requires "awsRegion"`); + } + } + } +} + +export function configToConnectionInfo(db: DatabaseConfig): { dbType: string; connectionInfo: any } { + const type = db.type.toLowerCase(); + + if (type === 'sqlite') { + return { dbType: 'sqlite', connectionInfo: db.path }; + } + + if (type === 'sqlserver') { + return { + dbType: 'sqlserver', + connectionInfo: { + server: db.server, + database: db.database, + user: db.user, + password: db.password, + port: db.port, + }, + }; + } + + if (type === 'postgresql' || type === 'postgres') { + return { + dbType: 'postgresql', + connectionInfo: { + host: db.host, + database: db.database, + user: db.user, + password: db.password, + port: db.port, + ssl: db.ssl, + connectionTimeout: db.connectionTimeout, + }, + }; + } + + // mysql + const connectionInfo: any = { + host: db.host, + database: db.database, + user: db.user, + password: db.password, + port: db.port, + ssl: db.awsIamAuth ? true : db.ssl, + connectionTimeout: db.connectionTimeout, + awsIamAuth: db.awsIamAuth || false, + awsRegion: db.awsRegion, + }; + return { dbType: 'mysql', connectionInfo }; +} + +export function loadConfig(filePath: string): MultiDbConfig { + let raw: string; + try { + raw = readFileSync(filePath, 'utf-8'); + } catch (err) { + throw new Error(`Failed to read config file "${filePath}": ${(err as Error).message}`); + } + + let parsed: any; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Failed to parse config file "${filePath}": ${(err as Error).message}`); + } + + if (!parsed.databases || !Array.isArray(parsed.databases)) { + throw new Error('Config must contain a "databases" array'); + } + + if (parsed.databases.length === 0) { + throw new Error('"databases" array must not be empty'); + } + + // Validate each database config + const names = new Set(); + for (let i = 0; i < parsed.databases.length; i++) { + validateDatabaseConfig(parsed.databases[i], i); + const name = parsed.databases[i].name; + if (names.has(name)) { + throw new Error(`Duplicate database name: "${name}"`); + } + names.add(name); + } + + return parsed as MultiDbConfig; +} diff --git a/src/db/index.ts b/src/db/index.ts index f883f2b..5b6bdc7 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,105 +1,132 @@ import { DbAdapter, createDbAdapter } from './adapter.js'; +import { DatabaseConfig, configToConnectionInfo } from '../config.js'; -// Store the active database adapter -let dbAdapter: DbAdapter | null = null; +// Database adapter registry +const adapters: Map = new Map(); +let defaultDbName: string | null = null; /** - * Initialize the database connection - * @param connectionInfo Connection information object or SQLite path string - * @param dbType Database type ('sqlite' or 'sqlserver') + * Resolve a database name to a registered adapter name. + * If only one database is registered and no name is given, returns the default. */ -export async function initDatabase(connectionInfo: any, dbType: string = 'sqlite'): Promise { +export function resolveDbName(dbName?: string): string { + if (dbName) { + if (!adapters.has(dbName)) { + const available = Array.from(adapters.keys()).join(', '); + throw new Error(`Database "${dbName}" not found. Available databases: ${available}`); + } + return dbName; + } + + if (defaultDbName && adapters.size === 1) { + return defaultDbName; + } + + if (adapters.size === 0) { + throw new Error("No databases initialized"); + } + + const available = Array.from(adapters.keys()).join(', '); + throw new Error(`"database" parameter is required when multiple databases are configured. Available databases: ${available}`); +} + +function getAdapter(dbName?: string): DbAdapter { + const name = resolveDbName(dbName); + return adapters.get(name)!; +} + +/** + * Initialize a single database connection and register it + */ +export async function initDatabase(connectionInfo: any, dbType: string = 'sqlite', dbName: string = 'default'): 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(); + + adapters.set(dbName, adapter); + if (!defaultDbName) { + defaultDbName = dbName; + } } catch (error) { - throw new Error(`Failed to initialize database: ${(error as Error).message}`); + throw new Error(`Failed to initialize database "${dbName}": ${(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 + * Initialize multiple databases from config. + * Uses Promise.allSettled so partial failures don't block the server. */ -export function dbAll(query: string, params: any[] = []): Promise { - if (!dbAdapter) { - throw new Error("Database not initialized"); +export async function initAllDatabases(configs: DatabaseConfig[]): Promise<{ successes: string[]; failures: { name: string; error: string }[] }> { + const results = await Promise.allSettled( + configs.map(async (cfg) => { + const { dbType, connectionInfo } = configToConnectionInfo(cfg); + await initDatabase(connectionInfo, dbType, cfg.name); + return cfg.name; + }) + ); + + const successes: string[] = []; + const failures: { name: string; error: string }[] = []; + + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + successes.push(result.value); + } else { + failures.push({ name: configs[i].name, error: result.reason?.message || String(result.reason) }); + } + }); + + if (successes.length === 0) { + throw new Error(`All database connections failed:\n${failures.map(f => ` - ${f.name}: ${f.error}`).join('\n')}`); } - return dbAdapter.all(query, params); + + return { successes, failures }; } /** - * Execute a SQL query that modifies data - * @param query SQL query to execute - * @param params Query parameters - * @returns Promise with result info + * List all registered databases */ -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 listRegisteredDatabases(): Array<{ name: string; type: string }> { + return Array.from(adapters.entries()).map(([name, adapter]) => { + const meta = adapter.getMetadata(); + return { name, type: meta.type }; + }); } -/** - * Execute multiple SQL statements - * @param query SQL statements to execute - * @returns Promise that resolves when execution completes - */ -export function dbExec(query: string): Promise { - if (!dbAdapter) { - throw new Error("Database not initialized"); - } - return dbAdapter.exec(query); +export function dbAll(query: string, params: any[] = [], dbName?: string): Promise { + return getAdapter(dbName).all(query, params); } -/** - * Close the database connection - */ -export function closeDatabase(): Promise { - if (!dbAdapter) { - return Promise.resolve(); - } - return dbAdapter.close(); +export function dbRun(query: string, params: any[] = [], dbName?: string): Promise<{ changes: number; lastID: number }> { + return getAdapter(dbName).run(query, params); } -/** - * Get database metadata - */ -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 function dbExec(query: string, dbName?: string): Promise { + return getAdapter(dbName).exec(query); } /** - * Get database-specific query for listing tables + * Close all database connections */ -export function getListTablesQuery(): string { - if (!dbAdapter) { - throw new Error("Database not initialized"); - } - return dbAdapter.getListTablesQuery(); +export async function closeDatabase(): Promise { + const closePromises = Array.from(adapters.values()).map(adapter => adapter.close()); + await Promise.allSettled(closePromises); + adapters.clear(); + defaultDbName = null; } -/** - * Get database-specific query for describing a table - * @param tableName Table name - */ -export function getDescribeTableQuery(tableName: string): string { - if (!dbAdapter) { - throw new Error("Database not initialized"); - } - return dbAdapter.getDescribeTableQuery(tableName); -} \ No newline at end of file +export function getDatabaseMetadata(dbName?: string): { name: string; type: string; path?: string; server?: string; database?: string } { + return getAdapter(dbName).getMetadata(); +} + +export function getListTablesQuery(dbName?: string): string { + return getAdapter(dbName).getListTablesQuery(); +} + +export function getDescribeTableQuery(tableName: string, dbName?: string): string { + return getAdapter(dbName).getDescribeTableQuery(tableName); +} diff --git a/src/handlers/resourceHandlers.ts b/src/handlers/resourceHandlers.ts index 100a9bd..8cf62fa 100644 --- a/src/handlers/resourceHandlers.ts +++ b/src/handlers/resourceHandlers.ts @@ -1,63 +1,58 @@ -import { dbAll, getListTablesQuery, getDescribeTableQuery, getDatabaseMetadata } from '../db/index.js'; +import { dbAll, getListTablesQuery, getDescribeTableQuery, listRegisteredDatabases } from '../db/index.js'; /** - * Handle listing resources request - * @returns List of available resources + * Handle listing resources request. + * Iterates all registered databases and lists table schemas for each. */ export async function handleListResources() { try { - const dbInfo = getDatabaseMetadata(); - const dbType = dbInfo.type; - let resourceBaseUrl: URL; - - // Create appropriate URL based on database type - if (dbType === 'sqlite' && dbInfo.path) { - resourceBaseUrl = new URL(`sqlite:///${dbInfo.path}`); - } else if (dbType === 'sqlserver' && dbInfo.server && dbInfo.database) { - resourceBaseUrl = new URL(`sqlserver://${dbInfo.server}/${dbInfo.database}`); - } else { - resourceBaseUrl = new URL(`db:///database`); + const databases = listRegisteredDatabases(); + const allResources: any[] = []; + + for (const db of databases) { + try { + const query = getListTablesQuery(db.name); + const tables = await dbAll(query, [], db.name); + + for (const row of tables) { + allResources.push({ + uri: `db://${db.name}/${row.name}/schema`, + mimeType: "application/json", + name: `"${row.name}" schema (${db.name})`, + }); + } + } catch { + // Skip databases that fail to list tables (e.g. connection issues) + } } - - const SCHEMA_PATH = "schema"; - // Use adapter-specific query to list tables - const query = getListTablesQuery(); - const result = await dbAll(query); - - return { - resources: result.map((row: any) => ({ - uri: new URL(`${row.name}/${SCHEMA_PATH}`, resourceBaseUrl).href, - mimeType: "application/json", - name: `"${row.name}" database schema`, - })), - }; + return { resources: allResources }; } catch (error: any) { throw new Error(`Error listing resources: ${error.message}`); } } /** - * Handle reading a specific resource - * @param uri URI of the resource to read - * @returns Resource contents + * Handle reading a specific resource. + * URI format: db://{dbName}/{tableName}/schema */ export async function handleReadResource(uri: string) { try { const resourceUrl = new URL(uri); const SCHEMA_PATH = "schema"; - const pathComponents = resourceUrl.pathname.split("/"); - const schema = pathComponents.pop(); - const tableName = pathComponents.pop(); + // Parse db name from host and table name from path + const dbName = resourceUrl.hostname; + const pathComponents = resourceUrl.pathname.split("/").filter(Boolean); - if (schema !== SCHEMA_PATH) { + if (pathComponents.length < 2 || pathComponents[pathComponents.length - 1] !== SCHEMA_PATH) { throw new Error("Invalid resource URI"); } - // Use adapter-specific query to describe the table - const query = getDescribeTableQuery(tableName!); - const result = await dbAll(query); + const tableName = pathComponents[pathComponents.length - 2]; + + const query = getDescribeTableQuery(tableName, dbName); + const result = await dbAll(query, [], dbName); return { contents: [ @@ -74,4 +69,4 @@ export async function handleReadResource(uri: string) { } catch (error: any) { throw new Error(`Error reading resource: ${error.message}`); } -} \ No newline at end of file +} diff --git a/src/handlers/toolHandlers.ts b/src/handlers/toolHandlers.ts index 463bcc3..4f005e3 100644 --- a/src/handlers/toolHandlers.ts +++ b/src/handlers/toolHandlers.ts @@ -4,14 +4,27 @@ import { formatErrorResponse } from '../utils/formatUtils.js'; import { readQuery, writeQuery, exportQuery } from '../tools/queryTools.js'; import { createTable, alterTable, dropTable, listTables, describeTable } from '../tools/schemaTools.js'; import { appendInsight, listInsights } from '../tools/insightTools.js'; +import { listDatabases } from '../tools/databaseTools.js'; + +const databaseProperty = { + type: "string", + description: "Name of the database to operate on. Required when multiple databases are configured.", +}; /** * Handle listing available tools - * @returns List of available tools */ export function handleListTools() { return { tools: [ + { + name: "list_databases", + description: "List all configured database connections and their types", + inputSchema: { + type: "object", + properties: {}, + }, + }, { name: "read_query", description: "Execute SELECT queries to read data from the database", @@ -19,6 +32,7 @@ export function handleListTools() { type: "object", properties: { query: { type: "string" }, + database: databaseProperty, }, required: ["query"], }, @@ -30,6 +44,7 @@ export function handleListTools() { type: "object", properties: { query: { type: "string" }, + database: databaseProperty, }, required: ["query"], }, @@ -41,6 +56,7 @@ export function handleListTools() { type: "object", properties: { query: { type: "string" }, + database: databaseProperty, }, required: ["query"], }, @@ -52,6 +68,7 @@ export function handleListTools() { type: "object", properties: { query: { type: "string" }, + database: databaseProperty, }, required: ["query"], }, @@ -64,6 +81,7 @@ export function handleListTools() { properties: { table_name: { type: "string" }, confirm: { type: "boolean" }, + database: databaseProperty, }, required: ["table_name", "confirm"], }, @@ -76,6 +94,7 @@ export function handleListTools() { properties: { query: { type: "string" }, format: { type: "string", enum: ["csv", "json"] }, + database: databaseProperty, }, required: ["query", "format"], }, @@ -85,7 +104,9 @@ export function handleListTools() { description: "Get a list of all tables in the database", inputSchema: { type: "object", - properties: {}, + properties: { + database: databaseProperty, + }, }, }, { @@ -95,6 +116,7 @@ export function handleListTools() { type: "object", properties: { table_name: { type: "string" }, + database: databaseProperty, }, required: ["table_name"], }, @@ -124,47 +146,47 @@ export function handleListTools() { /** * Handle tool call requests - * @param name Name of the tool to call - * @param args Arguments for the tool - * @returns Tool execution result */ export async function handleToolCall(name: string, args: any) { try { switch (name) { + case "list_databases": + return await listDatabases(); + case "read_query": - return await readQuery(args.query); - + return await readQuery(args.query, args.database); + case "write_query": - return await writeQuery(args.query); - + return await writeQuery(args.query, args.database); + case "create_table": - return await createTable(args.query); - + return await createTable(args.query, args.database); + case "alter_table": - return await alterTable(args.query); - + return await alterTable(args.query, args.database); + case "drop_table": - return await dropTable(args.table_name, args.confirm); - + return await dropTable(args.table_name, args.confirm, args.database); + case "export_query": - return await exportQuery(args.query, args.format); - + return await exportQuery(args.query, args.format, args.database); + case "list_tables": - return await listTables(); - + return await listTables(args.database); + case "describe_table": - return await describeTable(args.table_name); - + return await describeTable(args.table_name, args.database); + case "append_insight": return await appendInsight(args.insight); - + case "list_insights": return await listInsights(); - + default: throw new Error(`Unknown tool: ${name}`); } } catch (error: any) { return formatErrorResponse(error); } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 9bf7fda..eaa4180 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,10 @@ import { } from "@modelcontextprotocol/sdk/types.js"; // Import database utils -import { initDatabase, closeDatabase, getDatabaseMetadata } from './db/index.js'; +import { initDatabase, initAllDatabases, closeDatabase } from './db/index.js'; + +// Import config loader +import { loadConfig } from './config.js'; // Import handlers import { handleListResources, handleReadResource } from './handlers/resourceHandlers.js'; @@ -28,7 +31,7 @@ const logger = { const server = new Server( { name: "executeautomation/database-server", - version: "1.1.0", + version: "1.2.0", }, { capabilities: { @@ -47,146 +50,187 @@ if (args.length === 0) { 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("Usage for MySQL with AWS IAM: node index.js --mysql --aws-iam-auth --host --database --user --aws-region "); + logger.error("Usage for Multi-DB: node index.js --config "); process.exit(1); } -// Parse arguments to determine database type and connection info -let dbType = 'sqlite'; -let connectionInfo: any = null; - -// Check if using SQL Server -if (args.includes('--sqlserver')) { - dbType = 'sqlserver'; - connectionInfo = { - server: '', - database: '', - user: undefined, - password: undefined - }; - - // Parse SQL Server connection parameters - for (let i = 0; i < args.length; i++) { - if (args[i] === '--server' && i + 1 < args.length) { - connectionInfo.server = args[i + 1]; - } else if (args[i] === '--database' && i + 1 < args.length) { - connectionInfo.database = args[i + 1]; - } else if (args[i] === '--user' && i + 1 < args.length) { - connectionInfo.user = args[i + 1]; - } else if (args[i] === '--password' && i + 1 < args.length) { - connectionInfo.password = args[i + 1]; - } else if (args[i] === '--port' && i + 1 < args.length) { - connectionInfo.port = parseInt(args[i + 1], 10); - } - } - - // Validate SQL Server connection info - if (!connectionInfo.server || !connectionInfo.database) { - logger.error("Error: SQL Server requires --server and --database parameters"); - process.exit(1); - } -} -// Check if using PostgreSQL -else if (args.includes('--postgresql') || args.includes('--postgres')) { - dbType = 'postgresql'; - connectionInfo = { - host: '', - database: '', - user: undefined, - password: undefined, - port: undefined, - ssl: undefined, - connectionTimeout: undefined - }; - - // Parse PostgreSQL connection parameters - for (let i = 0; i < args.length; i++) { - if (args[i] === '--host' && i + 1 < args.length) { - connectionInfo.host = args[i + 1]; - } else if (args[i] === '--database' && i + 1 < args.length) { - connectionInfo.database = args[i + 1]; - } else if (args[i] === '--user' && i + 1 < args.length) { - connectionInfo.user = args[i + 1]; - } else if (args[i] === '--password' && i + 1 < args.length) { - connectionInfo.password = args[i + 1]; - } else if (args[i] === '--port' && i + 1 < args.length) { - connectionInfo.port = parseInt(args[i + 1], 10); - } else if (args[i] === '--ssl' && i + 1 < args.length) { - connectionInfo.ssl = args[i + 1] === 'true'; - } else if (args[i] === '--connection-timeout' && i + 1 < args.length) { - connectionInfo.connectionTimeout = parseInt(args[i + 1], 10); - } +/** + * Initialize databases from a config file (multi-database mode) + */ +async function initFromConfig(configPath: string) { + logger.info(`Loading multi-database config from: ${configPath}`); + const config = loadConfig(configPath); + logger.info(`Found ${config.databases.length} database(s) in config`); + + const { successes, failures } = await initAllDatabases(config.databases); + + for (const name of successes) { + logger.info(`Connected to database: ${name}`); } - - // Validate PostgreSQL connection info - if (!connectionInfo.host || !connectionInfo.database) { - logger.error("Error: PostgreSQL requires --host and --database parameters"); - process.exit(1); + for (const f of failures) { + logger.warn(`Failed to connect to database "${f.name}": ${f.error}`); } + + logger.info(`${successes.length}/${config.databases.length} database(s) connected successfully`); } -// Check if using MySQL -else if (args.includes('--mysql')) { - dbType = 'mysql'; - connectionInfo = { - host: '', - database: '', - user: undefined, - password: undefined, - port: undefined, - ssl: undefined, - connectionTimeout: undefined, - awsIamAuth: false, - awsRegion: undefined - }; - // Parse MySQL connection parameters - for (let i = 0; i < args.length; i++) { - if (args[i] === '--host' && i + 1 < args.length) { - connectionInfo.host = args[i + 1]; - } else if (args[i] === '--database' && i + 1 < args.length) { - connectionInfo.database = args[i + 1]; - } else if (args[i] === '--user' && i + 1 < args.length) { - connectionInfo.user = args[i + 1]; - } else if (args[i] === '--password' && i + 1 < args.length) { - connectionInfo.password = args[i + 1]; - } else if (args[i] === '--port' && i + 1 < args.length) { - connectionInfo.port = parseInt(args[i + 1], 10); - } else if (args[i] === '--ssl' && i + 1 < args.length) { - const sslVal = args[i + 1]; - if (sslVal === 'true') connectionInfo.ssl = true; - else if (sslVal === 'false') connectionInfo.ssl = false; - else connectionInfo.ssl = sslVal; - } else if (args[i] === '--connection-timeout' && i + 1 < args.length) { - connectionInfo.connectionTimeout = parseInt(args[i + 1], 10); - } else if (args[i] === '--aws-iam-auth') { - connectionInfo.awsIamAuth = true; - } else if (args[i] === '--aws-region' && i + 1 < args.length) { - connectionInfo.awsRegion = args[i + 1]; + +/** + * Initialize a single database from CLI arguments (legacy mode) + */ +async function initFromCliArgs() { + let dbType = 'sqlite'; + let connectionInfo: any = null; + let dbName = 'default'; + + // Check if using SQL Server + if (args.includes('--sqlserver')) { + dbType = 'sqlserver'; + connectionInfo = { + server: '', + database: '', + user: undefined, + password: undefined + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--server' && i + 1 < args.length) { + connectionInfo.server = args[i + 1]; + } else if (args[i] === '--database' && i + 1 < args.length) { + connectionInfo.database = args[i + 1]; + } else if (args[i] === '--user' && i + 1 < args.length) { + connectionInfo.user = args[i + 1]; + } else if (args[i] === '--password' && i + 1 < args.length) { + connectionInfo.password = args[i + 1]; + } else if (args[i] === '--port' && i + 1 < args.length) { + connectionInfo.port = parseInt(args[i + 1], 10); + } } + + if (!connectionInfo.server || !connectionInfo.database) { + logger.error("Error: SQL Server requires --server and --database parameters"); + process.exit(1); + } + + dbName = connectionInfo.database; } - // Validate MySQL connection info - if (!connectionInfo.host || !connectionInfo.database) { - logger.error("Error: MySQL requires --host and --database parameters"); - process.exit(1); - } - - // Additional validation for AWS IAM authentication - if (connectionInfo.awsIamAuth) { - if (!connectionInfo.user) { - logger.error("Error: AWS IAM authentication requires --user parameter"); + // Check if using PostgreSQL + else if (args.includes('--postgresql') || args.includes('--postgres')) { + dbType = 'postgresql'; + connectionInfo = { + host: '', + database: '', + user: undefined, + password: undefined, + port: undefined, + ssl: undefined, + connectionTimeout: undefined + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--host' && i + 1 < args.length) { + connectionInfo.host = args[i + 1]; + } else if (args[i] === '--database' && i + 1 < args.length) { + connectionInfo.database = args[i + 1]; + } else if (args[i] === '--user' && i + 1 < args.length) { + connectionInfo.user = args[i + 1]; + } else if (args[i] === '--password' && i + 1 < args.length) { + connectionInfo.password = args[i + 1]; + } else if (args[i] === '--port' && i + 1 < args.length) { + connectionInfo.port = parseInt(args[i + 1], 10); + } else if (args[i] === '--ssl' && i + 1 < args.length) { + connectionInfo.ssl = args[i + 1] === 'true'; + } else if (args[i] === '--connection-timeout' && i + 1 < args.length) { + connectionInfo.connectionTimeout = parseInt(args[i + 1], 10); + } + } + + if (!connectionInfo.host || !connectionInfo.database) { + logger.error("Error: PostgreSQL requires --host and --database parameters"); process.exit(1); } - if (!connectionInfo.awsRegion) { - logger.error("Error: AWS IAM authentication requires --aws-region parameter"); + + dbName = connectionInfo.database; + } + // Check if using MySQL + else if (args.includes('--mysql')) { + dbType = 'mysql'; + connectionInfo = { + host: '', + database: '', + user: undefined, + password: undefined, + port: undefined, + ssl: undefined, + connectionTimeout: undefined, + awsIamAuth: false, + awsRegion: undefined + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--host' && i + 1 < args.length) { + connectionInfo.host = args[i + 1]; + } else if (args[i] === '--database' && i + 1 < args.length) { + connectionInfo.database = args[i + 1]; + } else if (args[i] === '--user' && i + 1 < args.length) { + connectionInfo.user = args[i + 1]; + } else if (args[i] === '--password' && i + 1 < args.length) { + connectionInfo.password = args[i + 1]; + } else if (args[i] === '--port' && i + 1 < args.length) { + connectionInfo.port = parseInt(args[i + 1], 10); + } else if (args[i] === '--ssl' && i + 1 < args.length) { + const sslVal = args[i + 1]; + if (sslVal === 'true') connectionInfo.ssl = true; + else if (sslVal === 'false') connectionInfo.ssl = false; + else connectionInfo.ssl = sslVal; + } else if (args[i] === '--connection-timeout' && i + 1 < args.length) { + connectionInfo.connectionTimeout = parseInt(args[i + 1], 10); + } else if (args[i] === '--aws-iam-auth') { + connectionInfo.awsIamAuth = true; + } else if (args[i] === '--aws-region' && i + 1 < args.length) { + connectionInfo.awsRegion = args[i + 1]; + } + } + + if (!connectionInfo.host || !connectionInfo.database) { + logger.error("Error: MySQL requires --host and --database parameters"); process.exit(1); } - // Automatically enable SSL for AWS IAM authentication (required) - connectionInfo.ssl = true; - logger.info("AWS IAM authentication enabled - SSL automatically configured"); + + if (connectionInfo.awsIamAuth) { + if (!connectionInfo.user) { + logger.error("Error: AWS IAM authentication requires --user parameter"); + process.exit(1); + } + if (!connectionInfo.awsRegion) { + logger.error("Error: AWS IAM authentication requires --aws-region parameter"); + process.exit(1); + } + connectionInfo.ssl = true; + logger.info("AWS IAM authentication enabled - SSL automatically configured"); + } + + dbName = connectionInfo.database; + } else { + // SQLite mode (default) + dbType = 'sqlite'; + connectionInfo = args[0]; + dbName = 'default'; + logger.info(`Using SQLite database at path: ${connectionInfo}`); + } + + 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}`); } -} else { - // SQLite mode (default) - dbType = 'sqlite'; - connectionInfo = args[0]; // First argument is the SQLite file path - logger.info(`Using SQLite database at path: ${connectionInfo}`); + + await initDatabase(connectionInfo, dbType, dbName); + logger.info(`Connected to ${dbType} database as "${dbName}"`); } // Set up request handlers @@ -233,27 +277,18 @@ 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}`); + // Determine startup mode + const configIndex = args.indexOf('--config'); + if (configIndex !== -1 && configIndex + 1 < args.length) { + await initFromConfig(args[configIndex + 1]); + } else { + await initFromCliArgs(); } - - // 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); - + logger.info('Server running. Press Ctrl+C to exit.'); } catch (error) { logger.error("Failed to initialize:", error); @@ -265,4 +300,4 @@ async function runServer() { runServer().catch(error => { logger.error("Server initialization failed:", error); process.exit(1); -}); \ No newline at end of file +}); diff --git a/src/tools/databaseTools.ts b/src/tools/databaseTools.ts new file mode 100644 index 0000000..fc1460d --- /dev/null +++ b/src/tools/databaseTools.ts @@ -0,0 +1,10 @@ +import { listRegisteredDatabases } from '../db/index.js'; +import { formatSuccessResponse } from '../utils/formatUtils.js'; + +/** + * List all configured database connections + */ +export async function listDatabases() { + const databases = listRegisteredDatabases(); + return formatSuccessResponse(databases); +} diff --git a/src/tools/insightTools.ts b/src/tools/insightTools.ts index 0221da1..37efb38 100644 --- a/src/tools/insightTools.ts +++ b/src/tools/insightTools.ts @@ -1,10 +1,11 @@ -import { dbAll, dbExec, dbRun } from '../db/index.js'; import { formatSuccessResponse } from '../utils/formatUtils.js'; +// In-memory insight storage (database-agnostic, works with any DB type) +const insights: Array<{ id: number; insight: string; created_at: string }> = []; +let nextId = 1; + /** * Add a business insight to the memo - * @param insight Business insight text - * @returns Result of the operation */ export async function appendInsight(insight: string) { try { @@ -12,21 +13,12 @@ export async function appendInsight(insight: string) { 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] - ); - + insights.push({ + id: nextId++, + insight, + created_at: new Date().toISOString(), + }); + return formatSuccessResponse({ success: true, message: "Insight added" }); } catch (error: any) { throw new Error(`Error adding insight: ${error.message}`); @@ -35,30 +27,11 @@ export async function appendInsight(insight: string) { /** * List all insights in the memo - * @returns Array of insights */ export async function listInsights() { try { - // Check if insights table exists - const tableExists = await dbAll( - "SELECT name FROM sqlite_master WHERE type='table' AND name = 'mcp_insights'" - ); - - 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([...insights].reverse()); } catch (error: any) { throw new Error(`Error listing insights: ${error.message}`); } -} \ No newline at end of file +} diff --git a/src/tools/queryTools.ts b/src/tools/queryTools.ts index 8bb465a..7a78b93 100644 --- a/src/tools/queryTools.ts +++ b/src/tools/queryTools.ts @@ -1,18 +1,16 @@ -import { dbAll, dbRun, dbExec } from '../db/index.js'; -import { formatErrorResponse, formatSuccessResponse, convertToCSV } from '../utils/formatUtils.js'; +import { dbAll, dbRun } from '../db/index.js'; +import { formatSuccessResponse, convertToCSV } from '../utils/formatUtils.js'; /** * Execute a read-only SQL query - * @param query SQL query to execute - * @returns Query results */ -export async function readQuery(query: string) { +export async function readQuery(query: string, dbName?: 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(query, [], dbName); return formatSuccessResponse(result); } catch (error: any) { throw new Error(`SQL Error: ${error.message}`); @@ -21,22 +19,20 @@ export async function readQuery(query: string) { /** * Execute a data modification SQL query - * @param query SQL query to execute - * @returns Information about affected rows */ -export async function writeQuery(query: string) { +export async function writeQuery(query: string, dbName?: string) { try { const lowerQuery = query.trim().toLowerCase(); - + if (lowerQuery.startsWith("select")) { throw new Error("Use read_query for SELECT operations"); } - + if (!(lowerQuery.startsWith("insert") || lowerQuery.startsWith("update") || lowerQuery.startsWith("delete"))) { throw new Error("Only INSERT, UPDATE, or DELETE operations are allowed with write_query"); } - const result = await dbRun(query); + const result = await dbRun(query, [], dbName); return formatSuccessResponse({ affected_rows: result.changes }); } catch (error: any) { throw new Error(`SQL Error: ${error.message}`); @@ -45,23 +41,20 @@ export async function writeQuery(query: string) { /** * Export query results to CSV or JSON format - * @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(query: string, format: string, dbName?: 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(query, [], dbName); + if (format === "csv") { const csvData = convertToCSV(result); return { - content: [{ - type: "text", + content: [{ + type: "text", text: csvData }], isError: false, @@ -74,4 +67,4 @@ export async function exportQuery(query: string, format: string) { } catch (error: any) { throw new Error(`Export Error: ${error.message}`); } -} \ No newline at end of file +} diff --git a/src/tools/schemaTools.ts b/src/tools/schemaTools.ts index 6a068d8..b2ee2f9 100644 --- a/src/tools/schemaTools.ts +++ b/src/tools/schemaTools.ts @@ -3,16 +3,14 @@ import { formatSuccessResponse } from '../utils/formatUtils.js'; /** * Create a new table in the database - * @param query CREATE TABLE SQL statement - * @returns Result of the operation */ -export async function createTable(query: string) { +export async function createTable(query: string, dbName?: string) { try { if (!query.trim().toLowerCase().startsWith("create table")) { throw new Error("Only CREATE TABLE statements are allowed"); } - await dbExec(query); + await dbExec(query, dbName); return formatSuccessResponse({ success: true, message: "Table created successfully" }); } catch (error: any) { throw new Error(`SQL Error: ${error.message}`); @@ -21,16 +19,14 @@ export async function createTable(query: string) { /** * Alter an existing table schema - * @param query ALTER TABLE SQL statement - * @returns Result of the operation */ -export async function alterTable(query: string) { +export async function alterTable(query: string, dbName?: string) { try { if (!query.trim().toLowerCase().startsWith("alter table")) { throw new Error("Only ALTER TABLE statements are allowed"); } - await dbExec(query); + await dbExec(query, dbName); return formatSuccessResponse({ success: true, message: "Table altered successfully" }); } catch (error: any) { throw new Error(`SQL Error: ${error.message}`); @@ -39,38 +35,33 @@ export async function alterTable(query: string) { /** * Drop a table from the database - * @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(tableName: string, confirm: boolean, dbName?: string) { 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." + 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(dbName); + const tables = await dbAll(query, [], dbName); 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}"`); - - return formatSuccessResponse({ - success: true, - message: `Table '${tableName}' dropped successfully` + + await dbExec(`DROP TABLE "${tableName}"`, dbName); + + return formatSuccessResponse({ + success: true, + message: `Table '${tableName}' dropped successfully` }); } catch (error: any) { throw new Error(`Error dropping table: ${error.message}`); @@ -79,13 +70,11 @@ export async function dropTable(tableName: string, confirm: boolean) { /** * List all tables in the database - * @returns Array of table names */ -export async function listTables() { +export async function listTables(dbName?: string) { try { - // Use adapter-specific query for listing tables - const query = getListTablesQuery(); - const tables = await dbAll(query); + const query = getListTablesQuery(dbName); + const tables = await dbAll(query, [], dbName); return formatSuccessResponse(tables.map((t) => t.name)); } catch (error: any) { throw new Error(`Error listing tables: ${error.message}`); @@ -94,28 +83,24 @@ export async function listTables() { /** * Get schema information for a specific table - * @param tableName Name of the table to describe - * @returns Column definitions for the table */ -export async function describeTable(tableName: string) { +export async function describeTable(tableName: string, dbName?: 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(dbName); + const tables = await dbAll(query, [], dbName); 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(tableName, dbName); + const columns = await dbAll(descQuery, [], dbName); + return formatSuccessResponse(columns.map((col) => ({ name: col.name, type: col.type, @@ -126,4 +111,4 @@ export async function describeTable(tableName: string) { } catch (error: any) { throw new Error(`Error describing table: ${error.message}`); } -} \ No newline at end of file +} From fe05e3ea0ca5bfed3d684ea509111461b4702131 Mon Sep 17 00:00:00 2001 From: noxymon Date: Tue, 17 Mar 2026 03:42:15 +0700 Subject: [PATCH 2/2] Support inline JSON and env var for --config (Kubernetes-friendly) --config now auto-detects JSON strings vs file paths (starts with '{'). Added --config-env to read config from an environment variable, ideal for Kubernetes Secrets where the JSON is injected as an env var rather than mounted as a file. Co-Authored-By: Claude Opus 4.6 --- readme.md | 44 ++++++++++++++++++++++++++++++++++++++++++-- src/config.ts | 49 +++++++++++++++++++++++++++++++++++++------------ src/index.ts | 24 +++++++++++++++++------- 3 files changed, 96 insertions(+), 21 deletions(-) diff --git a/readme.md b/readme.md index 4f20cc9..d7ef0fa 100644 --- a/readme.md +++ b/readme.md @@ -141,13 +141,26 @@ Note: SSL is automatically enabled for AWS IAM authentication ### Multi-Database Mode -To connect to multiple databases simultaneously, create a JSON config file and use the `--config` flag: +To connect to multiple databases simultaneously, use one of the following: +**From a config file:** ``` node dist/src/index.js --config /path/to/databases.json ``` -The config file format: +**From an inline JSON string (useful for Kubernetes/Docker):** +``` +node dist/src/index.js --config '{"databases":[...]}' +``` + +**From an environment variable (ideal for Kubernetes Secrets):** +``` +node dist/src/index.js --config-env DB_CONFIG +``` + +The `--config` flag auto-detects whether the value is a JSON string (starts with `{`) or a file path. The `--config-env` flag reads the JSON config from the named environment variable. + +The config JSON format: ```json { @@ -261,6 +274,7 @@ If you installed the package globally, configure Claude Desktop with: To connect to multiple databases from a single MCP server (ideal for microservices environments): +**Using a config file:** ```json { "mcpServers": { @@ -276,6 +290,32 @@ To connect to multiple databases from a single MCP server (ideal for microservic } ``` +**Using an environment variable (Kubernetes / Docker):** +```json +{ + "mcpServers": { + "company-databases": { + "command": "npx", + "args": [ + "-y", + "@executeautomation/database-server", + "--config-env", "DB_CONFIG" + ] + } + } +} +``` + +In Kubernetes, set `DB_CONFIG` from a Secret: +```yaml +env: + - name: DB_CONFIG + valueFrom: + secretKeyRef: + name: mcp-db-config + key: config.json +``` + ### Local Development Configuration For local development, configure Claude Desktop to use your locally built version: diff --git a/src/config.ts b/src/config.ts index e9c9da0..5d254c5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -110,30 +110,25 @@ export function configToConnectionInfo(db: DatabaseConfig): { dbType: string; co return { dbType: 'mysql', connectionInfo }; } -export function loadConfig(filePath: string): MultiDbConfig { - let raw: string; - try { - raw = readFileSync(filePath, 'utf-8'); - } catch (err) { - throw new Error(`Failed to read config file "${filePath}": ${(err as Error).message}`); - } - +/** + * Parse and validate a JSON string as MultiDbConfig + */ +export function parseConfig(raw: string, source: string = 'config'): MultiDbConfig { let parsed: any; try { parsed = JSON.parse(raw); } catch (err) { - throw new Error(`Failed to parse config file "${filePath}": ${(err as Error).message}`); + throw new Error(`Failed to parse ${source}: ${(err as Error).message}`); } if (!parsed.databases || !Array.isArray(parsed.databases)) { - throw new Error('Config must contain a "databases" array'); + throw new Error(`${source} must contain a "databases" array`); } if (parsed.databases.length === 0) { - throw new Error('"databases" array must not be empty'); + throw new Error(`${source}: "databases" array must not be empty`); } - // Validate each database config const names = new Set(); for (let i = 0; i < parsed.databases.length; i++) { validateDatabaseConfig(parsed.databases[i], i); @@ -146,3 +141,33 @@ export function loadConfig(filePath: string): MultiDbConfig { return parsed as MultiDbConfig; } + +/** + * Load config from a file path or inline JSON string. + * Auto-detects: if value starts with '{', treats as JSON; otherwise reads as file. + */ +export function loadConfig(filePathOrJson: string): MultiDbConfig { + if (filePathOrJson.trimStart().startsWith('{')) { + return parseConfig(filePathOrJson, 'inline JSON config'); + } + + let raw: string; + try { + raw = readFileSync(filePathOrJson, 'utf-8'); + } catch (err) { + throw new Error(`Failed to read config file "${filePathOrJson}": ${(err as Error).message}`); + } + + return parseConfig(raw, `config file "${filePathOrJson}"`); +} + +/** + * Load config from an environment variable by name. + */ +export function loadConfigFromEnv(envVarName: string): MultiDbConfig { + const value = process.env[envVarName]; + if (!value) { + throw new Error(`Environment variable "${envVarName}" is not set or empty`); + } + return parseConfig(value, `environment variable "${envVarName}"`); +} diff --git a/src/index.ts b/src/index.ts index eaa4180..2615a7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { import { initDatabase, initAllDatabases, closeDatabase } from './db/index.js'; // Import config loader -import { loadConfig } from './config.js'; +import { loadConfig, loadConfigFromEnv } from './config.js'; // Import handlers import { handleListResources, handleReadResource } from './handlers/resourceHandlers.js'; @@ -50,16 +50,23 @@ if (args.length === 0) { 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("Usage for MySQL with AWS IAM: node index.js --mysql --aws-iam-auth --host --database --user --aws-region "); - logger.error("Usage for Multi-DB: node index.js --config "); + logger.error("Usage for Multi-DB: node index.js --config "); + logger.error("Usage for Multi-DB (env): node index.js --config-env "); process.exit(1); } /** - * Initialize databases from a config file (multi-database mode) + * Initialize databases from a config (file path, JSON string, or env var) */ -async function initFromConfig(configPath: string) { - logger.info(`Loading multi-database config from: ${configPath}`); - const config = loadConfig(configPath); +async function initFromConfig(configPathOrJson: string, fromEnv?: string) { + if (fromEnv) { + logger.info(`Loading multi-database config from env var: ${fromEnv}`); + } else if (configPathOrJson.trimStart().startsWith('{')) { + logger.info('Loading multi-database config from inline JSON'); + } else { + logger.info(`Loading multi-database config from file: ${configPathOrJson}`); + } + const config = fromEnv ? loadConfigFromEnv(fromEnv) : loadConfig(configPathOrJson); logger.info(`Found ${config.databases.length} database(s) in config`); const { successes, failures } = await initAllDatabases(config.databases); @@ -278,8 +285,11 @@ process.on('unhandledRejection', (reason, promise) => { async function runServer() { try { // Determine startup mode + const configEnvIndex = args.indexOf('--config-env'); const configIndex = args.indexOf('--config'); - if (configIndex !== -1 && configIndex + 1 < args.length) { + if (configEnvIndex !== -1 && configEnvIndex + 1 < args.length) { + await initFromConfig('', args[configEnvIndex + 1]); + } else if (configIndex !== -1 && configIndex + 1 < args.length) { await initFromConfig(args[configIndex + 1]); } else { await initFromCliArgs();