mapeg-postgres-mcp 1.0.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,323 @@
1
+ Metadata-Version: 2.4
2
+ Name: mapeg-postgres-mcp
3
+ Version: 1.0.0
4
+ Summary: PostgreSQL MCP server for Model Context Protocol
5
+ Author: mytsx
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mytsx/mcp-servers
8
+ Project-URL: Repository, https://github.com/mytsx/mcp-servers
9
+ Project-URL: Issues, https://github.com/mytsx/mcp-servers/issues
10
+ Keywords: mcp,model-context-protocol,postgresql,database,claude
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Database
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: mcp>=1.0.0
24
+ Requires-Dist: psycopg2-binary>=2.9.0
25
+ Requires-Dist: python-dotenv>=1.0.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
29
+ Requires-Dist: black>=23.0.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # PostgreSQL MCP Server
34
+
35
+ [![Python](https://img.shields.io/badge/python-3.10+-blue?logo=python&logoColor=white)](https://python.org)
36
+ [![MCP](https://img.shields.io/badge/MCP-1.0+-purple)](https://modelcontextprotocol.io)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
38
+ [![PyPI](https://img.shields.io/pypi/v/mapeg-postgres-mcp)](https://pypi.org/project/mapeg-postgres-mcp/)
39
+
40
+ A Model Context Protocol (MCP) server for PostgreSQL databases. Query, explore, and analyze your PostgreSQL databases directly from any MCP-compatible AI client.
41
+
42
+ ## Features
43
+
44
+ - **Execute SQL** — Run any SQL query with automatic result formatting
45
+ - **Natural Language Queries** — Ask questions in plain English or Turkish
46
+ - **Schema Exploration** — List tables, describe columns, view statistics
47
+ - **Execution Plans** — EXPLAIN / EXPLAIN ANALYZE with buffer stats
48
+ - **Query History** — Review past queries scoped to your database and workspace
49
+ - **Read-Only Mode** — Optional write protection via `READ_ONLY=true`
50
+ - **Zero Install** — Works with `uvx`, no virtual environment needed
51
+
52
+ ## Quick Start
53
+
54
+ ### Claude Code
55
+
56
+ ```bash
57
+ claude mcp add postgres \
58
+ -e DB_HOST=localhost \
59
+ -e DB_PORT=5432 \
60
+ -e DB_NAME=mydb \
61
+ -e DB_USER=postgres \
62
+ -e DB_PASSWORD=secret \
63
+ -- uvx mapeg-postgres-mcp
64
+ ```
65
+
66
+ ### Claude Desktop
67
+
68
+ Add to your config file:
69
+
70
+ - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
71
+ - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "postgres": {
77
+ "command": "uvx",
78
+ "args": ["mapeg-postgres-mcp"],
79
+ "env": {
80
+ "DB_HOST": "localhost",
81
+ "DB_PORT": "5432",
82
+ "DB_NAME": "mydb",
83
+ "DB_USER": "postgres",
84
+ "DB_PASSWORD": "secret"
85
+ }
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ ### Cursor
92
+
93
+ Add to `~/.cursor/mcp.json`:
94
+
95
+ ```json
96
+ {
97
+ "mcpServers": {
98
+ "postgres": {
99
+ "command": "uvx",
100
+ "args": ["mapeg-postgres-mcp"],
101
+ "env": {
102
+ "DB_HOST": "localhost",
103
+ "DB_PORT": "5432",
104
+ "DB_NAME": "mydb",
105
+ "DB_USER": "postgres",
106
+ "DB_PASSWORD": "secret"
107
+ }
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ ### Windsurf
114
+
115
+ Add to Windsurf MCP config:
116
+
117
+ ```json
118
+ {
119
+ "mcpServers": {
120
+ "postgres": {
121
+ "command": "uvx",
122
+ "args": ["mapeg-postgres-mcp"],
123
+ "env": {
124
+ "DB_HOST": "localhost",
125
+ "DB_PORT": "5432",
126
+ "DB_NAME": "mydb",
127
+ "DB_USER": "postgres",
128
+ "DB_PASSWORD": "secret"
129
+ }
130
+ }
131
+ }
132
+ }
133
+ ```
134
+
135
+ ### VS Code
136
+
137
+ Add to your VS Code settings (JSON):
138
+
139
+ ```json
140
+ "mcp": {
141
+ "servers": {
142
+ "postgres": {
143
+ "type": "stdio",
144
+ "command": "uvx",
145
+ "args": ["mapeg-postgres-mcp"],
146
+ "env": {
147
+ "DB_HOST": "localhost",
148
+ "DB_PORT": "5432",
149
+ "DB_NAME": "mydb",
150
+ "DB_USER": "postgres",
151
+ "DB_PASSWORD": "secret"
152
+ }
153
+ }
154
+ }
155
+ }
156
+ ```
157
+
158
+ ### Gemini CLI
159
+
160
+ Add to `~/.gemini/settings.json`:
161
+
162
+ ```json
163
+ {
164
+ "mcpServers": {
165
+ "postgres": {
166
+ "command": "uvx",
167
+ "args": ["mapeg-postgres-mcp"],
168
+ "env": {
169
+ "DB_HOST": "localhost",
170
+ "DB_PORT": "5432",
171
+ "DB_NAME": "mydb",
172
+ "DB_USER": "postgres",
173
+ "DB_PASSWORD": "secret"
174
+ }
175
+ }
176
+ }
177
+ }
178
+ ```
179
+
180
+ ### GitHub Copilot
181
+
182
+ Add to `~/.copilot/mcp-config.json`:
183
+
184
+ ```json
185
+ {
186
+ "mcpServers": {
187
+ "postgres": {
188
+ "command": "uvx",
189
+ "args": ["mapeg-postgres-mcp"],
190
+ "env": {
191
+ "DB_HOST": "localhost",
192
+ "DB_PORT": "5432",
193
+ "DB_NAME": "mydb",
194
+ "DB_USER": "postgres",
195
+ "DB_PASSWORD": "secret"
196
+ }
197
+ }
198
+ }
199
+ }
200
+ ```
201
+
202
+ ### OpenAI Codex
203
+
204
+ Add to `~/.codex/config.toml`:
205
+
206
+ ```toml
207
+ [mcp_servers.postgres]
208
+ command = "uvx"
209
+ args = ["mapeg-postgres-mcp"]
210
+
211
+ [mcp_servers.postgres.env]
212
+ DB_HOST = "localhost"
213
+ DB_PORT = "5432"
214
+ DB_NAME = "mydb"
215
+ DB_USER = "postgres"
216
+ DB_PASSWORD = "secret"
217
+ ```
218
+
219
+ ### Install from Source
220
+
221
+ ```bash
222
+ cd mapeg-postgres-mcp
223
+ pip install -e .
224
+ ```
225
+
226
+ ## Configuration
227
+
228
+ | Environment Variable | Required | Default | Description |
229
+ |---------------------|----------|---------|-------------|
230
+ | `DB_HOST` | No | `localhost` | PostgreSQL host |
231
+ | `DB_PORT` | No | `5432` | PostgreSQL port |
232
+ | `DB_NAME` | Yes | — | Database name |
233
+ | `DB_USER` | No | `postgres` | Database user |
234
+ | `DB_PASSWORD` | Yes | — | Database password |
235
+ | `READ_ONLY` | No | `false` | Block write operations (INSERT, UPDATE, DELETE, DROP, etc.) |
236
+
237
+ ## Tools
238
+
239
+ <details>
240
+ <summary><code>execute_sql</code> — Run SQL queries</summary>
241
+
242
+ Execute any SQL query on the connected PostgreSQL database.
243
+
244
+ | Parameter | Type | Required | Description |
245
+ |-----------|------|----------|-------------|
246
+ | `sql` | string | Yes | SQL query to execute |
247
+ | `limit` | integer | No | Max rows to return (default: 100) |
248
+
249
+ </details>
250
+
251
+ <details>
252
+ <summary><code>natural_language_query</code> — Query in plain language</summary>
253
+
254
+ Convert natural language to SQL and execute. Supports Turkish and English.
255
+
256
+ | Parameter | Type | Required | Description |
257
+ |-----------|------|----------|-------------|
258
+ | `query` | string | Yes | Natural language query |
259
+
260
+ **Examples:** "show all tables", "tabloları listele", "show database info"
261
+
262
+ </details>
263
+
264
+ <details>
265
+ <summary><code>describe_table</code> — Table structure details</summary>
266
+
267
+ Get column definitions, row count, and table size.
268
+
269
+ | Parameter | Type | Required | Description |
270
+ |-----------|------|----------|-------------|
271
+ | `table_name` | string | Yes | Table name (`schema.table` or `table`) |
272
+
273
+ </details>
274
+
275
+ <details>
276
+ <summary><code>smart_query</code> — AI-powered query assistant</summary>
277
+
278
+ Analyzes your schema and suggests queries based on your question.
279
+
280
+ | Parameter | Type | Required | Description |
281
+ |-----------|------|----------|-------------|
282
+ | `question` | string | Yes | Question about your data |
283
+
284
+ </details>
285
+
286
+ <details>
287
+ <summary><code>explain_query</code> — Execution plan analysis</summary>
288
+
289
+ Show the EXPLAIN plan for a SQL query.
290
+
291
+ | Parameter | Type | Required | Description |
292
+ |-----------|------|----------|-------------|
293
+ | `sql` | string | Yes | SQL query to explain |
294
+ | `analyze` | boolean | No | Run EXPLAIN ANALYZE (default: false) |
295
+ | `format` | string | No | Output format: `text`, `json`, `yaml` (default: text) |
296
+ | `buffers` | boolean | No | Include buffer usage (requires analyze=true) |
297
+
298
+ </details>
299
+
300
+ <details>
301
+ <summary><code>get_query_history</code> — Review past queries</summary>
302
+
303
+ Retrieve recent query history scoped to this database and workspace.
304
+
305
+ | Parameter | Type | Required | Description |
306
+ |-----------|------|----------|-------------|
307
+ | `limit` | integer | No | Number of entries (default: 20) |
308
+ | `status` | string | No | Filter: `success` or `error` |
309
+ | `tool_name` | string | No | Filter by tool name |
310
+
311
+ </details>
312
+
313
+ ## Resources
314
+
315
+ | URI | Description |
316
+ |-----|-------------|
317
+ | `postgresql://tables` | List all user tables |
318
+ | `postgresql://schema` | Detailed schema with columns |
319
+ | `postgresql://stats` | Database version and statistics |
320
+
321
+ ## License
322
+
323
+ MIT
@@ -0,0 +1,10 @@
1
+ mapeg_postgres_mcp-1.0.0.dist-info/licenses/LICENSE,sha256=geN4XKWlxLA8S01SA9_3thLyxgBWiTazfAAQHkQOo9A,1062
2
+ mcp_server_postgres/__init__.py,sha256=iTZ1DLKBM87jJ2uKqSpDbOEpuXgH1W4BVWlGwrmJx9Y,239
3
+ mcp_server_postgres/__main__.py,sha256=i81I2JgnIS68ZBaKXvB5wQu8aChnH7g2Z-VfcV0zz3s,760
4
+ mcp_server_postgres/query_logger.py,sha256=67PZVZsWRPnDEx0nlsVEOKRTUyMJsPe0nv1vidkrySU,4292
5
+ mcp_server_postgres/server.py,sha256=v7z3szFZFldTPA3xj_ON7HkFxzcfJdLvNYIAJuPqZiA,33520
6
+ mapeg_postgres_mcp-1.0.0.dist-info/METADATA,sha256=6jqHTKg69pYqJiinCs38ksQKynSFSOe8p360AkiL53E,8195
7
+ mapeg_postgres_mcp-1.0.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
8
+ mapeg_postgres_mcp-1.0.0.dist-info/entry_points.txt,sha256=Zv0nZVnXd3foLxRyorgyVSdnCx_xFhzUXHDGBIb6ajo,72
9
+ mapeg_postgres_mcp-1.0.0.dist-info/top_level.txt,sha256=82ig7A4oMxjpywnpLXBWG4V-qVFLHlpD41BvgfbGwSA,20
10
+ mapeg_postgres_mcp-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mapeg-postgres-mcp = mcp_server_postgres.__main__:run
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 mytsx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ mcp_server_postgres
@@ -0,0 +1,12 @@
1
+ """
2
+ PostgreSQL MCP Server
3
+ Natural language queries to PostgreSQL database via MCP protocol
4
+ """
5
+
6
+ __version__ = "1.0.0"
7
+ __author__ = "Your Name"
8
+ __license__ = "MIT"
9
+
10
+ from .server import PostgreSQLMCPServer
11
+
12
+ __all__ = ["PostgreSQLMCPServer"]
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Entry point for PostgreSQL MCP Server
4
+ Allows the package to be run as: python -m mcp_server_postgres
5
+ Or via uvx: uvx mcp-server-postgres
6
+ """
7
+
8
+ import asyncio
9
+ from .server import PostgreSQLMCPServer
10
+ from mcp.server.stdio import stdio_server
11
+
12
+
13
+ async def main():
14
+ """Main entry point for the PostgreSQL MCP server"""
15
+ postgresql_server = PostgreSQLMCPServer()
16
+
17
+ async with stdio_server() as (read_stream, write_stream):
18
+ await postgresql_server.server.run(
19
+ read_stream,
20
+ write_stream,
21
+ postgresql_server.server.create_initialization_options()
22
+ )
23
+
24
+
25
+ def run():
26
+ """Synchronous wrapper for the async main function"""
27
+ asyncio.run(main())
28
+
29
+
30
+ if __name__ == "__main__":
31
+ run()
@@ -0,0 +1,134 @@
1
+ """
2
+ Query Logger - Logs MCP query executions to a shared SQLite database.
3
+ Well-known path: ~/.local/share/mapeg-mcp/query_logs.db
4
+ """
5
+
6
+ import os
7
+ import sqlite3
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+
12
+ def _get_db_path() -> Path:
13
+ db_dir = Path.home() / ".local" / "share" / "mapeg-mcp"
14
+ db_dir.mkdir(parents=True, exist_ok=True)
15
+ return db_dir / "query_logs.db"
16
+
17
+
18
+ def _ensure_table(conn: sqlite3.Connection):
19
+ conn.execute("""
20
+ CREATE TABLE IF NOT EXISTS query_logs (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ timestamp TEXT NOT NULL,
23
+ server_type TEXT NOT NULL,
24
+ tool_name TEXT NOT NULL,
25
+ query_text TEXT NOT NULL,
26
+ execution_time_ms REAL NOT NULL,
27
+ status TEXT NOT NULL,
28
+ row_count INTEGER DEFAULT 0,
29
+ error_message TEXT DEFAULT '',
30
+ user_query TEXT DEFAULT '',
31
+ response_text TEXT DEFAULT '',
32
+ db_identifier TEXT DEFAULT '',
33
+ workspace_path TEXT DEFAULT ''
34
+ )
35
+ """)
36
+ conn.execute("""
37
+ CREATE INDEX IF NOT EXISTS idx_timestamp
38
+ ON query_logs(timestamp DESC)
39
+ """)
40
+ conn.execute("""
41
+ CREATE INDEX IF NOT EXISTS idx_server_type
42
+ ON query_logs(server_type)
43
+ """)
44
+ # Migrate: add columns if missing (existing DB)
45
+ cursor = conn.execute("PRAGMA table_info(query_logs)")
46
+ columns = {row[1] for row in cursor.fetchall()}
47
+ if "db_identifier" not in columns:
48
+ conn.execute("ALTER TABLE query_logs ADD COLUMN db_identifier TEXT DEFAULT ''")
49
+ if "workspace_path" not in columns:
50
+ conn.execute("ALTER TABLE query_logs ADD COLUMN workspace_path TEXT DEFAULT ''")
51
+ conn.commit()
52
+
53
+
54
+ def direct_log_query_execution(
55
+ server_type: str,
56
+ tool_name: str,
57
+ query_text: str,
58
+ execution_time_ms: float,
59
+ status: str,
60
+ row_count: int = 0,
61
+ error_message: str = "",
62
+ user_query: str = "",
63
+ response_text: str = "",
64
+ db_identifier: str = "",
65
+ workspace_path: str = "",
66
+ ) -> int:
67
+ """Log a query execution synchronously to the shared SQLite database."""
68
+ try:
69
+ db_path = _get_db_path()
70
+ conn = sqlite3.connect(str(db_path))
71
+ _ensure_table(conn)
72
+
73
+ timestamp = datetime.now().isoformat()
74
+ cursor = conn.execute(
75
+ """
76
+ INSERT INTO query_logs (
77
+ timestamp, server_type, tool_name, query_text,
78
+ execution_time_ms, status, row_count, error_message,
79
+ user_query, response_text, db_identifier, workspace_path
80
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
81
+ """,
82
+ (
83
+ timestamp, server_type, tool_name, query_text,
84
+ execution_time_ms, status, row_count, error_message,
85
+ user_query, response_text, db_identifier, workspace_path,
86
+ ),
87
+ )
88
+ log_id = cursor.lastrowid
89
+ conn.commit()
90
+ conn.close()
91
+ return log_id
92
+ except Exception:
93
+ return 0
94
+
95
+
96
+ def get_query_history(
97
+ db_identifier: str,
98
+ workspace_path: str,
99
+ limit: int = 20,
100
+ status: str = "",
101
+ tool_name: str = "",
102
+ ) -> list:
103
+ """Retrieve recent query logs scoped to this server's db+workspace."""
104
+ try:
105
+ db_path = _get_db_path()
106
+ if not db_path.exists():
107
+ return []
108
+ conn = sqlite3.connect(str(db_path))
109
+ conn.row_factory = sqlite3.Row
110
+ _ensure_table(conn)
111
+
112
+ where_parts = ["db_identifier = ?", "workspace_path = ?"]
113
+ params: list = [db_identifier, workspace_path]
114
+
115
+ if status:
116
+ where_parts.append("status = ?")
117
+ params.append(status)
118
+ if tool_name:
119
+ where_parts.append("tool_name = ?")
120
+ params.append(tool_name)
121
+
122
+ where_clause = " WHERE " + " AND ".join(where_parts)
123
+ params.append(limit)
124
+
125
+ rows = conn.execute(
126
+ f"SELECT id, timestamp, server_type, tool_name, query_text, "
127
+ f"execution_time_ms, status, row_count, error_message, user_query "
128
+ f"FROM query_logs{where_clause} ORDER BY timestamp DESC LIMIT ?",
129
+ params,
130
+ ).fetchall()
131
+ conn.close()
132
+ return [dict(r) for r in rows]
133
+ except Exception:
134
+ return []
@@ -0,0 +1,795 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PostgreSQL MCP Server for Claude Desktop
4
+ Natural language queries to PostgreSQL database
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import logging
10
+ import time
11
+ from typing import Any, List, Dict
12
+ import psycopg2
13
+ from psycopg2.extras import RealDictCursor
14
+ from mcp.server import Server
15
+ from mcp.server.stdio import stdio_server
16
+ from mcp.types import (
17
+ Resource,
18
+ Tool,
19
+ TextContent,
20
+ ImageContent,
21
+ EmbeddedResource,
22
+ LoggingLevel
23
+ )
24
+ from dotenv import load_dotenv
25
+
26
+ from .query_logger import direct_log_query_execution, get_query_history
27
+
28
+ # Load environment variables
29
+ load_dotenv()
30
+
31
+ # Configure logging
32
+ logging.basicConfig(level=logging.INFO)
33
+ logger = logging.getLogger(__name__)
34
+
35
+ class PostgreSQLMCPServer:
36
+ # Keywords that indicate a write/modify operation
37
+ WRITE_KEYWORDS = frozenset([
38
+ "INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE",
39
+ "TRUNCATE", "MERGE", "GRANT", "REVOKE",
40
+ ])
41
+
42
+ def __init__(self):
43
+ self.server = Server("postgresql-mcp-server")
44
+ self.connection = None
45
+ self.read_only = os.getenv("READ_ONLY", "").lower() in ("true", "1", "yes")
46
+ self.db_identifier = f"{os.getenv('DB_HOST', 'localhost')}:{os.getenv('DB_PORT', '5432')}/{os.getenv('DB_NAME', '')}"
47
+ self.workspace_path = os.getcwd()
48
+ if self.read_only:
49
+ logger.info("Read-only mode enabled - write queries will be blocked")
50
+ self.setup_handlers()
51
+
52
+ def setup_handlers(self):
53
+ """Setup MCP server handlers"""
54
+
55
+ @self.server.list_resources()
56
+ async def list_resources() -> List[Resource]:
57
+ """List available database resources"""
58
+ return [
59
+ Resource(
60
+ uri="postgresql://tables",
61
+ name="Database Tables",
62
+ description="List all tables in the PostgreSQL database",
63
+ mimeType="application/json"
64
+ ),
65
+ Resource(
66
+ uri="postgresql://schema",
67
+ name="Database Schema",
68
+ description="Get database schema information",
69
+ mimeType="application/json"
70
+ ),
71
+ Resource(
72
+ uri="postgresql://stats",
73
+ name="Database Statistics",
74
+ description="Get database statistics and info",
75
+ mimeType="application/json"
76
+ )
77
+ ]
78
+
79
+ @self.server.read_resource()
80
+ async def read_resource(uri: str) -> str:
81
+ """Read database resource"""
82
+ if not self.connection:
83
+ await self.connect_to_postgresql()
84
+
85
+ cursor = self.connection.cursor(cursor_factory=RealDictCursor)
86
+
87
+ if uri == "postgresql://tables":
88
+ cursor.execute("""
89
+ SELECT
90
+ schemaname,
91
+ tablename,
92
+ tableowner,
93
+ hasindexes,
94
+ hasrules,
95
+ hastriggers
96
+ FROM pg_tables
97
+ WHERE schemaname NOT IN ('information_schema', 'pg_catalog')
98
+ ORDER BY schemaname, tablename
99
+ """)
100
+ tables = cursor.fetchall()
101
+ result = "Database Tables:\n\n"
102
+ for table in tables:
103
+ result += f"• {table['schemaname']}.{table['tablename']} (Owner: {table['tableowner']})\n"
104
+ if table['hasindexes']:
105
+ result += " - Has indexes\n"
106
+ if table['hastriggers']:
107
+ result += " - Has triggers\n"
108
+ cursor.close()
109
+ return result
110
+
111
+ elif uri == "postgresql://schema":
112
+ # Get detailed schema information
113
+ cursor.execute("""
114
+ SELECT
115
+ t.table_schema,
116
+ t.table_name,
117
+ c.column_name,
118
+ c.data_type,
119
+ c.is_nullable,
120
+ c.column_default,
121
+ c.ordinal_position
122
+ FROM information_schema.tables t
123
+ JOIN information_schema.columns c ON t.table_name = c.table_name
124
+ AND t.table_schema = c.table_schema
125
+ WHERE t.table_schema NOT IN ('information_schema', 'pg_catalog')
126
+ ORDER BY t.table_schema, t.table_name, c.ordinal_position
127
+ LIMIT 500
128
+ """)
129
+
130
+ columns = cursor.fetchall()
131
+ schema_info = "Database Schema:\n\n"
132
+ current_table = None
133
+
134
+ for col in columns:
135
+ table_full_name = f"{col['table_schema']}.{col['table_name']}"
136
+ if current_table != table_full_name:
137
+ current_table = table_full_name
138
+ schema_info += f"\n📋 Table: {table_full_name}\n"
139
+ schema_info += "-" * 50 + "\n"
140
+
141
+ nullable = "NULL" if col['is_nullable'] == 'YES' else "NOT NULL"
142
+ default = f" DEFAULT {col['column_default']}" if col['column_default'] else ""
143
+ schema_info += f" {col['column_name']}: {col['data_type']} {nullable}{default}\n"
144
+
145
+ cursor.close()
146
+ return schema_info
147
+
148
+ elif uri == "postgresql://stats":
149
+ # Database statistics
150
+ cursor.execute("""
151
+ SELECT
152
+ current_database() as database_name,
153
+ current_user as current_user,
154
+ version() as postgresql_version
155
+ """)
156
+ info = cursor.fetchone()
157
+
158
+ cursor.execute("""
159
+ SELECT
160
+ schemaname,
161
+ COUNT(*) as table_count
162
+ FROM pg_tables
163
+ WHERE schemaname NOT IN ('information_schema', 'pg_catalog')
164
+ GROUP BY schemaname
165
+ ORDER BY table_count DESC
166
+ """)
167
+ schema_stats = cursor.fetchall()
168
+
169
+ result = f"Database Information:\n\n"
170
+ result += f"• Database: {info['database_name']}\n"
171
+ result += f"• Current User: {info['current_user']}\n"
172
+ result += f"• PostgreSQL Version: {info['postgresql_version']}\n\n"
173
+ result += "Schema Statistics:\n"
174
+ for stat in schema_stats:
175
+ result += f"• {stat['schemaname']}: {stat['table_count']} tables\n"
176
+
177
+ cursor.close()
178
+ return result
179
+
180
+ cursor.close()
181
+ return "Resource not found"
182
+
183
+ @self.server.list_tools()
184
+ async def list_tools() -> List[Tool]:
185
+ """List available tools"""
186
+ return [
187
+ Tool(
188
+ name="natural_language_query",
189
+ description="Execute natural language queries on PostgreSQL database",
190
+ inputSchema={
191
+ "type": "object",
192
+ "properties": {
193
+ "query": {
194
+ "type": "string",
195
+ "description": "Natural language query in Turkish or English"
196
+ }
197
+ },
198
+ "required": ["query"]
199
+ }
200
+ ),
201
+ Tool(
202
+ name="execute_sql",
203
+ description="Execute direct SQL query on PostgreSQL database",
204
+ inputSchema={
205
+ "type": "object",
206
+ "properties": {
207
+ "sql": {
208
+ "type": "string",
209
+ "description": "SQL query to execute"
210
+ },
211
+ "limit": {
212
+ "type": "integer",
213
+ "description": "Maximum number of rows to return (default: 100)",
214
+ "default": 100
215
+ }
216
+ },
217
+ "required": ["sql"]
218
+ }
219
+ ),
220
+ Tool(
221
+ name="describe_table",
222
+ description="Get detailed information about a specific table",
223
+ inputSchema={
224
+ "type": "object",
225
+ "properties": {
226
+ "table_name": {
227
+ "type": "string",
228
+ "description": "Name of the table to describe (format: schema.table or just table)"
229
+ }
230
+ },
231
+ "required": ["table_name"]
232
+ }
233
+ ),
234
+ Tool(
235
+ name="smart_query",
236
+ description="AI-powered smart query with context understanding",
237
+ inputSchema={
238
+ "type": "object",
239
+ "properties": {
240
+ "question": {
241
+ "type": "string",
242
+ "description": "Your question about the data in natural language"
243
+ }
244
+ },
245
+ "required": ["question"]
246
+ }
247
+ ),
248
+ Tool(
249
+ name="explain_query",
250
+ description="Show the execution plan for a SQL query using EXPLAIN",
251
+ inputSchema={
252
+ "type": "object",
253
+ "properties": {
254
+ "sql": {
255
+ "type": "string",
256
+ "description": "SQL query to explain"
257
+ },
258
+ "analyze": {
259
+ "type": "boolean",
260
+ "description": "Actually execute the query to get real timing (default: false - safe/estimated only)",
261
+ "default": False
262
+ },
263
+ "format": {
264
+ "type": "string",
265
+ "enum": ["text", "json", "yaml"],
266
+ "description": "Output format (default: text)",
267
+ "default": "text"
268
+ },
269
+ "buffers": {
270
+ "type": "boolean",
271
+ "description": "Include buffer usage information (only with analyze=true)",
272
+ "default": False
273
+ }
274
+ },
275
+ "required": ["sql"]
276
+ }
277
+ ),
278
+ Tool(
279
+ name="get_query_history",
280
+ description="Get recent query history for this database connection. Shows past queries, execution times, statuses and errors. Useful for reviewing what was run before.",
281
+ inputSchema={
282
+ "type": "object",
283
+ "properties": {
284
+ "limit": {
285
+ "type": "integer",
286
+ "description": "Maximum number of recent queries to return (default: 20)",
287
+ "default": 20
288
+ },
289
+ "status": {
290
+ "type": "string",
291
+ "enum": ["success", "error"],
292
+ "description": "Filter by status (optional - omit for all)"
293
+ },
294
+ "tool_name": {
295
+ "type": "string",
296
+ "description": "Filter by tool name, e.g. 'execute_sql', 'natural_language_query' (optional)"
297
+ }
298
+ }
299
+ }
300
+ )
301
+ ]
302
+
303
+ @self.server.call_tool()
304
+ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]:
305
+ """Handle tool calls"""
306
+ if name == "get_query_history":
307
+ return self.handle_get_query_history(arguments)
308
+
309
+ if not self.connection:
310
+ await self.connect_to_postgresql()
311
+
312
+ try:
313
+ if name == "natural_language_query":
314
+ return await self.handle_natural_language_query(arguments["query"])
315
+
316
+ elif name == "execute_sql":
317
+ return await self.handle_sql_query(arguments["sql"], arguments.get("limit", 100))
318
+
319
+ elif name == "describe_table":
320
+ return await self.handle_describe_table(arguments["table_name"])
321
+
322
+ elif name == "smart_query":
323
+ return await self.handle_smart_query(arguments["question"])
324
+
325
+ elif name == "explain_query":
326
+ return await self.handle_explain_query(
327
+ arguments["sql"],
328
+ arguments.get("analyze", False),
329
+ arguments.get("format", "text"),
330
+ arguments.get("buffers", False)
331
+ )
332
+
333
+ else:
334
+ return [TextContent(type="text", text=f"Unknown tool: {name}")]
335
+
336
+ except Exception as e:
337
+ logger.error(f"Error in tool call: {e}")
338
+ return [TextContent(type="text", text=f"Error: {str(e)}")]
339
+
340
+ def _is_write_query(self, sql: str) -> bool:
341
+ """Check if a SQL query is a write/modify operation"""
342
+ cleaned = sql.strip()
343
+ # Strip leading comments
344
+ while cleaned.startswith("--") or cleaned.startswith("/*"):
345
+ if cleaned.startswith("--"):
346
+ cleaned = cleaned.split("\n", 1)[-1].strip()
347
+ elif cleaned.startswith("/*"):
348
+ end = cleaned.find("*/")
349
+ cleaned = cleaned[end + 2:].strip() if end != -1 else cleaned
350
+ first_word = cleaned.split()[0].upper() if cleaned.split() else ""
351
+ return first_word in self.WRITE_KEYWORDS
352
+
353
+ async def connect_to_postgresql(self):
354
+ """Connect to PostgreSQL database"""
355
+ try:
356
+ host = os.getenv("DB_HOST", "localhost")
357
+ port = os.getenv("DB_PORT", "5432")
358
+ database = os.getenv("DB_NAME", "docsmapeg")
359
+ user = os.getenv("DB_USER", "postgres")
360
+ password = os.getenv("DB_PASSWORD", "postgres")
361
+
362
+ self.connection = psycopg2.connect(
363
+ host=host,
364
+ port=port,
365
+ database=database,
366
+ user=user,
367
+ password=password
368
+ )
369
+ self.connection.set_session(autocommit=True)
370
+ logger.info(f"Successfully connected to PostgreSQL database: {database}")
371
+
372
+ except Exception as e:
373
+ logger.error(f"Failed to connect to PostgreSQL: {e}")
374
+ raise
375
+
376
+ async def handle_natural_language_query(self, query: str) -> List[TextContent]:
377
+ """Convert natural language to SQL and execute"""
378
+ start_time = time.time()
379
+ query_lower = query.lower()
380
+ generated_sql = ""
381
+ status = "success"
382
+ error_message = ""
383
+
384
+ try:
385
+ # Enhanced pattern matching for common queries
386
+ if any(word in query_lower for word in ["tablo", "table", "liste", "list", "göster", "show"]):
387
+ if any(word in query_lower for word in ["liste", "list", "göster", "show", "all"]):
388
+ generated_sql = """
389
+ SELECT schemaname, tablename, tableowner
390
+ FROM pg_tables
391
+ WHERE schemaname NOT IN ('information_schema', 'pg_catalog')
392
+ ORDER BY schemaname, tablename
393
+ """
394
+ result = await self.handle_sql_query(generated_sql.strip(), 50)
395
+
396
+ # Log the natural language query
397
+ execution_time = (time.time() - start_time) * 1000
398
+ direct_log_query_execution(
399
+ server_type="postgresql",
400
+ tool_name="natural_language_query",
401
+ query_text=generated_sql.strip(),
402
+ execution_time_ms=execution_time,
403
+ status="success",
404
+ row_count=0, # Will be logged by handle_sql_query too
405
+ error_message="",
406
+ user_query=query,
407
+ db_identifier=self.db_identifier,
408
+ workspace_path=self.workspace_path,
409
+ )
410
+ return result
411
+
412
+ if any(word in query_lower for word in ["kullanıcı", "user", "kullanıcılar", "users"]):
413
+ generated_sql = """
414
+ SELECT usename as username, usesuper as is_superuser, usecreatedb as can_create_db
415
+ FROM pg_user
416
+ ORDER BY usename
417
+ """
418
+ result = await self.handle_sql_query(generated_sql.strip(), 20)
419
+
420
+ execution_time = (time.time() - start_time) * 1000
421
+ direct_log_query_execution(
422
+ server_type="postgresql",
423
+ tool_name="natural_language_query",
424
+ query_text=generated_sql.strip(),
425
+ execution_time_ms=execution_time,
426
+ status="success",
427
+ row_count=0,
428
+ error_message="",
429
+ user_query=query,
430
+ db_identifier=self.db_identifier,
431
+ workspace_path=self.workspace_path,
432
+ )
433
+ return result
434
+
435
+ if any(word in query_lower for word in ["şema", "schema", "schemas"]):
436
+ generated_sql = """
437
+ SELECT schema_name, schema_owner
438
+ FROM information_schema.schemata
439
+ WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'pg_toast')
440
+ ORDER BY schema_name
441
+ """
442
+ result = await self.handle_sql_query(generated_sql.strip(), 20)
443
+
444
+ execution_time = (time.time() - start_time) * 1000
445
+ direct_log_query_execution(
446
+ server_type="postgresql",
447
+ tool_name="natural_language_query",
448
+ query_text=generated_sql.strip(),
449
+ execution_time_ms=execution_time,
450
+ status="success",
451
+ row_count=0,
452
+ error_message="",
453
+ user_query=query,
454
+ db_identifier=self.db_identifier,
455
+ workspace_path=self.workspace_path,
456
+ )
457
+ return result
458
+
459
+ if any(word in query_lower for word in ["istatistik", "statistics", "stats", "bilgi", "info"]):
460
+ generated_sql = """
461
+ SELECT
462
+ current_database() as database,
463
+ current_user as user,
464
+ version() as postgresql_version
465
+ """
466
+ result = await self.handle_sql_query(generated_sql.strip(), 1)
467
+
468
+ execution_time = (time.time() - start_time) * 1000
469
+ direct_log_query_execution(
470
+ server_type="postgresql",
471
+ tool_name="natural_language_query",
472
+ query_text=generated_sql.strip(),
473
+ execution_time_ms=execution_time,
474
+ status="success",
475
+ row_count=0,
476
+ error_message="",
477
+ user_query=query,
478
+ db_identifier=self.db_identifier,
479
+ workspace_path=self.workspace_path,
480
+ )
481
+ return result
482
+
483
+ # If no pattern matches, return helpful message
484
+ execution_time = (time.time() - start_time) * 1000
485
+ direct_log_query_execution(
486
+ server_type="postgresql",
487
+ tool_name="natural_language_query",
488
+ query_text="NO_PATTERN_MATCH",
489
+ execution_time_ms=execution_time,
490
+ status="success",
491
+ row_count=0,
492
+ error_message="",
493
+ user_query=query,
494
+ db_identifier=self.db_identifier,
495
+ workspace_path=self.workspace_path,
496
+ )
497
+
498
+ return [TextContent(
499
+ type="text",
500
+ text=f"""🤖 Doğal dil sorgusu: "{query}"
501
+
502
+ Anlayabildiğim komutlar:
503
+ • "tabloları listele" / "show tables"
504
+ • "kullanıcıları göster" / "show users"
505
+ • "şemaları listele" / "show schemas"
506
+ • "veritabanı bilgilerini göster" / "show database info"
507
+
508
+ Gelişmiş sorgular için:
509
+ • 'execute_sql' aracını kullanın
510
+ • 'smart_query' aracıyla AI destekli sorgular yapın
511
+
512
+ Örnek SQL sorguları:
513
+ • SELECT * FROM pg_tables WHERE schemaname = 'public'
514
+ • SELECT table_name, column_name, data_type FROM information_schema.columns
515
+ """
516
+ )]
517
+ except Exception as e:
518
+ status = "error"
519
+ error_message = str(e)
520
+ execution_time = (time.time() - start_time) * 1000
521
+ direct_log_query_execution(
522
+ server_type="postgresql",
523
+ tool_name="natural_language_query",
524
+ query_text=generated_sql or "PATTERN_MATCHING_ERROR",
525
+ execution_time_ms=execution_time,
526
+ status=status,
527
+ row_count=0,
528
+ error_message=error_message,
529
+ user_query=query,
530
+ db_identifier=self.db_identifier,
531
+ workspace_path=self.workspace_path,
532
+ )
533
+ return [TextContent(type="text", text=f"❌ Error processing natural language query: {str(e)}")]
534
+
535
+ async def handle_sql_query(self, sql: str, limit: int = 100) -> List[TextContent]:
536
+ """Execute SQL query"""
537
+ # Read-only guard
538
+ if self.read_only and self._is_write_query(sql):
539
+ return [TextContent(type="text", text="❌ Read-only mode is enabled. Write operations (INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, TRUNCATE, MERGE, GRANT, REVOKE) are blocked. Set READ_ONLY=false to allow write operations.")]
540
+
541
+ start_time = time.time()
542
+ cursor = self.connection.cursor(cursor_factory=RealDictCursor)
543
+ row_count = 0
544
+ status = "success"
545
+ error_message = ""
546
+ result_text = ""
547
+
548
+ try:
549
+ # Add LIMIT for SELECT queries if not already present
550
+ if sql.strip().upper().startswith("SELECT") and "LIMIT" not in sql.upper():
551
+ sql += f" LIMIT {limit}"
552
+
553
+ cursor.execute(sql)
554
+
555
+ if sql.strip().upper().startswith("SELECT"):
556
+ rows = cursor.fetchall()
557
+ row_count = len(rows)
558
+
559
+ if not rows:
560
+ result_text = "Sorgu sonuç döndürmedi."
561
+ else:
562
+ # Format results
563
+ result = f"🔍 SQL Query: {sql}\n\n"
564
+ result += f"📊 Results ({len(rows)} rows):\n"
565
+ result += "=" * 60 + "\n"
566
+
567
+ # Get column names
568
+ columns = [desc[0] for desc in cursor.description]
569
+
570
+ # Add column headers
571
+ result += " | ".join(columns) + "\n"
572
+ result += "-" * 60 + "\n"
573
+
574
+ # Add data rows
575
+ for row in rows:
576
+ row_data = []
577
+ for col in columns:
578
+ val = row[col] if row[col] is not None else "NULL"
579
+ row_data.append(str(val))
580
+ result += " | ".join(row_data) + "\n"
581
+
582
+ result_text = result
583
+
584
+ return [TextContent(type="text", text=result_text)]
585
+ else:
586
+ # For non-SELECT queries
587
+ result_text = f"✅ Query executed successfully: {sql}"
588
+ return [TextContent(type="text", text=result_text)]
589
+
590
+ except Exception as e:
591
+ status = "error"
592
+ error_message = str(e)
593
+ result_text = f"❌ SQL Error: {str(e)}"
594
+ return [TextContent(type="text", text=result_text)]
595
+ finally:
596
+ # Log the query execution
597
+ execution_time = (time.time() - start_time) * 1000 # Convert to milliseconds
598
+ # Direct logging to avoid async queue truncation
599
+ direct_log_query_execution(
600
+ server_type="postgresql",
601
+ tool_name="execute_sql",
602
+ query_text=sql,
603
+ execution_time_ms=execution_time,
604
+ status=status,
605
+ row_count=row_count,
606
+ error_message=error_message,
607
+ response_text=result_text,
608
+ db_identifier=self.db_identifier,
609
+ workspace_path=self.workspace_path,
610
+ )
611
+ cursor.close()
612
+
613
+ async def handle_describe_table(self, table_name: str) -> List[TextContent]:
614
+ """Describe table structure"""
615
+ cursor = self.connection.cursor(cursor_factory=RealDictCursor)
616
+
617
+ try:
618
+ # Handle schema.table format
619
+ if '.' in table_name:
620
+ schema, table = table_name.split('.', 1)
621
+ else:
622
+ schema = 'public'
623
+ table = table_name
624
+
625
+ # Get table columns
626
+ cursor.execute("""
627
+ SELECT
628
+ column_name,
629
+ data_type,
630
+ character_maximum_length,
631
+ is_nullable,
632
+ column_default,
633
+ ordinal_position
634
+ FROM information_schema.columns
635
+ WHERE table_schema = %s AND table_name = %s
636
+ ORDER BY ordinal_position
637
+ """, (schema, table))
638
+
639
+ columns = cursor.fetchall()
640
+
641
+ if not columns:
642
+ return [TextContent(type="text", text=f"❌ Table '{schema}.{table}' not found")]
643
+
644
+ result = f"📋 Table: {schema}.{table}\n"
645
+ result += "=" * 60 + "\n\n"
646
+
647
+ for col in columns:
648
+ nullable = "NULL" if col['is_nullable'] == 'YES' else "NOT NULL"
649
+ length = f"({col['character_maximum_length']})" if col['character_maximum_length'] else ""
650
+ default = f" DEFAULT {col['column_default']}" if col['column_default'] else ""
651
+ result += f"• {col['column_name']}: {col['data_type']}{length} {nullable}{default}\n"
652
+
653
+ # Get row count
654
+ cursor.execute(f'SELECT COUNT(*) as count FROM "{schema}"."{table}"')
655
+ row_count = cursor.fetchone()['count']
656
+ result += f"\n📊 Total rows: {row_count:,}"
657
+
658
+ # Get table size
659
+ cursor.execute("""
660
+ SELECT pg_size_pretty(pg_total_relation_size(%s)) as size
661
+ """, (f'"{schema}"."{table}"',))
662
+ size_info = cursor.fetchone()
663
+ result += f"\n💾 Table size: {size_info['size']}"
664
+
665
+ return [TextContent(type="text", text=result)]
666
+
667
+ except Exception as e:
668
+ return [TextContent(type="text", text=f"❌ Error describing table: {str(e)}")]
669
+ finally:
670
+ cursor.close()
671
+
672
+ async def handle_smart_query(self, question: str) -> List[TextContent]:
673
+ """AI-powered smart query"""
674
+ try:
675
+ # First, get schema information to provide context
676
+ cursor = self.connection.cursor(cursor_factory=RealDictCursor)
677
+ cursor.execute("""
678
+ SELECT table_name, column_name, data_type
679
+ FROM information_schema.columns
680
+ WHERE table_schema = 'public'
681
+ ORDER BY table_name, ordinal_position
682
+ LIMIT 100
683
+ """)
684
+ schema_info = cursor.fetchall()
685
+ cursor.close()
686
+
687
+ # Build schema context
688
+ schema_context = "Available tables and columns:\n"
689
+ current_table = None
690
+ for item in schema_info:
691
+ if current_table != item['table_name']:
692
+ current_table = item['table_name']
693
+ schema_context += f"\n{item['table_name']}:\n"
694
+ schema_context += f" - {item['column_name']} ({item['data_type']})\n"
695
+
696
+ # For now, provide helpful guidance
697
+ # In a full implementation, you would use the Anthropic API here
698
+ return [TextContent(
699
+ type="text",
700
+ text=f"""🤖 Smart Query for: "{question}"
701
+
702
+ Şu anda basit pattern matching kullanıyorum. Tam AI özelliği için:
703
+
704
+ 1. Schema analizi:
705
+ {schema_context[:500]}...
706
+
707
+ 2. Önerilen yaklaşım:
708
+ • Sorunuzu daha spesifik hale getirin
709
+ • 'execute_sql' ile doğrudan SQL yazın
710
+ • 'describe_table' ile tablo yapısını inceleyin
711
+
712
+ Örnek sorgular:
713
+ • "users tablosundaki tüm kayıtları göster"
714
+ • "en son eklenen 10 kaydı listele"
715
+ • "boş olmayan email adreslerini say"
716
+ """
717
+ )]
718
+
719
+ except Exception as e:
720
+ return [TextContent(type="text", text=f"❌ Smart query error: {str(e)}")]
721
+
722
+ async def handle_explain_query(self, sql: str, analyze: bool = False, fmt: str = "text", buffers: bool = False) -> List[TextContent]:
723
+ """Show execution plan for a SQL query"""
724
+ cursor = self.connection.cursor()
725
+ try:
726
+ parts = ["EXPLAIN"]
727
+ options = []
728
+ if analyze:
729
+ options.append("ANALYZE true")
730
+ if buffers and analyze:
731
+ options.append("BUFFERS true")
732
+ if fmt != "text":
733
+ options.append(f"FORMAT {fmt}")
734
+ if options:
735
+ parts.append(f"({', '.join(options)})")
736
+ parts.append(sql)
737
+ explain_sql = " ".join(parts)
738
+
739
+ cursor.execute(explain_sql)
740
+ rows = cursor.fetchall()
741
+
742
+ plan_output = "\n".join(row[0] if isinstance(row[0], str) else str(row[0]) for row in rows)
743
+ result = f"📋 Execution Plan{' (ANALYZE)' if analyze else ''}:\n"
744
+ result += "=" * 60 + "\n"
745
+ result += plan_output
746
+ return [TextContent(type="text", text=result)]
747
+ except Exception as e:
748
+ return [TextContent(type="text", text=f"❌ EXPLAIN error: {str(e)}")]
749
+ finally:
750
+ cursor.close()
751
+
752
+ def handle_get_query_history(self, arguments: Dict[str, Any]) -> List[TextContent]:
753
+ """Return recent query history for this db+workspace"""
754
+ logs = get_query_history(
755
+ db_identifier=self.db_identifier,
756
+ workspace_path=self.workspace_path,
757
+ limit=arguments.get("limit", 20),
758
+ status=arguments.get("status", ""),
759
+ tool_name=arguments.get("tool_name", ""),
760
+ )
761
+
762
+ if not logs:
763
+ return [TextContent(type="text", text="No query history found for this database/workspace.")]
764
+
765
+ result = f"Query History ({len(logs)} entries):\n"
766
+ result += f"DB: {self.db_identifier} | Workspace: {self.workspace_path}\n"
767
+ result += "=" * 70 + "\n\n"
768
+
769
+ for log in logs:
770
+ status_icon = "OK" if log["status"] == "success" else "ERR"
771
+ time_str = log["timestamp"][:19].replace("T", " ")
772
+ result += f"[{status_icon}] {time_str} | {log['tool_name']} | {log['execution_time_ms']:.0f}ms | {log['row_count']} rows\n"
773
+ query_preview = log["query_text"][:120].replace("\n", " ")
774
+ result += f" {query_preview}\n"
775
+ if log["error_message"]:
776
+ result += f" Error: {log['error_message'][:100]}\n"
777
+ if log["user_query"]:
778
+ result += f" User: {log['user_query'][:100]}\n"
779
+ result += "\n"
780
+
781
+ return [TextContent(type="text", text=result)]
782
+
783
+ async def main():
784
+ """Main function to run the MCP server"""
785
+ postgresql_server = PostgreSQLMCPServer()
786
+
787
+ async with stdio_server() as (read_stream, write_stream):
788
+ await postgresql_server.server.run(
789
+ read_stream,
790
+ write_stream,
791
+ postgresql_server.server.create_initialization_options()
792
+ )
793
+
794
+ if __name__ == "__main__":
795
+ asyncio.run(main())