handler.ts•10.5 kB
/**
 * Unified tool handler for the Turso MCP server
 */
import { McpServer } from 'tmcp';
import { z } from 'zod';
import * as database_client from '../clients/database.js';
import * as organization_client from '../clients/organization.js';
import { ResultSet } from '../common/types.js';
import {
	resolve_database_name,
	set_current_database,
} from './context.js';
// Zod schemas for tool inputs
const EmptySchema = z.object({});
const CreateDatabaseSchema = z.object({
	name: z.string().describe('Name of the database to create - Must be unique within organization'),
	group: z.string().optional().describe('Optional group name for the database (defaults to "default")'),
	regions: z.array(z.string()).optional().describe('Optional list of regions to deploy the database to (affects latency and compliance)'),
});
const DeleteDatabaseSchema = z.object({
	name: z.string().describe('Name of the database to permanently delete - WARNING: ALL DATA WILL BE LOST FOREVER'),
});
const GenerateDatabaseTokenSchema = z.object({
	database: z.string().describe('Name of the database to generate a token for'),
	permission: z.enum(['full-access', 'read-only']).optional().describe('Permission level for the token'),
});
const DatabaseOnlySchema = z.object({
	database: z.string().optional().describe('Database name (optional, uses context if not provided)'),
});
const QuerySchema = z.object({
	query: z.string().describe('SQL query to execute'),
	params: z.record(z.string(), z.any()).optional().describe('Query parameters (optional) - Use parameterized queries for security'),
	database: z.string().optional().describe('Database name (optional, uses context if not provided)'),
});
const ReadOnlyQuerySchema = z.object({
	query: z.string().describe('Read-only SQL query to execute (SELECT, PRAGMA, EXPLAIN only)'),
	params: z.record(z.string(), z.any()).optional().describe('Query parameters (optional) - Use parameterized queries for security'),
	database: z.string().optional().describe('Database name (optional, uses context if not provided) - Specify target database'),
});
const DescribeTableSchema = z.object({
	table: z.string().describe('Table name'),
	database: z.string().optional().describe('Database name (optional, uses context if not provided)'),
});
const VectorSearchSchema = z.object({
	table: z.string().describe('Table name'),
	vector_column: z.string().describe('Column containing vectors'),
	query_vector: z.array(z.number()).describe('Query vector for similarity search'),
	limit: z.number().optional().describe('Maximum number of results (optional, default 10)'),
	database: z.string().optional().describe('Database name (optional, uses context if not provided)'),
});
/**
 * Create a tool error response
 */
function create_tool_error_response(error: unknown) {
	return {
		content: [
			{
				type: 'text' as const,
				text: JSON.stringify(
					{
						error: 'internal_error',
						message:
							error instanceof Error
								? error.message
								: 'Unknown error',
					},
					null,
					2,
				),
			},
		],
		isError: true,
	};
}
/**
 * Create a tool success response
 */
function create_tool_response(data: any) {
	return {
		content: [
			{
				type: 'text' as const,
				text: JSON.stringify(data, null, 2),
			},
		],
	};
}
/**
 * Register all tools with the server
 */
