Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion lib/core/mcp/mcp_access_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@ abstract class McpAccessPolicy {
Future<bool> canRead(ConnectionRow row);
}

/// Read and change which connections are shared (settings page).
abstract class McpAccessSettings implements McpAccessPolicy {
Future<Set<int>> readableIds();
Future<void> 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<Set<int>> readableIds() async {
final raw = await LocalDb.instance.getAppSetting(settingsKey);
if (raw == null || raw.isEmpty) return {};
Expand All @@ -26,6 +33,7 @@ class McpAccessStore implements McpAccessPolicy {
return {};
}

@override
Future<void> setReadable(int connectionId, bool readable) async {
final ids = await readableIds();
if (readable) {
Expand Down
53 changes: 53 additions & 0 deletions lib/core/mcp/mcp_client_config.dart
Original file line number Diff line number Diff line change
@@ -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<String, Object?> 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);
}
}
47 changes: 44 additions & 3 deletions lib/core/mcp/mcp_server_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -84,7 +90,7 @@ class McpServerController {
channel,
service: _service,
version: version,
onCall: _calls.add,
onCall: _onCall,
);
_servers.add(server);
await server.done;
Expand All @@ -109,6 +115,41 @@ class McpServerController {
status.value = McpServerStatus.stopped;
}

void _onCall(McpCallRecord r) {
if (_logActivity) unawaited(_persist(r));
_calls.add(r);
}

Future<void> _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<void> regenerateToken() async {
if (_host == null) return;
await stop();
await start();
}

void _publish(McpSocketHost host) {
if (_host != host && _host != null) return;
status.value = host.isRunning
Expand Down
113 changes: 112 additions & 1 deletion lib/core/storage/local_db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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);
}

Expand All @@ -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<void> _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<void> _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
Expand Down Expand Up @@ -317,6 +337,9 @@ class LocalDb {
if (oldVersion < 10) {
await _createMutationAuditTable(db);
}
if (oldVersion < 11) {
await _createMcpActivityTable(db);
}
}

Future<String?> getAppSetting(String key) async {
Expand Down Expand Up @@ -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<void> 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<List<McpActivityEntry>> 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<void> clearMcpActivity() async {
final db = await _open();
await db.delete('mcp_activity');
}

Future<List<String>> getFolders() async {
final db = await _open();
final rows = await db.query('folders', orderBy: 'sort_order ASC, name ASC');
Expand Down Expand Up @@ -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<String, Object?> 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<String, Object?> 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,
Expand Down
6 changes: 6 additions & 0 deletions lib/features/settings/preferences_category.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions lib/features/settings/preferences_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading