mcp-db-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- db/mysql_repository.py +116 -0
- db/repository.py +21 -0
- db/sqlite_repository.py +94 -0
- mcp_db_server-0.1.0.dist-info/METADATA +96 -0
- mcp_db_server-0.1.0.dist-info/RECORD +8 -0
- mcp_db_server-0.1.0.dist-info/WHEEL +5 -0
- mcp_db_server-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_db_server-0.1.0.dist-info/top_level.txt +1 -0
db/mysql_repository.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import pymysql
|
|
2
|
+
import os
|
|
3
|
+
from typing import List, Union
|
|
4
|
+
from .repository import DatabaseRepository
|
|
5
|
+
|
|
6
|
+
from dbutils.pooled_db import PooledDB
|
|
7
|
+
|
|
8
|
+
class MysqlRepository(DatabaseRepository):
|
|
9
|
+
def __init__(self, host: str, user: str, password: str, database: str, port: int = 3306):
|
|
10
|
+
self.host = host
|
|
11
|
+
self.user = user
|
|
12
|
+
self.password = password
|
|
13
|
+
self.database = database
|
|
14
|
+
self.port = port
|
|
15
|
+
|
|
16
|
+
# Initialize connection pool
|
|
17
|
+
# mincached=2, maxcached=5 are reasonable defaults
|
|
18
|
+
self.pool = PooledDB(
|
|
19
|
+
creator=pymysql,
|
|
20
|
+
mincached=2,
|
|
21
|
+
maxcached=5,
|
|
22
|
+
blocking=True, # Wait for connection if pool is empty
|
|
23
|
+
host=self.host,
|
|
24
|
+
user=self.user,
|
|
25
|
+
password=self.password,
|
|
26
|
+
database=self.database,
|
|
27
|
+
port=self.port,
|
|
28
|
+
cursorclass=pymysql.cursors.Cursor
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def _get_connection(self):
|
|
32
|
+
# Get connection from pool
|
|
33
|
+
return self.pool.connection()
|
|
34
|
+
|
|
35
|
+
def list_tables(self) -> List[str]:
|
|
36
|
+
try:
|
|
37
|
+
conn = self._get_connection()
|
|
38
|
+
with conn.cursor() as cursor:
|
|
39
|
+
cursor.execute("SHOW TABLES")
|
|
40
|
+
# result is tuple of tuples
|
|
41
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
42
|
+
conn.close()
|
|
43
|
+
return tables
|
|
44
|
+
except Exception as e:
|
|
45
|
+
return [f"Error listing tables: {e}"]
|
|
46
|
+
|
|
47
|
+
def describe_table(self, table_name: str) -> str:
|
|
48
|
+
try:
|
|
49
|
+
conn = self._get_connection()
|
|
50
|
+
with conn.cursor() as cursor:
|
|
51
|
+
# Validate table exists (prevent injection)
|
|
52
|
+
cursor.execute("SHOW TABLES")
|
|
53
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
54
|
+
if table_name not in tables:
|
|
55
|
+
return f"Error: Table '{table_name}' does not exist."
|
|
56
|
+
|
|
57
|
+
cursor.execute(f"DESCRIBE {table_name}")
|
|
58
|
+
columns = cursor.fetchall()
|
|
59
|
+
conn.close()
|
|
60
|
+
|
|
61
|
+
schema = []
|
|
62
|
+
# Field, Type, Null, Key, Default, Extra
|
|
63
|
+
for col in columns:
|
|
64
|
+
col_name = col[0]
|
|
65
|
+
col_type = col[1]
|
|
66
|
+
key = col[3]
|
|
67
|
+
|
|
68
|
+
col_str = f"- {col_name} ({col_type})"
|
|
69
|
+
if key == 'PRI':
|
|
70
|
+
col_str += " [PK]"
|
|
71
|
+
schema.append(col_str)
|
|
72
|
+
|
|
73
|
+
return f"Schema for {table_name}:\n" + "\n".join(schema)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return f"Error describing table: {e}"
|
|
76
|
+
|
|
77
|
+
def read_query(self, query: str) -> str:
|
|
78
|
+
normalized_query = query.strip().upper()
|
|
79
|
+
if not normalized_query.startswith("SELECT"):
|
|
80
|
+
return "Error: Only SELECT queries are allowed."
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
conn = self._get_connection()
|
|
84
|
+
try:
|
|
85
|
+
with conn.cursor() as cursor:
|
|
86
|
+
# Enforce Read Only mode for this session
|
|
87
|
+
cursor.execute("SET SESSION TRANSACTION READ ONLY")
|
|
88
|
+
|
|
89
|
+
cursor.execute(query)
|
|
90
|
+
results = cursor.fetchall()
|
|
91
|
+
|
|
92
|
+
output = []
|
|
93
|
+
if cursor.description:
|
|
94
|
+
col_names = [desc[0] for desc in cursor.description]
|
|
95
|
+
output.append(" | ".join(col_names))
|
|
96
|
+
output.append("-" * len(" | ".join(col_names)))
|
|
97
|
+
|
|
98
|
+
if not results:
|
|
99
|
+
return "No results found."
|
|
100
|
+
|
|
101
|
+
for row in results:
|
|
102
|
+
row_str = " | ".join(str(item) for item in row)
|
|
103
|
+
output.append(row_str)
|
|
104
|
+
|
|
105
|
+
return "\n".join(output)
|
|
106
|
+
finally:
|
|
107
|
+
conn.close()
|
|
108
|
+
|
|
109
|
+
except pymysql.OperationalError as e:
|
|
110
|
+
# Code 1290 is The MySQL server is running with the --read-only option so it cannot execute this statement
|
|
111
|
+
# Or similar for session read only
|
|
112
|
+
if e.args[0] == 1792: # Cannot execute statement in a READ ONLY transaction.
|
|
113
|
+
return "Security Error: Attempted write operation in read-only mode."
|
|
114
|
+
return f"Database Error: {e}"
|
|
115
|
+
except Exception as e:
|
|
116
|
+
return f"Error: {e}"
|
db/repository.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List, Union
|
|
3
|
+
|
|
4
|
+
class DatabaseRepository(ABC):
|
|
5
|
+
@abstractmethod
|
|
6
|
+
def list_tables(self) -> List[str]:
|
|
7
|
+
"""List all tables in the database."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def describe_table(self, table_name: str) -> str:
|
|
12
|
+
"""Get schema information for a specific table."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def read_query(self, query: str) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Execute a read-only query.
|
|
19
|
+
Returns a formatted string (table) or an error string.
|
|
20
|
+
"""
|
|
21
|
+
pass
|
db/sqlite_repository.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import os
|
|
3
|
+
from typing import List
|
|
4
|
+
from .repository import DatabaseRepository
|
|
5
|
+
|
|
6
|
+
class SqliteRepository(DatabaseRepository):
|
|
7
|
+
def __init__(self, db_path: str):
|
|
8
|
+
self.db_path = db_path
|
|
9
|
+
if not os.path.exists(self.db_path):
|
|
10
|
+
raise FileNotFoundError(f"Database file not found: {self.db_path}")
|
|
11
|
+
|
|
12
|
+
def _get_connection(self):
|
|
13
|
+
# Enforce read-only mode at driver level
|
|
14
|
+
return sqlite3.connect(f"file:{os.path.abspath(self.db_path)}?mode=ro", uri=True)
|
|
15
|
+
|
|
16
|
+
def list_tables(self) -> List[str]:
|
|
17
|
+
try:
|
|
18
|
+
conn = self._get_connection()
|
|
19
|
+
cursor = conn.cursor()
|
|
20
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
|
21
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
22
|
+
conn.close()
|
|
23
|
+
return tables
|
|
24
|
+
except Exception as e:
|
|
25
|
+
return [f"Error listing tables: {e}"]
|
|
26
|
+
|
|
27
|
+
def describe_table(self, table_name: str) -> str:
|
|
28
|
+
try:
|
|
29
|
+
conn = self._get_connection()
|
|
30
|
+
cursor = conn.cursor()
|
|
31
|
+
|
|
32
|
+
# Validate table existence first
|
|
33
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
|
34
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
35
|
+
|
|
36
|
+
if table_name not in tables:
|
|
37
|
+
conn.close()
|
|
38
|
+
return f"Error: Table '{table_name}' does not exist."
|
|
39
|
+
|
|
40
|
+
cursor.execute(f"PRAGMA table_info({table_name})")
|
|
41
|
+
columns = cursor.fetchall()
|
|
42
|
+
conn.close()
|
|
43
|
+
|
|
44
|
+
schema = []
|
|
45
|
+
for col in columns:
|
|
46
|
+
# PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk
|
|
47
|
+
col_name = col[1]
|
|
48
|
+
col_type = col[2]
|
|
49
|
+
pk = col[5]
|
|
50
|
+
|
|
51
|
+
col_str = f"- {col_name} ({col_type})"
|
|
52
|
+
if pk:
|
|
53
|
+
col_str += " [PK]"
|
|
54
|
+
schema.append(col_str)
|
|
55
|
+
|
|
56
|
+
return f"Schema for {table_name}:\n" + "\n".join(schema)
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
return f"Error describing table: {e}"
|
|
60
|
+
|
|
61
|
+
def read_query(self, query: str) -> str:
|
|
62
|
+
normalized_query = query.strip().upper()
|
|
63
|
+
if not normalized_query.startswith("SELECT"):
|
|
64
|
+
return "Error: Only SELECT queries are allowed."
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
conn = self._get_connection()
|
|
68
|
+
cursor = conn.cursor()
|
|
69
|
+
cursor.execute(query)
|
|
70
|
+
results = cursor.fetchall()
|
|
71
|
+
|
|
72
|
+
output = []
|
|
73
|
+
if cursor.description:
|
|
74
|
+
col_names = [desc[0] for desc in cursor.description]
|
|
75
|
+
output.append(" | ".join(col_names))
|
|
76
|
+
output.append("-" * len(" | ".join(col_names)))
|
|
77
|
+
|
|
78
|
+
conn.close()
|
|
79
|
+
|
|
80
|
+
if not results:
|
|
81
|
+
return "No results found."
|
|
82
|
+
|
|
83
|
+
for row in results:
|
|
84
|
+
row_str = " | ".join(str(item) for item in row)
|
|
85
|
+
output.append(row_str)
|
|
86
|
+
|
|
87
|
+
return "\n".join(output)
|
|
88
|
+
|
|
89
|
+
except sqlite3.OperationalError as e:
|
|
90
|
+
if "readonly" in str(e):
|
|
91
|
+
return f"Security Error: Attempted write operation on read-only database."
|
|
92
|
+
return f"Database Error: {e}"
|
|
93
|
+
except Exception as e:
|
|
94
|
+
return f"Error: {e}"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-db-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP Database Server with read-only access
|
|
5
|
+
Author-email: User <user@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: mcp
|
|
10
|
+
Requires-Dist: python-dotenv
|
|
11
|
+
Requires-Dist: pymysql
|
|
12
|
+
Requires-Dist: cryptography
|
|
13
|
+
Requires-Dist: DBUtils
|
|
14
|
+
|
|
15
|
+
# MCP Database Server
|
|
16
|
+
|
|
17
|
+
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server implementation in Python that provides **strictly read-only** access to databases. It allows Large Language Models (LLMs) to inspect schemas and query data safely without risking data modification.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **🛡️ Strict Read-Only**: Enforced at the driver level (e.g., SQLite `?mode=ro`) or session level (MySQL `SET SESSION TRANSACTION READ ONLY`).
|
|
22
|
+
- **🏗️ Repository Pattern**: Abstracted database access allows for easy extension to other backends (PostgreSQL, MySQL) via Dependency Injection.
|
|
23
|
+
- **⚡ FastMCP**: Built efficiently using the official MCP Python SDK.
|
|
24
|
+
- **🔌 Connection Pooling**: Automatic connection pooling for MySQL using `DBUtils` to ensure performance and reliability.
|
|
25
|
+
- **🔧 Easy Configuration**: Managed via environment variables and Makefiles.
|
|
26
|
+
|
|
27
|
+
## Project Structure
|
|
28
|
+
|
|
29
|
+
- `server.py`: Main entry point for the MCP server.
|
|
30
|
+
- `db/`: Contains the Repository interface and implementations (`sqlite_repository.py`, `mysql_repository.py`).
|
|
31
|
+
- `seed.py`: Utility script to populate the database (since the server itself is read-only).
|
|
32
|
+
- `Makefile`: Automation for common tasks.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### Prerequisites
|
|
37
|
+
- Python 3.12+ (managed via `venv`)
|
|
38
|
+
- `make` (optional, but recommended)
|
|
39
|
+
- MySQL Server (optional, if using MySQL backend)
|
|
40
|
+
|
|
41
|
+
### 1. Setup
|
|
42
|
+
Use the Makefile to create a virtual environment, install dependencies, and seed a test database:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
make setup
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
*This runs `pip install -r requirements.txt` and `python seed.py`.*
|
|
49
|
+
|
|
50
|
+
### 2. Configuration
|
|
51
|
+
The project uses `python-dotenv`. Copy the example config and adjust as needed:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cp .env.example .env
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Key Variables:**
|
|
58
|
+
- `DB_ENGINE`: `sqlite` (Default) or `mysql`
|
|
59
|
+
- `DB_ADDRESS`: Path to db file (SQLite) or Host URL (MySQL).
|
|
60
|
+
- `DB_PORT`: Database port (Default: 3306 for MySQL).
|
|
61
|
+
- `DB_USER` / `DB_PASSWORD`: Database credentials (required for MySQL).
|
|
62
|
+
- `DB_SCHEMA`: Database name (required for MySQL).
|
|
63
|
+
|
|
64
|
+
### 3. Run the Server
|
|
65
|
+
Start the MCP server:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
make run
|
|
69
|
+
```
|
|
70
|
+
*(Or directly via `venv/bin/python server.py`)*
|
|
71
|
+
|
|
72
|
+
## Usage with LLMs
|
|
73
|
+
|
|
74
|
+
Once running, the server exposes the following tools to connected MCP clients:
|
|
75
|
+
|
|
76
|
+
1. **`list_tables()`**
|
|
77
|
+
- Returns a list of all tables in the database.
|
|
78
|
+
2. **`describe_table(table_name: str)`**
|
|
79
|
+
- Returns the schema (columns, types, primary keys) for the specified table.
|
|
80
|
+
3. **`read_query(query: str)`**
|
|
81
|
+
- Executes a SQL `SELECT` query.
|
|
82
|
+
- **Note**: Queries attempting to modify data will raise a `Security Error` or `OperationalError`.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
### Running Tests
|
|
87
|
+
Execute the unit test suite:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
make test
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Extending
|
|
94
|
+
To add support for a new database (e.g., Postgres):
|
|
95
|
+
1. Create `db/postgres_repository.py` implementation of `DatabaseRepository`.
|
|
96
|
+
2. Update `server.py` to instantiate it when `DB_ENGINE=postgres`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
db/mysql_repository.py,sha256=Lz6XCKl3JGGt07vToqHjA-2gZw8oTDJwxgUez0YBqjM,4250
|
|
2
|
+
db/repository.py,sha256=LnX1P1zkAIJGickWzzsHP0Q1rxQIPH136fhJ8CiXBAs,567
|
|
3
|
+
db/sqlite_repository.py,sha256=vhcINgMrl7V5B95z8wXqUfb_d9xavOZuhSaM6XpYnwo,3392
|
|
4
|
+
mcp_db_server-0.1.0.dist-info/METADATA,sha256=y1eieit_zAO9QM-uckXjtgRaQdg7GXTlBcSUr5V7Pu4,3247
|
|
5
|
+
mcp_db_server-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
mcp_db_server-0.1.0.dist-info/entry_points.txt,sha256=_pmOXzdIi09RN6BMnvyi69zYjhQ-uyxYgGam6-FaPGA,46
|
|
7
|
+
mcp_db_server-0.1.0.dist-info/top_level.txt,sha256=UqPns8SCl180WOtC6JwkkipPz355wef80NxCAVT1uMU,3
|
|
8
|
+
mcp_db_server-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
db
|