mcp-database 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.
- mcp_database/__init__.py +3 -0
- mcp_database/__main__.py +5 -0
- mcp_database/adapters/__init__.py +6 -0
- mcp_database/adapters/base.py +108 -0
- mcp_database/adapters/mysql.py +173 -0
- mcp_database/adapters/postgres.py +174 -0
- mcp_database/adapters/sqlite.py +120 -0
- mcp_database/config.py +120 -0
- mcp_database/server.py +285 -0
- mcp_database-0.1.0.dist-info/METADATA +173 -0
- mcp_database-0.1.0.dist-info/RECORD +14 -0
- mcp_database-0.1.0.dist-info/WHEEL +4 -0
- mcp_database-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_database-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp_database/__init__.py
ADDED
mcp_database/__main__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Base adapter interface for database connections."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class QueryResult:
|
|
10
|
+
"""Result of a SQL query."""
|
|
11
|
+
|
|
12
|
+
columns: list[str]
|
|
13
|
+
rows: list[list[Any]]
|
|
14
|
+
row_count: int
|
|
15
|
+
truncated: bool = False
|
|
16
|
+
|
|
17
|
+
def to_table(self, max_rows: int = 100) -> str:
|
|
18
|
+
"""Format result as an aligned text table."""
|
|
19
|
+
if not self.columns:
|
|
20
|
+
return "No results."
|
|
21
|
+
|
|
22
|
+
rows = self.rows[:max_rows]
|
|
23
|
+
# Calculate column widths
|
|
24
|
+
widths = [len(c) for c in self.columns]
|
|
25
|
+
for row in rows:
|
|
26
|
+
for i, val in enumerate(row):
|
|
27
|
+
if i < len(widths):
|
|
28
|
+
widths[i] = max(widths[i], len(str(val)))
|
|
29
|
+
|
|
30
|
+
# Header
|
|
31
|
+
header = " | ".join(c.ljust(widths[i]) for i, c in enumerate(self.columns))
|
|
32
|
+
separator = "-+-".join("-" * w for w in widths)
|
|
33
|
+
lines = [header, separator]
|
|
34
|
+
|
|
35
|
+
# Rows
|
|
36
|
+
for row in rows:
|
|
37
|
+
line = " | ".join(str(row[i]).ljust(widths[i]) if i < len(row) else "" for i in range(len(self.columns)))
|
|
38
|
+
lines.append(line)
|
|
39
|
+
|
|
40
|
+
result = "\n".join(lines)
|
|
41
|
+
if self.truncated:
|
|
42
|
+
result += f"\n... ({self.row_count} total rows, showing first {max_rows})"
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class TableInfo:
|
|
48
|
+
"""Information about a database table."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
columns: list[dict[str, Any]]
|
|
52
|
+
row_count: int | None = None
|
|
53
|
+
create_sql: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DatabaseAdapter(ABC):
|
|
57
|
+
"""Abstract base class for database adapters."""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def connect(self) -> None:
|
|
61
|
+
"""Establish connection to the database."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def disconnect(self) -> None:
|
|
65
|
+
"""Close the database connection."""
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def test_connection(self) -> bool:
|
|
69
|
+
"""Test if the connection is alive."""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def list_databases(self) -> list[str]:
|
|
73
|
+
"""List available databases (if applicable)."""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def list_tables(self, database: str | None = None) -> list[str]:
|
|
77
|
+
"""List all tables in the database."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def get_table_info(self, table: str, database: str | None = None) -> TableInfo:
|
|
81
|
+
"""Get detailed information about a table."""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def get_schema(self, database: str | None = None) -> str:
|
|
85
|
+
"""Get the full database schema as formatted text."""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def execute_query(self, sql: str, database: str | None = None, max_rows: int = 100) -> QueryResult:
|
|
89
|
+
"""Execute a read-only SQL query."""
|
|
90
|
+
|
|
91
|
+
def execute_write(self, sql: str, database: str | None = None) -> int:
|
|
92
|
+
"""Execute a write SQL query (INSERT/UPDATE/DELETE). Returns affected rows.
|
|
93
|
+
|
|
94
|
+
Raises NotImplementedError if the adapter is read-only.
|
|
95
|
+
"""
|
|
96
|
+
raise NotImplementedError("This adapter is read-only. Use execute_query() for SELECT statements.")
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def db_type(self) -> str:
|
|
101
|
+
"""Return the database type name (e.g., 'sqlite', 'postgresql', 'mysql')."""
|
|
102
|
+
|
|
103
|
+
def _is_read_only_query(self, sql: str) -> bool:
|
|
104
|
+
"""Check if a query is read-only (SELECT, SHOW, DESCRIBE, EXPLAIN)."""
|
|
105
|
+
normalized = sql.strip().upper()
|
|
106
|
+
# Allow SELECT, SHOW, DESCRIBE, EXPLAIN, WITH (CTE leading to SELECT)
|
|
107
|
+
read_only_prefixes = ("SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH", "PRAGMA")
|
|
108
|
+
return any(normalized.startswith(prefix) for prefix in read_only_prefixes)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""MySQL database adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mcp_database.adapters.base import DatabaseAdapter, QueryResult, TableInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MySQLAdapter(DatabaseAdapter):
|
|
11
|
+
"""Adapter for MySQL databases."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
host: str = "localhost",
|
|
16
|
+
port: int = 3306,
|
|
17
|
+
user: str = "root",
|
|
18
|
+
password: str = "",
|
|
19
|
+
database: str = "",
|
|
20
|
+
read_only: bool = True,
|
|
21
|
+
):
|
|
22
|
+
self.host = host
|
|
23
|
+
self.port = port
|
|
24
|
+
self.user = user
|
|
25
|
+
self.password = password
|
|
26
|
+
self.default_database = database
|
|
27
|
+
self.read_only = read_only
|
|
28
|
+
self._conn: Any = None
|
|
29
|
+
|
|
30
|
+
def connect(self) -> None:
|
|
31
|
+
try:
|
|
32
|
+
import pymysql
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ImportError(
|
|
35
|
+
"pymysql is required for MySQL. Install with: pip install 'mcp-database[mysql]'"
|
|
36
|
+
)
|
|
37
|
+
self._conn = pymysql.connect(
|
|
38
|
+
host=self.host,
|
|
39
|
+
port=self.port,
|
|
40
|
+
user=self.user,
|
|
41
|
+
password=self.password,
|
|
42
|
+
database=self.default_database or None,
|
|
43
|
+
charset="utf8mb4",
|
|
44
|
+
cursorclass=pymysql.cursors.DictCursor,
|
|
45
|
+
)
|
|
46
|
+
if self.read_only:
|
|
47
|
+
cur = self._conn.cursor()
|
|
48
|
+
cur.execute("SET SESSION TRANSACTION READ ONLY")
|
|
49
|
+
cur.close()
|
|
50
|
+
|
|
51
|
+
def disconnect(self) -> None:
|
|
52
|
+
if self._conn:
|
|
53
|
+
self._conn.close()
|
|
54
|
+
self._conn = None
|
|
55
|
+
|
|
56
|
+
def test_connection(self) -> bool:
|
|
57
|
+
try:
|
|
58
|
+
if not self._conn:
|
|
59
|
+
return False
|
|
60
|
+
cur = self._conn.cursor()
|
|
61
|
+
cur.execute("SELECT 1")
|
|
62
|
+
cur.close()
|
|
63
|
+
return True
|
|
64
|
+
except Exception:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def list_databases(self) -> list[str]:
|
|
68
|
+
cur = self._get_conn().cursor()
|
|
69
|
+
cur.execute("SHOW DATABASES")
|
|
70
|
+
rows = cur.fetchall()
|
|
71
|
+
cur.close()
|
|
72
|
+
return [r["Database"] for r in rows]
|
|
73
|
+
|
|
74
|
+
def list_tables(self, database: str | None = None) -> list[str]:
|
|
75
|
+
conn = self._get_conn()
|
|
76
|
+
cur = conn.cursor()
|
|
77
|
+
if database:
|
|
78
|
+
cur.execute(f"SHOW TABLES FROM `{database}`")
|
|
79
|
+
else:
|
|
80
|
+
cur.execute("SHOW TABLES")
|
|
81
|
+
rows = cur.fetchall()
|
|
82
|
+
cur.close()
|
|
83
|
+
# The column name varies: "Tables_in_{database}" or "Tables_in_{current_db}"
|
|
84
|
+
return [list(r.values())[0] for r in rows]
|
|
85
|
+
|
|
86
|
+
def get_table_info(self, table: str, database: str | None = None) -> TableInfo:
|
|
87
|
+
conn = self._get_conn()
|
|
88
|
+
cur = conn.cursor()
|
|
89
|
+
|
|
90
|
+
table_ref = f"`{database}`.`{table}`" if database else f"`{table}`"
|
|
91
|
+
|
|
92
|
+
# Get columns
|
|
93
|
+
cur.execute(f"DESCRIBE {table_ref}")
|
|
94
|
+
col_rows = cur.fetchall()
|
|
95
|
+
columns = [
|
|
96
|
+
{
|
|
97
|
+
"name": r["Field"],
|
|
98
|
+
"type": r["Type"],
|
|
99
|
+
"nullable": r["Null"] == "YES",
|
|
100
|
+
"default": r["Default"],
|
|
101
|
+
"primary_key": r["Key"] == "PRI",
|
|
102
|
+
}
|
|
103
|
+
for r in col_rows
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
# Get row count
|
|
107
|
+
cur.execute(f"SELECT COUNT(*) as cnt FROM {table_ref}")
|
|
108
|
+
row_count = cur.fetchone()["cnt"]
|
|
109
|
+
|
|
110
|
+
# Get create SQL
|
|
111
|
+
cur.execute(f"SHOW CREATE TABLE {table_ref}")
|
|
112
|
+
create_row = cur.fetchone()
|
|
113
|
+
create_sql = create_row.get("Create Table") if create_row else None
|
|
114
|
+
|
|
115
|
+
cur.close()
|
|
116
|
+
return TableInfo(name=table, columns=columns, row_count=row_count, create_sql=create_sql)
|
|
117
|
+
|
|
118
|
+
def get_schema(self, database: str | None = None) -> str:
|
|
119
|
+
tables = self.list_tables(database)
|
|
120
|
+
parts = []
|
|
121
|
+
for table in tables:
|
|
122
|
+
info = self.get_table_info(table, database)
|
|
123
|
+
if info.create_sql:
|
|
124
|
+
parts.append(info.create_sql)
|
|
125
|
+
else:
|
|
126
|
+
col_lines = []
|
|
127
|
+
for col in info.columns:
|
|
128
|
+
parts_str = [col["type"]]
|
|
129
|
+
if col["primary_key"]:
|
|
130
|
+
parts_str.append("PRIMARY KEY")
|
|
131
|
+
if not col["nullable"]:
|
|
132
|
+
parts_str.append("NOT NULL")
|
|
133
|
+
if col["default"] is not None:
|
|
134
|
+
parts_str.append(f"DEFAULT {col['default']}")
|
|
135
|
+
col_lines.append(f" {col['name']} {' '.join(parts_str)}")
|
|
136
|
+
parts.append(f"CREATE TABLE {table} (\n" + ",\n".join(col_lines) + "\n);")
|
|
137
|
+
return "\n\n".join(parts) if parts else "No tables found."
|
|
138
|
+
|
|
139
|
+
def execute_query(self, sql: str, database: str | None = None, max_rows: int = 100) -> QueryResult:
|
|
140
|
+
conn = self._get_conn()
|
|
141
|
+
cur = conn.cursor()
|
|
142
|
+
if database:
|
|
143
|
+
cur.execute(f"USE `{database}`")
|
|
144
|
+
cur.execute(sql)
|
|
145
|
+
columns = [desc[0] for desc in cur.description] if cur.description else []
|
|
146
|
+
all_rows = cur.fetchall()
|
|
147
|
+
row_count = len(all_rows)
|
|
148
|
+
truncated = row_count > max_rows
|
|
149
|
+
rows = [[row.get(c) for c in columns] for row in all_rows[:max_rows]]
|
|
150
|
+
cur.close()
|
|
151
|
+
return QueryResult(columns=columns, rows=rows, row_count=row_count, truncated=truncated)
|
|
152
|
+
|
|
153
|
+
def execute_write(self, sql: str, database: str | None = None) -> int:
|
|
154
|
+
if self.read_only:
|
|
155
|
+
raise NotImplementedError("MySQL adapter is in read-only mode. Set read_only=False to enable writes.")
|
|
156
|
+
conn = self._get_conn()
|
|
157
|
+
cur = conn.cursor()
|
|
158
|
+
if database:
|
|
159
|
+
cur.execute(f"USE `{database}`")
|
|
160
|
+
cur.execute(sql)
|
|
161
|
+
affected = cur.rowcount
|
|
162
|
+
conn.commit()
|
|
163
|
+
cur.close()
|
|
164
|
+
return affected
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def db_type(self) -> str:
|
|
168
|
+
return "mysql"
|
|
169
|
+
|
|
170
|
+
def _get_conn(self) -> Any:
|
|
171
|
+
if not self._conn:
|
|
172
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
173
|
+
return self._conn
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""PostgreSQL database adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mcp_database.adapters.base import DatabaseAdapter, QueryResult, TableInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PostgreSQLAdapter(DatabaseAdapter):
|
|
11
|
+
"""Adapter for PostgreSQL databases."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
host: str = "localhost",
|
|
16
|
+
port: int = 5432,
|
|
17
|
+
user: str = "postgres",
|
|
18
|
+
password: str = "",
|
|
19
|
+
database: str = "postgres",
|
|
20
|
+
read_only: bool = True,
|
|
21
|
+
):
|
|
22
|
+
self.host = host
|
|
23
|
+
self.port = port
|
|
24
|
+
self.user = user
|
|
25
|
+
self.password = password
|
|
26
|
+
self.default_database = database
|
|
27
|
+
self.read_only = read_only
|
|
28
|
+
self._conn: Any = None
|
|
29
|
+
|
|
30
|
+
def connect(self) -> None:
|
|
31
|
+
try:
|
|
32
|
+
import psycopg2
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ImportError(
|
|
35
|
+
"psycopg2 is required for PostgreSQL. Install with: pip install 'mcp-database[postgres]'"
|
|
36
|
+
)
|
|
37
|
+
self._conn = psycopg2.connect(
|
|
38
|
+
host=self.host,
|
|
39
|
+
port=self.port,
|
|
40
|
+
user=self.user,
|
|
41
|
+
password=self.password,
|
|
42
|
+
dbname=self.default_database,
|
|
43
|
+
)
|
|
44
|
+
if self.read_only:
|
|
45
|
+
self._conn.set_session(readonly=True)
|
|
46
|
+
|
|
47
|
+
def disconnect(self) -> None:
|
|
48
|
+
if self._conn:
|
|
49
|
+
self._conn.close()
|
|
50
|
+
self._conn = None
|
|
51
|
+
|
|
52
|
+
def test_connection(self) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
if not self._conn:
|
|
55
|
+
return False
|
|
56
|
+
cur = self._conn.cursor()
|
|
57
|
+
cur.execute("SELECT 1")
|
|
58
|
+
cur.close()
|
|
59
|
+
return True
|
|
60
|
+
except Exception:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def list_databases(self) -> list[str]:
|
|
64
|
+
cur = self._get_conn().cursor()
|
|
65
|
+
cur.execute("SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname")
|
|
66
|
+
rows = cur.fetchall()
|
|
67
|
+
cur.close()
|
|
68
|
+
return [r[0] for r in rows]
|
|
69
|
+
|
|
70
|
+
def list_tables(self, database: str | None = None) -> list[str]:
|
|
71
|
+
cur = self._get_conn().cursor()
|
|
72
|
+
cur.execute(
|
|
73
|
+
"SELECT table_name FROM information_schema.tables "
|
|
74
|
+
"WHERE table_schema = 'public' ORDER BY table_name"
|
|
75
|
+
)
|
|
76
|
+
rows = cur.fetchall()
|
|
77
|
+
cur.close()
|
|
78
|
+
return [r[0] for r in rows]
|
|
79
|
+
|
|
80
|
+
def get_table_info(self, table: str, database: str | None = None) -> TableInfo:
|
|
81
|
+
conn = self._get_conn()
|
|
82
|
+
cur = conn.cursor()
|
|
83
|
+
|
|
84
|
+
# Get columns
|
|
85
|
+
cur.execute(
|
|
86
|
+
"SELECT column_name, data_type, is_nullable, column_default "
|
|
87
|
+
"FROM information_schema.columns "
|
|
88
|
+
"WHERE table_schema = 'public' AND table_name = %s "
|
|
89
|
+
"ORDER BY ordinal_position",
|
|
90
|
+
(table,),
|
|
91
|
+
)
|
|
92
|
+
col_rows = cur.fetchall()
|
|
93
|
+
columns = [
|
|
94
|
+
{
|
|
95
|
+
"name": r[0],
|
|
96
|
+
"type": r[1],
|
|
97
|
+
"nullable": r[2] == "YES",
|
|
98
|
+
"default": r[3],
|
|
99
|
+
"primary_key": False, # determined below
|
|
100
|
+
}
|
|
101
|
+
for r in col_rows
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
# Get primary keys
|
|
105
|
+
cur.execute(
|
|
106
|
+
"SELECT a.attname FROM pg_index i "
|
|
107
|
+
"JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) "
|
|
108
|
+
"WHERE i.indrelid = %s::regclass AND i.indisprimary",
|
|
109
|
+
(table,),
|
|
110
|
+
)
|
|
111
|
+
pk_cols = {r[0] for r in cur.fetchall()}
|
|
112
|
+
for col in columns:
|
|
113
|
+
if col["name"] in pk_cols:
|
|
114
|
+
col["primary_key"] = True
|
|
115
|
+
|
|
116
|
+
# Get row count (estimate for large tables)
|
|
117
|
+
cur.execute(f"SELECT COUNT(*) FROM \"{table}\"")
|
|
118
|
+
row_count = cur.fetchone()[0]
|
|
119
|
+
|
|
120
|
+
# Get create SQL (PostgreSQL doesn't have a single CREATE TABLE statement)
|
|
121
|
+
create_sql = None
|
|
122
|
+
|
|
123
|
+
cur.close()
|
|
124
|
+
return TableInfo(name=table, columns=columns, row_count=row_count, create_sql=create_sql)
|
|
125
|
+
|
|
126
|
+
def get_schema(self, database: str | None = None) -> str:
|
|
127
|
+
tables = self.list_tables(database)
|
|
128
|
+
parts = []
|
|
129
|
+
for table in tables:
|
|
130
|
+
info = self.get_table_info(table, database)
|
|
131
|
+
col_lines = []
|
|
132
|
+
for col in info.columns:
|
|
133
|
+
parts_str = [col["type"]]
|
|
134
|
+
if col["primary_key"]:
|
|
135
|
+
parts_str.append("PRIMARY KEY")
|
|
136
|
+
if not col["nullable"]:
|
|
137
|
+
parts_str.append("NOT NULL")
|
|
138
|
+
if col["default"] is not None:
|
|
139
|
+
parts_str.append(f"DEFAULT {col['default']}")
|
|
140
|
+
col_lines.append(f" {col['name']} {' '.join(parts_str)}")
|
|
141
|
+
parts.append(f"CREATE TABLE {table} (\n" + ",\n".join(col_lines) + "\n);")
|
|
142
|
+
return "\n\n".join(parts) if parts else "No tables found."
|
|
143
|
+
|
|
144
|
+
def execute_query(self, sql: str, database: str | None = None, max_rows: int = 100) -> QueryResult:
|
|
145
|
+
conn = self._get_conn()
|
|
146
|
+
cur = conn.cursor()
|
|
147
|
+
cur.execute(sql)
|
|
148
|
+
columns = [desc[0] for desc in cur.description] if cur.description else []
|
|
149
|
+
all_rows = cur.fetchall()
|
|
150
|
+
row_count = len(all_rows)
|
|
151
|
+
truncated = row_count > max_rows
|
|
152
|
+
rows = [list(row) for row in all_rows[:max_rows]]
|
|
153
|
+
cur.close()
|
|
154
|
+
return QueryResult(columns=columns, rows=rows, row_count=row_count, truncated=truncated)
|
|
155
|
+
|
|
156
|
+
def execute_write(self, sql: str, database: str | None = None) -> int:
|
|
157
|
+
if self.read_only:
|
|
158
|
+
raise NotImplementedError("PostgreSQL adapter is in read-only mode. Set read_only=False to enable writes.")
|
|
159
|
+
conn = self._get_conn()
|
|
160
|
+
cur = conn.cursor()
|
|
161
|
+
cur.execute(sql)
|
|
162
|
+
affected = cur.rowcount
|
|
163
|
+
conn.commit()
|
|
164
|
+
cur.close()
|
|
165
|
+
return affected
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def db_type(self) -> str:
|
|
169
|
+
return "postgresql"
|
|
170
|
+
|
|
171
|
+
def _get_conn(self) -> Any:
|
|
172
|
+
if not self._conn:
|
|
173
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
174
|
+
return self._conn
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""SQLite database adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mcp_database.adapters.base import DatabaseAdapter, QueryResult, TableInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SQLiteAdapter(DatabaseAdapter):
|
|
12
|
+
"""Adapter for SQLite databases."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, database_path: str, read_only: bool = True):
|
|
15
|
+
self.database_path = database_path
|
|
16
|
+
self.read_only = read_only
|
|
17
|
+
self._conn: sqlite3.Connection | None = None
|
|
18
|
+
|
|
19
|
+
def connect(self) -> None:
|
|
20
|
+
uri = f"file:{self.database_path}"
|
|
21
|
+
if self.read_only:
|
|
22
|
+
uri += "?mode=ro"
|
|
23
|
+
self._conn = sqlite3.connect(uri, uri=True)
|
|
24
|
+
self._conn.row_factory = sqlite3.Row
|
|
25
|
+
|
|
26
|
+
def disconnect(self) -> None:
|
|
27
|
+
if self._conn:
|
|
28
|
+
self._conn.close()
|
|
29
|
+
self._conn = None
|
|
30
|
+
|
|
31
|
+
def test_connection(self) -> bool:
|
|
32
|
+
try:
|
|
33
|
+
if not self._conn:
|
|
34
|
+
return False
|
|
35
|
+
self._conn.execute("SELECT 1")
|
|
36
|
+
return True
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
def list_databases(self) -> list[str]:
|
|
41
|
+
return ["main"]
|
|
42
|
+
|
|
43
|
+
def list_tables(self, database: str | None = None) -> list[str]:
|
|
44
|
+
conn = self._get_conn()
|
|
45
|
+
rows = conn.execute(
|
|
46
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
47
|
+
).fetchall()
|
|
48
|
+
return [r["name"] for r in rows]
|
|
49
|
+
|
|
50
|
+
def get_table_info(self, table: str, database: str | None = None) -> TableInfo:
|
|
51
|
+
conn = self._get_conn()
|
|
52
|
+
# Get column info
|
|
53
|
+
columns_raw = conn.execute(f"PRAGMA table_info('{table}')").fetchall()
|
|
54
|
+
columns = [
|
|
55
|
+
{
|
|
56
|
+
"name": r["name"],
|
|
57
|
+
"type": r["type"],
|
|
58
|
+
"nullable": not r["notnull"],
|
|
59
|
+
"default": r["dflt_value"],
|
|
60
|
+
"primary_key": bool(r["pk"]),
|
|
61
|
+
}
|
|
62
|
+
for r in columns_raw
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
# Get row count
|
|
66
|
+
row_count = conn.execute(f"SELECT COUNT(*) as cnt FROM '{table}'").fetchone()["cnt"]
|
|
67
|
+
|
|
68
|
+
# Get create SQL
|
|
69
|
+
create_row = conn.execute(
|
|
70
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
|
71
|
+
).fetchone()
|
|
72
|
+
create_sql = create_row["sql"] if create_row else None
|
|
73
|
+
|
|
74
|
+
return TableInfo(name=table, columns=columns, row_count=row_count, create_sql=create_sql)
|
|
75
|
+
|
|
76
|
+
def get_schema(self, database: str | None = None) -> str:
|
|
77
|
+
conn = self._get_conn()
|
|
78
|
+
tables = self.list_tables()
|
|
79
|
+
parts = []
|
|
80
|
+
for table in tables:
|
|
81
|
+
info = self.get_table_info(table)
|
|
82
|
+
col_lines = []
|
|
83
|
+
for col in info.columns:
|
|
84
|
+
parts_str = [col["type"]]
|
|
85
|
+
if col["primary_key"]:
|
|
86
|
+
parts_str.append("PRIMARY KEY")
|
|
87
|
+
if not col["nullable"]:
|
|
88
|
+
parts_str.append("NOT NULL")
|
|
89
|
+
if col["default"] is not None:
|
|
90
|
+
parts_str.append(f"DEFAULT {col['default']}")
|
|
91
|
+
col_lines.append(f" {col['name']} {' '.join(parts_str)}")
|
|
92
|
+
parts.append(f"CREATE TABLE {table} (\n" + ",\n".join(col_lines) + "\n);")
|
|
93
|
+
return "\n\n".join(parts) if parts else "No tables found."
|
|
94
|
+
|
|
95
|
+
def execute_query(self, sql: str, database: str | None = None, max_rows: int = 100) -> QueryResult:
|
|
96
|
+
conn = self._get_conn()
|
|
97
|
+
cursor = conn.execute(sql)
|
|
98
|
+
columns = [desc[0] for desc in cursor.description] if cursor.description else []
|
|
99
|
+
all_rows = cursor.fetchall()
|
|
100
|
+
row_count = len(all_rows)
|
|
101
|
+
truncated = row_count > max_rows
|
|
102
|
+
rows = [list(row) for row in all_rows[:max_rows]]
|
|
103
|
+
return QueryResult(columns=columns, rows=rows, row_count=row_count, truncated=truncated)
|
|
104
|
+
|
|
105
|
+
def execute_write(self, sql: str, database: str | None = None) -> int:
|
|
106
|
+
if self.read_only:
|
|
107
|
+
raise NotImplementedError("SQLite adapter is in read-only mode. Set read_only=False to enable writes.")
|
|
108
|
+
conn = self._get_conn()
|
|
109
|
+
cursor = conn.execute(sql)
|
|
110
|
+
conn.commit()
|
|
111
|
+
return cursor.rowcount
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def db_type(self) -> str:
|
|
115
|
+
return "sqlite"
|
|
116
|
+
|
|
117
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
118
|
+
if not self._conn:
|
|
119
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
120
|
+
return self._conn
|
mcp_database/config.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Configuration for mcp-database server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class DatabaseConfig:
|
|
11
|
+
"""Configuration for a single database connection."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
type: str # sqlite, postgresql, mysql
|
|
15
|
+
# SQLite
|
|
16
|
+
path: str = ""
|
|
17
|
+
# PostgreSQL / MySQL
|
|
18
|
+
host: str = "localhost"
|
|
19
|
+
port: int = 0
|
|
20
|
+
user: str = ""
|
|
21
|
+
password: str = ""
|
|
22
|
+
database: str = ""
|
|
23
|
+
# Options
|
|
24
|
+
read_only: bool = True
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if self.type == "sqlite" and not self.path:
|
|
28
|
+
raise ValueError(f"Database '{self.name}': 'path' is required for SQLite")
|
|
29
|
+
if self.type in ("postgresql", "mysql") and not self.host:
|
|
30
|
+
raise ValueError(f"Database '{self.name}': 'host' is required for {self.type}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ServerConfig:
|
|
35
|
+
"""Server-level configuration."""
|
|
36
|
+
|
|
37
|
+
databases: list[DatabaseConfig] = field(default_factory=list)
|
|
38
|
+
max_rows: int = 100
|
|
39
|
+
allow_writes: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_config_from_env() -> ServerConfig:
|
|
43
|
+
"""Load configuration from environment variables.
|
|
44
|
+
|
|
45
|
+
Supports:
|
|
46
|
+
MCP_DATABASE_URL - single database URL (sqlite:///path or postgres://...)
|
|
47
|
+
MCP_DATABASE_TYPE - database type (default: sqlite)
|
|
48
|
+
MCP_DATABASE_READ_ONLY - read-only mode (default: true)
|
|
49
|
+
MCP_MAX_ROWS - max rows to return (default: 100)
|
|
50
|
+
"""
|
|
51
|
+
db_url = os.environ.get("MCP_DATABASE_URL", "")
|
|
52
|
+
db_type = os.environ.get("MCP_DATABASE_TYPE", "sqlite")
|
|
53
|
+
read_only = os.environ.get("MCP_DATABASE_READ_ONLY", "true").lower() in ("true", "1", "yes")
|
|
54
|
+
max_rows = int(os.environ.get("MCP_MAX_ROWS", "100"))
|
|
55
|
+
|
|
56
|
+
if db_url:
|
|
57
|
+
config = _parse_url_to_config(db_url, db_type, read_only)
|
|
58
|
+
return ServerConfig(databases=[config], max_rows=max_rows, allow_writes=not read_only)
|
|
59
|
+
|
|
60
|
+
# Default: in-memory SQLite for demo/testing
|
|
61
|
+
return ServerConfig(
|
|
62
|
+
databases=[
|
|
63
|
+
DatabaseConfig(name="demo", type="sqlite", path=":memory:", read_only=False)
|
|
64
|
+
],
|
|
65
|
+
max_rows=max_rows,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_config_from_dict(data: dict) -> ServerConfig:
|
|
70
|
+
"""Load configuration from a dictionary (e.g., parsed YAML)."""
|
|
71
|
+
databases = []
|
|
72
|
+
for db_data in data.get("databases", []):
|
|
73
|
+
databases.append(DatabaseConfig(**db_data))
|
|
74
|
+
return ServerConfig(
|
|
75
|
+
databases=databases,
|
|
76
|
+
max_rows=data.get("max_rows", 100),
|
|
77
|
+
allow_writes=data.get("allow_writes", False),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_url_to_config(url: str, db_type: str, read_only: bool) -> DatabaseConfig:
|
|
82
|
+
"""Parse a database URL into a DatabaseConfig."""
|
|
83
|
+
if db_type == "sqlite":
|
|
84
|
+
# sqlite:///path/to/db.sqlite or sqlite:///:memory:
|
|
85
|
+
path = url.replace("sqlite:///", "").replace("sqlite://", "")
|
|
86
|
+
return DatabaseConfig(name="main", type="sqlite", path=path, read_only=read_only)
|
|
87
|
+
|
|
88
|
+
if db_type == "postgresql":
|
|
89
|
+
# postgres://user:password@host:port/database
|
|
90
|
+
from urllib.parse import urlparse
|
|
91
|
+
|
|
92
|
+
parsed = urlparse(url)
|
|
93
|
+
return DatabaseConfig(
|
|
94
|
+
name="main",
|
|
95
|
+
type="postgresql",
|
|
96
|
+
host=parsed.hostname or "localhost",
|
|
97
|
+
port=parsed.port or 5432,
|
|
98
|
+
user=parsed.username or "postgres",
|
|
99
|
+
password=parsed.password or "",
|
|
100
|
+
database=parsed.path.lstrip("/") or "postgres",
|
|
101
|
+
read_only=read_only,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if db_type == "mysql":
|
|
105
|
+
# mysql://user:password@host:port/database
|
|
106
|
+
from urllib.parse import urlparse
|
|
107
|
+
|
|
108
|
+
parsed = urlparse(url)
|
|
109
|
+
return DatabaseConfig(
|
|
110
|
+
name="main",
|
|
111
|
+
type="mysql",
|
|
112
|
+
host=parsed.hostname or "localhost",
|
|
113
|
+
port=parsed.port or 3306,
|
|
114
|
+
user=parsed.username or "root",
|
|
115
|
+
password=parsed.password or "",
|
|
116
|
+
database=parsed.path.lstrip("/") or "",
|
|
117
|
+
read_only=read_only,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
raise ValueError(f"Unsupported database type: {db_type}")
|
mcp_database/server.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""mcp-database — MCP server for multi-database access.
|
|
2
|
+
|
|
3
|
+
Provides tools for Claude to query, inspect, and manage databases
|
|
4
|
+
(SQLite, PostgreSQL, MySQL) through the Model Context Protocol.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
15
|
+
|
|
16
|
+
from mcp_database.adapters.base import DatabaseAdapter
|
|
17
|
+
from mcp_database.adapters.sqlite import SQLiteAdapter
|
|
18
|
+
from mcp_database.config import ServerConfig, load_config_from_env
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Adapter factory
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def _create_adapter(config) -> DatabaseAdapter:
|
|
28
|
+
"""Create a database adapter from config."""
|
|
29
|
+
if config.type == "sqlite":
|
|
30
|
+
return SQLiteAdapter(database_path=config.path, read_only=config.read_only)
|
|
31
|
+
if config.type == "postgresql":
|
|
32
|
+
from mcp_database.adapters.postgres import PostgreSQLAdapter
|
|
33
|
+
|
|
34
|
+
return PostgreSQLAdapter(
|
|
35
|
+
host=config.host,
|
|
36
|
+
port=config.port or 5432,
|
|
37
|
+
user=config.user,
|
|
38
|
+
password=config.password,
|
|
39
|
+
database=config.database,
|
|
40
|
+
read_only=config.read_only,
|
|
41
|
+
)
|
|
42
|
+
if config.type == "mysql":
|
|
43
|
+
from mcp_database.adapters.mysql import MySQLAdapter
|
|
44
|
+
|
|
45
|
+
return MySQLAdapter(
|
|
46
|
+
host=config.host,
|
|
47
|
+
port=config.port or 3306,
|
|
48
|
+
user=config.user,
|
|
49
|
+
password=config.password,
|
|
50
|
+
database=config.database,
|
|
51
|
+
read_only=config.read_only,
|
|
52
|
+
)
|
|
53
|
+
raise ValueError(f"Unsupported database type: {config.type}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Lifespan context
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class AppContext:
|
|
62
|
+
adapters: dict[str, DatabaseAdapter] = field(default_factory=dict)
|
|
63
|
+
config: ServerConfig = field(default_factory=ServerConfig)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@asynccontextmanager
|
|
67
|
+
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
68
|
+
"""Manage database connections on server startup/shutdown."""
|
|
69
|
+
config = load_config_from_env()
|
|
70
|
+
adapters: dict[str, DatabaseAdapter] = {}
|
|
71
|
+
|
|
72
|
+
for db_config in config.databases:
|
|
73
|
+
adapter = _create_adapter(db_config)
|
|
74
|
+
try:
|
|
75
|
+
adapter.connect()
|
|
76
|
+
adapters[db_config.name] = adapter
|
|
77
|
+
logger.info("Connected to %s (%s)", db_config.name, db_config.type)
|
|
78
|
+
except Exception:
|
|
79
|
+
logger.exception("Failed to connect to %s", db_config.name)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
yield AppContext(adapters=adapters, config=config)
|
|
83
|
+
finally:
|
|
84
|
+
for name, adapter in adapters.items():
|
|
85
|
+
try:
|
|
86
|
+
adapter.disconnect()
|
|
87
|
+
logger.info("Disconnected from %s", name)
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.exception("Error disconnecting from %s", name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# MCP Server
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
mcp = FastMCP(
|
|
97
|
+
"Database Server",
|
|
98
|
+
version="0.1.0",
|
|
99
|
+
lifespan=app_lifespan,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_adapter(ctx: Context, name: str | None = None) -> DatabaseAdapter:
|
|
104
|
+
"""Get a database adapter by name, or the first one if name is None."""
|
|
105
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
106
|
+
if not app_ctx.adapters:
|
|
107
|
+
raise RuntimeError("No databases configured.")
|
|
108
|
+
if name:
|
|
109
|
+
if name not in app_ctx.adapters:
|
|
110
|
+
available = ", ".join(app_ctx.adapters.keys())
|
|
111
|
+
raise ValueError(f"Database '{name}' not found. Available: {available}")
|
|
112
|
+
return app_ctx.adapters[name]
|
|
113
|
+
return next(iter(app_ctx.adapters.values()))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Tools
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
@mcp.tool()
|
|
121
|
+
def list_databases(ctx: Context) -> str:
|
|
122
|
+
"""List all configured database connections and their types."""
|
|
123
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
124
|
+
lines = []
|
|
125
|
+
for name, adapter in app_ctx.adapters.items():
|
|
126
|
+
status = "connected" if adapter.test_connection() else "disconnected"
|
|
127
|
+
lines.append(f"{name} ({adapter.db_type}) — {status}")
|
|
128
|
+
return "\n".join(lines) if lines else "No databases configured."
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@mcp.tool()
|
|
132
|
+
def list_tables(database: str | None = None, ctx: Context = None) -> str:
|
|
133
|
+
"""List all tables in a database.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
database: Name of the database connection (optional if only one is configured).
|
|
137
|
+
"""
|
|
138
|
+
adapter = _get_adapter(ctx, database)
|
|
139
|
+
tables = adapter.list_tables()
|
|
140
|
+
if not tables:
|
|
141
|
+
return f"No tables found in {database or adapter.db_type}."
|
|
142
|
+
return "\n".join(tables)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@mcp.tool()
|
|
146
|
+
def get_table_info(table: str, database: str | None = None, ctx: Context = None) -> str:
|
|
147
|
+
"""Get detailed information about a table: columns, types, row count.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
table: Table name.
|
|
151
|
+
database: Name of the database connection (optional if only one is configured).
|
|
152
|
+
"""
|
|
153
|
+
adapter = _get_adapter(ctx, database)
|
|
154
|
+
info = adapter.get_table_info(table)
|
|
155
|
+
|
|
156
|
+
lines = [f"Table: {info.name}", f"Rows: {info.row_count}", "", "Columns:"]
|
|
157
|
+
for col in info.columns:
|
|
158
|
+
parts = [col["name"], col["type"]]
|
|
159
|
+
if col["primary_key"]:
|
|
160
|
+
parts.append("PK")
|
|
161
|
+
if not col["nullable"]:
|
|
162
|
+
parts.append("NOT NULL")
|
|
163
|
+
if col["default"] is not None:
|
|
164
|
+
parts.append(f"DEFAULT {col['default']}")
|
|
165
|
+
lines.append(f" {' '.join(parts)}")
|
|
166
|
+
|
|
167
|
+
if info.create_sql:
|
|
168
|
+
lines.extend(["", "CREATE SQL:", info.create_sql])
|
|
169
|
+
|
|
170
|
+
return "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@mcp.tool()
|
|
174
|
+
def get_schema(database: str | None = None, ctx: Context = None) -> str:
|
|
175
|
+
"""Get the full database schema (CREATE TABLE statements for all tables).
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
database: Name of the database connection (optional if only one is configured).
|
|
179
|
+
"""
|
|
180
|
+
adapter = _get_adapter(ctx, database)
|
|
181
|
+
return adapter.get_schema()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@mcp.tool()
|
|
185
|
+
def query(sql: str, database: str | None = None, max_rows: int = 100, ctx: Context = None) -> str:
|
|
186
|
+
"""Execute a read-only SQL query (SELECT, SHOW, DESCRIBE, EXPLAIN) and return results.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
sql: SQL query to execute.
|
|
190
|
+
database: Name of the database connection (optional if only one is configured).
|
|
191
|
+
max_rows: Maximum number of rows to return (default: 100).
|
|
192
|
+
"""
|
|
193
|
+
adapter = _get_adapter(ctx, database)
|
|
194
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
195
|
+
max_rows = min(max_rows, app_ctx.config.max_rows)
|
|
196
|
+
|
|
197
|
+
result = adapter.execute_query(sql, max_rows=max_rows)
|
|
198
|
+
return result.to_table(max_rows=max_rows)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@mcp.tool()
|
|
202
|
+
def execute(sql: str, database: str | None = None, ctx: Context = None) -> str:
|
|
203
|
+
"""Execute a write SQL statement (INSERT, UPDATE, DELETE). Only works if writes are enabled.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
sql: SQL statement to execute.
|
|
207
|
+
database: Name of the database connection (optional if only one is configured).
|
|
208
|
+
"""
|
|
209
|
+
adapter = _get_adapter(ctx, database)
|
|
210
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
211
|
+
|
|
212
|
+
if not app_ctx.config.allow_writes:
|
|
213
|
+
return "Error: Write operations are disabled. Set allow_writes=True in config to enable."
|
|
214
|
+
|
|
215
|
+
if adapter._is_read_only_query(sql):
|
|
216
|
+
return "Error: Use the 'query' tool for SELECT statements."
|
|
217
|
+
|
|
218
|
+
affected = adapter.execute_write(sql)
|
|
219
|
+
return f"OK. {affected} row(s) affected."
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@mcp.tool()
|
|
223
|
+
def sample_rows(table: str, limit: int = 5, database: str | None = None, ctx: Context = None) -> str:
|
|
224
|
+
"""Get a sample of rows from a table to understand its data.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
table: Table name.
|
|
228
|
+
limit: Number of rows to sample (default: 5, max: 20).
|
|
229
|
+
database: Name of the database connection (optional if only one is configured).
|
|
230
|
+
"""
|
|
231
|
+
adapter = _get_adapter(ctx, database)
|
|
232
|
+
limit = min(limit, 20)
|
|
233
|
+
result = adapter.execute_query(f"SELECT * FROM \"{table}\" LIMIT {limit}", max_rows=limit)
|
|
234
|
+
return result.to_table(max_rows=limit)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@mcp.tool()
|
|
238
|
+
def search_tables(keyword: str, database: str | None = None, ctx: Context = None) -> str:
|
|
239
|
+
"""Search for tables or columns matching a keyword.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
keyword: Keyword to search for in table and column names.
|
|
243
|
+
database: Name of the database connection (optional if only one is configured).
|
|
244
|
+
"""
|
|
245
|
+
adapter = _get_adapter(ctx, database)
|
|
246
|
+
keyword_lower = keyword.lower()
|
|
247
|
+
matches = []
|
|
248
|
+
|
|
249
|
+
for table_name in adapter.list_tables():
|
|
250
|
+
if keyword_lower in table_name.lower():
|
|
251
|
+
matches.append(f"Table: {table_name}")
|
|
252
|
+
|
|
253
|
+
info = adapter.get_table_info(table_name)
|
|
254
|
+
for col in info.columns:
|
|
255
|
+
if keyword_lower in col["name"].lower():
|
|
256
|
+
matches.append(f" {table_name}.{col['name']} ({col['type']})")
|
|
257
|
+
|
|
258
|
+
return "\n".join(matches) if matches else f"No matches for '{keyword}'."
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# Resources
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
@mcp.resource("db://databases")
|
|
266
|
+
def resource_databases(ctx: Context) -> str:
|
|
267
|
+
"""List all configured databases."""
|
|
268
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
269
|
+
lines = []
|
|
270
|
+
for name, adapter in app_ctx.adapters.items():
|
|
271
|
+
lines.append(f"- {name} ({adapter.db_type})")
|
|
272
|
+
return "\n".join(lines) if lines else "No databases configured."
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Entry point
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def main():
|
|
280
|
+
"""Run the MCP server."""
|
|
281
|
+
mcp.run()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__":
|
|
285
|
+
main()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-database
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for multi-database access — SQLite, PostgreSQL, MySQL. Query, inspect schema, and manage databases through Claude.
|
|
5
|
+
Author: Mergewall Team
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: claude,database,mcp,model-context-protocol,mysql,postgresql,sqlite
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: mcp>=1.0.0
|
|
20
|
+
Requires-Dist: pyyaml>=6.0
|
|
21
|
+
Provides-Extra: all
|
|
22
|
+
Requires-Dist: psycopg2-binary>=2.9.0; extra == 'all'
|
|
23
|
+
Requires-Dist: pymysql>=1.1.0; extra == 'all'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
28
|
+
Provides-Extra: mysql
|
|
29
|
+
Requires-Dist: pymysql>=1.1.0; extra == 'mysql'
|
|
30
|
+
Provides-Extra: postgres
|
|
31
|
+
Requires-Dist: psycopg2-binary>=2.9.0; extra == 'postgres'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# mcp-database
|
|
35
|
+
|
|
36
|
+
[](https://www.python.org/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](https://modelcontextprotocol.io)
|
|
39
|
+
|
|
40
|
+
**MCP server for multi-database access — query, inspect schema, and manage SQLite, PostgreSQL, and MySQL databases through Claude.**
|
|
41
|
+
|
|
42
|
+
## Why mcp-database?
|
|
43
|
+
|
|
44
|
+
| Problem | Solution |
|
|
45
|
+
|---------|----------|
|
|
46
|
+
| Need to query a database from Claude Code / Claude Desktop | One MCP server, multiple database support |
|
|
47
|
+
| Existing database MCP servers are JS/Go only | Pure Python, uses official `mcp` SDK |
|
|
48
|
+
| Worried about accidental writes | Read-only by default, writes opt-in |
|
|
49
|
+
| Don't know the schema | Built-in schema inspection, table info, search |
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Install
|
|
55
|
+
pip install mcp-database
|
|
56
|
+
|
|
57
|
+
# Run with a SQLite database
|
|
58
|
+
MCP_DATABASE_URL=sqlite:///path/to/your.db mcp-database
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Claude Code Integration
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Add to Claude Code
|
|
65
|
+
claude mcp add mcp-database -- mcp-database
|
|
66
|
+
|
|
67
|
+
# Or with a specific database
|
|
68
|
+
claude mcp add mcp-database -e MCP_DATABASE_URL=sqlite:///path/to/db.sqlite -- mcp-database
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Claude Desktop Integration
|
|
72
|
+
|
|
73
|
+
Add to your `claude_desktop_config.json`:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"database": {
|
|
79
|
+
"command": "mcp-database",
|
|
80
|
+
"env": {
|
|
81
|
+
"MCP_DATABASE_URL": "sqlite:///path/to/your.db"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Supported Databases
|
|
89
|
+
|
|
90
|
+
| Database | Status | Install |
|
|
91
|
+
|----------|--------|---------|
|
|
92
|
+
| **SQLite** | Built-in | `pip install mcp-database` |
|
|
93
|
+
| **PostgreSQL** | Optional | `pip install 'mcp-database[postgres]'` |
|
|
94
|
+
| **MySQL** | Optional | `pip install 'mcp-database[mysql]'` |
|
|
95
|
+
| **All** | Optional | `pip install 'mcp-database[all]'` |
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
### Environment Variables
|
|
100
|
+
|
|
101
|
+
| Variable | Default | Description |
|
|
102
|
+
|----------|---------|-------------|
|
|
103
|
+
| `MCP_DATABASE_URL` | `sqlite:///:memory:` | Database connection URL |
|
|
104
|
+
| `MCP_DATABASE_TYPE` | `sqlite` | Database type: `sqlite`, `postgresql`, `mysql` |
|
|
105
|
+
| `MCP_DATABASE_READ_ONLY` | `true` | Enable read-only mode |
|
|
106
|
+
| `MCP_MAX_ROWS` | `100` | Maximum rows returned per query |
|
|
107
|
+
|
|
108
|
+
### Connection URLs
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# SQLite
|
|
112
|
+
MCP_DATABASE_URL=sqlite:///path/to/db.sqlite
|
|
113
|
+
MCP_DATABASE_URL=sqlite:///:memory:
|
|
114
|
+
|
|
115
|
+
# PostgreSQL
|
|
116
|
+
MCP_DATABASE_URL=postgres://user:password@localhost:5432/mydb
|
|
117
|
+
MCP_DATABASE_TYPE=postgresql
|
|
118
|
+
|
|
119
|
+
# MySQL
|
|
120
|
+
MCP_DATABASE_URL=mysql://user:password@localhost:3306/mydb
|
|
121
|
+
MCP_DATABASE_TYPE=mysql
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Available Tools
|
|
125
|
+
|
|
126
|
+
Once connected, Claude can use these tools:
|
|
127
|
+
|
|
128
|
+
| Tool | Description |
|
|
129
|
+
|------|-------------|
|
|
130
|
+
| `list_databases` | List all configured database connections |
|
|
131
|
+
| `list_tables` | List all tables in a database |
|
|
132
|
+
| `get_table_info` | Get detailed table info (columns, types, row count) |
|
|
133
|
+
| `get_schema` | Get full database schema (CREATE TABLE statements) |
|
|
134
|
+
| `query` | Execute a read-only SQL query (SELECT, SHOW, DESCRIBE) |
|
|
135
|
+
| `execute` | Execute a write statement (INSERT, UPDATE, DELETE) — opt-in only |
|
|
136
|
+
| `sample_rows` | Get sample rows from a table |
|
|
137
|
+
| `search_tables` | Search for tables or columns by keyword |
|
|
138
|
+
|
|
139
|
+
## Examples
|
|
140
|
+
|
|
141
|
+
Ask Claude things like:
|
|
142
|
+
|
|
143
|
+
- "What tables are in my database?"
|
|
144
|
+
- "Show me the schema for the users table"
|
|
145
|
+
- "Query the top 10 orders by amount"
|
|
146
|
+
- "Find all columns related to 'email'"
|
|
147
|
+
- "Sample some rows from the products table"
|
|
148
|
+
|
|
149
|
+
## Security
|
|
150
|
+
|
|
151
|
+
- **Read-only by default** — queries are safe, no data modification
|
|
152
|
+
- **Write opt-in** — set `allow_writes=True` and `MCP_DATABASE_READ_ONLY=false` to enable
|
|
153
|
+
- **Read-only detection** — write tool rejects SELECT statements (use `query` instead)
|
|
154
|
+
- **Row limits** — configurable max rows to prevent accidental large result sets
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# Clone and install
|
|
160
|
+
git clone https://github.com/Jansen003/mcp-database.git
|
|
161
|
+
cd mcp-database
|
|
162
|
+
pip install -e ".[dev]"
|
|
163
|
+
|
|
164
|
+
# Run tests
|
|
165
|
+
pytest
|
|
166
|
+
|
|
167
|
+
# Run with Inspector UI
|
|
168
|
+
mcp dev src/mcp_database/server.py
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mcp_database/__init__.py,sha256=bwWl9Arr7k4fU1mwQjneoOjKiGSilKzXUGEmbdIVV_4,84
|
|
2
|
+
mcp_database/__main__.py,sha256=5KjDHbkEGU5KzjggDpdI0sFDzr5zBtKOZB8EEesZl5Y,93
|
|
3
|
+
mcp_database/config.py,sha256=hxwqzYm_uuQS3q9_bxTGQAaeefFjNFWeBsP3-p3ogKg,3967
|
|
4
|
+
mcp_database/server.py,sha256=VIgUz_AzESxXCejMxXgfqL6E21nmxzhjJExkVqerZaM,9974
|
|
5
|
+
mcp_database/adapters/__init__.py,sha256=Rjo79cBgm6nk20FZlT-L1ShN-74C4CJQtWlNFHRz_kE,201
|
|
6
|
+
mcp_database/adapters/base.py,sha256=Ga2dykLUskYcdmERSboy6d52cusBjEszhGgbaf7AG2s,3562
|
|
7
|
+
mcp_database/adapters/mysql.py,sha256=gM7MxvFOAqTahdl9MqqdPuL-IgK27QMzfNNQoKixLfE,5821
|
|
8
|
+
mcp_database/adapters/postgres.py,sha256=C0DUm90Q2tAeVTLBqWWfcE7z1AqbBWV9ycHPneXFjZQ,5892
|
|
9
|
+
mcp_database/adapters/sqlite.py,sha256=OLuV9HPMy4jhZRwb70FwLb416v7kCwU6I55a3pe3Z2s,4339
|
|
10
|
+
mcp_database-0.1.0.dist-info/METADATA,sha256=3r47W2LP_OD8Vyh-2PTrjn2Ttmy_JMlMtgdRq8o8E3k,5320
|
|
11
|
+
mcp_database-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
mcp_database-0.1.0.dist-info/entry_points.txt,sha256=b3-EncwQwV8PjQeHSs2bt6EAbiCgDTUE_9vhonSgyX4,58
|
|
13
|
+
mcp_database-0.1.0.dist-info/licenses/LICENSE,sha256=UGM1IQ7xoSg6FMJ8S97NOZNSUCXK_n8S2CGQZZgyYNs,1071
|
|
14
|
+
mcp_database-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mergewall Team
|
|
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.
|