Skip to content

Commit 2dadc2e

Browse files
Merge pull request #1232 from QueryaHub/feat/1231-mcp-rule-ids
feat(mcp): every refused query records the guard rule that refused it (#1231)
2 parents 976c23e + c69898a commit 2dadc2e

7 files changed

Lines changed: 116 additions & 20 deletions

File tree

‎lib/core/mcp/mcp_query_service.dart‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart';
1313
/// A failure the MCP client should see as a tool error (the model reads the
1414
/// message and can fix its call).
1515
class McpToolException implements Exception {
16-
const McpToolException(this.message);
16+
const McpToolException(this.message, {this.rule});
1717

1818
final String message;
1919

20+
/// The guard rule that refused the call, when a rule did.
21+
final String? rule;
22+
2023
@override
2124
String toString() => message;
2225
}
@@ -218,8 +221,10 @@ class McpQueryService {
218221
}
219222

220223
void _guard(String sql, SqlDialect dialect) {
221-
final reason = McpSqlGuard.check(sql, dialect);
222-
if (reason != null) throw McpToolException(reason);
224+
final refusal = McpSqlGuard.refusal(sql, dialect);
225+
if (refusal != null) {
226+
throw McpToolException(refusal.message, rule: refusal.rule);
227+
}
223228
}
224229

225230
Future<ErdSchema> _loadSchema(

‎lib/core/mcp/mcp_server_controller.dart‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ class McpServerController {
139139
rowCount: r.rowCount,
140140
durationMs: r.duration.inMilliseconds,
141141
error: r.error,
142+
refusalRule: r.rule,
142143
));
143144
}
144145

‎lib/core/mcp/mcp_sql_guard.dart‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ import 'package:querya_desktop/core/database/destructive_sql_detector.dart';
22
import 'package:querya_desktop/core/database/sql_mutation_classifier.dart';
33
import 'package:querya_desktop/core/database/table_mutation_engine.dart';
44

