sql-safe-mcp 1.2.0__tar.gz → 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/CHANGELOG.md +15 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/PKG-INFO +38 -4
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/README.md +37 -3
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/pyproject.toml +1 -1
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/server.json +2 -2
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/sql-safe-mcp.example.yaml +3 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/__init__.py +1 -1
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/config.py +7 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/registry.py +38 -1
- sql_safe_mcp-1.3.0/src/sql_safe_mcp/diagnostics.py +70 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/mcp_server.py +4 -1
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/service.py +100 -39
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/.gitignore +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/LICENSE +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/__main__.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/__init__.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/extras.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/mysql.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/reflection.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/sqlserver.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/errors.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/models.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/__init__.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/dialect.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/executor.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/lineage.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/parser.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/pipeline.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/policy.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/reasons.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/schema.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/tokens.py +0 -0
- {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/validated_query.py +0 -0
|
@@ -7,6 +7,21 @@ categories `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security`.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.3.0] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Diagnostic logging to stderr, controlled by the new `logging.level` setting (`DEBUG`, `INFO`,
|
|
15
|
+
`WARNING`, or `ERROR`; default `INFO`). It records each database connection attempt (server alias,
|
|
16
|
+
database, elapsed time, and on failure the driver error class, SQLSTATE or code, and message with
|
|
17
|
+
the connection URL's user name and password masked and its host shown as the server alias) and each tool operation's outcome.
|
|
18
|
+
Connection URLs, credentials, keys, tokens, SQL, bind values, and rows are never logged.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- `TIMEOUT` and `CONNECTION_FAILED` errors now carry a `Reference` that matches the `reference=`
|
|
23
|
+
field of the log line for the same failure.
|
|
24
|
+
|
|
10
25
|
## [1.2.0] - 2026-09-20
|
|
11
26
|
|
|
12
27
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sql-safe-mcp
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Minimal, read-only, PII-safe MCP server for SQL databases.
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -44,12 +44,12 @@ A read-only, PII-safe SQL Server, MySQL and MariaDB MCP server for coding agents
|
|
|
44
44
|
|
|
45
45
|
[](https://github.com/proprock/sql-safe-mcp/actions/workflows/ci.yml)
|
|
46
46
|
[](https://github.com/proprock/sql-safe-mcp/releases)
|
|
47
|
-
[](https://pypi.org/project/sql-safe-mcp/)
|
|
47
|
+
[](https://pypi.org/project/sql-safe-mcp/)
|
|
48
48
|
[](https://www.python.org/downloads/)
|
|
49
49
|
[](LICENSE)
|
|
50
50
|
|
|
51
51
|
[](https://modelcontextprotocol.io)
|
|
52
|
-
[](server.json)
|
|
53
53
|
|
|
54
54
|
<img src="https://raw.githubusercontent.com/proprock/sql-safe-mcp/master/images/luna-guard.jpg" alt="Read the data. Protect the identity." width="760">
|
|
55
55
|
|
|
@@ -90,6 +90,14 @@ databases, tables, columns, keys, indexes, stored procedures - and, on servers y
|
|
|
90
90
|
case-insensitive name filters, and stored procedure lists that do not expand definitions.
|
|
91
91
|
- **Errors an agent can act on** - an ambiguous name lists the candidate schemas. Errors never
|
|
92
92
|
contain connection details, credentials, keys, tokens, or rows.
|
|
93
|
+
- **Tested against attacks, not just examples** - the SQL and PII boundary is checked with an
|
|
94
|
+
adversarial corpus of hostile statements, property-based tests (token isolation, tamper
|
|
95
|
+
resistance), a live attack run against a really writable login with before/after snapshots, and
|
|
96
|
+
mutation testing. Results are in
|
|
97
|
+
[Verification of the SQL boundary](SECURITY-MODEL.md#verification-of-the-sql-boundary).
|
|
98
|
+
- **Diagnosable failures** - a timeout or connection error carries a `Reference`, and the stderr
|
|
99
|
+
log records the connection stage, elapsed time, and driver error for the same reference, with
|
|
100
|
+
credentials removed. See [Logging](docs/configuration.md#logging).
|
|
93
101
|
- **On PyPI** - `uvx sql-safe-mcp`, no repo clone required.
|
|
94
102
|
|
|
95
103
|
| Tool | Access | Purpose |
|
|
@@ -129,7 +137,7 @@ or
|
|
|
129
137
|
pip install sql-safe-mcp
|
|
130
138
|
```
|
|
131
139
|
|
|
132
|
-
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.
|
|
140
|
+
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.3.0`.
|
|
133
141
|
|
|
134
142
|
Requires Python 3.12+, [uv](https://docs.astral.sh/uv/) (or `pip`), and
|
|
135
143
|
[Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)
|
|
@@ -152,6 +160,32 @@ servers:
|
|
|
152
160
|
connection_url: "${REPORTING_SQL_URL}"
|
|
153
161
|
```
|
|
154
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
|
+
|
|
173
|
+
# SQL Server (mssql+pyodbc), URL assembled from parts. Embedded placeholders are
|
|
174
|
+
# URL-encoded, so a password containing @ or / is safe.
|
|
175
|
+
billing:
|
|
176
|
+
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
|
|
180
|
+
|
|
181
|
+
# MySQL or MariaDB (mysql+pymysql). Use engine: mariadb for MariaDB.
|
|
182
|
+
shop:
|
|
183
|
+
engine: mysql
|
|
184
|
+
connection_url: "mysql+pymysql://${SHOP_USER}:${SHOP_PASSWORD}@db.internal/shop"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
A missing variable, or an `engine` that does not match the URL dialect, stops the server at startup.
|
|
188
|
+
|
|
155
189
|
Point the server at the file with `SQL_SAFE_MCP_CONFIG` (or `--config`), and check it without
|
|
156
190
|
connecting to any database:
|
|
157
191
|
|
|
@@ -6,12 +6,12 @@ A read-only, PII-safe SQL Server, MySQL and MariaDB MCP server for coding agents
|
|
|
6
6
|
|
|
7
7
|
[](https://github.com/proprock/sql-safe-mcp/actions/workflows/ci.yml)
|
|
8
8
|
[](https://github.com/proprock/sql-safe-mcp/releases)
|
|
9
|
-
[](https://pypi.org/project/sql-safe-mcp/)
|
|
9
|
+
[](https://pypi.org/project/sql-safe-mcp/)
|
|
10
10
|
[](https://www.python.org/downloads/)
|
|
11
11
|
[](LICENSE)
|
|
12
12
|
|
|
13
13
|
[](https://modelcontextprotocol.io)
|
|
14
|
-
[](server.json)
|
|
15
15
|
|
|
16
16
|
<img src="https://raw.githubusercontent.com/proprock/sql-safe-mcp/master/images/luna-guard.jpg" alt="Read the data. Protect the identity." width="760">
|
|
17
17
|
|
|
@@ -52,6 +52,14 @@ databases, tables, columns, keys, indexes, stored procedures - and, on servers y
|
|
|
52
52
|
case-insensitive name filters, and stored procedure lists that do not expand definitions.
|
|
53
53
|
- **Errors an agent can act on** - an ambiguous name lists the candidate schemas. Errors never
|
|
54
54
|
contain connection details, credentials, keys, tokens, or rows.
|
|
55
|
+
- **Tested against attacks, not just examples** - the SQL and PII boundary is checked with an
|
|
56
|
+
adversarial corpus of hostile statements, property-based tests (token isolation, tamper
|
|
57
|
+
resistance), a live attack run against a really writable login with before/after snapshots, and
|
|
58
|
+
mutation testing. Results are in
|
|
59
|
+
[Verification of the SQL boundary](SECURITY-MODEL.md#verification-of-the-sql-boundary).
|
|
60
|
+
- **Diagnosable failures** - a timeout or connection error carries a `Reference`, and the stderr
|
|
61
|
+
log records the connection stage, elapsed time, and driver error for the same reference, with
|
|
62
|
+
credentials removed. See [Logging](docs/configuration.md#logging).
|
|
55
63
|
- **On PyPI** - `uvx sql-safe-mcp`, no repo clone required.
|
|
56
64
|
|
|
57
65
|
| Tool | Access | Purpose |
|
|
@@ -91,7 +99,7 @@ or
|
|
|
91
99
|
pip install sql-safe-mcp
|
|
92
100
|
```
|
|
93
101
|
|
|
94
|
-
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.
|
|
102
|
+
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.3.0`.
|
|
95
103
|
|
|
96
104
|
Requires Python 3.12+, [uv](https://docs.astral.sh/uv/) (or `pip`), and
|
|
97
105
|
[Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)
|
|
@@ -114,6 +122,32 @@ servers:
|
|
|
114
122
|
connection_url: "${REPORTING_SQL_URL}"
|
|
115
123
|
```
|
|
116
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
|
+
|
|
135
|
+
# SQL Server (mssql+pyodbc), URL assembled from parts. Embedded placeholders are
|
|
136
|
+
# URL-encoded, so a password containing @ or / is safe.
|
|
137
|
+
billing:
|
|
138
|
+
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
|
|
142
|
+
|
|
143
|
+
# MySQL or MariaDB (mysql+pymysql). Use engine: mariadb for MariaDB.
|
|
144
|
+
shop:
|
|
145
|
+
engine: mysql
|
|
146
|
+
connection_url: "mysql+pymysql://${SHOP_USER}:${SHOP_PASSWORD}@db.internal/shop"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A missing variable, or an `engine` that does not match the URL dialect, stops the server at startup.
|
|
150
|
+
|
|
117
151
|
Point the server at the file with `SQL_SAFE_MCP_CONFIG` (or `--config`), and check it without
|
|
118
152
|
connecting to any database:
|
|
119
153
|
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/proprock/sql-safe-mcp",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.3.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "sql-safe-mcp",
|
|
15
|
-
"version": "1.
|
|
15
|
+
"version": "1.3.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -43,6 +43,12 @@ class RuntimeConfig(BaseModel):
|
|
|
43
43
|
return self
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
class LoggingConfig(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
|
|
49
|
+
level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
|
50
|
+
|
|
51
|
+
|
|
46
52
|
class PiiRule(BaseModel):
|
|
47
53
|
model_config = ConfigDict(extra="forbid")
|
|
48
54
|
|
|
@@ -113,6 +119,7 @@ class AppConfig(BaseModel):
|
|
|
113
119
|
|
|
114
120
|
version: Literal[1]
|
|
115
121
|
runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
|
|
122
|
+
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
116
123
|
servers: dict[str, ServerConfig] = Field(min_length=1)
|
|
117
124
|
|
|
118
125
|
@model_validator(mode="after")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
3
5
|
from collections import OrderedDict
|
|
4
6
|
from collections.abc import Callable
|
|
5
7
|
from threading import RLock
|
|
@@ -9,9 +11,11 @@ import anyio
|
|
|
9
11
|
from sqlalchemy import Engine, create_engine, event
|
|
10
12
|
|
|
11
13
|
from sql_safe_mcp.config import AppConfig
|
|
14
|
+
from sql_safe_mcp.diagnostics import describe_error, secrets_for
|
|
12
15
|
from sql_safe_mcp.errors import DomainError, ErrorCode
|
|
13
16
|
|
|
14
17
|
T = TypeVar("T")
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
class EngineRegistry:
|
|
@@ -48,6 +52,30 @@ class EngineRegistry:
|
|
|
48
52
|
**options,
|
|
49
53
|
)
|
|
50
54
|
|
|
55
|
+
secrets = secrets_for(server.connection_url.get_secret_value(), alias)
|
|
56
|
+
|
|
57
|
+
@event.listens_for(engine, "do_connect")
|
|
58
|
+
def connect_and_log(dialect: Any, _record: Any, cargs: Any, cparams: Any) -> Any:
|
|
59
|
+
started = time.perf_counter()
|
|
60
|
+
try:
|
|
61
|
+
connection = dialect.connect(*cargs, **cparams)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
logger.warning(
|
|
64
|
+
"connect failed server=%s database=%s elapsed_ms=%d error=%s",
|
|
65
|
+
alias,
|
|
66
|
+
database or "-",
|
|
67
|
+
(time.perf_counter() - started) * 1000,
|
|
68
|
+
describe_error(exc, secrets),
|
|
69
|
+
)
|
|
70
|
+
raise
|
|
71
|
+
logger.info(
|
|
72
|
+
"connect ok server=%s database=%s elapsed_ms=%d",
|
|
73
|
+
alias,
|
|
74
|
+
database or "-",
|
|
75
|
+
(time.perf_counter() - started) * 1000,
|
|
76
|
+
)
|
|
77
|
+
return connection
|
|
78
|
+
|
|
51
79
|
@event.listens_for(engine, "connect")
|
|
52
80
|
def set_connection_timeout(dbapi_connection: Any, _record: Any) -> None:
|
|
53
81
|
if hasattr(dbapi_connection, "timeout"):
|
|
@@ -95,8 +123,17 @@ class EngineRegistry:
|
|
|
95
123
|
return engine
|
|
96
124
|
engine = self._create_engine(alias, database)
|
|
97
125
|
self._engines[key] = engine
|
|
126
|
+
logger.debug(
|
|
127
|
+
"engine created server=%s engine=%s database=%s",
|
|
128
|
+
alias,
|
|
129
|
+
self._config.servers[alias].engine,
|
|
130
|
+
database or "-",
|
|
131
|
+
)
|
|
98
132
|
if len(self._engines) > self._config.runtime.engine_cache_size:
|
|
99
|
-
|
|
133
|
+
evicted_key, evicted = self._engines.popitem(last=False)
|
|
134
|
+
logger.debug(
|
|
135
|
+
"engine evicted server=%s database=%s", evicted_key[0], evicted_key[1] or "-"
|
|
136
|
+
)
|
|
100
137
|
if evicted is not None:
|
|
101
138
|
evicted.dispose()
|
|
102
139
|
return engine
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from sqlalchemy.engine import make_url
|
|
9
|
+
|
|
10
|
+
_MAX_MESSAGE_CHARS = 500
|
|
11
|
+
_CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]+")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def configure_logging(level: str) -> None:
|
|
15
|
+
logging.getLogger("sql_safe_mcp").setLevel(level)
|
|
16
|
+
# SQLAlchemy inherits the root level and would echo statements with bind values at DEBUG.
|
|
17
|
+
logging.getLogger("sqlalchemy").setLevel(logging.WARNING)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def secrets_for(url: str, alias: str) -> tuple[tuple[str, str], ...]:
|
|
21
|
+
"""Redactions for a connection URL: credentials become ``***``, the host the server alias."""
|
|
22
|
+
try:
|
|
23
|
+
parsed = make_url(url)
|
|
24
|
+
except Exception:
|
|
25
|
+
return ()
|
|
26
|
+
found: dict[str, str] = {}
|
|
27
|
+
for value, replacement in (
|
|
28
|
+
(parsed.password, "***"),
|
|
29
|
+
(parsed.username, "***"),
|
|
30
|
+
(parsed.host, alias),
|
|
31
|
+
):
|
|
32
|
+
if value:
|
|
33
|
+
found[value] = replacement
|
|
34
|
+
found[quote(value, safe="")] = replacement
|
|
35
|
+
return tuple(found.items())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _driver_parts(exc: BaseException) -> tuple[str, str, str]:
|
|
39
|
+
orig = getattr(exc, "orig", None) or exc
|
|
40
|
+
args = orig.args
|
|
41
|
+
if len(args) >= 2 and isinstance(args[0], int):
|
|
42
|
+
return type(orig).__name__, f" code={args[0]}", str(args[1])
|
|
43
|
+
if len(args) >= 2 and isinstance(args[0], str) and isinstance(args[1], str):
|
|
44
|
+
return type(orig).__name__, f" sqlstate={args[0]}", args[1]
|
|
45
|
+
return type(orig).__name__, "", str(orig)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _secret_pattern(secret: str) -> str:
|
|
49
|
+
# Whole-word only where the edge is a word character, so "sa" spares "usage" but a
|
|
50
|
+
# password ending in punctuation is still removed.
|
|
51
|
+
start = r"(?<!\w)" if re.match(r"\w", secret[0]) else ""
|
|
52
|
+
end = r"(?!\w)" if re.match(r"\w", secret[-1]) else ""
|
|
53
|
+
return f"{start}{re.escape(secret)}{end}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def describe_error(exc: BaseException, redactions: Iterable[tuple[str, str]]) -> str:
|
|
57
|
+
"""One log-safe line for a driver error: class, SQLSTATE or code, redacted message."""
|
|
58
|
+
name, code, message = _driver_parts(exc)
|
|
59
|
+
for secret, replacement in sorted(set(redactions), key=lambda pair: len(pair[0]), reverse=True):
|
|
60
|
+
if secret:
|
|
61
|
+
message = re.sub(
|
|
62
|
+
_secret_pattern(secret),
|
|
63
|
+
lambda _, text=replacement: text,
|
|
64
|
+
message,
|
|
65
|
+
flags=re.IGNORECASE,
|
|
66
|
+
)
|
|
67
|
+
message = _CONTROL_CHARACTERS.sub(" ", message).strip()
|
|
68
|
+
if len(message) > _MAX_MESSAGE_CHARS:
|
|
69
|
+
message = message[:_MAX_MESSAGE_CHARS] + "..."
|
|
70
|
+
return f"{name}{code} message={message}"
|
|
@@ -10,6 +10,7 @@ from mcp_types import ToolAnnotations
|
|
|
10
10
|
|
|
11
11
|
from sql_safe_mcp.config import AppConfig
|
|
12
12
|
from sql_safe_mcp.db.registry import EngineRegistry
|
|
13
|
+
from sql_safe_mcp.diagnostics import configure_logging
|
|
13
14
|
from sql_safe_mcp.errors import DomainError
|
|
14
15
|
from sql_safe_mcp.models import (
|
|
15
16
|
DatabaseList,
|
|
@@ -47,11 +48,13 @@ def create_server(config: AppConfig) -> MCPServer[AppContext]:
|
|
|
47
48
|
finally:
|
|
48
49
|
registry.dispose()
|
|
49
50
|
|
|
51
|
+
configure_logging(config.logging.level)
|
|
50
52
|
server: MCPServer[AppContext] = MCPServer(
|
|
51
53
|
"sql-safe-mcp",
|
|
52
54
|
description="Minimal, read-only, PII-safe SQL database navigation.",
|
|
53
|
-
version="1.
|
|
55
|
+
version="1.3.0",
|
|
54
56
|
lifespan=lifespan,
|
|
57
|
+
log_level=config.logging.level,
|
|
55
58
|
)
|
|
56
59
|
|
|
57
60
|
@server.tool(annotations=READ_ONLY)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import time
|
|
4
5
|
from collections.abc import Callable
|
|
5
6
|
from typing import TypeVar
|
|
7
|
+
from uuid import uuid4
|
|
6
8
|
|
|
7
9
|
from sqlalchemy import Connection, Engine
|
|
8
10
|
from sqlalchemy.exc import DBAPIError, OperationalError, SQLAlchemyError
|
|
@@ -13,6 +15,7 @@ from sql_safe_mcp.db.extras import extras_for
|
|
|
13
15
|
from sql_safe_mcp.db.reflection import get_table_definition as reflect_table_definition
|
|
14
16
|
from sql_safe_mcp.db.reflection import list_tables as reflect_tables
|
|
15
17
|
from sql_safe_mcp.db.registry import EngineRegistry
|
|
18
|
+
from sql_safe_mcp.diagnostics import describe_error, secrets_for
|
|
16
19
|
from sql_safe_mcp.errors import DomainError, ErrorCode
|
|
17
20
|
from sql_safe_mcp.models import (
|
|
18
21
|
ColumnSource,
|
|
@@ -42,6 +45,24 @@ _SCHEMA_CACHE_TTL_SECONDS = 300.0
|
|
|
42
45
|
T = TypeVar("T")
|
|
43
46
|
|
|
44
47
|
|
|
48
|
+
def _timeout_error() -> DomainError:
|
|
49
|
+
return DomainError(
|
|
50
|
+
ErrorCode.TIMEOUT,
|
|
51
|
+
"The database operation timed out.",
|
|
52
|
+
retryable=True,
|
|
53
|
+
correlation_id=uuid4().hex,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _connection_error() -> DomainError:
|
|
58
|
+
return DomainError(
|
|
59
|
+
ErrorCode.CONNECTION_FAILED,
|
|
60
|
+
"Could not connect to the configured database server.",
|
|
61
|
+
retryable=True,
|
|
62
|
+
correlation_id=uuid4().hex,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
45
66
|
def _contains(value: str, needle: str | None) -> bool:
|
|
46
67
|
return needle is None or needle.casefold() in value.casefold()
|
|
47
68
|
|
|
@@ -76,55 +97,95 @@ class DatabaseService:
|
|
|
76
97
|
"Call list_servers to discover configured aliases.",
|
|
77
98
|
) from exc
|
|
78
99
|
|
|
79
|
-
|
|
100
|
+
def _classified(
|
|
101
|
+
self,
|
|
102
|
+
alias: str,
|
|
103
|
+
database: str | None,
|
|
104
|
+
operation: str,
|
|
105
|
+
error: DomainError,
|
|
106
|
+
cause: BaseException,
|
|
107
|
+
) -> DomainError:
|
|
108
|
+
secrets = secrets_for(self.config.servers[alias].connection_url.get_secret_value(), alias)
|
|
109
|
+
logger.warning(
|
|
110
|
+
"operation failed server=%s database=%s operation=%s code=%s reference=%s error=%s",
|
|
111
|
+
alias,
|
|
112
|
+
database or "-",
|
|
113
|
+
operation,
|
|
114
|
+
error.code,
|
|
115
|
+
error.correlation_id or "-",
|
|
116
|
+
describe_error(cause, secrets),
|
|
117
|
+
)
|
|
118
|
+
return error
|
|
119
|
+
|
|
120
|
+
def _unexpected(
|
|
121
|
+
self, message: str, alias: str, database: str | None, operation: str, cause: BaseException
|
|
122
|
+
) -> DomainError:
|
|
123
|
+
error = DomainError.unexpected()
|
|
124
|
+
logger.error(
|
|
125
|
+
"%s; server=%s database=%s operation=%s error_class=%s reference=%s",
|
|
126
|
+
message,
|
|
127
|
+
alias,
|
|
128
|
+
database or "-",
|
|
129
|
+
operation,
|
|
130
|
+
type(cause).__name__,
|
|
131
|
+
error.correlation_id,
|
|
132
|
+
)
|
|
133
|
+
return error
|
|
134
|
+
|
|
135
|
+
async def _run(
|
|
136
|
+
self,
|
|
137
|
+
alias: str,
|
|
138
|
+
database: str | None,
|
|
139
|
+
operation_name: str,
|
|
140
|
+
operation: Callable[[Engine], T],
|
|
141
|
+
) -> T:
|
|
80
142
|
self._server(alias)
|
|
143
|
+
started = time.perf_counter()
|
|
81
144
|
try:
|
|
82
|
-
|
|
145
|
+
result = await self.registry.run(alias, database, operation)
|
|
83
146
|
except DomainError:
|
|
84
147
|
raise
|
|
85
148
|
except SQLAlchemyTimeoutError as exc:
|
|
86
|
-
raise
|
|
87
|
-
ErrorCode.TIMEOUT,
|
|
88
|
-
"The database operation timed out.",
|
|
89
|
-
retryable=True,
|
|
90
|
-
) from exc
|
|
149
|
+
raise self._classified(alias, database, operation_name, _timeout_error(), exc) from exc
|
|
91
150
|
except DBAPIError as exc:
|
|
92
151
|
message = str(exc.orig).casefold()
|
|
93
152
|
if any(value in message for value in ("timeout", "timed out", "hyt00", "hyt01")):
|
|
94
|
-
raise
|
|
95
|
-
|
|
153
|
+
raise self._classified(
|
|
154
|
+
alias, database, operation_name, _timeout_error(), exc
|
|
96
155
|
) from exc
|
|
97
156
|
if any(value in message for value in ("permission", "denied", "not authorized")):
|
|
98
|
-
raise
|
|
99
|
-
|
|
157
|
+
raise self._classified(
|
|
158
|
+
alias,
|
|
159
|
+
database,
|
|
160
|
+
operation_name,
|
|
161
|
+
DomainError(ErrorCode.ACCESS_DENIED, "The database denied this operation."),
|
|
162
|
+
exc,
|
|
100
163
|
) from exc
|
|
101
164
|
if any(
|
|
102
165
|
value in message for value in ("cannot open database", "(4060)", "08001", "08004")
|
|
103
|
-
):
|
|
104
|
-
raise
|
|
105
|
-
|
|
106
|
-
"Could not connect to the configured database server.",
|
|
107
|
-
retryable=True,
|
|
108
|
-
) from exc
|
|
109
|
-
if isinstance(exc, OperationalError):
|
|
110
|
-
raise DomainError(
|
|
111
|
-
ErrorCode.CONNECTION_FAILED,
|
|
112
|
-
"Could not connect to the configured database server.",
|
|
113
|
-
retryable=True,
|
|
166
|
+
) or isinstance(exc, OperationalError):
|
|
167
|
+
raise self._classified(
|
|
168
|
+
alias, database, operation_name, _connection_error(), exc
|
|
114
169
|
) from exc
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
170
|
+
raise self._unexpected(
|
|
171
|
+
"Database operation failed", alias, database, operation_name, exc
|
|
172
|
+
) from exc
|
|
118
173
|
except SQLAlchemyError as exc:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
174
|
+
raise self._unexpected(
|
|
175
|
+
"SQLAlchemy operation failed", alias, database, operation_name, exc
|
|
176
|
+
) from exc
|
|
122
177
|
except Exception as exc:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
178
|
+
raise self._unexpected(
|
|
179
|
+
"Unexpected database operation failure", alias, database, operation_name, exc
|
|
180
|
+
) from exc
|
|
181
|
+
logger.info(
|
|
182
|
+
"operation ok server=%s database=%s operation=%s elapsed_ms=%d",
|
|
183
|
+
alias,
|
|
184
|
+
database or "-",
|
|
185
|
+
operation_name,
|
|
186
|
+
(time.perf_counter() - started) * 1000,
|
|
187
|
+
)
|
|
188
|
+
return result
|
|
128
189
|
|
|
129
190
|
def list_servers(self) -> ServerList:
|
|
130
191
|
return ServerList(
|
|
@@ -143,7 +204,7 @@ class DatabaseService:
|
|
|
143
204
|
with engine.connect() as connection:
|
|
144
205
|
return extras_for(configured.engine).list_databases(connection)
|
|
145
206
|
|
|
146
|
-
names = await self._run(server, None, operation)
|
|
207
|
+
names = await self._run(server, None, "list_databases", operation)
|
|
147
208
|
return DatabaseList(
|
|
148
209
|
databases=[
|
|
149
210
|
DatabaseSummary(name=name)
|
|
@@ -165,7 +226,7 @@ class DatabaseService:
|
|
|
165
226
|
with engine.connect() as connection:
|
|
166
227
|
return reflect_tables(connection)
|
|
167
228
|
|
|
168
|
-
tables = await self._run(server, database, operation)
|
|
229
|
+
tables = await self._run(server, database, "list_tables", operation)
|
|
169
230
|
selected = [
|
|
170
231
|
table
|
|
171
232
|
for table in tables
|
|
@@ -204,7 +265,7 @@ class DatabaseService:
|
|
|
204
265
|
selected = self._resolve_table(reflect_tables(connection), table, schema)
|
|
205
266
|
return reflect_table_definition(connection, selected.schema_, selected.name)
|
|
206
267
|
|
|
207
|
-
return await self._run(server, database, operation)
|
|
268
|
+
return await self._run(server, database, "get_table_definition", operation)
|
|
208
269
|
|
|
209
270
|
async def list_stored_procedures(
|
|
210
271
|
self,
|
|
@@ -219,7 +280,7 @@ class DatabaseService:
|
|
|
219
280
|
with engine.connect() as connection:
|
|
220
281
|
return extras_for(configured.engine).list_stored_procedures(connection, database)
|
|
221
282
|
|
|
222
|
-
procedures = await self._run(server, database, operation)
|
|
283
|
+
procedures = await self._run(server, database, "list_stored_procedures", operation)
|
|
223
284
|
selected = [
|
|
224
285
|
item
|
|
225
286
|
for item in procedures
|
|
@@ -258,7 +319,7 @@ class DatabaseService:
|
|
|
258
319
|
connection, database, selected.schema_, selected.name
|
|
259
320
|
)
|
|
260
321
|
|
|
261
|
-
result = await self._run(server, database, operation)
|
|
322
|
+
result = await self._run(server, database, "get_stored_procedure", operation)
|
|
262
323
|
if result is None:
|
|
263
324
|
raise DomainError(ErrorCode.NOT_FOUND, f"Stored procedure {name!r} was not found.")
|
|
264
325
|
if (
|
|
@@ -322,4 +383,4 @@ class DatabaseService:
|
|
|
322
383
|
truncated=executed.truncated,
|
|
323
384
|
)
|
|
324
385
|
|
|
325
|
-
return await self._run(server, database, operation)
|
|
386
|
+
return await self._run(server, database, "execute_sql", operation)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|