diff --git a/lib/core/mcp/mcp_access_store.dart b/lib/core/mcp/mcp_access_store.dart index f743545a..217b2c38 100644 --- a/lib/core/mcp/mcp_access_store.dart +++ b/lib/core/mcp/mcp_access_store.dart @@ -8,14 +8,21 @@ abstract class McpAccessPolicy { Future canRead(ConnectionRow row); } +/// Read and change which connections are shared (settings page). +abstract class McpAccessSettings implements McpAccessPolicy { + Future> readableIds(); + Future setReadable(int connectionId, bool readable); +} + /// [McpAccessPolicy] stored in `app_settings` as a JSON list of connection ids. -class McpAccessStore implements McpAccessPolicy { +class McpAccessStore implements McpAccessSettings { McpAccessStore._(); static final McpAccessStore instance = McpAccessStore._(); static const settingsKey = 'mcp_read_connection_ids'; + @override Future> readableIds() async { final raw = await LocalDb.instance.getAppSetting(settingsKey); if (raw == null || raw.isEmpty) return {}; @@ -26,6 +33,7 @@ class McpAccessStore implements McpAccessPolicy { return {}; } + @override Future setReadable(int connectionId, bool readable) async { final ids = await readableIds(); if (readable) { diff --git a/lib/core/mcp/mcp_client_config.dart b/lib/core/mcp/mcp_client_config.dart new file mode 100644 index 00000000..cae48ca8 --- /dev/null +++ b/lib/core/mcp/mcp_client_config.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; +import 'dart:io'; + +/// MCP clients Querya can write a configuration snippet for. +enum McpClientKind { + claudeDesktop('Claude Desktop', 'claude_desktop_config.json'), + cursor('Cursor', '~/.cursor/mcp.json'), + vsCode('VS Code', '.vscode/mcp.json'), + generic('Other (stdio)', 'your client\'s MCP settings'); + + const McpClientKind(this.label, this.configLocation); + + final String label; + + /// Where the user pastes the snippet. + final String configLocation; +} + +/// Finds `querya-mcp` and builds client configuration snippets. +abstract final class McpClientConfig { + static String get executableName => + Platform.isWindows ? 'querya-mcp.exe' : 'querya-mcp'; + + /// `querya-mcp` next to the running app binary (bundled in Linux and + /// Windows builds), or `null` when it is not there. + static String? bundledShimPath({String? appExecutable}) { + final exe = appExecutable ?? Platform.resolvedExecutable; + if (exe.isEmpty) return null; + final dir = File(exe).parent.path; + final candidate = File('$dir${Platform.pathSeparator}$executableName'); + return candidate.existsSync() ? candidate.path : null; + } + + /// JSON the user pastes into [kind]'s configuration. + static String snippet(McpClientKind kind, String shimPath) { + final Map json = switch (kind) { + McpClientKind.vsCode => { + 'servers': { + 'querya': {'type': 'stdio', 'command': shimPath}, + }, + }, + McpClientKind.claudeDesktop || + McpClientKind.cursor || + McpClientKind.generic => + { + 'mcpServers': { + 'querya': {'command': shimPath}, + }, + }, + }; + return const JsonEncoder.withIndent(' ').convert(json); + } +} diff --git a/lib/core/mcp/mcp_server_controller.dart b/lib/core/mcp/mcp_server_controller.dart index dc62f92d..9affc85b 100644 --- a/lib/core/mcp/mcp_server_controller.dart +++ b/lib/core/mcp/mcp_server_controller.dart @@ -17,17 +17,23 @@ class McpServerController { McpQueryService? service, File? endpointFile, String? version, - }) : _service = service ?? + bool logActivity = false, + }) : _logActivity = logActivity, + _service = service ?? McpQueryService(createDelegate: createReadOnlyMcpDelegate), _endpointFile = endpointFile ?? McpEndpoint.defaultFile(), _versionOverride = version; - static final McpServerController instance = McpServerController(); + static final McpServerController instance = + McpServerController(logActivity: true); /// `app_settings` key; the server is off unless the user enables it. static const enabledKey = 'mcp_server_enabled'; final McpQueryService _service; + + /// Persist every call to the `mcp_activity` table (the app instance only). + final bool _logActivity; final File _endpointFile; final String? _versionOverride; @@ -84,7 +90,7 @@ class McpServerController { channel, service: _service, version: version, - onCall: _calls.add, + onCall: _onCall, ); _servers.add(server); await server.done; @@ -109,6 +115,41 @@ class McpServerController { status.value = McpServerStatus.stopped; } + void _onCall(McpCallRecord r) { + if (_logActivity) unawaited(_persist(r)); + _calls.add(r); + } + + Future _persist(McpCallRecord r) async { + String? name; + if (r.connectionId != null) { + try { + name = (await LocalDb.instance.getConnectionById(r.connectionId!)) + ?.name; + } catch (_) {} + } + final sql = r.sql; + await LocalDb.instance.recordMcpActivity(McpActivityEntry( + recordedAt: r.at.toUtc().toIso8601String(), + client: r.client, + tool: r.tool, + connectionId: r.connectionId, + connectionName: name, + sqlText: sql == null || sql.length <= 2000 ? sql : sql.substring(0, 2000), + rowCount: r.rowCount, + durationMs: r.duration.inMilliseconds, + error: r.error, + )); + } + + /// Stops and starts again with a new token; connected clients must + /// reconnect (their `querya-mcp` reads the new endpoint file). + Future regenerateToken() async { + if (_host == null) return; + await stop(); + await start(); + } + void _publish(McpSocketHost host) { if (_host != host && _host != null) return; status.value = host.isRunning diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 789f8cc1..ac0d41ee 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -11,7 +11,7 @@ import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; -const _dbVersion = 10; +const _dbVersion = 11; /// `app_settings` key under which each profile database's random id is /// stored (see [LocalDb._ensureProfileId] and issue #986). @@ -180,6 +180,7 @@ class LocalDb { ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) '''); await _createMutationAuditTable(db); + await _createMcpActivityTable(db); ConnectionSecretsStore.profileId = await _ensureProfileId(db); } @@ -206,6 +207,25 @@ class LocalDb { '''); } + /// Calls made by MCP clients (#1136), newest kept, capped at + /// [kMcpActivityCap]. Connection name is a snapshot, like the audit. + Future _createMcpActivityTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS mcp_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at TEXT NOT NULL, + client TEXT NOT NULL, + tool TEXT NOT NULL, + connection_id INTEGER, + connection_name TEXT, + sql_text TEXT, + row_count INTEGER, + duration_ms INTEGER NOT NULL, + error TEXT + ) + '''); + } + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { // Every upgrade path needs app_settings before it can read/write the // profile id; the oldVersion < 4 block below also creates this table for @@ -317,6 +337,9 @@ class LocalDb { if (oldVersion < 10) { await _createMutationAuditTable(db); } + if (oldVersion < 11) { + await _createMcpActivityTable(db); + } } Future getAppSetting(String key) async { @@ -540,6 +563,36 @@ class LocalDb { _mutationAuditInsertCount = 0; } + /// Appends one MCP tool call and keeps the newest [kMcpActivityCap]. + /// Never throws: logging must not break a tool call. + Future recordMcpActivity(McpActivityEntry entry) async { + try { + final db = await _open(); + final map = entry.toMap()..remove('id'); + await db.insert('mcp_activity', map); + await db.rawDelete( + 'DELETE FROM mcp_activity WHERE id <= (' + 'SELECT id FROM mcp_activity ORDER BY id DESC LIMIT 1 OFFSET ?)', + [kMcpActivityCap], + ); + } catch (_) { + // Best effort. + } + } + + /// Newest-first MCP tool calls. + Future> listMcpActivity({int limit = 200}) async { + final db = await _open(); + final rows = + await db.query('mcp_activity', orderBy: 'id DESC', limit: limit); + return rows.map(McpActivityEntry.fromMap).toList(); + } + + Future clearMcpActivity() async { + final db = await _open(); + await db.delete('mcp_activity'); + } + Future> getFolders() async { final db = await _open(); final rows = await db.query('folders', orderBy: 'sort_order ASC, name ASC'); @@ -766,6 +819,64 @@ enum MutationAuditSource { } /// One row of the local mutation audit trail. +/// Rows kept in `mcp_activity`. +const kMcpActivityCap = 200; + +/// One tool call made by an MCP client. +class McpActivityEntry { + const McpActivityEntry({ + this.id, + required this.recordedAt, + required this.client, + required this.tool, + this.connectionId, + this.connectionName, + this.sqlText, + this.rowCount, + required this.durationMs, + this.error, + }); + + final int? id; + + /// UTC ISO-8601. + final String recordedAt; + final String client; + final String tool; + final int? connectionId; + final String? connectionName; + final String? sqlText; + final int? rowCount; + final int durationMs; + final String? error; + + Map toMap() => { + 'id': id, + 'recorded_at': recordedAt, + 'client': client, + 'tool': tool, + 'connection_id': connectionId, + 'connection_name': connectionName, + 'sql_text': sqlText, + 'row_count': rowCount, + 'duration_ms': durationMs, + 'error': error, + }; + + static McpActivityEntry fromMap(Map m) => McpActivityEntry( + id: _sqliteInt(m['id']), + recordedAt: m['recorded_at'] as String, + client: m['client'] as String, + tool: m['tool'] as String, + connectionId: _sqliteInt(m['connection_id']), + connectionName: m['connection_name'] as String?, + sqlText: m['sql_text'] as String?, + rowCount: _sqliteInt(m['row_count']), + durationMs: _sqliteInt(m['duration_ms']) ?? 0, + error: m['error'] as String?, + ); +} + class MutationAuditEntry { const MutationAuditEntry({ required this.id, diff --git a/lib/features/settings/preferences_category.dart b/lib/features/settings/preferences_category.dart index 2b90fdee..c5ac9b91 100644 --- a/lib/features/settings/preferences_category.dart +++ b/lib/features/settings/preferences_category.dart @@ -32,6 +32,12 @@ enum PreferencesCategory { description: 'Sideloading .qext/.zip packages, extension folder', icon: material.Icons.extension_outlined, ), + mcp( + id: 'mcp', + label: 'MCP Server', + description: 'Let AI clients read shared connections', + icon: material.Icons.hub_outlined, + ), shortcuts( id: 'shortcuts', label: 'Shortcuts', diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 4e46de23..98c35167 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/features/settings/preferences_appearance_section. import 'package:querya_desktop/features/settings/preferences_category.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/features/settings/preferences_extensions_section.dart'; +import 'package:querya_desktop/features/settings/preferences_mcp_section.dart'; import 'package:querya_desktop/features/settings/preferences_shortcuts_section.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -194,6 +195,11 @@ class PreferencesDialogContentState .contains(q)) { count++; } + case PreferencesCategory.mcp: + if ('mcp ai model claude cursor vscode copilot llm server share token' + .contains(q)) { + count++; + } case PreferencesCategory.shortcuts: final matching = PreferencesShortcutsSection.allShortcuts.where( (s) => @@ -524,6 +530,8 @@ class PreferencesDialogContentState return _buildDataGridSection(theme); case PreferencesCategory.extensions: return const PreferencesExtensionsSection(); + case PreferencesCategory.mcp: + return const PreferencesMcpSection(); case PreferencesCategory.shortcuts: return PreferencesShortcutsSection(searchQuery: _searchQuery); case PreferencesCategory.about: diff --git a/lib/features/settings/preferences_mcp_section.dart b/lib/features/settings/preferences_mcp_section.dart new file mode 100644 index 00000000..48a09c71 --- /dev/null +++ b/lib/features/settings/preferences_mcp_section.dart @@ -0,0 +1,356 @@ +import 'dart:async' show StreamSubscription, unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:querya_desktop/core/mcp/mcp_access_store.dart'; +import 'package:querya_desktop/core/mcp/mcp_client_config.dart'; +import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; +import 'package:querya_desktop/core/mcp/mcp_server_controller.dart'; +import 'package:querya_desktop/core/security/connection_environment.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Preferences → MCP Server (#1136): enable the in-app MCP server, choose the +/// connections AI clients may read, copy client configs, see recent calls. +class PreferencesMcpSection extends material.StatefulWidget { + const PreferencesMcpSection({ + super.key, + this.controller, + this.access, + this.loadConnections, + this.loadActivity, + this.clearActivity, + this.shimPath, + }); + + final McpServerController? controller; + final McpAccessSettings? access; + final Future> Function()? loadConnections; + final Future> Function()? loadActivity; + final Future Function()? clearActivity; + + /// Overrides the bundled `querya-mcp` lookup (tests). + final String? shimPath; + + @override + material.State createState() => + PreferencesMcpSectionState(); +} + +class PreferencesMcpSectionState + extends material.State { + McpServerController get _controller => + widget.controller ?? McpServerController.instance; + McpAccessSettings get _access => widget.access ?? McpAccessStore.instance; + + bool _enabled = false; + bool _busy = false; + List _connections = const []; + Set _shared = {}; + List _activity = const []; + StreamSubscription? _callsSub; + late final String? _shimPath = + widget.shimPath ?? McpClientConfig.bundledShimPath(); + + @override + void initState() { + super.initState(); + unawaited(_load()); + // Reload the log after each call (the controller persists it first). + _callsSub = _controller.calls.listen((_) { + unawaited(Future.delayed( + const Duration(milliseconds: 200), _loadActivity)); + }); + } + + @override + void dispose() { + unawaited(_callsSub?.cancel()); + super.dispose(); + } + + Future _load() async { + final enabled = await _controller.isEnabled(); + final all = await (widget.loadConnections ?? + () => LocalDb.instance.getConnections())(); + final shared = await _access.readableIds(); + if (!mounted) return; + setState(() { + _enabled = enabled; + _connections = [ + for (final c in all) + if (c.id != null && McpQueryService.dialectOf(c.type) != null) c, + ]; + _shared = shared; + }); + await _loadActivity(); + } + + Future _loadActivity() async { + try { + final list = await (widget.loadActivity ?? + () => LocalDb.instance.listMcpActivity(limit: 50))(); + if (mounted) setState(() => _activity = list); + } catch (_) {} + } + + Future _setEnabled(bool v) async { + setState(() { + _busy = true; + _enabled = v; + }); + try { + await _controller.setEnabled(v); + } catch (e) { + if (mounted) { + setState(() => _enabled = false); + showAppToast( + context: context, + message: 'MCP server did not start: $e', + variant: AppToastVariant.error, + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _setShared(int id, bool v) async { + setState(() => v ? _shared.add(id) : _shared.remove(id)); + await _access.setReadable(id, v); + } + + Future _copy(McpClientKind kind) async { + final path = _shimPath ?? '/path/to/${McpClientConfig.executableName}'; + await Clipboard.setData( + ClipboardData(text: McpClientConfig.snippet(kind, path))); + if (!mounted) return; + showAppToast( + context: context, + message: '${kind.label} config copied. Paste it into ${kind.configLocation}.', + variant: AppToastVariant.success, + ); + } + + Future _regenerate() async { + setState(() => _busy = true); + try { + await _controller.regenerateToken(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _clearLog() async { + await (widget.clearActivity ?? LocalDb.instance.clearMcpActivity)(); + await _loadActivity(); + } + + @override + material.Widget build(material.BuildContext context) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('MCP Server').semiBold().small().foreground(), + const material.SizedBox(height: 6), + const PreferencesHint( + 'Lets AI clients (Claude Desktop, Cursor, VS Code, Gemini CLI, ...) ' + 'read the connections you share below. Read-only: writes are ' + 'refused, and no host, user or password is ever sent to the model.', + ), + const material.SizedBox(height: 14), + PreferencesSwitchRow( + key: const material.ValueKey('mcp_enabled'), + value: _enabled, + enabled: !_busy, + title: const Text('Enable MCP server').small(), + subtitle: material.ValueListenableBuilder( + valueListenable: _controller.status, + builder: (context, s, _) => + Text(_statusText(s)).muted().xSmall(), + ), + onChanged: (v) => unawaited(_setEnabled(v)), + ), + const material.SizedBox(height: 18), + _connectionsBlock(), + const material.SizedBox(height: 18), + _clientsBlock(), + const material.SizedBox(height: 18), + _activityBlock(), + ], + ); + } + + String _statusText(McpServerStatus s) { + if (s.error != null) return 'Not running: ${s.error}'; + if (!s.running) return 'Stopped. AI clients cannot connect.'; + final clients = s.clients == 1 ? '1 client' : '${s.clients} clients'; + return 'Running on 127.0.0.1:${s.port} · $clients connected'; + } + + material.Widget _connectionsBlock() { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Shared connections').semiBold().small().foreground(), + const material.SizedBox(height: 4), + const PreferencesHint( + 'Only switched-on connections are visible to MCP clients. ' + 'PostgreSQL, MySQL and SQLite.', + ), + const material.SizedBox(height: 8), + if (_connections.isEmpty) + const QueryaEmptyState( + compact: true, + title: 'No SQL connections', + description: 'Add a PostgreSQL, MySQL or SQLite connection first.', + ) + else + for (final c in _connections) + PreferencesSwitchRow( + key: material.ValueKey('mcp_share_${c.id}'), + value: _shared.contains(c.id), + title: material.Row( + children: [ + material.Flexible(child: Text(c.name).small()), + const material.SizedBox(width: 8), + QueryaBadge(label: c.type), + if (c.environment == ConnectionEnvironment.production) ...[ + const material.SizedBox(width: 6), + const QueryaBadge.status('PROD', + status: QueryaBadgeStatus.warning), + ], + ], + ), + subtitle: c.environment == ConnectionEnvironment.production + ? const Text( + 'Production: the model can read all data this user can.', + ).muted().xSmall() + : null, + onChanged: (v) => unawaited(_setShared(c.id!, v)), + ), + ], + ); + } + + material.Widget _clientsBlock() { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Connect a client').semiBold().small().foreground(), + const material.SizedBox(height: 4), + PreferencesHint(_shimPath != null + ? 'The client starts querya-mcp, which talks to this running app: ' + '$_shimPath' + : 'querya-mcp is not bundled with this build. Download it from the ' + 'GitHub release and replace /path/to/querya-mcp in the copied ' + 'config.'), + const material.SizedBox(height: 8), + material.Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final kind in McpClientKind.values) + QueryaActionButton( + key: material.ValueKey('mcp_copy_${kind.name}'), + label: 'Copy ${kind.label} config', + icon: material.Icons.copy_rounded, + size: ButtonSize.small, + onPressed: () => unawaited(_copy(kind)), + ), + QueryaActionButton( + key: const material.ValueKey('mcp_regenerate'), + label: 'Regenerate token', + icon: material.Icons.key_rounded, + size: ButtonSize.small, + tooltip: 'Disconnects connected clients; they reconnect on ' + 'their next start.', + onPressed: _enabled && !_busy ? () => unawaited(_regenerate()) : null, + ), + ], + ), + ], + ); + } + + material.Widget _activityBlock() { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + const Text('Recent calls').semiBold().small().foreground(), + const material.Spacer(), + QueryaActionButton( + key: const material.ValueKey('mcp_clear_log'), + label: 'Clear', + size: ButtonSize.small, + onPressed: _activity.isEmpty ? null : () => unawaited(_clearLog()), + ), + ], + ), + const material.SizedBox(height: 8), + if (_activity.isEmpty) + const QueryaEmptyState( + compact: true, + title: 'No calls yet', + description: 'Tool calls from MCP clients appear here.', + ) + else + for (final e in _activity) _ActivityRow(entry: e), + ], + ); + } +} + +class _ActivityRow extends material.StatelessWidget { + const _ActivityRow({required this.entry}); + + final McpActivityEntry entry; + + @override + material.Widget build(material.BuildContext context) { + final wb = context.workbench; + final at = DateTime.tryParse(entry.recordedAt)?.toLocal(); + final time = at == null + ? '' + : '${at.hour.toString().padLeft(2, '0')}:' + '${at.minute.toString().padLeft(2, '0')}:' + '${at.second.toString().padLeft(2, '0')}'; + final result = entry.error != null + ? entry.error! + : '${entry.rowCount != null ? '${entry.rowCount} row(s) · ' : ''}' + '${entry.durationMs} ms'; + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 4), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text([ + time, + entry.client, + entry.tool, + if (entry.connectionName != null) entry.connectionName!, + ].where((s) => s.isNotEmpty).join(' · ')) + .xSmall(), + if (entry.sqlText != null) + Text( + entry.sqlText!, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontFamily: 'monospace', color: wb.mutedForeground), + ).xSmall(), + Text( + result, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + color: entry.error != null ? wb.destructive : wb.mutedForeground), + ).xSmall(), + ], + ), + ); + } +} diff --git a/test/core/mcp/mcp_client_config_and_activity_test.dart b/test/core/mcp/mcp_client_config_and_activity_test.dart new file mode 100644 index 00000000..72d72f18 --- /dev/null +++ b/test/core/mcp/mcp_client_config_and_activity_test.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/mcp/mcp_client_config.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +import '../../support/local_db_test_support.dart'; + +void main() { + group('McpClientConfig', () { + test('Claude Desktop, Cursor and generic use mcpServers', () { + for (final kind in [ + McpClientKind.claudeDesktop, + McpClientKind.cursor, + McpClientKind.generic, + ]) { + final json = jsonDecode(McpClientConfig.snippet(kind, '/x/querya-mcp')); + expect(json, { + 'mcpServers': { + 'querya': {'command': '/x/querya-mcp'}, + }, + }); + } + }); + + test('VS Code uses servers with a stdio type', () { + final json = + jsonDecode(McpClientConfig.snippet(McpClientKind.vsCode, r'C:\q.exe')); + expect(json, { + 'servers': { + 'querya': {'type': 'stdio', 'command': r'C:\q.exe'}, + }, + }); + }); + + test('the bundled shim is found next to the app binary', () async { + final dir = await Directory.systemTemp.createTemp('querya_shim_'); + addTearDown(() => dir.delete(recursive: true)); + final app = File('${dir.path}/querya_desktop')..createSync(); + expect(McpClientConfig.bundledShimPath(appExecutable: app.path), isNull); + File('${dir.path}/${McpClientConfig.executableName}').createSync(); + expect(McpClientConfig.bundledShimPath(appExecutable: app.path), + endsWith(McpClientConfig.executableName)); + }); + }); + + group('mcp_activity', () { + late Directory dir; + setUpAll(() async => dir = await initTestLocalDb('querya_mcp_activity_')); + tearDownAll(() => disposeTestLocalDb(dir)); + setUp(() => LocalDb.instance.clearMcpActivity()); + + McpActivityEntry entry(int i, {String? error}) => McpActivityEntry( + recordedAt: DateTime.utc(2026, 10, 8, 10, 0, i).toIso8601String(), + client: 'c', + tool: 'run_query', + connectionId: 1, + connectionName: 'Shop', + sqlText: 'SELECT $i', + rowCount: i, + durationMs: i * 10, + error: error, + ); + + test('records newest first and round-trips every field', () async { + await LocalDb.instance.recordMcpActivity(entry(1)); + await LocalDb.instance.recordMcpActivity(entry(2, error: 'boom')); + + final list = await LocalDb.instance.listMcpActivity(); + expect(list.map((e) => e.sqlText), ['SELECT 2', 'SELECT 1']); + final e = list.first; + expect(e.id, isNotNull); + expect(e.client, 'c'); + expect(e.connectionId, 1); + expect(e.connectionName, 'Shop'); + expect(e.rowCount, 2); + expect(e.durationMs, 20); + expect(e.error, 'boom'); + }); + + test('keeps only the newest kMcpActivityCap rows', () async { + for (var i = 0; i < kMcpActivityCap + 5; i++) { + await LocalDb.instance.recordMcpActivity(entry(i % 60)); + } + final list = await LocalDb.instance.listMcpActivity(limit: 1000); + expect(list, hasLength(kMcpActivityCap)); + }); + + test('clear empties the log', () async { + await LocalDb.instance.recordMcpActivity(entry(1)); + await LocalDb.instance.clearMcpActivity(); + expect(await LocalDb.instance.listMcpActivity(), isEmpty); + }); + }); +} diff --git a/test/features/settings/preferences_mcp_section_test.dart b/test/features/settings/preferences_mcp_section_test.dart new file mode 100644 index 00000000..1c64c521 --- /dev/null +++ b/test/features/settings/preferences_mcp_section_test.dart @@ -0,0 +1,219 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/mcp/mcp_access_store.dart'; +import 'package:querya_desktop/core/mcp/mcp_server_controller.dart'; +import 'package:querya_desktop/core/security/connection_environment.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/settings/preferences_mcp_section.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +class _Controller extends McpServerController { + _Controller() : super(endpointFile: File('/nonexistent/mcp.json'), version: 't'); + + var enabled = false; + final enabledCalls = []; + var regenerated = 0; + + @override + Future isEnabled() async => enabled; + + @override + Future setEnabled(bool v) async { + enabledCalls.add(v); + enabled = v; + status.value = v + ? const McpServerStatus(running: true, port: 40123, clients: 2) + : McpServerStatus.stopped; + } + + @override + Future regenerateToken() async => regenerated++; +} + +class _Access implements McpAccessSettings { + _Access(this.ids); + final Set ids; + final changes = <(int, bool)>[]; + + @override + Future canRead(ConnectionRow row) async => ids.contains(row.id); + @override + Future> readableIds() async => {...ids}; + @override + Future setReadable(int id, bool readable) async { + changes.add((id, readable)); + readable ? ids.add(id) : ids.remove(id); + } +} + +ConnectionRow _row(int id, String type, String name, {bool prod = false}) { + final r = ConnectionRow( + id: id, + type: type, + name: name, + host: 'h', + createdAt: DateTime.utc(2026).toIso8601String(), + ); + return prod ? r.withEnvironment(ConnectionEnvironment.production) : r; +} + +void main() { + late _Controller controller; + late _Access access; + late List activity; + late List clipboard; + + setUp(() { + controller = _Controller(); + access = _Access({1}); + activity = []; + clipboard = []; + }); + + Future pump(WidgetTester tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + clipboard.add((call.arguments as Map)['text'] as String); + } + return null; + }, + ); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null)); + await tester.binding.setSurfaceSize(const material.Size(900, 1600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(queryaThemeTestShell( + child: material.SingleChildScrollView( + child: PreferencesMcpSection( + controller: controller, + access: access, + shimPath: '/opt/querya-desktop/querya-mcp', + loadConnections: () async => [ + _row(1, 'postgresql', 'Orders DB', prod: true), + _row(2, 'sqlite', 'Local file'), + _row(3, 'mongodb', 'Mongo'), // not SQL: hidden + ], + loadActivity: () async => activity, + clearActivity: () async => activity = [], + ), + ), + )); + await tester.pump(); + await tester.pump(); + } + + testWidgets('lists SQL connections with their shared state', (tester) async { + await pump(tester); + + expect(find.text('Orders DB'), findsOneWidget); + expect(find.text('Local file'), findsOneWidget); + expect(find.text('Mongo'), findsNothing); + expect(find.text('PROD'), findsOneWidget); + + material.Switch sw(int id) => tester.widget(find.descendant( + of: find.byKey(material.ValueKey('mcp_share_$id')), + matching: find.byType(material.Switch))); + expect(sw(1).value, isTrue); + expect(sw(2).value, isFalse); + + await tester.tap(find.byKey(const material.ValueKey('mcp_share_2'))); + await tester.pump(); + expect(access.changes, [(2, true)]); + expect(sw(2).value, isTrue); + }); + + testWidgets('enabling the server shows its status', (tester) async { + await pump(tester); + expect(find.text('Stopped. AI clients cannot connect.'), findsOneWidget); + + await tester.tap(find.byKey(const material.ValueKey('mcp_enabled'))); + await tester.pump(); + await tester.pump(); + + expect(controller.enabledCalls, [true]); + expect(find.text('Running on 127.0.0.1:40123 · 2 clients connected'), + findsOneWidget); + + await tester.tap(find.byKey(const material.ValueKey('mcp_regenerate'))); + await tester.pump(); + expect(controller.regenerated, 1); + }); + + testWidgets('copy buttons put a config with the shim path on the clipboard', + (tester) async { + await pump(tester); + + await tester.tap(find.byKey(const material.ValueKey('mcp_copy_claudeDesktop'))); + await tester.pump(); + await tester.tap(find.byKey(const material.ValueKey('mcp_copy_vsCode'))); + await tester.pump(); + + expect(clipboard, hasLength(2)); + expect(clipboard.first, contains('"mcpServers"')); + expect(clipboard.first, contains('/opt/querya-desktop/querya-mcp')); + expect(clipboard.last, contains('"servers"')); + expect(clipboard.last, contains('"type": "stdio"')); + for (final c in clipboard) { + expect(c, isNot(contains('token'))); + } + await tester.pump(const Duration(seconds: 6)); // let toasts expire + }); + + testWidgets('recent calls show results and errors, Clear empties the log', + (tester) async { + activity = [ + const McpActivityEntry( + recordedAt: '2026-10-08T10:00:00Z', + client: 'claude-desktop', + tool: 'run_query', + connectionName: 'Orders DB', + sqlText: 'SELECT count(*) FROM orders', + rowCount: 1, + durationMs: 12, + ), + const McpActivityEntry( + recordedAt: '2026-10-08T10:00:05Z', + client: 'claude-desktop', + tool: 'run_query', + sqlText: 'DELETE FROM orders', + durationMs: 1, + error: 'Data-modifying statements are not allowed over MCP.', + ), + ]; + await pump(tester); + + expect(find.text('SELECT count(*) FROM orders'), findsOneWidget); + expect(find.text('1 row(s) · 12 ms'), findsOneWidget); + expect(find.text('Data-modifying statements are not allowed over MCP.'), + findsOneWidget); + + await tester.tap(find.byKey(const material.ValueKey('mcp_clear_log'))); + await tester.pump(); + await tester.pump(); + expect(find.text('No calls yet'), findsOneWidget); + }); + + testWidgets('without SQL connections an empty state is shown', + (tester) async { + await tester.pumpWidget(queryaThemeTestShell( + child: material.SingleChildScrollView( + child: PreferencesMcpSection( + controller: controller, + access: access, + shimPath: null, + loadConnections: () async => [], + loadActivity: () async => [], + ), + ), + )); + await tester.pump(); + await tester.pump(); + expect(find.text('No SQL connections'), findsOneWidget); + }); +}