diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b7c72..df4ce5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ - Performance: every tool call saves one network round trip. The transaction setup (`SET TRANSACTION READ ONLY`/`READ WRITE`, `standard_conforming_strings`, statement and lock timeouts) now travels in the same batch as the first statement. With 25 ms added to each server response, median `execute_sql` latency fell from 106.8 ms to 80.8 ms. `SqlSession` no longer exposes its connection, transaction or any batch: it builds and owns every batch itself and prepends any pending setup, so no caller can run SQL before the transaction mode is set. - Document PostgreSQL 13 as the supported minimum with per-feature notes in the README. `get_top_queries` now adapts to the installed `pg_stat_statements` version: with extension 1.8 (no `toplevel`, as on PostgreSQL 13 or an upgraded cluster) it works and returns `toplevel` as null, explained in `notes`; below 1.8 it returns the specific `extension_outdated` error telling the administrator to run `ALTER EXTENSION pg_stat_statements UPDATE`, replacing the generic "A referenced column does not exist." (42703). On PostgreSQL 14+ with a current extension the result is unchanged. The `extension_missing` and `extension_not_ready` (55000) messages no longer assume `shared_preload_libraries` is editable, so they fit managed services. - Verifier and CI: `POSTGRES_FIXTURE_MAJOR` (13–18, default 17) selects the integration fixture's PostgreSQL major, which is pulled by manifest-list digest with HypoPG at an exact pgdg version. CI runs the integration scenarios for 13–16 and 18 in a new `integration` matrix job next to the existing PostgreSQL 17 `verify` job. A new scenario installs `pg_stat_statements` 1.8 and 1.7 to cover the adaptation. +- `--help` now lists every option with its environment variable, default, bounds and precedence (environment connection string over `--connection-string`, targets JSON over file, connection string versus targets exclusivity), including `POSTGRES_ACCESS_MODE`, `POSTGRES_QUERY_TIMEOUT` and `POSTGRES_LOG_LEVEL`, matching `ServerOptions.Parse`. The help text is hand-written, not generated from the parser. It states that a connection string creates the `primary` profile only without a database lock, that with `--databases` each locked name is a target alias of that one login profile, that only targets profiles count toward the pool-size budget, and that `--validate` also prints privilege/TLS warnings. +- Range errors for `--query-timeout` now name the option that was used (`--query-timeout must be 1..600.`) instead of `POSTGRES_QUERY_TIMEOUT`; the environment variable still reports its own name. The message wording is the only behavior change; exit code and valid ranges are unchanged. +- README: add a Docker subsection (build from source, MCP client JSON with explicit `-e` forwarding of every setting because the MCP `env` block reaches only the `docker` CLI, `127.0.0.1` inside a container, non-root targets-file permissions), rollback guidance for the 0.4.0 database-lock startup refusal (including removing `--require-database-lock` when pinning 0.3.3, which rejects it), the lock-name and alias rules, and the note that the `postgres` default bootstrap database applies to connection-string mode only. +- Documentation: attribute the discovery and unrestricted-default contract to 0.3.1. Source version 0.3.0 was never tagged or released; README, CONTRIBUTING and the security posture no longer say 0.3.0 or newer is required. - Show a grouped README badge table covering build/tests, security scanning, supply chain, registries, platforms, stack versions, features and project activity. Feature and practice badges link to the documentation or configuration that supports them; stack versions are read live from `global.json` and `Directory.Packages.props`. - Release npm job: install npm 12.2.0 from its registry tarball after a pinned SHA-512 check matching the published `dist.integrity`, replacing the unpinned `npm install --global` flagged by Scorecard Pinned-Dependencies. - Release npm job: accept npm 12's one-element JSON array from `npm view @ version --json`. Previously an already-published version failed with "requires an element of type 'String'" instead of being reported as not republished. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd18ced..4f3e053 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -195,7 +195,7 @@ Keep versions, affected help/docs and release notes consistent. Update [THIRD-PA ## Automation and releases -Compatible fixes use a patch increment, such as **0.3.2 → 0.3.3**; 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.3** removes npm's installed-.NET prerequisite without changing database-access behavior; the .NET tool still requires .NET. **0.4.0** adds the database lock and its startup PostgreSQL grant check, which can refuse startup for existing `POSTGRES_DATABASES` configurations. A version in source is not evidence of NuGet/npm/GitHub Release publication; check the corresponding registry endpoint and release jobs. +Compatible fixes use a patch increment, such as **0.3.2 → 0.3.3**; reserve a minor increment for new or incompatible 0.x contracts. Discovery and the unrestricted-default contract remain the capability boundary introduced by **0.3.1**, the first release that contains it (source version 0.3.0 was never tagged or released). **0.3.3** removes npm's installed-.NET prerequisite without changing database-access behavior; the .NET tool still requires .NET. **0.4.0** adds the database lock and its startup PostgreSQL grant check, which can refuse startup for existing `POSTGRES_DATABASES` configurations. 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 stages through package-specific GitHub OIDC, without `NPM_TOKEN` or token fallback, and waits for Windows 2FA approval; NuGet uses its own matching OIDC policy with `NUGET_USERNAME`. Existing public registry versions are skipped. Artifact creation, staging or a successful GitHub release alone does not prove registry publication. diff --git a/README.md b/README.md index d234734..8478e8e 100644 --- a/README.md +++ b/README.md @@ -248,12 +248,14 @@ For a remote server needing a provider CA certificate, retain `SSL Mode=VerifyFu ### Upgrading an existing installation -Automatic discovery and the unrestricted default require **0.3.0 or newer**; npm runtime bundling requires **0.3.3 or newer**. This guide targets **0.4.0**, which adds the [database lock](#database-lock): an existing `POSTGRES_DATABASES` configuration now refuses to start unless PostgreSQL grants match the list (see the [hardening guide](SECURITY.md#database-lock-hardening)). Older 0.2.0 executables use configured database aliases and a restricted default. Changing configuration does not upgrade an old executable. +Automatic discovery and the unrestricted default require **0.3.1 or newer**: the contract was developed under source version 0.3.0, which was never tagged or released, so 0.3.1 is the first release that contains it. npm runtime bundling requires **0.3.3 or newer**. This guide targets **0.4.0**, which adds the [database lock](#database-lock): an existing `POSTGRES_DATABASES` configuration now refuses to start unless PostgreSQL grants match the list (see the [hardening guide](SECURITY.md#database-lock-hardening)). Older 0.2.0 executables use configured database aliases and a restricted default. Changing configuration does not upgrade an old executable. After publication, update an existing global .NET tool with `dotnet tool update --global codegiveness.postgresql-sharp-mcp --version 0.4.0`. For npx, update the version in your client's `args` to `@codegiveness/postgresql-sharp-mcp@0.4.0`; retain package-specific script approval. For archives, fully quit the client/server and replace the extracted files together. Recheck `--version` before reconnecting. **Breaking behavior:** a targets-file entry is now a host/login connection profile, not a database allowlist. The same credentials can select other physical databases on that PostgreSQL server. Omitted access mode means `unrestricted`; `list_databases` returns live databases rather than configured aliases. Preserve an existing explicit `POSTGRES_ACCESS_MODE=restricted` and review PostgreSQL grants before upgrading. +**Rolling back the 0.4.0 lock startup refusal:** if an existing `POSTGRES_DATABASES`/`--databases` configuration is refused after upgrading, you do not have to give up the upgrade permanently. Read the refusal findings on stderr, then either (a) pin the previous release (**0.3.3**: `dotnet tool update --global codegiveness.postgresql-sharp-mcp --version 0.3.3`, or `@0.3.3` in the npx `args`), which enforces the list inside the server without the PostgreSQL grant check, and **remove `--require-database-lock` from `args`** because 0.3.3 does not recognize it and exits with "Unknown option" (a `POSTGRES_REQUIRE_DATABASE_LOCK` environment variable is ignored by 0.3.3, so it is harmless there but no longer enforces anything), or (b) temporarily remove `POSTGRES_DATABASES`/`--databases` and any `POSTGRES_REQUIRE_DATABASE_LOCK`/`--require-database-lock` setting so the server starts on 0.4.0. Both are stopgaps: 0.3.3 accepts a lock only with a connection string (not with targets JSON/file), and removing the lock leaves the login's full PostgreSQL privileges reachable, so keep that login least-privileged and restore the lock soon. Fix the findings with the [hardening guide](SECURITY.md#database-lock-hardening), restore the lock, and re-run `--validate` before reconnecting the client. + To migrate an existing MCP entry to the environment workflow, remove `POSTGRES_TARGETS`, `POSTGRES_TARGETS_FILE` and literal `POSTGRES_CONNECTION_STRING` from its PostgreSQL `env`, plus `--targets-file`/`--connection-string` arguments. Preserve other nonsensitive settings, especially restricted access mode. Step 2 clears conflicting variables only in the current shell. Combining a connection string with targets JSON/file fails closed. Protected files can remain on disk; do not delete or rename them merely to switch workflows. ### Optional reusable Bash/Zsh prompt in your rc file @@ -345,6 +347,46 @@ After publication, get a global npm command with `npm install --global --allow-s For a source build, use `dotnet` as MCP `command` and the absolute path to `PostgreSqlMcp.dll` as the first `args` item. Keep nonsensitive settings and launch the client from the prepared shell. Self-contained builds still need native OS libraries; Debian/Ubuntu GSS/Kerberos may require `libgssapi-krb5-2`. +**Docker (build from source).** No container image is published to a registry, so build one from a clone of this repository (the [Dockerfile](Dockerfile) is at its root): + +```sh +docker build -t postgresql-sharp-mcp . +``` + +MCP client entry. It carries no credential: `-e POSTGRES_CONNECTION_STRING` without a value makes Docker copy the variable from the environment of the process that starts `docker`, so launch the client from the shell prepared in [step 2](#2-prepare-the-connection-environment). `-i` keeps stdin open for MCP stdio; `--rm` removes the stopped container. + +**With `"command": "docker"`, the MCP entry's `env` block reaches only the `docker` CLI, not the server inside the container.** The container receives only what you pass explicitly: forward every setting (`POSTGRES_ACCESS_MODE`, `POSTGRES_DATABASES`, `POSTGRES_REQUIRE_DATABASE_LOCK`, `POSTGRES_QUERY_TIMEOUT`, ...) with its own `-e NAME` **before the image name**, or pass it as a CLI option **after the image name** (for example `--access-mode restricted`). The image sets no defaults, so a setting that is not forwarded is silently absent: the server runs `unrestricted` with no database lock and no error. The example keeps restricted mode and a required database lock: + +```json +{ + "mcpServers": { + "postgresql": { + "type": "stdio", + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "POSTGRES_CONNECTION_STRING", + "-e", "POSTGRES_ACCESS_MODE", + "-e", "POSTGRES_DATABASES", + "-e", "POSTGRES_REQUIRE_DATABASE_LOCK", + "postgresql-sharp-mcp" + ], + "env": { + "POSTGRES_ACCESS_MODE": "restricted", + "POSTGRES_DATABASES": "[\"\"]", + "POSTGRES_REQUIRE_DATABASE_LOCK": "true" + } + } + } +} +``` + +Omit the `POSTGRES_DATABASES`/`POSTGRES_REQUIRE_DATABASE_LOCK` lines (both `-e` pairs and `env` entries) if you do not use a [database lock](#database-lock); the lock needs the PostgreSQL grants described in the [hardening guide](SECURITY.md#database-lock-hardening). + +- **`Host=127.0.0.1` or `localhost` inside the container is the container itself,** not your computer. For PostgreSQL on the Docker host use a host name the container can resolve, such as `host.docker.internal` (Docker Desktop; on Linux Engine add `"--add-host=host.docker.internal:host-gateway"` to `args` before the image name), or `"--network", "host"` on Linux. For a remote server use its real host name and keep `SSL Mode=VerifyFull`. +- The container environment is plaintext to Docker inspection and privileged operators; it is not a secret store. Restart the container after credential changes. +- For a [protected targets file](#optional-protected-targets-file) instead, clear `POSTGRES_CONNECTION_STRING` and add a read-only bind mount plus `-e POSTGRES_TARGETS_FILE=/run/postgres.targets.json` (full command in [CONTRIBUTING](CONTRIBUTING.md#self-contained-executable-and-container)). The container runs as a **non-root user**, so a `chmod 600` file owned by your account is unreadable inside it and startup fails with the generic "Invalid targets configuration" error. Grant that user read access through a suitable group/UID or a secret mount; do not make the file world-readable. +
@@ -537,10 +579,10 @@ For one server, use inherited `POSTGRES_CONNECTION_STRING`; it creates the `prim | Environment | Default / bounds | |---|---| -| `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 database lock: JSON array of 1+ unique physical database names (no 32-name limit; with a connection string the names are `target` aliases of one login profile and do not count toward the profile limit), with a connection string or targets JSON/file; startup verifies PostgreSQL grants enforce it; `--databases` supported | +| `POSTGRES_TARGETS` | JSON profile-name-to-bootstrap-connection-string object; each string needs an explicit `Host` and `Database`; aliases are nonblank, ≤128 characters, without control characters; wins over `POSTGRES_TARGETS_FILE` | +| `POSTGRES_TARGETS_FILE` | Protected JSON profile file; `--targets-file` supported and overrides it | +| `POSTGRES_CONNECTION_STRING` | Base Npgsql string; `Database` defaults to `postgres` if omitted **in this connection-string mode only** (targets profiles must include `Database`); wins over `--connection-string` | +| `POSTGRES_DATABASES` | Optional database lock: JSON array of 1+ unique physical database names (each 1..63 UTF-8 bytes, no NUL; no 32-name limit; with a connection string the names are `target` aliases of one login profile and do not count toward the profile limit), with a connection string or targets JSON/file; startup verifies PostgreSQL grants enforce it; `--databases` supported | | `POSTGRES_REQUIRE_DATABASE_LOCK` | `false`; `true` makes a missing `POSTGRES_DATABASES` a startup error; `--require-database-lock` 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 | diff --git a/docs/security-posture.md b/docs/security-posture.md index 9755764..cef65ec 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -14,7 +14,7 @@ Configured controls are not proof that a scan passed, an artifact was attested, 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. +**Upgrade boundary:** the discovery contract, developed as source version 0.3.0 but first released as **0.3.1**, 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 environment-first setup documented in the same 0.3.1 release 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 diff --git a/src/PostgreSqlMcp.Core/ServerOptions.cs b/src/PostgreSqlMcp.Core/ServerOptions.cs index 1a5fb38..b894b99 100644 --- a/src/PostgreSqlMcp.Core/ServerOptions.cs +++ b/src/PostgreSqlMcp.Core/ServerOptions.cs @@ -44,7 +44,7 @@ int Number(string env, int fallback, int min, int max, string? key = null) string? text = key is null ? Env(env) : Value(key, env); if (text is null) return fallback; if (!int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int n) || n < min || n > max) - throw new ToolException("configuration", $"POSTGRES_{env} must be {min}..{max}."); + throw new ToolException("configuration", $"{(key is not null && cli.ContainsKey(key) ? key : "POSTGRES_" + env)} must be {min}..{max}."); return n; } string? requireText = Env("REQUIRE_DATABASE_LOCK"); diff --git a/src/PostgreSqlMcp/Program.cs b/src/PostgreSqlMcp/Program.cs index 68b8441..1ab7ccf 100644 --- a/src/PostgreSqlMcp/Program.cs +++ b/src/PostgreSqlMcp/Program.cs @@ -9,28 +9,43 @@ if (args.Contains("--help", StringComparer.Ordinal) || args.Contains("-h", StringComparer.Ordinal)) { Console.WriteLine(""" - 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 database lock (JSON string array); applies to connection-string and targets modes - --require-database-lock Refuse to start unless a database lock is configured - --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 seed connection (and the database lock) and exit; diagnostics on stderr - --version Print executable version - Environment: POSTGRES_CONNECTION_STRING, POSTGRES_TARGETS or POSTGRES_TARGETS_FILE. - POSTGRES_DATABASES locks every profile to the listed physical databases; startup then verifies - PostgreSQL grants enforce the same lock. POSTGRES_REQUIRE_DATABASE_LOCK=true requires a lock. - 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. + postgresql-sharp-mcp — database-agnostic PostgreSQL MCP over stdio; the MCP client launches it. + Prefer environment variables to CLI values: arguments can expose secrets. A CLI value overrides its + environment variable except where noted. + + Connection source (set exactly one; a connection string cannot be combined with targets JSON/file): + POSTGRES_CONNECTION_STRING Npgsql key=value string. Without a database lock it creates profile "primary" + (Database defaults to postgres); with --databases each locked name is a target + alias of this one login profile, with Database set to that name. + --connection-string STR Same, but POSTGRES_CONNECTION_STRING wins when both are set. + POSTGRES_TARGETS JSON object: profile -> Npgsql string. Each needs Host and Database; names are + 1..128 characters without control characters. Wins over a targets file. + POSTGRES_TARGETS_FILE Path to a targets JSON file with the same format. + --targets-file PATH Same; overrides POSTGRES_TARGETS_FILE. + + Options: + --databases JSON POSTGRES_DATABASES. Database lock: JSON array of 1+ unique physical names + (1..63 UTF-8 bytes, no NUL), with a connection string or targets. Startup then + verifies PostgreSQL grants enforce the lock. + --require-database-lock POSTGRES_REQUIRE_DATABASE_LOCK=true (default false) also works. Refuse to + start unless a database lock is configured. + --access-mode MODE POSTGRES_ACCESS_MODE: unrestricted (default) or restricted. + --query-timeout SECONDS POSTGRES_QUERY_TIMEOUT: 1..600 (default 30). + --log-level LEVEL POSTGRES_LOG_LEVEL: trace|debug|information|warning|error|critical|none + (default warning). + --validate Check each seed connection (and the database lock) and exit; diagnostics and + privilege/TLS warnings on stderr. + --version Print executable version. + -h, --help Print this help. + + Environment only (default; bounds): + POSTGRES_MAX_ROWS 1000; 1..5000 POSTGRES_MAX_RESULT_BYTES 65536; 4096..1048576 + POSTGRES_MAX_CELL_CHARS 4096; 1..16384 POSTGRES_MAX_CONCURRENT_CALLS 16; 1..64 + POSTGRES_POOL_SIZE 8; 1..32 (targets profiles x pool size must not exceed 256; a connection string is one) + + Discover accessible databases with list_databases; select a physical name per call with the tool's + database argument. Optional tool target selects a profile; the default is the profile named primary if it + exists, otherwise the first profile name in ordinal order. """); return 0; } diff --git a/tools/PostgreSqlMcp.Verify/Integration.cs b/tools/PostgreSqlMcp.Verify/Integration.cs index ddb28aa..1d6ce6b 100644 --- a/tools/PostgreSqlMcp.Verify/Integration.cs +++ b/tools/PostgreSqlMcp.Verify/Integration.cs @@ -667,6 +667,12 @@ private static async Task VerifyConfigurationAsync(Command command, Dictionary(validationEnvironment) { ["POSTGRES_QUERY_TIMEOUT"] = "0" }; + ProcessResult bad = await Processes.RunAsync(cli ? command.With("--query-timeout", "0", "--validate") : command.With("--validate"), rangeEnvironment, expected: 1); + Check.That(bad.Error.Contains(expected, StringComparison.Ordinal), $"Range error did not name the setting that was used: {expected}"); + } Console.WriteLine("PASS preflight, unreadable targets, unknown options and invalid configuration"); }