# Changelog

All notable changes to `rmcp-server-kit` are 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).
Breaking changes bump the **major** version.

## [Unreleased]

## [1.15.0] - 2026-06-29

### Changed

- **Bumped the `rmcp` SDK from 1.7 to 1.8.** Non-breaking for this crate
  ([rmcp-v1.7.0...rmcp-v1.8.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.7.0...rmcp-v1.8.0)):
  `ServerHandler`, `StreamableHttpService`, `call_tool` / `CallToolRequestParams`,
  and the `handler::server` module are all source-compatible. rmcp 1.8 adds
  transparent server-side hardening that flows through `serve()` unchanged —
  `MCP-Protocol-Version` header vs initialize-body validation, stricter tool
  input/output-schema stripping, and SEP-2164 resource-not-found code
  selection (peers on `2026-07-28`+ get `INVALID_PARAMS`, older peers keep
  `RESOURCE_NOT_FOUND`). SEP-2577 deprecates Roots/Sampling/Logging in the SDK;
  this crate does not use those APIs, so no warnings surface. `tower-http`
  stays on 0.6 (reqwest 0.13.4 still pins `^0.6.8`; 0.7 would only fork the
  tree without benefit). Other dependencies refreshed to latest semver-compatible
  patches (anyhow, rustls, bytes, zeroize, time, uuid, …).

## [1.14.0] - 2026-06-11

### Added

