sql-mcp 0.1.0__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_mcp/__init__.py +64 -0
- sql_mcp/__main__.py +4 -0
- sql_mcp/agent_server.py +71 -0
- sql_mcp/api/__init__.py +9 -0
- sql_mcp/api/api_client_sql.py +469 -0
- sql_mcp/api_client.py +11 -0
- sql_mcp/auth.py +142 -0
- sql_mcp/dialects.py +183 -0
- sql_mcp/main_agent.json +14 -0
- sql_mcp/mcp/__init__.py +5 -0
- sql_mcp/mcp/mcp_sql.py +224 -0
- sql_mcp/mcp_config.json +33 -0
- sql_mcp/mcp_server.py +59 -0
- sql_mcp/safety.py +173 -0
- sql_mcp/sql_input_models.py +63 -0
- sql_mcp/sql_response_models.py +51 -0
- sql_mcp-0.1.0.dist-info/METADATA +242 -0
- sql_mcp-0.1.0.dist-info/RECORD +22 -0
- sql_mcp-0.1.0.dist-info/WHEEL +5 -0
- sql_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- sql_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- sql_mcp-0.1.0.dist-info/top_level.txt +1 -0
sql_mcp/auth.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Connection and server-policy configuration for sql-mcp (CONCEPT:SQL-1.2).
|
|
2
|
+
|
|
3
|
+
Named connections come from the environment, in priority order:
|
|
4
|
+
|
|
5
|
+
1. ``SQL_CONNECTIONS`` — JSON mapping name -> DSN string or
|
|
6
|
+
``{"url": ...}`` / ``{"dialect": ..., "host": ..., "port": ...,
|
|
7
|
+
"username": ..., "password": ..., "database": ..., "options": {...}}``.
|
|
8
|
+
2. ``SQL_URL`` — a single DSN registered as connection ``"default"``.
|
|
9
|
+
3. Discrete fields — ``SQL_DIALECT`` + ``SQL_HOST`` / ``SQL_PORT`` /
|
|
10
|
+
``SQL_USERNAME`` / ``SQL_PASSWORD`` / ``SQL_DATABASE`` / ``SQL_OPTIONS``
|
|
11
|
+
(JSON), registered as ``"default"``.
|
|
12
|
+
4. Nothing configured — a zero-infra in-memory SQLite connection named
|
|
13
|
+
``"memory"`` so the server works out of the box.
|
|
14
|
+
|
|
15
|
+
Secrets are never logged: URLs are parsed into ``sqlalchemy.engine.URL``
|
|
16
|
+
objects and only ever rendered with ``hide_password=True``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
from agent_utilities.base_utilities import get_logger, to_boolean
|
|
23
|
+
from sqlalchemy.engine import URL, make_url
|
|
24
|
+
|
|
25
|
+
from sql_mcp.dialects import build_url
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
DEFAULT_MAX_ROWS = 500
|
|
30
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _connection_from_spec(name: str, spec: object) -> URL:
|
|
34
|
+
"""Build a SQLAlchemy URL from one ``SQL_CONNECTIONS`` entry."""
|
|
35
|
+
if isinstance(spec, str):
|
|
36
|
+
return make_url(spec)
|
|
37
|
+
if isinstance(spec, dict):
|
|
38
|
+
if "url" in spec:
|
|
39
|
+
return make_url(spec["url"])
|
|
40
|
+
if "dialect" in spec:
|
|
41
|
+
return build_url(
|
|
42
|
+
spec["dialect"],
|
|
43
|
+
host=spec.get("host"),
|
|
44
|
+
port=spec.get("port"),
|
|
45
|
+
username=spec.get("username"),
|
|
46
|
+
password=spec.get("password"),
|
|
47
|
+
database=spec.get("database"),
|
|
48
|
+
options=spec.get("options"),
|
|
49
|
+
)
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Connection {name!r} in SQL_CONNECTIONS must be a DSN string or an "
|
|
52
|
+
"object with 'url' or 'dialect' fields."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_connections() -> dict[str, URL]:
|
|
57
|
+
"""Load the named-connection registry from the environment."""
|
|
58
|
+
raw = os.getenv("SQL_CONNECTIONS", "")
|
|
59
|
+
if raw.strip():
|
|
60
|
+
try:
|
|
61
|
+
mapping = json.loads(raw)
|
|
62
|
+
except json.JSONDecodeError as exc:
|
|
63
|
+
raise ValueError(f"SQL_CONNECTIONS is not valid JSON: {exc}") from exc
|
|
64
|
+
if not isinstance(mapping, dict) or not mapping:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
"SQL_CONNECTIONS must be a non-empty JSON object mapping "
|
|
67
|
+
"connection names to DSNs or connection objects."
|
|
68
|
+
)
|
|
69
|
+
return {
|
|
70
|
+
name: _connection_from_spec(name, spec) for name, spec in mapping.items()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
url = os.getenv("SQL_URL", "")
|
|
74
|
+
if url.strip():
|
|
75
|
+
return {"default": make_url(url)}
|
|
76
|
+
|
|
77
|
+
if os.getenv("SQL_DIALECT") or os.getenv("SQL_HOST"):
|
|
78
|
+
options_raw = os.getenv("SQL_OPTIONS", "")
|
|
79
|
+
options = json.loads(options_raw) if options_raw.strip() else None
|
|
80
|
+
port_raw = os.getenv("SQL_PORT", "")
|
|
81
|
+
return {
|
|
82
|
+
"default": build_url(
|
|
83
|
+
os.getenv("SQL_DIALECT", "postgres"),
|
|
84
|
+
host=os.getenv("SQL_HOST"),
|
|
85
|
+
port=int(port_raw) if port_raw.strip() else None,
|
|
86
|
+
username=os.getenv("SQL_USERNAME"),
|
|
87
|
+
password=os.getenv("SQL_PASSWORD"),
|
|
88
|
+
database=os.getenv("SQL_DATABASE"),
|
|
89
|
+
options=options,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
logger.info(
|
|
94
|
+
"No SQL connection configured (SQL_CONNECTIONS/SQL_URL/SQL_HOST); "
|
|
95
|
+
"registering a zero-infra in-memory SQLite connection named 'memory'."
|
|
96
|
+
)
|
|
97
|
+
return {"memory": make_url("sqlite+pysqlite:///:memory:")}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def allow_writes() -> bool:
|
|
101
|
+
"""Whether DML/DDL via ``sql_execute`` is enabled (default: read-only)."""
|
|
102
|
+
return to_boolean(os.getenv("SQL_ALLOW_WRITES", "False"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def default_max_rows() -> int:
|
|
106
|
+
"""Per-call row cap (``SQL_MAX_ROWS``, default 500); requests are clamped."""
|
|
107
|
+
return int(os.getenv("SQL_MAX_ROWS", str(DEFAULT_MAX_ROWS)))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def default_timeout() -> float:
|
|
111
|
+
"""Per-call statement timeout in seconds (``SQL_TIMEOUT_SECONDS``)."""
|
|
112
|
+
return float(os.getenv("SQL_TIMEOUT_SECONDS", str(DEFAULT_TIMEOUT_SECONDS)))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_api = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_api():
|
|
119
|
+
"""Return the process-wide :class:`~sql_mcp.api_client.Api` instance.
|
|
120
|
+
|
|
121
|
+
Built lazily from the environment; ``reset_api()`` clears the cache (used
|
|
122
|
+
by tests and after configuration changes).
|
|
123
|
+
"""
|
|
124
|
+
global _api
|
|
125
|
+
if _api is None:
|
|
126
|
+
from sql_mcp.api_client import Api
|
|
127
|
+
|
|
128
|
+
_api = Api(
|
|
129
|
+
connections=load_connections(),
|
|
130
|
+
allow_writes=allow_writes(),
|
|
131
|
+
max_rows=default_max_rows(),
|
|
132
|
+
timeout=default_timeout(),
|
|
133
|
+
)
|
|
134
|
+
return _api
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def reset_api() -> None:
|
|
138
|
+
"""Dispose the cached client so the next ``get_api()`` re-reads the env."""
|
|
139
|
+
global _api
|
|
140
|
+
if _api is not None:
|
|
141
|
+
_api.dispose()
|
|
142
|
+
_api = None
|
sql_mcp/dialects.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Dialect registry for sql-mcp (CONCEPT:SQL-1.1).
|
|
2
|
+
|
|
3
|
+
Mirrors the vector-mcp backend-registry pattern: every supported SQL engine is
|
|
4
|
+
described by a :class:`DialectSpec` entry in :data:`DIALECTS`. The spec carries
|
|
5
|
+
the SQLAlchemy URL scheme, the optional driver package (and which pip extra
|
|
6
|
+
ships it), and the dialect-specific administrative SQL the ``sql_admin`` tool
|
|
7
|
+
uses. Core ships SQLite only (stdlib-backed); the other drivers are optional
|
|
8
|
+
extras so the base install stays thin.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from sqlalchemy.engine import URL
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class DialectSpec:
|
|
19
|
+
"""Static description of one supported SQL dialect."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
sqlalchemy_scheme: str
|
|
23
|
+
driver_module: str | None
|
|
24
|
+
extra: str | None
|
|
25
|
+
default_port: int | None
|
|
26
|
+
explain_prefix: str | None
|
|
27
|
+
version_sql: str | None
|
|
28
|
+
active_connections_sql: str | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DIALECTS: dict[str, DialectSpec] = {
|
|
32
|
+
"sqlite": DialectSpec(
|
|
33
|
+
name="sqlite",
|
|
34
|
+
sqlalchemy_scheme="sqlite+pysqlite",
|
|
35
|
+
driver_module=None,
|
|
36
|
+
extra=None,
|
|
37
|
+
default_port=None,
|
|
38
|
+
explain_prefix="EXPLAIN QUERY PLAN",
|
|
39
|
+
version_sql="SELECT sqlite_version()",
|
|
40
|
+
active_connections_sql=None,
|
|
41
|
+
),
|
|
42
|
+
"postgres": DialectSpec(
|
|
43
|
+
name="postgres",
|
|
44
|
+
sqlalchemy_scheme="postgresql+psycopg",
|
|
45
|
+
driver_module="psycopg",
|
|
46
|
+
extra="postgres",
|
|
47
|
+
default_port=5432,
|
|
48
|
+
explain_prefix="EXPLAIN",
|
|
49
|
+
version_sql="SELECT version()",
|
|
50
|
+
active_connections_sql=(
|
|
51
|
+
"SELECT pid, usename, datname, state, application_name, query_start "
|
|
52
|
+
"FROM pg_stat_activity WHERE pid <> pg_backend_pid()"
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
"mysql": DialectSpec(
|
|
56
|
+
name="mysql",
|
|
57
|
+
sqlalchemy_scheme="mysql+pymysql",
|
|
58
|
+
driver_module="pymysql",
|
|
59
|
+
extra="mysql",
|
|
60
|
+
default_port=3306,
|
|
61
|
+
explain_prefix="EXPLAIN",
|
|
62
|
+
version_sql="SELECT VERSION()",
|
|
63
|
+
active_connections_sql=(
|
|
64
|
+
"SELECT id, user, host, db, command, time, state "
|
|
65
|
+
"FROM information_schema.processlist"
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
"mssql": DialectSpec(
|
|
69
|
+
name="mssql",
|
|
70
|
+
sqlalchemy_scheme="mssql+pyodbc",
|
|
71
|
+
driver_module="pyodbc",
|
|
72
|
+
extra="mssql",
|
|
73
|
+
default_port=1433,
|
|
74
|
+
explain_prefix=None,
|
|
75
|
+
version_sql="SELECT @@VERSION",
|
|
76
|
+
active_connections_sql=(
|
|
77
|
+
"SELECT session_id, login_name, status, host_name, program_name "
|
|
78
|
+
"FROM sys.dm_exec_sessions WHERE is_user_process = 1"
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
"oracle": DialectSpec(
|
|
82
|
+
name="oracle",
|
|
83
|
+
sqlalchemy_scheme="oracle+oracledb",
|
|
84
|
+
driver_module="oracledb",
|
|
85
|
+
extra="oracle",
|
|
86
|
+
default_port=1521,
|
|
87
|
+
explain_prefix="EXPLAIN PLAN FOR",
|
|
88
|
+
version_sql="SELECT banner FROM v$version",
|
|
89
|
+
active_connections_sql=(
|
|
90
|
+
"SELECT sid, username, status, machine, program "
|
|
91
|
+
"FROM v$session WHERE username IS NOT NULL"
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ALIASES: dict[str, str] = {
|
|
97
|
+
"postgresql": "postgres",
|
|
98
|
+
"pg": "postgres",
|
|
99
|
+
"pgsql": "postgres",
|
|
100
|
+
"mariadb": "mysql",
|
|
101
|
+
"sqlserver": "mssql",
|
|
102
|
+
"mssqlserver": "mssql",
|
|
103
|
+
"sqlite3": "sqlite",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_dialect(name: str) -> DialectSpec:
|
|
108
|
+
"""Resolve a dialect (or alias) name to its :class:`DialectSpec`.
|
|
109
|
+
|
|
110
|
+
Raises ``ValueError`` listing the supported dialects when unknown.
|
|
111
|
+
"""
|
|
112
|
+
key = ALIASES.get(name.lower().strip(), name.lower().strip())
|
|
113
|
+
spec = DIALECTS.get(key)
|
|
114
|
+
if spec is None:
|
|
115
|
+
supported = ", ".join(sorted(DIALECTS))
|
|
116
|
+
raise ValueError(f"Unknown SQL dialect {name!r}. Supported: {supported}.")
|
|
117
|
+
return spec
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def dialect_for_url(url: URL) -> DialectSpec | None:
|
|
121
|
+
"""Best-effort match of a SQLAlchemy URL to a registered dialect spec."""
|
|
122
|
+
backend = url.get_backend_name()
|
|
123
|
+
for spec in DIALECTS.values():
|
|
124
|
+
if spec.sqlalchemy_scheme.split("+", 1)[0] == backend:
|
|
125
|
+
return spec
|
|
126
|
+
alias = ALIASES.get(backend)
|
|
127
|
+
return DIALECTS.get(alias) if alias else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def require_driver(spec: DialectSpec) -> None:
|
|
131
|
+
"""Verify the dialect's DBAPI driver is importable.
|
|
132
|
+
|
|
133
|
+
Raises ``ImportError`` naming the pip extra that ships the driver, so the
|
|
134
|
+
failure is self-explanatory (``pip install sql-mcp[postgres]`` etc.).
|
|
135
|
+
"""
|
|
136
|
+
if spec.driver_module is None:
|
|
137
|
+
return
|
|
138
|
+
try:
|
|
139
|
+
importlib.import_module(spec.driver_module)
|
|
140
|
+
except ImportError as exc:
|
|
141
|
+
raise ImportError(
|
|
142
|
+
f"The {spec.name!r} dialect needs the {spec.driver_module!r} driver. "
|
|
143
|
+
f"Install it with: pip install sql-mcp[{spec.extra}]"
|
|
144
|
+
) from exc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def driver_available(spec: DialectSpec) -> bool:
|
|
148
|
+
"""Return True when the dialect's DBAPI driver can be imported."""
|
|
149
|
+
if spec.driver_module is None:
|
|
150
|
+
return True
|
|
151
|
+
try:
|
|
152
|
+
importlib.import_module(spec.driver_module)
|
|
153
|
+
return True
|
|
154
|
+
except ImportError:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_url(
|
|
159
|
+
dialect: str,
|
|
160
|
+
host: str | None = None,
|
|
161
|
+
port: int | None = None,
|
|
162
|
+
username: str | None = None,
|
|
163
|
+
password: str | None = None,
|
|
164
|
+
database: str | None = None,
|
|
165
|
+
options: dict[str, str] | None = None,
|
|
166
|
+
) -> URL:
|
|
167
|
+
"""Build a SQLAlchemy URL from discrete connection fields.
|
|
168
|
+
|
|
169
|
+
Uses ``URL.create`` so credentials are quoted correctly and the password
|
|
170
|
+
is never interpolated into a plain string (redaction-safe by design).
|
|
171
|
+
"""
|
|
172
|
+
spec = get_dialect(dialect)
|
|
173
|
+
if spec.name == "sqlite":
|
|
174
|
+
return URL.create(spec.sqlalchemy_scheme, database=database or ":memory:")
|
|
175
|
+
return URL.create(
|
|
176
|
+
spec.sqlalchemy_scheme,
|
|
177
|
+
username=username,
|
|
178
|
+
password=password,
|
|
179
|
+
host=host,
|
|
180
|
+
port=port or spec.default_port,
|
|
181
|
+
database=database,
|
|
182
|
+
query=dict(options or {}),
|
|
183
|
+
)
|
sql_mcp/main_agent.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"task": "main-agent",
|
|
3
|
+
"input": "# main-agent\n\nYou are the primary orchestrator for this workspace. Your goal is to help the user manage their projects and coordinate specialized agents.\n\n### Core Principles\n* Be concise and efficient.\n* Use the knowledge graph to discover tools and experts.\n* Verify your work before concluding.\n\nYour personality:\n* **Emoji:** 🤖\n* **Vibe:** Professional, efficient, helpful",
|
|
4
|
+
"type": "prompt",
|
|
5
|
+
"description": "The primary orchestrator agent for this workspace.",
|
|
6
|
+
"tools": [
|
|
7
|
+
"workspace-manager",
|
|
8
|
+
"agent-workflows"
|
|
9
|
+
],
|
|
10
|
+
"topic": "General Expertise",
|
|
11
|
+
"tone": "technical and precise",
|
|
12
|
+
"style": "professional assistant",
|
|
13
|
+
"goal": "Coordinate specialized agents and manage the workspace."
|
|
14
|
+
}
|
sql_mcp/mcp/__init__.py
ADDED
sql_mcp/mcp/mcp_sql.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Action-dispatch MCP tools for sql-mcp (CONCEPT:SQL-1.0, CONCEPT:SQL-1.5).
|
|
2
|
+
|
|
3
|
+
Four consolidated tools — ``sql_query``, ``sql_execute``, ``sql_schema``, and
|
|
4
|
+
``sql_admin`` — each routing an ``action`` + ``params_json`` pair to the
|
|
5
|
+
:class:`~sql_mcp.api_client.Api` facade. The tools are thin shims: parameter
|
|
6
|
+
parsing only, no business logic. Every tool accepts an optional ``connection``
|
|
7
|
+
naming one of the configured connections (defaults to the sole/first one).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastmcp import FastMCP
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
|
|
16
|
+
from sql_mcp.auth import get_api
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register_sql_tools(mcp: FastMCP) -> None:
|
|
20
|
+
"""Register the query, execute, schema, and admin tools."""
|
|
21
|
+
|
|
22
|
+
@mcp.tool(tags={"query"})
|
|
23
|
+
async def sql_query(
|
|
24
|
+
action: str = Field(
|
|
25
|
+
description=(
|
|
26
|
+
"Query action. One of: 'execute' (run a read-only SELECT/CTE "
|
|
27
|
+
"with bound parameters), 'explain' (return the dialect's query "
|
|
28
|
+
"plan for a read-only statement)."
|
|
29
|
+
)
|
|
30
|
+
),
|
|
31
|
+
params_json: str = Field(
|
|
32
|
+
default="{}",
|
|
33
|
+
description=(
|
|
34
|
+
"JSON of arguments. execute: "
|
|
35
|
+
'{"sql": "SELECT * FROM users WHERE id = :id", '
|
|
36
|
+
'"params": {"id": 1}, "max_rows": 100, "timeout": 10}. '
|
|
37
|
+
'explain: {"sql": "SELECT ...", "params": {...}}. '
|
|
38
|
+
"Statements must be single, read-only (SELECT/WITH/EXPLAIN/"
|
|
39
|
+
"SHOW/DESCRIBE/PRAGMA/VALUES), and use :name bound parameters "
|
|
40
|
+
"— never inline values. max_rows is clamped to the server cap."
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
connection: str = Field(
|
|
44
|
+
default="",
|
|
45
|
+
description=(
|
|
46
|
+
"Named connection from the server config (see sql_admin "
|
|
47
|
+
"'connections'). Empty = the default (sole/first) connection."
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Run read-only SQL with row cap, timeout, and column metadata."""
|
|
52
|
+
api = get_api()
|
|
53
|
+
p = json.loads(params_json) if params_json else {}
|
|
54
|
+
if action == "execute":
|
|
55
|
+
return api.query(
|
|
56
|
+
p["sql"],
|
|
57
|
+
params=p.get("params"),
|
|
58
|
+
connection=connection or None,
|
|
59
|
+
max_rows=p.get("max_rows"),
|
|
60
|
+
timeout=p.get("timeout"),
|
|
61
|
+
)
|
|
62
|
+
if action == "explain":
|
|
63
|
+
return api.explain(
|
|
64
|
+
p["sql"],
|
|
65
|
+
params=p.get("params"),
|
|
66
|
+
connection=connection or None,
|
|
67
|
+
timeout=p.get("timeout"),
|
|
68
|
+
)
|
|
69
|
+
raise ValueError(f"Unknown query action: {action!r}.")
|
|
70
|
+
|
|
71
|
+
@mcp.tool(tags={"execute"})
|
|
72
|
+
async def sql_execute(
|
|
73
|
+
action: str = Field(
|
|
74
|
+
description=(
|
|
75
|
+
"Write action. One of: 'execute' (one DML/DDL statement in a "
|
|
76
|
+
"transaction; params may be a dict or a list of dicts for "
|
|
77
|
+
"executemany), 'script' (a list of statements in ONE "
|
|
78
|
+
"all-or-nothing transaction). Requires the server to run with "
|
|
79
|
+
"SQL_ALLOW_WRITES=True — the default is read-only."
|
|
80
|
+
)
|
|
81
|
+
),
|
|
82
|
+
params_json: str = Field(
|
|
83
|
+
default="{}",
|
|
84
|
+
description=(
|
|
85
|
+
"JSON of arguments. execute: "
|
|
86
|
+
'{"sql": "INSERT INTO t (a) VALUES (:a)", "params": {"a": 1}} '
|
|
87
|
+
'or "params": [{"a": 1}, {"a": 2}] for executemany. '
|
|
88
|
+
'script: {"statements": ["CREATE TABLE ...", "INSERT ..."]}. '
|
|
89
|
+
"Optional 'timeout' (seconds) on both. Returns affected-row "
|
|
90
|
+
"counts."
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
connection: str = Field(
|
|
94
|
+
default="",
|
|
95
|
+
description=(
|
|
96
|
+
"Named connection from the server config. Empty = the default "
|
|
97
|
+
"(sole/first) connection."
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
) -> Any:
|
|
101
|
+
"""Run DML/DDL in transactions (gated by SQL_ALLOW_WRITES)."""
|
|
102
|
+
api = get_api()
|
|
103
|
+
p = json.loads(params_json) if params_json else {}
|
|
104
|
+
if action == "execute":
|
|
105
|
+
return api.execute(
|
|
106
|
+
p["sql"],
|
|
107
|
+
params=p.get("params"),
|
|
108
|
+
connection=connection or None,
|
|
109
|
+
timeout=p.get("timeout"),
|
|
110
|
+
)
|
|
111
|
+
if action == "script":
|
|
112
|
+
return api.execute_script(
|
|
113
|
+
p["statements"],
|
|
114
|
+
connection=connection or None,
|
|
115
|
+
timeout=p.get("timeout"),
|
|
116
|
+
)
|
|
117
|
+
raise ValueError(f"Unknown execute action: {action!r}.")
|
|
118
|
+
|
|
119
|
+
@mcp.tool(tags={"schema"})
|
|
120
|
+
async def sql_schema(
|
|
121
|
+
action: str = Field(
|
|
122
|
+
description=(
|
|
123
|
+
"Schema action. One of: 'schemas' (list schema names), "
|
|
124
|
+
"'tables', 'views' (list names, optional schema), 'columns', "
|
|
125
|
+
"'indexes', 'foreign_keys' (describe a table), 'ddl' (reflect "
|
|
126
|
+
"CREATE TABLE DDL), 'sample' (preview rows with a limit)."
|
|
127
|
+
)
|
|
128
|
+
),
|
|
129
|
+
params_json: str = Field(
|
|
130
|
+
default="{}",
|
|
131
|
+
description=(
|
|
132
|
+
"JSON of arguments. schemas: {}. tables/views: "
|
|
133
|
+
'{"schema": "public"} (optional). '
|
|
134
|
+
'columns/indexes/foreign_keys/ddl: {"table": "users", '
|
|
135
|
+
'"schema": "public"}. sample: {"table": "users", '
|
|
136
|
+
'"limit": 10, "schema": "public"} (limit clamped to the '
|
|
137
|
+
"server row cap)."
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
connection: str = Field(
|
|
141
|
+
default="",
|
|
142
|
+
description=(
|
|
143
|
+
"Named connection from the server config. Empty = the default "
|
|
144
|
+
"(sole/first) connection."
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
) -> Any:
|
|
148
|
+
"""Inspect schemas, tables, views, columns, indexes, FKs, and DDL."""
|
|
149
|
+
api = get_api()
|
|
150
|
+
p = json.loads(params_json) if params_json else {}
|
|
151
|
+
conn = connection or None
|
|
152
|
+
schema = p.get("schema")
|
|
153
|
+
if action == "schemas":
|
|
154
|
+
return api.list_schemas(connection=conn)
|
|
155
|
+
if action == "tables":
|
|
156
|
+
return api.list_tables(schema=schema, connection=conn)
|
|
157
|
+
if action == "views":
|
|
158
|
+
return api.list_views(schema=schema, connection=conn)
|
|
159
|
+
if action == "columns":
|
|
160
|
+
return api.list_columns(p["table"], schema=schema, connection=conn)
|
|
161
|
+
if action == "indexes":
|
|
162
|
+
return api.list_indexes(p["table"], schema=schema, connection=conn)
|
|
163
|
+
if action == "foreign_keys":
|
|
164
|
+
return api.list_foreign_keys(p["table"], schema=schema, connection=conn)
|
|
165
|
+
if action == "ddl":
|
|
166
|
+
return api.table_ddl(p["table"], schema=schema, connection=conn)
|
|
167
|
+
if action == "sample":
|
|
168
|
+
return api.sample_rows(
|
|
169
|
+
p["table"],
|
|
170
|
+
schema=schema,
|
|
171
|
+
limit=p.get("limit", 10),
|
|
172
|
+
connection=conn,
|
|
173
|
+
)
|
|
174
|
+
raise ValueError(f"Unknown schema action: {action!r}.")
|
|
175
|
+
|
|
176
|
+
@mcp.tool(tags={"admin"})
|
|
177
|
+
async def sql_admin(
|
|
178
|
+
action: str = Field(
|
|
179
|
+
description=(
|
|
180
|
+
"Admin action. One of: 'ping' (connection test + latency), "
|
|
181
|
+
"'version' (server version), 'active_connections' (server "
|
|
182
|
+
"sessions, where the dialect supports it), 'connections' "
|
|
183
|
+
"(list configured connections, passwords redacted), "
|
|
184
|
+
"'dialects' (supported dialects + driver availability)."
|
|
185
|
+
)
|
|
186
|
+
),
|
|
187
|
+
params_json: str = Field(
|
|
188
|
+
default="{}",
|
|
189
|
+
description="JSON of arguments. All admin actions take {}.",
|
|
190
|
+
),
|
|
191
|
+
connection: str = Field(
|
|
192
|
+
default="",
|
|
193
|
+
description=(
|
|
194
|
+
"Named connection from the server config. Empty = the default "
|
|
195
|
+
"(sole/first) connection."
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
) -> Any:
|
|
199
|
+
"""Connection health, server version, sessions, and registry info."""
|
|
200
|
+
api = get_api()
|
|
201
|
+
if params_json:
|
|
202
|
+
json.loads(params_json) # validate early; admin actions take {}
|
|
203
|
+
conn = connection or None
|
|
204
|
+
if action == "ping":
|
|
205
|
+
return api.ping(connection=conn)
|
|
206
|
+
if action == "version":
|
|
207
|
+
return api.server_version(connection=conn)
|
|
208
|
+
if action == "active_connections":
|
|
209
|
+
return api.active_connections(connection=conn)
|
|
210
|
+
if action == "connections":
|
|
211
|
+
return api.describe_connections()
|
|
212
|
+
if action == "dialects":
|
|
213
|
+
from sql_mcp.dialects import DIALECTS, driver_available
|
|
214
|
+
|
|
215
|
+
return [
|
|
216
|
+
{
|
|
217
|
+
"dialect": spec.name,
|
|
218
|
+
"scheme": spec.sqlalchemy_scheme,
|
|
219
|
+
"extra": spec.extra,
|
|
220
|
+
"driver_installed": driver_available(spec),
|
|
221
|
+
}
|
|
222
|
+
for spec in DIALECTS.values()
|
|
223
|
+
]
|
|
224
|
+
raise ValueError(f"Unknown admin action: {action!r}.")
|
sql_mcp/mcp_config.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mcpServers": {
|
|
3
|
+
"sql-mcp": {
|
|
4
|
+
"command": "uv",
|
|
5
|
+
"args": [
|
|
6
|
+
"run",
|
|
7
|
+
"sql-mcp"
|
|
8
|
+
],
|
|
9
|
+
"env": {
|
|
10
|
+
"FASTMCP_LOG_LEVEL": "<YOUR_FASTMCP_LOG_LEVEL>",
|
|
11
|
+
"NO_COLOR": "<YOUR_NO_COLOR>",
|
|
12
|
+
"TERM": "<YOUR_TERM>",
|
|
13
|
+
"AGENT_DESCRIPTION": "<YOUR_AGENT_DESCRIPTION>",
|
|
14
|
+
"AGENT_SYSTEM_PROMPT": "<YOUR_AGENT_SYSTEM_PROMPT>",
|
|
15
|
+
"DEFAULT_AGENT_NAME": "<YOUR_DEFAULT_AGENT_NAME>",
|
|
16
|
+
"MCP_URL": "<YOUR_MCP_URL>",
|
|
17
|
+
"SQL_CONNECTIONS": "<YOUR_SQL_CONNECTIONS>",
|
|
18
|
+
"SQL_URL": "<YOUR_SQL_URL>",
|
|
19
|
+
"SQL_DIALECT": "<YOUR_SQL_DIALECT>",
|
|
20
|
+
"SQL_HOST": "<YOUR_SQL_HOST>",
|
|
21
|
+
"SQL_PORT": "<YOUR_SQL_PORT>",
|
|
22
|
+
"SQL_USERNAME": "<YOUR_SQL_USERNAME>",
|
|
23
|
+
"SQL_PASSWORD": "<YOUR_SQL_PASSWORD>",
|
|
24
|
+
"SQL_DATABASE": "<YOUR_SQL_DATABASE>",
|
|
25
|
+
"SQL_OPTIONS": "<YOUR_SQL_OPTIONS>",
|
|
26
|
+
"SQL_ALLOW_WRITES": "False",
|
|
27
|
+
"SQL_MAX_ROWS": "500",
|
|
28
|
+
"SQL_TIMEOUT_SECONDS": "30",
|
|
29
|
+
"SQLTOOL": "True"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
sql_mcp/mcp_server.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Main FastMCP server and tool registration for sql-mcp."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agent_utilities.base_utilities import to_boolean
|
|
8
|
+
from agent_utilities.mcp_utilities import create_mcp_server
|
|
9
|
+
from dotenv import find_dotenv, load_dotenv
|
|
10
|
+
from fastmcp.utilities.logging import get_logger
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
from starlette.responses import JSONResponse
|
|
13
|
+
|
|
14
|
+
from sql_mcp.mcp.mcp_sql import register_sql_tools
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
logger = get_logger(name="sql_mcp")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_mcp_instance() -> tuple[Any, ...]:
|
|
21
|
+
load_dotenv(find_dotenv())
|
|
22
|
+
args, mcp, middlewares = create_mcp_server(
|
|
23
|
+
name="SQL MCP",
|
|
24
|
+
version=__version__,
|
|
25
|
+
instructions=(
|
|
26
|
+
"Generic SQL database MCP Server - read-only queries, gated "
|
|
27
|
+
"DML/DDL, schema reflection, and connection admin over "
|
|
28
|
+
"SQLAlchemy 2.x Core (SQLite, Postgres, MySQL/MariaDB, MSSQL, "
|
|
29
|
+
"Oracle) with named multi-connection support."
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@mcp.custom_route("/health", methods=["GET"])
|
|
34
|
+
async def health_check(request: Request) -> JSONResponse:
|
|
35
|
+
return JSONResponse({"status": "OK"})
|
|
36
|
+
|
|
37
|
+
if to_boolean(os.getenv("SQLTOOL", "True")):
|
|
38
|
+
register_sql_tools(mcp)
|
|
39
|
+
|
|
40
|
+
for mw in middlewares:
|
|
41
|
+
mcp.add_middleware(mw)
|
|
42
|
+
return mcp, args, middlewares
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mcp_server() -> None:
|
|
46
|
+
mcp, args, middlewares = get_mcp_instance()
|
|
47
|
+
print(f"SQL MCP v{__version__}", file=sys.stderr)
|
|
48
|
+
if args.transport == "stdio":
|
|
49
|
+
mcp.run(transport="stdio")
|
|
50
|
+
elif args.transport == "streamable-http":
|
|
51
|
+
mcp.run(transport="streamable-http", host=args.host, port=args.port)
|
|
52
|
+
elif args.transport == "sse":
|
|
53
|
+
mcp.run(transport="sse", host=args.host, port=args.port)
|
|
54
|
+
else:
|
|
55
|
+
mcp.run(transport="stdio")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
mcp_server()
|