sql-mini-mcp 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/CHANGELOG.md +14 -0
  2. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/PKG-INFO +2 -2
  3. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/README.md +1 -1
  4. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/pyproject.toml +15 -1
  5. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/server.json +2 -2
  6. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/__init__.py +1 -1
  7. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/config.py +0 -2
  8. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/mcp_server.py +1 -1
  9. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/lineage.py +9 -8
  10. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/parser.py +40 -29
  11. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/policy.py +5 -4
  12. sql_mini_mcp-1.1.0/src/sql_mini_mcp/security/reasons.py +43 -0
  13. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/schema.py +4 -3
  14. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/tokens.py +24 -24
  15. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/validated_query.py +3 -2
  16. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/.gitignore +0 -0
  17. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/LICENSE +0 -0
  18. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/sql-mini-mcp.example.yaml +0 -0
  19. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/__main__.py +0 -0
  20. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/db/__init__.py +0 -0
  21. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/db/extras.py +0 -0
  22. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/db/reflection.py +0 -0
  23. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/db/registry.py +0 -0
  24. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/db/sqlserver.py +0 -0
  25. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/errors.py +0 -0
  26. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/models.py +0 -0
  27. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/__init__.py +0 -0
  28. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/executor.py +0 -0
  29. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/pipeline.py +0 -0
  30. {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/service.py +0 -0
@@ -7,6 +7,20 @@ categories `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security`.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-09-20
11
+
12
+ ### Changed
13
+
14
+ - `QUERY_REJECTED` errors from `execute_sql` now come from a fixed catalog of reasons. The wording
15
+ for unsupported syntax names the construct as `unsupported construct: <Node>` or
16
+ `unsupported option: <Node>.<argument>`; other reasons keep their earlier text.
17
+
18
+ ### Security
19
+
20
+ - `execute_sql` no longer forwards comments from the caller's SQL to the database. The executed
21
+ statement is generated only from the validated query, so comment text can never become
22
+ executable text.
23
+
10
24
  ## [1.0.0] - 2026-09-20
11
25
 
12
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sql-mini-mcp
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Minimal, read-only, PII-safe MCP server for SQL databases.
5
5
  License: MIT License
6
6
 
@@ -113,7 +113,7 @@ or
113
113
  pip install sql-mini-mcp
114
114
  ```
115
115
 
116
- Pin a version when you want a fixed surface: `uvx sql-mini-mcp==1.0.0`.
116
+ Pin a version when you want a fixed surface: `uvx sql-mini-mcp==1.1.0`.
117
117
 
118
118
  Requires Python 3.12+, [uv](https://docs.astral.sh/uv/) (or `pip`), and
119
119
  [Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server).
@@ -76,7 +76,7 @@ or
76
76
  pip install sql-mini-mcp
77
77
  ```
78
78
 
79
- Pin a version when you want a fixed surface: `uvx sql-mini-mcp==1.0.0`.
79
+ Pin a version when you want a fixed surface: `uvx sql-mini-mcp==1.1.0`.
80
80
 
81
81
  Requires Python 3.12+, [uv](https://docs.astral.sh/uv/) (or `pip`), and
82
82
  [Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-mini-mcp"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  description = "Minimal, read-only, PII-safe MCP server for SQL databases."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.15"
@@ -51,11 +51,22 @@ include = [
51
51
  [tool.pytest.ini_options]
52
52
  addopts = "-ra --strict-markers"
53
53
  testpaths = ["tests"]
54
+ pythonpath = ["tests/security"]
54
55
  markers = [
55
56
  "integration: requires a live database",
56
57
  "deep: expensive security verification",
57
58
  ]
58
59
 
60
+ [tool.mutmut]
61
+ source_paths = ["src/sql_mini_mcp"]
62
+ also_copy = ["server.json", "README.md"]
63
+ only_mutate = ["*security/*.py", "*config.py"]
64
+ mutate_only_covered_lines = true
65
+ pytest_add_cli_args = ["-q", "-m", "not integration and not deep", "-p", "no:cacheprovider"]
66
+ pytest_add_cli_args_test_selection = ["tests/unit", "tests/security", "tests/contract"]
67
+ timeout_multiplier = 3.0
68
+ timeout_constant = 5.0
69
+
59
70
  [tool.coverage.run]
60
71
  branch = true
61
72
  source = ["sql_mini_mcp"]
@@ -72,3 +83,6 @@ quote-style = "double"
72
83
 
73
84
  [tool.ty.src]
74
85
  include = ["src", "tests"]
86
+
87
+ [tool.ty.environment]
88
+ extra-paths = ["tests/security"]
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/proprock/sql-mini-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.0.0",
10
+ "version": "1.1.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "sql-mini-mcp",
15
- "version": "1.0.0",
15
+ "version": "1.1.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -1,3 +1,3 @@
1
1
  """Minimal, PII-safe MCP server for relational databases."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.0"
@@ -181,8 +181,6 @@ def load_config(path: str | Path, environ: Mapping[str, str] | None = None) -> A
181
181
  raise ValueError(f"PII key for server {alias!r} must decode to 32 bytes")
182
182
  value["pii_key"] = env[key_env]
183
183
  return AppConfig.model_validate(raw)
184
- except DomainError:
185
- raise
186
184
  except ValidationError as exc:
187
185
  raise DomainError(
188
186
  ErrorCode.CONFIG_ERROR,
@@ -50,7 +50,7 @@ def create_server(config: AppConfig) -> MCPServer[AppContext]:
50
50
  server: MCPServer[AppContext] = MCPServer(
51
51
  "sql-mini-mcp",
52
52
  description="Minimal, read-only, PII-safe SQL database navigation.",
53
- version="1.0.0",
53
+ version="1.1.0",
54
54
  lifespan=lifespan,
55
55
  )
56
56
 
@@ -15,6 +15,7 @@ from sql_mini_mcp.security.parser import (
15
15
  reject,
16
16
  validate_allowlist,
17
17
  )
18
+ from sql_mini_mcp.security.reasons import Reason
18
19
  from sql_mini_mcp.security.schema import TableSchema
19
20
 
20
21
  _CASE_SENSITIVE_DIALECT = f"{DIALECT}, normalization_strategy = case_sensitive"
@@ -65,13 +66,13 @@ def _collect(query: exp.Select, schemas: Sequence[TableSchema]) -> list[_Binding
65
66
  nodes.append(from_.this)
66
67
  nodes += [join.this for join in query.args.get("joins") or []]
67
68
  if len(nodes) != len(schemas):
68
- raise reject("table resolution does not match the query.")
69
+ raise reject(Reason.TABLE_RESOLUTION_MISMATCH)
69
70
  bindings: list[_Binding] = []
70
71
  seen: set[str] = set()
71
72
  for node, table in zip(nodes, schemas, strict=True):
72
73
  name = node.alias or table.name
73
74
  if name.casefold() in seen:
74
- raise reject("table names and aliases must be unique.")
75
+ raise reject(Reason.DUPLICATE_BINDING)
75
76
  seen.add(name.casefold())
76
77
  bindings.append(_Binding(name, table))
77
78
  return bindings
@@ -88,10 +89,10 @@ def _resolve(column: exp.Column, bindings: Sequence[_Binding]) -> tuple[_Binding
88
89
  if qualifier:
89
90
  matching = [b for b in bindings if b.name.casefold() == qualifier.casefold()]
90
91
  if len(matching) != 1:
91
- raise reject("a column qualifier does not match a table in the query.")
92
+ raise reject(Reason.QUALIFIER_UNKNOWN)
92
93
  canonical = _canonical_column(matching[0], name)
93
94
  if canonical is None:
94
- raise reject("a referenced column was not found or is ambiguous.")
95
+ raise reject(Reason.COLUMN_NOT_FOUND)
95
96
  return matching[0], canonical
96
97
  candidates = [
97
98
  (binding, canonical)
@@ -99,7 +100,7 @@ def _resolve(column: exp.Column, bindings: Sequence[_Binding]) -> tuple[_Binding
99
100
  if (canonical := _canonical_column(binding, name)) is not None
100
101
  ]
101
102
  if len(candidates) != 1:
102
- raise reject("a referenced column was not found or is ambiguous; qualify it.")
103
+ raise reject(Reason.COLUMN_AMBIGUOUS)
103
104
  return candidates[0]
104
105
 
105
106
 
@@ -117,7 +118,7 @@ def _expand_stars(query: exp.Select, bindings: Sequence[_Binding]) -> None:
117
118
  elif isinstance(projection, exp.Column) and isinstance(projection.this, exp.Star):
118
119
  chosen = [b for b in bindings if b.name.casefold() == projection.table.casefold()]
119
120
  if len(chosen) != 1:
120
- raise reject("a column qualifier does not match a table in the query.")
121
+ raise reject(Reason.QUALIFIER_UNKNOWN)
121
122
  else:
122
123
  expanded.append(projection)
123
124
  continue
@@ -160,7 +161,7 @@ def _resolve_order_column(
160
161
  if matches:
161
162
  # SQL Server resolves an unqualified ORDER BY name against output labels first.
162
163
  if len(matches) != 1 or matches[0].source is None:
163
- raise reject("ORDER BY refers to an ambiguous or non-column output.")
164
+ raise reject(Reason.ORDER_OUTPUT_AMBIGUOUS)
164
165
  source = matches[0].source
165
166
  binding = next(b for b in bindings if b.name == matches[0].binding)
166
167
  return _rewrite(column, binding, source.column)
@@ -181,7 +182,7 @@ def _cross_check(query: exp.Select, schemas: Sequence[TableSchema]) -> None:
181
182
  expand_stars=False,
182
183
  )
183
184
  except (OptimizeError, SqlglotError) as exc:
184
- raise reject("the query references unknown or ambiguous columns.") from exc
185
+ raise reject(Reason.QUALIFY_FAILED) from exc
185
186
 
186
187
 
187
188
  def analyze_query(
@@ -7,6 +7,7 @@ from sqlglot import exp
7
7
 
8
8
  from sql_mini_mcp.config import RuntimeConfig
9
9
  from sql_mini_mcp.errors import DomainError, ErrorCode
10
+ from sql_mini_mcp.security.reasons import Reason
10
11
 
11
12
  DIALECT = "tsql"
12
13
 
@@ -28,24 +29,27 @@ class ParserLimits:
28
29
  )
29
30
 
30
31
 
31
- def reject(message: str) -> DomainError:
32
- return DomainError(
33
- ErrorCode.QUERY_REJECTED,
34
- f"Query rejected: {message}",
35
- "Rewrite the operation as a single, simpler SELECT query.",
36
- )
32
+ REJECT_HINT = "Rewrite the operation as a single, simpler SELECT query."
33
+
34
+
35
+ def reject(reason: Reason, detail: str | None = None) -> DomainError:
36
+ """Build the QUERY_REJECTED error; only a fixed Reason (plus an optional name) is accepted."""
37
+ if not isinstance(reason, Reason):
38
+ raise TypeError("reject() takes a Reason")
39
+ text = str(reason) if detail is None else f"{reason}: {detail}"
40
+ return DomainError(ErrorCode.QUERY_REJECTED, f"Query rejected: {text}.", REJECT_HINT)
37
41
 
38
42
 
39
43
  def check_limits(query: exp.Expression, limits: ParserLimits) -> None:
40
44
  """Enforce AST size limits; also used again after transformations."""
41
45
  for count, _ in enumerate(query.walk(), start=1):
42
46
  if count > limits.max_ast_nodes:
43
- raise reject("the query exceeds the configured complexity limit.")
47
+ raise reject(Reason.LIMIT_NODES)
44
48
  if sum(1 for _ in query.find_all(exp.Join)) > limits.max_joins:
45
- raise reject("the query has too many joins.")
49
+ raise reject(Reason.LIMIT_JOINS)
46
50
  for node in query.find_all(exp.In):
47
51
  if len(node.expressions) > limits.max_in_list_items:
48
- raise reject("an IN list exceeds the configured item limit.")
52
+ raise reject(Reason.LIMIT_IN_LIST)
49
53
 
50
54
 
51
55
  _COMPARISONS = (exp.EQ, exp.NEQ, exp.GT, exp.GTE, exp.LT, exp.LTE)
@@ -95,79 +99,86 @@ def _check_node(node: exp.Expr, allow_placeholders: bool) -> None:
95
99
  elif kind in _ALLOWED_ARGS:
96
100
  allowed_args = _ALLOWED_ARGS[kind]
97
101
  else:
98
- raise reject(f"{kind.__name__} is not supported.")
102
+ raise reject(Reason.NODE_UNSUPPORTED, kind.__name__)
99
103
  for name, value in node.args.items():
100
104
  if name not in allowed_args and not _is_empty(value):
101
- raise reject(f"{kind.__name__} option {name!r} is not supported.")
105
+ raise reject(Reason.OPTION_UNSUPPORTED, f"{kind.__name__}.{name}")
102
106
 
103
107
  if isinstance(node, exp.Table):
104
108
  name = node.this
105
109
  if not isinstance(name, exp.Identifier) or str(name.this).startswith(("#", "@")):
106
- raise reject("only base tables of the current database are supported.")
110
+ raise reject(Reason.TABLE_UNSUPPORTED)
107
111
  elif isinstance(node, exp.Column):
108
112
  if not isinstance(node.this, (exp.Identifier, exp.Star)):
109
- raise reject("unsupported column reference.")
113
+ raise reject(Reason.COLUMN_REFERENCE_UNSUPPORTED)
110
114
  elif isinstance(node, exp.Count):
111
115
  if not isinstance(node.this, exp.Star):
112
- raise reject("only COUNT(*) is supported.")
116
+ raise reject(Reason.COUNT_STAR_ONLY)
113
117
  elif isinstance(node, exp.Star):
114
118
  if not isinstance(node.parent, (exp.Select, exp.Column, exp.Count)):
115
- raise reject("* is supported only as a projection.")
119
+ raise reject(Reason.STAR_PROJECTION_ONLY)
116
120
  elif isinstance(node, exp.Join):
117
121
  if not isinstance(node.this, exp.Table) or node.args.get("on") is None:
118
- raise reject("JOIN requires a table and an explicit ON expression.")
122
+ raise reject(Reason.JOIN_NEEDS_ON)
119
123
  if node.side not in {"", "LEFT"} or node.kind not in {"", "INNER"}:
120
- raise reject("only INNER JOIN and LEFT JOIN are supported.")
124
+ raise reject(Reason.JOIN_TYPE_UNSUPPORTED)
121
125
  elif isinstance(node, exp.Is):
122
126
  if not isinstance(node.expression, exp.Null):
123
- raise reject("IS is supported only with NULL.")
127
+ raise reject(Reason.IS_NULL_ONLY)
124
128
  elif isinstance(node, exp.Neg):
125
129
  if not _is_numeric_literal(node.this):
126
- raise reject("negation is supported only for numeric literals.")
130
+ raise reject(Reason.NEGATION_NUMERIC_ONLY)
127
131
  elif isinstance(node, exp.Limit):
128
132
  value = node.expression
129
133
  if not _is_numeric_literal(value) or not str(value.this).isdigit():
130
- raise reject("TOP must be a non-negative integer literal.")
134
+ raise reject(Reason.TOP_INTEGER_ONLY)
131
135
  elif isinstance(node, exp.Ordered):
132
136
  if not isinstance(node.this, exp.Column) or isinstance(node.this.this, exp.Star):
133
- raise reject("ORDER BY supports only direct columns.")
137
+ raise reject(Reason.ORDER_DIRECT_COLUMNS_ONLY)
134
138
  elif isinstance(node, exp.In):
135
139
  if not isinstance(node.this, exp.Column) or not all(
136
140
  isinstance(item, (exp.Literal, exp.Neg, exp.Placeholder)) for item in node.expressions
137
141
  ):
138
- raise reject("IN supports a column and a list of literals.")
142
+ raise reject(Reason.IN_LITERALS_ONLY)
139
143
  elif isinstance(node, _COMPARISONS):
140
144
  for side in (node.this, node.expression):
141
145
  if isinstance(side, exp.Star):
142
- raise reject("* cannot be compared.")
146
+ raise reject(Reason.STAR_COMPARISON)
143
147
 
144
148
 
145
149
  def validate_allowlist(query: exp.Expression, *, allow_placeholders: bool = False) -> None:
146
150
  """Fail closed on any node type, argument, or shape outside the supported subset."""
147
151
  if not isinstance(query, exp.Select):
148
- raise reject("only SELECT statements are supported.")
152
+ raise reject(Reason.SELECT_ONLY)
149
153
  for node in query.walk():
150
154
  _check_node(node, allow_placeholders)
151
155
  for projection in query.expressions:
152
156
  if not isinstance(projection, _PROJECTIONS):
153
- raise reject("unsupported projection.")
157
+ raise reject(Reason.PROJECTION_UNSUPPORTED)
158
+
159
+
160
+ def strip_comments(query: exp.Expression) -> None:
161
+ """Drop caller comments so no caller-controlled text is ever emitted into generated SQL."""
162
+ for node in query.walk():
163
+ node.pop_comments()
154
164
 
155
165
 
156
166
  def parse_select(sql: str, limits: ParserLimits) -> exp.Select:
157
167
  """Parse exactly one root SELECT; any failure becomes QUERY_REJECTED."""
158
168
  if len(sql) > limits.max_sql_chars:
159
- raise reject("the SQL exceeds the configured length limit.")
169
+ raise reject(Reason.LIMIT_CHARS)
160
170
  try:
161
171
  statements = [s for s in sqlglot.parse(sql, dialect=DIALECT) if s is not None]
162
172
  if len(statements) != 1:
163
- raise reject("exactly one statement is required.")
173
+ raise reject(Reason.ONE_STATEMENT)
164
174
  query = statements[0]
165
175
  if not isinstance(query, exp.Select):
166
- raise reject("only SELECT statements are supported.")
176
+ raise reject(Reason.SELECT_ONLY)
167
177
  check_limits(query, limits)
168
178
  validate_allowlist(query)
179
+ strip_comments(query)
169
180
  except DomainError:
170
181
  raise
171
182
  except Exception as exc:
172
- raise reject("the SQL could not be parsed.") from exc
183
+ raise reject(Reason.PARSE_FAILED) from exc
173
184
  return query
@@ -7,6 +7,7 @@ from sqlglot import exp
7
7
  from sql_mini_mcp.config import PiiConfig
8
8
  from sql_mini_mcp.security.lineage import AnalyzedQuery, ColumnRef, SourceColumn
9
9
  from sql_mini_mcp.security.parser import reject
10
+ from sql_mini_mcp.security.reasons import Reason
10
11
  from sql_mini_mcp.security.tokens import PREFIX, TokenCodec
11
12
 
12
13
 
@@ -57,7 +58,7 @@ class PiiPolicy:
57
58
  and str(literal.this).startswith(PREFIX)
58
59
  and id(literal) not in consumed
59
60
  ):
60
- raise reject("a PII token can only be compared with a protected column.")
61
+ raise reject(Reason.TOKEN_OUTSIDE_PROTECTED)
61
62
  protected = tuple(
62
63
  output.source is not None and self.is_protected(output.source)
63
64
  for output in analyzed.outputs
@@ -72,12 +73,12 @@ class PiiPolicy:
72
73
  if isinstance(parent, exp.Alias) and isinstance(parent.parent, exp.Select):
73
74
  return []
74
75
  if not _in_where(column):
75
- raise reject("a protected column can only be projected or compared with a token.")
76
+ raise reject(Reason.PROTECTED_POSITION)
76
77
  if isinstance(parent, exp.EQ) and parent.this is column:
77
78
  return [self._token_site(parent.expression)]
78
79
  if isinstance(parent, exp.In) and parent.this is column and parent.expressions:
79
80
  return [self._token_site(item) for item in parent.expressions]
80
- raise reject("a protected column can only be projected or compared with a token.")
81
+ raise reject(Reason.PROTECTED_POSITION)
81
82
 
82
83
  def _token_site(self, node: exp.Expression) -> TokenSite:
83
84
  if (
@@ -85,7 +86,7 @@ class PiiPolicy:
85
86
  or not node.is_string
86
87
  or not str(node.this).startswith(PREFIX)
87
88
  ):
88
- raise reject("a protected column can only be compared with a PII token.")
89
+ raise reject(Reason.PROTECTED_NEEDS_TOKEN)
89
90
  return TokenSite(node, self._codec.decrypt(str(node.this)))
90
91
 
91
92
 
@@ -0,0 +1,43 @@
1
+ """Fixed, reviewable reasons for QUERY_REJECTED. Free text never reaches reject()."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class Reason(StrEnum):
7
+ TABLE_RESOLUTION_MISMATCH = "table resolution does not match the query"
8
+ DUPLICATE_BINDING = "table names and aliases must be unique"
9
+ QUALIFIER_UNKNOWN = "a column qualifier does not match a table in the query"
10
+ COLUMN_NOT_FOUND = "a referenced column was not found or is ambiguous"
11
+ COLUMN_AMBIGUOUS = "a referenced column was not found or is ambiguous; qualify it"
12
+ ORDER_OUTPUT_AMBIGUOUS = "ORDER BY refers to an ambiguous or non-column output"
13
+ QUALIFY_FAILED = "the query references unknown or ambiguous columns"
14
+ LIMIT_NODES = "the query exceeds the configured complexity limit"
15
+ LIMIT_JOINS = "the query has too many joins"
16
+ LIMIT_IN_LIST = "an IN list exceeds the configured item limit"
17
+ TABLE_UNSUPPORTED = "only base tables of the current database are supported"
18
+ COLUMN_REFERENCE_UNSUPPORTED = "unsupported column reference"
19
+ COUNT_STAR_ONLY = "only COUNT(*) is supported"
20
+ STAR_PROJECTION_ONLY = "* is supported only as a projection"
21
+ JOIN_NEEDS_ON = "JOIN requires a table and an explicit ON expression"
22
+ JOIN_TYPE_UNSUPPORTED = "only INNER JOIN and LEFT JOIN are supported"
23
+ IS_NULL_ONLY = "IS is supported only with NULL"
24
+ NEGATION_NUMERIC_ONLY = "negation is supported only for numeric literals"
25
+ TOP_INTEGER_ONLY = "TOP must be a non-negative integer literal"
26
+ ORDER_DIRECT_COLUMNS_ONLY = "ORDER BY supports only direct columns"
27
+ IN_LITERALS_ONLY = "IN supports a column and a list of literals"
28
+ STAR_COMPARISON = "* cannot be compared"
29
+ SELECT_ONLY = "only SELECT statements are supported"
30
+ PROJECTION_UNSUPPORTED = "unsupported projection"
31
+ LIMIT_CHARS = "the SQL exceeds the configured length limit"
32
+ ONE_STATEMENT = "exactly one statement is required"
33
+ PARSE_FAILED = "the SQL could not be parsed"
34
+ TOKEN_OUTSIDE_PROTECTED = "a PII token can only be compared with a protected column"
35
+ PROTECTED_POSITION = "a protected column can only be projected or compared with a token"
36
+ PROTECTED_NEEDS_TOKEN = "a protected column can only be compared with a PII token"
37
+ TABLE_GONE = "a referenced table no longer exists"
38
+ TABLE_NOT_FOUND = "a referenced table was not found or is ambiguous; qualify it with a schema"
39
+ TABLE_SCOPE = "only tables of the current database are supported"
40
+ BIND_UNSAFE = "bind parameters could not be generated safely"
41
+ TOKEN_NOT_REPLACED = "a PII token could not be replaced by a bind parameter"
42
+ NODE_UNSUPPORTED = "unsupported construct"
43
+ OPTION_UNSUPPORTED = "unsupported option"
@@ -13,6 +13,7 @@ from sqlglot import exp
13
13
 
14
14
  from sql_mini_mcp.db.reflection import list_tables as reflect_tables
15
15
  from sql_mini_mcp.security.parser import reject
16
+ from sql_mini_mcp.security.reasons import Reason
16
17
 
17
18
 
18
19
  @dataclass(frozen=True, slots=True)
@@ -92,7 +93,7 @@ class ReflectedCatalog:
92
93
  try:
93
94
  reflected = inspect(self._connection).get_columns(table, schema=schema or None)
94
95
  except NoSuchTableError as exc:
95
- raise reject("a referenced table no longer exists.") from exc
96
+ raise reject(Reason.TABLE_GONE) from exc
96
97
  cached = tuple(str(column["name"]) for column in reflected)
97
98
  self._cache.put(key, cached)
98
99
  return cached
@@ -108,7 +109,7 @@ def _match(tables: Sequence[tuple[str, str]], schema: str | None, name: str) ->
108
109
  and (folded_schema is None or table_schema.casefold() == folded_schema)
109
110
  ]
110
111
  if len(matches) != 1:
111
- raise reject("a referenced table was not found or is ambiguous; qualify it with a schema.")
112
+ raise reject(Reason.TABLE_NOT_FOUND)
112
113
  return matches[0]
113
114
 
114
115
 
@@ -121,7 +122,7 @@ def resolve_tables(query: exp.Select, catalog: TableCatalog) -> tuple[TableSchem
121
122
  resolved: list[TableSchema] = []
122
123
  for table in query.find_all(exp.Table):
123
124
  if table.args.get("catalog") or not isinstance(table.this, exp.Identifier):
124
- raise reject("only tables of the current database are supported.")
125
+ raise reject(Reason.TABLE_SCOPE)
125
126
  schema = table.db or None
126
127
  schema_name, table_name = _match(available, schema, table.name)
127
128
  resolved.append(
@@ -47,18 +47,6 @@ def _encode_float(value: float) -> float:
47
47
  return value
48
48
 
49
49
 
50
- def _decode_strict(kind: type, parse: Callable[[Any], Any], check: Callable[[Any, Any], bool]):
51
- def decode(data: Any) -> Any:
52
- if type(data) is not kind:
53
- raise ValueError("wrong payload type")
54
- value = parse(data)
55
- if not check(value, data):
56
- raise ValueError("non-canonical payload")
57
- return value
58
-
59
- return decode
60
-
61
-
62
50
  def _identity_check(_value: Any, _data: Any) -> bool:
63
51
  return True
64
52
 
@@ -84,20 +72,32 @@ _CODECS: dict[type, tuple[str, Callable[[Any], Any]]] = {
84
72
  UUID: ("uuid", str),
85
73
  bytes: ("bytes", lambda v: base64.b64encode(v).decode("ascii")),
86
74
  }
87
- _DECODERS: dict[str, Callable[[Any], Any]] = {
88
- "str": _decode_strict(str, lambda d: d, _identity_check),
89
- "bool": _decode_strict(bool, lambda d: d, _identity_check),
90
- "int": _decode_strict(int, lambda d: d, _identity_check),
91
- "float": _decode_strict(float, lambda d: d, _identity_check),
92
- "decimal": _decode_strict(str, Decimal, _finite_decimal),
93
- "date": _decode_strict(str, date.fromisoformat, lambda v, d: v.isoformat() == d),
94
- "datetime": _decode_strict(str, datetime.fromisoformat, lambda v, d: v.isoformat() == d),
95
- "time": _decode_strict(str, time.fromisoformat, lambda v, d: v.isoformat() == d),
96
- "uuid": _decode_strict(str, UUID, lambda v, d: str(v) == d),
97
- "bytes": _decode_strict(str, lambda d: base64.b64decode(d, validate=True), _bytes_check),
75
+ # tag -> (JSON type of `d`, parser, canonical-form check); interpreted at decode time by
76
+ # _decode_value so every branch runs (and can be verified) per call.
77
+ _DECODERS: dict[str, tuple[type, Callable[[Any], Any], Callable[[Any, Any], bool]]] = {
78
+ "str": (str, lambda d: d, _identity_check),
79
+ "bool": (bool, lambda d: d, _identity_check),
80
+ "int": (int, lambda d: d, _identity_check),
81
+ "float": (float, lambda d: d, _identity_check),
82
+ "decimal": (str, Decimal, _finite_decimal),
83
+ "date": (str, date.fromisoformat, lambda v, d: v.isoformat() == d),
84
+ "datetime": (str, datetime.fromisoformat, lambda v, d: v.isoformat() == d),
85
+ "time": (str, time.fromisoformat, lambda v, d: v.isoformat() == d),
86
+ "uuid": (str, UUID, lambda v, d: str(v) == d),
87
+ "bytes": (str, lambda d: base64.b64decode(d, validate=True), _bytes_check),
98
88
  }
99
89
 
100
90
 
91
+ def _decode_value(tag: str, data: Any) -> Any:
92
+ kind, parse, check = _DECODERS[tag]
93
+ if type(data) is not kind:
94
+ raise ValueError("wrong payload type")
95
+ value = parse(data)
96
+ if not check(value, data):
97
+ raise ValueError("non-canonical payload")
98
+ return value
99
+
100
+
101
101
  class TokenCodec:
102
102
  """AES-256-GCM tokens for one server alias; the alias is authenticated as AAD."""
103
103
 
@@ -173,7 +173,7 @@ class TokenCodec:
173
173
  version = document["v"]
174
174
  if type(version) is not int or version != _PAYLOAD_VERSION:
175
175
  raise ValueError
176
- return _DECODERS[document["t"]](document["d"])
176
+ return _decode_value(document["t"], document["d"])
177
177
 
178
178
 
179
179
  class TokenKeyRegistry:
@@ -18,6 +18,7 @@ from sql_mini_mcp.security.parser import (
18
18
  validate_allowlist,
19
19
  )
20
20
  from sql_mini_mcp.security.policy import PolicyDecision
21
+ from sql_mini_mcp.security.reasons import Reason
21
22
  from sql_mini_mcp.security.tokens import PREFIX
22
23
 
23
24
  _SEAL = object()
@@ -154,7 +155,7 @@ def _to_qmark(query: exp.Select, parameters: dict[str, Any]) -> tuple[str, tuple
154
155
  sql = rendered.sql(dialect=DIALECT)
155
156
  found = re.findall(rf"__bind_{nonce}_\d+__", sql)
156
157
  if len(found) != len(markers) or set(found) != set(markers):
157
- raise reject("bind parameters could not be generated safely.")
158
+ raise reject(Reason.BIND_UNSAFE)
158
159
  for marker in found:
159
160
  sql = sql.replace(marker, "?", 1)
160
161
  return sql, tuple(parameters[markers[marker]] for marker in found)
@@ -186,7 +187,7 @@ def issue_validated_query(
186
187
  validate_allowlist(query, allow_placeholders=True)
187
188
  sql, ordered = _to_qmark(query, parameters)
188
189
  if PREFIX in sql:
189
- raise reject("a PII token could not be replaced by a bind parameter.")
190
+ raise reject(Reason.TOKEN_NOT_REPLACED)
190
191
  outputs = tuple(
191
192
  OutputPlan(output.label, output.kind, output.source, protected)
192
193
  for output, protected in zip(analyzed.outputs, decision.protected, strict=True)
File without changes
File without changes