db-adapter 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ dist/
2
+ *.egg-info/
3
+ __pycache__/
4
+ *.pyc
5
+ .venv/
6
+ .ruff_cache/
@@ -0,0 +1,44 @@
1
+ # MCP Database Server
2
+
3
+ MCP server that provides database access tools for AI agents.
4
+
5
+ ## Tools
6
+
7
+ ### `list_tables`
8
+ List all tables in the database. Returns table names and row counts.
9
+
10
+ ### `describe_table`
11
+ Get the schema of a specific table. Returns column names, types, nullable, default values, foreign keys, and indexes.
12
+
13
+ Parameters:
14
+ - `table_name` (string, required): The name of the table to describe.
15
+
16
+ ### `run_select_query`
17
+ Execute a read-only SELECT query against the database. Only SELECT statements are allowed for safety.
18
+
19
+ Parameters:
20
+ - `query` (string, required): The SELECT SQL query to execute.
21
+
22
+ ## Usage Guidelines
23
+
24
+ When working with the database:
25
+ - Use `list_tables` first to discover available tables
26
+ - Use `describe_table` to understand a table's schema before querying it
27
+ - Use `run_select_query` to execute SELECT queries only (INSERT/UPDATE/DELETE are blocked for safety)
28
+ - Limit results with `LIMIT` clause when exploring large tables
29
+ - Query results are returned as markdown tables
30
+
31
+ ## Configuration
32
+
33
+ The database connection is configured via these environment variables:
34
+
35
+ | Variable | Required | Default | Description |
36
+ |---|---|---|---|
37
+ | `DATABASE_POSTGRESQL_HOST` | Yes | — | PostgreSQL host address |
38
+ | `DATABASE_POSTGRESQL_PORT` | Yes | — | PostgreSQL port |
39
+ | `DATABASE_POSTGRESQL_USERNAME` | Yes | — | Database user |
40
+ | `DATABASE_POSTGRESQL_PASSWORD` | No | — | Database password |
41
+ | `DATABASE_POSTGRESQL_NAME` | Yes | — | Database name |
42
+ | `DATABASE_POSTGRESQL_SCHEMA` | No | `public` | Schema to use for table discovery |
43
+
44
+ If any required variable is missing, all tool calls will return an error indicating the database is not available.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Husni Robani
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,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: db-adapter
3
+ Version: 1.0.0
4
+ Summary: MCP server that provides PostgreSQL database access tools for AI agents (read-only)
5
+ Author-email: Husni Robani <v.husni.robani@banksinarmas.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
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
+ Requires-Python: >=3.10
18
+ Requires-Dist: asyncpg>=0.29.0
19
+ Requires-Dist: mcp>=1.0.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # MCP Database Server
23
+
24
+ MCP server that provides read-only PostgreSQL database access for AI agents.
25
+
26
+ ## Tools
27
+
28
+ | Tool | Description |
29
+ |------|-------------|
30
+ | `list_tables` | List all tables with row counts |
31
+ | `describe_table` | Get schema: columns, types, foreign keys, indexes |
32
+ | `run_select_query` | Execute read-only SELECT queries |
33
+
34
+ INSERT/UPDATE/DELETE are blocked for safety.
35
+
36
+ ## Configuration
37
+
38
+ | Variable | Required | Default | Description |
39
+ |---|---|---|---|
40
+ | `DATABASE_POSTGRESQL_HOST` | Yes | — | PostgreSQL host |
41
+ | `DATABASE_POSTGRESQL_PORT` | Yes | — | PostgreSQL port |
42
+ | `DATABASE_POSTGRESQL_USERNAME` | Yes | — | Database user |
43
+ | `DATABASE_POSTGRESQL_PASSWORD` | No | — | Database password |
44
+ | `DATABASE_POSTGRESQL_NAME` | Yes | — | Database name |
45
+ | `DATABASE_POSTGRESQL_SCHEMA` | No | `public` | Schema for table discovery |
46
+
47
+ ## Usage
48
+
49
+ ### Claude Desktop
50
+
51
+ Add to `claude_desktop_config.json`:
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "db": {
57
+ "command": "uvx",
58
+ "args": ["db-adapter"],
59
+ "env": {
60
+ "DATABASE_POSTGRESQL_HOST": "localhost",
61
+ "DATABASE_POSTGRESQL_PORT": "5432",
62
+ "DATABASE_POSTGRESQL_USERNAME": "postgres",
63
+ "DATABASE_POSTGRESQL_PASSWORD": "postgres",
64
+ "DATABASE_POSTGRESQL_NAME": "mydb"
65
+ }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ ### opencode
72
+
73
+ Add to `opencode.json`:
74
+
75
+ ```json
76
+ {
77
+ "mcp": {
78
+ "db": {
79
+ "type": "local",
80
+ "command": ["uvx", "db-adapter"],
81
+ "enabled": true,
82
+ "environment": {
83
+ "DATABASE_POSTGRESQL_HOST": "localhost",
84
+ "DATABASE_POSTGRESQL_PORT": "5432",
85
+ "DATABASE_POSTGRESQL_USERNAME": "postgres",
86
+ "DATABASE_POSTGRESQL_PASSWORD": "postgres",
87
+ "DATABASE_POSTGRESQL_NAME": "mydb"
88
+ }
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ Environment variables can be omitted to inherit from your shell.
95
+
96
+ ## Requirements
97
+
98
+ - Python >= 3.10
99
+ - PostgreSQL database
@@ -0,0 +1,78 @@
1
+ # MCP Database Server
2
+
3
+ MCP server that provides read-only PostgreSQL database access for AI agents.
4
+
5
+ ## Tools
6
+
7
+ | Tool | Description |
8
+ |------|-------------|
9
+ | `list_tables` | List all tables with row counts |
10
+ | `describe_table` | Get schema: columns, types, foreign keys, indexes |
11
+ | `run_select_query` | Execute read-only SELECT queries |
12
+
13
+ INSERT/UPDATE/DELETE are blocked for safety.
14
+
15
+ ## Configuration
16
+
17
+ | Variable | Required | Default | Description |
18
+ |---|---|---|---|
19
+ | `DATABASE_POSTGRESQL_HOST` | Yes | — | PostgreSQL host |
20
+ | `DATABASE_POSTGRESQL_PORT` | Yes | — | PostgreSQL port |
21
+ | `DATABASE_POSTGRESQL_USERNAME` | Yes | — | Database user |
22
+ | `DATABASE_POSTGRESQL_PASSWORD` | No | — | Database password |
23
+ | `DATABASE_POSTGRESQL_NAME` | Yes | — | Database name |
24
+ | `DATABASE_POSTGRESQL_SCHEMA` | No | `public` | Schema for table discovery |
25
+
26
+ ## Usage
27
+
28
+ ### Claude Desktop
29
+
30
+ Add to `claude_desktop_config.json`:
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "db": {
36
+ "command": "uvx",
37
+ "args": ["db-adapter"],
38
+ "env": {
39
+ "DATABASE_POSTGRESQL_HOST": "localhost",
40
+ "DATABASE_POSTGRESQL_PORT": "5432",
41
+ "DATABASE_POSTGRESQL_USERNAME": "postgres",
42
+ "DATABASE_POSTGRESQL_PASSWORD": "postgres",
43
+ "DATABASE_POSTGRESQL_NAME": "mydb"
44
+ }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ ### opencode
51
+
52
+ Add to `opencode.json`:
53
+
54
+ ```json
55
+ {
56
+ "mcp": {
57
+ "db": {
58
+ "type": "local",
59
+ "command": ["uvx", "db-adapter"],
60
+ "enabled": true,
61
+ "environment": {
62
+ "DATABASE_POSTGRESQL_HOST": "localhost",
63
+ "DATABASE_POSTGRESQL_PORT": "5432",
64
+ "DATABASE_POSTGRESQL_USERNAME": "postgres",
65
+ "DATABASE_POSTGRESQL_PASSWORD": "postgres",
66
+ "DATABASE_POSTGRESQL_NAME": "mydb"
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ Environment variables can be omitted to inherit from your shell.
74
+
75
+ ## Requirements
76
+
77
+ - Python >= 3.10
78
+ - PostgreSQL database
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "mcp": {
4
+ "db": {
5
+ "type": "local",
6
+ "command": [
7
+ "uv",
8
+ "run",
9
+ "server.py"
10
+ ],
11
+ "enabled": true,
12
+ "environment": {
13
+ "DATABASE_POSTGRESQL_NAME": "LMS_CUSTODY_MODULE"
14
+ }
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "db-adapter"
3
+ version = "1.0.0"
4
+ description = "MCP server that provides PostgreSQL database access tools for AI agents (read-only)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Husni Robani", email = "v.husni.robani@banksinarmas.com" },
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Database",
21
+ ]
22
+ dependencies = [
23
+ "mcp>=1.0.0",
24
+ "asyncpg>=0.29.0",
25
+ ]
26
+
27
+ [project.scripts]
28
+ mcp-db-adapter = "db_adapter.server:cli"
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
@@ -0,0 +1,5 @@
1
+ """MCP Database Server - PostgreSQL database tools for AI agents."""
2
+
3
+ from db_adapter.server import main, cli, server
4
+
5
+ __version__ = "1.0.0"
@@ -0,0 +1,319 @@
1
+ """
2
+ MCP Database Server - Exposes database tools for AI agents via MCP protocol.
3
+ Connects to a PostgreSQL database using individual env vars for host, port,
4
+ user, password, name, and schema.
5
+ Provides: list_tables, describe_table, run_select_query.
6
+ """
7
+
8
+ import asyncpg
9
+ import os
10
+ import re
11
+ import asyncio
12
+
13
+ from mcp.server import Server
14
+ from mcp.server.stdio import stdio_server
15
+ from mcp.types import Tool, TextContent
16
+
17
+ DB_NAME = os.environ.get("DATABASE_POSTGRESQL_NAME", "")
18
+ DB_HOST = os.environ.get("DATABASE_POSTGRESQL_HOST", "")
19
+ DB_PORT = os.environ.get("DATABASE_POSTGRESQL_PORT", "")
20
+ DB_USER = os.environ.get("DATABASE_POSTGRESQL_USERNAME", "")
21
+ DB_PASS = os.environ.get("DATABASE_POSTGRESQL_PASSWORD", "")
22
+ DB_SCHEMA = os.environ.get("DATABASE_POSTGRESQL_SCHEMA", "public")
23
+
24
+ _IDENTIFIER_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
25
+
26
+ server = Server("mcp-db")
27
+
28
+ _pool = None
29
+
30
+
31
+ def _is_safe_identifier(name: str) -> bool:
32
+ return bool(_IDENTIFIER_RE.match(name))
33
+
34
+
35
+ def _build_dsn() -> str:
36
+ if DB_PASS:
37
+ return f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
38
+ return f"postgresql://{DB_USER}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
39
+
40
+
41
+ async def _ensure_pool() -> TextContent | None:
42
+ global _pool
43
+ missing = []
44
+ if not DB_NAME:
45
+ missing.append("DATABASE_POSTGRESQL_NAME")
46
+ if not DB_HOST:
47
+ missing.append("DATABASE_POSTGRESQL_HOST")
48
+ if not DB_PORT:
49
+ missing.append("DATABASE_POSTGRESQL_PORT")
50
+ if not DB_USER:
51
+ missing.append("DATABASE_POSTGRESQL_USERNAME")
52
+ if missing:
53
+ return TextContent(
54
+ type="text",
55
+ text=f"Error: Missing required environment variables: {', '.join(missing)}. "
56
+ f"The database is not available.",
57
+ )
58
+ if not _is_safe_identifier(DB_SCHEMA):
59
+ return TextContent(
60
+ type="text",
61
+ text=f"Error: Invalid schema name '{DB_SCHEMA}'. "
62
+ f"Schema name must contain only alphanumeric characters and underscores.",
63
+ )
64
+ if _pool is None:
65
+ try:
66
+ _pool = await asyncpg.create_pool(_build_dsn())
67
+ except Exception as e:
68
+ return TextContent(
69
+ type="text",
70
+ text=f"Error: Failed to connect to database: {str(e)}",
71
+ )
72
+ return None
73
+
74
+
75
+ async def _release_pool():
76
+ global _pool
77
+ if _pool:
78
+ await _pool.close()
79
+ _pool = None
80
+
81
+
82
+ @server.list_tools()
83
+ async def list_tools() -> list[Tool]:
84
+ return [
85
+ Tool(
86
+ name="list_tables",
87
+ description="List all tables in the database. Returns table names and row counts.",
88
+ inputSchema={
89
+ "type": "object",
90
+ "properties": {},
91
+ "required": [],
92
+ },
93
+ ),
94
+ Tool(
95
+ name="describe_table",
96
+ description="Get the schema of a specific table. Returns column names, types, nullable, and default values.",
97
+ inputSchema={
98
+ "type": "object",
99
+ "properties": {
100
+ "table_name": {
101
+ "type": "string",
102
+ "description": "The name of the table to describe.",
103
+ },
104
+ },
105
+ "required": ["table_name"],
106
+ },
107
+ ),
108
+ Tool(
109
+ name="run_select_query",
110
+ description="Execute a read-only SELECT query against the database. Only SELECT statements are allowed for safety.",
111
+ inputSchema={
112
+ "type": "object",
113
+ "properties": {
114
+ "query": {
115
+ "type": "string",
116
+ "description": "The SELECT SQL query to execute.",
117
+ },
118
+ },
119
+ "required": ["query"],
120
+ },
121
+ ),
122
+ ]
123
+
124
+
125
+ @server.call_tool()
126
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
127
+ if name == "list_tables":
128
+ return await _list_tables()
129
+ elif name == "describe_table":
130
+ return await _describe_table(arguments["table_name"])
131
+ elif name == "run_select_query":
132
+ return await _run_select_query(arguments["query"])
133
+ else:
134
+ raise ValueError(f"Unknown tool: {name}")
135
+
136
+
137
+ async def _list_tables() -> list[TextContent]:
138
+ error = await _ensure_pool()
139
+ if error:
140
+ return [error]
141
+
142
+ schema = DB_SCHEMA
143
+ async with _pool.acquire() as conn:
144
+ rows = await conn.fetch(
145
+ f"SELECT table_name FROM information_schema.tables "
146
+ f"WHERE table_schema = '{schema}' AND table_type = 'BASE TABLE' "
147
+ f"ORDER BY table_name"
148
+ )
149
+
150
+ if not rows:
151
+ return [TextContent(type="text", text="No tables found in the database.")]
152
+
153
+ lines = []
154
+ for row in rows:
155
+ table_name = row["table_name"]
156
+ count = await conn.fetchval(
157
+ f'SELECT COUNT(*) FROM "{schema}"."{table_name}"'
158
+ )
159
+ lines.append(f"{table_name} ({count} rows)")
160
+
161
+ return [TextContent(type="text", text="\n".join(lines))]
162
+
163
+
164
+ async def _describe_table(table_name: str) -> list[TextContent]:
165
+ if not _is_safe_identifier(table_name):
166
+ return [
167
+ TextContent(
168
+ type="text",
169
+ text=f"Error: Invalid table name '{table_name}'. "
170
+ f"Table name must contain only alphanumeric characters and underscores.",
171
+ )
172
+ ]
173
+
174
+ error = await _ensure_pool()
175
+ if error:
176
+ return [error]
177
+
178
+ schema = DB_SCHEMA
179
+ async with _pool.acquire() as conn:
180
+ exists = await conn.fetchval(
181
+ f"SELECT COUNT(*) FROM information_schema.tables "
182
+ f"WHERE table_schema = '{schema}' AND table_name = $1",
183
+ table_name,
184
+ )
185
+
186
+ if not exists:
187
+ return [
188
+ TextContent(
189
+ type="text",
190
+ text=f"Table '{table_name}' does not exist.",
191
+ )
192
+ ]
193
+
194
+ columns = await conn.fetch(
195
+ f"""
196
+ SELECT
197
+ c.column_name,
198
+ c.data_type,
199
+ c.is_nullable,
200
+ c.column_default,
201
+ CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_pk
202
+ FROM information_schema.columns c
203
+ LEFT JOIN (
204
+ SELECT kcu.column_name
205
+ FROM information_schema.table_constraints tc
206
+ JOIN information_schema.key_column_usage kcu
207
+ ON tc.constraint_name = kcu.constraint_name
208
+ AND tc.table_schema = kcu.table_schema
209
+ WHERE tc.table_schema = '{schema}'
210
+ AND tc.table_name = $1
211
+ AND tc.constraint_type = 'PRIMARY KEY'
212
+ ) pk ON c.column_name = pk.column_name
213
+ WHERE c.table_schema = '{schema}'
214
+ AND c.table_name = $1
215
+ ORDER BY c.ordinal_position
216
+ """,
217
+ table_name,
218
+ )
219
+
220
+ fks = await conn.fetch(
221
+ f"""
222
+ SELECT
223
+ kcu.column_name,
224
+ ccu.table_name AS foreign_table_name,
225
+ ccu.column_name AS foreign_column_name
226
+ FROM information_schema.table_constraints tc
227
+ JOIN information_schema.key_column_usage kcu
228
+ ON tc.constraint_name = kcu.constraint_name
229
+ AND tc.table_schema = kcu.table_schema
230
+ JOIN information_schema.constraint_column_usage ccu
231
+ ON tc.constraint_name = ccu.constraint_name
232
+ AND tc.table_schema = ccu.table_schema
233
+ WHERE tc.table_schema = '{schema}'
234
+ AND tc.table_name = $1
235
+ AND tc.constraint_type = 'FOREIGN KEY'
236
+ """,
237
+ table_name,
238
+ )
239
+
240
+ indexes = await conn.fetch(
241
+ f"SELECT indexname, indexdef FROM pg_indexes "
242
+ f"WHERE schemaname = '{schema}' AND tablename = $1",
243
+ table_name,
244
+ )
245
+
246
+ lines = [f"Table: {table_name}", "", "Columns:"]
247
+ for col in columns:
248
+ pk = " PRIMARY KEY" if col["is_pk"] else ""
249
+ notnull = " NOT NULL" if col["is_nullable"] == "NO" else ""
250
+ default = (
251
+ f" DEFAULT {col['column_default']}" if col["column_default"] else ""
252
+ )
253
+ lines.append(
254
+ f" {col['column_name']} {col['data_type']}{pk}{notnull}{default}"
255
+ )
256
+
257
+ if fks:
258
+ lines.append("")
259
+ lines.append("Foreign Keys:")
260
+ for fk in fks:
261
+ lines.append(
262
+ f" {fk['column_name']} -> {fk['foreign_table_name']}.{fk['foreign_column_name']}"
263
+ )
264
+
265
+ if indexes:
266
+ lines.append("")
267
+ lines.append("Indexes:")
268
+ for idx in indexes:
269
+ unique = "UNIQUE" in (idx["indexdef"] or "")
270
+ lines.append(f" {idx['indexname']} (unique: {unique})")
271
+
272
+ return [TextContent(type="text", text="\n".join(lines))]
273
+
274
+
275
+ async def _run_select_query(query: str) -> list[TextContent]:
276
+ stripped = query.strip().upper()
277
+ if not stripped.startswith("SELECT"):
278
+ return [
279
+ TextContent(
280
+ type="text",
281
+ text="Error: Only SELECT queries are allowed for safety.",
282
+ )
283
+ ]
284
+
285
+ error = await _ensure_pool()
286
+ if error:
287
+ return [error]
288
+
289
+ async with _pool.acquire() as conn:
290
+ try:
291
+ rows = await conn.fetch(query)
292
+ except Exception as e:
293
+ return [TextContent(type="text", text=f"Error executing query: {str(e)}")]
294
+
295
+ if not rows:
296
+ return [TextContent(type="text", text="Query returned no rows.")]
297
+
298
+ headers = list(rows[0].keys())
299
+ lines = ["| " + " | ".join(headers) + " |"]
300
+ lines.append("|" + "|".join(["---" for _ in headers]) + "|")
301
+ for row in rows:
302
+ values = [str(v) if v is not None else "NULL" for v in row.values()]
303
+ lines.append("| " + " | ".join(values) + " |")
304
+
305
+ output = "\n".join(lines)
306
+ if len(rows) > 100:
307
+ output += f"\n\n({len(rows)} rows returned, showing all)"
308
+
309
+ return [TextContent(type="text", text=output)]
310
+
311
+
312
+ async def main():
313
+ async with stdio_server() as (read, write):
314
+ await server.run(read, write, server.create_initialization_options())
315
+
316
+
317
+ def cli():
318
+ """Entry point for the console script."""
319
+ asyncio.run(main())