5+
/// Why [McpSqlGuard] refused a query: a stable rule id for the audit log and
6+
/// the message the model reads.
7+
class McpSqlRefusal {
8+
const McpSqlRefusal(this.rule, this.message);
9+
10+
final String rule;
11+
final String message;
12+
}
13+
514
/// Decides whether SQL sent by an MCP client may run.
615
///
716
/// First of three read-only layers (the others are the read-only database
@@ -55,23 +64,29 @@ abstract final class McpSqlGuard {
5564
static final _pragma =
5665
RegExp(r'^PRAGMA\s+(?:[A-Z_]+\.)?([A-Z_]+)\s*(\(\s*[A-Z_0-9]*\s*\))?$');
5766

58-
/// Returns `null` when [sql] may run, or the reason it may not.
59-
static String? check(String sql, SqlDialect dialect) {
67+
/// Returns `null` when [sql] may run, or the reason it may not, as the
68+
/// message the model sees.
69+
static String? check(String sql, SqlDialect dialect) =>
70+
refusal(sql, dialect)?.message;
71+
72+
/// Returns `null` when [sql] may run, or why it may not: the rule that
73+
/// refused it (stable, for the audit log) and the message for the model.
74+
static McpSqlRefusal? refusal(String sql, SqlDialect dialect) {
6075
final statements = DestructiveSqlDetector.splitStatements(sql)
6176
.where((s) => DestructiveSqlDetector.stripCommentsAndStrings(s)
6277
.trim()
6378
.isNotEmpty)
6479
.toList();
65-
if (statements.isEmpty) return 'The query is empty.';
80+
if (statements.isEmpty) return const McpSqlRefusal('empty_query', 'The query is empty.');
6681
if (statements.length > 1) {
67-
return 'Only one statement per call is allowed; send them separately.';
82+
return const McpSqlRefusal('single_statement', 'Only one statement per call is allowed; send them separately.');
6883
}
6984
final statement = statements.single;
7085
// MySQL / MariaDB execute the body of `/*! ... */` and `/*M! ... */`
7186
// comments, which the comment stripper below would hide from the checks.
7287
if (dialect == SqlDialect.mysql &&
7388
RegExp(r'/\*M?!').hasMatch(statement)) {
74-
return 'MySQL executable comments (/*! ... */) are not allowed.';
89+
return const McpSqlRefusal('mysql_executable_comment', 'MySQL executable comments (/*! ... */) are not allowed.');
7590
}
7691
final upper = DestructiveSqlDetector.stripCommentsAndStrings(statement)
7792
.trim()
@@ -82,25 +97,25 @@ abstract final class McpSqlGuard {
8297
if (first == 'PRAGMA' && dialect == SqlDialect.sqlite) {
8398
final m = _pragma.firstMatch(upper.replaceAll(RegExp(r'\s+'), ' '));
8499
if (m != null && _readPragmas.contains(m.group(1))) return null;
85-
return 'Only schema pragmas are allowed (${_readPragmas.map((p) => p.toLowerCase()).join(', ')}).';
100+
return McpSqlRefusal('sqlite_pragma', 'Only schema pragmas are allowed (${_readPragmas.map((p) => p.toLowerCase()).join(', ')}).');
86101
}
87102
if (first == null || !_readStarts.contains(first)) {
88-
return 'Only read-only queries are allowed (SELECT, WITH, EXPLAIN, SHOW, DESCRIBE).';
103+
return const McpSqlRefusal('read_only_only', 'Only read-only queries are allowed (SELECT, WITH, EXPLAIN, SHOW, DESCRIBE).');
89104
}
90105
if (_forbiddenFunctions.hasMatch(upper)) {
91-
return 'This query calls a server function that is not allowed over MCP.';
106+
return const McpSqlRefusal('function_not_allowed', 'This query calls a server function that is not allowed over MCP.');
92107
}
93108
if (first == 'EXPLAIN') {
94109
if (_explainForbidden.hasMatch(upper)) {
95-
return 'EXPLAIN is allowed only for read-only statements and without ANALYZE.';
110+
return const McpSqlRefusal('explain_analyze_or_write', 'EXPLAIN is allowed only for read-only statements and without ANALYZE.');
96111
}
97112
return null;
98113
}
99114
if (isMutatingSqlStatement(statement)) {
100-
return 'Data-modifying statements are not allowed over MCP.';
115+
return const McpSqlRefusal('data_modifying', 'Data-modifying statements are not allowed over MCP.');
101116
}
102117
if (_selectSideEffects.hasMatch(upper)) {
103-
return 'SELECT ... INTO and row locks (FOR UPDATE / FOR SHARE) are not allowed.';
118+
return const McpSqlRefusal('select_into_or_lock', 'SELECT ... INTO and row locks (FOR UPDATE / FOR SHARE) are not allowed.');
104119
}
105120
return null;
106121
}

‎lib/core/mcp/querya_mcp_server.dart‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class McpCallRecord {
1616
this.sql,
1717
this.rowCount,
1818
this.error,
19+
this.rule,
1920
});
2021

2122
final DateTime at;
@@ -26,6 +27,9 @@ class McpCallRecord {
2627
final String? sql;
2728
final int? rowCount;
2829
final String? error;
30+
31+
/// The guard rule that refused the call, when a rule did.
32+
final String? rule;
2933
}
3034

3135
/// MCP session for one client: read-only tools and a `schema://` resource on
@@ -210,24 +214,24 @@ base class QueryaMcpServer extends MCPServer with ToolsSupport, ResourcesSupport
210214
}
211215
try {
212216
final (text, rows, sql) = await body(args);
213-
_report(tool, started, sw.elapsed, connectionId, sql, rows, null);
217+
_report(tool, started, sw.elapsed, connectionId, sql, rows, null, null);
214218
return CallToolResult(content: [TextContent(text: text)]);
215219
} on McpToolException catch (e) {
216220
_report(tool, started, sw.elapsed, connectionId,
217-
rawSql, null, e.message);
221+
rawSql, null, e.message, e.rule);
218222
return CallToolResult(
219223
isError: true, content: [TextContent(text: e.message)]);
220224
} catch (e) {
221225
final message = McpRedaction.redact('Internal error: $e');
222-
_report(tool, started, sw.elapsed, connectionId, rawSql, null, message);
226+
_report(tool, started, sw.elapsed, connectionId, rawSql, null, message, null);
223227
return CallToolResult(
224228
isError: true, content: [TextContent(text: message)]);
225229
}
226230
};
227231
}
228232

229233
void _report(String tool, DateTime at, Duration d, int? connectionId,
230-
String? sql, int? rows, String? error) {
234+
String? sql, int? rows, String? error, String? rule) {
231235
onCall?.call(McpCallRecord(
232236
at: at,
233237
client: _clientName,
@@ -237,6 +241,7 @@ base class QueryaMcpServer extends MCPServer with ToolsSupport, ResourcesSupport
237241
sql: sql,
238242
rowCount: rows,
239243
error: error,
244+
rule: rule,
240245
));
241246
}
242247

‎lib/core/storage/local_db.dart‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import 'package:querya_desktop/core/storage/connection_secrets_store.dart';
1111
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
1212

1313
const _dbName = 'querya.db';
14-
const _dbVersion = 11;
14+
const _dbVersion = 12;
1515

