postgres-mcp-jacob 0.3.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.
Files changed (32) hide show
  1. postgres_mcp/__init__.py +24 -0
  2. postgres_mcp/artifacts.py +323 -0
  3. postgres_mcp/database_health/__init__.py +4 -0
  4. postgres_mcp/database_health/buffer_health_calc.py +62 -0
  5. postgres_mcp/database_health/connection_health_calc.py +76 -0
  6. postgres_mcp/database_health/constraint_health_calc.py +101 -0
  7. postgres_mcp/database_health/database_health.py +98 -0
  8. postgres_mcp/database_health/index_health_calc.py +345 -0
  9. postgres_mcp/database_health/replication_calc.py +165 -0
  10. postgres_mcp/database_health/sequence_health_calc.py +164 -0
  11. postgres_mcp/database_health/vacuum_health_calc.py +102 -0
  12. postgres_mcp/explain/README.md +26 -0
  13. postgres_mcp/explain/__init__.py +7 -0
  14. postgres_mcp/explain/explain_plan.py +241 -0
  15. postgres_mcp/index/dta_calc.py +837 -0
  16. postgres_mcp/index/index_opt_base.py +671 -0
  17. postgres_mcp/index/llm_opt.py +384 -0
  18. postgres_mcp/index/presentation.py +266 -0
  19. postgres_mcp/server.py +767 -0
  20. postgres_mcp/sql/__init__.py +31 -0
  21. postgres_mcp/sql/bind_params.py +816 -0
  22. postgres_mcp/sql/extension_utils.py +246 -0
  23. postgres_mcp/sql/index.py +52 -0
  24. postgres_mcp/sql/safe_sql.py +1036 -0
  25. postgres_mcp/sql/sql_driver.py +272 -0
  26. postgres_mcp/top_queries/__init__.py +4 -0
  27. postgres_mcp/top_queries/top_queries_calc.py +249 -0
  28. postgres_mcp_jacob-0.3.0.dist-info/METADATA +668 -0
  29. postgres_mcp_jacob-0.3.0.dist-info/RECORD +32 -0
  30. postgres_mcp_jacob-0.3.0.dist-info/WHEEL +4 -0
  31. postgres_mcp_jacob-0.3.0.dist-info/entry_points.txt +2 -0
  32. postgres_mcp_jacob-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,24 @@
