diff --git a/.bestpractices.json b/.bestpractices.json index dda3dc6..663052f 100644 --- a/.bestpractices.json +++ b/.bestpractices.json @@ -8,7 +8,7 @@ "tests_documented_added_status": "Met", "tests_documented_added_justification": "The instructions for contributions document the policy of adding automated tests for major new functionality and explain acceptable regression coverage: https://github.com/codegiveness/postgresql-sharp-mcp/blob/main/CONTRIBUTING.md#verify-behavior", "description_good_status": "Met", - "description_good_justification": "README describes the PostgreSQL stdio MCP server, nine tools, explicit database targeting and read-only defaults: https://github.com/codegiveness/postgresql-sharp-mcp/blob/main/README.md", + "description_good_justification": "README describes the PostgreSQL stdio MCP server, nine tools, live database discovery, per-call physical database selection and default unrestricted access mode with read-only SQL calls unless writes are explicitly requested: https://github.com/codegiveness/postgresql-sharp-mcp/blob/main/README.md", "interact_status": "Met", "interact_justification": "README documents installation and links CONTRIBUTING for bug reports, enhancement requests and pull requests: https://github.com/codegiveness/postgresql-sharp-mcp/blob/main/CONTRIBUTING.md", "contribution_requirements_status": "Met", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33a292e..b2cdbda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,4 +61,5 @@ jobs: node-version: '24' - run: dotnet restore postgresql-sharp-mcp.slnx --locked-mode - run: dotnet run --project tools/PostgreSqlMcp.Build -c Release -- package --output artifacts/packages - - run: dotnet run --project tools/PostgreSqlMcp.Verify -c Release -- packages --artifacts artifacts/packages + - name: Install and exercise native entrypoints without a database service + run: dotnet run --project tools/PostgreSqlMcp.Verify -c Release -- packages --artifacts artifacts/packages --installation-only diff --git a/.gitleaks.toml b/.gitleaks.toml index 1bccc80..b864e6b 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -53,7 +53,7 @@ description = "README connection configuration placeholders" condition = "AND" paths = ['''^README\.md$'''] regexTarget = "match" -regexes = ['''^Password=<(?:database-password|other-password)>$'''] +regexes = ['''^Password=<(?:database-password|other-password|YOUR_PASSWORD)>$'''] # The verifier creates its own loopback-only disposable PostgreSQL containers. # These values must remain tied to these exact paths, not entire test files. @@ -67,11 +67,11 @@ regexes = ['''^Password=(?:reader-disposable|writer-disposable|sensitive-configu [[allowlists]] targetRules = ["npgsql-keyword-password", "sqlserver-keyword-password"] -description = "Disposable package startup authentication marker" +description = "Disposable package startup and owned PostgreSQL fixture authentication markers" condition = "AND" paths = ['''^tools/PostgreSqlMcp\.Verify/Packages\.cs$'''] regexTarget = "match" -regexes = ['''^Password=disposable-package-secret$'''] +regexes = ['''^Password=(?:disposable-package-secret|writer-disposable)$'''] # Default rules still run. Only these exact disposable values may be suppressed # if the generic provider-independent rule also recognizes them. diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a1380..853dc3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,20 @@ # Changelog -## Unreleased +## 0.3.1 +- Make **0.3.1** the environment-first setup follow-on: use masked Bash/PowerShell input to set `POSTGRES_CONNECTION_STRING`, inherit it when launching the MCP client, and keep credentials and mandatory targets files out of the primary client configuration. Existing discovery works without a JSON file; retain protected profiles as an optional alternative. +- Explain process-only lifetime, child-process inheritance, full client restart, mutually exclusive file/environment configuration and plaintext storage/inspection risks. Do not treat environment variables as encrypted secret storage or put secrets in shell history, persistent profiles or literal MCP JSON. +- Document optional secret-free prompt functions in Bash `.bashrc` and Zsh `.zshrc`, their login/startup-file behavior and shell-specific input syntax. Prompt explicitly in the launching shell; do not persist credentials or invoke secret prompts automatically from rc files. +- Exercise environment-only live discovery, physical selection, read-only/write boundaries and installed npm/NuGet entrypoints against disposable PostgreSQL. Align npm/NuGet metadata at patch version 0.3.1 without claiming release or registry publication. +- Include the 0.3.0 database-agnostic PostgreSQL selection contract: `list_databases` queries the live accessible-database catalog; configured connections are bootstrap profiles, not mandatory per-database entries. Select physical names per call, optionally choose a `target` profile, and discover newly created/granted databases without changing the protected file or restarting. Preserve optional explicit database allowlists and PostgreSQL permissions. +- **Breaking:** omitted access mode now defaults to `unrestricted`; explicit `restricted` remains effective. SQL calls still default to read-only, and writes require `read_only=false`. The `list_databases` payload now contains a paged `databases` result instead of an alias-only `targets` list. Existing 0.2.0 binaries require an upgrade; this entry does not claim release or registry publication. +- Bound dynamic data-source retention to `256 / POSTGRES_POOL_SIZE` pools: equivalent normalized profiles reuse sources, idle pools are evicted, active pools retain ownership, and capacity waits share the original operation deadline. Preserve per-call database isolation, transaction boundaries and no fallback or write replay. +- Exercise immutable single-seed files, live creation/CONNECT revocation, concurrent database switching, punctuation-safe names, optional targets, explicit allowlists, default unrestricted writes, pool churn/capacity cancellation and disposal against disposable PostgreSQL. Install built npm/NuGet artifacts and exercise live discovery, physical selection and write commit/cleanup through their actual entrypoints. +- Keep the package fixture's exact `writer-disposable` synthetic password exception scoped to `tools/PostgreSqlMcp.Verify/Packages.cs`; other passwords and paths remain subject to scanning. +- Add explicit `packages --installation-only` verification for native Windows/macOS runners without a Docker database service; retain actual installed CLI/MCP/unavailable-endpoint checks and full live discovery/write package verification on Linux. +- Make unavailable-endpoint package verification portable by owning a listening loopback socket and rejecting each accepted handshake; do not rely on bound non-listening sockets producing immediate connection refusals on every OS. +- Rewrite first-time setup around one self-contained release path, explicit file locations and naming, a single `primary` target, complete Linux/macOS/Windows MCP configurations, client reload, connectivity checkpoints and troubleshooting. Keep alternative install methods separate and preserve existing installations. Extend the existing secret-scanner exception only to the exact new README password placeholder. +- Document verified local NuGet artifact installation when registry publication is unavailable, including checksum/attestation checks, an isolated local feed and installed MCP verification. Clarify protected conversion of PostgreSQL URI secrets without claiming registry availability. - Add a NuGet-only manual release target that skips npm and GitHub Release publication while preserving main/tag validation, package verification, attestation and failure/cancellation gates. Keep the default all-target release behavior. - Verify Best Practices enrollment as project 15155 (19%, not passing); add evidence-backed owner-review proposals and correct stale enrollment, secret-feature and Scorecard findings documentation without claiming hosted changes. - Display the live OpenSSF Best Practices badge in README, including its in-progress state; distinguish the saved owner self-assessment from local answer proposals and security certification. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f86f36..d83bbb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,17 +18,18 @@ dotnet build postgresql-sharp-mcp.slnx -c Release dotnet src/PostgreSqlMcp/bin/Release/net10.0/PostgreSqlMcp.dll --help ``` -Run the source entrypoint with a protected targets file configured as described in [README.md](README.md): +Prepare `POSTGRES_CONNECTION_STRING` using the README's [hidden Bash/PowerShell prompt](README.md#2-prepare-the-connection-environment), then run the source entrypoint from that same shell; no targets file is required for one server: ```bash -dotnet src/PostgreSqlMcp/bin/Release/net10.0/PostgreSqlMcp.dll \ - --targets-file "" --validate +dotnet src/PostgreSqlMcp/bin/Release/net10.0/PostgreSqlMcp.dll --validate ``` -Omit `--validate` to start the stdio server. Never commit populated targets files, credentials, customer SQL or database results. +Omit `--validate` to start the stdio server. Access mode defaults to unrestricted, but SQL calls remain read-only unless `execute_sql` explicitly sets `read_only=false`. Add `--access-mode restricted` or `POSTGRES_ACCESS_MODE=restricted` to refuse writes. Never commit populated targets files, credentials, customer SQL or database results. The source layout is `src/PostgreSqlMcp.Core` (configuration, SQL and database behavior), `src/PostgreSqlMcp.Tools` (MCP tools), and `src/PostgreSqlMcp` (stdio host and CLI). Preserve dependency direction `Core <- Tools <- App`. MCP stdout is reserved for JSON-RPC; diagnostics belong on stderr. +An inherited base connection string alone creates the `primary` bootstrap profile and supports live discovery. Optional targets-file entries remain connection profiles for independent credentials, not per-database registration or implicit allowlists. Preserve exact alias selection for bootstrap compatibility, optional `target` for explicit profile selection, and physical database selection without rewriting configuration. Keep combining a base connection string with targets JSON/file fail-closed; do not silently prefer one credential source. Catalog discovery must be live and CONNECT-filtered; PostgreSQL enforces object privileges and RLS. Calls must not share a current database, fall back after selection errors, or grow unbounded pools. A base connection string's optional explicit database allowlist must constrain both listing and selection. See [selection and discovery](README.md#database-selection-and-live-discovery) and [resource boundaries](README.md#access-and-resource-boundaries). + Dependencies use current compatible stable releases verified against [official .NET release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/10.0/releases.json) and NuGet package indexes, not preview feeds. Central transitive pins keep the portable/five-RID graphs aligned; restore each RID with explicit `-p:RuntimeIdentifier= -p:RuntimeIdentifiers=` so its lockfile stays separate. Recheck compatibility and regenerate every affected lock on upgrades; do not infer that today's pins remain the latest indefinitely. ## Verify behavior @@ -39,6 +40,8 @@ dotnet run --project tools/PostgreSqlMcp.Verify -c Release -- integration The .NET verifier builds the application and exercises real MCP calls against a disposable PostgreSQL 17 Docker fixture with `pg_stat_statements` and HypoPG. It creates and removes its own container; do not substitute a production or unrelated database. +For database-discovery/setup changes, exercise one inherited connection string selecting an unregistered physical database with no targets file, live creation/grant/revoke changes, optional protected multi-profile files, optional allowlists, missing/denied selections without fallback, conflicting credential sources failing closed, and concurrent per-call isolation. Verify omitted-mode write commit/rollback with `read_only=false` separately from explicit restricted-mode refusal. Use disposable data; do not treat existing historical verification results as evidence that new behavior passed. + Coverage includes explicit multi-target isolation, permissions/RLS, bounded results and pagination, metadata, health/workload tools, plans, hypothetical-index cleanup, explicit writes and rollback, sanitized errors, configuration failures and log confidentiality. Linux subprocess and MCP-client regressions exercise blocked stdin, inherited output pipes, failed-writer disposal and owned-child cleanup. Verification command deadlines cover stdin, exit and output drains; release capture deadlines cover exit and drains. MCP verification requests also bound semaphore wait, stdin writing and response wait with one cancellation-aware deadline. Asynchronous stdin close is bounded, and cleanup observes readers before disposal. An already-orphaned descendant is outside the exited parent's `Process.Kill` tree, so this is not an OS process-group/job-object containment guarantee. Report only the commands and scenarios actually exercised; passing one fixture does not prove every platform, PostgreSQL version or MCP client works. The integration command also runs deterministic FsCheck SQL properties against PostgreSQL: quoted literal round trips and second-statement rejection, with shrinking and replay seeds, plus nested comments, mixed-case control statements and malformed boundaries. Failures report only synthetic generated inputs and replay information. These finite scenarios complement permission tests; they are not proof of exhaustive fuzzing or OSS-Fuzz enrollment. @@ -73,7 +76,9 @@ dotnet run --project tools/PostgreSqlMcp.Build -c Release -- package --output ar dotnet run --project tools/PostgreSqlMcp.Verify -c Release -- packages --artifacts artifacts/packages ``` -The package smoke installs into isolated locations with fresh caches and exercises the actual CLI and MCP entrypoints. An optional `--targets-file PATH` enables database calls against separately authorized disposable targets. Package generation verifies that npm and application versions match and includes dependency licenses and notices. +The package smoke installs into isolated locations with fresh caches and exercises the actual CLI and MCP entrypoints. By default it creates and removes its own PostgreSQL Docker fixture, requiring Docker and the same network/extension prerequisites as `integration`; installed npm and NuGet entrypoints must use an inherited connection string without a targets file, discover/select a newly created database without recurring configuration edits and commit/clean up an explicitly requested write. An optional `--targets-file PATH` instead enables database calls against separately authorized disposable profiles. Package generation verifies that npm and application versions match and includes dependency licenses and notices. + +Windows/macOS CI uses `packages --artifacts artifacts/packages --installation-only` because those runners do not provide the Docker fixture. This explicit mode still installs both artifacts offline, exercises their actual native CLI and MCP entrypoints, checks missing-.NET diagnostics, requires structured unavailable-database errors and verifies shutdown. It does not claim live discovery/query/write coverage; the Linux package job retains the full disposable-database scenarios above. Do not combine this mode with `--targets-file`, and do not use it to bypass a failed database verification run. To inspect the npm package manually, replace `` with the version in `npm/package.json`: @@ -95,7 +100,49 @@ dotnet tool install --tool-path ./artifacts/tools --configfile ./artifacts/packa On Windows, the tool executable ends in `.exe`. The generated `NuGet.Config` contains only the artifact directory and does not change user-level feeds. Regenerate it after moving the artifacts directory. Use a fresh tool directory or `dotnet tool update` when reinstalling. Do not point an MCP client at a `.nupkg` file. -Both installed entrypoints accept the same `--targets-file` and `--validate` flags as the source entrypoint. Runtime requirements remain those in [README.md](README.md). +Both installed entrypoints accept the inherited `POSTGRES_CONNECTION_STRING` and `--validate` used by the source entrypoint; optional `--targets-file` remains supported as an alternative, not combined with the base string. Runtime requirements remain those in [README.md](README.md). + +### Install a verified release artifact + +Registry publication can fail after the release's package and attestation jobs succeed. A maintainer-approved `release-artifacts` download from that main-branch run can still provide a verified local NuGet installation. This is not a published registry version; confirm the intended tag/version and successful package-verification and attestation jobs before installing. Access depends on GitHub artifact permissions and retention. + +Using a recent GitHub CLI with `gh attestation` support, replace `` and ``: + +```bash +gh run download "" --repo codegiveness/postgresql-sharp-mcp \ + --name release-artifacts --dir ./release-artifacts +sha256sum "./release-artifacts/packages/codegiveness.postgresql-sharp-mcp..nupkg" +gh attestation verify "./release-artifacts/packages/codegiveness.postgresql-sharp-mcp..nupkg" \ + --repo codegiveness/postgresql-sharp-mcp \ + --signer-workflow codegiveness/postgresql-sharp-mcp/.github/workflows/release.yml +``` + +Compare the printed checksum with the package's entry in `release-artifacts/SHA256SUMS`, and require successful attestation verification. A checksum by itself does not authenticate its source. + +The downloaded artifact does not contain the build's generated local-feed configuration. Create `release-artifacts/NuGet.Config` with this content; `packages` is relative to that configuration file: + +```xml + + + + + + + +``` + +Install into a fresh dedicated tool directory using only that local source. For the final `--validate` command, first prepare the connection string with the [hidden prompt](README.md#2-prepare-the-connection-environment) in this same shell: + +```bash +dotnet tool install codegiveness.postgresql-sharp-mcp --version "" \ + --tool-path ./postgresql-mcp-tool --configfile ./release-artifacts/NuGet.Config +./postgresql-mcp-tool/postgresql-sharp-mcp --version +./postgresql-mcp-tool/postgresql-sharp-mcp --validate +``` + +`--validate` performs a read-only connectivity query and can print the PostgreSQL database identity on stderr. Keep that output private; do not paste it into public reports or CI evidence for a real database. + +Use the absolute installed executable path in your MCP client's stdio entry and keep the .NET 10 runtime available. Follow [connection environment and MCP setup](README.md#2-prepare-the-connection-environment), fully quit the client and launch it from that prepared shell, then verify live database discovery and an authorized `SELECT 1`. `/mcp reload` alone cannot give an existing client a new parent environment. To keep this installation check in restricted mode, explicitly set `POSTGRES_ACCESS_MODE=restricted` in the client environment; the README's primary example otherwise defaults to unrestricted. Do not use application data or attempt writes merely to prove installation. Protected files remain an [optional alternative](README.md#optional-protected-targets-file), including for GUI clients that cannot inherit the prepared environment. ### Self-contained executable and container @@ -108,16 +155,24 @@ dotnet publish src/PostgreSqlMcp/PostgreSqlMcp.csproj -c Release -r linux-x64 -- Self-contained builds still require platform-native libraries. Cross-compiling does not establish runtime compatibility; exercise each platform you claim to support. -Build and run the container with a read-only configuration mount: +Build and run the container from a shell prepared with the README's [hidden connection-string prompt](README.md#2-prepare-the-connection-environment), with stale targets settings cleared: ```bash docker build -t postgresql-sharp-mcp . +docker run --rm -i -e POSTGRES_CONNECTION_STRING postgresql-sharp-mcp +``` + +`-e POSTGRES_CONNECTION_STRING` passes the existing shell value without putting its literal contents in command history; Docker/container inspection and sufficiently privileged operators can still see the plaintext container environment. It is not a secret store. Restart the container after credential changes. + +For an optional protected file instead, clear the conflicting connection-string/targets-JSON settings as described in the README, then use a read-only mount: + +```bash docker run --rm -i \ --mount "type=bind,src=,dst=/run/postgres.targets.json,readonly" \ -e POSTGRES_TARGETS_FILE=/run/postgres.targets.json postgresql-sharp-mcp ``` -The database hostname must be reachable from the container; `localhost` refers to the container itself. Ensure its non-root runtime user can read the mount using a suitable group/UID or secret mount, not world-readable permissions. `-i` keeps stdin available for MCP stdio. +The database hostname must be reachable from the container; `localhost` refers to the container itself. Retain certificate validation for remote connections and mount any required CA material at the connection string's container path. For the optional credentials file, ensure the non-root runtime user can read it using a suitable group/UID or secret mount, not world-readable permissions. `-i` keeps stdin available for MCP stdio. Both routes use one bootstrap profile to select live physical databases without per-database JSON edits. ## Issues, pull requests and reviews @@ -131,6 +186,8 @@ Keep versions, affected help/docs and release notes consistent. Update [THIRD-PA ## Automation and releases +Compatible documentation, setup improvements and fixes use a patch increment, such as **0.3.0 → 0.3.1**; reserve a minor increment for new or incompatible 0.x contracts. Discovery and the unrestricted-default contract remain the **0.3.0** capability boundary. **0.3.1** is the environment-first patch follow-on. A version in source is not evidence of NuGet/npm/GitHub Release publication; check the corresponding registry endpoint and release jobs. + - **CI** runs database integration and package installation checks for pushes and pull requests and uploads generated packages for inspection. - **Release workflow** is manually dispatched from `main` for an existing `v` tag reachable from `origin/main`; pushing a tag does not start publication. Read-only preflight compiles trusted release tooling from the immutable main workflow revision and validates the tag's commit. Read-only packaging checks out that commit and verifies version consistency and behavior. Checkout-free attestation and publishing jobs consume immutable artifact IDs with digest mismatches rejected; they do not compile or execute tag-source release tooling. npm requires `NPM_TOKEN`; NuGet uses GitHub OIDC with `NUGET_USERNAME` and a matching trusted publishing policy. Missing prerequisites fail only the corresponding registry job. Existing registry versions are skipped. Artifact creation or a successful GitHub release alone does not prove registry publication. - **Dependency audit** checks direct and transitive NuGet packages against known advisories. This is not proof that all vulnerabilities are absent. diff --git a/README.md b/README.md index 8ffebbf..a2ef730 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# postgresql-sharp-mcp +# postgresql-sharp-mcp — database-agnostic PostgreSQL MCP [![CI](https://github.com/codegiveness/postgresql-sharp-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/codegiveness/postgresql-sharp-mcp/actions/workflows/ci.yml) [![CodeQL](https://github.com/codegiveness/postgresql-sharp-mcp/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/codegiveness/postgresql-sharp-mcp/actions/workflows/codeql.yml) @@ -13,130 +13,418 @@ [![GitHub release](https://img.shields.io/github/v/release/codegiveness/postgresql-sharp-mcp)](https://github.com/codegiveness/postgresql-sharp-mcp/releases) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -A PostgreSQL MCP server built with C#/.NET 10 and Npgsql. One stdio server exposes nine tools for SQL, schema discovery, query plans, index analysis and database health. Every database-dependent call selects an explicitly configured target; there is no process-wide current database or fallback connection. +A PostgreSQL MCP server built with C#/.NET 10 and Npgsql. One stdio server exposes nine tools for SQL, schema discovery, query plans, index analysis and database health. Configure one connection profile, discover accessible PostgreSQL databases live, and select a physical database on each call without adding an alias for every tenant. This is PostgreSQL-only, not support for other database engines. -**Read-only by default.** Use least-privileged PostgreSQL roles and keep credentials in a protected configuration file, not tool arguments. Read-only transactions are not a sandbox for privileged functions or external side effects. See [SECURITY.md](SECURITY.md) for the trust boundaries. +**Access mode defaults to unrestricted; SQL calls still default to `read_only=true`.** Writes require an explicit `read_only=false` on `execute_sql` and PostgreSQL permission. Opt into `restricted` mode to refuse those write requests. Use least-privileged PostgreSQL roles and keep credentials in the prepared process environment or an optional protected configuration file, not tool arguments. Read-only transactions are not a sandbox for privileged functions or external side effects. See [SECURITY.md](SECURITY.md) for the trust boundaries. The server, npm installation helper, packaging, verification and release automation are C#/.NET. No maintained JavaScript, Python or Bash implementation is required. npm itself requires Node.js for installation; the installed server runs directly as a .NET executable, without a Node process. Workflow badges and Scorecard report checks and practices, not certifications or profile achievements. Best Practices shows the saved owner self-assessment, which may still be in progress. See [Security posture](docs/security-posture.md) for supply-chain evidence and limits. ## Quick start -### 1. Install or run +**Version boundary:** automatic discovery and the unrestricted default require **0.3.0 or newer**. This guide targets **0.3.1**, the environment-first patch follow-on, not another discovery/minor-version boundary. Older 0.2.0 executables still use configured database aliases and a restricted default. Check the version of the [release archive](https://github.com/codegiveness/postgresql-sharp-mcp/releases/latest) or registry package before installing; a [source build](CONTRIBUTING.md#build-from-source) or verified local package is an alternative when publication is unavailable. Changing configuration does not upgrade an old executable. + +**When a compatible release is available, the release archive is the simplest first install.** It includes the .NET runtime: no .NET SDK, npm, Node.js or Docker installation is needed for that route. You still need an existing PostgreSQL database and an MCP client. This app does not create a database or automatically edit your client's configuration. + +For one PostgreSQL server, prepare a session environment and register **one nonsecret MCP configuration file**. No `targets.json` is required: + +| Item | Purpose | Where it goes | +|---|---|---| +| `PostgreSqlMcp` (`PostgreSqlMcp.exe` on Windows) | The server your MCP client launches | `postgresql-mcp/app/` inside your home folder for the archive route | +| `POSTGRES_CONNECTION_STRING` | Your Npgsql bootstrap connection details, including credentials | The prepared shell's process environment, inherited by the client and server | +| Your client's MCP configuration, such as OMP's `mcp.json` | Tells the client which executable to launch, with nonsensitive options | The MCP client's configuration location, **not** the app folder | -**GitHub release archives:** download a self-contained archive from [GitHub Releases](https://github.com/codegiveness/postgresql-sharp-mcp/releases) for installation without registry access. +Follow steps 1–4 in order. Use the same paths throughout. + +**Already installed?** If your executable is 0.2.0, upgrade the binary to use automatic discovery. With 0.3.0 or newer, use your actual executable path below. Existing protected targets files and names remain supported: keep them if you choose the [optional file workflow](#optional-protected-targets-file). Switching to the environment workflow is manual; step 2 removes stale settings from the current shell, and step 3 explains which client entries to remove. Do not delete your protected files merely to switch workflows. + +**Breaking behavior:** a targets-file entry is now a connection profile/seed, not a database allowlist. The same credentials can select other physical databases on that PostgreSQL server. Omitted access mode now means `unrestricted`, and `list_databases` returns a live database page instead of configured aliases. Keep an existing explicit `POSTGRES_ACCESS_MODE=restricted` if you want to continue refusing write requests; it is not overridden by the new default. Review PostgreSQL grants before upgrading. + +### 1. Install or run -Choose `postgresql-sharp-mcp-.tar.gz` on Linux/macOS or `postgresql-sharp-mcp-win-x64.zip` on Windows for releases from 0.2.0. Older releases use `.tar.gz` on Windows too. +For the archive route, open [the latest release](https://github.com/codegiveness/postgresql-sharp-mcp/releases/latest), check its version against the boundary above, and download **one** archive. If it is still 0.2.0, use the source/local-package route instead: -| Platform | RID | +| Your computer | Archive to download | |---|---| -| Linux x64 / ARM64 | `linux-x64` / `linux-arm64` | -| macOS Intel / Apple Silicon | `osx-x64` / `osx-arm64` | -| Windows x64 | `win-x64` | +| Linux, Intel/AMD 64-bit | `postgresql-sharp-mcp-linux-x64.tar.gz` | +| Linux, ARM64 | `postgresql-sharp-mcp-linux-arm64.tar.gz` | +| Mac, Apple Silicon (M-series) | `postgresql-sharp-mcp-osx-arm64.tar.gz` | +| Mac, Intel | `postgresql-sharp-mcp-osx-x64.tar.gz` | +| Windows, Intel/AMD 64-bit | `postgresql-sharp-mcp-win-x64.zip` | -Extract the archive into a dedicated directory. On Linux/macOS, run `./PostgreSqlMcp --version`; on Windows, run `.\PostgreSqlMcp.exe --version`. Self-contained archives do not require a separate .NET runtime, but still require platform-native libraries. GSS/Kerberos connections on Debian/Ubuntu require `libgssapi-krb5-2`; see [Npgsql security](https://www.npgsql.org/doc/security.html) for authentication and TLS configuration. +Also download `SHA256SUMS` from **that same release**. Before extracting, run the checksum command for your OS below and compare its hash with the entry for your archive in `SHA256SUMS` (ignore uppercase/lowercase differences). Stop if the hashes differ. -**npm and NuGet alternatives:** these commands require the package to be available in the corresponding registry. If a package is unavailable, use a GitHub release archive or the [source build instructions](CONTRIBUTING.md). +These commands assume the archive is in your `Downloads` folder. Change that location if you saved it elsewhere. -Using npm requires **Node.js 22 or newer** for npm/npx installation and the **.NET 10 runtime** with `dotnet` on `PATH` during installation. The C# `postinstall` selects a bundled native apphost; it never downloads binaries or a runtime. npm 12 blocks unapproved dependency lifecycle scripts: approve only this package with `--allow-scripts` for npx/global installation, as below. For a custom .NET installation, also set `DOTNET_ROOT` to its installation directory so the native apphost can locate it: +**Linux x64:** ```bash -npx -y --allow-scripts=@codegiveness/postgresql-sharp-mcp @codegiveness/postgresql-sharp-mcp --version +sha256sum "$HOME/Downloads/postgresql-sharp-mcp-linux-x64.tar.gz" ``` -Or install the .NET tool using the **.NET 10 SDK**: +If the hash matches, extract and check the executable: ```bash -dotnet tool install --global codegiveness.postgresql-sharp-mcp -postgresql-sharp-mcp --version +mkdir -p "$HOME/postgresql-mcp/app" +tar -xzf "$HOME/Downloads/postgresql-sharp-mcp-linux-x64.tar.gz" -C "$HOME/postgresql-mcp/app" +"$HOME/postgresql-mcp/app/PostgreSqlMcp" --version ``` -Both packages run the same framework-dependent .NET server. After npm installation, `postgresql-sharp-mcp` starts the native apphost directly; no JavaScript launcher or Node child process relays MCP or signals. The internal npm executable has an `.exe` filename on every OS, but the public command remains `postgresql-sharp-mcp`. Keep .NET available when launching either package. To avoid npm and Node altogether, use the NuGet tool or a self-contained release archive. +For Linux ARM64, replace the archive filename in both commands with `postgresql-sharp-mcp-linux-arm64.tar.gz`. -### 2. Configure database targets +**macOS, Apple Silicon:** -Create a JSON file outside the repository, readable only by the server's OS user or an appropriately restricted group. Replace every `<...>` placeholder with your PostgreSQL connection details: +```bash +shasum -a 256 "$HOME/Downloads/postgresql-sharp-mcp-osx-arm64.tar.gz" +``` -```json -{ - "tenant_a": "Host=;Port=;Database=;Username=;Password=;SSL Mode=VerifyFull", - "tenant_b": "Host=;Port=;Database=;Username=;Password=;SSL Mode=VerifyFull" +If the hash matches, extract and check the executable: + +```bash +mkdir -p "$HOME/postgresql-mcp/app" +tar -xzf "$HOME/Downloads/postgresql-sharp-mcp-osx-arm64.tar.gz" -C "$HOME/postgresql-mcp/app" +"$HOME/postgresql-mcp/app/PostgreSqlMcp" --version +``` + +For an Intel Mac, replace the archive filename in both commands with `postgresql-sharp-mcp-osx-x64.tar.gz`. + +**Windows:** open PowerShell and run: + +```powershell +Get-FileHash "$HOME\Downloads\postgresql-sharp-mcp-win-x64.zip" -Algorithm SHA256 +``` + +If the hash matches, extract and check the executable: + +```powershell +New-Item -ItemType Directory -Force "$HOME\postgresql-mcp\app" | Out-Null +Expand-Archive -Path "$HOME\Downloads\postgresql-sharp-mcp-win-x64.zip" -DestinationPath "$HOME\postgresql-mcp\app" +& "$HOME\postgresql-mcp\app\PostgreSqlMcp.exe" --version +``` + +**Checkpoint:** you should see `postgresql-sharp-mcp` followed by **0.3.0 or newer** for discovery; a build of this revision should report **0.3.1**. If the available archive is still 0.2.0, stop and use the source/local-package route above instead. Keep all extracted files together; do not copy just the executable. + +Your app folder should now look like this: + +```text +postgresql-mcp/ + app/ + PostgreSqlMcp (PostgreSqlMcp.exe on Windows) + ...other release files... +``` + +On Debian/Ubuntu, a missing GSS/Kerberos native library may require `libgssapi-krb5-2`. Other authentication/TLS requirements are described in [Npgsql security](https://www.npgsql.org/doc/security.html). + +### 2. Prepare the connection environment + +Enter one complete **Npgsql `key=value` connection string** at the hidden prompt below. Replace every `<...>` placeholder, including angle brackets, using your provider's details; change the port if needed. This text block describes the format—it is not a command to execute or a JSON value: + +```text +Host=;Port=5432;Database=;Username=;Password=;SSL Mode=VerifyFull +``` + +| Field | What to enter | +|---|---| +| `Host` | The database hostname supplied by your provider, or `127.0.0.1` for a database on this same computer | +| `Port` | PostgreSQL's port, usually `5432`; use your provider's port if different | +| `Database` | An accessible bootstrap database on the server, such as your existing database or `postgres`; it is used for live discovery | +| `Username` | A PostgreSQL login role with CONNECT to the bootstrap and intended databases, preferably with read-only object privileges—not necessarily your computer's username | +| `Password` | That PostgreSQL role's password | +| `SSL Mode` | `VerifyFull` for a remote TLS-enabled database; see the local-only exception below | + +For a PostgreSQL server on **this same computer** that does not support TLS, use `Host=127.0.0.1` and replace `SSL Mode=VerifyFull` with `SSL Mode=Disable`. Do not disable TLS to work around a remote server's certificate error. For a remote server needing a provider CA certificate, retain `VerifyFull` and add `Root Certificate=`; follow your provider's [TLS requirements](https://www.npgsql.org/doc/security.html). + +If your provider gives you a `postgres://` or `postgresql://` URL, it cannot be entered unchanged. Map its hostname, port, database, username and password to the fields above, decode URL-escaped values, and preserve required TLS/authentication options. Prefer the provider's **.NET/Npgsql connection string** when available. + +**Quoting at the prompt has only one layer: Npgsql, not JSON or shell syntax.** Quote a value containing a semicolon, for example the synthetic fragment `Password="sample;value"`. Inside a double-quoted value, double a literal double quote (`Password="sample""value"`). Do not add JSON's `\"` or double backslashes for JSON when entering the full string at the prompt. Prefer a provider-generated Npgsql string over constructing one by hand. + +#### Bash hidden credential prompt + +**Linux/macOS — run these commands in Bash.** On macOS the default shell may be zsh: first run `bash`, then stay in that Bash shell for validation and launching the client, or use the native Zsh function below. A child Bash cannot export its changes back to the parent Zsh. These commands clear conflicting target settings from this shell, hide typed input and export it to future children: + +```bash +unset POSTGRES_TARGETS POSTGRES_TARGETS_FILE +IFS= read -r -s -p 'Npgsql connection string (hidden): ' POSTGRES_CONNECTION_STRING +printf '\n' +if [ -n "$POSTGRES_CONNECTION_STRING" ]; then + export POSTGRES_CONNECTION_STRING +else + unset POSTGRES_CONNECTION_STRING + printf 'No connection string entered; repeat this step before continuing.\n' >&2 +fi +``` + +#### PowerShell hidden credential prompt + +**Windows — PowerShell 7.1 or newer (`pwsh`) in an interactive terminal, not with piped/redirected input.** `-MaskInput` is not supported by built-in Windows PowerShell 5.1. Check `$PSVersionTable.PSVersion` and open a supported PowerShell before using this block. The prompt returns a plaintext string into the process environment: + +```powershell +Remove-Item Env:POSTGRES_TARGETS, Env:POSTGRES_TARGETS_FILE -ErrorAction SilentlyContinue +$env:POSTGRES_CONNECTION_STRING = Read-Host 'Npgsql connection string (hidden)' -MaskInput +if ([string]::IsNullOrWhiteSpace($env:POSTGRES_CONNECTION_STRING)) { + Remove-Item Env:POSTGRES_CONNECTION_STRING -ErrorAction SilentlyContinue + throw 'No connection string entered; repeat this step before continuing.' +} +``` + +Keep this shell open through steps 3–4. Do not put a literal real secret into a command, shell history/profile, `setx`, MCP JSON, chat or public bug reports. Masked entry avoids displaying the input; it is **not encrypted environment storage**. This session environment is plaintext in process memory, can be inspected by sufficiently privileged local processes, and is inherited by child processes. It is not automatically persisted across sessions, but launchers, supervisors, container tooling or deliberate persistence can store it. It is not inherently safer than an owner-only file; see [credential storage tradeoffs](SECURITY.md#credentials-and-diagnostics). + +The connection string alone creates the **`primary` connection profile**, not a database allowlist. `"database":"primary"` selects its bootstrap database; `"database":"tenant_b"` selects the real `tenant_b` database with the same host, credentials and TLS. Newly accessible databases need no recurring JSON edits or server restart. See [database selection](#database-selection-and-live-discovery). + +**Checkpoint:** input was entered at the hidden prompt, not as a command. Do not print the variable to check it; use step 4's connectivity check. When migrating, also remove stale client `env` entries in step 3: combining a connection string with targets JSON/file is rejected, not silently prioritized. + +#### Optional reusable Bash/Zsh prompt in your rc file + +You may save a **function definition without credentials** in your shell startup file, then invoke it when needed. Edit/add the function to your chosen file, creating that file if absent; preserve all existing configuration rather than replacing the file. Do not save the entered connection string, an automatic secret export/echo, or an automatic invocation in an rc/profile file. The function only prepares a session when you explicitly call it; the same plaintext/inheritance limits above still apply. + +- **Bash:** put the Bash function below in `~/.bashrc` for interactive non-login shells. Login Bash reads the first existing, readable file in this order: `~/.bash_profile`, `~/.bash_login`, `~/.profile`—not all three. It does not automatically read `.bashrc`. If needed, have your existing Bash login file conditionally source `.bashrc` when it exists and the shell is interactive Bash, rather than copying the function into every file or replacing existing login configuration. Noninteractive Bash does not normally load `.bashrc`. Do not put Bash-specific commands into a shared `.profile` used by other shells. See [Bash startup files](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html). +- **Zsh:** put the Zsh function below in `~/.zshrc` (or `$ZDOTDIR/.zshrc` if customized). Interactive Zsh reads it, including interactive login shells; `~/.zprofile` is for login startup, not a substitute for `.zshrc` in non-login terminals. Do not put a secret prompt in `.zshenv`, which is used by noninteractive shells too. See [Zsh startup files](https://zsh.sourceforge.io/Doc/Release/Files.html). + +**Bash function — add only to your chosen Bash rc file:** + +```bash +postgres_mcp_env() { + unset POSTGRES_TARGETS POSTGRES_TARGETS_FILE POSTGRES_CONNECTION_STRING + if ! IFS= read -r -s -p 'Npgsql connection string (hidden): ' POSTGRES_CONNECTION_STRING; then + printf '\n' + unset POSTGRES_CONNECTION_STRING + return 1 + fi + printf '\n' + if [ -z "$POSTGRES_CONNECTION_STRING" ]; then + unset POSTGRES_CONNECTION_STRING + printf 'No connection string entered.\n' >&2 + return 1 + fi + export POSTGRES_CONNECTION_STRING } ``` -Each target requires an explicit `Host` and `Database`. Choose authentication and TLS settings appropriate to your deployment; `VerifyFull` requires a trusted server certificate and matching hostname. Do not commit the populated file or make it world-readable. +**Zsh function — add only to your chosen Zsh rc file:** + +```zsh +postgres_mcp_env() { + unset POSTGRES_TARGETS POSTGRES_TARGETS_FILE POSTGRES_CONNECTION_STRING + if ! IFS= read -r -s 'POSTGRES_CONNECTION_STRING?Npgsql connection string (hidden): '; then + printf '\n' + unset POSTGRES_CONNECTION_STRING + return 1 + fi + printf '\n' + if [ -z "$POSTGRES_CONNECTION_STRING" ]; then + unset POSTGRES_CONNECTION_STRING + printf 'No connection string entered.\n' >&2 + return 1 + fi + export POSTGRES_CONNECTION_STRING +} +``` -The `database` tool argument is the **exact, case-sensitive alias** from this object, not an arbitrary PostgreSQL database name. Targets may use independent hosts and credentials. Restart the server after changing targets or credentials. +Zsh's [`read -p`](https://zsh.sourceforge.io/Doc/Release/Shell-Builtin-Commands.html#index-REPLY_002c-use-of-2) reads from a coprocess; it is **not Bash's prompt option**. The Zsh function uses its native `name?prompt` form instead. Do not paste the Bash prompt block into Zsh unchanged. + +After adding the function and saving the chosen file, reload **only the rc file for your current shell**: `source "$HOME/.bashrc"` in Bash **or** `source "${ZDOTDIR:-$HOME}/.zshrc"` in Zsh, not both. Review that file before sourcing: sourcing executes all its commands. After completing step 3's client registration, in that same shell, `postgres_mcp_env && omp` prompts and launches OMP only on success; for another client, replace `omp` with its documented launcher. Fully quit an existing client first. Do not run the function in a subprocess or a separate script and expect it to change the parent shell. No protected credential file is modified or deleted by these functions. ### 3. Add a stdio MCP client entry -Adapt this example to your client's configuration schema. Only a configuration-file path goes into the client entry: +**This step is required. Installing the app alone does not make its tools appear in your client.** The client starts the server for you; you do not need to leave a separate server terminal running. + +For **OMP**, edit or create `~/.omp/agent/mcp.json` on Linux/macOS, or `%USERPROFILE%\.omp\agent\mcp.json` on Windows. If you use a named OMP profile, edit that profile's MCP configuration instead. For another MCP client, open that client's MCP/server configuration; the examples below use the `mcpServers` format, not every client's schema. -For an extracted release archive: +If the file already contains other servers, add only the `"postgresql": { ... }` entry **inside its existing `mcpServers` object**. Preserve the other entries and separate adjacent entries with a comma. Do not add a second `mcpServers` object. + +For a new configuration file, use the whole example for your OS below. Replace **`YOUR_USER` in the executable path** with your home-folder name. On Linux/macOS, `echo "$HOME"` shows your home path; on Windows, `$HOME` in PowerShell shows it. If your home is elsewhere, replace the entire example home prefix with that actual path. Use full absolute paths in JSON, not literal `~`, `$HOME` or `%USERPROFILE%`. These examples rely on inherited process environment; they do not assume a client's `${ENV}` interpolation. + +
+Linux: complete MCP configuration ```json { "mcpServers": { "postgresql": { - "command": "", + "type": "stdio", + "command": "/home/YOUR_USER/postgresql-mcp/app/PostgreSqlMcp", + "timeout": 30000, "env": { - "POSTGRES_TARGETS_FILE": "" + "POSTGRES_QUERY_TIMEOUT": "10" } } } } ``` -Use `PostgreSqlMcp.exe` on Windows. With the npm package, use: +
+ +
+macOS: complete MCP configuration ```json { "mcpServers": { "postgresql": { - "command": "npx", - "args": ["-y", "--allow-scripts=@codegiveness/postgresql-sharp-mcp", "@codegiveness/postgresql-sharp-mcp"], + "type": "stdio", + "command": "/Users/YOUR_USER/postgresql-mcp/app/PostgreSqlMcp", + "timeout": 30000, "env": { - "POSTGRES_TARGETS_FILE": "" + "POSTGRES_QUERY_TIMEOUT": "10" } } } } ``` -For the globally installed .NET tool, use `"command": "postgresql-sharp-mcp"` and omit `args`. If the MCP client does not inherit the tool directory on `PATH`, use the absolute executable path. +
-**Windows:** escape backslashes in JSON paths. npm exposes `npx.cmd`; clients that cannot launch command scripts directly can use `"command": "cmd"` with `"args": ["/d", "/c", "npx", "-y", "--allow-scripts=@codegiveness/postgresql-sharp-mcp", "@codegiveness/postgresql-sharp-mcp"]`, or use the installed .NET tool executable instead. +
+Windows: complete MCP configuration + +```json +{ + "mcpServers": { + "postgresql": { + "type": "stdio", + "command": "C:\\Users\\YOUR_USER\\postgresql-mcp\\app\\PostgreSqlMcp.exe", + "timeout": 30000, + "env": { + "POSTGRES_QUERY_TIMEOUT": "10" + } + } + } +} +``` + +
+ +`command` points to the **executable**, not its folder or downloaded archive. The `env` object contains only nonsensitive options; `POSTGRES_CONNECTION_STRING` comes from the client's inherited environment, not this JSON. These examples omit access mode, so it is **unrestricted**; SQL calls remain read-only unless they explicitly set `read_only=false`. To refuse writes at the server, add `"POSTGRES_ACCESS_MODE": "restricted"` to `env`. OMP's `timeout` is in milliseconds (`30000` = 30 seconds); `POSTGRES_QUERY_TIMEOUT` is in seconds (`10` = 10 seconds). Other clients may use different timeout settings and environment policies; ensure yours passes its inherited environment to stdio servers. + +**Migrating an existing entry:** remove `POSTGRES_TARGETS`, `POSTGRES_TARGETS_FILE` and any literal `POSTGRES_CONNECTION_STRING` from the client's PostgreSQL `env` object, along with `--targets-file`/`--connection-string` launch arguments. Keep other nonsensitive settings, especially an intentional restricted access mode. Clear stale target variables in step 2; do not combine the two credential sources and expect one to win. Protected files may remain on disk for later use. + +**Checkpoint:** save the client configuration. The executable exists at the exact `command` path, no `YOUR_USER` placeholder remains, and no credentials or targets-file setting were added to the primary MCP JSON examples. ### 4. Validate and connect -For an extracted release archive on Linux/macOS: +In the **same prepared Bash/PowerShell session from step 2**, first check database connectivity, before troubleshooting the MCP client. Substitute your source/local-package executable if you did not install an archive. + +**Linux / macOS:** ```bash -./PostgreSqlMcp --targets-file "" --validate +"$HOME/postgresql-mcp/app/PostgreSqlMcp" --validate +``` + +**Windows PowerShell:** + +```powershell +& "$HOME\postgresql-mcp\app\PostgreSqlMcp.exe" --validate +``` + +**Checkpoint:** the command should exit successfully and print a result with your actual database name. That output is on **stderr** and can contain private database identity; keep it private. `--validate` performs a read-only check and exits. It does **not** register the server or start an ongoing MCP session. + +**Start the MCP client from this same prepared shell**, so the client—and the server it launches—inherits `POSTGRES_CONNECTION_STRING`. For OMP, run `omp` in that shell. For another client, use its documented executable/launcher that starts a new process from that shell. Fully quit any existing client first, including background/tray processes: opening a new window may reuse an old process. A desktop/Start-menu launch or a client already running in another terminal does not acquire this shell's changed environment. `/mcp reload` alone cannot repair an environment the client never inherited. + +**After changing the session secret**, fully quit the client, repeat step 2 in the shell and launch a new client process from that shell. Restarting only its server from an old client retains the old inherited value. If your GUI client cannot be launched this way or strips inherited variables, use the [optional protected file workflow](#optional-protected-targets-file) instead of persisting secrets in MCP JSON. + +Call `list_databases` with no arguments. It connects to `primary`'s bootstrap database and returns real accessible database names in `databases.rows`, not the alias `primary`. Then call: + +```json +{"name":"execute_sql","arguments":{"database":"primary","sql":"SELECT 1 AS connection_ok","limit":1}} ``` -On Windows, use `.\PostgreSqlMcp.exe` with the same arguments. With npm: +**You are ready when the SQL call succeeds with one row containing `1`.** In OMP you can ask: “Use the PostgreSQL MCP server to run `SELECT 1 AS connection_ok` against the bootstrap database using alias `primary`.” No extensions or application tables are needed for this check. + +To query another discovered database, replace `"database":"primary"` with its real name, for example `"database":"tenant_b"`. Discovery checks CONNECT permission, but PostgreSQL still enforces connection rules, object privileges and RLS on the actual call. Database creation and grant changes need no configuration edits. For environment credential changes, fully restart the client from the newly prepared shell as above. + +When finished, fully quit the client/server, then clear the shell value with `unset POSTGRES_CONNECTION_STRING` in Bash or `Remove-Item Env:POSTGRES_CONNECTION_STRING -ErrorAction SilentlyContinue` in PowerShell. Unsetting the parent value does not erase copies already inherited by running children or deliberately persisted elsewhere. + +### Optional protected targets file + +Choose this **instead of step 2's connection-string environment** when you need multiple profiles with independent credentials, or automation/GUI launch where session environment propagation is impractical. It is not required for one-server discovery. Existing `targets.json`, `targets-0.2.0.json` or other protected file names continue to work: keep the file and use its actual path, with no rename or per-database entries required. + +Before switching to the file route, fully quit the client/server. Clear `POSTGRES_CONNECTION_STRING` from the launching environment (`unset POSTGRES_CONNECTION_STRING` in Bash; `Remove-Item Env:POSTGRES_CONNECTION_STRING -ErrorAction SilentlyContinue` in PowerShell), and remove any client `env` entry or `--connection-string` argument for it. Clear stale `POSTGRES_TARGETS` too, because that JSON source takes precedence over a file. Combining a connection string and targets JSON/file fails closed. + +For a **new** Linux/macOS file, create private permissions before adding credentials: ```bash -npx -y --allow-scripts=@codegiveness/postgresql-sharp-mcp @codegiveness/postgresql-sharp-mcp --targets-file "" --validate +mkdir -p "$HOME/postgresql-mcp" +chmod 700 "$HOME/postgresql-mcp" +touch "$HOME/postgresql-mcp/targets.json" +chmod 600 "$HOME/postgresql-mcp/targets.json" ``` -With the .NET tool, use the same arguments after `postgresql-sharp-mcp`. `--validate` opens each configured target, reports its actual `current_database()` or a sanitized error to **stderr**, and exits 0 only if all targets work. It does not start MCP. +On Windows, keep the folder and file accessible only to your account and administrators using Windows permissions. In Notepad's Save As dialog choose **All files** to avoid `targets.json.txt`. Do not use a shared or publicly synced folder. OS permissions do not encrypt file contents; consider backup/sync exposure and privileged local access. -Restart your MCP client, call `list_databases`, then try: +Use a plain-text editor to populate the JSON object with a bootstrap connection string using step 2's field/TLS guidance: ```json -{"name":"execute_sql","arguments":{"database":"tenant_a","sql":"SELECT current_database()","limit":1}} +{ + "primary": "Host=;Port=5432;Database=;Username=;Password=;SSL Mode=VerifyFull" +} ``` -Normal operation reserves stdout for MCP JSON-RPC and logs to stderr. `list_databases` reports allowlisted aliases, access mode and limits without opening a connection; a listed alias does not guarantee that its database is reachable. +Unlike the hidden prompt, this file has **two escaping layers**: Npgsql followed by JSON. For example, the synthetic Npgsql fragment `Password="sample;value"` becomes `Password=\"sample;value\"` inside a JSON string. Double a literal double quote inside an Npgsql double-quoted value, then escape each quote for JSON; JSON backslashes must also be doubled. A JSON-aware editor/serializer can help. No trailing commas are allowed. Do not commit the file or paste its contents into chat/public reports. + +For multiple profiles, add additional case-sensitive keys with their own bootstrap strings (for example `reporting`), not an entry per physical database. Each key is a profile/seed, **not an allowlist**. Use optional `target` to choose one explicitly; see [selection rules](#database-selection-and-live-discovery). + +In the step 3 client entry, add only the file path as `"POSTGRES_TARGETS_FILE"` in `env`, alongside nonsensitive options. Use your real absolute path: `/home/YOUR_USER/postgresql-mcp/targets.json` on Linux, `/Users/YOUR_USER/postgresql-mcp/targets.json` on macOS, or `C:\\Users\\YOUR_USER\\postgresql-mcp\\targets.json` in Windows JSON. The path points to the credentials file, not the client's MCP JSON. Do not add a connection-string value too. + +Validate this alternative in the shell where the conflicting variables were cleared: + +```bash +"$HOME/postgresql-mcp/app/PostgreSqlMcp" --targets-file "$HOME/postgresql-mcp/targets.json" --validate +``` + +```powershell +& "$HOME\postgresql-mcp\app\PostgreSqlMcp.exe" --targets-file "$HOME\postgresql-mcp\targets.json" --validate +``` + +Use your actual executable/file paths if different. Keep validation stderr private. Reload the saved client configuration (OMP: `/mcp reload`) or fully restart the client to start the server with this file. After editing credentials/profiles, restart the server; new physical databases and grant changes still need no file edits or restart. A GUI file workflow avoids relying on a session secret, but any conflicting inherited connection string must still be removed before launching it. + +### If setup does not work + +| What you see | What to check | +|---|---| +| Executable not found / spawn error | `command` must be the full executable path, including `.exe` on Windows. Extract the archive first and keep its files together. | +| Wrong architecture / cannot execute binary | Download the archive matching your OS and CPU from step 1. | +| Invalid configuration / missing credentials | Repeat the hidden prompt, remove stale targets variables/client entries, and fully launch the client from the prepared shell. A base string combined with targets JSON/file is rejected. Do not enter a PostgreSQL URI unchanged. For the optional file route, check the exact path, JSON quotes/commas and accidental `.txt` extension. | +| Connection refused, timeout or authentication error | Run step 4's `--validate` command. Check host, port, database, login/password, network/VPN and the database's access rules. Installation does not grant database access. | +| Certificate validation error | Use the provider's correct hostname and CA certificate. Do not disable TLS for a remote database. | +| No PostgreSQL tools in the client | Check its configuration location/schema and executable path, then fully quit and launch it from the prepared shell. MCP reload can refresh a saved nonsecret entry only after the client has the correct environment. | +| Invalid target / database missing or denied | `target` must match a configured profile such as `primary`. `database` can be a real name; check spelling, CONNECT and object permissions. There is no fallback to the bootstrap database. | +| Server seems to wait silently when run without `--validate` | Normal: stdio mode waits for MCP messages from a client. Use `--validate` for a terminal connectivity check; let the client launch normal mode. | + +Normal operation reserves stdout for MCP JSON-RPC; diagnostics go to stderr. Keep real credentials, SQL and database results out of public troubleshooting reports. + +### Other installation methods + +Finish the same environment and client-configuration steps above with whichever executable you install. **Choose one install method; do not install all of them.** Registry examples below require an available compatible version. If the registry lacks 0.3.1 or newer, use a compatible release archive or this revision's source/local-package route, not an older registry executable. + +**NuGet/.NET tool:** requires the **.NET 10 SDK** to install, the **.NET 10 runtime** to run, and the package to be available on NuGet.org: + +```bash +dotnet tool install --global codegiveness.postgresql-sharp-mcp +postgresql-sharp-mcp --version +``` + +In the client configuration, replace `command` with the installed tool's absolute path: normally `~/.dotnet/tools/postgresql-sharp-mcp` on Linux/macOS or `%USERPROFILE%\.dotnet\tools\postgresql-sharp-mcp.exe` on Windows. Expand the home path before putting it in JSON. For a custom `--tool-path` installation, use that directory's executable instead; a path containing a version such as `tools/0.2.0/` is valid but not required. Keep the .NET runtime available to the client; nonstandard installations may require `DOTNET_ROOT`. + +If the package is unavailable on NuGet.org, use a compatible release archive or [install a verified release `.nupkg` locally](CONTRIBUTING.md#install-a-verified-release-artifact). Local artifact installation does not establish registry publication. Do not point the MCP client directly at a `.nupkg`. + +**npm/npx:** requires **Node.js 22 or newer**, the **.NET 10 runtime** with `dotnet` on `PATH` during installation, and the package to be available in npm: + +```bash +npx -y --allow-scripts=@codegiveness/postgresql-sharp-mcp @codegiveness/postgresql-sharp-mcp --version +``` + +Use `"command": "npx"` with `"args": ["-y", "--allow-scripts=@codegiveness/postgresql-sharp-mcp", "@codegiveness/postgresql-sharp-mcp"]` in place of the archive executable; keep the same `env` object from step 3. npm 12 requires this package's lifecycle script approval. On Windows, clients unable to launch `npx.cmd` directly can use `"command": "cmd"` and `"args": ["/d", "/c", "npx", "-y", "--allow-scripts=@codegiveness/postgresql-sharp-mcp", "@codegiveness/postgresql-sharp-mcp"]`. + +The C# installer uses bundled native apphosts; it does not download a runtime or binaries. After installation the server runs directly as .NET, without a JavaScript launcher or Node child process. Custom .NET installations also need an appropriate `DOTNET_ROOT`. + +**Build from source:** follow [CONTRIBUTING.md](CONTRIBUTING.md#build-from-source), then use `dotnet` as `command` and the absolute path to `PostgreSqlMcp.dll` as its first `args` item. Keep step 3's nonsensitive `env` object and launch the client from the shell prepared in step 2. ## Tools -Except `list_databases`, every tool **requires `database`**. Query and metadata page defaults are `min(100, POSTGRES_MAX_ROWS)`; top-query pages default to `min(10, POSTGRES_MAX_ROWS)`. +Except `list_databases`, every tool **requires `database`**. Every tool accepts optional `target` to select a configured connection profile. Query and metadata page defaults are `min(100, POSTGRES_MAX_ROWS)`; top-query pages default to `min(10, POSTGRES_MAX_ROWS)`. | Tool | Capability | Options | |---|---|---| -| `list_databases` | Configured aliases and limits; no database roundtrip | `limit`, `offset` | +| `list_databases` | Live accessible physical databases, selected profile, access mode and limits | `target`, `limit`, `offset` | | `list_schemas` | Schemas with USAGE privilege | literal `prefix`, `include_system`, page | | `list_objects` | Tables, views, materialized views, sequences, functions, procedures and extensions | `schema`, `type`, literal `search`, `include_system`, page | | `get_object_details` | One object's metadata section | `schema`, `name`, `section`: columns/constraints/indexes/triggers/definition/parameters; `type`, `identity_arguments`, page | @@ -148,18 +436,40 @@ Except `list_databases`, every tool **requires `database`**. Query and metadata Routine overloads require the exact `identity_arguments` from `list_objects`, including parameter names; an empty string selects zero arguments. Use `type` to disambiguate relation/routine name collisions. Discovery filters by role privileges; missing or hidden objects return an error. Table definitions are structural fragments, not a round-trip DDL export. -Each call resolves its own immutable target and leases a connection from its pool. Calls against different targets can run concurrently. An invalid alias, denied login or failed connection returns an error for that target; it never falls back to another database. +Each call independently resolves its physical database and leases a connection with that database in its connection string. Concurrent calls do not share a current database or issue `USE`; a failed selection never falls back to another database. Focused inspection: ```json -{"name":"list_objects","arguments":{"database":"tenant_a","schema":"public","type":"table","search":"order","limit":20}} +{"name":"list_objects","arguments":{"database":"primary","schema":"public","type":"table","search":"order","limit":20}} ``` ```json -{"name":"get_object_details","arguments":{"database":"tenant_a","schema":"public","name":"orders","section":"indexes","limit":10}} +{"name":"get_object_details","arguments":{"database":"primary","schema":"public","name":"orders","section":"indexes","limit":10}} ``` +### Database selection and live discovery + +A profile is a case-sensitive name mapped to a bootstrap connection string: `POSTGRES_CONNECTION_STRING` alone creates `primary`, while optional targets JSON/file defines named profiles. The default profile is `primary` when configured, otherwise the ordinal-first profile name. + +- **Without `target`:** an exact configured alias in `database` selects that alias's bootstrap database for compatibility. Any other value is a physical database name on the default profile. +- **With `target`:** that profile supplies the host, authentication and TLS settings; `database` is always a physical database name. Use this form to select a database whose name happens to match an alias. +- Only the connection string's `Database` changes. Tool calls cannot supply credentials, change the host, or rewrite the protected file. An unknown profile returns `invalid_target`; a nonexistent physical database returns PostgreSQL SQLSTATE `3D000`, and insufficient privilege returns `42501`. Connectivity failures remain errors, not fallback requests. + +For example, discover and select databases on the `primary` profile: + +```json +{"name":"list_databases","arguments":{"target":"primary","limit":20,"offset":0}} +``` + +```json +{"name":"execute_sql","arguments":{"target":"primary","database":"tenant_b","sql":"SELECT current_database() AS selected_database","limit":1}} +``` + +`list_databases` queries live `pg_catalog.pg_database` on the selected profile's bootstrap database. It excludes templates, databases with connections disabled and databases for which the current role lacks CONNECT; an optional explicit allowlist further filters the page. Names are ordered with PostgreSQL `COLLATE "C"`, and `is_current` marks the bootstrap database used for that listing. New databases and grant/revoke changes appear on subsequent calls without restarting; listing does not guarantee network/authentication or object access for a later connection. + +The response is `{ "target": "...", "databases": { ... }, "access_mode": "...", "limits": { ... } }`. `databases` is the same bounded query-page shape described below, with `columns` named `name` and `is_current`, positional `rows` such as `[["postgres",true],["tenant_b",false]]`, and `offset`, `next_offset`, `truncated`, `truncation_reason` and `clipped_cells`. Follow `databases.next_offset` using the same `target` and `limit`; pages are live queries, not a shared snapshot. The obsolete `targets` alias-list payload is no longer returned. Catalog failures are reported rather than silently replaced with configured aliases. + ### Plans and optional extensions No extensions are installed automatically. An authorized administrator must configure extensions in the databases where they are needed: @@ -178,7 +488,7 @@ WHERE name IN ('hypopg', 'pg_stat_statements'); Example what-if call: ```json -{"name":"explain_query","arguments":{"database":"tenant_a","sql":"SELECT * FROM public.orders WHERE customer=42","indexes":["CREATE INDEX ON public.orders(customer)"]}} +{"name":"explain_query","arguments":{"database":"primary","sql":"SELECT * FROM public.orders WHERE customer=42","indexes":["CREATE INDEX ON public.orders(customer)"]}} ``` Hypothetical candidates are passed to HypoPG, **not executed as permanent DDL**. The tool compares baseline and combined-candidate planner costs in one session, cleans hypothetical indexes after success or failure, and clears the pool if cleanup fails. At most 16 candidates are accepted; `analyze=true` cannot be combined with hypothetical indexes. Independent candidate commands are batched into one database round trip. @@ -193,7 +503,7 @@ Successful results provide a JSON object in MCP `structuredContent` and a compac ```json { - "database":"tenant_a", + "database":"primary", "columns":[{"name":"id","type":"integer"},{"name":"customer","type":"integer"}], "rows":[[1,42],[2,17]], "offset":0, @@ -204,7 +514,7 @@ Successful results provide a JSON object in MCP `structuredContent` and a compac } ``` -Metadata sections wrap the page in `page`; health/index/workload tools use `result`. Optional null envelope fields are omitted; SQL NULL remains null. DML without `RETURNING` includes `rows_affected` when known; DDL returns an empty rowset. `bytea` values are base64 strings with their PostgreSQL type retained. +Metadata sections wrap the page in `page`; health/index/workload tools use `result`; `list_databases` uses `databases`. Optional null envelope fields are omitted; SQL NULL remains null. DML without `RETURNING` includes `rows_affected` when known; DDL returns an empty rowset. `bytea` values are base64 strings with their PostgreSQL type retained. - Repeat the **same read-only operation and filters** with `next_offset`. SELECT/WITH/VALUES/TABLE reads use PostgreSQL `LIMIT limit+1 OFFSET offset`; other read-only statement types use streaming row bounds. - Pages re-execute SQL and **do not share a snapshot**. Use a stable unique `ORDER BY`; use keyset SQL for changing datasets or deep pagination. Offsets are capped at 1,000,000. @@ -223,24 +533,25 @@ Operation errors set MCP `isError=true` and include target, error code and Postg ## Access and resource boundaries -Restricted mode is the default. Each operation owns a transaction with `SET TRANSACTION READ ONLY`. Client SQL is lexically limited to one statement and cannot issue transaction/session control. The statement-boundary lexer handles comments and PostgreSQL quoting; it is **not a SQL authorization AST**. +**Unrestricted mode is the default when access mode is omitted.** Each read-only operation owns a transaction with `SET TRANSACTION READ ONLY`. Client SQL is lexically limited to one statement and cannot issue transaction/session control. The statement-boundary lexer handles comments and PostgreSQL quoting; it is **not a SQL authorization AST**. -For authorized writes, start with `POSTGRES_ACCESS_MODE=unrestricted` or `--access-mode unrestricted`, then explicitly pass `read_only=false` to `execute_sql`. The server commits once on success and rolls back on failure. Default calls remain read-only even in unrestricted mode. `explain_query` and metadata/operations tools remain read-only; tool annotations advertise potentially destructive SQL execution in unrestricted mode. +For authorized writes, explicitly pass `read_only=false` to `execute_sql`; the PostgreSQL role must also have the necessary privileges. The server commits once on success and rolls back on failure. Default calls remain read-only in unrestricted mode. To refuse writes, opt into `POSTGRES_ACCESS_MODE=restricted` or `--access-mode restricted`; existing explicit restricted settings remain effective. `explain_query` and metadata/operations tools remain read-only; tool annotations advertise potentially destructive SQL execution in unrestricted mode. Transaction/session controls, COPY, DO, CALL, PREPARE and VACUUM are unsupported in SQL tools; use an administrative client. Routine metadata inspection is supported and functions can be queried with SELECT. -**Never use a superuser role.** Read-only transactions do not sandbox PostgreSQL functions, SECURITY DEFINER routines, foreign servers/dblink, external side effects, session settings or privileged monitoring. The target allowlist restricts configured connections, not capabilities granted to the role. PostgreSQL must enforce CONNECT, schema/table/column privileges and RLS. Use separate credentials for tenants requiring separate authorization. Treat SQL results as untrusted data, not agent instructions. The server exposes stdio, not an authenticated remote transport. +**Never use a superuser role.** Read-only transactions do not sandbox PostgreSQL functions, SECURITY DEFINER routines, foreign servers/dblink, external side effects, session settings or privileged monitoring. Profiles constrain host/authentication/TLS, not which physical databases those credentials can access. PostgreSQL must enforce CONNECT, schema/table/column privileges and RLS; review `PUBLIC` CONNECT grants. Use separate credentials for tenants requiring separate authorization. An optional explicit database allowlist narrows selection but is not database authorization. Treat SQL results as untrusted data, not agent instructions. The server exposes stdio, not an authenticated remote transport. Resource management: -- Lazy, thread-safe `NpgsqlDataSource` per effective normalized connection string; identical targets share a pool. Listing or rejecting aliases opens no connection. -- 1–32 target aliases, with aliases × configured pool size at most 256. Default pool maximum is 8 physical connections, minimum 0. +- Lazy, thread-safe `NpgsqlDataSource` per effective normalized connection configuration, including physical database, credentials and TLS; equivalent profiles share a pool. Live listing opens the bootstrap connection. +- 1–32 configured profiles, with profiles × configured pool size at most 256. The runtime cache is separately bounded to `floor(256 / POSTGRES_POOL_SIZE)` database pools, so selecting new databases does not accumulate unbounded pools. Default pool maximum is 8 physical connections, minimum 0. +- Idle least-recently-used pool entries are evicted and disposed when capacity is needed; in-flight entries are never evicted. If every entry is active, new selections wait within the original operation deadline. Failed database selections do not accumulate pool entries. - Idle connections above the minimum are pruned after 60 seconds with a 10-second interval; physical lifetime is 1,800 seconds. Data sources are disposed on shutdown; operations dispose connections, readers and transactions. - Default concurrency is 16 database operations per process. Excess calls queue with cancellation; the whole-operation deadline includes queue/pool waits. Statement, lock and command timeouts are also applied. - Pool limits, reset-on-close, enlistment, multiplexing, application name and logging-safety settings are server-owned. Input enabling `No Reset On Close` or multiplexing is rejected. Credentials and TLS remain operator-controlled. -- Metadata is queried on demand, without a cache or per-operation preflight. This avoids stale privilege/schema results and cross-target cache leakage. +- Metadata and database catalogs are queried on demand, without a result cache or per-operation preflight. This avoids stale privilege/schema results and cross-database cache leakage. -A Linux/.NET 10.0.12 stress review exercised 650 successful text/binary queries, 30 PostgreSQL errors, 30 JSON plans, three deadlines and post-timeout recovery. Post-full-GC managed heap was 5.41–5.44 MB across the last three snapshots; file descriptors stayed at 159, and shutdown left no MCP database sessions. The unchanged baseline also stabilized. These finite measurements do not prove that every workload is leak-free: GC/array-pool retention and working set are different measurements, and result limits are not a process-memory ceiling. +A pre-discovery Linux/.NET 10.0.12 stress review exercised 650 successful text/binary queries, 30 PostgreSQL errors, 30 JSON plans, three deadlines and post-timeout recovery. Post-full-GC managed heap was 5.41–5.44 MB across the last three snapshots; file descriptors stayed at 159, and shutdown left no MCP database sessions. The unchanged baseline also stabilized. These historical finite measurements do not verify the new dynamic pool cache or prove that every workload is leak-free: GC/array-pool retention and working set are different measurements, and result limits are not a process-memory ceiling. An extended run added 3,200 successful queries, 50 errors, 50 JSON plans and one deadline. Its last three post-full-GC snapshots were 5.49, 5.50 and 5.50 MB, with 160 file descriptors throughout those snapshots. This longer sample supports stabilization after warm-up, not an absolute no-leak guarantee. @@ -250,23 +561,24 @@ SDK/provider payload logging is disabled even at debug/trace levels; host diagno ## Configuration reference -Choose targets JSON/file **or** a base connection string plus database allowlist; do not combine them. `POSTGRES_TARGETS` takes precedence over the file. CLI flags override corresponding environment variables, except that `POSTGRES_CONNECTION_STRING` overrides `--connection-string`. Configuration changes require a restart. +For one server, use inherited `POSTGRES_CONNECTION_STRING`; it creates the `primary` seed and enables live discovery without targets JSON. Choose a base connection string (optionally with a database allowlist) **or** targets JSON/file. Combining a connection string with either targets source is rejected, not silently prioritized. Within the targets-only route, `POSTGRES_TARGETS` takes precedence over the file. CLI flags override corresponding environment variables, except that `POSTGRES_CONNECTION_STRING` overrides `--connection-string`; avoid CLI secrets because process arguments/history can expose them. Profile/credential changes require a server restart; environment secret changes also require fully restarting the client from the newly prepared shell. Live database/grant changes do not. | Environment | Default / bounds | |---|---| -| `POSTGRES_TARGETS` | JSON alias-to-connection-string object | -| `POSTGRES_TARGETS_FILE` | Protected JSON file; `--targets-file` supported | -| `POSTGRES_CONNECTION_STRING` + `POSTGRES_DATABASES` | Base Npgsql string + explicit JSON array; `--connection-string` and `--databases` supported | -| `POSTGRES_ACCESS_MODE` | restricted; `--access-mode` supported | +| `POSTGRES_TARGETS` | JSON profile-name-to-bootstrap-connection-string object | +| `POSTGRES_TARGETS_FILE` | Protected JSON profile file; `--targets-file` supported | +| `POSTGRES_CONNECTION_STRING` | Base Npgsql string; bootstrap `Database` defaults to `postgres` if omitted; `--connection-string` supported | +| `POSTGRES_DATABASES` | Optional explicit JSON database-name array with the base string; `--databases` supported | +| `POSTGRES_ACCESS_MODE` | unrestricted when omitted; opt into restricted to refuse writes; `--access-mode` supported | | `POSTGRES_QUERY_TIMEOUT` | 30 seconds; 1–600; `--query-timeout` supported | | `POSTGRES_MAX_ROWS` | 1000; 1–5000 | | `POSTGRES_MAX_RESULT_BYTES` | 65536; 4096–1048576 | | `POSTGRES_MAX_CELL_CHARS` | 4096; 1–16384 | -| `POSTGRES_POOL_SIZE` | 8; 1–32; aliases × size ≤256 | +| `POSTGRES_POOL_SIZE` | 8; 1–32; profiles × size ≤256; runtime database-pool cache ≤`floor(256 / size)` | | `POSTGRES_MAX_CONCURRENT_CALLS` | 16; 1–64 | | `POSTGRES_LOG_LEVEL` | warning; trace/debug/information/warning/error/critical/none; `--log-level` supported | -With a base connection string and explicit `POSTGRES_DATABASES` JSON array, each allowlisted name becomes an alias and replaces any `Database` in the base string. It is never a default or fallback target. Prefer a protected targets file for independent credentials and to avoid exposing secrets in process arguments. +A base connection string alone creates the `primary` profile and supports live discovery. With an explicit `POSTGRES_DATABASES` JSON array, each allowlisted name becomes a seed alias and replaces any `Database` in the base string; catalog results are filtered to these names and selection of a nonallowlisted physical database is rejected. This allowlist is optional and must be maintained if used. Targets-file aliases are **not** an implicit allowlist. The [optional protected targets file](#optional-protected-targets-file) supports independent credentials/multiple profiles and launch environments where session propagation is impractical; neither method requires putting secrets in process arguments. Environment variables are plaintext inherited state, while files are persistent plaintext protected by OS permissions—choose according to the deployment's exposure and lifecycle, not a blanket safety claim. Framework-dependent packages require the .NET 10 runtime. Self-contained executables still require native OS libraries. For example, Debian/Ubuntu GSS/Kerberos support uses `libgssapi-krb5-2`; install the platform's appropriate library if that authentication is needed. Password fallback does not verify Kerberos support. The container includes this dependency. See [Npgsql security and encryption](https://www.npgsql.org/doc/security.html). diff --git a/SECURITY.md b/SECURITY.md index 02ae48e..e7bbc17 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,13 +4,15 @@ This server exposes PostgreSQL operations to an MCP client over stdio. Anyone who can use that client can request queries using the configured database credentials. There is no separate MCP user authentication, per-tool authorization, or tenant identity mechanism. Protect the host process and the client that launches it; do not expose stdio through an unauthenticated network bridge. -Targets are an explicit, case-sensitive allowlist of aliases mapped to connection strings. Tool requests cannot supply a new connection string or change a target's host, database, or login. Unknown targets fail without falling back to another database. Aliases that share the same effective connection configuration may share a connection pool. Separate tenant credentials and PostgreSQL permissions are necessary when tenant isolation matters; the alias allowlist is not a replacement for database authorization. +Connection profiles are case-sensitive names mapped to bootstrap connection strings, **not a database allowlist**. An inherited `POSTGRES_CONNECTION_STRING` alone creates the `primary` profile; protected targets JSON/file remains optional for multiple independent profiles. Tool requests can select a physical PostgreSQL database on a profile's server, but cannot supply a new connection string or change its host, login or TLS settings. Optional `target` selects the profile; without it, exact aliases select their configured bootstrap database and other database names use the default profile (`primary`, otherwise ordinal-first). With `target`, `database` is always a physical name. Unknown profiles, missing databases and denied connections fail without falling back. Each call owns its connection selection; there is no shared current database. Equivalent configurations for the same physical database may share a bounded connection pool. + +Live `list_databases` uses the bootstrap database's catalog, excluding templates, disabled connections and databases lacking the current role's CONNECT privilege. It exposes accessible database names, not just configured aliases. CONNECT discovery does not prove actual connectivity or schema/table access. Newly created or granted databases are selectable without configuration edits or restarting, whether credentials came from the session environment or a protected file. Review grants, including `PUBLIC` CONNECT, before upgrading from alias-only selection. An optional `POSTGRES_DATABASES`/`--databases` allowlist with a base connection string filters listing and rejects other physical names; targets-file aliases do not impose that restriction. ## PostgreSQL permissions are the authority Use dedicated, least-privileged PostgreSQL roles, not superusers or database owners. Grant only the required database connection, schema usage, table access, and routine execution privileges. Configure row-level security and separate roles where required by the application's data model. Review role memberships, default privileges, `PUBLIC` grants, `SECURITY DEFINER` routines, extensions, foreign servers, and monitoring privileges. -Restricted access is the default. Each database operation starts a server-owned read-only transaction and rolls it back after reading its result. Writes require both server access mode `unrestricted` and `read_only=false` on `execute_sql`. These writes use a transaction that commits after successful execution and result reading; failures dispose the transaction without committing. Other tools remain read-only. Connection loss or cancellation during commit can leave the caller uncertain whether a write committed; inspect database state before retrying. The server does not automatically replay writes. +**Unrestricted access is the default when access mode is omitted.** SQL requests still default to `read_only=true`; read-only operations use a server-owned read-only transaction and roll it back after reading the result. Writes require `read_only=false` on `execute_sql`, unrestricted mode and sufficient PostgreSQL privileges. Opt into `POSTGRES_ACCESS_MODE=restricted` or `--access-mode restricted` to refuse write requests; existing explicit restricted settings remain effective. Enabled writes use a transaction that commits after successful execution and result reading; failures dispose the transaction without committing. Other tools remain read-only. Connection loss or cancellation during commit can leave the caller uncertain whether a write committed; inspect database state before retrying. The server does not automatically replay writes. The SQL guard is a statement-boundary lexer, **not an authorization parser or SQL sandbox**. It accepts one statement, handles quoted SQL and comments, and rejects direct transaction/session control plus unsupported operations such as `COPY`, `DO`, `CALL`, and `VACUUM`. PostgreSQL transactions and role permissions enforce the actual access restrictions. @@ -18,9 +20,13 @@ Read-only transactions do not make arbitrary SQL harmless. Queries can execute f ## Credentials and diagnostics -Keep credentials in protected local configuration files or the process environment, never in committed files, SQL, tool arguments, or target aliases. Restrict configuration file access to the server's operating-system user. Environment variables and command-line arguments can be visible to sufficiently privileged local processes; CLI connection strings can also enter shell history. Restart the server after changing its target configuration or credentials. +The single-server quickstart uses masked Bash/PowerShell entry into the process environment, not a credentials-bearing MCP JSON entry. Masking prevents terminal echo; the resulting value is still **plaintext process state**, inherited by child processes and inspectable by sufficiently privileged local processes. Process-level environment assignment does not automatically persist across sessions, but launchers, supervisors, containers or explicit persistence can retain it. Fully quit the client/server after changing a session credential, then launch the client from the same newly prepared shell. An already running client, a desktop launch or `/mcp reload` alone cannot acquire a changed parent-shell environment. Unsetting a parent variable does not erase copies in existing children. + +An optional owner-protected targets file remains useful for multiple profiles, automation and clients where session propagation is impractical. It is persistent plaintext, protected by OS permissions rather than encryption; restrict file/folder access to the server's operating-system user and consider administrator, backup and sync exposure. A session environment is not inherently safer than an owner-only file: choose based on exposure, inheritance and lifecycle requirements. Keep existing protected file names if desired; no automatic local credential migration is performed. -The server does not return configured connection strings through target discovery. Configuration-parser failures use fixed diagnostics rather than forwarding parser or file-access exception messages. Unrecognized CLI arguments are not echoed. Missing or duplicate recognized options can identify the supported option name. +Never put real credentials in committed files, SQL, tool arguments, profile aliases, literal CLI commands, shell history, shell startup profiles/rc files, `setx` or MCP JSON. A reusable rc function may contain only the masked-prompt code, invoked explicitly in the launching shell—not a literal secret or automatic plaintext export. CLI connection strings can be exposed in process arguments/history. Combining a connection string with targets JSON/file is rejected; when switching workflows, clear stale variables and remove conflicting client `env`/launch arguments rather than expecting secret precedence. Restart the server after changing file-based profiles/credentials; live database creation and grant/revoke changes need no recurring JSON edits. + +The server does not return configured connection strings through database discovery. Live discovery can reveal sensitive database names; protect its results like other metadata. Configuration-parser failures use fixed diagnostics rather than forwarding parser or file-access exception messages. Unrecognized CLI arguments are not echoed. Missing or duplicate recognized options can identify the supported option name. Npgsql error details and parameter logging are disabled by server-owned connection settings. PostgreSQL `MessageText` and `Hint` can contain query literals, data values, or arbitrary routine-generated text even when error details are disabled. Tool errors therefore retain SQLSTATE and a server-defined summary but do not forward these PostgreSQL fields. Connection and unexpected failures also use fixed summaries. Investigate detailed database errors using an appropriately protected PostgreSQL diagnostic channel. @@ -34,6 +40,8 @@ TLS and certificate trust are operator-controlled Npgsql connection-string setti The server owns pool bounds, reset-on-close, transaction enlistment, multiplexing, application name, command timeouts, and logging safety settings. Configuration with `No Reset On Close=true` or multiplexing is rejected. Operation deadlines include queue and connection-pool wait; transactions also set statement and lock timeouts. Result row, byte, cell, and column limits bound responses. These are resource controls, not a guarantee against expensive queries, external side effects, or denial of service. Apply PostgreSQL and operating-system resource controls where needed. +Pools are keyed by normalized connection settings, including credentials/TLS and physical database. The runtime cache holds at most `floor(256 / POSTGRES_POOL_SIZE)` pool entries, each with that configured connection maximum. Idle least-recently-used entries are disposed when capacity is needed; active entries are not evicted. At full active capacity, calls wait within their existing operation deadline. Failed selections do not accumulate pool entries. These bounds prevent unbounded per-database pool growth, not privileged cross-database access. + ## Reporting a vulnerability Do not post credentials, private data, or an exploit against an operational database in a public issue. Private vulnerability reporting is enabled for this repository: open [Security and quality](https://github.com/codegiveness/postgresql-sharp-mcp/security), then **Report a vulnerability**, following [GitHub's private-report instructions](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/report-privately). No particular response time is guaranteed. If the private route becomes unavailable, open a minimal public issue asking the maintainer for a confidential contact route, without sensitive details. diff --git a/docs/security-posture.md b/docs/security-posture.md index ab8f6b4..f11103d 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -4,15 +4,18 @@ Configured controls are not proof that a scan passed, an artifact was attested, ## Runtime boundaries -- PostgreSQL roles and permissions are the authorization boundary. Default operations run in server-owned read-only transactions. Explicit writes require both unrestricted server mode and `read_only=false`; successful writes commit, failures roll back, and writes are not automatically replayed. +- PostgreSQL roles and permissions are the authorization boundary. Omitted server access mode defaults to **unrestricted**, while SQL requests default to `read_only=true` and run in server-owned read-only transactions. Explicit writes require `read_only=false` and sufficient PostgreSQL privileges; successful writes commit, failures roll back, and writes are not automatically replayed. Explicit `restricted` mode refuses writes and remains an operator opt-in. - The SQL lexer enforces a single statement and excludes transaction/session control. It is not an AST allowlist, SQL authorization parser, or sandbox. Read-only transactions cannot undo privileged routine or extension external effects. -- Configured aliases select allowlisted connections, with no fallback database and no connection strings accepted from tool arguments. Tenant separation requires appropriate PostgreSQL credentials and permissions. +- Configured aliases are bootstrap connection profiles, not database allowlists. Optional `target` selects a profile; each call independently selects a physical database using its host/authentication/TLS settings, with no shared current database, fallback or connection strings accepted from tool arguments. Live discovery checks CONNECT and excludes templates/disabled databases, but actual connectivity and object access remain PostgreSQL-enforced. Newly accessible databases need no configuration edits. Tenant separation requires appropriate PostgreSQL credentials and permissions, including review of `PUBLIC` CONNECT. A base connection string can optionally use an explicit database allowlist to narrow listing and selection. +- Environment-first setup uses a masked prompt for one inherited `POSTGRES_CONNECTION_STRING`, creating the `primary` seed without mandatory targets JSON. Masking is not encryption: session values are plaintext, copied to children and inspectable by privileged local processes; they are not automatically persisted, but launchers/container tools or explicit persistence can retain them. The client must start in the prepared shell and fully restart for changed environment secrets; a reload or existing GUI process cannot acquire the new parent environment. Optional rc functions persist prompt code only, never credentials or automatic secret exports. Optional owner-protected files remain for multiple profiles/automation and impractical environment propagation; they are persistent plaintext with OS-permission/backup/sync risks. Neither storage choice is universally safer. Combining a base string with targets JSON/file fails closed; switching is manual and does not require deleting protected files. - SDK/provider payload logging is disabled. Tool errors preserve SQLSTATE with fixed summaries, not PostgreSQL messages, hints or details. Successful results can contain sensitive authorized data. -- Bounded pools, operation deadlines, server-side read-only pagination and response limits constrain resource use, but do not prevent expensive SQL or guarantee a total process memory/CPU ceiling. -- Connections, transactions, commands/readers, cancellation sources and query/plan scratch buffers have scoped ownership. HypoPG failure cleanup resets session-local state or clears the pool. A finite repeated-call heap/handle review and in-flight shutdown smoke found no progressive retention on the exercised paths; see [measured resource limits](../README.md#access-and-resource-boundaries), not a blanket leak-free guarantee. +- Runtime database pools are bounded to `floor(256 / POSTGRES_POOL_SIZE)` entries; idle least-recently-used entries are disposed, active entries are never evicted, and full active capacity waits within the operation deadline. These bounds, server-side read-only pagination and response limits constrain resource use, but do not prevent expensive SQL or guarantee a total process memory/CPU ceiling. +- Connections, transactions, commands/readers, cancellation sources and query/plan scratch buffers have scoped ownership. HypoPG failure cleanup resets session-local state or clears the pool. A pre-discovery finite repeated-call heap/handle review and in-flight shutdown smoke found no progressive retention on the exercised paths; see [measured resource limits](../README.md#access-and-resource-boundaries). Those historical measurements do not verify the new dynamic pool cache or establish a blanket leak-free guarantee. See [SECURITY.md](../SECURITY.md) for credentials, TLS, extension privileges, reporting and operational limits. +**Upgrade boundary:** the **0.3.0** contract makes targets-file aliases connection profiles rather than confinement to bootstrap databases, changes omitted access mode from restricted, and returns a live `databases` query page from `list_databases` rather than a `targets` alias list. The **0.3.1 patch follow-on** documents environment-first setup; it is not another discovery boundary or proof of package publication. Preserve explicit restricted settings where intended and review database-role grants before deploying. Neither default unrestricted mode nor discovery grants privileges beyond those of the configured PostgreSQL role. Remote TLS/certificate validation remains operator-configured; masked credential entry does not replace `SSL Mode=VerifyFull` and appropriate CA trust. + ## Development controls - CodeQL uses `security-extended` for C# and GitHub Actions. C# scans build the real solution to capture compiler-generated code. Trusted main/scheduled runs publish results. PR base/candidate scans and the aggregate gate use read-only tokens and preserve raw SARIF artifacts, without privileged uploads or release secrets. diff --git a/npm/package.json b/npm/package.json index 7797d91..96bf18b 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,7 +1,7 @@ { "name": "@codegiveness/postgresql-sharp-mcp", - "version": "0.2.0", - "description": "Bounded, explicit multi-database PostgreSQL MCP server over stdio. Requires the .NET 10 runtime; set DOTNET_ROOT for nonstandard installations. No runtime downloads.", + "version": "0.3.1", + "description": "Database-agnostic PostgreSQL MCP server with live discovery and bounded per-call selection. Requires the .NET 10 runtime; set DOTNET_ROOT for nonstandard installations. No runtime downloads.", "license": "MIT", "author": "codegiveness", "homepage": "https://github.com/codegiveness/postgresql-sharp-mcp#readme", diff --git a/src/PostgreSqlMcp.Core/DatabaseRegistry.cs b/src/PostgreSqlMcp.Core/DatabaseRegistry.cs index abf7732..8344463 100644 --- a/src/PostgreSqlMcp.Core/DatabaseRegistry.cs +++ b/src/PostgreSqlMcp.Core/DatabaseRegistry.cs @@ -1,17 +1,29 @@ +using System.Text; using Npgsql; namespace PostgreSqlMcp.Core; public sealed class DatabaseRegistry : IAsyncDisposable { - private readonly IReadOnlyDictionary> _sources; + private readonly Dictionary _profiles = new(StringComparer.Ordinal); + private readonly Dictionary _sources = new(); + private readonly SemaphoreSlim _gate = new(1); + private TaskCompletionSource? _changed; + private readonly IReadOnlySet? _allowedDatabases; + private readonly int _capacity; + private long _clock; + private bool _disposed; public string[] TargetNames { get; } + public string DefaultTarget { get; } + public string[]? AllowedDatabaseNames { get; } public DatabaseRegistry(ServerOptions options) { TargetNames = options.Targets.Keys.Order(StringComparer.Ordinal).ToArray(); - var sources = new Dictionary>(StringComparer.Ordinal); - var pools = new Dictionary>(StringComparer.Ordinal); + DefaultTarget = options.Targets.ContainsKey("primary") ? "primary" : TargetNames[0]; + _allowedDatabases = options.AllowedDatabases; + AllowedDatabaseNames = options.AllowedDatabases?.ToArray(); + _capacity = 256 / options.PoolSize; foreach (var pair in options.Targets) { var builder = new NpgsqlConnectionStringBuilder(pair.Value) @@ -22,28 +34,143 @@ public DatabaseRegistry(ServerOptions options) NoResetOnClose = false, Multiplexing = false, Enlist = false, IncludeErrorDetail = false, LogParameters = false, ApplicationName = "postgresql-sharp-mcp" }; - string connectionString = builder.ConnectionString; - if (!pools.TryGetValue(connectionString, out var source)) + string database = builder.Database!; + builder.Database = ""; + // Keyword order is not connection identity; normalize once per profile. + string[] keywords = builder.Keys.Cast().Order(StringComparer.Ordinal).ToArray(); + var normalized = new NpgsqlConnectionStringBuilder(); + foreach (string keyword in keywords) normalized[keyword] = builder[keyword]; + _profiles.Add(pair.Key, new(normalized.ConnectionString, database)); + } + } + + public string GetBootstrapDatabase(string target) + { + if (!_profiles.TryGetValue(target, out var profile)) + throw new ToolException("invalid_target", "Specify an exact configured connection profile as target."); + return profile.Database; + } + + internal DatabaseSelection Resolve(string database, string? target) + { + if (string.IsNullOrEmpty(database)) + throw new ToolException("invalid_target", "A database name or configured profile alias is required."); + Profile profile; + if (target is not null) + { + if (!_profiles.TryGetValue(target, out profile!)) + throw new ToolException("invalid_target", "Specify an exact configured connection profile as target."); + } + else if (_profiles.TryGetValue(database, out profile!)) + database = profile.Database; + else + profile = _profiles[DefaultTarget]; + if (database.Contains('\0') || Encoding.UTF8.GetByteCount(database) > 63) + throw new ToolException("invalid_target", "Specify a database name of 1..63 UTF-8 bytes without NUL, or a configured profile alias."); + if (_allowedDatabases is not null && !_allowedDatabases.Contains(database)) + throw new ToolException("invalid_target", "The database is outside the configured explicit database allowlist."); + return new(profile.ConnectionString, database); + } + + internal async ValueTask AcquireAsync(DatabaseSelection selection, CancellationToken ct) + { + while (true) + { + Task changed; + await _gate.WaitAsync(ct).ConfigureAwait(false); + try { - source = new Lazy(() => NpgsqlDataSource.Create(connectionString), - LazyThreadSafetyMode.ExecutionAndPublication); - pools.Add(connectionString, source); + ObjectDisposedException.ThrowIf(_disposed, this); + if (_sources.TryGetValue(selection, out var existing)) + { + existing.Users++; + existing.LastUsed = ++_clock; + return existing; + } + SourceEntry? victim = null; + if (_sources.Count == _capacity) + { + foreach (var entry in _sources.Values) + if (entry.Users == 0 && (victim is null || entry.LastUsed < victim.LastUsed)) + victim = entry; + if (victim is not null) + { + // Keep the slot reserved until the old pool has actually closed. + await victim.Source.DisposeAsync().ConfigureAwait(false); + _sources.Remove(victim.Selection); + } + } + if (_sources.Count < _capacity) + { + var builder = new NpgsqlConnectionStringBuilder(selection.ConnectionString) { Database = selection.Database }; + var entry = new SourceEntry(selection, NpgsqlDataSource.Create(builder.ConnectionString), ++_clock); + _sources.Add(selection, entry); + return entry; + } + changed = (_changed ??= new(TaskCreationOptions.RunContinuationsAsynchronously)).Task; } - sources.Add(pair.Key, source); + finally { _gate.Release(); } + // All cached pools are in use. Wait within the caller's original deadline. + await changed.WaitAsync(ct).ConfigureAwait(false); } - _sources = sources; } - public NpgsqlDataSource Get(string database) + internal async ValueTask ReleaseAsync(SourceEntry entry) { - if (string.IsNullOrWhiteSpace(database) || !_sources.TryGetValue(database, out var source)) - throw new ToolException("invalid_target", "Specify an exact configured target from list_databases. No fallback is used."); - return source.Value; + await _gate.WaitAsync().ConfigureAwait(false); + try + { + entry.Users--; + entry.LastUsed = ++_clock; + if (entry.Users == 0) SignalChange(); + } + finally { _gate.Release(); } + } + + private void SignalChange() + { + TaskCompletionSource? changed = _changed; + _changed = null; + changed?.TrySetResult(); } public async ValueTask DisposeAsync() { - foreach (var source in _sources.Values.Distinct()) - if (source.IsValueCreated) await source.Value.DisposeAsync().ConfigureAwait(false); + while (true) + { + Task changed; + await _gate.WaitAsync().ConfigureAwait(false); + try + { + if (!_disposed) + { + _disposed = true; + SignalChange(); + } + bool busy = false; + foreach (var entry in _sources.Values) + busy |= entry.Users != 0; + if (!busy) + { + foreach (var entry in _sources.Values) + await entry.Source.DisposeAsync().ConfigureAwait(false); + _sources.Clear(); + return; + } + changed = (_changed ??= new(TaskCreationOptions.RunContinuationsAsynchronously)).Task; + } + finally { _gate.Release(); } + await changed.ConfigureAwait(false); + } + } + + private sealed record Profile(string ConnectionString, string Database); + internal readonly record struct DatabaseSelection(string ConnectionString, string Database); + internal sealed class SourceEntry(DatabaseSelection selection, NpgsqlDataSource source, long lastUsed) + { + internal DatabaseSelection Selection { get; } = selection; + internal NpgsqlDataSource Source { get; } = source; + internal int Users { get; set; } = 1; + internal long LastUsed { get; set; } = lastUsed; } } diff --git a/src/PostgreSqlMcp.Core/ServerOptions.cs b/src/PostgreSqlMcp.Core/ServerOptions.cs index b8a7ad5..86f310b 100644 --- a/src/PostgreSqlMcp.Core/ServerOptions.cs +++ b/src/PostgreSqlMcp.Core/ServerOptions.cs @@ -8,7 +8,8 @@ namespace PostgreSqlMcp.Core; public sealed class ServerOptions { public required IReadOnlyDictionary Targets { get; init; } - public bool Unrestricted { get; init; } + public IReadOnlySet? AllowedDatabases { get; init; } + public bool Unrestricted { get; init; } = true; public int QueryTimeout { get; init; } = 30; public int MaxRows { get; init; } = 1000; public int MaxResultBytes { get; init; } = 65536; @@ -45,6 +46,7 @@ int Number(string env, int fallback, int min, int max, string? key = null) return n; } var targets = new Dictionary(StringComparer.Ordinal); + HashSet? allowedDatabases = null; try { string? json = Env("TARGETS"); @@ -62,17 +64,28 @@ int Number(string env, int fallback, int min, int max, string? key = null) if (!targets.TryAdd(item.Name, item.Value.GetString() ?? "")) throw new ToolException("configuration", "Duplicate target alias."); } - else if (baseString is not null && names is not null) + else if (baseString is not null) { - using var doc = JsonDocument.Parse(names); - foreach (var name in doc.RootElement.EnumerateArray()) + var builder = new NpgsqlConnectionStringBuilder(baseString); + if (names is not null) { - string db = name.GetString() ?? ""; - var builder = new NpgsqlConnectionStringBuilder(baseString) { Database = db }; - if (!targets.TryAdd(db, builder.ConnectionString)) throw new ToolException("configuration", "Duplicate database target."); + using var doc = JsonDocument.Parse(names); + allowedDatabases = new HashSet(StringComparer.Ordinal); + foreach (var name in doc.RootElement.EnumerateArray()) + { + string db = name.GetString() ?? ""; + builder.Database = db; + if (!allowedDatabases.Add(db) || !targets.TryAdd(db, builder.ConnectionString)) + throw new ToolException("configuration", "Duplicate database target."); + } + } + else + { + if (string.IsNullOrWhiteSpace(builder.Database)) builder.Database = "postgres"; + targets.Add("primary", builder.ConnectionString); } } - else throw new ToolException("configuration", "Set POSTGRES_TARGETS, POSTGRES_TARGETS_FILE, or POSTGRES_CONNECTION_STRING + POSTGRES_DATABASES (JSON array)."); + else throw new ToolException("configuration", "Set POSTGRES_CONNECTION_STRING, POSTGRES_TARGETS, or POSTGRES_TARGETS_FILE."); if (targets.Count is < 1 or > 32) throw new ToolException("configuration", "Configure 1..32 database targets."); foreach (var (alias, connectionString) in targets) { @@ -90,13 +103,14 @@ int Number(string env, int fallback, int min, int max, string? key = null) // Provider/parser messages can contain secrets; never echo connection-string input. throw new ToolException("configuration", "Invalid targets configuration. Check JSON, file access and Npgsql connection-string syntax."); } - string mode = Value("--access-mode", "ACCESS_MODE") ?? "restricted"; + string mode = Value("--access-mode", "ACCESS_MODE") ?? "unrestricted"; if (mode is not ("restricted" or "unrestricted")) throw new ToolException("configuration", "Access mode must be restricted or unrestricted."); int poolSize = Number("POOL_SIZE", 8, 1, 32); if (poolSize * targets.Count > 256) throw new ToolException("configuration", "Targets × POSTGRES_POOL_SIZE must not exceed 256."); return new ServerOptions { - Targets = new ReadOnlyDictionary(targets), Unrestricted = mode == "unrestricted", + Targets = new ReadOnlyDictionary(targets), AllowedDatabases = allowedDatabases, + Unrestricted = mode == "unrestricted", QueryTimeout = Number("QUERY_TIMEOUT", 30, 1, 600, "--query-timeout"), MaxRows = Number("MAX_ROWS", 1000, 1, 5000), MaxResultBytes = Number("MAX_RESULT_BYTES", 65536, 4096, 1048576), MaxCellChars = Number("MAX_CELL_CHARS", 4096, 1, 16384), PoolSize = poolSize, diff --git a/src/PostgreSqlMcp.Core/SqlExecutor.cs b/src/PostgreSqlMcp.Core/SqlExecutor.cs index a334711..c9918d5 100644 --- a/src/PostgreSqlMcp.Core/SqlExecutor.cs +++ b/src/PostgreSqlMcp.Core/SqlExecutor.cs @@ -10,23 +10,24 @@ public sealed class SqlExecutor(ServerOptions options, DatabaseRegistry registry private readonly SemaphoreSlim _calls = new(options.MaxConcurrentCalls); public Task QueryAsync(string database, string sql, IReadOnlyDictionary? parameters = null, - int? limit = null, int offset = 0, bool readOnly = true, CancellationToken ct = default) => - WithSessionAsync(database, (session, token) => session.QueryAsync(sql, parameters, limit, offset, token), readOnly, ct); + int? limit = null, int offset = 0, bool readOnly = true, CancellationToken ct = default, string? target = null) => + WithSessionAsync(database, (session, token) => session.QueryAsync(sql, parameters, limit, offset, token), readOnly, ct, target); public async Task WithSessionAsync(string database, Func> action, - bool readOnly = true, CancellationToken ct = default) + bool readOnly = true, CancellationToken ct = default, string? target = null) { - // Resolve before queueing: an unknown target cannot allocate a pool or reach any database. - NpgsqlDataSource source = registry.Get(database); + DatabaseRegistry.DatabaseSelection selection = registry.Resolve(database, target); if (!readOnly && !options.Unrestricted) throw new ToolException("read_only", "Writes require server access-mode unrestricted and read_only=false."); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); deadline.CancelAfter(TimeSpan.FromSeconds(options.QueryTimeout)); CancellationToken token = deadline.Token; bool entered = false; + DatabaseRegistry.SourceEntry? source = null; try { await _calls.WaitAsync(token).ConfigureAwait(false); entered = true; - await using var connection = await source.OpenConnectionAsync(token).ConfigureAwait(false); + source = await registry.AcquireAsync(selection, token).ConfigureAwait(false); + await using var connection = await source.Source.OpenConnectionAsync(token).ConfigureAwait(false); await using var transaction = await connection.BeginTransactionAsync(token).ConfigureAwait(false); // Control statements are fixed server text. User SQL cannot end this transaction. await using (var setup = new NpgsqlCommand($"SET TRANSACTION {(readOnly ? "READ ONLY" : "READ WRITE")}; SET LOCAL standard_conforming_strings=on; SET LOCAL statement_timeout='{options.QueryTimeout * 1000}ms'; SET LOCAL lock_timeout='{options.QueryTimeout * 1000}ms'", connection, transaction) @@ -40,7 +41,14 @@ public async Task WithSessionAsync(string database, Func _calls.Dispose(); diff --git a/src/PostgreSqlMcp.Tools/DatabaseTools.cs b/src/PostgreSqlMcp.Tools/DatabaseTools.cs index 99a82e8..a958cc6 100644 --- a/src/PostgreSqlMcp.Tools/DatabaseTools.cs +++ b/src/PostgreSqlMcp.Tools/DatabaseTools.cs @@ -21,40 +21,48 @@ public DatabaseTools(SqlExecutor executor, ServerOptions options, DatabaseRegist } [McpServerTool(Name = "list_databases", ReadOnly = true, Destructive = false, OpenWorld = false)] - [Description("Page configured target aliases and server limits without connecting or exposing credentials. Pass a target as database to other tools.")] - public Task ListDatabases(CancellationToken ct, int? limit = null, int offset = 0) => - ToolReply.Run("", () => + [Description("Discover accessible databases from the live PostgreSQL catalog, with paging and is_current. Optional target selects a configured connection profile; newly accessible databases need no configuration edit.")] + public Task ListDatabases(CancellationToken ct, int? limit = null, int offset = 0, string? target = null) => + ToolReply.Run("", async () => { ct.ThrowIfCancellationRequested(); ValidatePage(limit, offset); - string[] targets = _registry.TargetNames.Skip(offset).Take(limit ?? Math.Min(100, _options.MaxRows)).ToArray(); - bool truncated = offset + targets.Length < _registry.TargetNames.Length; - return Task.FromResult(new + string profile = target ?? _registry.DefaultTarget; + string database = _registry.GetBootstrapDatabase(profile); + QueryPage databases = await _executor.QueryAsync(database, """ + SELECT datname::text AS name, datname = current_database() AS is_current + FROM pg_catalog.pg_database + WHERE NOT datistemplate AND datallowconn + AND pg_catalog.has_database_privilege(oid, 'CONNECT') + AND (@databases::text[] IS NULL OR datname::text = ANY(@databases::text[])) + ORDER BY datname COLLATE "C" + """, new Dictionary { ["databases"] = _registry.AllowedDatabaseNames }, + limit, offset, ct: ct, target: profile).ConfigureAwait(false); + return new { - targets, offset, next_offset = truncated ? (int?)(offset + targets.Length) : null, - truncated, truncation_reason = truncated ? "row_limit" : null, + target = profile, databases, access_mode = _options.Unrestricted ? "unrestricted" : "restricted", limits = new { max_rows = _options.MaxRows, max_result_bytes = _options.MaxResultBytes, max_cell_chars = _options.MaxCellChars, query_timeout_seconds = _options.QueryTimeout } - }); + }; }); [McpServerTool(Name = "list_schemas", ReadOnly = true, Destructive = false, OpenWorld = false)] [Description("Page schemas with USAGE privilege on an explicit target; system schemas excluded by default.")] public Task ListSchemas( CancellationToken ct, - [Description("Configured database target alias; required.")] string database, + [Description("Physical database name, or a configured profile alias when target is omitted.")] string database, [Description("Optional literal, case-sensitive schema name prefix.")] string? prefix = null, int? limit = null, int offset = 0, - bool include_system = false) => ToolReply.Run(database, async () => + bool include_system = false, string? target = null) => ToolReply.Run(database, async () => { ValidatePage(limit, offset); return await _executor.QueryAsync(database, SchemasSql, new Dictionary { ["prefix"] = prefix, ["include_system"] = include_system - }, limit, offset, ct: ct).ConfigureAwait(false); + }, limit, offset, ct: ct, target: target).ConfigureAwait(false); }); [McpServerTool(Name = "list_objects", ReadOnly = true, Destructive = false, OpenWorld = false)] @@ -67,7 +75,7 @@ public Task ListObjects( [Description("Literal, case-sensitive substring of the object name; not a SQL LIKE pattern.")] string? search = null, int? limit = null, int offset = 0, - bool include_system = false) => ToolReply.Run(database, async () => + bool include_system = false, string? target = null) => ToolReply.Run(database, async () => { ValidatePage(limit, offset); string? objectType = NormalizeType(type); @@ -77,7 +85,7 @@ public Task ListObjects( ["type"] = objectType, ["search"] = search, ["include_system"] = include_system - }, limit, offset, ct: ct).ConfigureAwait(false); + }, limit, offset, ct: ct, target: target).ConfigureAwait(false); }); [McpServerTool(Name = "get_object_details", ReadOnly = true, Destructive = false, OpenWorld = false)] @@ -92,7 +100,7 @@ public Task GetObjectDetails( int offset = 0, [Description("Exact pg_get_function_identity_arguments text from list_objects, including argument names. Empty string selects a zero-argument routine.")] string? identity_arguments = null, [Description("Optional object type, useful when a relation and routine share a name.")] string? type = null, - bool include_system = false) => ToolReply.Run(database, async () => + bool include_system = false, string? target = null) => ToolReply.Run(database, async () => { ValidatePage(limit, offset); if (string.IsNullOrEmpty(schema) || string.IsNullOrEmpty(name)) @@ -123,7 +131,7 @@ public Task GetObjectDetails( string sql = SelectDetailsSql(resolvedType, selectedSection); QueryPage page = await session.QueryAsync(sql, new Dictionary { ["object_id"] = objectId }, limit, offset, token).ConfigureAwait(false); return new { database, schema, name, type = resolvedType, identity_arguments, section = selectedSection, page }; - }, ct: ct).ConfigureAwait(false); + }, ct: ct, target: target).ConfigureAwait(false); }); private void ValidatePage(int? limit, int offset) diff --git a/src/PostgreSqlMcp.Tools/OpsTools.cs b/src/PostgreSqlMcp.Tools/OpsTools.cs index c9143f6..83d0d95 100644 --- a/src/PostgreSqlMcp.Tools/OpsTools.cs +++ b/src/PostgreSqlMcp.Tools/OpsTools.cs @@ -12,7 +12,7 @@ public sealed class OpsTools(SqlExecutor executor, ServerOptions options) [McpServerTool(Name = "analyze_db_health", ReadOnly = true, Destructive = false, OpenWorld = false)] [Description("Current-database health summary or focused vacuum, index, constraints, sequences, replication, or blocking evidence. Results are paged; no changes are made.")] public Task AnalyzeDbHealth(CancellationToken ct, string database, - string section = "summary", string? schema = null, int? limit = null, int offset = 0) => + string section = "summary", string? schema = null, int? limit = null, int offset = 0, string? target = null) => ToolReply.Run(database, async () => { string sql = section.ToLowerInvariant() switch @@ -26,7 +26,7 @@ public Task AnalyzeDbHealth(CancellationToken ct, string databas "blocking" => BlockingSql, _ => throw new ToolException("invalid_section", "section must be summary, vacuum, index, constraints, sequences, replication, or blocking.") }; - QueryPage page = await executor.QueryAsync(database, sql, Filters(schema), limit, offset, ct: ct); + QueryPage page = await executor.QueryAsync(database, sql, Filters(schema), limit, offset, ct: ct, target: target); return new { database, section, result = page, @@ -46,7 +46,7 @@ public Task AnalyzeDbHealth(CancellationToken ct, string databas [McpServerTool(Name = "get_top_queries", ReadOnly = true, Destructive = false, OpenWorld = false)] [Description("Rank pg_stat_statements for the current database only. Requires an installed, preloaded extension; query text clipping is explicit.")] public Task GetTopQueries(CancellationToken ct, string database, - string order_by = "total_time", int? limit = null, int offset = 0) => + string order_by = "total_time", int? limit = null, int offset = 0, string? target = null) => ToolReply.Run(database, async () => { string order = order_by.ToLowerInvariant() switch @@ -88,16 +88,16 @@ public Task GetTopQueries(CancellationToken ct, string database, { throw new ToolException("extension_not_ready", "pg_stat_statements is installed but not initialized (SQLSTATE 55000). Ask the administrator to configure shared_preload_libraries and restart PostgreSQL; this tool does not change server configuration."); } - }, ct: ct); + }, ct: ct, target: target); }); [McpServerTool(Name = "analyze_indexes", ReadOnly = true, Destructive = false, OpenWorld = false)] [Description("Paged current-database index validity, usage, size and structurally duplicate evidence. Recommendations are contextual, never fabricated missing-index predictions.")] public Task AnalyzeIndexes(CancellationToken ct, string database, - string? schema = null, string? table = null, int? limit = null, int offset = 0) => + string? schema = null, string? table = null, int? limit = null, int offset = 0, string? target = null) => ToolReply.Run(database, async () => { - QueryPage page = await executor.QueryAsync(database, IndexSql, Filters(schema, table), limit, offset, ct: ct); + QueryPage page = await executor.QueryAsync(database, IndexSql, Filters(schema, table), limit, offset, ct: ct, target: target); return new { database, result = page, notes = IndexNotes, evaluation = "Use explain_query on the actual workload. Supply candidate CREATE INDEX statements there to compare real planner costs through an already-installed hypopg extension. No indexes are created by these tools." }; }); diff --git a/src/PostgreSqlMcp.Tools/PlanTools.cs b/src/PostgreSqlMcp.Tools/PlanTools.cs index 4d06b0f..dedd579 100644 --- a/src/PostgreSqlMcp.Tools/PlanTools.cs +++ b/src/PostgreSqlMcp.Tools/PlanTools.cs @@ -17,7 +17,7 @@ public sealed partial class PlanTools(SqlExecutor executor, ServerOptions option [McpServerTool(Name = "explain_query", ReadOnly = true, Destructive = false, OpenWorld = false)] [Description("Real PostgreSQL JSON plan or compact summary. Optional ANALYZE executes inside READ ONLY. Supplied CREATE INDEX candidates use installed HypoPG in one session, compare estimated costs, and are always cleaned up; never permanent DDL.")] public Task ExplainQuery(CancellationToken ct, string database, string sql, - string format = "summary", bool analyze = false, string[]? indexes = null) => + string format = "summary", bool analyze = false, string[]? indexes = null, string? target = null) => ToolReply.Run(database, async () => { string statement = SqlGuard.Validate(sql); @@ -127,7 +127,7 @@ public Task ExplainQuery(CancellationToken ct, string database, throw new ToolException("hypopg_cleanup_failed", "Hypothetical index cleanup failed. The connection pool was cleared so this session cannot leak candidates to later calls."); } } - }, ct: ct); + }, ct: ct, target: target); }); private string[] ValidateCandidates(string[]? indexes) diff --git a/src/PostgreSqlMcp.Tools/SqlTools.cs b/src/PostgreSqlMcp.Tools/SqlTools.cs index 1354fc0..84d77b2 100644 --- a/src/PostgreSqlMcp.Tools/SqlTools.cs +++ b/src/PostgreSqlMcp.Tools/SqlTools.cs @@ -9,8 +9,8 @@ namespace PostgreSqlMcp.Tools; public sealed class SqlTools(SqlExecutor executor) { [McpServerTool(Name = "execute_sql", ReadOnly = true, Destructive = false, OpenWorld = false, Idempotent = false)] - [Description("Run one SQL statement on an explicit target. Bounded column/row arrays; next_offset re-executes read-only SQL. Use stable ORDER BY. Writes need unrestricted mode and read_only=false; never replay a truncated write.")] + [Description("Run one SQL statement on a physical database or configured profile alias. Optional target selects a connection profile. Bounded column/row arrays; next_offset re-executes read-only SQL. Use stable ORDER BY. Writes need unrestricted mode (default) and read_only=false; never replay a truncated write.")] public Task ExecuteSql(CancellationToken ct, string database, string sql, - int? limit = null, int offset = 0, bool read_only = true) => - ToolReply.Run(database, async () => await executor.QueryAsync(database, sql, limit: limit, offset: offset, readOnly: read_only, ct: ct).ConfigureAwait(false)); + int? limit = null, int offset = 0, bool read_only = true, string? target = null) => + ToolReply.Run(database, async () => await executor.QueryAsync(database, sql, limit: limit, offset: offset, readOnly: read_only, ct: ct, target: target).ConfigureAwait(false)); } diff --git a/src/PostgreSqlMcp/PostgreSqlMcp.csproj b/src/PostgreSqlMcp/PostgreSqlMcp.csproj index 1752dfe..7d0be9d 100644 --- a/src/PostgreSqlMcp/PostgreSqlMcp.csproj +++ b/src/PostgreSqlMcp/PostgreSqlMcp.csproj @@ -1,13 +1,13 @@ Exe - 0.2.0 + 0.3.1 true codegiveness.postgresql-sharp-mcp postgresql-sharp-mcp codegiveness Copyright (c) 2026 codegiveness - Bounded, explicit multi-database PostgreSQL MCP server using Npgsql. + Database-agnostic PostgreSQL MCP server with live discovery and bounded per-call database selection using Npgsql. MIT README.md https://github.com/codegiveness/postgresql-sharp-mcp diff --git a/src/PostgreSqlMcp/Program.cs b/src/PostgreSqlMcp/Program.cs index ccbeaea..8f609a4 100644 --- a/src/PostgreSqlMcp/Program.cs +++ b/src/PostgreSqlMcp/Program.cs @@ -9,17 +9,23 @@ if (args.Contains("--help", StringComparer.Ordinal) || args.Contains("-h", StringComparer.Ordinal)) { Console.WriteLine(""" - postgresql-sharp-mcp — PostgreSQL MCP over stdio - --targets-file PATH JSON object: target alias -> Npgsql connection string - --connection-string STR Base credentials (POSTGRES_CONNECTION_STRING overrides CLI) - --databases JSON Explicit database allowlist (JSON string array) - --access-mode MODE restricted (default) or unrestricted + postgresql-sharp-mcp — database-agnostic PostgreSQL MCP over stdio + Recommended: inherit POSTGRES_CONNECTION_STRING from a prepared shell or secret manager. + No targets JSON file is required for a single PostgreSQL server. + --targets-file PATH JSON object: connection profile -> Npgsql seed connection + --connection-string STR Optional CLI value; prefer environment input to avoid exposed secret arguments + --databases JSON Optional explicit database allowlist (JSON string array) + --access-mode MODE unrestricted (default) or restricted --query-timeout SECONDS 1..600 (default 30) --log-level LEVEL trace|debug|information|warning|error|critical|none - --validate Check each configured target and exit; diagnostics on stderr + --validate Check each configured seed connection and exit; diagnostics on stderr --version Print executable version - Environment: POSTGRES_TARGETS or POSTGRES_TARGETS_FILE, or - POSTGRES_CONNECTION_STRING + POSTGRES_DATABASES. Targets JSON wins over file. + Environment: POSTGRES_CONNECTION_STRING, POSTGRES_TARGETS or POSTGRES_TARGETS_FILE. + POSTGRES_DATABASES optionally limits connection-string mode. Targets JSON wins over file. + Connection-string environment and targets JSON/file are mutually exclusive; unset stale profile settings. + Discover accessible databases with list_databases; select a physical name per call. + Optional tool target selects a profile; default is primary or the first ordinal alias. + No USE statement, shared current database, or per-database file editing is required. Limits: POSTGRES_MAX_ROWS, POSTGRES_MAX_RESULT_BYTES, POSTGRES_MAX_CELL_CHARS, POSTGRES_POOL_SIZE, POSTGRES_MAX_CONCURRENT_CALLS. """); diff --git a/tools/PostgreSqlMcp.Verify/Integration.cs b/tools/PostgreSqlMcp.Verify/Integration.cs index 1c02c1c..32e27f5 100644 --- a/tools/PostgreSqlMcp.Verify/Integration.cs +++ b/tools/PostgreSqlMcp.Verify/Integration.cs @@ -17,13 +17,15 @@ public static async Task RunAsync(string root) string connection = $"Host=127.0.0.1;Port={fixture.Port};Username=mcp_reader;Password=reader-disposable;Database="; var targets = new Dictionary { - ["a"] = connection + "tenant_a", ["a_copy"] = connection + "tenant_a", ["b"] = connection + "tenant_b", + ["a"] = connection + "tenant_a", ["b"] = connection + "tenant_b", + ["a_copy"] = $"Database=tenant_a;Password=reader-disposable;Username=mcp_reader;Port={fixture.Port};Host=127.0.0.1", ["denied"] = connection + "tenant_denied", ["missing"] = connection + "does_not_exist", ["bad_auth"] = connection.Replace("reader-disposable", "wrong", StringComparison.Ordinal) + "tenant_a", ["offline"] = "Host=127.0.0.1;Port=1;Username=mcp_reader;Password=reader-disposable;Database=tenant_a;Timeout=1" }; var environment = Processes.CleanEnvironment(); environment["POSTGRES_TARGETS"] = JsonSerializer.Serialize(targets); + environment["POSTGRES_ACCESS_MODE"] = "restricted"; environment["POSTGRES_POOL_SIZE"] = "2"; environment["POSTGRES_MAX_CONCURRENT_CALLS"] = "8"; environment["POSTGRES_MAX_RESULT_BYTES"] = "4096"; @@ -33,7 +35,7 @@ public static async Task RunAsync(string root) environment["POSTGRES_LOG_LEVEL"] = "trace"; await using (var client = await McpClient.StartAsync(command, environment)) { - await VerifyToolsAsync(client, targets.Keys.Order(StringComparer.Ordinal).ToArray()); + await VerifyToolsAsync(client); await VerifyPoolsAsync(client); await VerifyBoundariesAsync(client); await VerifyPaginationAsync(client); @@ -48,34 +50,38 @@ public static async Task RunAsync(string root) Console.WriteLine("PASS PostgreSQL error and trace-level request/result confidentiality"); await VerifyWritesAsync(command, environment, fixture.Port); await VerifyConfigurationAsync(command, environment, targets, connection); + await VerifyDiscoveryAsync(command, environment, fixture, connection); + await VerifyPoolCapacityAsync(command, environment, fixture, connection); Console.WriteLine("ALL MCP INTEGRATION SCENARIOS PASSED"); } - private static async Task VerifyToolsAsync(McpClient client, string[] aliases) + private static async Task VerifyToolsAsync(McpClient client) { JsonArray tools = (await client.RequestAsync("tools/list", new { }))["result"]!["tools"].Array(); string[] expected = ["list_databases", "list_schemas", "list_objects", "get_object_details", "execute_sql", "explain_query", "get_top_queries", "analyze_indexes", "analyze_db_health"]; Check.That(tools.Select(tool => tool!["name"].Text()).ToHashSet().SetEquals(expected), "Incorrect shared nine-tool set."); - foreach (JsonNode? tool in tools) - if (tool!["name"].Text() != "list_databases") - Check.That(tool!["inputSchema"]!["required"].Array().Any(item => item.Text() == "database"), "Database selector is not required."); - Check.That(tools.Single(tool => tool!["name"].Text() == "execute_sql")!["annotations"]!["readOnlyHint"].Flag(), "Restricted tool annotations are not read-only."); - Check.Equal((await client.OkAsync("list_databases"))["targets"], aliases, "Incorrect configured aliases."); + JsonNode listed = await client.OkAsync("list_databases", new { target = "a", limit = 2 }); + Check.That(listed["access_mode"].Text() == "restricted", "Explicit restricted access was not retained."); + Check.That(Check.Rows((await client.OkAsync("list_databases"))["databases"]!).Single(row => row["is_current"].Flag())["name"].Text() == "tenant_a", + "Default discovery did not select the ordinal-first profile when primary was absent."); var discovered = new List(); int offset = 0; do { - JsonNode page = await client.OkAsync("list_databases", new { limit = 2, offset }); - discovered.AddRange(page["targets"].Array().Select(item => item.Text())); + JsonNode page = (await client.OkAsync("list_databases", new { target = "a", limit = 2, offset }))["databases"]!; + discovered.AddRange(Check.Rows(page).Select(row => row["name"].Text())); if (page["next_offset"] is null) break; int next = page["next_offset"].Int(); - Check.That(next > offset, "Target pagination did not advance."); + Check.That(next > offset, "Catalog pagination did not advance."); offset = next; } while (true); - Check.That(discovered.SequenceEqual(aliases), "Target pagination omitted or duplicated aliases."); + Check.That(discovered.SequenceEqual(discovered.Distinct().Order(StringComparer.Ordinal)) + && discovered.Contains("tenant_a") && discovered.Contains("tenant_b") + && !discovered.Contains("tenant_denied") && !discovered.Contains("template0") && !discovered.Contains("template1"), + "Live catalog pagination omitted accessible databases, included templates/denied databases, or repeated rows."); JsonNode missing = await client.RequestAsync("tools/call", new { name = "execute_sql", arguments = new { sql = "SELECT 1" } }); Check.That(missing["error"] is not null || missing["result"]?["isError"]?.Flag() == true, "Missing database argument was accepted."); - Console.WriteLine("PASS nine-tool contract, explicit targets, restricted annotations, alias pagination"); + Console.WriteLine("PASS nine-tool contract, live catalog pagination and explicit restricted access"); } private static async Task VerifyPoolsAsync(McpClient client) @@ -113,11 +119,14 @@ private static async Task VerifyBoundariesAsync(McpClient client) { foreach (var (target, code, state) in new (string, string, string?)[] { - ("unconfigured", "invalid_target", null), ("denied", "postgresql_error", "42501"), + ("unconfigured", "postgresql_error", "3D000"), ("denied", "postgresql_error", "42501"), ("missing", "postgresql_error", "3D000"), ("bad_auth", "postgresql_error", "28P01"), ("offline", "connection_error", null) }) await client.FailsAsync("execute_sql", new { database = target, sql = "SELECT 1" }, code, state); + await client.FailsAsync("execute_sql", new { database = "tenant_a", target = "unconfigured", sql = "SELECT 1" }, "invalid_target"); + await client.FailsAsync("list_databases", new { target = "unconfigured" }, "invalid_target"); + await client.FailsAsync("list_databases", new { target = "offline" }, "connection_error"); await client.FailsAsync("execute_sql", new { database = "a", sql = "SELECT * FROM secret" }, "postgresql_error", "42501"); Check.Equal((await client.OkAsync("execute_sql", new { database = "b", sql = "SELECT value FROM marker" }))["rows"], new[] { new[] { "B_ONLY" } }, "Denied targets fell back to another database."); Check.Equal((await client.OkAsync("execute_sql", new { database = "a", sql = "SELECT id FROM scoped_rows ORDER BY id" }))["rows"], new[] { new[] { 1 } }, "RLS was bypassed."); @@ -287,12 +296,10 @@ private static async Task VerifyWritesAsync(Command command, Dictionary(environment) { - ["POSTGRES_ACCESS_MODE"] = "unrestricted", ["POSTGRES_TARGETS"] = JsonSerializer.Serialize(new { a = $"Host=127.0.0.1;Port={port};Username=mcp_writer;Password=writer-disposable;Database=tenant_a" }) }; + writerEnvironment.Remove("POSTGRES_ACCESS_MODE"); await using var client = await McpClient.StartAsync(command, writerEnvironment); - JsonNode execute = (await client.RequestAsync("tools/list", new { }))["result"]!["tools"].Array().Single(tool => tool!["name"].Text() == "execute_sql")!; - Check.That(!execute["annotations"]!["readOnlyHint"].Flag() && execute["annotations"]!["destructiveHint"].Flag(), "Unrestricted annotations incorrect."); await client.FailsAsync("execute_sql", new { database = "a", sql = "INSERT INTO marker VALUES ('WRITE_OK')" }, "postgresql_error", "25006"); JsonNode mutation = await client.OkAsync("execute_sql", new { database = "a", sql = "INSERT INTO marker VALUES ('WRITE_OK')", read_only = false }); Check.That(mutation["rows_affected"].Int() == 1, "Write opt-in did not report affected rows."); @@ -310,7 +317,170 @@ private static async Task VerifyWritesAsync(Command command, Dictionary environment, PostgresFixture fixture, string connection) + { + using var temporary = new TemporaryDirectory(); + string path = Path.Combine(temporary.Path, "seed.json"); + string seed = JsonSerializer.Serialize(new { primary = connection + "tenant_a" }); + await File.WriteAllTextAsync(path, seed); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + var fileEnvironment = new Dictionary(environment) { ["POSTGRES_TARGETS_FILE"] = path }; + fileEnvironment.Remove("POSTGRES_TARGETS"); + await using (var client = await McpClient.StartAsync(command, fileEnvironment)) + { + Check.Equal((await client.OkAsync("execute_sql", new { database = "tenant_b", target = "primary", sql = "SELECT current_database(),value FROM marker" }))["rows"], + new[] { new[] { "tenant_b", "B_ONLY" } }, "Optional seed file did not select another physical database."); + Check.That(await File.ReadAllTextAsync(path) == seed, "Dynamic selection rewrote the protected seed file."); + await client.StopAsync(); + Check.Confidential(client.StandardError, "reader-disposable", path); + } + foreach (var (key, value, validationCommand) in new[] + { + ("POSTGRES_TARGETS", seed, command.With("--validate")), + ("POSTGRES_TARGETS_FILE", path, command.With("--validate")), + ("", path, command.With("--targets-file", path, "--validate")) + }) + { + var conflictEnvironment = new Dictionary(environment) + { + ["POSTGRES_CONNECTION_STRING"] = connection + "tenant_a" + }; + conflictEnvironment.Remove("POSTGRES_TARGETS"); + if (key.Length != 0) conflictEnvironment[key] = value; + ProcessResult conflict = await Processes.RunAsync(validationCommand, conflictEnvironment, expected: 1); + Check.That(conflict.Output.Length == 0, "Conflicting environment/profile configuration contaminated stdout."); + Check.Confidential(conflict.Error, "reader-disposable", connection + "tenant_a", path); + } + var discoveryEnvironment = new Dictionary(environment) + { + ["POSTGRES_CONNECTION_STRING"] = connection + "tenant_a" + }; + discoveryEnvironment.Remove("POSTGRES_TARGETS"); + await using (var client = await McpClient.StartAsync(command, discoveryEnvironment)) + { + JsonNode listed = await client.OkAsync("list_databases"); + var initial = Check.Rows(listed["databases"]!).ToArray(); + Check.That(initial.Any(row => row["name"].Text() == "tenant_a" && row["is_current"].Flag()) + && initial.Any(row => row["name"].Text() == "tenant_b" && !row["is_current"].Flag()), + "Environment-only primary seed did not discover other physical databases or mark the bootstrap database."); + var requests = Enumerable.Range(0, 24).Select(index => + { + string database = index % 2 == 0 ? "tenant_a" : "tenant_b"; + return (Database: database, Task: client.OkAsync("execute_sql", new { database, + sql = "SELECT current_database(),value FROM marker CROSS JOIN LATERAL (SELECT pg_sleep(0.02)) s" })); + }).ToArray(); + JsonNode[] results = await Task.WhenAll(requests.Select(request => request.Task)); + for (int index = 0; index < results.Length; index++) + Check.Equal(results[index]["rows"], new[] { new[] { requests[index].Database, + requests[index].Database == "tenant_a" ? "A_ONLY" : "B_ONLY" } }, "Concurrent physical-name selection crossed a database boundary."); + await VerifyOptionalTargetAsync(client); + await fixture.CreateDatabaseAsync(PostgresFixture.PunctuationDatabase); + await fixture.CreateDatabaseAsync("tenant_after_start"); + await fixture.CreateDatabaseAsync("tenant_no_connections"); + await fixture.SqlAsync("postgres", "ALTER DATABASE tenant_no_connections ALLOW_CONNECTIONS false;"); + var fresh = Check.Rows((await client.OkAsync("list_databases"))["databases"]!).Select(row => row["name"].Text()).ToHashSet(); + Check.That(fresh.Contains("tenant_after_start") && fresh.Contains(PostgresFixture.PunctuationDatabase) + && !fresh.Contains("tenant_no_connections"), "Live discovery cached the catalog or included a nonconnectable database."); + Check.Equal((await client.OkAsync("execute_sql", new { database = PostgresFixture.PunctuationDatabase, target = "primary", + sql = "SELECT current_database(),value FROM marker" }))["rows"], + new[] { new[] { PostgresFixture.PunctuationDatabase, "DYNAMIC_ONLY" } }, "Punctuation in a physical name escaped its connection-string boundary."); + await fixture.SqlAsync("postgres", "REVOKE CONNECT ON DATABASE tenant_after_start FROM PUBLIC,mcp_reader;"); + Check.That(Check.Rows((await client.OkAsync("list_databases"))["databases"]!).All(row => row["name"].Text() != "tenant_after_start"), + "Revoked CONNECT remained visible in discovery."); + await client.FailsAsync("execute_sql", new { database = "tenant_after_start", sql = "SELECT 1" }, "postgresql_error", "42501"); + await client.FailsAsync("execute_sql", new { database = "physical_missing", target = "primary", sql = "SELECT 1" }, "postgresql_error", "3D000"); + await client.FailsAsync("execute_sql", new { database = "tenant_b", target = "unknown_profile", sql = "SELECT 1" }, "invalid_target"); + Check.Equal((await client.OkAsync("execute_sql", new { database = "tenant_b", sql = "SELECT value FROM marker" }))["rows"], + new[] { new[] { "B_ONLY" } }, "Failed dynamic selection fell back or poisoned another database."); + await client.StopAsync(); + Check.Confidential(client.StandardError, "reader-disposable"); + } + var precedenceEnvironment = new Dictionary(environment) + { + ["POSTGRES_TARGETS"] = JsonSerializer.Serialize(new Dictionary + { + ["a"] = connection + "tenant_b", ["primary"] = connection + "tenant_a", ["tenant_b"] = connection + "tenant_a" + }) + }; + await using (var client = await McpClient.StartAsync(command, precedenceEnvironment)) + { + Check.That(Check.Rows((await client.OkAsync("list_databases"))["databases"]!).Single(row => row["is_current"].Flag())["name"].Text() == "tenant_a", + "Default profile did not prefer primary."); + Check.Equal((await client.OkAsync("execute_sql", new { database = "tenant_b", sql = "SELECT current_database()" }))["rows"], + new[] { new[] { "tenant_a" } }, "Exact configured alias no longer selected its bootstrap database."); + Check.Equal((await client.OkAsync("execute_sql", new { database = "tenant_b", target = "primary", sql = "SELECT current_database()" }))["rows"], + new[] { new[] { "tenant_b" } }, "Explicit profile treated a physical database name as an alias."); + } + Console.WriteLine("PASS optional immutable protected seed file, env/profile conflict rejection, environment-only primary seed, concurrent physical selection, nine optional targets, live create/revoke, profile precedence and punctuation-safe names"); + } + + private static async Task VerifyOptionalTargetAsync(McpClient client) + { + Check.Equal((await client.OkAsync("execute_sql", new { database = "tenant_b", target = "primary", sql = "SELECT value FROM marker" }))["rows"], + new[] { new[] { "B_ONLY" } }, "Explicit target failed to select another physical database."); + Check.That(Check.Rows((await client.OkAsync("list_databases", new { target = "primary" }))["databases"]!).Any(row => row["name"].Text() == "tenant_b"), + "Explicit discovery profile lost an accessible database."); + Check.That(Check.Rows(await client.OkAsync("list_schemas", new { database = "tenant_b", target = "primary", prefix = "app" })) + .Select(row => row["schema_name"].Text()).SequenceEqual(new[] { "app" }), "Explicit profile schema discovery failed."); + Check.That(Check.Rows(await client.OkAsync("list_objects", new { database = "tenant_b", target = "primary", schema = "public", type = "table" })) + .Any(row => row["name"].Text() == "marker"), "Explicit profile object discovery failed."); + Check.That(Check.Rows((await client.OkAsync("get_object_details", new { database = "tenant_b", target = "primary", schema = "public", name = "marker", section = "columns" }))["page"]!) + .Single()["name"].Text() == "value", "Explicit profile object details failed."); + JsonNode plan = await client.OkAsync("explain_query", new { database = "tenant_b", target = "primary", sql = "SELECT value FROM marker", format = "json" }); + Check.That(plan["plan"]![0]!["Plan"]!["Relation Name"].Text() == "marker", "Explicit profile plan selected the wrong relation."); + await client.FailsAsync("get_top_queries", new { database = "tenant_b", target = "primary" }, "extension_missing"); + JsonNode indexes = await client.OkAsync("analyze_indexes", new { database = "tenant_b", target = "primary", schema = "public", table = "orders" }); + Check.That(Check.Rows(indexes["result"]!).Count(row => row["index_name"].Text().StartsWith("orders_customer_duplicate", StringComparison.Ordinal)) == 2, + "Explicit profile index analysis lost database evidence."); + JsonNode health = await client.OkAsync("analyze_db_health", new { database = "tenant_b", target = "primary", section = "summary" }); + Check.That(Check.Rows(health["result"]!).Single()["database_name"].Text() == "tenant_b", "Explicit profile health evidence crossed databases."); + } + + private static async Task VerifyPoolCapacityAsync(Command command, Dictionary environment, PostgresFixture fixture, string connection) + { + string[] databases = Enumerable.Range(0, 10).Select(index => "pool_churn_" + index).ToArray(); + foreach (string database in databases) await fixture.CreateDatabaseAsync(database); + var poolEnvironment = new Dictionary(environment) + { + ["POSTGRES_CONNECTION_STRING"] = connection + "tenant_a", + ["POSTGRES_POOL_SIZE"] = "32", ["POSTGRES_MAX_CONCURRENT_CALLS"] = "16" + }; + poolEnvironment.Remove("POSTGRES_TARGETS"); + await using (var client = await McpClient.StartAsync(command, poolEnvironment)) + { + foreach (string database in databases) + { + Check.Equal((await client.OkAsync("execute_sql", new { database, sql = "SELECT current_database(),value FROM marker" }))["rows"], + new[] { new[] { database, "DYNAMIC_ONLY" } }, "Pool churn changed physical database selection."); + Check.That(await fixture.RuntimeBackendCountAsync() <= 8, "Idle pool eviction did not bound runtime backend retention."); + } + for (int index = 0; index < 10; index++) + await client.FailsAsync("execute_sql", new { database = "failed_pool_" + index, sql = "SELECT 1" }, "postgresql_error", "3D000"); + Task[] active = databases.Take(8).Select(database => + client.FailsAsync("execute_sql", new { database, sql = "SELECT pg_sleep(10)::text" }, "timeout")).ToArray(); + bool saturated = false; + for (int attempt = 0; attempt < 20; attempt++) + { + if (await fixture.RuntimeBackendCountAsync(activeOnly: true) == 8) { saturated = true; break; } + await Task.Delay(50); + } + Check.That(saturated, "Active-capacity fixture never reached eight simultaneous physical pools."); + var elapsed = System.Diagnostics.Stopwatch.StartNew(); + Task waiting = client.FailsAsync("execute_sql", new { database = databases[8], sql = "SELECT pg_sleep(2)::text" }, "timeout"); + await Task.Delay(100); + Check.That(await fixture.RuntimeBackendCountAsync() <= 8, "A busy pool was evicted or active capacity was exceeded."); + await waiting; + Check.That(elapsed.Elapsed < TimeSpan.FromSeconds(4.5), "Capacity waiting reset the original query deadline."); + await Task.WhenAll(active); + Check.Equal((await client.OkAsync("execute_sql", new { database = databases[9], sql = "SELECT value FROM marker" }))["rows"], + new[] { new[] { "DYNAMIC_ONLY" } }, "Timed-out capacity wait leaked a lease or poisoned later selection."); + } + Check.That(await fixture.RuntimeBackendCountAsync() == 0, "Runtime disposal retained physical-database backends."); + Console.WriteLine("PASS environment-only bounded idle churn, failed-name recovery, busy-pool capacity, original-deadline cancellation and deterministic disposal"); } private static async Task VerifyConfigurationAsync(Command command, Dictionary environment, Dictionary targets, string connection) @@ -329,7 +499,23 @@ private static async Task VerifyConfigurationAsync(Command command, Dictionary row["name"].Text()) + .SequenceEqual(new[] { "tenant_a", "tenant_b" }), "Optional allowlist did not filter live discovery."); + await client.FailsAsync("execute_sql", new { database = "postgres", sql = "SELECT 1" }, "invalid_target"); + await client.FailsAsync("execute_sql", new { database = "postgres", target = "tenant_a", sql = "SELECT 1" }, "invalid_target"); + } + baseEnvironment.Remove("POSTGRES_DATABASES"); + baseEnvironment["POSTGRES_CONNECTION_STRING"] = connection[..^"Database=".Length]; + await using (var client = await McpClient.StartAsync(command, baseEnvironment)) + { + Check.That(Check.Rows((await client.OkAsync("list_databases"))["databases"]!).Single(row => row["is_current"].Flag())["name"].Text() == "postgres", + "Connection string without Database did not bootstrap postgres."); + Check.Equal((await client.OkAsync("execute_sql", new { database = "tenant_a", sql = "SELECT value FROM marker" }))["rows"], + new[] { new[] { "A_ONLY" } }, "Default postgres bootstrap prevented physical selection."); + } ProcessResult unsafeCli = await Processes.RunAsync(command.With("--sensitive-cli-marker"), environment, expected: 1); Check.That(unsafeCli.Output.Length == 0, "Unknown CLI input contaminated stdout."); Check.Confidential(unsafeCli.Error, "sensitive-cli-marker"); diff --git a/tools/PostgreSqlMcp.Verify/Packages.cs b/tools/PostgreSqlMcp.Verify/Packages.cs index 61f4bb3..2cae19e 100644 --- a/tools/PostgreSqlMcp.Verify/Packages.cs +++ b/tools/PostgreSqlMcp.Verify/Packages.cs @@ -12,7 +12,7 @@ namespace PostgreSqlMcp.Verify; internal static class Packages { - public static async Task RunAsync(string root, string artifacts, string? targetsFile) + public static async Task RunAsync(string root, string artifacts, string? targetsFile, bool installationOnly) { JsonNode metadata = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(root, "npm", "package.json")))!; XDocument project = XDocument.Load(Path.Combine(root, "src", "PostgreSqlMcp", "PostgreSqlMcp.csproj")); @@ -70,18 +70,21 @@ await Processes.RunAsync(new(dotnet, "tool", "install", packageId, "--version", }; // Exercise the actual apphost too, rather than accepting only cmd-shim success on Windows. await Processes.RunAsync(new(native, "--help"), environment); + await using var fixture = targetsFile is null && !installationOnly ? new PostgresFixture() : null; + if (fixture is not null) + await fixture.StartAsync(); await using (var unavailable = new UnavailableEndpoint()) { var configured = new Dictionary(environment) { - ["POSTGRES_TARGETS"] = JsonSerializer.Serialize(new { package_smoke = $"Host=127.0.0.1;Port={unavailable.Port};Database=package_smoke;Username=package_smoke;Password=disposable-package-secret;Timeout=1" }), + ["POSTGRES_CONNECTION_STRING"] = $"Host=127.0.0.1;Port={unavailable.Port};Database=package_smoke;Username=package_smoke;Password=disposable-package-secret;Timeout=1", ["POSTGRES_MAX_RESULT_BYTES"] = "4096" }; foreach (var (name, command) in commands) { await VerifyCliAsync(name, command, version, environment, configured); - await VerifyMcpAsync(command, configured, ["package_smoke"]); - if (!OperatingSystem.IsWindows()) await VerifyMcpAsync(command, configured, ["package_smoke"], terminate: true); + await VerifyMcpAsync(command, configured, ["primary"]); + if (!OperatingSystem.IsWindows()) await VerifyMcpAsync(command, configured, ["primary"], terminate: true); if (targetsFile is not null) { string[] aliases = await SafeFixtureAliasesAsync(targetsFile); @@ -98,11 +101,23 @@ await Processes.RunAsync(new(dotnet, "tool", "install", packageId, "--version", await VerifyMcpAsync(command, fixtureEnvironment, aliases, query: true); Console.WriteLine($"{name}: all explicit loopback fixture targets validated and SELECT current_database() passed"); } + else if (fixture is not null) + { + var fixtureEnvironment = new Dictionary(environment) + { + ["POSTGRES_CONNECTION_STRING"] = $"Host=127.0.0.1;Port={fixture.Port};Database=tenant_a;Username=mcp_writer;Password=writer-disposable", + ["POSTGRES_MAX_RESULT_BYTES"] = "4096" + }; + await VerifyMcpAsync(command, fixtureEnvironment, ["primary"], query: true, fixture: fixture); + Console.WriteLine($"{name}: environment-only primary bootstrap, live discovery, physical selection, read-only default, write opt-in and cleanup passed"); + } Console.WriteLine($"{name}: installed CLI, validation, MCP initialize/tools/list/list_databases and shutdown passed"); } - if (OperatingSystem.IsWindows()) await VerifyMcpAsync(new(native), configured, ["package_smoke"]); + if (OperatingSystem.IsWindows()) await VerifyMcpAsync(new(native), configured, ["primary"]); } Console.WriteLine("Package verification passed; offline local installs, real native commands, missing .NET prerequisite and shutdown verified."); + if (installationOnly) + Console.WriteLine("Installation-only mode: live database discovery/query/write scenarios were not run; use default package verification for that coverage."); } private static Command NpmInstall(string npm, string home, string tarball) => new(npm, "install", "--prefix", home, @@ -166,34 +181,66 @@ private static async Task VerifyCliAsync(string name, Command command, string ve private static JsonNode[] Reports(string text) => text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(line => JsonNode.Parse(line)!).ToArray(); - private static async Task VerifyMcpAsync(Command command, Dictionary environment, string[] aliases, bool terminate = false, bool query = false) + private static async Task VerifyMcpAsync(Command command, Dictionary environment, string[] aliases, bool terminate = false, bool query = false, PostgresFixture? fixture = null) { await using var client = await McpClient.StartAsync(command, environment); JsonArray tools = (await client.RequestAsync("tools/list", new { }))["result"]!["tools"].Array(); Check.That(tools.Any(tool => tool!["name"].Text() == "list_databases"), "Installed package lost list_databases tool."); - var discovered = new List(); - int offset = 0; - while (true) + if (!query) + await client.FailsAsync("list_databases", new { }, "connection_error"); + else { - JsonNode listed = await client.OkAsync("list_databases", new { limit = 2, offset }); - discovered.AddRange(listed["targets"].Array().Select(alias => alias.Text())); - Check.That(listed["access_mode"].Text() == "restricted", "Installed package did not retain restricted access."); - if (listed["next_offset"] is null) break; - int next = listed["next_offset"].Int(); - Check.That(next > offset, "Installed package alias pagination did not advance."); - offset = next; - } - Check.That(discovered.Order(StringComparer.Ordinal).SequenceEqual(aliases.Order(StringComparer.Ordinal)), "Installed package returned incorrect target aliases."); - if (query) foreach (string alias in aliases) { - JsonNode page = await client.OkAsync("execute_sql", new { database = alias, sql = "SELECT current_database() AS database, 42 AS answer", limit = 1 }); - Check.That(page["database"].Text() == alias && page["columns"].Array().Select(column => column!["name"].Text()).SequenceEqual(new[] { "database", "answer" }), "Installed package query returned incorrect target or columns."); - Check.That(page["rows"].Array().Count == 1 && page["rows"]![0]![0] is JsonValue database && database.TryGetValue(out string? value) - && !string.IsNullOrWhiteSpace(value) && page["rows"]![0]![1].Int() == 42, "Installed package query returned incorrect values."); + JsonNode selected = await client.OkAsync("execute_sql", new { database = alias, sql = "SELECT current_database() AS database, 42 AS answer", limit = 1 }); + string physical = selected["rows"]![0]![0].Text(); + Check.That(selected["rows"]![0]![1].Int() == 42, "Installed package query returned incorrect values."); + var discovered = new List(); + int offset = 0; + bool currentFound = false; + while (true) + { + JsonNode page = (await client.OkAsync("list_databases", new { target = alias, limit = 2, offset }))["databases"]!; + foreach (var row in Check.Rows(page)) + { + discovered.Add(row["name"].Text()); + if (row["is_current"].Flag()) + { + Check.That(row["name"].Text() == physical && !currentFound, "Installed discovery reported an incorrect current database."); + currentFound = true; + } + } + if (page["next_offset"] is null) break; + int next = page["next_offset"].Int(); + Check.That(next > offset, "Installed package catalog pagination did not advance."); + offset = next; + } + Check.That(currentFound && discovered.SequenceEqual(discovered.Distinct().Order(StringComparer.Ordinal)), + "Installed package catalog omitted the bootstrap database or repeated/reordered rows."); } + if (fixture is not null) + { + Check.Equal((await client.OkAsync("execute_sql", new { database = "tenant_b", sql = "SELECT current_database(),value FROM marker" }))["rows"], + new[] { new[] { "tenant_b", "B_ONLY" } }, "Installed single-seed package did not select a discovered physical database."); + string database = "package_after_start_" + Guid.NewGuid().ToString("N"); + await fixture.CreateDatabaseAsync(database); + Check.That(Check.Rows((await client.OkAsync("list_databases"))["databases"]!).Any(row => row["name"].Text() == database), + "Installed package cached database discovery across catalog changes."); + Check.Equal((await client.OkAsync("execute_sql", new { database, sql = "SELECT current_database(),value FROM marker" }))["rows"], + new[] { new[] { database, "DYNAMIC_ONLY" } }, "Installed package could not select a database created after startup."); + await client.FailsAsync("execute_sql", new { database = "tenant_b", sql = "INSERT INTO marker VALUES ('PACKAGE_WRITE')" }, "postgresql_error", "25006"); + Check.Equal((await client.OkAsync("execute_sql", new { database = "tenant_b", sql = "SELECT value FROM marker" }))["rows"], + new[] { new[] { "B_ONLY" } }, "Installed environment-only package committed a default read-only mutation."); + await client.OkAsync("execute_sql", new { database = "tenant_b", sql = "INSERT INTO marker VALUES ('PACKAGE_WRITE')", read_only = false }); + Check.Equal((await client.OkAsync("execute_sql", new { database = "tenant_b", sql = "SELECT value FROM marker ORDER BY value" }))["rows"], + new[] { new[] { "B_ONLY" }, new[] { "PACKAGE_WRITE" } }, "Installed environment-only package write opt-in did not commit."); + await client.OkAsync("execute_sql", new { database = "tenant_b", sql = "DELETE FROM marker WHERE value='PACKAGE_WRITE'", read_only = false }); + Check.Equal((await client.OkAsync("execute_sql", new { database = "tenant_b", sql = "SELECT value FROM marker" }))["rows"], + new[] { new[] { "B_ONLY" } }, "Installed environment-only package write cleanup did not commit."); + } + } await client.StopAsync(terminate); - Check.Confidential(client.StandardError, "disposable-package-secret"); + Check.Confidential(client.StandardError, "disposable-package-secret", "reader-disposable", "writer-disposable"); } private static async Task SafeFixtureAliasesAsync(string path) @@ -263,21 +310,49 @@ private static async Task VerifyNoWorkspacePathAsync(Stream stream, byte[][] pat private sealed class UnavailableEndpoint : IAsyncDisposable { private readonly Socket socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + private readonly CancellationTokenSource stopping = new(); + private readonly Task rejecting; public int Port { get; } public UnavailableEndpoint() { try { - // Bound, deliberately non-listening loopback socket prevents a race with another service. + // Own the port and reject handshakes without OS-specific non-listening-socket behavior. socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + socket.Listen(8); Port = ((IPEndPoint)socket.LocalEndPoint!).Port; + rejecting = RejectAsync(); } catch { socket.Dispose(); + stopping.Dispose(); throw; } } - public ValueTask DisposeAsync() { socket.Dispose(); return ValueTask.CompletedTask; } + private async Task RejectAsync() + { + try + { + while (true) + { + using Socket connection = await socket.AcceptAsync(stopping.Token); + } + } + catch (OperationCanceledException) when (stopping.IsCancellationRequested) + { + } + } + + public async ValueTask DisposeAsync() + { + await stopping.CancelAsync(); + try { await rejecting; } + finally + { + socket.Dispose(); + stopping.Dispose(); + } + } } } diff --git a/tools/PostgreSqlMcp.Verify/PostgresFixture.cs b/tools/PostgreSqlMcp.Verify/PostgresFixture.cs index 036250d..f438ada 100644 --- a/tools/PostgreSqlMcp.Verify/PostgresFixture.cs +++ b/tools/PostgreSqlMcp.Verify/PostgresFixture.cs @@ -8,6 +8,7 @@ internal sealed class PostgresFixture : IAsyncDisposable private bool started; private bool attempted; public int Port { get; private set; } + public const string PunctuationDatabase = "tenant ' ; \" = punctuation"; public async Task StartAsync() { @@ -32,8 +33,24 @@ await Processes.RunAsync(new("docker", "run", "-d", "--name", name, "-e", "POSTG } public Task SqlAsync(string database, string sql) => Processes.RunAsync( - new("docker", "exec", "-i", name, "psql", "-X", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-d", database), input: sql); + new("docker", "exec", "-i", "-e", "PGDATABASE=" + database, name, "psql", "-X", "-v", "ON_ERROR_STOP=1", "-U", "postgres"), input: sql); + public async Task CreateDatabaseAsync(string database) + { + await SqlAsync("postgres", $"CREATE DATABASE {Identifier(database)};"); + await SqlAsync(database, "CREATE TABLE marker(value text NOT NULL); INSERT INTO marker VALUES ('DYNAMIC_ONLY'); GRANT SELECT ON marker TO mcp_reader,mcp_writer;"); + } + + public async Task RuntimeBackendCountAsync(bool activeOnly = false) + { + ProcessResult result = await SqlAsync("postgres", "SELECT count(*) FROM pg_stat_activity WHERE application_name='postgresql-sharp-mcp'" + + (activeOnly ? " AND state='active'" : "") + ";"); + string value = result.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(line => line.Trim()) + .Single(line => int.TryParse(line, NumberStyles.None, CultureInfo.InvariantCulture, out _)); + return int.Parse(value, CultureInfo.InvariantCulture); + } + + public static string Identifier(string value) => "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; private async Task SeedAsync() { await SqlAsync("postgres", """ diff --git a/tools/PostgreSqlMcp.Verify/Program.cs b/tools/PostgreSqlMcp.Verify/Program.cs index 27bdf66..f7868b5 100644 --- a/tools/PostgreSqlMcp.Verify/Program.cs +++ b/tools/PostgreSqlMcp.Verify/Program.cs @@ -22,20 +22,29 @@ public static async Task Main(string[] args) { string artifacts = Path.Combine(root, "artifacts", "packages"); string? targetsFile = null; - for (int i = 1; i < args.Length; i += 2) + bool installationOnly = false; + for (int i = 1; i < args.Length;) { - Check.That(i + 1 < args.Length, "Missing verifier option value."); - switch (args[i]) + string option = args[i++]; + if (option == "--installation-only") { - case "--artifacts": artifacts = Path.GetFullPath(args[i + 1]); break; - case "--targets-file": targetsFile = Path.GetFullPath(args[i + 1]); break; + installationOnly = true; + continue; + } + Check.That(i < args.Length, "Missing verifier option value."); + string value = args[i++]; + switch (option) + { + case "--artifacts": artifacts = Path.GetFullPath(value); break; + case "--targets-file": targetsFile = Path.GetFullPath(value); break; default: throw new VerificationException("Unknown verifier option."); } } - await Packages.RunAsync(root, artifacts, targetsFile); + Check.That(!installationOnly || targetsFile is null, "--installation-only cannot be combined with --targets-file."); + await Packages.RunAsync(root, artifacts, targetsFile, installationOnly); } else - throw new VerificationException("Usage: integration | packages [--artifacts directory] [--targets-file disposable-targets.json] | sarif --baseline directory --candidate directory | sarif-regressions"); + throw new VerificationException("Usage: integration | packages [--artifacts directory] [--targets-file disposable-targets.json | --installation-only] | sarif --baseline directory --candidate directory | sarif-regressions"); return 0; } catch (Exception ex)