mcp-turso
Version:
MCP server for interacting with Turso-hosted LibSQL databases
339 lines (332 loc) • 10.1 kB
JavaScript
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