sql-safe-mcp 1.3.0__tar.gz → 1.4.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_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/CHANGELOG.md +18 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/PKG-INFO +94 -79
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/README.md +93 -78
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/pyproject.toml +1 -1
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/server.json +2 -2
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/sql-safe-mcp.example.yaml +16 -3
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/__init__.py +1 -1
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/__main__.py +23 -1
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/config.py +113 -15
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/mcp_server.py +1 -1
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/policy.py +4 -1
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/.gitignore +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/LICENSE +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/__init__.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/extras.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/mysql.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/reflection.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/registry.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/sqlserver.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/diagnostics.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/errors.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/models.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/__init__.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/dialect.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/executor.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/lineage.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/parser.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/pipeline.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/reasons.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/schema.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/tokens.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/validated_query.py +0 -0
- {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/service.py +0 -0
|
@@ -7,6 +7,24 @@ categories `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security`.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.4.0] - 2026-09-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `sql-safe-mcp --gen-pii-key [N]` generates one base64-encoded 32-byte PII key by default, or
|
|
15
|
+
`N` keys as one value per line, without loading configuration or connecting to a database.
|
|
16
|
+
- PII table rules now accept case-insensitive full shell-glob patterns, and `pii_rules` can define
|
|
17
|
+
shared unnamed defaults or named sets explicitly included by `pii_safe` aliases. Shared rules are
|
|
18
|
+
flattened at startup in deterministic default, include, and local order.
|
|
19
|
+
|
|
20
|
+
## [1.3.1] - 2026-09-21
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- A `${NAME}` placeholder embedded in `connection_url` whose value puts an encoded character into
|
|
25
|
+
the host, such as a `host:port` value whose `:` becomes `%3A`, is now a startup error that names
|
|
26
|
+
the cause. Before, the server started and every call failed after the driver's login timeout.
|
|
27
|
+
|
|
10
28
|
## [1.3.0] - 2026-09-21
|
|
11
29
|
|
|
12
30
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sql-safe-mcp
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Minimal, read-only, PII-safe MCP server for SQL databases.
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -40,7 +40,9 @@ Description-Content-Type: text/markdown
|
|
|
40
40
|
|
|
41
41
|
# sql-safe-mcp
|
|
42
42
|
|
|
43
|
-
A
|
|
43
|
+
A PII-safe, read-only MCP server for coding agents working with SQL Server, MySQL and MariaDB:
|
|
44
|
+
schema knowledge and safe queries without write-capable tools or plaintext exposure of configured
|
|
45
|
+
PII columns.
|
|
44
46
|
|
|
45
47
|
[](https://github.com/proprock/sql-safe-mcp/actions/workflows/ci.yml)
|
|
46
48
|
[](https://github.com/proprock/sql-safe-mcp/releases)
|
|
@@ -59,31 +61,32 @@ A read-only, PII-safe SQL Server, MySQL and MariaDB MCP server for coding agents
|
|
|
59
61
|
|
|
60
62
|
<!-- mcp-name: io.github.proprock/sql-safe-mcp -->
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
`pii_safe`, a way to look at real rows without ever seeing the personal data in them.
|
|
64
|
+
Give a coding agent the schema knowledge it needs to write correct code - servers, databases,
|
|
65
|
+
tables, columns, keys, indexes, and stored procedures. On aliases you mark `pii_safe`, it can also
|
|
66
|
+
query real rows while configured personal data stays hidden behind authenticated tokens.
|
|
66
67
|
|
|
67
|
-
##
|
|
68
|
+
## PII-safe by default. Read-only by design.
|
|
68
69
|
|
|
69
|
-
- **Read-only by construction** - seven tools, all annotated read-only. No tool writes data, and
|
|
70
|
-
the server never executes SQL an agent wrote: metadata comes from SQLAlchemy Inspector and fixed
|
|
71
|
-
catalog queries, and `execute_sql` runs only a validated, regenerated `SELECT`.
|
|
72
70
|
- **PII-safe by default** - on a `pii_safe` server, the columns you configure come back as
|
|
73
71
|
alias-bound, authenticated tokens (`pii:v1:...`), never as plaintext. An agent can still
|
|
74
72
|
project, count, and filter on them with `=` and `IN` using tokens it was given, so it can follow
|
|
75
73
|
a record without reading it. Tokens do not work on another server alias or with another key.
|
|
74
|
+
- **Read-only by construction** - seven tools, all annotated read-only. No tool writes data, and
|
|
75
|
+
the server never executes SQL an agent wrote: metadata comes from SQLAlchemy Inspector and fixed
|
|
76
|
+
catalog queries, and `execute_sql` runs only a validated, regenerated `SELECT`.
|
|
77
|
+
- **Multiple servers, isolated policies** - one MCP process can expose multiple named SQL Server,
|
|
78
|
+
MySQL, and MariaDB instances. Each alias has its own connection, access level, and, when enabled,
|
|
79
|
+
PII key and protection rules. Every database operation targets an explicit alias; there is no
|
|
80
|
+
network discovery.
|
|
81
|
+
|
|
82
|
+
## Operational safeguards
|
|
83
|
+
|
|
76
84
|
- **Fails closed** - SQL validation is an allowlist. Unknown syntax, unresolved lineage, and
|
|
77
85
|
unsupported protected-value types are refused, not guessed at. The verification evidence is in
|
|
78
86
|
the [security model](SECURITY-MODEL.md).
|
|
79
87
|
- **Least access first** - `access_level: metadata` (the default) exposes schema only;
|
|
80
88
|
`execute_sql` needs an explicit `pii_safe` alias with its own key. Database permissions stay the
|
|
81
89
|
primary control, so use a least-privilege login.
|
|
82
|
-
|
|
83
|
-
## Also
|
|
84
|
-
|
|
85
|
-
- **Your aliases, not your network** - the agent sees only the server aliases you configure. There
|
|
86
|
-
is no network discovery, and the catalog is not published as MCP resources.
|
|
87
90
|
- **Secrets stay out of sight** - connection URLs live in YAML with `${NAME}` placeholders resolved
|
|
88
91
|
from the environment. They never appear in logs or model-visible errors.
|
|
89
92
|
- **Compact, predictable output** - object-rooted results with stable sorting, literal
|
|
@@ -98,7 +101,6 @@ databases, tables, columns, keys, indexes, stored procedures - and, on servers y
|
|
|
98
101
|
- **Diagnosable failures** - a timeout or connection error carries a `Reference`, and the stderr
|
|
99
102
|
log records the connection stage, elapsed time, and driver error for the same reference, with
|
|
100
103
|
credentials removed. See [Logging](docs/configuration.md#logging).
|
|
101
|
-
- **On PyPI** - `uvx sql-safe-mcp`, no repo clone required.
|
|
102
104
|
|
|
103
105
|
| Tool | Access | Purpose |
|
|
104
106
|
|---|---|---|
|
|
@@ -116,16 +118,9 @@ databases, tables, columns, keys, indexes, stored procedures - and, on servers y
|
|
|
116
118
|
> database is the catalog, and `execute_sql` uses `LIMIT` instead of `TOP`. See
|
|
117
119
|
> [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
118
120
|
|
|
119
|
-
|
|
120
|
-
- [Configure](#configure)
|
|
121
|
-
- [PII-safe queries](#pii-safe-queries)
|
|
122
|
-
- [Security](#security)
|
|
123
|
-
- [Contributing](#contributing)
|
|
121
|
+
## Quick start
|
|
124
122
|
|
|
125
|
-
|
|
126
|
-
[what the tools return](docs/tools.md), and the [security model](SECURITY-MODEL.md).
|
|
127
|
-
|
|
128
|
-
## Install
|
|
123
|
+
### Install
|
|
129
124
|
|
|
130
125
|
```bash
|
|
131
126
|
uvx sql-safe-mcp
|
|
@@ -137,7 +132,7 @@ or
|
|
|
137
132
|
pip install sql-safe-mcp
|
|
138
133
|
```
|
|
139
134
|
|
|
140
|
-
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.
|
|
135
|
+
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.4.0`.
|
|
141
136
|
|
|
142
137
|
Requires Python 3.12+, [uv](https://docs.astral.sh/uv/) (or `pip`), and
|
|
143
138
|
[Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)
|
|
@@ -146,10 +141,10 @@ else.
|
|
|
146
141
|
|
|
147
142
|
Verified against SQL Server 2022, MySQL 8.4, and MariaDB 11.4 (see [CHECKS.md](CHECKS.md)).
|
|
148
143
|
|
|
149
|
-
|
|
144
|
+
### Configure multiple servers
|
|
150
145
|
|
|
151
|
-
Copy [sql-safe-mcp.example.yaml](sql-safe-mcp.example.yaml) to `sql-safe-mcp.yaml
|
|
152
|
-
|
|
146
|
+
Copy [sql-safe-mcp.example.yaml](sql-safe-mcp.example.yaml) to `sql-safe-mcp.yaml`. Add each
|
|
147
|
+
database server under a named alias and keep credentials in environment variables:
|
|
153
148
|
|
|
154
149
|
```yaml
|
|
155
150
|
version: 1
|
|
@@ -158,36 +153,50 @@ servers:
|
|
|
158
153
|
engine: sqlserver
|
|
159
154
|
access_level: metadata
|
|
160
155
|
connection_url: "${REPORTING_SQL_URL}"
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
`connection_url` is a SQLAlchemy URL whose dialect must match `engine`. It is a secret, so keep the
|
|
164
|
-
credentials in environment variables and reference them with `${NAME}`:
|
|
165
|
-
|
|
166
|
-
```yaml
|
|
167
|
-
servers:
|
|
168
|
-
# The whole URL comes from one variable (it may hold any valid URL).
|
|
169
|
-
reporting:
|
|
170
|
-
engine: sqlserver
|
|
171
|
-
connection_url: "${REPORTING_SQL_URL}"
|
|
172
156
|
|
|
173
|
-
# SQL Server (mssql+pyodbc), URL assembled from parts. Embedded placeholders are
|
|
174
|
-
# URL-encoded, so a password containing @ or / is safe.
|
|
175
157
|
billing:
|
|
176
158
|
engine: sqlserver
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
159
|
+
access_level: pii_safe
|
|
160
|
+
connection_url: "${BILLING_SQL_URL}"
|
|
161
|
+
pii_key_env: BILLING_PII_KEY
|
|
162
|
+
pii:
|
|
163
|
+
rules:
|
|
164
|
+
- database: Billing
|
|
165
|
+
schema: dbo
|
|
166
|
+
table: Customers
|
|
167
|
+
columns: [Email, FullName]
|
|
180
168
|
|
|
181
|
-
# MySQL or MariaDB (mysql+pymysql). Use engine: mariadb for MariaDB.
|
|
182
169
|
shop:
|
|
183
170
|
engine: mysql
|
|
171
|
+
access_level: metadata
|
|
184
172
|
connection_url: "mysql+pymysql://${SHOP_USER}:${SHOP_PASSWORD}@db.internal/shop"
|
|
185
173
|
```
|
|
186
174
|
|
|
187
|
-
|
|
175
|
+
Here `reporting`, `billing`, and `shop` are the values an agent passes as `server`. Their
|
|
176
|
+
credentials and access policies are independent. A missing variable, or an `engine` that does not
|
|
177
|
+
match the URL dialect, stops the MCP server at startup.
|
|
178
|
+
|
|
179
|
+
### Ask an agent to install it
|
|
180
|
+
|
|
181
|
+
Give a coding agent the following prompt when you want it to perform the setup in its own MCP
|
|
182
|
+
host:
|
|
183
|
+
|
|
184
|
+
```text
|
|
185
|
+
Install and configure sql-safe-mcp as a stdio MCP server for this workspace. First inspect the
|
|
186
|
+
host's existing MCP configuration convention and preserve all unrelated server entries. Create or
|
|
187
|
+
update a non-secret YAML configuration using environment-variable placeholders only. Never put
|
|
188
|
+
connection URLs, passwords, PII keys, tokens, bind values, or query results in tracked files, chat
|
|
189
|
+
output, or logs. If the database aliases, engines, access levels, PII rules, configuration path,
|
|
190
|
+
or secret-storage mechanism are not specified, ask me before choosing them. Configure the host to
|
|
191
|
+
run `uvx sql-safe-mcp` with `SQL_SAFE_MCP_CONFIG` and the required variables supplied through the
|
|
192
|
+
host's secret or environment mechanism. Run `sql-safe-mcp --check-config` after the variables are
|
|
193
|
+
available, then report only the configured aliases and the validation result.
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Check the configuration
|
|
188
197
|
|
|
189
|
-
|
|
190
|
-
connecting to any database:
|
|
198
|
+
After defining every environment variable referenced by the YAML file, point the server at it with
|
|
199
|
+
`SQL_SAFE_MCP_CONFIG` (or `--config`) and check it without connecting to any database:
|
|
191
200
|
|
|
192
201
|
```bash
|
|
193
202
|
SQL_SAFE_MCP_CONFIG=sql-safe-mcp.yaml uvx sql-safe-mcp --check-config
|
|
@@ -196,8 +205,13 @@ SQL_SAFE_MCP_CONFIG=sql-safe-mcp.yaml uvx sql-safe-mcp --check-config
|
|
|
196
205
|
Configuration is validated at startup, and an error names the problem without printing a URL or
|
|
197
206
|
secret. Keep credentials in the host's own configuration and never commit them. The server acts
|
|
198
207
|
with the database account's permissions, so use a dedicated login with the least access the job
|
|
199
|
-
needs.
|
|
200
|
-
|
|
208
|
+
needs.
|
|
209
|
+
|
|
210
|
+
### Connect an MCP client
|
|
211
|
+
|
|
212
|
+
Every client configuration needs `SQL_SAFE_MCP_CONFIG` plus the environment variables referenced
|
|
213
|
+
by your YAML file. Keep connection URLs and PII keys in the MCP host's environment or
|
|
214
|
+
configuration, never in the YAML file or other tracked files.
|
|
201
215
|
|
|
202
216
|
<details>
|
|
203
217
|
<summary><b>Claude Code</b></summary>
|
|
@@ -206,15 +220,15 @@ needs. Every setting, including the runtime limits, is in
|
|
|
206
220
|
claude mcp add --env SQL_SAFE_MCP_CONFIG=/path/to/sql-safe-mcp.yaml --env REPORTING_SQL_URL=mssql+pyodbc://... --transport stdio sql-safe -- uvx sql-safe-mcp
|
|
207
221
|
```
|
|
208
222
|
|
|
209
|
-
Put at least one other option between the last `--env` and the server name, as above
|
|
210
|
-
|
|
223
|
+
Put at least one other option between the last `--env` and the server name, as above. Otherwise,
|
|
224
|
+
the CLI reads the name as another `KEY=value` pair.
|
|
211
225
|
|
|
212
226
|
</details>
|
|
213
227
|
|
|
214
228
|
<details>
|
|
215
229
|
<summary><b>Claude Desktop</b></summary>
|
|
216
230
|
|
|
217
|
-
|
|
231
|
+
Add the server to `claude_desktop_config.json`:
|
|
218
232
|
|
|
219
233
|
```json
|
|
220
234
|
{
|
|
@@ -245,42 +259,43 @@ codex mcp add sql-safe --env SQL_SAFE_MCP_CONFIG=/path/to/sql-safe-mcp.yaml --en
|
|
|
245
259
|
<details>
|
|
246
260
|
<summary><b>Any other stdio host</b></summary>
|
|
247
261
|
|
|
248
|
-
|
|
249
|
-
|
|
262
|
+
Use command `uvx`, argument `sql-safe-mcp`, and set `SQL_SAFE_MCP_CONFIG` plus every environment
|
|
263
|
+
variable referenced by the configuration file. The server writes MCP messages to stdout and logs
|
|
264
|
+
only to stderr.
|
|
250
265
|
|
|
251
266
|
</details>
|
|
252
267
|
|
|
253
268
|
## PII-safe queries
|
|
254
269
|
|
|
255
|
-
|
|
270
|
+
`execute_sql` on the `billing` alias allows one restricted `SELECT`. For example, these arguments:
|
|
256
271
|
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
pii_key_env: LEGACY_PROD_PII_KEY
|
|
264
|
-
pii:
|
|
265
|
-
rules:
|
|
266
|
-
- database: "*"
|
|
267
|
-
schema: dbo
|
|
268
|
-
table: Users
|
|
269
|
-
columns: [Email, FirstName, LastName]
|
|
272
|
+
```json
|
|
273
|
+
{
|
|
274
|
+
"server": "billing",
|
|
275
|
+
"database": "Billing",
|
|
276
|
+
"sql": "SELECT CustomerId, Email FROM dbo.Customers"
|
|
277
|
+
}
|
|
270
278
|
```
|
|
271
279
|
|
|
272
|
-
|
|
273
|
-
token is accepted only in `=` and `IN` predicates on the same
|
|
274
|
-
you list, so list every column that holds personal
|
|
275
|
-
|
|
280
|
+
can return a row such as `[42, "pii:v1:..."]`: the agent can follow the customer without reading
|
|
281
|
+
the email address. A token is accepted only in `=` and `IN` predicates on the same protected column
|
|
282
|
+
and server alias. Protection covers the columns you list, so list every column that holds personal
|
|
283
|
+
data.
|
|
284
|
+
|
|
285
|
+
## Documentation
|
|
286
|
+
|
|
287
|
+
- [Configuration reference](docs/configuration.md) - aliases, connection URLs, PII rules, runtime
|
|
288
|
+
limits, and logging.
|
|
289
|
+
- [Tool reference](docs/tools.md) - arguments, responses, errors, and the accepted SQL subset.
|
|
290
|
+
- [Security model](SECURITY-MODEL.md) - trust boundaries, guarantees, limitations, and verification.
|
|
291
|
+
- [Checks](CHECKS.md) - local, integration, and security verification commands.
|
|
276
292
|
|
|
277
293
|
## Security
|
|
278
294
|
|
|
279
|
-
The MCP caller, SQL input, database metadata, rows, and tokens are untrusted
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
use the private channel in [SECURITY.md](SECURITY.md).
|
|
295
|
+
The MCP caller, SQL input, database metadata, rows, and tokens are untrusted. Database permissions
|
|
296
|
+
remain the primary authorization control - this server never widens them. Read the full
|
|
297
|
+
[security model](SECURITY-MODEL.md), including its limitations and verification evidence. To report
|
|
298
|
+
a vulnerability, use the private channel in [SECURITY.md](SECURITY.md).
|
|
284
299
|
|
|
285
300
|
## Contributing
|
|
286
301
|
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
# sql-safe-mcp
|
|
4
4
|
|
|
5
|
-
A
|
|
5
|
+
A PII-safe, read-only MCP server for coding agents working with SQL Server, MySQL and MariaDB:
|
|
6
|
+
schema knowledge and safe queries without write-capable tools or plaintext exposure of configured
|
|
7
|
+
PII columns.
|
|
6
8
|
|
|
7
9
|
[](https://github.com/proprock/sql-safe-mcp/actions/workflows/ci.yml)
|
|
8
10
|
[](https://github.com/proprock/sql-safe-mcp/releases)
|
|
@@ -21,31 +23,32 @@ A read-only, PII-safe SQL Server, MySQL and MariaDB MCP server for coding agents
|
|
|
21
23
|
|
|
22
24
|
<!-- mcp-name: io.github.proprock/sql-safe-mcp -->
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
`pii_safe`, a way to look at real rows without ever seeing the personal data in them.
|
|
26
|
+
Give a coding agent the schema knowledge it needs to write correct code - servers, databases,
|
|
27
|
+
tables, columns, keys, indexes, and stored procedures. On aliases you mark `pii_safe`, it can also
|
|
28
|
+
query real rows while configured personal data stays hidden behind authenticated tokens.
|
|
28
29
|
|
|
29
|
-
##
|
|
30
|
+
## PII-safe by default. Read-only by design.
|
|
30
31
|
|
|
31
|
-
- **Read-only by construction** - seven tools, all annotated read-only. No tool writes data, and
|
|
32
|
-
the server never executes SQL an agent wrote: metadata comes from SQLAlchemy Inspector and fixed
|
|
33
|
-
catalog queries, and `execute_sql` runs only a validated, regenerated `SELECT`.
|
|
34
32
|
- **PII-safe by default** - on a `pii_safe` server, the columns you configure come back as
|
|
35
33
|
alias-bound, authenticated tokens (`pii:v1:...`), never as plaintext. An agent can still
|
|
36
34
|
project, count, and filter on them with `=` and `IN` using tokens it was given, so it can follow
|
|
37
35
|
a record without reading it. Tokens do not work on another server alias or with another key.
|
|
36
|
+
- **Read-only by construction** - seven tools, all annotated read-only. No tool writes data, and
|
|
37
|
+
the server never executes SQL an agent wrote: metadata comes from SQLAlchemy Inspector and fixed
|
|
38
|
+
catalog queries, and `execute_sql` runs only a validated, regenerated `SELECT`.
|
|
39
|
+
- **Multiple servers, isolated policies** - one MCP process can expose multiple named SQL Server,
|
|
40
|
+
MySQL, and MariaDB instances. Each alias has its own connection, access level, and, when enabled,
|
|
41
|
+
PII key and protection rules. Every database operation targets an explicit alias; there is no
|
|
42
|
+
network discovery.
|
|
43
|
+
|
|
44
|
+
## Operational safeguards
|
|
45
|
+
|
|
38
46
|
- **Fails closed** - SQL validation is an allowlist. Unknown syntax, unresolved lineage, and
|
|
39
47
|
unsupported protected-value types are refused, not guessed at. The verification evidence is in
|
|
40
48
|
the [security model](SECURITY-MODEL.md).
|
|
41
49
|
- **Least access first** - `access_level: metadata` (the default) exposes schema only;
|
|
42
50
|
`execute_sql` needs an explicit `pii_safe` alias with its own key. Database permissions stay the
|
|
43
51
|
primary control, so use a least-privilege login.
|
|
44
|
-
|
|
45
|
-
## Also
|
|
46
|
-
|
|
47
|
-
- **Your aliases, not your network** - the agent sees only the server aliases you configure. There
|
|
48
|
-
is no network discovery, and the catalog is not published as MCP resources.
|
|
49
52
|
- **Secrets stay out of sight** - connection URLs live in YAML with `${NAME}` placeholders resolved
|
|
50
53
|
from the environment. They never appear in logs or model-visible errors.
|
|
51
54
|
- **Compact, predictable output** - object-rooted results with stable sorting, literal
|
|
@@ -60,7 +63,6 @@ databases, tables, columns, keys, indexes, stored procedures - and, on servers y
|
|
|
60
63
|
- **Diagnosable failures** - a timeout or connection error carries a `Reference`, and the stderr
|
|
61
64
|
log records the connection stage, elapsed time, and driver error for the same reference, with
|
|
62
65
|
credentials removed. See [Logging](docs/configuration.md#logging).
|
|
63
|
-
- **On PyPI** - `uvx sql-safe-mcp`, no repo clone required.
|
|
64
66
|
|
|
65
67
|
| Tool | Access | Purpose |
|
|
66
68
|
|---|---|---|
|
|
@@ -78,16 +80,9 @@ databases, tables, columns, keys, indexes, stored procedures - and, on servers y
|
|
|
78
80
|
> database is the catalog, and `execute_sql` uses `LIMIT` instead of `TOP`. See
|
|
79
81
|
> [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
- [Configure](#configure)
|
|
83
|
-
- [PII-safe queries](#pii-safe-queries)
|
|
84
|
-
- [Security](#security)
|
|
85
|
-
- [Contributing](#contributing)
|
|
83
|
+
## Quick start
|
|
86
84
|
|
|
87
|
-
|
|
88
|
-
[what the tools return](docs/tools.md), and the [security model](SECURITY-MODEL.md).
|
|
89
|
-
|
|
90
|
-
## Install
|
|
85
|
+
### Install
|
|
91
86
|
|
|
92
87
|
```bash
|
|
93
88
|
uvx sql-safe-mcp
|
|
@@ -99,7 +94,7 @@ or
|
|
|
99
94
|
pip install sql-safe-mcp
|
|
100
95
|
```
|
|
101
96
|
|
|
102
|
-
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.
|
|
97
|
+
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.4.0`.
|
|
103
98
|
|
|
104
99
|
Requires Python 3.12+, [uv](https://docs.astral.sh/uv/) (or `pip`), and
|
|
105
100
|
[Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)
|
|
@@ -108,10 +103,10 @@ else.
|
|
|
108
103
|
|
|
109
104
|
Verified against SQL Server 2022, MySQL 8.4, and MariaDB 11.4 (see [CHECKS.md](CHECKS.md)).
|
|
110
105
|
|
|
111
|
-
|
|
106
|
+
### Configure multiple servers
|
|
112
107
|
|
|
113
|
-
Copy [sql-safe-mcp.example.yaml](sql-safe-mcp.example.yaml) to `sql-safe-mcp.yaml
|
|
114
|
-
|
|
108
|
+
Copy [sql-safe-mcp.example.yaml](sql-safe-mcp.example.yaml) to `sql-safe-mcp.yaml`. Add each
|
|
109
|
+
database server under a named alias and keep credentials in environment variables:
|
|
115
110
|
|
|
116
111
|
```yaml
|
|
117
112
|
version: 1
|
|
@@ -120,36 +115,50 @@ servers:
|
|
|
120
115
|
engine: sqlserver
|
|
121
116
|
access_level: metadata
|
|
122
117
|
connection_url: "${REPORTING_SQL_URL}"
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
`connection_url` is a SQLAlchemy URL whose dialect must match `engine`. It is a secret, so keep the
|
|
126
|
-
credentials in environment variables and reference them with `${NAME}`:
|
|
127
|
-
|
|
128
|
-
```yaml
|
|
129
|
-
servers:
|
|
130
|
-
# The whole URL comes from one variable (it may hold any valid URL).
|
|
131
|
-
reporting:
|
|
132
|
-
engine: sqlserver
|
|
133
|
-
connection_url: "${REPORTING_SQL_URL}"
|
|
134
118
|
|
|
135
|
-
# SQL Server (mssql+pyodbc), URL assembled from parts. Embedded placeholders are
|
|
136
|
-
# URL-encoded, so a password containing @ or / is safe.
|
|
137
119
|
billing:
|
|
138
120
|
engine: sqlserver
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
121
|
+
access_level: pii_safe
|
|
122
|
+
connection_url: "${BILLING_SQL_URL}"
|
|
123
|
+
pii_key_env: BILLING_PII_KEY
|
|
124
|
+
pii:
|
|
125
|
+
rules:
|
|
126
|
+
- database: Billing
|
|
127
|
+
schema: dbo
|
|
128
|
+
table: Customers
|
|
129
|
+
columns: [Email, FullName]
|
|
142
130
|
|
|
143
|
-
# MySQL or MariaDB (mysql+pymysql). Use engine: mariadb for MariaDB.
|
|
144
131
|
shop:
|
|
145
132
|
engine: mysql
|
|
133
|
+
access_level: metadata
|
|
146
134
|
connection_url: "mysql+pymysql://${SHOP_USER}:${SHOP_PASSWORD}@db.internal/shop"
|
|
147
135
|
```
|
|
148
136
|
|
|
149
|
-
|
|
137
|
+
Here `reporting`, `billing`, and `shop` are the values an agent passes as `server`. Their
|
|
138
|
+
credentials and access policies are independent. A missing variable, or an `engine` that does not
|
|
139
|
+
match the URL dialect, stops the MCP server at startup.
|
|
140
|
+
|
|
141
|
+
### Ask an agent to install it
|
|
142
|
+
|
|
143
|
+
Give a coding agent the following prompt when you want it to perform the setup in its own MCP
|
|
144
|
+
host:
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
Install and configure sql-safe-mcp as a stdio MCP server for this workspace. First inspect the
|
|
148
|
+
host's existing MCP configuration convention and preserve all unrelated server entries. Create or
|
|
149
|
+
update a non-secret YAML configuration using environment-variable placeholders only. Never put
|
|
150
|
+
connection URLs, passwords, PII keys, tokens, bind values, or query results in tracked files, chat
|
|
151
|
+
output, or logs. If the database aliases, engines, access levels, PII rules, configuration path,
|
|
152
|
+
or secret-storage mechanism are not specified, ask me before choosing them. Configure the host to
|
|
153
|
+
run `uvx sql-safe-mcp` with `SQL_SAFE_MCP_CONFIG` and the required variables supplied through the
|
|
154
|
+
host's secret or environment mechanism. Run `sql-safe-mcp --check-config` after the variables are
|
|
155
|
+
available, then report only the configured aliases and the validation result.
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Check the configuration
|
|
150
159
|
|
|
151
|
-
|
|
152
|
-
connecting to any database:
|
|
160
|
+
After defining every environment variable referenced by the YAML file, point the server at it with
|
|
161
|
+
`SQL_SAFE_MCP_CONFIG` (or `--config`) and check it without connecting to any database:
|
|
153
162
|
|
|
154
163
|
```bash
|
|
155
164
|
SQL_SAFE_MCP_CONFIG=sql-safe-mcp.yaml uvx sql-safe-mcp --check-config
|
|
@@ -158,8 +167,13 @@ SQL_SAFE_MCP_CONFIG=sql-safe-mcp.yaml uvx sql-safe-mcp --check-config
|
|
|
158
167
|
Configuration is validated at startup, and an error names the problem without printing a URL or
|
|
159
168
|
secret. Keep credentials in the host's own configuration and never commit them. The server acts
|
|
160
169
|
with the database account's permissions, so use a dedicated login with the least access the job
|
|
161
|
-
needs.
|
|
162
|
-
|
|
170
|
+
needs.
|
|
171
|
+
|
|
172
|
+
### Connect an MCP client
|
|
173
|
+
|
|
174
|
+
Every client configuration needs `SQL_SAFE_MCP_CONFIG` plus the environment variables referenced
|
|
175
|
+
by your YAML file. Keep connection URLs and PII keys in the MCP host's environment or
|
|
176
|
+
configuration, never in the YAML file or other tracked files.
|
|
163
177
|
|
|
164
178
|
<details>
|
|
165
179
|
<summary><b>Claude Code</b></summary>
|
|
@@ -168,15 +182,15 @@ needs. Every setting, including the runtime limits, is in
|
|
|
168
182
|
claude mcp add --env SQL_SAFE_MCP_CONFIG=/path/to/sql-safe-mcp.yaml --env REPORTING_SQL_URL=mssql+pyodbc://... --transport stdio sql-safe -- uvx sql-safe-mcp
|
|
169
183
|
```
|
|
170
184
|
|
|
171
|
-
Put at least one other option between the last `--env` and the server name, as above
|
|
172
|
-
|
|
185
|
+
Put at least one other option between the last `--env` and the server name, as above. Otherwise,
|
|
186
|
+
the CLI reads the name as another `KEY=value` pair.
|
|
173
187
|
|
|
174
188
|
</details>
|
|
175
189
|
|
|
176
190
|
<details>
|
|
177
191
|
<summary><b>Claude Desktop</b></summary>
|
|
178
192
|
|
|
179
|
-
|
|
193
|
+
Add the server to `claude_desktop_config.json`:
|
|
180
194
|
|
|
181
195
|
```json
|
|
182
196
|
{
|
|
@@ -207,42 +221,43 @@ codex mcp add sql-safe --env SQL_SAFE_MCP_CONFIG=/path/to/sql-safe-mcp.yaml --en
|
|
|
207
221
|
<details>
|
|
208
222
|
<summary><b>Any other stdio host</b></summary>
|
|
209
223
|
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
Use command `uvx`, argument `sql-safe-mcp`, and set `SQL_SAFE_MCP_CONFIG` plus every environment
|
|
225
|
+
variable referenced by the configuration file. The server writes MCP messages to stdout and logs
|
|
226
|
+
only to stderr.
|
|
212
227
|
|
|
213
228
|
</details>
|
|
214
229
|
|
|
215
230
|
## PII-safe queries
|
|
216
231
|
|
|
217
|
-
|
|
232
|
+
`execute_sql` on the `billing` alias allows one restricted `SELECT`. For example, these arguments:
|
|
218
233
|
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
pii_key_env: LEGACY_PROD_PII_KEY
|
|
226
|
-
pii:
|
|
227
|
-
rules:
|
|
228
|
-
- database: "*"
|
|
229
|
-
schema: dbo
|
|
230
|
-
table: Users
|
|
231
|
-
columns: [Email, FirstName, LastName]
|
|
234
|
+
```json
|
|
235
|
+
{
|
|
236
|
+
"server": "billing",
|
|
237
|
+
"database": "Billing",
|
|
238
|
+
"sql": "SELECT CustomerId, Email FROM dbo.Customers"
|
|
239
|
+
}
|
|
232
240
|
```
|
|
233
241
|
|
|
234
|
-
|
|
235
|
-
token is accepted only in `=` and `IN` predicates on the same
|
|
236
|
-
you list, so list every column that holds personal
|
|
237
|
-
|
|
242
|
+
can return a row such as `[42, "pii:v1:..."]`: the agent can follow the customer without reading
|
|
243
|
+
the email address. A token is accepted only in `=` and `IN` predicates on the same protected column
|
|
244
|
+
and server alias. Protection covers the columns you list, so list every column that holds personal
|
|
245
|
+
data.
|
|
246
|
+
|
|
247
|
+
## Documentation
|
|
248
|
+
|
|
249
|
+
- [Configuration reference](docs/configuration.md) - aliases, connection URLs, PII rules, runtime
|
|
250
|
+
limits, and logging.
|
|
251
|
+
- [Tool reference](docs/tools.md) - arguments, responses, errors, and the accepted SQL subset.
|
|
252
|
+
- [Security model](SECURITY-MODEL.md) - trust boundaries, guarantees, limitations, and verification.
|
|
253
|
+
- [Checks](CHECKS.md) - local, integration, and security verification commands.
|
|
238
254
|
|
|
239
255
|
## Security
|
|
240
256
|
|
|
241
|
-
The MCP caller, SQL input, database metadata, rows, and tokens are untrusted
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
use the private channel in [SECURITY.md](SECURITY.md).
|
|
257
|
+
The MCP caller, SQL input, database metadata, rows, and tokens are untrusted. Database permissions
|
|
258
|
+
remain the primary authorization control - this server never widens them. Read the full
|
|
259
|
+
[security model](SECURITY-MODEL.md), including its limitations and verification evidence. To report
|
|
260
|
+
a vulnerability, use the private channel in [SECURITY.md](SECURITY.md).
|
|
246
261
|
|
|
247
262
|
## Contributing
|
|
248
263
|
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/proprock/sql-safe-mcp",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.4.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "sql-safe-mcp",
|
|
15
|
-
"version": "1.
|
|
15
|
+
"version": "1.4.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -13,6 +13,18 @@ runtime:
|
|
|
13
13
|
default_max_rows: 200
|
|
14
14
|
hard_max_rows: 1000
|
|
15
15
|
|
|
16
|
+
pii_rules:
|
|
17
|
+
- rules: # optional unnamed default: every pii_safe alias receives this rule
|
|
18
|
+
- database: "*"
|
|
19
|
+
table: "Audit*"
|
|
20
|
+
columns: [IpAddress]
|
|
21
|
+
- name: users_pii
|
|
22
|
+
rules:
|
|
23
|
+
- database: "*"
|
|
24
|
+
schema: dbo
|
|
25
|
+
table: "Users[0-9]?"
|
|
26
|
+
columns: [Email, FirstName, LastName]
|
|
27
|
+
|
|
16
28
|
servers:
|
|
17
29
|
legacy-prod:
|
|
18
30
|
engine: sqlserver
|
|
@@ -23,11 +35,12 @@ servers:
|
|
|
23
35
|
?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=yes
|
|
24
36
|
pii_key_env: LEGACY_PROD_PII_KEY
|
|
25
37
|
pii:
|
|
38
|
+
include: [users_pii]
|
|
26
39
|
rules:
|
|
27
|
-
- database:
|
|
40
|
+
- database: Billing
|
|
28
41
|
schema: dbo
|
|
29
|
-
table:
|
|
30
|
-
columns: [
|
|
42
|
+
table: Cards
|
|
43
|
+
columns: [HolderName]
|
|
31
44
|
|
|
32
45
|
reporting-metadata:
|
|
33
46
|
engine: sqlserver
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import base64
|
|
4
5
|
import os
|
|
6
|
+
import secrets
|
|
5
7
|
import sys
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
|
|
@@ -10,6 +12,13 @@ from sql_safe_mcp.errors import DomainError
|
|
|
10
12
|
from sql_safe_mcp.mcp_server import create_server
|
|
11
13
|
|
|
12
14
|
|
|
15
|
+
def _positive_int(value: str) -> int:
|
|
16
|
+
count = int(value)
|
|
17
|
+
if count < 1:
|
|
18
|
+
raise argparse.ArgumentTypeError("must be a positive integer")
|
|
19
|
+
return count
|
|
20
|
+
|
|
21
|
+
|
|
13
22
|
def _parser() -> argparse.ArgumentParser:
|
|
14
23
|
parser = argparse.ArgumentParser(description="Run the sql-safe-mcp stdio server.")
|
|
15
24
|
parser.add_argument(
|
|
@@ -18,16 +27,29 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
18
27
|
default=os.environ.get("SQL_SAFE_MCP_CONFIG", "sql-safe-mcp.yaml"),
|
|
19
28
|
help="YAML configuration path (default: SQL_SAFE_MCP_CONFIG or sql-safe-mcp.yaml)",
|
|
20
29
|
)
|
|
21
|
-
parser.
|
|
30
|
+
actions = parser.add_mutually_exclusive_group()
|
|
31
|
+
actions.add_argument(
|
|
22
32
|
"--check-config",
|
|
23
33
|
action="store_true",
|
|
24
34
|
help="Validate configuration and secrets without connecting to databases.",
|
|
25
35
|
)
|
|
36
|
+
actions.add_argument(
|
|
37
|
+
"--gen-pii-key",
|
|
38
|
+
nargs="?",
|
|
39
|
+
type=_positive_int,
|
|
40
|
+
const=1,
|
|
41
|
+
metavar="N",
|
|
42
|
+
help="Print N base64-encoded 32-byte PII keys (default: 1) and exit.",
|
|
43
|
+
)
|
|
26
44
|
return parser
|
|
27
45
|
|
|
28
46
|
|
|
29
47
|
def main() -> None:
|
|
30
48
|
args = _parser().parse_args()
|
|
49
|
+
if args.gen_pii_key is not None:
|
|
50
|
+
for _ in range(args.gen_pii_key):
|
|
51
|
+
print(base64.b64encode(secrets.token_bytes(32)).decode("ascii"))
|
|
52
|
+
return
|
|
31
53
|
try:
|
|
32
54
|
config = load_config(args.config)
|
|
33
55
|
except DomainError as exc:
|
|
@@ -5,6 +5,7 @@ import binascii
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
7
|
from collections.abc import Mapping
|
|
8
|
+
from copy import deepcopy
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Literal
|
|
10
11
|
from urllib.parse import quote
|
|
@@ -17,6 +18,7 @@ from sql_safe_mcp.errors import DomainError, ErrorCode
|
|
|
17
18
|
|
|
18
19
|
_ENV_PATTERN = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
|
|
19
20
|
_FULL_ENV_PATTERN = re.compile(r"^\$\{([A-Z_][A-Z0-9_]*)\}$")
|
|
21
|
+
_SERVER_ALIAS_PATTERN = r"[A-Za-z0-9][A-Za-z0-9._-]*"
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
class RuntimeConfig(BaseModel):
|
|
@@ -59,8 +61,8 @@ class PiiRule(BaseModel):
|
|
|
59
61
|
|
|
60
62
|
@model_validator(mode="after")
|
|
61
63
|
def validate_wildcards(self) -> PiiRule:
|
|
62
|
-
if
|
|
63
|
-
raise ValueError("wildcards are allowed only in pii rule database")
|
|
64
|
+
if self.schema_ and "*" in self.schema_:
|
|
65
|
+
raise ValueError("wildcards are allowed only in pii rule table or database")
|
|
64
66
|
if self.database != "*" and "*" in self.database:
|
|
65
67
|
raise ValueError("database must be an exact name or '*'")
|
|
66
68
|
folded = [column.casefold() for column in self.columns]
|
|
@@ -75,6 +77,24 @@ class PiiConfig(BaseModel):
|
|
|
75
77
|
rules: list[PiiRule] = Field(min_length=1)
|
|
76
78
|
|
|
77
79
|
|
|
80
|
+
class PiiConfigInput(BaseModel):
|
|
81
|
+
"""The YAML-only PII shape before shared rules are resolved."""
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(extra="forbid")
|
|
84
|
+
|
|
85
|
+
include: list[str] = Field(default_factory=list)
|
|
86
|
+
rules: list[PiiRule] = Field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PiiRuleSet(BaseModel):
|
|
90
|
+
model_config = ConfigDict(extra="forbid")
|
|
91
|
+
|
|
92
|
+
name: str | None = Field(default=None, pattern=rf"^{_SERVER_ALIAS_PATTERN}$")
|
|
93
|
+
rules: list[PiiRule] = Field(min_length=1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_PERCENT_ESCAPE = re.compile(r"%[0-9A-Fa-f]{2}")
|
|
97
|
+
|
|
78
98
|
_DRIVERNAMES = {
|
|
79
99
|
"sqlserver": "mssql+pyodbc",
|
|
80
100
|
"mysql": "mysql+pymysql",
|
|
@@ -82,16 +102,39 @@ _DRIVERNAMES = {
|
|
|
82
102
|
}
|
|
83
103
|
|
|
84
104
|
|
|
85
|
-
class
|
|
105
|
+
class ServerConfigBase(BaseModel):
|
|
86
106
|
model_config = ConfigDict(extra="forbid")
|
|
87
107
|
|
|
88
108
|
engine: Literal["sqlserver", "mysql", "mariadb"]
|
|
89
109
|
access_level: Literal["metadata", "pii_safe"] = "metadata"
|
|
90
110
|
connection_url: SecretStr
|
|
91
111
|
pii_key_env: str | None = Field(default=None, pattern=r"^[A-Z_][A-Z0-9_]*$")
|
|
92
|
-
pii: PiiConfig | None = None
|
|
93
112
|
pii_key: SecretStr | None = Field(default=None, exclude=True)
|
|
94
113
|
|
|
114
|
+
@model_validator(mode="after")
|
|
115
|
+
def validate_connection_url(self) -> ServerConfigBase:
|
|
116
|
+
url = make_url(self.connection_url.get_secret_value())
|
|
117
|
+
if url.host and _PERCENT_ESCAPE.search(url.host):
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"connection_url host contains an encoded character; a ${NAME} placeholder inside "
|
|
120
|
+
"a URL is URL-encoded, so a 'host:port' value breaks. "
|
|
121
|
+
"Use separate ${HOST} and ${PORT} variables"
|
|
122
|
+
)
|
|
123
|
+
driver = url.drivername
|
|
124
|
+
expected = _DRIVERNAMES[self.engine]
|
|
125
|
+
if driver != expected:
|
|
126
|
+
raise ValueError(f"engine {self.engine!r} requires SQLAlchemy dialect {expected!r}")
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def key_bytes(self) -> bytes | None:
|
|
130
|
+
if self.pii_key is None:
|
|
131
|
+
return None
|
|
132
|
+
return base64.b64decode(self.pii_key.get_secret_value(), validate=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ServerConfig(ServerConfigBase):
|
|
136
|
+
pii: PiiConfig | None = None
|
|
137
|
+
|
|
95
138
|
@model_validator(mode="after")
|
|
96
139
|
def validate_security_shape(self) -> ServerConfig:
|
|
97
140
|
if self.access_level == "pii_safe":
|
|
@@ -101,17 +144,11 @@ class ServerConfig(BaseModel):
|
|
|
101
144
|
raise ValueError(f"pii rules for engine {self.engine!r} cannot set schema")
|
|
102
145
|
elif self.pii_key_env is not None or self.pii is not None:
|
|
103
146
|
raise ValueError("metadata servers cannot configure pii_key_env or pii rules")
|
|
104
|
-
|
|
105
|
-
driver = make_url(self.connection_url.get_secret_value()).drivername
|
|
106
|
-
expected = _DRIVERNAMES[self.engine]
|
|
107
|
-
if driver != expected:
|
|
108
|
-
raise ValueError(f"engine {self.engine!r} requires SQLAlchemy dialect {expected!r}")
|
|
109
147
|
return self
|
|
110
148
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return base64.b64decode(self.pii_key.get_secret_value(), validate=True)
|
|
149
|
+
|
|
150
|
+
class ServerConfigInput(ServerConfigBase):
|
|
151
|
+
pii: PiiConfigInput | None = None
|
|
115
152
|
|
|
116
153
|
|
|
117
154
|
class AppConfig(BaseModel):
|
|
@@ -126,7 +163,7 @@ class AppConfig(BaseModel):
|
|
|
126
163
|
def validate_aliases_and_keys(self) -> AppConfig:
|
|
127
164
|
seen_keys: dict[bytes, str] = {}
|
|
128
165
|
for alias, server in self.servers.items():
|
|
129
|
-
if not re.fullmatch(
|
|
166
|
+
if not re.fullmatch(_SERVER_ALIAS_PATTERN, alias):
|
|
130
167
|
raise ValueError(f"invalid server alias {alias!r}")
|
|
131
168
|
key = server.key_bytes()
|
|
132
169
|
if key is None:
|
|
@@ -141,6 +178,18 @@ class AppConfig(BaseModel):
|
|
|
141
178
|
return self
|
|
142
179
|
|
|
143
180
|
|
|
181
|
+
class AppConfigInput(BaseModel):
|
|
182
|
+
"""Strict top-level YAML shape, including shared PII rule declarations."""
|
|
183
|
+
|
|
184
|
+
model_config = ConfigDict(extra="forbid")
|
|
185
|
+
|
|
186
|
+
version: Literal[1]
|
|
187
|
+
runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
|
|
188
|
+
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
189
|
+
pii_rules: list[PiiRuleSet] | None = Field(default=None, min_length=1)
|
|
190
|
+
servers: dict[str, ServerConfigInput] = Field(min_length=1)
|
|
191
|
+
|
|
192
|
+
|
|
144
193
|
def _expand_connection_url(template: str, environ: Mapping[str, str]) -> str:
|
|
145
194
|
full_match = _FULL_ENV_PATTERN.fullmatch(template)
|
|
146
195
|
if full_match:
|
|
@@ -169,6 +218,54 @@ def _validation_summary(error: ValidationError) -> str:
|
|
|
169
218
|
return "; ".join(issues)
|
|
170
219
|
|
|
171
220
|
|
|
221
|
+
def _resolve_pii_rules(raw: dict[object, object], config: AppConfigInput) -> dict[object, object]:
|
|
222
|
+
"""Flatten shared PII rules before runtime configuration is constructed."""
|
|
223
|
+
|
|
224
|
+
default_rules: list[PiiRule] = []
|
|
225
|
+
named_rules: dict[str, list[PiiRule]] = {}
|
|
226
|
+
for rule_set in config.pii_rules or []:
|
|
227
|
+
if rule_set.name is None:
|
|
228
|
+
if default_rules:
|
|
229
|
+
raise ValueError("at most one unnamed pii rule set is allowed")
|
|
230
|
+
default_rules = rule_set.rules
|
|
231
|
+
continue
|
|
232
|
+
if rule_set.name in named_rules:
|
|
233
|
+
raise ValueError(f"duplicate pii rule set name {rule_set.name!r}")
|
|
234
|
+
named_rules[rule_set.name] = rule_set.rules
|
|
235
|
+
|
|
236
|
+
resolved = deepcopy(raw)
|
|
237
|
+
resolved.pop("pii_rules", None)
|
|
238
|
+
servers = resolved["servers"]
|
|
239
|
+
assert isinstance(servers, dict)
|
|
240
|
+
for alias, server in config.servers.items():
|
|
241
|
+
server_data = servers[alias]
|
|
242
|
+
assert isinstance(server_data, dict)
|
|
243
|
+
if server.access_level == "metadata":
|
|
244
|
+
if server.pii is not None:
|
|
245
|
+
raise ValueError("metadata servers cannot configure pii_key_env or pii rules")
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
effective_rules = list(default_rules)
|
|
249
|
+
if server.pii is not None:
|
|
250
|
+
included: set[str] = set()
|
|
251
|
+
for name in server.pii.include:
|
|
252
|
+
if name not in named_rules:
|
|
253
|
+
raise ValueError(f"server {alias!r} includes unknown pii rule set {name!r}")
|
|
254
|
+
if name in included:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"server {alias!r} includes pii rule set {name!r} more than once"
|
|
257
|
+
)
|
|
258
|
+
included.add(name)
|
|
259
|
+
effective_rules.extend(named_rules[name])
|
|
260
|
+
effective_rules.extend(server.pii.rules)
|
|
261
|
+
server_data["pii"] = (
|
|
262
|
+
{"rules": [rule.model_dump(by_alias=True) for rule in effective_rules]}
|
|
263
|
+
if effective_rules
|
|
264
|
+
else None
|
|
265
|
+
)
|
|
266
|
+
return resolved
|
|
267
|
+
|
|
268
|
+
|
|
172
269
|
def load_config(path: str | Path, environ: Mapping[str, str] | None = None) -> AppConfig:
|
|
173
270
|
env = os.environ if environ is None else environ
|
|
174
271
|
try:
|
|
@@ -196,7 +293,8 @@ def load_config(path: str | Path, environ: Mapping[str, str] | None = None) -> A
|
|
|
196
293
|
if len(decoded) != 32:
|
|
197
294
|
raise ValueError(f"PII key for server {alias!r} must decode to 32 bytes")
|
|
198
295
|
value["pii_key"] = env[key_env]
|
|
199
|
-
|
|
296
|
+
input_config = AppConfigInput.model_validate(raw)
|
|
297
|
+
return AppConfig.model_validate(_resolve_pii_rules(raw, input_config))
|
|
200
298
|
except ValidationError as exc:
|
|
201
299
|
raise DomainError(
|
|
202
300
|
ErrorCode.CONFIG_ERROR,
|
|
@@ -52,7 +52,7 @@ def create_server(config: AppConfig) -> MCPServer[AppContext]:
|
|
|
52
52
|
server: MCPServer[AppContext] = MCPServer(
|
|
53
53
|
"sql-safe-mcp",
|
|
54
54
|
description="Minimal, read-only, PII-safe SQL database navigation.",
|
|
55
|
-
version="1.
|
|
55
|
+
version="1.4.0",
|
|
56
56
|
lifespan=lifespan,
|
|
57
57
|
log_level=config.logging.level,
|
|
58
58
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
+
from fnmatch import fnmatchcase
|
|
4
5
|
|
|
5
6
|
from sqlglot import exp
|
|
6
7
|
|
|
@@ -42,7 +43,9 @@ class PiiPolicy:
|
|
|
42
43
|
continue
|
|
43
44
|
if rule.schema_ is not None and rule.schema_.casefold() != schema:
|
|
44
45
|
continue
|
|
45
|
-
if rule.table.casefold()
|
|
46
|
+
if fnmatchcase(table, rule.table.casefold()) and column in {
|
|
47
|
+
item.casefold() for item in rule.columns
|
|
48
|
+
}:
|
|
46
49
|
return True
|
|
47
50
|
return False
|
|
48
51
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|