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.
Files changed (33) hide show
  1. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/CHANGELOG.md +18 -0
  2. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/PKG-INFO +94 -79
  3. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/README.md +93 -78
  4. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/pyproject.toml +1 -1
  5. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/server.json +2 -2
  6. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/sql-safe-mcp.example.yaml +16 -3
  7. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/__init__.py +1 -1
  8. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/__main__.py +23 -1
  9. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/config.py +113 -15
  10. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/mcp_server.py +1 -1
  11. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/policy.py +4 -1
  12. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/.gitignore +0 -0
  13. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/LICENSE +0 -0
  14. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/__init__.py +0 -0
  15. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/extras.py +0 -0
  16. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/mysql.py +0 -0
  17. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/reflection.py +0 -0
  18. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/registry.py +0 -0
  19. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/db/sqlserver.py +0 -0
  20. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/diagnostics.py +0 -0
  21. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/errors.py +0 -0
  22. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/models.py +0 -0
  23. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/__init__.py +0 -0
  24. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/dialect.py +0 -0
  25. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/executor.py +0 -0
  26. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/lineage.py +0 -0
  27. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/parser.py +0 -0
  28. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/pipeline.py +0 -0
  29. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/reasons.py +0 -0
  30. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/schema.py +0 -0
  31. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/tokens.py +0 -0
  32. {sql_safe_mcp-1.3.0 → sql_safe_mcp-1.4.0}/src/sql_safe_mcp/security/validated_query.py +0 -0
  33. {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.0
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 read-only, PII-safe SQL Server, MySQL and MariaDB MCP server for coding agents: schema knowledge and safe queries, with no way to change or leak data.
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
  [![CI](https://github.com/proprock/sql-safe-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/proprock/sql-safe-mcp/actions/workflows/ci.yml)
46
48
  [![Release](https://img.shields.io/github/v/release/proprock/sql-safe-mcp)](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
- General-purpose database MCP servers hand the agent a raw SQL prompt and dozens of tools. This
63
- server gives a coding agent the schema knowledge it needs to write correct code - servers,
64
- databases, tables, columns, keys, indexes, stored procedures - and, on servers you mark
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
- ## Read-only by design. PII-safe by default
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
- - [Install](#install)
120
- - [Configure](#configure)
121
- - [PII-safe queries](#pii-safe-queries)
122
- - [Security](#security)
123
- - [Contributing](#contributing)
121
+ ## Quick start
124
122
 
125
- More detail lives in [`docs/`](docs): the [configuration reference](docs/configuration.md),
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.3.0`.
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
- ## Configure
144
+ ### Configure multiple servers
150
145
 
151
- Copy [sql-safe-mcp.example.yaml](sql-safe-mcp.example.yaml) to `sql-safe-mcp.yaml`, list your
152
- servers, and keep credentials in environment variables:
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
- connection_url: >-
178
- mssql+pyodbc://${BILLING_USER}:${BILLING_PASSWORD}@${BILLING_HOST}/master
179
- ?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=yes
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
- A missing variable, or an `engine` that does not match the URL dialect, stops the server at startup.
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
- Point the server at the file with `SQL_SAFE_MCP_CONFIG` (or `--config`), and check it without
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. Every setting, including the runtime limits, is in
200
- [configuration.md](docs/configuration.md).
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 - the CLI
210
- otherwise reads the name as another `KEY=value` pair.
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
- In `claude_desktop_config.json`:
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
- Command `uvx`, argument `sql-safe-mcp`, and the environment variables your configuration
249
- references, plus `SQL_SAFE_MCP_CONFIG`. The server speaks MCP over stdio and logs only to stderr.
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
- Mark an alias `access_level: pii_safe`, give it its own key, and list the protected columns:
270
+ `execute_sql` on the `billing` alias allows one restricted `SELECT`. For example, these arguments:
256
271
 
257
- ```yaml
258
- servers:
259
- legacy_prod:
260
- engine: sqlserver
261
- access_level: pii_safe
262
- connection_url: "${LEGACY_PROD_SQL_URL}"
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
- `execute_sql` then accepts one restricted `SELECT`. Protected cells come back as tokens, and a
273
- token is accepted only in `=` and `IN` predicates on the same alias. Protection covers the columns
274
- you list, so list every column that holds personal data. The rule format, key generation, and the
275
- accepted SQL are in [configuration.md](docs/configuration.md) and [tools.md](docs/tools.md).
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; the operator, the
280
- process environment, and the database credentials are the trusted boundary. Database permissions
281
- remain the primary authorization control - this server never widens them. The full model and its
282
- verification are in [SECURITY-MODEL.md](SECURITY-MODEL.md). To report a vulnerability,
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 read-only, PII-safe SQL Server, MySQL and MariaDB MCP server for coding agents: schema knowledge and safe queries, with no way to change or leak data.
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
  [![CI](https://github.com/proprock/sql-safe-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/proprock/sql-safe-mcp/actions/workflows/ci.yml)
8
10
  [![Release](https://img.shields.io/github/v/release/proprock/sql-safe-mcp)](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
- General-purpose database MCP servers hand the agent a raw SQL prompt and dozens of tools. This
25
- server gives a coding agent the schema knowledge it needs to write correct code - servers,
26
- databases, tables, columns, keys, indexes, stored procedures - and, on servers you mark
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
- ## Read-only by design. PII-safe by default
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
- - [Install](#install)
82
- - [Configure](#configure)
83
- - [PII-safe queries](#pii-safe-queries)
84
- - [Security](#security)
85
- - [Contributing](#contributing)
83
+ ## Quick start
86
84
 
87
- More detail lives in [`docs/`](docs): the [configuration reference](docs/configuration.md),
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.3.0`.
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
- ## Configure
106
+ ### Configure multiple servers
112
107
 
113
- Copy [sql-safe-mcp.example.yaml](sql-safe-mcp.example.yaml) to `sql-safe-mcp.yaml`, list your
114
- servers, and keep credentials in environment variables:
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
- connection_url: >-
140
- mssql+pyodbc://${BILLING_USER}:${BILLING_PASSWORD}@${BILLING_HOST}/master
141
- ?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=yes
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
- A missing variable, or an `engine` that does not match the URL dialect, stops the server at startup.
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
- Point the server at the file with `SQL_SAFE_MCP_CONFIG` (or `--config`), and check it without
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. Every setting, including the runtime limits, is in
162
- [configuration.md](docs/configuration.md).
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 - the CLI
172
- otherwise reads the name as another `KEY=value` pair.
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
- In `claude_desktop_config.json`:
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
- Command `uvx`, argument `sql-safe-mcp`, and the environment variables your configuration
211
- references, plus `SQL_SAFE_MCP_CONFIG`. The server speaks MCP over stdio and logs only to stderr.
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
- Mark an alias `access_level: pii_safe`, give it its own key, and list the protected columns:
232
+ `execute_sql` on the `billing` alias allows one restricted `SELECT`. For example, these arguments:
218
233
 
219
- ```yaml
220
- servers:
221
- legacy_prod:
222
- engine: sqlserver
223
- access_level: pii_safe
224
- connection_url: "${LEGACY_PROD_SQL_URL}"
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
- `execute_sql` then accepts one restricted `SELECT`. Protected cells come back as tokens, and a
235
- token is accepted only in `=` and `IN` predicates on the same alias. Protection covers the columns
236
- you list, so list every column that holds personal data. The rule format, key generation, and the
237
- accepted SQL are in [configuration.md](docs/configuration.md) and [tools.md](docs/tools.md).
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; the operator, the
242
- process environment, and the database credentials are the trusted boundary. Database permissions
243
- remain the primary authorization control - this server never widens them. The full model and its
244
- verification are in [SECURITY-MODEL.md](SECURITY-MODEL.md). To report a vulnerability,
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-safe-mcp"
3
- version = "1.3.0"
3
+ version = "1.4.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"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/proprock/sql-safe-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.3.0",
10
+ "version": "1.4.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "sql-safe-mcp",
15
- "version": "1.3.0",
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: Users
30
- columns: [Email, FirstName, LastName]
42
+ table: Cards
43
+ columns: [HolderName]
31
44
 
32
45
  reporting-metadata:
33
46
  engine: sqlserver
@@ -1,3 +1,3 @@
1
1
  """Minimal, PII-safe MCP server for relational databases."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.4.0"
@@ -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.add_argument(
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 "*" in self.table or (self.schema_ and "*" in self.schema_):
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 ServerConfig(BaseModel):
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
- def key_bytes(self) -> bytes | None:
112
- if self.pii_key is None:
113
- return None
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(r"[A-Za-z0-9][A-Za-z0-9._-]*", alias):
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
- return AppConfig.model_validate(raw)
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.3.0",
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() == table and column in {c.casefold() for c in rule.columns}:
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