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.
@@ -0,0 +1,3 @@
1
+ """mcp-sql-querystore: read-only MCP server for SQL Server Query Store diagnostics."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-sql-querystore = mcp_sql_querystore.server:run