diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 50e8d426..dd0111d1 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -62,12 +62,12 @@ jobs: pull-requests: write steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@08a903b1fb2e493a84a57577778fe5dd711f9468 # v0.58.3 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Close expired discussions - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -76,7 +76,7 @@ jobs: await main(); - name: Close expired issues - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -85,7 +85,7 @@ jobs: await main(); - name: Close expired pull requests - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -107,12 +107,12 @@ jobs: persist-credentials: false - name: Setup Scripts - uses: github/gh-aw/actions/setup@08a903b1fb2e493a84a57577778fe5dd711f9468 # v0.58.3 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Check admin/maintainer permissions - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -122,12 +122,12 @@ jobs: await main(); - name: Install gh-aw - uses: github/gh-aw/actions/setup-cli@08a903b1fb2e493a84a57577778fe5dd711f9468 # v0.58.3 + uses: github/gh-aw/actions/setup-cli@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: version: v0.57.2 - name: Run operation - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_AW_OPERATION: ${{ github.event.inputs.operation }} diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 212d2caa..3ac368b2 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -30,7 +30,7 @@ jobs: - name: Comment on major version updates if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-major' }} - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6bae36fd..f508d621 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -47,7 +47,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build image for scanning (local only) - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile @@ -193,7 +193,7 @@ jobs: - name: Build and push platform image id: build - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile @@ -213,7 +213,7 @@ jobs: touch "/tmp/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} path: /tmp/digests/* @@ -233,7 +233,7 @@ jobs: deployments: write environment: - name: ${{ github.ref == 'refs/heads/main' && 'production' || '' }} + name: ${{ startsWith(github.ref, 'refs/tags/v') && 'production' || '' }} url: https://hub.docker.com/r/writenotenow/postgres-mcp steps: @@ -274,8 +274,8 @@ jobs: flavor: | latest=false tags: | - type=raw,value=v${{ steps.version.outputs.version }},enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=v${{ steps.version.outputs.version }},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=sha,prefix=sha-,format=short - name: Create and push manifest @@ -290,7 +290,7 @@ jobs: # Update Docker Hub description - name: Update Docker Hub Description - if: github.ref == 'refs/heads/main' + if: startsWith(github.ref, 'refs/tags/v') uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5 timeout-minutes: 5 with: @@ -298,10 +298,10 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} repository: ${{ env.IMAGE_NAME }} readme-filepath: ./DOCKER_README.md - short-description: "PostgreSQL MCP: 248 Tools in 1 Code Mode, Audit+Token Log, Tool Filtering, Pooling, HTTP/SSE, OAuth" + short-description: "PostgreSQL MCP: 278 Tools in 1 Code Mode, Audit+Token Log, Tool Filtering, Pooling, HTTP/SSE, OAuth" - name: Deployment Summary - if: github.ref == 'refs/heads/main' + if: startsWith(github.ref, 'refs/tags/v') run: | echo "βœ… Successfully published Docker images to production" echo "🐳 Registry: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" @@ -315,7 +315,7 @@ jobs: # - If Lint/Test fails β†’ nothing publishes npm-publish: needs: [merge-and-push] - if: always() && needs.merge-and-push.result == 'success' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + if: always() && needs.merge-and-push.result == 'success' && startsWith(github.ref, 'refs/tags/v') uses: ./.github/workflows/publish-npm.yml secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/docs-drift-detector.lock.yml b/.github/workflows/docs-drift-detector.lock.yml index 95ee3206..4222a72f 100644 --- a/.github/workflows/docs-drift-detector.lock.yml +++ b/.github/workflows/docs-drift-detector.lock.yml @@ -62,7 +62,7 @@ jobs: title: ${{ steps.sanitized.outputs.title }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Generate agentic run info @@ -84,7 +84,7 @@ jobs: GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { main } = require('/opt/gh-aw/actions/generate_aw_info.cjs'); @@ -104,7 +104,7 @@ jobs: sparse-checkout-cone-mode: true fetch-depth: 1 - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_WORKFLOW_FILE: "docs-drift-detector.lock.yml" with: @@ -115,7 +115,7 @@ jobs: await main(); - name: Compute current body text id: sanitized - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -185,7 +185,7 @@ jobs: GH_AW_PROMPT_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt with: @@ -195,7 +195,7 @@ jobs: const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); await main(); - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_GITHUB_ACTOR: ${{ github.actor }} @@ -239,7 +239,7 @@ jobs: run: bash /opt/gh-aw/actions/print_prompt_summary.sh - name: Upload activation artifact if: success() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: activation path: | @@ -272,7 +272,7 @@ jobs: output_types: ${{ steps.collect_output.outputs.output_types }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -297,7 +297,7 @@ jobs: id: checkout-pr if: | (github.event.pull_request) || (github.event.issue.pull_request) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: @@ -313,7 +313,7 @@ jobs: run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} @@ -722,7 +722,7 @@ jobs: bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - name: Redact secrets in logs if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -740,7 +740,7 @@ jobs: run: bash /opt/gh-aw/actions/append_agent_step_summary.sh - name: Upload Safe Outputs if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: safe-output path: ${{ env.GH_AW_SAFE_OUTPUTS }} @@ -748,7 +748,7 @@ jobs: - name: Ingest agent output id: collect_output if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" @@ -762,13 +762,13 @@ jobs: await main(); - name: Upload sanitized agent output if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: agent-output path: ${{ env.GH_AW_AGENT_OUTPUT }} if-no-files-found: warn - name: Upload engine output files - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: agent_outputs path: | @@ -777,7 +777,7 @@ jobs: if-no-files-found: ignore - name: Parse agent logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ with: @@ -788,7 +788,7 @@ jobs: await main(); - name: Parse MCP Gateway logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -813,7 +813,7 @@ jobs: - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: agent-artifacts path: | @@ -857,7 +857,7 @@ jobs: ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: WORKFLOW_NAME: "Documentation Drift Detector" WORKFLOW_DESCRIPTION: "Audit README and DOCKER_README for consistency and accuracy on every code PR" @@ -913,7 +913,7 @@ jobs: - name: Parse threat detection results id: parse_detection_results if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -922,7 +922,7 @@ jobs: await main(); - name: Upload threat detection log if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: threat-detection.log path: /tmp/gh-aw/threat-detection/detection.log @@ -968,7 +968,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -986,7 +986,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - name: Process No-Op Messages id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_NOOP_MAX: "1" @@ -1000,7 +1000,7 @@ jobs: await main(); - name: Record Missing Tool id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Documentation Drift Detector" @@ -1013,7 +1013,7 @@ jobs: await main(); - name: Handle Agent Failure id: handle_agent_failure - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Documentation Drift Detector" @@ -1035,7 +1035,7 @@ jobs: await main(); - name: Handle No-Op Message id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Documentation Drift Detector" @@ -1059,12 +1059,12 @@ jobs: matched_command: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Check team membership for workflow id: check_membership - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_REQUIRED_ROLES: admin,maintainer,write with: @@ -1101,7 +1101,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -1119,7 +1119,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - name: Process Safe Outputs id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" @@ -1135,7 +1135,7 @@ jobs: await main(); - name: Upload safe output items manifest if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: safe-output-items path: /tmp/safe-output-items.jsonl diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c1fba5fe..8cdc034b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -42,7 +42,7 @@ jobs: - name: Run E2E tests run: npm run test:e2e - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1.0.0 if: ${{ !cancelled() }} with: name: playwright-report diff --git a/.github/workflows/gatekeeper.yml b/.github/workflows/gatekeeper.yml index b615111f..b1a60953 100644 --- a/.github/workflows/gatekeeper.yml +++ b/.github/workflows/gatekeeper.yml @@ -32,7 +32,7 @@ jobs: publish: needs: [lint, codeql, secrets, trivy] # Publish only after ALL security gates pass (fan-out: runs in parallel, gates publish) - if: always() && needs.lint.result == 'success' && needs.codeql.result == 'success' && needs.secrets.result == 'success' && needs.trivy.result == 'success' + if: always() && needs.lint.result == 'success' && needs.codeql.result == 'success' && needs.secrets.result == 'success' && needs.trivy.result == 'success' && startsWith(github.ref, 'refs/tags/v') uses: ./.github/workflows/docker-publish.yml secrets: diff --git a/.github/workflows/secrets-scanning.yml b/.github/workflows/secrets-scanning.yml index 9c45e18a..b27dd7dd 100644 --- a/.github/workflows/secrets-scanning.yml +++ b/.github/workflows/secrets-scanning.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - name: TruffleHog Secret Scanning - uses: trufflesecurity/trufflehog@6c05c4a00b91aa542267d8e32a8254774799d68d # v3.93.8 + uses: trufflesecurity/trufflehog@47e7b7cd74f578e1e3145d48f669f22fd1330ca6 # v3.94.3 with: path: ./ base: ${{ github.event.before || 'HEAD~1' }} diff --git a/.github/workflows/security-update.yml b/.github/workflows/security-update.yml index dfc5bdad..13f9ee86 100644 --- a/.github/workflows/security-update.yml +++ b/.github/workflows/security-update.yml @@ -33,7 +33,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Build image for scanning - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile @@ -75,7 +75,7 @@ jobs: - name: Create security issue if vulnerabilities found if: failure() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const title = '🚨 Security vulnerabilities detected in Docker images' diff --git a/.github/workflows/wiki-drift-detector.lock.yml b/.github/workflows/wiki-drift-detector.lock.yml index 253b9865..90adf135 100644 --- a/.github/workflows/wiki-drift-detector.lock.yml +++ b/.github/workflows/wiki-drift-detector.lock.yml @@ -62,7 +62,7 @@ jobs: title: ${{ steps.sanitized.outputs.title }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Generate agentic run info @@ -84,7 +84,7 @@ jobs: GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { main } = require('/opt/gh-aw/actions/generate_aw_info.cjs'); @@ -104,7 +104,7 @@ jobs: sparse-checkout-cone-mode: true fetch-depth: 1 - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_WORKFLOW_FILE: "wiki-drift-detector.lock.yml" with: @@ -115,7 +115,7 @@ jobs: await main(); - name: Compute current body text id: sanitized - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -185,7 +185,7 @@ jobs: GH_AW_PROMPT_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt with: @@ -195,7 +195,7 @@ jobs: const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); await main(); - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_GITHUB_ACTOR: ${{ github.actor }} @@ -239,7 +239,7 @@ jobs: run: bash /opt/gh-aw/actions/print_prompt_summary.sh - name: Upload activation artifact if: success() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: activation path: | @@ -272,7 +272,7 @@ jobs: output_types: ${{ steps.collect_output.outputs.output_types }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -297,7 +297,7 @@ jobs: id: checkout-pr if: | (github.event.pull_request) || (github.event.issue.pull_request) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: @@ -313,7 +313,7 @@ jobs: run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} @@ -722,7 +722,7 @@ jobs: bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - name: Redact secrets in logs if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -740,7 +740,7 @@ jobs: run: bash /opt/gh-aw/actions/append_agent_step_summary.sh - name: Upload Safe Outputs if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: safe-output path: ${{ env.GH_AW_SAFE_OUTPUTS }} @@ -748,7 +748,7 @@ jobs: - name: Ingest agent output id: collect_output if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" @@ -762,13 +762,13 @@ jobs: await main(); - name: Upload sanitized agent output if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: agent-output path: ${{ env.GH_AW_AGENT_OUTPUT }} if-no-files-found: warn - name: Upload engine output files - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: agent_outputs path: | @@ -777,7 +777,7 @@ jobs: if-no-files-found: ignore - name: Parse agent logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ with: @@ -788,7 +788,7 @@ jobs: await main(); - name: Parse MCP Gateway logs for step summary if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -813,7 +813,7 @@ jobs: - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: agent-artifacts path: | @@ -857,7 +857,7 @@ jobs: ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: WORKFLOW_NAME: "Wiki Documentation Drift Detector" WORKFLOW_DESCRIPTION: "Audit the GitHub Wiki documentation for accuracy and consistency on every code PR" @@ -913,7 +913,7 @@ jobs: - name: Parse threat detection results id: parse_detection_results if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -922,7 +922,7 @@ jobs: await main(); - name: Upload threat detection log if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: threat-detection.log path: /tmp/gh-aw/threat-detection/detection.log @@ -968,7 +968,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -986,7 +986,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - name: Process No-Op Messages id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_NOOP_MAX: "1" @@ -1000,7 +1000,7 @@ jobs: await main(); - name: Record Missing Tool id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Wiki Documentation Drift Detector" @@ -1013,7 +1013,7 @@ jobs: await main(); - name: Handle Agent Failure id: handle_agent_failure - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Wiki Documentation Drift Detector" @@ -1035,7 +1035,7 @@ jobs: await main(); - name: Handle No-Op Message id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Wiki Documentation Drift Detector" @@ -1059,12 +1059,12 @@ jobs: matched_command: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Check team membership for workflow id: check_membership - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_REQUIRED_ROLES: admin,maintainer,write with: @@ -1101,7 +1101,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + uses: github/gh-aw/actions/setup@5a06d310cf45161bde77d070065a1e1489fc411c # v0.68.1 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -1119,7 +1119,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - name: Process Safe Outputs id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" @@ -1135,7 +1135,7 @@ jobs: await main(); - name: Upload safe output items manifest if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: safe-output-items path: /tmp/safe-output-items.jsonl diff --git a/.gitignore b/.gitignore index 11c859d6..de00d474 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ config/secrets/ mcp-audit.jsonl *.txt task.md + +# Playwright +playwright-results.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5caa14..d834ffb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,52 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/neverinfamous/postgres-mcp/compare/v3.0.7...HEAD) +## [Unreleased](https://github.com/neverinfamous/postgres-mcp/compare/v3.1.0...HEAD) See [UNRELEASED.md](UNRELEASED.md) for all pending changes. +## [3.1.0](https://github.com/neverinfamous/postgres-mcp/releases/tag/v3.1.0) - 2026-05-14 + +### Added + +- **CI/CD Utilities**: Automated coverage badge updates in `README.md` and `DOCKER_README.md` upon test suite execution. +- **Connection Pool**: Added `initializationSql` config to safely execute session setup queries on connection checkout. +- **Security Tools**: Introduced 9 new tools for auditing, SSL/TLS monitoring, data masking, and firewall management. +- **Roles Tools**: Introduced 12 new tools for comprehensive role CRUD, privilege, and row-level security management. +- **Document Store Tools**: Introduced 9 new tools for NoSQL-style JSONB document management, indexing, and filtering. + +### Changed + +- **Dependencies**: Updated `typescript` (6.0.3), `eslint` (10.3.0), `jose` (6.2.3), `zod` (4.4.3), `@playwright/test` (1.60.0), `@types/node` (25.8.0), `vitest` and `@vitest/coverage-v8` (4.1.6), and `typescript-eslint` (8.59.3). +- **Docker Dependencies**: Pinned transitive Dockerfile dependencies to address known CVEs: `diff` (9.0.0), `tar` (7.5.15), and `brace-expansion` (5.0.6). +- **GitHub Actions**: Updated CI workflows to the latest tagged versions with strict SHA pinning. +- **Payload Optimization**: Optimized default `limit` and truncation parameters across Performance, Core, Monitoring, Docstore, and Schema tools to prevent LLM token bloat. Increased max `limit` in Stats tools to 1000 for broader dataset analysis. +- **Introspection Tools**: Streamlined `pg_schema_snapshot` compact mode to default exclusively to tables, views, and indexes. +- **Schema Tools**: Added an `exclude` array parameter to `pg_list_views` to safely filter out large system/extension views. + +### Fixed + +- **Validation (Split Schema)**: Resolved Zod validation leaks across Monitoring, Partman, Kcache, Core, Performance, Stats, Docstore, Roles, Vector, and Text tool groups by migrating input schemas to `z.unknown().optional()`. This ensures type mismatches return structured handler errors instead of raw `-32602` MCP framework exceptions. +- **Validation (Object Existence)**: Enforced strict P154 object existence verification across Migration, Citext, Pgcrypto, Core, Ltree, Backup, and Performance tool groups to explicitly handle nonexistent schemas, tables, and views instead of failing silently. +- **Validation (Type Coercion)**: Replaced `coerceNumber` with `coerceStrictNumber` in Stats, Migration, and Monitoring tools to prevent invalid string inputs from silently bypassing validation and resolving to `NaN` or `undefined`. +- **Parameter Aliasing**: Fixed alias mapping bugs across Schema, Roles, Text, Admin, and Ltree tools, ensuring aliases like `tableName`, `maxvalue`, and `setting` resolve properly through Zod preprocessing. +- **Error Handling**: Standardized parsing for missing extensions (`ltree`, `pg_stat_kcache`, `pgcrypto`, `fuzzystrmatch`) and native Postgres sequence, dimension mismatch, and operator exceptions, translating them into structured, actionable errors. +- **Backup & Kcache Tools**: Fixed `hasDifferences` field compliance in `pg_audit_diff_backup`. Fixed BIGINT/NUMERIC precision by casting to `float8`. Ensured successful reads reliably return `success: true`. +- **Docstore Tools**: Fixed `$in`/`$nin` operators, native dot-notation parsing, and JSONB containment (`@>`) for nested object filters. Added missing structured error handling for nested queries. +- **Ltree & Transactions Tools**: Rejected negative `length` values and fixed validation bypass for malformed syntax in `pg_ltree_lca`. Truncated multi-statement query outputs to cap payload sizes. +- **Migration Tools**: Fixed cross-schema scoping in internal tracking tables to accurately support the optional `schema` parameter. +- **Partman Tools**: Fixed a schema-resolution bug in `helpers.ts` where the extension was hardcoded to `public`/`partman`. Tools now dynamically detect the installed namespace. +- **Pgcrypto Tools**: Fixed `gen_random_bytes` to natively support `raw` output. Restored full algorithm options visibility in base schemas. +- **PostGIS Tools**: Standardized payload key names and fixed missing point fallback logic in `pg_distance` and `pg_point_in_polygon`. +- **Roles Tools**: Fixed `validUntil` timestamp serialization, prevented malformed queries from empty privilege arrays, and corrected parameter mismatches in role creation tools. +- **Security Tools**: Fixed SQL syntax generation for empty patterns in `pg_security_sensitive_tables` and handled empty object payloads correctly. +- **Vector Tools**: Corrected inline schema definitions and verified full array serialization parity against native vector inputs. +- **Testing**: Fixed state bleed issue in `reset-database.ps1` where test extensions fell back to `topology` instead of `public`. Resolved fragile assertions in E2E codemode tests and PowerShell encoding bugs. + +### Security + +- **Dependencies**: Bumped `hono` to `4.12.18` (HTML Injection), `ip-address` to `10.2.0` (XSS), and `fast-uri` to `3.1.2` (Path Traversal) via package overrides. + ## [3.0.7](https://github.com/neverinfamous/postgres-mcp/releases/tag/v3.0.7) - 2026-04-08 ### Fixed diff --git a/DOCKER_README.md b/DOCKER_README.md index 22189b66..1c8c855e 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -2,9 +2,9 @@ **PostgreSQL MCP Server** binding the Model Context Protocol to a secure PostgreSQL sandbox. -Features **Code Mode** β€” a revolutionary approach that provides access to all 248 tools through a secure, true V8 isolate (`worker_threads`), eliminating the massive token overhead of multi-step tool calls. Also includes schema introspection, migration tracking, smart tool filtering, deterministic error handling, connection pooling, HTTP/SSE transport, OAuth 2.1 authentication, and support for citext, ltree, pgcrypto, pg_cron, pg_stat_kcache, pgvector, PostGIS, and HypoPG. +Features **Code Mode** β€” a revolutionary approach that provides access to all 278 tools through a secure, true V8 isolate (`worker_threads`), eliminating the massive token overhead of multi-step tool calls. Also includes schema introspection, migration tracking, smart tool filtering, deterministic error handling, connection pooling, HTTP/SSE transport, OAuth 2.1 authentication, and support for citext, ltree, pgcrypto, pg_cron, pg_stat_kcache, pgvector, PostGIS, and HypoPG. -**248 Specialized Tools** Β· **23 Resources** Β· **20 AI-Powered Prompts** +**278 Specialized Tools** Β· **24 Resources** Β· **21 AI-Powered Prompts** [![GitHub](https://img.shields.io/badge/GitHub-neverinfamous/postgres--mcp-blue?logo=github)](https://github.com/neverinfamous/postgres-mcp) ![GitHub Release](https://img.shields.io/github/v/release/neverinfamous/postgres-mcp) @@ -17,7 +17,7 @@ Features **Code Mode** β€” a revolutionary approach that provides access to all [![TypeScript](https://img.shields.io/badge/TypeScript-Strict-blue.svg)](https://github.com/neverinfamous/postgres-mcp) [![E2E](https://github.com/neverinfamous/postgres-mcp/actions/workflows/e2e.yml/badge.svg)](https://github.com/neverinfamous/postgres-mcp/actions/workflows/e2e.yml) [![Tests](https://img.shields.io/badge/Tests-3750_passed-success.svg)](https://github.com/neverinfamous/postgres-mcp) -[![Coverage](https://img.shields.io/badge/Coverage-96%25-brightgreen.svg)](https://github.com/neverinfamous/postgres-mcp) +[![Coverage](https://img.shields.io/badge/Coverage-85.29%25-green.svg)](https://github.com/neverinfamous/postgres-mcp) **[GitHub](https://github.com/neverinfamous/postgres-mcp)** β€’ **[npm Package](https://www.npmjs.com/package/@neverinfamous/postgres-mcp)** β€’ **[MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.neverinfamous/postgres-mcp)** β€’ **[Wiki](https://github.com/neverinfamous/postgres-mcp/wiki)** β€’ **[Tool Reference](https://github.com/neverinfamous/postgres-mcp/wiki/Tool-Reference)** β€’ **[Changelog](https://github.com/neverinfamous/postgres-mcp/blob/main/CHANGELOG.md)** @@ -27,13 +27,13 @@ Features **Code Mode** β€” a revolutionary approach that provides access to all | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Code Mode (V8 Isolate)** | **Massive Token Savings:** Execute complex, multi-step operations inside a secure, true V8 isolate (`worker_threads`). Stop burning tokens on back-and-forth tool calls and reduce your AI overhead by up to 90%. | | **Deterministic Error Handling** | No more cryptic database errors causing AI hallucinations. We intercept and translate raw SQL exceptions into clear, actionable advice so your agent knows exactly how to recover without guessing. | -| **248 Token-Optimized Tools** | The largest PostgreSQL toolset on the MCP registry. Every query uses zero-cost token estimation and smart dataset truncation, ensuring agents always see the big picture without blowing their context windows. | +| **278 Token-Optimized Tools** | The largest PostgreSQL toolset on the MCP registry. Every query uses zero-cost token estimation and smart dataset truncation, ensuring agents always see the big picture without blowing their context windows. | | **OAuth 2.1 + Granular Control** | Real enterprise security. Authenticate via OAuth 2.1 and control exactly who can read, write, or administer your database with precision scopes mapped down to the specific tool layer. | | **Audit Trails & Semantic Diffing** | Total accountability. Track exactly what your AI is doing with detailed JSON logs, automatically snapshot schemas before mutations, and confidently review semantic row-by-row diffs before restoring data. | -| **23 Resources & 20 Prompts** | Instant database meta-awareness. Agents automatically read real-time health, performance, and replication metrics, and can invoke built-in prompt workflows for query tuning and schema design. | +| **24 Resources & 21 Prompts** | Instant database meta-awareness. Agents automatically read real-time health, performance, and replication metrics, and can invoke built-in prompt workflows for query tuning and schema design. | | **Introspection & Migrations** | Prevent costly mistakes. Let your AI simulate the cascade impact of schema changes, safely order foreign-key updates, and track migration history automatically. | | **8 Extension Ecosystems** | Ready for advanced workloads. First-class API support for **pgvector** (AI search), **PostGIS** (geospatial), **pg_cron**, **pgcrypto**, and moreβ€”all strictly typed and validated out of the box. | -| **Smart Tool Filtering** | Give your agent exactly what it needs without overflowing IDE limits. Dynamically compile your server with any combination of our 22 distinct tool groups. | +| **Smart Tool Filtering** | Give your agent exactly what it needs without overflowing IDE limits. Dynamically compile your server with any combination of our 25 distinct tool groups. | | **Enterprise Infrastructure** | Built for production. Blazing fast (millions of ops/sec), protected against SQL injection, features high-performance connection pooling, and supports both Streamable HTTP and Legacy SSE protocols simultaneously. | ## Suggested Rule (Add to AGENTS.md, GEMINI.md, etc) @@ -63,7 +63,7 @@ Features **Code Mode** β€” a revolutionary approach that provides access to all > Extension tool counts include `create_extension` helpers but exclude Code Mode; the Tool Groups table below adds +1 per group for Code Mode. -### MCP Resources (23) +### MCP Resources (24) Real-time database meta-awareness - AI accesses these automatically: @@ -80,7 +80,7 @@ Real-time database meta-awareness - AI accesses these automatically: **[Full resources list β†’](https://github.com/neverinfamous/postgres-mcp#resources)** -### MCP Prompts (20) +### MCP Prompts (21) Guided workflows for complex operations: @@ -225,7 +225,7 @@ Add this to your MCP client config (e.g., `~/.cursor/mcp.json` for Cursor): > [!IMPORTANT] > All tool groups include **Code Mode** (`pg_execute_code`) by default. To exclude it, add `-codemode` to your filter: `--tool-filter cron,pgcrypto,-codemode` -> **⭐ Code Mode** (`--tool-filter codemode`) is the recommended configuration β€” it exposes `pg_execute_code`, a secure, true V8 isolate sandbox providing access to all 248 tools' worth of capability with up to 90% token savings. See [Tool Filtering](#%EF%B8%8F-tool-filtering) for alternatives. +> **πŸ’‘ Code Mode** (`--tool-filter codemode`) is the recommended configuration β€” it exposes `pg_execute_code`, a secure, true V8 isolate sandbox providing access to all 278 tools' worth of capability with up to 90% token savings. See [Tool Filtering](#%EF%B8%8F-tool-filtering) for alternatives. - **Requires `admin` OAuth scope** β€” execution is logged for audit @@ -242,7 +242,7 @@ The `--tool-filter` argument accepts **groups** or **tool names** β€” mix and ma | Group + Tool | `core,+pg_stat_statements` | Extend a group | | Group - Tool | `core,-pg_drop_table` | Remove specific tools | -### Tool Groups (22 Available) +### Tool Groups (25 Available) | Group | Tools | Description | | --------------- | ----- | --------------------------------------------------------------------- | @@ -264,10 +264,13 @@ The `--tool-filter` argument accepts **groups** or **tool names** β€” mix and ma | `postgis` | 16 | PostGIS (geospatial) | | `cron` | 9 | pg_cron (job scheduling) | | `partman` | 11 | pg_partman (auto-partitioning) | -| `kcache` | 7 | pg_stat_kcache (OS-level stats) | +| `kcache` | 8 | pg_stat_kcache (OS-level stats) | | `citext` | 7 | citext (case-insensitive text) | | `ltree` | 9 | ltree (hierarchical data) | | `pgcrypto` | 10 | pgcrypto (encryption, UUIDs) | +| `security` | 10 | Security auditing, SSL, firewall, data masking, privilege analysis | +| `roles` | 13 | Role management, privileges, membership, RLS | +| `docstore` | 10 | JSONB document collections (NoSQL-style CRUD, indexing) | ### Syntax Reference diff --git a/Dockerfile b/Dockerfile index 53b703ec..1e4e947d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -85,6 +85,6 @@ ENTRYPOINT ["node", "dist/cli.js"] # Labels for Docker Hub LABEL maintainer="Adamic.tech" LABEL description="PostgreSQL MCP Server - AI-native PostgreSQL operations with 248 tools, 23 resources, 20 prompts" -LABEL version="3.0.7" +LABEL version="3.1.0" LABEL org.opencontainers.image.source="https://github.com/neverinfamous/postgres-mcp" LABEL io.modelcontextprotocol.server.name="io.github.neverinfamous/postgres-mcp" diff --git a/README.md b/README.md index e164dc2c..c96b34c2 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ **PostgreSQL MCP Server** binding the Model Context Protocol to a secure PostgreSQL sandbox. -Features **Code Mode** β€” a revolutionary approach that provides access to all 248 tools through a secure, true V8 isolate (`worker_threads`), eliminating the massive token overhead of multi-step tool calls. Also includes schema introspection, migration tracking, smart tool filtering, deterministic error handling, connection pooling, HTTP/SSE Transport, OAuth 2.1 authentication, and extension support for citext, ltree, pgcrypto, pg_cron, pg_stat_kcache, pgvector, PostGIS, and HypoPG. +Features **Code Mode** β€” a revolutionary approach that provides access to all 278 tools through a secure, true V8 isolate (`worker_threads`), eliminating the massive token overhead of multi-step tool calls. Also includes schema introspection, migration tracking, smart tool filtering, deterministic error handling, connection pooling, HTTP/SSE Transport, OAuth 2.1 authentication, and extension support for citext, ltree, pgcrypto, pg_cron, pg_stat_kcache, pgvector, PostGIS, and HypoPG. -**248 Specialized Tools** Β· **23 Resources** Β· **20 AI-Powered Prompts** +**278 Specialized Tools** Β· **24 Resources** Β· **21 AI-Powered Prompts** [![GitHub](https://img.shields.io/badge/GitHub-neverinfamous/postgres--mcp-blue?logo=github)](https://github.com/neverinfamous/postgres-mcp) ![GitHub Release](https://img.shields.io/github/v/release/neverinfamous/postgres-mcp) @@ -19,7 +19,7 @@ Features **Code Mode** β€” a revolutionary approach that provides access to all [![TypeScript](https://img.shields.io/badge/TypeScript-Strict-blue.svg)](https://github.com/neverinfamous/postgres-mcp) [![E2E](https://github.com/neverinfamous/postgres-mcp/actions/workflows/e2e.yml/badge.svg)](https://github.com/neverinfamous/postgres-mcp/actions/workflows/e2e.yml) [![Tests](https://img.shields.io/badge/Tests-3750_passed-success.svg)](https://github.com/neverinfamous/postgres-mcp) -[![Coverage](https://img.shields.io/badge/Coverage-96%25-brightgreen.svg)](https://github.com/neverinfamous/postgres-mcp) +[![Coverage](https://img.shields.io/badge/Coverage-85.29%25-green.svg)](https://github.com/neverinfamous/postgres-mcp) **[Docker Hub](https://hub.docker.com/r/writenotenow/postgres-mcp)** β€’ **[npm Package](https://www.npmjs.com/package/@neverinfamous/postgres-mcp)** β€’ **[MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.neverinfamous/postgres-mcp)** β€’ **[Wiki](https://github.com/neverinfamous/postgres-mcp/wiki)** β€’ **[Tool Reference](https://github.com/neverinfamous/postgres-mcp/wiki/Tool-Reference)** β€’ **[Changelog](https://github.com/neverinfamous/postgres-mcp/blob/main/CHANGELOG.md)** @@ -29,13 +29,13 @@ Features **Code Mode** β€” a revolutionary approach that provides access to all | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Code Mode (V8 Isolate)** | **Massive Token Savings:** Execute complex, multi-step operations inside a secure, true V8 isolate (`worker_threads`). Stop burning tokens on back-and-forth tool calls and reduce your AI overhead by up to 90%. | | **Deterministic Error Handling** | No more cryptic database errors causing AI hallucinations. We intercept and translate raw SQL exceptions into clear, actionable advice so your agent knows exactly how to recover without guessing. | -| **248 Token-Optimized Tools** | The largest PostgreSQL toolset on the MCP registry. Every query uses zero-cost token estimation and smart dataset truncation, ensuring agents always see the big picture without blowing their context windows. | +| **278 Token-Optimized Tools** | The largest PostgreSQL toolset on the MCP registry. Every query uses zero-cost token estimation and smart dataset truncation, ensuring agents always see the big picture without blowing their context windows. | | **OAuth 2.1 + Granular Control** | Real enterprise security. Authenticate via OAuth 2.1 and control exactly who can read, write, or administer your database with precision scopes mapped down to the specific tool layer. | | **Audit Trails & Semantic Diffing** | Total accountability. Track exactly what your AI is doing with detailed JSON logs, automatically snapshot schemas before mutations, and confidently review semantic row-by-row diffs before restoring data. | -| **23 Resources & 20 Prompts** | Instant database meta-awareness. Agents automatically read real-time health, performance, and replication metrics, and can invoke built-in prompt workflows for query tuning and schema design. | +| **24 Resources & 21 Prompts** | Instant database meta-awareness. Agents automatically read real-time health, performance, and replication metrics, and can invoke built-in prompt workflows for query tuning and schema design. | | **Introspection & Migrations** | Prevent costly mistakes. Let your AI simulate the cascade impact of schema changes, safely order foreign-key updates, and track migration history automatically. | | **8 Extension Ecosystems** | Ready for advanced workloads. First-class API support for **pgvector** (AI search), **PostGIS** (geospatial), **pg_cron**, **pgcrypto**, and moreβ€”all strictly typed and validated out of the box. | -| **Smart Tool Filtering** | Give your agent exactly what it needs without overflowing IDE limits. Dynamically compile your server with any combination of our 22 distinct tool groups. | +| **Smart Tool Filtering** | Give your agent exactly what it needs without overflowing IDE limits. Dynamically compile your server with any combination of our 25 distinct tool groups. | | **Enterprise Infrastructure** | Built for production. Blazing fast (millions of ops/sec), protected against SQL injection, features high-performance connection pooling, and supports both Streamable HTTP and Legacy SSE protocols simultaneously. | ## Suggested Rule (Add to AGENTS.md, GEMINI.md, etc) @@ -172,7 +172,7 @@ Run `npm run bench` to execute the performance benchmark suite (10 files, 93+ sc > [!IMPORTANT] > All tool groups include **Code Mode** (`pg_execute_code`) by default. To exclude it, add `-codemode` to your filter: `--tool-filter cron,pgcrypto,-codemode` -> **⭐ Code Mode** (`--tool-filter codemode`) is the recommended configuration β€” it exposes `pg_execute_code`, a secure, true V8 isolate sandbox providing access to all 248 tools' worth of capability with up to 90% token savings. See [Tool Filtering](#%EF%B8%8F-tool-filtering) for alternatives. +> **πŸ’‘ Code Mode** (`--tool-filter codemode`) is the recommended configuration β€” it exposes `pg_execute_code`, a secure, true V8 isolate sandbox providing access to all 278 tools' worth of capability with up to 90% token savings. See [Tool Filtering](#%EF%B8%8F-tool-filtering) for alternatives. - **Requires `admin` OAuth scope** β€” execution is logged for audit @@ -189,7 +189,7 @@ The `--tool-filter` argument accepts **groups** or **tool names** β€” mix and ma | Group + Tool | `core,+pg_stat_statements` | Extend a group | | Group - Tool | `core,-pg_drop_table` | Remove specific tools | -### Tool Groups (22 Available) +### Tool Groups (25 Available) | Group | Tools | Description | | --------------- | ----- | --------------------------------------------------------------------- | @@ -211,10 +211,13 @@ The `--tool-filter` argument accepts **groups** or **tool names** β€” mix and ma | `postgis` | 16 | PostGIS (geospatial) | | `cron` | 9 | pg_cron (job scheduling) | | `partman` | 11 | pg_partman (auto-partitioning) | -| `kcache` | 7 | pg_stat_kcache (OS-level stats) | +| `kcache` | 8 | pg_stat_kcache (OS-level stats) | | `citext` | 7 | citext (case-insensitive text) | | `ltree` | 9 | ltree (hierarchical data) | | `pgcrypto` | 10 | pgcrypto (encryption, UUIDs) | +| `security` | 10 | Security auditing, SSL, firewall, data masking, privilege analysis | +| `roles` | 13 | Role management, privileges, membership, RLS | +| `docstore` | 10 | JSONB document collections (NoSQL-style CRUD, indexing) | ### Syntax Reference @@ -431,7 +434,7 @@ The server exposes metadata at `/.well-known/oauth-protected-resource`. Prompts provide step-by-step guidance for complex database tasks. Instead of figuring out which tools to use and in what order, simply invoke a prompt and follow its workflow β€” great for learning PostgreSQL best practices or automating repetitive DBA tasks. -This server includes **20 intelligent prompts** for guided workflows: +This server includes **21 intelligent prompts** for guided workflows: | Prompt | Description | Required Groups | | -------------------------- | -------------------------------------------------- | ----------------------------- | @@ -455,12 +458,13 @@ This server includes **20 intelligent prompts** for guided workflows: | `pg_setup_ltree` | Complete ltree setup for hierarchical data | core, ltree | | `pg_setup_pgcrypto` | Complete pgcrypto setup for cryptographic funcs | core, pgcrypto | | `pg_safe_restore_workflow` | 6-step safe restore playbook with `restoreAs` | backup | +| `pg_setup_docstore` | Complete docstore setup for document collections | core, docstore | ## πŸ“¦ Resources Resources give you instant snapshots of database state without writing queries. Perfect for quickly checking schema, health, or performance metrics β€” the AI can read these to understand your database context before suggesting changes. -This server provides **23 resources** for structured data access: +This server provides **24 resources** for structured data access: | Resource | URI | Description | | ------------ | ------------------------- | -------------------------------------------------- | @@ -487,6 +491,7 @@ This server provides **23 resources** for structured data access: | Insights | `postgres://insights` | AI-appended business insights and observations | | Audit | `postgres://audit` | Audit trail with token summary and top tools | | Help | `postgres://help/{group}` | Group-specific help and workflow documentation | +| Docstore | `postgres://docstore` | JSONB document collection overview | ## πŸ”§ Extension Support diff --git a/package-lock.json b/package-lock.json index b7e91278..e354fa7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,35 +1,35 @@ { "name": "@neverinfamous/postgres-mcp", - "version": "3.0.7", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neverinfamous/postgres-mcp", - "version": "3.0.7", + "version": "3.1.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", "commander": "^14.0.3", - "jose": "^6.0.0", + "jose": "^6.2.3", "pg": "^8.20.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "postgres-mcp": "dist/cli.js" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.58.2", - "@types/node": "^25.5.2", + "@playwright/test": "^1.60.0", + "@types/node": "^25.8.0", "@types/pg": "^8.20.0", - "@vitest/coverage-v8": "^4.1.2", - "eslint": "^10.2.0", - "globals": "^17.4.0", - "typescript": "^6.0.2", - "typescript-eslint": "^8.57.2", + "@vitest/coverage-v8": "^4.1.6", + "eslint": "^10.3.0", + "globals": "^17.6.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", "unplugin-swc": "^1.5.9", - "vitest": "^4.1.2" + "vitest": "^4.1.6" }, "engines": { "node": ">=24.0.0" @@ -56,9 +56,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -258,9 +258,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -270,29 +270,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -431,9 +445,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", "dev": true, "license": "MIT", "funding": { @@ -441,13 +455,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.1" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -457,9 +471,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", "cpu": [ "arm64" ], @@ -474,9 +488,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", "cpu": [ "arm64" ], @@ -491,9 +505,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", "cpu": [ "x64" ], @@ -508,9 +522,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", "cpu": [ "x64" ], @@ -525,9 +539,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", "cpu": [ "arm" ], @@ -542,13 +556,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -559,13 +576,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -576,13 +596,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -593,13 +616,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -610,13 +636,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -627,13 +656,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -644,9 +676,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", "cpu": [ "arm64" ], @@ -661,9 +693,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", "cpu": [ "wasm32" ], @@ -680,9 +712,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", "cpu": [ "arm64" ], @@ -697,9 +729,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", "cpu": [ "x64" ], @@ -714,9 +746,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -758,9 +790,9 @@ "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz", - "integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz", + "integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -777,18 +809,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.24", - "@swc/core-darwin-x64": "1.15.24", - "@swc/core-linux-arm-gnueabihf": "1.15.24", - "@swc/core-linux-arm64-gnu": "1.15.24", - "@swc/core-linux-arm64-musl": "1.15.24", - "@swc/core-linux-ppc64-gnu": "1.15.24", - "@swc/core-linux-s390x-gnu": "1.15.24", - "@swc/core-linux-x64-gnu": "1.15.24", - "@swc/core-linux-x64-musl": "1.15.24", - "@swc/core-win32-arm64-msvc": "1.15.24", - "@swc/core-win32-ia32-msvc": "1.15.24", - "@swc/core-win32-x64-msvc": "1.15.24" + "@swc/core-darwin-arm64": "1.15.33", + "@swc/core-darwin-x64": "1.15.33", + "@swc/core-linux-arm-gnueabihf": "1.15.33", + "@swc/core-linux-arm64-gnu": "1.15.33", + "@swc/core-linux-arm64-musl": "1.15.33", + "@swc/core-linux-ppc64-gnu": "1.15.33", + "@swc/core-linux-s390x-gnu": "1.15.33", + "@swc/core-linux-x64-gnu": "1.15.33", + "@swc/core-linux-x64-musl": "1.15.33", + "@swc/core-win32-arm64-msvc": "1.15.33", + "@swc/core-win32-ia32-msvc": "1.15.33", + "@swc/core-win32-x64-msvc": "1.15.33" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -800,9 +832,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz", - "integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz", + "integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==", "cpu": [ "arm64" ], @@ -818,9 +850,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz", - "integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz", + "integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==", "cpu": [ "x64" ], @@ -836,9 +868,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz", - "integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz", + "integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==", "cpu": [ "arm" ], @@ -854,13 +886,16 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz", - "integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz", + "integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -872,13 +907,16 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz", - "integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz", + "integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -890,13 +928,16 @@ } }, "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz", - "integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz", + "integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -908,13 +949,16 @@ } }, "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz", - "integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz", + "integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -926,13 +970,16 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz", - "integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz", + "integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -944,13 +991,16 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz", - "integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz", + "integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -962,9 +1012,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz", - "integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz", + "integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==", "cpu": [ "arm64" ], @@ -980,9 +1030,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz", - "integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz", + "integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==", "cpu": [ "ia32" ], @@ -998,9 +1048,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz", - "integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz", + "integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==", "cpu": [ "x64" ], @@ -1035,9 +1085,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1071,9 +1121,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1085,13 +1135,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", - "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/pg": { @@ -1107,17 +1157,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", - "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/type-utils": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1130,7 +1180,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.2", + "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1146,16 +1196,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", - "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "engines": { @@ -1171,14 +1221,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", - "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.2", - "@typescript-eslint/types": "^8.59.2", + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "engines": { @@ -1193,14 +1243,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", - "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1211,9 +1261,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", - "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", "dev": true, "license": "MIT", "engines": { @@ -1228,15 +1278,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", - "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1253,9 +1303,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", - "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", "dev": true, "license": "MIT", "engines": { @@ -1267,16 +1317,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", - "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.2", - "@typescript-eslint/tsconfig-utils": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1295,16 +1345,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", - "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2" + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1319,13 +1369,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", - "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1337,14 +1387,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", + "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.6", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1358,8 +1408,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" + "@vitest/browser": "4.1.6", + "vitest": "4.1.6" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1368,16 +1418,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1386,13 +1436,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1413,9 +1463,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "dev": true, "license": "MIT", "dependencies": { @@ -1426,13 +1476,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.6", "pathe": "^2.0.3" }, "funding": { @@ -1440,14 +1490,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1456,9 +1506,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "dev": true, "license": "MIT", "funding": { @@ -1466,13 +1516,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1517,9 +1567,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1606,9 +1656,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "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": { @@ -1844,9 +1894,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -1970,9 +2020,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2089,9 +2139,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "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" @@ -2151,12 +2201,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -2189,9 +2239,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "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", @@ -2434,9 +2484,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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" @@ -2446,9 +2496,9 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2524,9 +2574,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -2824,6 +2874,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2845,6 +2898,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2866,6 +2922,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2887,6 +2946,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2979,13 +3041,13 @@ } }, "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", + "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } @@ -3084,9 +3146,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": [ { @@ -3385,13 +3447,13 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -3404,9 +3466,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3417,9 +3479,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -3566,14 +3628,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3582,21 +3644,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, "node_modules/router": { @@ -3622,9 +3684,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -3821,9 +3883,9 @@ } }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -3848,9 +3910,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "dev": true, "license": "MIT", "engines": { @@ -3928,17 +3990,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/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/typescript": { @@ -3956,16 +4035,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", - "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.2", - "@typescript-eslint/parser": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2" + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3980,9 +4059,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -4046,16 +4125,16 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", + "postcss": "^8.5.14", + "rolldown": "1.0.1", "tinyglobby": "^0.2.16" }, "bin": { @@ -4072,7 +4151,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -4139,19 +4218,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4179,12 +4258,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 5a698ed3..9c63ea10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neverinfamous/postgres-mcp", - "version": "3.0.7", + "version": "3.1.0", "mcpName": "io.github.neverinfamous/postgres-mcp", "description": "PostgreSQL MCP server with connection pooling, tool filtering, and full extension support", "type": "module", @@ -21,8 +21,8 @@ "lint": "eslint src/", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:coverage": "vitest run --coverage", - "test:e2e": "playwright test", + "test:coverage": "vitest run --coverage && node scripts/update-badges.ts", + "test:e2e": "playwright test && node scripts/update-badges.ts", "bench": "vitest bench --run", "bench:verbose": "vitest bench --run --reporter=verbose", "check": "npm run lint && npm run typecheck", @@ -54,30 +54,32 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", "commander": "^14.0.3", - "jose": "^6.0.0", + "jose": "^6.2.3", "pg": "^8.20.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.58.2", - "@types/node": "^25.5.2", + "@playwright/test": "^1.60.0", + "@types/node": "^25.8.0", "@types/pg": "^8.20.0", - "@vitest/coverage-v8": "^4.1.2", - "eslint": "^10.2.0", - "globals": "^17.4.0", - "typescript": "^6.0.2", - "typescript-eslint": "^8.57.2", + "@vitest/coverage-v8": "^4.1.6", + "eslint": "^10.3.0", + "globals": "^17.6.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", "unplugin-swc": "^1.5.9", - "vitest": "^4.1.2" + "vitest": "^4.1.6" }, "overrides": { "@isaacs/brace-expansion": "5.0.1", - "diff": "8.0.4", + "diff": "9.0.0", + "fast-uri": "3.1.2", "flatted": "3.4.2", - "hono": "4.12.12", + "hono": "4.12.18", + "ip-address": "10.2.0", "minimatch": "10.2.5", "picomatch": "4.0.4", - "tar": "7.5.13" + "tar": "7.5.15" } } diff --git a/playwright.config.ts b/playwright.config.ts index 303c5bf8..f13d2053 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 4 : undefined, globalSetup: "./tests/e2e/global-setup.ts", - reporter: "list", + reporter: [["list"], ["json", { outputFile: "playwright-results.json" }]], use: { trace: "on-first-retry", }, diff --git a/releases/v3.1.0.md b/releases/v3.1.0.md new file mode 100644 index 00000000..1490863c --- /dev/null +++ b/releases/v3.1.0.md @@ -0,0 +1,37 @@ +## Highlights + +- **New Tool Groups**: Added Security, Roles, and Document Store tool groups, totaling 30 new tools for advanced auditing, RLS management, and JSONB document collections. +- **Architectural Fixes**: Enforced strict Object Existence (P154) verification and resolved Zod validation leaks across 10 tool groups to prevent silent failures and raw exceptions. +- **Security Updates**: Pinned transitive Dockerfile dependencies and bumped `hono`, `ip-address`, and `fast-uri` to address known vulnerabilities. + +## Added + +- Automated coverage badge updates upon test suite execution. +- Added `initializationSql` config to safely execute session setup queries on connection checkout. +- Introduced 9 new tools for Security (auditing, SSL/TLS, masking, firewall). +- Introduced 12 new tools for Roles (CRUD, privilege, RLS management). +- Introduced 9 new tools for Document Store (JSONB document management, indexing). + +## Changed + +- Updated core dependencies (`typescript`, `eslint`, `zod`, `jose`, `@playwright/test`, `vitest`). +- Pinned transitive Docker dependencies for security. +- Optimized default limits and payload truncation parameters to prevent LLM token bloat. +- Streamlined `pg_schema_snapshot` and added `exclude` parameter to `pg_list_views`. + +## Fixed + +- Remediated Zod validation leaks across multiple groups to ensure structured handler errors. +- Enforced strict P154 object existence verification across Migration, Core, PostGIS, Performance, etc. +- Standardized alias parsing and type coercion across tool groups. +- Standardized parsing for missing extensions and native Postgres exceptions into structured errors. +- Fixed numerous bugs across Backup, Docstore, Ltree, Transactions, Partman, and Pgcrypto tools. +- Stabilized testing infrastructure (PowerShell bleed and E2E assertions). + +## Security + +- Bumped `hono` to `4.12.18`, `ip-address` to `10.2.0`, and `fast-uri` to `3.1.2` via package overrides. + +--- + +**View Full Changelog**: [v3.0.7...v3.1.0](https://github.com/neverinfamous/postgres-mcp/compare/v3.0.7...v3.1.0) diff --git a/scripts/patch-npm-deps.sh b/scripts/patch-npm-deps.sh index ac75a6a7..91c6ccff 100644 --- a/scripts/patch-npm-deps.sh +++ b/scripts/patch-npm-deps.sh @@ -11,13 +11,13 @@ set -eu NPM_DIR=/usr/local/lib/node_modules/npm -# Fix GHSA-73rr-hh4g-fpgx: diff β†’ 8.0.4 +# Fix GHSA-73rr-hh4g-fpgx: diff β†’ 9.0.0 cd "$NPM_DIR" -npm pack diff@8.0.4 +npm pack diff@9.0.0 rm -rf node_modules/diff -tar -xzf diff-8.0.4.tgz +tar -xzf diff-9.0.0.tgz mv package node_modules/diff -rm diff-8.0.4.tgz +rm diff-9.0.0.tgz # Fix CVE-2026-25547: @isaacs/brace-expansion β†’ 5.0.1 cd "$NPM_DIR" @@ -28,13 +28,13 @@ tar -xzf isaacs-brace-expansion-5.0.1.tgz mv package node_modules/@isaacs/brace-expansion rm isaacs-brace-expansion-5.0.1.tgz -# Fix CVE-2026-23950, CVE-2026-24842: tar β†’ 7.5.13 +# Fix CVE-2026-23950, CVE-2026-24842: tar β†’ 7.5.15 cd "$NPM_DIR" -npm pack tar@7.5.13 +npm pack tar@7.5.15 rm -rf node_modules/tar -tar -xzf tar-7.5.13.tgz +tar -xzf tar-7.5.15.tgz mv package node_modules/tar -rm tar-7.5.13.tgz +rm tar-7.5.15.tgz # Fix CVE-2026-27904, CVE-2026-27903: minimatch β†’ 10.2.5 cd "$NPM_DIR" @@ -55,13 +55,13 @@ mkdir -p node_modules/tinyglobby/node_modules cp -a package node_modules/tinyglobby/node_modules/picomatch rm -rf package picomatch-4.0.4.tgz -# Fix CVE-2026-33750: brace-expansion β†’ 5.0.5 +# Fix CVE-2026-33750: brace-expansion β†’ 5.0.6 cd "$NPM_DIR" -npm pack brace-expansion@5.0.5 +npm pack brace-expansion@5.0.6 rm -rf node_modules/brace-expansion -tar -xzf brace-expansion-5.0.5.tgz +tar -xzf brace-expansion-5.0.6.tgz mv package node_modules/brace-expansion -rm brace-expansion-5.0.5.tgz +rm brace-expansion-5.0.6.tgz # Optional cache cleanup (used in production stage to keep image lean) if [ "${1:-}" = "--clean-cache" ]; then diff --git a/scripts/update-badges.ts b/scripts/update-badges.ts new file mode 100644 index 00000000..e88ab579 --- /dev/null +++ b/scripts/update-badges.ts @@ -0,0 +1,99 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT_DIR = path.resolve(__dirname, ".."); + +function getBadgeColor(percentage: number): string { + if (percentage >= 95) return "brightgreen"; + if (percentage >= 85) return "green"; + if (percentage >= 75) return "yellowgreen"; + if (percentage >= 65) return "yellow"; + if (percentage >= 50) return "orange"; + return "red"; +} + +function updateBadges() { + const summaryPath = path.join(ROOT_DIR, "coverage/coverage-summary.json"); + const playwrightPath = path.join(ROOT_DIR, "playwright-results.json"); + + let linesPct = 0; + let coverageColor = "red"; + let hasCoverage = false; + + if (fs.existsSync(summaryPath)) { + const summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8")); + linesPct = summary.total.lines.pct; + coverageColor = getBadgeColor(linesPct); + hasCoverage = true; + } else { + console.warn(`Coverage summary not found at ${summaryPath}`); + } + + let e2ePassing = 0; + let e2eSkipped = 0; + let hasE2e = false; + + if (fs.existsSync(playwrightPath)) { + const pw = JSON.parse(fs.readFileSync(playwrightPath, "utf-8")); + e2ePassing = pw.stats.expected || 0; + e2eSkipped = pw.stats.skipped || 0; + hasE2e = true; + } else { + console.warn(`Playwright results not found at ${playwrightPath}`); + } + + // ![Coverage](https://img.shields.io/badge/Coverage-96.7%25-brightgreen.svg) + const covRegex = + /!\[Coverage\]\(https:\/\/img\.shields\.io\/badge\/Coverage-[0-9.]+.*?\.svg\)/g; + const newCovBadge = `![Coverage](https://img.shields.io/badge/Coverage-${linesPct}%25-${coverageColor}.svg)`; + + // ![E2E](https://img.shields.io/badge/E2E-179%20tests%20%C2%B7%20224%20tools-blue.svg) + const e2eRegex = + /!\[E2E\]\(https:\/\/img\.shields\.io\/badge\/E2E-[a-zA-Z0-9%.-]+.*?\.svg\)/g; + const newE2eBadge = `![E2E](https://img.shields.io/badge/E2E-${e2ePassing}%20passing%20%C2%B7%20${e2eSkipped}%20skipped-blue.svg)`; + + const filesToUpdate = ["README.md", "DOCKER_README.md"]; + + for (const file of filesToUpdate) { + const filePath = path.join(ROOT_DIR, file); + try { + let content = fs.readFileSync(filePath, "utf-8"); + let changed = false; + + if (hasCoverage) { + covRegex.lastIndex = 0; + if (covRegex.test(content)) { + covRegex.lastIndex = 0; + content = content.replace(covRegex, newCovBadge); + changed = true; + console.log(`Updated coverage badge in ${file} to ${linesPct}%`); + } + } + + if (hasE2e) { + e2eRegex.lastIndex = 0; + if (e2eRegex.test(content)) { + e2eRegex.lastIndex = 0; + content = content.replace(e2eRegex, newE2eBadge); + changed = true; + console.log( + `Updated E2E badge in ${file} to ${e2ePassing} passing, ${e2eSkipped} skipped`, + ); + } + } + + if (changed) { + fs.writeFileSync(filePath, content, "utf-8"); + } else { + console.log(`No badges found to update in ${file}.`); + } + } catch (err) { + console.warn(`Skipped updating ${file}: File not found or unreadable.`); + } + } +} + +updateBadges(); diff --git a/server.json b/server.json index 1a08b141..8c9dfd63 100644 --- a/server.json +++ b/server.json @@ -3,19 +3,19 @@ "name": "io.github.neverinfamous/postgres-mcp", "title": "PostgreSQL MCP Server", "description": "PostgreSQL MCP server with connection pooling, tool filtering, and full extension support", - "version": "3.0.7", + "version": "3.1.0", "packages": [ { "registryType": "npm", "identifier": "@neverinfamous/postgres-mcp", - "version": "3.0.7", + "version": "3.1.0", "transport": { "type": "stdio" } }, { "registryType": "oci", - "identifier": "docker.io/writenotenow/postgres-mcp:v3.0.7", + "identifier": "docker.io/writenotenow/postgres-mcp:v3.1.0", "transport": { "type": "stdio" } diff --git a/src/__tests__/mocks/adapter.ts b/src/__tests__/mocks/adapter.ts index a9c7c39b..45454683 100644 --- a/src/__tests__/mocks/adapter.ts +++ b/src/__tests__/mocks/adapter.ts @@ -166,7 +166,7 @@ export function createMockPostgresAdapter(): Partial & { rollbackToSavepoint: vi.fn().mockResolvedValue(undefined), getTransactionConnection: vi.fn().mockReturnValue({}), executeOnConnection: vi.fn().mockImplementation((_client, sql, params) => { - return executeQueryMock(sql, params) as unknown as Promise; + return executeQueryMock(sql, params) as Promise; }), invalidateSchemaCache: vi.fn(), invalidateTableCache: vi.fn( diff --git a/src/adapters/mcp-registry.ts b/src/adapters/mcp-registry.ts index ca9bf514..2d9df8d4 100644 --- a/src/adapters/mcp-registry.ts +++ b/src/adapters/mcp-registry.ts @@ -1,10 +1,12 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { z } from "zod"; +import type { ZodType } from "zod"; import { logger } from "../utils/logger.js"; import type { ToolDefinition, ResourceDefinition, PromptDefinition, + ToolAnnotations, + ToolIcon, } from "../types/index.js"; import { getAuthContext } from "../auth/auth-context.js"; import { getRequiredScope } from "../auth/scope-map.js"; @@ -37,39 +39,42 @@ export function registerSingleTool( server: McpServer, tool: ToolDefinition, ): void { - const toolOptions: Record = { + const toolOptions: { + description: string; + title?: string; + inputSchema?: ZodType; + outputSchema?: ZodType; + annotations?: ToolAnnotations; + icons?: ToolIcon[]; + } = { description: tool.description, }; if (tool.annotations?.title) { - toolOptions["title"] = tool.annotations.title; + toolOptions.title = tool.annotations.title; } if (tool.inputSchema !== undefined) { - toolOptions["inputSchema"] = tool.inputSchema; + toolOptions.inputSchema = tool.inputSchema as ZodType; } if (tool.outputSchema !== undefined) { - toolOptions["outputSchema"] = tool.outputSchema; + toolOptions.outputSchema = tool.outputSchema as ZodType; } if (tool.annotations) { - toolOptions["annotations"] = tool.annotations; + toolOptions.annotations = tool.annotations; } if (tool.icons && tool.icons.length > 0) { - toolOptions["icons"] = tool.icons; + toolOptions.icons = tool.icons; } const hasOutputSchema = Boolean(tool.outputSchema); server.registerTool( tool.name, - toolOptions as { - description?: string; - inputSchema?: z.ZodType; - outputSchema?: z.ZodType; - }, + toolOptions, async (args: unknown, extra: unknown) => { try { const authCtx = getAuthContext(); diff --git a/src/adapters/postgresql/prompts/__tests__/prompts.test.ts b/src/adapters/postgresql/prompts/__tests__/prompts.test.ts index 21032433..226bf05c 100644 --- a/src/adapters/postgresql/prompts/__tests__/prompts.test.ts +++ b/src/adapters/postgresql/prompts/__tests__/prompts.test.ts @@ -38,8 +38,8 @@ describe("getPostgresPrompts", () => { prompts = getPostgresPrompts(mockAdapter as unknown as PostgresAdapter); }); - it("should return 20 prompts", () => { - expect(prompts).toHaveLength(20); + it("should return 21 prompts", () => { + expect(prompts).toHaveLength(21); }); it("should have all expected prompt names", () => { @@ -72,6 +72,9 @@ describe("getPostgresPrompts", () => { // Audit & restore prompts expect(promptNames).toContain("pg_safe_restore_workflow"); + + // Docstore prompts + expect(promptNames).toContain("pg_setup_docstore"); }); it("should have handler function for all prompts", () => { diff --git a/src/adapters/postgresql/prompts/docstore.ts b/src/adapters/postgresql/prompts/docstore.ts new file mode 100644 index 00000000..f021fba1 --- /dev/null +++ b/src/adapters/postgresql/prompts/docstore.ts @@ -0,0 +1,131 @@ +/** + * PostgreSQL Prompt - Document Store Setup + * + * Complete Document Store setup guide for PostgreSQL JSONB collections. + */ +import type { PromptDefinition, RequestContext } from "../../../types/index.js"; + +export function createSetupDocstorePrompt(): PromptDefinition { + return { + name: "pg_setup_docstore", + description: + "Complete PostgreSQL Document Store setup guide using JSONB collections", + arguments: [], + handler: (_args: Record, _context: RequestContext) => { + return Promise.resolve(`# PostgreSQL Document Store Setup Guide + +PostgreSQL Document Store provides a NoSQL-like document abstraction using native JSONB columns. +No extensions needed β€” JSONB is built into PostgreSQL. + +## Prerequisites + +1. **PostgreSQL 12+** (full JSONB support) +2. No additional extensions required + +## Step 1: Create a Collection + +Collections are tables with a \`doc JSONB\` column and a generated \`_id\` primary key: + +\`\`\`sql +CREATE TABLE products ( + doc JSONB NOT NULL, + _id TEXT GENERATED ALWAYS AS (doc->>'_id') STORED PRIMARY KEY +); +\`\`\` + +Or use the MCP tool: +\`\`\` +pg_doc_create_collection({ name: "products" }) +\`\`\` + +## Step 2: Add Documents + +\`\`\` +pg_doc_add({ + collection: "products", + documents: [ + { "name": "Widget", "price": 9.99, "tags": ["sale"] }, + { "name": "Gadget", "price": 24.99, "category": "electronics" } + ] +}) +\`\`\` + +Documents automatically get a 32-character hex \`_id\` if not provided. + +## Step 3: Query Documents + +\`\`\` +-- Find all documents +pg_doc_find({ collection: "products" }) + +-- Find by field value +pg_doc_find({ collection: "products", filter: "name=Widget" }) + +-- Find by JSON path existence +pg_doc_find({ collection: "products", filter: "$.tags" }) + +-- Find by _id +pg_doc_find({ collection: "products", filter: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" }) +\`\`\` + +## SQL Access to Collections + +Collections are standard PostgreSQL tables: +\`\`\`sql +-- Direct JSONB queries +SELECT doc->>'name' AS name, (doc->>'price')::numeric AS price +FROM products +WHERE doc @> '{"category": "electronics"}'; + +-- Use JSONB containment +SELECT doc FROM products +WHERE doc ? 'tags'; +\`\`\` + +## Available MCP Tools + +| Tool | Description | +|------|-------------| +| \`pg_doc_list_collections\` | List collections | +| \`pg_doc_create_collection\` | Create collection | +| \`pg_doc_drop_collection\` | Drop collection | +| \`pg_doc_find\` | Query documents | +| \`pg_doc_add\` | Add documents | +| \`pg_doc_modify\` | Update documents | +| \`pg_doc_remove\` | Delete documents | +| \`pg_doc_create_index\` | Create index | +| \`pg_doc_collection_info\` | Collection stats | + +## Creating Indexes + +\`\`\` +pg_doc_create_index({ + collection: "products", + name: "idx_products_name", + fields: [{ path: "name", type: "TEXT" }] +}) +\`\`\` + +For GIN indexes on entire documents (recommended for containment queries): +\`\`\`sql +CREATE INDEX idx_products_gin ON products USING GIN (doc); +\`\`\` + +## Best Practices + +1. **Add GIN indexes** on the \`doc\` column for containment queries (\`@>\`) +2. **Use expression indexes** on frequently queried fields via \`pg_doc_create_index\` +3. **Include \`_id\`** in documents for consistent identification +4. **Use JSONB operators** for complex queries: \`@>\`, \`?\`, \`->\`, \`->>\` +5. **Consider hybrid approach** β€” mix relational columns with JSONB for frequently queried fields + +## Common Operations + +1. **Modify documents**: \`pg_doc_modify({ collection: "products", filter: "name=Widget", set: { price: 12.99 } })\` +2. **Remove fields**: \`pg_doc_modify({ collection: "products", filter: "name=Widget", unset: ["category"] })\` +3. **Delete documents**: \`pg_doc_remove({ collection: "products", filter: "name=Widget" })\` + +Start by listing collections with \`pg_doc_list_collections\`.`); + }, + }; +} diff --git a/src/adapters/postgresql/prompts/index.ts b/src/adapters/postgresql/prompts/index.ts index 05d7b225..b22c2d12 100644 --- a/src/adapters/postgresql/prompts/index.ts +++ b/src/adapters/postgresql/prompts/index.ts @@ -2,7 +2,7 @@ * PostgreSQL MCP Prompts * * AI-powered prompts for query building, schema design, and optimization. - * 20 prompts total. + * 21 prompts total. */ import type { PostgresAdapter } from "../postgres-adapter.js"; @@ -26,6 +26,7 @@ import { createSetupCitextPrompt } from "./citext.js"; import { createSetupLtreePrompt } from "./ltree.js"; import { createSetupPgcryptoPrompt } from "./pgcrypto.js"; import { createSafeRestoreWorkflowPrompt } from "./safe-restore.js"; +import { createSetupDocstorePrompt } from "./docstore.js"; /** * Get all PostgreSQL prompts @@ -59,6 +60,8 @@ export function getPostgresPrompts( createSetupPgcryptoPrompt(), // Audit & restore prompts createSafeRestoreWorkflowPrompt(), + // Document Store + createSetupDocstorePrompt(), ]; } diff --git a/src/adapters/postgresql/resources/capabilities.ts b/src/adapters/postgresql/resources/capabilities.ts index 86c12bf0..20e82752 100644 --- a/src/adapters/postgresql/resources/capabilities.ts +++ b/src/adapters/postgresql/resources/capabilities.ts @@ -214,9 +214,9 @@ export function createCapabilitiesResource( return { serverVersion: "0.3.0", postgresqlVersion: pgVersion, - totalTools: 146, - totalResources: 21, - totalPrompts: 7, + totalTools: 269, + totalResources: 23, + totalPrompts: 20, toolCategories, installedExtensions: extensions, criticalExtensions, diff --git a/src/adapters/postgresql/resources/docstore.ts b/src/adapters/postgresql/resources/docstore.ts new file mode 100644 index 00000000..4dbd0810 --- /dev/null +++ b/src/adapters/postgresql/resources/docstore.ts @@ -0,0 +1,62 @@ +/** + * PostgreSQL Resource - Document Store + * + * Lists JSONB document collections in the current database. + */ +import type { PostgresAdapter } from "../postgres-adapter.js"; +import type { + ResourceDefinition, + RequestContext, +} from "../../../types/index.js"; + +export function createDocstoreResource( + adapter: PostgresAdapter, +): ResourceDefinition { + return { + uri: "postgres://docstore", + name: "Document Store Collections", + description: "JSONB document collections in the current database", + mimeType: "application/json", + annotations: { + audience: ["user", "assistant"], + priority: 0.5, + }, + handler: async (_uri: string, _context: RequestContext) => { + try { + const result = await adapter.executeQuery(` + SELECT + t.table_name AS collection_name, + pg_stat_get_live_tuples(c.oid)::int AS row_count, + pg_size_pretty(pg_total_relation_size(c.oid)) AS size + FROM information_schema.tables t + JOIN pg_class c ON c.relname = t.table_name + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema + WHERE t.table_schema = current_schema() + AND EXISTS ( + SELECT 1 FROM information_schema.columns c1 + WHERE c1.table_schema = t.table_schema AND c1.table_name = t.table_name + AND c1.column_name = 'doc' AND c1.udt_name = 'jsonb' + ) + AND EXISTS ( + SELECT 1 FROM information_schema.columns c2 + WHERE c2.table_schema = t.table_schema AND c2.table_name = t.table_name + AND c2.column_name = '_id' + ) + ORDER BY t.table_name + `); + + return { + collectionCount: result.rows?.length ?? 0, + collections: result.rows ?? [], + note: "JSONB document collections detected by convention (doc JSONB + _id column)", + }; + } catch { + return { + collectionCount: 0, + collections: [], + error: "Unable to retrieve document store information", + }; + } + }, + }; +} diff --git a/src/adapters/postgresql/resources/index.ts b/src/adapters/postgresql/resources/index.ts index ad8ceda1..c641e1d6 100644 --- a/src/adapters/postgresql/resources/index.ts +++ b/src/adapters/postgresql/resources/index.ts @@ -2,7 +2,7 @@ * PostgreSQL MCP Resources * * Provides structured data access via URI patterns. - * 21 resources total. + * 22 resources total. */ import type { PostgresAdapter } from "../postgres-adapter.js"; @@ -37,8 +37,11 @@ import { createCryptoResource } from "./crypto.js"; // Utility resources import { createInsightsResource } from "./insights.js"; +// Data resources +import { createDocstoreResource } from "./docstore.js"; + /** - * Get all PostgreSQL resources (21 total) + * Get all PostgreSQL resources (22 total) * * Core (7): * - postgres://schema - Full database schema @@ -99,5 +102,7 @@ export function getPostgresResources( createCryptoResource(adapter), // Utility resources createInsightsResource(), + // Data resources + createDocstoreResource(adapter), ]; } diff --git a/src/adapters/postgresql/resources/vacuum.ts b/src/adapters/postgresql/resources/vacuum.ts index 01c60150..c9e9942b 100644 --- a/src/adapters/postgresql/resources/vacuum.ts +++ b/src/adapters/postgresql/resources/vacuum.ts @@ -10,11 +10,7 @@ import type { RequestContext, } from "../../../types/index.js"; import { MEDIUM_PRIORITY } from "../../../utils/resource-annotations.js"; -import { - generateVacuumSuggestions, - type VacuumStatsRow, - type WraparoundStats, -} from "../../../utils/resource-suggestions.js"; +import { generateVacuumSuggestions } from "../../../utils/resource-suggestions.js"; interface VacuumWarning { severity: "CRITICAL" | "HIGH" | "MEDIUM" | "INFO"; @@ -161,10 +157,7 @@ export function createVacuumResource( } // Β§7: Generate actionable suggestions based on vacuum data - const suggestions = generateVacuumSuggestions( - vacuumStats as unknown as VacuumStatsRow[], - wraparoundRow as WraparoundStats | null, - ); + const suggestions = generateVacuumSuggestions(vacuumStats, wraparoundRow); return { vacuumStatistics: vacuumStats, diff --git a/src/adapters/postgresql/schemas/__tests__/schemas.test.ts b/src/adapters/postgresql/schemas/__tests__/schemas.test.ts index de7171b3..1c175f7f 100644 --- a/src/adapters/postgresql/schemas/__tests__/schemas.test.ts +++ b/src/adapters/postgresql/schemas/__tests__/schemas.test.ts @@ -417,7 +417,7 @@ describe("BufferSchema", () => { distance: 0, }), ).toThrow( - "distance (or radius/meters alias) is required and must be positive", + "distance (or radius/meters alias) is required and cannot be zero", ); }); @@ -538,7 +538,7 @@ describe("CreateSequenceSchema", () => { it("should require name", () => { expect(() => CreateSequenceSchema.parse({})).toThrow( - "name (or sequenceName alias) is required", + "name (or sequenceName/sequence alias) is required", ); }); @@ -2946,7 +2946,7 @@ describe("BufferSchema", () => { column: "geom", distance: 0, }), - ).toThrow("must be positive"); + ).toThrow("cannot be zero"); }); }); @@ -3010,13 +3010,12 @@ describe("GeometryTransformSchema (standalone)", () => { ).toThrow("geometry (or wkt/geojson alias) is required"); }); - it("should reject missing fromSrid", () => { - expect(() => - GeometryTransformSchema.parse({ - geometry: "POINT(0 0)", - toSrid: 3857, - }), - ).toThrow("fromSrid (or sourceSrid alias) is required"); + it("should default fromSrid to 4326 if missing", () => { + const result = GeometryTransformSchema.parse({ + geometry: "POINT(0 0)", + toSrid: 3857, + }); + expect(result.fromSrid).toBe(4326); }); }); @@ -3049,7 +3048,7 @@ describe("GeometryBufferSchema (standalone)", () => { geometry: "POINT(0 0)", distance: 0, }), - ).toThrow("must be positive"); + ).toThrow("cannot be zero"); }); }); @@ -3742,7 +3741,7 @@ describe("CreateSequenceSchema", () => { it("should reject missing name", () => { expect(() => CreateSequenceSchema.parse({})).toThrow( - "name (or sequenceName alias) is required", + "name (or sequenceName/sequence alias) is required", ); }); diff --git a/src/adapters/postgresql/schemas/admin.ts b/src/adapters/postgresql/schemas/admin.ts index 5e469764..d25adec3 100644 --- a/src/adapters/postgresql/schemas/admin.ts +++ b/src/adapters/postgresql/schemas/admin.ts @@ -275,8 +275,8 @@ function preprocessSetConfigParams(input: unknown): unknown { if (result["param"] !== undefined && result["name"] === undefined) { result["name"] = result["param"]; } - if (result["setting"] !== undefined && result["name"] === undefined) { - result["name"] = result["setting"]; + if (result["setting"] !== undefined && result["value"] === undefined) { + result["value"] = result["setting"]; } return result; } @@ -285,7 +285,7 @@ function preprocessSetConfigParams(input: unknown): unknown { export const SetConfigSchemaBase = z.object({ name: z.string().optional().describe("Configuration parameter name"), param: z.string().optional().describe("Alias for name"), - setting: z.string().optional().describe("Alias for name"), + setting: z.string().optional().describe("Alias for value"), value: z.string().optional().describe("New value"), isLocal: z.boolean().optional().describe("Apply only to current transaction"), }); diff --git a/src/adapters/postgresql/schemas/backup.ts b/src/adapters/postgresql/schemas/backup.ts index 9abf0da2..08c7d5a3 100644 --- a/src/adapters/postgresql/schemas/backup.ts +++ b/src/adapters/postgresql/schemas/backup.ts @@ -164,6 +164,66 @@ export const PhysicalBackupSchema = z.object({ compress: z.preprocess(coerceNumber, z.number().optional()).optional(), }); +export const DumpTableSchemaBase = z.object({ + table: z.string().optional().describe("Table or sequence name"), + schema: z.string().optional().describe("Schema name (default: public)"), + includeData: z + .boolean() + .optional() + .describe("Include INSERT statements for table data"), + limit: z + .number() + .optional() + .describe( + "Maximum rows to include when includeData is true (default: 500, use 0 for all rows)", + ), +}); + +export const DumpTableSchema = z.object({ + table: z.string().optional(), + schema: z.string().optional(), + includeData: z.boolean().optional(), + limit: z.preprocess(coerceNumber, z.number().optional()).optional(), +}); + +export const CopyImportSchema = z.object({ + table: z.string().optional(), + tableName: z.string().optional().describe("Alias for table"), + schema: z.string().optional(), + filePath: z + .string() + .optional() + .describe("Path to import file (default: /path/to/file.csv)"), + format: z.string().optional().describe("Format (csv, text, binary)"), + header: z.boolean().optional(), + delimiter: z.string().optional(), + columns: z.array(z.string()).optional(), +}); + +export const RestoreCommandSchema = z.object({ + backupFile: z.string().optional(), + filename: z.string().optional().describe("Alias for backupFile"), + database: z + .string() + .optional() + .describe("Target database name (required for complete command)"), + schema: z.string().optional(), + table: z.string().optional(), + dataOnly: z.boolean().optional(), + schemaOnly: z.boolean().optional(), +}); + +export const RestoreValidateSchema = z.object({ + backupFile: z.string().optional().describe("Path to backup file"), + filename: z.string().optional().describe("Alias for backupFile"), + backupType: z + .string() + .optional() + .describe("Backup type (pg_dump, pg_basebackup)"), +}); + +export const BackupScheduleOptimizeSchema = z.object({}); + // ============================================================================ // Output Schemas // ============================================================================ @@ -456,7 +516,7 @@ export const AuditRestoreBackupSchema = z.object({ /** * pg_audit_diff_backup input schema */ -export const AuditDiffBackupSchema = z.object({ +export const AuditDiffBackupSchemaBase = z.object({ filename: z .string() .optional() @@ -464,12 +524,16 @@ export const AuditDiffBackupSchema = z.object({ compact: z .boolean() .optional() - .default(true) .describe( "If true, omits full DDL strings from response to save tokens (default: true)", ), }); +export const AuditDiffBackupSchema = z.object({ + filename: z.string().optional(), + compact: z.boolean().optional().default(true), +}); + /** * pg_audit_list_backups output - list of snapshots */ @@ -550,7 +614,7 @@ export const AuditDiffBackupOutputSchema = z .boolean() .optional() .describe("Whether target object still exists"), - hasDifferences: z + hasDrift: z .boolean() .optional() .describe("Whether schema or volume has drifted"), diff --git a/src/adapters/postgresql/schemas/core-exports.ts b/src/adapters/postgresql/schemas/core-exports.ts index 82fe1e8f..a67c399f 100644 --- a/src/adapters/postgresql/schemas/core-exports.ts +++ b/src/adapters/postgresql/schemas/core-exports.ts @@ -28,7 +28,6 @@ export { TransactionIdSchemaBase, SavepointSchema, SavepointSchemaBase, - ExecuteInTransactionSchema, TransactionExecuteSchema, TransactionExecuteSchemaBase, // Transaction output schemas @@ -67,6 +66,24 @@ export { SeqScanTablesSchema, IndexRecommendationsInputSchemaBase, IndexRecommendationsInputSchema, + PerformanceBaselineSchemaBase, + PerformanceBaselineSchema, + ConnectionPoolOptimizeInputSchemaBase, + ConnectionPoolOptimizeInputSchema, + PartitionStrategySchemaBase, + PartitionStrategySchema, + UnusedIndexesSchemaBase, + UnusedIndexesSchema, + DuplicateIndexesSchemaBase, + DuplicateIndexesSchema, + ConnectionSpikeInputBase, + ConnectionSpikeInput, + QueryPlanCompareSchemaBase, + QueryPlanCompareSchema, + QueryAnomaliesInputBase, + QueryAnomaliesInput, + BloatRiskInputBase, + BloatRiskInput, // Output schemas ExplainOutputSchema, IndexStatsOutputSchema, @@ -142,7 +159,9 @@ export { UptimeOutputSchema, RecoveryStatusOutputSchema, CapacityPlanningOutputSchema, - ResourceUsageAnalyzeOutputSchema, + SystemHealthSchemaBase, + SystemHealthSchema, + SystemHealthOutputSchema, AlertThresholdOutputSchema, } from "./monitoring.js"; @@ -158,7 +177,14 @@ export { AuditListBackupsSchemaBase, AuditListBackupsSchema, AuditRestoreBackupSchema, + AuditDiffBackupSchemaBase, AuditDiffBackupSchema, + DumpTableSchemaBase, + DumpTableSchema, + CopyImportSchema, + RestoreCommandSchema, + RestoreValidateSchema, + BackupScheduleOptimizeSchema, // Output schemas DumpTableOutputSchema, DumpSchemaOutputSchema, @@ -303,6 +329,19 @@ export { ConstraintAnalysisSchema, MigrationRisksSchemaBase, MigrationRisksSchema, + + // Output schemas + DependencyGraphOutputSchema, + TopologicalSortOutputSchema, + CascadeSimulatorOutputSchema, + SchemaSnapshotOutputSchema, + ConstraintAnalysisOutputSchema, + MigrationRisksOutputSchema, +} from "./introspection/index.js"; + +// Migration schemas (schema version tracking) +export { + // Input schemas MigrationInitSchemaBase, MigrationInitSchema, MigrationRecordSchemaBase, @@ -316,16 +355,118 @@ export { MigrationStatusSchemaBase, MigrationStatusSchema, // Output schemas - DependencyGraphOutputSchema, - TopologicalSortOutputSchema, - CascadeSimulatorOutputSchema, - SchemaSnapshotOutputSchema, - ConstraintAnalysisOutputSchema, - MigrationRisksOutputSchema, MigrationInitOutputSchema, MigrationRecordOutputSchema, MigrationApplyOutputSchema, MigrationRollbackOutputSchema, MigrationHistoryOutputSchema, MigrationStatusOutputSchema, -} from "./introspection/index.js"; +} from "./migration/index.js"; + +// Security schemas (auditing, SSL, privileges, data protection) +export { + // Input schemas + SecurityAuditSchemaBase, + SecurityAuditSchema, + FirewallStatusSchemaBase, + FirewallStatusSchema, + FirewallRulesSchemaBase, + FirewallRulesSchema, + MaskDataSchemaBase, + MaskDataSchema, + UserPrivilegesSchemaBase, + UserPrivilegesSchema, + SensitiveTablesSchemaBase, + SensitiveTablesSchema, + SSLStatusSchemaBase, + SSLStatusSchema, + EncryptionStatusSchemaBase, + EncryptionStatusSchema, + PasswordValidateSchemaBase, + PasswordValidateSchema, + // Output schemas + SecurityAuditOutputSchema, + FirewallStatusOutputSchema, + FirewallRulesOutputSchema, + MaskDataOutputSchema, + UserPrivilegesOutputSchema, + SensitiveTablesOutputSchema, + SSLStatusOutputSchema, + EncryptionStatusOutputSchema, + PasswordValidateOutputSchema, +} from "./security.js"; + +// Roles schemas (role management, grants, membership, RLS) +export { + // Input schemas + RoleListSchemaBase, + RoleListSchema, + RoleCreateSchemaBase, + RoleCreateSchema, + RoleDropSchemaBase, + RoleDropSchema, + RoleAttributesSchemaBase, + RoleAttributesSchema, + RoleGrantsSchemaBase, + RoleGrantsSchema, + RoleGrantSchemaBase, + RoleGrantSchema, + RoleAssignSchemaBase, + RoleAssignSchema, + RoleRevokeSchemaBase, + RoleRevokeSchema, + UserRolesSchemaBase, + UserRolesSchema, + RoleSetSchemaBase, + RoleSetSchema, + RoleRlsEnableSchemaBase, + RoleRlsEnableSchema, + RoleRlsPoliciesSchemaBase, + RoleRlsPoliciesSchema, + // Output schemas + RoleListOutputSchema, + RoleCreateOutputSchema, + RoleDropOutputSchema, + RoleAttributesOutputSchema, + RoleGrantsOutputSchema, + RoleGrantOutputSchema, + RoleAssignOutputSchema, + RoleRevokeOutputSchema, + UserRolesOutputSchema, + RoleSetOutputSchema, + RoleRlsEnableOutputSchema, + RoleRlsPoliciesOutputSchema, +} from "./roles.js"; + +// Document Store schemas (collection-based JSONB document management) +export { + // Input schemas + ListCollectionsSchemaBase, + ListCollectionsSchema, + CreateCollectionSchemaBase, + CreateCollectionSchema, + DropCollectionSchemaBase, + DropCollectionSchema, + CollectionInfoSchemaBase, + CollectionInfoSchema, + FindSchemaBase, + FindSchema, + AddDocSchemaBase, + AddDocSchema, + ModifyDocSchemaBase, + ModifyDocSchema, + RemoveDocSchemaBase, + RemoveDocSchema, + CreateDocIndexSchemaBase, + CreateDocIndexSchema, + // Output schemas + ListCollectionsOutputSchema, + CreateCollectionOutputSchema, + DropCollectionOutputSchema, + CollectionInfoOutputSchema, + FindOutputSchema, + AddDocOutputSchema, + ModifyDocOutputSchema, + RemoveDocOutputSchema, + CreateDocIndexOutputSchema, +} from "./docstore.js"; diff --git a/src/adapters/postgresql/schemas/core/queries.ts b/src/adapters/postgresql/schemas/core/queries.ts index 35697af1..8f218613 100644 --- a/src/adapters/postgresql/schemas/core/queries.ts +++ b/src/adapters/postgresql/schemas/core/queries.ts @@ -140,7 +140,7 @@ export const ListTablesSchemaBase = z.object({ limit: z .number() .optional() - .describe("Maximum number of tables to return (default: 100)"), + .describe("Maximum number of tables to return (default: 20)"), exclude: z .array(z.string()) .optional() diff --git a/src/adapters/postgresql/schemas/core/transactions.ts b/src/adapters/postgresql/schemas/core/transactions.ts index 77e34dab..11af569d 100644 --- a/src/adapters/postgresql/schemas/core/transactions.ts +++ b/src/adapters/postgresql/schemas/core/transactions.ts @@ -129,25 +129,6 @@ export const SavepointSchema = z }, ); -// Base schema for MCP visibility -const ExecuteInTransactionSchemaBase = z.object({ - transactionId: z.string().optional().describe("Transaction ID"), - txId: z.string().optional().describe("Alias for transactionId"), - tx: z.string().optional().describe("Alias for transactionId"), - sql: z.string().describe("SQL to execute"), - params: z.array(z.unknown()).optional().describe("Query parameters"), -}); - -// Transformed schema with alias resolution -export const ExecuteInTransactionSchema = - ExecuteInTransactionSchemaBase.transform((data) => ({ - transactionId: data.transactionId ?? data.txId ?? data.tx ?? "", - sql: data.sql, - params: data.params, - })).refine((data) => data.transactionId !== "", { - message: "transactionId (or txId/tx alias) is required", - }); - // Base schema for MCP visibility β€” uses z.record() for statement items and // z.string() for isolationLevel so invalid values reach the handler's try/catch. export const TransactionExecuteSchemaBase = z.object({ @@ -165,13 +146,24 @@ export const TransactionExecuteSchemaBase = z.object({ ), txId: z.string().optional().describe("Alias for transactionId"), tx: z.string().optional().describe("Alias for transactionId"), - isolationLevel: z.string().optional().describe("Transaction isolation level"), + isolationLevel: z + .string() + .optional() + .describe( + "Transaction isolation level (only applies if transactionId is omitted)", + ), isolation_level: z.string().optional().describe("Alias for isolationLevel"), read_only: z .boolean() .optional() - .describe("Set to true for read-only transaction"), + .describe( + "Set to true for read-only transaction (only applies if transactionId is omitted)", + ), readOnly: z.boolean().optional().describe("Alias for read_only"), + limit: z + .number() + .optional() + .describe("Maximum number of rows to return per statement (default: 1000)"), }); // Internal schema with strict validation (used inside handler try/catch) @@ -209,6 +201,10 @@ const TransactionExecuteValidationSchema = z.object({ .boolean() .optional() .describe("Set to true for read-only transaction"), + limit: z + .number() + .optional() + .describe("Maximum number of rows to return per statement (default: 1000)"), }); // Schema with undefined handling for pg_transaction_execute @@ -231,6 +227,7 @@ export const TransactionExecuteSchema = z transactionId: data.transactionId ?? data.txId ?? data.tx, isolationLevel: data.isolationLevel, read_only: data.read_only, + limit: data.limit ?? 1000, })) .refine((data) => data.statements.length > 0, { message: diff --git a/src/adapters/postgresql/schemas/docstore.ts b/src/adapters/postgresql/schemas/docstore.ts new file mode 100644 index 00000000..5851ddcc --- /dev/null +++ b/src/adapters/postgresql/schemas/docstore.ts @@ -0,0 +1,456 @@ +/** + * postgres-mcp - Document Store Tool Schemas + * + * Input validation and output schemas for document store tools. + * 9 tools: list_collections, create_collection, drop_collection, + * collection_info, find, add, modify, remove, create_index. + */ + +import { z } from "zod"; +import { ErrorResponseFields } from "./error-response-fields.js"; + +// Helper to handle undefined params (allows tools to be called without {}) +const defaultToEmpty = (val: unknown): unknown => val ?? {}; + +// ============================================================================= +// Input Schemas (Split Schema pattern: Base for MCP, Preprocessed for handler) +// ============================================================================= + +/** + * pg_doc_list_collections β€” list JSONB document collections in a schema + */ +export const ListCollectionsSchemaBase = z.object({ + schema: z + .string() + .optional() + .describe("Schema name (defaults to current_schema())"), +}); + +export const ListCollectionsSchema = z.preprocess( + defaultToEmpty, + ListCollectionsSchemaBase, +); + +/** + * pg_doc_create_collection β€” create a new JSONB document collection + */ +export const CreateCollectionSchemaBase = z.object({ + name: z.string().optional().describe("Collection name"), + collection: z.string().optional().describe("Alias for name"), + schema: z + .unknown() + .optional() + .describe("Schema to create in (defaults to current_schema())"), + ifNotExists: z + .unknown() + .optional() + .describe( + "Skip without error if collection already exists (default: false)", + ), +}); + +export const CreateCollectionSchema = z.preprocess( + (val: unknown) => { + if (typeof val === "object" && val !== null) { + const obj = val as Record; + return { + ...obj, + name: obj["name"] ?? obj["collection"], + }; + } + return val; + }, + z.object({ + name: z.string().describe("Collection name"), + schema: z.string().optional(), + ifNotExists: z.preprocess( + (val) => val === "true" || val === true, + z.boolean().default(false), + ), + }), +); + +/** + * pg_doc_drop_collection β€” drop a document collection + */ +export const DropCollectionSchemaBase = z.object({ + name: z.string().optional().describe("Collection name to drop"), + collection: z.string().optional().describe("Alias for name"), + schema: z.unknown().optional(), + ifExists: z + .unknown() + .optional() + .describe( + "Skip without error if collection does not exist (default: false)", + ), +}); + +export const DropCollectionSchema = z.preprocess( + (val: unknown) => { + if (typeof val === "object" && val !== null) { + const obj = val as Record; + return { + ...obj, + name: obj["name"] ?? obj["collection"], + }; + } + return val; + }, + z.object({ + name: z.string(), + schema: z.string().optional(), + ifExists: z.preprocess( + (val) => val === "true" || val === true, + z.boolean().default(false), + ), + }), +); + +/** + * pg_doc_collection_info β€” get collection statistics + */ +export const CollectionInfoSchemaBase = z.object({ + collection: z.string().optional().describe("Collection name"), + schema: z.string().optional(), +}); + +export const CollectionInfoSchema = z.object({ + collection: z.string(), + schema: z.string().optional(), +}); + +/** + * pg_doc_find β€” query documents in a collection + */ +export const FindSchemaBase = z.object({ + collection: z.string().optional().describe("Collection name"), + schema: z.string().optional(), + filter: z + .unknown() + .optional() + .describe( + 'Filter: _id value (32-char hex), field=value, JSON object filter ({"field":"value"}), or JSON path existence ($.field)', + ), + fields: z + .unknown() + .optional() + .describe("Fields to project (returns full doc if omitted)"), + limit: z + .unknown() + .optional() + .describe("Maximum documents to return (default: 100)"), + offset: z + .unknown() + .optional() + .describe("Number of documents to skip (default: 0)"), +}); + +export const FindSchema = z.object({ + collection: z.string(), + schema: z.string().optional(), + filter: z.preprocess( + (val) => + typeof val === "object" && val !== null ? JSON.stringify(val) : val, + z.string().optional(), + ), + fields: z.preprocess((val) => { + if (typeof val === "string") return val.split(",").map((s) => s.trim()); + return val; + }, z.array(z.string()).optional()), + limit: z.preprocess( + (val) => (val !== undefined ? Number(val) : 50), + z.number().default(50), + ), + offset: z.preprocess( + (val) => (val !== undefined ? Number(val) : 0), + z.number().default(0), + ), +}); + +/** + * pg_doc_add β€” add documents to a collection + */ +export const AddDocSchemaBase = z.object({ + collection: z.string().optional().describe("Collection name"), + schema: z.unknown().optional(), + documents: z.unknown().optional().describe("Documents to add"), +}); + +export const AddDocSchema = z.object({ + collection: z.string(), + schema: z.string().optional(), + documents: z.array(z.record(z.string(), z.unknown())), +}); + +/** + * pg_doc_modify β€” update documents matching a filter + */ +export const ModifyDocSchemaBase = z.object({ + collection: z.string().optional().describe("Collection name"), + schema: z.string().optional(), + filter: z + .unknown() + .optional() + .describe( + 'Filter: _id value (32-char hex), field=value, JSON object filter ({"field":"value"}), or JSON path existence ($.field)', + ), + set: z.unknown().optional().describe("Fields to set (keyβ†’value)"), + unset: z + .unknown() + .optional() + .describe("Field names to remove from documents"), +}); + +export const ModifyDocSchema = z.object({ + collection: z.string(), + schema: z.string().optional(), + filter: z.preprocess( + (val) => + typeof val === "object" && val !== null ? JSON.stringify(val) : val, + z.string(), + ), + set: z.record(z.string(), z.unknown()).optional(), + unset: z.array(z.string()).optional(), +}); + +/** + * pg_doc_remove β€” remove documents matching a filter + */ +export const RemoveDocSchemaBase = z.object({ + collection: z.string().optional().describe("Collection name"), + schema: z.string().optional(), + filter: z + .unknown() + .optional() + .describe( + 'Filter: _id value (32-char hex), field=value, JSON object filter ({"field":"value"}), or JSON path existence ($.field)', + ), +}); + +export const RemoveDocSchema = z.object({ + collection: z.string(), + schema: z.string().optional(), + filter: z.preprocess( + (val) => + typeof val === "object" && val !== null ? JSON.stringify(val) : val, + z.string(), + ), +}); + +/** + * pg_doc_create_index β€” create an index on document fields + */ +export const CreateDocIndexSchemaBase = z.object({ + collection: z.string().optional().describe("Collection name"), + schema: z.string().optional(), + name: z.unknown().optional().describe("Index name (generated if omitted)"), + fields: z.unknown().optional().describe("Fields to index"), + field: z + .unknown() + .optional() + .describe("Alias for fields (single path string)"), + unique: z + .unknown() + .optional() + .describe("Create a UNIQUE index (default: false)"), +}); + +export const CreateDocIndexSchema = z.preprocess( + (val: unknown) => { + if (typeof val === "object" && val !== null) { + const obj = val as Record; + const processed: Record = { ...obj }; + // Map 'field' to 'fields' array if 'fields' is missing + if ( + processed["fields"] === undefined && + typeof processed["field"] === "string" + ) { + processed["fields"] = [{ path: processed["field"], type: "TEXT" }]; + } else if (typeof processed["fields"] === "string") { + // Handle comma-separated string for fields + processed["fields"] = processed["fields"] + .split(",") + .map((s) => ({ path: s.trim(), type: "TEXT" })); + } else if (Array.isArray(processed["fields"])) { + // Handle array of strings for fields + processed["fields"] = processed["fields"].map((f: unknown) => { + if (typeof f === "string") return { path: f, type: "TEXT" }; + return f; + }); + } + // Auto-generate name if missing, using collection and the first field + if ( + processed["name"] === undefined && + typeof processed["collection"] === "string" && + Array.isArray(processed["fields"]) && + processed["fields"].length > 0 + ) { + const firstField = processed["fields"][0] as Record; + const pathStr = + typeof firstField["path"] === "string" + ? firstField["path"] + : "unknown"; + processed["name"] = + `idx_${processed["collection"]}_${pathStr.replace(/[^a-zA-Z0-9]/g, "_")}`; + } + return processed; + } + return val; + }, + z.object({ + collection: z.string(), + schema: z.string().optional(), + name: z.string(), + fields: z.array( + z.object({ + path: z.string(), + type: z + .enum(["TEXT", "INT", "DOUBLE", "DATE", "TIMESTAMP", "BOOLEAN"]) + .default("TEXT"), + }), + ), + unique: z.preprocess( + (val) => val === "true" || val === true, + z.boolean().default(false), + ), + }), +); + +// ============================================================================= +// Output Schemas +// ============================================================================= + +/** + * pg_doc_list_collections output + */ +export const ListCollectionsOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether operation succeeded"), + collections: z + .array( + z.object({ + name: z.string().describe("Collection (table) name"), + rowCount: z.number().optional().describe("Estimated row count"), + size: z.string().optional().describe("Table size (pretty-printed)"), + }), + ) + .optional() + .describe("Document collections found"), + count: z.number().optional().describe("Number of collections"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_doc_create_collection output + */ +export const CreateCollectionOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether collection was created"), + collection: z.string().optional().describe("Collection name"), + skipped: z + .boolean() + .optional() + .describe("True if collection already existed (with ifNotExists)"), + reason: z.string().optional().describe("Reason for skipping"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_doc_drop_collection output + */ +export const DropCollectionOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether collection was dropped"), + collection: z.string().optional().describe("Collection name"), + message: z.string().optional().describe("Status message"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_doc_collection_info output + */ +export const CollectionInfoOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether operation succeeded"), + collection: z.string().optional().describe("Collection name"), + stats: z + .object({ + rowCount: z.number().describe("Exact row count"), + totalSize: z + .string() + .optional() + .describe("Total size (pretty-printed)"), + tableSize: z.string().optional().describe("Table data size"), + indexSize: z.string().optional().describe("Index size"), + }) + .optional() + .describe("Collection statistics"), + indexes: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe("Indexes on the collection"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_doc_find output + */ +export const FindOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether operation succeeded"), + documents: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe("Matching documents"), + count: z.number().optional().describe("Number of documents returned"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_doc_add output + */ +export const AddDocOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether documents were added"), + inserted: z.number().optional().describe("Number of documents inserted"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_doc_modify output + */ +export const ModifyDocOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether documents were modified"), + modified: z.number().optional().describe("Number of documents modified"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_doc_remove output + */ +export const RemoveDocOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether documents were removed"), + removed: z.number().optional().describe("Number of documents removed"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_doc_create_index output + */ +export const CreateDocIndexOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether index was created"), + index: z.string().optional().describe("Index name"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); diff --git a/src/adapters/postgresql/schemas/extension-exports.ts b/src/adapters/postgresql/schemas/extension-exports.ts index 7df57b5b..10404a3b 100644 --- a/src/adapters/postgresql/schemas/extension-exports.ts +++ b/src/adapters/postgresql/schemas/extension-exports.ts @@ -22,6 +22,8 @@ export { JsonbStatsSchemaBase, JsonbIndexSuggestSchemaBase, JsonbSecurityScanSchemaBase, + JsonbMergeSchemaBase, + JsonbDiffSchemaBase, // Full schemas (with preprocess - for handler parsing) JsonbExtractSchema, JsonbSetSchema, @@ -37,6 +39,8 @@ export { JsonbStatsSchema, JsonbIndexSuggestSchema, JsonbSecurityScanSchema, + JsonbMergeSchema, + JsonbDiffSchema, // Preprocess function for handlers preprocessJsonbParams, // Path normalization functions (for handler use) @@ -72,6 +76,25 @@ export { export { TextSearchSchema, TextSearchSchemaBase, + TextRankSchema, + TextRankSchemaBase, + HeadlineSchema, + HeadlineSchemaBase, + FtsIndexSchema, + FtsIndexSchemaBase, + FuzzyMatchSchema, + FuzzyMatchSchemaBase, + LikeSearchSchema, + LikeSearchSchemaBase, + SentimentSchema, + SentimentSchemaBase, + NormalizeSchema, + NormalizeSchemaBase, + ToVectorSchema, + ToVectorSchemaBase, + ToQuerySchema, + ToQuerySchemaBase, + TextSearchConfigSchemaBase, TrigramSimilaritySchema, TrigramSimilaritySchemaBase, RegexpMatchSchema, @@ -92,9 +115,21 @@ export { // Base schemas for MCP visibility (Split Schema pattern) VectorSearchSchemaBase, VectorCreateIndexSchemaBase, + HybridSearchSchemaBase, + PerformanceSchemaBase, + IndexOptimizeSchemaBase, + VectorDimensionReduceSchemaBase, + EmbedSchemaBase, + VectorClusterSchemaBase, // Transformed schemas for handler validation VectorSearchSchema, VectorCreateIndexSchema, + HybridSearchSchema, + PerformanceSchema, + IndexOptimizeSchema, + VectorDimensionReduceSchema, + EmbedSchema, + VectorClusterSchema, // Utilities FiniteNumberArray, // Output schemas @@ -227,6 +262,8 @@ export { // pg_partman schemas export { + PartmanCreateExtensionSchema, + PartmanCreateExtensionSchemaBase, PartmanCreateParentSchema, PartmanCreateParentSchemaBase, DEPRECATED_INTERVALS, @@ -244,7 +281,6 @@ export { PartmanRetentionSchemaBase, PartmanUndoPartitionSchema, PartmanUndoPartitionSchemaBase, - PartmanUpdateConfigSchema, PartmanAnalyzeHealthSchema, PartmanAnalyzeHealthSchemaBase, // Output schemas @@ -263,14 +299,13 @@ export { // Extension schemas (kcache, citext, ltree, pgcrypto) export { // pg_stat_kcache - KcacheQueryStatsSchemaBase, + KcacheCreateExtensionSchema, KcacheQueryStatsSchema, - KcacheTopCpuSchemaBase, - KcacheTopIoSchemaBase, - KcacheDatabaseStatsSchemaBase, + KcacheTopCpuSchema, + KcacheTopIoSchema, KcacheDatabaseStatsSchema, - KcacheResourceAnalysisSchemaBase, KcacheResourceAnalysisSchema, + KcacheResetSchema, // Kcache output schemas KcacheCreateExtensionOutputSchema, KcacheQueryStatsOutputSchema, @@ -300,6 +335,8 @@ export { CitextCompareOutputSchema, CitextSchemaAdvisorOutputSchema, // ltree + LtreeCreateExtensionSchemaBase, + LtreeCreateExtensionSchema, LtreeQuerySchema, LtreeQuerySchemaBase, LtreeSubpathSchema, diff --git a/src/adapters/postgresql/schemas/extensions/index.ts b/src/adapters/postgresql/schemas/extensions/index.ts index 80dec29d..dc2047db 100644 --- a/src/adapters/postgresql/schemas/extensions/index.ts +++ b/src/adapters/postgresql/schemas/extensions/index.ts @@ -13,14 +13,13 @@ export { normalizeOptionalParams } from "./shared.js"; // pg_stat_kcache schemas export { - KcacheQueryStatsSchemaBase, KcacheQueryStatsSchema, - KcacheTopCpuSchemaBase, - KcacheTopIoSchemaBase, - KcacheDatabaseStatsSchemaBase, + KcacheTopCpuSchema, + KcacheTopIoSchema, KcacheDatabaseStatsSchema, - KcacheResourceAnalysisSchemaBase, KcacheResourceAnalysisSchema, + KcacheCreateExtensionSchema, + KcacheResetSchema, KcacheCreateExtensionOutputSchema, KcacheQueryStatsOutputSchema, KcacheTopCpuOutputSchema, @@ -55,6 +54,8 @@ export { // ltree schemas export { + LtreeCreateExtensionSchemaBase, + LtreeCreateExtensionSchema, LtreeQuerySchemaBase, LtreeQuerySchema, LtreeSubpathSchemaBase, diff --git a/src/adapters/postgresql/schemas/extensions/kcache.ts b/src/adapters/postgresql/schemas/extensions/kcache.ts index a5a90fa9..a0920a32 100644 --- a/src/adapters/postgresql/schemas/extensions/kcache.ts +++ b/src/adapters/postgresql/schemas/extensions/kcache.ts @@ -5,8 +5,6 @@ */ import { z } from "zod"; -import { normalizeOptionalParams } from "./shared.js"; -import { coerceNumber } from "../../../../utils/query-helpers.js"; // ============================================================================= // Input Schemas @@ -16,140 +14,134 @@ import { coerceNumber } from "../../../../utils/query-helpers.js"; * Schema for querying enhanced statistics with kcache data. * Joins pg_stat_statements with pg_stat_kcache for full picture. */ -export const KcacheQueryStatsSchemaBase = z.object({ +export const KcacheQueryStatsSchema = z.object({ limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Maximum number of queries to return (default: 5, min: 1, max: 100).", ), + dbname: z.unknown().optional().describe("Filter by database name"), + username: z.unknown().optional().describe("Filter by username"), orderBy: z - .string() + .unknown() .optional() .describe( "Order results by metric (default: total_time). Valid: total_time, cpu_time, reads, writes", ), - minCalls: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Minimum call count to include"), + minCalls: z.unknown().optional().describe("Minimum call count to include"), queryPreviewLength: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Characters for query preview (default: 100, max: 500, 0 for full)", ), compact: z - .boolean() + .unknown() .optional() - .describe( - "If true, omits the query_preview text and 0/empty fields to save output tokens", - ), + .describe("If true, omits 0/empty fields to save output tokens"), }); -export const KcacheQueryStatsSchema = z.preprocess( - normalizeOptionalParams, - KcacheQueryStatsSchemaBase, -); - /** * Base schema for MCP visibility - pg_kcache_top_cpu parameters. */ -export const KcacheTopCpuSchemaBase = z.object({ +export const KcacheTopCpuSchema = z.object({ limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Number of top queries to return (default: 5, min: 1, max: 100).", ), queryPreviewLength: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Characters for query preview (default: 100, max: 500, 0 for full)", ), compact: z - .boolean() + .unknown() .optional() - .describe( - "If true, omits the query_preview text and 0/empty fields to save output tokens", - ), + .describe("If true, omits 0/empty fields to save output tokens"), }); /** * Base schema for MCP visibility - pg_kcache_top_io parameters. */ -export const KcacheTopIoSchemaBase = z.object({ - type: z.string().optional().describe("I/O type to rank by (default: both)"), - ioType: z.string().optional().describe("Alias for type"), +export const KcacheTopIoSchema = z.object({ + type: z.unknown().optional().describe("I/O type to rank by (default: both)"), + ioType: z.unknown().optional().describe("Alias for type"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Number of top queries to return (default: 5, min: 1, max: 100).", ), queryPreviewLength: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Characters for query preview (default: 100, max: 500, 0 for full)", ), compact: z - .boolean() + .unknown() .optional() - .describe( - "If true, omits the query_preview text and 0/empty fields to save output tokens", - ), + .describe("If true, omits 0/empty fields to save output tokens"), }); /** * Schema for database-level aggregation. */ -export const KcacheDatabaseStatsSchemaBase = z.object({ +export const KcacheDatabaseStatsSchema = z.object({ database: z - .string() + .unknown() .optional() .describe("Database name (all databases if omitted)"), compact: z - .boolean() + .unknown() .optional() .describe("If true, omits 0/empty fields to save output tokens"), }); -export const KcacheDatabaseStatsSchema = z.preprocess( - normalizeOptionalParams, - KcacheDatabaseStatsSchemaBase, -); - /** * Schema for identifying resource-bound queries. */ -export const KcacheResourceAnalysisSchemaBase = z.object({ +export const KcacheResourceAnalysisSchema = z.object({ queryId: z - .string() + .unknown() .optional() .describe("Specific query ID to analyze (all if omitted)"), threshold: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("CPU/IO ratio threshold for classification (default: 0.5)"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Maximum number of queries to return (default: 5, min: 1, max: 100).", ), - minCalls: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Minimum call count to include"), + minCalls: z.unknown().optional().describe("Minimum call count to include"), queryPreviewLength: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Characters for query preview (default: 100, max: 500, 0 for full)", ), compact: z - .boolean() + .unknown() .optional() - .describe( - "If true, omits the query_preview text and 0/empty fields to save output tokens", - ), + .describe("If true, omits 0/empty fields to save output tokens"), }); -export const KcacheResourceAnalysisSchema = z.preprocess( - normalizeOptionalParams, - KcacheResourceAnalysisSchemaBase, -); +/** + * Base schema for MCP visibility - pg_kcache_create_extension parameters. + */ +export const KcacheCreateExtensionSchema = z.object({}); + +/** + * Base schema for MCP visibility - pg_kcache_reset parameters. + */ +export const KcacheResetSchema = z.object({}); // ============================================================================= // Output Schemas @@ -192,7 +184,7 @@ export const KcacheQueryStatsOutputSchema = z export const KcacheTopCpuOutputSchema = z .object({ success: z.boolean().optional().describe("Whether query succeeded"), - topCpuQueries: z + queries: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Top CPU-consuming queries"), @@ -210,7 +202,7 @@ export const KcacheTopCpuOutputSchema = z export const KcacheTopIoOutputSchema = z .object({ success: z.boolean().optional().describe("Whether query succeeded"), - topIoQueries: z + queries: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Top I/O-consuming queries"), @@ -232,7 +224,7 @@ export const KcacheTopIoOutputSchema = z export const KcacheDatabaseStatsOutputSchema = z .object({ success: z.boolean().optional().describe("Whether query succeeded"), - databaseStats: z + stats: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Database-level statistics"), diff --git a/src/adapters/postgresql/schemas/extensions/ltree.ts b/src/adapters/postgresql/schemas/extensions/ltree.ts index cf411f42..47e2d9bc 100644 --- a/src/adapters/postgresql/schemas/extensions/ltree.ts +++ b/src/adapters/postgresql/schemas/extensions/ltree.ts @@ -54,6 +54,17 @@ function preprocessLtreeTableParams(input: unknown): unknown { // Base Schemas (MCP Visibility) // ============================================================================= +/** + * Base schema for MCP visibility - shows all parameters including aliases. + */ +export const LtreeCreateExtensionSchemaBase = z.object({ + schema: z.string().optional().describe("Schema name (default: public)"), +}); + +export const LtreeCreateExtensionSchema = z.object({ + schema: z.string().optional().describe("Schema name (default: public)"), +}); + /** * Base schema for MCP visibility - shows all parameters including aliases. */ @@ -78,6 +89,7 @@ export const LtreeQuerySchemaBase = z.object({ .describe("Alias for mode"), schema: z.string().optional().describe("Schema name (default: public)"), limit: z.number().optional().describe("Maximum results"), + maxResults: z.number().optional().describe("Alias for limit"), }); /** @@ -99,6 +111,7 @@ export const LtreeSubpathSchemaBase = z.object({ .optional() .describe("Number of labels (omit for rest of path)"), len: z.number().optional().describe("Alias for length"), + end: z.number().optional().describe("End index (calculates length)"), }); /** @@ -169,6 +182,10 @@ export const LtreeQuerySchema = z.preprocess( if ("type" in result && !("mode" in result)) { result["mode"] = result["type"]; } + // Alias: maxResults -> limit + if (result["maxResults"] !== undefined && result["limit"] === undefined) { + result["limit"] = result["maxResults"]; + } return result; }, z.object({ @@ -184,11 +201,7 @@ export const LtreeQuerySchema = z.preprocess( "Query mode: ancestors (@>), descendants (<@), or exact (default: descendants)", ), schema: z.string().optional().describe("Schema name (default: public)"), - limit: z - .number() - .min(1) - .default(50) - .describe("Maximum results (default: 50)"), + limit: z.number().default(50).describe("Maximum results (default: 50)"), }), ); @@ -288,11 +301,7 @@ export const LtreeMatchSchema = z.preprocess( .string() .describe('lquery pattern (e.g., "*.Science.*" or "Top.*{1,3}.Stars")'), schema: z.string().optional().describe("Schema name (default: public)"), - limit: z - .number() - .min(1) - .default(50) - .describe("Maximum results (default: 50)"), + limit: z.number().default(50).describe("Maximum results (default: 50)"), }), ); @@ -364,7 +373,7 @@ export const LtreeQueryOutputSchema = z path: z.string().optional().describe("Query path"), mode: z.string().optional().describe("Query mode"), isPattern: z.boolean().optional().describe("Whether query uses patterns"), - results: z + rows: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Query results"), @@ -415,7 +424,7 @@ export const LtreeMatchOutputSchema = z .object({ success: z.boolean().optional().describe("Whether match succeeded"), pattern: z.string().optional().describe("Query pattern"), - results: z + rows: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Matching results"), diff --git a/src/adapters/postgresql/schemas/extensions/pgcrypto.ts b/src/adapters/postgresql/schemas/extensions/pgcrypto.ts index 521e7f0e..e74ff259 100644 --- a/src/adapters/postgresql/schemas/extensions/pgcrypto.ts +++ b/src/adapters/postgresql/schemas/extensions/pgcrypto.ts @@ -37,7 +37,10 @@ export const PgcryptoCreateExtensionSchema = z.object({ */ export const PgcryptoHashSchemaBase = z.object({ data: z.string().optional().describe("Data to hash"), - algorithm: z.string().optional().describe("Hash algorithm"), + algorithm: z + .string() + .optional() + .describe("Hash algorithm (md5, sha1, sha224, sha256, sha384, sha512)"), encoding: z.string().optional().describe("Output encoding (default: hex)"), }); @@ -61,7 +64,10 @@ export const PgcryptoHashSchema = z.object({ export const PgcryptoHmacSchemaBase = z.object({ data: z.string().optional().describe("Data to authenticate"), key: z.string().optional().describe("Secret key for HMAC"), - algorithm: z.string().optional().describe("Hash algorithm"), + algorithm: z + .string() + .optional() + .describe("Hash algorithm (md5, sha1, sha224, sha256, sha384, sha512)"), encoding: z.string().optional().describe("Output encoding (default: hex)"), }); @@ -149,7 +155,7 @@ export const PgcryptoDecryptSchema = PgcryptoDecryptSchemaBase.transform( */ export const PgcryptoGenRandomUuidSchemaBase = z.object({ count: z - .preprocess(coerceNumber, z.number().optional()) + .number() .optional() .describe("Number of UUIDs to generate (default: 1, max: 100)"), }); @@ -178,7 +184,7 @@ export const PgcryptoGenRandomUuidSchema = z */ export const PgcryptoRandomBytesSchemaBase = z.object({ length: z - .preprocess(coerceNumber, z.number().optional()) + .number() .optional() .describe("Number of random bytes to generate (1-1024)"), encoding: z.string().optional().describe("Output encoding (default: hex)"), @@ -193,7 +199,7 @@ export const PgcryptoRandomBytesSchema = z .preprocess(coerceNumber, z.number().optional()) .describe("Number of random bytes to generate (1-1024)"), encoding: z - .enum(["hex", "base64"]) + .enum(["hex", "base64", "raw"]) .optional() .describe("Output encoding (default: hex)"), }) @@ -219,7 +225,7 @@ export const PgcryptoGenSaltSchemaBase = z.object({ .optional() .describe("Salt type: bf (bcrypt, recommended), md5, xdes, or des"), iterations: z - .preprocess(coerceNumber, z.number().optional()) + .number() .optional() .describe("Iteration count (for bf: 4-31, for xdes: odd 1-16777215)"), }); @@ -241,6 +247,7 @@ export const PgcryptoGenSaltSchema = z.object({ */ export const PgcryptoCryptSchemaBase = z.object({ password: z.string().optional().describe("Password to hash or verify"), + data: z.string().optional().describe("Alias for password"), salt: z .string() .optional() @@ -250,12 +257,20 @@ export const PgcryptoCryptSchemaBase = z.object({ /** * Schema for password hashing with crypt(). */ -export const PgcryptoCryptSchema = z.object({ - password: z.string().describe("Password to hash or verify"), - salt: z - .string() - .describe("Salt from gen_salt() or stored hash for verification"), -}); +export const PgcryptoCryptSchema = PgcryptoCryptSchemaBase.transform( + (payload) => { + return { + password: payload.password ?? payload.data, + salt: payload.salt, + }; + }, +) + .refine((data) => data.password !== undefined, { + message: "password (or data alias) is required", + }) + .refine((data) => data.salt !== undefined, { + message: "salt is required", + }); // ============================================================================= // Output Schemas diff --git a/src/adapters/postgresql/schemas/introspection/index.ts b/src/adapters/postgresql/schemas/introspection/index.ts index 24054c08..b54b1132 100644 --- a/src/adapters/postgresql/schemas/introspection/index.ts +++ b/src/adapters/postgresql/schemas/introspection/index.ts @@ -17,18 +17,6 @@ export { ConstraintAnalysisSchema, MigrationRisksSchemaBase, MigrationRisksSchema, - MigrationInitSchemaBase, - MigrationInitSchema, - MigrationRecordSchemaBase, - MigrationRecordSchema, - MigrationApplySchemaBase, - MigrationApplySchema, - MigrationRollbackSchemaBase, - MigrationRollbackSchema, - MigrationHistorySchemaBase, - MigrationHistorySchema, - MigrationStatusSchemaBase, - MigrationStatusSchema, } from "./input.js"; export { @@ -38,10 +26,4 @@ export { SchemaSnapshotOutputSchema, ConstraintAnalysisOutputSchema, MigrationRisksOutputSchema, - MigrationInitOutputSchema, - MigrationRecordOutputSchema, - MigrationApplyOutputSchema, - MigrationRollbackOutputSchema, - MigrationHistoryOutputSchema, - MigrationStatusOutputSchema, } from "./output.js"; diff --git a/src/adapters/postgresql/schemas/introspection/input.ts b/src/adapters/postgresql/schemas/introspection/input.ts index 167b2fbf..64427ff6 100644 --- a/src/adapters/postgresql/schemas/introspection/input.ts +++ b/src/adapters/postgresql/schemas/introspection/input.ts @@ -272,154 +272,3 @@ export const MigrationRisksSchema = z.preprocess( }, MigrationRisksSchemaBase.required({ statements: true }), ); - -// ============================================================================= -// Migration Tracking Input Schemas (Phase 2: Schema Version Tracking) -// ============================================================================= - -/** - * pg_migration_init input - */ -export const MigrationInitSchemaBase = z.object({ - schema: z - .string() - .optional() - .describe("Schema to create the tracking table in (default: public)"), -}); - -export const MigrationInitSchema = MigrationInitSchemaBase.default({}); - -/** - * pg_migration_record input - */ -export const MigrationRecordSchemaBase = z.object({ - version: z - .string() - .optional() - .describe("Version identifier (e.g., '1.0.0', '2024-01-15-add-users')"), - description: z - .string() - .optional() - .describe("Human-readable description of the migration"), - migrationSql: z - .string() - .optional() - .describe("The DDL/SQL statements applied"), - sql: z.string().optional().describe("Alias for migrationSql"), - query: z.string().optional().describe("Alias for migrationSql"), - rollbackSql: z.string().optional().describe("SQL to reverse this migration"), - sourceSystem: z - .string() - .optional() - .describe("Origin system (e.g., 'mysql', 'sqlite', 'manual', 'agent')"), - appliedBy: z - .string() - .optional() - .describe("Who/what applied this migration (e.g., agent name, user)"), -}); - -// Internal parse schema β€” version and migrationSql are required -const MigrationRecordParseSchema = z.object({ - version: z - .string() - .describe("Version identifier (e.g., '1.0.0', '2024-01-15-add-users')"), - description: z - .string() - .optional() - .describe("Human-readable description of the migration"), - migrationSql: z.string().describe("The DDL/SQL statements applied"), - rollbackSql: z.string().optional().describe("SQL to reverse this migration"), - sourceSystem: z - .string() - .optional() - .describe("Origin system (e.g., 'mysql', 'sqlite', 'manual', 'agent')"), - appliedBy: z - .string() - .optional() - .describe("Who/what applied this migration (e.g., agent name, user)"), -}); - -export const MigrationRecordSchema = z.preprocess((input: unknown) => { - if (typeof input === "object" && input !== null) { - const obj = input as Record; - if (obj["migrationSql"] === undefined) { - if (obj["sql"] !== undefined) return { ...obj, migrationSql: obj["sql"] }; - if (obj["query"] !== undefined) - return { ...obj, migrationSql: obj["query"] }; - } - } - return input; -}, MigrationRecordParseSchema); - -/** - * pg_migration_apply input - * Same fields as pg_migration_record β€” version and migrationSql required. - */ -export const MigrationApplySchemaBase = MigrationRecordSchemaBase; - -// Internal parse schema β€” version and migrationSql are required -export const MigrationApplySchema = MigrationRecordSchema; - -/** - * pg_migration_rollback input - */ -export const MigrationRollbackSchemaBase = z.object({ - id: z - .union([z.number(), z.string()]) - .optional() - .describe("Migration ID to roll back"), - version: z - .string() - .optional() - .describe("Migration version to roll back (alternative to id)"), - dryRun: z - .boolean() - .optional() - .describe( - "If true, return the rollback SQL without executing (default: false)", - ), -}); - -export const MigrationRollbackSchema = z.object({ - id: z.preprocess(coerceNumber, z.number().optional()).optional(), - version: z.string().optional(), - dryRun: z.boolean().optional(), -}); - -/** - * pg_migration_history input - */ -export const MigrationHistorySchemaBase = z.object({ - status: z.string().optional().describe("Filter by status"), - sourceSystem: z.string().optional().describe("Filter by source system"), - limit: z - .union([z.number(), z.string()]) - .optional() - .describe("Maximum records to return (default: 50)"), - offset: z - .union([z.number(), z.string()]) - .optional() - .describe("Offset for pagination (default: 0)"), -}); - -// Internal parse schema β€” coerces limit/offset types to prevent Zod leaks -export const MigrationHistorySchema = z - .object({ - status: z.enum(["applied", "recorded", "rolled_back", "failed"]).optional(), - sourceSystem: z.string().optional(), - limit: z.preprocess(coerceNumber, z.number().optional()).optional(), - offset: z.preprocess(coerceNumber, z.number().optional()).optional(), - }) - .default({}); - -/** - * pg_migration_status input - */ -export const MigrationStatusSchemaBase = z.object({ - schema: z - .string() - .optional() - .describe("Schema where the tracking table lives (default: public)"), -}); - -export const MigrationStatusSchema = MigrationStatusSchemaBase.default({}); diff --git a/src/adapters/postgresql/schemas/introspection/output.ts b/src/adapters/postgresql/schemas/introspection/output.ts index e922ab1c..456134f7 100644 --- a/src/adapters/postgresql/schemas/introspection/output.ts +++ b/src/adapters/postgresql/schemas/introspection/output.ts @@ -176,86 +176,3 @@ export const MigrationRisksOutputSchema = z error: z.string().optional(), }) .extend(ErrorResponseFields.shape); - -// ============================================================================= -// Migration Tracking Output Schemas -// ============================================================================= - -const MigrationRecordOutputEntry = z.object({ - id: z.number(), - version: z.string(), - description: z.string().nullable(), - appliedAt: z.string(), - appliedBy: z.string().nullable(), - migrationHash: z.string(), - sourceSystem: z.string().nullable(), - status: z.string(), - errorInformation: z.string().nullable().optional(), -}); - -export const MigrationInitOutputSchema = z - .object({ - success: z.boolean(), - tableCreated: z.boolean().optional(), - tableName: z.string().optional(), - existingRecords: z.number().optional(), - error: z.string().optional(), - }) - .extend(ErrorResponseFields.shape); - -export const MigrationRecordOutputSchema = z - .object({ - success: z.boolean(), - record: MigrationRecordOutputEntry.optional(), - error: z.string().optional(), - }) - .extend(ErrorResponseFields.shape); - -export const MigrationApplyOutputSchema = z - .object({ - success: z.boolean(), - record: MigrationRecordOutputEntry.optional(), - error: z.string().optional(), - }) - .extend(ErrorResponseFields.shape); - -export const MigrationRollbackOutputSchema = z - .object({ - success: z.boolean(), - dryRun: z.boolean().optional(), - rollbackSql: z.string().nullable().optional(), - record: MigrationRecordOutputEntry.optional(), - error: z.string().optional(), - }) - .extend(ErrorResponseFields.shape); - -export const MigrationHistoryOutputSchema = z - .object({ - records: z.array(MigrationRecordOutputEntry).optional(), - total: z.number().optional(), - limit: z.number().optional(), - offset: z.number().optional(), - success: z.boolean(), - error: z.string().optional(), - }) - .extend(ErrorResponseFields.shape); - -export const MigrationStatusOutputSchema = z - .object({ - initialized: z.boolean().optional(), - latestVersion: z.string().nullable().optional(), - latestAppliedAt: z.string().nullable().optional(), - counts: z - .object({ - total: z.number(), - applied: z.number(), - recorded: z.number(), - rolledBack: z.number(), - failed: z.number(), - }) - .optional(), - sourceSystems: z.array(z.string()).optional(), - success: z.boolean(), - error: z.string().optional(), - }) - .extend(ErrorResponseFields.shape); diff --git a/src/adapters/postgresql/schemas/jsonb/advanced.ts b/src/adapters/postgresql/schemas/jsonb/advanced.ts index ca9ffa25..90da9e0b 100644 --- a/src/adapters/postgresql/schemas/jsonb/advanced.ts +++ b/src/adapters/postgresql/schemas/jsonb/advanced.ts @@ -185,6 +185,34 @@ export const JsonbSecurityScanSchema = z.preprocess( JsonbSecurityScanSchemaRefined, ); +// ============== MERGE SCHEMA ============== +// Base schema (for MCP inputSchema visibility - no preprocess) +export const JsonbMergeSchemaBase = z.object({ + base: z.unknown().optional().describe("Base JSONB document"), + json1: z.unknown().optional().describe("Alias for base document"), + overlay: z.unknown().optional().describe("JSONB to merge on top"), + json2: z.unknown().optional().describe("Alias for overlay document"), + deep: z + .boolean() + .optional() + .describe("Deep merge nested objects (default: true)"), + mergeArrays: z + .boolean() + .optional() + .describe("Concatenate arrays instead of replacing (default: false)"), +}); + +export const JsonbMergeSchema = JsonbMergeSchemaBase; + +// ============== DIFF SCHEMA ============== +// Base schema (for MCP inputSchema visibility - no preprocess) +export const JsonbDiffSchemaBase = z.object({ + doc1: z.unknown().optional().describe("First JSONB object to compare"), + doc2: z.unknown().optional().describe("Second JSONB object to compare"), +}); + +export const JsonbDiffSchema = JsonbDiffSchemaBase; + // ============== OUTPUT SCHEMAS (MCP 2025-11-25 structuredContent) ============== // Output schema for pg_jsonb_extract @@ -327,6 +355,10 @@ export const JsonbKeysOutputSchema = z // Uses combined schema with optional fields instead of union with z.literal() to avoid Zod validation issues export const JsonbStripNullsOutputSchema = z .object({ + result: z + .unknown() + .optional() + .describe("Stripped JSON (if raw json provided)"), // Update mode fields rowsAffected: z.number().optional().describe("Number of rows updated"), // Preview mode fields diff --git a/src/adapters/postgresql/schemas/jsonb/basic.ts b/src/adapters/postgresql/schemas/jsonb/basic.ts index 04fee1e0..831a3cfd 100644 --- a/src/adapters/postgresql/schemas/jsonb/basic.ts +++ b/src/adapters/postgresql/schemas/jsonb/basic.ts @@ -89,6 +89,7 @@ export const JsonbSetSchemaBase = z.object({ ), value: z .unknown() + .optional() .describe("New value to set at the path (will be converted to JSONB)"), where: z .string() @@ -166,6 +167,9 @@ const JsonbContainsSchemaRefined = JsonbContainsSchemaBase.extend({ }) .refine((data) => data.column !== undefined || data.col !== undefined, { message: "Either 'column' or 'col' is required", + }) + .refine((data) => data.value !== undefined || data.contains !== undefined, { + message: "Either 'value' or 'contains' is required", }); // Full schema with preprocess (for handler parsing) @@ -256,7 +260,7 @@ export const JsonbInsertSchemaBase = z.object({ .describe( "Path to insert at (for arrays). Accepts both string and array formats.", ), - value: z.unknown().describe("Value to insert"), + value: z.unknown().optional().describe("Value to insert"), where: z.string().optional().describe("WHERE clause"), filter: z.string().optional().describe("WHERE clause (alias for where)"), insertAfter: z @@ -279,6 +283,9 @@ const JsonbInsertSchemaRefined = JsonbInsertSchemaBase.refine( }) .refine((data) => data.where !== undefined || data.filter !== undefined, { message: "Either 'where' or 'filter' is required", + }) + .refine((data) => data.value !== undefined, { + message: "value is required", }); // Full schema with preprocess (for handler parsing) @@ -415,8 +422,11 @@ export const JsonbKeysSchema = z.preprocess( ); // ============== STRIP NULLS SCHEMA ============== -// Base schema (for MCP inputSchema visibility - no preprocess) export const JsonbStripNullsSchemaBase = z.object({ + json: z + .unknown() + .optional() + .describe("Raw JSON string or object to strip nulls from"), table: z.string().optional().describe("Table name"), tableName: z.string().optional().describe("Table name (alias for table)"), column: z.string().optional().describe("JSONB column name"), @@ -432,11 +442,24 @@ export const JsonbStripNullsSchemaBase = z.object({ // Internal schema with refine (for handler validation) const JsonbStripNullsSchemaRefined = JsonbStripNullsSchemaBase.refine( - (data) => data.table !== undefined || data.tableName !== undefined, - { message: "Either 'table' or 'tableName' is required" }, -).refine((data) => data.column !== undefined || data.col !== undefined, { - message: "Either 'column' or 'col' is required", -}); + (data) => + data.json !== undefined || + data.table !== undefined || + data.tableName !== undefined, + { + message: + "Either 'json' (raw json) or 'table' + 'column' (table mode) is required", + }, +).refine( + (data) => + data.json !== undefined || + data.column !== undefined || + data.col !== undefined, + { + message: + "Either 'json' (raw json) or 'table' + 'column' (table mode) is required", + }, +); // Full schema with preprocess (for handler parsing) export const JsonbStripNullsSchema = z.preprocess( diff --git a/src/adapters/postgresql/schemas/migration/index.ts b/src/adapters/postgresql/schemas/migration/index.ts new file mode 100644 index 00000000..4b6741b7 --- /dev/null +++ b/src/adapters/postgresql/schemas/migration/index.ts @@ -0,0 +1,29 @@ +/** + * postgres-mcp - Migration Schemas Barrel + * + * Re-exports all migration input and output schemas. + */ + +export { + MigrationInitSchemaBase, + MigrationInitSchema, + MigrationRecordSchemaBase, + MigrationRecordSchema, + MigrationApplySchemaBase, + MigrationApplySchema, + MigrationRollbackSchemaBase, + MigrationRollbackSchema, + MigrationHistorySchemaBase, + MigrationHistorySchema, + MigrationStatusSchemaBase, + MigrationStatusSchema, +} from "./input.js"; + +export { + MigrationInitOutputSchema, + MigrationRecordOutputSchema, + MigrationApplyOutputSchema, + MigrationRollbackOutputSchema, + MigrationHistoryOutputSchema, + MigrationStatusOutputSchema, +} from "./output.js"; diff --git a/src/adapters/postgresql/schemas/migration/input.ts b/src/adapters/postgresql/schemas/migration/input.ts new file mode 100644 index 00000000..cf21402c --- /dev/null +++ b/src/adapters/postgresql/schemas/migration/input.ts @@ -0,0 +1,174 @@ +/** + * postgres-mcp - Migration Input Schemas + * + * Input validation schemas for migration tracking tools. + */ + +import { z } from "zod"; +import { coerceStrictNumber } from "../../../../utils/query-helpers.js"; + +// ============================================================================= +// Migration Tracking Input Schemas +// ============================================================================= + +/** + * pg_migration_init input + */ +export const MigrationInitSchemaBase = z.object({ + schema: z + .string() + .optional() + .describe("Schema to create the tracking table in (default: public)"), +}); + +export const MigrationInitSchema = MigrationInitSchemaBase.default({}); + +/** + * pg_migration_record input + */ +export const MigrationRecordSchemaBase = z.object({ + schema: z + .string() + .optional() + .describe("Schema where the tracking table lives (default: public)"), + version: z + .string() + .optional() + .describe("Version identifier (e.g., '1.0.0', '2024-01-15-add-users')"), + description: z + .string() + .optional() + .describe("Human-readable description of the migration"), + migrationSql: z + .string() + .optional() + .describe("The DDL/SQL statements applied"), + sql: z.string().optional().describe("Alias for migrationSql"), + query: z.string().optional().describe("Alias for migrationSql"), + rollbackSql: z.string().optional().describe("SQL to reverse this migration"), + sourceSystem: z + .string() + .optional() + .describe("Origin system (e.g., 'mysql', 'sqlite', 'manual', 'agent')"), + appliedBy: z + .string() + .optional() + .describe("Who/what applied this migration (e.g., agent name, user)"), +}); + +// Internal parse schema β€” version and migrationSql are required +const MigrationRecordParseSchema = z.object({ + schema: z.string().optional(), + version: z + .string() + .describe("Version identifier (e.g., '1.0.0', '2024-01-15-add-users')"), + description: z + .string() + .optional() + .describe("Human-readable description of the migration"), + migrationSql: z.string().describe("The DDL/SQL statements applied"), + rollbackSql: z.string().optional().describe("SQL to reverse this migration"), + sourceSystem: z + .string() + .optional() + .describe("Origin system (e.g., 'mysql', 'sqlite', 'manual', 'agent')"), + appliedBy: z + .string() + .optional() + .describe("Who/what applied this migration (e.g., agent name, user)"), +}); + +export const MigrationRecordSchema = z.preprocess((input: unknown) => { + if (typeof input === "object" && input !== null) { + const obj = input as Record; + if (obj["migrationSql"] === undefined) { + if (obj["sql"] !== undefined) return { ...obj, migrationSql: obj["sql"] }; + if (obj["query"] !== undefined) + return { ...obj, migrationSql: obj["query"] }; + } + } + return input; +}, MigrationRecordParseSchema); + +/** + * pg_migration_apply input + * Same fields as pg_migration_record β€” version and migrationSql required. + */ +export const MigrationApplySchemaBase = MigrationRecordSchemaBase; + +// Internal parse schema β€” version and migrationSql are required +export const MigrationApplySchema = MigrationRecordSchema; + +/** + * pg_migration_rollback input + */ +export const MigrationRollbackSchemaBase = z.object({ + schema: z + .string() + .optional() + .describe("Schema where the tracking table lives (default: public)"), + id: z + .union([z.number(), z.string()]) + .optional() + .describe("Migration ID to roll back"), + version: z + .string() + .optional() + .describe("Migration version to roll back (alternative to id)"), + dryRun: z + .boolean() + .optional() + .describe( + "If true, return the rollback SQL without executing (default: false)", + ), +}); + +export const MigrationRollbackSchema = z.object({ + schema: z.string().optional(), + id: z.preprocess(coerceStrictNumber, z.number().optional()).optional(), + version: z.string().optional(), + dryRun: z.boolean().optional(), +}); + +/** + * pg_migration_history input + */ +export const MigrationHistorySchemaBase = z.object({ + schema: z + .string() + .optional() + .describe("Schema where the tracking table lives (default: public)"), + status: z.string().optional().describe("Filter by status"), + sourceSystem: z.string().optional().describe("Filter by source system"), + limit: z + .union([z.number(), z.string()]) + .optional() + .describe("Maximum records to return (default: 50)"), + offset: z + .union([z.number(), z.string()]) + .optional() + .describe("Offset for pagination (default: 0)"), +}); + +// Internal parse schema β€” coerces limit/offset types to prevent Zod leaks +export const MigrationHistorySchema = z + .object({ + schema: z.string().optional(), + status: z.enum(["applied", "recorded", "rolled_back", "failed"]).optional(), + sourceSystem: z.string().optional(), + limit: z.preprocess(coerceStrictNumber, z.number().optional()).optional(), + offset: z.preprocess(coerceStrictNumber, z.number().optional()).optional(), + }) + .default({}); + +/** + * pg_migration_status input + */ +export const MigrationStatusSchemaBase = z.object({ + schema: z + .string() + .optional() + .describe("Schema where the tracking table lives (default: public)"), +}); + +export const MigrationStatusSchema = MigrationStatusSchemaBase.default({}); diff --git a/src/adapters/postgresql/schemas/migration/output.ts b/src/adapters/postgresql/schemas/migration/output.ts new file mode 100644 index 00000000..749d5d05 --- /dev/null +++ b/src/adapters/postgresql/schemas/migration/output.ts @@ -0,0 +1,91 @@ +/** + * postgres-mcp - Migration Output Schemas + * + * Output validation schemas for migration tracking tool results. + */ + +import { z } from "zod"; +import { ErrorResponseFields } from "../error-response-fields.js"; + +// ============================================================================= +// Migration Tracking Output Schemas +// ============================================================================= + +const MigrationRecordOutputEntry = z.object({ + id: z.number(), + version: z.string(), + description: z.string().nullable(), + appliedAt: z.string(), + appliedBy: z.string().nullable(), + migrationHash: z.string(), + sourceSystem: z.string().nullable(), + status: z.string(), + errorInformation: z.string().nullable().optional(), +}); + +export const MigrationInitOutputSchema = z + .object({ + success: z.boolean(), + tableCreated: z.boolean().optional(), + tableName: z.string().optional(), + existingRecords: z.number().optional(), + error: z.string().optional(), + }) + .extend(ErrorResponseFields.shape); + +export const MigrationRecordOutputSchema = z + .object({ + success: z.boolean(), + record: MigrationRecordOutputEntry.optional(), + error: z.string().optional(), + }) + .extend(ErrorResponseFields.shape); + +export const MigrationApplyOutputSchema = z + .object({ + success: z.boolean(), + record: MigrationRecordOutputEntry.optional(), + error: z.string().optional(), + }) + .extend(ErrorResponseFields.shape); + +export const MigrationRollbackOutputSchema = z + .object({ + success: z.boolean(), + dryRun: z.boolean().optional(), + rollbackSql: z.string().nullable().optional(), + record: MigrationRecordOutputEntry.optional(), + error: z.string().optional(), + }) + .extend(ErrorResponseFields.shape); + +export const MigrationHistoryOutputSchema = z + .object({ + records: z.array(MigrationRecordOutputEntry).optional(), + total: z.number().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + success: z.boolean(), + error: z.string().optional(), + }) + .extend(ErrorResponseFields.shape); + +export const MigrationStatusOutputSchema = z + .object({ + initialized: z.boolean().optional(), + latestVersion: z.string().nullable().optional(), + latestAppliedAt: z.string().nullable().optional(), + counts: z + .object({ + total: z.number(), + applied: z.number(), + recorded: z.number(), + rolledBack: z.number(), + failed: z.number(), + }) + .optional(), + sourceSystems: z.array(z.string()).optional(), + success: z.boolean(), + error: z.string().optional(), + }) + .extend(ErrorResponseFields.shape); diff --git a/src/adapters/postgresql/schemas/monitoring.ts b/src/adapters/postgresql/schemas/monitoring.ts index f75b03e5..349cdf90 100644 --- a/src/adapters/postgresql/schemas/monitoring.ts +++ b/src/adapters/postgresql/schemas/monitoring.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { ErrorResponseFields } from "./error-response-fields.js"; -import { coerceNumber } from "../../../utils/query-helpers.js"; +import { coerceStrictNumber } from "../../../utils/query-helpers.js"; // Helper to handle undefined params (allows tools to be called without {}) const defaultToEmpty = (val: unknown): unknown => val ?? {}; @@ -14,40 +14,48 @@ const defaultToEmpty = (val: unknown): unknown => val ?? {}; // Base schemas for MCP visibility (Split Schema pattern) export const DatabaseSizeSchemaBase = z.object({ database: z - .string() + .unknown() .optional() .describe("Database name (current if omitted)"), }); export const ConnectionStatsSchemaBase = z.object({ - database: z.string().optional().describe("Filter by specific database name"), + database: z.unknown().optional().describe("Filter by specific database name"), }); export const ConnectionStatsSchema = z.preprocess( defaultToEmpty, - ConnectionStatsSchemaBase, + ConnectionStatsSchemaBase.extend({ + database: z.string().optional(), + }), ); export const DatabaseSizeSchema = z.preprocess( defaultToEmpty, - DatabaseSizeSchemaBase, + DatabaseSizeSchemaBase.extend({ + database: z.string().optional(), + }), ); export const TableSizesSchemaBase = z.object({ - schema: z.string().optional().describe("Schema name exact match"), + schema: z.unknown().optional().describe("Schema name exact match"), pattern: z - .string() + .unknown() .optional() .describe("Table name pattern (LIKE syntax or exact)"), - table: z.string().optional().describe("Alias for pattern - table name"), - name: z.string().optional().describe("Alias for pattern - table name"), + table: z.unknown().optional().describe("Alias for pattern - table name"), + name: z.unknown().optional().describe("Alias for pattern - table name"), limit: z.unknown().optional().describe("Max tables to return"), }); export const TableSizesSchema = z.preprocess( defaultToEmpty, TableSizesSchemaBase.extend({ - limit: z.preprocess(coerceNumber, z.number().optional()).optional(), + schema: z.string().optional(), + pattern: z.string().optional(), + table: z.string().optional(), + name: z.string().optional(), + limit: z.preprocess(coerceStrictNumber, z.number().optional()).optional(), }).transform((data) => ({ schema: data.schema, pattern: data.pattern ?? data.table ?? data.name, @@ -57,66 +65,76 @@ export const TableSizesSchema = z.preprocess( export const ShowSettingsSchemaBase = z.object({ pattern: z - .string() + .unknown() .optional() .describe("Setting name pattern (LIKE syntax with %)"), like: z - .string() + .unknown() .optional() .describe("Alias for pattern - setting name or pattern"), setting: z - .string() + .unknown() .optional() .describe("Alias for pattern - setting name or pattern"), name: z - .string() + .unknown() .optional() .describe("Alias for pattern - setting name or pattern"), limit: z .unknown() .optional() - .describe("Max settings to return (default: 50 when no pattern specified)"), + .describe("Max settings to return (default: 15 when no pattern specified)"), }); export const ShowSettingsSchema = z.preprocess( defaultToEmpty, ShowSettingsSchemaBase.extend({ - limit: z.preprocess(coerceNumber, z.number().optional()).optional(), + pattern: z.string().optional(), + like: z.string().optional(), + setting: z.string().optional(), + name: z.string().optional(), + limit: z.preprocess(coerceStrictNumber, z.number().optional()).optional(), }).transform((data) => { // Resolve alias: like, setting or name β†’ pattern const pattern = data.pattern ?? data.like ?? data.setting ?? data.name; - // Default limit to 50 only when NO filter is specified (to avoid 415+ results) - const limit = data.limit ?? (pattern === undefined ? 50 : undefined); + // Default limit to 15 only when NO filter is specified (to avoid payload explosion) + const limit = data.limit ?? (pattern === undefined ? 15 : undefined); return { pattern, limit }; }), ); export const AlertThresholdSetSchemaBase = z.object({ metric: z - .string() + .unknown() .optional() .describe("Specific metric to set thresholds for"), warning_threshold: z - .string() + .unknown() .optional() .describe("Alias for warningThreshold"), warningThreshold: z - .string() + .unknown() .optional() .describe("Warning threshold (e.g. '70%')"), critical_threshold: z - .string() + .unknown() .optional() .describe("Alias for criticalThreshold"), criticalThreshold: z - .string() + .unknown() .optional() .describe("Critical threshold (e.g. '90%')"), }); export const AlertThresholdSetSchema = z.preprocess( defaultToEmpty, - AlertThresholdSetSchemaBase.transform((data) => ({ + AlertThresholdSetSchemaBase.extend({ + metric: z.string().optional(), + warning_threshold: z.string().optional(), + warningThreshold: z.string().optional(), + critical_threshold: z.string().optional(), + criticalThreshold: z.string().optional(), + }).transform((data) => ({ metric: data.metric, warningThreshold: data.warningThreshold ?? data.warning_threshold, criticalThreshold: data.criticalThreshold ?? data.critical_threshold, @@ -135,9 +153,9 @@ export const CapacityPlanningSchema = z.preprocess( defaultToEmpty, CapacityPlanningSchemaBase.extend({ projectionDays: z - .preprocess(coerceNumber, z.number().optional()) + .preprocess(coerceStrictNumber, z.number().optional()) .optional(), - days: z.preprocess(coerceNumber, z.number().optional()).optional(), + days: z.preprocess(coerceStrictNumber, z.number().optional()).optional(), }) .refine( (data) => { @@ -155,6 +173,13 @@ export const CapacityPlanningSchema = z.preprocess( })), ); +export const SystemHealthSchemaBase = z.object({}); + +export const SystemHealthSchema = z.preprocess( + defaultToEmpty, + SystemHealthSchemaBase, +); + // ============================================================================ // Output Schemas // ============================================================================ @@ -392,9 +417,9 @@ export const CapacityPlanningOutputSchema = z .extend(ErrorResponseFields.shape); /** - * pg_resource_usage_analyze output + * pg_system_health output */ -export const ResourceUsageAnalyzeOutputSchema = z +export const SystemHealthOutputSchema = z .object({ backgroundWriter: z .object({ diff --git a/src/adapters/postgresql/schemas/partman/input.ts b/src/adapters/postgresql/schemas/partman/input.ts index 02e29a0a..7ea65047 100644 --- a/src/adapters/postgresql/schemas/partman/input.ts +++ b/src/adapters/postgresql/schemas/partman/input.ts @@ -105,13 +105,35 @@ function preprocessPartmanParams(input: unknown): unknown { } // Auto-prefix public. for parentTable when no schema specified - if (result.parentTable && !result.parentTable.includes(".")) { + if ( + typeof result.parentTable === "string" && + !result.parentTable.includes(".") + ) { result.parentTable = `public.${result.parentTable}`; } return result; } +/** + * Schema for enabling the pg_partman extension. + */ +export const PartmanCreateExtensionSchemaBase = z.object({ + schema: z + .string() + .optional() + .describe("Schema to install the extension in (default: public)"), +}); + +export const PartmanCreateExtensionSchema = z + .preprocess( + preprocessPartmanParams, + z.object({ + schema: z.string().optional().default("public"), + }), + ) + .default({ schema: "public" }); + /** * Schema for creating a partition set with pg_partman. * Uses partman.create_parent() function. @@ -138,7 +160,7 @@ export const PartmanCreateParentSchemaBase = z.object({ 'Partition interval using PostgreSQL syntax (e.g., "1 month", "1 day", "1 week", "10000" for integer). Required.', ), premake: z - .union([z.number(), z.string()]) + .unknown() .optional() .describe("Number of partitions to create in advance (default: 4)"), startPartition: z @@ -150,11 +172,11 @@ export const PartmanCreateParentSchemaBase = z.object({ .optional() .describe("Template table for indexes/privileges (schema.table format)"), epochType: z - .enum(["seconds", "milliseconds", "nanoseconds"]) + .unknown() .optional() .describe("If control column is integer representing epoch time"), defaultPartition: z - .boolean() + .unknown() .optional() .describe("Create a default partition (default: true)"), }); @@ -187,13 +209,19 @@ export const PartmanRunMaintenanceSchemaBase = z.object({ table: z.string().optional().describe("Alias for parentTable"), name: z.string().optional().describe("Alias for parentTable"), analyze: z - .boolean() + .unknown() .optional() .describe("Run ANALYZE on new partitions (default: true)"), }); export const PartmanRunMaintenanceSchema = z - .preprocess(preprocessPartmanParams, PartmanRunMaintenanceSchemaBase) + .preprocess( + preprocessPartmanParams, + z.object({ + parentTable: z.string().optional(), + analyze: z.boolean().optional(), + }), + ) .default({}); /** @@ -210,15 +238,12 @@ export const PartmanShowPartitionsSchemaBase = z.object({ table: z.string().optional().describe("Alias for parentTable"), name: z.string().optional().describe("Alias for parentTable"), includeDefault: z - .boolean() + .unknown() .optional() .describe("Include default partition in results"), - order: z - .enum(["asc", "desc"]) - .optional() - .describe("Order of partitions by boundary"), + order: z.unknown().optional().describe("Order of partitions by boundary"), limit: z - .union([z.number(), z.string()]) + .unknown() .optional() .describe( "Maximum number of partitions to return (default: 50, use 0 for all)", @@ -249,7 +274,7 @@ export const PartmanShowConfigSchemaBase = z.object({ table: z.string().optional().describe("Alias for parentTable"), name: z.string().optional().describe("Alias for parentTable"), limit: z - .union([z.number(), z.string()]) + .unknown() .optional() .describe( "Maximum number of configs to return (default: 50, use 0 for all)", @@ -282,7 +307,12 @@ export const PartmanCheckDefaultSchemaBase = z.object({ }); export const PartmanCheckDefaultSchema = z - .preprocess(preprocessPartmanParams, PartmanCheckDefaultSchemaBase) + .preprocess( + preprocessPartmanParams, + z.object({ + parentTable: z.string().optional(), + }), + ) .default({}); /** @@ -299,11 +329,11 @@ export const PartmanPartitionDataSchemaBase = z.object({ table: z.string().optional().describe("Alias for parentTable"), name: z.string().optional().describe("Alias for parentTable"), batchSize: z - .union([z.number(), z.string()]) + .unknown() .optional() .describe("Rows to move per batch (default: varies by function)"), lockWaitSeconds: z - .union([z.number(), z.string()]) + .unknown() .optional() .describe("Lock wait timeout in seconds"), }); @@ -340,16 +370,23 @@ export const PartmanRetentionSchemaBase = z.object({ 'Retention period (e.g., "30 days"). Pass null or omit to disable/clear retention.', ), retentionKeepTable: z - .boolean() + .unknown() .optional() .describe( "Keep tables after detaching (true) or drop them (false). Default: false (DROP). Use true to preserve partition data.", ), - keepTable: z.boolean().optional().describe("Alias for retentionKeepTable"), + keepTable: z.unknown().optional().describe("Alias for retentionKeepTable"), }); export const PartmanRetentionSchema = z - .preprocess(preprocessPartmanParams, PartmanRetentionSchemaBase) + .preprocess( + preprocessPartmanParams, + z.object({ + parentTable: z.string().optional(), + retention: z.string().nullable().optional(), + retentionKeepTable: z.boolean().optional(), + }), + ) .default({}); /** @@ -370,12 +407,9 @@ export const PartmanUndoPartitionSchemaBase = z.object({ "Target table for consolidated data. Must exist before calling. Required.", ), target: z.string().optional().describe("Alias for targetTable"), - batchSize: z - .union([z.number(), z.string()]) - .optional() - .describe("Rows to move per batch"), + batchSize: z.unknown().optional().describe("Rows to move per batch"), keepTable: z - .boolean() + .unknown() .optional() .describe("Keep child tables after moving data"), }); @@ -392,37 +426,6 @@ export const PartmanUndoPartitionSchema = z ) .default({}); -/** - * Schema for updating partition configuration. - */ -export const PartmanUpdateConfigSchema = z.preprocess( - preprocessPartmanParams, - z.object({ - parentTable: z - .string() - .optional() - .describe("Parent table name (schema.table format)"), - premake: z.number().optional().describe("Number of partitions to pre-make"), - optimizeTrigger: z - .number() - .optional() - .describe("Trigger optimization threshold"), - optimizeConstraint: z - .number() - .optional() - .describe("Constraint optimization threshold"), - inheritFk: z - .boolean() - .optional() - .describe("Inherit foreign keys to children"), - retention: z.string().optional().describe("Retention period"), - retentionKeepTable: z - .boolean() - .optional() - .describe("Keep tables after detaching"), - }), -); - /** * Schema for analyzing partition health. */ @@ -434,7 +437,7 @@ export const PartmanAnalyzeHealthSchemaBase = z.object({ table: z.string().optional().describe("Alias for parentTable"), name: z.string().optional().describe("Alias for parentTable"), limit: z - .union([z.number(), z.string()]) + .unknown() .optional() .describe( "Maximum number of partition sets to analyze (default: 50, use 0 for all)", diff --git a/src/adapters/postgresql/schemas/partman/output.ts b/src/adapters/postgresql/schemas/partman/output.ts index ff9dfca1..3a4d5cf9 100644 --- a/src/adapters/postgresql/schemas/partman/output.ts +++ b/src/adapters/postgresql/schemas/partman/output.ts @@ -97,6 +97,7 @@ export const PartmanShowPartitionsOutputSchema = z */ export const PartmanShowConfigOutputSchema = z .object({ + success: z.boolean().optional().describe("Operation success"), configs: z .array( z.record(z.string(), z.unknown()).and( diff --git a/src/adapters/postgresql/schemas/performance.ts b/src/adapters/postgresql/schemas/performance.ts index b28c2ecb..c6c123d7 100644 --- a/src/adapters/postgresql/schemas/performance.ts +++ b/src/adapters/postgresql/schemas/performance.ts @@ -41,6 +41,27 @@ export function preprocessExplainParams(input: unknown): unknown { return result; } +/** + * Preprocess table params to normalize aliases. + * Exported so tools can apply it in their handlers. + */ +export function preprocessTableAliasParams(input: unknown): unknown { + const normalized = input ?? {}; + if (typeof normalized !== "object" || normalized === null) { + return normalized; + } + const result = { ...(normalized as Record) }; + + // Alias: tableName, name β†’ table + if (result["tableName"] !== undefined && result["table"] === undefined) { + result["table"] = result["tableName"]; + } else if (result["name"] !== undefined && result["table"] === undefined) { + result["table"] = result["name"]; + } + + return result; +} + // ============================================================================= // Base Schema (for MCP inputSchema visibility - no preprocess) // ============================================================================= @@ -77,6 +98,8 @@ export const ExplainSchema = z.preprocess( export const IndexStatsSchemaBase = z.object({ table: z.string().optional().describe("Table name (all tables if omitted)"), + tableName: z.string().optional().describe("Alias for table"), + name: z.string().optional().describe("Alias for table"), schema: z.string().optional().describe("Schema name"), limit: z .unknown() @@ -85,7 +108,10 @@ export const IndexStatsSchemaBase = z.object({ }); export const IndexStatsSchema = z.preprocess( - defaultToEmpty, + (input) => { + const tableMapped = preprocessTableAliasParams(input); + return defaultToEmpty(tableMapped); + }, z.object({ table: z.string().optional(), schema: z.string().optional(), @@ -95,6 +121,8 @@ export const IndexStatsSchema = z.preprocess( export const TableStatsSchemaBase = z.object({ table: z.string().optional().describe("Table name (all tables if omitted)"), + tableName: z.string().optional().describe("Alias for table"), + name: z.string().optional().describe("Alias for table"), schema: z.string().optional().describe("Schema name"), limit: z .unknown() @@ -103,7 +131,10 @@ export const TableStatsSchemaBase = z.object({ }); export const TableStatsSchema = z.preprocess( - defaultToEmpty, + (input) => { + const tableMapped = preprocessTableAliasParams(input); + return defaultToEmpty(tableMapped); + }, z.object({ table: z.string().optional(), schema: z.string().optional(), @@ -222,6 +253,10 @@ export const BloatCheckSchemaBase = z.object({ .optional() .describe("Table name to check (all tables if omitted)"), schema: z.string().optional().describe("Schema name to filter"), + limit: z + .unknown() + .optional() + .describe("Max rows to return (default: 20, use 0 for all)"), }); export const BloatCheckSchema = z.preprocess( @@ -229,6 +264,7 @@ export const BloatCheckSchema = z.preprocess( z.object({ table: z.string().optional(), schema: z.string().optional(), + limit: z.preprocess(coerceNumber, z.number().optional()), }), ); @@ -236,7 +272,7 @@ export const CacheHitRatioInputSchema = z.object({}); export const DiagnoseInputSchemaBase = z.object({ schema: z - .string() + .unknown() .optional() .describe("Filter top tables to a specific schema"), topN: z @@ -275,17 +311,20 @@ export const SeqScanTablesSchema = z.preprocess( ); export const IndexRecommendationsInputSchemaBase = z.object({ - table: z.string().optional().describe("Table name to analyze"), + table: z.unknown().optional().describe("Table name to analyze"), sql: z - .string() + .unknown() .optional() .describe("SQL query to analyze for index recommendations"), - query: z.string().optional().describe("Alias for sql - SQL query to analyze"), + query: z + .unknown() + .optional() + .describe("Alias for sql - SQL query to analyze"), params: z - .array(z.unknown()) + .unknown() .optional() .describe("Query parameters for $1, $2, etc. placeholders"), - schema: z.string().optional().describe("Schema name (default: public)"), + schema: z.unknown().optional().describe("Schema name (default: public)"), }); export const IndexRecommendationsInputSchema = z.preprocess((input) => { @@ -297,6 +336,186 @@ export const IndexRecommendationsInputSchema = z.preprocess((input) => { return result; }, IndexRecommendationsInputSchemaBase); +// ============================================================================= +// Migrated Input Schemas (from handlers) +// ============================================================================= + +export const PerformanceBaselineSchemaBase = z.object({ + name: z.unknown().optional().describe("Baseline name for reference"), +}); + +export const PerformanceBaselineSchema = z.preprocess( + defaultToEmpty, + PerformanceBaselineSchemaBase, +); + +export const ConnectionPoolOptimizeInputSchemaBase = z.object({}).strict(); +export const ConnectionPoolOptimizeInputSchema = + ConnectionPoolOptimizeInputSchemaBase; + +export const PartitionStrategySchemaBase = z.object({ + table: z.unknown().optional().describe("Table to analyze"), + schema: z.unknown().optional().describe("Schema name"), +}); + +export const PartitionStrategySchema = z.preprocess((input) => { + const defaultObj = defaultToEmpty(input); + return preprocessTableAliasParams(defaultObj); +}, PartitionStrategySchemaBase); + +export const UnusedIndexesSchemaBase = z.object({ + schema: z + .unknown() + .optional() + .describe("Schema to filter (default: all user schemas)"), + minSize: z + .unknown() + .optional() + .describe('Minimum index size to include (e.g., "1 MB")'), + limit: z + .unknown() + .optional() + .describe("Max indexes to return (default: 20, use 0 for all)"), + summary: z + .unknown() + .optional() + .describe("Return aggregated summary instead of full list"), +}); + +export const UnusedIndexesSchema = z.preprocess( + defaultToEmpty, + z.object({ + schema: z.string().optional(), + minSize: z.string().optional(), + limit: z.preprocess(coerceNumber, z.number().optional()), + summary: z.boolean().optional(), + }), +); + +export const DuplicateIndexesSchemaBase = z.object({ + schema: z + .string() + .optional() + .describe("Schema to filter (default: all user schemas)"), + limit: z + .number() + .optional() + .describe("Max rows to return (default: 50, use 0 for all)"), +}); + +export const DuplicateIndexesSchema = z.preprocess( + defaultToEmpty, + z.object({ + schema: z.string().optional(), + limit: z.preprocess(coerceNumber, z.number().optional()), + }), +); + +export const ConnectionSpikeInputBase = z.object({ + warningPercent: z + .unknown() + .optional() + .describe("Percentage threshold for flagging concentration (default: 70)"), +}); + +export const ConnectionSpikeInput = z.preprocess( + defaultToEmpty, + z.object({ + warningPercent: z.preprocess(coerceNumber, z.number().optional()), + }), +); + +export const QueryPlanCompareSchemaBase = z.object({ + query1: z.unknown().optional().describe("First SQL query"), + query2: z.unknown().optional().describe("Second SQL query"), + sql1: z.unknown().optional().describe("Alias for query1"), + sql2: z.unknown().optional().describe("Alias for query2"), + sqlA: z.unknown().optional().describe("Alias for query1"), + sqlB: z.unknown().optional().describe("Alias for query2"), + queryA: z.unknown().optional().describe("Alias for query1"), + queryB: z.unknown().optional().describe("Alias for query2"), + params1: z + .unknown() + .optional() + .describe("Parameters for first query ($1, $2, etc.)"), + params2: z + .unknown() + .optional() + .describe("Parameters for second query ($1, $2, etc.)"), + analyze: z + .unknown() + .optional() + .describe("Run EXPLAIN ANALYZE (executes queries)"), + compact: z + .unknown() + .optional() + .describe("Omit full execution plans from output to save tokens"), +}); + +export const QueryPlanCompareSchema = z.preprocess((input) => { + if (typeof input !== "object" || input === null) return input; + const obj = input as Record; + const result = { ...obj }; + if (result["query1"] === undefined) { + if (result["sql1"] !== undefined) result["query1"] = result["sql1"]; + else if (result["sqlA"] !== undefined) result["query1"] = result["sqlA"]; + else if (result["queryA"] !== undefined) + result["query1"] = result["queryA"]; + } + if (result["query2"] === undefined) { + if (result["sql2"] !== undefined) result["query2"] = result["sql2"]; + else if (result["sqlB"] !== undefined) result["query2"] = result["sqlB"]; + else if (result["queryB"] !== undefined) + result["query2"] = result["queryB"]; + } + return result; +}, QueryPlanCompareSchemaBase); + +export const QueryAnomaliesInputBase = z.object({ + threshold: z + .unknown() + .optional() + .describe( + "Standard deviation multiplier for anomaly detection (default: 2.0)", + ), + minCalls: z + .unknown() + .optional() + .describe("Minimum call count to filter noise (default: 10)"), + limit: z + .unknown() + .optional() + .describe("Max anomalies to return (default: 20, max: 50)"), +}); + +export const QueryAnomaliesInput = z.preprocess( + defaultToEmpty, + z.object({ + threshold: z.preprocess(coerceNumber, z.number().optional()), + minCalls: z.preprocess(coerceNumber, z.number().optional()), + limit: z.preprocess(coerceNumber, z.number().optional()), + }), +); + +export const BloatRiskInputBase = z.object({ + schema: z + .string() + .optional() + .describe("Filter to a specific schema (default: all user schemas)"), + minRows: z + .unknown() + .optional() + .describe("Minimum live rows to include (default: 1000)"), +}); + +export const BloatRiskInput = z.preprocess( + defaultToEmpty, + z.object({ + schema: z.string().optional(), + minRows: z.preprocess(coerceNumber, z.number().optional()), + }), +); + // ============================================================================= // Output Schemas // ============================================================================= @@ -419,6 +638,14 @@ export const BloatCheckOutputSchema = z .optional() .describe("Tables with bloat"), count: z.number().optional().describe("Number of tables with bloat"), + totalCount: z + .number() + .optional() + .describe("Total count if results truncated"), + truncated: z + .boolean() + .optional() + .describe("Whether results were truncated"), success: z.boolean().optional().describe("Whether operation succeeded"), error: z.string().optional().describe("Error message if failed"), }) diff --git a/src/adapters/postgresql/schemas/postgis/advanced.ts b/src/adapters/postgresql/schemas/postgis/advanced.ts index e3ca5e8e..5a1263dc 100644 --- a/src/adapters/postgresql/schemas/postgis/advanced.ts +++ b/src/adapters/postgresql/schemas/postgis/advanced.ts @@ -101,6 +101,18 @@ export const GeoClusterSchemaBase = z.object({ .preprocess(coerceNumber, z.number().optional()) .optional() .describe("DBSCAN: Distance threshold"), + distance: z + .preprocess(coerceNumber, z.number().optional()) + .optional() + .describe("Alias for eps"), + radius: z + .preprocess(coerceNumber, z.number().optional()) + .optional() + .describe("Alias for eps"), + epsg: z + .preprocess(coerceNumber, z.number().optional()) + .optional() + .describe("Alias for eps (user typo fallback)"), minPoints: z .preprocess(coerceNumber, z.number().optional()) .optional() @@ -139,7 +151,8 @@ export const GeoClusterSchema = z schema: data.schema, column: data.column ?? data.geom ?? data.geometryColumn ?? "", method: data.method ?? data.algorithm, - eps: data.eps ?? paramsObj.eps, + eps: + data.eps ?? data.distance ?? data.radius ?? data.epsg ?? paramsObj.eps, minPoints: data.minPoints ?? paramsObj.minPoints, numClusters: data.numClusters ?? @@ -223,9 +236,8 @@ export const GeometryBufferSchema = GeometryBufferSchemaBase.transform( .refine((data) => data.geometry !== "", { message: "geometry (or wkt/geojson alias) is required", }) - .refine((data) => data.distance > 0, { - message: - "distance (or radius/meters alias) is required and must be positive", + .refine((data) => data.distance !== 0, { + message: "distance (or radius/meters alias) is required and cannot be zero", }) .refine((data) => data.simplify === undefined || data.simplify >= 0, { message: "simplify must be a non-negative number if provided", @@ -243,14 +255,16 @@ export const GeometryBufferSchema = GeometryBufferSchemaBase.transform( // pg_geometry_intersection export const GeometryIntersectionSchemaBase = z.object({ geometry1: z.string().optional().describe("First WKT or GeoJSON geometry"), + geom1: z.string().optional().describe("Alias for geometry1"), geometry2: z.string().optional().describe("Second WKT or GeoJSON geometry"), + geom2: z.string().optional().describe("Alias for geometry2"), }); export const GeometryIntersectionSchema = GeometryIntersectionSchemaBase.partial() .transform((data) => ({ - geometry1: data.geometry1 ?? "", - geometry2: data.geometry2 ?? "", + geometry1: data.geometry1 ?? data.geom1 ?? "", + geometry2: data.geometry2 ?? data.geom2 ?? "", })) .refine((data) => data.geometry1 !== "", { message: "geometry1 is required", @@ -285,16 +299,13 @@ export const GeometryTransformSchemaBase = z.object({ export const GeometryTransformSchema = GeometryTransformSchemaBase.transform( (data) => ({ geometry: data.geometry ?? data.wkt ?? data.geojson ?? "", - fromSrid: data.fromSrid ?? data.sourceSrid ?? 0, + fromSrid: data.fromSrid ?? data.sourceSrid ?? 4326, toSrid: data.toSrid ?? data.targetSrid ?? 0, }), ) .refine((data) => data.geometry !== "", { message: "geometry (or wkt/geojson alias) is required", }) - .refine((data) => data.fromSrid > 0, { - message: "fromSrid (or sourceSrid alias) is required", - }) .refine((data) => data.toSrid > 0, { message: "toSrid (or targetSrid alias) is required", }); diff --git a/src/adapters/postgresql/schemas/postgis/basic.ts b/src/adapters/postgresql/schemas/postgis/basic.ts index 5ac14e61..2981de8c 100644 --- a/src/adapters/postgresql/schemas/postgis/basic.ts +++ b/src/adapters/postgresql/schemas/postgis/basic.ts @@ -147,17 +147,26 @@ export const GeometryDistanceSchemaBase = z.object({ export const GeometryDistanceSchema = z .preprocess(preprocessPostgisParams, GeometryDistanceSchemaBase) .transform((data) => { - const point = preprocessPoint(data.point); + let point = preprocessPoint(data.point); + if (!point) { + const lat = data.lat ?? data.latitude ?? data.y; + const lng = data.lng ?? data.lon ?? data.longitude ?? data.x; + if (lat !== undefined && lng !== undefined) { + point = { lat, lng }; + } + } const rawDistance = data.maxDistance ?? data.radius ?? data.distance; return { table: data.table ?? data.tableName ?? "", column: data.column ?? data.geom ?? data.geometry ?? data.geometryColumn ?? "", - point: point ?? { lat: 0, lng: 0 }, + point: point ?? { lat: NaN, lng: NaN }, limit: data.limit, maxDistance: rawDistance !== undefined - ? convertToMeters(rawDistance, data.unit) + ? Number.isNaN(rawDistance) + ? NaN + : convertToMeters(rawDistance, data.unit) : undefined, unit: data.unit, schema: data.schema, @@ -166,7 +175,12 @@ export const GeometryDistanceSchema = z .refine((data) => data.table !== "", { message: "table (or tableName alias) is required", }) - + .refine( + (data) => !Number.isNaN(data.point.lat) && !Number.isNaN(data.point.lng), + { + message: "point (or lat/lng) is required", + }, + ) .refine((data) => data.maxDistance === undefined || data.maxDistance >= 0, { message: "distance must be a non-negative number", }) @@ -179,12 +193,22 @@ export const GeometryDistanceSchema = z "unit must be a valid distance unit (meters, m, kilometers, km, miles, mi)", }, ) - .refine((data) => data.point.lat >= -90 && data.point.lat <= 90, { - message: "lat must be between -90 and 90 degrees", - }) - .refine((data) => data.point.lng >= -180 && data.point.lng <= 180, { - message: "lng must be between -180 and 180 degrees", - }); + .refine( + (data) => + Number.isNaN(data.point.lat) || + (data.point.lat >= -90 && data.point.lat <= 90), + { + message: "lat must be between -90 and 90 degrees", + }, + ) + .refine( + (data) => + Number.isNaN(data.point.lng) || + (data.point.lng >= -180 && data.point.lng <= 180), + { + message: "lng must be between -180 and 180 degrees", + }, + ); // ============================================================================= // pg_point_in_polygon @@ -227,18 +251,30 @@ export const PointInPolygonSchemaBase = z.object({ .preprocess(coerceNumber, z.number().optional()) .optional() .describe("Y coordinate"), + limit: z + .preprocess(coerceNumber, z.number().optional()) + .optional() + .describe("Maximum rows to return (default: 10 to prevent large payloads)"), schema: z.string().optional().describe("Schema name (default: public)"), }); export const PointInPolygonSchema = z .preprocess(preprocessPostgisParams, PointInPolygonSchemaBase) .transform((data) => { - const point = preprocessPoint(data.point); + let point = preprocessPoint(data.point); + if (!point) { + const lat = data.lat ?? data.latitude ?? data.y; + const lng = data.lng ?? data.lon ?? data.longitude ?? data.x; + if (lat !== undefined && lng !== undefined) { + point = { lat, lng }; + } + } return { table: data.table ?? data.tableName ?? "", column: data.column ?? data.geom ?? data.geometry ?? data.geometryColumn ?? "", - point: point ?? { lat: 0, lng: 0 }, + point: point ?? { lat: NaN, lng: NaN }, + limit: data.limit, schema: data.schema, }; }) @@ -248,12 +284,28 @@ export const PointInPolygonSchema = z .refine((data) => data.column !== "", { message: "column (or geom/geometry/geometryColumn alias) is required", }) - .refine((data) => data.point.lat >= -90 && data.point.lat <= 90, { - message: "lat must be between -90 and 90 degrees", - }) - .refine((data) => data.point.lng >= -180 && data.point.lng <= 180, { - message: "lng must be between -180 and 180 degrees", - }); + .refine( + (data) => !Number.isNaN(data.point.lat) && !Number.isNaN(data.point.lng), + { + message: "point (or lat/lng) is required", + }, + ) + .refine( + (data) => + Number.isNaN(data.point.lat) || + (data.point.lat >= -90 && data.point.lat <= 90), + { + message: "lat must be between -90 and 90 degrees", + }, + ) + .refine( + (data) => + Number.isNaN(data.point.lng) || + (data.point.lng >= -180 && data.point.lng <= 180), + { + message: "lng must be between -180 and 180 degrees", + }, + ); // ============================================================================= // pg_spatial_index @@ -356,9 +408,8 @@ export const BufferSchema = z .refine((data) => data.column !== "", { message: "column (or geom/geometryColumn alias) is required", }) - .refine((data) => data.distance > 0, { - message: - "distance (or radius/meters alias) is required and must be positive", + .refine((data) => data.distance !== 0, { + message: "distance (or radius/meters alias) is required and cannot be zero", }) .refine((data) => data.simplify === undefined || data.simplify >= 0, { message: @@ -427,7 +478,7 @@ export const IntersectionSchemaBase = z.object({ limit: z .preprocess(coerceNumber, z.number().optional()) .optional() - .describe("Max results"), + .describe("Maximum rows to return (default: 10 to prevent large payloads)"), select: z.array(z.string()).optional().describe("Columns to select"), }); @@ -489,7 +540,7 @@ export const BoundingBoxSchemaBase = z.object({ limit: z .preprocess(coerceNumber, z.number().optional()) .optional() - .describe("Max results"), + .describe("Maximum rows to return (default: 10 to prevent large payloads)"), select: z.array(z.string()).optional().describe("Columns to select"), }); diff --git a/src/adapters/postgresql/schemas/postgis/output.ts b/src/adapters/postgresql/schemas/postgis/output.ts index 002d0dcd..2898e5d6 100644 --- a/src/adapters/postgresql/schemas/postgis/output.ts +++ b/src/adapters/postgresql/schemas/postgis/output.ts @@ -62,7 +62,7 @@ export const PointInPolygonOutputSchema = z export const DistanceOutputSchema = z .object({ success: z.boolean().optional().describe("Whether operation succeeded"), - results: z + rows: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Nearby geometries with distances"), @@ -77,7 +77,7 @@ export const DistanceOutputSchema = z export const BufferOutputSchema = z .object({ success: z.boolean().optional().describe("Whether operation succeeded"), - results: z + rows: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Buffer results"), @@ -118,7 +118,7 @@ export const IntersectionOutputSchema = z export const BoundingBoxOutputSchema = z .object({ success: z.boolean().optional().describe("Whether operation succeeded"), - results: z + rows: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Geometries in bounding box"), @@ -164,7 +164,7 @@ export const GeocodeOutputSchema = z export const GeoTransformOutputSchema = z .object({ success: z.boolean().optional().describe("Whether operation succeeded"), - results: z + rows: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Transformed geometries"), diff --git a/src/adapters/postgresql/schemas/postgis/utils.ts b/src/adapters/postgresql/schemas/postgis/utils.ts index 9ced9628..4dd8301a 100644 --- a/src/adapters/postgresql/schemas/postgis/utils.ts +++ b/src/adapters/postgresql/schemas/postgis/utils.ts @@ -45,7 +45,7 @@ export function preprocessPostgisParams(input: unknown): unknown { result["point"] === undefined || (typeof result["point"] === "object" && result["point"] !== null && - Object.keys(result["point"] as Record).length === 0) + Object.keys(result["point"]).length === 0) ) { const lat = result["lat"] ?? result["latitude"] ?? result["y"]; const lng = diff --git a/src/adapters/postgresql/schemas/roles.ts b/src/adapters/postgresql/schemas/roles.ts new file mode 100644 index 00000000..c473917e --- /dev/null +++ b/src/adapters/postgresql/schemas/roles.ts @@ -0,0 +1,599 @@ +/** + * postgres-mcp - Role Management Tool Schemas + * + * Input validation and output schemas for role management tools. + * 12 tools: list, create, drop, attributes, grants, grant, assign, + * revoke, user_roles, set, rls_enable, rls_policies. + */ + +import { z } from "zod"; +import { ErrorResponseFields } from "./error-response-fields.js"; + +// Helper to handle undefined params (allows tools to be called without {}) +const defaultToEmpty = (val: unknown): unknown => val ?? {}; + +// ============================================================================= +// Input Schemas (Split Schema pattern: Base for MCP, Preprocessed for handler) +// ============================================================================= + +/** + * pg_role_list β€” list all roles with optional pattern filter + */ +export const RoleListSchemaBase = z.object({ + pattern: z + .string() + .optional() + .describe("Filter roles by name pattern (SQL LIKE syntax, e.g. 'admin%')"), + includeSystem: z + .boolean() + .optional() + .describe("Include system roles (pg_* prefixed, default: false)"), + limit: z + .number() + .optional() + .describe("Maximum number of roles to return (default: 50)"), +}); + +export const RoleListSchema = z.preprocess(defaultToEmpty, RoleListSchemaBase); + +/** + * pg_role_create β€” create a new role with optional attributes + */ +export const RoleCreateSchemaBase = z.object({ + name: z.string().describe("Name for the new role"), + ifNotExists: z + .boolean() + .optional() + .describe("Skip without error if role already exists (default: true)"), + login: z + .boolean() + .optional() + .describe("Allow role to log in (default: false)"), + password: z.string().optional().describe("Password for login roles"), + superuser: z + .boolean() + .optional() + .describe("Grant superuser privilege (default: false)"), + createdb: z + .boolean() + .optional() + .describe("Allow creating databases (default: false)"), + createrole: z + .boolean() + .optional() + .describe("Allow creating other roles (default: false)"), + replication: z + .boolean() + .optional() + .describe("Allow replication connections (default: false)"), + bypassrls: z + .boolean() + .optional() + .describe("Bypass row-level security (default: false)"), + connectionLimit: z + .number() + .optional() + .describe("Maximum concurrent connections (-1 = unlimited)"), + validUntil: z + .string() + .optional() + .describe("Password expiration timestamp (ISO 8601)"), + inRoles: z + .array(z.string()) + .optional() + .describe("Roles to grant membership in upon creation"), +}); + +export const RoleCreateSchema = RoleCreateSchemaBase; + +/** + * pg_role_drop β€” drop a role + */ +export const RoleDropSchemaBase = z.object({ + name: z.string().describe("Name of the role to drop"), + ifExists: z + .boolean() + .optional() + .describe("Skip without error if role does not exist (default: true)"), +}); + +export const RoleDropSchema = RoleDropSchemaBase; + +/** + * pg_role_attributes β€” get detailed role attributes + */ +export const RoleAttributesSchemaBase = z.object({ + role: z.string().describe("Role name to inspect"), +}); + +export const RoleAttributesSchema = RoleAttributesSchemaBase; + +/** + * pg_role_grants β€” show privileges granted to a role + */ +export const RoleGrantsSchemaBase = z.object({ + role: z.string().describe("Role name to inspect"), + includeTableGrants: z + .boolean() + .optional() + .describe("Include object-level (table/schema) grants (default: true)"), +}); + +export const RoleGrantsSchema = RoleGrantsSchemaBase; + +/** + * pg_role_grant β€” grant privileges on objects to a role + */ +export const RoleGrantSchemaBase = z.object({ + role: z.string().describe("Role to grant privileges to"), + privileges: z + .array(z.string()) + .describe( + "Privileges to grant (SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, CREATE, CONNECT, TEMPORARY, EXECUTE, USAGE, ALL PRIVILEGES)", + ), + schema: z + .string() + .optional() + .describe("Schema containing the target object (default: 'public')"), + table: z + .string() + .optional() + .describe( + "Table name or '*' for all tables in schema. Omit for schema-level grants.", + ), + tableName: z.string().optional().describe("Alias for table"), + objectType: z + .string() + .optional() + .describe( + "Object type: 'TABLE' (default), 'SCHEMA', 'SEQUENCE', 'FUNCTION', 'ALL TABLES IN SCHEMA', 'ALL SEQUENCES IN SCHEMA'", + ), + withGrantOption: z + .boolean() + .optional() + .describe("Allow grantee to re-grant the privilege (default: false)"), +}); + +export const RoleGrantSchema = z.preprocess((val: unknown) => { + if (val === null || typeof val !== "object") return val; + const obj = val as Record; + return { + ...obj, + table: obj["table"] ?? obj["tableName"], + }; +}, RoleGrantSchemaBase); + +/** + * pg_role_assign β€” grant role membership to another role/user + */ +export const RoleAssignSchemaBase = z.object({ + role: z.string().describe("Role to grant (the membership)"), + user: z.string().describe("User/role that receives the membership"), + member: z.string().optional().describe("Alias for user"), + withAdminOption: z + .boolean() + .optional() + .describe( + "Allow the user to grant/revoke this role to/from others (default: false)", + ), + withSet: z + .boolean() + .optional() + .describe( + "Allow the user to SET ROLE to this role (PG 16+, default: true)", + ), +}); + +export const RoleAssignSchema = z.preprocess((val: unknown) => { + if (val === null || typeof val !== "object") return val; + const obj = val as Record; + return { + ...obj, + user: obj["user"] ?? obj["member"], + }; +}, RoleAssignSchemaBase); + +/** + * pg_role_revoke β€” revoke role or privileges from a user/role + */ +export const RoleRevokeSchemaBase = z.object({ + role: z + .string() + .describe( + "Role to revoke membership of, OR role to revoke privileges from (when privileges are specified)", + ), + user: z + .string() + .optional() + .describe( + "User/role to revoke from (for membership revocation). Required when revoking role membership.", + ), + member: z.string().optional().describe("Alias for user"), + privileges: z + .array(z.string()) + .optional() + .describe( + "Privileges to revoke (when revoking object privileges instead of membership)", + ), + schema: z + .string() + .optional() + .describe("Schema containing the target object (default: 'public')"), + table: z + .string() + .optional() + .describe("Table name for object-level privilege revocation"), + tableName: z.string().optional().describe("Alias for table"), + objectType: z + .string() + .optional() + .describe( + "Object type for privilege revocation: 'TABLE' (default), 'SCHEMA', 'SEQUENCE', 'FUNCTION', 'ALL TABLES IN SCHEMA', 'ALL SEQUENCES IN SCHEMA'", + ), +}); + +export const RoleRevokeSchema = z.preprocess((val: unknown) => { + if (val === null || typeof val !== "object") return val; + const obj = val as Record; + return { + ...obj, + user: obj["user"] ?? obj["member"], + table: obj["table"] ?? obj["tableName"], + }; +}, RoleRevokeSchemaBase); + +/** + * pg_user_roles β€” list roles assigned to a user/role + */ +export const UserRolesSchemaBase = z.object({ + user: z.string().describe("User/role name to inspect"), + role: z.string().optional().describe("Alias for user"), +}); + +export const UserRolesSchema = z.preprocess((val: unknown) => { + if (val === null || typeof val !== "object") return val; + const obj = val as Record; + return { + ...obj, + user: obj["user"] ?? obj["role"], + }; +}, UserRolesSchemaBase); + +/** + * pg_role_set β€” set session's active role + */ +export const RoleSetSchemaBase = z.object({ + role: z + .string() + .optional() + .describe("Role to switch to. Omit (or use reset: true) to reset."), + reset: z + .boolean() + .optional() + .describe("Reset to the original session role (default: false)"), +}); + +export const RoleSetSchema = z.preprocess(defaultToEmpty, RoleSetSchemaBase); + +/** + * pg_role_rls_enable β€” enable/disable row-level security on a table + */ +export const RoleRlsEnableSchemaBase = z.object({ + table: z.string().describe("Table name to enable/disable RLS on"), + schema: z.string().optional().describe("Schema name (default: 'public')"), + enable: z + .boolean() + .optional() + .describe("Enable (true) or disable (false) RLS (default: true)"), + force: z + .boolean() + .optional() + .describe( + "When true, RLS applies even to the table owner (FORCE ROW LEVEL SECURITY). Default: false.", + ), +}); + +export const RoleRlsEnableSchema = RoleRlsEnableSchemaBase; + +/** + * pg_role_rls_policies β€” list RLS policies on a table + */ +export const RoleRlsPoliciesSchemaBase = z.object({ + table: z + .string() + .optional() + .describe("Table name to list policies for. Omit for all tables."), + schema: z.string().optional().describe("Schema name (default: 'public')"), +}); + +export const RoleRlsPoliciesSchema = z.preprocess( + defaultToEmpty, + RoleRlsPoliciesSchemaBase, +); + +// ============================================================================= +// Output Schemas +// ============================================================================= + +/** + * pg_role_list output + */ +export const RoleListOutputSchema = z + .object({ + roles: z + .array( + z.object({ + name: z.string().describe("Role name"), + login: z.boolean().describe("Can log in"), + superuser: z.boolean().describe("Is superuser"), + createdb: z.boolean().describe("Can create databases"), + createrole: z.boolean().describe("Can create roles"), + replication: z.boolean().describe("Can replicate"), + bypassrls: z.boolean().describe("Can bypass RLS"), + connectionLimit: z + .number() + .describe("Max connections (-1=unlimited)"), + validUntil: z + .string() + .nullable() + .optional() + .describe("Password expiration"), + }), + ) + .optional() + .describe("Matching roles"), + count: z.number().optional().describe("Number of roles returned"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_role_create output + */ +export const RoleCreateOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether role was created"), + name: z.string().optional().describe("Created role name"), + skipped: z + .boolean() + .optional() + .describe("True if role already existed (with ifNotExists)"), + reason: z + .string() + .optional() + .describe("Reason for skipping, if applicable"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_role_drop output + */ +export const RoleDropOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether role was dropped"), + name: z.string().optional().describe("Dropped role name"), + skipped: z + .boolean() + .optional() + .describe("True if role did not exist (with ifExists)"), + reason: z + .string() + .optional() + .describe("Reason for skipping, if applicable"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_role_attributes output + */ +export const RoleAttributesOutputSchema = z + .object({ + exists: z.boolean().optional().describe("Whether the role exists"), + role: z + .object({ + name: z.string().describe("Role name"), + login: z.boolean().describe("Can log in"), + superuser: z.boolean().describe("Is superuser"), + createdb: z.boolean().describe("Can create databases"), + createrole: z.boolean().describe("Can create roles"), + replication: z.boolean().describe("Can replicate"), + bypassrls: z.boolean().describe("Can bypass RLS"), + inherit: z.boolean().describe("Inherits privileges from member roles"), + connectionLimit: z.number().describe("Max connections (-1=unlimited)"), + validUntil: z + .string() + .nullable() + .optional() + .describe("Password expiration"), + oid: z.number().describe("Role OID"), + }) + .optional() + .describe("Role attributes"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_role_grants output + */ +export const RoleGrantsOutputSchema = z + .object({ + exists: z.boolean().optional().describe("Whether the role exists"), + role: z.string().optional().describe("Role name"), + memberOf: z + .array( + z.object({ + role: z.string().describe("Parent role name"), + adminOption: z.boolean().describe("Has admin option"), + }), + ) + .optional() + .describe("Roles this role is a member of"), + tableGrants: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe("Object-level grants"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_role_grant output + */ +export const RoleGrantOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether grant succeeded"), + role: z.string().optional().describe("Role that received privileges"), + privileges: z.array(z.string()).optional().describe("Privileges granted"), + target: z.string().optional().describe("Target object"), + exists: z + .boolean() + .optional() + .describe("Whether target role exists (false if not found)"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_role_assign output + */ +export const RoleAssignOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether assignment succeeded"), + role: z.string().optional().describe("Role assigned"), + user: z.string().optional().describe("User that received membership"), + withAdminOption: z + .boolean() + .optional() + .describe("Whether admin option was granted"), + exists: z.boolean().optional().describe("Whether target user/role exists"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_role_revoke output + */ +export const RoleRevokeOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether revocation succeeded"), + role: z.string().optional().describe("Role revoked"), + user: z + .string() + .optional() + .describe("User that lost membership/privileges"), + privileges: z + .array(z.string()) + .optional() + .describe("Privileges revoked (for object-level revocation)"), + target: z + .string() + .optional() + .describe("Target object (for object-level revocation)"), + exists: z.boolean().optional().describe("Whether target role/user exists"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_user_roles output + */ +export const UserRolesOutputSchema = z + .object({ + exists: z.boolean().optional().describe("Whether the user/role exists"), + user: z.string().optional().describe("User/role name"), + roles: z + .array( + z.object({ + role: z.string().describe("Granted role name"), + adminOption: z.boolean().describe("Has admin option"), + setOption: z.boolean().optional().describe("Has SET option (PG 16+)"), + }), + ) + .optional() + .describe("Roles assigned to the user"), + count: z.number().optional().describe("Number of roles"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_role_set output + */ +export const RoleSetOutputSchema = z + .object({ + success: z.boolean().optional().describe("Whether SET ROLE succeeded"), + currentRole: z + .string() + .optional() + .describe("Active role after the operation"), + previousRole: z.string().optional().describe("Role before the operation"), + reset: z.boolean().optional().describe("Whether RESET ROLE was performed"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_role_rls_enable output + */ +export const RoleRlsEnableOutputSchema = z + .object({ + success: z + .boolean() + .optional() + .describe("Whether RLS was enabled/disabled"), + table: z.string().optional().describe("Table name"), + schema: z.string().optional().describe("Schema name"), + enabled: z + .boolean() + .optional() + .describe("Current RLS enabled state after operation"), + forced: z + .boolean() + .optional() + .describe("Whether FORCE ROW LEVEL SECURITY is active"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_role_rls_policies output + */ +export const RoleRlsPoliciesOutputSchema = z + .object({ + policies: z + .array( + z.object({ + policyName: z.string().describe("Policy name"), + tableName: z.string().describe("Table name"), + schemaName: z.string().describe("Schema name"), + command: z + .string() + .describe("Command (SELECT, INSERT, UPDATE, DELETE, ALL)"), + permissive: z.string().describe("PERMISSIVE or RESTRICTIVE"), + roles: z.array(z.string()).describe("Roles this policy applies to"), + usingExpr: z + .string() + .nullable() + .optional() + .describe("USING expression"), + withCheckExpr: z + .string() + .nullable() + .optional() + .describe("WITH CHECK expression"), + }), + ) + .optional() + .describe("RLS policies"), + count: z.number().optional().describe("Number of policies returned"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); diff --git a/src/adapters/postgresql/schemas/schema-mgmt.ts b/src/adapters/postgresql/schemas/schema-mgmt.ts index 9035605e..3e2803d7 100644 --- a/src/adapters/postgresql/schemas/schema-mgmt.ts +++ b/src/adapters/postgresql/schemas/schema-mgmt.ts @@ -59,7 +59,7 @@ export const DropSchemaSchemaBase = z.object({ export const DropSchemaSchema = z .preprocess(preprocessCreateSchemaParams, DropSchemaSchemaBase) .refine((data) => typeof data.name === "string" && data.name.length > 0, { - message: "name is required", + message: "name (or schema alias) is required", }); // Base schema for MCP visibility (shows both name and sequenceName) @@ -67,6 +67,7 @@ export const DropSchemaSchema = z export const CreateSequenceSchemaBase = z.object({ name: z.string().optional().describe("Sequence name"), sequenceName: z.string().optional().describe("Alias for name"), + sequence: z.string().optional().describe("Alias for name"), schema: z.string().optional().describe("Schema name"), start: z.unknown().optional().describe("Start value (number)"), increment: z @@ -125,13 +126,21 @@ function preprocessCreateSequenceParams(input: unknown): unknown { if (typeof input !== "object" || input === null) return input; const result = { ...(input as Record) }; - // Resolve sequenceName alias to name before dotted-name extraction - if ( - (result["name"] === undefined || result["name"] === "") && - result["sequenceName"] !== undefined && - result["sequenceName"] !== "" - ) { - result["name"] = result["sequenceName"]; + // Resolve sequenceName/sequence alias to name before dotted-name extraction + if (result["name"] === undefined || result["name"] === "") { + if (result["sequenceName"] !== undefined && result["sequenceName"] !== "") { + result["name"] = result["sequenceName"]; + } else if (result["sequence"] !== undefined && result["sequence"] !== "") { + result["name"] = result["sequence"]; + } + } + + // Handle case-insensitive aliases for Postgres defaults + if (result["maxValue"] === undefined && result["maxvalue"] !== undefined) { + result["maxValue"] = result["maxvalue"]; + } + if (result["minValue"] === undefined && result["minvalue"] !== undefined) { + result["minValue"] = result["minvalue"]; } return extractSchemaFromDottedName(result); @@ -144,6 +153,7 @@ export const CreateSequenceSchema = z.preprocess( .object({ name: z.string().optional(), sequenceName: z.string().optional(), + sequence: z.string().optional(), schema: z.string().optional(), start: z.preprocess(coerceStrictNumber, z.number().optional()), increment: z.preprocess(coerceStrictNumber, z.number().optional()), @@ -155,7 +165,7 @@ export const CreateSequenceSchema = z.preprocess( ifNotExists: z.boolean().optional(), }) .transform((data) => ({ - name: data.name ?? data.sequenceName ?? "", + name: data.name ?? data.sequenceName ?? data.sequence ?? "", schema: data.schema, start: data.start, increment: data.increment, @@ -167,7 +177,7 @@ export const CreateSequenceSchema = z.preprocess( ifNotExists: data.ifNotExists, })) .refine((data) => data.name !== "", { - message: "name (or sequenceName alias) is required", + message: "name (or sequenceName/sequence alias) is required", }), ); @@ -245,6 +255,7 @@ export const DropSequenceSchemaBase = z.object({ .optional() .describe("Sequence name (supports schema.name format)"), sequenceName: z.string().optional().describe("Alias for name"), + sequence: z.string().optional().describe("Alias for name"), schema: z.string().optional().describe("Schema name (default: public)"), ifExists: z.boolean().optional().describe("Use IF EXISTS to avoid errors"), cascade: z.boolean().optional().describe("Drop dependent objects"), @@ -256,12 +267,12 @@ export const DropSequenceSchemaBase = z.object({ function preprocessDropSequenceParams(input: unknown): unknown { if (typeof input !== "object" || input === null) return input; const result = { ...(input as Record) }; - if ( - (result["name"] === undefined || result["name"] === "") && - result["sequenceName"] !== undefined && - result["sequenceName"] !== "" - ) { - result["name"] = result["sequenceName"]; + if (result["name"] === undefined || result["name"] === "") { + if (result["sequenceName"] !== undefined && result["sequenceName"] !== "") { + result["name"] = result["sequenceName"]; + } else if (result["sequence"] !== undefined && result["sequence"] !== "") { + result["name"] = result["sequence"]; + } } return extractSchemaFromDottedName(result); } @@ -272,7 +283,7 @@ function preprocessDropSequenceParams(input: unknown): unknown { export const DropSequenceSchema = z .preprocess(preprocessDropSequenceParams, DropSequenceSchemaBase) .refine((data) => typeof data.name === "string" && data.name.length > 0, { - message: "name is required", + message: "name (or sequenceName/sequence alias) is required", }); /** @@ -316,7 +327,7 @@ function preprocessDropViewParams(input: unknown): unknown { export const DropViewSchema = z .preprocess(preprocessDropViewParams, DropViewSchemaBase) .refine((data) => typeof data.name === "string" && data.name.length > 0, { - message: "name is required", + message: "name (or viewName/view alias) is required", }); // ============================================================================= @@ -348,6 +359,12 @@ export const ListSequencesSchema = z.preprocess( export const ListViewsSchemaBase = z.object({ schema: z.string().optional().describe("Schema name"), + exclude: z + .array(z.string()) + .optional() + .describe( + 'Array of extension names/schemas to exclude, e.g., ["postgis", "ltree", "pgcrypto", "vector"]', + ), includeMaterialized: z .boolean() .optional() @@ -356,7 +373,7 @@ export const ListViewsSchemaBase = z.object({ .unknown() .optional() .describe( - "Max length for view definitions (number, default: 500). Use 0 for no truncation.", + "Max length for view definitions (number, default: 100). Use 0 for no truncation.", ), limit: z .unknown() @@ -375,6 +392,7 @@ export const ListViewsSchema = z.preprocess( }, z.object({ schema: z.string().optional(), + exclude: z.array(z.string()).optional(), includeMaterialized: z.boolean().optional(), truncateDefinition: z.preprocess(coerceStrictNumber, z.number().optional()), limit: z.preprocess(coerceStrictNumber, z.number().optional()), diff --git a/src/adapters/postgresql/schemas/security.ts b/src/adapters/postgresql/schemas/security.ts new file mode 100644 index 00000000..1f46cce2 --- /dev/null +++ b/src/adapters/postgresql/schemas/security.ts @@ -0,0 +1,490 @@ +/** + * postgres-mcp - Security Tool Schemas + * + * Input validation and output schemas for security tools. + * 9 tools: audit, firewall status/rules, mask data, user privileges, + * sensitive tables, SSL status, encryption status, password validate. + */ + +import { z } from "zod"; +import { ErrorResponseFields } from "./error-response-fields.js"; + +// Helper to handle undefined params (allows tools to be called without {}) +const defaultToEmpty = (val: unknown): unknown => val ?? {}; + +// ============================================================================= +// Input Schemas (Split Schema pattern: Base for MCP, Preprocessed for handler) +// ============================================================================= + +/** + * pg_security_audit β€” comprehensive security posture check + */ +export const SecurityAuditSchemaBase = z.object({ + limit: z + .number() + .optional() + .describe("Maximum number of findings to return (default: 20)"), + includeHba: z + .boolean() + .optional() + .describe( + "Include pg_hba.conf rules in audit (requires superuser, default: true)", + ), +}); + +export const SecurityAuditSchema = z.preprocess( + defaultToEmpty, + SecurityAuditSchemaBase, +); + +/** + * pg_security_firewall_status β€” pg_hba.conf summary + */ +export const FirewallStatusSchemaBase = z.object({}).strict(); + +export const FirewallStatusSchema = z.preprocess( + defaultToEmpty, + FirewallStatusSchemaBase, +); + +/** + * pg_security_firewall_rules β€” detailed pg_hba.conf listing + */ +export const FirewallRulesSchemaBase = z.object({ + user: z.string().optional().describe("Filter by username"), + type: z + .string() + .optional() + .describe("Filter by rule type (host, local, etc.)"), +}); + +export const FirewallRulesSchema = z.preprocess( + defaultToEmpty, + FirewallRulesSchemaBase, +); + +/** + * pg_security_mask_data β€” data masking (pure JS, no DB) + */ +export const MaskDataSchemaBase = z + .object({ + value: z.string().describe("Value to mask"), + type: z + .string() + .describe("Masking type (email, phone, ssn, credit_card, partial)"), + keepFirst: z + .number() + .default(0) + .describe("Characters to keep from start (partial type)"), + keepLast: z + .number() + .default(0) + .describe("Characters to keep from end (partial type)"), + maskChar: z.string().default("*").describe("Character to use for masking"), + }) + .partial(); + +export const MaskDataSchema = z.preprocess( + defaultToEmpty, + z.object({ + value: z.string().describe("Value to mask"), + type: z + .string() + .describe("Masking type (email, phone, ssn, credit_card, partial)"), + keepFirst: z + .number() + .default(0) + .describe("Characters to keep from start (partial type)"), + keepLast: z + .number() + .default(0) + .describe("Characters to keep from end (partial type)"), + maskChar: z.string().default("*").describe("Character to use for masking"), + }), +); + +/** + * pg_security_user_privileges β€” role/privilege report + */ +export const UserPrivilegesSchemaBase = z.object({ + user: z.string().optional().describe("Filter by role name"), + includeRoles: z + .boolean() + .default(true) + .describe("Include role membership information"), + summary: z + .boolean() + .default(false) + .describe( + "Return condensed summary (privilege counts) instead of full details", + ), + includeGrants: z + .boolean() + .default(false) + .describe("Include up to 100 object-level table grants per role"), + limit: z + .number() + .optional() + .describe("Maximum number of roles to return (default: 50)"), +}); + +export const UserPrivilegesSchema = z.preprocess( + defaultToEmpty, + UserPrivilegesSchemaBase, +); + +/** + * pg_security_sensitive_tables β€” detect columns with sensitive data + */ +export const SensitiveTablesSchemaBase = z.object({ + schema: z + .string() + .optional() + .describe("Schema to scan (defaults to 'public')"), + patterns: z + .array(z.string()) + .optional() + .describe("Column name patterns to consider sensitive"), + limit: z + .number() + .optional() + .describe( + "Maximum number of tables to return (default: 20). Set higher for full scan.", + ), +}); + +export const SensitiveTablesSchema = z.preprocess( + defaultToEmpty, + SensitiveTablesSchemaBase.transform((data) => ({ + schema: data.schema, + patterns: data.patterns ?? [ + "password", + "secret", + "token", + "key", + "ssn", + "credit", + "card", + "phone", + "email", + "address", + "salary", + "medical", + "health", + ], + limit: data.limit ?? 20, + })), +); + +/** + * pg_security_ssl_status β€” SSL/TLS connection status + */ +export const SSLStatusSchemaBase = z.object({}).strict(); + +export const SSLStatusSchema = z.preprocess( + defaultToEmpty, + SSLStatusSchemaBase, +); + +/** + * pg_security_encryption_status β€” encryption configuration + */ +export const EncryptionStatusSchemaBase = z.object({}).strict(); + +export const EncryptionStatusSchema = z.preprocess( + defaultToEmpty, + EncryptionStatusSchemaBase, +); + +/** + * pg_security_password_validate β€” password strength check (pure JS) + */ +export const PasswordValidateSchemaBase = z + .object({ + password: z.string().describe("Password to validate"), + }) + .partial(); + +export const PasswordValidateSchema = z.preprocess( + defaultToEmpty, + z.object({ + password: z.string().describe("Password to validate"), + }), +); + +// ============================================================================= +// Output Schemas +// ============================================================================= + +/** + * pg_security_audit output + */ +export const SecurityAuditOutputSchema = z + .object({ + findings: z + .array( + z.object({ + check: z.string().describe("Security check name"), + severity: z + .string() + .describe("Finding severity (info, warning, critical)"), + status: z.string().describe("Check status (pass, warn, fail)"), + message: z.string().describe("Finding description"), + recommendation: z + .string() + .optional() + .describe("Suggested remediation"), + }), + ) + .optional() + .describe("Security audit findings"), + summary: z + .object({ + total: z.number().describe("Total checks performed"), + passed: z.number().describe("Checks passed"), + warnings: z.number().describe("Warning-level findings"), + critical: z.number().describe("Critical-level findings"), + }) + .optional() + .describe("Audit summary"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_security_firewall_status output + */ +export const FirewallStatusOutputSchema = z + .object({ + available: z + .boolean() + .optional() + .describe("Whether pg_hba_file_rules is accessible"), + totalRules: z.number().optional().describe("Total number of HBA rules"), + rulesByType: z + .record(z.string(), z.number()) + .optional() + .describe("Rule count by type (local, host, hostssl, etc.)"), + authMethods: z + .record(z.string(), z.number()) + .optional() + .describe("Rule count by authentication method"), + hostsslEnforced: z + .boolean() + .optional() + .describe("Whether hostssl is enforced for remote connections"), + message: z.string().optional().describe("Status message"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_security_firewall_rules output + */ +export const FirewallRulesOutputSchema = z + .object({ + rules: z + .array( + z.object({ + line_number: z + .number() + .optional() + .describe("Line number in pg_hba.conf"), + type: z + .string() + .optional() + .describe("Rule type (local, host, hostssl)"), + database: z.unknown().optional().describe("Database(s)"), + user_name: z.unknown().optional().describe("User(s)"), + address: z.string().nullable().optional().describe("Client address"), + netmask: z.string().nullable().optional().describe("Netmask"), + auth_method: z.string().optional().describe("Authentication method"), + options: z.unknown().optional().describe("Additional auth options"), + }), + ) + .optional() + .describe("HBA rules"), + count: z.number().optional().describe("Number of rules returned"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_security_mask_data output + */ +export const MaskDataOutputSchema = z + .object({ + original: z.string().optional().describe("Original value"), + masked: z.string().optional().describe("Masked value"), + type: z.string().optional().describe("Masking type applied"), + warning: z + .string() + .optional() + .describe("Warning if masking was ineffective"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_security_user_privileges output + */ +export const UserPrivilegesOutputSchema = z + .object({ + users: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe("User privilege details"), + count: z.number().optional().describe("Number of users returned"), + limited: z.boolean().optional().describe("Whether results were truncated"), + summary: z.boolean().optional().describe("Whether summary mode was used"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_security_sensitive_tables output + */ +export const SensitiveTablesOutputSchema = z + .object({ + sensitiveTables: z + .array( + z.object({ + table: z.string().describe("Table name"), + sensitiveColumns: z + .array(z.record(z.string(), z.unknown())) + .describe("Columns matching sensitive patterns"), + columnCount: z.number().describe("Number of sensitive columns"), + }), + ) + .optional() + .describe("Tables with sensitive columns"), + tableCount: z.number().optional().describe("Number of tables returned"), + totalSensitiveColumns: z + .number() + .optional() + .describe("Total sensitive columns found"), + patternsUsed: z + .array(z.string()) + .optional() + .describe("Column name patterns used"), + limited: z.boolean().optional().describe("Whether results were truncated"), + totalAvailable: z + .number() + .optional() + .describe("Total tables available if truncated"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_security_ssl_status output + */ +export const SSLStatusOutputSchema = z + .object({ + sslEnabled: z.boolean().optional().describe("Whether SSL is enabled"), + sslConnections: z + .array( + z.object({ + pid: z.number().optional().describe("Backend process ID"), + ssl: z.boolean().optional().describe("Using SSL"), + version: z.string().nullable().optional().describe("TLS version"), + cipher: z.string().nullable().optional().describe("Cipher suite"), + client_dn: z + .string() + .nullable() + .optional() + .describe("Client cert DN"), + }), + ) + .optional() + .describe("Active SSL connections"), + configuration: z + .record(z.string(), z.unknown()) + .optional() + .describe("SSL configuration settings"), + totalConnections: z + .number() + .optional() + .describe("Total active connections"), + sslConnectionCount: z.number().optional().describe("Connections using SSL"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_security_encryption_status output + */ +export const EncryptionStatusOutputSchema = z + .object({ + sslEnabled: z.boolean().optional().describe("Whether SSL is enabled"), + passwordEncryption: z + .string() + .optional() + .describe("Password encryption method (scram-sha-256, md5)"), + pgcryptoAvailable: z + .boolean() + .optional() + .describe("Whether pgcrypto extension is installed"), + encryptionSettings: z + .record(z.string(), z.unknown()) + .optional() + .describe("Encryption-related settings"), + certificates: z + .object({ + ssl_ca_file: z.string().optional().describe("CA certificate file"), + ssl_cert_file: z + .string() + .optional() + .describe("Server certificate file"), + ssl_key_file: z.string().optional().describe("Server key file"), + ssl_crl_file: z + .string() + .optional() + .describe("Certificate revocation list"), + }) + .optional() + .describe("SSL certificate paths"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); + +/** + * pg_security_password_validate output + */ +export const PasswordValidateOutputSchema = z + .object({ + strength: z.number().optional().describe("Password strength score (0-100)"), + interpretation: z + .string() + .optional() + .describe("Human-readable strength label"), + meetsPolicy: z + .boolean() + .optional() + .describe("Whether password meets minimum strength"), + policy: z + .object({ + minLength: z.number().describe("Minimum length requirement"), + requireUppercase: z.boolean().describe("Requires uppercase letter"), + requireLowercase: z.boolean().describe("Requires lowercase letter"), + requireDigit: z.boolean().describe("Requires digit"), + requireSpecial: z.boolean().describe("Requires special character"), + }) + .optional() + .describe("Password policy used for validation"), + checks: z + .record(z.string(), z.boolean()) + .optional() + .describe("Individual check results"), + success: z.boolean().optional().describe("Whether operation succeeded"), + error: z.string().optional().describe("Error message if failed"), + }) + .extend(ErrorResponseFields.shape); diff --git a/src/adapters/postgresql/schemas/stats/advanced.ts b/src/adapters/postgresql/schemas/stats/advanced.ts index f0daea40..9597602d 100644 --- a/src/adapters/postgresql/schemas/stats/advanced.ts +++ b/src/adapters/postgresql/schemas/stats/advanced.ts @@ -7,7 +7,6 @@ import { z } from "zod"; import { ErrorResponseFields } from "../error-response-fields.js"; import { preprocessBasicStatsParams } from "./preprocessing.js"; -import { coerceNumber } from "../../../../utils/query-helpers.js"; // ============================================================================= // Base Schemas (for MCP visibility) @@ -22,16 +21,18 @@ export const StatsOutliersSchemaBase = z.object({ .optional() .describe("Detection method (default: iqr)"), threshold: z - .number() + .unknown() .optional() .describe("IQR multiplier (default 1.5) or Z-score threshold (default 3)"), schema: z.string().optional().describe("Schema name (default: public)"), where: z.string().optional().describe("Filter condition"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Maximum rows to scan (default: 10000)"), maxOutliers: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Maximum outliers to return (default: 50). Reduces payload for large datasets.", ), @@ -42,7 +43,8 @@ export const StatsTopNSchemaBase = z.object({ tableName: z.string().optional().describe("Alias for table"), column: z.string().describe("Column to rank by"), n: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Number of top values (default: 10, max: 100)"), direction: z .enum(["asc", "desc"]) @@ -63,7 +65,8 @@ export const StatsDistinctSchemaBase = z.object({ schema: z.string().optional().describe("Schema name (default: public)"), where: z.string().optional().describe("Filter condition"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Maximum values to return (default: 100, max: 1000)"), }); @@ -74,7 +77,8 @@ export const StatsFrequencySchemaBase = z.object({ schema: z.string().optional().describe("Schema name (default: public)"), where: z.string().optional().describe("Filter condition"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Maximum frequency entries (default: 20, max: 1000)"), }); diff --git a/src/adapters/postgresql/schemas/stats/base-schemas.ts b/src/adapters/postgresql/schemas/stats/base-schemas.ts index 71785a3c..1db88849 100644 --- a/src/adapters/postgresql/schemas/stats/base-schemas.ts +++ b/src/adapters/postgresql/schemas/stats/base-schemas.ts @@ -6,7 +6,6 @@ */ import { z } from "zod"; -import { coerceNumber } from "../../../../utils/query-helpers.js"; // ============================================================================= // Base Schemas (for MCP visibility) @@ -108,11 +107,13 @@ export const StatsTimeSeriesSchemaBase = z.object({ .optional() .describe("Parameters for $1, $2 placeholders in where clause"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Max time buckets to return (default: 100, 0 = no limit)"), groupBy: z.string().optional().describe("Column to group time series by"), groupLimit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Max number of groups when using groupBy (default: 20, 0 = no limit). Prevents large payloads with many groups", ), @@ -123,7 +124,8 @@ export const StatsDistributionSchemaBase = z.object({ tableName: z.string().optional().describe("Alias for table"), column: z.string().describe("Numeric column"), buckets: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Number of histogram buckets (default: 10)"), schema: z.string().optional().describe("Schema name"), where: z.string().optional().describe("Filter condition"), @@ -133,7 +135,8 @@ export const StatsDistributionSchemaBase = z.object({ .describe("Parameters for $1, $2 placeholders in where clause"), groupBy: z.string().optional().describe("Column to group distribution by"), groupLimit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Max number of groups when using groupBy (default: 20, 0 = no limit). Prevents large payloads with many groups", ), @@ -144,10 +147,12 @@ export const StatsHypothesisSchemaBase = z.object({ tableName: z.string().optional().describe("Alias for table"), column: z.string().describe("Numeric column"), hypothesizedMean: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Hypothesized population mean (default: 0)"), populationStdDev: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe( "Known population standard deviation (if provided, uses z-test; otherwise uses t-test)", ), @@ -170,10 +175,12 @@ export const StatsSamplingSchemaBase = z.object({ "Sampling method (default: random). Note: system uses page-level sampling and may return 0 rows on small tables", ), sampleSize: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Number of rows for random sampling (default: 20)"), percentage: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Percentage for bernoulli/system sampling (0-100)"), schema: z.string().optional().describe("Schema name"), select: z.array(z.string()).optional().describe("Columns to select"), @@ -196,6 +203,7 @@ export const StatisticsObjectSchema = z.object({ min: z.number().nullable().describe("Minimum value"), max: z.number().nullable().describe("Maximum value"), avg: z.number().nullable().describe("Mean/average value"), + mean: z.number().nullable().optional().describe("Alias for avg"), stddev: z.number().nullable().describe("Standard deviation"), variance: z.number().nullable().describe("Variance"), sum: z.number().nullable().describe("Sum of all values"), diff --git a/src/adapters/postgresql/schemas/stats/input.ts b/src/adapters/postgresql/schemas/stats/input.ts index 4b8eb470..7901b050 100644 --- a/src/adapters/postgresql/schemas/stats/input.ts +++ b/src/adapters/postgresql/schemas/stats/input.ts @@ -124,7 +124,7 @@ export const StatsTimeSeriesSchema = z.preprocess( export const StatsDistributionSchema = z.preprocess( preprocessDistributionParams, StatsDistributionSchemaBase.refine( - (data) => data.buckets === undefined || data.buckets > 0, + (data) => data.buckets === undefined || Number(data.buckets) > 0, { message: "buckets must be greater than 0", path: ["buckets"], @@ -165,7 +165,8 @@ export const StatsHypothesisSchema = z.preprocess( ) .refine( (data) => - data.populationStdDev === undefined || data.populationStdDev > 0, + data.populationStdDev === undefined || + Number(data.populationStdDev) > 0, { message: "populationStdDev must be greater than 0", path: ["populationStdDev"], @@ -176,7 +177,7 @@ export const StatsHypothesisSchema = z.preprocess( export const StatsSamplingSchema = z.preprocess( preprocessSamplingParams, StatsSamplingSchemaBase.refine( - (data) => data.sampleSize === undefined || data.sampleSize > 0, + (data) => data.sampleSize === undefined || Number(data.sampleSize) > 0, { message: "sampleSize must be greater than 0", path: ["sampleSize"], @@ -184,7 +185,7 @@ export const StatsSamplingSchema = z.preprocess( ).refine( (data) => data.percentage === undefined || - (data.percentage >= 0 && data.percentage <= 100), + (Number(data.percentage) >= 0 && Number(data.percentage) <= 100), { message: "percentage must be between 0 and 100", path: ["percentage"], diff --git a/src/adapters/postgresql/schemas/stats/preprocessing.ts b/src/adapters/postgresql/schemas/stats/preprocessing.ts index 8a6607bb..e43cd320 100644 --- a/src/adapters/postgresql/schemas/stats/preprocessing.ts +++ b/src/adapters/postgresql/schemas/stats/preprocessing.ts @@ -5,7 +5,10 @@ * Handles tableNameβ†’table, colβ†’column, schema.table parsing, percentile normalization, etc. */ -import { coerceNumber } from "../../../../utils/query-helpers.js"; +import { + coerceNumber, + coerceStrictNumber, +} from "../../../../utils/query-helpers.js"; // ============================================================================= // Schema.Table Parsing @@ -145,6 +148,17 @@ export function preprocessBasicStatsParams(input: unknown): unknown { // else: already in 0-1 format, no change needed } } + // Handle advanced stats parameters with strict coercion + if (result["n"] !== undefined) { + result["n"] = coerceStrictNumber(result["n"]); + } + if (result["limit"] !== undefined) { + result["limit"] = coerceStrictNumber(result["limit"]); + } + if (result["maxOutliers"] !== undefined) { + result["maxOutliers"] = coerceStrictNumber(result["maxOutliers"]); + } + return result; } @@ -417,19 +431,19 @@ export function preprocessHypothesisParams(input: unknown): unknown { } if (result["hypothesizedMean"] !== undefined) { - result["hypothesizedMean"] = coerceNumber(result["hypothesizedMean"]); + result["hypothesizedMean"] = coerceStrictNumber(result["hypothesizedMean"]); } if (result["populationStdDev"] !== undefined) { - result["populationStdDev"] = coerceNumber(result["populationStdDev"]); + result["populationStdDev"] = coerceStrictNumber(result["populationStdDev"]); } if (result["sigma"] !== undefined) { - result["sigma"] = coerceNumber(result["sigma"]); + result["sigma"] = coerceStrictNumber(result["sigma"]); } if (result["mean"] !== undefined) { - result["mean"] = coerceNumber(result["mean"]); + result["mean"] = coerceStrictNumber(result["mean"]); } if (result["expected"] !== undefined) { - result["expected"] = coerceNumber(result["expected"]); + result["expected"] = coerceStrictNumber(result["expected"]); } return result; @@ -467,7 +481,7 @@ export function preprocessDistributionParams(input: unknown): unknown { } if (result["buckets"] !== undefined) { - result["buckets"] = coerceNumber(result["buckets"]); + result["buckets"] = coerceStrictNumber(result["buckets"]); } if (result["groupLimit"] !== undefined) { result["groupLimit"] = coerceNumber(result["groupLimit"]); @@ -508,10 +522,10 @@ export function preprocessSamplingParams(input: unknown): unknown { } if (result["sampleSize"] !== undefined) { - result["sampleSize"] = coerceNumber(result["sampleSize"]); + result["sampleSize"] = coerceStrictNumber(result["sampleSize"]); } if (result["percentage"] !== undefined) { - result["percentage"] = coerceNumber(result["percentage"]); + result["percentage"] = coerceStrictNumber(result["percentage"]); } return result; diff --git a/src/adapters/postgresql/schemas/stats/window.ts b/src/adapters/postgresql/schemas/stats/window.ts index 381dd7b4..8c66aa42 100644 --- a/src/adapters/postgresql/schemas/stats/window.ts +++ b/src/adapters/postgresql/schemas/stats/window.ts @@ -7,7 +7,6 @@ import { z } from "zod"; import { ErrorResponseFields } from "../error-response-fields.js"; import { preprocessBasicStatsParams } from "./preprocessing.js"; -import { coerceNumber } from "../../../../utils/query-helpers.js"; // ============================================================================= // Base Schemas (for MCP visibility) @@ -25,7 +24,8 @@ export const StatsRowNumberSchemaBase = z.object({ schema: z.string().optional().describe("Schema name (default: public)"), where: z.string().optional().describe("Filter condition"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Maximum rows to return (default: 20)"), }); @@ -45,7 +45,8 @@ export const StatsRankSchemaBase = z.object({ schema: z.string().optional().describe("Schema name (default: public)"), where: z.string().optional().describe("Filter condition"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Maximum rows to return (default: 20)"), }); @@ -58,7 +59,8 @@ export const StatsLagLeadSchemaBase = z.object({ .enum(["lag", "lead"]) .describe("LAG (previous row) or LEAD (next row)"), offset: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Number of rows to look back/ahead (default: 1)"), defaultValue: z .string() @@ -72,7 +74,8 @@ export const StatsLagLeadSchemaBase = z.object({ schema: z.string().optional().describe("Schema name (default: public)"), where: z.string().optional().describe("Filter condition"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Maximum rows to return (default: 20)"), }); @@ -92,7 +95,8 @@ export const StatsRunningTotalSchemaBase = z.object({ schema: z.string().optional().describe("Schema name (default: public)"), where: z.string().optional().describe("Filter condition"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Maximum rows to return (default: 20)"), }); @@ -102,7 +106,8 @@ export const StatsMovingAvgSchemaBase = z.object({ column: z.string().describe("Numeric column to average"), orderBy: z.string().describe("Column(s) to order by"), windowSize: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Number of rows in the moving window"), partitionBy: z.string().optional().describe("Column(s) to partition by"), selectColumns: z @@ -112,7 +117,8 @@ export const StatsMovingAvgSchemaBase = z.object({ schema: z.string().optional().describe("Schema name (default: public)"), where: z.string().optional().describe("Filter condition"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Maximum rows to return (default: 20)"), }); @@ -121,7 +127,8 @@ export const StatsNtileSchemaBase = z.object({ tableName: z.string().optional().describe("Alias for table"), orderBy: z.string().describe("Column(s) to order by"), buckets: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Number of buckets (e.g., 4 for quartiles)"), partitionBy: z.string().optional().describe("Column(s) to partition by"), selectColumns: z @@ -131,7 +138,8 @@ export const StatsNtileSchemaBase = z.object({ schema: z.string().optional().describe("Schema name (default: public)"), where: z.string().optional().describe("Filter condition"), limit: z - .preprocess(coerceNumber, z.number().optional()) + .unknown() + .optional() .describe("Maximum rows to return (default: 20)"), }); diff --git a/src/adapters/postgresql/schemas/text-search.ts b/src/adapters/postgresql/schemas/text-search.ts index 85698692..0ce2bca5 100644 --- a/src/adapters/postgresql/schemas/text-search.ts +++ b/src/adapters/postgresql/schemas/text-search.ts @@ -38,6 +38,18 @@ export function preprocessTextParams(input: unknown): unknown { if (result["text"] !== undefined && result["value"] === undefined) { result["value"] = result["text"]; } + // Alias: query β†’ value (cross-tool normalization) + if (result["query"] !== undefined && result["value"] === undefined) { + result["value"] = result["query"]; + } + // Alias: value β†’ query (cross-tool normalization) + if (result["value"] !== undefined && result["query"] === undefined) { + result["query"] = result["value"]; + } + // Alias: value β†’ pattern (for like search) + if (result["value"] !== undefined && result["pattern"] === undefined) { + result["pattern"] = result["value"]; + } // Alias: indexName β†’ name (for FTS index tool) if (result["indexName"] !== undefined && result["name"] === undefined) { result["name"] = result["indexName"]; @@ -126,25 +138,239 @@ export const RegexpMatchSchemaBase = z.object({ schema: z.string().optional().describe("Schema name (default: public)"), }); +export const TextRankSchemaBase = z.object({ + table: z.string().optional().describe("Table name"), + tableName: z.string().optional().describe("Table name (alias for table)"), + column: z.string().optional().describe("Single column to search"), + columns: z + .array(z.string()) + .optional() + .describe("Multiple columns to search (alternative to column)"), + query: z.string().optional(), + config: z.string().optional(), + normalization: z.any().optional(), + select: z.array(z.string()).optional().describe("Columns to return"), + limit: z.any().optional().describe("Max results"), + schema: z.string().optional().describe("Schema name (default: public)"), +}); + +export const HeadlineSchemaBase = z.object({ + table: z.string().optional().describe("Table name"), + tableName: z.string().optional().describe("Table name (alias for table)"), + column: z.string().optional(), + query: z.string().optional(), + config: z.string().optional(), + options: z + .string() + .optional() + .describe( + 'Headline options (e.g., "MaxWords=20, MinWords=5"). Note: MinWords must be < MaxWords.', + ), + startSel: z + .string() + .optional() + .describe("Start selection marker (default: )"), + stopSel: z + .string() + .optional() + .describe("Stop selection marker (default: )"), + maxWords: z.any().optional().describe("Maximum words in headline"), + minWords: z.any().optional().describe("Minimum words in headline"), + select: z + .array(z.string()) + .optional() + .describe('Columns to return for row identification (e.g., ["id"])'), + limit: z.any().optional().describe("Max results"), + schema: z.string().optional().describe("Schema name (default: public)"), +}); + +export const FtsIndexSchemaBase = z.object({ + table: z.string().optional().describe("Table name"), + tableName: z.string().optional().describe("Table name (alias for table)"), + column: z.string().optional(), + name: z.string().optional(), + config: z.string().optional(), + ifNotExists: z + .boolean() + .optional() + .describe("Skip if index already exists (default: true)"), + schema: z.string().optional().describe("Schema name (default: public)"), +}); + +export const FuzzyMatchSchemaBase = z.object({ + table: z.string().optional().describe("Table name"), + tableName: z.string().optional().describe("Table name (alias for table)"), + column: z.string().optional(), + value: z.string().optional(), + method: z + .string() + .optional() + .describe( + "Fuzzy match method (default: levenshtein). Valid: soundex, levenshtein, damerau-levenshtein, metaphone", + ), + maxDistance: z + .any() + .optional() + .describe( + "Max Levenshtein distance (default: 3, use 5+ for longer strings)", + ), + select: z.array(z.string()).optional().describe("Columns to return"), + limit: z + .any() + .optional() + .describe("Max results (default: 100 to prevent large payloads)"), + where: z.string().optional().describe("Additional WHERE clause filter"), + schema: z.string().optional().describe("Schema name (default: public)"), +}); + +export const LikeSearchSchemaBase = z.object({ + table: z.string().optional().describe("Table name"), + tableName: z.string().optional().describe("Table name (alias for table)"), + column: z.string().optional(), + pattern: z.string().optional(), + caseSensitive: z + .boolean() + .optional() + .describe("Use case-sensitive LIKE (default: false, uses ILIKE)"), + select: z.array(z.string()).optional(), + limit: z + .any() + .optional() + .describe("Max results (default: 100 to prevent large payloads)"), + where: z.string().optional().describe("Additional WHERE clause filter"), + schema: z.string().optional().describe("Schema name (default: public)"), +}); + +export const SentimentSchemaBase = z.object({ + text: z.string().optional().describe("Text to analyze"), + returnWords: z + .boolean() + .optional() + .describe("Return matched sentiment words"), +}); + +export const NormalizeSchemaBase = z.object({ + text: z.string().optional().describe("Text to remove accent marks from"), +}); + +export const ToVectorSchemaBase = z.object({ + text: z.string().optional().describe("Text to convert to tsvector"), + config: z + .string() + .optional() + .describe("Text search configuration (default: english)"), +}); + +export const ToQuerySchemaBase = z.object({ + text: z.string().optional().describe("Text to convert to tsquery"), + config: z + .string() + .optional() + .describe("Text search configuration (default: english)"), + mode: z + .string() + .optional() + .describe( + "Query parsing mode: plain (default), phrase (proximity), websearch (Google-like)", + ), +}); + +export const TextSearchConfigSchemaBase = z.object({}).default({}); + // ============================================================================= // Full Schemas (with preprocess - for handler parsing) // ============================================================================= export const TextSearchSchema = z.preprocess( preprocessTextParams, - TextSearchSchemaBase, + TextSearchSchemaBase.extend({ + limit: z.number().optional(), + }), ); export const TrigramSimilaritySchema = z.preprocess( preprocessTextParams, - TrigramSimilaritySchemaBase, + TrigramSimilaritySchemaBase.extend({ + limit: z.number().optional(), + threshold: z.number().optional(), + }), ); export const RegexpMatchSchema = z.preprocess( preprocessTextParams, - RegexpMatchSchemaBase, + RegexpMatchSchemaBase.extend({ + limit: z.number().optional(), + }), ); +export const TextRankSchema = z.preprocess( + preprocessTextParams, + TextRankSchemaBase.extend({ + limit: z.number().optional(), + }), +); + +export const HeadlineSchema = z.preprocess( + preprocessTextParams, + HeadlineSchemaBase.extend({ + limit: z.number().optional(), + }), +); + +export const FtsIndexSchema = z.preprocess( + preprocessTextParams, + FtsIndexSchemaBase, +); + +export const FuzzyMatchSchema = z.preprocess( + preprocessTextParams, + FuzzyMatchSchemaBase.extend({ + limit: z.number().optional(), + maxDistance: z.number().optional(), + }), +); + +export const LikeSearchSchema = z.preprocess( + preprocessTextParams, + LikeSearchSchemaBase.extend({ + limit: z.number().optional(), + }), +); + +export const SentimentSchema = z.object({ + text: z.string().describe("Text to analyze"), + returnWords: z + .boolean() + .optional() + .describe("Return matched sentiment words"), +}); + +export const NormalizeSchema = z.object({ + text: z.string().describe("Text to remove accent marks from"), +}); + +export const ToVectorSchema = z.object({ + text: z.string().describe("Text to convert to tsvector"), + config: z + .string() + .optional() + .describe("Text search configuration (default: english)"), +}); + +export const ToQuerySchema = z.object({ + text: z.string().describe("Text to convert to tsquery"), + config: z + .string() + .optional() + .describe("Text search configuration (default: english)"), + mode: z + .enum(["plain", "phrase", "websearch"]) + .optional() + .describe( + "Query parsing mode: plain (default), phrase (proximity), websearch (Google-like)", + ), +}); + // ============================================================================= // OUTPUT SCHEMAS (MCP 2025-11-25 structuredContent) // ============================================================================= diff --git a/src/adapters/postgresql/schemas/vector/input.ts b/src/adapters/postgresql/schemas/vector/input.ts index de5c9474..3ff52dc6 100644 --- a/src/adapters/postgresql/schemas/vector/input.ts +++ b/src/adapters/postgresql/schemas/vector/input.ts @@ -38,10 +38,7 @@ export const VectorSearchSchemaBase = z.object({ col: z.string().optional().describe("Alias for column"), vector: FiniteNumberArray.optional().describe("Query vector"), queryVector: FiniteNumberArray.optional().describe("Alias for vector"), - metric: z - .enum(["l2", "cosine", "inner_product"]) - .optional() - .describe("Distance metric"), + metric: z.string().optional().describe("Distance metric"), limit: z.unknown().optional().describe("Number of results"), select: z .array(z.string()) @@ -121,12 +118,9 @@ export const VectorCreateIndexSchemaBase = z.object({ tableName: z.string().optional().describe("Alias for table"), column: z.string().optional().describe("Vector column name"), col: z.string().optional().describe("Alias for column"), - type: z.enum(["ivfflat", "hnsw"]).optional().describe("Index type"), - method: z.enum(["ivfflat", "hnsw"]).optional().describe("Alias for type"), - metric: z - .enum(["l2", "cosine", "inner_product"]) - .optional() - .describe("Distance metric (default: l2)"), + type: z.string().optional().describe("Index type"), + method: z.string().optional().describe("Alias for type"), + metric: z.string().optional().describe("Distance metric (default: l2)"), distanceMetric: z.string().optional().describe("Alias for metric"), ifNotExists: z .boolean() @@ -203,3 +197,199 @@ export const VectorCreateExtensionSchemaBase = z.object({ .optional() .describe("Database schema to create the extension in (default: public)"), }); + +// Advanced Search schemas +export const HybridSearchSchemaBase = z.object({ + table: z.string().optional().describe("Table name"), + tableName: z.string().optional().describe("Alias for table"), + vectorColumn: z.string().optional().describe("Vector column"), + vectorCol: z.string().optional().describe("Alias for vectorColumn"), + vector_column: z.string().optional().describe("Alias for vectorColumn"), + column: z.string().optional().describe("Alias for vectorColumn"), + col: z.string().optional().describe("Alias for vectorColumn"), + textColumn: z.string().optional().describe("Text column for FTS"), + searchColumn: z.string().optional().describe("Alias for textColumn"), + search_column: z.string().optional().describe("Alias for textColumn"), + vector: FiniteNumberArray.optional().describe("Query vector"), + queryVector: FiniteNumberArray.optional().describe("Alias for vector"), + query_vector: FiniteNumberArray.optional().describe("Alias for vector"), + textQuery: z.string().optional().describe("Text search query"), + queryText: z.string().optional().describe("Alias for text search query"), + query: z.string().optional().describe("Alias for text search query"), + vectorWeight: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Weight for vector score (0-1, default: 0.5)"), + limit: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Max results"), + select: z + .array(z.string()) + .optional() + .describe("Columns to return (defaults to non-vector columns)"), +}); + +export const HybridSearchSchema = HybridSearchSchemaBase.transform((data) => ({ + table: data.table ?? data.tableName ?? "", + vectorColumn: + data.vectorColumn ?? + data.vector_column ?? + data.vectorCol ?? + data.column ?? + data.col ?? + "", + textColumn: data.textColumn ?? data.searchColumn ?? data.search_column, + vector: data.vector ?? data.queryVector ?? data.query_vector, + textQuery: data.textQuery ?? data.queryText ?? data.query, + vectorWeight: data.vectorWeight, + limit: data.limit, + select: data.select, +})); + +export const PerformanceSchemaBase = z.object({ + table: z.string().optional().describe("Table name"), + tableName: z.string().optional().describe("Alias for table"), + column: z.string().optional().describe("Vector column"), + col: z.string().optional().describe("Alias for column"), + testVector: FiniteNumberArray.optional().describe( + "Test vector for benchmarking", + ), + schema: z.string().optional().describe("Database schema (default: public)"), +}); + +export const PerformanceSchema = PerformanceSchemaBase.transform((data) => ({ + table: data.table ?? data.tableName ?? "", + column: data.column ?? data.col ?? "", + testVector: data.testVector, + schema: data.schema, +})); + +// Management schemas +export const VectorClusterSchemaBase = z.object({ + table: z.string().optional().describe("Table name"), + tableName: z.string().optional().describe("Alias for table"), + column: z.string().optional().describe("Vector column"), + col: z.string().optional().describe("Alias for column"), + k: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Number of clusters"), + clusters: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Alias for k (number of clusters)"), + iterations: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Max iterations (default: 10)"), + sampleSize: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Sample size for large tables"), + schema: z.string().optional().describe("Database schema (default: public)"), +}); + +export const VectorClusterSchema = VectorClusterSchemaBase.transform((data) => { + const rawK = (data.k ?? data.clusters) as unknown; + const rawIterations = data.iterations as unknown; + const rawSampleSize = data.sampleSize as unknown; + return { + table: data.table ?? data.tableName ?? "", + column: data.column ?? data.col ?? "", + k: rawK != null ? Number(rawK) : undefined, + iterations: rawIterations != null ? Number(rawIterations) : undefined, + sampleSize: rawSampleSize != null ? Number(rawSampleSize) : undefined, + schema: data.schema, + }; +}).refine((data) => data.k !== undefined, { + message: "k (or clusters alias) is required", +}); + +// Management schemas +export const IndexOptimizeSchemaBase = z.object({ + table: z.string().optional().describe("Table name"), + tableName: z.string().optional().describe("Alias for table"), + column: z.string().optional().describe("Vector column"), + col: z.string().optional().describe("Alias for column"), + schema: z.string().optional().describe("Database schema (default: public)"), +}); + +export const IndexOptimizeSchema = IndexOptimizeSchemaBase.transform( + (data) => ({ + table: data.table ?? data.tableName ?? "", + column: data.column ?? data.col ?? "", + schema: data.schema, + }), +); + +export const VectorDimensionReduceSchemaBase = z.object({ + vector: FiniteNumberArray.optional().describe( + "Vector to reduce (for direct mode)", + ), + table: z.string().optional().describe("Table name (for table mode)"), + tableName: z.string().optional().describe("Alias for table"), + column: z.string().optional().describe("Vector column name (for table mode)"), + col: z.string().optional().describe("Alias for column"), + idColumn: z + .string() + .optional() + .describe("ID column to include in results (default: id)"), + limit: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Max rows to process (default: 5, max: 100)"), + targetDimensions: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Target number of dimensions"), + target_dimensions: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Alias for targetDimensions"), + dimensions: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Alias for targetDimensions"), + seed: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Random seed for reproducibility"), + summarize: z + .boolean() + .optional() + .describe( + "Summarize reduced vectors to preview format in table mode (default: true)", + ), +}); + +export const VectorDimensionReduceSchema = + VectorDimensionReduceSchemaBase.transform((data) => { + const rawTarget = (data.targetDimensions ?? + data.target_dimensions ?? + data.dimensions) as unknown; + const rawLimit = data.limit as unknown; + const rawSeed = data.seed as unknown; + return { + ...data, + table: data.table ?? data.tableName, + column: data.column ?? data.col, + targetDimensions: rawTarget != null ? Number(rawTarget) : undefined, + limit: rawLimit != null ? Number(rawLimit) : undefined, + seed: rawSeed != null ? Number(rawSeed) : undefined, + }; + }).refine((data) => data.targetDimensions !== undefined, { + message: "targetDimensions (or dimensions alias) is required", + }); + +export const EmbedSchemaBase = z.object({ + text: z.string().optional().describe("Text to embed"), + input: z.string().optional().describe("Alias for text"), + model: z + .string() + .optional() + .describe("Model name (ignored, for compatibility)"), + dimensions: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Vector dimensions (default: 384)"), + summarize: z + .boolean() + .optional() + .describe("Truncate embedding for display (default: true)"), +}); + +export const EmbedSchema = EmbedSchemaBase.transform((data) => ({ + text: data.text ?? data.input, + model: data.model, + dimensions: data.dimensions, + summarize: data.summarize, +})); diff --git a/src/adapters/postgresql/schemas/vector/output.ts b/src/adapters/postgresql/schemas/vector/output.ts index 838f67d6..d5c80a8b 100644 --- a/src/adapters/postgresql/schemas/vector/output.ts +++ b/src/adapters/postgresql/schemas/vector/output.ts @@ -75,12 +75,16 @@ export const VectorInsertOutputSchema = z export const VectorSearchOutputSchema = z .object({ success: z.boolean().optional().describe("Whether search succeeded"), - results: z + rows: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Search results with distance"), count: z.number().optional().describe("Number of results"), metric: z.string().optional().describe("Distance metric used"), + truncated: z + .boolean() + .optional() + .describe("Whether results were truncated"), hint: z.string().optional().describe("Helpful hint"), note: z.string().optional().describe("Additional note"), error: z.string().optional().describe("Error message"), @@ -278,7 +282,7 @@ export const VectorIndexOptimizeOutputSchema = z export const HybridSearchOutputSchema = z .object({ success: z.boolean().optional().describe("Whether search succeeded"), - results: z + rows: z .array(z.record(z.string(), z.unknown())) .optional() .describe("Hybrid search results"), @@ -361,7 +365,7 @@ export const VectorDimensionReduceOutputSchema = z // Table mode table: z.string().optional().describe("Table name"), column: z.string().optional().describe("Column name"), - results: z + rows: z .array( z.object({ id: z.unknown().optional().describe("Row ID"), diff --git a/src/adapters/postgresql/tool-registry.ts b/src/adapters/postgresql/tool-registry.ts index 2d76fab1..88558110 100644 --- a/src/adapters/postgresql/tool-registry.ts +++ b/src/adapters/postgresql/tool-registry.ts @@ -28,6 +28,9 @@ import { getLtreeTools } from "./tools/ltree/index.js"; import { getPgcryptoTools } from "./tools/pgcrypto.js"; import { getIntrospectionTools } from "./tools/introspection/index.js"; import { getMigrationTools } from "./tools/migration/index.js"; +import { getSecurityTools } from "./tools/security/index.js"; +import { getRoleTools } from "./tools/roles/index.js"; +import { getDocStoreTools } from "./tools/docstore/index.js"; import { getCodeModeTools } from "./tools/codemode/index.js"; import { getPostgresResources } from "./resources/index.js"; import { getPostgresPrompts } from "./prompts/index.js"; @@ -55,6 +58,9 @@ export function getSupportedPostgresToolGroups(): ToolGroup[] { "pgcrypto", "introspection", "migration", + "security", + "roles", + "docstore", "codemode", ]; } @@ -85,6 +91,9 @@ export function buildPostgresToolDefinitions( ...getPgcryptoTools(adapter), ...getIntrospectionTools(adapter), ...getMigrationTools(adapter), + ...getSecurityTools(adapter), + ...getRoleTools(adapter), + ...getDocStoreTools(adapter), ...getCodeModeTools(adapter), ]; } diff --git a/src/adapters/postgresql/tools/__tests__/admin.test.ts b/src/adapters/postgresql/tools/__tests__/admin.test.ts index 65d3bbce..c9f4af1e 100644 --- a/src/adapters/postgresql/tools/__tests__/admin.test.ts +++ b/src/adapters/postgresql/tools/__tests__/admin.test.ts @@ -1135,7 +1135,7 @@ describe("admin.ts uncovered branches", () => { expect(result.value).toBe("256MB"); }); - it("should use setting alias for name in pg_set_config", async () => { + it("should use setting alias for value in pg_set_config", async () => { mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [{ set_config: "off" }], }); @@ -1143,17 +1143,19 @@ describe("admin.ts uncovered branches", () => { const tool = tools.find((t) => t.name === "pg_set_config")!; const result = (await tool.handler( { - setting: "enable_seqscan", // alias for name - value: "off", + name: "enable_seqscan", + setting: "off", // alias for value }, mockContext, )) as { success: boolean; parameter: string; + value: string; }; expect(result.success).toBe(true); expect(result.parameter).toBe("enable_seqscan"); + expect(result.value).toBe("off"); }); // admin.ts L488-489: cluster preprocessor null/non-object input diff --git a/src/adapters/postgresql/tools/__tests__/backup.test.ts b/src/adapters/postgresql/tools/__tests__/backup.test.ts index d88f7d43..95dba2c0 100644 --- a/src/adapters/postgresql/tools/__tests__/backup.test.ts +++ b/src/adapters/postgresql/tools/__tests__/backup.test.ts @@ -235,7 +235,7 @@ describe("pg_audit_diff_backup", () => { )) as any; expect(result.success).toBe(true); - expect(result.hasDifferences).toBe(true); + expect(result.hasDrift).toBe(true); expect( result.diff.additions.some((line: string) => line.includes("new_col")), ).toBe(true); diff --git a/src/adapters/postgresql/tools/__tests__/citext.test.ts b/src/adapters/postgresql/tools/__tests__/citext.test.ts index f23c8d0c..f82fc182 100644 --- a/src/adapters/postgresql/tools/__tests__/citext.test.ts +++ b/src/adapters/postgresql/tools/__tests__/citext.test.ts @@ -97,7 +97,7 @@ describe("Citext Tools", () => { )) as { success: boolean; error: string }; expect(result.success).toBe(false); - expect(result.error).toContain("not found"); + expect(result.error).toContain("does not exist"); }); it("should report already citext column", async () => { @@ -156,14 +156,14 @@ describe("Citext Tools", () => { .mockResolvedValueOnce({ rows: [ { - table_schema: "public", - table_name: "users", - column_name: "email", + schema: "public", + tableName: "users", + columnName: "email", }, { - table_schema: "public", - table_name: "users", - column_name: "username", + schema: "public", + tableName: "users", + columnName: "username", }, ], }); @@ -274,16 +274,16 @@ describe("Citext Tools", () => { .mockResolvedValueOnce({ rows: [ { - table_schema: "public", - table_name: "users", - column_name: "email", - data_type: "text", + schema: "public", + tableName: "users", + columnName: "email", + dataType: "text", }, { - table_schema: "public", - table_name: "users", - column_name: "username", - data_type: "character varying", + schema: "public", + tableName: "users", + columnName: "username", + dataType: "character varying", }, ], }); @@ -305,7 +305,7 @@ describe("Citext Tools", () => { mockAdapter.executeQuery .mockResolvedValueOnce({ rows: [{ total: 1 }] }) .mockResolvedValueOnce({ - rows: [{ column_name: "custom_field", data_type: "text" }], + rows: [{ columnName: "custom_field", dataType: "text" }], }); const tool = findTool("pg_citext_analyze_candidates"); @@ -321,7 +321,7 @@ describe("Citext Tools", () => { mockAdapter.executeQuery .mockResolvedValueOnce({ rows: [{ total: 1 }] }) .mockResolvedValueOnce({ - rows: [{ column_name: "email", data_type: "text" }], + rows: [{ columnName: "email", dataType: "text" }], }); const tool = findTool("pg_citext_analyze_candidates"); @@ -515,9 +515,9 @@ describe("Citext Tools", () => { .mockResolvedValueOnce({ rows: [{ "?column?": 1 }] }) // table exists .mockResolvedValueOnce({ rows: [ - { column_name: "email", data_type: "text", udt_name: "text" }, - { column_name: "username", data_type: "text", udt_name: "text" }, - { column_name: "bio", data_type: "text", udt_name: "text" }, + { columnName: "email", dataType: "text", udtName: "text" }, + { columnName: "username", dataType: "text", udtName: "text" }, + { columnName: "bio", dataType: "text", udtName: "text" }, ], }); @@ -550,9 +550,9 @@ describe("Citext Tools", () => { .mockResolvedValueOnce({ rows: [ { - column_name: "email", - data_type: "USER-DEFINED", - udt_name: "citext", + columnName: "email", + dataType: "USER-DEFINED", + udtName: "citext", }, ], }); @@ -576,7 +576,7 @@ describe("Citext Tools", () => { mockAdapter.executeQuery .mockResolvedValueOnce({ rows: [{ "?column?": 1 }] }) // table exists .mockResolvedValueOnce({ - rows: [{ column_name: "email", data_type: "text", udt_name: "text" }], + rows: [{ columnName: "email", dataType: "text", udtName: "text" }], }); const tool = findTool("pg_citext_schema_advisor"); @@ -606,7 +606,7 @@ describe("Citext Tools", () => { )) as { success: boolean; error: string }; expect(result.success).toBe(false); - expect(result.error).toContain("not found"); + expect(result.error).toContain("does not exist"); }); }); diff --git a/src/adapters/postgresql/tools/__tests__/introspection.test.ts b/src/adapters/postgresql/tools/__tests__/introspection.test.ts index 88dbf428..0aa67593 100644 --- a/src/adapters/postgresql/tools/__tests__/introspection.test.ts +++ b/src/adapters/postgresql/tools/__tests__/introspection.test.ts @@ -913,7 +913,7 @@ describe("pg_schema_snapshot", () => { } const tool = tools.find((t) => t.name === "pg_schema_snapshot")!; - await tool.handler({}, mockContext); + await tool.handler({ compact: false }, mockContext); // Tables query (first call) should contain pg_depend exclusion const tablesSql = mockAdapter.executeQuery.mock.calls[0]![0] as string; @@ -951,7 +951,10 @@ describe("pg_schema_snapshot", () => { } const tool = tools.find((t) => t.name === "pg_schema_snapshot")!; - const result = (await tool.handler({ schema: "public" }, mockContext)) as { + const result = (await tool.handler( + { schema: "public", compact: false }, + mockContext, + )) as { snapshot: Record; stats: Record; }; @@ -971,8 +974,8 @@ describe("pg_schema_snapshot", () => { }); it("should omit columns from tables by default (compact: true)", async () => { - // Mock 9 section queries - for (let i = 0; i < 9; i++) { + // Mock 3 section queries (tables, views, indexes) since compact is true by default + for (let i = 0; i < 3; i++) { mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [{ name: `item_${String(i)}`, schema: "public" }], }); @@ -2257,7 +2260,7 @@ describe("pg_migration_rollback β€” uncovered branches", () => { mockContext = createMockRequestContext(); }); - it("should return error when id is NaN (coerceNumber returns undefined)", async () => { + it("should return validation error when id is NaN", async () => { const tool = tools.find((t) => t.name === "pg_migration_rollback")!; const result = (await tool.handler({ id: NaN }, mockContext)) as { success: boolean; @@ -2265,8 +2268,9 @@ describe("pg_migration_rollback β€” uncovered branches", () => { }; expect(result.success).toBe(false); - // coerceNumber converts NaN β†’ undefined, so handler sees no id/version - expect(result.error).toContain("Either"); + // coerceStrictNumber properly surfaces NaN as a validation failure + expect(result.error).toContain("expected number"); + expect(result.error).toContain("NaN"); }); it("should return error when migration is already rolled back", async () => { diff --git a/src/adapters/postgresql/tools/__tests__/jsonb.test.ts b/src/adapters/postgresql/tools/__tests__/jsonb.test.ts index 6a17184a..de18b603 100644 --- a/src/adapters/postgresql/tools/__tests__/jsonb.test.ts +++ b/src/adapters/postgresql/tools/__tests__/jsonb.test.ts @@ -1300,8 +1300,8 @@ describe("jsonb/read.ts β€” uncovered branches", () => { const findTool = (name: string) => tools.find((t) => t.name === name); - // coerceNumber converts non-numeric strings to undefined β†’ default limit is used - it("pg_jsonb_extract should silently default non-numeric limit", async () => { + // coerceNumber converts "abc" to undefined, so it defaults and succeeds + it("pg_jsonb_extract should coerce non-numeric limit to default", async () => { mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [{ extracted_value: "test" }], }); @@ -1310,11 +1310,16 @@ describe("jsonb/read.ts β€” uncovered branches", () => { const result = (await tool.handler( { table: "users", column: "data", path: "$.name", limit: "abc" }, mockContext, - )) as { rows: unknown[]; count: number }; + )) as { + success: boolean; + error?: string; + rows?: unknown[]; + count?: number; + }; - // coerceNumber converts "abc" β†’ undefined β†’ default limit is used - expect(result.rows).toBeDefined(); - expect(result.count).toBe(1); + // limit defaults silently + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); }); it("pg_jsonb_extract should return error when table is missing", async () => { diff --git a/src/adapters/postgresql/tools/__tests__/kcache.test.ts b/src/adapters/postgresql/tools/__tests__/kcache.test.ts index d07dbfa9..8e39e43d 100644 --- a/src/adapters/postgresql/tools/__tests__/kcache.test.ts +++ b/src/adapters/postgresql/tools/__tests__/kcache.test.ts @@ -191,11 +191,11 @@ describe("Kcache Tools", () => { const tool = findTool("pg_kcache_top_cpu"); const result = (await tool!.handler({}, mockContext)) as { - topCpuQueries: unknown[]; + queries: unknown[]; description: string; }; - expect(result.topCpuQueries).toHaveLength(1); + expect(result.queries).toHaveLength(1); expect(result.description).toContain("CPU"); }); @@ -251,11 +251,11 @@ describe("Kcache Tools", () => { const tool = findTool("pg_kcache_top_io"); const result = (await tool!.handler({}, mockContext)) as { - topIoQueries: unknown[]; + queries: unknown[]; ioType: string; }; - expect(result.topIoQueries).toHaveLength(1); + expect(result.queries).toHaveLength(1); expect(result.ioType).toBe("both"); }); @@ -300,7 +300,7 @@ describe("Kcache Tools", () => { { ioType: "reads" }, mockContext, )) as { - topIoQueries: unknown[]; + queries: unknown[]; ioType: string; }; @@ -344,7 +344,7 @@ describe("Kcache Tools", () => { const tool = findTool("pg_kcache_database_stats"); const result = (await tool!.handler({}, mockContext)) as { - databaseStats: unknown[]; + stats: unknown[]; count: number; }; @@ -358,7 +358,11 @@ describe("Kcache Tools", () => { it("should filter by specific database", async () => { // First call: column detection mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [] }); - // Second call: actual query + // Second call: existence check + mockAdapter.executeQuery.mockResolvedValueOnce({ + rows: [{ exists: true }], + }); + // Third call: actual query mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [{ database: "testdb", total_cpu_time: 100.5 }], }); @@ -371,6 +375,27 @@ describe("Kcache Tools", () => { ["testdb"], ); }); + + it("should return ValidationError if specified database does not exist", async () => { + // First call: column detection + mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [] }); + // Second call: existence check returns false + mockAdapter.executeQuery.mockResolvedValueOnce({ + rows: [{ exists: false }], + }); + + const tool = findTool("pg_kcache_database_stats"); + const result = (await tool!.handler( + { database: "non_existent_db" }, + mockContext, + )) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("does not exist"); + }); }); describe("pg_kcache_resource_analysis", () => { @@ -646,10 +671,10 @@ describe("Kcache Tools", () => { const tool = findTool("pg_kcache_top_cpu"); const result = (await tool!.handler(undefined, mockContext)) as { - topCpuQueries: unknown[]; + queries: unknown[]; }; - expect(result.topCpuQueries).toHaveLength(1); + expect(result.queries).toHaveLength(1); }); it("pg_kcache_top_io should work with undefined params", async () => { @@ -660,11 +685,11 @@ describe("Kcache Tools", () => { const tool = findTool("pg_kcache_top_io"); const result = (await tool!.handler(undefined, mockContext)) as { - topIoQueries: unknown[]; + queries: unknown[]; ioType: string; }; - expect(result.topIoQueries).toHaveLength(1); + expect(result.queries).toHaveLength(1); expect(result.ioType).toBe("both"); }); @@ -676,10 +701,10 @@ describe("Kcache Tools", () => { const tool = findTool("pg_kcache_database_stats"); const result = (await tool!.handler(undefined, mockContext)) as { - databaseStats: unknown[]; + stats: unknown[]; }; - expect(result.databaseStats).toHaveLength(1); + expect(result.stats).toHaveLength(1); }); it("pg_kcache_query_stats should work with undefined params", async () => { diff --git a/src/adapters/postgresql/tools/__tests__/ltree.test.ts b/src/adapters/postgresql/tools/__tests__/ltree.test.ts index 6d9565e1..e51d0a65 100644 --- a/src/adapters/postgresql/tools/__tests__/ltree.test.ts +++ b/src/adapters/postgresql/tools/__tests__/ltree.test.ts @@ -28,7 +28,9 @@ describe("Ltree Tools", () => { describe("pg_ltree_create_extension", () => { it("should create ltree extension", async () => { - mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [] }); + mockAdapter.executeQuery + .mockResolvedValueOnce({ rows: [{ "?column?": 1 }] }) + .mockResolvedValueOnce({ rows: [] }); const tool = findTool("pg_ltree_create_extension"); const result = (await tool!.handler({}, mockContext)) as { @@ -70,7 +72,7 @@ describe("Ltree Tools", () => { path: "root.child1", }, mockContext, - )) as { mode: string; results: unknown[]; count: number }; + )) as { mode: string; rows: unknown[]; count: number }; expect(result.mode).toBe("descendants"); expect(result.count).toBe(2); @@ -498,7 +500,9 @@ describe("Ltree Tools", () => { }); it("should filter by schema", async () => { - mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [] }); + mockAdapter.executeQuery + .mockResolvedValueOnce({ rows: [{ "?column?": 1 }] }) + .mockResolvedValueOnce({ rows: [] }); const tool = findTool("pg_ltree_list_columns"); await tool!.handler({ schema: "custom" }, mockContext); diff --git a/src/adapters/postgresql/tools/__tests__/monitoring.test.ts b/src/adapters/postgresql/tools/__tests__/monitoring.test.ts index 2c8ca8a9..8dbf9239 100644 --- a/src/adapters/postgresql/tools/__tests__/monitoring.test.ts +++ b/src/adapters/postgresql/tools/__tests__/monitoring.test.ts @@ -38,7 +38,7 @@ describe("getMonitoringTools", () => { expect(toolNames).toContain("pg_uptime"); expect(toolNames).toContain("pg_recovery_status"); expect(toolNames).toContain("pg_capacity_planning"); - expect(toolNames).toContain("pg_resource_usage_analyze"); + expect(toolNames).toContain("pg_system_health"); expect(toolNames).toContain("pg_alert_threshold_set"); }); @@ -75,7 +75,7 @@ describe("Tool Annotations", () => { "pg_uptime", "pg_recovery_status", "pg_capacity_planning", - "pg_resource_usage_analyze", + "pg_system_health", ]; for (const toolName of readOnlyTools) { @@ -718,7 +718,7 @@ describe("pg_capacity_planning", () => { }); }); -describe("pg_resource_usage_analyze", () => { +describe("pg_system_health", () => { let mockAdapter: ReturnType; let tools: ReturnType; let mockContext: ReturnType; @@ -762,7 +762,7 @@ describe("pg_resource_usage_analyze", () => { ], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { backgroundWriter: unknown; checkpoints: unknown; @@ -790,7 +790,7 @@ describe("pg_resource_usage_analyze", () => { rows: [{ active_queries: 1, io_waiting: 0, lock_waiting: 0 }], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { analysis: { checkpointPressure: string }; }; @@ -813,7 +813,7 @@ describe("pg_resource_usage_analyze", () => { rows: [{ active_queries: 5, io_waiting: 3, lock_waiting: 0 }], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { analysis: { ioPattern: string }; }; @@ -834,7 +834,7 @@ describe("pg_resource_usage_analyze", () => { rows: [{ active_queries: 5, io_waiting: 0, lock_waiting: 4 }], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { analysis: { lockContention: string }; }; @@ -859,7 +859,7 @@ describe("pg_resource_usage_analyze", () => { rows: [{ active_queries: 1, io_waiting: 0, lock_waiting: 0 }], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { bufferUsage: { heapHitRate: string; indexHitRate: string }; }; @@ -885,7 +885,7 @@ describe("pg_resource_usage_analyze", () => { rows: [{ active_queries: 1, io_waiting: 0, lock_waiting: 0 }], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { bufferUsage: { heapHitRate: string; indexHitRate: string }; }; @@ -907,7 +907,7 @@ describe("pg_resource_usage_analyze", () => { rows: [{ active_queries: 5, io_waiting: 0, lock_waiting: 0 }], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { analysis: { ioPattern: string; lockContention: string }; }; @@ -1225,7 +1225,7 @@ describe("monitoring.ts branch coverage", () => { expect(result).toEqual({ success: true }); }); - it("pg_resource_usage_analyze PG17+ code path", async () => { + it("pg_system_health PG17+ code path", async () => { mockAdapter.executeQuery .mockResolvedValueOnce({ rows: [{ version_num: 170000 }] }) // PG17+ .mockResolvedValueOnce({ @@ -1266,7 +1266,7 @@ describe("monitoring.ts branch coverage", () => { ], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as Record< string, unknown @@ -1443,7 +1443,7 @@ describe("pg_capacity_planning β€” uncovered branches", () => { }); }); -describe("pg_resource_usage_analyze β€” uncovered branches", () => { +describe("pg_system_health β€” uncovered branches", () => { let mockAdapter: ReturnType; let tools: ReturnType; let mockContext: ReturnType; @@ -1479,7 +1479,7 @@ describe("pg_resource_usage_analyze β€” uncovered branches", () => { ], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { bufferUsage: { heapHitRate: string; indexHitRate: string }; analysis: { @@ -1527,7 +1527,7 @@ describe("pg_resource_usage_analyze β€” uncovered branches", () => { ], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { analysis: { heapCachePerformance: string; @@ -1582,7 +1582,7 @@ describe("pg_resource_usage_analyze β€” uncovered branches", () => { ], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { backgroundWriter: Record; checkpoints: Record; @@ -1627,7 +1627,7 @@ describe("pg_resource_usage_analyze β€” uncovered branches", () => { ], }); - const tool = tools.find((t) => t.name === "pg_resource_usage_analyze")!; + const tool = tools.find((t) => t.name === "pg_system_health")!; const result = (await tool.handler({}, mockContext)) as { analysis: { heapCachePerformance: string; diff --git a/src/adapters/postgresql/tools/__tests__/partman.test.ts b/src/adapters/postgresql/tools/__tests__/partman.test.ts index 76089211..8bad0035 100644 --- a/src/adapters/postgresql/tools/__tests__/partman.test.ts +++ b/src/adapters/postgresql/tools/__tests__/partman.test.ts @@ -69,7 +69,7 @@ describe("pg_partman_create_extension", () => { }; expect(mockAdapter.executeQuery).toHaveBeenCalledWith( - "CREATE EXTENSION IF NOT EXISTS pg_partman", + "CREATE EXTENSION IF NOT EXISTS pg_partman WITH SCHEMA public", ); expect(result.success).toBe(true); expect(result.message).toContain("pg_partman"); diff --git a/src/adapters/postgresql/tools/__tests__/postgis.test.ts b/src/adapters/postgresql/tools/__tests__/postgis.test.ts index 8b0d50f1..7dd0d449 100644 --- a/src/adapters/postgresql/tools/__tests__/postgis.test.ts +++ b/src/adapters/postgresql/tools/__tests__/postgis.test.ts @@ -207,7 +207,7 @@ describe("PostGIS Tools", () => { limit: 5, }, mockContext, - )) as { results: unknown[]; count: number }; + )) as { rows: unknown[]; count: number }; expect(result.count).toBe(2); expect(mockAdapter.executeQuery).toHaveBeenCalledWith( @@ -355,9 +355,9 @@ describe("PostGIS Tools", () => { distance: 500, }, mockContext, - )) as { results: unknown[] }; + )) as { rows: unknown[] }; - expect(result.results).toHaveLength(1); + expect(result.rows).toHaveLength(1); expect(mockAdapter.executeQuery).toHaveBeenCalledWith( expect.stringContaining("ST_Buffer"), [500], @@ -471,7 +471,7 @@ describe("PostGIS Tools", () => { maxLat: 40.8, }, mockContext, - )) as { results: unknown[]; count: number }; + )) as { rows: unknown[]; count: number }; expect(result.count).toBe(3); expect(mockAdapter.executeQuery).toHaveBeenCalledWith( diff --git a/src/adapters/postgresql/tools/__tests__/vector.test.ts b/src/adapters/postgresql/tools/__tests__/vector.test.ts index 825cc4e7..2e1c1da4 100644 --- a/src/adapters/postgresql/tools/__tests__/vector.test.ts +++ b/src/adapters/postgresql/tools/__tests__/vector.test.ts @@ -130,9 +130,9 @@ describe("Vector Tools", () => { vector: [0.1, 0.2, 0.3], }, mockContext, - )) as { results: unknown[]; metric: string }; + )) as { rows: unknown[]; metric: string }; - expect(result.results).toHaveLength(2); + expect(result.rows).toHaveLength(2); expect(result.metric).toBe("l2"); }); @@ -199,7 +199,7 @@ describe("Vector Tools", () => { ); expect(mockAdapter.executeQuery).toHaveBeenCalledWith( - expect.stringMatching(/category = 'tech'.*LIMIT 5/s), + expect.stringMatching(/category = 'tech'.*LIMIT 6/s), ); }); }); @@ -503,9 +503,9 @@ describe("Vector Tools", () => { textQuery: "machine learning", }, mockContext, - )) as { results: unknown[]; vectorWeight: number; textWeight: number }; + )) as { rows: unknown[]; vectorWeight: number; textWeight: number }; - expect(result.results).toHaveLength(1); + expect(result.rows).toHaveLength(1); expect(result.vectorWeight).toBe(0.5); expect(result.textWeight).toBe(0.5); expect(mockAdapter.executeQuery).toHaveBeenCalledWith( @@ -678,12 +678,12 @@ describe("Vector Tools", () => { )) as { originalDimensions: number; targetDimensions: number; - reduced: number[]; + reducedVector: number[]; }; expect(result.originalDimensions).toBe(100); expect(result.targetDimensions).toBe(10); - expect(result.reduced).toHaveLength(10); + expect(result.reducedVector).toHaveLength(10); expect(mockAdapter.executeQuery).not.toHaveBeenCalled(); }); diff --git a/src/adapters/postgresql/tools/backup/audit-backup.ts b/src/adapters/postgresql/tools/backup/audit-backup.ts index cb65c6b6..7890a387 100644 --- a/src/adapters/postgresql/tools/backup/audit-backup.ts +++ b/src/adapters/postgresql/tools/backup/audit-backup.ts @@ -26,6 +26,7 @@ import { AuditListBackupsSchemaBase, AuditListBackupsSchema, AuditRestoreBackupSchema, + AuditDiffBackupSchemaBase, AuditDiffBackupSchema, AuditListBackupsOutputSchema, AuditRestoreBackupOutputSchema, @@ -125,7 +126,7 @@ export function createAuditListBackupsTool( return { success: true, - ...(resultSnapshots.length > 0 && { snapshots: resultSnapshots }), + snapshots: resultSnapshots, count, limit, ...(isCompact && { compact: true }), @@ -347,7 +348,7 @@ export function createAuditDiffBackupTool( description: "Compare a backup snapshot's DDL against the current live schema to show drift since the snapshot was taken.", group: "backup", - inputSchema: AuditDiffBackupSchema, + inputSchema: AuditDiffBackupSchemaBase, outputSchema: AuditDiffBackupOutputSchema, annotations: readOnly("Audit Diff Backup"), icons: getToolIcons("backup", readOnly("Audit Diff Backup")), @@ -404,9 +405,9 @@ export function createAuditDiffBackupTool( let line = ` "${col.name}" ${col.type}`; if (col.defaultValue !== undefined && col.defaultValue !== null) { const defVal = - typeof col.defaultValue === "object" - ? JSON.stringify(col.defaultValue) - : String(col.defaultValue as string | number | boolean); + typeof col.defaultValue === "string" + ? col.defaultValue + : JSON.stringify(col.defaultValue); line += ` DEFAULT ${defVal}`; } if (!col.nullable) line += " NOT NULL"; @@ -585,7 +586,7 @@ export function createAuditDiffBackupTool( } } - let hasDifferences = schemaDrift; + let hasDrift = schemaDrift; if ( volumeDrift && ((volumeDrift.rowCountCurrent !== undefined && @@ -595,14 +596,14 @@ export function createAuditDiffBackupTool( volumeDrift.sizeBytesSnapshot !== undefined && volumeDrift.sizeBytesCurrent !== volumeDrift.sizeBytesSnapshot)) ) { - hasDifferences = true; + hasDrift = true; } return { success: true, metadata: snapshot.metadata, objectExists, - hasDifferences, + hasDrift, ...(schemaDrift && { diff: { ...(additions.length > 0 && { diff --git a/src/adapters/postgresql/tools/backup/copy.ts b/src/adapters/postgresql/tools/backup/copy.ts index 5551a02f..b05a341c 100644 --- a/src/adapters/postgresql/tools/backup/copy.ts +++ b/src/adapters/postgresql/tools/backup/copy.ts @@ -10,7 +10,7 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; + import { readOnly, write } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { @@ -20,6 +20,7 @@ import { import { CopyExportSchema, CopyExportSchemaBase, + CopyImportSchema, // Output schemas CopyExportOutputSchema, CopyImportOutputSchema, @@ -88,6 +89,7 @@ export function createCopyExportTool(adapter: PostgresAdapter): ToolDefinition { if (result.rows === undefined || result.rows.length === 0) { return { + success: true, data: lines.join("\n"), rowCount: 0, note: "Query returned no rows.", @@ -142,6 +144,7 @@ export function createCopyExportTool(adapter: PostgresAdapter): ToolDefinition { await sendProgress(progress, 3, 3, "Export complete"); return { + success: true, data: dataStr, rowCount: result.rows.length, ...(isPayloadTruncated @@ -170,6 +173,7 @@ export function createCopyExportTool(adapter: PostgresAdapter): ToolDefinition { if (result.rows === undefined || result.rows.length === 0) { return { + success: true, data: lines.join("\n"), rowCount: 0, note: "Query returned no rows.", @@ -220,6 +224,7 @@ export function createCopyExportTool(adapter: PostgresAdapter): ToolDefinition { await sendProgress(progress, 3, 3, "Export complete"); return { + success: true, data: dataStr, rowCount: result.rows.length, ...(isPayloadTruncated @@ -243,33 +248,20 @@ export function createCopyExportTool(adapter: PostgresAdapter): ToolDefinition { }; } -export function createCopyImportTool( - _adapter: PostgresAdapter, -): ToolDefinition { +export function createCopyImportTool(adapter: PostgresAdapter): ToolDefinition { return { name: "pg_copy_import", description: "Generate COPY FROM command for importing data.", group: "backup", - inputSchema: z.object({ - table: z.string().optional(), - schema: z.string().optional(), - filePath: z - .string() - .optional() - .describe("Path to import file (default: /path/to/file.csv)"), - format: z.string().optional().describe("Format (csv, text, binary)"), - header: z.boolean().optional(), - delimiter: z.string().optional(), - columns: z.array(z.string()).optional(), - }), + inputSchema: CopyImportSchema, outputSchema: CopyImportOutputSchema, annotations: write("Copy Import"), icons: getToolIcons("backup", write("Copy Import")), handler: (params: unknown, _context: RequestContext) => { try { return Promise.resolve() - .then(() => { - const rawParams = params as { + .then(async () => { + const rawParams = CopyImportSchema.parse(params) as { table?: string; tableName?: string; // Alias for table schema?: string; @@ -313,6 +305,19 @@ export function createCopyImportTool( } } + // Verify table exists (P154 compliance) + const checkSchema = schemaNamePart ?? "public"; + const tableExists = await adapter.executeQuery( + "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = $1 AND n.nspname = $2", + [tableNamePart, checkSchema], + ); + if ( + tableExists.rows === undefined || + tableExists.rows.length === 0 + ) { + throw new Error(`relation "${tableNamePart}" does not exist`); + } + const tableName = sanitizeTableName(tableNamePart, schemaNamePart); const columnClause = @@ -336,6 +341,7 @@ export function createCopyImportTool( const filePath = parsed.filePath ?? `/path/to/file.${ext}`; return { + success: true, command: `COPY ${tableName}${columnClause} FROM '${filePath}' WITH (${options.join(", ")})`, stdinCommand: `COPY ${tableName}${columnClause} FROM STDIN WITH (${options.join(", ")})`, notes: "Use \\copy in psql for client-side files", diff --git a/src/adapters/postgresql/tools/backup/dump.ts b/src/adapters/postgresql/tools/backup/dump.ts index d3e99885..4d10e6e9 100644 --- a/src/adapters/postgresql/tools/backup/dump.ts +++ b/src/adapters/postgresql/tools/backup/dump.ts @@ -9,11 +9,13 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; + import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { DumpSchemaSchema, + DumpTableSchemaBase, + DumpTableSchema, // Output schemas DumpTableOutputSchema, DumpSchemaOutputSchema, @@ -23,7 +25,6 @@ import { sanitizeIdentifier, sanitizeTableName, } from "../../../../utils/identifiers.js"; -import { coerceNumber } from "../../../../utils/query-helpers.js"; export function createDumpTableTool(adapter: PostgresAdapter): ToolDefinition { return { @@ -31,30 +32,13 @@ export function createDumpTableTool(adapter: PostgresAdapter): ToolDefinition { description: "Generate DDL for a table or sequence. Returns CREATE TABLE for tables, CREATE SEQUENCE for sequences.", group: "backup", - inputSchema: z.object({ - table: z.string().optional().describe("Table or sequence name"), - schema: z.string().optional().describe("Schema name (default: public)"), - includeData: z - .boolean() - .optional() - .describe("Include INSERT statements for table data"), - limit: z - .preprocess(coerceNumber, z.number().optional()) - .describe( - "Maximum rows to include when includeData is true (default: 500, use 0 for all rows)", - ), - }), + inputSchema: DumpTableSchemaBase, outputSchema: DumpTableOutputSchema, annotations: readOnly("Dump Table"), icons: getToolIcons("backup", readOnly("Dump Table")), handler: async (params: unknown, _context: RequestContext) => { try { - const parsed = params as { - table: string; - schema?: string; - includeData?: boolean; - limit?: number; - }; + const parsed = DumpTableSchema.parse(params); // Validate required table parameter if (!parsed.table || parsed.table.trim() === "") { @@ -134,6 +118,7 @@ export function createDumpTableTool(adapter: PostgresAdapter): ToolDefinition { const cycle = seq["cycle"] === true ? " CYCLE" : ""; const ddl = `CREATE SEQUENCE ${sanitizeTableName(tableName, schemaName)}${startValue}${increment}${minValue}${maxValue}${cycle};`; return { + success: true, ddl, type: "sequence", note: "Use pg_list_sequences to see all sequences.", @@ -148,6 +133,7 @@ export function createDumpTableTool(adapter: PostgresAdapter): ToolDefinition { } // Fallback if pg_sequence query fails return { + success: true, ddl: `CREATE SEQUENCE ${sanitizeTableName(tableName, schemaName)};`, type: "sequence", note: "Basic CREATE SEQUENCE. Use pg_list_sequences for details.", @@ -173,6 +159,7 @@ export function createDumpTableTool(adapter: PostgresAdapter): ToolDefinition { const createType = relkind === "m" ? "MATERIALIZED VIEW" : "VIEW"; const ddl = `CREATE ${createType} ${sanitizeTableName(tableName, schemaName)} AS\n${definition.trim()}`; return { + success: true, ddl, type: relkind === "m" ? "materialized_view" : "view", note: `Use pg_list_views to see all views.`, @@ -184,6 +171,7 @@ export function createDumpTableTool(adapter: PostgresAdapter): ToolDefinition { // Fallback for views const createType = relkind === "m" ? "MATERIALIZED VIEW" : "VIEW"; return { + success: true, ddl: `-- Unable to retrieve ${createType.toLowerCase()} definition\nCREATE ${createType} ${sanitizeTableName(tableName, schemaName)} AS SELECT ...;`, type: relkind === "m" ? "materialized_view" : "view", note: "View definition could not be retrieved. Use pg_list_views for details.", @@ -301,11 +289,13 @@ export function createDumpTableTool(adapter: PostgresAdapter): ToolDefinition { const createTable = `${sequenceDdls}CREATE TABLE ${sanitizeTableName(tableName, schemaName)} (\n${columns}\n)${partitionClause};`; const result: { + success: boolean; ddl: string; type?: string; insertStatements?: string; note: string; } = { + success: true, ddl: createTable, type: isPartitionedTable ? "partitioned_table" : "table", note: isPartitionedTable @@ -379,9 +369,7 @@ export function createDumpTableTool(adapter: PostgresAdapter): ToolDefinition { }; } -export function createDumpSchemaTool( - _adapter: PostgresAdapter, -): ToolDefinition { +export function createDumpSchemaTool(adapter: PostgresAdapter): ToolDefinition { return { name: "pg_dump_schema", description: "Get the pg_dump command for a schema or database.", @@ -390,10 +378,43 @@ export function createDumpSchemaTool( outputSchema: DumpSchemaOutputSchema, annotations: readOnly("Dump Schema"), icons: getToolIcons("backup", readOnly("Dump Schema")), - handler: (params: unknown, _context: RequestContext) => { + handler: async (params: unknown, _context: RequestContext) => { try { const { table, schema, filename } = DumpSchemaSchema.parse(params); + if (schema) { + // Verify schema exists + const schemaResult = await adapter.executeQuery( + "SELECT 1 FROM pg_namespace WHERE nspname = $1", + [schema], + ); + if (schemaResult.rows?.length === 0) { + throw new Error(`Schema "${schema}" does not exist`); + } + } + + if (table) { + // Verify table exists + const checkSchema = schema ?? "public"; + let tableNamePart = table; + let schemaNamePart = checkSchema; + + if (table.includes(".")) { + const parts = table.split("."); + if (parts.length === 2 && parts[0] && parts[1]) { + schemaNamePart = parts[0]; + tableNamePart = parts[1]; + } + } + const tableExists = await adapter.executeQuery( + "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = $1 AND n.nspname = $2", + [tableNamePart, schemaNamePart], + ); + if (tableExists.rows === undefined || tableExists.rows.length === 0) { + throw new Error(`relation "${tableNamePart}" does not exist`); + } + } + let command = "pg_dump"; command += " --format=custom"; command += " --verbose"; @@ -414,7 +435,8 @@ export function createDumpSchemaTool( command += ` --file="${outputFilename}"`; command += " $POSTGRES_CONNECTION_STRING"; - return Promise.resolve({ + return { + success: true, command, ...(schema !== undefined && table !== undefined && { @@ -428,11 +450,9 @@ export function createDumpSchemaTool( "Add --data-only to exclude schema", "Add --schema-only to exclude data", ], - }); + }; } catch (error: unknown) { - return Promise.resolve( - formatHandlerErrorResponse(error, { tool: "pg_dump_schema" }), - ); + return formatHandlerErrorResponse(error, { tool: "pg_dump_schema" }); } }, }; diff --git a/src/adapters/postgresql/tools/backup/planning.ts b/src/adapters/postgresql/tools/backup/planning.ts index 2cb3de5a..577720c3 100644 --- a/src/adapters/postgresql/tools/backup/planning.ts +++ b/src/adapters/postgresql/tools/backup/planning.ts @@ -9,7 +9,7 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; + import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; @@ -23,6 +23,9 @@ import { CreateBackupPlanSchema, PhysicalBackupSchemaBase, PhysicalBackupSchema, + RestoreCommandSchema, + RestoreValidateSchema, + BackupScheduleOptimizeSchema, } from "../../schemas/index.js"; export function createBackupPlanTool(adapter: PostgresAdapter): ToolDefinition { @@ -77,6 +80,7 @@ export function createBackupPlanTool(adapter: PostgresAdapter): ToolDefinition { const sizeGB = (sizeBytes / (1024 * 1024 * 1024)).toFixed(2); return { + success: true, strategy: { fullBackup: { // Use timestamp with hours/minutes for hourly backups to prevent overwrites @@ -124,18 +128,7 @@ export function createRestoreCommandTool( name: "pg_restore_command", description: "Generate pg_restore command for restoring backups.", group: "backup", - inputSchema: z.object({ - backupFile: z.string().optional(), - filename: z.string().optional().describe("Alias for backupFile"), - database: z - .string() - .optional() - .describe("Target database name (required for complete command)"), - schema: z.string().optional(), - table: z.string().optional(), - dataOnly: z.boolean().optional(), - schemaOnly: z.boolean().optional(), - }), + inputSchema: RestoreCommandSchema, outputSchema: RestoreCommandOutputSchema, annotations: readOnly("Restore Command"), icons: getToolIcons("backup", readOnly("Restore Command")), @@ -143,7 +136,7 @@ export function createRestoreCommandTool( try { return Promise.resolve() .then(() => { - const parsed = params as { + const parsed = RestoreCommandSchema.parse(params) as { backupFile?: string; filename?: string; database?: string; @@ -188,6 +181,7 @@ export function createRestoreCommandTool( command += ` "${backupFile}"`; return { + success: true, command, ...(warnings.length > 0 && { warnings }), notes: [ @@ -287,6 +281,7 @@ export function createPhysicalBackupTool( " -h ${PGHOST:-localhost} -p ${PGPORT:-5432} -U ${PGUSER:-postgres}"; return { + success: true, command, notes: [ "Set PGHOST, PGPORT, PGUSER environment variables or replace the placeholders directly", @@ -328,14 +323,7 @@ export function createRestoreValidateTool( description: "Generate commands to validate backup integrity and restorability.", group: "backup", - inputSchema: z.object({ - backupFile: z.string().optional().describe("Path to backup file"), - filename: z.string().optional().describe("Alias for backupFile"), - backupType: z - .string() - .optional() - .describe("Backup type (pg_dump, pg_basebackup)"), - }), + inputSchema: RestoreValidateSchema, outputSchema: RestoreValidateOutputSchema, annotations: readOnly("Restore Validate"), icons: getToolIcons("backup", readOnly("Restore Validate")), @@ -343,13 +331,7 @@ export function createRestoreValidateTool( try { return Promise.resolve() .then(() => { - // Parse params through schema to validate enum values - const schema = z.object({ - backupFile: z.string().optional(), - filename: z.string().optional(), - backupType: z.string().optional(), - }); - const parsed = schema.parse(params); + const parsed = RestoreValidateSchema.parse(params); if ( parsed.backupType && @@ -373,6 +355,7 @@ export function createRestoreValidateTool( if (backupType === "pg_dump") { return { + success: true, ...(defaultUsed && { note: "No backupType specified - defaulting to pg_dump validation steps", }), @@ -406,6 +389,7 @@ export function createRestoreValidateTool( }; } else { return { + success: true, validationSteps: [ { step: 1, @@ -459,7 +443,7 @@ export function createBackupScheduleOptimizeTool( description: "Analyze database activity patterns and recommend optimal backup schedule.", group: "backup", - inputSchema: z.object({}).strict(), + inputSchema: BackupScheduleOptimizeSchema, outputSchema: BackupScheduleOptimizeOutputSchema, annotations: readOnly("Backup Schedule Optimize"), icons: getToolIcons("backup", readOnly("Backup Schedule Optimize")), @@ -518,6 +502,7 @@ export function createBackupScheduleOptimizeTool( } return { + success: true, analysis: { databaseSize: dbSize.rows?.[0]?.["size"], totalChanges, diff --git a/src/adapters/postgresql/tools/citext/candidates-advisor.ts b/src/adapters/postgresql/tools/citext/candidates-advisor.ts index aa21c767..bf27cfbb 100644 --- a/src/adapters/postgresql/tools/citext/candidates-advisor.ts +++ b/src/adapters/postgresql/tools/citext/candidates-advisor.ts @@ -195,12 +195,12 @@ Looks for common patterns like email, username, name, slug, etc.`, const sql = ` SELECT - table_schema, - table_name, - column_name, - data_type, - character_maximum_length, - is_nullable + table_schema as "schema", + table_name as "tableName", + column_name as "columnName", + data_type as "dataType", + character_maximum_length as "maxLength", + is_nullable as "isNullable" FROM information_schema.columns WHERE ${whereClause} ORDER BY table_schema, table_name, ordinal_position @@ -219,7 +219,7 @@ Looks for common patterns like email, username, name, slug, etc.`, let mediumConfidenceCount = 0; for (const row of candidates) { - const colName = (row["column_name"] as string).toLowerCase(); + const colName = (row["columnName"] as string).toLowerCase(); if ( colName.includes("email") || colName.includes("username") || @@ -305,7 +305,7 @@ Requires the 'table' parameter to specify which table to analyze.`, if (!tableCheck.rows || tableCheck.rows.length === 0) { throw new ValidationError( - `Table ${qualifiedTable} not found. Verify the table name and schema.`, + `Table ${qualifiedTable} does not exist. Verify the table name and schema.`, { code: "TABLE_NOT_FOUND" }, ); } @@ -313,11 +313,11 @@ Requires the 'table' parameter to specify which table to analyze.`, const colResult = await adapter.executeQuery( ` SELECT - column_name, - data_type, - udt_name, - is_nullable, - character_maximum_length + column_name as "columnName", + data_type as "dataType", + udt_name as "udtName", + is_nullable as "isNullable", + character_maximum_length as "maxLength" FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 @@ -354,13 +354,13 @@ Requires the 'table' parameter to specify which table to analyze.`, ]; for (const col of columns) { - const colName = (col["column_name"] as string).toLowerCase(); - const dataType = col["data_type"] as string; - const udtName = col["udt_name"] as string; + const colName = (col["columnName"] as string).toLowerCase(); + const dataType = col["dataType"] as string; + const udtName = col["udtName"] as string; if (udtName === "citext") { recommendations.push({ - column: col["column_name"] as string, + column: col["columnName"] as string, currentType: "citext", previousType: "text or varchar (converted)", recommendation: "already_citext", @@ -379,7 +379,7 @@ Requires the 'table' parameter to specify which table to analyze.`, if (isHighConfidence) { recommendations.push({ - column: col["column_name"] as string, + column: col["columnName"] as string, currentType: dataType, recommendation: "convert", confidence: "high", @@ -387,7 +387,7 @@ Requires the 'table' parameter to specify which table to analyze.`, }); } else if (isMediumConfidence) { recommendations.push({ - column: col["column_name"] as string, + column: col["columnName"] as string, currentType: dataType, recommendation: "convert", confidence: "medium", @@ -395,7 +395,7 @@ Requires the 'table' parameter to specify which table to analyze.`, }); } else if (!compact) { recommendations.push({ - column: col["column_name"] as string, + column: col["columnName"] as string, currentType: dataType, recommendation: "keep", confidence: "low", diff --git a/src/adapters/postgresql/tools/citext/list-compare.ts b/src/adapters/postgresql/tools/citext/list-compare.ts index e3f44ee6..f08706ef 100644 --- a/src/adapters/postgresql/tools/citext/list-compare.ts +++ b/src/adapters/postgresql/tools/citext/list-compare.ts @@ -88,6 +88,28 @@ Useful for auditing case-insensitive columns.`, } } + // Validate table existence when specified + if (table !== undefined) { + const schemaCondition = + schema !== undefined ? "AND table_schema = $2" : ""; + const queryParams: unknown[] = [table]; + if (schema !== undefined) queryParams.push(schema); + + const tableCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.tables + WHERE table_name = $1 ${schemaCondition}`, + queryParams, + ); + if (!tableCheck.rows || tableCheck.rows.length === 0) { + throw new ValidationError( + schema !== undefined + ? `Table "${schema}"."${table}" does not exist. Verify the table name and schema.` + : `Table "${table}" does not exist. Verify the table name.`, + { code: "TABLE_NOT_FOUND" }, + ); + } + } + const conditions: string[] = [ "udt_name = 'citext'", "table_schema NOT IN ('pg_catalog', 'information_schema')", @@ -122,11 +144,11 @@ Useful for auditing case-insensitive columns.`, const sql = ` SELECT - table_schema, - table_name, - column_name, - is_nullable, - column_default + table_schema as "schema", + table_name as "tableName", + column_name as "columnName", + is_nullable as "isNullable", + column_default as "columnDefault" FROM information_schema.columns WHERE ${whereClause} ORDER BY table_schema, table_name, ordinal_position diff --git a/src/adapters/postgresql/tools/citext/setup.ts b/src/adapters/postgresql/tools/citext/setup.ts index d437f849..79c787a8 100644 --- a/src/adapters/postgresql/tools/citext/setup.ts +++ b/src/adapters/postgresql/tools/citext/setup.ts @@ -45,6 +45,16 @@ citext is ideal for emails, usernames, and other identifiers where case shouldn' let query = "CREATE EXTENSION IF NOT EXISTS citext"; if (typeof schema === "string" && schema.length > 0) { + // Check if schema exists first + const schemaCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.schemata WHERE schema_name = $1`, + [schema], + ); + if (!schemaCheck.rows || schemaCheck.rows.length === 0) { + throw new ValidationError(`Schema "${schema}" does not exist.`, { + code: "SCHEMA_NOT_FOUND", + }); + } query += ` SCHEMA "${schema}"`; } @@ -131,7 +141,7 @@ Note: If views depend on this column, you must drop and recreate them manually b if (!colCheck.rows || colCheck.rows.length === 0) { throw new ValidationError( - `Column "${column}" not found in table ${qualifiedTable}. Verify the column name.`, + `Column "${column}" does not exist in table ${qualifiedTable}. Verify the column name.`, { code: "COLUMN_NOT_FOUND" }, ); } diff --git a/src/adapters/postgresql/tools/core/__tests__/core.test.ts b/src/adapters/postgresql/tools/core/__tests__/core.test.ts index ee68d4c2..a0b88495 100644 --- a/src/adapters/postgresql/tools/core/__tests__/core.test.ts +++ b/src/adapters/postgresql/tools/core/__tests__/core.test.ts @@ -387,10 +387,10 @@ describe("Handler Execution", () => { hint?: string; }; - expect(result.count).toBe(100); // Default limit + expect(result.count).toBe(20); // Default limit expect(result.totalCount).toBe(150); expect(result.truncated).toBe(true); - expect(result.hint).toContain("100 of 150"); + expect(result.hint).toContain("20 of 150"); }); it("should respect custom limit", async () => { @@ -581,7 +581,7 @@ describe("Handler Execution", () => { )) as { success: boolean; error: string }; expect(result.success).toBe(false); - expect(result.error).toMatch(/not found/); + expect(result.error).toMatch(/does not exist/); }); it("should return structured error for indexes", async () => { diff --git a/src/adapters/postgresql/tools/core/convenience-schemas.ts b/src/adapters/postgresql/tools/core/convenience-schemas.ts index dc857a21..1b20fe42 100644 --- a/src/adapters/postgresql/tools/core/convenience-schemas.ts +++ b/src/adapters/postgresql/tools/core/convenience-schemas.ts @@ -7,6 +7,10 @@ import { z } from "zod"; import type { PostgresAdapter } from "../../postgres-adapter.js"; +import { + ErrorCategory, + type ErrorResponse, +} from "../../../../types/error-types.js"; // ============================================================================= // Table Existence Validation (P154 Pattern) @@ -22,7 +26,7 @@ export async function validateTableExists( table: string, schema: string, transactionId?: string, -): Promise { +): Promise { const client = transactionId ? adapter.getTransactionConnection(transactionId) : undefined; @@ -34,7 +38,16 @@ export async function validateTableExists( : await adapter.executeQuery(schemaSql, [schema]); if (!schemaResult.rows || schemaResult.rows.length === 0) { - return `Schema '${schema}' does not exist. Use pg_list_objects with type 'table' to see available schemas.`; + return { + success: false, + error: `Schema '${schema}' does not exist. Use pg_list_objects with type 'table' to see available schemas.`, + code: "SCHEMA_NOT_FOUND", + category: ErrorCategory.RESOURCE, + suggestion: + "Schema not found. Use pg_list_schemas to see available schemas.", + recoverable: false, + details: undefined, + }; } const tableSql = `SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2`; @@ -43,7 +56,16 @@ export async function validateTableExists( : await adapter.executeQuery(tableSql, [schema, table]); if (!result.rows || result.rows.length === 0) { - return `Table '${schema}.${table}' not found. Use pg_list_tables to see available tables.`; + return { + success: false, + error: `Table '${schema}.${table}' not found. Use pg_list_tables to see available tables.`, + code: "TABLE_NOT_FOUND", + category: ErrorCategory.RESOURCE, + suggestion: + "Table or view does not exist. Run pg_list_tables to see available tables.", + recoverable: false, + details: undefined, + }; } return null; } @@ -251,7 +273,7 @@ export const CountSchemaBase = z.object({ .optional() .describe("WHERE clause (supports $1, $2 placeholders)"), params: z - .array(z.unknown()) + .unknown() .optional() .describe("Parameters for WHERE clause placeholders"), condition: z.string().optional().describe("Alias for where"), @@ -337,7 +359,7 @@ export const ExistsSchemaBase = z.object({ .optional() .describe("WHERE clause (supports $1, $2 placeholders)"), params: z - .array(z.unknown()) + .unknown() .optional() .describe("Parameters for WHERE clause placeholders"), condition: z.string().optional().describe("Alias for where"), @@ -408,11 +430,11 @@ export const TruncateSchemaBase = z.object({ tableName: z.string().optional().describe("Alias for table"), schema: z.string().optional().describe("Schema name (default: public)"), cascade: z - .boolean() + .unknown() .optional() .describe("Use CASCADE to truncate dependent tables"), restartIdentity: z - .boolean() + .unknown() .optional() .describe("Restart identity sequences"), }); @@ -426,11 +448,11 @@ const TruncateParseSchema = z.object({ tableName: z.string().optional().describe("Alias for table"), schema: z.string().optional().describe("Schema name (default: public)"), cascade: z - .boolean() + .unknown() .optional() .describe("Use CASCADE to truncate dependent tables"), restartIdentity: z - .boolean() + .unknown() .optional() .describe("Restart identity sequences"), }); diff --git a/src/adapters/postgresql/tools/core/convenience.ts b/src/adapters/postgresql/tools/core/convenience.ts index 9b035c32..a6f22c8a 100644 --- a/src/adapters/postgresql/tools/core/convenience.ts +++ b/src/adapters/postgresql/tools/core/convenience.ts @@ -72,7 +72,7 @@ export function createUpsertTool(adapter: PostgresAdapter): ToolDefinition { | undefined, ); if (validationError) { - return { success: false, error: validationError }; + return validationError; } const qualifiedTable = `"${schemaName}"."${parsed.table}"`; @@ -234,7 +234,7 @@ export function createBatchInsertTool( | undefined, ); if (validationError) { - return { success: false, error: validationError }; + return validationError; } const qualifiedTable = `"${schemaName}"."${parsed.table}"`; diff --git a/src/adapters/postgresql/tools/core/error-helpers.ts b/src/adapters/postgresql/tools/core/error-helpers.ts index 8d327f81..388a5494 100644 --- a/src/adapters/postgresql/tools/core/error-helpers.ts +++ b/src/adapters/postgresql/tools/core/error-helpers.ts @@ -21,9 +21,10 @@ export type { ErrorContext } from "./error-parser.js"; */ function isZodLikeError( error: unknown, -): error is Error & { issues: { message?: string; path?: unknown[] }[] } { +): error is { issues: { message?: string; path?: unknown[] }[] } { return ( - error instanceof Error && + typeof error === "object" && + error !== null && "issues" in error && Array.isArray((error as Record)["issues"]) ); diff --git a/src/adapters/postgresql/tools/core/error-parser.ts b/src/adapters/postgresql/tools/core/error-parser.ts index e8f7b5ee..8a72bba1 100644 --- a/src/adapters/postgresql/tools/core/error-parser.ts +++ b/src/adapters/postgresql/tools/core/error-parser.ts @@ -54,6 +54,55 @@ export function parsePostgresError( const msg = error.message; + // Extension missing guards (checked first because they can throw various codes like 42883 or 42P01) + if ( + context.tool?.startsWith("pg_cron_") && + /(?:relation ["']?cron\.job(?:_run_details)?["']?|schema ["']?cron["']?)/i.test( + msg, + ) + ) { + throw new Error( + `Extension "pg_cron" is not available. Ensure it is installed and enabled.`, + { cause: error }, + ); + } + + if ( + context.tool?.startsWith("pg_kcache_") && + /(?:relation|function) ["']?pg_stat_kcache(?:_.*)?(?:\(\))?["']? does not exist/i.test( + msg, + ) + ) { + throw new Error( + `Extension "pg_stat_kcache" is not available. Ensure it is installed and enabled.`, + { cause: error }, + ); + } + + if ( + context.tool?.startsWith("pg_ltree_") && + /(?:type|operator|function|relation|class) ["']?(?:ltree|lquery|ltxtquery|lca|nlevel|subpath|gist_ltree_ops)["']?(?:\([^)]*\))? does not exist/i.test( + msg, + ) + ) { + throw new Error( + `Extension "ltree" is not available. Ensure it is installed and enabled.`, + { cause: error }, + ); + } + + if ( + context.tool?.startsWith("pg_fuzzy_match") && + /(?:function|type|operator) ["']?(?:levenshtein|soundex|metaphone|damerau-levenshtein|levenshtein_less_equal)["']?(?:\([^)]*\))? does not exist/i.test( + msg, + ) + ) { + throw new Error( + `Extension "fuzzystrmatch" is not available. Ensure it is installed and enabled.`, + { cause: error }, + ); + } + // 42P01 β€” relation does not exist (table, view, sequence) // Regex anchored: must NOT be preceded by "of " (which indicates 42703 column errors) if ( @@ -63,18 +112,6 @@ export function parsePostgresError( ) && !/of relation/i.test(msg)) ) { - if ( - context.tool?.startsWith("pg_cron_") && - /(?:relation ["']?cron\.job["']?|relation ["']?cron\.job_run_details["']?)/i.test( - msg, - ) - ) { - throw new Error( - `Extension "pg_cron" is not available. Ensure it is installed and enabled.`, - { cause: error }, - ); - } - // pg_reindex with target=index: index-specific message if (context.tool === "pg_reindex" && context.target === "index") { const match = @@ -98,6 +135,13 @@ export function parsePostgresError( ); } + if (context.objectType === "view" || /view/i.test(msg)) { + throw new Error( + `View "${objectName}" does not exist in schema "${schemaName}". Use pg_list_views to see available views.`, + { cause: error }, + ); + } + throw new Error( `Table "${objectName}" does not exist in schema "${schemaName}". Use pg_list_tables to see available tables.`, { cause: error }, @@ -118,6 +162,11 @@ export function parsePostgresError( // 42P07 β€” duplicate relation (table, index, sequence, or view already exists) if (pgCode === "42P07" || /already exists/i.test(msg)) { + // Preserve manually formatted Collection error + if (/^Collection ['"][^'"]+['"] already exists/i.test(msg)) { + throw error; + } + const match = /relation "([^"]+)"/i.exec(msg); const objectName = match?.[1] ?? context.index ?? context.table ?? "unknown"; @@ -126,6 +175,7 @@ export function parsePostgresError( if ( context.tool === "pg_create_index" || context.tool === "pg_vector_create_index" || + context.tool === "pg_doc_create_index" || /index/i.test(msg) || context.index || /^idx_/i.test(objectName) @@ -232,6 +282,19 @@ export function parsePostgresError( ); } + // 2200H β€” sequence generator limit exceeded + if ( + pgCode === "2200H" || + /reached (maximum|minimum) value of sequence/i.test(msg) + ) { + const match = /sequence "([^"]+)"/i.exec(msg); + const seqName = match?.[1] ?? context.target ?? "unknown"; + throw new Error( + `Sequence '${seqName}' has reached its limit. Alter the sequence to change limits or enable cycle.`, + { cause: error }, + ); + } + // 25P02 β€” current transaction is aborted (checked before 42704 whose broad regex would match) if (pgCode === "25P02" || /current transaction is aborted/i.test(msg)) { throw new Error( @@ -286,16 +349,6 @@ export function parsePostgresError( if (pgCode === "42704" || /does not exist/i.test(msg)) { // Schema-specific: "schema X does not exist" (e.g., CREATE TABLE in nonexistent schema) if (/schema ["'].*["'] does not exist/i.test(msg)) { - if ( - context.tool?.startsWith("pg_cron_") && - /schema ["']cron["']/i.test(msg) - ) { - throw new Error( - `Extension "pg_cron" is not available. Ensure it is installed and enabled.`, - { cause: error }, - ); - } - const schemaMatch = /schema ["']([^"']+)["']/i.exec(msg); const schemaName = schemaMatch?.[1] ?? context.schema ?? "unknown"; throw new Error( @@ -390,9 +443,22 @@ export function parsePostgresError( ); } + // Preserve manually formatted Collection error + if (/^Collection ['"][^'"]+['"] does not exist/i.test(msg)) { + throw error; + } + + // Operator does not exist (e.g., trying to use LIKE on an integer column without cast) + if (/operator does not exist:/i.test(msg)) { + throw new Error( + `Type mismatch or missing operator: ${msg}. You may need to add explicit type casts, or verify the column type.`, + { cause: error }, + ); + } + // Generic "does not exist" fallback const match = - /(?:table|relation|object) ["']([^"']+)["']/i.exec(msg) ?? + /(?:table|relation|object|collection) ["']([^"']+)["']/i.exec(msg) ?? /["']([^"']+)["'] does not exist/i.exec(msg); const objectName = match?.[1] ?? context.table ?? "unknown"; throw new Error( @@ -411,16 +477,6 @@ export function parsePostgresError( // 3F000 β€” invalid schema name if (pgCode === "3F000" || /schema ["'].*["'] does not exist/i.test(msg)) { - if ( - context.tool?.startsWith("pg_cron_") && - /schema ["']cron["']/i.test(msg) - ) { - throw new Error( - `Extension "pg_cron" is not available. Ensure it is installed and enabled.`, - { cause: error }, - ); - } - const match = /schema "([^"]+)"/i.exec(msg); const schemaName = match?.[1] ?? context.schema ?? "unknown"; throw new Error( diff --git a/src/adapters/postgresql/tools/core/objects.ts b/src/adapters/postgresql/tools/core/objects.ts index 619fa7ce..190fe70b 100644 --- a/src/adapters/postgresql/tools/core/objects.ts +++ b/src/adapters/postgresql/tools/core/objects.ts @@ -42,9 +42,8 @@ export function createListObjectsTool( outputSchema: ObjectListOutputSchema, handler: async (params: unknown, _context: RequestContext) => { try { - const { schema, types, limit } = ListObjectsSchema.parse(params); - - // Validate types against allowed values (handler-side since Base schema uses z.string()) + const { schema, types, limit, exclude } = + ListObjectsSchema.parse(params); if (types) { const invalidTypes = types.filter( (t) => !(VALID_OBJECT_TYPES as readonly string[]).includes(t), @@ -62,10 +61,15 @@ export function createListObjectsTool( } } - const schemaFilter = schema + let schemaFilter = schema ? `AND n.nspname = '${schema}'` : `AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')`; + if (exclude && exclude.length > 0) { + const excludeList = exclude.map((s) => `'${s}'`).join(", "); + schemaFilter += ` AND n.nspname NOT IN (${excludeList})`; + } + const typeFilters: string[] = []; const selectedTypes = types ?? [ "table", @@ -139,11 +143,7 @@ export function createListObjectsTool( FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE p.prokind IN (${kindFilter.join(", ")}) - ${ - schema - ? `AND n.nspname = '${schema}'` - : `AND n.nspname NOT IN ('pg_catalog', 'information_schema')` - } + ${schemaFilter} ORDER BY n.nspname, p.proname `; const result = await adapter.executeQuery(sql); @@ -187,12 +187,14 @@ export function createListObjectsTool( objects.push(...(result.rows as typeof objects)); } - // Apply default limit of 100 if not specified - const effectiveLimit = limit ?? 100; - const truncated = objects.length > effectiveLimit; - const limitedObjects = truncated - ? objects.slice(0, effectiveLimit) - : objects; + // Apply default limit of 20 to reduce payload size if not specified + const effectiveLimit = limit === 0 ? undefined : (limit ?? 20); + const truncated = + effectiveLimit !== undefined && objects.length > effectiveLimit; + const limitedObjects = + truncated && effectiveLimit !== undefined + ? objects.slice(0, effectiveLimit) + : objects; return { objects: limitedObjects, @@ -330,9 +332,7 @@ export function createObjectDetailsTool( schemaName, ]); if (viewDefResult.rows && viewDefResult.rows.length > 0) { - details["definition"] = viewDefResult.rows[0]?.[ - "definition" - ] as string; + details["definition"] = viewDefResult.rows[0]?.["definition"]; details["hasDefinition"] = true; } } @@ -358,7 +358,7 @@ export function createObjectDetailsTool( ...details, ...funcRow, // Add camelCase aliases - returnType: funcRow["return_type"] as string, + returnType: funcRow["return_type"], }; } } else if (objectType === "sequence") { diff --git a/src/adapters/postgresql/tools/core/schemas/input.ts b/src/adapters/postgresql/tools/core/schemas/input.ts index f3f1ce46..dff02c27 100644 --- a/src/adapters/postgresql/tools/core/schemas/input.ts +++ b/src/adapters/postgresql/tools/core/schemas/input.ts @@ -63,15 +63,24 @@ export const ListObjectsSchemaBase = z.object({ .optional() .describe("Alias for types (singular or array)"), limit: z - .number() + .unknown() .optional() - .describe("Maximum number of objects to return (default: 100)"), + .describe("Maximum number of objects to return (default: 20)"), + exclude: z.unknown().optional().describe("Schemas to exclude"), +}); + +const ListObjectsParseSchema = z.object({ + schema: z.string().optional(), + types: z.array(z.string()).optional(), + type: z.union([z.string(), z.array(z.string())]).optional(), + limit: z.number().optional(), + exclude: z.array(z.string()).optional(), }); // Transformed schema with preprocess for handler parsing export const ListObjectsSchema = z.preprocess( preprocessListObjectsParams, - ListObjectsSchemaBase, + ListObjectsParseSchema, ); // Inner schema for ObjectDetails (used by preprocess and as base for MCP visibility) @@ -159,41 +168,53 @@ export const ObjectDetailsSchema = z export const AnalyzeDbHealthSchemaBase = z.object({ includeIndexes: z - .boolean() + .unknown() .optional() .describe("Include unused indexes analysis (default: true)"), includeVacuum: z - .boolean() + .unknown() .optional() .describe("Include tables needing vacuum analysis (default: true)"), includeConnections: z - .boolean() + .unknown() .optional() .describe("Include connection stats (default: true)"), }); +const AnalyzeDbHealthParseSchema = z.object({ + includeIndexes: z.boolean().optional(), + includeVacuum: z.boolean().optional(), + includeConnections: z.boolean().optional(), +}); + export const AnalyzeDbHealthSchema = z.preprocess( defaultToEmpty, - AnalyzeDbHealthSchemaBase, + AnalyzeDbHealthParseSchema, ); export const AnalyzeWorkloadIndexesSchemaBase = z.object({ topQueries: z - .number() + .unknown() .optional() .describe("Number of top queries to analyze (default: 20)"), - minCalls: z.number().optional().describe("Minimum call count threshold"), + minCalls: z.unknown().optional().describe("Minimum call count threshold"), queryPreviewLength: z - .number() + .unknown() .optional() .describe( "Maximum characters for query preview (default: 200). Truncated queries end with '…'", ), }); +const AnalyzeWorkloadIndexesParseSchema = z.object({ + topQueries: z.number().optional(), + minCalls: z.number().optional(), + queryPreviewLength: z.number().optional(), +}); + export const AnalyzeWorkloadIndexesSchema = z.preprocess( defaultToEmpty, - AnalyzeWorkloadIndexesSchemaBase, + AnalyzeWorkloadIndexesParseSchema, ); // Base schema for MCP visibility - exported so tool can use it for inputSchema @@ -203,7 +224,7 @@ export const AnalyzeQueryIndexesSchemaBase = z.object({ .optional() .describe("Query to analyze for index recommendations"), query: z.string().optional().describe("Alias for sql"), - params: z.array(z.unknown()).optional().describe("Query parameters"), + params: z.unknown().optional().describe("Query parameters"), verbosity: z .string() .optional() @@ -212,9 +233,16 @@ export const AnalyzeQueryIndexesSchemaBase = z.object({ ), }); +const AnalyzeQueryIndexesParseSchema = z.object({ + sql: z.string().optional(), + query: z.string().optional(), + params: z.array(z.unknown()).optional(), + verbosity: z.string().optional(), +}); + // Transformed schema with alias resolution export const AnalyzeQueryIndexesSchema = - AnalyzeQueryIndexesSchemaBase.transform((data) => ({ + AnalyzeQueryIndexesParseSchema.transform((data) => ({ sql: data.sql ?? data.query ?? "", params: data.params, verbosity: data.verbosity ?? "summary", diff --git a/src/adapters/postgresql/tools/core/tables.ts b/src/adapters/postgresql/tools/core/tables.ts index 646b28b1..72f4fff1 100644 --- a/src/adapters/postgresql/tools/core/tables.ts +++ b/src/adapters/postgresql/tools/core/tables.ts @@ -44,6 +44,17 @@ export function createListTablesTool(adapter: PostgresAdapter): ToolDefinition { handler: async (params: unknown, _context: RequestContext) => { try { const { schema, limit, exclude } = ListTablesSchema.parse(params); + + if (schema) { + const schemaCheck = await adapter.executeQuery( + `SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1`, + [schema], + ); + if (!schemaCheck.rows || schemaCheck.rows.length === 0) { + throw new Error(`schema "${schema}" does not exist`); + } + } + let tables = await adapter.listTables(); if (schema) { @@ -58,8 +69,8 @@ export function createListTablesTool(adapter: PostgresAdapter): ToolDefinition { // totalCount reflects filtered results (after schema/exclude), before limit const totalCount = tables.length; - // Apply default limit of 100 if not specified; limit: 0 means "no limit" (return all) - const effectiveLimit = limit === 0 ? undefined : (limit ?? 100); + // Apply default limit of 20 if not specified; limit: 0 means "no limit" (return all) + const effectiveLimit = limit === 0 ? undefined : (limit ?? 20); const truncated = effectiveLimit !== undefined && tables.length > effectiveLimit; if (truncated && effectiveLimit !== undefined) { @@ -114,9 +125,7 @@ export function createDescribeTableTool( ); if (!typeCheck.rows || typeCheck.rows.length === 0) { - throw new Error( - `Object '${schemaName}.${table}' not found. Use pg_list_tables to see available tables.`, - ); + throw new Error(`relation "${table}" does not exist`); } const relkind = typeCheck.rows[0]?.["relkind"] as string; diff --git a/src/adapters/postgresql/tools/core/utility.ts b/src/adapters/postgresql/tools/core/utility.ts index fb7510be..931fc004 100644 --- a/src/adapters/postgresql/tools/core/utility.ts +++ b/src/adapters/postgresql/tools/core/utility.ts @@ -62,7 +62,7 @@ export function createCountTool(adapter: PostgresAdapter): ToolDefinition { schemaName, ); if (validationError) { - return { success: false, error: validationError }; + return validationError; } const qualifiedTable = `"${schemaName}"."${parsed.table}"`; @@ -118,7 +118,7 @@ export function createExistsTool(adapter: PostgresAdapter): ToolDefinition { schemaName, ); if (validationError) { - return { success: false, error: validationError }; + return validationError; } const qualifiedTable = `"${schemaName}"."${parsed.table}"`; @@ -173,7 +173,7 @@ export function createTruncateTool(adapter: PostgresAdapter): ToolDefinition { schemaName, ); if (validationError) { - return { success: false, error: validationError }; + return validationError; } const qualifiedTable = `"${schemaName}"."${parsed.table}"`; diff --git a/src/adapters/postgresql/tools/cron/management.ts b/src/adapters/postgresql/tools/cron/management.ts index 3b01d3f0..467923cf 100644 --- a/src/adapters/postgresql/tools/cron/management.ts +++ b/src/adapters/postgresql/tools/cron/management.ts @@ -248,12 +248,9 @@ export function createCronListJobsTool( const resultPayload: Record = { success: true, count: jobs.length, + jobs: jobs, }; - if (jobs.length > 0) { - resultPayload["jobs"] = jobs; - } - if (truncated) { resultPayload["truncated"] = true; resultPayload["totalCount"] = totalCount; @@ -453,13 +450,10 @@ Useful for monitoring and debugging scheduled jobs. Default limit is 10 rows.`, const resultPayload: Record = { success: true, count: rows.length, + runs: rows, + summary: summaryStats, }; - if (rows.length > 0) { - resultPayload["runs"] = rows; - resultPayload["summary"] = summaryStats; - } - if (truncated) { resultPayload["truncated"] = true; resultPayload["totalCount"] = totalCount; diff --git a/src/adapters/postgresql/tools/docstore/collection.ts b/src/adapters/postgresql/tools/docstore/collection.ts new file mode 100644 index 00000000..420aad33 --- /dev/null +++ b/src/adapters/postgresql/tools/docstore/collection.ts @@ -0,0 +1,399 @@ +/** + * PostgreSQL Document Store - Collection Tools + * + * Tools for listing, creating, dropping, and inspecting document collections. + * 4 tools total. + */ + +import { ZodError } from "zod"; +import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { + ToolDefinition, + RequestContext, +} from "../../../../types/index.js"; +import { readOnly, write, destructive } from "../../../../utils/annotations.js"; +import { getToolIcons } from "../../../../utils/icons.js"; +import { + IDENTIFIER_RE, + checkCollectionExists, + escapeTableRef, +} from "./helpers.js"; +import { + ListCollectionsSchema, + ListCollectionsSchemaBase, + CreateCollectionSchema, + CreateCollectionSchemaBase, + DropCollectionSchema, + DropCollectionSchemaBase, + CollectionInfoSchema, + CollectionInfoSchemaBase, + // Output schemas + ListCollectionsOutputSchema, + CreateCollectionOutputSchema, + DropCollectionOutputSchema, + CollectionInfoOutputSchema, +} from "../../schemas/index.js"; + +// ============================================================================= +// pg_doc_list_collections +// ============================================================================= + +export function createListCollectionsTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_doc_list_collections", + description: + "List JSONB document collections in a schema. Collections are tables with a 'doc' JSONB column and '_id' text column.", + group: "docstore", + inputSchema: ListCollectionsSchemaBase, + outputSchema: ListCollectionsOutputSchema, + annotations: readOnly("List Collections"), + icons: getToolIcons("docstore", readOnly("List Collections")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const { schema } = ListCollectionsSchema.parse(params) as { + schema?: string; + }; + + if (schema) { + const schemaCheck = await adapter.executeQuery( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1", + [schema], + ); + if (!schemaCheck.rows || schemaCheck.rows.length === 0) { + return formatHandlerErrorResponse( + new Error(`Schema '${schema}' does not exist`), + { tool: "pg_doc_list_collections" }, + ); + } + } + + const query = ` + SELECT + t.table_name AS name, + pg_stat_get_live_tuples(c.oid)::int AS "rowCount", + pg_size_pretty(pg_total_relation_size(c.oid)) AS size + FROM information_schema.tables t + JOIN pg_class c ON c.relname = t.table_name + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema + WHERE t.table_schema = COALESCE($1, current_schema()) + AND EXISTS ( + SELECT 1 FROM information_schema.columns c1 + WHERE c1.table_schema = t.table_schema AND c1.table_name = t.table_name + AND c1.column_name = 'doc' AND c1.udt_name = 'jsonb' + ) + AND EXISTS ( + SELECT 1 FROM information_schema.columns c2 + WHERE c2.table_schema = t.table_schema AND c2.table_name = t.table_name + AND c2.column_name = '_id' + ) + ORDER BY t.table_name`; + + const result = await adapter.executeQuery(query, [schema ?? null]); + return { + success: true, + collections: result.rows ?? [], + count: result.rows?.length ?? 0, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_doc_list_collections", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_doc_list_collections", + }); + } + }, + }; +} + +// ============================================================================= +// pg_doc_create_collection +// ============================================================================= + +export function createCreateCollectionTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_doc_create_collection", + description: + "Create a new JSONB document collection (table with doc JSONB + generated _id primary key).", + group: "docstore", + inputSchema: CreateCollectionSchemaBase, + outputSchema: CreateCollectionOutputSchema, + annotations: write("Create Collection"), + icons: getToolIcons("docstore", write("Create Collection")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const { name, schema, ifNotExists } = + CreateCollectionSchema.parse(params); + if (!IDENTIFIER_RE.test(name)) { + return formatHandlerErrorResponse( + new Error("Invalid collection name"), + { tool: "pg_doc_create_collection" }, + ); + } + if (schema && !IDENTIFIER_RE.test(schema)) { + return formatHandlerErrorResponse(new Error("Invalid schema name"), { + tool: "pg_doc_create_collection", + }); + } + + // P154: Pre-check existence + if (ifNotExists) { + const check = await checkCollectionExists(adapter, name, schema); + if (check.exists) { + return { + success: true, + collection: name, + skipped: true, + reason: "Collection already exists", + }; + } + if (!check.exists && check.reason === "schema") { + return formatHandlerErrorResponse( + new Error(`Schema '${check.name}' does not exist`), + { tool: "pg_doc_create_collection" }, + ); + } + } + + const tableRef = escapeTableRef(name, schema); + const createClause = ifNotExists + ? "CREATE TABLE IF NOT EXISTS" + : "CREATE TABLE"; + + const sql = `${createClause} ${tableRef} ( + doc JSONB NOT NULL, + _id TEXT GENERATED ALWAYS AS (doc->>'_id') STORED PRIMARY KEY + )`; + + await adapter.executeQuery(sql); + adapter.invalidateSchemaCache(); + return { success: true, collection: name }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_doc_create_collection", + }); + } + const message = err instanceof Error ? err.message : String(err); + if (message.toLowerCase().includes("already exists")) { + return formatHandlerErrorResponse( + new Error( + `Collection '${(params as { collection?: string })?.collection ?? (params as { name?: string })?.name ?? "unknown"}' already exists`, + ), + { tool: "pg_doc_create_collection" }, + ); + } + return formatHandlerErrorResponse(err, { + tool: "pg_doc_create_collection", + }); + } + }, + }; +} + +// ============================================================================= +// pg_doc_drop_collection +// ============================================================================= + +export function createDropCollectionTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_doc_drop_collection", + description: "Drop a document collection (table).", + group: "docstore", + inputSchema: DropCollectionSchemaBase, + outputSchema: DropCollectionOutputSchema, + annotations: destructive("Drop Collection"), + icons: getToolIcons("docstore", destructive("Drop Collection")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const { name, schema, ifExists } = DropCollectionSchema.parse(params); + if (!IDENTIFIER_RE.test(name)) { + return formatHandlerErrorResponse( + new Error("Invalid collection name"), + { tool: "pg_doc_drop_collection" }, + ); + } + if (schema && !IDENTIFIER_RE.test(schema)) { + return formatHandlerErrorResponse(new Error("Invalid schema name"), { + tool: "pg_doc_drop_collection", + }); + } + + // P154: Schema existence check + if (schema) { + const schemaCheck = await adapter.executeQuery( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1", + [schema], + ); + if (!schemaCheck.rows || schemaCheck.rows.length === 0) { + return formatHandlerErrorResponse( + new Error(`Schema '${schema}' does not exist`), + { tool: "pg_doc_drop_collection" }, + ); + } + } + + // Pre-check existence when ifExists is true + if (ifExists) { + const check = await checkCollectionExists(adapter, name, schema); + if (!check.exists) { + return { + success: true, + collection: name, + message: "Collection did not exist", + }; + } + } + + const tableRef = escapeTableRef(name, schema); + await adapter.executeQuery( + `DROP TABLE ${ifExists ? "IF EXISTS " : ""}${tableRef}`, + ); + adapter.invalidateSchemaCache(); + return { success: true, collection: name }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_doc_drop_collection", + }); + } + const message = err instanceof Error ? err.message : String(err); + if (message.toLowerCase().includes("does not exist")) { + return formatHandlerErrorResponse( + new Error( + `Collection '${(params as { collection?: string })?.collection ?? (params as { name?: string })?.name ?? "unknown"}' does not exist`, + ), + { tool: "pg_doc_drop_collection" }, + ); + } + return formatHandlerErrorResponse(err, { + tool: "pg_doc_drop_collection", + }); + } + }, + }; +} + +// ============================================================================= +// pg_doc_collection_info +// ============================================================================= + +export function createCollectionInfoTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_doc_collection_info", + description: + "Get document collection statistics: row count, size, and indexes.", + group: "docstore", + inputSchema: CollectionInfoSchemaBase, + outputSchema: CollectionInfoOutputSchema, + annotations: readOnly("Collection Info"), + icons: getToolIcons("docstore", readOnly("Collection Info")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const { collection, schema } = CollectionInfoSchema.parse(params); + if (!IDENTIFIER_RE.test(collection)) { + return formatHandlerErrorResponse( + new Error("Invalid collection name"), + { tool: "pg_doc_collection_info" }, + ); + } + + // P154: Check collection existence + const infoCheck = await checkCollectionExists( + adapter, + collection, + schema, + ); + if (!infoCheck.exists) { + return infoCheck.reason === "schema" + ? formatHandlerErrorResponse( + new Error(`Schema '${infoCheck.name}' does not exist`), + { tool: "pg_doc_collection_info" }, + ) + : formatHandlerErrorResponse( + new Error(`Collection '${collection}' does not exist`), + { tool: "pg_doc_collection_info" }, + ); + } + + const tableRef = escapeTableRef(collection, schema); + + // Get accurate row count + const countResult = await adapter.executeQuery( + `SELECT COUNT(*) AS "rowCount" FROM ${tableRef}`, + ); + const countRow = countResult.rows?.[0] as + | { rowCount: string | number } + | undefined; + const rowCount = + typeof countRow?.rowCount === "string" + ? parseInt(countRow.rowCount, 10) + : (countRow?.rowCount ?? 0); + + // Get size info + const sizeResult = await adapter.executeQuery( + `SELECT + pg_size_pretty(pg_total_relation_size(c.oid)) AS "totalSize", + pg_size_pretty(pg_relation_size(c.oid)) AS "tableSize", + pg_size_pretty(pg_indexes_size(c.oid)) AS "indexSize" + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = $1 + AND n.nspname = COALESCE($2, current_schema())`, + [collection, schema ?? null], + ); + + const sizeRow = sizeResult.rows?.[0] as + | Record + | undefined; + + // Get indexes + const indexResult = await adapter.executeQuery( + `SELECT + i.relname AS "indexName", + ix.indisunique AS "isUnique", + pg_get_indexdef(ix.indexrelid) AS "definition" + FROM pg_index ix + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE t.relname = $1 + AND n.nspname = COALESCE($2, current_schema())`, + [collection, schema ?? null], + ); + + return { + success: true, + collection, + stats: { + rowCount, + totalSize: sizeRow?.["totalSize"], + tableSize: sizeRow?.["tableSize"], + indexSize: sizeRow?.["indexSize"], + }, + indexes: indexResult.rows ?? [], + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_doc_collection_info", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_doc_collection_info", + }); + } + }, + }; +} diff --git a/src/adapters/postgresql/tools/docstore/documents.ts b/src/adapters/postgresql/tools/docstore/documents.ts new file mode 100644 index 00000000..020c7e9c --- /dev/null +++ b/src/adapters/postgresql/tools/docstore/documents.ts @@ -0,0 +1,401 @@ +/** + * PostgreSQL Document Store - Document Tools + * + * Tools for finding, adding, modifying, and removing documents. + * 4 tools total. + */ + +import { ZodError } from "zod"; +import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { + ToolDefinition, + RequestContext, +} from "../../../../types/index.js"; +import { readOnly, write, destructive } from "../../../../utils/annotations.js"; +import { getToolIcons } from "../../../../utils/icons.js"; +import { + IDENTIFIER_RE, + parseDocFilter, + checkCollectionExists, + escapeTableRef, +} from "./helpers.js"; +import { + FindSchema, + FindSchemaBase, + AddDocSchema, + AddDocSchemaBase, + ModifyDocSchema, + ModifyDocSchemaBase, + RemoveDocSchema, + RemoveDocSchemaBase, + // Output schemas + FindOutputSchema, + AddDocOutputSchema, + ModifyDocOutputSchema, + RemoveDocOutputSchema, +} from "../../schemas/index.js"; + +// ============================================================================= +// pg_doc_find +// ============================================================================= + +export function createFindTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_doc_find", + description: + "Query documents in a JSONB collection with optional filter, field projection, and pagination.", + group: "docstore", + inputSchema: FindSchemaBase, + outputSchema: FindOutputSchema, + annotations: readOnly("Find Documents"), + icons: getToolIcons("docstore", readOnly("Find Documents")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const { collection, schema, filter, fields, limit, offset } = + FindSchema.parse(params); + if (!IDENTIFIER_RE.test(collection)) { + return formatHandlerErrorResponse( + new Error("Invalid collection name"), + { tool: "pg_doc_find" }, + ); + } + if (schema && !IDENTIFIER_RE.test(schema)) { + return formatHandlerErrorResponse(new Error("Invalid schema name"), { + tool: "pg_doc_find", + }); + } + + // P154: Check collection existence + const findCheck = await checkCollectionExists( + adapter, + collection, + schema, + ); + if (!findCheck.exists) { + return findCheck.reason === "schema" + ? formatHandlerErrorResponse( + new Error(`Schema '${findCheck.name}' does not exist`), + { tool: "pg_doc_find" }, + ) + : formatHandlerErrorResponse( + new Error(`Collection '${collection}' does not exist`), + { tool: "pg_doc_find" }, + ); + } + + let selectClause = "_id, doc"; + if (fields && fields.length > 0) { + // Validate all field names + for (const f of fields) { + if (!IDENTIFIER_RE.test(f)) { + return formatHandlerErrorResponse( + new Error( + `Invalid field name: "${f}". Field names must be valid identifiers.`, + ), + { tool: "pg_doc_find" }, + ); + } + } + // Build a JSONB projection using jsonb_build_object + selectClause = + "jsonb_build_object(" + + fields.map((f) => `'${f}', doc->'${f}'`).join(", ") + + ") AS doc"; + } + + const tableRef = escapeTableRef(collection, schema); + let query = `SELECT ${selectClause} FROM ${tableRef}`; + let queryParams: unknown[] = []; + + if (filter) { + const { where, params: whereParams } = parseDocFilter(filter); + query += ` WHERE ${where}`; + queryParams = whereParams; + } + + // Add LIMIT and OFFSET using parameterized values + const limitParamIdx = queryParams.length + 1; + const offsetParamIdx = queryParams.length + 2; + query += ` LIMIT $${String(limitParamIdx)} OFFSET $${String(offsetParamIdx)}`; + queryParams.push(limit, offset); + + const result = await adapter.executeQuery(query, queryParams); + const docs = (result.rows ?? []).map((r: Record) => { + const docValue = r["doc"]; + const idValue = r["_id"]; + const parsed = + typeof docValue === "string" + ? (JSON.parse(docValue) as Record) + : (docValue as Record); + + if ( + idValue !== undefined && + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) + ) { + if (!("_id" in parsed)) { + parsed["_id"] = idValue; + } + } + return parsed; + }); + + return { success: true, documents: docs, count: docs.length }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_doc_find" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_doc_find" }); + } + }, + }; +} + +// ============================================================================= +// pg_doc_add +// ============================================================================= + +export function createAddTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_doc_add", + description: "Add one or more JSON documents to a collection.", + group: "docstore", + inputSchema: AddDocSchemaBase, + outputSchema: AddDocOutputSchema, + annotations: write("Add Documents"), + icons: getToolIcons("docstore", write("Add Documents")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const { collection, schema, documents } = AddDocSchema.parse(params); + if (documents.length === 0) { + return formatHandlerErrorResponse( + new Error("Validation error: documents array must not be empty"), + { tool: "pg_doc_add" }, + ); + } + if (!IDENTIFIER_RE.test(collection)) { + return formatHandlerErrorResponse( + new Error("Invalid collection name"), + { tool: "pg_doc_add" }, + ); + } + if (schema && !IDENTIFIER_RE.test(schema)) { + return formatHandlerErrorResponse(new Error("Invalid schema name"), { + tool: "pg_doc_add", + }); + } + + const addCheck = await checkCollectionExists( + adapter, + collection, + schema, + ); + if (!addCheck.exists) { + return addCheck.reason === "schema" + ? formatHandlerErrorResponse( + new Error(`Schema '${addCheck.name}' does not exist`), + { tool: "pg_doc_add" }, + ) + : formatHandlerErrorResponse( + new Error(`Collection '${collection}' does not exist`), + { tool: "pg_doc_add" }, + ); + } + + const tableRef = escapeTableRef(collection, schema); + let inserted = 0; + for (const doc of documents) { + // Generate _id if not present (32-char hex for cross-project consistency) + doc["_id"] ??= crypto.randomUUID().replace(/-/g, ""); + await adapter.executeQuery( + `INSERT INTO ${tableRef} (doc) VALUES ($1::jsonb)`, + [JSON.stringify(doc)], + ); + inserted++; + } + return { success: true, inserted }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_doc_add" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_doc_add" }); + } + }, + }; +} + +// ============================================================================= +// pg_doc_modify +// ============================================================================= + +export function createModifyTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_doc_modify", + description: + "Update documents matching a filter. Set fields with 'set' and remove fields with 'unset'.", + group: "docstore", + inputSchema: ModifyDocSchemaBase, + outputSchema: ModifyDocOutputSchema, + annotations: write("Modify Documents"), + icons: getToolIcons("docstore", write("Modify Documents")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const { collection, schema, filter, set, unset } = + ModifyDocSchema.parse(params); + if (!IDENTIFIER_RE.test(collection)) { + return formatHandlerErrorResponse( + new Error("Invalid collection name"), + { tool: "pg_doc_modify" }, + ); + } + if (schema && !IDENTIFIER_RE.test(schema)) { + return formatHandlerErrorResponse(new Error("Invalid schema name"), { + tool: "pg_doc_modify", + }); + } + + const modCheck = await checkCollectionExists( + adapter, + collection, + schema, + ); + if (!modCheck.exists) { + return modCheck.reason === "schema" + ? formatHandlerErrorResponse( + new Error(`Schema '${modCheck.name}' does not exist`), + { tool: "pg_doc_modify" }, + ) + : formatHandlerErrorResponse( + new Error(`Collection '${collection}' does not exist`), + { tool: "pg_doc_modify" }, + ); + } + + // Build SET clause using jsonb_set for set operations + // and #- operator for unset operations + let docExpr = "doc"; + const updateParams: unknown[] = []; + let paramIdx = 1; + + if (set) { + for (const [path, value] of Object.entries(set)) { + if (!IDENTIFIER_RE.test(path)) { + return formatHandlerErrorResponse( + new Error( + `Invalid field path: "${path}". Paths must be valid identifiers.`, + ), + { tool: "pg_doc_modify" }, + ); + } + // jsonb_set(doc, '{path}', $N::jsonb, true) + const pgPath = path.split(".").join(","); + docExpr = `jsonb_set(${docExpr}, '{${pgPath}}', $${String(paramIdx)}::jsonb, true)`; + updateParams.push(JSON.stringify(value)); + paramIdx++; + } + } + + if (unset) { + for (const path of unset) { + if (!IDENTIFIER_RE.test(path)) { + return formatHandlerErrorResponse( + new Error( + `Invalid field path: "${path}". Paths must be valid identifiers.`, + ), + { tool: "pg_doc_modify" }, + ); + } + // doc #- '{path}' + const pgPath = path.split(".").join(","); + docExpr = `${docExpr} #- '{${pgPath}}'`; + } + } + + if (docExpr === "doc") { + return formatHandlerErrorResponse( + new Error("No modifications specified"), + { tool: "pg_doc_modify" }, + ); + } + + const { where, params: whereParams } = parseDocFilter( + filter, + updateParams.length, + ); + const allParams = [...updateParams, ...whereParams]; + + const tableRef = escapeTableRef(collection, schema); + const query = `UPDATE ${tableRef} SET doc = ${docExpr} WHERE ${where}`; + const result = await adapter.executeQuery(query, allParams); + return { success: true, modified: result.rowsAffected ?? 0 }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_doc_modify" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_doc_modify" }); + } + }, + }; +} + +// ============================================================================= +// pg_doc_remove +// ============================================================================= + +export function createRemoveTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_doc_remove", + description: "Remove documents matching a filter from a collection.", + group: "docstore", + inputSchema: RemoveDocSchemaBase, + outputSchema: RemoveDocOutputSchema, + annotations: destructive("Remove Documents"), + icons: getToolIcons("docstore", destructive("Remove Documents")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const { collection, schema, filter } = RemoveDocSchema.parse(params); + if (!IDENTIFIER_RE.test(collection)) { + return formatHandlerErrorResponse( + new Error("Invalid collection name"), + { tool: "pg_doc_remove" }, + ); + } + if (schema && !IDENTIFIER_RE.test(schema)) { + return formatHandlerErrorResponse(new Error("Invalid schema name"), { + tool: "pg_doc_remove", + }); + } + + const rmCheck = await checkCollectionExists( + adapter, + collection, + schema, + ); + if (!rmCheck.exists) { + return rmCheck.reason === "schema" + ? formatHandlerErrorResponse( + new Error(`Schema '${rmCheck.name}' does not exist`), + { tool: "pg_doc_remove" }, + ) + : formatHandlerErrorResponse( + new Error(`Collection '${collection}' does not exist`), + { tool: "pg_doc_remove" }, + ); + } + + const { where, params: whereParams } = parseDocFilter(filter); + const tableRef = escapeTableRef(collection, schema); + const query = `DELETE FROM ${tableRef} WHERE ${where}`; + const result = await adapter.executeQuery(query, whereParams); + return { success: true, removed: result.rowsAffected ?? 0 }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_doc_remove" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_doc_remove" }); + } + }, + }; +} diff --git a/src/adapters/postgresql/tools/docstore/helpers.ts b/src/adapters/postgresql/tools/docstore/helpers.ts new file mode 100644 index 00000000..b7170c45 --- /dev/null +++ b/src/adapters/postgresql/tools/docstore/helpers.ts @@ -0,0 +1,279 @@ +/** + * PostgreSQL Document Store - Shared Helpers + * + * Utilities for document collection tools: identifier validation, + * filter parsing, collection existence checks, and table reference escaping. + */ + +import type { PostgresAdapter } from "../../postgres-adapter.js"; + +export const IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_.]*$/; + +function buildJsonPathCastFloat(field: string): string { + const parts = field.split("."); + if (parts.length === 1) return `(doc->>'${parts[0] ?? ""}')::float`; + const last = parts.pop() ?? ""; + return ( + `(doc` + parts.map((p) => `->'${p}'`).join("") + `->>'${last}')::float` + ); +} + +function buildJsonPath(field: string): string { + const parts = field.split("."); + if (parts.length === 1) return `doc->>'${parts[0] ?? ""}'`; + const last = parts.pop() ?? ""; + return `doc` + parts.map((p) => `->'${p}'`).join("") + `->>'${last}'`; +} + +function hasNestedOperators(obj: Record): boolean { + for (const [key, val] of Object.entries(obj)) { + if (key.startsWith("$")) return true; + if (typeof val === "object" && val !== null && !Array.isArray(val)) { + if (hasNestedOperators(val as Record)) return true; + } + } + return false; +} + +// Valid JSON path: $, $.field, $.field.sub, $.field[0], $[0], $[*] +export const JSON_PATH_RE = + /^(\$)((\.([a-zA-Z_][a-zA-Z0-9_]*))((\[\d+\])|(\[\*\]))?)*((\[\d+\])|(\[\*\]))?$/; + +/** + * Parse filter string into a WHERE clause with parameterized queries. + * Supports: + * - _id match: 32-char hex string β†’ WHERE _id = $1 + * - JSON object: {"name":"Alice"} β†’ WHERE doc->>'name' = $1 + * - Field equality: name=Alice β†’ WHERE doc->>'name' = $1 + * - JSON path existence: $.name β†’ WHERE doc ? 'name' + */ +export function parseDocFilter( + filter: string, + paramOffset = 0, +): { + where: string; + params: unknown[]; +} { + // Check if it's a direct _id (32-char hex) + if (/^[a-f0-9]{32}$/i.test(filter)) { + return { where: `_id = $${String(paramOffset + 1)}`, params: [filter] }; + } + + // Check if it's a stringified JSON object (e.g. {"name":"Alice"}) + if (filter.trim().startsWith("{") && filter.trim().endsWith("}")) { + try { + const parsed = JSON.parse(filter) as unknown; + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + const record = parsed as Record; + const keys = Object.keys(record); + const field = keys[0]; + if (typeof field === "string" && IDENTIFIER_RE.test(field)) { + const value = record[field]; + + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + const opObj = value as Record; + const opKeys = Object.keys(opObj); + if ( + opKeys.length === 1 && + typeof opKeys[0] === "string" && + opKeys[0].startsWith("$") + ) { + const op = opKeys[0]; + const opVal = opObj[op]; + let sqlOp = "="; + let isArrayOp = false; + if (op === "$gt") sqlOp = ">"; + else if (op === "$gte") sqlOp = ">="; + else if (op === "$lt") sqlOp = "<"; + else if (op === "$lte") sqlOp = "<="; + else if (op === "$ne") sqlOp = "!="; + else if (op === "$in") { + sqlOp = "IN"; + isArrayOp = true; + } else if (op === "$nin") { + sqlOp = "NOT IN"; + isArrayOp = true; + } + + if (sqlOp !== "=" && !isArrayOp) { + if (typeof opVal === "number") { + return { + where: `${buildJsonPathCastFloat(field)} ${sqlOp} $${String(paramOffset + 1)}::float`, + params: [String(opVal)], + }; + } else { + return { + where: `${buildJsonPath(field)} ${sqlOp} $${String(paramOffset + 1)}`, + params: [String(opVal)], + }; + } + } else if ( + isArrayOp && + Array.isArray(opVal) && + opVal.length > 0 + ) { + if (opVal.every((v) => typeof v === "number")) { + const placeholders = opVal + .map((_, i) => `$${String(paramOffset + 1 + i)}::float`) + .join(", "); + return { + where: `${buildJsonPathCastFloat(field)} ${sqlOp} (${placeholders})`, + params: opVal.map(String), + }; + } else { + const placeholders = opVal + .map((_, i) => `$${String(paramOffset + 1 + i)}`) + .join(", "); + return { + where: `${buildJsonPath(field)} ${sqlOp} (${placeholders})`, + params: opVal.map(String), + }; + } + } + } + + if (hasNestedOperators(value as Record)) { + throw new Error( + "Unsupported filter structure: Nested operators are not supported. Use dot-notation (e.g., {'address.city': {'$gt': 'A'}}).", + ); + } + + // Nested object without a matching operator -> containment check + return { + where: `doc @> $${String(paramOffset + 1)}::jsonb`, + params: [JSON.stringify(record)], + }; + } + + // Support multiple keys if present using containment check, + // otherwise use simple equality for the single field + if (keys.length > 1) { + return { + where: `doc @> $${String(paramOffset + 1)}::jsonb`, + params: [JSON.stringify(record)], + }; + } + + return { + where: `${buildJsonPath(field)} = $${String(paramOffset + 1)}`, + params: [String(value)], + }; + } + } + } catch (e) { + if ( + e instanceof Error && + e.message.startsWith("Unsupported filter structure") + ) { + throw e; + } + // Ignore parse error and fall through + } + } + + // Check for simple field=value pattern + const eqMatch = /^([a-zA-Z_][a-zA-Z0-9_.]*)=(.+)$/.exec(filter); + if (eqMatch) { + const field = eqMatch[1] ?? ""; + const value = eqMatch[2] ?? ""; + if (!IDENTIFIER_RE.test(field)) { + throw new Error( + `Invalid field name in filter: "${field}". Field names must be valid identifiers.`, + ); + } + return { + where: `${buildJsonPath(field)} = $${String(paramOffset + 1)}`, + params: [value], + }; + } + + // Default: treat as JSON path existence check + if (!filter.startsWith("$")) { + throw new Error( + `Invalid filter: "${filter}". Use JSON path ($.field), _id value, or field=value format.`, + ); + } + + // Validate JSON path against allowlist regex + if (!JSON_PATH_RE.test(filter)) { + throw new Error( + `Invalid JSON path: "${filter}". Only alphanumeric field names, array indices, and dot notation are allowed.`, + ); + } + + // Extract the top-level key from the path for the ? operator + // $.name β†’ 'name', $.address.city β†’ use @> containment + const pathParts = filter + .substring(2) // strip "$." + .split("."); + + if ( + pathParts.length === 1 && + pathParts[0] !== undefined && + pathParts[0] !== "" + ) { + // Simple top-level key: doc ? 'key' + return { + where: `doc ? $${String(paramOffset + 1)}`, + params: [pathParts[0]], + }; + } + + // Nested path: use jsonb_extract_path_text IS NOT NULL + const pathArgs = pathParts + .map((_p, i) => `$${String(paramOffset + 1 + i)}`) + .join(", "); + return { + where: `jsonb_extract_path_text(doc, ${pathArgs}) IS NOT NULL`, + params: pathParts, + }; +} + +/** + * Check if a collection (table with doc JSONB + _id column) exists. + * Returns a discriminated result distinguishing schema-not-found from collection-not-found. + */ +export async function checkCollectionExists( + adapter: PostgresAdapter, + collection: string, + schema?: string, +): Promise< + | { exists: true } + | { exists: false; reason: "schema" | "collection"; name: string } +> { + // When schema is explicitly provided, check schema existence first + if (schema) { + const schemaCheck = await adapter.executeQuery( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1", + [schema], + ); + if (!schemaCheck.rows || schemaCheck.rows.length === 0) { + return { exists: false, reason: "schema", name: schema }; + } + } + + const result = await adapter.executeQuery( + `SELECT 1 FROM information_schema.tables + WHERE table_schema = COALESCE($1, current_schema()) AND table_name = $2`, + [schema ?? null, collection], + ); + if ((result.rows?.length ?? 0) > 0) { + return { exists: true }; + } + return { exists: false, reason: "collection", name: collection }; +} + +/** + * Build a double-quoted PostgreSQL table reference. + */ +export function escapeTableRef(name: string, schema?: string): string { + return schema ? `"${schema}"."${name}"` : `"${name}"`; +} diff --git a/src/adapters/postgresql/tools/docstore/index.ts b/src/adapters/postgresql/tools/docstore/index.ts new file mode 100644 index 00000000..7a3f1f92 --- /dev/null +++ b/src/adapters/postgresql/tools/docstore/index.ts @@ -0,0 +1,56 @@ +/** + * PostgreSQL Document Store Tools + * + * NoSQL-style JSONB document collection management. + * 9 tools total: collection CRUD (4), document CRUD (4), indexing (1). + */ + +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { ToolDefinition } from "../../../../types/index.js"; + +// Import from submodules +import { + createListCollectionsTool, + createCreateCollectionTool, + createDropCollectionTool, + createCollectionInfoTool, +} from "./collection.js"; + +import { + createFindTool, + createAddTool, + createModifyTool, + createRemoveTool, +} from "./documents.js"; + +import { createDocIndexTool } from "./indexes.js"; + +/** + * Get all document store tools + */ +export function getDocStoreTools(adapter: PostgresAdapter): ToolDefinition[] { + return [ + createListCollectionsTool(adapter), + createCreateCollectionTool(adapter), + createDropCollectionTool(adapter), + createCollectionInfoTool(adapter), + createFindTool(adapter), + createAddTool(adapter), + createModifyTool(adapter), + createRemoveTool(adapter), + createDocIndexTool(adapter), + ]; +} + +// Re-export individual tool creators for direct imports +export { + createListCollectionsTool, + createCreateCollectionTool, + createDropCollectionTool, + createCollectionInfoTool, + createFindTool, + createAddTool, + createModifyTool, + createRemoveTool, + createDocIndexTool, +}; diff --git a/src/adapters/postgresql/tools/docstore/indexes.ts b/src/adapters/postgresql/tools/docstore/indexes.ts new file mode 100644 index 00000000..e61e3dbf --- /dev/null +++ b/src/adapters/postgresql/tools/docstore/indexes.ts @@ -0,0 +1,163 @@ +/** + * PostgreSQL Document Store - Index Tools + * + * Tools for creating expression indexes on document fields. + * 1 tool total. + */ + +import { ZodError } from "zod"; +import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { + ToolDefinition, + RequestContext, +} from "../../../../types/index.js"; +import { write } from "../../../../utils/annotations.js"; +import { getToolIcons } from "../../../../utils/icons.js"; +import { + IDENTIFIER_RE, + checkCollectionExists, + escapeTableRef, +} from "./helpers.js"; +import { + CreateDocIndexSchema, + CreateDocIndexSchemaBase, + CreateDocIndexOutputSchema, +} from "../../schemas/index.js"; + +/** Map docstore field types to PostgreSQL cast expressions */ +const TYPE_CAST_MAP: Record = { + TEXT: "TEXT", + INT: "INTEGER", + DOUBLE: "DOUBLE PRECISION", + DATE: "DATE", + TIMESTAMP: "TIMESTAMP", + BOOLEAN: "BOOLEAN", +}; + +// ============================================================================= +// pg_doc_create_index +// ============================================================================= + +export function createDocIndexTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_doc_create_index", + description: + "Create an expression index on document fields for faster queries. Uses PostgreSQL expression indexes on JSONB paths.", + group: "docstore", + inputSchema: CreateDocIndexSchemaBase, + outputSchema: CreateDocIndexOutputSchema, + annotations: write("Create Doc Index"), + icons: getToolIcons("docstore", write("Create Doc Index")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const { collection, schema, name, fields, unique } = + CreateDocIndexSchema.parse(params); + if (fields.length === 0) { + return formatHandlerErrorResponse( + new Error("Validation error: fields array must not be empty"), + { tool: "pg_doc_create_index" }, + ); + } + if (!IDENTIFIER_RE.test(collection)) { + return formatHandlerErrorResponse( + new Error("Invalid collection name"), + { tool: "pg_doc_create_index" }, + ); + } + if (schema && !IDENTIFIER_RE.test(schema)) { + return formatHandlerErrorResponse(new Error("Invalid schema name"), { + tool: "pg_doc_create_index", + }); + } + if (!IDENTIFIER_RE.test(name)) { + return formatHandlerErrorResponse(new Error("Invalid index name"), { + tool: "pg_doc_create_index", + }); + } + + const idxCheck = await checkCollectionExists( + adapter, + collection, + schema, + ); + if (!idxCheck.exists) { + return idxCheck.reason === "schema" + ? formatHandlerErrorResponse( + new Error(`Schema '${idxCheck.name}' does not exist`), + { tool: "pg_doc_create_index" }, + ) + : formatHandlerErrorResponse( + new Error(`Collection '${collection}' does not exist`), + { tool: "pg_doc_create_index" }, + ); + } + + // Validate all field paths + for (const field of fields) { + const pathParts = field.path.split("."); + for (const part of pathParts) { + if (!IDENTIFIER_RE.test(part)) { + return formatHandlerErrorResponse( + new Error( + `Invalid field path: "${field.path}". Path segments must be valid identifiers.`, + ), + { tool: "pg_doc_create_index" }, + ); + } + } + } + + // Build expression index columns + // For TEXT: (doc->>'field') + // For typed: ((doc->>'field')::INTEGER) + const expressions = fields.map((field) => { + // Build the JSONB extraction chain + // For nested paths like "address.city": (doc->'address'->>'city') + const pathParts = field.path.split("."); + let expr: string; + if (pathParts.length === 1) { + const part = pathParts[0] ?? ""; + expr = `(doc->>'${part}')`; + } else { + // Navigate with -> for intermediate, ->> for last + const intermediate = pathParts + .slice(0, -1) + .map((p) => `'${p}'`) + .join("->"); + const last = pathParts[pathParts.length - 1] ?? ""; + expr = `(doc->${intermediate}->>'${last}')`; + } + + // Apply type cast if not TEXT + const castType = TYPE_CAST_MAP[field.type]; + if (field.type !== "TEXT" && castType) { + expr = `(${expr}::${castType})`; + } + + return expr; + }); + + const tableRef = escapeTableRef(collection, schema); + const uniqueClause = unique ? "UNIQUE " : ""; + const cols = expressions.join(", "); + + await adapter.executeQuery( + `CREATE ${uniqueClause}INDEX "${name}" ON ${tableRef} (${cols})`, + ); + + adapter.invalidateSchemaCache(); + return { success: true, index: name }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_doc_create_index", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_doc_create_index", + }); + } + }, + }; +} diff --git a/src/adapters/postgresql/tools/introspection/analysis.ts b/src/adapters/postgresql/tools/introspection/analysis.ts index 6653a6e5..06a31d2e 100644 --- a/src/adapters/postgresql/tools/introspection/analysis.ts +++ b/src/adapters/postgresql/tools/introspection/analysis.ts @@ -6,9 +6,10 @@ */ import type { PostgresAdapter } from "../../postgres-adapter.js"; -import type { - ToolDefinition, - RequestContext, +import { + type ToolDefinition, + type RequestContext, + ValidationError, } from "../../../../types/index.js"; import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; @@ -398,6 +399,10 @@ export function createMigrationRisksTool( try { const parsed = MigrationRisksSchema.parse(params); + if (parsed.statements.length === 0) { + throw new ValidationError("At least one statement is required"); + } + if (parsed.schema) { await checkSchemaExists(adapter, parsed.schema); } diff --git a/src/adapters/postgresql/tools/jsonb/__tests__/jsonb.test.ts b/src/adapters/postgresql/tools/jsonb/__tests__/jsonb.test.ts index 19339f71..581a3541 100644 --- a/src/adapters/postgresql/tools/jsonb/__tests__/jsonb.test.ts +++ b/src/adapters/postgresql/tools/jsonb/__tests__/jsonb.test.ts @@ -495,7 +495,7 @@ describe("JSONB Validation and Error Paths", () => { )) as { success: boolean; error: string }; expect(result.success).toBe(false); - expect(result.error).toMatch(/value parameter/); + expect(result.error).toMatch(/pg_jsonb_set requires a value parameter/i); }); it("should handle empty path - replace entire column", async () => { @@ -623,6 +623,17 @@ describe("JSONB Validation and Error Paths", () => { }); describe("pg_jsonb_insert validations", () => { + it("should reject when value is undefined", async () => { + const tool = tools.find((t) => t.name === "pg_jsonb_insert")!; + const result = (await tool.handler( + { table: "users", column: "tags", path: ["tags", 0], where: "id = 1" }, + mockContext, + )) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toMatch(/value is required/i); + }); + it("should reject empty WHERE clause", async () => { const tool = tools.find((t) => t.name === "pg_jsonb_insert")!; @@ -1160,7 +1171,7 @@ describe("JSONB Validation and Error Paths", () => { }); describe("wrong-type numeric param coercion", () => { - it("pg_jsonb_stats should silently default non-numeric sampleSize", async () => { + it("pg_jsonb_stats should coerce non-numeric sampleSize", async () => { const tool = tools.find((t) => t.name === "pg_jsonb_stats")!; // Mock the adapter calls that pg_jsonb_stats makes mockAdapter.executeQuery.mockResolvedValueOnce({ @@ -1183,12 +1194,13 @@ describe("JSONB Validation and Error Paths", () => { mockContext, )) as Record; - // coerceNumber converts "abc" β†’ undefined β†’ default sampleSize is used + // coerceNumber silently defaults expect(result).toBeDefined(); - expect(result.success).not.toBe(false); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); }); - it("pg_jsonb_contains should silently default non-numeric limit", async () => { + it("pg_jsonb_contains should coerce non-numeric limit", async () => { const tool = tools.find((t) => t.name === "pg_jsonb_contains")!; // Mock the adapter call that pg_jsonb_contains makes mockAdapter.executeQuery.mockResolvedValueOnce({ @@ -1205,9 +1217,10 @@ describe("JSONB Validation and Error Paths", () => { mockContext, )) as Record; - // coerceNumber converts "abc" β†’ undefined β†’ default limit is used + // coerceNumber silently defaults expect(result).toBeDefined(); - expect(result.success).not.toBe(false); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); }); }); }); diff --git a/src/adapters/postgresql/tools/jsonb/query.ts b/src/adapters/postgresql/tools/jsonb/query.ts index a039db01..b1bf5b13 100644 --- a/src/adapters/postgresql/tools/jsonb/query.ts +++ b/src/adapters/postgresql/tools/jsonb/query.ts @@ -102,17 +102,18 @@ export function createJsonbAggTool(adapter: PostgresAdapter): ToolDefinition { const sql = `SELECT ${groupExpr} as group_key, jsonb_agg(${selectExpr}${aggOrderBy}) as items FROM ${qualifiedTable} t${whereClause}${groupClause}${limitClause}`; const result = await adapter.executeQuery(sql); const count = result.rows?.length ?? 0; + const rows = result.rows ?? []; const response: { success: boolean; - result?: unknown; + result: unknown; count: number; grouped: boolean; } = { success: true, count, grouped: true, + result: rows, }; - if (count > 0) response.result = result.rows; return response; } else { const innerSql = `SELECT * FROM ${qualifiedTable} t${whereClause}${orderByClause}${limitClause}`; @@ -122,12 +123,11 @@ export function createJsonbAggTool(adapter: PostgresAdapter): ToolDefinition { const count = Array.isArray(arr) ? arr.length : 0; const response: { success: boolean; - result?: unknown; + result: unknown; count: number; grouped: boolean; hint?: string; - } = { success: true, count, grouped: false }; - if (count > 0) response.result = arr; + } = { success: true, count, grouped: false, result: arr }; if (count === 0) { response.hint = "No rows matched - returns empty array []"; } @@ -187,15 +187,15 @@ export function createJsonbKeysTool(adapter: PostgresAdapter): ToolDefinition { const response: { success: boolean; - keys?: string[]; + keys: string[]; count: number; hint: string; } = { success: true, - count: keys?.length ?? 0, + count: keys.length, + keys, hint: "Returns unique keys deduplicated across all matching rows", }; - if (keys.length > 0) response.keys = keys; return response; } catch (error: unknown) { // Improve error for array columns diff --git a/src/adapters/postgresql/tools/jsonb/read.ts b/src/adapters/postgresql/tools/jsonb/read.ts index f2e2153e..ae800d65 100644 --- a/src/adapters/postgresql/tools/jsonb/read.ts +++ b/src/adapters/postgresql/tools/jsonb/read.ts @@ -153,30 +153,31 @@ export function createJsonbExtractTool( // If select columns were provided, return full row objects if (parsed.select !== undefined && parsed.select.length > 0) { - const rows = result.rows?.map((r) => { - // Rename extracted_value back to 'value' for consistency - const row: Record = {}; - for (const [key, val] of Object.entries(r)) { - if (key === "extracted_value") { - row["value"] = val; - } else { - row[key] = val; + const rows = + result.rows?.map((r) => { + // Rename extracted_value back to 'value' for consistency + const row: Record = {}; + for (const [key, val] of Object.entries(r)) { + if (key === "extracted_value") { + row["value"] = val; + } else { + row[key] = val; + } } - } - return row; - }); - const allNulls = rows?.every((r) => r["value"] === null) ?? false; + return row; + }) ?? []; + const allNulls = rows.every((r) => r["value"] === null); const response: { success: boolean; - rows?: unknown; + rows: unknown; count: number; hint?: string; } = { success: true, - count: rows?.length ?? 0, + count: rows.length, + rows, }; - if (rows && rows.length > 0) response.rows = rows; - if (allNulls && (rows?.length ?? 0) > 0) { + if (allNulls && rows.length > 0) { response.hint = "All values are null - path may not exist in data. Use pg_jsonb_typeof to check."; } @@ -185,20 +186,22 @@ export function createJsonbExtractTool( // Original behavior: return just the extracted values // Wrap each value in an object with 'value' key for consistency with select mode - const rows = result.rows?.map((r) => ({ value: r["extracted_value"] })); + const rows = (result.rows ?? []).map((r) => ({ + value: r["extracted_value"], + })); // Check if all results are null (path may not exist) - const allNulls = rows?.every((r) => r.value === null) ?? false; + const allNulls = rows.every((r) => r.value === null); const response: { success: boolean; - rows?: { value: unknown }[]; + rows: { value: unknown }[]; count: number; hint?: string; } = { success: true, - count: rows?.length ?? 0, + count: rows.length, + rows, }; - if (rows && rows.length > 0) response.rows = rows; - if (allNulls && (rows?.length ?? 0) > 0) { + if (allNulls && rows.length > 0) { response.hint = "All values are null - path may not exist in data. Use pg_jsonb_typeof to check."; } @@ -297,7 +300,7 @@ export function createJsonbContainsTool( Object.keys(value).length === 0; const response: { success: boolean; - rows?: unknown; + rows: unknown; count: number; truncated?: boolean; totalCount?: number; @@ -305,8 +308,9 @@ export function createJsonbContainsTool( } = { success: true, count: rows.length, + rows, }; - if (rows.length > 0) response.rows = rows; + if (isTruncated) { response.truncated = true; // Get exact total count @@ -431,12 +435,12 @@ export function createJsonbPathQueryTool( const response: { success: boolean; - results?: unknown[]; + results: unknown[]; count: number; truncated?: boolean; totalCount?: number; - } = { success: true, count: results.length }; - if (results.length > 0) response.results = results; + } = { success: true, count: results.length, results }; + if (isTruncated) { response.truncated = true; if (exactTotalCount !== undefined) { diff --git a/src/adapters/postgresql/tools/jsonb/transform.ts b/src/adapters/postgresql/tools/jsonb/transform.ts index 2645212e..faca3418 100644 --- a/src/adapters/postgresql/tools/jsonb/transform.ts +++ b/src/adapters/postgresql/tools/jsonb/transform.ts @@ -30,6 +30,8 @@ import { JsonbDiffOutputSchema, // Base schemas for MCP visibility (Split Schema pattern) JsonbNormalizeSchemaBase, + JsonbMergeSchema, + JsonbDiffSchemaBase, // Full schemas (with preprocess - for handler parsing) JsonbNormalizeSchema, } from "../../schemas/index.js"; @@ -181,20 +183,6 @@ function deepMergeObjects( return result; } -// Schema for pg_jsonb_merge - direct schema for MCP visibility -const JsonbMergeSchema = z.object({ - base: z.unknown().describe("Base JSONB document (required)"), - overlay: z.unknown().describe("JSONB to merge on top (required)"), - deep: z - .boolean() - .optional() - .describe("Deep merge nested objects (default: true)"), - mergeArrays: z - .boolean() - .optional() - .describe("Concatenate arrays instead of replacing (default: false)"), -}); - /** * Preprocess merge params to parse JSON strings and validate objects */ @@ -206,8 +194,8 @@ function parseMergeParams(params: unknown): { } { const parsed = JsonbMergeSchema.parse(params); // Parse JSON strings if needed - let base = parsed.base; - let overlay = parsed.overlay; + let base = parsed.base ?? parsed.json1; + let overlay = parsed.overlay ?? parsed.json2; if (typeof base === "string") { try { base = JSON.parse(base); @@ -605,23 +593,9 @@ export function createJsonbNormalizeTool( * Diff two JSONB documents * Note: Uses jsonb_each() which requires object inputs, not arrays or primitives */ -// Schema for pg_jsonb_diff - requires objects (not arrays or primitives) -// Base schema for MCP visibility β€” z.unknown() to avoid SDK-level Zod rejection -// of non-object types (arrays, primitives). Handler validates internally. -const JsonbDiffSchemaBase = z.object({ - doc1: z.unknown().optional().describe("First JSONB object to compare"), - doc2: z.unknown().optional().describe("Second JSONB object to compare"), -}); - -// Internal schema for handler validation (required fields) -const JsonbDiffSchema = z.object({ - doc1: z - .record(z.string(), z.unknown()) - .describe("First JSONB object to compare"), - doc2: z - .record(z.string(), z.unknown()) - .describe("Second JSONB object to compare"), -}); + +// Internal schema for handler validation is no longer needed since we +// handle validation and parsing of doc1 and doc2 manually below. export function createJsonbDiffTool(adapter: PostgresAdapter): ToolDefinition { return { @@ -637,8 +611,45 @@ export function createJsonbDiffTool(adapter: PostgresAdapter): ToolDefinition { try { let parsed; try { - parsed = JsonbDiffSchema.parse(params); + parsed = JsonbDiffSchemaBase.parse(params); } catch { + throw new ValidationError( + "pg_jsonb_diff requires doc1 and doc2 parameters.", + ); + } + + let doc1 = parsed.doc1; + let doc2 = parsed.doc2; + + if (doc1 === undefined || doc2 === undefined) { + throw new ValidationError( + "pg_jsonb_diff requires doc1 and doc2 parameters.", + ); + } + + if (typeof doc1 === "string") { + try { + doc1 = JSON.parse(doc1); + } catch { + /* ignore */ + } + } + if (typeof doc2 === "string") { + try { + doc2 = JSON.parse(doc2); + } catch { + /* ignore */ + } + } + + if ( + typeof doc1 !== "object" || + doc1 === null || + Array.isArray(doc1) || + typeof doc2 !== "object" || + doc2 === null || + Array.isArray(doc2) + ) { throw new ValidationError( "pg_jsonb_diff requires two JSONB objects. Arrays and primitive values are not supported. Use {} format for both doc1 and doc2.", ); @@ -663,8 +674,8 @@ export function createJsonbDiffTool(adapter: PostgresAdapter): ToolDefinition { `; const result = await adapter.executeQuery(sql, [ - toJsonString(parsed.doc1), - toJsonString(parsed.doc2), + toJsonString(doc1), + toJsonString(doc2), ]); const response: { diff --git a/src/adapters/postgresql/tools/jsonb/write-builders.ts b/src/adapters/postgresql/tools/jsonb/write-builders.ts index f6fa6957..3ac5cd8a 100644 --- a/src/adapters/postgresql/tools/jsonb/write-builders.ts +++ b/src/adapters/postgresql/tools/jsonb/write-builders.ts @@ -186,13 +186,30 @@ export function createJsonbStripNullsTool( icons: getToolIcons("jsonb", write("JSONB Strip Nulls")), handler: async (params: unknown, _context: RequestContext) => { try { - // Parse with preprocess schema to resolve aliases (tableNameβ†’table, colβ†’column, filterβ†’where) - const parsed = JsonbStripNullsSchema.parse(params); + const parsed = JsonbStripNullsSchema.parse(params) as { + json?: unknown; + table?: string; + column?: string; + where?: string; + preview?: boolean; + schema?: string; + }; + + if (parsed.json !== undefined) { + const sql = `SELECT jsonb_strip_nulls($1::jsonb) as result`; + const result = await adapter.executeQuery(sql, [ + toJsonString(parsed.json), + ]); + return { success: true, result: result.rows?.[0]?.["result"] }; + } + const table = parsed.table; const column = parsed.column; const whereClause = parsed.where; if (!table || !column) { - throw new ValidationError("table and column are required"); + throw new ValidationError( + "table and column are required when not using raw json", + ); } // Validate schema and build qualified table name diff --git a/src/adapters/postgresql/tools/kcache/admin.ts b/src/adapters/postgresql/tools/kcache/admin.ts index d13b58aa..a3d9388d 100644 --- a/src/adapters/postgresql/tools/kcache/admin.ts +++ b/src/adapters/postgresql/tools/kcache/admin.ts @@ -16,8 +16,10 @@ import { readOnly, write, destructive } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; import { - KcacheDatabaseStatsSchemaBase, - KcacheResourceAnalysisSchemaBase, + KcacheCreateExtensionSchema, + KcacheDatabaseStatsSchema, + KcacheResourceAnalysisSchema, + KcacheResetSchema, KcacheCreateExtensionOutputSchema, KcacheDatabaseStatsOutputSchema, KcacheResourceAnalysisOutputSchema, @@ -36,7 +38,7 @@ export function createKcacheExtensionTool( description: `Enable the pg_stat_kcache extension for OS-level performance metrics. Requires pg_stat_statements to be installed first. Both extensions must be in shared_preload_libraries.`, group: "kcache", - inputSchema: z.object({}), + inputSchema: KcacheCreateExtensionSchema, outputSchema: KcacheCreateExtensionOutputSchema, annotations: write("Create Kcache Extension"), icons: getToolIcons("kcache", write("Create Kcache Extension")), @@ -85,29 +87,43 @@ export function createKcacheDatabaseStatsTool( description: `Get aggregated OS-level statistics for a database. Shows total CPU time, I/O, and page faults across all queries.`, group: "kcache", - inputSchema: KcacheDatabaseStatsSchemaBase, + inputSchema: KcacheDatabaseStatsSchema, outputSchema: KcacheDatabaseStatsOutputSchema, annotations: readOnly("Kcache Database Stats"), icons: getToolIcons("kcache", readOnly("Kcache Database Stats")), handler: async (params: unknown, _context: RequestContext) => { try { - const { database, compact } = KcacheDatabaseStatsSchemaBase.parse( - params ?? {}, - ); + const parsed = z + .object({ + database: z.string().optional(), + compact: z.boolean().optional(), + }) + .parse(params ?? {}); + const { database, compact } = parsed; const cols = await getKcacheColumnNames(adapter); let sql: string; const queryParams: unknown[] = []; if (database !== undefined) { + const dbExistsResult = await adapter.executeQuery( + "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1) as exists", + [database], + ); + const exists = + (dbExistsResult.rows?.[0]?.["exists"] as boolean) ?? false; + if (!exists) { + throw new ValidationError(`Database "${database}" does not exist`); + } + sql = ` SELECT d.datname as database, SUM(k.${cols.userTime}) as total_user_time, SUM(k.${cols.systemTime}) as total_system_time, SUM(k.${cols.userTime} + k.${cols.systemTime}) as total_cpu_time, - SUM(k.${cols.reads}) as total_read_bytes, - SUM(k.${cols.writes}) as total_write_bytes, + SUM(k.${cols.reads})::float8 as total_read_bytes, + SUM(k.${cols.writes})::float8 as total_write_bytes, pg_size_pretty(SUM(k.${cols.reads})::bigint) as total_reads_pretty, pg_size_pretty(SUM(k.${cols.writes})::bigint) as total_writes_pretty, SUM(k.${cols.minflts}) as total_minor_faults, @@ -126,8 +142,8 @@ Shows total CPU time, I/O, and page faults across all queries.`, SUM(${cols.userTime}) as total_user_time, SUM(${cols.systemTime}) as total_system_time, SUM(${cols.userTime} + ${cols.systemTime}) as total_cpu_time, - SUM(${cols.reads}) as total_read_bytes, - SUM(${cols.writes}) as total_write_bytes, + SUM(${cols.reads})::float8 as total_read_bytes, + SUM(${cols.writes})::float8 as total_write_bytes, pg_size_pretty(SUM(${cols.reads})::bigint) as total_reads_pretty, pg_size_pretty(SUM(${cols.writes})::bigint) as total_writes_pretty, SUM(${cols.minflts}) as total_minor_faults, @@ -155,7 +171,8 @@ Shows total CPU time, I/O, and page faults across all queries.`, : rawRows; return { - databaseStats: rows, + success: true, + stats: rows, count: rows.length, }; } catch (error: unknown) { @@ -178,7 +195,7 @@ export function createKcacheResourceAnalysisTool( description: `Analyze queries to classify them as CPU-bound, I/O-bound, or balanced. Helps identify the root cause of performance issues - is the query computation-heavy or disk-heavy?`, group: "kcache", - inputSchema: KcacheResourceAnalysisSchemaBase, + inputSchema: KcacheResourceAnalysisSchema, outputSchema: KcacheResourceAnalysisOutputSchema, annotations: readOnly("Kcache Resource Analysis"), icons: getToolIcons("kcache", readOnly("Kcache Resource Analysis")), @@ -199,15 +216,15 @@ Helps identify the root cause of performance issues - is the query computation-h const threshold = parsed.threshold; const limit = parsed.limit; - if (limit !== undefined && (limit < 1 || limit > 100)) { - throw new ValidationError("limit must be between 1 and 100"); + if (limit !== undefined && limit < 1) { + throw new ValidationError("limit must be greater than or equal to 1"); } const minCalls = parsed.minCalls; const queryPreviewLength = parsed.queryPreviewLength; const thresholdVal = threshold ?? 0.5; const DEFAULT_LIMIT = 5; - const effectiveLimit = limit ?? DEFAULT_LIMIT; + const effectiveLimit = Math.min(limit ?? DEFAULT_LIMIT, 100); // Bound queryPreviewLength: 0 = full query, default 100, max 500 const previewLen = queryPreviewLength === 0 @@ -292,8 +309,8 @@ Helps identify the root cause of performance issues - is the query computation-h END as resource_classification, user_time, system_time, - reads, - writes, + reads::float8 as reads, + writes::float8 as writes, pg_size_pretty(io_bytes::bigint) as io_pretty FROM query_metrics ORDER BY total_time_ms DESC @@ -331,6 +348,7 @@ Helps identify the root cause of performance issues - is the query computation-h ).length; const response: Record = { + success: true, queries: rows, count: rows.length, summary: { @@ -371,7 +389,7 @@ export function createKcacheResetTool( description: `Reset pg_stat_kcache statistics. Use this to start fresh measurements. Note: This also resets pg_stat_statements statistics.`, group: "kcache", - inputSchema: z.object({}), + inputSchema: KcacheResetSchema, outputSchema: KcacheResetOutputSchema, annotations: destructive("Reset Kcache Stats"), icons: getToolIcons("kcache", destructive("Reset Kcache Stats")), diff --git a/src/adapters/postgresql/tools/kcache/query.ts b/src/adapters/postgresql/tools/kcache/query.ts index 9e85619b..91bb583f 100644 --- a/src/adapters/postgresql/tools/kcache/query.ts +++ b/src/adapters/postgresql/tools/kcache/query.ts @@ -15,9 +15,9 @@ import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; import { - KcacheQueryStatsSchemaBase, - KcacheTopCpuSchemaBase, - KcacheTopIoSchemaBase, + KcacheQueryStatsSchema, + KcacheTopCpuSchema, + KcacheTopIoSchema, KcacheQueryStatsOutputSchema, KcacheTopCpuOutputSchema, KcacheTopIoOutputSchema, @@ -37,7 +37,7 @@ Joins pg_stat_statements with pg_stat_kcache to show what SQL did AND what syste orderBy options: 'total_time' (default), 'cpu_time', 'reads', 'writes'. Use minCalls parameter to filter by call count.`, group: "kcache", - inputSchema: KcacheQueryStatsSchemaBase, + inputSchema: KcacheQueryStatsSchema, outputSchema: KcacheQueryStatsOutputSchema, annotations: readOnly("Kcache Query Stats"), icons: getToolIcons("kcache", readOnly("Kcache Query Stats")), @@ -46,6 +46,8 @@ orderBy options: 'total_time' (default), 'cpu_time', 'reads', 'writes'. Use minC const parsed = z .object({ limit: z.coerce.number().optional(), + dbname: z.string().optional(), + username: z.string().optional(), orderBy: z.string().optional(), minCalls: z.coerce.number().optional(), queryPreviewLength: z.coerce.number().optional(), @@ -55,13 +57,15 @@ orderBy options: 'total_time' (default), 'cpu_time', 'reads', 'writes'. Use minC const limit = parsed.limit; - if (limit !== undefined && (limit < 1 || limit > 100)) { - throw new ValidationError("limit must be between 1 and 100"); + if (limit !== undefined && limit < 1) { + throw new ValidationError("limit must be greater than or equal to 1"); } const orderBy = parsed.orderBy; const minCalls = parsed.minCalls; const queryPreviewLength = parsed.queryPreviewLength; + const dbname = parsed.dbname; + const username = parsed.username; // Validate orderBy inside handler for structured error response const VALID_ORDER_BY = [ @@ -83,7 +87,7 @@ orderBy options: 'total_time' (default), 'cpu_time', 'reads', 'writes'. Use minC const cols = await getKcacheColumnNames(adapter); const DEFAULT_LIMIT = 5; - const effectiveLimit = limit ?? DEFAULT_LIMIT; + const effectiveLimit = Math.min(limit ?? DEFAULT_LIMIT, 100); // Bound queryPreviewLength: 0 = full query, default 100, max 500 const previewLen = queryPreviewLength === 0 @@ -101,10 +105,27 @@ orderBy options: 'total_time' (default), 'cpu_time', 'reads', 'writes'. Use minC const conditions: string[] = []; const queryParams: unknown[] = []; - const paramIndex = 1; + let paramIndex = 1; + + let joinClause = ` + JOIN pg_stat_kcache() k ON s.queryid = k.queryid + AND s.userid = k.userid + AND s.dbid = k.dbid`; + + if (dbname !== undefined) { + joinClause += `\n JOIN pg_database d ON s.dbid = d.oid`; + conditions.push(`d.datname = $${String(paramIndex++)}`); + queryParams.push(dbname); + } + + if (username !== undefined) { + joinClause += `\n JOIN pg_roles r ON s.userid = r.oid`; + conditions.push(`r.rolname = $${String(paramIndex++)}`); + queryParams.push(username); + } if (minCalls !== undefined) { - conditions.push(`s.calls >= $${String(paramIndex)}`); + conditions.push(`s.calls >= $${String(paramIndex++)}`); queryParams.push(minCalls); } @@ -115,9 +136,7 @@ orderBy options: 'total_time' (default), 'cpu_time', 'reads', 'writes'. Use minC const countSql = ` SELECT COUNT(*) as total FROM pg_stat_statements s - JOIN pg_stat_kcache() k ON s.queryid = k.queryid - AND s.userid = k.userid - AND s.dbid = k.dbid + ${joinClause} ${whereClause} `; const countResult = await adapter.executeQuery(countSql, queryParams); @@ -138,16 +157,14 @@ orderBy options: 'total_time' (default), 'cpu_time', 'reads', 'writes'. Use minC k.${cols.userTime} as user_time, k.${cols.systemTime} as system_time, (k.${cols.userTime} + k.${cols.systemTime}) as total_cpu_time, - k.${cols.reads} as read_bytes, - k.${cols.writes} as write_bytes, + k.${cols.reads}::float8 as read_bytes, + k.${cols.writes}::float8 as write_bytes, pg_size_pretty(k.${cols.reads}::bigint) as reads_pretty, pg_size_pretty(k.${cols.writes}::bigint) as writes_pretty, k.${cols.minflts} as minor_page_faults, k.${cols.majflts} as major_page_faults FROM pg_stat_statements s - JOIN pg_stat_kcache() k ON s.queryid = k.queryid - AND s.userid = k.userid - AND s.dbid = k.dbid + ${joinClause} ${whereClause} ORDER BY ${orderColumn} DESC LIMIT ${String(effectiveLimit)} @@ -172,6 +189,7 @@ orderBy options: 'total_time' (default), 'cpu_time', 'reads', 'writes'. Use minC : rawQueries; const response: Record = { + success: true, queries: finalQueries, count: rowCount, orderBy: orderBy ?? "total_time", @@ -200,7 +218,7 @@ export function createKcacheTopCpuTool( description: `Get top CPU-consuming queries. Shows which queries spend the most time in user CPU (application code) vs system CPU (kernel operations).`, group: "kcache", - inputSchema: KcacheTopCpuSchemaBase, + inputSchema: KcacheTopCpuSchema, outputSchema: KcacheTopCpuOutputSchema, annotations: readOnly("Kcache Top CPU"), icons: getToolIcons("kcache", readOnly("Kcache Top CPU")), @@ -213,15 +231,12 @@ in user CPU (application code) vs system CPU (kernel operations).`, compact: z.boolean().optional(), }) .parse(params ?? {}); - if ( - parsed.limit !== undefined && - (parsed.limit < 1 || parsed.limit > 100) - ) { - throw new ValidationError("limit must be between 1 and 100"); + if (parsed.limit !== undefined && parsed.limit < 1) { + throw new ValidationError("limit must be greater than or equal to 1"); } const DEFAULT_LIMIT = 5; - const effectiveLimit = parsed.limit ?? DEFAULT_LIMIT; + const effectiveLimit = Math.min(parsed.limit ?? DEFAULT_LIMIT, 100); // Bound queryPreviewLength: 0 = full query, default 100, max 500 const previewLen = parsed.queryPreviewLength === 0 @@ -256,13 +271,13 @@ in user CPU (application code) vs system CPU (kernel operations).`, (k.${cols.userTime} + k.${cols.systemTime}) as total_cpu_time, CASE WHEN (k.${cols.userTime} + k.${cols.systemTime}) > 0 - THEN ROUND((k.${cols.userTime} / (k.${cols.userTime} + k.${cols.systemTime}) * 100)::numeric, 2) + THEN ROUND((k.${cols.userTime} / (k.${cols.userTime} + k.${cols.systemTime}) * 100)::numeric, 2)::float8 ELSE 0 END as user_cpu_percent, s.total_exec_time as total_time_ms, CASE WHEN s.total_exec_time > 0 - THEN ROUND(((k.${cols.userTime} + k.${cols.systemTime}) / s.total_exec_time * 100)::numeric, 2) + THEN ROUND(((k.${cols.userTime} + k.${cols.systemTime}) / s.total_exec_time * 100)::numeric, 2)::float8 ELSE 0 END as cpu_time_percent FROM pg_stat_statements s @@ -293,7 +308,8 @@ in user CPU (application code) vs system CPU (kernel operations).`, : rawQueries; const response: Record = { - topCpuQueries: finalQueries, + success: true, + queries: finalQueries, count: rowCount, description: "Queries ranked by total CPU time (user + system)", truncated, @@ -319,7 +335,7 @@ export function createKcacheTopIoTool( description: `Get top I/O-consuming queries. Shows filesystem-level reads and writes, which represent actual disk access (not just shared buffer hits).`, group: "kcache", - inputSchema: KcacheTopIoSchemaBase, + inputSchema: KcacheTopIoSchema, outputSchema: KcacheTopIoOutputSchema, annotations: readOnly("Kcache Top IO"), icons: getToolIcons("kcache", readOnly("Kcache Top IO")), @@ -364,15 +380,12 @@ which represent actual disk access (not just shared buffer hits).`, ); } const ioType = rawIoType as (typeof VALID_IO_TYPES)[number]; - if ( - parsed.limit !== undefined && - (parsed.limit < 1 || parsed.limit > 100) - ) { - throw new ValidationError("limit must be between 1 and 100"); + if (parsed.limit !== undefined && parsed.limit < 1) { + throw new ValidationError("limit must be greater than or equal to 1"); } const DEFAULT_LIMIT = 5; - const effectiveLimit = parsed.limit ?? DEFAULT_LIMIT; + const effectiveLimit = Math.min(parsed.limit ?? DEFAULT_LIMIT, 100); // Bound queryPreviewLength: 0 = full query, default 100, max 500 const previewLen = parsed.queryPreviewLength === 0 @@ -417,9 +430,9 @@ which represent actual disk access (not just shared buffer hits).`, s.queryid, ${previewCol} s.calls, - k.${cols.reads} as read_bytes, - k.${cols.writes} as write_bytes, - (k.${cols.reads} + k.${cols.writes}) as total_io_bytes, + k.${cols.reads}::float8 as read_bytes, + k.${cols.writes}::float8 as write_bytes, + (k.${cols.reads} + k.${cols.writes})::float8 as total_io_bytes, pg_size_pretty(k.${cols.reads}::bigint) as reads_pretty, pg_size_pretty(k.${cols.writes}::bigint) as writes_pretty, s.total_exec_time as total_time_ms @@ -451,7 +464,8 @@ which represent actual disk access (not just shared buffer hits).`, : rawQueries; const response: Record = { - topIoQueries: finalQueries, + success: true, + queries: finalQueries, count: rowCount, ioType, description: `Queries ranked by ${ioType === "both" ? "total I/O" : ioType}`, diff --git a/src/adapters/postgresql/tools/ltree/basic.ts b/src/adapters/postgresql/tools/ltree/basic.ts index 0cee2f6e..479542f6 100644 --- a/src/adapters/postgresql/tools/ltree/basic.ts +++ b/src/adapters/postgresql/tools/ltree/basic.ts @@ -11,11 +11,13 @@ import { type RequestContext, ValidationError, } from "../../../../types/index.js"; -import { z } from "zod"; + import { readOnly, write } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; import { + LtreeCreateExtensionSchemaBase, + LtreeCreateExtensionSchema, LtreeQuerySchema, LtreeQuerySchemaBase, LtreeSubpathSchema, @@ -50,14 +52,32 @@ function createLtreeExtensionTool(adapter: PostgresAdapter): ToolDefinition { description: "Enable the ltree extension for hierarchical tree-structured labels.", group: "ltree", - inputSchema: z.object({}).strict(), + inputSchema: LtreeCreateExtensionSchemaBase, outputSchema: LtreeCreateExtensionOutputSchema, annotations: write("Create Ltree Extension"), icons: getToolIcons("ltree", write("Create Ltree Extension")), - handler: async (_params: unknown, _context: RequestContext) => { + handler: async (params: unknown, _context: RequestContext) => { try { - await adapter.executeQuery("CREATE EXTENSION IF NOT EXISTS ltree"); - return { success: true, message: "ltree extension enabled" }; + const { schema } = LtreeCreateExtensionSchema.parse(params); + const schemaName = schema ?? "public"; + + const schemaCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.schemata WHERE schema_name = $1`, + [schemaName], + ); + if (!schemaCheck.rows || schemaCheck.rows.length === 0) { + throw new ValidationError(`Schema "${schemaName}" does not exist.`, { + schema: schemaName, + }); + } + + await adapter.executeQuery( + `CREATE EXTENSION IF NOT EXISTS ltree SCHEMA "${schemaName}"`, + ); + return { + success: true, + message: `ltree extension enabled in schema ${schemaName}`, + }; } catch (error: unknown) { return formatHandlerErrorResponse(error, { tool: "pg_ltree_create_extension", @@ -82,6 +102,13 @@ function createLtreeQueryTool(adapter: PostgresAdapter): ToolDefinition { const { table, column, path, mode, schema, limit } = LtreeQuerySchema.parse(params); + if (limit !== undefined && limit < 1) { + throw new ValidationError( + `Limit must be at least 1, received: ${String(limit)}`, + { limit }, + ); + } + if (path === "") { throw new ValidationError( `Empty path "" is not allowed as it acts as an unconstrained match-all query. Please provide a specific path.`, @@ -180,7 +207,7 @@ function createLtreeQueryTool(adapter: PostgresAdapter): ToolDefinition { }; if (resultCount > 0) { - response["results"] = result.rows; + response["rows"] = result.rows; } // Add truncation indicators when limit is applied @@ -219,6 +246,13 @@ function createLtreeSubpathTool(adapter: PostgresAdapter): ToolDefinition { ); const pathDepth = depthResult.rows?.[0]?.["depth"] as number; + if (length !== undefined && length < 0) { + throw new ValidationError( + `Invalid length: ${String(length)}. Length cannot be negative.`, + { length }, + ); + } + // Validate offset is within bounds const effectiveOffset = offset < 0 ? pathDepth + offset : offset; if (effectiveOffset < 0 || effectiveOffset >= pathDepth) { @@ -276,6 +310,9 @@ function createLtreeLcaTool(adapter: PostgresAdapter): ToolDefinition { // (Postgres lca() natively returns the parent if given identical paths) const allIdentical = paths.every((p) => p === paths[0]); if (allIdentical) { + // Validate syntax by casting to ltree + await adapter.executeQuery(`SELECT $1::ltree`, [paths[0]]); + return { success: true, paths, @@ -317,6 +354,19 @@ function createLtreeListColumnsTool(adapter: PostgresAdapter): ToolDefinition { handler: async (params: unknown, _context: RequestContext) => { try { const { schema } = LtreeListColumnsSchema.parse(params); + + if (schema !== undefined) { + const schemaCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.schemata WHERE schema_name = $1`, + [schema], + ); + if (!schemaCheck.rows || schemaCheck.rows.length === 0) { + throw new ValidationError(`Schema "${schema}" does not exist.`, { + schema, + }); + } + } + const conditions: string[] = [ "udt_name = 'ltree'", "table_schema NOT IN ('pg_catalog', 'information_schema')", @@ -330,8 +380,14 @@ function createLtreeListColumnsTool(adapter: PostgresAdapter): ToolDefinition { const result = await adapter.executeQuery(sql, queryParams); const count = result.rows?.length ?? 0; const response: Record = { success: true, count }; - if (count > 0) { - response["columns"] = result.rows; + if (count > 0 && result.rows) { + response["columns"] = result.rows.map((row) => ({ + schema: row["table_schema"], + table: row["table_name"], + column: row["column_name"], + isNullable: row["is_nullable"], + columnDefault: row["column_default"], + })); } return response; } catch (error: unknown) { diff --git a/src/adapters/postgresql/tools/ltree/operations.ts b/src/adapters/postgresql/tools/ltree/operations.ts index abeb2d6a..bc56a3c7 100644 --- a/src/adapters/postgresql/tools/ltree/operations.ts +++ b/src/adapters/postgresql/tools/ltree/operations.ts @@ -48,6 +48,14 @@ function createLtreeMatchTool(adapter: PostgresAdapter): ToolDefinition { try { const { table, column, pattern, schema, limit } = LtreeMatchSchema.parse(params); + + if (limit !== undefined && limit < 1) { + throw new ValidationError( + `Limit must be at least 1, received: ${String(limit)}`, + { limit }, + ); + } + const schemaName = schema ?? "public"; const qualifiedTable = `"${schemaName}"."${table}"`; const limitClause = limit !== undefined ? `LIMIT ${String(limit)}` : ""; @@ -99,7 +107,7 @@ function createLtreeMatchTool(adapter: PostgresAdapter): ToolDefinition { }; if (resultCount > 0) { - response["results"] = result.rows; + response["rows"] = result.rows; } // Add truncation indicators when limit is applied diff --git a/src/adapters/postgresql/tools/introspection/migration/helpers.ts b/src/adapters/postgresql/tools/migration/helpers.ts similarity index 87% rename from src/adapters/postgresql/tools/introspection/migration/helpers.ts rename to src/adapters/postgresql/tools/migration/helpers.ts index 12b305a3..89af00d0 100644 --- a/src/adapters/postgresql/tools/introspection/migration/helpers.ts +++ b/src/adapters/postgresql/tools/migration/helpers.ts @@ -5,7 +5,7 @@ */ import { createHash } from "node:crypto"; -import type { PostgresAdapter } from "../../../postgres-adapter.js"; +import type { PostgresAdapter } from "../../postgres-adapter.js"; // ============================================================================= // Migration tracking β€” shared helpers @@ -43,19 +43,28 @@ CREATE TABLE IF NOT EXISTS ${qualifiedTable} ( */ export async function ensureTrackingTable( adapter: PostgresAdapter, + targetSchema = "public", ): Promise { const check = await adapter.executeQuery( `SELECT EXISTS ( SELECT 1 FROM pg_tables - WHERE schemaname = 'public' AND tablename = $1 + WHERE schemaname = $1 AND tablename = $2 ) AS "table_exists"`, - [TRACKING_TABLE], + [targetSchema, TRACKING_TABLE], ); const firstRow = (check.rows ?? [])[0]; const existed = firstRow?.["table_exists"] === true; if (!existed) { - await adapter.executeQuery(buildCreateTrackingTableSql(TRACKING_TABLE)); + const sanitizedSchema = + targetSchema === "public" + ? "public" + : `"${targetSchema.replace(/"/g, '""')}"`; + const qualifiedTable = + targetSchema === "public" + ? TRACKING_TABLE + : `${sanitizedSchema}."${TRACKING_TABLE}"`; + await adapter.executeQuery(buildCreateTrackingTableSql(qualifiedTable)); } return !existed; } @@ -71,6 +80,7 @@ export function hashMigrationSql(sql: string): string { export async function checkDuplicateHash( adapter: PostgresAdapter, migrationSql: string, + qualifiedTable = TRACKING_TABLE, ): Promise<{ migrationHash: string; duplicateError: null | { @@ -83,7 +93,7 @@ export async function checkDuplicateHash( }> { const migrationHash = hashMigrationSql(migrationSql); const dupCheck = await adapter.executeQuery( - `SELECT id, version, status FROM ${TRACKING_TABLE} + `SELECT id, version, status FROM ${qualifiedTable} WHERE migration_hash = $1 AND status = 'applied'`, [migrationHash], ); diff --git a/src/adapters/postgresql/tools/migration/index.ts b/src/adapters/postgresql/tools/migration/index.ts index e71ac87f..955ffbb6 100644 --- a/src/adapters/postgresql/tools/migration/index.ts +++ b/src/adapters/postgresql/tools/migration/index.ts @@ -12,13 +12,13 @@ import { createMigrationInitTool, createMigrationRecordTool, createMigrationApplyTool, -} from "../introspection/migration.js"; +} from "./migration.js"; import { createMigrationRollbackTool, createMigrationHistoryTool, createMigrationStatusTool, -} from "../introspection/migration-query.js"; +} from "./migration-query.js"; /** * Get all migration tools diff --git a/src/adapters/postgresql/tools/introspection/migration-query.ts b/src/adapters/postgresql/tools/migration/migration-query.ts similarity index 89% rename from src/adapters/postgresql/tools/introspection/migration-query.ts rename to src/adapters/postgresql/tools/migration/migration-query.ts index 87c4fcb7..d6ac8ea4 100644 --- a/src/adapters/postgresql/tools/introspection/migration-query.ts +++ b/src/adapters/postgresql/tools/migration/migration-query.ts @@ -31,7 +31,7 @@ import { TRACKING_TABLE, ensureTrackingTable, formatRecord, -} from "./migration/helpers.js"; +} from "./helpers.js"; // ============================================================================= // pg_migration_rollback @@ -55,7 +55,14 @@ export function createMigrationRollbackTool( handler: async (params: unknown, _context: RequestContext) => { try { const parsed = MigrationRollbackSchema.parse(params); - await ensureTrackingTable(adapter); + const targetSchema = parsed.schema ?? "public"; + const sanitizedSchema = sanitizeIdentifier(targetSchema); + const qualifiedTable = + targetSchema === "public" + ? TRACKING_TABLE + : `${sanitizedSchema}."${TRACKING_TABLE}"`; + + await ensureTrackingTable(adapter, targetSchema); if (parsed.id === undefined && parsed.version === undefined) { throw new ValidationError( @@ -81,7 +88,7 @@ export function createMigrationRollbackTool( const whereValue = coercedId ?? parsed.version; const findResult = await adapter.executeQuery( - `SELECT * FROM ${TRACKING_TABLE} WHERE ${whereClause} ORDER BY id DESC LIMIT 1`, + `SELECT * FROM ${qualifiedTable} WHERE ${whereClause} ORDER BY id DESC LIMIT 1`, [whereValue], ); @@ -137,7 +144,7 @@ export function createMigrationRollbackTool( await adapter.executeOnConnection(client, rollbackSql); await adapter.executeOnConnection( client, - `UPDATE ${TRACKING_TABLE} SET status = 'rolled_back' WHERE id = $1`, + `UPDATE ${qualifiedTable} SET status = 'rolled_back' WHERE id = $1`, [rowId], ); await adapter.commitTransaction(transactionId); @@ -189,7 +196,14 @@ export function createMigrationHistoryTool( handler: async (params: unknown, _context: RequestContext) => { try { const parsed = MigrationHistorySchema.parse(params); - await ensureTrackingTable(adapter); + const targetSchema = parsed.schema ?? "public"; + const sanitizedSchema = sanitizeIdentifier(targetSchema); + const qualifiedTable = + targetSchema === "public" + ? TRACKING_TABLE + : `${sanitizedSchema}."${TRACKING_TABLE}"`; + + await ensureTrackingTable(adapter, targetSchema); // Coerce limit/offset: wrong-type values silently default const limit = parsed.limit ?? 50; @@ -216,7 +230,7 @@ export function createMigrationHistoryTool( // Get total count const countResult = await adapter.executeQuery( - `SELECT COUNT(*)::int AS count FROM ${TRACKING_TABLE} ${whereClause}`, + `SELECT COUNT(*)::int AS count FROM ${qualifiedTable} ${whereClause}`, values.length > 0 ? values : undefined, ); const countRow = (countResult.rows ?? [])[0]; @@ -229,7 +243,7 @@ export function createMigrationHistoryTool( const dataResult = await adapter.executeQuery( `SELECT id, version, description, applied_at, applied_by, migration_hash, source_system, rollback_sql IS NOT NULL AS has_rollback, status, error_information - FROM ${TRACKING_TABLE} + FROM ${qualifiedTable} ${whereClause} ORDER BY applied_at DESC LIMIT $${limitIdx} OFFSET $${offsetIdx}`, @@ -280,6 +294,20 @@ export function createMigrationStatusTool( // Sanitize schema to prevent SQL injection via identifier interpolation const sanitizedSchema = sanitizeIdentifier(targetSchema); + // Check if schema exists first (except for public) + if (targetSchema !== "public") { + const schemaCheck = await adapter.executeQuery( + `SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = $1) AS "schema_exists"`, + [targetSchema], + ); + if ( + schemaCheck.rows && + schemaCheck.rows[0]?.["schema_exists"] === false + ) { + throw new Error(`schema "${targetSchema}" does not exist`); + } + } + // Check if tracking table exists const check = await adapter.executeQuery( `SELECT EXISTS ( diff --git a/src/adapters/postgresql/tools/introspection/migration.ts b/src/adapters/postgresql/tools/migration/migration.ts similarity index 91% rename from src/adapters/postgresql/tools/introspection/migration.ts rename to src/adapters/postgresql/tools/migration/migration.ts index 4e48f1e4..a1d81f25 100644 --- a/src/adapters/postgresql/tools/introspection/migration.ts +++ b/src/adapters/postgresql/tools/migration/migration.ts @@ -36,7 +36,7 @@ import { ensureTrackingTable, checkDuplicateHash, formatRecord, -} from "./migration/helpers.js"; +} from "./helpers.js"; // ============================================================================= // pg_migration_init @@ -130,16 +130,24 @@ export function createMigrationRecordTool( handler: async (params: unknown, _context: RequestContext) => { try { const parsed = MigrationRecordSchema.parse(params); - await ensureTrackingTable(adapter); + const targetSchema = parsed.schema ?? "public"; + const sanitizedSchema = sanitizeIdentifier(targetSchema); + const qualifiedTable = + targetSchema === "public" + ? TRACKING_TABLE + : `${sanitizedSchema}."${TRACKING_TABLE}"`; + + await ensureTrackingTable(adapter, targetSchema); const { migrationHash, duplicateError } = await checkDuplicateHash( adapter, parsed.migrationSql, + qualifiedTable, ); if (duplicateError) return duplicateError; const result = await adapter.executeQuery( - `INSERT INTO ${TRACKING_TABLE} + `INSERT INTO ${qualifiedTable} (version, description, applied_by, migration_hash, migration_sql, source_system, rollback_sql, status) VALUES ($1, $2, $3, $4, $5, $6, $7, 'recorded') RETURNING *`, @@ -198,11 +206,19 @@ export function createMigrationApplyTool( handler: async (params: unknown, _context: RequestContext) => { try { const parsed = MigrationApplySchema.parse(params); - await ensureTrackingTable(adapter); + const targetSchema = parsed.schema ?? "public"; + const sanitizedSchema = sanitizeIdentifier(targetSchema); + const qualifiedTable = + targetSchema === "public" + ? TRACKING_TABLE + : `${sanitizedSchema}."${TRACKING_TABLE}"`; + + await ensureTrackingTable(adapter, targetSchema); const { migrationHash, duplicateError } = await checkDuplicateHash( adapter, parsed.migrationSql, + qualifiedTable, ); if (duplicateError) return duplicateError; @@ -219,7 +235,7 @@ export function createMigrationApplyTool( // Record in tracking table const result = await adapter.executeOnConnection( client, - `INSERT INTO ${TRACKING_TABLE} + `INSERT INTO ${qualifiedTable} (version, description, applied_by, migration_hash, migration_sql, source_system, rollback_sql) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, @@ -261,7 +277,7 @@ export function createMigrationApplyTool( // Record a 'failed' entry outside the rolled-back transaction try { await adapter.executeQuery( - `INSERT INTO ${TRACKING_TABLE} + `INSERT INTO ${qualifiedTable} (version, description, applied_by, migration_hash, migration_sql, source_system, rollback_sql, status, error_information) VALUES ($1, $2, $3, $4, $5, $6, $7, 'failed', $8)`, [ diff --git a/src/adapters/postgresql/tools/monitoring/basic.ts b/src/adapters/postgresql/tools/monitoring/basic.ts index 8aa967f8..ab598f7a 100644 --- a/src/adapters/postgresql/tools/monitoring/basic.ts +++ b/src/adapters/postgresql/tools/monitoring/basic.ts @@ -119,7 +119,7 @@ export function createTableSizesTool(adapter: PostgresAdapter): ToolDefinition { ); if (schemaCheck.rows?.length === 0) { throw new Error( - `Schema '${schema}' does not exist. Use pg_list_schemas to see available schemas.`, + `Schema "${schema}" does not exist. Use pg_list_schemas to see available schemas.`, ); } } @@ -238,7 +238,7 @@ export function createConnectionStatsTool( [database], ); if (dbCheck.rows?.length === 0) { - throw new Error(`Database '${database}' does not exist.`); + throw new Error(`Database "${database}" does not exist.`); } } diff --git a/src/adapters/postgresql/tools/monitoring/index.ts b/src/adapters/postgresql/tools/monitoring/index.ts index 4cfc587c..6656cd50 100644 --- a/src/adapters/postgresql/tools/monitoring/index.ts +++ b/src/adapters/postgresql/tools/monitoring/index.ts @@ -22,7 +22,7 @@ import { // Advanced analysis tools import { createCapacityPlanningTool } from "./capacity-planning.js"; -import { createResourceUsageAnalyzeTool } from "./resource-usage.js"; +import { createSystemHealthTool } from "./resource-usage.js"; import { createAlertThresholdSetTool } from "./alert-thresholds.js"; /** @@ -39,7 +39,7 @@ export function getMonitoringTools(adapter: PostgresAdapter): ToolDefinition[] { createUptimeTool(adapter), createRecoveryStatusTool(adapter), createCapacityPlanningTool(adapter), - createResourceUsageAnalyzeTool(adapter), + createSystemHealthTool(adapter), createAlertThresholdSetTool(adapter), ]; } @@ -55,6 +55,6 @@ export { createUptimeTool, createRecoveryStatusTool, createCapacityPlanningTool, - createResourceUsageAnalyzeTool, + createSystemHealthTool, createAlertThresholdSetTool, }; diff --git a/src/adapters/postgresql/tools/monitoring/resource-usage.ts b/src/adapters/postgresql/tools/monitoring/resource-usage.ts index 864fd9f6..fc5e9ff4 100644 --- a/src/adapters/postgresql/tools/monitoring/resource-usage.ts +++ b/src/adapters/postgresql/tools/monitoring/resource-usage.ts @@ -7,23 +7,26 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; + import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; -import { ResourceUsageAnalyzeOutputSchema } from "../../schemas/index.js"; +import { + SystemHealthSchemaBase, + SystemHealthOutputSchema, +} from "../../schemas/index.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; -export function createResourceUsageAnalyzeTool( +export function createSystemHealthTool( adapter: PostgresAdapter, ): ToolDefinition { return { - name: "pg_resource_usage_analyze", + name: "pg_system_health", description: - "Analyze current resource usage including CPU, memory, and I/O patterns.", + "Analyze current system health and resource usage including CPU, memory, and I/O patterns.", group: "monitoring", - inputSchema: z.object({}).strict(), - outputSchema: ResourceUsageAnalyzeOutputSchema, + inputSchema: SystemHealthSchemaBase, + outputSchema: SystemHealthOutputSchema, annotations: readOnly("Resource Usage Analysis"), icons: getToolIcons("monitoring", readOnly("Resource Usage Analysis")), handler: async (_params: unknown, _context: RequestContext) => { @@ -225,7 +228,7 @@ export function createResourceUsageAnalyzeTool( }; } catch (err) { return formatHandlerErrorResponse(err, { - tool: "pg_resource_usage_analyze", + tool: "pg_system_health", }); } }, diff --git a/src/adapters/postgresql/tools/partman/create.ts b/src/adapters/postgresql/tools/partman/create.ts index 329c08d8..c9fe6cc4 100644 --- a/src/adapters/postgresql/tools/partman/create.ts +++ b/src/adapters/postgresql/tools/partman/create.ts @@ -11,11 +11,13 @@ import { type RequestContext, ValidationError, } from "../../../../types/index.js"; -import { z } from "zod"; + import { write } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; import { + PartmanCreateExtensionSchema, + PartmanCreateExtensionSchemaBase, PartmanCreateParentSchema, PartmanCreateParentSchemaBase, DEPRECATED_INTERVALS, @@ -36,13 +38,16 @@ export function createPartmanExtensionTool( description: "Enable the pg_partman extension for automated partition management. Requires superuser privileges.", group: "partman", - inputSchema: z.object({}).strict(), + inputSchema: PartmanCreateExtensionSchemaBase, outputSchema: PartmanCreateExtensionOutputSchema, annotations: write("Create Partman Extension"), icons: getToolIcons("partman", write("Create Partman Extension")), - handler: async (_params: unknown, _context: RequestContext) => { + handler: async (params: unknown, _context: RequestContext) => { try { - await adapter.executeQuery("CREATE EXTENSION IF NOT EXISTS pg_partman"); + const { schema } = PartmanCreateExtensionSchema.parse(params); + await adapter.executeQuery( + `CREATE EXTENSION IF NOT EXISTS pg_partman WITH SCHEMA ${schema}`, + ); return { success: true, message: "pg_partman extension enabled" }; } catch (error: unknown) { return formatHandlerErrorResponse(error, { @@ -108,15 +113,13 @@ A startPartition far in the past (e.g., '2024-01-01' with daily intervals) creat if (!parentTable) missing.push("parentTable"); if (!controlColumn) missing.push("controlColumn (or control)"); if (!interval) missing.push("interval"); - return { - success: false, - error: `Missing required parameters: ${missing.join(", ")}.`, - code: "VALIDATION_ERROR", - category: "validation", - recoverable: false, - hint: 'Example: pg_partman_create_parent({ parentTable: "public.events", controlColumn: "created_at", interval: "1 month" })', - aliases: { control: "controlColumn" }, - }; + throw new ValidationError( + `Validation error: Missing required parameters: ${missing.join(", ")}.`, + { + hint: 'Example: pg_partman_create_parent({ parentTable: "public.events", controlColumn: "created_at", interval: "1 month" })', + aliases: { control: "controlColumn" }, + }, + ); } // Check for deprecated interval keywords and return structured error diff --git a/src/adapters/postgresql/tools/partman/health-analysis.ts b/src/adapters/postgresql/tools/partman/health-analysis.ts index d405b512..8a30c8e8 100644 --- a/src/adapters/postgresql/tools/partman/health-analysis.ts +++ b/src/adapters/postgresql/tools/partman/health-analysis.ts @@ -268,6 +268,7 @@ stale maintenance, and retention configuration.`, const truncated = applyLimit && totalCount > limit; return { + success: true, partitionSets: healthChecks, truncated, totalCount, diff --git a/src/adapters/postgresql/tools/partman/helpers.ts b/src/adapters/postgresql/tools/partman/helpers.ts index e4beed06..590c8a28 100644 --- a/src/adapters/postgresql/tools/partman/helpers.ts +++ b/src/adapters/postgresql/tools/partman/helpers.ts @@ -43,7 +43,6 @@ export async function getPartmanSchema( const result = await adapter.executeQuery(` SELECT table_schema FROM information_schema.tables WHERE table_name = 'part_config' - AND table_schema IN ('partman', 'public') LIMIT 1 `); @@ -72,6 +71,7 @@ export async function getPartmanSchema( */ export async function ensurePartmanSchemaAlias( adapter: PostgresAdapter, + partmanSchema: string, ): Promise { try { await adapter.executeQuery("CREATE SCHEMA IF NOT EXISTS partman"); @@ -80,7 +80,7 @@ export async function ensurePartmanSchemaAlias( p_parent_schema text, p_parent_tablename text, p_control text ) RETURNS TABLE(general_type text, exact_type text) LANGUAGE sql STABLE AS $$ - SELECT * FROM public.check_control_type(p_parent_schema, p_parent_tablename, p_control) + SELECT * FROM ${partmanSchema}.check_control_type(p_parent_schema, p_parent_tablename, p_control) $$ `); } catch { @@ -97,10 +97,10 @@ export async function callPartmanProcedure( partmanSchema: string, sql: string, ): Promise { - // When pg_partman is installed in 'public', ensure the 'partman' schema alias + // When pg_partman is installed in a schema other than 'partman', ensure the 'partman' schema alias // exists for hardcoded partman.* references inside pg_partman's functions - if (partmanSchema === "public") { - await ensurePartmanSchemaAlias(adapter); + if (partmanSchema !== "partman") { + await ensurePartmanSchemaAlias(adapter, partmanSchema); } await adapter.executeQuery(sql); } diff --git a/src/adapters/postgresql/tools/partman/management.ts b/src/adapters/postgresql/tools/partman/management.ts index 6c1e0330..30d83256 100644 --- a/src/adapters/postgresql/tools/partman/management.ts +++ b/src/adapters/postgresql/tools/partman/management.ts @@ -464,6 +464,7 @@ export function createPartmanShowConfigTool( } return { + success: true, configs: configsWithStatus, count: configsWithStatus.length, truncated, diff --git a/src/adapters/postgresql/tools/partman/operations.ts b/src/adapters/postgresql/tools/partman/operations.ts index 9bf80d9f..b4949659 100644 --- a/src/adapters/postgresql/tools/partman/operations.ts +++ b/src/adapters/postgresql/tools/partman/operations.ts @@ -49,7 +49,7 @@ Data in default indicates partitions may be missing for certain time/value range // parentTable is required - provide clear error if missing if (!parentTable) { throw new ValidationError( - 'parentTable parameter is required. Specify the parent table (e.g., "public.events") to check its default partition.', + 'Validation error: parentTable parameter is required. Specify the parent table (e.g., "public.events") to check its default partition.', { hint: "Use pg_partman_show_config to list all partition sets first.", }, @@ -212,7 +212,7 @@ Creates new partitions if needed for the data being moved.`, // parentTable is required - provide clear error if missing if (!parentTable) { throw new ValidationError( - 'parentTable parameter is required. Specify the parent table (e.g., "public.events") to move data from its default partition.', + 'Validation error: parentTable parameter is required. Specify the parent table (e.g., "public.events") to move data from its default partition.', { hint: "Use pg_partman_show_config to list all partition sets first.", }, diff --git a/src/adapters/postgresql/tools/partman/retention.ts b/src/adapters/postgresql/tools/partman/retention.ts index bec70ca8..af0beb05 100644 --- a/src/adapters/postgresql/tools/partman/retention.ts +++ b/src/adapters/postgresql/tools/partman/retention.ts @@ -50,7 +50,7 @@ Partitions older than the retention period will be dropped or detached during ma // Validate required parentTable if (!parentTable) { throw new ValidationError( - "Missing required parameter: parentTable.", + "Validation error: Missing required parameter: parentTable.", { hint: 'Example: pg_partman_set_retention({ parentTable: "public.events", retention: "30 days" })', }, @@ -62,11 +62,14 @@ Partitions older than the retention period will be dropped or detached during ma // If retention is omitted (undefined), it's required if (retention === undefined) { - throw new ValidationError("Missing required parameter: retention.", { - hint: - 'Provide a retention period (e.g., "30 days") or pass null to explicitly disable retention. ' + - 'Example: pg_partman_set_retention({ parentTable: "public.events", retention: "30 days" })', - }); + throw new ValidationError( + "Validation error: Missing required parameter: retention.", + { + hint: + 'Provide a retention period (e.g., "30 days") or pass null to explicitly disable retention. ' + + 'Example: pg_partman_set_retention({ parentTable: "public.events", retention: "30 days" })', + }, + ); } // Special case: explicit null or empty string means disable/clear retention @@ -229,7 +232,7 @@ Example: undoPartition({ parentTable: "public.events", targetTable: "public.even if (!parentTable) missing.push("parentTable"); if (!targetTable) missing.push("targetTable (or target)"); throw new ValidationError( - `Missing required parameters: ${missing.join(", ")}.`, + `Validation error: Missing required parameters: ${missing.join(", ")}.`, { hint: 'Example: pg_partman_undo_partition({ parentTable: "public.events", targetTable: "public.events_archive" }). Target table must exist first.', aliases: { target: "targetTable" }, diff --git a/src/adapters/postgresql/tools/performance/__tests__/anomaly-detection.test.ts b/src/adapters/postgresql/tools/performance/__tests__/anomaly-detection.test.ts index a6851b51..035f2077 100644 --- a/src/adapters/postgresql/tools/performance/__tests__/anomaly-detection.test.ts +++ b/src/adapters/postgresql/tools/performance/__tests__/anomaly-detection.test.ts @@ -140,24 +140,29 @@ describe("pg_detect_query_anomalies", () => { expect(mainQuery).toContain("3"); }); - it("should reject out-of-range threshold and minCalls with validation errors", async () => { + it("should clamp out-of-range threshold and minCalls to internal bounds", async () => { + mockAdapter.executeQuery.mockResolvedValueOnce({ + rows: [{ 1: 1 }], + }); + mockAdapter.executeQuery.mockResolvedValueOnce({ + rows: [{ total: 10 }], + }); + mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [] }); + const tool = findTool(tools, "pg_detect_query_anomalies"); - // threshold below minimum (0.5) should return a structured error - const resultLowThreshold = (await tool.handler( - { threshold: 0.1, minCalls: 10 }, + const result = (await tool.handler( + { threshold: 0.001, minCalls: -5 }, mockContext, - )) as { success: boolean; error: string }; - expect(resultLowThreshold.success).toBe(false); - expect(resultLowThreshold.error).toContain("threshold"); + )) as { success: boolean }; - // minCalls below minimum (1) should return a structured error - const resultLowMinCalls = (await tool.handler( - { threshold: 2.0, minCalls: -5 }, - mockContext, - )) as { success: boolean; error: string }; - expect(resultLowMinCalls.success).toBe(false); - expect(resultLowMinCalls.error).toContain("minCalls"); + expect(result.success).toBe(true); + + const countQuery = mockAdapter.executeQuery.mock.calls[1]?.[0] as string; + expect(countQuery).toContain(">= 1"); + + const mainQuery = mockAdapter.executeQuery.mock.calls[2]?.[0] as string; + expect(mainQuery).toContain("* 0.01)"); }); it("should calculate critical risk for many anomalies with high z-scores", async () => { @@ -356,16 +361,38 @@ describe("pg_detect_bloat_risk", () => { }); it("should filter by schema when specified", async () => { + // Schema existence check + mockAdapter.executeQuery.mockResolvedValueOnce({ + rows: [{ "?column?": 1 }], + }); // Main query mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [] }); const tool = findTool(tools, "pg_detect_bloat_risk"); await tool.handler({ schema: "sales" }, mockContext); - const sql = mockAdapter.executeQuery.mock.calls[0]?.[0] as string; + // Main query should be called second + const sql = mockAdapter.executeQuery.mock.calls[1]?.[0] as string; expect(sql).toContain("schemaname = 'sales'"); }); + it("should return error for non-existent schema", async () => { + // Schema existence check returns empty + mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [] }); + + const tool = findTool(tools, "pg_detect_bloat_risk"); + const result = (await tool.handler( + { schema: "nonexistent" }, + mockContext, + )) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("does not exist"); + }); + it("should exclude system schemas by default", async () => { mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [] }); diff --git a/src/adapters/postgresql/tools/performance/__tests__/performance.test.ts b/src/adapters/postgresql/tools/performance/__tests__/performance.test.ts index 98ec0ef0..377589c9 100644 --- a/src/adapters/postgresql/tools/performance/__tests__/performance.test.ts +++ b/src/adapters/postgresql/tools/performance/__tests__/performance.test.ts @@ -3300,7 +3300,7 @@ describe("P154 pre-checks", () => { )) as { success: boolean; error: string }; expect(result.success).toBe(false); - expect(result.error).toContain("not found"); + expect(result.error).toContain("does not exist"); expect(result.error).toContain("nonexistent_table"); }); @@ -3329,7 +3329,7 @@ describe("P154 pre-checks", () => { )) as { success: boolean; error: string }; expect(result.success).toBe(false); - expect(result.error).toContain("not found"); + expect(result.error).toContain("does not exist"); }); it("pg_table_stats should return error for nonexistent schema", async () => { @@ -3355,7 +3355,7 @@ describe("P154 pre-checks", () => { )) as { success: boolean; error: string }; expect(result.success).toBe(false); - expect(result.error).toContain("not found"); + expect(result.error).toContain("does not exist"); }); it("pg_bloat_check should return error for nonexistent table", async () => { @@ -3368,7 +3368,7 @@ describe("P154 pre-checks", () => { )) as { success: boolean; error: string }; expect(result.success).toBe(false); - expect(result.error).toContain("not found"); + expect(result.error).toContain("does not exist"); }); it("pg_index_recommendations should return error for nonexistent table (table mode)", async () => { @@ -3383,7 +3383,7 @@ describe("P154 pre-checks", () => { )) as { success: boolean; error: string }; expect(result.success).toBe(false); - expect(result.error).toContain("not found"); + expect(result.error).toContain("does not exist"); }); it("pg_seq_scan_tables should return error for nonexistent schema", async () => { diff --git a/src/adapters/postgresql/tools/performance/analysis.ts b/src/adapters/postgresql/tools/performance/analysis.ts index b29c242b..71b29f3f 100644 --- a/src/adapters/postgresql/tools/performance/analysis.ts +++ b/src/adapters/postgresql/tools/performance/analysis.ts @@ -187,16 +187,18 @@ export function createIndexRecommendationsTool( handler: async (params: unknown, _context: RequestContext) => { try { const parsed = IndexRecommendationsInputSchema.parse(params); - const schemaName = parsed.schema ?? "public"; - const queryParams = parsed.params ?? []; + const schemaName = + typeof parsed.schema === "string" ? parsed.schema : "public"; + const queryParams = Array.isArray(parsed.params) ? parsed.params : []; // If SQL query provided, perform query-specific analysis - if (parsed.sql !== undefined && parsed.sql.trim() !== "") { + if (typeof parsed.sql === "string" && parsed.sql.trim() !== "") { + const sqlStr = parsed.sql; const hypopgAvailable = await checkHypoPG(); // Get baseline EXPLAIN plan (with parameter binding support) const baselineResult = await adapter.executeQuery( - `EXPLAIN (FORMAT JSON) ${parsed.sql}`, + `EXPLAIN (FORMAT JSON) ${sqlStr}`, queryParams, ); const baselinePlanRow = baselineResult.rows?.[0] as @@ -247,7 +249,7 @@ export function createIndexRecommendationsTool( // Re-run EXPLAIN with hypothetical index (with parameter binding) const improvedResult = await adapter.executeQuery( - `EXPLAIN (FORMAT JSON) ${parsed.sql}`, + `EXPLAIN (FORMAT JSON) ${sqlStr}`, queryParams, ); const improvedPlanRow = improvedResult.rows?.[0] as @@ -332,7 +334,7 @@ export function createIndexRecommendationsTool( const statsParams: string[] = [schemaName]; const schemaClause = `AND schemaname = $${String(statsParams.length)}`; let tableClause = ""; - if (parsed.table !== undefined) { + if (typeof parsed.table === "string") { statsParams.push(parsed.table); tableClause = `AND relname = $${String(statsParams.length)}`; } @@ -340,8 +342,8 @@ export function createIndexRecommendationsTool( // P154: Validate table/schema existence in table-stats path (throws ValidationError on failure) await validatePerformanceTableExists( adapter, - parsed.table, - parsed.schema ?? "public", + typeof parsed.table === "string" ? parsed.table : undefined, + schemaName, ); const sql = `SELECT schemaname, relname as table_name, diff --git a/src/adapters/postgresql/tools/performance/anomaly-detection.ts b/src/adapters/postgresql/tools/performance/anomaly-detection.ts index e7a20fae..9864d079 100644 --- a/src/adapters/postgresql/tools/performance/anomaly-detection.ts +++ b/src/adapters/postgresql/tools/performance/anomaly-detection.ts @@ -18,15 +18,18 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; -import { readOnly } from "../../../../utils/annotations.js"; -import { getToolIcons } from "../../../../utils/icons.js"; -import { formatHandlerErrorResponse } from "../core/error-helpers.js"; -import { validateIdentifier } from "../../../../utils/identifiers.js"; import { DetectQueryAnomaliesOutputSchema, DetectBloatRiskOutputSchema, + QueryAnomaliesInputBase, + QueryAnomaliesInput, + BloatRiskInputBase, + BloatRiskInput, } from "../../schemas/performance.js"; +import { readOnly } from "../../../../utils/annotations.js"; +import { getToolIcons } from "../../../../utils/icons.js"; +import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import { validateIdentifier } from "../../../../utils/identifiers.js"; // ============================================================================= // Shared Helpers (exported for connection-analysis.ts) // ============================================================================= @@ -50,37 +53,6 @@ export function riskFromScore(score: number): RiskLevel { // 1. pg_detect_query_anomalies // ============================================================================= -const coerceNumber = (val: unknown): unknown => - typeof val === "string" - ? isNaN(Number(val)) - ? undefined - : Number(val) - : val; - -const QueryAnomaliesInputBase = z.object({ - threshold: z - .unknown() - .optional() - .describe( - "Standard deviation multiplier for anomaly detection (default: 2.0)", - ), - minCalls: z - .unknown() - .optional() - .describe("Minimum call count to filter noise (default: 10)"), -}); - -const QueryAnomaliesInput = z.preprocess( - (data: unknown) => { - if (typeof data !== "object" || data === null) return {}; - return data; - }, - z.object({ - threshold: z.preprocess(coerceNumber, z.number().optional()), - minCalls: z.preprocess(coerceNumber, z.number().optional()), - }), -); - export function createDetectQueryAnomaliesTool( adapter: PostgresAdapter, ): ToolDefinition { @@ -105,24 +77,13 @@ export function createDetectQueryAnomaliesTool( }; } - const threshold = parsed.data.threshold ?? 2.0; - const minCalls = parsed.data.minCalls ?? 10; + let threshold = parsed.data.threshold ?? 2.0; + let minCalls = parsed.data.minCalls ?? 10; + let limit = parsed.data.limit ?? 20; - if (threshold < 0.5 || threshold > 10) { - return { - success: false, - error: "Validation error: threshold must be between 0.5 and 10", - code: "VALIDATION_ERROR", - }; - } - - if (minCalls < 1 || minCalls > 10000) { - return { - success: false, - error: "Validation error: minCalls must be between 1 and 10000", - code: "VALIDATION_ERROR", - }; - } + threshold = Math.max(0.01, Math.min(100, threshold)); + minCalls = Math.max(1, Math.min(1000000, minCalls)); + limit = Math.max(1, Math.min(100, limit)); // Check if pg_stat_statements is available const extCheck = await adapter.executeQuery( @@ -135,8 +96,11 @@ export function createDetectQueryAnomaliesTool( "pg_stat_statements extension is not installed. " + "Install with: CREATE EXTENSION pg_stat_statements; " + "(requires shared_preload_libraries configuration)", + code: "EXTENSION_NOT_FOUND", + category: "resource", suggestion: "Use pg_diagnose_database_performance for baseline-free health checks", + recoverable: false, }; } @@ -161,7 +125,7 @@ export function createDetectQueryAnomaliesTool( AND stddev_exec_time > 0 AND mean_exec_time > (stddev_exec_time * ${String(threshold)}) ORDER BY (mean_exec_time / NULLIF(stddev_exec_time, 0)) DESC - LIMIT 20 + LIMIT ${String(limit)} `); const anomalies = (result.rows ?? []).map( @@ -218,28 +182,6 @@ export function createDetectQueryAnomaliesTool( // 2. pg_detect_bloat_risk // ============================================================================= -const BloatRiskInputBase = z.object({ - schema: z - .string() - .optional() - .describe("Filter to a specific schema (default: all user schemas)"), - minRows: z - .unknown() - .optional() - .describe("Minimum live rows to include (default: 1000)"), -}); - -const BloatRiskInput = z.preprocess( - (data: unknown) => { - if (typeof data !== "object" || data === null) return {}; - return data; - }, - z.object({ - schema: z.string().optional(), - minRows: z.preprocess(coerceNumber, z.number().optional()), - }), -); - export function createDetectBloatRiskTool( adapter: PostgresAdapter, ): ToolDefinition { @@ -272,12 +214,29 @@ export function createDetectBloatRiskTool( success: false, error: "Validation error: minRows must be between 0 and 1000000", code: "VALIDATION_ERROR", + category: "validation", + recoverable: false, }; } let schemaFilter: string; if (schema) { validateIdentifier(schema); + + const schemaCheck = await adapter.executeQuery( + `SELECT 1 FROM pg_namespace WHERE nspname = $1`, + [schema], + ); + if (!schemaCheck.rows || schemaCheck.rows.length === 0) { + return { + success: false, + error: `Schema "${schema}" does not exist`, + code: "SCHEMA_NOT_FOUND", + category: "schema", + recoverable: false, + }; + } + schemaFilter = `AND schemaname = '${schema}'`; } else { schemaFilter = `AND schemaname NOT IN ('pg_catalog', 'information_schema', 'cron', 'topology', 'tiger', 'tiger_data')`; diff --git a/src/adapters/postgresql/tools/performance/compare.ts b/src/adapters/postgresql/tools/performance/compare.ts index fd5594c6..b3530b48 100644 --- a/src/adapters/postgresql/tools/performance/compare.ts +++ b/src/adapters/postgresql/tools/performance/compare.ts @@ -10,11 +10,14 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; -import { QueryPlanCompareOutputSchema } from "../../schemas/index.js"; +import { + QueryPlanCompareOutputSchema, + QueryPlanCompareSchemaBase, + QueryPlanCompareSchema, +} from "../../schemas/index.js"; /** * Recursively strip zero-value block stats, empty Triggers arrays, @@ -59,47 +62,6 @@ export function createQueryPlanCompareTool( adapter: PostgresAdapter, ): ToolDefinition { // Base schema for MCP visibility (no preprocess) - const QueryPlanCompareSchemaBase = z.object({ - query1: z.string().optional().describe("First SQL query"), - query2: z.string().optional().describe("Second SQL query"), - sql1: z.string().optional().describe("Alias for query1"), - sql2: z.string().optional().describe("Alias for query2"), - sqlA: z.string().optional().describe("Alias for query1"), - sqlB: z.string().optional().describe("Alias for query2"), - params1: z - .array(z.unknown()) - .optional() - .describe("Parameters for first query ($1, $2, etc.)"), - params2: z - .array(z.unknown()) - .optional() - .describe("Parameters for second query ($1, $2, etc.)"), - analyze: z - .boolean() - .optional() - .describe("Run EXPLAIN ANALYZE (executes queries)"), - compact: z - .boolean() - .optional() - .describe("Omit full execution plans from output to save tokens"), - }); - - // Preprocess for sql1/sql2 β†’ query1/query2 aliases - const QueryPlanCompareSchema = z.preprocess((input) => { - if (typeof input !== "object" || input === null) return input; - const obj = input as Record; - const result = { ...obj }; - // Alias: sql1/sqlA β†’ query1, sql2/sqlB β†’ query2 - if (result["query1"] === undefined) { - if (result["sql1"] !== undefined) result["query1"] = result["sql1"]; - else if (result["sqlA"] !== undefined) result["query1"] = result["sqlA"]; - } - if (result["query2"] === undefined) { - if (result["sql2"] !== undefined) result["query2"] = result["sql2"]; - else if (result["sqlB"] !== undefined) result["query2"] = result["sqlB"]; - } - return result; - }, QueryPlanCompareSchemaBase); return { name: "pg_query_plan_compare", @@ -115,11 +77,15 @@ export function createQueryPlanCompareTool( const parsed = QueryPlanCompareSchema.parse(params); // Validate required parameters - if (!parsed.query1 || !parsed.query2) { + if ( + typeof parsed.query1 !== "string" || + !parsed.query1 || + typeof parsed.query2 !== "string" || + !parsed.query2 + ) { return { success: false as const, - error: - "Missing required parameters: both query1 and query2 are required", + error: "Validation error: both query1 and query2 are required", code: "VALIDATION_ERROR", category: "validation", recoverable: false, @@ -134,11 +100,11 @@ export function createQueryPlanCompareTool( const [result1, result2] = await Promise.all([ adapter.executeQuery( `${explainType} ${parsed.query1}`, - parsed.params1 ?? [], + Array.isArray(parsed.params1) ? parsed.params1 : [], ), adapter.executeQuery( `${explainType} ${parsed.query2}`, - parsed.params2 ?? [], + Array.isArray(parsed.params2) ? parsed.params2 : [], ), ]); @@ -180,7 +146,7 @@ export function createQueryPlanCompareTool( : null, recommendation: "", }, - ...(parsed.compact + ...(parsed.compact === true ? {} : { fullPlans: { diff --git a/src/adapters/postgresql/tools/performance/connection-analysis.ts b/src/adapters/postgresql/tools/performance/connection-analysis.ts index 900fe48b..07f8a5fa 100644 --- a/src/adapters/postgresql/tools/performance/connection-analysis.ts +++ b/src/adapters/postgresql/tools/performance/connection-analysis.ts @@ -13,41 +13,20 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; -import { DetectConnectionSpikeOutputSchema } from "../../schemas/performance.js"; +import { + DetectConnectionSpikeOutputSchema, + ConnectionSpikeInputBase, + ConnectionSpikeInput, +} from "../../schemas/performance.js"; import { toNum, toStr, riskFromScore } from "./anomaly-detection.js"; // ============================================================================= // pg_detect_connection_spike // ============================================================================= -const coerceNumber = (val: unknown): unknown => - typeof val === "string" - ? isNaN(Number(val)) - ? undefined - : Number(val) - : val; - -const ConnectionSpikeInputBase = z.object({ - warningPercent: z - .unknown() - .optional() - .describe("Percentage threshold for flagging concentration (default: 70)"), -}); - -const ConnectionSpikeInput = z.preprocess( - (data: unknown) => { - if (typeof data !== "object" || data === null) return {}; - return data; - }, - z.object({ - warningPercent: z.preprocess(coerceNumber, z.number().optional()), - }), -); - interface ConnectionConcentration { dimension: string; value: string; @@ -80,8 +59,16 @@ export function createDetectConnectionSpikeTool( }; } - const rawPercent = parsed.data.warningPercent ?? 70; - const warningPercent = Math.max(10, Math.min(100, rawPercent)); + const warningPercent = parsed.data.warningPercent ?? 70; + + if (warningPercent < 10 || warningPercent > 100) { + return { + success: false, + error: + "Validation error: warningPercent must be between 10 and 100", + code: "VALIDATION_ERROR", + }; + } // Gather connection data in parallel const [stateResult, userResult, appResult, maxResult, idleTxResult] = diff --git a/src/adapters/postgresql/tools/performance/helpers.ts b/src/adapters/postgresql/tools/performance/helpers.ts index 2b07c66c..d6252e30 100644 --- a/src/adapters/postgresql/tools/performance/helpers.ts +++ b/src/adapters/postgresql/tools/performance/helpers.ts @@ -49,9 +49,7 @@ export async function validatePerformanceTableExists( [schema], ); if (!schemaResult.rows || schemaResult.rows.length === 0) { - throw new ValidationError( - `Schema '${schema}' does not exist. Use pg_list_objects with type 'table' to see available schemas.`, - ); + throw new ValidationError(`Schema "${schema}" does not exist`); } } @@ -64,7 +62,7 @@ export async function validatePerformanceTableExists( ); if (!tableResult.rows || tableResult.rows.length === 0) { throw new ValidationError( - `Table '${targetSchema}.${table}' not found. Use pg_list_tables to see available tables.`, + `Table "${targetSchema}.${table}" does not exist`, ); } } diff --git a/src/adapters/postgresql/tools/performance/index-analysis.ts b/src/adapters/postgresql/tools/performance/index-analysis.ts index 5a2d3715..06516389 100644 --- a/src/adapters/postgresql/tools/performance/index-analysis.ts +++ b/src/adapters/postgresql/tools/performance/index-analysis.ts @@ -9,53 +9,22 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; import { UnusedIndexesOutputSchema, DuplicateIndexesOutputSchema, + UnusedIndexesSchemaBase, + UnusedIndexesSchema, + DuplicateIndexesSchemaBase, + DuplicateIndexesSchema, } from "../../schemas/index.js"; -import { - defaultToEmpty, - toNum, - coerceNumber, - validatePerformanceTableExists, -} from "./helpers.js"; +import { toNum, validatePerformanceTableExists } from "./helpers.js"; export function createUnusedIndexesTool( adapter: PostgresAdapter, ): ToolDefinition { - const UnusedIndexesSchemaBase = z.object({ - schema: z - .string() - .optional() - .describe("Schema to filter (default: all user schemas)"), - minSize: z - .string() - .optional() - .describe('Minimum index size to include (e.g., "1 MB")'), - limit: z - .number() - .optional() - .describe("Max indexes to return (default: 20, use 0 for all)"), - summary: z - .boolean() - .optional() - .describe("Return aggregated summary instead of full list"), - }); - - const UnusedIndexesSchema = z.preprocess( - defaultToEmpty, - z.object({ - schema: z.string().optional(), - minSize: z.string().optional(), - limit: z.preprocess(coerceNumber, z.number().optional()), - summary: z.boolean().optional(), - }), - ); - return { name: "pg_unused_indexes", description: @@ -177,25 +146,6 @@ export function createUnusedIndexesTool( export function createDuplicateIndexesTool( adapter: PostgresAdapter, ): ToolDefinition { - const DuplicateIndexesSchemaBase = z.object({ - schema: z - .string() - .optional() - .describe("Schema to filter (default: all user schemas)"), - limit: z - .number() - .optional() - .describe("Max rows to return (default: 50, use 0 for all)"), - }); - - const DuplicateIndexesSchema = z.preprocess( - defaultToEmpty, - z.object({ - schema: z.string().optional(), - limit: z.preprocess(coerceNumber, z.number().optional()), - }), - ); - return { name: "pg_duplicate_indexes", description: diff --git a/src/adapters/postgresql/tools/performance/monitoring.ts b/src/adapters/postgresql/tools/performance/monitoring.ts index b0a2cbee..8bd3b1e1 100644 --- a/src/adapters/postgresql/tools/performance/monitoring.ts +++ b/src/adapters/postgresql/tools/performance/monitoring.ts @@ -121,6 +121,10 @@ export function createBloatCheckTool(adapter: PostgresAdapter): ToolDefinition { // P154: Validate table/schema existence before querying (throws ValidationError on failure) await validatePerformanceTableExists(adapter, tableName, schemaName); + const rawLimit = parsed.limit; + const limit = + rawLimit === undefined ? 20 : rawLimit === 0 ? null : rawLimit; + const sql = `SELECT schemaname, relname as table_name, n_live_tup as live_tuples, n_dead_tup as dead_tuples, CASE WHEN n_live_tup > 0 THEN round((100.0 * n_dead_tup / n_live_tup)::numeric, 2) ELSE 0 END as dead_pct, @@ -128,7 +132,7 @@ export function createBloatCheckTool(adapter: PostgresAdapter): ToolDefinition { FROM pg_stat_user_tables WHERE ${whereClause} ORDER BY n_dead_tup DESC - LIMIT 20`; + ${limit !== null ? `LIMIT ${String(limit)}` : ""}`; const result = await adapter.executeQuery(sql, queryParams); // Coerce numeric fields to JavaScript numbers @@ -140,11 +144,19 @@ export function createBloatCheckTool(adapter: PostgresAdapter): ToolDefinition { dead_pct: toNum(row["dead_pct"]), }), ); - return { + const response: Record = { success: true as const, tables, count: tables.length, }; + // Add totalCount if results were limited + if (limit !== null && tables.length === limit) { + const countSql = `SELECT COUNT(*) as total FROM pg_stat_user_tables WHERE ${whereClause}`; + const countResult = await adapter.executeQuery(countSql, queryParams); + response["totalCount"] = toNum(countResult.rows?.[0]?.["total"]); + response["truncated"] = true; + } + return response; } catch (error: unknown) { return formatHandlerErrorResponse(error, { tool: "pg_bloat_check" }); } diff --git a/src/adapters/postgresql/tools/performance/optimization.ts b/src/adapters/postgresql/tools/performance/optimization.ts index 7f95c73c..b30d33c7 100644 --- a/src/adapters/postgresql/tools/performance/optimization.ts +++ b/src/adapters/postgresql/tools/performance/optimization.ts @@ -7,7 +7,6 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; @@ -16,40 +15,18 @@ import { validatePerformanceTableExists } from "./helpers.js"; import { PerformanceBaselineOutputSchema, + PerformanceBaselineSchemaBase, + PerformanceBaselineSchema, ConnectionPoolOptimizeOutputSchema, + ConnectionPoolOptimizeInputSchemaBase, PartitionStrategySuggestOutputSchema, + PartitionStrategySchemaBase, + PartitionStrategySchema, } from "../../schemas/index.js"; -// Helper to handle undefined params (allows tools to be called without {}) -const defaultToEmpty = (val: unknown): unknown => val ?? {}; - -// Preprocess partition strategy params with tableName/name aliases -function preprocessPartitionStrategyParams(input: unknown): unknown { - const normalized = defaultToEmpty(input) as Record; - const result = { ...normalized }; - // Alias: tableName/name β†’ table - if (result["table"] === undefined) { - if (result["tableName"] !== undefined) - result["table"] = result["tableName"]; - else if (result["name"] !== undefined) result["table"] = result["name"]; - } - return result; -} - export function createPerformanceBaselineTool( adapter: PostgresAdapter, ): ToolDefinition { - // Base schema for MCP visibility (no preprocess) - const PerformanceBaselineSchemaBase = z.object({ - name: z.string().optional().describe("Baseline name for reference"), - }); - - // Full schema with defaultToEmpty preprocessing for handler-side parsing - const PerformanceBaselineSchema = z.preprocess( - defaultToEmpty, - PerformanceBaselineSchemaBase, - ); - return { name: "pg_performance_baseline", description: @@ -63,7 +40,9 @@ export function createPerformanceBaselineTool( try { const parsed = PerformanceBaselineSchema.parse(params); const baselineName = - parsed.name ?? `baseline_${new Date().toISOString()}`; + typeof parsed.name === "string" + ? parsed.name + : `baseline_${new Date().toISOString()}`; const [cacheHit, tableStats, indexStats, connections, dbSize] = await Promise.all([ @@ -145,7 +124,7 @@ export function createConnectionPoolOptimizeTool( description: "Analyze connection usage and provide pool optimization recommendations.", group: "performance", - inputSchema: z.object({}).strict(), + inputSchema: ConnectionPoolOptimizeInputSchemaBase, outputSchema: ConnectionPoolOptimizeOutputSchema, annotations: readOnly("Connection Pool Optimize"), icons: getToolIcons("performance", readOnly("Connection Pool Optimize")), @@ -265,18 +244,6 @@ export function createConnectionPoolOptimizeTool( export function createPartitionStrategySuggestTool( adapter: PostgresAdapter, ): ToolDefinition { - // Base schema for MCP visibility (no preprocess) - const PartitionStrategySchemaBase = z.object({ - table: z.string().optional().describe("Table to analyze"), - schema: z.string().optional().describe("Schema name"), - }); - - // Full schema with preprocessing for aliases - const PartitionStrategySchema = z.preprocess( - preprocessPartitionStrategyParams, - PartitionStrategySchemaBase, - ); - return { name: "pg_partition_strategy_suggest", description: "Analyze a table and suggest optimal partitioning strategy.", @@ -290,10 +257,10 @@ export function createPartitionStrategySuggestTool( const parsed = PartitionStrategySchema.parse(params); // Validate required parameter - if (!parsed.table) { + if (typeof parsed.table !== "string" || !parsed.table) { return { success: false as const, - error: "Missing required parameter: table is required", + error: "Validation error: table is required", code: "VALIDATION_ERROR", category: "validation", recoverable: false, @@ -301,7 +268,8 @@ export function createPartitionStrategySuggestTool( } // Parse schema from table if it contains a dot (e.g., 'public.users') - let schemaName = parsed.schema ?? "public"; + let schemaName = + typeof parsed.schema === "string" ? parsed.schema : "public"; let tableName = parsed.table; if (tableName.includes(".")) { const parts = tableName.split("."); diff --git a/src/adapters/postgresql/tools/pgcrypto.ts b/src/adapters/postgresql/tools/pgcrypto.ts index 51e549a3..026afb2c 100644 --- a/src/adapters/postgresql/tools/pgcrypto.ts +++ b/src/adapters/postgresql/tools/pgcrypto.ts @@ -70,6 +70,15 @@ function createPgcryptoExtensionTool(adapter: PostgresAdapter): ToolDefinition { handler: async (params: unknown, _context: RequestContext) => { try { const { schema } = PgcryptoCreateExtensionSchema.parse(params); + if (schema) { + const checkResult = await adapter.executeQuery( + `SELECT 1 FROM information_schema.schemata WHERE schema_name = $1`, + [schema], + ); + if (checkResult.rows?.length === 0) { + throw new ValidationError(`Schema "${schema}" does not exist`); + } + } const schemaClause = schema ? ` SCHEMA ${schema}` : ""; await adapter.executeQuery( `CREATE EXTENSION IF NOT EXISTS pgcrypto${schemaClause}`, @@ -275,11 +284,14 @@ function createPgcryptoGenRandomBytesTool( try { const { length, encoding } = PgcryptoRandomBytesSchema.parse(params); const enc = encoding ?? "hex"; - const encodeFormat = enc === "base64" ? "base64" : "hex"; + + const encodeFormat = + enc === "base64" ? "base64" : enc === "raw" ? "escape" : "hex"; const result = await adapter.executeQuery( `SELECT encode(gen_random_bytes($1), $2) as random_bytes`, [length, encodeFormat], ); + return { success: true, randomBytes: result.rows?.[0]?.["random_bytes"] as string, @@ -336,6 +348,10 @@ function createPgcryptoCryptTool(adapter: PostgresAdapter): ToolDefinition { handler: async (params: unknown, _context: RequestContext) => { try { const { password, salt } = PgcryptoCryptSchema.parse(params); + if (typeof salt !== "string") { + throw new ValidationError("Salt is required"); + } + const result = await adapter.executeQuery( `SELECT crypt($1, $2) as hash`, [password, salt], @@ -373,8 +389,45 @@ function handlePgcryptoError(error: unknown, toolName: string): ErrorResponse { { tool: toolName }, ); } + if ( + msg.includes("does not exist") && + (msg.includes("function digest") || + msg.includes("function hmac") || + msg.includes("function pgp_") || + msg.includes("function gen_random_") || + msg.includes("function gen_salt") || + msg.includes("function crypt")) + ) { + return formatHandlerErrorResponse( + new ValidationError( + "EXTENSION_MISSING: pgcrypto extension is not installed or available in the search path", + ), + { tool: toolName }, + ); + } + if (msg.includes("invalid symbol") || msg.includes("invalid base64")) { + return formatHandlerErrorResponse( + new ValidationError( + "Decryption failed: Invalid base64 encoding in encrypted data", + ), + { tool: toolName }, + ); + } + if ( + msg.includes("Illegal argument to function") && + toolName.includes("encrypt") + ) { + return formatHandlerErrorResponse( + new ValidationError("Encryption failed: Password must not be empty"), + { tool: toolName }, + ); + } + if (msg.includes("Wrong key or corrupt data")) { + return formatHandlerErrorResponse( + new ValidationError("Decryption failed: Wrong key or corrupt data"), + { tool: toolName }, + ); + } } - // Let formatHandlerErrorResponse handle DECRYPTION_FAILED and INVALID_BASE64 - // using the P154 error-suggestions.ts mappings. return formatHandlerErrorResponse(error, { tool: toolName }); } diff --git a/src/adapters/postgresql/tools/postgis/__tests__/postgis.test.ts b/src/adapters/postgresql/tools/postgis/__tests__/postgis.test.ts index 83788abe..dca65d9a 100644 --- a/src/adapters/postgresql/tools/postgis/__tests__/postgis.test.ts +++ b/src/adapters/postgresql/tools/postgis/__tests__/postgis.test.ts @@ -872,8 +872,9 @@ describe("PostGIS Advanced Tool Edge Cases", () => { }); it("pg_geo_index_optimize should warn when table filter matches nothing", async () => { - // Both queries return empty rows + // Both queries return empty rows, and existence check returns empty mockAdapter.executeQuery + .mockResolvedValueOnce({ rows: [] }) .mockResolvedValueOnce({ rows: [] }) .mockResolvedValueOnce({ rows: [] }); @@ -884,7 +885,7 @@ describe("PostGIS Advanced Tool Edge Cases", () => { )) as Record; expect(result["success"]).toBe(false); - expect(result["error"]).toContain("not found"); + expect(result["error"]).toContain("does not exist"); }); it("pg_geo_index_optimize should recommend GiST for large tables without spatial indexes", async () => { diff --git a/src/adapters/postgresql/tools/postgis/advanced.ts b/src/adapters/postgresql/tools/postgis/advanced.ts index 90aead53..96e39017 100644 --- a/src/adapters/postgresql/tools/postgis/advanced.ts +++ b/src/adapters/postgresql/tools/postgis/advanced.ts @@ -108,7 +108,7 @@ export function createGeoTransformTool( if ((tableCheckResult.rows?.length ?? 0) === 0) { return { success: false as const, - error: `Table "${parsed.table}" does not exist in schema "${schemaName}". Use pg_list_tables to see available tables.`, + error: `Table "${schemaName}.${parsed.table}" does not exist. Use pg_list_tables to see available tables.`, code: "TABLE_NOT_FOUND", category: "resource", suggestion: "Use pg_list_tables to see available tables.", @@ -179,7 +179,7 @@ export function createGeoTransformTool( // Build response with truncation indicators if default limit was applied const response: Record = { success: true, - results: result.rows, + rows: result.rows, count: result.rows?.length ?? 0, fromSrid: fromSrid, toSrid: parsed.toSrid, diff --git a/src/adapters/postgresql/tools/postgis/query.ts b/src/adapters/postgresql/tools/postgis/query.ts index 734c5d71..b1b3f83e 100644 --- a/src/adapters/postgresql/tools/postgis/query.ts +++ b/src/adapters/postgresql/tools/postgis/query.ts @@ -45,7 +45,7 @@ export function createPointInPolygonTool( return { name: "pg_point_in_polygon", description: - "Check if a point is within any polygon in a table. The geometry column should contain POLYGON or MULTIPOLYGON geometries.", + "Check if a point is within any polygon in a table. The geometry column should contain POLYGON or MULTIPOLYGON geometries. Default limit: 10 rows.", group: "postgis", inputSchema: PointInPolygonSchemaBase, // Base schema for MCP visibility outputSchema: PointInPolygonOutputSchema, @@ -53,9 +53,8 @@ export function createPointInPolygonTool( icons: getToolIcons("postgis", readOnly("Point in Polygon")), handler: async (params: unknown, _context: RequestContext) => { try { - const { table, column, point, schema } = PointInPolygonSchema.parse( - params ?? {}, - ); + const { table, column, point, limit, schema } = + PointInPolygonSchema.parse(params ?? {}); const schemaName = schema ?? "public"; const tableName = sanitizeTableName( table, @@ -93,9 +92,14 @@ export function createPointInPolygonTool( ? `${nonGeomCols}, ST_AsText(${columnName}) as geometry_text` : `ST_AsText(${columnName}) as geometry_text`; + const effectiveLimit = limit ?? 10; + const limitClause = + effectiveLimit > 0 ? ` LIMIT ${String(effectiveLimit)}` : ""; + const sql = `SELECT ${selectCols} FROM ${tableName} - WHERE ST_Contains(${columnName}, ST_SetSRID(ST_MakePoint($1, $2), 4326))`; + WHERE ST_Contains(${columnName}, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + ${limitClause}`; const result = await adapter.executeQuery(sql, [point.lng, point.lat]); @@ -105,6 +109,20 @@ export function createPointInPolygonTool( count: result.rows?.length ?? 0, }; + if (effectiveLimit > 0) { + const countSql = `SELECT COUNT(*) as cnt FROM ${tableName} WHERE ST_Contains(${columnName}, ST_SetSRID(ST_MakePoint($1, $2), 4326))`; + const countResult = await adapter.executeQuery(countSql, [ + point.lng, + point.lat, + ]); + const totalCount = Number(countResult.rows?.[0]?.["cnt"] ?? 0); + if (totalCount > effectiveLimit) { + response["truncated"] = true; + response["totalCount"] = totalCount; + response["limit"] = effectiveLimit; + } + } + // Add warning if geometry type is not polygon if (!isPolygonType && geomType !== undefined) { response["warning"] = @@ -228,7 +246,7 @@ export function createDistanceTool(adapter: PostgresAdapter): ToolDefinition { const result = await adapter.executeQuery(sql, [point.lng, point.lat]); return { success: true, - results: result.rows, + rows: result.rows, count: result.rows?.length ?? 0, }; } catch (error: unknown) { @@ -311,7 +329,7 @@ export function createBufferTool(adapter: PostgresAdapter): ToolDefinition { // Build response with truncation indicators if default limit was applied const response: Record = { success: true, - results: result.rows, + rows: result.rows, }; // Check if results were truncated (works for both default and explicit limits) @@ -352,7 +370,7 @@ export function createIntersectionTool( return { name: "pg_intersection", description: - "Find geometries that intersect with a given geometry. Auto-detects SRID from target column if not specified.", + "Find geometries that intersect with a given geometry. Auto-detects SRID from target column if not specified. Default limit: 10 rows.", group: "postgis", inputSchema: IntersectionSchemaBase, // Base schema for MCP visibility outputSchema: IntersectionOutputSchema, @@ -429,10 +447,9 @@ export function createIntersectionTool( geomExpr = `ST_GeomFromText($1)`; } + const effectiveLimit = parsed.limit ?? 10; const limitClause = - parsed.limit !== undefined && parsed.limit > 0 - ? ` LIMIT ${String(parsed.limit)}` - : ""; + effectiveLimit > 0 ? ` LIMIT ${String(effectiveLimit)}` : ""; const sql = `SELECT ${selectCols} FROM ${qualifiedTable} @@ -440,12 +457,26 @@ export function createIntersectionTool( ${limitClause}`; const result = await adapter.executeQuery(sql, [parsed.geometry]); - return { + const response: Record = { success: true, intersecting: result.rows, count: result.rows?.length ?? 0, sridUsed: srid ?? "none (explicit SRID in geometry or GeoJSON)", }; + + if (effectiveLimit > 0) { + const countSql = `SELECT COUNT(*) as cnt FROM ${qualifiedTable} WHERE ST_Intersects(${columnName}, ${geomExpr})`; + const countResult = await adapter.executeQuery(countSql, [ + parsed.geometry, + ]); + const totalCount = Number(countResult.rows?.[0]?.["cnt"] ?? 0); + if (totalCount > effectiveLimit) { + response["truncated"] = true; + response["totalCount"] = totalCount; + response["limit"] = effectiveLimit; + } + } + return response; } catch (error: unknown) { return formatHandlerErrorResponse(error, { tool: "pg_intersection", @@ -464,7 +495,7 @@ export function createBoundingBoxTool( return { name: "pg_bounding_box", description: - "Find geometries within a bounding box. Swapped min/max values are auto-corrected.", + "Find geometries within a bounding box. Swapped min/max values are auto-corrected. Default limit: 10 rows.", group: "postgis", inputSchema: BoundingBoxSchemaBase, // Base schema for MCP visibility outputSchema: BoundingBoxOutputSchema, @@ -528,10 +559,9 @@ export function createBoundingBoxTool( corrections.push("minLat/maxLat were swapped"); } + const effectiveLimit = parsed.limit ?? 10; const limitClause = - parsed.limit !== undefined && parsed.limit > 0 - ? ` LIMIT ${String(parsed.limit)}` - : ""; + effectiveLimit > 0 ? ` LIMIT ${String(effectiveLimit)}` : ""; const sql = `SELECT ${selectCols}, ST_AsText(${columnName}) as geometry_text FROM ${qualifiedTable} @@ -547,10 +577,26 @@ export function createBoundingBoxTool( const response: Record = { success: true, - results: result.rows, + rows: result.rows, count: result.rows?.length ?? 0, }; + if (effectiveLimit > 0) { + const countSql = `SELECT COUNT(*) as cnt FROM ${qualifiedTable} WHERE ${columnName} && ST_MakeEnvelope($1, $2, $3, $4, 4326)`; + const countResult = await adapter.executeQuery(countSql, [ + actualMinLng, + actualMinLat, + actualMaxLng, + actualMaxLat, + ]); + const totalCount = Number(countResult.rows?.[0]?.["cnt"] ?? 0); + if (totalCount > effectiveLimit) { + response["truncated"] = true; + response["totalCount"] = totalCount; + response["limit"] = effectiveLimit; + } + } + if (corrections.length > 0) { response["note"] = `Auto-corrected: ${corrections.join(", ")}`; } diff --git a/src/adapters/postgresql/tools/postgis/spatial-analysis.ts b/src/adapters/postgresql/tools/postgis/spatial-analysis.ts index ab2b4c35..aad2648f 100644 --- a/src/adapters/postgresql/tools/postgis/spatial-analysis.ts +++ b/src/adapters/postgresql/tools/postgis/spatial-analysis.ts @@ -138,13 +138,28 @@ export function createGeoIndexOptimizeTool( (indexes.rows?.length ?? 0) === 0 && (tableStats.rows?.length ?? 0) === 0 ) { + // Check if table exists + const tableCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2`, + [schemaName, parsed.table], + ); + if ((tableCheck.rows?.length ?? 0) === 0) { + return { + success: false, + error: `Table "${schemaName}.${parsed.table}" does not exist.`, + code: "TABLE_NOT_FOUND", + category: "query", + recoverable: false, + suggestion: `Use pg_list_tables to see available tables.`, + }; + } return { success: false, - error: `Table "${parsed.table}" not found in schema "${schemaName}" or has no spatial columns/indexes.`, - code: "TABLE_NOT_FOUND", + error: `Table "${schemaName}.${parsed.table}" has no spatial columns.`, + code: "COLUMN_NOT_FOUND", category: "query", recoverable: false, - suggestion: `Use pg_geo_index_optimize without a table filter to see all spatial tables in schema "${schemaName}".`, + suggestion: `Use pg_geometry_column to add a spatial column.`, }; } @@ -210,10 +225,9 @@ export function createGeoClusterTool(adapter: PostgresAdapter): ToolDefinition { parsed.where !== undefined ? `WHERE ${sanitizeWhereClause(parsed.where)}` : ""; + const effectiveLimit = parsed.limit ?? 50; const limitClause = - parsed.limit !== undefined && parsed.limit > 0 - ? `LIMIT ${String(parsed.limit)}` - : ""; + effectiveLimit > 0 ? `LIMIT ${String(effectiveLimit)}` : ""; // Track warning if K > N let warning: string | undefined; @@ -331,6 +345,15 @@ export function createGeoClusterTool(adapter: PostgresAdapter): ToolDefinition { clusters: normalizedClusters, }; + if ( + effectiveLimit > 0 && + normalizedSummary.num_clusters > effectiveLimit + ) { + response["truncated"] = true; + response["limit"] = effectiveLimit; + response["totalClusters"] = normalizedSummary.num_clusters; + } + // Add warning if K was clamped if (warning !== undefined) { response["warning"] = warning; diff --git a/src/adapters/postgresql/tools/roles/index.ts b/src/adapters/postgresql/tools/roles/index.ts new file mode 100644 index 00000000..ff0e5622 --- /dev/null +++ b/src/adapters/postgresql/tools/roles/index.ts @@ -0,0 +1,68 @@ +/** + * PostgreSQL Role Management Tools + * + * Tools for role CRUD, privilege management, membership, + * session role switching, and row-level security. + * 12 tools total. + */ + +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { ToolDefinition } from "../../../../types/index.js"; + +// Import from submodules +import { + createRoleListTool, + createRoleCreateTool, + createRoleDropTool, + createRoleAttributesTool, +} from "./management.js"; + +import { + createRoleGrantsTool, + createRoleGrantTool, + createRoleAssignTool, + createRoleRevokeTool, +} from "./privileges.js"; + +import { + createUserRolesTool, + createRoleSetTool, + createRoleRlsEnableTool, + createRoleRlsPoliciesTool, +} from "./session.js"; + +/** + * Get all role management tools + */ +export function getRoleTools(adapter: PostgresAdapter): ToolDefinition[] { + return [ + createRoleListTool(adapter), + createRoleCreateTool(adapter), + createRoleDropTool(adapter), + createRoleAttributesTool(adapter), + createRoleGrantsTool(adapter), + createRoleGrantTool(adapter), + createRoleAssignTool(adapter), + createRoleRevokeTool(adapter), + createUserRolesTool(adapter), + createRoleSetTool(adapter), + createRoleRlsEnableTool(adapter), + createRoleRlsPoliciesTool(adapter), + ]; +} + +// Re-export individual tool creators for direct imports +export { + createRoleListTool, + createRoleCreateTool, + createRoleDropTool, + createRoleAttributesTool, + createRoleGrantsTool, + createRoleGrantTool, + createRoleAssignTool, + createRoleRevokeTool, + createUserRolesTool, + createRoleSetTool, + createRoleRlsEnableTool, + createRoleRlsPoliciesTool, +}; diff --git a/src/adapters/postgresql/tools/roles/management.ts b/src/adapters/postgresql/tools/roles/management.ts new file mode 100644 index 00000000..ec4b12db --- /dev/null +++ b/src/adapters/postgresql/tools/roles/management.ts @@ -0,0 +1,431 @@ +/** + * PostgreSQL Role Management - CRUD Tools + * + * Tools for listing, creating, dropping, and inspecting roles. + * 4 tools total. + */ + +import { ZodError } from "zod"; +import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import { QueryError, ValidationError } from "../../../../types/errors.js"; +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { + ToolDefinition, + RequestContext, +} from "../../../../types/index.js"; +import { readOnly, admin, destructive } from "../../../../utils/annotations.js"; +import { getToolIcons } from "../../../../utils/icons.js"; +import { + RoleListSchemaBase, + RoleListSchema, + RoleCreateSchemaBase, + RoleCreateSchema, + RoleDropSchemaBase, + RoleDropSchema, + RoleAttributesSchemaBase, + RoleAttributesSchema, + // Output schemas + RoleListOutputSchema, + RoleCreateOutputSchema, + RoleDropOutputSchema, + RoleAttributesOutputSchema, +} from "../../schemas/index.js"; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Validate a SQL identifier to prevent injection */ +function validateIdentifier(name: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(name); +} + +/** Check if a role exists via pg_roles */ +async function roleExists( + adapter: PostgresAdapter, + roleName: string, +): Promise { + const result = await adapter.executeQuery( + `SELECT 1 FROM pg_roles WHERE rolname = $1`, + [roleName], + ); + return (result.rows?.length ?? 0) > 0; +} + +// ============================================================================= +// pg_role_list +// ============================================================================= + +/** + * List all roles with optional pattern filter + */ +export function createRoleListTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_role_list", + description: + "List PostgreSQL roles with attributes (login, superuser, createdb, etc.) and optional name filtering.", + group: "roles", + inputSchema: RoleListSchemaBase.partial(), + outputSchema: RoleListOutputSchema, + annotations: readOnly("List Roles"), + icons: getToolIcons("roles", readOnly("List Roles")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleListSchema.parse(params) as { + pattern?: string; + includeSystem?: boolean; + limit?: number; + }; + const includeSystem = parsed.includeSystem ?? false; + const limit = parsed.limit ?? 50; + + let query = ` + SELECT + rolname AS name, + rolcanlogin AS login, + rolsuper AS superuser, + rolcreatedb AS createdb, + rolcreaterole AS createrole, + rolreplication AS replication, + rolbypassrls AS bypassrls, + rolconnlimit AS "connectionLimit", + rolvaliduntil AS "validUntil" + FROM pg_roles + `; + + const conditions: string[] = []; + const queryParams: string[] = []; + + if (!includeSystem) { + conditions.push(`rolname NOT LIKE 'pg_%'`); + } + + if (parsed.pattern) { + queryParams.push(parsed.pattern); + conditions.push(`rolname LIKE $${String(queryParams.length)}`); + } + + if (conditions.length > 0) { + query += " WHERE " + conditions.join(" AND "); + } + + query += " ORDER BY rolname"; + query += ` LIMIT ${String(limit)}`; + + const result = await adapter.executeQuery(query, queryParams); + const roles = (result.rows ?? []).map( + (row: Record) => ({ + name: row["name"] as string, + login: row["login"] as boolean, + superuser: row["superuser"] as boolean, + createdb: row["createdb"] as boolean, + createrole: row["createrole"] as boolean, + replication: row["replication"] as boolean, + bypassrls: row["bypassrls"] as boolean, + connectionLimit: Number(row["connectionLimit"] ?? -1), + validUntil: + row["validUntil"] != null + ? new Date(row["validUntil"] as string | Date).toISOString() + : null, + }), + ); + + return { + success: true, + roles, + count: roles.length, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_role_list" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_role_list" }); + } + }, + }; +} + +// ============================================================================= +// pg_role_create +// ============================================================================= + +/** + * Create a new PostgreSQL role with optional attributes + */ +export function createRoleCreateTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_role_create", + description: + "Create a new PostgreSQL role with optional attributes (LOGIN, PASSWORD, SUPERUSER, CREATEDB, CREATEROLE, REPLICATION, BYPASSRLS, CONNECTION LIMIT, VALID UNTIL).", + group: "roles", + inputSchema: RoleCreateSchemaBase.partial(), + outputSchema: RoleCreateOutputSchema, + annotations: admin("Create Role"), + icons: getToolIcons("roles", admin("Create Role")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleCreateSchema.parse(params) as { + name: string; + ifNotExists?: boolean; + login?: boolean; + password?: string; + superuser?: boolean; + createdb?: boolean; + createrole?: boolean; + replication?: boolean; + bypassrls?: boolean; + connectionLimit?: number; + validUntil?: string; + inRoles?: string[]; + }; + + const ifNotExists = parsed.ifNotExists ?? true; + + if (!validateIdentifier(parsed.name)) { + return formatHandlerErrorResponse( + new ValidationError( + `Invalid role name: '${parsed.name}' β€” must start with a letter or underscore and contain only alphanumeric characters, underscores, or dollar signs`, + ), + { tool: "pg_role_create" }, + ); + } + + // P154: Check existence first + const exists = await roleExists(adapter, parsed.name); + if (exists) { + if (ifNotExists) { + return { + success: true, + name: parsed.name, + skipped: true, + reason: "Role already exists", + }; + } + return formatHandlerErrorResponse( + new QueryError(`Role '${parsed.name}' already exists`), + { tool: "pg_role_create" }, + ); + } + + // Build CREATE ROLE statement + const attributes: string[] = []; + + if (parsed.login === true) attributes.push("LOGIN"); + if (parsed.login === false) attributes.push("NOLOGIN"); + if (parsed.superuser === true) attributes.push("SUPERUSER"); + if (parsed.superuser === false) attributes.push("NOSUPERUSER"); + if (parsed.createdb === true) attributes.push("CREATEDB"); + if (parsed.createdb === false) attributes.push("NOCREATEDB"); + if (parsed.createrole === true) attributes.push("CREATEROLE"); + if (parsed.createrole === false) attributes.push("NOCREATEROLE"); + if (parsed.replication === true) attributes.push("REPLICATION"); + if (parsed.replication === false) attributes.push("NOREPLICATION"); + if (parsed.bypassrls === true) attributes.push("BYPASSRLS"); + if (parsed.bypassrls === false) attributes.push("NOBYPASSRLS"); + + if (parsed.connectionLimit !== undefined) { + attributes.push(`CONNECTION LIMIT ${String(parsed.connectionLimit)}`); + } + + if (parsed.validUntil) { + // Use parameterized query for the timestamp value + attributes.push( + `VALID UNTIL '${parsed.validUntil.replace(/'/g, "''")}'`, + ); + } + + if (parsed.password) { + attributes.push(`PASSWORD '${parsed.password.replace(/'/g, "''")}'`); + } + + // Validate inRoles identifiers + if (parsed.inRoles) { + for (const roleName of parsed.inRoles) { + if (!validateIdentifier(roleName)) { + return formatHandlerErrorResponse( + new ValidationError( + `Invalid role name in inRoles: '${roleName}'`, + ), + { tool: "pg_role_create" }, + ); + } + } + attributes.push( + `IN ROLE ${parsed.inRoles.map((r) => `"${r}"`).join(", ")}`, + ); + } + + const attrClause = + attributes.length > 0 ? " " + attributes.join(" ") : ""; + await adapter.executeQuery(`CREATE ROLE "${parsed.name}"${attrClause}`); + + return { + success: true, + name: parsed.name, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_role_create" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_role_create" }); + } + }, + }; +} + +// ============================================================================= +// pg_role_drop +// ============================================================================= + +/** + * Drop a PostgreSQL role + */ +export function createRoleDropTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_role_drop", + description: + "Drop a PostgreSQL role. Use ifExists (default: true) to skip gracefully if the role does not exist.", + group: "roles", + inputSchema: RoleDropSchemaBase.partial(), + outputSchema: RoleDropOutputSchema, + annotations: destructive("Drop Role"), + icons: getToolIcons("roles", destructive("Drop Role")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleDropSchema.parse(params) as { + name: string; + ifExists?: boolean; + }; + + const ifExists = parsed.ifExists ?? true; + + if (!validateIdentifier(parsed.name)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid role name: '${parsed.name}'`), + { tool: "pg_role_drop" }, + ); + } + + // P154: Check existence first + const exists = await roleExists(adapter, parsed.name); + if (!exists) { + if (ifExists) { + return { + success: true, + name: parsed.name, + skipped: true, + reason: "Role did not exist", + }; + } + return formatHandlerErrorResponse( + new QueryError(`Role '${parsed.name}' does not exist`), + { tool: "pg_role_drop" }, + ); + } + + await adapter.executeQuery(`DROP ROLE "${parsed.name}"`); + + return { + success: true, + name: parsed.name, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_role_drop" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_role_drop" }); + } + }, + }; +} + +// ============================================================================= +// pg_role_attributes +// ============================================================================= + +/** + * Get detailed role attributes + */ +export function createRoleAttributesTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_role_attributes", + description: + "Get detailed attributes for a PostgreSQL role: login, superuser, createdb, createrole, replication, bypassrls, inherit, connection limit, expiration, and OID.", + group: "roles", + inputSchema: RoleAttributesSchemaBase.partial(), + outputSchema: RoleAttributesOutputSchema, + annotations: readOnly("Role Attributes"), + icons: getToolIcons("roles", readOnly("Role Attributes")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleAttributesSchema.parse(params); + + const result = await adapter.executeQuery( + `SELECT + rolname AS name, + rolcanlogin AS login, + rolsuper AS superuser, + rolcreatedb AS createdb, + rolcreaterole AS createrole, + rolreplication AS replication, + rolbypassrls AS bypassrls, + rolinherit AS inherit, + rolconnlimit AS "connectionLimit", + rolvaliduntil AS "validUntil", + oid + FROM pg_roles + WHERE rolname = $1`, + [parsed.role], + ); + + if ((result.rows?.length ?? 0) === 0) { + return formatHandlerErrorResponse( + new QueryError(`Role '${parsed.role}' does not exist`), + { tool: "pg_role_attributes" }, + ); + } + + const row = (result.rows ?? [])[0]; + + if (!row) { + return formatHandlerErrorResponse( + new QueryError(`Role '${parsed.role}' does not exist`), + { tool: "pg_role_attributes" }, + ); + } + + return { + success: true, + exists: true, + role: { + name: row["name"] as string, + login: row["login"] as boolean, + superuser: row["superuser"] as boolean, + createdb: row["createdb"] as boolean, + createrole: row["createrole"] as boolean, + replication: row["replication"] as boolean, + bypassrls: row["bypassrls"] as boolean, + inherit: row["inherit"] as boolean, + connectionLimit: Number(row["connectionLimit"] ?? -1), + validUntil: + row["validUntil"] != null + ? new Date(row["validUntil"] as string | Date).toISOString() + : null, + oid: Number(row["oid"]), + }, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_role_attributes", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_role_attributes", + }); + } + }, + }; +} diff --git a/src/adapters/postgresql/tools/roles/privileges.ts b/src/adapters/postgresql/tools/roles/privileges.ts new file mode 100644 index 00000000..0b21378f --- /dev/null +++ b/src/adapters/postgresql/tools/roles/privileges.ts @@ -0,0 +1,644 @@ +/** + * PostgreSQL Role Management - Privilege Tools + * + * Tools for granting/revoking privileges and role membership. + * 4 tools total. + */ + +import { ZodError } from "zod"; +import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import { QueryError, ValidationError } from "../../../../types/errors.js"; +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { + ToolDefinition, + RequestContext, +} from "../../../../types/index.js"; +import { readOnly, admin } from "../../../../utils/annotations.js"; +import { getToolIcons } from "../../../../utils/icons.js"; +import { + RoleGrantsSchemaBase, + RoleGrantsSchema, + RoleGrantSchemaBase, + RoleGrantSchema, + RoleAssignSchemaBase, + RoleAssignSchema, + RoleRevokeSchemaBase, + RoleRevokeSchema, + // Output schemas + RoleGrantsOutputSchema, + RoleGrantOutputSchema, + RoleAssignOutputSchema, + RoleRevokeOutputSchema, +} from "../../schemas/index.js"; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Validate a SQL identifier to prevent injection */ +function validateIdentifier(name: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(name); +} + +/** Check if a role exists via pg_roles */ +async function roleExists( + adapter: PostgresAdapter, + roleName: string, +): Promise { + const result = await adapter.executeQuery( + `SELECT 1 FROM pg_roles WHERE rolname = $1`, + [roleName], + ); + return (result.rows?.length ?? 0) > 0; +} + +/** Valid PostgreSQL privilege names */ +const VALID_PRIVILEGES = new Set([ + "SELECT", + "INSERT", + "UPDATE", + "DELETE", + "TRUNCATE", + "REFERENCES", + "TRIGGER", + "CREATE", + "CONNECT", + "TEMPORARY", + "TEMP", + "EXECUTE", + "USAGE", + "ALL", + "ALL PRIVILEGES", +]); + +/** Validate privilege names against the allowlist */ +function validatePrivileges( + privileges: string[], +): { valid: true } | { valid: false; invalid: string[] } { + const invalid = privileges.filter( + (p) => !VALID_PRIVILEGES.has(p.toUpperCase()), + ); + if (invalid.length > 0) { + return { valid: false, invalid }; + } + return { valid: true }; +} + +// ============================================================================= +// pg_role_grants +// ============================================================================= + +/** + * Show privileges granted to a role + */ +export function createRoleGrantsTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_role_grants", + description: + "Show privileges and memberships for a PostgreSQL role. Includes role attributes, membership in other roles, and optionally table-level grants.", + group: "roles", + inputSchema: RoleGrantsSchemaBase.partial(), + outputSchema: RoleGrantsOutputSchema, + annotations: readOnly("Role Grants"), + icons: getToolIcons("roles", readOnly("Role Grants")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleGrantsSchema.parse(params) as { + role: string; + includeTableGrants?: boolean; + }; + + const includeTableGrants = parsed.includeTableGrants ?? true; + + // P154: Check role existence + const exists = await roleExists(adapter, parsed.role); + if (!exists) { + return { + ...formatHandlerErrorResponse( + new QueryError(`Role '${parsed.role}' does not exist`), + { tool: "pg_role_grants" }, + ), + exists: false, + role: parsed.role, + }; + } + + // Get role memberships (roles this role is a member of) + const memberResult = await adapter.executeQuery( + `SELECT + r.rolname AS role, + m.admin_option AS "adminOption" + FROM pg_auth_members m + JOIN pg_roles r ON r.oid = m.roleid + JOIN pg_roles u ON u.oid = m.member + WHERE u.rolname = $1 + ORDER BY r.rolname`, + [parsed.role], + ); + + const memberOf = (memberResult.rows ?? []).map( + (row: Record) => ({ + role: row["role"] as string, + adminOption: row["adminOption"] as boolean, + }), + ); + + // Get table-level grants if requested + let tableGrants: Record[] | undefined; + if (includeTableGrants) { + const grantsResult = await adapter.executeQuery( + `SELECT + table_schema, + table_name, + privilege_type, + is_grantable + FROM information_schema.role_table_grants + WHERE grantee = $1 + ORDER BY table_schema, table_name, privilege_type`, + [parsed.role], + ); + tableGrants = grantsResult.rows ?? []; + } + + return { + success: true, + exists: true, + role: parsed.role, + memberOf, + tableGrants, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_role_grants" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_role_grants" }); + } + }, + }; +} + +// ============================================================================= +// pg_role_grant +// ============================================================================= + +/** + * Grant privileges on objects to a role + */ +export function createRoleGrantTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_role_grant", + description: + "Grant privileges (SELECT, INSERT, UPDATE, DELETE, ALL, etc.) on tables, schemas, sequences, or functions to a PostgreSQL role.", + group: "roles", + inputSchema: RoleGrantSchemaBase.partial(), + outputSchema: RoleGrantOutputSchema, + annotations: admin("Grant Privileges"), + icons: getToolIcons("roles", admin("Grant Privileges")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleGrantSchema.parse(params) as { + role: string; + privileges: string[]; + schema?: string; + table?: string; + objectType?: string; + withGrantOption?: boolean; + }; + + const schema = parsed.schema ?? "public"; + const withGrantOption = parsed.withGrantOption ?? false; + + if (!validateIdentifier(parsed.role)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid role name: '${parsed.role}'`), + { tool: "pg_role_grant" }, + ); + } + + // P154: Check role existence + const exists = await roleExists(adapter, parsed.role); + if (!exists) { + return { + ...formatHandlerErrorResponse( + new QueryError(`Role '${parsed.role}' does not exist`), + { tool: "pg_role_grant" }, + ), + exists: false, + role: parsed.role, + }; + } + + // Validate privileges + if (parsed.privileges.length === 0) { + return formatHandlerErrorResponse( + new ValidationError("At least one privilege must be specified"), + { tool: "pg_role_grant" }, + ); + } + + const privCheck = validatePrivileges(parsed.privileges); + if (!privCheck.valid) { + return formatHandlerErrorResponse( + new ValidationError( + `Invalid privilege(s): ${privCheck.invalid.join(", ")}. Valid: ${[...VALID_PRIVILEGES].join(", ")}`, + ), + { tool: "pg_role_grant" }, + ); + } + + const privList = parsed.privileges + .map((p) => p.toUpperCase()) + .join(", "); + + // Determine target + let target: string; + const objType = (parsed.objectType ?? "TABLE").toUpperCase(); + + if ( + objType === "ALL TABLES IN SCHEMA" || + (parsed.table === "*" && objType === "TABLE") + ) { + if (!validateIdentifier(schema)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid schema name: '${schema}'`), + { tool: "pg_role_grant" }, + ); + } + target = `ALL TABLES IN SCHEMA "${schema}"`; + } else if (objType === "ALL SEQUENCES IN SCHEMA") { + if (!validateIdentifier(schema)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid schema name: '${schema}'`), + { tool: "pg_role_grant" }, + ); + } + target = `ALL SEQUENCES IN SCHEMA "${schema}"`; + } else if (objType === "SCHEMA") { + if (!validateIdentifier(schema)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid schema name: '${schema}'`), + { tool: "pg_role_grant" }, + ); + } + target = `SCHEMA "${schema}"`; + } else if (parsed.table) { + if (!validateIdentifier(parsed.table)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid table name: '${parsed.table}'`), + { tool: "pg_role_grant" }, + ); + } + if (!validateIdentifier(schema)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid schema name: '${schema}'`), + { tool: "pg_role_grant" }, + ); + } + + // P154: Check table exists + const tableCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2`, + [schema, parsed.table], + ); + if ((tableCheck.rows?.length ?? 0) === 0) { + return formatHandlerErrorResponse( + new QueryError( + `Table '${schema}.${parsed.table}' does not exist`, + ), + { tool: "pg_role_grant" }, + ); + } + + target = `TABLE "${schema}"."${parsed.table}"`; + } else { + return formatHandlerErrorResponse( + new ValidationError( + "Either 'table' or 'objectType' of SCHEMA/ALL TABLES IN SCHEMA is required", + ), + { tool: "pg_role_grant" }, + ); + } + + let sql = `GRANT ${privList} ON ${target} TO "${parsed.role}"`; + if (withGrantOption) { + sql += " WITH GRANT OPTION"; + } + + await adapter.executeQuery(sql); + + return { + success: true, + role: parsed.role, + privileges: parsed.privileges, + target, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_role_grant" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_role_grant" }); + } + }, + }; +} + +// ============================================================================= +// pg_role_assign +// ============================================================================= + +/** + * Grant role membership to a user/role + */ +export function createRoleAssignTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_role_assign", + description: + "Assign (grant) a role to a user/role, establishing role membership. Optionally with ADMIN OPTION to allow re-granting.", + group: "roles", + inputSchema: RoleAssignSchemaBase.partial(), + outputSchema: RoleAssignOutputSchema, + annotations: admin("Assign Role"), + icons: getToolIcons("roles", admin("Assign Role")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleAssignSchema.parse(params) as { + role: string; + user: string; + withAdminOption?: boolean; + withSet?: boolean; + }; + + const withAdminOption = parsed.withAdminOption ?? false; + + if (!validateIdentifier(parsed.role)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid role name: '${parsed.role}'`), + { tool: "pg_role_assign" }, + ); + } + if (!validateIdentifier(parsed.user)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid user name: '${parsed.user}'`), + { tool: "pg_role_assign" }, + ); + } + + // P154: Check both roles exist + const roleExistsVal = await roleExists(adapter, parsed.role); + if (!roleExistsVal) { + return { + ...formatHandlerErrorResponse( + new QueryError(`Role '${parsed.role}' does not exist`), + { tool: "pg_role_assign" }, + ), + exists: false, + role: parsed.role, + }; + } + + const userExistsVal = await roleExists(adapter, parsed.user); + if (!userExistsVal) { + return { + ...formatHandlerErrorResponse( + new QueryError(`User/role '${parsed.user}' does not exist`), + { tool: "pg_role_assign" }, + ), + exists: false, + user: parsed.user, + }; + } + + let sql = `GRANT "${parsed.role}" TO "${parsed.user}"`; + if (withAdminOption) { + sql += " WITH ADMIN OPTION"; + } + + await adapter.executeQuery(sql); + + return { + success: true, + role: parsed.role, + user: parsed.user, + withAdminOption, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_role_assign" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_role_assign" }); + } + }, + }; +} + +// ============================================================================= +// pg_role_revoke +// ============================================================================= + +/** + * Revoke role membership or privileges from a user/role + */ +export function createRoleRevokeTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_role_revoke", + description: + "Revoke role membership from a user, or revoke specific privileges on objects from a role. For membership: provide role + user. For privileges: provide role + privileges + table/schema.", + group: "roles", + inputSchema: RoleRevokeSchemaBase.partial(), + outputSchema: RoleRevokeOutputSchema, + annotations: admin("Revoke Role/Privileges"), + icons: getToolIcons("roles", admin("Revoke Role/Privileges")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleRevokeSchema.parse(params) as { + role: string; + user?: string; + privileges?: string[]; + schema?: string; + table?: string; + objectType?: string; + }; + + if (!validateIdentifier(parsed.role)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid role name: '${parsed.role}'`), + { tool: "pg_role_revoke" }, + ); + } + + const roleExistsVal = await roleExists(adapter, parsed.role); + if (!roleExistsVal) { + return { + ...formatHandlerErrorResponse( + new QueryError(`Role '${parsed.role}' does not exist`), + { tool: "pg_role_revoke" }, + ), + exists: false, + role: parsed.role, + }; + } + + // Determine revocation mode: membership vs privileges + if (parsed.privileges && parsed.privileges.length > 0) { + // Object privilege revocation + const privCheck = validatePrivileges(parsed.privileges); + if (!privCheck.valid) { + return formatHandlerErrorResponse( + new ValidationError( + `Invalid privilege(s): ${privCheck.invalid.join(", ")}`, + ), + { tool: "pg_role_revoke" }, + ); + } + + const schema = parsed.schema ?? "public"; + const privList = parsed.privileges + .map((p) => p.toUpperCase()) + .join(", "); + + let target: string; + const objType = (parsed.objectType ?? "TABLE").toUpperCase(); + + if (objType === "ALL TABLES IN SCHEMA") { + if (!validateIdentifier(schema)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid schema name: '${schema}'`), + { tool: "pg_role_revoke" }, + ); + } + target = `ALL TABLES IN SCHEMA "${schema}"`; + } else if (objType === "ALL SEQUENCES IN SCHEMA") { + if (!validateIdentifier(schema)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid schema name: '${schema}'`), + { tool: "pg_role_revoke" }, + ); + } + target = `ALL SEQUENCES IN SCHEMA "${schema}"`; + } else if (objType === "SCHEMA") { + if (!validateIdentifier(schema)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid schema name: '${schema}'`), + { tool: "pg_role_revoke" }, + ); + } + target = `SCHEMA "${schema}"`; + } else if (parsed.table) { + if (!validateIdentifier(parsed.table)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid table name: '${parsed.table}'`), + { tool: "pg_role_revoke" }, + ); + } + if (!validateIdentifier(schema)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid schema name: '${schema}'`), + { tool: "pg_role_revoke" }, + ); + } + + // P154: Check table exists + const tableCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2`, + [schema, parsed.table], + ); + if ((tableCheck.rows?.length ?? 0) === 0) { + return formatHandlerErrorResponse( + new QueryError( + `Table '${schema}.${parsed.table}' does not exist`, + ), + { tool: "pg_role_revoke" }, + ); + } + + target = `TABLE "${schema}"."${parsed.table}"`; + } else { + return formatHandlerErrorResponse( + new ValidationError( + "Either 'table' or 'objectType' is required for privilege revocation", + ), + { tool: "pg_role_revoke" }, + ); + } + + await adapter.executeQuery( + `REVOKE ${privList} ON ${target} FROM "${parsed.role}"`, + ); + + return { + success: true, + role: parsed.role, + privileges: parsed.privileges, + target, + }; + } else if (parsed.user) { + // Role membership revocation + if (!validateIdentifier(parsed.user)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid user name: '${parsed.user}'`), + { tool: "pg_role_revoke" }, + ); + } + + // Check user exists + const userExistsVal = await roleExists(adapter, parsed.user); + if (!userExistsVal) { + return { + ...formatHandlerErrorResponse( + new QueryError(`User/role '${parsed.user}' does not exist`), + { tool: "pg_role_revoke" }, + ), + exists: false, + user: parsed.user, + }; + } + + // Check membership exists + const memberCheck = await adapter.executeQuery( + `SELECT 1 + FROM pg_auth_members m + JOIN pg_roles r ON r.oid = m.roleid + JOIN pg_roles u ON u.oid = m.member + WHERE r.rolname = $1 AND u.rolname = $2`, + [parsed.role, parsed.user], + ); + + if ((memberCheck.rows?.length ?? 0) === 0) { + return { + ...formatHandlerErrorResponse( + new QueryError( + `Role '${parsed.role}' is not currently assigned to '${parsed.user}'`, + ), + { tool: "pg_role_revoke" }, + ), + role: parsed.role, + user: parsed.user, + }; + } + + await adapter.executeQuery( + `REVOKE "${parsed.role}" FROM "${parsed.user}"`, + ); + + return { + success: true, + role: parsed.role, + user: parsed.user, + }; + } else { + return formatHandlerErrorResponse( + new ValidationError( + "Either 'user' (for membership revocation) or 'privileges' (for object privilege revocation) is required", + ), + { tool: "pg_role_revoke" }, + ); + } + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_role_revoke" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_role_revoke" }); + } + }, + }; +} diff --git a/src/adapters/postgresql/tools/roles/session.ts b/src/adapters/postgresql/tools/roles/session.ts new file mode 100644 index 00000000..296f2ae6 --- /dev/null +++ b/src/adapters/postgresql/tools/roles/session.ts @@ -0,0 +1,475 @@ +/** + * PostgreSQL Role Management - Session & RLS Tools + * + * Tools for user role inspection, session role switching, + * and row-level security management. + * 4 tools total. + */ + +import { ZodError } from "zod"; +import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import { QueryError, ValidationError } from "../../../../types/errors.js"; +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { + ToolDefinition, + RequestContext, +} from "../../../../types/index.js"; +import { readOnly, admin } from "../../../../utils/annotations.js"; +import { getToolIcons } from "../../../../utils/icons.js"; +import { + UserRolesSchemaBase, + UserRolesSchema, + RoleSetSchemaBase, + RoleSetSchema, + RoleRlsEnableSchemaBase, + RoleRlsEnableSchema, + RoleRlsPoliciesSchemaBase, + RoleRlsPoliciesSchema, + // Output schemas + UserRolesOutputSchema, + RoleSetOutputSchema, + RoleRlsEnableOutputSchema, + RoleRlsPoliciesOutputSchema, +} from "../../schemas/index.js"; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Validate a SQL identifier to prevent injection */ +function validateIdentifier(name: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(name); +} + +/** Check if a role exists via pg_roles */ +async function roleExists( + adapter: PostgresAdapter, + roleName: string, +): Promise { + const result = await adapter.executeQuery( + `SELECT 1 FROM pg_roles WHERE rolname = $1`, + [roleName], + ); + return (result.rows?.length ?? 0) > 0; +} + +// ============================================================================= +// pg_user_roles +// ============================================================================= + +/** + * List roles assigned to a user/role + */ +export function createUserRolesTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_user_roles", + description: + "List all roles assigned to a user/role, including admin option and SET option (PG 16+).", + group: "roles", + inputSchema: UserRolesSchemaBase.partial(), + outputSchema: UserRolesOutputSchema, + annotations: readOnly("User Roles"), + icons: getToolIcons("roles", readOnly("User Roles")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = UserRolesSchema.parse(params); + + // P154: Check user existence + const exists = await roleExists(adapter, parsed.user); + if (!exists) { + return { + ...formatHandlerErrorResponse( + new QueryError(`User/role '${parsed.user}' does not exist`), + { tool: "pg_user_roles" }, + ), + exists: false, + user: parsed.user, + }; + } + + // Query role membership with admin_option + // set_option is PG 16+, so we use a try/catch to handle older versions + let roles: { + role: string; + adminOption: boolean; + setOption?: boolean; + }[]; + + try { + const result = await adapter.executeQuery( + `SELECT + r.rolname AS role, + m.admin_option AS "adminOption", + m.set_option AS "setOption" + FROM pg_auth_members m + JOIN pg_roles r ON r.oid = m.roleid + JOIN pg_roles u ON u.oid = m.member + WHERE u.rolname = $1 + ORDER BY r.rolname`, + [parsed.user], + ); + + roles = (result.rows ?? []).map((row: Record) => ({ + role: row["role"] as string, + adminOption: row["adminOption"] as boolean, + setOption: row["setOption"] as boolean, + })); + } catch { + // Fallback for PG < 16 (no set_option column) + const result = await adapter.executeQuery( + `SELECT + r.rolname AS role, + m.admin_option AS "adminOption" + FROM pg_auth_members m + JOIN pg_roles r ON r.oid = m.roleid + JOIN pg_roles u ON u.oid = m.member + WHERE u.rolname = $1 + ORDER BY r.rolname`, + [parsed.user], + ); + + roles = (result.rows ?? []).map((row: Record) => ({ + role: row["role"] as string, + adminOption: row["adminOption"] as boolean, + })); + } + + return { + success: true, + exists: true, + user: parsed.user, + roles, + count: roles.length, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_user_roles" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_user_roles" }); + } + }, + }; +} + +// ============================================================================= +// pg_role_set +// ============================================================================= + +/** + * Set the session's active role + */ +export function createRoleSetTool(adapter: PostgresAdapter): ToolDefinition { + return { + name: "pg_role_set", + description: + "Set the session's active role using SET ROLE, or reset to the original session role with RESET ROLE. Session-scoped and reversible.", + group: "roles", + inputSchema: RoleSetSchemaBase.partial(), + outputSchema: RoleSetOutputSchema, + annotations: admin("Set Role"), + icons: getToolIcons("roles", admin("Set Role")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleSetSchema.parse(params) as { + role?: string; + reset?: boolean; + }; + + // Get current role before change + const currentResult = await adapter.executeQuery( + `SELECT current_user AS current_role`, + ); + const currentRow = currentResult.rows?.[0]; + const previousRole = (currentRow?.["current_role"] ?? "") as string; + + if (parsed.reset || !parsed.role) { + // RESET ROLE β€” restore original session role + await adapter.executeQuery(`RESET ROLE`); + + const afterResult = await adapter.executeQuery( + `SELECT current_user AS current_role`, + ); + const afterRow = afterResult.rows?.[0]; + const newRole = (afterRow?.["current_role"] ?? "") as string; + + return { + success: true, + currentRole: newRole, + previousRole, + reset: true, + }; + } + + // SET ROLE + if (!validateIdentifier(parsed.role)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid role name: '${parsed.role}'`), + { tool: "pg_role_set" }, + ); + } + + // P154: Check role exists + const exists = await roleExists(adapter, parsed.role); + if (!exists) { + return { + ...formatHandlerErrorResponse( + new QueryError(`Role '${parsed.role}' does not exist`), + { tool: "pg_role_set" }, + ), + previousRole, + }; + } + + await adapter.executeQuery(`SET ROLE "${parsed.role}"`); + + const afterResult2 = await adapter.executeQuery( + `SELECT current_user AS current_role`, + ); + const afterRow2 = afterResult2.rows?.[0]; + const newRole = (afterRow2?.["current_role"] ?? "") as string; + + return { + success: true, + currentRole: newRole, + previousRole, + reset: false, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { tool: "pg_role_set" }); + } + return formatHandlerErrorResponse(err, { tool: "pg_role_set" }); + } + }, + }; +} + +// ============================================================================= +// pg_role_rls_enable +// ============================================================================= + +/** + * Enable or disable row-level security on a table + */ +export function createRoleRlsEnableTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_role_rls_enable", + description: + "Enable or disable row-level security (RLS) on a table. Optionally use FORCE to apply RLS even to the table owner.", + group: "roles", + inputSchema: RoleRlsEnableSchemaBase.partial(), + outputSchema: RoleRlsEnableOutputSchema, + annotations: admin("RLS Enable/Disable"), + icons: getToolIcons("roles", admin("RLS Enable/Disable")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleRlsEnableSchema.parse(params) as { + table: string; + schema?: string; + enable?: boolean; + force?: boolean; + }; + + const schema = parsed.schema ?? "public"; + const enable = parsed.enable ?? true; + const force = parsed.force ?? false; + + if (!validateIdentifier(parsed.table)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid table name: '${parsed.table}'`), + { tool: "pg_role_rls_enable" }, + ); + } + if (!validateIdentifier(schema)) { + return formatHandlerErrorResponse( + new ValidationError(`Invalid schema name: '${schema}'`), + { tool: "pg_role_rls_enable" }, + ); + } + + // P154: Check table exists + const tableCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.tables + WHERE table_schema = $1 AND table_name = $2`, + [schema, parsed.table], + ); + if ((tableCheck.rows?.length ?? 0) === 0) { + return formatHandlerErrorResponse( + new QueryError(`Table '${schema}.${parsed.table}' does not exist`), + { tool: "pg_role_rls_enable" }, + ); + } + + const qualifiedName = `"${schema}"."${parsed.table}"`; + + if (enable) { + await adapter.executeQuery( + `ALTER TABLE ${qualifiedName} ENABLE ROW LEVEL SECURITY`, + ); + if (force) { + await adapter.executeQuery( + `ALTER TABLE ${qualifiedName} FORCE ROW LEVEL SECURITY`, + ); + } + } else { + await adapter.executeQuery( + `ALTER TABLE ${qualifiedName} DISABLE ROW LEVEL SECURITY`, + ); + // Also remove FORCE if disabling + await adapter.executeQuery( + `ALTER TABLE ${qualifiedName} NO FORCE ROW LEVEL SECURITY`, + ); + } + + // Verify current state + const stateResult = await adapter.executeQuery( + `SELECT + relrowsecurity AS rls_enabled, + relforcerowsecurity AS rls_forced + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2`, + [schema, parsed.table], + ); + + const stateRow = stateResult.rows?.[0]; + + return { + success: true, + table: parsed.table, + schema, + enabled: (stateRow?.["rls_enabled"] as boolean) ?? enable, + forced: (stateRow?.["rls_forced"] as boolean) ?? force, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_role_rls_enable", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_role_rls_enable", + }); + } + }, + }; +} + +// ============================================================================= +// pg_role_rls_policies +// ============================================================================= + +/** + * List RLS policies on a table + */ +export function createRoleRlsPoliciesTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_role_rls_policies", + description: + "List row-level security (RLS) policies for a table or all tables in a schema. Shows policy name, command, roles, USING/WITH CHECK expressions, and permissive/restrictive type.", + group: "roles", + inputSchema: RoleRlsPoliciesSchemaBase.partial(), + outputSchema: RoleRlsPoliciesOutputSchema, + annotations: readOnly("RLS Policies"), + icons: getToolIcons("roles", readOnly("RLS Policies")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = RoleRlsPoliciesSchema.parse(params) as { + table?: string; + schema?: string; + }; + + const schema = parsed.schema ?? "public"; + + if (parsed.table) { + // P154: Check table exists + const tableCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.tables + WHERE table_schema = $1 AND table_name = $2`, + [schema, parsed.table], + ); + if ((tableCheck.rows?.length ?? 0) === 0) { + return formatHandlerErrorResponse( + new QueryError( + `Table '${schema}.${parsed.table}' does not exist`, + ), + { tool: "pg_role_rls_policies" }, + ); + } + } + + let query = ` + SELECT + pol.polname AS "policyName", + cls.relname AS "tableName", + nsp.nspname AS "schemaName", + CASE pol.polcmd + WHEN 'r' THEN 'SELECT' + WHEN 'a' THEN 'INSERT' + WHEN 'w' THEN 'UPDATE' + WHEN 'd' THEN 'DELETE' + WHEN '*' THEN 'ALL' + ELSE pol.polcmd::text + END AS command, + CASE pol.polpermissive + WHEN true THEN 'PERMISSIVE' + ELSE 'RESTRICTIVE' + END AS permissive, + ARRAY( + SELECT rolname FROM pg_roles + WHERE oid = ANY(pol.polroles) + ) AS roles, + pg_get_expr(pol.polqual, pol.polrelid) AS "usingExpr", + pg_get_expr(pol.polwithcheck, pol.polrelid) AS "withCheckExpr" + FROM pg_policy pol + JOIN pg_class cls ON cls.oid = pol.polrelid + JOIN pg_namespace nsp ON nsp.oid = cls.relnamespace + WHERE nsp.nspname = $1 + `; + + const queryParams: string[] = [schema]; + + if (parsed.table) { + queryParams.push(parsed.table); + query += ` AND cls.relname = $${String(queryParams.length)}`; + } + + query += ` ORDER BY nsp.nspname, cls.relname, pol.polname`; + + const result = await adapter.executeQuery(query, queryParams); + + const policies = (result.rows ?? []).map( + (row: Record) => ({ + policyName: row["policyName"] as string, + tableName: row["tableName"] as string, + schemaName: row["schemaName"] as string, + command: row["command"] as string, + permissive: row["permissive"] as string, + roles: row["roles"] as string[], + usingExpr: row["usingExpr"] as string | null, + withCheckExpr: row["withCheckExpr"] as string | null, + }), + ); + + return { + success: true, + policies, + count: policies.length, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_role_rls_policies", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_role_rls_policies", + }); + } + }, + }; +} diff --git a/src/adapters/postgresql/tools/schema/catalog.ts b/src/adapters/postgresql/tools/schema/catalog.ts index f3dd8d50..a5b37cbc 100644 --- a/src/adapters/postgresql/tools/schema/catalog.ts +++ b/src/adapters/postgresql/tools/schema/catalog.ts @@ -13,6 +13,7 @@ import type { import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import { ValidationError } from "../../../../types/errors.js"; import { ListFunctionsSchemaBase, ListFunctionsSchema, @@ -69,7 +70,7 @@ export function createListFunctionsTool( [parsed.schema], ); if ((schemaCheck.rows?.length ?? 0) === 0) { - throw new Error( + throw new ValidationError( `Schema '${parsed.schema}' does not exist. Use pg_list_schemas to see available schemas.`, ); } @@ -191,7 +192,7 @@ export function createListTriggersTool( [schemaName], ); if ((schemaCheck.rows?.length ?? 0) === 0) { - throw new Error( + throw new ValidationError( `Schema '${schemaName}' does not exist. Use pg_list_schemas to see available schemas.`, ); } @@ -204,7 +205,7 @@ export function createListTriggersTool( [resolvedSchema, tableName], ); if ((tableCheck.rows?.length ?? 0) === 0) { - throw new Error( + throw new ValidationError( `Table '${resolvedSchema}.${tableName}' not found. Use pg_list_tables to see available tables.`, ); } @@ -297,10 +298,9 @@ export function createListConstraintsTool( parsed.type !== undefined && !validTypes.includes(parsed.type as (typeof validTypes)[number]) ) { - return { - success: false, - error: `Validation error: type must be one of: ${validTypes.join(", ")}`, - }; + throw new ValidationError( + `type must be one of: ${validTypes.join(", ")}`, + ); } // Parse schema.table format @@ -325,7 +325,7 @@ export function createListConstraintsTool( [parsed.schema], ); if ((schemaCheck.rows?.length ?? 0) === 0) { - throw new Error( + throw new ValidationError( `Schema '${parsed.schema}' does not exist. Use pg_list_schemas to see available schemas.`, ); } @@ -338,7 +338,7 @@ export function createListConstraintsTool( [schemaName, parsed.table], ); if ((tableCheck.rows?.length ?? 0) === 0) { - throw new Error( + throw new ValidationError( `Table '${schemaName}.${parsed.table}' not found. Use pg_list_tables to see available tables.`, ); } diff --git a/src/adapters/postgresql/tools/schema/objects.ts b/src/adapters/postgresql/tools/schema/objects.ts index c4e58b02..4b83db19 100644 --- a/src/adapters/postgresql/tools/schema/objects.ts +++ b/src/adapters/postgresql/tools/schema/objects.ts @@ -218,13 +218,9 @@ export function createListSequencesTool( [parsed.schema], ); if ((schemaCheck.rows?.length ?? 0) === 0) { - return { - success: false, - error: `Schema '${parsed.schema}' does not exist. Use pg_list_schemas to see available schemas.`, - code: "VALIDATION_ERROR", - category: "validation", - recoverable: false, - }; + throw new ValidationError( + `Schema '${parsed.schema}' does not exist. Use pg_list_schemas to see available schemas.`, + ); } } @@ -363,10 +359,9 @@ export function createCreateSequenceTool( // Validate and sanitize ownedBy: table.column or schema.table.column const ownedByParts = ownedBy.split("."); if (ownedByParts.length < 2 || ownedByParts.length > 3) { - return { - success: false, - error: `Invalid ownedBy format: '${ownedBy}'. Expected 'table.column' or 'schema.table.column'.`, - }; + throw new ValidationError( + `Invalid ownedBy format: '${ownedBy}'. Expected 'table.column' or 'schema.table.column'.`, + ); } const sanitizedOwnedBy = ownedByParts .map((p) => sanitizeIdentifier(p)) @@ -448,6 +443,7 @@ export function createDropSequenceTool( } catch (error: unknown) { return formatHandlerErrorResponse(error, { tool: "pg_drop_sequence", + objectType: "sequence", ...(schema !== undefined && { schema }), }); } diff --git a/src/adapters/postgresql/tools/schema/views.ts b/src/adapters/postgresql/tools/schema/views.ts index 93279c9c..d65f6d0f 100644 --- a/src/adapters/postgresql/tools/schema/views.ts +++ b/src/adapters/postgresql/tools/schema/views.ts @@ -14,6 +14,7 @@ import { readOnly, write, destructive } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { sanitizeIdentifier } from "../../../../utils/identifiers.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import { ValidationError } from "../../../../types/errors.js"; import { CreateViewSchemaBase, CreateViewSchema, @@ -42,7 +43,13 @@ export function createListViewsTool(adapter: PostgresAdapter): ToolDefinition { icons: getToolIcons("schema", readOnly("List Views")), handler: async (params: unknown, _context: RequestContext) => { try { - const parsed = ListViewsSchema.parse(params ?? {}); + const parsed = ListViewsSchema.parse(params ?? {}) as { + schema?: string; + exclude?: string[]; + includeMaterialized?: boolean; + truncateDefinition?: number; + limit?: number; + }; const queryParams: unknown[] = []; // Validate schema existence when filtering by schema @@ -52,26 +59,53 @@ export function createListViewsTool(adapter: PostgresAdapter): ToolDefinition { [parsed.schema], ); if ((schemaCheck.rows?.length ?? 0) === 0) { - return { - success: false, - error: `Schema '${parsed.schema}' does not exist. Use pg_list_schemas to see available schemas.`, - code: "QUERY_ERROR", - category: "query", - recoverable: false, - }; + throw new ValidationError( + `Schema '${parsed.schema}' does not exist. Use pg_list_schemas to see available schemas.`, + ); } } - const schemaClause = parsed.schema - ? (queryParams.push(parsed.schema), - `AND n.nspname = $${String(queryParams.length)}`) - : ""; - const kindClause = - parsed.includeMaterialized !== false ? "IN ('v', 'm')" : "= 'v'"; + const conditions: string[] = [ + `c.relkind ${parsed.includeMaterialized !== false ? "IN ('v', 'm')" : "= 'v'"}`, + "n.nspname NOT IN ('pg_catalog', 'information_schema')", + ]; + + if (parsed.schema) { + queryParams.push(parsed.schema); + conditions.push(`n.nspname = $${String(queryParams.length)}`); + } + + if (Array.isArray(parsed.exclude) && parsed.exclude.length > 0) { + const EXTENSION_ALIASES: Record = { + pgvector: "vector", + vector: "vector", + partman: "pg_partman", + fuzzymatch: "fuzzystrmatch", + fuzzy: "fuzzystrmatch", + }; + const normalizedExclude = parsed.exclude.flatMap((s: unknown) => { + const str = String(s); + const alias = EXTENSION_ALIASES[str]; + return alias ? [str, alias] : [str]; + }); + const excludePlaceholders = normalizedExclude.map((s: string) => { + queryParams.push(s); + return `$${String(queryParams.length)}`; + }); + const excludeList = excludePlaceholders.join(", "); + conditions.push(`n.nspname NOT IN (${excludeList})`); + conditions.push(`NOT EXISTS ( + SELECT 1 FROM pg_depend d + JOIN pg_extension e ON d.refobjid = e.oid + WHERE d.objid = c.oid + AND d.deptype = 'e' + AND e.extname IN (${excludeList}) + )`); + } - // Default truncation: 500 chars, 0 = no truncation (safe coercion) + // Default truncation: 100 chars, 0 = no truncation (safe coercion) const rawTruncate = Number(parsed.truncateDefinition); - const truncateLimit = Number.isFinite(rawTruncate) ? rawTruncate : 500; + const truncateLimit = Number.isFinite(rawTruncate) ? rawTruncate : 100; // Default limit: 50, 0 = no limit (safe coercion) const rawLimit = Number(parsed.limit); @@ -83,9 +117,7 @@ export function createListViewsTool(adapter: PostgresAdapter): ToolDefinition { TRIM(pg_get_viewdef(c.oid, true)) as definition FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind ${kindClause} - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - ${schemaClause} + WHERE ${conditions.join(" AND ")} ORDER BY n.nspname, c.relname ${limitClause}`; @@ -134,19 +166,12 @@ export function createListViewsTool(adapter: PostgresAdapter): ToolDefinition { response["truncated"] = hasMore; if (hasMore) { // Get total count - const countParams: unknown[] = []; - const countSchemaClause = parsed.schema - ? (countParams.push(parsed.schema), - `AND n.nspname = $${String(countParams.length)}`) - : ""; const countSql = `SELECT COUNT(*)::int as total FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind ${kindClause} - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - ${countSchemaClause}`; + WHERE ${conditions.join(" AND ")}`; const countResult = - countParams.length > 0 - ? await adapter.executeQuery(countSql, countParams) + queryParams.length > 0 + ? await adapter.executeQuery(countSql, queryParams) : await adapter.executeQuery(countSql); response["totalCount"] = countResult.rows?.[0]?.["total"] ?? views.length; @@ -307,6 +332,7 @@ export function createDropViewTool(adapter: PostgresAdapter): ToolDefinition { } catch (error: unknown) { return formatHandlerErrorResponse(error, { tool: "pg_drop_view", + objectType: "view", ...(schema !== undefined && { schema }), }); } diff --git a/src/adapters/postgresql/tools/security/audit.ts b/src/adapters/postgresql/tools/security/audit.ts new file mode 100644 index 00000000..521bd893 --- /dev/null +++ b/src/adapters/postgresql/tools/security/audit.ts @@ -0,0 +1,478 @@ +/** + * PostgreSQL Security - Audit and Firewall Tools + * + * Tools for security auditing, HBA/firewall monitoring, and compliance. + * 3 tools total. + */ + +import { ZodError } from "zod"; +import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { + ToolDefinition, + RequestContext, +} from "../../../../types/index.js"; +import { readOnly, admin } from "../../../../utils/annotations.js"; +import { getToolIcons } from "../../../../utils/icons.js"; +import { + SecurityAuditSchemaBase, + SecurityAuditSchema, + FirewallStatusSchemaBase, + FirewallStatusSchema, + FirewallRulesSchemaBase, + FirewallRulesSchema, + // Output schemas + SecurityAuditOutputSchema, + FirewallStatusOutputSchema, + FirewallRulesOutputSchema, +} from "../../schemas/index.js"; + +// ============================================================================= +// Types +// ============================================================================= + +interface AuditFinding { + check: string; + severity: "info" | "warning" | "critical"; + status: "pass" | "warn" | "fail"; + message: string; + recommendation?: string | undefined; +} + +// ============================================================================= +// pg_security_audit +// ============================================================================= + +/** + * Comprehensive security posture audit + */ +export function createSecurityAuditTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_security_audit", + description: + "Run a comprehensive security audit checking SSL, password encryption, superuser exposure, logging, and HBA rules.", + group: "security", + inputSchema: SecurityAuditSchemaBase, + outputSchema: SecurityAuditOutputSchema, + annotations: admin("Security Audit"), + icons: getToolIcons("security", admin("Security Audit")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = SecurityAuditSchema.parse(params) as { + limit?: number; + includeHba?: boolean; + }; + const limit = parsed.limit ?? 20; + const includeHba = parsed.includeHba ?? true; + + const findings: AuditFinding[] = []; + + // 1. Check SSL status + try { + const sslResult = await adapter.executeQuery( + `SELECT current_setting('ssl', true) as ssl_enabled`, + ); + const sslEnabled = sslResult.rows?.[0]?.["ssl_enabled"] === "on"; + findings.push({ + check: "SSL/TLS", + severity: sslEnabled ? "info" : "critical", + status: sslEnabled ? "pass" : "fail", + message: sslEnabled + ? "SSL is enabled" + : "SSL is not enabled β€” connections are unencrypted", + recommendation: sslEnabled + ? undefined + : "Enable SSL by setting ssl = on in postgresql.conf and configuring certificates", + }); + } catch { + findings.push({ + check: "SSL/TLS", + severity: "warning", + status: "warn", + message: "Could not determine SSL status", + }); + } + + // 2. Check password encryption method + try { + const encResult = await adapter.executeQuery( + `SELECT current_setting('password_encryption', true) as method`, + ); + const method = + (encResult.rows?.[0]?.["method"] as string) ?? "unknown"; + const isScram = method === "scram-sha-256"; + findings.push({ + check: "Password Encryption", + severity: isScram ? "info" : "warning", + status: isScram ? "pass" : "warn", + message: `Password encryption method: ${method}`, + recommendation: isScram + ? undefined + : "Upgrade to scram-sha-256: ALTER SYSTEM SET password_encryption = 'scram-sha-256'", + }); + } catch { + findings.push({ + check: "Password Encryption", + severity: "warning", + status: "warn", + message: "Could not determine password encryption method", + }); + } + + // 3. Check connection logging + try { + const logResult = await adapter.executeQuery(` + SELECT name, setting + FROM pg_settings + WHERE name IN ('log_connections', 'log_disconnections') + `); + const settings: Record = Object.fromEntries( + (logResult.rows ?? []).map((r: Record) => [ + r["name"] as string, + r["setting"] as string, + ]), + ); + const logConn = settings["log_connections"] === "on"; + const logDisconn = settings["log_disconnections"] === "on"; + findings.push({ + check: "Connection Logging", + severity: logConn && logDisconn ? "info" : "warning", + status: logConn && logDisconn ? "pass" : "warn", + message: `log_connections: ${logConn ? "on" : "off"}, log_disconnections: ${logDisconn ? "on" : "off"}`, + recommendation: + logConn && logDisconn + ? undefined + : "Enable connection auditing: ALTER SYSTEM SET log_connections = on; ALTER SYSTEM SET log_disconnections = on", + }); + } catch { + // Skip if settings not accessible + } + + // 4. Check superuser count + try { + const superResult = await adapter.executeQuery(` + SELECT count(*) as cnt FROM pg_roles WHERE rolsuper = true + `); + const superCount = Number(superResult.rows?.[0]?.["cnt"] ?? 0); + findings.push({ + check: "Superuser Exposure", + severity: superCount > 2 ? "warning" : "info", + status: superCount > 2 ? "warn" : "pass", + message: `${String(superCount)} superuser role(s) found`, + recommendation: + superCount > 2 + ? "Minimize superuser roles. Use GRANT for specific privileges instead." + : undefined, + }); + } catch { + // Skip if roles not accessible + } + + // 5. Check for roles with no password + try { + const noPwResult = await adapter.executeQuery(` + SELECT count(*) as cnt + FROM pg_authid + WHERE rolcanlogin = true + AND rolpassword IS NULL + `); + const noPwCount = Number(noPwResult.rows?.[0]?.["cnt"] ?? 0); + if (noPwCount > 0) { + findings.push({ + check: "Passwordless Login Roles", + severity: "critical", + status: "fail", + message: `${String(noPwCount)} login role(s) have no password set`, + recommendation: + "Set passwords for all login roles or disable login: ALTER ROLE rolename NOLOGIN", + }); + } else { + findings.push({ + check: "Passwordless Login Roles", + severity: "info", + status: "pass", + message: "All login roles have passwords set", + }); + } + } catch { + // pg_authid requires superuser β€” skip gracefully + findings.push({ + check: "Passwordless Login Roles", + severity: "info", + status: "warn", + message: "Cannot check pg_authid (requires superuser). Skipped.", + }); + } + + // 6. Check pg_hba.conf rules if requested + if (includeHba) { + try { + const hbaResult = await adapter.executeQuery(` + SELECT type, auth_method, count(*) as cnt + FROM pg_hba_file_rules + WHERE error IS NULL + GROUP BY type, auth_method + ORDER BY type, auth_method + `); + + const trustRules = (hbaResult.rows ?? []).filter( + (r: Record) => r["auth_method"] === "trust", + ); + const trustCount = trustRules.reduce( + (sum: number, r: Record) => + sum + Number(r["cnt"] ?? 0), + 0, + ); + + if (trustCount > 0) { + findings.push({ + check: "HBA Trust Authentication", + severity: "critical", + status: "fail", + message: `${String(trustCount)} pg_hba.conf rule(s) use 'trust' authentication (no password required)`, + recommendation: + "Replace 'trust' with 'scram-sha-256' or 'md5' in pg_hba.conf", + }); + } else { + findings.push({ + check: "HBA Trust Authentication", + severity: "info", + status: "pass", + message: "No 'trust' authentication rules found", + }); + } + } catch { + findings.push({ + check: "HBA Rules", + severity: "info", + status: "warn", + message: + "Cannot read pg_hba_file_rules (requires superuser or pg_read_all_settings). Skipped.", + }); + } + } + + // Limit findings + const limitedFindings = findings.slice(0, limit); + + // Build summary + const summaryObj = { + total: limitedFindings.length, + passed: limitedFindings.filter((f) => f.status === "pass").length, + warnings: limitedFindings.filter((f) => f.status === "warn").length, + critical: limitedFindings.filter((f) => f.status === "fail").length, + }; + + return { + success: true, + findings: limitedFindings, + summary: summaryObj, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_security_audit", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_security_audit", + }); + } + }, + }; +} + +// ============================================================================= +// pg_security_firewall_status +// ============================================================================= + +/** + * Get pg_hba.conf rules summary (firewall equivalent) + */ +export function createSecurityFirewallStatusTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_security_firewall_status", + description: + "Get PostgreSQL host-based authentication (pg_hba.conf) summary β€” the PostgreSQL equivalent of a firewall.", + group: "security", + inputSchema: FirewallStatusSchemaBase, + outputSchema: FirewallStatusOutputSchema, + annotations: readOnly("HBA/Firewall Status"), + icons: getToolIcons("security", readOnly("HBA/Firewall Status")), + handler: async (_params: unknown, _context: RequestContext) => { + try { + FirewallStatusSchema.parse(_params); + + // Try to read pg_hba_file_rules + try { + const hbaResult = await adapter.executeQuery(` + SELECT type, auth_method, count(*) as cnt + FROM pg_hba_file_rules + WHERE error IS NULL + GROUP BY type, auth_method + ORDER BY type, auth_method + `); + + const rows = hbaResult.rows ?? []; + + // Aggregate by type + const rulesByType: Record = {}; + const authMethods: Record = {}; + let totalRules = 0; + + for (const row of rows) { + const r = row; + const type = r["type"] as string; + const method = r["auth_method"] as string; + const cnt = Number(r["cnt"] ?? 0); + + rulesByType[type] = (rulesByType[type] ?? 0) + cnt; + authMethods[method] = (authMethods[method] ?? 0) + cnt; + totalRules += cnt; + } + + // Check if hostssl is enforced for remote + const hostRules = rulesByType["host"] ?? 0; + const hostsslRules = rulesByType["hostssl"] ?? 0; + const hostsslEnforced = hostRules === 0 && hostsslRules > 0; + + return { + success: true, + available: true, + totalRules, + rulesByType, + authMethods, + hostsslEnforced, + }; + } catch { + return formatHandlerErrorResponse( + new Error( + "pg_hba_file_rules not accessible. Requires superuser or pg_read_all_settings role.", + ), + { tool: "pg_security_firewall_status" }, + ); + } + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_security_firewall_status", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_security_firewall_status", + }); + } + }, + }; +} + +// ============================================================================= +// pg_security_firewall_rules +// ============================================================================= + +/** + * List pg_hba.conf rules (detailed) + */ +export function createSecurityFirewallRulesTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_security_firewall_rules", + description: + "List detailed pg_hba.conf authentication rules with optional filtering by user or rule type.", + group: "security", + inputSchema: FirewallRulesSchemaBase, + outputSchema: FirewallRulesOutputSchema, + annotations: admin("HBA/Firewall Rules"), + icons: getToolIcons("security", admin("HBA/Firewall Rules")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = FirewallRulesSchema.parse(params) as { + user?: string; + type?: string; + }; + const { user, type } = parsed; + + // Validate type if provided + const validTypes = [ + "local", + "host", + "hostssl", + "hostnossl", + "hostgssenc", + "hostnogssenc", + ] as const; + if (type && !validTypes.includes(type as (typeof validTypes)[number])) { + return formatHandlerErrorResponse( + new Error( + `Invalid type: '${type}' β€” expected one of: ${validTypes.join(", ")}`, + ), + { tool: "pg_security_firewall_rules" }, + ); + } + + try { + let query = ` + SELECT + line_number, + type, + database, + user_name, + address, + netmask, + auth_method, + options + FROM pg_hba_file_rules + WHERE error IS NULL + `; + + const conditions: string[] = []; + const queryParams: string[] = []; + + if (user) { + queryParams.push(user); + conditions.push(`$${String(queryParams.length)} = ANY(user_name)`); + } + if (type) { + queryParams.push(type); + conditions.push(`type = $${String(queryParams.length)}`); + } + + if (conditions.length > 0) { + query += " AND " + conditions.join(" AND "); + } + + query += " ORDER BY line_number"; + + const result = await adapter.executeQuery(query, queryParams); + + return { + success: true, + rules: result.rows ?? [], + count: result.rows?.length ?? 0, + }; + } catch { + return formatHandlerErrorResponse( + new Error( + "pg_hba_file_rules not accessible. Requires superuser or pg_read_all_settings role.", + ), + { tool: "pg_security_firewall_rules" }, + ); + } + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_security_firewall_rules", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_security_firewall_rules", + }); + } + }, + }; +} diff --git a/src/adapters/postgresql/tools/security/data-protection.ts b/src/adapters/postgresql/tools/security/data-protection.ts new file mode 100644 index 00000000..8ddb97c7 --- /dev/null +++ b/src/adapters/postgresql/tools/security/data-protection.ts @@ -0,0 +1,491 @@ +/** + * PostgreSQL Security - Data Protection Tools + * + * Tools for data masking, privilege management, and sensitive data identification. + * 3 tools total. + */ + +import { ZodError } from "zod"; +import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { + ToolDefinition, + RequestContext, +} from "../../../../types/index.js"; +import { readOnly, admin } from "../../../../utils/annotations.js"; +import { getToolIcons } from "../../../../utils/icons.js"; +import { + MaskDataSchemaBase, + MaskDataSchema, + UserPrivilegesSchemaBase, + UserPrivilegesSchema, + SensitiveTablesSchemaBase, + SensitiveTablesSchema, + // Output schemas + MaskDataOutputSchema, + UserPrivilegesOutputSchema, + SensitiveTablesOutputSchema, +} from "../../schemas/index.js"; + +// ============================================================================= +// pg_security_mask_data +// ============================================================================= + +/** + * Mask sensitive data (pure JS, no database queries) + */ +export function createSecurityMaskDataTool( + _adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_security_mask_data", + description: + "Apply data masking to sensitive values. Supports email, phone, SSN, credit card, and partial masking.", + group: "security", + inputSchema: MaskDataSchemaBase, + outputSchema: MaskDataOutputSchema, + annotations: readOnly("Data Masking"), + icons: getToolIcons("security", readOnly("Data Masking")), + handler: (params: unknown, _context: RequestContext): Promise => { + try { + const { value, type, keepFirst, keepLast, maskChar } = + MaskDataSchema.parse(params); + + const validTypes = [ + "email", + "phone", + "ssn", + "credit_card", + "partial", + ] as const; + if (!validTypes.includes(type as (typeof validTypes)[number])) { + return Promise.resolve({ + success: false, + error: `Invalid type: '${type}' β€” expected one of: ${validTypes.join(", ")}`, + code: "VALIDATION_ERROR", + category: "validation", + recoverable: false, + }); + } + + let maskedValue: string; + + switch (type) { + case "email": { + const atIndex = value.indexOf("@"); + if (atIndex > 0) { + const localPart = value.substring(0, atIndex); + const domain = value.substring(atIndex); + const maskedLocal = + localPart.length > 2 + ? (localPart[0] ?? "") + + maskChar.repeat(localPart.length - 2) + + (localPart[localPart.length - 1] ?? "") + : maskChar.repeat(localPart.length); + maskedValue = maskedLocal + domain; + } else { + maskedValue = maskChar.repeat(value.length); + } + break; + } + case "phone": { + const digits = value.replace(/\D/g, ""); + maskedValue = + maskChar.repeat(Math.max(0, digits.length - 4)) + + digits.slice(-4); + break; + } + case "ssn": { + const ssnDigits = value.replace(/\D/g, ""); + maskedValue = `${maskChar}${maskChar}${maskChar}-${maskChar}${maskChar}-${ssnDigits.slice(-4)}`; + break; + } + case "credit_card": { + const ccDigits = value.replace(/\D/g, ""); + if (ccDigits.length <= 8) { + return Promise.resolve({ + success: true, + original: value, + masked: maskChar.repeat(value.length), + type, + warning: + "Value too short for credit_card format (expected more than 8 digits); fully masked instead", + }); + } + maskedValue = + ccDigits.slice(0, 4) + + maskChar.repeat(Math.max(0, ccDigits.length - 8)) + + ccDigits.slice(-4); + break; + } + case "partial": { + if (keepFirst + keepLast >= value.length) { + return Promise.resolve({ + success: true, + original: value, + masked: value, + type, + warning: + "Masking ineffective: keepFirst + keepLast covers entire value length; returned unchanged", + }); + } else { + const maskLength = value.length - keepFirst - keepLast; + maskedValue = + value.slice(0, keepFirst) + + maskChar.repeat(maskLength) + + (keepLast > 0 ? value.slice(-keepLast) : ""); + } + break; + } + default: + maskedValue = maskChar.repeat(value.length); + } + + return Promise.resolve({ + success: true, + original: value, + masked: maskedValue, + type, + }); + } catch (error) { + if (error instanceof ZodError) { + return Promise.resolve( + formatHandlerErrorResponse(error, { + tool: "pg_security_mask_data", + }), + ); + } + return Promise.resolve( + formatHandlerErrorResponse(error, { + tool: "pg_security_mask_data", + }), + ); + } + }, + }; +} + +// ============================================================================= +// pg_security_user_privileges +// ============================================================================= + +/** + * Get comprehensive user/role privileges + */ +export function createSecurityUserPrivilegesTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_security_user_privileges", + description: + "Get comprehensive privilege report for PostgreSQL roles including attributes, membership, and object grants.", + group: "security", + inputSchema: UserPrivilegesSchemaBase, + outputSchema: UserPrivilegesOutputSchema, + annotations: admin("User Privileges"), + icons: getToolIcons("security", admin("User Privileges")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const { user, includeRoles, summary, includeGrants, limit } = + UserPrivilegesSchema.parse(params) as { + user?: string; + includeRoles: boolean; + summary: boolean; + includeGrants: boolean; + limit?: number; + }; + + const resultLimit = limit ?? 50; + + // P154: Validate role existence when explicitly provided + if (user) { + const roleCheck = await adapter.executeQuery( + `SELECT 1 FROM pg_roles WHERE rolname = $1`, + [user], + ); + if (!roleCheck.rows || roleCheck.rows.length === 0) { + return { + success: false, + error: `Role '${user}' does not exist.`, + code: "OBJECT_NOT_FOUND", + category: "resource", + recoverable: false, + }; + } + } + + // Get roles with their attributes + let rolesQuery = ` + SELECT + r.rolname as role_name, + r.rolsuper as is_superuser, + r.rolinherit as inherits, + r.rolcreaterole as can_create_role, + r.rolcreatedb as can_create_db, + r.rolcanlogin as can_login, + r.rolreplication as is_replication, + r.rolbypassrls as bypass_rls, + r.rolconnlimit as connection_limit, + r.rolvaliduntil as valid_until + FROM pg_roles r + `; + + const queryParams: string[] = []; + if (user) { + rolesQuery += ` WHERE r.rolname = $1`; + queryParams.push(user); + } else { + // Exclude system roles for cleaner output + rolesQuery += ` WHERE r.rolname NOT LIKE 'pg_%'`; + } + rolesQuery += ` ORDER BY r.rolname LIMIT $${String(queryParams.length + 1)}`; + queryParams.push(String(resultLimit)); + + const rolesResult = await adapter.executeQuery(rolesQuery, queryParams); + + const userPrivileges: Record[] = []; + + for (const roleRow of rolesResult.rows ?? []) { + const r = roleRow; + const roleName = r["role_name"] as string; + + let memberOf: string[] = []; + if (includeRoles) { + try { + const memberResult = await adapter.executeQuery( + ` + SELECT b.rolname as granted_role + FROM pg_auth_members m + JOIN pg_roles a ON m.member = a.oid + JOIN pg_roles b ON m.roleid = b.oid + WHERE a.rolname = $1 + ORDER BY b.rolname + `, + [roleName], + ); + + memberOf = (memberResult.rows ?? []).map( + (row: Record) => row["granted_role"] as string, + ); + } catch { + // Membership info not accessible + } + } + + if (summary) { + // Get grant count for summary mode + let grantCount = 0; + try { + const grantsResult = await adapter.executeQuery( + ` + SELECT count(*) as cnt + FROM information_schema.role_table_grants + WHERE grantee = $1 + `, + [roleName], + ); + grantCount = Number(grantsResult.rows?.[0]?.["cnt"] ?? 0); + } catch { + // Grant info not accessible + } + + userPrivileges.push({ + role: roleName, + isSuperuser: r["is_superuser"], + canLogin: r["can_login"], + canCreateDb: r["can_create_db"], + canCreateRole: r["can_create_role"], + isReplication: r["is_replication"], + bypassRls: r["bypass_rls"], + grantCount, + roleCount: memberOf.length, + }); + } else { + let tableGrants: Record[] = []; + if (includeGrants) { + try { + const grantsResult = await adapter.executeQuery( + ` + SELECT + table_schema as schema, + table_name, + privilege_type, + is_grantable + FROM information_schema.role_table_grants + WHERE grantee = $1 + ORDER BY table_schema, table_name, privilege_type + LIMIT 100 + `, + [roleName], + ); + tableGrants = grantsResult.rows ?? []; + } catch { + // Grant info not accessible + } + } + + userPrivileges.push({ + role: roleName, + attributes: { + isSuperuser: r["is_superuser"], + canLogin: r["can_login"], + canCreateDb: r["can_create_db"], + canCreateRole: r["can_create_role"], + isReplication: r["is_replication"], + bypassRls: r["bypass_rls"], + inherits: r["inherits"], + connectionLimit: r["connection_limit"], + validUntil: r["valid_until"], + }, + memberOf, + ...(includeGrants ? { tableGrants } : {}), + }); + } + } + + // Find out total available vs limited + let limited = false; + if (!user) { + const totalCountResult = await adapter.executeQuery( + `SELECT count(*) as cnt FROM pg_roles WHERE rolname NOT LIKE 'pg_%'`, + ); + const totalCount = Number(totalCountResult.rows?.[0]?.["cnt"] ?? 0); + limited = totalCount > resultLimit; + } + + return { + success: true, + users: userPrivileges, + count: userPrivileges.length, + summary, + ...(limited ? { limited: true } : {}), + }; + } catch (err) { + return formatHandlerErrorResponse(err, { + tool: "pg_security_user_privileges", + }); + } + }, + }; +} + +// ============================================================================= +// pg_security_sensitive_tables +// ============================================================================= + +/** + * Identify tables with potentially sensitive data + */ +export function createSecuritySensitiveTablesTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_security_sensitive_tables", + description: + "Identify tables and columns that may contain sensitive data based on column name patterns.", + group: "security", + inputSchema: SensitiveTablesSchemaBase, + outputSchema: SensitiveTablesOutputSchema, + annotations: readOnly("Sensitive Tables"), + icons: getToolIcons("security", readOnly("Sensitive Tables")), + handler: async (params: unknown, _context: RequestContext) => { + try { + const parsed = SensitiveTablesSchema.parse(params) as { + schema?: string; + patterns: string[]; + limit: number; + }; + const { schema, patterns, limit } = parsed; + + // P154: Schema existence check when explicitly provided + if (schema) { + const schemaCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.schemata WHERE schema_name = $1`, + [schema], + ); + if (!schemaCheck.rows || schemaCheck.rows.length === 0) { + return { + success: false, + error: `Schema '${schema}' does not exist. Use pg_list_schemas to see available schemas.`, + code: "OBJECT_NOT_FOUND", + category: "resource", + recoverable: false, + }; + } + } + + if (patterns.length === 0) { + return { + success: true, + sensitiveTables: [], + tableCount: 0, + totalSensitiveColumns: 0, + patternsUsed: [], + }; + } + + // Build pattern conditions using parameterized queries + const schemaTarget = schema ?? "public"; + const patternConditions = patterns + .map((_: string, i: number) => `column_name ILIKE $${String(i + 2)}`) + .join(" OR "); + const patternParams = patterns.map((p: string) => `%${p}%`); + + const query = ` + SELECT + table_name, + column_name, + data_type, + udt_name, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_schema = $1 + AND (${patternConditions}) + ORDER BY table_name, column_name + `; + + const result = await adapter.executeQuery(query, [ + schemaTarget, + ...patternParams, + ]); + + // Group by table + const tableMap = new Map[]>(); + for (const row of result.rows ?? []) { + const r = row; + const tableName = r["table_name"] as string; + if (!tableMap.has(tableName)) { + tableMap.set(tableName, []); + } + tableMap.get(tableName)?.push(r); + } + + const allItems = Array.from(tableMap.entries()).map( + ([table, columns]) => ({ + table, + sensitiveColumns: columns, + columnCount: columns.length, + }), + ); + + const totalAvailable = allItems.length; + const limited = totalAvailable > limit; + const sensitiveItems = limited ? allItems.slice(0, limit) : allItems; + + return { + success: true, + sensitiveTables: sensitiveItems, + tableCount: sensitiveItems.length, + totalSensitiveColumns: result.rows?.length ?? 0, + patternsUsed: patterns, + ...(limited ? { limited: true, totalAvailable } : {}), + }; + } catch (err) { + return formatHandlerErrorResponse(err, { + tool: "pg_security_sensitive_tables", + }); + } + }, + }; +} diff --git a/src/adapters/postgresql/tools/security/encryption.ts b/src/adapters/postgresql/tools/security/encryption.ts new file mode 100644 index 00000000..7f8ce2bd --- /dev/null +++ b/src/adapters/postgresql/tools/security/encryption.ts @@ -0,0 +1,380 @@ +/** + * PostgreSQL Security - Encryption and SSL Tools + * + * Tools for SSL/TLS monitoring, encryption status, and password validation. + * 3 tools total. + */ + +import { ZodError } from "zod"; +import { formatHandlerErrorResponse } from "../core/error-helpers.js"; +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { + ToolDefinition, + RequestContext, +} from "../../../../types/index.js"; +import { readOnly, admin } from "../../../../utils/annotations.js"; +import { getToolIcons } from "../../../../utils/icons.js"; +import { + SSLStatusSchemaBase, + SSLStatusSchema, + EncryptionStatusSchemaBase, + EncryptionStatusSchema, + PasswordValidateSchemaBase, + PasswordValidateSchema, + // Output schemas + SSLStatusOutputSchema, + EncryptionStatusOutputSchema, + PasswordValidateOutputSchema, +} from "../../schemas/index.js"; + +// ============================================================================= +// pg_security_ssl_status +// ============================================================================= + +/** + * Get SSL/TLS connection status + */ +export function createSecuritySSLStatusTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_security_ssl_status", + description: + "Get SSL/TLS connection and certificate status for active connections.", + group: "security", + inputSchema: SSLStatusSchemaBase, + outputSchema: SSLStatusOutputSchema, + annotations: readOnly("SSL Status"), + icons: getToolIcons("security", readOnly("SSL Status")), + handler: async (_params: unknown, _context: RequestContext) => { + try { + SSLStatusSchema.parse(_params); + + // Check if ssl is enabled + const sslSettingResult = await adapter.executeQuery( + `SELECT current_setting('ssl', true) as ssl_enabled`, + ); + const sslEnabled = sslSettingResult.rows?.[0]?.["ssl_enabled"] === "on"; + + // Try to get SSL connection details from pg_stat_ssl + try { + const sslResult = await adapter.executeQuery(` + SELECT + s.pid, + s.ssl, + s.version, + s.cipher, + s.client_dn + FROM pg_stat_ssl s + JOIN pg_stat_activity a ON s.pid = a.pid + WHERE a.state IS NOT NULL + ORDER BY s.ssl DESC, s.pid + LIMIT 50 + `); + + const connections = sslResult.rows ?? []; + const sslCount = connections.filter( + (r: Record) => r["ssl"] === true, + ).length; + + // Get SSL configuration + const configResult = await adapter.executeQuery(` + SELECT name, setting + FROM pg_settings + WHERE name IN ( + 'ssl', 'ssl_ca_file', 'ssl_cert_file', 'ssl_key_file', + 'ssl_crl_file', 'ssl_ciphers', 'ssl_min_protocol_version', + 'ssl_max_protocol_version' + ) + ORDER BY name + `); + + const configuration: Record = Object.fromEntries( + (configResult.rows ?? []).map((r: Record) => [ + r["name"] as string, + r["setting"], + ]), + ); + + return { + success: true, + sslEnabled, + sslConnections: connections, + configuration, + totalConnections: connections.length, + sslConnectionCount: sslCount, + }; + } catch { + // pg_stat_ssl not available (PG < 9.5 or permissions) + return { + success: true, + sslEnabled, + sslConnections: [], + configuration: {}, + totalConnections: 0, + sslConnectionCount: 0, + message: + "pg_stat_ssl not accessible. SSL is " + + (sslEnabled ? "enabled" : "disabled") + + " at the server level.", + }; + } + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_security_ssl_status", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_security_ssl_status", + }); + } + }, + }; +} + +// ============================================================================= +// pg_security_encryption_status +// ============================================================================= + +/** + * Check encryption and certificate configuration + */ +export function createSecurityEncryptionStatusTool( + adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_security_encryption_status", + description: + "Get encryption status including SSL configuration, password encryption method, and pgcrypto availability.", + group: "security", + inputSchema: EncryptionStatusSchemaBase, + outputSchema: EncryptionStatusOutputSchema, + annotations: admin("Encryption Status"), + icons: getToolIcons("security", admin("Encryption Status")), + handler: async (_params: unknown, _context: RequestContext) => { + try { + EncryptionStatusSchema.parse(_params); + + // Get encryption-related settings + const settingsResult = await adapter.executeQuery(` + SELECT name, setting + FROM pg_settings + WHERE name IN ( + 'ssl', 'password_encryption', + 'ssl_ca_file', 'ssl_cert_file', 'ssl_key_file', 'ssl_crl_file', + 'ssl_ciphers', 'ssl_min_protocol_version', 'ssl_max_protocol_version' + ) + ORDER BY name + `); + + const settings: Record = Object.fromEntries( + (settingsResult.rows ?? []).map((r: Record) => [ + r["name"] as string, + r["setting"], + ]), + ); + + const sslEnabled = settings["ssl"] === "on"; + const passwordEncryption = + (settings["password_encryption"] as string) ?? "unknown"; + + // Extract certificate paths + const certificates = { + ssl_ca_file: (settings["ssl_ca_file"] as string) ?? "", + ssl_cert_file: (settings["ssl_cert_file"] as string) ?? "", + ssl_key_file: (settings["ssl_key_file"] as string) ?? "", + ssl_crl_file: (settings["ssl_crl_file"] as string) ?? "", + }; + + // Check if pgcrypto is available + let pgcryptoAvailable = false; + try { + const pgcryptoResult = await adapter.executeQuery(` + SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto' + `); + pgcryptoAvailable = (pgcryptoResult.rows?.length ?? 0) > 0; + } catch { + // Extension catalog not accessible + } + + // Build encryption settings (excluding cert paths already extracted) + const encryptionSettings: Record = { + ssl: settings["ssl"], + password_encryption: passwordEncryption, + ssl_ciphers: settings["ssl_ciphers"], + ssl_min_protocol_version: settings["ssl_min_protocol_version"], + ssl_max_protocol_version: settings["ssl_max_protocol_version"], + }; + + return { + success: true, + sslEnabled, + passwordEncryption, + pgcryptoAvailable, + encryptionSettings, + certificates, + }; + } catch (err) { + if (err instanceof ZodError) { + return formatHandlerErrorResponse(err, { + tool: "pg_security_encryption_status", + }); + } + return formatHandlerErrorResponse(err, { + tool: "pg_security_encryption_status", + }); + } + }, + }; +} + +// ============================================================================= +// pg_security_password_validate +// ============================================================================= + +/** + * Common password patterns to check against + */ +const COMMON_PASSWORDS = new Set([ + "password", + "123456", + "12345678", + "qwerty", + "abc123", + "monkey", + "master", + "dragon", + "111111", + "baseball", + "iloveyou", + "trustno1", + "sunshine", + "letmein", + "welcome", + "admin", + "login", + "princess", + "football", + "shadow", +]); + +/** + * Validate password strength (pure JS, no database) + */ +export function createSecurityPasswordValidateTool( + _adapter: PostgresAdapter, +): ToolDefinition { + return { + name: "pg_security_password_validate", + description: + "Validate password strength against configurable policy. Uses local analysis (no database query).", + group: "security", + inputSchema: PasswordValidateSchemaBase, + outputSchema: PasswordValidateOutputSchema, + annotations: readOnly("Password Validate"), + icons: getToolIcons("security", readOnly("Password Validate")), + handler: (params: unknown, _context: RequestContext): Promise => { + try { + const { password } = PasswordValidateSchema.parse(params); + + if (password.length === 0) { + return Promise.resolve({ + success: false, + error: "Validation error: Password cannot be empty", + code: "VALIDATION_ERROR", + category: "validation", + recoverable: false, + }); + } + + const policy = { + minLength: 8, + requireUppercase: true, + requireLowercase: true, + requireDigit: true, + requireSpecial: true, + }; + + const checks: Record = { + minLength: password.length >= policy.minLength, + hasUppercase: /[A-Z]/.test(password), + hasLowercase: /[a-z]/.test(password), + hasDigit: /\d/.test(password), + hasSpecial: /[^A-Za-z0-9]/.test(password), + notCommon: !COMMON_PASSWORDS.has(password.toLowerCase()), + noRepeatingChars: !/(.)\1{2,}/.test(password), + noSequentialChars: !hasSequentialChars(password), + }; + + // Calculate strength score (0-100) + let strength = 0; + + // Length scoring (up to 30 points) + strength += Math.min(30, password.length * 3); + + // Character class scoring (up to 40 points) + if (checks["hasUppercase"]) strength += 10; + if (checks["hasLowercase"]) strength += 10; + if (checks["hasDigit"]) strength += 10; + if (checks["hasSpecial"]) strength += 10; + + // Penalty scoring (up to -30) + if (!checks["notCommon"]) strength -= 30; + if (!checks["noRepeatingChars"]) strength -= 10; + if (!checks["noSequentialChars"]) strength -= 10; + + // Bonus for length > 12 + if (password.length > 12) strength += 10; + if (password.length > 16) strength += 10; + + // Clamp to 0-100 + strength = Math.max(0, Math.min(100, strength)); + + let interpretation: string; + if (strength >= 80) interpretation = "Very Strong"; + else if (strength >= 60) interpretation = "Strong"; + else if (strength >= 40) interpretation = "Medium"; + else if (strength >= 20) interpretation = "Weak"; + else interpretation = "Very Weak"; + + return Promise.resolve({ + success: true, + strength, + interpretation, + meetsPolicy: strength >= 50, + policy, + checks, + }); + } catch (error) { + if (error instanceof ZodError) { + return Promise.resolve( + formatHandlerErrorResponse(error, { + tool: "pg_security_password_validate", + }), + ); + } + return Promise.resolve( + formatHandlerErrorResponse(error, { + tool: "pg_security_password_validate", + }), + ); + } + }, + }; +} + +/** + * Check for sequential character patterns (e.g., "abc", "123") + */ +function hasSequentialChars(password: string): boolean { + const lower = password.toLowerCase(); + for (let i = 0; i < lower.length - 2; i++) { + const c1 = lower.charCodeAt(i); + const c2 = lower.charCodeAt(i + 1); + const c3 = lower.charCodeAt(i + 2); + if (c2 === c1 + 1 && c3 === c2 + 1) return true; + if (c2 === c1 - 1 && c3 === c2 - 1) return true; + } + return false; +} diff --git a/src/adapters/postgresql/tools/security/index.ts b/src/adapters/postgresql/tools/security/index.ts new file mode 100644 index 00000000..1631d46c --- /dev/null +++ b/src/adapters/postgresql/tools/security/index.ts @@ -0,0 +1,59 @@ +/** + * PostgreSQL Security Tools + * + * Tools for security auditing, SSL monitoring, data masking, + * privilege analysis, and compliance. + * 9 tools total. + */ + +import type { PostgresAdapter } from "../../postgres-adapter.js"; +import type { ToolDefinition } from "../../../../types/index.js"; + +// Import from submodules +import { + createSecurityAuditTool, + createSecurityFirewallStatusTool, + createSecurityFirewallRulesTool, +} from "./audit.js"; + +import { + createSecuritySSLStatusTool, + createSecurityEncryptionStatusTool, + createSecurityPasswordValidateTool, +} from "./encryption.js"; + +import { + createSecurityMaskDataTool, + createSecurityUserPrivilegesTool, + createSecuritySensitiveTablesTool, +} from "./data-protection.js"; + +/** + * Get all security tools + */ +export function getSecurityTools(adapter: PostgresAdapter): ToolDefinition[] { + return [ + createSecurityAuditTool(adapter), + createSecurityFirewallStatusTool(adapter), + createSecurityFirewallRulesTool(adapter), + createSecurityMaskDataTool(adapter), + createSecurityPasswordValidateTool(adapter), + createSecuritySSLStatusTool(adapter), + createSecurityUserPrivilegesTool(adapter), + createSecuritySensitiveTablesTool(adapter), + createSecurityEncryptionStatusTool(adapter), + ]; +} + +// Re-export individual tool creators for direct imports +export { + createSecurityAuditTool, + createSecurityFirewallStatusTool, + createSecurityFirewallRulesTool, + createSecurityMaskDataTool, + createSecurityUserPrivilegesTool, + createSecuritySensitiveTablesTool, + createSecuritySSLStatusTool, + createSecurityEncryptionStatusTool, + createSecurityPasswordValidateTool, +}; diff --git a/src/adapters/postgresql/tools/stats/__tests__/stats.test.ts b/src/adapters/postgresql/tools/stats/__tests__/stats.test.ts index f6b88646..9da53a23 100644 --- a/src/adapters/postgresql/tools/stats/__tests__/stats.test.ts +++ b/src/adapters/postgresql/tools/stats/__tests__/stats.test.ts @@ -3751,8 +3751,8 @@ describe("pg_stats_frequency", () => { // Mock frequency data mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [ - { value: "active", frequency: "70", percentage: "70.00" }, - { value: "inactive", frequency: "30", percentage: "30.00" }, + { value: "active", count: "70", percentage: "70.00" }, + { value: "inactive", count: "30", percentage: "30.00" }, ], }); // Mock distinct count @@ -3770,7 +3770,7 @@ describe("pg_stats_frequency", () => { distinctValues: number; distribution: Array<{ value: string; - frequency: number; + count: number; percentage: number; }>; }; @@ -3779,7 +3779,7 @@ describe("pg_stats_frequency", () => { expect(result.column).toBe("status"); expect(result.distinctValues).toBe(2); expect(result.distribution).toHaveLength(2); - expect(result.distribution[0].frequency).toBe(70); + expect(result.distribution[0].count).toBe(70); expect(result.distribution[0].percentage).toBe(70); }); }); @@ -3797,6 +3797,10 @@ describe("pg_stats_summary", () => { }); it("should return summary statistics for specified columns", async () => { + // Mock table existence check + mockAdapter.executeQuery.mockResolvedValueOnce({ + rows: [{ "?column?": 1 }], + }); // Mock column validation mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [ @@ -3841,6 +3845,10 @@ describe("pg_stats_summary", () => { }); it("should auto-detect numeric columns when none specified", async () => { + // Mock table existence check + mockAdapter.executeQuery.mockResolvedValueOnce({ + rows: [{ "?column?": 1 }], + }); // Mock column discovery mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [{ column_name: "price" }, { column_name: "quantity" }], @@ -3877,6 +3885,10 @@ describe("pg_stats_summary", () => { }); it("should throw validation error when explicitly specified column is not numeric", async () => { + // Mock table existence check + mockAdapter.executeQuery.mockResolvedValueOnce({ + rows: [{ "?column?": 1 }], + }); mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [ { column_name: "price", data_type: "numeric" }, @@ -3895,6 +3907,10 @@ describe("pg_stats_summary", () => { }); it("should throw validation error when explicitly specified column does not exist", async () => { + // Mock table existence check + mockAdapter.executeQuery.mockResolvedValueOnce({ + rows: [{ "?column?": 1 }], + }); mockAdapter.executeQuery.mockResolvedValueOnce({ rows: [{ column_name: "price", data_type: "numeric" }], }); diff --git a/src/adapters/postgresql/tools/stats/advanced.ts b/src/adapters/postgresql/tools/stats/advanced.ts index e66b46cc..06e9360b 100644 --- a/src/adapters/postgresql/tools/stats/advanced.ts +++ b/src/adapters/postgresql/tools/stats/advanced.ts @@ -76,7 +76,7 @@ export function createStatsTopNTool(adapter: PostgresAdapter): ToolDefinition { const parsed = StatsTopNSchema.parse(params) as { table: string; column: string; - n?: number; + n?: number | string; direction?: "asc" | "desc"; selectColumns?: string[]; schema?: string; @@ -84,14 +84,12 @@ export function createStatsTopNTool(adapter: PostgresAdapter): ToolDefinition { }; const { table, column, schema, where, selectColumns } = parsed; - const n = - parsed.n === undefined || Number.isNaN(parsed.n) ? 10 : parsed.n; + const nRaw = parsed.n !== undefined ? Number(parsed.n) : 10; + const n = Number.isNaN(nRaw) ? 10 : nRaw; if (n <= 0) { throw new ValidationError("Parameter 'n' must be greater than 0."); } - if (n > 100) { - throw new ValidationError("Parameter 'n' cannot exceed 100."); - } + const finalN = Math.min(n, 100); const direction = parsed.direction ?? "desc"; const schemaName = schema ?? "public"; const schemaPrefix = schema ? `"${schema}".` : ""; @@ -146,7 +144,7 @@ export function createStatsTopNTool(adapter: PostgresAdapter): ToolDefinition { FROM ${schemaPrefix}"${table}" ${whereClause} ORDER BY "${column}" ${direction.toUpperCase()} - LIMIT ${String(n)} + LIMIT ${String(finalN)} `; const result = await adapter.executeQuery(sql); @@ -198,22 +196,19 @@ export function createStatsDistinctTool( column: string; schema?: string; where?: string; - limit?: number; + limit?: number | string; }; const { table, column, schema, where } = parsed; - const limit = - parsed.limit === undefined || Number.isNaN(parsed.limit) - ? 100 - : parsed.limit; + const limitRaw = + parsed.limit !== undefined ? Number(parsed.limit) : 100; + const limit = Number.isNaN(limitRaw) ? 100 : limitRaw; if (limit <= 0) { throw new ValidationError( "Parameter 'limit' must be greater than 0.", ); } - if (limit > 1000) { - throw new ValidationError("Parameter 'limit' cannot exceed 1000."); - } + const finalLimit = Math.min(limit, 1000); const schemaPrefix = schema ? `"${schema}".` : ""; const whereClause = where ? `WHERE ${sanitizeWhereClause(where)}` : ""; @@ -222,7 +217,7 @@ export function createStatsDistinctTool( FROM ${schemaPrefix}"${table}" ${whereClause} ORDER BY "${column}" - LIMIT ${String(limit)} + LIMIT ${String(finalLimit)} `; const result = await adapter.executeQuery(sql); @@ -279,41 +274,37 @@ export function createStatsFrequencyTool( column: string; schema?: string; where?: string; - limit?: number; + limit?: number | string; }; const { table, column, schema, where } = parsed; - const limit = - parsed.limit === undefined || Number.isNaN(parsed.limit) - ? 20 - : parsed.limit; + const limitRaw = parsed.limit !== undefined ? Number(parsed.limit) : 20; + const limit = Number.isNaN(limitRaw) ? 20 : limitRaw; if (limit <= 0) { throw new ValidationError( "Parameter 'limit' must be greater than 0.", ); } - if (limit > 1000) { - throw new ValidationError("Parameter 'limit' cannot exceed 1000."); - } + const finalLimit = Math.min(limit, 1000); const schemaPrefix = schema ? `"${schema}".` : ""; const whereClause = where ? `WHERE ${sanitizeWhereClause(where)}` : ""; const sql = ` SELECT "${column}" AS value, - COUNT(*) AS frequency, + COUNT(*) AS count, ROUND(COUNT(*)::numeric * 100.0 / SUM(COUNT(*)) OVER(), 2) AS percentage FROM ${schemaPrefix}"${table}" ${whereClause} GROUP BY "${column}" ORDER BY COUNT(*) DESC - LIMIT ${String(limit)} + LIMIT ${String(finalLimit)} `; const result = await adapter.executeQuery(sql); const distribution = (result.rows ?? []).map((row) => ({ value: row["value"], - frequency: Number(row["frequency"]), + count: Number(row["count"]), percentage: Number(row["percentage"]), })); @@ -373,6 +364,17 @@ export function createStatsSummaryTool( const schemaPrefix = schema ? `"${schema}".` : ""; const whereClause = where ? `WHERE ${sanitizeWhereClause(where)}` : ""; + // Verify table exists first to comply with P154 + const tableCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2`, + [schemaName, table], + ); + if (!tableCheck.rows || tableCheck.rows.length === 0) { + throw new ValidationError( + `Table "${schemaName}.${table}" does not exist`, + ); + } + // Determine columns to summarize let targetColumns: string[]; @@ -434,19 +436,6 @@ export function createStatsSummaryTool( } if (targetColumns.length === 0) { - // Check if table actually exists or if it just has no numeric columns - if (!parsed.columns || parsed.columns.length === 0) { - const tableCheck = await adapter.executeQuery( - `SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2`, - [schemaName, table], - ); - if (!tableCheck.rows || tableCheck.rows.length === 0) { - throw new ValidationError( - `Table "${schemaName}.${table}" does not exist`, - ); - } - } - return { success: true, table: `${schemaName}.${table}`, diff --git a/src/adapters/postgresql/tools/stats/basic.ts b/src/adapters/postgresql/tools/stats/basic.ts index b4cf9f84..34b95163 100644 --- a/src/adapters/postgresql/tools/stats/basic.ts +++ b/src/adapters/postgresql/tools/stats/basic.ts @@ -142,6 +142,7 @@ export function createStatsCorrelationTool( })); return { + success: true, table: `${schema ?? "public"}.${table}`, columns: [column1, column2], groupBy, @@ -172,6 +173,7 @@ export function createStatsCorrelationTool( if (!row) throw new ValidationError("No correlation data found"); const response: Record = { + success: true, table: `${schema ?? "public"}.${table}`, columns: [column1, column2], ...mapCorrelation(row), @@ -302,6 +304,7 @@ export function createStatsRegressionTool( })); return { + success: true, table: `${schema ?? "public"}.${table}`, xColumn, yColumn, @@ -338,6 +341,7 @@ export function createStatsRegressionTool( if (!row) throw new ValidationError("No regression data found"); const response: Record = { + success: true, table: `${schema ?? "public"}.${table}`, xColumn, yColumn, diff --git a/src/adapters/postgresql/tools/stats/descriptive.ts b/src/adapters/postgresql/tools/stats/descriptive.ts index 9be33cb3..5e2e8ff5 100644 --- a/src/adapters/postgresql/tools/stats/descriptive.ts +++ b/src/adapters/postgresql/tools/stats/descriptive.ts @@ -123,6 +123,7 @@ export function createStatsDescriptiveTool( min: number | null; max: number | null; avg: number | null; + mean: number | null; stddev: number | null; variance: number | null; sum: number | null; @@ -132,6 +133,7 @@ export function createStatsDescriptiveTool( min: row["min"] !== null ? Number(row["min"]) : null, max: row["max"] !== null ? Number(row["max"]) : null, avg: row["avg"] !== null ? Number(row["avg"]) : null, + mean: row["avg"] !== null ? Number(row["avg"]) : null, stddev: row["stddev"] !== null ? Number(row["stddev"]) : null, variance: row["variance"] !== null ? Number(row["variance"]) : null, sum: row["sum"] !== null ? Number(row["sum"]) : null, diff --git a/src/adapters/postgresql/tools/stats/distribution.ts b/src/adapters/postgresql/tools/stats/distribution.ts index 6e0d05ed..7dff22ed 100644 --- a/src/adapters/postgresql/tools/stats/distribution.ts +++ b/src/adapters/postgresql/tools/stats/distribution.ts @@ -263,6 +263,7 @@ export function createStatsDistributionTool( // Build response with truncation indicators const response: Record = { + success: true, table: `${schema ?? "public"}.${table}`, column, groupBy, @@ -294,6 +295,7 @@ export function createStatsDistributionTool( const histogram = await generateHistogram(minVal, maxVal); return { + success: true, table: `${schema ?? "public"}.${table}`, column, range: { min: minVal, max: maxVal }, diff --git a/src/adapters/postgresql/tools/stats/hypothesis.ts b/src/adapters/postgresql/tools/stats/hypothesis.ts index a392a035..ad74be8c 100644 --- a/src/adapters/postgresql/tools/stats/hypothesis.ts +++ b/src/adapters/postgresql/tools/stats/hypothesis.ts @@ -202,6 +202,7 @@ export function createStatsHypothesisTool( }); return { + success: true, table: `${schema ?? "public"}.${table}`, column, testType, @@ -249,6 +250,7 @@ export function createStatsHypothesisTool( } return { + success: true, table: `${schema ?? "public"}.${table}`, column, testType, diff --git a/src/adapters/postgresql/tools/stats/sampling.ts b/src/adapters/postgresql/tools/stats/sampling.ts index 15efe0c1..8c9724dd 100644 --- a/src/adapters/postgresql/tools/stats/sampling.ts +++ b/src/adapters/postgresql/tools/stats/sampling.ts @@ -151,6 +151,7 @@ export function createStatsSamplingTool( } const response: { + success: boolean; table: string; method: string; sampleSize: number; @@ -159,6 +160,7 @@ export function createStatsSamplingTool( totalSampled?: number; note?: string; } = { + success: true, table: `${schema ?? "public"}.${table}`, method: samplingMethod, sampleSize: rows.length, diff --git a/src/adapters/postgresql/tools/stats/time-series.ts b/src/adapters/postgresql/tools/stats/time-series.ts index e12cdb63..206a08e1 100644 --- a/src/adapters/postgresql/tools/stats/time-series.ts +++ b/src/adapters/postgresql/tools/stats/time-series.ts @@ -298,6 +298,7 @@ export function createStatsTimeSeriesTool( // Build response with truncation indicators const response: Record = { + success: true, table: `${schema ?? "public"}.${table}`, valueColumn, timeColumn, @@ -368,6 +369,7 @@ export function createStatsTimeSeriesTool( // Build response const response: Record = { + success: true, table: `${schema ?? "public"}.${table}`, valueColumn, timeColumn, diff --git a/src/adapters/postgresql/tools/stats/window.ts b/src/adapters/postgresql/tools/stats/window.ts index 32aaf93b..2b842a67 100644 --- a/src/adapters/postgresql/tools/stats/window.ts +++ b/src/adapters/postgresql/tools/stats/window.ts @@ -77,8 +77,8 @@ function resolveLimit(limit?: number): number { if (limit <= 0) { throw new ValidationError("Parameter 'limit' must be greater than 0."); } - if (limit > 100) { - throw new ValidationError("Parameter 'limit' cannot exceed 100."); + if (limit > 1000) { + throw new ValidationError("Parameter 'limit' cannot exceed 1000."); } return limit; } diff --git a/src/adapters/postgresql/tools/text/fts.ts b/src/adapters/postgresql/tools/text/fts.ts index edba4d0e..f2e04ddd 100644 --- a/src/adapters/postgresql/tools/text/fts.ts +++ b/src/adapters/postgresql/tools/text/fts.ts @@ -10,7 +10,7 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; + import { readOnly, write } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; @@ -26,7 +26,12 @@ import { buildLimitClause } from "../../../../utils/query-helpers.js"; import { TextSearchSchema, TextSearchSchemaBase, - preprocessTextParams, + TextRankSchema, + TextRankSchemaBase, + HeadlineSchema, + HeadlineSchemaBase, + FtsIndexSchema, + FtsIndexSchemaBase, // Output schemas TextRowsOutputSchema, FtsIndexOutputSchema, @@ -98,7 +103,7 @@ export function createTextSearchTool(adapter: PostgresAdapter): ToolDefinition { const tsvector = sanitizedCols .map((c) => `coalesce(${c}, '')`) .join(" || ' ' || "); - const safeLimit = parsed.limit as number | undefined; + const safeLimit = parsed.limit; let limitVal = 100; if (safeLimit !== undefined) { if (safeLimit < 0) { @@ -129,6 +134,7 @@ export function createTextSearchTool(adapter: PostgresAdapter): ToolDefinition { const count = result.rows?.length ?? 0; const truncated = limitVal !== null && count === limitVal; return { + success: true, rows: result.rows, count, ...(truncated @@ -152,26 +158,6 @@ export function createTextSearchTool(adapter: PostgresAdapter): ToolDefinition { // ============================================================================= export function createTextRankTool(adapter: PostgresAdapter): ToolDefinition { - // Base schema for MCP visibility (no preprocess) - const TextRankSchemaBase = z.object({ - table: z.string().optional().describe("Table name"), - tableName: z.string().optional().describe("Table name (alias for table)"), - column: z.string().optional().describe("Single column to search"), - columns: z - .array(z.string()) - .optional() - .describe("Multiple columns to search (alternative to column)"), - query: z.string().optional(), - config: z.string().optional(), - normalization: z.any().optional(), - select: z.array(z.string()).optional().describe("Columns to return"), - limit: z.any().optional().describe("Max results"), - schema: z.string().optional().describe("Schema name (default: public)"), - }); - - // Full schema with preprocess for handler parsing - const TextRankSchema = z.preprocess(preprocessTextParams, TextRankSchemaBase); - return { name: "pg_text_rank", description: @@ -217,7 +203,7 @@ export function createTextRankTool(adapter: PostgresAdapter): ToolDefinition { const tsvector = sanitizedCols .map((c) => `coalesce(${c}, '')`) .join(" || ' ' || "); - const safeLimit = parsed.limit as number | undefined; + const safeLimit = parsed.limit; let limitVal = 100; if (safeLimit !== undefined) { if (safeLimit < 0) { @@ -248,6 +234,7 @@ export function createTextRankTool(adapter: PostgresAdapter): ToolDefinition { const count = result.rows?.length ?? 0; const truncated = limitVal !== null && count === limitVal; return { + success: true, rows: result.rows, count, ...(truncated @@ -273,40 +260,6 @@ export function createTextRankTool(adapter: PostgresAdapter): ToolDefinition { export function createTextHeadlineTool( adapter: PostgresAdapter, ): ToolDefinition { - // Base schema for MCP visibility (no preprocess) - const HeadlineSchemaBase = z.object({ - table: z.string().optional().describe("Table name"), - tableName: z.string().optional().describe("Table name (alias for table)"), - column: z.string().optional(), - query: z.string().optional(), - config: z.string().optional(), - options: z - .string() - .optional() - .describe( - 'Headline options (e.g., "MaxWords=20, MinWords=5"). Note: MinWords must be < MaxWords.', - ), - startSel: z - .string() - .optional() - .describe("Start selection marker (default: )"), - stopSel: z - .string() - .optional() - .describe("Stop selection marker (default: )"), - maxWords: z.any().optional().describe("Maximum words in headline"), - minWords: z.any().optional().describe("Minimum words in headline"), - select: z - .array(z.string()) - .optional() - .describe('Columns to return for row identification (e.g., ["id"])'), - limit: z.any().optional().describe("Max results"), - schema: z.string().optional().describe("Schema name (default: public)"), - }); - - // Full schema with preprocess for handler parsing - const HeadlineSchema = z.preprocess(preprocessTextParams, HeadlineSchemaBase); - return { name: "pg_text_headline", description: @@ -366,7 +319,7 @@ export function createTextHeadlineTool( parsed.select !== undefined && parsed.select.length > 0 ? sanitizeIdentifiers(parsed.select).join(", ") + ", " : ""; - const safeLimit = parsed.limit as number | undefined; + const safeLimit = parsed.limit; let limitVal = 100; if (safeLimit !== undefined) { if (safeLimit < 0) { @@ -396,6 +349,7 @@ export function createTextHeadlineTool( const count = result.rows?.length ?? 0; const truncated = limitVal !== null && count === limitVal; return { + success: true, rows: result.rows, count, ...(truncated @@ -419,23 +373,6 @@ export function createTextHeadlineTool( // ============================================================================= export function createFtsIndexTool(adapter: PostgresAdapter): ToolDefinition { - // Base schema for MCP visibility (no preprocess) - const FtsIndexSchemaBase = z.object({ - table: z.string().optional().describe("Table name"), - tableName: z.string().optional().describe("Table name (alias for table)"), - column: z.string().optional(), - name: z.string().optional(), - config: z.string().optional(), - ifNotExists: z - .boolean() - .optional() - .describe("Skip if index already exists (default: true)"), - schema: z.string().optional().describe("Schema name (default: public)"), - }); - - // Full schema with preprocess for handler parsing - const FtsIndexSchema = z.preprocess(preprocessTextParams, FtsIndexSchemaBase); - return { name: "pg_create_fts_index", description: "Create a GIN index for full-text search on a column.", diff --git a/src/adapters/postgresql/tools/text/matching.ts b/src/adapters/postgresql/tools/text/matching.ts index fa13385e..b14dfa63 100644 --- a/src/adapters/postgresql/tools/text/matching.ts +++ b/src/adapters/postgresql/tools/text/matching.ts @@ -10,7 +10,7 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; + import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; @@ -27,14 +27,12 @@ import { TrigramSimilaritySchemaBase, RegexpMatchSchema, RegexpMatchSchemaBase, - preprocessTextParams, + FuzzyMatchSchema, + FuzzyMatchSchemaBase, // Output schemas TextRowsOutputSchema, } from "../../schemas/index.js"; -// Fuzzy match method type (validated by zod enum in schema) -type FuzzyMethod = "levenshtein" | "soundex" | "metaphone"; - // ============================================================================= // pg_trigram_similarity // ============================================================================= @@ -62,7 +60,13 @@ export function createTrigramSimilarityTool( : isNaN(rawThresh) ? 0.3 : rawThresh; - const safeLimit = parsed.limit as number | undefined; + + if (thresh < 0 || thresh > 1) { + throw new ValidationError("threshold must be between 0 and 1", { + code: "VALIDATION_ERROR", + }); + } + const safeLimit = parsed.limit; let limitVal = 100; if (safeLimit !== undefined) { if (safeLimit < 0) { @@ -113,6 +117,7 @@ export function createTrigramSimilarityTool( const count = result.rows?.length ?? 0; const truncated = limitVal !== null && count === limitVal; return { + success: true, rows: result.rows, count, ...(truncated @@ -136,39 +141,6 @@ export function createTrigramSimilarityTool( // ============================================================================= export function createFuzzyMatchTool(adapter: PostgresAdapter): ToolDefinition { - // Base schema for MCP visibility (no preprocess) - const FuzzyMatchSchemaBase = z.object({ - table: z.string().optional().describe("Table name"), - tableName: z.string().optional().describe("Table name (alias for table)"), - column: z.string().optional(), - value: z.string().optional(), - method: z - .string() - .optional() - .describe( - "Fuzzy match method (default: levenshtein). Valid: soundex, levenshtein, metaphone", - ), - maxDistance: z - .any() - .optional() - .describe( - "Max Levenshtein distance (default: 3, use 5+ for longer strings)", - ), - select: z.array(z.string()).optional().describe("Columns to return"), - limit: z - .any() - .optional() - .describe("Max results (default: 100 to prevent large payloads)"), - where: z.string().optional().describe("Additional WHERE clause filter"), - schema: z.string().optional().describe("Schema name (default: public)"), - }); - - // Full schema with preprocess for handler parsing - const FuzzyMatchSchema = z.preprocess( - preprocessTextParams, - FuzzyMatchSchemaBase, - ); - return { name: "pg_fuzzy_match", description: @@ -183,18 +155,19 @@ export function createFuzzyMatchTool(adapter: PostgresAdapter): ToolDefinition { const parsed = FuzzyMatchSchema.parse(params); // Validate method (moved from z.enum to handler for structured error) - const VALID_METHODS: FuzzyMethod[] = [ + const VALID_METHODS = [ "levenshtein", + "damerau-levenshtein", "soundex", "metaphone", ]; const rawMethod = parsed.method ?? "levenshtein"; - if (!VALID_METHODS.includes(rawMethod as FuzzyMethod)) { + if (!VALID_METHODS.includes(rawMethod)) { throw new ValidationError( `Invalid method "${rawMethod}". Valid methods: ${VALID_METHODS.join(", ")}`, ); } - const method: FuzzyMethod = rawMethod as FuzzyMethod; + const method = rawMethod; const rawMaxDist = Number(parsed.maxDistance); const maxDist = @@ -203,7 +176,7 @@ export function createFuzzyMatchTool(adapter: PostgresAdapter): ToolDefinition { : isNaN(rawMaxDist) ? 3 : rawMaxDist; - const safeLimit = parsed.limit as number | undefined; + const safeLimit = parsed.limit; let limitVal = 100; if (safeLimit !== undefined) { if (safeLimit < 0) { @@ -250,6 +223,8 @@ export function createFuzzyMatchTool(adapter: PostgresAdapter): ToolDefinition { sql = `SELECT ${selectCols}, soundex(${columnName}) as code FROM ${tableName} WHERE soundex(${columnName}) = soundex($1)${additionalWhere}${limitClause}`; } else if (method === "metaphone") { sql = `SELECT ${selectCols}, metaphone(${columnName}, 10) as code FROM ${tableName} WHERE metaphone(${columnName}, 10) = metaphone($1, 10)${additionalWhere}${limitClause}`; + } else if (method === "damerau-levenshtein") { + sql = `SELECT ${selectCols}, levenshtein_less_equal(${columnName}, $1, ${String(maxDist)}) as distance FROM ${tableName} WHERE levenshtein_less_equal(${columnName}, $1, ${String(maxDist)}) <= ${String(maxDist)}${additionalWhere} ORDER BY distance${limitClause}`; } else { sql = `SELECT ${selectCols}, levenshtein(${columnName}, $1) as distance FROM ${tableName} WHERE levenshtein(${columnName}, $1) <= ${String(maxDist)}${additionalWhere} ORDER BY distance${limitClause}`; } @@ -258,6 +233,7 @@ export function createFuzzyMatchTool(adapter: PostgresAdapter): ToolDefinition { const count = result.rows?.length ?? 0; const truncated = limitVal !== null && count === limitVal; return { + success: true, rows: result.rows, count, ...(truncated @@ -315,7 +291,7 @@ export function createRegexpMatchTool( const additionalWhere = parsed.where ? ` AND (${sanitizeWhereClause(parsed.where)})` : ""; - const safeLimit = parsed.limit as number | undefined; + const safeLimit = parsed.limit; let limitVal = 100; if (safeLimit !== undefined) { if (safeLimit < 0) { @@ -342,6 +318,7 @@ export function createRegexpMatchTool( const count = result.rows?.length ?? 0; const truncated = limitVal !== null && count === limitVal; return { + success: true, rows: result.rows, count, ...(truncated diff --git a/src/adapters/postgresql/tools/text/search-tools.ts b/src/adapters/postgresql/tools/text/search-tools.ts index 07527ef0..eb5d1c5c 100644 --- a/src/adapters/postgresql/tools/text/search-tools.ts +++ b/src/adapters/postgresql/tools/text/search-tools.ts @@ -10,7 +10,7 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; + import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; @@ -23,7 +23,10 @@ import { import { sanitizeWhereClause } from "../../../../utils/where-clause.js"; import { buildLimitClause } from "../../../../utils/query-helpers.js"; import { - preprocessTextParams, + LikeSearchSchema, + LikeSearchSchemaBase, + SentimentSchema, + SentimentSchemaBase, // Output schemas TextRowsOutputSchema, TextSentimentOutputSchema, @@ -34,31 +37,6 @@ import { // ============================================================================= export function createLikeSearchTool(adapter: PostgresAdapter): ToolDefinition { - // Base schema for MCP visibility (no preprocess) - const LikeSearchSchemaBase = z.object({ - table: z.string().optional().describe("Table name"), - tableName: z.string().optional().describe("Table name (alias for table)"), - column: z.string().optional(), - pattern: z.string().optional(), - caseSensitive: z - .boolean() - .optional() - .describe("Use case-sensitive LIKE (default: false, uses ILIKE)"), - select: z.array(z.string()).optional(), - limit: z - .any() - .optional() - .describe("Max results (default: 100 to prevent large payloads)"), - where: z.string().optional().describe("Additional WHERE clause filter"), - schema: z.string().optional().describe("Schema name (default: public)"), - }); - - // Full schema with preprocess for handler parsing - const LikeSearchSchema = z.preprocess( - preprocessTextParams, - LikeSearchSchemaBase, - ); - return { name: "pg_like_search", description: @@ -75,23 +53,13 @@ export function createLikeSearchTool(adapter: PostgresAdapter): ToolDefinition { // The preprocessor guarantees table is set (converts tableName β†’ table) const resolvedTable = parsed.table ?? parsed.tableName; if (!resolvedTable) { - return { - success: false, - error: "Either 'table' or 'tableName' is required", - code: "VALIDATION_ERROR", - category: "validation", - recoverable: false, - }; + throw new ValidationError( + "Either 'table' or 'tableName' is required", + ); } const tableName = sanitizeTableName(resolvedTable, parsed.schema); if (!parsed.column || !parsed.pattern) { - return { - success: false, - error: "column and pattern are required", - code: "VALIDATION_ERROR", - category: "validation", - recoverable: false, - }; + throw new ValidationError("column and pattern are required"); } const columnName = sanitizeIdentifier(parsed.column); const selectCols = @@ -102,7 +70,7 @@ export function createLikeSearchTool(adapter: PostgresAdapter): ToolDefinition { const additionalWhere = parsed.where ? ` AND (${sanitizeWhereClause(parsed.where)})` : ""; - const safeLimit = parsed.limit as number | undefined; + const safeLimit = parsed.limit; let limitVal = 100; if (safeLimit !== undefined) { if (safeLimit < 0) { @@ -129,6 +97,7 @@ export function createLikeSearchTool(adapter: PostgresAdapter): ToolDefinition { const count = result.rows?.length ?? 0; const truncated = limitVal !== null && count === limitVal; return { + success: true, rows: result.rows, count, ...(truncated @@ -157,25 +126,6 @@ export function createLikeSearchTool(adapter: PostgresAdapter): ToolDefinition { export function createTextSentimentTool( _adapter: PostgresAdapter, ): ToolDefinition { - const SentimentSchemaBase = z.object({ - text: z.string().optional().describe("Text to analyze"), - returnWords: z - .boolean() - .optional() - .describe("Return matched sentiment words"), - }); - - const SentimentSchema = z.object({ - text: z - .string() - .min(1, "Text must not be empty") - .describe("Text to analyze"), - returnWords: z - .boolean() - .optional() - .describe("Return matched sentiment words"), - }); - return { name: "pg_text_sentiment", description: @@ -188,6 +138,9 @@ export function createTextSentimentTool( handler: (params: unknown, _context: RequestContext) => { try { const parsed = SentimentSchema.parse(params ?? {}); + if (!parsed.text || parsed.text.trim().length === 0) { + throw new ValidationError("Text must not be empty"); + } const text = parsed.text.toLowerCase(); const positiveWords = [ @@ -287,7 +240,7 @@ export function createTextSentimentTool( result.matchedNegative = matchedNegative; } - return Promise.resolve(result); + return Promise.resolve({ success: true, ...result }); } catch (error: unknown) { return Promise.resolve( formatHandlerErrorResponse(error, { diff --git a/src/adapters/postgresql/tools/text/search.ts b/src/adapters/postgresql/tools/text/search.ts index 18e30d4c..85df1e90 100644 --- a/src/adapters/postgresql/tools/text/search.ts +++ b/src/adapters/postgresql/tools/text/search.ts @@ -10,7 +10,7 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; + import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; @@ -20,6 +20,13 @@ import { TextToVectorOutputSchema, TextToQueryOutputSchema, TextSearchConfigOutputSchema, + NormalizeSchema, + NormalizeSchemaBase, + ToVectorSchema, + ToVectorSchemaBase, + ToQuerySchema, + ToQuerySchemaBase, + TextSearchConfigSchemaBase, } from "../../schemas/index.js"; // ============================================================================= @@ -29,14 +36,6 @@ import { export function createTextNormalizeTool( adapter: PostgresAdapter, ): ToolDefinition { - const NormalizeSchemaBase = z.object({ - text: z.string().optional().describe("Text to remove accent marks from"), - }); - - const NormalizeSchema = z.object({ - text: z.string().describe("Text to remove accent marks from"), - }); - return { name: "pg_text_normalize", description: @@ -57,7 +56,7 @@ export function createTextNormalizeTool( `SELECT unaccent($1) as normalized`, [parsed.text], ); - return { normalized: result.rows?.[0]?.["normalized"] }; + return { success: true, normalized: result.rows?.[0]?.["normalized"] }; } catch (error: unknown) { return formatHandlerErrorResponse(error, { tool: "pg_text_normalize", @@ -74,22 +73,6 @@ export function createTextNormalizeTool( export function createTextToVectorTool( adapter: PostgresAdapter, ): ToolDefinition { - const ToVectorSchemaBase = z.object({ - text: z.string().optional().describe("Text to convert to tsvector"), - config: z - .string() - .optional() - .describe("Text search configuration (default: english)"), - }); - - const ToVectorSchema = z.object({ - text: z.string().describe("Text to convert to tsvector"), - config: z - .string() - .optional() - .describe("Text search configuration (default: english)"), - }); - return { name: "pg_text_to_vector", description: @@ -108,7 +91,7 @@ export function createTextToVectorTool( `SELECT to_tsvector($1, $2) as vector`, [cfg, parsed.text], ); - return { vector: result.rows?.[0]?.["vector"] }; + return { success: true, vector: result.rows?.[0]?.["vector"] }; } catch (error: unknown) { return formatHandlerErrorResponse(error, { tool: "pg_text_to_vector", @@ -125,34 +108,6 @@ export function createTextToVectorTool( export function createTextToQueryTool( adapter: PostgresAdapter, ): ToolDefinition { - const ToQuerySchemaBase = z.object({ - text: z.string().optional().describe("Text to convert to tsquery"), - config: z - .string() - .optional() - .describe("Text search configuration (default: english)"), - mode: z - .string() - .optional() - .describe( - "Query parsing mode: plain (default), phrase (proximity), websearch (Google-like)", - ), - }); - - const ToQuerySchema = z.object({ - text: z.string().describe("Text to convert to tsquery"), - config: z - .string() - .optional() - .describe("Text search configuration (default: english)"), - mode: z - .enum(["plain", "phrase", "websearch"]) - .optional() - .describe( - "Query parsing mode: plain (default), phrase (proximity), websearch (Google-like)", - ), - }); - return { name: "pg_text_to_query", description: @@ -184,7 +139,7 @@ export function createTextToQueryTool( `SELECT ${fn}($1, $2) as query`, [cfg, parsed.text], ); - return { query: result.rows?.[0]?.["query"], mode }; + return { success: true, query: result.rows?.[0]?.["query"], mode }; } catch (error: unknown) { return formatHandlerErrorResponse(error, { tool: "pg_text_to_query", @@ -206,12 +161,13 @@ export function createTextSearchConfigTool( description: "List available full-text search configurations (e.g., english, german, simple).", group: "text", - inputSchema: z.object({}).strict().default({}), + inputSchema: TextSearchConfigSchemaBase, outputSchema: TextSearchConfigOutputSchema, annotations: readOnly("Search Configurations"), icons: getToolIcons("text", readOnly("Search Configurations")), - handler: async (_params: unknown, _context: RequestContext) => { + handler: async (params: unknown, _context: RequestContext) => { try { + TextSearchConfigSchemaBase.parse(params ?? {}); const result = await adapter.executeQuery(` SELECT c.cfgname as name, @@ -222,6 +178,7 @@ export function createTextSearchConfigTool( ORDER BY c.cfgname `); return { + success: true, configs: result.rows ?? [], count: result.rows?.length ?? 0, }; diff --git a/src/adapters/postgresql/tools/transactions.ts b/src/adapters/postgresql/tools/transactions.ts index a446f6af..904204d6 100644 --- a/src/adapters/postgresql/tools/transactions.ts +++ b/src/adapters/postgresql/tools/transactions.ts @@ -318,7 +318,8 @@ function createTransactionExecuteTool( }); } - const { statements, transactionId, isolationLevel, read_only } = parsed; + const { statements, transactionId, isolationLevel, read_only, limit } = + parsed; // Check if joining an existing transaction or creating a new one const isJoiningExisting = transactionId !== undefined; @@ -352,7 +353,8 @@ function createTransactionExecuteTool( : (result.rowsAffected ?? 0), rowCount: result.rows?.length ?? 0, // Include returned rows when using RETURNING clause - ...(result.rows && result.rows.length > 0 && { rows: result.rows }), + ...(result.rows && + result.rows.length > 0 && { rows: result.rows.slice(0, limit) }), }); } diff --git a/src/adapters/postgresql/tools/vector/__tests__/vector.test.ts b/src/adapters/postgresql/tools/vector/__tests__/vector.test.ts index 06da8e43..68e4d124 100644 --- a/src/adapters/postgresql/tools/vector/__tests__/vector.test.ts +++ b/src/adapters/postgresql/tools/vector/__tests__/vector.test.ts @@ -457,7 +457,7 @@ describe("Bug Fixes", () => { )) as Record; expect(result.targetDimensions).toBe(2); - expect(result.reduced).toBeDefined(); + expect(result.reducedVector).toBeDefined(); }); it("should work with targetDimensions directly", async () => { @@ -471,7 +471,7 @@ describe("Bug Fixes", () => { )) as Record; expect(result.targetDimensions).toBe(3); - expect((result.reduced as number[]).length).toBe(3); + expect((result.reducedVector as number[]).length).toBe(3); }); it("should return structured error when neither targetDimensions nor dimensions provided", async () => { @@ -2431,7 +2431,7 @@ describe("Coverage: Advanced Tool Edge Cases", () => { mockContext, )) as Record; expect(result.mode).toBe("table"); - expect(result.processedCount).toBe(2); + expect(result.rowsProcessed).toBe(2); }); it("should handle empty table in table mode", async () => { @@ -2457,7 +2457,7 @@ describe("Coverage: Advanced Tool Edge Cases", () => { { table: "t", column: "vec", targetDimensions: 5 }, mockContext, )) as Record; - expect(result.processedCount).toBe(0); + expect(result.rowsProcessed).toBe(0); }); it("should return error when neither vector nor table provided", async () => { diff --git a/src/adapters/postgresql/tools/vector/aggregate.ts b/src/adapters/postgresql/tools/vector/aggregate.ts index b37f54cf..20df2a6f 100644 --- a/src/adapters/postgresql/tools/vector/aggregate.ts +++ b/src/adapters/postgresql/tools/vector/aggregate.ts @@ -23,6 +23,7 @@ import { VectorAggregateOutputSchema, VectorValidateOutputSchema, } from "../../schemas/index.js"; +import { coerceNumber } from "../../../../utils/query-helpers.js"; export function createVectorAggregateTool( adapter: PostgresAdapter, @@ -44,6 +45,9 @@ export function createVectorAggregateTool( .boolean() .optional() .describe("Truncate large vectors to preview (default: true)"), + limit: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Max groups to return (default: 100)"), }); // Transformed schema applies alias resolution @@ -55,6 +59,7 @@ export function createVectorAggregateTool( schema: data.schema, excludeNullGroups: data.excludeNullGroups, summarizeVector: data.summarizeVector ?? true, + limit: data.limit, })); return { @@ -156,10 +161,14 @@ export function createVectorAggregateTool( "groupBy only supports simple column names (not expressions like LOWER(column)). Use a direct column reference.", }; } + const limitClause = + parsed.limit !== undefined + ? ` LIMIT ${String(parsed.limit)}` + : ` LIMIT 100`; const sql = `SELECT ${groupByCol} as group_key, avg(${columnName})::text as average_vector, count(*):: integer as count FROM ${tableName}${whereClause} GROUP BY ${groupByCol} - ORDER BY ${groupByCol} `; + ORDER BY ${groupByCol}${limitClause}`; const result = await adapter.executeQuery(sql); let groups = @@ -269,7 +278,9 @@ export function createVectorValidateTool( column: z.string().optional().describe("Vector column"), col: z.string().optional().describe("Alias for column"), vector: z.array(z.number()).optional().describe("Vector to validate"), - dimensions: z.number().optional().describe("Expected dimensions"), + dimensions: z + .preprocess(coerceNumber, z.number().optional()) + .describe("Expected dimensions"), schema: z.string().optional().describe("Database schema (default: public)"), }); diff --git a/src/adapters/postgresql/tools/vector/cluster.ts b/src/adapters/postgresql/tools/vector/cluster.ts index cbac41b5..68c91dad 100644 --- a/src/adapters/postgresql/tools/vector/cluster.ts +++ b/src/adapters/postgresql/tools/vector/cluster.ts @@ -11,7 +11,7 @@ import { type ToolDefinition, type RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; + import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; @@ -20,8 +20,11 @@ import { sanitizeTableName, } from "../../../../utils/identifiers.js"; import { checkTableAndColumn, truncateVector } from "./data.js"; -import { VectorClusterOutputSchema } from "../../schemas/index.js"; -import { coerceNumber } from "../../../../utils/query-helpers.js"; +import { + VectorClusterOutputSchema, + VectorClusterSchemaBase, + VectorClusterSchema, +} from "../../schemas/index.js"; /** * Parse a PostgreSQL vector string to a number array. @@ -39,55 +42,18 @@ function parseVector(vecStr: unknown): number[] | null { export function createVectorClusterTool( adapter: PostgresAdapter, ): ToolDefinition { - // Schema with parameter smoothing - const ClusterSchemaBase = z.object({ - table: z.string().optional().describe("Table name"), - tableName: z.string().optional().describe("Alias for table"), - column: z.string().optional().describe("Vector column"), - col: z.string().optional().describe("Alias for column"), - k: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Number of clusters"), - clusters: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Alias for k (number of clusters)"), - iterations: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Max iterations (default: 10)"), - sampleSize: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Sample size for large tables"), - schema: z.string().optional().describe("Database schema (default: public)"), - }); - - const ClusterSchema = ClusterSchemaBase.transform((data) => { - const rawK = (data.k ?? data.clusters) as unknown; - const rawIterations = data.iterations as unknown; - const rawSampleSize = data.sampleSize as unknown; - return { - table: data.table ?? data.tableName ?? "", - column: data.column ?? data.col ?? "", - k: rawK != null ? Number(rawK) : undefined, - iterations: rawIterations != null ? Number(rawIterations) : undefined, - sampleSize: rawSampleSize != null ? Number(rawSampleSize) : undefined, - schema: data.schema, - }; - }).refine((data) => data.k !== undefined, { - message: "k (or clusters alias) is required", - }); - return { name: "pg_vector_cluster", description: "Perform K-means clustering on vectors. Returns cluster centroids only (not row assignments). To assign rows to clusters, compare row vectors to centroids using pg_vector_distance.", group: "vector", - inputSchema: ClusterSchemaBase, + inputSchema: VectorClusterSchemaBase, outputSchema: VectorClusterOutputSchema, annotations: readOnly("Vector Cluster"), icons: getToolIcons("vector", readOnly("Vector Cluster")), handler: async (params: unknown, _context: RequestContext) => { try { - const parsed = ClusterSchema.parse(params); + const parsed = VectorClusterSchema.parse(params); // Refine guarantees k is defined, but add explicit check for TypeScript const k = parsed.k; if (k === undefined) { @@ -109,6 +75,26 @@ export function createVectorClusterTool( suggestion: "Provide k >= 1, typically between 2 and 20", }; } + + if (parsed.table === "") { + return { + success: false, + error: "table (or tableName) parameter is required", + code: "VALIDATION_ERROR", + category: "validation", + requiredParams: ["table", "column"], + }; + } + if (parsed.column === "") { + return { + success: false, + error: "column (or col) parameter is required", + code: "VALIDATION_ERROR", + category: "validation", + requiredParams: ["table", "column"], + }; + } + const maxIter = parsed.iterations ?? 10; const sample = parsed.sampleSize ?? 10000; const schemaName = parsed.schema ?? "public"; diff --git a/src/adapters/postgresql/tools/vector/data.ts b/src/adapters/postgresql/tools/vector/data.ts index 32890704..b3c467b5 100644 --- a/src/adapters/postgresql/tools/vector/data.ts +++ b/src/adapters/postgresql/tools/vector/data.ts @@ -130,6 +130,24 @@ export function createVectorExtensionTool( handler: async (_params: unknown, _context: RequestContext) => { try { const parsed = VectorCreateExtensionSchemaBase.parse(_params ?? {}); + + if (parsed.schema) { + const schemaCheck = await adapter.executeQuery( + `SELECT 1 FROM information_schema.schemata WHERE schema_name = $1`, + [parsed.schema], + ); + if ((schemaCheck.rows?.length ?? 0) === 0) { + return { + success: false, + error: `Schema "${parsed.schema}" does not exist.`, + code: "SCHEMA_NOT_FOUND", + category: "resource", + suggestion: + "Create the schema before enabling the extension in it.", + }; + } + } + const schemaClause = parsed.schema ? ` SCHEMA ${sanitizeIdentifier(parsed.schema)}` : ""; diff --git a/src/adapters/postgresql/tools/vector/management.ts b/src/adapters/postgresql/tools/vector/management.ts index 32e413e1..b4bd1ef1 100644 --- a/src/adapters/postgresql/tools/vector/management.ts +++ b/src/adapters/postgresql/tools/vector/management.ts @@ -10,7 +10,6 @@ import { type ToolDefinition, type RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; @@ -23,27 +22,16 @@ import { VectorIndexOptimizeOutputSchema, VectorDimensionReduceOutputSchema, VectorEmbedOutputSchema, + IndexOptimizeSchemaBase, + IndexOptimizeSchema, + VectorDimensionReduceSchemaBase, + VectorDimensionReduceSchema, + EmbedSchemaBase, + EmbedSchema, } from "../../schemas/index.js"; -import { coerceNumber } from "../../../../utils/query-helpers.js"; - export function createVectorIndexOptimizeTool( adapter: PostgresAdapter, ): ToolDefinition { - // Schema with parameter smoothing - const IndexOptimizeSchemaBase = z.object({ - table: z.string().optional().describe("Table name"), - tableName: z.string().optional().describe("Alias for table"), - column: z.string().optional().describe("Vector column"), - col: z.string().optional().describe("Alias for column"), - schema: z.string().optional().describe("Database schema (default: public)"), - }); - - const IndexOptimizeSchema = IndexOptimizeSchemaBase.transform((data) => ({ - table: data.table ?? data.tableName ?? "", - column: data.column ?? data.col ?? "", - schema: data.schema, - })); - return { name: "pg_vector_index_optimize", description: @@ -203,66 +191,6 @@ export function createVectorIndexOptimizeTool( export function createVectorDimensionReduceTool( adapter: PostgresAdapter, ): ToolDefinition { - // Define base schema that exposes all properties correctly to MCP - const VectorDimensionReduceSchemaBase = z.object({ - // Direct vector mode - vector: z - .array(z.number()) - .optional() - .describe("Vector to reduce (for direct mode)"), - // Table-based mode - include aliases for Split Schema compliance - table: z.string().optional().describe("Table name (for table mode)"), - tableName: z.string().optional().describe("Alias for table"), - column: z - .string() - .optional() - .describe("Vector column name (for table mode)"), - col: z.string().optional().describe("Alias for column"), - idColumn: z - .string() - .optional() - .describe("ID column to include in results (default: id)"), - limit: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Max rows to process (default: 20, max: 100)"), - // Common parameters - targetDimensions is required - targetDimensions: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Target number of dimensions"), - dimensions: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Alias for targetDimensions"), - seed: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Random seed for reproducibility"), - summarize: z - .boolean() - .optional() - .describe( - "Summarize reduced vectors to preview format in table mode (default: true)", - ), - }); - - // Schema with alias resolution applied via refinement - const VectorDimensionReduceSchema = VectorDimensionReduceSchemaBase.transform( - (data) => { - // Handle aliases: dimensions -> targetDimensions, tableName -> table, col -> column - const rawTarget = (data.targetDimensions ?? data.dimensions) as unknown; - const rawLimit = data.limit as unknown; - const rawSeed = data.seed as unknown; - return { - ...data, - table: data.table ?? data.tableName, - column: data.column ?? data.col, - targetDimensions: rawTarget != null ? Number(rawTarget) : undefined, - limit: rawLimit != null ? Number(rawLimit) : undefined, - seed: rawSeed != null ? Number(rawSeed) : undefined, - }; - }, - ).refine((data) => data.targetDimensions !== undefined, { - message: "targetDimensions (or dimensions alias) is required", - }); - // Helper function for dimension reduction const reduceVector = ( vector: number[], @@ -340,7 +268,7 @@ export function createVectorDimensionReduceTool( success: true, originalDimensions: originalDim, targetDimensions: targetDim, - reduced: reduceVector(parsed.vector, targetDim, seed), + reducedVector: reduceVector(parsed.vector, targetDim, seed), method: "random_projection", note: "For PCA or UMAP, use external libraries", }; @@ -348,6 +276,26 @@ export function createVectorDimensionReduceTool( // Table-based mode if (parsed.table !== undefined && parsed.column !== undefined) { + if (parsed.table === "") { + return { + success: false, + error: + "table (or tableName) parameter is required for table mode", + code: "VALIDATION_ERROR", + category: "validation", + requiredParams: ["table", "column"], + }; + } + if (parsed.column === "") { + return { + success: false, + error: "column (or col) parameter is required for table mode", + code: "VALIDATION_ERROR", + category: "validation", + requiredParams: ["table", "column"], + }; + } + // P154: Verify table and column exist before querying const existenceError = await checkTableAndColumn( adapter, @@ -360,7 +308,7 @@ export function createVectorDimensionReduceTool( } const idCol = parsed.idColumn ?? "id"; - let limitVal = parsed.limit ?? 20; + let limitVal = parsed.limit ?? 5; if (limitVal > 100) limitVal = 100; // Fetch vectors from table @@ -387,13 +335,9 @@ export function createVectorDimensionReduceTool( const reducedRows: { id: unknown; original_dimensions: number; - reduced: - | number[] - | { - preview: number[] | null; - dimensions: number; - truncated: boolean; - }; + preview: number[] | null; + dimensions: number; + truncated: boolean; }[] = []; let originalDim = 0; @@ -413,12 +357,18 @@ export function createVectorDimensionReduceTool( const reducedVector = reduceVector(vector, targetDim, seed); // Apply summarization if requested + const outputObj = shouldSummarize + ? truncateVector(reducedVector) + : { + preview: reducedVector, + dimensions: reducedVector.length, + truncated: false, + }; + reducedRows.push({ id: row["id"], original_dimensions: vector.length, - reduced: shouldSummarize - ? truncateVector(reducedVector) - : reducedVector, + ...outputObj, }); } @@ -429,7 +379,7 @@ export function createVectorDimensionReduceTool( column: parsed.column, originalDimensions: originalDim, targetDimensions: targetDim, - processedCount: reducedRows.length, + rowsProcessed: reducedRows.length, rows: reducedRows, method: "random_projection", note: "For PCA or UMAP, use external libraries", @@ -463,18 +413,6 @@ export function createVectorDimensionReduceTool( } export function createVectorEmbedTool(): ToolDefinition { - // Base schema for MCP visibility β€” text optional to prevent MCP -32602 rejection - const EmbedSchemaBase = z.object({ - text: z.string().optional().describe("Text to embed"), - dimensions: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Vector dimensions (default: 384)"), - summarize: z - .boolean() - .optional() - .describe("Truncate embedding for display (default: true)"), - }); - return { name: "pg_vector_embed", description: @@ -486,7 +424,7 @@ export function createVectorEmbedTool(): ToolDefinition { icons: getToolIcons("vector", readOnly("Vector Embed")), handler: (params: unknown, _context: RequestContext) => { try { - const parsed = EmbedSchemaBase.parse(params ?? {}); + const parsed = EmbedSchema.parse(params ?? {}); // Validate required text parameter if (!parsed.text || parsed.text === "") { diff --git a/src/adapters/postgresql/tools/vector/search-advanced.ts b/src/adapters/postgresql/tools/vector/search-advanced.ts index 829fedf5..7dfd755b 100644 --- a/src/adapters/postgresql/tools/vector/search-advanced.ts +++ b/src/adapters/postgresql/tools/vector/search-advanced.ts @@ -9,7 +9,6 @@ import type { ToolDefinition, RequestContext, } from "../../../../types/index.js"; -import { z } from "zod"; import { readOnly } from "../../../../utils/annotations.js"; import { getToolIcons } from "../../../../utils/icons.js"; import { formatHandlerErrorResponse } from "../core/error-helpers.js"; @@ -21,50 +20,14 @@ import { checkTableAndColumn } from "./data.js"; import { HybridSearchOutputSchema, VectorPerformanceOutputSchema, + HybridSearchSchemaBase, + HybridSearchSchema, + PerformanceSchemaBase, + PerformanceSchema, } from "../../schemas/index.js"; -import { coerceNumber } from "../../../../utils/query-helpers.js"; - export function createHybridSearchTool( adapter: PostgresAdapter, ): ToolDefinition { - // Schema with parameter smoothing - const HybridSearchSchemaBase = z.object({ - table: z.string().optional().describe("Table name"), - tableName: z.string().optional().describe("Alias for table"), - vectorColumn: z.string().optional().describe("Vector column"), - vectorCol: z.string().optional().describe("Alias for vectorColumn"), - column: z.string().optional().describe("Alias for vectorColumn"), - col: z.string().optional().describe("Alias for vectorColumn"), - textColumn: z.string().optional().describe("Text column for FTS"), - vector: z.array(z.number()).optional().describe("Query vector"), - queryVector: z.array(z.number()).optional().describe("Alias for vector"), - textQuery: z.string().optional().describe("Text search query"), - queryText: z.string().optional().describe("Alias for text search query"), - query: z.string().optional().describe("Alias for text search query"), - vectorWeight: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Weight for vector score (0-1, default: 0.5)"), - limit: z - .preprocess(coerceNumber, z.number().optional()) - .describe("Max results"), - select: z - .array(z.string()) - .optional() - .describe("Columns to return (defaults to non-vector columns)"), - }); - - const HybridSearchSchema = HybridSearchSchemaBase.transform((data) => ({ - table: data.table ?? data.tableName ?? "", - vectorColumn: - data.vectorColumn ?? data.vectorCol ?? data.column ?? data.col ?? "", - textColumn: data.textColumn, - vector: data.vector ?? data.queryVector, - textQuery: data.textQuery ?? data.queryText ?? data.query, - vectorWeight: data.vectorWeight, - limit: data.limit, - select: data.select, - })); - return { name: "pg_hybrid_search", description: @@ -279,7 +242,7 @@ export function createHybridSearchTool( const result = await adapter.executeQuery(sql, [parsed.textQuery]); return { success: true, - results: result.rows, + rows: result.rows, count: result.rows?.length ?? 0, vectorWeight, textWeight, @@ -369,26 +332,6 @@ export function createHybridSearchTool( export function createVectorPerformanceTool( adapter: PostgresAdapter, ): ToolDefinition { - // Schema with parameter smoothing - const PerformanceSchemaBase = z.object({ - table: z.string().optional().describe("Table name"), - tableName: z.string().optional().describe("Alias for table"), - column: z.string().optional().describe("Vector column"), - col: z.string().optional().describe("Alias for column"), - testVector: z - .array(z.number()) - .optional() - .describe("Test vector for benchmarking"), - schema: z.string().optional().describe("Database schema (default: public)"), - }); - - const PerformanceSchema = PerformanceSchemaBase.transform((data) => ({ - table: data.table ?? data.tableName ?? "", - column: data.column ?? data.col ?? "", - testVector: data.testVector, - schema: data.schema, - })); - return { name: "pg_vector_performance", description: @@ -506,7 +449,34 @@ export function createVectorPerformanceTool( ORDER BY ${columnName} <-> '${vectorStr}'::vector LIMIT 10 `; - const benchResult = await adapter.executeQuery(benchSql); + let benchResult; + try { + benchResult = await adapter.executeQuery(benchSql); + } catch (error: unknown) { + if (error instanceof Error) { + const dimMatch = + /different vector dimensions (\d+) and (\d+)/.exec( + error.message, + ); + if (dimMatch) { + const dim1 = parseInt(dimMatch[1] ?? "0", 10); + const dim2 = parseInt(dimMatch[2] ?? "0", 10); + const providedDim = testVector.length; + const expectedDim = dim1 === providedDim ? dim2 : dim1; + return { + success: false, + error: `Vector dimension mismatch: column expects ${String(expectedDim)} dimensions, but you provided ${String(providedDim)} dimensions.`, + code: "DIMENSION_MISMATCH", + category: "query", + expectedDimensions: expectedDim, + providedDimensions: providedDim, + suggestion: + "Ensure your test vector has the same dimensions as the column.", + }; + } + } + throw error; + } // Truncate large vectors in EXPLAIN output to reduce payload size // Pattern matches vector literals like '[0.1,0.2,...,0.9]'::vector diff --git a/src/adapters/postgresql/tools/vector/search.ts b/src/adapters/postgresql/tools/vector/search.ts index 534e0b72..660aabcf 100644 --- a/src/adapters/postgresql/tools/vector/search.ts +++ b/src/adapters/postgresql/tools/vector/search.ts @@ -103,7 +103,20 @@ export function createVectorSearchTool( }; } const vectorStr = `[${vector.join(",")}]`; - const limitVal = limit ?? 10; + const requestedLimit = limit ?? 10; + if (requestedLimit === 0) { + throw new ValidationError( + "limit: 0 is not permitted. Please specify a reasonable limit (max 100) or omit for the default limit.", + ); + } + + let limitVal = requestedLimit; + let limitWasLowered = false; + if (limitVal > 100) { + limitVal = 100; + limitWasLowered = true; + } + const selectCols = select !== undefined && select.length > 0 ? select.map((c) => sanitizeIdentifier(c)).join(", ") + ", " @@ -121,19 +134,40 @@ export function createVectorSearchTool( case "inner_product": distanceExpr = `${columnName} <#>'${vectorStr}'`; break; - default: // l2 + case "l2": + case undefined: + case null: distanceExpr = `${columnName} <-> '${vectorStr}'`; + break; + default: + return { + success: false, + error: `Validation error: Invalid metric '${metric}'`, + code: "VALIDATION_ERROR", + category: "validation", + suggestion: + "Metric must be one of: 'l2', 'cosine', 'inner_product'", + }; } + // Query limitVal + 1 to detect if there are more rows than requested const sql = `SELECT ${selectCols}${distanceExpr} as distance FROM ${tableName} WHERE TRUE${nullFilter}${whereClause} ORDER BY ${distanceExpr} - LIMIT ${String(limitVal)} `; + LIMIT ${String(limitVal + 1)} `; try { const result = await adapter.executeQuery(sql); + let hasMore = false; + if (result.rows && result.rows.length > limitVal) { + hasMore = true; + result.rows.pop(); // Remove the extra row + } + + const isTruncated = hasMore; + // Check for NULL distance values (from NULL vectors) const nullCount = (result.rows ?? []).filter( (r: Record) => r["distance"] === null, @@ -159,15 +193,35 @@ export function createVectorSearchTool( const response: Record = { success: true, - results: finalRows, + rows: finalRows, count: finalRows.length, metric: metric ?? "l2", }; + const hints: string[] = []; + + if (isTruncated) { + response["truncated"] = true; + if (limitWasLowered) { + hints.push( + `Results truncated to max limit of ${String(limitVal)} rows.`, + ); + } else { + hints.push( + `Results truncated to requested limit of ${String(limitVal)} rows.`, + ); + } + } + // Add hint when no select columns specified if (select === undefined || select.length === 0) { - response["hint"] = - 'Results only contain distance. Use select param (e.g., select: ["id", "name"]) to include identifying columns.'; + hints.push( + 'Results only contain distance. Use select param (e.g., select: ["id", "name"]) to include identifying columns.', + ); + } + + if (hints.length > 0) { + response["hint"] = hints.join(" "); } // Note about NULL vectors @@ -254,6 +308,32 @@ export function createVectorCreateIndexTool( throw new ValidationError("type (or method alias) is required"); } + if (type !== "ivfflat" && type !== "hnsw") { + return { + success: false, + error: `Validation error: Invalid index type '${type}'`, + code: "VALIDATION_ERROR", + category: "validation", + suggestion: "Index type must be one of: 'ivfflat', 'hnsw'", + }; + } + + if ( + metric !== undefined && + metric !== "l2" && + metric !== "cosine" && + metric !== "inner_product" + ) { + return { + success: false, + error: `Validation error: Invalid distance metric '${metric}'`, + code: "VALIDATION_ERROR", + category: "validation", + suggestion: + "Metric must be one of: 'l2', 'cosine', 'inner_product'", + }; + } + // P154: Verify table and column exist before attempting index creation const existenceError = await checkTableAndColumn( adapter, diff --git a/src/audit/backup-manager.test.ts b/src/audit/backup-manager.test.ts index 0356ec99..61e90554 100644 --- a/src/audit/backup-manager.test.ts +++ b/src/audit/backup-manager.test.ts @@ -112,6 +112,7 @@ describe("BackupManager", () => { expect(filename).toContain("users"); expect(filename).toMatch(/\.snapshot\.json\.gz$/); expect(adapter.describeTable).toHaveBeenCalledWith("users", "public"); + await mgr.flush(); }); it("should return undefined for non-snapshotted tools", async () => { @@ -140,6 +141,7 @@ describe("BackupManager", () => { ); expect(adapter.describeTable).toHaveBeenCalledWith("users", "myschema"); + await mgr.flush(); }); it("should include data when configured", async () => { @@ -251,6 +253,7 @@ describe("BackupManager", () => { expect(filename).toBeDefined(); expect(filename).toContain("unknown"); + await mgr.flush(); }); }); diff --git a/src/audit/backup-manager.ts b/src/audit/backup-manager.ts index 84d9a5a6..1f7a4a85 100644 --- a/src/audit/backup-manager.ts +++ b/src/audit/backup-manager.ts @@ -448,9 +448,9 @@ export class BackupManager { let line = ` "${col.name}" ${col.type}`; if (col.defaultValue !== undefined && col.defaultValue !== null) { const defVal = - typeof col.defaultValue === "object" - ? JSON.stringify(col.defaultValue) - : String(col.defaultValue as string | number | boolean); + typeof col.defaultValue === "string" + ? col.defaultValue + : JSON.stringify(col.defaultValue); line += ` DEFAULT ${defVal}`; } if (!col.nullable) line += " NOT NULL"; diff --git a/src/audit/interceptor.ts b/src/audit/interceptor.ts index ff96ecd6..808ed0e1 100644 --- a/src/audit/interceptor.ts +++ b/src/audit/interceptor.ts @@ -164,7 +164,7 @@ export function createAuditInterceptor( timestamp: new Date().toISOString(), requestId, tool: options?.logAs ?? toolName, - category: "read" as AuditCategory, + category: "read", scope, durationMs, success, diff --git a/src/auth/scopes.ts b/src/auth/scopes.ts index 05b91961..1c1fc036 100644 --- a/src/auth/scopes.ts +++ b/src/auth/scopes.ts @@ -98,6 +98,15 @@ export const TOOL_GROUP_SCOPES: Record = { // Migration tracking (write operations) migration: SCOPES.WRITE, + // Security auditing and monitoring + security: SCOPES.READ, + + // Role management (admin-level DBA operations) + roles: SCOPES.ADMIN, + + // Document Store (read base, write overrides for mutations) + docstore: SCOPES.READ, + // Code Mode (requires admin - can execute arbitrary operations) codemode: SCOPES.ADMIN, }; @@ -129,6 +138,27 @@ export const TOOL_SCOPE_OVERRIDES: Partial> = { // Backup group β€” read-only audit tools (group default is admin) pg_audit_list_backups: SCOPES.READ, pg_audit_diff_backup: SCOPES.READ, + + // Security group β€” admin-level tools + pg_security_encryption_status: SCOPES.ADMIN, + pg_security_user_privileges: SCOPES.ADMIN, + pg_security_audit: SCOPES.ADMIN, + pg_security_firewall_rules: SCOPES.ADMIN, + + // Roles group β€” read-only inspection tools + pg_role_list: SCOPES.READ, + pg_role_grants: SCOPES.READ, + pg_role_attributes: SCOPES.READ, + pg_user_roles: SCOPES.READ, + pg_role_rls_policies: SCOPES.READ, + + // Docstore group β€” write/destructive operations + pg_doc_create_collection: SCOPES.WRITE, + pg_doc_drop_collection: SCOPES.ADMIN, + pg_doc_add: SCOPES.WRITE, + pg_doc_modify: SCOPES.WRITE, + pg_doc_remove: SCOPES.WRITE, + pg_doc_create_index: SCOPES.WRITE, }; // ============================================================================= diff --git a/src/codemode/__tests__/api.test.ts b/src/codemode/__tests__/api.test.ts index acce1224..77dbb089 100644 --- a/src/codemode/__tests__/api.test.ts +++ b/src/codemode/__tests__/api.test.ts @@ -1452,7 +1452,7 @@ describe("createSandboxBindings β€” full group coverage", () => { "pg_recovery_status", "pg_replication_status", "pg_capacity_planning", - "pg_resource_usage_analyze", + "pg_system_health", "pg_alert_threshold_set", ].map((name) => ({ name, diff --git a/src/codemode/api/aliases.ts b/src/codemode/api/aliases.ts index 480c4ce8..da98a5be 100644 --- a/src/codemode/api/aliases.ts +++ b/src/codemode/api/aliases.ts @@ -520,4 +520,105 @@ export const TOP_LEVEL_ALIASES: readonly { bindingName: "cronCleanupHistory", methodName: "cleanupHistory", }, + // security + { + group: "security", + bindingName: "securitySslStatus", + methodName: "sslStatus", + }, + { + group: "security", + bindingName: "securityEncryptionStatus", + methodName: "encryptionStatus", + }, + { + group: "security", + bindingName: "securityPasswordValidate", + methodName: "passwordValidate", + }, + { + group: "security", + bindingName: "securityMaskData", + methodName: "maskData", + }, + { + group: "security", + bindingName: "securityUserPrivileges", + methodName: "userPrivileges", + }, + { + group: "security", + bindingName: "securitySensitiveTables", + methodName: "sensitiveTables", + }, + { + group: "security", + bindingName: "securityAudit", + methodName: "audit", + }, + { + group: "security", + bindingName: "securityFirewallStatus", + methodName: "firewallStatus", + }, + { + group: "security", + bindingName: "securityFirewallRules", + methodName: "firewallRules", + }, + // roles + { group: "roles", bindingName: "roleList", methodName: "list" }, + { group: "roles", bindingName: "roleCreate", methodName: "create" }, + { group: "roles", bindingName: "roleDrop", methodName: "drop" }, + { + group: "roles", + bindingName: "roleAttributes", + methodName: "attributes", + }, + { group: "roles", bindingName: "roleGrants", methodName: "grants" }, + { group: "roles", bindingName: "roleGrant", methodName: "grant" }, + { group: "roles", bindingName: "roleAssign", methodName: "assign" }, + { group: "roles", bindingName: "roleRevoke", methodName: "revoke" }, + { group: "roles", bindingName: "userRoles", methodName: "userRoles" }, + { group: "roles", bindingName: "roleSet", methodName: "set" }, + { + group: "roles", + bindingName: "roleRlsEnable", + methodName: "rlsEnable", + }, + { + group: "roles", + bindingName: "roleRlsPolicies", + methodName: "rlsPolicies", + }, + // docstore + { + group: "docstore", + bindingName: "docListCollections", + methodName: "listCollections", + }, + { + group: "docstore", + bindingName: "docCreateCollection", + methodName: "createCollection", + }, + { + group: "docstore", + bindingName: "docDropCollection", + methodName: "dropCollection", + }, + { + group: "docstore", + bindingName: "docCollectionInfo", + methodName: "collectionInfo", + }, + { group: "docstore", bindingName: "docFind", methodName: "find" }, + { group: "docstore", bindingName: "docAdd", methodName: "add" }, + { group: "docstore", bindingName: "docModify", methodName: "modify" }, + { group: "docstore", bindingName: "docRemove", methodName: "remove" }, + { + group: "docstore", + bindingName: "docCreateIndex", + methodName: "createIndex", + }, ]; diff --git a/src/codemode/api/index.ts b/src/codemode/api/index.ts index 12ceba81..2172adc2 100644 --- a/src/codemode/api/index.ts +++ b/src/codemode/api/index.ts @@ -1,7 +1,7 @@ /** * postgres-mcp - Code Mode API * - * Main API class exposing all 21 tool groups organized for the + * Main API class exposing all 24 tool groups organized for the * sandboxed code execution environment. */ @@ -49,6 +49,9 @@ export class PgApi { (...args: unknown[]) => Promise >; readonly migration: Record Promise>; + readonly security: Record Promise>; + readonly roles: Record Promise>; + readonly docstore: Record Promise>; private readonly toolsByGroup: Map; @@ -187,6 +190,24 @@ export class PgApi { this.toolsByGroup.get("migration") ?? [], audit, ); + this.security = createGroupApi( + adapter, + "security", + this.toolsByGroup.get("security") ?? [], + audit, + ); + this.roles = createGroupApi( + adapter, + "roles", + this.toolsByGroup.get("roles") ?? [], + audit, + ); + this.docstore = createGroupApi( + adapter, + "docstore", + this.toolsByGroup.get("docstore") ?? [], + audit, + ); } /** @@ -225,7 +246,7 @@ export class PgApi { getGroupMethods(groupName: string): string[] { const groupApi = this[groupName as keyof PgApi]; if (typeof groupApi === "object" && groupApi !== null) { - return Object.keys(groupApi as Record); + return Object.keys(groupApi); } return []; } @@ -275,6 +296,9 @@ export class PgApi { "pgcrypto", "introspection", "migration", + "security", + "roles", + "docstore", ] as const; for (const groupName of groupNames) { diff --git a/src/codemode/api/maps.ts b/src/codemode/api/maps.ts index 5e8e532b..958a2ab9 100644 --- a/src/codemode/api/maps.ts +++ b/src/codemode/api/maps.ts @@ -108,8 +108,8 @@ export const METHOD_ALIASES: Record> = { config: "showSettings", // config() β†’ showSettings() alerts: "alertThresholdSet", // alerts() β†’ alertThresholdSet() thresholds: "alertThresholdSet", // thresholds() β†’ alertThresholdSet() - activeConnections: "connectionStats", - systemHealth: "resourceUsageAnalyze", + systemHealth: "systemHealth", + resourceUsageAnalyze: "systemHealth", }, // Transactions: shorter aliases transactions: { @@ -269,6 +269,71 @@ export const METHOD_ALIASES: Record> = { list: "history", // list() β†’ history() dashboard: "status", // dashboard() β†’ status() }, + // Security: naming aliases for security tools + security: { + securitySslStatus: "sslStatus", + securityEncryptionStatus: "encryptionStatus", + securityPasswordValidate: "passwordValidate", + securityMaskData: "maskData", + securityUserPrivileges: "userPrivileges", + securitySensitiveTables: "sensitiveTables", + securityAudit: "audit", + securityFirewallStatus: "firewallStatus", + securityFirewallRules: "firewallRules", + // Intuitive aliases + ssl: "sslStatus", + privileges: "userPrivileges", + mask: "maskData", + sensitive: "sensitiveTables", + hba: "firewallStatus", + hbaRules: "firewallRules", + }, + // Roles: naming aliases for role management tools + roles: { + list: "roleList", + create: "roleCreate", + drop: "roleDrop", + attributes: "roleAttributes", + grants: "roleGrants", + grant: "roleGrant", + assign: "roleAssign", + revoke: "roleRevoke", + set: "roleSet", + rlsEnable: "roleRlsEnable", + rlsPolicies: "roleRlsPolicies", + // Intuitive aliases + members: "userRoles", + membership: "userRoles", + permissions: "roleGrants", + addMember: "roleAssign", + removeMember: "roleRevoke", + switchRole: "roleSet", + rls: "roleRlsPolicies", + enableRls: "roleRlsEnable", + policies: "roleRlsPolicies", + }, + // Docstore: shorthand aliases for document collection tools + docstore: { + listCollections: "docListCollections", + createCollection: "docCreateCollection", + dropCollection: "docDropCollection", + collectionInfo: "docCollectionInfo", + find: "docFind", + add: "docAdd", + modify: "docModify", + remove: "docRemove", + createIndex: "docCreateIndex", + // Intuitive aliases + create: "docCreateCollection", + drop: "docDropCollection", + list: "docListCollections", + info: "docCollectionInfo", + search: "docFind", + insert: "docAdd", + update: "docModify", + delete: "docRemove", + index: "docCreateIndex", + }, }; /** @@ -365,7 +430,7 @@ export const GROUP_EXAMPLES: Record = { "pg.stats.percentiles({ table: 'orders', column: 'amount', percentiles: [0.5, 0.95, 0.99] })", "pg.stats.timeSeries({ table: 'metrics', timeColumn: 'ts', valueColumn: 'value', interval: '1 hour' })", "pg.stats.rowNumber({ table: 'orders', orderBy: 'created_at' })", - "pg.stats.rank({ table: 'sales', orderBy: 'revenue', rankType: 'dense_rank' })", + "pg.stats.rank({ table: 'sales', orderBy: 'revenue', method: 'dense_rank' })", "pg.stats.runningTotal({ table: 'orders', column: 'amount', orderBy: 'created_at' })", "pg.stats.movingAvg({ table: 'metrics', column: 'value', orderBy: 'ts', windowSize: 7 })", "pg.stats.outliers({ table: 'orders', column: 'amount', method: 'iqr' })", @@ -425,6 +490,44 @@ export const GROUP_EXAMPLES: Record = { "pg.migration.history({ status: 'applied' })", "pg.migration.status()", ], + security: [ + "pg.security.sslStatus()", + "pg.security.encryptionStatus()", + "pg.security.userPrivileges({ user: 'myapp' })", + 'pg.security.maskData({ value: "test@email.com", type: "email" })', + "pg.security.sensitiveTables({ schema: 'public' })", + "pg.security.audit()", + "pg.security.firewallStatus()", + "pg.security.firewallRules({ type: 'hostssl' })", + 'pg.security.passwordValidate({ password: "MyP@ssw0rd!" })', + ], + roles: [ + "pg.roles.list()", + 'pg.roles.create({ name: "readonly" })', + 'pg.roles.create({ name: "webapp", login: true, password: "secure123" })', + 'pg.roles.grant({ role: "readonly", privileges: ["SELECT"], schema: "public", table: "*" })', + 'pg.roles.assign({ role: "readonly", user: "webapp" })', + 'pg.roles.grants({ role: "readonly" })', + 'pg.roles.userRoles({ user: "webapp" })', + 'pg.roles.attributes({ role: "webapp" })', + 'pg.roles.revoke({ role: "readonly", user: "webapp" })', + 'pg.roles.set({ role: "readonly" })', + 'pg.roles.rlsEnable({ table: "users" })', + 'pg.roles.rlsPolicies({ table: "users" })', + ], + docstore: [ + "pg.docstore.createCollection({ name: 'products' })", + "pg.docstore.add({ collection: 'products', documents: [{ name: 'Widget', price: 9.99 }] })", + "pg.docstore.find({ collection: 'products' })", + "pg.docstore.find({ collection: 'products', filter: '$.name' })", + "pg.docstore.find({ collection: 'products', filter: 'name=Widget' })", + "pg.docstore.modify({ collection: 'products', filter: 'name=Widget', set: { price: 12.99 } })", + "pg.docstore.remove({ collection: 'products', filter: 'name=Widget' })", + "pg.docstore.createIndex({ collection: 'products', name: 'idx_name', fields: [{ path: 'name' }] })", + "pg.docstore.collectionInfo({ collection: 'products' })", + "pg.docstore.listCollections()", + "pg.docstore.dropCollection({ name: 'products' })", + ], }; /** @@ -595,6 +698,32 @@ export const POSITIONAL_PARAM_MAP: Record = { apply: ["version", "migrationSql"], // Explicitly skipping rollback and status to prevent TS1117 collisions with transactions group history: "status", + + // ============ SECURITY GROUP ============ + passwordValidate: "password", + maskData: ["value", "type"], + userPrivileges: "user", + sensitiveTables: "schema", + firewallRules: "user", + + // ============ ROLES GROUP ============ + attributes: "role", + grants: "role", + grant: ["role", "privileges"], + assign: ["role", "user"], + userRoles: "user", + rlsEnable: ["table", "schema"], + rlsPolicies: "table", + + // ============ DOCSTORE GROUP ============ + listCollections: "schema", + createCollection: "name", + dropCollection: "name", + collectionInfo: "collection", + find: ["collection", "filter"], + add: ["collection", "documents"], + modify: ["collection", "filter"], + remove: ["collection", "filter"], }; /** diff --git a/src/codemode/api/normalize.ts b/src/codemode/api/normalize.ts index 20b26f20..15946e1a 100644 --- a/src/codemode/api/normalize.ts +++ b/src/codemode/api/normalize.ts @@ -118,9 +118,7 @@ export function normalizeParams(methodName: string, args: unknown[]): unknown { typeof lastArg === "object" && lastArg !== null && !Array.isArray(lastArg) && - Object.keys(lastArg as Record).some((k) => - paramMapping.includes(k), - ); + Object.keys(lastArg).some((k) => paramMapping.includes(k)); // Map positional args to their keys, skipping options object if detected const argsToMap = lastArgIsOptionsObject ? args.length - 1 : args.length; diff --git a/src/codemode/sandbox.ts b/src/codemode/sandbox.ts index 66a46e4a..893d0682 100644 --- a/src/codemode/sandbox.ts +++ b/src/codemode/sandbox.ts @@ -129,10 +129,12 @@ export class CodeModeSandbox { * Execute code in the sandbox * @param code - TypeScript/JavaScript code to execute * @param apiBindings - Object with pg.* API methods to expose + * @param timeoutMs - Optional execution timeout in milliseconds */ async execute( code: string, apiBindings: Record, + timeoutMs?: number, ): Promise { if (this.disposed) { return { @@ -142,6 +144,7 @@ export class CodeModeSandbox { }; } + const effectiveTimeout = timeoutMs ?? this.options.timeoutMs; const startTime = performance.now(); const startRss = process.memoryUsage.rss(); @@ -156,7 +159,7 @@ export class CodeModeSandbox { const script = this.getOrCompileScript(wrappedCode); const result = await (script.runInContext(this.context, { - timeout: this.options.timeoutMs, + timeout: effectiveTimeout, breakOnSigint: true, }) as Promise); @@ -178,9 +181,13 @@ export class CodeModeSandbox { // Check for specific error types if (errorMessage.includes("Script execution timed out")) { + // VM contexts get corrupted microtask queues after a hard timeout interrupt. + // We MUST dispose this sandbox so it isn't reused. + this.dispose(); + return { success: false, - error: `Execution timeout: exceeded ${String(this.options.timeoutMs)}ms limit`, + error: `Execution timeout: exceeded ${String(effectiveTimeout)}ms limit`, stack, metrics: this.calculateMetrics(startTime, endTime, startRss, endRss), }; @@ -381,10 +388,11 @@ export class SandboxPool { async execute( code: string, apiBindings: Record, + timeoutMs?: number, ): Promise { const sandbox = this.acquire(); try { - return await sandbox.execute(code, apiBindings); + return await sandbox.execute(code, apiBindings, timeoutMs); } finally { this.release(sandbox); } diff --git a/src/constants/server-instructions.ts b/src/constants/server-instructions.ts index 91a59f1a..dfb52f87 100644 --- a/src/constants/server-instructions.ts +++ b/src/constants/server-instructions.ts @@ -57,6 +57,7 @@ Some highlights include: - **Core Operations**: \`core\`, \`transactions\`, \`migration\`, \`schema\` - **Data Types**: \`jsonb\`, \`text\`, \`vector\`, \`postgis\`, \`citext\`, \`ltree\` - **Introspection/Health**: \`introspection\`, \`monitoring\`, \`performance\`, \`kcache\` +- **Access Control**: \`security\`, \`roles\` - **Scale/Maintenance**: \`partitioning\`, \`partman\`, \`cron\`, \`backup\`, \`admin\` - **Analytics**: \`stats\`, \`pgcrypto\` @@ -78,7 +79,7 @@ Sandbox: No \`setTimeout\`, \`setInterval\`, \`fetch\`, or network access. Use \ /** * All group keys that have help content (for dynamic help pointer generation). */ -const HELP_GROUP_KEYS: readonly string[] = ["admin","backup","citext","cron","introspection","jsonb","kcache","ltree","migration","monitoring","partitioning","partman","performance","pgcrypto","postgis","schema","stats","text","transactions","vector"] +const HELP_GROUP_KEYS: readonly string[] = ["admin","backup","citext","cron","docstore","introspection","jsonb","kcache","ltree","migration","monitoring","partitioning","partman","performance","pgcrypto","postgis","roles","schema","security","stats","text","transactions","vector"] /** * Build dynamic help pointers listing only the enabled groups. @@ -245,6 +246,22 @@ Core: \`createExtension()\`, \`schedule()\`, \`scheduleInDatabase()\`, \`unsched - \`pg_cron_create_extension\`: Enable pg_cron extension (idempotent). Requires superuser **Discovery**: \`pg.cron.help()\` returns \`{methods, methodAliases, examples}\` object`], + ["docstore", `# Document Store (\`pg_doc_*\`) + +- **Collection creation**: \`pg_doc_create_collection\` creates a JSONB document collection. Use \`ifNotExists: true\` (default) to avoid errors when the collection already exists. Returns \`{ success: false, error }\` if collection already exists (without \`ifNotExists\`). Accepts optional \`schema\` parameter. +- **Collection drop**: \`pg_doc_drop_collection\` removes a collection. With \`ifExists: true\` (default), returns \`{ success: true, message: "Collection did not exist" }\` when the collection was already absent. +- **Collection detection**: Tools identify document collections as tables containing a \`doc JSONB\` column with an \`_id\` text column. Manually created JSONB tables with this pattern may appear in collection listings. +- **Nonexistent collection handling**: \`pg_doc_collection_info\`, \`pg_doc_add\`, \`pg_doc_find\`, \`pg_doc_modify\`, \`pg_doc_remove\`, and \`pg_doc_create_index\` return \`{ success: false, error }\` when the target collection does not exist. +- **Nonexistent schema handling**: All docstore tools that accept a \`schema\` parameter return a structured error when a nonexistent schema is explicitly provided, matching the P154 pattern. +- **Index creation**: \`pg_doc_create_index\` creates PostgreSQL expression indexes on JSONB paths. Returns \`{ success: false, error }\` if the index already exists. Supports typed indexes (\`TEXT\`, \`INT\`, \`DOUBLE\`, \`DATE\`, \`TIMESTAMP\`, \`BOOLEAN\`). +- **Filter Syntax** (for \`pg_doc_find\`, \`pg_doc_modify\`, \`pg_doc_remove\`): + - **By _id**: Pass the 32-character hex _id directly: \`filter: "686dd247b9724bcfa08ce6f1efed8b77"\` + - **By field value**: Use \`field=value\` format: \`filter: "name=Alice"\` or \`filter: "age=30"\` + - **By existence**: Use JSON path: \`filter: "$.address"\` (matches docs where address field exists) + - ❌ Incorrect: \`filter: "$.name == 'Alice'"\` (comparison operators not supported in path) + - βœ… Correct: \`filter: "name=Alice"\` (field=value format) +- **Find Filters** (\`pg_doc_find\`): The filter parameter supports _id, field=value, and JSON path existence (e.g., \`$.address.zip\`). The path must be a valid JSON path; invalid paths return \`{ success: false, error }\`. +- **PostgreSQL-specific**: Uses JSONB operators (\`@>\`, \`?\`, \`->\`, \`->>\`), \`jsonb_set()\` for modifications, \`#-\` for field removal, and expression indexes instead of generated columns.`], ["gotchas", `# postgres-mcp Code Mode ## ⚠️ Critical Gotchas @@ -560,6 +577,78 @@ Core: \`createExtension()\`, \`hash()\`, \`hmac()\`, \`encrypt()\`, \`decrypt()\ - \`pg_geo_index_optimize\`: Analyze spatial indexes. Without \`table\` param, analyzes all spatial indexes. Returns structured error (\`TABLE_NOT_FOUND\`) if specified table has no spatial columns or indexes **Code Mode Aliases:** \`pg.postgis.addColumn()\` β†’ \`geometryColumn\`, \`pg.postgis.indexOptimize()\` β†’ \`geoIndexOptimize\`, \`pg.postgis.geoCluster()\` β†’ \`pg_geo_cluster\`, \`pg.postgis.geoTransform()\` β†’ \`pg_geo_transform\`. Note: \`pg.{group}.help()\` returns \`{methods, methodAliases, examples}\``], + ["roles", `# Role Management Tools + +PostgreSQL role CRUD, privilege management, membership, session control, and row-level security. + +## Tools (12) + +| Tool | Description | +|------|-------------| +| \`pg_role_list\` | List all roles with optional pattern filter and attributes | +| \`pg_role_create\` | Create a new role with optional attributes (LOGIN, PASSWORD, SUPERUSER, etc.) | +| \`pg_role_drop\` | Drop a role (with IF EXISTS safety by default) | +| \`pg_role_attributes\` | Get detailed role attributes and settings (OID, inherit, connection limit, expiration) | +| \`pg_role_grants\` | Show privileges and memberships for a role | +| \`pg_role_grant\` | Grant privileges (SELECT, INSERT, ALL, etc.) on tables/schemas/sequences to a role | +| \`pg_role_assign\` | Grant role membership to a user/role (with optional ADMIN OPTION) | +| \`pg_role_revoke\` | Revoke role membership or object privileges from a user/role | +| \`pg_user_roles\` | List roles assigned to a user (including admin and SET options) | +| \`pg_role_set\` | Set session's active role (SET ROLE / RESET ROLE) | +| \`pg_role_rls_enable\` | Enable/disable row-level security on a table (with optional FORCE) | +| \`pg_role_rls_policies\` | List RLS policies for a table (name, command, USING/WITH CHECK expressions) | + +## Key Concepts + +- **Unified Role Model**: PostgreSQL uses roles for both users and groups. A role with \`LOGIN\` is a "user"; a role without is a "group." Use \`pg_role_create\` with \`login: true\` for user-like roles. +- **Role Attributes**: \`SUPERUSER\`, \`CREATEDB\`, \`CREATEROLE\`, \`REPLICATION\`, \`BYPASSRLS\`, \`LOGIN\`, \`INHERIT\`, \`CONNECTION LIMIT\`, \`VALID UNTIL\`. +- **Membership**: \`pg_role_assign\` grants membership (equivalent to MySQL's role assignment). Use \`withAdminOption: true\` to allow re-granting. +- **Row-Level Security (RLS)**: Must be enabled per-table with \`pg_role_rls_enable\`. Use \`force: true\` to apply RLS even to the table owner. Policies are created via SQL and inspected via \`pg_role_rls_policies\`. +- **SET ROLE**: Temporarily switch the session's effective role. Reversible with \`pg_role_set({ reset: true })\`. + +## Code Mode + +\`\`\`javascript +// List all roles +const roles = await pg.roles.list(); + +// Create a login role +await pg.roles.create({ name: "webapp", login: true, password: "secure123" }); + +// Create a group role +await pg.roles.create({ name: "readonly" }); + +// Grant SELECT on all tables in public schema +await pg.roles.grant({ role: "readonly", privileges: ["SELECT"], table: "*" }); + +// Assign role to user +await pg.roles.assign({ role: "readonly", user: "webapp" }); + +// Inspect role memberships +const memberships = await pg.roles.userRoles({ user: "webapp" }); + +// Inspect role attributes +const attrs = await pg.roles.attributes({ role: "webapp" }); + +// Revoke membership +await pg.roles.revoke({ role: "readonly", user: "webapp" }); + +// Enable RLS on a table +await pg.roles.rlsEnable({ table: "users" }); + +// List RLS policies +const policies = await pg.roles.rlsPolicies({ table: "users" }); + +// Switch session role +await pg.roles.set({ role: "readonly" }); +await pg.roles.set({ reset: true }); // restore original +\`\`\` + +## Permissions + +- \`pg_role_list\`, \`pg_role_attributes\`, \`pg_role_grants\`, \`pg_user_roles\`, \`pg_role_rls_policies\`: Require **read** scope +- \`pg_role_create\`, \`pg_role_drop\`, \`pg_role_grant\`, \`pg_role_assign\`, \`pg_role_revoke\`, \`pg_role_set\`, \`pg_role_rls_enable\`: Require **admin** scope +- All tools perform existence checks (P154) before executing mutations`], ["schema", `# Schema Tools Core: \`listSchemas()\`, \`createSchema()\`, \`dropSchema()\`, \`listViews()\`, \`createView()\`, \`dropView()\`, \`listSequences()\`, \`createSequence()\`, \`dropSequence()\`, \`listFunctions()\`, \`listTriggers()\`, \`listConstraints()\` @@ -582,6 +671,62 @@ Response Structures: πŸ“¦ **AI-Optimized Payloads**: \`listViews\`, \`listSequences\`, \`listFunctions\`, \`listTriggers\`, and \`listConstraints\` all default to a 50-row limit. Returns \`truncated: true\` + \`totalCount\` when applicable (for views and sequences) or \`limit\` parameter to indicate sizing. Use \`limit: 0\` for all **Discovery**: \`pg.schema.help()\` returns \`{methods, methodAliases, examples}\` object`], + ["security", `# Security Tools + +PostgreSQL security auditing, monitoring, and data protection. + +## Tools (9) + +| Tool | Description | +|------|-------------| +| \`pg_security_audit\` | Comprehensive security posture audit (SSL, password encryption, superusers, logging, HBA rules) | +| \`pg_security_firewall_status\` | pg_hba.conf rule summary β€” PostgreSQL's host-based authentication firewall | +| \`pg_security_firewall_rules\` | Detailed pg_hba.conf rule listing with user/type filtering | +| \`pg_security_ssl_status\` | SSL/TLS connection status for active connections | +| \`pg_security_encryption_status\` | Encryption configuration (SSL settings, password encryption, pgcrypto) | +| \`pg_security_password_validate\` | Password strength validation (local analysis, no DB query) | +| \`pg_security_mask_data\` | Data masking for email, phone, SSN, credit card, partial formats | +| \`pg_security_user_privileges\` | Role privilege report (attributes, membership, object grants) | +| \`pg_security_sensitive_tables\` | Detect columns with potentially sensitive data by name pattern | + +## Key Concepts + +- **pg_hba.conf**: PostgreSQL's host-based authentication file controls who can connect and how. The firewall tools read \`pg_hba_file_rules\` (PG 10+, requires superuser). +- **SSL/TLS**: PostgreSQL supports native SSL. \`pg_stat_ssl\` shows per-connection SSL details. +- **Password Encryption**: \`scram-sha-256\` is the recommended method (PG 10+). \`md5\` is legacy. +- **Role System**: PostgreSQL uses roles (not separate users/groups). Roles can have LOGIN, SUPERUSER, CREATEDB, CREATEROLE, REPLICATION, BYPASSRLS attributes. + +## Code Mode + +\`\`\`javascript +// Quick audit +const audit = await pg.security.audit(); + +// Check SSL status +const ssl = await pg.security.sslStatus(); + +// Mask sensitive data +const masked = await pg.security.maskData({ value: "user@example.com", type: "email" }); + +// Check user privileges +const privs = await pg.security.userPrivileges({ user: "webapp" }); + +// Find sensitive columns +const sensitive = await pg.security.sensitiveTables({ schema: "public" }); + +// HBA rules +const hba = await pg.security.firewallStatus(); +const rules = await pg.security.firewallRules({ type: "hostssl" }); + +// Password strength +const strength = await pg.security.passwordValidate({ password: "MyP@ssw0rd!" }); +\`\`\` + +## Permissions + +- \`pg_security_audit\`, \`pg_security_encryption_status\`, \`pg_security_user_privileges\`, \`pg_security_firewall_rules\`: Require **admin** scope +- \`pg_security_ssl_status\`, \`pg_security_firewall_status\`, \`pg_security_mask_data\`, \`pg_security_sensitive_tables\`, \`pg_security_password_validate\`: Require **read** scope +- HBA tools gracefully degrade if the user lacks superuser or \`pg_read_all_settings\` role`], ["stats", `# Stats Tools - All stats tools support \`schema.table\` format (auto-parsed, embedded schema takes priority over explicit \`schema\` param) @@ -598,7 +743,7 @@ Response Structures: **Window Functions (6 tools):** - \`pg_stats_row_number({ table, orderBy, partitionBy?, selectColumns?, where?, limit? })\`: Sequential numbering within ordered result. \`partitionBy\` restarts numbering per group. Default \`limit: 20\` (max: 100). Returns \`{success, rowCount, rows}\` -- \`pg_stats_rank({ table, orderBy, rankType?, partitionBy?, selectColumns?, where?, limit? })\`: Rank within ordered set. \`rankType\`: 'rank' (default, with gaps), 'dense_rank' (no gaps), 'percent_rank' (0-1). Default \`limit: 20\` (max: 100). Returns \`{success, rankType, rowCount, rows}\` +- \`pg_stats_rank({ table, orderBy, method?, partitionBy?, selectColumns?, where?, limit? })\`: Rank within ordered set. \`method\`: 'rank' (default, with gaps), 'dense_rank' (no gaps), 'percent_rank' (0-1). Default \`limit: 20\` (max: 100). Returns \`{success, rankType, rowCount, rows}\` - \`pg_stats_lag_lead({ table, column, orderBy, direction, offset?, defaultValue?, partitionBy?, selectColumns?, where?, limit? })\`: Access previous (\`lag\`) or next (\`lead\`) row values. \`direction\`: 'lag' or 'lead'. \`offset\` (default: 1) = number of rows to look back/ahead. \`defaultValue\` fills when no row exists. Default \`limit: 20\` (max: 100). Returns \`{success, direction, offset, rowCount, rows}\` - \`pg_stats_running_total({ table, column, orderBy, partitionBy?, selectColumns?, where?, limit? })\`: Cumulative running total using \`SUM OVER\`. \`partitionBy\` resets total per group. Default \`limit: 20\` (max: 100). Returns \`{success, valueColumn, rowCount, rows}\` - \`pg_stats_moving_avg({ table, column, orderBy, windowSize, partitionBy?, selectColumns?, where?, limit? })\`: Moving average over sliding window. \`windowSize\` = number of rows in window (default: 3). Default \`limit: 20\` (max: 100). Returns \`{success, valueColumn, windowSize, rowCount, rows}\` diff --git a/src/constants/server-instructions/docstore.md b/src/constants/server-instructions/docstore.md new file mode 100644 index 00000000..27223872 --- /dev/null +++ b/src/constants/server-instructions/docstore.md @@ -0,0 +1,16 @@ +# Document Store (`pg_doc_*`) + +- **Collection creation**: `pg_doc_create_collection` creates a JSONB document collection. Use `ifNotExists: true` (default) to avoid errors when the collection already exists. Returns `{ success: false, error }` if collection already exists (without `ifNotExists`). Accepts optional `schema` parameter. +- **Collection drop**: `pg_doc_drop_collection` removes a collection. With `ifExists: true` (default), returns `{ success: true, message: "Collection did not exist" }` when the collection was already absent. +- **Collection detection**: Tools identify document collections as tables containing a `doc JSONB` column with an `_id` text column. Manually created JSONB tables with this pattern may appear in collection listings. +- **Nonexistent collection handling**: `pg_doc_collection_info`, `pg_doc_add`, `pg_doc_find`, `pg_doc_modify`, `pg_doc_remove`, and `pg_doc_create_index` return `{ success: false, error }` when the target collection does not exist. +- **Nonexistent schema handling**: All docstore tools that accept a `schema` parameter return a structured error when a nonexistent schema is explicitly provided, matching the P154 pattern. +- **Index creation**: `pg_doc_create_index` creates PostgreSQL expression indexes on JSONB paths. Returns `{ success: false, error }` if the index already exists. Supports typed indexes (`TEXT`, `INT`, `DOUBLE`, `DATE`, `TIMESTAMP`, `BOOLEAN`). +- **Filter Syntax** (for `pg_doc_find`, `pg_doc_modify`, `pg_doc_remove`): + - **By \_id**: Pass the 32-character hex \_id directly: `filter: "686dd247b9724bcfa08ce6f1efed8b77"` + - **By field value**: Use `field=value` format: `filter: "name=Alice"` or `filter: "age=30"` + - **By existence**: Use JSON path: `filter: "$.address"` (matches docs where address field exists) + - ❌ Incorrect: `filter: "$.name == 'Alice'"` (comparison operators not supported in path) + - βœ… Correct: `filter: "name=Alice"` (field=value format) +- **Find Filters** (`pg_doc_find`): The filter parameter supports \_id, field=value, and JSON path existence (e.g., `$.address.zip`). The path must be a valid JSON path; invalid paths return `{ success: false, error }`. +- **PostgreSQL-specific**: Uses JSONB operators (`@>`, `?`, `->`, `->>`), `jsonb_set()` for modifications, `#-` for field removal, and expression indexes instead of generated columns. diff --git a/src/constants/server-instructions/overview.md b/src/constants/server-instructions/overview.md index 05d56617..56b282e0 100644 --- a/src/constants/server-instructions/overview.md +++ b/src/constants/server-instructions/overview.md @@ -26,6 +26,7 @@ Some highlights include: - **Core Operations**: `core`, `transactions`, `migration`, `schema` - **Data Types**: `jsonb`, `text`, `vector`, `postgis`, `citext`, `ltree` - **Introspection/Health**: `introspection`, `monitoring`, `performance`, `kcache` +- **Access Control**: `security`, `roles` - **Scale/Maintenance**: `partitioning`, `partman`, `cron`, `backup`, `admin` - **Analytics**: `stats`, `pgcrypto` diff --git a/src/constants/server-instructions/roles.md b/src/constants/server-instructions/roles.md new file mode 100644 index 00000000..f6452488 --- /dev/null +++ b/src/constants/server-instructions/roles.md @@ -0,0 +1,72 @@ +# Role Management Tools + +PostgreSQL role CRUD, privilege management, membership, session control, and row-level security. + +## Tools (12) + +| Tool | Description | +| ---------------------- | -------------------------------------------------------------------------------------- | +| `pg_role_list` | List all roles with optional pattern filter and attributes | +| `pg_role_create` | Create a new role with optional attributes (LOGIN, PASSWORD, SUPERUSER, etc.) | +| `pg_role_drop` | Drop a role (with IF EXISTS safety by default) | +| `pg_role_attributes` | Get detailed role attributes and settings (OID, inherit, connection limit, expiration) | +| `pg_role_grants` | Show privileges and memberships for a role | +| `pg_role_grant` | Grant privileges (SELECT, INSERT, ALL, etc.) on tables/schemas/sequences to a role | +| `pg_role_assign` | Grant role membership to a user/role (with optional ADMIN OPTION) | +| `pg_role_revoke` | Revoke role membership or object privileges from a user/role | +| `pg_user_roles` | List roles assigned to a user (including admin and SET options) | +| `pg_role_set` | Set session's active role (SET ROLE / RESET ROLE) | +| `pg_role_rls_enable` | Enable/disable row-level security on a table (with optional FORCE) | +| `pg_role_rls_policies` | List RLS policies for a table (name, command, USING/WITH CHECK expressions) | + +## Key Concepts + +- **Unified Role Model**: PostgreSQL uses roles for both users and groups. A role with `LOGIN` is a "user"; a role without is a "group." Use `pg_role_create` with `login: true` for user-like roles. +- **Role Attributes**: `SUPERUSER`, `CREATEDB`, `CREATEROLE`, `REPLICATION`, `BYPASSRLS`, `LOGIN`, `INHERIT`, `CONNECTION LIMIT`, `VALID UNTIL`. +- **Membership**: `pg_role_assign` grants membership (equivalent to MySQL's role assignment). Use `withAdminOption: true` to allow re-granting. +- **Row-Level Security (RLS)**: Must be enabled per-table with `pg_role_rls_enable`. Use `force: true` to apply RLS even to the table owner. Policies are created via SQL and inspected via `pg_role_rls_policies`. +- **SET ROLE**: Temporarily switch the session's effective role. Reversible with `pg_role_set({ reset: true })`. + +## Code Mode + +```javascript +// List all roles +const roles = await pg.roles.list(); + +// Create a login role +await pg.roles.create({ name: "webapp", login: true, password: "secure123" }); + +// Create a group role +await pg.roles.create({ name: "readonly" }); + +// Grant SELECT on all tables in public schema +await pg.roles.grant({ role: "readonly", privileges: ["SELECT"], table: "*" }); + +// Assign role to user +await pg.roles.assign({ role: "readonly", user: "webapp" }); + +// Inspect role memberships +const memberships = await pg.roles.userRoles({ user: "webapp" }); + +// Inspect role attributes +const attrs = await pg.roles.attributes({ role: "webapp" }); + +// Revoke membership +await pg.roles.revoke({ role: "readonly", user: "webapp" }); + +// Enable RLS on a table +await pg.roles.rlsEnable({ table: "users" }); + +// List RLS policies +const policies = await pg.roles.rlsPolicies({ table: "users" }); + +// Switch session role +await pg.roles.set({ role: "readonly" }); +await pg.roles.set({ reset: true }); // restore original +``` + +## Permissions + +- `pg_role_list`, `pg_role_attributes`, `pg_role_grants`, `pg_user_roles`, `pg_role_rls_policies`: Require **read** scope +- `pg_role_create`, `pg_role_drop`, `pg_role_grant`, `pg_role_assign`, `pg_role_revoke`, `pg_role_set`, `pg_role_rls_enable`: Require **admin** scope +- All tools perform existence checks (P154) before executing mutations diff --git a/src/constants/server-instructions/security.md b/src/constants/server-instructions/security.md new file mode 100644 index 00000000..823370a1 --- /dev/null +++ b/src/constants/server-instructions/security.md @@ -0,0 +1,61 @@ +# Security Tools + +PostgreSQL security auditing, monitoring, and data protection. + +## Tools (9) + +| Tool | Description | +| ------------------------------- | ----------------------------------------------------------------------------------------------- | +| `pg_security_audit` | Comprehensive security posture audit (SSL, password encryption, superusers, logging, HBA rules) | +| `pg_security_firewall_status` | pg_hba.conf rule summary β€” PostgreSQL's host-based authentication firewall | +| `pg_security_firewall_rules` | Detailed pg_hba.conf rule listing with user/type filtering | +| `pg_security_ssl_status` | SSL/TLS connection status for active connections | +| `pg_security_encryption_status` | Encryption configuration (SSL settings, password encryption, pgcrypto) | +| `pg_security_password_validate` | Password strength validation (local analysis, no DB query) | +| `pg_security_mask_data` | Data masking for email, phone, SSN, credit card, partial formats | +| `pg_security_user_privileges` | Role privilege report (attributes, membership, object grants) | +| `pg_security_sensitive_tables` | Detect columns with potentially sensitive data by name pattern | + +## Key Concepts + +- **pg_hba.conf**: PostgreSQL's host-based authentication file controls who can connect and how. The firewall tools read `pg_hba_file_rules` (PG 10+, requires superuser). +- **SSL/TLS**: PostgreSQL supports native SSL. `pg_stat_ssl` shows per-connection SSL details. +- **Password Encryption**: `scram-sha-256` is the recommended method (PG 10+). `md5` is legacy. +- **Role System**: PostgreSQL uses roles (not separate users/groups). Roles can have LOGIN, SUPERUSER, CREATEDB, CREATEROLE, REPLICATION, BYPASSRLS attributes. + +## Code Mode + +```javascript +// Quick audit +const audit = await pg.security.audit(); + +// Check SSL status +const ssl = await pg.security.sslStatus(); + +// Mask sensitive data +const masked = await pg.security.maskData({ + value: "user@example.com", + type: "email", +}); + +// Check user privileges +const privs = await pg.security.userPrivileges({ user: "webapp" }); + +// Find sensitive columns +const sensitive = await pg.security.sensitiveTables({ schema: "public" }); + +// HBA rules +const hba = await pg.security.firewallStatus(); +const rules = await pg.security.firewallRules({ type: "hostssl" }); + +// Password strength +const strength = await pg.security.passwordValidate({ + password: "MyP@ssw0rd!", +}); +``` + +## Permissions + +- `pg_security_audit`, `pg_security_encryption_status`, `pg_security_user_privileges`, `pg_security_firewall_rules`: Require **admin** scope +- `pg_security_ssl_status`, `pg_security_firewall_status`, `pg_security_mask_data`, `pg_security_sensitive_tables`, `pg_security_password_validate`: Require **read** scope +- HBA tools gracefully degrade if the user lacks superuser or `pg_read_all_settings` role diff --git a/src/constants/server-instructions/stats.md b/src/constants/server-instructions/stats.md index 6c4df6ca..edbbf33c 100644 --- a/src/constants/server-instructions/stats.md +++ b/src/constants/server-instructions/stats.md @@ -14,7 +14,7 @@ **Window Functions (6 tools):** - `pg_stats_row_number({ table, orderBy, partitionBy?, selectColumns?, where?, limit? })`: Sequential numbering within ordered result. `partitionBy` restarts numbering per group. Default `limit: 20` (max: 100). Returns `{success, rowCount, rows}` -- `pg_stats_rank({ table, orderBy, rankType?, partitionBy?, selectColumns?, where?, limit? })`: Rank within ordered set. `rankType`: 'rank' (default, with gaps), 'dense_rank' (no gaps), 'percent_rank' (0-1). Default `limit: 20` (max: 100). Returns `{success, rankType, rowCount, rows}` +- `pg_stats_rank({ table, orderBy, method?, partitionBy?, selectColumns?, where?, limit? })`: Rank within ordered set. `method`: 'rank' (default, with gaps), 'dense_rank' (no gaps), 'percent_rank' (0-1). Default `limit: 20` (max: 100). Returns `{success, rankType, rowCount, rows}` - `pg_stats_lag_lead({ table, column, orderBy, direction, offset?, defaultValue?, partitionBy?, selectColumns?, where?, limit? })`: Access previous (`lag`) or next (`lead`) row values. `direction`: 'lag' or 'lead'. `offset` (default: 1) = number of rows to look back/ahead. `defaultValue` fills when no row exists. Default `limit: 20` (max: 100). Returns `{success, direction, offset, rowCount, rows}` - `pg_stats_running_total({ table, column, orderBy, partitionBy?, selectColumns?, where?, limit? })`: Cumulative running total using `SUM OVER`. `partitionBy` resets total per group. Default `limit: 20` (max: 100). Returns `{success, valueColumn, rowCount, rows}` - `pg_stats_moving_avg({ table, column, orderBy, windowSize, partitionBy?, selectColumns?, where?, limit? })`: Moving average over sliding window. `windowSize` = number of rows in window (default: 3). Default `limit: 20` (max: 100). Returns `{success, valueColumn, windowSize, rowCount, rows}` diff --git a/src/filtering/__tests__/tool-filter.test.ts b/src/filtering/__tests__/tool-filter.test.ts index bf2d3885..83f27daf 100644 --- a/src/filtering/__tests__/tool-filter.test.ts +++ b/src/filtering/__tests__/tool-filter.test.ts @@ -29,7 +29,7 @@ function groupSum(...groups: string[]): number { } describe("TOOL_GROUPS", () => { - it("should contain all 22 tool groups", () => { + it("should contain all 25 tool groups", () => { const expectedGroups = [ "core", "transactions", @@ -51,11 +51,14 @@ describe("TOOL_GROUPS", () => { "ltree", "introspection", "migration", + "security", + "roles", "pgcrypto", "codemode", + "docstore", ]; - expect(Object.keys(TOOL_GROUPS)).toHaveLength(22); + expect(Object.keys(TOOL_GROUPS)).toHaveLength(25); for (const group of expectedGroups) { expect(TOOL_GROUPS).toHaveProperty(group); } diff --git a/src/filtering/tool-constants.ts b/src/filtering/tool-constants.ts index 3a011a9f..0f0ce44e 100644 --- a/src/filtering/tool-constants.ts +++ b/src/filtering/tool-constants.ts @@ -5,7 +5,7 @@ * * TOOL COUNT NOTES: * Counts and shortcut capacities are validated by unit tests in tool-filter.test.ts. - * The 248 "Specialized Tools" total encompasses all tools defined below, + * The 278 "Specialized Tools" total encompasses all tools defined below, * including Code Mode and extension utilities. * * When adding new tools: update the group array below. @@ -138,7 +138,7 @@ export const TOOL_GROUPS: Record = { "pg_uptime", "pg_recovery_status", "pg_capacity_planning", - "pg_resource_usage_analyze", + "pg_system_health", "pg_alert_threshold_set", ], backup: [ @@ -309,5 +309,41 @@ export const TOOL_GROUPS: Record = { "pg_pgcrypto_gen_salt", "pg_pgcrypto_crypt", ], + security: [ + "pg_security_audit", + "pg_security_firewall_status", + "pg_security_firewall_rules", + "pg_security_mask_data", + "pg_security_user_privileges", + "pg_security_sensitive_tables", + "pg_security_ssl_status", + "pg_security_encryption_status", + "pg_security_password_validate", + ], + roles: [ + "pg_role_list", + "pg_role_create", + "pg_role_drop", + "pg_role_attributes", + "pg_role_grants", + "pg_role_grant", + "pg_role_assign", + "pg_role_revoke", + "pg_user_roles", + "pg_role_set", + "pg_role_rls_enable", + "pg_role_rls_policies", + ], + docstore: [ + "pg_doc_list_collections", + "pg_doc_create_collection", + "pg_doc_drop_collection", + "pg_doc_collection_info", + "pg_doc_find", + "pg_doc_add", + "pg_doc_modify", + "pg_doc_remove", + "pg_doc_create_index", + ], codemode: ["pg_execute_code"], }; diff --git a/src/pool/__tests__/connection-pool.test.ts b/src/pool/__tests__/connection-pool.test.ts index 7826e1cb..199ae6fd 100644 --- a/src/pool/__tests__/connection-pool.test.ts +++ b/src/pool/__tests__/connection-pool.test.ts @@ -115,6 +115,142 @@ describe("ConnectionPool", () => { }); }); + describe("initializationSql", () => { + it("should not run extra queries when initializationSql is unset", async () => { + await pool.initialize(); + + // Clear calls from initialization + mockClientQuery.mockClear(); + + await pool.getConnection(); + + // No extra init queries should have been called + expect(mockClientQuery).not.toHaveBeenCalled(); + }); + + it("should run initialization sql exactly once per connection", async () => { + const initSql = [ + "SET SESSION search_path TO myapp, public", + "SET SESSION work_mem = '256MB'", + ]; + const poolWithInit = new ConnectionPool({ + host: "localhost", + port: 5432, + user: "test", + password: "test", + database: "testdb", + initializationSql: initSql, + }); + await poolWithInit.initialize(); + + const internalPool = (poolWithInit as unknown as { pool: unknown }) + .pool as { + connect: typeof mockPoolConnect; + }; + const mockConn = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release: vi.fn(), + }; + vi.spyOn(internalPool, "connect") + .mockResolvedValueOnce(mockConn) + .mockResolvedValueOnce(mockConn); + + await poolWithInit.getConnection(); + expect(mockConn.query).toHaveBeenCalledWith(initSql[0]); + expect(mockConn.query).toHaveBeenCalledWith(initSql[1]); + expect(mockConn.query).toHaveBeenCalledTimes(2); + + // Second checkout of the SAME connection should not run init again + await poolWithInit.getConnection(); + expect(mockConn.query).toHaveBeenCalledTimes(2); // Still 2 + }); + + it("should apply initialization sql via query() path", async () => { + const initSql = ["SET SESSION statement_timeout = 30000"]; + const poolWithInit = new ConnectionPool({ + host: "localhost", + port: 5432, + user: "test", + password: "test", + database: "testdb", + initializationSql: initSql, + }); + await poolWithInit.initialize(); + + const internalPool = (poolWithInit as unknown as { pool: unknown }) + .pool as { + connect: typeof mockPoolConnect; + }; + const mockConn = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + }; + vi.spyOn(internalPool, "connect").mockResolvedValue(mockConn); + + // Call query() which internally routes through getConnection() + await poolWithInit.query("SELECT 1"); + + // Init SQL + the actual query = 2 calls + expect(mockConn.query).toHaveBeenCalledWith(initSql[0]); + expect(mockConn.query).toHaveBeenCalledWith("SELECT 1", undefined); + expect(mockConn.release).toHaveBeenCalled(); + }); + + it("should fail connection checkout if initialization fails", async () => { + const poolWithInit = new ConnectionPool({ + host: "localhost", + port: 5432, + user: "test", + password: "test", + database: "testdb", + initializationSql: ["SET INVALID"], + }); + await poolWithInit.initialize(); + + const internalPool = (poolWithInit as unknown as { pool: unknown }) + .pool as { + connect: typeof mockPoolConnect; + }; + const mockConn = { + query: vi.fn().mockRejectedValue(new Error("Syntax error")), + release: vi.fn(), + }; + vi.spyOn(internalPool, "connect").mockResolvedValueOnce(mockConn); + + await expect(poolWithInit.getConnection()).rejects.toThrow( + "Failed to initialize connection: Syntax error", + ); + expect(mockConn.release).toHaveBeenCalled(); + }); + + it("should skip initialization when initializationSql is empty array", async () => { + const poolWithInit = new ConnectionPool({ + host: "localhost", + port: 5432, + user: "test", + password: "test", + database: "testdb", + initializationSql: [], + }); + await poolWithInit.initialize(); + + const internalPool = (poolWithInit as unknown as { pool: unknown }) + .pool as { + connect: typeof mockPoolConnect; + }; + const mockConn = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release: vi.fn(), + }; + vi.spyOn(internalPool, "connect").mockResolvedValueOnce(mockConn); + + await poolWithInit.getConnection(); + + // No init queries called β€” empty array short-circuits + expect(mockConn.query).not.toHaveBeenCalled(); + }); + }); + describe("Health Monitoring", () => { it("should report unhealthy when not initialized", async () => { const health = await pool.checkHealth(); @@ -451,7 +587,7 @@ describe("ConnectionPool", () => { await pool.initialize(); const queryError = new Error('syntax error at or near "SELCT"'); - mockPoolQuery.mockRejectedValueOnce(queryError); + mockClientQuery.mockRejectedValueOnce(queryError); await expect(pool.query("SELCT 1")).rejects.toThrow("syntax error"); }); @@ -462,7 +598,7 @@ describe("ConnectionPool", () => { const initialStats = pool.getStats(); const initialQueries = initialStats.totalQueries; - mockPoolQuery.mockRejectedValueOnce(new Error("Query failed")); + mockClientQuery.mockRejectedValueOnce(new Error("Query failed")); try { await pool.query("INVALID SQL"); diff --git a/src/pool/connection-pool.ts b/src/pool/connection-pool.ts index 2d5ea1c9..aa34d42a 100644 --- a/src/pool/connection-pool.ts +++ b/src/pool/connection-pool.ts @@ -27,6 +27,7 @@ export interface ConnectionPoolConfig { user: string; password: string; database: string; + initializationSql?: string[]; pool?: PoolConfig | undefined; ssl?: pg.ConnectionConfig["ssl"] | undefined; statementTimeout?: number | undefined; @@ -39,6 +40,7 @@ export interface ConnectionPoolConfig { export class ConnectionPool { private pool: pg.Pool | null = null; private config: ConnectionPoolConfig; + private initializedConnections = new WeakSet(); private stats: PoolStats = { total: 0, active: 0, @@ -165,6 +167,10 @@ export class ConnectionPool { this.stats.waiting++; const client = await this.pool.connect(); this.stats.waiting = Math.max(0, this.stats.waiting - 1); + + // Run initialization SQL if configured and not already initialized + await this.applyInitializationSql(client); + return client; } catch (error: unknown) { this.stats.waiting = Math.max(0, this.stats.waiting - 1); @@ -187,7 +193,8 @@ export class ConnectionPool { } /** - * Execute a query using a pooled connection + * Execute a query using a pooled connection. + * Routes through getConnection() to ensure initializationSql is applied. */ async query[]>( sql: string, @@ -200,8 +207,9 @@ export class ConnectionPool { const startTime = Date.now(); this.stats.totalQueries++; + const client = await this.getConnection(); try { - const result = await this.pool.query(sql, params); + const result = await client.query(sql, params); logger.debug("Query executed", { sql: sql.substring(0, 100), @@ -217,6 +225,8 @@ export class ConnectionPool { error: message, }); throw error; + } finally { + this.releaseConnection(client); } } @@ -312,4 +322,30 @@ export class ConnectionPool { isClosing(): boolean { return this.shuttingDown; } + + /** + * Apply initialization SQL to a connection if configured and not yet applied. + * Uses WeakSet tracking so init runs exactly once per physical connection. + * Releases the connection and throws PoolError on failure. + */ + private async applyInitializationSql(client: PoolClient): Promise { + if ( + !this.config.initializationSql || + this.config.initializationSql.length === 0 || + this.initializedConnections.has(client) + ) { + return; + } + + try { + for (const sql of this.config.initializationSql) { + await client.query(sql); + } + this.initializedConnections.add(client); + } catch (error: unknown) { + client.release(); + const message = error instanceof Error ? error.message : "Unknown error"; + throw new PoolError(`Failed to initialize connection: ${message}`); + } + } } diff --git a/src/transports/http/legacy-sse.ts b/src/transports/http/legacy-sse.ts index 5f0f23d2..abacbbff 100644 --- a/src/transports/http/legacy-sse.ts +++ b/src/transports/http/legacy-sse.ts @@ -44,7 +44,7 @@ export async function handleLegacySSERequest( // Connect MCP server to this transport (must complete before client sends messages) if (onConnect) { - await onConnect(transport as unknown as Transport); + await onConnect(transport); } } diff --git a/src/transports/http/stateless.ts b/src/transports/http/stateless.ts index e24e3bbf..636393f7 100644 --- a/src/transports/http/stateless.ts +++ b/src/transports/http/stateless.ts @@ -72,9 +72,7 @@ export async function handleStatelessRequest( // Create a fresh transport for each request (no session persistence) // Omitting sessionIdGenerator tells the SDK to run in stateless mode - const transport = new StreamableHTTPServerTransport( - {} as ConstructorParameters[0], - ); + const transport = new StreamableHTTPServerTransport({}); if (onConnect) { await onConnect(transport as unknown as Transport); diff --git a/src/types/filtering.ts b/src/types/filtering.ts index 2aad7ed5..ab3e6841 100644 --- a/src/types/filtering.ts +++ b/src/types/filtering.ts @@ -29,6 +29,9 @@ export type ToolGroup = | "pgcrypto" // pgcrypto extension - cryptographic functions | "introspection" // Agent-optimized database analysis (read-only) | "migration" // Schema migration tracking & management + | "roles" // Role management, grants, membership, RLS + | "security" // Security auditing, SSL, privileges, data protection + | "docstore" // Document Store - JSONB document collections | "codemode"; // Code Mode - sandboxed code execution /** diff --git a/src/utils/error-suggestions.ts b/src/utils/error-suggestions.ts index 014a3c48..96bcf0c9 100644 --- a/src/utils/error-suggestions.ts +++ b/src/utils/error-suggestions.ts @@ -59,6 +59,14 @@ const ERROR_SUGGESTIONS: { category: ErrorCategory.VALIDATION, code: "INVALID_IDENTIFIER", }, + { + pattern: /has reached its limit/i, + suggestion: + "Sequence limit reached. Alter the sequence to change limits or enable cycle.", + category: ErrorCategory.VALIDATION, + code: "VALIDATION_ERROR", + }, + { pattern: /numeric field overflow/i, suggestion: diff --git a/src/utils/icons.ts b/src/utils/icons.ts index d2bee083..087788d9 100644 --- a/src/utils/icons.ts +++ b/src/utils/icons.ts @@ -127,6 +127,21 @@ const CATEGORY_ICONS: Record = { path: '', color: "#D946EF", }, + // Roles: Users/people + roles: { + path: '', + color: "#F97316", + }, + // Security: Shield with checkmark + security: { + path: '', + color: "#DC2626", + }, + // Docstore: Document/file + docstore: { + path: '', + color: "#0D9488", + }, // Codemode: Terminal/code codemode: { path: '', diff --git a/test-server/README.md b/test-server/README.md index aaf5f52f..6a830695 100644 --- a/test-server/README.md +++ b/test-server/README.md @@ -7,7 +7,7 @@ | File | Size | Purpose | When to Read | | -------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `test-tools.md` | 17 KB | **Entry-point protocol** β€” schema reference, P154 error patterns, Split Schema verification, structured error docs, cleanup rules. Open the corresponding group checklist from `test-tool-groups/`. | Always read first (Step 1 says read `src/constants/server-instructions.md`, Step 2 is the testing) | -| `test-tool-groups/*.md` | ~24 KB ea | Per-group **deterministic checklists** for all 22 tool groups. Each section has numbered items with exact inputs/outputs, πŸ”΄ error path items, alias tests, and createβ†’useβ†’drop lifecycles. | When running a specific tool group | +| `test-tool-groups/*.md` | ~24 KB ea | Per-group **deterministic checklists** for all 25 tool groups. Each section has numbered items with exact inputs/outputs, πŸ”΄ error path items, alias tests, and createβ†’useβ†’drop lifecycles. | When running a specific tool group | | `test-advanced/test-tools-advanced-[1-4].md` | 14-31 KB ea | **Second-pass stress tests (4 Parts)** β€” 8 categories: boundary values, state pollution, alias matrix, error quality, concurrency/transactions, extension edge cases, payload truncation, code mode parity. | After basic checklist passes | | `test-preflight.md` | ~2KB | **Pre-flight check** β€” validates slim instructions, help resources, data resources, and tool-filter alignment in 5 steps | Before any test pass | | `test-tool-annotations.mjs` | ~3 KB | **Tool annotations script** β€” validates `openWorldHint` presence and values across all tools | Structural validation | @@ -16,8 +16,7 @@ | `test-resources.sql` | 10 KB | Seed SQL for resource-specific test data (`resource_test_job` cron, vacuum stats, etc.) | Run before resource testing | | `test-prompts.md` | 8 KB | Prompt testing plan (19 prompts). Tested manually since agents typically don't invoke prompts yet. | When testing prompts | | `test-prompts.sql` | 19 KB | Seed SQL for prompt-specific `prompt_*` tables | Run before prompt testing | -| `tool-groups-list.md` | 8 KB | **Canonical tool inventory** β€” all 22 groups, 231 tools (222 published + 9 utility). Source of truth for tool counts. | Reference / auditing | -| `tool-reference.md` | 31 KB | **Complete Tool Reference** β€” Detailed list of all 231 tools mapped to their specific tool groups. | Reference | +| `tool-reference.md` | 31 KB | **Complete Tool Reference** β€” Detailed list of all 278 tools mapped to their specific tool groups. | Reference | | [`code-map.md`](code-map.md) | ~16KB | **Source Code Map** β€” Directory tree, handlerβ†’tool mapping, type/schema locations, error hierarchy, constants, architecture patterns. | When debugging source code or making changes | | `test-database.sql` | 9 KB | Core seed SQL for all `test_*` tables | Reference only β€” reset script uses this | | `reset-database.ps1` | 15 KB | PowerShell script to reset Docker container DB from seed data. Handles `_mcp_migrations`, partman cleanup, cron jobs. | After migration/partman testing or data pollution | @@ -54,12 +53,13 @@ | `test_projects` | 2 | lead_id FK SET NULL, department_id FK RESTRICT | β€” | Introspection | | `test_assignments` | 3 | employee_id FK CASCADE, project_id FK CASCADE, UNIQUE(emp,proj) | β€” | Introspection | | `test_audit_log` | 3 | employee_id FK (**no PK, no index on FK** β€” intentional) | β€” | Introspection | +| `test_documents` | 5 | \_id (TEXT PK), doc (JSONB) | **doc** (JSONB) | Docstore (9 tools) | **Schema objects:** `test_schema`, `test_schema.order_seq` (starts 1000), `test_order_summary` (view), `test_get_order_count()` (function). **Indexes:** `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_locations_geo` (GIST), `idx_categories_path` (GIST), HNSW on `test_embeddings.embedding`. -## Tool Groups (22 groups, 231 tools) +## Tool Groups (25 groups, 278 tools) | Group | Tools | Key Test Data | | ------------- | ----- | ----------------------------------------------------------------------------------------------- | @@ -82,8 +82,11 @@ | citext | 6+1 | `test_users` (case-insensitive username/email) | | ltree | 8+1 | `test_categories` (electronicsβ†’phonesβ†’smartphones hierarchy) | | pgcrypto | 9+1 | `test_secure_data`, encrypt/decrypt/hash cycles | +| security | 9+1 | system catalogs (`pg_hba_file_rules`, `pg_stat_ssl`, `pg_roles`, `pg_settings`) | | introspection | 6+1 | `test_departmentsβ†’employeesβ†’projectsβ†’assignments` FK chain, cascade simulation, schema analysis | | migration | 6+1 | Migration tracking, SHA-256 dedup, rollback, history/status | +| roles | 12+1 | `pg_roles` catalog, role CRUD, privileges, RLS policies | +| docstore | 9+1 | `test_documents` (JSONB document CRUD, collection management, indexes) | ## Conventions & Protocols diff --git a/test-server/Tool-Reference.md b/test-server/Tool-Reference.md index bafa16b5..e36ace00 100644 --- a/test-server/Tool-Reference.md +++ b/test-server/Tool-Reference.md @@ -1,6 +1,6 @@ # Tool Reference -Complete reference of all **248 tools** organized by their 22 tool groups. Each group automatically includes Code Mode (`pg_execute_code`) for token-efficient operations. +Complete reference of all **278 tools** organized by their 25 tool groups. Each group automatically includes Code Mode (`pg_execute_code`) for token-efficient operations. > Use [Tool Filtering](Tool-Filtering) to select the groups you need. See [Code Mode](Code-Mode) for the `pg.*` API that exposes every tool below through sandboxed JavaScript. @@ -8,7 +8,7 @@ Complete reference of all **248 tools** organized by their 22 tool groups. Each ## codemode (1 tool) -Sandboxed JavaScript execution that exposes all 22 tool groups through the `pg.*` API. +Sandboxed JavaScript execution that exposes all 25 tool groups through the `pg.*` API. | Tool | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -449,3 +449,60 @@ pgcrypto extension β€” cryptographic hashing, encryption, UUIDs, and salt genera | `pg_pgcrypto_gen_random_bytes` | Generate cryptographically secure random bytes. | | `pg_pgcrypto_gen_salt` | Generate a salt for use with `crypt()` password hashing. | | `pg_pgcrypto_crypt` | Hash a password using `crypt()` with a salt from `gen_salt()`. | + +--- + +## security (9 tools + Code Mode) + +Security auditing, SSL/TLS monitoring, HBA firewall management, data masking, privilege analysis, and sensitive data detection. + +| Tool | Description | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pg_security_audit` | Comprehensive security audit analyzing authentication, SSL, password policies, superuser exposure, and pg_hba.conf rules. Returns risk-scored findings. | +| `pg_security_firewall_status` | Summarize pg_hba.conf rules by type and authentication method. Requires superuser for `pg_hba_file_rules` access. | +| `pg_security_firewall_rules` | List detailed pg_hba.conf rules with optional filtering by type, database, or auth method. Requires superuser. | +| `pg_security_ssl_status` | Check SSL/TLS connection status for active sessions including cipher, protocol version, and certificate details. | +| `pg_security_encryption_status` | Analyze encryption-related PostgreSQL settings (ssl, password_encryption) and installed security extensions. | +| `pg_security_password_validate` | Validate password strength using configurable rules (length, complexity, common patterns). Pure JS β€” no database query. | +| `pg_security_mask_data` | Mask sensitive data values (email, credit card, phone, SSN, custom patterns). Pure JS β€” no database query. | +| `pg_security_user_privileges` | Analyze role privileges including superuser status, login capability, role memberships, and table-level grants. | +| `pg_security_sensitive_tables` | Detect tables with potentially sensitive columns by matching column names against PII/credential patterns. | + +--- + +## roles (12 tools + Code Mode) + +Role management, privilege control, membership assignment, session role switching, and row-level security. + +| Tool | Description | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `pg_role_list` | List all roles with optional pattern filter and attributes (login, superuser, inherit, connection limit, expiration). | +| `pg_role_create` | Create a new role with optional attributes (LOGIN, PASSWORD, SUPERUSER, CREATEDB, CREATEROLE, REPLICATION, BYPASSRLS, CONNECTION LIMIT). | +| `pg_role_drop` | Drop a role with IF EXISTS safety by default. Returns confirmation and notes about ownership reassignment. | +| `pg_role_attributes` | Get detailed role attributes including OID, inherit status, connection limit, password expiration, and membership summary. | +| `pg_role_grants` | Show all privileges and memberships for a role β€” object grants, schema grants, and role memberships. | +| `pg_role_grant` | Grant privileges (SELECT, INSERT, UPDATE, DELETE, ALL, etc.) on tables, schemas, or sequences to a role. | +| `pg_role_assign` | Grant role membership to a user/role. Supports WITH ADMIN OPTION for delegation and SET option control. | +| `pg_role_revoke` | Revoke role membership or object privileges from a user/role. Supports CASCADE for dependent privilege removal. | +| `pg_user_roles` | List all roles assigned to a user including admin option and SET option status. | +| `pg_role_set` | Set the session's active role (SET ROLE) or reset to the original authenticated role. Useful for privilege testing. | +| `pg_role_rls_enable` | Enable or disable row-level security on a table. Supports FORCE option to apply RLS even to the table owner. | +| `pg_role_rls_policies` | List RLS policies for a table including policy name, command type (SELECT/INSERT/UPDATE/DELETE/ALL), USING and WITH CHECK expressions. | + +--- + +## docstore (9 tools + Code Mode) + +NoSQL-style JSONB document collection management β€” create collections, CRUD documents, and build expression indexes. + +| Tool | Description | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `pg_doc_list_collections` | List JSONB document collections in a schema. Collections are tables with a `doc` JSONB column and `_id` text column. | +| `pg_doc_create_collection` | Create a new JSONB document collection (table with doc JSONB + generated `_id` primary key). | +| `pg_doc_drop_collection` | Drop a document collection (table). | +| `pg_doc_collection_info` | Get document collection statistics: row count, size, and indexes. | +| `pg_doc_find` | Query documents in a JSONB collection with optional filter, field projection, and pagination. | +| `pg_doc_add` | Add one or more JSON documents to a collection. | +| `pg_doc_modify` | Update documents matching a filter. Set fields with `set` and remove fields with `unset`. | +| `pg_doc_remove` | Remove documents matching a filter from a collection. | +| `pg_doc_create_index` | Create an expression index on document fields for faster queries. Uses PostgreSQL expression indexes on JSONB paths. | diff --git a/test-server/code-map.md b/test-server/code-map.md index f5e9b090..b89354db 100644 --- a/test-server/code-map.md +++ b/test-server/code-map.md @@ -2,7 +2,7 @@ > **Agent-optimized navigation reference.** Read this before searching the codebase. Covers directory layout, handlerβ†’tool mapping, type/schema locations, error hierarchy, and key constants. > -> Last updated: March 31, 2026 +> Last updated: May 7, 2026 --- @@ -36,7 +36,7 @@ src/ β”‚ β”œβ”€β”€ constants/ β”‚ β”œβ”€β”€ server-instructions.ts # Generated: generateInstructions() + HELP_CONTENT map (composable, filter-aware) -β”‚ └── server-instructions/ # Source .md files for each help resource (22 files: overview, gotchas, jsonb, text, stats, etc.) +β”‚ └── server-instructions/ # Source .md files for each help resource (26 files: overview, gotchas, jsonb, text, stats, docstore, etc.) β”‚ β”œβ”€β”€ filtering/ β”‚ β”œβ”€β”€ tool-constants.ts # TOOL_GROUPS arrays, groupβ†’tools map @@ -66,7 +66,7 @@ src/ β”‚ └── resource-suggestions.ts # Threshold-based actionable suggestions for resources (vacuum POC) β”‚ β”œβ”€β”€ pool/ -β”‚ └── connection-pool.ts # PostgreSQL connection pool manager (pg) +β”‚ └── connection-pool.ts # PostgreSQL connection pool manager (pg), initializationSql support β”‚ β”œβ”€β”€ auth/ # OAuth 2.1 implementation (11 files) β”‚ β”œβ”€β”€ transport-agnostic.ts # Transport-agnostic auth (createAuthenticatedContext, validateAuth, formatOAuthError) @@ -130,7 +130,7 @@ src/ ## Handler β†’ Tool Mapping -248 tools across 22 groups. Each handler file registers tools with `group` labels. +278 tools across 25 groups. Each handler file registers tools with `group` labels. ### Tool Handlers (`src/adapters/postgresql/tools/`) @@ -233,8 +233,18 @@ src/ | **introspection** | `introspection/graph.ts` | 3 | `pg_dependency_graph`, `pg_topological_sort`, `pg_cascade_simulator` | | | `introspection/analysis.ts` | 2 | `pg_constraint_analysis`, `pg_migration_risks` | | | `introspection/snapshot.ts` | 1 | `pg_schema_snapshot` | -| **migration** | `introspection/migration.ts` | 3 | `pg_migration_init`, `pg_migration_record`, `pg_migration_apply` | -| | `introspection/migration-query.ts` | 3 | `pg_migration_rollback`, `pg_migration_history`, `pg_migration_status` | +| **migration** | `migration/migration.ts` | 3 | `pg_migration_init`, `pg_migration_record`, `pg_migration_apply` | +| | `migration/migration-query.ts` | 3 | `pg_migration_rollback`, `pg_migration_history`, `pg_migration_status` | +| **security** | `security/audit.ts` | 3 | `pg_security_audit`, `pg_security_firewall_status`, `pg_security_firewall_rules` | +| | `security/encryption.ts` | 3 | `pg_security_ssl_status`, `pg_security_encryption_status`, `pg_security_password_validate` | +| | `security/data-protection.ts` | 3 | `pg_security_mask_data`, `pg_security_user_privileges`, `pg_security_sensitive_tables` | +| **roles** | `roles/management.ts` | 4 | `pg_role_list`, `pg_role_create`, `pg_role_drop`, `pg_role_attributes` | +| | `roles/privileges.ts` | 4 | `pg_role_grants`, `pg_role_grant`, `pg_role_assign`, `pg_role_revoke` | +| | `roles/session.ts` | 4 | `pg_user_roles`, `pg_role_set`, `pg_role_rls_enable`, `pg_role_rls_policies` | +| **docstore** | `docstore/collection.ts` | 4 | `pg_doc_list_collections`, `pg_doc_create_collection`, `pg_doc_drop_collection`, `pg_doc_collection_info` | +| | `docstore/documents.ts` | 4 | `pg_doc_find`, `pg_doc_add`, `pg_doc_modify`, `pg_doc_remove` | +| | `docstore/indexes.ts` | 1 | `pg_doc_create_index` | +| | `docstore/helpers.ts` | β€” | Shared docstore helpers (identifier regex, filter parser, collection existence checks, table ref escaping) | --- @@ -242,50 +252,53 @@ src/ Per-group Zod schema files (unlike mysql-mcp's monolithic 72KB file): -| Subdirectory / File | Groups Covered | -| --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `index.ts` | Barrel (re-exports `core-exports.ts` + `extension-exports.ts`) | -| `core-exports.ts` | Core schema barrel exports | -| `extension-exports.ts` | Extension schema barrel exports | -| `error-response-fields.ts` | Shared `ErrorResponseFields` β€” merged into all 100 output schemas via `.extend()` | -| `core/queries.ts` | Core read/write query schemas | -| `core/transactions.ts` | Transaction schemas | -| `core/index-schemas.ts` | Index operation schemas | -| `jsonb/basic.ts` | JSONB read/write/transform schemas | -| `jsonb/advanced.ts` | JSONB analytics/validation schemas | -| `jsonb/pretty.ts` | JSONB pretty-print schemas | -| `jsonb/utils.ts` | Path normalization, preprocessing helpers | -| `extensions/citext.ts` | Citext schemas | -| `extensions/ltree.ts` | Ltree schemas | -| `extensions/pgcrypto.ts` | pgcrypto schemas | -| `extensions/kcache.ts` | pg_stat_kcache schemas | -| `extensions/shared.ts` | Shared extension schemas | -| `stats/base-schemas.ts` | Statistics base schemas | -| `stats/input.ts` | Statistics input schemas | -| `stats/output.ts` | Statistics output schemas | -| `stats/preprocessing.ts` | Statistics preprocessing helpers | -| `stats/window.ts` | Window function schemas | -| `stats/advanced.ts` | Advanced analysis + outlier detection schemas | -| `introspection/input.ts` | Introspection input schemas | -| `introspection/output.ts` | Introspection output schemas | -| `partitioning/range.ts` | Range partitioning schemas | -| `partitioning/list.ts` | List partitioning schemas | -| `partitioning/preprocess.ts` | Alias resolution, bounds construction | -| `postgis/basic.ts` | PostGIS basic schemas | -| `postgis/advanced.ts` | PostGIS advanced input schemas | -| `postgis/output.ts` | PostGIS output schemas (16 schemas) | -| `postgis/utils.ts` | Preprocessing, coordinate helpers | -| `partman/input.ts` | Partman input schemas | -| `partman/output.ts` | Partman output schemas | -| `vector/input.ts` | Vector input schemas | -| `vector/output.ts` | Vector output schemas | -| Plus: `admin.ts`, `backup.ts`, `cron.ts`, `monitoring.ts`, `performance.ts`, `schema-mgmt.ts`, `text-search.ts` | +| Subdirectory / File | Groups Covered | +| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `index.ts` | Barrel (re-exports `core-exports.ts` + `extension-exports.ts`) | +| `core-exports.ts` | Core schema barrel exports | +| `extension-exports.ts` | Extension schema barrel exports | +| `error-response-fields.ts` | Shared `ErrorResponseFields` β€” merged into all 100 output schemas via `.extend()` | +| `core/queries.ts` | Core read/write query schemas | +| `core/transactions.ts` | Transaction schemas | +| `core/index-schemas.ts` | Index operation schemas | +| `jsonb/basic.ts` | JSONB read/write/transform schemas | +| `jsonb/advanced.ts` | JSONB analytics/validation schemas | +| `jsonb/pretty.ts` | JSONB pretty-print schemas | +| `jsonb/utils.ts` | Path normalization, preprocessing helpers | +| `extensions/citext.ts` | Citext schemas | +| `extensions/ltree.ts` | Ltree schemas | +| `extensions/pgcrypto.ts` | pgcrypto schemas | +| `extensions/kcache.ts` | pg_stat_kcache schemas | +| `extensions/shared.ts` | Shared extension schemas | +| `stats/base-schemas.ts` | Statistics base schemas | +| `stats/input.ts` | Statistics input schemas | +| `stats/output.ts` | Statistics output schemas | +| `stats/preprocessing.ts` | Statistics preprocessing helpers | +| `stats/window.ts` | Window function schemas | +| `stats/advanced.ts` | Advanced analysis + outlier detection schemas | +| `introspection/input.ts` | Introspection input schemas | +| `introspection/output.ts` | Introspection output schemas | +| `migration/index.ts` | Migration tracking schema barrel exports | +| `migration/input.ts` | Migration tracking input schemas | +| `migration/output.ts` | Migration tracking output schemas | +| `partitioning/range.ts` | Range partitioning schemas | +| `partitioning/list.ts` | List partitioning schemas | +| `partitioning/preprocess.ts` | Alias resolution, bounds construction | +| `postgis/basic.ts` | PostGIS basic schemas | +| `postgis/advanced.ts` | PostGIS advanced input schemas | +| `postgis/output.ts` | PostGIS output schemas (16 schemas) | +| `postgis/utils.ts` | Preprocessing, coordinate helpers | +| `partman/input.ts` | Partman input schemas | +| `partman/output.ts` | Partman output schemas | +| `vector/input.ts` | Vector input schemas | +| `vector/output.ts` | Vector output schemas | +| Plus: `admin.ts`, `backup.ts`, `cron.ts`, `docstore.ts`, `monitoring.ts`, `performance.ts`, `roles.ts`, `schema-mgmt.ts`, `security.ts`, `text-search.ts` | --- ## Prompts (`src/adapters/postgresql/prompts/`) -20 prompt definitions: +21 prompt definitions: | File | Prompts | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -303,12 +316,13 @@ Per-group Zod schema files (unlike mysql-mcp's monolithic 72KB file): | `pgvector.ts` | `pg_setup_pgvector` | | `postgis.ts` | `pg_setup_postgis` | | `safe-restore.ts` | `pg_safe_restore_workflow` | +| `docstore.ts` | `pg_setup_docstore` | --- ## Resources (`src/adapters/postgresql/resources/`) -22 data resources + 21 help resources providing read-only metadata and agent guidance: +23 data resources + 22 help resources providing read-only metadata and agent guidance: ### Data Resources @@ -336,6 +350,7 @@ Per-group Zod schema files (unlike mysql-mcp's monolithic 72KB file): | `crypto.ts` | `postgres://crypto/{info}` | | `insights.ts` | `postgres://insights` | | `audit.ts` | `postgres://audit` | +| `docstore.ts` | `postgres://docstore` | ### Help Resources (registered dynamically by McpServer) @@ -344,7 +359,7 @@ Per-group Zod schema files (unlike mysql-mcp's monolithic 72KB file): | `postgres://help` | `server-instructions/overview.md` + `gotchas.md` | Gotchas, aliases, Code Mode API β€” always available | | `postgres://help/{group}` | `server-instructions/{group}.md` | Per-group tool reference β€” filtered by `--tool-filter` | -20 group-specific help resources. Only groups enabled by `--tool-filter` are registered. The `core` and `codemode` tools are covered by the global `postgres://help` resource. +21 group-specific help resources. Only groups enabled by `--tool-filter` are registered. The `core` and `codemode` tools are covered by the global `postgres://help` resource. --- @@ -394,7 +409,7 @@ throw new ExtensionNotAvailableError("pgvector"); | What | Where | Notes | | ---------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Server instructions (agent prompt) | `src/constants/server-instructions.ts` | Generated: `generateInstructions(enabledGroups, level, toolCount)` + `HELP_CONTENT` map. Composable segments gated by tool groups and `InstructionLevel`. Source: `server-instructions/*.md` (22 files) | +| Server instructions (agent prompt) | `src/constants/server-instructions.ts` | Generated: `generateInstructions(enabledGroups, level, toolCount)` + `HELP_CONTENT` map. Composable segments gated by tool groups and `InstructionLevel`. Source: `server-instructions/*.md` (26 files) | | Tool group arrays | `src/filtering/tool-constants.ts` | `TOOL_GROUPS` map | | Tool filter logic | `src/filtering/tool-filter.ts` | `ToolFilter` class, `getEnabledGroups()` utility | | Connection pool | `src/pool/connection-pool.ts` | pg-native pool wrapper | @@ -415,7 +430,7 @@ throw new ExtensionNotAvailableError("pgvector"); | **P154 Pattern** | All tools verify object existence before operating. Returns structured error for missing tables/schemas. | | **Adapter Pattern** | `DatabaseAdapter` (abstract) β†’ `PostgresAdapter`. Single adapter (no WASM variant). | | **Schema Cache** | Metadata caching via `schema-operations/` (describe + list). | -| **Connection Pool** | `ConnectionPool` wraps `pg` module. Managed lifecycle with health checks and centralized 30,000ms default timeout. | +| **Connection Pool** | `ConnectionPool` wraps `pg` module. Managed lifecycle with health checks, centralized 30,000ms default timeout, and optional `initializationSql` for per-connection session setup. | | **Code Mode Bridge** | `pg.*` API in sandbox. Dual-mode: VM (default, `sandbox.ts`) or Worker (`worker-sandbox.ts` + `worker-script.ts`). Factory in `sandbox-factory.ts`. Unique `api/` subdir with alias resolution + group-api generation. Security constants in `SecurityConfig`. Returns `metrics.tokenEstimate` for per-execution burn-rate feedback. | | **Tool Aliases** | postgres-mcp has a dedicated alias system (`codemode/api/aliases.ts`, 15KB) for Code Mode. | | **Per-Group Schemas** | Zod schemas separated into `schemas/` subdir organized by group (vs mysql-mcp's monolithic file). | @@ -444,9 +459,8 @@ throw new ExtensionNotAvailableError("pgvector"); | `test-server/README.md` | Agent testing orchestration doc | | `test-server/test-database.sql` | Core seed DDL+DML (16 tables, ~700+ rows) | | `test-server/reset-database.ps1` | Reset Docker container DB from seed data | -| `test-server/Tool-Reference.md` | Complete 248-tool inventory with descriptions | -| `test-server/tool-groups-list.md` | Canonical tool inventory (22 groups) | -| `test-server/test-tool-groups/` | Per-group deterministic direct MCP tool call checklists (21 groups) | +| `test-server/Tool-Reference.md` | Complete 278-tool inventory with descriptions | +| `test-server/test-tool-groups/` | Per-group deterministic direct MCP tool call checklists (25 groups) | | `test-server/test-tool-groups-codemode/` | Code Mode execution mappings for the standard groups | | `test-server/test-advanced/` | Advanced stress tests (boundary, edge cases, cross-group optimization) split into 22 granular parts | | `test-server/test-resources.md` | Resource testing plan (20 resources) | diff --git a/test-server/reset-database.ps1 b/test-server/reset-database.ps1 index 820bdefa..876a9b1c 100644 --- a/test-server/reset-database.ps1 +++ b/test-server/reset-database.ps1 @@ -36,9 +36,9 @@ $SqlFile = Join-Path $ScriptDir "test-database.sql" # Colors for output function Write-Step { param($Step, $Message) Write-Host "`n[$Step/10] " -ForegroundColor Cyan -NoNewline; Write-Host $Message -ForegroundColor White } -function Write-Success { param($Message) Write-Host " βœ“ " -ForegroundColor Green -NoNewline; Write-Host $Message } -function Write-Info { param($Message) Write-Host " β†’ " -ForegroundColor DarkGray -NoNewline; Write-Host $Message -ForegroundColor DarkGray } -function Write-Error { param($Message) Write-Host " βœ— " -ForegroundColor Red -NoNewline; Write-Host $Message -ForegroundColor Red } +function Write-Success { param($Message) Write-Host " OK " -ForegroundColor Green -NoNewline; Write-Host $Message } +function Write-Info { param($Message) Write-Host " -> " -ForegroundColor DarkGray -NoNewline; Write-Host $Message -ForegroundColor DarkGray } +function Write-Error { param($Message) Write-Host " ERR " -ForegroundColor Red -NoNewline; Write-Host $Message -ForegroundColor Red } Write-Host "`n╔════════════════════════════════════════════════════════════╗" -ForegroundColor Magenta Write-Host "β•‘ PostgreSQL MCP Test Database Reset β•‘" -ForegroundColor Magenta @@ -183,14 +183,21 @@ DO `$`$ DECLARE r RECORD; BEGIN -- Delete partman configs for test_* tables (prevents orphaned configs) - IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'part_config' AND schemaname IN ('public', 'partman')) THEN - -- Clean sub-partition configs first (FK to part_config) - IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'part_config_sub' AND schemaname IN ('public', 'partman')) THEN - DELETE FROM public.part_config_sub WHERE sub_parent LIKE 'public.test_%'; - DELETE FROM public.part_config_sub WHERE sub_parent LIKE 'public.temp_%'; - END IF; - DELETE FROM public.part_config WHERE parent_table LIKE 'public.test_%'; - DELETE FROM public.part_config WHERE parent_table LIKE 'public.temp_%'; + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_partman') THEN + DECLARE + v_schema TEXT; + BEGIN + SELECT extnamespace::regnamespace::text INTO v_schema FROM pg_extension WHERE extname = 'pg_partman'; + -- Clean sub-partition configs first (FK to part_config) + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'part_config_sub' AND schemaname = v_schema) THEN + EXECUTE format('DELETE FROM %I.part_config_sub WHERE sub_parent LIKE ''public.test_%%''', v_schema); + EXECUTE format('DELETE FROM %I.part_config_sub WHERE sub_parent LIKE ''public.temp_%%''', v_schema); + END IF; + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'part_config' AND schemaname = v_schema) THEN + EXECUTE format('DELETE FROM %I.part_config WHERE parent_table LIKE ''public.test_%%''', v_schema); + EXECUTE format('DELETE FROM %I.part_config WHERE parent_table LIKE ''public.temp_%%''', v_schema); + END IF; + END; END IF; -- Drop template tables created by partman for test tables @@ -394,6 +401,7 @@ if (-not $SkipVerify) { "test_assignments" = 3 "test_audit_log" = 3 "test_lock_target" = 1 + "test_documents" = 5 } Write-Host "`n Table verification:" -ForegroundColor Yellow @@ -434,7 +442,7 @@ if (-not $SkipVerify) { Write-Host "`n βœ“ " -ForegroundColor Green -NoNewline Write-Host "All tables verified successfully" } else { - Write-Host "`n ⚠ " -ForegroundColor Yellow -NoNewline + Write-Host "`n WARN " -ForegroundColor Yellow -NoNewline Write-Host "Some tables have unexpected row counts" -ForegroundColor Yellow } @@ -459,7 +467,7 @@ if (-not $SkipVerify) { } if ($unexpectedTables.Count -gt 0) { - Write-Host " ⚠ Found $($unexpectedTables.Count) unexpected table(s) β€” possible stale test artifacts:" -ForegroundColor Yellow + Write-Host " WARN Found $($unexpectedTables.Count) unexpected table(s) β€” possible stale test artifacts:" -ForegroundColor Yellow foreach ($ut in $unexpectedTables) { Write-Host " [stale] " -ForegroundColor Yellow -NoNewline Write-Host $ut -ForegroundColor Gray diff --git a/test-server/scripts/README.md b/test-server/scripts/README.md index adbca6cb..f51d6e04 100644 --- a/test-server/scripts/README.md +++ b/test-server/scripts/README.md @@ -24,7 +24,7 @@ suites β€” they run directly with `node`. | `test-filter-instructions.mjs` | `--tool-filter` Γ— `--instruction-level` matrix (8 configs) | Instruction sections present/absent per config | | `test-instruction-levels.mjs` | `essential` ≀ `standard` ≀ `full` ordering + section checks | Char counts monotonically increase; sections gated correctly | | `test-prompts.mjs` | `prompts/list` + `prompts/get` for all 20 prompts | All 24 test cases return valid `messages` with expected content | -| `test-tool-annotations.mjs` | `tools/list` annotation coverage | All 248 tools have `annotations` with `openWorldHint` set | +| `test-tool-annotations.mjs` | `tools/list` annotation coverage | All 269 tools have `annotations` with `openWorldHint` set | ## Running diff --git a/test-server/test-advanced/README.md b/test-server/test-advanced/README.md index ecd941ea..190d6d05 100644 --- a/test-server/test-advanced/README.md +++ b/test-server/test-advanced/README.md @@ -11,44 +11,40 @@ This directory contains the "Second-Pass" advanced tests for the `postgres-mcp` ## Execution Parts -The original monolithic advanced stress testing suite was split into 28 granular parts to preserve agent attention spans and prevent LLM context window exhaustion. Each file strictly tests one major domain or cross-domain group. - -| File | Primary Focus | Key Validations | -| ------------------------------------------ | ------------- | --------------------------------------------------------------------------------------------- | -| `test-tools-advanced-core-part1.md` | Core | Idempotent DDL bounds, boundary logic, empty states. | -| `test-tools-advanced-core-part2.md` | Core | State pollution, duplicate object detection, alias combinations. | -| `test-tools-advanced-transactions.md` | Transactions | Transaction rollback recovery, abandoned transactions, rapid state transitions. | -| `test-tools-advanced-jsonb-part1.md` | JSONB | JSON object path mutation workflows. | -| `test-tools-advanced-jsonb-part2.md` | JSONB | Nested key operations, array mutations. | -| `test-tools-advanced-text.md` | Text | Full-text search edge cases, dictionary normalization limits. | -| `test-tools-advanced-stats-part1.md` | Stats | Statistical analysis boundary testing. | -| `test-tools-advanced-stats-part2.md` | Stats | Top-N token payloads, extreme standard deviation handling. | -| `test-tools-advanced-admin.md` | Admin | Query logging bounds, insight memo truncation handling. | -| `test-tools-advanced-vector-part1.md` | Vector | Geometric correlations. | -| `test-tools-advanced-vector-part2.md` | Vector | HNSW index parameter limits. | -| `test-tools-advanced-performance-part1.md` | Performance | Anomaly detection thresholds. | -| `test-tools-advanced-performance-part2.md` | Performance | Explain plan payload truncations. | -| `test-tools-advanced-postgis-part1.md` | PostGIS | Geometric out-of-bounds validations. | -| `test-tools-advanced-postgis-part2.md` | PostGIS | Spatial intersections boundary loops. | -| `test-tools-advanced-ltree.md` | Ltree | Path hierarchy node boundaries, missing l-nodes. | -| `test-tools-advanced-pgcrypto.md` | pgcrypto | Structured crypto errors, algorithm boundary validations. | -| `test-tools-advanced-citext.md` | Citext | Case-insensitive extension parity edge cases. | -| `test-tools-advanced-cron.md` | Cron | Missing schema boundaries for cron job triggers. | -| `test-tools-advanced-kcache.md` | KCache | KCache token exhaustion safeguards. | -| `test-tools-advanced-partman.md` | Partman | Idempotent partman schema routing logic boundaries. | -| `test-tools-advanced-introspection.md` | Introspection | Object discovery filters, non-existent relation handling. | -| `test-tools-advanced-migration.md` | Migration | Record-vs-apply tracking logic, self-referencing cascades. | -| `test-tools-advanced-backup.md` | Backup | V2 Backup volumeDrift parameters, missing snapshot checks. | -| `test-tools-advanced-cross-group.md` | Cross-Group | Multi-group memory retention limits, cross-domain integrity chaining. | -| `test-tools-advanced-monitoring.md` | Monitoring | Extreme limits testing for resource usage and dynamic alert thresholds limits. | -| `test-tools-advanced-schema.md` | Schema | Cascaded object dropping bounds, deep dependency checking, and extreme generation boundaries. | -| `test-tools-advanced-partitioning.md` | Partitioning | Deep partition structures, edge limits for range/list boundaries, massive attach routines. | - -### Test Results - -Token consumption metrics and final summaries from executing the above stress tests are persisted in [`test-results.md`](./test-results.md). - -> **Note:** The exact tool group breakdown may shift over time. Always defer to the headings within the specific `.md` files to see what groups are covered in that pass. +The original monolithic advanced stress testing suite was split into 31 granular parts to preserve agent attention spans and prevent LLM context window exhaustion. Each file strictly tests one major domain or cross-domain group. + +| File | Primary Focus | Key Validations | +| ------------------------------------------ | ------------- | ----------------------------------------------------------------------------------------------------- | +| `test-tools-advanced-core-part1.md` | Core | Idempotent DDL bounds, boundary logic, empty states. | +| `test-tools-advanced-core-part2.md` | Core | State pollution, duplicate object detection, alias combinations. | +| `test-tools-advanced-transactions.md` | Transactions | Transaction rollback recovery, abandoned transactions, rapid state transitions. | +| `test-tools-advanced-jsonb-part1.md` | JSONB | JSON object path mutation workflows. | +| `test-tools-advanced-jsonb-part2.md` | JSONB | Nested key operations, array mutations. | +| `test-tools-advanced-text.md` | Text | Full-text search edge cases, dictionary normalization limits. | +| `test-tools-advanced-stats-part1.md` | Stats | Statistical analysis boundary testing. | +| `test-tools-advanced-stats-part2.md` | Stats | Top-N token payloads, extreme standard deviation handling. | +| `test-tools-advanced-admin.md` | Admin | Query logging bounds, insight memo truncation handling. | +| `test-tools-advanced-vector-part1.md` | Vector | Geometric correlations. | +| `test-tools-advanced-vector-part2.md` | Vector | HNSW index parameter limits. | +| `test-tools-advanced-performance-part1.md` | Performance | Anomaly detection thresholds. | +| `test-tools-advanced-performance-part2.md` | Performance | Explain plan payload truncations. | +| `test-tools-advanced-postgis-part1.md` | PostGIS | Geometric out-of-bounds validations. | +| `test-tools-advanced-postgis-part2.md` | PostGIS | Spatial intersections boundary loops. | +| `test-tools-advanced-ltree.md` | Ltree | Path hierarchy node boundaries, missing l-nodes. | +| `test-tools-advanced-pgcrypto.md` | pgcrypto | Structured crypto errors, algorithm boundary validations. | +| `test-tools-advanced-citext.md` | Citext | Case-insensitive extension parity edge cases. | +| `test-tools-advanced-cron.md` | Cron | Missing schema boundaries for cron job triggers. | +| `test-tools-advanced-kcache.md` | KCache | KCache token exhaustion safeguards. | +| `test-tools-advanced-partman.md` | Partman | Idempotent partman schema routing logic boundaries. | +| `test-tools-advanced-introspection.md` | Introspection | Object discovery filters, non-existent relation handling. | +| `test-tools-advanced-migration.md` | Migration | Record-vs-apply tracking logic, self-referencing cascades. | +| `test-tools-advanced-backup.md` | Backup | V2 Backup volumeDrift parameters, missing snapshot checks. | +| `test-tools-advanced-monitoring.md` | Monitoring | Extreme limits testing for resource usage and dynamic alert thresholds limits. | +| `test-tools-advanced-schema.md` | Schema | Cascaded object dropping bounds, deep dependency checking, and extreme generation boundaries. | +| `test-tools-advanced-partitioning.md` | Partitioning | Deep partition structures, edge limits for range/list boundaries, massive attach routines. | +| `test-tools-advanced-security.md` | Security | Boundary audit limits, idempotency, data masking matrices, SQL injection resilience, payload bounds. | +| `test-tools-advanced-roles.md` | Roles | Duplicate role idempotency, full RBAC pipeline, RLS toggle, SQL injection resilience, payload bounds. | +| `test-tools-advanced-docstore.md` | Docstore | JSONB collection boundaries, lifecycle pipelines, filter operator matrices, payload bounds. | ## Agent Execution Protocol diff --git a/test-server/test-advanced/test-results.md b/test-server/test-advanced/test-results.md deleted file mode 100644 index 0b357c6f..00000000 --- a/test-server/test-advanced/test-results.md +++ /dev/null @@ -1,47 +0,0 @@ -# Token Consumption during Advanced Stress Testing of postgres-mcp - -Last tested: April 4th, 2026 - -| Test Document | Approximate Token Usage | Notes | -| :----------------------------------------- | :---------------------- | :---- | -| `test-tools-advanced-admin.md` | ~2,300 | | -| `test-tools-advanced-backup.md` | ~2,518 | | -| `test-tools-advanced-citext.md` | ~4,072 | | -| `test-tools-advanced-citext.md` | ~3,183 | | -| `test-tools-advanced-core-part1.md` | ~6,729 | | -| `test-tools-advanced-core-part2.md` | ~39,638 | | -| `test-tools-advanced-cron.md` | ~4,979 | | -| `test-tools-advanced-introspection.md` | ~5,948 | | -| `test-tools-advanced-jsonb-part1.md` | ~3,021 | | -| `test-tools-advanced-jsonb-part2.md` | ~4,814 | | -| `test-tools-advanced-kcache.md` | ~564 | | -| `test-tools-advanced-ltree.md` | ~4,658 | | -| `test-tools-advanced-migration.md` | ~4,062 | | -| `test-tools-advanced-migration.md` | ~3,091 | | -| `test-tools-advanced-monitoring.md` | ~13,440 | | -| `test-tools-advanced-partitioning.md` | ~1,478 | | -| `test-tools-advanced-partman.md` | ~6,166 | | -| `test-tools-advanced-performance-part1.md` | ~14,393 | | -| `test-tools-advanced-performance-part2.md` | ~8,322 | | -| `test-tools-advanced-pgcrypto.md` | ~5,627 | | -| `test-tools-advanced-postgis-part1.md` | ~6,063 | | -| `test-tools-advanced-postgis-part2.md` | ~5,848 | | -| `test-tools-advanced-schema.md` | ~2,375 | | -| `test-tools-advanced-stats.md` | ~1,985 | | -| `test-tools-advanced-stats-part1.md` | ~6,549 | | -| `test-tools-advanced-stats-part2.md` | ~14,558 | | -| `test-tools-advanced-text.md` | ~2,256 | | -| `test-tools-advanced-transactions.md` | ~5,328 | | -| `test-tools-advanced-vector-part1.md` | ~3,930 | | -| `test-tools-advanced-vector-part2.md` | ~2,500 | | -| **Total Estimated Tokens** | **~190,395** | | - -**Safe to test in pairs** -jsonb + vector -postgis + ltree -pgcrypto + citext -text + cron -partman + partitioning -stats + backup - -**Token counts don't include tokens used by the testing prompts themselves.** diff --git a/test-server/test-advanced/test-tools-advanced-admin.md b/test-server/test-advanced/test-tools-advanced-admin.md index 80507af8..09db1113 100644 --- a/test-server/test-advanced/test-tools-advanced-admin.md +++ b/test-server/test-advanced/test-tools-advanced-admin.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-backup.md b/test-server/test-advanced/test-tools-advanced-backup.md index 1876a571..01e0f8e8 100644 --- a/test-server/test-advanced/test-tools-advanced-backup.md +++ b/test-server/test-advanced/test-tools-advanced-backup.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-citext.md b/test-server/test-advanced/test-tools-advanced-citext.md index 8c33ba6e..434f2bfd 100644 --- a/test-server/test-advanced/test-tools-advanced-citext.md +++ b/test-server/test-advanced/test-tools-advanced-citext.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-core-part1.md b/test-server/test-advanced/test-tools-advanced-core-part1.md index 7507f061..ea3d0f81 100644 --- a/test-server/test-advanced/test-tools-advanced-core-part1.md +++ b/test-server/test-advanced/test-tools-advanced-core-part1.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-core-part2.md b/test-server/test-advanced/test-tools-advanced-core-part2.md index 68719e25..b3de1e81 100644 --- a/test-server/test-advanced/test-tools-advanced-core-part2.md +++ b/test-server/test-advanced/test-tools-advanced-core-part2.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-cron.md b/test-server/test-advanced/test-tools-advanced-cron.md index d42f8142..925fd892 100644 --- a/test-server/test-advanced/test-tools-advanced-cron.md +++ b/test-server/test-advanced/test-tools-advanced-cron.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-docstore.md b/test-server/test-advanced/test-tools-advanced-docstore.md new file mode 100644 index 00000000..3f080e43 --- /dev/null +++ b/test-server/test-advanced/test-tools-advanced-docstore.md @@ -0,0 +1,302 @@ +# Advanced Stress Test β€” postgres-mcp β€” docstore Group + +**ESSENTIAL INSTRUCTIONS** + +- Execute **EVERY** numbered stress test below using code mode (`pg_execute_code`). +- Do not use scripts or terminal to replace planned tests, run any other test files, or do anything other than these tests. Ignore distractions in terminal from work being done in other thread. +- Do not modify or skip tests. +- All changes **MUST** be consistent with other postgres-mcp tools and `code-map.md`. +- Allow me to handle Lint, typecheck, Vitest, and Playwright. You cannot restart the server in Antigravity as the cache has to be refreshed manually. +- If you have trouble saving task.md because it already exists, use a different filename. +- Please let me handle checking lint, typecheck, vitest, and playwright. You cannot restart the server in antigravity as the cache has to be refreshed manually. + +## Code Mode Execution + +All tests should be executed via `pg_execute_code` code mode. Native direct tool calls are not to be used unless explicitly compared. State persists across sequential code mode logic inside a script. + +## Test Database Schema + +The test database (`postgres`) contains these tables: + +| Table | Rows | Key Columns | JSONB Columns | Tool Groups | +| ------------------- | ---- | ---------------------------------------------------------------------------------- | ------------------------ | --------------------- | +| `test_products` | 15 | id, name, description, price, created_at | β€” | Core, Stats | +| `test_orders` | 20 | id, product_id (FK), quantity, total_price, status | β€” | Core, Stats, Trans | +| `test_jsonb_docs` | 3 | id | metadata, settings, tags | JSONB (20 tools) | +| `test_articles` | 3 | id, title, body, search_vector (TSVECTOR) | β€” | Text | +| `test_measurements` | 500 | id, sensor_id (INT 1-6), temperature, humidity, pressure | β€” | Stats (19 tools) | +| `test_embeddings` | 50 | id, content, category, embedding (vector 384d) | β€” | Vector (16 tools) | +| `test_locations` | 5 | id, name, location (GEOMETRY POINT SRID 4326) | β€” | PostGIS (15 tools) | +| `test_users` | 3 | id, username (CITEXT), email (CITEXT) | β€” | Citext (6 tools) | +| `test_categories` | 6 | id, name, path (LTREE) | β€” | Ltree (8 tools) | +| `test_secure_data` | 0 | id, user_id, sensitive_data (BYTEA), created_at | β€” | pgcrypto (9 tools) | +| `test_events` | 100 | id, event_type, event_date, payload (JSONB) β€” PARTITION BY RANGE | payload | Partitioning, Partman | +| `test_logs` | 0 | id, log_level, message, created_at β€” PARTITION BY RANGE | β€” | Partman | +| `test_departments` | 3 | id, name, budget | β€” | Introspection | +| `test_employees` | 5 | id, name, department_id (FK CASCADE), manager_id (FK self-ref SET NULL), hire_date | β€” | Introspection | +| `test_projects` | 2 | id, name, lead_id (FK SET NULL), department_id (FK RESTRICT) | β€” | Introspection | +| `test_assignments` | 3 | id, employee_id (FK CASCADE), project_id (FK CASCADE), role β€” UNIQUE(emp,proj) | β€” | Introspection | +| `test_audit_log` | 3 | entry_id (no PK!), employee_id (FK, no index!), action, created_at | β€” | Introspection | +| `test_documents` | 5 | \_id (TEXT PK), doc (JSONB) | doc | Docstore (9 tools) | + +Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_order_summary` (view), `test_get_order_count()` (function). +Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_locations_geo` (GIST), `idx_categories_path` (GIST), HNSW on `test_embeddings.embedding`. + +## Testing Requirements + +1. Use existing `test_*` tables for read operations (SELECT, COUNT, EXISTS, etc.) +2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) +3. Test each tool with realistic inputs based on the schema above +4. Clean up any `stress_*` tables after testing +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. +6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal +7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. +8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. +9. **Scripting Efficiency**: You should bundle multiple tool checks into a single `pg_execute_code` call to save LLM context window tokens. Use conditional checks to aggregate errors and return a `failures` array. +10. **Pacing**: Test up to an entire tool group in a single script if feasible, but limit scripts to ~10-15 steps to remain manageable. Report the aggregated results, update your matrix, and move to the next group. +11. **Deterministic checklist first**: Complete ALL items in the Deterministic Checklist below using Code Mode before moving to the Strict Coverage Matrix exploration. +12. **Audit backup tools**: The 3 `pg_audit_*` tools require `--audit-backup` to be enabled on the test server. When enabled, destructive operations (`pg_truncate`, `pg_drop_table`, `pg_vacuum`, etc.) create gzip-compressed `.snapshot.json.gz` files alongside the audit log. **V2 features to verify**: `pg_audit_diff_backup` now returns a `volumeDrift` field (row count + size changes); `pg_audit_restore_backup` supports `restoreAs` for side-by-side non-destructive restore; and Code Mode calls through `pg_execute_code` that trigger destructive operations are also captured by the interceptor. When disabled, all 3 tools return `{success: false, error: "Audit backup not enabled"}`. + +Note: The isError flag propagation issue has been fixed. P154 structured errors (`{success: false, error: "..."}`) return as parseable JSON objects. During error path testing, verify this: if an invalid Code Mode call returns a raw error string instead of a JSON object with `success` and `error` fields, report it as ❌. + +## Structured Error Response Pattern + +All tools must return errors as structured objects instead of throwing. A thrown error propagates as a raw MCP error, which is unhelpful to clients. The expected pattern: + +```json +{ + "success": false, + "error": "Human-readable error message", + "code": "QUERY_ERROR", + "category": "query", + "recoverable": false +} +``` + +The enriched `ErrorResponse` from `formatHandlerError` always includes `success`, `error`, `code`, `category`, and `recoverable`. Optional fields `suggestion` and `details` may also be present. Some tools include additional context fields (e.g., `pg_transaction_execute` includes `statementsExecuted`, `failedStatement`, `autoRolledBack`). These are acceptable as long as `success: false` and `error` are always present. + +### Handler Error vs MCP Error β€” How to Distinguish + +There are two kinds of error responses. Only one is correct: + +| Type | Source | What you see | Verdict | +| -------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------ | +| **Handler error** βœ… | Handler catches error and returns `{success: false, error: "..."}` | Parseable JSON object with `success` and `error` fields | Correct | +| **MCP error** ❌ | Uncaught throw propagates to MCP framework | Raw text error string, often prefixed with `Error:`, wrapped in an `isError: true` content block β€” no `success` field | Bug β€” report as ❌ | + +**Concrete examples:** + +``` +βœ… Handler error (correct): +{"success": false, "error": "Table \"public.nonexistent\" does not exist"} + +❌ MCP error (bug β€” handler threw instead of catching): +content: [{type: "text", text: "Error: relation \"nonexistent\" does not exist"}] +isError: true +``` + +The MCP error case means the handler is missing a `try/catch` block. When testing, if you see a raw error string (especially one containing PostgreSQL internal messages like `relation "..." does not exist` without a `success` field), report it as ❌. + +### Zod Validation Errors + +Calling a tool with wrong parameter types or missing required fields triggers a Zod validation error. If the handler has no outer `try/catch`, this surfaces as a raw MCP error. Test every tool with `{}` (empty params) if it has required parameters β€” the response must be a handler error, not an MCP error. + +**Error message format matters:** Zod `.refine()` failures produce a `ZodError` whose `.message` property is a **raw JSON array** of Zod issues (e.g., `[{"code":"custom","message":"..."}]`). If the handler catches the error with `error.message` instead of routing through `formatHandlerError`, this raw JSON leaks as the error string. All handlers must route through `formatHandlerError`, which duck-types the `.issues` array and produces clean `Validation error: name (or table alias) is required; Validation error: columns must not be empty` messages. If you see a raw JSON array in an error message, report it as ❌. + +**Zod refinement leak pattern:** The Split Schema pattern uses `.partial()` on input schemas so the SDK accepts `{}`. But `.partial()` only makes keys **optional** β€” it does NOT strip refinements like `.min(1)`, `.max(90)`, or `.min(-90).max(90)`. This applies to **ALL types** β€” strings, arrays, AND numbers: + +- `z.string().min(1)` + empty `""` β†’ SDK rejects with raw MCP `-32602` +- `z.array().min(1)` + empty `[]` β†’ SDK rejects with raw MCP `-32602` +- `z.number().min(-90).max(90)` + value `91` β†’ SDK rejects with raw MCP `-32602` + +**Fix:** Remove ALL `.min(N)` / `.max(N)` refinements from the schema and validate inside the handler instead. Optional fields with `.default()` are safe because the default satisfies the constraint. + +**Required enum coercion pattern:** For **optional** enum params with defaults, `z.preprocess(coercer, z.enum([...]).optional().default(...))` works β€” the coercer returns `undefined` for invalid values β†’ the `.default()` kicks in. For **required** enum params (no `.optional().default(...)`), this pattern **fails**: the SDK's `.partial()` wraps the preprocess in `.optional()`, but the inner `z.enum()` still rejects `undefined` β†’ raw MCP `-32602`. **Fix:** Use `z.string()` in the schema and validate the enum inside the handler's `try/catch`, returning a structured error. + +**What to report:** + +- If a tool call returns a raw MCP error (no JSON body with `success` field), report it as ❌ with the tool name and the raw error message +- If a tool returns `{success: false, error: "..."}` but the error string is a raw Zod JSON array (starts with `[{`), report as ❌ (handler uses `error.message` instead of `formatHandlerError`) +- If a tool returns `{success: false, error: "Validation error: ..."}` with clean human-readable text, that is the correct behavior β€” do not report it as a failure +- If a tool returns a successful response for an obviously invalid input (e.g., nonexistent table returns `{success: true}`), report it as ⚠️ + +## Split Schema Pattern Verification + +All tools use the Split Schema pattern: a plain `z.object()` Base schema for MCP parameter visibility (used as `inputSchema`), and handler-side parsing via `z.preprocess()`, `.default({})`, or direct `.parse()` inside `try/catch`. Verify: + +1. **JSON Schema visibility**: Before testing tool behavior, call `tools/list` (or inspect the MCP server's tool definitions) and confirm each tool's `inputSchema` exposes its parameters. Tools with optional parameters (e.g., `schema`, `limit`, `direction`) must show non-empty `properties` in the JSON Schema. If a tool's `inputSchema` is empty or missing `properties`, report as a Split Schema violation. +2. **Parameter visibility**: For tools with optional parameters (e.g., `schema`, `limit`), make a Code Mode call using those parameters. If the tool ignores or rejects documented parameters, report as a Split Schema violation. +3. **Alias acceptance**: For tools with documented parameter aliases (e.g., table/tableName/name, sql/query), verify that Code Mode calls correctly accept the aliasesβ€”not just the primary parameter name. If a call using only an alias fails with a validation error like "X is required", report it as a Split Schema violation requiring a fix. +4. **`z.preprocess()` as `inputSchema`**: If a tool uses `z.preprocess()` directly as its `inputSchema` (instead of a plain `SchemaBase`), parameter metadata is stripped from JSON Schema generation, making MCP tooling unable to see or use those parameters. Report as a Split Schema violation. + +## P154 Object Existence Verification + +All tools should return structured error responses for nonexistent tables/schemas (via `formatHandlerError`). The 5 core convenience tools (pg_count, pg_exists, pg_upsert, pg_batch_insert, pg_truncate) implement explicit pre-checks and serve as canonical verification targets. Beyond those, **every tool group must have at least one nonexistent-table test in its checklist** β€” see the error-path items (marked πŸ”΄) in each group's checklist in `test-group-tools.md`. + +For each P154 test, verify that calling with a nonexistent table (e.g., `table: "nonexistent_table_xyz"`) returns a handler error like `{success: false, error: "Table \"public.nonexistent_table_xyz\" does not exist"}` rather than a raw MCP error. Also verify that a nonexistent schema (e.g., `table: "fake_schema.users"`) produces a similarly clear handler error. + +Key PostgreSQL error codes that should be intercepted by `formatHandlerError` (not leaked as raw errors): + +| PG Error Code | Meaning | Expected Structured Message | +| ------------- | ------------------- | --------------------------------- | +| 42P01 | Undefined table | `Table "X" does not exist` | +| 42P06 | Duplicate schema | `Schema "X" already exists` | +| 42P07 | Duplicate table | `Table "X" already exists` | +| 42701 | Duplicate column | `Column "X" already exists` | +| 42703 | Undefined column | `Column "X" does not exist` | +| 23505 | Unique violation | `Duplicate key: ...` | +| 23503 | FK violation | `Foreign key constraint violated` | +| 42601 | Syntax error | `SQL syntax error: ...` | +| 3F000 | Invalid schema name | `Schema "X" does not exist` | +| XX000 | Internal error | `Internal error: ...` | + +## Error Consistency Audit + +During testing, check for these inconsistencies across tool groups: + +1. **Throw-vs-return**: If a tool throws a raw error instead of returning `{success: false}`, report as ❌. Document which tool groups have the worst raw-error leakage. +2. **Error field name**: All `{ success: false }` error responses should use `error` as the field name. If a tool uses a different field name for error context in a failure response, report as ⚠️. +3. **Zod validation leaks**: If calling a tool with an invalid enum value or missing required field produces a raw MCP `-32602` Zod validation error instead of a structured response, report as ❌. This indicates the Zod schema is rejecting the input at the MCP framework level before the handler's `try/catch` can intercept. +4. **Missing `formatHandlerError` wrapping**: postgres-mcp has a centralized `formatHandlerError` helper. If a handler catches errors but returns ad-hoc messages instead of using the centralized formatter, report which handler and the ad-hoc message pattern. +5. **Orphaned output schemas**: If a schema is exported from `src/adapters/postgresql/schemas/` but the corresponding tool definition does not reference it via `outputSchema`, report as ⚠️. Use `grep_search` to check whether the schema name appears in any tool file. Defined-but-unwired schemas provide zero enforcement. +6. **Inline output schemas**: If any tool defines `outputSchema: z.object({...})` inline in the handler file instead of importing a named schema from the `schemas/` directory, report as ⚠️. All output schemas must live in the appropriate `schemas/` directory with named exports. + +## Error Path Testing Checklist + +For each tool group under test, verify at least one scenario from each applicable row: + +| Error Scenario | Tool Groups to Test | Example Input | +| --------------------------------- | ------------------------------------- | ----------------------------------------------------------------------- | +| Nonexistent table | All table-accepting tools | `table: "nonexistent_xyz"` | +| Nonexistent schema | Core, introspection, schema | `schema: "fake_schema"` or `table: "fake_schema.users"` | +| Invalid SQL syntax | Core (`read_query`, `write_query`) | `sql: "SELECTT * FROM"` | +| Invalid column name | Stats, JSONB, text, vector, PostGIS | `column: "nonexistent_col"` | +| Duplicate table/index | Core (`create_table`, `create_index`) | Create existing table | +| Empty required array | Transactions | `statements: []` | +| Missing required field via alias | Core, transactions | `sql` alias instead of `query` | +| **Zod validation (empty params)** | **Every tool with required params** | `{}` (empty object β€” must return handler error, not MCP `-32602` error) | +| **Zod validation (wrong type)** | **Tools with typed params** | Pass string where number expected, etc. | + +## Cleanup Conventions + +During testing, use these naming conventions: + +- **Temporary collections**: Prefix with `stress_` (e.g., `stress_doc_test`) +- **Test views**: Prefix with `test_view_` (e.g., `test_view_order_summary`) +- **Test functions**: Prefix with `test_func_` (e.g., `test_func_calculate`) +- **Test schemas**: Prefix with `test_schema_` (e.g., `test_schema_temp`) + +After testing, clean up: + +```sql +-- List stress tables/collections +SELECT tablename FROM pg_tables +WHERE schemaname = 'public' AND tablename LIKE 'stress_%'; + +-- Drop stress collection +DROP TABLE IF EXISTS stress_doc_test; +``` + +## Post-Test Procedures + +### Reporting Rules + +- Use βœ… only in inline notes during testing; omit from Final Summary +- Do not mention what already works well or issues already documented in server-instructions.md and runtime hints + +### After Testing + +1. **Cleanup**: Confirm all `stress_*` tables and temporary testing data are removed +2. **Fix EVERY finding** β€” not just ❌ Fails, but also ⚠️ Issues including behavioral improvements, missing warnings, error code consistency, πŸ“¦ Payload problems (responses that should be truncated or offer a `limit` param) and files listed below. All changes MUST be consistent with other postgres-mcp tools and `code-map.md` +3. **Scope of fixes** includes corrections to any of: + - Handler code + - `server-instructions.md` + - Test database (`test-database.sql`) + - This prompt (`test-tools-codemode.md`) and group file (`test-group-tools-codemode.md`) +4. Update the changelog with any changes made (being careful not to create duplicate headers), and commit without pushing. +5. **Token Audit**: Before concluding, call `read_resource` on `postgres://audit` to retrieve the `sessionTokenEstimate` (total token usage) for your testing session. Include this "Total Token Usage" in your final test report and session summary. Highlight the single most expensive Code Mode execution block. +6. Stop and briefly summarize the testing results and fixes, **ensuring the total token count is prominently displayed.** + +--- + +## docstore Group Advanced Tests + +### docstore Group Tools (9 + 1 code mode) + +1. `pg_doc_list_collections` +2. `pg_doc_create_collection` +3. `pg_doc_drop_collection` +4. `pg_doc_collection_info` +5. `pg_doc_find` +6. `pg_doc_add` +7. `pg_doc_modify` +8. `pg_doc_remove` +9. `pg_doc_create_index` +10. `pg_execute_code` (auto-added) + +### Category 1: Boundary Values & Empty States + +Test tools against extreme parameters, zero-state inputs, and boundary sizing. + +1. `pg_doc_find` β†’ Supply `limit: 0` boundary edge case. Verify whether handler returns empty documents array or clamps to minimum natively. +2. `pg_doc_find` β†’ Supply extreme `limit: 999999` on `test_documents` (5 rows). Verify handler does not crash and returns all 5 documents. +3. `pg_doc_add` β†’ Supply empty documents array `documents: []`. Verify Zod or handler enforcement triggers structured error `{success: false}` β€” not raw MCP `-32602`. +4. `pg_doc_find` β†’ Execute on `stress_doc_empty` collection (create it empty first). Verify clean empty array returned `{success: true, documents: [], count: 0}` β€” no null leakage. + +### Category 2: State Pollution & Idempotency + +Ensure tools execute safely when repeated identically multiple times. + +5. `pg_doc_create_collection` β†’ Create `stress_doc_dup`, then attempt to create same collection again. Verify second call returns structured duplicate error `{success: false}` β€” not a raw PostgreSQL `42P07` (duplicate table) MCP error. +6. `pg_doc_drop_collection` β†’ Drop `stress_doc_dup`, then attempt to drop again. Verify second call returns structured error or success with IF EXISTS safety β€” not a raw error. +7. `pg_doc_list_collections` β†’ Execute consecutively 3 times inside a single Code Mode script. Verify all 3 responses return identical collection counts β€” no state pollution. +8. `pg_doc_find` β†’ Execute identical query on `test_documents` 3 times. Verify all 3 responses are structurally identical (same document count, same `_id` values). + +### Category 3: Alias & Parameter Combinations + +Test parametric fallback modes and configuration matrices. + +9. `pg_doc_find` β†’ Execute a filter operator matrix across all supported comparison operators: `$gt`, `$gte`, `$lt`, `$lte`, `$ne`, `$in`, `$nin` against `test_documents` using the `age` field. Verify each operator produces correct results: + - `{age: {$gt: 30}}` β†’ 2 docs (Charlie=35, Eve=32) + - `{age: {$gte: 30}}` β†’ 3 docs (Alice=30, Charlie=35, Eve=32) + - `{age: {$lt: 28}}` β†’ 1 doc (Bob=25) + - `{age: {$lte: 28}}` β†’ 2 docs (Bob=25, Diana=28) + - `{age: {$ne: 30}}` β†’ 4 docs (all except Alice) + - `{age: {$in: [25, 35]}}` β†’ 2 docs (Bob, Charlie) + - `{age: {$nin: [25, 35]}}` β†’ 3 docs (Alice, Diana, Eve) +10. `pg_doc_modify` β†’ Progressive field mutations on a single document in `stress_doc_mutations`: set field `status: "draft"` β†’ verify β†’ set `status: "published"` β†’ verify β†’ unset `status` β†’ verify field absent. Confirm each state transition is atomic and correct. + +### Category 4: Error Message Quality + +Ensure tools predictably return typed structured errors with quality messages. + +11. `pg_doc_add` β†’ Pass SQL injection attempt in document value: `documents: [{"name": "'; DROP TABLE test_products;--"}]` into `stress_doc_inject`. Verify safe JSONB handling β€” the document is stored literally as a string, no SQL injection occurs. Verify `test_products` still exists afterwards via `pg.count({table: "test_products"})`. +12. `pg_doc_find` β†’ Pass filter with deeply nested path: `filter: {"address": {"city": {"$gt": "A"}}}`. Verify structured error or correct nested JSONB path traversal β€” not a raw PostgreSQL error. + +### Category 5: Complex Flow Architectures + +Verify that multi-tool pipelines compose correctly across the docstore tool surface. + +13. Full Document Lifecycle Pipeline β†’ Execute the following sequence in a single Code Mode script: + - `pg.docstore.createCollection({collection: "stress_doc_pipeline"})` β€” create collection + - `pg.docstore.add({collection: "stress_doc_pipeline", documents: [{name: "Item1", price: 10}, {name: "Item2", price: 20}, {name: "Item3", price: 30}, {name: "Item4", price: 40}, {name: "Item5", price: 50}, {name: "Item6", price: 60}, {name: "Item7", price: 70}, {name: "Item8", price: 80}, {name: "Item9", price: 90}, {name: "Item10", price: 100}]})` β€” add 10 docs + - `pg.docstore.find({collection: "stress_doc_pipeline"})` β†’ verify 10 docs + - `pg.docstore.find({collection: "stress_doc_pipeline", filter: {price: {$gt: 50}}})` β†’ verify 5 docs + - `pg.docstore.modify({collection: "stress_doc_pipeline", filter: {price: {$lte: 30}}, set: {category: "budget"}})` β†’ verify 3 modified + - `pg.docstore.find({collection: "stress_doc_pipeline", filter: {category: "budget"}})` β†’ verify 3 budget docs + - `pg.docstore.remove({collection: "stress_doc_pipeline", filter: {category: "budget"}})` β†’ verify 3 removed + - `pg.docstore.find({collection: "stress_doc_pipeline"})` β†’ verify 7 remaining + - `pg.docstore.createIndex({collection: "stress_doc_pipeline", field: "price"})` β€” create index + - `pg.docstore.collectionInfo({collection: "stress_doc_pipeline"})` β†’ verify rowCount=7, index present + - `pg.docstore.dropCollection({collection: "stress_doc_pipeline"})` β€” cleanup + - Verify each step returns `{success: true}` and the full pipeline round-trips cleanly with zero residual state. + +### Category 6: Large Payload & Truncation Verification + +Ensure sweeping reads cap context window exposure. + +14. `pg_doc_add` β†’ Insert 100 documents with large nested JSONB (5 levels deep, arrays of objects) into `stress_doc_payload`. Then `pg_doc_find` with no filter. Monitor `metrics.tokenEstimate` strictly. Report if payload exceeds 3000 tokens. Verify `limit` parameter correctly caps output when applied. Drop `stress_doc_payload` at end. + +### Final Cleanup + +15. Verify no `stress_*` collections remain. Execute `pg.execute("SELECT tablename FROM pg_tables WHERE tablename LIKE 'stress_%' AND schemaname = 'public'")` and assert zero rows. diff --git a/test-server/test-advanced/test-tools-advanced-introspection.md b/test-server/test-advanced/test-tools-advanced-introspection.md index dc86ce5e..2c4287e9 100644 --- a/test-server/test-advanced/test-tools-advanced-introspection.md +++ b/test-server/test-advanced/test-tools-advanced-introspection.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-jsonb-part1.md b/test-server/test-advanced/test-tools-advanced-jsonb-part1.md index 2fee65ae..a211056c 100644 --- a/test-server/test-advanced/test-tools-advanced-jsonb-part1.md +++ b/test-server/test-advanced/test-tools-advanced-jsonb-part1.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-jsonb-part2.md b/test-server/test-advanced/test-tools-advanced-jsonb-part2.md index 911fa90c..0a7b7190 100644 --- a/test-server/test-advanced/test-tools-advanced-jsonb-part2.md +++ b/test-server/test-advanced/test-tools-advanced-jsonb-part2.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-kcache.md b/test-server/test-advanced/test-tools-advanced-kcache.md index 083de16a..0770a09e 100644 --- a/test-server/test-advanced/test-tools-advanced-kcache.md +++ b/test-server/test-advanced/test-tools-advanced-kcache.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-ltree.md b/test-server/test-advanced/test-tools-advanced-ltree.md index 76655de0..570862f5 100644 --- a/test-server/test-advanced/test-tools-advanced-ltree.md +++ b/test-server/test-advanced/test-tools-advanced-ltree.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-migration.md b/test-server/test-advanced/test-tools-advanced-migration.md index f386a563..d658ffd8 100644 --- a/test-server/test-advanced/test-tools-advanced-migration.md +++ b/test-server/test-advanced/test-tools-advanced-migration.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-monitoring.md b/test-server/test-advanced/test-tools-advanced-monitoring.md index a40489a6..9baebd7a 100644 --- a/test-server/test-advanced/test-tools-advanced-monitoring.md +++ b/test-server/test-advanced/test-tools-advanced-monitoring.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-partitioning.md b/test-server/test-advanced/test-tools-advanced-partitioning.md index b9b3d6d3..3eccd1d6 100644 --- a/test-server/test-advanced/test-tools-advanced-partitioning.md +++ b/test-server/test-advanced/test-tools-advanced-partitioning.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-partman.md b/test-server/test-advanced/test-tools-advanced-partman.md index 97e13cc4..f574f266 100644 --- a/test-server/test-advanced/test-tools-advanced-partman.md +++ b/test-server/test-advanced/test-tools-advanced-partman.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-performance-part1.md b/test-server/test-advanced/test-tools-advanced-performance-part1.md index 6157bbd0..9c445a88 100644 --- a/test-server/test-advanced/test-tools-advanced-performance-part1.md +++ b/test-server/test-advanced/test-tools-advanced-performance-part1.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-performance-part2.md b/test-server/test-advanced/test-tools-advanced-performance-part2.md index a2d7b70f..226c5c11 100644 --- a/test-server/test-advanced/test-tools-advanced-performance-part2.md +++ b/test-server/test-advanced/test-tools-advanced-performance-part2.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-pgcrypto.md b/test-server/test-advanced/test-tools-advanced-pgcrypto.md index 121704ae..543e8b47 100644 --- a/test-server/test-advanced/test-tools-advanced-pgcrypto.md +++ b/test-server/test-advanced/test-tools-advanced-pgcrypto.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-postgis-part1.md b/test-server/test-advanced/test-tools-advanced-postgis-part1.md index 4a9db014..d002c338 100644 --- a/test-server/test-advanced/test-tools-advanced-postgis-part1.md +++ b/test-server/test-advanced/test-tools-advanced-postgis-part1.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-postgis-part2.md b/test-server/test-advanced/test-tools-advanced-postgis-part2.md index b0def0c0..80a9902b 100644 --- a/test-server/test-advanced/test-tools-advanced-postgis-part2.md +++ b/test-server/test-advanced/test-tools-advanced-postgis-part2.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-roles.md b/test-server/test-advanced/test-tools-advanced-roles.md new file mode 100644 index 00000000..892591f2 --- /dev/null +++ b/test-server/test-advanced/test-tools-advanced-roles.md @@ -0,0 +1,303 @@ +# Advanced Stress Test β€” postgres-mcp β€” roles Group + +**ESSENTIAL INSTRUCTIONS** + +- Execute **EVERY** numbered stress test below using code mode (`pg_execute_code`). +- Do not use scripts or terminal to replace planned tests, run any other test files, or do anything other than these tests. Ignore distractions in terminal from work being done in other thread. +- Do not modify or skip tests. +- All changes **MUST** be consistent with other postgres-mcp tools and `code-map.md`. +- Allow me to handle Lint, typecheck, Vitest, and Playwright. You cannot restart the server in Antigravity as the cache has to be refreshed manually. +- If you have trouble saving task.md because it already exists, use a different filename. +- Please let me handle checking lint, typecheck, vitest, and playwright. You cannot restart the server in antigravity as the cache has to be refreshed manually. + +## Code Mode Execution + +All tests should be executed via `pg_execute_code` code mode. Native direct tool calls are not to be used unless explicitly compared. State persists across sequential code mode logic inside a script. + +## Test Database Schema + +The test database (`postgres`) contains these tables: + +| Table | Rows | Key Columns | JSONB Columns | Tool Groups | +| ------------------- | ---- | ---------------------------------------------------------------------------------- | ------------------------ | --------------------- | +| `test_products` | 15 | id, name, description, price, created_at | β€” | Core, Stats | +| `test_orders` | 20 | id, product_id (FK), quantity, total_price, status | β€” | Core, Stats, Trans | +| `test_jsonb_docs` | 3 | id | metadata, settings, tags | JSONB (20 tools) | +| `test_articles` | 3 | id, title, body, search_vector (TSVECTOR) | β€” | Text | +| `test_measurements` | 500 | id, sensor_id (INT 1-6), temperature, humidity, pressure | β€” | Stats (19 tools) | +| `test_embeddings` | 50 | id, content, category, embedding (vector 384d) | β€” | Vector (16 tools) | +| `test_locations` | 5 | id, name, location (GEOMETRY POINT SRID 4326) | β€” | PostGIS (15 tools) | +| `test_users` | 3 | id, username (CITEXT), email (CITEXT) | β€” | Citext (6 tools) | +| `test_categories` | 6 | id, name, path (LTREE) | β€” | Ltree (8 tools) | +| `test_secure_data` | 0 | id, user_id, sensitive_data (BYTEA), created_at | β€” | pgcrypto (9 tools) | +| `test_events` | 100 | id, event_type, event_date, payload (JSONB) β€” PARTITION BY RANGE | payload | Partitioning, Partman | +| `test_logs` | 0 | id, log_level, message, created_at β€” PARTITION BY RANGE | β€” | Partman | +| `test_departments` | 3 | id, name, budget | β€” | Introspection | +| `test_employees` | 5 | id, name, department_id (FK CASCADE), manager_id (FK self-ref SET NULL), hire_date | β€” | Introspection | +| `test_projects` | 2 | id, name, lead_id (FK SET NULL), department_id (FK RESTRICT) | β€” | Introspection | +| `test_assignments` | 3 | id, employee_id (FK CASCADE), project_id (FK CASCADE), role β€” UNIQUE(emp,proj) | β€” | Introspection | +| `test_audit_log` | 3 | entry_id (no PK!), employee_id (FK, no index!), action, created_at | β€” | Introspection | + +Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_order_summary` (view), `test_get_order_count()` (function). +Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_locations_geo` (GIST), `idx_categories_path` (GIST), HNSW on `test_embeddings.embedding`. + +## Testing Requirements + +1. Use existing `test_*` tables for read operations (SELECT, COUNT, EXISTS, etc.) +2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) +3. Test each tool with realistic inputs based on the schema above +4. Clean up any `stress_*` tables after testing +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. +6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal +7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. +8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. +9. **Scripting Efficiency**: You should bundle multiple tool checks into a single `pg_execute_code` call to save LLM context window tokens. Use conditional checks to aggregate errors and return a `failures` array. +10. **Pacing**: Test up to an entire tool group in a single script if feasible, but limit scripts to ~10-15 steps to remain manageable. Report the aggregated results, update your matrix, and move to the next group. +11. **Deterministic checklist first**: Complete ALL items in the Deterministic Checklist below using Code Mode before moving to the Strict Coverage Matrix exploration. +12. **Audit backup tools**: The 3 `pg_audit_*` tools require `--audit-backup` to be enabled on the test server. When enabled, destructive operations (`pg_truncate`, `pg_drop_table`, `pg_vacuum`, etc.) create gzip-compressed `.snapshot.json.gz` files alongside the audit log. **V2 features to verify**: `pg_audit_diff_backup` now returns a `volumeDrift` field (row count + size changes); `pg_audit_restore_backup` supports `restoreAs` for side-by-side non-destructive restore; and Code Mode calls through `pg_execute_code` that trigger destructive operations are also captured by the interceptor. When disabled, all 3 tools return `{success: false, error: "Audit backup not enabled"}`. + +Note: The isError flag propagation issue has been fixed. P154 structured errors (`{success: false, error: "..."}`) return as parseable JSON objects. During error path testing, verify this: if an invalid Code Mode call returns a raw error string instead of a JSON object with `success` and `error` fields, report it as ❌. + +## Structured Error Response Pattern + +All tools must return errors as structured objects instead of throwing. A thrown error propagates as a raw MCP error, which is unhelpful to clients. The expected pattern: + +```json +{ + "success": false, + "error": "Human-readable error message", + "code": "QUERY_ERROR", + "category": "query", + "recoverable": false +} +``` + +The enriched `ErrorResponse` from `formatHandlerError` always includes `success`, `error`, `code`, `category`, and `recoverable`. Optional fields `suggestion` and `details` may also be present. Some tools include additional context fields (e.g., `pg_transaction_execute` includes `statementsExecuted`, `failedStatement`, `autoRolledBack`). These are acceptable as long as `success: false` and `error` are always present. + +### Handler Error vs MCP Error β€” How to Distinguish + +There are two kinds of error responses. Only one is correct: + +| Type | Source | What you see | Verdict | +| -------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------ | +| **Handler error** βœ… | Handler catches error and returns `{success: false, error: "..."}` | Parseable JSON object with `success` and `error` fields | Correct | +| **MCP error** ❌ | Uncaught throw propagates to MCP framework | Raw text error string, often prefixed with `Error:`, wrapped in an `isError: true` content block β€” no `success` field | Bug β€” report as ❌ | + +**Concrete examples:** + +``` +βœ… Handler error (correct): +{"success": false, "error": "Table \"public.nonexistent\" does not exist"} + +❌ MCP error (bug β€” handler threw instead of catching): +content: [{type: "text", text: "Error: relation \"nonexistent\" does not exist"}] +isError: true +``` + +The MCP error case means the handler is missing a `try/catch` block. When testing, if you see a raw error string (especially one containing PostgreSQL internal messages like `relation "..." does not exist` without a `success` field), report it as ❌. + +### Zod Validation Errors + +Calling a tool with wrong parameter types or missing required fields triggers a Zod validation error. If the handler has no outer `try/catch`, this surfaces as a raw MCP error. Test every tool with `{}` (empty params) if it has required parameters β€” the response must be a handler error, not an MCP error. + +**Error message format matters:** Zod `.refine()` failures produce a `ZodError` whose `.message` property is a **raw JSON array** of Zod issues (e.g., `[{"code":"custom","message":"..."}]`). If the handler catches the error with `error.message` instead of routing through `formatHandlerError`, this raw JSON leaks as the error string. All handlers must route through `formatHandlerError`, which duck-types the `.issues` array and produces clean `Validation error: name (or table alias) is required; Validation error: columns must not be empty` messages. If you see a raw JSON array in an error message, report it as ❌. + +**Zod refinement leak pattern:** The Split Schema pattern uses `.partial()` on input schemas so the SDK accepts `{}`. But `.partial()` only makes keys **optional** β€” it does NOT strip refinements like `.min(1)`, `.max(90)`, or `.min(-90).max(90)`. This applies to **ALL types** β€” strings, arrays, AND numbers: + +- `z.string().min(1)` + empty `""` β†’ SDK rejects with raw MCP `-32602` +- `z.array().min(1)` + empty `[]` β†’ SDK rejects with raw MCP `-32602` +- `z.number().min(-90).max(90)` + value `91` β†’ SDK rejects with raw MCP `-32602` + +**Fix:** Remove ALL `.min(N)` / `.max(N)` refinements from the schema and validate inside the handler instead. Optional fields with `.default()` are safe because the default satisfies the constraint. + +**Required enum coercion pattern:** For **optional** enum params with defaults, `z.preprocess(coercer, z.enum([...]).optional().default(...))` works β€” the coercer returns `undefined` for invalid values β†’ the `.default()` kicks in. For **required** enum params (no `.optional().default(...)`), this pattern **fails**: the SDK's `.partial()` wraps the preprocess in `.optional()`, but the inner `z.enum()` still rejects `undefined` β†’ raw MCP `-32602`. **Fix:** Use `z.string()` in the schema and validate the enum inside the handler's `try/catch`, returning a structured error. + +**What to report:** + +- If a tool call returns a raw MCP error (no JSON body with `success` field), report it as ❌ with the tool name and the raw error message +- If a tool returns `{success: false, error: "..."}` but the error string is a raw Zod JSON array (starts with `[{`), report as ❌ (handler uses `error.message` instead of `formatHandlerError`) +- If a tool returns `{success: false, error: "Validation error: ..."}` with clean human-readable text, that is the correct behavior β€” do not report it as a failure +- If a tool returns a successful response for an obviously invalid input (e.g., nonexistent table returns `{success: true}`), report it as ⚠️ + +## Split Schema Pattern Verification + +All tools use the Split Schema pattern: a plain `z.object()` Base schema for MCP parameter visibility (used as `inputSchema`), and handler-side parsing via `z.preprocess()`, `.default({})`, or direct `.parse()` inside `try/catch`. Verify: + +1. **JSON Schema visibility**: Before testing tool behavior, call `tools/list` (or inspect the MCP server's tool definitions) and confirm each tool's `inputSchema` exposes its parameters. Tools with optional parameters (e.g., `schema`, `limit`, `direction`) must show non-empty `properties` in the JSON Schema. If a tool's `inputSchema` is empty or missing `properties`, report as a Split Schema violation. +2. **Parameter visibility**: For tools with optional parameters (e.g., `schema`, `limit`), make a Code Mode call using those parameters. If the tool ignores or rejects documented parameters, report as a Split Schema violation. +3. **Alias acceptance**: For tools with documented parameter aliases (e.g., table/tableName/name, sql/query), verify that Code Mode calls correctly accept the aliasesβ€”not just the primary parameter name. If a call using only an alias fails with a validation error like "X is required", report it as a Split Schema violation requiring a fix. +4. **`z.preprocess()` as `inputSchema`**: If a tool uses `z.preprocess()` directly as its `inputSchema` (instead of a plain `SchemaBase`), parameter metadata is stripped from JSON Schema generation, making MCP tooling unable to see or use those parameters. Report as a Split Schema violation. + +## P154 Object Existence Verification + +All tools should return structured error responses for nonexistent tables/schemas (via `formatHandlerError`). The 5 core convenience tools (pg_count, pg_exists, pg_upsert, pg_batch_insert, pg_truncate) implement explicit pre-checks and serve as canonical verification targets. Beyond those, **every tool group must have at least one nonexistent-table test in its checklist** β€” see the error-path items (marked πŸ”΄) in each group's checklist in `test-group-tools.md`. + +For each P154 test, verify that calling with a nonexistent table (e.g., `table: "nonexistent_table_xyz"`) returns a handler error like `{success: false, error: "Table \"public.nonexistent_table_xyz\" does not exist"}` rather than a raw MCP error. Also verify that a nonexistent schema (e.g., `table: "fake_schema.users"`) produces a similarly clear handler error. + +Key PostgreSQL error codes that should be intercepted by `formatHandlerError` (not leaked as raw errors): + +| PG Error Code | Meaning | Expected Structured Message | +| ------------- | ------------------- | --------------------------------- | +| 42P01 | Undefined table | `Table "X" does not exist` | +| 42P06 | Duplicate schema | `Schema "X" already exists` | +| 42P07 | Duplicate table | `Table "X" already exists` | +| 42701 | Duplicate column | `Column "X" already exists` | +| 42703 | Undefined column | `Column "X" does not exist` | +| 23505 | Unique violation | `Duplicate key: ...` | +| 23503 | FK violation | `Foreign key constraint violated` | +| 42601 | Syntax error | `SQL syntax error: ...` | +| 3F000 | Invalid schema name | `Schema "X" does not exist` | +| XX000 | Internal error | `Internal error: ...` | + +## Error Consistency Audit + +During testing, check for these inconsistencies across tool groups: + +1. **Throw-vs-return**: If a tool throws a raw error instead of returning `{success: false}`, report as ❌. Document which tool groups have the worst raw-error leakage. +2. **Error field name**: All `{ success: false }` error responses should use `error` as the field name. If a tool uses a different field name for error context in a failure response, report as ⚠️. +3. **Zod validation leaks**: If calling a tool with an invalid enum value or missing required field produces a raw MCP `-32602` Zod validation error instead of a structured response, report as ❌. This indicates the Zod schema is rejecting the input at the MCP framework level before the handler's `try/catch` can intercept. +4. **Missing `formatHandlerError` wrapping**: postgres-mcp has a centralized `formatHandlerError` helper. If a handler catches errors but returns ad-hoc messages instead of using the centralized formatter, report which handler and the ad-hoc message pattern. +5. **Orphaned output schemas**: If a schema is exported from `src/adapters/postgresql/schemas/` but the corresponding tool definition does not reference it via `outputSchema`, report as ⚠️. Use `grep_search` to check whether the schema name appears in any tool file. Defined-but-unwired schemas provide zero enforcement. +6. **Inline output schemas**: If any tool defines `outputSchema: z.object({...})` inline in the handler file instead of importing a named schema from the `schemas/` directory, report as ⚠️. All output schemas must live in the appropriate `schemas/` directory with named exports. + +## Error Path Testing Checklist + +For each tool group under test, verify at least one scenario from each applicable row: + +| Error Scenario | Tool Groups to Test | Example Input | +| --------------------------------- | ------------------------------------- | ----------------------------------------------------------------------- | +| Nonexistent table | All table-accepting tools | `table: "nonexistent_xyz"` | +| Nonexistent schema | Core, introspection, schema | `schema: "fake_schema"` or `table: "fake_schema.users"` | +| Invalid SQL syntax | Core (`read_query`, `write_query`) | `sql: "SELECTT * FROM"` | +| Invalid column name | Stats, JSONB, text, vector, PostGIS | `column: "nonexistent_col"` | +| Duplicate table/index | Core (`create_table`, `create_index`) | Create existing table | +| Empty required array | Transactions | `statements: []` | +| Missing required field via alias | Core, transactions | `sql` alias instead of `query` | +| **Zod validation (empty params)** | **Every tool with required params** | `{}` (empty object β€” must return handler error, not MCP `-32602` error) | +| **Zod validation (wrong type)** | **Tools with typed params** | Pass string where number expected, etc. | + +## Cleanup Conventions + +During testing, use these naming conventions: + +- **Temporary tables**: Prefix with `stress_` (e.g., `stress_rls_demo`) +- **Temporary roles**: Prefix with `stress_test_role_` (e.g., `stress_test_role_analyst`) +- **Test views**: Prefix with `test_view_` (e.g., `test_view_order_summary`) +- **Test functions**: Prefix with `test_func_` (e.g., `test_func_calculate`) +- **Test schemas**: Prefix with `test_schema_` (e.g., `test_schema_temp`) + +After testing, clean up: + +```sql +-- List temp tables +SELECT tablename FROM pg_tables +WHERE schemaname = 'public' AND tablename LIKE 'stress_%'; + +-- Drop temp table +DROP TABLE IF EXISTS stress_rls_demo; + +-- Drop temp roles +DROP ROLE IF EXISTS stress_test_role_analyst; +DROP ROLE IF EXISTS stress_test_role_writer; +``` + +## Post-Test Procedures + +### Reporting Rules + +- Use βœ… only in inline notes during testing; omit from Final Summary +- Do not mention what already works well or issues already documented in server-instructions.md and runtime hints + +### After Testing + +1. **Cleanup**: Confirm all `stress_*` tables, `stress_test_role_*` roles, and temporary testing data are removed +2. **Fix EVERY finding** β€” not just ❌ Fails, but also ⚠️ Issues including behavioral improvements, missing warnings, error code consistency, πŸ“¦ Payload problems (responses that should be truncated or offer a `limit` param) and files listed below. All changes MUST be consistent with other postgres-mcp tools and `code-map.md` +3. **Scope of fixes** includes corrections to any of: + - Handler code + - `server-instructions.md` + - Test database (`test-database.sql`) + - This prompt (`test-tools-codemode.md`) and group file (`test-group-tools-codemode.md`) +4. Update the changelog with any changes made (being careful not to create duplicate headers), and commit without pushing. +5. **Token Audit**: Before concluding, call `read_resource` on `postgres://audit` to retrieve the `sessionTokenEstimate` (total token usage) for your testing session. Include this "Total Token Usage" in your final test report and session summary. Highlight the single most expensive Code Mode execution block. +6. Stop and briefly summarize the testing results and fixes, **ensuring the total token count is prominently displayed.** + +--- + +## roles Group Advanced Tests + +### roles Group Tools (12 + 1 code mode) + +1. `pg_role_list` +2. `pg_role_create` +3. `pg_role_drop` +4. `pg_role_attributes` +5. `pg_role_grants` +6. `pg_role_grant` +7. `pg_role_assign` +8. `pg_role_revoke` +9. `pg_user_roles` +10. `pg_role_set` +11. `pg_role_rls_enable` +12. `pg_role_rls_policies` +13. `pg_execute_code` (auto-added) + +### Category 1: Boundary Values & Empty States + +Test tools against extreme parameters, zero-state inputs, and boundary sizing. + +1. `pg_role_list` β†’ Supply `pattern: ""` empty-string filter. Verify whether handler returns all roles or clamps to safe default. +2. `pg_role_create` β†’ Create role with maximum-length name (63 chars, PostgreSQL identifier limit): `name: "stress_test_role_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"`. Verify success and that `pg_role_attributes` returns the full name. Then drop it. +3. `pg_role_rls_policies` β†’ Call on `stress_rls_demo` table with zero policies. Verify clean empty array returned (not null, not error). +4. `pg_role_grants` β†’ Call on freshly-created `stress_test_role_empty` with zero grants. Verify response structure has empty arrays/objects β€” no null leakage. + +### Category 2: State Pollution & Idempotency + +Ensure tools execute safely when repeated identically multiple times. + +5. `pg_role_create` β†’ Create `stress_test_role_dup`, then attempt to create same role again. Verify second call returns structured duplicate error `{success: false}` β€” not a raw PostgreSQL `42710` (duplicate object) MCP error. +6. `pg_role_drop` β†’ Drop `stress_test_role_dup`, then attempt to drop again. Verify IF EXISTS safety β€” second call returns `{success: true}` or structured "does not exist" message, not a raw error. +7. `pg_role_list` β†’ Execute consecutively 3 times inside a single Code Mode script. Verify all 3 responses return identical role counts β€” no state pollution or drift. + +### Category 3: Alias & Parameter Combinations + +Test parametric fallback modes and configuration matrices. + +8. `pg_role_grant` β†’ Execute privilege grant matrix across all privilege types (`SELECT`, `INSERT`, `UPDATE`, `DELETE`, `ALL`) on `stress_rls_demo` table for `stress_test_role_privtest`. Verify each privilege is accepted. Then verify `pg_role_grants` shows all expected privileges. Revoke ALL at end. +9. `pg_role_create` β†’ Create roles with different attribute combinations in sequence: `{login: true}`, `{createdb: true}`, `{createrole: true}`, `{connectionLimit: 5}`, `{validUntil: "2099-12-31"}`. Verify `pg_role_attributes` for each confirms the set attribute. Drop all at end. +10. `pg_role_rls_enable` β†’ Toggle RLS enableβ†’disableβ†’enable on `stress_rls_demo`. After each toggle, query `pg_class.relrowsecurity` directly to verify the actual catalog state matches the tool's response. + +### Category 4: Error Message Quality + +Ensure tools predictably return typed structured errors with quality messages. + +11. `pg_role_create` β†’ Pass SQL injection attempt: `name: "'; DROP TABLE test_products;--"`. Verify parameterized DDL safely rejects with structured error `{success: false}` and that `test_products` still exists afterwards (verify via `pg.count({table: "test_products"})`). +12. `pg_role_grant` β†’ Grant on a table in a nonexistent schema: `table: "fake_schema.test_products"`. Verify clean structured error returned with schema-qualified message. + +### Category 5: Complex Flow Architectures + +Verify that multi-tool pipelines compose correctly across the roles tool surface. + +13. Full RBAC Pipeline β†’ Execute the following sequence in a single Code Mode script: + - `pg.roles.create({name: "stress_test_role_app"})` β€” create application role + - `pg.roles.create({name: "stress_test_role_user", login: true})` β€” create user role + - `pg.roles.grant({role: "stress_test_role_app", privileges: ["SELECT", "INSERT"], table: "test_products"})` β€” grant table privileges + - `pg.roles.assign({role: "stress_test_role_app", member: "stress_test_role_user"})` β€” assign membership + - `pg.roles.userRoles({role: "stress_test_role_user"})` β†’ verify `stress_test_role_app` membership + - `pg.roles.rlsEnable({table: "stress_rls_demo", enable: true})` β€” enable RLS + - `pg.roles.rlsPolicies({table: "stress_rls_demo"})` β†’ verify policies visible (empty but structured) + - `pg.roles.set({role: "stress_test_role_app"})` β€” switch active role + - `pg.roles.set({reset: true})` β€” reset back to postgres + - `pg.roles.revoke({role: "stress_test_role_app", member: "stress_test_role_user"})` β€” revoke membership + - `pg.roles.drop({name: "stress_test_role_user"})` β€” drop user + - `pg.roles.drop({name: "stress_test_role_app"})` β€” drop app role + - Verify each step returns `{success: true}` and the full pipeline round-trips cleanly with zero residual state. + +### Category 6: Large Payload & Truncation Verification + +Ensure sweeping reads cap context window exposure. + +14. `pg_role_list` β†’ Execute with no filter (all system + user roles). Monitor `metrics.tokenEstimate` strictly. Report if payload exceeds 2000 tokens (many system roles may inflate output). Verify response includes role count metadata. + +### Final Cleanup + +15. Verify no `stress_*` tables or `stress_test_role_*` roles remain. Execute `pg.execute("SELECT rolname FROM pg_roles WHERE rolname LIKE 'stress_test_role_%'")` and assert zero rows. Execute `pg.execute("SELECT tablename FROM pg_tables WHERE tablename LIKE 'stress_%' AND schemaname = 'public'")` and assert zero rows. diff --git a/test-server/test-advanced/test-tools-advanced-schema.md b/test-server/test-advanced/test-tools-advanced-schema.md index 116922af..ec7ac71b 100644 --- a/test-server/test-advanced/test-tools-advanced-schema.md +++ b/test-server/test-advanced/test-tools-advanced-schema.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-cross-group.md b/test-server/test-advanced/test-tools-advanced-security.md similarity index 81% rename from test-server/test-advanced/test-tools-advanced-cross-group.md rename to test-server/test-advanced/test-tools-advanced-security.md index 5ff53acc..1fbf1801 100644 --- a/test-server/test-advanced/test-tools-advanced-cross-group.md +++ b/test-server/test-advanced/test-tools-advanced-security.md @@ -1,10 +1,10 @@ -# Advanced Stress Test β€” postgres-mcp β€” cross-group Integration +# Advanced Stress Test β€” postgres-mcp β€” security Group **ESSENTIAL INSTRUCTIONS** - Execute **EVERY** numbered stress test below using code mode (`pg_execute_code`). -- Do not use scripts or terminal to replace planned tests. -- Do not modify or skip tests, run any other test files, or do anything other than these tests. Ignore distractions in terminal from work being done in other thread. +- Do not use scripts or terminal to replace planned tests, run any other test files, or do anything other than these tests. Ignore distractions in terminal from work being done in other thread. +- Do not modify or skip tests. - All changes **MUST** be consistent with other postgres-mcp tools and `code-map.md`. - Allow me to handle Lint, typecheck, Vitest, and Playwright. You cannot restart the server in Antigravity as the cache has to be refreshed manually. - If you have trouble saving task.md because it already exists, use a different filename. @@ -12,13 +12,7 @@ ## Code Mode Execution -All tests should be executed via `pg_execute_code` code mode. Code Mode is explicitly designed for multi-group coordination inside a single sandboxed worker. - -**Key rules:** - -- Use `pg..help()` to discover method names and parameters natively. -- State **persists** across `pg_execute_code` calls. -- Group multiple related tests into a single code mode call cleanly. +All tests should be executed via `pg_execute_code` code mode. Native direct tool calls are not to be used unless explicitly compared. State persists across sequential code mode logic inside a script. ## Test Database Schema @@ -53,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. @@ -224,32 +218,64 @@ DROP TABLE IF EXISTS stress_my_test_table; --- -## cross-group Advanced Workflows +## security Group Advanced Tests + +### security Group Tools (9 + 1 code mode) + +1. `pg_security_audit` +2. `pg_security_firewall_status` +3. `pg_security_firewall_rules` +4. `pg_security_ssl_status` +5. `pg_security_encryption_status` +6. `pg_security_password_validate` +7. `pg_security_mask_data` +8. `pg_security_user_privileges` +9. `pg_security_sensitive_tables` +10. `pg_execute_code` (auto-added) + +### Category 1: Boundary Values & Empty States + +Test tools against extreme parameters, zero-state inputs, and boundary sizing. + +1. `pg_security_audit` β†’ Supply `limit: 0` boundary edge case. Verify whether handler returns empty findings array or clamps to minimum natively. +2. `pg_security_audit` β†’ Supply extreme `limit: 999999` constraint. Evaluate whether handler caps or returns unbounded findings. +3. `pg_security_password_validate` β†’ Supply `password: ""` empty string. Verify Zod `min(1)` enforcement triggers structured handler error (not raw MCP `-32602`). Note: PasswordValidateSchema uses `z.string().min(1)` β€” this is the exact Zod refinement leak pattern documented above. Confirm this is handled correctly. +4. `pg_security_mask_data` β†’ Supply `value: ""` with `type: "email"`. Verify empty-value masking produces deterministic empty-state output. + +### Category 2: State Pollution & Idempotency + +Ensure tools execute safely when repeated identically multiple times. + +5. `pg_security_audit` β†’ Execute consecutively 3 times inside a single Code Mode execution. Verify all 3 responses are structurally identical (same finding count, same summary) β€” no state pollution. +6. `pg_security_ssl_status` β†’ Execute consecutively 3 times. Verify `sslEnabled` value is stable across all calls. + +### Category 3: Alias & Parameter Combinations + +Test parametric fallback modes and configuration matrices. + +7. `pg_security_mask_data` β†’ Execute a matrix across all 5 masking types (`email`, `phone`, `ssn`, `credit_card`, `partial`) using the same input value `"12345678901234567890"`. Verify each type produces a distinct masked output, and none throw. +8. `pg_security_sensitive_tables` β†’ Execute with increasing pattern counts: 1 pattern (`["email"]`), 5 patterns (`["email", "password", "token", "key", "ssn"]`), then all 13 defaults (no patterns arg). Verify result count increases monotonically with pattern breadth. -> **Purpose**: Test realistic deep multi-group pipelines dynamically tracked purely inside Javascript worker threads. These catch serialization, token bound, isolation, and handler decoupling bugs that identical API calls miss natively. +### Category 4: Error Message Quality -### Category 1: Core β†’ JSONB β†’ Stats (Data Pipeline) +Ensure tools predictably return typed structured errors with quality messages. -1. Create a `stress_cross_data` table mapping `SERIAL` id to `JSONB` blobs. - a) Populate 100 rows containing nested numeric telemetry inside the JSONB structure using `pg.core.batchInsert`. - b) Invoke `pg.jsonb.extract` to cleanly extract the nested array values across all rows. - c) Bridge the extracted dynamic arrays safely into `pg.stats.percentiles` to verify math operations on dynamically transformed JSON arrays limit accurately. +9. `pg_security_user_privileges` β†’ Pass SQL injection attempt: `user: "'; DROP TABLE test_products;--"`. Verify P154 parameterized query safely rejects with structured error `{success: false}` and no table is dropped (verify `test_products` still exists afterwards). +10. `pg_security_sensitive_tables` β†’ Pass path traversal attempt: `schema: "../../../etc"`. Verify structured error returned with clean message. -### Category 2: Core β†’ Vector β†’ Text (AI Search Pipeline) +### Category 5: Complex Flow Architectures -2. Create `stress_cross_ai` mapping `VECTOR`, `TEXT`, and `JSONB` parameters cleanly. - a) Inject 3 rows with explicit embeddings and text descriptions. - b) Capture `pg.vector.search` locally to find the nearest neighbor. - c) Execute `pg.text.search` purely using the extracted ID from the vector search to confirm string metadata alignment safely. +Verify that multi-tool pipelines compose correctly across the security tool surface. -### Category 3: Transactions β†’ Backup β†’ Migration (Exception IPC Parity) +11. Dynamic Security Pipeline β†’ Execute `pg.security.audit()` to get findings β†’ extract any `critical` or `warn` severity items β†’ use `pg.security.userPrivileges({summary: true})` to get role exposure β†’ use `pg.security.sensitiveTables()` to identify data exposure β†’ compose a JSON report object aggregating all three results. Verify the pipeline produces a well-formed composite report with `auditFindings`, `roleExposure`, and `dataExposure` keys. -3. Deep Handler Validation: Call `pg.transactions.begin` then `pg.migration.apply`. Force a synthetic parser failure seamlessly (e.g. invalid migration path). Ensure `pg.transactions.rollback` smartly cleans up the migration partial state cleanly, and retrieve the audit log inside the same script using `pg.backup` tools (e.g., `auditListBackups`) or `pg.migration.history` to verify the rollback was recorded gracefully. +### Category 6: Large Payload & Truncation Verification -### Category 4: Vector β†’ JSONB β†’ Code Mode Context Limits +Ensure sweeping reads cap context window exposure. -4. Inject 500 large mock vectors directly into a Code Mode array and batch insert them, immediately pulling them back via `pg.jsonb.extract` and reading the payload size natively. Verify the sandbox context limits gracefully reject massive allocations or return `metrics.tokenEstimate` effectively. +12. `pg_security_user_privileges` β†’ Execute with no filters (all roles). Monitor `metrics.tokenEstimate` strictly. Report if payload exceeds 2000 tokens (many system roles may inflate output). +13. `pg_security_sensitive_tables` β†’ Execute with `limit: 1000` and all 13 default patterns. Monitor payload size and verify `limited` field behavior when result count exceeds limit threshold. -### Final Reporting +### Final Cleanup -Verify completely flawlessly that all state chains correctly dropped and temporary `stress_cross_` structures are removed cleanly. +14. Verify no `stress_*` tables were created (security tools are read-only/pure-JS β€” no write artifacts expected). diff --git a/test-server/test-advanced/test-tools-advanced-stats-part1.md b/test-server/test-advanced/test-tools-advanced-stats-part1.md index 0a9f9304..5ca45f69 100644 --- a/test-server/test-advanced/test-tools-advanced-stats-part1.md +++ b/test-server/test-advanced/test-tools-advanced-stats-part1.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-stats-part2.md b/test-server/test-advanced/test-tools-advanced-stats-part2.md index 1887ff48..6d75e1ca 100644 --- a/test-server/test-advanced/test-tools-advanced-stats-part2.md +++ b/test-server/test-advanced/test-tools-advanced-stats-part2.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-text.md b/test-server/test-advanced/test-tools-advanced-text.md index 9b7f8f89..9de47bf9 100644 --- a/test-server/test-advanced/test-tools-advanced-text.md +++ b/test-server/test-advanced/test-tools-advanced-text.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-transactions.md b/test-server/test-advanced/test-tools-advanced-transactions.md index c0e8987c..d738b6de 100644 --- a/test-server/test-advanced/test-tools-advanced-transactions.md +++ b/test-server/test-advanced/test-tools-advanced-transactions.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-vector-part1.md b/test-server/test-advanced/test-tools-advanced-vector-part1.md index 4f74af2a..d51683cb 100644 --- a/test-server/test-advanced/test-tools-advanced-vector-part1.md +++ b/test-server/test-advanced/test-tools-advanced-vector-part1.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-advanced/test-tools-advanced-vector-part2.md b/test-server/test-advanced/test-tools-advanced-vector-part2.md index 4c1f9fa5..16abce2c 100644 --- a/test-server/test-advanced/test-tools-advanced-vector-part2.md +++ b/test-server/test-advanced/test-tools-advanced-vector-part2.md @@ -47,7 +47,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `stress_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `stress_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. diff --git a/test-server/test-agent-experience.md b/test-server/test-agent-experience.md deleted file mode 100644 index a938b7f6..00000000 --- a/test-server/test-agent-experience.md +++ /dev/null @@ -1,319 +0,0 @@ -# Agent Experience Test β€” postgres-mcp - -> **Purpose:** Validate that the slim `instructions` field + `postgres://help` resources are sufficient for an agent to operate the server cold β€” with **zero** schema info, tool hints, or checklists in the prompt. - -## How to Run - -Run **each pass** as a separate conversation with the corresponding `--tool-filter`. Each pass tests whether the agent can complete realistic tasks using only the tools + help resources available under that filter. - -| Pass | `--tool-filter` | Tools | Scenarios | -| ------ | ------------------------------ | -------------------------------------- | --------- | -| Pass 1 | `starter` | Core, Trans, JSONB, Schema (~60) | 1–13 | -| Pass 2 | `dev-analytics` | Core, Trans, Stats, Partitioning (~54) | 14–19 | -| Pass 3 | `ai-data` | Core, JSONB, Text, Trans (~61) | 20–22 | -| Pass 4 | `ai-vector` | Core, Vector, Trans, Part (~51) | 23–25 | -| Pass 5 | `geo` | Core, PostGIS, Trans (~44) | 26–28 | -| Pass 6 | `dba-monitor` | Core, Monitoring, Perf, Trans (~64) | 29–31 | -| Pass 7 | `dba-infra` | Core, Admin, Backup, Part (~46) | 32–36, 44 | -| Pass 8 | `core,introspection,migration` | Core, Introspection, Migration (~33) | 37–39 | -| Pass 9 | `codemode` | Code Mode only (1+3) | 40–43 | - -> **Important:** Do NOT combine passes. Each pass is a fresh conversation with a clean context. The agent has never seen this database before. - -## Rules - -1. **Do NOT read** `test-tools.md`, `test-group-tools.md`, or any other test documentation before running these scenarios -2. **Do NOT read** source code files (`src/`) β€” you are a user, not a developer -3. **DO** use the MCP instructions you received during initialization + `postgres://help` resources -4. **DO** discover the database schema via `postgres://schema` or `postgres://tables` resources -5. **DO** read group-specific help (`postgres://help/{group}`) when you need reference for unfamiliar tools -6. The test database is already connected (Docker container `postgres-server`, database `postgres`) - -## Success Criteria - -| Symbol | Meaning | -| ------ | --------------------------------------------------------------------- | -| βœ… | Agent completed the task correctly without external help | -| ⚠️ | Agent completed but needed multiple retries or used wrong tools first | -| ❌ | Agent failed or produced incorrect results | -| πŸ“– | Agent had to read help resources β€” note which ones | - -Track **every** help resource read and whether it provided what was needed. Gaps are the actionable finding. - -## Reporting Format - -For each scenario, report: - -``` -### Scenario N: [title] -**Result:** βœ…/⚠️/❌ -**Resources read:** postgres://help, postgres://help/jsonb (or "none beyond instructions") -**Tools used:** pg_read_query, pg_jsonb_extract, ... -**Issues:** (any gaps in help content, confusing tool names, missing examples) -``` - ---- - -## Pass 1: `starter` - -**Tool groups under test:** `core` (21), `transactions` (9), `jsonb` (20), `schema` (13), `codemode` (1) - -### Phase 1 β€” Discovery - -#### Scenario 1 β€” What's in this database? - -List all tables and briefly describe what each one contains, including any partitioned tables and special column types. - -#### Scenario 2 β€” Table deep dive - -Pick the most interesting table and fully characterize it: row count, column types, indexes, constraints, and any foreign key relationships. - -#### Scenario 3 β€” Health check - -Is the database healthy? What PostgreSQL version is running? What are the key settings (shared_buffers, work_mem, etc.)? - -### Phase 2 β€” Core Operations - -#### Scenario 4 β€” Filtered read - -Find all products priced above $50, sorted by price descending. - -#### Scenario 5 β€” Aggregation - -What is the total revenue (sum of total_price) per order status? Which status has the highest revenue? - -#### Scenario 6 β€” Write and verify - -Create a new product called "Test Widget" priced at $29.99, then verify it was inserted. Clean up after. - -### Phase 3 β€” JSONB Operations - -#### Scenario 7 β€” JSONB extraction - -Extract the `name` field from the JSONB `metadata` column in `test_jsonb_docs`. What keys exist at the top level? - -#### Scenario 8 β€” Nested JSONB - -Query for documents where `metadata->'nested'->'key'` has a specific value. Does the agent navigate JSONB paths correctly? - -#### Scenario 9 β€” JSONB analysis - -Analyze the structure of the `settings` column in `test_jsonb_docs`. What field types and nesting patterns exist? - -#### Scenario 10 β€” JSONB formatting - -Pretty-print the JSONB metadata for the first document in `test_jsonb_docs` in a human-readable format. Can the agent present nested JSON structures readably? - -### Phase 4 β€” Schema Management - -#### Scenario 11 β€” Schema exploration - -List all schemas, views, sequences, and functions in the database. How many are user-created vs system? - -#### Scenario 12 β€” View management - -Create a view called `test_view_order_summary` that joins products and orders. Query it. Clean up after. - -#### Scenario 13 β€” Constraint analysis - -List all constraints on `test_orders`. What types are they (PK, FK, CHECK, UNIQUE)? - ---- - -## Pass 2: `dev-analytics` - -**Tool groups under test:** `core` (21), `transactions` (9), `stats` (20), `partitioning` (7), `codemode` (1) - -### Phase 5 β€” Statistics - -#### Scenario 14 β€” Descriptive stats - -Compute descriptive statistics (mean, median, std dev, min, max) for the `temperature` column in `test_measurements`. Break it down by `sensor_id`. - -#### Scenario 15 β€” Correlation - -Is there a correlation between temperature and humidity in `test_measurements`? How strong? - -#### Scenario 16 β€” Window function analysis - -Rank all sensors by their average temperature. For each sensor, show a running total of temperature readings over time. Which sensor shows the most temperature variation? - -#### Scenario 17 β€” Outlier detection - -Are there any anomalous temperature readings in `test_measurements`? Identify statistical outliers and explain what makes them unusual compared to the overall distribution. - -#### Scenario 18 β€” Multi-column summary - -Give me a quick statistical overview of all numeric columns in `test_measurements`. Which columns have the highest variance? How many distinct sensor IDs are there? - -#### Scenario 19 β€” Partition inspection - -How are `test_events` partitioned? List the partitions, their ranges, and row counts. - ---- - -## Pass 3: `ai-data` - -**Tool groups under test:** `core` (21), `jsonb` (20), `text` (14), `transactions` (9), `codemode` (1) - -### Phase 6 β€” Text & Full-Text Search - -#### Scenario 20 β€” Full-text search - -Search `test_articles` for articles about "database" and "index". Rank results by relevance using the tsvector column. - -#### Scenario 21 β€” Fuzzy matching - -Find users in `test_users` whose names are similar to "jon" (case-insensitive, fuzzy). Did citext affect the results? - -#### Scenario 22 β€” JSONB + Text combo - -Find events in `test_events` where the JSONB `payload` contains a specific key, and filter by event_type using text matching. - ---- - -## Pass 4: `ai-vector` - -**Tool groups under test:** `core` (21), `vector` (17), `transactions` (9), `partitioning` (7), `codemode` (1) - -### Phase 7 β€” Vector & Semantic Search - -#### Scenario 23 β€” Similarity search - -Find the 5 embeddings most similar to the first embedding in `test_embeddings`. What categories are they? - -#### Scenario 24 β€” Filtered vector search - -Search for similar embeddings but only within the "tech" category. Can the agent combine metadata filters with vector search? - -#### Scenario 25 β€” Vector stats - -What are the dimensions of the embeddings? How many vectors are stored? What index type is used? - ---- - -## Pass 5: `geo` - -**Tool groups under test:** `core` (21), `postgis` (16), `transactions` (9), `codemode` (1) - -### Phase 8 β€” Geospatial - -#### Scenario 26 β€” Distance between cities - -What is the distance between New York (or NYC) and Tokyo based on the geometry data in `test_locations`? - -#### Scenario 27 β€” Nearby locations - -Find all locations within 10,000 km of London. How many are there? - -#### Scenario 28 β€” Spatial query - -Find all locations within a bounding box covering North America. Which cities are included? - ---- - -## Pass 6: `dba-monitor` - -**Tool groups under test:** `core` (21), `monitoring` (12), `performance` (25), `transactions` (9), `codemode` (1) - -### Phase 9 β€” Monitoring & Performance - -#### Scenario 29 β€” Database overview - -What are the current database size, active connections, and cache hit ratio? - -#### Scenario 30 β€” Slow query analysis - -Are there any long-running queries? What are the top queries by total execution time? - -#### Scenario 31 β€” Table bloat - -Check for table bloat across all test tables. Which tables, if any, would benefit from a VACUUM? - ---- - -## Pass 7: `dba-infra` - -**Tool groups under test:** `core` (21), `admin` (11), `backup` (12), `partitioning` (7), `codemode` (1) - -### Phase 10 β€” Admin & Infrastructure - -#### Scenario 32 β€” Database maintenance - -Run ANALYZE on all test tables. Then check the vacuum and analyze stats β€” when were tables last maintained? - -#### Scenario 33 β€” Backup - -Create a logical dump of the `test_products` table. Verify the dump was created successfully. - -#### Scenario 34 β€” Partition management - -Inspect the partitioning setup for `test_events`. Can the agent identify the partition strategy and boundaries? - -#### Scenario 35 β€” Insight memo - -As you investigate the database health, record your key findings as insights so they can be reviewed later via the insights resource. Append at least 3 observations about the database state, then verify they're accessible. - -#### Scenario 36 β€” Audit trail, recovery, and non-destructive restore - -Create a table, insert data, then truncate it (triggering a pre-mutation snapshot). List the snapshot. Add a column to simulate schema drift, then diff the snapshot β€” can the agent read the `volumeDrift` information without being told it exists? Finally, restore β€” but use **non-destructive restore** (`restoreAs`) to recover the original schema alongside the current drifted table, rather than overwriting it. Can the agent complete the full "oops β†’ recover safely" workflow using only the backup tools and help resources, without any prior knowledge of `restoreAs` or `volumeDrift`? - -#### Scenario 44 β€” Safe restore workflow prompt - -The server provides a prompt called `pg_safe_restore_workflow`. Without being told what it does, invoke it and follow its guidance to recover a table that has diverged from a known-good snapshot. Does the prompt provide enough context for the agent to complete the workflow safely? - ---- - -## Pass 8: `core,introspection,migration` - -**Tool groups under test:** `core` (21), `introspection` (7), `migration` (7), `codemode` (1) - -### Phase 11 β€” Schema Analysis & Migration - -#### Scenario 37 β€” Dependency graph - -Map out the foreign key dependency graph starting from `test_departments`. What's the full cascade chain? Which tables depend on it? - -#### Scenario 38 β€” Cascade simulation - -What would happen if `test_departments` row 1 were deleted? Simulate the cascade impact on employees, projects, and assignments. - -#### Scenario 39 β€” Migration workflow - -Initialize migration tracking, then create and apply a migration that adds a `description` column to `test_products`. Roll it back after verifying. - ---- - -## Pass 9: `codemode` - -**Tool groups under test:** `codemode` (1) + built-in resources (3) - -### Phase 12 β€” Code Mode Discovery & Efficiency - -#### Scenario 40 β€” Cold-start Code Mode - -Using only `pg_execute_code`, list all tables, pick one, and run a query against it. Can the agent discover the `pg.*` API without external help? - -#### Scenario 41 β€” Multi-step workflow - -Using only `pg_execute_code`, find the top 5 products by order count with total revenue β€” in a single code execution. - -#### Scenario 42 β€” Cross-group orchestration - -Using only `pg_execute_code`, do a full data quality audit: check for NULLs, orphaned FKs, missing indexes, and table bloat β€” all in one execution. Compare the token efficiency vs individual tool calls. - -#### Scenario 43 β€” Stats pipeline via Code Mode - -Using only `pg_execute_code`, compute outlier detection on `test_measurements.temperature`, then get the frequency distribution of `sensor_id`, and summarize all numeric columns β€” all in a single execution. Can the agent discover the `pg.stats.*` API methods? - ---- - -## Post-Test Summary - -Compile findings across all passes into: - -1. **Help resource gaps** β€” scenarios where help content was missing, incomplete, or misleading (44 scenarios total) -2. **Discovery friction** β€” cases where the agent struggled to find the right tool or resource -3. **Suggested improvements** β€” specific additions to `src/constants/server-instructions/*.md` - -> **Key metric:** How many of the 44 scenarios did the agent complete on the first try with ≀1 help resource read? This measures whether the instructions + tool descriptions are self-sufficient. diff --git a/test-server/test-database.sql b/test-server/test-database.sql index 754fc66a..846c735b 100644 --- a/test-server/test-database.sql +++ b/test-server/test-database.sql @@ -1,3 +1,32 @@ +-- Ensure public schema exists (in case stress tests dropped it) +CREATE SCHEMA IF NOT EXISTS public; +GRANT ALL ON SCHEMA public TO public; + +-- Ensure required extensions are installed (tests might drop them) +CREATE EXTENSION IF NOT EXISTS ltree SCHEMA public CASCADE; +CREATE EXTENSION IF NOT EXISTS vector SCHEMA public CASCADE; +CREATE EXTENSION IF NOT EXISTS postgis SCHEMA public CASCADE; +CREATE EXTENSION IF NOT EXISTS citext SCHEMA public CASCADE; +CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA public CASCADE; +CREATE EXTENSION IF NOT EXISTS pg_stat_statements SCHEMA public CASCADE; +CREATE EXTENSION IF NOT EXISTS pg_stat_kcache SCHEMA public CASCADE; +CREATE EXTENSION IF NOT EXISTS pg_partman SCHEMA public CASCADE; + +-- Move extensions to public if they were accidentally installed in topology +DO $$ +BEGIN + EXECUTE 'ALTER EXTENSION ltree SET SCHEMA public'; + EXECUTE 'ALTER EXTENSION vector SET SCHEMA public'; + EXECUTE 'ALTER EXTENSION postgis SET SCHEMA public'; + EXECUTE 'ALTER EXTENSION citext SET SCHEMA public'; + EXECUTE 'ALTER EXTENSION pgcrypto SET SCHEMA public'; + EXECUTE 'ALTER EXTENSION pg_stat_statements SET SCHEMA public'; + EXECUTE 'ALTER EXTENSION pg_stat_kcache SET SCHEMA public'; + EXECUTE 'ALTER EXTENSION pg_partman SET SCHEMA public'; +EXCEPTION WHEN OTHERS THEN + -- Ignore errors if extension doesn't exist yet or already in public +END $$; + -- Core test tables CREATE TABLE test_products ( id SERIAL PRIMARY KEY, @@ -272,3 +301,16 @@ INSERT INTO test_assignments (employee_id, project_id, role) VALUES INSERT INTO test_audit_log (entry_id, employee_id, action) VALUES (1, 1, 'login'), (2, 2, 'update_profile'), (3, 1, 'logout'); + +-- Docstore test collection (JSONB document store) +CREATE TABLE test_documents ( + _id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + doc JSONB NOT NULL DEFAULT '{}'::jsonb +); + +INSERT INTO test_documents (_id, doc) VALUES + ('doc-001', '{"name": "Alice", "age": 30, "tags": ["admin", "user"], "address": {"city": "NYC"}}'), + ('doc-002', '{"name": "Bob", "age": 25, "tags": ["user"], "address": {"city": "LA"}}'), + ('doc-003', '{"name": "Charlie", "age": 35, "tags": ["admin"], "address": {"city": "Chicago"}}'), + ('doc-004', '{"name": "Diana", "age": 28, "tags": ["user", "moderator"], "address": {"city": "London"}}'), + ('doc-005', '{"name": "Eve", "age": 32, "tags": ["admin", "user"], "address": {"city": "Tokyo"}}'); diff --git a/test-server/test-resources.sql b/test-server/test-resources.sql index cf19aea9..cb7b4f60 100644 --- a/test-server/test-resources.sql +++ b/test-server/test-resources.sql @@ -117,24 +117,28 @@ ON CONFLICT DO NOTHING; -- PARTMAN RESOURCE: Create a partman-managed table -- ============================================================================ DO $$ +DECLARE + v_schema TEXT; + v_dummy INT; BEGIN -- Only if pg_partman is installed IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_partman') THEN + SELECT extnamespace::regnamespace::text INTO v_schema FROM pg_extension WHERE extname = 'pg_partman'; -- Check if already configured - IF NOT EXISTS (SELECT 1 FROM public.part_config WHERE parent_table = 'public.test_logs') THEN + EXECUTE format('SELECT 1 FROM %I.part_config WHERE parent_table = ''public.test_logs''', v_schema) INTO v_dummy; + IF v_dummy IS NULL THEN -- First create the template and initial partition - PERFORM public.create_parent( - p_parent_table := 'public.test_logs', - p_control := 'created_at', - p_interval := '1 day', - p_premake := 7, - p_start_partition := (NOW() - INTERVAL '14 days')::text - ); + EXECUTE format(' + SELECT %I.create_parent( + p_parent_table := ''public.test_logs'', + p_control := ''created_at'', + p_interval := ''1 day'', + p_premake := 7, + p_start_partition := (NOW() - INTERVAL ''14 days'')::text + )', v_schema); RAISE NOTICE 'Created partman config for test_logs'; END IF; END IF; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'pg_partman setup skipped: %', SQLERRM; END $$; -- Insert log data diff --git a/test-server/test-tool-groups-codemode/README.md b/test-server/test-tool-groups-codemode/README.md index ec953b1e..44e9133a 100644 --- a/test-server/test-tool-groups-codemode/README.md +++ b/test-server/test-tool-groups-codemode/README.md @@ -1,6 +1,6 @@ # Postgres-MCP Code Mode Testing Suite -**Directory Purpose**: This folder contains 28 self-contained, modular test prompts covering every tool group in `postgres-mcp`. These prompts are strictly designed for **Code Mode (`pg_execute_code`) validation only**. +**Directory Purpose**: This folder contains 31 self-contained, modular test prompts covering every tool group in `postgres-mcp`. These prompts are strictly designed for **Code Mode (`pg_execute_code`) validation only**. ## Agent Instructions @@ -53,10 +53,6 @@ Never proceed to the final step until every tool in a given group has both colum 19. `text` 20. `transactions` 21. `vector` -22. `cross-group` - -Execute these sequentially, updating the Changelog and resolving bugs systematically before moving to the next. - -## Test Results - -Token consumption metrics and final summaries from executing the above codemode tests are persisted in [`test-results.md`](./test-results.md). +22. `security` +23. `roles` +24. `docstore` diff --git a/test-server/test-tool-groups-codemode/test-results.md b/test-server/test-tool-groups-codemode/test-results.md deleted file mode 100644 index a033c53c..00000000 --- a/test-server/test-tool-groups-codemode/test-results.md +++ /dev/null @@ -1,44 +0,0 @@ -# Token Consumption during codemode Testing of postgres-mcp - -Last tested: April 4th, 2026 - -| Test Document | Approximate Token Usage | Notes | -| :---------------------------------------------- | :---------------------- | :---- | -| `test-tool-group-codemode-admin.md` | ~3,298 | | -| `test-tool-group-codemode-backup.md` | ~7,665 | | -| `test-tool-group-codemode-citext.md` | ~7,370 | | -| `test-tool-group-codemode-core-part1.md` | ~3,489 | | -| `test-tool-group-codemode-core-part2.md` | ~3,550 | | -| `test-tool-group-codemode-cron.md` | ~4,930 | | -| `test-tool-group-codemode-introspection.md` | ~37,783 | | -| `test-tool-group-codemode-jsonb-part1.md` | ~31,891 | | -| `test-tool-group-codemode-jsonb-part2.md` | ~3,309 | | -| `test-tool-group-codemode-kcache.md` | ~6,341 | | -| `test-tool-group-codemode-ltree.md` | ~7,569 | | -| `test-tool-group-codemode-migration.md` | ~3,930 | | -| `test-tool-group-codemode-monitoring.md` | ~6,336 | | -| `test-tool-group-codemode-partitioning.md` | ~2,117 | | -| `test-tool-group-codemode-partman.md` | ~3,598 | | -| `test-tool-group-codemode-performance-part1.md` | ~8,397 | | -| `test-tool-group-codemode-performance-part2.md` | ~10,726 | | -| `test-tool-group-codemode-pgcrypto.md` | ~10,634 | | -| `test-tool-group-codemode-postgis-part1.md` | ~5,974 | | -| `test-tool-group-codemode-postgis-part2.md` | ~9,606 | | -| `test-tool-group-codemode-schema.md` | ~12,790 | | -| `test-tool-group-codemode-stats-part1.md` | ~13,706 | | -| `test-tool-group-codemode-stats-part2.md` | ~9,082 | | -| `test-tool-group-codemode-text.md` | ~6,042 | | -| `test-tool-group-codemode-transactions.md` | ~2,893 | | -| `test-tool-group-codemode-vector-part1.md` | ~3,630 | | -| `test-tool-group-codemode-vector-part2.md` | ~6,931 | | -| **Total Estimated Tokens** | **~233,587** | | - -**Safe to test in pairs** -jsonb + vector -postgis + ltree -pgcrypto + citext -text + cron -partman + partitioning -stats + backup - -**Token counts don't include tokens used by the testing prompts themselves.** diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-admin.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-admin.md index 056ebf15..7d25d4bd 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-admin.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-admin.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-backup.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-backup.md index 85dd515d..0ee332ed 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-backup.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-backup.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-citext.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-citext.md index bb10cf6f..50e3785e 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-citext.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-citext.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-core-part1.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-core-part1.md index 43d0acac..abcd1d90 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-core-part1.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-core-part1.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -242,53 +242,52 @@ All tools implement P154 structured error handling for nonexistent tables/schema > **Instructions**: Construct a single `pg_execute_code` script to execute the numbered checklist items below. Use the `pg.*` namespace to call the corresponding methods with the exact inputs shown. Compare responses against the expected results within your script, and push any deviations or errors to a `failures` array. Return the `failures` array at the end of the script. Report any issues logged. -**Convenience tools (P154 canonical targets):** +**Read/Write/Schema tools (Happy Paths):** -**Read/Write/Schema tools:** - -12. `pg_read_query({sql: "SELECT COUNT(*) AS n FROM test_orders"})` β†’ `{rows: [{n: 20}], rowCount: 1}` -13. `pg_list_tables({schema: "public", limit: 5})` β†’ `{tables: [...], count: 5, truncated: true}` -14. `pg_describe_table({table: "test_products"})` β†’ verify `columns` includes `id`, `name`, `price`; `primaryKey` present -15. `pg_list_objects({type: "view"})` β†’ verify `test_order_summary` appears in results -16. `pg_get_indexes({table: "test_orders"})` β†’ verify `idx_orders_status` and `idx_orders_date` in results +1. `pg_read_query({sql: "SELECT COUNT(*) AS n FROM test_orders"})` β†’ `{rows: [{n: 20}], rowCount: 1}` +2. `pg_write_query({sql: "INSERT INTO temp_lifecycle (name) VALUES ('Alice') RETURNING id"})` β†’ `{rowsAffected: 1, rows: [...]}` (run this after creating temp table below) +3. `pg_list_tables({schema: "public", limit: 5})` β†’ `{tables: [...], count: 5, truncated: true}` +4. `pg_describe_table({table: "test_products"})` β†’ verify `columns` includes `id`, `name`, `price`; `primaryKey` present +5. `pg_list_objects({type: "view"})` β†’ verify `test_order_summary` appears in results +6. `pg_get_indexes({table: "test_orders"})` β†’ verify `idx_orders_status` and `idx_orders_date` in results **Domain error paths (πŸ”΄):** -22. πŸ”΄ `pg_read_query({sql: "SELECT * FROM nonexistent_table_xyz"})` β†’ `{success: false, error: "..."}` handler error, NOT MCP error -23. πŸ”΄ `pg_write_query({sql: "INSERT INTO nonexistent_xyz VALUES (1)"})` β†’ `{success: false, error: "..."}` handler error -24. πŸ”΄ `pg_read_query({sql: "SELECT nonexistent_column FROM test_products"})` β†’ `{success: false, error: "..."}` mentioning column name -25. πŸ”΄ `pg_list_tables({schema: "nonexistent_schema_xyz"})` β†’ either empty results or `{success: false}` β€” not raw MCP error -26. πŸ”΄ `pg_describe_table({table: "nonexistent_table_xyz"})` β†’ `{success: false, error: "..."}` mentioning table name -27. πŸ”΄ `pg_describe_table({table: "test_schema.order_seq"})` β†’ `{success: false, error: "..."}` mentioning "sequence" (not a table) -28. πŸ”΄ `pg_list_objects({type: "invalid_type"})` β†’ `{success: false, error: "Validation error: ..."}` β€” NOT raw MCP `-32602` output validation error -29. πŸ”΄ `pg_drop_index({name: "nonexistent_index_xyz"})` β†’ `{success: false, error: "..."}` handler error with hint +7. πŸ”΄ `pg_read_query({sql: "SELECT * FROM nonexistent_table_xyz"})` β†’ `{success: false, error: "..."}` handler error, NOT MCP error +8. πŸ”΄ `pg_write_query({sql: "INSERT INTO nonexistent_xyz VALUES (1)"})` β†’ `{success: false, error: "..."}` handler error +9. πŸ”΄ `pg_read_query({sql: "SELECT nonexistent_column FROM test_products"})` β†’ `{success: false, error: "..."}` mentioning column name +10. πŸ”΄ `pg_list_tables({schema: "nonexistent_schema_xyz"})` β†’ either empty results or `{success: false}` β€” not raw MCP error +11. πŸ”΄ `pg_describe_table({table: "nonexistent_table_xyz"})` β†’ `{success: false, error: "..."}` mentioning table name +12. πŸ”΄ `pg_describe_table({table: "test_schema.order_seq"})` β†’ `{success: false, error: "..."}` mentioning "sequence" (not a table) +13. πŸ”΄ `pg_list_objects({type: "invalid_type"})` β†’ `{success: false, error: "Validation error: ..."}` β€” NOT raw MCP `-32602` output validation error +14. πŸ”΄ `pg_drop_index({name: "nonexistent_index_xyz"})` β†’ `{success: false, error: "..."}` handler error with hint **Zod validation error paths (πŸ”΄ β€” verify `"Validation error: ..."` format, NOT raw JSON array):** -30. πŸ”΄ `pg_create_table({})` β†’ `{success: false, error: "Validation error: name (or table alias) is required; Validation error: columns must not be empty"}` β€” NOT raw JSON array, NOT raw MCP error -31. πŸ”΄ `pg_describe_table({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required `table` param) -32. πŸ”΄ `pg_read_query({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required `sql`) -33. πŸ”΄ `pg_write_query({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required `sql`) -34. πŸ”΄ `pg_create_index({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required params) -35. πŸ”΄ `pg_drop_table({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required `table`) +15. πŸ”΄ `pg_create_table({})` β†’ `{success: false, error: "Validation error: name (or table alias) is required; Validation error: columns must not be empty"}` β€” NOT raw JSON array, NOT raw MCP error +16. πŸ”΄ `pg_describe_table({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required `table` param) +17. πŸ”΄ `pg_read_query({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required `sql`) +18. πŸ”΄ `pg_write_query({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required `sql`) +19. πŸ”΄ `pg_create_index({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required params) +20. πŸ”΄ `pg_drop_table({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required `table`) +21. πŸ”΄ `pg_drop_index({})` β†’ `{success: false, error: "Validation error: ..."}` (missing required `name`) +22. πŸ”΄ `pg_list_objects({})` β†’ works without type (returns default objects) **Alias acceptance (verify aliases produce identical results to primary parameter name):** -39. `pg_read_query({query: "SELECT 1 AS test"})` β†’ works via `query` alias for `sql` -40. `pg_describe_table({name: "test_products"})` β†’ works via `name` alias for `table` +23. `pg_read_query({query: "SELECT 1 AS test"})` β†’ works via `query` alias for `sql` +24. `pg_describe_table({name: "test_products"})` β†’ works via `name` alias for `table` **Create β†’ Use β†’ Drop lifecycle (temp tables):** -43. `pg_create_table({name: "temp_lifecycle", columns: [{name: "id", type: "SERIAL", primaryKey: true}, {name: "name", type: "TEXT", notNull: true}]})` β†’ `{success: true}` -44. `pg_create_index({table: "temp_lifecycle", columns: ["name"], ifNotExists: true})` β†’ `{success: true}` -45. `pg_get_indexes({table: "temp_lifecycle"})` β†’ verify the new index appears -46. `pg_drop_table({table: "temp_lifecycle", ifExists: true})` β†’ `{success: true, existed: true}` -47. `pg_drop_table({table: "temp_lifecycle", ifExists: true})` β†’ `{success: true, existed: false}` (already dropped) +25. `pg_create_table({name: "temp_lifecycle", columns: [{name: "id", type: "SERIAL", primaryKey: true}, {name: "name", type: "TEXT", notNull: true}]})` β†’ `{success: true}` +26. `pg_create_index({table: "temp_lifecycle", columns: ["name"], ifNotExists: true})` β†’ `{success: true}` +27. `pg_get_indexes({table: "temp_lifecycle"})` β†’ verify the new index appears +28. `pg_drop_table({table: "temp_lifecycle", ifExists: true})` β†’ `{success: true, existed: true}` +29. `pg_drop_table({table: "temp_lifecycle", ifExists: true})` β†’ `{success: true, existed: false}` (already dropped) **Code mode (`pg_execute_code`) deterministic items:** -53. `pg_execute_code({code: "return await pg.core.help()"})` β†’ verify lists ~20 core methods -54. `pg_execute_code({code: "return await pg.count('test_products')"})` β†’ verify works via top-level alias -55. `pg_execute_code({code: "return await pg.exists('test_products', 'id = 1')"})` β†’ verify positional args work -56. `pg_execute_code({code: "return await pg.core.readQuery({sql: 'SELECT 1 AS n'})"})` β†’ verify `{rows: [{n: 1}]}` -57. `pg_execute_code({code: "return await pg.readQuery({sql: 'SELECT * FROM nonexistent_xyz'})"})` β†’ verify error is returned (not thrown), contains `{success: false}` or error object +30. `pg_execute_code({code: "return await pg.core.help()"})` β†’ verify lists ~20 core methods +31. `pg_execute_code({code: "return await pg.core.readQuery({sql: 'SELECT 1 AS n'})"})` β†’ verify `{rows: [{n: 1}]}` +32. `pg_execute_code({code: "return await pg.readQuery({sql: 'SELECT * FROM nonexistent_xyz'})"})` β†’ verify error is returned (not thrown), contains `{success: false}` or error object diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-core-part2.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-core-part2.md index 6cb84521..3125ba8e 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-core-part2.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-core-part2.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -256,39 +256,42 @@ All tools implement P154 structured error handling for nonexistent tables/schema 10. `pg_batch_insert({table: "nonexistent_table_xyz", rows: [{id: 1}]})` β†’ `{success: false}` structured error 11. `pg_upsert({table: "nonexistent_table_xyz", data: {id: 1}, conflictColumns: ["id"]})` β†’ `{success: false}` structured error -**Read/Write/Schema tools:** +**Introspection and Analysis tools:** -16. `pg_object_details({name: "test_order_summary", type: "view"})` β†’ verify `definition` field present -17. `pg_list_extensions()` β†’ verify response includes `pgcrypto`, `pg_trgm`, `vector` (or other installed extensions) -18. `pg_analyze_db_health()` β†’ verify `overallStatus` is one of: `healthy`, `needs_attention`, `critical` -19. `pg_analyze_workload_indexes()` β†’ verify response structure with `recommendations` or `queries` array -20. `pg_analyze_query_indexes({sql: "SELECT * FROM test_products WHERE name = 'Widget'"})` β†’ verify `plan` and `recommendations` fields present +12. `pg_object_details({name: "test_order_summary", type: "view"})` β†’ verify `definition` field present +13. `pg_list_extensions()` β†’ verify response includes `pgcrypto`, `pg_trgm`, `vector` (or other installed extensions) +14. `pg_analyze_db_health()` β†’ verify `overallStatus` is one of: `healthy`, `needs_attention`, `critical` +15. `pg_analyze_workload_indexes()` β†’ verify response structure with `recommendations` or `queries` array +16. `pg_analyze_query_indexes({sql: "SELECT * FROM test_products WHERE name = 'Widget'"})` β†’ verify `plan` and `recommendations` fields present -**Domain error paths (πŸ”΄):** +**Domain and Zod error paths (πŸ”΄):** -**Zod validation error paths (πŸ”΄ β€” verify `"Validation error: ..."` format, NOT raw JSON array):** +17. πŸ”΄ `pg_count({params: ["not_a_number"]})` β†’ `{success: false, error: "..."}` structured error for bad param type +18. πŸ”΄ `pg_count({})` β†’ `{success: false, error: "..."}` (missing required `table`) +19. πŸ”΄ `pg_exists({})` β†’ `{success: false, error: "..."}` (missing required `table`) +20. πŸ”΄ `pg_truncate({})` β†’ `{success: false, error: "..."}` (missing required `table`) +21. πŸ”΄ `pg_batch_insert({})` β†’ `{success: false, error: "..."}` (missing required params) +22. πŸ”΄ `pg_upsert({})` β†’ `{success: false, error: "..."}` (missing required params) +23. πŸ”΄ `pg_object_details({})` β†’ `{success: false, error: "..."}` (missing required `name`) +24. πŸ”΄ `pg_analyze_query_indexes({})` β†’ `{success: false, error: "..."}` (missing required `sql`) -36. πŸ”΄ `pg_count({params: ["not_a_number"]})` β†’ `{success: false, error: "..."}` structured error for bad param type +**Alias acceptance:** -**Alias acceptance (verify aliases produce identical results to primary parameter name):** +25. `pg_count({tableName: "test_products"})` β†’ same result as item 1 (`{count: 15}`) +26. `pg_count({table: "test_products", condition: "price > 50"})` β†’ same as `where` alias +27. `pg_exists({tableName: "test_products"})` β†’ works via `tableName` alias for `table` +28. `pg_analyze_query_indexes({query: "SELECT * FROM test_products"})` β†’ works via `query` alias for `sql` -37. `pg_count({tableName: "test_products"})` β†’ same result as item 1 (`{count: 15}`) -38. `pg_count({table: "test_products", condition: "price > 50"})` β†’ same as `where` alias -39. `pg_exists({tableName: "test_products"})` β†’ works via `tableName` alias for `table` -40. `pg_analyze_query_indexes({query: "SELECT * FROM test_products"})` β†’ works via `query` alias for `sql` +**Convenience tools lifecycle (temp tables):** -**Create β†’ Use β†’ Drop lifecycle (temp tables):** - -44. `pg_batch_insert({table: "temp_lifecycle", rows: [{name: "Alice"}, {name: "Bob"}], returning: ["id", "name"]})` β†’ verify returned rows with auto-generated IDs -45. `pg_upsert({table: "temp_lifecycle", data: {id: 1, name: "Alice Updated"}, conflictColumns: ["id"]})` β†’ verify update -46. `pg_count({table: "temp_lifecycle"})` β†’ `{count: 2}` -47. `pg_truncate({table: "temp_lifecycle", restartIdentity: true})` β†’ `{success: true}` -48. `pg_count({table: "temp_lifecycle"})` β†’ `{count: 0}` +29. `pg_batch_insert({table: "temp_lifecycle", rows: [{name: "Alice"}, {name: "Bob"}], returning: ["id", "name"]})` β†’ verify returned rows with auto-generated IDs (must create `temp_lifecycle` via `pg_execute_code` before this) +30. `pg_upsert({table: "temp_lifecycle", data: {id: 1, name: "Alice Updated"}, conflictColumns: ["id"]})` β†’ verify update +31. `pg_count({table: "temp_lifecycle"})` β†’ `{count: 2}` +32. `pg_truncate({table: "temp_lifecycle", restartIdentity: true})` β†’ `{success: true}` +33. `pg_count({table: "temp_lifecycle"})` β†’ `{count: 0}` **Code mode (`pg_execute_code`) deterministic items:** -53. `pg_execute_code({code: "return await pg.core.help()"})` β†’ verify lists ~20 core methods -54. `pg_execute_code({code: "return await pg.count('test_products')"})` β†’ verify works via top-level alias -55. `pg_execute_code({code: "return await pg.exists('test_products', 'id = 1')"})` β†’ verify positional args work -56. `pg_execute_code({code: "return await pg.core.readQuery({sql: 'SELECT 1 AS n'})"})` β†’ verify `{rows: [{n: 1}]}` -57. `pg_execute_code({code: "return await pg.readQuery({sql: 'SELECT * FROM nonexistent_xyz'})"})` β†’ verify error is returned (not thrown), contains `{success: false}` or error object +34. `pg_execute_code({code: "return await pg.core.help()"})` β†’ verify lists ~20 core methods +35. `pg_execute_code({code: "return await pg.count('test_products')"})` β†’ verify works via top-level alias +36. `pg_execute_code({code: "return await pg.exists('test_products', 'id = 1')"})` β†’ verify positional args work diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-cron.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-cron.md index 45168cc0..6b2e890b 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-cron.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-cron.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -240,20 +240,20 @@ cron Tool Group (8 tools +1 for code mode) > **Instructions**: Construct a single `pg_execute_code` script to execute the numbered checklist items below. Use the `pg.*` namespace to call the corresponding methods with the exact inputs shown. Compare responses against the expected results within your script, and push any deviations or errors to a `failures` array. Return the `failures` array at the end of the script. Report any issues logged. -- [x] 1. `pg_cron_list_jobs()` β†’ verify response structure `{jobs, count}` -- [x] 2. `pg_cron_schedule({name: "checklist_test_job", schedule: "0 5 * * *", command: "SELECT 1"})` β†’ capture jobId -- [x] 3. `pg_cron_list_jobs()` β†’ verify `checklist_test_job` appears -- [x] 4. `pg_cron_unschedule({jobName: "checklist_test_job"})` β†’ verify success -- [x] 5. `pg_cron_list_jobs()` β†’ verify job removed -- [x] 6. πŸ”΄ `pg_cron_unschedule({jobName: "nonexistent_job_xyz"})` β†’ `{success: false, error: "..."}` handler error -- [x] 7. πŸ”΄ `pg_cron_schedule({})` β†’ `{success: false, error: "..."}` (Zod validation) -- [x] 8. πŸ”΄ `pg_cron_cleanup_history({days: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `days` (wrong-type numeric param) - -13. `pg_cron_alter_job()` β†’ verify happy path expected behavior -14. πŸ”΄ `pg_cron_alter_job({})` β†’ verify structured P154 error response or valid defaults -15. `pg_cron_job_run_details()` β†’ verify happy path expected behavior -16. πŸ”΄ `pg_cron_job_run_details({})` β†’ verify structured P154 error response or valid defaults -17. `pg_cron_create_extension()` β†’ verify happy path expected behavior -18. πŸ”΄ `pg_cron_create_extension({})` β†’ verify structured P154 error response or valid defaults -19. `pg_cron_schedule_in_database()` β†’ verify happy path expected behavior -20. πŸ”΄ `pg_cron_schedule_in_database({})` β†’ verify structured P154 error response or valid defaults +- [ ] 1. `pg_cron_list_jobs()` β†’ verify response structure `{jobs, count}` +- [ ] 2. `pg_cron_schedule({name: "checklist_test_job", schedule: "0 5 * * *", command: "SELECT 1"})` β†’ capture jobId +- [ ] 3. `pg_cron_list_jobs()` β†’ verify `checklist_test_job` appears +- [ ] 4. `pg_cron_unschedule({jobName: "checklist_test_job"})` β†’ verify success +- [ ] 5. `pg_cron_list_jobs()` β†’ verify job removed +- [ ] 6. πŸ”΄ `pg_cron_unschedule({jobName: "nonexistent_job_xyz"})` β†’ `{success: false, error: "..."}` handler error +- [ ] 7. πŸ”΄ `pg_cron_schedule({})` β†’ `{success: false, error: "..."}` (Zod validation) +- [ ] 8. πŸ”΄ `pg_cron_cleanup_history({days: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `days` (wrong-type numeric param) + +13. [ ] `pg_cron_alter_job()` β†’ verify happy path expected behavior +14. [ ] πŸ”΄ `pg_cron_alter_job({})` β†’ verify structured P154 error response or valid defaults +15. [ ] `pg_cron_job_run_details()` β†’ verify happy path expected behavior +16. [ ] πŸ”΄ `pg_cron_job_run_details({})` β†’ verify structured P154 error response or valid defaults +17. [ ] `pg_cron_create_extension()` β†’ verify happy path expected behavior +18. [ ] πŸ”΄ `pg_cron_create_extension({})` β†’ verify structured P154 error response or valid defaults +19. [ ] `pg_cron_schedule_in_database()` β†’ verify happy path expected behavior +20. [ ] πŸ”΄ `pg_cron_schedule_in_database({})` β†’ verify structured P154 error response or valid defaults diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-docstore.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-docstore.md new file mode 100644 index 00000000..b6176635 --- /dev/null +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-docstore.md @@ -0,0 +1,275 @@ +# postgres-mcp codemode Re-Testing: [docstore] + +**ESSENTIAL INSTRUCTIONS** + +- Conduct an exhaustive test of the tool group listed below using ONLY code mode (`pg_execute_code`). +- Do not use scripts or terminal to replace planned tests. +- Do not modify or skip tests. +- Ensure your validation script returns an aggregated array of failures if any exist. +- Group multiple tests into a single script to save context window tokens. +- Do not run test-tools-advanced-2.md at this time. +- All changes MUST be consistent with other postgres-mcp tools and `code-map.md`. + +## Reporting Format + +- ❌ Fail: Tool errors or produces incorrect results (include error message) +- ⚠️ Issue: Unexpected behavior or improvement opportunity +- πŸ“¦ Payload: Unnecessarily large response that should be optimized β€” **blocking, equally important as ❌ bugs**. Oversized payloads waste LLM context window tokens and degrade downstream tool-calling quality. **You MUST monitor `metrics.tokenEstimate` for every operation**. Report the response size in tokens/KB and suggest a concrete optimization (e.g., filter system tables, add `compact` option, omit empty arrays). + +> **Token estimates**: Every tool response includes `_meta.tokenEstimate` in its `content[].text` payload (approximate token count based on ~4 bytes/token). Code Mode responses include `metrics.tokenEstimate` instead. These are injected automatically by the adapter β€” no per-tool assertions needed, but report as ⚠️ if absent. + +## Test Database Schema + +The test database (`postgres`) contains these tables: + +| Table | Rows | Key Columns | JSONB Columns | Tool Groups | +| ------------------- | ---- | ---------------------------------------------------------------------------------- | ------------------------ | --------------------- | +| `test_products` | 15 | id, name, description, price, created_at | β€” | Core, Stats | +| `test_orders` | 20 | id, product_id (FK), quantity, total_price, status | β€” | Core, Stats, Trans | +| `test_jsonb_docs` | 3 | id | metadata, settings, tags | JSONB (20 tools) | +| `test_articles` | 3 | id, title, body, search_vector (TSVECTOR) | β€” | Text | +| `test_measurements` | 500 | id, sensor_id (INT 1-6), temperature, humidity, pressure | β€” | Stats (19 tools) | +| `test_embeddings` | 50 | id, content, category, embedding (vector 384d) | β€” | Vector (16 tools) | +| `test_locations` | 5 | id, name, location (GEOMETRY POINT SRID 4326) | β€” | PostGIS (15 tools) | +| `test_users` | 3 | id, username (CITEXT), email (CITEXT) | β€” | Citext (6 tools) | +| `test_categories` | 6 | id, name, path (LTREE) | β€” | Ltree (8 tools) | +| `test_secure_data` | 0 | id, user_id, sensitive_data (BYTEA), created_at | β€” | pgcrypto (9 tools) | +| `test_events` | 100 | id, event_type, event_date, payload (JSONB) β€” PARTITION BY RANGE | payload | Partitioning, Partman | +| `test_logs` | 0 | id, log_level, message, created_at β€” PARTITION BY RANGE | β€” | Partman | +| `test_departments` | 3 | id, name, budget | β€” | Introspection | +| `test_employees` | 5 | id, name, department_id (FK CASCADE), manager_id (FK self-ref SET NULL), hire_date | β€” | Introspection | +| `test_projects` | 2 | id, name, lead_id (FK SET NULL), department_id (FK RESTRICT) | β€” | Introspection | +| `test_assignments` | 3 | id, employee_id (FK CASCADE), project_id (FK CASCADE), role β€” UNIQUE(emp,proj) | β€” | Introspection | +| `test_audit_log` | 3 | entry_id (no PK!), employee_id (FK, no index!), action, created_at | β€” | Introspection | +| `test_documents` | 5 | \_id (TEXT PK), doc (JSONB) | doc | Docstore (9 tools) | + +Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_order_summary` (view), `test_get_order_count()` (function). +Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_locations_geo` (GIST), `idx_categories_path` (GIST), HNSW on `test_embeddings.embedding`. + +## Testing Requirements + +1. Use existing `test_*` tables for read operations (SELECT, COUNT, EXISTS, etc.) +2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) +3. Test each tool with realistic inputs based on the schema above +4. Clean up any `temp_*` tables after testing +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop +6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal +7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. +8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. +9. **Scripting Efficiency**: You should bundle multiple tool checks into a single `pg_execute_code` call to save LLM context window tokens. Use conditional checks to aggregate errors and return a `failures` array. +10. **Pacing**: Test up to an entire tool group in a single script if feasible, but limit scripts to ~10-15 steps to remain manageable. Report the aggregated results, update your matrix, and move to the next group. +11. **Deterministic checklist first**: Complete ALL items in the Deterministic Checklist below using Code Mode before moving to the Strict Coverage Matrix exploration. +12. **Audit backup tools**: The 3 `pg_audit_*` tools require `--audit-backup` to be enabled on the test server. When enabled, destructive operations (`pg_truncate`, `pg_drop_table`, `pg_vacuum`, etc.) create gzip-compressed `.snapshot.json.gz` files alongside the audit log. **V2 features to verify**: `pg_audit_diff_backup` now returns a `volumeDrift` field (row count + size changes); `pg_audit_restore_backup` supports `restoreAs` for side-by-side non-destructive restore; and Code Mode calls through `pg_execute_code` that trigger destructive operations are also captured by the interceptor. When disabled, all 3 tools return `{success: false, error: "Audit backup not enabled"}`. + +Note: The isError flag propagation issue has been fixed. P154 structured errors (`{success: false, error: "..."}`) return as parseable JSON objects. During error path testing, verify this: if an invalid Code Mode call returns a raw error string instead of a JSON object with `success` and `error` fields, report it as ❌. + +## Structured Error Response Pattern + +All tools must return errors as structured objects instead of throwing. A thrown error propagates as a raw MCP error, which is unhelpful to clients. The expected pattern: + +```json +{ + "success": false, + "error": "Human-readable error message", + "code": "QUERY_ERROR", + "category": "query", + "recoverable": false +} +``` + +The enriched `ErrorResponse` from `formatHandlerError` always includes `success`, `error`, `code`, `category`, and `recoverable`. Optional fields `suggestion` and `details` may also be present. Some tools include additional context fields (e.g., `pg_transaction_execute` includes `statementsExecuted`, `failedStatement`, `autoRolledBack`). These are acceptable as long as `success: false` and `error` are always present. + +### Handler Error vs MCP Error β€” How to Distinguish + +There are two kinds of error responses. Only one is correct: + +| Type | Source | What you see | Verdict | +| -------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------ | +| **Handler error** βœ… | Handler catches error and returns `{success: false, error: "..."}` | Parseable JSON object with `success` and `error` fields | Correct | +| **MCP error** ❌ | Uncaught throw propagates to MCP framework | Raw text error string, often prefixed with `Error:`, wrapped in an `isError: true` content block β€” no `success` field | Bug β€” report as ❌ | + +**Concrete examples:** + +``` +βœ… Handler error (correct): +{"success": false, "error": "Table \"public.nonexistent\" does not exist"} + +❌ MCP error (bug β€” handler threw instead of catching): +content: [{type: "text", text: "Error: relation \"nonexistent\" does not exist"}] +isError: true +``` + +The MCP error case means the handler is missing a `try/catch` block. When testing, if you see a raw error string (especially one containing PostgreSQL internal messages like `relation "..." does not exist` without a `success` field), report it as ❌. + +### Zod Validation Errors + +Calling a tool with wrong parameter types or missing required fields triggers a Zod validation error. If the handler has no outer `try/catch`, this surfaces as a raw MCP error. Test every tool with `{}` (empty params) if it has required parameters β€” the response must be a handler error, not an MCP error. + +**Error message format matters:** Zod `.refine()` failures produce a `ZodError` whose `.message` property is a **raw JSON array** of Zod issues (e.g., `[{"code":"custom","message":"..."}]`). If the handler catches the error with `error.message` instead of routing through `formatHandlerError`, this raw JSON leaks as the error string. All handlers must route through `formatHandlerError`, which duck-types the `.issues` array and produces clean `Validation error: name (or table alias) is required; Validation error: columns must not be empty` messages. If you see a raw JSON array in an error message, report it as ❌. + +**Zod refinement leak pattern:** The Split Schema pattern uses `.partial()` on input schemas so the SDK accepts `{}`. But `.partial()` only makes keys **optional** β€” it does NOT strip refinements like `.min(1)`, `.max(90)`, or `.min(-90).max(90)`. This applies to **ALL types** β€” strings, arrays, AND numbers: + +- `z.string().min(1)` + empty `""` β†’ SDK rejects with raw MCP `-32602` +- `z.array().min(1)` + empty `[]` β†’ SDK rejects with raw MCP `-32602` +- `z.number().min(-90).max(90)` + value `91` β†’ SDK rejects with raw MCP `-32602` + +**Fix:** Remove ALL `.min(N)` / `.max(N)` refinements from the schema and validate inside the handler instead. Optional fields with `.default()` are safe because the default satisfies the constraint. + +**Required enum coercion pattern:** For **optional** enum params with defaults, `z.preprocess(coercer, z.enum([...]).optional().default(...))` works β€” the coercer returns `undefined` for invalid values β†’ the `.default()` kicks in. For **required** enum params (no `.optional().default(...)`), this pattern **fails**: the SDK's `.partial()` wraps the preprocess in `.optional()`, but the inner `z.enum()` still rejects `undefined` β†’ raw MCP `-32602`. **Fix:** Use `z.string()` in the schema and validate the enum inside the handler's `try/catch`, returning a structured error. + +**What to report:** + +- If a tool call returns a raw MCP error (no JSON body with `success` field), report it as ❌ with the tool name and the raw error message +- If a tool returns `{success: false, error: "..."}` but the error string is a raw Zod JSON array (starts with `[{`), report as ❌ (handler uses `error.message` instead of `formatHandlerError`) +- If a tool returns `{success: false, error: "Validation error: ..."}` with clean human-readable text, that is the correct behavior β€” do not report it as a failure +- If a tool returns a successful response for an obviously invalid input (e.g., nonexistent table returns `{success: true}`), report it as ⚠️ + +## Split Schema Pattern Verification + +All tools use the Split Schema pattern: a plain `z.object()` Base schema for MCP parameter visibility (used as `inputSchema`), and handler-side parsing via `z.preprocess()`, `.default({})`, or direct `.parse()` inside `try/catch`. Verify: + +1. **JSON Schema visibility**: Before testing tool behavior, call `tools/list` (or inspect the MCP server's tool definitions) and confirm each tool's `inputSchema` exposes its parameters. Tools with optional parameters (e.g., `schema`, `limit`, `direction`) must show non-empty `properties` in the JSON Schema. If a tool's `inputSchema` is empty or missing `properties`, report as a Split Schema violation. +2. **Parameter visibility**: For tools with optional parameters (e.g., `schema`, `limit`), make a Code Mode call using those parameters. If the tool ignores or rejects documented parameters, report as a Split Schema violation. +3. **Alias acceptance**: For tools with documented parameter aliases (e.g., table/tableName/name, sql/query), verify that Code Mode calls correctly accept the aliasesβ€”not just the primary parameter name. If a call using only an alias fails with a validation error like "X is required", report it as a Split Schema violation requiring a fix. +4. **`z.preprocess()` as `inputSchema`**: If a tool uses `z.preprocess()` directly as its `inputSchema` (instead of a plain `SchemaBase`), parameter metadata is stripped from JSON Schema generation, making MCP tooling unable to see or use those parameters. Report as a Split Schema violation. + +## P154 Object Existence Verification + +All tools should return structured error responses for nonexistent tables/schemas (via `formatHandlerError`). The 5 core convenience tools (pg_count, pg_exists, pg_upsert, pg_batch_insert, pg_truncate) implement explicit pre-checks and serve as canonical verification targets. Beyond those, **every tool group must have at least one nonexistent-table test in its checklist** β€” see the error-path items (marked πŸ”΄) in each group's checklist in `test-group-tools.md`. + +For each P154 test, verify that calling with a nonexistent table (e.g., `table: "nonexistent_table_xyz"`) returns a handler error like `{success: false, error: "Table \"public.nonexistent_table_xyz\" does not exist"}` rather than a raw MCP error. Also verify that a nonexistent schema (e.g., `table: "fake_schema.users"`) produces a similarly clear handler error. + +Key PostgreSQL error codes that should be intercepted by `formatHandlerError` (not leaked as raw errors): + +| PG Error Code | Meaning | Expected Structured Message | +| ------------- | ------------------- | --------------------------------- | +| 42P01 | Undefined table | `Table "X" does not exist` | +| 42P06 | Duplicate schema | `Schema "X" already exists` | +| 42P07 | Duplicate table | `Table "X" already exists` | +| 42701 | Duplicate column | `Column "X" already exists` | +| 42703 | Undefined column | `Column "X" does not exist` | +| 23505 | Unique violation | `Duplicate key: ...` | +| 23503 | FK violation | `Foreign key constraint violated` | +| 42601 | Syntax error | `SQL syntax error: ...` | +| 3F000 | Invalid schema name | `Schema "X" does not exist` | +| XX000 | Internal error | `Internal error: ...` | + +## Error Consistency Audit + +During testing, check for these inconsistencies across tool groups: + +1. **Throw-vs-return**: If a tool throws a raw error instead of returning `{success: false}`, report as ❌. Document which tool groups have the worst raw-error leakage. +2. **Error field name**: All `{ success: false }` error responses should use `error` as the field name. If a tool uses a different field name for error context in a failure response, report as ⚠️. +3. **Zod validation leaks**: If calling a tool with an invalid enum value or missing required field produces a raw MCP `-32602` Zod validation error instead of a structured response, report as ❌. This indicates the Zod schema is rejecting the input at the MCP framework level before the handler's `try/catch` can intercept. +4. **Missing `formatHandlerError` wrapping**: postgres-mcp has a centralized `formatHandlerError` helper. If a handler catches errors but returns ad-hoc messages instead of using the centralized formatter, report which handler and the ad-hoc message pattern. +5. **Orphaned output schemas**: If a schema is exported from `src/adapters/postgresql/schemas/` but the corresponding tool definition does not reference it via `outputSchema`, report as ⚠️. Use `grep_search` to check whether the schema name appears in any tool file. Defined-but-unwired schemas provide zero enforcement. +6. **Inline output schemas**: If any tool defines `outputSchema: z.object({...})` inline in the handler file instead of importing a named schema from the `schemas/` directory, report as ⚠️. All output schemas must live in the appropriate `schemas/` directory with named exports. + +## Error Path Testing Checklist + +For each tool group under test, verify at least one scenario from each applicable row: + +| Error Scenario | Tool Groups to Test | Example Input | +| --------------------------------- | ------------------------------------- | ----------------------------------------------------------------------- | +| Nonexistent table | All table-accepting tools | `table: "nonexistent_xyz"` | +| Nonexistent schema | Core, introspection, schema | `schema: "fake_schema"` or `table: "fake_schema.users"` | +| Invalid SQL syntax | Core (`read_query`, `write_query`) | `sql: "SELECTT * FROM"` | +| Invalid column name | Stats, JSONB, text, vector, PostGIS | `column: "nonexistent_col"` | +| Duplicate table/index | Core (`create_table`, `create_index`) | Create existing table | +| Empty required array | Transactions | `statements: []` | +| Missing required field via alias | Core, transactions | `sql` alias instead of `query` | +| **Zod validation (empty params)** | **Every tool with required params** | `{}` (empty object β€” must return handler error, not MCP `-32602` error) | +| **Zod validation (wrong type)** | **Tools with typed params** | Pass string where number expected, etc. | + +## Cleanup Conventions + +During testing, use these naming conventions: + +- **Temporary collections**: Prefix with `temp_` (e.g., `temp_doc_cm_test`) +- **Test views**: Prefix with `test_view_` (e.g., `test_view_order_summary`) +- **Test functions**: Prefix with `test_func_` (e.g., `test_func_calculate`) +- **Test schemas**: Prefix with `test_schema_` (e.g., `test_schema_temp`) + +After testing, clean up: + +```sql +-- List temp tables/collections +SELECT tablename FROM pg_tables +WHERE schemaname = 'public' AND tablename LIKE 'temp_%'; + +-- Drop temp collection +DROP TABLE IF EXISTS temp_doc_cm_test; +``` + +## Post-Test Procedures + +### Reporting Rules + +- Use βœ… only in inline notes during testing; omit from Final Summary +- Do not mention what already works well or issues already documented in server-instructions.md and runtime hints + +### After Testing + +1. **Cleanup**: Confirm all `temp_*` tables and temporary testing data are removed +2. **Fix EVERY finding** β€” not just ❌ Fails, but also ⚠️ Issues including behavioral improvements, missing warnings, error code consistency, πŸ“¦ Payload problems (responses that should be truncated or offer a `limit` param) and files listed below. All changes MUST be consistent with other postgres-mcp tools and `code-map.md` +3. **Scope of fixes** includes corrections to any of: + - Handler code + - `server-instructions.md` + - Test database (`test-database.sql`) + - This prompt (`test-tools-codemode.md`) and group file (`test-group-tools-codemode.md`) +4. Update the changelog with any changes made (being careful not to create duplicate headers), and commit without pushing. +5. **Token Audit**: Before concluding, call `read_resource` on `postgres://audit` to retrieve the `sessionTokenEstimate` (total token usage) for your testing session. Include this "Total Token Usage" in your final test report and session summary. Highlight the single most expensive Code Mode execution block. +6. Stop and briefly summarize the testing results and fixes, ensuring the total token count is prominently displayed. + +--- + +## Group Focus: docstore + +### docstore Group-Specific Testing + +docstore Tool Group (9 tools +1 for code mode) + +1. 'pg_doc_list_collections' +2. 'pg_doc_create_collection' +3. 'pg_doc_drop_collection' +4. 'pg_doc_collection_info' +5. 'pg_doc_find' +6. 'pg_doc_add' +7. 'pg_doc_modify' +8. 'pg_doc_remove' +9. 'pg_doc_create_index' +10. 'pg_execute_code' (codemode, auto-added) + +> **Instructions**: Construct a single `pg_execute_code` script to execute the numbered checklist items below. Use the `pg.*` namespace to call the corresponding methods with the exact inputs shown. Compare responses against the expected results within your script, and push any deviations or errors to a `failures` array. Return the `failures` array at the end of the script. Report any issues logged. + +**Test data:** Docstore tools operate on JSONB document collections β€” tables with a `_id` TEXT primary key and `doc` JSONB column. The `test_documents` collection provides 5 seed documents with `name`, `age`, `tags` (array), and `address` (nested object) fields. Create `temp_doc_cm_test` for write operation testing via `pg.docstore.createCollection`. + +**Checklist:** + +1. `pg.docstore.listCollections()` β†’ verify `test_documents` present in results +2. `pg.docstore.listCollections({schema: "public"})` β†’ verify schema filter works +3. `pg.docstore.collectionInfo({collection: "test_documents"})` β†’ verify `{success: true}` with rowCount (5) +4. `pg.docstore.find({collection: "test_documents"})` β†’ verify returns 5 docs +5. `pg.docstore.find({collection: "test_documents", filter: {name: "Alice"}})` β†’ verify returns 1 doc +6. `pg.docstore.find({collection: "test_documents", filter: {age: {$gt: 30}}})` β†’ verify returns 2 docs (Charlie, Eve) +7. `pg.docstore.find({collection: "test_documents", limit: 2})` β†’ verify returns exactly 2 docs +8. `pg.docstore.find({collection: "test_documents", fields: ["name", "age"]})` β†’ verify projected fields only +9. `pg.docstore.createCollection({collection: "temp_doc_cm_test"})` β†’ verify created +10. `pg.docstore.add({collection: "temp_doc_cm_test", documents: [{name: "CM1", value: 100}, {name: "CM2", value: 200}]})` β†’ verify 2 added +11. `pg.docstore.find({collection: "temp_doc_cm_test"})` β†’ verify returns 2 docs with auto-generated `_id` +12. `pg.docstore.modify({collection: "temp_doc_cm_test", filter: {name: "CM1"}, set: {status: "done"}})` β†’ verify modified +13. `pg.docstore.find({collection: "temp_doc_cm_test", filter: {status: "done"}})` β†’ verify 1 doc with `status: "done"` +14. `pg.docstore.remove({collection: "temp_doc_cm_test", filter: {status: "done"}})` β†’ verify 1 removed +15. `pg.docstore.find({collection: "temp_doc_cm_test"})` β†’ verify 1 doc remaining +16. `pg.docstore.createIndex({collection: "temp_doc_cm_test", field: "name"})` β†’ verify index created +17. `pg.docstore.dropCollection({collection: "temp_doc_cm_test"})` β†’ verify dropped + +18. πŸ”΄ `pg.docstore.find({})` β†’ `{success: false, error: "..."}` (missing collection) +19. πŸ”΄ `pg.docstore.add({})` β†’ `{success: false, error: "..."}` (missing params) +20. πŸ”΄ `pg.docstore.find({collection: "nonexistent_collection_xyz"})` β†’ `{success: false, error: "..."}` (P154) +21. πŸ”΄ `pg.docstore.collectionInfo({collection: "nonexistent_collection_xyz"})` β†’ `{success: false, error: "..."}` (P154) +22. πŸ”΄ `pg.docstore.modify({collection: "nonexistent_collection_xyz", filter: {}, set: {x: 1}})` β†’ `{success: false, error: "..."}` (P154) +23. πŸ”΄ `pg.docstore.remove({collection: "nonexistent_collection_xyz", filter: {}})` β†’ `{success: false, error: "..."}` (P154) +24. πŸ”΄ `pg.docstore.createCollection({collection: "test_documents"})` β†’ `{success: false, error: "..."}` (duplicate) +25. πŸ”΄ `pg.docstore.createIndex({})` β†’ `{success: false, error: "..."}` (missing params) +26. πŸ”΄ `pg.docstore.listCollections({schema: "fake_schema_xyz"})` β†’ `{success: false, error: "..."}` (P154) diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-introspection.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-introspection.md index 03711a41..0e0268f6 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-introspection.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-introspection.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-jsonb-part1.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-jsonb-part1.md index 7fc4255b..56ce219d 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-jsonb-part1.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-jsonb-part1.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -253,27 +253,27 @@ jsonb Tool Group (20 tools +1 for code mode): **Checklist:** 1. `pg_jsonb_extract({table: "test_jsonb_docs", column: "metadata", path: "author", where: "id = 1"})` β†’ result contains `"Alice"` -2. `pg_jsonb_extract({table: "test_jsonb_docs", column: "metadata", path: "nested.level1.level2", where: "id = 3"})` β†’ result contains `"deep"` -3. `pg_jsonb_keys({table: "test_jsonb_docs", column: "metadata", where: "id = 1"})` β†’ keys include `type`, `author`, `views` -4. `pg_jsonb_contains({table: "test_jsonb_docs", column: "metadata", contains: {"type": "article"}, where: "id = 1"})` β†’ true - -**pg_jsonb_pretty:** - -**Domain error paths (πŸ”΄):** - -13. πŸ”΄ `pg_jsonb_extract({table: "nonexistent_xyz", column: "data", path: "key"})` β†’ `{success: false, error: "..."}` handler error -14. πŸ”΄ `pg_jsonb_keys({})` β†’ `{success: false, error: "..."}` (Zod validation) -15. πŸ”΄ `pg_jsonb_contains({table: "test_jsonb_docs", column: "metadata", value: {"type": "article"}, limit: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should silently default `limit` to 100 and return valid results (wrong-type numeric param coercion) - -16. `pg_jsonb_agg()` β†’ verify happy path expected behavior -17. πŸ”΄ `pg_jsonb_agg({})` β†’ verify structured P154 error response or valid defaults -18. `pg_jsonb_path_query()` β†’ verify happy path expected behavior -19. πŸ”΄ `pg_jsonb_path_query({})` β†’ verify structured P154 error response or valid defaults -20. `pg_jsonb_array()` β†’ verify happy path expected behavior -21. πŸ”΄ `pg_jsonb_array({})` β†’ verify structured P154 error response or valid defaults -22. `pg_jsonb_set()` β†’ verify happy path expected behavior -23. πŸ”΄ `pg_jsonb_set({})` β†’ verify structured P154 error response or valid defaults -24. `pg_jsonb_insert()` β†’ verify happy path expected behavior -25. πŸ”΄ `pg_jsonb_insert({})` β†’ verify structured P154 error response or valid defaults -26. `pg_jsonb_delete()` β†’ verify happy path expected behavior -27. πŸ”΄ `pg_jsonb_delete({})` β†’ verify structured P154 error response or valid defaults +2. `pg_jsonb_set({table: "test_jsonb_docs", column: "metadata", path: "author", value: "Alicia", where: "id = 1"})` β†’ verify successful update +3. `pg_jsonb_insert({table: "test_jsonb_docs", column: "metadata", path: "new_key", value: "new_val", where: "id = 1"})` β†’ verify successful insert +4. `pg_jsonb_delete({table: "test_jsonb_docs", column: "metadata", path: "new_key", where: "id = 1"})` β†’ verify successful delete +5. `pg_jsonb_contains({table: "test_jsonb_docs", column: "metadata", contains: {"type": "article"}, where: "id = 1"})` β†’ true +6. `pg_jsonb_path_query({table: "test_jsonb_docs", column: "metadata", path: "$.author"})` β†’ verify expected behavior +7. `pg_jsonb_agg({table: "test_jsonb_docs", column: "metadata"})` β†’ verify aggregated array +8. `pg_jsonb_object({keys: ["a", "b"], values: ["1", "2"]})` β†’ verify expected behavior +9. `pg_jsonb_array({elements: ["a", "b", "c"]})` β†’ verify expected behavior +10. `pg_jsonb_keys({table: "test_jsonb_docs", column: "metadata", where: "id = 1"})` β†’ keys include `type`, `author`, `views` + +**Domain and Zod error paths (πŸ”΄):** + +11. πŸ”΄ `pg_jsonb_extract({table: "nonexistent_xyz", column: "data", path: "key"})` β†’ `{success: false, error: "..."}` handler error +12. πŸ”΄ `pg_jsonb_contains({table: "test_jsonb_docs", column: "metadata", contains: {"type": "article"}, limit: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should silently default `limit` (wrong-type numeric param coercion) +13. πŸ”΄ `pg_jsonb_extract({})` β†’ `{success: false, error: "..."}` (Zod validation) +14. πŸ”΄ `pg_jsonb_set({})` β†’ `{success: false, error: "..."}` (Zod validation) +15. πŸ”΄ `pg_jsonb_insert({})` β†’ `{success: false, error: "..."}` (Zod validation) +16. πŸ”΄ `pg_jsonb_delete({})` β†’ `{success: false, error: "..."}` (Zod validation) +17. πŸ”΄ `pg_jsonb_contains({})` β†’ `{success: false, error: "..."}` (Zod validation) +18. πŸ”΄ `pg_jsonb_path_query({})` β†’ `{success: false, error: "..."}` (Zod validation) +19. πŸ”΄ `pg_jsonb_agg({})` β†’ `{success: false, error: "..."}` (Zod validation) +20. πŸ”΄ `pg_jsonb_object({})` β†’ `{success: false, error: "..."}` (Zod validation) +21. πŸ”΄ `pg_jsonb_array({})` β†’ `{success: false, error: "..."}` (Zod validation) +22. πŸ”΄ `pg_jsonb_keys({})` β†’ `{success: false, error: "..."}` (Zod validation) diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-jsonb-part2.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-jsonb-part2.md index 16e699c6..c7b5249e 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-jsonb-part2.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-jsonb-part2.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -252,29 +252,29 @@ jsonb Tool Group (20 tools +1 for code mode): **Checklist:** -4. `pg_jsonb_typeof({table: "test_jsonb_docs", column: "tags", where: "id = 1"})` β†’ `"array"` -5. `pg_jsonb_typeof({table: "test_jsonb_docs", column: "metadata", where: "id = 1"})` β†’ `"object"` -6. `pg_jsonb_stats({table: "test_jsonb_docs", column: "metadata"})` β†’ verify `topKeys` present, `typeDistribution` present -7. `pg_jsonb_validate_path({path: "$.a.b.c"})` β†’ valid (note: validates JSONPath syntax, not dot-notation β€” `"a.b.c"` is invalid JSONPath) +1. `pg_jsonb_strip_nulls({json: {"a": 1, "b": null}})` β†’ verify `{"a": 1}` +2. `pg_jsonb_typeof({table: "test_jsonb_docs", column: "tags", where: "id = 1"})` β†’ `"array"` +3. `pg_jsonb_typeof({table: "test_jsonb_docs", column: "metadata", where: "id = 1"})` β†’ `"object"` +4. `pg_jsonb_validate_path({path: "$.a.b.c"})` β†’ valid (note: validates JSONPath syntax, not dot-notation β€” `"a.b.c"` is invalid JSONPath) +5. `pg_jsonb_stats({table: "test_jsonb_docs", column: "metadata"})` β†’ verify `topKeys` present, `typeDistribution` present +6. `pg_jsonb_merge({json1: {"a": 1}, json2: {"b": 2}})` β†’ verify `{"a": 1, "b": 2}` +7. `pg_jsonb_normalize({json: "{\"a\": 1, \"b\": 2}"})` β†’ verify parsed json 8. `pg_jsonb_diff({doc1: {"a": 1, "b": 2}, doc2: {"a": 1, "c": 3}})` β†’ verify `differences` array with `status` field (`"added"`, `"removed"`, `"modified"`), `hasDifferences: true` - -**pg_jsonb_pretty:** - -10. `pg_jsonb_pretty({json: "{\"a\":1,\"b\":2}"})` β†’ verify pretty-printed JSON string with indentation -11. `pg_jsonb_pretty({table: "test_jsonb_docs", column: "metadata", where: "id = 1"})` β†’ verify formatted output contains `"author": "Alice"` with indentation -12. πŸ”΄ `pg_jsonb_pretty({})` β†’ `{success: false, error: "..."}` (Zod validation β€” must provide either `json` or `table`+`column`) - -**Domain error paths (πŸ”΄):** - -15. πŸ”΄ `pg_jsonb_stats({table: "test_jsonb_docs", column: "metadata", sampleSize: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should silently default `sampleSize` to 1000 and return valid stats (wrong-type numeric param coercion) - -16. `pg_jsonb_index_suggest()` β†’ verify happy path expected behavior -17. πŸ”΄ `pg_jsonb_index_suggest({})` β†’ verify structured P154 error response or valid defaults -18. `pg_jsonb_security_scan()` β†’ verify happy path expected behavior -19. πŸ”΄ `pg_jsonb_security_scan({})` β†’ verify structured P154 error response or valid defaults -20. `pg_jsonb_merge()` β†’ verify happy path expected behavior -21. πŸ”΄ `pg_jsonb_merge({})` β†’ verify structured P154 error response or valid defaults -22. `pg_jsonb_normalize()` β†’ verify happy path expected behavior -23. πŸ”΄ `pg_jsonb_normalize({})` β†’ verify structured P154 error response or valid defaults -24. `pg_jsonb_strip_nulls()` β†’ verify happy path expected behavior -25. πŸ”΄ `pg_jsonb_strip_nulls({})` β†’ verify structured P154 error response or valid defaults +9. `pg_jsonb_index_suggest({table: "test_jsonb_docs", column: "metadata"})` β†’ verify expected behavior +10. `pg_jsonb_security_scan({table: "test_jsonb_docs", column: "metadata"})` β†’ verify expected behavior +11. `pg_jsonb_pretty({json: "{\"a\":1,\"b\":2}"})` β†’ verify pretty-printed JSON string with indentation +12. `pg_jsonb_pretty({table: "test_jsonb_docs", column: "metadata", where: "id = 1"})` β†’ verify formatted output contains `"author": "Alice"` with indentation + +**Domain and Zod error paths (πŸ”΄):** + +13. πŸ”΄ `pg_jsonb_stats({table: "test_jsonb_docs", column: "metadata", sampleSize: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should silently default `sampleSize` to 1000 and return valid stats (wrong-type numeric param coercion) +14. πŸ”΄ `pg_jsonb_strip_nulls({})` β†’ `{success: false, error: "..."}` (Zod validation) +15. πŸ”΄ `pg_jsonb_typeof({})` β†’ `{success: false, error: "..."}` (Zod validation) +16. πŸ”΄ `pg_jsonb_validate_path({})` β†’ `{success: false, error: "..."}` (Zod validation) +17. πŸ”΄ `pg_jsonb_stats({})` β†’ `{success: false, error: "..."}` (Zod validation) +18. πŸ”΄ `pg_jsonb_merge({})` β†’ `{success: false, error: "..."}` (Zod validation) +19. πŸ”΄ `pg_jsonb_normalize({})` β†’ `{success: false, error: "..."}` (Zod validation) +20. πŸ”΄ `pg_jsonb_diff({})` β†’ `{success: false, error: "..."}` (Zod validation) +21. πŸ”΄ `pg_jsonb_index_suggest({})` β†’ `{success: false, error: "..."}` (Zod validation) +22. πŸ”΄ `pg_jsonb_security_scan({})` β†’ `{success: false, error: "..."}` (Zod validation) +23. πŸ”΄ `pg_jsonb_pretty({})` β†’ `{success: false, error: "..."}` (Zod validation β€” must provide either `json` or `table`+`column`) diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-kcache.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-kcache.md index 415db2e7..0641fb8d 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-kcache.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-kcache.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-ltree.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-ltree.md index fe3aeae9..ad921ca7 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-ltree.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-ltree.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-migration.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-migration.md index c6632b7c..dc04ec19 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-migration.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-migration.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-monitoring.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-monitoring.md index a71ee2a3..2157008b 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-monitoring.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-monitoring.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-partitioning.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-partitioning.md index a985a86c..f034a93e 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-partitioning.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-partitioning.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-partman.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-partman.md index d33145ab..b48ec328 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-partman.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-partman.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-performance-part1.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-performance-part1.md index e7c718e3..10cf5d88 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-performance-part1.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-performance-part1.md @@ -20,7 +20,7 @@ ## Test Database Schema -The test database (`postgres`) contains these tables: +The test database (`postgres`) contains these tables:Please examine | Table | Rows | Key Columns | JSONB Columns | Tool Groups | | ------------------- | ---- | ---------------------------------------------------------------------------------- | ------------------------ | --------------------- | @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -247,36 +247,24 @@ performance Tool Group (24 tools +1 code mode) 3. `pg_table_stats({limit: 3})` β†’ verify `{tables: [...], count: 3, truncated: true, totalCount: N}` 4. `pg_index_stats({limit: 3})` β†’ verify `{indexes: [...], count: 3, truncated: true, totalCount: N}` -**Diagnostics tool:** - -**Anomaly detection tools β€” pg_detect_query_anomalies:** - -**Anomaly detection tools β€” pg_detect_bloat_risk:** - -**Anomaly detection tools β€” pg_detect_connection_spike:** - **Domain error paths (πŸ”΄):** -21. πŸ”΄ `pg_table_stats({})` β†’ verify returns handler error (not MCP error) for empty params or returns valid results -22. πŸ”΄ `pg_explain({})` β†’ `{success: false, error: "..."}` (Zod validation β€” missing required `sql`) +5. πŸ”΄ `pg_table_stats({})` β†’ verify returns handler error (not MCP error) for empty params or returns valid results +6. πŸ”΄ `pg_explain({})` β†’ `{success: false, error: "..."}` (Zod validation β€” missing required `sql`) **Wrong-type numeric param coercion (πŸ”΄):** -23. πŸ”΄ `pg_table_stats({limit: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `limit` (wrong-type numeric param) - -**Code mode parity (anomaly detection):** +7. πŸ”΄ `pg_table_stats({limit: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `limit` (wrong-type numeric param) -28. `pg_execute_code({code: "return await pg.performance.detectQueryAnomalies()"})` β†’ verify returns same structure as item 11 -29. `pg_execute_code({code: "return await pg.performance.detectBloatRisk({schema: 'public'})"})` β†’ verify returns same structure as item 15 -30. `pg_execute_code({code: "return await pg.performance.detectConnectionSpike()"})` β†’ verify returns same structure as item 18 +**Remaining tools:** -31. `pg_explain_analyze()` β†’ verify happy path expected behavior -32. πŸ”΄ `pg_explain_analyze({})` β†’ verify structured P154 error response or valid defaults -33. `pg_explain_buffers()` β†’ verify happy path expected behavior -34. πŸ”΄ `pg_explain_buffers({})` β†’ verify structured P154 error response or valid defaults -35. `pg_locks()` β†’ verify happy path expected behavior -36. πŸ”΄ `pg_locks({})` β†’ verify structured P154 error response or valid defaults -37. `pg_stat_statements()` β†’ verify happy path expected behavior -38. πŸ”΄ `pg_stat_statements({})` β†’ verify structured P154 error response or valid defaults -39. `pg_stat_activity()` β†’ verify happy path expected behavior -40. πŸ”΄ `pg_stat_activity({})` β†’ verify structured P154 error response or valid defaults +8. `pg_explain_analyze()` β†’ verify happy path expected behavior +9. πŸ”΄ `pg_explain_analyze({})` β†’ verify structured P154 error response or valid defaults +10. `pg_explain_buffers()` β†’ verify happy path expected behavior +11. πŸ”΄ `pg_explain_buffers({})` β†’ verify structured P154 error response or valid defaults +12. `pg_locks()` β†’ verify happy path expected behavior +13. πŸ”΄ `pg_locks({})` β†’ verify structured P154 error response or valid defaults +14. `pg_stat_statements()` β†’ verify happy path expected behavior +15. πŸ”΄ `pg_stat_statements({})` β†’ verify structured P154 error response or valid defaults +16. `pg_stat_activity()` β†’ verify happy path expected behavior +17. πŸ”΄ `pg_stat_activity({})` β†’ verify structured P154 error response or valid defaults diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-performance-part2.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-performance-part2.md index 0a032ea6..e02dd8c1 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-performance-part2.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-performance-part2.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -228,83 +228,92 @@ DROP TABLE IF EXISTS temp_my_test_table; performance Tool Group (24 tools +1 code mode) -9. 'pg_bloat_check' -10. 'pg_cache_hit_ratio' -11. 'pg_seq_scan_tables' -12. 'pg_index_recommendations' -13. 'pg_query_plan_compare' -14. 'pg_performance_baseline' -15. 'pg_connection_pool_optimize' -16. 'pg_partition_strategy_suggest' -17. 'pg_unused_indexes' -18. 'pg_duplicate_indexes' -19. 'pg_vacuum_stats' -20. 'pg_query_plan_stats' -21. 'pg_diagnose_database_performance' -22. 'pg_detect_query_anomalies' -23. 'pg_detect_bloat_risk' -24. 'pg_detect_connection_spike' -25. 'pg_execute_code' (codemode, auto-added) +1. 'pg_bloat_check' +2. 'pg_cache_hit_ratio' +3. 'pg_seq_scan_tables' +4. 'pg_index_recommendations' +5. 'pg_query_plan_compare' +6. 'pg_performance_baseline' +7. 'pg_connection_pool_optimize' +8. 'pg_partition_strategy_suggest' +9. 'pg_unused_indexes' +10. 'pg_duplicate_indexes' +11. 'pg_vacuum_stats' +12. 'pg_query_plan_stats' +13. 'pg_diagnose_database_performance' +14. 'pg_detect_query_anomalies' +15. 'pg_detect_bloat_risk' +16. 'pg_detect_connection_spike' +17. 'pg_execute_code' (codemode, auto-added) > **Instructions**: Construct a single `pg_execute_code` script to execute the numbered checklist items below. Use the `pg.*` namespace to call the corresponding methods with the exact inputs shown. Compare responses against the expected results within your script, and push any deviations or errors to a `failures` array. Return the `failures` array at the end of the script. Report any issues logged. **Existing performance tools:** -5. `pg_cache_hit_ratio()` β†’ verify `{heap_read, heap_hit, cache_hit_ratio}` where all are numbers or null -6. `pg_bloat_check()` β†’ verify returns `{tables, count}` -7. `pg_seq_scan_tables({limit: 3, minScans: 1})` β†’ verify `{tables, count: 3, truncated: true, totalCount: N}` -8. `pg_unused_indexes({limit: 3})` β†’ verify returns `{unusedIndexes, count}` -9. `pg_duplicate_indexes()` β†’ verify response structure +1. `pg_cache_hit_ratio()` β†’ verify `{heap_read, heap_hit, cache_hit_ratio}` where all are numbers or null +2. `pg_bloat_check()` β†’ verify returns `{tables, count}` +3. `pg_seq_scan_tables({limit: 3, minScans: 1})` β†’ verify `{tables, count: 3, truncated: true, totalCount: N}` +4. `pg_unused_indexes({limit: 3})` β†’ verify returns `{unusedIndexes, count}` +5. `pg_duplicate_indexes()` β†’ verify response structure **Diagnostics tool:** -10. `pg_diagnose_database_performance()` β†’ verify `{sections, overallScore, overallStatus, totalRecommendations, allRecommendations}` where `overallStatus` is one of `healthy`, `warning`, `critical`; `overallScore` is 0-100 +6. `pg_diagnose_database_performance()` β†’ verify `{sections, overallScore, overallStatus, totalRecommendations, allRecommendations}` where `overallStatus` is one of `healthy`, `warning`, `critical`; `overallScore` is 0-100 **Anomaly detection tools β€” pg_detect_query_anomalies:** -11. `pg_detect_query_anomalies()` β†’ verify `{anomalies, riskLevel, totalAnalyzed, anomalyCount, summary}` where `riskLevel` ∈ `{low, moderate, high, critical}`; `anomalyCount` matches `anomalies.length` -12. `pg_detect_query_anomalies({threshold: 1.0})` β†’ lower threshold may produce more anomalies; verify `anomalyCount >= 0` -13. `pg_detect_query_anomalies({threshold: 5.0, minCalls: 100})` β†’ higher threshold + minCalls should reduce noise; verify response structure +7. `pg_detect_query_anomalies()` β†’ verify `{anomalies, riskLevel, totalAnalyzed, anomalyCount, summary}` where `riskLevel` ∈ `{low, moderate, high, critical}`; `anomalyCount` matches `anomalies.length` +8. `pg_detect_query_anomalies({threshold: 1.0})` β†’ lower threshold may produce more anomalies; verify `anomalyCount >= 0` +9. `pg_detect_query_anomalies({threshold: 5.0, minCalls: 100})` β†’ higher threshold + minCalls should reduce noise; verify response structure **Anomaly detection tools β€” pg_detect_bloat_risk:** -14. `pg_detect_bloat_risk()` β†’ verify `{tables, highRiskCount, totalAnalyzed, summary}` where `highRiskCount >= 0` and `totalAnalyzed >= 0` -15. `pg_detect_bloat_risk({schema: "public"})` β†’ verify only `public` schema tables in results -16. `pg_detect_bloat_risk({minRows: 1})` β†’ lower threshold should include more tables; verify `totalAnalyzed` >= default result's `totalAnalyzed` -17. πŸ”΄ `pg_detect_bloat_risk({schema: "nonexistent_schema_xyz"})` β†’ should return structured P154 error response natively (`Schema ... does not exist`) +10. `pg_detect_bloat_risk()` β†’ verify `{tables, highRiskCount, totalAnalyzed, summary}` where `highRiskCount >= 0` and `totalAnalyzed >= 0` +11. `pg_detect_bloat_risk({schema: "public"})` β†’ verify only `public` schema tables in results +12. `pg_detect_bloat_risk({minRows: 1})` β†’ lower threshold should include more tables; verify `totalAnalyzed` >= default result's `totalAnalyzed` +13. πŸ”΄ `pg_detect_bloat_risk({schema: "nonexistent_schema_xyz"})` β†’ should return structured P154 error response natively (`Schema ... does not exist`) **Anomaly detection tools β€” pg_detect_connection_spike:** -18. `pg_detect_connection_spike()` β†’ verify `{totalConnections, maxConnections, usagePercent, byState, concentrations, warnings, riskLevel, summary}` where `totalConnections >= 1`, `maxConnections > 0`, `usagePercent` is 0-100, `riskLevel` ∈ `{low, moderate, high, critical}` -19. `pg_detect_connection_spike({warningPercent: 10})` β†’ lower threshold may produce more warnings; verify `warnings` is an array -20. `pg_detect_connection_spike({warningPercent: 100})` β†’ maximum threshold should produce fewer warnings; verify response structure +14. `pg_detect_connection_spike()` β†’ verify `{totalConnections, maxConnections, usagePercent, byState, concentrations, warnings, riskLevel, summary}` where `totalConnections >= 1`, `maxConnections > 0`, `usagePercent` is 0-100, `riskLevel` ∈ `{low, moderate, high, critical}` +15. `pg_detect_connection_spike({warningPercent: 10})` β†’ lower threshold may produce more warnings; verify `warnings` is an array +16. `pg_detect_connection_spike({warningPercent: 100})` β†’ maximum threshold should produce fewer warnings; verify response structure **Domain error paths (πŸ”΄):** +17. πŸ”΄ `pg_cache_hit_ratio({})` β†’ verify returns handler error or valid defaults +18. πŸ”΄ `pg_bloat_check({})` β†’ verify returns handler error or valid defaults +19. πŸ”΄ `pg_seq_scan_tables({})` β†’ verify returns handler error or valid defaults +20. πŸ”΄ `pg_unused_indexes({})` β†’ verify returns handler error or valid defaults +21. πŸ”΄ `pg_duplicate_indexes({})` β†’ verify returns handler error or valid defaults +22. πŸ”΄ `pg_diagnose_database_performance({})` β†’ verify returns handler error or valid defaults + **Wrong-type numeric param coercion (πŸ”΄):** -24. πŸ”΄ `pg_detect_query_anomalies({threshold: "abc"})` β†’ must NOT return raw MCP error; `threshold` should silently coerce to default 2.0 and return valid results -25. πŸ”΄ `pg_detect_query_anomalies({minCalls: "abc"})` β†’ must NOT return raw MCP error; `minCalls` should silently coerce to default 10 and return valid results -26. πŸ”΄ `pg_detect_bloat_risk({minRows: "abc"})` β†’ must NOT return raw MCP error; `minRows` should silently coerce to default 1000 and return valid results -27. πŸ”΄ `pg_detect_connection_spike({warningPercent: "abc"})` β†’ must NOT return raw MCP error; `warningPercent` should silently coerce to default 70 and return valid results +23. πŸ”΄ `pg_detect_query_anomalies({threshold: "abc"})` β†’ must NOT return raw MCP error; `threshold` should silently coerce to default 2.0 and return valid results +24. πŸ”΄ `pg_detect_query_anomalies({minCalls: "abc"})` β†’ must NOT return raw MCP error; `minCalls` should silently coerce to default 10 and return valid results +25. πŸ”΄ `pg_detect_bloat_risk({minRows: "abc"})` β†’ must NOT return raw MCP error; `minRows` should silently coerce to default 1000 and return valid results +26. πŸ”΄ `pg_detect_connection_spike({warningPercent: "abc"})` β†’ must NOT return raw MCP error; `warningPercent` should silently coerce to default 70 and return valid results **Code mode parity (anomaly detection):** -28. `pg_execute_code({code: "return await pg.performance.detectQueryAnomalies()"})` β†’ verify returns same structure as item 11 -29. `pg_execute_code({code: "return await pg.performance.detectBloatRisk({schema: 'public'})"})` β†’ verify returns same structure as item 15 -30. `pg_execute_code({code: "return await pg.performance.detectConnectionSpike()"})` β†’ verify returns same structure as item 18 - -31. `pg_index_recommendations()` β†’ verify happy path expected behavior -32. πŸ”΄ `pg_index_recommendations({})` β†’ verify structured P154 error response or valid defaults -33. `pg_vacuum_stats()` β†’ verify happy path expected behavior -34. πŸ”΄ `pg_vacuum_stats({})` β†’ verify structured P154 error response or valid defaults -35. `pg_query_plan_compare()` β†’ verify happy path expected behavior -36. πŸ”΄ `pg_query_plan_compare({})` β†’ verify structured P154 error response or valid defaults -37. `pg_performance_baseline()` β†’ verify happy path expected behavior -38. πŸ”΄ `pg_performance_baseline({})` β†’ verify structured P154 error response or valid defaults -39. `pg_connection_pool_optimize()` β†’ verify happy path expected behavior -40. πŸ”΄ `pg_connection_pool_optimize({})` β†’ verify structured P154 error response or valid defaults -41. `pg_partition_strategy_suggest()` β†’ verify happy path expected behavior -42. πŸ”΄ `pg_partition_strategy_suggest({})` β†’ verify structured P154 error response or valid defaults -43. `pg_query_plan_stats()` β†’ verify happy path expected behavior -44. πŸ”΄ `pg_query_plan_stats({})` β†’ verify structured P154 error response or valid defaults +27. `pg_execute_code({code: "return await pg.performance.detectQueryAnomalies()"})` β†’ verify returns same structure as item 7 +28. `pg_execute_code({code: "return await pg.performance.detectBloatRisk({schema: 'public'})"})` β†’ verify returns same structure as item 11 +29. `pg_execute_code({code: "return await pg.performance.detectConnectionSpike()"})` β†’ verify returns same structure as item 14 + +**Remaining tools:** + +30. `pg_index_recommendations()` β†’ verify happy path expected behavior +31. πŸ”΄ `pg_index_recommendations({})` β†’ verify structured P154 error response or valid defaults +32. `pg_vacuum_stats()` β†’ verify happy path expected behavior +33. πŸ”΄ `pg_vacuum_stats({})` β†’ verify structured P154 error response or valid defaults +34. `pg_query_plan_compare()` β†’ verify happy path expected behavior +35. πŸ”΄ `pg_query_plan_compare({})` β†’ verify structured P154 error response or valid defaults +36. `pg_performance_baseline()` β†’ verify happy path expected behavior +37. πŸ”΄ `pg_performance_baseline({})` β†’ verify structured P154 error response or valid defaults +38. `pg_connection_pool_optimize()` β†’ verify happy path expected behavior +39. πŸ”΄ `pg_connection_pool_optimize({})` β†’ verify structured P154 error response or valid defaults +40. `pg_partition_strategy_suggest()` β†’ verify happy path expected behavior +41. πŸ”΄ `pg_partition_strategy_suggest({})` β†’ verify structured P154 error response or valid defaults +42. `pg_query_plan_stats()` β†’ verify happy path expected behavior +43. πŸ”΄ `pg_query_plan_stats({})` β†’ verify structured P154 error response or valid defaults diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-pgcrypto.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-pgcrypto.md index 8b3787d5..dcd783f1 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-pgcrypto.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-pgcrypto.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-postgis-part1.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-postgis-part1.md index c67d0a4e..33d9842a 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-postgis-part1.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-postgis-part1.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -248,20 +248,22 @@ Test distance calculations between cities (e.g., New York ↔ London). **Checklist:** -2. `pg_distance({table: "test_locations", column: "location", lat: 40.7128, lng: -74.006, distance: 100000})` β†’ expect: New York in results -3. `pg_bounding_box({table: "test_locations", column: "location", minLat: 34, maxLat: 42, minLng: -119, maxLng: -73})` β†’ expect: NY, LA, Chicago -4. πŸ”΄ `pg_distance({table: "nonexistent_xyz", column: "geom", lat: 0, lng: 0, distance: 100})` β†’ `{success: false, error: "..."}` handler error -5. πŸ”΄ `pg_distance({table: "test_locations", column: "location", lat: 40.7128, lng: -74.006, distance: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `distance` (wrong-type numeric param) - -6. `pg_point_in_polygon()` β†’ verify happy path expected behavior -7. πŸ”΄ `pg_point_in_polygon({})` β†’ verify structured P154 error response or valid defaults -8. `pg_buffer()` β†’ verify happy path expected behavior -9. πŸ”΄ `pg_buffer({})` β†’ verify structured P154 error response or valid defaults -10. `pg_intersection()` β†’ verify happy path expected behavior -11. πŸ”΄ `pg_intersection({})` β†’ verify structured P154 error response or valid defaults -12. `pg_postgis_create_extension()` β†’ verify happy path expected behavior -13. πŸ”΄ `pg_postgis_create_extension({})` β†’ verify structured P154 error response or valid defaults -14. `pg_geometry_column()` β†’ verify happy path expected behavior +1. `pg_distance({table: "test_locations", column: "location", lat: 40.7128, lng: -74.006, distance: 100000})` β†’ expect: New York in results +2. `pg_bounding_box({table: "test_locations", column: "location", minLat: 34, maxLat: 42, minLng: -119, maxLng: -73})` β†’ expect: NY, LA, Chicago +3. `pg_point_in_polygon()` β†’ verify happy path expected behavior +4. `pg_buffer()` β†’ verify happy path expected behavior +5. `pg_intersection()` β†’ verify happy path expected behavior +6. `pg_postgis_create_extension()` β†’ verify happy path expected behavior +7. `pg_geometry_column()` β†’ verify happy path expected behavior +8. `pg_spatial_index()` β†’ verify happy path expected behavior + +**Domain and Zod error paths (πŸ”΄):** + +9. πŸ”΄ `pg_distance({table: "nonexistent_xyz", column: "geom", lat: 0, lng: 0, distance: 100})` β†’ `{success: false, error: "..."}` handler error +10. πŸ”΄ `pg_distance({table: "test_locations", column: "location", lat: 40.7128, lng: -74.006, distance: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `distance` (wrong-type numeric param) +11. πŸ”΄ `pg_point_in_polygon({})` β†’ verify structured P154 error response or valid defaults +12. πŸ”΄ `pg_buffer({})` β†’ verify structured P154 error response or valid defaults +13. πŸ”΄ `pg_intersection({})` β†’ verify structured P154 error response or valid defaults +14. πŸ”΄ `pg_postgis_create_extension({})` β†’ verify structured P154 error response or valid defaults 15. πŸ”΄ `pg_geometry_column({})` β†’ verify structured P154 error response or valid defaults -16. `pg_spatial_index()` β†’ verify happy path expected behavior -17. πŸ”΄ `pg_spatial_index({})` β†’ verify structured P154 error response or valid defaults +16. πŸ”΄ `pg_spatial_index({})` β†’ verify structured P154 error response or valid defaults diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-postgis-part2.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-postgis-part2.md index 21bd5b56..61a59a82 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-postgis-part2.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-postgis-part2.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -248,16 +248,19 @@ Test distance calculations between cities (e.g., New York ↔ London). **Checklist:** 1. `pg_geocode({lat: 40.7128, lng: -74.006})` β†’ verify `{geojson, wkt}` present -2. `pg_geo_index_optimize({table: "test_locations"})` β†’ verify spatial index analysis returned -3. πŸ”΄ `pg_geocode({})` β†’ `{success: false, error: "..."}` (Zod validation β€” missing required `lat`/`lng`) - -4. `pg_geo_transform()` β†’ verify happy path expected behavior -5. πŸ”΄ `pg_geo_transform({})` β†’ verify structured P154 error response or valid defaults -6. `pg_geo_cluster()` β†’ verify happy path expected behavior -7. πŸ”΄ `pg_geo_cluster({})` β†’ verify structured P154 error response or valid defaults -8. `pg_geometry_buffer()` β†’ verify happy path expected behavior -9. πŸ”΄ `pg_geometry_buffer({})` β†’ verify structured P154 error response or valid defaults -10. `pg_geometry_intersection()` β†’ verify happy path expected behavior -11. πŸ”΄ `pg_geometry_intersection({})` β†’ verify structured P154 error response or valid defaults -12. `pg_geometry_transform()` β†’ verify happy path expected behavior -13. πŸ”΄ `pg_geometry_transform({})` β†’ verify structured P154 error response or valid defaults +2. `pg_geo_transform()` β†’ verify happy path expected behavior +3. `pg_geo_index_optimize({table: "test_locations"})` β†’ verify spatial index analysis returned +4. `pg_geo_cluster()` β†’ verify happy path expected behavior +5. `pg_geometry_buffer()` β†’ verify happy path expected behavior +6. `pg_geometry_intersection()` β†’ verify happy path expected behavior +7. `pg_geometry_transform()` β†’ verify happy path expected behavior + +**Domain and Zod error paths (πŸ”΄):** + +8. πŸ”΄ `pg_geocode({})` β†’ `{success: false, error: "..."}` (Zod validation β€” missing required `lat`/`lng`) +9. πŸ”΄ `pg_geo_transform({})` β†’ verify structured P154 error response or valid defaults +10. πŸ”΄ `pg_geo_index_optimize({})` β†’ verify structured P154 error response or valid defaults +11. πŸ”΄ `pg_geo_cluster({})` β†’ verify structured P154 error response or valid defaults +12. πŸ”΄ `pg_geometry_buffer({})` β†’ verify structured P154 error response or valid defaults +13. πŸ”΄ `pg_geometry_intersection({})` β†’ verify structured P154 error response or valid defaults +14. πŸ”΄ `pg_geometry_transform({})` β†’ verify structured P154 error response or valid defaults diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-roles.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-roles.md new file mode 100644 index 00000000..75c26116 --- /dev/null +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-roles.md @@ -0,0 +1,290 @@ +# postgres-mcp codemode Re-Testing: [roles] + +**ESSENTIAL INSTRUCTIONS** + +- Conduct an exhaustive test of the tool group listed below using ONLY code mode (`pg_execute_code`). +- Do not use scripts or terminal to replace planned tests. +- Do not modify or skip tests. +- Ensure your validation script returns an aggregated array of failures if any exist. +- Group multiple tests into a single script to save context window tokens. +- Do not run test-tools-advanced-2.md at this time. +- All changes MUST be consistent with other postgres-mcp tools and `code-map.md`. + +## Reporting Format + +- ❌ Fail: Tool errors or produces incorrect results (include error message) +- ⚠️ Issue: Unexpected behavior or improvement opportunity +- πŸ“¦ Payload: Unnecessarily large response that should be optimized β€” **blocking, equally important as ❌ bugs**. Oversized payloads waste LLM context window tokens and degrade downstream tool-calling quality. **You MUST monitor `metrics.tokenEstimate` for every operation**. Report the response size in tokens/KB and suggest a concrete optimization (e.g., filter system tables, add `compact` option, omit empty arrays). + +> **Token estimates**: Every tool response includes `_meta.tokenEstimate` in its `content[].text` payload (approximate token count based on ~4 bytes/token). Code Mode responses include `metrics.tokenEstimate` instead. These are injected automatically by the adapter β€” no per-tool assertions needed, but report as ⚠️ if absent. + +## Test Database Schema + +The test database (`postgres`) contains these tables: + +| Table | Rows | Key Columns | JSONB Columns | Tool Groups | +| ------------------- | ---- | ---------------------------------------------------------------------------------- | ------------------------ | --------------------- | +| `test_products` | 15 | id, name, description, price, created_at | β€” | Core, Stats | +| `test_orders` | 20 | id, product_id (FK), quantity, total_price, status | β€” | Core, Stats, Trans | +| `test_jsonb_docs` | 3 | id | metadata, settings, tags | JSONB (20 tools) | +| `test_articles` | 3 | id, title, body, search_vector (TSVECTOR) | β€” | Text | +| `test_measurements` | 500 | id, sensor_id (INT 1-6), temperature, humidity, pressure | β€” | Stats (19 tools) | +| `test_embeddings` | 50 | id, content, category, embedding (vector 384d) | β€” | Vector (16 tools) | +| `test_locations` | 5 | id, name, location (GEOMETRY POINT SRID 4326) | β€” | PostGIS (15 tools) | +| `test_users` | 3 | id, username (CITEXT), email (CITEXT) | β€” | Citext (6 tools) | +| `test_categories` | 6 | id, name, path (LTREE) | β€” | Ltree (8 tools) | +| `test_secure_data` | 0 | id, user_id, sensitive_data (BYTEA), created_at | β€” | pgcrypto (9 tools) | +| `test_events` | 100 | id, event_type, event_date, payload (JSONB) β€” PARTITION BY RANGE | payload | Partitioning, Partman | +| `test_logs` | 0 | id, log_level, message, created_at β€” PARTITION BY RANGE | β€” | Partman | +| `test_departments` | 3 | id, name, budget | β€” | Introspection | +| `test_employees` | 5 | id, name, department_id (FK CASCADE), manager_id (FK self-ref SET NULL), hire_date | β€” | Introspection | +| `test_projects` | 2 | id, name, lead_id (FK SET NULL), department_id (FK RESTRICT) | β€” | Introspection | +| `test_assignments` | 3 | id, employee_id (FK CASCADE), project_id (FK CASCADE), role β€” UNIQUE(emp,proj) | β€” | Introspection | +| `test_audit_log` | 3 | entry_id (no PK!), employee_id (FK, no index!), action, created_at | β€” | Introspection | + +Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_order_summary` (view), `test_get_order_count()` (function). +Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_locations_geo` (GIST), `idx_categories_path` (GIST), HNSW on `test_embeddings.embedding`. + +## Testing Requirements + +1. Use existing `test_*` tables for read operations (SELECT, COUNT, EXISTS, etc.) +2. Create temporary tables with `temp_` prefix for write operations (CREATE, INSERT, DROP, etc.) +3. Test each tool with realistic inputs based on the schema above +4. Clean up any `temp_*` tables after testing +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop +6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal +7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. +8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. +9. **Scripting Efficiency**: You should bundle multiple tool checks into a single `pg_execute_code` call to save LLM context window tokens. Use conditional checks to aggregate errors and return a `failures` array. +10. **Pacing**: Test up to an entire tool group in a single script if feasible, but limit scripts to ~10-15 steps to remain manageable. Report the aggregated results, update your matrix, and move to the next group. +11. **Deterministic checklist first**: Complete ALL items in the Deterministic Checklist below using Code Mode before moving to the Strict Coverage Matrix exploration. +12. **Audit backup tools**: The 3 `pg_audit_*` tools require `--audit-backup` to be enabled on the test server. When enabled, destructive operations (`pg_truncate`, `pg_drop_table`, `pg_vacuum`, etc.) create gzip-compressed `.snapshot.json.gz` files alongside the audit log. **V2 features to verify**: `pg_audit_diff_backup` now returns a `volumeDrift` field (row count + size changes); `pg_audit_restore_backup` supports `restoreAs` for side-by-side non-destructive restore; and Code Mode calls through `pg_execute_code` that trigger destructive operations are also captured by the interceptor. When disabled, all 3 tools return `{success: false, error: "Audit backup not enabled"}`. + +Note: The isError flag propagation issue has been fixed. P154 structured errors (`{success: false, error: "..."}`) return as parseable JSON objects. During error path testing, verify this: if an invalid Code Mode call returns a raw error string instead of a JSON object with `success` and `error` fields, report it as ❌. + +## Structured Error Response Pattern + +All tools must return errors as structured objects instead of throwing. A thrown error propagates as a raw MCP error, which is unhelpful to clients. The expected pattern: + +```json +{ + "success": false, + "error": "Human-readable error message", + "code": "QUERY_ERROR", + "category": "query", + "recoverable": false +} +``` + +The enriched `ErrorResponse` from `formatHandlerError` always includes `success`, `error`, `code`, `category`, and `recoverable`. Optional fields `suggestion` and `details` may also be present. Some tools include additional context fields (e.g., `pg_transaction_execute` includes `statementsExecuted`, `failedStatement`, `autoRolledBack`). These are acceptable as long as `success: false` and `error` are always present. + +### Handler Error vs MCP Error β€” How to Distinguish + +There are two kinds of error responses. Only one is correct: + +| Type | Source | What you see | Verdict | +| -------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------ | +| **Handler error** βœ… | Handler catches error and returns `{success: false, error: "..."}` | Parseable JSON object with `success` and `error` fields | Correct | +| **MCP error** ❌ | Uncaught throw propagates to MCP framework | Raw text error string, often prefixed with `Error:`, wrapped in an `isError: true` content block β€” no `success` field | Bug β€” report as ❌ | + +**Concrete examples:** + +``` +βœ… Handler error (correct): +{"success": false, "error": "Table \"public.nonexistent\" does not exist"} + +❌ MCP error (bug β€” handler threw instead of catching): +content: [{type: "text", text: "Error: relation \"nonexistent\" does not exist"}] +isError: true +``` + +The MCP error case means the handler is missing a `try/catch` block. When testing, if you see a raw error string (especially one containing PostgreSQL internal messages like `relation "..." does not exist` without a `success` field), report it as ❌. + +### Zod Validation Errors + +Calling a tool with wrong parameter types or missing required fields triggers a Zod validation error. If the handler has no outer `try/catch`, this surfaces as a raw MCP error. Test every tool with `{}` (empty params) if it has required parameters β€” the response must be a handler error, not an MCP error. + +**Error message format matters:** Zod `.refine()` failures produce a `ZodError` whose `.message` property is a **raw JSON array** of Zod issues (e.g., `[{"code":"custom","message":"..."}]`). If the handler catches the error with `error.message` instead of routing through `formatHandlerError`, this raw JSON leaks as the error string. All handlers must route through `formatHandlerError`, which duck-types the `.issues` array and produces clean `Validation error: name (or table alias) is required; Validation error: columns must not be empty` messages. If you see a raw JSON array in an error message, report it as ❌. + +**Zod refinement leak pattern:** The Split Schema pattern uses `.partial()` on input schemas so the SDK accepts `{}`. But `.partial()` only makes keys **optional** β€” it does NOT strip refinements like `.min(1)`, `.max(90)`, or `.min(-90).max(90)`. This applies to **ALL types** β€” strings, arrays, AND numbers: + +- `z.string().min(1)` + empty `""` β†’ SDK rejects with raw MCP `-32602` +- `z.array().min(1)` + empty `[]` β†’ SDK rejects with raw MCP `-32602` +- `z.number().min(-90).max(90)` + value `91` β†’ SDK rejects with raw MCP `-32602` + +**Fix:** Remove ALL `.min(N)` / `.max(N)` refinements from the schema and validate inside the handler instead. Optional fields with `.default()` are safe because the default satisfies the constraint. + +**Required enum coercion pattern:** For **optional** enum params with defaults, `z.preprocess(coercer, z.enum([...]).optional().default(...))` works β€” the coercer returns `undefined` for invalid values β†’ the `.default()` kicks in. For **required** enum params (no `.optional().default(...)`), this pattern **fails**: the SDK's `.partial()` wraps the preprocess in `.optional()`, but the inner `z.enum()` still rejects `undefined` β†’ raw MCP `-32602`. **Fix:** Use `z.string()` in the schema and validate the enum inside the handler's `try/catch`, returning a structured error. + +**What to report:** + +- If a tool call returns a raw MCP error (no JSON body with `success` field), report it as ❌ with the tool name and the raw error message +- If a tool returns `{success: false, error: "..."}` but the error string is a raw Zod JSON array (starts with `[{`), report as ❌ (handler uses `error.message` instead of `formatHandlerError`) +- If a tool returns `{success: false, error: "Validation error: ..."}` with clean human-readable text, that is the correct behavior β€” do not report it as a failure +- If a tool returns a successful response for an obviously invalid input (e.g., nonexistent table returns `{success: true}`), report it as ⚠️ + +## Split Schema Pattern Verification + +All tools use the Split Schema pattern: a plain `z.object()` Base schema for MCP parameter visibility (used as `inputSchema`), and handler-side parsing via `z.preprocess()`, `.default({})`, or direct `.parse()` inside `try/catch`. Verify: + +1. **JSON Schema visibility**: Before testing tool behavior, call `tools/list` (or inspect the MCP server's tool definitions) and confirm each tool's `inputSchema` exposes its parameters. Tools with optional parameters (e.g., `schema`, `limit`, `direction`) must show non-empty `properties` in the JSON Schema. If a tool's `inputSchema` is empty or missing `properties`, report as a Split Schema violation. +2. **Parameter visibility**: For tools with optional parameters (e.g., `schema`, `limit`), make a Code Mode call using those parameters. If the tool ignores or rejects documented parameters, report as a Split Schema violation. +3. **Alias acceptance**: For tools with documented parameter aliases (e.g., table/tableName/name, sql/query), verify that Code Mode calls correctly accept the aliasesβ€”not just the primary parameter name. If a call using only an alias fails with a validation error like "X is required", report it as a Split Schema violation requiring a fix. +4. **`z.preprocess()` as `inputSchema`**: If a tool uses `z.preprocess()` directly as its `inputSchema` (instead of a plain `SchemaBase`), parameter metadata is stripped from JSON Schema generation, making MCP tooling unable to see or use those parameters. Report as a Split Schema violation. + +## P154 Object Existence Verification + +All tools should return structured error responses for nonexistent tables/schemas (via `formatHandlerError`). The 5 core convenience tools (pg_count, pg_exists, pg_upsert, pg_batch_insert, pg_truncate) implement explicit pre-checks and serve as canonical verification targets. Beyond those, **every tool group must have at least one nonexistent-table test in its checklist** β€” see the error-path items (marked πŸ”΄) in each group's checklist in `test-group-tools.md`. + +For each P154 test, verify that calling with a nonexistent table (e.g., `table: "nonexistent_table_xyz"`) returns a handler error like `{success: false, error: "Table \"public.nonexistent_table_xyz\" does not exist"}` rather than a raw MCP error. Also verify that a nonexistent schema (e.g., `table: "fake_schema.users"`) produces a similarly clear handler error. + +Key PostgreSQL error codes that should be intercepted by `formatHandlerError` (not leaked as raw errors): + +| PG Error Code | Meaning | Expected Structured Message | +| ------------- | ------------------- | --------------------------------- | +| 42P01 | Undefined table | `Table "X" does not exist` | +| 42P06 | Duplicate schema | `Schema "X" already exists` | +| 42P07 | Duplicate table | `Table "X" already exists` | +| 42701 | Duplicate column | `Column "X" already exists` | +| 42703 | Undefined column | `Column "X" does not exist` | +| 23505 | Unique violation | `Duplicate key: ...` | +| 23503 | FK violation | `Foreign key constraint violated` | +| 42601 | Syntax error | `SQL syntax error: ...` | +| 3F000 | Invalid schema name | `Schema "X" does not exist` | +| XX000 | Internal error | `Internal error: ...` | + +## Error Consistency Audit + +During testing, check for these inconsistencies across tool groups: + +1. **Throw-vs-return**: If a tool throws a raw error instead of returning `{success: false}`, report as ❌. Document which tool groups have the worst raw-error leakage. +2. **Error field name**: All `{ success: false }` error responses should use `error` as the field name. If a tool uses a different field name for error context in a failure response, report as ⚠️. +3. **Zod validation leaks**: If calling a tool with an invalid enum value or missing required field produces a raw MCP `-32602` Zod validation error instead of a structured response, report as ❌. This indicates the Zod schema is rejecting the input at the MCP framework level before the handler's `try/catch` can intercept. +4. **Missing `formatHandlerError` wrapping**: postgres-mcp has a centralized `formatHandlerError` helper. If a handler catches errors but returns ad-hoc messages instead of using the centralized formatter, report which handler and the ad-hoc message pattern. +5. **Orphaned output schemas**: If a schema is exported from `src/adapters/postgresql/schemas/` but the corresponding tool definition does not reference it via `outputSchema`, report as ⚠️. Use `grep_search` to check whether the schema name appears in any tool file. Defined-but-unwired schemas provide zero enforcement. +6. **Inline output schemas**: If any tool defines `outputSchema: z.object({...})` inline in the handler file instead of importing a named schema from the `schemas/` directory, report as ⚠️. All output schemas must live in the appropriate `schemas/` directory with named exports. + +## Error Path Testing Checklist + +For each tool group under test, verify at least one scenario from each applicable row: + +| Error Scenario | Tool Groups to Test | Example Input | +| --------------------------------- | ------------------------------------- | ----------------------------------------------------------------------- | +| Nonexistent table | All table-accepting tools | `table: "nonexistent_xyz"` | +| Nonexistent schema | Core, introspection, schema | `schema: "fake_schema"` or `table: "fake_schema.users"` | +| Invalid SQL syntax | Core (`read_query`, `write_query`) | `sql: "SELECTT * FROM"` | +| Invalid column name | Stats, JSONB, text, vector, PostGIS | `column: "nonexistent_col"` | +| Duplicate table/index | Core (`create_table`, `create_index`) | Create existing table | +| Empty required array | Transactions | `statements: []` | +| Missing required field via alias | Core, transactions | `sql` alias instead of `query` | +| **Zod validation (empty params)** | **Every tool with required params** | `{}` (empty object β€” must return handler error, not MCP `-32602` error) | +| **Zod validation (wrong type)** | **Tools with typed params** | Pass string where number expected, etc. | + +## Cleanup Conventions + +During testing, use these naming conventions: + +- **Temporary tables**: Prefix with `temp_` (e.g., `temp_rls_demo`) +- **Temporary roles**: Prefix with `temp_test_role_` (e.g., `temp_test_role_analyst`) +- **Test views**: Prefix with `test_view_` (e.g., `test_view_order_summary`) +- **Test functions**: Prefix with `test_func_` (e.g., `test_func_calculate`) +- **Test schemas**: Prefix with `test_schema_` (e.g., `test_schema_temp`) + +After testing, clean up: + +```sql +-- List temp tables +SELECT tablename FROM pg_tables +WHERE schemaname = 'public' AND tablename LIKE 'temp_%'; + +-- Drop temp table +DROP TABLE IF EXISTS temp_rls_demo; + +-- Drop temp roles +DROP ROLE IF EXISTS temp_test_role_analyst; +DROP ROLE IF EXISTS temp_test_role_writer; +``` + +## Post-Test Procedures + +### Reporting Rules + +- Use βœ… only in inline notes during testing; omit from Final Summary +- Do not mention what already works well or issues already documented in server-instructions.md and runtime hints + +### After Testing + +1. **Cleanup**: Confirm all `temp_*` tables, `temp_test_role_*` roles, and temporary testing data are removed +2. **Fix EVERY finding** β€” not just ❌ Fails, but also ⚠️ Issues including behavioral improvements, missing warnings, error code consistency, πŸ“¦ Payload problems (responses that should be truncated or offer a `limit` param) and files listed below. All changes MUST be consistent with other postgres-mcp tools and `code-map.md` +3. **Scope of fixes** includes corrections to any of: + - Handler code + - `server-instructions.md` + - Test database (`test-database.sql`) + - This prompt (`test-tools-codemode.md`) and group file (`test-group-tools-codemode.md`) +4. Update the changelog with any changes made (being careful not to create duplicate headers), and commit without pushing. +5. **Token Audit**: Before concluding, call `read_resource` on `postgres://audit` to retrieve the `sessionTokenEstimate` (total token usage) for your testing session. Include this "Total Token Usage" in your final test report and session summary. Highlight the single most expensive Code Mode execution block. +6. Stop and briefly summarize the testing results and fixes, ensuring the total token count is prominently displayed. + +--- + +## Group Focus: roles + +### roles Group-Specific Testing + +roles Tool Group (12 tools +1 for code mode) + +1. 'pg_role_list' +2. 'pg_role_create' +3. 'pg_role_drop' +4. 'pg_role_attributes' +5. 'pg_role_grants' +6. 'pg_role_grant' +7. 'pg_role_assign' +8. 'pg_role_revoke' +9. 'pg_user_roles' +10. 'pg_role_set' +11. 'pg_role_rls_enable' +12. 'pg_role_rls_policies' +13. 'pg_execute_code' (codemode, auto-added) + +> **Instructions**: Construct a single `pg_execute_code` script to execute the numbered checklist items below. Use the `pg.*` namespace to call the corresponding methods with the exact inputs shown. Compare responses against the expected results within your script, and push any deviations or errors to a `failures` array. Return the `failures` array at the end of the script. Report any issues logged. + +**Test data:** Roles tools operate on PostgreSQL catalog views (`pg_roles`, `pg_auth_members`, `pg_policies`) and DDL statements (`CREATE ROLE`, `GRANT`, `SET ROLE`, etc.). No user-created test tables are required for most tools. Create `temp_rls_demo` for RLS testing via `pg.execute()`. Create `temp_test_role_*` roles for role CRUD and privilege testing. + +> **Superuser note:** Most roles tools (`pg_role_create`, `pg_role_drop`, `pg_role_grant`, `pg_role_assign`, `pg_role_revoke`, `pg_role_set`, `pg_role_rls_enable`) require superuser access or appropriate role management privileges. The test server runs as `postgres` (superuser). If running against a non-superuser connection, these tools should return a structured error β€” not a raw MCP error. + +**Checklist:** + +βœ… 1. `pg.execute("CREATE TABLE IF NOT EXISTS temp_rls_demo (id SERIAL PRIMARY KEY, user_id TEXT, data TEXT)")` β†’ setup temp table for RLS tests +βœ… 2. `pg.roles.list()` β†’ verify `{success: true, roles: [...]}` with `postgres` present +βœ… 3. `pg.roles.list({pattern: "postgres"})` β†’ verify filtered result +βœ… 4. `pg.roles.create({name: "temp_test_role_analyst"})` β†’ verify `{success: true}` +βœ… 5. `pg.roles.create({name: "temp_test_role_writer", login: true, password: "testpass123"})` β†’ verify with LOGIN attribute +βœ… 6. `pg.roles.attributes({role: "temp_test_role_analyst"})` β†’ verify OID, inherit, login=false +βœ… 7. `pg.roles.attributes({role: "postgres"})` β†’ verify `superuser: true` +βœ… 8. `pg.roles.grants({role: "temp_test_role_analyst"})` β†’ verify empty grants +βœ… 9. `pg.roles.grant({role: "temp_test_role_analyst", privileges: ["SELECT"], table: "test_products"})` β†’ verify success +βœ… 10. `pg.roles.grants({role: "temp_test_role_analyst"})` β†’ verify SELECT on test_products appears +βœ… 11. `pg.roles.assign({role: "temp_test_role_analyst", member: "temp_test_role_writer"})` β†’ verify membership +βœ… 12. `pg.roles.userRoles({role: "temp_test_role_writer"})` β†’ verify `temp_test_role_analyst` in memberships +βœ… 13. `pg.roles.revoke({role: "temp_test_role_analyst", member: "temp_test_role_writer"})` β†’ verify revoked +βœ… 14. `pg.roles.userRoles({role: "temp_test_role_writer"})` β†’ verify membership removed +βœ… 15. `pg.roles.set({role: "temp_test_role_analyst"})` β†’ verify SET ROLE +βœ… 16. `pg.roles.set({reset: true})` β†’ verify RESET ROLE +βœ… 17. `pg.roles.rlsEnable({table: "temp_rls_demo", enable: true})` β†’ verify RLS enabled +βœ… 18. `pg.roles.rlsPolicies({table: "temp_rls_demo"})` β†’ verify empty policies array +βœ… 19. `pg.roles.rlsEnable({table: "temp_rls_demo", enable: false})` β†’ verify RLS disabled +βœ… 20. `pg.roles.drop({name: "temp_test_role_writer"})` β†’ verify dropped + +βœ… 21. πŸ”΄ `pg.roles.create({})` β†’ `{success: false, error: "..."}` (missing name) +βœ… 22. πŸ”΄ `pg.roles.drop({})` β†’ `{success: false, error: "..."}` (missing name) +βœ… 23. πŸ”΄ `pg.roles.attributes({role: "nonexistent_role_xyz"})` β†’ `{success: false}` (P154) +βœ… 24. πŸ”΄ `pg.roles.grants({role: "nonexistent_role_xyz"})` β†’ `{success: false}` (P154) +βœ… 25. πŸ”΄ `pg.roles.grant({role: "temp_test_role_analyst", privileges: ["SELECT"], table: "nonexistent_xyz"})` β†’ `{success: false}` (P154 table) +βœ… 26. πŸ”΄ `pg.roles.rlsEnable({table: "nonexistent_xyz"})` β†’ `{success: false}` (P154 table) +βœ… 27. πŸ”΄ `pg.roles.rlsPolicies({table: "nonexistent_xyz"})` β†’ `{success: false}` (P154 table) + +**Cleanup (inside the script):** + +βœ… 28. `pg.roles.drop({name: "temp_test_role_analyst"})` (revoke grants first if needed) +βœ… 29. `pg.execute("DROP TABLE IF EXISTS temp_rls_demo")` diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-schema.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-schema.md index 78ab0b4b..d8c12953 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-schema.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-schema.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-cross-group.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-security.md similarity index 77% rename from test-server/test-tool-groups-codemode/test-tool-group-codemode-cross-group.md rename to test-server/test-tool-groups-codemode/test-tool-group-codemode-security.md index f8430380..cc9af5c1 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-cross-group.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-security.md @@ -1,22 +1,22 @@ -# postgres-mcp codemode Re-Testing β€” postgres-mcp β€” cross-group Integration +# postgres-mcp codemode Re-Testing: [security] **ESSENTIAL INSTRUCTIONS** -- Execute **EVERY** numbered stress test below using code mode (`pg_execute_code`). +- Conduct an exhaustive test of the tool group listed below using ONLY code mode (`pg_execute_code`). - Do not use scripts or terminal to replace planned tests. - Do not modify or skip tests. -- Do not run test-tools-advanced-1.md through test-tools-advanced-7.md. -- All changes **MUST** be consistent with other postgres-mcp tools and `code-map.md`. +- Ensure your validation script returns an aggregated array of failures if any exist. +- Group multiple tests into a single script to save context window tokens. +- Do not run test-tools-advanced-2.md at this time. +- All changes MUST be consistent with other postgres-mcp tools and `code-map.md`. -## Code Mode Execution +## Reporting Format -All tests should be executed via `pg_execute_code` code mode. Code Mode is explicitly designed for multi-group coordination inside a single sandboxed worker. +- ❌ Fail: Tool errors or produces incorrect results (include error message) +- ⚠️ Issue: Unexpected behavior or improvement opportunity +- πŸ“¦ Payload: Unnecessarily large response that should be optimized β€” **blocking, equally important as ❌ bugs**. Oversized payloads waste LLM context window tokens and degrade downstream tool-calling quality. **You MUST monitor `metrics.tokenEstimate` for every operation**. Report the response size in tokens/KB and suggest a concrete optimization (e.g., filter system tables, add `compact` option, omit empty arrays). -**Key rules:** - -- Use `pg..help()` to discover method names and parameters natively. -- State **persists** across `pg_execute_code` calls. -- Group multiple related tests into a single code mode call cleanly. +> **Token estimates**: Every tool response includes `_meta.tokenEstimate` in its `content[].text` payload (approximate token count based on ~4 bytes/token). Code Mode responses include `metrics.tokenEstimate` instead. These are injected automatically by the adapter β€” no per-tool assertions needed, but report as ⚠️ if absent. ## Test Database Schema @@ -51,10 +51,10 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. -8. **Advanced Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the advanced test categories, you must explicitly track completions. Do not proceed to the final summary until every check is marked with a βœ…. +8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. 9. **Scripting Efficiency**: You should bundle multiple tool checks into a single `pg_execute_code` call to save LLM context window tokens. Use conditional checks to aggregate errors and return a `failures` array. 10. **Pacing**: Test up to an entire tool group in a single script if feasible, but limit scripts to ~10-15 steps to remain manageable. Report the aggregated results, update your matrix, and move to the next group. 11. **Deterministic checklist first**: Complete ALL items in the Deterministic Checklist below using Code Mode before moving to the Strict Coverage Matrix exploration. @@ -222,32 +222,52 @@ DROP TABLE IF EXISTS temp_my_test_table; --- -## cross-group Advanced Workflows - -> **Purpose**: Test realistic deep multi-group pipelines dynamically tracked purely inside Javascript worker threads. These catch serialization, token bound, isolation, and handler decoupling bugs that identical API calls miss natively. - -### Category 1: Core β†’ JSONB β†’ Stats (Data Pipeline) - -1. Create a `temp_cross_data` table mapping `SERIAL` id to `JSONB` blobs. - a) Populate 100 rows containing nested numeric telemetry inside the JSONB structure using `pg.core.batchInsert`. - b) Invoke `pg.jsonb.extract` to cleanly extract the nested array values across all rows. - c) Bridge the extracted dynamic arrays safely into `pg.stats.percentiles` to verify math operations on dynamically transformed JSON arrays limit accurately. - -### Category 2: Core β†’ Vector β†’ Text (AI Search Pipeline) - -2. Create `temp_cross_ai` mapping `VECTOR`, `TEXT`, and `JSONB` parameters cleanly. - a) Inject 3 rows with explicit embeddings and text descriptions. - b) Capture `pg.vector.search` locally to find the nearest neighbor. - c) Execute `pg.text.search` purely using the extracted ID from the vector search to confirm string metadata alignment safely. - -### Category 3: Transactions β†’ Backup β†’ Migration (Exception IPC Parity) - -3. Deep Handler Validation: Call `pg.transactions.begin` then `pg.migration.apply`. Force a synthetic parser failure seamlessly (e.g. invalid migration path). Ensure `pg.transactions.rollback` smartly cleans up the migration partial state cleanly, and retrieve the audit log inside the same script using `pg.backup` tools (e.g., `auditListBackups`) or `pg.migration.history` to verify the rollback was recorded gracefully. - -### Category 4: Vector β†’ JSONB β†’ Code Mode Context Limits - -4. Inject 500 large mock vectors directly into a Code Mode array and batch insert them, immediately pulling them back via `pg.jsonb.extract` and reading the payload size natively. Verify the sandbox context limits gracefully reject massive allocations or return `metrics.tokenEstimate` effectively. - -### Final Reporting - -Verify completely flawlessly that all state chains correctly dropped and temporary `temp_cross_` structures are removed cleanly. +## Group Focus: security + +### security Group-Specific Testing + +security Tool Group (9 tools +1 for code mode) + +1. 'pg_security_audit' +2. 'pg_security_firewall_status' +3. 'pg_security_firewall_rules' +4. 'pg_security_ssl_status' +5. 'pg_security_encryption_status' +6. 'pg_security_password_validate' +7. 'pg_security_mask_data' +8. 'pg_security_user_privileges' +9. 'pg_security_sensitive_tables' +10. 'pg_execute_code' (codemode, auto-added) + +> **Instructions**: Construct a single `pg_execute_code` script to execute the numbered checklist items below. Use the `pg.*` namespace to call the corresponding methods with the exact inputs shown. Compare responses against the expected results within your script, and push any deviations or errors to a `failures` array. Return the `failures` array at the end of the script. Report any issues logged. + +**Test data:** Security tools operate on PostgreSQL catalog views (`pg_roles`, `pg_stat_ssl`, `pg_hba_file_rules`, `information_schema.columns`) and pure-JS logic. No user-created test tables are required. The existing seed includes tables with sensitive-looking columns (`test_secure_data.sensitive_data`, `test_users.email`, `test_employees.name`) which are used by `pg_security_sensitive_tables`. + +> **Superuser note:** `pg_security_firewall_status` and `pg_security_firewall_rules` require superuser access to `pg_hba_file_rules`. The test server runs as `postgres` (superuser). If running against a non-superuser connection, these tools should return a structured error β€” not a raw MCP error. + +**Checklist:** + +1. `pg.security.audit()` β†’ verify `{success: true, findings: [...], summary: {total, passed, warnings, critical}}` +2. `pg.security.audit({limit: 2})` β†’ verify findings array length ≀ 2 +3. `pg.security.sslStatus()` β†’ verify `{success: true, sslEnabled: boolean}` +4. `pg.security.encryptionStatus()` β†’ verify `{success: true, sslEnabled, passwordEncryption, pgcryptoAvailable}` +5. `pg.security.passwordValidate({password: "Str0ng!Pass#2026"})` β†’ verify `strength >= 80`, `interpretation: "Very Strong"` +6. `pg.security.passwordValidate({password: "123456"})` β†’ verify `strength < 20`, `checks.notCommon: false` +7. `pg.security.maskData({value: "user@example.com", type: "email"})` β†’ verify masked output preserves domain, local part masked +8. `pg.security.maskData({value: "4111111111111111", type: "credit_card"})` β†’ verify first/last 4 preserved +9. `pg.security.maskData({value: "555-12-3456", type: "ssn"})` β†’ verify last 4 digits preserved +10. `pg.security.maskData({value: "sensitive-data", type: "partial", keepFirst: 3, keepLast: 2})` β†’ verify `sen*********ta` +11. `pg.security.userPrivileges()` β†’ verify `{success: true, users: [...], count: N}` +12. `pg.security.userPrivileges({user: "postgres"})` β†’ verify single role with `isSuperuser: true` +13. `pg.security.userPrivileges({summary: true})` β†’ verify condensed output with `grantCount` +14. `pg.security.sensitiveTables()` β†’ verify returns matches with default patterns +15. `pg.security.sensitiveTables({schema: "public", patterns: ["email", "password"]})` β†’ verify custom patterns +16. `pg.security.firewallStatus()` β†’ verify `{success: true}` with HBA data or structured error +17. `pg.security.firewallRules()` β†’ verify `{success: true, rules: [...]}` or structured error + +18. πŸ”΄ `pg.security.passwordValidate({})` β†’ `{success: false, error: "..."}` (missing password) +19. πŸ”΄ `pg.security.maskData({})` β†’ `{success: false, error: "..."}` (missing value and type) +20. πŸ”΄ `pg.security.maskData({value: "test", type: "invalid_type"})` β†’ `{success: false, error: "..."}` handler error +21. πŸ”΄ `pg.security.userPrivileges({user: "nonexistent_role_xyz"})` β†’ `{success: false, error: "Role 'nonexistent_role_xyz' does not exist."}` +22. πŸ”΄ `pg.security.sensitiveTables({schema: "fake_schema_xyz"})` β†’ `{success: false, error: "Schema 'fake_schema_xyz' does not exist..."}` +23. πŸ”΄ `pg.security.firewallRules({type: 999})` β†’ `{success: false, error: "..."}` (Zod type mismatch) diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-stats-part1.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-stats-part1.md index 733a36e8..dcf65953 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-stats-part1.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-stats-part1.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -244,42 +244,28 @@ stats Group (19 tools +1 for code mode) **Test data:** Uses `test_measurements` (500 rows, sensor_id 1-6, columns: temperature, humidity, pressure, measured_at). -**Original 8 tools β€” Checklist:** +**Checklist:** 1. `pg_stats_descriptive({table: "test_measurements", column: "temperature"})` β†’ verify `mean`, `stddev`, `min`, `max` present 2. `pg_stats_percentiles({table: "test_measurements", column: "temperature", percentiles: [0.25, 0.5, 0.75]})` β†’ verify 3 percentile values 3. `pg_stats_correlation({table: "test_measurements", column1: "temperature", column2: "humidity"})` β†’ verify correlation value between -1 and 1 -4. `pg_stats_distribution({table: "test_measurements", column: "temperature", buckets: 10})` β†’ verify `buckets` array with 10 entries +4. `pg_stats_regression()` β†’ verify happy path expected behavior 5. `pg_stats_time_series({table: "test_measurements", timeColumn: "measured_at", valueColumn: "temperature", interval: "day"})` β†’ verify time series data returned -6. `pg_stats_sampling({table: "test_measurements", sampleSize: 10})` β†’ verify exactly 10 rows returned -7. `pg_stats_sampling({table: "test_measurements", method: "bernoulli", percentage: 10})` β†’ verify sample returned with `method: "bernoulli"` -8. `pg_stats_hypothesis({table: "test_measurements", column: "temperature", hypothesizedMean: 27})` β†’ verify `results.pValue` present - -**Window function tools:** - +6. `pg_stats_distribution({table: "test_measurements", column: "temperature", buckets: 10})` β†’ verify `buckets` array with 10 entries +7. `pg_stats_hypothesis({table: "test_measurements", column: "temperature", hypothesizedMean: 27})` β†’ verify `results.pValue` present +8. `pg_stats_sampling({table: "test_measurements", sampleSize: 10})` β†’ verify exactly 10 rows returned 9. `pg_stats_row_number({table: "test_measurements", column: "temperature", orderBy: "measured_at", limit: 5})` β†’ verify 5 rows returned, each with `row_number` field (1-5) -10. `pg_stats_row_number({table: "test_measurements", column: "temperature", orderBy: "measured_at", partitionBy: "sensor_id", limit: 10})` β†’ verify `row_number` resets per sensor_id partition -11. `pg_stats_rank({table: "test_measurements", column: "temperature", orderBy: "temperature", limit: 5})` β†’ verify rows with `rank` field -12. `pg_stats_rank({table: "test_measurements", column: "temperature", orderBy: "temperature", method: "dense_rank", limit: 5})` β†’ verify `dense_rank` β€” no gaps in ranking - -**Outlier detection and analysis tools:** - -**Domain error paths (πŸ”΄):** - -27. πŸ”΄ `pg_stats_descriptive({table: "nonexistent_xyz", column: "x"})` β†’ `{success: false, error: "..."}` handler error -28. πŸ”΄ `pg_stats_percentiles({})` β†’ `{success: false, error: "..."}` (Zod validation) -29. πŸ”΄ `pg_stats_row_number({})` β†’ `{success: false, error: "..."}` (Zod validation β€” missing required `table`, `column`, `orderBy`) - -**Wrong-type numeric param coercion (πŸ”΄):** - -32. πŸ”΄ `pg_stats_sampling({table: "test_measurements", sampleSize: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `sampleSize` (wrong-type numeric param) -33. πŸ”΄ `pg_stats_distribution({table: "test_measurements", column: "temperature", buckets: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `buckets` (wrong-type numeric param) - -**Code mode parity:** - -35. `pg_execute_code({code: "return await pg.stats.help()"})` β†’ verify lists all 19 stats methods including `rowNumber`, `rank`, `lagLead`, `runningTotal`, `movingAvg`, `ntile`, `outliers`, `topN`, `distinct`, `frequency`, `summary` -36. `pg_execute_code({code: "return await pg.stats.outliers({table: 'test_measurements', column: 'temperature'})"})` β†’ verify returns same structure as item 19 -37. `pg_execute_code({code: "return await pg.stats.distinct({table: 'test_measurements', column: 'sensor_id'})"})` β†’ verify returns same structure as item 23 - -38. `pg_stats_regression()` β†’ verify happy path expected behavior -39. πŸ”΄ `pg_stats_regression({})` β†’ verify structured P154 error response or valid defaults +10. `pg_stats_rank({table: "test_measurements", column: "temperature", orderBy: "temperature", limit: 5})` β†’ verify rows with `rank` field + +**Domain and Zod error paths (πŸ”΄):** + +11. πŸ”΄ `pg_stats_descriptive({table: "nonexistent_xyz", column: "x"})` β†’ `{success: false, error: "..."}` handler error +12. πŸ”΄ `pg_stats_percentiles({})` β†’ `{success: false, error: "..."}` (Zod validation) +13. πŸ”΄ `pg_stats_row_number({})` β†’ `{success: false, error: "..."}` (Zod validation β€” missing required `table`, `column`, `orderBy`) +14. πŸ”΄ `pg_stats_sampling({table: "test_measurements", sampleSize: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `sampleSize` (wrong-type numeric param) +15. πŸ”΄ `pg_stats_distribution({table: "test_measurements", column: "temperature", buckets: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `buckets` (wrong-type numeric param) +16. πŸ”΄ `pg_stats_correlation({})` β†’ `{success: false, error: "..."}` (Zod validation) +17. πŸ”΄ `pg_stats_regression({})` β†’ verify structured P154 error response or valid defaults +18. πŸ”΄ `pg_stats_time_series({})` β†’ `{success: false, error: "..."}` (Zod validation) +19. πŸ”΄ `pg_stats_hypothesis({})` β†’ `{success: false, error: "..."}` (Zod validation) +20. πŸ”΄ `pg_stats_rank({})` β†’ `{success: false, error: "..."}` (Zod validation) diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-stats-part2.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-stats-part2.md index 5a0d15f9..11590a9b 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-stats-part2.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-stats-part2.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -243,39 +243,40 @@ stats Group (19 tools +1 for code mode) **Test data:** Uses `test_measurements` (500 rows, sensor_id 1-6, columns: temperature, humidity, pressure, measured_at). -**Original 8 tools β€” Checklist:** - -**Window function tools:** - -13. `pg_stats_lag_lead({table: "test_measurements", column: "temperature", orderBy: "measured_at", direction: "lag", limit: 5})` β†’ verify rows with `lag_value` field; first row's `lag_value` should be null -14. `pg_stats_lag_lead({table: "test_measurements", column: "temperature", orderBy: "measured_at", direction: "lead", offset: 2, limit: 5})` β†’ verify `lead_value` with offset 2 -15. `pg_stats_running_total({table: "test_measurements", column: "temperature", orderBy: "measured_at", limit: 5})` β†’ verify rows with `running_total` field, monotonically increasing -16. `pg_stats_running_total({table: "test_measurements", column: "temperature", orderBy: "measured_at", partitionBy: "sensor_id", limit: 10})` β†’ verify `running_total` resets per sensor_id -17. `pg_stats_moving_avg({table: "test_measurements", column: "temperature", orderBy: "measured_at", windowSize: 5, limit: 5})` β†’ verify rows with `moving_avg` field -18. `pg_stats_ntile({table: "test_measurements", column: "temperature", orderBy: "temperature", buckets: 4, limit: 10})` β†’ verify rows with `ntile` field (values 1-4) - -**Outlier detection and analysis tools:** - -19. `pg_stats_outliers({table: "test_measurements", column: "temperature"})` β†’ verify `{outliers, outlierCount, method, stats}` where `method` is `"iqr"` (default) -20. `pg_stats_outliers({table: "test_measurements", column: "temperature", method: "zscore", threshold: 2})` β†’ verify same shape with `method: "zscore"` -21. `pg_stats_top_n({table: "test_measurements", column: "temperature", n: 3})` β†’ verify exactly 3 rows, descending order by default -22. `pg_stats_top_n({table: "test_measurements", column: "temperature", n: 3, direction: "asc"})` β†’ verify 3 rows in ascending order -23. `pg_stats_distinct({table: "test_measurements", column: "sensor_id"})` β†’ verify `{values, distinctCount}` with `distinctCount` of 6 (sensors 1-6) -24. `pg_stats_frequency({table: "test_measurements", column: "sensor_id"})` β†’ verify `{distribution}` array with value, count, and percentage for each sensor -25. `pg_stats_summary({table: "test_measurements"})` β†’ verify multi-column summary auto-detecting numeric columns (temperature, humidity, pressure) -26. `pg_stats_summary({table: "test_measurements", columns: ["temperature", "humidity"]})` β†’ verify summary for exactly 2 specified columns - -**Domain error paths (πŸ”΄):** - -30. πŸ”΄ `pg_stats_outliers({table: "nonexistent_xyz", column: "x"})` β†’ `{success: false, error: "..."}` handler error -31. πŸ”΄ `pg_stats_frequency({table: "test_measurements", column: "nonexistent_col_xyz"})` β†’ `{success: false, error: "..."}` handler error mentioning column - -**Wrong-type numeric param coercion (πŸ”΄):** - -34. πŸ”΄ `pg_stats_top_n({table: "test_measurements", column: "temperature", n: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `n` (wrong-type numeric param) +**Checklist:** + +1. `pg_stats_lag_lead({table: "test_measurements", column: "temperature", orderBy: "measured_at", direction: "lag", limit: 5})` β†’ verify rows with `lag_value` field; first row's `lag_value` should be null +2. `pg_stats_lag_lead({table: "test_measurements", column: "temperature", orderBy: "measured_at", direction: "lead", offset: 2, limit: 5})` β†’ verify `lead_value` with offset 2 +3. `pg_stats_running_total({table: "test_measurements", column: "temperature", orderBy: "measured_at", limit: 5})` β†’ verify rows with `running_total` field, monotonically increasing +4. `pg_stats_running_total({table: "test_measurements", column: "temperature", orderBy: "measured_at", partitionBy: "sensor_id", limit: 10})` β†’ verify `running_total` resets per sensor_id +5. `pg_stats_moving_avg({table: "test_measurements", column: "temperature", orderBy: "measured_at", windowSize: 5, limit: 5})` β†’ verify rows with `moving_avg` field +6. `pg_stats_ntile({table: "test_measurements", column: "temperature", orderBy: "temperature", buckets: 4, limit: 10})` β†’ verify rows with `ntile` field (values 1-4) +7. `pg_stats_outliers({table: "test_measurements", column: "temperature"})` β†’ verify `{outliers, outlierCount, method, stats}` where `method` is `"iqr"` (default) +8. `pg_stats_outliers({table: "test_measurements", column: "temperature", method: "zscore", threshold: 2})` β†’ verify same shape with `method: "zscore"` +9. `pg_stats_top_n({table: "test_measurements", column: "temperature", n: 3})` β†’ verify exactly 3 rows, descending order by default +10. `pg_stats_top_n({table: "test_measurements", column: "temperature", n: 3, direction: "asc"})` β†’ verify 3 rows in ascending order +11. `pg_stats_distinct({table: "test_measurements", column: "sensor_id"})` β†’ verify `{values, distinctCount}` with `distinctCount` of 6 (sensors 1-6) +12. `pg_stats_frequency({table: "test_measurements", column: "sensor_id"})` β†’ verify `{distribution}` array with value, count, and percentage for each sensor +13. `pg_stats_summary({table: "test_measurements"})` β†’ verify multi-column summary auto-detecting numeric columns (temperature, humidity, pressure) +14. `pg_stats_summary({table: "test_measurements", columns: ["temperature", "humidity"]})` β†’ verify summary for exactly 2 specified columns + +**Domain and Zod error paths (πŸ”΄):** + +15. πŸ”΄ `pg_stats_outliers({table: "nonexistent_xyz", column: "x"})` β†’ `{success: false, error: "..."}` handler error +16. πŸ”΄ `pg_stats_frequency({table: "test_measurements", column: "nonexistent_col_xyz"})` β†’ `{success: false, error: "..."}` handler error mentioning column +17. πŸ”΄ `pg_stats_top_n({table: "test_measurements", column: "temperature", n: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `n` (wrong-type numeric param) +18. πŸ”΄ `pg_stats_lag_lead({})` β†’ `{success: false, error: "..."}` (Zod validation) +19. πŸ”΄ `pg_stats_running_total({})` β†’ `{success: false, error: "..."}` (Zod validation) +20. πŸ”΄ `pg_stats_moving_avg({})` β†’ `{success: false, error: "..."}` (Zod validation) +21. πŸ”΄ `pg_stats_ntile({})` β†’ `{success: false, error: "..."}` (Zod validation) +22. πŸ”΄ `pg_stats_outliers({})` β†’ `{success: false, error: "..."}` (Zod validation) +23. πŸ”΄ `pg_stats_top_n({})` β†’ `{success: false, error: "..."}` (Zod validation) +24. πŸ”΄ `pg_stats_distinct({})` β†’ `{success: false, error: "..."}` (Zod validation) +25. πŸ”΄ `pg_stats_frequency({})` β†’ `{success: false, error: "..."}` (Zod validation) +26. πŸ”΄ `pg_stats_summary({})` β†’ `{success: false, error: "..."}` (Zod validation) **Code mode parity:** -35. `pg_execute_code({code: "return await pg.stats.help()"})` β†’ verify lists all 19 stats methods including `rowNumber`, `rank`, `lagLead`, `runningTotal`, `movingAvg`, `ntile`, `outliers`, `topN`, `distinct`, `frequency`, `summary` -36. `pg_execute_code({code: "return await pg.stats.outliers({table: 'test_measurements', column: 'temperature'})"})` β†’ verify returns same structure as item 19 -37. `pg_execute_code({code: "return await pg.stats.distinct({table: 'test_measurements', column: 'sensor_id'})"})` β†’ verify returns same structure as item 23 +27. `pg_execute_code({code: "return await pg.stats.help()"})` β†’ verify lists all 19 stats methods including `rowNumber`, `rank`, `lagLead`, `runningTotal`, `movingAvg`, `ntile`, `outliers`, `topN`, `distinct`, `frequency`, `summary` +28. `pg_execute_code({code: "return await pg.stats.outliers({table: 'test_measurements', column: 'temperature'})"})` β†’ verify returns same structure as item 7 +29. `pg_execute_code({code: "return await pg.stats.distinct({table: 'test_measurements', column: 'sensor_id'})"})` β†’ verify returns same structure as item 11 diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-text.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-text.md index f4ba44c9..d9c31542 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-text.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-text.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-transactions.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-transactions.md index 8aab0699..df08b994 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-transactions.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-transactions.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-vector-part1.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-vector-part1.md index 69160ad4..8b8ada8b 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-vector-part1.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-vector-part1.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -242,19 +242,25 @@ vector Tool Group (16 tools +1 for code mode) **Test data:** Uses `test_embeddings` with 384-dimension vectors (50 rows, 5 categories: tech, science, business, sports, entertainment). HNSW index on `embedding` column using cosine distance. -**Checklist** (Use Code Mode for vector operations to avoid truncation): - -1. Via code mode: read first embedding from `test_embeddings`, then search with it β†’ verify results returned with distances -2. `pg_vector_distance({vector1: [1,0,0], vector2: [0,1,0], metric: "cosine"})` β†’ verify distance returned -3. `pg_vector_normalize({vector: [3, 4]})` β†’ `{normalized: [0.6, 0.8], magnitude: 5}` -4. πŸ”΄ `pg_vector_search({table: "nonexistent_xyz", column: "v", vector: [1,0,0]})` β†’ `{success: false, error: "..."}` handler error -5. πŸ”΄ `pg_vector_search({table: "test_embeddings", column: "embedding", vector: [1,0,0], limit: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `limit` (wrong-type numeric param) - -6. `pg_vector_insert()` β†’ verify happy path expected behavior -7. πŸ”΄ `pg_vector_insert({})` β†’ verify structured P154 error response or valid defaults -8. `pg_vector_create_extension()` β†’ verify happy path expected behavior -9. πŸ”΄ `pg_vector_create_extension({})` β†’ verify structured P154 error response or valid defaults -10. `pg_vector_add_column()` β†’ verify happy path expected behavior -11. πŸ”΄ `pg_vector_add_column({})` β†’ verify structured P154 error response or valid defaults -12. `pg_vector_create_index()` β†’ verify happy path expected behavior -13. πŸ”΄ `pg_vector_create_index({})` β†’ verify structured P154 error response or valid defaults +**Checklist:** + +1. `pg_vector_create_extension()` β†’ verify happy path expected behavior +2. `pg_vector_add_column()` β†’ verify happy path expected behavior +3. `pg_vector_insert()` β†’ verify happy path expected behavior +4. `pg_vector_batch_insert()` β†’ verify happy path expected behavior +5. `pg_vector_search()` via code mode: read first embedding from `test_embeddings`, then search with it β†’ verify results returned with distances +6. `pg_vector_create_index()` β†’ verify happy path expected behavior +7. `pg_vector_distance({vector1: [1,0,0], vector2: [0,1,0], metric: "cosine"})` β†’ verify distance returned +8. `pg_vector_normalize({vector: [3, 4]})` β†’ `{normalized: [0.6, 0.8], magnitude: 5}` + +**Domain and Zod error paths (πŸ”΄):** + +9. πŸ”΄ `pg_vector_search({table: "nonexistent_xyz", column: "v", vector: [1,0,0]})` β†’ `{success: false, error: "..."}` handler error +10. πŸ”΄ `pg_vector_search({table: "test_embeddings", column: "embedding", vector: [1,0,0], limit: "abc"})` β†’ must NOT return raw MCP `-32602` error β€” should return handler error or silently default `limit` (wrong-type numeric param) +11. πŸ”΄ `pg_vector_insert({})` β†’ verify structured P154 error response or valid defaults +12. πŸ”΄ `pg_vector_batch_insert({})` β†’ verify structured P154 error response or valid defaults +13. πŸ”΄ `pg_vector_create_extension({})` β†’ verify structured P154 error response or valid defaults +14. πŸ”΄ `pg_vector_add_column({})` β†’ verify structured P154 error response or valid defaults +15. πŸ”΄ `pg_vector_create_index({})` β†’ verify structured P154 error response or valid defaults +16. πŸ”΄ `pg_vector_distance({})` β†’ verify structured P154 error response or valid defaults +17. πŸ”΄ `pg_vector_normalize({})` β†’ verify structured P154 error response or valid defaults diff --git a/test-server/test-tool-groups-codemode/test-tool-group-codemode-vector-part2.md b/test-server/test-tool-groups-codemode/test-tool-group-codemode-vector-part2.md index e058694d..2b91f1cc 100644 --- a/test-server/test-tool-groups-codemode/test-tool-group-codemode-vector-part2.md +++ b/test-server/test-tool-groups-codemode/test-tool-group-codemode-vector-part2.md @@ -51,7 +51,7 @@ Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_ 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Code Mode Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md` in C:\Users\chris\Desktop\postgres-mcp\tmp. For EVERY tool in the group, you must explicitly log: Code Mode (Happy Path) and Code Mode (Domain Error). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -244,21 +244,23 @@ vector Tool Group (16 tools +1 for code mode) **Checklist** (Use Code Mode for vector operations to avoid truncation): -1. Via code mode: read first embedding from `test_embeddings`, then search with it β†’ verify results returned with distances +1. `pg_vector_aggregate({table: "test_embeddings", column: "embedding"})` β†’ verify `{average_vector, count: 50}` 2. `pg_vector_validate({vector: [1.0, 2.0, 3.0]})` β†’ `{valid: true, vectorDimensions: 3}` 3. `pg_vector_validate({vector: []})` β†’ `{valid: true, vectorDimensions: 0}` -4. `pg_vector_aggregate({table: "test_embeddings", column: "embedding"})` β†’ verify `{average_vector, count: 50}` -5. πŸ”΄ `pg_vector_validate({})` β†’ `{success: false, error: "..."}` (Zod validation β€” missing required `vector`) - -6. `pg_vector_cluster()` β†’ verify happy path expected behavior -7. πŸ”΄ `pg_vector_cluster({})` β†’ verify structured P154 error response or valid defaults -8. `pg_vector_index_optimize()` β†’ verify happy path expected behavior -9. πŸ”΄ `pg_vector_index_optimize({})` β†’ verify structured P154 error response or valid defaults -10. `pg_vector_dimension_reduce()` β†’ verify happy path expected behavior -11. πŸ”΄ `pg_vector_dimension_reduce({})` β†’ verify structured P154 error response or valid defaults -12. `pg_vector_embed()` β†’ verify happy path expected behavior -13. πŸ”΄ `pg_vector_embed({})` β†’ verify structured P154 error response or valid defaults -14. `pg_hybrid_search()` β†’ verify happy path expected behavior -15. πŸ”΄ `pg_hybrid_search({})` β†’ verify structured P154 error response or valid defaults -16. `pg_vector_performance()` β†’ verify happy path expected behavior -17. πŸ”΄ `pg_vector_performance({})` β†’ verify structured P154 error response or valid defaults +4. `pg_vector_cluster()` β†’ verify happy path expected behavior +5. `pg_vector_index_optimize()` β†’ verify happy path expected behavior +6. `pg_hybrid_search()` β†’ verify happy path expected behavior +7. `pg_vector_performance()` β†’ verify happy path expected behavior +8. `pg_vector_dimension_reduce()` β†’ verify happy path expected behavior +9. `pg_vector_embed()` β†’ verify happy path expected behavior + +**Domain and Zod error paths (πŸ”΄):** + +10. πŸ”΄ `pg_vector_validate({})` β†’ `{success: false, error: "..."}` (Zod validation β€” missing required `vector`) +11. πŸ”΄ `pg_vector_aggregate({})` β†’ verify structured P154 error response or valid defaults +12. πŸ”΄ `pg_vector_cluster({})` β†’ verify structured P154 error response or valid defaults +13. πŸ”΄ `pg_vector_index_optimize({})` β†’ verify structured P154 error response or valid defaults +14. πŸ”΄ `pg_hybrid_search({})` β†’ verify structured P154 error response or valid defaults +15. πŸ”΄ `pg_vector_performance({})` β†’ verify structured P154 error response or valid defaults +16. πŸ”΄ `pg_vector_dimension_reduce({})` β†’ verify structured P154 error response or valid defaults +17. πŸ”΄ `pg_vector_embed({})` β†’ verify structured P154 error response or valid defaults diff --git a/test-server/test-tool-groups/README.md b/test-server/test-tool-groups/README.md index e6abd763..ede41be2 100644 --- a/test-server/test-tool-groups/README.md +++ b/test-server/test-tool-groups/README.md @@ -1,6 +1,6 @@ # Postgres-MCP Standard Testing Suite -**Directory Purpose**: This folder contains 27 self-contained, modular test prompts covering every tool group in `postgres-mcp`. Unlike the `test-tool-groups-codemode.md` directory, these prompts are strictly designed for **Direct MCP Tool Call validation**. +**Directory Purpose**: This folder contains 30 self-contained, modular test prompts covering every tool group in `postgres-mcp`. Unlike the `test-tool-groups-codemode.md` directory, these prompts are strictly designed for **Direct MCP Tool Call validation**. ## Agent Instructions diff --git a/test-server/test-tool-groups/test-results.md b/test-server/test-tool-groups/test-results.md deleted file mode 100644 index 75fde8c3..00000000 --- a/test-server/test-tool-groups/test-results.md +++ /dev/null @@ -1,44 +0,0 @@ -# Token Consumption during Direct Tool Testing of postgres-mcp - -Last tested: April 4th, 2026 - -| Test Document | Approximate Token Usage | Notes | -| :------------------------------------- | :---------------------- | :---- | -| `test-tool-group-admin.md` | ~3,405 | | -| `test-tool-group-backup.md` | ~6,132 | | -| `test-tool-group-citext.md` | ~5,369 | | -| `test-tool-group-core-part1.md` | ~7,737 | | -| `test-tool-group-core-part2.md` | ~4,322 | | -| `test-tool-group-cron.md` | ~3,309 | | -| `test-tool-group-introspection.md` | ~20,513 | | -| `test-tool-group-jsonb-part1.md` | ~8,482 | | -| `test-tool-group-jsonb-part2.md` | ~2,845 | | -| `test-tool-group-kcache.md` | ~4,386 | | -| `test-tool-group-ltree.md` | ~4,441 | | -| `test-tool-group-migration.md` | ~4,609 | -| `test-tool-group-monitoring.md` | ~5,380 | | -| `test-tool-group-partitioning.md` | ~3,365 | | -| `test-tool-group-partman.md` | ~4,174 | | -| `test-tool-group-performance-part1.md` | ~8,938 | | -| `test-tool-group-performance-part2.md` | ~11,189 | | -| `test-tool-group-pgcrypto.md` | ~2,876 | | -| `test-tool-group-postgis-part1.md` | ~5,119 | | -| `test-tool-group-postgis-part2.md` | ~5,072 | | -| `test-tool-group-schema.md` | ~5,506 | | -| `test-tool-group-stats-part1.md` | ~8,824 | | -| `test-tool-group-stats-part2.md` | ~9,835 | | -| `test-tool-group-text.md` | ~5,377 | | -| `test-tool-group-transactions.md` | ~3,240 | | -| `test-tool-group-vector-part1.md` | ~2,678 | | -| `test-tool-group-vector-part2.md` | ~6,552 | | -| **Total Estimated Tokens** | **~163,675** | | - -**Safe to test in pairs** -jsonb + vector -postgis + ltree -pgcrypto + citext -text + cron -partman + partitioning -stats + backup - -**Token counts don't include tokens used by the testing prompts themselves.** diff --git a/test-server/test-tool-groups/test-tool-group-admin.md b/test-server/test-tool-groups/test-tool-group-admin.md index 4955f8a8..73879d27 100644 --- a/test-server/test-tool-groups/test-tool-group-admin.md +++ b/test-server/test-tool-groups/test-tool-group-admin.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-backup.md b/test-server/test-tool-groups/test-tool-group-backup.md index 965d1c39..bea12842 100644 --- a/test-server/test-tool-groups/test-tool-group-backup.md +++ b/test-server/test-tool-groups/test-tool-group-backup.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-citext.md b/test-server/test-tool-groups/test-tool-group-citext.md index 98471fa1..1c0b3e37 100644 --- a/test-server/test-tool-groups/test-tool-group-citext.md +++ b/test-server/test-tool-groups/test-tool-group-citext.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-core-part1.md b/test-server/test-tool-groups/test-tool-group-core-part1.md index f569ff0c..17cc1d6a 100644 --- a/test-server/test-tool-groups/test-tool-group-core-part1.md +++ b/test-server/test-tool-groups/test-tool-group-core-part1.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-core-part2.md b/test-server/test-tool-groups/test-tool-group-core-part2.md index 733cf3d4..88688eab 100644 --- a/test-server/test-tool-groups/test-tool-group-core-part2.md +++ b/test-server/test-tool-groups/test-tool-group-core-part2.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-cron.md b/test-server/test-tool-groups/test-tool-group-cron.md index 770d753f..6932f0d9 100644 --- a/test-server/test-tool-groups/test-tool-group-cron.md +++ b/test-server/test-tool-groups/test-tool-group-cron.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-docstore.md b/test-server/test-tool-groups/test-tool-group-docstore.md new file mode 100644 index 00000000..0b2f8469 --- /dev/null +++ b/test-server/test-tool-groups/test-tool-group-docstore.md @@ -0,0 +1,271 @@ +# postgres-mcp Tool Group Re-Testing: [docstore] + +**ESSENTIAL INSTRUCTIONS** + +- Execute **EVERY** numbered stress test below using direct MCP tool calls, **NOT** codemode. +- Do not use scripts or terminal to replace planned tests. +- Do not modify or skip tests. +- Do not put temp files in root; Use C:\Users\chris\Desktop\postgres-mcp\tmp + +## Reporting Format + +- ❌ Fail: Tool errors or produces incorrect results (include error message) +- ⚠️ Issue: Unexpected behavior or improvement opportunity +- πŸ“¦ Payload: Unnecessarily large response that should be optimized β€” **blocking, equally important as ❌ bugs**. Oversized payloads waste LLM context window tokens and degrade downstream tool-calling quality. **You MUST monitor `_meta.tokenEstimate` for every operation**. Report the response size in tokens/KB and suggest a concrete optimization (e.g., filter system tables, add `compact` option, omit empty arrays). + +> **Token estimates**: Every tool response includes `_meta.tokenEstimate` in its `content[].text` payload (approximate token count based on ~4 bytes/token). Code Mode responses include `metrics.tokenEstimate` instead. These are injected automatically by the adapter β€” no per-tool assertions needed, but report as ⚠️ if absent. +> **Code Mode Token Tracking**: For at least one `pg_execute_code` test, explicitly verify that `metrics.tokenEstimate` is present in the response and is a number greater than 0, reporting as ❌ if it is missing or zero. + +## Test Database Schema + +The test database (`postgres`) contains these tables: + +| Table | Rows | Key Columns | JSONB Columns | Tool Groups | +| ------------------- | ---- | ---------------------------------------------------------------------------------- | ------------------------ | --------------------- | +| `test_products` | 15 | id, name, description, price, created_at | β€” | Core, Stats | +| `test_orders` | 20 | id, product_id (FK), quantity, total_price, status | β€” | Core, Stats, Trans | +| `test_jsonb_docs` | 3 | id | metadata, settings, tags | JSONB (20 tools) | +| `test_articles` | 3 | id, title, body, search_vector (TSVECTOR) | β€” | Text | +| `test_measurements` | 640 | id, sensor_id (INT 1-6), temperature, humidity, pressure | β€” | Stats (19 tools) | +| `test_embeddings` | 75 | id, content, category, embedding (vector 384d) | β€” | Vector (16 tools) | +| `test_locations` | 25 | id, name, location (GEOMETRY POINT SRID 4326) | β€” | PostGIS (15 tools) | +| `test_users` | 3 | id, username (CITEXT), email (CITEXT) | β€” | Citext (6 tools) | +| `test_categories` | 6 | id, name, path (LTREE) | β€” | Ltree (8 tools) | +| `test_secure_data` | 0 | id, user_id, sensitive_data (BYTEA), created_at | β€” | pgcrypto (9 tools) | +| `test_events` | 100 | id, event_type, event_date, payload (JSONB) β€” PARTITION BY RANGE | payload | Partitioning, Partman | +| `test_logs` | 0 | id, log_level, message, created_at β€” PARTITION BY RANGE | β€” | Partman | +| `test_departments` | 3 | id, name, budget | β€” | Introspection | +| `test_employees` | 5 | id, name, department_id (FK CASCADE), manager_id (FK self-ref SET NULL), hire_date | β€” | Introspection | +| `test_projects` | 2 | id, name, lead_id (FK SET NULL), department_id (FK RESTRICT) | β€” | Introspection | +| `test_assignments` | 3 | id, employee_id (FK CASCADE), project_id (FK CASCADE), role β€” UNIQUE(emp,proj) | β€” | Introspection | +| `test_audit_log` | 3 | entry_id (no PK!), employee_id (FK, no index!), action, created_at | β€” | Introspection | +| `test_documents` | 5 | \_id (TEXT PK), doc (JSONB) | doc | Docstore (9 tools) | + +Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_order_summary` (view), `test_get_order_count()` (function). + +> **Note:** Row counts reflect the post-seed state after both `test-database.sql` and `test-resources.sql` run. The resource seed adds ~200 measurements (minus deletions by `id % 5 = 0 AND id > 400`), 25 embeddings (IDs 51-75), and 20 locations (IDs 6-25). +> Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_locations_geo` (GIST), `idx_categories_path` (GIST), HNSW on `test_embeddings.embedding`. + +## Testing Requirements + +1. Use existing `test_*` tables for read operations (SELECT, COUNT, EXISTS, etc.) +2. Create temporary tables with `temp_` prefix for write operations (CREATE, INSERT, DROP, etc.) +3. Test each tool with realistic inputs based on the schema above +4. Clean up any `temp_*` tables after testing +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. +6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal +7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{"success": false, "error": "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. +8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. +9. **No Scripted Loops**: You must test each error path by writing an individual, distinct tool call. +10. **Pacing**: Test a maximum of 3-5 tools at a time. Report the results, update your matrix, and then move on to the next chunk. +11. **Deterministic checklist first**: Complete ALL items in the Deterministic Checklist below before moving to the Strict Coverage Matrix exploration. The checklist uses exact inputs and expected outputs to ensure reproducible coverage every run. +12. **Audit backup tools**: The 3 `pg_audit_*` tools require `--audit-backup` to be enabled on the test server. When enabled, destructive operations (`pg_truncate`, `pg_drop_table`, `pg_vacuum`, etc.) create gzip-compressed `.snapshot.json.gz` files alongside the audit log. **V2 features to verify**: `pg_audit_diff_backup` now returns a `volumeDrift` field (row count + size changes); `pg_audit_restore_backup` supports `restoreAs` for side-by-side non-destructive restore; and Code Mode calls through `pg_execute_code` that trigger destructive operations are also captured by the interceptor. When disabled, all 3 tools return `{success: false, error: "Audit backup not enabled"}`. + +Note: The isError flag propagation issue has been fixed. P154 structured errors (`{success: false, error: "..."}`) now return as parseable JSON objects via direct tool calls β€” not as raw MCP error strings. During error path testing, verify this: if a direct tool call for a nonexistent schema/table returns a raw error string instead of a JSON object with `success` and `error` fields, report it as ❌. + +## Structured Error Response Pattern + +All tools must return errors as structured objects instead of throwing. A thrown error propagates as a raw MCP error, which is unhelpful to clients. The expected pattern: + +```json +{ + "success": false, + "error": "Human-readable error message", + "code": "QUERY_ERROR", + "category": "query", + "recoverable": false +} +``` + +The enriched `ErrorResponse` from `formatHandlerError` always includes `success`, `error`, `code`, `category`, and `recoverable`. Optional fields `suggestion` and `details` may also be present. Some tools include additional context fields (e.g., `pg_transaction_execute` includes `statementsExecuted`, `failedStatement`, `autoRolledBack`). These are acceptable as long as `success: false` and `error` are always present. + +### Handler Error vs MCP Error β€” How to Distinguish + +There are two kinds of error responses. Only one is correct: + +| Type | Source | What you see | Verdict | +| -------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------ | +| **Handler error** βœ… | Handler catches error and returns `{success: false, error: "..."}` | Parseable JSON object with `success` and `error` fields | Correct | +| **MCP error** ❌ | Uncaught throw propagates to MCP framework | Raw text error string, often prefixed with `Error:`, wrapped in an `isError: true` content block β€” no `success` field | Bug β€” report as ❌ | + +**Concrete examples:** + +``` +βœ… Handler error (correct): +{"success": false, "error": "Table \"public.nonexistent\" does not exist"} + +❌ MCP error (bug β€” handler threw instead of catching): +content: [{type: "text", text: "Error: relation \"nonexistent\" does not exist"}] +isError: true +``` + +The MCP error case means the handler is missing a `try/catch` block. When testing, if you see a raw error string (especially one containing PostgreSQL internal messages like `relation "..." does not exist` without a `success` field), report it as ❌. + +### Zod Validation Errors + +Calling a tool with wrong parameter types or missing required fields triggers a Zod validation error. If the handler has no outer `try/catch`, this surfaces as a raw MCP error. Test every tool with `{}` (empty params) if it has required parameters β€” the response must be a handler error, not an MCP error. + +**Error message format matters:** Zod `.refine()` failures produce a `ZodError` whose `.message` property is a **raw JSON array** of Zod issues (e.g., `[{"code":"custom","message":"..."}]`). If the handler catches the error with `error.message` instead of routing through `formatHandlerError`, this raw JSON leaks as the error string. All handlers must route through `formatHandlerError`, which duck-types the `.issues` array and produces clean `Validation error: name (or table alias) is required; Validation error: columns must not be empty` messages. If you see a raw JSON array in an error message, report it as ❌. + +**Zod refinement leak pattern:** The Split Schema pattern uses `.partial()` on input schemas so the SDK accepts `{}`. But `.partial()` only makes keys **optional** β€” it does NOT strip refinements like `.min(1)`, `.max(90)`, or `.min(-90).max(90)`. This applies to **ALL types** β€” strings, arrays, AND numbers: + +- `z.string().min(1)` + empty `""` β†’ SDK rejects with raw MCP `-32602` +- `z.array().min(1)` + empty `[]` β†’ SDK rejects with raw MCP `-32602` +- `z.number().min(-90).max(90)` + value `91` β†’ SDK rejects with raw MCP `-32602` + +**Fix:** Remove ALL `.min(N)` / `.max(N)` refinements from the schema and validate inside the handler instead. Optional fields with `.default()` are safe because the default satisfies the constraint. + +**Required enum coercion pattern:** For **optional** enum params with defaults, `z.preprocess(coercer, z.enum([...]).optional().default(...))` works β€” the coercer returns `undefined` for invalid values β†’ the `.default()` kicks in. For **required** enum params (no `.optional().default(...)`), this pattern **fails**: the SDK's `.partial()` wraps the preprocess in `.optional()`, but the inner `z.enum()` still rejects `undefined` β†’ raw MCP `-32602`. **Fix:** Use `z.string()` in the schema and validate the enum inside the handler's `try/catch`, returning a structured error. + +**What to report:** + +- If a tool call returns a raw MCP error (no JSON body with `success` field), report it as ❌ with the tool name and the raw error message +- If a tool returns `{success: false, error: "..."}` but the error string is a raw Zod JSON array (starts with `[{`), report as ❌ (handler uses `error.message` instead of `formatHandlerError`) +- If a tool returns `{success: false, error: "Validation error: ..."}` with clean human-readable text, that is the correct behavior β€” do not report it as a failure +- If a tool returns a successful response for an obviously invalid input (e.g., nonexistent table returns `{success: true}`), report it as ⚠️ + +## Split Schema Pattern Verification + +All tools use the Split Schema pattern: a plain `z.object()` Base schema for MCP parameter visibility (used as `inputSchema`), and handler-side parsing via `z.preprocess()`, `.default({})`, or direct `.parse()` inside `try/catch`. Verify: + +1. **JSON Schema visibility**: Before testing tool behavior, call `tools/list` (or inspect the MCP server's tool definitions) and confirm each tool's `inputSchema` exposes its parameters. Tools with optional parameters (e.g., `schema`, `limit`, `direction`) must show non-empty `properties` in the JSON Schema. If a tool's `inputSchema` is empty or missing `properties`, report as a Split Schema violation. +2. **Parameter visibility**: For tools with optional parameters (e.g., `schema`, `limit`), make a direct MCP call using those parameters. If the tool ignores or rejects documented parameters, report as a Split Schema violation. +3. **Alias acceptance**: For tools with documented parameter aliases (e.g., table/tableName/name, sql/query), verify that direct MCP tool calls correctly accept the aliasesβ€”not just the primary parameter name. If a direct call using only an alias fails with a validation error like "X is required", report it as a Split Schema violation requiring a fix. +4. **`z.preprocess()` as `inputSchema`**: If a tool uses `z.preprocess()` directly as its `inputSchema` (instead of a plain `SchemaBase`), parameter metadata is stripped from JSON Schema generation, making direct MCP calls unable to see or use those parameters. Report as a Split Schema violation. + +## P154 Object Existence Verification + +All tools should return structured error responses for nonexistent tables/schemas (via `formatHandlerError`). The 5 core convenience tools (pg_count, pg_exists, pg_upsert, pg_batch_insert, pg_truncate) implement explicit pre-checks and serve as canonical verification targets. Beyond those, **every tool group must have at least one nonexistent-table test in its checklist** β€” see the error-path items (marked πŸ”΄) in each group's checklist in `test-group-tools.md`. + +For each P154 test, verify that calling with a nonexistent table (e.g., `table: "nonexistent_table_xyz"`) returns a handler error like `{success: false, error: "Table \"public.nonexistent_table_xyz\" does not exist"}` rather than a raw MCP error. Also verify that a nonexistent schema (e.g., `table: "fake_schema.users"`) produces a similarly clear handler error. + +Key PostgreSQL error codes that should be intercepted by `formatHandlerError` (not leaked as raw errors): + +| PG Error Code | Meaning | Expected Structured Message | +| ------------- | ------------------- | --------------------------------- | +| 42P01 | Undefined table | `Table "X" does not exist` | +| 42P06 | Duplicate schema | `Schema "X" already exists` | +| 42P07 | Duplicate table | `Table "X" already exists` | +| 42701 | Duplicate column | `Column "X" already exists` | +| 42703 | Undefined column | `Column "X" does not exist` | +| 23505 | Unique violation | `Duplicate key: ...` | +| 23503 | FK violation | `Foreign key constraint violated` | +| 42601 | Syntax error | `SQL syntax error: ...` | +| 3F000 | Invalid schema name | `Schema "X" does not exist` | +| XX000 | Internal error | `Internal error: ...` | + +## Error Consistency Audit + +During testing, check for these inconsistencies across tool groups: + +1. **Throw-vs-return**: If a tool throws a raw error instead of returning `{success: false}`, report as ❌. Document which tool groups have the worst raw-error leakage. +2. **Error field name**: All `{ success: false }` error responses should use `error` as the field name. If a tool uses a different field name for error context in a failure response, report as ⚠️. +3. **Zod validation leaks**: If calling a tool with an invalid enum value or missing required field produces a raw MCP `-32602` Zod validation error instead of a structured response, report as ❌. This indicates the Zod schema is rejecting the input at the MCP framework level before the handler's `try/catch` can intercept. +4. **Missing `formatHandlerError` wrapping**: postgres-mcp has a centralized `formatHandlerError` helper. If a handler catches errors but returns ad-hoc messages instead of using the centralized formatter, report which handler and the ad-hoc message pattern. +5. **Orphaned output schemas**: If a schema is exported from `src/adapters/postgresql/schemas/` but the corresponding tool definition does not reference it via `outputSchema`, report as ⚠️. Use `grep_search` to check whether the schema name appears in any tool file. Defined-but-unwired schemas provide zero enforcement. +6. **Inline output schemas**: If any tool defines `outputSchema: z.object({...})` inline in the handler file instead of importing a named schema from the `schemas/` directory, report as ⚠️. All output schemas must live in the appropriate `schemas/` directory with named exports. + +## Error Path Testing Checklist + +For each tool group under test, verify at least one scenario from each applicable row: + +| Error Scenario | Tool Groups to Test | Example Input | +| --------------------------------- | ------------------------------------- | ----------------------------------------------------------------------- | +| Nonexistent table | All table-accepting tools | `table: "nonexistent_xyz"` | +| Nonexistent schema | Core, introspection, schema | `schema: "fake_schema"` or `table: "fake_schema.users"` | +| Invalid SQL syntax | Core (`read_query`, `write_query`) | `sql: "SELECTT * FROM"` | +| Invalid column name | Stats, JSONB, text, vector, PostGIS | `column: "nonexistent_col"` | +| Duplicate table/index | Core (`create_table`, `create_index`) | Create existing table | +| Empty required array | Transactions | `statements: []` | +| Missing required field via alias | Core, transactions | `sql` alias instead of `query` | +| **Zod validation (empty params)** | **Every tool with required params** | `{}` (empty object β€” must return handler error, not MCP `-32602` error) | +| **Zod validation (wrong type)** | **Tools with typed params** | Pass string where number expected, etc. | + +## Cleanup Conventions + +During testing, use these naming conventions: + +- **Temporary collections**: Prefix with `temp_` (e.g., `temp_doc_test`) +- **Test views**: Prefix with `test_view_` (e.g., `test_view_order_summary`) +- **Test functions**: Prefix with `test_func_` (e.g., `test_func_calculate`) +- **Test schemas**: Prefix with `test_schema_` (e.g., `test_schema_temp`) + +After testing, clean up: + +```sql +-- List temp tables/collections +SELECT tablename FROM pg_tables +WHERE schemaname = 'public' AND tablename LIKE 'temp_%'; + +-- Drop temp collection +DROP TABLE IF EXISTS temp_doc_test; +``` + +## Post-Test Procedures + +### Reporting Rules + +- Use βœ… only in inline notes during testing; omit from Final Summary +- Do not mention what already works well or issues already documented in server-instructions.md and runtime hints + +### After Testing + +1. **Token Audit**: Before concluding, call `read_resource` on `postgres://audit` to retrieve the `sessionTokenEstimate` (total token usage) for your testing session. Include this "Total Token Usage" in your final test report and session summary. Highlight the single most expensive tool call. +2. **Cleanup**: Confirm all `temp_*` tables and temporary testing data are removed including any files created during testing. +3. **Fix EVERY finding** β€” not just ❌ Fails, but also ⚠️ Issues including behavioral improvements, missing warnings, error code consistency, inaccuracies in the files listed below, and πŸ“¦ Payload problems (responses that should be truncated or offer a `limit` param). +4. **Read `code-map.md` before making changes and make all changes consistent with other tools.** +5. **Scope of fixes** includes corrections to any of: + - Handler code + - `server-instructions.md` + - Test database (`test-database.sql`) + - This prompt +6. **User will handle validation** +7. Update the changelog if there were any changes made (being careful not to create duplicate headers), and commit without pushing. +8. Create a /session-summary in memory-journal-mcp for the issues and their fixes, explicitly including the "Total Token Usage" captured. +9. Stop and briefly summarize the testing results and fixes, ensuring the total token count is prominently displayed. + +--- + +## Group Focus: docstore + +### docstore Group-Specific Testing + +docstore Tool Group (9 tools +1 for code mode) + +1. 'pg_doc_list_collections' +2. 'pg_doc_create_collection' +3. 'pg_doc_drop_collection' +4. 'pg_doc_collection_info' +5. 'pg_doc_find' +6. 'pg_doc_add' +7. 'pg_doc_modify' +8. 'pg_doc_remove' +9. 'pg_doc_create_index' +10. 'pg_execute_code' (codemode, auto-added) + +> **Instructions**: Execute every numbered checklist item with the exact inputs shown using DIRECT TOOL CALLS ONLY. Skip any items specifically testing `pg_execute_code` or Code Mode Parity. Compare responses against the expected results. Report any deviation. These are the minimum-bar tests that must pass every run β€” freeform testing comes after. + +**Test data:** Docstore tools operate on JSONB document collections β€” tables with a `_id` TEXT primary key and `doc` JSONB column. The `test_documents` collection provides 5 seed documents with `name`, `age`, `tags` (array), and `address` (nested object) fields. Create `temp_doc_test` for write operation testing via `pg_doc_create_collection`. + +**Certification Coverage Matrix:** + +| Tool | Direct Call (Happy Path) | Domain Error (P154) | Zod Empty Param `{}` | Alias Acceptance | +| :------------------------- | :----------------------- | :-------------------------- | :------------------- | :----------------------------------- | +| `pg_doc_list_collections` | βœ… | βœ… (Nonexistent schema) | βœ… | N/A | +| `pg_doc_create_collection` | βœ… | βœ… (Duplicate collection) | βœ… | βœ… (`name`) | +| `pg_doc_drop_collection` | βœ… | βœ… (Nonexistent collection) | βœ… | βœ… (`name`) | +| `pg_doc_collection_info` | βœ… | βœ… (Nonexistent collection) | βœ… | N/A | +| `pg_doc_find` | βœ… | βœ… (Nonexistent collection) | βœ… | βœ… (`fields` array/string) | +| `pg_doc_add` | βœ… | βœ… (Nonexistent collection) | βœ… | N/A | +| `pg_doc_modify` | βœ… | βœ… (Nonexistent collection) | βœ… | N/A | +| `pg_doc_remove` | βœ… | βœ… (Nonexistent collection) | βœ… | N/A | +| `pg_doc_create_index` | βœ… | βœ… (Nonexistent collection) | βœ… | βœ… (`field` / `fields` array/string) | +| `pg_execute_code` | βœ… (Docstore query) | βœ… (Code evaluation error) | βœ… | βœ… (includes metrics) | + +**Key Findings & Remediation:** + +- ⚠️ **Issue**: The `fields` alias in `pg_doc_create_index` and `pg_doc_find` did not robustly map comma-separated strings or string arrays, leading to Zod validation exceptions when users provided shorthand projections. +- πŸ”§ **Fix**: Updated `z.preprocess()` in `CreateDocIndexSchema` and `FindSchema` to natively split and map strings into the expected internal structures. +- πŸ“¦ **Payload**: Token consumption is highly efficient. The largest call, `pg_doc_find`, used only ~130 tokens for a full 5-document dump. +- ❌ **E2E Flake**: Fixed a test fragility in `codemode-worker.spec.ts` that intermittently failed because it strictly checked for `"timed out"` without accounting for the exact text `"Worker exited with code 1"`. +- πŸ“Š **Total Token Usage**: 2,866 tokens across 50 operations. diff --git a/test-server/test-tool-groups/test-tool-group-introspection.md b/test-server/test-tool-groups/test-tool-group-introspection.md index 2e92c393..7d24ee54 100644 --- a/test-server/test-tool-groups/test-tool-group-introspection.md +++ b/test-server/test-tool-groups/test-tool-group-introspection.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-jsonb-part1.md b/test-server/test-tool-groups/test-tool-group-jsonb-part1.md index 062266bf..80be8f72 100644 --- a/test-server/test-tool-groups/test-tool-group-jsonb-part1.md +++ b/test-server/test-tool-groups/test-tool-group-jsonb-part1.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-jsonb-part2.md b/test-server/test-tool-groups/test-tool-group-jsonb-part2.md index 0e7e9c76..bca40817 100644 --- a/test-server/test-tool-groups/test-tool-group-jsonb-part2.md +++ b/test-server/test-tool-groups/test-tool-group-jsonb-part2.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-kcache.md b/test-server/test-tool-groups/test-tool-group-kcache.md index f9d8f2ae..17ff3d63 100644 --- a/test-server/test-tool-groups/test-tool-group-kcache.md +++ b/test-server/test-tool-groups/test-tool-group-kcache.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-ltree.md b/test-server/test-tool-groups/test-tool-group-ltree.md index 155cf6cb..57cf22c6 100644 --- a/test-server/test-tool-groups/test-tool-group-ltree.md +++ b/test-server/test-tool-groups/test-tool-group-ltree.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. @@ -249,18 +249,18 @@ Paths: `electronics`, `electronics.phones`, `electronics.phones.smartphones`, `e **Checklist:** -1. `pg_ltree_query({table: "test_categories", column: "path", path: "electronics"})` β†’ verify descendants include `phones`, `smartphones`, `accessories` -2. `pg_ltree_query({table: "test_categories", column: "path", path: "electronics", mode: "exact"})` β†’ exactly 1 result -3. `pg_ltree_subpath({path: "electronics.phones.smartphones", offset: 1, length: 2})` β†’ `"phones.smartphones"` -4. `pg_ltree_lca({paths: ["electronics.phones", "electronics.accessories"]})` β†’ `"electronics"` -5. `pg_ltree_match({table: "test_categories", column: "path", pattern: "electronics.*"})` β†’ results include `phones`, `accessories` -6. `pg_ltree_list_columns()` β†’ verify `test_categories.path` appears -7. πŸ”΄ `pg_ltree_query({table: "nonexistent_xyz", column: "path", path: "a"})` β†’ `{success: false, error: "..."}` handler error -8. πŸ”΄ `pg_ltree_subpath({})` β†’ `{success: false, error: "..."}` (Zod validation) - -9. `pg_ltree_create_extension()` β†’ verify happy path expected behavior -10. πŸ”΄ `pg_ltree_create_extension({})` β†’ verify structured P154 error response or valid defaults -11. `pg_ltree_convert_column()` β†’ verify happy path expected behavior -12. πŸ”΄ `pg_ltree_convert_column({})` β†’ verify structured P154 error response or valid defaults -13. `pg_ltree_create_index()` β†’ verify happy path expected behavior -14. πŸ”΄ `pg_ltree_create_index({})` β†’ verify structured P154 error response or valid defaults +1. βœ… `pg_ltree_query({table: "test_categories", column: "path", path: "electronics"})` β†’ verify descendants include `phones`, `smartphones`, `accessories` +2. βœ… `pg_ltree_query({table: "test_categories", column: "path", path: "electronics", mode: "exact"})` β†’ exactly 1 result +3. βœ… `pg_ltree_subpath({path: "electronics.phones.smartphones", offset: 1, length: 2})` β†’ `"phones.smartphones"` +4. βœ… `pg_ltree_lca({paths: ["electronics.phones", "electronics.accessories"]})` β†’ `"electronics"` +5. βœ… `pg_ltree_match({table: "test_categories", column: "path", pattern: "electronics.*"})` β†’ results include `phones`, `accessories` +6. βœ… `pg_ltree_list_columns()` β†’ verify `test_categories.path` appears +7. βœ… πŸ”΄ `pg_ltree_query({table: "nonexistent_xyz", column: "path", path: "a"})` β†’ `{success: false, error: "..."}` handler error +8. βœ… πŸ”΄ `pg_ltree_subpath({})` β†’ `{success: false, error: "..."}` (Zod validation) + +9. βœ… `pg_ltree_create_extension()` β†’ verify happy path expected behavior +10. βœ… πŸ”΄ `pg_ltree_create_extension({})` β†’ verify structured P154 error response or valid defaults +11. βœ… `pg_ltree_convert_column()` β†’ verify happy path expected behavior +12. βœ… πŸ”΄ `pg_ltree_convert_column({})` β†’ verify structured P154 error response or valid defaults +13. βœ… `pg_ltree_create_index()` β†’ verify happy path expected behavior +14. βœ… πŸ”΄ `pg_ltree_create_index({})` β†’ verify structured P154 error response or valid defaults diff --git a/test-server/test-tool-groups/test-tool-group-migration.md b/test-server/test-tool-groups/test-tool-group-migration.md index c49464ed..a759d572 100644 --- a/test-server/test-tool-groups/test-tool-group-migration.md +++ b/test-server/test-tool-groups/test-tool-group-migration.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-monitoring.md b/test-server/test-tool-groups/test-tool-group-monitoring.md index 71110327..2f2fbd74 100644 --- a/test-server/test-tool-groups/test-tool-group-monitoring.md +++ b/test-server/test-tool-groups/test-tool-group-monitoring.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-partitioning.md b/test-server/test-tool-groups/test-tool-group-partitioning.md index 7ae5b0e0..c3f4c0a7 100644 --- a/test-server/test-tool-groups/test-tool-group-partitioning.md +++ b/test-server/test-tool-groups/test-tool-group-partitioning.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-partman.md b/test-server/test-tool-groups/test-tool-group-partman.md index 378ee560..24d3fac1 100644 --- a/test-server/test-tool-groups/test-tool-group-partman.md +++ b/test-server/test-tool-groups/test-tool-group-partman.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-performance-part1.md b/test-server/test-tool-groups/test-tool-group-performance-part1.md index 0ede740a..9a280d9c 100644 --- a/test-server/test-tool-groups/test-tool-group-performance-part1.md +++ b/test-server/test-tool-groups/test-tool-group-performance-part1.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-performance-part2.md b/test-server/test-tool-groups/test-tool-group-performance-part2.md index 62d0661d..9ee83d70 100644 --- a/test-server/test-tool-groups/test-tool-group-performance-part2.md +++ b/test-server/test-tool-groups/test-tool-group-performance-part2.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-pgcrypto.md b/test-server/test-tool-groups/test-tool-group-pgcrypto.md index 467971ee..b0888026 100644 --- a/test-server/test-tool-groups/test-tool-group-pgcrypto.md +++ b/test-server/test-tool-groups/test-tool-group-pgcrypto.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-postgis-part1.md b/test-server/test-tool-groups/test-tool-group-postgis-part1.md index e3146fb7..7bf1a58b 100644 --- a/test-server/test-tool-groups/test-tool-group-postgis-part1.md +++ b/test-server/test-tool-groups/test-tool-group-postgis-part1.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-postgis-part2.md b/test-server/test-tool-groups/test-tool-group-postgis-part2.md index 1d4be608..4c5e2d60 100644 --- a/test-server/test-tool-groups/test-tool-group-postgis-part2.md +++ b/test-server/test-tool-groups/test-tool-group-postgis-part2.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-roles.md b/test-server/test-tool-groups/test-tool-group-roles.md new file mode 100644 index 00000000..01244d75 --- /dev/null +++ b/test-server/test-tool-groups/test-tool-group-roles.md @@ -0,0 +1,297 @@ +# postgres-mcp Tool Group Re-Testing: [roles] + +**ESSENTIAL INSTRUCTIONS** + +- Execute **EVERY** numbered stress test below using direct MCP tool calls, **NOT** codemode. +- Do not use scripts or terminal to replace planned tests. +- Do not modify or skip tests. +- Do not put temp files in root; Use C:\Users\chris\Desktop\postgres-mcp\tmp + +## Reporting Format + +- ❌ Fail: Tool errors or produces incorrect results (include error message) +- ⚠️ Issue: Unexpected behavior or improvement opportunity +- πŸ“¦ Payload: Unnecessarily large response that should be optimized β€” **blocking, equally important as ❌ bugs**. Oversized payloads waste LLM context window tokens and degrade downstream tool-calling quality. **You MUST monitor `_meta.tokenEstimate` for every operation**. Report the response size in tokens/KB and suggest a concrete optimization (e.g., filter system tables, add `compact` option, omit empty arrays). + +> **Token estimates**: Every tool response includes `_meta.tokenEstimate` in its `content[].text` payload (approximate token count based on ~4 bytes/token). Code Mode responses include `metrics.tokenEstimate` instead. These are injected automatically by the adapter β€” no per-tool assertions needed, but report as ⚠️ if absent. +> **Code Mode Token Tracking**: For at least one `pg_execute_code` test, explicitly verify that `metrics.tokenEstimate` is present in the response and is a number greater than 0, reporting as ❌ if it is missing or zero. + +## Test Database Schema + +The test database (`postgres`) contains these tables: + +| Table | Rows | Key Columns | JSONB Columns | Tool Groups | +| ------------------- | ---- | ---------------------------------------------------------------------------------- | ------------------------ | --------------------- | +| `test_products` | 15 | id, name, description, price, created_at | β€” | Core, Stats | +| `test_orders` | 20 | id, product_id (FK), quantity, total_price, status | β€” | Core, Stats, Trans | +| `test_jsonb_docs` | 3 | id | metadata, settings, tags | JSONB (20 tools) | +| `test_articles` | 3 | id, title, body, search_vector (TSVECTOR) | β€” | Text | +| `test_measurements` | 640 | id, sensor_id (INT 1-6), temperature, humidity, pressure | β€” | Stats (19 tools) | +| `test_embeddings` | 75 | id, content, category, embedding (vector 384d) | β€” | Vector (16 tools) | +| `test_locations` | 25 | id, name, location (GEOMETRY POINT SRID 4326) | β€” | PostGIS (15 tools) | +| `test_users` | 3 | id, username (CITEXT), email (CITEXT) | β€” | Citext (6 tools) | +| `test_categories` | 6 | id, name, path (LTREE) | β€” | Ltree (8 tools) | +| `test_secure_data` | 0 | id, user_id, sensitive_data (BYTEA), created_at | β€” | pgcrypto (9 tools) | +| `test_events` | 100 | id, event_type, event_date, payload (JSONB) β€” PARTITION BY RANGE | payload | Partitioning, Partman | +| `test_logs` | 0 | id, log_level, message, created_at β€” PARTITION BY RANGE | β€” | Partman | +| `test_departments` | 3 | id, name, budget | β€” | Introspection | +| `test_employees` | 5 | id, name, department_id (FK CASCADE), manager_id (FK self-ref SET NULL), hire_date | β€” | Introspection | +| `test_projects` | 2 | id, name, lead_id (FK SET NULL), department_id (FK RESTRICT) | β€” | Introspection | +| `test_assignments` | 3 | id, employee_id (FK CASCADE), project_id (FK CASCADE), role β€” UNIQUE(emp,proj) | β€” | Introspection | +| `test_audit_log` | 3 | entry_id (no PK!), employee_id (FK, no index!), action, created_at | β€” | Introspection | + +Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_order_summary` (view), `test_get_order_count()` (function). + +> **Note:** Row counts reflect the post-seed state after both `test-database.sql` and `test-resources.sql` run. The resource seed adds ~200 measurements (minus deletions by `id % 5 = 0 AND id > 400`), 25 embeddings (IDs 51-75), and 20 locations (IDs 6-25). +> Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_locations_geo` (GIST), `idx_categories_path` (GIST), HNSW on `test_embeddings.embedding`. + +## Testing Requirements + +1. Use existing `test_*` tables for read operations (SELECT, COUNT, EXISTS, etc.) +2. Create temporary tables with `temp_` prefix for write operations (CREATE, INSERT, DROP, etc.) +3. Test each tool with realistic inputs based on the schema above +4. Clean up any `temp_*` tables after testing +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. +6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal +7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. +8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. +9. **No Scripted Loops**: You must test each error path by writing an individual, distinct tool call. +10. **Pacing**: Test a maximum of 3-5 tools at a time. Report the results, update your matrix, and then move on to the next chunk. +11. **Deterministic checklist first**: Complete ALL items in the Deterministic Checklist below before moving to the Strict Coverage Matrix exploration. The checklist uses exact inputs and expected outputs to ensure reproducible coverage every run. +12. **Audit backup tools**: The 3 `pg_audit_*` tools require `--audit-backup` to be enabled on the test server. When enabled, destructive operations (`pg_truncate`, `pg_drop_table`, `pg_vacuum`, etc.) create gzip-compressed `.snapshot.json.gz` files alongside the audit log. **V2 features to verify**: `pg_audit_diff_backup` now returns a `volumeDrift` field (row count + size changes); `pg_audit_restore_backup` supports `restoreAs` for side-by-side non-destructive restore; and Code Mode calls through `pg_execute_code` that trigger destructive operations are also captured by the interceptor. When disabled, all 3 tools return `{success: false, error: "Audit backup not enabled"}`. + +Note: The isError flag propagation issue has been fixed. P154 structured errors (`{success: false, error: "..."}`) now return as parseable JSON objects via direct tool calls β€” not as raw MCP error strings. During error path testing, verify this: if a direct tool call for a nonexistent schema/table returns a raw error string instead of a JSON object with `success` and `error` fields, report it as ❌. + +## Structured Error Response Pattern + +All tools must return errors as structured objects instead of throwing. A thrown error propagates as a raw MCP error, which is unhelpful to clients. The expected pattern: + +```json +{ + "success": false, + "error": "Human-readable error message", + "code": "QUERY_ERROR", + "category": "query", + "recoverable": false +} +``` + +The enriched `ErrorResponse` from `formatHandlerError` always includes `success`, `error`, `code`, `category`, and `recoverable`. Optional fields `suggestion` and `details` may also be present. Some tools include additional context fields (e.g., `pg_transaction_execute` includes `statementsExecuted`, `failedStatement`, `autoRolledBack`). These are acceptable as long as `success: false` and `error` are always present. + +### Handler Error vs MCP Error β€” How to Distinguish + +There are two kinds of error responses. Only one is correct: + +| Type | Source | What you see | Verdict | +| -------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------ | +| **Handler error** βœ… | Handler catches error and returns `{success: false, error: "..."}` | Parseable JSON object with `success` and `error` fields | Correct | +| **MCP error** ❌ | Uncaught throw propagates to MCP framework | Raw text error string, often prefixed with `Error:`, wrapped in an `isError: true` content block β€” no `success` field | Bug β€” report as ❌ | + +**Concrete examples:** + +``` +βœ… Handler error (correct): +{"success": false, "error": "Table \"public.nonexistent\" does not exist"} + +❌ MCP error (bug β€” handler threw instead of catching): +content: [{type: "text", text: "Error: relation \"nonexistent\" does not exist"}] +isError: true +``` + +The MCP error case means the handler is missing a `try/catch` block. When testing, if you see a raw error string (especially one containing PostgreSQL internal messages like `relation "..." does not exist` without a `success` field), report it as ❌. + +### Zod Validation Errors + +Calling a tool with wrong parameter types or missing required fields triggers a Zod validation error. If the handler has no outer `try/catch`, this surfaces as a raw MCP error. Test every tool with `{}` (empty params) if it has required parameters β€” the response must be a handler error, not an MCP error. + +**Error message format matters:** Zod `.refine()` failures produce a `ZodError` whose `.message` property is a **raw JSON array** of Zod issues (e.g., `[{"code":"custom","message":"..."}]`). If the handler catches the error with `error.message` instead of routing through `formatHandlerError`, this raw JSON leaks as the error string. All handlers must route through `formatHandlerError`, which duck-types the `.issues` array and produces clean `Validation error: name (or table alias) is required; Validation error: columns must not be empty` messages. If you see a raw JSON array in an error message, report it as ❌. + +**Zod refinement leak pattern:** The Split Schema pattern uses `.partial()` on input schemas so the SDK accepts `{}`. But `.partial()` only makes keys **optional** β€” it does NOT strip refinements like `.min(1)`, `.max(90)`, or `.min(-90).max(90)`. This applies to **ALL types** β€” strings, arrays, AND numbers: + +- `z.string().min(1)` + empty `""` β†’ SDK rejects with raw MCP `-32602` +- `z.array().min(1)` + empty `[]` β†’ SDK rejects with raw MCP `-32602` +- `z.number().min(-90).max(90)` + value `91` β†’ SDK rejects with raw MCP `-32602` + +**Fix:** Remove ALL `.min(N)` / `.max(N)` refinements from the schema and validate inside the handler instead. Optional fields with `.default()` are safe because the default satisfies the constraint. + +**Required enum coercion pattern:** For **optional** enum params with defaults, `z.preprocess(coercer, z.enum([...]).optional().default(...))` works β€” the coercer returns `undefined` for invalid values β†’ the `.default()` kicks in. For **required** enum params (no `.optional().default(...)`), this pattern **fails**: the SDK's `.partial()` wraps the preprocess in `.optional()`, but the inner `z.enum()` still rejects `undefined` β†’ raw MCP `-32602`. **Fix:** Use `z.string()` in the schema and validate the enum inside the handler's `try/catch`, returning a structured error. + +**What to report:** + +- If a tool call returns a raw MCP error (no JSON body with `success` field), report it as ❌ with the tool name and the raw error message +- If a tool returns `{success: false, error: "..."}` but the error string is a raw Zod JSON array (starts with `[{`), report as ❌ (handler uses `error.message` instead of `formatHandlerError`) +- If a tool returns `{success: false, error: "Validation error: ..."}` with clean human-readable text, that is the correct behavior β€” do not report it as a failure +- If a tool returns a successful response for an obviously invalid input (e.g., nonexistent table returns `{success: true}`), report it as ⚠️ + +## Split Schema Pattern Verification + +All tools use the Split Schema pattern: a plain `z.object()` Base schema for MCP parameter visibility (used as `inputSchema`), and handler-side parsing via `z.preprocess()`, `.default({})`, or direct `.parse()` inside `try/catch`. Verify: + +1. **JSON Schema visibility**: Before testing tool behavior, call `tools/list` (or inspect the MCP server's tool definitions) and confirm each tool's `inputSchema` exposes its parameters. Tools with optional parameters (e.g., `schema`, `limit`, `direction`) must show non-empty `properties` in the JSON Schema. If a tool's `inputSchema` is empty or missing `properties`, report as a Split Schema violation. +2. **Parameter visibility**: For tools with optional parameters (e.g., `schema`, `limit`), make a direct MCP call using those parameters. If the tool ignores or rejects documented parameters, report as a Split Schema violation. +3. **Alias acceptance**: For tools with documented parameter aliases (e.g., table/tableName/name, sql/query), verify that direct MCP tool calls correctly accept the aliasesβ€”not just the primary parameter name. If a direct call using only an alias fails with a validation error like "X is required", report it as a Split Schema violation requiring a fix. +4. **`z.preprocess()` as `inputSchema`**: If a tool uses `z.preprocess()` directly as its `inputSchema` (instead of a plain `SchemaBase`), parameter metadata is stripped from JSON Schema generation, making direct MCP calls unable to see or use those parameters. Report as a Split Schema violation. + +## P154 Object Existence Verification + +All tools should return structured error responses for nonexistent tables/schemas (via `formatHandlerError`). The 5 core convenience tools (pg_count, pg_exists, pg_upsert, pg_batch_insert, pg_truncate) implement explicit pre-checks and serve as canonical verification targets. Beyond those, **every tool group must have at least one nonexistent-table test in its checklist** β€” see the error-path items (marked πŸ”΄) in each group's checklist in `test-group-tools.md`. + +For each P154 test, verify that calling with a nonexistent table (e.g., `table: "nonexistent_table_xyz"`) returns a handler error like `{success: false, error: "Table \"public.nonexistent_table_xyz\" does not exist"}` rather than a raw MCP error. Also verify that a nonexistent schema (e.g., `table: "fake_schema.users"`) produces a similarly clear handler error. + +Key PostgreSQL error codes that should be intercepted by `formatHandlerError` (not leaked as raw errors): + +| PG Error Code | Meaning | Expected Structured Message | +| ------------- | ------------------- | --------------------------------- | +| 42P01 | Undefined table | `Table "X" does not exist` | +| 42P06 | Duplicate schema | `Schema "X" already exists` | +| 42P07 | Duplicate table | `Table "X" already exists` | +| 42701 | Duplicate column | `Column "X" already exists` | +| 42703 | Undefined column | `Column "X" does not exist` | +| 23505 | Unique violation | `Duplicate key: ...` | +| 23503 | FK violation | `Foreign key constraint violated` | +| 42601 | Syntax error | `SQL syntax error: ...` | +| 3F000 | Invalid schema name | `Schema "X" does not exist` | +| XX000 | Internal error | `Internal error: ...` | + +## Error Consistency Audit + +During testing, check for these inconsistencies across tool groups: + +1. **Throw-vs-return**: If a tool throws a raw error instead of returning `{success: false}`, report as ❌. Document which tool groups have the worst raw-error leakage. +2. **Error field name**: All `{ success: false }` error responses should use `error` as the field name. If a tool uses a different field name for error context in a failure response, report as ⚠️. +3. **Zod validation leaks**: If calling a tool with an invalid enum value or missing required field produces a raw MCP `-32602` Zod validation error instead of a structured response, report as ❌. This indicates the Zod schema is rejecting the input at the MCP framework level before the handler's `try/catch` can intercept. +4. **Missing `formatHandlerError` wrapping**: postgres-mcp has a centralized `formatHandlerError` helper. If a handler catches errors but returns ad-hoc messages instead of using the centralized formatter, report which handler and the ad-hoc message pattern. +5. **Orphaned output schemas**: If a schema is exported from `src/adapters/postgresql/schemas/` but the corresponding tool definition does not reference it via `outputSchema`, report as ⚠️. Use `grep_search` to check whether the schema name appears in any tool file. Defined-but-unwired schemas provide zero enforcement. +6. **Inline output schemas**: If any tool defines `outputSchema: z.object({...})` inline in the handler file instead of importing a named schema from the `schemas/` directory, report as ⚠️. All output schemas must live in the appropriate `schemas/` directory with named exports. + +## Error Path Testing Checklist + +For each tool group under test, verify at least one scenario from each applicable row: + +| Error Scenario | Tool Groups to Test | Example Input | +| --------------------------------- | ------------------------------------- | ----------------------------------------------------------------------- | +| Nonexistent table | All table-accepting tools | `table: "nonexistent_xyz"` | +| Nonexistent schema | Core, introspection, schema | `schema: "fake_schema"` or `table: "fake_schema.users"` | +| Invalid SQL syntax | Core (`read_query`, `write_query`) | `sql: "SELECTT * FROM"` | +| Invalid column name | Stats, JSONB, text, vector, PostGIS | `column: "nonexistent_col"` | +| Duplicate table/index | Core (`create_table`, `create_index`) | Create existing table | +| Empty required array | Transactions | `statements: []` | +| Missing required field via alias | Core, transactions | `sql` alias instead of `query` | +| **Zod validation (empty params)** | **Every tool with required params** | `{}` (empty object β€” must return handler error, not MCP `-32602` error) | +| **Zod validation (wrong type)** | **Tools with typed params** | Pass string where number expected, etc. | + +## Cleanup Conventions + +During testing, use these naming conventions: + +- **Temporary tables**: Prefix with `temp_` (e.g., `temp_rls_demo`) +- **Temporary roles**: Prefix with `temp_test_role_` (e.g., `temp_test_role_analyst`) +- **Test views**: Prefix with `test_view_` (e.g., `test_view_order_summary`) +- **Test functions**: Prefix with `test_func_` (e.g., `test_func_calculate`) +- **Test schemas**: Prefix with `test_schema_` (e.g., `test_schema_temp`) + +After testing, clean up: + +```sql +-- List temp tables +SELECT tablename FROM pg_tables +WHERE schemaname = 'public' AND tablename LIKE 'temp_%'; + +-- Drop temp table +DROP TABLE IF EXISTS temp_rls_demo; + +-- Drop temp roles +DROP ROLE IF EXISTS temp_test_role_analyst; +DROP ROLE IF EXISTS temp_test_role_writer; +``` + +## Post-Test Procedures + +### Reporting Rules + +- Use βœ… only in inline notes during testing; omit from Final Summary +- Do not mention what already works well or issues already documented in server-instructions.md and runtime hints + +### After Testing + +1. **Token Audit**: Before concluding, call `read_resource` on `postgres://audit` to retrieve the `sessionTokenEstimate` (total token usage) for your testing session. Include this "Total Token Usage" in your final test report and session summary. Highlight the single most expensive tool call. +2. **Cleanup**: Confirm all `temp_*` tables, `temp_test_role_*` roles, and temporary testing data are removed including any files created during testing. +3. **Fix EVERY finding** β€” not just ❌ Fails, but also ⚠️ Issues including behavioral improvements, missing warnings, error code consistency, inaccuracies in the files listed below, and πŸ“¦ Payload problems (responses that should be truncated or offer a `limit` param). +4. **Read `code-map.md` before making changes and make all changes consistent with other tools.** +5. **Scope of fixes** includes corrections to any of: + - Handler code + - `server-instructions.md` + - Test database (`test-database.sql`) + - This prompt +6. **User will handle validation** +7. Update the changelog if there were any changes made (being careful not to create duplicate headers), and commit without pushing. +8. Create a /session-summary in memory-journal-mcp for the issues and their fixes, explicitly including the "Total Token Usage" captured. +9. Stop and briefly summarize the testing results and fixes, ensuring the total token count is prominently displayed. + +--- + +## Group Focus: roles + +### roles Group-Specific Testing + +roles Tool Group (12 tools +1 for code mode) + +1. 'pg_role_list' +2. 'pg_role_create' +3. 'pg_role_drop' +4. 'pg_role_attributes' +5. 'pg_role_grants' +6. 'pg_role_grant' +7. 'pg_role_assign' +8. 'pg_role_revoke' +9. 'pg_user_roles' +10. 'pg_role_set' +11. 'pg_role_rls_enable' +12. 'pg_role_rls_policies' +13. 'pg_execute_code' (codemode, auto-added) + +> **Instructions**: Execute every numbered checklist item with the exact inputs shown using DIRECT TOOL CALLS ONLY. Skip any items specifically testing `pg_execute_code` or Code Mode Parity. Compare responses against the expected results. Report any deviation. These are the minimum-bar tests that must pass every run β€” freeform testing comes after. + +**Test data:** Roles tools operate on PostgreSQL catalog views (`pg_roles`, `pg_auth_members`, `pg_policies`) and DDL statements (`CREATE ROLE`, `GRANT`, `SET ROLE`, etc.). No user-created test tables are required for most tools. Create `temp_rls_demo` for RLS testing via `pg_write_query`. Create `temp_test_role_*` roles for role CRUD and privilege testing. + +> **Superuser note:** Most roles tools (`pg_role_create`, `pg_role_drop`, `pg_role_grant`, `pg_role_assign`, `pg_role_revoke`, `pg_role_set`, `pg_role_rls_enable`) require superuser access or appropriate role management privileges. The test server runs as `postgres` (superuser). If running against a non-superuser connection, these tools should return a structured error β€” not a raw MCP error. + +**Setup (run before checklist):** + +- Create `temp_rls_demo` table via `pg_write_query({sql: "CREATE TABLE temp_rls_demo (id SERIAL PRIMARY KEY, user_id TEXT, data TEXT)"})` + +**Checklist:** + +1. `pg_role_list()` β†’ verify `{success: true, roles: [...]}` with `postgres` role present, each role has `name`, `login`, `superuser` fields +2. `pg_role_list({pattern: "postgres"})` β†’ verify filtered result contains only `postgres` role +3. `pg_role_create({name: "temp_test_role_analyst"})` β†’ verify `{success: true}` with confirmation message +4. `pg_role_create({name: "temp_test_role_writer", login: true, password: "testpass123"})` β†’ verify `{success: true}` with LOGIN attribute +5. `pg_role_attributes({role: "temp_test_role_analyst"})` β†’ verify includes OID, inherit status, connection limit, login=false (default) +6. `pg_role_attributes({role: "postgres"})` β†’ verify `superuser: true`, `login: true` +7. `pg_role_grants({role: "temp_test_role_analyst"})` β†’ verify empty grants for freshly-created role +8. `pg_role_grant({role: "temp_test_role_analyst", privileges: ["SELECT"], table: "test_products"})` β†’ verify `{success: true}` +9. `pg_role_grants({role: "temp_test_role_analyst"})` β†’ verify SELECT on test_products now appears in grants +10. `pg_role_assign({role: "temp_test_role_analyst", member: "temp_test_role_writer"})` β†’ verify `{success: true}` membership assigned +11. `pg_user_roles({role: "temp_test_role_writer"})` β†’ verify `temp_test_role_analyst` appears in memberships +12. `pg_role_revoke({role: "temp_test_role_analyst", member: "temp_test_role_writer"})` β†’ verify `{success: true}` membership revoked +13. `pg_user_roles({role: "temp_test_role_writer"})` β†’ verify `temp_test_role_analyst` no longer in memberships +14. `pg_role_set({role: "temp_test_role_analyst"})` β†’ verify `{success: true}` SET ROLE executed +15. `pg_role_set({reset: true})` β†’ verify `{success: true}` RESET ROLE to original +16. `pg_role_rls_enable({table: "temp_rls_demo", enable: true})` β†’ verify `{success: true}` RLS enabled +17. `pg_role_rls_policies({table: "temp_rls_demo"})` β†’ verify `{success: true}` with empty policies array (no policies created yet) +18. `pg_role_rls_enable({table: "temp_rls_demo", enable: false})` β†’ verify `{success: true}` RLS disabled +19. `pg_role_drop({name: "temp_test_role_writer"})` β†’ verify `{success: true}` role dropped + +20. πŸ”΄ `pg_role_create({})` β†’ `{success: false, error: "..."}` (missing required `name` param) +21. πŸ”΄ `pg_role_drop({})` β†’ `{success: false, error: "..."}` (missing required `name` param) +22. πŸ”΄ `pg_role_attributes({role: "nonexistent_role_xyz"})` β†’ `{success: false, error: "..."}` (P154 β€” role doesn't exist) +23. πŸ”΄ `pg_role_grants({role: "nonexistent_role_xyz"})` β†’ `{success: false, error: "..."}` (P154 β€” role doesn't exist) +24. πŸ”΄ `pg_role_grant({role: "temp_test_role_analyst", privileges: ["SELECT"], table: "nonexistent_table_xyz"})` β†’ `{success: false, error: "..."}` (P154 β€” table doesn't exist) +25. πŸ”΄ `pg_role_assign({role: "nonexistent_role_xyz", member: "postgres"})` β†’ `{success: false, error: "..."}` (P154 β€” role doesn't exist) +26. πŸ”΄ `pg_role_rls_enable({table: "nonexistent_table_xyz"})` β†’ `{success: false, error: "..."}` (P154 β€” table doesn't exist) +27. πŸ”΄ `pg_role_rls_policies({table: "nonexistent_table_xyz"})` β†’ `{success: false, error: "..."}` (P154 β€” table doesn't exist) + +**Cleanup:** + +28. Drop remaining temp roles: `pg_role_drop({name: "temp_test_role_analyst"})` (revoke grants first if needed) +29. Drop temp table: `pg_write_query({sql: "DROP TABLE IF EXISTS temp_rls_demo"})` diff --git a/test-server/test-tool-groups/test-tool-group-schema.md b/test-server/test-tool-groups/test-tool-group-schema.md index 4e0980a2..3cfce9df 100644 --- a/test-server/test-tool-groups/test-tool-group-schema.md +++ b/test-server/test-tool-groups/test-tool-group-schema.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-security.md b/test-server/test-tool-groups/test-tool-group-security.md new file mode 100644 index 00000000..20026fdb --- /dev/null +++ b/test-server/test-tool-groups/test-tool-group-security.md @@ -0,0 +1,276 @@ +# postgres-mcp Tool Group Re-Testing: [security] + +**ESSENTIAL INSTRUCTIONS** + +- Execute **EVERY** numbered stress test below using direct MCP tool calls, **NOT** codemode. +- Do not use scripts or terminal to replace planned tests. +- Do not modify or skip tests. +- Do not put temp files in root; Use C:\Users\chris\Desktop\postgres-mcp\tmp + +## Reporting Format + +- ❌ Fail: Tool errors or produces incorrect results (include error message) +- ⚠️ Issue: Unexpected behavior or improvement opportunity +- πŸ“¦ Payload: Unnecessarily large response that should be optimized β€” **blocking, equally important as ❌ bugs**. Oversized payloads waste LLM context window tokens and degrade downstream tool-calling quality. **You MUST monitor `_meta.tokenEstimate` for every operation**. Report the response size in tokens/KB and suggest a concrete optimization (e.g., filter system tables, add `compact` option, omit empty arrays). + +> **Token estimates**: Every tool response includes `_meta.tokenEstimate` in its `content[].text` payload (approximate token count based on ~4 bytes/token). Code Mode responses include `metrics.tokenEstimate` instead. These are injected automatically by the adapter β€” no per-tool assertions needed, but report as ⚠️ if absent. +> **Code Mode Token Tracking**: For at least one `pg_execute_code` test, explicitly verify that `metrics.tokenEstimate` is present in the response and is a number greater than 0, reporting as ❌ if it is missing or zero. + +## Test Database Schema + +The test database (`postgres`) contains these tables: + +| Table | Rows | Key Columns | JSONB Columns | Tool Groups | +| ------------------- | ---- | ---------------------------------------------------------------------------------- | ------------------------ | --------------------- | +| `test_products` | 15 | id, name, description, price, created_at | β€” | Core, Stats | +| `test_orders` | 20 | id, product_id (FK), quantity, total_price, status | β€” | Core, Stats, Trans | +| `test_jsonb_docs` | 3 | id | metadata, settings, tags | JSONB (20 tools) | +| `test_articles` | 3 | id, title, body, search_vector (TSVECTOR) | β€” | Text | +| `test_measurements` | 640 | id, sensor_id (INT 1-6), temperature, humidity, pressure | β€” | Stats (19 tools) | +| `test_embeddings` | 75 | id, content, category, embedding (vector 384d) | β€” | Vector (16 tools) | +| `test_locations` | 25 | id, name, location (GEOMETRY POINT SRID 4326) | β€” | PostGIS (15 tools) | +| `test_users` | 3 | id, username (CITEXT), email (CITEXT) | β€” | Citext (6 tools) | +| `test_categories` | 6 | id, name, path (LTREE) | β€” | Ltree (8 tools) | +| `test_secure_data` | 0 | id, user_id, sensitive_data (BYTEA), created_at | β€” | pgcrypto (9 tools) | +| `test_events` | 100 | id, event_type, event_date, payload (JSONB) β€” PARTITION BY RANGE | payload | Partitioning, Partman | +| `test_logs` | 0 | id, log_level, message, created_at β€” PARTITION BY RANGE | β€” | Partman | +| `test_departments` | 3 | id, name, budget | β€” | Introspection | +| `test_employees` | 5 | id, name, department_id (FK CASCADE), manager_id (FK self-ref SET NULL), hire_date | β€” | Introspection | +| `test_projects` | 2 | id, name, lead_id (FK SET NULL), department_id (FK RESTRICT) | β€” | Introspection | +| `test_assignments` | 3 | id, employee_id (FK CASCADE), project_id (FK CASCADE), role β€” UNIQUE(emp,proj) | β€” | Introspection | +| `test_audit_log` | 3 | entry_id (no PK!), employee_id (FK, no index!), action, created_at | β€” | Introspection | + +Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_order_summary` (view), `test_get_order_count()` (function). + +> **Note:** Row counts reflect the post-seed state after both `test-database.sql` and `test-resources.sql` run. The resource seed adds ~200 measurements (minus deletions by `id % 5 = 0 AND id > 400`), 25 embeddings (IDs 51-75), and 20 locations (IDs 6-25). +> Indexes: `idx_orders_status`, `idx_orders_date`, `idx_articles_fts` (GIN), `idx_locations_geo` (GIST), `idx_categories_path` (GIST), HNSW on `test_embeddings.embedding`. + +## Testing Requirements + +1. Use existing `test_*` tables for read operations (SELECT, COUNT, EXISTS, etc.) +2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) +3. Test each tool with realistic inputs based on the schema above +4. Clean up any `temp_*` tables after testing +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. +6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal +7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{"success": false, "error": "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. +8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. +9. **No Scripted Loops**: You must test each error path by writing an individual, distinct tool call. +10. **Pacing**: Test a maximum of 3-5 tools at a time. Report the results, update your matrix, and then move on to the next chunk. +11. **Deterministic checklist first**: Complete ALL items in the Deterministic Checklist below before moving to the Strict Coverage Matrix exploration. The checklist uses exact inputs and expected outputs to ensure reproducible coverage every run. +12. **Audit backup tools**: The 3 `pg_audit_*` tools require `--audit-backup` to be enabled on the test server. When enabled, destructive operations (`pg_truncate`, `pg_drop_table`, `pg_vacuum`, etc.) create gzip-compressed `.snapshot.json.gz` files alongside the audit log. **V2 features to verify**: `pg_audit_diff_backup` now returns a `volumeDrift` field (row count + size changes); `pg_audit_restore_backup` supports `restoreAs` for side-by-side non-destructive restore; and Code Mode calls through `pg_execute_code` that trigger destructive operations are also captured by the interceptor. When disabled, all 3 tools return `{success: false, error: "Audit backup not enabled"}`. + +Note: The isError flag propagation issue has been fixed. P154 structured errors (`{success: false, error: "..."}`) now return as parseable JSON objects via direct tool calls β€” not as raw MCP error strings. During error path testing, verify this: if a direct tool call for a nonexistent schema/table returns a raw error string instead of a JSON object with `success` and `error` fields, report it as ❌. + +## Structured Error Response Pattern + +All tools must return errors as structured objects instead of throwing. A thrown error propagates as a raw MCP error, which is unhelpful to clients. The expected pattern: + +```json +{ + "success": false, + "error": "Human-readable error message", + "code": "QUERY_ERROR", + "category": "query", + "recoverable": false +} +``` + +The enriched `ErrorResponse` from `formatHandlerError` always includes `success`, `error`, `code`, `category`, and `recoverable`. Optional fields `suggestion` and `details` may also be present. Some tools include additional context fields (e.g., `pg_transaction_execute` includes `statementsExecuted`, `failedStatement`, `autoRolledBack`). These are acceptable as long as `success: false` and `error` are always present. + +### Handler Error vs MCP Error β€” How to Distinguish + +There are two kinds of error responses. Only one is correct: + +| Type | Source | What you see | Verdict | +| -------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------ | +| **Handler error** βœ… | Handler catches error and returns `{success: false, error: "..."}` | Parseable JSON object with `success` and `error` fields | Correct | +| **MCP error** ❌ | Uncaught throw propagates to MCP framework | Raw text error string, often prefixed with `Error:`, wrapped in an `isError: true` content block β€” no `success` field | Bug β€” report as ❌ | + +**Concrete examples:** + +``` +βœ… Handler error (correct): +{"success": false, "error": "Table \"public.nonexistent\" does not exist"} + +❌ MCP error (bug β€” handler threw instead of catching): +content: [{type: "text", text: "Error: relation \"nonexistent\" does not exist"}] +isError: true +``` + +The MCP error case means the handler is missing a `try/catch` block. When testing, if you see a raw error string (especially one containing PostgreSQL internal messages like `relation "..." does not exist` without a `success` field), report it as ❌. + +### Zod Validation Errors + +Calling a tool with wrong parameter types or missing required fields triggers a Zod validation error. If the handler has no outer `try/catch`, this surfaces as a raw MCP error. Test every tool with `{}` (empty params) if it has required parameters β€” the response must be a handler error, not an MCP error. + +**Error message format matters:** Zod `.refine()` failures produce a `ZodError` whose `.message` property is a **raw JSON array** of Zod issues (e.g., `[{"code":"custom","message":"..."}]`). If the handler catches the error with `error.message` instead of routing through `formatHandlerError`, this raw JSON leaks as the error string. All handlers must route through `formatHandlerError`, which duck-types the `.issues` array and produces clean `Validation error: name (or table alias) is required; Validation error: columns must not be empty` messages. If you see a raw JSON array in an error message, report it as ❌. + +**Zod refinement leak pattern:** The Split Schema pattern uses `.partial()` on input schemas so the SDK accepts `{}`. But `.partial()` only makes keys **optional** β€” it does NOT strip refinements like `.min(1)`, `.max(90)`, or `.min(-90).max(90)`. This applies to **ALL types** β€” strings, arrays, AND numbers: + +- `z.string().min(1)` + empty `""` β†’ SDK rejects with raw MCP `-32602` +- `z.array().min(1)` + empty `[]` β†’ SDK rejects with raw MCP `-32602` +- `z.number().min(-90).max(90)` + value `91` β†’ SDK rejects with raw MCP `-32602` + +**Fix:** Remove ALL `.min(N)` / `.max(N)` refinements from the schema and validate inside the handler instead. Optional fields with `.default()` are safe because the default satisfies the constraint. + +**Required enum coercion pattern:** For **optional** enum params with defaults, `z.preprocess(coercer, z.enum([...]).optional().default(...))` works β€” the coercer returns `undefined` for invalid values β†’ the `.default()` kicks in. For **required** enum params (no `.optional().default(...)`), this pattern **fails**: the SDK's `.partial()` wraps the preprocess in `.optional()`, but the inner `z.enum()` still rejects `undefined` β†’ raw MCP `-32602`. **Fix:** Use `z.string()` in the schema and validate the enum inside the handler's `try/catch`, returning a structured error. + +**What to report:** + +- If a tool call returns a raw MCP error (no JSON body with `success` field), report it as ❌ with the tool name and the raw error message +- If a tool returns `{success: false, error: "..."}` but the error string is a raw Zod JSON array (starts with `[{`), report as ❌ (handler uses `error.message` instead of `formatHandlerError`) +- If a tool returns `{success: false, error: "Validation error: ..."}` with clean human-readable text, that is the correct behavior β€” do not report it as a failure +- If a tool returns a successful response for an obviously invalid input (e.g., nonexistent table returns `{success: true}`), report it as ⚠️ + +## Split Schema Pattern Verification + +All tools use the Split Schema pattern: a plain `z.object()` Base schema for MCP parameter visibility (used as `inputSchema`), and handler-side parsing via `z.preprocess()`, `.default({})`, or direct `.parse()` inside `try/catch`. Verify: + +1. **JSON Schema visibility**: Before testing tool behavior, call `tools/list` (or inspect the MCP server's tool definitions) and confirm each tool's `inputSchema` exposes its parameters. Tools with optional parameters (e.g., `schema`, `limit`, `direction`) must show non-empty `properties` in the JSON Schema. If a tool's `inputSchema` is empty or missing `properties`, report as a Split Schema violation. +2. **Parameter visibility**: For tools with optional parameters (e.g., `schema`, `limit`), make a direct MCP call using those parameters. If the tool ignores or rejects documented parameters, report as a Split Schema violation. +3. **Alias acceptance**: For tools with documented parameter aliases (e.g., table/tableName/name, sql/query), verify that direct MCP tool calls correctly accept the aliasesβ€”not just the primary parameter name. If a direct call using only an alias fails with a validation error like "X is required", report it as a Split Schema violation requiring a fix. +4. **`z.preprocess()` as `inputSchema`**: If a tool uses `z.preprocess()` directly as its `inputSchema` (instead of a plain `SchemaBase`), parameter metadata is stripped from JSON Schema generation, making direct MCP calls unable to see or use those parameters. Report as a Split Schema violation. + +## P154 Object Existence Verification + +All tools should return structured error responses for nonexistent tables/schemas (via `formatHandlerError`). The 5 core convenience tools (pg_count, pg_exists, pg_upsert, pg_batch_insert, pg_truncate) implement explicit pre-checks and serve as canonical verification targets. Beyond those, **every tool group must have at least one nonexistent-table test in its checklist** β€” see the error-path items (marked πŸ”΄) in each group's checklist in `test-group-tools.md`. + +For each P154 test, verify that calling with a nonexistent table (e.g., `table: "nonexistent_table_xyz"`) returns a handler error like `{success: false, error: "Table \"public.nonexistent_table_xyz\" does not exist"}` rather than a raw MCP error. Also verify that a nonexistent schema (e.g., `table: "fake_schema.users"`) produces a similarly clear handler error. + +Key PostgreSQL error codes that should be intercepted by `formatHandlerError` (not leaked as raw errors): + +| PG Error Code | Meaning | Expected Structured Message | +| ------------- | ------------------- | --------------------------------- | +| 42P01 | Undefined table | `Table "X" does not exist` | +| 42P06 | Duplicate schema | `Schema "X" already exists` | +| 42P07 | Duplicate table | `Table "X" already exists` | +| 42701 | Duplicate column | `Column "X" already exists` | +| 42703 | Undefined column | `Column "X" does not exist` | +| 23505 | Unique violation | `Duplicate key: ...` | +| 23503 | FK violation | `Foreign key constraint violated` | +| 42601 | Syntax error | `SQL syntax error: ...` | +| 3F000 | Invalid schema name | `Schema "X" does not exist` | +| XX000 | Internal error | `Internal error: ...` | + +## Error Consistency Audit + +During testing, check for these inconsistencies across tool groups: + +1. **Throw-vs-return**: If a tool throws a raw error instead of returning `{success: false}`, report as ❌. Document which tool groups have the worst raw-error leakage. +2. **Error field name**: All `{ success: false }` error responses should use `error` as the field name. If a tool uses a different field name for error context in a failure response, report as ⚠️. +3. **Zod validation leaks**: If calling a tool with an invalid enum value or missing required field produces a raw MCP `-32602` Zod validation error instead of a structured response, report as ❌. This indicates the Zod schema is rejecting the input at the MCP framework level before the handler's `try/catch` can intercept. +4. **Missing `formatHandlerError` wrapping**: postgres-mcp has a centralized `formatHandlerError` helper. If a handler catches errors but returns ad-hoc messages instead of using the centralized formatter, report which handler and the ad-hoc message pattern. +5. **Orphaned output schemas**: If a schema is exported from `src/adapters/postgresql/schemas/` but the corresponding tool definition does not reference it via `outputSchema`, report as ⚠️. Use `grep_search` to check whether the schema name appears in any tool file. Defined-but-unwired schemas provide zero enforcement. +6. **Inline output schemas**: If any tool defines `outputSchema: z.object({...})` inline in the handler file instead of importing a named schema from the `schemas/` directory, report as ⚠️. All output schemas must live in the appropriate `schemas/` directory with named exports. + +## Error Path Testing Checklist + +For each tool group under test, verify at least one scenario from each applicable row: + +| Error Scenario | Tool Groups to Test | Example Input | +| --------------------------------- | ------------------------------------- | ----------------------------------------------------------------------- | +| Nonexistent table | All table-accepting tools | `table: "nonexistent_xyz"` | +| Nonexistent schema | Core, introspection, schema | `schema: "fake_schema"` or `table: "fake_schema.users"` | +| Invalid SQL syntax | Core (`read_query`, `write_query`) | `sql: "SELECTT * FROM"` | +| Invalid column name | Stats, JSONB, text, vector, PostGIS | `column: "nonexistent_col"` | +| Duplicate table/index | Core (`create_table`, `create_index`) | Create existing table | +| Empty required array | Transactions | `statements: []` | +| Missing required field via alias | Core, transactions | `sql` alias instead of `query` | +| **Zod validation (empty params)** | **Every tool with required params** | `{}` (empty object β€” must return handler error, not MCP `-32602` error) | +| **Zod validation (wrong type)** | **Tools with typed params** | Pass string where number expected, etc. | + +## Cleanup Conventions + +During testing, use these naming conventions: + +- **Temporary tables**: Prefix with `temp_` (e.g., `temp_analysis_results`) +- **Test views**: Prefix with `test_view_` (e.g., `test_view_order_summary`) +- **Test functions**: Prefix with `test_func_` (e.g., `test_func_calculate`) +- **Test schemas**: Prefix with `test_schema_` (e.g., `test_schema_temp`) + +After testing, clean up: + +```sql +-- List temp tables +SELECT tablename FROM pg_tables +WHERE schemaname = 'public' AND tablename LIKE 'temp_%'; + +-- Drop temp table +DROP TABLE IF EXISTS temp_my_test_table; +``` + +## Post-Test Procedures + +### Reporting Rules + +- Use βœ… only in inline notes during testing; omit from Final Summary +- Do not mention what already works well or issues already documented in server-instructions.md and runtime hints + +### After Testing + +1. **Token Audit**: Before concluding, call `read_resource` on `postgres://audit` to retrieve the `sessionTokenEstimate` (total token usage) for your testing session. Include this "Total Token Usage" in your final test report and session summary. Highlight the single most expensive tool call. +2. **Cleanup**: Confirm all `temp_*` tables and temporary testing data are removed including any files created during testing. +3. **Fix EVERY finding** β€” not just ❌ Fails, but also ⚠️ Issues including behavioral improvements, missing warnings, error code consistency, inaccuracies in the files listed below, and πŸ“¦ Payload problems (responses that should be truncated or offer a `limit` param). +4. **Read `code-map.md` before making changes and make all changes consistent with other tools.** +5. **Scope of fixes** includes corrections to any of: + - Handler code + - `server-instructions.md` + - Test database (`test-database.sql`) + - This prompt +6. **User will handle validation** +7. Update the changelog if there were any changes made (being careful not to create duplicate headers), and commit without pushing. +8. Create a /session-summary in memory-journal-mcp for the issues and their fixes, explicitly including the "Total Token Usage" captured. +9. Stop and briefly summarize the testing results and fixes, ensuring the total token count is prominently displayed. + +--- + +## Group Focus: security + +### security Group-Specific Testing + +security Tool Group (9 tools +1 for code mode) + +1. 'pg_security_audit' +2. 'pg_security_firewall_status' +3. 'pg_security_firewall_rules' +4. 'pg_security_ssl_status' +5. 'pg_security_encryption_status' +6. 'pg_security_password_validate' +7. 'pg_security_mask_data' +8. 'pg_security_user_privileges' +9. 'pg_security_sensitive_tables' +10. 'pg_execute_code' (codemode, auto-added) + +> **Instructions**: Execute every numbered checklist item with the exact inputs shown using DIRECT TOOL CALLS ONLY. Skip any items specifically testing `pg_execute_code` or Code Mode Parity. Compare responses against the expected results. Report any deviation. These are the minimum-bar tests that must pass every run β€” freeform testing comes after. + +**Test data:** Security tools operate on PostgreSQL catalog views (`pg_roles`, `pg_stat_ssl`, `pg_hba_file_rules`, `information_schema.columns`) and pure-JS logic. No user-created test tables are required. The existing seed includes tables with sensitive-looking columns (`test_secure_data.sensitive_data`, `test_users.email`, `test_employees.name`) which are used by `pg_security_sensitive_tables`. + +> **Superuser note:** `pg_security_firewall_status` and `pg_security_firewall_rules` require superuser access to `pg_hba_file_rules`. The test server runs as `postgres` (superuser). If running against a non-superuser connection, these tools should return a structured error β€” not a raw MCP error. + +**Checklist:** + +1. `pg_security_audit()` β†’ verify `{success: true, findings: [...], summary: {total, passed, warnings, critical}}` with severity levels +2. `pg_security_audit({limit: 2})` β†’ verify findings array length ≀ 2 +3. `pg_security_ssl_status()` β†’ verify `{success: true, sslEnabled: boolean, sslConnections: [...], totalConnections: N}` +4. `pg_security_encryption_status()` β†’ verify `{success: true, sslEnabled, passwordEncryption, pgcryptoAvailable, encryptionSettings, certificates}` +5. `pg_security_password_validate({password: "Str0ng!Pass#2026"})` β†’ verify `strength >= 80`, `interpretation: "Very Strong"`, `meetsPolicy: true` +6. `pg_security_password_validate({password: "123456"})` β†’ verify `strength < 20`, `checks.notCommon: false` +7. `pg_security_mask_data({value: "user@example.com", type: "email"})` β†’ verify masked output preserves `@example.com` domain, local part is masked +8. `pg_security_mask_data({value: "4111111111111111", type: "credit_card"})` β†’ verify first 4 and last 4 digits preserved, middle masked +9. `pg_security_mask_data({value: "555-12-3456", type: "ssn"})` β†’ verify last 4 digits preserved, prefix masked (`***-**-3456`) +10. `pg_security_mask_data({value: "sensitive-data", type: "partial", keepFirst: 3, keepLast: 2})` β†’ verify `sen*********ta` +11. `pg_security_user_privileges()` β†’ verify `{success: true, users: [...], count: N}` listing non-system roles +12. `pg_security_user_privileges({user: "postgres"})` β†’ verify single role with `isSuperuser: true` +13. `pg_security_user_privileges({summary: true})` β†’ verify condensed output with `grantCount` and `roleCount` fields +14. `pg_security_sensitive_tables()` β†’ verify returns tables matching default patterns (password, email, token, etc.) +15. `pg_security_sensitive_tables({schema: "public", patterns: ["email", "password"]})` β†’ verify custom patterns used, `patternsUsed` matches input +16. `pg_security_firewall_status()` β†’ verify `{success: true}` with HBA summary or structured permission error +17. `pg_security_firewall_rules()` β†’ verify `{success: true, rules: [...], count: N}` or structured permission error + +18. πŸ”΄ `pg_security_password_validate({})` β†’ `{success: false, error: "..."}` (missing required `password` param) +19. πŸ”΄ `pg_security_mask_data({})` β†’ `{success: false, error: "..."}` (missing `value` and `type`) +20. πŸ”΄ `pg_security_mask_data({value: "test", type: "invalid_type"})` β†’ `{success: false, error: "..."}` handler error for invalid masking type +21. πŸ”΄ `pg_security_user_privileges({user: "nonexistent_role_xyz"})` β†’ `{success: false, error: "Role 'nonexistent_role_xyz' does not exist."}` (P154) +22. πŸ”΄ `pg_security_sensitive_tables({schema: "fake_schema_xyz"})` β†’ `{success: false, error: "Schema 'fake_schema_xyz' does not exist..."}` (P154) +23. πŸ”΄ `pg_security_firewall_rules({type: 999})` β†’ `{success: false, error: "..."}` (Zod type mismatch β€” number instead of string) diff --git a/test-server/test-tool-groups/test-tool-group-stats-part1.md b/test-server/test-tool-groups/test-tool-group-stats-part1.md index dfbc60ce..6f943390 100644 --- a/test-server/test-tool-groups/test-tool-group-stats-part1.md +++ b/test-server/test-tool-groups/test-tool-group-stats-part1.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-stats-part2.md b/test-server/test-tool-groups/test-tool-group-stats-part2.md index a29ca470..7d484a4c 100644 --- a/test-server/test-tool-groups/test-tool-group-stats-part2.md +++ b/test-server/test-tool-groups/test-tool-group-stats-part2.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-text.md b/test-server/test-tool-groups/test-tool-group-text.md index ca64bd86..aaea9c28 100644 --- a/test-server/test-tool-groups/test-tool-group-text.md +++ b/test-server/test-tool-groups/test-tool-group-text.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-transactions.md b/test-server/test-tool-groups/test-tool-group-transactions.md index 4e1238d6..572df2be 100644 --- a/test-server/test-tool-groups/test-tool-group-transactions.md +++ b/test-server/test-tool-groups/test-tool-group-transactions.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-vector-part1.md b/test-server/test-tool-groups/test-tool-group-vector-part1.md index 782a5734..2efea96f 100644 --- a/test-server/test-tool-groups/test-tool-group-vector-part1.md +++ b/test-server/test-tool-groups/test-tool-group-vector-part1.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/test-server/test-tool-groups/test-tool-group-vector-part2.md b/test-server/test-tool-groups/test-tool-group-vector-part2.md index a80f7d6e..28538b67 100644 --- a/test-server/test-tool-groups/test-tool-group-vector-part2.md +++ b/test-server/test-tool-groups/test-tool-group-vector-part2.md @@ -51,7 +51,7 @@ Schema objects: `test_schema`, `test_schema.order_seq` (starts at 1000), `test_o 2. Create temporary tables with `temp_*` prefix for write operations (CREATE, INSERT, DROP, etc.) 3. Test each tool with realistic inputs based on the schema above 4. Clean up any `temp_*` tables after testing -5. Report all failures, unexpected behaviors, improvement opportunities, or unnecessarily large payloads +5. Report all failures, broken contracts, or deviations from defined standards (e.g., P154 object-existence, Split Schema validation leaks, or unoptimized payloads). Do NOT report or implement subjective "improvement opportunities" beyond these objective criteria. If the tool group meets all standards perfectly, state that 0 changes are required and stop. 6. Do not mention what already works well or issues well documented in ServerInstructions and runtime hints which are already optimal 7. **Error path testing**: For **every** tool, test at least **two** invalid inputs: (a) a domain error (nonexistent table, invalid column, bad parameter value) and (b) a **Zod validation error** (call the tool with `{}` empty params if it has required parameters, or pass the wrong type). Both must return a **structured handler error** (`{success: false, error: "..."}`) β€” NOT a raw MCP error frame. See the "Structured Error Response Pattern" section below for how to distinguish the two. This is the most common deficiency found across tool groups. 8. **Strict Coverage Matrix**: You must create a markdown table tracking your progress in your `task.md`. For EVERY tool in the group, you must explicitly log: Direct Call (Happy Path), Domain Error (Direct Call), Zod Empty Param (Direct Call), and Alias Acceptance (if applicable). Do not proceed to the final summary until every cell in this matrix is marked with a βœ…. diff --git a/tests/e2e/codemode-worker.spec.ts b/tests/e2e/codemode-worker.spec.ts index 74c29c21..e860f2fd 100644 --- a/tests/e2e/codemode-worker.spec.ts +++ b/tests/e2e/codemode-worker.spec.ts @@ -70,6 +70,6 @@ test.describe("Code Mode Worker-Thread Execution", () => { }); expect(response.success).toBe(false); - expect(response.error).toContain("timed out"); + expect(response.error).toMatch(/timed out|Worker exited with code/i); }); }); diff --git a/tests/e2e/codemode.spec.ts b/tests/e2e/codemode.spec.ts index 0de96b4b..7c916c73 100644 --- a/tests/e2e/codemode.spec.ts +++ b/tests/e2e/codemode.spec.ts @@ -304,7 +304,7 @@ test.describe("Code Mode: Multi-Step Workflows", () => { const p = await callToolAndParse(client, "pg_execute_code", { code: ` // List tables - const tables = await pg.core.listTables({}); + const tables = await pg.core.listTables({ limit: 1000 }); const hasProducts = tables.tables.some(t => t.name === "test_products"); // Describe diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index e28923bb..c61ca7f0 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -203,8 +203,8 @@ export async function startServer( serverProcesses.set(port, proc); - // Wait for server readiness - const maxAttempts = 60; + // Wait for server readiness (bumped to 60s to prevent flakes under heavy parallel load) + const maxAttempts = 120; for (let i = 0; i < maxAttempts; i++) { try { const res = await fetch(`http://127.0.0.1:${port}/health`); diff --git a/tests/e2e/payloads-admin.spec.ts b/tests/e2e/payloads-admin.spec.ts index 7271e025..6069a6fb 100644 --- a/tests/e2e/payloads-admin.spec.ts +++ b/tests/e2e/payloads-admin.spec.ts @@ -150,9 +150,9 @@ test.describe("Payload Contracts: Admin + Monitoring", () => { test("pg_set_config returns { success }", async () => { const payload = await callToolAndParse(client, "pg_set_config", { - setting: "work_mem", + name: "work_mem", value: "4MB", - local: true, + isLocal: true, }); expectSuccess(payload); expect(payload.success).toBe(true); @@ -174,12 +174,8 @@ test.describe("Payload Contracts: Admin + Monitoring", () => { expect(typeof payload).toBe("object"); }); - test("pg_resource_usage_analyze returns shape", async () => { - const payload = await callToolAndParse( - client, - "pg_resource_usage_analyze", - {}, - ); + test("pg_system_health returns shape", async () => { + const payload = await callToolAndParse(client, "pg_system_health", {}); expectSuccess(payload); expect(typeof payload).toBe("object"); }); diff --git a/tests/e2e/payloads-vector.spec.ts b/tests/e2e/payloads-vector.spec.ts index df72e7ed..1f47c0d5 100644 --- a/tests/e2e/payloads-vector.spec.ts +++ b/tests/e2e/payloads-vector.spec.ts @@ -83,7 +83,7 @@ test.describe("Payload Contracts: Vector", () => { expect(payload.success).toBe(true); }); - test("pg_vector_search returns { results, count }", async () => { + test("pg_vector_search returns { rows, count }", async () => { const payload = await callToolAndParse(client, "pg_vector_search", { table: testTable, column: "embedding", @@ -91,7 +91,7 @@ test.describe("Payload Contracts: Vector", () => { limit: 3, }); expectSuccess(payload); - expect(Array.isArray(payload.results)).toBe(true); + expect(Array.isArray(payload.rows)).toBe(true); expect(typeof payload.count).toBe("number"); }); @@ -107,7 +107,7 @@ test.describe("Payload Contracts: Vector", () => { expect(response.error.toLowerCase()).toContain("validation"); }); - test("pg_hybrid_search returns { results, count }", async () => { + test("pg_hybrid_search returns { rows, count }", async () => { // Requires column for FTS and vector, might fail if table absent, but shape should be object const payload = await callToolAndParse(client, "pg_hybrid_search", { table: testTable, diff --git a/tests/e2e/prompts.spec.ts b/tests/e2e/prompts.spec.ts index 11e561bf..7ef356fa 100644 --- a/tests/e2e/prompts.spec.ts +++ b/tests/e2e/prompts.spec.ts @@ -52,13 +52,14 @@ test.describe("E2E Prompt Reads (via MCP SDK Client)", () => { "pg_setup_ltree", "pg_setup_pgcrypto", "pg_safe_restore_workflow", + "pg_setup_docstore", ]; - test("should list all 20 prompts", async () => { + test("should list all 21 prompts", async () => { const listResponse = await client.listPrompts(); expect(listResponse.prompts).toBeDefined(); - expect(listResponse.prompts.length).toBe(20); + expect(listResponse.prompts.length).toBe(21); const names = listResponse.prompts.map((p) => p.name); for (const expected of EXPECTED_PROMPTS) { @@ -286,4 +287,15 @@ test.describe("E2E Prompt Reads (via MCP SDK Client)", () => { expect(text.toLowerCase()).toContain("restore"); expect(text).toContain("restoreAs"); }); + + test("should get pg_setup_docstore prompt", async () => { + const response = await client.getPrompt({ + name: "pg_setup_docstore", + arguments: {}, + }); + + expect(response.messages).toBeDefined(); + const text = (response.messages[0].content as any).text as string; + expect(text.toLowerCase()).toContain("document store"); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 79460643..0c552215 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ ], coverage: { provider: "v8", + reporter: ["text", "json", "json-summary"], exclude: [ "**/__tests__/**", "**/node_modules/**",