Added support for SQL Server

This commit is contained in:
Karthik KK
2025-04-14 13:22:46 +12:00
parent 2de75b8fa6
commit e125b991fb
14 changed files with 2368 additions and 371 deletions

BIN
src/.DS_Store vendored

Binary file not shown.

74
src/db/adapter.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* Database adapter interface
* Defines the contract for all database implementations (SQLite, SQL Server)
*/
export interface DbAdapter {
/**
* Initialize database connection
*/
init(): Promise<void>;
/**
* Close database connection
*/
close(): Promise<void>;
/**
* Execute a query and return all results
* @param query SQL query to execute
* @param params Query parameters
*/
all(query: string, params?: any[]): Promise<any[]>;
/**
* Execute a query that modifies data
* @param query SQL query to execute
* @param params Query parameters
*/
run(query: string, params?: any[]): Promise<{ changes: number, lastID: number }>;
/**
* Execute multiple SQL statements
* @param query SQL statements to execute
*/
exec(query: string): Promise<void>;
/**
* Get database metadata
*/
getMetadata(): { name: string, type: string, path?: string, server?: string, database?: string };
/**
* Get database-specific query for listing tables
*/
getListTablesQuery(): string;
/**
* Get database-specific query for describing a table
* @param tableName Table name
*/
getDescribeTableQuery(tableName: string): string;
}
// Import adapters using dynamic imports
import { SqliteAdapter } from './sqlite-adapter.js';
import { SqlServerAdapter } from './sqlserver-adapter.js';
/**
* Factory function to create the appropriate database adapter
*/
export function createDbAdapter(type: string, connectionInfo: any): DbAdapter {
switch (type.toLowerCase()) {
case 'sqlite':
// For SQLite, if connectionInfo is a string, use it directly as path
if (typeof connectionInfo === 'string') {
return new SqliteAdapter(connectionInfo);
} else {
return new SqliteAdapter(connectionInfo.path);
}
case 'sqlserver':
return new SqlServerAdapter(connectionInfo);
default:
throw new Error(`Unsupported database type: ${type}`);
}
}

View File