1616
/// `app_settings` key under which each profile database's random id is
1717
/// stored (see [LocalDb._ensureProfileId] and issue #986).
@@ -221,7 +221,8 @@ class LocalDb {
221221
sql_text TEXT,
222222
row_count INTEGER,
223223
duration_ms INTEGER NOT NULL,
224-
error TEXT
224+
error TEXT,
225+
refusal_rule TEXT
225226
)
226227
''');
227228
}
@@ -340,6 +341,10 @@ class LocalDb {
340341
if (oldVersion < 11) {
341342
await _createMcpActivityTable(db);
342343
}
344+
if (oldVersion >= 11 && oldVersion < 12) {
345+
// The rule that refused a call, next to its error text.
346+
await db.execute('ALTER TABLE mcp_activity ADD COLUMN refusal_rule TEXT');
347+
}
343348
}
344349

345350
Future<String?> getAppSetting(String key) async {
@@ -835,6 +840,7 @@ class McpActivityEntry {
835840
this.rowCount,
836841
required this.durationMs,
837842
this.error,
843+
this.refusalRule,
838844
});
839845

840846
final int? id;
@@ -850,6 +856,9 @@ class McpActivityEntry {
850856
final int durationMs;
851857
final String? error;
852858

859+
/// The guard rule that refused the call (`read_only_only`, ...), when one did.
860+
final String? refusalRule;
861+
853862
Map<String, Object?> toMap() => {
854863
'id': id,
855864
'recorded_at': recordedAt,
@@ -861,6 +870,7 @@ class McpActivityEntry {
861870
'row_count': rowCount,
862871
'duration_ms': durationMs,
863872
'error': error,
873+
'refusal_rule': refusalRule,
864874
};
865875

866876
static McpActivityEntry fromMap(Map<String, Object?> m) => McpActivityEntry(
@@ -874,6 +884,7 @@ class McpActivityEntry {
874884
rowCount: _sqliteInt(m['row_count']),
875885
durationMs: _sqliteInt(m['duration_ms']) ?? 0,
876886
error: m['error'] as String?,
887+
refusalRule: m['refusal_rule'] as String?,
877888
);
878889
}
879890

‎test/core/mcp/mcp_client_config_and_activity_test.dart‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,28 @@ void main() {
9393
expect(await LocalDb.instance.listMcpActivity(), isEmpty);
9494
});
9595
});
96+
97+
test('an activity entry keeps the rule that refused its call (#1231)', () {
98+
const entry = McpActivityEntry(
99+
recordedAt: '2026-10-09T10:00:00Z',
100+
client: 'test',
101+
tool: 'run_query',
102+
durationMs: 3,
103+
error: 'Data-modifying statements are not allowed over MCP.',
104+
refusalRule: 'data_modifying',
105+
);
106+
final back = McpActivityEntry.fromMap(entry.toMap());
107+
expect(back.refusalRule, 'data_modifying');
108+
expect(back.error, entry.error);
109+
expect(
110+
McpActivityEntry.fromMap({
111+
'recorded_at': '2026-10-09T10:00:00Z',
112+
'client': 'test',
113+
'tool': 'run_query',
114+
'duration_ms': 3,
115+
}).refusalRule,
116+
isNull,
117+
reason: 'rows written before the column existed read as no rule',
118+
);
119+
});
96120
}

‎test/core/mcp/mcp_sql_guard_test.dart‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,39 @@ void main() {
9696
refused('PRAGMA journal_mode', SqlDialect.sqlite);
9797
refused('PRAGMA table_info(users)', SqlDialect.postgres);
9898
});
99+
100+
group('refusal rule ids (#1231)', () {
101+
String? ruleOf(String sql, SqlDialect d) =>
102+
McpSqlGuard.refusal(sql, d)?.rule;
103+
104+
test('every refusal names the rule that caught it', () {
105+
expect(ruleOf('', SqlDialect.postgres), 'empty_query');
106+
expect(ruleOf('SELECT 1; SELECT 2', SqlDialect.postgres),
107+
'single_statement');
108+
expect(ruleOf('INSERT INTO t VALUES (1)', SqlDialect.sqlite),
109+
'read_only_only');
110+
expect(ruleOf('PRAGMA writable_schema = 1', SqlDialect.sqlite),
111+
'sqlite_pragma');
112+
expect(ruleOf('/*! DROP TABLE t */ SELECT 1', SqlDialect.mysql),
113+
'mysql_executable_comment');
114+
expect(ruleOf('WITH x AS (SELECT 1) DELETE FROM t', SqlDialect.postgres),
115+
'data_modifying');
116+
expect(ruleOf('SELECT 1 FOR UPDATE', SqlDialect.postgres),
117+
'select_into_or_lock');
118+
expect(ruleOf('EXPLAIN ANALYZE SELECT 1', SqlDialect.postgres),
119+
'explain_analyze_or_write');
120+
});
121+
122+
test('a query that may run has no refusal and no rule', () {
123+
expect(McpSqlGuard.refusal('SELECT 1', SqlDialect.postgres), isNull);
124+
expect(McpSqlGuard.check('SELECT 1', SqlDialect.postgres), isNull);
125+
});
126+
127+
test('the message the model reads is unchanged by the rule id', () {
128+
expect(McpSqlGuard.check('', SqlDialect.postgres),
129+
'The query is empty.');
130+
expect(McpSqlGuard.refusal('', SqlDialect.postgres)!.message,
131+
'The query is empty.');
132+
});
133+
});
99134
}

0 commit comments

Comments
 (0)