mcp-sql-querystore 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.
- mcp_sql_querystore/__init__.py +3 -0
- mcp_sql_querystore/db.py +251 -0
- mcp_sql_querystore/plan_parser.py +101 -0
- mcp_sql_querystore/queries.py +261 -0
- mcp_sql_querystore/server.py +504 -0
- mcp_sql_querystore-0.1.0.dist-info/METADATA +224 -0
- mcp_sql_querystore-0.1.0.dist-info/RECORD +9 -0
- mcp_sql_querystore-0.1.0.dist-info/WHEEL +4 -0
- mcp_sql_querystore-0.1.0.dist-info/entry_points.txt +2 -0
mcp_sql_querystore/db.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Database connection layer.
|
|
2
|
+
|
|
3
|
+
The read-only guarantee comes from the SQL login's permissions, NOT from this
|
|
4
|
+
code. Provision a dedicated login with only:
|
|
5
|
+
GRANT VIEW DATABASE STATE -- (or VIEW SERVER STATE for server-wide DMVs)
|
|
6
|
+
GRANT SELECT ON the sys.query_store_* catalog views
|
|
7
|
+
and NO db_datareader / no SELECT on user tables. See README.
|
|
8
|
+
|
|
9
|
+
This module adds parameterization and a keyword screen as defense-in-depth only.
|
|
10
|
+
|
|
11
|
+
Secret handling: the connection string can be supplied whole, or assembled from
|
|
12
|
+
parts with the password sourced indirectly (env var or file), so the password
|
|
13
|
+
need not sit in a plaintext MCP config. See _connection_string() for the order.
|
|
14
|
+
|
|
15
|
+
Audit logging: every query attempt is recorded via the "mcp_sql_querystore.audit"
|
|
16
|
+
logger — tool, database, a hash of the SQL (not the text), row count, duration,
|
|
17
|
+
and outcome. Credentials and parameter values are never logged.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from typing import Any, Iterator
|
|
29
|
+
|
|
30
|
+
# pyodbc is imported lazily inside get_cursor() rather than at module load. This
|
|
31
|
+
# keeps the pure validators (validate_db_name, assert_select_only) importable in
|
|
32
|
+
# environments without the ODBC driver — e.g. CI running the test suite — while
|
|
33
|
+
# still requiring pyodbc the moment an actual connection is attempted.
|
|
34
|
+
|
|
35
|
+
audit_log = logging.getLogger("mcp_sql_querystore.audit")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --- Configuration & secret handling ------------------------------------------
|
|
39
|
+
#
|
|
40
|
+
# Resolution order for the connection string:
|
|
41
|
+
# 1. MCP_SQL_CONNECTION_STRING — full string (back-compat; simplest)
|
|
42
|
+
# 2. MCP_SQL_CONNECTION_STRING_FILE — path to a file containing the full
|
|
43
|
+
# string (Docker/K8s secret style)
|
|
44
|
+
# 3. assembled from parts:
|
|
45
|
+
# MCP_SQL_SERVER, MCP_SQL_DATABASE (default "master"),
|
|
46
|
+
# MCP_SQL_DRIVER (default "ODBC Driver 18 for SQL Server"),
|
|
47
|
+
# MCP_SQL_ENCRYPT (default "yes"), MCP_SQL_TRUST_CERT (default "no"),
|
|
48
|
+
# MCP_SQL_EXTRA (optional, appended verbatim, e.g. ApplicationIntent=ReadOnly)
|
|
49
|
+
# Auth for the assembled form:
|
|
50
|
+
# - Integrated (Windows/AAD): set MCP_SQL_TRUSTED=yes and omit UID/PWD.
|
|
51
|
+
# Preferred for CJIS/PCI — no password to store at all.
|
|
52
|
+
# - SQL auth: MCP_SQL_UID plus the password from ONE of:
|
|
53
|
+
# MCP_SQL_PWD_FILE — path to a file holding just the password
|
|
54
|
+
# (secret-store / vault-mounted file)
|
|
55
|
+
# MCP_SQL_PWD_ENV — the NAME of another env var holding the password
|
|
56
|
+
# MCP_SQL_PWD — the password directly (least preferred)
|
|
57
|
+
|
|
58
|
+
_FULL_ENV = "MCP_SQL_CONNECTION_STRING"
|
|
59
|
+
_FULL_FILE_ENV = "MCP_SQL_CONNECTION_STRING_FILE"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_secret_file(path: str) -> str:
|
|
63
|
+
# utf-8-sig transparently strips a UTF-8/UTF-16 BOM if present (e.g. files
|
|
64
|
+
# written by PowerShell Out-File or some secret mounts) and reads plain
|
|
65
|
+
# UTF-8 otherwise.
|
|
66
|
+
try:
|
|
67
|
+
with open(path, encoding="utf-8-sig") as fh:
|
|
68
|
+
return fh.read().strip()
|
|
69
|
+
except UnicodeDecodeError:
|
|
70
|
+
# Fall back for UTF-16 without/with BOM that utf-8-sig can't handle.
|
|
71
|
+
try:
|
|
72
|
+
with open(path, encoding="utf-16") as fh:
|
|
73
|
+
return fh.read().strip()
|
|
74
|
+
except (OSError, UnicodeError) as exc:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
f"Secret file {path!r} is not UTF-8 or UTF-16 text: {exc}"
|
|
77
|
+
) from exc
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
raise RuntimeError(f"Could not read secret file {path!r}: {exc}") from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_password() -> str:
|
|
83
|
+
"""Resolve the SQL password from a file, a named env var, or directly —
|
|
84
|
+
in that order of preference. Returns '' if none set (caller decides if ok)."""
|
|
85
|
+
pwd_file = os.environ.get("MCP_SQL_PWD_FILE")
|
|
86
|
+
if pwd_file:
|
|
87
|
+
return _read_secret_file(pwd_file)
|
|
88
|
+
pwd_env = os.environ.get("MCP_SQL_PWD_ENV")
|
|
89
|
+
if pwd_env:
|
|
90
|
+
val = os.environ.get(pwd_env)
|
|
91
|
+
if val is None:
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
f"MCP_SQL_PWD_ENV points to {pwd_env!r} but that variable is not set."
|
|
94
|
+
)
|
|
95
|
+
return val
|
|
96
|
+
return os.environ.get("MCP_SQL_PWD", "")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _assemble_connection_string() -> str:
|
|
100
|
+
server = os.environ.get("MCP_SQL_SERVER")
|
|
101
|
+
if not server:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
"No connection configured. Set MCP_SQL_CONNECTION_STRING, or "
|
|
104
|
+
"MCP_SQL_CONNECTION_STRING_FILE, or MCP_SQL_SERVER (+ auth parts)."
|
|
105
|
+
)
|
|
106
|
+
driver = os.environ.get("MCP_SQL_DRIVER", "ODBC Driver 18 for SQL Server")
|
|
107
|
+
database = os.environ.get("MCP_SQL_DATABASE", "master")
|
|
108
|
+
encrypt = os.environ.get("MCP_SQL_ENCRYPT", "yes")
|
|
109
|
+
trust_cert = os.environ.get("MCP_SQL_TRUST_CERT", "no")
|
|
110
|
+
|
|
111
|
+
parts = [
|
|
112
|
+
f"Driver={{{driver}}}",
|
|
113
|
+
f"Server={server}",
|
|
114
|
+
f"Database={database}",
|
|
115
|
+
f"Encrypt={encrypt}",
|
|
116
|
+
f"TrustServerCertificate={trust_cert}",
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
if os.environ.get("MCP_SQL_TRUSTED", "").lower() in ("1", "yes", "true"):
|
|
120
|
+
parts.append("Trusted_Connection=yes")
|
|
121
|
+
else:
|
|
122
|
+
uid = os.environ.get("MCP_SQL_UID")
|
|
123
|
+
if not uid:
|
|
124
|
+
raise RuntimeError(
|
|
125
|
+
"SQL auth selected but MCP_SQL_UID is not set (and MCP_SQL_TRUSTED "
|
|
126
|
+
"is not enabled for integrated auth)."
|
|
127
|
+
)
|
|
128
|
+
pwd = _resolve_password()
|
|
129
|
+
if not pwd:
|
|
130
|
+
raise RuntimeError(
|
|
131
|
+
"No password resolved. Set MCP_SQL_PWD_FILE, MCP_SQL_PWD_ENV, or "
|
|
132
|
+
"MCP_SQL_PWD — or use integrated auth via MCP_SQL_TRUSTED=yes."
|
|
133
|
+
)
|
|
134
|
+
parts.append(f"UID={uid}")
|
|
135
|
+
parts.append(f"PWD={pwd}")
|
|
136
|
+
|
|
137
|
+
extra = os.environ.get("MCP_SQL_EXTRA")
|
|
138
|
+
if extra:
|
|
139
|
+
parts.append(extra)
|
|
140
|
+
return ";".join(parts) + ";"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _connection_string() -> str:
|
|
144
|
+
full = os.environ.get(_FULL_ENV)
|
|
145
|
+
if full:
|
|
146
|
+
return full
|
|
147
|
+
full_file = os.environ.get(_FULL_FILE_ENV)
|
|
148
|
+
if full_file:
|
|
149
|
+
return _read_secret_file(full_file)
|
|
150
|
+
return _assemble_connection_string()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --- Defense-in-depth identifier + statement screening ------------------------
|
|
154
|
+
|
|
155
|
+
# Database names are validated against this before being used to switch context.
|
|
156
|
+
# SQL Server identifiers are broad, but we deliberately restrict to a safe subset
|
|
157
|
+
# rather than trying to fully escape arbitrary names.
|
|
158
|
+
_SAFE_DB_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_$#]{0,127}$")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def validate_db_name(name: str) -> str:
|
|
162
|
+
if not isinstance(name, str) or not _SAFE_DB_NAME.match(name):
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"Invalid database name: {name!r}. Expected a plain SQL Server "
|
|
165
|
+
"identifier (letters, digits, _ $ #; starting with a letter/underscore)."
|
|
166
|
+
)
|
|
167
|
+
return name
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Statements we run are all fixed SELECT text defined in this codebase. This
|
|
171
|
+
# screen is a tripwire in case someone adds dynamic text later.
|
|
172
|
+
_FORBIDDEN = re.compile(
|
|
173
|
+
r"\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|"
|
|
174
|
+
r"GRANT|REVOKE|DENY|BACKUP|RESTORE|SHUTDOWN|RECONFIGURE)\b",
|
|
175
|
+
re.IGNORECASE,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def assert_select_only(sql: str) -> None:
|
|
180
|
+
if _FORBIDDEN.search(sql):
|
|
181
|
+
raise ValueError("Refusing to run: statement contains a non-SELECT keyword.")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- Connection handling ------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
# Per-query command timeout (seconds); 0 disables. Connect timeout is separate.
|
|
187
|
+
_QUERY_TIMEOUT = int(os.environ.get("MCP_SQL_QUERY_TIMEOUT", "30"))
|
|
188
|
+
_CONNECT_TIMEOUT = int(os.environ.get("MCP_SQL_CONNECT_TIMEOUT", "10"))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@contextmanager
|
|
192
|
+
def get_cursor() -> Iterator[Any]:
|
|
193
|
+
import pyodbc # lazy: only needed when actually connecting
|
|
194
|
+
|
|
195
|
+
conn = pyodbc.connect(_connection_string(), timeout=_CONNECT_TIMEOUT)
|
|
196
|
+
try:
|
|
197
|
+
# Belt-and-suspenders: reject any accidental writes at the session level.
|
|
198
|
+
# This does NOT replace login permissions.
|
|
199
|
+
conn.autocommit = True
|
|
200
|
+
conn.timeout = _QUERY_TIMEOUT # per-command timeout
|
|
201
|
+
cursor = conn.cursor()
|
|
202
|
+
yield cursor
|
|
203
|
+
finally:
|
|
204
|
+
conn.close()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _sql_fingerprint(sql: str) -> str:
|
|
208
|
+
"""Short stable hash of the SQL text, for the audit log — lets you correlate
|
|
209
|
+
which fixed statement ran without recording the text itself."""
|
|
210
|
+
return hashlib.sha256(sql.encode("utf-8")).hexdigest()[:12]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def run_query(
|
|
214
|
+
sql: str,
|
|
215
|
+
params: tuple[Any, ...] = (),
|
|
216
|
+
database: str | None = None,
|
|
217
|
+
tool: str | None = None,
|
|
218
|
+
) -> list[dict[str, Any]]:
|
|
219
|
+
"""Run a fixed SELECT against an optional database context.
|
|
220
|
+
|
|
221
|
+
database, if given, is validated and switched via USE with a validated
|
|
222
|
+
identifier (it cannot be parameterized). params are bound positionally.
|
|
223
|
+
tool, if given, is recorded in the audit log to attribute the query.
|
|
224
|
+
"""
|
|
225
|
+
assert_select_only(sql)
|
|
226
|
+
fingerprint = _sql_fingerprint(sql)
|
|
227
|
+
started = time.monotonic()
|
|
228
|
+
try:
|
|
229
|
+
with get_cursor() as cursor:
|
|
230
|
+
if database is not None:
|
|
231
|
+
db = validate_db_name(database)
|
|
232
|
+
# Identifier cannot be a bound parameter; db is validated above.
|
|
233
|
+
cursor.execute(f"USE [{db}];")
|
|
234
|
+
cursor.execute(sql, params)
|
|
235
|
+
columns = [c[0] for c in cursor.description]
|
|
236
|
+
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
elapsed_ms = round((time.monotonic() - started) * 1000, 1)
|
|
239
|
+
# Log the outcome, never the SQL text, params, or connection string.
|
|
240
|
+
audit_log.warning(
|
|
241
|
+
"query_failed tool=%s db=%s sql=%s elapsed_ms=%s error=%s",
|
|
242
|
+
tool or "-", database or "-", fingerprint, elapsed_ms,
|
|
243
|
+
type(exc).__name__,
|
|
244
|
+
)
|
|
245
|
+
raise
|
|
246
|
+
elapsed_ms = round((time.monotonic() - started) * 1000, 1)
|
|
247
|
+
audit_log.info(
|
|
248
|
+
"query_ok tool=%s db=%s sql=%s rows=%s elapsed_ms=%s",
|
|
249
|
+
tool or "-", database or "-", fingerprint, len(rows), elapsed_ms,
|
|
250
|
+
)
|
|
251
|
+
return rows
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Parse SQL Server showplan XML into a compact JSON summary.
|
|
2
|
+
|
|
3
|
+
Goal: strip the heavy XML down to the things an LLM actually needs to reason
|
|
4
|
+
about — missing indexes, implicit conversions (via warnings), key lookups, and
|
|
5
|
+
plan-level warnings — so we don't burn tokens on raw showplan.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Showplan uses this namespace on every element.
|
|
14
|
+
_NS = {"s": "http://schemas.microsoft.com/sqlserver/2004/07/showplan"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _tag(elem: ET.Element) -> str:
|
|
18
|
+
return elem.tag.split("}", 1)[-1]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def summarize_plan(plan_xml: str) -> dict[str, Any]:
|
|
22
|
+
"""Return a compact dict summary of a showplan XML string."""
|
|
23
|
+
try:
|
|
24
|
+
root = ET.fromstring(plan_xml)
|
|
25
|
+
except ET.ParseError as exc:
|
|
26
|
+
return {"error": f"could not parse plan XML: {exc}"}
|
|
27
|
+
|
|
28
|
+
summary: dict[str, Any] = {
|
|
29
|
+
"missing_indexes": _missing_indexes(root),
|
|
30
|
+
"warnings": _warnings(root),
|
|
31
|
+
"key_lookups": _count_ops(root, "Key Lookup"),
|
|
32
|
+
"index_scans": _count_ops(root, "Index Scan"),
|
|
33
|
+
"table_scans": _count_ops(root, "Table Scan"),
|
|
34
|
+
"estimated_subtree_cost": _root_cost(root),
|
|
35
|
+
}
|
|
36
|
+
return summary
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _missing_indexes(root: ET.Element) -> list[dict[str, Any]]:
|
|
40
|
+
out: list[dict[str, Any]] = []
|
|
41
|
+
for mig in root.iter("{%s}MissingIndexGroup" % _NS["s"]):
|
|
42
|
+
impact = mig.get("Impact")
|
|
43
|
+
for mi in mig.iter("{%s}MissingIndex" % _NS["s"]):
|
|
44
|
+
cols = {"equality": [], "inequality": [], "included": []}
|
|
45
|
+
for cg in mi.iter("{%s}ColumnGroup" % _NS["s"]):
|
|
46
|
+
usage = cg.get("Usage", "").upper()
|
|
47
|
+
names = [c.get("Name") for c in cg.iter("{%s}Column" % _NS["s"])]
|
|
48
|
+
# Check INEQUALITY before EQUALITY: "EQUALITY" is a substring of
|
|
49
|
+
# "INEQUALITY", so a loose "equality in usage" test misclassifies
|
|
50
|
+
# inequality column groups as equality ones.
|
|
51
|
+
if usage == "INEQUALITY":
|
|
52
|
+
cols["inequality"] = names
|
|
53
|
+
elif usage == "EQUALITY":
|
|
54
|
+
cols["equality"] = names
|
|
55
|
+
elif usage == "INCLUDE":
|
|
56
|
+
cols["included"] = names
|
|
57
|
+
out.append(
|
|
58
|
+
{
|
|
59
|
+
"impact_pct": float(impact) if impact else None,
|
|
60
|
+
"database": mi.get("Database"),
|
|
61
|
+
"schema": mi.get("Schema"),
|
|
62
|
+
"table": mi.get("Table"),
|
|
63
|
+
"columns": cols,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _warnings(root: ET.Element) -> list[str]:
|
|
70
|
+
found: list[str] = []
|
|
71
|
+
for w in root.iter("{%s}Warnings" % _NS["s"]):
|
|
72
|
+
for child in w:
|
|
73
|
+
name = _tag(child)
|
|
74
|
+
# Common ones: PlanAffectingConvert (implicit conversion),
|
|
75
|
+
# SpillToTempDb, ColumnsWithNoStatistics, NoJoinPredicate.
|
|
76
|
+
if name == "PlanAffectingConvert":
|
|
77
|
+
found.append(
|
|
78
|
+
f"implicit_conversion: {child.get('Expression', '')[:200]}"
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
found.append(name)
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _count_ops(root: ET.Element, physical_op: str) -> int:
|
|
86
|
+
return sum(
|
|
87
|
+
1
|
|
88
|
+
for rel in root.iter("{%s}RelOp" % _NS["s"])
|
|
89
|
+
if rel.get("PhysicalOp") == physical_op
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _root_cost(root: ET.Element) -> float | None:
|
|
94
|
+
for stmt in root.iter("{%s}StmtSimple" % _NS["s"]):
|
|
95
|
+
cost = stmt.get("StatementSubTreeCost")
|
|
96
|
+
if cost:
|
|
97
|
+
try:
|
|
98
|
+
return float(cost)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return None
|
|
101
|
+
return None
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Fixed T-SQL for each diagnostic tool.
|
|
2
|
+
|
|
3
|
+
All statements are SELECT-only and parameterized. Metric column names are chosen
|
|
4
|
+
from a fixed whitelist in the caller — never interpolated from user input.
|
|
5
|
+
|
|
6
|
+
Query Store column availability assumed: SQL Server 2019+/2022 and Azure SQL MI.
|
|
7
|
+
On 2016/2017 some columns (e.g. avg_query_max_used_memory) differ; adjust if you
|
|
8
|
+
target those.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
# Whitelist mapping the public "metric" enum to the Query Store column.
|
|
14
|
+
# The value is substituted into SQL, so it MUST come from this dict only.
|
|
15
|
+
METRIC_COLUMNS: dict[str, str] = {
|
|
16
|
+
"cpu_time": "avg_cpu_time",
|
|
17
|
+
"duration": "avg_duration",
|
|
18
|
+
"logical_reads": "avg_logical_io_reads",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def regressed_queries_sql(metric_column: str) -> str:
|
|
23
|
+
"""Baseline-vs-recent regression detection.
|
|
24
|
+
|
|
25
|
+
Splits the lookback window into a baseline period (older) and a recent period
|
|
26
|
+
(newer), aggregates the chosen metric per query over each, and returns queries
|
|
27
|
+
whose recent metric is materially worse than baseline.
|
|
28
|
+
|
|
29
|
+
metric_column MUST be a value from METRIC_COLUMNS (caller enforces this).
|
|
30
|
+
All other values are bound positionally via pyodbc `?` placeholders.
|
|
31
|
+
|
|
32
|
+
Bound parameter order (see server._get_regressed_queries):
|
|
33
|
+
1 recent_hours -> recent window boundary
|
|
34
|
+
2 recent_hours -> (reused) baseline window start
|
|
35
|
+
3 baseline_hours -> (reused) baseline window start
|
|
36
|
+
4 min_executions -> baseline exec floor
|
|
37
|
+
5 min_executions -> recent exec floor
|
|
38
|
+
6 regression_threshold
|
|
39
|
+
7 top_n
|
|
40
|
+
"""
|
|
41
|
+
return f"""
|
|
42
|
+
SET NOCOUNT ON;
|
|
43
|
+
|
|
44
|
+
DECLARE @now DATETIMEOFFSET = SYSUTCDATETIME();
|
|
45
|
+
DECLARE @recent_start DATETIMEOFFSET = DATEADD(HOUR, -CAST(? AS INT), @now);
|
|
46
|
+
DECLARE @baseline_start DATETIMEOFFSET =
|
|
47
|
+
DATEADD(HOUR, -(CAST(? AS INT) + CAST(? AS INT)), @now);
|
|
48
|
+
|
|
49
|
+
WITH stats AS (
|
|
50
|
+
SELECT
|
|
51
|
+
q.query_id,
|
|
52
|
+
rs.plan_id,
|
|
53
|
+
rsi.start_time,
|
|
54
|
+
rs.count_executions,
|
|
55
|
+
rs.{metric_column} AS metric_val
|
|
56
|
+
FROM sys.query_store_runtime_stats rs
|
|
57
|
+
JOIN sys.query_store_runtime_stats_interval rsi
|
|
58
|
+
ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id
|
|
59
|
+
JOIN sys.query_store_plan p
|
|
60
|
+
ON rs.plan_id = p.plan_id
|
|
61
|
+
JOIN sys.query_store_query q
|
|
62
|
+
ON p.query_id = q.query_id
|
|
63
|
+
WHERE rsi.start_time >= @baseline_start
|
|
64
|
+
),
|
|
65
|
+
baseline AS (
|
|
66
|
+
SELECT
|
|
67
|
+
query_id,
|
|
68
|
+
-- execution-weighted average over the baseline period
|
|
69
|
+
SUM(metric_val * count_executions) / NULLIF(SUM(count_executions), 0)
|
|
70
|
+
AS base_metric,
|
|
71
|
+
SUM(count_executions) AS base_execs
|
|
72
|
+
FROM stats
|
|
73
|
+
WHERE start_time < @recent_start
|
|
74
|
+
GROUP BY query_id
|
|
75
|
+
),
|
|
76
|
+
recent AS (
|
|
77
|
+
SELECT
|
|
78
|
+
query_id,
|
|
79
|
+
SUM(metric_val * count_executions) / NULLIF(SUM(count_executions), 0)
|
|
80
|
+
AS recent_metric,
|
|
81
|
+
SUM(count_executions) AS recent_execs
|
|
82
|
+
FROM stats
|
|
83
|
+
WHERE start_time >= @recent_start
|
|
84
|
+
GROUP BY query_id
|
|
85
|
+
)
|
|
86
|
+
SELECT TOP (CAST(? AS INT))
|
|
87
|
+
r.query_id,
|
|
88
|
+
CAST(qt.query_sql_text AS NVARCHAR(1000)) AS query_text_sample,
|
|
89
|
+
b.base_metric,
|
|
90
|
+
r.recent_metric,
|
|
91
|
+
CASE WHEN b.base_metric > 0
|
|
92
|
+
THEN (r.recent_metric - b.base_metric) / b.base_metric
|
|
93
|
+
ELSE NULL END AS pct_change,
|
|
94
|
+
b.base_execs,
|
|
95
|
+
r.recent_execs
|
|
96
|
+
FROM recent r
|
|
97
|
+
JOIN baseline b ON r.query_id = b.query_id
|
|
98
|
+
JOIN sys.query_store_query q ON q.query_id = r.query_id
|
|
99
|
+
JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
|
|
100
|
+
WHERE b.base_metric > 0
|
|
101
|
+
AND b.base_execs >= ?
|
|
102
|
+
AND r.recent_execs >= ?
|
|
103
|
+
-- regression threshold: recent is at least X% worse than baseline
|
|
104
|
+
AND (r.recent_metric - b.base_metric) / b.base_metric >= ?
|
|
105
|
+
ORDER BY (r.recent_metric - b.base_metric) / b.base_metric DESC;
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# get_query_execution_plan: returns the plan XML and the compiled plans for a
|
|
110
|
+
# query_id. Missing-index / warning parsing is done in Python from the XML.
|
|
111
|
+
EXECUTION_PLAN_SQL = """
|
|
112
|
+
SET NOCOUNT ON;
|
|
113
|
+
|
|
114
|
+
SELECT
|
|
115
|
+
p.plan_id,
|
|
116
|
+
p.query_id,
|
|
117
|
+
p.query_plan, -- XML showplan
|
|
118
|
+
p.is_forced_plan,
|
|
119
|
+
p.count_compiles,
|
|
120
|
+
-- pyodbc cannot convert datetimeoffset (SQL type -155); return as ISO string.
|
|
121
|
+
CONVERT(NVARCHAR(34), p.last_compile_start_time, 127) AS last_compile_start_time,
|
|
122
|
+
CAST(qt.query_sql_text AS NVARCHAR(MAX)) AS query_sql_text
|
|
123
|
+
FROM sys.query_store_plan p
|
|
124
|
+
JOIN sys.query_store_query q ON p.query_id = q.query_id
|
|
125
|
+
JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
|
|
126
|
+
WHERE p.query_id = ?
|
|
127
|
+
ORDER BY p.last_compile_start_time DESC;
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
# analyze_parameter_sniffing: find query_ids that have multiple plans AND high
|
|
131
|
+
# runtime variance across those plans — the classic sniffing signature (one query,
|
|
132
|
+
# several plans, wildly different durations). We use the stdev columns Query Store
|
|
133
|
+
# already stores, so no XML parsing is needed here.
|
|
134
|
+
#
|
|
135
|
+
# Bound parameter order (text order):
|
|
136
|
+
# 1: min_plan_count (HAVING COUNT(DISTINCT plan_id) >= ?)
|
|
137
|
+
# 2: top_n (SELECT TOP)
|
|
138
|
+
PARAMETER_SNIFFING_SQL = """
|
|
139
|
+
SET NOCOUNT ON;
|
|
140
|
+
|
|
141
|
+
WITH plan_stats AS (
|
|
142
|
+
SELECT
|
|
143
|
+
q.query_id,
|
|
144
|
+
rs.plan_id,
|
|
145
|
+
SUM(rs.count_executions) AS execs,
|
|
146
|
+
-- execution-weighted mean duration for this plan
|
|
147
|
+
SUM(rs.avg_duration * rs.count_executions)
|
|
148
|
+
/ NULLIF(SUM(rs.count_executions), 0) AS mean_duration,
|
|
149
|
+
-- max stdev observed for this plan (per-interval stdev, take the worst)
|
|
150
|
+
MAX(rs.stdev_duration) AS max_stdev_duration
|
|
151
|
+
FROM sys.query_store_runtime_stats rs
|
|
152
|
+
JOIN sys.query_store_plan p ON rs.plan_id = p.plan_id
|
|
153
|
+
JOIN sys.query_store_query q ON p.query_id = q.query_id
|
|
154
|
+
GROUP BY q.query_id, rs.plan_id
|
|
155
|
+
),
|
|
156
|
+
agg AS (
|
|
157
|
+
SELECT
|
|
158
|
+
query_id,
|
|
159
|
+
COUNT(DISTINCT plan_id) AS plan_count,
|
|
160
|
+
SUM(execs) AS total_execs,
|
|
161
|
+
MIN(mean_duration) AS min_plan_mean_duration,
|
|
162
|
+
MAX(mean_duration) AS max_plan_mean_duration,
|
|
163
|
+
MAX(max_stdev_duration) AS worst_stdev_duration
|
|
164
|
+
FROM plan_stats
|
|
165
|
+
GROUP BY query_id
|
|
166
|
+
HAVING COUNT(DISTINCT plan_id) >= ?
|
|
167
|
+
)
|
|
168
|
+
SELECT TOP (CAST(? AS INT))
|
|
169
|
+
a.query_id,
|
|
170
|
+
a.plan_count,
|
|
171
|
+
a.total_execs,
|
|
172
|
+
a.min_plan_mean_duration,
|
|
173
|
+
a.max_plan_mean_duration,
|
|
174
|
+
-- ratio of the slowest plan's mean to the fastest: bigger = more suspicious
|
|
175
|
+
CASE WHEN a.min_plan_mean_duration > 0
|
|
176
|
+
THEN a.max_plan_mean_duration / a.min_plan_mean_duration
|
|
177
|
+
ELSE NULL END AS duration_ratio,
|
|
178
|
+
a.worst_stdev_duration,
|
|
179
|
+
CAST(qt.query_sql_text AS NVARCHAR(1000)) AS query_text_sample
|
|
180
|
+
FROM agg a
|
|
181
|
+
JOIN sys.query_store_query q ON q.query_id = a.query_id
|
|
182
|
+
JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
|
|
183
|
+
ORDER BY duration_ratio DESC;
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# get_missing_index_impact: the missing-index impact score lives inside the plan
|
|
188
|
+
# XML, not in any column. So we fetch the most recent plans (bounded) and let the
|
|
189
|
+
# Python side parse + aggregate + rank. This query just returns candidate plans.
|
|
190
|
+
#
|
|
191
|
+
# Bound parameter order (text order):
|
|
192
|
+
# 1: plan_scan_limit (SELECT TOP — how many recent plans to scan for indexes)
|
|
193
|
+
MISSING_INDEX_PLANS_SQL = """
|
|
194
|
+
SET NOCOUNT ON;
|
|
195
|
+
|
|
196
|
+
SELECT TOP (CAST(? AS INT))
|
|
197
|
+
p.plan_id,
|
|
198
|
+
p.query_id,
|
|
199
|
+
p.query_plan,
|
|
200
|
+
CAST(qt.query_sql_text AS NVARCHAR(1000)) AS query_text_sample
|
|
201
|
+
FROM sys.query_store_plan p
|
|
202
|
+
JOIN sys.query_store_query q ON p.query_id = q.query_id
|
|
203
|
+
JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
|
|
204
|
+
WHERE p.query_plan LIKE '%<MissingIndexes>%'
|
|
205
|
+
ORDER BY p.last_execution_time DESC;
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
# get_wait_stats: aggregate query wait time by wait category over a time window.
|
|
209
|
+
# Answers "why is it slow" (CPU vs blocking vs IO vs memory) rather than "what is
|
|
210
|
+
# slow". Per MS docs, wait rows must be de-duplicated by grouping on plan_id,
|
|
211
|
+
# interval, execution_type and wait_category before summing, or flushed + in-memory
|
|
212
|
+
# rows double-count. We aggregate to the wait_category level across the window.
|
|
213
|
+
#
|
|
214
|
+
# Bound parameter order (text order):
|
|
215
|
+
# 1: recent_hours (window start)
|
|
216
|
+
# 2: top_n (SELECT TOP)
|
|
217
|
+
WAIT_STATS_SQL = """
|
|
218
|
+
SET NOCOUNT ON;
|
|
219
|
+
|
|
220
|
+
DECLARE @window_start DATETIMEOFFSET =
|
|
221
|
+
DATEADD(HOUR, -CAST(? AS INT), SYSUTCDATETIME());
|
|
222
|
+
|
|
223
|
+
WITH deduped AS (
|
|
224
|
+
-- one row per (plan, interval, execution_type, category): MS-recommended grain
|
|
225
|
+
SELECT
|
|
226
|
+
ws.plan_id,
|
|
227
|
+
ws.runtime_stats_interval_id,
|
|
228
|
+
ws.execution_type,
|
|
229
|
+
ws.wait_category_desc,
|
|
230
|
+
SUM(ws.total_query_wait_time_ms) AS total_wait_ms,
|
|
231
|
+
MAX(ws.max_query_wait_time_ms) AS max_wait_ms
|
|
232
|
+
FROM sys.query_store_wait_stats ws
|
|
233
|
+
JOIN sys.query_store_runtime_stats_interval rsi
|
|
234
|
+
ON ws.runtime_stats_interval_id = rsi.runtime_stats_interval_id
|
|
235
|
+
WHERE rsi.start_time >= @window_start
|
|
236
|
+
GROUP BY ws.plan_id, ws.runtime_stats_interval_id,
|
|
237
|
+
ws.execution_type, ws.wait_category_desc
|
|
238
|
+
)
|
|
239
|
+
SELECT TOP (CAST(? AS INT))
|
|
240
|
+
wait_category_desc,
|
|
241
|
+
SUM(total_wait_ms) AS total_wait_ms,
|
|
242
|
+
MAX(max_wait_ms) AS max_wait_ms,
|
|
243
|
+
COUNT(DISTINCT plan_id) AS distinct_plans
|
|
244
|
+
FROM deduped
|
|
245
|
+
GROUP BY wait_category_desc
|
|
246
|
+
ORDER BY SUM(total_wait_ms) DESC;
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
# list_querystore_databases: enumerate online databases with Query Store actually
|
|
250
|
+
# ON, so the sweep skips DBs that would error or return nothing. Runs in the
|
|
251
|
+
# server context (master); no USE needed. sys.databases is server-scoped.
|
|
252
|
+
LIST_QS_DATABASES_SQL = """
|
|
253
|
+
SET NOCOUNT ON;
|
|
254
|
+
|
|
255
|
+
SELECT name
|
|
256
|
+
FROM sys.databases
|
|
257
|
+
WHERE state_desc = 'ONLINE'
|
|
258
|
+
AND database_id > 4 -- skip system DBs
|
|
259
|
+
AND is_query_store_on = 1
|
|
260
|
+
ORDER BY name;
|
|
261
|
+
"""
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""MCP server exposing read-only Query Store diagnostics.
|
|
2
|
+
|
|
3
|
+
Run with: python -m mcp_sql_querystore.server
|
|
4
|
+
Requires: MCP_SQL_CONNECTION_STRING set to a dedicated read-only login.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import mcp.types as types
|
|
13
|
+
from mcp.server import Server
|
|
14
|
+
from mcp.server.stdio import stdio_server
|
|
15
|
+
|
|
16
|
+
from . import queries
|
|
17
|
+
from .db import run_query
|
|
18
|
+
from .plan_parser import summarize_plan
|
|
19
|
+
|
|
20
|
+
app: Server = Server("mcp-sql-querystore")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --- Tool catalog -------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.list_tools()
|
|
27
|
+
async def list_tools() -> list[types.Tool]:
|
|
28
|
+
return [
|
|
29
|
+
types.Tool(
|
|
30
|
+
name="get_regressed_queries",
|
|
31
|
+
description=(
|
|
32
|
+
"Detect queries whose performance regressed by comparing a recent "
|
|
33
|
+
"period against an earlier baseline period from Query Store."
|
|
34
|
+
),
|
|
35
|
+
inputSchema={
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"database_name": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "Target SQL Server database name.",
|
|
41
|
+
},
|
|
42
|
+
"metric": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"enum": list(queries.METRIC_COLUMNS.keys()),
|
|
45
|
+
"default": "cpu_time",
|
|
46
|
+
"description": "Metric to evaluate regression against.",
|
|
47
|
+
},
|
|
48
|
+
"recent_hours": {
|
|
49
|
+
"type": "integer",
|
|
50
|
+
"default": 24,
|
|
51
|
+
"description": "Length of the recent window, in hours.",
|
|
52
|
+
},
|
|
53
|
+
"baseline_hours": {
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"default": 168,
|
|
56
|
+
"description": "Length of the baseline window preceding the "
|
|
57
|
+
"recent window, in hours (default 7 days).",
|
|
58
|
+
},
|
|
59
|
+
"regression_threshold": {
|
|
60
|
+
"type": "number",
|
|
61
|
+
"default": 0.5,
|
|
62
|
+
"description": "Minimum fractional worsening to flag "
|
|
63
|
+
"(0.5 = 50% worse than baseline).",
|
|
64
|
+
},
|
|
65
|
+
"min_executions": {
|
|
66
|
+
"type": "integer",
|
|
67
|
+
"default": 5,
|
|
68
|
+
"description": "Ignore queries with fewer executions than "
|
|
69
|
+
"this in either period (filters noise).",
|
|
70
|
+
},
|
|
71
|
+
"top_n": {
|
|
72
|
+
"type": "integer",
|
|
73
|
+
"default": 10,
|
|
74
|
+
"description": "Max rows to return.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
"required": ["database_name"],
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
types.Tool(
|
|
81
|
+
name="get_query_execution_plan",
|
|
82
|
+
description=(
|
|
83
|
+
"Fetch execution plan(s) for a Query Store query_id and return a "
|
|
84
|
+
"compact summary (missing indexes, warnings, lookups) plus optional "
|
|
85
|
+
"raw XML."
|
|
86
|
+
),
|
|
87
|
+
inputSchema={
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {
|
|
90
|
+
"database_name": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"description": "Target SQL Server database name.",
|
|
93
|
+
},
|
|
94
|
+
"query_id": {
|
|
95
|
+
"type": "integer",
|
|
96
|
+
"description": "Query Store Query ID.",
|
|
97
|
+
},
|
|
98
|
+
"include_xml": {
|
|
99
|
+
"type": "boolean",
|
|
100
|
+
"default": False,
|
|
101
|
+
"description": "Include raw showplan XML alongside summary.",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
"required": ["database_name", "query_id"],
|
|
105
|
+
},
|
|
106
|
+
),
|
|
107
|
+
types.Tool(
|
|
108
|
+
name="analyze_parameter_sniffing",
|
|
109
|
+
description=(
|
|
110
|
+
"Detect queries whose runtime varies widely across multiple compiled "
|
|
111
|
+
"plans — the classic parameter-sniffing signature. Ranks by the ratio "
|
|
112
|
+
"of slowest to fastest plan mean duration."
|
|
113
|
+
),
|
|
114
|
+
inputSchema={
|
|
115
|
+
"type": "object",
|
|
116
|
+
"properties": {
|
|
117
|
+
"database_name": {
|
|
118
|
+
"type": "string",
|
|
119
|
+
"description": "Target SQL Server database name.",
|
|
120
|
+
},
|
|
121
|
+
"min_plan_count": {
|
|
122
|
+
"type": "integer",
|
|
123
|
+
"default": 2,
|
|
124
|
+
"description": "Minimum distinct plans for a query to be "
|
|
125
|
+
"considered (2 = at least two plans).",
|
|
126
|
+
},
|
|
127
|
+
"top_n": {
|
|
128
|
+
"type": "integer",
|
|
129
|
+
"default": 10,
|
|
130
|
+
"description": "Max rows to return.",
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
"required": ["database_name"],
|
|
134
|
+
},
|
|
135
|
+
),
|
|
136
|
+
types.Tool(
|
|
137
|
+
name="get_missing_index_impact",
|
|
138
|
+
description=(
|
|
139
|
+
"Aggregate missing-index recommendations found in Query Store plans, "
|
|
140
|
+
"ranked by the optimizer's estimated impact score. Groups duplicate "
|
|
141
|
+
"recommendations across queries."
|
|
142
|
+
),
|
|
143
|
+
inputSchema={
|
|
144
|
+
"type": "object",
|
|
145
|
+
"properties": {
|
|
146
|
+
"database_name": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"description": "Target SQL Server database name.",
|
|
149
|
+
},
|
|
150
|
+
"top_n": {
|
|
151
|
+
"type": "integer",
|
|
152
|
+
"default": 10,
|
|
153
|
+
"description": "Max index recommendations to return.",
|
|
154
|
+
},
|
|
155
|
+
"plan_scan_limit": {
|
|
156
|
+
"type": "integer",
|
|
157
|
+
"default": 200,
|
|
158
|
+
"description": "How many recent plans (that contain missing "
|
|
159
|
+
"indexes) to scan and aggregate. Higher = more thorough, slower.",
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
"required": ["database_name"],
|
|
163
|
+
},
|
|
164
|
+
),
|
|
165
|
+
types.Tool(
|
|
166
|
+
name="get_wait_stats",
|
|
167
|
+
description=(
|
|
168
|
+
"Aggregate query wait time by wait category over a time window — shows "
|
|
169
|
+
"WHY queries are slow (CPU, blocking/locks, IO, memory, etc.) rather "
|
|
170
|
+
"than which are slow. Ranked by total wait time."
|
|
171
|
+
),
|
|
172
|
+
inputSchema={
|
|
173
|
+
"type": "object",
|
|
174
|
+
"properties": {
|
|
175
|
+
"database_name": {
|
|
176
|
+
"type": "string",
|
|
177
|
+
"description": "Target SQL Server database name.",
|
|
178
|
+
},
|
|
179
|
+
"recent_hours": {
|
|
180
|
+
"type": "integer",
|
|
181
|
+
"default": 24,
|
|
182
|
+
"description": "Lookback window in hours.",
|
|
183
|
+
},
|
|
184
|
+
"top_n": {
|
|
185
|
+
"type": "integer",
|
|
186
|
+
"default": 10,
|
|
187
|
+
"description": "Max wait categories to return.",
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
"required": ["database_name"],
|
|
191
|
+
},
|
|
192
|
+
),
|
|
193
|
+
types.Tool(
|
|
194
|
+
name="sweep_regressions",
|
|
195
|
+
description=(
|
|
196
|
+
"Run regression detection across ALL online databases that have Query "
|
|
197
|
+
"Store enabled, and return the worst regressions found per database. "
|
|
198
|
+
"Use this to triage a whole instance instead of one database at a time."
|
|
199
|
+
),
|
|
200
|
+
inputSchema={
|
|
201
|
+
"type": "object",
|
|
202
|
+
"properties": {
|
|
203
|
+
"metric": {
|
|
204
|
+
"type": "string",
|
|
205
|
+
"enum": list(queries.METRIC_COLUMNS.keys()),
|
|
206
|
+
"default": "cpu_time",
|
|
207
|
+
"description": "Metric to evaluate regression against.",
|
|
208
|
+
},
|
|
209
|
+
"recent_hours": {
|
|
210
|
+
"type": "integer",
|
|
211
|
+
"default": 24,
|
|
212
|
+
"description": "Length of the recent window, in hours.",
|
|
213
|
+
},
|
|
214
|
+
"baseline_hours": {
|
|
215
|
+
"type": "integer",
|
|
216
|
+
"default": 168,
|
|
217
|
+
"description": "Length of the baseline window, in hours.",
|
|
218
|
+
},
|
|
219
|
+
"regression_threshold": {
|
|
220
|
+
"type": "number",
|
|
221
|
+
"default": 0.5,
|
|
222
|
+
"description": "Minimum fractional worsening to flag.",
|
|
223
|
+
},
|
|
224
|
+
"min_executions": {
|
|
225
|
+
"type": "integer",
|
|
226
|
+
"default": 5,
|
|
227
|
+
"description": "Ignore queries below this execution count.",
|
|
228
|
+
},
|
|
229
|
+
"top_n_per_db": {
|
|
230
|
+
"type": "integer",
|
|
231
|
+
"default": 3,
|
|
232
|
+
"description": "Max regressed queries to return per database.",
|
|
233
|
+
},
|
|
234
|
+
"database_names": {
|
|
235
|
+
"type": "array",
|
|
236
|
+
"items": {"type": "string"},
|
|
237
|
+
"description": "Optional explicit list of databases to sweep. "
|
|
238
|
+
"If omitted, sweeps all Query Store-enabled online databases.",
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
"required": [],
|
|
242
|
+
},
|
|
243
|
+
),
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# --- Tool dispatch ------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.call_tool()
|
|
251
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
252
|
+
try:
|
|
253
|
+
if name == "get_regressed_queries":
|
|
254
|
+
result = _get_regressed_queries(arguments)
|
|
255
|
+
elif name == "get_query_execution_plan":
|
|
256
|
+
result = _get_query_execution_plan(arguments)
|
|
257
|
+
elif name == "analyze_parameter_sniffing":
|
|
258
|
+
result = _analyze_parameter_sniffing(arguments)
|
|
259
|
+
elif name == "get_missing_index_impact":
|
|
260
|
+
result = _get_missing_index_impact(arguments)
|
|
261
|
+
elif name == "get_wait_stats":
|
|
262
|
+
result = _get_wait_stats(arguments)
|
|
263
|
+
elif name == "sweep_regressions":
|
|
264
|
+
result = _sweep_regressions(arguments)
|
|
265
|
+
else:
|
|
266
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
267
|
+
except Exception as exc: # surface a clean error to the agent
|
|
268
|
+
result = {"error": type(exc).__name__, "message": str(exc)}
|
|
269
|
+
|
|
270
|
+
return [types.TextContent(type="text", text=json.dumps(result, default=str, indent=2))]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _run_regression(
|
|
274
|
+
database: str,
|
|
275
|
+
metric: str,
|
|
276
|
+
recent_hours: int,
|
|
277
|
+
baseline_hours: int,
|
|
278
|
+
min_executions: int,
|
|
279
|
+
threshold: float,
|
|
280
|
+
top_n: int,
|
|
281
|
+
tool: str = "get_regressed_queries",
|
|
282
|
+
) -> list[dict[str, Any]]:
|
|
283
|
+
"""Core regression query for one database. Single source of truth for the
|
|
284
|
+
parameter binding order, reused by both the single-DB tool and the sweep."""
|
|
285
|
+
if metric not in queries.METRIC_COLUMNS:
|
|
286
|
+
raise ValueError(f"Unsupported metric: {metric}")
|
|
287
|
+
metric_column = queries.METRIC_COLUMNS[metric] # whitelisted, safe to interpolate
|
|
288
|
+
sql = queries.regressed_queries_sql(metric_column)
|
|
289
|
+
|
|
290
|
+
# Params bound in the ORDER THE ? PLACEHOLDERS APPEAR in the SQL text:
|
|
291
|
+
# 1: recent_start DATEADD (recent_hours)
|
|
292
|
+
# 2: baseline_start recent portion (recent_hours)
|
|
293
|
+
# 3: baseline_start baseline portion(baseline_hours)
|
|
294
|
+
# 4: SELECT TOP (top_n)
|
|
295
|
+
# 5: baseline exec floor (min_executions)
|
|
296
|
+
# 6: recent exec floor (min_executions)
|
|
297
|
+
# 7: regression threshold (threshold)
|
|
298
|
+
params = (
|
|
299
|
+
recent_hours,
|
|
300
|
+
recent_hours,
|
|
301
|
+
baseline_hours,
|
|
302
|
+
top_n,
|
|
303
|
+
min_executions,
|
|
304
|
+
min_executions,
|
|
305
|
+
threshold,
|
|
306
|
+
)
|
|
307
|
+
return run_query(sql, params, database=database, tool=tool)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _get_regressed_queries(args: dict[str, Any]) -> dict[str, Any]:
|
|
311
|
+
metric = args.get("metric", "cpu_time")
|
|
312
|
+
rows = _run_regression(
|
|
313
|
+
database=args["database_name"],
|
|
314
|
+
metric=metric,
|
|
315
|
+
recent_hours=int(args.get("recent_hours", 24)),
|
|
316
|
+
baseline_hours=int(args.get("baseline_hours", 168)),
|
|
317
|
+
min_executions=int(args.get("min_executions", 5)),
|
|
318
|
+
threshold=float(args.get("regression_threshold", 0.5)),
|
|
319
|
+
top_n=int(args.get("top_n", 10)),
|
|
320
|
+
)
|
|
321
|
+
return {"metric": metric, "count": len(rows), "regressed_queries": rows}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _get_query_execution_plan(args: dict[str, Any]) -> dict[str, Any]:
|
|
325
|
+
rows = run_query(
|
|
326
|
+
queries.EXECUTION_PLAN_SQL,
|
|
327
|
+
(int(args["query_id"]),),
|
|
328
|
+
database=args["database_name"],
|
|
329
|
+
tool="get_query_execution_plan",
|
|
330
|
+
)
|
|
331
|
+
include_xml = bool(args.get("include_xml", False))
|
|
332
|
+
plans = []
|
|
333
|
+
for row in rows:
|
|
334
|
+
xml = row.get("query_plan") or ""
|
|
335
|
+
entry: dict[str, Any] = {
|
|
336
|
+
"plan_id": row.get("plan_id"),
|
|
337
|
+
"is_forced_plan": row.get("is_forced_plan"),
|
|
338
|
+
"count_compiles": row.get("count_compiles"),
|
|
339
|
+
"summary": summarize_plan(xml) if xml else {"error": "no plan xml"},
|
|
340
|
+
}
|
|
341
|
+
if include_xml:
|
|
342
|
+
entry["query_plan_xml"] = xml
|
|
343
|
+
plans.append(entry)
|
|
344
|
+
return {
|
|
345
|
+
"query_id": args["query_id"],
|
|
346
|
+
"plan_count": len(plans),
|
|
347
|
+
"plans": plans,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _analyze_parameter_sniffing(args: dict[str, Any]) -> dict[str, Any]:
|
|
352
|
+
min_plan_count = int(args.get("min_plan_count", 2))
|
|
353
|
+
top_n = int(args.get("top_n", 10))
|
|
354
|
+
# Bound params in text order: min_plan_count (HAVING), then top_n (TOP).
|
|
355
|
+
rows = run_query(
|
|
356
|
+
queries.PARAMETER_SNIFFING_SQL,
|
|
357
|
+
(min_plan_count, top_n),
|
|
358
|
+
database=args["database_name"],
|
|
359
|
+
tool="analyze_parameter_sniffing",
|
|
360
|
+
)
|
|
361
|
+
return {"count": len(rows), "suspected_sniffing": rows}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _get_missing_index_impact(args: dict[str, Any]) -> dict[str, Any]:
|
|
365
|
+
top_n = int(args.get("top_n", 10))
|
|
366
|
+
plan_scan_limit = int(args.get("plan_scan_limit", 200))
|
|
367
|
+
rows = run_query(
|
|
368
|
+
queries.MISSING_INDEX_PLANS_SQL,
|
|
369
|
+
(plan_scan_limit,),
|
|
370
|
+
database=args["database_name"],
|
|
371
|
+
tool="get_missing_index_impact",
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Aggregate missing-index recommendations across the scanned plans. The impact
|
|
375
|
+
# score and column set come from the plan XML (parsed by summarize_plan). We key
|
|
376
|
+
# duplicates by (table, equality, inequality, included) so the same suggested
|
|
377
|
+
# index across many queries is combined rather than listed repeatedly.
|
|
378
|
+
aggregated: dict[tuple, dict[str, Any]] = {}
|
|
379
|
+
for row in rows:
|
|
380
|
+
xml = row.get("query_plan") or ""
|
|
381
|
+
if not xml:
|
|
382
|
+
continue
|
|
383
|
+
for mi in summarize_plan(xml).get("missing_indexes", []):
|
|
384
|
+
cols = mi.get("columns", {})
|
|
385
|
+
key = (
|
|
386
|
+
mi.get("table"),
|
|
387
|
+
tuple(cols.get("equality", [])),
|
|
388
|
+
tuple(cols.get("inequality", [])),
|
|
389
|
+
tuple(cols.get("included", [])),
|
|
390
|
+
)
|
|
391
|
+
impact = mi.get("impact_pct") or 0.0
|
|
392
|
+
if key not in aggregated:
|
|
393
|
+
aggregated[key] = {
|
|
394
|
+
"table": mi.get("table"),
|
|
395
|
+
"schema": mi.get("schema"),
|
|
396
|
+
"database": mi.get("database"),
|
|
397
|
+
"equality_columns": cols.get("equality", []),
|
|
398
|
+
"inequality_columns": cols.get("inequality", []),
|
|
399
|
+
"included_columns": cols.get("included", []),
|
|
400
|
+
"max_impact_pct": impact,
|
|
401
|
+
"occurrences": 0,
|
|
402
|
+
}
|
|
403
|
+
entry = aggregated[key]
|
|
404
|
+
entry["occurrences"] += 1
|
|
405
|
+
entry["max_impact_pct"] = max(entry["max_impact_pct"], impact)
|
|
406
|
+
|
|
407
|
+
# Rank by impact first, then by how often the recommendation recurs.
|
|
408
|
+
ranked = sorted(
|
|
409
|
+
aggregated.values(),
|
|
410
|
+
key=lambda e: (e["max_impact_pct"], e["occurrences"]),
|
|
411
|
+
reverse=True,
|
|
412
|
+
)[:top_n]
|
|
413
|
+
return {
|
|
414
|
+
"plans_scanned": len(rows),
|
|
415
|
+
"distinct_recommendations": len(aggregated),
|
|
416
|
+
"recommendations": ranked,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _get_wait_stats(args: dict[str, Any]) -> dict[str, Any]:
|
|
421
|
+
recent_hours = int(args.get("recent_hours", 24))
|
|
422
|
+
top_n = int(args.get("top_n", 10))
|
|
423
|
+
# Bound params in text order: recent_hours (window), then top_n (TOP).
|
|
424
|
+
rows = run_query(
|
|
425
|
+
queries.WAIT_STATS_SQL,
|
|
426
|
+
(recent_hours, top_n),
|
|
427
|
+
database=args["database_name"],
|
|
428
|
+
tool="get_wait_stats",
|
|
429
|
+
)
|
|
430
|
+
return {"recent_hours": recent_hours, "count": len(rows), "wait_categories": rows}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _sweep_regressions(args: dict[str, Any]) -> dict[str, Any]:
|
|
434
|
+
metric = args.get("metric", "cpu_time")
|
|
435
|
+
recent_hours = int(args.get("recent_hours", 24))
|
|
436
|
+
baseline_hours = int(args.get("baseline_hours", 168))
|
|
437
|
+
min_executions = int(args.get("min_executions", 5))
|
|
438
|
+
threshold = float(args.get("regression_threshold", 0.5))
|
|
439
|
+
top_n_per_db = int(args.get("top_n_per_db", 3))
|
|
440
|
+
|
|
441
|
+
# Determine the database set: explicit list, or auto-enumerate QS-enabled DBs.
|
|
442
|
+
explicit = args.get("database_names")
|
|
443
|
+
if explicit:
|
|
444
|
+
databases = [str(d) for d in explicit]
|
|
445
|
+
else:
|
|
446
|
+
# is_query_store_on in sys.databases is a cached bit and can be stale after
|
|
447
|
+
# AG/mirroring failover, so a DB may pass this filter yet error on query —
|
|
448
|
+
# that per-DB error is caught below and reported, not fatal to the sweep.
|
|
449
|
+
db_rows = run_query(queries.LIST_QS_DATABASES_SQL, tool="sweep_regressions") # server context
|
|
450
|
+
databases = [r["name"] for r in db_rows]
|
|
451
|
+
|
|
452
|
+
results: list[dict[str, Any]] = []
|
|
453
|
+
errors: list[dict[str, Any]] = []
|
|
454
|
+
for db in databases:
|
|
455
|
+
try:
|
|
456
|
+
rows = _run_regression(
|
|
457
|
+
database=db,
|
|
458
|
+
metric=metric,
|
|
459
|
+
tool="sweep_regressions",
|
|
460
|
+
recent_hours=recent_hours,
|
|
461
|
+
baseline_hours=baseline_hours,
|
|
462
|
+
min_executions=min_executions,
|
|
463
|
+
threshold=threshold,
|
|
464
|
+
top_n=top_n_per_db,
|
|
465
|
+
)
|
|
466
|
+
if rows: # only report databases that actually have regressions
|
|
467
|
+
results.append({"database": db, "regression_count": len(rows),
|
|
468
|
+
"top_regressions": rows})
|
|
469
|
+
except Exception as exc:
|
|
470
|
+
# One bad database must not sink the whole sweep.
|
|
471
|
+
errors.append({"database": db, "error": type(exc).__name__,
|
|
472
|
+
"message": str(exc)})
|
|
473
|
+
|
|
474
|
+
# Databases with the worst single regression first.
|
|
475
|
+
results.sort(
|
|
476
|
+
key=lambda r: max((abs(q.get("pct_change") or 0) for q in r["top_regressions"]),
|
|
477
|
+
default=0),
|
|
478
|
+
reverse=True,
|
|
479
|
+
)
|
|
480
|
+
return {
|
|
481
|
+
"metric": metric,
|
|
482
|
+
"databases_scanned": len(databases),
|
|
483
|
+
"databases_with_regressions": len(results),
|
|
484
|
+
"results": results,
|
|
485
|
+
"errors": errors,
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
async def main() -> None:
|
|
490
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
491
|
+
await app.run(read_stream, write_stream, app.create_initialization_options())
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def run() -> None:
|
|
495
|
+
"""Synchronous entry point for the console script. The console script in
|
|
496
|
+
pyproject.toml must point here, not at the async `main`, or invoking the
|
|
497
|
+
command just creates a coroutine and never awaits it."""
|
|
498
|
+
import asyncio
|
|
499
|
+
|
|
500
|
+
asyncio.run(main())
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
if __name__ == "__main__":
|
|
504
|
+
run()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcp-sql-querystore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read-only MCP server for SQL Server performance diagnostics over Query Store, DMVs, and execution plans
|
|
5
|
+
Project-URL: Homepage, https://github.com/deepeshd87/mcp-sql-querystore
|
|
6
|
+
Project-URL: Repository, https://github.com/deepeshd87/mcp-sql-querystore
|
|
7
|
+
Project-URL: Issues, https://github.com/deepeshd87/mcp-sql-querystore/issues
|
|
8
|
+
Author: Deepesh Dhake
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: database,dba,mcp,model-context-protocol,mssql,performance-tuning,query-store,sql-server
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: System :: Monitoring
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: mcp<2,>=1.2.0
|
|
21
|
+
Requires-Dist: pyodbc>=5.1.0
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# mcp-sql-querystore
|
|
27
|
+
|
|
28
|
+
Read-only MCP server exposing SQL Server Query Store diagnostics to LLM agents.
|
|
29
|
+
|
|
30
|
+
Six read-only diagnostic tools over Query Store, DMVs, and execution plans. The
|
|
31
|
+
tools have been validated against a live SQL Server instance and are covered by a
|
|
32
|
+
unit + integration test suite. Still validate against a non-prod instance of your
|
|
33
|
+
own before pointing it at production, especially on SQL Server versions other than
|
|
34
|
+
those noted under caveats.
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
1. **Provision a read-only login.** Run `provisioning/create_readonly_login.sql`
|
|
39
|
+
against your instance (edit names first). This login's permissions are the
|
|
40
|
+
read-only guarantee — see the security model below.
|
|
41
|
+
2. **Install.** `pip install -e .` in a virtual environment. ODBC Driver 18 for
|
|
42
|
+
SQL Server must be installed on the host.
|
|
43
|
+
3. **Store the password outside the repo.** Put it in a plain-text file somewhere
|
|
44
|
+
the repo can't reach (not under the project folder):
|
|
45
|
+
|
|
46
|
+
# Windows PowerShell, UTF-8, password only, no quotes/newline
|
|
47
|
+
New-Item -ItemType Directory -Force C:\Users\you\secrets | Out-Null
|
|
48
|
+
Set-Content -NoNewline -Encoding utf8 C:\Users\you\secrets\mcp_sql.pwd 'your-password'
|
|
49
|
+
|
|
50
|
+
Or skip the password entirely with integrated auth (`MCP_SQL_TRUSTED=yes`) —
|
|
51
|
+
preferred for CJIS/PCI. See **Secret handling** below for all options.
|
|
52
|
+
4. **Configure your MCP client.** Copy the `sql-querystore` block from
|
|
53
|
+
`claude_desktop_config.example.json` into your real Claude Desktop config
|
|
54
|
+
(Windows: `%APPDATA%\Claude\claude_desktop_config.json`), then replace the
|
|
55
|
+
placeholder paths, server name, and `MCP_SQL_PWD_FILE`. Set
|
|
56
|
+
`MCP_SQL_TRUST_CERT=yes` only for a self-signed/local cert; leave it `no`
|
|
57
|
+
against instances with proper certificates.
|
|
58
|
+
5. **Restart your MCP client** and confirm the server shows as running.
|
|
59
|
+
|
|
60
|
+
Never commit your real config or your password file. `.gitignore` already
|
|
61
|
+
excludes `*.pwd`, `.env`, and `claude_desktop_config.json`.
|
|
62
|
+
|
|
63
|
+
## Security model (read this first)
|
|
64
|
+
|
|
65
|
+
The read-only guarantee comes from **the SQL login's permissions**, not from any
|
|
66
|
+
code in this repo:
|
|
67
|
+
|
|
68
|
+
- Provision a dedicated login with `VIEW DATABASE STATE` (and `VIEW SERVER STATE`
|
|
69
|
+
only if you use server-scoped DMVs) and **nothing else** — no `db_datareader`,
|
|
70
|
+
no `SELECT` on user tables. See `provisioning/create_readonly_login.sql`.
|
|
71
|
+
- The keyword screen in `db.py` and the fixed SELECT-only query text are
|
|
72
|
+
**defense-in-depth**, not the primary control.
|
|
73
|
+
- `ApplicationIntent=ReadOnly` in the connection string only routes to a readable
|
|
74
|
+
secondary in an availability group. On a standalone instance it does not make
|
|
75
|
+
the session read-only. Do not rely on it for safety.
|
|
76
|
+
- Credentials never belong in code. The simplest setup uses the
|
|
77
|
+
`MCP_SQL_CONNECTION_STRING` env var, but for CJIS/PCI environments prefer
|
|
78
|
+
integrated auth or a file/secret-store-sourced password — see the
|
|
79
|
+
**Secret handling** section below.
|
|
80
|
+
- Every query is recorded via the audit logger — see **Audit logging** below. In
|
|
81
|
+
a regulated environment, route that logger to a durable file or SIEM.
|
|
82
|
+
|
|
83
|
+
## Setup
|
|
84
|
+
|
|
85
|
+
ODBC Driver 18 for SQL Server must be installed on the host. Install the package,
|
|
86
|
+
then configure the connection via environment variables (see **Secret handling**
|
|
87
|
+
for all options). The recommended form keeps the password in a file, not inline:
|
|
88
|
+
|
|
89
|
+
```powershell
|
|
90
|
+
pip install -e .
|
|
91
|
+
|
|
92
|
+
# PowerShell — connection assembled from parts, password read from a file
|
|
93
|
+
$env:MCP_SQL_SERVER = "yourhost\INSTANCE"
|
|
94
|
+
$env:MCP_SQL_DATABASE = "master"
|
|
95
|
+
$env:MCP_SQL_UID = "mcp_readonly"
|
|
96
|
+
$env:MCP_SQL_PWD_FILE = "C:\path\to\your\secret.pwd"
|
|
97
|
+
$env:MCP_SQL_TRUST_CERT = "no" # "yes" only for a self-signed/local cert
|
|
98
|
+
|
|
99
|
+
python -m mcp_sql_querystore.server
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or use integrated auth with no stored password at all (`MCP_SQL_TRUSTED=yes`).
|
|
103
|
+
A full `MCP_SQL_CONNECTION_STRING` is also accepted for simple cases — see
|
|
104
|
+
**Secret handling**.
|
|
105
|
+
|
|
106
|
+
Register it with your MCP client (e.g. Claude Desktop) as an stdio server
|
|
107
|
+
invoking `python -m mcp_sql_querystore.server`; see
|
|
108
|
+
`claude_desktop_config.example.json`.
|
|
109
|
+
|
|
110
|
+
## Tools
|
|
111
|
+
|
|
112
|
+
All tools are read-only and take a `database_name` (except `sweep_regressions`,
|
|
113
|
+
which can sweep all databases). Each returns JSON, or a structured error dict on
|
|
114
|
+
failure rather than raising.
|
|
115
|
+
|
|
116
|
+
- **get_regressed_queries** — compares a recent window against an earlier
|
|
117
|
+
baseline window per query and flags those worse by at least
|
|
118
|
+
`regression_threshold`. A real baseline-vs-recent comparison, not a top-CPU list.
|
|
119
|
+
- **get_query_execution_plan** — returns compiled plans for a `query_id` with a
|
|
120
|
+
compact JSON summary (missing indexes, warnings incl. implicit conversions,
|
|
121
|
+
key lookups) and optional raw XML.
|
|
122
|
+
- **analyze_parameter_sniffing** — finds queries with multiple compiled plans and
|
|
123
|
+
ranks them by the ratio of slowest to fastest plan mean duration — the classic
|
|
124
|
+
parameter-sniffing signature.
|
|
125
|
+
- **get_missing_index_impact** — scans recent plans containing missing-index
|
|
126
|
+
recommendations, parses the impact score from the plan XML, and aggregates
|
|
127
|
+
duplicate recommendations across queries, ranked by impact then recurrence.
|
|
128
|
+
- **get_wait_stats** — aggregates query wait time by wait category over a window,
|
|
129
|
+
showing *why* queries are slow (CPU, blocking/locks, IO, memory) rather than
|
|
130
|
+
which. De-duplicates flushed vs in-memory rows per Microsoft guidance.
|
|
131
|
+
- **sweep_regressions** — runs regression detection across all Query Store-enabled
|
|
132
|
+
online databases (or an explicit `database_names` list) and returns the worst
|
|
133
|
+
per database, ranked. One failing database does not abort the sweep; its error
|
|
134
|
+
is collected and reported.
|
|
135
|
+
|
|
136
|
+
## Example prompts
|
|
137
|
+
|
|
138
|
+
Once the server is connected to your MCP client, you drive the tools in plain
|
|
139
|
+
language. Name the target database in the prompt (except `sweep_regressions`,
|
|
140
|
+
which can scan all of them). Replace `YourDB` with your database name.
|
|
141
|
+
|
|
142
|
+
**Wait stats — why queries are slow**
|
|
143
|
+
- "What are the top wait categories in YourDB over the last week?"
|
|
144
|
+
- "Is YourDB waiting on CPU, memory, or IO?"
|
|
145
|
+
- "Show me wait stats for YourDB over the last 24 hours."
|
|
146
|
+
|
|
147
|
+
**Execution plans**
|
|
148
|
+
- "Get the execution plan for query_id 10 in YourDB and summarize it."
|
|
149
|
+
- "Does query_id 13 in YourDB have missing index recommendations?"
|
|
150
|
+
- "Are there implicit conversion warnings in query 12's plan in YourDB?"
|
|
151
|
+
|
|
152
|
+
**Regression analysis**
|
|
153
|
+
- "Check YourDB for CPU regressions over the last 24 hours."
|
|
154
|
+
- "Which queries in YourDB regressed by more than 30%?"
|
|
155
|
+
- "Find duration regressions in YourDB, ignoring anything with fewer than 10 executions."
|
|
156
|
+
|
|
157
|
+
**Parameter sniffing**
|
|
158
|
+
- "Check YourDB for parameter sniffing."
|
|
159
|
+
- "Which queries in YourDB have unstable plans?"
|
|
160
|
+
|
|
161
|
+
**Missing indexes**
|
|
162
|
+
- "What missing indexes does YourDB need most?"
|
|
163
|
+
- "Show me the top 10 index recommendations for YourDB by impact."
|
|
164
|
+
|
|
165
|
+
**Multi-database sweep (no database name needed)**
|
|
166
|
+
- "Sweep all my databases for CPU regressions."
|
|
167
|
+
- "Which database has the worst regressions this week?"
|
|
168
|
+
|
|
169
|
+
**Combined — chaining tools in one turn**
|
|
170
|
+
- "Find the biggest CPU regression in YourDB, pull its plan, and tell me why it might have regressed."
|
|
171
|
+
- "YourDB feels slow — diagnose it." (wait stats → regressions → plans)
|
|
172
|
+
- "Full performance triage of YourDB: wait stats, top regressions, and missing indexes."
|
|
173
|
+
|
|
174
|
+
## Known caveats / TODO
|
|
175
|
+
|
|
176
|
+
- **Version differences.** Query Store column names assume SQL Server 2019+/2022
|
|
177
|
+
and Azure SQL MI. Verify against 2016/2017 if you target those.
|
|
178
|
+
- **Regression semantics.** Current logic uses execution-weighted averages. You
|
|
179
|
+
may prefer percentile-based comparison (Query Store doesn't store percentiles
|
|
180
|
+
directly, so that needs `*_stdev` columns and assumptions).
|
|
181
|
+
- **Not time-windowed:** `analyze_parameter_sniffing` aggregates across all Query
|
|
182
|
+
Store history; on busy databases consider adding a `recent_hours` filter like
|
|
183
|
+
the other tools have.
|
|
184
|
+
- **Remaining hardening:** connection retry with backoff, and version-aware column
|
|
185
|
+
handling for mixed 2016/2017/2019/2022 fleets.
|
|
186
|
+
|
|
187
|
+
## Secret handling
|
|
188
|
+
|
|
189
|
+
The connection string is resolved in this order, so the password need not sit in
|
|
190
|
+
plaintext config:
|
|
191
|
+
|
|
192
|
+
1. `MCP_SQL_CONNECTION_STRING` — the full string (simplest; back-compat).
|
|
193
|
+
2. `MCP_SQL_CONNECTION_STRING_FILE` — path to a file holding the full string
|
|
194
|
+
(Docker/K8s secret-mount style).
|
|
195
|
+
3. Assembled from parts: `MCP_SQL_SERVER` (+ `MCP_SQL_DATABASE`, `MCP_SQL_DRIVER`,
|
|
196
|
+
`MCP_SQL_ENCRYPT`, `MCP_SQL_TRUST_CERT`, `MCP_SQL_EXTRA`). Auth is either:
|
|
197
|
+
- **Integrated** (preferred for CJIS/PCI — no password stored): `MCP_SQL_TRUSTED=yes`.
|
|
198
|
+
- **SQL auth**: `MCP_SQL_UID` plus the password from `MCP_SQL_PWD_FILE` (a
|
|
199
|
+
vault-mounted file), `MCP_SQL_PWD_ENV` (name of another env var), or
|
|
200
|
+
`MCP_SQL_PWD` (direct; least preferred).
|
|
201
|
+
|
|
202
|
+
Timeouts: `MCP_SQL_CONNECT_TIMEOUT` (default 10s) and `MCP_SQL_QUERY_TIMEOUT`
|
|
203
|
+
(default 30s, 0 disables).
|
|
204
|
+
|
|
205
|
+
## Audit logging
|
|
206
|
+
|
|
207
|
+
Every query attempt is logged via the `mcp_sql_querystore.audit` logger: tool,
|
|
208
|
+
database, a 12-char hash of the SQL (not the text), row count, elapsed ms, and
|
|
209
|
+
outcome. Connection strings, SQL text, and parameter values are never logged.
|
|
210
|
+
Configure a handler for that logger to route the audit trail to a file or SIEM.
|
|
211
|
+
|
|
212
|
+
## Testing
|
|
213
|
+
|
|
214
|
+
Unit tests (no database, safe in CI):
|
|
215
|
+
|
|
216
|
+
pip install -e ".[test]"
|
|
217
|
+
pytest
|
|
218
|
+
|
|
219
|
+
Integration tests (real instance, opt-in):
|
|
220
|
+
|
|
221
|
+
# set a working connection (any form above), then:
|
|
222
|
+
$env:MCP_SQL_TEST_DATABASE = "RAG"
|
|
223
|
+
$env:MCP_SQL_RUN_INTEGRATION = "1"
|
|
224
|
+
pytest tests/test_integration.py -v
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
mcp_sql_querystore/__init__.py,sha256=17bkvpLjjo2E8PrbLs5YHZOwEOKkaM2p5NQVEQaa9zU,110
|
|
2
|
+
mcp_sql_querystore/db.py,sha256=938cnNIIq2v5F5Wr35BGi4-j0yMBuqs85e9Tqigbn_M,9956
|
|
3
|
+
mcp_sql_querystore/plan_parser.py,sha256=QwG-gzcazerpiLe6xpdD20D3aovKDn-dUYi73Ug_OPI,3617
|
|
4
|
+
mcp_sql_querystore/queries.py,sha256=mCCP2t5_ChfibfdeFji2qRFMgLBYrrDfYGGUG5xaPF0,9524
|
|
5
|
+
mcp_sql_querystore/server.py,sha256=-LU7DPjE1JM7pDPLaXtwzh30ZCuNLoBcZodbMY9QPlE,19729
|
|
6
|
+
mcp_sql_querystore-0.1.0.dist-info/METADATA,sha256=GmdMx2Nt-ozCzV7BgLrD2K1Wz3Ulah5XtABIW5K6TdE,10613
|
|
7
|
+
mcp_sql_querystore-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
mcp_sql_querystore-0.1.0.dist-info/entry_points.txt,sha256=OhWq9ia51RlL9-TYX3xGFXdq8QqhATlSl6mV_aDcZY4,69
|
|
9
|
+
mcp_sql_querystore-0.1.0.dist-info/RECORD,,
|