sql-safe-mcp 1.5.2__tar.gz → 2.0.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 (37) hide show
  1. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/CHANGELOG.md +61 -0
  2. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/PKG-INFO +6 -4
  3. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/README.md +3 -1
  4. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/pyproject.toml +4 -4
  5. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/server.json +2 -2
  6. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/sql-safe-mcp.example-multi.yaml +6 -2
  7. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/sql-safe-mcp.example-shared.yaml +18 -0
  8. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/__init__.py +1 -1
  9. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/__main__.py +21 -1
  10. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/config.py +215 -31
  11. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/diagnostics.py +18 -2
  12. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/mcp_server.py +8 -1
  13. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/models.py +3 -1
  14. sql_safe_mcp-2.0.0/src/sql_safe_mcp/pii_audit.py +200 -0
  15. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/executor.py +15 -2
  16. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/lineage.py +17 -12
  17. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/parser.py +42 -1
  18. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/pipeline.py +1 -0
  19. sql_safe_mcp-2.0.0/src/sql_safe_mcp/security/policy.py +147 -0
  20. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/reasons.py +3 -0
  21. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/schema.py +51 -5
  22. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/tokens.py +102 -54
  23. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/validated_query.py +43 -3
  24. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/service.py +67 -3
  25. sql_safe_mcp-1.5.2/src/sql_safe_mcp/security/policy.py +0 -104
  26. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/.gitignore +0 -0
  27. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/LICENSE +0 -0
  28. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/sql-safe-mcp.example-simple.yaml +0 -0
  29. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/__init__.py +0 -0
  30. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/extras.py +0 -0
  31. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/mysql.py +0 -0
  32. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/reflection.py +0 -0
  33. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/registry.py +0 -0
  34. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/sqlserver.py +0 -0
  35. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/errors.py +0 -0
  36. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/__init__.py +0 -0
  37. {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/dialect.py +0 -0
@@ -7,6 +7,67 @@ categories `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security`.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.0] - 2026-11-07
11
+
12
+ ### Added
13
+
14
+ - `sql-safe-mcp --verify-pii-rules [--strict]` connects to each `all_pii_safe` alias, reports how
15
+ many tables and columns every PII rule matches, and warns about rules that match nothing
16
+ (exit 2 with `--strict`) and about unprotected columns whose names look personal.
17
+ - `logging.audit: true` writes one `sql_safe_mcp.audit` line per `execute_sql` call with the server,
18
+ database, tables and columns read, protected column count, row count, truncation, and elapsed
19
+ time (or the error `code`). It never contains SQL, bind values, rows, or tokens. Off by default;
20
+ the tool response is unchanged.
21
+ - `--verify-pii-rules` also checks `pii.exposed_columns` and `pii.deny` entries (unmatched ones
22
+ warn and fail `--strict`) and, in `deny-by-default`, advises only on exposed columns.
23
+ - `pii.mode: deny-by-default` protects every column not listed in `pii.exposed_columns`, and
24
+ `pii.deny` rejects any reference to a column with `QUERY_REJECTED` ("a denied column cannot be
25
+ referenced"). Existing configurations are unchanged. See `docs/configuration.md`.
26
+ - `runtime.token_ttl_seconds` (off by default) rejects tokens older than the limit with
27
+ `INVALID_PII_TOKEN`. Enabling it also rejects tokens issued before it was set. The token format
28
+ stays `pii:v1:`.
29
+ - `runtime.min_count_cell` (off by default) makes `execute_sql` return a `COUNT(*)` value from 1
30
+ to the threshold minus one as `null`, with the new column encoding `suppressed`. `0` is not
31
+ suppressed.
32
+ - `pii_key_file` reads an alias key from a file instead of `pii_key_env`; a relative path is
33
+ resolved against the configuration file's directory. On POSIX the file must be owned by the
34
+ server user or root, with no access for others and no group write.
35
+
36
+ ### Security
37
+
38
+ - With `logging.level: DEBUG`, the MCP SDK no longer writes a malformed protocol message to
39
+ stderr; its logger is held at `INFO` or above, because that message could carry SQL, tokens,
40
+ or rows.
41
+ - `all_pii_safe` aliases now require verified TLS in `connection_url`: SQL Server needs
42
+ `Encrypt=yes` and rejects `TrustServerCertificate=yes` and `odbc_connect`; MySQL and MariaDB
43
+ need `ssl_ca` or `ssl_capath` and reject options that turn certificate checks off. A
44
+ configuration without them fails at startup with `CONFIG_ERROR`. `require_tls: false` on the
45
+ alias turns the check off and logs a warning at every start.
46
+ - After loading the configuration, the server removes `pii_key_env` variables from its environment,
47
+ so child processes such as ODBC drivers no longer inherit PII keys.
48
+ - `pii.rules` entries accept an optional `domain`. A token returned for a column of one domain is
49
+ rejected with `INVALID_PII_TOKEN` when compared with a column of another domain, so protected
50
+ values cannot be replayed across unrelated columns. Without `domain` the domain is the server
51
+ alias. Rules of different domains matching one column reject queries touching it with
52
+ `QUERY_REJECTED`.
53
+ - PII tokens now pad their payload to a multiple of 32 bytes, so token length reveals only a
54
+ 32-byte bucket of the plaintext length.
55
+ - Tokens issued before padding and domains are rejected with `INVALID_PII_TOKEN`; agents must
56
+ request new tokens.
57
+ - `execute_sql` now allows each SQL construct only in the positions the supported subset uses.
58
+ A bare column or literal used as a condition (`WHERE a`, `ON 1`, `WHERE a AND b = 1`) and any
59
+ other node in an unlisted position is rejected with `QUERY_REJECTED` before the database runs
60
+ the query.
61
+ - `execute_sql` now checks a PII token against the type of the protected column it is compared
62
+ with. A string token compared with an integer or decimal column, or any token compared with a
63
+ column of an unrecognised type, is rejected with `QUERY_REJECTED` before the database runs the
64
+ query. Before, SQL Server could echo the decrypted value in a conversion error (for example
65
+ error 245).
66
+ - Driver error messages in logs no longer carry values. Quoted literals (`'…'`, `N'…'`, `"…"`)
67
+ become `'***'` and digit runs of six or more become `***`, since SQL Server and MySQL echo the
68
+ offending value in conversion and duplicate-key errors. A failed `execute_sql` logs only the
69
+ driver error class and its SQLSTATE or numeric code, with no message text.
70
+
10
71
  ## [1.5.2] - 2026-10-05
11
72
 
12
73
  ### Security
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sql-safe-mcp
3
- Version: 1.5.2
3
+ Version: 2.0.0
4
4
  Summary: Minimal, read-only, PII-safe MCP server for SQL databases.
5
5
  License: MIT License
6
6
 
@@ -26,10 +26,10 @@ License: MIT License
26
26
  License-File: LICENSE
27
27
  Requires-Python: <3.15,>=3.12
28
28
  Requires-Dist: anyio<5,>=4.8
29
- Requires-Dist: cryptography<47,>=46.0.7
29
+ Requires-Dist: cryptography<51,>=48.0.1
30
30
  Requires-Dist: mcp<3,>=2
31
31
  Requires-Dist: pydantic<3,>=2.10
32
- Requires-Dist: pymysql>=1.2.3
32
+ Requires-Dist: pymysql<2,>=1.2.3
33
33
  Requires-Dist: pyodbc<6,>=5.2
34
34
  Requires-Dist: pyyaml<7,>=6
35
35
  Requires-Dist: sqlalchemy<3,>=2.0
@@ -133,7 +133,7 @@ or
133
133
  pip install sql-safe-mcp
134
134
  ```
135
135
 
136
- Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.5.2`.
136
+ Pin a version when you want a fixed surface: `uvx sql-safe-mcp==2.0.0`.
137
137
 
138
138
  Requires Python 3.12–3.14, [uv](https://docs.astral.sh/uv/) (or `pip`), and
139
139
  [Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)
@@ -278,6 +278,8 @@ Protection covers the columns you list, so list every column that holds personal
278
278
  limits, and logging.
279
279
  - [Tool reference](docs/tools.md) - arguments, responses, errors, and the accepted SQL subset.
280
280
  - [Security model](docs/security-model.md) - trust boundaries, guarantees, limitations, and verification.
281
+ - [Health data of minors](docs/health-data.md) - requirements, mechanisms, and an operator
282
+ checklist for medical deployments.
281
283
  - [Checks](docs/checks.md) - local, integration, and security verification commands.
282
284
 
283
285
  ## Security
@@ -95,7 +95,7 @@ or
95
95
  pip install sql-safe-mcp
96
96
  ```
97
97
 
98
- Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.5.2`.
98
+ Pin a version when you want a fixed surface: `uvx sql-safe-mcp==2.0.0`.
99
99
 
100
100
  Requires Python 3.12–3.14, [uv](https://docs.astral.sh/uv/) (or `pip`), and
101
101
  [Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)
@@ -240,6 +240,8 @@ Protection covers the columns you list, so list every column that holds personal
240
240
  limits, and logging.
241
241
  - [Tool reference](docs/tools.md) - arguments, responses, errors, and the accepted SQL subset.
242
242
  - [Security model](docs/security-model.md) - trust boundaries, guarantees, limitations, and verification.
243
+ - [Health data of minors](docs/health-data.md) - requirements, mechanisms, and an operator
244
+ checklist for medical deployments.
243
245
  - [Checks](docs/checks.md) - local, integration, and security verification commands.
244
246
 
245
247
  ## Security
@@ -1,16 +1,16 @@
1
1
  [project]
2
2
  name = "sql-safe-mcp"
3
- version = "1.5.2"
3
+ version = "2.0.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
7
  license = { file = "LICENSE" }
8
8
  dependencies = [
9
9
  "anyio>=4.8,<5",
10
- "cryptography>=46.0.7,<47",
10
+ "cryptography>=48.0.1,<51",
11
11
  "mcp>=2,<3",
12
12
  "pydantic>=2.10,<3",
13
- "pymysql>=1.2.3",
13
+ "pymysql>=1.2.3,<2",
14
14
  "pyodbc>=5.2,<6",
15
15
  "pyyaml>=6,<7",
16
16
  "sqlalchemy>=2.0,<3",
@@ -63,7 +63,7 @@ markers = [
63
63
  [tool.mutmut]
64
64
  source_paths = ["src/sql_safe_mcp"]
65
65
  also_copy = ["server.json", "README.md", "tools", "scripts", "compose.sqlserver.yml", "compose.mysql.yml"]
66
- only_mutate = ["*security/*.py", "*config.py"]
66
+ only_mutate = ["*security/*.py", "*config.py", "*diagnostics.py", "*service.py"]
67
67
  mutate_only_covered_lines = true
68
68
  pytest_add_cli_args = ["-q", "-m", "not integration and not deep", "-p", "no:cacheprovider"]
69
69
  pytest_add_cli_args_test_selection = ["tests/unit", "tests/security", "tests/contract"]
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/proprock/sql-safe-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.5.2",
10
+ "version": "2.0.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "sql-safe-mcp",
15
- "version": "1.5.2",
15
+ "version": "2.0.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -19,11 +19,14 @@ servers:
19
19
  billing:
20
20
  # `sqlserver` uses an mssql+pyodbc URL; the URL dialect must match this engine.
21
21
  engine: sqlserver
22
- # all_pii_safe requires both `pii_key_env` and at least one effective local PII rule.
22
+ # all_pii_safe requires a key (`pii_key_env` or `pii_key_file`) and at least one effective PII rule.
23
23
  access_level: all_pii_safe
24
+ # all_pii_safe requires verified TLS: the URL must set Encrypt=yes and must not set
25
+ # TrustServerCertificate=yes. `require_tls: false` turns the check off for a dev server.
24
26
  connection_url: "${BILLING_SQL_URL}"
25
27
  # Environment-variable name for one base64-encoded 32-byte key. Names must match
26
- # ^[A-Z_][A-Z0-9_]*$; each all_pii_safe alias needs a different key.
28
+ # ^[A-Z_][A-Z0-9_]*$; each all_pii_safe alias needs a different key. Instead of
29
+ # pii_key_env you may set `pii_key_file: secrets/billing.key` (relative to this file).
27
30
  pii_key_env: BILLING_PII_KEY
28
31
  pii:
29
32
  # Only local rules are used in this file; `include` is intentionally absent.
@@ -42,6 +45,7 @@ servers:
42
45
  # mysql and mariadb both use mysql+pymysql URLs. Select the engine matching the server.
43
46
  engine: mysql
44
47
  access_level: all_pii_safe
48
+ # Verified TLS for MySQL/MariaDB: the URL must set ssl_ca (or ssl_capath).
45
49
  connection_url: "${SHOP_MYSQL_URL}"
46
50
  pii_key_env: SHOP_PII_KEY
47
51
  pii:
@@ -38,6 +38,9 @@ pii_rules:
38
38
  schema: dbo # Optional for SQL Server; omit it to match every schema.
39
39
  table: "Users[0-9]?"
40
40
  columns: [Email, FirstName, LastName]
41
+ # Optional token domain (alias character set). Tokens of one domain are accepted only on
42
+ # columns of that domain; without it the domain is the server alias.
43
+ domain: users_identity
41
44
 
42
45
  servers:
43
46
  legacy-prod:
@@ -65,6 +68,21 @@ servers:
65
68
  table: Cards
66
69
  columns: [HolderName]
67
70
 
71
+ peds-deny-by-default:
72
+ engine: sqlserver
73
+ access_level: all_pii_safe
74
+ connection_url: "${PEDS_SQL_URL}"
75
+ pii_key_env: PEDS_PII_KEY
76
+ pii:
77
+ # `deny-by-default` protects every column that exposed_columns does not list.
78
+ mode: deny-by-default
79
+ exposed_columns: # `database` is optional here and defaults to "*".
80
+ - table: Visits
81
+ columns: [VisitId, DepartmentCode, VisitYear]
82
+ deny: # Unreachable even as a token; columns: ["*"] denies a whole table.
83
+ - table: "*"
84
+ columns: [Notes, Anamnesis, Attachment]
85
+
68
86
  reporting-metadata:
69
87
  engine: sqlserver
70
88
  access_level: metadata
@@ -1,3 +1,3 @@
1
1
  """Minimal, PII-safe MCP server for relational databases."""
2
2
 
3
- __version__ = "1.5.2"
3
+ __version__ = "2.0.0"
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  from sql_safe_mcp.config import load_config
11
11
  from sql_safe_mcp.errors import DomainError
12
12
  from sql_safe_mcp.mcp_server import create_server
13
+ from sql_safe_mcp.pii_audit import collect_catalog, verify_pii_rules
13
14
 
14
15
 
15
16
  def _positive_int(value: str) -> int:
@@ -41,11 +42,24 @@ def _parser() -> argparse.ArgumentParser:
41
42
  metavar="N",
42
43
  help="Print N base64-encoded 32-byte PII keys (default: 1) and exit.",
43
44
  )
45
+ actions.add_argument(
46
+ "--verify-pii-rules",
47
+ action="store_true",
48
+ help="Connect to each all_pii_safe alias and report PII rules that match nothing.",
49
+ )
50
+ parser.add_argument(
51
+ "--strict",
52
+ action="store_true",
53
+ help="With --verify-pii-rules, exit 2 when any rule matches nothing.",
54
+ )
44
55
  return parser
45
56
 
46
57
 
47
58
  def main() -> None:
48
- args = _parser().parse_args()
59
+ parser = _parser()
60
+ args = parser.parse_args()
61
+ if args.strict and not args.verify_pii_rules:
62
+ parser.error("--strict requires --verify-pii-rules")
49
63
  if args.gen_pii_key is not None:
50
64
  for _ in range(args.gen_pii_key):
51
65
  print(base64.b64encode(secrets.token_bytes(32)).decode("ascii"))
@@ -58,6 +72,12 @@ def main() -> None:
58
72
  if args.check_config:
59
73
  print(f"Configuration valid: {len(config.servers)} server alias(es).")
60
74
  return
75
+ if args.verify_pii_rules:
76
+ report = verify_pii_rules(config, collect_catalog)
77
+ print("\n".join(report.lines), file=sys.stderr)
78
+ if report.errors or (args.strict and report.unmatched_rules):
79
+ raise SystemExit(2)
80
+ return
61
81
  create_server(config).run("stdio")
62
82
 
63
83
 
@@ -4,6 +4,8 @@ import base64
4
4
  import binascii
5
5
  import os
6
6
  import re
7
+ import stat
8
+ import sys
7
9
  from collections.abc import Mapping
8
10
  from copy import deepcopy
9
11
  from pathlib import Path
@@ -32,6 +34,10 @@ class RuntimeConfig(BaseModel):
32
34
  pool_timeout_seconds: int = Field(default=10, ge=1, le=300)
33
35
  statement_timeout_seconds: int = Field(default=30, ge=1, le=3600)
34
36
  max_definition_chars: int = Field(default=262_144, ge=1024, le=10_000_000)
37
+ # None keeps tokens valid until the key changes; otherwise a token expires this many whole
38
+ # seconds after it was issued.
39
+ token_ttl_seconds: int | None = Field(default=None, ge=1, le=315_360_000, strict=True)
40
+ min_count_cell: int = Field(default=0, ge=0, le=1000, strict=True)
35
41
  default_max_rows: int = Field(default=200, ge=1, le=100_000)
36
42
  hard_max_rows: int = Field(default=1000, ge=1, le=100_000)
37
43
  max_sql_chars: int = Field(default=65_536, ge=256, le=1_000_000)
@@ -50,6 +56,7 @@ class LoggingConfig(BaseModel):
50
56
  model_config = ConfigDict(extra="forbid")
51
57
 
52
58
  level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
59
+ audit: bool = False
53
60
 
54
61
 
55
62
  class PiiRule(BaseModel):
@@ -59,6 +66,7 @@ class PiiRule(BaseModel):
59
66
  schema_: str | None = Field(default=None, alias="schema")
60
67
  table: str = Field(min_length=1)
61
68
  columns: list[str] = Field(min_length=1)
69
+ domain: str | None = Field(default=None, pattern=rf"^{_SERVER_ALIAS_PATTERN}$")
62
70
 
63
71
  @model_validator(mode="after")
64
72
  def validate_wildcards(self) -> PiiRule:
@@ -72,10 +80,41 @@ class PiiRule(BaseModel):
72
80
  return self
73
81
 
74
82
 
83
+ class PiiScopeRule(PiiRule):
84
+ """An `exposed_columns` or `deny` entry; the database defaults to any."""
85
+
86
+ database: str = Field(default="*", min_length=1)
87
+
88
+ @model_validator(mode="after")
89
+ def validate_no_domain(self) -> PiiScopeRule:
90
+ if self.domain is not None:
91
+ raise ValueError("domain is allowed only on pii rules")
92
+ return self
93
+
94
+
95
+ def _check_column_wildcards(
96
+ rules: list[PiiRule], exposed: list[PiiScopeRule], deny: list[PiiScopeRule]
97
+ ) -> None:
98
+ if any("*" in column for rule in [*rules, *exposed] for column in rule.columns):
99
+ raise ValueError("pii rules and exposed_columns cannot list '*' columns; only deny can")
100
+ if any("*" in column and rule.columns != ["*"] for rule in deny for column in rule.columns):
101
+ raise ValueError("a deny column wildcard must be exactly ['*']")
102
+
103
+
75
104
  class PiiConfig(BaseModel):
76
105
  model_config = ConfigDict(extra="forbid")
77
106
 
78
- rules: list[PiiRule] = Field(min_length=1)
107
+ mode: Literal["protect", "deny-by-default"] = "protect"
108
+ rules: list[PiiRule] = Field(default_factory=list)
109
+ exposed_columns: list[PiiScopeRule] = Field(default_factory=list)
110
+ deny: list[PiiScopeRule] = Field(default_factory=list)
111
+
112
+ @model_validator(mode="after")
113
+ def validate_shape(self) -> PiiConfig:
114
+ _check_column_wildcards(self.rules, self.exposed_columns, self.deny)
115
+ if self.mode == "protect" and not (self.rules or self.deny):
116
+ raise ValueError("pii requires at least one rule or deny entry")
117
+ return self
79
118
 
80
119
 
81
120
  class PiiConfigInput(BaseModel):
@@ -83,15 +122,26 @@ class PiiConfigInput(BaseModel):
83
122
 
84
123
  model_config = ConfigDict(extra="forbid")
85
124
 
125
+ mode: Literal["protect", "deny-by-default"] = "protect"
86
126
  include: list[str] = Field(default_factory=list)
87
127
  rules: list[PiiRule] = Field(default_factory=list)
128
+ exposed_columns: list[PiiScopeRule] = Field(default_factory=list)
129
+ deny: list[PiiScopeRule] = Field(default_factory=list)
88
130
 
89
131
 
90
132
  class PiiRuleSet(BaseModel):
91
133
  model_config = ConfigDict(extra="forbid")
92
134
 
93
135
  name: str | None = Field(default=None, pattern=rf"^{_SERVER_ALIAS_PATTERN}$")
94
- rules: list[PiiRule] = Field(min_length=1)
136
+ rules: list[PiiRule] = Field(default_factory=list)
137
+ exposed_columns: list[PiiScopeRule] = Field(default_factory=list)
138
+ deny: list[PiiScopeRule] = Field(default_factory=list)
139
+
140
+ @model_validator(mode="after")
141
+ def validate_not_empty(self) -> PiiRuleSet:
142
+ if not (self.rules or self.exposed_columns or self.deny):
143
+ raise ValueError("a pii rule set needs rules, exposed_columns or deny")
144
+ return self
95
145
 
96
146
 
97
147
  _PERCENT_ESCAPE = re.compile(r"%[0-9A-Fa-f]{2}")
@@ -110,7 +160,9 @@ class ServerConfigBase(BaseModel):
110
160
  access_level: Literal["metadata", "meta_and_code", "all_pii_safe"] = "metadata"
111
161
  connection_url: SecretStr
112
162
  pii_key_env: str | None = Field(default=None, pattern=r"^[A-Z_][A-Z0-9_]*$")
163
+ pii_key_file: str | None = Field(default=None, min_length=1)
113
164
  pii_key: SecretStr | None = Field(default=None, exclude=True)
165
+ require_tls: bool = Field(default=True, strict=True)
114
166
 
115
167
  @model_validator(mode="after")
116
168
  def validate_connection_url(self) -> ServerConfigBase:
@@ -136,20 +188,84 @@ class ServerConfigBase(BaseModel):
136
188
  return base64.b64decode(self.pii_key.get_secret_value(), validate=True)
137
189
 
138
190
 
191
+ _TLS_ERROR = "all_pii_safe servers require verified TLS: "
192
+ # Allowlists: only these values prove an encrypted connection with a checked certificate.
193
+ _MSSQL_ENCRYPT_ON = frozenset({"yes", "true", "mandatory", "strict"})
194
+ _MSSQL_TRUST_OFF = frozenset({"no", "false"})
195
+ # PyMySQL options that replace or weaken the certificate checks SQLAlchemy builds from ssl_ca.
196
+ _MYSQL_TLS_OVERRIDES = ("ssl_verify_cert", "ssl_verify_identity", "ssl_disabled")
197
+
198
+
199
+ def _tls_options(url: str) -> dict[str, str]:
200
+ """Casefolded query options; a repeated option (in any case) is refused."""
201
+ options: dict[str, str] = {}
202
+ for name, value in make_url(url).query.items():
203
+ key = name.casefold()
204
+ if key in options or not isinstance(value, str):
205
+ raise ValueError(f"{_TLS_ERROR}connection_url sets {key!r} more than once")
206
+ options[key] = value
207
+ return options
208
+
209
+
210
+ def _weakened(option: str) -> ValueError:
211
+ return ValueError(
212
+ f"{_TLS_ERROR}connection_url option {option!r} disables certificate checks; "
213
+ "remove it, or set require_tls: false"
214
+ )
215
+
216
+
217
+ def _check_verified_tls(engine: str, url: str) -> None:
218
+ options = _tls_options(url)
219
+ if engine == "sqlserver":
220
+ if "odbc_connect" in options:
221
+ raise ValueError(
222
+ f"{_TLS_ERROR}odbc_connect cannot be checked; use URL query options, "
223
+ "or set require_tls: false"
224
+ )
225
+ if options.get("encrypt", "").casefold() not in _MSSQL_ENCRYPT_ON:
226
+ raise ValueError(
227
+ f"{_TLS_ERROR}set Encrypt=yes in connection_url, or require_tls: false"
228
+ )
229
+ trust = options.get("trustservercertificate")
230
+ if trust is not None and trust.casefold() not in _MSSQL_TRUST_OFF:
231
+ raise _weakened("trustservercertificate")
232
+ return
233
+ if not options.get("ssl_ca") and not options.get("ssl_capath"):
234
+ raise ValueError(
235
+ f"{_TLS_ERROR}set ssl_ca or ssl_capath in connection_url, or require_tls: false"
236
+ )
237
+ for option in _MYSQL_TLS_OVERRIDES:
238
+ if option in options:
239
+ raise _weakened(option)
240
+ hostname = options.get("ssl_check_hostname")
241
+ if hostname is not None and hostname.casefold() != "true":
242
+ raise _weakened("ssl_check_hostname")
243
+
244
+
139
245
  class ServerConfig(ServerConfigBase):
140
246
  pii: PiiConfig | None = None
141
247
 
142
248
  @model_validator(mode="after")
143
249
  def validate_security_shape(self) -> ServerConfig:
144
250
  if self.access_level == "all_pii_safe":
145
- if not self.pii_key_env or self.pii is None:
146
- raise ValueError("all_pii_safe servers require pii_key_env and pii rules")
147
- if self.engine != "sqlserver" and any(rule.schema_ for rule in self.pii.rules):
251
+ if self.pii_key_env and self.pii_key_file:
252
+ raise ValueError("set either pii_key_env or pii_key_file, not both")
253
+ if not (self.pii_key_env or self.pii_key_file) or self.pii is None:
254
+ raise ValueError(
255
+ "all_pii_safe servers require pii_key_env or pii_key_file, and pii rules"
256
+ )
257
+ scoped = [*self.pii.rules, *self.pii.exposed_columns, *self.pii.deny]
258
+ if self.engine != "sqlserver" and any(rule.schema_ for rule in scoped):
148
259
  raise ValueError(f"pii rules for engine {self.engine!r} cannot set schema")
149
- elif self.pii_key_env is not None or self.pii is not None:
260
+ if self.require_tls:
261
+ _check_verified_tls(self.engine, self.connection_url.get_secret_value())
262
+ elif self.pii_key_env is not None or self.pii_key_file is not None or self.pii is not None:
150
263
  raise ValueError(
151
- f"{self.access_level} servers cannot configure pii_key_env or pii rules"
264
+ f"{self.access_level} servers cannot configure "
265
+ "pii_key_env, pii_key_file or pii rules"
152
266
  )
267
+ elif "require_tls" in self.model_fields_set:
268
+ raise ValueError(f"{self.access_level} servers cannot configure require_tls")
153
269
  return self
154
270
 
155
271
 
@@ -227,17 +343,17 @@ def _validation_summary(error: ValidationError) -> str:
227
343
  def _resolve_pii_rules(raw: dict[object, object], config: AppConfigInput) -> dict[object, object]:
228
344
  """Flatten shared PII rules before runtime configuration is constructed."""
229
345
 
230
- default_rules: list[PiiRule] = []
231
- named_rules: dict[str, list[PiiRule]] = {}
346
+ default_set: PiiRuleSet | None = None
347
+ named_sets: dict[str, PiiRuleSet] = {}
232
348
  for rule_set in config.pii_rules or []:
233
349
  if rule_set.name is None:
234
- if default_rules:
350
+ if default_set is not None:
235
351
  raise ValueError("at most one unnamed pii rule set is allowed")
236
- default_rules = rule_set.rules
352
+ default_set = rule_set
237
353
  continue
238
- if rule_set.name in named_rules:
354
+ if rule_set.name in named_sets:
239
355
  raise ValueError(f"duplicate pii rule set name {rule_set.name!r}")
240
- named_rules[rule_set.name] = rule_set.rules
356
+ named_sets[rule_set.name] = rule_set
241
357
 
242
358
  resolved = deepcopy(raw)
243
359
  resolved.pop("pii_rules", None)
@@ -249,31 +365,94 @@ def _resolve_pii_rules(raw: dict[object, object], config: AppConfigInput) -> dic
249
365
  if server.access_level != "all_pii_safe":
250
366
  if server.pii is not None:
251
367
  raise ValueError(
252
- f"{server.access_level} servers cannot configure pii_key_env or pii rules"
368
+ f"{server.access_level} servers cannot configure "
369
+ "pii_key_env, pii_key_file or pii rules"
253
370
  )
254
371
  continue
255
372
 
256
- effective_rules = list(default_rules)
257
- if server.pii is not None:
373
+ sets: list[PiiRuleSet | PiiConfigInput] = [default_set] if default_set is not None else []
374
+ mode = "protect"
375
+ local = server.pii
376
+ if local is not None:
377
+ mode = local.mode
258
378
  included: set[str] = set()
259
- for name in server.pii.include:
260
- if name not in named_rules:
379
+ for name in local.include:
380
+ if name not in named_sets:
261
381
  raise ValueError(f"server {alias!r} includes unknown pii rule set {name!r}")
262
382
  if name in included:
263
383
  raise ValueError(
264
384
  f"server {alias!r} includes pii rule set {name!r} more than once"
265
385
  )
266
386
  included.add(name)
267
- effective_rules.extend(named_rules[name])
268
- effective_rules.extend(server.pii.rules)
387
+ sets.append(named_sets[name])
388
+ sets.append(local)
389
+ merged = {
390
+ field: [
391
+ rule.model_dump(by_alias=True) for item in sets for rule in getattr(item, field)
392
+ ]
393
+ for field in ("rules", "exposed_columns", "deny")
394
+ }
269
395
  server_data["pii"] = (
270
- {"rules": [rule.model_dump(by_alias=True) for rule in effective_rules]}
271
- if effective_rules
272
- else None
396
+ {"mode": mode, **merged} if any(merged.values()) or mode != "protect" else None
273
397
  )
274
398
  return resolved
275
399
 
276
400
 
401
+ _KEY_FILE_LIMIT = 1024
402
+ # Group write/execute and every "other" bit: a key file may be shared with the group read-only.
403
+ _KEY_FILE_FORBIDDEN_BITS = 0o037
404
+
405
+
406
+ def _decode_key(alias: object, text: str) -> str:
407
+ try:
408
+ decoded = base64.b64decode(text, validate=True)
409
+ except (binascii.Error, ValueError) as exc:
410
+ raise ValueError(f"PII key for server {alias!r} is not valid base64") from exc
411
+ if len(decoded) != 32:
412
+ raise ValueError(f"PII key for server {alias!r} must decode to 32 bytes")
413
+ return text
414
+
415
+
416
+ def _check_key_file_owner(alias: object, info: os.stat_result) -> None:
417
+ if sys.platform != "win32":
418
+ if info.st_uid not in (0, os.geteuid()):
419
+ raise ValueError(
420
+ f"PII key file for server {alias!r} must be owned by the server user or root"
421
+ )
422
+ if stat.S_IMODE(info.st_mode) & _KEY_FILE_FORBIDDEN_BITS:
423
+ raise ValueError(
424
+ f"PII key file for server {alias!r} must not be writable by group or accessible "
425
+ "by others (use mode 0600, 0640 or 0440)"
426
+ )
427
+
428
+
429
+ def _read_key_file(alias: object, path: Path) -> str:
430
+ """Read a base64 key file; permissions are checked on the opened descriptor."""
431
+ try:
432
+ if sys.platform == "win32":
433
+ descriptor = os.open(path, os.O_RDONLY)
434
+ else:
435
+ # O_NONBLOCK keeps a FIFO from blocking startup; it is rejected as non-regular below.
436
+ descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
437
+ try:
438
+ info = os.fstat(descriptor)
439
+ if not stat.S_ISREG(info.st_mode):
440
+ raise ValueError(f"PII key file for server {alias!r} is not a regular file")
441
+ _check_key_file_owner(alias, info)
442
+ data = os.read(descriptor, _KEY_FILE_LIMIT + 1)
443
+ finally:
444
+ os.close(descriptor)
445
+ except OSError as exc:
446
+ raise ValueError(f"PII key file for server {alias!r} cannot be read") from exc
447
+ if len(data) > _KEY_FILE_LIMIT:
448
+ raise ValueError(f"PII key file for server {alias!r} is larger than 1024 bytes")
449
+ try:
450
+ text = data.decode("ascii").strip()
451
+ except UnicodeDecodeError as exc:
452
+ raise ValueError(f"PII key for server {alias!r} is not valid base64") from exc
453
+ return _decode_key(alias, text)
454
+
455
+
277
456
  def load_config(path: str | Path, environ: Mapping[str, str] | None = None) -> AppConfig:
278
457
  env = os.environ if environ is None else environ
279
458
  try:
@@ -291,18 +470,23 @@ def load_config(path: str | Path, environ: Mapping[str, str] | None = None) -> A
291
470
  raise ValueError(f"server {alias!r} requires connection_url")
292
471
  value["connection_url"] = _expand_connection_url(url, env)
293
472
  key_env = value.get("pii_key_env")
473
+ key_file = value.get("pii_key_file")
294
474
  if key_env is not None:
295
475
  if not isinstance(key_env, str) or key_env not in env:
296
476
  raise ValueError(f"missing PII key environment variable {key_env!r}")
297
- try:
298
- decoded = base64.b64decode(env[key_env], validate=True)
299
- except (binascii.Error, ValueError) as exc:
300
- raise ValueError(f"PII key for server {alias!r} is not valid base64") from exc
301
- if len(decoded) != 32:
302
- raise ValueError(f"PII key for server {alias!r} must decode to 32 bytes")
303
- value["pii_key"] = env[key_env]
477
+ value["pii_key"] = _decode_key(alias, env[key_env])
478
+ elif isinstance(key_file, str) and key_file:
479
+ # Relative to the YAML file, never to the working directory of the MCP host.
480
+ value["pii_key"] = _read_key_file(alias, Path(path).parent / key_file)
304
481
  input_config = AppConfigInput.model_validate(raw)
305
- return AppConfig.model_validate(_resolve_pii_rules(raw, input_config))
482
+ config = AppConfig.model_validate(_resolve_pii_rules(raw, input_config))
483
+ if environ is None:
484
+ # Child processes (ODBC drivers, shells) must not inherit the keys.
485
+ for server in config.servers.values():
486
+ if server.pii_key_env is not None:
487
+ # Present: loading read it, and aliases cannot share a key variable.
488
+ del os.environ[server.pii_key_env]
489
+ return config
306
490
  except ValidationError as exc:
307
491
  raise DomainError(
308
492
  ErrorCode.CONFIG_ERROR,