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.
Files changed (33) hide show
  1. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/CHANGELOG.md +15 -0
  2. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/PKG-INFO +38 -4
  3. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/README.md +37 -3
  4. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/pyproject.toml +1 -1
  5. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/server.json +2 -2
  6. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/sql-safe-mcp.example.yaml +3 -0
  7. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/__init__.py +1 -1
  8. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/config.py +7 -0
  9. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/registry.py +38 -1
  10. sql_safe_mcp-1.3.0/src/sql_safe_mcp/diagnostics.py +70 -0
  11. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/mcp_server.py +4 -1
  12. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/service.py +100 -39
  13. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/.gitignore +0 -0
  14. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/LICENSE +0 -0
  15. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/__main__.py +0 -0
  16. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/__init__.py +0 -0
  17. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/extras.py +0 -0
  18. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/mysql.py +0 -0
  19. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/reflection.py +0 -0
  20. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/db/sqlserver.py +0 -0
  21. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/errors.py +0 -0
  22. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/models.py +0 -0
  23. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/__init__.py +0 -0
  24. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/dialect.py +0 -0
  25. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/executor.py +0 -0
  26. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/lineage.py +0 -0
  27. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/parser.py +0 -0
  28. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/pipeline.py +0 -0
  29. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/policy.py +0 -0
  30. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/reasons.py +0 -0
  31. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/schema.py +0 -0
  32. {sql_safe_mcp-1.2.0 → sql_safe_mcp-1.3.0}/src/sql_safe_mcp/security/tokens.py +0 -0
  33. {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.2.0
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
  [![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
46
  [![Release](https://img.shields.io/github/v/release/proprock/sql-safe-mcp)](https://github.com/proprock/sql-safe-mcp/releases)
47
- [![PyPI Version](https://img.shields.io/pypi/v/sql-safe-mcp)](https://pypi.org/project/sql-safe-mcp/)
47
+ [![PyPI Version](https://img.shields.io/pypi/v/sql-safe-mcp?cacheSeconds=3600)](https://pypi.org/project/sql-safe-mcp/)
48
48
  [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
49
49
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
50
50
 
51
51
  [![Model Context Protocol compatible](https://img.shields.io/badge/Model_Context_Protocol-compatible-000000?logo=modelcontextprotocol&logoColor=white)](https://modelcontextprotocol.io)
52
- [![MCP Registry: io.github.proprock/sql-safe-mcp](https://img.shields.io/badge/MCP_Registry-io.github.proprock%2Fsql--mini--mcp-000000?logo=modelcontextprotocol&logoColor=white)](server.json)
52
+ [![MCP Registry: io.github.proprock/sql-safe-mcp](https://img.shields.io/badge/MCP_Registry-io.github.proprock%2Fsql--safe--mcp-000000?logo=modelcontextprotocol&logoColor=white)](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.2.0`.
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
  [![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
8
  [![Release](https://img.shields.io/github/v/release/proprock/sql-safe-mcp)](https://github.com/proprock/sql-safe-mcp/releases)
9
- [![PyPI Version](https://img.shields.io/pypi/v/sql-safe-mcp)](https://pypi.org/project/sql-safe-mcp/)
9
+ [![PyPI Version](https://img.shields.io/pypi/v/sql-safe-mcp?cacheSeconds=3600)](https://pypi.org/project/sql-safe-mcp/)
10
10
  [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
 
13
13
  [![Model Context Protocol compatible](https://img.shields.io/badge/Model_Context_Protocol-compatible-000000?logo=modelcontextprotocol&logoColor=white)](https://modelcontextprotocol.io)
14
- [![MCP Registry: io.github.proprock/sql-safe-mcp](https://img.shields.io/badge/MCP_Registry-io.github.proprock%2Fsql--mini--mcp-000000?logo=modelcontextprotocol&logoColor=white)](server.json)
14
+ [![MCP Registry: io.github.proprock/sql-safe-mcp](https://img.shields.io/badge/MCP_Registry-io.github.proprock%2Fsql--safe--mcp-000000?logo=modelcontextprotocol&logoColor=white)](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.2.0`.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-safe-mcp"
3
- version = "1.2.0"
3
+ version = "1.3.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.2.0",
10
+ "version": "1.3.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "sql-safe-mcp",
15
- "version": "1.2.0",
15
+ "version": "1.3.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -1,5 +1,8 @@
1
1
  version: 1
2
2
 
3
+ logging:
4
+ level: INFO
5
+
3
6
  runtime:
4
7
  max_concurrent_db_operations: 8
5
8
  engine_cache_size: 32
@@ -1,3 +1,3 @@
1
1
  """Minimal, PII-safe MCP server for relational databases."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.3.0"
@@ -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
- _, evicted = self._engines.popitem(last=False)
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.2.0",
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
- async def _run(self, alias: str, database: str | None, operation: Callable[[Engine], T]) -> T:
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
- return await self.registry.run(alias, database, operation)
145
+ result = await self.registry.run(alias, database, operation)
83
146
  except DomainError:
84
147
  raise
85
148
  except SQLAlchemyTimeoutError as exc:
86
- raise DomainError(
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 DomainError(
95
- ErrorCode.TIMEOUT, "The database operation timed out.", retryable=True
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 DomainError(
99
- ErrorCode.ACCESS_DENIED, "The database denied this operation."
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 DomainError(
105
- ErrorCode.CONNECTION_FAILED,
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
- error = DomainError.unexpected()
116
- logger.error("Database operation failed; reference=%s", error.correlation_id)
117
- raise error from exc
170
+ raise self._unexpected(
171
+ "Database operation failed", alias, database, operation_name, exc
172
+ ) from exc
118
173
  except SQLAlchemyError as exc:
119
- error = DomainError.unexpected()
120
- logger.error("SQLAlchemy operation failed; reference=%s", error.correlation_id)
121
- raise error from exc
174
+ raise self._unexpected(
175
+ "SQLAlchemy operation failed", alias, database, operation_name, exc
176
+ ) from exc
122
177
  except Exception as exc:
123
- error = DomainError.unexpected()
124
- logger.error(
125
- "Unexpected database operation failure; reference=%s", error.correlation_id
126
- )
127
- raise error from exc
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