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.
- mapeg_postgres_mcp-1.0.0.dist-info/METADATA +323 -0
- mapeg_postgres_mcp-1.0.0.dist-info/RECORD +10 -0
- mapeg_postgres_mcp-1.0.0.dist-info/WHEEL +5 -0
- mapeg_postgres_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- mapeg_postgres_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- mapeg_postgres_mcp-1.0.0.dist-info/top_level.txt +1 -0
- mcp_server_postgres/__init__.py +12 -0
- mcp_server_postgres/__main__.py +31 -0
- mcp_server_postgres/query_logger.py +134 -0
- mcp_server_postgres/server.py +795 -0
|
@@ -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
|
+
[](https://python.org)
|
|
36
|
+
[](https://modelcontextprotocol.io)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](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,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())
|