sql-mini-mcp 0.9.1__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.
@@ -0,0 +1,19 @@
1
+ /.serena/
2
+ /*.agents.md
3
+ /.coverage
4
+ /.coverage.*
5
+ /coverage.xml
6
+ /htmlcov/
7
+ .venv/
8
+ __pycache__/
9
+ *.egg-info/
10
+ /dist/
11
+ /.pytest_cache/
12
+ /.ruff_cache/
13
+ .env
14
+ .env.*
15
+ /.hypothesis/
16
+ /.mutmut-cache
17
+ /mutants/
18
+ /.cbm-cache/
19
+ /.cbm-runtime/
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable externally observable changes to this project are documented in this file.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses the
6
+ categories `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security`.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.9.1] - 2026-09-20
11
+
12
+ ### Added
13
+
14
+ - Publishing of release-tag packages to PyPI and their metadata to the MCP Registry.
15
+
16
+ ### Fixed
17
+
18
+ - Source distributions now include only release files, excluding local development caches.
19
+
20
+ ## [0.9.0] - 2026-09-20
21
+
22
+ ### Added
23
+
24
+ - Read-only SQL Server metadata tools for configured server aliases, databases, tables, and
25
+ stored procedures.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anton Padapryhara
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.5
2
+ Name: sql-mini-mcp
3
+ Version: 0.9.1
4
+ Summary: Minimal, read-only, PII-safe MCP server for SQL databases.
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Anton Padapryhara
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+ License-File: LICENSE
27
+ Requires-Python: <3.15,>=3.12
28
+ Requires-Dist: anyio<5,>=4.8
29
+ Requires-Dist: mcp<3,>=2
30
+ Requires-Dist: pydantic<3,>=2.10
31
+ Requires-Dist: pyodbc<6,>=5.2
32
+ Requires-Dist: pyyaml<7,>=6
33
+ Requires-Dist: sqlalchemy<3,>=2.0
34
+ Description-Content-Type: text/markdown
35
+
36
+ # sql-mini-mcp
37
+
38
+ <!-- mcp-name: io.github.proprock/sql-mini-mcp -->
39
+
40
+ Compact MCP server for SQL Server metadata. The current Milestone 1 build supports only SQL
41
+ Server and is metadata-only; PII-safe `execute_sql` is developed separately in Milestone 2.
42
+
43
+ ## Tools
44
+
45
+ - `list_servers`
46
+ - `list_databases`
47
+ - `list_tables`
48
+ - `get_table_definition`
49
+ - `list_stored_procedures`
50
+ - `get_stored_procedure`
51
+
52
+ The server does not discover network servers, expose a full catalog as resources, use an ORM, or
53
+ execute caller-provided SQL.
54
+
55
+ ## Install and run
56
+
57
+ Python 3.12–3.14 and `uv` are required. SQL Server uses `pyodbc` and needs Microsoft ODBC Driver
58
+ 18 for SQL Server.
59
+
60
+ ```powershell
61
+ uv sync --all-groups --locked
62
+ Copy-Item sql-mini-mcp.example.yaml sql-mini-mcp.yaml
63
+ $env:SQL_MINI_MCP_CONFIG = "$PWD\sql-mini-mcp.yaml"
64
+ uv run sql-mini-mcp --check-config
65
+ uv run sql-mini-mcp
66
+ ```
67
+
68
+ `sql-mini-mcp` speaks MCP over stdio. Configure the same command and environment in the MCP host.
69
+ All logs go to stderr.
70
+
71
+ MySQL/MariaDB configuration is intentionally rejected until Milestone 3.
72
+
73
+ ## Configuration and secrets
74
+
75
+ Connection topology stays in YAML while `${NAME}` placeholders read process environment values.
76
+ A placeholder occupying the entire `connection_url` may contain a complete SQLAlchemy URL;
77
+ embedded values are URL-encoded before substitution.
78
+
79
+ Every `pii_safe` alias requires its own base64-encoded 32-byte key:
80
+
81
+ ```powershell
82
+ $bytes = New-Object byte[] 32
83
+ [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
84
+ $env:LEGACY_PROD_PII_KEY = [Convert]::ToBase64String($bytes)
85
+ ```
86
+
87
+ Keys are rejected if reused by two aliases. Milestone 2 will authenticate the server alias as
88
+ AES-GCM associated data, so tokens cannot cross aliases even if keys are accidentally duplicated
89
+ outside normal config loading. Key rotation or alias renaming will invalidate existing tokens.
90
+
91
+ See [sql-mini-mcp.example.yaml](sql-mini-mcp.example.yaml) for a complete example.
92
+
93
+ ## Development
94
+
95
+ ```powershell
96
+ uv run ruff format --check .
97
+ uv run ruff check .
98
+ uv run ty check
99
+ uv run pytest tests/unit tests/contract -m "not integration"
100
+ ```
101
+
102
+ ## Live SQL Server gate
103
+
104
+ The reproducible Windows gate requires Docker Desktop in Linux-container mode and ODBC Driver 18.
105
+ It starts the digest-pinned SQL Server 2022 service when needed, then reuses the healthy container
106
+ and its test database on later runs:
107
+
108
+ ```powershell
109
+ .\scripts\test-sqlserver.ps1
110
+ ```
111
+
112
+ Each run creates uniquely named schemas, tables, stored procedures, users, and logins, then removes
113
+ those objects in `finally`. The container and its volume remain available for fast repeat runs. To
114
+ explicitly remove that local test service and its volume:
115
+
116
+ ```powershell
117
+ .\scripts\test-sqlserver.ps1 -Reset
118
+ ```
119
+
120
+ The container runs SQL Server 2022 with the test database at compatibility level 130. That exercises
121
+ the SQL Server 2016 compatibility surface, but it is not evidence of a run against an actual SQL
122
+ Server 2016 instance. An external disposable SQL Server can be checked directly:
123
+
124
+ ```powershell
125
+ $env:SQL_MINI_MCP_TEST_SQLSERVER_URL = "mssql+pyodbc://..."
126
+ uv run pytest tests/integration/sqlserver -m integration -v
127
+ ```
128
+
129
+ Live and future Milestone 2 security gates are documented in [CHECKS.md](CHECKS.md). Architecture
130
+ and threat assumptions are in [ARCHITECTURE.md](ARCHITECTURE.md) and [SECURITY.md](SECURITY.md).
@@ -0,0 +1,95 @@
1
+ # sql-mini-mcp
2
+
3
+ <!-- mcp-name: io.github.proprock/sql-mini-mcp -->
4
+
5
+ Compact MCP server for SQL Server metadata. The current Milestone 1 build supports only SQL
6
+ Server and is metadata-only; PII-safe `execute_sql` is developed separately in Milestone 2.
7
+
8
+ ## Tools
9
+
10
+ - `list_servers`
11
+ - `list_databases`
12
+ - `list_tables`
13
+ - `get_table_definition`
14
+ - `list_stored_procedures`
15
+ - `get_stored_procedure`
16
+
17
+ The server does not discover network servers, expose a full catalog as resources, use an ORM, or
18
+ execute caller-provided SQL.
19
+
20
+ ## Install and run
21
+
22
+ Python 3.12–3.14 and `uv` are required. SQL Server uses `pyodbc` and needs Microsoft ODBC Driver
23
+ 18 for SQL Server.
24
+
25
+ ```powershell
26
+ uv sync --all-groups --locked
27
+ Copy-Item sql-mini-mcp.example.yaml sql-mini-mcp.yaml
28
+ $env:SQL_MINI_MCP_CONFIG = "$PWD\sql-mini-mcp.yaml"
29
+ uv run sql-mini-mcp --check-config
30
+ uv run sql-mini-mcp
31
+ ```
32
+
33
+ `sql-mini-mcp` speaks MCP over stdio. Configure the same command and environment in the MCP host.
34
+ All logs go to stderr.
35
+
36
+ MySQL/MariaDB configuration is intentionally rejected until Milestone 3.
37
+
38
+ ## Configuration and secrets
39
+
40
+ Connection topology stays in YAML while `${NAME}` placeholders read process environment values.
41
+ A placeholder occupying the entire `connection_url` may contain a complete SQLAlchemy URL;
42
+ embedded values are URL-encoded before substitution.
43
+
44
+ Every `pii_safe` alias requires its own base64-encoded 32-byte key:
45
+
46
+ ```powershell
47
+ $bytes = New-Object byte[] 32
48
+ [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
49
+ $env:LEGACY_PROD_PII_KEY = [Convert]::ToBase64String($bytes)
50
+ ```
51
+
52
+ Keys are rejected if reused by two aliases. Milestone 2 will authenticate the server alias as
53
+ AES-GCM associated data, so tokens cannot cross aliases even if keys are accidentally duplicated
54
+ outside normal config loading. Key rotation or alias renaming will invalidate existing tokens.
55
+
56
+ See [sql-mini-mcp.example.yaml](sql-mini-mcp.example.yaml) for a complete example.
57
+
58
+ ## Development
59
+
60
+ ```powershell
61
+ uv run ruff format --check .
62
+ uv run ruff check .
63
+ uv run ty check
64
+ uv run pytest tests/unit tests/contract -m "not integration"
65
+ ```
66
+
67
+ ## Live SQL Server gate
68
+
69
+ The reproducible Windows gate requires Docker Desktop in Linux-container mode and ODBC Driver 18.
70
+ It starts the digest-pinned SQL Server 2022 service when needed, then reuses the healthy container
71
+ and its test database on later runs:
72
+
73
+ ```powershell
74
+ .\scripts\test-sqlserver.ps1
75
+ ```
76
+
77
+ Each run creates uniquely named schemas, tables, stored procedures, users, and logins, then removes
78
+ those objects in `finally`. The container and its volume remain available for fast repeat runs. To
79
+ explicitly remove that local test service and its volume:
80
+
81
+ ```powershell
82
+ .\scripts\test-sqlserver.ps1 -Reset
83
+ ```
84
+
85
+ The container runs SQL Server 2022 with the test database at compatibility level 130. That exercises
86
+ the SQL Server 2016 compatibility surface, but it is not evidence of a run against an actual SQL
87
+ Server 2016 instance. An external disposable SQL Server can be checked directly:
88
+
89
+ ```powershell
90
+ $env:SQL_MINI_MCP_TEST_SQLSERVER_URL = "mssql+pyodbc://..."
91
+ uv run pytest tests/integration/sqlserver -m integration -v
92
+ ```
93
+
94
+ Live and future Milestone 2 security gates are documented in [CHECKS.md](CHECKS.md). Architecture
95
+ and threat assumptions are in [ARCHITECTURE.md](ARCHITECTURE.md) and [SECURITY.md](SECURITY.md).
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "sql-mini-mcp"
3
+ version = "0.9.1"
4
+ description = "Minimal, read-only, PII-safe MCP server for SQL databases."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12,<3.15"
7
+ license = { file = "LICENSE" }
8
+ dependencies = [
9
+ "anyio>=4.8,<5",
10
+ "mcp>=2,<3",
11
+ "pydantic>=2.10,<3",
12
+ "pyodbc>=5.2,<6",
13
+ "pyyaml>=6,<7",
14
+ "sqlalchemy>=2.0,<3",
15
+ ]
16
+
17
+ [project.scripts]
18
+ sql-mini-mcp = "sql_mini_mcp.__main__:main"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "prek>=0.2",
23
+ "pytest>=8.3,<10",
24
+ "pytest-cov>=6,<8",
25
+ "ruff>=0.11",
26
+ "ty>=0.0.1a20",
27
+ ]
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/sql_mini_mcp"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = [
38
+ "/CHANGELOG.md",
39
+ "/LICENSE",
40
+ "/README.md",
41
+ "/pyproject.toml",
42
+ "/server.json",
43
+ "/sql-mini-mcp.example.yaml",
44
+ "/src",
45
+ ]
46
+
47
+ [tool.pytest.ini_options]
48
+ addopts = "-ra --strict-markers"
49
+ testpaths = ["tests"]
50
+ markers = [
51
+ "integration: requires a live database",
52
+ "deep: expensive security verification",
53
+ ]
54
+
55
+ [tool.coverage.run]
56
+ branch = true
57
+ source = ["sql_mini_mcp"]
58
+
59
+ [tool.ruff]
60
+ line-length = 100
61
+ target-version = "py312"
62
+
63
+ [tool.ruff.lint]
64
+ select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
65
+
66
+ [tool.ruff.format]
67
+ quote-style = "double"
68
+
69
+ [tool.ty.src]
70
+ include = ["src", "tests"]
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.proprock/sql-mini-mcp",
4
+ "title": "SQL Mini MCP",
5
+ "description": "Minimal, read-only, PII-safe MCP server for SQL databases.",
6
+ "repository": {
7
+ "url": "https://github.com/proprock/sql-mini-mcp",
8
+ "source": "github"
9
+ },
10
+ "version": "0.9.1",
11
+ "packages": [
12
+ {
13
+ "registryType": "pypi",
14
+ "identifier": "sql-mini-mcp",
15
+ "version": "0.9.1",
16
+ "transport": {
17
+ "type": "stdio"
18
+ }
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,32 @@
1
+ version: 1
2
+
3
+ runtime:
4
+ max_concurrent_db_operations: 8
5
+ engine_cache_size: 32
6
+ pool_size: 2
7
+ max_overflow: 2
8
+ pool_timeout_seconds: 10
9
+ statement_timeout_seconds: 30
10
+ default_max_rows: 200
11
+ hard_max_rows: 1000
12
+
13
+ servers:
14
+ legacy-prod:
15
+ engine: sqlserver
16
+ access_level: pii_safe
17
+ connection_url: >-
18
+ mssql+pyodbc://${LEGACY_PROD_USER}:${LEGACY_PROD_PASSWORD}
19
+ @${LEGACY_PROD_HOST}/master
20
+ ?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=yes
21
+ pii_key_env: LEGACY_PROD_PII_KEY
22
+ pii:
23
+ rules:
24
+ - database: "*"
25
+ schema: dbo
26
+ table: Users
27
+ columns: [Email, FirstName, LastName]
28
+
29
+ reporting-metadata:
30
+ engine: sqlserver
31
+ access_level: metadata
32
+ connection_url: "${REPORTING_SQL_URL}"
@@ -0,0 +1,3 @@
1
+ """Minimal, PII-safe MCP server for relational databases."""
2
+
3
+ __version__ = "0.9.1"
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from sql_mini_mcp.config import load_config
9
+ from sql_mini_mcp.errors import DomainError
10
+ from sql_mini_mcp.mcp_server import create_server
11
+
12
+
13
+ def _parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(description="Run the sql-mini-mcp stdio server.")
15
+ parser.add_argument(
16
+ "--config",
17
+ type=Path,
18
+ default=os.environ.get("SQL_MINI_MCP_CONFIG", "sql-mini-mcp.yaml"),
19
+ help="YAML configuration path (default: SQL_MINI_MCP_CONFIG or sql-mini-mcp.yaml)",
20
+ )
21
+ parser.add_argument(
22
+ "--check-config",
23
+ action="store_true",
24
+ help="Validate configuration and secrets without connecting to databases.",
25
+ )
26
+ return parser
27
+
28
+
29
+ def main() -> None:
30
+ args = _parser().parse_args()
31
+ try:
32
+ config = load_config(args.config)
33
+ except DomainError as exc:
34
+ print(str(exc), file=sys.stderr)
35
+ raise SystemExit(2) from None
36
+ if args.check_config:
37
+ print(f"Configuration valid: {len(config.servers)} server alias(es).")
38
+ return
39
+ create_server(config).run("stdio")
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import os
6
+ import re
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from typing import Literal
10
+ from urllib.parse import quote
11
+
12
+ import yaml
13
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError, model_validator
14
+ from sqlalchemy.engine import make_url
15
+
16
+ from sql_mini_mcp.errors import DomainError, ErrorCode
17
+
18
+ _ENV_PATTERN = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
19
+ _FULL_ENV_PATTERN = re.compile(r"^\$\{([A-Z_][A-Z0-9_]*)\}$")
20
+
21
+
22
+ class RuntimeConfig(BaseModel):
23
+ model_config = ConfigDict(extra="forbid")
24
+
25
+ max_concurrent_db_operations: int = Field(default=8, ge=1, le=128)
26
+ engine_cache_size: int = Field(default=32, ge=1, le=1024)
27
+ pool_size: int = Field(default=2, ge=1, le=32)
28
+ max_overflow: int = Field(default=2, ge=0, le=64)
29
+ pool_timeout_seconds: int = Field(default=10, ge=1, le=300)
30
+ statement_timeout_seconds: int = Field(default=30, ge=1, le=3600)
31
+ max_definition_chars: int = Field(default=262_144, ge=1024, le=10_000_000)
32
+ default_max_rows: int = Field(default=200, ge=1, le=100_000)
33
+ hard_max_rows: int = Field(default=1000, ge=1, le=100_000)
34
+ max_sql_chars: int = Field(default=65_536, ge=256, le=1_000_000)
35
+ max_ast_nodes: int = Field(default=2000, ge=10, le=100_000)
36
+ max_joins: int = Field(default=8, ge=0, le=100)
37
+ max_in_list_items: int = Field(default=500, ge=1, le=100_000)
38
+
39
+ @model_validator(mode="after")
40
+ def validate_row_limits(self) -> RuntimeConfig:
41
+ if self.default_max_rows > self.hard_max_rows:
42
+ raise ValueError("default_max_rows cannot exceed hard_max_rows")
43
+ return self
44
+
45
+
46
+ class PiiRule(BaseModel):
47
+ model_config = ConfigDict(extra="forbid")
48
+
49
+ database: str = Field(min_length=1)
50
+ schema_: str | None = Field(default=None, alias="schema")
51
+ table: str = Field(min_length=1)
52
+ columns: list[str] = Field(min_length=1)
53
+
54
+ @model_validator(mode="after")
55
+ def validate_wildcards(self) -> PiiRule:
56
+ if "*" in self.table or (self.schema_ and "*" in self.schema_):
57
+ raise ValueError("wildcards are allowed only in pii rule database")
58
+ if self.database != "*" and "*" in self.database:
59
+ raise ValueError("database must be an exact name or '*'")
60
+ folded = [column.casefold() for column in self.columns]
61
+ if len(folded) != len(set(folded)):
62
+ raise ValueError("pii rule columns must be unique")
63
+ return self
64
+
65
+
66
+ class PiiConfig(BaseModel):
67
+ model_config = ConfigDict(extra="forbid")
68
+
69
+ rules: list[PiiRule] = Field(min_length=1)
70
+
71
+
72
+ class ServerConfig(BaseModel):
73
+ model_config = ConfigDict(extra="forbid")
74
+
75
+ engine: Literal["sqlserver"]
76
+ access_level: Literal["metadata", "pii_safe"] = "metadata"
77
+ connection_url: SecretStr
78
+ pii_key_env: str | None = Field(default=None, pattern=r"^[A-Z_][A-Z0-9_]*$")
79
+ pii: PiiConfig | None = None
80
+ pii_key: SecretStr | None = Field(default=None, exclude=True)
81
+
82
+ @model_validator(mode="after")
83
+ def validate_security_shape(self) -> ServerConfig:
84
+ if self.access_level == "pii_safe":
85
+ if not self.pii_key_env or self.pii is None:
86
+ raise ValueError("pii_safe servers require pii_key_env and pii rules")
87
+ elif self.pii_key_env is not None or self.pii is not None:
88
+ raise ValueError("metadata servers cannot configure pii_key_env or pii rules")
89
+
90
+ driver = make_url(self.connection_url.get_secret_value()).drivername
91
+ expected = "mssql+pyodbc"
92
+ if driver != expected:
93
+ raise ValueError(f"engine {self.engine!r} requires SQLAlchemy dialect {expected!r}")
94
+ return self
95
+
96
+ def key_bytes(self) -> bytes | None:
97
+ if self.pii_key is None:
98
+ return None
99
+ return base64.b64decode(self.pii_key.get_secret_value(), validate=True)
100
+
101
+
102
+ class AppConfig(BaseModel):
103
+ model_config = ConfigDict(extra="forbid")
104
+
105
+ version: Literal[1]
106
+ runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
107
+ servers: dict[str, ServerConfig] = Field(min_length=1)
108
+
109
+ @model_validator(mode="after")
110
+ def validate_aliases_and_keys(self) -> AppConfig:
111
+ seen_keys: dict[bytes, str] = {}
112
+ for alias, server in self.servers.items():
113
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", alias):
114
+ raise ValueError(f"invalid server alias {alias!r}")
115
+ key = server.key_bytes()
116
+ if key is None:
117
+ continue
118
+ if len(key) != 32:
119
+ raise ValueError(f"PII key for server {alias!r} must decode to 32 bytes")
120
+ if previous := seen_keys.get(key):
121
+ raise ValueError(
122
+ f"PII keys must be unique per server alias; {alias!r} duplicates {previous!r}"
123
+ )
124
+ seen_keys[key] = alias
125
+ return self
126
+
127
+
128
+ def _expand_connection_url(template: str, environ: Mapping[str, str]) -> str:
129
+ full_match = _FULL_ENV_PATTERN.fullmatch(template)
130
+ if full_match:
131
+ name = full_match.group(1)
132
+ if name not in environ:
133
+ raise ValueError(f"missing environment variable {name}")
134
+ return environ[name]
135
+
136
+ def replace(match: re.Match[str]) -> str:
137
+ name = match.group(1)
138
+ if name not in environ:
139
+ raise ValueError(f"missing environment variable {name}")
140
+ return quote(environ[name], safe="")
141
+
142
+ expanded = _ENV_PATTERN.sub(replace, template)
143
+ if "${" in expanded:
144
+ raise ValueError("invalid environment placeholder in connection_url")
145
+ return expanded
146
+
147
+
148
+ def _validation_summary(error: ValidationError) -> str:
149
+ issues: list[str] = []
150
+ for issue in error.errors(include_url=False, include_context=False, include_input=False):
151
+ location = ".".join(str(part) for part in issue["loc"])
152
+ issues.append(f"{location}: {issue['msg']}" if location else str(issue["msg"]))
153
+ return "; ".join(issues)
154
+
155
+
156
+ def load_config(path: str | Path, environ: Mapping[str, str] | None = None) -> AppConfig:
157
+ env = os.environ if environ is None else environ
158
+ try:
159
+ raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
160
+ if not isinstance(raw, dict):
161
+ raise ValueError("configuration root must be a mapping")
162
+ servers = raw.get("servers")
163
+ if not isinstance(servers, dict):
164
+ raise ValueError("servers must be a mapping")
165
+ for alias, value in servers.items():
166
+ if not isinstance(value, dict):
167
+ raise ValueError(f"server {alias!r} must be a mapping")
168
+ url = value.get("connection_url")
169
+ if not isinstance(url, str):
170
+ raise ValueError(f"server {alias!r} requires connection_url")
171
+ value["connection_url"] = _expand_connection_url(url, env)
172
+ key_env = value.get("pii_key_env")
173
+ if key_env is not None:
174
+ if not isinstance(key_env, str) or key_env not in env:
175
+ raise ValueError(f"missing PII key environment variable {key_env!r}")
176
+ try:
177
+ decoded = base64.b64decode(env[key_env], validate=True)
178
+ except (binascii.Error, ValueError) as exc:
179
+ raise ValueError(f"PII key for server {alias!r} is not valid base64") from exc
180
+ if len(decoded) != 32:
181
+ raise ValueError(f"PII key for server {alias!r} must decode to 32 bytes")
182
+ value["pii_key"] = env[key_env]
183
+ return AppConfig.model_validate(raw)
184
+ except DomainError:
185
+ raise
186
+ except ValidationError as exc:
187
+ raise DomainError(
188
+ ErrorCode.CONFIG_ERROR,
189
+ f"Invalid configuration: {_validation_summary(exc)}",
190
+ ) from exc
191
+ except (OSError, yaml.YAMLError) as exc:
192
+ raise DomainError(ErrorCode.CONFIG_ERROR, "Invalid configuration file.") from exc
193
+ except ValueError as exc:
194
+ raise DomainError(ErrorCode.CONFIG_ERROR, f"Invalid configuration: {exc}") from exc
@@ -0,0 +1 @@
1
+ """Database integration layer."""