Skip to content

Commit 5f2ccad

Browse files
committed
Fix multiple GitHub issues
This commit addresses the following issues: - #118: Add percentile_cont, percentile_disc, mode to ALLOWED_FUNCTIONS - #111: Fix PostgreSQL ≤12 compatibility for stddev_exec_time column - #110: Fix Docker entrypoint (add exec) and create app user in Dockerfile - #109 & #94: Fix double quote parsing and sequence name escaping - #99: Add configurable query timeout via QUERY_TIMEOUT env var or --query-timeout - #95: Redirect logging to stderr to avoid interfering with stdio MCP transport - #93: Update broken README links to archived MCP servers repo - #100 & #74: Add uvx and WSL instructions to README - #71: Add table/column comments to metadata tools (list_objects, get_object_details) Changes: - safe_sql.py: Add ordered-set aggregate functions - top_queries_calc.py: Use version-appropriate column name for stddev - docker-entrypoint.sh: Use exec for proper signal handling - Dockerfile: Create app user before COPY with --chown - sequence_health_calc.py: Fix parsing of quoted identifiers - server.py: Add configurable timeout, table/column comments in metadata - __init__.py: Configure logging to stderr - README.md: Add uvx/WSL instructions, fix broken links
1 parent 18edf62 commit 5f2ccad

File tree

8 files changed

+195
-42
lines changed

8 files changed

+195
-42
lines changed

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ FROM python:3.12-slim-bookworm
2727
# Python executable must be the same, e.g., using `python:3.11-slim-bookworm`
2828
# will fail.
2929

30+
# Create a non-root user for security
31+
RUN groupadd --gid 1000 app && \
32+
useradd --uid 1000 --gid app --shell /bin/bash --create-home app
33+
3034
COPY --from=builder --chown=app:app /app /app
3135

3236
ENV PATH="/app/.venv/bin:$PATH"

README.md

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,53 @@ The Postgres MCP Pro Docker image will automatically remap the hostname `localho
194194
```
195195

196196

197+
##### If you are using `uvx`
198+
199+
`uvx` is a convenient way to run Python packages directly without explicit installation:
200+
201+
```json
202+
{
203+
"mcpServers": {
204+
"postgres": {
205+
"command": "uvx",
206+
"args": [
207+
"postgres-mcp",
208+
"--access-mode=unrestricted"
209+
],
210+
"env": {
211+
"DATABASE_URI": "postgresql://username:password@localhost:5432/dbname"
212+
}
213+
}
214+
}
215+
}
216+
```
217+
218+
219+
##### If you are using WSL (Windows Subsystem for Linux)
220+
221+
When running Claude Desktop on Windows with the MCP server in WSL, use `wsl` as the command:
222+
223+
```json
224+
{
225+
"mcpServers": {
226+
"postgres": {
227+
"command": "wsl",
228+
"args": [
229+
"uvx",
230+
"postgres-mcp",
231+
"--access-mode=unrestricted"
232+
],
233+
"env": {
234+
"DATABASE_URI": "postgresql://username:password@localhost:5432/dbname"
235+
}
236+
}
237+
}
238+
}
239+
```
240+
241+
Note: Ensure `uvx` is installed and available in your WSL environment's PATH.
242+
243+
197244
##### Connection URI
198245