1
+ import asyncio
2
+ import sys
3
+
4
+ from . import server
5
+ from . import top_queries
6
+
7
+
8
+ def main():
9
+ """Main entry point for the package."""
10
+ # As of version 3.3.0 Psycopg on Windows is not compatible with the default
11
+ # ProactorEventLoop.
12
+ # See: https://www.psycopg.org/psycopg3/docs/advanced/async.html#async
13
+ if sys.platform == "win32":
14
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
15
+
16
+ asyncio.run(server.main())
17
+
18
+
19
+ # Optionally expose other important items at package level
20
+ __all__ = [
21
+ "main",
22
+ "server",
23
+ "top_queries",
24
+ ]
@@ -0,0 +1,323 @@
1
+ """Artifacts for the Database Tuning Advisor."""
2
+
3
+ import difflib
4
+ import json
5
+ from typing import Any
6
+
7
+ from attrs import define
8
+ from attrs import field
9
+
10
+ # If the recommendation cost is 0.0, we can't calculate the improvement multiple.
11
+ # Return 1000000.0 to indicate infinite improvement.
12
+ INFINITE_IMPROVEMENT_MULTIPLIER = 1000000.0
13
+
14
+
15
+ class ErrorResult:
16
+ """Simple error result class."""
17
+
18
+ def to_text(self) -> str:
19
+ return self.value
20
+
21
+ def __init__(self, message: str):
22
+ self.value = message
23
+
24
+
25
+ def calculate_improvement_multiple(base_cost: float, rec_cost: float) -> float:
26
+ """Calculate the improvement multiple from this recommendation."""
27
+ if base_cost <= 0.0:
28
+ # base_cost or rec_cost might be zero, but as they are floats, the might be
29
+ # represented as -0.0. That's why we compare to <= 0.0.
30
+ return 1.0
31
+ if rec_cost <= 0.0:
32
+ # If the recommendation cost is 0.0, we can't calculate the improvement multiple.
33
+ # Return INFINITE_IMPROVEMENT_MULTIPLIER to indicate infinite improvement.
34
+ return INFINITE_IMPROVEMENT_MULTIPLIER
35
+ return base_cost / rec_cost
36
+
37
+
38
+ @define
39
+ class PlanNode:
40
+ node_type: str
41
+ total_cost: float
42
+ startup_cost: float
43
+ plan_rows: int
44
+ plan_width: int
45
+
46
+ # Actual metrics from ANALYZE
47
+ actual_total_time: float | None = field(default=None)
48
+ actual_startup_time: float | None = field(default=None)
49
+ actual_rows: int | None = field(default=None)
50
+ actual_loops: int | None = field(default=None)
51
+
52
+ # Buffer info
53
+ shared_hit_blocks: int | None = field(default=None)
54
+ shared_read_blocks: int | None = field(default=None)
55
+ shared_written_blocks: int | None = field(default=None)
56
+
57
+ # Other common fields
58
+ relation_name: str | None = field(default=None)
59
+ filter: str | None = field(default=None)
60
+ children: list["PlanNode"] = field(factory=list)
61
+
62
+ @classmethod
63
+ def from_json_data(cls, json_node: dict[str, Any]) -> "PlanNode":
64
+ # Extract basic fields
65
+ node = cls(
66
+ node_type=json_node["Node Type"],
67
+ total_cost=json_node["Total Cost"],
68
+ startup_cost=json_node["Startup Cost"],
69
+ plan_rows=json_node["Plan Rows"],
70
+ plan_width=json_node["Plan Width"],
71
+ )
72
+
73
+ # Optional ANALYZE fields
74
+ if "Actual Total Time" in json_node:
75
+ node.actual_total_time = json_node["Actual Total Time"]
76
+ node.actual_startup_time = json_node["Actual Startup Time"]
77
+ node.actual_rows = json_node["Actual Rows"]
78
+ node.actual_loops = json_node["Actual Loops"]
79
+
80
+ # Optional BUFFERS fields
81
+ if "Shared Hit Blocks" in json_node:
82
+ node.shared_hit_blocks = json_node["Shared Hit Blocks"]
83
+ node.shared_read_blocks = json_node["Shared Read Blocks"]
84
+ node.shared_written_blocks = json_node["Shared Written Blocks"]
85
+
86
+ # Common optional fields
87
+ if "Relation Name" in json_node:
88
+ node.relation_name = json_node["Relation Name"]
89
+ if "Filter" in json_node:
90
+ node.filter = json_node["Filter"]
91
+
92
+ # Recursively process child plans
93
+ if "Plans" in json_node:
94
+ node.children = [cls.from_json_data(child) for child in json_node["Plans"]]
95
+
96
+ return node
97
+
98
+
99
+ @define
100
+ class ExplainPlanArtifact:
101
+ value: str
102
+ plan_tree: PlanNode
103
+ planning_time: float | None = field(default=None)
104
+ execution_time: float | None = field(default=None)
105
+
106
+ def __init__(
107
+ self,
108
+ value: str,
109
+ plan_tree: PlanNode,
110
+ planning_time: float | None = None,
111
+ execution_time: float | None = None,
112
+ ):
113
+ self.value = value
114
+ self.plan_tree = plan_tree
115
+ self.planning_time = planning_time
116
+ self.execution_time = execution_time
117
+
118
+ def to_text(self) -> str:
119
+ """Convert the explain plan to a text representation.
120
+
121
+ Returns:
122
+ str: A string representation of the execution plan with timing information.
123
+ """
124
+ result = []
125
+
126
+ # Add timing information if available
127
+ if self.planning_time is not None:
128
+ result.append(f"Planning Time: {self.planning_time:.3f} ms")
129
+ if self.execution_time is not None:
130
+ result.append(f"Execution Time: {self.execution_time:.3f} ms")
131
+
132
+ # Add plan tree representation
133
+ result.append(self._format_plan_node(self.plan_tree))
134
+
135
+ return "\n".join(result)
136
+
137
+ @staticmethod
138
+ def _format_plan_node(node: PlanNode, level: int = 0) -> str:
139
+ """Recursively format a plan node and its children.
140
+
141
+ Args:
142
+ node: The plan node to format
143
+ level: The current indentation level
144
+
145
+ Returns:
146
+ str: A formatted string representation of the node and its children
147
+ """
148
+ indent = " " * level
149
+ output = f"{indent}→ {node.node_type} (Cost: {node.startup_cost:.2f}..{node.total_cost:.2f})"
150
+
151
+ # Add table name if present
152
+ if node.relation_name:
153
+ output += f" on {node.relation_name}"
154
+
155
+ # Add rows information
156
+ output += f" [Rows: {node.plan_rows}]"
157
+
158
+ # Add actual metrics if available in a compact form
159
+ if node.actual_total_time is not None:
160
+ output += (
161
+ f" [Actual: {node.actual_startup_time:.2f}..{node.actual_total_time:.2f} ms, Rows: {node.actual_rows}, Loops: {node.actual_loops}]"
162
+ )
163
+
164
+ # Add filter if present
165
+ if node.filter:
166
+ filter_text = node.filter
167
+ # Truncate long filters for readability
168
+ if len(filter_text) > 100:
169
+ filter_text = filter_text[:97] + "..."
170
+ output += f"\n{indent} Filter: {filter_text}"
171
+
172
+ # Add buffer information if available in a compact form
173
+ if node.shared_hit_blocks is not None:
174
+ output += f"\n{indent} Buffers - hit: {node.shared_hit_blocks}, read: {node.shared_read_blocks}, written: {node.shared_written_blocks}"
175
+
176
+ # Recursively format children
177
+ if node.children:
178
+ for child in node.children:
179
+ output += "\n" + ExplainPlanArtifact._format_plan_node(child, level + 1)
180
+
181
+ return output
182
+
183
+ @classmethod
184
+ def from_json_data(cls, plan_data: dict[str, Any]) -> "ExplainPlanArtifact":
185
+ if "Plan" not in plan_data:
186
+ raise ValueError("Missing 'Plan' field in explain plan data")
187
+
188
+ # Create plan tree from the "Plan" field
189
+ plan_tree = PlanNode.from_json_data(plan_data["Plan"])
190
+
191
+ # Extract optional timing information
192
+ planning_time = plan_data.get("Planning Time")
193
+ execution_time = plan_data.get("Execution Time")
194
+
195
+ return cls(
196
+ value=json.dumps(plan_data, indent=2),
197
+ plan_tree=plan_tree,
198
+ planning_time=planning_time,
199
+ execution_time=execution_time,
200
+ )
201
+
202
+ @staticmethod
203
+ def format_plan_summary(plan_data):
204
+ """Extract and format key information from a raw plan data."""
205
+ if not plan_data:
206
+ return "No plan data available"
207
+
208
+ try:
209
+ # Create a PlanNode from the raw JSON data
210
+ if "Plan" in plan_data:
211
+ plan_node = PlanNode.from_json_data(plan_data["Plan"])
212
+
213
+ # Use _format_plan_node to format the output
214
+ plan_tree = ExplainPlanArtifact._format_plan_node(plan_node, 0)
215
+
216
+ return f"{plan_tree}"
217
+ else:
218
+ return "Invalid plan data (missing Plan field)"
219
+
220
+ except Exception as e:
221
+ return f"Error summarizing plan: {e}"
222
+
223
+ @staticmethod
224
+ def create_plan_diff(before_plan: dict[str, Any], after_plan: dict[str, Any]) -> str:
225
+ """Generate a textual diff between two explain plans.
226
+
227
+ Args:
228
+ before_plan: The explain plan before changes
229
+ after_plan: The explain plan after changes
230
+
231
+ Returns:
232
+ A string containing a readable diff between the two plans
233
+ """
234
+ if not before_plan or not after_plan:
235
+ return "Cannot generate diff: Missing plan data"
236
+
237
+ try:
238
+ # Create PlanNode objects from the plans
239
+ before_tree = PlanNode.from_json_data(before_plan["Plan"]) if "Plan" in before_plan else None
240
+ after_tree = PlanNode.from_json_data(after_plan["Plan"]) if "Plan" in after_plan else None
241
+
242
+ if not before_tree or not after_tree:
243
+ return "Cannot generate diff: Invalid plan structure"
244
+
245
+ # Format the plans as text
246
+ before_lines = ExplainPlanArtifact._format_plan_node(before_tree).split("\n")
247
+ after_lines = ExplainPlanArtifact._format_plan_node(after_tree).split("\n")
248
+
249
+ # Generate a readable diff with context
250
+ diff_lines = []
251
+ diff_lines.append("PLAN CHANGES:")
252
+ diff_lines.append("------------")
253
+
254
+ # Extract cost information for a summary
255
+ before_cost = before_tree.total_cost
256
+ after_cost = after_tree.total_cost
257
+ improvement = calculate_improvement_multiple(before_cost, after_cost)
258
+
259
+ diff_lines.append(f"Cost: {before_cost:.2f} → {after_cost:.2f} ({improvement:.1f}x improvement)")
260
+ diff_lines.append("")
261
+
262
+ # Node type changes - a simplified structural diff
263
+ diff_lines.append("Operation Changes:")
264
+
265
+ # Helper function to extract node types with indentation
266
+ def extract_node_types(node, level=0, result=None):
267
+ if result is None:
268
+ result = []
269
+ indent = " " * level
270
+ node_info = f"{indent}→ {node.node_type}"
271
+ if node.relation_name:
272
+ node_info += f" on {node.relation_name}"
273
+ result.append(node_info)
274
+ for child in node.children:
275
+ extract_node_types(child, level + 1, result)
276
+ return result
277
+
278
+ before_structure = extract_node_types(before_tree)
279
+ after_structure = extract_node_types(after_tree)
280
+
281
+ # Generate the structural diff
282
+ structure_diff = list(
283
+ difflib.unified_diff(
284
+ before_structure,
285
+ after_structure,
286
+ n=1, # Context lines
287
+ lineterm="",
288
+ )
289
+ )
290
+
291
+ # Add structural diff to output
292
+ if structure_diff:
293
+ diff_lines.extend(structure_diff)
294
+ else:
295
+ diff_lines.append("No structural changes detected")
296
+
297
+ # Add more specific details about key changes
298
+ diff_lines.append("")
299
+ diff_lines.append("Major Changes:")
300
+
301
+ # Look for significant changes like seq scan to index scan, changed filters, etc.
302
+ # This requires traversing both trees and comparing nodes
303
+
304
+ # For simplicity, we'll just list key changes in cost and rows
305
+ if before_tree.node_type != after_tree.node_type:
306
+ diff_lines.append(f"- Root operation changed: {before_tree.node_type} → {after_tree.node_type}")
307
+
308
+ # Compare scan methods used
309
+ before_scans = [line for line in before_lines if "Seq Scan" in line]
310
+ after_scans = [line for line in after_lines if "Seq Scan" in line]
311
+ if len(before_scans) > len(after_scans):
312
+ diff_lines.append(f"- {len(before_scans) - len(after_scans)} sequential scans replaced with more efficient access methods")
313
+
314
+ # Look for new index scans
315
+ before_idx_scans = [line for line in before_lines if "Index Scan" in line]
316
+ after_idx_scans = [line for line in after_lines if "Index Scan" in line]
317
+ if len(after_idx_scans) > len(before_idx_scans):
318
+ diff_lines.append(f"- {len(after_idx_scans) - len(before_idx_scans)} new index scans used")
319
+
320
+ return "\n".join(diff_lines)
321
+
322
+ except Exception as e:
323
+ return f"Error generating plan diff: {e}"
@@ -0,0 +1,4 @@
1
+ from .database_health import DatabaseHealthTool
2
+ from .database_health import HealthType
3
+
4
+ __all__ = ["DatabaseHealthTool", "HealthType"]
@@ -0,0 +1,62 @@
1
+ from typing import Any
2
+
3
+ from ..sql import SqlDriver
4
+
5
+
6
+ class BufferHealthCalc:
7
+ _cached_indexes: list[dict[str, Any]] | None = None
8
+
9
+ def __init__(self, sql_driver: SqlDriver):
10
+ self.sql_driver = sql_driver
11
+
12
+ async def index_hit_rate(self, threshold: float = 0.95) -> str:
13
+ """Calculate the index cache hit rate.
14
+
15
+ Returns:
16
+ String describing the index cache hit rate as a percentage and comparison to threshold
17
+ """
18
+ result = await self.sql_driver.execute_query("""
19
+ SELECT
20
+ (sum(idx_blks_hit)) / nullif(sum(idx_blks_hit + idx_blks_read), 0) AS rate
21
+ FROM
22
+ pg_statio_user_indexes
23
+ """)
24
+
25
+ result_list = [dict(x.cells) for x in result] if result else []
26
+
27
+ if not result_list or result_list[0]["rate"] is None:
28
+ return "No index cache statistics available."
29
+
30
+ hit_rate = float(result_list[0]["rate"]) * 100
31
+ threshold_pct = threshold * 100
32
+
33
+ if hit_rate >= threshold_pct:
34
+ return f"Index cache hit rate: {hit_rate:.1f}% (above {threshold_pct:.1f}% threshold)"
35
+ else:
36
+ return f"Index cache hit rate: {hit_rate:.1f}% (below {threshold_pct:.1f}% threshold)"
37
+
38
+ async def table_hit_rate(self, threshold: float = 0.95) -> str:
39
+ """Calculate the table cache hit rate.
40
+
41
+ Returns:
42
+ String describing the table cache hit rate as a percentage and comparison to threshold
43
+ """
44
+ result = await self.sql_driver.execute_query("""
45
+ SELECT
46
+ sum(heap_blks_hit) / nullif(sum(heap_blks_hit + heap_blks_read), 0) AS rate
47
+ FROM
48
+ pg_statio_user_tables
49
+ """)
50
+
51
+ result_list = [dict(x.cells) for x in result] if result else []
52
+
53
+ if not result_list or result_list[0]["rate"] is None:
54
+ return "No table cache statistics available."
55
+
56
+ hit_rate = float(result_list[0]["rate"]) * 100
57
+ threshold_pct = threshold * 100
58
+
59
+ if hit_rate >= threshold_pct:
60
+ return f"Table cache hit rate: {hit_rate:.1f}% (above {threshold_pct:.1f}% threshold)"
61
+ else:
62
+ return f"Table cache hit rate: {hit_rate:.1f}% (below {threshold_pct:.1f}% threshold)"
@@ -0,0 +1,76 @@
1
+ from dataclasses import dataclass
2
+
3
+ from ..sql import SqlDriver
4
+
5
+
6
+ @dataclass
7
+ class ConnectionHealthMetrics:
8
+ total_connections: int
9
+ idle_connections: int
10
+ max_total_connections: int
11
+ max_idle_connections: int
12
+ is_total_connections_healthy: bool
13
+ is_idle_connections_healthy: bool
14
+
15
+ @property
16
+ def is_healthy(self) -> bool:
17
+ return self.is_total_connections_healthy and self.is_idle_connections_healthy
18
+
19
+
20
+ class ConnectionHealthCalc:
21
+ def __init__(
22
+ self,
23
+ sql_driver: SqlDriver,
24
+ max_total_connections: int = 500,
25
+ max_idle_connections: int = 100,
26
+ ):
27
+ self.sql_driver = sql_driver
28
+ self.max_total_connections = max_total_connections
29
+ self.max_idle_connections = max_idle_connections
30
+
31
+ async def total_connections_check(self) -> str:
32
+ """Check if total number of connections is within healthy limits."""
33
+ total = await self._get_total_connections()
34
+
35
+ if total <= self.max_total_connections:
36
+ return f"Total connections healthy: {total}"
37
+ return f"High number of connections: {total} (max: {self.max_total_connections})"
38
+
39
+ async def idle_connections_check(self) -> str:
40
+ """Check if number of idle connections is within healthy limits."""
41
+ idle = await self._get_idle_connections()
42
+
43
+ if idle <= self.max_idle_connections:
44
+ return f"Idle connections healthy: {idle}"
45
+ return f"High number of idle connections: {idle} (max: {self.max_idle_connections})"
46
+
47
+ async def connection_health_check(self) -> str:
48
+ """Run all connection health checks and return combined results."""
49
+ total = await self._get_total_connections()
50
+ idle = await self._get_idle_connections()
51
+
52
+ if total > self.max_total_connections:
53
+ return f"High number of connections: {total}"
54
+ elif idle > self.max_idle_connections:
55
+ return f"High number of connections idle in transaction: {idle}"
56
+ else:
57
+ return f"Connections healthy: {total} total, {idle} idle"
58
+
59
+ async def _get_total_connections(self) -> int:
60
+ """Get the total number of database connections."""
61
+ result = await self.sql_driver.execute_query("""
62
+ SELECT COUNT(*) as count
63
+ FROM pg_stat_activity
64
+ """)
65
+ result_list = [dict(x.cells) for x in result] if result else []
66
+ return result_list[0]["count"] if result_list else 0
67
+
68
+ async def _get_idle_connections(self) -> int:
69
+ """Get the number of connections that are idle in transaction."""
70
+ result = await self.sql_driver.execute_query("""
71
+ SELECT COUNT(*) as count
72
+ FROM pg_stat_activity
73
+ WHERE state = 'idle in transaction'
74
+ """)
75
+ result_list = [dict(x.cells) for x in result] if result else []
76
+ return result_list[0]["count"] if result_list else 0
@@ -0,0 +1,101 @@
1
+ from dataclasses import dataclass
2
+
3
+ from ..sql import SqlDriver
4
+
5
+
6
+ @dataclass
7
+ class ConstraintMetrics:
8
+ schema: str
9
+ table: str
10
+ name: str
11
+ referenced_schema: str | None
12
+ referenced_table: str | None
13
+
14
+
15
+ class ConstraintHealthCalc:
16
+ def __init__(self, sql_driver: SqlDriver):
17
+ self.sql_driver = sql_driver
18
+
19
+ async def invalid_constraints_check(self) -> str:
20
+ """Check for any invalid constraints in the database.
21
+
22
+ Returns:
23
+ String describing any invalid constraints found
24
+ """
25
+ metrics = await self._get_invalid_constraints()
26
+
27
+ if not metrics:
28
+ return "No invalid constraints found."
29
+
30
+ result = ["Invalid constraints found:"]
31
+ for metric in metrics:
32
+ if metric.referenced_table:
33
+ result.append(
34
+ f"Constraint '{metric.name}' on table '{metric.schema}.{metric.table}' "
35
+ f"referencing '{metric.referenced_schema}.{metric.referenced_table}' is invalid"
36
+ )
37
+ else:
38
+ result.append(f"Constraint '{metric.name}' on table '{metric.schema}.{metric.table}' is invalid")
39
+ return "\n".join(result)
40
+
41
+ async def _get_invalid_constraints(self) -> list[ConstraintMetrics]:
42
+ """Get all invalid constraints in the database."""
43
+ results = await self.sql_driver.execute_query("""
44
+ SELECT
45
+ nsp.nspname AS schema,
46
+ rel.relname AS table,
47
+ con.conname AS name,
48
+ fnsp.nspname AS referenced_schema,
49
+ frel.relname AS referenced_table
50
+ FROM
51
+ pg_catalog.pg_constraint con
52
+ INNER JOIN
53
+ pg_catalog.pg_class rel ON rel.oid = con.conrelid
54
+ LEFT JOIN
55
+ pg_catalog.pg_class frel ON frel.oid = con.confrelid
56
+ LEFT JOIN
57
+ pg_catalog.pg_namespace nsp ON nsp.oid = con.connamespace
58
+ LEFT JOIN
59
+ pg_catalog.pg_namespace fnsp ON fnsp.oid = frel.relnamespace
60
+ WHERE
61
+ con.convalidated = 'f'
62
+ """)
63
+
64
+ if not results:
65
+ return []
66
+
67
+ result_list = [dict(x.cells) for x in results]
68
+
69
+ return [
70
+ ConstraintMetrics(
71
+ schema=row["schema"],
72
+ table=row["table"],
73
+ name=row["name"],
74
+ referenced_schema=row["referenced_schema"],
75
+ referenced_table=row["referenced_table"],
76
+ )
77
+ for row in result_list
78
+ ]
79
+
80
+ async def _get_total_constraints(self) -> int:
81
+ """Get the total number of constraints."""
82
+ result = await self.sql_driver.execute_query("""
83
+ SELECT COUNT(*) as count
84
+ FROM information_schema.table_constraints
85
+ """)
86
+ if not result:
87
+ return 0
88
+ result_list = [dict(x.cells) for x in result]
89
+ return result_list[0]["count"] if result_list else 0
90
+
91
+ async def _get_active_constraints(self) -> int:
92
+ """Get the number of active constraints."""
93
+ result = await self.sql_driver.execute_query("""
94
+ SELECT COUNT(*) as count
95
+ FROM information_schema.table_constraints
96
+ WHERE is_deferrable = 'NO'
97
+ """)
98
+ if not result:
99
+ return 0
100
+ result_list = [dict(x.cells) for x in result]
101
+ return result_list[0]["count"] if result_list else 0
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from enum import Enum
5
+ from typing import List
6
+
7
+ import mcp.types as types
8
+
9
+ from .buffer_health_calc import BufferHealthCalc
10
+ from .connection_health_calc import ConnectionHealthCalc
11
+ from .constraint_health_calc import ConstraintHealthCalc
12
+ from .index_health_calc import IndexHealthCalc
13
+ from .replication_calc import ReplicationCalc
14
+ from .sequence_health_calc import SequenceHealthCalc
15
+ from .vacuum_health_calc import VacuumHealthCalc
16
+
17
+ ResponseType = List[types.TextContent | types.ImageContent | types.EmbeddedResource]
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class HealthType(str, Enum):
23
+ INDEX = "index"
24
+ CONNECTION = "connection"
25
+ VACUUM = "vacuum"
26
+ SEQUENCE = "sequence"
27
+ REPLICATION = "replication"
28
+ BUFFER = "buffer"
29
+ CONSTRAINT = "constraint"
30
+ ALL = "all"
31
+
32
+
33
+ class DatabaseHealthTool:
34
+ """Tool for analyzing database health metrics."""
35
+
36
+ def __init__(self, sql_driver):
37
+ self.sql_driver = sql_driver
38
+
39
+ async def health(self, health_type: str) -> str:
40
+ """Run database health checks for the specified components.
41
+
42
+ Args:
43
+ health_type: Comma-separated list of health check types to perform
44
+ Valid values: index, connection, vacuum, sequence, replication, buffer, constraint, all
45
+
46
+ Returns:
47
+ A string with the health check results
48
+ """
49
+ try:
50
+ result = ""
51
+ try:
52
+ health_types = {HealthType(x.strip()) for x in health_type.split(",")}
53
+ except ValueError:
54
+ return (
55
+ f"Invalid health types provided: '{health_type}'. "
56
+ + f"Valid values are: {', '.join(sorted([t.value for t in HealthType]))}. "
57
+ + "Please try again with a comma-separated list of valid health types."
58
+ )
59
+
60
+ if HealthType.ALL in health_types:
61
+ health_types = [t.value for t in HealthType if t != HealthType.ALL]
62
+
63
+ if HealthType.INDEX in health_types:
64
+ index_health = IndexHealthCalc(self.sql_driver)
65
+ result += "Invalid index check: " + await index_health.invalid_index_check() + "\n"
66
+ result += "Duplicate index check: " + await index_health.duplicate_index_check() + "\n"
67
+ result += "Index bloat: " + await index_health.index_bloat() + "\n"
68
+ result += "Unused index check: " + await index_health.unused_indexes() + "\n"
69
+
70
+ if HealthType.CONNECTION in health_types:
71
+ connection_health = ConnectionHealthCalc(self.sql_driver)
72
+ result += "Connection health: " + await connection_health.connection_health_check() + "\n"
73
+
74
+ if HealthType.VACUUM in health_types:
75
+ vacuum_health = VacuumHealthCalc(self.sql_driver)
76
+ result += "Vacuum health: " + await vacuum_health.transaction_id_danger_check() + "\n"
77
+
78
+ if HealthType.SEQUENCE in health_types:
79
+ sequence_health = SequenceHealthCalc(self.sql_driver)
80
+ result += "Sequence health: " + await sequence_health.sequence_danger_check() + "\n"
81
+
82
+ if HealthType.REPLICATION in health_types:
83
+ replication_health = ReplicationCalc(self.sql_driver)
84
+ result += "Replication health: " + await replication_health.replication_health_check() + "\n"
85
+
86
+ if HealthType.BUFFER in health_types:
87
+ buffer_health = BufferHealthCalc(self.sql_driver)
88
+ result += "Buffer health for indexes: " + await buffer_health.index_hit_rate() + "\n"
89
+ result += "Buffer health for tables: " + await buffer_health.table_hit_rate() + "\n"
90
+
91
+ if HealthType.CONSTRAINT in health_types:
92
+ constraint_health = ConstraintHealthCalc(self.sql_driver)
93
+ result += "Constraint health: " + await constraint_health.invalid_constraints_check() + "\n"
94
+
95
+ return result if result else "No health checks were performed."
96
+ except Exception as e:
97
+ logger.error(f"Error calculating database health: {e}", exc_info=True)
98
+ return f"Error calculating database health: {e}"