tai42-mcp-dynamic-postgres 0.5.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.
- tai42_mcp_dynamic_postgres/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/cli/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/cli/main.py +177 -0
- tai42_mcp_dynamic_postgres/config/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/config/settings.py +40 -0
- tai42_mcp_dynamic_postgres/core/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/core/app.py +23 -0
- tai42_mcp_dynamic_postgres/database/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/database/connection.py +130 -0
- tai42_mcp_dynamic_postgres/database/helpers.py +44 -0
- tai42_mcp_dynamic_postgres/docs/index.mdx +127 -0
- tai42_mcp_dynamic_postgres/gen/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/gen/builders/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/gen/builders/base_gen.py +82 -0
- tai42_mcp_dynamic_postgres/gen/builders/delete_gen.py +51 -0
- tai42_mcp_dynamic_postgres/gen/builders/insert_gen.py +124 -0
- tai42_mcp_dynamic_postgres/gen/builders/select_gen.py +69 -0
- tai42_mcp_dynamic_postgres/gen/builders/select_joined_gen.py +160 -0
- tai42_mcp_dynamic_postgres/gen/builders/update_gen.py +83 -0
- tai42_mcp_dynamic_postgres/gen/filters/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/gen/filters/builder.py +145 -0
- tai42_mcp_dynamic_postgres/gen/filters/models.py +84 -0
- tai42_mcp_dynamic_postgres/gen/loader.py +137 -0
- tai42_mcp_dynamic_postgres/gen/order/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/gen/order/builder.py +47 -0
- tai42_mcp_dynamic_postgres/gen/order/models.py +22 -0
- tai42_mcp_dynamic_postgres/gen/schema/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/gen/schema/codegen.py +111 -0
- tai42_mcp_dynamic_postgres/gen/schema/introspect.py +199 -0
- tai42_mcp_dynamic_postgres/gen/templates/__init__.py +0 -0
- tai42_mcp_dynamic_postgres/gen/templates/common.py +52 -0
- tai42_mcp_dynamic_postgres/gen/templates/delete.py +35 -0
- tai42_mcp_dynamic_postgres/gen/templates/insert.py +73 -0
- tai42_mcp_dynamic_postgres/gen/templates/select.py +33 -0
- tai42_mcp_dynamic_postgres/gen/templates/select_joined.py +47 -0
- tai42_mcp_dynamic_postgres/gen/templates/update.py +50 -0
- tai42_mcp_dynamic_postgres/gen/vector.py +15 -0
- tai42_mcp_dynamic_postgres/py.typed +0 -0
- tai42_mcp_dynamic_postgres/tai-plugin.yml +42 -0
- tai42_mcp_dynamic_postgres/tools/__init__.py +0 -0
- tai42_mcp_dynamic_postgres-0.5.0.dist-info/METADATA +134 -0
- tai42_mcp_dynamic_postgres-0.5.0.dist-info/RECORD +47 -0
- tai42_mcp_dynamic_postgres-0.5.0.dist-info/WHEEL +5 -0
- tai42_mcp_dynamic_postgres-0.5.0.dist-info/entry_points.txt +2 -0
- tai42_mcp_dynamic_postgres-0.5.0.dist-info/licenses/LICENSE +202 -0
- tai42_mcp_dynamic_postgres-0.5.0.dist-info/licenses/NOTICE +5 -0
- tai42_mcp_dynamic_postgres-0.5.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Literal, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from tai42_mcp_dynamic_postgres.core.app import mcp_app
|
|
9
|
+
from tai42_mcp_dynamic_postgres.database.connection import close_connection_pool, get_async_connection
|
|
10
|
+
from tai42_mcp_dynamic_postgres.gen.loader import load_dynamic_tools
|
|
11
|
+
|
|
12
|
+
if sys.platform == "win32":
|
|
13
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
14
|
+
|
|
15
|
+
TransportName = Literal["stdio", "http", "sse", "streamable-http"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def runner(
|
|
19
|
+
overwrite: bool,
|
|
20
|
+
readonly: bool,
|
|
21
|
+
allow_unfiltered: bool,
|
|
22
|
+
select_joined: Tuple[str, ...],
|
|
23
|
+
ignore_insert_column: Tuple[str, ...],
|
|
24
|
+
ignore_select_column: Tuple[str, ...],
|
|
25
|
+
ignore_update_column: Tuple[str, ...],
|
|
26
|
+
ignore_select_joined_column: Tuple[str, ...],
|
|
27
|
+
transport: TransportName,
|
|
28
|
+
host: str,
|
|
29
|
+
port: int,
|
|
30
|
+
) -> Optional[int]:
|
|
31
|
+
if transport == "stdio" and (host != "127.0.0.1" or port != 8000):
|
|
32
|
+
raise click.BadParameter("Host and port should not be set when using 'stdio' transport.")
|
|
33
|
+
|
|
34
|
+
await load_dynamic_tools(
|
|
35
|
+
overwrite=overwrite,
|
|
36
|
+
readonly=readonly,
|
|
37
|
+
allow_unfiltered=allow_unfiltered,
|
|
38
|
+
ignore_insert_columns=list(ignore_insert_column),
|
|
39
|
+
ignore_select_columns=list(ignore_select_column),
|
|
40
|
+
ignore_update_columns=list(ignore_update_column),
|
|
41
|
+
ignore_select_joined_columns=list(ignore_select_joined_column),
|
|
42
|
+
select_joined=[group.split(",") for group in select_joined or []],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# ping pg (fail fast on a bad connection before starting the server)
|
|
46
|
+
async with get_async_connection():
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
if transport == "stdio":
|
|
51
|
+
await mcp_app.run_async(transport=transport)
|
|
52
|
+
else:
|
|
53
|
+
await mcp_app.run_async(transport=transport, host=host, port=port)
|
|
54
|
+
except KeyboardInterrupt:
|
|
55
|
+
logging.info("KeyboardInterrupt")
|
|
56
|
+
return 130
|
|
57
|
+
except Exception:
|
|
58
|
+
logging.exception("Server terminated with an unexpected error")
|
|
59
|
+
return 1
|
|
60
|
+
finally:
|
|
61
|
+
# Safety net for paths that never reached the server; the app lifespan
|
|
62
|
+
# already closes the pool on clean shutdown. No-op when no pool is open.
|
|
63
|
+
try:
|
|
64
|
+
await close_connection_pool()
|
|
65
|
+
except asyncio.CancelledError:
|
|
66
|
+
raise
|
|
67
|
+
except Exception:
|
|
68
|
+
logging.exception("Error during connection pool closure")
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@click.command()
|
|
73
|
+
@click.option(
|
|
74
|
+
"--overwrite/--no-overwrite",
|
|
75
|
+
default=True,
|
|
76
|
+
show_default=True,
|
|
77
|
+
help="Regenerate the tool files on startup so they reflect the current schema. "
|
|
78
|
+
"Use --no-overwrite to reuse existing generated files instead.",
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--readonly",
|
|
82
|
+
is_flag=True,
|
|
83
|
+
help="Generate only read-only tools (e.g., select functions). No insert or update tools will be generated.",
|
|
84
|
+
)
|
|
85
|
+
@click.option(
|
|
86
|
+
"--allow-unfiltered",
|
|
87
|
+
is_flag=True,
|
|
88
|
+
help="Allow update/delete tools to run without a WHERE filter (affects every row). "
|
|
89
|
+
"Off by default: unfiltered updates and deletes raise an error.",
|
|
90
|
+
)
|
|
91
|
+
@click.option(
|
|
92
|
+
"--select-joined",
|
|
93
|
+
multiple=True,
|
|
94
|
+
help="Join groups for select, e.g., --select-joined table1,table2,table3",
|
|
95
|
+
)
|
|
96
|
+
@click.option(
|
|
97
|
+
"--ignore-insert-column",
|
|
98
|
+
multiple=True,
|
|
99
|
+
default=["id"],
|
|
100
|
+
show_default=True,
|
|
101
|
+
help="Columns to ignore when generating insert functions.",
|
|
102
|
+
)
|
|
103
|
+
@click.option(
|
|
104
|
+
"--ignore-select-column",
|
|
105
|
+
multiple=True,
|
|
106
|
+
default=[],
|
|
107
|
+
help="Columns to ignore when generating select functions.",
|
|
108
|
+
)
|
|
109
|
+
@click.option(
|
|
110
|
+
"--ignore-update-column",
|
|
111
|
+
multiple=True,
|
|
112
|
+
default=["id"],
|
|
113
|
+
show_default=True,
|
|
114
|
+
help="Columns to ignore when generating update functions.",
|
|
115
|
+
)
|
|
116
|
+
@click.option(
|
|
117
|
+
"--ignore-select-joined-column",
|
|
118
|
+
multiple=True,
|
|
119
|
+
default=[],
|
|
120
|
+
help="Columns to ignore when generating select_joined functions.",
|
|
121
|
+
)
|
|
122
|
+
@click.option(
|
|
123
|
+
"-t",
|
|
124
|
+
"--transport",
|
|
125
|
+
type=click.Choice(["stdio", "http", "sse", "streamable-http"]),
|
|
126
|
+
default="stdio",
|
|
127
|
+
show_default=True,
|
|
128
|
+
help="Transport mechanism for the MCP server.",
|
|
129
|
+
)
|
|
130
|
+
@click.option(
|
|
131
|
+
"--host",
|
|
132
|
+
default="127.0.0.1",
|
|
133
|
+
show_default=True,
|
|
134
|
+
help="Host to bind the server to (used only with HTTP/SSE transports).",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--port",
|
|
138
|
+
default=8000,
|
|
139
|
+
show_default=True,
|
|
140
|
+
type=int,
|
|
141
|
+
help="Port number to bind the server to (used only with HTTP/SSE transports).",
|
|
142
|
+
)
|
|
143
|
+
def main(
|
|
144
|
+
overwrite: bool,
|
|
145
|
+
readonly: bool,
|
|
146
|
+
allow_unfiltered: bool,
|
|
147
|
+
select_joined: Tuple[str, ...],
|
|
148
|
+
ignore_insert_column: Tuple[str, ...],
|
|
149
|
+
ignore_select_column: Tuple[str, ...],
|
|
150
|
+
ignore_update_column: Tuple[str, ...],
|
|
151
|
+
ignore_select_joined_column: Tuple[str, ...],
|
|
152
|
+
transport: TransportName,
|
|
153
|
+
host: str,
|
|
154
|
+
port: int,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Generate scoped PostgreSQL CRUD MCP tools from a live schema."""
|
|
157
|
+
sys.exit(
|
|
158
|
+
asyncio.run(
|
|
159
|
+
runner(
|
|
160
|
+
overwrite,
|
|
161
|
+
readonly,
|
|
162
|
+
allow_unfiltered,
|
|
163
|
+
select_joined,
|
|
164
|
+
ignore_insert_column,
|
|
165
|
+
ignore_select_column,
|
|
166
|
+
ignore_update_column,
|
|
167
|
+
ignore_select_joined_column,
|
|
168
|
+
transport,
|
|
169
|
+
host,
|
|
170
|
+
port,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
from pydantic import Field, SecretStr
|
|
5
|
+
from pydantic_settings import BaseSettings
|
|
6
|
+
|
|
7
|
+
load_dotenv()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PostgresSettings(BaseSettings):
|
|
11
|
+
model_config = {
|
|
12
|
+
"env_prefix": "PG_",
|
|
13
|
+
}
|
|
14
|
+
tools_dir: str = Field(
|
|
15
|
+
default_factory=lambda: str(Path.home() / ".cache" / "tai42-mcp-dynamic-postgres" / "tools"),
|
|
16
|
+
description="Directory for generated tool files",
|
|
17
|
+
validation_alias="TOOLS_DIR",
|
|
18
|
+
)
|
|
19
|
+
host: str = Field("localhost", description="PostgreSQL host")
|
|
20
|
+
port: int = Field(5432, description="PostgreSQL port")
|
|
21
|
+
# No defaults: db, user, and password must be supplied; a missing value raises
|
|
22
|
+
# a ValidationError at startup rather than connecting with a default.
|
|
23
|
+
db: str = Field(description="Database name")
|
|
24
|
+
user: str = Field(description="Database user (use a least-privilege role)")
|
|
25
|
+
password: SecretStr = Field(description="Database password")
|
|
26
|
+
|
|
27
|
+
# Statement timeout (milliseconds) applied to every pooled connection. Caps a
|
|
28
|
+
# runaway query so it cannot hold a pool slot indefinitely. 0 disables it.
|
|
29
|
+
statement_timeout: int = Field(30000, description="Per-connection statement_timeout in milliseconds (0 disables)")
|
|
30
|
+
|
|
31
|
+
# Pool configuration
|
|
32
|
+
pool_min_size: int = Field(1, description="Minimum number of pooled connections")
|
|
33
|
+
pool_max_size: int = Field(10, description="Maximum number of pooled connections")
|
|
34
|
+
pool_timeout: int = Field(10, description="Pool acquire timeout in seconds")
|
|
35
|
+
pool_max_lifetime: int = Field(300, description="Max lifetime of connection in seconds")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Populated from PG_*/TOOLS_DIR env vars. db/user/password have no defaults, so
|
|
39
|
+
# construction fails loudly if they are missing.
|
|
40
|
+
pg_settings = PostgresSettings() # pyright: ignore[reportCallIssue]
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
from tai42_mcp_dynamic_postgres.database.connection import close_connection_pool, get_connection_pool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@asynccontextmanager
|
|
10
|
+
async def lifespan(server: FastMCP) -> AsyncGenerator[None, None]:
|
|
11
|
+
"""Open the connection pool on startup and close it on shutdown.
|
|
12
|
+
|
|
13
|
+
Bound to the app lifespan, so the pool closes whenever the server stops,
|
|
14
|
+
whatever entry point (CLI, embedding host, tests) started it.
|
|
15
|
+
"""
|
|
16
|
+
await get_connection_pool()
|
|
17
|
+
try:
|
|
18
|
+
yield
|
|
19
|
+
finally:
|
|
20
|
+
await close_connection_pool()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
mcp_app: FastMCP = FastMCP(lifespan=lifespan)
|
|
File without changes
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from psycopg import AsyncConnection, AsyncCursor
|
|
8
|
+
from psycopg.conninfo import make_conninfo
|
|
9
|
+
from psycopg.rows import AsyncRowFactory
|
|
10
|
+
from psycopg_pool import AsyncConnectionPool
|
|
11
|
+
|
|
12
|
+
from tai42_mcp_dynamic_postgres.config.settings import pg_settings
|
|
13
|
+
from tai42_mcp_dynamic_postgres.database.helpers import register_types_loaders
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# The single process-wide pool. Built lazily on first use and closed once on
|
|
18
|
+
# shutdown; never rebuilt merely to be closed.
|
|
19
|
+
_pool: Optional[AsyncConnectionPool[AsyncConnection[Any]]] = None
|
|
20
|
+
_pool_lock = asyncio.Lock()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_conninfo() -> str:
|
|
24
|
+
"""Assemble the libpq conninfo string from settings.
|
|
25
|
+
|
|
26
|
+
``make_conninfo`` escapes each value so passwords or names with special
|
|
27
|
+
characters cannot break out. Built lazily and never stored at module scope, so
|
|
28
|
+
the cleartext password is not a global.
|
|
29
|
+
"""
|
|
30
|
+
return make_conninfo(
|
|
31
|
+
host=pg_settings.host,
|
|
32
|
+
port=pg_settings.port,
|
|
33
|
+
dbname=pg_settings.db,
|
|
34
|
+
user=pg_settings.user,
|
|
35
|
+
password=pg_settings.password.get_secret_value(),
|
|
36
|
+
# Cap any single statement so a runaway query cannot hold a pool slot.
|
|
37
|
+
options=f"-c statement_timeout={pg_settings.statement_timeout}",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def get_connection_pool() -> AsyncConnectionPool[AsyncConnection[Any]]:
|
|
42
|
+
global _pool
|
|
43
|
+
async with _pool_lock:
|
|
44
|
+
if _pool is None:
|
|
45
|
+
pool: AsyncConnectionPool[AsyncConnection[Any]] = AsyncConnectionPool(
|
|
46
|
+
conninfo=_build_conninfo(),
|
|
47
|
+
min_size=pg_settings.pool_min_size,
|
|
48
|
+
max_size=pg_settings.pool_max_size,
|
|
49
|
+
timeout=pg_settings.pool_timeout,
|
|
50
|
+
max_lifetime=pg_settings.pool_max_lifetime,
|
|
51
|
+
# Register type loaders once per physical connection, not per checkout.
|
|
52
|
+
configure=register_types_loaders,
|
|
53
|
+
open=False,
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
await pool.open()
|
|
57
|
+
except BaseException:
|
|
58
|
+
# Opening spun up background workers; close the partial pool so
|
|
59
|
+
# they are not orphaned, then propagate (``_pool`` stays None).
|
|
60
|
+
await pool.close()
|
|
61
|
+
raise
|
|
62
|
+
_pool = pool
|
|
63
|
+
return _pool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def close_connection_pool() -> None:
|
|
67
|
+
# Only close a pool that was actually created; do not build one just to close it.
|
|
68
|
+
global _pool
|
|
69
|
+
async with _pool_lock:
|
|
70
|
+
if _pool is not None:
|
|
71
|
+
await _pool.close()
|
|
72
|
+
_pool = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@asynccontextmanager
|
|
76
|
+
async def get_async_connection() -> AsyncGenerator[AsyncConnection[Any], None]:
|
|
77
|
+
"""Async context manager yielding a pooled DB connection in autocommit mode.
|
|
78
|
+
|
|
79
|
+
The read/introspection path: autocommit leaves a SELECT-only connection IDLE,
|
|
80
|
+
so returning it triggers no reset warning or rollback. Autocommit is reset to
|
|
81
|
+
the pool default before return, or a later write checkout of the same physical
|
|
82
|
+
connection would inherit it and lose its transactional guarantee.
|
|
83
|
+
"""
|
|
84
|
+
pool: Optional[AsyncConnectionPool[AsyncConnection[Any]]] = None
|
|
85
|
+
conn: Optional[AsyncConnection[Any]] = None
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
pool = await get_connection_pool()
|
|
89
|
+
conn = await pool.getconn()
|
|
90
|
+
await conn.set_autocommit(True)
|
|
91
|
+
yield conn
|
|
92
|
+
except Exception:
|
|
93
|
+
logger.exception("Error acquiring or using a pooled database connection")
|
|
94
|
+
raise
|
|
95
|
+
finally:
|
|
96
|
+
if conn is not None and pool is not None:
|
|
97
|
+
# Reset autocommit to the pool default so a later write checkout stays
|
|
98
|
+
# transactional. A connection that died mid-statement raises here; log
|
|
99
|
+
# but fall through to putconn (which discards a broken connection) and
|
|
100
|
+
# never let this mask the original read error.
|
|
101
|
+
try:
|
|
102
|
+
await conn.set_autocommit(False)
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.exception("Failed to reset autocommit before returning connection to the pool")
|
|
105
|
+
await pool.putconn(conn)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@asynccontextmanager
|
|
109
|
+
async def cursor(
|
|
110
|
+
name: str = "",
|
|
111
|
+
*,
|
|
112
|
+
binary: bool = False,
|
|
113
|
+
row_factory: Optional[AsyncRowFactory[Any]] = None,
|
|
114
|
+
scrollable: Optional[bool] = None,
|
|
115
|
+
withhold: bool = False,
|
|
116
|
+
) -> AsyncGenerator[AsyncCursor[Any], None]:
|
|
117
|
+
# Forward row_factory only when supplied so the connection's default
|
|
118
|
+
# row factory is used otherwise.
|
|
119
|
+
extra: dict[str, Any] = {} if row_factory is None else {"row_factory": row_factory}
|
|
120
|
+
async with (
|
|
121
|
+
get_async_connection() as conn,
|
|
122
|
+
conn.cursor(
|
|
123
|
+
name=name,
|
|
124
|
+
binary=binary,
|
|
125
|
+
scrollable=scrollable,
|
|
126
|
+
withhold=withhold,
|
|
127
|
+
**extra,
|
|
128
|
+
) as cur,
|
|
129
|
+
):
|
|
130
|
+
yield cur
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from typing import Any, List
|
|
3
|
+
|
|
4
|
+
from psycopg import AsyncConnection
|
|
5
|
+
from psycopg.abc import Buffer
|
|
6
|
+
from psycopg.adapt import Loader
|
|
7
|
+
from psycopg.types import TypeInfo
|
|
8
|
+
from psycopg.types.json import JsonDumper
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VectorLoader(Loader):
|
|
12
|
+
def load(self, data: Buffer) -> List[float]:
|
|
13
|
+
s = bytes(data).decode("utf-8")
|
|
14
|
+
if s.startswith("[") and s.endswith("]"):
|
|
15
|
+
values = s[1:-1].split(",")
|
|
16
|
+
return [float(x.strip()) for x in values if x.strip()]
|
|
17
|
+
else:
|
|
18
|
+
raise ValueError(f"Invalid vector format: {s}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def register_vector_as_list(conn: AsyncConnection[Any]) -> None:
|
|
22
|
+
tinfo = await TypeInfo.fetch(conn, "vector")
|
|
23
|
+
if not tinfo:
|
|
24
|
+
warnings.warn(
|
|
25
|
+
"Postgres type 'vector' not found. Did you enable the pgvector extension?",
|
|
26
|
+
RuntimeWarning,
|
|
27
|
+
stacklevel=2,
|
|
28
|
+
)
|
|
29
|
+
else:
|
|
30
|
+
conn.adapters.register_loader(tinfo.oid, VectorLoader)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register_json_dumpers(conn: AsyncConnection[Any]) -> None:
|
|
34
|
+
# Safety net so a bare ``dict`` binds as JSON. Generated insert/update tools
|
|
35
|
+
# already wrap json/jsonb values in ``Json(...)`` by column type, so array
|
|
36
|
+
# columns keep their native list adapter.
|
|
37
|
+
conn.adapters.register_dumper(dict, JsonDumper)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def register_types_loaders(conn: AsyncConnection[Any]) -> None:
|
|
41
|
+
# Temporal and uuid types keep psycopg's native loaders, matching the native
|
|
42
|
+
# annotations the code generator emits for those columns.
|
|
43
|
+
await register_vector_as_list(conn)
|
|
44
|
+
register_json_dumpers(conn)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Dynamic Postgres MCP server
|
|
3
|
+
description: Configure and run the PostgreSQL MCP server — the PG_ and TOOLS_DIR settings, the CLI scoping flags, transport selection, and the generated CRUD tools.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Point the server at a PostgreSQL database and it introspects the schema and
|
|
7
|
+
generates one typed MCP tool per DML operation per table. What the agent can reach
|
|
8
|
+
is set two ways that must agree: the CLI flags below decide which tools exist, and
|
|
9
|
+
the PostgreSQL role you connect as decides what those tools can physically do.
|
|
10
|
+
Connect as a dedicated least-privilege role, never a superuser.
|
|
11
|
+
|
|
12
|
+
## Connection settings
|
|
13
|
+
|
|
14
|
+
Connection and pooling come from environment variables. `PG_DB`, `PG_USER`, and
|
|
15
|
+
`PG_PASSWORD` have no default — a missing one fails at startup rather than
|
|
16
|
+
connecting with a phantom value.
|
|
17
|
+
|
|
18
|
+
| Variable | Default | Effect |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `PG_HOST` | `localhost` | PostgreSQL host. |
|
|
21
|
+
| `PG_PORT` | `5432` | PostgreSQL port. |
|
|
22
|
+
| `PG_DB` | required | Database name. |
|
|
23
|
+
| `PG_USER` | required | Database user; use a least-privilege role. |
|
|
24
|
+
| `PG_PASSWORD` | required | Database password. Held as a secret. |
|
|
25
|
+
| `PG_STATEMENT_TIMEOUT` | `30000` | Per-connection `statement_timeout` in ms (`0` disables), capping a runaway query. |
|
|
26
|
+
| `PG_POOL_MIN_SIZE` | `1` | Minimum pooled connections. |
|
|
27
|
+
| `PG_POOL_MAX_SIZE` | `10` | Maximum pooled connections. |
|
|
28
|
+
| `PG_POOL_TIMEOUT` | `10` | Pool acquire timeout (seconds). |
|
|
29
|
+
| `PG_POOL_MAX_LIFETIME` | `300` | Maximum connection lifetime (seconds). |
|
|
30
|
+
| `TOOLS_DIR` | `~/.cache/tai42-mcp-dynamic-postgres/tools` | Where generated tool files are written. |
|
|
31
|
+
|
|
32
|
+
## Scoping flags
|
|
33
|
+
|
|
34
|
+
The flags decide which tools are generated and how permissive they are.
|
|
35
|
+
|
|
36
|
+
| Flag | Default | Effect |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `--overwrite / --no-overwrite` | on | Regenerate tool files on startup so they track the schema; `--no-overwrite` reuses existing files. |
|
|
39
|
+
| `--readonly` | off | Generate only `select`/`select_joined` tools — no insert, update, or delete. |
|
|
40
|
+
| `--allow-unfiltered` | off | Allow `update`/`delete` to run without a `WHERE` filter (affects every row). Off means unfiltered writes raise. |
|
|
41
|
+
| `--select-joined a,b,c` | — | Generate a joined select over the given tables (repeatable). |
|
|
42
|
+
| `--ignore-insert-column` | `id` | Column to exclude from insert inputs (repeatable). |
|
|
43
|
+
| `--ignore-update-column` | `id` | Column to exclude from update inputs (repeatable). |
|
|
44
|
+
| `--ignore-select-column` | — | Column to exclude from select output models (repeatable). |
|
|
45
|
+
| `--ignore-select-joined-column` | — | Column to exclude from joined select models (repeatable). |
|
|
46
|
+
| `-t, --transport` | `stdio` | `stdio`, `http`, `sse`, or `streamable-http`. |
|
|
47
|
+
| `--host` | `127.0.0.1` | Bind host (HTTP/SSE transports only). |
|
|
48
|
+
| `--port` | `8000` | Bind port (HTTP/SSE transports only). |
|
|
49
|
+
|
|
50
|
+
## Transport
|
|
51
|
+
|
|
52
|
+
The default transport is `stdio`: an MCP client launches the server as a
|
|
53
|
+
subprocess and speaks over stdin/stdout. Wire it into a client's server list,
|
|
54
|
+
passing the connection settings through `env`:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"postgres": {
|
|
60
|
+
"command": "uvx",
|
|
61
|
+
"args": ["tai42-mcp-dynamic-postgres", "--readonly"],
|
|
62
|
+
"env": { "PG_DB": "dbname", "PG_USER": "agent", "PG_PASSWORD": "secret" }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
For a network-mounted server, choose an HTTP transport and bind a host and port
|
|
69
|
+
(`--host` / `--port` apply only here):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uvx tai42-mcp-dynamic-postgres -t http --host 0.0.0.0 --port 8000
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Generated tools
|
|
76
|
+
|
|
77
|
+
For a table `public.events` you get (unless `--readonly`):
|
|
78
|
+
|
|
79
|
+
- `select_public_events(where, order_by, limit, offset)`
|
|
80
|
+
- `insert_public_events(params, raise_on_conflict)`
|
|
81
|
+
- `update_public_events(data, where)`
|
|
82
|
+
- `delete_public_events(where)`
|
|
83
|
+
|
|
84
|
+
Column types map to native Python: temporal columns to `datetime`/`date`/`time`,
|
|
85
|
+
`uuid` to `uuid.UUID`, `numeric`/`decimal` to `Decimal`, `json`/`jsonb` to `Any`,
|
|
86
|
+
and array columns to `list[...]`. `insert` returns the table's real primary key (a
|
|
87
|
+
scalar list for a single-column key, a list of lists for a composite key, or the
|
|
88
|
+
affected row count when the table has none); columns with a database default are
|
|
89
|
+
omittable. `order_by` items take an optional `nulls` (`FIRST`/`LAST`).
|
|
90
|
+
|
|
91
|
+
### Filtering — `WhereFilter`
|
|
92
|
+
|
|
93
|
+
`select`, `update`, and `delete` accept a `where` argument. Field names must be
|
|
94
|
+
real columns of the table; unknown fields are rejected, and values are always
|
|
95
|
+
bound parameters, so there is no path for SQL injection through field names.
|
|
96
|
+
|
|
97
|
+
```jsonc
|
|
98
|
+
// Simple field filters (implicitly ANDed)
|
|
99
|
+
{ "status": { "eq": "open" }, "total": { "gte": 100 } }
|
|
100
|
+
|
|
101
|
+
// Logical composition
|
|
102
|
+
{ "AND": [ { "status": { "eq": "open" } },
|
|
103
|
+
{ "OR": [ { "total": { "gt": 1000 } }, { "starred": { "eq": true } } ] } ] }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `like`, `not_like`, `ilike`,
|
|
107
|
+
`not_ilike`, `in`, `not_in`, `between`, `is_null`, and `knn` (pgvector). Logical
|
|
108
|
+
keys: `AND`, `OR`, `NOT`.
|
|
109
|
+
|
|
110
|
+
### Vector search (pgvector)
|
|
111
|
+
|
|
112
|
+
When a column is a `vector`, filter or order by similarity. Requires the
|
|
113
|
+
`pgvector` extension enabled in the database.
|
|
114
|
+
|
|
115
|
+
```jsonc
|
|
116
|
+
{ "embedding": { "knn": { "query": [0.1, 0.2, 0.3],
|
|
117
|
+
"distance": "cosine", // l2 | inner_product | cosine
|
|
118
|
+
"threshold": 0.5 } } }
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Docker
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
docker build -t tai42-mcp-dynamic-postgres .
|
|
125
|
+
docker run --rm -e PG_HOST=... -e PG_DB=... -e PG_USER=... -e PG_PASSWORD=... \
|
|
126
|
+
tai42-mcp-dynamic-postgres tai42-mcp-dynamic-postgres --readonly
|
|
127
|
+
```
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from tai42_mcp_dynamic_postgres.config.settings import pg_settings
|
|
6
|
+
from tai42_mcp_dynamic_postgres.gen.schema.introspect import ColumnInfo, ForeignKey, TableInfo
|
|
7
|
+
|
|
8
|
+
OUTPUT_DIR = Path(pg_settings.tools_dir)
|
|
9
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
|
|
11
|
+
TOOLS_SUFFIX = "tools"
|
|
12
|
+
|
|
13
|
+
# (model_code, tool_code) for one generated tool; model_code may be empty.
|
|
14
|
+
Chunk = Tuple[str, str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseGen(ABC):
|
|
18
|
+
def __init__(self, prefix: str, imports: str, template: str, ignore_columns: Optional[List[str]] = None) -> None:
|
|
19
|
+
self.prefix = prefix
|
|
20
|
+
self.imports = imports
|
|
21
|
+
self.template = template
|
|
22
|
+
self.ignore_columns = ignore_columns or []
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def tool_chunks(self, tables: Dict[str, TableInfo], fks: List[ForeignKey]) -> Iterable[Chunk]:
|
|
26
|
+
"""Yield a ``(model_code, tool_code)`` pair per generated tool."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def output_path(self) -> Path:
|
|
30
|
+
return OUTPUT_DIR / f"{self.prefix}_{TOOLS_SUFFIX}.py"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def module_name(self) -> str:
|
|
34
|
+
return f"{self.prefix}_{TOOLS_SUFFIX}"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_exists(self) -> bool:
|
|
38
|
+
return self.output_path.exists()
|
|
39
|
+
|
|
40
|
+
def func_name(self, table: str) -> str:
|
|
41
|
+
return f"{self.prefix}_{table.replace('.', '_')}"
|
|
42
|
+
|
|
43
|
+
def generate_tools(self, tables: Dict[str, TableInfo], fks: List[ForeignKey]) -> List[str]:
|
|
44
|
+
chunks = [self.imports]
|
|
45
|
+
for model_code, tool_code in self.tool_chunks(tables, fks):
|
|
46
|
+
chunks.append(model_code)
|
|
47
|
+
chunks.append(tool_code)
|
|
48
|
+
return chunks
|
|
49
|
+
|
|
50
|
+
def generate_file(self, tables: Dict[str, TableInfo], fks: List[ForeignKey]) -> None:
|
|
51
|
+
# Generate fully before truncating the target, so a generation failure
|
|
52
|
+
# leaves any existing file intact rather than half-written.
|
|
53
|
+
code = "".join(chunk for chunk in self.generate_tools(tables, fks) if chunk)
|
|
54
|
+
self.output_path.write_text(code)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TableGen(BaseGen):
|
|
58
|
+
"""Base for generators that emit one tool per table."""
|
|
59
|
+
|
|
60
|
+
# Write generators (insert/update/delete) override this: views and
|
|
61
|
+
# materialized views are read-only and get no write tools.
|
|
62
|
+
writable_only: bool = False
|
|
63
|
+
|
|
64
|
+
def tool_chunks(self, tables: Dict[str, TableInfo], fks: List[ForeignKey]) -> Iterable[Chunk]:
|
|
65
|
+
for table_info in tables.values():
|
|
66
|
+
if self.writable_only and not table_info.writable:
|
|
67
|
+
continue
|
|
68
|
+
chunk = self.generate_tool(table_info)
|
|
69
|
+
if chunk is not None:
|
|
70
|
+
yield chunk
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def generate_tool(self, table_info: TableInfo) -> Optional[Chunk]:
|
|
74
|
+
"""Build the (model_code, tool_code) for a single table, or None to skip it."""
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def col_names(table_info: TableInfo) -> List[str]:
|
|
78
|
+
return [col.name for col in table_info.columns]
|
|
79
|
+
|
|
80
|
+
def included(self, table_info: TableInfo) -> List[ColumnInfo]:
|
|
81
|
+
"""Columns kept after applying ``ignore_columns``."""
|
|
82
|
+
return [col for col in table_info.columns if col.name not in self.ignore_columns]
|