199246
Replace `postgresql://...` with your [Postgres database connection URI](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS).
@@ -313,7 +360,7 @@ The [MCP standard](https://modelcontextprotocol.io/) defines various types of en
313360

314361
Postgres MCP Pro provides functionality via [MCP tools](https://modelcontextprotocol.io/docs/concepts/tools) alone.
315362
We chose this approach because the [MCP client ecosystem](https://modelcontextprotocol.io/clients) has widespread support for MCP tools.
316-
This contrasts with the approach of other Postgres MCP servers, including the [Reference Postgres MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres), which use [MCP resources](https://modelcontextprotocol.io/docs/concepts/resources) to expose schema information.
363+
This contrasts with the approach of other Postgres MCP servers, including the [Reference Postgres MCP Server](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres), which use [MCP resources](https://modelcontextprotocol.io/docs/concepts/resources) to expose schema information.
317364

318365

319366
Postgres MCP Pro Tools:
@@ -336,7 +383,7 @@ Postgres MCP Pro Tools:
336383
**Postgres MCP Servers**
337384
- [Query MCP](https://github.com/alexander-zuev/supabase-mcp-server). An MCP server for Supabase Postgres with a three-tier safety architecture and Supabase management API support.
338385
- [PG-MCP](https://github.com/stuzero/pg-mcp-server). An MCP server for PostgreSQL with flexible connection options, explain plans, extension context, and more.
339-
- [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres). A simple MCP Server implementation exposing schema information as MCP resources and executing read-only queries.
386+
- [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres). A simple MCP Server implementation exposing schema information as MCP resources and executing read-only queries.
340387
- [Supabase Postgres MCP Server](https://github.com/supabase-community/supabase-mcp). This MCP Server provides Supabase management features and is actively maintained by the Supabase community.
341388
- [Nile MCP Server](https://github.com/niledatabase/nile-mcp-server). An MCP server providing access to the management API for the Nile's multi-tenant Postgres service.
342389
- [Neon MCP Server](https://github.com/neondatabase-labs/mcp-server-neon). An MCP server providing access to the management API for Neon's serverless Postgres service.
@@ -524,7 +571,7 @@ We remain open to revising this decision in the future.
524571

525572
### Connection Configuration
526573

527-
Like the [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres), Postgres MCP Pro takes Postgres connection information at startup.
574+
Like the [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres), Postgres MCP Pro takes Postgres connection information at startup.
528575
This is convenient for users who always connect to the same database but can be cumbersome when users switch databases.
529576

530577
An alternative approach, taken by [PG-MCP](https://github.com/stuzero/pg-mcp-server), is provide connection details via MCP tool calls at the time of use.
@@ -549,7 +596,7 @@ However, we do not know whether other LLMs do so as reliably and capably.
549596

550597
*Would it be better to provide schema information using [MCP resources](https://modelcontextprotocol.io/docs/concepts/resources) rather than [MCP tools](https://modelcontextprotocol.io/docs/concepts/tools)?*
551598

552-
The [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres) uses resources to expose schema information rather than tools.
599+
The [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) uses resources to expose schema information rather than tools.
553600
Navigating resources is similar to navigating a file system, so this approach is natural in many ways.
554601
However, resource support is less widespread than tool support in the MCP client ecosystem (see [example clients](https://modelcontextprotocol.io/clients)).
555602
In addition, while the MCP standard says that resources can be accessed by either AI agents or end-user humans, some clients only support human navigation of the resource tree.
@@ -570,7 +617,7 @@ While this is a good approach, many find this cumbersome in practice.
570617
Postgres does not provide a way to place a connection or session into read-only mode, so Postgres MCP Pro uses a more complex approach to ensure read-only SQL execution on top of a read-write connection.
571618

572619
Postgres MCP Provides a read-only transaction mode that prevents data and schema modifications.
573-
Like the [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres), we use read-only transactions to provide protected SQL execution.
620+
Like the [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres), we use read-only transactions to provide protected SQL execution.
574621

575622
To make this mechanism robust, we need to ensure that the SQL does not somehow circumvent the read-only transaction mode, say by issuing a `COMMIT` or `ROLLBACK` statement and then beginning a new transaction.
576623

docker-entrypoint.sh

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,6 @@ echo "Executing command:" >&2
9393
echo "${processed_args[@]}" >&2
9494
echo "----------------" >&2
9595

96-
# Execute the command with the processed arguments
97-
"${processed_args[@]}"
98-
99-
# Capture exit code from the Python process
100-
exit_code=$?
101-
102-
# If the Python process failed, print additional debug info
103-
if [ $exit_code -ne 0 ]; then
104-
echo "ERROR: Command failed with exit code $exit_code" >&2
105-
echo "Command was: ${processed_args[@]}" >&2
106-
fi
107-
108-
# Return the exit code from the Python process
109-
exit $exit_code
96+
# Execute the command with the processed arguments using exec to replace the shell
97+
# This ensures proper signal handling (SIGTERM, SIGINT) and makes Python PID 1
98+
exec "${processed_args[@]}"

src/postgres_mcp/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import logging
23
import sys
34

45
from . import server
@@ -7,6 +8,14 @@
78

89
def main():
910
"""Main entry point for the package."""
11+
# Configure logging to use stderr to avoid interfering with stdio MCP transport
12+
# The MCP protocol uses stdout for communication, so all logs must go to stderr
13+
logging.basicConfig(
14+
level=logging.INFO,
15+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
16+
stream=sys.stderr,
17+
)
18+
1019
# As of version 3.3.0 Psycopg on Windows is not compatible with the default
1120
# ProactorEventLoop.
1221
# See: https://www.psycopg.org/psycopg3/docs/advanced/async.html#async

src/postgres_mcp/database_health/sequence_health_calc.py

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from dataclasses import dataclass
22

33
from psycopg.sql import Identifier
4+
from psycopg.sql import Literal
45

56
from ..sql import SafeSqlDriver
67
from ..sql import SqlDriver
@@ -101,15 +102,23 @@ async def _get_sequence_metrics(self) -> list[SequenceMetrics]:
101102
max_value = 2147483647 if seq["column_type"] == "integer" else 9223372036854775807
102103

103104
# Get sequence attributes
105+
# Note: has_sequence_privilege expects a text argument (sequence name as string)
106+
# while FROM clause needs a properly quoted identifier
107+
# Build the fully qualified sequence name for has_sequence_privilege
108+
if schema:
109+
seq_name_for_privilege = f'"{schema}"."{sequence}"'
110+
else:
111+
seq_name_for_privilege = f'"{sequence}"'
112+
104113
attrs = await SafeSqlDriver.execute_param_query(
105114
self.sql_driver,
106115
"""
107116
SELECT
108-
has_sequence_privilege('{}', 'SELECT') AS readable,
117+
has_sequence_privilege({}, 'SELECT') AS readable,
109118
last_value
110119
FROM {}
111120
""",
112-
[Identifier(schema, sequence), Identifier(schema, sequence)],
121+
[Literal(seq_name_for_privilege), Identifier(schema, sequence)],
113122
)
114123

115124
if not attrs:
@@ -135,17 +144,51 @@ async def _get_sequence_metrics(self) -> list[SequenceMetrics]:
135144
return sequence_metrics
136145

137146
def _parse_sequence_name(self, default_value: str) -> tuple[str, str]:
138-
"""Parse schema and sequence name from default value expression."""
139-
# Handle both formats:
140-
# nextval('id_seq'::regclass)
141-
# nextval(('id_seq'::text)::regclass)
147+
"""Parse schema and sequence name from default value expression.
142148
149+
Handles various formats including:
150+
- nextval('id_seq'::regclass)
151+
- nextval('public.id_seq'::regclass)
152+
- nextval('"Schema"."Sequence_Name"'::regclass)
153+
- nextval(('"Schema"."Sequence_Name"'::text)::regclass)
154+
"""
143155
# Remove nextval and cast parts
144156
clean_value = default_value.replace("nextval('", "").replace("'::regclass)", "")
145157
clean_value = clean_value.replace("('", "").replace("'::text)", "")
146158

147-
# Split into schema and sequence
148-
parts = clean_value.split(".")
159+
# Handle quoted identifiers (e.g., "Schema"."Table")
160+
# Split on '.' but respect quoted identifiers
161+
parts = []
162+
current_part = ""
163+
in_quotes = False
164+
165+
for char in clean_value:
166+
if char == '"':
167+
in_quotes = not in_quotes
168+
# Keep the quotes for now, we'll strip them later
169+
current_part += char
170+
elif char == '.' and not in_quotes:
171+
if current_part:
172+
parts.append(current_part)
173+
current_part = ""
174+
else:
175+
current_part += char
176+
177+
if current_part:
178+
parts.append(current_part)
179+
180+
# Strip double quotes from parts and handle empty parts
181+
def strip_quotes(s: str) -> str:
182+
s = s.strip()
183+
if s.startswith('"') and s.endswith('"'):
184+
return s[1:-1]
185+
return s
186+
187+
parts = [strip_quotes(p) for p in parts if p.strip()]
188+
149189
if len(parts) == 1:
150190
return "public", parts[0] # Default to public schema
151-
return parts[0], parts[1]
191+
elif len(parts) >= 2:
192+
return parts[0], parts[1]
193+
else:
194+
return "public", ""

src/postgres_mcp/server.py

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
# Constants
4040
PG_STAT_STATEMENTS = "pg_stat_statements"
4141
HYPOPG_EXTENSION = "hypopg"
42+
DEFAULT_QUERY_TIMEOUT = 30 # Default timeout in seconds for restricted mode
4243

4344
ResponseType = List[types.TextContent | types.ImageContent | types.EmbeddedResource]
4445

@@ -55,6 +56,7 @@ class AccessMode(str, Enum):
5556
# Global variables
5657
db_connection = DbConnPool()
5758
current_access_mode = AccessMode.UNRESTRICTED
59+
current_query_timeout = DEFAULT_QUERY_TIMEOUT
5860
shutdown_in_progress = False
5961

6062

@@ -63,8 +65,8 @@ async def get_sql_driver() -> Union[SqlDriver, SafeSqlDriver]:
6365
base_driver = SqlDriver(conn=db_connection)
6466

6567
if current_access_mode == AccessMode.RESTRICTED:
66-
logger.debug("Using SafeSqlDriver with restrictions (RESTRICTED mode)")
67-
return SafeSqlDriver(sql_driver=base_driver, timeout=30) # 30 second timeout
68+
logger.debug(f"Using SafeSqlDriver with restrictions (RESTRICTED mode, timeout={current_query_timeout}s)")
69+
return SafeSqlDriver(sql_driver=base_driver, timeout=current_query_timeout)
6870
else:
6971
logger.debug("Using unrestricted SqlDriver (UNRESTRICTED mode)")
7072
return base_driver
@@ -120,15 +122,27 @@ async def list_objects(
120122
rows = await SafeSqlDriver.execute_param_query(
121123
sql_driver,
122124
"""
123-
SELECT table_schema, table_name, table_type
124-
FROM information_schema.tables
125-
WHERE table_schema = {} AND table_type = {}
126-
ORDER BY table_name
125+
SELECT
126+
t.table_schema,
127+
t.table_name,
128+
t.table_type,
129+
obj_description((quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::regclass, 'pg_class') AS comment
130+
FROM information_schema.tables t
131+
WHERE t.table_schema = {} AND t.table_type = {}
132+
ORDER BY t.table_name
127133
""",
128134
[schema_name, table_type],
129135
)
130136
objects = (
131-
[{"schema": row.cells["table_schema"], "name": row.cells["table_name"], "type": row.cells["table_type"]} for row in rows]
137+
[
138+
{
139+
"schema": row.cells["table_schema"],
140+
"name": row.cells["table_name"],
141+
"type": row.cells["table_type"],
142+
"comment": row.cells["comment"],
143+
}
144+
for row in rows
145+
]
132146
if rows
133147
else []
134148
)
@@ -185,14 +199,22 @@ async def get_object_details(
185199
sql_driver = await get_sql_driver()
186200

187201
if object_type in ("table", "view"):
188-
# Get columns
202+
# Get columns with comments
189203
col_rows = await SafeSqlDriver.execute_param_query(
190204
sql_driver,
191205
"""
192-
SELECT column_name, data_type, is_nullable, column_default
193-
FROM information_schema.columns
194-
WHERE table_schema = {} AND table_name = {}
195-
ORDER BY ordinal_position
206+
SELECT
207+
c.column_name,
208+
c.data_type,
209+
c.is_nullable,
210+
c.column_default,
211+
col_description(
212+
(quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass,
213+
c.ordinal_position
214+
) AS comment
215+
FROM information_schema.columns c
216+
WHERE c.table_schema = {} AND c.table_name = {}
217+
ORDER BY c.ordinal_position
196218
""",
197219
[schema_name, object_name],
198220
)
@@ -203,6 +225,7 @@ async def get_object_details(
203225
"data_type": r.cells["data_type"],
204226
"is_nullable": r.cells["is_nullable"],
205227
"default": r.cells["column_default"],
228+
"comment": r.cells["comment"],
206229
}
207230
for r in col_rows
208231
]
@@ -251,8 +274,21 @@ async def get_object_details(
251274

252275
indexes = [{"name": r.cells["indexname"], "definition": r.cells["indexdef"]} for r in idx_rows] if idx_rows else []
253276

277+
# Get table/view comment
278+
comment_rows = await SafeSqlDriver.execute_param_query(
279+
sql_driver,
280+
"""
281+
SELECT obj_description(
282+
(quote_ident({}) || '.' || quote_ident({}))::regclass,
283+
'pg_class'
284+
) AS comment
285+
""",
286+
[schema_name, object_name],
287+
)
288+
table_comment = comment_rows[0].cells["comment"] if comment_rows else None
289+
254290
result = {
255-
"basic": {"schema": schema_name, "name": object_name, "type": object_type},
291+
"basic": {"schema": schema_name, "name": object_name, "type": object_type, "comment": table_comment},
256292
"columns": columns,
257293
"constraints": constraints_list,
258294
"indexes": indexes,
@@ -539,13 +575,32 @@ async def main():
539575
default=8000,
540576
help="Port for SSE server (default: 8000)",
541577
)
578+
parser.add_argument(
579+
"--query-timeout",
580+
type=int,
581+
default=None,
582+
help=f"Query timeout in seconds for restricted mode (default: {DEFAULT_QUERY_TIMEOUT}). Can also be set via QUERY_TIMEOUT env var.",
583+
)
542584

543585
args = parser.parse_args()
544586

545587
# Store the access mode in the global variable
546588
global current_access_mode
547589
current_access_mode = AccessMode(args.access_mode)
548590

591+
# Set query timeout from CLI argument or environment variable
592+
global current_query_timeout
593+
if args.query_timeout is not None:
594+
current_query_timeout = args.query_timeout
595+
else:
596+
env_timeout = os.environ.get("QUERY_TIMEOUT")
597+
if env_timeout is not None:
598+
try:
599+
current_query_timeout = int(env_timeout)
600+
except ValueError:
601+
logger.warning(f"Invalid QUERY_TIMEOUT value '{env_timeout}', using default {DEFAULT_QUERY_TIMEOUT}")
602+
current_query_timeout = DEFAULT_QUERY_TIMEOUT
603+
549604
# Add the query tool with a description appropriate to the access mode
550605
if current_access_mode == AccessMode.UNRESTRICTED:
551606
mcp.add_tool(execute_sql, description="Execute any SQL query")

0 commit comments

Comments
 (0)