@@ -1,23 +1,28 @@
import sqlite3 from "sqlite3";
import { DbAdapter, createDbAdapter } from './adapter.js';
let db: sqlite3.Database;
let databasePath: string;
// Store the active database adapter
let dbAdapter: DbAdapter | null = null;
/**
* Initialize the SQLite database connection
* @param dbPath Path to the SQLite database file
* Initialize the database connection
* @param connectionInfo Connection information object or SQLite path string
* @param dbType Database type ('sqlite' or 'sqlserver')
*/
export function initDatabase(dbPath: string): Promise<void> {
databasePath = dbPath;
return new Promise((resolve, reject) => {
db = new sqlite3.Database(dbPath, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
export async function initDatabase(connectionInfo: any, dbType: string = 'sqlite'): Promise<void> {
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();
} catch (error) {
throw new Error(`Failed to initialize database: ${(error as Error).message}`);
}
}
/**
@@ -27,15 +32,10 @@ export function initDatabase(dbPath: string): Promise<void> {
* @returns Promise with query results
*/
export function dbAll(query: string, params: any[] = []): Promise<any[]> {
return new Promise((resolve, reject) => {
db.all(query, params, (err: Error | null, rows: any[]) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
if (!dbAdapter) {
throw new Error("Database not initialized");
}
return dbAdapter.all(query, params);
}
/**
@@ -45,15 +45,10 @@ export function dbAll(query: string, params: any[] = []): Promise<any[]> {
* @returns Promise with result info
*/
export function dbRun(query: string, params: any[] = []): Promise<{ changes: number, lastID: number }> {
return new Promise((resolve, reject) => {
db.run(query, params, function(this: sqlite3.RunResult, err: Error | null) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes, lastID: this.lastID });
}
});
});
if (!dbAdapter) {
throw new Error("Database not initialized");
}
return dbAdapter.run(query, params);
}
/**
@@ -62,40 +57,49 @@ export function dbRun(query: string, params: any[] = []): Promise<{ changes: num
* @returns Promise that resolves when execution completes
*/
export function dbExec(query: string): Promise<void> {
return new Promise((resolve, reject) => {
db.exec(query, (err: Error | null) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
if (!dbAdapter) {
throw new Error("Database not initialized");
}
return dbAdapter.exec(query);
}
/**
* Close the database connection
*/
export function closeDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
if (!db) {
resolve();
return;
}
db.close((err: Error | null) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
if (!dbAdapter) {
return Promise.resolve();
}
return dbAdapter.close();
}
/**
* Get the current database path
* Get database metadata
*/
export function getDatabasePath(): string {
return databasePath;
export function getDatabaseMetadata(): { name: string, type: string, path?: string, server?: string, database?: string } {
if (!dbAdapter) {
throw new Error("Database not initialized");
}
return dbAdapter.getMetadata();
}
/**
* Get database-specific query for listing tables
*/
export function getListTablesQuery(): string {
if (!dbAdapter) {
throw new Error("Database not initialized");
}
return dbAdapter.getListTablesQuery();
}
/**
* 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);
}

145
src/db/sqlite-adapter.ts Normal file
View File

@@ -0,0 +1,145 @@
import sqlite3 from "sqlite3";
import { DbAdapter } from "./adapter.js";
/**
* SQLite database adapter implementation
*/
export class SqliteAdapter implements DbAdapter {
private db: sqlite3.Database | null = null;
private dbPath: string;
constructor(dbPath: string) {
this.dbPath = dbPath;
}
/**
* Initialize the SQLite database connection
*/
async init(): Promise<void> {
return new Promise((resolve, reject) => {
// Ensure the dbPath is accessible
console.log(`Opening SQLite database at: ${this.dbPath}`);
this.db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, (err) => {
if (err) {
console.error(`SQLite connection error: ${err.message}`);
reject(err);
} else {
console.log("SQLite database opened successfully");
resolve();
}
});
});
}
/**
* Execute a SQL query and get all results
* @param query SQL query to execute
* @param params Query parameters
* @returns Promise with query results
*/
async all(query: string, params: any[] = []): Promise<any[]> {
if (!this.db) {
throw new Error("Database not initialized");
}
return new Promise((resolve, reject) => {
this.db!.all(query, params, (err: Error | null, rows: any[]) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
}
/**
* Execute a SQL query that modifies data
* @param query SQL query to execute
* @param params Query parameters
* @returns Promise with result info
*/
async run(query: string, params: any[] = []): Promise<{ changes: number, lastID: number }> {
if (!this.db) {
throw new Error("Database not initialized");
}
return new Promise((resolve, reject) => {
this.db!.run(query, params, function(this: sqlite3.RunResult, err: Error | null) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes, lastID: this.lastID });
}
});
});
}
/**
* Execute multiple SQL statements
* @param query SQL statements to execute
* @returns Promise that resolves when execution completes
*/
async exec(query: string): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
return new Promise((resolve, reject) => {
this.db!.exec(query, (err: Error | null) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
/**
* Close the database connection
*/
async close(): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.db) {
resolve();
return;
}
this.db.close((err: Error | null) => {
if (err) {
reject(err);
} else {
this.db = null;
resolve();
}
});
});
}
/**
* Get database metadata
*/
getMetadata(): { name: string, type: string, path: string } {
return {
name: "SQLite",
type: "sqlite",
path: this.dbPath
};
}
/**
* Get database-specific query for listing tables
*/
getListTablesQuery(): string {
return "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'";
}
/**
* Get database-specific query for describing a table
* @param tableName Table name
*/
getDescribeTableQuery(tableName: string): string {
return `PRAGMA table_info(${tableName})`;
}
}

212
src/db/sqlserver-adapter.ts Normal file
View File

@@ -0,0 +1,212 @@
import { DbAdapter } from "./adapter.js";
import sql from 'mssql';
/**
* SQL Server database adapter implementation
*/
export class SqlServerAdapter implements DbAdapter {
private pool: sql.ConnectionPool | null = null;
private config: sql.config;
private server: string;
private database: string;
constructor(connectionInfo: {
server: string;
database: string;
user?: string;
password?: string;
port?: number;
trustServerCertificate?: boolean;
options?: any;
}) {
this.server = connectionInfo.server;
this.database = connectionInfo.database;
// Create SQL Server connection config
this.config = {
server: connectionInfo.server,
database: connectionInfo.database,
port: connectionInfo.port || 1433,
options: {
trustServerCertificate: connectionInfo.trustServerCertificate ?? true,
...connectionInfo.options
}
};
// Add authentication options
if (connectionInfo.user && connectionInfo.password) {
this.config.user = connectionInfo.user;
this.config.password = connectionInfo.password;
} else {
// Use Windows authentication if no username/password provided
this.config.options!.trustedConnection = true;
this.config.options!.enableArithAbort = true;
}
}
/**
* Initialize SQL Server connection
*/
async init(): Promise<void> {
try {
this.pool = await new sql.ConnectionPool(this.config).connect();
} catch (err) {
throw new Error(`Failed to connect to SQL Server: ${(err 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
*/
async all(query: string, params: any[] = []): Promise<any[]> {
if (!this.pool) {
throw new Error("Database not initialized");
}
try {
const request = this.pool.request();
// Add parameters to the request
params.forEach((param, index) => {
request.input(`param${index}`, param);
});
// Replace ? with named parameters
const preparedQuery = query.replace(/\?/g, (_, i) => `@param${i}`);
const result = await request.query(preparedQuery);
return result.recordset;
} catch (err) {
throw new Error(`SQL Server query error: ${(err as Error).message}`);
}
}
/**
* Execute a SQL query that modifies data
* @param query SQL query to execute
* @param params Query parameters
* @returns Promise with result info
*/
async run(query: string, params: any[] = []): Promise<{ changes: number, lastID: number }> {
if (!this.pool) {
throw new Error("Database not initialized");
}
try {
const request = this.pool.request();
// Add parameters to the request
params.forEach((param, index) => {
request.input(`param${index}`, param);
});
// Replace ? with named parameters
const preparedQuery = query.replace(/\?/g, (_, i) => `@param${i}`);
// Add output parameter for identity value if it's an INSERT
let lastID = 0;
if (query.trim().toUpperCase().startsWith('INSERT')) {
request.output('insertedId', sql.Int, 0);
const updatedQuery = `${preparedQuery}; SELECT @insertedId = SCOPE_IDENTITY();`;
const result = await request.query(updatedQuery);
lastID = result.output.insertedId || 0;
} else {
const result = await request.query(preparedQuery);
lastID = 0;
}
return {
changes: this.getAffectedRows(query, lastID),
lastID: lastID
};
} catch (err) {
throw new Error(`SQL Server query error: ${(err as Error).message}`);
}
}
/**
* Execute multiple SQL statements
* @param query SQL statements to execute
* @returns Promise that resolves when execution completes
*/
async exec(query: string): Promise<void> {
if (!this.pool) {
throw new Error("Database not initialized");
}
try {
const request = this.pool.request();
await request.batch(query);
} catch (err) {
throw new Error(`SQL Server batch error: ${(err as Error).message}`);
}
}
/**
* Close the database connection
*/
async close(): Promise<void> {
if (this.pool) {
await this.pool.close();
this.pool = null;
}
}
/**
* Get database metadata
*/
getMetadata(): { name: string, type: string, server: string, database: string } {
return {
name: "SQL Server",
type: "sqlserver",
server: this.server,
database: this.database
};
}
/**
* Get database-specific query for listing tables
*/
getListTablesQuery(): string {
return "SELECT TABLE_NAME as name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME";
}
/**
* Get database-specific query for describing a table
* @param tableName Table name
*/
getDescribeTableQuery(tableName: string): string {
return `
SELECT
c.COLUMN_NAME as name,
c.DATA_TYPE as type,
CASE WHEN c.IS_NULLABLE = 'YES' THEN 1 ELSE 0 END as notnull,
CASE WHEN pk.CONSTRAINT_TYPE = 'PRIMARY KEY' THEN 1 ELSE 0 END as pk,
c.COLUMN_DEFAULT as dflt_value
FROM
INFORMATION_SCHEMA.COLUMNS c
LEFT JOIN
INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu ON c.TABLE_NAME = kcu.TABLE_NAME AND c.COLUMN_NAME = kcu.COLUMN_NAME
LEFT JOIN
INFORMATION_SCHEMA.TABLE_CONSTRAINTS pk ON kcu.CONSTRAINT_NAME = pk.CONSTRAINT_NAME AND pk.CONSTRAINT_TYPE = 'PRIMARY KEY'
WHERE
c.TABLE_NAME = '${tableName}'
ORDER BY
c.ORDINAL_POSITION
`;
}
/**
* Helper to get the number of affected rows based on query type
*/
private getAffectedRows(query: string, lastID: number): number {
const queryType = query.trim().split(' ')[0].toUpperCase();
if (queryType === 'INSERT' && lastID > 0) {
return 1;
}
return 0; // For SELECT, unknown for UPDATE/DELETE without additional query
}
}

View File

@@ -1,5 +1,4 @@
import { dbAll } from '../db/index.js';
import { getDatabasePath } from '../db/index.js';
import { dbAll, getListTablesQuery, getDescribeTableQuery, getDatabaseMetadata } from '../db/index.js';
/**
* Handle listing resources request
@@ -7,13 +6,24 @@ import { getDatabasePath } from '../db/index.js';
*/
export async function handleListResources() {
try {
const databasePath = getDatabasePath();
const resourceBaseUrl = new URL(`sqlite:///${databasePath}`);
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 SCHEMA_PATH = "schema";
const result = await dbAll(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
);
// Use adapter-specific query to list tables
const query = getListTablesQuery();
const result = await dbAll(query);
return {
resources: result.map((row: any) => ({
@@ -45,8 +55,9 @@ export async function handleReadResource(uri: string) {
throw new Error("Invalid resource URI");
}
// Query to get column information for a table
const result = await dbAll(`PRAGMA table_info("${tableName}")`);
// Use adapter-specific query to describe the table
const query = getDescribeTableQuery(tableName!);
const result = await dbAll(query);
return {
contents: [

View File

@@ -10,7 +10,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
// Import database utils
import { initDatabase, closeDatabase } from './db/index.js';
import { initDatabase, closeDatabase, getDatabaseMetadata } from './db/index.js';
// Import handlers
import { handleListResources, handleReadResource } from './handlers/resourceHandlers.js';
@@ -33,11 +33,52 @@ const server = new Server(
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Please provide a database file path as a command-line argument");
console.error("Please provide database connection information");
console.error("Usage for SQLite: node index.js <database_file_path>");
console.error("Usage for SQL Server: node index.js --sqlserver --server <server> --database <database> [--user <user> --password <password>]");
process.exit(1);
}
const databasePath = args[0];
// 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) {
console.error("Error: SQL Server requires --server and --database parameters");
process.exit(1);
}
} else {
// SQLite mode (default)
dbType = 'sqlite';
connectionInfo = args[0]; // First argument is the SQLite file path
console.log(`Using SQLite database at path: ${connectionInfo}`);
}
// Set up request handlers
server.setRequestHandler(ListResourcesRequestSchema, async () => {
@@ -69,13 +110,32 @@ process.on('SIGTERM', async () => {
process.exit(0);
});
// Add global error handler
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
/**
* Start the server
*/
async function runServer() {
try {
console.log(`Initializing database: ${databasePath}`);
await initDatabase(databasePath);
console.log(`Initializing ${dbType} database...`);
if (dbType === 'sqlite') {
console.log(`Database path: ${connectionInfo}`);
} else if (dbType === 'sqlserver') {
console.log(`Server: ${connectionInfo.server}, Database: ${connectionInfo.database}`);
}
// Initialize the database
await initDatabase(connectionInfo, dbType);
const dbInfo = getDatabaseMetadata();
console.log(`Connected to ${dbInfo.name} database`);
console.log('Starting MCP server...');
const transport = new StdioServerTransport();
@@ -89,4 +149,7 @@ async function runServer() {
}
// Start the server
runServer().catch(console.error);
runServer().catch(error => {
console.error("Server initialization failed:", error);
process.exit(1);
});

View File

@@ -1,4 +1,4 @@
import { dbAll, dbExec } from '../db/index.js';
import { dbAll, dbExec, getListTablesQuery, getDescribeTableQuery } from '../db/index.js';
import { formatSuccessResponse } from '../utils/formatUtils.js';
/**
@@ -56,13 +56,12 @@ export async function dropTable(tableName: string, confirm: boolean) {
});
}
// Check if table exists
const tableExists = await dbAll(
"SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
[tableName]
);
// First check if table exists by directly querying for tables
const query = getListTablesQuery();
const tables = await dbAll(query);
const tableNames = tables.map(t => t.name);
if (tableExists.length === 0) {
if (!tableNames.includes(tableName)) {
throw new Error(`Table '${tableName}' does not exist`);
}
@@ -84,9 +83,9 @@ export async function dropTable(tableName: string, confirm: boolean) {
*/
export async function listTables() {
try {
const tables = await dbAll(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
);
// Use adapter-specific query for listing tables
const query = getListTablesQuery();
const tables = await dbAll(query);
return formatSuccessResponse(tables.map((t) => t.name));
} catch (error: any) {
throw new Error(`Error listing tables: ${error.message}`);
@@ -104,17 +103,19 @@ export async function describeTable(tableName: string) {
throw new Error("Table name is required");
}
// Check if table exists
const tableExists = await dbAll(
"SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
[tableName]
);
// First check if table exists by directly querying for tables
const query = getListTablesQuery();
const tables = await dbAll(query);
const tableNames = tables.map(t => t.name);
if (tableExists.length === 0) {
if (!tableNames.includes(tableName)) {
throw new Error(`Table '${tableName}' does not exist`);
}
const columns = await dbAll(`PRAGMA table_info("${tableName}")`);
// Use adapter-specific query for describing tables
const descQuery = getDescribeTableQuery(tableName);
const columns = await dbAll(descQuery);
return formatSuccessResponse(columns.map((col) => ({
name: col.name,
type: col.type,