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 [![smithery badge](https://smithery.ai/badge/@HenkDz/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. - PostgreSQL Server MCP server -## 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, 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 sessions WHERE api_key = 'raw-key' AND id = 42", + format: 'json' + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(output).toContain("api_key = '?'"); + expect(output).toContain('id = ?'); + expect(output).not.toContain('raw-key'); + expect(output).not.toContain('id = 42'); + }); + + it('sanitizes legacy EXPLAIN database errors before returning them', async () => { + const query = vi.fn().mockRejectedValue( + new Error("password=db-secret failed on SELECT * FROM sessions WHERE api_key = 'raw-key'") + ); + 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 sessions WHERE api_key = 'raw-key'" + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to explain query'); + expect(result.content[0].text).toContain('password=*****'); + expect(result.content[0].text).toContain("api_key = '?'"); + expect(result.content[0].text).not.toContain('db-secret'); + expect(result.content[0].text).not.toContain('raw-key'); + }); + + it('rejects unsafe EXPLAIN input 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 explainQueryTool.execute({ + query: 'UPDATE users SET admin = true' + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('read-only SELECT'); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.transaction).not.toHaveBeenCalled(); + }); + + it('rejects unknown EXPLAIN input fields 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 explainQueryTool.execute({ + query: 'SELECT * FROM users', + 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(); + }); + + it('rejects data-changing CTEs in legacy EXPLAIN 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 explainQueryTool.execute({ + query: 'WITH updated AS (UPDATE users SET admin = true RETURNING *) SELECT * FROM updated' + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('UPDATE'); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.transaction).not.toHaveBeenCalled(); + }); + + it('parameterizes slow-query duration filters', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn() + .mockResolvedValueOnce([{ exists: 1 }]) + .mockResolvedValueOnce([]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await getSlowQueriesTool.execute({ + limit: 25, + minDuration: 100, + orderBy: 'total_time' + }, mockGetConnectionString); + + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenNthCalledWith(2, expect.stringContaining('WHERE mean_time >= $2'), [25, 100]); + expect(mockDb.query).toHaveBeenNthCalledWith(2, expect.stringContaining('ORDER BY total_time DESC'), [25, 100]); + }); + + it('rejects unknown slow-query input fields before connecting', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await getSlowQueriesTool.execute({ + orderBy: 'calls', + injectedOrderBy: 'query; 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(); + }); + + it('rejects unknown query-stats input fields before connecting', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await getQueryStatsTool.execute({ + orderBy: 'total_time', + rawFilter: 'query ILIKE \'%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(); + }); + + it('redacts literals in legacy slow-query output', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn() + .mockResolvedValueOnce([{ exists: 1 }]) + .mockResolvedValueOnce([{ + query: "SELECT * FROM sessions WHERE api_key = 'secret-key'", + calls: 2, + total_time: 80, + mean_time: 40, + rows: 2, + stddev_time: 1, + min_time: 39, + max_time: 41, + shared_blks_hit: 5, + shared_blks_read: 0, + shared_blks_written: 0, + temp_blks_read: 0, + temp_blks_written: 0 + }]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await getSlowQueriesTool.execute({}, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(output).toContain("api_key = '?'"); + expect(output).not.toContain('secret-key'); + }); + + it('rejects invalid reset query IDs before executing reset', 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 resetQueryStatsTool.execute({ + queryId: '123;select' + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('queryId must be'); + expect(mockDb.query).not.toHaveBeenCalled(); + }); + + it('rejects unknown reset-stat input fields 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 resetQueryStatsTool.execute({ + queryId: '123', + resetAll: 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(); + expect(mockDb.query).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 170d137..4f1002c 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.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 { getReadOnlySqlValidationError, redactSqlText } from '../utils/sql.js'; interface ExplainResult { query: string; @@ -29,7 +30,7 @@ interface SlowQuery { temp_blks_written: number; } -interface QueryStats { +interface QueryStats { query_id: string; query: string; calls: number; @@ -42,27 +43,58 @@ interface QueryStats { shared_blks_hit: number; shared_blks_read: number; shared_blks_written: number; - cache_hit_ratio: number; -} - -// --- EXPLAIN Query Tool --- -const ExplainQueryInputSchema = z.object({ - connectionString: z.string().optional(), - query: z.string().describe("SQL query to explain"), + cache_hit_ratio: number; +} + +const MAX_QUERY_STATS_LIMIT = 100; + +function normalizeLimit(limit: number | undefined, defaultValue: number): number { + const value = limit ?? defaultValue; + if (!Number.isInteger(value) || value < 1 || value > MAX_QUERY_STATS_LIMIT) { + throw new McpError(ErrorCode.InvalidParams, `limit must be an integer between 1 and ${MAX_QUERY_STATS_LIMIT}`); + } + + return value; +} + +function assertExplainQuerySafe(query: string): void { + const validationError = getReadOnlySqlValidationError(query); + if (validationError) { + throw new McpError(ErrorCode.InvalidParams, `EXPLAIN ${validationError}`); + } +} + +function parseQueryId(queryId: string): number { + if (!/^-?\d+$/.test(queryId.trim())) { + throw new McpError(ErrorCode.InvalidParams, 'queryId must be a pg_stat_statements numeric query ID'); + } + + return Number(queryId); +} + +function formatValidationError(error: z.ZodError): string { + return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', '); +} + +// --- EXPLAIN Query Tool --- +const ExplainQueryInputSchema = z.object({ + connectionString: z.string().optional(), + query: z.string().describe("SQL query to explain"), analyze: z.boolean().optional().default(false).describe("Use EXPLAIN ANALYZE (actually executes the query)"), buffers: z.boolean().optional().default(false).describe("Include buffer usage information"), - verbose: z.boolean().optional().default(false).describe("Include verbose output"), - costs: z.boolean().optional().default(true).describe("Include cost estimates"), - format: z.enum(['text', 'json', 'xml', 'yaml']).optional().default('json').describe("Output format"), -}); + verbose: z.boolean().optional().default(false).describe("Include verbose output"), + costs: z.boolean().optional().default(true).describe("Include cost estimates"), + format: z.enum(['text', 'json', 'xml', 'yaml']).optional().default('json').describe("Output format"), +}).strict(); type ExplainQueryInput = z.infer; -async function executeExplainQuery( - input: ExplainQueryInput, - getConnectionString: GetConnectionStringFn -): Promise { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); +async function executeExplainQuery( + input: ExplainQueryInput, + getConnectionString: GetConnectionStringFn +): Promise { + assertExplainQuerySafe(input.query); + const resolvedConnectionString = getConnectionString(input.connectionString); + const db = DatabaseConnection.getInstance(); const { query, analyze, buffers, verbose, costs, format } = input; try { @@ -76,9 +108,12 @@ async function executeExplainQuery( if (!costs) options.push('COSTS false'); options.push(`FORMAT ${format.toUpperCase()}`); - const explainQuery = `EXPLAIN (${options.join(', ')}) ${query}`; - - const result = await db.query(explainQuery); + const explainQuery = `EXPLAIN (${options.join(', ')}) ${query}`; + + const result = await db.transaction(async (client) => { + const queryResult = await client.query(explainQuery); + return queryResult.rows; + }, { readOnly: true }); // Extract timing information if available (from EXPLAIN ANALYZE) let execution_time: number | undefined; @@ -103,7 +138,7 @@ async function executeExplainQuery( } return { - query, + query: redactSqlText(query), plan: result, execution_time, planning_time, @@ -112,9 +147,12 @@ async function executeExplainQuery( estimated_rows }; - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to explain query: ${error instanceof Error ? error.message : String(error)}`); - } finally { + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError(ErrorCode.InternalError, `Failed to explain query: ${sanitizeErrorMessage(error)}`); + } finally { await db.disconnect(); } } @@ -124,10 +162,10 @@ export const explainQueryTool: PostgresTool = { description: 'EXPLAIN/EXPLAIN ANALYZE for queries to understand execution plans', inputSchema: ExplainQueryInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = ExplainQueryInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = ExplainQueryInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeExplainQuery(validationResult.data, getConnectionString); const message = validationResult.data.analyze @@ -135,20 +173,20 @@ export const explainQueryTool: PostgresTool = { : 'Query execution plan'; 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 explaining query: ${errorMessage}` }], isError: true }; } } }; // --- Get Slow Queries Tool --- -const GetSlowQueriesInputSchema = z.object({ - connectionString: z.string().optional(), - limit: z.number().optional().default(10).describe("Number of slow queries to return"), - minDuration: z.number().optional().describe("Minimum average duration in milliseconds"), - orderBy: z.enum(['mean_time', 'total_time', 'calls']).optional().default('mean_time').describe("Sort order"), - includeNormalized: z.boolean().optional().default(true).describe("Include normalized query text"), -}); +const GetSlowQueriesInputSchema = z.object({ + connectionString: z.string().optional(), + limit: z.number().int().min(1).max(MAX_QUERY_STATS_LIMIT).optional().default(10).describe("Number of slow queries to return"), + minDuration: z.number().min(0).optional().describe("Minimum average duration in milliseconds"), + orderBy: z.enum(['mean_time', 'total_time', 'calls']).optional().default('mean_time').describe("Sort order"), + includeNormalized: z.boolean().optional().default(true).describe("Include normalized query text"), +}).strict(); type GetSlowQueriesInput = z.infer; async function executeGetSlowQueries( @@ -171,8 +209,15 @@ async function executeGetSlowQueries( throw new McpError(ErrorCode.InvalidParams, 'pg_stat_statements extension is not installed. Please install it first: CREATE EXTENSION pg_stat_statements;'); } - const queryColumn = includeNormalized ? 'query' : 'query'; - const minDurationClause = minDuration ? `WHERE mean_time >= ${minDuration}` : ''; + const queryColumn = includeNormalized ? 'query' : 'query'; + const slowQueryLimit = normalizeLimit(limit, 10); + const whereConditions: string[] = []; + const params: number[] = [slowQueryLimit]; + if (minDuration !== undefined) { + whereConditions.push('mean_time >= $2'); + params.push(minDuration); + } + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''; const slowQueriesQuery = ` SELECT @@ -189,18 +234,24 @@ async function executeGetSlowQueries( shared_blks_written, temp_blks_read, temp_blks_written - FROM pg_stat_statements - ${minDurationClause} - ORDER BY ${orderBy} DESC - LIMIT $1 - `; - - const result = await db.query(slowQueriesQuery, [limit]); - return result; + FROM pg_stat_statements + ${whereClause} + ORDER BY ${orderBy} DESC + LIMIT $1 + `; + + const result = await db.query(slowQueriesQuery, params); + return result.map((row) => ({ + ...row, + query: redactSqlText(row.query) + })); - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to get slow queries: ${error instanceof Error ? error.message : String(error)}`); - } finally { + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError(ErrorCode.InternalError, `Failed to get slow queries: ${sanitizeErrorMessage(error)}`); + } finally { await db.disconnect(); } } @@ -210,38 +261,39 @@ export const getSlowQueriesTool: PostgresTool = { description: 'Find slow running queries using pg_stat_statements', inputSchema: GetSlowQueriesInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = GetSlowQueriesInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = GetSlowQueriesInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeGetSlowQueries(validationResult.data, getConnectionString); const message = `Top ${validationResult.data.limit} slow queries ordered by ${validationResult.data.orderBy}`; 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 slow queries: ${errorMessage}` }], isError: true }; } } }; // --- Get Query Stats Tool --- -const GetQueryStatsInputSchema = z.object({ - connectionString: z.string().optional(), - limit: z.number().optional().default(20).describe("Number of queries to return"), - orderBy: z.enum(['calls', 'total_time', 'mean_time', 'cache_hit_ratio']).optional().default('total_time').describe("Sort order"), - minCalls: z.number().optional().describe("Minimum number of calls"), - queryPattern: z.string().optional().describe("Filter queries containing this pattern"), -}); +const GetQueryStatsInputSchema = z.object({ + connectionString: z.string().optional(), + limit: z.number().int().min(1).max(MAX_QUERY_STATS_LIMIT).optional().default(20).describe("Number of queries to return"), + orderBy: z.enum(['calls', 'total_time', 'mean_time', 'cache_hit_ratio']).optional().default('total_time').describe("Sort order"), + minCalls: z.number().int().min(1).optional().describe("Minimum number of calls"), + queryPattern: z.string().optional().describe("Filter queries containing this pattern"), +}).strict(); type GetQueryStatsInput = z.infer; async function executeGetQueryStats( input: GetQueryStatsInput, getConnectionString: GetConnectionStringFn ): Promise { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); - const { limit, orderBy, minCalls, queryPattern } = input; + const resolvedConnectionString = getConnectionString(input.connectionString); + const db = DatabaseConnection.getInstance(); + const { limit, orderBy, minCalls, queryPattern } = input; + const statsLimit = normalizeLimit(limit, 20); try { await db.connect(resolvedConnectionString); @@ -256,7 +308,7 @@ async function executeGetQueryStats( } const whereConditions: string[] = []; - const params: (number | string)[] = [limit]; + const params: (number | string)[] = [statsLimit]; let paramIndex = 2; if (minCalls) { @@ -297,12 +349,18 @@ async function executeGetQueryStats( LIMIT $1 `; - const result = await db.query(queryStatsQuery, params); - return result; + const result = await db.query(queryStatsQuery, params); + return result.map((row) => ({ + ...row, + query: redactSqlText(row.query) + })); - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to get query statistics: ${error instanceof Error ? error.message : String(error)}`); - } finally { + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError(ErrorCode.InternalError, `Failed to get query statistics: ${sanitizeErrorMessage(error)}`); + } finally { await db.disconnect(); } } @@ -312,50 +370,54 @@ export const getQueryStatsTool: PostgresTool = { description: 'Query statistics from pg_stat_statements with cache hit ratios', inputSchema: GetQueryStatsInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = GetQueryStatsInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = GetQueryStatsInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeGetQueryStats(validationResult.data, getConnectionString); const message = `Query statistics ordered by ${validationResult.data.orderBy}`; 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 query statistics: ${errorMessage}` }], isError: true }; } } }; // --- Reset Query Stats Tool --- -const ResetQueryStatsInputSchema = z.object({ - connectionString: z.string().optional(), - queryId: z.string().optional().describe("Specific query ID to reset (optional, resets all if not provided)"), -}); +const ResetQueryStatsInputSchema = z.object({ + connectionString: z.string().optional(), + queryId: z.string().optional().describe("Specific query ID to reset (optional, resets all if not provided)"), +}).strict(); type ResetQueryStatsInput = z.infer; async function executeResetQueryStats( input: ResetQueryStatsInput, getConnectionString: GetConnectionStringFn ): Promise<{ message: string; queryId?: string }> { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); - const { queryId } = input; - - try { - await db.connect(resolvedConnectionString); - - if (queryId) { - await db.query('SELECT pg_stat_statements_reset($1)', [Number(queryId)]); - return { message: `Query statistics reset for query ID: ${queryId}`, queryId }; - } + const resolvedConnectionString = getConnectionString(input.connectionString); + const db = DatabaseConnection.getInstance(); + const { queryId } = input; + const parsedQueryId = queryId ? parseQueryId(queryId) : undefined; + + try { + await db.connect(resolvedConnectionString); + + if (parsedQueryId !== undefined) { + await db.query('SELECT pg_stat_statements_reset($1)', [parsedQueryId]); + return { message: `Query statistics reset for query ID: ${queryId}`, queryId }; + } await db.query('SELECT pg_stat_statements_reset()'); return { message: 'All query statistics have been reset' }; - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to reset query statistics: ${error instanceof Error ? error.message : String(error)}`); - } finally { + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError(ErrorCode.InternalError, `Failed to reset query statistics: ${sanitizeErrorMessage(error)}`); + } finally { await db.disconnect(); } } @@ -365,16 +427,16 @@ export const resetQueryStatsTool: PostgresTool = { description: 'Reset pg_stat_statements statistics (all or specific query)', inputSchema: ResetQueryStatsInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = ResetQueryStatsInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = ResetQueryStatsInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeResetQueryStats(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: result.message }] }; } 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 resetting query statistics: ${errorMessage}` }], isError: true }; } } -}; \ No newline at end of file +}; diff --git a/src/tools/query.test.ts b/src/tools/query.test.ts new file mode 100644 index 0000000..7e3b7c8 --- /dev/null +++ b/src/tools/query.test.ts @@ -0,0 +1,296 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DatabaseConnection } from '../utils/connection'; +import { manageQueryTool } from './query'; + +describe('manageQueryTool', () => { + 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 manageQueryTool.execute({ + operation: 'explain', + query: 'SELECT * FROM users WHERE email = $1', + 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 WHERE email = $1'); + }); + + it('redacts echoed EXPLAIN SQL in 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, options: unknown) => callback({ query })), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageQueryTool.execute({ + operation: 'explain', + query: "SELECT * FROM sessions WHERE token = 'raw-token' AND id = 42", + format: 'json' + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(output).toContain("token = '?'"); + expect(output).toContain('id = ?'); + expect(output).not.toContain('raw-token'); + expect(output).not.toContain('id = 42'); + }); + + it('sanitizes EXPLAIN database errors before returning them', async () => { + const query = vi.fn().mockRejectedValue( + new Error("password=db-secret failed on SELECT * FROM sessions WHERE token = 'raw-token'") + ); + 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 manageQueryTool.execute({ + operation: 'explain', + query: "SELECT * FROM sessions WHERE token = 'raw-token'" + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to explain query'); + 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-token'); + }); + + it('rejects multi-statement EXPLAIN input 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 manageQueryTool.execute({ + operation: 'explain', + query: 'SELECT 1; DROP TABLE users' + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('without semicolons'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.transaction).not.toHaveBeenCalled(); + }); + + it('rejects data-changing CTEs in EXPLAIN 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 manageQueryTool.execute({ + operation: 'explain', + query: 'WITH inserted AS (INSERT INTO users(email) VALUES ($1) RETURNING *) SELECT * FROM inserted' + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('INSERT'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.transaction).not.toHaveBeenCalled(); + }); + + it('parameterizes slow-query duration filters and caps limits', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn() + .mockResolvedValueOnce([{ exists: 1 }]) + .mockResolvedValueOnce([]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageQueryTool.execute({ + operation: 'get_slow_queries', + limit: 25, + minDuration: 100, + orderBy: 'total_time' + }, mockGetConnectionString); + + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenNthCalledWith(2, expect.stringContaining('WHERE mean_time >= $2'), [25, 100]); + expect(mockDb.query).toHaveBeenNthCalledWith(2, expect.stringContaining('ORDER BY total_time DESC'), [25, 100]); + }); + + it('orders slow queries by computed cache hit ratio when requested', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn() + .mockResolvedValueOnce([{ exists: 1 }]) + .mockResolvedValueOnce([]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageQueryTool.execute({ + operation: 'get_slow_queries', + orderBy: 'cache_hit_ratio' + }, mockGetConnectionString); + + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenNthCalledWith(2, expect.stringContaining('END as cache_hit_ratio'), [10]); + expect(mockDb.query).toHaveBeenNthCalledWith(2, expect.stringContaining('ORDER BY cache_hit_ratio DESC'), [10]); + }); + + it('redacts literals in slow-query output from pg_stat_statements', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn() + .mockResolvedValueOnce([{ exists: 1 }]) + .mockResolvedValueOnce([{ + query: "SELECT * FROM users WHERE email = 'admin@example.com' AND token = 'secret'", + calls: 3, + total_time: 120, + mean_time: 40, + rows: 3, + stddev_time: 1, + min_time: 39, + max_time: 41, + shared_blks_hit: 10, + shared_blks_read: 0, + shared_blks_written: 0, + temp_blks_read: 0, + temp_blks_written: 0 + }]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageQueryTool.execute({ + operation: 'get_slow_queries' + }, 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 literals in query stats output from pg_stat_statements', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn() + .mockResolvedValueOnce([{ exists: 1 }]) + .mockResolvedValueOnce([{ + query_id: '123', + query: "UPDATE users SET password = 'new-secret' WHERE id = 42", + calls: 1, + total_time: 20, + mean_time: 20, + min_time: 20, + max_time: 20, + stddev_time: 0, + rows: 1, + shared_blks_hit: 1, + shared_blks_read: 0, + shared_blks_written: 1, + cache_hit_ratio: 100 + }]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageQueryTool.execute({ + operation: 'get_stats' + }, 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('rejects invalid limits 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 manageQueryTool.execute({ + operation: 'get_stats', + limit: 101 + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid input'); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.query).not.toHaveBeenCalled(); + }); + + it('rejects unknown query-management fields before connecting', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + transaction: vi.fn(), + query: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageQueryTool.execute({ + operation: 'explain', + query: 'SELECT * FROM users', + 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.transaction).not.toHaveBeenCalled(); + }); + + it('rejects invalid reset query IDs before executing reset', 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 manageQueryTool.execute({ + operation: 'reset_stats', + queryId: '123;select' + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('queryId must be'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.query).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tools/query.ts b/src/tools/query.ts index 7e856a9..ca5066d 100644 --- a/src/tools/query.ts +++ b/src/tools/query.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 { getReadOnlySqlValidationError, redactSqlText } from '../utils/sql.js'; interface ExplainResult { query: string; @@ -22,14 +23,15 @@ interface SlowQuery { stddev_time: number; min_time: number; max_time: number; - shared_blks_hit: number; - shared_blks_read: number; - shared_blks_written: number; - temp_blks_read: number; - temp_blks_written: number; -} + shared_blks_hit: number; + shared_blks_read: number; + shared_blks_written: number; + temp_blks_read: number; + temp_blks_written: number; + cache_hit_ratio: number; +} -interface QueryStats { +interface QueryStats { query_id: string; query: string; calls: number; @@ -42,10 +44,40 @@ interface QueryStats { shared_blks_hit: number; shared_blks_read: number; shared_blks_written: number; - cache_hit_ratio: number; -} - -const ManageQueryInputSchema = z.object({ + cache_hit_ratio: number; +} + +const MAX_QUERY_STATS_LIMIT = 100; + +function normalizeLimit(limit: number | undefined, defaultValue: number): number { + const value = limit ?? defaultValue; + if (!Number.isInteger(value) || value < 1 || value > MAX_QUERY_STATS_LIMIT) { + throw new McpError(ErrorCode.InvalidParams, `limit must be an integer between 1 and ${MAX_QUERY_STATS_LIMIT}`); + } + + return value; +} + +function assertExplainQuerySafe(query: string): void { + const validationError = getReadOnlySqlValidationError(query); + if (validationError) { + throw new McpError(ErrorCode.InvalidParams, `EXPLAIN ${validationError}`); + } +} + +function parseQueryId(queryId: string): number { + if (!/^-?\d+$/.test(queryId.trim())) { + throw new McpError(ErrorCode.InvalidParams, 'queryId must be a pg_stat_statements numeric query ID'); + } + + return Number(queryId); +} + +function formatValidationError(error: z.ZodError): string { + return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', '); +} + +const ManageQueryInputSchema = z.object({ operation: z.enum(['explain', 'get_slow_queries', 'get_stats', 'reset_stats']).describe( 'Operation: explain (EXPLAIN/EXPLAIN ANALYZE query), get_slow_queries (find slow queries from pg_stat_statements), get_stats (query statistics with cache hit ratios), reset_stats (reset pg_stat_statements)' ), @@ -60,18 +92,18 @@ const ManageQueryInputSchema = z.object({ format: z.enum(['text', 'json', 'xml', 'yaml']).optional().default('json').describe('Output format (for explain operation)'), // GET_SLOW_QUERIES operation parameters - limit: z.number().optional().default(10).describe('Number of slow queries to return (for get_slow_queries operation)'), - minDuration: z.number().optional().describe('Minimum average duration in milliseconds (for get_slow_queries operation)'), + limit: z.number().int().min(1).max(MAX_QUERY_STATS_LIMIT).optional().default(10).describe('Number of slow queries to return (for get_slow_queries operation)'), + minDuration: z.number().min(0).optional().describe('Minimum average duration in milliseconds (for get_slow_queries operation)'), orderBy: z.enum(['mean_time', 'total_time', 'calls', 'cache_hit_ratio']).optional().default('mean_time').describe('Sort order (for get_slow_queries and get_stats operations)'), includeNormalized: z.boolean().optional().default(true).describe('Include normalized query text (for get_slow_queries operation)'), // GET_STATS operation parameters - minCalls: z.number().optional().describe('Minimum number of calls (for get_stats operation)'), + minCalls: z.number().int().min(1).optional().describe('Minimum number of calls (for get_stats operation)'), queryPattern: z.string().optional().describe('Filter queries containing this pattern (for get_stats operation)'), - // RESET_STATS operation parameters - queryId: z.string().optional().describe('Specific query ID to reset (for reset_stats operation, resets all if not provided)'), -}); + // RESET_STATS operation parameters + queryId: z.string().optional().describe('Specific query ID to reset (for reset_stats operation, resets all if not provided)'), +}).strict(); type ManageQueryInput = z.infer; @@ -79,9 +111,10 @@ async function executeExplainQuery( input: ManageQueryInput, getConnectionString: GetConnectionStringFn ): Promise { - if (!input.query) { - throw new McpError(ErrorCode.InvalidParams, 'query parameter is required for explain operation'); - } + if (!input.query) { + throw new McpError(ErrorCode.InvalidParams, 'query parameter is required for explain operation'); + } + assertExplainQuerySafe(input.query); const resolvedConnectionString = getConnectionString(input.connectionString); const db = DatabaseConnection.getInstance(); @@ -98,9 +131,12 @@ async function executeExplainQuery( if (!costs) options.push('COSTS false'); options.push(`FORMAT ${format?.toUpperCase()}`); - const explainQuery = `EXPLAIN (${options.join(', ')}) ${query}`; - - const result = await db.query(explainQuery); + const explainQuery = `EXPLAIN (${options.join(', ')}) ${query}`; + + const result = await db.transaction(async (client) => { + const queryResult = await client.query(explainQuery); + return queryResult.rows; + }, { readOnly: true }); // Extract timing information if available (from EXPLAIN ANALYZE) let execution_time: number | undefined; @@ -125,7 +161,7 @@ async function executeExplainQuery( } return { - query, + query: redactSqlText(query), plan: result, execution_time, planning_time, @@ -134,9 +170,12 @@ async function executeExplainQuery( estimated_rows }; - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to explain query: ${error instanceof Error ? error.message : String(error)}`); - } finally { + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError(ErrorCode.InternalError, `Failed to explain query: ${sanitizeErrorMessage(error)}`); + } finally { await db.disconnect(); } } @@ -162,36 +201,53 @@ async function executeGetSlowQueries( } const queryColumn = includeNormalized ? 'query' : 'query'; - const minDurationClause = minDuration ? `WHERE mean_time >= ${minDuration}` : ''; - const orderByColumn = orderBy === 'cache_hit_ratio' ? 'mean_time' : orderBy || 'mean_time'; // fallback for unsupported column - - const slowQueriesQuery = ` - SELECT - ${queryColumn}, - calls, - total_time, + const slowQueryLimit = normalizeLimit(limit, 10); + const whereConditions: string[] = []; + const params: number[] = [slowQueryLimit]; + if (minDuration !== undefined) { + whereConditions.push('mean_time >= $2'); + params.push(minDuration); + } + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''; + const orderByColumn = orderBy || 'mean_time'; + + const slowQueriesQuery = ` + SELECT + ${queryColumn}, + calls, + total_time, mean_time, rows, stddev_time, min_time, max_time, shared_blks_hit, - shared_blks_read, - shared_blks_written, - temp_blks_read, - temp_blks_written - FROM pg_stat_statements - ${minDurationClause} - ORDER BY ${orderByColumn} DESC - LIMIT $1 - `; + shared_blks_read, + shared_blks_written, + temp_blks_read, + temp_blks_written, + CASE + WHEN (shared_blks_hit + shared_blks_read) = 0 THEN 0 + ELSE round((shared_blks_hit::numeric / (shared_blks_hit + shared_blks_read)::numeric) * 100, 2) + END as cache_hit_ratio + FROM pg_stat_statements + ${whereClause} + ORDER BY ${orderByColumn} DESC + LIMIT $1 + `; + + const result = await db.query(slowQueriesQuery, params); + return result.map((row) => ({ + ...row, + query: redactSqlText(row.query) + })); - const result = await db.query(slowQueriesQuery, [limit]); - return result; - - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to get slow queries: ${error instanceof Error ? error.message : String(error)}`); - } finally { + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError(ErrorCode.InternalError, `Failed to get slow queries: ${sanitizeErrorMessage(error)}`); + } finally { await db.disconnect(); } } @@ -202,8 +258,8 @@ async function executeGetQueryStats( ): Promise { const resolvedConnectionString = getConnectionString(input.connectionString); const db = DatabaseConnection.getInstance(); - const { limit, orderBy, minCalls, queryPattern } = input; - const statsLimit = limit || 20; // Default for stats operation + const { limit, orderBy, minCalls, queryPattern } = input; + const statsLimit = normalizeLimit(limit, 20); try { await db.connect(resolvedConnectionString); @@ -260,38 +316,48 @@ async function executeGetQueryStats( LIMIT $1 `; - const result = await db.query(queryStatsQuery, params); - return result; + const result = await db.query(queryStatsQuery, params); + return result.map((row) => ({ + ...row, + query: redactSqlText(row.query) + })); - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to get query statistics: ${error instanceof Error ? error.message : String(error)}`); - } finally { + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError(ErrorCode.InternalError, `Failed to get query statistics: ${sanitizeErrorMessage(error)}`); + } finally { await db.disconnect(); } } -async function executeResetQueryStats( - input: ManageQueryInput, - getConnectionString: GetConnectionStringFn -): Promise<{ message: string; queryId?: string }> { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); - const { queryId } = input; - - try { - await db.connect(resolvedConnectionString); - - if (queryId) { - await db.query('SELECT pg_stat_statements_reset($1)', [Number(queryId)]); - return { message: `Query statistics reset for query ID: ${queryId}`, queryId }; - } - +async function executeResetQueryStats( + input: ManageQueryInput, + getConnectionString: GetConnectionStringFn +): Promise<{ message: string; queryId?: string }> { + const { queryId } = input; + const parsedQueryId = queryId ? parseQueryId(queryId) : undefined; + const resolvedConnectionString = getConnectionString(input.connectionString); + const db = DatabaseConnection.getInstance(); + + try { + await db.connect(resolvedConnectionString); + + if (parsedQueryId !== undefined) { + await db.query('SELECT pg_stat_statements_reset($1)', [parsedQueryId]); + return { message: `Query statistics reset for query ID: ${queryId}`, queryId }; + } + await db.query('SELECT pg_stat_statements_reset()'); return { message: 'All query statistics have been reset' }; - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to reset query statistics: ${error instanceof Error ? error.message : String(error)}`); - } finally { + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError(ErrorCode.InternalError, `Failed to reset query statistics: ${sanitizeErrorMessage(error)}`); + } finally { await db.disconnect(); } } @@ -323,13 +389,13 @@ export const manageQueryTool: PostgresTool = { description: 'Manage PostgreSQL query analysis and performance - operation="explain" for EXPLAIN plans, operation="get_slow_queries" for slow query analysis, operation="get_stats" for query statistics, operation="reset_stats" for clearing statistics', inputSchema: ManageQueryInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = ManageQueryInputSchema.safeParse(params); - if (!validationResult.success) { - return { - content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], - isError: true - }; - } + const validationResult = ManageQueryInputSchema.safeParse(params); + if (!validationResult.success) { + return { + content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], + isError: true + }; + } try { const result = await executeManageQuery(validationResult.data, getConnectionString); @@ -361,11 +427,11 @@ export const manageQueryTool: 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 in query operation: ${errorMessage}` }], isError: true }; } } -}; \ No newline at end of file +}; diff --git a/src/tools/schema.test.ts b/src/tools/schema.test.ts index 31b42b1..2704a11 100644 --- a/src/tools/schema.test.ts +++ b/src/tools/schema.test.ts @@ -1,12 +1,17 @@ -import { describe, it, expect, vi } from 'vitest'; -import { manageSchemaTools } from './schema'; -import { Pool } from 'pg'; -import { DatabaseConnection } from '../utils/connection'; - -describe('manageSchemaTools', () => { - const mockGetConnectionString = vi.fn().mockReturnValue('mock-connection-string'); - - it('should handle get_info operation', async () => { +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { getSchemaInfoTool, manageSchemaTools } from './schema'; +import { Pool } from 'pg'; +import { DatabaseConnection } from '../utils/connection'; + +describe('manageSchemaTools', () => { + const mockGetConnectionString = vi.fn().mockReturnValue('mock-connection-string'); + + afterEach(() => { + vi.restoreAllMocks(); + mockGetConnectionString.mockClear(); + }); + + it('should handle get_info operation', async () => { const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ table_name: 'users' }] }), connect: vi.fn(), @@ -18,11 +23,497 @@ describe('manageSchemaTools', () => { operation: 'get_info' }, mockGetConnectionString); - expect(result.content).toContainEqual(expect.objectContaining({ type: 'text' })); - expect(mockPool.query).toHaveBeenCalled(); - }); - - it('should throw error for invalid operation', async () => { - await expect(manageSchemaTools.execute({ operation: 'invalid' }, mockGetConnectionString)).rejects.toThrow(); - }); -}); \ No newline at end of file + expect(result.content).toContainEqual(expect.objectContaining({ type: 'text' })); + expect(mockPool.query).toHaveBeenCalled(); + }); + + it('redacts literals in detailed schema metadata', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn() + .mockResolvedValueOnce([ + { + column_name: 'token', + data_type: 'text', + is_nullable: 'NO', + column_default: "'raw-schema-secret'::text" + } + ]) + .mockResolvedValueOnce([ + { + constraint_name: 'users_token_check', + constraint_type: 'CHECK', + definition: "CHECK ((token <> 'raw-schema-secret'))" + } + ]) + .mockResolvedValueOnce([ + { + indexname: 'idx_users_token', + indexdef: "CREATE INDEX idx_users_token ON users USING btree (token) WHERE token = 'raw-schema-secret'" + } + ]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageSchemaTools.execute({ + operation: 'get_info', + tableName: 'users' + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(output).toContain("'?'::text"); + expect(output).toContain("token <> '?'"); + expect(output).toContain("token = '?'"); + expect(output).not.toContain('raw-schema-secret'); + }); + + it('lists tables from the requested schema', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue([{ table_name: 'events' }]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageSchemaTools.execute({ + operation: 'get_info', + schema: 'audit' + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(output).toContain('List of tables in schema audit'); + expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('WHERE table_schema = $1'), ['audit']); + expect(mockDb.query).not.toHaveBeenCalledWith(expect.stringContaining("table_schema = 'public'")); + }); + + it('direct schema-info tool lists tables from the requested schema', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue([{ table_name: 'events' }]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await getSchemaInfoTool.execute({ + schema: 'audit' + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(output).toContain('List of tables in schema audit'); + expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('WHERE table_schema = $1'), ['audit']); + }); + + it('gets detailed table info from the requested schema', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn() + .mockResolvedValueOnce([ + { + column_name: 'event_id', + data_type: 'uuid', + is_nullable: 'NO', + column_default: null + } + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageSchemaTools.execute({ + operation: 'get_info', + schema: 'audit', + tableName: 'events' + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(output).toContain('Schema information for table audit.events'); + expect(output).toContain('"schema": "audit"'); + expect(mockDb.query).toHaveBeenNthCalledWith(1, expect.stringContaining('WHERE table_schema = $1 AND table_name = $2'), ['audit', 'events']); + expect(mockDb.query).toHaveBeenNthCalledWith(2, expect.stringContaining('WHERE tn.nspname = $1 AND cl.relname = $2'), ['audit', 'events']); + expect(mockDb.query).toHaveBeenNthCalledWith(3, expect.stringContaining('WHERE c.relkind = \'r\' AND n.nspname = $1 AND c.relname = $2'), ['audit', 'events']); + }); + + it('should return an MCP error result for invalid operation', async () => { + const result = await manageSchemaTools.execute({ operation: 'invalid' }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid input'); + expect(result.content[0].text).toContain('Invalid enum value'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + }); + + it('rejects unknown schema-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 manageSchemaTools.execute({ + operation: 'create_table', + tableName: 'users', + columns: [{ name: 'id', type: 'integer' }], + 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(); + }); + + it('rejects unknown nested schema-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 manageSchemaTools.execute({ + operation: 'alter_table', + tableName: 'users', + operations: [ + { + type: 'add', + columnName: 'nickname', + dataType: 'text', + using: 'nickname::text' + } + ] + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid input'); + expect(result.content[0].text).toContain('operations.0'); + expect(result.content[0].text).toContain('Unrecognized key'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + }); + + it('creates tables with quoted schema, table, and column 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 manageSchemaTools.execute({ + operation: 'create_table', + schema: 'app', + tableName: 'users', + columns: [ + { name: 'id', type: 'integer', nullable: false }, + { name: 'email', type: 'varchar(255)' }, + { name: 'created_at', type: 'timestamp with time zone' } + ] + }, mockGetConnectionString); + + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenCalledWith( + 'CREATE TABLE IF NOT EXISTS "app"."users" ("id" integer NOT NULL, "email" varchar(255), "created_at" timestamp with time zone)' + ); + }); + + it('does not echo default expressions in create-table 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 manageSchemaTools.execute({ + operation: 'create_table', + schema: 'app', + tableName: 'tokens', + columns: [ + { name: 'token', type: 'text', default: "'raw-secret-token'" } + ] + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenCalledWith( + 'CREATE TABLE IF NOT EXISTS "app"."tokens" ("token" text DEFAULT \'raw-secret-token\')' + ); + expect(output).toContain('"defaultSet": true'); + expect(output).not.toContain('raw-secret-token'); + }); + + it('alters tables with quoted identifiers inside a transaction', async () => { + const client = { query: vi.fn().mockResolvedValue(undefined) }; + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + transaction: vi.fn(async (callback: (client: typeof client) => Promise) => callback(client)), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageSchemaTools.execute({ + operation: 'alter_table', + schema: 'app', + tableName: 'users', + operations: [ + { type: 'add', columnName: 'nickname', dataType: 'text', nullable: true }, + { type: 'drop', columnName: 'legacy_name' } + ] + }, mockGetConnectionString); + + expect(result.isError).toBeUndefined(); + expect(client.query).toHaveBeenNthCalledWith(1, 'ALTER TABLE "app"."users" ADD COLUMN "nickname" text'); + expect(client.query).toHaveBeenNthCalledWith(2, 'ALTER TABLE "app"."users" DROP COLUMN "legacy_name"'); + }); + + it('does not echo default expressions in alter-table success responses', async () => { + const client = { query: vi.fn().mockResolvedValue(undefined) }; + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + transaction: vi.fn(async (callback: (client: typeof client) => Promise) => callback(client)), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageSchemaTools.execute({ + operation: 'alter_table', + schema: 'app', + tableName: 'tokens', + operations: [ + { type: 'add', columnName: 'token', dataType: 'text', default: "'raw-secret-token'" } + ] + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(client.query).toHaveBeenCalledWith( + 'ALTER TABLE "app"."tokens" ADD COLUMN "token" text DEFAULT \'raw-secret-token\'' + ); + expect(output).toContain('"defaultChanged": true'); + expect(output).not.toContain('raw-secret-token'); + }); + + it('splits multi-action alter-column operations into PostgreSQL-valid statements', async () => { + const client = { query: vi.fn().mockResolvedValue(undefined) }; + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + transaction: vi.fn(async (callback: (client: typeof client) => Promise) => callback(client)), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageSchemaTools.execute({ + operation: 'alter_table', + schema: 'app', + tableName: 'settings', + operations: [ + { + type: 'alter', + columnName: 'value', + dataType: 'text', + nullable: false, + default: "'pending'" + } + ] + }, mockGetConnectionString); + + expect(result.isError).toBeUndefined(); + expect(client.query).toHaveBeenNthCalledWith(1, 'ALTER TABLE "app"."settings" ALTER COLUMN "value" TYPE text'); + expect(client.query).toHaveBeenNthCalledWith(2, 'ALTER TABLE "app"."settings" ALTER COLUMN "value" SET NOT NULL'); + expect(client.query).toHaveBeenNthCalledWith(3, 'ALTER TABLE "app"."settings" ALTER COLUMN "value" SET DEFAULT \'pending\''); + }); + + it('does not echo enum values in create-enum 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 manageSchemaTools.execute({ + operation: 'create_enum', + schema: 'app', + enumName: 'token_state', + values: ['raw-secret-token'] + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenCalledWith( + 'CREATE TYPE "app"."token_state" AS ENUM (\'raw-secret-token\');' + ); + expect(output).toContain('"valueCount": 1'); + expect(output).not.toContain('raw-secret-token'); + }); + + it('gets enums as text arrays for stable pg decoding', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue([ + { + enum_schema: 'app', + enum_name: 'status', + enum_values: ['active', "owner's"] + } + ]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageSchemaTools.execute({ + operation: 'get_enums', + schema: 'app', + enumName: 'status' + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('array_agg(e.enumlabel::text ORDER BY e.enumsortorder)'), ['app', 'status']); + expect(output).toContain('"enum_values": ['); + expect(output).toContain('"active"'); + }); + + it('passes undefined connection strings through enum fallback paths', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + await manageSchemaTools.execute({ operation: 'get_enums' }, mockGetConnectionString); + await manageSchemaTools.execute({ + operation: 'create_enum', + enumName: 'status', + values: ['active'] + }, mockGetConnectionString); + + expect(mockGetConnectionString).toHaveBeenNthCalledWith(1, undefined); + expect(mockGetConnectionString).toHaveBeenNthCalledWith(2, undefined); + }); + + it('sanitizes schema database errors before returning them', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockRejectedValue( + new Error("password=db-secret failed near DEFAULT 'raw-secret-token'") + ), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageSchemaTools.execute({ + operation: 'create_table', + schema: 'app', + tableName: 'tokens', + columns: [ + { name: 'token', type: 'text', default: "'raw-secret-token'" } + ] + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to create table'); + expect(result.content[0].text).toContain('password=*****'); + expect(result.content[0].text).toContain("DEFAULT '?'"); + expect(result.content[0].text).not.toContain('db-secret'); + expect(result.content[0].text).not.toContain('raw-secret-token'); + }); + + it('rejects unsafe schema 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 manageSchemaTools.execute({ + operation: 'create_table', + tableName: 'users; drop table users', + columns: [{ name: 'id', type: 'integer' }] + }, 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 data types 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 manageSchemaTools.execute({ + operation: 'create_table', + tableName: 'users', + columns: [{ name: 'id', type: 'integer; drop schema public' }] + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid PostgreSQL data type'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.query).not.toHaveBeenCalled(); + }); + + it('rejects unsafe alter-table data types before resolving a connection string', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + transaction: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageSchemaTools.execute({ + operation: 'alter_table', + tableName: 'users', + operations: [ + { type: 'add', columnName: 'nickname', dataType: 'text; drop schema public' } + ] + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid PostgreSQL data type'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.transaction).not.toHaveBeenCalled(); + }); + + it('rejects unsafe enum 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 manageSchemaTools.execute({ + operation: 'create_enum', + enumName: 'state; drop type state', + 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/schema.ts b/src/tools/schema.ts index 472ac80..afcab8f 100644 --- a/src/tools/schema.ts +++ b/src/tools/schema.ts @@ -1,8 +1,9 @@ -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 type { PoolClient } from 'pg'; // For transaction client type +import type { PostgresTool, GetConnectionStringFn, ToolOutput } from '../types/tool.js'; +import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; +import type { PoolClient } from 'pg'; // For transaction client type +import { buildCreateEnumTypeSql, quoteIdent, quoteQualifiedIdent, redactSqlText } from '../utils/sql.js'; interface SchemaResult { success: boolean; @@ -10,10 +11,11 @@ interface SchemaResult { details: unknown; } -interface TableInfo { - tableName: string; - columns: ColumnInfo[]; - constraints: ConstraintInfo[]; +interface TableInfo { + schema: string; + tableName: string; + columns: ColumnInfo[]; + constraints: ConstraintInfo[]; indexes: IndexInfo[]; } @@ -30,50 +32,71 @@ interface ConstraintInfo { definition: string; } -interface IndexInfo { - name: string; - definition: string; -} +interface IndexInfo { + name: string; + definition: string; +} + +interface CreatedColumnInfo { + name: string; + type: string; + nullable?: boolean; + defaultSet: boolean; +} + +interface AlteredColumnInfo { + type: 'add' | 'alter' | 'drop'; + columnName: string; + dataType?: string; + nullable?: boolean; + defaultChanged: boolean; +} // Enum interfaces (from enums.ts) -interface EnumInfo { - enum_schema: string; - enum_name: string; - enum_values: string[]; -} - -// --- GetSchemaInfo Tool --- -const GetSchemaInfoInputSchema = z.object({ - connectionString: z.string().optional(), - tableName: z.string().optional().describe("Optional table name to get detailed schema for"), -}); -type GetSchemaInfoInput = z.infer; +interface EnumInfo { + enum_schema: string; + enum_name: string; + enum_values: string[]; +} + +function formatValidationError(error: z.ZodError): string { + return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', '); +} + +// --- GetSchemaInfo Tool --- +const GetSchemaInfoInputSchema = z.object({ + connectionString: z.string().optional(), + schema: z.string().optional().default('public').describe("Schema name to inspect"), + tableName: z.string().optional().describe("Optional table name to get detailed schema for"), +}).strict(); +type GetSchemaInfoInput = z.infer; async function executeGetSchemaInfo( input: GetSchemaInfoInput, getConnectionString: GetConnectionStringFn ): Promise { // Return type depends on whether tableName is provided - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); - const { tableName } = input; - - try { - await db.connect(resolvedConnectionString); - - if (tableName) { - return await getTableInfo(db, tableName); - } - - const tables = await db.query<{ table_name: string }>( - `SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' AND table_type = 'BASE TABLE' -- Ensure only base tables - ORDER BY table_name` - ); + const resolvedConnectionString = getConnectionString(input.connectionString); + const db = DatabaseConnection.getInstance(); + const { tableName, schema } = input; + + try { + await db.connect(resolvedConnectionString); + + if (tableName) { + return await getTableInfo(db, tableName, schema); + } + + const tables = await db.query<{ table_name: string }>( + `SELECT table_name + FROM information_schema.tables + WHERE table_schema = $1 AND table_type = 'BASE TABLE' + ORDER BY table_name`, + [schema] + ); return tables.map(t => t.table_name); } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to get schema information: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to get schema information: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -84,72 +107,92 @@ export const getSchemaInfoTool: PostgresTool = { description: 'Get schema information for a database or specific table', inputSchema: GetSchemaInfoInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = GetSchemaInfoInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = GetSchemaInfoInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeGetSchemaInfo(validationResult.data, getConnectionString); - const message = validationResult.data.tableName - ? `Schema information for table ${validationResult.data.tableName}` - : 'List of tables in database'; + const schema = validationResult.data.schema; + const message = validationResult.data.tableName + ? `Schema information for table ${schema}.${validationResult.data.tableName}` + : `List of tables in schema ${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 schema info: ${errorMessage}` }], isError: true }; } } }; // --- CreateTable Tool --- -const CreateTableColumnSchema = z.object({ - name: z.string(), - type: z.string().describe("PostgreSQL data type"), - nullable: z.boolean().optional(), - default: z.string().optional().describe("Default value expression"), - // primaryKey: z.boolean().optional(), // Consider adding PK constraint separately or via constraint tools -}); - -const CreateTableInputSchema = z.object({ - connectionString: z.string().optional(), - tableName: z.string(), - columns: z.array(CreateTableColumnSchema).min(1), - // primaryKeyColumns: z.array(z.string()).optional(), // Alternative for PKs -}); -type CreateTableInput = z.infer; - -async function executeCreateTable( - input: CreateTableInput, - getConnectionString: GetConnectionStringFn -): Promise<{ tableName: string; columns: z.infer[] }> { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); - const { tableName, columns } = input; - - try { - await db.connect(resolvedConnectionString); - - const columnDefs = columns.map(col => { - let def = `"${col.name}" ${col.type}`; - if (col.nullable === false) def += ' NOT NULL'; - if (col.default !== undefined) def += ` DEFAULT ${col.default}`; - // if (col.primaryKey) def += ' PRIMARY KEY'; // If using column-level PK - return def; - }).join(', '); - - // const primaryKeyDef = input.primaryKeyColumns && input.primaryKeyColumns.length > 0 - // ? `, PRIMARY KEY (${input.primaryKeyColumns.map(pk => `"${pk}"`).join(', ')})` - // : ''; - - // const createTableSQL = `CREATE TABLE IF NOT EXISTS "${tableName}" (${columnDefs}${primaryKeyDef})`; - const createTableSQL = `CREATE TABLE IF NOT EXISTS "${tableName}" (${columnDefs})`; - - await db.query(createTableSQL); - - return { tableName, columns }; - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to create table: ${error instanceof Error ? error.message : String(error)}`); - } finally { +const CreateTableColumnSchema = z.object({ + name: z.string(), + type: z.string().describe("PostgreSQL data type"), + nullable: z.boolean().optional(), + default: z.string().optional().describe("Default value expression"), + // primaryKey: z.boolean().optional(), // Consider adding PK constraint separately or via constraint tools +}).strict(); + +const CreateTableInputSchema = z.object({ + connectionString: z.string().optional(), + tableName: z.string(), + schema: z.string().optional().default('public'), + columns: z.array(CreateTableColumnSchema).min(1), + // primaryKeyColumns: z.array(z.string()).optional(), // Alternative for PKs +}).strict(); +type CreateTableInput = z.infer; + +function validateDataType(dataType: string): string { + const normalizedType = dataType.trim().replace(/\s+/g, ' '); + if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?(?:\s+[A-Za-z_][A-Za-z0-9_]*)*(?:\([0-9,\s]+\))?(?:\[\])?$/.test(normalizedType)) { + throw new Error(`Invalid PostgreSQL data type "${dataType}". Use simple type names such as text, integer, numeric(10,2), timestamp with time zone, or schema.type.`); + } + + if (normalizedType.includes('.')) { + return normalizedType.split('.').map(quoteIdent).join('.'); + } + + return normalizedType; +} + +function buildColumnDefinition(column: z.infer): string { + let definition = `${quoteIdent(column.name)} ${validateDataType(column.type)}`; + if (column.nullable === false) definition += ' NOT NULL'; + if (column.default !== undefined) definition += ` DEFAULT ${column.default}`; + return definition; +} +async function executeCreateTable( + input: CreateTableInput, + getConnectionString: GetConnectionStringFn +): Promise<{ tableName: string; schema: string; columnCount: number; columns: CreatedColumnInfo[] }> { + const db = DatabaseConnection.getInstance(); + const { tableName, schema, columns } = input; + + try { + const qualifiedTableName = quoteQualifiedIdent(tableName, schema); + const columnDefs = columns.map(buildColumnDefinition).join(', '); + + const createTableSQL = `CREATE TABLE IF NOT EXISTS ${qualifiedTableName} (${columnDefs})`; + const resolvedConnectionString = getConnectionString(input.connectionString); + + await db.connect(resolvedConnectionString); + await db.query(createTableSQL); + + return { + tableName, + schema, + columnCount: columns.length, + columns: columns.map((column) => ({ + name: column.name, + type: column.type, + nullable: column.nullable, + defaultSet: column.default !== undefined + })) + }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Failed to create table: ${sanitizeErrorMessage(error)}`); + } finally { await db.disconnect(); } } @@ -159,101 +202,110 @@ export const createTableTool: PostgresTool = { description: 'Create a new table in the database', inputSchema: CreateTableInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = CreateTableInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = CreateTableInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeCreateTable(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: `Table ${result.tableName} created successfully (if not exists).` }, { 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 table: ${errorMessage}` }], isError: true }; } } }; // --- AlterTable Tool --- -const AlterTableOperationSchema = z.object({ - type: z.enum(['add', 'alter', 'drop']), - columnName: z.string(), - dataType: z.string().optional().describe("PostgreSQL data type (for add/alter)"), - nullable: z.boolean().optional().describe("Whether the column can be NULL (for add/alter)"), - default: z.string().optional().describe("Default value expression (for add/alter)"), -}); - -const AlterTableInputSchema = z.object({ - connectionString: z.string().optional(), - tableName: z.string(), - operations: z.array(AlterTableOperationSchema).min(1), -}); -type AlterTableInput = z.infer; - -async function executeAlterTable( - input: AlterTableInput, - getConnectionString: GetConnectionStringFn -): Promise<{ tableName: string; operations: z.infer[] }> { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); - const { tableName, operations } = input; - - try { - await db.connect(resolvedConnectionString); - - await db.transaction(async (client: PoolClient) => { - for (const op of operations) { - let sql = ''; - const colNameQuoted = `"${op.columnName}"`; - - switch (op.type) { - case 'add': - if (!op.dataType) throw new Error('Data type is required for ADD operation'); - sql = `ALTER TABLE "${tableName}" ADD COLUMN ${colNameQuoted} ${op.dataType}`; - if (op.nullable === false) sql += ' NOT NULL'; - if (op.default !== undefined) sql += ` DEFAULT ${op.default}`; - break; - - case 'alter': { - // PostgreSQL requires separate ALTER COLUMN clauses for different alterations - // This simplified version might need to be split into multiple statements for complex alters - // Or use a more specific action like 'set data type', 'set default', 'set not null' etc. - sql = `ALTER TABLE "${tableName}" ALTER COLUMN ${colNameQuoted}`; - const alterActions: string[] = []; - if (op.dataType) alterActions.push(`TYPE ${op.dataType}`); // May need USING clause for some type changes - if (op.nullable !== undefined) { - alterActions.push(op.nullable ? 'DROP NOT NULL' : 'SET NOT NULL'); - } - if (op.default !== undefined) { - alterActions.push(op.default === null || op.default === '' - ? 'DROP DEFAULT' - : `SET DEFAULT ${op.default}`); - } - if (alterActions.length === 0) throw new Error('No alter operation specified for column.'); - // This only works if all actions can be combined in one ALTER COLUMN. Often not true. - // A robust solution would execute separate ALTER TABLE ... ALTER COLUMN statements for each action. - // For now, we will assume simple cases or require user to send multiple ops for one column. - sql += ` ${alterActions.join(' ')}`; - if (alterActions.length > 1) { - console.warn("[MCP Warning] Multiple alterations on a single column in one 'alter' operation might not be supported directly by PostgreSQL. Consider separate operations if it fails."); - // Example of how to split: Iterate alterActions and make separate SQL calls. - // For simplicity, current code attempts to combine. - } - break; - } - - case 'drop': - sql = `ALTER TABLE "${tableName}" DROP COLUMN ${colNameQuoted}`; - break; - } - if (sql) { // Ensure sql is not empty, e.g. if alterActions was empty - await client.query(sql); - } - } - }); - - return { tableName, operations }; - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to alter table: ${error instanceof Error ? error.message : String(error)}`); +const AlterTableOperationSchema = z.object({ + type: z.enum(['add', 'alter', 'drop']), + columnName: z.string(), + dataType: z.string().optional().describe("PostgreSQL data type (for add/alter)"), + nullable: z.boolean().optional().describe("Whether the column can be NULL (for add/alter)"), + default: z.string().optional().describe("Default value expression (for add/alter)"), +}).strict(); + +const AlterTableInputSchema = z.object({ + connectionString: z.string().optional(), + tableName: z.string(), + schema: z.string().optional().default('public'), + operations: z.array(AlterTableOperationSchema).min(1), +}).strict(); +type AlterTableInput = z.infer; +async function executeAlterTable( + input: AlterTableInput, + getConnectionString: GetConnectionStringFn +): Promise<{ tableName: string; operationCount: number; operations: AlteredColumnInfo[] }> { + const db = DatabaseConnection.getInstance(); + const { tableName, schema, operations } = input; + + try { + const qualifiedTableName = quoteQualifiedIdent(tableName, schema); + const statements: string[] = []; + + for (const op of operations) { + const colNameQuoted = quoteIdent(op.columnName); + + switch (op.type) { + case 'add': + if (!op.dataType) throw new Error('Data type is required for ADD operation'); + let addColumnSql = `ALTER TABLE ${qualifiedTableName} ADD COLUMN ${colNameQuoted} ${validateDataType(op.dataType)}`; + if (op.nullable === false) addColumnSql += ' NOT NULL'; + if (op.default !== undefined) addColumnSql += ` DEFAULT ${op.default}`; + statements.push(addColumnSql); + break; + + case 'alter': { + let statementCount = 0; + + if (op.dataType) { + statements.push(`ALTER TABLE ${qualifiedTableName} ALTER COLUMN ${colNameQuoted} TYPE ${validateDataType(op.dataType)}`); + statementCount++; + } + + if (op.nullable !== undefined) { + statements.push(`ALTER TABLE ${qualifiedTableName} ALTER COLUMN ${colNameQuoted} ${op.nullable ? 'DROP NOT NULL' : 'SET NOT NULL'}`); + statementCount++; + } + + if (op.default !== undefined) { + statements.push(`ALTER TABLE ${qualifiedTableName} ALTER COLUMN ${colNameQuoted} ${op.default === '' ? 'DROP DEFAULT' : `SET DEFAULT ${op.default}`}`); + statementCount++; + } + + if (statementCount === 0) { + throw new Error('No alter operation specified for column.'); + } + break; + } + + case 'drop': + statements.push(`ALTER TABLE ${qualifiedTableName} DROP COLUMN ${colNameQuoted}`); + break; + } + } + + const resolvedConnectionString = getConnectionString(input.connectionString); + await db.connect(resolvedConnectionString); + await db.transaction(async (client: PoolClient) => { + for (const sql of statements) { + await client.query(sql); + } + }); + + return { + tableName, + operationCount: operations.length, + operations: operations.map((operation) => ({ + type: operation.type, + columnName: operation.columnName, + dataType: operation.dataType, + nullable: operation.nullable, + defaultChanged: operation.default !== undefined + })) + }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Failed to alter table: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -264,15 +316,15 @@ export const alterTableTool: PostgresTool = { description: 'Alter an existing table (add/modify/drop columns)', inputSchema: AlterTableInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = AlterTableInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = AlterTableInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeAlterTable(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: `Table ${result.tableName} altered 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 altering table: ${errorMessage}` }], isError: true }; } } @@ -281,7 +333,7 @@ export const alterTableTool: PostgresTool = { /** * Get detailed information about a specific table */ -async function getTableInfo(db: DatabaseConnection, tableName: string): Promise { +async function getTableInfo(db: DatabaseConnection, tableName: string, schema: string): Promise { // Get column information const columns = await db.query<{ column_name: string; @@ -289,12 +341,12 @@ async function getTableInfo(db: DatabaseConnection, tableName: string): Promise< is_nullable: string; column_default: string | null; }>( - `SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = $1 - ORDER BY ordinal_position`, - [tableName] - ); + `SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + ORDER BY ordinal_position`, + [schema, tableName] + ); // Get constraint information const constraints = await db.query<{ @@ -312,12 +364,13 @@ async function getTableInfo(db: DatabaseConnection, tableName: string): Promise< ELSE c.contype::text END as constraint_type, pg_get_constraintdef(c.oid) as definition - FROM pg_constraint c - JOIN pg_namespace n ON n.oid = c.connamespace - JOIN pg_class cl ON cl.oid = c.conrelid - WHERE n.nspname = 'public' AND cl.relname = $1`, - [tableName] - ); + FROM pg_constraint c + JOIN pg_namespace n ON n.oid = c.connamespace + JOIN pg_class cl ON cl.oid = c.conrelid + JOIN pg_namespace tn ON tn.oid = cl.relnamespace + WHERE tn.nspname = $1 AND cl.relname = $2`, + [schema, tableName] + ); // Get index information const indexes = await db.query<{ @@ -328,39 +381,40 @@ async function getTableInfo(db: DatabaseConnection, tableName: string): Promise< i.relname as indexname, pg_get_indexdef(i.oid) as indexdef FROM pg_index x - JOIN pg_class c ON c.oid = x.indrelid - JOIN pg_class i ON i.oid = x.indexrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind = 'r' AND n.nspname = 'public' AND c.relname = $1`, - [tableName] - ); - - return { - tableName, + JOIN pg_class c ON c.oid = x.indrelid + JOIN pg_class i ON i.oid = x.indexrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' AND n.nspname = $1 AND c.relname = $2`, + [schema, tableName] + ); + + return { + schema, + tableName, columns: columns.map(col => ({ name: col.column_name, dataType: col.data_type, nullable: col.is_nullable === 'YES', - default: col.column_default + default: col.column_default ? redactSqlText(col.column_default) : null })), constraints: constraints.map(con => ({ name: con.constraint_name, type: con.constraint_type, - definition: con.definition + definition: redactSqlText(con.definition) })), indexes: indexes.map(idx => ({ name: idx.indexname, - definition: idx.indexdef + definition: redactSqlText(idx.indexdef) })) }; } // Enum functions (adapted from enums.ts) -async function executeGetEnumsInSchema( - connectionString: string, - schema = 'public', - enumName?: string, - getConnectionString?: GetConnectionStringFn +async function executeGetEnumsInSchema( + connectionString: string | undefined, + schema = 'public', + enumName?: string, + getConnectionString?: GetConnectionStringFn ): Promise { const resolvedConnectionString = getConnectionString ? getConnectionString(connectionString) : connectionString; const db = DatabaseConnection.getInstance(); @@ -370,7 +424,7 @@ async function executeGetEnumsInSchema( 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 @@ -389,120 +443,90 @@ async function executeGetEnumsInSchema( return result; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to fetch ENUMs: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to fetch ENUMs: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } } -async function executeCreateEnumInSchema( - connectionString: string, - enumName: string, - values: string[], +async function executeCreateEnumInSchema( + connectionString: string | undefined, + enumName: string, + values: string[], schema = 'public', ifNotExists = false, getConnectionString?: GetConnectionStringFn -): Promise<{ schema: string; enumName: string; values: string[]}> { - const resolvedConnectionString = getConnectionString ? getConnectionString(connectionString) : connectionString; - const db = DatabaseConnection.getInstance(); - try { - await db.connect(resolvedConnectionString); - const qualifiedSchema = `"${schema}"`; - const qualifiedEnumName = `"${enumName}"`; - const fullEnumName = `${qualifiedSchema}.${qualifiedEnumName}`; - 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 }; - } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to create ENUM ${enumName}: ${error instanceof Error ? error.message : String(error)}`); +): Promise<{ schema: string; enumName: string; valueCount: number}> { + const db = DatabaseConnection.getInstance(); + try { + const query = buildCreateEnumTypeSql(enumName, values, schema, ifNotExists); + const resolvedConnectionString = getConnectionString ? getConnectionString(connectionString) : connectionString; + + await db.connect(resolvedConnectionString); + await db.query(query); + return { schema, enumName, valueCount: values.length }; + } catch (error) { + throw new McpError(ErrorCode.InternalError, `Failed to create ENUM ${enumName}: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); - } -} - -// Complete Consolidated Schema Management Tool (covers all 5 operations) -export const manageSchemaTools: PostgresTool = { - name: 'pg_manage_schema', - description: 'Manage PostgreSQL schema - get schema info, create/alter tables, manage enums. Examples: operation="get_info" for table lists, operation="create_table" with tableName and columns, operation="get_enums" to list enums, operation="create_enum" with enumName and values', - inputSchema: z.object({ - connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'), - operation: z.enum(['get_info', 'create_table', 'alter_table', 'get_enums', 'create_enum']).describe('Operation: get_info (schema/table info), create_table (new table), alter_table (modify table), get_enums (list ENUMs), create_enum (new ENUM)'), - - // Common parameters - tableName: z.string().optional().describe('Table name (optional for get_info to get specific table info, required for create_table/alter_table)'), - schema: z.string().optional().describe('Schema name (defaults to public)'), - - // Create table parameters - columns: z.array(z.object({ - name: z.string(), - type: z.string().describe("PostgreSQL data type"), - nullable: z.boolean().optional(), - default: z.string().optional().describe("Default value expression"), - })).optional().describe('Column definitions (required for create_table)'), - - // Alter table parameters - operations: z.array(z.object({ - type: z.enum(['add', 'alter', 'drop']), - columnName: z.string(), - dataType: z.string().optional().describe("PostgreSQL data type (for add/alter)"), - nullable: z.boolean().optional().describe("Whether the column can be NULL (for add/alter)"), - default: z.string().optional().describe("Default value expression (for add/alter)"), - })).optional().describe('Alter operations (required for alter_table)'), - - // Enum parameters - enumName: z.string().optional().describe('ENUM name (optional for get_enums to filter, required for create_enum)'), - values: z.array(z.string()).optional().describe('ENUM values (required for create_enum)'), - ifNotExists: z.boolean().optional().describe('Include IF NOT EXISTS clause (for create_enum)') - }), - // biome-ignore lint/suspicious/noExplicitAny: - execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => { - const { - connectionString: connStringArg, - operation, - tableName, + } +} + +const ManageSchemaInputSchema = z.object({ + connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'), + operation: z.enum(['get_info', 'create_table', 'alter_table', 'get_enums', 'create_enum']).describe('Operation: get_info (schema/table info), create_table (new table), alter_table (modify table), get_enums (list ENUMs), create_enum (new ENUM)'), + + // Common parameters + tableName: z.string().optional().describe('Table name (optional for get_info to get specific table info, required for create_table/alter_table)'), + schema: z.string().optional().describe('Schema name (defaults to public)'), + + // Create table parameters + columns: z.array(CreateTableColumnSchema).optional().describe('Column definitions (required for create_table)'), + + // Alter table parameters + operations: z.array(AlterTableOperationSchema).optional().describe('Alter operations (required for alter_table)'), + + // Enum parameters + enumName: z.string().optional().describe('ENUM name (optional for get_enums to filter, required for create_enum)'), + values: z.array(z.string()).optional().describe('ENUM values (required for create_enum)'), + ifNotExists: z.boolean().optional().describe('Ignore duplicate type errors by wrapping CREATE TYPE in a DO block (for create_enum)') +}).strict(); + +// Complete Consolidated Schema Management Tool (covers all 5 operations) +export const manageSchemaTools: PostgresTool = { + name: 'pg_manage_schema', + description: 'Manage PostgreSQL schema - get schema info, create/alter tables, manage enums. Examples: operation="get_info" for table lists, operation="create_table" with tableName and columns, operation="get_enums" to list enums, operation="create_enum" with enumName and values', + inputSchema: ManageSchemaInputSchema, + execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => { + const validationResult = ManageSchemaInputSchema.safeParse(args); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } + + const { + connectionString: connStringArg, + operation, + tableName, schema, columns, operations, - enumName, - values, - ifNotExists - } = args as { - connectionString?: string; - operation: 'get_info' | 'create_table' | 'alter_table' | 'get_enums' | 'create_enum'; - tableName?: string; - schema?: string; - columns?: Array<{ - name: string; - type: string; - nullable?: boolean; - default?: string; - }>; - operations?: Array<{ - type: 'add' | 'alter' | 'drop'; - columnName: string; - dataType?: string; - nullable?: boolean; - default?: string; - }>; - enumName?: string; - values?: string[]; - ifNotExists?: boolean; - }; + enumName, + values, + ifNotExists + } = validationResult.data; try { switch (operation) { - case 'get_info': { - const result = await executeGetSchemaInfo({ - connectionString: connStringArg, - tableName - }, getConnectionStringVal); - const message = tableName - ? `Schema information for table ${tableName}` - : 'List of tables in database'; + case 'get_info': { + const result = await executeGetSchemaInfo({ + connectionString: connStringArg, + schema: schema || 'public', + tableName + }, getConnectionStringVal); + const resolvedSchema = schema || 'public'; + const message = tableName + ? `Schema information for table ${resolvedSchema}.${tableName}` + : `List of tables in schema ${resolvedSchema}`; return { content: [{ type: 'text', text: message }, { type: 'text', text: JSON.stringify(result, null, 2) }] }; } @@ -513,11 +537,12 @@ export const manageSchemaTools: PostgresTool = { isError: true }; } - const result = await executeCreateTable({ - connectionString: connStringArg, - tableName, - columns - }, getConnectionStringVal); + const result = await executeCreateTable({ + connectionString: connStringArg, + tableName, + schema: schema || 'public', + columns + }, getConnectionStringVal); return { content: [{ type: 'text', text: `Table ${result.tableName} created successfully (if not exists).` }, { type: 'text', text: JSON.stringify(result, null, 2) }] }; } @@ -528,20 +553,21 @@ export const manageSchemaTools: PostgresTool = { isError: true }; } - const result = await executeAlterTable({ - connectionString: connStringArg, - tableName, - operations - }, getConnectionStringVal); + const result = await executeAlterTable({ + connectionString: connStringArg, + tableName, + schema: schema || 'public', + operations + }, getConnectionStringVal); return { content: [{ type: 'text', text: `Table ${result.tableName} altered successfully.` }, { type: 'text', text: JSON.stringify(result, null, 2) }] }; } - case 'get_enums': { - const result = await executeGetEnumsInSchema( - connStringArg || '', - schema || 'public', - enumName, - getConnectionStringVal + case 'get_enums': { + const result = await executeGetEnumsInSchema( + connStringArg, + schema || 'public', + enumName, + getConnectionStringVal ); return { content: [{ type: 'text', text: `Fetched ${result.length} ENUM(s).` }, { type: 'text', text: JSON.stringify(result, null, 2) }] }; } @@ -552,12 +578,12 @@ export const manageSchemaTools: PostgresTool = { content: [{ type: 'text', text: 'Error: enumName and values are required for create_enum operation' }], isError: true }; - } - const result = await executeCreateEnumInSchema( - connStringArg || '', - enumName, - values, - schema || 'public', + } + const result = await executeCreateEnumInSchema( + connStringArg, + enumName, + values, + schema || 'public', ifNotExists || false, getConnectionStringVal ); @@ -572,8 +598,8 @@ export const manageSchemaTools: 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/triggers.test.ts b/src/tools/triggers.test.ts new file mode 100644 index 0000000..74c0cd4 --- /dev/null +++ b/src/tools/triggers.test.ts @@ -0,0 +1,273 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DatabaseConnection } from '../utils/connection'; +import { manageTriggersTools } from './triggers'; + +describe('manageTriggersTools', () => { + const mockGetConnectionString = vi.fn().mockReturnValue('postgresql://test'); + + afterEach(() => { + vi.restoreAllMocks(); + mockGetConnectionString.mockClear(); + }); + + it('creates triggers with quoted table, trigger, and function 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 manageTriggersTools.execute({ + operation: 'create', + triggerName: 'audit_trigger', + tableName: 'users', + functionName: 'audit_function', + schema: 'public', + timing: 'BEFORE', + events: ['INSERT', 'UPDATE'], + forEach: 'ROW' + }, mockGetConnectionString); + + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenCalledTimes(1); + const sql = mockDb.query.mock.calls[0][0] as string; + expect(sql).toContain('CREATE TRIGGER "audit_trigger"'); + expect(sql).toContain('BEFORE INSERT OR UPDATE'); + expect(sql).toContain('ON "users"'); + expect(sql).toContain('FOR EACH ROW'); + expect(sql).toContain('EXECUTE FUNCTION "audit_function"()'); + }); + + it('quotes non-public schema names in trigger DDL', 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 manageTriggersTools.execute({ + operation: 'set_state', + triggerName: 'audit_trigger', + tableName: 'users', + schema: 'audit', + enable: false + }, mockGetConnectionString); + + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenCalledWith('ALTER TABLE "audit"."users" DISABLE TRIGGER "audit_trigger"'); + }); + + it('redacts trigger definitions returned from catalog lookups', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue([{ + name: 'audit_trigger', + tableName: 'users', + tableSchema: 'public', + event: 'UPDATE', + timing: 'BEFORE', + definition: "CREATE TRIGGER audit_trigger BEFORE UPDATE ON users FOR EACH ROW WHEN ((NEW.token = 'raw-trigger-secret')) EXECUTE FUNCTION audit_function()", + function: 'audit_function', + enabled: true + }]), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageTriggersTools.execute({ + operation: 'get' + }, mockGetConnectionString); + + const output = result.content.map((item) => item.text).join('\n'); + expect(result.isError).toBeUndefined(); + expect(output).toContain("NEW.token = '?'"); + expect(output).not.toContain('raw-trigger-secret'); + }); + + it('sanitizes trigger database errors before returning them', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockRejectedValue( + new Error("password=db-secret failed near WHEN (NEW.token = 'raw-trigger-secret')") + ), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageTriggersTools.execute({ + operation: 'create', + triggerName: 'audit_trigger', + tableName: 'users', + functionName: 'audit_function', + when: "NEW.token = 'raw-trigger-secret'" + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to create trigger'); + expect(result.content[0].text).toContain('password=*****'); + expect(result.content[0].text).toContain("NEW.token = '?'"); + expect(result.content[0].text).not.toContain('db-secret'); + expect(result.content[0].text).not.toContain('raw-trigger-secret'); + }); + + it('rejects unknown trigger-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 manageTriggersTools.execute({ + operation: 'create', + triggerName: 'audit_trigger', + tableName: 'users', + functionName: 'audit_function', + rawSql: 'DROP TRIGGER audit_trigger 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 trigger-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 manageTriggersTools.execute({ + operation: 'create', + triggerName: 'audit_trigger', + tableName: 'users', + functionName: 'audit_function', + events: ['INSERT', 'SELECT'] + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid input'); + expect(result.content[0].text).toContain('events.1'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.query).not.toHaveBeenCalled(); + }); + + it('rejects empty trigger event 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 manageTriggersTools.execute({ + operation: 'create', + triggerName: 'audit_trigger', + tableName: 'users', + functionName: 'audit_function', + events: [] + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid input'); + expect(result.content[0].text).toContain('events'); + 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 manageTriggersTools.execute({ + operation: 'create', + triggerName: 'audit_trigger', + tableName: 'users; drop table users', + functionName: 'audit_function' + }, 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 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 manageTriggersTools.execute({ + operation: 'create', + triggerName: 'audit_trigger', + tableName: 'users', + functionName: 'audit_function; drop function audit_function' + }, 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 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 manageTriggersTools.execute({ + operation: 'drop', + triggerName: 'audit_trigger; 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 state-change 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 manageTriggersTools.execute({ + operation: 'set_state', + triggerName: 'audit_trigger', + tableName: 'users; drop table users', + enable: false + }, 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/triggers.ts b/src/tools/triggers.ts index b3b1ae3..1db3c0d 100644 --- a/src/tools/triggers.ts +++ b/src/tools/triggers.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 { quoteIdent, quoteQualifiedIdent, redactSqlText } from '../utils/sql.js'; interface TriggerResult { success: boolean; @@ -20,6 +21,17 @@ interface TriggerInfo { enabled: boolean; } +function redactTriggerInfo(trigger: TriggerInfo): TriggerInfo { + return { + ...trigger, + definition: redactSqlText(trigger.definition) + }; +} + +function formatValidationError(error: z.ZodError): string { + return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', '); +} + /** * Get information about database triggers */ @@ -76,7 +88,7 @@ export async function getTriggers( connectionString: string, tableName?: strin query += ' ORDER BY c.relname, t.tgname'; - const triggers = await db.query(query, params); + const triggers = (await db.query(query, params)).map(redactTriggerInfo); return { success: true, @@ -88,7 +100,7 @@ export async function getTriggers( connectionString: string, tableName?: strin } catch (error) { return { success: false, - message: `Failed to get trigger information: ${error instanceof Error ? error.message : String(error)}`, + message: `Failed to get trigger information: ${sanitizeErrorMessage(error)}`, details: null }; } finally { @@ -116,19 +128,19 @@ export async function createTrigger( const db = DatabaseConnection.getInstance(); try { - await db.connect(connectionString); - const schema = options.schema || 'public'; const timing = options.timing || 'AFTER'; const events = options.events || ['INSERT']; const forEach = options.forEach || 'ROW'; const createOrReplace = options.replace ? 'CREATE OR REPLACE' : 'CREATE'; + const qualifiedTableName = quoteQualifiedIdent(tableName, schema); + const qualifiedFunctionName = quoteQualifiedIdent(functionName, schema); // Build trigger creation SQL let sql = ` - ${createOrReplace} TRIGGER ${triggerName} + ${createOrReplace} TRIGGER ${quoteIdent(triggerName)} ${timing} ${events.join(' OR ')} - ON ${schema}.${tableName} + ON ${qualifiedTableName} `; // Add FOR EACH clause @@ -142,8 +154,9 @@ export async function createTrigger( } // Add EXECUTE PROCEDURE clause - sql += ` EXECUTE FUNCTION ${functionName}()`; + sql += ` EXECUTE FUNCTION ${qualifiedFunctionName}()`; + await db.connect(connectionString); await db.query(sql); return { @@ -161,7 +174,7 @@ export async function createTrigger( } catch (error) { return { success: false, - message: `Failed to create trigger: ${error instanceof Error ? error.message : String(error)}`, + message: `Failed to create trigger: ${sanitizeErrorMessage(error)}`, details: null }; } finally { @@ -185,20 +198,20 @@ export async function dropTrigger( 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' : ''; + const qualifiedTableName = quoteQualifiedIdent(tableName, schema); // Build trigger drop SQL - let sql = `DROP TRIGGER ${ifExists} ${triggerName} ON ${schema}.${tableName}`; + let sql = `DROP TRIGGER ${ifExists} ${quoteIdent(triggerName)} ON ${qualifiedTableName}`; // Add cascade if specified if (cascade) { sql += ` ${cascade}`; } + await db.connect(connectionString); await db.query(sql); return { @@ -213,7 +226,7 @@ export async function dropTrigger( } catch (error) { return { success: false, - message: `Failed to drop trigger: ${error instanceof Error ? error.message : String(error)}`, + message: `Failed to drop trigger: ${sanitizeErrorMessage(error)}`, details: null }; } finally { @@ -236,14 +249,14 @@ export async function setTriggerState( const db = DatabaseConnection.getInstance(); try { - await db.connect(connectionString); - const schema = options.schema || 'public'; const action = enable ? 'ENABLE' : 'DISABLE'; + const qualifiedTableName = quoteQualifiedIdent(tableName, schema); // Build trigger alter SQL - const sql = `ALTER TABLE ${schema}.${tableName} ${action} TRIGGER ${triggerName}`; + const sql = `ALTER TABLE ${qualifiedTableName} ${action} TRIGGER ${quoteIdent(triggerName)}`; + await db.connect(connectionString); await db.query(sql); return { @@ -259,7 +272,7 @@ export async function setTriggerState( } catch (error) { return { success: false, - message: `Failed to ${enable ? 'enable' : 'disable'} trigger: ${error instanceof Error ? error.message : String(error)}`, + message: `Failed to ${enable ? 'enable' : 'disable'} trigger: ${sanitizeErrorMessage(error)}`, details: null }; } finally { @@ -272,7 +285,7 @@ const GetTriggersInputSchema = z.object({ connectionString: z.string().optional(), tableName: z.string().optional(), schema: z.string().optional().default('public'), -}); +}).strict(); type GetTriggersInput = z.infer; async function executeGetTriggers( input: GetTriggersInput, getConnectionString: GetConnectionStringFn): Promise { const resolvedConnectionString = getConnectionString(input.connectionString); const db = DatabaseConnection.getInstance(); const { tableName, schema } = input; try { await db.connect(resolvedConnectionString); let query = ` @@ -323,9 +336,9 @@ async function executeGetTriggers( input: GetTriggersInput, getConnectionStrin query += ' ORDER BY c.relname, t.tgname'; const triggers = await db.query(query, params); - return triggers; + return triggers.map(redactTriggerInfo); } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to get trigger information: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to get trigger information: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -338,7 +351,7 @@ export const getTriggersTool: PostgresTool = { async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { const validationResult = GetTriggersInputSchema.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 triggers = await executeGetTriggers(validationResult.data, getConnectionString); @@ -348,7 +361,7 @@ export const getTriggersTool: PostgresTool = { : `Found ${triggers.length} triggers in schema ${schema}`; return { content: [{ type: 'text', text: message }, { type: 'text', text: JSON.stringify(triggers, 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 triggers: ${errorMessage}` }], isError: true }; } } @@ -366,26 +379,23 @@ const CreateTriggerInputSchema = z.object({ when: z.string().optional(), forEach: z.enum(['ROW', 'STATEMENT']).optional().default('ROW'), replace: z.boolean().optional().default(false), -}); +}).strict(); type CreateTriggerInput = z.infer; async function executeCreateTrigger( input: CreateTriggerInput, getConnectionString: GetConnectionStringFn ): Promise<{ name: string; table: string; schema: string; timing: string; events: string[]; function: string }> { - const resolvedConnectionString = getConnectionString(input.connectionString); const db = DatabaseConnection.getInstance(); const { triggerName, tableName, functionName, schema, timing, events, when, forEach, replace } = input; try { - await db.connect(resolvedConnectionString); - const createOrReplace = replace ? 'CREATE OR REPLACE' : 'CREATE'; - const qualifiedTableName = `"${schema}"."${tableName}"`; - const qualifiedFunctionName = `"${functionName}"`; // Assuming functionName might also need quoting or schema qualification + const qualifiedTableName = quoteQualifiedIdent(tableName, schema); + const qualifiedFunctionName = quoteQualifiedIdent(functionName, schema); let sql = ` - ${createOrReplace} TRIGGER "${triggerName}" + ${createOrReplace} TRIGGER ${quoteIdent(triggerName)} ${timing} ${events.join(' OR ')} ON ${qualifiedTableName} `; @@ -398,8 +408,10 @@ async function executeCreateTrigger( sql += ` WHEN (${when})`; } - sql += ` EXECUTE FUNCTION ${qualifiedFunctionName}()`; // Ensure function has () if it's a procedure/function call + sql += ` EXECUTE FUNCTION ${qualifiedFunctionName}()`; + const resolvedConnectionString = getConnectionString(input.connectionString); + await db.connect(resolvedConnectionString); await db.query(sql); return { @@ -411,7 +423,7 @@ async function executeCreateTrigger( function: functionName }; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to create trigger: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to create trigger: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -424,13 +436,13 @@ export const createTriggerTool: PostgresTool = { async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { const validationResult = CreateTriggerInputSchema.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 executeCreateTrigger(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: `Trigger ${result.name} created successfully on ${result.schema}.${result.table}` }, { 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 trigger: ${errorMessage}` }], isError: true }; } } @@ -444,34 +456,33 @@ const DropTriggerInputSchema = z.object({ schema: z.string().optional().default('public'), ifExists: z.boolean().optional().default(false), cascade: z.boolean().optional().default(false), -}); +}).strict(); type DropTriggerInput = z.infer; async function executeDropTrigger( input: DropTriggerInput, getConnectionString: GetConnectionStringFn ): Promise<{ name: string; table: string; schema: string }> { - const resolvedConnectionString = getConnectionString(input.connectionString); const db = DatabaseConnection.getInstance(); const { triggerName, tableName, schema, ifExists, cascade } = input; try { - await db.connect(resolvedConnectionString); - const ifExistsClause = ifExists ? 'IF EXISTS' : ''; const cascadeClause = cascade ? 'CASCADE' : ''; - const qualifiedTableName = `"${schema}"."${tableName}"`; + const qualifiedTableName = quoteQualifiedIdent(tableName, schema); - let sql = `DROP TRIGGER ${ifExistsClause} "${triggerName}" ON ${qualifiedTableName}`; + let sql = `DROP TRIGGER ${ifExistsClause} ${quoteIdent(triggerName)} ON ${qualifiedTableName}`; if (cascadeClause) { sql += ` ${cascadeClause}`; } + const resolvedConnectionString = getConnectionString(input.connectionString); + await db.connect(resolvedConnectionString); await db.query(sql); return { name: triggerName, table: tableName, schema }; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to drop trigger: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to drop trigger: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -484,13 +495,13 @@ export const dropTriggerTool: PostgresTool = { async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { const validationResult = DropTriggerInputSchema.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 executeDropTrigger(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: `Trigger ${result.name} dropped successfully from ${result.schema}.${result.table}` }, { 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 trigger: ${errorMessage}` }], isError: true }; } } @@ -503,30 +514,29 @@ const SetTriggerStateInputSchema = z.object({ tableName: z.string(), enable: z.boolean(), schema: z.string().optional().default('public'), -}); +}).strict(); type SetTriggerStateInput = z.infer; async function executeSetTriggerState( input: SetTriggerStateInput, getConnectionString: GetConnectionStringFn ): Promise<{ name: string; table: string; schema: string; enabled: boolean }> { - const resolvedConnectionString = getConnectionString(input.connectionString); const db = DatabaseConnection.getInstance(); const { triggerName, tableName, enable, schema } = input; try { - await db.connect(resolvedConnectionString); - const action = enable ? 'ENABLE' : 'DISABLE'; - const qualifiedTableName = `"${schema}"."${tableName}"`; + const qualifiedTableName = quoteQualifiedIdent(tableName, schema); - const sql = `ALTER TABLE ${qualifiedTableName} ${action} TRIGGER "${triggerName}"`; + const sql = `ALTER TABLE ${qualifiedTableName} ${action} TRIGGER ${quoteIdent(triggerName)}`; + const resolvedConnectionString = getConnectionString(input.connectionString); + await db.connect(resolvedConnectionString); await db.query(sql); return { name: triggerName, table: tableName, schema, enabled: enable }; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to set trigger state: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to set trigger state: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -539,51 +549,57 @@ export const setTriggerStateTool: PostgresTool = { async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { const validationResult = SetTriggerStateInputSchema.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 executeSetTriggerState(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: `Trigger ${result.name} ${result.enabled ? 'enabled' : 'disabled'} on ${result.schema}.${result.table}` }, { 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 setting trigger state: ${errorMessage}` }], isError: true }; } } }; +const ManageTriggersInputSchema = z.object({ + connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'), + operation: z.enum(['get', 'create', 'drop', 'set_state']).describe('Operation: get (list triggers), create (new trigger), drop (remove trigger), set_state (enable/disable trigger)'), + + // Common parameters + schema: z.string().optional().describe('Schema name (defaults to public)'), + tableName: z.string().optional().describe('Table name (optional filter for get, required for create/drop/set_state)'), + + // Trigger identification (for create/drop/set_state) + triggerName: z.string().optional().describe('Trigger name (required for create/drop/set_state)'), + + // Create trigger parameters + functionName: z.string().optional().describe('Function name (required for create operation)'), + timing: z.enum(['BEFORE', 'AFTER', 'INSTEAD OF']).optional().describe('Trigger timing (for create operation, defaults to AFTER)'), + events: z.array(z.enum(['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE'])).min(1).optional().describe('Trigger events (for create operation, defaults to ["INSERT"])'), + forEach: z.enum(['ROW', 'STATEMENT']).optional().describe('FOR EACH ROW or STATEMENT (for create operation, defaults to ROW)'), + when: z.string().optional().describe('WHEN clause condition (for create operation)'), + replace: z.boolean().optional().describe('Whether to replace trigger if exists (for create operation)'), + + // Drop trigger parameters + ifExists: z.boolean().optional().describe('Include IF EXISTS clause (for drop operation)'), + cascade: z.boolean().optional().describe('Include CASCADE clause (for drop operation)'), + + // Set state parameters + enable: z.boolean().optional().describe('Whether to enable the trigger (required for set_state operation)') +}).strict(); + // Complete Consolidated Trigger Management Tool (covers all 4 operations) export const manageTriggersTools: PostgresTool = { name: 'pg_manage_triggers', description: 'Manage PostgreSQL triggers - get, create, drop, and enable/disable triggers. Examples: operation="get" to list triggers, operation="create" with triggerName, tableName, functionName, operation="drop" with triggerName and tableName, operation="set_state" with triggerName, tableName, enable', - inputSchema: z.object({ - connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'), - operation: z.enum(['get', 'create', 'drop', 'set_state']).describe('Operation: get (list triggers), create (new trigger), drop (remove trigger), set_state (enable/disable trigger)'), - - // Common parameters - schema: z.string().optional().describe('Schema name (defaults to public)'), - tableName: z.string().optional().describe('Table name (optional filter for get, required for create/drop/set_state)'), - - // Trigger identification (for create/drop/set_state) - triggerName: z.string().optional().describe('Trigger name (required for create/drop/set_state)'), - - // Create trigger parameters - functionName: z.string().optional().describe('Function name (required for create operation)'), - timing: z.enum(['BEFORE', 'AFTER', 'INSTEAD OF']).optional().describe('Trigger timing (for create operation, defaults to AFTER)'), - events: z.array(z.enum(['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE'])).optional().describe('Trigger events (for create operation, defaults to ["INSERT"])'), - forEach: z.enum(['ROW', 'STATEMENT']).optional().describe('FOR EACH ROW or STATEMENT (for create operation, defaults to ROW)'), - when: z.string().optional().describe('WHEN clause condition (for create operation)'), - replace: z.boolean().optional().describe('Whether to replace trigger if exists (for create operation)'), - - // Drop trigger parameters - ifExists: z.boolean().optional().describe('Include IF EXISTS clause (for drop operation)'), - cascade: z.boolean().optional().describe('Include CASCADE clause (for drop operation)'), - - // Set state parameters - enable: z.boolean().optional().describe('Whether to enable the trigger (required for set_state operation)') - }), - // biome-ignore lint/suspicious/noExplicitAny: - execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => { - const { + inputSchema: ManageTriggersInputSchema, + execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => { + const validationResult = ManageTriggersInputSchema.safeParse(args); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } + + const { connectionString: connStringArg, operation, schema, @@ -598,22 +614,7 @@ export const manageTriggersTools: PostgresTool = { ifExists, cascade, enable - } = args as { - connectionString?: string; - operation: 'get' | 'create' | 'drop' | 'set_state'; - schema?: string; - tableName?: string; - triggerName?: string; - functionName?: string; - timing?: 'BEFORE' | 'AFTER' | 'INSTEAD OF'; - events?: ('INSERT' | 'UPDATE' | 'DELETE' | 'TRUNCATE')[]; - forEach?: 'ROW' | 'STATEMENT'; - when?: string; - replace?: boolean; - ifExists?: boolean; - cascade?: boolean; - enable?: boolean; - }; + } = validationResult.data; try { switch (operation) { @@ -694,10 +695,8 @@ export const manageTriggersTools: 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/users.test.ts b/src/tools/users.test.ts index ffa8fc9..a626429 100644 --- a/src/tools/users.test.ts +++ b/src/tools/users.test.ts @@ -10,7 +10,7 @@ describe('manageUsersTool', () => { mockGetConnectionString.mockClear(); }); - it('quotes roles and escapes password literals when creating users', async () => { + it('creates users with quoted roles and escaped password literals', async () => { const mockDb = { connect: vi.fn().mockResolvedValue(undefined), query: vi.fn().mockResolvedValue([]), @@ -22,14 +22,15 @@ describe('manageUsersTool', () => { operation: 'create', username: 'app_user', password: "owner's secret", - login: true + login: true, + connectionLimit: 5 }, mockGetConnectionString); expect(result.isError).toBeUndefined(); - expect(mockDb.query).toHaveBeenCalledWith('CREATE USER "app_user" PASSWORD \'owner\'\'s secret\' LOGIN'); + expect(mockDb.query).toHaveBeenCalledWith('CREATE USER "app_user" PASSWORD \'owner\'\'s secret\' LOGIN CONNECTION LIMIT 5'); }); - it('rejects unsafe role identifiers before executing SQL', async () => { + it('grants permissions with quoted role and target identifiers', async () => { const mockDb = { connect: vi.fn().mockResolvedValue(undefined), query: vi.fn().mockResolvedValue([]), @@ -38,16 +39,65 @@ describe('manageUsersTool', () => { vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); const result = await manageUsersTool.execute({ - operation: 'drop', - username: 'victim"; ALTER USER postgres WITH SUPERUSER; --' + operation: 'grant', + username: 'app_user', + permissions: ['SELECT', 'UPDATE'], + target: 'orders', + targetType: 'table', + schema: 'sales' + }, mockGetConnectionString); + + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenCalledWith('GRANT SELECT, UPDATE ON TABLE "sales"."orders" TO "app_user"'); + }); + + it('does not echo password values in alter-user 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 manageUsersTool.execute({ + operation: 'alter', + username: 'app_user', + password: "owner's secret", + login: false + }, mockGetConnectionString); + + expect(result.isError).toBeUndefined(); + expect(mockDb.query).toHaveBeenCalledWith('ALTER USER "app_user" PASSWORD \'owner\'\'s secret\' NOLOGIN'); + expect(result.content[0].text).toContain('Changes: password, login'); + expect(result.content[0].text).not.toContain("owner's secret"); + expect(result.content[0].text).not.toContain('PASSWORD'); + }); + + it('sanitizes user-management database errors before returning them', async () => { + const mockDb = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockRejectedValue( + new Error("password=db-secret failed while running ALTER USER app_user PASSWORD 'raw-secret'") + ), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(DatabaseConnection, 'getInstance').mockReturnValue(mockDb as unknown as DatabaseConnection); + + const result = await manageUsersTool.execute({ + operation: 'alter', + username: 'app_user', + password: 'raw-secret' }, mockGetConnectionString); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Invalid SQL identifier'); - expect(mockDb.query).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('Failed to alter user'); + expect(result.content[0].text).toContain('password=*****'); + expect(result.content[0].text).toContain("PASSWORD '?'"); + expect(result.content[0].text).not.toContain('db-secret'); + expect(result.content[0].text).not.toContain('raw-secret'); }); - it('quotes grant targets and grantees', async () => { + it('rejects unknown user-management fields before resolving a connection string', async () => { const mockDb = { connect: vi.fn().mockResolvedValue(undefined), query: vi.fn().mockResolvedValue([]), @@ -61,10 +111,148 @@ describe('manageUsersTool', () => { permissions: ['SELECT'], target: 'orders', targetType: 'table', - schema: 'sales' + rawSql: 'GRANT SUPERUSER TO app_user' }, mockGetConnectionString); - expect(result.isError).toBeUndefined(); - expect(mockDb.query).toHaveBeenCalledWith('GRANT SELECT ON TABLE "sales"."orders" TO "app_user"'); + 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 user-management option types 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 manageUsersTool.execute({ + operation: 'create', + username: 'app_user', + connectionLimit: 1.5 + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid input'); + expect(result.content[0].text).toContain('Expected integer'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.query).not.toHaveBeenCalled(); + }); + + it('rejects invalid connection limits 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 manageUsersTool.execute({ + operation: 'alter', + username: 'app_user', + connectionLimit: -2 + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid input'); + expect(result.content[0].text).toContain('connectionLimit'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.query).not.toHaveBeenCalled(); + }); + + it('rejects malformed validUntil dates 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 manageUsersTool.execute({ + operation: 'create', + username: 'app_user', + validUntil: '2024-02-31' + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid input'); + expect(result.content[0].text).toContain('validUntil must be a valid YYYY-MM-DD calendar date'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.query).not.toHaveBeenCalled(); + }); + + it('rejects empty permission lists 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 manageUsersTool.execute({ + operation: 'grant', + username: 'app_user', + permissions: [], + target: 'orders', + targetType: 'table' + }, mockGetConnectionString); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid input'); + expect(result.content[0].text).toContain('permissions'); + expect(mockGetConnectionString).not.toHaveBeenCalled(); + expect(mockDb.connect).not.toHaveBeenCalled(); + expect(mockDb.query).not.toHaveBeenCalled(); + }); + + it('rejects unsafe role 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 manageUsersTool.execute({ + operation: 'create', + username: 'app_user"; drop role postgres', + password: 'secret' + }, 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 grant 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 manageUsersTool.execute({ + operation: 'grant', + username: 'app_user', + permissions: ['SELECT'], + target: 'orders; drop table orders', + targetType: '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(); }); }); diff --git a/src/tools/users.ts b/src/tools/users.ts index 9f28f0d..94700ad 100644 --- a/src/tools/users.ts +++ b/src/tools/users.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, quoteQualifiedIdent } from '../utils/sql.js'; interface UserInfo { rolname: string; @@ -26,26 +27,34 @@ interface Permission { grantor: string; } -const SIMPLE_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const PermissionSchema = z.enum(['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER', 'ALL']); +const TargetTypeSchema = z.enum(['table', 'schema', 'database', 'sequence', 'function']); +const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; -function quoteIdent(identifier: string): string { - if (!SIMPLE_IDENTIFIER_PATTERN.test(identifier)) { - throw new Error(`Invalid SQL identifier "${identifier}". Use simple unquoted PostgreSQL identifiers only.`); +function isValidIsoDate(value: string): boolean { + const match = ISO_DATE_PATTERN.exec(value); + if (!match) { + return false; } - return `"${identifier}"`; -} - -function quoteQualifiedIdent(identifier: string, schema = 'public'): string { - if (!schema || schema === 'public') { - return quoteIdent(identifier); - } + const [, yearText, monthText, dayText] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const date = new Date(Date.UTC(year, month - 1, day)); - return `${quoteIdent(schema)}.${quoteIdent(identifier)}`; + return date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day; } -function quoteLiteral(value: string): string { - return `'${value.replace(/'/g, "''")}'`; +const ConnectionLimitSchema = z.number().int().min(-1).describe('Maximum number of connections (-1 for unlimited)'); +const ValidUntilSchema = z.string() + .refine(isValidIsoDate, 'validUntil must be a valid YYYY-MM-DD calendar date') + .describe('Password expiration date (YYYY-MM-DD)'); + +function formatValidationError(error: z.ZodError): string { + return error.errors.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join(', '); } // --- Create User Tool --- @@ -58,54 +67,53 @@ const CreateUserInputSchema = z.object({ createrole: z.boolean().optional().default(false).describe("Allow user to create roles"), login: z.boolean().optional().default(true).describe("Allow user to login"), replication: z.boolean().optional().default(false).describe("Allow replication privileges"), - connectionLimit: z.number().optional().describe("Maximum number of connections"), - validUntil: z.string().optional().describe("Password expiration date (YYYY-MM-DD)"), - inherit: z.boolean().optional().default(true).describe("Inherit privileges from parent roles"), -}); + connectionLimit: ConnectionLimitSchema.optional(), + validUntil: ValidUntilSchema.optional(), + inherit: z.boolean().optional().default(true).describe("Inherit privileges from parent roles"), +}).strict(); type CreateUserInput = z.infer; -async function executeCreateUser( - input: CreateUserInput, - getConnectionString: GetConnectionStringFn -): Promise<{ username: string; created: true }> { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); - const { - username, - password, - superuser, - createdb, - createrole, - login, - replication, - connectionLimit, - validUntil, - inherit +async function executeCreateUser( + input: CreateUserInput, + getConnectionString: GetConnectionStringFn +): Promise<{ username: string; created: true }> { + const db = DatabaseConnection.getInstance(); + const { + username, + password, + superuser, + createdb, + createrole, + login, + replication, + connectionLimit, + validUntil, + inherit } = input; try { const quotedUsername = quoteIdent(username); - await db.connect(resolvedConnectionString); - const options = []; - + if (password) options.push(`PASSWORD ${quoteLiteral(password)}`); if (superuser) options.push('SUPERUSER'); if (createdb) options.push('CREATEDB'); - if (createrole) options.push('CREATEROLE'); - if (login) options.push('LOGIN'); + if (createrole) options.push('CREATEROLE'); + if (login) options.push('LOGIN'); if (replication) options.push('REPLICATION'); if (!inherit) options.push('NOINHERIT'); if (connectionLimit !== undefined) options.push(`CONNECTION LIMIT ${connectionLimit}`); if (validUntil) options.push(`VALID UNTIL ${quoteLiteral(validUntil)}`); - + const createUserSQL = `CREATE USER ${quotedUsername}${options.length > 0 ? ` ${options.join(' ')}` : ''}`; - - await db.query(createUserSQL); + const resolvedConnectionString = getConnectionString(input.connectionString); + + await db.connect(resolvedConnectionString); + await db.query(createUserSQL); return { username, created: true }; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to create user: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to create user: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -116,54 +124,55 @@ export const createUserTool: PostgresTool = { description: 'Create a new PostgreSQL user/role', inputSchema: CreateUserInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = CreateUserInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = CreateUserInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeCreateUser(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: `User ${result.username} 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 user: ${errorMessage}` }], isError: true }; } } }; // --- Drop User Tool --- -const DropUserInputSchema = z.object({ - connectionString: z.string().optional(), - username: z.string().describe("Username to drop"), - ifExists: z.boolean().optional().default(true).describe("Include IF EXISTS clause"), - cascade: z.boolean().optional().default(false).describe("Include CASCADE to drop owned objects"), -}); +const DropUserInputSchema = z.object({ + connectionString: z.string().optional(), + username: z.string().describe("Username to drop"), + ifExists: z.boolean().optional().default(true).describe("Include IF EXISTS clause"), + cascade: z.boolean().optional().default(false).describe("Include CASCADE to drop owned objects"), +}).strict(); type DropUserInput = z.infer; -async function executeDropUser( - input: DropUserInput, - getConnectionString: GetConnectionStringFn -): Promise<{ username: string; dropped: true }> { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); +async function executeDropUser( + input: DropUserInput, + getConnectionString: GetConnectionStringFn +): Promise<{ username: string; dropped: true }> { + const db = DatabaseConnection.getInstance(); const { username, ifExists, cascade } = input; try { const quotedUsername = quoteIdent(username); + const resolvedConnectionString = getConnectionString(input.connectionString); + await db.connect(resolvedConnectionString); - + // First, reassign or drop owned objects if cascade is true if (cascade) { await db.query(`DROP OWNED BY ${quotedUsername} CASCADE`); } - + const ifExistsClause = ifExists ? 'IF EXISTS ' : ''; const dropUserSQL = `DROP USER ${ifExistsClause}${quotedUsername}`; - - await db.query(dropUserSQL); - + + await db.query(dropUserSQL); + return { username, dropped: true }; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to drop user: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to drop user: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -174,22 +183,22 @@ export const dropUserTool: PostgresTool = { description: 'Drop a PostgreSQL user/role', inputSchema: DropUserInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = DropUserInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = DropUserInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeDropUser(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: `User ${result.username} 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 user: ${errorMessage}` }], isError: true }; } } }; // --- Alter User Tool --- -const AlterUserInputSchema = z.object({ +const AlterUserInputSchema = z.object({ connectionString: z.string().optional(), username: z.string().describe("Username to alter"), password: z.string().optional().describe("New password"), @@ -198,18 +207,17 @@ const AlterUserInputSchema = z.object({ createrole: z.boolean().optional().describe("Grant/revoke role creation privileges"), login: z.boolean().optional().describe("Grant/revoke login privileges"), replication: z.boolean().optional().describe("Grant/revoke replication privileges"), - connectionLimit: z.number().optional().describe("Set connection limit"), - validUntil: z.string().optional().describe("Set password expiration date (YYYY-MM-DD)"), - inherit: z.boolean().optional().describe("Set privilege inheritance"), -}); + connectionLimit: ConnectionLimitSchema.optional().describe("Set connection limit (-1 for unlimited)"), + validUntil: ValidUntilSchema.optional().describe("Set password expiration date (YYYY-MM-DD)"), + inherit: z.boolean().optional().describe("Set privilege inheritance"), +}).strict(); type AlterUserInput = z.infer; -async function executeAlterUser( - input: AlterUserInput, - getConnectionString: GetConnectionStringFn -): Promise<{ username: string; altered: true; changes: string[] }> { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); +async function executeAlterUser( + input: AlterUserInput, + getConnectionString: GetConnectionStringFn +): Promise<{ username: string; altered: true; changes: string[] }> { + const db = DatabaseConnection.getInstance(); const { username, password, @@ -225,34 +233,37 @@ async function executeAlterUser( try { const quotedUsername = quoteIdent(username); - await db.connect(resolvedConnectionString); - - const changes = []; - + const changes: string[] = []; + const attributes: string[] = []; + const addAttribute = (sql: string, change: string) => { + attributes.push(sql); + changes.push(change); + }; + if (password !== undefined) { - await db.query(`ALTER USER ${quotedUsername} PASSWORD ${quoteLiteral(password)}`); - changes.push('password'); + addAttribute(`PASSWORD ${quoteLiteral(password)}`, 'password'); } - - const attributes = []; - if (superuser !== undefined) attributes.push(superuser ? 'SUPERUSER' : 'NOSUPERUSER'); - if (createdb !== undefined) attributes.push(createdb ? 'CREATEDB' : 'NOCREATEDB'); - if (createrole !== undefined) attributes.push(createrole ? 'CREATEROLE' : 'NOCREATEROLE'); - if (login !== undefined) attributes.push(login ? 'LOGIN' : 'NOLOGIN'); - if (replication !== undefined) attributes.push(replication ? 'REPLICATION' : 'NOREPLICATION'); - if (inherit !== undefined) attributes.push(inherit ? 'INHERIT' : 'NOINHERIT'); - if (connectionLimit !== undefined) attributes.push(`CONNECTION LIMIT ${connectionLimit}`); - if (validUntil !== undefined) attributes.push(`VALID UNTIL ${quoteLiteral(validUntil)}`); - + + if (superuser !== undefined) addAttribute(superuser ? 'SUPERUSER' : 'NOSUPERUSER', 'superuser'); + if (createdb !== undefined) addAttribute(createdb ? 'CREATEDB' : 'NOCREATEDB', 'createdb'); + if (createrole !== undefined) addAttribute(createrole ? 'CREATEROLE' : 'NOCREATEROLE', 'createrole'); + if (login !== undefined) addAttribute(login ? 'LOGIN' : 'NOLOGIN', 'login'); + if (replication !== undefined) addAttribute(replication ? 'REPLICATION' : 'NOREPLICATION', 'replication'); + if (inherit !== undefined) addAttribute(inherit ? 'INHERIT' : 'NOINHERIT', 'inherit'); + if (connectionLimit !== undefined) addAttribute(`CONNECTION LIMIT ${connectionLimit}`, 'connectionLimit'); + if (validUntil !== undefined) addAttribute(`VALID UNTIL ${quoteLiteral(validUntil)}`, 'validUntil'); + + const resolvedConnectionString = getConnectionString(input.connectionString); + await db.connect(resolvedConnectionString); + if (attributes.length > 0) { const alterUserSQL = `ALTER USER ${quotedUsername} ${attributes.join(' ')}`; await db.query(alterUserSQL); - changes.push(...attributes); - } + } return { username, altered: true, changes }; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to alter user: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to alter user: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -263,75 +274,68 @@ export const alterUserTool: PostgresTool = { description: 'Alter an existing PostgreSQL user/role', inputSchema: AlterUserInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = AlterUserInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = AlterUserInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeAlterUser(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: `User ${result.username} altered 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 altering user: ${errorMessage}` }], isError: true }; } } }; // --- Grant Permissions Tool --- -const GrantPermissionsInputSchema = z.object({ - connectionString: z.string().optional(), - username: z.string().describe("Username to grant permissions to"), - permissions: z.array(z.enum(['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER', 'ALL'])).describe("Permissions to grant"), - target: z.string().describe("Target object (table, schema, database, etc.)"), - targetType: z.enum(['table', 'schema', 'database', 'sequence', 'function']).describe("Type of target object"), - schema: z.string().optional().default('public').describe("Schema name (for table/sequence/function targets)"), - withGrantOption: z.boolean().optional().default(false).describe("Allow user to grant these permissions to others"), -}); -type GrantPermissionsInput = z.infer; - -async function executeGrantPermissions( - input: GrantPermissionsInput, - getConnectionString: GetConnectionStringFn -): Promise<{ username: string; permissions: string[]; target: string; granted: true }> { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); - const { username, permissions, target, targetType, schema, withGrantOption } = input; - +const GrantPermissionsInputSchema = z.object({ + connectionString: z.string().optional(), + username: z.string().describe("Username to grant permissions to"), + permissions: z.array(PermissionSchema).min(1).describe("Permissions to grant"), + target: z.string().describe("Target object (table, schema, database, etc.)"), + targetType: TargetTypeSchema.describe("Type of target object"), + schema: z.string().optional().default('public').describe("Schema name (for table/sequence/function targets)"), + withGrantOption: z.boolean().optional().default(false).describe("Allow user to grant these permissions to others"), +}).strict(); +type GrantPermissionsInput = z.infer; + +function buildPrivilegeTargetSpec(targetType: 'table' | 'schema' | 'database' | 'sequence' | 'function', target: string, schema = 'public'): string { + switch (targetType) { + case 'table': + return `TABLE ${quoteQualifiedIdent(target, schema)}`; + case 'schema': + return `SCHEMA ${quoteIdent(target)}`; + case 'database': + return `DATABASE ${quoteIdent(target)}`; + case 'sequence': + return `SEQUENCE ${quoteQualifiedIdent(target, schema)}`; + case 'function': + return `FUNCTION ${quoteQualifiedIdent(target, schema)}`; + } +} + +async function executeGrantPermissions( + input: GrantPermissionsInput, + getConnectionString: GetConnectionStringFn +): Promise<{ username: string; permissions: string[]; target: string; granted: true }> { + const db = DatabaseConnection.getInstance(); + const { username, permissions, target, targetType, schema, withGrantOption } = input; + try { - await db.connect(resolvedConnectionString); - - let targetSpec = ''; - switch (targetType) { - case 'table': { - targetSpec = `TABLE ${quoteQualifiedIdent(target, schema)}`; - break; - } - case 'schema': - targetSpec = `SCHEMA ${quoteIdent(target)}`; - break; - case 'database': - targetSpec = `DATABASE ${quoteIdent(target)}`; - break; - case 'sequence': { - targetSpec = `SEQUENCE ${quoteQualifiedIdent(target, schema)}`; - break; - } - case 'function': { - targetSpec = `FUNCTION ${quoteQualifiedIdent(target, schema)}`; - break; - } - } - + const targetSpec = buildPrivilegeTargetSpec(targetType, target, schema); const permissionsStr = permissions.join(', '); const withGrantClause = withGrantOption ? ' WITH GRANT OPTION' : ''; - + const grantSQL = `GRANT ${permissionsStr} ON ${targetSpec} TO ${quoteIdent(username)}${withGrantClause}`; - - await db.query(grantSQL); - + const resolvedConnectionString = getConnectionString(input.connectionString); + + await db.connect(resolvedConnectionString); + await db.query(grantSQL); + return { username, permissions, target, granted: true }; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to grant permissions: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to grant permissions: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -342,75 +346,53 @@ export const grantPermissionsTool: PostgresTool = { description: 'Grant permissions to a user/role', inputSchema: GrantPermissionsInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = GrantPermissionsInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = GrantPermissionsInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeGrantPermissions(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: `Permissions granted to ${result.username} 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 granting permissions: ${errorMessage}` }], isError: true }; } } }; // --- Revoke Permissions Tool --- -const RevokePermissionsInputSchema = z.object({ - connectionString: z.string().optional(), - username: z.string().describe("Username to revoke permissions from"), - permissions: z.array(z.enum(['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER', 'ALL'])).describe("Permissions to revoke"), - target: z.string().describe("Target object (table, schema, database, etc.)"), - targetType: z.enum(['table', 'schema', 'database', 'sequence', 'function']).describe("Type of target object"), - schema: z.string().optional().default('public').describe("Schema name (for table/sequence/function targets)"), - cascade: z.boolean().optional().default(false).describe("Cascade revoke to dependent privileges"), -}); +const RevokePermissionsInputSchema = z.object({ + connectionString: z.string().optional(), + username: z.string().describe("Username to revoke permissions from"), + permissions: z.array(PermissionSchema).min(1).describe("Permissions to revoke"), + target: z.string().describe("Target object (table, schema, database, etc.)"), + targetType: TargetTypeSchema.describe("Type of target object"), + schema: z.string().optional().default('public').describe("Schema name (for table/sequence/function targets)"), + cascade: z.boolean().optional().default(false).describe("Cascade revoke to dependent privileges"), +}).strict(); type RevokePermissionsInput = z.infer; -async function executeRevokePermissions( - input: RevokePermissionsInput, - getConnectionString: GetConnectionStringFn -): Promise<{ username: string; permissions: string[]; target: string; revoked: true }> { - const resolvedConnectionString = getConnectionString(input.connectionString); - const db = DatabaseConnection.getInstance(); - const { username, permissions, target, targetType, schema, cascade } = input; - +async function executeRevokePermissions( + input: RevokePermissionsInput, + getConnectionString: GetConnectionStringFn +): Promise<{ username: string; permissions: string[]; target: string; revoked: true }> { + const db = DatabaseConnection.getInstance(); + const { username, permissions, target, targetType, schema, cascade } = input; + try { - await db.connect(resolvedConnectionString); - - let targetSpec = ''; - switch (targetType) { - case 'table': { - targetSpec = `TABLE ${quoteQualifiedIdent(target, schema)}`; - break; - } - case 'schema': - targetSpec = `SCHEMA ${quoteIdent(target)}`; - break; - case 'database': - targetSpec = `DATABASE ${quoteIdent(target)}`; - break; - case 'sequence': { - targetSpec = `SEQUENCE ${quoteQualifiedIdent(target, schema)}`; - break; - } - case 'function': { - targetSpec = `FUNCTION ${quoteQualifiedIdent(target, schema)}`; - break; - } - } - + const targetSpec = buildPrivilegeTargetSpec(targetType, target, schema); const permissionsStr = permissions.join(', '); const cascadeClause = cascade ? ' CASCADE' : ''; - + const revokeSQL = `REVOKE ${permissionsStr} ON ${targetSpec} FROM ${quoteIdent(username)}${cascadeClause}`; - - await db.query(revokeSQL); - + const resolvedConnectionString = getConnectionString(input.connectionString); + + await db.connect(resolvedConnectionString); + await db.query(revokeSQL); + return { username, permissions, target, revoked: true }; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to revoke permissions: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to revoke permissions: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -421,27 +403,27 @@ export const revokePermissionsTool: PostgresTool = { description: 'Revoke permissions from a user/role', inputSchema: RevokePermissionsInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = RevokePermissionsInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = RevokePermissionsInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeRevokePermissions(validationResult.data, getConnectionString); return { content: [{ type: 'text', text: `Permissions revoked from ${result.username} 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 revoking permissions: ${errorMessage}` }], isError: true }; } } }; // --- Get User Permissions Tool --- -const GetUserPermissionsInputSchema = z.object({ - connectionString: z.string().optional(), - username: z.string().optional().describe("Username to get permissions for (optional, shows all if not provided)"), - schema: z.string().optional().describe("Filter by schema (optional)"), - targetType: z.enum(['table', 'schema', 'database', 'sequence', 'function']).optional().describe("Filter by target type"), -}); +const GetUserPermissionsInputSchema = z.object({ + connectionString: z.string().optional(), + username: z.string().optional().describe("Username to get permissions for (optional, shows all if not provided)"), + schema: z.string().optional().describe("Filter by schema (optional)"), + targetType: TargetTypeSchema.optional().describe("Filter by target type"), +}).strict(); type GetUserPermissionsInput = z.infer; async function executeGetUserPermissions( @@ -504,7 +486,7 @@ async function executeGetUserPermissions( return result; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to get user permissions: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to get user permissions: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } @@ -515,10 +497,10 @@ export const getUserPermissionsTool: PostgresTool = { description: 'Get permissions for a user/role or all users', inputSchema: GetUserPermissionsInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = GetUserPermissionsInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = GetUserPermissionsInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeGetUserPermissions(validationResult.data, getConnectionString); const message = validationResult.data.username @@ -526,17 +508,17 @@ export const getUserPermissionsTool: PostgresTool = { : 'All user permissions'; 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 user permissions: ${errorMessage}` }], isError: true }; } } }; // --- List Users Tool --- -const ListUsersInputSchema = z.object({ - connectionString: z.string().optional(), - includeSystemRoles: z.boolean().optional().default(false).describe("Include system roles"), -}); +const ListUsersInputSchema = z.object({ + connectionString: z.string().optional(), + includeSystemRoles: z.boolean().optional().default(false).describe("Include system roles"), +}).strict(); type ListUsersInput = z.infer; async function executeListUsers( @@ -573,21 +555,21 @@ async function executeListUsers( return result; } catch (error) { - throw new McpError(ErrorCode.InternalError, `Failed to list users: ${error instanceof Error ? error.message : String(error)}`); + throw new McpError(ErrorCode.InternalError, `Failed to list users: ${sanitizeErrorMessage(error)}`); } finally { await db.disconnect(); } } -export const listUsersTool: PostgresTool = { +export const listUsersTool: PostgresTool = { name: 'pg_list_users', description: 'List all users/roles in the database', inputSchema: ListUsersInputSchema, async execute(params: unknown, getConnectionString: GetConnectionStringFn): Promise { - const validationResult = ListUsersInputSchema.safeParse(params); - if (!validationResult.success) { - return { content: [{ type: 'text', text: `Invalid input: ${validationResult.error.format()}` }], isError: true }; - } + const validationResult = ListUsersInputSchema.safeParse(params); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } try { const result = await executeListUsers(validationResult.data, getConnectionString); const message = validationResult.data.includeSystemRoles @@ -595,56 +577,62 @@ export const listUsersTool: PostgresTool = { : 'All user-created roles'; 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 listing users: ${errorMessage}` }], isError: true }; } - } -}; - -// Consolidated User Management Tool -export const manageUsersTool: PostgresTool = { - name: 'pg_manage_users', - description: 'Manage PostgreSQL users and permissions - create, drop, alter users, grant/revoke permissions. Examples: operation="create" with username="testuser", operation="grant" with username, permissions, target, targetType', - inputSchema: z.object({ - connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'), - operation: z.enum(['create', 'drop', 'alter', 'grant', 'revoke', 'get_permissions', 'list']).describe('Operation: create (new user), drop (remove user), alter (modify user), grant (permissions), revoke (permissions), get_permissions (view permissions), list (all users)'), - - // Common parameters - username: z.string().optional().describe('Username (required for create/drop/alter/grant/revoke/get_permissions, optional filter for list)'), - - // Create user parameters - password: z.string().optional().describe('Password for the user (for create operation)'), - superuser: z.boolean().optional().describe('Grant superuser privileges (for create/alter operations)'), - createdb: z.boolean().optional().describe('Allow user to create databases (for create/alter operations)'), - createrole: z.boolean().optional().describe('Allow user to create roles (for create/alter operations)'), - login: z.boolean().optional().describe('Allow user to login (for create/alter operations)'), - replication: z.boolean().optional().describe('Allow replication privileges (for create/alter operations)'), - connectionLimit: z.number().optional().describe('Maximum number of connections (for create/alter operations)'), - validUntil: z.string().optional().describe('Password expiration date YYYY-MM-DD (for create/alter operations)'), - inherit: z.boolean().optional().describe('Inherit privileges from parent roles (for create/alter operations)'), - - // Drop user parameters - ifExists: z.boolean().optional().describe('Include IF EXISTS clause (for drop operation)'), - cascade: z.boolean().optional().describe('Include CASCADE to drop owned objects (for drop/revoke operations)'), - - // Permission parameters - permissions: z.array(z.enum(['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER', 'ALL'])).optional().describe('Permissions to grant/revoke: ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "ALL"]'), - target: z.string().optional().describe('Target object name (for grant/revoke operations)'), - targetType: z.enum(['table', 'schema', 'database', 'sequence', 'function']).optional().describe('Type of target object (for grant/revoke operations)'), - withGrantOption: z.boolean().optional().describe('Allow user to grant these permissions to others (for grant operation)'), - - // Get permissions parameters - schema: z.string().optional().describe('Filter by schema (for get_permissions operation)'), - - // List users parameters - includeSystemRoles: z.boolean().optional().describe('Include system roles (for list operation)') - }), - // biome-ignore lint/suspicious/noExplicitAny: - execute: async (args: any, getConnectionStringVal: GetConnectionStringFn): Promise => { - const { - connectionString: connStringArg, - operation, - username, + } +}; + +const ManageUsersInputSchema = z.object({ + connectionString: z.string().optional().describe('PostgreSQL connection string (optional)'), + operation: z.enum(['create', 'drop', 'alter', 'grant', 'revoke', 'get_permissions', 'list']).describe('Operation: create (new user), drop (remove user), alter (modify user), grant (permissions), revoke (permissions), get_permissions (view permissions), list (all users)'), + + // Common parameters + username: z.string().optional().describe('Username (required for create/drop/alter/grant/revoke/get_permissions, optional filter for list)'), + + // Create/alter user parameters + password: z.string().optional().describe('Password for the user (for create operation)'), + superuser: z.boolean().optional().describe('Grant superuser privileges (for create/alter operations)'), + createdb: z.boolean().optional().describe('Allow user to create databases (for create/alter operations)'), + createrole: z.boolean().optional().describe('Allow user to create roles (for create/alter operations)'), + login: z.boolean().optional().describe('Allow user to login (for create/alter operations)'), + replication: z.boolean().optional().describe('Allow replication privileges (for create/alter operations)'), + connectionLimit: ConnectionLimitSchema.optional().describe('Maximum number of connections, -1 for unlimited (for create/alter operations)'), + validUntil: ValidUntilSchema.optional().describe('Password expiration date YYYY-MM-DD (for create/alter operations)'), + inherit: z.boolean().optional().describe('Inherit privileges from parent roles (for create/alter operations)'), + + // Drop user parameters + ifExists: z.boolean().optional().describe('Include IF EXISTS clause (for drop operation)'), + cascade: z.boolean().optional().describe('Include CASCADE to drop owned objects (for drop/revoke operations)'), + + // Permission parameters + permissions: z.array(PermissionSchema).min(1).optional().describe('Permissions to grant/revoke: ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "ALL"]'), + target: z.string().optional().describe('Target object name (for grant/revoke operations)'), + targetType: TargetTypeSchema.optional().describe('Type of target object (for grant/revoke operations)'), + withGrantOption: z.boolean().optional().describe('Allow user to grant these permissions to others (for grant operation)'), + + // Get permissions parameters + schema: z.string().optional().describe('Filter by schema (for get_permissions operation)'), + + // List users parameters + includeSystemRoles: z.boolean().optional().describe('Include system roles (for list operation)') +}).strict(); + +// Consolidated User Management Tool +export const manageUsersTool: PostgresTool = { + name: 'pg_manage_users', + description: 'Manage PostgreSQL users and permissions - create, drop, alter users, grant/revoke permissions. Examples: operation="create" with username="testuser", operation="grant" with username, permissions, target, targetType', + inputSchema: ManageUsersInputSchema, + execute: async (args: unknown, getConnectionStringVal: GetConnectionStringFn): Promise => { + const validationResult = ManageUsersInputSchema.safeParse(args); + if (!validationResult.success) { + return { content: [{ type: 'text', text: `Invalid input: ${formatValidationError(validationResult.error)}` }], isError: true }; + } + + const { + connectionString: connStringArg, + operation, + username, password, superuser, createdb, @@ -658,32 +646,11 @@ export const manageUsersTool: PostgresTool = { cascade, permissions, target, - targetType, - withGrantOption, - schema, - includeSystemRoles - } = args as { - connectionString?: string; - operation: 'create' | 'drop' | 'alter' | 'grant' | 'revoke' | 'get_permissions' | 'list'; - username?: string; - password?: string; - superuser?: boolean; - createdb?: boolean; - createrole?: boolean; - login?: boolean; - replication?: boolean; - connectionLimit?: number; - validUntil?: string; - inherit?: boolean; - ifExists?: boolean; - cascade?: boolean; - permissions?: string[]; - target?: string; - targetType?: 'table' | 'schema' | 'database' | 'sequence' | 'function'; - withGrantOption?: boolean; - schema?: string; - includeSystemRoles?: boolean; - }; + targetType, + withGrantOption, + schema, + includeSystemRoles + } = validationResult.data; try { switch (operation) { @@ -813,11 +780,11 @@ export const manageUsersTool: 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 }; } } -}; +}; diff --git a/src/utils/connection.test.ts b/src/utils/connection.test.ts new file mode 100644 index 0000000..a581a11 --- /dev/null +++ b/src/utils/connection.test.ts @@ -0,0 +1,918 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DatabaseConnection, maskConnectionString, sanitizeErrorMessage } from './connection'; + +function resetSingleton(): void { + (DatabaseConnection as unknown as { instance?: DatabaseConnection }).instance = undefined; + (DatabaseConnection as unknown as { connectionQueue: Promise }).connectionQueue = Promise.resolve(); +} + +describe('DatabaseConnection lifecycle', () => { + afterEach(() => { + resetSingleton(); + vi.restoreAllMocks(); + }); + + it('executes regular queries through the pool without a held client', async () => { + const db = DatabaseConnection.getInstance(); + const poolQuery = vi.fn().mockResolvedValue({ rows: [{ id: 1 }], rowCount: 1 }); + const pool = { query: poolQuery }; + + (db as unknown as { pool: typeof pool; connectionString: string }).pool = pool; + (db as unknown as { connectionString: string }).connectionString = 'postgresql://test'; + + const rows = await db.query('SELECT $1::int AS id', [1], { timeout: 500 }); + + expect(rows).toEqual([{ id: 1 }]); + expect(poolQuery).toHaveBeenCalledWith({ + text: 'SELECT $1::int AS id', + values: [1], + timeout: 500 + }); + expect(db.getClient()).toBeNull(); + expect(db.isConnected()).toBe(true); + }); + + it('can return full query metadata including rowCount', async () => { + const db = DatabaseConnection.getInstance(); + const poolQuery = vi.fn().mockResolvedValue({ rows: [], rowCount: 7 }); + const pool = { query: poolQuery }; + + (db as unknown as { pool: typeof pool; connectionString: string }).pool = pool; + (db as unknown as { connectionString: string }).connectionString = 'postgresql://test'; + + const result = await db.queryResult('UPDATE users SET active = $1', [true]); + + expect(result.rowCount).toBe(7); + expect(result.rows).toEqual([]); + expect(poolQuery).toHaveBeenCalledWith({ + text: 'UPDATE users SET active = $1', + values: [true] + }); + }); + + it('rejects invalid per-query timeout options before sending queries to the pool', async () => { + const db = DatabaseConnection.getInstance(); + const poolQuery = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }); + const pool = { query: poolQuery }; + + (db as unknown as { pool: typeof pool; connectionString: string }).pool = pool; + (db as unknown as { connectionString: string }).connectionString = 'postgresql://test'; + + await expect(db.query('SELECT 1', [], { timeout: 0 })).rejects.toThrow('query timeout must be a positive integer'); + await expect(db.queryResult('SELECT 1', [], { timeout: 1.5 })).rejects.toThrow('query timeout must be a positive integer'); + expect(poolQuery).not.toHaveBeenCalled(); + }); + + it('checks out and releases a transaction client on commit', async () => { + const db = DatabaseConnection.getInstance(); + const release = vi.fn(); + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const pool = { connect: vi.fn().mockResolvedValue(client) }; + + (db as unknown as { pool: typeof pool; connectionString: string }).pool = pool; + (db as unknown as { connectionString: string }).connectionString = 'postgresql://test'; + + const result = await db.transaction(async (transactionClient) => { + await transactionClient.query('SELECT 1'); + return 'ok'; + }, { readOnly: true }); + + expect(result).toBe('ok'); + expect(client.query).toHaveBeenNthCalledWith(1, 'BEGIN READ ONLY'); + expect(client.query).toHaveBeenNthCalledWith(2, 'SELECT 1'); + expect(client.query).toHaveBeenNthCalledWith(3, 'COMMIT'); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('rolls back and releases a transaction client on failure', async () => { + const db = DatabaseConnection.getInstance(); + const release = vi.fn(); + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const pool = { connect: vi.fn().mockResolvedValue(client) }; + + (db as unknown as { pool: typeof pool; connectionString: string }).pool = pool; + (db as unknown as { connectionString: string }).connectionString = 'postgresql://test'; + + await expect(db.transaction(async () => { + throw new Error('boom'); + })).rejects.toThrow('Transaction failed: boom'); + + expect(client.query).toHaveBeenNthCalledWith(1, 'BEGIN'); + expect(client.query).toHaveBeenNthCalledWith(2, 'ROLLBACK'); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('sanitizes secrets in query errors before propagating', async () => { + const db = DatabaseConnection.getInstance(); + const pool = { + query: vi.fn().mockRejectedValue( + new Error("connection postgresql://app:s3cr3t@localhost/main failed while running SELECT * FROM users WHERE token = 'secret-token'") + ) + }; + + (db as unknown as { pool: typeof pool; connectionString: string }).pool = pool; + (db as unknown as { connectionString: string }).connectionString = 'postgresql://test'; + + await expect(db.query('SELECT 1')).rejects.toThrow("Query failed: connection postgresql://app:*****@localhost/main failed while running SELECT * FROM users WHERE token = '?'"); + await expect(db.query('SELECT 1')).rejects.not.toThrow('s3cr3t'); + await expect(db.query('SELECT 1')).rejects.not.toThrow('secret-token'); + }); + + it('sanitizes rollback and transaction errors', async () => { + const db = DatabaseConnection.getInstance(); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const release = vi.fn(); + const client = { + query: vi.fn() + .mockResolvedValueOnce({ rows: [] }) + .mockRejectedValueOnce(new Error("rollback password=rollback-secret WHERE token = 'rollback-token'")), + release + }; + const pool = { connect: vi.fn().mockResolvedValue(client) }; + + (db as unknown as { pool: typeof pool; connectionString: string }).pool = pool; + (db as unknown as { connectionString: string }).connectionString = 'postgresql://test'; + + await expect(db.transaction(async () => { + throw new Error("transaction postgresql://app:tx-secret@localhost/main WHERE token = 'tx-token'"); + })).rejects.toThrow("Transaction failed: transaction postgresql://app:*****@localhost/main WHERE token = '?'"); + + const logOutput = consoleError.mock.calls.flat().join(' '); + expect(logOutput).toContain('password=*****'); + expect(logOutput).toContain("token = '?'"); + expect(logOutput).not.toContain('rollback-secret'); + expect(logOutput).not.toContain('rollback-token'); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('masks URI and query-parameter passwords in connection info', () => { + const db = DatabaseConnection.getInstance(); + (db as unknown as { connectionString: string }).connectionString = + 'postgresql://app:s3cr3t@db.example.com:5432/main?sslmode=require&password=query-secret'; + + const connectionInfo = db.getConnectionInfo(); + + expect(connectionInfo).toContain('app:*****@db.example.com'); + expect(connectionInfo).toContain('password=*****'); + expect(connectionInfo).not.toContain('s3cr3t'); + expect(connectionInfo).not.toContain('query-secret'); + }); + + it('masks keyword-style connection strings', () => { + expect(maskConnectionString('host=localhost user=app password=s3cr3t dbname=main')).toBe( + 'host=localhost user=app password=***** dbname=main' + ); + expect(maskConnectionString('postgresql://app:s3cr3t@localhost/main')).toBe( + 'postgresql://app:*****@localhost/main' + ); + }); + + it('sanitizes standalone error messages', () => { + expect(sanitizeErrorMessage("password=s3cr3t SELECT 'private'")).toBe("password=***** SELECT '?'"); + expect(sanitizeErrorMessage("postgresql://app:s3cr3t@localhost/main failed on SELECT 'private'")).toBe( + "postgresql://app:*****@localhost/main failed on SELECT '?'" + ); + expect(sanitizeErrorMessage('Use the server --connection-string option or --allow-tool-connection-string.')).toBe( + 'Use the server --connection-string option or --allow-tool-connection-string.' + ); + }); + + it('rejects invalid POSTGRES_MCP_DEBUG_SQL values on fresh import', async () => { + const originalDebugSql = process.env.POSTGRES_MCP_DEBUG_SQL; + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool: vi.fn() } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + process.env.POSTGRES_MCP_DEBUG_SQL = 'yes'; + + try { + await expect(import('./connection')).rejects.toThrow('POSTGRES_MCP_DEBUG_SQL must be "true" or "false"'); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + if (originalDebugSql === undefined) { + delete process.env.POSTGRES_MCP_DEBUG_SQL; + } else { + process.env.POSTGRES_MCP_DEBUG_SQL = originalDebugSql; + } + } + }); + + it('attaches pg-monitor only when POSTGRES_MCP_DEBUG_SQL is true', async () => { + const originalDebugSql = process.env.POSTGRES_MCP_DEBUG_SQL; + const attach = vi.fn(); + const setTheme = vi.fn(); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool: vi.fn() } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach, + setTheme + } + })); + process.env.POSTGRES_MCP_DEBUG_SQL = 'true'; + + try { + await import('./connection'); + + expect(attach).toHaveBeenCalledWith({ + query: true, + error: true, + notice: true, + connect: true, + disconnect: true + }); + expect(setTheme).toHaveBeenCalledWith('matrix'); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + if (originalDebugSql === undefined) { + delete process.env.POSTGRES_MCP_DEBUG_SQL; + } else { + process.env.POSTGRES_MCP_DEBUG_SQL = originalDebugSql; + } + } + }); + + it('applies built-in bounded timeout defaults to new pools', async () => { + const originalMaxConnections = process.env.POSTGRES_MCP_MAX_CONNECTIONS; + const originalIdleTimeout = process.env.POSTGRES_MCP_IDLE_TIMEOUT_MS; + const originalConnectionTimeout = process.env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS; + const originalStatementTimeout = process.env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS; + const originalQueryTimeout = process.env.POSTGRES_MCP_QUERY_TIMEOUT_MS; + const originalLockTimeout = process.env.POSTGRES_MCP_LOCK_TIMEOUT_MS; + const originalIdleInTransactionSessionTimeout = process.env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS; + const release = vi.fn(); + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const pool = { + connect: vi.fn().mockResolvedValue(client), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const Pool = vi.fn(() => pool); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + delete process.env.POSTGRES_MCP_MAX_CONNECTIONS; + delete process.env.POSTGRES_MCP_IDLE_TIMEOUT_MS; + delete process.env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS; + delete process.env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS; + delete process.env.POSTGRES_MCP_QUERY_TIMEOUT_MS; + delete process.env.POSTGRES_MCP_LOCK_TIMEOUT_MS; + delete process.env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS; + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + await FreshDatabaseConnection.getInstance().connect('postgresql://test'); + + expect(Pool).toHaveBeenCalledWith(expect.objectContaining({ + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + statement_timeout: 60000, + query_timeout: 65000, + lock_timeout: 10000, + idle_in_transaction_session_timeout: 60000 + })); + expect(client.query).toHaveBeenCalledWith('SELECT 1'); + + await FreshDatabaseConnection.cleanupPools(); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + if (originalMaxConnections === undefined) { + delete process.env.POSTGRES_MCP_MAX_CONNECTIONS; + } else { + process.env.POSTGRES_MCP_MAX_CONNECTIONS = originalMaxConnections; + } + if (originalIdleTimeout === undefined) { + delete process.env.POSTGRES_MCP_IDLE_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_IDLE_TIMEOUT_MS = originalIdleTimeout; + } + if (originalConnectionTimeout === undefined) { + delete process.env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS = originalConnectionTimeout; + } + if (originalStatementTimeout === undefined) { + delete process.env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS = originalStatementTimeout; + } + if (originalQueryTimeout === undefined) { + delete process.env.POSTGRES_MCP_QUERY_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_QUERY_TIMEOUT_MS = originalQueryTimeout; + } + if (originalLockTimeout === undefined) { + delete process.env.POSTGRES_MCP_LOCK_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_LOCK_TIMEOUT_MS = originalLockTimeout; + } + if (originalIdleInTransactionSessionTimeout === undefined) { + delete process.env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS = originalIdleInTransactionSessionTimeout; + } + } + }); + + it('applies environment timeout overrides to new pools', async () => { + const originalMaxConnections = process.env.POSTGRES_MCP_MAX_CONNECTIONS; + const originalIdleTimeout = process.env.POSTGRES_MCP_IDLE_TIMEOUT_MS; + const originalConnectionTimeout = process.env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS; + const originalStatementTimeout = process.env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS; + const originalQueryTimeout = process.env.POSTGRES_MCP_QUERY_TIMEOUT_MS; + const originalLockTimeout = process.env.POSTGRES_MCP_LOCK_TIMEOUT_MS; + const originalIdleInTransactionSessionTimeout = process.env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS; + const release = vi.fn(); + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const pool = { + connect: vi.fn().mockResolvedValue(client), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const Pool = vi.fn(() => pool); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + process.env.POSTGRES_MCP_MAX_CONNECTIONS = '8'; + process.env.POSTGRES_MCP_IDLE_TIMEOUT_MS = '9000'; + process.env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS = '1000'; + process.env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS = '30000'; + process.env.POSTGRES_MCP_QUERY_TIMEOUT_MS = '45000'; + process.env.POSTGRES_MCP_LOCK_TIMEOUT_MS = '5000'; + process.env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS = '55000'; + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + await FreshDatabaseConnection.getInstance().connect('postgresql://test'); + + expect(Pool).toHaveBeenCalledWith(expect.objectContaining({ + max: 8, + idleTimeoutMillis: 9000, + connectionTimeoutMillis: 1000, + statement_timeout: 30000, + query_timeout: 45000, + lock_timeout: 5000, + idle_in_transaction_session_timeout: 55000 + })); + expect(client.query).toHaveBeenCalledWith('SELECT 1'); + + await FreshDatabaseConnection.cleanupPools(); + expect(FreshDatabaseConnection.getInstance().isConnected()).toBe(false); + expect(FreshDatabaseConnection.getInstance().getConnectionInfo()).toBe('Not connected'); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + if (originalMaxConnections === undefined) { + delete process.env.POSTGRES_MCP_MAX_CONNECTIONS; + } else { + process.env.POSTGRES_MCP_MAX_CONNECTIONS = originalMaxConnections; + } + if (originalIdleTimeout === undefined) { + delete process.env.POSTGRES_MCP_IDLE_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_IDLE_TIMEOUT_MS = originalIdleTimeout; + } + if (originalConnectionTimeout === undefined) { + delete process.env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_CONNECTION_TIMEOUT_MS = originalConnectionTimeout; + } + if (originalStatementTimeout === undefined) { + delete process.env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_STATEMENT_TIMEOUT_MS = originalStatementTimeout; + } + if (originalQueryTimeout === undefined) { + delete process.env.POSTGRES_MCP_QUERY_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_QUERY_TIMEOUT_MS = originalQueryTimeout; + } + if (originalLockTimeout === undefined) { + delete process.env.POSTGRES_MCP_LOCK_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_LOCK_TIMEOUT_MS = originalLockTimeout; + } + if (originalIdleInTransactionSessionTimeout === undefined) { + delete process.env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS; + } else { + process.env.POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS = originalIdleInTransactionSessionTimeout; + } + } + }); + + it('rejects invalid connection options before constructing a pool', async () => { + const Pool = vi.fn(); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + const db = FreshDatabaseConnection.getInstance(); + + await expect(db.connect('postgresql://test', { maxConnections: 0 })).rejects.toThrow('maxConnections must be a positive integer'); + await expect(db.connect('postgresql://test', { idleTimeoutMillis: 1.5 })).rejects.toThrow('idleTimeoutMillis must be a positive integer'); + await expect(db.connect('postgresql://test', { connectionTimeoutMillis: -1 })).rejects.toThrow('connectionTimeoutMillis must be a positive integer'); + await expect(db.connect('postgresql://test', { statementTimeout: 0 })).rejects.toThrow('statementTimeout must be a positive integer'); + await expect(db.connect('postgresql://test', { queryTimeout: 1.5 })).rejects.toThrow('queryTimeout must be a positive integer'); + await expect(db.connect('postgresql://test', { lockTimeout: 0 })).rejects.toThrow('lockTimeout must be a positive integer'); + await expect(db.connect('postgresql://test', { idleInTransactionSessionTimeout: -1 })).rejects.toThrow('idleInTransactionSessionTimeout must be a positive integer'); + + expect(Pool).not.toHaveBeenCalled(); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + } + }); + + it('rejects blank connection strings before constructing a pool', async () => { + const originalConnectionString = process.env.POSTGRES_CONNECTION_STRING; + const Pool = vi.fn(); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + const db = FreshDatabaseConnection.getInstance(); + process.env.POSTGRES_CONNECTION_STRING = 'postgresql://env'; + + await expect(db.connect(' ')).rejects.toThrow('No non-empty connection string provided'); + + process.env.POSTGRES_CONNECTION_STRING = '\t '; + await expect(db.connect()).rejects.toThrow('No non-empty connection string provided'); + + expect(Pool).not.toHaveBeenCalled(); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + if (originalConnectionString === undefined) { + delete process.env.POSTGRES_CONNECTION_STRING; + } else { + process.env.POSTGRES_CONNECTION_STRING = originalConnectionString; + } + } + }); + + it('keeps an active connection when a later connect call has invalid options', async () => { + const db = DatabaseConnection.getInstance(); + const pool = { + query: vi.fn(), + end: vi.fn() + }; + + (db as unknown as { pool: typeof pool; connectionString: string }).pool = pool; + (db as unknown as { connectionString: string }).connectionString = 'postgresql://app:secret@localhost/main'; + + await expect(db.connect('postgresql://other', { maxConnections: 0 })).rejects.toThrow('maxConnections must be a positive integer'); + + expect(db.getPool()).toBe(pool); + expect(db.getConnectionInfo()).toBe('postgresql://app:*****@localhost/main'); + expect(pool.end).not.toHaveBeenCalled(); + }); + + it('passes validated direct connection options to new pools', async () => { + const release = vi.fn(); + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const pool = { + connect: vi.fn().mockResolvedValue(client), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const Pool = vi.fn(() => pool); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + + await FreshDatabaseConnection.getInstance().connect('postgresql://test', { + maxConnections: 5, + idleTimeoutMillis: 1000, + connectionTimeoutMillis: 2000, + statementTimeout: 3000, + queryTimeout: 4000, + lockTimeout: 5000, + idleInTransactionSessionTimeout: 6000 + }); + + expect(Pool).toHaveBeenCalledWith(expect.objectContaining({ + max: 5, + idleTimeoutMillis: 1000, + connectionTimeoutMillis: 2000, + statement_timeout: 3000, + query_timeout: 4000, + lock_timeout: 5000, + idle_in_transaction_session_timeout: 6000 + })); + expect(client.query).toHaveBeenCalledWith('SELECT 1'); + expect(release).toHaveBeenCalledTimes(1); + + await FreshDatabaseConnection.cleanupPools(); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + } + }); + + it('serializes overlapping active connection workflows until disconnect releases the lease', async () => { + const firstClient = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release: vi.fn() + }; + const secondClient = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release: vi.fn() + }; + const firstPool = { + connect: vi.fn().mockResolvedValue(firstClient), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const secondPool = { + connect: vi.fn().mockResolvedValue(secondClient), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const Pool = vi.fn() + .mockReturnValueOnce(firstPool) + .mockReturnValueOnce(secondPool); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + const db = FreshDatabaseConnection.getInstance(); + + await db.connect('postgresql://first'); + + let secondConnected = false; + const secondConnect = db.connect('postgresql://second').then(() => { + secondConnected = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(secondConnected).toBe(false); + expect(Pool).toHaveBeenCalledTimes(1); + + await db.disconnect(); + await secondConnect; + + expect(secondConnected).toBe(true); + expect(Pool).toHaveBeenCalledTimes(2); + expect(secondPool.connect).toHaveBeenCalledTimes(1); + + await FreshDatabaseConnection.cleanupPools(); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + } + }); + + it('reuses an active same-pool connection without waiting on its lease', async () => { + const release = vi.fn(); + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const pool = { + connect: vi.fn().mockResolvedValue(client), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const Pool = vi.fn(() => pool); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + const db = FreshDatabaseConnection.getInstance(); + + await db.connect('postgresql://same'); + await db.connect('postgresql://same'); + + expect(Pool).toHaveBeenCalledTimes(1); + expect(pool.connect).toHaveBeenCalledTimes(1); + expect(client.query).toHaveBeenCalledTimes(1); + + await FreshDatabaseConnection.cleanupPools(); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + } + }); + + it('releases the connection lease when connect validation fails', async () => { + const release = vi.fn(); + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const pool = { + connect: vi.fn().mockResolvedValue(client), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const Pool = vi.fn(() => pool); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + const db = FreshDatabaseConnection.getInstance(); + + await expect(db.connect(' ')).rejects.toThrow('No non-empty connection string provided'); + await db.connect('postgresql://valid'); + + expect(Pool).toHaveBeenCalledTimes(1); + expect(pool.connect).toHaveBeenCalledTimes(1); + + await FreshDatabaseConnection.cleanupPools(); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + } + }); + + it('recovers if the connection queue is accidentally poisoned', async () => { + const release = vi.fn(); + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const pool = { + connect: vi.fn().mockResolvedValue(client), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const Pool = vi.fn(() => pool); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + (FreshDatabaseConnection as unknown as { connectionQueue: Promise }).connectionQueue = Promise.reject(new Error('poisoned lease')); + + const db = FreshDatabaseConnection.getInstance(); + await db.connect('postgresql://valid'); + + expect(Pool).toHaveBeenCalledTimes(1); + expect(pool.connect).toHaveBeenCalledTimes(1); + expect(client.query).toHaveBeenCalledWith('SELECT 1'); + expect(db.isConnected()).toBe(true); + + await FreshDatabaseConnection.cleanupPools(); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + } + }); + + it('clears active state and removes failed pools after connection failure', async () => { + const failedPool = { + connect: vi.fn().mockRejectedValue(new Error('database is down')), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const release = vi.fn(); + const healthyClient = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const healthyPool = { + connect: vi.fn().mockResolvedValue(healthyClient), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const Pool = vi.fn() + .mockReturnValueOnce(failedPool) + .mockReturnValueOnce(healthyPool); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + const db = FreshDatabaseConnection.getInstance(); + + await expect(db.connect('postgresql://app:secret@localhost/main')).rejects.toThrow('Failed to connect to database: database is down'); + + expect(failedPool.end).toHaveBeenCalledTimes(1); + expect(db.isConnected()).toBe(false); + expect(db.getPool()).toBeNull(); + expect(db.getConnectionInfo()).toBe('Not connected'); + await expect(db.query('SELECT 1')).rejects.toThrow('Not connected to database'); + + await db.connect('postgresql://app:secret@localhost/main'); + + expect(Pool).toHaveBeenCalledTimes(2); + expect(healthyPool.connect).toHaveBeenCalledTimes(1); + expect(healthyClient.query).toHaveBeenCalledWith('SELECT 1'); + expect(release).toHaveBeenCalledTimes(1); + + await FreshDatabaseConnection.cleanupPools(); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + } + }); + + it('clears active singleton state when pool cleanup runs after shutdown', async () => { + const release = vi.fn(); + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const pool = { + connect: vi.fn().mockResolvedValue(client), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const Pool = vi.fn(() => pool); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + const db = FreshDatabaseConnection.getInstance(); + + await db.connect('postgresql://app:secret@localhost/main'); + expect(db.isConnected()).toBe(true); + expect(db.getConnectionInfo()).toBe('postgresql://app:*****@localhost/main'); + + await FreshDatabaseConnection.cleanupPools(); + + expect(pool.end).toHaveBeenCalledTimes(1); + expect(db.isConnected()).toBe(false); + expect(db.getPool()).toBeNull(); + expect(db.getConnectionInfo()).toBe('Not connected'); + await expect(db.query('SELECT 1')).rejects.toThrow('Not connected to database'); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + } + }); + + it('disconnects the active handle without ending the reusable cached pool', async () => { + const release = vi.fn(); + const client = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release + }; + const pool = { + connect: vi.fn().mockResolvedValue(client), + on: vi.fn(), + end: vi.fn().mockResolvedValue(undefined) + }; + const Pool = vi.fn(() => pool); + + vi.resetModules(); + vi.doMock('pg', () => ({ default: { Pool } })); + vi.doMock('pg-monitor', () => ({ + default: { + attach: vi.fn(), + setTheme: vi.fn() + } + })); + + try { + const { DatabaseConnection: FreshDatabaseConnection } = await import('./connection'); + const db = FreshDatabaseConnection.getInstance(); + + await db.connect('postgresql://app:secret@localhost/main'); + await db.disconnect(); + + expect(db.isConnected()).toBe(false); + expect(db.getPool()).toBeNull(); + expect(db.getConnectionInfo()).toBe('Not connected'); + expect(pool.end).not.toHaveBeenCalled(); + await expect(db.query('SELECT 1')).rejects.toThrow('Not connected to database'); + + await db.connect('postgresql://app:secret@localhost/main'); + + expect(Pool).toHaveBeenCalledTimes(1); + expect(pool.connect).toHaveBeenCalledTimes(2); + expect(db.isConnected()).toBe(true); + + await FreshDatabaseConnection.cleanupPools(); + } finally { + vi.doUnmock('pg'); + vi.doUnmock('pg-monitor'); + vi.resetModules(); + } + }); +}); diff --git a/src/utils/connection.ts b/src/utils/connection.ts index 1446c1d..8075bf0 100644 --- a/src/utils/connection.ts +++ b/src/utils/connection.ts @@ -1,19 +1,16 @@ import pkg from 'pg'; -import type { Pool as PoolType, PoolClient as PoolClientType, PoolConfig, QueryResultRow } from 'pg'; +import type { Pool as PoolType, PoolClient as PoolClientType, PoolConfig, QueryResult, QueryResultRow } from 'pg'; import monitor from 'pg-monitor'; +import { redactSqlText } from './sql.js'; const { Pool } = pkg; -// Enable pg-monitor for better debugging in development -if (process.env.NODE_ENV !== 'production') { - monitor.attach({ - query: true, - error: true, - notice: true, - connect: true, - disconnect: true - }); - monitor.setTheme('matrix'); -} +export const DEFAULT_STATEMENT_TIMEOUT_MS = 60000; +export const DEFAULT_QUERY_TIMEOUT_MS = 65000; +export const DEFAULT_LOCK_TIMEOUT_MS = 10000; +export const DEFAULT_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS = 60000; +export const DEFAULT_MAX_CONNECTIONS = 20; +export const DEFAULT_POOL_IDLE_TIMEOUT_MS = 30000; +export const DEFAULT_CONNECTION_TIMEOUT_MS = 2000; // Connection pool cache to reuse connections const poolCache = new Map(); @@ -24,9 +21,18 @@ interface ConnectionOptions { connectionTimeoutMillis?: number; statementTimeout?: number; queryTimeout?: number; + lockTimeout?: number; + idleInTransactionSessionTimeout?: number; ssl?: boolean | { rejectUnauthorized: boolean }; } +type PoolConfigWithTimeouts = PoolConfig & { + statement_timeout?: number; + query_timeout?: number; + lock_timeout?: number; + idle_in_transaction_session_timeout?: number; +}; + // Extended query config with additional options interface ExtendedQueryConfig { text: string; @@ -35,13 +41,170 @@ interface ExtendedQueryConfig { rowMode?: string; } +function parseBooleanEnv(name: string): boolean { + const value = process.env[name]; + + if (value === undefined || value === '') { + return false; + } + + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + throw new Error(`${name} must be "true" or "false".`); +} + +// pg-monitor can print raw SQL and bind values. Keep it opt-in so stdio MCP logs +// do not leak queries or secrets during normal development use. +if (parseBooleanEnv('POSTGRES_MCP_DEBUG_SQL')) { + monitor.attach({ + query: true, + error: true, + notice: true, + connect: true, + disconnect: true + }); + monitor.setTheme('matrix'); +} + +function parsePositiveIntegerEnv(name: string, fallback: number): number { + const value = process.env[name]; + if (!value) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer.`); + } + + return parsed; +} + +function assertPositiveIntegerOption(name: string, value: number | undefined): void { + if (value === undefined) { + return; + } + + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer.`); + } +} + +function assertConnectionOptions(options: ConnectionOptions): void { + assertPositiveIntegerOption('maxConnections', options.maxConnections); + assertPositiveIntegerOption('idleTimeoutMillis', options.idleTimeoutMillis); + assertPositiveIntegerOption('connectionTimeoutMillis', options.connectionTimeoutMillis); + assertPositiveIntegerOption('statementTimeout', options.statementTimeout); + assertPositiveIntegerOption('queryTimeout', options.queryTimeout); + assertPositiveIntegerOption('lockTimeout', options.lockTimeout); + assertPositiveIntegerOption('idleInTransactionSessionTimeout', options.idleInTransactionSessionTimeout); +} + +function getDefaultConnectionOptions(): ConnectionOptions { + return { + maxConnections: parsePositiveIntegerEnv('POSTGRES_MCP_MAX_CONNECTIONS', DEFAULT_MAX_CONNECTIONS), + idleTimeoutMillis: parsePositiveIntegerEnv('POSTGRES_MCP_IDLE_TIMEOUT_MS', DEFAULT_POOL_IDLE_TIMEOUT_MS), + connectionTimeoutMillis: parsePositiveIntegerEnv('POSTGRES_MCP_CONNECTION_TIMEOUT_MS', DEFAULT_CONNECTION_TIMEOUT_MS), + statementTimeout: parsePositiveIntegerEnv('POSTGRES_MCP_STATEMENT_TIMEOUT_MS', DEFAULT_STATEMENT_TIMEOUT_MS), + queryTimeout: parsePositiveIntegerEnv('POSTGRES_MCP_QUERY_TIMEOUT_MS', DEFAULT_QUERY_TIMEOUT_MS), + lockTimeout: parsePositiveIntegerEnv('POSTGRES_MCP_LOCK_TIMEOUT_MS', DEFAULT_LOCK_TIMEOUT_MS), + idleInTransactionSessionTimeout: parsePositiveIntegerEnv('POSTGRES_MCP_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS', DEFAULT_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS) + }; +} + +function buildPoolCacheKey(connectionString: string, options: ConnectionOptions): string { + return JSON.stringify({ + connectionString, + maxConnections: options.maxConnections, + idleTimeoutMillis: options.idleTimeoutMillis, + connectionTimeoutMillis: options.connectionTimeoutMillis, + statementTimeout: options.statementTimeout, + queryTimeout: options.queryTimeout, + lockTimeout: options.lockTimeout, + idleInTransactionSessionTimeout: options.idleInTransactionSessionTimeout, + ssl: options.ssl + }); +} + +const ERROR_MESSAGE_CLI_FLAGS = [ + '--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 function maskConnectionString(connectionString: string): string { + try { + if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+$/.test(connectionString)) { + const parsed = new URL(connectionString); + if (parsed.password) { + parsed.password = '*****'; + } + + for (const key of ['password', 'pass', 'pwd']) { + if (parsed.searchParams.has(key)) { + parsed.searchParams.set(key, '*****'); + } + } + + return parsed.toString(); + } + } catch { + // Fall through to regex masking for malformed or embedded connection strings. + } + + return connectionString + .replace(/(password|pass|pwd)=([^&\s]*)/gi, '$1=*****') + .replace(/:\/\/([^:\s/@]+):([^@\s]+)@/g, '://$1:*****@'); +} + +export function sanitizeErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + let masked = maskConnectionString(message); + const placeholders: string[] = []; + + for (const flag of ERROR_MESSAGE_CLI_FLAGS) { + masked = masked.replaceAll(flag, () => { + const placeholder = `__POSTGRES_MCP_CLI_FLAG_${placeholders.length}__`; + placeholders.push(flag); + return placeholder; + }); + } + + let redacted = redactSqlText(masked, 1000); + placeholders.forEach((flag, index) => { + redacted = redacted.replaceAll(`__POSTGRES_MCP_CLI_FLAG_${index}__`, flag); + }); + return redacted; +} + export class DatabaseConnection { private static instance: DatabaseConnection; + private static connectionQueue: Promise = Promise.resolve(); private pool: PoolType | null = null; - private client: PoolClientType | null = null; private connectionString = ''; + private poolCacheKey = ''; private lastError: Error | null = null; private connectionOptions: ConnectionOptions = {}; + private connectionLeaseRelease: (() => void) | null = null; private constructor() {} @@ -52,84 +215,151 @@ export class DatabaseConnection { return DatabaseConnection.instance; } + private static clearActivePool(poolCacheKey: string): void { + if (!DatabaseConnection.instance || DatabaseConnection.instance.poolCacheKey !== poolCacheKey) { + return; + } + + DatabaseConnection.instance.clearConnectionState(); + } + + private clearConnectionState(): void { + this.pool = null; + this.connectionString = ''; + this.poolCacheKey = ''; + this.connectionOptions = {}; + } + + private async acquireConnectionLease(): Promise { + let releaseCurrentLease!: () => void; + const currentLease = new Promise((resolve) => { + releaseCurrentLease = resolve; + }); + const previousLease = DatabaseConnection.connectionQueue; + DatabaseConnection.connectionQueue = previousLease.then( + () => currentLease, + () => currentLease + ); + + try { + await previousLease; + } catch { + // Keep the connection queue live even if a future edit accidentally poisons it. + } + this.connectionLeaseRelease = releaseCurrentLease; + } + + private releaseConnectionLease(): void { + if (!this.connectionLeaseRelease) { + return; + } + + const releaseLease = this.connectionLeaseRelease; + this.connectionLeaseRelease = null; + releaseLease(); + } + /** * Connect to a PostgreSQL database */ public async connect(connectionString?: string, options: ConnectionOptions = {}): Promise { + // Use environment variable only when no explicit connection string was provided. + const connString = connectionString !== undefined ? connectionString : process.env.POSTGRES_CONNECTION_STRING; + const effectiveOptions = { + ...getDefaultConnectionOptions(), + ...options + }; + + if (connString === undefined || connString.trim() === '') { + throw new Error('No non-empty connection string provided and POSTGRES_CONNECTION_STRING environment variable is not set'); + } + + assertConnectionOptions(effectiveOptions); + const poolCacheKey = buildPoolCacheKey(connString, effectiveOptions); + + // If already connected to this database, reuse the connection without waiting on our own active lease. + if (this.pool && this.poolCacheKey === poolCacheKey) { + return; + } + + await this.acquireConnectionLease(); + let connectionStateChanged = false; + try { - // Use environment variable if connection string is not provided - const connString = connectionString || process.env.POSTGRES_CONNECTION_STRING; - - if (!connString) { - throw new Error('No connection string provided and POSTGRES_CONNECTION_STRING environment variable is not set'); - } - - // If already connected to this database, reuse the connection - if (this.pool && this.connectionString === connString) { + // Another queued connect may have established the requested pool while we waited. + if (this.pool && this.poolCacheKey === poolCacheKey) { return; } // If connected to a different database, disconnect first if (this.pool) { - await this.disconnect(); + this.clearConnectionState(); } this.connectionString = connString; - this.connectionOptions = options; + this.poolCacheKey = poolCacheKey; + this.connectionOptions = effectiveOptions; + connectionStateChanged = true; // Check if we have a cached pool for this connection string - if (poolCache.has(connString)) { - this.pool = poolCache.get(connString) as PoolType; + if (poolCache.has(poolCacheKey)) { + this.pool = poolCache.get(poolCacheKey) as PoolType; } else { // Create a new pool - const config: PoolConfig = { + const config: PoolConfigWithTimeouts = { connectionString: connString, - max: options.maxConnections || 20, - idleTimeoutMillis: options.idleTimeoutMillis || 30000, - connectionTimeoutMillis: options.connectionTimeoutMillis || 2000, + max: effectiveOptions.maxConnections, + idleTimeoutMillis: effectiveOptions.idleTimeoutMillis, + connectionTimeoutMillis: effectiveOptions.connectionTimeoutMillis, allowExitOnIdle: true, - ssl: options.ssl + ssl: effectiveOptions.ssl, + statement_timeout: effectiveOptions.statementTimeout, + query_timeout: effectiveOptions.queryTimeout, + lock_timeout: effectiveOptions.lockTimeout, + idle_in_transaction_session_timeout: effectiveOptions.idleInTransactionSessionTimeout }; this.pool = new Pool(config); // Set up error handler for the pool this.pool.on('error', (err: Error) => { - console.error('Unexpected error on idle client', err); + console.error('Unexpected error on idle client:', sanitizeErrorMessage(err)); this.lastError = err; }); // Cache the pool for future use - poolCache.set(connString, this.pool); + poolCache.set(poolCacheKey, this.pool); } - // Test connection - this.client = await this.pool.connect(); - - // Set statement timeout if specified - if (options.statementTimeout) { - await this.client.query(`SET statement_timeout = ${options.statementTimeout}`); + const client = await this.pool.connect(); + try { + // Test the connection without keeping a client checked out. + await client.query('SELECT 1'); + } finally { + client.release(); } - - // Test the connection - await this.client.query('SELECT 1'); } catch (error) { this.lastError = error instanceof Error ? error : new Error(String(error)); + const failedPool = connectionStateChanged ? this.pool : null; + const failedPoolCacheKey = connectionStateChanged ? this.poolCacheKey : ''; - if (this.client) { - this.client.release(); - this.client = null; - } - - if (this.pool) { + if (failedPool) { // Remove from cache if connection failed - poolCache.delete(this.connectionString); - await this.pool.end(); - this.pool = null; + poolCache.delete(failedPoolCacheKey); + try { + await failedPool.end(); + } catch (cleanupError) { + console.error(`Error closing failed pool for ${maskConnectionString(failedPoolCacheKey)}:`, sanitizeErrorMessage(cleanupError)); + } + } + + if (connectionStateChanged) { + this.clearConnectionState(); } + this.releaseConnectionLease(); - throw new Error(`Failed to connect to database: ${this.lastError.message}`); + throw new Error(`Failed to connect to database: ${sanitizeErrorMessage(this.lastError)}`); } } @@ -137,15 +367,9 @@ export class DatabaseConnection { * Disconnect from the database */ public async disconnect(): Promise { - if (this.client) { - this.client.release(); - this.client = null; - } - - // Note: We don't end the pool here to allow connection reuse - // The pool will be cleaned up when the application exits - - this.connectionString = ''; + // Detach the active handle while keeping the cached pool available for reuse. + this.clearConnectionState(); + this.releaseConnectionLease(); } /** @@ -156,29 +380,40 @@ export class DatabaseConnection { values: unknown[] = [], options: { timeout?: number } = {} ): Promise { - if (!this.client || !this.pool) { + const result = await this.queryResult(text, values, options); + return result.rows; + } + + /** + * Execute a SQL query and return PostgreSQL metadata such as rowCount + */ + public async queryResult>( + text: string, + values: unknown[] = [], + options: { timeout?: number } = {} + ): Promise> { + if (!this.pool) { throw new Error('Not connected to database'); } try { + assertPositiveIntegerOption('query timeout', options.timeout); const queryConfig = { text, values }; // Set query timeout if specified - if (options.timeout || this.connectionOptions.queryTimeout) { + if (options.timeout !== undefined || this.connectionOptions.queryTimeout) { // We need to use a type assertion here because the pg types don't include timeout // but the library actually supports it - (queryConfig as ExtendedQueryConfig).timeout = options.timeout || this.connectionOptions.queryTimeout; + (queryConfig as ExtendedQueryConfig).timeout = options.timeout ?? this.connectionOptions.queryTimeout; } - // Use type assertion only for the query call - const result = await this.client.query(queryConfig); - return result.rows; + return await this.pool.query(queryConfig); } catch (error) { this.lastError = error instanceof Error ? error : new Error(String(error)); - throw new Error(`Query failed: ${this.lastError.message}`); + throw new Error(`Query failed: ${sanitizeErrorMessage(this.lastError)}`); } } @@ -214,20 +449,30 @@ export class DatabaseConnection { /** * Execute multiple queries in a transaction */ - public async transaction(callback: (client: PoolClientType) => Promise): Promise { - if (!this.client || !this.pool) { + public async transaction( + callback: (client: PoolClientType) => Promise, + options: { readOnly?: boolean } = {} + ): Promise { + if (!this.pool) { throw new Error('Not connected to database'); } + const client = await this.pool.connect(); try { - await this.client.query('BEGIN'); - const result = await callback(this.client); - await this.client.query('COMMIT'); + await client.query(options.readOnly ? 'BEGIN READ ONLY' : 'BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); return result; } catch (error) { - await this.client.query('ROLLBACK'); + try { + await client.query('ROLLBACK'); + } catch (rollbackError) { + console.error('Transaction rollback failed:', sanitizeErrorMessage(rollbackError)); + } this.lastError = error instanceof Error ? error : new Error(String(error)); - throw new Error(`Transaction failed: ${this.lastError.message}`); + throw new Error(`Transaction failed: ${sanitizeErrorMessage(this.lastError)}`); + } finally { + client.release(); } } @@ -242,7 +487,7 @@ export class DatabaseConnection { * Get the current client */ public getClient(): PoolClientType | null { - return this.client; + return null; } /** @@ -256,7 +501,7 @@ export class DatabaseConnection { * Check if connected to database */ public isConnected(): boolean { - return this.pool !== null && this.client !== null; + return this.pool !== null; } /** @@ -266,9 +511,8 @@ export class DatabaseConnection { if (!this.connectionString) { return 'Not connected'; } - - // Mask password in connection string - return this.connectionString.replace(/password=([^&]*)/, 'password=*****'); + + return maskConnectionString(this.connectionString); } /** @@ -276,13 +520,16 @@ export class DatabaseConnection { * Should be called when the application is shutting down */ public static async cleanupPools(): Promise { - for (const [connectionString, pool] of poolCache.entries()) { + for (const [poolCacheKey, pool] of Array.from(poolCache.entries())) { try { await pool.end(); - poolCache.delete(connectionString); } catch (error) { - console.error(`Error closing pool for ${connectionString}:`, error); + console.error(`Error closing pool for ${maskConnectionString(poolCacheKey)}:`, sanitizeErrorMessage(error)); + } finally { + poolCache.delete(poolCacheKey); + DatabaseConnection.clearActivePool(poolCacheKey); + DatabaseConnection.instance?.releaseConnectionLease(); } } } -} \ No newline at end of file +} diff --git a/src/utils/filesystem.test.ts b/src/utils/filesystem.test.ts new file mode 100644 index 0000000..0c95c20 --- /dev/null +++ b/src/utils/filesystem.test.ts @@ -0,0 +1,125 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + assertReadableSandboxFile, + assertWritableContentSize, + getFileSandboxConfig, + resolveSandboxPath +} from './filesystem'; + +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-fs-')); +} + +async function trySymlink(target: string, linkPath: string, type: fs.symlink.Type): Promise { + try { + await fs.promises.symlink(target, linkPath, process.platform === 'win32' && type === 'dir' ? 'junction' : type); + return true; + } catch { + return false; + } +} + +describe('filesystem sandbox helpers', () => { + afterEach(async () => { + 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; + } + }); + + it('requires an explicit workspace directory', () => { + delete process.env.POSTGRES_MCP_WORKSPACE_DIR; + + expect(() => getFileSandboxConfig()).toThrow('POSTGRES_MCP_WORKSPACE_DIR'); + }); + + it('requires positive integer max file bytes when configured', async () => { + const workspace = await makeWorkspace(); + process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace; + + process.env.POSTGRES_MCP_MAX_FILE_BYTES = '1.5'; + expect(() => getFileSandboxConfig()).toThrow('POSTGRES_MCP_MAX_FILE_BYTES must be a positive integer'); + + process.env.POSTGRES_MCP_MAX_FILE_BYTES = '0'; + expect(() => getFileSandboxConfig()).toThrow('POSTGRES_MCP_MAX_FILE_BYTES must be a positive integer'); + }); + + it('resolves relative paths inside the configured workspace', async () => { + const workspace = await makeWorkspace(); + process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace; + + expect(resolveSandboxPath('exports/users.json', 'json')).toBe(path.join(workspace, 'exports', 'users.json')); + }); + + it('rejects traversal outside the configured workspace', async () => { + const workspace = await makeWorkspace(); + process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace; + + expect(() => resolveSandboxPath('../outside.json', 'json')).toThrow('outside POSTGRES_MCP_WORKSPACE_DIR'); + }); + + it('rejects existing file symlinks that resolve outside the configured workspace', async () => { + const workspace = await makeWorkspace(); + const outside = await makeWorkspace(); + const outsideFile = path.join(outside, 'secret.json'); + const linkPath = path.join(workspace, 'secret.json'); + await fs.promises.writeFile(outsideFile, '[{}]'); + if (!await trySymlink(outsideFile, linkPath, 'file')) { + return; + } + process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace; + + expect(() => resolveSandboxPath('secret.json', 'json')).toThrow(/outside POSTGRES_MCP_WORKSPACE_DIR|unresolved target/); + }); + + it('rejects parent directory symlinks that resolve outside the configured workspace', async () => { + const workspace = await makeWorkspace(); + const outside = await makeWorkspace(); + const linkPath = path.join(workspace, 'exports'); + if (!await trySymlink(outside, linkPath, 'dir')) { + return; + } + process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace; + + expect(() => resolveSandboxPath('exports/users.json', 'json')).toThrow('outside POSTGRES_MCP_WORKSPACE_DIR'); + }); + + it('enforces extensions and requested formats', async () => { + const workspace = await makeWorkspace(); + process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace; + + expect(() => resolveSandboxPath('users.txt')).toThrow('Unsupported file extension'); + expect(() => resolveSandboxPath('users.csv', 'json')).toThrow('does not match requested format'); + }); + + it('checks import file size before reading', async () => { + const workspace = await makeWorkspace(); + const inputPath = path.join(workspace, 'users.json'); + await fs.promises.writeFile(inputPath, '[{}]'); + process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace; + process.env.POSTGRES_MCP_MAX_FILE_BYTES = '2'; + + await expect(assertReadableSandboxFile(inputPath, 'json')).rejects.toThrow('exceeds max file size'); + }); + + it('checks export content size before writing', async () => { + const workspace = await makeWorkspace(); + process.env.POSTGRES_MCP_WORKSPACE_DIR = workspace; + process.env.POSTGRES_MCP_MAX_FILE_BYTES = '2'; + + expect(() => assertWritableContentSize('abcd')).toThrow('Generated export exceeds max file size'); + }); +}); diff --git a/src/utils/filesystem.ts b/src/utils/filesystem.ts new file mode 100644 index 0000000..2b82872 --- /dev/null +++ b/src/utils/filesystem.ts @@ -0,0 +1,144 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; +const ALLOWED_EXTENSIONS = new Set(['.csv', '.json']); + +export interface FileSandboxConfig { + workspaceDir: string; + maxFileBytes: number; +} + +function parseMaxFileBytes(value: string | undefined): number { + if (!value) { + return DEFAULT_MAX_FILE_BYTES; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error('POSTGRES_MCP_MAX_FILE_BYTES must be a positive integer.'); + } + + return parsed; +} + +export function getFileSandboxConfig(): FileSandboxConfig { + const workspaceDir = process.env.POSTGRES_MCP_WORKSPACE_DIR; + if (!workspaceDir) { + throw new Error('Filesystem tools require POSTGRES_MCP_WORKSPACE_DIR or --workspace-dir.'); + } + + return { + workspaceDir: fs.realpathSync(path.resolve(workspaceDir)), + maxFileBytes: parseMaxFileBytes(process.env.POSTGRES_MCP_MAX_FILE_BYTES) + }; +} + +function assertInsideWorkspace(resolvedPath: string, workspaceDir: string): void { + const relativePath = path.relative(workspaceDir, resolvedPath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new Error(`Path "${resolvedPath}" is outside POSTGRES_MCP_WORKSPACE_DIR.`); + } +} + +function isEnoent(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} + +function realpathIfExists(resolvedPath: string): string | undefined { + try { + return fs.realpathSync(resolvedPath); + } catch (error) { + if (isEnoent(error)) { + return undefined; + } + + throw error; + } +} + +function assertNoDanglingSymlink(resolvedPath: string): void { + try { + if (fs.lstatSync(resolvedPath).isSymbolicLink()) { + throw new Error(`Path "${resolvedPath}" is a symlink with an unresolved target.`); + } + } catch (error) { + if (!isEnoent(error)) { + throw error; + } + } +} + +function nearestExistingParentRealpath(resolvedPath: string): string { + let currentPath = path.dirname(resolvedPath); + + while (true) { + const realPath = realpathIfExists(currentPath); + if (realPath) { + return realPath; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + throw new Error(`Path "${resolvedPath}" has no existing parent directory.`); + } + + currentPath = parentPath; + } +} + +function assertAllowedExtension(resolvedPath: string, expectedFormat?: 'json' | 'csv'): void { + const extension = path.extname(resolvedPath).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(extension)) { + throw new Error(`Unsupported file extension "${extension}". Only .json and .csv are allowed.`); + } + + if (expectedFormat && extension !== `.${expectedFormat}`) { + throw new Error(`File extension "${extension}" does not match requested format "${expectedFormat}".`); + } +} + +export function resolveSandboxPath(inputPath: string, expectedFormat?: 'json' | 'csv'): string { + const config = getFileSandboxConfig(); + const resolvedPath = path.isAbsolute(inputPath) + ? path.resolve(inputPath) + : path.resolve(config.workspaceDir, inputPath); + + assertInsideWorkspace(resolvedPath, config.workspaceDir); + assertAllowedExtension(resolvedPath, expectedFormat); + + const realResolvedPath = realpathIfExists(resolvedPath); + if (realResolvedPath) { + assertInsideWorkspace(realResolvedPath, config.workspaceDir); + } else { + assertNoDanglingSymlink(resolvedPath); + assertInsideWorkspace(nearestExistingParentRealpath(resolvedPath), config.workspaceDir); + } + + return resolvedPath; +} + +export async function assertReadableSandboxFile(inputPath: string, expectedFormat?: 'json' | 'csv'): Promise { + const resolvedPath = resolveSandboxPath(inputPath, expectedFormat); + const config = getFileSandboxConfig(); + const stats = await fs.promises.stat(resolvedPath); + + if (!stats.isFile()) { + throw new Error(`Path "${resolvedPath}" is not a file.`); + } + + if (stats.size > config.maxFileBytes) { + throw new Error(`File "${resolvedPath}" exceeds max file size of ${config.maxFileBytes} bytes.`); + } + + return resolvedPath; +} + +export function assertWritableContentSize(content: string): void { + const config = getFileSandboxConfig(); + const byteLength = Buffer.byteLength(content, 'utf8'); + + if (byteLength > config.maxFileBytes) { + throw new Error(`Generated export exceeds max file size of ${config.maxFileBytes} bytes.`); + } +} diff --git a/src/utils/sql.test.ts b/src/utils/sql.test.ts new file mode 100644 index 0000000..9b80a2c --- /dev/null +++ b/src/utils/sql.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { + buildCreateEnumTypeSql, + buildStaticWhereClause, + buildReturningClause, + buildWhereClause, + getReadOnlySqlValidationError, + hasSqlStatementSeparator, + redactSqlText, + quoteLiteral, + quoteIdent, + quoteQualifiedIdent +} from './sql'; + +describe('SQL safety helpers', () => { + it('quotes strict PostgreSQL identifiers', () => { + expect(quoteIdent('users')).toBe('"users"'); + expect(quoteIdent('_internal_1')).toBe('"_internal_1"'); + expect(quoteQualifiedIdent('users')).toBe('"users"'); + expect(quoteQualifiedIdent('users', 'audit')).toBe('"audit"."users"'); + }); + + it('rejects unsafe identifiers instead of escaping arbitrary input', () => { + expect(() => quoteIdent('users; drop table users')).toThrow('Invalid SQL identifier'); + expect(() => quoteIdent('user"name')).toThrow('Invalid SQL identifier'); + expect(() => quoteIdent('123users')).toThrow('Invalid SQL identifier'); + }); + + it('builds parameterized where clauses from structured predicates', () => { + const result = buildWhereClause({ + id: 123, + status: { in: ['active', 'pending'] }, + deleted_at: { isNull: true }, + score: { gte: 10 } + }, 2); + + expect(result.clause).toBe('"id" = $2 AND "status" IN ($3, $4) AND "deleted_at" IS NULL AND "score" >= $5'); + expect(result.values).toEqual([123, 'active', 'pending', 10]); + }); + + it('uses IS NULL semantics for null equality comparisons', () => { + expect(buildWhereClause({ deleted_at: null }).clause).toBe('"deleted_at" IS NULL'); + expect(buildWhereClause({ deleted_at: { ne: null } }).clause).toBe('"deleted_at" IS NOT NULL'); + }); + + it('rejects ambiguous and empty predicates', () => { + expect(() => buildWhereClause({})).toThrow('at least one column'); + expect(() => buildWhereClause({ status: { in: [] } })).toThrow('at least one value'); + expect(() => buildWhereClause({ status: { eq: 'a', ne: 'b' } as never })).toThrow('exactly one operator'); + }); + + it('builds safe returning clauses from star, strings, or arrays', () => { + expect(buildReturningClause('*')).toBe(' RETURNING *'); + expect(buildReturningClause('id, email')).toBe(' RETURNING "id", "email"'); + expect(buildReturningClause(['id', 'email'])).toBe(' RETURNING "id", "email"'); + }); + + it('rejects expressions in returning clauses', () => { + expect(() => buildReturningClause('id, now()')).toThrow('Invalid SQL identifier'); + }); + + it('quotes SQL literals for static DDL predicates', () => { + expect(quoteLiteral("O'Reilly")).toBe("'O''Reilly'"); + expect(quoteLiteral(true)).toBe('TRUE'); + expect(quoteLiteral(42)).toBe('42'); + expect(quoteLiteral(null)).toBe('NULL'); + }); + + it('builds PostgreSQL-valid enum DDL', () => { + expect(buildCreateEnumTypeSql('status', ['active', "owner's"], 'app')).toBe( + 'CREATE TYPE "app"."status" AS ENUM (\'active\', \'owner\'\'s\');' + ); + }); + + it('builds idempotent enum DDL without invalid IF NOT EXISTS syntax', () => { + const sql = buildCreateEnumTypeSql('status', ['active'], 'app', true); + + expect(sql).toContain('DO $postgres_mcp_enum$'); + expect(sql).toContain('CREATE TYPE "app"."status" AS ENUM (\'active\');'); + expect(sql).toContain('WHEN duplicate_object THEN NULL;'); + expect(sql).not.toContain('CREATE TYPE IF NOT EXISTS'); + }); + + it('uses a safe dollar quote delimiter for enum DDL blocks', () => { + const sql = buildCreateEnumTypeSql('status', ['$postgres_mcp_enum$'], 'app', true); + + expect(sql).toContain('DO $postgres_mcp_enum_1$'); + expect(sql).toContain("'$postgres_mcp_enum$'"); + }); + + it('builds static where clauses for DDL contexts', () => { + expect(buildStaticWhereClause({ + active: true, + status: { in: ['draft', "owner's"] }, + deleted_at: { isNull: true } + })).toBe('"active" = TRUE AND "status" IN (\'draft\', \'owner\'\'s\') AND "deleted_at" IS NULL'); + }); + + it('redacts SQL literals and comments while preserving query shape', () => { + const redacted = redactSqlText( + "SELECT * FROM users WHERE email = 'admin@example.com' AND code = 123 -- token=secret\nAND body = $$private$$" + ); + + expect(redacted).toBe("SELECT * FROM users WHERE email = '?' AND code = ? -- redacted comment\nAND body = $$?$$"); + }); + + it('truncates redacted SQL text to the requested length', () => { + expect(redactSqlText("SELECT 'secret', repeat('x', 100)", 18)).toBe("SELECT '?', rep..."); + }); + + it('rejects data-changing SQL hidden inside CTEs', () => { + expect(getReadOnlySqlValidationError( + 'WITH deleted AS (DELETE FROM users RETURNING *) SELECT * FROM deleted' + )).toContain('DELETE'); + expect(getReadOnlySqlValidationError( + 'WITH updated AS (UPDATE users SET admin = true RETURNING *) SELECT * FROM updated' + )).toContain('UPDATE'); + }); + + it('ignores blocked keywords inside literals, comments, and quoted identifiers', () => { + expect(getReadOnlySqlValidationError( + 'SELECT "update", \'delete\', $$insert$$ -- drop\nFROM audit_log' + )).toBeUndefined(); + }); + + it('ignores semicolons inside literals and comments when validating one statement', () => { + expect(getReadOnlySqlValidationError( + "SELECT '; still one statement' AS text_value -- comment ; ignored" + )).toBeUndefined(); + expect(getReadOnlySqlValidationError( + 'SELECT $$; also one statement$$ AS text_value /* comment ; ignored */' + )).toBeUndefined(); + }); + + it('detects real statement separators outside masked SQL regions', () => { + expect(hasSqlStatementSeparator("SELECT '; safe literal';")).toBe(false); + expect(hasSqlStatementSeparator('SELECT $$; safe body$$;')).toBe(false); + expect(hasSqlStatementSeparator('SELECT 1 -- comment ; ignored')).toBe(false); + expect(hasSqlStatementSeparator("SELECT '; safe literal'; SELECT 2")).toBe(true); + }); + + it('still rejects semicolon statement separators outside masked SQL regions', () => { + expect(getReadOnlySqlValidationError( + "SELECT '; safe literal'; SELECT 2" + )).toContain('without semicolons'); + }); +}); diff --git a/src/utils/sql.ts b/src/utils/sql.ts new file mode 100644 index 0000000..cc46ad7 --- /dev/null +++ b/src/utils/sql.ts @@ -0,0 +1,480 @@ +const SIMPLE_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const DOLLAR_QUOTE_PATTERN = /^\$[A-Za-z_][A-Za-z0-9_]*\$/; +const READ_ONLY_START_PATTERN = /^(select|with|values|table)\b/i; +const DATA_CHANGING_SQL_PATTERN = /\b(insert|update|delete|merge|call|create|alter|drop|truncate|grant|revoke|vacuum|copy)\b/i; + +export type SqlScalar = string | number | boolean | null; + +export type WhereOperator = + | { eq: SqlScalar } + | { ne: SqlScalar } + | { gt: SqlScalar } + | { gte: SqlScalar } + | { lt: SqlScalar } + | { lte: SqlScalar } + | { like: string } + | { ilike: string } + | { in: SqlScalar[] } + | { isNull: boolean }; + +export type WherePredicate = Record; + +interface WhereClauseResult { + clause: string; + values: unknown[]; +} + +export function quoteIdent(identifier: string): string { + if (!SIMPLE_IDENTIFIER_PATTERN.test(identifier)) { + throw new Error(`Invalid SQL identifier "${identifier}". Use simple unquoted PostgreSQL identifiers only.`); + } + + return `"${identifier}"`; +} + +export function quoteQualifiedIdent(identifier: string, schema = 'public'): string { + if (!schema || schema === 'public') { + return quoteIdent(identifier); + } + + return `${quoteIdent(schema)}.${quoteIdent(identifier)}`; +} + +function dollarQuoteSqlBlock(sql: string, baseTag: string): string { + let tag = baseTag; + let delimiter = `$${tag}$`; + let counter = 0; + + while (sql.includes(delimiter)) { + counter += 1; + tag = `${baseTag}_${counter}`; + delimiter = `$${tag}$`; + } + + return `${delimiter}\n${sql}\n${delimiter}`; +} + +export function quoteLiteral(value: SqlScalar): string { + if (value === null) { + return 'NULL'; + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error('Invalid SQL numeric literal.'); + } + return String(value); + } + + if (typeof value === 'boolean') { + return value ? 'TRUE' : 'FALSE'; + } + + return `'${value.replace(/'/g, "''")}'`; +} + +export function buildCreateEnumTypeSql( + enumName: string, + values: string[], + schema = 'public', + ifNotExists = false +): string { + if (values.length === 0) { + throw new Error('ENUM values must include at least one value.'); + } + + const fullEnumName = quoteQualifiedIdent(enumName, schema); + const valueLiterals = values.map(quoteLiteral).join(', '); + const createTypeSql = `CREATE TYPE ${fullEnumName} AS ENUM (${valueLiterals});`; + + if (!ifNotExists) { + return createTypeSql; + } + + return `DO ${dollarQuoteSqlBlock(`BEGIN\n ${createTypeSql}\nEXCEPTION\n WHEN duplicate_object THEN NULL;\nEND`, 'postgres_mcp_enum')};`; +} + +function isIdentifierCharacter(character: string | undefined): boolean { + return !!character && /[A-Za-z0-9_$]/.test(character); +} + +function truncateText(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, Math.max(0, maxLength - 3))}...`; +} + +export function redactSqlText(sql: string, maxLength = 500): string { + let redacted = ''; + let index = 0; + + while (index < sql.length) { + const character = sql[index]; + const next = sql[index + 1]; + + if (character === '-' && next === '-') { + const newlineIndex = sql.indexOf('\n', index + 2); + redacted += '-- redacted comment'; + if (newlineIndex === -1) { + break; + } + redacted += '\n'; + index = newlineIndex + 1; + continue; + } + + if (character === '/' && next === '*') { + const commentEnd = sql.indexOf('*/', index + 2); + redacted += '/* redacted comment */'; + index = commentEnd === -1 ? sql.length : commentEnd + 2; + continue; + } + + if (character === "'") { + redacted += "'?'"; + index++; + while (index < sql.length) { + if (sql[index] === "'" && sql[index + 1] === "'") { + index += 2; + continue; + } + if (sql[index] === "'") { + index++; + break; + } + index++; + } + continue; + } + + if (character === '$') { + const dollarQuote = sql.slice(index).match(DOLLAR_QUOTE_PATTERN); + const delimiter = dollarQuote?.[0] || (next === '$' ? '$$' : undefined); + if (delimiter) { + const bodyStart = index + delimiter.length; + const bodyEnd = sql.indexOf(delimiter, bodyStart); + redacted += `${delimiter}?${delimiter}`; + index = bodyEnd === -1 ? sql.length : bodyEnd + delimiter.length; + continue; + } + } + + if ( + /[0-9]/.test(character) && + !isIdentifierCharacter(sql[index - 1]) + ) { + let numberEnd = index + 1; + while (numberEnd < sql.length && /[0-9._eE+-]/.test(sql[numberEnd])) { + numberEnd++; + } + + if (!isIdentifierCharacter(sql[numberEnd])) { + redacted += '?'; + index = numberEnd; + continue; + } + } + + redacted += character; + index++; + } + + return truncateText(redacted, maxLength); +} + +function maskSqlForKeywordScan(sql: string): string { + let masked = ''; + let index = 0; + + while (index < sql.length) { + const character = sql[index]; + const next = sql[index + 1]; + + if (character === '-' && next === '-') { + const newlineIndex = sql.indexOf('\n', index + 2); + if (newlineIndex === -1) { + break; + } + masked += '\n'; + index = newlineIndex + 1; + continue; + } + + if (character === '/' && next === '*') { + const commentEnd = sql.indexOf('*/', index + 2); + index = commentEnd === -1 ? sql.length : commentEnd + 2; + masked += ' '; + continue; + } + + if (character === "'" || character === '"') { + const quote = character; + masked += ' '; + index++; + while (index < sql.length) { + if (sql[index] === quote && sql[index + 1] === quote) { + index += 2; + continue; + } + if (sql[index] === quote) { + index++; + break; + } + index++; + } + continue; + } + + if (character === '$') { + const dollarQuote = sql.slice(index).match(DOLLAR_QUOTE_PATTERN); + const delimiter = dollarQuote?.[0] || (next === '$' ? '$$' : undefined); + if (delimiter) { + const bodyStart = index + delimiter.length; + const bodyEnd = sql.indexOf(delimiter, bodyStart); + masked += ' '; + index = bodyEnd === -1 ? sql.length : bodyEnd + delimiter.length; + continue; + } + } + + masked += character; + index++; + } + + return masked; +} + +export function hasSqlStatementSeparator(sql: string): boolean { + const normalizedQuery = sql.trim(); + const withoutTrailingSemicolon = normalizedQuery.replace(/;\s*$/, ''); + return maskSqlForKeywordScan(withoutTrailingSemicolon).includes(';'); +} + +export function getReadOnlySqlValidationError(sql: string): string | undefined { + const normalizedQuery = sql.trim(); + if (!normalizedQuery) { + return 'query parameter is required'; + } + + const withoutTrailingSemicolon = normalizedQuery.replace(/;\s*$/, ''); + const keywordScanSql = maskSqlForKeywordScan(withoutTrailingSemicolon); + if (hasSqlStatementSeparator(normalizedQuery)) { + return 'query must contain exactly one statement without semicolons'; + } + + if (!READ_ONLY_START_PATTERN.test(withoutTrailingSemicolon)) { + return 'query is limited to read-only SELECT, WITH, VALUES, or TABLE statements'; + } + + const dataChangingKeyword = keywordScanSql.match(DATA_CHANGING_SQL_PATTERN)?.[1]; + if (dataChangingKeyword) { + return `query contains data-changing SQL keyword "${dataChangingKeyword.toUpperCase()}"`; + } + + return undefined; +} + +export function normalizeReturningColumns(returning?: string | string[]): string[] | undefined { + if (!returning) { + return undefined; + } + + if (Array.isArray(returning)) { + return returning; + } + + if (returning.trim() === '*') { + return ['*']; + } + + return returning.split(',').map((column) => column.trim()).filter(Boolean); +} + +export function buildReturningClause(returning?: string | string[]): string { + const columns = normalizeReturningColumns(returning); + if (!columns || columns.length === 0) { + return ''; + } + + if (columns.length === 1 && columns[0] === '*') { + return ' RETURNING *'; + } + + return ` RETURNING ${columns.map(quoteIdent).join(', ')}`; +} + +function isOperatorPredicate(value: SqlScalar | WhereOperator): value is WhereOperator { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function appendComparison( + clauses: string[], + values: unknown[], + column: string, + operator: string, + value: SqlScalar, + nextPlaceholder: () => string +): void { + if (value === null && operator === '=') { + clauses.push(`${quoteIdent(column)} IS NULL`); + return; + } + + if (value === null && operator === '<>') { + clauses.push(`${quoteIdent(column)} IS NOT NULL`); + return; + } + + values.push(value); + clauses.push(`${quoteIdent(column)} ${operator} ${nextPlaceholder()}`); +} + +export function buildWhereClause(where: WherePredicate, startingPlaceholder = 1): WhereClauseResult { + const clauses: string[] = []; + const values: unknown[] = []; + let placeholderIndex = startingPlaceholder; + const nextPlaceholder = () => `$${placeholderIndex++}`; + + for (const [column, predicate] of Object.entries(where)) { + if (!isOperatorPredicate(predicate)) { + appendComparison(clauses, values, column, '=', predicate, nextPlaceholder); + continue; + } + + const entries = Object.entries(predicate); + if (entries.length !== 1) { + throw new Error(`Where predicate for "${column}" must specify exactly one operator.`); + } + + const [operator, value] = entries[0]; + + switch (operator) { + case 'eq': + appendComparison(clauses, values, column, '=', value as SqlScalar, nextPlaceholder); + break; + case 'ne': + appendComparison(clauses, values, column, '<>', value as SqlScalar, nextPlaceholder); + break; + case 'gt': + appendComparison(clauses, values, column, '>', value as SqlScalar, nextPlaceholder); + break; + case 'gte': + appendComparison(clauses, values, column, '>=', value as SqlScalar, nextPlaceholder); + break; + case 'lt': + appendComparison(clauses, values, column, '<', value as SqlScalar, nextPlaceholder); + break; + case 'lte': + appendComparison(clauses, values, column, '<=', value as SqlScalar, nextPlaceholder); + break; + case 'like': + appendComparison(clauses, values, column, 'LIKE', value as string, nextPlaceholder); + break; + case 'ilike': + appendComparison(clauses, values, column, 'ILIKE', value as string, nextPlaceholder); + break; + case 'in': { + const items = value as SqlScalar[]; + if (!Array.isArray(items) || items.length === 0) { + throw new Error(`Where predicate "in" for "${column}" must contain at least one value.`); + } + const placeholders = items.map(() => nextPlaceholder()); + values.push(...items); + clauses.push(`${quoteIdent(column)} IN (${placeholders.join(', ')})`); + break; + } + case 'isNull': + clauses.push(`${quoteIdent(column)} IS ${value ? '' : 'NOT '}NULL`); + break; + default: + throw new Error(`Unsupported where operator "${operator}" for "${column}".`); + } + } + + if (clauses.length === 0) { + throw new Error('Where predicate must include at least one column.'); + } + + return { + clause: clauses.join(' AND '), + values + }; +} + +function buildStaticComparison(column: string, operator: string, value: SqlScalar): string { + if (value === null && operator === '=') { + return `${quoteIdent(column)} IS NULL`; + } + + if (value === null && operator === '<>') { + return `${quoteIdent(column)} IS NOT NULL`; + } + + return `${quoteIdent(column)} ${operator} ${quoteLiteral(value)}`; +} + +export function buildStaticWhereClause(where: WherePredicate): string { + const clauses: string[] = []; + + for (const [column, predicate] of Object.entries(where)) { + if (!isOperatorPredicate(predicate)) { + clauses.push(buildStaticComparison(column, '=', predicate)); + continue; + } + + const entries = Object.entries(predicate); + if (entries.length !== 1) { + throw new Error(`Where predicate for "${column}" must specify exactly one operator.`); + } + + const [operator, value] = entries[0]; + + switch (operator) { + case 'eq': + clauses.push(buildStaticComparison(column, '=', value as SqlScalar)); + break; + case 'ne': + clauses.push(buildStaticComparison(column, '<>', value as SqlScalar)); + break; + case 'gt': + clauses.push(buildStaticComparison(column, '>', value as SqlScalar)); + break; + case 'gte': + clauses.push(buildStaticComparison(column, '>=', value as SqlScalar)); + break; + case 'lt': + clauses.push(buildStaticComparison(column, '<', value as SqlScalar)); + break; + case 'lte': + clauses.push(buildStaticComparison(column, '<=', value as SqlScalar)); + break; + case 'like': + clauses.push(buildStaticComparison(column, 'LIKE', value as string)); + break; + case 'ilike': + clauses.push(buildStaticComparison(column, 'ILIKE', value as string)); + break; + case 'in': { + const items = value as SqlScalar[]; + if (!Array.isArray(items) || items.length === 0) { + throw new Error(`Where predicate "in" for "${column}" must contain at least one value.`); + } + clauses.push(`${quoteIdent(column)} IN (${items.map(quoteLiteral).join(', ')})`); + break; + } + case 'isNull': + clauses.push(`${quoteIdent(column)} IS ${value ? '' : 'NOT '}NULL`); + break; + default: + throw new Error(`Unsupported where operator "${operator}" for "${column}".`); + } + } + + if (clauses.length === 0) { + throw new Error('Where predicate must include at least one column.'); + } + + return clauses.join(' AND '); +}