Skip to main content
Glama

tbls MCP Server

by yhosok
validation.ts9.43 kB
import { Result, ok, err } from 'neverthrow'; import { z } from 'zod'; /** * Custom validation error class */ export class ValidationError extends Error { public context?: Record<string, unknown>; constructor(message: string, context?: Record<string, unknown>) { super(message); this.name = 'ValidationError'; this.context = context; } } /** * SQL keywords that should be rejected for table/column names */ const SQL_KEYWORDS = new Set([ 'select', 'from', 'where', 'insert', 'update', 'delete', 'drop', 'create', 'alter', 'truncate', 'grant', 'revoke', 'union', 'join', 'inner', 'outer', 'left', 'right', 'group', 'order', 'having', 'limit', 'offset', 'distinct', 'count', 'sum', 'avg', 'max', 'min', 'as', 'and', 'or', 'not', 'null', 'true', 'false', 'is', 'in', 'exists', 'between', 'like', 'case', 'when', 'then', 'else', 'end', 'if', 'else', 'elseif', 'while', 'for', 'do', 'begin', ]); /** * Checks if a SQL query is a SELECT statement * @param query - SQL query string to validate * @returns true if query starts with SELECT (case insensitive) */ export const isSelectQuery = (query: string): boolean => { if (!query || typeof query !== 'string') { return false; } const trimmedQuery = query.trim(); if (trimmedQuery.length === 0) { return false; } // Check if query starts with SELECT (case insensitive) const selectRegex = /^\s*select\s+/i; if (!selectRegex.test(trimmedQuery)) { return false; } // Basic check for potential SQL injection attempts // Reject queries with multiple statements (semicolon followed by non-whitespace) const multiStatementRegex = /;\s*\S/; if (multiStatementRegex.test(trimmedQuery)) { return false; } return true; }; /** * Validates SQL query to ensure it's a SELECT statement * @param query - SQL query string to validate * @returns Result containing the query or error message */ export const validateSqlQuery = (query: string): Result<string, string> => { if (!query || typeof query !== 'string') { return err('SQL query must be a non-empty string'); } const trimmedQuery = query.trim(); if (trimmedQuery.length === 0) { return err('SQL query cannot be empty'); } if (!isSelectQuery(trimmedQuery)) { return err('Only SELECT queries are allowed for security reasons'); } return ok(trimmedQuery); }; /** * Generic function to validate and sanitize SQL identifiers (table names, column names, etc.) * @param identifier - Identifier to validate * @param entityType - Type of entity for error messages (e.g., 'table name', 'column name') * @returns Result containing sanitized identifier or error message */ export const sanitizeIdentifier = ( identifier: string, entityType: string ): Result<string, string> => { if (!identifier || typeof identifier !== 'string') { return err( `${entityType.charAt(0).toUpperCase() + entityType.slice(1)} must be a non-empty string` ); } const trimmedName = identifier.trim(); if (trimmedName.length === 0) { return err(`Invalid ${entityType}: cannot be empty`); } // Check for valid identifier format (letters, numbers, underscore) // Must start with letter or underscore const validIdentifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; if (!validIdentifierRegex.test(trimmedName)) { return err( `Invalid ${entityType}: must contain only letters, numbers, and underscores, and start with a letter or underscore` ); } // Check against SQL keywords if (SQL_KEYWORDS.has(trimmedName.toLowerCase())) { return err(`Invalid ${entityType}: cannot use SQL keywords`); } return ok(trimmedName); }; /** * Validates and sanitizes table names * @param tableName - Table name to validate * @returns Result containing sanitized table name or error message */ export const sanitizeTableName = ( tableName: string ): Result<string, string> => { return sanitizeIdentifier(tableName, 'table name'); }; /** * Validates and sanitizes column names * @param columnName - Column name to validate * @returns Result containing sanitized column name or error message */ export const sanitizeColumnName = ( columnName: string ): Result<string, string> => { return sanitizeIdentifier(columnName, 'column name'); }; /** * Connection string validation schema */ const MySQLConnectionStringSchema = z .string() .regex( /^mysql:\/\/[^@/]+@[^@/:]+(:\d+)?\/[^/]+$/, 'Invalid MySQL connection string format' ); const SQLitePathSchema = z .string() .min(1) .refine((path) => { // Reject URLs that look like MySQL but failed MySQL validation if (path.startsWith('mysql://')) return false; if (path.startsWith('invalid://')) return false; // Allow :memory: for in-memory databases if (path === ':memory:') return true; // Allow file: prefix if (path.startsWith('file:')) return true; // Reject paths that look like incomplete URLs if (path.includes('://')) return false; // Basic path validation (no invalid characters) const invalidChars = /[<>|"*?]/; return !invalidChars.test(path); }, 'Invalid SQLite path format'); /** * Validates database connection strings * @param connectionString - Connection string to validate * @returns Result containing validated connection string or error message */ export const validateConnectionString = ( connectionString: string ): Result<string, string> => { if (!connectionString || typeof connectionString !== 'string') { return err('Connection string must be a non-empty string'); } const trimmed = connectionString.trim(); if (trimmed.length === 0) { return err('Connection string cannot be empty'); } // Try MySQL format first const mysqlResult = MySQLConnectionStringSchema.safeParse(trimmed); if (mysqlResult.success) { return ok(trimmed); } // Try SQLite path format const sqliteResult = SQLitePathSchema.safeParse(trimmed); if (sqliteResult.success) { return ok(trimmed); } return err( 'Invalid connection string format. Must be a valid MySQL URL or SQLite path' ); }; /** * Connection info for parsed connection strings */ export type ParsedConnectionInfo = | { type: 'mysql'; host: string; port: number; user: string; password?: string; database: string; } | { type: 'sqlite'; path: string; }; /** * Parses connection string into structured information * @param connectionString - Connection string to parse * @returns Result containing parsed connection info or error message */ export const parseConnectionString = ( connectionString: string ): Result<ParsedConnectionInfo, string> => { const validationResult = validateConnectionString(connectionString); if (validationResult.isErr()) { return err(validationResult.error); } const trimmed = connectionString.trim(); // Try parsing as MySQL connection string if (trimmed.startsWith('mysql://')) { try { const url = new URL(trimmed); if ( !url.hostname || !url.username || !url.pathname || url.pathname === '/' ) { return err( 'Invalid MySQL connection string: missing required components' ); } const database = url.pathname.slice(1); // Remove leading slash const port = url.port ? parseInt(url.port, 10) : 3306; return ok({ type: 'mysql', host: url.hostname, port: port, user: url.username, password: url.password || undefined, database: database, }); } catch { return err('Invalid MySQL connection string format'); } } // Treat as SQLite path return ok({ type: 'sqlite', path: trimmed, }); }; /** * Validates file path for security * @param filePath - File path to validate * @returns Result containing validated path or error message */ export const validateFilePath = (filePath: string): Result<string, string> => { if (!filePath || typeof filePath !== 'string') { return err('File path must be a non-empty string'); } const trimmed = filePath.trim(); if (trimmed.length === 0) { return err('File path cannot be empty'); } // Check for directory traversal attempts if (trimmed.includes('..')) { return err('File path cannot contain directory traversal sequences (..)'); } // Check for potentially dangerous characters const dangerousChars = /[<>|"*?]/; if (dangerousChars.test(trimmed)) { return err('File path contains invalid characters'); } return ok(trimmed); }; /** * Validates that a string represents a positive integer * @param value - String value to validate * @returns Result containing parsed integer or error message */ export const validatePositiveInteger = ( value: string ): Result<number, string> => { if (!value || typeof value !== 'string') { return err('Value must be a non-empty string'); } const trimmed = value.trim(); const parsed = parseInt(trimmed, 10); if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { return err('Value must be a positive integer'); } // Check if the string representation matches (no leading zeros, etc.) if (parsed.toString() !== trimmed) { return err('Invalid integer format'); } return ok(parsed); };

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/yhosok/tbls-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server