# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added - **A `-full` image variant carrying the runtimes stdio backends spawn.** `ghcr.io/mikkoparkkola/mcp-gateway:latest-full` adds Node.js 24, `uv`, `git` and `openssh-client` to the default image, and carries `curl` and a `python3` that NodeSource depends on. It does not carry `pnpm`, `yarn` or `bunx`. Node comes from NodeSource rather than the distribution, whose 20.19.2 is below the floor stdio backends declare. The release publishes `latest-full`, `-full` and `.-full` alongside the tags they mirror, plus the `sha--full` provenance tag the list is composed under; as on the default image, `latest-full` and `.-full` move only for a stable release, so a candidate is reachable only by its exact version. The default image is unchanged. - **`EXTRA_APT_PACKAGES` on the `-full` variant.** The entrypoint installs the apt packages a deployment names — `iproute2` to route a backend's egress through a tunnel, say — so needing one more than the image carries no longer means forking it. Installing needs root, so the deployment asks with `--user root` and the entrypoint drops back to the gateway user before exec'ing it; the image still declares `USER gateway`, and with the variable unset a container runs exactly as it did before. The install runs non-interactively, is bounded, refuses an apt option or a removal (a word starting or ending with `-`) in the list, and stops the container on a failure rather than starting without a package it was told to carry. It resolves versions at every start, and the list takes apt's pinning syntax (`iproute2=6.12.0-1`); a derived image stays the reproducible option, and is the only one open to a deployment that runs non-root. ([@terafin](https://github.com/terafin), [#4065](https://github.com/MikkoParkkola/mcp-gateway/pull/4065), from #729) - **`/docker-entrypoint.d` on the `-full` variant.** Executable `*.sh` files in that directory run, and `*.envsh` files are sourced, in name order, before the gateway starts — so a deployment whose startup needs a route, a key or a cache warm mounts its own steps instead of forking the image. A container started as root runs them as root and then drops to the gateway user, so the directory is mounted read-only and owned by root. A variable an `.envsh` step exports reaches the gateway, except `HOME` in a container started as root; one it only assigns does not. A file that is not executable is skipped with a message, and a step that fails stops the container. nginx and postgres ship the same convention. The `-full` variant's health check allows a 60 s start period for the install; the default image is unchanged. ([@terafin](https://github.com/terafin), [#4065](https://github.com/MikkoParkkola/mcp-gateway/pull/4065), from #729) `docs/DEPLOYMENT.md` previously answered this case with "install Node.js in the image", which leaves each operator maintaining a private layer that nothing keeps in step with the gateway it fronts — and which fails silently when it falls behind, because a stale runtime is only visible when a backend that needs it stops starting. ### Fixed - **A restart no longer resets the daily cost budgets.** The gateway saved each day's spend to `costs.json` on shutdown but never read it back, so any restart put the global, per-tool and per-key daily totals at zero and an agent that had hit its cap could spend again. The totals saved today are now restored on start; a file saved on an earlier day is not carried over, and an unreadable one starts at zero with a warning naming the file. (MIK-8904) - **tokio 1.53.3 (RUSTSEC-2026-0386).** tokio 1.53.3 deprecates `AsyncFd`'s safe constructors, which accepted any raw file descriptor and could break I/O safety. The gateway does not call them. Lockfile only. Ported from the release line (#4081). - **ctutils 0.4.5 (RUSTSEC-2026-0385).** ctutils 0.4.2 could compile its constant-time mask generation to a branch. HMAC verification of attestation tokens, receipts and signed log entries reaches it through `digest`. Lockfile only. Ported from the release line (a1ecb6fc6). - **The `-full` image pins `undici` 6.29.1 (CVE-2026-109869).** The pin was 6.29.0, which carries CVE-2026-109869 (HIGH, a denial of service through an unhandled stream error) and fails the image's Trivy gate. The new pin carries the registry's `dist.integrity`, which the downloaded tarball also hashes to. - **The `-full` image no longer runs a downloaded installer script as root.** Node.js comes from NodeSource's apt repository, trusted through a signing key whose fingerprint is pinned and checked at build time against the key file's one primary key (a matching subkey is not enough); apt's `Signed-By` also names that fingerprint, so apt trusts that key alone whatever else the file holds. `uv` is copied from its official image pinned by version and digest, instead of piping NodeSource's setup script and uv's `install.sh` into a root shell. - **`docker stop` no longer waits out the grace period while a `-full` startup step runs.** The `-full` image's entrypoint ran as PID 1, and a sourced `.envsh` step ran in its own shell, so a stop during that step waited for it to return: Docker's 10 s stop timeout, then a `SIGKILL`. The image now runs `tini -g` as PID 1 in front of the entrypoint, so the stop reaches the step at once and the container exits with status 143. tini also reaps orphaned processes. The default image is unchanged. Ported from the release line (#3812). - **A `-full` startup step that turns off `set -e` or `set -u` no longer weakens the steps after it.** A sourced `.envsh` step ran in the entrypoint's own shell, so a `set +e` in one step meant a failing command in a later step no longer stopped the container, and the gateway started after a failed step. The entrypoint now restores `set -eu` after each sourced step. A step's exported variables still reach the gateway. - **Backend names that differ only in punctuation or case no longer share a package cache, and every package manager gets the cache setting it reads.** `team.alpha` and `team_alpha` mapped to one cache directory, and `bunx`, `yarn` and `pnpm` were given `npm_config_cache`, which they do not read. The cache directory name now carries a hash of the full backend name: - `bunx` gets `BUN_INSTALL_CACHE_DIR`; - `yarn` gets `YARN_CACHE_FOLDER`, with Berry's global cache turned off so the folder takes effect; - `pnpm` gets `pnpm_config_store_dir` and `npm_config_store_dir`. An operator's own cache setting is recognised in exactly the spellings that runner reads. A data directory that is not valid UTF-8, or holds `..` or `${`, is assigned to no runner, instead of a path the runner would read as another directory. A relative `MCP_GATEWAY_CONFIG_DIR` is resolved against the gateway's working directory, and a non-UTF-8 one is used rather than replaced by the default. Package caches are downloaded once more after the upgrade. The old `pkg-cache/` directories are no longer used and can be deleted. Ported from the release line (#2258, MIK-8147, MIK-7964). - **A damaged package cache is repaired without blocking or racing other starts.** Each start of a backend with a gateway-assigned cache now holds that cache's lock from the first spawn to the end of any retry, so a concurrent start cannot install into a tree a repair is replacing. The wait for the lock is bounded, and a start that waits it out fails as unavailable and is retried. The damaged tree is moved aside in one rename and deleted in the background with no lock held. A move left behind by a crash is swept on the next start. Ported from the release line. - **The `-full` image's bundled npm packages are past the new HIGH CVEs, and verified by hash.** `brace-expansion` (CVE-2026-102276, CVE-2026-102278) and `undici` (CVE-2026-19534) in npm's own tree are replaced with fixed releases, and every npm tarball the image fetches is checked against a pinned sha512 before use. - **The `-full` variant's smoke gate bounds every probe.** Four of its six probes resolve over the network — npx and uvx fetch a package, git fetches a remote — and none was bounded. On a runner whose resolver stops answering, the gate waits on the first probe until the job is killed hours later and reports only `cancelled`, with nothing in the log between the step's first line and the cancellation. Each probe now runs under a per-probe ceiling, and a probe that hits it reports which one and how long it waited, so a stalled network is a red step in seconds that names itself instead of an invisible wait. - **Two backends running the same command no longer share a package cache.** `npx -y ` installs into a cache directory shared by every process on the host, and concurrent installs into one tree can tear it — after which npm trusts the damaged tree and every later spawn of that command fails with `MODULE_NOT_FOUND`, reported as "Backend timeout" with zero tools. Each backend now gets its own cache directory. An operator-set `npm_config_cache` is left alone. - **A backend whose package install is unusable now repairs itself.** A package manager says so on stderr and the child exits before it can answer the handshake, so the transport's own error carried only `Backend timeout` and nothing could tell that death apart from a backend that is merely dead. Every start of a backend now passes through the repair: an install that names a missing module has its cache cleared and is retried once. Because the decision sits on the start path rather than inside the transport, it also reaches the starts warm-start and the health probe make. Telling the two deaths apart means reading the child's stderr, so the tail is held in memory for that purpose and never written to the log; a failed start reports which needle matched and how the child exited, which classifies the death without putting an installer's output — a failing install prints the token it tried — into the gateway's log. A cache is cleared at most once per successful start, so a backend that cannot install is not reinstalled on every restart, and a removal that fails re-arms the repair rather than consuming it. Only a cache this gateway assigned is touched: the path recorded when the child's environment was built, never one an operator set, and a path ending in a separator or reaching a symlinked leaf is refused rather than walked. A per-path lock spans the mark, the removal and the retry, so two starts of one backend cannot delete the tree the other is installing into. The delete runs on the blocking pool. A refusal that a fresh install would meet identically (`EALLOWGIT`) is not treated as a damaged tree. - **An operator's npm settings now reach the package managers a backend spawns.** `env_clear()` left a backend's installs with none of them, so a setting that has to apply to every backend — a CA bundle, a log level, an offline preference — had to be repeated in each backend's own `env:`. A short explicit allowlist is forwarded instead: `npm_config_allow_git`, `npm_config_cafile`, `npm_config_loglevel`, `npm_config_offline`, `npm_config_prefer_offline` and `npm_config_strict_ssl`, matched the way npm reads them (case-insensitively) and delivered in the operator's own spelling. An allowlist rather than a deny list because npm's credential surface is open-ended — `_password`, `certfile`, `keyfile`, `userconfig`, and registry or proxy URLs with embedded userinfo all name secrets — and anything a deny rule misses would reach every backend. A backend that needs a credential names it in its own `env:`, as it already does for `allow_git`. Because this is one operator value handed to every backend, setting `strict_ssl`, `cafile` or `allow_git` in the gateway's own environment sets that policy deployment-wide. A backend that needs a different value names it in its own `env:`, in whichever spelling — the backend's setting is not forwarded over, and it is the one the child keeps. - **`gateway_search_tools` finds a backend by its own name.** The match ran over each tool's name and description only, so a query naming the server returned nothing unless the caller happened to guess one of that server's tool names — `codesearch` serves `search_code`, `search_instructions` and `get_code_context`, and the string `codesearch` appears in none of them, so the search answered with spelling suggestions for a backend whose tools were being served at that moment. The serving backend's name is now part of the haystack, which is also what the code-mode path already did for `server:tool` queries. ### Changed - **A tool count is no longer reported as `0` before a backend has been enumerated.** `gateway_list_servers`, the `initialize` preamble and the `gateway_list_tools` / `gateway_search_tools` descriptions all derive their total from the tool cache, which is filled lazily; a backend that had not been asked yet contributed `0`, so a cold gateway advertised "0 tools across N backends" and its discovery descriptions said there was nothing to search. The total is now stated as a floor ("at least N tools") until every backend has been enumerated, and left unstated only when none has — so a partially warmed gateway keeps its number instead of losing it. `gateway_list_servers` gains `tools_known` beside `tools_count`, so a reader can tell "exposes no tools" from "not asked yet", and its `tools_count` is `null`, not `0`, until the backend's tools are known: agents read a bare `0` as "this backend serves no tools" and gave up on working backends. The tool description says so and points at `gateway_list_tools(server)`, which loads the list. A client that parsed `tools_count` as an integer must accept `null` (UPGRADING-4.0 item 7). Thanks to @terafin (#4040). - **Six meta-tools are now listed only where they can answer.** `gateway_get_stats`, `gateway_cost_report`, `gateway_run_playbook`, `gateway_set_profile`, `gateway_get_profile` and `gateway_list_profiles` were listed in `tools/list` unconditionally, so a default deployment spent context on six tools whose only possible reply was "not configured". Each is now gated on the thing that lets it answer: a cost registry, a non-empty playbook engine, a configured routing profile, and for statistics a new `meta_mcp.expose_stats_tool` opt-in (the usage collector is always attached, so its presence never gated anything). The default HTTP surface drops from 17 tools to 11, stdio from 16 to 10, and the `NFR.PERF.4` band from `14..=17` to `9..=17`, all at admin standing. **This is a disclosure change, not a capability removal.** All seventeen names still dispatch by name on every deployment. A caller that invokes a tool it was not shown gets the tool's own answer — for a gated one, a refusal naming the configuration to add — never "no such tool". An operator who wants a tool back in the listing configures the feature it reports on; `meta_mcp.expose_stats_tool: true` restores `gateway_get_stats`. Operator-visible consequence: a client that enumerates `tools/list` and refuses to call anything absent from it will stop reaching these six until the corresponding feature is configured. ### Security - **A crafted `costs.json` can no longer reset an exhausted daily budget.** Restoring the saved totals skipped a valid per-tool total when it would overflow the restored global sum, and restored a counter that had saturated as zero; either reopened a per-tool or per-key cap after a restart. Every in-range saved total now comes back, the global total saturates instead of dropping one, and a saturated counter comes back saturated. ## [4.0.0] - 2026-09-19 > Upgrading from 3.x: see [`docs/UPGRADING-4.0.md`](docs/UPGRADING-4.0.md). A 3.x `gateway.yaml` > loads unchanged and no migration edits it; the strict `env_files` parsing is the one change that > refuses a start rather than warning. The first start from a 3.x install prints the changes that > need an operator action. > **What the performance numbers are, and are not.** The 4.0.0 comparison against > 3.5.0 is a component benchmark, not an end-to-end one. It measures in-process > work with `criterion` — no wire, no backend, no queue — and reports a point > estimate with a bootstrap confidence interval. It therefore produces **no P50 > and no P99**: a confidence interval is not a percentile, and the repository has > no client-to-backend harness at any version to take percentiles from. What the > measurement does support: over the 47 cases 3.5.0 and 4.0.0 share, the worst > regression is +6.07% (`session_sandbox/check_tool_denied`, 86.27 ns to 94.18 ns) > and the largest movement is a 67% improvement. Nothing approaches the 5% P50 or > 10% P99 budgets those bounds were written against. Read it as headroom on > component cost, and do not quote it as end-to-end latency. ### Added - **The kill-switch error budgets are tunable from the config file** (GH #475). An `error_budget:` section sets the backend failure-rate `threshold`, `window_size`, `window_duration` and `min_samples`, and an `error_budget.capability:` sub-section sets the same four plus a recovery `cooldown` for the per-capability budget. Every key is optional and an absent key keeps the value that has been shipping, so a config without the section behaves exactly as before. Values are validated at load and refused with the offending field named rather than clamped: a threshold outside `(0.0, 1.0]` (`.nan` included), a window of zero or of more than 100000 calls, a zero `window_duration` or `cooldown`, and a `min_samples` above its own `window_size`, which describes a budget that can never be evaluated. An unknown key at either level is refused too, so a typo is not read as a default. The section is read when the meta-MCP server is built, so an edit to it is reported as restart-required rather than appearing to take effect. - **`meta_mcp.exposed_meta_tools` restricts the meta-tool surface** (GH issue 449): an allow-list of meta-tools to expose, enforced on both `tools/list` and `tools/call` for every meta-tool built-in, including the two Code Mode tools (`gateway_search`, `gateway_execute`). The field is new in 4.0.0 and defaults to empty, which exposes everything as before, so no existing configuration changes behaviour on upgrade. An allow-list that omits `gateway_invoke` is honoured and logged as a warning, since it leaves backend tools unreachable through the gateway. `meta_mcp.surfaced_tools` is a separate list and is unaffected. - **First start after upgrading to 4.0.0 prints what changed underneath it.** The release re-keys OAuth credentials, refuses a malformed `env_files` line at startup instead of ignoring it, stops advertising protocol revision 2024-10-07 and stops counting rate limiting against error budgets. Each is announced once, on the first start from a 3.x install; the notice reads no configuration and writes none. - **MCP protocol revision 2026-07-28, behind `server.modern_protocol`.** The revision removes the `initialize` handshake, protocol sessions and the `Mcp-Session-Id` header, `ping`, `logging/setLevel` and server-initiated requests; it adds `server/discover`, per-request metadata, multi-round-trip requests, required result and cacheability fields, and the standard request headers. **The switch is on by default in 4.0.0.** A stock gateway serves 2026-07-28 to a client that asks for it, and downgrades to the highest revision the client supports otherwise. Set `server.modern_protocol: false` to serve the legacy generation only. With it off, a client asking for 2026-07-28 is refused with `UnsupportedProtocolVersion` — an answer it can act on — rather than served half a revision, where the half that works hides the half that does not. Clients on 2025-11-25 and earlier are unaffected either way, and the gateway serves both generations on one endpoint. `server/discover` is answered regardless of the switch, on stdio and Streamable HTTP. It is additive, and it is the only probe that works in both directions once the handshake is gone. **With the switch on, a retry reaches one replica.** The consumed-continuation ledger and the mint counter are process-local, and so is the continuation key each process generates at startup: an envelope opens only on the replica that minted it, which is what makes a continuation single-use across replicas without a shared store. The cost is that a retry landing on any other replica is refused, and a restart invalidates the continuations outstanding against the process it replaced. This binds only when `server.modern_protocol` is on; with it off, scale as before. **The tasks extension is not implemented.** `io.modelcontextprotocol/tasks` is never advertised, so no client negotiates it. The types in the tree are short of the specification — three statuses of five, two required fields missing, a string where a JSON-RPC error object belongs — and turning the advertisement on before that is fixed would break a client that trusted the identifier. MIK-7311 owns the conformant implementation. ### Changed - **BREAKING: an HTTP backend that uses OAuth must be reached over TLS or on loopback** (CodeQL `rust/cleartext-transmission` #90, #91; CWE-319). The bearer token this transport attaches is a replayable credential, so it is no longer put on the wire in cleartext. `https://` is always accepted; `http://` is accepted only when the host is loopback — `localhost`, any address in `127.0.0.0/8`, or `::1` — because a local MCP backend has no certificate and its traffic never leaves the machine. Anything else is refused twice: the backend fails to start with `refusing to send an OAuth token in cleartext to `, and the token is refused again at request time if an SSE-advertised message endpoint ever downgrades the scheme. IPv4-mapped IPv6 (`http://[::ffff:127.0.0.1]`) is deliberately treated as non-loopback; use `http://127.0.0.1` instead. **Migration**: put TLS in front of the backend, or move it to a loopback address. There is no configuration flag to opt out — a flag would re-enable the finding, and 4.0.0 is the release allowed to break this. Backends without OAuth are unaffected and may still use plaintext `http://`. ### Removed - Removed the ungrounded savings estimates from gateway statistics: the `stats --price` flag, the `gateway_get_stats.price_per_million` argument, the `tokens_saved` and `estimated_savings_usd` response fields, and the public `StatsSnapshot::tokens_saved`, `StatsSnapshot::estimated_savings_usd`, and `UsageStats::cost_savings` fields. ### Fixed - **Competitive shadow-scan exports now stay portable and loadable.** The generated grep rules use the system `grep -E` on macOS and Linux, while the Nginx example preserves quoted and escaped log values. ## [3.5.1] - 2026-09-04 ### Changed - **A backend that would send credentials in cleartext is refused at config load** (code-scanning alerts #90, #91): an enabled backend whose `http_url` or `a2a_url` is `http://` against a host off this machine, and whose configuration is credential-bearing — an `oauth` section (including one with `enabled: false`), identity propagation, secret injection, any static header whatever its name, or userinfo or a query string in the URL — no longer starts the gateway. The predicate is deliberately blunt: a header named `X-Trace-Id` and a query of `?page=2` trip it too, because whether a given header or query carries a secret is not decidable at config load, and a name list would only catch the operators who guessed the same names we did. Such a credential is readable by every host on the path and replayable for as long as it is valid, and a config typo should not be what decides that. Loopback is exempt, decided by the same classifier the Origin gate uses. **Breaking for operators pointing any of that configuration at a plain-`http` internal host**: use TLS, or set `allow_cleartext_credentials: true` on that backend to accept the exposure. The refusal names the backend and never echoes the URL, which is the credential-bearing string. - **Destructive meta-tools are refused over stdio** (MIK-7246): `gateway_kill_server` carries `destructiveHint: true`, and the gateway asks the operator to confirm such a call before running it. That ask travels over the elicitation channel, which only the HTTP transport has — stdio speaks to one process over two pipes and can reach nobody. A destructive tool called over stdio is now refused with `-32001` and a message naming the action, rather than executed with a warning. **Breaking for stdio operators who kill backends through the gateway**: reach the management tools over the HTTP listener with a client that answers `elicitation/create`, or change the backend's configuration directly. Neither an unobtainable confirmation nor an operator decline counts against the caller's failure budget — the gate working is not the client misbehaving. - **OAuth credentials are keyed by the authorization server that granted them.** MCP 2026-07-28 requires a client to key persisted credentials by the issuer identifier, to not reuse them with a different authorization server, and to re-register when that server changes. Tokens and dynamically registered client ids were keyed on the backend name alone, so moving a backend to a new authorization server presented it a client id it never issued — surfacing later as a confusing rejection rather than the re-registration it should have been. **On upgrade, backends using OAuth re-authenticate once.** Credentials stored by an earlier version carry no issuer and so cannot be attributed to one; they are not served to any. Reading them under the old key would defeat the separation this change exists to enforce, so the gateway re-registers and re-authorizes instead. No configuration change is needed. - **BREAKING: a malformed line in an `env_files` file now refuses the start.** Earlier versions skipped an unparseable line silently, so a typo cost one missing variable and surfaced later as an unauthenticated backend. The loader now fails startup and names the file, the line number and the category of fault; the offending line is never echoed, because the offending line is the secret. **Migration**: start once before rolling out and fix what it names — a file that parsed by luck under 3.x now has to parse by grammar. Assigning `HOME` in a reloaded env file reports `restart required` rather than moving where a later `~` points. - **`2024-10-07` is no longer advertised as a supported protocol version.** It is not a revision the specification has ever defined; it was introduced with the first version-negotiation commit in January and has been offered to every client since. It was inert for negotiation — no conforming client can request a revision that does not exist — but `server/discover` publishes this list as the gateway's own statement of what it speaks, which turns an unused constant into a claim. - **A response is cached only under a protocol revision the gateway can identify.** The response cache is keyed by the revision a request was served under, and a request whose revision cannot be determined is not cached at all (`cache_protocol_revision`, `src/protocol/meta.rs:514`). A modern request carries its revision in the body. A legacy-shaped request must supply it in the `MCP-Protocol-Version` header or have bound one by completing `initialize` on the session (`:522`). Anything else resolves to "no revision", which is documented as fail-closed and means skip the cache (`:512`). Earlier versions had no such key, so a response fetched for a caller that declared no revision could be served to a caller asking under a different one. **A stateless client loses response caching on upgrade.** A bare `POST` that sends no `MCP-Protocol-Version` header and never runs `initialize` — the shape common to load generators, probes and short scripts — is no longer served from cache, and that traffic reaches the backends instead. Nothing errors, so the symptom is throughput and backend load rather than a failure, and the gateway's own rate limits then apply to calls that previously never reached them. Send the header on stateless requests, or complete `initialize` and reuse the session; either restores caching and neither needs a configuration change. - **BREAKING: one license covers the whole repository — PolyForm Noncommercial 1.0.0.** 4.0.0 retires the MIT core and the per-file allowlist that enumerated it, so every first-party file in the tree is Noncommercial from this release onward ([ADR-013](docs/adr/ADR-013-single-noncommercial-license.md), [LICENSES.md](LICENSES.md)). **Migration**: commercial use that relied on the MIT-headered core needs a commercial license — see [COMMERCIAL.md](COMMERCIAL.md). Releases already published under MIT keep the terms they shipped under; this is not retroactive. - **`gateway_search` no longer emits ranking signals that never vary.** Thirteen of sixteen were the constant `1.0` in every response. The per-tool ranking block falls from 534 to 304 bytes. ### Removed - Removed the ungrounded savings estimates from gateway statistics: the `stats --price` flag, the `gateway_get_stats.price_per_million` argument, the `tokens_saved` and `estimated_savings_usd` response fields, and the public `StatsSnapshot::tokens_saved`, `StatsSnapshot::estimated_savings_usd`, and `UsageStats::cost_savings` fields. - Removed `.mit-core-allowlist`, the per-file manifest that enumerated which sources carried an MIT header. A single repository-wide license leaves it nothing to enumerate ([ADR-013](docs/adr/ADR-013-single-noncommercial-license.md)). ### Fixed - **A backend whose SSE response opens with a retry priming frame is no longer a transport error** (GH #563). Servers built on `rmcp` with its default `sse_retry` prepend such a frame -- a `data:` line with nothing after it, plus `id:` and `retry:` -- to every POST response stream. 3.5.x took the first `data:` line verbatim and failed the whole call with `Failed to parse SSE data: EOF while parsing a value at line 1 column 0`, so no such backend could be used at all. The response stream is now decoded frame by frame: a block whose joined `data` is empty carries no event, and fields that are neither `data` nor `event` are ignored, so the exchange resolves on the frame that actually holds the JSON-RPC response. - **Windows stdio backends start with a usable environment, and quoted commands parse by host rules.** `APPDATA` and `LOCALAPPDATA` were never passed to stdio child processes, so a backend resolving its own configuration under those paths started degraded on Windows. Configured stdio commands now go through a single parser, `transport::split_command`, which follows `CommandLineToArgvW` on Windows so the backslashes in a path survive; spawn, lifecycle, diagnostics, the UI summary and `doctor` all read that one parser, and `doctor` fails on invalid quoting instead of reporting a false `PASS`. ([@yfcyfc123234](https://github.com/yfcyfc123234), [#522](https://github.com/MikkoParkkola/mcp-gateway/pull/522), [#564](https://github.com/MikkoParkkola/mcp-gateway/pull/564)) - **A backend that rejects the gateway's protocol version is negotiated down rather than failed.** A Streamable HTTP backend supporting 2025-06-18 or earlier answered `initialize` with a `400`, and the gateway gave up instead of offering a revision that backend could accept. The gateway now adopts the server-selected version for post-handshake headers, negotiates when a version is rejected by HTTP status, and fails the backend by name only when the selection is genuinely unsupported. (reported by [@luochen1990](https://github.com/luochen1990), [#517](https://github.com/MikkoParkkola/mcp-gateway/issues/517)) - **A throttled backend no longer looks like a failing one.** A rate-limited response counted against the backend error budget, the per-capability budget and the circuit breaker exactly as a `500` did, so a caller fast enough to be throttled could open a circuit on a backend that was answering correctly. `429`, `too many requests`, `rate limit`, `RESOURCE_EXHAUSTED` and `throttled` now record the backend as reachable and contribute no budget sample at all — neither success nor failure, because a throttle says nothing about health. Every exclusion increments `mcp_error_budget_suppressed_total`, so the suppression is visible rather than inferred. (requested by [@crepererum](https://github.com/crepererum), [#475](https://github.com/MikkoParkkola/mcp-gateway/issues/475)) - **A failed config load no longer leaks its env files into the process.** Reading a config file used to apply every `env_files` entry it named to the process environment before validating the file, so a refused reload changed the environment a capability resolved its credentials from and a refused reload was only a partial no-op. Env files now resolve into an overlay that a failed load discards, so the environment is left exactly as the last accepted configuration left it. A **malformed** line now fails startup rather than being skipped, naming the file, the line number and the category of fault. The offending line is never echoed, because the offending line is the secret. A `~` in an `env_files` path resolves exactly once, at startup; each file is applied before the next is expanded, so a file that sets `HOME` moves where a later `~` points, and a reload reuses the paths startup recorded rather than resolving them again. Assigning `HOME` in a reloaded env file reports `restart required` instead. Capability credentials resolve through that overlay. An `env:` key on a capability used to read the process environment directly, so a value an env file supplied reached a `${VAR}` expansion in the config but not the credential a capability sent upstream. Both now read the same value, and the process environment is still the last place looked. `GATEWAY_ATTESTATION_SIGNING_KEY` and `GATEWAY_ATTESTATION_KEY_ID` read the same overlay, under fixed variable names rather than through a `{env.VAR}` reference in configuration. An env file can supply them, and the process environment is still the last place looked. - **A `{env.VAR}` secret now reads the env-file overlay.** Env files no longer load into the process environment, and secret resolution still read only that environment, so a webhook secret or capability credential written as `{env.VAR}` and supplied by an env file expanded to the empty string. The resolver now consults the overlay first and the process environment after, matching how `env:` credential keys already resolved. - **A webhook secret that resolves to nothing is refused.** An empty HMAC key is one anyone can compute, so every forged signature verified. An empty resolved secret is now rejected the same way a missing one is, whatever made it empty. - **The provenance signing key reads the env-file overlay.** Runtime provenance stamping took `GATEWAY_ATTESTATION_SIGNING_KEY` from the process environment, so a key supplied by an env file no longer reached it and the signer stayed uninstalled. The key and its id now resolve through the overlay. - **A config rewrite no longer persists a resolved secret.** Every read-modify-write path — the admin UI and the CLI commands that edit `gateway.yaml` — used to load the config with `env:` references and `${VAR}` placeholders already resolved, then serialise the result back to disk in plaintext. Those paths now load the file literally: references are preserved as written, and a value an env file or an `MCP_GATEWAY_*` variable supplies never reaches the struct that is written out. A write is still validated, against the env files the config being written names. - **A reload is no longer refused over a reference the parser would ignore.** Env files are no longer applied to the process, so a `${K}` or `$K` reference to a key another env file defines can no longer expand on a reload, and the gateway refuses the reload rather than silently substituting nothing. That refusal now matches `dotenvy`'s own grammar: an unbraced name ends at the first non-alphanumeric character, a tab before `#` starts a trailing comment, and comments, single-quoted values and escaped `\$` are inert as they always were. Scanning is per logical line, as the parser reads them. - **Competitive shadow-scan exports now stay portable and loadable.** The generated grep rules use the system `grep -E` on macOS and Linux, while the Nginx example preserves quoted and escaped log values. ### Security - **Anomaly detection reports when it cannot see, instead of scoring a call neutral.** It was keyed on the session, and a per-request session makes every call look like a first call — scoring 0.5 against a 0.7 threshold, forever. The control kept running and stopped protecting. - **Per-caller state is reclaimed on a deadline as well as on disconnect.** The disconnect trigger fires on an SSE close or `DELETE /mcp`, neither of which exists in the new revision, so every registered cleanup handler would simply never run. - **A destructive call that cannot be confirmed is refused on the modern path.** The gate proceeded on a warning when elicitation was unsupported *or there was no session*; with sessions removed, every modern destructive call would take that branch. The legacy path keeps its documented behaviour. The governed set now comes from the `destructiveHint` annotation rather than one hardcoded name. - **Multi-round-trip continuations are sealed, bound and single-use.** A backend's opaque state is encrypted inside the gateway's own envelope rather than handed to the client, bound to the caller and the original request, and redeemable once. **Retry forwarding is not implemented in this release.** The minting, sealing and single-use ledger exist and are tested; unsealing a continuation and forwarding the retry to the backend does not. A well-formed retry is refused with `-32602` and "retry forwarding is not available on this build" rather than being run as a fresh call, because running it fresh would repeat whatever the first attempt already did. MIK-7325 owns the forwarding path. - **`tools/call` no longer drops a retry's `inputResponses` and `requestState`.** Both were silently discarded, so an elicitation could never complete and the destructive-confirmation gate ran without the answer it exists to collect. - **Dynamic client registration declares `application_type`, and a returned `iss` is validated before an authorization code is redeemed** (RFC 9207). Persisted credentials gain an issuer-keyed storage key, since a credential is not valid with an authorization server that never issued it. ## [3.5.1] - 2026-09-04 ### Changed - **`gateway_search` returns L0 by default** (MIK-7084): tool name, one-line purpose, and score. `detail=l1` adds signature, when-to-use, and required params; `detail=l2` returns the full `input_schema`. `include_schema=true` still maps to L2 and is deprecated, not removed. Ranking diagnostics (`ranking` reasons and signals) are omitted unless `explain=true`. `gateway_search_tools` also omits `ranking` unless `explain=true`. ### Fixed - **`prompts/list` and `resources/list` no longer stall on a slow or hung backend.** Both handlers aggregated every backend sequentially, so a single backend that was slow to answer held the whole request for its full transport timeout (often 120s). On a gateway with 50+ backends this blew past client connect timeouts on every (re)connect, and the gateway's late response surfaced as an "unknown message ID" error. Backends are now fetched in parallel and each fetch is bounded by a short timeout, so a slow or hung backend is skipped instead of blocking the list. Reported and fixed by [@terafin](https://github.com/terafin) from a 56-backend deployment, in [#465](https://github.com/MikkoParkkola/mcp-gateway/pull/465). - **The aggregation timeout is configurable** via `meta_mcp.prompts_resources_fetch_timeout` (default `10s`). Operators with unusually slow backends can raise it without a code change. ([@terafin](https://github.com/terafin), [#465](https://github.com/MikkoParkkola/mcp-gateway/pull/465)) - **A cancelled transport request no longer strands its `pending` entry.** When an outer timeout drops an in-flight stdio or WebSocket request before the transport's own request timeout fires, a RAII guard removes the entry from the transport's `pending` map on drop, so a late response finds no dangling sender and the map does not grow across reconnect loops. ([@terafin](https://github.com/terafin), [#465](https://github.com/MikkoParkkola/mcp-gateway/pull/465)) - **Unreadable gateway config now reports a diagnosis** instead of a generic failure, so a permissions or parse problem is visible at startup. ([#461](https://github.com/MikkoParkkola/mcp-gateway/pull/461)) - **Sampling POST-backs are bound to the prompted session**, so a late sampling response cannot land on a different client. - **Glob L0 ranking drops disabled tools** and still assigns a score, so search results do not advertise tools the operator turned off. ([#470](https://github.com/MikkoParkkola/mcp-gateway/pull/470)) ## [3.5.0] - 2026-08-28 ### Added - **`mcp-gateway init` generates an admin credential for the install** and writes it into `gateway.yaml`, along with `auth.public_paths` covering `/health` and `/mcp` so tool calls keep working. This is what makes the credential requirement below survivable on a new install: management needs a credential, and now there is one. An upgrade does not rewrite an existing config. If you manage the gateway today with authentication off, see the BREAKING note below for the edit to make by hand. - **A way into the dashboard that a browser can actually use.** A browser cannot attach an `Authorization` header to a navigation, so `serve` prints a link once: ``` DASHBOARD (opens once, then remembered in this browser): http://127.0.0.1:39400/dashboard?bootstrap=... ``` Opening it exchanges a single-use value for a session cookie and redirects, so nothing stays in the address bar. The value in the link is **not** the admin credential; it works once, dies with the process, and is redeemable only from this machine, so a link left in a shell history is spent. Locality is established from the connection's peer address rather than from the `Host` header, which the caller writes and a reverse proxy rewrites — nginx's default for a bare `proxy_pass` is the upstream address, so a forwarded request used to arrive carrying a loopback `Host`. A request with a forwarding header is refused as well, since a proxy on this same machine also connects from loopback. A proxy that strips those headers remains indistinguishable from a local browser; the printed value is sensitive. The cookie carries an opaque handle rather than the credential, `HttpOnly` and `SameSite=Strict`, and `Secure` when the listener speaks TLS. Details in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#opening-the-dashboard). - **Config files are written readable only by you** (`0600`) on Unix, on every path that writes one — `init`, the dashboard's edits, and the config-export command. The file holds this gateway's credentials, and until now it inherited the process umask. Windows has no equivalent here and the file takes the directory's inherited permissions; stated rather than silently implied by the sentence above. An existing file is **reported, not changed**: the startup log names it, its mode, and the `chmod` to fix it. Silently re-permissioning a file you own is its own surprise. It does not stay wide forever either — writes replace the file from a scratch file created `0600`, so the next config write tightens it. ### Security - **Origin validation on the HTTP surface (CWE-346).** Reported by Avishai Gonen, Pluto Security. `mcp-gateway serve` accepted requests on `/mcp` without checking `Origin` or `Host`, and the identity used when authentication is disabled carried admin rights and access to every backend. A web page could therefore reach the gateway's local port and call its tools with whatever credentials the gateway holds. A related shape is worth stating precisely, because the mechanism that closes it is not the obvious one. The handler accepts a request body without requiring a JSON content type, a session, or a prior `initialize`, so a cross-origin form POST can reach `tools/call` without triggering a preflight. That vector is closed by the origin check below and by nothing else: a form POST from a browser carries `Origin`, and the request is refused on it. No content-type requirement was added, because non-browser MCP clients do not reliably send one and refusing them would break the callers this gateway exists to serve. Separately, the checks below apply to browsers only; a process running under the same user account is not constrained by them. Two changes, both required: - `Origin`, `Host`, the HTTP/2 `:authority` and `Sec-Fetch-Site` are checked ahead of authentication, so a cross-site request is refused before an identity is assigned. A request without `Origin` is not refused on that ground, since non-browser MCP clients do not send one — but the `Host` check applies to every request regardless, so a client that reaches the gateway by a name it does not answer to is refused whether or not it is a browser. `Sec-Fetch-Site` covers the no-CORS GET, which the Fetch standard omits `Origin` from. - The identity used when authentication is disabled no longer carries admin. - **A playbook step now faces the caller's own permissions.** Found while reviewing the fix above, and pre-existing rather than introduced by it. The per-caller checks — backend scope, tool allow-list, global tool policy, certificate policy and agent scope — ran at the HTTP router, which inspects the incoming request. A playbook's steps come from the stored playbook, so their targets never appeared in that request and the router had nothing to check: a restricted client could reach a backend through a playbook that it could not reach directly. The check moved to the single point every backend invocation passes through, ahead of every side effect, so a refused call reads no cached result, consumes no replay nonce, mints no credential and charges no budget. Code-mode steps and surfaced tools pass the same check. Operator-visible consequences: - A client with `backends` or `allowed_tools` set may now see a playbook step refused that previously ran. That is the fix working; widen the client's scope if the access was intended. - A refused call answers HTTP `403` rather than `200` with the refusal in the body. - Under `on_error: continue`, a refused step is recorded in a new `step_errors` field on the playbook result, keyed by step name, alongside the existing `steps_failed`. A run that fails nothing omits the field entirely, so successful output is unchanged. A refusal is never retried, since retrying a permission denial cannot change the answer. - `PlaybookResult` gained that field and is now `#[non_exhaustive]`. Code constructing it with a struct literal must change; code reading it need not. Standing limitation, unchanged: a process running under the same user account is not constrained by any of this. - **stdio gained the tool-policy check it never had.** The stdio transport applied the global tool policy to `gateway_invoke` alone, so a playbook or code-mode step reached a backend with no policy check. Every dispatch shape now passes the same check. Certificate policy is deliberately not applied there: stdio presents no certificate, so evaluating it would refuse every call once any certificate rule existed. ### Changed - **BREAKING: an agent's key material is checked at startup.** With `agent_auth.enabled = true`, a config that previously loaded is now refused when an agent: - sets an `hs256_secret` shorter than the 32-byte minimum, or one whose `env:` variable is unset. `DecodingKey::from_secret(b"")` is a valid key, so an empty secret verifies a token anyone can sign — that agent authenticates the world. A secret that is merely short is not forgeable by inspection, but it falls under the project's minimum and is refused on the same line; - sets both `hs256_secret` and `rs256_public_key`. The algorithm is read from the token header, so the caller picks which key verifies it and the agent is only as strong as the weaker one. Configure exactly one; - sets neither, and so can verify nothing. The refusal names the agent and the reason. To upgrade: rotate any secret below 32 bytes, and drop one key from any agent holding both. - **BREAKING: server management requires a credential.** With `auth.enabled = false`, `gateway_kill_server`, `gateway_revive_server`, `gateway_reload_config` and `gateway_reload_capabilities` are unavailable. Those four change the gateway for every session. `gateway_set_profile` and `gateway_set_state` are NOT gated: each writes only the caller's own session and cannot widen what that caller reaches, and gating the first stopped nothing anyway, since a profile can be chosen at `initialize` through the same call with no credential. This applies to callers over HTTP. A stdio caller is treated as admin, because the client that spawned the process already holds whatever the operator holds and could edit the config file directly; withholding it there would remove management from exactly the single-user setup this protects. `/dashboard` and the management endpoints under `/ui/api/` return `403`. So does `/api/costs`, which reports spend across every key and session and was previously open — note that is the top-level route, not `/ui/api/costs`, which already required admin. `/ui/api/status` returns counts without backend names, and `/health` returns a backend count and overall health rather than names. Ordinary tool invocation is unchanged, so local MCP clients are unaffected. To restore them on an existing install, set `auth.enabled = true` with a bearer token — and list `/health` and `/mcp` under `auth.public_paths` at the same time: ```yaml auth: enabled: true bearer_token: "" public_paths: ["/health", "/mcp"] ``` The second half is not optional. Turning authentication on gates **every** path, so enabling it alone makes the MCP client you already configured start failing — a worse outcome than the missing dashboard it was meant to fix. `mcp-gateway init` writes this shape for a new install; an upgrade does not rewrite your config, so this is the step to take by hand. The startup log says the same. Without a credential the gateway cannot distinguish its operator from any other caller that reaches the port, so admin now follows an explicit credential. - **The gateway refuses to serve when its tools are reachable without a credential.** Reachable means a non-loopback bind, or a `server.public_url` declaring a name a proxy or tunnel answers to; open means authentication is off, or an entry in `auth.public_paths` covers the `/mcp` tool surface. Public paths are matched by prefix, so `""`, `/`, `/m` and `/mcp` all count, while `/health` and `/metrics` do not. The refusal happens before the listener binds, so such a configuration never opens a port. It names which of the two conditions fired and how to fix that one. Where authentication terminates in front of the gateway — a sidecar, a mesh, a reverse proxy — set `server.allow_unauthenticated_network_bind = true`, which is logged on every start while it remains set. This is a real break for a deployment that binds wide with authentication off. It is the shape a browser or any other caller on the network can drive today, which is why it now stops rather than warns. **The shipped deployment templates were that deployment.** The Helm chart and the Kubernetes base bind `0.0.0.0` — a pod that binds loopback receives nothing — and carried no `auth` section at all, so an unmodified install would now exit at startup instead of serving. Both now require a credential and read it from a Secret: ```yaml # values.yaml auth: existingSecret: mcp-gateway-auth # kubectl create secret generic ... secretKey: token ``` **Upgrading the chart therefore needs that Secret created first.** Without it Kubernetes stops the pod with `CreateContainerConfigError`, naming the Secret and key it could not find — the container never starts, so there are no gateway logs to read. If a service mesh authenticates before anything reaches the pod, set `auth.mode=mesh`. That renders `server.allow_unauthenticated_network_bind` AND removes the credential and its Secret reference; setting the override on its own leaves credential mode active and the pod still demanding a token. **Both templates now also declare `server.public_url`.** On a `0.0.0.0` bind the Host gate admits a NAME only when it is declared, so an install that did not declare one answered the kubelet's numeric probes — staying green — while refusing every caller that dialled the Service DNS name. The chart derives the name from the release and namespace; the raw Kubernetes base carries the default `mcp-gateway` namespace and a comment saying to edit it. **Applying the base into another namespace, or fronting it with an ingress, means editing that one line**, and a refusal is logged with the Host it rejected. The Docker Compose template needed no credential: it publishes to `127.0.0.1:39400` on the host, so the container's `0.0.0.0` is the container's own interface. It now says so with `MCP_GATEWAY_SERVER__ALLOW_UNAUTHENTICATED_NETWORK_BIND`, which stops being true the moment that publish is widened. It is also a break, less obviously, for a reachable gateway whose `auth.public_paths` contains a blank entry — a stray `-` in that YAML list. Because public paths match by prefix, a blank entry is a prefix of every path and makes the whole gateway public, `/mcp` included, whatever `auth.enabled` says. Such a config previously started and read as protected. It now refuses, and the message names the list. Remove the empty entry. - `server.public_url` is re-read on each request, so a configuration reload takes effect without a restart — **unless applying it would leave the tools reachable without a credential**, in which case the reload is refused and no backend is started or stopped and no configuration is published. It does not claim more than that. Reading a config file applies any `env_files` it names to the process environment before the file is validated, so a refused reload is not a complete no-op, and that is true of every failed reload rather than only this one. The refusal is judged against the configuration that would be **in force**, not against the file. `auth` and the override are not applied by a reload — the router snapshots them at startup — so declaring a `public_url` and enabling authentication in one edit does not pass the check. It then says which of two things a restart does with that same file, because they differ. A file that also enables authentication would not be refused for this reason on a restart: set both and restart, and that is the documented fix. A file that only declares the name will *refuse* at the next start, planned or not, so revert it or close the tool paths. Not refused *for this reason* is the whole promise. A restart reads the whole file, so a missing `env:` reference or an unreadable certificate can still stop it — this check answers its own question and no other. - The startup log states what the anonymous identity cannot do and how to restore it, reports when the gateway binds to a non-loopback address while authentication is disabled, and warns on every start while `server.allow_unauthenticated_network_bind` is set — including when authentication is enabled with tool paths left public, which is the shape that escape hatch is most often reached from. ### Fixed - **A backend that was not listening when the gateway started is no longer invisible for the rest of the process.** Warm-start made one attempt, and a backend that missed it kept an empty tool cache forever. Discovery skips a backend with an empty cache and a semantic query never fills one, so `gateway_search` could not see a backend that `gateway_execute` reached perfectly well. Warm-start now retries on a slow cadence, re-resolving the backend by name each attempt so a config reload is respected. - **An empty tool list is no longer believed the first time.** A backend that answered with no tools had that emptiness cached like any other answer, so an earlier bounded retry re-read the same cached emptiness within microseconds and never reached the backend again. The cache entry is now invalidated between attempts, so the retry asks the question it was written to ask. - **A mistyped backend command is reported instead of retried forever.** A spawn failure lost its error kind at the transport boundary, so a command that does not exist looked exactly like a port that was not listening yet — and warm-start respawned it once a minute for the life of the process with nothing saying the configuration was wrong. A missing command and a non-executable file are now permanent failures that stop both retry predicates. HTTP statuses are deliberately left unclassified: this protocol overloads 404 and 400 to mean the session expired, so a status-only classifier would be unsafe. - **stdio mode has a health loop.** It never had one, so a backend that died while the gateway ran in stdio mode stayed dead until restart. The loop is now shared with the HTTP path. Both background tasks — the reaper and the health loop — are aborted through a guard on every exit path; previously the reaper was aborted only on the EOF path, and a dropped task handle detaches rather than stops, so a host cancelling `run_stdio` left both holding the backend registry alive. - **Retry backoff is jittered, as it was already documented to be.** The module described full-jitter backoff and slept the plain exponential, so callers backing off from one failure woke together and hit the recovering service as a single burst — precisely the thundering herd the jitter was named for. - **The Homebrew formula no longer mutates quarantine attributes during install.** It also emits an explicit release version rather than inferring one. - **A config file reached through a symlink now reloads when its target is written.** The watcher followed the path it was given and nothing else, so a deployment that points `gateway.yaml` at a released file and rewrites that file in place produced no matching event, and the gateway kept serving the configuration it started with. The link is resolved on every filesystem event, so repointing it at a new release and editing that file is picked up too, as long as the new target sits in a directory watched at startup — the link's own directory and the directory of the target it pointed at then. A retarget outside those directories is tracked in #453. A plain config file is unaffected: it resolves to itself. ## [3.4.0] - 2026-07-27 ### Added - **`stop_when_idle_for`: release backend processes the gateway started, after they go unused (#392).** The gateway starts backends lazily but never stopped one once started, so every backend touched even once stayed resident for the life of the process. Measured on a six-day-uptime machine: `codex` held 37 MB for 21 hours to do 1.8 seconds of work; `trvl` 67 MB for 11.6 hours to do 7.6 seconds. ```yaml backends: tavily: command: "npx -y tavily-mcp@0.1.4" stop_when_idle_for: 5m ``` Valid **only for a backend the gateway starts itself** — one declared with a `command`. For a backend reached over a URL the gateway can close its client connection, but that does not stop the server on the other end, so the setting is **rejected at config load** rather than silently accepted. Locality does not grant ownership: a local HTTP MCP server on `127.0.0.1` is still not ours to stop. Absent means never stop, so upgrading changes no behaviour. Configurable per backend in the admin panel, which offers the control only where it applies and refuses it with an explanation elsewhere. Adds a third lifecycle state. A backend stopped on purpose is neither healthy nor failed: `Dormant` does not trip or heal a circuit breaker and is not probed by the health loop, so a sleeping backend no longer reports as degraded. A real fault still wins — an open breaker reports `Unhealthy` even for a backend that opted into stopping. In-flight work is refused rather than interrupted: a sweep that finds a request running declines, and the next one retries. ### Changed - **BREAKING for library users: four config-writing functions moved module.** `write_config_and_reload`, `write_config_and_reload_outcome`, `mutate_config_and_reload`, and `ConfigMutation` now live in `config_reload` instead of `config_persistence`. Rust code calling them by the old path stops compiling and needs one import changed; `load_config_or_default` and `write_config` did not move. Nothing changes for anyone running the binary. The two modules referenced each other in a cycle, which no Rust build complains about and which made both harder to reason about. No compatibility re-export is provided, because a re-export from `config_persistence` would recreate the cycle it removes. The break is documented here rather than deferred to 4.0.0 deliberately: two of the four names are new in this release and never shipped, and crates.io lists no reverse dependencies on this crate (`/api/v1/crates/mcp-gateway/reverse_dependencies`, total 0, checked 2026-07-27). Private consumers outside the registry cannot be ruled out. - **`failsafe.retry.max_attempts` now means attempts, not retries.** It was passed straight to `backon`, which counts retries, so every backend made `max_attempts + 1` calls: a configured `3` produced four, and the shipped default of `3` has always meant four. The two user-facing statements of this setting already contradicted each other — `examples/gateway-full.yaml` documented it as "Total attempts (1 original + 2 retries)" while the struct doc said "Maximum retry attempts". The code now matches the example and the name. **Action required if you tuned around the old behaviour:** backends make one fewer call per request than before. Raise `max_attempts` by one to keep the previous number of calls. `max_attempts: 0` clamps to a single attempt rather than none. - **A config-file reload now logs once, on completion, instead of twice.** The file watcher had its own copy of the reload sequence, and that copy logged `Config reload: applying patch` with the change summary before applying and a bare `Config reload: complete` afterwards. The watcher now runs the same reload used by the meta-tool and the admin UI, so a single line is emitted when the reload finishes, carrying the same change summary plus whether a restart is required. **If you grep logs for `applying patch`, that string is gone**; match on `Config reload: complete` and read the `changes` field. A reload that finds nothing to do logs at debug level, as before. - **Helm charts track the 3.4.0 release.** `deploy/helm/mcp-gateway` and `deploy/helm/mcp-gateway-crds` `appVersion` and the default image `tag` move to `3.4.0`; both chart `version`s go to `0.1.2` for republishing. Bumped in the same commit as the crate version rather than at release time, so the repository never describes a 3.4.0 gateway that Helm would install as 3.3.2. ### Removed - **`BackendConfig::idle_timeout` removed** (Rust API only; config files unaffected). The field was parsed, documented in `examples/gateway-full.yaml` as "Hibernate after 5 min idle", and read by exactly one thing in the codebase: a `Debug` formatter. Nothing enforced it. Operators set it in good faith — one real config carried `idle_timeout: 10m` on a stdio backend whose child then ran for over three days and burned 10.4 CPU-hours. Two attempts to implement it were reviewed and rejected. The blocker is not difficulty: the name spans stdio child-process lifetime, per-user session TTL, HTTP connection pooling, and remote scale-to-zero. Closing an HTTP client transport does not scale down the service behind it, so no single implementation can be correct for every transport. **Impact on existing configs: none at load time.** Config parsing tolerates extra keys, so files carrying `idle_timeout` keep loading unchanged. The gateway now logs a warning naming the key so its inertness is visible rather than silent. Note that any command which rewrites the config will drop the key along with surrounding comments, since the writer re-serialises from memory — delete it by hand first if you care about the comments. **Impact on API consumers:** code constructing `BackendConfig` with a struct literal that sets `idle_timeout` will no longer compile. Remove the field. **Why this is a minor bump and not a major one.** Removing a `pub` field is an incompatible change to the Rust library API, and that argues for 4.0.0. Two things argue the other way and win. The field was dead: setting it did nothing, so no behaviour changes for anyone. And the versioned surface of this project is the CLI and the config format, not the Rust library API — the crate ships a binary, the `pub` types exist for modularity and testing, and crates.io reports zero reverse dependencies. That scope is now stated explicitly in the README's **Versioning and stability** section rather than left to be inferred, which is the part that was genuinely missing before. The counter-argument was taken seriously: a policy published in this release cannot retroactively bind the promise made in 3.3.2, and zero reverse dependencies shows nobody HAS broken, not that breaking is permitted. It loses on cost. A major number spent on removing a field that never did anything makes every future major number mean less. Embedders should pin an exact version. The versioned surfaces are unaffected either way: configs carrying the key keep loading, and no command changes behaviour. **A replacement is planned: `stop_when_idle_for` (#392).** It does what `idle_timeout` claimed to do — stop a backend process the gateway started once it has been unused for a given time, and restart it on the next request — but scoped correctly. It is valid only where the gateway owns the process lifecycle (a backend with a `command`), and is rejected at config load for externally managed HTTP endpoints, because closing a client connection does not stop a server the gateway did not start. Local HTTP MCP servers are included in that exclusion: locality does not grant ownership. It also introduces a third health state. A backend stopped on purpose is neither healthy nor failed, and without `Dormant` a sleeping backend trips its own circuit breaker and shows as degraded. ### Fixed - **The deployment guide said the Prometheus endpoint was off by default.** It has been on in a default build, and it is unauthenticated, so an operator following the guide could be exposing `/metrics` without knowing. The feature table now says so and points at the section explaining how to keep it off the public internet. The same section now lists `mcp_backend_idle_stop_close_failures`, the counter `stop_when_idle_for` raises when a backend refuses to shut down, together with the alert rule to watch it and what an operator does when it fires. A backend that will not stop can leave its child process alive, and until now nothing told anyone that had happened. - **Two admin UI config edits at once could lose one of them silently.** Saving a config wrote the file first and took the reload lock afterwards, and every save on Unix used the same temp filename, `.tmp`. Two saves arriving together therefore wrote the same temp file: whichever renamed first shipped the other's bytes while reporting its own edit saved, and the second rename failed with `Failed to replace config file: No such file or directory`. A test that runs eight concurrent saves reproduces it on the first iteration. Temp filenames are now unique per write, and the write happens inside the same lock as the reload, so a save reloads its own bytes. The same symptom had a second cause one layer up: each admin UI save read the whole config, changed one backend, and wrote the whole thing back, and the read happened before the lock. Two saves therefore both started from the pre-edit config, and the second wrote the first person's change out of existence while telling both callers it had saved. Adding, removing, and editing a backend now do the read, the change, the write, and the reload under one lock, so an edit that waits its turn builds on what it waited for. A test queues one edit behind another and fails if the queued edit erases it. Both bugs predate this release; they are fixed here because the reload work is what made the boundary visible. - **Concurrent config reloads could orphan a backend process (#397).** A reload stops a modified backend and then registers its replacement, and registration replaces by name. Nothing serialized reloads, and four paths can trigger one at the same time: the `gateway_reload_config` meta-tool, the admin UI reload button, every admin UI backend edit, and the config-file watcher that fires when the file changes on disk. Two reloads racing could each register a replacement; the second registration discarded the first, and if ordinary traffic had started that first replacement in the gap, its child process was left running with nothing holding a handle to it — the exact leak `stop_when_idle_for` exists to prevent. A reload now holds a lock across the whole transaction — reading the config file, comparing it against the live one, applying the difference, and publishing the result — so the next reload compares against a config that already includes the previous one's work. Holding the lock only around the apply step is not enough: both reloads would have already decided "backend added" against the same stale live config before they queued, and both would still register. - **Duration parsing rejected every `ms` value.** The parser tested the `"s"` suffix before `"ms"`, so `100ms` took the seconds branch and failed to parse `100m` as an integer. Affected every duration field in the config. Values using `ms` failed the config load outright rather than being misread, so no config that previously loaded changes meaning. - **Config writes could park an admin request forever.** A save waited on the reload lock with no bound, and that lock is held across a whole reload: stopping backends, re-registering, republishing. One slow backend shutdown parked every settings-panel edit indefinitely, with retries queueing behind it. A write that is *waiting* for the lock now gives up after five seconds and answers 503, which is a refusal the caller can retry rather than a request that never returns. The bound covers the wait only. Once a write wins the lock it still runs the reload to completion, so the one edit that triggers a slow backend shutdown can still take as long as that shutdown takes; what no longer happens is every other edit queueing behind it forever. Reloads stay unbounded on purpose: refusing one would silently drop a change already written to disk, and a refused write changed nothing. - **The scratch file a config save writes through could be silently reused.** The name came from a counter private to one process, and it was opened in a mode that truncates whatever is already there. A second gateway process, a leftover from a crashed run, or a wrapped counter put two writers on one file. The save now claims the file exclusively and moves to the next name rather than truncating a file someone else holds. It never deletes a scratch file it did not create, since a live writer may own it. - **On Windows the config was written in place, so a crash mid-write truncated it.** Every other platform wrote to a scratch file and renamed it into place, which is atomic; Windows took a separate branch that did not, and no test reached it. Both branches are gone, replaced by one path every platform takes, with a bounded retry for the sharing violations Windows raises when another handle is momentarily open. The remaining Windows-specific piece, classifying OS error 32 as transient, is unverified on Windows itself. - **A config write could block a runtime worker thread.** The rename retry above slept between attempts, in a synchronous function reached from an async task that holds the reload lock, lengthening the exact wait the five-second bound exists to cap. It also retried on permission errors, which are permanent on Unix, paying every sleep before failing anyway. Retries are immediate now. - **The alert rule this release documents is now tested, offline.** Prometheus' own `promtool` checks it on four cases: silent when the counter is flat, firing after a single increment, resolving once that increment ages out of the window, and firing per backend rather than across all of them. The rules and their tests live in `deploy/prometheus/`. A test also covers the export half of the counter's path, that the recorder carries this metric name and its `backend` label through to scrape output. It issues the counter directly rather than driving a real failing shutdown, so it pairs with the existing pool tests covering the increment itself; neither half was observed before. ## [3.3.2] - 2026-07-15 ### Fixed - **OAuth: send the RFC 8707 resource indicator on every OAuth request (#369).** The MCP authorization spec (rev 2025-06-18) requires clients to include the `resource` parameter on the authorization request and on every token request so the authorization server can audience-bind the issued token to the target MCP server. The gateway discovered the protected-resource metadata but never sent `resource` back, so spec-strict providers (e.g. kapa.ai) rejected the login flow with `server_error`. The discovered resource identifier (falling back to the configured MCP endpoint URL when a server publishes no protected-resource metadata) is now threaded into all four OAuth requests: the authorization URL, the `authorization_code` exchange, `refresh_token`, and `client_credentials`. Reported by @crepererum. ### Changed - **Helm charts track the gateway release.** `deploy/helm/mcp-gateway` and `deploy/helm/mcp-gateway-crds` `appVersion` and the default image `tag` now point at `3.3.2` (were stale at `2.19.0`); both chart `version`s bumped to `0.1.1`. ## [3.2.1] - 2026-07-07 ### Changed - **Internal refactor (MIK-6863).** Split the 3038-line `src/backend/mod.rs` into focused submodules (`pool`, `lifecycle`, `ops`, `metadata`, `cached_metadata`, `registry`, `annotations`, plus `tests`/`pool_tests`) so no file exceeds the 800-line hygiene cap. Pure move refactor: no behavioural or public-API change, identical test coverage. ## [3.2.0] - 2026-07-07 ### Added - **Per-user transport/session pool (MIK-6735).** Each caller identity now gets its own isolated upstream MCP transport slot instead of sharing a single connection. One identity's reconnect or failure no longer disrupts others on the same backend. Pool keys are `Shared | PerUser{binding}`; the `Shared` path is byte-identical to prior single-transport behaviour for backends that do not opt into per-user isolation. - **Per-slot failsafe isolation.** The circuit breaker, rate limiter, and health tracker now live on each pool slot rather than a single backend-wide instance. A per-user slot tripping its circuit breaker Open can no longer reject another identity's healthy slot. - **Identity-aware notifications.** `notify_with_headers()` routes client notifications to the caller's own pool slot. Notifications resolve the caller's session-bucket binding only and stay within the caller's identity (fire-and-forget, no response body, no cross-tenant path). - **Pool telemetry.** `mcp_backend_pool_slots` gauge plus debug slot-count logging on slot creation and idle eviction. ### Changed - Idle per-user pool slots are evicted after a TTL (300s, 60s sweep) to bound resource growth under many distinct identities. - Config now accepts per-user isolation settings (previously rejected), retaining the http-required and oauth-conflict fail-closed validation. ## [3.1.3] - 2026-07-06 ### Security Hardening from a pre-release security audit. Four independent findings, no externally observed exploitation. - **Bearer token and API key comparison is now constant-time (CWE-208).** The auth layer compared the presented credential against configured secrets with `==`, whose early-exit on the first differing byte leaks match-prefix length through timing. Comparison now uses `subtle::ConstantTimeEq`, matching the existing constant-time path in the key server. Accepted and rejected credentials are indistinguishable by timing. - **`tools/list` responses are now scanned by the firewall (OWASP ASI01 tool-poisoning).** Backend-supplied tool `description`/metadata strings reached the client without passing through the response scanner that already guards the `tools/call` path, so a malicious backend could smuggle prompt injection or embedded credentials in tool metadata. Both the direct `tools/list` route and the aggregated discovery surface (`gateway_list_tools` / `gateway_search_tools`) now run the same `Firewall::check_response` scan-and-redact pass. The check is a no-op when the firewall feature or response scanning is disabled, so behavior is unchanged when unconfigured. - **`TokenInfo` no longer leaks OAuth secrets through its `Debug` output.** The derived `Debug` impl printed `access_token`, `refresh_token`, and `client_secret` verbatim, so any log line or panic message that formatted a `TokenInfo` exposed live credentials. `Debug` is now a manual impl that redacts the three secret fields while preserving the non-sensitive fields for diagnostics. - **`OpenApiConverter::convert_url` now validates its target against the SSRF deny list before fetching.** Converting an OpenAPI spec from a URL fetched the address with no private/reserved/loopback check, while every other capability fetch path (jsonrpc, graphql, executor, discovery, transport) already gated on `validate_url_not_ssrf`. A spec URL supplied on the CLI is untrusted input, so the converter now runs the same guard unconditionally, rejecting link-local, private, and loopback targets before any request. Beyond the literal pre-check, both `convert_url` and the OpenAPI import HTTP endpoint (`POST /ui/api/import/openapi`, admin-only, `fetch_spec`) now build their reqwest client with `PinningResolver` (validates every resolved IP, closing the DNS-rebinding TOCTOU window) and a redirect policy that re-validates each hop and stops at >=5 redirects, so a hostname that resolves to — or 3xx-redirects into — an internal address is rejected. On the UI endpoint these guards stay gated on the operator's `ssrf_protection` flag. ## [3.1.2] - 2026-07-06 ### Security - **Passthrough upstream session id is now partitioned per caller identity (MIK-6785).** This closes the second code path of the session-sharing class fixed for the minting path in 3.1.1 (MIK-6784). In Passthrough mode the direct backend route left the caller identity key unset, so every passthrough caller shared the empty-key default upstream-session bucket. Against a stateful upstream that binds data to the `MCP-Session-Id` rather than the bearer token, one passthrough caller could be served another caller's session-bound data. The forwarded backend credential is now hashed at its single read point via SHA-256 into a stable, collision-safe per-caller bucket key; the raw token is never logged or stored except as that one-way in-memory session-map key. Each distinct passthrough credential selects its own session bucket, the same credential reuses its bucket, and the no-credential non-required path keeps the shared default bucket, so single-tenant behavior is unchanged. With this the entire upstream session-sharing class is closed across both code paths and no follow-up remains. ## [3.1.1] - 2026-07-06 ### Security - **Upstream MCP session id is now partitioned per caller identity (MIK-6784).** One `HttpTransport` instance is shared across all gateway users for a given backend, and the upstream `MCP-Session-Id` was held in a single shared slot. It was written from the first caller's response and then attached to every other caller's outbound request. Against a stateful upstream that binds data to the session id rather than the bearer token, one user's session-bound data could be served to another. Session state is now keyed by the caller's stable identity binding and never stored on shared transport state; the empty-key default bucket keeps single-tenant behavior unchanged. Session expiry evicts only the affected caller, and transport close terminates every per-identity session. Also folded in: a startup warning when single-user mode coexists with an OAuth-enabled backend, and config-load rejection of an enabled OIDC provider that has an empty audience list. Passthrough mode still uses the shared default bucket (the trusted-internal path the audit scoped out) and is tracked as a follow-up. ### Fixed - **stdio serve boots without a mounted config (Glama build).** An explicit `--config ` that does not exist was a hard error, so `mcp-gateway serve --stdio --config /config.yaml` exited non-zero when the file was absent — the exact shape of Glama's container build smoke-test, which passes that placeholder while the generated Dockerfile never writes the file. The stdio serve path now downgrades a missing explicit `--config` to the normal no-config resolution (fallback locations, env vars, then defaults) with a stderr warning. Scoped to stdio only: the HTTP server path stays fail-loud so a missing intended config can never silently start with authentication disabled. ## [3.1.0] - 2026-07-05 ### Added - **RFC 8693 token-exchange identity propagation** (MIK-6729). A backend can opt in per backend with `strategy: token_exchange`. The gateway exchanges a gateway-signed subject assertion for a scoped downstream token at the backend's token-exchange endpoint, then injects that token on the call. The gateway stores no credential. Exchanged tokens live in process memory keyed by subject and audience with per-entry expiry, and the client assertion is minted fresh for each call. The strategy stays dormant until a backend opts in. ### Fixed - **`/auth/token` now uses standard OAuth form-encoding.** The key server's `POST /auth/token` endpoint accepted a JSON request body. The key server is disabled by default, and this endpoint is opt-in; even so, the JSON body did not match RFC 8693 / RFC 6749 §4.1.3, which specify `application/x-www-form-urlencoded`. Off-the-shelf OAuth clients already send form-encoded requests, so this aligns a dormant, opt-in endpoint with the standard. A JSON body now gets HTTP 415 Unsupported Media Type. ## [3.0.2] - 2026-07-05 ### Fixed - **Fail closed when identity propagation is required but the transport cannot carry it.** A backend configured with `identity_propagation.required` on a non-HTTP transport (stdio/websocket) previously minted and audited an identity token that the transport then silently dropped, letting the call proceed unauthenticated under the shared gateway static credential. Added `Transport::carries_identity_headers` (default false, HTTP overrides true) and the dispatch path now refuses before minting when propagation is required and the transport cannot carry it. - **Bounded transparency-log reads to prevent memory exhaustion (MIK-6710).** `log_contains_signed_entry`, `verify_log_inner`, and `show_session_entries` loaded the entire audit log into memory with no size bound, so an unbounded or attacker-grown transparency log could exhaust gateway memory on every audit verify or show call. Reads are now capped at 256 MiB and fail closed instead of allocating an oversized buffer. Crash recovery (`recover_chain_state`, run on every logger open) no longer reads the whole file either; it now scans backward from the last 4 MiB to find the final complete line, so recovery time no longer scales with total log size. Hash-chain verification logic is unchanged. - **Pinned the admin-UI htmx CDN script with Subresource Integrity.** The bundled web UI loaded htmx from unpkg with no integrity attribute, so a compromised CDN could serve altered JavaScript. The script tag now carries a SHA-384 SRI hash and `crossorigin=anonymous`. The web UI is opt-in and normally localhost-bound, so severity is low. ## [3.0.1] - 2026-07-03 Security hardening fast-follow for the 3.0.0 end-user identity-propagation feature. No API changes; both items close latent gaps on the per-user credential path. ### Security - **Fail-closed provider adapter (MIK-6741).** `McpProvider::invoke` now refuses to dispatch a backend configured with `identity_propagation.required`, because the `Provider` trait carries no per-user identity and would otherwise fall back to the shared gateway session. No live route reaches this adapter today; the guard is a tripwire so a future wiring cannot become a silent identity bypass. (`src/provider/mcp_provider.rs`) - **Tamper-evident credential-propagation audit (MIK-6740).** The direct `/mcp/{name}` route now writes redacted `idp_mint` / `idp_refuse` events to the transparency log at the mint and refuse decision points. Entries carry only subject / backend / audience / reason — never token or assertion bytes (enforced structurally and by a canary-secret regression test). Audit writes are best-effort and never fail the user request; the mint/refuse decision itself remains fail-closed. (`src/gateway/router/backend_handlers.rs`) > **Breaking change.** The default OAuth posture changes: a gateway with > `auth.enabled: true` no longer serves one stored backend token to every > caller (see `src/oauth/storage.rs`). See "Added" and "Security" below, > and `docs/UPGRADING-3.0.md` for the upgrade path. ### Added - **Per-user OAuth isolation as the fail-closed default** (ADR-008, MIK-6742; see `docs/adr/ADR-008-multi-user-oauth-isolation.md`). On a multi-user gateway, a backend that requires a per-user OAuth identity now refuses a call that lacks a verified end-user identity instead of falling back to a shared stored token. Two invariants are enforced end to end: - **INV-1**: a per-user backend never falls back to a shared or other-principal token. Required-and-unresolved always refuses. - **INV-2**: a multi-user gateway never serves a gateway-held OAuth token to an arbitrary caller, unless the operator explicitly opts a backend into `oauth.shared_account: true` (logged). Multi-user detection is itself fail-closed: auth enabled implies multi-user unless the operator declares `auth.single_user: true`; more than one API key or any OIDC issuer overrides that declaration. - Coverage spans both call paths that resolve an OAuth token: MCP backends (`MetaMcp::enforce_oauth_isolation`) and capability-backed REST connectors (`validate_oauth_isolation` in `src/capability/execution_context.rs`). (#316, #317) - **RFC 9728 protected-resource metadata + refresh-task lifecycle** (MIK-6750; #316). The gateway now advertises per-backend OAuth requirements so a capable MCP client can run its own browser-based login and attach its own token per request instead of relying on a gateway-held credential. OAuth refresh tasks are scoped to transport lifetime and are aborted when a transport is discarded, closing an orphaned-task leak. - **Client-supplied OAuth passthrough** (MIK-6746). A caller can attach its own backend credential on the request; the gateway forwards it without storing a copy, covering the direct backend route (`/mcp/{name}`) as well as meta-MCP dispatch. - **v3.0.0 upgrade posture-notice migration** (#318, MIK-6742). Read-only: on first startup after upgrading, the gateway backs up the existing `gateway.yaml` to `gateway.yaml.bak.`, detects whether the deployment declares a multi-user posture, and prints a one-time notice. No config field is changed automatically. See `docs/UPGRADING-3.0.md`. - **End-user identity propagation to backend MCP servers** (MIK-6704 epic; ADR-007 framework-first). The gateway can mint a per-user credential for a backend configured with `identity_propagation` and attach it on the wire, instead of forwarding only a shared static credential. A strategy-agnostic async `IdentityPropagation` trait with a first-party `SignedAssertionStrategy` (ES256 assertions signed by the gateway key) is wired across every invocation surface: meta-MCP dispatch (`gateway_invoke`), Code Mode (`gateway_execute`, single + chain), and the direct backend route (`/mcp/{name}`). - **Fail-closed (IDP.2):** a `required` backend refuses the call (never a static-credential fallback) when there is no verified identity, no strategy wired, minting fails, or a minted header does not parse. Non-HTTP transports (stdio/websocket) cannot carry the credential header and are rejected at config load. #317 extends this fail-closed behavior to every direct-route method. - **Tenant isolation (IDP.3):** per-request credential headers are passed by value and never stored on the shared transport. - **Identity-aware caching (IDP.8):** a collision-safe `cache_binding` (user + audience) is mixed into the response and idempotency cache keys, so per-user results cache in isolation rather than leaking across users or being dropped. - Deferred as tracked follow-ups (not in this release): additional strategies token-exchange (MIK-6729) and vault (MIK-6730), per-user transport/session pooling (MIK-6735, `session_mode: per_user` stays fail-closed until then), transparency-log audit of propagation events (MIK-6740), and `McpProvider` adapter hardening (MIK-6741). ### Changed - **Major version bump to 3.0.0** ("Trust Fabric"): per-user OAuth isolation (ADR-008), identity propagation (ADR-007), transparency-log per-entry HMAC verification (MIK-6700), control-plane read-reflects-store (MIK-6701), collision-safe role-mapping hot-reload (MIK-6702), and server-run SIEM evidence export (MIK-6703). - **Default OAuth posture on auth-enabled gateways.** Previously, any gateway with `auth.enabled: true` served each backend's stored OAuth token to every authenticated caller. Now a backend requiring per-user identity refuses calls lacking one. Existing `gateway.yaml` files load unchanged; the upgrade migration only backs up the file and prints a notice (see Added, above). To keep the previous shared-credential behavior, add one line: `auth.single_user: true` for a personal gateway, or `oauth.shared_account: true` under a specific backend for an intentionally shared service account. ### Security - **Cross-user OAuth token exposure closed** (ADR-008, MIK-6742, MIK-6751, MIK-6752). Before this release, a shared/multi-user gateway stored one OAuth token per `(backend, resource)` and attached it caller-agnostically, so one user's call to a personal-OAuth backend (for example Gmail) could be served with another user's login. This predates 3.0.0 and is closed by the INV-1/INV-2 guards described above. - Bump `cmov` 0.5.3 → 0.5.4 (GHSA-3rjw-m598-pq24). Bump `quick-xml` 0.40 → 0.41 (RUSTSEC-2026-0194/0195, MIK-6731). ## [2.19.0] - 2026-06-08 ### Added - **Capability response projection applied at dispatch** (MIK-3534, closes the MIK-3530 epic): a capability may now declare a canonical `projection: ProjectionSpec`, and `gateway_invoke` applies it to the dispatched response — mapping backend fields onto the canonical schema (`actor`/`subject`/`env_time`/`url`/`body`) while preserving the untouched payload under `_raw`. The descriptor field added in MIK-3531 (`Tool.projection`) is now surfaced from the capability via `to_mcp_tool`, and the projection engine from MIK-3533 is now wired into the live path. Projection is applied **last** — after `response_transform` and output-schema validation — so it is leak-safe by construction: `_raw` is built from the already-redacted, already-validated payload, so a field redacted by `response_transform` can never reappear. It rides the same `_full` opt-out as `response_transform` (so the response cache and idempotency layers inherit correctness), operates on the inner capability payload rather than the MCP envelope (avoiding bug #167), never projects error envelopes, and passes the response through untouched when the spec resolves no fields (fail-fast). Internal orchestration (chain / playbook steps) requests the unprojected payload so step-output interpolation is unaffected. Covered by tests for the redaction-leak guard, `_full` bypass, text-envelope fail-fast, error-envelope skip, and inner-payload targeting. ## [2.18.0] - 2026-06-08 ### Added - **Projection engine** (MIK-3533): `projection::project(response, spec)` maps a backend response onto the canonical schema (`Actor`/`Subject`/`EnvTime`/`Url`/`Body`) using a `ProjectionSpec`'s per-field dotted source paths (e.g. `assignee.email` → `Actor.email`). The original payload is always preserved under `_raw`. **Fail-fast:** if a spec resolves no canonical fields against a response, the original response is returned unchanged rather than an empty projection — a projection never silently drops data. Pure, no behavior change yet (not wired into the dispatch path; that is the per-backend-mappings step, MIK-3534). Covered by tests for leaf projection, fail-fast, empty spec, partial resolution, and scalar stringification. ## [2.17.0] - 2026-06-08 ### Added - **`list_tools` role filter** (MIK-3532): `gateway_list_tools` accepts an optional `role` argument (`selector` / `extractor` / `enricher` / `action`) and returns only tools of that role, in both the single-backend and aggregate paths. A tool's effective role is its explicit `role` tag (MIK-3531) if present, otherwise inferred conservatively from its name + `readOnlyHint` (read-only `search`/`list`/… → selector, `get`/`read`/… → extractor, everything else → action — the safe default). So the filter is useful immediately without every tool being hand-tagged. An invalid `role` value is rejected (fail-fast) rather than silently returning everything. The `gateway_list_tools` meta-tool is itself tagged `selector`. Covered by unit tests for inference, explicit-tag precedence, matching, and argument parsing. ## [2.16.0] - 2026-06-08 ### Added - **Tool descriptor carries `role` + `projection`** (MIK-3531, completes the foundation): the MCP `Tool` descriptor now has two optional fields — `role` (`selector`/`extractor`/`enricher`/`action`) and `projection` (a `ProjectionSpec`). Both default to `None` and are omitted from the wire for untagged tools, so existing payloads serialize and deserialize byte-for-byte as before. This is the descriptor surface the projection epic consumes: `list_tools` role filtering (MIK-3532) and response projection (MIK-3533/3534) build on it. No behavior change. Serialization-contract tests assert untagged tools omit the fields and pre-existing JSON still parses, and that tagged tools round-trip role + projection. ## [2.15.1] - 2026-06-08 ### Fixed - **Stdio-mode warm-start never prefetched backend tools** (MIK-4649): in `serve --stdio` mode (how Claude Code / Codex connect), warm-start started each backend but **skipped tool prefetch** — that step was gated on HTTP mode only. Subprocess MCP backends (e.g. `codex`) were therefore left with an empty tool cache, and since discovery skips empty-cache backends, their tools never appeared in `gateway_search` / `tools/list`. Tool prefetch now runs in both transport modes. Root-caused empirically against the live gateway: `codex mcp-server` serves `tools/list` fine, but the gateway logged only pings for it and never a tool fetch. Regression test asserts prefetch occurs in both modes. ## [2.15.0] - 2026-06-08 ### Added - **Projection layer foundation** (MIK-3531, part of the MIK-3530 epic): new `src/projection/` module with the canonical projection vocabulary — `Actor`, `Subject`, `EnvTime`, `Url`, `Body`, a `Projected` wrapper that always preserves the untouched backend payload under `_raw`, a `ProjectionSpec` (declarative canonical-field → source-path mapping), and a `Role` enum (`selector` / `extractor` / `enricher` / `action`, defaulting to `action`). Types and serialization contract only — no behavior change. Subsequent PRs wire `role`/`projection` onto the tool descriptor and add the projection logic that consumes a `ProjectionSpec` (MIK-3532 / MIK-3533 / MIK-3534). Covered by round-trip serialization tests. ## [2.14.0] - 2026-06-08 ### Added - **Response-projection safety: `_full` opt-out + fail-fast warning** (MIK-3533): the gateway already supports per-capability `response_transform.project` (trimming proxied responses to listed fields). This release makes it safe to rely on: - **`_full: true`** — passing `_full: true` in a tool call's arguments bypasses response projection and returns the unprojected payload. The flag is a gateway directive: it is stripped before the request reaches any backend, and a `_full` call bypasses the response cache and idempotency replay so its (unprojected) result can never be served to, or replayed from, a normal projected call. - **Fail-fast warning** — if a projection would empty a previously-populated payload (e.g. the spec names fields absent from this particular response), the gateway logs a warning and still applies the projection. It does **not** fall back to the full response, because `project` can be a privacy/allowlist boundary and a fallback would risk leaking the dropped fields; callers who want the full payload pass `_full: true`. Covered by deterministic tests (`json_is_populated` truth table, projection-to-absent-field empties payload, healthy-projection passthrough). ## [2.13.0] - 2026-06-08 ### Added - **Retry transient outbound transport errors with backoff** (MIK-5081): capability calls run inside the gateway's own tokio runtime, so a momentary connect/timeout failure reaching an upstream (e.g. `api.linear.app` under host load) previously surfaced straight to the caller as a `BACKEND_ERROR`. Outbound REST, GraphQL, and JSON-RPC requests now retry transient connection/timeout failures up to 3 attempts with exponential backoff (100ms, 200ms). HTTP error *statuses* (4xx/5xx) are not retried. Covered by a deterministic regression test. - **`/health` reflects real backend health, not just the circuit breaker** (MIK-5080): `/health` derived overall health solely from circuit-breaker state, so a backend timing out under load reported healthy until the breaker tripped Open. Registry `BackendStatus` now carries `healthy`, `consecutive_failures`, and `latency_p95_ms` from the health tracker, and the **in-process capability backend** (previously absent from `/health` entirely, and the source of the original incident) now has its own health tracker: it records every capability execution outcome and is folded into overall health and exposed as a `capability_backend` field in the admin `/health` payload. Overall health now requires a non-Open circuit *and* live health trackers across both registry and capability backends. ## [2.12.2] - 2026-06-08 ### Fixed - **stdio logs corrupted the JSON-RPC stream** (#224): `serve --stdio` wrote tracing output to stdout, interleaving log lines (with ANSI escapes) among the newline-delimited JSON-RPC frames and breaking MCP clients such as Claude Desktop. `setup_tracing` now writes all log output to stderr regardless of mode, so stdout carries protocol only. Added a regression test that spawns `serve --stdio` and asserts every stdout line is valid JSON. Thanks to @robn for the report and the `strace` diagnosis. - **`tool list` hard-failed on a missing capability directory** (#225): the command errored with "Capabilities directory does not exist" when its local capability-YAML directory was absent, and its naming implied it reflected the running gateway's config (it does not — it ignores `-c gateway.yaml` and `capabilities.enabled`). `tool list` now degrades gracefully (empty catalogue, exit 0) with a one-line note clarifying it scans a local catalogue independent of server config; the `--help` text and command description were corrected to match. Thanks to @robn for the report. ## [2.12.1] - 2026-05-25 ### Fixed - **`linear_get_issue` cache TTL** (#205): set TTL to 0 to fix claim-protocol read-after-write failures where `verify_claim` returned `Missing` immediately after `linear_create_comment` due to stale cached issue JSON. - **Capability hot-reload watcher race at startup** (#188): watcher exited early with "No capability directories to watch" because it read `backend.watched_directories()` before the async loader had registered paths. Fix: synchronous `register_directories` call before the async loader spawns, so the watcher always sees populated paths from boot. - **Capability count drift** (#199): synced `capability_count` 112→113 across `benchmarks/public_claims.json`, `capabilities/README.md`, `docs/COMMUNITY_REGISTRY.md`, and `docs/BENCHMARKS.md`; restores `public_claims_validation` test suite to 7/7. ### CI / Build - **npm trusted publishing + provenance attestation** (#203): replaced `NPM_TOKEN` secret with OIDC trusted publishing; npm packages now carry provenance attestation. - **Automated package + formula publish** (#201): release workflow now publishes the npm package and Homebrew formula automatically on tag push. - **Dropped NPM_TOKEN fallback** (#202): removed the legacy secret fallback after trusted publishing was confirmed live. - **Least-privilege CI workflow permissions** (#214): top-level `permissions: contents: read` added to `ci.yml`, resolving CodeQL `actions/missing-workflow-permissions` findings. - **Dependabot bumps**: `serde_json` 1.0.149→1.0.150, `jsonwebtoken` 10.3.0→10.4.0, `tower-http` 0.6.10→0.6.11, `rcgen` 0.14.7→0.14.8; CI actions: `docker/build-push-action` 7.1→7.2, `docker/setup-buildx-action` 4.0→4.1, `actions/setup-node` 5.0→6.4, `docker/metadata-action` 6.0→6.1, `trufflesecurity/trufflehog`. ### Docs - **README "vs Anthropic MCP Tunnels" section** (#204, MIK-4696): added comparison section for users evaluating the official Anthropic tunneling offering. ## [2.12.0] - 2026-05-20 ### Added - **OAuth cancellation-survival** (MIK-4486): the interactive browser handshake for OAuth-enabled backends now runs on a detached `tokio::spawn` task. When the calling MCP request future is cancelled (e.g. the client times out before the user finishes browser auth), the OAuth task continues to completion and persists the token to `~/.mcp-gateway/oauth/`. The next call from the client finds the cached token and skips re-authorization. Previously, request cancellation killed the callback server and any browser auth that landed afterwards went nowhere. Docs: `docs/OAUTH_CONFIG.md § First-time interactive authorization`. - **OAuth discovery progress at INFO level** (MIK-4486): `Discovering …` and `Discovered …` log lines in `src/oauth/metadata.rs` promoted from DEBUG to INFO so operators can see the full handshake progression in the default log without flipping `RUST_LOG`. - **OAuth cancellation-survival regression test** (MIK-4486): new `tests/oauth_cancellation.rs` pins the `tokio::spawn`-survives-outer-drop semantics the fix relies on. - **Windows x86_64 release artifact**: release workflow now builds and publishes `mcp-gateway-windows-x86_64.exe` for `x86_64-pc-windows-msvc`. - **MCP tool annotation policy** (MIK-2985): ADR-003 documents the hybrid pass-through/fill policy; gateway meta-tools now carry annotation titles plus all four MCP 2025-11-25 behavior hints. ### CI / Build - **Windows compile coverage in CI**: `ci.yml` now runs `cargo check --all-features` on `windows-latest` in addition to the existing Linux checks. ### Docs - **README Windows install path**: install table and direct-download section now include the Windows MSVC release binary. - **Always-load pin-set guidance** (MIK-3639): client-side pin-set rationale for Claude Code v2.1.121 `alwaysLoad` flag — recommends a small set of hot-path always-loaded MCP servers with rollback procedure. ### Tests - **Windows path regressions**: added coverage proving Windows-style path separators do not bypass tool-poisoning detection and do not destabilize capability hash pinning. ### Fixed - **Proxy-time SSRF check trusts configured backends** (MIK-3529): the guest-side SSRF re-check in `authorize_destination` no longer rejects operator-declared backend URLs that resolve to loopback or private IP ranges. A new `security.trust_configured_backends` flag (default `true`) exempts URLs matching a configured backend host from the proxy-time guard; set to `false` to restore strict re-checking. Dynamic/unconfigured destinations continue to be SSRF-validated. Resolves the regression where the gateway blocked traffic to its own configured `127.0.0.1` MCP backends. ## [2.11.0] - 2026-04-25 ### Changed - **Dual licensing introduced** (Path C, MIK-3034 / MIK-3036): designated Enterprise Edition modules are now licensed under PolyForm Noncommercial 1.0.0; everything else remains MIT. See [LICENSE-EE.md](LICENSE-EE.md) and the License section of the README for the full file list. - Every EE-designated source file now carries an `// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0` header. - Releases prior to v2.11.0 remain entirely MIT and stay MIT forever; the new license terms apply only to commits in v2.11.0 and later that touch EE-designated paths. ### Added - **Output schema enforcement** in `MetaMcp::invoke`: tool results are validated against the capability's declared output schema for both meta-MCP and backend-routed dispatch paths. Non-conforming results return an LLM-readable "Tool result validation failed" error so agents can self-repair. ## [2.10.0] - 2026-04-16 ### Security - **Destructive confirmation gate** (OWASP ASI09): Meta-tools annotated as destructive now require explicit user confirmation before execution, preventing unintended data loss from autonomous agents. - **HMAC-SHA256 message signing** (OWASP ASI07, ADR-001): Inter-agent messages carry HMAC-SHA256 signatures with nonce-based replay protection, ensuring message integrity and authenticity across the gateway mesh. - **Anomaly blocking gate** (OWASP ASI10): Anomaly detector promoted from warn-only to active blocking — anomalous tool invocation patterns are now rejected, not just logged. - **Response content inspection**: Outbound responses scanned for credential exfiltration patterns (API keys, tokens, secrets) before reaching the AI client. - **Tool poisoning validator**: Hash-pinned capability definitions detect tampering in OpenAPI-imported tool schemas. ### Added - **A2A transport adapter — Phase 1**: Google Agent2Agent (A2A) protocol support with types, client, translator, and provider. Proxy A2A agents as native MCP backends. Feature-gated behind `a2a` (included in defaults). - **Upgrade command** (`mcp-gateway upgrade`): Version-stamp tracking, what's-new registry with arrow-style output, config backup before migrations, and post-upgrade migration framework. - **`gateway_reload_capabilities` meta-tool**: Agent-callable hot-reload of capability definitions without gateway restart. - **FSM state-gated tool visibility** (#113): Finite state machine controls which tools are surfaced based on session state, enabling multi-step workflows where tools appear/disappear as the conversation progresses. - **Structured self-healing error responses** (#115): Tool invocation errors now include structured recovery hints (retry, fallback tool, parameter correction) for autonomous agent self-repair. - **Response transforms wired into `gateway_invoke`** (#118): Per-capability field projection and PII redaction now applied inline during tool invocation, not just in playbooks. - **Universal protocol adapters**: GraphQL and JSON-RPC 2.0 adapters join HTTP REST — backends speaking any of the 3 protocols are proxied transparently. - **SKILL.md / agentskills.io compatibility** (#114): Parser, registry, and CLI for the emerging agent skills specification. - **Multi-platform MCP guides**: Prompts and annotations tailored for Claude, GPT, Gemini, and other LLM clients. - **Trawl web extraction capability**: Structured web content extraction as a built-in capability. - **8 knowledge capabilities**: Gzip/deflate/brotli compression added to reqwest; 8 new knowledge-domain capabilities bundled. - **OAuth refresh improvements**: `client_id`/`client_secret` sent on token refresh; Google capabilities migrated to new auth flow. - **Kani formal verification proofs**: State machine and kill-switch budget decision correctness proved with Kani. ### Changed - **Unified error handling**: Router, SSE, WebUI, webhook, and middleware error responses consolidated into shared HTTP error builders with consistent JSON-RPC error codes. - **Config runtime contract**: Reload outcomes now distinguish restart-required vs. hot-reloadable changes; restart-required outcomes exposed to callers. - **Backend metadata cache**: Coalesced cache refreshes with shared snapshots reduce redundant backend queries. - **Meta-MCP prompt cache**: Isolated into dedicated module for testability. - **Prometheus metrics hardened**: Install and export logic made more robust. ### Fixed - Missing `KeyInit` import for HMAC message signing. - Clippy `doc_markdown` warnings in invoke.rs. - Skills parser doc comment incorrectly compiled as doctest. - Stale "4 meta-tools" claims removed from all public surfaces. - JSON-RPC response serialization and contract hardening. - Stdio request parsing alignment and pending-write clearing on failure. - Backend notification routing via `notify`. - Provider tool content preservation. - Transform chain error context propagation. - HTTP close header contract alignment. - Public capability count claims updated (93 to 101). ### Docs - **OWASP Agentic AI compliance matrix**: 8/10 Top 10 items covered, with per-item status and mitigation references. - **ADR-001**: Inter-agent message signing design (OWASP ASI07). - **ADR-002**: A2A transport adapter design. - **AP2/Galileo evaluation**: Independent agent protocol evaluation results. - **README**: Agent-first install flow, OWASP 8/10 badge, independent review links (Ruach Tov), VS Code / Cursor one-click install badges, tool count corrections. - **CODEOWNERS** added. ### CI / Build - TruffleHog secrets scanning job. - Workflow action SHAs pinned. - Release workflow lint fix and pre-publish gate. - Published crate contents curated (`include` list). - Dependabot automation added. - Smithery manifest added. ### Tests - Firewall action resolution proof. - Kill-switch budget decision proof. - Kani state machine proofs. - Meta-MCP tool-count assertions updated for `gateway_reload_capabilities`. - README startup claim guards. ## [2.9.1] - 2026-03-24 ### Changed - **refactor: extract `build_meta_mcp` helper** — ~110 lines of duplicated Meta-MCP construction logic consolidated into a single reusable function. ### Fixed - **Notion capability `database_id` parent type** — `notion_create_page.yaml` now correctly supports `database_id` as a parent type in addition to `page_id`. ### Dependencies - **tokio-tungstenite** bumped to 0.29.0. ### Tests - **6 new stdio edge-case tests** — covers malformed JSON, empty lines, oversized payloads, concurrent requests, graceful shutdown, and partial reads (2576 total). ## [2.9.0] - 2026-03-24 ### Added - **Native stdio transport** (`mcp-gateway serve --stdio`): gateway now reads newline-delimited JSON-RPC from stdin and writes responses to stdout, enabling direct use as a Claude Code / MCP stdio subprocess without a bridge script. Supports all MCP methods (`initialize`, `tools/list`, `tools/call`, `prompts/*`, `resources/*`, `logging/setLevel`, `ping`) and batch requests. Reuses the same `MetaMcp` dispatch logic as the HTTP server. - **5 new capability YAML files**: - `capabilities/productivity/notion_create_page.yaml` — create a Notion page under any parent page or database - `capabilities/finance/stripe_create_payment_intent.yaml` — create a Stripe PaymentIntent (modern payments API) - `capabilities/developer/github_create_issue.yaml` — create a GitHub issue with labels, assignees, and milestone - **`capabilities/developer/` directory** — new top-level category for developer-tool capabilities ## [2.7.3] - 2026-03-16 ### Added - **WebUI: Cost tracking dashboard** — new "Costs" tab at `/ui#costs` showing aggregate spend, per-key and per-session breakdowns with stat cards and tables. Backed by `GET /ui/api/costs` endpoint (admin-only, feature-gated behind `cost-governance`). ## [2.7.2] - 2026-03-15 ### Fixed - **Dependency minimum versions raised** — `Cargo.toml` version constraints now exclude all known vulnerable ranges: `bytes` ≥1.11.1 (RUSTSEC-2026-0007), `chrono` ≥0.4.20 (RUSTSEC-2020-0159), `rustls` ≥0.23.18 (RUSTSEC-2024-0399), `time` ≥0.3.47 (RUSTSEC-2026-0009), `tracing-subscriber` ≥0.3.20 (RUSTSEC-2025-0055). ### Added - **Glama registry metadata** — `glama.json` for MCP server registry scoring and author verification. - **Automated crates.io publishing** — release workflow now auto-publishes to crates.io on tag push. ## [2.7.1] - 2026-03-14 ### Fixed - **WebUI: JS syntax error breaking all views** — orphaned code block with top-level `return` statements caused `Uncaught SyntaxError: Illegal return statement`, preventing the entire UI from loading in any browser. - **WebUI: missing Cache-Control header** — `/ui` response now sends `no-cache, no-store, must-revalidate` to prevent browsers from serving stale HTML after gateway rebuilds. - **WebUI: confusing auth indicator** — when authentication is disabled, the auth bar now auto-detects this and shows a green "Auth disabled" status instead of the misleading red "Not authenticated" with a non-functional "Set API Key" link. ## [2.7.0] - 2026-03-14 ### Added - **Intelligent Tool Surfacing** (RFC-0081): Static tool pinning via `surfaced_tools` config — operators can expose high-value backend tools directly in `tools/list` for one-hop invocation while preserving the compact Meta-MCP surface for the rest. - **Tool Annotations** (MCP 2025-11-25): All meta-tools now carry `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` annotations. `gateway_search_tools` includes `outputSchema`. - **"Did You Mean?" suggestions**: Levenshtein-based typo correction on both meta-tool dispatch (`handle_tools_call`) and backend tool invocation (`gateway_invoke`). - **Dynamic meta-tool descriptions**: Tool and server counts are live (`format!()`) instead of static "150+". - **Enhanced initialize instructions**: Discovery-first pattern with "use `gateway_search_tools` FIRST" emphasis and dynamic counts. - **SEP-1821: Filtered `tools/list`** (behind `spec-preview` flag): Optional `query` parameter triggers semantic search returning filtered tools with full schemas. - **SEP-1862: `tools/resolve`** (behind `spec-preview` flag): Deferred schema loading — resolve a tool's full `inputSchema` by name on demand. - **Dynamic promotion** (behind `spec-preview` flag): Session-scoped auto-surfacing of tools after successful `gateway_invoke`, with FIFO eviction at configurable max (default: 10). - **`notifications/tools/list_changed`**: Gateway now sends the notification it already advertised — fired on backend connect/disconnect and config reload. Fixes MCP spec compliance gap. - **Config path discovery**: Auto-detect `gateway.yaml` / `config.yaml` in cwd, `~/.config/mcp-gateway/`, and `/etc/mcp-gateway/` when `--config` is omitted. - **Config validation**: `Config::validate()` checks port, backend name validity, and HTTP URL parseability at load time. - 8 new synonym groups in search ranking (12 → 20 total). - 78 new tests across both RFCs. ### Changed - **Config split** (RFC-0080): `config/features.rs` (650 lines) split into 10 focused modules under `config/features/`. - **Error handling overhaul**: 48 of 58 `Error::Internal(String)` replaced with 6 typed variants (`ConfigValidation`, `CircuitOpen`, `ToolNotFound`, `OAuth`, `Tls`, `ConfigWatcher`). - **3 dependencies removed**: `dialoguer` (replaced with stdin prompt), `md5` (replaced with `sha2`), `open` (replaced with `std::process::Command`). - `derive(Default)` applied where manual impl was equivalent (`UsageStats`). - Surfaced tools respect routing profiles — blocked backends never leak through surfacing. - Collision detection prevents surfaced tool names from shadowing meta-tools. ### Fixed - 112 `collapsible_if` clippy warnings for Rust 1.93 stable compatibility. - MSRV bumped to 1.88 (matching Docker image and CI). - `criterion` 0.7→0.8, `metrics-exporter-prometheus` 0.16→0.18. ## [2.6.0] - 2026-03-13 ### Added - **Cost Governance** (RFC-0075): Per-tool, per-key, and global daily budgets with configurable alert thresholds (log, notify, block). Live spend dashboard at `/ui/api/costs`. - **Security Firewall** (RFC-0071): Bidirectional request/response scanning with credential redaction (AWS keys, GitHub tokens, JWTs), prompt injection detection, shell/SQL/path traversal detection, per-tool glob rules, and NDJSON audit logging. - **Config Export** (RFC-0070): Export sanitized gateway config as YAML/JSON. Supports Claude Code, Cursor, Windsurf, and Zed client formats via `mcp-gateway config export`. - **Auto-Discovery** (RFC-0074): Discover MCP servers from npm, pip, and Docker sources with quality scoring and deduplication via `mcp-gateway discover`. - **Semantic Search** (RFC-0072): TF-IDF ranked tool search across all tool names and descriptions with relevance feedback learning. - **Tool Profiles** (RFC-0073): Usage analytics per tool with latency histograms, error categorization, usage trends, and persistent storage. - 19 cross-feature integration tests covering all RFC combinations. - Performance benchmarks for all v2.6.0 features (Criterion): firewall <1us, cost enforcer <100ns, semantic search <50us. - Complete example config (`examples/gateway-full.yaml`) with all options documented. ### Changed - **13 dependency upgrades**: reqwest 0.12->0.13, rand 0.9->0.10, rcgen 0.13->0.14, jsonwebtoken 9.3->10.3, quick-xml 0.37->0.39, x509-parser 0.16->0.18, axum-server 0.7->0.8, md5 0.7->0.8, dialoguer 0.11->0.12, clap_complete 4.5->4.6, tokio-tungstenite 0.28, rustls 0.23, time 0.3. - rcgen 0.14 `Issuer` API migration -- removed ~60 lines of manual DER parsing in JWKS endpoint. - rand 0.10 `RngExt` API migration across 4 modules. - All 7 features compile-time gated with `#[cfg(feature)]` -- disable any with `--no-default-features`. ### Fixed - `--no-default-features` build failure: `add`/`remove` commands gated behind `webui` feature. - GitHub push protection false positive for Slack token test patterns in firewall redactor tests. ## [2.5.0] - 2026-03-12 ### Added - **Embedded Web UI** (`/ui`): htmx SPA with 5 views (Dashboard, Tools, Servers, Capabilities, Config), hash routing, search, YAML editor with line numbers. Feature-gated behind `webui`. - **Operator Dashboard** (`/dashboard`): Server-rendered HTML with backend health matrix, cache hit rates, top tools. Auto-refreshes every 5 seconds. - **Web UI Management API**: Server management, capability management, OpenAPI import via `/ui/api/*` endpoints. - **WebSocket transport** for MCP backends. - **Plugin CLI**: `plugin install`, `plugin list`, `plugin search`, `plugin uninstall` with marketplace support. - **Setup wizard** (`mcp-gateway setup`) with 48-server registry. - **CLI server management**: `add`/`remove`/`list`/`get` commands (Claude/Codex compatible syntax). - **Doctor command** (`mcp-gateway doctor`) for configuration diagnostics. - **MCP protocol version negotiation** for stdio transports. - Load test suite and deployment documentation. ### Changed - Agent-scoped tool permissions via OAuth 2.0 JWT identity. - Cache key propagation for backend tool invocations. - Engram-inspired O(1) tool registry with prefetching. - Secret injection proxy with OS keychain integration. - Durable capability chains with step-level checkpoint/retry. ### Fixed - FD exhaustion from streaming session leak + unpooled connections. - Split 12 oversized files under 800 LOC limit. - All clippy pedantic warnings resolved. ## [2.4.0] - 2026-02-25 ### Added - **FastMCP 3.0 Provider Transforms & Playbook Engine** (#32): Dynamic tool transformation engine for FastMCP 3.0-compatible backends. `Provider` trait with `McpProvider`, `CapabilityProvider`, and `CompositeProvider` implementations. `TransformChain` with namespace, filter, rename, and response transforms. - **LLM Key Server — OIDC to Scoped API Keys** (#43): Convert OIDC identity tokens to short-lived, capability-scoped API keys. `InMemoryTokenStore` with dual DashMap indices for O(1) validation and revocation. Background reaper for expired tokens. RFC 8693 token exchange endpoint with constant-time admin token comparison. - **mTLS Authenticated Tool Access** (#51): Certificate-based authorization for tool execution. Client certificate verification against configured CAs. Per-capability mTLS enforcement with policy engine and cert identity extraction. - **O(1) Tool Registry Lookup** (#78): `IndexedCapabilities` with `HashMap` name index. `get()` and `has_capability()` now O(1). Pre-built MCP Tool cache eliminates per-request `to_mcp_tool()` computation. Load dedup reduced from O(n²) to O(n). - **Query Parameter Auth Injection** (`auth.param`): APIs requiring credentials as query parameters (e.g., `?apiKey=...`) now supported natively. No YAML workarounds needed. ### Fixed - **Static Parameters in GET Requests**: `static_params` defined in capability YAML were merged into the substitution context but never appended as actual query parameters. Weather, recipe search, and other capabilities now send all configured static params. - **XML Response Parsing**: Added `quick-xml` for XML-to-JSON conversion. Executor auto-detects XML `Content-Type` and parses accordingly. ECB exchange rates (29 EUR currency pairs) now working. - **Stats Endpoint Performance**: `gateway_get_stats` replaced sequential `get_tools().await` loop (24 backends × 30s timeout worst case) with non-blocking `cached_tools_count()`. Response time reduced from >30s to ~0.1s. - **Registry Test Assertions**: Updated capability count and metadata assertions to match post-dedup state (38 bundled capabilities). - **Merge Conflict Resolution**: Resolved 6 conflict markers across Cargo.toml, config.rs, server.rs, and router.rs from stale stash pop. ### Changed - **Capability YAML Naming Convention** (CAP-010): All capability YAML files renamed to match the `name` field declared in their configuration. - **Capability Validator**: Support for non-REST services, complex placeholders, and runtime-injected auth placeholder whitelisting. ## [2.2.0] - 2026-02-13 ### Added - **Validate CLI** (`mcp-gateway validate`): Lint capability YAMLs against 9 built-in rules. SARIF output for CI integration. `--fix` flag auto-corrects common issues. - **Response Transforms**: Per-capability field projection and PII redaction applied before the response reaches the AI client. Configured via `transform` block in capability YAML. - **Playbooks**: Multi-step tool chains defined in YAML. Executed via the `gateway_run_playbook` meta-tool. Steps can reference previous outputs with `$prev`. ## [2.1.0] - 2026-02-13 ### Added - **Response Caching**: Tool responses cached with configurable TTLs. Per-capability `cache_ttl` override. Configurable `default_ttl` and `max_entries`. - **Usage Statistics & Cost Tracking**: Real-time token savings tracking via `gateway_get_stats` meta-tool and `mcp-gateway stats` CLI command. - **Capability Registry**: Install community capabilities with `mcp-gateway cap install `. Search, list, and fetch from GitHub. - **Smart Search Ranking**: `gateway_search_tools` results ranked by usage frequency. Persisted across restarts in `~/.mcp-gateway/usage.json`. - **Keychain Integration**: Store API keys in macOS Keychain or Linux secret-service via `{keychain.name}` syntax. Session-cached for performance. - **42 Starter Capabilities**: 25 zero-config (weather, Wikipedia, geocoding, Hacker News, npm/PyPI, country info, public holidays, etc.) and 17 free-tier (Brave Search, stock quotes, movies, IP geolocation, recipes, package tracking). - **OpenAPI Import**: `mcp-gateway cap import spec.yaml` generates capability YAMLs from OpenAPI/Swagger specs automatically. - **Metacognition Verification**: Capability for AI self-verification workflows. - **Integration Tests**: Full test suite covering all 5 major features. - **87 Unit Tests**: Comprehensive coverage across the codebase. ### Changed - **Consolidated capabilities**: Registry and capabilities merged into single `capabilities/` directory as source of truth. - **Large files split**: All source files refactored to 800 LOC or fewer. ### Fixed - Resolved all 243 clippy pedantic warnings; `#![warn(missing_docs)]` enabled. ## [2.0.0] - 2025-01-25 ### Changed - **BREAKING**: Complete rewrite from Python to Rust - Now requires Rust 1.85+ (Edition 2024) ### Added - **Rust Implementation**: Full async/await with tokio runtime - **MCP Protocol**: 2025-11-25 (latest specification) - **Authentication**: Bearer token and API key auth with per-client rate limits and backend restrictions. Supports `auto`, `env:VAR`, or literal tokens. - **Streaming / SSE**: Real-time backend notifications via Server-Sent Events. Notification multiplexer routes backend events to connected clients. - **OAuth Support**: Per-backend OAuth configuration with dynamic client registration. - **Failsafes**: - Circuit breaker with configurable thresholds - Exponential backoff retry (backoff crate) - Rate limiting (governor crate) - Concurrency limits per backend - **Transport Support**: - stdio: Subprocess with JSON-RPC over stdin/stdout - HTTP: Streamable HTTP POST with session management - SSE: Server-Sent Events parsing - **Architecture**: - Axum HTTP server with graceful shutdown - DashMap for lock-free concurrent access - Health checks and idle backend hibernation - Signal handling (SIGINT/SIGTERM) - **Environment**: `env_files` config field loads `.env` files with `~` expansion before variable resolution. - **Docker Support**: Official container image at `ghcr.io/mikkoparkkola/mcp-gateway`. - **Homebrew**: `brew install MikkoParkkola/tap/mcp-gateway`. - **JSON Logging**: `--log-format json` for structured log output. - **Prometheus Metrics**: Optional `--features metrics` for request count, latency, circuit breaker state changes, and rate limiter rejections. ### Removed - Python implementation (see v1.0.0 for Python version) - Pydantic configuration (replaced with figment + serde) ## [1.0.0] - 2025-01-24 ### Added - Initial release of MCP Gateway (Python implementation) - Meta-MCP Mode: 4 meta-tools for dynamic tool discovery - Transport support: stdio, HTTP, SSE - Configuration via YAML with Pydantic validation - systemd/launchd service templates [Unreleased]: https://github.com/MikkoParkkola/mcp-gateway/compare/v3.5.1...HEAD [4.0.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v3.5.1...v4.0.0 [3.5.1]: https://github.com/MikkoParkkola/mcp-gateway/compare/v3.5.0...v3.5.1 [3.5.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v3.4.0...v3.5.0 [2.10.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.9.1...v2.10.0 [2.9.1]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.9.0...v2.9.1 [2.9.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.8.1...v2.9.0 [2.7.3]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.7.2...v2.7.3 [2.7.2]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.7.1...v2.7.2 [2.7.1]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.7.0...v2.7.1 [2.7.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.6.0...v2.7.0 [2.6.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.5.0...v2.6.0 [2.5.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.4.0...v2.5.0 [2.4.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.2.0...v2.4.0 [2.2.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.1.0...v2.2.0 [2.1.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v2.0.0...v2.1.0 [2.0.0]: https://github.com/MikkoParkkola/mcp-gateway/compare/v1.0.0...v2.0.0 [1.0.0]: https://github.com/MikkoParkkola/mcp-gateway/releases/tag/v1.0.0