sql-mini-mcp 0.9.1__py3-none-any.whl
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_mini_mcp/__init__.py +3 -0
- sql_mini_mcp/__main__.py +43 -0
- sql_mini_mcp/config.py +194 -0
- sql_mini_mcp/db/__init__.py +1 -0
- sql_mini_mcp/db/extras.py +31 -0
- sql_mini_mcp/db/reflection.py +127 -0
- sql_mini_mcp/db/registry.py +98 -0
- sql_mini_mcp/db/sqlserver.py +65 -0
- sql_mini_mcp/errors.py +49 -0
- sql_mini_mcp/mcp_server.py +127 -0
- sql_mini_mcp/models.py +101 -0
- sql_mini_mcp/service.py +246 -0
- sql_mini_mcp-0.9.1.dist-info/METADATA +130 -0
- sql_mini_mcp-0.9.1.dist-info/RECORD +17 -0
- sql_mini_mcp-0.9.1.dist-info/WHEEL +4 -0
- sql_mini_mcp-0.9.1.dist-info/entry_points.txt +2 -0
- sql_mini_mcp-0.9.1.dist-info/licenses/LICENSE +21 -0
sql_mini_mcp/__init__.py
ADDED
sql_mini_mcp/__main__.py
ADDED
|
@@ -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()
|
sql_mini_mcp/config.py
ADDED
|
@@ -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."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import Connection
|
|
6
|
+
|
|
7
|
+
from sql_mini_mcp.models import StoredProcedureDefinition, StoredProcedureSummary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DatabaseExtras(Protocol):
|
|
11
|
+
def list_databases(self, connection: Connection) -> list[str]: ...
|
|
12
|
+
|
|
13
|
+
def list_stored_procedures(
|
|
14
|
+
self, connection: Connection, database: str
|
|
15
|
+
) -> list[StoredProcedureSummary]: ...
|
|
16
|
+
|
|
17
|
+
def get_stored_procedure(
|
|
18
|
+
self,
|
|
19
|
+
connection: Connection,
|
|
20
|
+
database: str,
|
|
21
|
+
schema: str | None,
|
|
22
|
+
name: str,
|
|
23
|
+
) -> StoredProcedureDefinition | None: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extras_for(engine: str) -> DatabaseExtras:
|
|
27
|
+
if engine == "sqlserver":
|
|
28
|
+
from sql_mini_mcp.db.sqlserver import SqlServerExtras
|
|
29
|
+
|
|
30
|
+
return SqlServerExtras()
|
|
31
|
+
raise ValueError(f"unsupported engine {engine!r}")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Connection, inspect
|
|
7
|
+
|
|
8
|
+
from sql_mini_mcp.models import (
|
|
9
|
+
ColumnDefinition,
|
|
10
|
+
ForeignKeyDefinition,
|
|
11
|
+
IndexDefinition,
|
|
12
|
+
PrimaryKeyDefinition,
|
|
13
|
+
TableDefinition,
|
|
14
|
+
TableSummary,
|
|
15
|
+
UniqueConstraintDefinition,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_SQLSERVER_SYSTEM_SCHEMAS = {
|
|
19
|
+
"db_accessadmin",
|
|
20
|
+
"db_backupoperator",
|
|
21
|
+
"db_datareader",
|
|
22
|
+
"db_datawriter",
|
|
23
|
+
"db_ddladmin",
|
|
24
|
+
"db_denydatareader",
|
|
25
|
+
"db_denydatawriter",
|
|
26
|
+
"db_owner",
|
|
27
|
+
"db_securityadmin",
|
|
28
|
+
"guest",
|
|
29
|
+
"information_schema",
|
|
30
|
+
"sys",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def list_tables(connection: Connection) -> list[TableSummary]:
|
|
35
|
+
inspector = inspect(connection)
|
|
36
|
+
tables: list[TableSummary] = []
|
|
37
|
+
for schema in inspector.get_schema_names():
|
|
38
|
+
if schema.casefold() in _SQLSERVER_SYSTEM_SCHEMAS:
|
|
39
|
+
continue
|
|
40
|
+
tables.extend(
|
|
41
|
+
TableSummary(schema_=schema, name=name)
|
|
42
|
+
for name in inspector.get_table_names(schema=schema)
|
|
43
|
+
)
|
|
44
|
+
return tables
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _dict_or_none(value: Any) -> dict[str, Any] | None:
|
|
48
|
+
return dict(value) if isinstance(value, dict) else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalize_columns(
|
|
52
|
+
connection: Connection, columns: Iterable[Mapping[str, Any]]
|
|
53
|
+
) -> list[ColumnDefinition]:
|
|
54
|
+
result: list[ColumnDefinition] = []
|
|
55
|
+
for column in columns:
|
|
56
|
+
type_ = column["type"]
|
|
57
|
+
native_type = str(type_.compile(dialect=connection.dialect))
|
|
58
|
+
result.append(
|
|
59
|
+
ColumnDefinition(
|
|
60
|
+
name=str(column["name"]),
|
|
61
|
+
native_type=native_type,
|
|
62
|
+
nullable=bool(column.get("nullable", True)),
|
|
63
|
+
length=getattr(type_, "length", None),
|
|
64
|
+
precision=getattr(type_, "precision", None),
|
|
65
|
+
scale=getattr(type_, "scale", None),
|
|
66
|
+
autoincrement=column.get("autoincrement"),
|
|
67
|
+
identity=_dict_or_none(column.get("identity")),
|
|
68
|
+
computed=_dict_or_none(column.get("computed")),
|
|
69
|
+
default=None if column.get("default") is None else str(column["default"]),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_table_definition(
|
|
76
|
+
connection: Connection,
|
|
77
|
+
schema: str | None,
|
|
78
|
+
table: str,
|
|
79
|
+
) -> TableDefinition:
|
|
80
|
+
inspector = inspect(connection)
|
|
81
|
+
primary_key = inspector.get_pk_constraint(table, schema=schema)
|
|
82
|
+
foreign_keys = inspector.get_foreign_keys(table, schema=schema)
|
|
83
|
+
indexes = inspector.get_indexes(table, schema=schema)
|
|
84
|
+
try:
|
|
85
|
+
unique_constraints = inspector.get_unique_constraints(table, schema=schema)
|
|
86
|
+
except NotImplementedError:
|
|
87
|
+
unique_constraints = [
|
|
88
|
+
{"name": item.get("name"), "column_names": item.get("column_names")}
|
|
89
|
+
for item in indexes
|
|
90
|
+
if item.get("unique")
|
|
91
|
+
]
|
|
92
|
+
return TableDefinition(
|
|
93
|
+
schema_=schema,
|
|
94
|
+
name=table,
|
|
95
|
+
columns=_normalize_columns(connection, inspector.get_columns(table, schema=schema)),
|
|
96
|
+
primary_key=PrimaryKeyDefinition(
|
|
97
|
+
name=primary_key.get("name"),
|
|
98
|
+
columns=[str(name) for name in primary_key.get("constrained_columns") or []],
|
|
99
|
+
),
|
|
100
|
+
foreign_keys=[
|
|
101
|
+
ForeignKeyDefinition(
|
|
102
|
+
name=item.get("name"),
|
|
103
|
+
columns=[str(name) for name in item.get("constrained_columns") or []],
|
|
104
|
+
referred_schema=item.get("referred_schema"),
|
|
105
|
+
referred_table=str(item["referred_table"]),
|
|
106
|
+
referred_columns=[str(name) for name in item.get("referred_columns") or []],
|
|
107
|
+
options=dict(item.get("options") or {}),
|
|
108
|
+
)
|
|
109
|
+
for item in foreign_keys
|
|
110
|
+
],
|
|
111
|
+
unique_constraints=[
|
|
112
|
+
UniqueConstraintDefinition(
|
|
113
|
+
name=None if item.get("name") is None else str(item["name"]),
|
|
114
|
+
columns=[str(name) for name in item.get("column_names") or []],
|
|
115
|
+
)
|
|
116
|
+
for item in unique_constraints
|
|
117
|
+
],
|
|
118
|
+
indexes=[
|
|
119
|
+
IndexDefinition(
|
|
120
|
+
name=item.get("name"),
|
|
121
|
+
columns=list(item.get("column_names") or []),
|
|
122
|
+
unique=bool(item.get("unique", False)),
|
|
123
|
+
expressions=[str(value) for value in item.get("expressions") or []],
|
|
124
|
+
)
|
|
125
|
+
for item in indexes
|
|
126
|
+
],
|
|
127
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from threading import RLock
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
from sqlalchemy import Engine, create_engine, event
|
|
10
|
+
|
|
11
|
+
from sql_mini_mcp.config import AppConfig
|
|
12
|
+
from sql_mini_mcp.errors import DomainError, ErrorCode
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EngineRegistry:
|
|
18
|
+
"""Thread-safe lazy LRU of SQLAlchemy engines."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: AppConfig) -> None:
|
|
21
|
+
self._config = config
|
|
22
|
+
self._engines: OrderedDict[tuple[str, str | None], Engine] = OrderedDict()
|
|
23
|
+
self._lock = RLock()
|
|
24
|
+
self._limiter = anyio.CapacityLimiter(config.runtime.max_concurrent_db_operations)
|
|
25
|
+
|
|
26
|
+
def _create_engine(self, alias: str, database: str | None) -> Engine:
|
|
27
|
+
server = self._config.servers[alias]
|
|
28
|
+
url = server.connection_url.get_secret_value()
|
|
29
|
+
if database is not None:
|
|
30
|
+
from sqlalchemy.engine import make_url
|
|
31
|
+
|
|
32
|
+
url = make_url(url).set(database=database)
|
|
33
|
+
engine = create_engine(
|
|
34
|
+
url,
|
|
35
|
+
pool_size=self._config.runtime.pool_size,
|
|
36
|
+
max_overflow=self._config.runtime.max_overflow,
|
|
37
|
+
pool_timeout=self._config.runtime.pool_timeout_seconds,
|
|
38
|
+
pool_pre_ping=True,
|
|
39
|
+
pool_use_lifo=True,
|
|
40
|
+
)
|
|
41
|
+
timeout = self._config.runtime.statement_timeout_seconds
|
|
42
|
+
|
|
43
|
+
@event.listens_for(engine, "connect")
|
|
44
|
+
def set_connection_timeout(dbapi_connection: Any, _record: Any) -> None:
|
|
45
|
+
if hasattr(dbapi_connection, "timeout"):
|
|
46
|
+
dbapi_connection.timeout = timeout
|
|
47
|
+
|
|
48
|
+
@event.listens_for(engine, "before_cursor_execute")
|
|
49
|
+
def set_statement_timeout(
|
|
50
|
+
_connection: Any,
|
|
51
|
+
cursor: Any,
|
|
52
|
+
_statement: str,
|
|
53
|
+
_parameters: Any,
|
|
54
|
+
_context: Any,
|
|
55
|
+
_executemany: bool,
|
|
56
|
+
) -> None:
|
|
57
|
+
if hasattr(cursor, "timeout"):
|
|
58
|
+
cursor.timeout = timeout
|
|
59
|
+
|
|
60
|
+
return engine
|
|
61
|
+
|
|
62
|
+
def get(self, alias: str, database: str | None = None) -> Engine:
|
|
63
|
+
if alias not in self._config.servers:
|
|
64
|
+
raise DomainError(ErrorCode.UNKNOWN_SERVER, f"Unknown configured server {alias!r}.")
|
|
65
|
+
key = (alias, database)
|
|
66
|
+
evicted: Engine | None = None
|
|
67
|
+
with self._lock:
|
|
68
|
+
if engine := self._engines.get(key):
|
|
69
|
+
self._engines.move_to_end(key)
|
|
70
|
+
return engine
|
|
71
|
+
engine = self._create_engine(alias, database)
|
|
72
|
+
self._engines[key] = engine
|
|
73
|
+
if len(self._engines) > self._config.runtime.engine_cache_size:
|
|
74
|
+
_, evicted = self._engines.popitem(last=False)
|
|
75
|
+
if evicted is not None:
|
|
76
|
+
evicted.dispose()
|
|
77
|
+
return engine
|
|
78
|
+
|
|
79
|
+
async def run(
|
|
80
|
+
self,
|
|
81
|
+
alias: str,
|
|
82
|
+
database: str | None,
|
|
83
|
+
operation: Callable[[Engine], T],
|
|
84
|
+
) -> T:
|
|
85
|
+
engine = self.get(alias, database)
|
|
86
|
+
return await anyio.to_thread.run_sync(operation, engine, limiter=self._limiter)
|
|
87
|
+
|
|
88
|
+
def dispose(self) -> None:
|
|
89
|
+
with self._lock:
|
|
90
|
+
engines = list(self._engines.values())
|
|
91
|
+
self._engines.clear()
|
|
92
|
+
for engine in engines:
|
|
93
|
+
engine.dispose()
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def cached_engine_count(self) -> int:
|
|
97
|
+
with self._lock:
|
|
98
|
+
return len(self._engines)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Connection, text
|
|
4
|
+
|
|
5
|
+
from sql_mini_mcp.db.extras import DatabaseExtras
|
|
6
|
+
from sql_mini_mcp.models import StoredProcedureDefinition, StoredProcedureSummary
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SqlServerExtras(DatabaseExtras):
|
|
10
|
+
def list_databases(self, connection: Connection) -> list[str]:
|
|
11
|
+
rows = connection.execute(
|
|
12
|
+
text(
|
|
13
|
+
"SELECT name FROM sys.databases "
|
|
14
|
+
"WHERE state = 0 AND HAS_DBACCESS(name) = 1 ORDER BY name"
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
return [str(row.name) for row in rows]
|
|
18
|
+
|
|
19
|
+
def list_stored_procedures(
|
|
20
|
+
self, connection: Connection, database: str
|
|
21
|
+
) -> list[StoredProcedureSummary]:
|
|
22
|
+
del database
|
|
23
|
+
rows = connection.execute(
|
|
24
|
+
text(
|
|
25
|
+
"SELECT s.name AS schema_name, p.name AS procedure_name "
|
|
26
|
+
"FROM sys.procedures AS p "
|
|
27
|
+
"JOIN sys.schemas AS s ON s.schema_id = p.schema_id "
|
|
28
|
+
"WHERE p.is_ms_shipped = 0 "
|
|
29
|
+
"ORDER BY s.name, p.name"
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return [
|
|
33
|
+
StoredProcedureSummary(schema_=str(row.schema_name), name=str(row.procedure_name))
|
|
34
|
+
for row in rows
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def get_stored_procedure(
|
|
38
|
+
self,
|
|
39
|
+
connection: Connection,
|
|
40
|
+
database: str,
|
|
41
|
+
schema: str | None,
|
|
42
|
+
name: str,
|
|
43
|
+
) -> StoredProcedureDefinition | None:
|
|
44
|
+
del database
|
|
45
|
+
if schema is None:
|
|
46
|
+
return None
|
|
47
|
+
row = connection.execute(
|
|
48
|
+
text(
|
|
49
|
+
"SELECT s.name AS schema_name, p.name AS procedure_name, "
|
|
50
|
+
"OBJECT_DEFINITION(p.object_id) AS definition "
|
|
51
|
+
"FROM sys.procedures AS p "
|
|
52
|
+
"JOIN sys.schemas AS s ON s.schema_id = p.schema_id "
|
|
53
|
+
"WHERE s.name = :schema AND p.name = :name AND p.is_ms_shipped = 0"
|
|
54
|
+
),
|
|
55
|
+
{"schema": schema, "name": name},
|
|
56
|
+
).first()
|
|
57
|
+
if row is None:
|
|
58
|
+
return None
|
|
59
|
+
definition = None if row.definition is None else str(row.definition)
|
|
60
|
+
return StoredProcedureDefinition(
|
|
61
|
+
schema_=str(row.schema_name),
|
|
62
|
+
name=str(row.procedure_name),
|
|
63
|
+
definition=definition,
|
|
64
|
+
definition_available=definition is not None,
|
|
65
|
+
)
|
sql_mini_mcp/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorCode(StrEnum):
|
|
9
|
+
INVALID_ARGUMENT = "INVALID_ARGUMENT"
|
|
10
|
+
UNKNOWN_SERVER = "UNKNOWN_SERVER"
|
|
11
|
+
NOT_FOUND = "NOT_FOUND"
|
|
12
|
+
AMBIGUOUS_OBJECT = "AMBIGUOUS_OBJECT"
|
|
13
|
+
ACCESS_LEVEL_DENIED = "ACCESS_LEVEL_DENIED"
|
|
14
|
+
CONFIG_ERROR = "CONFIG_ERROR"
|
|
15
|
+
CONNECTION_FAILED = "CONNECTION_FAILED"
|
|
16
|
+
ACCESS_DENIED = "ACCESS_DENIED"
|
|
17
|
+
TIMEOUT = "TIMEOUT"
|
|
18
|
+
METADATA_UNAVAILABLE = "METADATA_UNAVAILABLE"
|
|
19
|
+
DEFINITION_TOO_LARGE = "DEFINITION_TOO_LARGE"
|
|
20
|
+
QUERY_REJECTED = "QUERY_REJECTED"
|
|
21
|
+
INVALID_PII_TOKEN = "INVALID_PII_TOKEN"
|
|
22
|
+
RESULT_LIMIT_EXCEEDED = "RESULT_LIMIT_EXCEEDED"
|
|
23
|
+
DATABASE_ERROR = "DATABASE_ERROR"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class DomainError(Exception):
|
|
28
|
+
code: ErrorCode
|
|
29
|
+
public_message: str
|
|
30
|
+
hint: str | None = None
|
|
31
|
+
retryable: bool = False
|
|
32
|
+
correlation_id: str | None = None
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
parts = [f"[{self.code}] {self.public_message}"]
|
|
36
|
+
if self.hint:
|
|
37
|
+
parts.append(f"Hint: {self.hint}")
|
|
38
|
+
if self.correlation_id:
|
|
39
|
+
parts.append(f"Reference: {self.correlation_id}")
|
|
40
|
+
return " ".join(parts)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def unexpected(cls) -> DomainError:
|
|
44
|
+
return cls(
|
|
45
|
+
ErrorCode.DATABASE_ERROR,
|
|
46
|
+
"The database operation failed unexpectedly.",
|
|
47
|
+
retryable=False,
|
|
48
|
+
correlation_id=uuid4().hex,
|
|
49
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from mcp.server.mcpserver import Context, MCPServer
|
|
8
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
9
|
+
from mcp_types import ToolAnnotations
|
|
10
|
+
|
|
11
|
+
from sql_mini_mcp.config import AppConfig
|
|
12
|
+
from sql_mini_mcp.db.registry import EngineRegistry
|
|
13
|
+
from sql_mini_mcp.errors import DomainError
|
|
14
|
+
from sql_mini_mcp.models import (
|
|
15
|
+
DatabaseList,
|
|
16
|
+
ServerList,
|
|
17
|
+
StoredProcedureDefinition,
|
|
18
|
+
StoredProcedureList,
|
|
19
|
+
TableDefinition,
|
|
20
|
+
TableList,
|
|
21
|
+
)
|
|
22
|
+
from sql_mini_mcp.service import DatabaseService
|
|
23
|
+
|
|
24
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class AppContext:
|
|
29
|
+
service: DatabaseService
|
|
30
|
+
registry: EngineRegistry
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _domain_call[T](operation: Callable[[], Awaitable[T]]) -> T:
|
|
34
|
+
try:
|
|
35
|
+
return await operation()
|
|
36
|
+
except DomainError as exc:
|
|
37
|
+
raise ToolError(str(exc)) from exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_server(config: AppConfig) -> MCPServer[AppContext]:
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def lifespan(_server: MCPServer[AppContext]) -> AsyncIterator[AppContext]:
|
|
43
|
+
registry = EngineRegistry(config)
|
|
44
|
+
try:
|
|
45
|
+
yield AppContext(service=DatabaseService(config, registry), registry=registry)
|
|
46
|
+
finally:
|
|
47
|
+
registry.dispose()
|
|
48
|
+
|
|
49
|
+
server: MCPServer[AppContext] = MCPServer(
|
|
50
|
+
"sql-mini-mcp",
|
|
51
|
+
description="Minimal, read-only, PII-safe SQL database navigation.",
|
|
52
|
+
version="0.9.1",
|
|
53
|
+
lifespan=lifespan,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@server.tool(annotations=READ_ONLY)
|
|
57
|
+
async def list_servers(ctx: Context[AppContext]) -> ServerList:
|
|
58
|
+
"""List explicitly configured database server aliases."""
|
|
59
|
+
return ctx.request_context.lifespan_context.service.list_servers()
|
|
60
|
+
|
|
61
|
+
@server.tool(annotations=READ_ONLY)
|
|
62
|
+
async def list_databases(
|
|
63
|
+
server: str,
|
|
64
|
+
ctx: Context[AppContext],
|
|
65
|
+
name_contains: str | None = None,
|
|
66
|
+
) -> DatabaseList:
|
|
67
|
+
"""List databases visible to the configured credentials."""
|
|
68
|
+
service = ctx.request_context.lifespan_context.service
|
|
69
|
+
return await _domain_call(lambda: service.list_databases(server, name_contains))
|
|
70
|
+
|
|
71
|
+
@server.tool(annotations=READ_ONLY)
|
|
72
|
+
async def list_tables(
|
|
73
|
+
server: str,
|
|
74
|
+
database: str,
|
|
75
|
+
ctx: Context[AppContext],
|
|
76
|
+
schema: str | None = None,
|
|
77
|
+
name_contains: str | None = None,
|
|
78
|
+
) -> TableList:
|
|
79
|
+
"""List base tables, optionally filtering by schema and a name substring."""
|
|
80
|
+
service = ctx.request_context.lifespan_context.service
|
|
81
|
+
return await _domain_call(
|
|
82
|
+
lambda: service.list_tables(server, database, schema, name_contains)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@server.tool(annotations=READ_ONLY)
|
|
86
|
+
async def get_table_definition(
|
|
87
|
+
server: str,
|
|
88
|
+
database: str,
|
|
89
|
+
table: str,
|
|
90
|
+
ctx: Context[AppContext],
|
|
91
|
+
schema: str | None = None,
|
|
92
|
+
) -> TableDefinition:
|
|
93
|
+
"""Get columns, keys, constraints, and indexes for one table."""
|
|
94
|
+
service = ctx.request_context.lifespan_context.service
|
|
95
|
+
return await _domain_call(
|
|
96
|
+
lambda: service.get_table_definition(server, database, table, schema)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@server.tool(annotations=READ_ONLY)
|
|
100
|
+
async def list_stored_procedures(
|
|
101
|
+
server: str,
|
|
102
|
+
database: str,
|
|
103
|
+
ctx: Context[AppContext],
|
|
104
|
+
schema: str | None = None,
|
|
105
|
+
name_contains: str | None = None,
|
|
106
|
+
) -> StoredProcedureList:
|
|
107
|
+
"""List stored procedures without expanding their definitions."""
|
|
108
|
+
service = ctx.request_context.lifespan_context.service
|
|
109
|
+
return await _domain_call(
|
|
110
|
+
lambda: service.list_stored_procedures(server, database, schema, name_contains)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@server.tool(annotations=READ_ONLY)
|
|
114
|
+
async def get_stored_procedure(
|
|
115
|
+
server: str,
|
|
116
|
+
database: str,
|
|
117
|
+
name: str,
|
|
118
|
+
ctx: Context[AppContext],
|
|
119
|
+
schema: str | None = None,
|
|
120
|
+
) -> StoredProcedureDefinition:
|
|
121
|
+
"""Get the original definition of one stored procedure when visible."""
|
|
122
|
+
service = ctx.request_context.lifespan_context.service
|
|
123
|
+
return await _domain_call(
|
|
124
|
+
lambda: service.get_stored_procedure(server, database, name, schema)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return server
|
sql_mini_mcp/models.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OutputModel(BaseModel):
|
|
9
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServerSummary(OutputModel):
|
|
13
|
+
name: str
|
|
14
|
+
engine: Literal["sqlserver"]
|
|
15
|
+
access_level: Literal["metadata", "pii_safe"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ServerList(OutputModel):
|
|
19
|
+
servers: list[ServerSummary]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DatabaseSummary(OutputModel):
|
|
23
|
+
name: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DatabaseList(OutputModel):
|
|
27
|
+
databases: list[DatabaseSummary]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TableSummary(OutputModel):
|
|
31
|
+
schema_: str | None = Field(alias="schema")
|
|
32
|
+
name: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TableList(OutputModel):
|
|
36
|
+
tables: list[TableSummary]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ColumnDefinition(OutputModel):
|
|
40
|
+
name: str
|
|
41
|
+
native_type: str
|
|
42
|
+
nullable: bool
|
|
43
|
+
length: int | None = None
|
|
44
|
+
precision: int | None = None
|
|
45
|
+
scale: int | None = None
|
|
46
|
+
autoincrement: bool | str | None = None
|
|
47
|
+
identity: dict[str, Any] | None = None
|
|
48
|
+
computed: dict[str, Any] | None = None
|
|
49
|
+
default: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PrimaryKeyDefinition(OutputModel):
|
|
53
|
+
name: str | None = None
|
|
54
|
+
columns: list[str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ForeignKeyDefinition(OutputModel):
|
|
58
|
+
name: str | None = None
|
|
59
|
+
columns: list[str]
|
|
60
|
+
referred_schema: str | None = None
|
|
61
|
+
referred_table: str
|
|
62
|
+
referred_columns: list[str]
|
|
63
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class UniqueConstraintDefinition(OutputModel):
|
|
67
|
+
name: str | None = None
|
|
68
|
+
columns: list[str]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class IndexDefinition(OutputModel):
|
|
72
|
+
name: str | None = None
|
|
73
|
+
columns: list[str | None]
|
|
74
|
+
unique: bool = False
|
|
75
|
+
expressions: list[str] = Field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TableDefinition(OutputModel):
|
|
79
|
+
schema_: str | None = Field(alias="schema")
|
|
80
|
+
name: str
|
|
81
|
+
columns: list[ColumnDefinition]
|
|
82
|
+
primary_key: PrimaryKeyDefinition
|
|
83
|
+
foreign_keys: list[ForeignKeyDefinition]
|
|
84
|
+
unique_constraints: list[UniqueConstraintDefinition]
|
|
85
|
+
indexes: list[IndexDefinition]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class StoredProcedureSummary(OutputModel):
|
|
89
|
+
schema_: str | None = Field(alias="schema")
|
|
90
|
+
name: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class StoredProcedureList(OutputModel):
|
|
94
|
+
stored_procedures: list[StoredProcedureSummary]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class StoredProcedureDefinition(OutputModel):
|
|
98
|
+
schema_: str | None = Field(alias="schema")
|
|
99
|
+
name: str
|
|
100
|
+
definition: str | None
|
|
101
|
+
definition_available: bool
|
sql_mini_mcp/service.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Engine
|
|
8
|
+
from sqlalchemy.exc import DBAPIError, OperationalError, SQLAlchemyError
|
|
9
|
+
from sqlalchemy.exc import TimeoutError as SQLAlchemyTimeoutError
|
|
10
|
+
|
|
11
|
+
from sql_mini_mcp.config import AppConfig, ServerConfig
|
|
12
|
+
from sql_mini_mcp.db.extras import extras_for
|
|
13
|
+
from sql_mini_mcp.db.reflection import get_table_definition as reflect_table_definition
|
|
14
|
+
from sql_mini_mcp.db.reflection import list_tables as reflect_tables
|
|
15
|
+
from sql_mini_mcp.db.registry import EngineRegistry
|
|
16
|
+
from sql_mini_mcp.errors import DomainError, ErrorCode
|
|
17
|
+
from sql_mini_mcp.models import (
|
|
18
|
+
DatabaseList,
|
|
19
|
+
DatabaseSummary,
|
|
20
|
+
ServerList,
|
|
21
|
+
ServerSummary,
|
|
22
|
+
StoredProcedureDefinition,
|
|
23
|
+
StoredProcedureList,
|
|
24
|
+
StoredProcedureSummary,
|
|
25
|
+
TableDefinition,
|
|
26
|
+
TableList,
|
|
27
|
+
TableSummary,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _contains(value: str, needle: str | None) -> bool:
|
|
35
|
+
return needle is None or needle.casefold() in value.casefold()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DatabaseService:
|
|
39
|
+
def __init__(self, config: AppConfig, registry: EngineRegistry) -> None:
|
|
40
|
+
self.config = config
|
|
41
|
+
self.registry = registry
|
|
42
|
+
|
|
43
|
+
def _server(self, alias: str) -> ServerConfig:
|
|
44
|
+
try:
|
|
45
|
+
return self.config.servers[alias]
|
|
46
|
+
except KeyError as exc:
|
|
47
|
+
raise DomainError(
|
|
48
|
+
ErrorCode.UNKNOWN_SERVER,
|
|
49
|
+
f"Unknown configured server {alias!r}.",
|
|
50
|
+
"Call list_servers to discover configured aliases.",
|
|
51
|
+
) from exc
|
|
52
|
+
|
|
53
|
+
async def _run(self, alias: str, database: str | None, operation: Callable[[Engine], T]) -> T:
|
|
54
|
+
self._server(alias)
|
|
55
|
+
try:
|
|
56
|
+
return await self.registry.run(alias, database, operation)
|
|
57
|
+
except DomainError:
|
|
58
|
+
raise
|
|
59
|
+
except SQLAlchemyTimeoutError as exc:
|
|
60
|
+
raise DomainError(
|
|
61
|
+
ErrorCode.TIMEOUT,
|
|
62
|
+
"The database operation timed out.",
|
|
63
|
+
retryable=True,
|
|
64
|
+
) from exc
|
|
65
|
+
except DBAPIError as exc:
|
|
66
|
+
message = str(exc.orig).casefold()
|
|
67
|
+
if "timeout" in message or "hyt00" in message or "hyt01" in message:
|
|
68
|
+
raise DomainError(
|
|
69
|
+
ErrorCode.TIMEOUT, "The database operation timed out.", retryable=True
|
|
70
|
+
) from exc
|
|
71
|
+
if any(value in message for value in ("permission", "denied", "not authorized")):
|
|
72
|
+
raise DomainError(
|
|
73
|
+
ErrorCode.ACCESS_DENIED, "The database denied this operation."
|
|
74
|
+
) from exc
|
|
75
|
+
if any(
|
|
76
|
+
value in message for value in ("cannot open database", "(4060)", "08001", "08004")
|
|
77
|
+
):
|
|
78
|
+
raise DomainError(
|
|
79
|
+
ErrorCode.CONNECTION_FAILED,
|
|
80
|
+
"Could not connect to the configured database server.",
|
|
81
|
+
retryable=True,
|
|
82
|
+
) from exc
|
|
83
|
+
if isinstance(exc, OperationalError):
|
|
84
|
+
raise DomainError(
|
|
85
|
+
ErrorCode.CONNECTION_FAILED,
|
|
86
|
+
"Could not connect to the configured database server.",
|
|
87
|
+
retryable=True,
|
|
88
|
+
) from exc
|
|
89
|
+
error = DomainError.unexpected()
|
|
90
|
+
logger.error("Database operation failed; reference=%s", error.correlation_id)
|
|
91
|
+
raise error from exc
|
|
92
|
+
except SQLAlchemyError as exc:
|
|
93
|
+
error = DomainError.unexpected()
|
|
94
|
+
logger.error("SQLAlchemy operation failed; reference=%s", error.correlation_id)
|
|
95
|
+
raise error from exc
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
error = DomainError.unexpected()
|
|
98
|
+
logger.error(
|
|
99
|
+
"Unexpected database operation failure; reference=%s", error.correlation_id
|
|
100
|
+
)
|
|
101
|
+
raise error from exc
|
|
102
|
+
|
|
103
|
+
def list_servers(self) -> ServerList:
|
|
104
|
+
return ServerList(
|
|
105
|
+
servers=[
|
|
106
|
+
ServerSummary(name=alias, engine=server.engine, access_level=server.access_level)
|
|
107
|
+
for alias, server in sorted(
|
|
108
|
+
self.config.servers.items(), key=lambda item: item[0].casefold()
|
|
109
|
+
)
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def list_databases(self, server: str, name_contains: str | None = None) -> DatabaseList:
|
|
114
|
+
configured = self._server(server)
|
|
115
|
+
|
|
116
|
+
def operation(engine: Engine) -> list[str]:
|
|
117
|
+
with engine.connect() as connection:
|
|
118
|
+
return extras_for(configured.engine).list_databases(connection)
|
|
119
|
+
|
|
120
|
+
names = await self._run(server, None, operation)
|
|
121
|
+
return DatabaseList(
|
|
122
|
+
databases=[
|
|
123
|
+
DatabaseSummary(name=name)
|
|
124
|
+
for name in sorted(names, key=str.casefold)
|
|
125
|
+
if _contains(name, name_contains)
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def list_tables(
|
|
130
|
+
self,
|
|
131
|
+
server: str,
|
|
132
|
+
database: str,
|
|
133
|
+
schema: str | None = None,
|
|
134
|
+
name_contains: str | None = None,
|
|
135
|
+
) -> TableList:
|
|
136
|
+
self._server(server)
|
|
137
|
+
|
|
138
|
+
def operation(engine: Engine) -> list[TableSummary]:
|
|
139
|
+
with engine.connect() as connection:
|
|
140
|
+
return reflect_tables(connection)
|
|
141
|
+
|
|
142
|
+
tables = await self._run(server, database, operation)
|
|
143
|
+
selected = [
|
|
144
|
+
table
|
|
145
|
+
for table in tables
|
|
146
|
+
if (schema is None or (table.schema_ or "").casefold() == schema.casefold())
|
|
147
|
+
and _contains(table.name, name_contains)
|
|
148
|
+
]
|
|
149
|
+
selected.sort(key=lambda table: ((table.schema_ or "").casefold(), table.name.casefold()))
|
|
150
|
+
return TableList(tables=selected)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _resolve_table(tables: list[TableSummary], table: str, schema: str | None) -> TableSummary:
|
|
154
|
+
matches = [
|
|
155
|
+
item
|
|
156
|
+
for item in tables
|
|
157
|
+
if item.name.casefold() == table.casefold()
|
|
158
|
+
and (schema is None or (item.schema_ or "").casefold() == schema.casefold())
|
|
159
|
+
]
|
|
160
|
+
if not matches:
|
|
161
|
+
raise DomainError(ErrorCode.NOT_FOUND, f"Table {table!r} was not found.")
|
|
162
|
+
if len(matches) > 1:
|
|
163
|
+
candidates = ", ".join(f"{item.schema_}.{item.name}" for item in matches)
|
|
164
|
+
raise DomainError(
|
|
165
|
+
ErrorCode.AMBIGUOUS_OBJECT,
|
|
166
|
+
f"Table {table!r} is ambiguous.",
|
|
167
|
+
f"Specify schema; candidates: {candidates}.",
|
|
168
|
+
)
|
|
169
|
+
return matches[0]
|
|
170
|
+
|
|
171
|
+
async def get_table_definition(
|
|
172
|
+
self, server: str, database: str, table: str, schema: str | None = None
|
|
173
|
+
) -> TableDefinition:
|
|
174
|
+
self._server(server)
|
|
175
|
+
|
|
176
|
+
def operation(engine: Engine) -> TableDefinition:
|
|
177
|
+
with engine.connect() as connection:
|
|
178
|
+
selected = self._resolve_table(reflect_tables(connection), table, schema)
|
|
179
|
+
return reflect_table_definition(connection, selected.schema_, selected.name)
|
|
180
|
+
|
|
181
|
+
return await self._run(server, database, operation)
|
|
182
|
+
|
|
183
|
+
async def list_stored_procedures(
|
|
184
|
+
self,
|
|
185
|
+
server: str,
|
|
186
|
+
database: str,
|
|
187
|
+
schema: str | None = None,
|
|
188
|
+
name_contains: str | None = None,
|
|
189
|
+
) -> StoredProcedureList:
|
|
190
|
+
configured = self._server(server)
|
|
191
|
+
|
|
192
|
+
def operation(engine: Engine) -> list[StoredProcedureSummary]:
|
|
193
|
+
with engine.connect() as connection:
|
|
194
|
+
return extras_for(configured.engine).list_stored_procedures(connection, database)
|
|
195
|
+
|
|
196
|
+
procedures = await self._run(server, database, operation)
|
|
197
|
+
selected = [
|
|
198
|
+
item
|
|
199
|
+
for item in procedures
|
|
200
|
+
if (schema is None or (item.schema_ or "").casefold() == schema.casefold())
|
|
201
|
+
and _contains(item.name, name_contains)
|
|
202
|
+
]
|
|
203
|
+
selected.sort(key=lambda item: ((item.schema_ or "").casefold(), item.name.casefold()))
|
|
204
|
+
return StoredProcedureList(stored_procedures=selected)
|
|
205
|
+
|
|
206
|
+
async def get_stored_procedure(
|
|
207
|
+
self,
|
|
208
|
+
server: str,
|
|
209
|
+
database: str,
|
|
210
|
+
name: str,
|
|
211
|
+
schema: str | None = None,
|
|
212
|
+
) -> StoredProcedureDefinition:
|
|
213
|
+
configured = self._server(server)
|
|
214
|
+
listing = await self.list_stored_procedures(server, database, schema)
|
|
215
|
+
matches = [
|
|
216
|
+
item for item in listing.stored_procedures if item.name.casefold() == name.casefold()
|
|
217
|
+
]
|
|
218
|
+
if not matches:
|
|
219
|
+
raise DomainError(ErrorCode.NOT_FOUND, f"Stored procedure {name!r} was not found.")
|
|
220
|
+
if len(matches) > 1:
|
|
221
|
+
candidates = ", ".join(f"{item.schema_}.{item.name}" for item in matches)
|
|
222
|
+
raise DomainError(
|
|
223
|
+
ErrorCode.AMBIGUOUS_OBJECT,
|
|
224
|
+
f"Stored procedure {name!r} is ambiguous.",
|
|
225
|
+
f"Specify schema; candidates: {candidates}.",
|
|
226
|
+
)
|
|
227
|
+
selected = matches[0]
|
|
228
|
+
|
|
229
|
+
def operation(engine: Engine) -> StoredProcedureDefinition | None:
|
|
230
|
+
with engine.connect() as connection:
|
|
231
|
+
return extras_for(configured.engine).get_stored_procedure(
|
|
232
|
+
connection, database, selected.schema_, selected.name
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
result = await self._run(server, database, operation)
|
|
236
|
+
if result is None:
|
|
237
|
+
raise DomainError(ErrorCode.NOT_FOUND, f"Stored procedure {name!r} was not found.")
|
|
238
|
+
if (
|
|
239
|
+
result.definition is not None
|
|
240
|
+
and len(result.definition) > self.config.runtime.max_definition_chars
|
|
241
|
+
):
|
|
242
|
+
raise DomainError(
|
|
243
|
+
ErrorCode.DEFINITION_TOO_LARGE,
|
|
244
|
+
"Stored procedure definition exceeds the configured response limit.",
|
|
245
|
+
)
|
|
246
|
+
return result
|
|
@@ -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,17 @@
|
|
|
1
|
+
sql_mini_mcp/__init__.py,sha256=8CM4oqvSvhTvMBpyrk-u9hekO-_wnTCdXJUwnq9sMNQ,84
|
|
2
|
+
sql_mini_mcp/__main__.py,sha256=2SW1ssw2kdxbwsgwv38eutoVXtYfsArk2PgvViBLvws,1210
|
|
3
|
+
sql_mini_mcp/config.py,sha256=KYDiW2MR_xI28iay1w73cylnfxkE4R7Noy6dHA_5xZU,8031
|
|
4
|
+
sql_mini_mcp/errors.py,sha256=2V-gXe9j3zdlQQ0ryGEaHk9ET1BxMbWrc7DXkdqOd2w,1483
|
|
5
|
+
sql_mini_mcp/mcp_server.py,sha256=236wIfMI294vM68GaGIlPZec3cvt0hZNeiWt0MxulVo,4358
|
|
6
|
+
sql_mini_mcp/models.py,sha256=IHPm9gXIinHbXEhZSCLh3BoTB3Fk6-rdSUFrDOUNRWU,2348
|
|
7
|
+
sql_mini_mcp/service.py,sha256=6SMLgPds4mTe9bGuF54Bla4T1eB0M6Wlwfat8E6i3zk,9632
|
|
8
|
+
sql_mini_mcp/db/__init__.py,sha256=9OzZMXtRCRhrc9UW7wyP3WpHqYdARd-t9CpEgNJ7n-U,34
|
|
9
|
+
sql_mini_mcp/db/extras.py,sha256=WhawJs8czPfm7EO5-t8kNGPijIVs8qBjlh-cUuTYVso,839
|
|
10
|
+
sql_mini_mcp/db/reflection.py,sha256=7UdtSglUZ8duvMpRCEY6061ZtR41BrdhQOBY6hgMemM,4331
|
|
11
|
+
sql_mini_mcp/db/registry.py,sha256=V-zobzqW1a5KfuP3riEZbNGJ2F0eFWkWE4j4xbbPnT0,3373
|
|
12
|
+
sql_mini_mcp/db/sqlserver.py,sha256=Kky09ANKgPUts-Z8Oa8ywdvTrJdbqDm5UyrJM-xTyE4,2296
|
|
13
|
+
sql_mini_mcp-0.9.1.dist-info/METADATA,sha256=P4jSC2PBZPJsX0CJLFX-_NOAh4LJmJ9WbUgXlfY2tzk,4988
|
|
14
|
+
sql_mini_mcp-0.9.1.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
15
|
+
sql_mini_mcp-0.9.1.dist-info/entry_points.txt,sha256=7wwNP0cLVOr0osCAJudsw5o793e1evW6uK8NSxCyPJ0,60
|
|
16
|
+
sql_mini_mcp-0.9.1.dist-info/licenses/LICENSE,sha256=D5ajj8LaHI_KEmp64J78GqH7a71vwYWZIg3spYMVYJY,1074
|
|
17
|
+
sql_mini_mcp-0.9.1.dist-info/RECORD,,
|
|
@@ -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.
|