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.
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/CHANGELOG.md +14 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/PKG-INFO +2 -2
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/README.md +1 -1
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/pyproject.toml +15 -1
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/server.json +2 -2
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/__init__.py +1 -1
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/config.py +0 -2
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/mcp_server.py +1 -1
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/lineage.py +9 -8
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/parser.py +40 -29
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/policy.py +5 -4
- sql_mini_mcp-1.1.0/src/sql_mini_mcp/security/reasons.py +43 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/schema.py +4 -3
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/tokens.py +24 -24
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/validated_query.py +3 -2
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/.gitignore +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/LICENSE +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/sql-mini-mcp.example.yaml +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/__main__.py +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/db/__init__.py +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/db/extras.py +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/db/reflection.py +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/db/registry.py +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/db/sqlserver.py +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/errors.py +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/models.py +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/__init__.py +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/executor.py +0 -0
- {sql_mini_mcp-1.0.0 → sql_mini_mcp-1.1.0}/src/sql_mini_mcp/security/pipeline.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
10
|
+
"version": "1.1.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "sql-mini-mcp",
|
|
15
|
-
"version": "1.
|
|
15
|
+
"version": "1.1.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -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.
|
|
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(
|
|
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(
|
|
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(
|
|
92
|
+
raise reject(Reason.QUALIFIER_UNKNOWN)
|
|
92
93
|
canonical = _canonical_column(matching[0], name)
|
|
93
94
|
if canonical is None:
|
|
94
|
-
raise reject(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
47
|
+
raise reject(Reason.LIMIT_NODES)
|
|
44
48
|
if sum(1 for _ in query.find_all(exp.Join)) > limits.max_joins:
|
|
45
|
-
raise reject(
|
|
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(
|
|
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(
|
|
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__}
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
122
|
+
raise reject(Reason.JOIN_NEEDS_ON)
|
|
119
123
|
if node.side not in {"", "LEFT"} or node.kind not in {"", "INNER"}:
|
|
120
|
-
raise reject(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
173
|
+
raise reject(Reason.ONE_STATEMENT)
|
|
164
174
|
query = statements[0]
|
|
165
175
|
if not isinstance(query, exp.Select):
|
|
166
|
-
raise reject(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|