{
  "markdown": "# db-access-mcp\n\n[![npm](https://img.shields.io/npm/v/@rheopyrin/db-access-mcp?logo=npm)](https://www.npmjs.com/package/@rheopyrin/db-access-mcp)\n[![npm downloads](https://img.shields.io/npm/dm/@rheopyrin/db-access-mcp)](https://www.npmjs.com/package/@rheopyrin/db-access-mcp)\n[![license: MIT](https://img.shields.io/npm/l/@rheopyrin/db-access-mcp)](LICENSE)\n[![node](https://img.shields.io/node/v/@rheopyrin/db-access-mcp)](package.json)\n\n[![db-access-mcp MCP server](https://glama.ai/mcp/servers/Rheopyrin/db-access-mcp/badges/card.svg)](https://glama.ai/mcp/servers/Rheopyrin/db-access-mcp)\n\nMCP (Model Context Protocol) stdio server that gives AI agents (Claude Code, Claude\nDesktop, any MCP client) access to configured databases:\n\n- **PostgreSQL** (≥ 10)\n- **MySQL** (≥ 5; read-only sessions require ≥ 5.6)\n- **Amazon Redshift**\n- **Microsoft SQL Server** (via [tedious](https://github.com/tediousjs/node-mssql))\n\nwith **SSH / AWS SSM tunnels**, pluggable **secret providers** (env vars, HashiCorp\nVault, AWS Secrets Manager) and strict **per-instance isolation** — many MCP\ninstances can run concurrently on one machine without sharing connections or\ntunnels, and crashed instances never leave orphaned tunnel processes behind.\n\n## Highlights\n\n**Tools:** `connection_list` / `connection_find` / `connection_test`, `query`,\n`query_plan` (EXPLAIN), `query_to_file` (streamed CSV/JSONL export that bypasses the\nmodel context), `up_tunnel` / `down_tunnel` / `tunnel_list`, `config_reload`\n(pick up new connections/tunnels without a restart).\n\n**Security-first design:**\n\n- **Verified SSH tunnels** — the bastion host key is checked against a\n  `host_key_sha256` pin or `known_hosts`, failing closed on mismatch (MITM defence),\n  not blindly trusted.\n- **`read_only` seatbelt** + single-statement-by-default, which also closes the\n  `SET session read-only off; INSERT …` bypass.\n- **Confined file exports** — `query_to_file` writes only under the export dir or an\n  `allow_export_paths` root; it cannot clobber `~/.ssh`, dotfiles or the config dir.\n- **No secrets leak out** — tools never return stacks or credentials; logs redact by\n  key name *and* scrub inline `user:password@host` URIs.\n- **Pluggable secret providers** — env vars, HashiCorp Vault (dynamic leases with\n  auto-refresh and atomic pool swaps), AWS Secrets Manager, RDS IAM auth tokens and\n  temporary Redshift credentials.\n\n**Tunnels & crash-safety:** in-process SSH (dies with the process — no orphaned\nports) and AWS SSM under a watchdog (killed even on SIGKILL), with AWS SSO bootstrap.\nEvery instance's pools and tunnels are isolated; a crashed instance's tunnels are\nreaped by the next start.\n\n## Quick start\n\n```jsonc\n// Claude Code / Claude Desktop MCP config\n{\n  \"mcpServers\": {\n    \"db-access-mcp\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@rheopyrin/db-access-mcp\"]\n    }\n  }\n}\n```\n\nOn first start the server creates the working directory `~/.db_acess_mcp`\n(intentional spelling — it is the product contract) with an empty `config.json`, an\nempty `conf.d/` directory and a full `config.example.json` covering every dialect,\nsecret provider and tunnel type. The export directory (default\n`/tmp/db-access-mcp/exports`) is **not** created up front — `query_to_file` makes it\non demand on the first export. Edit `~/.db_acess_mcp/config.json`, restart the MCP\nserver, done.\n\n## Integrating the MCP\n\nThe server speaks MCP over **stdio**: any client that can spawn\n`npx -y @rheopyrin/db-access-mcp` (or `node <path>/dist/cli.js` for a local build) can use it.\nEvery spawned instance is fully isolated — its own pools, tunnels and idle timers —\nso it is safe to register it in several clients/sessions at once.\n\n### Claude Code\n\n```bash\n# current project only (writes .mcp.json in the project root)\nclaude mcp add db-access-mcp -- npx -y @rheopyrin/db-access-mcp\n\n# for all your projects\nclaude mcp add --scope user db-access-mcp -- npx -y @rheopyrin/db-access-mcp\n\n# with options (custom config dir, verbose logs, extra env file)\nclaude mcp add db-access-mcp -- npx -y @rheopyrin/db-access-mcp --workdir ~/.db_acess_mcp --log-level debug --env-file ~/.db_acess_mcp/secrets.env\n```\n\nCheck with `/mcp` inside a session (server status, reconnect). Server stderr logs\nland in `~/Library/Caches/claude-cli-nodejs/<project-slug>/mcp-logs-db-access-mcp/`\n(macOS). After editing `config.json`, reconnect the server (`/mcp`) — the config\nis read at startup only.\n\nOr declare it in the project's `.mcp.json` directly:\n\n```json\n{\n  \"mcpServers\": {\n    \"db-access-mcp\": { \"command\": \"npx\", \"args\": [\"-y\", \"@rheopyrin/db-access-mcp\"] }\n  }\n}\n```\n\n### Claude Desktop\n\nAdd the same `mcpServers` block to the config file and restart the app:\n\n- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`\n- Windows: `%APPDATA%\\Claude\\claude_desktop_config.json`\n\n### Cursor / other MCP clients\n\nAny stdio-capable client works with the same shape — command `npx`,\nargs `[\"-y\", \"@rheopyrin/db-access-mcp\"]` (Cursor: `~/.cursor/mcp.json`, same `mcpServers`\nformat). Two things to know:\n\n- **stdout is the protocol** — if your client shows a JSON-RPC parse error, make\n  sure nothing wraps the command with extra output; all server logs go to stderr.\n- Pass CLI options via `args`, e.g.\n  `[\"-y\", \"@rheopyrin/db-access-mcp\", \"--workdir\", \"/opt/mcp-db\", \"--log-level\", \"warn\"]`.\n\n### Local build (development)\n\n```bash\ngit clone <repo> && cd db_access_mcp && npm ci && npm run build\nclaude mcp add db-access-mcp-dev -- node /abs/path/db_access_mcp/dist/cli.js --log-level debug\n```\n\n### Trying it without a client\n\n```bash\nnpx -y @modelcontextprotocol/inspector npx -y @rheopyrin/db-access-mcp\n```\n\nopens a web UI listing all tools with call forms and live stderr. A sensible\nfirst-session sequence: `dialect_list` → `connection_list` →\n`connection_test` on one connection → `query`.\n\n### Requirements on the host\n\n- Node.js ≥ 20.19.\n- For **ssm tunnels**: AWS CLI + [session-manager-plugin](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html) on PATH; for the SSO bootstrap a browser (login opens interactively).\n- For **ssh tunnels**: nothing extra (in-process ssh2 client).\n\n### CLI options\n\n```\ndb-access-mcp [workdir] [exportdir] [--workdir <dir>] [--exportdir <dir>] [--config <file>] [--env-file <file>]... [--log-level <level>]\n```\n\n| Option | Env var | Default |\n|---|---|---|\n| `--workdir` (or first positional) | `DB_ACCESS_MCP_WORKDIR` | `~/.db_acess_mcp` |\n| `--exportdir` (or second positional) | `DB_ACCESS_MCP_EXPORTDIR` | `/tmp/db-access-mcp/exports` |\n| `--config` | `DB_ACCESS_MCP_CONFIG` | discovery: `<workdir>/config.json` + `<workdir>/conf.d/*.json` |\n| `--env-file` (repeatable) | — | none |\n| `--log-level` (`debug`\\|`info`\\|`warn`\\|`error`\\|`silent`) | `DB_ACCESS_MCP_LOG_LEVEL` | `info` |\n\nThe **workdir** holds `config.json`, `conf.d/`, `config.example.json` and the\nruntime `instances/` and `sso/` state (unchanged from earlier releases). The\n**exportdir** is where `query_to_file` writes exports (created on demand, not at\nstartup); `allow_export_paths` adds extra writable roots.\n\nAll logs are JSON lines on **stderr** (stdout belongs to the MCP protocol). Values\nof keys matching `password`, `token`, `secret`, `privateKey` etc. are redacted.\n\n## MCP tools\n\n| Tool | What it does |\n|---|---|\n| `dialect_list` | Lists the supported database dialects: name (the `type` value for connections), default port and the plan format `query_plan` produces. |\n| `connection_list` | Lists configured connections: key, type, description, `read_only`, host/port/database, tunnel name, metadata. Credentials are **never** returned (allowlist-based sanitization; connection strings are parsed only for host/port/database). |\n| `connection_find` | Finds connections by `host`, `port`, `database`, `type`, `read_only` and/or `metadata` key-value pairs. All filters are combined with **AND**. `user`/`password` filters are ignored (and noted in the response). |\n| `connection_test` | End-to-end health check: secrets → tunnel → pool → one-row server-info query. Returns `ok: true` with server version/user/database/latency, or `ok: false` with the failure code and hint (an unreachable DB is a valid result, not a tool error). |\n| `query` | Executes SQL on a connection. Accepts `connection`, `query`, optional `database` (see multi-database connections), `max_rows` and `timeout_ms` overrides. Results are truncated to the row cap with `truncated: true`. |\n| `query_to_file` | Executes a query and writes the result to a file (`csv`/`jsonl`, inferred from the extension) instead of the model context. `file_path` is relative to the export dir (default `/tmp/db-access-mcp/exports`, created on demand), or an absolute/`~` path **under** the export dir or a configured `allow_export_paths` root — writes outside are rejected. Existing files require `overwrite: true`. postgres/mysql **stream** rows (no cap by default); redshift/mssql buffer and are capped at 100k rows. |\n| `query_plan` | Returns the execution plan without running the query: `EXPLAIN (FORMAT JSON)` for postgres, `EXPLAIN FORMAT=JSON` for mysql, text `EXPLAIN` for redshift, `SHOWPLAN_XML` for mssql. |\n| `up_tunnel` | Opens (or reuses) the tunnel configured for a connection and returns `{host, port, tunnel_id, reused}`. Optional `local_port` binds an exact local port; if the tunnel is already open on a different port or the port is taken, the call fails with the current port in the error. |\n| `down_tunnel` | Closes a tunnel by `tunnel_id`. By default only the up_tunnel pin is released — if query pools still hold the tunnel it stays open (`remaining_holders`); `force: true` drains the holder pools and closes it unconditionally. |\n| `tunnel_list` | Lists the tunnels currently open in this MCP instance with a live health probe: `tunnel_id`, tunnel name/type, local and remote endpoints, `healthy`, holder pools (`connections`), up_tunnel `pins`, external PIDs. |\n| `config_reload` | Re-reads the config files into the running server so connections/tunnels added or edited on disk become usable without a restart. **Never disturbs live state** — see below. |\n\n### `config_reload`\n\nEdit `config.json` / `conf.d/*.json` (or the `--config` file), call `config_reload`,\nand the next `connection_list`, `query`, `up_tunnel`, … sees the new definitions.\nThe files are re-read, re-validated (schema + semantics + dialect check) and the\nconfig env_files are re-applied before anything is swapped in — **an invalid config\nchanges nothing**: the server keeps running on the previous configuration and the\ntool returns the validation error.\n\nThe reload is deliberately **non-disruptive**: open pools and running tunnels are\nnever closed, drained, recreated or reconfigured, even when their definition\nchanged or was removed from the file. They keep the definition they were created\nwith until they are closed the normal way (idle timeout, `down_tunnel`, server\nexit); the next pool/tunnel created after that uses the new definition. Every such\ncase is listed in the response's `warnings`.\n\nResponse: `files` (what was read), `connections` and `tunnels` as\n`{added, removed, changed, unchanged}`, the `pool_defaults_changed` /\n`limits_changed` / `env_files_changed` flags, and `warnings`. Per-call and\nper-connection `limits` are read at call time, so they take effect immediately;\n`pool` settings only apply to pools created after the reload.\n\nSecurity note for `query_to_file`: writes are confined to the export dir (default\n`/tmp/db-access-mcp/exports`) plus any roots listed in `allow_export_paths` (e.g.\n`[\"/tmp\", \"~/data_files\"]`) — every subpath below a listed root is allowed, anything\nelse is rejected, so the tool cannot clobber `~/.ssh`, dotfiles or the workdir. It\nonly ever creates files (no reads, no appends) and refuses to overwrite without an\nexplicit `overwrite: true`. Cells are written verbatim; be mindful of CSV-injection\nwhen opening exports in Excel.\n\n## Configuration reference\n\nThe schema is **strict** — unknown keys are rejected at startup with a readable\nerror (typo protection). Everything inside a connection's `options` is passed\nthrough to the database driver as-is.\n\n```jsonc\n{\n  \"vault\":               { /* named Vault servers */ },\n  \"aws_secret_profiles\": { /* named AWS Secrets Manager profiles */ },\n  \"env_files\":           [ /* extra .env files applied at startup */ ],\n  \"pool\":                { /* global pool defaults */ },\n  \"limits\":              { /* global query limits */ },\n  \"tunnels\":             { /* named tunnel definitions */ },\n  \"connections\":         { /* named connections */ }\n}\n```\n\n### Config files: single or split (`conf.d`)\n\nWithout `--config`, the server loads `<workdir>/config.json` (optional) **plus**\nevery `<workdir>/conf.d/*.json` (sorted by name, non-recursive, dotfiles ignored)\nand merges them:\n\n- Named-record sections (`vault`, `aws_secret_profiles`, `tunnels`, `connections`)\n  are unioned across files. The **same name in two files is a startup error**\n  naming both files — no silent overrides.\n- Scalar sections (`pool`, `limits`, `env_files`) may appear in **at most one** file.\n- `--config <file>` loads exactly that file; `conf.d` is not scanned.\n\nEverything can live in a single `config.json` (that is what the example shows) —\n`conf.d` is for splitting per team/project when the config grows.\n\n### Env-ref values\n\nWherever noted below, a config value can be an inline string or a reference to an\nenvironment variable, resolved lazily at the moment it is needed:\n\n```jsonc\n\"token\": \"hvs.inline\"            // inline\n\"token\": { \"env\": \"VAULT_2_TOKEN\" }  // read from the environment at use time\n```\n\nA missing variable fails only the connections that actually need it, with an\nerror naming the variable.\n\n### `connections.<key>`\n\n| Field | Required | Description |\n|---|---|---|\n| `type` | yes | `postgres` \\| `mysql` \\| `redshift` \\| `mssql` |\n| `options` | yes | Driver passthrough options (see per-dialect notes below). May contain `${provider.path}` secret placeholders in any string value. Must declare `database` and/or a non-empty `databases` list (connectionString/uri connections carry the database inside the string). |\n| `description` | no | Free-text description shown by `connection_list`. |\n| `read_only` | no | Session-level read-only enforcement (see semantics below). Default `false`. |\n| `metadata` | no | Flat map (`string`/`number`/`boolean` values) used by `connection_find`. |\n| `pool` | no | Per-connection pool overrides. |\n| `limits` | no | Per-connection limit overrides. |\n| `tunnel` | no | `{ \"target\": \"<tunnel name>\", \"localPort\": 25432? }`. Without `localPort` a random free port from 20000–45000 is picked. |\n| `secrets` | no | Exactly **one** provider per connection: `{ \"<provider>\": <spec> }`. |\n\n#### Per-dialect `options`\n\n- **postgres / redshift** — anything [node-postgres](https://node-postgres.com/apis/client) accepts:\n  `host`, `port`, `database`, `user`, `password`, `ssl`, … or a single\n  `connectionString` (`postgres://user:pass@host:5432/db`).\n- **mysql** — anything [mysql2](https://sidorares.github.io/node-mysql2/docs) accepts:\n  `host`, `port`, `database`, `user`, `password`, or `uri` (`mysql://…`).\n  `multipleStatements` follows the shared rule below.\n- **mssql** — anything [mssql](https://github.com/tediousjs/node-mssql#configuration) accepts:\n  `server` (or `host` alias), `port`, `database`, `user`, `password`,\n  `options: { encrypt, trustServerCertificate, … }`, or `connectionString`\n  (`mssql://…` URL or ADO style `Server=…;Database=…`).\n\n#### `options.multipleStatements` (default off)\n\nBy default a `query` call runs a **single** statement. Set\n`\"multipleStatements\": true` in a connection's `options` to allow several\n`;`-separated statements in one call. Enforced by the engine, not by parsing SQL:\n\n- **mysql** — the driver's native `multipleStatements` flag.\n- **postgres / redshift** — with it off, queries run over the extended protocol,\n  so the server itself rejects a second statement (`42601`); no SQL splitting.\n- **mssql** — cannot be enforced at the protocol level (T-SQL batches), so\n  multi-statement is always allowed here; rely on a read-only DB user.\n\nLeaving it off also closes the `SET session-read-only off; INSERT …` bypass of a\n`read_only` connection (the write can't ride along in a second statement). Like\n`read_only`, this is a seatbelt — the real guarantee is a read-only DB user.\n\n### Multi-database connections (`options.databases`)\n\nOne server often hosts many databases. Instead of duplicating the connection,\ndeclare them all:\n\n```jsonc\n\"shared-mysql\": {\n  \"type\": \"mysql\",\n  \"options\": { \"host\": \"...\", \"port\": 3306,\n               \"databases\": [\"app\", \"reporting\", \"audit\"],\n               \"user\": \"...\", \"password\": \"...\" }\n}\n```\n\nRules (`query`, `query_plan`, `query_to_file`, `connection_test` accept an\noptional `database` parameter):\n\n- no `database` parameter → `options.database` is used; when the connection\n  declares only a `databases` list there is **no implicit default** — the call\n  fails with `DATABASE_NOT_FOUND` listing the available names;\n- a passed `database` must equal `options.database` or be a member of\n  `options.databases`, otherwise `DATABASE_NOT_FOUND`;\n- `database` and `databases` may be declared together; every connection must\n  declare at least one of them (config error otherwise);\n- each (connection, database) pair gets its own pool; all pools of a connection\n  share one tunnel, released when the last pool closes;\n- `connection_list`/`connection_find` expose `databases`, and the `database`\n  find-filter matches either the single property or any list member;\n- `databases` cannot be combined with `connectionString`/`uri`.\n\n### `pool` (global and per-connection)\n\n| Field | Default | Meaning |\n|---|---|---|\n| `max` | 5 | Max connections in the pool. |\n| `min` | 0 | Min idle connections kept. |\n| `idle_timeout_ms` | 30000 | Driver-level idle client timeout inside the pool. |\n| `connection_timeout_ms` | 10000 | Time to wait for a new connection. |\n\n### `limits` (global, per-connection, per-call)\n\n| Field | Default | Meaning |\n|---|---|---|\n| `max_rows` | 1000 | Row cap per result set; exceeded → rows are cut and `truncated: true`. Overridable per `query` call. |\n| `query_timeout_ms` | 30000 | Query timeout. postgres/redshift: server-side `statement_timeout`; mysql: client-side inactivity timeout (connection is destroyed, the server may finish the statement); mssql: client-side `request.cancel()`. Overridable per `query` call. |\n| `idle_close_ms` | 600000 | Per-instance idle timer: a connection unused this long gets its pool closed **and** its tunnel released. |\n\nResolution order: tool-call argument → connection `limits` → global `limits` → defaults.\n\n### `read_only` semantics by dialect\n\n| Dialect | Mechanism | Enforcement |\n|---|---|---|\n| postgres | `SET default_transaction_read_only = on` per checkout | Hard — the server rejects writes (`25006`). |\n| mysql ≥ 5.6 | `SET SESSION TRANSACTION READ ONLY` per checkout | Hard. On 5.5 the statement fails → warning logged, no enforcement. |\n| redshift | attempted, but Redshift does not support it | Best-effort: warning logged. Use a read-only DB user. |\n| mssql | `readOnlyIntent` connection option | Only effective on Availability Group read replicas; warning logged. Use a read-only DB user. |\n\n**For real guarantees always prefer a read-only database user.** `read_only` is a\nseatbelt, not a security boundary.\n\n## Secrets\n\nOne provider per connection; placeholders are namespaced by the provider name and\nresolved against the parsed secret object: `${env.userName}`, `${vault.data.password}`,\n`${aws.password}`. A placeholder that is the **whole** string keeps the raw value\ntype (numbers stay numbers); embedded placeholders are string-substituted. Mismatched\nnamespaces are rejected at config load.\n\n### `env` — environment variables\n\n```jsonc\n\"options\": { \"user\": \"${env.userName}\", \"password\": \"${env.password}\" },\n\"secrets\": { \"env\": { \"userName\": \"PG_USER_ENV_VAR\", \"password\": \"PG_PASSWORD_ENV_VAR\" } }\n```\n\nThe spec maps placeholder keys to environment variable names. Missing variables\nfail with the exact list of what is missing. Static — never reloaded.\n\n### `vault` — HashiCorp Vault (multiple servers)\n\n```jsonc\n\"vault\": {\n  \"vault-main\": { \"address\": \"https://vault.example.com:8200\", \"token\": \"hvs....\" },\n  \"vault-dr\":   { \"address\": { \"env\": \"VAULT_DR_ADDR\" }, \"token\": { \"env\": \"VAULT_DR_TOKEN\" } }\n},\n...\n\"secrets\": { \"vault\": { \"target\": \"vault-dr\", \"path\": \"secret/data/databases/db4\" } }\n```\n\n- `vault` is a map of **named servers**; `address`/`token`/`namespace` accept\n  env-refs, extra keys are passed through to\n  [node-vault](https://github.com/nodevault/node-vault).\n- `secrets.vault.target` picks the server. **Without `target`** the implicit\n  default client is used, built purely from `VAULT_ADDR`/`VAULT_TOKEN`\n  (/`VAULT_NAMESPACE`) — node-vault's standard variables. The implicit default is\n  *not* part of the named map: an entry you name `\"default\"` is just a regular\n  named entry.\n- KV v2 responses are unwrapped: placeholders address the secret payload directly.\n- **Dynamic secrets** (e.g. `database/creds/<role>`) carry a lease: the lease is\n  renewed at ~80% of its TTL (with jitter). When renewal fails (max TTL reached),\n  fresh credentials are requested; if they differ, the connection pool is swapped\n  atomically (new queries use the new pool immediately, in-flight queries finish on\n  the old one, which is then drained).\n- If Vault is unreachable past the lease deadline, the secret is marked stale and\n  re-resolved lazily on next use; an auth failure with a stale secret triggers one\n  forced re-resolve + reconnect.\n\n### `aws` — AWS Secrets Manager (named profiles)\n\n```jsonc\n\"aws_secret_profiles\": {\n  \"aws-prod\": { \"aws_profile\": \"prod\", \"aws_region\": \"us-east-1\", \"reload_interval_ms\": 3600000 },\n  \"aws-dev\":  { \"aws_profile\": { \"env\": \"AWS_DEV_PROFILE\" }, \"aws_region\": { \"env\": \"AWS_DEV_REGION\" } }\n},\n...\n\"secrets\": {\n  \"aws\": {\n    \"secret_id\": \"prod/erp/mssql\",       // name or full ARN\n    \"target\": \"aws-prod\",                 // optional: aws_secret_profiles entry\n    \"version_stage\": \"AWSCURRENT\"         // optional\n  }\n}\n```\n\n`aws_profile`, `aws_region` and `reload_interval_ms` live in named\n`aws_secret_profiles` entries (values accept env-refs). **Without `target`** the\ndefault AWS SDK credential chain is used (env, shared config, SSO, IMDS…) and the\nsecret is static. The secret value must be a **JSON object** (`SecretString`); its\nkeys become the `${aws.*}` namespace. AWS Secrets Manager has no leases, so\nreloading is opt-in via the profile's `reload_interval_ms` (min 10s): the secret is\nre-fetched on that interval and rotation is picked up with the same atomic pool\nswap as Vault.\n\n### `env_files` — extra .env files\n\n```jsonc\n\"env_files\": [\"~/.db_acess_mcp/secrets.env\"]\n```\n\nApplied at startup, before any secret resolution; `--env-file <file>` (repeatable)\nappends to the config list. Files use standard dotenv syntax. Rules (security):\n\n| Rule | Why |\n|---|---|\n| The **real environment always wins** — variables present at process start are never overridden by a file. | An env file must not be able to repoint `VAULT_ADDR`/`AWS_*` of a running setup. |\n| `PATH`, `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `LD_*`/`DYLD_*` are **skipped** with a warning. | Prevents binary/loader/TLS-trust hijack for the processes we spawn (aws CLI). |\n| Later files override earlier ones (config `env_files` first, then `--env-file` in order). | Deterministic precedence. |\n| A group/other-readable env file logs a warning on POSIX (`chmod 600` recommended). | Secrets hygiene. |\n| Values are never logged; keys only at `debug` level. | Secrets hygiene. |\n\n### `aws_iam` — passwordless RDS/Aurora (IAM auth tokens)\n\n```jsonc\n\"options\": {\n  \"host\": \"pg.abc.us-east-1.rds.amazonaws.com\", \"port\": 5432, \"database\": \"appdb\",\n  \"user\": \"${aws_iam.username}\", \"password\": \"${aws_iam.token}\",\n  \"ssl\": { \"rejectUnauthorized\": false }          // SSL is MANDATORY for IAM auth\n},\n\"secrets\": { \"aws_iam\": { \"username\": \"readonly\", \"target\": \"aws-prod\" } }\n```\n\nNo password is stored anywhere: a 15-minute SigV4 auth token is generated\nlocally and used as the password; the standard refresh pipeline re-signs it\nbefore expiry and swaps the pools atomically. Tokens are always signed for the\n**real RDS host/port** from `options` (never the tunnel's 127.0.0.1), so\ntunnels work unchanged; connection strings are not supported here. Optional\n`host`/`port` in the spec override the endpoint (e.g. a reader endpoint).\n\nAWS-side prerequisites:\n\n- IAM auth enabled on the instance/cluster (`IAMDatabaseAuthenticationEnabled`);\n- the DB user is IAM-bound — postgres: `GRANT rds_iam TO readonly;`\n  mysql: `CREATE USER readonly IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS';`\n- the caller has `rds-db:connect` on `arn:aws:rds-db:<region>:<acct>:dbuser:<resource-id>/readonly`;\n- **not supported** by RDS for SQL Server. Every connect is auditable in CloudTrail.\n\n### `aws_redshift_creds` — temporary Redshift credentials\n\n```jsonc\n\"options\": { \"host\": \"cluster....redshift.amazonaws.com\", \"port\": 5439, \"database\": \"dwh\",\n             \"user\": \"${aws_redshift_creds.username}\", \"password\": \"${aws_redshift_creds.password}\" },\n\"secrets\": { \"aws_redshift_creds\": { \"cluster_id\": \"my-cluster\", \"db_user\": \"readonly\",\n                                     \"target\": \"aws-prod\", \"duration_seconds\": 3600 } }\n```\n\n`redshift:GetClusterCredentials` issues a temporary user+password pair\n(900–3600s); the returned username carries the `IAM:` prefix and is sent to the\nserver verbatim. TTL comes from the API's expiration and feeds the same\nauto-refresh + pool-swap pipeline.\n\n### SSO bootstrap for AWS profiles\n\nAn `aws_secret_profiles` entry may carry the same `sso` block as ssm tunnels:\n\n```jsonc\n\"aws_secret_profiles\": {\n  \"aws-prod\": { \"aws_profile\": \"my-profile\", \"aws_region\": \"us-east-1\",\n                \"sso\": { \"session\": \"my-sso-session\", \"timeout_ms\": 300000 } }\n}\n```\n\nBefore any provider referencing the profile (`aws`, `aws_iam`,\n`aws_redshift_creds`) uses credentials, the session is verified with\n`aws sts get-caller-identity`; an expired session triggers `aws sso login`\n(browser) and the resolution waits up to `timeout_ms`. `sso.profile` defaults\nto the entry's `aws_profile`. Login dedup is by session name — a tunnel and a\nsecret resolution on the same session share one browser login.\n\n### Adding a provider\n\nImplement `SecretProvider` (`src/interfaces/secret-provider.ts`) and add one binding\nline in `src/composition/modules/secrets.module.ts`. The provider `name` is both the\nconfig key under `secrets` and the placeholder namespace.\n\n## Tunnels\n\n```jsonc\n\"tunnels\": {\n  \"bastion-ssm\": { \"type\": \"ssm\", \"options\": { \"target\": \"i-0123...\", \"region\": \"us-east-1\", \"profile\": \"default\" } },\n  \"bastion-ssh\": { \"type\": \"ssh\", \"options\": { \"host\": \"bastion\", \"port\": 22, \"username\": \"ec2-user\", \"privateKey\": \"~/.ssh/id_ed25519\" } }\n}\n```\n\n- **ssh** — runs **inside** the MCP process (via the `ssh2` library): a local\n  listener forwards TCP through the SSH channel. Because it is in-process it dies\n  with the process even on SIGKILL — orphaned ports are impossible. Options:\n  `host`, `port` (22), `username`, `password`, `privateKey` (file path, `~` ok),\n  `passphrase`, `agent` (`true` = platform default agent, or an explicit\n  socket/pipe path), `ready_timeout_ms`.\n  The bastion **host key is verified** (MITM defence): by default against\n  `~/.ssh/known_hosts` (plaintext and hashed entries, and `[host]:port` for\n  non-standard ports). Pin it explicitly with `host_key_sha256` (the\n  `ssh-keygen -lf` fingerprint, with or without the `SHA256:` prefix), point\n  `known_hosts` at another file, or set `strict_host_key: false` to accept any\n  key (**insecure — opt-out only**). An unknown or changed key is rejected.\n- **ssm** — spawns `aws ssm start-session --document-name\n  AWS-StartPortForwardingSessionToRemoteHost` under a tiny **watchdog** process.\n  The watchdog holds a stdin pipe from the MCP process: if the MCP process dies for\n  *any* reason (including SIGKILL), the OS closes the pipe and the watchdog kills\n  the whole aws/session-manager-plugin tree (`taskkill /T /F` on Windows, process\n  group kill on POSIX). Requires the AWS CLI and\n  [session-manager-plugin](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html)\n  on PATH. Options: `target` (instance id), `region`, `profile`, `document_name`.\n\n### AWS SSO bootstrap (ssm tunnels)\n\n```jsonc\n\"bastion-ssm\": {\n  \"type\": \"ssm\",\n  \"options\": { \"target\": \"i-...\", \"region\": \"us-east-1\", \"profile\": \"prod\" },\n  \"sso\": { \"session\": \"my-sso\", \"profile\": \"prod\", \"timeout_ms\": 300000 }   // all fields optional\n}\n```\n\nWhen a tunnel has an `sso` block, the session is verified with\n`aws sts get-caller-identity --profile <profile>` **before** the tunnel opens.\nIf it is missing or expired, a login is started (browser flow): with\n`sso.session` set it runs `aws sso login --sso-session <name>` (the canonical\nIAM Identity Center form — one session may back several profiles); otherwise\n`aws sso login --profile <profile>`. The tunnel waits, polling every 3s, until\nthe session works or `timeout_ms` (default 5 minutes) elapses — then\n`TUNNEL_FAILED` with a hint containing the exact manual command. `sso.profile`\ndefaults to the tunnel's `options.profile`; the dedup/marker key is the session\nname when present.\n\n- The SSO session is **never closed** by this server; the login process is not\n  watchdog-wrapped, is never killed and survives the MCP instance.\n- Concurrent logins are deduplicated: within an instance by profile; across\n  instances via a `<workdir>/sso/<profile>.login.json` marker — a second instance\n  waits for the first login instead of opening another browser tab (markers of\n  dead processes are ignored via PID + start-time checks).\n- SSO tokens and `~/.aws/sso/cache` are never read, parsed or logged — only\n  fixed-argument aws CLI invocations, no shell.\n\nBehavior:\n\n- The tunnel's **remote endpoint** is taken from the connection's `options`\n  (host/port as seen *from the bastion*).\n- Tunnels are cached per instance and keyed by `(tunnel name, remote host:port)` —\n  connections through the same bastion to the same database share one tunnel;\n  `query`/`up_tunnel` reuse an already-open healthy tunnel.\n- Reference counting: the tunnel closes when the last connection using it is closed\n  (including by the idle timer).\n- Every instance writes `<workdir>/instances/<pid>-<startTime>.json` with its tunnel\n  PIDs. At startup (and every 10 minutes) each instance sweeps files of dead\n  instances: PID liveness check, PID-reuse protection via OS process start time,\n  and a command-line sanity check before killing anything.\n- On a connection error during `query`, the tunnel is health-checked, reopened if\n  needed, the pool is rebuilt, and the query is retried — up to 3 attempts with\n  exponential backoff. Auth errors are never retried.\n\n## Isolation model\n\nEvery `npx -y @rheopyrin/db-access-mcp` process is fully isolated: its own config snapshot,\nconnection pools, tunnels and idle timers. Nothing is shared between instances; the\nper-instance registry files exist only so that *later* instances can clean up after\na crashed one. Two instances talking to the same database simply hold independent\npools (mind your database `max_connections`; the default pool `max` is 5 per\nconnection per instance).\n\n## Error codes\n\nTools return `isError: true` with a structured payload — never raw stacks or credentials:\n\n| Code | Meaning |\n|---|---|\n| `CONFIG_INVALID` | Config schema/semantic violation (bad tunnel ref, placeholder namespace, …). |\n| `CONNECTION_NOT_FOUND` | Unknown connection key. |\n| `DATABASE_NOT_FOUND` | The requested database is not declared for the connection, or a multi-database connection was called without the `database` parameter (available names are in the hint). |\n| `SECRET_RESOLUTION_FAILED` | Provider could not produce the secret (missing env var, Vault/AWS error, bad path). |\n| `TUNNEL_FAILED` | Tunnel could not be opened / port busy / CLI missing. |\n| `CONNECTION_FAILED` | Database unreachable after retries, or auth failed. |\n| `QUERY_FAILED` | SQL error. |\n| `QUERY_TIMEOUT` | Query exceeded `timeout_ms`. |\n\n## Windows notes\n\n- Paths use `os.homedir()`; `~` in CLI args and `privateKey` is expanded manually.\n- Tunnel trees are killed with `taskkill /T /F`; process inspection uses PowerShell\n  (`wmic` is gone from Windows 11).\n- AWS CLI v2 (`aws.exe`) and v1 (`aws.cmd`) are both handled.\n- Default SSH agent pipe: `\\\\.\\pipe\\openssh-ssh-agent`.\n\n## Development\n\n```bash\nnpm ci\nnpm run lint        # eslint (typescript-eslint, type-checked)\nnpm run typecheck   # tsc --noEmit\nnpm test            # unit tests (fast, no docker)\nnpm run test:integration  # requires Docker: postgres:16, mysql:8, testcontainers/sshd\nnpm run test:e2e    # builds, then drives dist/cli.js over real stdio MCP\nnpm run build       # tsup -> dist/cli.js + dist/watchdog.js\n```\n\nArchitecture: inversify DI container; every extension point (dialect drivers,\nsecret providers, tunnel providers, MCP tools) is a multi-bound interface collected\ninto a registry — adding an implementation is one class + one binding line. See\n`src/composition/`.\n\nNot covered by automated integration tests (unit-tested with mocks; verify manually):\nRedshift specifics, SSM tunnels (needs real AWS), MSSQL against a live server,\nVault/AWS Secrets Manager against live services.\n\n## Manual verification checklist\n\n1. `npx -y @rheopyrin/db-access-mcp` first run → workdir, `config.json`, `config.example.json` created.\n2. Add a real connection → `connection_list`, `query` (`SELECT 1`), `query_plan`.\n3. Tunneled connection → `up_tunnel` returns `127.0.0.1:<port>`; `psql -h 127.0.0.1 -p <port>` works.\n4. `kill -9 <mcp pid>` → tunnel process disappears within seconds (watchdog); next\n   start removes the stale instance file.\n5. Vault dynamic creds: watch the log for `secret refreshed` / `pool swapped after\n   credential rotation` around 80% of the lease TTL.\n",
  "bytes": 34277,
  "sha": "2ea8267cad8d1d1bd1da1553efa6a000a601c8701430e5b8708b3a36fee42b70",
  "repo_slug": "rheopyrin/db-access-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_rheopyrin_db_access_mcp_90c742cd/readme"
}