- **Exempt paths for the extra-route rate limiter**
  ([#11](https://github.com/andrico21/rmcp-server-kit/issues/11)):
  `McpServerConfig::with_extra_route_rate_limit_exempt_paths(paths)` +
  TOML `server.extra_route_rate_limit_exempt_paths`. Entries are matched
  by **raw exact string comparison** against the request path — no
  globs, no normalization — and the check is fail-closed (anything not
  listed stays limited) and runs before key extraction, so exempt
  requests (e.g. the RFC 8414 metadata document MCP clients fetch on
  every connect) consume no limiter budget and never appear in deny
  telemetry. Requires `extra_route_rate_limit`; entries must be
  non-empty and start with `/` (validated at startup).
- **Prometheus deny counters for all four built-in rate limiters**
  ([#11](https://github.com/andrico21/rmcp-server-kit/issues/11),
  feature `metrics`): new `McpMetrics.rate_limited_total`
  (`rmcp_server_kit_rate_limited_total`, label `limiter` in `tool` /
  `auth_pre` / `auth_post` / `extra_route`), incremented at each deny
  site alongside the existing warn-level log. The shared `McpMetrics`
  handle now travels to inner middleware via a request extension
  inserted by the metrics layer; with metrics disabled the deny sites
  are unchanged. `McpMetrics` is `#[non_exhaustive]`, so the field
  addition is semver-minor.

### Security

- **CRL URL gate now rejects embedded credentials (userinfo), and
  rejection sites no longer echo what they reject.** The shared
  scheme guard used by CDP extraction and the CRL fetcher refuses
  `https://user:pass@host/...` URLs (`userinfo_forbidden`) — making the
  userinfo rejection that `SECURITY.md` already documented actually
  enforced — and both rejection sites log only a sanitized
  scheme+host+port rendering, so credentials from a CA chain's or client
  certificate's CDP extension can never reach warn logs or error
  strings. Found by the 1.13.0 guidelines review; fix design approved by
  Oracle and Momus review gates.
- **OAuth redirect-rejection warns no longer echo the rejected target
  URL.** Both redirect-policy closures (`OauthHttpClient`, `JwksCache`)
  log the sanitized scheme+host+port instead of the full target, so a
  malicious IdP redirecting to a userinfo-bearing URL cannot plant
  credentials in the logs.

### Fixed

- **Unparsed CDP URIs are debug-logged with `Debug` formatting** (`?raw`
  instead of `%raw`), escaping control characters an attacker could
  embed in a client certificate's CDP extension to forge log lines or
  emit terminal escapes (the string only reaches this branch when
  `Url::parse` fails, which strips none of the original bytes).

### Documentation

- All non-test `tokio::select!` sites now carry explicit
  `// cancel-safe:` annotations (transport shutdown races, CRL
  bootstrap/refresher loops), completing the crate's cancel-safety
  documentation mandate.
- New `SECURITY.md` subsection "CRL discovery under adversarial load":
  documents the pre-validation CDP discovery invariant, the bounded
  griefing residual and why per-source-IP budgeting is impossible at the
  `ClientCertVerifier` layer, plus operator guidance (alert on
  `discovery_rate_limited`, size `crl_max_seen_urls` /
  `crl_max_cache_entries` to the CA estate, rely on bootstrap
  pre-seeding). The drop-newest-at-cap cache policy rationale (LRU would
  let an attacker evict the legitimate warm set) is now documented at
  the policy site and in `SECURITY.md`.

### Tooling

- **`clippy::nursery` enabled crate-wide at `warn`** (promoted to deny in
  CI via `-D warnings`), with three documented allows:
  `missing_const_for_fn` (const-ness on pub fns is a one-way semver
  promise), `redundant_pub_crate` (antagonistic to the enabled
  `unreachable_pub`), and `option_if_let_else` (readability regressions
  on the HMAC-fallback / poisoned-lock idioms). ~90 nursery findings
  fixed across the crate, including explicit early guard drops on the
  JWKS/CRL cache write paths and doc-link cleanups.
- **`str_to_string` flipped from `allow` to `warn`** and remaining
  violations fixed; `string_to_string` documented as removed from clippy
  (covered by the already-enabled `implicit_clone`).
- **`await_holding_refcell_ref` and `mem_forget` denied** (declared for
  future-proofing; the crate has no current usage of either).
- **`cargo vet` exemption baseline committed** under `supply-chain/`
  (400 exemptions); the CI vet job now runs without `|| true` and can be
  promoted to required after a green run.
- **New `taplo` CI job** (`taplo fmt --check`); all tracked TOML files
  reformatted to the repo's `.taplo.toml` policy, which now excludes the
  cargo-vet-owned `supply-chain/` directory.

## [1.13.0] - 2026-06-11

### Added

- **Trusted-forwarder mode: proxy-aware client IPs for all rate
  limiters** (completes the final deferred item from
  [#10](https://github.com/andrico21/rmcp-server-kit/issues/10)):
  `McpServerConfig::with_trusted_proxies(cidrs)` +
  `with_forwarded_header(mode)` (TOML `server.trusted_proxies`,
  `server.forwarded_header` = `"x-forwarded-for"` default /
  `"forwarded"` for RFC 7239). When the **direct peer** is inside the
  trusted CIDRs, the client IP is resolved via the rightmost-untrusted
  walk over the last forwarding-header instance (16-entry scan cap;
  malformed/obfuscated/all-trusted chains fall back to the direct peer;
  only reason codes are logged, never header contents). The result is
  the new public `transport::ClientIp` request extension, and **all
  four per-IP rate limiters now key by it** — behind a proxy, clients
  get individual buckets instead of sharing the proxy's. With the
  feature off (default), `ClientIp` equals the direct peer and behavior
  is unchanged. `PeerAddr` keeps its direct-socket-peer contract.
  Headers from untrusted peers are ignored entirely (leftmost-trust is
  never used). New dependency: `ipnet` (MIT OR Apache-2.0).

## [1.12.0] - 2026-06-11

### Added

- **`Retry-After` on every rate-limit response.** All four built-in
  limiters (auth pre-auth gate, post-failure auth limiter, `tools/call`
  limiter, extra-route limiter) now deny with a `Retry-After: n` header
  (RFC 9110 delta-seconds: best-effort wait rounded **up** to whole
  seconds, never `0`) alongside the unchanged 429 status and plain-text
  body. Backed by the new `BoundedKeyedLimiter::check_key_wait` method
  (returns the wait `Duration` on deny; `check_key` now delegates to it)
  and the new `McpxError::RateLimitedFor { message, retry_after }`
  variant. The legacy `McpxError::RateLimited(String)` variant is
  retained and remains headerless. Completes the first deferred item
  from [#10](https://github.com/andrico21/rmcp-server-kit/issues/10).
- **Optional burst knobs on every rate limiter.** Burst sets the bucket
  capacity (maximum requests admitted back-to-back); the sustained
  per-minute rate is unchanged, and burst may be smaller or larger than
  the rate. New surface: `McpServerConfig::with_tool_rate_limit_burst` /
  `with_extra_route_rate_limit_burst` (+ TOML `tool_rate_limit_burst`,
  `extra_route_rate_limit_burst`) and
  `RateLimitConfig::{with_burst, with_pre_auth_burst}` (+ TOML
  `auth.rate_limit.{burst, pre_auth_burst}`). Bursts must be greater
  than zero; the tool/extra-route bursts require their base knob, while
  `pre_auth_burst` is valid without an explicit pre-auth rate (the
  gate's base always resolves to `max_attempts_per_minute × 10`).
  Unset = today's behavior (burst = rate). Completes the second
  deferred item from
  [#10](https://github.com/andrico21/rmcp-server-kit/issues/10).

## [1.11.0] - 2026-06-10

### Added

- **Opt-in per-IP rate limiting for `with_extra_router` routes** (closes
  [#10](https://github.com/andrico21/rmcp-server-kit/issues/10)):
  `McpServerConfig::with_extra_route_rate_limit(per_minute)` and the
  matching TOML field `server.extra_route_rate_limit`. When set, the
  application's extra router is wrapped — pre-merge, so the limiter can
  never leak onto `/mcp`, health, admin, or OAuth endpoints — in a
  per-source-IP limiter backed by the same memory-bounded machinery as
  the tool limiter (10,000 tracked keys, 15-minute idle eviction). On
  limit: `429` with a plain-text body, matching the tool/auth limiters
  (no `Retry-After`; adding it uniformly across all limiters is tracked
  separately). Keyed by the direct socket peer (no `X-Forwarded-For`
  interpretation); fails open when no peer address is present; the
  value must be greater than zero (validated at startup); startup-only
  (not hot-reloadable).

## [1.10.0] - 2026-06-10

### Added

- **Uniform client peer-address exposure for application routes**
  (requested by a downstream consumer running chained-OAuth endpoints on
  `with_extra_router` under direct TLS):
  - New public `transport::PeerAddr` request extension (`#[non_exhaustive]`,
    `Copy`/`Eq`/`Hash`) carrying the direct socket peer address, inserted
    on **both** the plain and the TLS listener and extractable via its
    `FromRequestParts` impl or `Extension<PeerAddr>` — including from
    `with_extra_router` routes, which bypass auth/RBAC. Direct peer only
    (no `X-Forwarded-For` interpretation); absent under `serve_stdio`;
    never logged by the framework.
  - The TLS listener now also mirrors the peer address into the standard
    `axum::extract::ConnectInfo<SocketAddr>` extension (insert-only-when-
    absent), so stock per-IP middleware (e.g. `tower_governor`'s
    `PeerIpKeyExtractor`) works unmodified on direct-TLS deployments
    instead of failing every request. `TlsConnInfo` (and the mTLS
    identity it carries) remains private and connection-bound.

## [1.9.0] - 2026-06-10

### Added

- **TLS accept-path tuning knobs** (closes
  [#9](https://github.com/andrico21/rmcp-server-kit/issues/9)):
  `McpServerConfig::with_tls_handshake_timeout(Duration)` and
  `McpServerConfig::with_max_concurrent_tls_handshakes(usize)`, plus the
  matching TOML fields `server.tls_handshake_timeout` (humantime string)
  and `server.max_concurrent_tls_handshakes`. Defaults are unchanged
  (10 s / 256). Both values must be greater than zero (validated in
  `McpServerConfig::validate` and `validate_server_config`) and are
  **startup-only** — they bind at listener construction and do not
  participate in `ReloadHandle` hot reload. The completed-handshake
  channel capacity remains internal.

### Fixed

- **Removed markdown backticks from the azp-only audience deprecation
  warning.** The one-shot `tracing::warn!` emitted in
  `AudienceValidationMode::Warn` carried rustdoc-style backticks into
  terminal/JSON log output; the message is now plain text, matching the
  crate's logging style. No behavior change.

## [1.8.2] - 2026-06-10

### Security

- **The SSRF IP range guard now classifies IPv6 transition prefixes.**
  NAT64 (`64:ff9b::/96`, RFC 6052) and 6to4 (`2002::/16`, RFC 3056)
  addresses are blocked when the IPv4 address they embed is itself
  blocked (closing e.g. `64:ff9b::10.0.0.1` reaching internal RFC 1918
  space through a NAT64 gateway) while remaining permitted for embedded
  public addresses, so DNS64/NAT64-only egress networks keep working.
  Teredo (`2001::/32`, RFC 4380) is blocked outright. Applies to both the
  CRL and OAuth/JWKS fetch paths; see SECURITY.md "IPv6 transition
  prefixes".

### Changed

- **`JwksCache::new` returns an error instead of panicking when
  `jwks_cache_ttl` is not a valid humantime duration.** The documented
  panic existed only for unvalidated configs (the `OAuthConfig::validate`
  pipeline rejects invalid TTLs up front); the function signature already
  returned `Result`, so the failure now surfaces through it.
- **Deduplicated OAuth SSRF target screening (internal).** The screening
  logic previously existed twice: a test-instrumented copy and a
  byte-identical production copy compiled only under
  `cfg(not(any(test, feature = "test-helpers")))` — meaning the test suite
  never compiled the production branch and a future edit could silently
  diverge the two. Both paths now delegate to one shared core
  (`screen_oauth_target_core`) compiled identically under all cfgs, with
  the loopback bypass plumbed as a parameter that production hardcodes to
  `false`. No behavior change; error messages are byte-identical.
- **Lint hardening (internal):** enabled `clippy::string_slice` (warn,
  escalated to deny in CI) and pinned `clippy::await_holding_lock` to
  deny. Manual `&str[range]` slicing in the RBAC glob matcher and the
  origin auto-derivation was rewritten with checked `get(..)` accessors —
  behavior is unchanged under the existing char-boundary invariants, and
  a future invariant violation now degrades to a non-match instead of a
  panic.
- **Corrected the `log_format` field documentation** to list all three
  accepted values (`json`, `pretty`, `text`) and the actual default
  (`pretty`); the validator already accepted all three.

### Fixed

- **TLS accept loop no longer serializes handshakes (idle-connection
  denial of service).** `TlsListener::accept` previously performed each
  TLS handshake inline before accepting the next connection, so a single
  idle TCP connection (e.g. `nc host 8443` sending no bytes) stalled ALL
  new connections indefinitely. TCP accepts and TLS handshakes now run on
  a dedicated background task that spawns each handshake onto its own
  worker, bounded by a 256-handshake in-flight cap (with kernel-backlog
  backpressure at saturation) and a 10-second per-handshake timeout. The
  handshake-time mTLS identity extraction and its binding to the
  connection stream are unchanged.
- **CRL timestamps outside the platform-representable `SystemTime` range no
  longer panic the CRL refresher.** `thisUpdate`/`nextUpdate` values are
  parsed from raw fetched CRL bytes before signature validation, so they are
  attacker-controlled; a pre-1601 timestamp (unrepresentable by Windows
  `SystemTime`) previously panicked the spawned refresher task, silently
  halting CRL discovery and refresh for the process lifetime. Conversion now
  uses checked arithmetic and clamps unrepresentable or absurd values toward
  `UNIX_EPOCH` — the safe direction (a clamped timestamp can only make a CRL
  look older, forcing an eager refresh, never fresher).
- **The per-host CRL fetch semaphore cap no longer permanently locks out new
  CRL hosts.** Previously, once `crl_max_host_semaphores` (default 1024)
  distinct CRL hosts had ever been seen, fetches for any NEW host failed
  with `crl_host_semaphore_cap_exceeded` until process restart — an
  attacker presenting client certificates with unique CDP hostnames could
  poison the map permanently. At the cap, idle entries (no in-flight fetch)
  are now evicted on demand; the cap error remains only for genuinely
  concurrent fetch floods across `crl_max_host_semaphores` distinct hosts.


## [1.8.1] - 2026-06-05

### Changed

- **Renamed build-time environment variables to match the crate name.**
  The `/version` endpoint now reads `RMCP_SERVER_KIT_BUILD_SHA`,
  `RMCP_SERVER_KIT_BUILD_TIME`, and `RMCP_SERVER_KIT_RUSTC_VERSION` (via
  `option_env!`) instead of the legacy `MCPX_BUILD_SHA`,
  `MCPX_BUILD_TIME`, and `MCPX_RUSTC_VERSION` names. Build pipelines that
  populate these variables at compile time must update their CI / build
  scripts; otherwise the affected `/version` fields silently fall back to
  `"unknown"`. The runtime JSON shape (`build_git_sha`, `build_timestamp`,
  `rust_version`, `mcpx_version`) and all public API surface are
  unchanged.

## [1.8.0] - 2026-06-04

### Changed

- **Raised the minimum supported `rmcp` version from `1.5` to `1.7`.** The
  crate is built and tested exclusively against `rmcp 1.7.x` in CI, so the
  declared floor now matches the version actually exercised rather than
  claiming support for a range that CI never verifies. The public API is
  unchanged and the code still compiles and passes the full test suite
  against `rmcp 1.5.0`; this bump tightens the dependency requirement only.
  Downstream consumers pinned below `rmcp 1.7` must update their own `rmcp`
  requirement accordingly.

## [1.7.7] - 2026-06-04

### Dependencies

- **Bumped the `shlex` constraint `1.3` → `2`.** The RBAC argument-allowlist
  splitter consumes only `shlex::split`, whose behaviour is identical across
  the two lines; shlex 2.0 merely *removed* the deprecated `quote`/`join`
  APIs (subject of RUSTSEC-2024-0006) and an unsound `DerefMut` impl, none of
  which this crate uses. The bump collapses the duplicate `shlex` copy that
  was otherwise pulled in transitively (via `cc`), so the resolved graph now
  carries a single `shlex 2.0.1`. The `rbac` tokenization regression suite
  (`src/rbac.rs`) passes unchanged, confirming behaviour parity.
- **Validated the crate against the latest semver-compatible dependency
  versions** (minor/patch only; `Cargo.lock` remains intentionally untracked
  for this library crate). Confirmed clean against notable upstream releases
  including the `rmcp` MCP SDK and `rmcp-macros` `1.5.0 → 1.7.0`, `rustls`
  `0.23.38 → 0.23.40`, `tower-http` `0.6.8 → 0.6.11`, `jsonwebtoken`
  `10.3.0 → 10.4.0`, `reqwest` `0.13.3 → 0.13.4`, `hyper` `1.9 → 1.10.1`, and
  `tokio` `1.52.1 → 1.52.3`. Full build, Clippy (`-D warnings`), and the
  complete test suite (`--all-features`) all pass unchanged.

## [1.7.6] - 2026-05-20

### Security / Hardening

- **`SeenIdentitySet` is now memory-bounded** (M2). The internal
  first-seen-identity log-dedup table in `src/auth.rs` previously used
  an unbounded `Mutex<HashSet<String>>`, which grew with attacker-
  influenced identity churn (mTLS SAN/CN or OAuth `sub`) until process
  exit. Replaced with a bounded FIFO set capped at 4096 entries
  (~256 KiB at 64-byte names). Poison-tolerant `Mutex` with explicit
  `SAFETY:` rationale. Honest clients never trigger eviction; hostile
  churn is bounded. Internal type, no public API change.

### Quality / lint hygiene

- **Spelled out test fixtures** (M1). Replaced 9 `..Default::default()`
  shorthand uses across `src/auth.rs` and `src/transport.rs` test modules
  with explicit per-field initialisation, making the assertions readable
  without cross-referencing the type's `Default` impl.
- **Demoted speculative `TODO(refactor):` markers to `NOTE:`** (L1) at
  `src/rbac.rs:647` and `src/transport.rs:850` — these are documented
  design trade-offs, not pending work.
- **Added `reason = "..."` justifications** to remaining `#[allow]` /
  `#[expect]` attributes (L2 / L3 / Q5): `src/auth.rs:1031`,
  `src/transport.rs:2124`, `src/oauth.rs:2376`, plus the test-module
  inner attributes in `src/config.rs`, `src/metrics.rs`,
  `src/observability.rs`, `src/cancel.rs`, and the crate-level
  `#![cfg_attr(test, allow(...))]` in `src/lib.rs`.
- **Added `clippy::panic_in_result_fn` to the crate-level test-only
  allow list** (Q8) in `src/lib.rs` as cheap future-proofing for
  `Result`-returning `#[tokio::test]` bodies.
- **`SAFETY:` comment** (M3) added to the `Mutex` poison-recovery path
  in `SeenIdentitySet::insert_is_first` explaining why continuing past
  poison preserves correctness.
- **Removed unused `use std::sync::Mutex`** in `src/admin.rs` (bonus
  cleanup surfaced during M2).

### Docs

- **Clarified `SeenIdentitySet` as FIFO, not LRU** (Q3). The type's
  rustdoc and the call-site comment in `AuthState::log_auth` now
  consistently say "bounded FIFO set" instead of the previously vague
  "LRU-style". Added a unit test
  (`seen_identity_set_fifo_does_not_refresh_on_repeat_hit`) that locks
  in the FIFO contract by asserting repeat hits do **not** bump an
  entry's eviction position.
- **Clarified the global CRL discovery limiter** (Q13) at
  `src/auth.rs:467-477` and `src/mtls_revocation.rs:117-125`. The
  comments now explicitly note that this limiter is **distinct** from
  the bearer pre-auth limiter (which is already keyed per-IP via a
  bounded keyed governor in the ordinary request middleware path).
- **Scoped the typed pre-tokenized argument matcher** (Q18) as a
  `NOTE(future-pr):` design block above `ArgumentAllowlist` in
  `src/rbac.rs`. Captures Oracle-approved scope: keep public
  `ArgumentAllowlist` shape stable, add a private compiled IR owned by
  `RbacPolicy::new`, with a required equivalence test matrix.
- **Marked the deferred `#[must_use]` on `with_hooks`** (Q15) with a
  `NOTE(next-minor):` comment in `src/tool_hooks.rs:239` so the next
  minor-bump owner finds the deferred semver-minor change.

## [1.7.5] - 2026-05-20

### Changed

- **Lints: tightened `clippy::expect_used` from `allow` to `deny`** at the
  crate level. The five legitimate production `.expect()` sites
  (`auth.rs` `DUMMY_PHC_HASH` PHC string construction, fixed-salt Argon2
  hash; `oauth.rs` re-parsing the already-validated `jwks_cache_ttl`;
  `rbac.rs` HMAC key construction from a 32-byte SHA-256 digest) now
  carry per-site `#[allow(clippy::expect_used, reason = "...")]`
  attributes that pin the safety argument next to the call. Closes the
  asymmetry where `unwrap_used = "deny"` was bypassable via `.expect()`
  with no machine-checked justification. Existing test files already
  carry the `expect_used` allow at file scope; one (`oauth_url_validation.rs`)
  was updated to match the convention.

- **API: removed `impl Deref<Target = T> for Validated<T>`** in
  `transport.rs`. `Validated<T>` is a typestate proof-of-validation
  newtype; exposing `Deref` made the validation marker easy to lose at
  call sites via implicit auto-deref. Use [`Validated::as_inner`] for
  read-only borrowing or [`Validated::into_inner`] to recover the raw
  value. The two `serve()` variants already called `into_inner()`
  immediately, so the change is observable only through the test
  helper and any downstream caller that wrote `*validated` or
  `validated.<field>` instead of `validated.as_inner().<field>`.

  **Migration**: replace `*validated` / `&*validated` with
  `validated.as_inner()`, and `validated.<field>` with
  `validated.as_inner().<field>`. The doc-comment on `Validated`
  reflects the new access pattern.

- **Lint attributes: upgraded four `#[allow(clippy::...)]` allows to
  `reason = "..."` form** in `rbac.rs` (`rbac_middleware`),
  `transport.rs` (`build_app_router`, `serve_stdio`), and `oauth.rs`
  (`select_jwks_key`). The justifications previously lived in adjacent
  comments only; they are now attached to the attribute itself so they
  travel with the suppression in lint reports.

- **CI: re-enabled the `cargo-semver-checks` job on pull requests.** Disabled
  for the 1.6.0 H3 break (`Option<String>` -> `Option<RfcTimestamp>` on
  `ApiKeyEntry::expires_at`); the intentional break shipped, became the
  published baseline on crates.io, and was followed by purely additive
  releases (1.7.4 added the `cancel` module and `McpxError::RetryableTimeout`).
  Locally verified clean against the published baseline (222 checks pass,
  no semver update required).

## [1.7.4] - 2026-05-19

### Added

- **`cancel` module: `run_with_cancel_and_timeout` for cancel-safe
  tool handlers.** Solves the "drop mid-`.await`" hazard when a
  `tokio::select!` arm racing `CancellationToken::cancelled()` or
  `tokio::time::sleep(timeout)` wins against a long-running future
  that owns a remote-side resource (SSH channel, in-flight HTTP
  body, DB transaction). Spawning the future onto `tokio::spawn`
  first and racing the `JoinHandle` (without `.abort()`) lets the
  inner future complete its own cleanup path while the caller
  returns cancel/timeout to the client immediately. `DetachOutcome`
  is `#[non_exhaustive]` and `#[must_use]`. The originating
  tracing span is preserved via `.instrument(Span::current())`.
  Task-local RBAC scope is intentionally NOT propagated into the
  detached task -- detached work should finish or close
  already-authorized resources rather than initiate fresh
  RBAC-gated operations; the module-level `# Caveats` rustdoc
  shows how to capture and rebind RBAC context for callers that
  genuinely need it. Originally implemented in the downstream
  `podmcp` crate to close that crate's M-6 deferred-audit finding.

## [1.7.3] - 2026-05-15

### Changed

- **Deps: routine dependency refresh.** Bumped runtime crates `rmcp`
  `1.6 -> 1.7` (via `cargo update`, semver-compatible),
  `hmac` `0.12 -> 0.13`, `sha2` `0.10 -> 0.11`. The `hmac` 0.13 release
  no longer re-exports `KeyInit` through the `Mac` trait, so
  `src/rbac.rs` was updated to import `hmac::KeyInit` explicitly at the
  single call-site that constructs `Hmac<Sha256>::new_from_slice`
  (HMAC seed for the redaction token derivation). No behavioural
  change, no public API change. Bumped dev/bench-only
  `criterion` `0.5 -> 0.8`; the bench harness uses only stable
  `criterion_group!` / `criterion_main!` / `Criterion::bench_function`
  / `black_box` APIs, so no source changes were required in
  `benches/`. Lockfile also picks up transitive `winnow` `1.0.2 ->
  1.0.3` patch. After this update every direct dependency in the
  manifest is at its latest crates.io stable; remaining lockfile
  duplications (`hmac 0.12+0.13`, `sha2 0.10+0.11`, `thiserror 1+2`,
  `rand 0.8+0.9+0.10`) are transitive-only and pinned by upstream
  leaf crates (`argon2`, `jsonwebtoken`, `rcgen`, `rsa`, `wiremock`,
  `prometheus`). The two `cargo update --verbose --dry-run`
  hold-backs (`crypto-common 0.1.6 -> 0.1.7`,
  `matchit 0.8.4 -> 0.8.6`) are unfixable from this repo:
  `matchit` is exact-version pinned (`=0.8.4`) by `axum 0.8.9` and
  `crypto-common` is held by transitive pins inside the RustCrypto
  v0.10 / `digest 0.10` ecosystem that `jsonwebtoken 10.4.0` and
  `argon2 0.5.3` still target. All 321 unit tests + 29 E2E tests
  pass on Rust 1.95.0 under `--all-features`; clippy clean with
  `-D warnings`; both benches execute end-to-end.

## [1.7.2] - 2026-05-15

### Fixed

- **Test: consolidate the M-H2 env-proxy matrix into a single
  sequential test to eliminate a Windows CI race**
  (`tests/ssrf_resolver.rs`). The six per-variant tests
  (`no_proxy_defeats_*`) each invoked `temp_env::with_var` to mutate
  process-wide environment variables (`HTTP_PROXY` / `HTTPS_PROXY` /
  `ALL_PROXY` upper- and lower-case) before constructing an
  `OauthHttpClient`. Rust's default test runner runs `#[test]` cases
  in parallel threads; the env-var mutations could leak across threads
  and into other concurrently-running tests on Windows runners
  (`Test (windows-latest)` failed on tag `1.7.1`). The matrix now
  runs as one sequential `#[test]` so all six variants are exercised
  without racing parallel tests. Coverage is preserved (still
  asserting `ssrf:` diagnostic for every variant).

## [1.7.1] - 2026-05-15

### Fixed

- **Build: replace runtime-RNG salt for the constant-time Argon2
  placeholder with a fixed salt** (`src/auth.rs`). The
  `DUMMY_PHC_HASH` was previously generated with
  `SaltString::generate(&mut argon2::password_hash::rand_core::OsRng)`,
  which depends on `rand_core 0.6`'s `getrandom` cargo feature being
  activated transitively. That feature is not turned on in any
  configuration of this crate (default, `--features metrics`,
  `--no-default-features`), so the build broke as soon as `argon2`'s
  re-exported `rand_core` was reached by name resolution. Switch to a
  fixed 16-byte salt (`SaltString::from_b64("AAAA...")`); the dummy
  hash never matches real input and is only used as a same-cost
  Argon2 verification target to flatten timing across slots, so salt
  randomness is irrelevant. Closes the post-release CI failure on
  `1.7.0` tag.

## [1.7.0] - 2026-05-15

### Security

- **M-H2: Outbound HTTP clients now close the TOCTOU window between
  pre-flight SSRF screening and connect-time DNS resolution**
  (`src/ssrf_resolver.rs`, `src/ssrf.rs`, `src/oauth.rs`,
  `src/mtls_revocation.rs`). Previously `screen_oauth_target` and
  `CrlSet::new` performed an `IpAddr` lookup, validated it against the
  cloud-metadata blocklist and operator allowlist, and then handed the
  request to `reqwest`, which independently re-resolved the hostname
  inside its own connector. A controlled-DNS attacker could pass the
  pre-flight check with a public IP and have the connector see a
  loopback / private / metadata answer microseconds later. Every
  outbound `reqwest::Client` now installs a custom
  `SsrfScreeningResolver` (`ClientBuilder::dns_resolver(...)`) that
  re-applies the same `ip_block_reason` + `CompiledSsrfAllowlist`
  policy on the addresses actually returned to the connector.
  Cloud-metadata short-circuits before the allowlist is consulted and
  remains unbypassable in every code path. The resolver fails closed
  with a `"ssrf:"`-prefixed error on policy denial so operators can
  distinguish deliberate denials from generic DNS failures. Defence in
  depth: every `ClientBuilder` also calls `.no_proxy()` to disable
  reqwest's auto-proxy detection, since `HTTP_PROXY` /
  `HTTPS_PROXY` / `ALL_PROXY` env vars would otherwise route DNS
  through the proxy and bypass the resolver entirely. Wired at all six
  outbound construction sites: `OauthHttpClient::build`,
  `build_mtls_clients`, `JwksCache::with_config`, the OAuth wiremock
  test harness, `CrlSet::new`, and `bootstrap_fetch`. Closes the last
  open finding from the 2026-05-13 deep code review.

### Added

- **`oauth-mtls-client` cargo feature** enabling RFC 8705 §2 mTLS
  client authentication for the OAuth token-exchange endpoint.
  Disabled by default; opt in via
  `rmcp-server-kit = { version = "1", features = ["oauth-mtls-client"] }`.
  See M-H4 entry under `### Security` for the full security rationale.
- **`ClientCertConfig::new(cert_path, key_path)`** constructor for the
  `#[non_exhaustive]` `ClientCertConfig` so downstream crates can build
  one without struct-literal syntax.

### Fixed

- **M4: `oauth.role_claim` now resolves first-class `Claims` fields**
  (`src/oauth.rs`). `resolve_role` previously only walked the `extra`
  map, so `role_claim = "sub"` (or `azp` / `client_id` / `aud` / `scope`)
  was silently treated as missing even when the JWT contained those
  standard fields. A new `first_class_claim_values` helper layers the
  RFC 7519 / RFC 8693 standard claims into the lookup, with `scope`
  whitespace-split per RFC 8693 §4.2 and `aud` returning every audience.
- **M7: Prometheus `/metrics` listener now participates in graceful
  shutdown** (`src/metrics.rs`, `src/transport.rs`). `serve_metrics`
  gained a `shutdown: CancellationToken` parameter and wires it into
  `axum::serve(...).with_graceful_shutdown(...)`, so cancelling the
  parent server's shutdown token now releases the metrics port instead
  of leaking it until process exit.

### Fixed

- **M5: `oauth.jwks_cache_ttl` is now validated up-front** (`src/oauth.rs`).
  Previously, a malformed `jwks_cache_ttl` (e.g. `"ten minutes"`) was
  silently swallowed by `unwrap_or(Duration::from_mins(10))` inside
  `JwksCache::new`, so the operator-configured TTL was ignored without
  any warning. `OAuthConfig::validate` now parses the string and rejects
  startup with a clear `McpxError::Config` on failure; `JwksCache::new`
  therefore relies on a typed invariant instead of a silent fallback.
- **M6: `max_concurrent_requests = Some(0)` is now rejected** at
  `McpServerConfig::validate` time (`src/transport.rs`). A zero cap would
  deadlock the global concurrency limiter and reject every request.
  Mirrors the equivalent TOML-side check already present in
  `src/config.rs`.
- **M8: `auth.rate_limit.max_tracked_keys = 0` is now rejected** at
  `McpServerConfig::validate` time (`src/transport.rs`). A zero cap would
  force the bounded keyed limiter to evict on every insert and
  effectively disable rate limiting. `BoundedKeyedLimiter::new` now also
  carries a `debug_assert!(max_tracked_keys > 0)` as defense-in-depth.

### Documentation

- **M9: `docs/GUIDE.md` configuration tables now match the actual `config.rs`
  schema**. Added previously-missing `ServerConfig` rows
  (`session_idle_timeout`, `sse_keep_alive`, `public_url`,
  `compression_enabled`, `compression_min_size`, `max_concurrent_requests`,
  `admin_enabled`, `admin_role`, `auth`), the `ObservabilityConfig`
  `log_request_headers` row, and the `OAuthConfig`
  `audience_validation_mode` row. The `stdio_enabled` row now warns that
  stdio bypasses auth/RBAC/TLS/Origin checks. The
  `strict_audience_validation` row is marked **Deprecated since 1.7.0**
  with the resolution semantics documented; the "new deployments"
  recommendation snippet now uses `audience_validation_mode = "strict"`.
- **M10: crate-level rustdoc on `src/lib.rs` expanded** with a runnable
  `no_run` quick-start example, a feature-flag overview (`oauth`,
  `metrics`, `test-helpers`), and a prominent security warning for
  `transport::serve_stdio` (which bypasses auth, RBAC, TLS, Origin
  validation, and rate limiting).

## [1.6.0] - 2026-05-13

### Security

- **Fail-closed RFC 3339 validation for API key `expires_at`** (`src/auth.rs`).
  Previously, a malformed `expires_at` string in the API key TOML file was
  silently treated as "never expires" because `chrono::DateTime::parse_from_rfc3339`
  errors inside `verify_bearer_token` were discarded. An operator who
  mistyped (e.g. `"2026-01-01"` instead of `"2026-01-01T00:00:00Z"`) would
  unknowingly ship a non-expiring key. Expiry strings are now parsed and
  validated **at TOML deserialization time** via a new `RfcTimestamp`
  newtype: any malformed value rejects server startup (or hot-reload) with
  a clear error pointing at the offending key. `verify_bearer_token` no
  longer needs to parse strings on the hot path.

### Changed (BREAKING — source compatibility)

> Shipped as **1.6.0** by maintainer policy: the only known downstream
> consumer (`atlassian-mcp-rs`, same maintainer) does not touch the
> affected API surface. `cargo-semver-checks` is temporarily disabled in
> CI with a `FIXME(H3-fix, 2026-05-13)` marker; re-enable on the next
> release with no public-API breaks.

- `ApiKeyEntry::expires_at` is now `Option<RfcTimestamp>` (was
  `Option<String>`).
- `ApiKeySummary::expires_at` is now `Option<RfcTimestamp>` (was
  `Option<String>`).
- `ApiKeyEntry::with_expiry` now takes `RfcTimestamp` (was
  `impl Into<String>`). For string input use the new
  `ApiKeyEntry::try_with_expiry(impl AsRef<str>) -> Result<Self, chrono::ParseError>`.
- `RfcTimestamp` (`Copy`) is now part of the public API in `src/auth.rs`;
  its on-the-wire form is `chrono`'s canonical RFC 3339 with `+00:00`
  (not `Z`) for UTC.

### Added

- **`RfcTimestamp` newtype** in `src/auth.rs` wrapping
  `chrono::DateTime<chrono::FixedOffset>` with a fail-closed `Deserialize`,
  `Display`/`Debug` via `to_rfc3339`, `parse`, `as_datetime`, and
  `into_inner`.
- **Mutation-coverage tests** for `glob_match` / `match_middle` boundary
  cases in `src/rbac.rs` and for `RbacPolicy::argument_allowed` glob-tool
  matching, killing five surviving mutants surfaced by the nightly
  `cargo mutants` job. Each test is annotated with the specific mutation
  it kills so the intent survives future refactors.
- **Exact-string contract tests** for `AuthFailureClass::as_str`,
  `response_body`, and `bearer_error` in `src/auth.rs`. These literals
  are part of the observable wire/log surface (metric labels, audit-log
  fields, OAuth `WWW-Authenticate` reasons); the tests pin them so a
  silent change becomes a test failure.
- **Boolean-flag contract tests** for `AuthConfig::summary` in
  `src/auth.rs`, asserting `bearer` is `true` iff `api_keys` is
  non-empty (kills the surviving `!`-deletion mutant at line 615) and
  pinning `enabled` / `mtls` / `oauth` propagation.
- **`RfcTimestamp` regression suite** (`src/auth.rs`) — eight tests covering
  malformed/valid parse, TOML deserialization fail-closed behavior,
  `try_with_expiry`, and `ApiKeySummary` JSON serialization wire format.

## [1.5.0] - 2026-04-29

### Added

- **Configurable security headers** (`src/transport.rs`) -- new
  `SecurityHeadersConfig` struct and `McpServerConfig::with_security_headers`
  builder method allow operators to override or omit any of the twelve
  OWASP security headers emitted by `security_headers_middleware`. Each
  field is `Option<String>` with a three-state semantic: `None` keeps the
  default, `Some("")` omits the header entirely, and `Some(value)` overrides.
  Non-empty values are validated via `HeaderValue::from_str` inside
  `McpServerConfig::validate()`; invalid values fail server startup. The
  `Strict-Transport-Security` field additionally rejects any value containing
  `preload` (case-insensitive) -- HSTS preload-list opt-in must be made via
  a dedicated future builder, not smuggled through this knob. Existing
  defaults are unchanged; this is a purely additive API surface change.

### Fixed

- **OAuth proxy** (`src/transport.rs`) -- `/token`, `/register`, `/introspect`,
  and `/revoke` responses now include `Pragma: no-cache` and
  `Vary: Authorization`, completing RFC 6749 §5.1 / RFC 6750 §5.4 compliance
  for OAuth proxy deployments. `Cache-Control: no-store` was already set
  globally by `security_headers_middleware`; this patch fills the remaining
  legacy-cache and `Vary` gaps. The new `oauth_token_cache_headers_middleware`
  is feature-gated (`oauth`) and only active when `OAuthConfig.proxy` is
  configured -- resource-server-only deployments are unaffected. `Vary` is
  appended (not replaced), preserving any pre-existing `Vary` value (e.g.
  `Accept-Encoding` from the compression layer).

## [1.4.1] - 2026-04-24

Patch release fixing a tokenization bug in `RbacPolicy::argument_allowed`
that prevented allowlist entries containing spaces from ever matching,
and tightening fail-closed handling of malformed shell input.

### Security

- **`Cargo.lock`** -- bump transitive `rustls-webpki` `0.103.12 -> 0.103.13`
  to pick up the fix for [RUSTSEC-2026-0104](https://rustsec.org/advisories/RUSTSEC-2026-0104).
  The advisory describes a reachable panic in
  `BorrowedCertRevocationList::from_der` /
  `OwnedCertRevocationList::from_der` when parsing a syntactically valid
  empty `BIT STRING` in the `onlySomeReasons` element of an
  `IssuingDistributionPoint` CRL extension. The panic is reachable
  before the CRL signature is verified, so any consumer that fetches
  CRLs via `mtls_revocation` would be exposed; consumers that do not
  use CRLs are unaffected. No code or API changes in this crate -- the
  fix is entirely a transitive dependency bump.

### Fixed

- **`src/rbac.rs`** -- `RbacPolicy::argument_allowed` now tokenizes
  argument values with POSIX-shell-like lexical rules (`shlex::split`)
  instead of `str::split_whitespace`. Allowlist entries containing
  spaces (e.g. `/usr/bin/my tool`) now match correctly when the value
  quotes the path per shell rules; previously they were unmatchable.
  Malformed shell syntax (unbalanced quotes, dangling escapes), empty
  `value`, and well-formed but empty first argv elements (e.g.
  `value = r#""""#`) now fail closed.

### Behavior change matrix

POSIX-shell-like tokenization is now the contract. The new behavior
diverges from `str::split_whitespace` in the cases below. We ship as a
patch because (a) the function signature is unchanged, (b) the
"now-allow" change unbreaks legitimately-quoted spaced paths, and
(c) every "now-deny" change is either malformed input or a
configuration that worked only by accident under whitespace splitting
and almost certainly diverged from the consumer's actual exec
tokenization downstream.

| Input class | 1.4.0 | 1.4.1 | Direction |
|---|---|---|---|
| Plain unquoted token (`ls`) | allow if listed | allow if listed | identical |
| Quoted path with embedded space (e.g. `"/usr/bin/my tool" --x`) | deny (broken) | allow if listed | stricter-correct |
| Unbalanced quote / dangling escape | accepts truncation | **deny** | stricter (security-positive) |
| Empty input string `""` | accepts `""` if listed | **deny** | stricter |
| Quoted empty token `r#""""#` | accepts `""` if listed | **deny** | stricter |
| Tab/newline separator | works incidentally | works per POSIX | identical in practice |
| Quoted-literal allowlist entry (e.g. `["'bash'"]` matching `'bash' -c true`) | allow | **deny** (shlex strips the surrounding quotes -> first token `bash`, not `'bash'`) | observable regression -- see operator notes |
| Backslash-literal allowlist entry (e.g. `[r"foo\bar"]`) | allow | **deny** (POSIX shlex treats `\` as escape -> first token becomes `foobar`) | observable regression -- see operator notes |
| Windows-style path allowlist entry (e.g. `[r"C:\Windows\System32\cmd.exe"]`) | allow | **deny** (POSIX shlex eats backslashes) | observable regression -- see operator notes |

### Notes for operators

- **POSIX-shell-like semantics only.** The matcher now models POSIX
  word-splitting + quote removal as performed by `shlex::split`. It
  does **not** model real shell *execution* (`FOO=1 cmd`, expansions,
  command substitution, redirections, operators) or Windows
  command-line tokenization (`CommandLineToArgvW`, `cmd.exe`,
  PowerShell). Consumers in those regimes still need their own
  validation at the boundary.
- **Backslash is an escape character** under POSIX rules. Allowlist
  entries that embed `\` (e.g. Windows-style paths) must be quoted at
  the policy boundary, expressed with forward slashes, or migrated to
  a typed pre-tokenized argument matcher in a future release.
- **Quoted literals in the allowlist** (e.g. `"'bash'"`) no longer
  match. These configurations were never sound -- they only worked
  because the old `split_whitespace` first token also retained the
  quote characters as literals, which any execve-aware consumer would
  immediately strip. Update such entries to the bare command name
  (`"bash"`) or its full path.
- **Performance:** `shlex::split` allocates a `Vec<String>` for the
  full input on every matched allowlist entry, where the previous
  implementation only walked to the first whitespace. Acceptable under
  existing request-body caps; observable on adversarial input.

### API surface

API surface unchanged: signature of `RbacPolicy::argument_allowed`
(`fn(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool`)
is preserved. `cargo semver-checks` confirms patch-level compatibility.

### Dependencies

- Added `shlex = "1.3"` (MIT/Apache-2.0, zero transitive deps). Pinned
  to `>=1.3` to stay on the post-RUSTSEC-2024-0006 line; that advisory
  affects `shlex::quote` / `shlex::join` (CVE-2024-58266), neither of
  which is consumed here.

## [1.4.0] - 2026-04-24

Minor release adding an opt-in operator allowlist for the OAuth/JWKS
post-DNS SSRF guard, so in-cluster IdPs (e.g. Keycloak resolving to
RFC1918 addresses) can be reached without disabling SSRF protection.
Defaults are unchanged (fail-closed), and cloud-metadata addresses
remain blocked regardless of allowlist contents.

### Added

- **`src/oauth.rs`** — New `OAuthSsrfAllowlist { hosts, cidrs }` type and
  `OAuthConfigBuilder::ssrf_allowlist(...)` setter. Lets operators name
  the hostnames or CIDR blocks (IPv4 and IPv6) whose otherwise-blocked
  addresses (private/loopback/link-local/CGNAT/unique-local) the
  OAuth/JWKS fetcher is allowed to reach. Hosts are case-insensitive
  exact match; CIDRs are family-strict (no IPv4-mapped-IPv6, no `/0`,
  no zone IDs, host bits must be zero). Misconfiguration is rejected at
  `OAuthConfig::validate()` and `JwksCache::new()` so deploy-time
  feedback is immediate. When non-empty, validation logs a
  `tracing::warn!` naming the host and CIDR counts.
- **`src/ssrf.rs`** — New `CompiledSsrfAllowlist` + `CidrEntry` types
  (crate-private) and `redirect_target_reason_with_allowlist` that
  consults the allowlist on per-redirect-hop literal-IP screening while
  keeping cloud-metadata unbypassable.
- **`src/ssrf.rs`** — Cloud-metadata classifier now also covers AWS
  IPv6 (`fd00:ec2::254`), GCP IPv6 (`fd20:ce::254`), and the
  Alibaba/Tencent IPv4 metadata address (`100.100.100.200`). These
  addresses are classified as `cloud_metadata` *before* the generic
  `unique_local` / `cgnat` buckets so an operator allowlist for
  `fd00::/8` or `100.64.0.0/10` cannot silently re-allow them.

### Security

- **`src/oauth.rs`** — Cloud-metadata IPv4 (`169.254.169.254`,
  `100.100.100.200`) and IPv6 (`fd00:ec2::254`, `fd20:ce::254`) are
  now explicitly carved out of the operator allowlist path: even when
  an operator allowlists a containing CIDR, addresses classified as
  `cloud_metadata` continue to use the strict legacy error message and
  are never permitted. New unit tests pin this invariant
  (`redirect_with_fd00_8_allowlist_still_blocks_aws_v6_metadata`,
  `redirect_with_cgnat_allowlist_still_blocks_alibaba_metadata`).
- **`src/oauth.rs`** — Empty (default) allowlist preserves the
  pre-1.4.0 error message verbatim so existing operator runbooks and
  alerting on "OAuth target resolved to blocked IP" keep working.
  Configured allowlists that still block emit a more verbose error
  naming the hostname, the resolved IP, the block reason, and the two
  config fields the operator can edit.

### Changed

- **`src/oauth.rs`** — `evaluate_oauth_redirect`,
  `screen_oauth_target`, and `screen_oauth_target_with_test_override`
  now take a `&CompiledSsrfAllowlist` parameter. These are private
  helpers; no downstream impact.

### Documentation

- **`docs/GUIDE.md`** — New "Allowing in-cluster IdPs" subsection in the
  OAuth chapter showing the recommended TOML and builder snippets.
- **`SECURITY.md`** — New "Operator allowlist" subsection under OAuth
  SSRF hardening documenting the trust model, the cloud-metadata
  carve-out, and the auditing expectations.

## [1.3.2] - 2026-04-21

Security and quality patch release rolling up the post-1.3.1 multi-agent
review findings. No breaking changes; drop-in replacement for `1.3.1`.

### Security

- **`src/auth.rs`** — Bearer-scheme parsing in the auth middleware is now case-insensitive per RFC 7235 §2.1 (e.g. `bearer …` and `BEARER …` are accepted alongside `Bearer …`). Previously these were silently rejected as `invalid_credential` and counted toward the auth-failure rate limit, which could cause spurious lockouts for spec-conformant clients.
- **`src/auth.rs`** — `AuthIdentity` and `ApiKeyEntry` now have manual `Debug` implementations that redact the raw bearer token, the JWT `sub` claim, and the Argon2id hash. This prevents secret material from leaking via `format!("{:?}", …)` or `tracing::debug!(?identity, …)` calls, and is enforced by new unit tests.
- **`src/oauth.rs`** — Added post-DNS SSRF screening for the initial OAuth/JWKS request target so hostnames resolving to blocked IP ranges are rejected before connect, mirroring CRL fetch hardening.
- **`src/oauth.rs`** — Added opt-in `strict_audience_validation` so operators can disable the legacy `azp` fallback and enforce `aud`-only audience checks for new deployments.
- **`src/transport.rs` / `src/oauth.rs`** — Added opt-in `require_auth_on_admin_endpoints` so OAuth `/introspect` and `/revoke` can be mounted behind the normal auth middleware while preserving legacy behavior by default.
- **`src/rbac.rs`** — RBAC and tool rate limiting now inspect JSON-RPC batch arrays and reject the full batch if any `tools/call` entry is denied.
- **`src/oauth.rs`** — Added `jwks_max_response_bytes` (default 1 MiB) and streaming JWKS reads so oversized responses are refused without unbounded allocation.

### Changed

- **`src/metrics.rs`** — `http_request_duration_seconds` now uses an explicit, latency-tuned bucket set (`[1ms, 5ms, 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2.5s, 5s]`) instead of the Prometheus default buckets, which were skewed toward web-page rather than RPC latency. The histogram name and labels are unchanged; existing dashboards keep working but will gain finer sub-100 ms resolution.
- **`src/tool_hooks.rs`** — `with_hooks` now documents that dropping the returned wrapper silently loses the configured hooks. The natural `#[must_use]` enforcement is deferred to the next minor-version bump (adding `#[must_use]` to a public function is a SemVer-minor change per cargo-semver-checks).
- **`README.md`** — Quick-start dependency line dropped the gratuitous `features = ["oauth"]` so a copy-paste install no longer pulls in OAuth, `jsonwebtoken`, and `reqwest` for users who only need the default transport. Optional features are now described in a separate note pointing at the Cargo features table.

### Documentation

- **`docs/ARCHITECTURE.md` / `docs/MINDMAP.md`** — Refreshed mTLS sections to match the current per-connection `TlsConnInfo` design (the previous text described the long-removed `RwLock<HashMap<SocketAddr, AuthIdentity>>` map).
- **`docs/ARCHITECTURE.md`** — Metrics section now lists only the metrics actually exported by `src/metrics.rs` (`http_requests_total`, `http_request_duration_seconds`) and points operators at `McpMetrics::registry` for custom collectors. The previous list named gauges and counters that were never implemented.

## [1.3.1] - 2026-04-21

First usable release of `rmcp-server-kit`. A reusable, production-grade
framework for building [Model Context Protocol](https://modelcontextprotocol.io/)
servers in Rust on top of the official `rmcp` SDK.

Consumers supply an `rmcp::handler::server::ServerHandler` implementation;
this crate provides Streamable HTTP transport, TLS / mTLS, structured
authentication (API key, mTLS, OAuth 2.1 JWT), RBAC with per-tool
argument allowlists, per-IP rate limiting, OWASP security headers,
structured observability, optional Prometheus metrics, admin
diagnostics, and graceful shutdown.

### Highlights

- **Transport** — Streamable HTTP (`/mcp`), `/healthz`, `/readyz`,
  `/version`, admin diagnostics, graceful shutdown, configurable TLS
  and mTLS. Optional `serve_stdio()` for local subprocess MCP.
- **Authentication** — API-key (Argon2id-hashed, constant-time verify),
  mTLS client certificates with subject→role mapping, OAuth 2.1 JWT
  validation against JWKS (feature `oauth`). Pre-auth rate limiting
  defends Argon2id against CPU-spray attacks.
- **mTLS revocation** — CDP-driven CRL fetching with bounded memory,
  bounded concurrency, and bounded discovery rate. Auto-discovers CRL
  URLs from the CA chain at startup and from connecting client certs
  during handshakes. Hot-reloadable via `ReloadHandle::refresh_crls()`.
- **RBAC** — `RbacPolicy` with default-deny, per-role allow/deny tool
  lists (glob-supported), per-tool argument allowlists, HMAC-SHA256
  argument-value redaction in deny logs, task-local accessors
  (`current_role`, `current_identity`, `current_token`, `current_sub`).
- **OAuth 2.1** — JWKS cache with refresh cooldown, configurable allowed
  algorithms (RS256/ES256 default; symmetric keys rejected), HTTPS-only
  redirect policy, custom CA support, optional OAuth proxy endpoints
  (`/authorize`, `/token`, `/register`, `/introspect`, `/revoke`).
- **SSRF hardening** — Validate-time literal-IP / userinfo rejection on
  every operator-supplied URL plus a runtime per-hop IP-range guard on
  every redirect closure (CRL, JWKS, OAuth admin traffic). Blocks
  private, loopback, link-local, multicast, broadcast, and cloud-
  metadata ranges.
- **Hardening defaults** — Per-IP token-bucket rate limiting (governor)
  with memory-bounded LRU eviction, request-body cap (default 1 MiB),
  request-timeout cap, OWASP security headers (HSTS, CSP, X-Frame-
  Options, etc.), configurable CORS and Host allow-lists, JWKS key cap
  (default 256), CRL response-body cap (default 5 MiB).
- **Hot reload** — Lock-free `arc-swap`-backed reload of API keys,
  RBAC policy, and CRL set without dropping in-flight requests.
- **Tool hooks** — Opt-in `HookedHandler` wrapping `ServerHandler` with
  async `before_call` / `after_call` hooks. After-hooks run on a
  spawned task with the parent span and RBAC task-locals re-installed.
  Configurable `max_result_bytes` cap.
- **Observability** — `tracing-subscriber` initialization with
  `EnvFilter`, JSON or pretty console output, optional audit-file
  sink. Sensitive values wrapped in `secrecy::SecretString` end-to-end.
- **Metrics** (feature `metrics`) — Prometheus registry served on a
  separate listener (request count, duration histogram, in-flight
  gauge, auth failures, RBAC denies).
- **Configuration** — Programmatic builder API on `McpServerConfig`
  with compile-time `Validated<T>` typestate, plus matching TOML
  schema in `src/config.rs`.

### Cargo features

- `oauth` (default off) — OAuth 2.1 JWT validation via JWKS plus
  optional OAuth proxy endpoints.
- `metrics` (default off) — Prometheus registry and `/metrics` endpoint.
- `test-helpers` (default off) — opt-in test-only constructors used by
  downstream integration suites; not part of the stable API surface.

### Minimum supported Rust

`rmcp-server-kit` targets stable Rust **1.95** or newer (`edition = "2024"`).

### Documentation

- [`README.md`](README.md) — quick start.
- [`docs/GUIDE.md`](docs/GUIDE.md) — end-to-end consumer guide and TOML schema.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — file-cited deep architecture map.
- [`docs/MINDMAP.md`](docs/MINDMAP.md) — visual project mindmap.
- [`AGENTS.md`](AGENTS.md) — repository navigation hub for AI agents.
- [`SECURITY.md`](SECURITY.md) — coordinated disclosure policy and
  hardening posture.
