diff --git a/lib/core/mcp/mcp_access_store.dart b/lib/core/mcp/mcp_access_store.dart new file mode 100644 index 00000000..f743545a --- /dev/null +++ b/lib/core/mcp/mcp_access_store.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Which connections MCP clients may read. Opt-in per connection: a +/// connection that was never enabled is invisible to every MCP tool. +abstract class McpAccessPolicy { + Future canRead(ConnectionRow row); +} + +/// [McpAccessPolicy] stored in `app_settings` as a JSON list of connection ids. +class McpAccessStore implements McpAccessPolicy { + McpAccessStore._(); + + static final McpAccessStore instance = McpAccessStore._(); + + static const settingsKey = 'mcp_read_connection_ids'; + + Future> readableIds() async { + final raw = await LocalDb.instance.getAppSetting(settingsKey); + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is List) return {for (final v in decoded) if (v is int) v}; + } catch (_) {} + return {}; + } + + Future setReadable(int connectionId, bool readable) async { + final ids = await readableIds(); + if (readable) { + ids.add(connectionId); + } else { + ids.remove(connectionId); + } + await LocalDb.instance + .setAppSetting(settingsKey, jsonEncode(ids.toList()..sort())); + } + + @override + Future canRead(ConnectionRow row) async => + row.id != null && (await readableIds()).contains(row.id); +} diff --git a/lib/core/mcp/mcp_query_service.dart b/lib/core/mcp/mcp_query_service.dart new file mode 100644 index 00000000..a6a79a1f --- /dev/null +++ b/lib/core/mcp/mcp_query_service.dart @@ -0,0 +1,411 @@ +import 'dart:async'; + +import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/mcp/mcp_access_store.dart'; +import 'package:querya_desktop/core/mcp/mcp_sql_guard.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/erd/erd_catalog.dart'; +import 'package:querya_desktop/features/erd/erd_model.dart'; +import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; + +/// A failure the MCP client should see as a tool error (the model reads the +/// message and can fix its call). +class McpToolException implements Exception { + const McpToolException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Creates a **read-only** execution delegate for [row]; the service disposes +/// it after each call. +typedef McpDelegateFactory = SqlExecutionDelegate Function( + ConnectionRow row, + SqlDialect dialect, +); + +/// Read-only SQL for MCP tools: connection list, schema, samples, queries and +/// plans for PostgreSQL, MySQL and SQLite. +/// +/// Never exposes host, user, password, SSH settings or connection strings. +/// Every query passes [McpSqlGuard] and runs on a read-only database session +/// with a statement timeout and a row limit. +class McpQueryService { + McpQueryService({ + required McpDelegateFactory createDelegate, + McpAccessPolicy? access, + Future> Function()? loadConnections, + this.maxRows = 1000, + this.maxSampleRows = 100, + this.timeout = const Duration(seconds: 15), + this.maxCellChars = 4096, + }) : _createDelegate = createDelegate, + _access = access ?? McpAccessStore.instance, + _loadConnections = + loadConnections ?? (() => LocalDb.instance.getConnections()); + + final McpDelegateFactory _createDelegate; + final McpAccessPolicy _access; + final Future> Function() _loadConnections; + + final int maxRows; + final int maxSampleRows; + final Duration timeout; + final int maxCellChars; + + /// Upper bound for catalog queries (one row per column of the schema). + static const _catalogRows = 200000; + + static SqlDialect? dialectOf(String type) => switch (type) { + 'postgresql' => SqlDialect.postgres, + 'mysql' => SqlDialect.mysql, + 'sqlite' => SqlDialect.sqlite, + _ => null, + }; + + /// Connections the user opened to MCP, without any credentials. + Future> listConnections() async { + final out = []; + for (final row in await _loadConnections()) { + if (row.id == null || dialectOf(row.type) == null) continue; + if (!await _access.canRead(row)) continue; + out.add(McpConnectionInfo.fromRow(row)); + } + return out; + } + + Future> listTables(int connectionId) async { + final schema = await _withDelegate(connectionId, _loadSchema); + return [ + for (final t in schema.tables) + McpTableSummary(name: t.name, columnCount: t.columns.length), + ]; + } + + Future describeTable( + int connectionId, + String table, + ) { + return _withDelegate(connectionId, (delegate, dialect) async { + final schema = await _loadSchema(delegate, dialect); + final t = _findTable(schema, table); + final indexes = await _run( + () => delegate.executeQuery( + _indexesSql(dialect, t.name), + limit: _catalogRows, + timeout: timeout, + ), + dialect, + ); + return McpTableDescription( + name: t.name, + columns: [ + for (final c in t.columns) + McpColumnInfo( + name: c.name, + type: c.type, + primaryKey: c.isPrimaryKey, + references: [ + for (final r in schema.relations) + if (r.fromTable == t.name && r.fromColumn == c.name) + '${r.toTable}.${r.toColumn}', + ].firstOrNull, + ), + ], + indexes: [ + for (final r in indexes.rows) + if (r.length >= 2) McpIndexInfo(name: r[0], definition: r[1]), + ], + ); + }); + } + + Future sampleRows( + int connectionId, + String table, { + int rows = 20, + }) { + final n = rows.clamp(1, maxSampleRows); + return _withDelegate(connectionId, (delegate, dialect) async { + final t = _findTable(await _loadSchema(delegate, dialect), table); + final result = await _run( + () => delegate.executeQuery( + 'SELECT * FROM ${quoteIdentifier(t.name, dialect)}', + limit: n, + timeout: timeout, + ), + dialect, + ); + return _toResult(result, n); + }); + } + + Future runQuery(int connectionId, String sql) { + return _withDelegate(connectionId, (delegate, dialect) async { + _guard(sql, dialect); + final result = await _run( + () => delegate.executeQuery(sql, limit: maxRows, timeout: timeout), + dialect, + ); + return _toResult(result, maxRows); + }); + } + + Future explainQuery(int connectionId, String sql) { + return _withDelegate(connectionId, (delegate, dialect) async { + _guard(sql, dialect); + if (!delegate.supportsExplain) { + throw const McpToolException('EXPLAIN is not supported for this connection.'); + } + final stripped = sql.trim().replaceAll(RegExp(r';\s*$'), ''); + if (RegExp(r'^explain\b', caseSensitive: false).hasMatch(stripped)) { + throw const McpToolException( + 'Pass the statement itself; explain_query adds EXPLAIN.'); + } + return _run( + () => delegate.explainQuery(stripped).timeout(timeout), + dialect, + ); + }); + } + + // --------------------------------------------------------------------------- + + static String quoteIdentifier(String name, SqlDialect dialect) => + dialect == SqlDialect.mysql + ? '`${name.replaceAll('`', '``')}`' + : '"${name.replaceAll('"', '""')}"'; + + static String _literal(String value, SqlDialect dialect) { + var v = value.replaceAll("'", "''"); + if (dialect == SqlDialect.mysql) v = v.replaceAll(r'\', r'\\'); + return "'$v'"; + } + + static String _indexesSql(SqlDialect dialect, String table) { + final t = _literal(table, dialect); + return switch (dialect) { + SqlDialect.postgres => 'SELECT indexname, indexdef FROM pg_indexes ' + 'WHERE schemaname = current_schema() AND tablename = $t ORDER BY indexname', + SqlDialect.mysql => "SELECT index_name, CONCAT(IF(MIN(non_unique) = 0, 'UNIQUE ', ''), " + "'(', GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ', '), ')') " + 'FROM information_schema.statistics ' + 'WHERE table_schema = DATABASE() AND table_name = $t ' + 'GROUP BY index_name ORDER BY index_name', + SqlDialect.sqlite => "SELECT name, COALESCE(sql, '(automatic)') FROM sqlite_master " + "WHERE type = 'index' AND tbl_name = $t ORDER BY name", + }; + } + + void _guard(String sql, SqlDialect dialect) { + final reason = McpSqlGuard.check(sql, dialect); + if (reason != null) throw McpToolException(reason); + } + + Future _loadSchema( + SqlExecutionDelegate delegate, + SqlDialect dialect, + ) async { + final cols = await _run( + () => delegate.executeQuery(ErdCatalog.columnsSql(dialect), + limit: _catalogRows, timeout: timeout), + dialect, + ); + final fks = await _run( + () => delegate.executeQuery(ErdCatalog.foreignKeysSql(dialect), + limit: _catalogRows, timeout: timeout), + dialect, + ); + return ErdSchema.fromCatalog(columnRows: cols.rows, fkRows: fks.rows); + } + + /// Exact name first, then a unique case-insensitive match. The table must + /// exist in the catalog, so model input never reaches SQL unchecked. + ErdTable _findTable(ErdSchema schema, String name) { + final exact = schema.tables.where((t) => t.name == name); + if (exact.isNotEmpty) return exact.first; + final lower = name.toLowerCase(); + final loose = + schema.tables.where((t) => t.name.toLowerCase() == lower).toList(); + if (loose.length == 1) return loose.single; + throw McpToolException( + 'Table "$name" not found. Call list_tables to see the available tables.'); + } + + Future _run(Future Function() body, SqlDialect dialect) async { + try { + return await body(); + } on McpToolException { + rethrow; + } on TimeoutException { + throw McpToolException( + 'The query exceeded the ${timeout.inSeconds} s time limit.'); + } catch (e) { + throw McpToolException(describeDatabaseError(e, driver: _driver(dialect))); + } + } + + static DatabaseDriver _driver(SqlDialect d) => switch (d) { + SqlDialect.postgres => DatabaseDriver.postgres, + SqlDialect.mysql => DatabaseDriver.mysql, + SqlDialect.sqlite => DatabaseDriver.sqlite, + }; + + McpQueryResult _toResult(SqlExecutionResult r, int limit) { + final rows = r.rows.length > limit ? r.rows.sublist(0, limit) : r.rows; + return McpQueryResult( + columns: r.columns, + rows: [ + for (final row in rows) [for (final cell in row) _cell(cell)], + ], + truncated: r.isTruncated || r.rows.length > limit, + ); + } + + String _cell(String value) { + if (value.length <= maxCellChars) return value; + final cut = value.length - maxCellChars; + return '${value.substring(0, maxCellChars)}… [truncated $cut chars]'; + } + + Future _withDelegate( + int connectionId, + Future Function(SqlExecutionDelegate delegate, SqlDialect dialect) body, + ) async { + final row = await _readableConnection(connectionId); + final dialect = dialectOf(row.type)!; + final delegate = _createDelegate(row, dialect); + try { + return await body(delegate, dialect); + } finally { + delegate.dispose(); + } + } + + Future _readableConnection(int id) async { + for (final row in await _loadConnections()) { + if (row.id != id) continue; + if (dialectOf(row.type) == null || !await _access.canRead(row)) break; + return row; + } + // Same message for "missing" and "not shared" so ids cannot be probed. + throw McpToolException( + 'Connection $id is not available. Call list_connections to see the shared connections.'); + } +} + +class McpConnectionInfo { + const McpConnectionInfo({ + required this.id, + required this.name, + required this.type, + this.environment, + this.database, + }); + + factory McpConnectionInfo.fromRow(ConnectionRow row) => McpConnectionInfo( + id: row.id!, + name: row.name, + type: row.type, + environment: row.environment?.storageValue, + // SQLite keeps the file path in `host`; the model only gets the name. + database: row.type == 'sqlite' + ? row.host?.split(RegExp(r'[\\/]')).last + : row.databaseName, + ); + + final int id; + final String name; + final String type; + final String? environment; + final String? database; + + Map toJson() => { + 'id': id, + 'name': name, + 'type': type, + if (environment != null) 'environment': environment, + if (database != null && database!.isNotEmpty) 'database': database, + }; +} + +class McpTableSummary { + const McpTableSummary({required this.name, required this.columnCount}); + + final String name; + final int columnCount; + + Map toJson() => {'name': name, 'columns': columnCount}; +} + +class McpColumnInfo { + const McpColumnInfo({ + required this.name, + required this.type, + this.primaryKey = false, + this.references, + }); + + final String name; + final String type; + final bool primaryKey; + + /// `table.column` this column points to, when it is a foreign key. + final String? references; + + Map toJson() => { + 'name': name, + 'type': type, + if (primaryKey) 'primary_key': true, + if (references != null) 'references': references, + }; +} + +class McpIndexInfo { + const McpIndexInfo({required this.name, required this.definition}); + + final String name; + final String definition; + + Map toJson() => {'name': name, 'definition': definition}; +} + +class McpTableDescription { + const McpTableDescription({ + required this.name, + required this.columns, + required this.indexes, + }); + + final String name; + final List columns; + final List indexes; + + Map toJson() => { + 'name': name, + 'columns': [for (final c in columns) c.toJson()], + 'indexes': [for (final i in indexes) i.toJson()], + }; +} + +class McpQueryResult { + const McpQueryResult({ + required this.columns, + required this.rows, + required this.truncated, + }); + + final List columns; + final List> rows; + final bool truncated; + + Map toJson() => { + 'columns': columns, + 'rows': rows, + 'row_count': rows.length, + 'truncated': truncated, + }; +} diff --git a/lib/core/mcp/mcp_sql_delegates.dart b/lib/core/mcp/mcp_sql_delegates.dart new file mode 100644 index 00000000..4ca60b9b --- /dev/null +++ b/lib/core/mcp/mcp_sql_delegates.dart @@ -0,0 +1,30 @@ +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_workspace.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sql_workspace.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_sql_workspace.dart'; +import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; + +/// Production [McpDelegateFactory]: the SQL editor's delegates, always on a +/// read-only session (Postgres `default_transaction_read_only`, MySQL +/// `SET SESSION TRANSACTION READ ONLY`, SQLite `SQLITE_OPEN_READONLY`). +SqlExecutionDelegate createReadOnlyMcpDelegate( + ConnectionRow row, + SqlDialect dialect, +) { + switch (dialect) { + case SqlDialect.postgres: + final db = row.databaseName?.trim(); + return PostgresSqlExecutionDelegate( + connectionRow: row, + isReadOnly: true, + effectiveDatabaseProvider: () => + db == null || db.isEmpty ? 'postgres' : db, + autocommitProvider: () => true, + ); + case SqlDialect.mysql: + return MysqlSqlExecutionDelegate(connectionRow: row, isReadOnly: true); + case SqlDialect.sqlite: + return SqliteSqlExecutionDelegate(connectionRow: row, isReadOnly: true); + } +} diff --git a/lib/core/mcp/mcp_sql_guard.dart b/lib/core/mcp/mcp_sql_guard.dart new file mode 100644 index 00000000..8ef5e3d9 --- /dev/null +++ b/lib/core/mcp/mcp_sql_guard.dart @@ -0,0 +1,101 @@ +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/sql_mutation_classifier.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; + +/// Decides whether SQL sent by an MCP client may run. +/// +/// First of three read-only layers (the others are the read-only database +/// session and the per-connection access policy). Conservative on purpose: +/// anything it cannot prove harmless is refused with a message the model can +/// act on. +abstract final class McpSqlGuard { + static const _readStarts = { + 'SELECT', + 'WITH', + 'EXPLAIN', + 'SHOW', + 'DESCRIBE', + 'DESC', + 'VALUES', + }; + + static final _firstWord = RegExp(r'^\(*\s*([A-Z_]+)'); + + /// `SELECT ... INTO` creates a table (Postgres) or writes a file (MySQL + /// `INTO OUTFILE`); row locks have no place in read-only exploration. + static final _selectSideEffects = RegExp( + r'\bINTO\b|\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b|\bLOCK\s+IN\s+SHARE\s+MODE\b', + ); + + /// `EXPLAIN ANALYZE` executes the statement; the rest would explain a write. + static final _explainForbidden = RegExp( + r'\b(ANALYZE|ANALYSE|INSERT|UPDATE|DELETE|MERGE|REPLACE|CREATE|DROP|ALTER|TRUNCATE|GRANT|REVOKE|CALL|DO|COPY)\b', + ); + + /// Functions that act on the server even inside a read-only transaction. + static final _forbiddenFunctions = RegExp( + r'\b(PG_TERMINATE_BACKEND|PG_CANCEL_BACKEND|PG_RELOAD_CONF|PG_ROTATE_LOGFILE|' + r'PG_READ_FILE|PG_READ_BINARY_FILE|PG_LS_DIR|PG_STAT_FILE|LO_IMPORT|LO_EXPORT|' + r'DBLINK\w*|SET_CONFIG|LOAD_FILE|LOAD_EXTENSION|WRITEFILE|READFILE|FSDIR)\s*\(', + ); + + /// SQLite pragmas that only read the schema. + static const _readPragmas = { + 'TABLE_INFO', + 'TABLE_XINFO', + 'TABLE_LIST', + 'INDEX_LIST', + 'INDEX_INFO', + 'INDEX_XINFO', + 'FOREIGN_KEY_LIST', + 'DATABASE_LIST', + 'COLLATION_LIST', + }; + + static final _pragma = + RegExp(r'^PRAGMA\s+(?:[A-Z_]+\.)?([A-Z_]+)\s*(\(\s*[A-Z_0-9]*\s*\))?$'); + + /// Returns `null` when [sql] may run, or the reason it may not. + static String? check(String sql, SqlDialect dialect) { + final statements = DestructiveSqlDetector.splitStatements(sql) + .where((s) => DestructiveSqlDetector.stripCommentsAndStrings(s) + .trim() + .isNotEmpty) + .toList(); + if (statements.isEmpty) return 'The query is empty.'; + if (statements.length > 1) { + return 'Only one statement per call is allowed; send them separately.'; + } + final statement = statements.single; + final upper = DestructiveSqlDetector.stripCommentsAndStrings(statement) + .trim() + .replaceAll(RegExp(r';\s*$'), '') + .toUpperCase(); + final first = _firstWord.firstMatch(upper)?.group(1); + + if (first == 'PRAGMA' && dialect == SqlDialect.sqlite) { + final m = _pragma.firstMatch(upper.replaceAll(RegExp(r'\s+'), ' ')); + if (m != null && _readPragmas.contains(m.group(1))) return null; + return 'Only schema pragmas are allowed (${_readPragmas.map((p) => p.toLowerCase()).join(', ')}).'; + } + if (first == null || !_readStarts.contains(first)) { + return 'Only read-only queries are allowed (SELECT, WITH, EXPLAIN, SHOW, DESCRIBE).'; + } + if (_forbiddenFunctions.hasMatch(upper)) { + return 'This query calls a server function that is not allowed over MCP.'; + } + if (first == 'EXPLAIN') { + if (_explainForbidden.hasMatch(upper)) { + return 'EXPLAIN is allowed only for read-only statements and without ANALYZE.'; + } + return null; + } + if (isMutatingSqlStatement(statement)) { + return 'Data-modifying statements are not allowed over MCP.'; + } + if (_selectSideEffects.hasMatch(upper)) { + return 'SELECT ... INTO and row locks (FOR UPDATE / FOR SHARE) are not allowed.'; + } + return null; + } +} diff --git a/lib/features/erd/erd_catalog.dart b/lib/features/erd/erd_catalog.dart index 326a6194..4c960cbb 100644 --- a/lib/features/erd/erd_catalog.dart +++ b/lib/features/erd/erd_catalog.dart @@ -4,6 +4,9 @@ import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; /// Catalog queries per dialect. Column query yields `table, column, type, /// isPk`; FK query yields `table, column, refTable, refColumn`. +/// +/// Every output column has a distinct alias: the SQLite driver returns rows as +/// maps keyed by column name, so two `name` columns would collapse into one. class ErdCatalog { ErdCatalog._(); @@ -36,7 +39,8 @@ JOIN information_schema.tables t WHERE c.table_schema = DATABASE() ORDER BY c.table_name, c.ordinal_position''', SqlDialect.sqlite => ''' -SELECT m.name, p.name, p.type, CASE WHEN p.pk > 0 THEN 1 ELSE 0 END +SELECT m.name AS table_name, p.name AS column_name, p.type AS data_type, + CASE WHEN p.pk > 0 THEN 1 ELSE 0 END AS is_pk FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid''', @@ -44,7 +48,8 @@ ORDER BY m.name, p.cid''', static String foreignKeysSql(SqlDialect d) => switch (d) { SqlDialect.postgres => ''' -SELECT kcu.table_name, kcu.column_name, ccu.table_name, ccu.column_name +SELECT kcu.table_name AS table_name, kcu.column_name AS column_name, + ccu.table_name AS ref_table, ccu.column_name AS ref_column FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name @@ -58,7 +63,8 @@ SELECT table_name, column_name, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE table_schema = DATABASE() AND referenced_table_name IS NOT NULL''', SqlDialect.sqlite => ''' -SELECT m.name, f."from", f."table", f."to" +SELECT m.name AS table_name, f."from" AS column_name, + f."table" AS ref_table, f."to" AS ref_column FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) f WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' ''', diff --git a/test/core/mcp/mcp_query_service_test.dart b/test/core/mcp/mcp_query_service_test.dart new file mode 100644 index 00000000..7c79d91c --- /dev/null +++ b/test/core/mcp/mcp_query_service_test.dart @@ -0,0 +1,269 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/mcp/mcp_access_store.dart'; +import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; +import 'package:querya_desktop/core/security/connection_environment.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; + +import '../../support/fake_sql_execution_delegate.dart'; + +class _Access implements McpAccessPolicy { + _Access(this.ids); + final Set ids; + @override + Future canRead(ConnectionRow row) async => ids.contains(row.id); +} + +/// Answers catalog queries for a `users` / `orders` schema; records the rest. +class _Db extends FakeSqlExecutionDelegate { + final limits = []; + final timeouts = []; + + @override + Future executeQuery(String sql, + {int? limit, Duration? timeout}) async { + limits.add(limit); + timeouts.add(timeout); + return super.executeQuery(sql, limit: limit, timeout: timeout); + } +} + +const _secret = 'S3cret-Pw-77'; + +ConnectionRow _row(int id, String type, {String? env}) { + final row = ConnectionRow( + id: id, + type: type, + name: 'conn $id', + host: type == 'sqlite' ? '/home/me/data/shop.db' : 'db.internal', + port: 5432, + username: 'admin', + password: _secret, + databaseName: type == 'sqlite' ? null : 'shop', + connectionString: 'postgresql://admin:$_secret@db.internal/shop', + createdAt: DateTime.utc(2026).toIso8601String(), + ); + return env == null + ? row + : row.withEnvironment(ConnectionEnvironment.values + .firstWhere((e) => e.storageValue == env)); +} + +SqlExecutionResult _catalog(String sql) { + if (sql.contains('pg_indexes')) { + return const SqlExecutionResult(columns: ['n', 'd'], rows: [ + ['users_pkey', 'CREATE UNIQUE INDEX users_pkey ON users (id)'], + ]); + } + if (sql.contains('FOREIGN KEY')) { + return const SqlExecutionResult(columns: ['t', 'c', 'rt', 'rc'], rows: [ + ['orders', 'user_id', 'users', 'id'], + ]); + } + if (sql.contains('information_schema.columns')) { + return const SqlExecutionResult(columns: ['t', 'c', 'ty', 'pk'], rows: [ + ['users', 'id', 'integer', '1'], + ['users', 'name', 'text', '0'], + ['orders', 'id', 'integer', '1'], + ['orders', 'user_id', 'integer', '0'], + ]); + } + return const SqlExecutionResult(columns: ['n'], rows: [ + ['1'], + ]); +} + +void main() { + late _Db db; + late List dialects; + late List rows; + + McpQueryService service({Set shared = const {1, 2, 3}, int maxCell = 4096}) => + McpQueryService( + createDelegate: (row, dialect) { + dialects.add(dialect); + return db; + }, + access: _Access(shared), + loadConnections: () async => rows, + maxCellChars: maxCell, + ); + + setUp(() { + db = _Db()..onExecute = _catalog; + dialects = []; + rows = [ + _row(1, 'postgresql', env: 'production'), + _row(2, 'mysql'), + _row(3, 'sqlite'), + _row(4, 'postgresql'), // not shared + _row(5, 'mongodb'), // not SQL + ]; + }); + + group('listConnections', () { + test('only shared SQL connections, without credentials', () async { + final list = await service().listConnections(); + expect(list.map((c) => c.id), [1, 2, 3]); + + final json = jsonEncode([for (final c in list) c.toJson()]); + for (final leak in [_secret, 'admin', 'db.internal', '/home/me', '5432']) { + expect(json, isNot(contains(leak)), reason: leak); + } + expect(list.first.environment, 'production'); + expect(list.first.database, 'shop'); + expect(list.last.database, 'shop.db'); + }); + + test('nothing is shared by default', () async { + expect(await service(shared: {}).listConnections(), isEmpty); + }); + }); + + group('connection access', () { + test('a connection that is not shared looks like a missing one', () async { + final s = service(); + Future msg(int id) async { + try { + await s.runQuery(id, 'SELECT 1'); + } on McpToolException catch (e) { + return e.message.replaceAll('$id', '#'); + } + fail('expected an error'); + } + + expect(await msg(4), await msg(99)); + expect(db.executed, isEmpty); + }); + + test('a non-SQL connection is refused', () async { + await expectLater(service(shared: {5}).runQuery(5, 'SELECT 1'), + throwsA(isA())); + }); + + test('the dialect follows the connection type', () async { + final s = service(); + await s.runQuery(1, 'SELECT 1'); + await s.runQuery(2, 'SELECT 1'); + await s.runQuery(3, 'SELECT 1'); + expect(dialects, [SqlDialect.postgres, SqlDialect.mysql, SqlDialect.sqlite]); + }); + }); + + group('runQuery', () { + test('a refused statement never reaches the database', () async { + await expectLater(service().runQuery(1, 'DELETE FROM users'), + throwsA(isA())); + await expectLater(service().runQuery(1, 'SELECT 1; DROP TABLE users'), + throwsA(isA())); + expect(db.executed, isEmpty); + }); + + test('runs with the row limit and the timeout and disposes the delegate', + () async { + final r = await service().runQuery(1, 'SELECT 1'); + expect(r.columns, ['n']); + expect(r.rows, [ + ['1'], + ]); + expect(db.limits.single, 1000); + expect(db.timeouts.single, const Duration(seconds: 15)); + expect(db.disposeCount, 1); + }); + + test('a capped result is reported as truncated', () async { + db.onExecute = (_) => const SqlExecutionResult( + columns: ['n'], + rows: [ + ['1'], + ], + isTruncated: true); + expect((await service().runQuery(1, 'SELECT 1')).truncated, isTrue); + }); + + test('long cells are cut with a marker', () async { + db.onExecute = (_) => SqlExecutionResult(columns: const ['t'], rows: [ + ['x' * 50], + ]); + final r = await service(maxCell: 10).runQuery(1, 'SELECT t FROM x'); + expect(r.rows.single.single, '${'x' * 10}… [truncated 40 chars]'); + }); + + test('database errors come back as tool errors', () async { + db.onExecute = (_) => throw StateError('relation "nope" does not exist'); + await expectLater(service().runQuery(1, 'SELECT * FROM nope'), + throwsA(isA())); + expect(db.disposeCount, 1); + }); + + test('a timeout becomes a readable error', () async { + db.onExecute = (_) => throw TimeoutException('slow'); + await expectLater( + service().runQuery(1, 'SELECT pg_sleep(60)'), + throwsA(isA().having( + (e) => e.message, 'message', contains('time limit'))), + ); + }); + }); + + group('schema tools', () { + test('listTables reports tables and column counts', () async { + final tables = await service().listTables(1); + expect([for (final t in tables) t.toJson()], [ + {'name': 'users', 'columns': 2}, + {'name': 'orders', 'columns': 2}, + ]); + }); + + test('describeTable has keys, references and indexes', () async { + final users = await service().describeTable(1, 'users'); + expect(users.columns.first.primaryKey, isTrue); + expect(users.indexes.single.name, 'users_pkey'); + + final orders = await service().describeTable(1, 'ORDERS'); + expect(orders.name, 'orders'); + expect(orders.columns.last.references, 'users.id'); + }); + + test('an unknown table is an error and runs no table query', () async { + await expectLater(service().describeTable(1, 'users; DROP TABLE x'), + throwsA(isA())); + expect(db.executed.where((s) => s.contains('DROP')), isEmpty); + }); + + test('sampleRows quotes the catalog name and clamps the count', () async { + await service().sampleRows(2, 'users', rows: 5000); + expect(db.executed.last, 'SELECT * FROM `users`'); + expect(db.limits.last, 100); + + await service().sampleRows(1, 'users', rows: 0); + expect(db.executed.last, 'SELECT * FROM "users"'); + expect(db.limits.last, 1); + }); + }); + + group('explainQuery', () { + test('explains a read and refuses a write', () async { + db.explainPlan = 'Seq Scan on users'; + expect(await service().explainQuery(1, 'SELECT * FROM users;'), + 'Seq Scan on users'); + expect(db.explained, ['SELECT * FROM users']); + + await expectLater(service().explainQuery(1, 'DELETE FROM users'), + throwsA(isA())); + await expectLater(service().explainQuery(1, 'EXPLAIN SELECT 1'), + throwsA(isA())); + expect(db.explained, hasLength(1)); + }); + + test('a driver without EXPLAIN says so', () async { + db.explainSupported = false; + await expectLater(service().explainQuery(1, 'SELECT 1'), + throwsA(isA())); + }); + }); +} diff --git a/test/core/mcp/mcp_sql_guard_test.dart b/test/core/mcp/mcp_sql_guard_test.dart new file mode 100644 index 00000000..767e80e1 --- /dev/null +++ b/test/core/mcp/mcp_sql_guard_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/mcp/mcp_sql_guard.dart'; + +void main() { + void allowed(String sql, [SqlDialect d = SqlDialect.postgres]) => + expect(McpSqlGuard.check(sql, d), isNull, reason: sql); + void refused(String sql, [SqlDialect d = SqlDialect.postgres]) => + expect(McpSqlGuard.check(sql, d), isNotNull, reason: sql); + + test('read-only statements are allowed', () { + allowed('SELECT 1'); + allowed('select * from users where id = 1;'); + allowed(' -- comment\n SELECT name FROM users'); + allowed('(SELECT 1) UNION (SELECT 2)'); + allowed('WITH t AS (SELECT 1 AS n) SELECT n FROM t'); + allowed('EXPLAIN SELECT * FROM users'); + allowed('EXPLAIN (FORMAT JSON) SELECT 1'); + allowed('SHOW search_path'); + allowed('DESCRIBE users', SqlDialect.mysql); + allowed('VALUES (1), (2)'); + allowed("SELECT 'DROP TABLE users; DELETE FROM x' AS text"); + allowed('SELECT 1 /* UPDATE users SET a = 1 */'); + }); + + test('writes and DDL are refused', () { + for (final sql in [ + 'INSERT INTO users VALUES (1)', + 'UPDATE users SET name = 1', + 'DELETE FROM users', + 'MERGE INTO t USING s ON true WHEN MATCHED THEN DELETE', + 'REPLACE INTO users VALUES (1)', + 'TRUNCATE users', + 'DROP TABLE users', + 'CREATE TABLE x (id int)', + 'ALTER TABLE users ADD c int', + 'GRANT ALL ON users TO bob', + 'COPY users TO \'/tmp/x\'', + 'CALL do_things()', + 'DO \$\$ BEGIN END \$\$', + 'SET ROLE admin', + 'BEGIN', + 'VACUUM', + 'ATTACH DATABASE \'/tmp/x.db\' AS x', + ]) { + refused(sql); + } + }); + + test('data-modifying CTEs are refused', () { + refused('WITH d AS (DELETE FROM users RETURNING *) SELECT * FROM d'); + refused('WITH u AS (UPDATE users SET a = 1 RETURNING id) SELECT 1'); + }); + + test('more than one statement is refused', () { + refused('SELECT 1; DROP TABLE users'); + refused('SELECT 1; SELECT 2'); + }); + + test('empty input is refused', () { + refused(''); + refused(' -- only a comment'); + }); + + test('EXPLAIN ANALYZE and EXPLAIN of a write are refused', () { + refused('EXPLAIN ANALYZE SELECT 1'); + refused('EXPLAIN (ANALYZE, BUFFERS) SELECT 1'); + refused('EXPLAIN DELETE FROM users'); + refused('EXPLAIN ANALYZE DELETE FROM users'); + }); + + test('SELECT INTO, OUTFILE and row locks are refused', () { + refused('SELECT * INTO backup FROM users'); + refused("SELECT * FROM users INTO OUTFILE '/tmp/u.csv'", SqlDialect.mysql); + refused('SELECT * FROM users FOR UPDATE'); + refused('SELECT * FROM users FOR SHARE'); + refused('SELECT * FROM users LOCK IN SHARE MODE', SqlDialect.mysql); + }); + + test('server-side functions with effects are refused', () { + refused('SELECT pg_terminate_backend(123)'); + refused('SELECT pg_read_file(\'/etc/passwd\')'); + refused('SELECT * FROM dblink(\'x\', \'DELETE FROM y\') AS t(a int)'); + refused("SELECT set_config('default_transaction_read_only', 'off', false)"); + refused("SELECT LOAD_FILE('/etc/passwd')", SqlDialect.mysql); + refused("SELECT load_extension('evil')", SqlDialect.sqlite); + }); + + test('SQLite schema pragmas are allowed, others are refused', () { + allowed('PRAGMA table_info(users)', SqlDialect.sqlite); + allowed('PRAGMA index_list(users)', SqlDialect.sqlite); + allowed('PRAGMA foreign_key_list(users)', SqlDialect.sqlite); + allowed('PRAGMA database_list', SqlDialect.sqlite); + refused('PRAGMA query_only = OFF', SqlDialect.sqlite); + refused('PRAGMA writable_schema = ON', SqlDialect.sqlite); + refused('PRAGMA journal_mode', SqlDialect.sqlite); + refused('PRAGMA table_info(users)', SqlDialect.postgres); + }); +} diff --git a/test/core/mcp/mcp_sqlite_readonly_test.dart b/test/core/mcp/mcp_sqlite_readonly_test.dart new file mode 100644 index 00000000..ac8f34bb --- /dev/null +++ b/test/core/mcp/mcp_sqlite_readonly_test.dart @@ -0,0 +1,90 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/mcp/mcp_access_store.dart'; +import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; +import 'package:querya_desktop/core/mcp/mcp_sql_delegates.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _All implements McpAccessPolicy { + @override + Future canRead(ConnectionRow row) async => true; +} + +/// The real SQLite path: the MCP delegate opens the file read-only, so even a +/// write that slipped past the guard is refused by the database. +void main() { + late Directory dir; + late ConnectionRow row; + late McpQueryService service; + + setUpAll(() async { + sqfliteFfiInit(); + dir = await Directory.systemTemp.createTemp('querya_mcp_sqlite_'); + final path = '${dir.path}/shop.db'; + final seed = SqliteConnection( + id: 1, name: 'seed', path: path, createIfMissing: true); + await seed.connect(); + await seed.execute( + 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'); + await seed.execute('CREATE TABLE orders (id INTEGER PRIMARY KEY, ' + 'user_id INTEGER REFERENCES users(id), total REAL)'); + await seed.execute('CREATE INDEX orders_user ON orders (user_id)'); + await seed.execute("INSERT INTO users VALUES (1, 'ann'), (2, 'bob')"); + await seed.execute('INSERT INTO orders VALUES (1, 1, 9.5)'); + await seed.disconnect(); + + row = ConnectionRow( + id: 42, + type: 'sqlite', + name: 'Shop', + host: path, + createdAt: DateTime.utc(2026).toIso8601String(), + ); + service = McpQueryService( + createDelegate: createReadOnlyMcpDelegate, + access: _All(), + loadConnections: () async => [row], + ); + }); + + tearDownAll(() async { + await SqliteService.instance.disconnectAll(); + await dir.delete(recursive: true); + }); + + test('schema, samples and queries work on a read-only session', () async { + final tables = await service.listTables(42); + expect(tables.map((t) => t.name), containsAll(['users', 'orders'])); + + final orders = await service.describeTable(42, 'orders'); + expect(orders.columns.firstWhere((c) => c.name == 'user_id').references, + 'users.id'); + expect(orders.indexes.map((i) => i.name), contains('orders_user')); + + final sample = await service.sampleRows(42, 'users', rows: 1); + expect(sample.rows, hasLength(1)); + + final r = await service.runQuery(42, + 'SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id'); + expect(r.rows.single, ['ann', '9.5']); + + final plan = await service.explainQuery(42, 'SELECT * FROM orders WHERE user_id = 1'); + expect(plan, isNotEmpty); + }); + + test('the database itself refuses a write on the MCP session', () async { + final delegate = createReadOnlyMcpDelegate(row, SqlDialect.sqlite); + addTearDown(delegate.dispose); + await expectLater( + delegate.executeQuery("INSERT INTO users VALUES (3, 'eve')"), + throwsA(anything), + ); + final check = await service.runQuery(42, 'SELECT COUNT(*) FROM users'); + expect(check.rows.single.single, '2'); + }); +}