export function register_tools(server: McpServer<any>): void {
	// Organization tools
	server.tool(
		{
			name: 'list_databases',
			description: 'List all databases in your Turso organization',
			schema: EmptySchema,
		},
		async () => {
			try {
				const databases = await organization_client.list_databases();
				return create_tool_response({ databases });
			} catch (error) {
				return create_tool_error_response(error);
			}
		},
	);
	server.tool(
		{
			name: 'create_database',
			description: `✓ SAFE: Create a new database in your Turso organization. Database name must be unique.`,
			schema: CreateDatabaseSchema,
		},
		async ({ name, group, regions }) => {
			try {
				const database = await organization_client.create_database(
					name,
					{ group, regions },
				);
				return create_tool_response({ database });
			} catch (error) {
				return create_tool_error_response(error);
			}
		},
	);
	server.tool(
		{
			name: 'delete_database',
			description: `⚠️ DESTRUCTIVE: Permanently deletes a database and ALL its data. Cannot be undone. Always confirm with user before proceeding and verify correct database name.`,
			schema: DeleteDatabaseSchema,
		},
		async ({ name }) => {
			try {
				await organization_client.delete_database(name);
				return create_tool_response({
					success: true,
					message: `Database '${name}' deleted successfully`,
				});
			} catch (error) {
				return create_tool_error_response(error);
			}
		},
	);
	server.tool(
		{
			name: 'generate_database_token',
			description: 'Generate a new token for a specific database',
			schema: GenerateDatabaseTokenSchema,
		},
		async ({ database, permission = 'full-access' }) => {
			try {
				const jwt = await organization_client.generate_database_token(
					database,
					permission,
				);
				return create_tool_response({
					success: true,
					database,
					token: { jwt, permission, database },
					message: `Token generated successfully for database '${database}' with '${permission}' permissions`,
				});
			} catch (error) {
				return create_tool_error_response(error);
			}
		},
	);
	// Database tools
	server.tool(
		{
			name: 'list_tables',
			description: 'Lists all tables in a database',
			schema: DatabaseOnlySchema,
		},
		async ({ database }) => {
			try {
				const database_name = resolve_database_name(database);
				if (database) set_current_database(database);
				const tables = await database_client.list_tables(database_name);
				return create_tool_response({ database: database_name, tables });
			} catch (error) {
				return create_tool_error_response(error);
			}
		},
	);
	server.tool(
		{
			name: 'execute_read_only_query',
			description: `✓ SAFE: Execute read-only SQL queries (SELECT, PRAGMA, EXPLAIN). Automatically rejects write operations.`,
			schema: ReadOnlyQuerySchema,
		},
		async ({ query, params = {}, database }) => {
			try {
				// Validate that this is a read-only query
				const normalized_query = query.trim().toLowerCase();
				if (
					!normalized_query.startsWith('select') &&
					!normalized_query.startsWith('pragma')
				) {
					throw new Error(
						'Only SELECT and PRAGMA queries are allowed with execute_read_only_query',
					);
				}
				const database_name = resolve_database_name(database);
				if (database) set_current_database(database);
				const result = await database_client.execute_query(
					database_name,
					query,
					params,
				);
				const formatted_result = format_query_result(result);
				return create_tool_response({
					database: database_name,
					query,
					result: formatted_result,
				});
			} catch (error) {
				return create_tool_error_response(error);
			}
		},
	);
	server.tool(
		{
			name: 'execute_query',
			description: `⚠️ DESTRUCTIVE: Execute SQL that can modify/delete data (INSERT, UPDATE, DELETE, DROP, ALTER). Always confirm with user before destructive operations.`,
			schema: QuerySchema,
		},
		async ({ query, params = {}, database }) => {
			try {
				// Validate that this is not a read-only query
				const normalized_query = query.trim().toLowerCase();
				if (
					normalized_query.startsWith('select') ||
					normalized_query.startsWith('pragma')
				) {
					throw new Error(
						'SELECT and PRAGMA queries should use execute_read_only_query',
					);
				}
				const database_name = resolve_database_name(database);
				if (database) set_current_database(database);
				const result = await database_client.execute_query(
					database_name,
					query,
					params,
				);
				const formatted_result = format_query_result(result);
				return create_tool_response({
					database: database_name,
					query,
					result: formatted_result,
				});
			} catch (error) {
				return create_tool_error_response(error);
			}
		},
	);
	server.tool(
		{
			name: 'describe_table',
			description: 'Gets schema information for a table',
			schema: DescribeTableSchema,
		},
		async ({ table, database }) => {
			try {
				const database_name = resolve_database_name(database);
				if (database) set_current_database(database);
				const columns = await database_client.describe_table(
					database_name,
					table,
				);
				return create_tool_response({
					database: database_name,
					table,
					columns: columns.map((col) => ({
						name: col.name,
						type: col.type,
						nullable: col.notnull === 0,
						default_value: col.dflt_value,
						primary_key: col.pk === 1,
					})),
				});
			} catch (error) {
				return create_tool_error_response(error);
			}
		},
	);
	server.tool(
		{
			name: 'vector_search',
			description: 'Performs vector similarity search',
			schema: VectorSearchSchema,
		},
		async ({ table, vector_column, query_vector, limit = 10, database }) => {
			try {
				const database_name = resolve_database_name(database);
				if (database) set_current_database(database);
				// Construct a vector search query using SQLite's vector functions
				const vector_string = query_vector.join(',');
				const query = `
          SELECT *, vector_distance(${vector_column}, vector_from_json(?)) as distance
          FROM ${table}
          ORDER BY distance ASC
          LIMIT ?
        `;
				const params = {
					1: `[${vector_string}]`,
					2: limit,
				};
				const result = await database_client.execute_query(
					database_name,
					query,
					params,
				);
				const formatted_result = format_query_result(result);
				return create_tool_response({
					database: database_name,
					table,
					vector_column,
					query_vector,
					results: formatted_result,
				});
			} catch (error) {
				return create_tool_error_response(error);
			}
		},
	);
}
/**
 * Format a query result for better readability
 * Handles BigInt serialization
 */
function format_query_result(result: ResultSet): any {
	// Convert BigInt to string to avoid serialization issues
	const lastInsertRowid =
		result.lastInsertRowid !== null &&
		typeof result.lastInsertRowid === 'bigint'
			? result.lastInsertRowid.toString()
			: result.lastInsertRowid;
	return {
		rows: result.rows,
		rowsAffected: result.rowsAffected,
		lastInsertRowid: lastInsertRowid,
		columns: result.columns,
	};
}