@@ -2,6 +2,15 @@ import 'package:querya_desktop/core/database/destructive_sql_detector.dart';
22import 'package:querya_desktop/core/database/sql_mutation_classifier.dart' ;
33import '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 }
0 commit comments