diff --git a/.DS_Store b/.DS_Store index fc12758..a3e81e5 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/build.sh b/build.sh index 5d0d9c7..3608c24 100755 --- a/build.sh +++ b/build.sh @@ -16,6 +16,7 @@ echo "Building TypeScript..." # Make JavaScript files executable echo "Making JavaScript files executable..." -chmod +x dist/*.js +chmod +x dist/src/index.js -echo "Build completed successfully!" \ No newline at end of file +echo "Build completed successfully!" +echo "You can now run the server with: node dist/src/index.js /path/to/your/database.db" \ No newline at end of file diff --git a/package.json b/package.json index 7fd837f..69c3705 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,19 @@ "bugs": "https://github.com/executeautomation/database-server/issues", "type": "module", "bin": { - "ea-database-server": "dist/index.js" + "ea-database-server": "dist/src/index.js" }, "files": [ "dist" ], "scripts": { - "build": "tsc && shx chmod +x dist/*.js", + "build": "tsc && shx chmod +x dist/src/index.js", "prepare": "npm run build", "watch": "tsc --watch", - "start": "node dist/index.js", - "dev": "tsc && node dist/index.js", - "example": "node examples/example.js" + "start": "node dist/src/index.js", + "dev": "tsc && node dist/src/index.js", + "example": "node examples/example.js", + "clean": "rimraf dist" }, "dependencies": { "@modelcontextprotocol/sdk": "1.9.0", @@ -27,6 +28,7 @@ }, "devDependencies": { "@types/sqlite3": "5.1.0", + "rimraf": "^5.0.5", "shx": "0.4.0", "typescript": "5.8.3" } diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..80a21be Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..069dc6c --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,101 @@ +import sqlite3 from "sqlite3"; + +let db: sqlite3.Database; +let databasePath: string; + +/** + * Initialize the SQLite database connection + * @param dbPath Path to the SQLite database file + */ +export function initDatabase(dbPath: string): Promise { + databasePath = dbPath; + return new Promise((resolve, reject) => { + db = new sqlite3.Database(dbPath, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +/** + * Execute a SQL query and get all results + * @param query SQL query to execute + * @param params Query parameters + * @returns Promise with query results + */ +export function dbAll(query: string, params: any[] = []): Promise { + return new Promise((resolve, reject) => { + 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 + */ +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 }); + } + }); + }); +} + +/** + * Execute multiple SQL statements + * @param query SQL statements to execute + * @returns Promise that resolves when execution completes + */ +export function dbExec(query: string): Promise { + return new Promise((resolve, reject) => { + db.exec(query, (err: Error | null) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +/** + * Close the database connection + */ +export function closeDatabase(): Promise { + return new Promise((resolve, reject) => { + if (!db) { + resolve(); + return; + } + + db.close((err: Error | null) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +/** + * Get the current database path + */ +export function getDatabasePath(): string { + return databasePath; +} \ No newline at end of file diff --git a/src/handlers/resourceHandlers.ts b/src/handlers/resourceHandlers.ts new file mode 100644 index 0000000..156251a --- /dev/null +++ b/src/handlers/resourceHandlers.ts @@ -0,0 +1,66 @@ +import { dbAll } from '../db/index.js'; +import { getDatabasePath } from '../db/index.js'; + +/** + * Handle listing resources request + * @returns List of available resources + */ +export async function handleListResources() { + try { + const databasePath = getDatabasePath(); + const resourceBaseUrl = new URL(`sqlite:///${databasePath}`); + const SCHEMA_PATH = "schema"; + + const result = await dbAll( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ); + + return { + resources: result.map((row: any) => ({ + uri: new URL(`${row.name}/${SCHEMA_PATH}`, resourceBaseUrl).href, + mimeType: "application/json", + name: `"${row.name}" database schema`, + })), + }; + } 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 + */ +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(); + + if (schema !== SCHEMA_PATH) { + throw new Error("Invalid resource URI"); + } + + // Query to get column information for a table + const result = await dbAll(`PRAGMA table_info("${tableName}")`); + + return { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify(result.map((column: any) => ({ + column_name: column.name, + data_type: column.type + })), null, 2), + }, + ], + }; + } 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 new file mode 100644 index 0000000..463bcc3 --- /dev/null +++ b/src/handlers/toolHandlers.ts @@ -0,0 +1,170 @@ +import { formatErrorResponse } from '../utils/formatUtils.js'; + +// Import all tool implementations +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'; + +/** + * Handle listing available tools + * @returns List of available tools + */ +export function handleListTools() { + return { + tools: [ + { + name: "read_query", + description: "Execute SELECT queries to read data from the database", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, + { + name: "write_query", + description: "Execute INSERT, UPDATE, or DELETE queries", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, + { + name: "create_table", + description: "Create new tables in the database", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, + { + name: "alter_table", + description: "Modify existing table schema (add columns, rename tables, etc.)", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, + { + name: "drop_table", + description: "Remove a table from the database with safety confirmation", + inputSchema: { + type: "object", + properties: { + table_name: { type: "string" }, + confirm: { type: "boolean" }, + }, + required: ["table_name", "confirm"], + }, + }, + { + name: "export_query", + description: "Export query results to various formats (CSV, JSON)", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + format: { type: "string", enum: ["csv", "json"] }, + }, + required: ["query", "format"], + }, + }, + { + name: "list_tables", + description: "Get a list of all tables in the database", + inputSchema: { + type: "object", + properties: {}, + }, + }, + { + name: "describe_table", + description: "View schema information for a specific table", + inputSchema: { + type: "object", + properties: { + table_name: { type: "string" }, + }, + required: ["table_name"], + }, + }, + { + name: "append_insight", + description: "Add a business insight to the memo", + inputSchema: { + type: "object", + properties: { + insight: { type: "string" }, + }, + required: ["insight"], + }, + }, + { + name: "list_insights", + description: "List all business insights in the memo", + inputSchema: { + type: "object", + properties: {}, + }, + }, + ], + }; +} + +/** + * 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 "read_query": + return await readQuery(args.query); + + case "write_query": + return await writeQuery(args.query); + + case "create_table": + return await createTable(args.query); + + case "alter_table": + return await alterTable(args.query); + + case "drop_table": + return await dropTable(args.table_name, args.confirm); + + case "export_query": + return await exportQuery(args.query, args.format); + + case "list_tables": + return await listTables(); + + case "describe_table": + return await describeTable(args.table_name); + + 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 new file mode 100644 index 0000000..9dd7823 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +// Import database utils +import { initDatabase, closeDatabase } from './db/index.js'; + +// Import handlers +import { handleListResources, handleReadResource } from './handlers/resourceHandlers.js'; +import { handleListTools, handleToolCall } from './handlers/toolHandlers.js'; + +// Configure the server +const server = new Server( + { + name: "executeautomation/database-server", + version: "1.0.0", + }, + { + capabilities: { + resources: {}, + tools: {}, + }, + }, +); + +// 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"); + process.exit(1); +} + +const databasePath = args[0]; + +// Set up request handlers +server.setRequestHandler(ListResourcesRequestSchema, async () => { + return await handleListResources(); +}); + +server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + return await handleReadResource(request.params.uri); +}); + +server.setRequestHandler(ListToolsRequestSchema, async () => { + return handleListTools(); +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + return await handleToolCall(request.params.name, request.params.arguments); +}); + +// Handle shutdown gracefully +process.on('SIGINT', async () => { + console.log('Shutting down gracefully...'); + await closeDatabase(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + console.log('Shutting down gracefully...'); + await closeDatabase(); + process.exit(0); +}); + +/** + * Start the server + */ +async function runServer() { + try { + console.log(`Initializing database: ${databasePath}`); + await initDatabase(databasePath); + + console.log('Starting MCP server...'); + const transport = new StdioServerTransport(); + await server.connect(transport); + + console.log('Server running. Press Ctrl+C to exit.'); + } catch (error) { + console.error("Failed to initialize:", error); + process.exit(1); + } +} + +// Start the server +runServer().catch(console.error); \ No newline at end of file diff --git a/src/tools/insightTools.ts b/src/tools/insightTools.ts new file mode 100644 index 0000000..0221da1 --- /dev/null +++ b/src/tools/insightTools.ts @@ -0,0 +1,64 @@ +import { dbAll, dbExec, dbRun } from '../db/index.js'; +import { formatSuccessResponse } from '../utils/formatUtils.js'; + +/** + * Add a business insight to the memo + * @param insight Business insight text + * @returns Result of the operation + */ +export async function appendInsight(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] + ); + + return formatSuccessResponse({ success: true, message: "Insight added" }); + } catch (error: any) { + throw new Error(`Error adding insight: ${error.message}`); + } +} + +/** + * 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); + } 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 new file mode 100644 index 0000000..8bb465a --- /dev/null +++ b/src/tools/queryTools.ts @@ -0,0 +1,77 @@ +import { dbAll, dbRun, dbExec } from '../db/index.js'; +import { formatErrorResponse, 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) { + try { + if (!query.trim().toLowerCase().startsWith("select")) { + throw new Error("Only SELECT queries are allowed with read_query"); + } + + const result = await dbAll(query); + return formatSuccessResponse(result); + } catch (error: any) { + throw new Error(`SQL Error: ${error.message}`); + } +} + +/** + * Execute a data modification SQL query + * @param query SQL query to execute + * @returns Information about affected rows + */ +export async function writeQuery(query: 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); + return formatSuccessResponse({ affected_rows: result.changes }); + } catch (error: any) { + throw new Error(`SQL Error: ${error.message}`); + } +} + +/** + * 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) { + try { + if (!query.trim().toLowerCase().startsWith("select")) { + throw new Error("Only SELECT queries are allowed with export_query"); + } + + const result = await dbAll(query); + + if (format === "csv") { + const csvData = convertToCSV(result); + return { + content: [{ + type: "text", + text: csvData + }], + isError: false, + }; + } else if (format === "json") { + return formatSuccessResponse(result); + } else { + throw new Error("Unsupported export format. Use 'csv' or 'json'"); + } + } 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 new file mode 100644 index 0000000..3d93704 --- /dev/null +++ b/src/tools/schemaTools.ts @@ -0,0 +1,128 @@ +import { dbAll, dbExec } from '../db/index.js'; +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) { + try { + if (!query.trim().toLowerCase().startsWith("create table")) { + throw new Error("Only CREATE TABLE statements are allowed"); + } + + await dbExec(query); + return formatSuccessResponse({ success: true, message: "Table created successfully" }); + } catch (error: any) { + throw new Error(`SQL Error: ${error.message}`); + } +} + +/** + * Alter an existing table schema + * @param query ALTER TABLE SQL statement + * @returns Result of the operation + */ +export async function alterTable(query: string) { + try { + if (!query.trim().toLowerCase().startsWith("alter table")) { + throw new Error("Only ALTER TABLE statements are allowed"); + } + + await dbExec(query); + return formatSuccessResponse({ success: true, message: "Table altered successfully" }); + } catch (error: any) { + throw new Error(`SQL Error: ${error.message}`); + } +} + +/** + * 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) { + 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." + }); + } + + // Check if table exists + const tableExists = await dbAll( + "SELECT name FROM sqlite_master WHERE type='table' AND name = ?", + [tableName] + ); + + if (tableExists.length === 0) { + 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` + }); + } catch (error: any) { + throw new Error(`Error dropping table: ${error.message}`); + } +} + +/** + * List all tables in the database + * @returns Array of table names + */ +export async function listTables() { + try { + const tables = await dbAll( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ); + return formatSuccessResponse(tables.map((t) => t.name)); + } catch (error: any) { + throw new Error(`Error listing tables: ${error.message}`); + } +} + +/** + * 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) { + try { + if (!tableName) { + 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] + ); + + if (tableExists.length === 0) { + throw new Error(`Table '${tableName}' does not exist`); + } + + const columns = await dbAll(`PRAGMA table_info("${tableName}")`); + return formatSuccessResponse(columns.map((col) => ({ + name: col.name, + type: col.type, + notnull: !!col.notnull, + default_value: col.dflt_value, + primary_key: !!col.pk + }))); + } catch (error: any) { + throw new Error(`Error describing table: ${error.message}`); + } +} \ No newline at end of file diff --git a/src/utils/formatUtils.ts b/src/utils/formatUtils.ts new file mode 100644 index 0000000..cbf2bed --- /dev/null +++ b/src/utils/formatUtils.ts @@ -0,0 +1,61 @@ +/** + * Convert data to CSV format + * @param data Array of objects to convert to CSV + * @returns CSV formatted string + */ +export function convertToCSV(data: any[]): string { + if (data.length === 0) return ''; + + // Get headers + const headers = Object.keys(data[0]); + + // Create CSV header row + let csv = headers.join(',') + '\n'; + + // Add data rows + data.forEach(row => { + const values = headers.map(header => { + const val = row[header]; + // Handle strings with commas, quotes, etc. + if (typeof val === 'string') { + return `"${val.replace(/"/g, '""')}"`; + } + // Use empty string for null/undefined + return val === null || val === undefined ? '' : val; + }); + csv += values.join(',') + '\n'; + }); + + return csv; +} + +/** + * Format error response + * @param error Error object or message + * @returns Formatted error response object + */ +export function formatErrorResponse(error: Error | string): { content: Array<{type: string, text: string}>, isError: boolean } { + const message = error instanceof Error ? error.message : error; + return { + content: [{ + type: "text", + text: JSON.stringify({ error: message }, null, 2) + }], + isError: true + }; +} + +/** + * Format success response + * @param data Data to format + * @returns Formatted success response object + */ +export function formatSuccessResponse(data: any): { content: Array<{type: string, text: string}>, isError: boolean } { + return { + content: [{ + type: "text", + text: JSON.stringify(data, null, 2) + }], + isError: false + }; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 0dbd459..b1d40b2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "declaration": false }, "include": [ - "./**/*.ts" + "./src/**/*.ts" ], "exclude": [ "node_modules",