Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ Postgres MCP Pro Tools:
| `analyze_workload_indexes` | Analyzes the database workload to identify resource-intensive queries, then recommends optimal indexes for them. |
| `analyze_query_indexes` | Analyzes a list of specific SQL queries (up to 10) and recommends optimal indexes for them. |
| `analyze_db_health` | Performs comprehensive health checks including: buffer cache hit rates, connection health, constraint validation, index health (duplicate/unused/invalid), sequence limits, and vacuum health. |
| `execute_sql_xlsx` | Executes a SQL query and exports the results to an Excel (.xlsx) file. Supports a configurable row limit to prevent excessive output. |


## Related Projects
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"attrs>=25.4.0",
"psycopg-pool>=3.3.0",
"instructor>=1.14.4",
"openpyxl>=3.1.2",
]
license = "mit"
license-files = ["LICENSE"]
Expand Down
67 changes: 67 additions & 0 deletions src/postgres_mcp/formatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Excel formatter for query results."""

import json
import os
import tempfile
from datetime import datetime
from typing import Any


def _serialize_cell(value: Any) -> Any:
"""Serialize non-scalar PostgreSQL types (json/jsonb/array) to JSON strings."""
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, default=str)
return value


def format_to_excel(rows: list[dict], columns: list[str], output_dir: str | None = None) -> str:
"""Format query result rows to an Excel file.

Args:
rows: List of row dictionaries from query results.
columns: List of column names.
output_dir: Output directory (default: system temp / postgres-mcp-results).

Returns:
Path to the created Excel file.
"""
import uuid

from openpyxl import Workbook

if output_dir is None:
output_dir = os.path.join(tempfile.gettempdir(), "postgres-mcp-results")

os.makedirs(output_dir, exist_ok=True)

timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_suffix = uuid.uuid4().hex[:8]
filename = f"query_{timestamp}_{unique_suffix}.xlsx"
filepath = os.path.join(output_dir, filename)
Comment on lines +37 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Generate unique export filenames

The Excel path is derived from a timestamp with second-level precision, so concurrent/rapid exports in the same second will target the same filename and overwrite each other. This can return a stale/incorrect file to one caller and lose prior output; use a uniqueness source (UUID, monotonic suffix, or tempfile APIs) when constructing the filename.

Useful? React with 👍 / 👎.


wb = Workbook()
ws = wb.active
ws.title = "Query Results"

# Write header row
ws.append(columns)

# Write data rows, serializing complex types before appending
for row in rows:
ws.append([_serialize_cell(row.get(col)) for col in columns])

# Auto-adjust column widths
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value is not None:
max_length = max(max_length, len(str(cell.value)))
except Exception:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width

wb.save(filepath)
return filepath
66 changes: 66 additions & 0 deletions src/postgres_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from .database_health import DatabaseHealthTool
from .database_health import HealthType
from .explain import ExplainPlanTool
from .formatter import format_to_excel
from .index.index_opt_base import MAX_NUM_INDEX_TUNING_QUERIES
from .index.llm_opt import LLMOptimizerTool
from .index.presentation import TextPresentation
Expand Down Expand Up @@ -554,6 +555,49 @@ async def get_top_queries(
return format_error_response(str(e))


# Tool function declaration without decorator - registered dynamically based on access mode (like execute_sql)
@validate_call
async def execute_sql_xlsx(
sql: str = Field(description="SQL query to execute and export to Excel"),
max_rows: int = Field(
description="Maximum number of rows to export. Rows beyond this limit are truncated.",
default=10000,
ge=1,
),
) -> ResponseType:
"""Executes a SQL query and exports results to an Excel file."""
try:
sql_driver = await get_sql_driver()

# Inject LIMIT to protect server from large result sets.
# Skip if user already provided a LIMIT clause.
import re

sql_stripped = sql.strip().rstrip(";")
if not re.search(r"\bLIMIT\b", sql_stripped, re.IGNORECASE):
capped_sql = f"{sql_stripped} LIMIT {max_rows}"
else:
capped_sql = sql

rows = await sql_driver.execute_query(capped_sql) # type: ignore
if rows is None or len(rows) == 0:
return format_text_response("Query returned no results. No Excel file was created.")

row_dicts = [r.cells for r in rows]
columns = list(row_dicts[0].keys())
file_path = format_to_excel(rows=row_dicts, columns=columns)

result_parts = [
f"Excel file created: {file_path}",
f"Rows exported: {len(row_dicts)}",
f"Columns: {', '.join(columns)}",
]
return format_text_response("\n".join(result_parts))
except Exception as e:
logger.error(f"Error executing query for Excel export: {e}")
return format_error_response(str(e))


async def main():
# Parse command line arguments
parser = argparse.ArgumentParser(description="PostgreSQL MCP Server")
Expand Down Expand Up @@ -623,6 +667,28 @@ async def main():
),
)

# Add the xlsx export tool with a description and annotations appropriate to the access mode
if current_access_mode == AccessMode.UNRESTRICTED:
mcp.add_tool(
execute_sql_xlsx,
description="Executes a SQL query and exports results to an Excel (.xlsx) file. "
"Use this when the user wants to save query results as a spreadsheet.",
annotations=ToolAnnotations(
title="Execute SQL to Excel",
destructiveHint=True,
),
)
else:
mcp.add_tool(
execute_sql_xlsx,
description="Executes a read-only SQL query and exports results to an Excel (.xlsx) file. "
"Use this when the user wants to save query results as a spreadsheet.",
annotations=ToolAnnotations(
title="Execute SQL to Excel (Read-Only)",
readOnlyHint=True,
),
)

logger.info(f"Starting PostgreSQL MCP Server in {current_access_mode.upper()} mode")

# Get database URL from environment variable or command line
Expand Down
Loading