diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..834eccb
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,74 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run linter
+ run: npm run lint
+
+ - name: Run prepublish checks
+ run: npm run prepublishOnly
+
+ - name: Test built CLI help
+ run: node build/index.js --help
+
+ - name: Verify dry-run package
+ run: npm pack --dry-run --cache .npm-cache
+
+ postgres-integration:
+ runs-on: ubuntu-latest
+
+ services:
+ postgres:
+ image: postgres:17-alpine
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d postgres"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 12
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run PostgreSQL integration tests
+ run: npm run test:integration
+ env:
+ POSTGRES_MCP_INTEGRATION_CONNECTION_STRING: postgresql://postgres:postgres@localhost:5432/postgres
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index a751291..649e0d1 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -5,35 +5,56 @@ on:
types: [published]
jobs:
- publish:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- id-token: write # For npm provenance
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '18'
- registry-url: 'https://registry.npmjs.org'
-
- - name: Install dependencies
- run: npm ci
-
- - name: Run linter
- run: npm run lint
-
- - name: Build project
- run: npm run build
-
- - name: Test CLI
- run: node build/index.js --help
-
- - name: Publish to npm
- run: npm publish --access public --provenance
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
\ No newline at end of file
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write # For npm provenance
+ services:
+ postgres:
+ image: postgres:17-alpine
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d postgres"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 12
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run linter
+ run: npm run lint
+
+ - name: Run prepublish checks
+ run: npm run prepublishOnly
+
+ - name: Run PostgreSQL integration tests
+ run: npm run test:integration
+ env:
+ POSTGRES_MCP_INTEGRATION_CONNECTION_STRING: postgresql://postgres:postgres@localhost:5432/postgres
+
+ - name: Test CLI
+ run: node build/index.js --help
+
+ - name: Verify package contents
+ run: npm pack --dry-run
+
+ - name: Publish to npm
+ run: npm publish --access public --provenance
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 169e980..93fe62f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
node_modules/
build/
+.npm-cache/
*.log
.env*
.cursor/mcp.json
diff --git a/.npmignore b/.npmignore
index 5b299bf..91409b4 100644
--- a/.npmignore
+++ b/.npmignore
@@ -10,12 +10,10 @@ tsconfig.json
.env*
.editorconfig
-# Documentation (development only)
-docs/
-examples/
-REFACTOR_PROGRESS.md
-TOOLS.md
-CONSOLIDATION_PROGRESS.md
+examples/
+REFACTOR_PROGRESS.md
+TOOLS.md
+CONSOLIDATION_PROGRESS.md
# Build tools
Dockerfile
@@ -36,4 +34,4 @@ yarn.lock
# Misc
*.tsbuildinfo
.DS_Store
-Thumbs.db
\ No newline at end of file
+Thumbs.db
diff --git a/Dockerfile b/Dockerfile
index 763edd2..8b1a244 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,35 +1,26 @@
-# Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
-FROM node:lts-alpine
+FROM node:20-alpine AS build
WORKDIR /app
-# Copy package files and install dependencies
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
-# Copy TypeScript configuration
COPY tsconfig.json ./
+COPY src ./src
-# Copy all source files
-COPY . .
-
-# Build the TypeScript source
RUN npm run build
+RUN npm prune --omit=dev --ignore-scripts
-# Copy and set up entrypoint script
-COPY docker-entrypoint.sh /usr/local/bin/
-RUN chmod +x /usr/local/bin/docker-entrypoint.sh
-
-# Create non-root user for security
-RUN addgroup -g 1001 -S nodejs
-RUN adduser -S postgres-mcp -u 1001
+FROM node:20-alpine AS runtime
+WORKDIR /app
-# Change ownership of the app directory
-RUN chown -R postgres-mcp:nodejs /app
-USER postgres-mcp
+ENV NODE_ENV=production
-# Expose any necessary ports if needed (optional)
-# EXPOSE 3000
+COPY --from=build --chown=node:node /app/package.json ./package.json
+COPY --from=build --chown=node:node /app/node_modules ./node_modules
+COPY --from=build --chown=node:node /app/build ./build
+COPY --chown=node:node docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+RUN chmod 755 /usr/local/bin/docker-entrypoint.sh
-# Use entrypoint script for flexible configuration
+USER node
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD []
diff --git a/README.md b/README.md
index 677b1c4..6d1e877 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,27 @@
# PostgreSQL MCP Server
[](https://smithery.ai/server/@HenkDz/postgresql-mcp-server)
-A Model Context Protocol (MCP) server that provides comprehensive PostgreSQL database management capabilities for AI assistants.
-
-## Features
-**🚀 What's New**: This server has been completely redesigned from 46 individual tools to 17 intelligent tools through consolidation (34→8 meta-tools) and enhancement (+4 new tools), providing better AI discovery while adding powerful data manipulation and comment management capabilities.
+A Model Context Protocol (MCP) server that provides comprehensive PostgreSQL database management capabilities for AI assistants.
+
+**🚀 What's New**: This server has been completely redesigned from 46 individual tools to 18 intelligent tools through consolidation (34→8 meta-tools) and enhancement (+4 new tools), providing better AI discovery while adding powerful data manipulation and comment management capabilities.
+
+## Breaking Changes in 2.0.0
+
+Version 2.0.0 introduces security boundaries that intentionally change default behavior from the 1.x line:
+
+- The server starts in `readonly` mode. Mutations, DDL, role administration, filesystem import/export, and arbitrary SQL require `--security-mode write`, `--security-mode admin`, or `--security-mode unsafe` as appropriate.
+- Destructive operations such as drops, resets, broad role grants, and arbitrary SQL require `--allow-destructive`.
+- Per-tool `connectionString`, `sourceConnectionString`, and `targetConnectionString` arguments are disabled by default. Use server-level `--connection-string` or `POSTGRES_CONNECTION_STRING`, or explicitly opt in with `--allow-tool-connection-string`.
+- Legacy string `where` clauses are rejected for mutation, index, export, and copy filters. Use structured `where` predicates, or `rawWhere` only with `--security-mode unsafe --allow-destructive`.
+- Multi-statement `pg_execute_sql` calls must use `transactional: true`, `expectRows: false`, and no bind `parameters`.
+- Tool schemas reject unknown fields, so misspelled or unintended inputs fail before connection resolution.
+- User and target identifiers are restricted to safe simple PostgreSQL identifiers.
+
+For the non-breaking security patch line, use `@henkey/postgres-mcp-server@1.0.7`.
## Quick Start
@@ -111,11 +124,142 @@ Add to your MCP client configuration:
}
```
+## Security Modes
+
+The server now starts in `readonly` mode by default. Tools may still be listed for MCP discovery, but every call is classified and checked before it reaches the database.
+
+| Mode | Allows | Blocks by default |
+| ---- | ------ | ----------------- |
+| `readonly` | schema inspection, analysis, monitoring, SELECT-style query tools | mutations, DDL, role changes, filesystem import/export, arbitrary SQL |
+| `write` | readonly operations plus data mutations | DDL, role changes, filesystem import/export, arbitrary SQL |
+| `admin` | write operations plus schema, index, function, trigger, RLS, role, and filesystem tools | arbitrary SQL |
+| `unsafe` | all tool categories, including arbitrary SQL | destructive operations unless explicitly allowed |
+
+Destructive operations such as drops, resets, and arbitrary SQL also require explicit opt-in:
+
+```bash
+# Default: readonly, no per-tool connection strings
+npx @henkey/postgres-mcp-server --connection-string "postgresql://readonly_user:pass@host:5432/db"
+
+# Enable DML mutations, but still block DDL/admin/arbitrary SQL
+npx @henkey/postgres-mcp-server --security-mode write --connection-string "postgresql://app_writer:pass@host:5432/db"
+
+# Enable admin tools and destructive operations
+npx @henkey/postgres-mcp-server --security-mode admin --allow-destructive --connection-string "postgresql://admin_user:pass@host:5432/db"
+
+# Enable arbitrary SQL only for trusted local/admin use
+npx @henkey/postgres-mcp-server --security-mode unsafe --allow-destructive --connection-string "postgresql://admin_user:pass@host:5432/db"
+```
+
+Per-tool `connectionString`, `sourceConnectionString`, and `targetConnectionString` arguments are disabled by default. Prefer a fixed server-level connection string with a least-privilege PostgreSQL role. For development only, enable per-tool connection strings with `--allow-tool-connection-string` or `POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING=true`.
+Explicit per-tool, CLI, and `POSTGRES_CONNECTION_STRING` values must be non-empty strings. Blank higher-priority connection strings fail validation instead of falling back to lower-priority sources.
+
+Optionally restrict all server-level and per-tool connection strings to an allowlist with `--allowed-connection-target`, `allowedConnectionTargets`, or `POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS`. Target patterns use `[user@]host[:port][/database]`; omitted fields are unconstrained and `*` is allowed only as a full-field wildcard, for example `readonly@db.internal:5432/app` or `*@localhost:*/dev`.
+
+For deployment grants, see [PostgreSQL Role Templates](./docs/POSTGRES_ROLES.md). The templates split readonly, writer, schema-admin, and role-admin credentials so the PostgreSQL role remains aligned with the selected MCP `securityMode`.
+
+Security settings can also be placed in the tools config file:
+
+```json
+{
+ "securityMode": "readonly",
+ "allowDestructive": false,
+ "allowToolConnectionString": false,
+ "workspaceDir": "/path/to/mcp-workspace",
+ "auditFile": "/path/to/postgres-mcp-audit.jsonl",
+ "maxConnections": 20,
+ "idleTimeoutMillis": 30000,
+ "connectionTimeoutMillis": 2000,
+ "maxFileBytes": 10485760,
+ "statementTimeoutMs": 30000,
+ "queryTimeoutMs": 45000,
+ "lockTimeoutMs": 10000,
+ "idleInTransactionSessionTimeoutMs": 60000,
+ "allowedConnectionTargets": [
+ "readonly@db.internal:5432/app"
+ ],
+ "enabledTools": [
+ "pg_analyze_database",
+ "pg_manage_schema",
+ "pg_execute_query"
+ ]
+}
+```
+
+Runtime configuration precedence is CLI options, then the tools config file, then environment variables. Explicit `false` values in the tools config override enabling environment variables such as `POSTGRES_MCP_ALLOW_DESTRUCTIVE=true`.
+
+If a tools config path is provided, the server treats it as required: unreadable, malformed, non-object, incorrectly typed, unknown-key, invalid `securityMode`, or unknown `enabledTools` entries stop startup instead of falling back to all available tools.
+
+CLI options:
+
+- `--version`
+- `--connection-string`
+- `--tools-config`
+- `--security-mode`
+- `--allow-destructive`
+- `--allow-tool-connection-string`
+- `--workspace-dir`
+- `--audit-file`
+- `--max-connections`
+- `--idle-timeout-ms`
+- `--connection-timeout-ms`
+- `--max-file-bytes`
+- `--statement-timeout-ms`
+- `--query-timeout-ms`
+- `--lock-timeout-ms`
+- `--idle-in-transaction-session-timeout-ms`
+- `--allowed-connection-target`
+
+Environment variables:
+
+- `POSTGRES_TOOLS_CONFIG=/path/to/tools.json`
+- `POSTGRES_MCP_SECURITY_MODE=readonly|write|admin|unsafe`
+- `POSTGRES_MCP_ALLOW_DESTRUCTIVE=true`
+- `POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING=true`
+- `POSTGRES_MCP_WORKSPACE_DIR=/path/to/mcp-workspace`
+- `POSTGRES_MCP_AUDIT_FILE=/path/to/postgres-mcp-audit.jsonl`
+- `POSTGRES_MCP_MAX_CONNECTIONS=20`
+- `POSTGRES_MCP_IDLE_TIMEOUT_MS=30000`
+- `POSTGRES_MCP_CONNECTION_TIMEOUT_MS=2000`
+- `POSTGRES_MCP_MAX_FILE_BYTES=10485760`
+- `POSTGRES_MCP_STATEMENT_TIMEOUT_MS=60000`
+- `POSTGRES_MCP_QUERY_TIMEOUT_MS=65000`
+- `POSTGRES_MCP_LOCK_TIMEOUT_MS=10000`
+- `POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS=60000`
+- `POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS=readonly@db.internal:5432/app,*@localhost:*/dev`
+- `POSTGRES_MCP_DEBUG_SQL=true` to opt into verbose `pg-monitor` SQL tracing. This may log raw SQL and bind values, so leave it disabled unless you are debugging a trusted local database.
+
+Boolean environment flags must be exactly `true` or `false` when set.
+Numeric resource settings from CLI, tools config, or environment variables must be positive integers. Runtime defaults use a 20-connection pool, a 30000 ms pool idle timeout, a 2000 ms connection timeout, a 60000 ms PostgreSQL `statement_timeout`, a 65000 ms node-postgres query timeout, a 10000 ms PostgreSQL `lock_timeout`, and a 60000 ms PostgreSQL `idle_in_transaction_session_timeout`. Pool and timeout settings can be raised or lowered with `--max-connections`, `--idle-timeout-ms`, `--connection-timeout-ms`, `--statement-timeout-ms`, `--query-timeout-ms`, `--lock-timeout-ms`, `--idle-in-transaction-session-timeout-ms`, `maxConnections`, `idleTimeoutMillis`, `connectionTimeoutMillis`, `statementTimeoutMs`, `queryTimeoutMs`, `lockTimeoutMs`, `idleInTransactionSessionTimeoutMs`, `POSTGRES_MCP_MAX_CONNECTIONS`, `POSTGRES_MCP_IDLE_TIMEOUT_MS`, `POSTGRES_MCP_CONNECTION_TIMEOUT_MS`, `POSTGRES_MCP_STATEMENT_TIMEOUT_MS`, `POSTGRES_MCP_QUERY_TIMEOUT_MS`, `POSTGRES_MCP_LOCK_TIMEOUT_MS`, or `POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS`.
+Explicit connection string, `workspaceDir`, `auditFile`, `--workspace-dir`, and `--audit-file` values must be non-empty strings.
+Connection target allowlists are enforced before tool execution for per-tool connection strings and during connection resolution for server-level sources. When an allowlist is configured, connection strings must be PostgreSQL URL or keyword-style strings with an explicit `host` or `hostaddr`.
+
+Mutation, index, export, and copy filters should use structured `where` predicates. Legacy string `where` clauses are rejected; the explicit `rawWhere` escape hatch is treated as arbitrary SQL and requires `--security-mode unsafe --allow-destructive`.
+
+EXPLAIN tools only accept one read-only statement and run inside a read-only transaction. `analyze: true` still requires unsafe mode because PostgreSQL executes the supplied query to collect runtime statistics.
+
+Multi-statement `pg_execute_sql` calls must use `transactional: true`, `expectRows: false`, and no bind `parameters`. Use a single parameterized statement or CTE when bind parameters are needed.
+
+Error messages, diagnostics, and catalog metadata are sanitized by default. SQL text from `pg_stat_statements`, function definitions, RLS predicates, check constraints, index definitions, and column defaults are redacted unless they are intentionally returned as user data.
+Data execution, query/performance, schema, index, constraint, user/permission, trigger, comment, function, RLS, migration, and diagnostic tools reject unknown input fields so misspelled or unintended parameters fail before connection resolution.
+
+Denied security-boundary requests emit one structured stderr line prefixed with `[MCP Audit]`. Audit events include sanitized fields such as `toolName`, `reason`, `securityMode`, `risk`, and whether per-tool connection strings were present; they do not log raw SQL, full request payloads, or connection-string passwords. Set `POSTGRES_MCP_AUDIT_FILE`, `--audit-file`, or `auditFile` to append the same sanitized audit events to a JSONL file.
+
+Filesystem tools such as table export/import require a workspace directory and only read or write `.json` and `.csv` files inside it:
+
+```bash
+npx @henkey/postgres-mcp-server \
+ --security-mode admin \
+ --allow-destructive \
+ --workspace-dir /path/to/mcp-workspace \
+ --connection-string "postgresql://admin_user:pass@host:5432/db"
+```
+
## What's Included
-**17 powerful tools** organized into three categories:
+**18 powerful tools** organized into three categories:
- **🔄 Consolidation**: 34 original tools consolidated into 8 intelligent meta-tools
-- **🔧 Specialized**: 5 tools kept separate for complex operations
+- **🔧 Specialized**: 6 tools kept separate for complex operations
- **🆕 Enhancement**: 4 brand new tools (not in original 46)
### 📊 **Consolidated Meta-Tools** (8 tools)
@@ -135,10 +279,11 @@ Add to your MCP client configuration:
- **Execute SQL** - Arbitrary SQL execution with transaction support
- **Comments Management** - Comprehensive comment management for all database objects
-### 🔧 **Specialized Tools** (5 tools)
+### 🔧 **Specialized Tools** (6 tools)
- **Database Analysis** - Performance and configuration analysis
- **Debug Database** - Troubleshoot connection, performance, locks
-- **Data Export/Import** - JSON/CSV data migration
+- **Data Export** - JSON/CSV data export
+- **Data Import** - JSON/CSV data import
- **Copy Between Databases** - Cross-database data transfer
- **Real-time Monitoring** - Live database metrics and alerts
@@ -146,7 +291,7 @@ Add to your MCP client configuration:
```typescript
// Analyze database performance
-{ "analysisType": "performance" }
+{ "analysisType": "performance", "schema": "public" }
// Create a table with constraints
{
@@ -165,14 +310,17 @@ Add to your MCP client configuration:
"parameters": ["2024-01-01"],
"limit": 100
}
+// Select results are always bounded: default limit 100, max 1000.
// Insert new data
{
"operation": "insert",
"table": "users",
"data": {"name": "John Doe", "email": "john@example.com"},
- "returning": "*"
+ "returning": "*",
+ "maxReturningRows": 100
}
+// Mutation RETURNING output is capped in the response: default 100, max 1000.
// Find slow queries
{
@@ -193,7 +341,17 @@ Add to your MCP client configuration:
"operation": "insert",
"table": "products",
"data": {"name": "New Product", "price": 99.99},
- "returning": "id"
+ "returning": "id",
+ "maxReturningRows": 100
+}
+
+// Perform an UPDATE mutation with a structured WHERE predicate
+{
+ "operation": "update",
+ "table": "products",
+ "data": {"price": 89.99},
+ "where": {"id": 123},
+ "returning": ["id", "price"]
}
// Manage database object comments
@@ -211,10 +369,12 @@ Add to your MCP client configuration:
For additional information, see the [`docs/`](./docs/) folder:
-- **[📖 Usage Guide](./docs/USAGE.md)** - Comprehensive tool usage and examples
-- **[🛠️ Development Guide](./docs/DEVELOPMENT.md)** - Setup and contribution guide
-- **[⚙️ Technical Details](./docs/TECHNICAL.md)** - Architecture and implementation
-- **[👨💻 Developer Reference](./docs/DEVELOPER.md)** - API reference and advanced usage
+- **[🔐 Security Posture](./SECURITY.md)** - Sandboxing, approvals, audit events, and deployment posture
+- **[PostgreSQL Role Templates](./docs/POSTGRES_ROLES.md)** - Least-privilege database roles for each deployment profile
+- **[📖 Usage Guide](./docs/USAGE.md)** - Hardened usage patterns and examples
+- **[🛠️ Development Guide](./docs/DEVELOPMENT.md)** - Setup and release checklist
+- **[⚙️ Technical Details](./docs/TECHNICAL.md)** - Security architecture and implementation constraints
+- **[👨💻 Developer Reference](./docs/DEVELOPER.md)** - Contribution rules for tool and policy changes
- **[📋 Documentation Index](./docs/INDEX.md)** - Complete documentation overview
## Features Highlights
@@ -226,17 +386,17 @@ For additional information, see the [`docs/`](./docs/) folder:
### **🆕 Enhanced Data Capabilities**
✅ **Complete CRUD operations** - INSERT/UPDATE/DELETE/UPSERT with parameterized queries
-✅ **Flexible querying** - SELECT with count/exists support and safety limits
+✅ **Flexible querying** - SELECT with count/exists support and bounded safety limits
✅ **Arbitrary SQL execution** - Transaction support for complex operations
### **🔧 Production Ready**
-✅ **Flexible connection** - CLI args, env vars, or per-tool configuration
-✅ **Security focused** - SQL injection prevention, parameterized queries
+✅ **Controlled connection** - CLI args or env vars by default; per-tool connection strings require opt-in
+✅ **Security focused** - Read-only default mode, centralized policy checks, structured mutation predicates
✅ **Robust architecture** - Connection pooling, comprehensive error handling
## Docker Usage
-The PostgreSQL MCP Server is fully Docker-compatible and can be used in production environments.
+The PostgreSQL MCP Server is fully Docker-compatible and can be used in production environments. The image uses a multi-stage build, installs only production dependencies in the runtime stage, and runs as the non-root `node` user.
### Building the Image
```bash
@@ -302,9 +462,9 @@ For use with MCP clients like Cursor or Claude Desktop:
"run",
"-i",
"--rm",
- "henkey/postgres-mcp:latest",
"-e",
- "POSTGRES_CONNECTION_STRING"
+ "POSTGRES_CONNECTION_STRING",
+ "henkey/postgres-mcp:latest"
],
"env": {
"POSTGRES_CONNECTION_STRING": "postgresql://user:password@host:port/database"
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..16d36d4
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,120 @@
+# Security Posture
+
+This server is security-first by default, but it is not a database firewall and it does not replace PostgreSQL permissions. Treat it as a controlled MCP request boundary in front of a PostgreSQL role that should already be least-privilege.
+
+## Default Runtime Boundary
+
+The default mode is `readonly`. Tools may be listed for MCP discovery, but every call is classified before it can reach PostgreSQL.
+
+| Control | Default | Purpose |
+| --- | --- | --- |
+| `securityMode` | `readonly` | Allows read-only inspection and SELECT-style query tools only. |
+| `allowDestructive` | `false` | Blocks destructive operations even when the selected mode would otherwise allow their risk category. |
+| `allowToolConnectionString` | `false` | Rejects per-tool `connectionString`, `sourceConnectionString`, and `targetConnectionString` arguments. |
+| `enabledTools` | all listed | Tool discovery remains broad, but calls are still policy-checked. Use `enabledTools` for a stricter allow-list. |
+| filesystem workspace | unset | Import/export tools cannot access files until a workspace is configured. |
+
+Mode escalation is explicit:
+
+- `readonly`: schema inspection, analysis, monitoring, and read-only query tools.
+- `write`: `readonly` plus structured data mutations.
+- `admin`: `write` plus DDL, roles, RLS, filesystem import/export, and migration-style tools.
+- `unsafe`: arbitrary SQL and raw SQL fragments.
+
+Destructive calls, including drops, resets, arbitrary SQL, trusted raw SQL fragments, elevated role attributes, broad grants, and delegable grants require `allowDestructive=true` in addition to an allowing mode.
+
+## Sandboxing
+
+The server has three main sandbox boundaries:
+
+- PostgreSQL permissions: the connection role is the strongest boundary. Use a fixed server-level connection string for a role scoped to the job.
+- MCP policy: each tool call is classified by risk and checked against the active security mode before database access.
+- Filesystem workspace: import/export paths must resolve inside `POSTGRES_MCP_WORKSPACE_DIR` or `--workspace-dir`, must use `.json` or `.csv`, and are capped by `POSTGRES_MCP_MAX_FILE_BYTES` or `--max-file-bytes`.
+
+Connection target allowlists add another boundary when per-tool connection strings are enabled. Configure repeated `--allowed-connection-target`, tools config `allowedConnectionTargets`, or `POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS`. Patterns use `[user@]host[:port][/database]`; omitted fields are unconstrained and `*` is accepted only as a full-field wildcard.
+
+The Docker image uses a multi-stage build, production dependencies only in the runtime stage, and runs as the non-root `node` user. This reduces host risk, but container isolation is still provided by the container runtime and deployment platform.
+
+For concrete PostgreSQL grants, use [PostgreSQL Role Templates](docs/POSTGRES_ROLES.md). The templates separate readonly, writer, schema-admin, and role-admin credentials so MCP policy mode and database privileges can be aligned.
+
+## Approvals
+
+The server does not prompt interactively for approvals during MCP tool execution. Approval is modeled as explicit deployment configuration:
+
+- starting in `write`, `admin`, or `unsafe` mode;
+- setting `--allow-destructive` or `POSTGRES_MCP_ALLOW_DESTRUCTIVE=true`;
+- setting `--allow-tool-connection-string` or `POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING=true`;
+- configuring an `enabledTools` allow-list;
+- configuring connection target allowlists.
+
+MCP clients may add their own human approval prompts before calling tools. The server-side controls still apply if a client prompt is bypassed, absent, or misconfigured.
+
+## Notable Non-Goals
+
+- This server does not parse and prove arbitrary SQL safety. `pg_execute_sql` is deliberately classified as `arbitrary_sql` and requires `unsafe` plus destructive opt-in.
+- This server does not redact normal query rows, mutation `RETURNING` data, comments, or enum labels. Those are treated as user data. Scope the database role accordingly.
+- This server does not grant network egress restrictions by itself. Use container, host, firewall, or orchestration policy for network isolation.
+- This server does not store credentials. Connection strings are accepted at runtime from CLI, environment, or explicitly enabled tool arguments.
+
+## Audit And Logging
+
+Security-boundary denials emit one structured stderr line prefixed with `[MCP Audit]`. Audit events include sanitized fields such as:
+
+- denial reason;
+- tool name;
+- security mode;
+- destructive opt-in state;
+- per-tool connection-string presence;
+- policy risk.
+
+Audit events intentionally omit raw request payloads, raw SQL, and connection-string passwords. Set `POSTGRES_MCP_AUDIT_FILE`, `--audit-file`, or `auditFile` to append the same sanitized events to a JSONL file. File-write failures are logged as `[MCP Audit Error]` and do not include raw request payloads.
+
+`POSTGRES_MCP_DEBUG_SQL=true` enables verbose `pg-monitor` SQL tracing and may log raw SQL and bind values. Use it only for trusted local debugging.
+
+## Recommended Deployment Profiles
+
+Read-only inspection:
+
+```bash
+POSTGRES_CONNECTION_STRING="postgresql://readonly_user:pass@db.internal:5432/app" \
+ npx @henkey/postgres-mcp-server
+```
+
+Application data maintenance:
+
+```bash
+POSTGRES_CONNECTION_STRING="postgresql://writer:pass@db.internal:5432/app" \
+ npx @henkey/postgres-mcp-server --security-mode write
+```
+
+Administrative maintenance:
+
+```bash
+POSTGRES_CONNECTION_STRING="postgresql://admin:pass@db.internal:5432/app" \
+ npx @henkey/postgres-mcp-server \
+ --security-mode admin \
+ --allow-destructive \
+ --workspace-dir /var/lib/postgres-mcp/workspace \
+ --allowed-connection-target "admin@db.internal:5432/app"
+```
+
+Trusted arbitrary SQL:
+
+```bash
+POSTGRES_CONNECTION_STRING="postgresql://admin:pass@db.internal:5432/app" \
+ npx @henkey/postgres-mcp-server --security-mode unsafe --allow-destructive
+```
+
+## Operational Checklist
+
+- Use a least-privilege PostgreSQL role.
+- Start from [PostgreSQL Role Templates](docs/POSTGRES_ROLES.md) for readonly, writer, schema-admin, and role-admin deployments.
+- Prefer one fixed server-level connection string.
+- Keep per-tool connection strings disabled unless the workflow genuinely needs them.
+- If per-tool connection strings are enabled, configure connection target allowlists.
+- Use `enabledTools` to reduce the available surface for production deployments.
+- Review the default 20 max pool connections, 30000 ms pool idle timeout, 2000 ms connection timeout, default 60000 ms statement timeout, 65000 ms query timeout, 10000 ms lock timeout, and 60000 ms idle-in-transaction timeout; raise or lower them explicitly with `--max-connections`, `POSTGRES_MCP_MAX_CONNECTIONS`, `POSTGRES_MCP_IDLE_TIMEOUT_MS`, `POSTGRES_MCP_CONNECTION_TIMEOUT_MS`, `POSTGRES_MCP_STATEMENT_TIMEOUT_MS`, `POSTGRES_MCP_QUERY_TIMEOUT_MS`, `POSTGRES_MCP_LOCK_TIMEOUT_MS`, and `POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS` for the deployment.
+- Configure a filesystem workspace only when import/export tools are required.
+- Ship stderr logs to monitoring and alert on `[MCP Audit]` denials, or configure `POSTGRES_MCP_AUDIT_FILE` for JSONL audit persistence.
+- Leave `POSTGRES_MCP_DEBUG_SQL` disabled outside trusted local debugging.
+- Run `npm run prepublishOnly` before publishing or deploying changed code.
diff --git a/TOOL_SCHEMAS.md b/TOOL_SCHEMAS.md
index 538aa0b..1d614f0 100644
--- a/TOOL_SCHEMAS.md
+++ b/TOOL_SCHEMAS.md
@@ -1,6 +1,6 @@
# PostgreSQL MCP Server - Complete Tool Schema Reference
-> **Quick Reference**: This document contains the complete parameter schemas for all 17 tools. No more hunting through multiple docs!
+> **Quick Reference**: This document contains the complete parameter schemas for all 18 tools. No more hunting through multiple docs!
## 🚀 Quick Navigation
@@ -34,14 +34,16 @@
"schema": "public", // optional, defaults to "public"
"columns": [ // required
{
- "name": "id", // required
- "type": "SERIAL", // required: PostgreSQL data type
- "nullable": false, // optional, defaults to true
- "default": "DEFAULT_VALUE" // optional
- }
- ]
-}
-```
+ "name": "id", // required
+ "type": "SERIAL", // required: PostgreSQL data type
+ "nullable": false, // optional, defaults to true
+ "default": "DEFAULT_VALUE" // optional raw SQL expression; requires unsafe mode
+ }
+ ]
+}
+```
+
+Table, schema, enum, and column names are restricted to simple PostgreSQL identifiers and quoted by the server. Column `type` accepts simple PostgreSQL type names such as `text`, `integer`, `numeric(10,2)`, `timestamp with time zone`, or `schema.type`. Column `default` is a raw SQL expression and requires `--security-mode unsafe --allow-destructive`.
#### Alter Table
```json
@@ -52,13 +54,13 @@
"operations": [ // required
{
"type": "add", // required: "add" | "alter" | "drop"
- "columnName": "email", // required
- "dataType": "VARCHAR(255)", // required for add/alter
- "nullable": false, // optional for add/alter
- "default": "DEFAULT_VALUE" // optional for add/alter
- }
- ]
-}
+ "columnName": "email", // required
+ "dataType": "VARCHAR(255)", // required for add/alter
+ "nullable": false, // optional for add/alter
+ "default": "DEFAULT_VALUE" // optional raw SQL expression; requires unsafe mode
+ }
+ ]
+}
```
#### Get ENUMs
@@ -98,25 +100,29 @@
"superuser": false, // optional
"replication": false, // optional
"inherit": true, // optional
- "connectionLimit": 10, // optional
+ "connectionLimit": 10, // optional: -1 for unlimited
"validUntil": "2024-12-31" // optional: YYYY-MM-DD
}
```
-#### Grant Permissions
-```json
-{
- "operation": "grant",
- "username": "testuser", // required
- "permissions": ["SELECT", "INSERT"], // required: array of permissions
+#### Grant Permissions
+```json
+{
+ "operation": "grant",
+ "username": "testuser", // required
+ "permissions": ["SELECT", "INSERT"], // required: non-empty array of permissions
"target": "users", // required: object name
"targetType": "table", // required: "table" | "schema" | "database" | "sequence" | "function"
"schema": "public", // optional
"withGrantOption": false // optional
-}
-```
-
-#### Other User Operations
+}
+```
+
+Role, schema, database, table, sequence, and function names are validated as simple PostgreSQL identifiers and then quoted. For `targetType: "function"`, provide the function name only; overloaded function signatures are not accepted by this safe grant/revoke path.
+
+Creating or altering a role with `superuser`, `createdb`, `createrole`, or `replication` set to `true` requires `--allow-destructive` in addition to `--security-mode admin`. Grants that include `ALL`, `TRUNCATE`, or `withGrantOption: true` also require destructive opt-in because they broaden or delegate future database permissions.
+
+#### Other User Operations
```json
// List users
{ "operation": "list", "includeSystemRoles": false }
@@ -148,31 +154,35 @@
"verbose": false, // optional: include verbose output
"costs": true, // optional: include cost estimates
"buffers": false, // optional: include buffer usage
- "format": "json" // optional: "text" | "json" | "xml" | "yaml"
-}
-```
-
-#### Get Slow Queries
-```json
-{
- "operation": "get_slow_queries",
- "limit": 10, // optional, defaults to 10
- "minDuration": 100, // optional: minimum avg duration in ms
- "orderBy": "mean_time", // optional: "mean_time" | "total_time" | "calls" | "cache_hit_ratio"
- "includeNormalized": true // optional: include normalized query text
-}
-```
+ "format": "json" // optional: "text" | "json" | "xml" | "yaml"
+}
+```
+
+`explain` accepts one read-only `SELECT`, `WITH`, `VALUES`, or `TABLE` statement without semicolons and runs the EXPLAIN inside a read-only transaction. `analyze: true` runs `EXPLAIN ANALYZE`, which executes the supplied query and therefore requires `--security-mode unsafe --allow-destructive`.
+
+#### Get Slow Queries
+```json
+{
+ "operation": "get_slow_queries",
+ "limit": 10, // optional, defaults to 10, max 100
+ "minDuration": 100, // optional: minimum avg duration in ms
+ "orderBy": "mean_time", // optional: "mean_time" | "total_time" | "calls" | "cache_hit_ratio"
+ "includeNormalized": true // optional: include normalized query text
+}
+```
#### Other Query Operations
```json
// Get query statistics
{ "operation": "get_stats", "queryPattern": "SELECT", "minCalls": 5, "orderBy": "mean_time" }
-// Reset query statistics
-{ "operation": "reset_stats", "queryId": "12345" } // queryId optional, resets all if omitted
-```
-
----
+// Reset query statistics
+{ "operation": "reset_stats", "queryId": "12345" } // queryId optional, resets all if omitted
+```
+
+`reset_stats` changes `pg_stat_statements` state and is treated as destructive. `queryId` must be a numeric pg_stat_statements query ID.
+
+---
### Index Management
**Tool:** `pg_manage_indexes`
@@ -185,13 +195,18 @@
"tableName": "users", // required
"columns": ["email"], // required: array of column names
"schema": "public", // optional
- "unique": false, // optional
- "concurrent": false, // optional: create concurrently
- "method": "btree", // optional: "btree" | "hash" | "gist" | "spgist" | "gin" | "brin"
- "where": "email IS NOT NULL", // optional: partial index condition
- "ifNotExists": false // optional
-}
-```
+ "unique": false, // optional
+ "concurrent": false, // optional: create concurrently
+ "method": "btree", // optional: "btree" | "hash" | "gist" | "spgist" | "gin" | "brin"
+ "where": { // optional: structured partial index predicate
+ "email": { "isNull": false },
+ "active": true
+ },
+ "ifNotExists": false // optional
+}
+```
+
+Partial-index `where` supports the same structured operators as mutation filters. Legacy string `where` clauses are rejected. Use `rawWhere` only for trusted local/admin partial-index predicates; it is classified as arbitrary SQL and requires `--security-mode unsafe --allow-destructive`.
#### Other Index Operations
```json
@@ -234,11 +249,13 @@
// List functions
{ "operation": "get", "functionName": "calc%", "schema": "public" } // functionName optional for filtering
-// Drop function
-{ "operation": "drop", "functionName": "old_func", "parameters": "INT, TEXT", "ifExists": true, "cascade": false }
-```
-
----
+// Drop function
+{ "operation": "drop", "functionName": "old_func", "parameters": "INT, TEXT", "ifExists": true, "cascade": false }
+```
+
+Function names are restricted to simple PostgreSQL identifiers and quoted by the server. Function `parameters`, `returnType`, and `functionBody` are raw SQL/code inputs; creating functions is classified as arbitrary SQL and requires `--security-mode unsafe --allow-destructive`. Drop signatures accept only comma-separated simple type names.
+
+---
### Triggers Management
**Tool:** `pg_manage_triggers`
@@ -249,17 +266,19 @@
"operation": "create",
"triggerName": "audit_trigger", // required
"tableName": "users", // required
- "functionName": "audit_function", // required
- "timing": "AFTER", // optional: "BEFORE" | "AFTER" | "INSTEAD OF"
- "events": ["INSERT", "UPDATE"], // optional: array of "INSERT" | "UPDATE" | "DELETE" | "TRUNCATE"
- "forEach": "ROW", // optional: "ROW" | "STATEMENT"
- "when": "NEW.active = true", // optional: WHEN condition
- "schema": "public", // optional
- "replace": false // optional
-}
-```
-
-#### Other Trigger Operations
+ "functionName": "audit_function", // required
+ "timing": "AFTER", // optional: "BEFORE" | "AFTER" | "INSTEAD OF"
+ "events": ["INSERT", "UPDATE"], // optional: non-empty array of "INSERT" | "UPDATE" | "DELETE" | "TRUNCATE"
+ "forEach": "ROW", // optional: "ROW" | "STATEMENT"
+ "when": "NEW.active = true", // optional: raw WHEN condition; requires unsafe mode
+ "schema": "public", // optional
+ "replace": false // optional
+}
+```
+
+Trigger, table, and function names are restricted to simple PostgreSQL identifiers and quoted by the server. Trigger `when` is a raw SQL expression; using it is classified as arbitrary SQL and requires `--security-mode unsafe --allow-destructive`.
+
+#### Other Trigger Operations
```json
// List triggers
{ "operation": "get", "tableName": "users", "schema": "public" }
@@ -282,9 +301,9 @@
"operation": "create_fk",
"constraintName": "fk_user_id", // required
"tableName": "orders", // required
- "columnNames": ["user_id"], // required
+ "columnNames": ["user_id"], // required: non-empty
"referencedTable": "users", // required
- "referencedColumns": ["id"], // required
+ "referencedColumns": ["id"], // required: non-empty
"schema": "public", // optional
"referencedSchema": "public", // optional
"onDelete": "CASCADE", // optional: "NO ACTION" | "RESTRICT" | "CASCADE" | "SET NULL" | "SET DEFAULT"
@@ -297,15 +316,16 @@
#### Create Other Constraints
```json
{
- "operation": "create",
- "constraintName": "unique_email", // required
- "tableName": "users", // required
- "constraintType": "unique", // required: "unique" | "check" | "primary_key"
- "columnNames": ["email"], // required for unique/primary_key
- "checkExpression": "email LIKE '%@%'", // required for check constraints
- "schema": "public" // optional
-}
-```
+ "operation": "create",
+ "constraintName": "unique_email", // required
+ "tableName": "users", // required
+ "constraintTypeCreate": "unique", // required: "unique" | "check" | "primary_key"
+ "columnNames": ["email"], // required and non-empty for unique/primary_key
+ "schema": "public" // optional
+}
+```
+
+`checkExpression` is raw SQL by nature and is therefore treated as unsafe by the server policy. Creating CHECK constraints requires `--security-mode unsafe --allow-destructive`.
#### Other Constraint Operations
```json
@@ -344,11 +364,13 @@
"command": "ALL", // optional: "ALL" | "SELECT" | "INSERT" | "UPDATE" | "DELETE"
"role": "authenticated", // optional: role name
"schema": "public", // optional
- "replace": false // optional
-}
-```
-
-#### Other RLS Operations
+ "replace": false // optional
+}
+```
+
+Policy, table, and role names are restricted to simple PostgreSQL identifiers and quoted by the server. RLS `using` and `check` are raw SQL policy expressions; creating policies or editing those expressions is classified as arbitrary SQL and requires `--security-mode unsafe --allow-destructive`.
+
+#### Other RLS Operations
```json
// List policies
{ "operation": "get_policies", "tableName": "users", "schema": "public" }
@@ -371,14 +393,16 @@
#### Basic SELECT
```json
{
- "operation": "select",
- "query": "SELECT * FROM users WHERE active = $1", // required: SELECT query
- "parameters": [true], // optional: parameters for $1, $2, etc.
- "limit": 100, // optional: safety limit on rows
- "timeout": 30000, // optional: query timeout in ms
- "connectionString": "postgresql://..." // optional if env var set
-}
-```
+ "operation": "select",
+ "query": "SELECT * FROM users WHERE active = $1", // required: SELECT query
+ "parameters": [true], // optional: parameters for $1, $2, etc.
+ "limit": 100, // optional: select row limit, default 100, max 1000
+ "timeout": 30000, // optional: query timeout in ms
+ "connectionString": "postgresql://..." // optional if env var set
+}
+```
+
+`select` operations are wrapped in a bounded outer query and always receive a row limit. `count` and `exists` evaluate the provided SELECT without applying the select row limit.
#### Count Rows
```json
@@ -414,11 +438,12 @@
"name": "John Doe",
"email": "john@example.com",
"active": true
- },
- "schema": "public", // optional: defaults to "public"
- "returning": "*", // optional: RETURNING clause
- "connectionString": "postgresql://..." // optional if env var set
-}
+ },
+ "schema": "public", // optional: defaults to "public"
+ "returning": "*", // optional: RETURNING clause
+ "maxReturningRows": 100, // optional: RETURNING rows in response, default 100, max 1000
+ "connectionString": "postgresql://..." // optional if env var set
+}
```
#### Update Data
@@ -426,28 +451,48 @@
{
"operation": "update",
"table": "users", // required
- "data": { // required: fields to update
- "name": "Jane Doe",
- "updated_at": "NOW()"
- },
- "where": "id = 123", // required: WHERE clause (without WHERE)
- "schema": "public", // optional
- "returning": "id, name, updated_at" // optional
-}
-```
-
-#### Delete Data
-```json
-{
- "operation": "delete",
- "table": "users", // required
- "where": "active = false AND last_login < '2023-01-01'", // required
- "schema": "public" // optional
-}
-```
-
-#### Upsert (INSERT ... ON CONFLICT)
-```json
+ "data": { // required: fields to update
+ "name": "Jane Doe",
+ "updated_at": "NOW()"
+ },
+ "where": { "id": 123 }, // required: structured WHERE predicate
+ "schema": "public", // optional
+ "returning": ["id", "name", "updated_at"], // optional
+ "maxReturningRows": 100 // optional: response output cap
+}
+```
+
+#### Delete Data
+```json
+{
+ "operation": "delete",
+ "table": "users", // required
+ "where": {
+ "active": false,
+ "last_login": { "lt": "2023-01-01" }
+ },
+ "schema": "public" // optional
+}
+```
+
+#### Structured WHERE Predicates
+```json
+{
+ "where": {
+ "id": 123,
+ "status": { "in": ["active", "pending"] },
+ "created_at": { "gte": "2024-01-01" },
+ "deleted_at": { "isNull": true }
+ }
+}
+```
+
+Supported operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`, and `isNull`.
+
+Legacy string `where` clauses are rejected. Use `rawWhere` only for trusted local/admin filters; it is treated as an unsafe SQL fragment by the server security policy and requires `--security-mode unsafe --allow-destructive`.
+
+#### Upsert (INSERT ... ON CONFLICT)
+```json
{
"operation": "upsert",
"table": "users", // required
@@ -456,10 +501,13 @@
"name": "Updated Name",
"last_seen": "NOW()"
},
- "conflictColumns": ["email"], // required: columns for ON CONFLICT
- "returning": "*" // optional
-}
-```
+ "conflictColumns": ["email"], // required: columns for ON CONFLICT
+ "returning": "*", // optional
+ "maxReturningRows": 100 // optional: response output cap
+}
+```
+
+`maxReturningRows` limits only the MCP response payload. The database mutation still runs normally, and `Rows affected` reports PostgreSQL `rowCount`.
---
@@ -471,40 +519,46 @@
```json
{
"sql": "CREATE INDEX CONCURRENTLY idx_users_email ON users(email)", // required
- "expectRows": false, // optional: whether to expect rows back
- "timeout": 60000, // optional: timeout in ms
- "transactional": false, // optional: wrap in transaction
- "connectionString": "postgresql://..." // optional if env var set
-}
+ "expectRows": false, // optional: whether to expect rows back
+ "timeout": 60000, // optional: timeout in ms
+ "maxRows": 100, // optional: result rows in response, default 100, max 1000
+ "transactional": false, // optional: wrap in transaction
+ "connectionString": "postgresql://..." // optional if env var set
+}
```
#### Complex Query with Parameters
```json
{
"sql": "WITH recent_users AS (SELECT * FROM users WHERE created_at > $1) SELECT COUNT(*) FROM recent_users",
- "parameters": ["2024-01-01"], // optional: parameters for $1, $2, etc.
- "expectRows": true,
- "timeout": 30000
-}
-```
-
-#### Transactional Operation
-```json
-{
- "sql": "UPDATE accounts SET balance = balance - 100 WHERE id = $1; UPDATE accounts SET balance = balance + 100 WHERE id = $2;",
- "parameters": [1, 2],
- "transactional": true, // wraps in BEGIN/COMMIT
- "expectRows": false
-}
+ "parameters": ["2024-01-01"], // optional: parameters for $1, $2, etc.
+ "expectRows": true,
+ "timeout": 30000,
+ "maxRows": 100
+}
+```
+
+`maxRows` limits only the rows included in the MCP response. It does not rewrite arbitrary SQL or reduce database work.
+
+Multi-statement arbitrary SQL must use `transactional: true`, `expectRows: false`, and no `parameters`. Use a single parameterized statement, for example a CTE, when bind parameters are needed.
+
+#### Transactional Operation
+```json
+{
+ "sql": "WITH debit AS (UPDATE accounts SET balance = balance - $1 WHERE id = $2 RETURNING id) UPDATE accounts SET balance = balance + $1 WHERE id = $3",
+ "parameters": [100, 1, 2],
+ "transactional": true, // wraps in BEGIN/COMMIT
+ "expectRows": false
+}
```
#### Data Definition (DDL)
```json
-{
- "sql": "ALTER TABLE users ADD COLUMN phone VARCHAR(20); CREATE INDEX idx_users_phone ON users(phone);",
- "expectRows": false,
- "transactional": true
-}
+{
+ "sql": "ALTER TABLE users ADD COLUMN phone VARCHAR(20); CREATE INDEX idx_users_phone ON users(phone);",
+ "expectRows": false,
+ "transactional": true
+}
```
---
@@ -529,13 +583,17 @@
```json
{
"operation": "set",
- "objectType": "table", // required
- "objectName": "users", // required
- "comment": "Main user account information table", // required
- "schema": "public", // optional, defaults to "public"
- "columnName": "created_at" // required when objectType is "column"
-}
-```
+ "objectType": "table", // required
+ "objectName": "users", // required
+ "comment": "Main user account information table", // required
+ "schema": "public", // optional, defaults to "public"
+ "columnName": "created_at", // required when objectType is "column"
+ "tableName": "users", // required when objectType is "constraint" or "trigger"
+ "functionSignature": "" // optional for function comments; use "" for no arguments
+}
+```
+
+Comment targets are restricted to simple PostgreSQL identifiers and quoted by the server. Comment text is escaped as a SQL literal. Function comment signatures accept only comma-separated simple type names.
#### Remove Comment
```json
@@ -596,11 +654,12 @@
**Tool:** `pg_analyze_database`
```json
-{
- "analysisType": "performance", // required: "configuration" | "performance" | "security"
- "connectionString": "postgresql://..." // optional if env var set
-}
-```
+{
+ "analysisType": "performance", // optional, defaults to "configuration": "configuration" | "performance" | "security"
+ "schema": "public", // optional, defaults to "public"; schema to inspect for table-size diagnostics
+ "connectionString": "postgresql://..." // optional if env var set
+}
+```
---
@@ -622,23 +681,25 @@
#### Export
```json
-{
- "tableName": "users", // required
- "outputPath": "/path/to/file.json", // required: absolute path
- "format": "json", // optional: "json" | "csv"
- "limit": 1000, // optional: row limit
- "where": "active = true", // optional: WHERE clause
- "connectionString": "postgresql://..." // optional
-}
-```
-
-#### Import
-```json
-{
- "tableName": "users", // required
- "inputPath": "/path/to/file.json", // required: absolute path
- "format": "json", // optional: "json" | "csv"
- "delimiter": ",", // optional: for CSV
+{
+ "tableName": "users", // required
+ "schema": "public", // optional, defaults to "public"
+ "outputPath": "exports/users.json", // required: path under POSTGRES_MCP_WORKSPACE_DIR
+ "format": "json", // optional: "json" | "csv"
+ "limit": 1000, // optional: export row limit, default 1000, max 100000
+ "where": { "active": true }, // optional: structured filter
+ "connectionString": "postgresql://..." // optional
+}
+```
+
+#### Import
+```json
+{
+ "tableName": "users", // required
+ "schema": "public", // optional, defaults to "public"
+ "inputPath": "imports/users.json", // required: path under POSTGRES_MCP_WORKSPACE_DIR
+ "format": "json", // optional: "json" | "csv"
+ "delimiter": ",", // optional: for CSV
"truncateFirst": false, // optional: clear table first
"connectionString": "postgresql://..." // optional
}
@@ -646,18 +707,24 @@
---
-### Copy Between Databases
-**Tool:** `pg_copy_between_databases`
-
-```json
-{
- "sourceConnectionString": "postgresql://source...", // required
- "targetConnectionString": "postgresql://target...", // required
- "tableName": "users", // required
- "where": "created_at > '2024-01-01'", // optional: filter condition
- "truncateTarget": false // optional: clear target table first
-}
-```
+### Copy Between Databases
+**Tool:** `pg_copy_between_databases`
+
+This tool necessarily takes `sourceConnectionString` and `targetConnectionString`; the server must be started with `--allow-tool-connection-string` or `POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING=true` for those per-tool connection arguments to be accepted.
+
+```json
+{
+ "sourceConnectionString": "postgresql://source...", // required
+ "targetConnectionString": "postgresql://target...", // required
+ "tableName": "users", // required
+ "schema": "public", // optional, defaults to "public"; used for both source and target
+ "where": { "created_at": { "gt": "2024-01-01" } }, // optional structured filter
+ "limit": 1000, // optional: copied row limit, default 1000, max 100000
+ "truncateTarget": false // optional: clear target table first
+}
+```
+
+Export/import paths are sandboxed to `POSTGRES_MCP_WORKSPACE_DIR` or `--workspace-dir`, must use `.json` or `.csv`, and are limited by `POSTGRES_MCP_MAX_FILE_BYTES` or `--max-file-bytes` (default: 10485760). JSON imports must be arrays of objects. Exports and copy-between-databases always use row limits (default: 1000, max: 100000). Legacy string `where` clauses are rejected. Use `rawWhere` only for trusted local/admin filters; it is classified as arbitrary SQL and requires `--security-mode unsafe --allow-destructive`.
---
@@ -702,10 +769,14 @@ postgresql://user:pass@localhost:5432/mydb?sslmode=require
# With connection pooling
postgresql://user:pass@localhost:5432/mydb?application_name=mcp-server&connect_timeout=10
```
-
-**Environment Variable:** `POSTGRES_CONNECTION_STRING`
-
----
+
+**Environment Variable:** `POSTGRES_CONNECTION_STRING`
+
+Per-tool connection string arguments are visible in schemas for compatibility, but the server rejects them by default. Enable them only for trusted local/admin workflows with `--allow-tool-connection-string` or `POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING=true`.
+Explicit per-tool, CLI, and `POSTGRES_CONNECTION_STRING` values must be non-empty strings. Blank higher-priority connection strings fail validation instead of falling back to lower-priority sources.
+Connection target allowlists can be configured with repeated `--allowed-connection-target`, the tools config `allowedConnectionTargets` array, or comma-separated `POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS`. Patterns use `[user@]host[:port][/database]`; omitted fields are unconstrained and `*` is accepted only as a full-field wildcard.
+
+---
## Common Parameter Patterns
@@ -721,15 +792,26 @@ postgresql://user:pass@localhost:5432/mydb?application_name=mcp-server&connect_t
- Use `ifExists: true` for safer DROP operations
- Use `ifNotExists: true` for safer CREATE operations
-### Parameterized Queries (Enhancement Tools)
-- Use `$1`, `$2`, etc. placeholders in SQL queries
-- Provide corresponding values in the `parameters` array
-- This prevents SQL injection attacks
-
-### Pagination & Safety Limits
-- Query tools support `limit` parameter for safety (default varies)
-- Meta-tools that return lists often support pagination
-- Data mutation tools validate input for safety
+### Parameterized Queries (Enhancement Tools)
+- Use `$1`, `$2`, etc. placeholders in SQL queries
+- Provide corresponding values in the `parameters` array
+- This prevents SQL injection attacks
+
+### Security Policy Defaults
+- The server defaults to `readonly` mode.
+- Mutations require `--security-mode write` or higher.
+- DDL, role, RLS, filesystem import/export, and migration-style tools require `--security-mode admin` or higher.
+- Arbitrary SQL through `pg_execute_sql` requires `--security-mode unsafe`.
+- Destructive operations such as drops, resets, and arbitrary SQL also require `--allow-destructive`.
+- Per-tool connection string arguments are disabled unless `--allow-tool-connection-string` or `POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING=true` is set.
+- Connection target allowlists apply to both server-level and per-tool connection strings before database access.
+- Security-boundary denials emit sanitized `[MCP Audit]` JSON lines on stderr without raw SQL, full request payloads, or connection-string passwords.
+- Default runtime timeouts can be configured with `--statement-timeout-ms`, `--query-timeout-ms`, `POSTGRES_MCP_STATEMENT_TIMEOUT_MS`, `POSTGRES_MCP_QUERY_TIMEOUT_MS`, or the tools config keys `statementTimeoutMs` and `queryTimeoutMs`.
+
+### Pagination & Safety Limits
+- Query tools support `limit` parameter for safety (default varies)
+- Meta-tools that return lists often support pagination
+- Data mutation tools validate input for safety
### Transactions (Execute SQL)
- Set `transactional: true` for operations requiring ACID properties
@@ -737,28 +819,16 @@ postgresql://user:pass@localhost:5432/mydb?application_name=mcp-server&connect_t
---
-## Error Handling
-
-All tools return structured error information:
-
-```json
-{
- "error": "Descriptive error message",
- "code": "POSTGRES_ERROR_CODE",
- "details": { /* additional context */ }
-}
-```
-
-**Common Error Codes:**
-- `CONNECTION_ERROR` - Database connection issues
-- `INVALID_PARAMETER` - Missing or invalid parameters
-- `PERMISSION_DENIED` - Insufficient database privileges
-- `OBJECT_NOT_FOUND` - Referenced object doesn't exist
-- `SYNTAX_ERROR` - Invalid SQL syntax
-- `TIMEOUT_ERROR` - Query exceeded timeout limit (enhancement tools)
-- `TRANSACTION_ERROR` - Transaction rollback or failure (execute SQL)
-- `CONSTRAINT_VIOLATION` - Data violates constraints (mutations)
+## Error Handling
+
+MCP tool calls return text content with `isError: true` for validation, policy, connection, and database failures. Error messages are sanitized before they cross the server boundary: credentials, SQL literals, and sensitive diagnostic SQL text are redacted where possible.
+
+Common failure categories:
+- Input validation failures, including missing required fields and unknown fields
+- Security policy denials, such as write tools in `readonly` mode or arbitrary SQL without `unsafe` mode
+- Connection resolution and database connection failures
+- PostgreSQL syntax, permission, timeout, transaction, and constraint failures
---
-*Need more examples? Check the [examples/](./examples/) directory for complete working scenarios.*
\ No newline at end of file
+*Need more examples? Check the [examples/](./examples/) directory for complete working scenarios.*
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index 9da5ed7..4e0bb03 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -1,29 +1,4 @@
#!/bin/sh
set -e
-# Default values
-CONNECTION_STRING=""
-TOOLS_CONFIG=""
-
-# Parse environment variables
-if [ -n "$POSTGRES_CONNECTION_STRING" ]; then
- CONNECTION_STRING="--connection-string $POSTGRES_CONNECTION_STRING"
-fi
-
-if [ -n "$POSTGRES_TOOLS_CONFIG" ]; then
- TOOLS_CONFIG="--tools-config $POSTGRES_TOOLS_CONFIG"
-fi
-
-# Build the command
-CMD="node build/index.js"
-
-if [ -n "$CONNECTION_STRING" ]; then
- CMD="$CMD $CONNECTION_STRING"
-fi
-
-if [ -n "$TOOLS_CONFIG" ]; then
- CMD="$CMD $TOOLS_CONFIG"
-fi
-
-# Execute the command
-exec $CMD "$@"
+exec node build/index.js "$@"
diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md
index caf4704..ad42196 100644
--- a/docs/DEVELOPER.md
+++ b/docs/DEVELOPER.md
@@ -1,342 +1,119 @@
-# PostgreSQL MCP Server - Developer Guide
-
-This guide provides examples and best practices for using the PostgreSQL MCP server in your applications.
-
-## Getting Started
-
-### Installation
-
-1. Install the server:
- ```bash
- npm install
- npm run build
- ```
-
-2. Test the server:
- ```bash
- # On Unix/Linux/macOS
- ./test-client.js get_schema_info '{"connectionString":"postgresql://user:password@localhost:5432/dbname"}'
-
- # On Windows
- node test-client.js get_schema_info '{"connectionString":"postgresql://user:password@localhost:5432/dbname"}'
- ```
-
-### Connection Strings
-
-PostgreSQL connection strings follow this format:
-```
-postgresql://[user[:password]@][host][:port][/dbname][?param1=value1&...]
-```
-
-Examples:
-- `postgresql://postgres:password@localhost:5432/mydb`
-- `postgresql://postgres@localhost/mydb`
-- `postgresql://postgres:password@localhost/mydb?sslmode=require`
-
-## Tool Examples
-
-### Schema Management
-
-#### Get Schema Information
-
-List all tables in a database:
-```javascript
-{
- "name": "get_schema_info",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb"
- }
-}
-```
-
-Get detailed information about a specific table:
-```javascript
-{
- "name": "get_schema_info",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "tableName": "users"
- }
-}
-```
-
-#### Create a Table
-
-Create a new users table:
-```javascript
-{
- "name": "create_table",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "tableName": "users",
- "columns": [
- { "name": "id", "type": "SERIAL", "nullable": false },
- { "name": "username", "type": "VARCHAR(100)", "nullable": false },
- { "name": "email", "type": "VARCHAR(255)", "nullable": false },
- { "name": "created_at", "type": "TIMESTAMP", "default": "NOW()" }
- ]
- }
-}
-```
-
-#### Alter a Table
-
-Add, modify, and drop columns:
-```javascript
-{
- "name": "alter_table",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "tableName": "users",
- "operations": [
- { "type": "add", "columnName": "last_login", "dataType": "TIMESTAMP" },
- { "type": "alter", "columnName": "email", "nullable": false },
- { "type": "drop", "columnName": "temporary_field" }
- ]
- }
-}
-```
-
-### Data Migration
-
-#### Export Table Data
-
-Export to JSON:
-```javascript
-{
- "name": "export_table_data",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "tableName": "users",
- "outputPath": "./exports/users.json",
- "where": "created_at > '2023-01-01'",
- "limit": 1000
- }
-}
-```
-
-Export to CSV:
-```javascript
-{
- "name": "export_table_data",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "tableName": "users",
- "outputPath": "./exports/users.csv",
- "format": "csv"
- }
-}
-```
-
-#### Import Table Data
-
-Import from JSON:
-```javascript
-{
- "name": "import_table_data",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "tableName": "users",
- "inputPath": "./imports/users.json",
- "truncateFirst": true
- }
-}
-```
-
-Import from CSV:
-```javascript
-{
- "name": "import_table_data",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "tableName": "users",
- "inputPath": "./imports/users.csv",
- "format": "csv",
- "delimiter": ","
- }
-}
-```
-
-#### Copy Between Databases
-
-Copy data between databases:
-```javascript
-{
- "name": "copy_between_databases",
- "arguments": {
- "sourceConnectionString": "postgresql://postgres:password@localhost:5432/source_db",
- "targetConnectionString": "postgresql://postgres:password@localhost:5432/target_db",
- "tableName": "users",
- "where": "active = true",
- "truncateTarget": false
- }
-}
-```
-
-### Database Monitoring
-
-#### Monitor Database
-
-Basic monitoring:
-```javascript
-{
- "name": "monitor_database",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb"
- }
-}
-```
-
-Advanced monitoring with alerts:
-```javascript
-{
- "name": "monitor_database",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "includeTables": true,
- "includeQueries": true,
- "includeLocks": true,
- "includeReplication": true,
- "alertThresholds": {
- "connectionPercentage": 80,
- "longRunningQuerySeconds": 30,
- "cacheHitRatio": 0.95,
- "deadTuplesPercentage": 10,
- "vacuumAge": 7
- }
- }
-}
-```
-
-### Database Analysis and Debugging
-
-#### Analyze Database
-
-Analyze configuration:
-```javascript
-{
- "name": "analyze_database",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "analysisType": "configuration"
- }
-}
-```
-
-Analyze performance:
-```javascript
-{
- "name": "analyze_database",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "analysisType": "performance"
- }
-}
-```
-
-Analyze security:
-```javascript
-{
- "name": "analyze_database",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "analysisType": "security"
- }
-}
-```
-
-#### Debug Database Issues
-
-Debug connection issues:
-```javascript
-{
- "name": "debug_database",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "issue": "connection",
- "logLevel": "debug"
- }
-}
-```
-
-Debug performance issues:
-```javascript
-{
- "name": "debug_database",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "issue": "performance",
- "logLevel": "debug"
- }
-}
-```
-
-Debug lock issues:
-```javascript
-{
- "name": "debug_database",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "issue": "locks",
- "logLevel": "debug"
- }
-}
-```
-
-Debug replication issues:
-```javascript
-{
- "name": "debug_database",
- "arguments": {
- "connectionString": "postgresql://postgres:password@localhost:5432/mydb",
- "issue": "replication",
- "logLevel": "debug"
- }
-}
-```
-
-
-
-## Best Practices
-
-1. **Connection Pooling**: The server implements connection pooling internally, but you should still close connections when done.
-
-2. **Error Handling**: Always check the `success` field in responses and handle errors appropriately.
-
-3. **Security**:
- - Never hardcode connection strings with passwords in your code
- - Use environment variables or secure vaults for credentials
- - Use SSL connections in production environments
-
-4. **Performance**:
- - Limit the amount of data returned by using WHERE clauses and LIMIT
- - For large data exports/imports, consider using batching
- - Monitor query performance regularly
-
-5. **Monitoring**:
- - Set up regular monitoring to catch issues early
- - Configure appropriate alert thresholds based on your application needs
- - Pay special attention to connection usage and cache hit ratio
-
-## Troubleshooting
-
-### Common Issues
-
-1. **Connection Errors**:
- - Check that the PostgreSQL server is running
- - Verify connection string parameters
- - Ensure network connectivity between the MCP server and PostgreSQL
-
-2. **Permission Errors**:
- - Verify that the user has appropriate permissions for the requested operations
- - Check schema and table permissions
-
-3. **Performance Issues**:
- - Use the `analyze_database` and `debug_database` tools to identify bottlenecks
- - Check for long-running queries
- - Verify proper indexing on tables
-
-4. **Data Migration Issues**:
- - Ensure table schemas match when copying between databases
- - Check disk space for large exports
- - Verify file permissions for import/export paths
\ No newline at end of file
+# PostgreSQL MCP Server Developer Guide
+
+This guide is for contributors changing the server. For end-user tool parameters, use [TOOL_SCHEMAS.md](../TOOL_SCHEMAS.md).
+
+## Local Setup
+
+```bash
+npm install
+npm run build
+npm run test:run
+```
+
+The deterministic test command is:
+
+```bash
+npx vitest run --pool=threads --maxWorkers=1 --minWorkers=1
+```
+
+Use the same command before publishing or making security-sensitive changes.
+
+## Current Project Shape
+
+```text
+src/
+ index.ts MCP server, CLI/config loading, security policy enforcement
+ server/boundary.ts request boundary helpers for connection string handling
+ security/policy.ts centralized tool-call risk classification
+ tools/ individual MCP tools and focused unit tests
+ types/tool.ts shared tool types
+ utils/connection.ts PostgreSQL pool wrapper, timeouts, error sanitization
+ utils/filesystem.ts workspace path and file-size sandbox helpers
+ utils/sql.ts identifier quoting, predicates, redaction, read-only validation
+```
+
+The public runtime is built into `build/`. Source tests live beside implementation files as `*.test.ts`.
+
+## Security Rules For New Work
+
+Start with the security boundary, not the SQL string.
+
+- Add every new tool or new operation to `src/security/policy.ts`.
+- Default new functionality to `read` or the narrowest applicable risk.
+- Treat arbitrary SQL fragments, executable database code, column defaults, RLS predicates, CHECK expressions, trigger `WHEN` expressions, and raw filters as `arbitrary_sql`.
+- Require explicit structured inputs whenever possible.
+- Quote identifiers with `quoteIdent` or `quoteQualifiedIdent`.
+- Parameterize values. Do not interpolate untrusted values into SQL.
+- Use `buildWhereClause` for DML predicates and `buildStaticWhereClause` only where PostgreSQL syntax does not allow bind parameters.
+- Reject legacy string predicates unless the field is explicitly named `rawWhere`.
+- Sanitize returned errors with `sanitizeErrorMessage`.
+- Redact catalog SQL text with `redactSqlText` unless returning user data is the purpose of the tool.
+- Keep filesystem reads/writes inside `POSTGRES_MCP_WORKSPACE_DIR` or `--workspace-dir`.
+
+## Adding A Tool Or Operation
+
+1. Define a strict Zod schema near the tool implementation.
+2. Validate input before resolving a connection string.
+3. Add policy classification tests in `src/security/policy.test.ts`.
+4. Add focused tool tests for SQL construction, policy-sensitive input rejection, redaction, and error sanitization.
+5. Update [TOOL_SCHEMAS.md](../TOOL_SCHEMAS.md) and user-facing docs.
+6. Run `npm run prepublishOnly` and `git diff --check`.
+
+Minimal tool pattern:
+
+```ts
+const InputSchema = z.object({
+ operation: z.enum(['get']),
+ schema: z.string().optional().default('public')
+});
+
+export const exampleTool: PostgresTool = {
+ name: 'pg_example',
+ description: 'Example read-only tool',
+ inputSchema: InputSchema,
+ async execute(args, getConnectionString) {
+ const parsed = InputSchema.safeParse(args);
+ if (!parsed.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${parsed.error.message}` }], isError: true };
+ }
+
+ const db = DatabaseConnection.getInstance();
+
+ try {
+ await db.connect(getConnectionString(undefined));
+ const rows = await db.query('SELECT 1 AS ok');
+ return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
+ } catch (error) {
+ return { content: [{ type: 'text', text: sanitizeErrorMessage(error) }], isError: true };
+ } finally {
+ await db.disconnect();
+ }
+ }
+};
+```
+
+## Testing Expectations
+
+Use small unit tests with mocked `DatabaseConnection` for most behavior. Security-sensitive tests should prove both the generated SQL and that rejected input does not call `connect` or `query`.
+
+Cover these cases when relevant:
+
+- invalid identifiers
+- structured predicate SQL and values
+- rejection of legacy raw strings
+- output caps
+- timeout propagation
+- redaction of database errors and catalog SQL
+- policy classification and denial behavior
+
+## Release Checklist
+
+Before publishing:
+
+```bash
+npm run prepublishOnly
+git diff --check
+npm pack --dry-run
+```
+
+`prepublishOnly` runs the deterministic test suite, production dependency audit verifier, build verifier, built-CLI startup verifier, tool connection lifecycle verifier and self-test, docs/runtime parity verifier, security posture docs verifier, Docker runtime verifier, MCP stdio smoke verifier, workflow verifier, package contents verifier, and packed-install verifier. The CLI verifier checks help/version output and startup failure behavior for malformed tools config, unknown tools-config keys, invalid environment values, invalid allowlists, and denied connection targets. The connection lifecycle verifier checks that tool `db.connect()` calls are awaited inside a `try` block whose `finally` awaits the matching `db.disconnect()`, and that tools receiving `getConnectionString` connect with resolver-derived values. The package verifier checks that the tarball includes required runtime files and excludes source, tests, caches, lockfiles, and local development artifacts.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index d73500c..b21be4b 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -1,382 +1,77 @@
# PostgreSQL MCP Server Development Guide
-## Development Environment Setup
+This file is the broad development checklist. For implementation details and security rules, see [DEVELOPER.md](DEVELOPER.md).
-### Prerequisites
-
-1. **Node.js Environment**
- - Node.js >= 18.0.0
- - npm or yarn
- - TypeScript knowledge
-
-2. **PostgreSQL Setup**
- - PostgreSQL server (latest stable version)
- - psql command-line tool
- - Development database
-
-3. **Development Tools**
- - VS Code or preferred IDE
- - ESLint
- - Git
-
-### Initial Setup
-
-1. **Clone Repository**
- ```bash
- git clone [repository-url]
- cd postgresql-mcp-server
- ```
-
-2. **Install Dependencies**
- ```bash
- npm install
- ```
-
-3. **Configure Development Environment**
- ```bash
- # Create .env file
- cp .env.example .env
-
- # Edit with your settings
- vim .env
- ```
-
-4. **Build Project**
- ```bash
- npm run build
- ```
-
-## Project Structure
+## Setup
+```bash
+npm install
+npm run test:run
+npm run build
```
-postgresql-mcp-server/
-├── src/
-│ ├── index.ts # Main entry point
-│ ├── server/ # MCP server implementation
-│ │ ├── index.ts # Server setup
-│ │ └── handlers.ts # Request handlers
-│ ├── tools/ # MCP tools implementation
-│ │ ├── analyze.ts # Database analysis
-│ │ ├── setup.ts # Setup instructions
-│ │ └── debug.ts # Debugging tools
-│ ├── db/ # Database interactions
-│ │ ├── connection.ts # Connection management
-│ │ └── queries.ts # SQL queries
-│ └── utils/ # Utility functions
-├── tests/ # Test files
-├── docs/ # Documentation
-└── build/ # Compiled output
-```
-
-## Adding New Features
-
-### 1. Creating a New Tool
-
-1. **Define Tool Interface**
- ```typescript
- // src/types/tools.ts
- interface NewToolInput {
- param1: string;
- param2?: number;
- options?: {
- // Tool options
- };
- }
-
- interface NewToolOutput {
- status: "success" | "error";
- data: {
- // Tool output
- };
- error?: {
- code: string;
- message: string;
- };
- }
- ```
-
-2. **Implement Tool Logic**
- ```typescript
- // src/tools/newTool.ts
- import { Tool } from '../types';
-
- export class NewTool implements Tool {
- async execute(input: NewToolInput): Promise {
- try {
- // Tool implementation
- return {
- status: "success",
- data: {
- // Result data
- }
- };
- } catch (error) {
- return {
- status: "error",
- error: {
- code: "TOOL_ERROR",
- message: error.message
- }
- };
- }
- }
- }
- ```
-
-3. **Register Tool**
- ```typescript
- // src/server/index.ts
- import { NewTool } from '../tools/newTool';
-
- server.registerTool('new_tool', new NewTool());
- ```
-
-### 2. Adding Database Features
-
-1. **Define Database Queries**
- ```typescript
- // src/db/queries.ts
- export const newFeatureQueries = {
- getData: `
- SELECT *
- FROM your_table
- WHERE condition = $1
- `,
- updateData: `
- UPDATE your_table
- SET column = $1
- WHERE id = $2
- `
- };
- ```
-
-2. **Implement Database Operations**
- ```typescript
- // src/db/operations.ts
- import { pool } from './connection';
- import { newFeatureQueries } from './queries';
-
- export async function performNewOperation(params: any) {
- const client = await pool.connect();
- try {
- await client.query('BEGIN');
- // Perform operations
- await client.query('COMMIT');
- } catch (error) {
- await client.query('ROLLBACK');
- throw error;
- } finally {
- client.release();
- }
- }
- ```
-
-### 3. Adding Utility Functions
-1. **Create Utility Module**
- ```typescript
- // src/utils/newUtil.ts
- export function newUtilityFunction(input: any): any {
- // Implementation
- }
- ```
+Use Node.js 18 or newer. Tests are written with Vitest and are designed to run deterministically with one worker.
-2. **Add Tests**
- ```typescript
- // tests/utils/newUtil.test.ts
- import { newUtilityFunction } from '../../src/utils/newUtil';
+## Integration Tests
- describe('newUtilityFunction', () => {
- it('should handle valid input', () => {
- // Test implementation
- });
+Real PostgreSQL integration tests are opt-in and require a disposable database:
- it('should handle invalid input', () => {
- // Test implementation
- });
- });
- ```
-
-## Testing
-
-### Unit Tests
-
-```typescript
-// tests/tools/newTool.test.ts
-import { NewTool } from '../../src/tools/newTool';
-
-describe('NewTool', () => {
- let tool: NewTool;
-
- beforeEach(() => {
- tool = new NewTool();
- });
-
- it('should process valid input', async () => {
- const input = {
- param1: 'test',
- param2: 123
- };
-
- const result = await tool.execute(input);
- expect(result.status).toBe('success');
- });
-
- it('should handle errors', async () => {
- const input = {
- param1: 'invalid'
- };
-
- const result = await tool.execute(input);
- expect(result.status).toBe('error');
- });
-});
+```bash
+POSTGRES_MCP_INTEGRATION_CONNECTION_STRING="postgresql://user:pass@localhost:5432/postgres" npm run test:integration
```
-### Integration Tests
+The suite creates and drops a temporary schema. Without `POSTGRES_MCP_INTEGRATION_CONNECTION_STRING`, the integration file is skipped during normal `npm run test:run` and `npm run prepublishOnly`.
-```typescript
-// tests/integration/newTool.test.ts
-import { setupTestDatabase, teardownTestDatabase } from '../helpers';
+GitHub Actions runs the integration suite with a PostgreSQL service on pull requests, pushes to `main`, and release publishing.
-describe('NewTool Integration', () => {
- beforeAll(async () => {
- await setupTestDatabase();
- });
+## Repository Layout
- afterAll(async () => {
- await teardownTestDatabase();
- });
-
- it('should interact with database', async () => {
- // Test implementation
- });
-});
+```text
+src/
+ index.ts server entrypoint, CLI/config, tool registration
+ integration/ opt-in real PostgreSQL integration tests
+ security/ tool-call policy classification and tests
+ tools/ MCP tools and focused tests
+ types/ shared TypeScript types
+ utils/ SQL, connection, and filesystem helpers
+docs/ published documentation
+build/ compiled package output
```
-## Error Handling
+## Change Workflow
-### 1. Custom Error Types
+1. Inspect the existing tool and helper patterns before editing.
+2. Keep new SQL behind typed schemas, identifier quoting, and bind parameters.
+3. Update `src/security/policy.ts` for every new tool or operation.
+4. Add focused tests beside the changed module.
+5. Update [TOOL_SCHEMAS.md](../TOOL_SCHEMAS.md) and relevant docs.
+6. Run verification:
-```typescript
-// src/types/errors.ts
-export class ToolError extends Error {
- constructor(
- message: string,
- public code: string,
- public details?: any
- ) {
- super(message);
- this.name = 'ToolError';
- }
-}
+```bash
+npm run prepublishOnly
+git diff --check
```
-### 2. Error Handling in Tools
-
-```typescript
-try {
- // Tool operation
-} catch (error) {
- if (error instanceof DatabaseError) {
- throw new ToolError(
- 'Database operation failed',
- 'DATABASE_ERROR',
- error
- );
- }
- throw error;
-}
-```
+## Documentation Rules
-## Documentation
+Security-sensitive behavior must be documented where users are most likely to read it:
-### 1. Code Documentation
+- README for defaults and operational posture.
+- SECURITY for sandboxing, approvals, audit events, non-goals, and deployment posture.
+- TOOL_SCHEMAS for exact tool parameters and per-tool caveats.
+- docs/USAGE for common workflows.
+- docs/TECHNICAL for architecture and implementation constraints.
+- docs/DEVELOPER for contribution rules.
-```typescript
-/**
- * Performs analysis of database configuration
- * @param {string} connectionString - PostgreSQL connection string
- * @param {AnalysisOptions} options - Analysis options
- * @returns {Promise} Analysis results
- * @throws {ToolError} When analysis fails
- */
-async function analyzeConfiguration(
- connectionString: string,
- options: AnalysisOptions
-): Promise {
- // Implementation
-}
-```
+Do not document per-tool connection strings as the default path. They are disabled unless `--allow-tool-connection-string` or `POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING=true` is configured.
-### 2. Tool Documentation
+## Release Checklist
-```typescript
-/**
- * @tool new_tool
- * @description Performs new operation on database
- * @input {
- * param1: string,
- * param2?: number,
- * options?: object
- * }
- * @output {
- * status: "success" | "error",
- * data: object,
- * error?: {
- * code: string,
- * message: string
- * }
- * }
- */
+```bash
+npm run prepublishOnly
+git diff --check
+npm pack --dry-run
```
-## Release Process
-
-1. **Version Update**
- ```bash
- npm version patch|minor|major
- ```
-
-2. **Build and Test**
- ```bash
- npm run build
- npm test
- ```
-
-3. **Documentation Update**
- - Update CHANGELOG.md
- - Update API documentation
- - Review README.md
-
-4. **Create Release**
- ```bash
- git tag v1.0.0
- git push origin v1.0.0
- ```
-
-## Best Practices
-
-1. **Code Style**
- - Follow TypeScript best practices
- - Use ESLint rules
- - Maintain consistent formatting
- - Write clear comments
-
-2. **Testing**
- - Write unit tests for new features
- - Include integration tests
- - Maintain test coverage
- - Use meaningful test names
-
-3. **Error Handling**
- - Use custom error types
- - Provide meaningful error messages
- - Include error context
- - Log errors appropriately
-
-4. **Documentation**
- - Document new features
- - Update API documentation
- - Include examples
- - Keep README current
\ No newline at end of file
+`prepublishOnly` verifies tests, production dependency audit status, build outputs, built-CLI startup behavior, tool connection lifecycle cleanup, Docker runtime hardening, MCP stdio smoke behavior, docs/runtime parity, security posture documentation, package contents, and installed package/bin behavior from a generated tarball. Confirm the manual dry-run package output remains consistent with that automated package verifier.
+It also runs `verify:workflows`, which checks that GitHub Actions retain least-privilege CI permissions and the PostgreSQL integration service before release publishing.
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 797e256..ad67d34 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -1,103 +1,34 @@
# PostgreSQL MCP Server Documentation
-## Overview
+Start with the current authoritative docs:
-The PostgreSQL MCP Server is a Model Context Protocol (MCP) server that provides PostgreSQL database management capabilities. This documentation set covers all aspects of using, understanding, and developing the server.
+- [README](../README.md): installation, security modes, runtime options, and feature overview.
+- [Security Posture](../SECURITY.md): sandboxing, approvals, audit events, non-goals, and deployment checklist.
+- [Complete Tool Schema Reference](../TOOL_SCHEMAS.md): all tool parameters, examples, limits, and security notes.
-## Documentation Structure
+Supporting docs:
-### 1. [README](../README.md)
-- Project overview
-- Feature summary
-- Installation instructions
-- Basic usage
-- Security considerations
-- Best practices
+- [Usage Guide](USAGE.md): common hardened workflows.
+- [PostgreSQL Role Templates](POSTGRES_ROLES.md): least-privilege database grants for readonly, writer, schema-admin, and role-admin deployments.
+- [Technical Notes](TECHNICAL.md): architecture, security policy, SQL safety, redaction, filesystem sandbox, and timeout model.
+- [Developer Guide](DEVELOPER.md): contribution workflow, test commands, and security rules for new tools.
+- [Development Guide](DEVELOPMENT.md): setup, change workflow, documentation rules, and release checklist.
-### 2. [Usage Guide](USAGE.md)
-- Detailed tool usage
-- Common patterns
-- Configuration examples
-- Troubleshooting
-- Best practices
-- Common issues and solutions
+## Security Defaults
-### 3. [Technical Documentation](TECHNICAL.md)
-- Architecture overview
-- Tool specifications
-- Implementation details
-- Error handling
-- Performance considerations
-- Security implementation
+The server starts in `readonly` mode. Per-tool connection strings are disabled by default. DML requires `write`, DDL/filesystem/roles require `admin`, arbitrary SQL and raw SQL fragments require `unsafe`, and destructive operations also require `--allow-destructive`.
-### 4. [Development Guide](DEVELOPMENT.md)
-- Development environment setup
-- Project structure
-- Adding new features
-- Testing guidelines
-- Error handling
-- Documentation standards
-- Release process
+Use a fixed server-level PostgreSQL connection string and a least-privilege database role whenever possible. Start from [PostgreSQL Role Templates](POSTGRES_ROLES.md) when provisioning deployment credentials.
-## Quick Start
+## Quick Commands
-1. **Installation**
- ```bash
- npm install postgresql-mcp-server
- ```
+```bash
+npm install
+npm run test:run
+npm run build
+```
-2. **Basic Usage**
- ```typescript
- // Analyze database
- const result = await useMcpTool("postgresql-mcp", "analyze_database", {
- connectionString: "postgresql://user:password@localhost:5432/dbname",
- analysisType: "performance"
- });
- ```
-
-## Tool Reference
-
-### 1. analyze_database
-Analyzes PostgreSQL database configuration and performance.
-- [Technical Specification](TECHNICAL.md#1-analyze_database)
-- [Usage Guide](USAGE.md#1-database-analysis)
-- [Implementation Details](DEVELOPMENT.md#1-creating-a-new-tool)
-
-
-### 2. debug_database
-Helps troubleshoot database issues.
-- [Technical Specification](TECHNICAL.md#2-debug_database)
-- [Usage Guide](USAGE.md#2-database-debugging)
-- [Implementation Details](DEVELOPMENT.md#2-adding-database-features)
-
-## Contributing
-
-See the [Development Guide](DEVELOPMENT.md) for detailed information on:
-- Setting up development environment
-- Code style and standards
-- Testing requirements
-- Documentation guidelines
-- Release process
-
-## License
-
-This project is licensed under the GNU Affero General Public License v3.0 (AGPLv3).
-See the [LICENSE](../LICENSE) file for details.
-
-## Support
-
-- Review the [Usage Guide](USAGE.md) for common issues
-- Check [Technical Documentation](TECHNICAL.md) for implementation details
-- Follow the [Development Guide](DEVELOPMENT.md) for contribution guidelines
-- Submit issues through the project's issue tracker
-
-## Version History
-
-See [CHANGELOG.md](../CHANGELOG.md) for a detailed list of changes.
-
-## Additional Resources
-
-- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
-- [MCP Protocol Documentation](https://modelcontextprotocol.org/docs/)
-- [Node.js Documentation](https://nodejs.org/docs/)
-- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
\ No newline at end of file
+```bash
+POSTGRES_CONNECTION_STRING="postgresql://readonly_user:pass@localhost:5432/app" \
+ npx @henkey/postgres-mcp-server
+```
diff --git a/docs/POSTGRES_ROLES.md b/docs/POSTGRES_ROLES.md
new file mode 100644
index 0000000..b4c7376
--- /dev/null
+++ b/docs/POSTGRES_ROLES.md
@@ -0,0 +1,123 @@
+# PostgreSQL Role Templates
+
+This server's MCP policy is an application boundary, not a replacement for PostgreSQL privileges. Use one fixed server-level connection string for a role that matches the configured `securityMode`.
+
+The examples below are templates. Run them as a database owner or DBA, replace the names, and scope grants to the schemas and objects the MCP server should actually manage.
+
+## Baseline Principles
+
+- Do not use a superuser role for routine MCP access.
+- Do not give `CREATEDB`, `CREATEROLE`, `BYPASSRLS`, or replication privileges to read or write profiles.
+- Prefer one role per deployment profile: readonly, writer, schema admin, and role admin should be separate credentials.
+- Keep `--security-mode` aligned with the PostgreSQL grants. A `readonly` server should connect as a read-only database role.
+- Use `enabledTools` to hide tools that the connected database role should never exercise.
+- Use connection target allowlists when per-tool connection strings are enabled.
+
+The snippets use psql variables:
+
+```sql
+\set app_db 'app'
+\set app_schema 'public'
+\set app_owner 'app_owner'
+\set mcp_readonly 'mcp_readonly'
+\set mcp_readonly_password 'change-me-readonly'
+\set mcp_writer 'mcp_writer'
+\set mcp_writer_password 'change-me-writer'
+\set mcp_schema_admin 'mcp_schema_admin'
+\set mcp_schema_admin_password 'change-me-schema-admin'
+```
+
+## Readonly Role
+
+Use this with the default `readonly` security mode. It supports schema inspection, analysis, monitoring, and bounded read-only query tools over the granted schemas.
+
+```sql
+CREATE ROLE :"mcp_readonly"
+ LOGIN
+ PASSWORD :'mcp_readonly_password'
+ NOSUPERUSER
+ NOCREATEDB
+ NOCREATEROLE
+ NOREPLICATION
+ NOBYPASSRLS;
+
+GRANT CONNECT ON DATABASE :"app_db" TO :"mcp_readonly";
+GRANT USAGE ON SCHEMA :"app_schema" TO :"mcp_readonly";
+GRANT SELECT ON ALL TABLES IN SCHEMA :"app_schema" TO :"mcp_readonly";
+
+ALTER DEFAULT PRIVILEGES FOR ROLE :"app_owner" IN SCHEMA :"app_schema"
+ GRANT SELECT ON TABLES TO :"mcp_readonly";
+```
+
+Optional monitoring visibility:
+
+```sql
+GRANT pg_monitor TO :"mcp_readonly";
+```
+
+`pg_monitor` exposes broader server activity and statistics. Skip it when the MCP client should only see objects and activity visible to the application role.
+
+## Writer Role
+
+Use this with `--security-mode write` for structured `INSERT`, `UPDATE`, and `DELETE` workflows. It inherits the read profile and adds DML privileges over the selected schema.
+
+```sql
+CREATE ROLE :"mcp_writer"
+ LOGIN
+ PASSWORD :'mcp_writer_password'
+ NOSUPERUSER
+ NOCREATEDB
+ NOCREATEROLE
+ NOREPLICATION
+ NOBYPASSRLS;
+
+GRANT CONNECT ON DATABASE :"app_db" TO :"mcp_writer";
+GRANT USAGE ON SCHEMA :"app_schema" TO :"mcp_writer";
+GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA :"app_schema" TO :"mcp_writer";
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA :"app_schema" TO :"mcp_writer";
+
+ALTER DEFAULT PRIVILEGES FOR ROLE :"app_owner" IN SCHEMA :"app_schema"
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO :"mcp_writer";
+ALTER DEFAULT PRIVILEGES FOR ROLE :"app_owner" IN SCHEMA :"app_schema"
+ GRANT USAGE, SELECT ON SEQUENCES TO :"mcp_writer";
+```
+
+Only grant `TRUNCATE` if a trusted workflow explicitly needs it. Most mutation tools do not require it.
+
+## Schema Admin Role
+
+Use this with `--security-mode admin` only for trusted schema maintenance. This profile can create new objects in the selected schema, but PostgreSQL still requires object ownership or owner-role membership to alter or drop existing objects.
+
+```sql
+CREATE ROLE :"mcp_schema_admin"
+ LOGIN
+ PASSWORD :'mcp_schema_admin_password'
+ NOSUPERUSER
+ NOCREATEDB
+ NOCREATEROLE
+ NOREPLICATION
+ NOBYPASSRLS;
+
+GRANT CONNECT ON DATABASE :"app_db" TO :"mcp_schema_admin";
+GRANT USAGE, CREATE ON SCHEMA :"app_schema" TO :"mcp_schema_admin";
+GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA :"app_schema" TO :"mcp_schema_admin";
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA :"app_schema" TO :"mcp_schema_admin";
+```
+
+For migration-style work on existing application objects, prefer a purpose-built migration role owned by the deployment system. Avoid giving the MCP role ownership of unrelated schemas.
+
+## Role Administration
+
+Tools that create, alter, grant, revoke, or drop roles require `--security-mode admin` and PostgreSQL role administration privileges. Keep that deployment separate from schema or data maintenance.
+
+Recommended controls:
+
+- Use a short-lived credential.
+- Restrict `enabledTools` to role-management tools only.
+- Configure `--allowed-connection-target` for the exact host, database, and role.
+- Enable persistent audit output with `--audit-file`.
+- Avoid `SUPERUSER`; if PostgreSQL `CREATEROLE` is required, treat it as high-risk access and review every generated grant before use.
+
+## Unsafe Mode
+
+`--security-mode unsafe --allow-destructive` can run arbitrary SQL and trusted raw SQL fragments. Do not pair unsafe mode with production superuser credentials. For emergency production maintenance, use a short-lived, task-specific PostgreSQL role and a narrow `enabledTools` list.
diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md
index 76b6816..313e2ed 100644
--- a/docs/TECHNICAL.md
+++ b/docs/TECHNICAL.md
@@ -1,299 +1,131 @@
-# PostgreSQL MCP Server Technical Documentation
-
-## Architecture Overview
-
-The PostgreSQL MCP Server is built on the Model Context Protocol (MCP) framework and provides database management capabilities through a set of specialized tools.
-
-### Core Components
-
-1. **MCP Server**
- - Handles protocol communication
- - Manages tool registration
- - Processes requests/responses
- - Implements error handling
-
-2. **Database Interface**
- - Connection management
- - Query execution
- - Transaction handling
- - Result processing
-
-3. **Analysis Engine**
- - Configuration analysis
- - Performance metrics
- - Security auditing
- - Recommendations generation
-
-## Tool Specifications
-
-### 1. analyze_database
-
-#### Input Schema
-```typescript
-interface AnalyzeDatabaseInput {
- connectionString: string;
- analysisType?: "configuration" | "performance" | "security";
- options?: {
- timeout?: number;
- depth?: "basic" | "detailed" | "comprehensive";
- includeData?: boolean;
- };
-}
-```
+# PostgreSQL MCP Server Technical Notes
-#### Output Schema
-```typescript
-interface AnalysisResult {
- status: "success" | "error";
- timestamp: string;
- duration: number;
- analysis: {
- type: string;
- metrics: Record;
- findings: Array<{
- category: string;
- level: "info" | "warning" | "critical";
- message: string;
- details: any;
- }>;
- recommendations: Array<{
- priority: "low" | "medium" | "high";
- description: string;
- implementation: string;
- impact: string;
- }>;
- };
- error?: {
- code: string;
- message: string;
- details: any;
- };
-}
-```
+## Architecture
-### 2. debug_database
-
-#### Input Schema
-```typescript
-interface DebugDatabaseInput {
- connectionString: string;
- issue: "connection" | "performance" | "locks" | "replication";
- logLevel?: "info" | "debug" | "trace";
- options?: {
- timeout?: number;
- maxResults?: number;
- includeQueries?: boolean;
- collectMetrics?: boolean;
- };
-}
-```
+The server exposes PostgreSQL capabilities through Model Context Protocol tools. Runtime flow is:
-#### Output Schema
-```typescript
-interface DebugResult {
- status: "success" | "error";
- timestamp: string;
- duration: number;
- debug: {
- issue: string;
- context: {
- serverVersion: string;
- currentConnections: number;
- uptime: string;
- };
- findings: Array<{
- type: string;
- severity: "low" | "medium" | "high";
- description: string;
- evidence: any;
- solution?: string;
- }>;
- metrics?: {
- cpu: number;
- memory: number;
- io: {
- read: number;
- write: number;
- };
- connections: number;
- };
- queries?: Array<{
- sql: string;
- duration: number;
- plan?: any;
- stats?: any;
- }>;
- };
- error?: {
- code: string;
- message: string;
- details: any;
- };
-}
-```
+1. CLI/config/environment options are loaded in `src/index.ts`.
+2. MCP tool calls are classified by `src/security/policy.ts`.
+3. Calls blocked by the active security mode or missing destructive opt-in are rejected before database access.
+4. Tool schemas validate input with Zod.
+5. SQL helpers quote identifiers, parameterize values, validate read-only SQL, and redact SQL text.
+6. `DatabaseConnection` manages PostgreSQL pools, runtime timeouts, and sanitized error messages.
+
+## Security Policy
+
+Tool risks are:
+
+- `read`
+- `write`
+- `ddl`
+- `role_admin`
+- `filesystem`
+- `arbitrary_sql`
+- `unclassified`
-## Implementation Details
+Modes allow progressively wider risk:
-### Connection Management
+- `readonly`: `read`
+- `write`: `read`, `write`
+- `admin`: `read`, `write`, `ddl`, `role_admin`, `filesystem`
+- `unsafe`: all risks, including `arbitrary_sql`
-```typescript
-class ConnectionManager {
- private pools: Map;
- private config: ConnectionConfig;
+Destructive calls require `allowDestructive=true` even when the mode allows the risk. Arbitrary SQL is always destructive. Unclassified tools and unclassified operations are not allowed in any mode; new tool operations must be explicitly classified before they can run.
- constructor(config: ConnectionConfig) {
- this.pools = new Map();
- this.config = config;
- }
+## SQL Safety
- async getConnection(connectionString: string): Promise {
- // Implementation
- }
+Identifier inputs are restricted to simple PostgreSQL identifiers and quoted server-side. Values are passed as bind parameters when PostgreSQL syntax allows it.
- async releaseConnection(client: PoolClient): Promise {
- // Implementation
- }
+Structured predicates use:
- private createPool(connectionString: string): Pool {
- // Implementation
- }
-}
+```ts
+type WherePredicate = Record;
```
-### Analysis Engine
+`buildWhereClause` produces parameterized DML predicates. `buildStaticWhereClause` exists for DDL contexts such as partial indexes, where bind parameters are not valid.
-```typescript
-class AnalysisEngine {
- private connection: ConnectionManager;
- private metrics: MetricsCollector;
+Read-only SQL validation accepts one `SELECT`, `WITH`, `VALUES`, or `TABLE` statement without semicolons and scans outside literals/comments for data-changing keywords. Read-only query and EXPLAIN paths also run inside read-only transactions where applicable.
- constructor(connection: ConnectionManager) {
- this.connection = connection;
- this.metrics = new MetricsCollector();
- }
+## Redaction And Sanitization
- async analyzeConfiguration(): Promise {
- // Implementation
- }
+`sanitizeErrorMessage` masks connection-string passwords and redacts SQL literals/comments before errors reach MCP responses or normal logs.
- async analyzePerformance(): Promise {
- // Implementation
- }
+`redactSqlText` is applied to diagnostic SQL surfaces such as:
- async analyzeSecurity(): Promise {
- // Implementation
- }
-}
-```
+- `pg_stat_statements.query`
+- active query and lock text
+- function definitions
+- trigger definitions
+- RLS `USING` and `WITH CHECK` expressions
+- CHECK constraints
+- index definitions
+- column defaults
-### Debug Engine
+Returned table rows, mutation `RETURNING` values, comments, and enum labels are considered user data and are not blanket-redacted.
-```typescript
-class DebugEngine {
- private connection: ConnectionManager;
- private logger: Logger;
+Security boundary denials emit structured audit lines to stderr with the `[MCP Audit]` prefix. Events use a stable `postgres_mcp.security` name and include denial reason, tool name, current security mode, destructive opt-in state, per-tool connection-string state, and policy risk where applicable. They intentionally omit raw request payloads and raw SQL; policy-denial audit events rely on reason codes and risk fields instead of the full human error message.
- constructor(connection: ConnectionManager, logger: Logger) {
- this.connection = connection;
- this.logger = logger;
- }
+`POSTGRES_MCP_AUDIT_FILE`, `--audit-file`, or the tools config `auditFile` key can also append these sanitized events to a JSONL file. File-write failures are logged as `[MCP Audit Error]` without exposing raw request payloads.
- async debugConnection(): Promise {
- // Implementation
- }
+## Connection And Timeout Model
- async debugPerformance(): Promise {
- // Implementation
- }
+`DatabaseConnection` caches PostgreSQL pools by connection string and connection options. Runtime guardrails default to 20 max pool connections, a 30000 ms pool idle timeout, a 2000 ms connection timeout, a 60000 ms PostgreSQL `statement_timeout`, a 65000 ms node-postgres query timeout, a 10000 ms PostgreSQL `lock_timeout`, and a 60000 ms PostgreSQL `idle_in_transaction_session_timeout`. They can be configured with:
- async debugLocks(): Promise {
- // Implementation
- }
+- `POSTGRES_MCP_MAX_CONNECTIONS`
+- `POSTGRES_MCP_IDLE_TIMEOUT_MS`
+- `POSTGRES_MCP_CONNECTION_TIMEOUT_MS`
+- `POSTGRES_MCP_STATEMENT_TIMEOUT_MS`
+- `POSTGRES_MCP_QUERY_TIMEOUT_MS`
+- `POSTGRES_MCP_LOCK_TIMEOUT_MS`
+- `POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS`
+- `--max-connections`
+- `--idle-timeout-ms`
+- `--connection-timeout-ms`
+- `--statement-timeout-ms`
+- `--query-timeout-ms`
+- `--lock-timeout-ms`
+- `--idle-in-transaction-session-timeout-ms`
- async debugReplication(): Promise {
- // Implementation
- }
-}
-```
+`statementTimeoutMs` maps to PostgreSQL `statement_timeout`, `queryTimeoutMs` is passed to node-postgres as query timeout, `lockTimeoutMs` maps to PostgreSQL `lock_timeout`, and `idleInTransactionSessionTimeoutMs` maps to PostgreSQL `idle_in_transaction_session_timeout`. Some tools also accept per-call `timeout`.
+
+The singleton `DatabaseConnection` serializes active `connect` to `disconnect` workflows. Concurrent MCP tool calls therefore cannot switch the active pool out from under another in-flight tool call; the next workflow waits until the current one disconnects while cached pools remain reusable.
+
+Per-tool connection string arguments are disabled by default by the server boundary. Prefer a fixed server-level connection string with a least-privilege PostgreSQL role.
+
+Connection target allowlists are configured with `--allowed-connection-target`, `allowedConnectionTargets`, or `POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS`. Patterns use `[user@]host[:port][/database]`, with omitted fields unconstrained and `*` allowed only as a full-field wildcard. Server-level sources are checked during connection resolution; per-tool `connectionString`, `sourceConnectionString`, and `targetConnectionString` values are checked at the request boundary before tool execution.
+
+## Lifecycle
-## Error Handling
-
-### Error Types
-
-```typescript
-enum ErrorCode {
- CONNECTION_ERROR = "CONNECTION_ERROR",
- AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR",
- PERMISSION_ERROR = "PERMISSION_ERROR",
- TIMEOUT_ERROR = "TIMEOUT_ERROR",
- VALIDATION_ERROR = "VALIDATION_ERROR",
- INTERNAL_ERROR = "INTERNAL_ERROR"
-}
-
-interface McpError {
- code: ErrorCode;
- message: string;
- details?: any;
- cause?: Error;
-}
+Server shutdown is idempotent. `SIGINT`, `SIGTERM`, explicit `close()`, and failed startup cleanup all share the same cleanup path, remove registered signal handlers, close cached PostgreSQL pools, and close the MCP server. Repeated process signals share one signal-triggered shutdown promise so cleanup and process exit are not duplicated. Cleanup attempts both pool and MCP-server shutdown even if one side fails; signal-triggered cleanup exits with a nonzero code if cleanup fails.
+
+## Container Runtime
+
+The Docker image uses a multi-stage build. The build stage installs full dependencies and compiles TypeScript; the runtime stage copies only `package.json`, production `node_modules`, `build/`, and the entrypoint. The container runs as the non-root `node` user.
+
+The Docker entrypoint does not assemble a shell command from environment variables. It directly executes `node build/index.js "$@"`; runtime configuration remains handled by the server's CLI and environment parser.
+
+## Filesystem Sandbox
+
+Import/export paths are resolved through `src/utils/filesystem.ts`.
+
+Rules:
+
+- A workspace directory must be configured.
+- Paths must stay inside that workspace after resolution.
+- Only `.json` and `.csv` files are accepted by migration tools.
+- File size is capped by `POSTGRES_MCP_MAX_FILE_BYTES` or `--max-file-bytes`.
+- Successful responses avoid echoing resolved absolute host paths.
+
+## Testing Strategy
+
+Most tests mock `DatabaseConnection` and assert generated SQL, parameter arrays, policy classification, output caps, and redaction behavior. Use:
+
+```bash
+npm run test:run
```
-### Error Handling Strategy
-
-1. **Connection Errors**
- - Retry with exponential backoff
- - Pool connection management
- - Timeout handling
- - Circuit breaker implementation
-
-2. **Query Errors**
- - SQL error parsing
- - Query timeout handling
- - Transaction management
- - Resource cleanup
-
-3. **Analysis Errors**
- - Partial result handling
- - Metric collection failures
- - Analysis timeout management
- - Resource constraints
-
-## Performance Considerations
-
-1. **Connection Pooling**
- - Pool size configuration
- - Connection lifecycle
- - Resource limits
- - Idle timeout management
-
-2. **Query Optimization**
- - Prepared statements
- - Query planning
- - Result streaming
- - Batch operations
-
-3. **Resource Management**
- - Memory usage
- - CPU utilization
- - I/O operations
- - Network bandwidth
-
-## Security Implementation
-
-1. **Authentication**
- - Connection string validation
- - Credential management
- - SSL/TLS configuration
- - Role-based access
-
-2. **Query Safety**
- - SQL injection prevention
- - Query sanitization
- - Parameter binding
- - Resource limits
-
-3. **Audit Logging**
- - Operation logging
- - Access tracking
- - Error logging
- - Security events
\ No newline at end of file
+Build verification is:
+
+```bash
+npm run build
+```
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 31eb230..df3bdde 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -1,202 +1,224 @@
# PostgreSQL MCP Server Usage Guide
-## Overview
+This guide covers the current hardened toolset. The complete parameter reference is in [TOOL_SCHEMAS.md](../TOOL_SCHEMAS.md).
+
+## Security Baseline
+
+Run the server with one fixed connection string and the least-privilege PostgreSQL role that matches the job. Per-tool `connectionString`, `sourceConnectionString`, and `targetConnectionString` arguments are disabled by default and should only be enabled for trusted local development.
+Explicit per-tool, CLI, and `POSTGRES_CONNECTION_STRING` values must be non-empty strings. Blank higher-priority connection strings fail validation instead of falling back to lower-priority sources.
+For deployments that enable per-tool connection strings, configure a connection target allowlist. Use `--allowed-connection-target`, the tools config `allowedConnectionTargets` array, or `POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS` with patterns such as `readonly@db.internal:5432/app` or `*@localhost:*/dev`.
+Use [PostgreSQL Role Templates](POSTGRES_ROLES.md) to provision readonly, writer, schema-admin, and role-admin credentials that match the selected MCP mode.
+
+Security modes are enforced before a tool reaches PostgreSQL:
+
+- `readonly`: schema inspection, analysis, monitoring, and bounded SELECT-style query tools.
+- `write`: readonly operations plus structured data mutations.
+- `admin`: write operations plus DDL, roles, RLS, filesystem import/export, and migration-style tools.
+- `unsafe`: arbitrary SQL and raw SQL fragments.
+
+Destructive operations, including arbitrary SQL and drops/resets, also require `--allow-destructive`.
+
+Runtime configuration precedence is CLI options, then the tools config file, then environment variables. Explicit `false` values in the tools config override enabling environment variables.
+
+If a tools config path is provided, startup fails when that file is unreadable, malformed, non-object, incorrectly typed, or contains unknown keys. This avoids accidentally falling back to a broader tool surface or silently ignoring typoed security settings.
+
+CLI options:
+
+- `--version`
+- `--connection-string`
+- `--tools-config`
+- `--security-mode`
+- `--allow-destructive`
+- `--allow-tool-connection-string`
+- `--workspace-dir`
+- `--audit-file`
+- `--max-connections`
+- `--idle-timeout-ms`
+- `--connection-timeout-ms`
+- `--max-file-bytes`
+- `--statement-timeout-ms`
+- `--query-timeout-ms`
+- `--lock-timeout-ms`
+- `--idle-in-transaction-session-timeout-ms`
+- `--allowed-connection-target`
+
+Environment variables:
+
+- `POSTGRES_CONNECTION_STRING`
+- `POSTGRES_TOOLS_CONFIG`
+- `POSTGRES_MCP_SECURITY_MODE`
+- `POSTGRES_MCP_ALLOW_DESTRUCTIVE`
+- `POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING`
+- `POSTGRES_MCP_WORKSPACE_DIR`
+- `POSTGRES_MCP_AUDIT_FILE`
+- `POSTGRES_MCP_MAX_CONNECTIONS`
+- `POSTGRES_MCP_IDLE_TIMEOUT_MS`
+- `POSTGRES_MCP_CONNECTION_TIMEOUT_MS`
+- `POSTGRES_MCP_MAX_FILE_BYTES`
+- `POSTGRES_MCP_STATEMENT_TIMEOUT_MS`
+- `POSTGRES_MCP_QUERY_TIMEOUT_MS`
+- `POSTGRES_MCP_LOCK_TIMEOUT_MS`
+- `POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS`
+- `POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS`
+- `POSTGRES_MCP_DEBUG_SQL`
+
+Tools config keys:
+
+- `enabledTools`
+- `securityMode`
+- `allowDestructive`
+- `allowToolConnectionString`
+- `workspaceDir`
+- `auditFile`
+- `maxConnections`
+- `idleTimeoutMillis`
+- `connectionTimeoutMillis`
+- `maxFileBytes`
+- `statementTimeoutMs`
+- `queryTimeoutMs`
+- `lockTimeoutMs`
+- `idleInTransactionSessionTimeoutMs`
+- `allowedConnectionTargets`
+
+```bash
+POSTGRES_CONNECTION_STRING="postgresql://readonly_user:pass@localhost:5432/app" \
+ npx @henkey/postgres-mcp-server
+
+POSTGRES_CONNECTION_STRING="postgresql://writer:pass@localhost:5432/app" \
+ npx @henkey/postgres-mcp-server --security-mode write
+
+POSTGRES_CONNECTION_STRING="postgresql://admin:pass@localhost:5432/app" \
+ npx @henkey/postgres-mcp-server --security-mode admin --allow-destructive
+```
+
+Connection target patterns use `[user@]host[:port][/database]`. Omitted fields are unconstrained, and `*` is accepted only as a full-field wildcard. When an allowlist is set, connection strings must be PostgreSQL URL or keyword-style strings with an explicit `host` or `hostaddr`.
+
+## Common Read Workflows
+
+Analyze database health:
+
+```json
+{
+ "analysisType": "performance",
+ "schema": "public"
+}
+```
+
+Inspect schema:
+
+```json
+{
+ "operation": "get_info",
+ "schema": "public",
+ "tableName": "users"
+}
+```
+
+Run a bounded SELECT:
+
+```json
+{
+ "operation": "select",
+ "query": "SELECT id, email FROM users WHERE active = $1",
+ "parameters": [true],
+ "limit": 100,
+ "timeout": 30000
+}
+```
+
+`pg_execute_query` validates that the input is one read-only statement and wraps `select` in an outer `LIMIT`. `count` and `exists` evaluate the supplied SELECT without the select row limit, so use database permissions and timeouts appropriately.
+
+Explain a query:
+
+```json
+{
+ "operation": "explain",
+ "query": "SELECT * FROM users WHERE email = $1",
+ "format": "json",
+ "analyze": false
+}
+```
-The PostgreSQL MCP Server provides tools for managing and analyzing PostgreSQL databases through the Model Context Protocol (MCP). This guide covers common usage patterns and examples.
+EXPLAIN tools accept one read-only statement and run inside a read-only transaction. `analyze: true` still executes the supplied query and therefore requires `--security-mode unsafe --allow-destructive`.
-## Tools
+## Structured Mutations
-### 1. Database Analysis
+Mutations require `--security-mode write` or higher. Update and delete operations require a structured `where` predicate to prevent accidental table-wide changes.
-The `analyze_database` tool provides comprehensive database analysis:
+```json
+{
+ "operation": "update",
+ "table": "users",
+ "data": { "active": false },
+ "where": {
+ "last_login": { "lt": "2024-01-01" },
+ "active": true
+ },
+ "returning": ["id", "active"],
+ "maxReturningRows": 100
+}
+```
+
+Supported structured operators are `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`, and `isNull`.
+
+Legacy string `where` clauses are rejected. The explicit `rawWhere` field remains as a trusted local/admin escape hatch, is classified as arbitrary SQL, and requires `--security-mode unsafe --allow-destructive`.
+
+## Filesystem Import And Export
-```typescript
-const result = await useMcpTool("postgresql-mcp", "analyze_database", {
- connectionString: "postgresql://user:password@localhost:5432/dbname",
- analysisType: "performance"
-});
+Export/import tools require `--security-mode admin`, a configured workspace directory, and `.json` or `.csv` paths inside that workspace.
+
+```bash
+npx @henkey/postgres-mcp-server \
+ --security-mode admin \
+ --allow-destructive \
+ --workspace-dir ./mcp-workspace \
+ --connection-string "postgresql://admin:pass@localhost:5432/app"
+```
+
+```json
+{
+ "tableName": "users",
+ "schema": "public",
+ "outputPath": "exports/users.json",
+ "format": "json",
+ "where": { "active": true },
+ "limit": 1000
+}
```
-#### Analysis Types
-
-1. **Configuration Analysis**
- ```typescript
- {
- "connectionString": "postgresql://user:password@localhost:5432/dbname",
- "analysisType": "configuration"
- }
- ```
- - Reviews database settings
- - Checks configuration parameters
- - Validates security settings
- - Suggests optimizations
-
-2. **Performance Analysis**
- ```typescript
- {
- "connectionString": "postgresql://user:password@localhost:5432/dbname",
- "analysisType": "performance"
- }
- ```
- - Query performance metrics
- - Index usage statistics
- - Buffer cache hit ratios
- - Table statistics
-
-3. **Security Analysis**
- ```typescript
- {
- "connectionString": "postgresql://user:password@localhost:5432/dbname",
- "analysisType": "security"
- }
- ```
- - Permission audits
- - Security configuration review
- - SSL/TLS settings
- - Access control validation
-
-### 2. Database Debugging
-
-The `debug_database` tool helps troubleshoot issues:
-
-```typescript
-const debug = await useMcpTool("postgresql-mcp", "debug_database", {
- connectionString: "postgresql://user:password@localhost:5432/dbname",
- issue: "performance",
- logLevel: "debug"
-});
+The server rejects paths outside the workspace, rejects empty explicit workspace directory values, caps file size with `POSTGRES_MCP_MAX_FILE_BYTES` or `--max-file-bytes`, requires JSON imports to be arrays of objects, and always applies row limits to export and copy-between-databases reads. Export/copy `limit` defaults to 1000 and is capped at 100000.
+
+## Arbitrary SQL
+
+Use `pg_execute_sql` only for trusted administrative workflows. It requires `--security-mode unsafe --allow-destructive`.
+
+```json
+{
+ "sql": "ALTER TABLE users ADD COLUMN last_seen timestamp",
+ "expectRows": false,
+ "transactional": true,
+ "timeout": 60000
+}
```
-#### Debug Categories
-
-1. **Connection Issues**
- ```typescript
- {
- "connectionString": "postgresql://user:password@localhost:5432/dbname",
- "issue": "connection"
- }
- ```
- - Network connectivity
- - Authentication problems
- - SSL/TLS issues
- - Connection pooling
-
-2. **Performance Issues**
- ```typescript
- {
- "connectionString": "postgresql://user:password@localhost:5432/dbname",
- "issue": "performance"
- }
- ```
- - Slow queries
- - Resource utilization
- - Index effectiveness
- - Query planning
-
-3. **Lock Issues**
- ```typescript
- {
- "connectionString": "postgresql://user:password@localhost:5432/dbname",
- "issue": "locks"
- }
- ```
- - Transaction deadlocks
- - Lock contention
- - Blocking queries
- - Lock timeouts
-
-4. **Replication Issues**
- ```typescript
- {
- "connectionString": "postgresql://user:password@localhost:5432/dbname",
- "issue": "replication"
- }
- ```
- - Replication lag
- - Streaming status
- - WAL issues
- - Synchronization problems
-
-## Best Practices
-
-1. **Connection Management**
- - Use connection pooling
- - Implement timeouts
- - Handle reconnection logic
- - Monitor connection counts
-
-2. **Security**
- - Use SSL/TLS connections
- - Implement least privilege access
- - Regular security audits
- - Credential rotation
-
-3. **Performance**
- - Regular performance analysis
- - Index maintenance
- - Query optimization
- - Resource monitoring
-
-4. **Error Handling**
- - Implement proper error handling
- - Log relevant information
- - Set appropriate timeouts
- - Handle edge cases
-
-## Common Issues
-
-1. **Connection Failures**
- ```typescript
- // Check connection with debug logging
- const debug = await useMcpTool("postgresql-mcp", "debug_database", {
- connectionString: "postgresql://user:password@localhost:5432/dbname",
- issue: "connection",
- logLevel: "debug"
- });
- ```
-
-2. **Performance Problems**
- ```typescript
- // Analyze performance with detailed metrics
- const analysis = await useMcpTool("postgresql-mcp", "analyze_database", {
- connectionString: "postgresql://user:password@localhost:5432/dbname",
- analysisType: "performance"
- });
- ```
-
-3. **Security Concerns**
- ```typescript
- // Run security audit
- const security = await useMcpTool("postgresql-mcp", "analyze_database", {
- connectionString: "postgresql://user:password@localhost:5432/dbname",
- analysisType: "security"
- });
- ```
-
-## Troubleshooting
-
-1. **Tool Connection Issues**
- - Verify MCP server status
- - Check network connectivity
- - Validate configuration
- - Review error logs
-
-2. **Analysis Failures**
- - Check database permissions
- - Verify connection string
- - Review PostgreSQL logs
- - Check resource availability
-
-3. **Setup Problems**
- - Verify system requirements
- - Check installation paths
- - Review environment variables
- - Validate configurations
-
-## Support
-
-For issues and questions:
-1. Check documentation
-2. Review error logs
-3. Search issue tracker
-4. Submit detailed bug reports
\ No newline at end of file
+`maxRows` limits the MCP response payload only. It does not reduce database work for arbitrary SQL.
+
+Multi-statement arbitrary SQL must be transactional, must set `expectRows: false`, and cannot use `parameters`. Use a single statement or CTE when bind parameters are needed.
+
+## Output And Error Handling
+
+The server sanitizes errors and redacts SQL text in diagnostics and catalog metadata by default. Query rows, mutation `RETURNING` data, comments, and enum values are intentionally returned as user data, so connect with roles scoped to what the client is allowed to see.
+
+Security-boundary denials are also logged to stderr as `[MCP Audit]` JSON events. These events are intended for operational monitoring and contain sanitized metadata only, such as denial reason, tool name, mode, risk, and connection-string-presence flags. They do not include raw SQL, full request payloads, or connection-string passwords. Configure `POSTGRES_MCP_AUDIT_FILE`, `--audit-file`, or `auditFile` to append the same sanitized events to a JSONL file.
+
+Configure runtime guardrails with:
+
+- `POSTGRES_MCP_MAX_CONNECTIONS` or `--max-connections` to override the default 20 pool connections
+- `POSTGRES_MCP_IDLE_TIMEOUT_MS` or `--idle-timeout-ms` to override the default 30000 ms pool idle timeout
+- `POSTGRES_MCP_CONNECTION_TIMEOUT_MS` or `--connection-timeout-ms` to override the default 2000 ms connection timeout
+- `POSTGRES_MCP_STATEMENT_TIMEOUT_MS` or `--statement-timeout-ms` to override the default 60000 ms PostgreSQL `statement_timeout`
+- `POSTGRES_MCP_QUERY_TIMEOUT_MS` or `--query-timeout-ms` to override the default 65000 ms node-postgres query timeout
+- `POSTGRES_MCP_LOCK_TIMEOUT_MS` or `--lock-timeout-ms` to override the default 10000 ms PostgreSQL `lock_timeout`
+- `POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS` or `--idle-in-transaction-session-timeout-ms` to override the default 60000 ms PostgreSQL `idle_in_transaction_session_timeout`
+- `POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS` or repeated `--allowed-connection-target`
+- `POSTGRES_MCP_DEBUG_SQL=true` only for trusted local debugging, because it may log raw SQL and bind values
diff --git a/package-lock.json b/package-lock.json
index f5df059..0861083 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,22 +1,22 @@
{
"name": "@henkey/postgres-mcp-server",
- "version": "1.0.7",
+ "version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@henkey/postgres-mcp-server",
- "version": "1.0.7",
+ "version": "2.0.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"commander": "^12.1.0",
- "pg": "^8.16.3",
- "pg-monitor": "^3.0.0",
- "pg-query-stream": "^4.2.4",
- "zod": "^3.24.4",
- "zod-to-json-schema": "^3.24.5"
+ "pg": "^8.21.0",
+ "pg-monitor": "^3.1.0",
+ "pg-query-stream": "^4.15.0",
+ "zod": "^3.25.76",
+ "zod-to-json-schema": "^3.25.2"
},
"bin": {
"postgres-mcp": "build/index.js"
@@ -24,9 +24,6 @@
"devDependencies": {
"@types/node": "^20.11.17",
"@types/pg": "^8.10.2",
- "@typescript-eslint/eslint-plugin": "^7.1.0",
- "@typescript-eslint/parser": "^7.1.0",
- "eslint": "^8.57.0",
"nodemon": "^3.0.3",
"typescript": "^5.3.3",
"vitest": "^3.2.4"
@@ -477,93 +474,6 @@
"node": ">=18"
}
},
- "node_modules/@eslint-community/eslint-utils": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
- "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-visitor-keys": "^3.4.3"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- },
- "peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
- }
- },
- "node_modules/@eslint-community/regexpp": {
- "version": "4.12.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
- "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
- }
- },
- "node_modules/@eslint/eslintrc": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
- "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ajv": "^6.12.4",
- "debug": "^4.3.2",
- "espree": "^9.6.0",
- "globals": "^13.19.0",
- "ignore": "^5.2.0",
- "import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
- "strip-json-comments": "^3.1.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/@eslint/eslintrc/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/@eslint/js": {
- "version": "8.57.1",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
- "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- }
- },
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -576,68 +486,6 @@
"hono": "^4"
}
},
- "node_modules/@humanwhocodes/config-array": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
- "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
- "deprecated": "Use @eslint/config-array instead",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanwhocodes/object-schema": "^2.0.3",
- "debug": "^4.3.1",
- "minimatch": "^3.0.5"
- },
- "engines": {
- "node": ">=10.10.0"
- }
- },
- "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
- "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.22"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@humanwhocodes/object-schema": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
- "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
- "deprecated": "Use @eslint/object-schema instead",
- "dev": true,
- "license": "BSD-3-Clause"
- },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
@@ -707,66 +555,10 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
- "node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
- "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
- },
- "node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": {
- "version": "3.25.2",
- "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
- "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
- "license": "ISC",
- "peerDependencies": {
- "zod": "^3.25.28 || ^4"
- }
- },
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz",
- "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+ "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
"cpu": [
"arm"
],
@@ -778,9 +570,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz",
- "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+ "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
"cpu": [
"arm64"
],
@@ -792,9 +584,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz",
- "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+ "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
"cpu": [
"arm64"
],
@@ -806,9 +598,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz",
- "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+ "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
"cpu": [
"x64"
],
@@ -820,9 +612,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz",
- "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+ "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
"cpu": [
"arm64"
],
@@ -834,9 +626,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz",
- "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+ "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
"cpu": [
"x64"
],
@@ -848,9 +640,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz",
- "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+ "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
"cpu": [
"arm"
],
@@ -862,9 +654,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz",
- "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+ "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
"cpu": [
"arm"
],
@@ -876,9 +668,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz",
- "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+ "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
"cpu": [
"arm64"
],
@@ -890,9 +682,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz",
- "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+ "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
"cpu": [
"arm64"
],
@@ -903,10 +695,24 @@
"linux"
]
},
- "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz",
- "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==",
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+ "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+ "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
"cpu": [
"loong64"
],
@@ -917,10 +723,24 @@
"linux"
]
},
- "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz",
- "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==",
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+ "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+ "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
"cpu": [
"ppc64"
],
@@ -932,9 +752,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz",
- "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+ "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
"cpu": [
"riscv64"
],
@@ -946,9 +766,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz",
- "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+ "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
"cpu": [
"riscv64"
],
@@ -960,9 +780,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz",
- "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+ "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
"cpu": [
"s390x"
],
@@ -974,9 +794,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz",
- "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
"cpu": [
"x64"
],
@@ -988,9 +808,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz",
- "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+ "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
"cpu": [
"x64"
],
@@ -1001,10 +821,38 @@
"linux"
]
},
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+ "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+ "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz",
- "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+ "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
"cpu": [
"arm64"
],
@@ -1016,9 +864,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz",
- "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+ "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
"cpu": [
"ia32"
],
@@ -1029,10 +877,24 @@
"win32"
]
},
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz",
- "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+ "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
"cpu": [
"x64"
],
@@ -1068,359 +930,97 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "20.17.16",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.16.tgz",
- "integrity": "sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==",
+ "version": "20.19.41",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
+ "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~6.19.2"
+ "undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
- "version": "8.11.11",
- "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.11.tgz",
- "integrity": "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==",
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
- "pg-types": "^4.0.1"
- }
- },
- "node_modules/@types/pg/node_modules/pg-types": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz",
- "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pg-int8": "1.0.1",
- "pg-numeric": "1.0.2",
- "postgres-array": "~3.0.1",
- "postgres-bytea": "~3.0.0",
- "postgres-date": "~2.1.0",
- "postgres-interval": "^3.0.0",
- "postgres-range": "^1.1.1"
- },
- "engines": {
- "node": ">=10"
+ "pg-types": "^2.2.0"
}
},
- "node_modules/@types/pg/node_modules/postgres-array": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz",
- "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@types/pg/node_modules/postgres-bytea": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
- "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
- "obuf": "~1.1.2"
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
},
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/@types/pg/node_modules/postgres-date": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
- "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@types/pg/node_modules/postgres-interval": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz",
- "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/@typescript-eslint/eslint-plugin": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
- "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==",
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "7.18.0",
- "@typescript-eslint/type-utils": "7.18.0",
- "@typescript-eslint/utils": "7.18.0",
- "@typescript-eslint/visitor-keys": "7.18.0",
- "graphemer": "^1.4.0",
- "ignore": "^5.3.1",
- "natural-compare": "^1.4.0",
- "ts-api-utils": "^1.3.0"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
},
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
+ "url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^7.0.0",
- "eslint": "^8.56.0"
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
- "typescript": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
"optional": true
}
}
},
- "node_modules/@typescript-eslint/parser": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz",
- "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
- "license": "BSD-2-Clause",
+ "license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "7.18.0",
- "@typescript-eslint/types": "7.18.0",
- "@typescript-eslint/typescript-estree": "7.18.0",
- "@typescript-eslint/visitor-keys": "7.18.0",
- "debug": "^4.3.4"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
+ "tinyrainbow": "^2.0.0"
},
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.56.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/@typescript-eslint/scope-manager": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz",
- "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==",
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "7.18.0",
- "@typescript-eslint/visitor-keys": "7.18.0"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
},
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/type-utils": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz",
- "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/typescript-estree": "7.18.0",
- "@typescript-eslint/utils": "7.18.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^1.3.0"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.56.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/types": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz",
- "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/typescript-estree": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz",
- "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "@typescript-eslint/types": "7.18.0",
- "@typescript-eslint/visitor-keys": "7.18.0",
- "debug": "^4.3.4",
- "globby": "^11.1.0",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^1.3.0"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/utils": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz",
- "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.4.0",
- "@typescript-eslint/scope-manager": "7.18.0",
- "@typescript-eslint/types": "7.18.0",
- "@typescript-eslint/typescript-estree": "7.18.0"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.56.0"
- }
- },
- "node_modules/@typescript-eslint/visitor-keys": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz",
- "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "7.18.0",
- "eslint-visitor-keys": "^3.4.3"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@ungap/structured-clone": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
- "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/@vitest/expect": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
- "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/chai": "^5.2.2",
- "@vitest/spy": "3.2.4",
- "@vitest/utils": "3.2.4",
- "chai": "^5.2.0",
- "tinyrainbow": "^2.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/mocker": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
- "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "3.2.4",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.17"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/pretty-format": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
- "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyrainbow": "^2.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/runner": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
- "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/utils": "3.2.4",
- "pathe": "^2.0.3",
- "strip-literal": "^3.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
+ "url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
@@ -1479,46 +1079,6 @@
"node": ">= 0.6"
}
},
- "node_modules/acorn": {
- "version": "8.14.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
- "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-jsx": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
- "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
@@ -1558,32 +1118,6 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1598,23 +1132,6 @@
"node": ">= 8"
}
},
- "node_modules/argparse": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
- "license": "Python-2.0"
- },
- "node_modules/array-union": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
- "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1626,11 +1143,14 @@
}
},
"node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
},
"node_modules/binary-extensions": {
"version": "2.3.0",
@@ -1646,20 +1166,20 @@
}
},
"node_modules/body-parser": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
- "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
- "content-type": "^2.0.0",
+ "content-type": "^1.0.5",
"debug": "^4.4.3",
- "http-errors": "^2.0.1",
- "iconv-lite": "^0.7.2",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
- "qs": "^6.15.2",
- "raw-body": "^3.0.2",
- "type-is": "^2.1.0"
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
@@ -1669,27 +1189,17 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/body-parser/node_modules/content-type": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
- "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
"node_modules/brace-expansion": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
@@ -1753,16 +1263,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/callsites": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
- "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/chai": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz",
@@ -1780,23 +1280,6 @@
"node": ">=18"
}
},
- "node_modules/chalk": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
- "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
"node_modules/check-error": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@@ -1845,26 +1328,6 @@
"node": ">= 6"
}
},
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@@ -1874,13 +1337,6 @@
"node": ">=18"
}
},
- "node_modules/concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -1979,13 +1435,6 @@
"node": ">=6"
}
},
- "node_modules/deep-is": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
- "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1995,32 +1444,6 @@
"node": ">= 0.8"
}
},
- "node_modules/dir-glob": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
- "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-type": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/doctrine": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
- "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "esutils": "^2.0.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2135,184 +1558,6 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
- "node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/eslint": {
- "version": "8.57.1",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
- "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
- "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
- "@eslint-community/regexpp": "^4.6.1",
- "@eslint/eslintrc": "^2.1.4",
- "@eslint/js": "8.57.1",
- "@humanwhocodes/config-array": "^0.13.0",
- "@humanwhocodes/module-importer": "^1.0.1",
- "@nodelib/fs.walk": "^1.2.8",
- "@ungap/structured-clone": "^1.2.0",
- "ajv": "^6.12.4",
- "chalk": "^4.0.0",
- "cross-spawn": "^7.0.2",
- "debug": "^4.3.2",
- "doctrine": "^3.0.0",
- "escape-string-regexp": "^4.0.0",
- "eslint-scope": "^7.2.2",
- "eslint-visitor-keys": "^3.4.3",
- "espree": "^9.6.1",
- "esquery": "^1.4.2",
- "esutils": "^2.0.2",
- "fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^6.0.1",
- "find-up": "^5.0.0",
- "glob-parent": "^6.0.2",
- "globals": "^13.19.0",
- "graphemer": "^1.4.0",
- "ignore": "^5.2.0",
- "imurmurhash": "^0.1.4",
- "is-glob": "^4.0.0",
- "is-path-inside": "^3.0.3",
- "js-yaml": "^4.1.0",
- "json-stable-stringify-without-jsonify": "^1.0.1",
- "levn": "^0.4.1",
- "lodash.merge": "^4.6.2",
- "minimatch": "^3.1.2",
- "natural-compare": "^1.4.0",
- "optionator": "^0.9.3",
- "strip-ansi": "^6.0.1",
- "text-table": "^0.2.0"
- },
- "bin": {
- "eslint": "bin/eslint.js"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-scope": {
- "version": "7.2.2",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
- "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "esrecurse": "^4.3.0",
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
- "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/eslint/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/espree": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
- "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.9.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^3.4.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/esquery": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
- "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "estraverse": "^5.1.0"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/esrecurse": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
- "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -2323,16 +1568,6 @@
"@types/estree": "^1.0.0"
}
},
- "node_modules/esutils": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
- "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -2355,9 +1590,9 @@
}
},
"node_modules/eventsource-parser": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
- "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
+ "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -2440,88 +1675,21 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
- "node_modules/fast-glob": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
- "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.8"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/fast-glob/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/fast-json-stable-stringify": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
- "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-levenshtein": {
- "version": "2.0.6",
- "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
- "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/fastify"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/fastify"
- }
- ],
- "license": "BSD-3-Clause"
- },
- "node_modules/fastq": {
- "version": "1.18.0",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
- "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
- "node_modules/file-entry-cache": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
- "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flat-cache": "^3.0.4"
- },
- "engines": {
- "node": "^10.12.0 || >=12.0.0"
- }
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
},
"node_modules/fill-range": {
"version": "7.1.1",
@@ -2557,45 +1725,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/find-up": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
- "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "locate-path": "^6.0.0",
- "path-exists": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/flat-cache": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
- "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flatted": "^3.2.9",
- "keyv": "^4.5.3",
- "rimraf": "^3.0.2"
- },
- "engines": {
- "node": "^10.12.0 || >=12.0.0"
- }
- },
- "node_modules/flatted": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz",
- "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -2614,13 +1743,6 @@
"node": ">= 0.8"
}
},
- "node_modules/fs.realpath": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
- "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2682,102 +1804,6 @@
"node": ">= 0.4"
}
},
- "node_modules/glob": {
- "version": "7.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
- "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "deprecated": "Glob versions prior to v9 are no longer supported",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- },
- "engines": {
- "node": "*"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/glob-parent": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
- "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.3"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/glob/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/glob/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/globals": {
- "version": "13.24.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
- "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "type-fest": "^0.20.2"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/globby": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
- "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array-union": "^2.1.0",
- "dir-glob": "^3.0.1",
- "fast-glob": "^3.2.9",
- "ignore": "^5.2.0",
- "merge2": "^1.4.1",
- "slash": "^3.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -2790,23 +1816,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/has-flag": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
- "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -2820,9 +1829,9 @@
}
},
"node_modules/hasown": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
- "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -2876,16 +1885,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/ignore": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
- "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -2893,45 +1892,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/import-fresh": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
- "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "parent-module": "^1.0.0",
- "resolve-from": "^4.0.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
- "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.19"
- }
- },
- "node_modules/inflight": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
- "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
- "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -3002,16 +1962,6 @@
"node": ">=0.12.0"
}
},
- "node_modules/is-path-inside": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
- "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -3040,93 +1990,12 @@
"dev": true,
"license": "MIT"
},
- "node_modules/js-yaml": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
- "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
- "node_modules/json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
- "node_modules/json-stable-stringify-without-jsonify": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
- "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/keyv": {
- "version": "4.5.4",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
- "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "json-buffer": "3.0.1"
- }
- },
- "node_modules/levn": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
- "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1",
- "type-check": "~0.4.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/locate-path": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
- "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-locate": "^5.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/lodash.merge": {
- "version": "4.6.2",
- "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
- "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/loupe": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz",
@@ -3174,30 +2043,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
- },
- "engines": {
- "node": ">=8.6"
- }
- },
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -3224,16 +2069,16 @@
}
},
"node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.5"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -3246,9 +2091,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -3264,13 +2109,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/natural-compare": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
- "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -3281,16 +2119,16 @@
}
},
"node_modules/nodemon": {
- "version": "3.1.10",
- "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
- "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
+ "version": "3.1.14",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
+ "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^4",
"ignore-by-default": "^1.0.1",
- "minimatch": "^3.1.2",
+ "minimatch": "^10.2.1",
"pstree.remy": "^1.1.8",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
@@ -3309,17 +2147,6 @@
"url": "https://opencollective.com/nodemon"
}
},
- "node_modules/nodemon/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
"node_modules/nodemon/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
@@ -3330,19 +2157,6 @@
"node": ">=4"
}
},
- "node_modules/nodemon/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
"node_modules/nodemon/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -3375,107 +2189,37 @@
"node": ">=0.10.0"
}
},
- "node_modules/object-inspect": {
- "version": "1.13.4",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
- "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/obuf": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
- "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/on-finished": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
- "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
- "license": "MIT",
- "dependencies": {
- "ee-first": "1.1.1"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "license": "ISC",
- "dependencies": {
- "wrappy": "1"
- }
- },
- "node_modules/optionator": {
- "version": "0.9.4",
- "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
- "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "deep-is": "^0.1.3",
- "fast-levenshtein": "^2.0.6",
- "levn": "^0.4.1",
- "prelude-ls": "^1.2.1",
- "type-check": "^0.4.0",
- "word-wrap": "^1.2.5"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/p-limit": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
- "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
- "dev": true,
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
- "dependencies": {
- "yocto-queue": "^0.1.0"
- },
"engines": {
- "node": ">=10"
+ "node": ">= 0.4"
},
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/p-locate": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
- "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
- "dev": true,
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
- "p-limit": "^3.0.2"
+ "ee-first": "1.1.1"
},
"engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "node": ">= 0.8"
}
},
- "node_modules/parent-module": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
- "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
- "dev": true,
- "license": "MIT",
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
"dependencies": {
- "callsites": "^3.0.0"
- },
- "engines": {
- "node": ">=6"
+ "wrappy": "1"
}
},
"node_modules/parseurl": {
@@ -3487,26 +2231,6 @@
"node": ">= 0.8"
}
},
- "node_modules/path-exists": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
- "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/path-is-absolute": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
- "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -3526,16 +2250,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/path-type": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
- "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -3554,14 +2268,14 @@
}
},
"node_modules/pg": {
- "version": "8.16.3",
- "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
- "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
+ "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
- "pg-connection-string": "^2.9.1",
- "pg-pool": "^3.10.1",
- "pg-protocol": "^1.10.3",
+ "pg-connection-string": "^2.13.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
@@ -3569,7 +2283,7 @@
"node": ">= 16.0.0"
},
"optionalDependencies": {
- "pg-cloudflare": "^1.2.7"
+ "pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
@@ -3581,22 +2295,22 @@
}
},
"node_modules/pg-cloudflare": {
- "version": "1.2.7",
- "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
- "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
- "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
+ "version": "2.13.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
+ "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-cursor": {
- "version": "2.14.6",
- "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.14.6.tgz",
- "integrity": "sha512-PColx8rSw5kINWDZTj/bg5I4ABh1I/5KsnW9oYw+OYwvEd5G0u67mkvAQo1RziH2pAFjFaPZXNu9FWah9WPneA==",
+ "version": "2.20.0",
+ "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.20.0.tgz",
+ "integrity": "sha512-HP/EbUafheaUOs7DxlG6tda/rhmsX2hCTJJJ+gCnhljGyNEs6pBHddbNuomlW3DqEhP3zYD+GqBWkYnJPIZ4tA==",
"license": "MIT",
"peerDependencies": {
"pg": "^8"
@@ -3612,49 +2326,39 @@
}
},
"node_modules/pg-monitor": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/pg-monitor/-/pg-monitor-3.0.0.tgz",
- "integrity": "sha512-62jezmq3lR+lKCIsi9BXVg8Fxv+JG5LtaAuUmex5EVnBPlvAU7Ad6dOiQXHtH1xNh/Oy6Hux36k8uIjZWNeWtQ==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/pg-monitor/-/pg-monitor-3.1.0.tgz",
+ "integrity": "sha512-giK0h52AOO/v8iu6hZCdZ/X9W8oAM9Dm1VReQQtki532X8g4z1LVIm4Z/3cGvDcETWW+Ty0FrtU8iTrGFYIZfA==",
"license": "MIT",
"dependencies": {
- "picocolors": "^1.1.1"
+ "picocolors": "1.1.1"
},
"engines": {
- "node": ">=14"
- }
- },
- "node_modules/pg-numeric": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
- "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=4"
+ "node": ">=16"
}
},
"node_modules/pg-pool": {
- "version": "3.10.1",
- "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
- "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
- "version": "1.10.3",
- "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
- "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
+ "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-query-stream": {
- "version": "4.9.6",
- "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.9.6.tgz",
- "integrity": "sha512-fJALA+mw8RrnajWQdWDdK8Yo9soI3qqOH9cpepWRegXi7UCEi9AArgPmjM9tkVUd6Y/G7kbr728RRpgT+ya72g==",
+ "version": "4.15.0",
+ "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.15.0.tgz",
+ "integrity": "sha512-hyCs0PaOyCWqC90N9vyHL2wVNyR3OjnqFrLvgX74Pyh0JihTKUywpPyhKnuVp9TCCcGz7Fc9LdtxoBU+3nv10A==",
"license": "MIT",
"dependencies": {
- "pg-cursor": "^2.14.6"
+ "pg-cursor": "^2.20.0"
},
"peerDependencies": {
"pg": "^8"
@@ -3692,13 +2396,13 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=8.6"
+ "node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
@@ -3714,9 +2418,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.6",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -3734,7 +2438,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -3781,23 +2485,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/postgres-range": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz",
- "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/prelude-ls": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
- "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -3818,16 +2505,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -3843,27 +2520,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -3910,48 +2566,10 @@
"node": ">=0.10.0"
}
},
- "node_modules/resolve-from": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
- "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/reusify": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
- "node_modules/rimraf": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
- "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
- "deprecated": "Rimraf versions prior to v4 are no longer supported",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/rollup": {
- "version": "4.44.2",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz",
- "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
+ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3965,26 +2583,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.44.2",
- "@rollup/rollup-android-arm64": "4.44.2",
- "@rollup/rollup-darwin-arm64": "4.44.2",
- "@rollup/rollup-darwin-x64": "4.44.2",
- "@rollup/rollup-freebsd-arm64": "4.44.2",
- "@rollup/rollup-freebsd-x64": "4.44.2",
- "@rollup/rollup-linux-arm-gnueabihf": "4.44.2",
- "@rollup/rollup-linux-arm-musleabihf": "4.44.2",
- "@rollup/rollup-linux-arm64-gnu": "4.44.2",
- "@rollup/rollup-linux-arm64-musl": "4.44.2",
- "@rollup/rollup-linux-loongarch64-gnu": "4.44.2",
- "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2",
- "@rollup/rollup-linux-riscv64-gnu": "4.44.2",
- "@rollup/rollup-linux-riscv64-musl": "4.44.2",
- "@rollup/rollup-linux-s390x-gnu": "4.44.2",
- "@rollup/rollup-linux-x64-gnu": "4.44.2",
- "@rollup/rollup-linux-x64-musl": "4.44.2",
- "@rollup/rollup-win32-arm64-msvc": "4.44.2",
- "@rollup/rollup-win32-ia32-msvc": "4.44.2",
- "@rollup/rollup-win32-x64-msvc": "4.44.2",
+ "@rollup/rollup-android-arm-eabi": "4.60.4",
+ "@rollup/rollup-android-arm64": "4.60.4",
+ "@rollup/rollup-darwin-arm64": "4.60.4",
+ "@rollup/rollup-darwin-x64": "4.60.4",
+ "@rollup/rollup-freebsd-arm64": "4.60.4",
+ "@rollup/rollup-freebsd-x64": "4.60.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+ "@rollup/rollup-linux-arm64-musl": "4.60.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+ "@rollup/rollup-linux-loong64-musl": "4.60.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-musl": "4.60.4",
+ "@rollup/rollup-openbsd-x64": "4.60.4",
+ "@rollup/rollup-openharmony-arm64": "4.60.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+ "@rollup/rollup-win32-x64-gnu": "4.60.4",
+ "@rollup/rollup-win32-x64-msvc": "4.60.4",
"fsevents": "~2.3.2"
}
},
@@ -4004,30 +2627,6 @@
"node": ">= 18"
}
},
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
- },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -4035,9 +2634,9 @@
"license": "MIT"
},
"node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"dev": true,
"license": "ISC",
"bin": {
@@ -4120,14 +2719,14 @@
}
},
"node_modules/side-channel": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
- "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
- "object-inspect": "^1.13.4",
- "side-channel-list": "^1.0.1",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -4211,16 +2810,6 @@
"node": ">=10"
}
},
- "node_modules/slash": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
- "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4263,32 +2852,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/strip-json-comments": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
- "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/strip-literal": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz",
@@ -4302,26 +2865,6 @@
"url": "https://github.com/sponsors/antfu"
}
},
- "node_modules/supports-color": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
- "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/text-table": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
- "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -4337,14 +2880,14 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
- "version": "0.2.14",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
- "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "fdir": "^6.4.4",
- "picomatch": "^4.0.2"
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -4354,11 +2897,14 @@
}
},
"node_modules/tinyglobby/node_modules/fdir": {
- "version": "6.4.6",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
- "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -4368,19 +2914,6 @@
}
}
},
- "node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
@@ -4443,45 +2976,6 @@
"nodetouch": "bin/nodetouch.js"
}
},
- "node_modules/ts-api-utils": {
- "version": "1.4.3",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
- "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=16"
- },
- "peerDependencies": {
- "typescript": ">=4.2.0"
- }
- },
- "node_modules/type-check": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
- "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/type-fest": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
- "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
- "dev": true,
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
@@ -4514,9 +3008,9 @@
}
},
"node_modules/typescript": {
- "version": "5.7.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
- "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4535,9 +3029,9 @@
"license": "MIT"
},
"node_modules/undici-types": {
- "version": "6.19.8",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
- "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
@@ -4550,16 +3044,6 @@
"node": ">= 0.8"
}
},
- "node_modules/uri-js": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
- "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "punycode": "^2.1.0"
- }
- },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -4570,9 +3054,9 @@
}
},
"node_modules/vite": {
- "version": "6.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
- "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4682,19 +3166,6 @@
}
}
},
- "node_modules/vite/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
@@ -4768,19 +3239,6 @@
}
}
},
- "node_modules/vitest/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4813,16 +3271,6 @@
"node": ">=8"
}
},
- "node_modules/word-wrap": {
- "version": "1.2.5",
- "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
- "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -4838,35 +3286,22 @@
"node": ">=0.4"
}
},
- "node_modules/yocto-queue": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
- "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/zod": {
- "version": "3.24.4",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
- "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
- "version": "3.24.5",
- "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz",
- "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==",
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
- "zod": "^3.24.1"
+ "zod": "^3.25.28 || ^4"
}
}
}
diff --git a/package.json b/package.json
index dc7ebeb..6c0b715 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@henkey/postgres-mcp-server",
- "version": "1.0.7",
+ "version": "2.0.0",
"description": "A Model Context Protocol (MCP) server that provides comprehensive PostgreSQL database management capabilities for AI assistants",
"main": "build/index.js",
"types": "build/index.d.ts",
@@ -11,6 +11,9 @@
"files": [
"build/",
"README.md",
+ "SECURITY.md",
+ "docs/",
+ "scripts/",
"LICENSE",
"TOOL_SCHEMAS.md"
],
@@ -21,9 +24,23 @@
"build": "tsc && node -e \"const fs = require('fs'); const path = 'build/index.js'; const stats = fs.statSync(path); fs.chmodSync(path, stats.mode | parseInt('755', 8));\"",
"start": "node build/index.js",
"dev": "tsc -w & nodemon build/index.js",
- "lint": "eslint . --ext .ts",
+ "lint": "tsc --noEmit",
"test": "vitest",
- "prepublishOnly": "npm run build",
+ "test:run": "vitest run --pool=threads --maxWorkers=1 --minWorkers=1",
+ "test:integration": "vitest run src/integration --pool=threads --maxWorkers=1 --minWorkers=1",
+ "verify:audit": "node scripts/verify-audit.mjs",
+ "verify:build": "node scripts/verify-build.mjs",
+ "verify:cli": "node scripts/verify-cli.mjs",
+ "verify:connection-lifecycle": "node scripts/verify-connection-lifecycle.mjs",
+ "verify:connection-lifecycle-selftest": "node scripts/verify-connection-lifecycle-selftest.mjs",
+ "verify:docs": "node scripts/verify-docs.mjs",
+ "verify:docker": "node scripts/verify-docker.mjs",
+ "verify:mcp": "node scripts/verify-mcp.mjs",
+ "verify:package": "node scripts/verify-package.mjs",
+ "verify:packed-install": "node scripts/verify-packed-install.mjs",
+ "verify:security-docs": "node scripts/verify-security-docs.mjs",
+ "verify:workflows": "node scripts/verify-workflows.mjs",
+ "prepublishOnly": "npm run test:run && npm run build && npm run verify:audit && npm run verify:build && npm run verify:cli && npm run verify:connection-lifecycle && npm run verify:connection-lifecycle-selftest && npm run verify:docs && npm run verify:security-docs && npm run verify:docker && npm run verify:mcp && npm run verify:workflows && npm run verify:package && npm run verify:packed-install",
"postinstall": "echo 'postgres-mcp-server installed successfully! Run with: npx @henkey/postgres-mcp-server'"
},
"keywords": [
@@ -55,20 +72,23 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"commander": "^12.1.0",
- "pg": "^8.16.3",
- "pg-monitor": "^3.0.0",
- "pg-query-stream": "^4.2.4",
- "zod": "^3.24.4",
- "zod-to-json-schema": "^3.24.5"
+ "pg": "^8.21.0",
+ "pg-monitor": "^3.1.0",
+ "pg-query-stream": "^4.15.0",
+ "zod": "^3.25.76",
+ "zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@types/node": "^20.11.17",
"@types/pg": "^8.10.2",
- "@typescript-eslint/eslint-plugin": "^7.1.0",
- "@typescript-eslint/parser": "^7.1.0",
- "eslint": "^8.57.0",
"nodemon": "^3.0.3",
"typescript": "^5.3.3",
"vitest": "^3.2.4"
+ },
+ "overrides": {
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.10",
+ "rollup": "^4.59.0",
+ "vite": "6.4.2"
}
}
diff --git a/scripts/verify-audit.mjs b/scripts/verify-audit.mjs
new file mode 100644
index 0000000..6fe95e1
--- /dev/null
+++ b/scripts/verify-audit.mjs
@@ -0,0 +1,43 @@
+import { spawnSync } from 'node:child_process';
+
+const command = process.env.npm_execpath ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm';
+const args = process.env.npm_execpath
+ ? [process.env.npm_execpath, 'audit', '--omit=dev', '--audit-level=moderate', '--json', '--cache', '.npm-cache']
+ : ['audit', '--omit=dev', '--audit-level=moderate', '--json', '--cache', '.npm-cache'];
+
+const result = spawnSync(command, args, {
+ encoding: 'utf8',
+ maxBuffer: 10 * 1024 * 1024
+});
+
+let auditReport;
+try {
+ auditReport = result.stdout ? JSON.parse(result.stdout) : undefined;
+} catch {
+ auditReport = undefined;
+}
+
+if (result.status !== 0) {
+ console.error('Production dependency audit failed.');
+ if (auditReport?.vulnerabilities) {
+ for (const [name, vulnerability] of Object.entries(auditReport.vulnerabilities)) {
+ console.error(`- ${name}: ${vulnerability.severity}`);
+ }
+ } else {
+ if (result.stdout) {
+ console.error(result.stdout);
+ }
+ if (result.stderr) {
+ console.error(result.stderr);
+ }
+ }
+ process.exit(result.status ?? 1);
+}
+
+const total = auditReport?.metadata?.vulnerabilities?.total;
+if (total !== 0) {
+ console.error(`Production dependency audit expected 0 vulnerabilities, got ${total}.`);
+ process.exit(1);
+}
+
+console.log('Production dependency audit verified with 0 vulnerabilities.');
diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs
new file mode 100644
index 0000000..29dc109
--- /dev/null
+++ b/scripts/verify-build.mjs
@@ -0,0 +1,82 @@
+import { existsSync, readdirSync } from 'node:fs';
+import { join, relative, sep } from 'node:path';
+
+const buildDir = 'build';
+const sourceDir = 'src';
+const staleOutputs = [];
+const missingOutputs = [];
+
+function walk(directory, onFile) {
+ if (!existsSync(directory)) {
+ return;
+ }
+
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const entryPath = join(directory, entry.name);
+ if (entry.isDirectory()) {
+ walk(entryPath, onFile);
+ continue;
+ }
+
+ onFile(entryPath);
+ }
+}
+
+function collectBuildOutput(entryPath) {
+ const relativeBuildPath = relative(buildDir, entryPath);
+ if (
+ !relativeBuildPath.endsWith('.js') &&
+ !relativeBuildPath.endsWith('.d.ts') &&
+ !relativeBuildPath.endsWith('.js.map')
+ ) {
+ return;
+ }
+
+ const withoutSourceMap = relativeBuildPath.replace(/\.js\.map$/, '.js');
+ const withoutDeclaration = withoutSourceMap.replace(/\.d\.ts$/, '.ts');
+ const sourcePath = join(sourceDir, withoutDeclaration.replace(/\.js$/, '.ts'));
+ if (!existsSync(sourcePath)) {
+ staleOutputs.push(relativeBuildPath.split(sep).join('/'));
+ }
+}
+
+function verifySourceOutput(entryPath) {
+ if (!entryPath.endsWith('.ts') || entryPath.endsWith('.test.ts')) {
+ return;
+ }
+
+ const relativeSourcePath = relative(sourceDir, entryPath);
+ const relativeOutputBase = relativeSourcePath.replace(/\.ts$/, '');
+ const expectedOutputs = [
+ `${relativeOutputBase}.js`,
+ `${relativeOutputBase}.d.ts`,
+ `${relativeOutputBase}.js.map`,
+ ];
+
+ for (const output of expectedOutputs) {
+ if (!existsSync(join(buildDir, output))) {
+ missingOutputs.push(output.split(sep).join('/'));
+ }
+ }
+}
+
+walk(buildDir, collectBuildOutput);
+walk(sourceDir, verifySourceOutput);
+
+if (staleOutputs.length > 0) {
+ console.error('Build output contains files without matching source:');
+ for (const output of staleOutputs) {
+ console.error(`- build/${output}`);
+ }
+}
+
+if (missingOutputs.length > 0) {
+ console.error('Source files are missing expected build outputs:');
+ for (const output of missingOutputs) {
+ console.error(`- build/${output}`);
+ }
+}
+
+if (staleOutputs.length > 0 || missingOutputs.length > 0) {
+ process.exit(1);
+}
diff --git a/scripts/verify-cli.mjs b/scripts/verify-cli.mjs
new file mode 100644
index 0000000..bd7bec0
--- /dev/null
+++ b/scripts/verify-cli.mjs
@@ -0,0 +1,151 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { PACKAGE_VERSION } from '../build/index.js';
+
+const errors = [];
+const postgresEnvKeys = [
+ 'POSTGRES_CONNECTION_STRING',
+ 'POSTGRES_TOOLS_CONFIG',
+ 'POSTGRES_MCP_SECURITY_MODE',
+ 'POSTGRES_MCP_ALLOW_DESTRUCTIVE',
+ 'POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING',
+ 'POSTGRES_MCP_WORKSPACE_DIR',
+ 'POSTGRES_MCP_AUDIT_FILE',
+ 'POSTGRES_MCP_MAX_CONNECTIONS',
+ 'POSTGRES_MCP_IDLE_TIMEOUT_MS',
+ 'POSTGRES_MCP_CONNECTION_TIMEOUT_MS',
+ 'POSTGRES_MCP_MAX_FILE_BYTES',
+ 'POSTGRES_MCP_STATEMENT_TIMEOUT_MS',
+ 'POSTGRES_MCP_QUERY_TIMEOUT_MS',
+ 'POSTGRES_MCP_LOCK_TIMEOUT_MS',
+ 'POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS',
+ 'POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS',
+ 'POSTGRES_MCP_DEBUG_SQL'
+];
+
+function cleanEnv(overrides = {}) {
+ const env = { ...process.env };
+ for (const key of postgresEnvKeys) {
+ delete env[key];
+ }
+ return { ...env, ...overrides };
+}
+
+function runCli(args, options = {}) {
+ return spawnSync(process.execPath, ['build/index.js', ...args], {
+ encoding: 'utf8',
+ timeout: 5000,
+ env: cleanEnv(options.env)
+ });
+}
+
+function expectSuccess(name, result, expectedStdout) {
+ if (result.status !== 0) {
+ errors.push(`${name}: expected exit code 0, got ${result.status}. stderr=${result.stderr}`);
+ return;
+ }
+
+ if (expectedStdout !== undefined && !result.stdout.includes(expectedStdout)) {
+ errors.push(`${name}: expected stdout to include "${expectedStdout}", got ${result.stdout}`);
+ }
+}
+
+function expectFailure(name, result, expectedMessage, forbiddenMessages = []) {
+ if (result.status === 0 || result.status === null) {
+ errors.push(`${name}: expected nonzero exit code, got ${result.status}. stdout=${result.stdout} stderr=${result.stderr}`);
+ return;
+ }
+
+ if (!result.stderr.includes('Failed to run the server:')) {
+ errors.push(`${name}: stderr should include the top-level startup failure prefix. stderr=${result.stderr}`);
+ }
+
+ if (!result.stderr.includes(expectedMessage)) {
+ errors.push(`${name}: stderr should include "${expectedMessage}". stderr=${result.stderr}`);
+ }
+
+ if (result.stderr.includes('Error: Failed to run the server')) {
+ errors.push(`${name}: stderr should not include an unsanitized stack-style Error prefix. stderr=${result.stderr}`);
+ }
+
+ for (const forbiddenMessage of forbiddenMessages) {
+ if (result.stderr.includes(forbiddenMessage)) {
+ errors.push(`${name}: stderr should not include "${forbiddenMessage}". stderr=${result.stderr}`);
+ }
+ }
+}
+
+const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-cli-'));
+
+try {
+ expectSuccess('version', runCli(['--version']), PACKAGE_VERSION);
+ expectSuccess('help', runCli(['--help']), '--allowed-connection-target');
+
+ expectFailure(
+ 'invalid boolean environment',
+ runCli([], { env: { POSTGRES_MCP_ALLOW_DESTRUCTIVE: 'yes' } }),
+ 'POSTGRES_MCP_ALLOW_DESTRUCTIVE must be "true" or "false".'
+ );
+
+ expectFailure(
+ 'invalid numeric environment',
+ runCli([], { env: { POSTGRES_MCP_MAX_FILE_BYTES: '0' } }),
+ 'POSTGRES_MCP_MAX_FILE_BYTES must be a positive integer.'
+ );
+
+ expectFailure(
+ 'invalid connection target allowlist',
+ runCli([], { env: { POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS: 'db.*.internal/app' } }),
+ 'host only supports "*" as a full-field wildcard'
+ );
+
+ expectFailure(
+ 'connection string outside allowlist',
+ runCli([], {
+ env: {
+ POSTGRES_CONNECTION_STRING: 'postgresql://readonly:secret-password@other.internal:5432/app',
+ POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS: 'readonly@db.internal:5432/app'
+ }
+ }),
+ 'is not allowed by the configured connection target allowlist',
+ ['secret-password']
+ );
+
+ const malformedConfigPath = join(tempDir, 'malformed-tools.json');
+ writeFileSync(malformedConfigPath, '{"enabledTools": [');
+ expectFailure(
+ 'malformed tools config',
+ runCli(['--tools-config', malformedConfigPath]),
+ 'Failed to load tools configuration file'
+ );
+
+ const invalidConfigPath = join(tempDir, 'invalid-tools.json');
+ writeFileSync(invalidConfigPath, JSON.stringify({ enabledTools: 'pg_execute_query' }));
+ expectFailure(
+ 'invalid tools config field type',
+ runCli(['--tools-config', invalidConfigPath]),
+ 'enabledTools must be an array of tool names.'
+ );
+
+ const unknownConfigPath = join(tempDir, 'unknown-tools-key.json');
+ writeFileSync(unknownConfigPath, JSON.stringify({ enabledTools: ['pg_execute_query'], securitymode: 'unsafe' }));
+ expectFailure(
+ 'unknown tools config key',
+ runCli(['--tools-config', unknownConfigPath]),
+ 'Unknown tools config key(s): securitymode'
+ );
+} finally {
+ rmSync(tempDir, { recursive: true, force: true });
+}
+
+if (errors.length > 0) {
+ console.error('CLI verification failed:');
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log('CLI startup verification passed.');
diff --git a/scripts/verify-connection-lifecycle-selftest.mjs b/scripts/verify-connection-lifecycle-selftest.mjs
new file mode 100644
index 0000000..8b2414e
--- /dev/null
+++ b/scripts/verify-connection-lifecycle-selftest.mjs
@@ -0,0 +1,110 @@
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+const verifierPath = path.resolve('scripts/verify-connection-lifecycle.mjs');
+const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'postgres-mcp-connection-lifecycle-'));
+
+function writeFixture(directoryName, sourceText) {
+ const directoryPath = path.join(tempRoot, directoryName);
+ fs.mkdirSync(directoryPath, { recursive: true });
+ fs.writeFileSync(path.join(directoryPath, 'fixture.ts'), sourceText);
+ return directoryPath;
+}
+
+function runVerifier(fixtureDirectory) {
+ return spawnSync(process.execPath, [verifierPath, fixtureDirectory], {
+ cwd: process.cwd(),
+ encoding: 'utf8'
+ });
+}
+
+try {
+ const goodFixture = writeFixture('good', `
+ async function execute(input: { connectionString?: string }, getConnectionString: (value?: string) => string) {
+ const db = {
+ connect: async (_connectionString: string) => {},
+ disconnect: async () => {}
+ };
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
+ try {
+ await db.connect(resolvedConnectionString);
+ } finally {
+ await db.disconnect();
+ }
+ }
+ `);
+
+ const directResolverFixture = writeFixture('direct-resolver', `
+ async function execute(input: { connectionString?: string }, getConnectionString: (value?: string) => string) {
+ const db = {
+ connect: async (_connectionString: string) => {},
+ disconnect: async () => {}
+ };
+
+ try {
+ await db.connect(getConnectionString(input.connectionString));
+ } finally {
+ await db.disconnect();
+ }
+ }
+ `);
+
+ const rawConnectFixture = writeFixture('raw-connect', `
+ async function execute(input: { connectionString?: string }, getConnectionString: (value?: string) => string) {
+ const db = {
+ connect: async (_connectionString?: string) => {},
+ disconnect: async () => {}
+ };
+
+ try {
+ await db.connect(input.connectionString);
+ } finally {
+ await db.disconnect();
+ }
+ }
+ `);
+
+ const fakeResolverFixture = writeFixture('fake-resolver', `
+ async function execute(input: { connectionString?: string }, getConnectionString: (value?: string) => string) {
+ const db = {
+ connect: async (_connectionString: string) => {},
+ disconnect: async () => {}
+ };
+
+ try {
+ await db.connect(\`\${getConnectionString.name}:\${input.connectionString}\`);
+ } finally {
+ await db.disconnect();
+ }
+ }
+ `);
+
+ const successFixtures = [goodFixture, directResolverFixture];
+ for (const fixtureDirectory of successFixtures) {
+ const result = runVerifier(fixtureDirectory);
+ if (result.status !== 0) {
+ throw new Error(`Expected fixture ${path.basename(fixtureDirectory)} to pass, got exit ${result.status}:\n${result.stderr || result.stdout}`);
+ }
+ }
+
+ const failureFixtures = [rawConnectFixture, fakeResolverFixture];
+ for (const fixtureDirectory of failureFixtures) {
+ const result = runVerifier(fixtureDirectory);
+ const output = `${result.stderr}\n${result.stdout}`;
+
+ if (result.status === 0) {
+ throw new Error(`Expected fixture ${path.basename(fixtureDirectory)} to fail, but it passed.`);
+ }
+
+ if (!output.includes('must connect with a value resolved through getConnectionString().')) {
+ throw new Error(`Expected fixture ${path.basename(fixtureDirectory)} to report resolver enforcement, got:\n${output}`);
+ }
+ }
+
+ console.log('Connection lifecycle verifier self-test passed.');
+} finally {
+ fs.rmSync(tempRoot, { recursive: true, force: true });
+}
diff --git a/scripts/verify-connection-lifecycle.mjs b/scripts/verify-connection-lifecycle.mjs
new file mode 100644
index 0000000..9ebb0ee
--- /dev/null
+++ b/scripts/verify-connection-lifecycle.mjs
@@ -0,0 +1,200 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import ts from 'typescript';
+
+const toolsDirectory = path.resolve(process.argv[2] ?? 'src/tools');
+const errors = [];
+
+function isMethodCall(callExpression, methodName) {
+ const expression = callExpression.expression;
+ if (!ts.isPropertyAccessExpression(expression)) {
+ return null;
+ }
+
+ if (expression.name.text !== methodName || !ts.isIdentifier(expression.expression)) {
+ return null;
+ }
+
+ return expression.expression.text;
+}
+
+function isAwaitedMethodCall(node, methodName) {
+ if (!ts.isAwaitExpression(node) || !ts.isCallExpression(node.expression)) {
+ return null;
+ }
+
+ return isMethodCall(node.expression, methodName);
+}
+
+function containsNode(container, node) {
+ return node.pos >= container.pos && node.end <= container.end;
+}
+
+function lineAndColumn(sourceFile, node) {
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
+ return `${position.line + 1}:${position.character + 1}`;
+}
+
+function isFunctionLikeWithBody(node) {
+ return (
+ ts.isFunctionDeclaration(node) ||
+ ts.isFunctionExpression(node) ||
+ ts.isArrowFunction(node) ||
+ ts.isMethodDeclaration(node)
+ ) && node.body;
+}
+
+function resolverParameterNames(node) {
+ return node.parameters
+ .filter((parameter) => ts.isIdentifier(parameter.name) && parameter.name.text.startsWith('getConnectionString'))
+ .map((parameter) => parameter.name.text);
+}
+
+function containsResolverCall(node, resolverNames) {
+ let found = false;
+
+ function visit(child) {
+ if (found) {
+ return;
+ }
+
+ if (ts.isCallExpression(child) &&
+ ts.isIdentifier(child.expression) &&
+ resolverNames.includes(child.expression.text)
+ ) {
+ found = true;
+ return;
+ }
+
+ ts.forEachChild(child, visit);
+ }
+
+ visit(node);
+ return found;
+}
+
+function collectResolvedConnectionVariables(functionNode, resolverNames) {
+ const resolvedVariables = new Set();
+
+ if (resolverNames.length === 0) {
+ return resolvedVariables;
+ }
+
+ function visit(node) {
+ if (isFunctionLikeWithBody(node) && node !== functionNode) {
+ return;
+ }
+
+ if (ts.isVariableDeclaration(node) &&
+ ts.isIdentifier(node.name) &&
+ node.initializer &&
+ containsResolverCall(node.initializer, resolverNames)
+ ) {
+ resolvedVariables.add(node.name.text);
+ }
+
+ ts.forEachChild(node, visit);
+ }
+
+ visit(functionNode.body);
+ return resolvedVariables;
+}
+
+function isResolvedConnectionExpression(expression, resolutionContext) {
+ if (!resolutionContext || resolutionContext.resolverNames.length === 0) {
+ return true;
+ }
+
+ if (containsResolverCall(expression, resolutionContext.resolverNames)) {
+ return true;
+ }
+
+ return ts.isIdentifier(expression) && resolutionContext.resolvedVariables.has(expression.text);
+}
+
+function hasAwaitedDisconnect(block, receiverName) {
+ let found = false;
+
+ function visit(node) {
+ if (found) {
+ return;
+ }
+
+ const disconnectReceiver = isAwaitedMethodCall(node, 'disconnect');
+ if (disconnectReceiver === receiverName) {
+ found = true;
+ return;
+ }
+
+ ts.forEachChild(node, visit);
+ }
+
+ visit(block);
+ return found;
+}
+
+function hasMatchingFinally(ancestors, node, receiverName) {
+ return ancestors
+ .filter((ancestor) => ts.isTryStatement(ancestor) && containsNode(ancestor.tryBlock, node))
+ .some((tryStatement) => tryStatement.finallyBlock && hasAwaitedDisconnect(tryStatement.finallyBlock, receiverName));
+}
+
+function verifyFile(filePath) {
+ const sourceText = fs.readFileSync(filePath, 'utf8');
+ const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
+ const displayPath = path.relative(process.cwd(), filePath).replaceAll(path.sep, '/');
+
+ function visit(node, ancestors, resolutionContext) {
+ if (isFunctionLikeWithBody(node)) {
+ const resolverNames = resolverParameterNames(node);
+ const nextResolutionContext = {
+ resolverNames,
+ resolvedVariables: collectResolvedConnectionVariables(node, resolverNames)
+ };
+
+ ts.forEachChild(node, (child) => visit(child, [...ancestors, node], nextResolutionContext));
+ return;
+ }
+
+ if (ts.isCallExpression(node)) {
+ const connectReceiver = isMethodCall(node, 'connect');
+ if (connectReceiver && (!ts.isAwaitExpression(node.parent) || node.parent.expression !== node)) {
+ errors.push(`${displayPath}:${lineAndColumn(sourceFile, node)} awaits every ${connectReceiver}.connect() call.`);
+ }
+
+ if (connectReceiver) {
+ const connectionArgument = node.arguments[0];
+ if (!connectionArgument || !isResolvedConnectionExpression(connectionArgument, resolutionContext)) {
+ errors.push(`${displayPath}:${lineAndColumn(sourceFile, node)} must connect with a value resolved through getConnectionString().`);
+ }
+ }
+ }
+
+ const connectReceiver = isAwaitedMethodCall(node, 'connect');
+ if (connectReceiver && !hasMatchingFinally(ancestors, node, connectReceiver)) {
+ errors.push(`${displayPath}:${lineAndColumn(sourceFile, node)} must wrap ${connectReceiver}.connect() in a try/finally that awaits ${connectReceiver}.disconnect().`);
+ }
+
+ ts.forEachChild(node, (child) => visit(child, [...ancestors, node], resolutionContext));
+ }
+
+ visit(sourceFile, [], null);
+}
+
+for (const entry of fs.readdirSync(toolsDirectory, { withFileTypes: true })) {
+ if (!entry.isFile() || !entry.name.endsWith('.ts') || entry.name.endsWith('.test.ts')) {
+ continue;
+ }
+
+ verifyFile(path.join(toolsDirectory, entry.name));
+}
+
+if (errors.length > 0) {
+ console.error('Connection lifecycle verification failed:');
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log('Connection lifecycle verification passed.');
diff --git a/scripts/verify-docker.mjs b/scripts/verify-docker.mjs
new file mode 100644
index 0000000..d34d1c9
--- /dev/null
+++ b/scripts/verify-docker.mjs
@@ -0,0 +1,84 @@
+import { existsSync, readFileSync } from 'node:fs';
+
+const errors = [];
+
+function requireFile(path) {
+ if (!existsSync(path)) {
+ errors.push(`Missing ${path}.`);
+ return '';
+ }
+
+ return readFileSync(path, 'utf8');
+}
+
+function requireText(path, text, expected) {
+ if (!text.includes(expected)) {
+ errors.push(`${path} must include "${expected}".`);
+ }
+}
+
+function forbidText(path, text, forbidden) {
+ if (text.includes(forbidden)) {
+ errors.push(`${path} must not include "${forbidden}".`);
+ }
+}
+
+function requireNoTrailingWhitespace(path, text) {
+ text.split(/\r?\n/).forEach((line, index) => {
+ if (/[ \t]+$/.test(line)) {
+ errors.push(`${path}:${index + 1} must not contain trailing whitespace.`);
+ }
+ });
+}
+
+const dockerfilePath = 'Dockerfile';
+const entrypointPath = 'docker-entrypoint.sh';
+const dockerignorePath = '.dockerignore';
+const dockerfile = requireFile(dockerfilePath);
+const entrypoint = requireFile(entrypointPath);
+const dockerignore = requireFile(dockerignorePath);
+
+for (const [path, text] of [
+ [dockerfilePath, dockerfile],
+ [entrypointPath, entrypoint],
+ [dockerignorePath, dockerignore]
+]) {
+ requireNoTrailingWhitespace(path, text);
+}
+
+requireText(dockerfilePath, dockerfile, 'FROM node:20-alpine AS build');
+requireText(dockerfilePath, dockerfile, 'FROM node:20-alpine AS runtime');
+requireText(dockerfilePath, dockerfile, 'RUN npm ci --ignore-scripts');
+requireText(dockerfilePath, dockerfile, 'COPY src ./src');
+requireText(dockerfilePath, dockerfile, 'RUN npm run build');
+requireText(dockerfilePath, dockerfile, 'RUN npm prune --omit=dev --ignore-scripts');
+requireText(dockerfilePath, dockerfile, 'ENV NODE_ENV=production');
+requireText(dockerfilePath, dockerfile, 'COPY --from=build --chown=node:node /app/node_modules ./node_modules');
+requireText(dockerfilePath, dockerfile, 'COPY --from=build --chown=node:node /app/build ./build');
+requireText(dockerfilePath, dockerfile, 'USER node');
+requireText(dockerfilePath, dockerfile, 'ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]');
+forbidText(dockerfilePath, dockerfile, 'node:lts');
+forbidText(dockerfilePath, dockerfile, 'COPY . .');
+forbidText(dockerfilePath, dockerfile, 'USER root');
+
+requireText(entrypointPath, entrypoint, '#!/bin/sh');
+requireText(entrypointPath, entrypoint, 'set -e');
+requireText(entrypointPath, entrypoint, 'exec node build/index.js "$@"');
+forbidText(entrypointPath, entrypoint, 'CMD=');
+forbidText(entrypointPath, entrypoint, 'CONNECTION_STRING=');
+forbidText(entrypointPath, entrypoint, 'exec $');
+
+requireText(dockerignorePath, dockerignore, 'node_modules');
+requireText(dockerignorePath, dockerignore, 'build');
+requireText(dockerignorePath, dockerignore, '.git');
+requireText(dockerignorePath, dockerignore, '.env');
+
+if (errors.length > 0) {
+ console.error('Docker verification failed:');
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log('Docker runtime files verified.');
diff --git a/scripts/verify-docs.mjs b/scripts/verify-docs.mjs
new file mode 100644
index 0000000..e6cabeb
--- /dev/null
+++ b/scripts/verify-docs.mjs
@@ -0,0 +1,152 @@
+import fs from 'node:fs';
+import {
+ DEFAULT_CONNECTION_TIMEOUT_MS,
+ DEFAULT_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS,
+ DEFAULT_LOCK_TIMEOUT_MS,
+ DEFAULT_MAX_CONNECTIONS,
+ DEFAULT_POOL_IDLE_TIMEOUT_MS,
+ DEFAULT_QUERY_TIMEOUT_MS,
+ DEFAULT_STATEMENT_TIMEOUT_MS,
+ DOCUMENTED_CLI_OPTIONS,
+ DOCUMENTED_ENVIRONMENT_VARIABLES,
+ DOCUMENTED_TOOLS_CONFIG_KEYS,
+ PACKAGE_VERSION,
+ allTools,
+ createCliProgram
+} from '../build/index.js';
+import { zodToJsonSchema } from 'zod-to-json-schema';
+
+const toolSchemas = fs.readFileSync('TOOL_SCHEMAS.md', 'utf8');
+const readme = fs.readFileSync('README.md', 'utf8');
+const usage = fs.readFileSync('docs/USAGE.md', 'utf8');
+const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+const packageLock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
+const expectedToolCount = allTools.length;
+const errors = [];
+
+function requireText(sourceName, sourceText, expectedText) {
+ if (!sourceText.includes(expectedText)) {
+ errors.push(`${sourceName} must include "${expectedText}".`);
+ }
+}
+
+function documentedToolNames(markdown) {
+ const names = new Set();
+ const toolLines = markdown.matchAll(/^\*\*Tool:\*\*\s*(.+)$/gm);
+
+ for (const match of toolLines) {
+ const codeNames = match[1].matchAll(/`(pg_[a-z_]+)`/g);
+ for (const codeName of codeNames) {
+ names.add(codeName[1]);
+ }
+ }
+
+ return names;
+}
+
+function expectedLongCliOptions() {
+ return createCliProgram().options
+ .map((option) => option.long)
+ .filter(Boolean)
+ .sort();
+}
+
+function requireEverywhere(label, values, sources) {
+ for (const value of values) {
+ for (const [sourceName, sourceText] of Object.entries(sources)) {
+ requireText(sourceName, sourceText, value);
+ }
+ }
+ if (values.length !== new Set(values).size) {
+ errors.push(`${label} contains duplicate entries.`);
+ }
+}
+
+requireText('TOOL_SCHEMAS.md', toolSchemas, `all ${expectedToolCount} tools`);
+requireText('README.md', readme, `${expectedToolCount} powerful tools`);
+requireText('README.md', readme, `All ${expectedToolCount} tool parameters`);
+requireText('README.md', readme, `POSTGRES_MCP_MAX_CONNECTIONS=${DEFAULT_MAX_CONNECTIONS}`);
+requireText('README.md', readme, `POSTGRES_MCP_IDLE_TIMEOUT_MS=${DEFAULT_POOL_IDLE_TIMEOUT_MS}`);
+requireText('README.md', readme, `POSTGRES_MCP_CONNECTION_TIMEOUT_MS=${DEFAULT_CONNECTION_TIMEOUT_MS}`);
+requireText('README.md', readme, `POSTGRES_MCP_STATEMENT_TIMEOUT_MS=${DEFAULT_STATEMENT_TIMEOUT_MS}`);
+requireText('README.md', readme, `POSTGRES_MCP_QUERY_TIMEOUT_MS=${DEFAULT_QUERY_TIMEOUT_MS}`);
+requireText('README.md', readme, `POSTGRES_MCP_LOCK_TIMEOUT_MS=${DEFAULT_LOCK_TIMEOUT_MS}`);
+requireText('README.md', readme, `POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS=${DEFAULT_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS}`);
+requireText('docs/USAGE.md', usage, `default ${DEFAULT_MAX_CONNECTIONS}`);
+requireText('docs/USAGE.md', usage, `default ${DEFAULT_POOL_IDLE_TIMEOUT_MS} ms`);
+requireText('docs/USAGE.md', usage, `default ${DEFAULT_CONNECTION_TIMEOUT_MS} ms`);
+requireText('docs/USAGE.md', usage, `default ${DEFAULT_STATEMENT_TIMEOUT_MS} ms`);
+requireText('docs/USAGE.md', usage, `default ${DEFAULT_QUERY_TIMEOUT_MS} ms`);
+requireText('docs/USAGE.md', usage, `default ${DEFAULT_LOCK_TIMEOUT_MS} ms`);
+requireText('docs/USAGE.md', usage, `default ${DEFAULT_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS} ms`);
+requireText('README.md', readme, 'unknown-key');
+requireText('docs/USAGE.md', usage, 'unknown keys');
+
+const documentedTools = documentedToolNames(toolSchemas);
+const runtimeToolNames = allTools.map((tool) => tool.name);
+
+for (const toolName of runtimeToolNames) {
+ if (!documentedTools.has(toolName)) {
+ errors.push(`TOOL_SCHEMAS.md is missing a **Tool:** entry for ${toolName}.`);
+ }
+}
+
+for (const toolName of documentedTools) {
+ if (!runtimeToolNames.includes(toolName)) {
+ errors.push(`TOOL_SCHEMAS.md documents unknown tool ${toolName}.`);
+ }
+}
+
+for (const tool of allTools) {
+ const schema = zodToJsonSchema(tool.inputSchema);
+ if (schema.type !== 'object') {
+ errors.push(`${tool.name} input schema must convert to a root JSON object.`);
+ }
+
+ if (schema.additionalProperties !== false) {
+ errors.push(`${tool.name} root input schema must reject unknown fields.`);
+ }
+}
+
+const runtimeCliOptions = expectedLongCliOptions();
+const documentedCliOptions = [...DOCUMENTED_CLI_OPTIONS].sort();
+if (JSON.stringify(runtimeCliOptions) !== JSON.stringify(documentedCliOptions)) {
+ errors.push(`DOCUMENTED_CLI_OPTIONS must match createCliProgram long options. Runtime=${runtimeCliOptions.join(', ')} Documented=${documentedCliOptions.join(', ')}`);
+}
+
+requireEverywhere('CLI options', DOCUMENTED_CLI_OPTIONS, {
+ 'README.md': readme,
+ 'docs/USAGE.md': usage
+});
+
+requireEverywhere('environment variables', DOCUMENTED_ENVIRONMENT_VARIABLES, {
+ 'README.md': readme,
+ 'docs/USAGE.md': usage
+});
+
+requireEverywhere('tools config keys', DOCUMENTED_TOOLS_CONFIG_KEYS, {
+ 'README.md': readme,
+ 'docs/USAGE.md': usage
+});
+
+if (packageJson.version !== PACKAGE_VERSION) {
+ errors.push(`PACKAGE_VERSION (${PACKAGE_VERSION}) must match package.json version (${packageJson.version}).`);
+}
+
+if (packageLock.version !== PACKAGE_VERSION) {
+ errors.push(`PACKAGE_VERSION (${PACKAGE_VERSION}) must match package-lock.json root version (${packageLock.version}).`);
+}
+
+if (packageLock.packages?.['']?.version !== PACKAGE_VERSION) {
+ errors.push(`PACKAGE_VERSION (${PACKAGE_VERSION}) must match package-lock.json packages[""].version (${packageLock.packages?.['']?.version}).`);
+}
+
+if (errors.length > 0) {
+ console.error('Documentation verification failed:');
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log(`Documentation verified for ${expectedToolCount} runtime tools, ${DOCUMENTED_CLI_OPTIONS.length} CLI options, ${DOCUMENTED_ENVIRONMENT_VARIABLES.length} environment variables, ${DOCUMENTED_TOOLS_CONFIG_KEYS.length} tools config keys, and package version ${PACKAGE_VERSION}.`);
diff --git a/scripts/verify-mcp.mjs b/scripts/verify-mcp.mjs
new file mode 100644
index 0000000..fbcbc3d
--- /dev/null
+++ b/scripts/verify-mcp.mjs
@@ -0,0 +1,105 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
+import { PACKAGE_VERSION, allTools } from '../build/index.js';
+
+const errors = [];
+const postgresEnvKeys = [
+ 'POSTGRES_CONNECTION_STRING',
+ 'POSTGRES_TOOLS_CONFIG',
+ 'POSTGRES_MCP_SECURITY_MODE',
+ 'POSTGRES_MCP_ALLOW_DESTRUCTIVE',
+ 'POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING',
+ 'POSTGRES_MCP_WORKSPACE_DIR',
+ 'POSTGRES_MCP_AUDIT_FILE',
+ 'POSTGRES_MCP_MAX_CONNECTIONS',
+ 'POSTGRES_MCP_IDLE_TIMEOUT_MS',
+ 'POSTGRES_MCP_CONNECTION_TIMEOUT_MS',
+ 'POSTGRES_MCP_MAX_FILE_BYTES',
+ 'POSTGRES_MCP_STATEMENT_TIMEOUT_MS',
+ 'POSTGRES_MCP_QUERY_TIMEOUT_MS',
+ 'POSTGRES_MCP_LOCK_TIMEOUT_MS',
+ 'POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS',
+ 'POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS',
+ 'POSTGRES_MCP_DEBUG_SQL'
+];
+
+function cleanChildEnvironment() {
+ const env = getDefaultEnvironment();
+ for (const key of postgresEnvKeys) {
+ delete env[key];
+ }
+ return env;
+}
+
+function requireCondition(condition, message) {
+ if (!condition) {
+ errors.push(message);
+ }
+}
+
+async function runSmokeTest() {
+ const client = new Client({
+ name: 'postgres-mcp-smoke-verifier',
+ version: '1.0.0'
+ }, {
+ capabilities: {}
+ });
+ const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: ['build/index.js'],
+ env: cleanChildEnvironment(),
+ stderr: 'ignore'
+ });
+
+ try {
+ await client.connect(transport);
+
+ const serverVersion = client.getServerVersion();
+ requireCondition(serverVersion?.name === 'postgresql-mcp-server', `Expected server name postgresql-mcp-server, got ${serverVersion?.name}.`);
+ requireCondition(serverVersion?.version === PACKAGE_VERSION, `Expected server version ${PACKAGE_VERSION}, got ${serverVersion?.version}.`);
+ requireCondition(!!client.getServerCapabilities()?.tools, 'Expected server to advertise tools capability.');
+
+ const listedTools = await client.listTools({}, { timeout: 5000 });
+ const listedToolNames = listedTools.tools.map((tool) => tool.name);
+ const runtimeToolNames = allTools.map((tool) => tool.name);
+ requireCondition(listedToolNames.length === runtimeToolNames.length, `Expected ${runtimeToolNames.length} listed tools, got ${listedToolNames.length}.`);
+ requireCondition(JSON.stringify(listedToolNames) === JSON.stringify(runtimeToolNames), 'Listed tool names do not match runtime tool order.');
+
+ const executeSqlSchema = listedTools.tools.find((tool) => tool.name === 'pg_execute_sql')?.inputSchema;
+ requireCondition(executeSqlSchema?.additionalProperties === false, 'pg_execute_sql schema should reject unknown input fields.');
+ requireCondition(Array.isArray(executeSqlSchema?.required) && executeSqlSchema.required.includes('sql'), 'pg_execute_sql schema should require sql.');
+
+ const blockedResult = await client.callTool({
+ name: 'pg_execute_query',
+ arguments: {
+ operation: 'select',
+ query: 'SELECT 1',
+ connectionString: 'postgresql://attacker:secret@localhost/postgres'
+ }
+ }, undefined, { timeout: 5000 });
+ const blockedText = blockedResult.content?.[0]?.text ?? '';
+ requireCondition(blockedResult.isError === true, 'Per-tool connection string call should return an MCP tool error result.');
+ requireCondition(blockedText.includes('Per-tool connection string arguments are disabled'), 'Expected per-tool connection string denial text.');
+ requireCondition(blockedText.includes('--connection-string'), 'Expected CLI guidance to preserve --connection-string.');
+ requireCondition(blockedText.includes('--allow-tool-connection-string'), 'Expected CLI guidance to preserve --allow-tool-connection-string.');
+ requireCondition(!blockedText.includes('secret'), 'Denied MCP tool result must not include connection-string password.');
+ } finally {
+ await client.close();
+ }
+}
+
+try {
+ await runSmokeTest();
+} catch (error) {
+ errors.push(error instanceof Error ? error.message : String(error));
+}
+
+if (errors.length > 0) {
+ console.error('MCP stdio verification failed:');
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log('MCP stdio verification passed.');
diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs
new file mode 100644
index 0000000..024f064
--- /dev/null
+++ b/scripts/verify-package.mjs
@@ -0,0 +1,173 @@
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import { join, relative, sep } from 'node:path';
+import { spawnSync } from 'node:child_process';
+
+const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
+const errors = [];
+
+function walk(directory, onFile) {
+ if (!existsSync(directory)) {
+ return;
+ }
+
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const entryPath = join(directory, entry.name);
+ if (entry.isDirectory()) {
+ walk(entryPath, onFile);
+ continue;
+ }
+
+ onFile(entryPath);
+ }
+}
+
+function toPosixPath(filePath) {
+ return filePath.split(sep).join('/');
+}
+
+function runPackDryRun() {
+ const command = process.env.npm_execpath ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm';
+ const args = process.env.npm_execpath
+ ? [process.env.npm_execpath, 'pack', '--dry-run', '--json', '--cache', '.npm-cache']
+ : ['pack', '--dry-run', '--json', '--cache', '.npm-cache'];
+ const result = spawnSync(command, args, {
+ encoding: 'utf8',
+ maxBuffer: 10 * 1024 * 1024
+ });
+
+ if (result.status !== 0) {
+ if (result.stdout) {
+ console.error(result.stdout);
+ }
+ if (result.stderr) {
+ console.error(result.stderr);
+ }
+ const errorDetail = result.error ? `: ${result.error.message}` : '';
+ throw new Error(`npm pack --dry-run failed with exit code ${result.status}${errorDetail}.`);
+ }
+
+ return JSON.parse(result.stdout);
+}
+
+function expectedBuildFilesFromSource() {
+ const expectedFiles = [];
+
+ walk('src', (entryPath) => {
+ if (!entryPath.endsWith('.ts') || entryPath.endsWith('.test.ts')) {
+ return;
+ }
+
+ const relativeSourcePath = toPosixPath(relative('src', entryPath));
+ const outputBase = relativeSourcePath.replace(/\.ts$/, '');
+ expectedFiles.push(`build/${outputBase}.js`);
+ expectedFiles.push(`build/${outputBase}.d.ts`);
+ expectedFiles.push(`build/${outputBase}.js.map`);
+ });
+
+ return expectedFiles;
+}
+
+function scriptFilesFromPackageScripts() {
+ const scriptFiles = new Set();
+
+ for (const script of Object.values(packageJson.scripts ?? {})) {
+ for (const match of script.matchAll(/\bnode\s+(scripts\/[^\s&|]+\.mjs)\b/g)) {
+ scriptFiles.add(match[1]);
+ }
+ }
+
+ return [...scriptFiles].sort();
+}
+
+function matchesForbiddenPattern(filePath) {
+ return filePath.startsWith('src/') ||
+ filePath.startsWith('node_modules/') ||
+ filePath.startsWith('.git/') ||
+ filePath.startsWith('.npm-cache/') ||
+ filePath === 'package-lock.json' ||
+ filePath === 'tsconfig.json' ||
+ filePath.endsWith('.test.ts') ||
+ filePath.endsWith('.test.js') ||
+ filePath.endsWith('.log') ||
+ filePath.startsWith('.env');
+}
+
+const packResults = runPackDryRun();
+if (!Array.isArray(packResults) || packResults.length !== 1) {
+ errors.push('npm pack --dry-run --json must return exactly one package result.');
+}
+
+const packResult = packResults[0] ?? { files: [] };
+if (packResult.name !== packageJson.name) {
+ errors.push(`Pack result name ${packResult.name} does not match package.json name ${packageJson.name}.`);
+}
+if (packResult.version !== packageJson.version) {
+ errors.push(`Pack result version ${packResult.version} does not match package.json version ${packageJson.version}.`);
+}
+
+const packedFiles = new Set((packResult.files ?? []).map((file) => file.path));
+const requiredFiles = [
+ 'package.json',
+ 'README.md',
+ 'SECURITY.md',
+ 'TOOL_SCHEMAS.md',
+ 'LICENSE',
+ packageJson.main,
+ packageJson.types,
+ ...Object.values(packageJson.bin ?? {}),
+ 'docs/INDEX.md',
+ 'docs/USAGE.md',
+ 'docs/POSTGRES_ROLES.md',
+ 'docs/TECHNICAL.md',
+ 'docs/DEVELOPER.md',
+ 'docs/DEVELOPMENT.md',
+ ...scriptFilesFromPackageScripts(),
+ ...expectedBuildFilesFromSource()
+];
+
+for (const requiredFile of new Set(requiredFiles)) {
+ if (!packedFiles.has(requiredFile)) {
+ errors.push(`Package is missing required file ${requiredFile}.`);
+ }
+}
+
+const allowedTopLevelFiles = new Set(['LICENSE', 'README.md', 'SECURITY.md', 'TOOL_SCHEMAS.md', 'package.json']);
+const allowedTopLevelDirectories = new Set(['build', 'docs', 'scripts']);
+
+for (const filePath of packedFiles) {
+ const [topLevel] = filePath.split('/');
+ if (!allowedTopLevelFiles.has(filePath) && !allowedTopLevelDirectories.has(topLevel)) {
+ errors.push(`Package contains unexpected top-level path ${filePath}.`);
+ }
+
+ if (matchesForbiddenPattern(filePath)) {
+ errors.push(`Package contains forbidden development artifact ${filePath}.`);
+ }
+}
+
+for (const filePath of packedFiles) {
+ if (filePath.startsWith('build/') && !filePath.endsWith('.js') && !filePath.endsWith('.d.ts') && !filePath.endsWith('.js.map')) {
+ errors.push(`Build package file has unexpected extension: ${filePath}.`);
+ }
+}
+
+if (packResult.entryCount !== packedFiles.size) {
+ errors.push(`Pack entryCount ${packResult.entryCount} does not match unique file count ${packedFiles.size}.`);
+}
+
+const prepublishOnly = packageJson.scripts?.prepublishOnly ?? '';
+for (const scriptName of Object.keys(packageJson.scripts ?? {}).filter((name) => name.startsWith('verify:')).sort()) {
+ if (!prepublishOnly.includes(`npm run ${scriptName}`)) {
+ errors.push(`prepublishOnly must run ${scriptName}.`);
+ }
+}
+
+if (errors.length > 0) {
+ console.error('Package verification failed:');
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log(`Package verified with ${packedFiles.size} files and no forbidden development artifacts.`);
diff --git a/scripts/verify-packed-install.mjs b/scripts/verify-packed-install.mjs
new file mode 100644
index 0000000..177d7b9
--- /dev/null
+++ b/scripts/verify-packed-install.mjs
@@ -0,0 +1,136 @@
+import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { spawnSync } from 'node:child_process';
+
+const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
+const errors = [];
+
+function npmCommandArgs(args) {
+ return process.env.npm_execpath
+ ? { command: process.execPath, args: [process.env.npm_execpath, ...args] }
+ : { command: process.platform === 'win32' ? 'npm.cmd' : 'npm', args };
+}
+
+function runNpm(args, options = {}) {
+ const command = npmCommandArgs(args);
+ return spawnSync(command.command, command.args, {
+ encoding: 'utf8',
+ maxBuffer: 10 * 1024 * 1024,
+ ...options
+ });
+}
+
+function runNode(args, options = {}) {
+ return spawnSync(process.execPath, args, {
+ encoding: 'utf8',
+ maxBuffer: 10 * 1024 * 1024,
+ ...options
+ });
+}
+
+function runInstalledBin(args, options = {}) {
+ const binName = process.platform === 'win32' ? 'postgres-mcp.cmd' : 'postgres-mcp';
+ const binPath = join(installDir, 'node_modules', '.bin', binName);
+ if (!existsSync(binPath)) {
+ return {
+ status: 1,
+ stdout: '',
+ stderr: `Installed bin not found: ${binPath}`
+ };
+ }
+
+ if (process.platform === 'win32') {
+ return runNpm(['exec', '--', 'postgres-mcp', ...args], options);
+ }
+
+ return spawnSync(binPath, args, {
+ encoding: 'utf8',
+ maxBuffer: 10 * 1024 * 1024,
+ ...options
+ });
+}
+
+function requireSuccess(name, result) {
+ if (result.status !== 0) {
+ const error = result.error ? ` error=${result.error.message}` : '';
+ errors.push(`${name} failed with exit code ${result.status}.${error} stdout=${result.stdout} stderr=${result.stderr}`);
+ return false;
+ }
+
+ return true;
+}
+
+function requireIncludes(name, value, expected) {
+ if (!value.includes(expected)) {
+ errors.push(`${name} should include "${expected}", got ${value}`);
+ }
+}
+
+const tempRoot = mkdtempSync(join(tmpdir(), 'postgres-mcp-pack-install-'));
+const packDir = join(tempRoot, 'pack');
+const installDir = join(tempRoot, 'install');
+const npmCacheDir = join(tempRoot, 'npm-cache');
+mkdirSync(packDir);
+mkdirSync(installDir);
+
+try {
+ const packResult = runNpm(['pack', '--json', '--pack-destination', packDir, '--cache', npmCacheDir]);
+ if (requireSuccess('npm pack', packResult)) {
+ const parsedPackResult = JSON.parse(packResult.stdout);
+ const tarballName = parsedPackResult[0]?.filename;
+ if (!tarballName) {
+ errors.push('npm pack did not return a tarball filename.');
+ } else {
+ const tarballPath = join(packDir, tarballName);
+ writeFileSync(join(installDir, 'package.json'), JSON.stringify({
+ private: true,
+ type: 'module'
+ }, null, 2));
+
+ const installResult = runNpm([
+ 'install',
+ '--ignore-scripts',
+ '--no-audit',
+ '--no-fund',
+ '--cache',
+ npmCacheDir,
+ tarballPath
+ ], { cwd: installDir });
+ if (requireSuccess('npm install packed tarball', installResult)) {
+ const importResult = runNode([
+ '--input-type=module',
+ '-e',
+ "const pkg = await import('@henkey/postgres-mcp-server'); console.log(`${pkg.PACKAGE_VERSION}:${pkg.allTools.length}`);"
+ ], { cwd: installDir });
+ if (requireSuccess('import installed package', importResult)) {
+ requireIncludes('installed package import output', importResult.stdout.trim(), `${packageJson.version}:18`);
+ }
+
+ const versionResult = runInstalledBin(['--version'], { cwd: installDir });
+ if (requireSuccess('installed postgres-mcp --version', versionResult)) {
+ requireIncludes('installed postgres-mcp --version output', versionResult.stdout.trim(), packageJson.version);
+ }
+
+ const helpResult = runInstalledBin(['--help'], { cwd: installDir });
+ if (requireSuccess('installed postgres-mcp --help', helpResult)) {
+ requireIncludes('installed postgres-mcp --help output', helpResult.stdout, '--allowed-connection-target');
+ }
+ }
+ }
+ }
+} catch (error) {
+ errors.push(error instanceof Error ? error.message : String(error));
+} finally {
+ rmSync(tempRoot, { recursive: true, force: true });
+}
+
+if (errors.length > 0) {
+ console.error('Packed install verification failed:');
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log('Packed install verified from generated tarball.');
diff --git a/scripts/verify-security-docs.mjs b/scripts/verify-security-docs.mjs
new file mode 100644
index 0000000..3a7835f
--- /dev/null
+++ b/scripts/verify-security-docs.mjs
@@ -0,0 +1,141 @@
+import { readFileSync } from 'node:fs';
+import {
+ DEFAULT_CONNECTION_TIMEOUT_MS,
+ DEFAULT_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS,
+ DEFAULT_LOCK_TIMEOUT_MS,
+ DEFAULT_MAX_CONNECTIONS,
+ DEFAULT_POOL_IDLE_TIMEOUT_MS,
+ DEFAULT_QUERY_TIMEOUT_MS,
+ DEFAULT_STATEMENT_TIMEOUT_MS,
+ DOCUMENTED_CLI_OPTIONS,
+ DOCUMENTED_ENVIRONMENT_VARIABLES,
+ DOCUMENTED_TOOLS_CONFIG_KEYS
+} from '../build/index.js';
+
+const errors = [];
+const security = readFileSync('SECURITY.md', 'utf8');
+const readme = readFileSync('README.md', 'utf8');
+const docsIndex = readFileSync('docs/INDEX.md', 'utf8');
+const usage = readFileSync('docs/USAGE.md', 'utf8');
+const postgresRoles = readFileSync('docs/POSTGRES_ROLES.md', 'utf8');
+
+function requireText(sourceName, sourceText, expectedText) {
+ if (!sourceText.includes(expectedText)) {
+ errors.push(`${sourceName} must include "${expectedText}".`);
+ }
+}
+
+for (const expected of [
+ 'securityMode',
+ 'allowDestructive',
+ 'allowToolConnectionString',
+ 'enabledTools',
+ 'POSTGRES_MCP_WORKSPACE_DIR',
+ 'POSTGRES_MCP_MAX_FILE_BYTES',
+ 'POSTGRES_MCP_AUDIT_FILE',
+ 'POSTGRES_MCP_MAX_CONNECTIONS',
+ 'POSTGRES_MCP_IDLE_TIMEOUT_MS',
+ 'POSTGRES_MCP_CONNECTION_TIMEOUT_MS',
+ 'POSTGRES_MCP_STATEMENT_TIMEOUT_MS',
+ 'POSTGRES_MCP_QUERY_TIMEOUT_MS',
+ 'POSTGRES_MCP_LOCK_TIMEOUT_MS',
+ 'POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS',
+ 'POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS',
+ '[MCP Audit]',
+ '[MCP Audit Error]',
+ 'JSONL',
+ 'POSTGRES_MCP_DEBUG_SQL',
+ 'readonly',
+ 'write',
+ 'admin',
+ 'unsafe',
+ 'least-privilege PostgreSQL role',
+ `default ${DEFAULT_MAX_CONNECTIONS} max pool connections`,
+ `${DEFAULT_POOL_IDLE_TIMEOUT_MS} ms pool idle timeout`,
+ `${DEFAULT_CONNECTION_TIMEOUT_MS} ms connection timeout`,
+ `default ${DEFAULT_STATEMENT_TIMEOUT_MS} ms statement timeout`,
+ `${DEFAULT_QUERY_TIMEOUT_MS} ms query timeout`,
+ `${DEFAULT_LOCK_TIMEOUT_MS} ms lock timeout`,
+ `${DEFAULT_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS} ms idle-in-transaction timeout`,
+ 'The server does not prompt interactively for approvals',
+ 'The Docker image uses a multi-stage build',
+ 'non-root `node` user',
+ 'Use `enabledTools` to reduce the available surface',
+ 'elevated role attributes',
+ 'broad grants',
+ 'delegable grants'
+]) {
+ requireText('SECURITY.md', security, expected);
+}
+
+for (const option of [
+ '--allow-destructive',
+ '--allow-tool-connection-string',
+ '--allowed-connection-target',
+ '--workspace-dir',
+ '--audit-file',
+ '--max-connections',
+ '--security-mode'
+]) {
+ if (!DOCUMENTED_CLI_OPTIONS.includes(option)) {
+ errors.push(`Runtime DOCUMENTED_CLI_OPTIONS is missing ${option}.`);
+ }
+ requireText('SECURITY.md', security, option);
+}
+
+for (const envName of [
+ 'POSTGRES_CONNECTION_STRING',
+ 'POSTGRES_MCP_ALLOW_DESTRUCTIVE',
+ 'POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING',
+ 'POSTGRES_MCP_AUDIT_FILE',
+ 'POSTGRES_MCP_MAX_CONNECTIONS',
+ 'POSTGRES_MCP_IDLE_TIMEOUT_MS',
+ 'POSTGRES_MCP_CONNECTION_TIMEOUT_MS',
+ 'POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS'
+]) {
+ if (!DOCUMENTED_ENVIRONMENT_VARIABLES.includes(envName)) {
+ errors.push(`Runtime DOCUMENTED_ENVIRONMENT_VARIABLES is missing ${envName}.`);
+ }
+ requireText('SECURITY.md', security, envName);
+}
+
+for (const key of ['enabledTools', 'auditFile', 'allowedConnectionTargets']) {
+ if (!DOCUMENTED_TOOLS_CONFIG_KEYS.includes(key)) {
+ errors.push(`Runtime DOCUMENTED_TOOLS_CONFIG_KEYS is missing ${key}.`);
+ }
+ requireText('SECURITY.md', security, key);
+}
+
+requireText('README.md', readme, './SECURITY.md');
+requireText('README.md', readme, './docs/POSTGRES_ROLES.md');
+requireText('docs/INDEX.md', docsIndex, '[Security Posture](../SECURITY.md)');
+requireText('docs/INDEX.md', docsIndex, '[PostgreSQL Role Templates](POSTGRES_ROLES.md)');
+requireText('docs/USAGE.md', usage, '[PostgreSQL Role Templates](POSTGRES_ROLES.md)');
+requireText('SECURITY.md', security, 'docs/POSTGRES_ROLES.md');
+
+for (const expected of [
+ 'Do not use a superuser role for routine MCP access',
+ 'NOCREATEDB',
+ 'NOCREATEROLE',
+ 'NOBYPASSRLS',
+ 'GRANT CONNECT ON DATABASE',
+ 'GRANT SELECT ON ALL TABLES',
+ 'GRANT SELECT, INSERT, UPDATE, DELETE',
+ 'GRANT USAGE, CREATE ON SCHEMA',
+ 'GRANT pg_monitor',
+ 'short-lived credential',
+ 'Avoid `SUPERUSER`',
+ '--security-mode unsafe --allow-destructive'
+]) {
+ requireText('docs/POSTGRES_ROLES.md', postgresRoles, expected);
+}
+
+if (errors.length > 0) {
+ console.error('Security documentation verification failed:');
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log('Security documentation verified.');
diff --git a/scripts/verify-workflows.mjs b/scripts/verify-workflows.mjs
new file mode 100644
index 0000000..40a8f40
--- /dev/null
+++ b/scripts/verify-workflows.mjs
@@ -0,0 +1,90 @@
+import { existsSync, readFileSync } from 'node:fs';
+
+const errors = [];
+
+function readWorkflow(path) {
+ if (!existsSync(path)) {
+ errors.push(`Missing workflow file ${path}.`);
+ return '';
+ }
+
+ return readFileSync(path, 'utf8');
+}
+
+function requireText(path, text, expected) {
+ if (!text.includes(expected)) {
+ errors.push(`${path} must include "${expected}".`);
+ }
+}
+
+function forbidText(path, text, forbidden) {
+ if (text.includes(forbidden)) {
+ errors.push(`${path} must not include "${forbidden}".`);
+ }
+}
+
+function requireNoTabsOrTrailingWhitespace(path, text) {
+ const lines = text.split(/\r?\n/);
+ lines.forEach((line, index) => {
+ if (line.includes('\t')) {
+ errors.push(`${path}:${index + 1} must not contain tab indentation.`);
+ }
+
+ if (/[ \t]+$/.test(line)) {
+ errors.push(`${path}:${index + 1} must not contain trailing whitespace.`);
+ }
+ });
+}
+
+const ciPath = '.github/workflows/ci.yml';
+const publishPath = '.github/workflows/publish.yml';
+const ci = readWorkflow(ciPath);
+const publish = readWorkflow(publishPath);
+const workflows = [
+ [ciPath, ci],
+ [publishPath, publish]
+];
+
+for (const [path, text] of workflows) {
+ requireNoTabsOrTrailingWhitespace(path, text);
+ forbidText(path, text, 'pull_request_target');
+ requireText(path, text, 'uses: actions/checkout@v4');
+ requireText(path, text, 'uses: actions/setup-node@v4');
+ requireText(path, text, "node-version: '18'");
+ requireText(path, text, 'run: npm ci');
+ requireText(path, text, 'run: npm run lint');
+ requireText(path, text, 'run: npm run prepublishOnly');
+ requireText(path, text, 'run: npm run test:integration');
+ requireText(path, text, 'image: postgres:17-alpine');
+ requireText(path, text, 'POSTGRES_MCP_INTEGRATION_CONNECTION_STRING: postgresql://postgres:postgres@localhost:5432/postgres');
+}
+
+requireText(ciPath, ci, 'pull_request:');
+requireText(ciPath, ci, 'push:');
+requireText(ciPath, ci, ' - main');
+requireText(ciPath, ci, 'permissions:\n contents: read');
+requireText(ciPath, ci, ' verify:');
+requireText(ciPath, ci, ' postgres-integration:');
+requireText(ciPath, ci, 'cache: npm');
+requireText(ciPath, ci, 'run: node build/index.js --help');
+requireText(ciPath, ci, 'run: npm pack --dry-run --cache .npm-cache');
+
+requireText(publishPath, publish, 'release:');
+requireText(publishPath, publish, 'types: [published]');
+requireText(publishPath, publish, 'contents: read');
+requireText(publishPath, publish, 'id-token: write');
+requireText(publishPath, publish, "registry-url: 'https://registry.npmjs.org'");
+requireText(publishPath, publish, 'run: node build/index.js --help');
+requireText(publishPath, publish, 'run: npm pack --dry-run');
+requireText(publishPath, publish, 'run: npm publish --access public --provenance');
+requireText(publishPath, publish, 'NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}');
+
+if (errors.length > 0) {
+ console.error('Workflow verification failed:');
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log('GitHub Actions workflows verified.');
diff --git a/src/index.test.ts b/src/index.test.ts
new file mode 100644
index 0000000..f17f828
--- /dev/null
+++ b/src/index.test.ts
@@ -0,0 +1,1191 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { z } from 'zod';
+import {
+ DOCUMENTED_CLI_OPTIONS,
+ DOCUMENTED_ENVIRONMENT_VARIABLES,
+ DOCUMENTED_TOOLS_CONFIG_KEYS,
+ PACKAGE_VERSION,
+ PostgreSQLServer,
+ allTools,
+ createCliProgram,
+ createRuntimeConfig,
+ isCliEntrypointPath,
+ main,
+ type RuntimeConfig
+} from './index';
+import type { PostgresTool, ToolOutput } from './types/tool';
+import { executeSqlTool } from './tools/data';
+import { DatabaseConnection } from './utils/connection';
+
+function runtimeConfig(overrides: Partial = {}): RuntimeConfig {
+ return {
+ securityPolicy: { mode: 'readonly', allowDestructive: false },
+ allowToolConnectionString: false,
+ connectionString: 'postgresql://server',
+ ...overrides
+ };
+}
+
+function textOutput(text: string): ToolOutput {
+ return {
+ content: [{ type: 'text', text }]
+ };
+}
+
+function createTool(
+ name: string,
+ execute: PostgresTool['execute']
+): PostgresTool {
+ return {
+ name,
+ description: `${name} test tool`,
+ inputSchema: z.object({}),
+ execute
+ };
+}
+
+function getAuditEvents(): Array> {
+ return vi.mocked(console.error).mock.calls
+ .filter((call) => call[0] === '[MCP Audit]')
+ .map((call) => JSON.parse(call[1] as string) as Record);
+}
+
+describe('PostgreSQLServer request boundary', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('blocks per-tool connection strings before executing the tool', async () => {
+ let called = false;
+ const tool = createTool('pg_execute_query', async () => {
+ called = true;
+ return textOutput('should not execute');
+ });
+ const server = new PostgreSQLServer([tool], runtimeConfig(), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_query', {
+ query: 'SELECT 1',
+ connectionString: 'postgresql://attacker'
+ });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Per-tool connection string arguments are disabled');
+ expect(called).toBe(false);
+ expect(getAuditEvents()).toEqual([
+ expect.objectContaining({
+ event: 'postgres_mcp.security',
+ outcome: 'denied',
+ reason: 'per_tool_connection_string_blocked',
+ toolName: 'pg_execute_query',
+ securityMode: 'readonly',
+ hasToolConnectionString: true
+ })
+ ]);
+ });
+
+ it('passes the configured server-level connection string to allowed tools', async () => {
+ const tool = createTool('pg_execute_query', async (args, getConnectionString) => {
+ expect(args).toEqual({ query: 'SELECT 1' });
+ return textOutput(getConnectionString());
+ });
+ const server = new PostgreSQLServer([tool], runtimeConfig(), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_query', { query: 'SELECT 1' });
+
+ expect(result).toEqual(textOutput('postgresql://server'));
+ });
+
+ it('blocks disallowed server-level connection targets before connecting', async () => {
+ const tool = createTool('pg_execute_query', async (_args, getConnectionString) => {
+ return textOutput(getConnectionString());
+ });
+ const server = new PostgreSQLServer([tool], runtimeConfig({
+ connectionString: 'postgresql://readonly:secret@other.internal:5432/app',
+ allowedConnectionTargets: [{
+ source: 'readonly@db.internal:5432/app',
+ user: 'readonly',
+ host: 'db.internal',
+ port: '5432',
+ database: 'app'
+ }]
+ }), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_query', { query: 'SELECT 1' });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Connection target "readonly@other.internal:');
+ expect(result.content[0].text).toContain('/app" is not allowed');
+ expect(result.content[0].text).not.toContain('secret');
+ expect(getAuditEvents()).toEqual([
+ expect.objectContaining({
+ reason: 'connection_target_denied',
+ toolName: 'pg_execute_query',
+ hasToolConnectionString: false
+ })
+ ]);
+ expect(JSON.stringify(getAuditEvents())).not.toContain('secret');
+ });
+
+ it('blocks disallowed per-tool connection targets before executing the tool', async () => {
+ let called = false;
+ const tool = createTool('pg_execute_query', async () => {
+ called = true;
+ return textOutput('should not execute');
+ });
+ const server = new PostgreSQLServer([tool], runtimeConfig({
+ allowToolConnectionString: true,
+ allowedConnectionTargets: [{
+ source: 'readonly@db.internal:5432/app',
+ user: 'readonly',
+ host: 'db.internal',
+ port: '5432',
+ database: 'app'
+ }]
+ }), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_query', {
+ query: 'SELECT 1',
+ connectionString: 'postgresql://readonly:secret@other.internal:5432/app'
+ });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Connection target "readonly@other.internal:');
+ expect(result.content[0].text).toContain('/app" is not allowed');
+ expect(result.content[0].text).not.toContain('secret');
+ expect(called).toBe(false);
+ expect(getAuditEvents()).toEqual([
+ expect.objectContaining({
+ reason: 'connection_target_denied',
+ toolName: 'pg_execute_query',
+ hasToolConnectionString: true
+ })
+ ]);
+ expect(JSON.stringify(getAuditEvents())).not.toContain('secret');
+ });
+
+ it('allows per-tool source and target connection strings when each target is allowlisted', async () => {
+ const tool = createTool('pg_copy_between_databases', async () => textOutput('copy allowed'));
+ const server = new PostgreSQLServer([tool], runtimeConfig({
+ allowToolConnectionString: true,
+ securityPolicy: { mode: 'admin', allowDestructive: true },
+ allowedConnectionTargets: [{
+ source: '*@db.internal:5432/*',
+ user: '*',
+ host: 'db.internal',
+ port: '5432',
+ database: '*'
+ }]
+ }), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_copy_between_databases', {
+ sourceConnectionString: 'postgresql://reader:secret@db.internal:5432/source',
+ targetConnectionString: 'postgresql://writer:secret@db.internal:5432/target',
+ tableName: 'users'
+ });
+
+ expect(result).toEqual(textOutput('copy allowed'));
+ });
+
+ it('fails closed when enabledTools names an unavailable tool', () => {
+ const tool = createTool('pg_execute_query', async () => textOutput('query allowed'));
+
+ expect(() => new PostgreSQLServer([
+ tool
+ ], runtimeConfig({
+ enabledTools: ['pg_execute_query', 'pg_missing_tool']
+ }), { registerSignalHandlers: false })).toThrow('Unknown enabledTools configured: pg_missing_tool');
+ });
+
+ it('does not execute available tools omitted from enabledTools', async () => {
+ const queryTool = createTool('pg_execute_query', async () => textOutput('query allowed'));
+ const mutationTool = createTool('pg_execute_mutation', async () => textOutput('should not execute'));
+ const server = new PostgreSQLServer([
+ queryTool,
+ mutationTool
+ ], runtimeConfig({
+ enabledTools: ['pg_execute_query'],
+ securityPolicy: { mode: 'write', allowDestructive: false }
+ }), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_mutation', {
+ operation: 'insert',
+ tableName: 'users',
+ data: { email: 'user@example.com' }
+ });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('available but not enabled');
+ expect(getAuditEvents()).toEqual([
+ expect.objectContaining({
+ reason: 'tool_not_enabled',
+ toolName: 'pg_execute_mutation',
+ availableButDisabled: true
+ })
+ ]);
+ });
+
+ it('lists the hardened schema contract for every runtime tool', () => {
+ const server = new PostgreSQLServer(allTools, runtimeConfig(), { registerSignalHandlers: false });
+
+ const result = server.listTools();
+ const runtimeToolNames = allTools.map(tool => tool.name);
+ const listedToolNames = result.tools.map(tool => tool.name);
+
+ expect(result.tools).toHaveLength(allTools.length);
+ expect(listedToolNames).toEqual(runtimeToolNames);
+
+ for (const tool of result.tools) {
+ expect(tool.description.length, `${tool.name} description`).toBeGreaterThan(0);
+ expect(tool.inputSchema, `${tool.name} input schema`).toMatchObject({
+ type: 'object',
+ additionalProperties: false
+ });
+ }
+ });
+
+ it('lists only enabled tools when an allow-list is configured', () => {
+ const server = new PostgreSQLServer(allTools, runtimeConfig({
+ enabledTools: ['pg_execute_query', 'pg_monitor_database']
+ }), { registerSignalHandlers: false });
+
+ expect(server.listTools().tools.map(tool => tool.name)).toEqual([
+ 'pg_execute_query',
+ 'pg_monitor_database'
+ ]);
+ });
+
+ it('exposes current pg_execute_sql guardrails in listed JSON schema', () => {
+ const server = new PostgreSQLServer(allTools, runtimeConfig(), { registerSignalHandlers: false });
+
+ const executeSqlSchema = server.listTools().tools.find(tool => tool.name === 'pg_execute_sql')?.inputSchema as {
+ required?: string[];
+ properties?: Record>;
+ } | undefined;
+
+ expect(executeSqlSchema).toBeDefined();
+ expect(executeSqlSchema?.required).toEqual(['sql']);
+ expect(executeSqlSchema?.properties?.maxRows).toMatchObject({
+ minimum: 1,
+ maximum: 1000,
+ default: 100
+ });
+ expect(executeSqlSchema?.properties?.transactional).toMatchObject({
+ type: 'boolean',
+ default: false
+ });
+ });
+
+ it('blocks policy-denied write tools before executing the tool', async () => {
+ let called = false;
+ const tool = createTool('pg_execute_mutation', async () => {
+ called = true;
+ return textOutput('should not execute');
+ });
+ const server = new PostgreSQLServer([tool], runtimeConfig(), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_mutation', {
+ operation: 'insert',
+ tableName: 'users',
+ data: { email: 'user@example.com' }
+ });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Blocked by PostgreSQL MCP security policy');
+ expect(called).toBe(false);
+ expect(getAuditEvents()).toEqual([
+ expect.objectContaining({
+ reason: 'security_policy_denied',
+ toolName: 'pg_execute_mutation',
+ risk: 'write',
+ destructive: false
+ })
+ ]);
+ });
+
+ it('allows write tools in write mode', async () => {
+ const tool = createTool('pg_execute_mutation', async () => textOutput('mutation allowed'));
+ const server = new PostgreSQLServer([
+ tool
+ ], runtimeConfig({
+ securityPolicy: { mode: 'write', allowDestructive: false }
+ }), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_mutation', {
+ operation: 'insert',
+ tableName: 'users',
+ data: { email: 'user@example.com' }
+ });
+
+ expect(result).toEqual(textOutput('mutation allowed'));
+ });
+
+ it('blocks arbitrary SQL before executing the tool when mode is below unsafe', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [{ value: 1 }], rowCount: 1 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+ const server = new PostgreSQLServer([executeSqlTool], runtimeConfig({
+ securityPolicy: { mode: 'admin', allowDestructive: true }
+ }), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_sql', {
+ sql: 'SELECT 1'
+ });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Blocked by PostgreSQL MCP security policy');
+ expect(result.content[0].text).toContain('Current mode "admin"');
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.queryResult).not.toHaveBeenCalled();
+ });
+
+ it('requires destructive opt-in for arbitrary SQL even in unsafe mode', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [{ value: 1 }], rowCount: 1 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+ const server = new PostgreSQLServer([executeSqlTool], runtimeConfig({
+ securityPolicy: { mode: 'unsafe', allowDestructive: false }
+ }), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_sql', {
+ sql: 'SELECT 1'
+ });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Destructive operations require allowDestructive=true');
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.queryResult).not.toHaveBeenCalled();
+ });
+
+ it('runs arbitrary SQL tool validation before connection resolution once policy allows the call', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+ const server = new PostgreSQLServer([executeSqlTool], runtimeConfig({
+ securityPolicy: { mode: 'unsafe', allowDestructive: true }
+ }), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_sql', {
+ sql: 'SELECT 1; SELECT 2',
+ transactional: true,
+ expectRows: true
+ });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('expectRows=false');
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.transaction).not.toHaveBeenCalled();
+ });
+
+ it('allows arbitrary SQL through the real tool only in unsafe mode with destructive opt-in', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [{ value: 1 }], rowCount: 1 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+ const server = new PostgreSQLServer([executeSqlTool], runtimeConfig({
+ securityPolicy: { mode: 'unsafe', allowDestructive: true }
+ }), { registerSignalHandlers: false });
+
+ const result = await server.handleToolCall('pg_execute_sql', {
+ sql: 'SELECT 1 AS value',
+ maxRows: 10
+ });
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('Retrieved 1 rows');
+ expect(result.content[0].text).toContain('"value": 1');
+ expect(mockDb.connect).toHaveBeenCalledWith('postgresql://server');
+ expect(mockDb.queryResult).toHaveBeenCalledWith('SELECT 1 AS value', [], {});
+ });
+
+ it('cleans up server resources only once across repeated close calls', async () => {
+ const server = new PostgreSQLServer([], runtimeConfig(), { registerSignalHandlers: false });
+ const closeServer = vi.fn().mockResolvedValue(undefined);
+ const cleanupPools = vi.spyOn(DatabaseConnection, 'cleanupPools').mockResolvedValue(undefined);
+
+ (server as unknown as { server: { close: () => Promise } }).server = {
+ close: closeServer
+ };
+
+ await Promise.all([
+ server.close(),
+ server.close()
+ ]);
+
+ expect(cleanupPools).toHaveBeenCalledTimes(1);
+ expect(closeServer).toHaveBeenCalledTimes(1);
+ });
+
+ it('still closes the MCP server when pool cleanup fails', async () => {
+ const server = new PostgreSQLServer([], runtimeConfig(), { registerSignalHandlers: false });
+ const closeServer = vi.fn().mockResolvedValue(undefined);
+ const cleanupPools = vi.spyOn(DatabaseConnection, 'cleanupPools').mockRejectedValue(new Error('pool cleanup failed'));
+
+ (server as unknown as { server: { close: () => Promise } }).server = {
+ close: closeServer
+ };
+
+ await expect(server.close()).rejects.toThrow('Cleanup failed: pool cleanup failed');
+
+ expect(cleanupPools).toHaveBeenCalledTimes(1);
+ expect(closeServer).toHaveBeenCalledTimes(1);
+ });
+
+ it('removes registered signal handlers and exits after signal-triggered cleanup', async () => {
+ const registeredHandlers = new Map();
+ const processOn = vi.spyOn(process, 'on').mockImplementation((signal, listener) => {
+ if (signal === 'SIGINT' || signal === 'SIGTERM') {
+ registeredHandlers.set(signal, listener as NodeJS.SignalsListener);
+ }
+ return process;
+ });
+ const removeListener = vi.spyOn(process, 'removeListener').mockImplementation(() => process);
+ const exitProcess = vi.fn();
+ const cleanupPools = vi.spyOn(DatabaseConnection, 'cleanupPools').mockResolvedValue(undefined);
+ const closeServer = vi.fn().mockResolvedValue(undefined);
+
+ const server = new PostgreSQLServer([], runtimeConfig(), {
+ exitProcess
+ });
+ (server as unknown as { server: { close: () => Promise } }).server = {
+ close: closeServer
+ };
+
+ await registeredHandlers.get('SIGTERM')?.('SIGTERM');
+
+ expect(processOn).toHaveBeenCalledWith('SIGINT', expect.any(Function));
+ expect(processOn).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
+ expect(removeListener).toHaveBeenCalledWith('SIGINT', registeredHandlers.get('SIGINT'));
+ expect(removeListener).toHaveBeenCalledWith('SIGTERM', registeredHandlers.get('SIGTERM'));
+ expect(cleanupPools).toHaveBeenCalledTimes(1);
+ expect(closeServer).toHaveBeenCalledTimes(1);
+ expect(exitProcess).toHaveBeenCalledWith(0);
+ });
+
+ it('exits nonzero when signal-triggered cleanup fails', async () => {
+ const registeredHandlers = new Map();
+ vi.spyOn(process, 'on').mockImplementation((signal, listener) => {
+ if (signal === 'SIGINT' || signal === 'SIGTERM') {
+ registeredHandlers.set(signal, listener as NodeJS.SignalsListener);
+ }
+ return process;
+ });
+ vi.spyOn(process, 'removeListener').mockImplementation(() => process);
+ const exitProcess = vi.fn();
+ const cleanupPools = vi.spyOn(DatabaseConnection, 'cleanupPools').mockRejectedValue(new Error('pool cleanup failed'));
+ const closeServer = vi.fn().mockResolvedValue(undefined);
+
+ const server = new PostgreSQLServer([], runtimeConfig(), {
+ exitProcess
+ });
+ (server as unknown as { server: { close: () => Promise } }).server = {
+ close: closeServer
+ };
+
+ await registeredHandlers.get('SIGINT')?.('SIGINT');
+
+ expect(cleanupPools).toHaveBeenCalledTimes(1);
+ expect(closeServer).toHaveBeenCalledTimes(1);
+ expect(exitProcess).toHaveBeenCalledWith(1);
+ expect(vi.mocked(console.error).mock.calls.some((call) =>
+ call[0] === 'Error during PostgreSQL MCP server shutdown:' &&
+ String(call[1]).includes('pool cleanup failed')
+ )).toBe(true);
+ });
+
+ it('runs signal-triggered shutdown exit path only once for repeated signals', async () => {
+ const registeredHandlers = new Map();
+ vi.spyOn(process, 'on').mockImplementation((signal, listener) => {
+ if (signal === 'SIGINT' || signal === 'SIGTERM') {
+ registeredHandlers.set(signal, listener as NodeJS.SignalsListener);
+ }
+ return process;
+ });
+ vi.spyOn(process, 'removeListener').mockImplementation(() => process);
+ const exitProcess = vi.fn();
+ const cleanupPools = vi.spyOn(DatabaseConnection, 'cleanupPools').mockResolvedValue(undefined);
+ const closeServer = vi.fn().mockResolvedValue(undefined);
+
+ const server = new PostgreSQLServer([], runtimeConfig(), {
+ exitProcess
+ });
+ (server as unknown as { server: { close: () => Promise } }).server = {
+ close: closeServer
+ };
+
+ await Promise.all([
+ registeredHandlers.get('SIGINT')?.('SIGINT'),
+ registeredHandlers.get('SIGTERM')?.('SIGTERM')
+ ]);
+
+ expect(cleanupPools).toHaveBeenCalledTimes(1);
+ expect(closeServer).toHaveBeenCalledTimes(1);
+ expect(exitProcess).toHaveBeenCalledTimes(1);
+ expect(exitProcess).toHaveBeenCalledWith(0);
+ });
+
+ it('cleans up constructed server resources when main run fails', async () => {
+ vi.spyOn(process, 'on').mockImplementation(() => process);
+ vi.spyOn(process, 'removeListener').mockImplementation(() => process);
+ vi.spyOn(PostgreSQLServer.prototype, 'run').mockRejectedValue(new Error('transport failed'));
+ const close = vi.spyOn(PostgreSQLServer.prototype, 'close').mockResolvedValue(undefined);
+
+ await expect(main(['node', 'build/index.js'])).rejects.toThrow('transport failed');
+
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves the startup failure when cleanup after failed run also fails', async () => {
+ vi.spyOn(process, 'on').mockImplementation(() => process);
+ vi.spyOn(process, 'removeListener').mockImplementation(() => process);
+ vi.spyOn(PostgreSQLServer.prototype, 'run').mockRejectedValue(new Error('transport failed'));
+ vi.spyOn(PostgreSQLServer.prototype, 'close').mockRejectedValue(new Error('cleanup failed'));
+
+ await expect(main(['node', 'build/index.js'])).rejects.toThrow('transport failed');
+
+ expect(vi.mocked(console.error).mock.calls.some((call) =>
+ call[0] === 'Error during PostgreSQL MCP server cleanup after failed startup:' &&
+ String(call[1]).includes('cleanup failed')
+ )).toBe(true);
+ });
+
+ it('normalizes runtime config without rewriting valid numeric environment defaults', () => {
+ const env: NodeJS.ProcessEnv = {
+ POSTGRES_CONNECTION_STRING: 'postgresql://env',
+ POSTGRES_MCP_SECURITY_MODE: 'write',
+ POSTGRES_MCP_AUDIT_FILE: '/env/audit.jsonl',
+ POSTGRES_MCP_MAX_CONNECTIONS: '7',
+ POSTGRES_MCP_IDLE_TIMEOUT_MS: '1500',
+ POSTGRES_MCP_CONNECTION_TIMEOUT_MS: '2500',
+ POSTGRES_MCP_MAX_FILE_BYTES: '1000',
+ POSTGRES_MCP_STATEMENT_TIMEOUT_MS: '2000',
+ POSTGRES_MCP_LOCK_TIMEOUT_MS: '3500',
+ POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS: '4500'
+ };
+
+ const config = createRuntimeConfig({
+ queryTimeoutMs: 2500
+ }, env);
+
+ expect(config.securityPolicy.mode).toBe('write');
+ expect(env.POSTGRES_MCP_AUDIT_FILE).toBe('/env/audit.jsonl');
+ expect(env.POSTGRES_MCP_MAX_CONNECTIONS).toBe('7');
+ expect(env.POSTGRES_MCP_IDLE_TIMEOUT_MS).toBe('1500');
+ expect(env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS).toBe('2500');
+ expect(env.POSTGRES_MCP_MAX_FILE_BYTES).toBe('1000');
+ expect(env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS).toBe('2000');
+ expect(env.POSTGRES_MCP_QUERY_TIMEOUT_MS).toBe('2500');
+ expect(env.POSTGRES_MCP_LOCK_TIMEOUT_MS).toBe('3500');
+ expect(env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS).toBe('4500');
+ });
+
+ it('keeps runtime option metadata in sync with CLI and main docs', () => {
+ const runtimeCliOptions = createCliProgram().options
+ .map((option) => option.long)
+ .filter(Boolean)
+ .sort();
+
+ expect([...DOCUMENTED_CLI_OPTIONS].sort()).toEqual(runtimeCliOptions);
+
+ const readme = readFileSync('README.md', 'utf8');
+ const usage = readFileSync('docs/USAGE.md', 'utf8');
+ const documentedValues = [
+ ...DOCUMENTED_CLI_OPTIONS,
+ ...DOCUMENTED_ENVIRONMENT_VARIABLES,
+ ...DOCUMENTED_TOOLS_CONFIG_KEYS
+ ];
+
+ for (const value of documentedValues) {
+ expect(readme, `README.md should document ${value}`).toContain(value);
+ expect(usage, `docs/USAGE.md should document ${value}`).toContain(value);
+ }
+ });
+
+ it('keeps runtime version metadata in sync with package manifests', () => {
+ const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as { version: string };
+ const packageLock = JSON.parse(readFileSync('package-lock.json', 'utf8')) as {
+ version: string;
+ packages?: Record;
+ };
+
+ expect(PACKAGE_VERSION).toBe(packageJson.version);
+ expect(PACKAGE_VERSION).toBe(packageLock.version);
+ expect(PACKAGE_VERSION).toBe(packageLock.packages?.['']?.version);
+ expect(createCliProgram().version()).toBe(PACKAGE_VERSION);
+ });
+
+ it('recognizes npm symlinked bin paths as CLI entrypoints', () => {
+ const modulePath = resolve('node_modules/@henkey/postgres-mcp-server/build/index.js');
+ const binPath = resolve('node_modules/.bin/postgres-mcp');
+ const realpath = (filePath: string) => filePath === binPath ? modulePath : filePath;
+
+ expect(isCliEntrypointPath(modulePath, binPath, realpath)).toBe(true);
+ });
+
+ it('fails closed on invalid explicit CLI numeric resource settings', () => {
+ expect(() => createRuntimeConfig({
+ maxConnections: '0'
+ }, {})).toThrow('--max-connections must be a positive integer');
+
+ expect(() => createRuntimeConfig({
+ idleTimeoutMillis: -1
+ }, {})).toThrow('--idle-timeout-ms must be a positive integer');
+
+ expect(() => createRuntimeConfig({
+ connectionTimeoutMillis: 1.5
+ }, {})).toThrow('--connection-timeout-ms must be a positive integer');
+
+ expect(() => createRuntimeConfig({
+ maxFileBytes: '0'
+ }, {})).toThrow('--max-file-bytes must be a positive integer');
+
+ expect(() => createRuntimeConfig({
+ statementTimeoutMs: 'not-a-number'
+ }, {})).toThrow('--statement-timeout-ms must be a positive integer');
+
+ expect(() => createRuntimeConfig({
+ queryTimeoutMs: -1
+ }, {})).toThrow('--query-timeout-ms must be a positive integer');
+
+ expect(() => createRuntimeConfig({
+ lockTimeoutMs: 1.5
+ }, {})).toThrow('--lock-timeout-ms must be a positive integer');
+
+ expect(() => createRuntimeConfig({
+ idleInTransactionSessionTimeoutMs: 0
+ }, {})).toThrow('--idle-in-transaction-session-timeout-ms must be a positive integer');
+ });
+
+ it('fails closed on invalid numeric environment resource settings', () => {
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_MAX_CONNECTIONS: '0'
+ })).toThrow('POSTGRES_MCP_MAX_CONNECTIONS must be a positive integer');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_IDLE_TIMEOUT_MS: 'nope'
+ })).toThrow('POSTGRES_MCP_IDLE_TIMEOUT_MS must be a positive integer');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_CONNECTION_TIMEOUT_MS: '1.5'
+ })).toThrow('POSTGRES_MCP_CONNECTION_TIMEOUT_MS must be a positive integer');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_MAX_FILE_BYTES: '0'
+ })).toThrow('POSTGRES_MCP_MAX_FILE_BYTES must be a positive integer');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_STATEMENT_TIMEOUT_MS: 'not-a-number'
+ })).toThrow('POSTGRES_MCP_STATEMENT_TIMEOUT_MS must be a positive integer');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_QUERY_TIMEOUT_MS: '1.5'
+ })).toThrow('POSTGRES_MCP_QUERY_TIMEOUT_MS must be a positive integer');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_LOCK_TIMEOUT_MS: '0'
+ })).toThrow('POSTGRES_MCP_LOCK_TIMEOUT_MS must be a positive integer');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS: 'NaN'
+ })).toThrow('POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS must be a positive integer');
+ });
+
+ it('lets CLI numeric values override invalid environment resource settings', () => {
+ const env: NodeJS.ProcessEnv = {
+ POSTGRES_MCP_MAX_CONNECTIONS: 'invalid',
+ POSTGRES_MCP_IDLE_TIMEOUT_MS: 'invalid',
+ POSTGRES_MCP_CONNECTION_TIMEOUT_MS: 'invalid',
+ POSTGRES_MCP_MAX_FILE_BYTES: 'invalid',
+ POSTGRES_MCP_STATEMENT_TIMEOUT_MS: 'invalid',
+ POSTGRES_MCP_QUERY_TIMEOUT_MS: 'invalid',
+ POSTGRES_MCP_LOCK_TIMEOUT_MS: 'invalid',
+ POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS: 'invalid'
+ };
+
+ createRuntimeConfig({
+ maxConnections: '10',
+ idleTimeoutMillis: '11000',
+ connectionTimeoutMillis: '12000',
+ maxFileBytes: '1000',
+ statementTimeoutMs: '2000',
+ queryTimeoutMs: '3000',
+ lockTimeoutMs: '4000',
+ idleInTransactionSessionTimeoutMs: '5000'
+ }, env);
+
+ expect(env.POSTGRES_MCP_MAX_CONNECTIONS).toBe('10');
+ expect(env.POSTGRES_MCP_IDLE_TIMEOUT_MS).toBe('11000');
+ expect(env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS).toBe('12000');
+ expect(env.POSTGRES_MCP_MAX_FILE_BYTES).toBe('1000');
+ expect(env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS).toBe('2000');
+ expect(env.POSTGRES_MCP_QUERY_TIMEOUT_MS).toBe('3000');
+ expect(env.POSTGRES_MCP_LOCK_TIMEOUT_MS).toBe('4000');
+ expect(env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS).toBe('5000');
+ });
+
+ it('fails closed on invalid explicit security modes', () => {
+ expect(() => createRuntimeConfig({
+ securityMode: 'readwrite'
+ }, {})).toThrow('securityMode must be one of readonly, write, admin, or unsafe');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_SECURITY_MODE: 'owner'
+ })).toThrow('securityMode must be one of readonly, write, admin, or unsafe');
+
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ securityMode: 'superuser'
+ }));
+
+ try {
+ expect(() => createRuntimeConfig({
+ toolsConfig: configPath
+ }, {})).toThrow('securityMode must be one of readonly, write, admin, or unsafe');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('lets explicit false config booleans override enabling environment variables', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ allowDestructive: false,
+ allowToolConnectionString: false
+ }));
+
+ try {
+ const config = createRuntimeConfig({
+ toolsConfig: configPath
+ }, {
+ POSTGRES_MCP_ALLOW_DESTRUCTIVE: 'true',
+ POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING: 'true'
+ });
+
+ expect(config.securityPolicy.allowDestructive).toBe(false);
+ expect(config.allowToolConnectionString).toBe(false);
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('fails closed on invalid boolean environment flags', () => {
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_ALLOW_DESTRUCTIVE: 'yes'
+ })).toThrow('POSTGRES_MCP_ALLOW_DESTRUCTIVE must be "true" or "false"');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING: '1'
+ })).toThrow('POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING must be "true" or "false"');
+ });
+
+ it('accepts explicit false boolean environment flags', () => {
+ const config = createRuntimeConfig({}, {
+ POSTGRES_MCP_ALLOW_DESTRUCTIVE: 'false',
+ POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING: 'false'
+ });
+
+ expect(config.securityPolicy.allowDestructive).toBe(false);
+ expect(config.allowToolConnectionString).toBe(false);
+ });
+
+ it('fails closed on explicit empty startup connection string values', () => {
+ expect(() => createRuntimeConfig({
+ connectionString: ''
+ }, {
+ POSTGRES_CONNECTION_STRING: 'postgresql://env'
+ })).toThrow('--connection-string must be a non-empty string');
+
+ expect(() => createRuntimeConfig({
+ connectionString: ' '
+ }, {
+ POSTGRES_CONNECTION_STRING: 'postgresql://env'
+ })).toThrow('--connection-string must be a non-empty string');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_CONNECTION_STRING: ''
+ })).toThrow('POSTGRES_CONNECTION_STRING must be a non-empty string');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_CONNECTION_STRING: '\t '
+ })).toThrow('POSTGRES_CONNECTION_STRING must be a non-empty string');
+ });
+
+ it('lets a valid CLI connection string override a blank environment connection string', () => {
+ const config = createRuntimeConfig({
+ connectionString: 'postgresql://cli'
+ }, {
+ POSTGRES_CONNECTION_STRING: ' '
+ });
+
+ expect(config.connectionString).toBe('postgresql://cli');
+ });
+
+ it('loads allowed connection targets from CLI, config, and environment with precedence', () => {
+ const cliConfig = createRuntimeConfig({
+ connectionString: 'postgresql://readonly@cli.internal:5432/app',
+ allowedConnectionTarget: ['readonly@cli.internal:5432/app']
+ }, {
+ POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS: 'readonly@env.internal:5432/app'
+ });
+ expect(cliConfig.allowedConnectionTargets?.map(target => target.source)).toEqual([
+ 'readonly@cli.internal:5432/app'
+ ]);
+
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ allowedConnectionTargets: ['readonly@config.internal:5432/app']
+ }));
+
+ try {
+ const fileConfig = createRuntimeConfig({
+ toolsConfig: configPath
+ }, {
+ POSTGRES_CONNECTION_STRING: 'postgresql://readonly@config.internal:5432/app',
+ POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS: 'readonly@env.internal:5432/app'
+ });
+ expect(fileConfig.allowedConnectionTargets?.map(target => target.source)).toEqual([
+ 'readonly@config.internal:5432/app'
+ ]);
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+
+ const envConfig = createRuntimeConfig({}, {
+ POSTGRES_CONNECTION_STRING: 'postgresql://readonly@env.internal:5432/app',
+ POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS: 'readonly@env.internal:5432/app'
+ });
+ expect(envConfig.allowedConnectionTargets?.map(target => target.source)).toEqual([
+ 'readonly@env.internal:5432/app'
+ ]);
+ });
+
+ it('fails startup when the configured connection string is outside the allowlist', () => {
+ expect(() => createRuntimeConfig({
+ connectionString: 'postgresql://readonly:secret@other.internal:5432/app',
+ allowedConnectionTarget: ['readonly@db.internal:5432/app']
+ }, {})).toThrow('Connection target "readonly@other.internal:5432/app" is not allowed');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_CONNECTION_STRING: 'postgresql://readonly:secret@other.internal:5432/app',
+ POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS: 'readonly@db.internal:5432/app'
+ })).toThrow('Connection target "readonly@other.internal:5432/app" is not allowed');
+ });
+
+ it('fails closed on invalid allowed connection target configuration', () => {
+ expect(() => createRuntimeConfig({
+ allowedConnectionTarget: ['db.*.internal/app']
+ }, {})).toThrow('full-field wildcard');
+
+ expect(() => createRuntimeConfig({}, {
+ POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS: 'db.internal,,localhost'
+ })).toThrow('must not contain empty entries');
+
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ allowedConnectionTargets: 'db.internal'
+ }));
+
+ try {
+ expect(() => createRuntimeConfig({
+ toolsConfig: configPath
+ }, {})).toThrow('allowedConnectionTargets must be an array');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('lets CLI boolean flags override safer config defaults', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ allowDestructive: false,
+ allowToolConnectionString: false
+ }));
+
+ try {
+ const config = createRuntimeConfig({
+ toolsConfig: configPath,
+ allowDestructive: true,
+ allowToolConnectionString: true
+ }, {});
+
+ expect(config.securityPolicy.allowDestructive).toBe(true);
+ expect(config.allowToolConnectionString).toBe(true);
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('applies CLI numeric and workspace options over config and environment values', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ workspaceDir: '/config-workspace',
+ maxConnections: 2,
+ idleTimeoutMillis: 1000,
+ connectionTimeoutMillis: 1500,
+ maxFileBytes: 2000,
+ statementTimeoutMs: 3000,
+ queryTimeoutMs: 4000,
+ lockTimeoutMs: 5000,
+ idleInTransactionSessionTimeoutMs: 6000
+ }));
+
+ try {
+ const env: NodeJS.ProcessEnv = {
+ POSTGRES_MCP_WORKSPACE_DIR: '/env-workspace',
+ POSTGRES_MCP_MAX_CONNECTIONS: '3',
+ POSTGRES_MCP_IDLE_TIMEOUT_MS: '4',
+ POSTGRES_MCP_CONNECTION_TIMEOUT_MS: '5',
+ POSTGRES_MCP_MAX_FILE_BYTES: '20',
+ POSTGRES_MCP_STATEMENT_TIMEOUT_MS: '30',
+ POSTGRES_MCP_QUERY_TIMEOUT_MS: '40',
+ POSTGRES_MCP_LOCK_TIMEOUT_MS: '50',
+ POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS: '60'
+ };
+
+ createRuntimeConfig({
+ toolsConfig: configPath,
+ workspaceDir: '/cli-workspace',
+ maxConnections: '11',
+ idleTimeoutMillis: '12',
+ connectionTimeoutMillis: '13',
+ maxFileBytes: '5000',
+ statementTimeoutMs: '6000',
+ queryTimeoutMs: '7000',
+ lockTimeoutMs: '8000',
+ idleInTransactionSessionTimeoutMs: '9000'
+ }, env);
+
+ expect(env.POSTGRES_MCP_WORKSPACE_DIR).toBe('/cli-workspace');
+ expect(env.POSTGRES_MCP_MAX_CONNECTIONS).toBe('11');
+ expect(env.POSTGRES_MCP_IDLE_TIMEOUT_MS).toBe('12');
+ expect(env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS).toBe('13');
+ expect(env.POSTGRES_MCP_MAX_FILE_BYTES).toBe('5000');
+ expect(env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS).toBe('6000');
+ expect(env.POSTGRES_MCP_QUERY_TIMEOUT_MS).toBe('7000');
+ expect(env.POSTGRES_MCP_LOCK_TIMEOUT_MS).toBe('8000');
+ expect(env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS).toBe('9000');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('applies config numeric and workspace values over environment defaults', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ workspaceDir: '/config-workspace',
+ maxConnections: 2,
+ idleTimeoutMillis: 1000,
+ connectionTimeoutMillis: 1500,
+ maxFileBytes: 2000,
+ statementTimeoutMs: 3000,
+ queryTimeoutMs: 4000,
+ lockTimeoutMs: 5000,
+ idleInTransactionSessionTimeoutMs: 6000
+ }));
+
+ try {
+ const env: NodeJS.ProcessEnv = {
+ POSTGRES_MCP_WORKSPACE_DIR: '/env-workspace',
+ POSTGRES_MCP_MAX_CONNECTIONS: '3',
+ POSTGRES_MCP_IDLE_TIMEOUT_MS: '4',
+ POSTGRES_MCP_CONNECTION_TIMEOUT_MS: '5',
+ POSTGRES_MCP_MAX_FILE_BYTES: '20',
+ POSTGRES_MCP_STATEMENT_TIMEOUT_MS: '30',
+ POSTGRES_MCP_QUERY_TIMEOUT_MS: '40',
+ POSTGRES_MCP_LOCK_TIMEOUT_MS: '50',
+ POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS: '60'
+ };
+
+ createRuntimeConfig({
+ toolsConfig: configPath
+ }, env);
+
+ expect(env.POSTGRES_MCP_WORKSPACE_DIR).toBe('/config-workspace');
+ expect(env.POSTGRES_MCP_MAX_CONNECTIONS).toBe('2');
+ expect(env.POSTGRES_MCP_IDLE_TIMEOUT_MS).toBe('1000');
+ expect(env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS).toBe('1500');
+ expect(env.POSTGRES_MCP_MAX_FILE_BYTES).toBe('2000');
+ expect(env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS).toBe('3000');
+ expect(env.POSTGRES_MCP_QUERY_TIMEOUT_MS).toBe('4000');
+ expect(env.POSTGRES_MCP_LOCK_TIMEOUT_MS).toBe('5000');
+ expect(env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS).toBe('6000');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('fails closed on explicit empty workspace directory values', () => {
+ expect(() => createRuntimeConfig({
+ workspaceDir: ''
+ }, {})).toThrow('--workspace-dir must be a non-empty string');
+
+ expect(() => createRuntimeConfig({
+ workspaceDir: ' '
+ }, {})).toThrow('--workspace-dir must be a non-empty string');
+
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ workspaceDir: ''
+ }));
+
+ try {
+ expect(() => createRuntimeConfig({
+ toolsConfig: configPath
+ }, {})).toThrow('workspaceDir must be a non-empty string');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('fails closed on explicit empty audit file values', () => {
+ expect(() => createRuntimeConfig({
+ auditFile: ''
+ }, {})).toThrow('--audit-file must be a non-empty string');
+
+ expect(() => createRuntimeConfig({
+ auditFile: ' '
+ }, {})).toThrow('--audit-file must be a non-empty string');
+
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ auditFile: ''
+ }));
+
+ try {
+ expect(() => createRuntimeConfig({
+ toolsConfig: configPath
+ }, {})).toThrow('auditFile must be a non-empty string');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('fails closed when an explicit tools config path cannot be loaded', () => {
+ expect(() => createRuntimeConfig({
+ toolsConfig: join(tmpdir(), 'missing-postgres-mcp-tools.json')
+ }, {})).toThrow('Failed to load tools configuration file');
+ });
+
+ it('fails closed when tools config JSON is malformed', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, '{"enabledTools": [');
+
+ try {
+ expect(() => createRuntimeConfig({
+ toolsConfig: configPath
+ }, {})).toThrow('Failed to load tools configuration file');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('fails closed when tools config is not a JSON object', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify(['pg_execute_query']));
+
+ try {
+ expect(() => createRuntimeConfig({
+ toolsConfig: configPath
+ }, {})).toThrow('tools config must be a JSON object');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('fails closed when tools config contains unknown keys', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ enabledTools: ['pg_execute_query'],
+ securitymode: 'unsafe',
+ allowDropEverything: true
+ }));
+
+ try {
+ expect(() => createRuntimeConfig({
+ toolsConfig: configPath
+ }, {})).toThrow('Unknown tools config key(s): securitymode, allowDropEverything');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('fails closed when tools config fields have invalid types', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ enabledTools: 'pg_execute_query',
+ maxFileBytes: 0
+ }));
+
+ try {
+ expect(() => createRuntimeConfig({
+ toolsConfig: configPath
+ }, {})).toThrow('enabledTools must be an array');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('applies CLI and config audit file values over environment defaults', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-config-'));
+ const configPath = join(tempDir, 'tools.json');
+ writeFileSync(configPath, JSON.stringify({
+ auditFile: '/config/audit.jsonl'
+ }));
+
+ try {
+ const cliEnv: NodeJS.ProcessEnv = {
+ POSTGRES_MCP_AUDIT_FILE: '/env/audit.jsonl'
+ };
+
+ createRuntimeConfig({
+ toolsConfig: configPath,
+ auditFile: '/cli/audit.jsonl'
+ }, cliEnv);
+
+ expect(cliEnv.POSTGRES_MCP_AUDIT_FILE).toBe('/cli/audit.jsonl');
+
+ const configEnv: NodeJS.ProcessEnv = {
+ POSTGRES_MCP_AUDIT_FILE: '/env/audit.jsonl'
+ };
+
+ createRuntimeConfig({
+ toolsConfig: configPath
+ }, configEnv);
+
+ expect(configEnv.POSTGRES_MCP_AUDIT_FILE).toBe('/config/audit.jsonl');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/index.ts b/src/index.ts
index e29fac0..7ae61fa 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,8 @@
#!/usr/bin/env node
-import { program } from 'commander';
+import { Command } from 'commander';
import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
@@ -13,7 +15,32 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
// Import tool types
import type { PostgresTool, ToolOutput } from './types/tool.js';
-import { DatabaseConnection } from './utils/connection.js';
+import { emitAuditEvent, isConnectionTargetDenial } from './server/audit.js';
+import { getToolSuppliedConnectionStrings, hasToolSuppliedConnectionString, resolveConnectionString } from './server/boundary.js';
+import {
+ assertConnectionTargetAllowed,
+ normalizeAllowedConnectionTargets,
+ parseAllowedConnectionTargetList,
+ type AllowedConnectionTarget
+} from './server/connection-target.js';
+import {
+ DEFAULT_CONNECTION_TIMEOUT_MS,
+ DEFAULT_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS,
+ DEFAULT_LOCK_TIMEOUT_MS,
+ DEFAULT_MAX_CONNECTIONS,
+ DEFAULT_POOL_IDLE_TIMEOUT_MS,
+ DEFAULT_QUERY_TIMEOUT_MS,
+ DEFAULT_STATEMENT_TIMEOUT_MS,
+ DatabaseConnection,
+ sanitizeErrorMessage
+} from './utils/connection.js';
+import {
+ classifyToolCall,
+ explainPolicyDenial,
+ isToolCallAllowed,
+ normalizeSecurityMode,
+ type SecurityPolicy
+} from './security/policy.js';
// Import all tool implementations
import { analyzeDatabaseTool } from './tools/analyze.js';
@@ -30,82 +57,570 @@ import { manageConstraintsTool } from './tools/constraints.js';
import { executeQueryTool, executeMutationTool, executeSqlTool } from './tools/data.js';
import { manageCommentsTool } from './tools/comments.js';
-// Initialize commander
-program
- .version('1.0.7')
- .option('-cs, --connection-string ', 'PostgreSQL connection string')
- .option('-tc, --tools-config ', 'Path to tools configuration JSON file')
- .parse(process.argv);
+export {
+ DEFAULT_CONNECTION_TIMEOUT_MS,
+ DEFAULT_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS,
+ DEFAULT_LOCK_TIMEOUT_MS,
+ DEFAULT_MAX_CONNECTIONS,
+ DEFAULT_POOL_IDLE_TIMEOUT_MS,
+ DEFAULT_QUERY_TIMEOUT_MS,
+ DEFAULT_STATEMENT_TIMEOUT_MS
+};
-const options = program.opts();
+export interface CliOptions {
+ connectionString?: string;
+ toolsConfig?: string;
+ securityMode?: string;
+ allowDestructive?: boolean;
+ allowToolConnectionString?: boolean;
+ workspaceDir?: string;
+ auditFile?: string;
+ maxConnections?: string | number;
+ idleTimeoutMillis?: string | number;
+ connectionTimeoutMillis?: string | number;
+ maxFileBytes?: string | number;
+ statementTimeoutMs?: string | number;
+ queryTimeoutMs?: string | number;
+ lockTimeoutMs?: string | number;
+ idleInTransactionSessionTimeoutMs?: string | number;
+ allowedConnectionTarget?: string[];
+}
-/**
- * Get connection string from various sources in order of precedence:
- * 1. Function argument (tool-specific)
- * 2. CLI --connection-string option
- * 3. POSTGRES_CONNECTION_STRING environment variable
- */
-function getConnectionString(connectionStringArg?: string): string {
- if (connectionStringArg) {
- return connectionStringArg;
- }
- const cliConnectionString = options.connectionString;
- if (cliConnectionString) {
- return cliConnectionString;
- }
- const envConnectionString = process.env.POSTGRES_CONNECTION_STRING;
- if (envConnectionString) {
- return envConnectionString;
- }
- throw new McpError(
- ErrorCode.InvalidParams,
- 'No connection string provided. Provide one in the tool arguments, via the --connection-string CLI option, or set the POSTGRES_CONNECTION_STRING environment variable.'
- );
+export interface ToolsConfigFile {
+ enabledTools?: string[];
+ securityMode?: string;
+ allowDestructive?: boolean;
+ allowToolConnectionString?: boolean;
+ workspaceDir?: string;
+ auditFile?: string;
+ maxConnections?: number;
+ idleTimeoutMillis?: number;
+ connectionTimeoutMillis?: number;
+ maxFileBytes?: number;
+ statementTimeoutMs?: number;
+ queryTimeoutMs?: number;
+ lockTimeoutMs?: number;
+ idleInTransactionSessionTimeoutMs?: number;
+ allowedConnectionTargets?: string[];
+}
+
+export interface RuntimeConfig {
+ enabledTools?: string[];
+ securityPolicy: SecurityPolicy;
+ allowToolConnectionString: boolean;
+ connectionString?: string;
+ allowedConnectionTargets?: AllowedConnectionTarget[];
+ toolsConfigPath?: string;
+}
+
+export interface PostgreSQLServerOptions {
+ registerSignalHandlers?: boolean;
+ exitProcess?: (code: number) => void;
+}
+
+export interface ToolInputJsonSchema {
+ type: 'object';
+ properties?: Record;
+ [key: string]: unknown;
+}
+
+export interface ListedTool {
+ name: string;
+ description: string;
+ inputSchema: ToolInputJsonSchema;
+ [key: string]: unknown;
}
-class PostgreSQLServer {
+export interface ListToolsResult {
+ tools: ListedTool[];
+ [key: string]: unknown;
+}
+
+export const DOCUMENTED_CLI_OPTIONS = [
+ '--version',
+ '--connection-string',
+ '--tools-config',
+ '--security-mode',
+ '--allow-destructive',
+ '--allow-tool-connection-string',
+ '--workspace-dir',
+ '--audit-file',
+ '--max-connections',
+ '--idle-timeout-ms',
+ '--connection-timeout-ms',
+ '--max-file-bytes',
+ '--statement-timeout-ms',
+ '--query-timeout-ms',
+ '--lock-timeout-ms',
+ '--idle-in-transaction-session-timeout-ms',
+ '--allowed-connection-target'
+] as const;
+
+export const DOCUMENTED_ENVIRONMENT_VARIABLES = [
+ 'POSTGRES_CONNECTION_STRING',
+ 'POSTGRES_TOOLS_CONFIG',
+ 'POSTGRES_MCP_SECURITY_MODE',
+ 'POSTGRES_MCP_ALLOW_DESTRUCTIVE',
+ 'POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING',
+ 'POSTGRES_MCP_WORKSPACE_DIR',
+ 'POSTGRES_MCP_AUDIT_FILE',
+ 'POSTGRES_MCP_MAX_CONNECTIONS',
+ 'POSTGRES_MCP_IDLE_TIMEOUT_MS',
+ 'POSTGRES_MCP_CONNECTION_TIMEOUT_MS',
+ 'POSTGRES_MCP_MAX_FILE_BYTES',
+ 'POSTGRES_MCP_STATEMENT_TIMEOUT_MS',
+ 'POSTGRES_MCP_QUERY_TIMEOUT_MS',
+ 'POSTGRES_MCP_LOCK_TIMEOUT_MS',
+ 'POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS',
+ 'POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS',
+ 'POSTGRES_MCP_DEBUG_SQL'
+] as const;
+
+export const DOCUMENTED_TOOLS_CONFIG_KEYS = [
+ 'enabledTools',
+ 'securityMode',
+ 'allowDestructive',
+ 'allowToolConnectionString',
+ 'workspaceDir',
+ 'auditFile',
+ 'maxConnections',
+ 'idleTimeoutMillis',
+ 'connectionTimeoutMillis',
+ 'maxFileBytes',
+ 'statementTimeoutMs',
+ 'queryTimeoutMs',
+ 'lockTimeoutMs',
+ 'idleInTransactionSessionTimeoutMs',
+ 'allowedConnectionTargets'
+] as const;
+
+const ALLOWED_TOOLS_CONFIG_KEYS = new Set(DOCUMENTED_TOOLS_CONFIG_KEYS);
+
+export const PACKAGE_VERSION = '2.0.0';
+
+function isStringArray(value: unknown): value is string[] {
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
+}
+
+function parsePositiveInteger(value: unknown): number | undefined {
+ if (value === undefined || value === null || value === '') {
+ return undefined;
+ }
+
+ const parsed = typeof value === 'number' ? value : Number(value);
+ if (!Number.isInteger(parsed) || parsed <= 0) {
+ return undefined;
+ }
+
+ return parsed;
+}
+
+export function createCliProgram(): Command {
+ return new Command()
+ .version(PACKAGE_VERSION)
+ .option('-cs, --connection-string ', 'PostgreSQL connection string')
+ .option('-tc, --tools-config ', 'Path to tools configuration JSON file')
+ .option('--security-mode ', 'Security mode: readonly, write, admin, or unsafe')
+ .option('--allow-destructive', 'Allow destructive operations such as drops, resets, and arbitrary SQL')
+ .option('--allow-tool-connection-string', 'Allow per-tool connection string arguments')
+ .option('--workspace-dir ', 'Workspace directory for filesystem import/export tools')
+ .option('--audit-file ', 'Optional JSONL file for sanitized security audit events')
+ .option('--max-connections ', 'Maximum PostgreSQL pool connections')
+ .option('--idle-timeout-ms ', 'PostgreSQL pool idle client timeout in milliseconds')
+ .option('--connection-timeout-ms ', 'PostgreSQL connection acquisition timeout in milliseconds')
+ .option('--max-file-bytes ', 'Maximum JSON/CSV import/export file size in bytes')
+ .option('--statement-timeout-ms ', 'Default PostgreSQL statement_timeout in milliseconds')
+ .option('--query-timeout-ms ', 'Default node-postgres query timeout in milliseconds')
+ .option('--lock-timeout-ms ', 'Default PostgreSQL lock_timeout in milliseconds')
+ .option('--idle-in-transaction-session-timeout-ms ', 'Default PostgreSQL idle_in_transaction_session_timeout in milliseconds')
+ .option(
+ '--allowed-connection-target ',
+ 'Allowed connection target as [user@]host[:port][/database]; repeat to allow multiple targets',
+ (value: string, previous: string[] | undefined) => [...(previous ?? []), value]
+ );
+}
+
+export function parseCliOptions(argv = process.argv): CliOptions {
+ const cliProgram = createCliProgram();
+ cliProgram.parse(argv);
+ return cliProgram.opts();
+}
+
+export function readToolsConfigFile(configPath?: string): ToolsConfigFile {
+ if (!configPath) {
+ return {};
+ }
+
+ try {
+ const configContent = fs.readFileSync(configPath, 'utf-8');
+ const parsedJson = JSON.parse(configContent) as unknown;
+ if (!parsedJson || typeof parsedJson !== 'object' || Array.isArray(parsedJson)) {
+ throw new Error('tools config must be a JSON object.');
+ }
+
+ const parsed = parsedJson as Record;
+ const unknownKeys = Object.keys(parsed).filter((key) => !ALLOWED_TOOLS_CONFIG_KEYS.has(key));
+ if (unknownKeys.length > 0) {
+ throw new Error(`Unknown tools config key(s): ${unknownKeys.join(', ')}. Allowed keys: ${DOCUMENTED_TOOLS_CONFIG_KEYS.join(', ')}.`);
+ }
+
+ const config: ToolsConfigFile = {};
+
+ if ('enabledTools' in parsed) {
+ if (!isStringArray(parsed.enabledTools)) {
+ throw new Error('enabledTools must be an array of tool names.');
+ }
+ config.enabledTools = parsed.enabledTools;
+ }
+
+ if ('securityMode' in parsed && typeof parsed.securityMode !== 'string') {
+ throw new Error('securityMode must be a string.');
+ } else if (typeof parsed.securityMode === 'string') {
+ config.securityMode = parsed.securityMode;
+ }
+
+ if ('allowDestructive' in parsed && typeof parsed.allowDestructive !== 'boolean') {
+ throw new Error('allowDestructive must be a boolean.');
+ } else if (typeof parsed.allowDestructive === 'boolean') {
+ config.allowDestructive = parsed.allowDestructive;
+ }
+
+ if ('allowToolConnectionString' in parsed && typeof parsed.allowToolConnectionString !== 'boolean') {
+ throw new Error('allowToolConnectionString must be a boolean.');
+ } else if (typeof parsed.allowToolConnectionString === 'boolean') {
+ config.allowToolConnectionString = parsed.allowToolConnectionString;
+ }
+
+ if ('workspaceDir' in parsed && typeof parsed.workspaceDir !== 'string') {
+ throw new Error('workspaceDir must be a string.');
+ } else if (typeof parsed.workspaceDir === 'string') {
+ if (parsed.workspaceDir.trim() === '') {
+ throw new Error('workspaceDir must be a non-empty string.');
+ }
+ config.workspaceDir = parsed.workspaceDir;
+ }
+
+ if ('auditFile' in parsed && typeof parsed.auditFile !== 'string') {
+ throw new Error('auditFile must be a string.');
+ } else if (typeof parsed.auditFile === 'string') {
+ if (parsed.auditFile.trim() === '') {
+ throw new Error('auditFile must be a non-empty string.');
+ }
+ config.auditFile = parsed.auditFile;
+ }
+
+ if ('maxConnections' in parsed) {
+ const maxConnections = parsePositiveInteger(parsed.maxConnections);
+ if (maxConnections === undefined) {
+ throw new Error('maxConnections must be a positive integer.');
+ }
+ config.maxConnections = maxConnections;
+ }
+
+ if ('idleTimeoutMillis' in parsed) {
+ const idleTimeoutMillis = parsePositiveInteger(parsed.idleTimeoutMillis);
+ if (idleTimeoutMillis === undefined) {
+ throw new Error('idleTimeoutMillis must be a positive integer.');
+ }
+ config.idleTimeoutMillis = idleTimeoutMillis;
+ }
+
+ if ('connectionTimeoutMillis' in parsed) {
+ const connectionTimeoutMillis = parsePositiveInteger(parsed.connectionTimeoutMillis);
+ if (connectionTimeoutMillis === undefined) {
+ throw new Error('connectionTimeoutMillis must be a positive integer.');
+ }
+ config.connectionTimeoutMillis = connectionTimeoutMillis;
+ }
+
+ if ('maxFileBytes' in parsed) {
+ const maxFileBytes = parsePositiveInteger(parsed.maxFileBytes);
+ if (maxFileBytes === undefined) {
+ throw new Error('maxFileBytes must be a positive integer.');
+ }
+ config.maxFileBytes = maxFileBytes;
+ }
+
+ if ('statementTimeoutMs' in parsed) {
+ const statementTimeoutMs = parsePositiveInteger(parsed.statementTimeoutMs);
+ if (statementTimeoutMs === undefined) {
+ throw new Error('statementTimeoutMs must be a positive integer.');
+ }
+ config.statementTimeoutMs = statementTimeoutMs;
+ }
+
+ if ('queryTimeoutMs' in parsed) {
+ const queryTimeoutMs = parsePositiveInteger(parsed.queryTimeoutMs);
+ if (queryTimeoutMs === undefined) {
+ throw new Error('queryTimeoutMs must be a positive integer.');
+ }
+ config.queryTimeoutMs = queryTimeoutMs;
+ }
+
+ if ('lockTimeoutMs' in parsed) {
+ const lockTimeoutMs = parsePositiveInteger(parsed.lockTimeoutMs);
+ if (lockTimeoutMs === undefined) {
+ throw new Error('lockTimeoutMs must be a positive integer.');
+ }
+ config.lockTimeoutMs = lockTimeoutMs;
+ }
+
+ if ('idleInTransactionSessionTimeoutMs' in parsed) {
+ const idleInTransactionSessionTimeoutMs = parsePositiveInteger(parsed.idleInTransactionSessionTimeoutMs);
+ if (idleInTransactionSessionTimeoutMs === undefined) {
+ throw new Error('idleInTransactionSessionTimeoutMs must be a positive integer.');
+ }
+ config.idleInTransactionSessionTimeoutMs = idleInTransactionSessionTimeoutMs;
+ }
+
+ if ('allowedConnectionTargets' in parsed) {
+ if (!isStringArray(parsed.allowedConnectionTargets)) {
+ throw new Error('allowedConnectionTargets must be an array of connection target patterns.');
+ }
+ config.allowedConnectionTargets = parsed.allowedConnectionTargets;
+ }
+
+ console.error(`[MCP Info] Loaded tools configuration from ${configPath}.`);
+ return config;
+ } catch (error) {
+ throw new Error(`Failed to load tools configuration file at ${configPath}: ${sanitizeErrorMessage(error)}`);
+ }
+}
+
+function setPositiveIntegerEnv(
+ env: NodeJS.ProcessEnv,
+ key: string,
+ optionName: string,
+ cliValue: string | number | undefined,
+ configValue: number | undefined
+): void {
+ const resolvedValue = resolvePositiveIntegerOption(optionName, cliValue, configValue);
+
+ if (resolvedValue !== undefined) {
+ env[key] = String(resolvedValue);
+ return;
+ }
+
+ if (env[key] !== undefined && env[key] !== '') {
+ const parsedEnvValue = parsePositiveInteger(env[key]);
+ if (parsedEnvValue === undefined) {
+ throw new Error(`${key} must be a positive integer.`);
+ }
+ }
+}
+
+function resolvePositiveIntegerOption(
+ optionName: string,
+ cliValue: string | number | undefined,
+ configValue: number | undefined
+): number | undefined {
+ if (cliValue !== undefined) {
+ const parsedCliValue = parsePositiveInteger(cliValue);
+ if (parsedCliValue === undefined) {
+ throw new Error(`${optionName} must be a positive integer.`);
+ }
+ return parsedCliValue;
+ }
+
+ return configValue;
+}
+
+function resolveBooleanOption(
+ optionName: string,
+ cliValue: boolean | undefined,
+ configValue: boolean | undefined,
+ envValue: string | undefined
+): boolean {
+ if (cliValue !== undefined) {
+ return cliValue;
+ }
+
+ if (configValue !== undefined) {
+ return configValue;
+ }
+
+ if (envValue === undefined || envValue === '') {
+ return false;
+ }
+
+ if (envValue === 'true') {
+ return true;
+ }
+
+ if (envValue === 'false') {
+ return false;
+ }
+
+ throw new Error(`${optionName} must be "true" or "false".`);
+}
+
+function validateRuntimeConnectionString(optionName: string, value: string | undefined): string | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+
+ if (value.trim() === '') {
+ throw new Error(`${optionName} must be a non-empty string.`);
+ }
+
+ return value;
+}
+
+function resolveAllowedConnectionTargets(
+ options: CliOptions,
+ configFile: ToolsConfigFile,
+ env: NodeJS.ProcessEnv
+): AllowedConnectionTarget[] | undefined {
+ const targetPatterns = options.allowedConnectionTarget !== undefined
+ ? options.allowedConnectionTarget
+ : configFile.allowedConnectionTargets !== undefined
+ ? configFile.allowedConnectionTargets
+ : parseAllowedConnectionTargetList(env.POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS);
+
+ return normalizeAllowedConnectionTargets(targetPatterns);
+}
+
+export function createRuntimeConfig(
+ options: CliOptions = {},
+ env: NodeJS.ProcessEnv = process.env
+): RuntimeConfig {
+ const toolsConfigPath = options.toolsConfig || env.POSTGRES_TOOLS_CONFIG;
+ const configFile = readToolsConfigFile(toolsConfigPath);
+ const connectionString = validateRuntimeConnectionString('--connection-string', options.connectionString);
+ if (connectionString === undefined) {
+ validateRuntimeConnectionString('POSTGRES_CONNECTION_STRING', env.POSTGRES_CONNECTION_STRING);
+ }
+ const allowedConnectionTargets = resolveAllowedConnectionTargets(options, configFile, env);
+ const startupConnectionString = connectionString ?? env.POSTGRES_CONNECTION_STRING;
+ if (startupConnectionString !== undefined && startupConnectionString.trim() !== '') {
+ assertConnectionTargetAllowed(startupConnectionString, allowedConnectionTargets);
+ }
+
+ if (options.workspaceDir !== undefined && options.workspaceDir.trim() === '') {
+ throw new Error('--workspace-dir must be a non-empty string.');
+ }
+
+ const workspaceDir = options.workspaceDir ?? configFile.workspaceDir;
+ if (workspaceDir !== undefined) {
+ env.POSTGRES_MCP_WORKSPACE_DIR = workspaceDir;
+ }
+
+ if (options.auditFile !== undefined && options.auditFile.trim() === '') {
+ throw new Error('--audit-file must be a non-empty string.');
+ }
+
+ const auditFile = options.auditFile ?? configFile.auditFile;
+ if (auditFile !== undefined) {
+ env.POSTGRES_MCP_AUDIT_FILE = auditFile;
+ }
+
+ setPositiveIntegerEnv(env, 'POSTGRES_MCP_MAX_CONNECTIONS', '--max-connections', options.maxConnections, configFile.maxConnections);
+ setPositiveIntegerEnv(env, 'POSTGRES_MCP_IDLE_TIMEOUT_MS', '--idle-timeout-ms', options.idleTimeoutMillis, configFile.idleTimeoutMillis);
+ setPositiveIntegerEnv(env, 'POSTGRES_MCP_CONNECTION_TIMEOUT_MS', '--connection-timeout-ms', options.connectionTimeoutMillis, configFile.connectionTimeoutMillis);
+ setPositiveIntegerEnv(env, 'POSTGRES_MCP_MAX_FILE_BYTES', '--max-file-bytes', options.maxFileBytes, configFile.maxFileBytes);
+ setPositiveIntegerEnv(env, 'POSTGRES_MCP_STATEMENT_TIMEOUT_MS', '--statement-timeout-ms', options.statementTimeoutMs, configFile.statementTimeoutMs);
+ setPositiveIntegerEnv(env, 'POSTGRES_MCP_QUERY_TIMEOUT_MS', '--query-timeout-ms', options.queryTimeoutMs, configFile.queryTimeoutMs);
+ setPositiveIntegerEnv(env, 'POSTGRES_MCP_LOCK_TIMEOUT_MS', '--lock-timeout-ms', options.lockTimeoutMs, configFile.lockTimeoutMs);
+ setPositiveIntegerEnv(env, 'POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS', '--idle-in-transaction-session-timeout-ms', options.idleInTransactionSessionTimeoutMs, configFile.idleInTransactionSessionTimeoutMs);
+
+ return {
+ enabledTools: configFile.enabledTools,
+ securityPolicy: {
+ mode: normalizeSecurityMode(options.securityMode || configFile.securityMode || env.POSTGRES_MCP_SECURITY_MODE),
+ allowDestructive: resolveBooleanOption(
+ 'POSTGRES_MCP_ALLOW_DESTRUCTIVE',
+ options.allowDestructive,
+ configFile.allowDestructive,
+ env.POSTGRES_MCP_ALLOW_DESTRUCTIVE
+ )
+ },
+ allowToolConnectionString: resolveBooleanOption(
+ 'POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING',
+ options.allowToolConnectionString,
+ configFile.allowToolConnectionString,
+ env.POSTGRES_MCP_ALLOW_TOOL_CONNECTION_STRING
+ ),
+ connectionString,
+ allowedConnectionTargets,
+ toolsConfigPath
+ };
+}
+
+export class PostgreSQLServer {
private server: Server;
public availableToolsList: PostgresTool[];
private enabledTools: PostgresTool[];
private enabledToolsMap: Record;
+ private securityPolicy: SecurityPolicy;
+ private allowToolConnectionString: boolean;
+ private runtimeConfig: RuntimeConfig;
+ private cleanupPromise: Promise | null = null;
+ private signalShutdownPromise: Promise | null = null;
+ private signalHandlers: Array<{ signal: NodeJS.Signals; handler: NodeJS.SignalsListener }> = [];
+ private exitProcess: (code: number) => void;
+
+ private static toolMetadata(tool: PostgresTool): ListedTool {
+ return {
+ name: tool.name,
+ description: tool.description,
+ inputSchema: zodToJsonSchema(tool.inputSchema) as ToolInputJsonSchema,
+ };
+ }
- constructor(initialTools: PostgresTool[] = []) {
+ constructor(
+ initialTools: PostgresTool[] = [],
+ runtimeConfig: RuntimeConfig = createRuntimeConfig(),
+ serverOptions: PostgreSQLServerOptions = {}
+ ) {
this.availableToolsList = [...initialTools];
this.enabledTools = [];
this.enabledToolsMap = {};
+ this.securityPolicy = runtimeConfig.securityPolicy;
+ this.allowToolConnectionString = runtimeConfig.allowToolConnectionString;
+ this.runtimeConfig = runtimeConfig;
+ this.exitProcess = serverOptions.exitProcess ?? ((code: number) => process.exit(code));
this.loadAndFilterTools();
this.server = new Server(
{
name: 'postgresql-mcp-server',
- version: '1.0.7',
+ version: PACKAGE_VERSION,
},
{
capabilities: {
tools: this.enabledTools.reduce((acc, tool) => {
- acc[tool.name] = {
- name: tool.name,
- description: tool.description,
- inputSchema: zodToJsonSchema(tool.inputSchema),
- };
+ acc[tool.name] = PostgreSQLServer.toolMetadata(tool);
return acc;
- }, {} as Record),
+ }, {} as Record),
},
}
);
this.setupToolHandlers();
- this.server.onerror = (error) => console.error('[MCP Error]', error);
+ this.server.onerror = (error) => console.error('[MCP Error]', sanitizeErrorMessage(error));
- // Handle graceful shutdown
- process.on('SIGINT', async () => {
- await this.cleanup();
- process.exit(0);
- });
- process.on('SIGTERM', async () => {
- await this.cleanup();
- process.exit(0);
+ if (serverOptions.registerSignalHandlers !== false) {
+ this.registerSignalHandlers();
+ }
+ }
+
+ /**
+ * Get connection string from various sources in order of precedence:
+ * 1. Function argument (internal use after request policy checks)
+ * 2. CLI --connection-string option
+ * 3. POSTGRES_CONNECTION_STRING environment variable
+ *
+ * Per-tool request connection strings are blocked in the MCP request handler
+ * unless --allow-tool-connection-string is explicitly enabled.
+ */
+ private getConnectionString(connectionStringArg?: string): string {
+ const connectionString = resolveConnectionString({
+ requestConnectionString: connectionStringArg,
+ cliConnectionString: this.runtimeConfig.connectionString,
+ envConnectionString: process.env.POSTGRES_CONNECTION_STRING
});
+ assertConnectionTargetAllowed(connectionString, this.runtimeConfig.allowedConnectionTargets);
+ return connectionString;
}
/**
@@ -113,32 +628,21 @@ class PostgreSQLServer {
*/
private loadAndFilterTools(): void {
let toolsToEnable = [...this.availableToolsList];
- const toolsConfigPath = options.toolsConfig;
- if (toolsConfigPath) {
- try {
- const configContent = fs.readFileSync(toolsConfigPath, 'utf-8');
- const config = JSON.parse(configContent);
- if (config && Array.isArray(config.enabledTools) && config.enabledTools.every((t: unknown) => typeof t === 'string')) {
- const enabledToolNames = new Set(config.enabledTools as string[]);
- toolsToEnable = this.availableToolsList.filter(tool => enabledToolNames.has(tool.name));
- console.error(`[MCP Info] Loaded tools configuration from ${toolsConfigPath}. Enabled tools: ${toolsToEnable.map(t => t.name).join(', ')}`);
-
- // Warn about tools specified in config but not available
- for (const requestedName of enabledToolNames) {
- if (!this.availableToolsList.some(tool => tool.name === requestedName)) {
- console.warn(`[MCP Warning] Tool "${requestedName}" specified in config file but not found in available tools.`);
- }
- }
- } else {
- console.error(`[MCP Warning] Invalid tools configuration file format at ${toolsConfigPath}.`);
- }
- } catch (error) {
- console.error(`[MCP Warning] Could not read or parse tools configuration file at ${toolsConfigPath}. Error: ${error instanceof Error ? error.message : String(error)}.`);
+ if (this.runtimeConfig.enabledTools) {
+ const enabledToolNames = new Set(this.runtimeConfig.enabledTools);
+ const availableToolNames = new Set(this.availableToolsList.map(tool => tool.name));
+ const unknownToolNames = [...enabledToolNames].filter(toolName => !availableToolNames.has(toolName));
+
+ if (unknownToolNames.length > 0) {
+ throw new Error(`Unknown enabledTools configured: ${unknownToolNames.join(', ')}`);
}
+
+ toolsToEnable = this.availableToolsList.filter(tool => enabledToolNames.has(tool.name));
+ console.error(`[MCP Info] Enabled tools from configuration: ${toolsToEnable.map(t => t.name).join(', ')}`);
} else {
if (this.availableToolsList.length > 0) {
- console.error('[MCP Info] No tools configuration file provided. All available tools will be enabled.');
+ console.error('[MCP Info] No enabledTools allow-list provided. All available tools are listed, with security policy enforced at call time.');
} else {
console.error('[MCP Info] No tools configuration file provided and no tools loaded into availableToolsList.');
}
@@ -151,69 +655,207 @@ class PostgreSQLServer {
}, {} as Record);
}
+ private registerSignalHandlers(): void {
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
+ const handler: NodeJS.SignalsListener = async () => {
+ await this.handleShutdownSignal();
+ };
+ this.signalHandlers.push({ signal, handler });
+ process.on(signal, handler);
+ }
+ }
+
+ private async handleShutdownSignal(): Promise {
+ if (this.signalShutdownPromise) {
+ return this.signalShutdownPromise;
+ }
+
+ this.signalShutdownPromise = (async () => {
+ try {
+ await this.close();
+ this.exitProcess(0);
+ } catch (error) {
+ console.error('Error during PostgreSQL MCP server shutdown:', sanitizeErrorMessage(error));
+ this.exitProcess(1);
+ }
+ })();
+
+ return this.signalShutdownPromise;
+ }
+
+ private removeSignalHandlers(): void {
+ for (const { signal, handler } of this.signalHandlers) {
+ process.removeListener(signal, handler);
+ }
+ this.signalHandlers = [];
+ }
+
/**
* Clean up resources on shutdown
*/
private async cleanup(): Promise {
- console.error('Shutting down PostgreSQL MCP server...');
- await DatabaseConnection.cleanupPools();
- if (this.server) {
- await this.server.close();
+ if (this.cleanupPromise) {
+ return this.cleanupPromise;
+ }
+
+ this.cleanupPromise = (async () => {
+ console.error('Shutting down PostgreSQL MCP server...');
+ this.removeSignalHandlers();
+ const cleanupErrors: string[] = [];
+ try {
+ await DatabaseConnection.cleanupPools();
+ } catch (error) {
+ cleanupErrors.push(sanitizeErrorMessage(error));
+ }
+
+ if (this.server) {
+ try {
+ await this.server.close();
+ } catch (error) {
+ cleanupErrors.push(sanitizeErrorMessage(error));
+ }
+ }
+
+ if (cleanupErrors.length > 0) {
+ throw new Error(`Cleanup failed: ${cleanupErrors.join('; ')}`);
+ }
+ })();
+
+ return this.cleanupPromise;
+ }
+
+ async close(): Promise {
+ await this.cleanup();
+ }
+
+ async handleToolCall(toolName: string, args: unknown): Promise {
+ let auditEventEmitted = false;
+
+ try {
+ const tool = this.enabledToolsMap[toolName];
+
+ if (!tool) {
+ const wasAvailable = this.availableToolsList.some(t => t.name === toolName);
+ const message = wasAvailable
+ ? `Tool "${toolName}" is available but not enabled by the current server configuration.`
+ : `Tool '${toolName}' is not enabled or does not exist.`;
+ emitAuditEvent({
+ reason: 'tool_not_enabled',
+ toolName,
+ args,
+ securityPolicy: this.securityPolicy,
+ allowToolConnectionString: this.allowToolConnectionString,
+ availableButDisabled: wasAvailable,
+ message
+ });
+ auditEventEmitted = true;
+ throw new McpError(ErrorCode.MethodNotFound, message);
+ }
+
+ if (!this.allowToolConnectionString && hasToolSuppliedConnectionString(args)) {
+ const message = 'Per-tool connection string arguments are disabled by default. Use the server --connection-string option, POSTGRES_CONNECTION_STRING, or explicitly enable --allow-tool-connection-string.';
+ emitAuditEvent({
+ reason: 'per_tool_connection_string_blocked',
+ toolName,
+ args,
+ securityPolicy: this.securityPolicy,
+ allowToolConnectionString: this.allowToolConnectionString,
+ message
+ });
+ auditEventEmitted = true;
+ throw new McpError(
+ ErrorCode.InvalidParams,
+ message
+ );
+ }
+
+ if (this.allowToolConnectionString) {
+ for (const connectionString of getToolSuppliedConnectionStrings(args)) {
+ try {
+ assertConnectionTargetAllowed(connectionString, this.runtimeConfig.allowedConnectionTargets);
+ } catch (error) {
+ emitAuditEvent({
+ reason: 'connection_target_denied',
+ toolName,
+ args,
+ securityPolicy: this.securityPolicy,
+ allowToolConnectionString: this.allowToolConnectionString,
+ message: error instanceof Error ? error.message : String(error)
+ });
+ auditEventEmitted = true;
+ throw error;
+ }
+ }
+ }
+
+ const classification = classifyToolCall(toolName, args);
+ if (!isToolCallAllowed(this.securityPolicy, classification)) {
+ const message = explainPolicyDenial(this.securityPolicy, classification);
+ emitAuditEvent({
+ reason: 'security_policy_denied',
+ toolName,
+ args,
+ securityPolicy: this.securityPolicy,
+ allowToolConnectionString: this.allowToolConnectionString,
+ classification,
+ message
+ });
+ auditEventEmitted = true;
+ throw new McpError(ErrorCode.InvalidParams, message);
+ }
+
+ return await tool.execute(args, this.getConnectionString.bind(this));
+ } catch (error) {
+ const errorMessage = sanitizeErrorMessage(error);
+ if (!auditEventEmitted && isConnectionTargetDenial(error)) {
+ emitAuditEvent({
+ reason: 'connection_target_denied',
+ toolName,
+ args,
+ securityPolicy: this.securityPolicy,
+ allowToolConnectionString: this.allowToolConnectionString,
+ message: errorMessage
+ });
+ }
+ console.error(`Error handling request for tool ${toolName}:`, errorMessage);
+ return {
+ content: [{ type: 'text', text: `Error: ${errorMessage}` }],
+ isError: true,
+ } as ToolOutput;
}
}
+ listTools(): ListToolsResult {
+ return {
+ tools: this.enabledTools.map(tool => PostgreSQLServer.toolMetadata(tool)),
+ };
+ }
+
/**
* Setup MCP request handlers
*/
private setupToolHandlers(): void {
- this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
- tools: this.enabledTools.map(tool => ({
- name: tool.name,
- description: tool.description,
- inputSchema: zodToJsonSchema(tool.inputSchema),
- })),
- }));
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => this.listTools());
// Handle tool execution requests
// biome-ignore lint/suspicious/noExplicitAny: MCP SDK type inference issue
this.server.setRequestHandler(CallToolRequestSchema, (async (request: any): Promise => {
- try {
- const toolName = request.params.name;
- const tool = this.enabledToolsMap[toolName];
-
- if (!tool) {
- const wasAvailable = this.availableToolsList.some(t => t.name === toolName);
- const message = wasAvailable
- ? `Tool "${toolName}" is available but not enabled by the current server configuration.`
- : `Tool '${toolName}' is not enabled or does not exist.`;
- throw new McpError(ErrorCode.MethodNotFound, message);
- }
-
- const result: ToolOutput = await tool.execute(request.params.arguments, getConnectionString);
- return result;
- } catch (error) {
- console.error(`Error handling request for tool ${request.params.name}:`, error);
- let errorMessage = error instanceof Error ? error.message : String(error);
- if (error instanceof McpError) {
- errorMessage = error.message;
- }
- return {
- content: [{ type: 'text', text: `Error: ${errorMessage}` }],
- isError: true,
- } as ToolOutput;
- }
+ return this.handleToolCall(request.params.name, request.params.arguments);
// biome-ignore lint/suspicious/noExplicitAny: MCP SDK type inference issue
}) as any);
}
async run() {
- if (this.availableToolsList.length === 0 && !options.toolsConfig) {
+ if (this.availableToolsList.length === 0 && !this.runtimeConfig.toolsConfigPath) {
console.warn("[MCP Warning] No tools loaded and no tools config provided. Server will start with no active tools.");
}
- this.loadAndFilterTools();
const transport = new StdioServerTransport();
await this.server.connect(transport);
+ console.error(`[MCP Info] Security mode: ${this.securityPolicy.mode}. Destructive operations: ${this.securityPolicy.allowDestructive ? 'allowed' : 'blocked'}. Per-tool connection strings: ${this.allowToolConnectionString ? 'allowed' : 'blocked'}.`);
+ console.error(`[MCP Info] Filesystem workspace: ${process.env.POSTGRES_MCP_WORKSPACE_DIR || 'not configured'}. Max file bytes: ${process.env.POSTGRES_MCP_MAX_FILE_BYTES || '10485760'}.`);
+ console.error(`[MCP Info] Pool max connections: ${process.env.POSTGRES_MCP_MAX_CONNECTIONS || DEFAULT_MAX_CONNECTIONS}. Pool idle timeout: ${process.env.POSTGRES_MCP_IDLE_TIMEOUT_MS || DEFAULT_POOL_IDLE_TIMEOUT_MS}. Connection timeout: ${process.env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS || DEFAULT_CONNECTION_TIMEOUT_MS}.`);
+ console.error(`[MCP Info] Statement timeout: ${process.env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS || DEFAULT_STATEMENT_TIMEOUT_MS}. Query timeout: ${process.env.POSTGRES_MCP_QUERY_TIMEOUT_MS || DEFAULT_QUERY_TIMEOUT_MS}. Lock timeout: ${process.env.POSTGRES_MCP_LOCK_TIMEOUT_MS || DEFAULT_LOCK_TIMEOUT_MS}. Idle transaction timeout: ${process.env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS || DEFAULT_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS}.`);
console.error('PostgreSQL MCP server running on stdio');
}
}
@@ -222,7 +864,7 @@ class PostgreSQLServer {
* All available PostgreSQL MCP tools
* Organized by category for maintainability
*/
-const allTools: PostgresTool[] = [
+export const allTools: PostgresTool[] = [
// Core Analysis & Debugging
analyzeDatabaseTool,
debugDatabaseTool,
@@ -256,9 +898,49 @@ const allTools: PostgresTool[] = [
monitorDatabaseTool
];
-const serverInstance = new PostgreSQLServer(allTools);
+export async function main(argv = process.argv): Promise {
+ const options = parseCliOptions(argv);
+ const runtimeConfig = createRuntimeConfig(options);
+ const serverInstance = new PostgreSQLServer(allTools, runtimeConfig);
+ try {
+ await serverInstance.run();
+ } catch (error) {
+ try {
+ await serverInstance.close();
+ } catch (cleanupError) {
+ console.error('Error during PostgreSQL MCP server cleanup after failed startup:', sanitizeErrorMessage(cleanupError));
+ }
+ throw error;
+ }
+}
+
+type RealpathResolver = (filePath: string) => string;
+
+function resolveEntrypointPath(filePath: string, realpath: RealpathResolver): string {
+ try {
+ return realpath(filePath);
+ } catch {
+ return path.resolve(filePath);
+ }
+}
-serverInstance.run().catch(error => {
- console.error('Failed to run the server:', error);
- process.exit(1);
-});
+export function isCliEntrypointPath(
+ modulePath: string,
+ argvPath: string | undefined,
+ realpath: RealpathResolver = fs.realpathSync
+): boolean {
+ if (!argvPath) {
+ return false;
+ }
+
+ return resolveEntrypointPath(modulePath, realpath) === resolveEntrypointPath(path.resolve(argvPath), realpath);
+}
+
+const isCliEntrypoint = isCliEntrypointPath(fileURLToPath(import.meta.url), process.argv[1]);
+
+if (isCliEntrypoint) {
+ main().catch(error => {
+ console.error('Failed to run the server:', sanitizeErrorMessage(error));
+ process.exit(1);
+ });
+}
diff --git a/src/integration/postgres.integration.test.ts b/src/integration/postgres.integration.test.ts
new file mode 100644
index 0000000..bd3ff6b
--- /dev/null
+++ b/src/integration/postgres.integration.test.ts
@@ -0,0 +1,177 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import pkg, { type Pool as PoolType } from 'pg';
+import { PostgreSQLServer, allTools, type RuntimeConfig } from '../index';
+import { DatabaseConnection } from '../utils/connection';
+
+const { Pool } = pkg;
+const integrationConnectionString = process.env.POSTGRES_MCP_INTEGRATION_CONNECTION_STRING;
+const describePostgres = integrationConnectionString ? describe : describe.skip;
+
+function getIntegrationConnectionString(): string {
+ if (!integrationConnectionString) {
+ throw new Error('POSTGRES_MCP_INTEGRATION_CONNECTION_STRING is required for integration tests.');
+ }
+
+ return integrationConnectionString;
+}
+
+function runtimeConfig(overrides: Partial = {}): RuntimeConfig {
+ return {
+ securityPolicy: { mode: 'readonly', allowDestructive: false },
+ allowToolConnectionString: false,
+ connectionString: getIntegrationConnectionString(),
+ ...overrides
+ };
+}
+
+function quoteIdent(identifier: string): string {
+ return `"${identifier.replace(/"/g, '""')}"`;
+}
+
+describePostgres('PostgreSQL integration', () => {
+ let setupPool: PoolType;
+ let schemaName: string;
+
+ const table = (name: string): string => `${quoteIdent(schemaName)}.${quoteIdent(name)}`;
+
+ beforeAll(async () => {
+ schemaName = `mcp_integration_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ setupPool = new Pool({
+ connectionString: getIntegrationConnectionString(),
+ allowExitOnIdle: true,
+ connectionTimeoutMillis: 2000,
+ idleTimeoutMillis: 1000
+ });
+
+ await setupPool.query(`CREATE SCHEMA ${quoteIdent(schemaName)}`);
+ await setupPool.query(`
+ CREATE TABLE ${table('items')} (
+ id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ name text NOT NULL,
+ quantity integer NOT NULL
+ )
+ `);
+ });
+
+ beforeEach(async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ await setupPool.query(`TRUNCATE ${table('items')} RESTART IDENTITY`);
+ await setupPool.query(`DROP TABLE IF EXISTS ${table('sql_created')}`);
+ await DatabaseConnection.cleanupPools();
+ });
+
+ afterAll(async () => {
+ vi.restoreAllMocks();
+ await DatabaseConnection.cleanupPools();
+
+ if (setupPool) {
+ await setupPool.query(`DROP SCHEMA IF EXISTS ${quoteIdent(schemaName)} CASCADE`);
+ await setupPool.end();
+ }
+ });
+
+ it('executes a readonly query through the MCP server boundary against real PostgreSQL', async () => {
+ await setupPool.query(`INSERT INTO ${table('items')} (name, quantity) VALUES ($1, $2)`, ['alpha', 2]);
+ const server = new PostgreSQLServer(allTools, runtimeConfig(), { registerSignalHandlers: false });
+
+ try {
+ const result = await server.handleToolCall('pg_execute_query', {
+ operation: 'select',
+ query: `SELECT name, quantity FROM ${table('items')} WHERE quantity >= $1 ORDER BY id`,
+ parameters: [1],
+ limit: 10
+ });
+ const text = String(result.content[0].text);
+
+ expect(result.isError).toBeUndefined();
+ expect(text).toContain('Query executed successfully. Retrieved 1 rows.');
+ expect(text).toContain('"name": "alpha"');
+ expect(text).toContain('"quantity": 2');
+ } finally {
+ await server.close();
+ }
+ });
+
+ it('blocks write tools in readonly mode before the database is changed', async () => {
+ const server = new PostgreSQLServer(allTools, runtimeConfig(), { registerSignalHandlers: false });
+
+ try {
+ const result = await server.handleToolCall('pg_execute_mutation', {
+ operation: 'insert',
+ schema: schemaName,
+ table: 'items',
+ data: { name: 'blocked', quantity: 1 }
+ });
+ const rowCount = await setupPool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${table('items')}`);
+
+ expect(result.isError).toBe(true);
+ expect(String(result.content[0].text)).toContain('Blocked by PostgreSQL MCP security policy');
+ expect(rowCount.rows[0]?.count).toBe('0');
+ } finally {
+ await server.close();
+ }
+ });
+
+ it('executes structured write mutations in write mode against real PostgreSQL', async () => {
+ const server = new PostgreSQLServer(allTools, runtimeConfig({
+ securityPolicy: { mode: 'write', allowDestructive: false }
+ }), { registerSignalHandlers: false });
+
+ try {
+ const insert = await server.handleToolCall('pg_execute_mutation', {
+ operation: 'insert',
+ schema: schemaName,
+ table: 'items',
+ data: { name: 'alpha', quantity: 1 },
+ returning: ['id', 'name'],
+ maxReturningRows: 10
+ });
+ const update = await server.handleToolCall('pg_execute_mutation', {
+ operation: 'update',
+ schema: schemaName,
+ table: 'items',
+ data: { quantity: 3 },
+ where: { name: 'alpha' },
+ returning: ['name', 'quantity'],
+ maxReturningRows: 10
+ });
+ const rows = await setupPool.query<{ quantity: number }>(
+ `SELECT quantity FROM ${table('items')} WHERE name = $1`,
+ ['alpha']
+ );
+
+ expect(insert.isError).toBeUndefined();
+ expect(String(insert.content[0].text)).toContain('INSERT operation completed successfully. Rows affected: 1');
+ expect(update.isError).toBeUndefined();
+ expect(String(update.content[0].text)).toContain('"quantity": 3');
+ expect(rows.rows).toEqual([{ quantity: 3 }]);
+ } finally {
+ await server.close();
+ }
+ });
+
+ it('executes multi-statement arbitrary SQL only through unsafe transactional mode', async () => {
+ const server = new PostgreSQLServer(allTools, runtimeConfig({
+ securityPolicy: { mode: 'unsafe', allowDestructive: true }
+ }), { registerSignalHandlers: false });
+
+ try {
+ const result = await server.handleToolCall('pg_execute_sql', {
+ sql: `
+ CREATE TABLE ${table('sql_created')} (id integer PRIMARY KEY, label text NOT NULL);
+ INSERT INTO ${table('sql_created')} (id, label) VALUES (1, 'created');
+ `,
+ expectRows: false,
+ transactional: true
+ });
+ const rows = await setupPool.query<{ label: string }>(`SELECT label FROM ${table('sql_created')} WHERE id = 1`);
+
+ expect(result.isError).toBeUndefined();
+ expect(String(result.content[0].text)).toContain('SQL executed successfully in transaction');
+ expect(rows.rows).toEqual([{ label: 'created' }]);
+ } finally {
+ await server.close();
+ }
+ });
+});
diff --git a/src/security/policy.test.ts b/src/security/policy.test.ts
new file mode 100644
index 0000000..4fea446
--- /dev/null
+++ b/src/security/policy.test.ts
@@ -0,0 +1,467 @@
+import { describe, expect, it } from 'vitest';
+import {
+ classifyToolCall,
+ explainPolicyDenial,
+ isToolCallAllowed,
+ normalizeSecurityMode,
+ type ToolRisk,
+ type SecurityPolicy
+} from './policy';
+import { allTools } from '../index';
+import { zodToJsonSchema } from 'zod-to-json-schema';
+
+const readonlyPolicy: SecurityPolicy = { mode: 'readonly', allowDestructive: false };
+const writePolicy: SecurityPolicy = { mode: 'write', allowDestructive: false };
+const adminPolicy: SecurityPolicy = { mode: 'admin', allowDestructive: false };
+const destructiveAdminPolicy: SecurityPolicy = { mode: 'admin', allowDestructive: true };
+const unsafePolicy: SecurityPolicy = { mode: 'unsafe', allowDestructive: true };
+
+const operationPolicyExpectations = {
+ pg_manage_schema: {
+ get_info: ['read', false],
+ get_enums: ['read', false],
+ create_table: ['ddl', false],
+ alter_table: ['ddl', true],
+ create_enum: ['ddl', false]
+ },
+ pg_manage_query: {
+ explain: ['read', false],
+ get_slow_queries: ['read', false],
+ get_stats: ['read', false],
+ reset_stats: ['ddl', true]
+ },
+ pg_manage_indexes: {
+ get: ['read', false],
+ analyze_usage: ['read', false],
+ create: ['ddl', false],
+ drop: ['ddl', true],
+ reindex: ['ddl', true]
+ },
+ pg_manage_constraints: {
+ get: ['read', false],
+ create_fk: ['ddl', false],
+ create: ['ddl', false],
+ drop_fk: ['ddl', true],
+ drop: ['ddl', true]
+ },
+ pg_manage_functions: {
+ get: ['read', false],
+ create: ['arbitrary_sql', true],
+ drop: ['ddl', true]
+ },
+ pg_manage_rls: {
+ get_policies: ['read', false],
+ enable: ['ddl', false],
+ disable: ['ddl', true],
+ create_policy: ['arbitrary_sql', true],
+ edit_policy: ['ddl', false],
+ drop_policy: ['ddl', true]
+ },
+ pg_manage_triggers: {
+ get: ['read', false],
+ create: ['ddl', false],
+ drop: ['ddl', true],
+ set_state: ['ddl', true]
+ },
+ pg_manage_comments: {
+ get: ['read', false],
+ bulk_get: ['read', false],
+ set: ['ddl', false],
+ remove: ['ddl', true]
+ },
+ pg_manage_users: {
+ list: ['read', false],
+ get_permissions: ['read', false],
+ create: ['role_admin', false],
+ alter: ['role_admin', false],
+ grant: ['role_admin', false],
+ drop: ['role_admin', true],
+ revoke: ['role_admin', true]
+ },
+ pg_execute_query: {
+ select: ['read', false],
+ count: ['read', false],
+ exists: ['read', false]
+ },
+ pg_execute_mutation: {
+ insert: ['write', false],
+ update: ['write', false],
+ delete: ['write', true],
+ upsert: ['write', false]
+ }
+} satisfies Record>;
+
+function runtimeOperationEnums(): Record {
+ const operationEnums: Record = {};
+
+ for (const tool of allTools) {
+ const schema = zodToJsonSchema(tool.inputSchema) as {
+ properties?: {
+ operation?: {
+ enum?: unknown[];
+ };
+ };
+ };
+ const enumValues = schema.properties?.operation?.enum;
+
+ if (enumValues) {
+ operationEnums[tool.name] = enumValues.filter((value): value is string => typeof value === 'string');
+ }
+ }
+
+ return operationEnums;
+}
+
+describe('security policy', () => {
+ it('defaults absent modes to readonly and rejects invalid explicit modes', () => {
+ expect(normalizeSecurityMode(undefined)).toBe('readonly');
+ expect(normalizeSecurityMode('')).toBe('readonly');
+ expect(() => normalizeSecurityMode('invalid')).toThrow('securityMode must be one of readonly, write, admin, or unsafe');
+ expect(normalizeSecurityMode('readonly')).toBe('readonly');
+ expect(normalizeSecurityMode('write')).toBe('write');
+ expect(normalizeSecurityMode('admin')).toBe('admin');
+ expect(normalizeSecurityMode('unsafe')).toBe('unsafe');
+ });
+
+ it('allows read operations in readonly mode', () => {
+ const classification = classifyToolCall('pg_manage_schema', { operation: 'get_info' });
+
+ expect(classification.risk).toBe('read');
+ expect(isToolCallAllowed(readonlyPolicy, classification)).toBe(true);
+ });
+
+ it('blocks schema writes in readonly mode', () => {
+ const classification = classifyToolCall('pg_manage_schema', { operation: 'create_table' });
+
+ expect(classification.risk).toBe('ddl');
+ expect(isToolCallAllowed(readonlyPolicy, classification)).toBe(false);
+ expect(explainPolicyDenial(readonlyPolicy, classification)).toContain('Current mode "readonly"');
+ });
+
+ it('allows mutation in write mode but blocks it in readonly mode', () => {
+ const classification = classifyToolCall('pg_execute_mutation', { operation: 'insert' });
+
+ expect(classification.risk).toBe('write');
+ expect(isToolCallAllowed(readonlyPolicy, classification)).toBe(false);
+ expect(isToolCallAllowed(writePolicy, classification)).toBe(true);
+ });
+
+ it('treats raw mutation SQL fragments as unsafe', () => {
+ const legacyWhere = classifyToolCall('pg_execute_mutation', { operation: 'update', where: 'id = 1' });
+ const rawWhere = classifyToolCall('pg_execute_mutation', { operation: 'delete', rawWhere: 'id = 1' });
+ const structuredWhere = classifyToolCall('pg_execute_mutation', { operation: 'update', where: { id: 1 } });
+
+ expect(legacyWhere.risk).toBe('arbitrary_sql');
+ expect(rawWhere.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(writePolicy, legacyWhere)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, legacyWhere)).toBe(true);
+ expect(structuredWhere.risk).toBe('write');
+ expect(isToolCallAllowed(writePolicy, structuredWhere)).toBe(true);
+ });
+
+ it('treats raw migration SQL fragments as unsafe', () => {
+ const rawExport = classifyToolCall('pg_export_table_data', { where: 'id = 1' });
+ const structuredExport = classifyToolCall('pg_export_table_data', { where: { id: 1 } });
+
+ expect(rawExport.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(adminPolicy, rawExport)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, rawExport)).toBe(true);
+ expect(structuredExport.risk).toBe('filesystem');
+ expect(isToolCallAllowed(adminPolicy, structuredExport)).toBe(true);
+ });
+
+ it('treats raw partial-index predicates as unsafe', () => {
+ const rawIndex = classifyToolCall('pg_manage_indexes', {
+ operation: 'create',
+ where: 'deleted_at IS NULL'
+ });
+ const structuredIndex = classifyToolCall('pg_manage_indexes', {
+ operation: 'create',
+ where: { deleted_at: { isNull: true } }
+ });
+
+ expect(rawIndex.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(adminPolicy, rawIndex)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, rawIndex)).toBe(true);
+ expect(structuredIndex.risk).toBe('ddl');
+ expect(isToolCallAllowed(adminPolicy, structuredIndex)).toBe(true);
+ });
+
+ it('treats raw CHECK expressions as unsafe', () => {
+ const checkConstraint = classifyToolCall('pg_manage_constraints', {
+ operation: 'create',
+ constraintTypeCreate: 'check',
+ checkExpression: 'price > 0'
+ });
+ const uniqueConstraint = classifyToolCall('pg_manage_constraints', {
+ operation: 'create',
+ constraintTypeCreate: 'unique',
+ columnNames: ['email']
+ });
+
+ expect(checkConstraint.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(adminPolicy, checkConstraint)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, checkConstraint)).toBe(true);
+ expect(uniqueConstraint.risk).toBe('ddl');
+ expect(isToolCallAllowed(adminPolicy, uniqueConstraint)).toBe(true);
+ });
+
+ it('treats raw schema default expressions as unsafe', () => {
+ const createWithDefault = classifyToolCall('pg_manage_schema', {
+ operation: 'create_table',
+ tableName: 'users',
+ columns: [{ name: 'created_at', type: 'timestamp', default: 'now()' }]
+ });
+ const createWithoutDefault = classifyToolCall('pg_manage_schema', {
+ operation: 'create_table',
+ tableName: 'users',
+ columns: [{ name: 'created_at', type: 'timestamp' }]
+ });
+
+ expect(createWithDefault.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(adminPolicy, createWithDefault)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, createWithDefault)).toBe(true);
+ expect(createWithoutDefault.risk).toBe('ddl');
+ expect(isToolCallAllowed(adminPolicy, createWithoutDefault)).toBe(true);
+ });
+
+ it('treats EXPLAIN ANALYZE as unsafe because it executes supplied SQL', () => {
+ const explainOnly = classifyToolCall('pg_manage_query', {
+ operation: 'explain',
+ query: 'SELECT 1',
+ analyze: false
+ });
+ const explainAnalyze = classifyToolCall('pg_manage_query', {
+ operation: 'explain',
+ query: 'SELECT 1',
+ analyze: true
+ });
+
+ expect(explainOnly.risk).toBe('read');
+ expect(isToolCallAllowed(readonlyPolicy, explainOnly)).toBe(true);
+ expect(explainAnalyze.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(adminPolicy, explainAnalyze)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, explainAnalyze)).toBe(true);
+ });
+
+ it('classifies legacy direct performance tools consistently', () => {
+ const directExplain = classifyToolCall('pg_explain_query', {
+ query: 'SELECT 1',
+ analyze: false
+ });
+ const directAnalyze = classifyToolCall('pg_explain_query', {
+ query: 'SELECT 1',
+ analyze: true
+ });
+ const resetStats = classifyToolCall('pg_reset_query_stats', {});
+
+ expect(directExplain.risk).toBe('read');
+ expect(isToolCallAllowed(readonlyPolicy, directExplain)).toBe(true);
+ expect(directAnalyze.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(adminPolicy, directAnalyze)).toBe(false);
+ expect(resetStats.risk).toBe('ddl');
+ expect(resetStats.destructive).toBe(true);
+ expect(isToolCallAllowed(adminPolicy, resetStats)).toBe(false);
+ expect(isToolCallAllowed(destructiveAdminPolicy, resetStats)).toBe(true);
+ });
+
+ it('classifies legacy direct enum tools consistently', () => {
+ const getEnums = classifyToolCall('pg_get_enums', {});
+ const createEnum = classifyToolCall('pg_create_enum', {});
+
+ expect(getEnums.risk).toBe('read');
+ expect(isToolCallAllowed(readonlyPolicy, getEnums)).toBe(true);
+ expect(createEnum.risk).toBe('ddl');
+ expect(isToolCallAllowed(adminPolicy, createEnum)).toBe(true);
+ });
+
+ it('treats function creation as unsafe executable SQL', () => {
+ const createFunction = classifyToolCall('pg_manage_functions', {
+ operation: 'create',
+ functionName: 'calculate_total',
+ parameters: 'price DECIMAL',
+ returnType: 'DECIMAL',
+ functionBody: 'SELECT price'
+ });
+ const dropFunction = classifyToolCall('pg_manage_functions', {
+ operation: 'drop',
+ functionName: 'old_func'
+ });
+
+ expect(createFunction.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(adminPolicy, createFunction)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, createFunction)).toBe(true);
+ expect(dropFunction.risk).toBe('ddl');
+ expect(dropFunction.destructive).toBe(true);
+ });
+
+ it('treats raw RLS policy expressions as unsafe', () => {
+ const createPolicy = classifyToolCall('pg_manage_rls', {
+ operation: 'create_policy',
+ tableName: 'users',
+ policyName: 'user_isolation',
+ using: 'user_id = current_user_id()'
+ });
+ const editRolesOnly = classifyToolCall('pg_manage_rls', {
+ operation: 'edit_policy',
+ tableName: 'users',
+ policyName: 'user_isolation',
+ roles: ['authenticated']
+ });
+ const editExpression = classifyToolCall('pg_manage_rls', {
+ operation: 'edit_policy',
+ tableName: 'users',
+ policyName: 'user_isolation',
+ using: 'tenant_id = current_tenant_id()'
+ });
+
+ expect(createPolicy.risk).toBe('arbitrary_sql');
+ expect(editExpression.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(adminPolicy, createPolicy)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, createPolicy)).toBe(true);
+ expect(editRolesOnly.risk).toBe('ddl');
+ expect(isToolCallAllowed(adminPolicy, editRolesOnly)).toBe(true);
+ });
+
+ it('treats raw trigger WHEN expressions as unsafe', () => {
+ const rawTrigger = classifyToolCall('pg_manage_triggers', {
+ operation: 'create',
+ triggerName: 'audit_trigger',
+ tableName: 'users',
+ functionName: 'audit_function',
+ when: 'NEW.active = true'
+ });
+ const triggerWithoutWhen = classifyToolCall('pg_manage_triggers', {
+ operation: 'create',
+ triggerName: 'audit_trigger',
+ tableName: 'users',
+ functionName: 'audit_function'
+ });
+
+ expect(rawTrigger.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(adminPolicy, rawTrigger)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, rawTrigger)).toBe(true);
+ expect(triggerWithoutWhen.risk).toBe('ddl');
+ expect(isToolCallAllowed(adminPolicy, triggerWithoutWhen)).toBe(true);
+ });
+
+ it('requires destructive opt-in even when the mode otherwise allows the risk', () => {
+ const classification = classifyToolCall('pg_manage_indexes', { operation: 'drop' });
+
+ expect(classification.risk).toBe('ddl');
+ expect(classification.destructive).toBe(true);
+ expect(isToolCallAllowed(adminPolicy, classification)).toBe(false);
+ expect(isToolCallAllowed(destructiveAdminPolicy, classification)).toBe(true);
+ });
+
+ it('requires unsafe mode for arbitrary SQL', () => {
+ const classification = classifyToolCall('pg_execute_sql', { sql: 'SELECT 1' });
+
+ expect(classification.risk).toBe('arbitrary_sql');
+ expect(isToolCallAllowed(readonlyPolicy, classification)).toBe(false);
+ expect(isToolCallAllowed(writePolicy, classification)).toBe(false);
+ expect(isToolCallAllowed(destructiveAdminPolicy, classification)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, classification)).toBe(true);
+ });
+
+ it('keeps read-only operations available inside mixed meta-tools', () => {
+ expect(isToolCallAllowed(readonlyPolicy, classifyToolCall('pg_manage_users', { operation: 'list' }))).toBe(true);
+ expect(isToolCallAllowed(readonlyPolicy, classifyToolCall('pg_manage_users', { operation: 'create' }))).toBe(false);
+ expect(isToolCallAllowed(readonlyPolicy, classifyToolCall('pg_manage_comments', { operation: 'get' }))).toBe(true);
+ expect(isToolCallAllowed(readonlyPolicy, classifyToolCall('pg_manage_comments', { operation: 'set' }))).toBe(false);
+ });
+
+ it('requires destructive opt-in for privilege-escalating role attributes', () => {
+ const regularCreate = classifyToolCall('pg_manage_users', {
+ operation: 'create',
+ username: 'reporting_user'
+ });
+ const superuserCreate = classifyToolCall('pg_manage_users', {
+ operation: 'create',
+ username: 'break_glass',
+ superuser: true
+ });
+ const createdbAlter = classifyToolCall('pg_alter_user', {
+ username: 'migrator',
+ createdb: true
+ });
+ const reducePrivileges = classifyToolCall('pg_manage_users', {
+ operation: 'alter',
+ username: 'migrator',
+ createrole: false
+ });
+
+ expect(regularCreate.risk).toBe('role_admin');
+ expect(regularCreate.destructive).toBe(false);
+ expect(superuserCreate.risk).toBe('role_admin');
+ expect(superuserCreate.destructive).toBe(true);
+ expect(createdbAlter.risk).toBe('role_admin');
+ expect(createdbAlter.destructive).toBe(true);
+ expect(reducePrivileges.destructive).toBe(false);
+ expect(isToolCallAllowed(adminPolicy, superuserCreate)).toBe(false);
+ expect(isToolCallAllowed(destructiveAdminPolicy, superuserCreate)).toBe(true);
+ });
+
+ it('requires destructive opt-in for broad or delegable grants', () => {
+ const regularGrant = classifyToolCall('pg_manage_users', {
+ operation: 'grant',
+ permissions: ['SELECT']
+ });
+ const delegableGrant = classifyToolCall('pg_manage_users', {
+ operation: 'grant',
+ permissions: ['SELECT'],
+ withGrantOption: true
+ });
+ const allGrant = classifyToolCall('pg_grant_permissions', {
+ permissions: ['ALL']
+ });
+ const truncateGrant = classifyToolCall('pg_manage_users', {
+ operation: 'grant',
+ permissions: ['TRUNCATE']
+ });
+
+ expect(regularGrant.risk).toBe('role_admin');
+ expect(regularGrant.destructive).toBe(false);
+ expect(delegableGrant.destructive).toBe(true);
+ expect(allGrant.destructive).toBe(true);
+ expect(truncateGrant.destructive).toBe(true);
+ expect(isToolCallAllowed(adminPolicy, delegableGrant)).toBe(false);
+ expect(isToolCallAllowed(destructiveAdminPolicy, delegableGrant)).toBe(true);
+ });
+
+ it('has explicit policy expectations for every runtime operation enum', () => {
+ const runtimeEnums = runtimeOperationEnums();
+ const expectedToolNames = Object.keys(operationPolicyExpectations).sort();
+ const runtimeToolNames = Object.keys(runtimeEnums).sort();
+
+ expect(expectedToolNames).toEqual(runtimeToolNames);
+
+ for (const [toolName, operations] of Object.entries(runtimeEnums)) {
+ const expectedOperations = Object.keys(operationPolicyExpectations[toolName]).sort();
+ expect([...operations].sort()).toEqual(expectedOperations);
+
+ for (const operation of operations) {
+ const [expectedRisk, expectedDestructive] = operationPolicyExpectations[toolName][operation];
+ const args = { operation };
+ const classification = classifyToolCall(toolName, args);
+
+ expect(classification.risk, `${toolName}.${operation} risk`).toBe(expectedRisk);
+ expect(classification.destructive, `${toolName}.${operation} destructive`).toBe(expectedDestructive);
+ expect(classification.risk, `${toolName}.${operation} must be explicitly classified`).not.toBe('unclassified');
+ }
+ }
+ });
+
+ it('fails closed for unclassified tools and unclassified managed operations', () => {
+ const unclassifiedTool = classifyToolCall('pg_future_tool', {});
+ const unclassifiedOperation = classifyToolCall('pg_manage_schema', { operation: 'future_operation' });
+
+ expect(unclassifiedTool.risk).toBe('unclassified');
+ expect(unclassifiedOperation.risk).toBe('unclassified');
+ expect(isToolCallAllowed(destructiveAdminPolicy, unclassifiedTool)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, unclassifiedTool)).toBe(false);
+ expect(isToolCallAllowed(destructiveAdminPolicy, unclassifiedOperation)).toBe(false);
+ expect(isToolCallAllowed(unsafePolicy, unclassifiedOperation)).toBe(false);
+ expect(explainPolicyDenial(unsafePolicy, unclassifiedOperation)).toContain('not explicitly classified');
+ });
+});
diff --git a/src/security/policy.ts b/src/security/policy.ts
new file mode 100644
index 0000000..0a3fac2
--- /dev/null
+++ b/src/security/policy.ts
@@ -0,0 +1,407 @@
+export type SecurityMode = 'readonly' | 'write' | 'admin' | 'unsafe';
+
+export type ToolRisk = 'read' | 'write' | 'ddl' | 'role_admin' | 'filesystem' | 'arbitrary_sql' | 'unclassified';
+
+export interface SecurityPolicy {
+ mode: SecurityMode;
+ allowDestructive: boolean;
+}
+
+export interface ToolCallClassification {
+ risk: ToolRisk;
+ destructive: boolean;
+ reason: string;
+}
+
+const MODE_ALLOWANCES: Record> = {
+ readonly: new Set(['read']),
+ write: new Set(['read', 'write']),
+ admin: new Set(['read', 'write', 'ddl', 'role_admin', 'filesystem']),
+ unsafe: new Set(['read', 'write', 'ddl', 'role_admin', 'filesystem', 'arbitrary_sql'])
+};
+
+const READ_ONLY_TOOLS = new Set([
+ 'pg_analyze_database',
+ 'pg_debug_database',
+ 'pg_execute_query',
+ 'pg_monitor_database',
+ 'pg_explain_query',
+ 'pg_get_enums',
+ 'pg_get_slow_queries',
+ 'pg_get_query_stats'
+]);
+
+const WRITE_ONLY_TOOLS = new Set([
+ 'pg_execute_mutation'
+]);
+
+const FILESYSTEM_TOOLS = new Set([
+ 'pg_export_table_data',
+ 'pg_import_table_data',
+ 'pg_copy_between_databases'
+]);
+
+const ROLE_ADMIN_TOOLS = new Set([
+ 'pg_create_user',
+ 'pg_drop_user',
+ 'pg_alter_user',
+ 'pg_grant_permissions',
+ 'pg_revoke_permissions',
+ 'pg_manage_users'
+]);
+
+const DDL_TOOLS = new Set([
+ 'pg_create_table',
+ 'pg_alter_table',
+ 'pg_create_enum',
+ 'pg_create_index',
+ 'pg_drop_index',
+ 'pg_reindex',
+ 'pg_manage_indexes',
+ 'pg_create_foreign_key',
+ 'pg_drop_foreign_key',
+ 'pg_create_constraint',
+ 'pg_drop_constraint',
+ 'pg_manage_constraints',
+ 'pg_create_function',
+ 'pg_drop_function',
+ 'pg_manage_functions',
+ 'pg_enable_rls',
+ 'pg_disable_rls',
+ 'pg_create_rls_policy',
+ 'pg_drop_rls_policy',
+ 'pg_edit_rls_policy',
+ 'pg_manage_rls',
+ 'pg_create_trigger',
+ 'pg_drop_trigger',
+ 'pg_set_trigger_state',
+ 'pg_manage_triggers',
+ 'pg_manage_schema',
+ 'pg_manage_comments',
+ 'pg_reset_query_stats'
+]);
+
+type ManagedOperationPolicy = Record & { reason: string }>;
+
+const MANAGED_TOOL_POLICIES: Record = {
+ pg_manage_schema: {
+ get_info: { risk: 'read', destructive: false, reason: 'Schema get_info is read-only.' },
+ get_enums: { risk: 'read', destructive: false, reason: 'Schema get_enums is read-only.' },
+ create_table: { risk: 'ddl', destructive: false, reason: 'Schema create_table changes database structure.' },
+ alter_table: { risk: 'ddl', destructive: true, reason: 'Schema alter_table changes database structure.' },
+ create_enum: { risk: 'ddl', destructive: false, reason: 'Schema create_enum changes database structure.' }
+ },
+ pg_manage_query: {
+ explain: { risk: 'read', destructive: false, reason: 'Query explain is read-only.' },
+ get_slow_queries: { risk: 'read', destructive: false, reason: 'Query get_slow_queries is read-only.' },
+ get_stats: { risk: 'read', destructive: false, reason: 'Query get_stats is read-only.' },
+ reset_stats: { risk: 'ddl', destructive: true, reason: 'Query reset_stats changes database statistics state.' }
+ },
+ pg_manage_indexes: {
+ get: { risk: 'read', destructive: false, reason: 'Index get is read-only.' },
+ analyze_usage: { risk: 'read', destructive: false, reason: 'Index analyze_usage is read-only.' },
+ create: { risk: 'ddl', destructive: false, reason: 'Index create changes database structure.' },
+ drop: { risk: 'ddl', destructive: true, reason: 'Index drop changes database structure.' },
+ reindex: { risk: 'ddl', destructive: true, reason: 'Index reindex changes database structure.' }
+ },
+ pg_manage_constraints: {
+ get: { risk: 'read', destructive: false, reason: 'Constraint get is read-only.' },
+ create_fk: { risk: 'ddl', destructive: false, reason: 'Constraint create_fk changes database structure.' },
+ create: { risk: 'ddl', destructive: false, reason: 'Constraint create changes database structure.' },
+ drop_fk: { risk: 'ddl', destructive: true, reason: 'Constraint drop_fk changes database structure.' },
+ drop: { risk: 'ddl', destructive: true, reason: 'Constraint drop changes database structure.' }
+ },
+ pg_manage_functions: {
+ get: { risk: 'read', destructive: false, reason: 'Function get is read-only.' },
+ create: { risk: 'ddl', destructive: false, reason: 'Function create changes executable database code.' },
+ drop: { risk: 'ddl', destructive: true, reason: 'Function drop changes executable database code.' }
+ },
+ pg_manage_rls: {
+ get_policies: { risk: 'read', destructive: false, reason: 'RLS get_policies is read-only.' },
+ enable: { risk: 'ddl', destructive: false, reason: 'RLS enable changes access policy state.' },
+ disable: { risk: 'ddl', destructive: true, reason: 'RLS disable changes access policy state.' },
+ create_policy: { risk: 'ddl', destructive: false, reason: 'RLS create_policy changes access policy state.' },
+ edit_policy: { risk: 'ddl', destructive: false, reason: 'RLS edit_policy changes access policy state.' },
+ drop_policy: { risk: 'ddl', destructive: true, reason: 'RLS drop_policy changes access policy state.' }
+ },
+ pg_manage_triggers: {
+ get: { risk: 'read', destructive: false, reason: 'Trigger get is read-only.' },
+ create: { risk: 'ddl', destructive: false, reason: 'Trigger create changes database behavior.' },
+ drop: { risk: 'ddl', destructive: true, reason: 'Trigger drop changes database behavior.' },
+ set_state: { risk: 'ddl', destructive: true, reason: 'Trigger set_state changes database behavior.' }
+ },
+ pg_manage_comments: {
+ get: { risk: 'read', destructive: false, reason: 'Comment get is read-only.' },
+ bulk_get: { risk: 'read', destructive: false, reason: 'Comment bulk_get is read-only.' },
+ set: { risk: 'ddl', destructive: false, reason: 'Comment set changes database metadata.' },
+ remove: { risk: 'ddl', destructive: true, reason: 'Comment remove changes database metadata.' }
+ },
+ pg_manage_users: {
+ list: { risk: 'read', destructive: false, reason: 'User list is read-only.' },
+ get_permissions: { risk: 'read', destructive: false, reason: 'User get_permissions is read-only.' },
+ create: { risk: 'role_admin', destructive: false, reason: 'User create changes roles or permissions.' },
+ alter: { risk: 'role_admin', destructive: false, reason: 'User alter changes roles or permissions.' },
+ grant: { risk: 'role_admin', destructive: false, reason: 'User grant changes roles or permissions.' },
+ drop: { risk: 'role_admin', destructive: true, reason: 'User drop changes roles or permissions.' },
+ revoke: { risk: 'role_admin', destructive: true, reason: 'User revoke changes roles or permissions.' }
+ }
+};
+
+function getOperation(args: unknown): string | undefined {
+ if (!args || typeof args !== 'object' || !('operation' in args)) {
+ return undefined;
+ }
+
+ const operation = (args as { operation?: unknown }).operation;
+ return typeof operation === 'string' ? operation : undefined;
+}
+
+function hasRawSchemaDefault(argsObject: Record): boolean {
+ const columns = argsObject.columns;
+ if (Array.isArray(columns) && columns.some((column) =>
+ column && typeof column === 'object' && typeof (column as Record).default === 'string'
+ )) {
+ return true;
+ }
+
+ const operations = argsObject.operations;
+ return Array.isArray(operations) && operations.some((operation) =>
+ operation && typeof operation === 'object' && typeof (operation as Record).default === 'string'
+ );
+}
+
+function hasPrivilegeEscalatingRoleAttribute(argsObject: Record): boolean {
+ return ['superuser', 'createdb', 'createrole', 'replication'].some((attribute) => argsObject[attribute] === true);
+}
+
+function hasDelegatingOrBroadGrant(argsObject: Record): boolean {
+ const permissions = argsObject.permissions;
+ return argsObject.withGrantOption === true ||
+ (Array.isArray(permissions) && permissions.some((permission) => permission === 'ALL' || permission === 'TRUNCATE'));
+}
+
+function classifyManagedTool(toolName: string, operation: string | undefined): ToolCallClassification | null {
+ const operationPolicies = MANAGED_TOOL_POLICIES[toolName];
+ if (!operationPolicies) {
+ return null;
+ }
+
+ if (operation && operation in operationPolicies) {
+ return operationPolicies[operation];
+ }
+
+ return {
+ risk: 'unclassified',
+ destructive: true,
+ reason: `${toolName} operation "${operation || ''}" is not explicitly classified by the security policy.`
+ };
+}
+
+export function normalizeSecurityMode(mode: unknown): SecurityMode {
+ if (mode === undefined || mode === null || mode === '') {
+ return 'readonly';
+ }
+
+ if (mode === 'write' || mode === 'admin' || mode === 'unsafe') {
+ return mode;
+ }
+
+ if (mode === 'readonly') {
+ return 'readonly';
+ }
+
+ throw new Error('securityMode must be one of readonly, write, admin, or unsafe.');
+}
+
+export function classifyToolCall(toolName: string, args: unknown): ToolCallClassification {
+ if (toolName === 'pg_execute_sql') {
+ return { risk: 'arbitrary_sql', destructive: true, reason: 'Arbitrary SQL can read, write, change schema, or change roles.' };
+ }
+
+ const operation = getOperation(args);
+ const argsObject = args && typeof args === 'object' ? args as Record : {};
+ if (
+ (toolName === 'pg_create_index' || (toolName === 'pg_manage_indexes' && operation === 'create')) &&
+ (typeof argsObject.where === 'string' || typeof argsObject.rawWhere === 'string')
+ ) {
+ return {
+ risk: 'arbitrary_sql',
+ destructive: true,
+ reason: `${toolName} includes a raw SQL partial-index predicate.`
+ };
+ }
+ if (
+ ((toolName === 'pg_manage_query' && operation === 'explain') || toolName === 'pg_explain_query') &&
+ argsObject.analyze === true
+ ) {
+ return {
+ risk: 'arbitrary_sql',
+ destructive: true,
+ reason: 'EXPLAIN ANALYZE executes the supplied SQL query.'
+ };
+ }
+ if (
+ (toolName === 'pg_create_constraint' || (toolName === 'pg_manage_constraints' && operation === 'create')) &&
+ typeof argsObject.checkExpression === 'string'
+ ) {
+ return {
+ risk: 'arbitrary_sql',
+ destructive: true,
+ reason: `${toolName} includes a raw SQL CHECK expression.`
+ };
+ }
+ if (
+ (toolName === 'pg_create_table' || toolName === 'pg_alter_table' ||
+ ((toolName === 'pg_manage_schema') && (operation === 'create_table' || operation === 'alter_table'))) &&
+ hasRawSchemaDefault(argsObject)
+ ) {
+ return {
+ risk: 'arbitrary_sql',
+ destructive: true,
+ reason: `${toolName} includes a raw SQL column default expression.`
+ };
+ }
+ if (toolName === 'pg_create_function' || (toolName === 'pg_manage_functions' && operation === 'create')) {
+ return {
+ risk: 'arbitrary_sql',
+ destructive: true,
+ reason: `${toolName} creates executable database code from raw SQL input.`
+ };
+ }
+ if (
+ toolName === 'pg_create_rls_policy' ||
+ (toolName === 'pg_manage_rls' && operation === 'create_policy') ||
+ ((toolName === 'pg_edit_rls_policy' || (toolName === 'pg_manage_rls' && operation === 'edit_policy')) &&
+ (typeof argsObject.using === 'string' || typeof argsObject.check === 'string'))
+ ) {
+ return {
+ risk: 'arbitrary_sql',
+ destructive: true,
+ reason: `${toolName} includes raw SQL RLS policy expressions.`
+ };
+ }
+ if (
+ (toolName === 'pg_create_trigger' || (toolName === 'pg_manage_triggers' && operation === 'create')) &&
+ typeof argsObject.when === 'string'
+ ) {
+ return {
+ risk: 'arbitrary_sql',
+ destructive: true,
+ reason: `${toolName} includes a raw SQL trigger WHEN expression.`
+ };
+ }
+ if (
+ (toolName === 'pg_create_user' || (toolName === 'pg_manage_users' && operation === 'create')) &&
+ hasPrivilegeEscalatingRoleAttribute(argsObject)
+ ) {
+ return {
+ risk: 'role_admin',
+ destructive: true,
+ reason: `${toolName} creates a role with elevated PostgreSQL attributes.`
+ };
+ }
+ if (
+ (toolName === 'pg_alter_user' || (toolName === 'pg_manage_users' && operation === 'alter')) &&
+ hasPrivilegeEscalatingRoleAttribute(argsObject)
+ ) {
+ return {
+ risk: 'role_admin',
+ destructive: true,
+ reason: `${toolName} grants elevated PostgreSQL role attributes.`
+ };
+ }
+ if (
+ (toolName === 'pg_grant_permissions' || (toolName === 'pg_manage_users' && operation === 'grant')) &&
+ hasDelegatingOrBroadGrant(argsObject)
+ ) {
+ return {
+ risk: 'role_admin',
+ destructive: true,
+ reason: `${toolName} grants broad or delegable PostgreSQL permissions.`
+ };
+ }
+
+ const managedClassification = classifyManagedTool(toolName, operation);
+ if (managedClassification) {
+ return managedClassification;
+ }
+
+ if (READ_ONLY_TOOLS.has(toolName)) {
+ return { risk: 'read', destructive: false, reason: `${toolName} is classified as read-only.` };
+ }
+
+ if (WRITE_ONLY_TOOLS.has(toolName)) {
+ if (typeof argsObject.where === 'string' || typeof argsObject.rawWhere === 'string') {
+ return {
+ risk: 'arbitrary_sql',
+ destructive: true,
+ reason: `${toolName} includes a raw SQL WHERE fragment.`
+ };
+ }
+
+ return {
+ risk: 'write',
+ destructive: operation === 'delete',
+ reason: `${toolName} performs data mutation operations.`
+ };
+ }
+
+ if (FILESYSTEM_TOOLS.has(toolName)) {
+ const argsObject = args && typeof args === 'object' ? args as Record : {};
+ if (typeof argsObject.where === 'string' || typeof argsObject.rawWhere === 'string') {
+ return {
+ risk: 'arbitrary_sql',
+ destructive: true,
+ reason: `${toolName} includes a raw SQL WHERE fragment.`
+ };
+ }
+
+ return {
+ risk: 'filesystem',
+ destructive: toolName !== 'pg_export_table_data',
+ reason: `${toolName} reads or writes local files and may move data between trust boundaries.`
+ };
+ }
+
+ if (ROLE_ADMIN_TOOLS.has(toolName)) {
+ return {
+ risk: 'role_admin',
+ destructive: toolName === 'pg_drop_user' || toolName === 'pg_revoke_permissions',
+ reason: `${toolName} changes roles or permissions.`
+ };
+ }
+
+ if (DDL_TOOLS.has(toolName)) {
+ return {
+ risk: 'ddl',
+ destructive: toolName.includes('_drop_') || toolName === 'pg_reindex' || toolName === 'pg_reset_query_stats',
+ reason: `${toolName} changes database structure or behavior.`
+ };
+ }
+
+ return {
+ risk: 'unclassified',
+ destructive: true,
+ reason: `${toolName} is not explicitly classified by the security policy.`
+ };
+}
+
+export function isToolCallAllowed(policy: SecurityPolicy, classification: ToolCallClassification): boolean {
+ if (!MODE_ALLOWANCES[policy.mode].has(classification.risk)) {
+ return false;
+ }
+
+ if (classification.destructive && !policy.allowDestructive) {
+ return false;
+ }
+
+ return true;
+}
+
+export function explainPolicyDenial(policy: SecurityPolicy, classification: ToolCallClassification): string {
+ if (classification.destructive && MODE_ALLOWANCES[policy.mode].has(classification.risk) && !policy.allowDestructive) {
+ return `Blocked by PostgreSQL MCP security policy: ${classification.reason} Destructive operations require allowDestructive=true.`;
+ }
+
+ return `Blocked by PostgreSQL MCP security policy: ${classification.reason} Current mode "${policy.mode}" allows ${Array.from(MODE_ALLOWANCES[policy.mode]).join(', ')} operations.`;
+}
diff --git a/src/server/audit.test.ts b/src/server/audit.test.ts
new file mode 100644
index 0000000..c5f059e
--- /dev/null
+++ b/src/server/audit.test.ts
@@ -0,0 +1,136 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { buildAuditEvent, emitAuditEvent, isConnectionTargetDenial } from './audit';
+
+describe('audit logging helpers', () => {
+ const originalAuditFile = process.env.POSTGRES_MCP_AUDIT_FILE;
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ if (originalAuditFile === undefined) {
+ delete process.env.POSTGRES_MCP_AUDIT_FILE;
+ } else {
+ process.env.POSTGRES_MCP_AUDIT_FILE = originalAuditFile;
+ }
+ });
+
+ it('builds sanitized denial events without raw request payloads', () => {
+ const event = buildAuditEvent({
+ reason: 'security_policy_denied',
+ toolName: 'pg_execute_sql',
+ args: {
+ operation: "drop table users; password='secret'",
+ sql: "SELECT 'private-token'",
+ connectionString: 'postgresql://app:secret@db.internal/app'
+ },
+ securityPolicy: { mode: 'readonly', allowDestructive: false },
+ allowToolConnectionString: false,
+ classification: {
+ risk: 'arbitrary_sql',
+ destructive: true,
+ reason: 'Arbitrary SQL can read, write, change schema, or change roles.'
+ },
+ message: "Blocked near SELECT 'private-token' using postgresql://app:secret@db.internal/app"
+ });
+
+ expect(event).toMatchObject({
+ event: 'postgres_mcp.security',
+ outcome: 'denied',
+ reason: 'security_policy_denied',
+ toolName: 'pg_execute_sql',
+ securityMode: 'readonly',
+ allowDestructive: false,
+ allowToolConnectionString: false,
+ hasToolConnectionString: true,
+ operation: '',
+ risk: 'arbitrary_sql',
+ destructive: true
+ });
+ expect(JSON.stringify(event)).not.toContain('private-token');
+ expect(JSON.stringify(event)).not.toContain('secret@');
+ expect(JSON.stringify(event)).not.toContain('SELECT');
+ });
+
+ it('emits machine-readable audit JSON to stderr', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ emitAuditEvent({
+ reason: 'tool_not_enabled',
+ toolName: 'pg_missing_tool',
+ args: {},
+ securityPolicy: { mode: 'readonly', allowDestructive: false },
+ allowToolConnectionString: false,
+ availableButDisabled: false,
+ message: 'Tool is not enabled.'
+ });
+
+ expect(consoleError).toHaveBeenCalledTimes(1);
+ expect(consoleError.mock.calls[0][0]).toBe('[MCP Audit]');
+ expect(JSON.parse(consoleError.mock.calls[0][1] as string)).toMatchObject({
+ event: 'postgres_mcp.security',
+ outcome: 'denied',
+ reason: 'tool_not_enabled',
+ toolName: 'pg_missing_tool'
+ });
+ });
+
+ it('optionally appends sanitized audit JSONL to a configured file', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'postgres-mcp-audit-'));
+ const auditFile = join(tempDir, 'audit.jsonl');
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ process.env.POSTGRES_MCP_AUDIT_FILE = auditFile;
+
+ try {
+ emitAuditEvent({
+ reason: 'per_tool_connection_string_blocked',
+ toolName: 'pg_execute_query',
+ args: {
+ connectionString: 'postgresql://app:secret@db.internal/app',
+ query: "SELECT 'private-token'"
+ },
+ securityPolicy: { mode: 'readonly', allowDestructive: false },
+ allowToolConnectionString: false,
+ message: "Blocked postgresql://app:secret@db.internal/app near SELECT 'private-token'"
+ });
+
+ expect(consoleError).toHaveBeenCalledWith('[MCP Audit]', expect.any(String));
+ const lines = readFileSync(auditFile, 'utf8').trim().split('\n');
+ expect(lines).toHaveLength(1);
+ expect(JSON.parse(lines[0])).toMatchObject({
+ event: 'postgres_mcp.security',
+ outcome: 'denied',
+ reason: 'per_tool_connection_string_blocked',
+ toolName: 'pg_execute_query',
+ hasToolConnectionString: true
+ });
+ expect(lines[0]).not.toContain('secret@');
+ expect(lines[0]).not.toContain('private-token');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('does not throw when the optional audit file cannot be written', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ process.env.POSTGRES_MCP_AUDIT_FILE = join('__missing_parent__', 'audit.jsonl');
+
+ expect(() => emitAuditEvent({
+ reason: 'tool_not_enabled',
+ toolName: 'pg_missing_tool',
+ args: {},
+ securityPolicy: { mode: 'readonly', allowDestructive: false },
+ allowToolConnectionString: false
+ })).not.toThrow();
+
+ expect(consoleError).toHaveBeenCalledWith('[MCP Audit Error]', expect.any(String));
+ expect(existsSync(process.env.POSTGRES_MCP_AUDIT_FILE)).toBe(false);
+ });
+
+ it('recognizes connection target denial messages', () => {
+ expect(isConnectionTargetDenial(new Error('Connection target "db" is not allowed by the configured connection target allowlist.'))).toBe(true);
+ expect(isConnectionTargetDenial(new Error('Connection target allowlist requires URL connection strings to include an explicit host.'))).toBe(true);
+ expect(isConnectionTargetDenial(new Error('Query failed'))).toBe(false);
+ });
+});
diff --git a/src/server/audit.ts b/src/server/audit.ts
new file mode 100644
index 0000000..f750aa0
--- /dev/null
+++ b/src/server/audit.ts
@@ -0,0 +1,116 @@
+import { appendFileSync } from 'node:fs';
+import type { SecurityPolicy, ToolCallClassification } from '../security/policy.js';
+import { sanitizeErrorMessage } from '../utils/connection.js';
+import { hasToolSuppliedConnectionString } from './boundary.js';
+
+export type AuditDenialReason =
+ | 'tool_not_enabled'
+ | 'per_tool_connection_string_blocked'
+ | 'connection_target_denied'
+ | 'security_policy_denied';
+
+export interface AuditEvent {
+ event: 'postgres_mcp.security';
+ outcome: 'denied';
+ reason: AuditDenialReason;
+ toolName: string;
+ securityMode: SecurityPolicy['mode'];
+ allowDestructive: boolean;
+ allowToolConnectionString: boolean;
+ hasToolConnectionString: boolean;
+ operation?: string;
+ risk?: ToolCallClassification['risk'];
+ destructive?: boolean;
+ availableButDisabled?: boolean;
+ message?: string;
+}
+
+export interface AuditEventContext {
+ reason: AuditDenialReason;
+ toolName: string;
+ args: unknown;
+ securityPolicy: SecurityPolicy;
+ allowToolConnectionString: boolean;
+ classification?: ToolCallClassification;
+ availableButDisabled?: boolean;
+ message?: string;
+}
+
+const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9_:-]{1,128}$/;
+
+function normalizeAuditIdentifier(value: string): string {
+ return SAFE_IDENTIFIER_PATTERN.test(value) ? value : '';
+}
+
+function getSafeOperation(args: unknown): string | undefined {
+ if (!args || typeof args !== 'object' || !('operation' in args)) {
+ return undefined;
+ }
+
+ const operation = (args as { operation?: unknown }).operation;
+ if (typeof operation !== 'string') {
+ return '';
+ }
+
+ return SAFE_IDENTIFIER_PATTERN.test(operation) ? operation : '';
+}
+
+function truncate(value: string, maxLength: number): string {
+ return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
+}
+
+export function buildAuditEvent(context: AuditEventContext): AuditEvent {
+ const event: AuditEvent = {
+ event: 'postgres_mcp.security',
+ outcome: 'denied',
+ reason: context.reason,
+ toolName: normalizeAuditIdentifier(context.toolName),
+ securityMode: context.securityPolicy.mode,
+ allowDestructive: context.securityPolicy.allowDestructive,
+ allowToolConnectionString: context.allowToolConnectionString,
+ hasToolConnectionString: hasToolSuppliedConnectionString(context.args)
+ };
+
+ const operation = getSafeOperation(context.args);
+ if (operation !== undefined) {
+ event.operation = operation;
+ }
+
+ if (context.classification) {
+ event.risk = context.classification.risk;
+ event.destructive = context.classification.destructive;
+ }
+
+ if (context.availableButDisabled !== undefined) {
+ event.availableButDisabled = context.availableButDisabled;
+ }
+
+ if (context.message && context.reason !== 'security_policy_denied') {
+ event.message = truncate(sanitizeErrorMessage(context.message), 500);
+ }
+
+ return event;
+}
+
+export function emitAuditEvent(context: AuditEventContext): void {
+ const line = JSON.stringify(buildAuditEvent(context));
+ console.error('[MCP Audit]', line);
+
+ const auditFile = process.env.POSTGRES_MCP_AUDIT_FILE;
+ if (!auditFile) {
+ return;
+ }
+
+ try {
+ appendFileSync(auditFile, `${line}\n`, { encoding: 'utf8' });
+ } catch (error) {
+ console.error('[MCP Audit Error]', sanitizeErrorMessage(error));
+ }
+}
+
+export function isConnectionTargetDenial(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error);
+ return message.includes('Connection target allowlist') ||
+ message.includes('connection target allowlist') ||
+ message.includes('configured connection target allowlist');
+}
diff --git a/src/server/boundary.test.ts b/src/server/boundary.test.ts
new file mode 100644
index 0000000..58d46f6
--- /dev/null
+++ b/src/server/boundary.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import { McpError } from '@modelcontextprotocol/sdk/types.js';
+import { hasToolSuppliedConnectionString, resolveConnectionString } from './boundary';
+
+describe('server boundary helpers', () => {
+ it('detects top-level per-tool connection string arguments', () => {
+ expect(hasToolSuppliedConnectionString(undefined)).toBe(false);
+ expect(hasToolSuppliedConnectionString({ tableName: 'users' })).toBe(false);
+ expect(hasToolSuppliedConnectionString({ connectionString: 'postgresql://request' })).toBe(true);
+ expect(hasToolSuppliedConnectionString({ sourceConnectionString: 'postgresql://source' })).toBe(true);
+ expect(hasToolSuppliedConnectionString({ targetConnectionString: 'postgresql://target' })).toBe(true);
+ });
+
+ it('ignores non-string connection-string shaped fields', () => {
+ expect(hasToolSuppliedConnectionString({ connectionString: 42 })).toBe(false);
+ expect(hasToolSuppliedConnectionString({ sourceConnectionString: null })).toBe(false);
+ expect(hasToolSuppliedConnectionString({ targetConnectionString: false })).toBe(false);
+ });
+
+ it('resolves connection strings in request, CLI, environment order', () => {
+ expect(resolveConnectionString({
+ requestConnectionString: 'postgresql://request',
+ cliConnectionString: 'postgresql://cli',
+ envConnectionString: 'postgresql://env'
+ })).toBe('postgresql://request');
+
+ expect(resolveConnectionString({
+ cliConnectionString: 'postgresql://cli',
+ envConnectionString: 'postgresql://env'
+ })).toBe('postgresql://cli');
+
+ expect(resolveConnectionString({
+ envConnectionString: 'postgresql://env'
+ })).toBe('postgresql://env');
+ });
+
+ it('rejects blank request connection strings instead of falling through', () => {
+ expect(() => resolveConnectionString({
+ requestConnectionString: ' ',
+ cliConnectionString: 'postgresql://cli',
+ envConnectionString: 'postgresql://env'
+ })).toThrow('Tool argument connection string must be a non-empty string.');
+ });
+
+ it('rejects blank server-level connection strings instead of falling through', () => {
+ expect(() => resolveConnectionString({
+ cliConnectionString: '',
+ envConnectionString: 'postgresql://env'
+ })).toThrow('Server-level connection string must be a non-empty string.');
+ });
+
+ it('rejects blank environment connection strings', () => {
+ expect(() => resolveConnectionString({
+ envConnectionString: '\t '
+ })).toThrow('POSTGRES_CONNECTION_STRING must be a non-empty string.');
+ });
+
+ it('throws an MCP validation error when no connection string source exists', () => {
+ expect(() => resolveConnectionString({})).toThrow(McpError);
+ expect(() => resolveConnectionString({})).toThrow('No connection string provided');
+ });
+});
diff --git a/src/server/boundary.ts b/src/server/boundary.ts
new file mode 100644
index 0000000..f0e9c9b
--- /dev/null
+++ b/src/server/boundary.ts
@@ -0,0 +1,81 @@
+import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
+
+export interface ConnectionStringSources {
+ requestConnectionString?: string;
+ cliConnectionString?: string;
+ envConnectionString?: string;
+}
+
+export function hasToolSuppliedConnectionString(args: unknown): boolean {
+ if (!args || typeof args !== 'object') {
+ return false;
+ }
+
+ const params = args as Record;
+ return typeof params.connectionString === 'string' ||
+ typeof params.sourceConnectionString === 'string' ||
+ typeof params.targetConnectionString === 'string';
+}
+
+export function getToolSuppliedConnectionStrings(args: unknown): string[] {
+ if (!args || typeof args !== 'object') {
+ return [];
+ }
+
+ const params = args as Record;
+ return [
+ params.connectionString,
+ params.sourceConnectionString,
+ params.targetConnectionString
+ ].filter((value): value is string => typeof value === 'string');
+}
+
+function validateConnectionStringSource(errorMessage: string, value: string | undefined): string | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+
+ if (value.trim() === '') {
+ throw new McpError(
+ ErrorCode.InvalidParams,
+ errorMessage
+ );
+ }
+
+ return value;
+}
+
+/**
+ * Request connection strings are accepted here only after the MCP request
+ * handler has applied the allow-tool-connection-string gate.
+ */
+export function resolveConnectionString(sources: ConnectionStringSources): string {
+ const requestConnectionString = validateConnectionStringSource(
+ 'Tool argument connection string must be a non-empty string.',
+ sources.requestConnectionString
+ );
+ if (requestConnectionString !== undefined) {
+ return requestConnectionString;
+ }
+
+ const cliConnectionString = validateConnectionStringSource(
+ 'Server-level connection string must be a non-empty string.',
+ sources.cliConnectionString
+ );
+ if (cliConnectionString !== undefined) {
+ return cliConnectionString;
+ }
+
+ const envConnectionString = validateConnectionStringSource(
+ 'POSTGRES_CONNECTION_STRING must be a non-empty string.',
+ sources.envConnectionString
+ );
+ if (envConnectionString !== undefined) {
+ return envConnectionString;
+ }
+
+ throw new McpError(
+ ErrorCode.InvalidParams,
+ 'No connection string provided. Provide one in the tool arguments, via the --connection-string CLI option, or set the POSTGRES_CONNECTION_STRING environment variable.'
+ );
+}
diff --git a/src/server/connection-target.test.ts b/src/server/connection-target.test.ts
new file mode 100644
index 0000000..ac22048
--- /dev/null
+++ b/src/server/connection-target.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest';
+import {
+ assertConnectionTargetAllowed,
+ normalizeAllowedConnectionTargets,
+ parseAllowedConnectionTarget,
+ parseAllowedConnectionTargetList,
+ parseConnectionTarget
+} from './connection-target';
+
+describe('connection target allowlist helpers', () => {
+ it('parses PostgreSQL URL connection targets without retaining secrets', () => {
+ expect(parseConnectionTarget('postgresql://readonly:s3cr3t@DB.internal:5432/app?sslmode=require')).toEqual({
+ host: 'db.internal',
+ port: '5432',
+ database: 'app',
+ user: 'readonly'
+ });
+ });
+
+ it('normalizes bracketed IPv6 URL hosts before allowlist matching', () => {
+ const allowedTargets = normalizeAllowedConnectionTargets(['readonly@[::1]:5432/app']);
+
+ expect(parseConnectionTarget('postgresql://readonly:secret@[::1]:5432/app')).toEqual({
+ host: '::1',
+ port: '5432',
+ database: 'app',
+ user: 'readonly'
+ });
+ expect(() => assertConnectionTargetAllowed(
+ 'postgresql://readonly:secret@[::1]:5432/app',
+ allowedTargets
+ )).not.toThrow();
+ });
+
+ it('rejects URL host override parameters before allowlist matching', () => {
+ const allowedTargets = normalizeAllowedConnectionTargets(['allowed.internal']);
+
+ expect(() => assertConnectionTargetAllowed(
+ 'postgresql://allowed.internal/app?host=evil.internal',
+ allowedTargets
+ )).toThrow('host or hostaddr query parameters');
+ expect(() => assertConnectionTargetAllowed(
+ 'postgresql://allowed.internal/app?hostaddr=10.0.0.1',
+ allowedTargets
+ )).toThrow('host or hostaddr query parameters');
+ });
+
+ it('parses keyword-style connection targets', () => {
+ expect(parseConnectionTarget("host=db.internal port=5432 dbname=app user='read only' password=secret")).toEqual({
+ host: 'db.internal',
+ port: '5432',
+ database: 'app',
+ user: 'read only'
+ });
+ });
+
+ it('supports exact and full-field wildcard allowlist entries', () => {
+ const allowedTargets = normalizeAllowedConnectionTargets([
+ 'readonly@db.internal:5432/app',
+ '*@localhost:*/dev'
+ ]);
+
+ expect(() => assertConnectionTargetAllowed(
+ 'postgresql://readonly:secret@db.internal:5432/app',
+ allowedTargets
+ )).not.toThrow();
+ expect(() => assertConnectionTargetAllowed(
+ 'postgresql://alice:secret@localhost:6543/dev',
+ allowedTargets
+ )).not.toThrow();
+ expect(() => assertConnectionTargetAllowed(
+ 'postgresql://readonly:secret@db.internal:5432/other',
+ allowedTargets
+ )).toThrow('is not allowed by the configured connection target allowlist');
+ });
+
+ it('treats omitted allowlist fields as unconstrained', () => {
+ const allowedTargets = normalizeAllowedConnectionTargets(['db.internal']);
+
+ expect(() => assertConnectionTargetAllowed(
+ 'postgresql://admin:secret@db.internal:9999/any_database',
+ allowedTargets
+ )).not.toThrow();
+ });
+
+ it('rejects empty and partial-wildcard allowlist patterns', () => {
+ expect(() => parseAllowedConnectionTarget('')).toThrow('non-empty');
+ expect(() => parseAllowedConnectionTarget('read*@db.internal/app')).toThrow('full-field wildcard');
+ expect(() => parseAllowedConnectionTarget('db.*.internal/app')).toThrow('full-field wildcard');
+ expect(() => parseAllowedConnectionTarget('db.internal:70000/app')).toThrow('integer from 1 to 65535');
+ });
+
+ it('parses comma-separated environment allowlist entries fail-closed', () => {
+ expect(parseAllowedConnectionTargetList(' readonly@db:5432/app, *@localhost/* ')).toEqual([
+ 'readonly@db:5432/app',
+ '*@localhost/*'
+ ]);
+ expect(parseAllowedConnectionTargetList(undefined)).toBeUndefined();
+ expect(parseAllowedConnectionTargetList(' ')).toBeUndefined();
+ expect(() => parseAllowedConnectionTargetList('db.internal,,localhost')).toThrow('must not contain empty entries');
+ });
+
+ it('rejects unsupported or ambiguous connection strings when allowlisting is enabled', () => {
+ expect(() => parseConnectionTarget('postgresql:///app')).toThrow('explicit host');
+ expect(() => parseConnectionTarget('service=prod')).toThrow('include host or hostaddr');
+ expect(() => parseConnectionTarget('not a connection string')).toThrow('only supports PostgreSQL URL or keyword-style');
+ });
+
+ it('does not leak passwords in rejection errors', () => {
+ const allowedTargets = normalizeAllowedConnectionTargets(['readonly@db.internal:5432/app']);
+
+ expect(() => assertConnectionTargetAllowed(
+ 'postgresql://readonly:s3cr3t@other.internal:5432/app',
+ allowedTargets
+ )).toThrow('readonly@other.internal:5432/app');
+ expect(() => assertConnectionTargetAllowed(
+ 'postgresql://readonly:s3cr3t@other.internal:5432/app',
+ allowedTargets
+ )).not.toThrow('s3cr3t');
+ });
+});
diff --git a/src/server/connection-target.ts b/src/server/connection-target.ts
new file mode 100644
index 0000000..9f39f03
--- /dev/null
+++ b/src/server/connection-target.ts
@@ -0,0 +1,300 @@
+export interface ConnectionTarget {
+ host: string;
+ port?: string;
+ database?: string;
+ user?: string;
+}
+
+export interface AllowedConnectionTarget {
+ host: string;
+ port?: string;
+ database?: string;
+ user?: string;
+ source: string;
+}
+
+function hasPartialWildcard(value: string): boolean {
+ return value.includes('*') && value !== '*';
+}
+
+function normalizeOptionalPatternComponent(
+ label: string,
+ value: string | undefined,
+ source: string
+): string | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+
+ if (value === '') {
+ throw new Error(`Invalid allowed connection target "${source}": ${label} must not be empty.`);
+ }
+
+ if (hasPartialWildcard(value)) {
+ throw new Error(`Invalid allowed connection target "${source}": ${label} only supports "*" as a full-field wildcard.`);
+ }
+
+ return value;
+}
+
+function normalizePortPattern(port: string | undefined, source: string): string | undefined {
+ const normalized = normalizeOptionalPatternComponent('port', port, source);
+ if (normalized === undefined || normalized === '*') {
+ return normalized;
+ }
+
+ const parsed = Number(normalized);
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
+ throw new Error(`Invalid allowed connection target "${source}": port must be "*" or an integer from 1 to 65535.`);
+ }
+
+ return normalized;
+}
+
+function normalizeConnectionHost(host: string): string {
+ const decoded = decodeURIComponent(host);
+ return (decoded.startsWith('[') && decoded.endsWith(']') ? decoded.slice(1, -1) : decoded).toLowerCase();
+}
+
+function splitHostAndPort(hostPort: string, source: string): { host: string; port?: string } {
+ if (hostPort.startsWith('[')) {
+ const closingBracket = hostPort.indexOf(']');
+ if (closingBracket <= 1) {
+ throw new Error(`Invalid allowed connection target "${source}": bracketed IPv6 host is malformed.`);
+ }
+
+ const host = hostPort.slice(1, closingBracket);
+ const rest = hostPort.slice(closingBracket + 1);
+ if (rest === '') {
+ return { host };
+ }
+ if (!rest.startsWith(':')) {
+ throw new Error(`Invalid allowed connection target "${source}": unexpected text after bracketed host.`);
+ }
+ return { host, port: rest.slice(1) };
+ }
+
+ const colonCount = [...hostPort].filter((char) => char === ':').length;
+ if (colonCount > 1) {
+ throw new Error(`Invalid allowed connection target "${source}": IPv6 hosts must use bracket notation.`);
+ }
+
+ const colonIndex = hostPort.lastIndexOf(':');
+ if (colonIndex === -1) {
+ return { host: hostPort };
+ }
+
+ return {
+ host: hostPort.slice(0, colonIndex),
+ port: hostPort.slice(colonIndex + 1)
+ };
+}
+
+export function parseAllowedConnectionTarget(source: string): AllowedConnectionTarget {
+ const trimmed = source.trim();
+ if (trimmed === '') {
+ throw new Error('Allowed connection target entries must be non-empty strings.');
+ }
+
+ const slashIndex = trimmed.indexOf('/');
+ const targetWithoutDatabase = slashIndex === -1 ? trimmed : trimmed.slice(0, slashIndex);
+ const database = slashIndex === -1 ? undefined : trimmed.slice(slashIndex + 1);
+
+ if (database?.includes('/')) {
+ throw new Error(`Invalid allowed connection target "${trimmed}": database must not contain "/".`);
+ }
+
+ const atIndex = targetWithoutDatabase.lastIndexOf('@');
+ const user = atIndex === -1 ? undefined : targetWithoutDatabase.slice(0, atIndex);
+ const hostPort = atIndex === -1 ? targetWithoutDatabase : targetWithoutDatabase.slice(atIndex + 1);
+ const { host, port } = splitHostAndPort(hostPort, trimmed);
+ const normalizedHost = normalizeOptionalPatternComponent('host', host, trimmed);
+
+ if (normalizedHost === undefined) {
+ throw new Error(`Invalid allowed connection target "${trimmed}": host is required.`);
+ }
+
+ return {
+ source: trimmed,
+ user: normalizeOptionalPatternComponent('user', user, trimmed),
+ host: normalizedHost === '*' ? '*' : normalizedHost.toLowerCase(),
+ port: normalizePortPattern(port, trimmed),
+ database: normalizeOptionalPatternComponent('database', database, trimmed)
+ };
+}
+
+export function parseAllowedConnectionTargetList(value: string | undefined): string[] | undefined {
+ if (value === undefined || value.trim() === '') {
+ return undefined;
+ }
+
+ return value.split(',').map((entry) => {
+ const trimmed = entry.trim();
+ if (trimmed === '') {
+ throw new Error('POSTGRES_MCP_ALLOWED_CONNECTION_TARGETS must not contain empty entries.');
+ }
+ return trimmed;
+ });
+}
+
+export function normalizeAllowedConnectionTargets(
+ targets: string[] | undefined
+): AllowedConnectionTarget[] | undefined {
+ if (targets === undefined) {
+ return undefined;
+ }
+
+ return targets.map(parseAllowedConnectionTarget);
+}
+
+function parseKeywordConnectionString(connectionString: string): Record | undefined {
+ const fields: Record = {};
+ let index = 0;
+
+ while (index < connectionString.length) {
+ while (connectionString[index] === ' ' || connectionString[index] === '\t' || connectionString[index] === '\n') {
+ index += 1;
+ }
+ if (index >= connectionString.length) {
+ break;
+ }
+
+ const keyStart = index;
+ while (index < connectionString.length && connectionString[index] !== '=' && !/\s/.test(connectionString[index])) {
+ index += 1;
+ }
+ const key = connectionString.slice(keyStart, index);
+ if (key === '' || connectionString[index] !== '=') {
+ return undefined;
+ }
+ index += 1;
+
+ let value = '';
+ if (connectionString[index] === "'") {
+ index += 1;
+ while (index < connectionString.length) {
+ const char = connectionString[index];
+ if (char === '\\' && index + 1 < connectionString.length) {
+ value += connectionString[index + 1];
+ index += 2;
+ continue;
+ }
+ if (char === "'") {
+ index += 1;
+ break;
+ }
+ value += char;
+ index += 1;
+ }
+ } else {
+ const valueStart = index;
+ while (index < connectionString.length && !/\s/.test(connectionString[index])) {
+ index += 1;
+ }
+ value = connectionString.slice(valueStart, index);
+ }
+
+ fields[key.toLowerCase()] = value;
+ }
+
+ return Object.keys(fields).length > 0 ? fields : undefined;
+}
+
+export function parseConnectionTarget(connectionString: string): ConnectionTarget {
+ if (connectionString.trim() === '') {
+ throw new Error('Connection string must be a non-empty string.');
+ }
+
+ try {
+ const parsed = new URL(connectionString);
+ if (parsed.protocol === 'postgresql:' || parsed.protocol === 'postgres:') {
+ if (!parsed.hostname) {
+ throw new Error('Connection target allowlist requires URL connection strings to include an explicit host.');
+ }
+ if (parsed.searchParams.has('host') || parsed.searchParams.has('hostaddr')) {
+ throw new Error('Connection target allowlist does not support URL host or hostaddr query parameters.');
+ }
+
+ return {
+ host: normalizeConnectionHost(parsed.hostname),
+ port: parsed.port || undefined,
+ database: parsed.pathname && parsed.pathname !== '/' ? decodeURIComponent(parsed.pathname.slice(1)) : undefined,
+ user: parsed.username ? decodeURIComponent(parsed.username) : undefined
+ };
+ }
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('allowlist')) {
+ throw error;
+ }
+ }
+
+ const keywordFields = parseKeywordConnectionString(connectionString);
+ if (keywordFields) {
+ const host = keywordFields.host || keywordFields.hostaddr;
+ if (!host) {
+ throw new Error('Connection target allowlist requires keyword connection strings to include host or hostaddr.');
+ }
+
+ return {
+ host: normalizeConnectionHost(host),
+ port: keywordFields.port || undefined,
+ database: keywordFields.dbname || keywordFields.database || undefined,
+ user: keywordFields.user || undefined
+ };
+ }
+
+ throw new Error('Connection target allowlist only supports PostgreSQL URL or keyword-style connection strings.');
+}
+
+function fieldMatches(pattern: string | undefined, value: string | undefined, caseInsensitive = false): boolean {
+ if (pattern === undefined || pattern === '*') {
+ return true;
+ }
+
+ if (value === undefined) {
+ return false;
+ }
+
+ return caseInsensitive ? pattern.toLowerCase() === value.toLowerCase() : pattern === value;
+}
+
+export function isConnectionTargetAllowed(
+ target: ConnectionTarget,
+ allowedTargets: AllowedConnectionTarget[] | undefined
+): boolean {
+ if (allowedTargets === undefined) {
+ return true;
+ }
+
+ return allowedTargets.some((allowedTarget) =>
+ fieldMatches(allowedTarget.host, target.host, true) &&
+ fieldMatches(allowedTarget.port, target.port) &&
+ fieldMatches(allowedTarget.database, target.database) &&
+ fieldMatches(allowedTarget.user, target.user)
+ );
+}
+
+export function formatConnectionTarget(target: ConnectionTarget): string {
+ const user = target.user ? `${target.user}@` : '';
+ const port = target.port ? `:${target.port}` : '';
+ const database = target.database ? `/${target.database}` : '';
+ return `${user}${target.host}${port}${database}`;
+}
+
+export function assertConnectionTargetAllowed(
+ connectionString: string,
+ allowedTargets: AllowedConnectionTarget[] | undefined
+): void {
+ if (allowedTargets === undefined) {
+ return;
+ }
+
+ if (allowedTargets.length === 0) {
+ throw new Error('Connection target allowlist is configured but contains no allowed entries.');
+ }
+
+ const target = parseConnectionTarget(connectionString);
+ if (!isConnectionTargetAllowed(target, allowedTargets)) {
+ throw new Error(`Connection target "${formatConnectionTarget(target)}" is not allowed by the configured connection target allowlist.`);
+ }
+}
diff --git a/src/tools/analyze.test.ts b/src/tools/analyze.test.ts
new file mode 100644
index 0000000..a8b8d9e
--- /dev/null
+++ b/src/tools/analyze.test.ts
@@ -0,0 +1,130 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import { analyzeDatabaseTool } from './analyze';
+
+describe('analyzeDatabaseTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('defaults to configuration analysis and avoids diagnostic stderr logging', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn()
+ .mockResolvedValueOnce([{ version: 'PostgreSQL 16' }])
+ .mockResolvedValueOnce([
+ { name: 'max_connections', setting: '100', unit: '' },
+ { name: 'shared_buffers', setting: '128', unit: 'MB' }
+ ])
+ .mockResolvedValueOnce([{ count: '5' }])
+ .mockResolvedValueOnce([{ count: '1' }])
+ .mockResolvedValueOnce([{ ratio: '0.98' }])
+ .mockResolvedValueOnce([{ tablename: 'users', size: '16 kB' }]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await analyzeDatabaseTool.execute({}, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(consoleError).not.toHaveBeenCalled();
+ expect(JSON.parse(result.content[0].text)).toMatchObject({
+ version: 'PostgreSQL 16',
+ metrics: {
+ connections: 5,
+ activeQueries: 1,
+ cacheHitRatio: 0.98,
+ tableSizesSchema: 'public',
+ tableSizes: {
+ users: '16 kB'
+ }
+ }
+ });
+ expect(mockDb.query).toHaveBeenNthCalledWith(6, expect.stringContaining('n.nspname = $1'), ['public', 101]);
+ });
+
+ it('rejects invalid analysis types before connecting', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await analyzeDatabaseTool.execute({
+ analysisType: 'all'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown fields before resolving a connection', async () => {
+ const result = await analyzeDatabaseTool.execute({
+ analysisType: 'configuration',
+ unexpected: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ });
+
+ it('caps table size diagnostics before formatting output', async () => {
+ const tableRows = Array.from({ length: 101 }, (_, index) => ({
+ tablename: `table_${index}`,
+ size: `${index} kB`
+ }));
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn()
+ .mockResolvedValueOnce([{ version: 'PostgreSQL 16' }])
+ .mockResolvedValueOnce([{ name: 'max_connections', setting: '100', unit: '' }])
+ .mockResolvedValueOnce([{ count: '5' }])
+ .mockResolvedValueOnce([{ count: '1' }])
+ .mockResolvedValueOnce([{ ratio: '0.99' }])
+ .mockResolvedValueOnce(tableRows),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await analyzeDatabaseTool.execute({}, mockGetConnectionString);
+ const output = JSON.parse(result.content[0].text);
+
+ expect(result.isError).toBeUndefined();
+ expect(Object.keys(output.metrics.tableSizes)).toHaveLength(100);
+ expect(output.metrics.tableSizes.table_99).toBe('99 kB');
+ expect(output.metrics.tableSizes.table_100).toBeUndefined();
+ expect(output.metrics.tableSizesCapped).toBe(true);
+ expect(output.recommendations).toContain('Table size diagnostics are capped at the largest 100 tables in schema "public"');
+ expect(mockDb.query).toHaveBeenNthCalledWith(6, expect.stringContaining('LIMIT $2'), ['public', 101]);
+ });
+
+ it('uses the requested schema for table-size diagnostics', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn()
+ .mockResolvedValueOnce([{ version: 'PostgreSQL 16' }])
+ .mockResolvedValueOnce([{ name: 'max_connections', setting: '100', unit: '' }])
+ .mockResolvedValueOnce([{ count: '5' }])
+ .mockResolvedValueOnce([{ count: '1' }])
+ .mockResolvedValueOnce([{ ratio: '0.99' }])
+ .mockResolvedValueOnce([{ tablename: 'events', size: '16 kB' }]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await analyzeDatabaseTool.execute({ schema: 'audit' }, mockGetConnectionString);
+ const output = JSON.parse(result.content[0].text);
+
+ expect(result.isError).toBeUndefined();
+ expect(output.metrics.tableSizesSchema).toBe('audit');
+ expect(output.metrics.tableSizes).toEqual({ events: '16 kB' });
+ expect(mockDb.query).toHaveBeenNthCalledWith(6, expect.stringContaining('n.nspname = $1'), ['audit', 101]);
+ });
+});
diff --git a/src/tools/analyze.ts b/src/tools/analyze.ts
index 4a72896..adac711 100644
--- a/src/tools/analyze.ts
+++ b/src/tools/analyze.ts
@@ -1,6 +1,5 @@
import { DatabaseConnection } from '../utils/connection.js';
import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
-import { analyzeDatabase as originalAnalyzeDatabase } from './analyze.js'; // Assuming it's from a .js file initially
import { z } from 'zod';
interface AnalysisResult {
@@ -10,11 +9,19 @@ interface AnalysisResult {
connections: number;
activeQueries: number;
cacheHitRatio: number;
+ tableSizesSchema: string;
tableSizes: Record;
+ tableSizesCapped?: boolean;
};
recommendations: string[];
}
+const TABLE_SIZE_DIAGNOSTIC_LIMIT = 100;
+
+function formatValidationError(error: z.ZodError): string {
+ return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', ');
+}
+
// Definition previously in TOOL_DEFINITIONS
const toolDefinition = {
name: 'pg_analyze_database',
@@ -22,27 +29,29 @@ const toolDefinition = {
inputSchema: z.object({
connectionString: z.string().optional()
.describe('PostgreSQL connection string (optional if POSTGRES_CONNECTION_STRING environment variable or --connection-string CLI option is set)'),
+ schema: z.string().optional().default('public')
+ .describe('Schema to inspect for table-size diagnostics'),
analysisType: z.enum(['configuration', 'performance', 'security']).optional()
.describe('Type of analysis to perform')
- })
+ }).strict()
};
export const analyzeDatabaseTool: PostgresTool = {
name: toolDefinition.name,
description: toolDefinition.description,
inputSchema: toolDefinition.inputSchema,
- execute: async (args: { connectionString?: string; analysisType?: 'configuration' | 'performance' | 'security'; }, getConnectionString: GetConnectionStringFn): Promise => {
- const { connectionString: connStringArg, analysisType } = args;
-
- if (!analysisType || !['configuration', 'performance', 'security'].includes(analysisType)) {
- return {
- content: [{ type: 'text', text: 'Error: analysisType is required and must be one of [\'configuration\', \'performance\', \'security\'].' }],
- isError: true,
- };
+ execute: async (args: unknown, getConnectionString: GetConnectionStringFn): Promise => {
+ const validationResult = toolDefinition.inputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return {
+ content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }],
+ isError: true,
+ };
}
+ const { connectionString: connStringArg, analysisType = 'configuration', schema } = validationResult.data;
const resolvedConnString = getConnectionString(connStringArg);
- const result = await originalAnalyzeDatabase(resolvedConnString, analysisType);
+ const result = await analyzeDatabase(resolvedConnString, analysisType, schema);
return {
content: [
@@ -57,15 +66,16 @@ export const analyzeDatabaseTool: PostgresTool = {
export async function analyzeDatabase(
connectionString: string,
- analysisType: 'configuration' | 'performance' | 'security' = 'configuration'
+ analysisType: 'configuration' | 'performance' | 'security' = 'configuration',
+ schema = 'public'
): Promise {
const db = DatabaseConnection.getInstance();
- await db.connect(connectionString);
try {
+ await db.connect(connectionString);
const version = await getVersion();
const settings = await getSettings();
- const metrics = await getMetrics();
+ const metrics = await getMetrics(schema);
const recommendations = await generateRecommendations(analysisType, settings, metrics);
return {
@@ -98,7 +108,7 @@ async function getSettings(): Promise> {
}, {});
}
-async function getMetrics(): Promise {
+async function getMetrics(schema = 'public'): Promise {
const db = DatabaseConnection.getInstance();
const connections = await db.query<{ count: string }>(
@@ -109,19 +119,6 @@ async function getMetrics(): Promise {
"SELECT count(*) FROM pg_stat_activity WHERE state = 'active'"
);
- // First get raw stats for diagnostic logging
- const rawStats = await db.query<{ datname: string; hits: number; reads: number }>(
- `SELECT
- datname,
- COALESCE(blks_hit, 0) as hits,
- COALESCE(blks_read, 0) as reads
- FROM pg_stat_database
- WHERE datname = current_database()`
- );
-
- console.error('Cache stats:', rawStats[0]); // Diagnostic logging
-
- // Then calculate ratio with additional safety checks
const cacheHit = await db.query<{ ratio: number }>(
`WITH stats AS (
SELECT
@@ -155,24 +152,31 @@ async function getMetrics(): Promise {
ratio = 0;
}
- console.error('Calculated ratio:', ratio); // Diagnostic logging
-
const tableSizes = await db.query<{ tablename: string; size: string }>(
- `SELECT
- tablename,
- pg_size_pretty(pg_table_size(schemaname || '.' || tablename)) as size
- FROM pg_tables
- WHERE schemaname = 'public'`
+ `SELECT
+ c.relname AS tablename,
+ pg_size_pretty(pg_table_size(c.oid)) as size
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = $1
+ AND c.relkind IN ('r', 'p')
+ ORDER BY pg_table_size(c.oid) DESC
+ LIMIT $2`,
+ [schema, TABLE_SIZE_DIAGNOSTIC_LIMIT + 1]
);
+ const visibleTableSizes = tableSizes.slice(0, TABLE_SIZE_DIAGNOSTIC_LIMIT);
+ const tableSizesCapped = tableSizes.length > TABLE_SIZE_DIAGNOSTIC_LIMIT;
return {
connections: Number.parseInt(connections[0].count),
activeQueries: Number.parseInt(activeQueries[0].count),
cacheHitRatio: Number.parseFloat(ratio.toFixed(2)),
- tableSizes: tableSizes.reduce((acc: Record, row: { tablename: string; size: string }) => {
+ tableSizesSchema: schema,
+ tableSizes: visibleTableSizes.reduce((acc: Record, row: { tablename: string; size: string }) => {
acc[row.tablename] = row.size;
return acc;
}, {}),
+ tableSizesCapped,
};
}
@@ -184,6 +188,10 @@ async function generateRecommendations(
const recommendations: string[] = [];
if (type === 'configuration' || type === 'performance') {
+ if (metrics.tableSizesCapped) {
+ recommendations.push(`Table size diagnostics are capped at the largest ${TABLE_SIZE_DIAGNOSTIC_LIMIT} tables in schema "${metrics.tableSizesSchema}"`);
+ }
+
if (metrics.cacheHitRatio < 0.99) {
recommendations.push('Consider increasing shared_buffers to improve cache hit ratio');
}
@@ -213,4 +221,4 @@ async function generateRecommendations(
}
return recommendations;
-}
\ No newline at end of file
+}
diff --git a/src/tools/comments.test.ts b/src/tools/comments.test.ts
new file mode 100644
index 0000000..a8963e3
--- /dev/null
+++ b/src/tools/comments.test.ts
@@ -0,0 +1,231 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import { manageCommentsTool } from './comments';
+
+describe('manageCommentsTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('sets comments with quoted identifiers and escaped comment literals', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageCommentsTool.execute({
+ operation: 'set',
+ objectType: 'table',
+ objectName: 'users',
+ schema: 'public',
+ comment: "Owner's account table"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith('COMMENT ON TABLE "public"."users" IS \'Owner\'\'s account table\'');
+ expect(result.content.map((item) => item.text).join('\n')).not.toContain("Owner's account table");
+ expect(result.content.map((item) => item.text).join('\n')).toContain('"commentSet": true');
+ });
+
+ it('sets column and function comments with explicit object targets', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const columnResult = await manageCommentsTool.execute({
+ operation: 'set',
+ objectType: 'column',
+ objectName: 'orders',
+ columnName: 'total_amount',
+ schema: 'sales',
+ comment: 'Order total'
+ }, mockGetConnectionString);
+ const functionResult = await manageCommentsTool.execute({
+ operation: 'set',
+ objectType: 'function',
+ objectName: 'calculate_tax',
+ functionSignature: 'numeric, text',
+ schema: 'sales',
+ comment: 'Tax calculator'
+ }, mockGetConnectionString);
+
+ expect(columnResult.isError).toBeUndefined();
+ expect(functionResult.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenNthCalledWith(1, 'COMMENT ON COLUMN "sales"."orders"."total_amount" IS \'Order total\'');
+ expect(mockDb.query).toHaveBeenNthCalledWith(2, 'COMMENT ON FUNCTION "sales"."calculate_tax"(numeric, text) IS \'Tax calculator\'');
+ });
+
+ it('removes constraint and trigger comments with a separate parent table name', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const constraintResult = await manageCommentsTool.execute({
+ operation: 'remove',
+ objectType: 'constraint',
+ objectName: 'orders_user_id_fkey',
+ tableName: 'orders',
+ schema: 'sales'
+ }, mockGetConnectionString);
+ const triggerResult = await manageCommentsTool.execute({
+ operation: 'remove',
+ objectType: 'trigger',
+ objectName: 'orders_audit_trigger',
+ tableName: 'orders',
+ schema: 'sales'
+ }, mockGetConnectionString);
+
+ expect(constraintResult.isError).toBeUndefined();
+ expect(triggerResult.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenNthCalledWith(1, 'COMMENT ON CONSTRAINT "orders_user_id_fkey" ON "sales"."orders" IS NULL');
+ expect(mockDb.query).toHaveBeenNthCalledWith(2, 'COMMENT ON TRIGGER "orders_audit_trigger" ON "sales"."orders" IS NULL');
+ });
+
+ it('sanitizes comment database errors before returning them', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockRejectedValue(
+ new Error("password=db-secret failed near COMMENT ON TABLE users IS 'raw-comment-secret'")
+ ),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageCommentsTool.execute({
+ operation: 'set',
+ objectType: 'table',
+ objectName: 'users',
+ comment: 'raw-comment-secret'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Failed to set comment');
+ expect(result.content[0].text).toContain('password=*****');
+ expect(result.content[0].text).toContain("IS '?'");
+ expect(result.content[0].text).not.toContain('db-secret');
+ expect(result.content[0].text).not.toContain('raw-comment-secret');
+ });
+
+ it('rejects unknown comment-management fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageCommentsTool.execute({
+ operation: 'set',
+ objectType: 'table',
+ objectName: 'users',
+ comment: 'Unsafe',
+ rawSql: 'COMMENT ON DATABASE postgres IS NULL'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe identifiers before connecting', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageCommentsTool.execute({
+ operation: 'set',
+ objectType: 'table',
+ objectName: 'users; drop table users',
+ comment: 'Unsafe'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects missing column lookup fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageCommentsTool.execute({
+ operation: 'get',
+ objectType: 'column',
+ objectName: 'users'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('columnName is required');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe function signatures before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageCommentsTool.execute({
+ operation: 'set',
+ objectType: 'function',
+ objectName: 'calculate_tax',
+ functionSignature: 'numeric); drop function calculate_tax',
+ comment: 'Unsafe'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid function signature');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe parent-table comment targets before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageCommentsTool.execute({
+ operation: 'remove',
+ objectType: 'trigger',
+ objectName: 'orders_audit_trigger',
+ tableName: 'orders; drop table orders'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/tools/comments.ts b/src/tools/comments.ts
index 3b88ebc..9bf0422 100644
--- a/src/tools/comments.ts
+++ b/src/tools/comments.ts
@@ -1,7 +1,8 @@
-import { DatabaseConnection } from '../utils/connection.js';
-import { z } from 'zod';
-import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
-import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import { DatabaseConnection, sanitizeErrorMessage } from '../utils/connection.js';
+import { z } from 'zod';
+import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
+import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import { quoteIdent, quoteLiteral } from '../utils/sql.js';
interface CommentInfo {
objectType: string;
@@ -18,47 +19,116 @@ interface CommentResult {
}
// Input schema for the consolidated comments management tool
-const ManageCommentsInputSchema = z.object({
+const ManageCommentsInputSchema = z.object({
connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
operation: z.enum(['get', 'set', 'remove', 'bulk_get']).describe('Operation: get (retrieve comments), set (add/update comment), remove (delete comment), bulk_get (discovery mode)'),
// Target object identification
objectType: z.enum(['table', 'column', 'index', 'constraint', 'function', 'trigger', 'view', 'sequence', 'schema', 'database']).optional().describe('Type of database object (required for get/set/remove)'),
- objectName: z.string().optional().describe('Name of the object (required for get/set/remove)'),
- schema: z.string().optional().describe('Schema name (defaults to public, required for most object types)'),
-
- // Column-specific parameters
- columnName: z.string().optional().describe('Column name (required when objectType is "column")'),
+ objectName: z.string().optional().describe('Name of the object (required for get/set/remove)'),
+ schema: z.string().optional().describe('Schema name (defaults to public, required for most object types)'),
+ tableName: z.string().optional().describe('Parent table name (required when objectType is "constraint" or "trigger")'),
+
+ // Column-specific parameters
+ columnName: z.string().optional().describe('Column name (required when objectType is "column")'),
+ functionSignature: z.string().optional().describe('Function argument types for function comments, e.g. "integer, text" or empty for no arguments'),
// Comment content
comment: z.string().optional().describe('Comment text (required for set operation)'),
// Bulk get parameters
- includeSystemObjects: z.boolean().optional().describe('Include system objects in bulk_get (defaults to false)'),
- filterObjectType: z.enum(['table', 'column', 'index', 'constraint', 'function', 'trigger', 'view', 'sequence', 'schema', 'database']).optional().describe('Filter by object type in bulk_get operation')
-});
-
-type ManageCommentsInput = z.infer;
+ includeSystemObjects: z.boolean().optional().describe('Include system objects in bulk_get (defaults to false)'),
+ filterObjectType: z.enum(['table', 'column', 'index', 'constraint', 'function', 'trigger', 'view', 'sequence', 'schema', 'database']).optional().describe('Filter by object type in bulk_get operation')
+}).strict();
+
+type ManageCommentsInput = z.infer;
+
+function formatValidationError(error: z.ZodError): string {
+ return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', ');
+}
+
+function quoteSchemaObject(schema: string, objectName: string): string {
+ return `${quoteIdent(schema)}.${quoteIdent(objectName)}`;
+}
+
+function buildFunctionCommentSignature(signature?: string): string {
+ if (!signature || signature.trim() === '') {
+ return '()';
+ }
+
+ const normalizedSignature = signature.trim();
+ if (!/^[A-Za-z0-9_.,\s[\]]+$/.test(normalizedSignature)) {
+ throw new Error('Invalid function signature. Use a comma-separated list of simple PostgreSQL type names only.');
+ }
+
+ return `(${normalizedSignature})`;
+}
+
+function buildCommentTarget(input: ManageCommentsInput): string {
+ const { objectType, objectName, schema = 'public', columnName, tableName, functionSignature } = input;
+
+ if (!objectType || !objectName) {
+ throw new McpError(ErrorCode.InvalidParams, 'objectType and objectName are required');
+ }
+
+ switch (objectType) {
+ case 'table':
+ return `TABLE ${quoteSchemaObject(schema, objectName)}`;
+ case 'column':
+ if (!columnName) {
+ throw new McpError(ErrorCode.InvalidParams, 'columnName is required when objectType is "column"');
+ }
+ return `COLUMN ${quoteSchemaObject(schema, objectName)}.${quoteIdent(columnName)}`;
+ case 'index':
+ return `INDEX ${quoteSchemaObject(schema, objectName)}`;
+ case 'function':
+ return `FUNCTION ${quoteSchemaObject(schema, objectName)}${buildFunctionCommentSignature(functionSignature)}`;
+ case 'view':
+ return `VIEW ${quoteSchemaObject(schema, objectName)}`;
+ case 'sequence':
+ return `SEQUENCE ${quoteSchemaObject(schema, objectName)}`;
+ case 'schema':
+ return `SCHEMA ${quoteIdent(objectName)}`;
+ case 'database':
+ return `DATABASE ${quoteIdent(objectName)}`;
+ case 'constraint':
+ if (!tableName) {
+ throw new McpError(ErrorCode.InvalidParams, 'tableName is required when objectType is "constraint"');
+ }
+ return `CONSTRAINT ${quoteIdent(objectName)} ON ${quoteSchemaObject(schema, tableName)}`;
+ case 'trigger':
+ if (!tableName) {
+ throw new McpError(ErrorCode.InvalidParams, 'tableName is required when objectType is "trigger"');
+ }
+ return `TRIGGER ${quoteIdent(objectName)} ON ${quoteSchemaObject(schema, tableName)}`;
+ default:
+ throw new McpError(ErrorCode.InvalidParams, `Unsupported object type: ${objectType}`);
+ }
+}
/**
* Get comment for a specific database object
*/
-async function executeGetComment(
- input: ManageCommentsInput,
- getConnectionString: GetConnectionStringFn
-): Promise {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const { objectType, objectName, schema = 'public', columnName } = input;
-
- if (!objectType || !objectName) {
- throw new McpError(ErrorCode.InvalidParams, 'objectType and objectName are required for get operation');
- }
-
- try {
- await db.connect(resolvedConnectionString);
-
- let query: string;
+async function executeGetComment(
+ input: ManageCommentsInput,
+ getConnectionString: GetConnectionStringFn
+): Promise {
+ const db = DatabaseConnection.getInstance();
+ const { objectType, objectName, schema = 'public', columnName } = input;
+
+ if (!objectType || !objectName) {
+ throw new McpError(ErrorCode.InvalidParams, 'objectType and objectName are required for get operation');
+ }
+
+ if (objectType === 'column' && !columnName) {
+ throw new McpError(ErrorCode.InvalidParams, 'columnName is required when objectType is "column"');
+ }
+
+ try {
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+ await db.connect(resolvedConnectionString);
+
+ let query: string;
let params: (string | undefined)[];
switch (objectType) {
@@ -72,13 +142,10 @@ async function executeGetComment(
params = [objectName, schema];
break;
- case 'column':
- if (!columnName) {
- throw new McpError(ErrorCode.InvalidParams, 'columnName is required when objectType is "column"');
- }
- query = `
- SELECT col_description(c.oid, a.attnum) AS comment
- FROM pg_class c
+ case 'column':
+ query = `
+ SELECT col_description(c.oid, a.attnum) AS comment
+ FROM pg_class c
JOIN pg_namespace n ON c.relnamespace = n.oid
JOIN pg_attribute a ON a.attrelid = c.oid
WHERE c.relname = $1 AND n.nspname = $2 AND a.attname = $3 AND NOT a.attisdropped
@@ -183,9 +250,12 @@ async function executeGetComment(
comment: result[0].comment as string | null
};
- } catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to get comment: ${error instanceof Error ? error.message : String(error)}`);
- } finally {
+ } catch (error) {
+ if (error instanceof McpError) {
+ throw error;
+ }
+ throw new McpError(ErrorCode.InternalError, `Failed to get comment: ${sanitizeErrorMessage(error)}`);
+ } finally {
await db.disconnect();
}
}
@@ -193,86 +263,38 @@ async function executeGetComment(
/**
* Set comment on a database object
*/
-async function executeSetComment(
- input: ManageCommentsInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ objectType: string; objectName: string; schema?: string; columnName?: string; comment: string }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const { objectType, objectName, schema = 'public', columnName, comment } = input;
-
- if (!objectType || !objectName || comment === undefined) {
- throw new McpError(ErrorCode.InvalidParams, 'objectType, objectName, and comment are required for set operation');
- }
-
- try {
- await db.connect(resolvedConnectionString);
-
- let sql: string;
- const escapedComment = comment.replace(/'/g, "''"); // Escape single quotes
-
- switch (objectType) {
- case 'table':
- sql = `COMMENT ON TABLE "${schema}"."${objectName}" IS '${escapedComment}'`;
- break;
-
- case 'column':
- if (!columnName) {
- throw new McpError(ErrorCode.InvalidParams, 'columnName is required when objectType is "column"');
- }
- sql = `COMMENT ON COLUMN "${schema}"."${objectName}"."${columnName}" IS '${escapedComment}'`;
- break;
-
- case 'index':
- sql = `COMMENT ON INDEX "${schema}"."${objectName}" IS '${escapedComment}'`;
- break;
-
- case 'function':
- // Note: This is simplified - in practice, you'd need to handle function overloads
- sql = `COMMENT ON FUNCTION "${schema}"."${objectName}" IS '${escapedComment}'`;
- break;
-
- case 'view':
- sql = `COMMENT ON VIEW "${schema}"."${objectName}" IS '${escapedComment}'`;
- break;
-
- case 'sequence':
- sql = `COMMENT ON SEQUENCE "${schema}"."${objectName}" IS '${escapedComment}'`;
- break;
-
- case 'schema':
- sql = `COMMENT ON SCHEMA "${objectName}" IS '${escapedComment}'`;
- break;
-
- case 'database':
- sql = `COMMENT ON DATABASE "${objectName}" IS '${escapedComment}'`;
- break;
-
- case 'constraint':
- sql = `COMMENT ON CONSTRAINT "${objectName}" ON "${schema}"."${objectName}" IS '${escapedComment}'`;
- break;
-
- case 'trigger':
- // Note: PostgreSQL doesn't support COMMENT ON TRIGGER directly
- throw new McpError(ErrorCode.InvalidParams, 'PostgreSQL does not support comments on triggers');
-
- default:
- throw new McpError(ErrorCode.InvalidParams, `Unsupported object type: ${objectType}`);
- }
-
- await db.query(sql);
+async function executeSetComment(
+ input: ManageCommentsInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ objectType: string; objectName: string; schema?: string; columnName?: string; commentSet: true }> {
+ const db = DatabaseConnection.getInstance();
+ const { objectType, objectName, schema = 'public', columnName, comment } = input;
+
+ if (!objectType || !objectName || comment === undefined) {
+ throw new McpError(ErrorCode.InvalidParams, 'objectType, objectName, and comment are required for set operation');
+ }
+
+ try {
+ const sql = `COMMENT ON ${buildCommentTarget(input)} IS ${quoteLiteral(comment)}`;
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
+ await db.connect(resolvedConnectionString);
+ await db.query(sql);
return {
objectType,
objectName,
schema: objectType !== 'database' && objectType !== 'schema' ? schema : undefined,
- columnName,
- comment
+ columnName,
+ commentSet: true
};
- } catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to set comment: ${error instanceof Error ? error.message : String(error)}`);
- } finally {
+ } catch (error) {
+ if (error instanceof McpError) {
+ throw error;
+ }
+ throw new McpError(ErrorCode.InternalError, `Failed to set comment: ${sanitizeErrorMessage(error)}`);
+ } finally {
await db.disconnect();
}
}
@@ -280,71 +302,23 @@ async function executeSetComment(
/**
* Remove comment from a database object
*/
-async function executeRemoveComment(
- input: ManageCommentsInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ objectType: string; objectName: string; schema?: string; columnName?: string }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const { objectType, objectName, schema = 'public', columnName } = input;
-
- if (!objectType || !objectName) {
- throw new McpError(ErrorCode.InvalidParams, 'objectType and objectName are required for remove operation');
- }
-
- try {
- await db.connect(resolvedConnectionString);
-
- let sql: string;
-
- switch (objectType) {
- case 'table':
- sql = `COMMENT ON TABLE "${schema}"."${objectName}" IS NULL`;
- break;
-
- case 'column':
- if (!columnName) {
- throw new McpError(ErrorCode.InvalidParams, 'columnName is required when objectType is "column"');
- }
- sql = `COMMENT ON COLUMN "${schema}"."${objectName}"."${columnName}" IS NULL`;
- break;
-
- case 'index':
- sql = `COMMENT ON INDEX "${schema}"."${objectName}" IS NULL`;
- break;
-
- case 'function':
- sql = `COMMENT ON FUNCTION "${schema}"."${objectName}" IS NULL`;
- break;
-
- case 'view':
- sql = `COMMENT ON VIEW "${schema}"."${objectName}" IS NULL`;
- break;
-
- case 'sequence':
- sql = `COMMENT ON SEQUENCE "${schema}"."${objectName}" IS NULL`;
- break;
-
- case 'schema':
- sql = `COMMENT ON SCHEMA "${objectName}" IS NULL`;
- break;
-
- case 'database':
- sql = `COMMENT ON DATABASE "${objectName}" IS NULL`;
- break;
-
- case 'constraint':
- sql = `COMMENT ON CONSTRAINT "${objectName}" ON "${schema}"."${objectName}" IS NULL`;
- break;
-
- case 'trigger':
- throw new McpError(ErrorCode.InvalidParams, 'PostgreSQL does not support comments on triggers');
-
- default:
- throw new McpError(ErrorCode.InvalidParams, `Unsupported object type: ${objectType}`);
- }
-
- await db.query(sql);
+async function executeRemoveComment(
+ input: ManageCommentsInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ objectType: string; objectName: string; schema?: string; columnName?: string }> {
+ const db = DatabaseConnection.getInstance();
+ const { objectType, objectName, schema = 'public', columnName } = input;
+
+ if (!objectType || !objectName) {
+ throw new McpError(ErrorCode.InvalidParams, 'objectType and objectName are required for remove operation');
+ }
+
+ try {
+ const sql = `COMMENT ON ${buildCommentTarget(input)} IS NULL`;
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
+ await db.connect(resolvedConnectionString);
+ await db.query(sql);
return {
objectType,
@@ -353,9 +327,12 @@ async function executeRemoveComment(
columnName
};
- } catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to remove comment: ${error instanceof Error ? error.message : String(error)}`);
- } finally {
+ } catch (error) {
+ if (error instanceof McpError) {
+ throw error;
+ }
+ throw new McpError(ErrorCode.InternalError, `Failed to remove comment: ${sanitizeErrorMessage(error)}`);
+ } finally {
await db.disconnect();
}
}
@@ -483,7 +460,7 @@ async function executeBulkGetComments(
return comments;
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to get bulk comments: ${error instanceof Error ? error.message : String(error)}`);
+ throw new McpError(ErrorCode.InternalError, `Failed to get bulk comments: ${sanitizeErrorMessage(error)}`);
} finally {
await db.disconnect();
}
@@ -495,13 +472,13 @@ export const manageCommentsTool: PostgresTool = {
description: 'Manage PostgreSQL object comments - get, set, remove comments on tables, columns, functions, and other database objects. Examples: operation="get" with objectType="table", objectName="users", operation="set" with comment text, operation="bulk_get" for discovery',
inputSchema: ManageCommentsInputSchema,
execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const validationResult = ManageCommentsInputSchema.safeParse(args);
- if (!validationResult.success) {
- return {
- content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }],
- isError: true
- };
- }
+ const validationResult = ManageCommentsInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return {
+ content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }],
+ isError: true
+ };
+ }
const input = validationResult.data;
@@ -560,11 +537,11 @@ export const manageCommentsTool: PostgresTool = {
}
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return {
content: [{ type: 'text', text: `Error executing ${input.operation} operation: ${errorMessage}` }],
isError: true
};
}
}
-};
\ No newline at end of file
+};
diff --git a/src/tools/constraints.test.ts b/src/tools/constraints.test.ts
new file mode 100644
index 0000000..058a503
--- /dev/null
+++ b/src/tools/constraints.test.ts
@@ -0,0 +1,279 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import { manageConstraintsTool } from './constraints';
+
+describe('manageConstraintsTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('creates unique constraints with quoted identifiers', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'create',
+ constraintName: 'users_email_unique',
+ tableName: 'users',
+ constraintTypeCreate: 'unique',
+ columnNames: ['email']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('ALTER TABLE "users"'));
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('ADD CONSTRAINT "users_email_unique"'));
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('UNIQUE ("email")'));
+ });
+
+ it('creates foreign keys with quoted local and referenced identifiers', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'create_fk',
+ constraintName: 'orders_user_id_fkey',
+ tableName: 'orders',
+ columnNames: ['user_id'],
+ referencedTable: 'users',
+ referencedColumns: ['id'],
+ schema: 'sales',
+ referencedSchema: 'public'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('ALTER TABLE "sales"."orders"'));
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('ADD CONSTRAINT "orders_user_id_fkey"'));
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('FOREIGN KEY ("user_id")'));
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('REFERENCES "users" ("id")'));
+ });
+
+ it('redacts check clauses returned from catalog listings', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([{
+ constraint_name: 'users_token_check',
+ constraint_type: 'CHECK',
+ table_name: 'users',
+ column_name: 'token',
+ check_clause: "token <> 'raw-check-secret'",
+ is_deferrable: 'NO',
+ initially_deferred: 'NO'
+ }]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'get',
+ constraintType: 'CHECK'
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(output).toContain("token <> '?'");
+ expect(output).not.toContain('raw-check-secret');
+ });
+
+ it('does not echo check expressions in create-constraint success responses', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'create',
+ constraintName: 'users_token_check',
+ tableName: 'users',
+ constraintTypeCreate: 'check',
+ checkExpression: "token <> 'raw-check-secret'"
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining("CHECK (token <> 'raw-check-secret')"));
+ expect(output).toContain('"constraintType": "check"');
+ expect(output).not.toContain('raw-check-secret');
+ expect(output).not.toContain('checkExpression');
+ });
+
+ it('sanitizes constraint database errors before returning them', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockRejectedValue(
+ new Error("password=db-secret failed near CHECK (token <> 'raw-check-secret')")
+ ),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'create',
+ constraintName: 'users_token_check',
+ tableName: 'users',
+ constraintTypeCreate: 'check',
+ checkExpression: "token <> 'raw-check-secret'"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Failed to create constraint');
+ expect(result.content[0].text).toContain('password=*****');
+ expect(result.content[0].text).toContain("token <> '?'");
+ expect(result.content[0].text).not.toContain('db-secret');
+ expect(result.content[0].text).not.toContain('raw-check-secret');
+ });
+
+ it('preserves validation errors instead of wrapping them as internal errors', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'create_fk',
+ constraintName: 'orders_user_id_fkey',
+ tableName: 'orders',
+ columnNames: ['user_id', 'account_id'],
+ referencedTable: 'users',
+ referencedColumns: ['id']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Number of columns must match number of referenced columns');
+ expect(result.content[0].text).not.toContain('Failed to create foreign key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown constraint-management fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'create',
+ constraintName: 'users_token_check',
+ tableName: 'users',
+ constraintTypeCreate: 'check',
+ checkExpression: "token <> 'raw-check-secret'",
+ rawSql: 'DROP TABLE users'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe identifiers before connecting', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'create',
+ constraintName: 'users_email_unique',
+ tableName: 'users; drop table users',
+ constraintTypeCreate: 'unique',
+ columnNames: ['email']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects empty constraint column arrays before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'create',
+ constraintName: 'users_email_unique',
+ tableName: 'users',
+ constraintTypeCreate: 'unique',
+ columnNames: []
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('columnNames');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe drop-constraint identifiers before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'drop',
+ constraintName: 'users_email_unique; drop table users',
+ tableName: 'users'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe foreign-key identifiers before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageConstraintsTool.execute({
+ operation: 'create_fk',
+ constraintName: 'orders_user_id_fkey',
+ tableName: 'orders',
+ columnNames: ['user_id; drop table orders'],
+ referencedTable: 'users',
+ referencedColumns: ['id']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/tools/constraints.ts b/src/tools/constraints.ts
index 637b574..f4a1c9c 100644
--- a/src/tools/constraints.ts
+++ b/src/tools/constraints.ts
@@ -1,9 +1,10 @@
-import { DatabaseConnection } from '../utils/connection.js';
-import { z } from 'zod';
-import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
-import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import { DatabaseConnection, sanitizeErrorMessage } from '../utils/connection.js';
+import { z } from 'zod';
+import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
+import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import { quoteIdent, quoteQualifiedIdent, redactSqlText } from '../utils/sql.js';
-interface ConstraintInfo {
+interface ConstraintInfo {
constraint_name: string;
constraint_type: string;
table_name: string;
@@ -12,16 +13,28 @@ interface ConstraintInfo {
foreign_column_name?: string;
check_clause?: string;
is_deferrable: string;
- initially_deferred: string;
-}
-
-// --- Get Constraints Tool ---
-const GetConstraintsInputSchema = z.object({
- connectionString: z.string().optional(),
- schema: z.string().optional().default('public').describe("Schema name"),
- tableName: z.string().optional().describe("Optional table name to filter constraints"),
- constraintType: z.enum(['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK']).optional().describe("Filter by constraint type"),
-});
+ initially_deferred: string;
+}
+
+function toInternalError(prefix: string, error: unknown): McpError {
+ if (error instanceof McpError) {
+ return error;
+ }
+
+ return new McpError(ErrorCode.InternalError, `${prefix}: ${sanitizeErrorMessage(error)}`);
+}
+
+function formatValidationError(error: z.ZodError): string {
+ return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', ');
+}
+
+// --- Get Constraints Tool ---
+const GetConstraintsInputSchema = z.object({
+ connectionString: z.string().optional(),
+ schema: z.string().optional().default('public').describe("Schema name"),
+ tableName: z.string().optional().describe("Optional table name to filter constraints"),
+ constraintType: z.enum(['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK']).optional().describe("Filter by constraint type"),
+}).strict();
type GetConstraintsInput = z.infer;
async function executeGetConstraints(
@@ -78,11 +91,14 @@ async function executeGetConstraints(
ORDER BY tc.table_name, tc.constraint_type, tc.constraint_name
`;
- const result = await db.query(constraintsQuery, params);
- return result;
+ const result = await db.query(constraintsQuery, params);
+ return result.map((constraint) => ({
+ ...constraint,
+ check_clause: constraint.check_clause ? redactSqlText(constraint.check_clause) : constraint.check_clause
+ }));
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to get constraints: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to get constraints', error);
} finally {
await db.disconnect();
}
@@ -93,10 +109,10 @@ export const getConstraintsTool: PostgresTool = {
description: 'List all constraints (primary keys, foreign keys, unique, check)',
inputSchema: GetConstraintsInputSchema,
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
- const validationResult = GetConstraintsInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = GetConstraintsInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeGetConstraints(validationResult.data, getConnectionString);
const message = validationResult.data.tableName
@@ -104,14 +120,14 @@ export const getConstraintsTool: PostgresTool = {
: `All constraints in schema ${validationResult.data.schema}`;
return { content: [{ type: 'text', text: message }, { type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error getting constraints: ${errorMessage}` }], isError: true };
}
}
};
// --- Create Foreign Key Tool ---
-const CreateForeignKeyInputSchema = z.object({
+const CreateForeignKeyInputSchema = z.object({
connectionString: z.string().optional(),
constraintName: z.string().describe("Name of the foreign key constraint"),
tableName: z.string().describe("Table to add the foreign key to"),
@@ -122,61 +138,59 @@ const CreateForeignKeyInputSchema = z.object({
referencedSchema: z.string().optional().describe("Referenced table schema (defaults to same as table schema)"),
onUpdate: z.enum(['NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', 'SET DEFAULT']).optional().default('NO ACTION').describe("ON UPDATE action"),
onDelete: z.enum(['NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', 'SET DEFAULT']).optional().default('NO ACTION').describe("ON DELETE action"),
- deferrable: z.boolean().optional().default(false).describe("Make constraint deferrable"),
- initiallyDeferred: z.boolean().optional().default(false).describe("Initially deferred"),
-});
+ deferrable: z.boolean().optional().default(false).describe("Make constraint deferrable"),
+ initiallyDeferred: z.boolean().optional().default(false).describe("Initially deferred"),
+}).strict();
type CreateForeignKeyInput = z.infer;
-async function executeCreateForeignKey(
- input: CreateForeignKeyInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ constraintName: string; tableName: string; created: true }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const {
- constraintName,
- tableName,
- columnNames,
- referencedTable,
- referencedColumns,
- schema,
- referencedSchema,
- onUpdate,
- onDelete,
+async function executeCreateForeignKey(
+ input: CreateForeignKeyInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ constraintName: string; tableName: string; created: true }> {
+ const db = DatabaseConnection.getInstance();
+ const {
+ constraintName,
+ tableName,
+ columnNames,
+ referencedTable,
+ referencedColumns,
+ schema,
+ referencedSchema,
+ onUpdate,
+ onDelete,
deferrable,
initiallyDeferred
- } = input;
-
- try {
- await db.connect(resolvedConnectionString);
-
- if (columnNames.length !== referencedColumns.length) {
- throw new McpError(ErrorCode.InvalidParams, 'Number of columns must match number of referenced columns');
- }
-
- const schemaPrefix = schema !== 'public' ? `"${schema}".` : '';
- const refSchemaPrefix = (referencedSchema || schema) !== 'public' ? `"${referencedSchema || schema}".` : '';
-
- const columnsClause = columnNames.map(col => `"${col}"`).join(', ');
- const referencedColumnsClause = referencedColumns.map(col => `"${col}"`).join(', ');
-
- const deferrableClause = deferrable ? ' DEFERRABLE' : '';
- const initiallyDeferredClause = initiallyDeferred ? ' INITIALLY DEFERRED' : '';
-
- const createFkSQL = `
- ALTER TABLE ${schemaPrefix}"${tableName}"
- ADD CONSTRAINT "${constraintName}"
- FOREIGN KEY (${columnsClause})
- REFERENCES ${refSchemaPrefix}"${referencedTable}" (${referencedColumnsClause})
- ON UPDATE ${onUpdate}
- ON DELETE ${onDelete}${deferrableClause}${initiallyDeferredClause}
- `;
-
- await db.query(createFkSQL);
-
+ } = input;
+
+ try {
+ if (columnNames.length !== referencedColumns.length) {
+ throw new McpError(ErrorCode.InvalidParams, 'Number of columns must match number of referenced columns');
+ }
+
+ const qualifiedTableName = quoteQualifiedIdent(tableName, schema);
+ const qualifiedReferencedTable = quoteQualifiedIdent(referencedTable, referencedSchema || schema);
+ const columnsClause = columnNames.map(quoteIdent).join(', ');
+ const referencedColumnsClause = referencedColumns.map(quoteIdent).join(', ');
+
+ const deferrableClause = deferrable ? ' DEFERRABLE' : '';
+ const initiallyDeferredClause = initiallyDeferred ? ' INITIALLY DEFERRED' : '';
+
+ const createFkSQL = `
+ ALTER TABLE ${qualifiedTableName}
+ ADD CONSTRAINT ${quoteIdent(constraintName)}
+ FOREIGN KEY (${columnsClause})
+ REFERENCES ${qualifiedReferencedTable} (${referencedColumnsClause})
+ ON UPDATE ${onUpdate}
+ ON DELETE ${onDelete}${deferrableClause}${initiallyDeferredClause}
+ `;
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
+ await db.connect(resolvedConnectionString);
+ await db.query(createFkSQL);
+
return { constraintName, tableName, created: true };
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to create foreign key: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to create foreign key', error);
} finally {
await db.disconnect();
}
@@ -187,53 +201,52 @@ export const createForeignKeyTool: PostgresTool = {
description: 'Create a foreign key constraint',
inputSchema: CreateForeignKeyInputSchema,
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
- const validationResult = CreateForeignKeyInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = CreateForeignKeyInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeCreateForeignKey(validationResult.data, getConnectionString);
return { content: [{ type: 'text', text: `Foreign key ${result.constraintName} created successfully.` }, { type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error creating foreign key: ${errorMessage}` }], isError: true };
}
}
};
// --- Drop Foreign Key Tool ---
-const DropForeignKeyInputSchema = z.object({
+const DropForeignKeyInputSchema = z.object({
connectionString: z.string().optional(),
constraintName: z.string().describe("Name of the foreign key constraint to drop"),
tableName: z.string().describe("Table name"),
- schema: z.string().optional().default('public').describe("Schema name"),
- ifExists: z.boolean().optional().default(true).describe("Include IF EXISTS clause"),
- cascade: z.boolean().optional().default(false).describe("Include CASCADE clause"),
-});
+ schema: z.string().optional().default('public').describe("Schema name"),
+ ifExists: z.boolean().optional().default(true).describe("Include IF EXISTS clause"),
+ cascade: z.boolean().optional().default(false).describe("Include CASCADE clause"),
+}).strict();
type DropForeignKeyInput = z.infer;
-async function executeDropForeignKey(
- input: DropForeignKeyInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ constraintName: string; tableName: string; dropped: true }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const { constraintName, tableName, schema, ifExists, cascade } = input;
-
- try {
- await db.connect(resolvedConnectionString);
-
- const schemaPrefix = schema !== 'public' ? `"${schema}".` : '';
- const ifExistsClause = ifExists ? 'IF EXISTS ' : '';
- const cascadeClause = cascade ? ' CASCADE' : '';
-
- const dropFkSQL = `ALTER TABLE ${schemaPrefix}"${tableName}" DROP CONSTRAINT ${ifExistsClause}"${constraintName}"${cascadeClause}`;
-
- await db.query(dropFkSQL);
-
+async function executeDropForeignKey(
+ input: DropForeignKeyInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ constraintName: string; tableName: string; dropped: true }> {
+ const db = DatabaseConnection.getInstance();
+ const { constraintName, tableName, schema, ifExists, cascade } = input;
+
+ try {
+ const qualifiedTableName = quoteQualifiedIdent(tableName, schema);
+ const ifExistsClause = ifExists ? 'IF EXISTS ' : '';
+ const cascadeClause = cascade ? ' CASCADE' : '';
+
+ const dropFkSQL = `ALTER TABLE ${qualifiedTableName} DROP CONSTRAINT ${ifExistsClause}${quoteIdent(constraintName)}${cascadeClause}`;
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
+ await db.connect(resolvedConnectionString);
+ await db.query(dropFkSQL);
+
return { constraintName, tableName, dropped: true };
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to drop foreign key: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to drop foreign key', error);
} finally {
await db.disconnect();
}
@@ -244,73 +257,70 @@ export const dropForeignKeyTool: PostgresTool = {
description: 'Drop a foreign key constraint',
inputSchema: DropForeignKeyInputSchema,
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
- const validationResult = DropForeignKeyInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = DropForeignKeyInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeDropForeignKey(validationResult.data, getConnectionString);
return { content: [{ type: 'text', text: `Foreign key ${result.constraintName} dropped successfully.` }, { type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error dropping foreign key: ${errorMessage}` }], isError: true };
}
}
};
// --- Create Constraint Tool ---
-const CreateConstraintInputSchema = z.object({
+const CreateConstraintInputSchema = z.object({
connectionString: z.string().optional(),
constraintName: z.string().describe("Name of the constraint"),
tableName: z.string().describe("Table to add the constraint to"),
constraintType: z.enum(['unique', 'check', 'primary_key']).describe("Type of constraint"),
- columnNames: z.array(z.string()).optional().describe("Column names (for unique/primary key constraints)"),
+ columnNames: z.array(z.string()).min(1).optional().describe("Column names (for unique/primary key constraints)"),
checkExpression: z.string().optional().describe("Check expression (for check constraints)"),
- schema: z.string().optional().default('public').describe("Schema name"),
- deferrable: z.boolean().optional().default(false).describe("Make constraint deferrable"),
- initiallyDeferred: z.boolean().optional().default(false).describe("Initially deferred"),
-});
+ schema: z.string().optional().default('public').describe("Schema name"),
+ deferrable: z.boolean().optional().default(false).describe("Make constraint deferrable"),
+ initiallyDeferred: z.boolean().optional().default(false).describe("Initially deferred"),
+}).strict();
type CreateConstraintInput = z.infer;
-async function executeCreateConstraint(
- input: CreateConstraintInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ constraintName: string; tableName: string; constraintType: string; created: true }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const {
- constraintName,
- tableName,
- constraintType,
- columnNames,
- checkExpression,
- schema,
- deferrable,
+async function executeCreateConstraint(
+ input: CreateConstraintInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ constraintName: string; tableName: string; constraintType: string; created: true }> {
+ const db = DatabaseConnection.getInstance();
+ const {
+ constraintName,
+ tableName,
+ constraintType,
+ columnNames,
+ checkExpression,
+ schema,
+ deferrable,
initiallyDeferred
- } = input;
-
- try {
- await db.connect(resolvedConnectionString);
-
- const schemaPrefix = schema !== 'public' ? `"${schema}".` : '';
- const deferrableClause = deferrable ? ' DEFERRABLE' : '';
- const initiallyDeferredClause = initiallyDeferred ? ' INITIALLY DEFERRED' : '';
-
- let constraintClause = '';
-
+ } = input;
+
+ try {
+ const qualifiedTableName = quoteQualifiedIdent(tableName, schema);
+ const deferrableClause = deferrable ? ' DEFERRABLE' : '';
+ const initiallyDeferredClause = initiallyDeferred ? ' INITIALLY DEFERRED' : '';
+
+ let constraintClause = '';
+
switch (constraintType) {
case 'unique':
- if (!columnNames || columnNames.length === 0) {
- throw new McpError(ErrorCode.InvalidParams, 'Column names are required for unique constraints');
- }
- constraintClause = `UNIQUE (${columnNames.map(col => `"${col}"`).join(', ')})`;
- break;
- case 'primary_key':
- if (!columnNames || columnNames.length === 0) {
- throw new McpError(ErrorCode.InvalidParams, 'Column names are required for primary key constraints');
- }
- constraintClause = `PRIMARY KEY (${columnNames.map(col => `"${col}"`).join(', ')})`;
- break;
+ if (!columnNames || columnNames.length === 0) {
+ throw new McpError(ErrorCode.InvalidParams, 'Column names are required for unique constraints');
+ }
+ constraintClause = `UNIQUE (${columnNames.map(quoteIdent).join(', ')})`;
+ break;
+ case 'primary_key':
+ if (!columnNames || columnNames.length === 0) {
+ throw new McpError(ErrorCode.InvalidParams, 'Column names are required for primary key constraints');
+ }
+ constraintClause = `PRIMARY KEY (${columnNames.map(quoteIdent).join(', ')})`;
+ break;
case 'check':
if (!checkExpression) {
throw new McpError(ErrorCode.InvalidParams, 'Check expression is required for check constraints');
@@ -318,18 +328,20 @@ async function executeCreateConstraint(
constraintClause = `CHECK (${checkExpression})`;
break;
}
-
- const createConstraintSQL = `
- ALTER TABLE ${schemaPrefix}"${tableName}"
- ADD CONSTRAINT "${constraintName}"
- ${constraintClause}${deferrableClause}${initiallyDeferredClause}
- `;
-
- await db.query(createConstraintSQL);
-
+
+ const createConstraintSQL = `
+ ALTER TABLE ${qualifiedTableName}
+ ADD CONSTRAINT ${quoteIdent(constraintName)}
+ ${constraintClause}${deferrableClause}${initiallyDeferredClause}
+ `;
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
+ await db.connect(resolvedConnectionString);
+ await db.query(createConstraintSQL);
+
return { constraintName, tableName, constraintType, created: true };
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to create constraint: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to create constraint', error);
} finally {
await db.disconnect();
}
@@ -340,117 +352,122 @@ export const createConstraintTool: PostgresTool = {
description: 'Create a constraint (unique, check, or primary key)',
inputSchema: CreateConstraintInputSchema,
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
- const validationResult = CreateConstraintInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = CreateConstraintInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeCreateConstraint(validationResult.data, getConnectionString);
return { content: [{ type: 'text', text: `${result.constraintType} constraint ${result.constraintName} created successfully.` }, { type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error creating constraint: ${errorMessage}` }], isError: true };
}
}
};
// --- Drop Constraint Tool ---
-const DropConstraintInputSchema = z.object({
+const DropConstraintInputSchema = z.object({
connectionString: z.string().optional(),
constraintName: z.string().describe("Name of the constraint to drop"),
tableName: z.string().describe("Table name"),
- schema: z.string().optional().default('public').describe("Schema name"),
- ifExists: z.boolean().optional().default(true).describe("Include IF EXISTS clause"),
- cascade: z.boolean().optional().default(false).describe("Include CASCADE clause"),
-});
+ schema: z.string().optional().default('public').describe("Schema name"),
+ ifExists: z.boolean().optional().default(true).describe("Include IF EXISTS clause"),
+ cascade: z.boolean().optional().default(false).describe("Include CASCADE clause"),
+}).strict();
type DropConstraintInput = z.infer;
-async function executeDropConstraint(
- input: DropConstraintInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ constraintName: string; tableName: string; dropped: true }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const { constraintName, tableName, schema, ifExists, cascade } = input;
-
- try {
- await db.connect(resolvedConnectionString);
-
- const schemaPrefix = schema !== 'public' ? `"${schema}".` : '';
- const ifExistsClause = ifExists ? 'IF EXISTS ' : '';
- const cascadeClause = cascade ? ' CASCADE' : '';
-
- const dropConstraintSQL = `ALTER TABLE ${schemaPrefix}"${tableName}" DROP CONSTRAINT ${ifExistsClause}"${constraintName}"${cascadeClause}`;
-
- await db.query(dropConstraintSQL);
-
+async function executeDropConstraint(
+ input: DropConstraintInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ constraintName: string; tableName: string; dropped: true }> {
+ const db = DatabaseConnection.getInstance();
+ const { constraintName, tableName, schema, ifExists, cascade } = input;
+
+ try {
+ const qualifiedTableName = quoteQualifiedIdent(tableName, schema);
+ const ifExistsClause = ifExists ? 'IF EXISTS ' : '';
+ const cascadeClause = cascade ? ' CASCADE' : '';
+
+ const dropConstraintSQL = `ALTER TABLE ${qualifiedTableName} DROP CONSTRAINT ${ifExistsClause}${quoteIdent(constraintName)}${cascadeClause}`;
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
+ await db.connect(resolvedConnectionString);
+ await db.query(dropConstraintSQL);
+
return { constraintName, tableName, dropped: true };
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to drop constraint: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to drop constraint', error);
} finally {
await db.disconnect();
}
}
-export const dropConstraintTool: PostgresTool = {
+export const dropConstraintTool: PostgresTool = {
name: 'pg_drop_constraint',
description: 'Drop a constraint',
inputSchema: DropConstraintInputSchema,
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
- const validationResult = DropConstraintInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = DropConstraintInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeDropConstraint(validationResult.data, getConnectionString);
return { content: [{ type: 'text', text: `Constraint ${result.constraintName} dropped successfully.` }, { type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error dropping constraint: ${errorMessage}` }], isError: true };
}
- }
-};
-
-// Consolidated Constraint Management Tool
-export const manageConstraintsTool: PostgresTool = {
- name: 'pg_manage_constraints',
- description: 'Manage PostgreSQL constraints - get, create foreign keys, drop foreign keys, create constraints, drop constraints. Examples: operation="get" to list constraints, operation="create_fk" with constraintName, tableName, columnNames, referencedTable, referencedColumns',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- operation: z.enum(['get', 'create_fk', 'drop_fk', 'create', 'drop']).describe('Operation: get (list constraints), create_fk (foreign key), drop_fk (drop foreign key), create (constraint), drop (constraint)'),
-
- // Common parameters
- schema: z.string().optional().describe('Schema name (defaults to public)'),
- constraintName: z.string().optional().describe('Constraint name (required for create_fk/drop_fk/create/drop)'),
- tableName: z.string().optional().describe('Table name (optional filter for get, required for create_fk/drop_fk/create/drop)'),
-
- // Get operation parameters
- constraintType: z.enum(['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK']).optional().describe('Filter by constraint type (for get operation)'),
-
- // Foreign key specific parameters
- columnNames: z.array(z.string()).optional().describe('Column names in the table (required for create_fk)'),
- referencedTable: z.string().optional().describe('Referenced table name (required for create_fk)'),
- referencedColumns: z.array(z.string()).optional().describe('Referenced column names (required for create_fk)'),
- referencedSchema: z.string().optional().describe('Referenced table schema (for create_fk, defaults to same as table schema)'),
- onUpdate: z.enum(['NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', 'SET DEFAULT']).optional().describe('ON UPDATE action (for create_fk)'),
- onDelete: z.enum(['NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', 'SET DEFAULT']).optional().describe('ON DELETE action (for create_fk)'),
-
- // Constraint specific parameters
- constraintTypeCreate: z.enum(['unique', 'check', 'primary_key']).optional().describe('Type of constraint to create (for create operation)'),
- checkExpression: z.string().optional().describe('Check expression (for create operation with check constraints)'),
-
- // Common options
- deferrable: z.boolean().optional().describe('Make constraint deferrable (for create_fk/create operations)'),
- initiallyDeferred: z.boolean().optional().describe('Initially deferred (for create_fk/create operations)'),
- ifExists: z.boolean().optional().describe('Include IF EXISTS clause (for drop_fk/drop operations)'),
- cascade: z.boolean().optional().describe('Include CASCADE clause (for drop_fk/drop operations)')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const {
- connectionString: connStringArg,
- operation,
- schema,
+ }
+};
+
+const ManageConstraintsInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ operation: z.enum(['get', 'create_fk', 'drop_fk', 'create', 'drop']).describe('Operation: get (list constraints), create_fk (foreign key), drop_fk (drop foreign key), create (constraint), drop (constraint)'),
+
+ // Common parameters
+ schema: z.string().optional().describe('Schema name (defaults to public)'),
+ constraintName: z.string().optional().describe('Constraint name (required for create_fk/drop_fk/create/drop)'),
+ tableName: z.string().optional().describe('Table name (optional filter for get, required for create_fk/drop_fk/create/drop)'),
+
+ // Get operation parameters
+ constraintType: z.enum(['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK']).optional().describe('Filter by constraint type (for get operation)'),
+
+ // Foreign key specific parameters
+ columnNames: z.array(z.string()).min(1).optional().describe('Column names in the table (required for create_fk)'),
+ referencedTable: z.string().optional().describe('Referenced table name (required for create_fk)'),
+ referencedColumns: z.array(z.string()).min(1).optional().describe('Referenced column names (required for create_fk)'),
+ referencedSchema: z.string().optional().describe('Referenced table schema (for create_fk, defaults to same as table schema)'),
+ onUpdate: z.enum(['NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', 'SET DEFAULT']).optional().describe('ON UPDATE action (for create_fk)'),
+ onDelete: z.enum(['NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', 'SET DEFAULT']).optional().describe('ON DELETE action (for create_fk)'),
+
+ // Constraint specific parameters
+ constraintTypeCreate: z.enum(['unique', 'check', 'primary_key']).optional().describe('Type of constraint to create (for create operation)'),
+ checkExpression: z.string().optional().describe('Check expression (for create operation with check constraints)'),
+
+ // Common options
+ deferrable: z.boolean().optional().describe('Make constraint deferrable (for create_fk/create operations)'),
+ initiallyDeferred: z.boolean().optional().describe('Initially deferred (for create_fk/create operations)'),
+ ifExists: z.boolean().optional().describe('Include IF EXISTS clause (for drop_fk/drop operations)'),
+ cascade: z.boolean().optional().describe('Include CASCADE clause (for drop_fk/drop operations)')
+}).strict();
+
+// Consolidated Constraint Management Tool
+export const manageConstraintsTool: PostgresTool = {
+ name: 'pg_manage_constraints',
+ description: 'Manage PostgreSQL constraints - get, create foreign keys, drop foreign keys, create constraints, drop constraints. Examples: operation="get" to list constraints, operation="create_fk" with constraintName, tableName, columnNames, referencedTable, referencedColumns',
+ inputSchema: ManageConstraintsInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = ManageConstraintsInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const {
+ connectionString: connStringArg,
+ operation,
+ schema,
constraintName,
tableName,
constraintType,
@@ -462,30 +479,11 @@ export const manageConstraintsTool: PostgresTool = {
onDelete,
constraintTypeCreate,
checkExpression,
- deferrable,
- initiallyDeferred,
- ifExists,
- cascade
- } = args as {
- connectionString?: string;
- operation: 'get' | 'create_fk' | 'drop_fk' | 'create' | 'drop';
- schema?: string;
- constraintName?: string;
- tableName?: string;
- constraintType?: 'PRIMARY KEY' | 'FOREIGN KEY' | 'UNIQUE' | 'CHECK';
- columnNames?: string[];
- referencedTable?: string;
- referencedColumns?: string[];
- referencedSchema?: string;
- onUpdate?: 'NO ACTION' | 'RESTRICT' | 'CASCADE' | 'SET NULL' | 'SET DEFAULT';
- onDelete?: 'NO ACTION' | 'RESTRICT' | 'CASCADE' | 'SET NULL' | 'SET DEFAULT';
- constraintTypeCreate?: 'unique' | 'check' | 'primary_key';
- checkExpression?: string;
- deferrable?: boolean;
- initiallyDeferred?: boolean;
- ifExists?: boolean;
- cascade?: boolean;
- };
+ deferrable,
+ initiallyDeferred,
+ ifExists,
+ cascade
+ } = validationResult.data;
try {
switch (operation) {
@@ -591,8 +589,8 @@ export const manageConstraintsTool: PostgresTool = {
}
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error executing ${operation} operation: ${errorMessage}` }], isError: true };
}
}
-};
\ No newline at end of file
+};
diff --git a/src/tools/data.test.ts b/src/tools/data.test.ts
new file mode 100644
index 0000000..3744851
--- /dev/null
+++ b/src/tools/data.test.ts
@@ -0,0 +1,567 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import { executeMutationTool, executeQueryTool, executeSqlTool } from './data';
+
+describe('executeMutationTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('builds update SQL with quoted identifiers and structured where predicates', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [{ id: 123, email: 'new@example.com' }], rowCount: 1 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeMutationTool.execute({
+ operation: 'update',
+ table: 'users',
+ data: { email: 'new@example.com' },
+ where: { id: 123 },
+ returning: ['id', 'email']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.queryResult).toHaveBeenCalledWith(
+ 'UPDATE "users" SET "email" = $1 WHERE "id" = $2 RETURNING "id", "email"',
+ ['new@example.com', 123]
+ );
+ });
+
+ it('rejects unsafe identifiers before executing SQL', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeMutationTool.execute({
+ operation: 'insert',
+ table: 'users; drop table users',
+ data: { email: 'new@example.com' }
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.queryResult).not.toHaveBeenCalled();
+ });
+
+ it('reports rows affected from PostgreSQL rowCount when no returning data is requested', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 7 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeMutationTool.execute({
+ operation: 'update',
+ table: 'users',
+ data: { active: false },
+ where: { stale: true }
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('Rows affected: 7');
+ expect(mockDb.queryResult).toHaveBeenCalledWith(
+ 'UPDATE "users" SET "active" = $1 WHERE "stale" = $2',
+ [false, true]
+ );
+ });
+
+ it('rejects legacy string where predicates before executing mutations', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeMutationTool.execute({
+ operation: 'update',
+ table: 'users',
+ data: { active: false },
+ where: "token = 'raw-mutation-secret'"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('String where predicates are not allowed');
+ expect(result.content[0].text).not.toContain('raw-mutation-secret');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.queryResult).not.toHaveBeenCalled();
+ });
+
+ it('rejects missing mutation data before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeMutationTool.execute({
+ operation: 'update',
+ table: 'users',
+ where: { id: 1 }
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Data object is required for update operation');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.queryResult).not.toHaveBeenCalled();
+ });
+
+ it('allows explicit rawWhere predicates for trusted local/admin mutation SQL', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 1 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeMutationTool.execute({
+ operation: 'update',
+ table: 'users',
+ data: { active: false },
+ rawWhere: "token = 'raw-mutation-secret'"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.queryResult).toHaveBeenCalledWith(
+ 'UPDATE "users" SET "active" = $1 WHERE token = \'raw-mutation-secret\'',
+ [false]
+ );
+ });
+
+ it('truncates returning data in the MCP response without changing affected row count', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({
+ rows: [{ id: 1 }, { id: 2 }, { id: 3 }],
+ rowCount: 3
+ }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeMutationTool.execute({
+ operation: 'update',
+ table: 'users',
+ data: { active: false },
+ where: { stale: true },
+ returning: ['id'],
+ maxReturningRows: 2
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('Rows affected: 3');
+ expect(result.content[0].text).toContain('Returning data truncated to 2 of 3 rows');
+ expect(result.content[0].text).toContain('"id": 1');
+ expect(result.content[0].text).toContain('"id": 2');
+ expect(result.content[0].text).not.toContain('"id": 3');
+ });
+
+ it('rejects invalid returning output limits before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeMutationTool.execute({
+ operation: 'insert',
+ table: 'users',
+ data: { email: 'new@example.com' },
+ returning: '*',
+ maxReturningRows: 1001
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown mutation input fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeMutationTool.execute({
+ operation: 'update',
+ table: 'users',
+ data: { active: false },
+ where: { id: 1 },
+ unsafeSql: 'DROP TABLE users'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+});
+
+describe('executeQueryTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('runs SELECT queries in a read-only transaction', async () => {
+ const query = vi.fn().mockResolvedValue({ rows: [{ id: 1 }] });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise, options: unknown) => {
+ return callback({ query });
+ }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeQueryTool.execute({
+ operation: 'select',
+ query: 'SELECT * FROM users WHERE id = $1',
+ parameters: [1],
+ timeout: 1000
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.transaction).toHaveBeenCalledWith(expect.any(Function), { readOnly: true });
+ expect(query).toHaveBeenCalledWith({
+ text: 'SELECT * FROM (SELECT * FROM users WHERE id = $1) AS mcp_query LIMIT $2',
+ values: [1, 100],
+ timeout: 1000
+ });
+ });
+
+ it('applies a bounded custom SELECT limit as a parameter', async () => {
+ const query = vi.fn().mockResolvedValue({ rows: [{ id: 1 }] });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise) => {
+ return callback({ query });
+ }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeQueryTool.execute({
+ operation: 'select',
+ query: 'SELECT * FROM users WHERE active = $1;',
+ parameters: [true],
+ limit: 25
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(query).toHaveBeenCalledWith({
+ text: 'SELECT * FROM (SELECT * FROM users WHERE active = $1) AS mcp_query LIMIT $2',
+ values: [true, 25]
+ });
+ });
+
+ it('rejects invalid SELECT limits before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeQueryTool.execute({
+ operation: 'select',
+ query: 'SELECT * FROM users',
+ limit: 1001
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown query input fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeQueryTool.execute({
+ operation: 'select',
+ query: 'SELECT * FROM users',
+ rawSql: 'DELETE FROM users'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('rejects data-changing CTEs before connecting', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeQueryTool.execute({
+ operation: 'select',
+ query: 'WITH deleted AS (DELETE FROM users RETURNING *) SELECT * FROM deleted'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('DELETE');
+ expect(result.content[0].text).not.toContain('InternalError');
+ expect(result.content[0].text).not.toContain('Failed to execute query');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.transaction).not.toHaveBeenCalled();
+ });
+});
+
+describe('executeSqlTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('reports non-transactional affected rows from PostgreSQL rowCount', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 4 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeSqlTool.execute({
+ sql: 'UPDATE users SET active = false WHERE stale = true',
+ expectRows: false
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('Rows affected: 4');
+ expect(mockDb.queryResult).toHaveBeenCalledWith(
+ 'UPDATE users SET active = false WHERE stale = true',
+ [],
+ {}
+ );
+ });
+
+ it('truncates arbitrary SQL row output', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({
+ rows: [{ id: 1 }, { id: 2 }, { id: 3 }],
+ rowCount: 3
+ }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeSqlTool.execute({
+ sql: 'SELECT * FROM users',
+ maxRows: 2
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('Retrieved 3 rows; returning first 2');
+ expect(result.content[0].text).toContain('"id": 1');
+ expect(result.content[0].text).toContain('"id": 2');
+ expect(result.content[0].text).not.toContain('"id": 3');
+ });
+
+ it('passes timeout through transactional arbitrary SQL execution', async () => {
+ const query = vi.fn().mockResolvedValue({ rows: [{ id: 1 }], rowCount: 1 });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise) => {
+ return callback({ query });
+ }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeSqlTool.execute({
+ sql: 'SELECT * FROM users WHERE id = $1',
+ parameters: [1],
+ timeout: 2000,
+ transactional: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(query).toHaveBeenCalledWith({
+ text: 'SELECT * FROM users WHERE id = $1',
+ values: [1],
+ timeout: 2000
+ });
+ });
+
+ it('rejects multi-statement arbitrary SQL unless it is transactional', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeSqlTool.execute({
+ sql: 'UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2',
+ expectRows: false
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Multi-statement arbitrary SQL must use transactional=true');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('rejects multi-statement arbitrary SQL result sets as ambiguous', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeSqlTool.execute({
+ sql: 'SELECT 1; SELECT 2',
+ transactional: true,
+ expectRows: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('expectRows=false');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('rejects parameters for multi-statement arbitrary SQL', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeSqlTool.execute({
+ sql: 'UPDATE accounts SET balance = balance - $1 WHERE id = $2; UPDATE accounts SET balance = balance + $1 WHERE id = $3',
+ parameters: [100, 1, 2],
+ transactional: true,
+ expectRows: false
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('cannot use parameters');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('allows transactional multi-statement arbitrary SQL without parameters or result rows', async () => {
+ const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise) => {
+ return callback({ query });
+ }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeSqlTool.execute({
+ sql: 'ALTER TABLE users ADD COLUMN archived boolean; CREATE INDEX idx_users_archived ON users(archived)',
+ transactional: true,
+ expectRows: false
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.transaction).toHaveBeenCalled();
+ expect(query).toHaveBeenCalledWith({
+ text: 'ALTER TABLE users ADD COLUMN archived boolean; CREATE INDEX idx_users_archived ON users(archived)',
+ values: []
+ });
+ });
+
+ it('sanitizes arbitrary SQL execution errors before returning them', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockRejectedValue(
+ new Error("password=db-secret failed while running SELECT * FROM tokens WHERE value = 'raw-token'")
+ ),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeSqlTool.execute({
+ sql: 'SELECT * FROM tokens'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('password=*****');
+ expect(result.content[0].text).toContain("value = '?'");
+ expect(result.content[0].text).not.toContain('db-secret');
+ expect(result.content[0].text).not.toContain('raw-token');
+ });
+
+ it('rejects invalid arbitrary SQL output limits before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeSqlTool.execute({
+ sql: 'SELECT * FROM users',
+ maxRows: 1001
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown arbitrary SQL input fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryResult: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await executeSqlTool.execute({
+ sql: 'SELECT * FROM users',
+ operation: 'drop'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/tools/data.ts b/src/tools/data.ts
index 87597f0..c1790e9 100644
--- a/src/tools/data.ts
+++ b/src/tools/data.ts
@@ -1,85 +1,124 @@
-import { z } from 'zod';
-import { DatabaseConnection } from '../utils/connection.js';
-import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
-import type { PostgresTool, ToolOutput, GetConnectionStringFn } from '../types/tool.js';
-
-// ===== EXECUTE QUERY TOOL (SELECT operations) =====
-
-const ExecuteQueryInputSchema = z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- operation: z.enum(['select', 'count', 'exists']).describe('Query operation: select (fetch rows), count (count rows), exists (check existence)'),
- query: z.string().describe('SQL SELECT query to execute'),
- parameters: z.array(z.unknown()).optional().default([]).describe('Parameter values for prepared statement placeholders ($1, $2, etc.)'),
- limit: z.number().optional().describe('Maximum number of rows to return (safety limit)'),
- timeout: z.number().optional().describe('Query timeout in milliseconds')
-});
-
-type ExecuteQueryInput = z.infer;
-
-async function executeQuery(
- input: ExecuteQueryInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ operation: string; rowCount: number; rows?: unknown[]; result?: unknown }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const { operation, query, parameters, limit, timeout } = input;
-
- try {
- await db.connect(resolvedConnectionString);
-
- // Validate query is a SELECT-like operation
- const trimmedQuery = query.trim().toLowerCase();
- if (!trimmedQuery.startsWith('select') && !trimmedQuery.startsWith('with')) {
- throw new McpError(ErrorCode.InvalidParams, 'Query must be a SELECT statement or CTE (WITH clause)');
- }
-
- let finalQuery = query;
- const queryParams = parameters || [];
-
- // Apply limit if specified and not already in query
- if (limit && !trimmedQuery.includes('limit')) {
- finalQuery += ` LIMIT ${limit}`;
- }
-
- const queryOptions = timeout ? { timeout } : {};
-
- switch (operation) {
- case 'select': {
- const rows = await db.query(finalQuery, queryParams, queryOptions);
- return {
- operation: 'select',
- rowCount: rows.length,
- rows: rows
- };
- }
-
- case 'count': {
- // Wrap the query in a COUNT to get total rows
- const countQuery = `SELECT COUNT(*) as total FROM (${query}) as subquery`;
- const result = await db.queryOne<{ total: number }>(countQuery, queryParams, queryOptions);
- return {
- operation: 'count',
- rowCount: 1,
- result: result?.total || 0
- };
- }
-
- case 'exists': {
- // Wrap the query in an EXISTS check
- const existsQuery = `SELECT EXISTS (${query}) as exists`;
- const result = await db.queryOne<{ exists: boolean }>(existsQuery, queryParams, queryOptions);
- return {
- operation: 'exists',
- rowCount: 1,
- result: result?.exists || false
- };
- }
+import { z } from 'zod';
+import { DatabaseConnection, sanitizeErrorMessage } from '../utils/connection.js';
+import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import type { PostgresTool, ToolOutput, GetConnectionStringFn } from '../types/tool.js';
+import {
+ buildReturningClause,
+ buildWhereClause,
+ getReadOnlySqlValidationError,
+ hasSqlStatementSeparator,
+ quoteIdent,
+ quoteQualifiedIdent,
+ type WherePredicate
+} from '../utils/sql.js';
+
+// ===== EXECUTE QUERY TOOL (SELECT operations) =====
+
+const DEFAULT_QUERY_LIMIT = 100;
+const MAX_QUERY_LIMIT = 1000;
+const DEFAULT_OUTPUT_ROW_LIMIT = 100;
+const MAX_OUTPUT_ROW_LIMIT = 1000;
+
+interface LimitedRows {
+ rows: unknown[];
+ totalRows: number;
+ truncated: boolean;
+}
+
+const ExecuteQueryInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ operation: z.enum(['select', 'count', 'exists']).describe('Query operation: select (fetch rows), count (count rows), exists (check existence)'),
+ query: z.string().describe('SQL SELECT query to execute'),
+ parameters: z.array(z.unknown()).optional().default([]).describe('Parameter values for prepared statement placeholders ($1, $2, etc.)'),
+ limit: z.number().int().min(1).max(MAX_QUERY_LIMIT).optional().default(DEFAULT_QUERY_LIMIT).describe(`Maximum number of rows to return for select operations (default ${DEFAULT_QUERY_LIMIT}, max ${MAX_QUERY_LIMIT})`),
+ timeout: z.number().int().positive().optional().describe('Query timeout in milliseconds')
+}).strict();
+type ExecuteQueryInput = z.infer;
+type QueryConfigWithTimeout = { text: string; values: unknown[]; timeout?: number };
+
+function createQueryConfig(text: string, values: unknown[], timeout?: number): QueryConfigWithTimeout {
+ const config: QueryConfigWithTimeout = { text, values };
+ if (timeout !== undefined) {
+ config.timeout = timeout;
+ }
+ return config;
+}
+
+function limitOutputRows(rows: unknown[], maxRows: number): LimitedRows {
+ return {
+ rows: rows.slice(0, maxRows),
+ totalRows: rows.length,
+ truncated: rows.length > maxRows
+ };
+}
+
+async function executeQuery(
+ input: ExecuteQueryInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ operation: string; rowCount: number; rows?: unknown[]; result?: unknown }> {
+ const db = DatabaseConnection.getInstance();
+ const { operation, query, parameters, limit, timeout } = input;
+
+ try {
+ const normalizedQuery = query.trim().replace(/;\s*$/, '');
+ const validationError = getReadOnlySqlValidationError(normalizedQuery);
+ if (validationError) {
+ throw new McpError(ErrorCode.InvalidParams, validationError);
+ }
+
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+ await db.connect(resolvedConnectionString);
+
+ const queryParams = parameters || [];
+
+ switch (operation) {
+ case 'select': {
+ const limitPlaceholder = `$${queryParams.length + 1}`;
+ const finalQuery = `SELECT * FROM (${normalizedQuery}) AS mcp_query LIMIT ${limitPlaceholder}`;
+ return await db.transaction(async (client) => {
+ const result = await client.query(createQueryConfig(finalQuery, [...queryParams, limit], timeout));
+ return {
+ operation: 'select',
+ rowCount: result.rows.length,
+ rows: result.rows
+ };
+ }, { readOnly: true });
+ }
+
+ case 'count': {
+ // Wrap the query in a COUNT to get total rows
+ const countQuery = `SELECT COUNT(*) as total FROM (${normalizedQuery}) as subquery`;
+ return await db.transaction(async (client) => {
+ const result = await client.query<{ total: number }>(createQueryConfig(countQuery, queryParams, timeout));
+ return {
+ operation: 'count',
+ rowCount: 1,
+ result: result.rows[0]?.total || 0
+ };
+ }, { readOnly: true });
+ }
+
+ case 'exists': {
+ // Wrap the query in an EXISTS check
+ const existsQuery = `SELECT EXISTS (${normalizedQuery}) as exists`;
+ return await db.transaction(async (client) => {
+ const result = await client.query<{ exists: boolean }>(createQueryConfig(existsQuery, queryParams, timeout));
+ return {
+ operation: 'exists',
+ rowCount: 1,
+ result: result.rows[0]?.exists || false
+ };
+ }, { readOnly: true });
+ }
default:
throw new McpError(ErrorCode.InvalidParams, `Unknown operation: ${operation}`);
}
- } catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to execute query: ${error instanceof Error ? error.message : String(error)}`);
+ } catch (error) {
+ if (error instanceof McpError) {
+ throw error;
+ }
+ throw new McpError(ErrorCode.InternalError, `Failed to execute query: ${sanitizeErrorMessage(error)}`);
} finally {
await db.disconnect();
}
@@ -88,40 +127,33 @@ async function executeQuery(
export const executeQueryTool: PostgresTool = {
name: 'pg_execute_query',
description: 'Execute SELECT queries and data retrieval operations - operation="select/count/exists" with query and optional parameters. Examples: operation="select", query="SELECT * FROM users WHERE created_at > $1", parameters=["2024-01-01"]',
- inputSchema: ExecuteQueryInputSchema,
- execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const {
- connectionString: connStringArg,
- operation,
- query,
- parameters,
- limit,
- timeout
- } = args as {
- connectionString?: string;
- operation: 'select' | 'count' | 'exists';
- query: string;
- parameters?: unknown[];
- limit?: number;
- timeout?: number;
- };
-
- const resolvedConnString = getConnectionStringVal(connStringArg);
-
- try {
- // Input validation
+ inputSchema: ExecuteQueryInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = ExecuteQueryInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ const errorDetails = validationResult.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
+ return {
+ content: [{ type: 'text', text: `Invalid input: ${errorDetails}` }],
+ isError: true,
+ };
+ }
+
+ const { connectionString: connStringArg, operation, query, parameters, limit, timeout } = validationResult.data;
+
+ try {
+ // Input validation
if (!query?.trim()) {
return {
content: [{ type: 'text', text: 'Error: query is required' }],
isError: true
- };
- }
-
- const result = await executeQuery({
- connectionString: resolvedConnString,
- operation,
- query,
- parameters: parameters ?? [],
+ };
+ }
+
+ const result = await executeQuery({
+ connectionString: connStringArg,
+ operation,
+ query,
+ parameters: parameters ?? [],
limit,
timeout
}, getConnectionStringVal);
@@ -141,114 +173,147 @@ export const executeQueryTool: PostgresTool = {
return { content: [{ type: 'text', text: responseText }] };
- } catch (error) {
- return {
- content: [{ type: 'text', text: `Error executing ${operation} query: ${error instanceof Error ? error.message : String(error)}` }],
- isError: true
- };
+ } catch (error) {
+ const errorMessage = sanitizeErrorMessage(error);
+ return {
+ content: [{ type: 'text', text: `Error executing ${operation} query: ${errorMessage}` }],
+ isError: true
+ };
}
}
};
-// ===== EXECUTE MUTATION TOOL (INSERT/UPDATE/DELETE operations) =====
-
-const ExecuteMutationInputSchema = z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- operation: z.enum(['insert', 'update', 'delete', 'upsert']).describe('Mutation operation: insert (add rows), update (modify rows), delete (remove rows), upsert (insert or update)'),
- table: z.string().describe('Table name for the operation'),
- data: z.record(z.unknown()).optional().describe('Data object with column-value pairs (required for insert/update/upsert)'),
- where: z.string().optional().describe('WHERE clause for update/delete operations (without WHERE keyword)'),
- conflictColumns: z.array(z.string()).optional().describe('Columns for conflict resolution in upsert (ON CONFLICT)'),
- returning: z.string().optional().describe('RETURNING clause to get back inserted/updated data'),
- schema: z.string().optional().default('public').describe('Schema name (defaults to public)')
-});
-
-type ExecuteMutationInput = z.infer;
-
-async function executeMutation(
- input: ExecuteMutationInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ operation: string; rowsAffected: number; returning?: unknown[] }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const { operation, table, data, where, conflictColumns, returning, schema } = input;
-
- try {
- await db.connect(resolvedConnectionString);
-
- const schemaPrefix = (schema && schema !== 'public') ? `"${schema}".` : '';
- const tableName = `${schemaPrefix}"${table}"`;
-
- switch (operation) {
- case 'insert': {
+// ===== EXECUTE MUTATION TOOL (INSERT/UPDATE/DELETE operations) =====
+
+const SqlScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
+const WhereOperatorSchema = z.object({
+ eq: SqlScalarSchema.optional(),
+ ne: SqlScalarSchema.optional(),
+ gt: SqlScalarSchema.optional(),
+ gte: SqlScalarSchema.optional(),
+ lt: SqlScalarSchema.optional(),
+ lte: SqlScalarSchema.optional(),
+ like: z.string().optional(),
+ ilike: z.string().optional(),
+ in: z.array(SqlScalarSchema).optional(),
+ isNull: z.boolean().optional()
+}).strict().refine((value) => Object.values(value).filter((item) => item !== undefined).length === 1, {
+ message: 'Each where predicate must specify exactly one operator'
+});
+const WherePredicateSchema = z.record(z.union([SqlScalarSchema, WhereOperatorSchema]));
+
+const ExecuteMutationInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ operation: z.enum(['insert', 'update', 'delete', 'upsert']).describe('Mutation operation: insert (add rows), update (modify rows), delete (remove rows), upsert (insert or update)'),
+ table: z.string().describe('Table name for the operation'),
+ data: z.record(z.unknown()).optional().describe('Data object with column-value pairs (required for insert/update/upsert)'),
+ where: z.union([WherePredicateSchema, z.string()]).optional().describe('Structured WHERE predicate for update/delete operations. Legacy string WHERE clauses are rejected; use rawWhere only for trusted local/admin SQL.'),
+ rawWhere: z.string().optional().describe('Unsafe raw WHERE SQL clause for trusted local/admin use only'),
+ conflictColumns: z.array(z.string()).optional().describe('Columns for conflict resolution in upsert (ON CONFLICT)'),
+ returning: z.union([z.literal('*'), z.string(), z.array(z.string())]).optional().describe('Columns to return. Use "*" or an array/string list of column names; SQL expressions are rejected.'),
+ maxReturningRows: z.number().int().min(1).max(MAX_OUTPUT_ROW_LIMIT).optional().default(DEFAULT_OUTPUT_ROW_LIMIT).describe(`Maximum number of RETURNING rows to include in the MCP response (default ${DEFAULT_OUTPUT_ROW_LIMIT}, max ${MAX_OUTPUT_ROW_LIMIT})`),
+ schema: z.string().optional().default('public').describe('Schema name (defaults to public)')
+}).strict();
+
+type ExecuteMutationInput = z.infer;
+
+interface MutationResult {
+ operation: string;
+ rowsAffected: number;
+ returning?: unknown[];
+ totalReturningRows?: number;
+ returningTruncated?: boolean;
+}
+
+function buildMutationResult(
+ operation: string,
+ result: { rowCount: number | null; rows: unknown[] },
+ returning: ExecuteMutationInput['returning'],
+ maxReturningRows: number
+): MutationResult {
+ const baseResult: MutationResult = {
+ operation,
+ rowsAffected: result.rowCount ?? result.rows.length
+ };
+
+ if (!returning) {
+ return baseResult;
+ }
+
+ const limited = limitOutputRows(result.rows, maxReturningRows);
+ return {
+ ...baseResult,
+ returning: limited.rows,
+ totalReturningRows: limited.totalRows,
+ returningTruncated: limited.truncated
+ };
+}
+
+async function executeMutation(
+ input: ExecuteMutationInput,
+ getConnectionString: GetConnectionStringFn
+): Promise {
+ const db = DatabaseConnection.getInstance();
+ const { operation, table, data, where, rawWhere, conflictColumns, returning, maxReturningRows, schema } = input;
+
+ try {
+ const tableName = quoteQualifiedIdent(table, schema);
+ const returningClause = buildReturningClause(returning);
+ const buildMutationWhere = (startingPlaceholder: number): { clause: string; values: unknown[] } => {
+ if (rawWhere) {
+ return { clause: rawWhere, values: [] };
+ }
+
+ if (!where) {
+ throw new McpError(ErrorCode.InvalidParams, `WHERE predicate is required for ${operation} operation to prevent accidental full table changes`);
+ }
+
+ if (typeof where === 'string') {
+ throw new McpError(ErrorCode.InvalidParams, 'String where predicates are not allowed. Use structured where predicates or rawWhere for trusted local/admin SQL.');
+ }
+
+ return buildWhereClause(where as WherePredicate, startingPlaceholder);
+ };
+
+ let sql: string;
+ let queryValues: unknown[];
+
+ switch (operation) {
+ case 'insert': {
if (!data || Object.keys(data).length === 0) {
throw new McpError(ErrorCode.InvalidParams, 'Data object is required for insert operation');
}
- const columns = Object.keys(data);
- const values = Object.values(data);
- const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
-
- let insertSQL = `INSERT INTO ${tableName} (${columns.map(col => `"${col}"`).join(', ')}) VALUES (${placeholders})`;
-
- if (returning) {
- insertSQL += ` RETURNING ${returning}`;
- }
-
- const result = await db.query(insertSQL, values);
- return {
- operation: 'insert',
- rowsAffected: Array.isArray(result) ? result.length : 1,
- returning: returning ? result : undefined
- };
- }
-
- case 'update': {
+ const columns = Object.keys(data);
+ const values = Object.values(data);
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
+
+ sql = `INSERT INTO ${tableName} (${columns.map(quoteIdent).join(', ')}) VALUES (${placeholders})${returningClause}`;
+ queryValues = values;
+ break;
+ }
+
+ case 'update': {
if (!data || Object.keys(data).length === 0) {
throw new McpError(ErrorCode.InvalidParams, 'Data object is required for update operation');
}
- if (!where) {
- throw new McpError(ErrorCode.InvalidParams, 'WHERE clause is required for update operation to prevent accidental full table updates');
- }
-
- const columns = Object.keys(data);
- const values = Object.values(data);
- const setClause = columns.map((col, i) => `"${col}" = $${i + 1}`).join(', ');
-
- let updateSQL = `UPDATE ${tableName} SET ${setClause} WHERE ${where}`;
-
- if (returning) {
- updateSQL += ` RETURNING ${returning}`;
- }
-
- const result = await db.query(updateSQL, values);
- return {
- operation: 'update',
- rowsAffected: Array.isArray(result) ? result.length : 1,
- returning: returning ? result : undefined
- };
- }
-
- case 'delete': {
- if (!where) {
- throw new McpError(ErrorCode.InvalidParams, 'WHERE clause is required for delete operation to prevent accidental full table deletion');
- }
-
- let deleteSQL = `DELETE FROM ${tableName} WHERE ${where}`;
-
- if (returning) {
- deleteSQL += ` RETURNING ${returning}`;
- }
-
- const result = await db.query(deleteSQL);
- return {
- operation: 'delete',
- rowsAffected: Array.isArray(result) ? result.length : 1,
- returning: returning ? result : undefined
- };
- }
-
- case 'upsert': {
+ const columns = Object.keys(data);
+ const values = Object.values(data);
+ const setClause = columns.map((col, i) => `${quoteIdent(col)} = $${i + 1}`).join(', ');
+ const whereClause = buildMutationWhere(values.length + 1);
+ sql = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause.clause}${returningClause}`;
+ queryValues = [...values, ...whereClause.values];
+ break;
+ }
+
+ case 'delete': {
+ const whereClause = buildMutationWhere(1);
+ sql = `DELETE FROM ${tableName} WHERE ${whereClause.clause}${returningClause}`;
+ queryValues = whereClause.values;
+ break;
+ }
+
+ case 'upsert': {
if (!data || Object.keys(data).length === 0) {
throw new McpError(ErrorCode.InvalidParams, 'Data object is required for upsert operation');
}
@@ -256,147 +321,161 @@ async function executeMutation(
throw new McpError(ErrorCode.InvalidParams, 'Conflict columns are required for upsert operation');
}
- const columns = Object.keys(data);
- const values = Object.values(data);
- const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
- const conflictCols = conflictColumns.map(col => `"${col}"`).join(', ');
- const updateClause = columns
- .filter(col => !conflictColumns.includes(col))
- .map(col => `"${col}" = EXCLUDED."${col}"`)
- .join(', ');
-
- let upsertSQL = `INSERT INTO ${tableName} (${columns.map(col => `"${col}"`).join(', ')}) VALUES (${placeholders}) ON CONFLICT (${conflictCols})`;
-
- if (updateClause) {
- upsertSQL += ` DO UPDATE SET ${updateClause}`;
- } else {
- upsertSQL += ' DO NOTHING';
- }
-
- if (returning) {
- upsertSQL += ` RETURNING ${returning}`;
- }
-
- const result = await db.query(upsertSQL, values);
- return {
- operation: 'upsert',
- rowsAffected: Array.isArray(result) ? result.length : 1,
- returning: returning ? result : undefined
- };
- }
-
- default:
- throw new McpError(ErrorCode.InvalidParams, `Unknown operation: ${operation}`);
- }
- } catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to execute ${operation}: ${error instanceof Error ? error.message : String(error)}`);
+ const columns = Object.keys(data);
+ const values = Object.values(data);
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
+ const conflictCols = conflictColumns.map(quoteIdent).join(', ');
+ const updateClause = columns
+ .filter(col => !conflictColumns.includes(col))
+ .map(col => `${quoteIdent(col)} = EXCLUDED.${quoteIdent(col)}`)
+ .join(', ');
+
+ sql = `INSERT INTO ${tableName} (${columns.map(quoteIdent).join(', ')}) VALUES (${placeholders}) ON CONFLICT (${conflictCols})`;
+
+ if (updateClause) {
+ sql += ` DO UPDATE SET ${updateClause}`;
+ } else {
+ sql += ' DO NOTHING';
+ }
+
+ sql += returningClause;
+ queryValues = values;
+ break;
+ }
+
+ default:
+ throw new McpError(ErrorCode.InvalidParams, `Unknown operation: ${operation}`);
+ }
+
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+ await db.connect(resolvedConnectionString);
+ const result = await db.queryResult(sql, queryValues);
+ return buildMutationResult(operation, result, returning, maxReturningRows);
+ } catch (error) {
+ if (error instanceof McpError) {
+ throw error;
+ }
+ throw new McpError(ErrorCode.InternalError, `Failed to execute ${operation}: ${sanitizeErrorMessage(error)}`);
} finally {
await db.disconnect();
}
}
export const executeMutationTool: PostgresTool = {
- name: 'pg_execute_mutation',
- description: 'Execute data modification operations (INSERT/UPDATE/DELETE/UPSERT) - operation="insert/update/delete/upsert" with table and data. Examples: operation="insert", table="users", data={"name":"John","email":"john@example.com"}',
- inputSchema: ExecuteMutationInputSchema,
- execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const {
- connectionString: connStringArg,
- operation,
- table,
- data,
- where,
- conflictColumns,
- returning,
- schema
- } = args as {
- connectionString?: string;
- operation: 'insert' | 'update' | 'delete' | 'upsert';
- table: string;
- data?: Record;
- where?: string;
- conflictColumns?: string[];
- returning?: string;
- schema?: string;
- };
-
- const resolvedConnString = getConnectionStringVal(connStringArg);
-
- try {
- // Input validation
+ name: 'pg_execute_mutation',
+ description: 'Execute data modification operations (INSERT/UPDATE/DELETE/UPSERT) - operation="insert/update/delete/upsert" with table and data. Examples: operation="insert", table="users", data={"name":"John","email":"john@example.com"}',
+ inputSchema: ExecuteMutationInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = ExecuteMutationInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ const errorDetails = validationResult.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
+ return {
+ content: [{ type: 'text', text: `Invalid input: ${errorDetails}` }],
+ isError: true,
+ };
+ }
+
+ const { connectionString: connStringArg, operation, table } = validationResult.data;
+
+ try {
+ // Input validation
if (!table?.trim()) {
return {
content: [{ type: 'text', text: 'Error: table is required' }],
isError: true
- };
- }
-
- const result = await executeMutation({
- connectionString: resolvedConnString,
- operation,
- table,
- data,
- where,
- conflictColumns,
- returning,
- schema: schema || 'public'
- } as ExecuteMutationInput, getConnectionStringVal);
-
- let responseText = `${operation.toUpperCase()} operation completed successfully. Rows affected: ${result.rowsAffected}`;
-
- if (result.returning && result.returning.length > 0) {
- responseText += `\n\nReturning data:\n${JSON.stringify(result.returning, null, 2)}`;
- }
+ };
+ }
+
+ const result = await executeMutation({
+ ...validationResult.data,
+ connectionString: connStringArg
+ }, getConnectionStringVal);
+
+ let responseText = `${operation.toUpperCase()} operation completed successfully. Rows affected: ${result.rowsAffected}`;
+
+ if (result.returning && result.returning.length > 0) {
+ if (result.returningTruncated) {
+ responseText += `\n\nReturning data truncated to ${result.returning.length} of ${result.totalReturningRows} rows.`;
+ }
+ responseText += `\n\nReturning data:\n${JSON.stringify(result.returning, null, 2)}`;
+ }
return { content: [{ type: 'text', text: responseText }] };
- } catch (error) {
- return {
- content: [{ type: 'text', text: `Error executing ${operation} operation: ${error instanceof Error ? error.message : String(error)}` }],
- isError: true
- };
+ } catch (error) {
+ const errorMessage = sanitizeErrorMessage(error);
+ return {
+ content: [{ type: 'text', text: `Error executing ${operation} operation: ${errorMessage}` }],
+ isError: true
+ };
}
}
};
// ===== EXECUTE SQL TOOL (Arbitrary SQL execution) =====
-const ExecuteSqlInputSchema = z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- sql: z.string().describe('SQL statement to execute (can be any valid PostgreSQL SQL)'),
- parameters: z.array(z.unknown()).optional().default([]).describe('Parameter values for prepared statement placeholders ($1, $2, etc.)'),
- expectRows: z.boolean().optional().default(true).describe('Whether to expect rows back (false for statements like CREATE, DROP, etc.)'),
- timeout: z.number().optional().describe('Query timeout in milliseconds'),
- transactional: z.boolean().optional().default(false).describe('Whether to wrap in a transaction')
-});
-
-type ExecuteSqlInput = z.infer;
-
-async function executeSql(
- input: ExecuteSqlInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ sql: string; rowsAffected?: number; rows?: unknown[]; message: string }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const { sql, parameters, expectRows, timeout, transactional } = input;
-
- try {
- await db.connect(resolvedConnectionString);
-
- const queryOptions = timeout ? { timeout } : {};
-
- if (transactional) {
- return await db.transaction(async (client) => {
- const result = await client.query(sql, parameters || []);
-
- if (expectRows) {
- return {
- sql,
- rowsAffected: Array.isArray(result.rows) ? result.rows.length : 0,
- rows: result.rows,
- message: `SQL executed successfully in transaction. Retrieved ${Array.isArray(result.rows) ? result.rows.length : 0} rows.`
- };
- }
+const ExecuteSqlInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ sql: z.string().describe('SQL statement to execute (can be any valid PostgreSQL SQL)'),
+ parameters: z.array(z.unknown()).optional().default([]).describe('Parameter values for prepared statement placeholders ($1, $2, etc.)'),
+ expectRows: z.boolean().optional().default(true).describe('Whether to expect rows back (false for statements like CREATE, DROP, etc.)'),
+ timeout: z.number().int().positive().optional().describe('Query timeout in milliseconds'),
+ maxRows: z.number().int().min(1).max(MAX_OUTPUT_ROW_LIMIT).optional().default(DEFAULT_OUTPUT_ROW_LIMIT).describe(`Maximum number of result rows to include in the MCP response (default ${DEFAULT_OUTPUT_ROW_LIMIT}, max ${MAX_OUTPUT_ROW_LIMIT})`),
+ transactional: z.boolean().optional().default(false).describe('Whether to wrap in a transaction')
+}).strict();
+type ExecuteSqlInput = z.infer;
+
+function validateArbitrarySqlExecutionShape(input: ExecuteSqlInput): void {
+ const hasMultipleStatements = hasSqlStatementSeparator(input.sql);
+
+ if (!hasMultipleStatements) {
+ return;
+ }
+
+ if (!input.transactional) {
+ throw new McpError(ErrorCode.InvalidParams, 'Multi-statement arbitrary SQL must use transactional=true to avoid partial execution.');
+ }
+
+ if (input.expectRows) {
+ throw new McpError(ErrorCode.InvalidParams, 'Multi-statement arbitrary SQL must use expectRows=false because result sets are ambiguous.');
+ }
+
+ if ((input.parameters ?? []).length > 0) {
+ throw new McpError(ErrorCode.InvalidParams, 'Multi-statement arbitrary SQL cannot use parameters. Use a single parameterized statement or inline trusted admin SQL.');
+ }
+}
+
+async function executeSql(
+ input: ExecuteSqlInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ sql: string; rowsAffected?: number; rows?: unknown[]; message: string; totalRows?: number; truncated?: boolean }> {
+ const db = DatabaseConnection.getInstance();
+ const { sql, parameters, expectRows, timeout, maxRows, transactional } = input;
+
+ try {
+ validateArbitrarySqlExecutionShape(input);
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+ await db.connect(resolvedConnectionString);
+
+ const queryOptions = timeout ? { timeout } : {};
+
+ if (transactional) {
+ return await db.transaction(async (client) => {
+ const result = await client.query(createQueryConfig(sql, parameters || [], timeout));
+
+ if (expectRows) {
+ const limited = limitOutputRows(result.rows, maxRows);
+ return {
+ sql,
+ rowsAffected: Array.isArray(result.rows) ? result.rows.length : 0,
+ rows: limited.rows,
+ totalRows: limited.totalRows,
+ truncated: limited.truncated,
+ message: limited.truncated
+ ? `SQL executed successfully in transaction. Retrieved ${limited.totalRows} rows; returning first ${limited.rows.length}.`
+ : `SQL executed successfully in transaction. Retrieved ${limited.totalRows} rows.`
+ };
+ }
return {
sql,
rowsAffected: result.rowCount || 0,
@@ -404,68 +483,67 @@ async function executeSql(
};
});
}
- const result = await db.query(sql, parameters || [], queryOptions);
-
- if (expectRows) {
- return {
- sql,
- rowsAffected: Array.isArray(result) ? result.length : 0,
- rows: result,
- message: `SQL executed successfully. Retrieved ${Array.isArray(result) ? result.length : 0} rows.`
- };
- }
- return {
- sql,
- rowsAffected: Array.isArray(result) ? result.length : 1,
- message: 'SQL executed successfully. Operation completed.'
- };
- } catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to execute SQL: ${error instanceof Error ? error.message : String(error)}`);
+ const result = await db.queryResult(sql, parameters || [], queryOptions);
+
+ if (expectRows) {
+ const limited = limitOutputRows(result.rows, maxRows);
+ return {
+ sql,
+ rowsAffected: result.rows.length,
+ rows: limited.rows,
+ totalRows: limited.totalRows,
+ truncated: limited.truncated,
+ message: limited.truncated
+ ? `SQL executed successfully. Retrieved ${limited.totalRows} rows; returning first ${limited.rows.length}.`
+ : `SQL executed successfully. Retrieved ${limited.totalRows} rows.`
+ };
+ }
+ return {
+ sql,
+ rowsAffected: result.rowCount ?? 0,
+ message: `SQL executed successfully. Rows affected: ${result.rowCount ?? 0}`
+ };
+ } catch (error) {
+ if (error instanceof McpError) {
+ throw error;
+ }
+ throw new McpError(ErrorCode.InternalError, `Failed to execute SQL: ${sanitizeErrorMessage(error)}`);
} finally {
await db.disconnect();
}
}
export const executeSqlTool: PostgresTool = {
- name: 'pg_execute_sql',
- description: 'Execute arbitrary SQL statements - sql="ANY_VALID_SQL" with optional parameters and transaction support. Examples: sql="CREATE INDEX ...", sql="WITH complex_cte AS (...) SELECT ...", transactional=true',
- inputSchema: ExecuteSqlInputSchema,
- execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const {
- connectionString: connStringArg,
- sql,
- parameters,
- expectRows,
- timeout,
- transactional
- } = args as {
- connectionString?: string;
- sql: string;
- parameters?: unknown[];
- expectRows?: boolean;
- timeout?: number;
- transactional?: boolean;
- };
-
- const resolvedConnString = getConnectionStringVal(connStringArg);
-
- try {
- // Input validation
- if (!sql?.trim()) {
- return {
- content: [{ type: 'text', text: 'Error: sql is required' }],
- isError: true
- };
- }
-
- const result = await executeSql({
- connectionString: resolvedConnString,
- sql,
- parameters: parameters ?? [],
- expectRows: expectRows ?? true,
- timeout,
- transactional: transactional ?? false
- }, getConnectionStringVal);
+ name: 'pg_execute_sql',
+ description: 'Execute arbitrary SQL statements - sql="ANY_VALID_SQL" with optional parameters and transaction support. Examples: sql="CREATE INDEX ...", sql="WITH complex_cte AS (...) SELECT ...", transactional=true',
+ inputSchema: ExecuteSqlInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = ExecuteSqlInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ const errorDetails = validationResult.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
+ return {
+ content: [{ type: 'text', text: `Invalid input: ${errorDetails}` }],
+ isError: true,
+ };
+ }
+
+ const { connectionString: connStringArg, sql } = validationResult.data;
+
+ try {
+ // Input validation
+ if (!sql?.trim()) {
+ return {
+ content: [{ type: 'text', text: 'Error: sql is required' }],
+ isError: true
+ };
+ }
+
+ validateArbitrarySqlExecutionShape(validationResult.data);
+
+ const result = await executeSql({
+ ...validationResult.data,
+ connectionString: connStringArg
+ }, getConnectionStringVal);
let responseText = result.message;
@@ -475,11 +553,12 @@ export const executeSqlTool: PostgresTool = {
return { content: [{ type: 'text', text: responseText }] };
- } catch (error) {
- return {
- content: [{ type: 'text', text: `Error executing SQL: ${error instanceof Error ? error.message : String(error)}` }],
- isError: true
- };
+ } catch (error) {
+ const errorMessage = sanitizeErrorMessage(error);
+ return {
+ content: [{ type: 'text', text: `Error executing SQL: ${errorMessage}` }],
+ isError: true
+ };
}
}
-};
\ No newline at end of file
+};
diff --git a/src/tools/debug.test.ts b/src/tools/debug.test.ts
new file mode 100644
index 0000000..26d7886
--- /dev/null
+++ b/src/tools/debug.test.ts
@@ -0,0 +1,128 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import { debugDatabaseTool } from './debug';
+
+describe('debugDatabaseTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('rejects unknown fields before resolving a connection', async () => {
+ const result = await debugDatabaseTool.execute({
+ issue: 'connection',
+ unexpected: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ });
+
+ it('redacts active query literals in performance diagnostics', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn()
+ .mockResolvedValueOnce([
+ {
+ query: "SELECT * FROM users WHERE email = 'admin@example.com' AND token = 'secret'",
+ duration: 45
+ }
+ ])
+ .mockResolvedValueOnce([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await debugDatabaseTool.execute({
+ issue: 'performance'
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(output).toContain("email = '?'");
+ expect(output).toContain("token = '?'");
+ expect(output).not.toContain('admin@example.com');
+ expect(output).not.toContain('secret');
+ });
+
+ it('redacts blocked query literals in lock diagnostics', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([
+ {
+ blocked_pid: 11,
+ blocked_user: 'app',
+ blocking_pid: 12,
+ blocking_user: 'worker',
+ blocked_statement: "UPDATE users SET password = 'new-secret' WHERE id = 42"
+ }
+ ]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await debugDatabaseTool.execute({
+ issue: 'locks'
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(output).toContain("password = '?'");
+ expect(output).toContain('id = ?');
+ expect(output).not.toContain('new-secret');
+ expect(output).not.toContain('id = 42');
+ });
+
+ it('sanitizes caught diagnostic errors before returning details', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockRejectedValue(
+ new Error("password=diagnostic-secret failed while running SELECT * FROM users WHERE token = 'raw-token'")
+ ),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await debugDatabaseTool.execute({
+ issue: 'connection'
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(output).toContain('password=*****');
+ expect(output).toContain("token = '?'");
+ expect(output).not.toContain('diagnostic-secret');
+ expect(output).not.toContain('raw-token');
+ });
+
+ it('caps performance diagnostic rows before formatting output', async () => {
+ const slowQueries = Array.from({ length: 26 }, (_, index) => ({
+ query: `SELECT * FROM jobs WHERE token = 'secret-${index}'`,
+ duration: index + 30
+ }));
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn()
+ .mockResolvedValueOnce(slowQueries)
+ .mockResolvedValueOnce([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await debugDatabaseTool.execute({
+ issue: 'performance'
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(output).toContain('Additional long-running queries omitted after 25 rows.');
+ expect(output).toContain('Duration: 54s');
+ expect(output).not.toContain('Duration: 55s');
+ expect(output).not.toContain('secret-25');
+ expect(mockDb.query).toHaveBeenNthCalledWith(1, expect.stringContaining('LIMIT 26'));
+ });
+});
diff --git a/src/tools/debug.ts b/src/tools/debug.ts
index c0c2516..f36c906 100644
--- a/src/tools/debug.ts
+++ b/src/tools/debug.ts
@@ -1,7 +1,8 @@
-import { DatabaseConnection } from '../utils/connection.js';
+import { DatabaseConnection, sanitizeErrorMessage } from '../utils/connection.js';
import { z } from 'zod';
import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import { redactSqlText } from '../utils/sql.js';
interface DebugResult {
issue: string;
@@ -38,11 +39,20 @@ interface ReplicationStatus {
replay_lag: string | null;
}
+const SLOW_QUERY_DIAGNOSTIC_LIMIT = 25;
+const UNUSED_INDEX_DIAGNOSTIC_LIMIT = 50;
+const LOCK_DIAGNOSTIC_LIMIT = 50;
+const REPLICATION_DIAGNOSTIC_LIMIT = 50;
+
+function formatValidationError(error: z.ZodError): string {
+ return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', ');
+}
+
const DebugDatabaseInputSchema = z.object({
connectionString: z.string().optional(),
issue: z.enum(['connection', 'performance', 'locks', 'replication']),
logLevel: z.enum(['info', 'debug', 'trace']).optional().default('info'),
-});
+}).strict();
type DebugDatabaseInput = z.infer;
@@ -82,9 +92,8 @@ export const debugDatabaseTool: PostgresTool = {
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
const validationResult = DebugDatabaseInputSchema.safeParse(params);
if (!validationResult.success) {
- const errorDetails = validationResult.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
return {
- content: [{ type: 'text', text: `Invalid input: ${errorDetails}` }],
+ content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }],
isError: true,
};
}
@@ -100,7 +109,7 @@ export const debugDatabaseTool: PostgresTool = {
],
};
} catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
+ const errorMessage = sanitizeErrorMessage(error);
return {
content: [{ type: 'text', text: `Error debugging database: ${errorMessage}` }],
isError: true,
@@ -158,7 +167,7 @@ async function debugConnection(db: DatabaseConnection): Promise {
} catch (error: unknown) {
result.status = 'error';
- result.details.push(`Connection error: ${error instanceof Error ? error.message : String(error)}`);
+ result.details.push(`Connection error: ${sanitizeErrorMessage(error)}`);
}
return result;
@@ -179,14 +188,20 @@ async function debugPerformance(db: DatabaseConnection): Promise {
FROM pg_stat_activity
WHERE state = 'active'
AND query NOT LIKE '%pg_stat_activity%'
- AND query_start < now() - interval '30 second'`
+ AND query_start < now() - interval '30 second'
+ ORDER BY query_start
+ LIMIT ${SLOW_QUERY_DIAGNOSTIC_LIMIT + 1}`
);
+ const visibleSlowQueries = slowQueries.slice(0, SLOW_QUERY_DIAGNOSTIC_LIMIT);
- if (slowQueries.length > 0) {
+ if (visibleSlowQueries.length > 0) {
result.status = 'warning';
result.details.push('Long-running queries detected:');
- for (const q of slowQueries) {
- result.details.push(`Duration: ${q.duration}s - Query: ${q.query}`);
+ for (const q of visibleSlowQueries) {
+ result.details.push(`Duration: ${q.duration}s - Query: ${redactSqlText(q.query)}`);
+ }
+ if (slowQueries.length > SLOW_QUERY_DIAGNOSTIC_LIMIT) {
+ result.details.push(`Additional long-running queries omitted after ${SLOW_QUERY_DIAGNOSTIC_LIMIT} rows.`);
}
result.recommendations.push(
'Review and optimize slow queries',
@@ -203,16 +218,22 @@ async function debugPerformance(db: DatabaseConnection): Promise {
s.idx_scan
FROM pg_stat_user_indexes s
WHERE s.idx_scan = 0
- AND s.schemaname NOT IN ('pg_catalog', 'information_schema')`
+ AND s.schemaname NOT IN ('pg_catalog', 'information_schema')
+ ORDER BY s.schemaname, s.relname, s.indexrelname
+ LIMIT ${UNUSED_INDEX_DIAGNOSTIC_LIMIT + 1}`
);
+ const visibleUnusedIndexes = unusedIndexes.slice(0, UNUSED_INDEX_DIAGNOSTIC_LIMIT);
- if (unusedIndexes.length > 0) {
+ if (visibleUnusedIndexes.length > 0) {
result.details.push('Unused indexes found:');
- for (const idx of unusedIndexes) {
+ for (const idx of visibleUnusedIndexes) {
result.details.push(
`${idx.schemaname}.${idx.tablename} - ${idx.indexname}`
);
}
+ if (unusedIndexes.length > UNUSED_INDEX_DIAGNOSTIC_LIMIT) {
+ result.details.push(`Additional unused indexes omitted after ${UNUSED_INDEX_DIAGNOSTIC_LIMIT} rows.`);
+ }
result.recommendations.push(
'Consider removing unused indexes',
'Review index strategy'
@@ -221,7 +242,7 @@ async function debugPerformance(db: DatabaseConnection): Promise {
} catch (error: unknown) {
result.status = 'error';
- result.details.push(`Performance analysis error: ${error instanceof Error ? error.message : String(error)}`);
+ result.details.push(`Performance analysis error: ${sanitizeErrorMessage(error)}`);
}
return result;
@@ -257,17 +278,23 @@ async function debugLocks(db: DatabaseConnection): Promise {
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
- WHERE NOT blocked_locks.GRANTED`
+ WHERE NOT blocked_locks.GRANTED
+ ORDER BY blocked_locks.pid, blocking_locks.pid
+ LIMIT ${LOCK_DIAGNOSTIC_LIMIT + 1}`
);
+ const visibleLocks = locks.slice(0, LOCK_DIAGNOSTIC_LIMIT);
- if (locks.length > 0) {
+ if (visibleLocks.length > 0) {
result.status = 'warning';
result.details.push('Lock conflicts detected:');
- for (const lock of locks) {
+ for (const lock of visibleLocks) {
result.details.push(
`Process ${lock.blocked_pid} (${lock.blocked_user}) blocked by process ${lock.blocking_pid} (${lock.blocking_user})`
);
- result.details.push(`Blocked query: ${lock.blocked_statement}`);
+ result.details.push(`Blocked query: ${redactSqlText(lock.blocked_statement)}`);
+ }
+ if (locks.length > LOCK_DIAGNOSTIC_LIMIT) {
+ result.details.push(`Additional lock conflicts omitted after ${LOCK_DIAGNOSTIC_LIMIT} rows.`);
}
result.recommendations.push(
'Consider killing blocking queries if appropriate',
@@ -278,7 +305,7 @@ async function debugLocks(db: DatabaseConnection): Promise {
} catch (error: unknown) {
result.status = 'error';
- result.details.push(`Lock analysis error: ${error instanceof Error ? error.message : String(error)}`);
+ result.details.push(`Lock analysis error: ${sanitizeErrorMessage(error)}`);
}
return result;
@@ -304,10 +331,13 @@ async function debugReplication(db: DatabaseConnection): Promise {
write_lag,
flush_lag,
replay_lag
- FROM pg_stat_replication`
+ FROM pg_stat_replication
+ ORDER BY client_addr NULLS LAST, state
+ LIMIT ${REPLICATION_DIAGNOSTIC_LIMIT + 1}`
);
+ const visibleReplicationStatus = replicationStatus.slice(0, REPLICATION_DIAGNOSTIC_LIMIT);
- if (replicationStatus.length === 0) {
+ if (visibleReplicationStatus.length === 0) {
result.details.push('No active replication detected');
result.recommendations.push(
'If replication is expected, check configuration',
@@ -319,7 +349,7 @@ async function debugReplication(db: DatabaseConnection): Promise {
result.status = 'ok'; // Default to ok, specific checks might change it
result.details.push('Replication status:');
- for (const status of replicationStatus) {
+ for (const status of visibleReplicationStatus) {
result.details.push(
`Replica: ${status.client_addr}, State: ${status.state}, Sent LSN: ${status.sent_lsn}, Replay LSN: ${status.replay_lsn}`
);
@@ -338,10 +368,13 @@ async function debugReplication(db: DatabaseConnection): Promise {
);
}
}
+ if (replicationStatus.length > REPLICATION_DIAGNOSTIC_LIMIT) {
+ result.details.push(`Additional replication rows omitted after ${REPLICATION_DIAGNOSTIC_LIMIT} rows.`);
+ }
} catch (error: unknown) {
result.status = 'error';
- result.details.push(`Replication analysis error: ${error instanceof Error ? error.message : String(error)}`);
+ result.details.push(`Replication analysis error: ${sanitizeErrorMessage(error)}`);
}
return result;
diff --git a/src/tools/enums.test.ts b/src/tools/enums.test.ts
new file mode 100644
index 0000000..4a390e8
--- /dev/null
+++ b/src/tools/enums.test.ts
@@ -0,0 +1,181 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import { createEnumTool, getEnumsTool } from './enums';
+
+describe('legacy direct enum tools', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('gets enums with parameterized schema and enum filters', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await getEnumsTool.execute({
+ schema: 'app',
+ enumName: 'status'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('AND t.typname = $2'), ['app', 'status']);
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('array_agg(e.enumlabel::text ORDER BY e.enumsortorder)'), ['app', 'status']);
+ });
+
+ it('creates enums with quoted schema and enum identifiers', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue(undefined),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await createEnumTool.execute({
+ schema: 'app',
+ enumName: 'status',
+ values: ['active', "owner's"],
+ ifNotExists: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ const sql = mockDb.query.mock.calls[0][0];
+ expect(sql).toContain('DO $postgres_mcp_enum$');
+ expect(sql).toContain('CREATE TYPE "app"."status" AS ENUM (\'active\', \'owner\'\'s\');');
+ expect(sql).toContain('WHEN duplicate_object THEN NULL;');
+ expect(sql).not.toContain('CREATE TYPE IF NOT EXISTS');
+ expect(result.content.map((item) => item.text).join('\n')).toContain('"valueCount": 2');
+ expect(result.content.map((item) => item.text).join('\n')).not.toContain("owner's");
+ });
+
+ it('creates enums with plain PostgreSQL DDL by default', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue(undefined),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await createEnumTool.execute({
+ schema: 'app',
+ enumName: 'status',
+ values: ['active']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith('CREATE TYPE "app"."status" AS ENUM (\'active\');');
+ });
+
+ it('sanitizes enum database errors before returning them', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockRejectedValue(
+ new Error("password=db-secret failed while creating ENUM value 'raw-enum-secret'")
+ ),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await createEnumTool.execute({
+ schema: 'app',
+ enumName: 'status',
+ values: ['raw-enum-secret']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Failed to create ENUM');
+ expect(result.content[0].text).toContain('password=*****');
+ expect(result.content[0].text).toContain("value '?'");
+ expect(result.content[0].text).not.toContain('db-secret');
+ expect(result.content[0].text).not.toContain('raw-enum-secret');
+ });
+
+ it('rejects unsafe enum identifiers before connecting', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue(undefined),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await createEnumTool.execute({
+ enumName: 'status; drop type status',
+ values: ['active']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown get-enum fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue(undefined),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await getEnumsTool.execute({
+ schema: 'app',
+ rawSql: 'DROP TYPE status'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown create-enum fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue(undefined),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await createEnumTool.execute({
+ enumName: 'status',
+ values: ['active'],
+ rawSql: 'DROP TYPE status'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe enum schemas before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue(undefined),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await createEnumTool.execute({
+ schema: 'app; drop schema app',
+ enumName: 'status',
+ values: ['active']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/tools/enums.ts b/src/tools/enums.ts
index e97c23a..da8d124 100644
--- a/src/tools/enums.ts
+++ b/src/tools/enums.ts
@@ -1,11 +1,12 @@
import { z } from 'zod';
// Remove direct import of sql from @vercel/postgres
// import { sql } from '@vercel/postgres';
-import { DatabaseConnection } from '../utils/connection.js'; // Use the custom connection wrapper
+import { DatabaseConnection, sanitizeErrorMessage } from '../utils/connection.js'; // Use the custom connection wrapper
// Remove MCP specific type imports - rely on structural typing
// import type { MCPToolDefinition, MCPToolExecuteInput } from '../types.js';
import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import { buildCreateEnumTypeSql } from '../utils/sql.js';
// Define return type structure similar to schema.ts
@@ -19,7 +20,7 @@ const GetEnumsInputSchema = z.object({
connectionString: z.string().optional(),
schema: z.string().optional().default('public').describe('Schema name (defaults to public)'),
enumName: z.string().optional().describe('Optional specific ENUM name to filter by'),
-});
+}).strict();
type GetEnumsInput = z.infer;
@@ -28,8 +29,8 @@ const CreateEnumInputSchema = z.object({
enumName: z.string().describe('Name of the ENUM type to create'),
values: z.array(z.string()).min(1).describe('List of values for the ENUM type'),
schema: z.string().optional().default('public').describe('Schema name (defaults to public)'),
- ifNotExists: z.boolean().optional().default(false).describe('Include IF NOT EXISTS clause'),
-});
+ ifNotExists: z.boolean().optional().default(false).describe('Ignore duplicate type errors by wrapping CREATE TYPE in a DO block'),
+}).strict();
type CreateEnumInput = z.infer;
@@ -47,7 +48,7 @@ async function executeGetEnums(
SELECT
n.nspname as enum_schema,
t.typname as enum_name,
- array_agg(e.enumlabel ORDER BY e.enumsortorder) as enum_values
+ array_agg(e.enumlabel::text ORDER BY e.enumsortorder) as enum_values
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
@@ -67,8 +68,10 @@ async function executeGetEnums(
return result;
} catch (error) {
- console.error("Error fetching ENUMs:", error);
- throw new McpError(ErrorCode.InternalError, `Failed to fetch ENUMs: ${error instanceof Error ? error.message : String(error)}`);
+ if (error instanceof McpError) {
+ throw error;
+ }
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch ENUMs: ${sanitizeErrorMessage(error)}`);
} finally {
await db.disconnect();
}
@@ -78,27 +81,21 @@ async function executeGetEnums(
async function executeCreateEnum(
input: CreateEnumInput,
getConnectionString: GetConnectionStringFn
-): Promise<{ schema?: string; enumName: string; values: string[]}> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
+): Promise<{ schema?: string; enumName: string; valueCount: number}> {
const { enumName, values, schema, ifNotExists } = input;
const db = DatabaseConnection.getInstance();
try {
+ const query = buildCreateEnumTypeSql(enumName, values, schema || 'public', ifNotExists);
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
await db.connect(resolvedConnectionString);
- // Manually quote identifiers using double quotes
- const qualifiedSchema = `"${schema || 'public'}"`;
- const qualifiedEnumName = `"${enumName}"`;
- const fullEnumName = `${qualifiedSchema}.${qualifiedEnumName}`;
- // Use parameterized query for values and add explicit types to map
- const valuesPlaceholders = values.map((_: string, i: number) => `$${i + 1}`).join(', ');
- const ifNotExistsClause = ifNotExists ? 'IF NOT EXISTS' : '';
-
- const query = `CREATE TYPE ${ifNotExistsClause} ${fullEnumName} AS ENUM (${valuesPlaceholders});`;
-
- await db.query(query, values);
- return { schema, enumName, values };
+ await db.query(query);
+ return { schema, enumName, valueCount: values.length };
} catch (error) {
- console.error("Error creating ENUM:", error);
- throw new McpError(ErrorCode.InternalError, `Failed to create ENUM ${enumName}: ${error instanceof Error ? error.message : String(error)}`);
+ if (error instanceof McpError) {
+ throw error;
+ }
+ throw new McpError(ErrorCode.InternalError, `Failed to create ENUM ${enumName}: ${sanitizeErrorMessage(error)}`);
} finally {
await db.disconnect();
}
@@ -126,7 +123,7 @@ export const getEnumsTool: PostgresTool = {
],
};
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return {
content: [{ type: 'text', text: `Error getting ENUMs: ${errorMessage}` }],
isError: true,
@@ -157,7 +154,7 @@ export const createEnumTool: PostgresTool = {
],
};
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return {
content: [{ type: 'text', text: `Error creating ENUM: ${errorMessage}` }],
isError: true,
@@ -166,4 +163,4 @@ export const createEnumTool: PostgresTool = {
}
};
-// Potential future additions: dropEnum, alterEnumAddValue, alterEnumRenameValue
\ No newline at end of file
+// Potential future additions: dropEnum, alterEnumAddValue, alterEnumRenameValue
diff --git a/src/tools/functions.test.ts b/src/tools/functions.test.ts
new file mode 100644
index 0000000..d8d3f9b
--- /dev/null
+++ b/src/tools/functions.test.ts
@@ -0,0 +1,645 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import {
+ createFunctionTool,
+ createRLSPolicyTool,
+ dropFunctionTool,
+ editRLSPolicyTool,
+ enableRLSTool,
+ getFunctionsTool,
+ manageFunctionsTool,
+ manageRLSTool
+} from './functions';
+
+describe('manageFunctionsTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('creates functions with quoted function identifiers and collision-safe dollar quotes', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageFunctionsTool.execute({
+ operation: 'create',
+ functionName: 'calculate_total',
+ schema: 'billing',
+ parameters: 'price DECIMAL, tax DECIMAL',
+ returnType: 'DECIMAL',
+ functionBody: 'BEGIN RETURN price + (price * tax); END;',
+ language: 'plpgsql',
+ volatility: 'STABLE',
+ security: 'INVOKER'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledTimes(1);
+ const sql = mockDb.query.mock.calls[0][0] as string;
+ expect(sql).toContain('CREATE FUNCTION "billing"."calculate_total"(price DECIMAL, tax DECIMAL)');
+ expect(sql).toContain('RETURNS DECIMAL');
+ expect(sql).toContain('LANGUAGE plpgsql');
+ expect(sql).toContain('STABLE');
+ expect(sql).toContain('SECURITY INVOKER');
+ expect(sql).toContain('AS $function$');
+ });
+
+ it('drops functions with quoted identifiers and validated signatures', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageFunctionsTool.execute({
+ operation: 'drop',
+ functionName: 'old_func',
+ schema: 'billing',
+ parameters: 'INT, TEXT',
+ ifExists: true,
+ cascade: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith('DROP FUNCTION IF EXISTS "billing"."old_func"(INT, TEXT) CASCADE');
+ });
+
+ it('redacts function definitions returned from catalog lookups', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([{
+ name: 'leaky_func',
+ language: 'plpgsql',
+ returnType: 'text',
+ arguments: '',
+ definition: "CREATE FUNCTION leaky_func() RETURNS text LANGUAGE sql AS $$ SELECT 'raw-function-secret' $$",
+ volatility: 'VOLATILE',
+ owner: 'app'
+ }]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageFunctionsTool.execute({
+ operation: 'get',
+ functionName: 'leaky_func'
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(output).toContain('AS $$?$$');
+ expect(output).not.toContain('raw-function-secret');
+ });
+
+ it('sanitizes create function database errors before returning them', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockRejectedValue(
+ new Error("password=db-secret failed while compiling SELECT 'raw-token'")
+ ),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageFunctionsTool.execute({
+ operation: 'create',
+ functionName: 'leaky_func',
+ parameters: '',
+ returnType: 'TEXT',
+ functionBody: "BEGIN RETURN 'raw-token'; END;",
+ language: 'plpgsql',
+ volatility: 'VOLATILE',
+ security: 'INVOKER'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Failed to create function');
+ expect(result.content[0].text).toContain('password=*****');
+ expect(result.content[0].text).toContain("SELECT '?'");
+ expect(result.content[0].text).not.toContain('db-secret');
+ expect(result.content[0].text).not.toContain('raw-token');
+ });
+
+ it('rejects unknown function-management fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageFunctionsTool.execute({
+ operation: 'create',
+ functionName: 'leaky_func',
+ parameters: '',
+ returnType: 'TEXT',
+ functionBody: "SELECT 'x'",
+ rawSql: 'DROP FUNCTION leaky_func()'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects invalid function-management option values before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageFunctionsTool.execute({
+ operation: 'create',
+ functionName: 'leaky_func',
+ parameters: '',
+ returnType: 'TEXT',
+ functionBody: "SELECT 'x'",
+ language: 'plperlu'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('language');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe function identifiers before connecting', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageFunctionsTool.execute({
+ operation: 'drop',
+ functionName: 'old_func; drop schema public',
+ parameters: 'INT'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects missing create-function fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageFunctionsTool.execute({
+ operation: 'create',
+ functionName: 'missing_body',
+ returnType: 'TEXT'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Missing required fields');
+ expect(result.content[0].text).toContain('functionBody');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe function signatures before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageFunctionsTool.execute({
+ operation: 'drop',
+ functionName: 'old_func',
+ parameters: 'INT); drop function old_func'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid function signature');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+});
+
+describe('legacy direct function and RLS tools', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('rejects unknown direct function fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await createFunctionTool.execute({
+ functionName: 'leaky_func',
+ returnType: 'TEXT',
+ functionBody: "SELECT 'x'",
+ rawSql: 'DROP FUNCTION leaky_func()'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects invalid direct function fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await createFunctionTool.execute({
+ functionName: 'leaky_func',
+ returnType: 'TEXT',
+ functionBody: "SELECT 'x'",
+ language: 'plperlu'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('language');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown direct function listing fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await getFunctionsTool.execute({
+ functionName: 'leaky_func',
+ rawSql: 'SELECT pg_get_functiondef(oid)'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects invalid direct RLS policy fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await createRLSPolicyTool.execute({
+ tableName: 'users',
+ policyName: 'tenant_policy',
+ using: 'true',
+ command: 'MERGE'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('command');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe direct function identifiers before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await createFunctionTool.execute({
+ functionName: 'leaky_func; drop schema public',
+ returnType: 'TEXT',
+ functionBody: "SELECT 'x'"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe direct function signatures before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await dropFunctionTool.execute({
+ functionName: 'leaky_func',
+ parameters: 'INT); drop function leaky_func'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid function signature');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe direct RLS table identifiers before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await enableRLSTool.execute({
+ tableName: 'users; drop table users'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe direct RLS roles before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await editRLSPolicyTool.execute({
+ tableName: 'users',
+ policyName: 'tenant_policy',
+ roles: ['authenticated; drop role authenticated']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+});
+
+describe('manageRLSTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('enables RLS with quoted table identifiers', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageRLSTool.execute({
+ operation: 'enable',
+ tableName: 'users',
+ schema: 'auth'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith('ALTER TABLE "auth"."users" ENABLE ROW LEVEL SECURITY');
+ });
+
+ it('creates RLS policies with quoted policy, table, and role identifiers', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageRLSTool.execute({
+ operation: 'create_policy',
+ tableName: 'users',
+ policyName: 'user_isolation',
+ using: 'user_id = current_user_id()',
+ check: 'user_id = current_user_id()',
+ command: 'SELECT',
+ role: 'authenticated',
+ replace: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledTimes(1);
+ const sql = mockDb.query.mock.calls[0][0] as string;
+ expect(sql).toContain('DROP POLICY IF EXISTS "user_isolation" ON "users";');
+ expect(sql).toContain('CREATE POLICY "user_isolation"');
+ expect(sql).toContain('ON "users"');
+ expect(sql).toContain('FOR SELECT');
+ expect(sql).toContain('TO "authenticated"');
+ expect(sql).toContain('USING (user_id = current_user_id())');
+ expect(sql).toContain('WITH CHECK (user_id = current_user_id())');
+ });
+
+ it('redacts RLS policy expressions returned from catalog lookups', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([{
+ schemaname: 'public',
+ tablename: 'users',
+ policyname: 'tenant_policy',
+ roles: ['public'],
+ cmd: 'SELECT',
+ using: "tenant_id = 'tenant-secret'",
+ check: "tenant_id = 'tenant-secret'"
+ }]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageRLSTool.execute({
+ operation: 'get_policies',
+ tableName: 'users'
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(output).toContain("tenant_id = '?'");
+ expect(output).not.toContain('tenant-secret');
+ });
+
+ it('sanitizes RLS policy database errors before returning them', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockRejectedValue(
+ new Error("password=db-secret failed near USING (tenant_id = 'tenant-secret')")
+ ),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageRLSTool.execute({
+ operation: 'create_policy',
+ tableName: 'users',
+ policyName: 'tenant_policy',
+ using: "tenant_id = 'tenant-secret'"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Failed to create policy');
+ expect(result.content[0].text).toContain('password=*****');
+ expect(result.content[0].text).toContain("tenant_id = '?'");
+ expect(result.content[0].text).not.toContain('db-secret');
+ expect(result.content[0].text).not.toContain('tenant-secret');
+ });
+
+ it('rejects unknown RLS-management fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageRLSTool.execute({
+ operation: 'create_policy',
+ tableName: 'users',
+ policyName: 'tenant_policy',
+ using: 'tenant_id = current_setting(\'app.tenant_id\')',
+ rawSql: 'DROP POLICY tenant_policy ON users'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects invalid RLS-management option values before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageRLSTool.execute({
+ operation: 'create_policy',
+ tableName: 'users',
+ policyName: 'tenant_policy',
+ using: 'true',
+ command: 'MERGE'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('command');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe RLS identifiers before connecting', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageRLSTool.execute({
+ operation: 'drop_policy',
+ tableName: 'users',
+ policyName: 'policy1; drop table users'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects missing RLS operation fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageRLSTool.execute({
+ operation: 'create_policy',
+ tableName: 'users',
+ policyName: 'tenant_policy'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('tableName, policyName, and using are required');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe RLS roles before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageRLSTool.execute({
+ operation: 'edit_policy',
+ tableName: 'users',
+ policyName: 'tenant_policy',
+ roles: ['authenticated; drop role authenticated']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/tools/functions.ts b/src/tools/functions.ts
index d5bf97d..1ad38d8 100644
--- a/src/tools/functions.ts
+++ b/src/tools/functions.ts
@@ -1,6 +1,7 @@
-import { DatabaseConnection } from '../utils/connection.js';
-import type { PostgresTool, ToolOutput, GetConnectionStringFn } from '../types/tool.js';
-import { z } from 'zod';
+import { DatabaseConnection, sanitizeErrorMessage } from '../utils/connection.js';
+import type { PostgresTool, ToolOutput, GetConnectionStringFn } from '../types/tool.js';
+import { z } from 'zod';
+import { quoteIdent, quoteQualifiedIdent, redactSqlText } from '../utils/sql.js';
interface FunctionResult {
success: boolean;
@@ -8,15 +9,75 @@ interface FunctionResult {
details: unknown;
}
-interface FunctionInfo {
- name: string;
+interface FunctionInfo {
+ name: string;
language: string;
returnType: string;
arguments: string;
definition: string;
volatility: string;
- owner: string;
-}
+ owner: string;
+}
+
+interface RLSPolicyInfo {
+ schemaname: string;
+ tablename: string;
+ policyname: string;
+ roles: string[];
+ cmd: string;
+ using: string | null;
+ check: string | null;
+}
+
+function redactFunctionInfo(fn: FunctionInfo): FunctionInfo {
+ return {
+ ...fn,
+ definition: redactSqlText(fn.definition)
+ };
+}
+
+function redactRLSPolicyInfo(policy: RLSPolicyInfo): RLSPolicyInfo {
+ return {
+ ...policy,
+ using: policy.using ? redactSqlText(policy.using) : policy.using,
+ check: policy.check ? redactSqlText(policy.check) : policy.check
+ };
+}
+
+function formatValidationError(error: z.ZodError): string {
+ return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', ');
+}
+
+function dollarQuote(value: string): string {
+ let tag = 'function';
+ let delimiter = `$${tag}$`;
+ let counter = 0;
+
+ while (value.includes(delimiter)) {
+ counter += 1;
+ tag = `function_${counter}`;
+ delimiter = `$${tag}$`;
+ }
+
+ return `${delimiter}\n${value}\n${delimiter}`;
+}
+
+function buildFunctionSignature(parameters?: string): string {
+ if (!parameters || parameters.trim() === '') {
+ return '()';
+ }
+
+ const signature = parameters.trim();
+ if (!/^[A-Za-z0-9_.,\s[\]]+$/.test(signature)) {
+ throw new Error('Invalid function signature. Use a comma-separated list of simple PostgreSQL type names only.');
+ }
+
+ return `(${signature})`;
+}
+
+function quoteRoleName(role: string): string {
+ return role.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : quoteIdent(role);
+}
/**
* Get information about database functions
@@ -60,7 +121,7 @@ async function _getFunctions(
query += ' ORDER BY p.proname';
- const functions = await db.query(query, params);
+ const functions = (await db.query(query, params)).map(redactFunctionInfo);
return {
success: true,
@@ -72,7 +133,7 @@ async function _getFunctions(
} catch (error) {
return {
success: false,
- message: `Failed to get function information: ${error instanceof Error ? error.message : String(error)}`,
+ message: `Failed to get function information: ${sanitizeErrorMessage(error)}`,
details: null
};
} finally {
@@ -97,30 +158,29 @@ async function _createFunction(
replace?: boolean;
} = {}
): Promise {
- const db = DatabaseConnection.getInstance();
-
- try {
- await db.connect(connectionString);
-
- const language = options.language || 'plpgsql';
- const volatility = options.volatility || 'VOLATILE';
- const schema = options.schema || 'public';
- const security = options.security || 'INVOKER';
- const createOrReplace = options.replace ? 'CREATE OR REPLACE' : 'CREATE';
-
- // Build function creation SQL
- const sql = `
- ${createOrReplace} FUNCTION ${schema}.${functionName}(${parameters})
- RETURNS ${returnType}
- LANGUAGE ${language}
- ${volatility}
- SECURITY ${security}
- AS $function$
- ${functionBody}
- $function$;
- `;
-
- await db.query(sql);
+ const db = DatabaseConnection.getInstance();
+
+ try {
+ const language = options.language || 'plpgsql';
+ const volatility = options.volatility || 'VOLATILE';
+ const schema = options.schema || 'public';
+ const security = options.security || 'INVOKER';
+ const createOrReplace = options.replace ? 'CREATE OR REPLACE' : 'CREATE';
+ const qualifiedFunctionName = quoteQualifiedIdent(functionName, schema);
+ const quotedBody = dollarQuote(functionBody);
+
+ // Build function creation SQL
+ const sql = `
+ ${createOrReplace} FUNCTION ${qualifiedFunctionName}(${parameters})
+ RETURNS ${returnType}
+ LANGUAGE ${language}
+ ${volatility}
+ SECURITY ${security}
+ AS ${quotedBody};
+ `;
+
+ await db.connect(connectionString);
+ await db.query(sql);
return {
success: true,
@@ -137,7 +197,7 @@ async function _createFunction(
} catch (error) {
return {
success: false,
- message: `Failed to create function: ${error instanceof Error ? error.message : String(error)}`,
+ message: `Failed to create function: ${sanitizeErrorMessage(error)}`,
details: null
};
} finally {
@@ -158,29 +218,24 @@ async function _dropFunction(
cascade?: boolean;
} = {}
): Promise {
- const db = DatabaseConnection.getInstance();
-
- try {
- await db.connect(connectionString);
-
- const schema = options.schema || 'public';
- const ifExists = options.ifExists ? 'IF EXISTS' : '';
- const cascade = options.cascade ? 'CASCADE' : '';
-
- // Build function drop SQL
- let sql = `DROP FUNCTION ${ifExists} ${schema}.${functionName}`;
-
- // Add parameters if provided
- if (parameters) {
- sql += `(${parameters})`;
- }
-
- // Add cascade if specified
- if (cascade) {
- sql += ` ${cascade}`;
- }
-
- await db.query(sql);
+ const db = DatabaseConnection.getInstance();
+
+ try {
+ const schema = options.schema || 'public';
+ const ifExists = options.ifExists ? 'IF EXISTS' : '';
+ const cascade = options.cascade ? 'CASCADE' : '';
+ const qualifiedFunctionName = quoteQualifiedIdent(functionName, schema);
+
+ // Build function drop SQL
+ let sql = `DROP FUNCTION ${ifExists} ${qualifiedFunctionName}${buildFunctionSignature(parameters)}`;
+
+ // Add cascade if specified
+ if (cascade) {
+ sql += ` ${cascade}`;
+ }
+
+ await db.connect(connectionString);
+ await db.query(sql);
return {
success: true,
@@ -193,7 +248,7 @@ async function _dropFunction(
} catch (error) {
return {
success: false,
- message: `Failed to drop function: ${error instanceof Error ? error.message : String(error)}`,
+ message: `Failed to drop function: ${sanitizeErrorMessage(error)}`,
details: null
};
} finally {
@@ -209,12 +264,13 @@ async function _enableRLS(
tableName: string,
schema = 'public'
): Promise {
- const db = DatabaseConnection.getInstance();
-
- try {
- await db.connect(connectionString);
-
- await db.query(`ALTER TABLE ${schema}.${tableName} ENABLE ROW LEVEL SECURITY`);
+ const db = DatabaseConnection.getInstance();
+
+ try {
+ const qualifiedTableName = quoteQualifiedIdent(tableName, schema);
+
+ await db.connect(connectionString);
+ await db.query(`ALTER TABLE ${qualifiedTableName} ENABLE ROW LEVEL SECURITY`);
return {
success: true,
@@ -227,7 +283,7 @@ async function _enableRLS(
} catch (error) {
return {
success: false,
- message: `Failed to enable RLS: ${error instanceof Error ? error.message : String(error)}`,
+ message: `Failed to enable RLS: ${sanitizeErrorMessage(error)}`,
details: null
};
} finally {
@@ -243,12 +299,13 @@ async function _disableRLS(
tableName: string,
schema = 'public'
): Promise {
- const db = DatabaseConnection.getInstance();
-
- try {
- await db.connect(connectionString);
-
- await db.query(`ALTER TABLE ${schema}.${tableName} DISABLE ROW LEVEL SECURITY`);
+ const db = DatabaseConnection.getInstance();
+
+ try {
+ const qualifiedTableName = quoteQualifiedIdent(tableName, schema);
+
+ await db.connect(connectionString);
+ await db.query(`ALTER TABLE ${qualifiedTableName} DISABLE ROW LEVEL SECURITY`);
return {
success: true,
@@ -261,7 +318,7 @@ async function _disableRLS(
} catch (error) {
return {
success: false,
- message: `Failed to disable RLS: ${error instanceof Error ? error.message : String(error)}`,
+ message: `Failed to disable RLS: ${sanitizeErrorMessage(error)}`,
details: null
};
} finally {
@@ -285,26 +342,26 @@ async function _createRLSPolicy(
replace?: boolean;
} = {}
): Promise {
- const db = DatabaseConnection.getInstance();
-
- try {
- await db.connect(connectionString);
-
- const schema = options.schema || 'public';
- const command = options.command || 'ALL';
- const createOrReplace = options.replace ? 'CREATE OR REPLACE' : 'CREATE';
-
- // Build policy creation SQL
- let sql = `
- ${createOrReplace} POLICY ${policyName}
- ON ${schema}.${tableName}
- FOR ${command}
- `;
-
- // Add role if specified
- if (options.role) {
- sql += ` TO ${options.role}`;
- }
+ const db = DatabaseConnection.getInstance();
+
+ try {
+ const schema = options.schema || 'public';
+ const command = options.command || 'ALL';
+ const qualifiedTableName = quoteQualifiedIdent(tableName, schema);
+ const quotedPolicyName = quoteIdent(policyName);
+ const replaceSql = options.replace ? `DROP POLICY IF EXISTS ${quotedPolicyName} ON ${qualifiedTableName};\n` : '';
+
+ // Build policy creation SQL
+ let sql = `
+ ${replaceSql}CREATE POLICY ${quotedPolicyName}
+ ON ${qualifiedTableName}
+ FOR ${command}
+ `;
+
+ // Add role if specified
+ if (options.role) {
+ sql += ` TO ${quoteRoleName(options.role)}`;
+ }
// Add USING expression
sql += ` USING (${using})`;
@@ -314,7 +371,8 @@ async function _createRLSPolicy(
sql += ` WITH CHECK (${check})`;
}
- await db.query(sql);
+ await db.connect(connectionString);
+ await db.query(sql);
return {
success: true,
@@ -329,7 +387,7 @@ async function _createRLSPolicy(
} catch (error) {
return {
success: false,
- message: `Failed to create policy: ${error instanceof Error ? error.message : String(error)}`,
+ message: `Failed to create policy: ${sanitizeErrorMessage(error)}`,
details: null
};
} finally {
@@ -349,15 +407,16 @@ async function _dropRLSPolicy(
ifExists?: boolean;
} = {}
): Promise {
- const db = DatabaseConnection.getInstance();
-
- try {
- await db.connect(connectionString);
-
- const schema = options.schema || 'public';
- const ifExists = options.ifExists ? 'IF EXISTS' : '';
-
- await db.query(`DROP POLICY ${ifExists} ${policyName} ON ${schema}.${tableName}`);
+ const db = DatabaseConnection.getInstance();
+
+ try {
+ const schema = options.schema || 'public';
+ const ifExists = options.ifExists ? 'IF EXISTS' : '';
+ const qualifiedTableName = quoteQualifiedIdent(tableName, schema);
+ const quotedPolicyName = quoteIdent(policyName);
+
+ await db.connect(connectionString);
+ await db.query(`DROP POLICY ${ifExists} ${quotedPolicyName} ON ${qualifiedTableName}`);
return {
success: true,
@@ -371,7 +430,7 @@ async function _dropRLSPolicy(
} catch (error) {
return {
success: false,
- message: `Failed to drop policy: ${error instanceof Error ? error.message : String(error)}`,
+ message: `Failed to drop policy: ${sanitizeErrorMessage(error)}`,
details: null
};
} finally {
@@ -393,20 +452,20 @@ async function _editRLSPolicy(
check?: string;
} = {}
): Promise {
- const db = DatabaseConnection.getInstance();
-
- try {
- await db.connect(connectionString);
-
- const schema = options.schema || 'public';
- const alterClauses: string[] = [];
-
- if (options.roles !== undefined) {
- const rolesString = options.roles.length === 0
- ? 'PUBLIC' // Assuming empty array means PUBLIC, adjust if needed
- : options.roles.join(', ');
- alterClauses.push(`TO ${rolesString}`);
- }
+ const db = DatabaseConnection.getInstance();
+
+ try {
+ const schema = options.schema || 'public';
+ const qualifiedTableName = quoteQualifiedIdent(tableName, schema);
+ const quotedPolicyName = quoteIdent(policyName);
+ const alterClauses: string[] = [];
+
+ if (options.roles !== undefined) {
+ const rolesString = options.roles.length === 0
+ ? 'PUBLIC' // Assuming empty array means PUBLIC, adjust if needed
+ : options.roles.map(quoteRoleName).join(', ');
+ alterClauses.push(`TO ${rolesString}`);
+ }
if (options.using !== undefined) {
alterClauses.push(`USING (${options.using})`);
@@ -437,13 +496,14 @@ async function _editRLSPolicy(
};
}
- const sql = `
- ALTER POLICY ${policyName}
- ON ${schema}.${tableName}
- ${alterClauses.join('\n')};
- `;
-
- await db.query(sql);
+ const sql = `
+ ALTER POLICY ${quotedPolicyName}
+ ON ${qualifiedTableName}
+ ${alterClauses.join('\n')};
+ `;
+
+ await db.connect(connectionString);
+ await db.query(sql);
return {
success: true,
@@ -458,7 +518,7 @@ async function _editRLSPolicy(
} catch (error) {
return {
success: false,
- message: `Failed to edit policy: ${error instanceof Error ? error.message : String(error)}`,
+ message: `Failed to edit policy: ${sanitizeErrorMessage(error)}`,
details: null
};
} finally {
@@ -501,7 +561,7 @@ async function _getRLSPolicies(
query += ' ORDER BY tablename, policyname';
- const policies = await db.query(query, params);
+ const policies = (await db.query(query, params)).map(redactRLSPolicyInfo);
return {
success: true,
@@ -513,7 +573,7 @@ async function _getRLSPolicies(
} catch (error) {
return {
success: false,
- message: `Failed to get policies: ${error instanceof Error ? error.message : String(error)}`,
+ message: `Failed to get policies: ${sanitizeErrorMessage(error)}`,
details: null
};
} finally {
@@ -521,350 +581,407 @@ async function _getRLSPolicies(
}
}
-// --- Tool Definitions ---
-
-export const getFunctionsTool: PostgresTool = {
- name: 'pg_get_functions',
- description: 'Get information about PostgreSQL functions',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- functionName: z.string().optional().describe('Optional function name to filter by'),
- schema: z.string().optional().describe('Schema name (defaults to public)') // Assuming 'public' default is handled in execute or not strictly enforced by schema
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const { connectionString: connStringArg, functionName, schema } = args as {
- connectionString?: string;
- functionName?: string;
- schema?: string;
- };
- const resolvedConnString = getConnectionStringVal(connStringArg);
- const result = await _getFunctions(resolvedConnString, functionName, schema);
- if (result.success) {
+// --- Tool Definitions ---
+
+const GetFunctionsInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ functionName: z.string().optional().describe('Optional function name to filter by'),
+ schema: z.string().optional().describe('Schema name (defaults to public)')
+}).strict();
+
+export const getFunctionsTool: PostgresTool = {
+ name: 'pg_get_functions',
+ description: 'Get information about PostgreSQL functions',
+ inputSchema: GetFunctionsInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = GetFunctionsInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const { connectionString: connStringArg, functionName, schema } = validationResult.data;
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ const result = await _getFunctions(resolvedConnString, functionName, schema);
+ if (result.success) {
return { content: [{ type: 'text', text: JSON.stringify(result.details, null, 2) || result.message }] };
}
return { content: [{ type: 'text', text: result.message }], isError: true };
- },
-};
-
-export const createFunctionTool: PostgresTool = {
- name: 'pg_create_function',
- description: 'Create or replace a PostgreSQL function',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- functionName: z.string().describe('Name of the function to create'),
- parameters: z.string().describe('Function parameters - required for create operation, required for drop when function is overloaded. Use empty string "" for functions with no parameters'),
- returnType: z.string().describe('Return type of the function'),
- functionBody: z.string().describe('Function body code'),
- language: z.enum(['sql', 'plpgsql', 'plpython3u']).describe('Function language'),
- volatility: z.enum(['VOLATILE', 'STABLE', 'IMMUTABLE']).describe('Function volatility'),
- schema: z.string().describe('Schema name (defaults to public)'),
- security: z.enum(['INVOKER', 'DEFINER']).describe('Function security context'),
- replace: z.boolean().describe('Whether to replace the function if it exists')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const {
- connectionString: connStringArg,
- functionName,
- parameters,
- returnType,
- functionBody,
- language,
- volatility,
- schema,
- security,
- replace
- } = args as {
- connectionString?: string;
- functionName: string;
- parameters: string;
- returnType: string;
- functionBody: string;
- language?: 'sql' | 'plpgsql' | 'plpython3u';
- volatility?: 'VOLATILE' | 'STABLE' | 'IMMUTABLE';
- schema?: string;
- security?: 'INVOKER' | 'DEFINER';
- replace?: boolean;
- };
- const resolvedConnString = getConnectionStringVal(connStringArg);
- const result = await _createFunction(resolvedConnString, functionName, parameters, returnType, functionBody, { language, volatility, schema, security, replace });
- if (result.success) {
- return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
- }
- return { content: [{ type: 'text', text: result.message }], isError: true };
- },
-};
-
-export const dropFunctionTool: PostgresTool = {
- name: 'pg_drop_function',
- description: 'Drop a PostgreSQL function',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- functionName: z.string().describe('Name of the function to drop'),
- parameters: z.string().describe('Function parameters signature (required for overloaded functions)'),
- schema: z.string().describe('Schema name (defaults to public)'),
- ifExists: z.boolean().describe('Whether to include IF EXISTS clause'),
- cascade: z.boolean().describe('Whether to include CASCADE clause')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const {
- connectionString: connStringArg,
- functionName,
- parameters,
- schema,
- ifExists,
- cascade
- } = args as {
- connectionString?: string;
- functionName: string;
- parameters?: string;
- schema?: string;
- ifExists?: boolean;
- cascade?: boolean;
- };
- const resolvedConnString = getConnectionStringVal(connStringArg);
- const result = await _dropFunction(resolvedConnString, functionName, parameters, { schema, ifExists, cascade });
- if (result.success) {
- return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
- }
- return { content: [{ type: 'text', text: result.message }], isError: true };
- },
-};
-
-export const enableRLSTool: PostgresTool = {
- name: 'pg_enable_rls',
- description: 'Enable Row-Level Security on a table',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- tableName: z.string().describe('Name of the table to enable RLS on'),
- schema: z.string().describe('Schema name (defaults to public)')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const { connectionString: connStringArg, tableName, schema } = args;
- const resolvedConnString = getConnectionStringVal(connStringArg);
- const result = await _enableRLS(resolvedConnString, tableName, schema);
- if (result.success) {
- return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
- }
- return { content: [{ type: 'text', text: result.message }], isError: true };
- }
-};
-
-export const disableRLSTool: PostgresTool = {
- name: 'pg_disable_rls',
- description: 'Disable Row-Level Security on a table',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- tableName: z.string().describe('Name of the table to disable RLS on'),
- schema: z.string().describe('Schema name (defaults to public)')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const { connectionString: connStringArg, tableName, schema } = args;
- const resolvedConnString = getConnectionStringVal(connStringArg);
- const result = await _disableRLS(resolvedConnString, tableName, schema);
- if (result.success) {
- return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
- }
- return { content: [{ type: 'text', text: result.message }], isError: true };
- }
-};
-
-export const createRLSPolicyTool: PostgresTool = {
- name: 'pg_create_rls_policy',
- description: 'Create a Row-Level Security policy',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- tableName: z.string().describe('Name of the table to create policy on'),
- policyName: z.string().describe('Name of the policy to create'),
- using: z.string().describe('USING expression for the policy (e.g., "user_id = current_user_id()")'),
- check: z.string().describe('WITH CHECK expression for the policy (if different from USING)'),
- schema: z.string().describe('Schema name (defaults to public)'),
- command: z.enum(['ALL', 'SELECT', 'INSERT', 'UPDATE', 'DELETE']).describe('Command the policy applies to'),
- role: z.string().describe('Role the policy applies to'),
- replace: z.boolean().describe('Whether to replace the policy if it exists')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const { connectionString: connStringArg, tableName, policyName, using, check, schema, command, role, replace } = args;
- const resolvedConnString = getConnectionStringVal(connStringArg);
- const result = await _createRLSPolicy(resolvedConnString, tableName, policyName, using, check, { schema, command, role, replace });
- if (result.success) {
- return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
- }
- return { content: [{ type: 'text', text: result.message }], isError: true };
- }
-};
-
-export const dropRLSPolicyTool: PostgresTool = {
- name: 'pg_drop_rls_policy',
- description: 'Drop a Row-Level Security policy',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- tableName: z.string().describe('Name of the table the policy is on'),
- policyName: z.string().describe('Name of the policy to drop'),
- schema: z.string().describe('Schema name (defaults to public)'),
- ifExists: z.boolean().describe('Whether to include IF EXISTS clause')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const { connectionString: connStringArg, tableName, policyName, schema, ifExists } = args;
- const resolvedConnString = getConnectionStringVal(connStringArg);
- const result = await _dropRLSPolicy(resolvedConnString, tableName, policyName, { schema, ifExists });
- if (result.success) {
- return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
- }
- return { content: [{ type: 'text', text: result.message }], isError: true };
- }
-};
+ },
+};
+
+const CreateFunctionInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ functionName: z.string().describe('Name of the function to create'),
+ parameters: z.string().optional().default('').describe('Function parameters. Use empty string "" for functions with no parameters'),
+ returnType: z.string().describe('Return type of the function'),
+ functionBody: z.string().describe('Function body code'),
+ language: z.enum(['sql', 'plpgsql', 'plpython3u']).optional().describe('Function language'),
+ volatility: z.enum(['VOLATILE', 'STABLE', 'IMMUTABLE']).optional().describe('Function volatility'),
+ schema: z.string().optional().describe('Schema name (defaults to public)'),
+ security: z.enum(['INVOKER', 'DEFINER']).optional().describe('Function security context'),
+ replace: z.boolean().optional().describe('Whether to replace the function if it exists')
+}).strict();
+
+export const createFunctionTool: PostgresTool = {
+ name: 'pg_create_function',
+ description: 'Create or replace a PostgreSQL function',
+ inputSchema: CreateFunctionInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = CreateFunctionInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const {
+ connectionString: connStringArg,
+ functionName,
+ parameters,
+ returnType,
+ functionBody,
+ language,
+ volatility,
+ schema,
+ security,
+ replace
+ } = validationResult.data;
+ try {
+ quoteQualifiedIdent(functionName, schema || 'public');
+ buildFunctionSignature(parameters);
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ const result = await _createFunction(resolvedConnString, functionName, parameters, returnType, functionBody, { language, volatility, schema, security, replace });
+ if (result.success) {
+ return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
+ }
+ return { content: [{ type: 'text', text: result.message }], isError: true };
+ } catch (error) {
+ return { content: [{ type: 'text', text: `Error creating function: ${sanitizeErrorMessage(error)}` }], isError: true };
+ }
+ },
+};
+
+const DropFunctionInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ functionName: z.string().describe('Name of the function to drop'),
+ parameters: z.string().optional().describe('Function parameters signature (required for overloaded functions)'),
+ schema: z.string().optional().describe('Schema name (defaults to public)'),
+ ifExists: z.boolean().optional().describe('Whether to include IF EXISTS clause'),
+ cascade: z.boolean().optional().describe('Whether to include CASCADE clause')
+}).strict();
+
+export const dropFunctionTool: PostgresTool = {
+ name: 'pg_drop_function',
+ description: 'Drop a PostgreSQL function',
+ inputSchema: DropFunctionInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = DropFunctionInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const {
+ connectionString: connStringArg,
+ functionName,
+ parameters,
+ schema,
+ ifExists,
+ cascade
+ } = validationResult.data;
+ try {
+ quoteQualifiedIdent(functionName, schema || 'public');
+ buildFunctionSignature(parameters);
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ const result = await _dropFunction(resolvedConnString, functionName, parameters, { schema, ifExists, cascade });
+ if (result.success) {
+ return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
+ }
+ return { content: [{ type: 'text', text: result.message }], isError: true };
+ } catch (error) {
+ return { content: [{ type: 'text', text: `Error dropping function: ${sanitizeErrorMessage(error)}` }], isError: true };
+ }
+ },
+};
+
+const ToggleRLSInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ tableName: z.string().describe('Name of the table'),
+ schema: z.string().optional().describe('Schema name (defaults to public)')
+}).strict();
+
+export const enableRLSTool: PostgresTool = {
+ name: 'pg_enable_rls',
+ description: 'Enable Row-Level Security on a table',
+ inputSchema: ToggleRLSInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = ToggleRLSInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const { connectionString: connStringArg, tableName, schema } = validationResult.data;
+ try {
+ quoteQualifiedIdent(tableName, schema || 'public');
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ const result = await _enableRLS(resolvedConnString, tableName, schema);
+ if (result.success) {
+ return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
+ }
+ return { content: [{ type: 'text', text: result.message }], isError: true };
+ } catch (error) {
+ return { content: [{ type: 'text', text: `Error enabling RLS: ${sanitizeErrorMessage(error)}` }], isError: true };
+ }
+ }
+};
-export const editRLSPolicyTool: PostgresTool = {
- name: 'pg_edit_rls_policy',
- description: 'Edit an existing Row-Level Security policy',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- tableName: z.string().describe('Name of the table the policy is on'),
- policyName: z.string().describe('Name of the policy to edit'),
- schema: z.string().describe('Schema name (defaults to public)'),
- roles: z.array(z.string()).describe('New list of roles the policy applies to (e.g., ["role1", "role2"]. Use PUBLIC or leave empty for all roles)'),
- using: z.string().describe('New USING expression for the policy'),
- check: z.string().describe('New WITH CHECK expression for the policy')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const { connectionString: connStringArg, tableName, policyName, schema, roles, using, check } = args;
- const resolvedConnString = getConnectionStringVal(connStringArg);
- const result = await _editRLSPolicy(resolvedConnString, tableName, policyName, { schema, roles, using, check });
- if (result.success) {
- return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
- }
- return { content: [{ type: 'text', text: result.message }], isError: true };
- }
-};
-
-export const getRLSPoliciesTool: PostgresTool = {
- name: 'pg_get_rls_policies',
- description: 'Get Row-Level Security policies',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- tableName: z.string().optional().describe('Optional table name to filter by'),
- schema: z.string().optional().describe('Schema name (defaults to public)')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const { connectionString: connStringArg, tableName, schema } = args;
- const resolvedConnString = getConnectionStringVal(connStringArg);
- const result = await _getRLSPolicies(resolvedConnString, tableName, schema);
- if (result.success) {
+export const disableRLSTool: PostgresTool = {
+ name: 'pg_disable_rls',
+ description: 'Disable Row-Level Security on a table',
+ inputSchema: ToggleRLSInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = ToggleRLSInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const { connectionString: connStringArg, tableName, schema } = validationResult.data;
+ try {
+ quoteQualifiedIdent(tableName, schema || 'public');
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ const result = await _disableRLS(resolvedConnString, tableName, schema);
+ if (result.success) {
+ return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
+ }
+ return { content: [{ type: 'text', text: result.message }], isError: true };
+ } catch (error) {
+ return { content: [{ type: 'text', text: `Error disabling RLS: ${sanitizeErrorMessage(error)}` }], isError: true };
+ }
+ }
+};
+
+const CreateRLSPolicyInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ tableName: z.string().describe('Name of the table to create policy on'),
+ policyName: z.string().describe('Name of the policy to create'),
+ using: z.string().describe('USING expression for the policy'),
+ check: z.string().optional().describe('WITH CHECK expression for the policy'),
+ schema: z.string().optional().describe('Schema name (defaults to public)'),
+ command: z.enum(['ALL', 'SELECT', 'INSERT', 'UPDATE', 'DELETE']).optional().describe('Command the policy applies to'),
+ role: z.string().optional().describe('Role the policy applies to'),
+ replace: z.boolean().optional().describe('Whether to replace the policy if it exists')
+}).strict();
+
+export const createRLSPolicyTool: PostgresTool = {
+ name: 'pg_create_rls_policy',
+ description: 'Create a Row-Level Security policy',
+ inputSchema: CreateRLSPolicyInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = CreateRLSPolicyInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const { connectionString: connStringArg, tableName, policyName, using, check, schema, command, role, replace } = validationResult.data;
+ try {
+ quoteQualifiedIdent(tableName, schema || 'public');
+ quoteIdent(policyName);
+ if (role) {
+ quoteRoleName(role);
+ }
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ const result = await _createRLSPolicy(resolvedConnString, tableName, policyName, using, check, { schema, command, role, replace });
+ if (result.success) {
+ return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
+ }
+ return { content: [{ type: 'text', text: result.message }], isError: true };
+ } catch (error) {
+ return { content: [{ type: 'text', text: `Error creating RLS policy: ${sanitizeErrorMessage(error)}` }], isError: true };
+ }
+ }
+};
+
+const DropRLSPolicyInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ tableName: z.string().describe('Name of the table the policy is on'),
+ policyName: z.string().describe('Name of the policy to drop'),
+ schema: z.string().optional().describe('Schema name (defaults to public)'),
+ ifExists: z.boolean().optional().describe('Whether to include IF EXISTS clause')
+}).strict();
+
+export const dropRLSPolicyTool: PostgresTool = {
+ name: 'pg_drop_rls_policy',
+ description: 'Drop a Row-Level Security policy',
+ inputSchema: DropRLSPolicyInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = DropRLSPolicyInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const { connectionString: connStringArg, tableName, policyName, schema, ifExists } = validationResult.data;
+ try {
+ quoteQualifiedIdent(tableName, schema || 'public');
+ quoteIdent(policyName);
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ const result = await _dropRLSPolicy(resolvedConnString, tableName, policyName, { schema, ifExists });
+ if (result.success) {
+ return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
+ }
+ return { content: [{ type: 'text', text: result.message }], isError: true };
+ } catch (error) {
+ return { content: [{ type: 'text', text: `Error dropping RLS policy: ${sanitizeErrorMessage(error)}` }], isError: true };
+ }
+ }
+};
+
+const EditRLSPolicyInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ tableName: z.string().describe('Name of the table the policy is on'),
+ policyName: z.string().describe('Name of the policy to edit'),
+ schema: z.string().optional().describe('Schema name (defaults to public)'),
+ roles: z.array(z.string()).optional().describe('New list of roles the policy applies to'),
+ using: z.string().optional().describe('New USING expression for the policy'),
+ check: z.string().optional().describe('New WITH CHECK expression for the policy')
+}).strict();
+
+export const editRLSPolicyTool: PostgresTool = {
+ name: 'pg_edit_rls_policy',
+ description: 'Edit an existing Row-Level Security policy',
+ inputSchema: EditRLSPolicyInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = EditRLSPolicyInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const { connectionString: connStringArg, tableName, policyName, schema, roles, using, check } = validationResult.data;
+ try {
+ quoteQualifiedIdent(tableName, schema || 'public');
+ quoteIdent(policyName);
+ if (roles) {
+ roles.forEach(quoteRoleName);
+ }
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ const result = await _editRLSPolicy(resolvedConnString, tableName, policyName, { schema, roles, using, check });
+ if (result.success) {
+ return { content: [{ type: 'text', text: result.message + (result.details ? ` Details: ${JSON.stringify(result.details)}` : '') }] };
+ }
+ return { content: [{ type: 'text', text: result.message }], isError: true };
+ } catch (error) {
+ return { content: [{ type: 'text', text: `Error editing RLS policy: ${sanitizeErrorMessage(error)}` }], isError: true };
+ }
+ }
+};
+
+const GetRLSPoliciesInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ tableName: z.string().optional().describe('Optional table name to filter by'),
+ schema: z.string().optional().describe('Schema name (defaults to public)')
+}).strict();
+
+export const getRLSPoliciesTool: PostgresTool = {
+ name: 'pg_get_rls_policies',
+ description: 'Get Row-Level Security policies',
+ inputSchema: GetRLSPoliciesInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = GetRLSPoliciesInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const { connectionString: connStringArg, tableName, schema } = validationResult.data;
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ const result = await _getRLSPolicies(resolvedConnString, tableName, schema);
+ if (result.success) {
return { content: [{ type: 'text', text: JSON.stringify(result.details, null, 2) || result.message }] };
}
return { content: [{ type: 'text', text: result.message }], isError: true };
- }
-};
-
-// Consolidated Functions Management Tool
-export const manageFunctionsTool: PostgresTool = {
- name: 'pg_manage_functions',
- description: 'Manage PostgreSQL functions - get, create, or drop functions with a single tool. Examples: operation="get" to list functions, operation="create" with functionName="test_func", parameters="" (empty for no params), returnType="TEXT", functionBody="SELECT \'Hello\'"',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- operation: z.enum(['get', 'create', 'drop']).describe('Operation to perform: get (list/info), create (new function), or drop (remove function)'),
-
- // Common parameters
- functionName: z.string().optional().describe('Name of the function (required for create/drop, optional for get to filter)'),
- schema: z.string().optional().describe('Schema name (defaults to public)'),
-
- // Create operation parameters
- parameters: z.string().optional().describe('Function parameters - required for create operation, required for drop when function is overloaded. Use empty string "" for functions with no parameters'),
- returnType: z.string().optional().describe('Return type of the function (required for create operation)'),
- functionBody: z.string().optional().describe('Function body code (required for create operation)'),
- language: z.enum(['sql', 'plpgsql', 'plpython3u']).optional().describe('Function language (defaults to plpgsql for create)'),
- volatility: z.enum(['VOLATILE', 'STABLE', 'IMMUTABLE']).optional().describe('Function volatility (defaults to VOLATILE for create)'),
- security: z.enum(['INVOKER', 'DEFINER']).optional().describe('Function security context (defaults to INVOKER for create)'),
- replace: z.boolean().optional().describe('Whether to replace the function if it exists (for create operation)'),
-
- // Drop operation parameters
- ifExists: z.boolean().optional().describe('Whether to include IF EXISTS clause (for drop operation)'),
- cascade: z.boolean().optional().describe('Whether to include CASCADE clause (for drop operation)')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const {
- connectionString: connStringArg,
- operation,
- functionName,
+ }
+};
+
+const ManageFunctionsInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ operation: z.enum(['get', 'create', 'drop']).describe('Operation to perform: get (list/info), create (new function), or drop (remove function)'),
+
+ // Common parameters
+ functionName: z.string().optional().describe('Name of the function (required for create/drop, optional for get to filter)'),
+ schema: z.string().optional().describe('Schema name (defaults to public)'),
+
+ // Create operation parameters
+ parameters: z.string().optional().describe('Function parameters - required for create operation, required for drop when function is overloaded. Use empty string "" for functions with no parameters'),
+ returnType: z.string().optional().describe('Return type of the function (required for create operation)'),
+ functionBody: z.string().optional().describe('Function body code (required for create operation)'),
+ language: z.enum(['sql', 'plpgsql', 'plpython3u']).optional().describe('Function language (defaults to plpgsql for create)'),
+ volatility: z.enum(['VOLATILE', 'STABLE', 'IMMUTABLE']).optional().describe('Function volatility (defaults to VOLATILE for create)'),
+ security: z.enum(['INVOKER', 'DEFINER']).optional().describe('Function security context (defaults to INVOKER for create)'),
+ replace: z.boolean().optional().describe('Whether to replace the function if it exists (for create operation)'),
+
+ // Drop operation parameters
+ ifExists: z.boolean().optional().describe('Whether to include IF EXISTS clause (for drop operation)'),
+ cascade: z.boolean().optional().describe('Whether to include CASCADE clause (for drop operation)')
+}).strict();
+
+// Consolidated Functions Management Tool
+export const manageFunctionsTool: PostgresTool = {
+ name: 'pg_manage_functions',
+ description: 'Manage PostgreSQL functions - get, create, or drop functions with a single tool. Examples: operation="get" to list functions, operation="create" with functionName="test_func", parameters="" (empty for no params), returnType="TEXT", functionBody="SELECT \'Hello\'"',
+ inputSchema: ManageFunctionsInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = ManageFunctionsInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const {
+ connectionString: connStringArg,
+ operation,
+ functionName,
schema,
parameters,
returnType,
functionBody,
language,
volatility,
- security,
- replace,
- ifExists,
- cascade
- } = args as {
- connectionString?: string;
- operation: 'get' | 'create' | 'drop';
- functionName?: string;
- schema?: string;
- parameters?: string;
- returnType?: string;
- functionBody?: string;
- language?: 'sql' | 'plpgsql' | 'plpython3u';
- volatility?: 'VOLATILE' | 'STABLE' | 'IMMUTABLE';
- security?: 'INVOKER' | 'DEFINER';
- replace?: boolean;
- ifExists?: boolean;
- cascade?: boolean;
- };
-
- const resolvedConnString = getConnectionStringVal(connStringArg);
- let result: FunctionResult;
-
- try {
- switch (operation) {
- case 'get':
- result = await _getFunctions(resolvedConnString, functionName, schema);
- if (result.success) {
- return { content: [{ type: 'text', text: JSON.stringify(result.details, null, 2) || result.message }] };
- }
- break;
-
- case 'create': {
- // Debug logging to understand what's being passed
- console.error('DEBUG - Create operation parameters:', {
- functionName: functionName,
- parameters: parameters,
- returnType: returnType,
- functionBody: functionBody,
- parametersType: typeof parameters,
- parametersUndefined: parameters === undefined,
- parametersNull: parameters === null
- });
-
- // Fix validation: be more specific about which fields are missing
+ security,
+ replace,
+ ifExists,
+ cascade
+ } = validationResult.data;
+
+ let result: FunctionResult;
+
+ try {
+ switch (operation) {
+ case 'get': {
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ result = await _getFunctions(resolvedConnString, functionName, schema);
+ if (result.success) {
+ return { content: [{ type: 'text', text: JSON.stringify(result.details, null, 2) || result.message }] };
+ }
+ break;
+ }
+
+ case 'create': {
+ // Fix validation: be more specific about which fields are missing
const missingFields = [];
if (!functionName) missingFields.push('functionName');
if (!returnType) missingFields.push('returnType');
if (!functionBody) missingFields.push('functionBody');
- if (missingFields.length > 0) {
- return {
- content: [{ type: 'text', text: `Error: Missing required fields: ${missingFields.join(', ')}. Note: parameters can be empty string "" for functions with no parameters` }],
- isError: true
- };
- }
-
- // Normalize parameters: treat undefined, null, or whitespace-only as empty string
- const normalizedParameters: string = parameters === undefined || parameters === null ? '' :
- (typeof parameters === 'string' && parameters.trim() === '') ? '' : String(parameters);
- result = await _createFunction(resolvedConnString, functionName as string, normalizedParameters, returnType as string, functionBody as string, {
- language,
- volatility,
+ if (missingFields.length > 0) {
+ return {
+ content: [{ type: 'text', text: `Error: Missing required fields: ${missingFields.join(', ')}. Note: parameters can be empty string "" for functions with no parameters` }],
+ isError: true
+ };
+ }
+
+ // Normalize parameters: treat undefined, null, or whitespace-only as empty string
+ const normalizedParameters: string = parameters === undefined || parameters === null ? '' :
+ (typeof parameters === 'string' && parameters.trim() === '') ? '' : String(parameters);
+ quoteQualifiedIdent(functionName as string, schema || 'public');
+ buildFunctionSignature(normalizedParameters);
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ result = await _createFunction(resolvedConnString, functionName as string, normalizedParameters, returnType as string, functionBody as string, {
+ language,
+ volatility,
schema,
security,
replace
@@ -872,16 +989,19 @@ export const manageFunctionsTool: PostgresTool = {
break;
}
- case 'drop':
- if (!functionName) {
- return {
- content: [{ type: 'text', text: 'Error: functionName is required for drop operation' }],
- isError: true
- };
- }
- result = await _dropFunction(resolvedConnString, functionName, parameters, {
- schema,
- ifExists,
+ case 'drop':
+ if (!functionName) {
+ return {
+ content: [{ type: 'text', text: 'Error: functionName is required for drop operation' }],
+ isError: true
+ };
+ }
+ quoteQualifiedIdent(functionName, schema || 'public');
+ buildFunctionSignature(parameters);
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ result = await _dropFunction(resolvedConnString, functionName, parameters, {
+ schema,
+ ifExists,
cascade
});
break;
@@ -899,107 +1019,109 @@ export const manageFunctionsTool: PostgresTool = {
return { content: [{ type: 'text', text: result.message }], isError: true };
} catch (error) {
- return {
- content: [{ type: 'text', text: `Error executing ${operation} operation: ${error instanceof Error ? error.message : String(error)}` }],
- isError: true
- };
+ return {
+ content: [{ type: 'text', text: `Error executing ${operation} operation: ${sanitizeErrorMessage(error)}` }],
+ isError: true
+ };
}
}
-};
-
-// Consolidated Row-Level Security Management Tool
-export const manageRLSTool: PostgresTool = {
- name: 'pg_manage_rls',
- description: 'Manage PostgreSQL Row-Level Security - enable/disable RLS and manage policies. Examples: operation="enable" with tableName="users", operation="create_policy" with tableName, policyName, using, check',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- operation: z.enum(['enable', 'disable', 'create_policy', 'edit_policy', 'drop_policy', 'get_policies']).describe('Operation: enable/disable RLS, create_policy, edit_policy, drop_policy, get_policies'),
-
- // Common parameters
- tableName: z.string().optional().describe('Table name (required for enable/disable/create_policy/edit_policy/drop_policy, optional filter for get_policies)'),
- schema: z.string().optional().describe('Schema name (defaults to public)'),
-
- // Policy-specific parameters
- policyName: z.string().optional().describe('Policy name (required for create_policy/edit_policy/drop_policy)'),
- using: z.string().optional().describe('USING expression for policy (required for create_policy, optional for edit_policy)'),
- check: z.string().optional().describe('WITH CHECK expression for policy (optional for create_policy/edit_policy)'),
- command: z.enum(['ALL', 'SELECT', 'INSERT', 'UPDATE', 'DELETE']).optional().describe('Command the policy applies to (for create_policy)'),
- role: z.string().optional().describe('Role the policy applies to (for create_policy)'),
- replace: z.boolean().optional().describe('Whether to replace policy if exists (for create_policy)'),
-
- // Edit policy parameters
- roles: z.array(z.string()).optional().describe('List of roles for policy (for edit_policy)'),
-
- // Drop policy parameters
- ifExists: z.boolean().optional().describe('Include IF EXISTS clause (for drop_policy)')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const {
- connectionString: connStringArg,
- operation,
- tableName,
+};
+
+const ManageRLSInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ operation: z.enum(['enable', 'disable', 'create_policy', 'edit_policy', 'drop_policy', 'get_policies']).describe('Operation: enable/disable RLS, create_policy, edit_policy, drop_policy, get_policies'),
+
+ // Common parameters
+ tableName: z.string().optional().describe('Table name (required for enable/disable/create_policy/edit_policy/drop_policy, optional filter for get_policies)'),
+ schema: z.string().optional().describe('Schema name (defaults to public)'),
+
+ // Policy-specific parameters
+ policyName: z.string().optional().describe('Policy name (required for create_policy/edit_policy/drop_policy)'),
+ using: z.string().optional().describe('USING expression for policy (required for create_policy, optional for edit_policy)'),
+ check: z.string().optional().describe('WITH CHECK expression for policy (optional for create_policy/edit_policy)'),
+ command: z.enum(['ALL', 'SELECT', 'INSERT', 'UPDATE', 'DELETE']).optional().describe('Command the policy applies to (for create_policy)'),
+ role: z.string().optional().describe('Role the policy applies to (for create_policy)'),
+ replace: z.boolean().optional().describe('Whether to replace policy if exists (for create_policy)'),
+
+ // Edit policy parameters
+ roles: z.array(z.string()).optional().describe('List of roles for policy (for edit_policy)'),
+
+ // Drop policy parameters
+ ifExists: z.boolean().optional().describe('Include IF EXISTS clause (for drop_policy)')
+}).strict();
+
+// Consolidated Row-Level Security Management Tool
+export const manageRLSTool: PostgresTool = {
+ name: 'pg_manage_rls',
+ description: 'Manage PostgreSQL Row-Level Security - enable/disable RLS and manage policies. Examples: operation="enable" with tableName="users", operation="create_policy" with tableName, policyName, using, check',
+ inputSchema: ManageRLSInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = ManageRLSInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const {
+ connectionString: connStringArg,
+ operation,
+ tableName,
schema,
policyName,
using,
check,
command,
role,
- replace,
- roles,
- ifExists
- } = args as {
- connectionString?: string;
- operation: 'enable' | 'disable' | 'create_policy' | 'edit_policy' | 'drop_policy' | 'get_policies';
- tableName?: string;
- schema?: string;
- policyName?: string;
- using?: string;
- check?: string;
- command?: 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
- role?: string;
- replace?: boolean;
- roles?: string[];
- ifExists?: boolean;
- };
-
- const resolvedConnString = getConnectionStringVal(connStringArg);
- let result: FunctionResult;
-
- try {
- switch (operation) {
+ replace,
+ roles,
+ ifExists
+ } = validationResult.data;
+
+ let result: FunctionResult;
+
+ try {
+ switch (operation) {
case 'enable': {
- if (!tableName) {
- return {
- content: [{ type: 'text', text: 'Error: tableName is required for enable operation' }],
- isError: true
- };
- }
- result = await _enableRLS(resolvedConnString, tableName, schema);
- break;
- }
+ if (!tableName) {
+ return {
+ content: [{ type: 'text', text: 'Error: tableName is required for enable operation' }],
+ isError: true
+ };
+ }
+ quoteQualifiedIdent(tableName, schema || 'public');
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ result = await _enableRLS(resolvedConnString, tableName, schema);
+ break;
+ }
case 'disable': {
- if (!tableName) {
- return {
- content: [{ type: 'text', text: 'Error: tableName is required for disable operation' }],
- isError: true
- };
- }
- result = await _disableRLS(resolvedConnString, tableName, schema);
- break;
- }
+ if (!tableName) {
+ return {
+ content: [{ type: 'text', text: 'Error: tableName is required for disable operation' }],
+ isError: true
+ };
+ }
+ quoteQualifiedIdent(tableName, schema || 'public');
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ result = await _disableRLS(resolvedConnString, tableName, schema);
+ break;
+ }
case 'create_policy': {
- if (!tableName || !policyName || !using) {
- return {
- content: [{ type: 'text', text: 'Error: tableName, policyName, and using are required for create_policy operation' }],
- isError: true
- };
- }
- result = await _createRLSPolicy(resolvedConnString, tableName, policyName, using, check, {
- schema,
- command,
+ if (!tableName || !policyName || !using) {
+ return {
+ content: [{ type: 'text', text: 'Error: tableName, policyName, and using are required for create_policy operation' }],
+ isError: true
+ };
+ }
+ quoteQualifiedIdent(tableName, schema || 'public');
+ quoteIdent(policyName);
+ if (role) {
+ quoteRoleName(role);
+ }
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ result = await _createRLSPolicy(resolvedConnString, tableName, policyName, using, check, {
+ schema,
+ command,
role,
replace
});
@@ -1007,15 +1129,21 @@ export const manageRLSTool: PostgresTool = {
}
case 'edit_policy': {
- if (!tableName || !policyName) {
- return {
- content: [{ type: 'text', text: 'Error: tableName and policyName are required for edit_policy operation' }],
- isError: true
- };
- }
- result = await _editRLSPolicy(resolvedConnString, tableName, policyName, {
- schema,
- roles,
+ if (!tableName || !policyName) {
+ return {
+ content: [{ type: 'text', text: 'Error: tableName and policyName are required for edit_policy operation' }],
+ isError: true
+ };
+ }
+ quoteQualifiedIdent(tableName, schema || 'public');
+ quoteIdent(policyName);
+ if (roles) {
+ roles.forEach(quoteRoleName);
+ }
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ result = await _editRLSPolicy(resolvedConnString, tableName, policyName, {
+ schema,
+ roles,
using,
check
});
@@ -1023,23 +1151,27 @@ export const manageRLSTool: PostgresTool = {
}
case 'drop_policy': {
- if (!tableName || !policyName) {
- return {
- content: [{ type: 'text', text: 'Error: tableName and policyName are required for drop_policy operation' }],
- isError: true
- };
- }
- result = await _dropRLSPolicy(resolvedConnString, tableName, policyName, {
- schema,
- ifExists
+ if (!tableName || !policyName) {
+ return {
+ content: [{ type: 'text', text: 'Error: tableName and policyName are required for drop_policy operation' }],
+ isError: true
+ };
+ }
+ quoteQualifiedIdent(tableName, schema || 'public');
+ quoteIdent(policyName);
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ result = await _dropRLSPolicy(resolvedConnString, tableName, policyName, {
+ schema,
+ ifExists
});
break;
}
-
- case 'get_policies': {
- result = await _getRLSPolicies(resolvedConnString, tableName, schema);
- if (result.success) {
- return { content: [{ type: 'text', text: JSON.stringify(result.details, null, 2) || result.message }] };
+
+ case 'get_policies': {
+ const resolvedConnString = getConnectionStringVal(connStringArg);
+ result = await _getRLSPolicies(resolvedConnString, tableName, schema);
+ if (result.success) {
+ return { content: [{ type: 'text', text: JSON.stringify(result.details, null, 2) || result.message }] };
}
break;
}
@@ -1057,10 +1189,10 @@ export const manageRLSTool: PostgresTool = {
return { content: [{ type: 'text', text: result.message }], isError: true };
} catch (error) {
- return {
- content: [{ type: 'text', text: `Error executing ${operation} operation: ${error instanceof Error ? error.message : String(error)}` }],
- isError: true
- };
+ return {
+ content: [{ type: 'text', text: `Error executing ${operation} operation: ${sanitizeErrorMessage(error)}` }],
+ isError: true
+ };
}
}
};
diff --git a/src/tools/indexes.test.ts b/src/tools/indexes.test.ts
new file mode 100644
index 0000000..1837c76
--- /dev/null
+++ b/src/tools/indexes.test.ts
@@ -0,0 +1,274 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import { manageIndexesTool } from './indexes';
+
+describe('manageIndexesTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('creates indexes with quoted identifiers and structured partial predicates', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'create',
+ indexName: 'idx_users_active_email',
+ tableName: 'users',
+ columns: ['email'],
+ schema: 'public',
+ unique: true,
+ where: {
+ active: true,
+ deleted_at: { isNull: true }
+ }
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith(
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "idx_users_active_email" ON "users" ("email") WHERE "active" = TRUE AND "deleted_at" IS NULL'
+ );
+ expect(result.content[0].text).toContain('"predicateSet":true');
+ expect(result.content[0].text).not.toContain('CREATE UNIQUE INDEX');
+ });
+
+ it('does not echo raw partial-index predicates in create success responses', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'create',
+ indexName: 'idx_sessions_token',
+ tableName: 'sessions',
+ columns: ['token'],
+ rawWhere: "token <> 'raw-index-secret'"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith(
+ 'CREATE INDEX IF NOT EXISTS "idx_sessions_token" ON "sessions" ("token") WHERE token <> \'raw-index-secret\''
+ );
+ expect(result.content[0].text).toContain('"predicateSet":true');
+ expect(result.content[0].text).not.toContain('raw-index-secret');
+ expect(result.content[0].text).not.toContain('token <>');
+ });
+
+ it('redacts index definitions returned from catalog lookups', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([{
+ schemaname: 'public',
+ tablename: 'sessions',
+ indexname: 'idx_sessions_token',
+ indexdef: "CREATE INDEX idx_sessions_token ON sessions USING btree (token) WHERE token = 'raw-index-secret'",
+ size: '16 kB'
+ }]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'get',
+ includeStats: false
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(output).toContain("token = '?'");
+ expect(output).not.toContain('raw-index-secret');
+ });
+
+ it('rejects legacy string partial-index predicates before connecting', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'create',
+ indexName: 'idx_sessions_token',
+ tableName: 'sessions',
+ columns: ['token'],
+ where: "token <> 'raw-index-secret'"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('String where predicates are not allowed');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown index-management fields before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'create',
+ indexName: 'idx_sessions_token',
+ tableName: 'sessions',
+ columns: ['token'],
+ rawSql: 'DROP INDEX idx_sessions_token'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown structured partial-index operators before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'create',
+ indexName: 'idx_sessions_token',
+ tableName: 'sessions',
+ columns: ['token'],
+ where: {
+ token: { raw: "token <> 'secret'" }
+ }
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('sanitizes index database errors before returning them', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockRejectedValue(
+ new Error("password=db-secret failed near WHERE token = 'raw-index-secret'")
+ ),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'create',
+ indexName: 'idx_sessions_token',
+ tableName: 'sessions',
+ columns: ['token'],
+ rawWhere: "token = 'raw-index-secret'"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Failed to create index');
+ expect(result.content[0].text).toContain('password=*****');
+ expect(result.content[0].text).toContain("token = '?'");
+ expect(result.content[0].text).not.toContain('db-secret');
+ expect(result.content[0].text).not.toContain('raw-index-secret');
+ });
+
+ it('rejects unsafe identifiers before connecting', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'create',
+ indexName: 'idx_users_email',
+ tableName: 'users; drop table users',
+ columns: ['email']
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe drop-index identifiers before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'drop',
+ indexName: 'idx_users_email; drop table users'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe reindex targets before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'reindex',
+ target: 'users; drop table users',
+ type: 'table'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects negative index analysis size filters before resolving a connection string', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await manageIndexesTool.execute({
+ operation: 'analyze_usage',
+ minSizeBytes: -1
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('minSizeBytes');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/tools/indexes.ts b/src/tools/indexes.ts
index d869ff2..c4bf8e5 100644
--- a/src/tools/indexes.ts
+++ b/src/tools/indexes.ts
@@ -1,7 +1,31 @@
-import { DatabaseConnection } from '../utils/connection.js';
-import { z } from 'zod';
-import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
-import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import { DatabaseConnection, sanitizeErrorMessage } from '../utils/connection.js';
+import { z } from 'zod';
+import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
+import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import {
+ buildStaticWhereClause,
+ quoteIdent,
+ quoteQualifiedIdent,
+ redactSqlText,
+ type WherePredicate
+} from '../utils/sql.js';
+
+const SqlScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
+const WhereOperatorSchema = z.object({
+ eq: SqlScalarSchema.optional(),
+ ne: SqlScalarSchema.optional(),
+ gt: SqlScalarSchema.optional(),
+ gte: SqlScalarSchema.optional(),
+ lt: SqlScalarSchema.optional(),
+ lte: SqlScalarSchema.optional(),
+ like: z.string().optional(),
+ ilike: z.string().optional(),
+ in: z.array(SqlScalarSchema).optional(),
+ isNull: z.boolean().optional()
+}).strict().refine((value) => Object.values(value).filter((item) => item !== undefined).length === 1, {
+ message: 'Each where predicate must specify exactly one operator'
+});
+const WherePredicateSchema = z.record(z.union([SqlScalarSchema, WhereOperatorSchema]));
interface IndexInfo {
schemaname: string;
@@ -14,7 +38,7 @@ interface IndexInfo {
tuples_fetched: number;
}
-interface IndexUsageStats {
+interface IndexUsageStats {
schemaname: string;
tablename: string;
indexname: string;
@@ -25,16 +49,28 @@ interface IndexUsageStats {
size_pretty: string;
is_unique: boolean;
is_primary: boolean;
- usage_ratio: number;
-}
-
-// --- Get Indexes Tool ---
-const GetIndexesInputSchema = z.object({
- connectionString: z.string().optional(),
- schema: z.string().optional().default('public').describe("Schema name"),
- tableName: z.string().optional().describe("Optional table name to filter indexes"),
- includeStats: z.boolean().optional().default(true).describe("Include usage statistics"),
-});
+ usage_ratio: number;
+}
+
+function toInternalError(prefix: string, error: unknown): McpError {
+ if (error instanceof McpError) {
+ return error;
+ }
+
+ return new McpError(ErrorCode.InternalError, `${prefix}: ${sanitizeErrorMessage(error)}`);
+}
+
+function formatValidationError(error: z.ZodError): string {
+ return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', ');
+}
+
+// --- Get Indexes Tool ---
+const GetIndexesInputSchema = z.object({
+ connectionString: z.string().optional(),
+ schema: z.string().optional().default('public').describe("Schema name"),
+ tableName: z.string().optional().describe("Optional table name to filter indexes"),
+ includeStats: z.boolean().optional().default(true).describe("Include usage statistics"),
+}).strict();
type GetIndexesInput = z.infer;
async function executeGetIndexes(
@@ -91,10 +127,13 @@ async function executeGetIndexes(
`;
const params = tableName ? [schema, tableName] : [schema];
- const results = await db.query(basicQuery, params);
- return results;
- } catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to get indexes: ${error instanceof Error ? error.message : String(error)}`);
+ const results = await db.query(basicQuery, params);
+ return results.map((row) => ({
+ ...row,
+ indexdef: redactSqlText(row.indexdef)
+ }));
+ } catch (error) {
+ throw toInternalError('Failed to get indexes', error);
} finally {
await db.disconnect();
}
@@ -105,10 +144,10 @@ export const getIndexesTool: PostgresTool = {
description: 'List indexes with size and usage statistics',
inputSchema: GetIndexesInputSchema,
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
- const validationResult = GetIndexesInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = GetIndexesInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeGetIndexes(validationResult.data, getConnectionString);
const message = validationResult.data.tableName
@@ -116,53 +155,70 @@ export const getIndexesTool: PostgresTool = {
: `Indexes in schema ${validationResult.data.schema}`;
return { content: [{ type: 'text', text: message }, { type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error getting indexes: ${errorMessage}` }], isError: true };
}
}
};
// --- Create Index Tool ---
-const CreateIndexInputSchema = z.object({
+const CreateIndexInputSchema = z.object({
connectionString: z.string().optional(),
indexName: z.string().describe("Name of the index to create"),
tableName: z.string().describe("Table to create index on"),
columns: z.array(z.string()).min(1).describe("Column names for the index"),
schema: z.string().optional().default('public').describe("Schema name"),
unique: z.boolean().optional().default(false).describe("Create unique index"),
- concurrent: z.boolean().optional().default(false).describe("Create index concurrently"),
- method: z.enum(['btree', 'hash', 'gist', 'spgist', 'gin', 'brin']).optional().default('btree').describe("Index method"),
- where: z.string().optional().describe("WHERE clause for partial index"),
- ifNotExists: z.boolean().optional().default(true).describe("Include IF NOT EXISTS clause"),
-});
+ concurrent: z.boolean().optional().default(false).describe("Create index concurrently"),
+ method: z.enum(['btree', 'hash', 'gist', 'spgist', 'gin', 'brin']).optional().default('btree').describe("Index method"),
+ where: z.union([WherePredicateSchema, z.string()]).optional().describe("Structured WHERE predicate for partial index. Legacy string predicates are unsafe."),
+ rawWhere: z.string().optional().describe("Unsafe raw WHERE SQL clause for trusted local/admin use only"),
+ ifNotExists: z.boolean().optional().default(true).describe("Include IF NOT EXISTS clause"),
+}).strict();
type CreateIndexInput = z.infer;
-async function executeCreateIndex(
- input: CreateIndexInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ indexName: string; tableName: string; definition: string }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const { indexName, tableName, columns, schema, unique, concurrent, method, where, ifNotExists } = input;
-
- try {
- await db.connect(resolvedConnectionString);
-
- const schemaPrefix = schema !== 'public' ? `"${schema}".` : '';
- const uniqueClause = unique ? 'UNIQUE ' : '';
- const concurrentClause = concurrent ? 'CONCURRENTLY ' : '';
- const ifNotExistsClause = ifNotExists ? 'IF NOT EXISTS ' : '';
- const columnsClause = columns.map(col => `"${col}"`).join(', ');
- const methodClause = method !== 'btree' ? ` USING ${method}` : '';
- const whereClause = where ? ` WHERE ${where}` : '';
-
- const createIndexSQL = `CREATE ${uniqueClause}INDEX ${concurrentClause}${ifNotExistsClause}"${indexName}" ON ${schemaPrefix}"${tableName}"${methodClause} (${columnsClause})${whereClause}`;
-
- await db.query(createIndexSQL);
-
- return { indexName, tableName, definition: createIndexSQL };
- } catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to create index: ${error instanceof Error ? error.message : String(error)}`);
+async function executeCreateIndex(
+ input: CreateIndexInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ indexName: string; tableName: string; schema: string; columnCount: number; predicateSet: boolean; created: true }> {
+ const db = DatabaseConnection.getInstance();
+ const { indexName, tableName, columns, schema, unique, concurrent, method, where, rawWhere, ifNotExists } = input;
+
+ try {
+ const qualifiedIndexName = quoteQualifiedIdent(indexName, schema);
+ const qualifiedTableName = quoteQualifiedIdent(tableName, schema);
+ const uniqueClause = unique ? 'UNIQUE ' : '';
+ const concurrentClause = concurrent ? 'CONCURRENTLY ' : '';
+ const ifNotExistsClause = ifNotExists ? 'IF NOT EXISTS ' : '';
+ const columnsClause = columns.map(quoteIdent).join(', ');
+ const methodClause = method !== 'btree' ? ` USING ${method}` : '';
+ if (where && typeof where === 'string') {
+ throw new McpError(ErrorCode.InvalidParams, 'String where predicates are not allowed. Use structured where predicates or rawWhere for trusted local/admin SQL.');
+ }
+ const whereClause = rawWhere
+ ? ` WHERE ${rawWhere}`
+ : where && typeof where !== 'string'
+ ? ` WHERE ${buildStaticWhereClause(where as WherePredicate)}`
+ : where
+ ? ` WHERE ${where}`
+ : '';
+
+ const createIndexSQL = `CREATE ${uniqueClause}INDEX ${concurrentClause}${ifNotExistsClause}${qualifiedIndexName} ON ${qualifiedTableName}${methodClause} (${columnsClause})${whereClause}`;
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
+ await db.connect(resolvedConnectionString);
+ await db.query(createIndexSQL);
+
+ return {
+ indexName,
+ tableName,
+ schema,
+ columnCount: columns.length,
+ predicateSet: Boolean(whereClause),
+ created: true
+ };
+ } catch (error) {
+ throw toInternalError('Failed to create index', error);
} finally {
await db.disconnect();
}
@@ -173,54 +229,53 @@ export const createIndexTool: PostgresTool = {
description: 'Create a new index on a table',
inputSchema: CreateIndexInputSchema,
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
- const validationResult = CreateIndexInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = CreateIndexInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeCreateIndex(validationResult.data, getConnectionString);
return { content: [{ type: 'text', text: `Index ${result.indexName} created successfully.` }, { type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error creating index: ${errorMessage}` }], isError: true };
}
}
};
// --- Drop Index Tool ---
-const DropIndexInputSchema = z.object({
+const DropIndexInputSchema = z.object({
connectionString: z.string().optional(),
indexName: z.string().describe("Name of the index to drop"),
schema: z.string().optional().default('public').describe("Schema name"),
- concurrent: z.boolean().optional().default(false).describe("Drop index concurrently"),
- ifExists: z.boolean().optional().default(true).describe("Include IF EXISTS clause"),
- cascade: z.boolean().optional().default(false).describe("Include CASCADE clause"),
-});
+ concurrent: z.boolean().optional().default(false).describe("Drop index concurrently"),
+ ifExists: z.boolean().optional().default(true).describe("Include IF EXISTS clause"),
+ cascade: z.boolean().optional().default(false).describe("Include CASCADE clause"),
+}).strict();
type DropIndexInput = z.infer;
-async function executeDropIndex(
- input: DropIndexInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ indexName: string; schema: string }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
- const { indexName, schema, concurrent, ifExists, cascade } = input;
+async function executeDropIndex(
+ input: DropIndexInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ indexName: string; schema: string }> {
+ const db = DatabaseConnection.getInstance();
+ const { indexName, schema, concurrent, ifExists, cascade } = input;
try {
- await db.connect(resolvedConnectionString);
-
- const schemaPrefix = schema !== 'public' ? `"${schema}".` : '';
- const concurrentClause = concurrent ? 'CONCURRENTLY ' : '';
- const ifExistsClause = ifExists ? 'IF EXISTS ' : '';
- const cascadeClause = cascade ? ' CASCADE' : '';
-
- const dropIndexSQL = `DROP INDEX ${concurrentClause}${ifExistsClause}${schemaPrefix}"${indexName}"${cascadeClause}`;
-
- await db.query(dropIndexSQL);
-
+ const qualifiedIndexName = quoteQualifiedIdent(indexName, schema);
+ const concurrentClause = concurrent ? 'CONCURRENTLY ' : '';
+ const ifExistsClause = ifExists ? 'IF EXISTS ' : '';
+ const cascadeClause = cascade ? ' CASCADE' : '';
+
+ const dropIndexSQL = `DROP INDEX ${concurrentClause}${ifExistsClause}${qualifiedIndexName}${cascadeClause}`;
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
+ await db.connect(resolvedConnectionString);
+ await db.query(dropIndexSQL);
+
return { indexName, schema };
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to drop index: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to drop index', error);
} finally {
await db.disconnect();
}
@@ -231,68 +286,65 @@ export const dropIndexTool: PostgresTool = {
description: 'Drop an existing index',
inputSchema: DropIndexInputSchema,
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
- const validationResult = DropIndexInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = DropIndexInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeDropIndex(validationResult.data, getConnectionString);
return { content: [{ type: 'text', text: `Index ${result.indexName} dropped successfully.` }, { type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error dropping index: ${errorMessage}` }], isError: true };
}
}
};
// --- Reindex Tool ---
-const ReindexInputSchema = z.object({
+const ReindexInputSchema = z.object({
connectionString: z.string().optional(),
target: z.string().describe("Index name, table name, or schema name to reindex"),
- type: z.enum(['index', 'table', 'schema', 'database']).describe("Type of target to reindex"),
- schema: z.string().optional().default('public').describe("Schema name (for table/index targets)"),
- concurrent: z.boolean().optional().default(false).describe("Reindex concurrently (PostgreSQL 12+)"),
-});
+ type: z.enum(['index', 'table', 'schema', 'database']).describe("Type of target to reindex"),
+ schema: z.string().optional().default('public').describe("Schema name (for table/index targets)"),
+ concurrent: z.boolean().optional().default(false).describe("Reindex concurrently (PostgreSQL 12+)"),
+}).strict();
type ReindexInput = z.infer;
-async function executeReindex(
- input: ReindexInput,
- getConnectionString: GetConnectionStringFn
-): Promise<{ target: string; type: string; schema?: string }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
- const db = DatabaseConnection.getInstance();
+async function executeReindex(
+ input: ReindexInput,
+ getConnectionString: GetConnectionStringFn
+): Promise<{ target: string; type: string; schema?: string }> {
+ const db = DatabaseConnection.getInstance();
const { target, type, schema, concurrent } = input;
try {
- await db.connect(resolvedConnectionString);
-
- let reindexSQL = '';
- const concurrentClause = concurrent ? ' CONCURRENTLY' : '';
-
- switch (type) {
- case 'index': {
- const schemaPrefix = schema !== 'public' ? `"${schema}".` : '';
- reindexSQL = `REINDEX${concurrentClause} INDEX ${schemaPrefix}"${target}"`;
- break;
- }
- case 'table': {
- const tableSchemaPrefix = schema !== 'public' ? `"${schema}".` : '';
- reindexSQL = `REINDEX${concurrentClause} TABLE ${tableSchemaPrefix}"${target}"`;
- break;
- }
- case 'schema':
- reindexSQL = `REINDEX${concurrentClause} SCHEMA "${target}"`;
- break;
- case 'database':
- reindexSQL = `REINDEX${concurrentClause} DATABASE "${target}"`;
- break;
- }
-
- await db.query(reindexSQL);
-
+ let reindexSQL = '';
+ const concurrentClause = concurrent ? ' CONCURRENTLY' : '';
+
+ switch (type) {
+ case 'index': {
+ reindexSQL = `REINDEX${concurrentClause} INDEX ${quoteQualifiedIdent(target, schema)}`;
+ break;
+ }
+ case 'table': {
+ reindexSQL = `REINDEX${concurrentClause} TABLE ${quoteQualifiedIdent(target, schema)}`;
+ break;
+ }
+ case 'schema':
+ reindexSQL = `REINDEX${concurrentClause} SCHEMA ${quoteIdent(target)}`;
+ break;
+ case 'database':
+ reindexSQL = `REINDEX${concurrentClause} DATABASE ${quoteIdent(target)}`;
+ break;
+ }
+
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+ await db.connect(resolvedConnectionString);
+ await db.query(reindexSQL);
+
return { target, type, schema };
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to reindex: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to reindex', error);
} finally {
await db.disconnect();
}
@@ -303,29 +355,29 @@ export const reindexTool: PostgresTool = {
description: 'Rebuild indexes to improve performance and reclaim space',
inputSchema: ReindexInputSchema,
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
- const validationResult = ReindexInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = ReindexInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeReindex(validationResult.data, getConnectionString);
return { content: [{ type: 'text', text: `Reindex completed successfully for ${result.type} ${result.target}.` }, { type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error during reindex: ${errorMessage}` }], isError: true };
}
}
};
// --- Analyze Index Usage Tool ---
-const AnalyzeIndexUsageInputSchema = z.object({
+const AnalyzeIndexUsageInputSchema = z.object({
connectionString: z.string().optional(),
schema: z.string().optional().default('public').describe("Schema name"),
tableName: z.string().optional().describe("Optional table name to filter"),
- minSizeBytes: z.number().optional().describe("Minimum index size in bytes to include"),
- showUnused: z.boolean().optional().default(true).describe("Include indexes with zero scans"),
- showDuplicates: z.boolean().optional().default(true).describe("Detect potentially duplicate indexes"),
-});
+ minSizeBytes: z.number().min(0).optional().describe("Minimum index size in bytes to include"),
+ showUnused: z.boolean().optional().default(true).describe("Include indexes with zero scans"),
+ showDuplicates: z.boolean().optional().default(true).describe("Detect potentially duplicate indexes"),
+}).strict();
type AnalyzeIndexUsageInput = z.infer;
interface IndexAnalysis {
@@ -418,11 +470,11 @@ async function executeAnalyzeIndexUsage(
index_count: number;
}>(duplicateQuery, tableName ? [schema, tableName] : [schema]);
- duplicate_indexes = duplicateResults.map(row => ({
- table_name: row.tablename,
- columns: row.definitions,
- indexes: row.index_names
- }));
+ duplicate_indexes = duplicateResults.map(row => ({
+ table_name: row.tablename,
+ columns: redactSqlText(row.definitions),
+ indexes: row.index_names
+ }));
}
const totalSizeBytes = allIndexes.reduce((sum, idx) => sum + idx.size_bytes, 0);
@@ -444,7 +496,7 @@ async function executeAnalyzeIndexUsage(
};
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to analyze index usage: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to analyze index usage', error);
} finally {
await db.disconnect();
}
@@ -455,15 +507,15 @@ async function formatBytes(db: DatabaseConnection, bytes: number): Promise {
- const validationResult = AnalyzeIndexUsageInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = AnalyzeIndexUsageInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeAnalyzeIndexUsage(validationResult.data, getConnectionString);
const message = validationResult.data.tableName
@@ -471,55 +523,62 @@ export const analyzeIndexUsageTool: PostgresTool = {
: `Index usage analysis for schema ${validationResult.data.schema}`;
return { content: [{ type: 'text', text: message }, { type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error analyzing index usage: ${errorMessage}` }], isError: true };
}
- }
-};
-
-// Consolidated Index Management Tool
-export const manageIndexesTool: PostgresTool = {
- name: 'pg_manage_indexes',
- description: 'Manage PostgreSQL indexes - get, create, drop, reindex, and analyze usage with a single tool. Examples: operation="get" to list indexes, operation="create" with indexName, tableName, columns, operation="analyze_usage" for performance analysis',
- inputSchema: z.object({
- connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
- operation: z.enum(['get', 'create', 'drop', 'reindex', 'analyze_usage']).describe('Operation: get (list indexes), create (new index), drop (remove index), reindex (rebuild), analyze_usage (find unused/duplicate)'),
-
- // Common parameters
- schema: z.string().optional().describe('Schema name (defaults to public)'),
- tableName: z.string().optional().describe('Table name (optional for get/analyze_usage, required for create)'),
- indexName: z.string().optional().describe('Index name (required for create/drop)'),
-
- // Get operation parameters
- includeStats: z.boolean().optional().describe('Include usage statistics (for get operation)'),
-
- // Create operation parameters
- columns: z.array(z.string()).optional().describe('Column names for the index (required for create operation)'),
- unique: z.boolean().optional().describe('Create unique index (for create operation)'),
- concurrent: z.boolean().optional().describe('Create/drop index concurrently (for create/drop operations)'),
- method: z.enum(['btree', 'hash', 'gist', 'spgist', 'gin', 'brin']).optional().describe('Index method (for create operation, defaults to btree)'),
- where: z.string().optional().describe('WHERE clause for partial index (for create operation)'),
- ifNotExists: z.boolean().optional().describe('Include IF NOT EXISTS clause (for create operation)'),
-
- // Drop operation parameters
- ifExists: z.boolean().optional().describe('Include IF EXISTS clause (for drop operation)'),
- cascade: z.boolean().optional().describe('Include CASCADE clause (for drop operation)'),
-
- // Reindex operation parameters
- target: z.string().optional().describe('Target name for reindex (required for reindex operation)'),
- type: z.enum(['index', 'table', 'schema', 'database']).optional().describe('Type of target for reindex (required for reindex operation)'),
-
- // Analyze usage parameters
- minSizeBytes: z.number().optional().describe('Minimum index size in bytes (for analyze_usage operation)'),
- showUnused: z.boolean().optional().describe('Include unused indexes (for analyze_usage operation)'),
- showDuplicates: z.boolean().optional().describe('Detect duplicate indexes (for analyze_usage operation)')
- }),
- // biome-ignore lint/suspicious/noExplicitAny:
- execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => {
- const {
- connectionString: connStringArg,
- operation,
- schema,
+ }
+};
+
+const ManageIndexesInputSchema = z.object({
+ connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'),
+ operation: z.enum(['get', 'create', 'drop', 'reindex', 'analyze_usage']).describe('Operation: get (list indexes), create (new index), drop (remove index), reindex (rebuild), analyze_usage (find unused/duplicate)'),
+
+ // Common parameters
+ schema: z.string().optional().describe('Schema name (defaults to public)'),
+ tableName: z.string().optional().describe('Table name (optional for get/analyze_usage, required for create)'),
+ indexName: z.string().optional().describe('Index name (required for create/drop)'),
+
+ // Get operation parameters
+ includeStats: z.boolean().optional().describe('Include usage statistics (for get operation)'),
+
+ // Create operation parameters
+ columns: z.array(z.string()).optional().describe('Column names for the index (required for create operation)'),
+ unique: z.boolean().optional().describe('Create unique index (for create operation)'),
+ concurrent: z.boolean().optional().describe('Create/drop index concurrently (for create/drop operations)'),
+ method: z.enum(['btree', 'hash', 'gist', 'spgist', 'gin', 'brin']).optional().describe('Index method (for create operation, defaults to btree)'),
+ where: z.union([WherePredicateSchema, z.string()]).optional().describe('Structured WHERE predicate for partial index (for create operation). Legacy string predicates are unsafe.'),
+ rawWhere: z.string().optional().describe('Unsafe raw WHERE SQL clause for trusted local/admin use only'),
+ ifNotExists: z.boolean().optional().describe('Include IF NOT EXISTS clause (for create operation)'),
+
+ // Drop operation parameters
+ ifExists: z.boolean().optional().describe('Include IF EXISTS clause (for drop operation)'),
+ cascade: z.boolean().optional().describe('Include CASCADE clause (for drop operation)'),
+
+ // Reindex operation parameters
+ target: z.string().optional().describe('Target name for reindex (required for reindex operation)'),
+ type: z.enum(['index', 'table', 'schema', 'database']).optional().describe('Type of target for reindex (required for reindex operation)'),
+
+ // Analyze usage parameters
+ minSizeBytes: z.number().min(0).optional().describe('Minimum index size in bytes (for analyze_usage operation)'),
+ showUnused: z.boolean().optional().describe('Include unused indexes (for analyze_usage operation)'),
+ showDuplicates: z.boolean().optional().describe('Detect duplicate indexes (for analyze_usage operation)')
+}).strict();
+
+// Consolidated Index Management Tool
+export const manageIndexesTool: PostgresTool = {
+ name: 'pg_manage_indexes',
+ description: 'Manage PostgreSQL indexes - get, create, drop, reindex, and analyze usage with a single tool. Examples: operation="get" to list indexes, operation="create" with indexName, tableName, columns, operation="analyze_usage" for performance analysis',
+ inputSchema: ManageIndexesInputSchema,
+ execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => {
+ const validationResult = ManageIndexesInputSchema.safeParse(args);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
+
+ const {
+ connectionString: connStringArg,
+ operation,
+ schema,
tableName,
indexName,
includeStats,
@@ -527,36 +586,17 @@ export const manageIndexesTool: PostgresTool = {
unique,
concurrent,
method,
- where,
+ where,
+ rawWhere,
ifNotExists,
ifExists,
cascade,
target,
type,
- minSizeBytes,
- showUnused,
- showDuplicates
- } = args as {
- connectionString?: string;
- operation: 'get' | 'create' | 'drop' | 'reindex' | 'analyze_usage';
- schema?: string;
- tableName?: string;
- indexName?: string;
- includeStats?: boolean;
- columns?: string[];
- unique?: boolean;
- concurrent?: boolean;
- method?: 'btree' | 'hash' | 'gist' | 'spgist' | 'gin' | 'brin';
- where?: string;
- ifNotExists?: boolean;
- ifExists?: boolean;
- cascade?: boolean;
- target?: string;
- type?: 'index' | 'table' | 'schema' | 'database';
- minSizeBytes?: number;
- showUnused?: boolean;
- showDuplicates?: boolean;
- };
+ minSizeBytes,
+ showUnused,
+ showDuplicates
+ } = validationResult.data;
try {
switch (operation) {
@@ -588,9 +628,10 @@ export const manageIndexesTool: PostgresTool = {
schema: schema ?? 'public',
unique: unique ?? false,
concurrent: concurrent ?? false,
- method: method ?? 'btree',
- where,
- ifNotExists: ifNotExists ?? true
+ method: method ?? 'btree',
+ where,
+ rawWhere,
+ ifNotExists: ifNotExists ?? true
}, getConnectionStringVal);
return { content: [{ type: 'text', text: `Index ${result.indexName} created successfully. Details: ${JSON.stringify(result)}` }] };
}
@@ -650,11 +691,11 @@ export const manageIndexesTool: PostgresTool = {
}
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return {
content: [{ type: 'text', text: `Error executing ${operation} operation: ${errorMessage}` }],
isError: true
};
}
}
-};
\ No newline at end of file
+};
diff --git a/src/tools/migration.test.ts b/src/tools/migration.test.ts
new file mode 100644
index 0000000..b4cf759
--- /dev/null
+++ b/src/tools/migration.test.ts
@@ -0,0 +1,577 @@
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import { copyBetweenDatabasesTool, exportTableDataTool, importTableDataTool } from './migration';
+
+const originalWorkspace = process.env.POSTGRES_MCP_WORKSPACE_DIR;
+const originalMaxBytes = process.env.POSTGRES_MCP_MAX_FILE_BYTES;
+
+async function makeWorkspace(): Promise {
+ return fs.promises.mkdtemp(path.join(os.tmpdir(), 'postgres-mcp-migration-'));
+}
+
+describe('migration tools', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ if (originalWorkspace === undefined) {
+ delete process.env.POSTGRES_MCP_WORKSPACE_DIR;
+ } else {
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = originalWorkspace;
+ }
+
+ if (originalMaxBytes === undefined) {
+ delete process.env.POSTGRES_MCP_MAX_FILE_BYTES;
+ } else {
+ process.env.POSTGRES_MCP_MAX_FILE_BYTES = originalMaxBytes;
+ }
+ mockGetConnectionString.mockClear();
+ vi.restoreAllMocks();
+ });
+
+ it('rejects unknown export fields before resolving a connection', async () => {
+ const result = await exportTableDataTool.execute({
+ tableName: 'users',
+ outputPath: 'exports/users.json',
+ format: 'json',
+ unexpected: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(result.content[0].text).not.toContain('[object Object]');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ });
+
+ it('blocks export paths outside the configured workspace before connecting', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await exportTableDataTool.execute({
+ tableName: 'users',
+ outputPath: path.resolve(workspace, '..', 'outside.json'),
+ format: 'json'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('outside POSTGRES_MCP_WORKSPACE_DIR');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('exports with quoted identifiers and structured where predicates', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([{ id: 1, email: 'a@example.com' }]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await exportTableDataTool.execute({
+ tableName: 'users',
+ outputPath: 'exports/users.json',
+ format: 'json',
+ where: { status: 'active', id: { gte: 1 } },
+ limit: 25
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('to exports/users.json');
+ expect(result.content[0].text).not.toContain(workspace);
+ expect(mockDb.query).toHaveBeenCalledWith(
+ 'SELECT * FROM "users" WHERE "status" = $1 AND "id" >= $2 LIMIT $3',
+ ['active', 1, 25]
+ );
+ await expect(fs.promises.readFile(path.join(workspace, 'exports', 'users.json'), 'utf8')).resolves.toContain('a@example.com');
+ });
+
+ it('exports from the requested schema with a qualified table name', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([{ id: 1, event: 'login' }]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await exportTableDataTool.execute({
+ tableName: 'events',
+ schema: 'audit',
+ outputPath: 'exports/events.json',
+ format: 'json',
+ where: { id: { gte: 1 } },
+ limit: 10
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('from audit.events');
+ expect(mockDb.query).toHaveBeenCalledWith(
+ 'SELECT * FROM "audit"."events" WHERE "id" >= $1 LIMIT $2',
+ [1, 10]
+ );
+ });
+
+ it('exports CSV with escaped headers, quotes, delimiters, and newlines', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([{
+ 'full,name': 'Ada, Lovelace',
+ note: 'She said "hi"\nthen left',
+ padded: ' x ',
+ count: 2
+ }]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await exportTableDataTool.execute({
+ tableName: 'users',
+ outputPath: 'exports/users.csv',
+ format: 'csv'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.query).toHaveBeenCalledWith('SELECT * FROM "users" LIMIT $1', [1000]);
+ await expect(fs.promises.readFile(path.join(workspace, 'exports', 'users.csv'), 'utf8')).resolves.toBe([
+ '"full,name",note,padded,count',
+ '"Ada, Lovelace","She said ""hi""',
+ 'then left"," x ",2'
+ ].join('\n'));
+ });
+
+ it('rejects oversized export limits before resolving a connection', async () => {
+ const result = await exportTableDataTool.execute({
+ tableName: 'users',
+ outputPath: 'exports/users.json',
+ format: 'json',
+ limit: 100001
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Number must be less than or equal to 100000');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ });
+
+ it('rejects legacy string where predicates before connecting', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await exportTableDataTool.execute({
+ tableName: 'users',
+ outputPath: 'exports/users.json',
+ format: 'json',
+ where: "token = 'raw-migration-secret'"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('String where predicates are not allowed');
+ expect(result.content[0].text).not.toContain('raw-migration-secret');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown structured where operators before resolving a connection', async () => {
+ const result = await exportTableDataTool.execute({
+ tableName: 'users',
+ outputPath: 'exports/users.json',
+ format: 'json',
+ where: {
+ status: {
+ startsWith: 'active'
+ }
+ }
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).not.toContain('[object Object]');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ });
+
+ it('sanitizes export database errors before returning them', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockRejectedValue(
+ new Error("password=db-secret failed while exporting WHERE token = 'raw-migration-secret'")
+ ),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await exportTableDataTool.execute({
+ tableName: 'users',
+ outputPath: 'exports/users.json',
+ format: 'json',
+ rawWhere: "token = 'raw-migration-secret'"
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Failed to export data');
+ expect(result.content[0].text).toContain('password=*****');
+ expect(result.content[0].text).toContain("token = '?'");
+ expect(result.content[0].text).not.toContain('db-secret');
+ expect(result.content[0].text).not.toContain('raw-migration-secret');
+ });
+
+ it('rejects unknown import fields before resolving a connection', async () => {
+ const result = await importTableDataTool.execute({
+ tableName: 'users',
+ inputPath: 'users.json',
+ format: 'json',
+ unexpected: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(result.content[0].text).not.toContain('[object Object]');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ });
+
+ it('imports using sandboxed files and quoted identifiers', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+ await fs.promises.writeFile(path.join(workspace, 'users.json'), JSON.stringify([{ id: 1, email: 'a@example.com' }]));
+
+ const query = vi.fn().mockResolvedValue({ rows: [] });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise) => callback({ query })),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await importTableDataTool.execute({
+ tableName: 'users',
+ inputPath: 'users.json',
+ format: 'json'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO "users" ("id", "email")'), [1, 'a@example.com']);
+ });
+
+ it('rejects JSON import rows that are not objects before connecting', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+ await fs.promises.writeFile(path.join(workspace, 'users.json'), JSON.stringify([{ id: 1 }, null]));
+
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await importTableDataTool.execute({
+ tableName: 'users',
+ inputPath: 'users.json',
+ format: 'json'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Import record at index ? must be a JSON object');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.transaction).not.toHaveBeenCalled();
+ });
+
+ it('rejects JSON import array records before connecting', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+ await fs.promises.writeFile(path.join(workspace, 'users.json'), JSON.stringify([[1, 'a@example.com']]));
+
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await importTableDataTool.execute({
+ tableName: 'users',
+ inputPath: 'users.json',
+ format: 'json'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Import record at index ? must be a JSON object');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.transaction).not.toHaveBeenCalled();
+ });
+
+ it('imports into the requested schema and truncates the qualified table', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+ await fs.promises.writeFile(path.join(workspace, 'events.json'), JSON.stringify([{ id: 1, event: 'login' }]));
+
+ const query = vi.fn().mockResolvedValue({ rows: [] });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise) => callback({ query })),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await importTableDataTool.execute({
+ tableName: 'events',
+ schema: 'audit',
+ inputPath: 'events.json',
+ format: 'json',
+ truncateFirst: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('into audit.events');
+ expect(mockDb.query).toHaveBeenCalledWith('TRUNCATE TABLE "audit"."events"');
+ expect(query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO "audit"."events" ("id", "event")'), [1, 'login']);
+ });
+
+ it('imports CSV with escaped quotes, delimiters, CRLF rows, and quoted newlines', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+ await fs.promises.writeFile(
+ path.join(workspace, 'users.csv'),
+ [
+ '"id","email","note","empty"',
+ '"1","ada,l@example.com","Line ""one""',
+ 'Line two",'
+ ].join('\r\n')
+ );
+
+ const query = vi.fn().mockResolvedValue({ rows: [] });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([]),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise) => callback({ query })),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await importTableDataTool.execute({
+ tableName: 'users',
+ inputPath: 'users.csv',
+ format: 'csv'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(query).toHaveBeenCalledWith(
+ expect.stringContaining('INSERT INTO "users" ("id", "email", "note", "empty")'),
+ ['1', 'ada,l@example.com', 'Line "one"\r\nLine two', '']
+ );
+ });
+
+ it('rejects invalid CSV delimiters before connecting', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+ await fs.promises.writeFile(path.join(workspace, 'users.csv'), 'id,email\n1,a@example.com');
+
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await importTableDataTool.execute({
+ tableName: 'users',
+ inputPath: 'users.csv',
+ format: 'csv',
+ delimiter: '||'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('CSV delimiter must be a single non-quote, non-newline character');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('rejects CSV rows with more values than headers before connecting', async () => {
+ const workspace = await makeWorkspace();
+ process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace;
+ await fs.promises.writeFile(path.join(workspace, 'users.csv'), 'id,email\n1,a@example.com,unexpected');
+
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await importTableDataTool.execute({
+ tableName: 'users',
+ inputPath: 'users.csv',
+ format: 'csv'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('has more values than headers');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown copy fields before resolving a connection', async () => {
+ const result = await copyBetweenDatabasesTool.execute({
+ sourceConnectionString: 'postgresql://source',
+ targetConnectionString: 'postgresql://target',
+ tableName: 'users',
+ unexpected: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(result.content[0].text).not.toContain('[object Object]');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ });
+
+ it('resolves copy source and target connection strings before connecting', async () => {
+ const resolvingGetConnectionString = vi.fn((connectionString?: string) => `${connectionString}?resolved`);
+ const query = vi.fn().mockResolvedValue({ rows: [] });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([{ id: 1, email: 'a@example.com' }]),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise) => callback({ query })),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await copyBetweenDatabasesTool.execute({
+ sourceConnectionString: 'postgresql://source',
+ targetConnectionString: 'postgresql://target',
+ tableName: 'users',
+ where: { status: 'active' }
+ }, resolvingGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(resolvingGetConnectionString).toHaveBeenCalledWith('postgresql://source');
+ expect(resolvingGetConnectionString).toHaveBeenCalledWith('postgresql://target');
+ expect(mockDb.connect).toHaveBeenNthCalledWith(1, 'postgresql://source?resolved');
+ expect(mockDb.connect).toHaveBeenNthCalledWith(2, 'postgresql://target?resolved');
+ expect(mockDb.query).toHaveBeenCalledWith('SELECT * FROM "users" WHERE "status" = $1 LIMIT $2', ['active', 1000]);
+ expect(query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO "users" ("id", "email")'), [1, 'a@example.com']);
+ });
+
+ it('copies between the requested source and target schema', async () => {
+ const resolvingGetConnectionString = vi.fn((connectionString?: string) => `${connectionString}?resolved`);
+ const query = vi.fn().mockResolvedValue({ rows: [] });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn().mockResolvedValue([{ id: 1, event: 'login' }]),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise) => callback({ query })),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await copyBetweenDatabasesTool.execute({
+ sourceConnectionString: 'postgresql://source',
+ targetConnectionString: 'postgresql://target',
+ tableName: 'events',
+ schema: 'audit',
+ limit: 10,
+ truncateTarget: true
+ }, resolvingGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('to audit.events');
+ expect(mockDb.query).toHaveBeenNthCalledWith(1, 'SELECT * FROM "audit"."events" LIMIT $1', [10]);
+ expect(mockDb.query).toHaveBeenNthCalledWith(2, 'TRUNCATE TABLE "audit"."events"');
+ expect(query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO "audit"."events" ("id", "event")'), [1, 'login']);
+ });
+
+ it('rejects oversized copy limits before resolving connection strings', async () => {
+ const result = await copyBetweenDatabasesTool.execute({
+ sourceConnectionString: 'postgresql://source',
+ targetConnectionString: 'postgresql://target',
+ tableName: 'users',
+ limit: 100001
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Number must be less than or equal to 100000');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ });
+
+ it('rejects denied copy connection targets before connecting', async () => {
+ const rejectingGetConnectionString = vi.fn((connectionString?: string) => {
+ if (connectionString === 'postgresql://denied-source') {
+ throw new Error('Connection target "denied-source" is not allowed by the configured connection target allowlist.');
+ }
+
+ return connectionString ?? 'postgresql://fallback';
+ });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn(),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await copyBetweenDatabasesTool.execute({
+ sourceConnectionString: 'postgresql://denied-source',
+ targetConnectionString: 'postgresql://target',
+ tableName: 'users'
+ }, rejectingGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('not allowed by the configured connection target allowlist');
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ expect(mockDb.query).not.toHaveBeenCalled();
+ });
+
+ it('rejects unsafe copy table identifiers before resolving a connection', async () => {
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ query: vi.fn(),
+ transaction: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await copyBetweenDatabasesTool.execute({
+ sourceConnectionString: 'postgresql://source',
+ targetConnectionString: 'postgresql://target',
+ tableName: 'users; drop table users'
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid SQL identifier');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ expect(mockDb.connect).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/tools/migration.ts b/src/tools/migration.ts
index b6a14a6..22ed393 100644
--- a/src/tools/migration.ts
+++ b/src/tools/migration.ts
@@ -1,14 +1,244 @@
-import { DatabaseConnection } from '../utils/connection.js';
+import { DatabaseConnection, sanitizeErrorMessage } from '../utils/connection.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
-import { promisify } from 'node:util';
import { z } from 'zod';
import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import {
+ assertReadableSandboxFile,
+ assertWritableContentSize,
+ resolveSandboxPath
+} from '../utils/filesystem.js';
+import {
+ buildWhereClause,
+ quoteIdent,
+ quoteQualifiedIdent,
+ type WherePredicate
+} from '../utils/sql.js';
-const writeFile = promisify(fs.writeFile);
-const readFile = promisify(fs.readFile);
-const mkdir = promisify(fs.mkdir);
+const SqlScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
+const WhereOperatorSchema = z.object({
+ eq: SqlScalarSchema.optional(),
+ ne: SqlScalarSchema.optional(),
+ gt: SqlScalarSchema.optional(),
+ gte: SqlScalarSchema.optional(),
+ lt: SqlScalarSchema.optional(),
+ lte: SqlScalarSchema.optional(),
+ like: z.string().optional(),
+ ilike: z.string().optional(),
+ in: z.array(SqlScalarSchema).optional(),
+ isNull: z.boolean().optional()
+}).strict().refine((value) => Object.values(value).filter((item) => item !== undefined).length === 1, {
+ message: 'Each where predicate must specify exactly one operator'
+});
+const WherePredicateSchema = z.record(z.union([SqlScalarSchema, WhereOperatorSchema]));
+const DEFAULT_EXPORT_ROW_LIMIT = 1000;
+const MAX_EXPORT_ROW_LIMIT = 100000;
+const DEFAULT_COPY_ROW_LIMIT = 1000;
+const MAX_COPY_ROW_LIMIT = 100000;
+
+function formatValidationError(error: z.ZodError): string {
+ return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', ');
+}
+
+function buildSelectQuery(
+ tableName: string,
+ schema: string,
+ where: WherePredicate | string | undefined,
+ rawWhere: string | undefined,
+ limit?: number
+): { query: string; params: unknown[] } {
+ let query = `SELECT * FROM ${quoteQualifiedIdent(tableName, schema)}`;
+ let params: unknown[] = [];
+
+ if (rawWhere) {
+ query += ` WHERE ${rawWhere}`;
+ } else if (typeof where === 'string') {
+ throw new McpError(ErrorCode.InvalidParams, 'String where predicates are not allowed. Use structured where predicates or rawWhere for trusted local/admin SQL.');
+ } else if (where && Object.keys(where).length > 0) {
+ const whereClause = buildWhereClause(where);
+ query += ` WHERE ${whereClause.clause}`;
+ params = whereClause.values;
+ }
+
+ if (limit !== undefined) {
+ query += ` LIMIT $${params.length + 1}`;
+ params.push(limit);
+ }
+
+ return { query, params };
+}
+
+function toInternalError(prefix: string, error: unknown): McpError {
+ if (error instanceof McpError) {
+ return error;
+ }
+
+ return new McpError(ErrorCode.InternalError, `${prefix}: ${sanitizeErrorMessage(error)}`);
+}
+
+function validateCsvDelimiter(delimiter: string | undefined): string {
+ const csvDelimiter = delimiter ?? ',';
+
+ if (csvDelimiter.length !== 1 || csvDelimiter === '"' || csvDelimiter === '\n' || csvDelimiter === '\r') {
+ throw new McpError(ErrorCode.InvalidParams, 'CSV delimiter must be a single non-quote, non-newline character.');
+ }
+
+ return csvDelimiter;
+}
+
+function serializeCsvField(value: unknown, delimiter = ','): string {
+ const stringValue = value === undefined ? '' : String(value);
+
+ if (
+ stringValue.includes(delimiter) ||
+ stringValue.includes('"') ||
+ stringValue.includes('\n') ||
+ stringValue.includes('\r') ||
+ stringValue !== stringValue.trim()
+ ) {
+ return `"${stringValue.replace(/"/g, '""')}"`;
+ }
+
+ return stringValue;
+}
+
+function serializeCsv(rows: Record[]): string {
+ if (rows.length === 0) {
+ return '';
+ }
+
+ const headers = Object.keys(rows[0]);
+ const outputRows = [
+ headers.map(header => serializeCsvField(header)).join(','),
+ ...rows.map(row => headers.map(header => serializeCsvField(row[header])).join(','))
+ ];
+
+ return outputRows.join('\n');
+}
+
+function parseCsv(fileContent: string, delimiter: string): string[][] {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let field = '';
+ let inQuotes = false;
+ let justClosedQuote = false;
+
+ for (let index = 0; index < fileContent.length; index++) {
+ const char = fileContent[index];
+ const nextChar = fileContent[index + 1];
+
+ if (inQuotes) {
+ if (char === '"') {
+ if (nextChar === '"') {
+ field += '"';
+ index++;
+ } else {
+ inQuotes = false;
+ justClosedQuote = true;
+ }
+ } else {
+ field += char;
+ }
+ continue;
+ }
+
+ if (char === '"') {
+ if (field.length === 0) {
+ inQuotes = true;
+ justClosedQuote = false;
+ continue;
+ }
+ throw new Error('Invalid CSV: unexpected quote in unquoted field');
+ }
+
+ if (char === delimiter) {
+ row.push(field);
+ field = '';
+ justClosedQuote = false;
+ continue;
+ }
+
+ if (char === '\n' || char === '\r') {
+ row.push(field);
+ rows.push(row);
+ row = [];
+ field = '';
+ justClosedQuote = false;
+
+ if (char === '\r' && nextChar === '\n') {
+ index++;
+ }
+ continue;
+ }
+
+ if (justClosedQuote) {
+ if (char.trim() !== '') {
+ throw new Error('Invalid CSV: unexpected content after closing quote');
+ }
+ continue;
+ }
+
+ field += char;
+ }
+
+ if (inQuotes) {
+ throw new Error('Invalid CSV: unterminated quoted field');
+ }
+
+ if (field.length > 0 || row.length > 0 || fileContent.endsWith(delimiter)) {
+ row.push(field);
+ rows.push(row);
+ }
+
+ return rows.filter(parsedRow => parsedRow.some(value => value.trim() !== ''));
+}
+
+function parseCsvRecords(fileContent: string, delimiter: string): Record[] {
+ const rows = parseCsv(fileContent, delimiter);
+
+ if (rows.length === 0) {
+ return [];
+ }
+
+ const [headers, ...dataRows] = rows;
+
+ if (headers.some(header => header.length === 0)) {
+ throw new Error('Invalid CSV: headers cannot be empty');
+ }
+
+ if (new Set(headers).size !== headers.length) {
+ throw new Error('Invalid CSV: headers must be unique');
+ }
+
+ return dataRows.map((values, rowIndex) => {
+ if (values.length > headers.length) {
+ throw new Error(`Invalid CSV: row ${rowIndex + 2} has more values than headers`);
+ }
+
+ const record: Record = {};
+
+ for (let index = 0; index < headers.length; index++) {
+ record[headers[index]] = values[index] !== undefined ? values[index] : null;
+ }
+
+ return record;
+ });
+}
+
+function validateImportRecords(value: unknown): Record[] {
+ if (!Array.isArray(value)) {
+ throw new Error('Input file does not contain an array of records');
+ }
+
+ return value.map((record, index) => {
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) {
+ throw new Error(`Import record at index ${index} must be a JSON object.`);
+ }
+
+ return record as Record;
+ });
+}
// interface MigrationResult {
// success: boolean;
@@ -20,65 +250,51 @@ const mkdir = promisify(fs.mkdir);
const ExportTableDataInputSchema = z.object({
connectionString: z.string().optional(),
tableName: z.string(),
- outputPath: z.string().describe("absolute path to save the exported data"),
- where: z.string().optional(),
- limit: z.number().int().positive().optional(),
+ schema: z.string().optional().default('public'),
+ outputPath: z.string().describe("path under POSTGRES_MCP_WORKSPACE_DIR to save the exported data"),
+ where: z.union([WherePredicateSchema, z.string()]).optional(),
+ rawWhere: z.string().optional(),
+ limit: z.number().int().min(1).max(MAX_EXPORT_ROW_LIMIT).optional().default(DEFAULT_EXPORT_ROW_LIMIT),
format: z.enum(['json', 'csv']).optional().default('json'),
-});
+}).strict();
type ExportTableDataInput = z.infer;
async function executeExportTableData(
input: ExportTableDataInput,
getConnectionString: GetConnectionStringFn
): Promise<{ tableName: string; rowCount: number; outputPath: string }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
const db = DatabaseConnection.getInstance();
- const { tableName, outputPath, where, limit, format } = input;
-
+ const { tableName, schema, outputPath, where, rawWhere, limit, format } = input;
+
try {
+ const resolvedOutputPath = resolveSandboxPath(outputPath, format);
+ const selectQuery = buildSelectQuery(tableName, schema, where as WherePredicate | string | undefined, rawWhere, limit);
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+
await db.connect(resolvedConnectionString);
-
- let query = `SELECT * FROM "${tableName}"`; // Consider quoting table name properly
- const params: unknown[] = [];
-
- if (where) {
- query += ` WHERE ${where}`; // SECURITY: Ensure 'where' is safe or validated if user-supplied
- }
-
- if (limit) {
- query += ` LIMIT ${limit}`;
- }
-
- const data = await db.query[]>(query, params);
-
- const dir = path.dirname(outputPath);
- // Use fs.promises.mkdir for cleaner async/await
- await fs.promises.mkdir(dir, { recursive: true });
-
+ const data = await db.query>(selectQuery.query, selectQuery.params);
+
+ let outputContent: string;
+
if (format === 'csv') {
- if (data.length === 0) {
- await fs.promises.writeFile(outputPath, '');
- } else {
- const headers = Object.keys(data[0]).join(',');
- const rows = data.map(row =>
- Object.values(row).map(value => {
- const stringValue = String(value); // Ensure value is a string
- return typeof value === 'string' ? `"${stringValue.replace(/"/g, '""')}"` : stringValue;
- }).join(',')
- );
- await fs.promises.writeFile(outputPath, [headers, ...rows].join('\n'));
- }
+ outputContent = serializeCsv(data);
} else {
- await fs.promises.writeFile(outputPath, JSON.stringify(data, null, 2));
+ outputContent = JSON.stringify(data, null, 2);
}
+
+ assertWritableContentSize(outputContent);
+
+ const dir = path.dirname(resolvedOutputPath);
+ await fs.promises.mkdir(dir, { recursive: true });
+ await fs.promises.writeFile(resolvedOutputPath, outputContent);
return {
- tableName,
+ tableName: `${schema}.${tableName}`,
rowCount: data.length,
outputPath
};
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to export data: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to export data', error);
} finally {
await db.disconnect();
}
@@ -91,13 +307,13 @@ export const exportTableDataTool: PostgresTool = {
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
const validationResult = ExportTableDataInputSchema.safeParse(params);
if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
}
try {
const result = await executeExportTableData(validationResult.data, getConnectionString);
return { content: [{ type: 'text', text: `Successfully exported ${result.rowCount} rows from ${result.tableName} to ${result.outputPath}` }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error exporting data: ${errorMessage}` }], isError: true };
}
}
@@ -108,56 +324,39 @@ export const exportTableDataTool: PostgresTool = {
const ImportTableDataInputSchema = z.object({
connectionString: z.string().optional(),
tableName: z.string(),
- inputPath: z.string().describe("absolute path to the file to import"),
+ schema: z.string().optional().default('public'),
+ inputPath: z.string().describe("path under POSTGRES_MCP_WORKSPACE_DIR to the file to import"),
truncateFirst: z.boolean().optional().default(false),
format: z.enum(['json', 'csv']).optional().default('json'),
delimiter: z.string().optional(),
-});
+}).strict();
type ImportTableDataInput = z.infer;
async function executeImportTableData(
input: ImportTableDataInput,
getConnectionString: GetConnectionStringFn
): Promise<{ tableName: string; rowCount: number }> {
- const resolvedConnectionString = getConnectionString(input.connectionString);
const db = DatabaseConnection.getInstance();
- const { tableName, inputPath, truncateFirst, format, delimiter } = input;
+ const { tableName, schema, inputPath, truncateFirst, format, delimiter } = input;
try {
- await db.connect(resolvedConnectionString);
-
- const fileContent = await fs.promises.readFile(inputPath, 'utf8');
+ const quotedTableName = quoteQualifiedIdent(tableName, schema);
+ const resolvedInputPath = await assertReadableSandboxFile(inputPath, format);
+ const fileContent = await fs.promises.readFile(resolvedInputPath, 'utf8');
let dataToImport: Record[];
if (format === 'csv') {
- const csvDelimiter = delimiter || ',';
- const lines = fileContent.split('\n').filter(line => line.trim()); // Use \n consistently
-
- if (lines.length === 0) {
- return { tableName, rowCount: 0 };
- }
-
- const headers = lines[0].split(csvDelimiter).map(h => h.trim().replace(/^"|"$/g, '')); // Remove surrounding quotes from headers
-
- dataToImport = lines.slice(1).map(line => {
- // Basic CSV parsing, might need a more robust library for complex CSVs
- const values = line.split(csvDelimiter).map(val => val.trim().replace(/^"|"$/g, '').replace(/""/g, '"'));
- return headers.reduce((obj, header, index) => {
- obj[header] = values[index] !== undefined ? values[index] : null;
- return obj;
- }, {} as Record);
- });
+ dataToImport = parseCsvRecords(fileContent, validateCsvDelimiter(delimiter));
} else {
- dataToImport = JSON.parse(fileContent);
- }
-
- if (!Array.isArray(dataToImport)) {
- throw new Error('Input file does not contain an array of records');
+ dataToImport = validateImportRecords(JSON.parse(fileContent));
}
+
+ const resolvedConnectionString = getConnectionString(input.connectionString);
+ await db.connect(resolvedConnectionString);
if (truncateFirst) {
- await db.query(`TRUNCATE TABLE "${tableName}"`); // Consider quoting
+ await db.query(`TRUNCATE TABLE ${quotedTableName}`);
}
let importedCount = 0;
@@ -170,7 +369,7 @@ async function executeImportTableData(
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
const query = `
- INSERT INTO "${tableName}" (${columns.map(c => `"${c}"`).join(', ')})
+ INSERT INTO ${quotedTableName} (${columns.map(quoteIdent).join(', ')})
VALUES (${placeholders})
`;
@@ -181,11 +380,11 @@ async function executeImportTableData(
}
return {
- tableName,
+ tableName: `${schema}.${tableName}`,
rowCount: importedCount
};
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to import data: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to import data', error);
} finally {
await db.disconnect();
}
@@ -198,13 +397,13 @@ export const importTableDataTool: PostgresTool = {
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
const validationResult = ImportTableDataInputSchema.safeParse(params);
if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
}
try {
const result = await executeImportTableData(validationResult.data, getConnectionString);
return { content: [{ type: 'text', text: `Successfully imported ${result.rowCount} rows into ${result.tableName}` }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error importing data: ${errorMessage}` }], isError: true };
}
}
@@ -215,42 +414,44 @@ const CopyBetweenDatabasesInputSchema = z.object({
sourceConnectionString: z.string(),
targetConnectionString: z.string(),
tableName: z.string(),
- where: z.string().optional(),
+ schema: z.string().optional().default('public'),
+ where: z.union([WherePredicateSchema, z.string()]).optional(),
+ rawWhere: z.string().optional(),
+ limit: z.number().int().min(1).max(MAX_COPY_ROW_LIMIT).optional().default(DEFAULT_COPY_ROW_LIMIT),
truncateTarget: z.boolean().optional().default(false),
-});
+}).strict();
type CopyBetweenDatabasesInput = z.infer;
async function executeCopyBetweenDatabases(
input: CopyBetweenDatabasesInput,
getConnectionString: GetConnectionStringFn
): Promise<{ tableName: string; rowCount: number }> {
- const { sourceConnectionString, targetConnectionString, tableName, where, truncateTarget } = input;
+ const { sourceConnectionString, targetConnectionString, tableName, schema, where, rawWhere, limit, truncateTarget } = input;
const db = DatabaseConnection.getInstance(); // Use the singleton for both connections sequentially
try {
+ const quotedTableName = quoteQualifiedIdent(tableName, schema);
+ const selectQuery = buildSelectQuery(tableName, schema, where as WherePredicate | string | undefined, rawWhere, limit);
+ const resolvedSourceConnectionString = getConnectionString(sourceConnectionString);
+ const resolvedTargetConnectionString = getConnectionString(targetConnectionString);
+
// --- Source Operations ---
- await db.connect(sourceConnectionString);
-
- let query = `SELECT * FROM "${tableName}"`;
- if (where) {
- query += ` WHERE ${where}`;
- }
-
- const data = await db.query[]>(query);
+ await db.connect(resolvedSourceConnectionString);
+ const data = await db.query>(selectQuery.query, selectQuery.params);
if (data.length === 0) {
await db.disconnect(); // Disconnect source if no data
- return { tableName, rowCount: 0 };
+ return { tableName: `${schema}.${tableName}`, rowCount: 0 };
}
await db.disconnect(); // Disconnect source before connecting to target
// --- Target Operations ---
- await db.connect(targetConnectionString);
+ await db.connect(resolvedTargetConnectionString);
if (truncateTarget) {
- await db.query(`TRUNCATE TABLE "${tableName}"`);
+ await db.query(`TRUNCATE TABLE ${quotedTableName}`);
}
let importedCount = 0;
@@ -262,7 +463,7 @@ async function executeCopyBetweenDatabases(
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
const insertQuery = `
- INSERT INTO "${tableName}" (${columns.map(c => `"${c}"`).join(', ')})
+ INSERT INTO ${quotedTableName} (${columns.map(quoteIdent).join(', ')})
VALUES (${placeholders})
`;
await client.query(insertQuery, values);
@@ -270,9 +471,9 @@ async function executeCopyBetweenDatabases(
}
});
- return { tableName, rowCount: importedCount };
+ return { tableName: `${schema}.${tableName}`, rowCount: importedCount };
} catch (error) {
- throw new McpError(ErrorCode.InternalError, `Failed to copy data: ${error instanceof Error ? error.message : String(error)}`);
+ throw toInternalError('Failed to copy data', error);
} finally {
// Ensure disconnection in normal flow; connect() handles prior disconnects if needed.
// The connect method in DatabaseConnection already handles disconnecting if connected to a different DB.
@@ -291,13 +492,13 @@ export const copyBetweenDatabasesTool: PostgresTool = {
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
const validationResult = CopyBetweenDatabasesInputSchema.safeParse(params);
if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
}
try {
const result = await executeCopyBetweenDatabases(validationResult.data, getConnectionString);
return { content: [{ type: 'text', text: `Successfully copied ${result.rowCount} rows to ${result.tableName}` }] };
} catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
+ const errorMessage = sanitizeErrorMessage(error);
return { content: [{ type: 'text', text: `Error copying data: ${errorMessage}` }], isError: true };
}
}
@@ -306,4 +507,4 @@ export const copyBetweenDatabasesTool: PostgresTool = {
// Removed old function exports
// export async function exportTableData(...)
// export async function importTableData(...)
-// export async function copyBetweenDatabases(...)
\ No newline at end of file
+// export async function copyBetweenDatabases(...)
diff --git a/src/tools/monitor.test.ts b/src/tools/monitor.test.ts
new file mode 100644
index 0000000..9c08302
--- /dev/null
+++ b/src/tools/monitor.test.ts
@@ -0,0 +1,163 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import { monitorDatabaseTool } from './monitor';
+
+describe('monitorDatabaseTool', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('rejects unknown fields before resolving a connection', async () => {
+ const result = await monitorDatabaseTool.execute({
+ includeQueries: true,
+ unexpected: true
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(result.content[0].text).not.toContain('[object Object]');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown alert threshold fields before resolving a connection', async () => {
+ const result = await monitorDatabaseTool.execute({
+ alertThresholds: {
+ cacheHitRatio: 0.9,
+ unexpected: true
+ }
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Invalid input');
+ expect(result.content[0].text).toContain('Unrecognized key');
+ expect(result.content[0].text).not.toContain('[object Object]');
+ expect(mockGetConnectionString).not.toHaveBeenCalled();
+ });
+
+ it('redacts active query text in metrics and alert context', async () => {
+ const queryStart = new Date(Date.now() - 65_000).toISOString();
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryOne: vi.fn()
+ .mockResolvedValueOnce({
+ db_name: 'postgres',
+ db_size: '10 MB',
+ uptime: '1 day',
+ committed_tx: '100',
+ rolled_back_tx: '2'
+ })
+ .mockResolvedValueOnce({
+ active_connections: '1',
+ idle_connections: '0',
+ total_connections: '1',
+ max_connections: '100'
+ })
+ .mockResolvedValueOnce({
+ cache_hit_ratio: 0.99
+ }),
+ query: vi.fn().mockResolvedValue([
+ {
+ pid: '123',
+ usename: 'app',
+ datname: 'postgres',
+ query_start: queryStart,
+ state: 'active',
+ wait_event: null,
+ query: "SELECT * FROM sessions WHERE api_key = 'secret-key' AND user_id = 99"
+ }
+ ]),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await monitorDatabaseTool.execute({
+ includeQueries: true,
+ alertThresholds: {
+ longRunningQuerySeconds: 30
+ }
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(output).toContain("api_key = '?'");
+ expect(output).toContain('user_id = ?');
+ expect(output).not.toContain('secret-key');
+ expect(output).not.toContain('user_id = 99');
+ });
+
+ it('sanitizes monitor errors before logging and returning them', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryOne: vi.fn().mockRejectedValue(
+ new Error("postgresql://app:monitor-secret@localhost/main failed on SELECT * FROM tokens WHERE value = 'raw-token'")
+ ),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await monitorDatabaseTool.execute({}, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ const logOutput = consoleError.mock.calls.flat().join(' ');
+ expect(result.isError).toBe(true);
+ expect(output).toContain('postgresql://app:*****@localhost/main');
+ expect(output).toContain("value = '?'");
+ expect(output).not.toContain('monitor-secret');
+ expect(output).not.toContain('raw-token');
+ expect(logOutput).not.toContain('monitor-secret');
+ expect(logOutput).not.toContain('raw-token');
+ });
+
+ it('caps active query metrics before formatting output', async () => {
+ const queryStart = new Date(Date.now() - 65_000).toISOString();
+ const activeQueries = Array.from({ length: 51 }, (_, index) => ({
+ pid: String(1000 + index),
+ usename: 'app',
+ datname: 'postgres',
+ query_start: queryStart,
+ state: 'active',
+ wait_event: null,
+ query: `SELECT * FROM sessions WHERE token = 'secret-${index}'`
+ }));
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ queryOne: vi.fn()
+ .mockResolvedValueOnce({
+ db_name: 'postgres',
+ db_size: '10 MB',
+ uptime: '1 day',
+ committed_tx: '100',
+ rolled_back_tx: '2'
+ })
+ .mockResolvedValueOnce({
+ active_connections: '1',
+ idle_connections: '0',
+ total_connections: '1',
+ max_connections: '100'
+ })
+ .mockResolvedValueOnce({
+ cache_hit_ratio: 0.99
+ }),
+ query: vi.fn().mockResolvedValue(activeQueries),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await monitorDatabaseTool.execute({
+ includeQueries: true
+ }, mockGetConnectionString);
+
+ const output = result.content.map((item) => item.text).join('\n');
+ expect(result.isError).toBeUndefined();
+ expect(output).toContain('INFO: Active query output capped at 50 rows');
+ expect(output).toContain('"pid": 1049');
+ expect(output).not.toContain('"pid": 1050');
+ expect(output).not.toContain('secret-50');
+ expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('LIMIT 51'));
+ });
+});
diff --git a/src/tools/monitor.ts b/src/tools/monitor.ts
index 12f915e..37d283f 100644
--- a/src/tools/monitor.ts
+++ b/src/tools/monitor.ts
@@ -1,7 +1,8 @@
-import { DatabaseConnection } from '../utils/connection.js';
-import { z } from 'zod';
-import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
-import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import { DatabaseConnection, sanitizeErrorMessage } from '../utils/connection.js';
+import { z } from 'zod';
+import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js';
+import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
+import { redactSqlText } from '../utils/sql.js';
interface MonitoringResult {
timestamp: string;
@@ -75,28 +76,42 @@ interface ReplicationInfo {
replayLag: string | null;
}
-interface Alert {
- level: 'info' | 'warning' | 'critical';
- message: string;
- context?: Record;
-}
-
-const AlertThresholdsSchema = z.object({
- connectionPercentage: z.number().min(0).max(100).optional().describe("Connection usage percentage threshold"),
- longRunningQuerySeconds: z.number().positive().optional().describe("Long-running query threshold in seconds"),
- cacheHitRatio: z.number().min(0).max(1).optional().describe("Cache hit ratio threshold"),
- deadTuplesPercentage: z.number().min(0).max(100).optional().describe("Dead tuples percentage threshold"),
- vacuumAge: z.number().positive().int().optional().describe("Vacuum age threshold in days"),
-}).describe("Alert thresholds");
-
-const MonitorDatabaseInputSchema = z.object({
- connectionString: z.string().optional(),
- includeTables: z.boolean().optional().default(false),
- includeQueries: z.boolean().optional().default(false),
- includeLocks: z.boolean().optional().default(false),
- includeReplication: z.boolean().optional().default(false),
- alertThresholds: AlertThresholdsSchema.optional(),
-});
+interface Alert {
+ level: 'info' | 'warning' | 'critical';
+ message: string;
+ context?: Record;
+}
+
+interface CappedRows {
+ rows: T[];
+ capped: boolean;
+}
+
+const TABLE_METRICS_DIAGNOSTIC_LIMIT = 100;
+const ACTIVE_QUERY_DIAGNOSTIC_LIMIT = 50;
+const LOCK_DIAGNOSTIC_LIMIT = 100;
+const REPLICATION_DIAGNOSTIC_LIMIT = 50;
+
+function formatValidationError(error: z.ZodError): string {
+ return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', ');
+}
+
+const AlertThresholdsSchema = z.object({
+ connectionPercentage: z.number().min(0).max(100).optional().describe("Connection usage percentage threshold"),
+ longRunningQuerySeconds: z.number().positive().optional().describe("Long-running query threshold in seconds"),
+ cacheHitRatio: z.number().min(0).max(1).optional().describe("Cache hit ratio threshold"),
+ deadTuplesPercentage: z.number().min(0).max(100).optional().describe("Dead tuples percentage threshold"),
+ vacuumAge: z.number().positive().int().optional().describe("Vacuum age threshold in days"),
+}).strict().describe("Alert thresholds");
+
+const MonitorDatabaseInputSchema = z.object({
+ connectionString: z.string().optional(),
+ includeTables: z.boolean().optional().default(false),
+ includeQueries: z.boolean().optional().default(false),
+ includeLocks: z.boolean().optional().default(false),
+ includeReplication: z.boolean().optional().default(false),
+ alertThresholds: AlertThresholdsSchema.optional(),
+}).strict();
type MonitorDatabaseInput = z.infer;
@@ -141,11 +156,18 @@ async function executeMonitorDatabase(
});
}
- const tableMetricsResult: Record = {};
- if (includeTables) {
- const tables = await getTableMetrics(db);
-
- for (const table of tables) {
+ const tableMetricsResult: Record = {};
+ if (includeTables) {
+ const tableMetrics = await getTableMetrics(db);
+ const tables = tableMetrics.rows;
+ if (tableMetrics.capped) {
+ alerts.push({
+ level: 'info',
+ message: `Table metrics output capped at ${TABLE_METRICS_DIAGNOSTIC_LIMIT} rows`
+ });
+ }
+
+ for (const table of tables) {
tableMetricsResult[table.name] = table;
if (alertThresholds?.deadTuplesPercentage) {
@@ -184,35 +206,50 @@ async function executeMonitorDatabase(
}
}
- let activeQueriesResult: ActiveQueryInfo[] = [];
- if (includeQueries) {
- activeQueriesResult = await getActiveQueries(db);
-
- if (alertThresholds?.longRunningQuerySeconds) {
+ let activeQueriesResult: ActiveQueryInfo[] = [];
+ if (includeQueries) {
+ const activeQueries = await getActiveQueries(db);
+ activeQueriesResult = activeQueries.rows;
+ if (activeQueries.capped) {
+ alerts.push({
+ level: 'info',
+ message: `Active query output capped at ${ACTIVE_QUERY_DIAGNOSTIC_LIMIT} rows`
+ });
+ }
+
+ if (alertThresholds?.longRunningQuerySeconds) {
const threshold = alertThresholds.longRunningQuerySeconds;
const longRunningQueries = activeQueriesResult.filter(
q => q.duration > threshold
);
- for (const query of longRunningQueries) {
- alerts.push({
- level: query.duration > threshold * 2 ? 'critical' : 'warning',
- message: `Long-running query (${query.duration.toFixed(1)}s) by ${query.username}`,
- context: {
- pid: query.pid,
- duration: query.duration,
- query: query.query.substring(0, 100) + (query.query.length > 100 ? '...' : '')
- }
- });
- }
+ for (const query of longRunningQueries) {
+ const redactedQuery = redactSqlText(query.query, 100);
+ alerts.push({
+ level: query.duration > threshold * 2 ? 'critical' : 'warning',
+ message: `Long-running query (${query.duration.toFixed(1)}s) by ${query.username}`,
+ context: {
+ pid: query.pid,
+ duration: query.duration,
+ query: redactedQuery
+ }
+ });
+ }
}
}
- let locksResult: LockInfo[] = [];
- if (includeLocks) {
- locksResult = await getLockInfo(db);
-
- const blockingLocks = locksResult.filter(l => !l.granted);
+ let locksResult: LockInfo[] = [];
+ if (includeLocks) {
+ const locks = await getLockInfo(db);
+ locksResult = locks.rows;
+ if (locks.capped) {
+ alerts.push({
+ level: 'info',
+ message: `Lock output capped at ${LOCK_DIAGNOSTIC_LIMIT} rows`
+ });
+ }
+
+ const blockingLocks = locksResult.filter(l => !l.granted);
if (blockingLocks.length > 0) {
alerts.push({
level: 'warning',
@@ -224,11 +261,18 @@ async function executeMonitorDatabase(
}
}
- let replicationResult: ReplicationInfo[] = [];
- if (includeReplication) {
- replicationResult = await getReplicationInfo(db);
-
- for (const replica of replicationResult) {
+ let replicationResult: ReplicationInfo[] = [];
+ if (includeReplication) {
+ const replication = await getReplicationInfo(db);
+ replicationResult = replication.rows;
+ if (replication.capped) {
+ alerts.push({
+ level: 'info',
+ message: `Replication output capped at ${REPLICATION_DIAGNOSTIC_LIMIT} rows`
+ });
+ }
+
+ for (const replica of replicationResult) {
if (replica.replayLag) {
const lagMatch = replica.replayLag.match(/(\d+):(\d+):(\d+)/);
if (lagMatch) {
@@ -261,9 +305,10 @@ async function executeMonitorDatabase(
},
alerts
};
- } catch (error) {
- console.error("Error monitoring database:", error);
- throw new McpError(ErrorCode.InternalError, `Failed to monitor database: ${error instanceof Error ? error.message : String(error)}`);
+ } catch (error) {
+ const errorMessage = sanitizeErrorMessage(error);
+ console.error("Error monitoring database:", errorMessage);
+ throw new McpError(ErrorCode.InternalError, `Failed to monitor database: ${errorMessage}`);
} finally {
await db.disconnect();
}
@@ -274,10 +319,10 @@ export const monitorDatabaseTool: PostgresTool = {
description: 'Get real-time monitoring information for a PostgreSQL database',
inputSchema: MonitorDatabaseInputSchema,
async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise {
- const validationResult = MonitorDatabaseInputSchema.safeParse(params);
- if (!validationResult.success) {
- return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true };
- }
+ const validationResult = MonitorDatabaseInputSchema.safeParse(params);
+ if (!validationResult.success) {
+ return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true };
+ }
try {
const result = await executeMonitorDatabase(validationResult.data, getConnectionString);
return {
@@ -287,11 +332,11 @@ export const monitorDatabaseTool: PostgresTool = {
{ type: 'text', text: `Full metrics (JSON): ${JSON.stringify(result.metrics, null, 2)}` }
]
};
- } catch (error) {
- const errorMessage = error instanceof McpError ? error.message : (error instanceof Error ? error.message : String(error));
- return { content: [{ type: 'text', text: `Error monitoring database: ${errorMessage}` }], isError: true };
- }
- }
+ } catch (error) {
+ const errorMessage = sanitizeErrorMessage(error);
+ return { content: [{ type: 'text', text: `Error monitoring database: ${errorMessage}` }], isError: true };
+ }
+ }
};
/**
@@ -357,8 +402,8 @@ async function getDatabaseMetrics(db: DatabaseConnection): Promise {
- const tableStats = await db.query<{
+async function getTableMetrics(db: DatabaseConnection): Promise> {
+ const tableStats = await db.query<{
relname: string;
size: string;
n_live_tup: string;
@@ -378,30 +423,35 @@ async function getTableMetrics(db: DatabaseConnection): Promise
s.seq_scan,
s.idx_scan
FROM pg_class c
- JOIN pg_stat_user_tables s ON s.relid = c.oid
- WHERE c.relkind = 'r'
- ORDER BY c.relname`
- );
-
- return tableStats.map(table => ({
- name: table.relname,
- size: table.size,
- rowCount: Number.parseInt(table.n_live_tup),
- deadTuples: Number.parseInt(table.n_dead_tup),
- lastVacuum: table.last_vacuum,
- lastAnalyze: table.last_analyze,
- scanCount: Number.parseInt(table.seq_scan),
- indexUseRatio: Number.parseInt(table.seq_scan) + Number.parseInt(table.idx_scan) > 0
- ? Number.parseInt(table.idx_scan) / (Number.parseInt(table.seq_scan) + Number.parseInt(table.idx_scan))
- : 0
- }));
-}
+ JOIN pg_stat_user_tables s ON s.relid = c.oid
+ WHERE c.relkind = 'r'
+ ORDER BY pg_total_relation_size(c.oid) DESC, c.relname
+ LIMIT ${TABLE_METRICS_DIAGNOSTIC_LIMIT + 1}`
+ );
+ const visibleTableStats = tableStats.slice(0, TABLE_METRICS_DIAGNOSTIC_LIMIT);
+
+ return {
+ rows: visibleTableStats.map(table => ({
+ name: table.relname,
+ size: table.size,
+ rowCount: Number.parseInt(table.n_live_tup),
+ deadTuples: Number.parseInt(table.n_dead_tup),
+ lastVacuum: table.last_vacuum,
+ lastAnalyze: table.last_analyze,
+ scanCount: Number.parseInt(table.seq_scan),
+ indexUseRatio: Number.parseInt(table.seq_scan) + Number.parseInt(table.idx_scan) > 0
+ ? Number.parseInt(table.idx_scan) / (Number.parseInt(table.seq_scan) + Number.parseInt(table.idx_scan))
+ : 0
+ })),
+ capped: tableStats.length > TABLE_METRICS_DIAGNOSTIC_LIMIT
+ };
+}
/**
* Get information about active queries
*/
-async function getActiveQueries(db: DatabaseConnection): Promise {
- const queries = await db.query<{
+async function getActiveQueries(db: DatabaseConnection): Promise> {
+ const queries = await db.query<{
pid: string;
usename: string;
datname: string;
@@ -419,35 +469,40 @@ async function getActiveQueries(db: DatabaseConnection): Promise pg_backend_pid()
- ORDER BY query_start`
- );
-
- const now = new Date();
-
- return queries.map(q => {
- const startTime = new Date(q.query_start);
- const durationSeconds = (now.getTime() - startTime.getTime()) / 1000;
-
- return {
- pid: Number.parseInt(q.pid),
- username: q.usename,
- database: q.datname,
- startTime: q.query_start,
- duration: durationSeconds,
- state: q.state,
- waitEvent: q.wait_event || undefined,
- query: q.query
- };
- });
-}
+ WHERE state != 'idle'
+ AND pid <> pg_backend_pid()
+ ORDER BY query_start
+ LIMIT ${ACTIVE_QUERY_DIAGNOSTIC_LIMIT + 1}`
+ );
+ const visibleQueries = queries.slice(0, ACTIVE_QUERY_DIAGNOSTIC_LIMIT);
+
+ const now = new Date();
+
+ return {
+ rows: visibleQueries.map(q => {
+ const startTime = new Date(q.query_start);
+ const durationSeconds = (now.getTime() - startTime.getTime()) / 1000;
+
+ return {
+ pid: Number.parseInt(q.pid),
+ username: q.usename,
+ database: q.datname,
+ startTime: q.query_start,
+ duration: durationSeconds,
+ state: q.state,
+ waitEvent: q.wait_event || undefined,
+ query: redactSqlText(q.query)
+ };
+ }),
+ capped: queries.length > ACTIVE_QUERY_DIAGNOSTIC_LIMIT
+ };
+}
/**
* Get information about locks
*/
-async function getLockInfo(db: DatabaseConnection): Promise {
- const locks = await db.query<{
+async function getLockInfo(db: DatabaseConnection): Promise> {
+ const locks = await db.query<{
relation: string;
mode: string;
granted: string;
@@ -465,27 +520,32 @@ async function getLockInfo(db: DatabaseConnection): Promise {
l.pid,
a.usename,
a.query
- FROM pg_locks l
- JOIN pg_stat_activity a ON l.pid = a.pid
- WHERE l.pid <> pg_backend_pid()
- ORDER BY relation, mode`
- );
-
- return locks.map(lock => ({
- relation: lock.relation,
- mode: lock.mode,
- granted: lock.granted === 't',
- pid: Number.parseInt(lock.pid),
- username: lock.usename,
- query: lock.query
- }));
-}
+ FROM pg_locks l
+ JOIN pg_stat_activity a ON l.pid = a.pid
+ WHERE l.pid <> pg_backend_pid()
+ ORDER BY relation, mode
+ LIMIT ${LOCK_DIAGNOSTIC_LIMIT + 1}`
+ );
+ const visibleLocks = locks.slice(0, LOCK_DIAGNOSTIC_LIMIT);
+
+ return {
+ rows: visibleLocks.map(lock => ({
+ relation: lock.relation,
+ mode: lock.mode,
+ granted: lock.granted === 't',
+ pid: Number.parseInt(lock.pid),
+ username: lock.usename,
+ query: redactSqlText(lock.query)
+ })),
+ capped: locks.length > LOCK_DIAGNOSTIC_LIMIT
+ };
+}
/**
* Get information about replication
*/
-async function getReplicationInfo(db: DatabaseConnection): Promise {
- const replication = await db.query<{
+async function getReplicationInfo(db: DatabaseConnection): Promise> {
+ const replication = await db.query<{
client_addr: string | null;
state: string;
sent_lsn: string;
@@ -504,20 +564,26 @@ async function getReplicationInfo(db: DatabaseConnection): Promise ({
- clientAddr: rep.client_addr || 'local',
- state: rep.state,
- sentLsn: rep.sent_lsn,
- writeLsn: rep.write_lsn,
- flushLsn: rep.flush_lsn,
- replayLsn: rep.replay_lsn,
- writeLag: rep.write_lag,
- flushLag: rep.flush_lag,
- replayLag: rep.replay_lag
- }));
-}
\ No newline at end of file
+ flush_lag::text,
+ replay_lag::text
+ FROM pg_stat_replication
+ ORDER BY client_addr NULLS LAST, state
+ LIMIT ${REPLICATION_DIAGNOSTIC_LIMIT + 1}`
+ );
+ const visibleReplication = replication.slice(0, REPLICATION_DIAGNOSTIC_LIMIT);
+
+ return {
+ rows: visibleReplication.map(rep => ({
+ clientAddr: rep.client_addr || 'local',
+ state: rep.state,
+ sentLsn: rep.sent_lsn,
+ writeLsn: rep.write_lsn,
+ flushLsn: rep.flush_lsn,
+ replayLsn: rep.replay_lsn,
+ writeLag: rep.write_lag,
+ flushLag: rep.flush_lag,
+ replayLag: rep.replay_lag
+ })),
+ capped: replication.length > REPLICATION_DIAGNOSTIC_LIMIT
+ };
+}
diff --git a/src/tools/performance.test.ts b/src/tools/performance.test.ts
new file mode 100644
index 0000000..1bf807e
--- /dev/null
+++ b/src/tools/performance.test.ts
@@ -0,0 +1,263 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DatabaseConnection } from '../utils/connection';
+import { explainQueryTool, getQueryStatsTool, getSlowQueriesTool, resetQueryStatsTool } from './performance';
+
+describe('legacy direct performance tools', () => {
+ const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test');
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ mockGetConnectionString.mockClear();
+ });
+
+ it('builds EXPLAIN only for a single read-only statement', async () => {
+ const query = vi.fn().mockResolvedValue({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Total Cost': 1, 'Plan Rows': 1 } }] }] });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise, options: unknown) => callback({ query })),
+ disconnect: vi.fn().mockResolvedValue(undefined)
+ };
+ vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection);
+
+ const result = await explainQueryTool.execute({
+ query: 'SELECT * FROM users',
+ format: 'json',
+ costs: false
+ }, mockGetConnectionString);
+
+ expect(result.isError).toBeUndefined();
+ expect(mockDb.transaction).toHaveBeenCalledWith(expect.any(Function), { readOnly: true });
+ expect(query).toHaveBeenCalledWith('EXPLAIN (COSTS false, FORMAT JSON) SELECT * FROM users');
+ });
+
+ it('redacts echoed EXPLAIN SQL in legacy successful responses', async () => {
+ const query = vi.fn().mockResolvedValue({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Total Cost': 1, 'Plan Rows': 1 } }] }] });
+ const mockDb = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(async (callback: (client: { query: typeof query }) => Promise