UNPKG

mcp-turso

Version:

MCP server for interacting with Turso-hosted LibSQL databases

339 lines (332 loc) 10.1 kB
import { FastMCP } from "fastmcp"; import { z } from "zod"; import { appendFileSync, existsSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { createClient } from "@libsql/client"; //#region package.json var name = "mcp-turso"; var version = "0.2.1"; //#endregion //#region src/logger.ts const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const parentDir = join(__dirname, ".."); const logsDir = join(parentDir, "logs"); const DEFAULT_LOG_FILE = join(logsDir, `${name}.log`); /** * Formats a log message with timestamp, level, and optional data. * * @param level - The log level (INFO, ERROR, DEBUG, etc) * @param message - The log message text * @param data - Optional data to include in the log entry * @returns A formatted log string */ function formatLogMessage(level, message, data) { const timestamp = new Date().toISOString(); const dataStr = data ? `\n${JSON.stringify(data, null, 2)}` : ""; return `[${timestamp}] [${level}] ${message}${dataStr}\n`; } /** * Creates a logger instance that writes to the specified log file. * * @param logFile - Path to the log file (defaults to DEFAULT_LOG_FILE) * @returns A logger object with info, error, and debug methods */ /** * Creates a logger instance that writes log messages to a file. * * @param logFile - The path to the log file. Defaults to DEFAULT_LOG_FILE. * @returns A `logger` object with methods for logging at different levels. * @returns {Object} `logger` - The logger object. * @returns {string} `logger.logFile` - The path to the log file. * @returns {function} `logger.log` - Logs a message with a custom level. * @returns {function} `logger.info` - Logs an info level message. * @returns {function} `logger.error` - Logs an error level message. * @returns {function} `logger.debug` - Logs a debug level message. */ function createLogger(logFile$1 = DEFAULT_LOG_FILE) { const logDir = dirname(logFile$1); if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }); if (!existsSync(logFile$1)) appendFileSync(logFile$1, ""); return { logFile: logFile$1, log(level, message, data) { const logMessage = formatLogMessage(level, message, data); appendFileSync(logFile$1, logMessage); }, info(message, data) { const logMessage = formatLogMessage("INFO", message, data); appendFileSync(logFile$1, logMessage); }, error(message, error) { const logMessage = formatLogMessage("ERROR", message, error); appendFileSync(logFile$1, logMessage); }, debug(message, data) { const logMessage = formatLogMessage("DEBUG", message, data); appendFileSync(logFile$1, logMessage); }, }; } var logger_default = createLogger; //#endregion //#region src/types.ts const TableColumnSchema = z.object({ name: z.string(), type: z.string(), notnull: z.number(), dflt_value: z.union([z.string(), z.null()]), pk: z.number(), }); const envSchema = z.object({ TURSO_DATABASE_URL: z.string().min(1, "Database URL is required"), TURSO_AUTH_TOKEN: z.string().min(1, "Auth token is required"), }); //#endregion //#region src/utils.ts /** * Retrieves a list of all tables in the Turso database. * * @param client - The Turso database client instance * @returns A promise that resolves to an array of table names */ async function listTables(client) { const result = await client.execute({ sql: "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", args: [], }); return result.rows.map((row) => row.name); } /** * Retrieves the SQL schema definitions for all tables in the database. * * @param client - The Turso database client instance * @returns A promise that resolves to an array of SQL schema statements */ async function dbSchema(client) { const result = await client.execute({ sql: "SELECT sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", args: [], }); return result.rows.map((row) => row.sql); } /** * Retrieves detailed schema information for a specific table. * * @param tableName - The name of the table to describe * @param client - The Turso database client instance * @returns A promise that resolves to an array of column definitions * @throws Error if the table name is invalid or the table doesn't exist */ async function describeTable(tableName, client) { if (!/^[a-zA-Z0-9_]+$/.test(tableName)) throw new Error( "Invalid table name. Only alphanumeric characters and underscores are allowed.", ); const result = await client.execute({ sql: `PRAGMA table_info(${tableName})`, args: [], }); if (result.rows.length === 0) throw new Error(`Table '${tableName}' not found`); return result.rows.map((row) => ({ name: row.name, type: row.type, notnull: row.notnull, dflt_value: row.dflt_value, pk: row.pk, })); } /** * Executes a SELECT SQL query against the database. * * @param sql - The SQL query to execute (must be a SELECT query) * @param client - The Turso database client instance * @returns A promise that resolves to an object containing columns, rows, and row count * @throws Error if the query is not a SELECT query */ async function query(sql, client) { const trimmedQuery = sql.trim().toUpperCase(); if (!trimmedQuery.startsWith("SELECT")) throw new Error("Only SELECT queries are allowed for safety reasons"); const result = await client.execute({ sql, args: [], }); return { columns: result.columns, rows: result.rows, rowCount: result.rows.length, }; } /** * Creates a formatted content response object for MCP tools. * * @param text - The text content to include in the response * @param error - Whether this content represents an error (default: false) * @returns A formatted content result object */ function content(text, error = false) { return { content: [ { type: "text", text, }, ], isError: error, }; } /** * Determines the log file path based on command line arguments or defaults. * * @returns The path to the log file */ function getLogFile() { const { values } = parseArgs({ args: process.argv, options: { logs: { type: "string" } }, strict: true, allowPositionals: true, }); const parsedLogs = z .string() .refine((targetPath) => { const posixPath = targetPath.split("\\").join("/"); return targetPath === posixPath && posixPath.includes("/"); }) .safeParse(values.logs); return values.logs && parsedLogs.success ? parsedLogs.data : DEFAULT_LOG_FILE; } /** * Retrieves the version string from a package.json file. * * @param pkg - The package.json file content * @returns The version string */ function getVersion(version$1) { return version$1.match(/^\d+\.\d+\.\d+$/) ? version$1 : "0.0.0"; } //#endregion //#region src/index.ts const server = new FastMCP({ name: "Turso MCP Server", version: getVersion(version), }); const dbUrl = process.env.TURSO_DATABASE_URL; const authToken = process.env.TURSO_AUTH_TOKEN; const logFile = getLogFile(); const logger = logger_default(logFile); if (!dbUrl) { logger.error("TURSO_DATABASE_URL environment variable is required"); process.exit(1); } if (!authToken) { logger.error("TURSO_AUTH_TOKEN environment variable is required"); process.exit(1); } let db; try { db = createClient({ url: dbUrl, authToken, }); logger.info("Successfully connected to Turso database"); } catch (error) { logger.error("Failed to connect to Turso database", error); process.exit(1); } server.addTool({ name: "list_tables", description: "List all tables in the database", parameters: z.object({}), execute: async () => { try { logger.info("Executing list_tables"); const tables = await listTables(db); return content(JSON.stringify({ tables }, null, 2)); } catch (error) { logger.error("Failed to list tables", error); return content( `Error listing tables: ${error instanceof Error ? error.message : String(error)}`, true, ); } }, }); server.addTool({ name: "get_db_schema", description: "Get the schema for all tables in the database", parameters: z.object({}), execute: async () => { try { const schema = await dbSchema(db); return content(JSON.stringify({ schema }, null, 2)); } catch (error) { return content( `Error getting schema: ${error instanceof Error ? error.message : String(error)}`, true, ); } }, }); server.addTool({ name: "describe_table", description: "View schema information for a specific table", parameters: z.object({ table_name: z .string() .describe("Name of the table to describe") .min(1, "Table name is required"), }), execute: async ({ table_name }) => { try { logger.info(`Executing describe_table for table: ${table_name}`); const schema = await describeTable(table_name, db); return content(JSON.stringify({ schema }, null, 2)); } catch (error) { logger.error(`Failed to describe table ${table_name}`, error); return content( `Error describing table: ${error instanceof Error ? error.message : String(error)}`, true, ); } }, }); server.addTool({ name: "query_database", description: "Execute a SELECT query to read data from the database", parameters: z.object({ sql: z .string() .describe("SQL query to execute") .min(1, "SQL query is required"), }), execute: async ({ sql }) => { try { logger.info(`Executing query: ${sql}`); const result = await query(sql, db); return content(JSON.stringify(result, null, 2)); } catch (error) { logger.error("Failed to execute query", error); return content( `Error executing query: ${error instanceof Error ? error.message : String(error)}`, true, ); } }, }); process.on("uncaughtException", (error) => { logger.error("Uncaught exception", error); }); process.on("unhandledRejection", (reason) => { logger.error("Unhandled rejection", reason); }); console.error(`[INFO] Additional logs available at: ${logger.logFile}`); server.start({ transportType: "stdio" }); process.on("exit", (code) => { logger.info("Turso MCP server closed", code); }); //#endregion