# rmcp-server-kit -- MCP Server Framework for Rust
A production-grade, reusable framework for building
[Model Context Protocol](https://modelcontextprotocol.io/) servers in Rust.
Provides Streamable HTTP transport with TLS/mTLS, structured observability,
authentication (Bearer / mTLS / OAuth 2.1 JWT), role-based access control
(RBAC), per-IP rate limiting, and Prometheus metrics -- all wired up and
ready to go.
You supply a `ServerHandler` implementation; rmcp-server-kit handles everything else.
---
## Table of Contents
- [Quick Start](#quick-start)
- [Cargo Features](#cargo-features)
- [Architecture Overview](#architecture-overview)
- [Module Reference](#module-reference)
- [transport](#transport) -- HTTP server, TLS, health endpoints
- [auth](#auth) -- Authentication middleware
- [rbac](#rbac) -- Role-based access control
- [config](#config) -- Server and observability configuration
- [error](#error) -- Error types
- [observability](#observability) -- Tracing and logging
- [cancel](#cancel) -- Cancel-safe detach helper for tool handlers
- [oauth](#oauth) -- OAuth 2.1 JWT validation (feature-gated)
- [metrics](#metrics) -- Prometheus metrics (feature-gated)
- [Additional Built-in Endpoints and Features](#additional-built-in-endpoints-and-features)
- [Full Example: Building a Custom MCP Server](#full-example-building-a-custom-mcp-server)
- [Client Usage Guide](#client-usage-guide)
- [Recipes](#recipes)
- [Configuration via TOML](#configuration-via-toml)
- [Testing Your Server](#testing-your-server)
---
## Quick Start
Add rmcp-server-kit to your `Cargo.toml`:
```toml,cargo
[dependencies]
rmcp-server-kit = { version = "3", features = ["oauth"] }
rmcp = { version = "3", features = ["server", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
```
Implement `ServerHandler` and call `serve()`:
```rust
use rmcp_server_kit::{
config::ObservabilityConfig,
observability::init_tracing_from_config_strict,
transport::{McpServerConfig, serve},
};
use rmcp::handler::server::ServerHandler;
use rmcp::model::{ServerCapabilities, ServerConfig};
#[derive(Clone)]
struct MyHandler;
impl ServerHandler for MyHandler {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
}
}
#[tokio::main]
async fn main() -> rmcp_server_kit::Result<()> {
let mut observability = ObservabilityConfig::default();
observability.log_level = "info,my_server=debug".into();
let _tracing_guard = init_tracing_from_config_strict(&observability)?;
let config = McpServerConfig::new("127.0.0.1:8080", "my-server", "0.1.0")
.with_request_timeout(std::time::Duration::from_secs(30))
.enable_request_header_logging();
serve(config.validate()?, || MyHandler).await
}
```
This gives you `/healthz`, `/readyz`, and `/mcp` endpoints out of the box.
---
## Cargo Features
| Feature | Default | Description |
|-----------|---------|-------------|
| `oauth` | No | OAuth 2.1 JWT validation via JWKS. Adds `jsonwebtoken` and `reqwest`. |
| `oauth-mtls-client` | No | RFC 8705 §2 mTLS client authentication for the OAuth token-exchange endpoint. Implies `oauth`. Without this feature, configurations that set `TokenExchangeConfig::client_cert` are rejected at startup by `OAuthConfig::validate`. See Recipe 2 for usage. |
| `metrics` | No | Prometheus metrics endpoint on a separate listener. Adds `prometheus`. |
| `test-helpers` | No | Exposes test-only helpers from `mtls_revocation` and, when `oauth` is also enabled, `oauth`, for downstream integration tests. **Not part of the stable API surface** -- no semver guarantees across minor releases. **never enable in a production build:** some helpers deliberately bypass SSRF screening, the JWKS refresh cooldown, the CDP discovery rate limiter, and CRL verifier publication. |
Enable in `Cargo.toml`:
```toml,cargo
rmcp-server-kit = { version = "1", features = ["oauth", "metrics"] }
```
---
## Architecture Overview
```
+-----------+
| Your App | (bin crate)
| |
| MyHandler |---implements---> rmcp::ServerHandler
+-----+-----+
|
| depends on
v
+-----------------+
| rmcp-server-kit | (lib crate)
| |
| transport | Streamable HTTP + TLS/mTLS
| auth | Bearer, mTLS, OAuth JWT
| rbac | Role-based access control
| config | Server/observability config
| error | RmcpServerKitError -> HTTP status codes
| metrics | Prometheus (optional)
| oauth | JWT/JWKS validation (optional)
+-----------------+
|
| uses
v
+-----------+
| rmcp | Official MCP SDK
| axum | HTTP framework
| rustls | TLS
| governor | Rate limiting
| argon2 | Password hashing
+-----------+
```
**Key design rule:** rmcp-server-kit is generic. It has zero knowledge of your domain
(Podman, Docker, databases, etc.). Your crate supplies the `ServerHandler`;
rmcp-server-kit supplies the server infrastructure.
---
## Module Reference
### transport
The core module. Provides `serve()` which starts the full HTTP server stack.
#### `McpServerConfig`
Server configuration. All fields have safe defaults except `bind_addr`,
`name`, and `version`.
```rust
use rmcp_server_kit::transport::McpServerConfig;
use std::time::Duration;
// Builder style (recommended): chain `with_*` / `enable_*` methods.
let config = McpServerConfig::new("0.0.0.0:8443", "my-server", "1.0.0")
// Optional: TLS (enables HTTPS)
.with_tls("/etc/certs/server.crt", "/etc/certs/server.key")
// Optional: DNS rebinding protection (MCP spec requirement)
.with_allowed_origins([
"http://localhost:3000",
"https://myapp.example.com",
])
// Optional: request limits
.with_max_request_body(2 * 1024 * 1024) // 2 MiB
.with_request_timeout(Duration::from_secs(60))
.with_shutdown_timeout(Duration::from_secs(10))
// Optional: per-IP tool rate limiting (calls/minute)
.with_tool_rate_limit(60);
// Validate eagerly to surface misconfiguration before binding.
// `serve()` and `serve_with_listener()` also call this internally.
config.validate().expect("config valid");
```
> **Note**: Direct field assignment on `McpServerConfig` is still
> supported (the struct fields remain `pub`), but the builder is the
> recommended path because it is `#[must_use]`, chainable, and routes
> through `validate()` automatically when passed to `serve()`.
##### Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `bind_addr` | `String` | (required) | Socket address, e.g. `"0.0.0.0:8443"` |
| `name` | `String` | (required) | Server name, returned in `/healthz` |
| `version` | `String` | (required) | Server version, returned in `/healthz` |
| `tls_cert_path` | `Option<PathBuf>` | `None` | PEM certificate for TLS |
| `tls_key_path` | `Option<PathBuf>` | `None` | PEM private key for TLS |
| `tls_handshake_timeout` | `Duration` | `10s` | Per-handshake deadline on the TLS accept path (startup-only) |
| `max_concurrent_tls_handshakes` | `usize` | `256` | Cap on in-flight TLS handshakes (startup-only) |
| `auth` | `Option<AuthConfig>` | `None` | Authentication config |
| `rbac` | `Option<Arc<RbacPolicy>>` | `None` | RBAC enforcement policy |
| `tool_list_filtering` | `bool` | `true` | Hide RBAC-denied tools from `tools/list` when RBAC and a caller role are active |
| `allowed_origins` | `Vec<String>` | `[]` | Allowed Origin header values |
| `tool_rate_limit` | `Option<u32>` | `None` | Max tool calls/min per IP |
| `session_binding` | `bool` | `true` | Statelessly bind MCP session IDs to the authenticated identity |
| `session_binding_secret` | `Option<SecretString>` | `None` | Shared HMAC secret for cross-instance session binding; also used by `task_binding` |
| `task_binding` | `bool` | `false` | Bind MCP task IDs (SEP-2663) to the authenticated identity that created them |
| `session_store` | `Option<Arc<dyn SessionStore>>` | `None` | Programmatic external rmcp session store; no TOML field |
| `event_store` | `Option<Arc<dyn EventStore>>` | `None` | Programmatic external rmcp event store for durable `Last-Event-ID` replay; no TOML field |
| `readiness_check` | `Option<ReadinessCheck>` | `None` | Custom `/readyz` probe |
| `max_request_body` | `usize` | `1 MiB` | Max request body bytes |
| `request_timeout` | `Duration` | `120s` | Per-request timeout (408) |
| `shutdown_timeout` | `Duration` | `30s` | Graceful shutdown window |
| `metrics_enabled` | `bool` | `false` | Enable Prometheus (feature: `metrics`) |
| `metrics_bind` | `String` | `"127.0.0.1:9090"` | Metrics listener (feature: `metrics`) |
#### `serve()`
```rust
pub async fn serve<H, F>(config: McpServerConfig, handler_factory: F) -> rmcp_server_kit::Result<()>
where
H: ServerHandler + 'static,
F: Fn() -> H + Send + Sync + Clone + 'static,
```
Starts the HTTP server. The `handler_factory` is a closure that creates a
fresh handler for each MCP session. The server:
- Binds TCP (or TLS when cert/key provided)
- Registers `/healthz` (always 200), `/readyz` (custom or mirrors healthz),
`/mcp` (MCP Streamable HTTP endpoint)
- Applies middleware layers: Origin validation -> Auth -> RBAC + tool
rate-limit -> session binding -> Request timeout -> Body size limit
- Listens for SIGTERM/SIGINT for graceful shutdown
- Cancels active MCP sessions on shutdown
#### `ReadinessCheck`
Custom readiness probe for `/readyz`:
```rust
use rmcp_server_kit::transport::ReadinessCheck;
use std::sync::Arc;
let check: ReadinessCheck = Arc::new(|| {
Box::pin(async {
let db_ok = check_database().await;
serde_json::json!({
"ready": db_ok,
"database": if db_ok { "connected" } else { "unreachable" }
})
})
});
config.readiness_check = Some(check);
```
When the returned JSON has `"ready": false`, `/readyz` returns HTTP 503.
#### Health Endpoints
Both endpoints return JSON:
```
GET /healthz -> 200 {"status":"ok"}
GET /readyz -> 200 {"ready":true,...} or 503 {"ready":false,"reason":"..."}
```
---
### auth
Authentication middleware supporting three methods (tried in priority order):
1. **mTLS client certificates** -- extracted during TLS handshake
2. **Bearer tokens** -- API keys verified against Argon2id hashes
3. **OAuth 2.1 JWT** -- validated against JWKS endpoint (feature: `oauth`)
#### `AuthConfig`
```rust
use rmcp_server_kit::auth::{AuthConfig, ApiKeyEntry, RateLimitConfig};
// Simple: just API keys
let auth = AuthConfig::with_keys(vec![
ApiKeyEntry::new("deploy-bot", hash, "ops"),
ApiKeyEntry::new("readonly", ro_hash, "viewer"),
]);
// With rate limiting
let auth = AuthConfig::with_keys(vec![
ApiKeyEntry::new("admin", hash, "admin"),
])
.with_rate_limit(RateLimitConfig::new(30));
```
##### Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | `bool` | `false` | Master switch (`with_keys()` sets true) |
| `api_keys` | `Vec<ApiKeyEntry>` | `[]` | Bearer token API keys |
| `mtls` | `Option<MtlsConfig>` | `None` | mTLS client cert config |
| `rate_limit` | `Option<RateLimitConfig>` | `None` | Auth attempt rate limit |
| `oauth` | `Option<OAuthConfig>` | `None` | OAuth 2.1 (feature: `oauth`) |
##### Constructors
| Method | Description |
|--------|-------------|
| `AuthConfig::default()` | Disabled (no auth enforced) |
| `AuthConfig::with_keys(keys)` | Enabled with API keys |
| `.with_rate_limit(config)` | Builder: attach rate limiting |
#### `ApiKeyEntry`
Represents a single API key. The `hash` field stores an Argon2id PHC string.
```rust
use rmcp_server_kit::auth::{generate_api_key, ApiKeyEntry};
// Generate a new key pair (returns Result<_, RmcpServerKitError>)
let (plaintext_token, argon2id_hash) = generate_api_key()?;
// plaintext_token: 43-char base64url string (give to client)
// argon2id_hash: PHC format string (store in config)
let key = ApiKeyEntry::new("my-key", argon2id_hash, "ops");
// With expiry
let key = ApiKeyEntry::new("temp-key", hash, "viewer")
.with_expiry("2025-12-31T23:59:59Z");
```
#### `RateLimitConfig`
Per-source-IP rate limiting for authentication. rmcp-server-kit uses two independent
token-bucket limiters keyed by source IP:
1. **Pre-auth abuse gate** (`pre_auth_max_per_minute`, optional): consulted
*before* any password-hash work runs. Throttles unauthenticated traffic
from a single source IP so an attacker cannot pin the CPU on Argon2id by
spraying invalid bearer tokens. Defaults to **10x** the post-failure
quota when unset, and is disabled entirely if the wrapping
`RateLimitConfig` is itself absent. mTLS-authenticated connections
bypass this gate entirely (the TLS handshake already performed
expensive crypto with a verified peer, so the CPU-spray vector does
not apply).
2. **Post-failure backoff** (`max_attempts_per_minute`, required):
consulted *after* an authentication attempt fails. Provides explicit
backpressure on bad credentials.
```rust
use rmcp_server_kit::auth::RateLimitConfig;
// Default: 30 failed attempts/min and ~300 unauthenticated requests/min
// (10x default) per source IP.
let rate_limit = RateLimitConfig::new(30);
// Tighter pre-auth gate, e.g. for a public-facing instance:
let rate_limit = RateLimitConfig::new(30).with_pre_auth_max_per_minute(60);
```
When exceeded, the middleware returns HTTP 429 Too Many Requests.
##### Parameters
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `max_attempts_per_minute` | `u32` | `30` | Max failed auth attempts per source IP per minute. Successful authentications do not consume this budget. |
| `pre_auth_max_per_minute` | `Option<u32>` | `None` (defaults to `max_attempts_per_minute × 10`) | Max unauthenticated requests per source IP per minute admitted to the password-hash path. mTLS callers bypass this gate entirely. |
| `max_tracked_keys` | `usize` | `10_000` | Hard cap on distinct source IPs tracked per limiter. When the cap is reached, idle entries are pruned first; if still full, the LRU entry is evicted. Bounds memory under IP-spray attacks. |
| `idle_eviction` | humantime duration | `"15m"` | Per-IP entries idle longer than this duration are eligible for opportunistic pruning. |
| `burst` | `Option<u32>` | `None` (= rate) | Burst capacity for the post-failure limiter. Must be greater than zero when set. |
| `pre_auth_burst` | `Option<u32>` | `None` (= gate rate) | Burst capacity for the pre-auth gate. Valid even when `pre_auth_max_per_minute` is not explicitly set. |
#### `generate_api_key()`
```rust
pub fn generate_api_key() -> Result<(String, String), RmcpServerKitError>
```
Returns `Ok((plaintext_token, argon2id_hash))`. The token is 256-bit random,
base64url-encoded (43 characters). Store the hash in your config file; give
the plaintext token to the client. The `Result` accommodates the rare case
where the OS RNG fails.
#### `AuthIdentity`
Populated by the auth middleware in request extensions upon successful
authentication. Available to your handler via `current_role()` and
`current_identity()` (see rbac module).
```rust
pub struct AuthIdentity {
pub name: String, // e.g. "deploy-bot" or mTLS CN
pub role: String, // e.g. "ops", "viewer", "admin"
pub method: AuthMethod, // BearerToken, MtlsCertificate, OAuthJwt
}
```
#### `AuthMethod`
```rust
pub enum AuthMethod {
BearerToken,
MtlsCertificate,
OAuthJwt,
}
```
#### `MtlsConfig`
For mutual TLS client certificate authentication:
```toml
# In your TOML config:
[server.auth.mtls]
ca_cert_path = "/etc/certs/client-ca.pem"
required = true
default_role = "operator"
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `ca_cert_path` | `PathBuf` | (required) | CA cert(s) for client cert verification |
| `required` | `bool` | `false` | If true, clients MUST present a cert |
| `default_role` | `String` | `"viewer"` | RBAC role for mTLS-authenticated clients |
#### `extract_mtls_identity()`
```rust
pub fn extract_mtls_identity(cert_der: &[u8], default_role: &str) -> Option<AuthIdentity>
```
Parses an X.509 DER certificate and extracts the identity name: the first
non-blank Common Name (CN), else the first non-blank DNS SAN. A blank (empty
or whitespace-only) CN is treated as absent rather than shadowing a usable
SAN, and a certificate yielding no non-blank CN or SAN is rejected. Used
internally by the TLS acceptor.
#### Certificate lifecycle and revocation (operator runbook)
> ✅ **rmcp-server-kit performs CDP-driven CRL revocation
> checking for client certificates by default whenever `[mtls]` is
> configured.** OCSP is **not** implemented. See
> [SECURITY.md](../SECURITY.md#certificate-revocation) for the full
> threat model.
CRL URLs are auto-discovered from the X.509 **CRL Distribution Points**
(CDP) extension on the configured CA chain (eagerly at startup, with a
10-second total bootstrap deadline) and from each new client certificate
observed during a TLS handshake (lazily). CRLs are cached in memory keyed
by URL and refreshed on a background task before `nextUpdate`, clamped to
`[10 min, 24 h]`. The underlying `rustls::ClientCertVerifier` is hot-swapped
via `ArcSwap` whenever fresh CRLs land, so handshakes always see the
latest revocation data without dropping in-flight connections.
**Default behaviour is fail-closed** (since 3.8): if a certificate advertises
CRL distribution points and *none* of them is cached or fetchable, the
handshake is rejected, per RFC 5280 §6.3. Denial requires every relevant CDP
to be unavailable, so an attacker who blocks a single mirror cannot deny
service. Expired CRLs are not trusted when `crl_enforce_expiration = true`
(the default); webpki rejects them at `nextUpdate`. Operators who need the
previous fail-open behaviour - where an unfetchable CRL still permits the
handshake with a `WARN` log - can set `crl_deny_on_unavailable = false`, at
the cost of accepting a revoked certificate whenever its CRL is unreachable.
> **Upgrading to 3.8:** fail-closed makes a low `crl_max_cache_entries`
> operationally visible. A CDP that fetches successfully can still be
> rejected by the cache cap, leaving those handshakes denied. Raise
> `crl_max_cache_entries` for large PKIs, or opt out explicitly.
**A certificate advertising more than 64 distinct CDP URLs is rejected as
malformed** (since 3.8). Every step of CDP handling is linear in that
peer-chosen count, so an unbounded count is an amplification lever on the
unauthenticated handshake path. RFC 5280 4.2.1.13 treats multiple URIs inside
one distribution point as mirrors of the *same* CRL, so a conforming
certificate needs only a handful.
This one is **not** governed by `crl_deny_on_unavailable`: it applies in
fail-open mode too and has no opt-out, because the same cost is paid either
way. It is a malformed-certificate rejection, not a revocation-status denial.
Its observable signature is a throttled `crl_cdp_url_cap_exceeded` WARN naming
the observed count and the cap.
**Mutating the CRL cache out of band is detected and denies handshakes**
(since 3.8). `CrlSet::cache` is a public field (deprecated in 3.8, private in
4.0); writing through it bypasses the atomic commit path and would otherwise
leave the server claiming revocation coverage its verifier cannot enforce.
Such a write now fails closed with a throttled
`crl_cache_out_of_band_mutation` WARN. Reading the field remains safe. This
detects API misuse, not a same-process adversary -- see
[SECURITY.md](../SECURITY.md#out-of-band-crl-cache-mutation).
`ReloadHandle::refresh_crls()` forces an immediate refresh of every
cached CRL - useful from an admin endpoint or a cron-driven probe.
##### CRL configuration (TOML, all defaults shown)
```toml
[server.auth.mtls]
ca_cert_path = "/etc/certs/clients-ca.pem"
crl_enabled = true # set false to disable revocation entirely
crl_deny_on_unavailable = true # fail-closed by default (RFC 5280 6.3); set false to fail open
crl_allow_http = true # allow http:// CDP URLs (CRLs are signed by the CA)
crl_end_entity_only = false # check the full chain, not just the leaf
crl_enforce_expiration = true # reject CRLs whose nextUpdate is in the past
crl_fetch_timeout = "30s" # per-fetch HTTP timeout
crl_retry_retention = "24h" # keep failed-refresh entries for retry only; never stale use
# crl_stale_grace = "24h" # deprecated alias for crl_retry_retention
# crl_refresh_interval = "1h" # override the auto interval derived from nextUpdate
# SSRF / DoS hardening knobs (defaults shown):
crl_max_concurrent_fetches = 4 # global parallel CRL fetches across all hosts
# (per-host concurrency is hard-capped at 1)
crl_max_response_bytes = 5242880 # 5 MiB hard cap; streams aborted mid-response when exceeded
crl_discovery_rate_per_min = 60 # process-global rate limit on *new* CDP URLs admitted
# to the fetch pipeline; URLs that lose the race are
# NOT marked as seen and may retry on the next handshake
crl_max_host_semaphores = 1024 # caps unique CDP hosts tracked
crl_max_seen_urls = 4096 # caps URL-deduplication map
crl_max_cache_entries = 1024 # caps parsed CRLs held in memory
```
> **Tuning guidance.** The defaults are calibrated for a typical
> single-tenant deployment. Raise `crl_discovery_rate_per_min` when you
> expect bursts of *distinct* client identities pointing at many
> distinct CDP URLs (e.g. multi-PKI federations); leave it conservative
> when CDPs are few and stable. `crl_max_concurrent_fetches` is the global
> SSRF blast-radius bound - keep it low. Raise `crl_max_seen_urls` and
> `crl_max_cache_entries` if your PKI hierarchy is unusually deep
> or diverse.
>
> **On `crl_max_response_bytes`, raise before you lower.** A CRL larger
> than the cap is never fetched, so under the fail-closed default every
> certificate relying on it is *denied*. Real public-CA CRLs of ~9.5 MB and
> ~12 MB have been measured, and RFC 5280 specifies no maximum CRL size at
> all - the 5 MiB default is already stricter than OpenSSL (32 MiB) and
> OpenJDK (20 MiB). Lower it only if you control the issuing CA and know its
> CRLs stay small. It is **not** a lever for bounding per-handshake cost:
> that cost is independent of CRL size by design. See
> [SECURITY.md](../SECURITY.md#why-crl_max_response_bytes-stays-at-5-mib).
##### Defence-in-depth (still recommended even with CRL enabled)
CRL checking does not eliminate the value of the strategies below - combine
them for the strongest posture:
1. **Short-lived certificates (recommended).** Issue client certs with a
maximum lifetime of **24 hours or less** so that compromised
credentials expire on their own. Supported issuers:
- **[cert-manager](https://cert-manager.io/)** - Kubernetes-native
issuer; configure `Certificate.spec.duration: 24h` and
`renewBefore: 8h`. Pair with the CSI driver to deliver short-lived
certs to workload pods without restart.
- **[HashiCorp Vault PKI](https://developer.hashicorp.com/vault/docs/secrets/pki)**
- set `max_ttl` on the role to `24h` and have clients re-issue
via `vault write pki/issue/<role>` on a cron / sidecar.
- **[Smallstep `step-ca`](https://smallstep.com/docs/step-ca/)** -
configure provisioner `claims.maxTLSCertDuration: 24h`; use
`step ca renew --daemon` for hands-off rotation.
2. **CA rotation on compromise.** If a long-lived cert leaks, rotate
the issuing CA and update `mtls.ca_cert_path` in your rmcp-server-kit config.
Use `ReloadHandle::reload_*` (see `transport::ReloadHandle`) for a
zero-downtime swap.
3. **Network-layer revocation.** Block compromised client identities at
the load balancer, service mesh (Istio/Linkerd `AuthorizationPolicy`),
or WAF. This is the only mechanism with sub-second propagation.
If your PKI publishes revocation only via OCSP (no CDP), CRL checking
will not protect you. Prefer the Bearer or OAuth 2.1 JWT auth methods,
which support immediate revocation via the RFC 7009 revocation endpoint
(`oauth.revocation_endpoint`) or by deleting the API key entry and
calling `ReloadHandle::reload_auth_keys`.
#### Limiter construction (internal)
The per-source-IP auth limiters (post-failure backoff and pre-auth
gate) are built internally by `serve()` from `RateLimitConfig` - the
constructors are `pub(crate)` and not part of the public API. Configure
the limiters via the `RateLimitConfig` fields above; there is no public
constructor to call.
---
### rbac
Role-based access control with deny-overrides-allow semantics, per-tool
argument allowlists, and host-scoped visibility.
#### One role per identity
Authorization evaluates **exactly one role string per identity**. rmcp-server-kit does not
union several matched roles, and there is no role inheritance. That single string comes from
whichever mechanism authenticated the caller:
| Auth method | Role source |
|---|---|
| API key | `ApiKeyEntry.role` |
| mTLS | `MtlsConfig.default_role` (same role for every client certificate) |
| OAuth JWT | the **first matching entry in configuration order** - see [oauth](#oauth) |
The resolved string must name a role defined in `[[rbac.roles]]`. An unknown name **fails
closed**: the lookup misses and every check denies.
**If your IdP emits several role-granting claims** - say a user in both a "Jira ops" group and a
"Confluence read-only" group - the caller receives only the *first* role that matches, not the
combination. Two supported ways to grant the combined capability:
1. Define one RBAC role that grants the union, and map the claim to it.
2. Normalise the claim at the IdP so one claim value denotes the combined entitlement.
A combined role is evaluated like any other: `global_deny` and that role's own `deny` entries
subtract capability, and `allow` globbing remains governed by `allow_operation_matching`.
> `current_role()` reports the resolved role to your tool handlers, but it is **not** an
> authorization decision - enforcement happens in middleware before it is set. Use it for
> context, audit, and filtering.
#### `RbacConfig`
```rust
use rmcp_server_kit::rbac::{RbacConfig, RoleConfig, ArgumentAllowlist};
let config = RbacConfig::with_roles(vec![
// Admin: full access
RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]),
// Ops: most tools, all hosts
RoleConfig::new(
"ops",
vec!["container_*".into(), "image_*".into(), "pod_*".into()],
vec!["*".into()],
),
// Viewer: read-only, specific hosts only
RoleConfig::new(
"viewer",
vec!["container_list".into(), "container_inspect".into()],
vec!["prod-*".into()],
),
// Restricted exec: can run only safe commands.
// `new_required` also denies a call that omits `cmd` entirely; plain
// `new` would let such a call through to the handler's default.
RoleConfig::new(
"restricted",
vec!["container_exec".into()],
vec!["*".into()],
)
.with_argument_allowlists(vec![
ArgumentAllowlist::new_required(
"container_exec",
"cmd",
vec!["ls".into(), "cat".into(), "ps".into(), "df".into()],
),
]),
]);
```
##### Constructors
| Method | Description |
|--------|-------------|
| `RbacConfig::default()` | Disabled (all operations allowed) |
| `RbacConfig::with_roles(roles)` | Enabled with the given role definitions |
##### Optional fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `redaction_salt` | `Option<SecretString>` | `None` | Stable HMAC key used to redact denied argument values in deny logs. When omitted, a random per-process salt is used. See the `[rbac]` TOML example below. |
#### `RoleConfig`
A single role definition.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | `String` | (required) | Role name, matched against `ApiKeyEntry.role` |
| `description` | `Option<String>` | `None` | Human-readable description |
| `allow` | `Vec<String>` | `[]` | Allowed operations; `["*"]` = all |
| `deny` | `Vec<String>` | `[]` | Denied operations (overrides allow) |
| `hosts` | `Vec<String>` | `["*"]` | Host glob patterns |
| `argument_allowlists` | `Vec<ArgumentAllowlist>` | `[]` | Per-tool argument constraints |
##### Constructors
| Method | Description |
|--------|-------------|
| `RoleConfig::new(name, allow, hosts)` | Create with required fields |
| `.with_argument_allowlists(vec)` | Builder: attach allowlists |
**Evaluation order:** deny is checked first (deny overrides allow).
#### `ArgumentAllowlist`
Constrains specific arguments on tool calls:
```rust
let allowlist = ArgumentAllowlist::new(
"container_exec", // tool name
"cmd", // argument key
vec!["ls".into(), "cat".into()], // permitted command prefixes
);
```
When a `tools/call` request arrives for the matched tool, the middleware
extracts the argument value, takes the first whitespace-delimited token (or
`/`-basename), and checks it against the allowlist. If not found, the request
is rejected with 403.
By default this constrains the value **only when the argument is present** - a
caller that omits the key entirely passes unchecked. That is safe when the
tool's input schema already marks the argument required, but it fails open if
the handler substitutes a default for a missing value. Opt into presence
enforcement with `with_required`:
> **This default is permanent.** `required` defaults to `false` and will not
> change in a future release. Presence enforcement is opt-in by design: an
> allowlist that constrains a supplied value is a legitimate configuration, and
> flipping the default would silently convert previously-allowed traffic into
> `403`s with no compile error to warn you. Set `required = true` -- or use
> `ArgumentAllowlist::new_required` -- on every allowlist where omitting the
> argument must be rejected. A startup warning names any allowlist left in the
> fail-open form.
> **Argument validation belongs in your tool, not here.** Only your tool knows
> its parameter types, value ranges, and which option combinations are
> meaningful; declare those in its input schema and enforce them in the handler.
> An allowlist is a coarse role-scoped gate at the authorization boundary --
> defence in depth on top of that validation, never a substitute for it. It
> checks only the **first `shlex::split` word**, so an allowlist of `["ls"]`
> accepts `"ls -la; id"`: the first parsed word is `ls`, and everything after it
> is unconstrained. Object- and array-valued arguments are denied rather than
> inspected wherever an allowlist or `deny_unknown_arguments` applies, and
> basename matching is POSIX-only. If a tool pipes an allowlisted value into a
> shell, this control will not save you.
```rust
use rmcp_server_kit::rbac::ArgumentAllowlist;
let allowlist = ArgumentAllowlist::new(
"container_exec",
"cmd",
vec!["ls".into(), "cat".into()],
)
.with_required(true); // omitting `cmd` is now a 403
```
Or in TOML:
```toml
[rbac]
enabled = true
[[rbac.roles]]
name = "restricted"
allow = ["container_exec"]
hosts = ["*"]
[[rbac.roles.argument_allowlists]]
tool = "container_exec"
argument = "cmd"
allowed = ["ls", "cat"]
required = true # optional; defaults to false
deny_unknown_arguments = true # optional; defaults to false
```
With `required = true` the argument must be present **and** string-valued;
a missing key, a non-string value, or a missing `arguments` object are all
rejected with 403. Combining `required = true` with an empty `allowed` list
means "must be supplied as a string, any value accepted". Omitting `required`
preserves the previous behaviour exactly, so existing configurations are
unaffected.
`deny_unknown_arguments` closes a wider gap: by default an allowlist constrains
only the argument it names, so with just `cmd` allowlisted a call carrying
`{"cmd": "ls", "danger": true}` is admitted and `danger` reaches the handler
unreviewed. That is safe when the tool's input schema rejects unknown keys, and
fails open when it does not.
Setting it on **any** allowlist matching a `(role, tool)` pair confines the
whole tool: the permitted argument names become the union of every matching
entry's `argument`, and any other top-level key is rejected with 403.
Object- and array-valued arguments are rejected too, because there is no
nested-path allowlist to constrain their contents.
> Note the scope: the flag applies to the `(role, tool)` pair, not only to the
> entry that sets it. If the tool also takes a `host` argument for host-glob
> matching, add an allowlist entry naming `host` or strict mode will reject it.
#### `RbacPolicy`
Compiled policy for fast lookups. Built from `RbacConfig` at startup.
```rust
use rmcp_server_kit::rbac::{RbacPolicy, RbacConfig, RbacDecision};
use std::sync::Arc;
let config = RbacConfig::with_roles(vec![/* ... */]);
let policy = Arc::new(RbacPolicy::new(&config));
// Check if a role can perform an operation
assert_eq!(
policy.check_operation("admin", "container_delete"),
RbacDecision::Allow,
);
assert_eq!(
policy.check_operation("viewer", "container_delete"),
RbacDecision::Deny,
);
// Check with host
assert_eq!(
policy.check("viewer", "container_list", "prod-east"),
RbacDecision::Allow,
);
// Check argument allowlist
assert!(policy.argument_allowed("restricted", "container_exec", "cmd", "ls -la"));
assert!(!policy.argument_allowed("restricted", "container_exec", "cmd", "rm -rf /"));
// Host visibility (for filtering list results)
assert!(policy.host_visible("viewer", "prod-east"));
assert!(!policy.host_visible("viewer", "dev-west"));
```
##### Methods
| Method | Returns | Description |
|--------|---------|-------------|
| `new(config)` | `Self` | Build from `RbacConfig` |
| `disabled()` | `Self` | Always-allow policy |
| `is_enabled()` | `bool` | Whether enforcement is active |
| `check_operation(role, op)` | `RbacDecision` | Check without host |
| `check(role, op, host)` | `RbacDecision` | Check with host |
| `host_visible(role, host)` | `bool` | For list filtering |
| `host_patterns(role)` | `Option<&[String]>` | Get host patterns |
| `argument_allowed(role, tool, arg, val)` | `bool` | Check per-tool allowlists |
#### Task-Local Accessors
Inside your tool handlers, retrieve the current caller's identity:
```rust
use rmcp_server_kit::rbac::{current_role, current_identity};
fn handle_tool_call() {
if let Some(role) = current_role() {
tracing::info!(%role, "caller role");
}
if let Some(name) = current_identity() {
tracing::info!(identity = %name, "caller identity");
}
}
```
These are set by the RBAC middleware for the duration of the request.
#### RBAC-filtered `tools/list`
When RBAC is enabled and the request has an authenticated non-empty role,
rmcp-server-kit filters `tools/list` responses before they leave the server.
Each advertised tool is retained only when
`RbacPolicy::check_operation(role, tool.name) == RbacDecision::Allow`. Host
scoping is still enforced at invocation time (`tools/call`), because a tool list
is not host-specific and a tool usable on some host should remain discoverable.
Filtering is enabled by default. Opt out only if your application intentionally
advertises a superset, for example a discovery UI:
```rust
let config = McpServerConfig::new("127.0.0.1:8080", "my-server", "0.1.0")
.with_tool_list_filtering(false);
```
Or in TOML, set `tool_list_filtering = false` under `[server]`.
Whenever filtering runs, the result is returned with `cache_scope = Private`,
even if no tools were removed. In rmcp/SEP-2549, an absent `cacheScope` defaults
to public, which means an intermediary may serve a cached list to another user;
private scope prevents a cross-role tool-list leak. The inner handler's
`next_cursor` and `ttl_ms` are preserved, so empty pages with a live cursor are
valid and clients should keep paginating until `next_cursor` is absent.
RBAC hot reloads are invocation-safe because every `tools/call` checks the live
policy, and `tools/list` filtering also reads the live policy on each request.
However, a reload does **not** invalidate list responses already cached inside a
client: rmcp clients cache list responses by method and cursor and honour a
positive `ttl_ms`. Emit a low `ttl_ms` (or no `ttl_ms`) for role-sensitive lists
if you need clients to discover policy changes quickly.
#### `RbacDecision`
```rust
pub enum RbacDecision {
Allow,
Deny,
}
```
#### Tool-limiter construction (internal)
The per-source-IP `tools/call` limiter is built internally by `serve()`
from the configured rate and optional burst - the constructor is
`pub(crate)` and not part of the public API. Configure it via
`McpServerConfig::with_tool_rate_limit` /
`with_tool_rate_limit_burst` (TOML: `tool_rate_limit`,
`tool_rate_limit_burst`).
---
### config
Configuration structs for TOML-based server configuration. Useful when your
app loads config from a file rather than building `McpServerConfig`
programmatically.
#### `ServerConfig`
```toml
[server]
listen_addr = "0.0.0.0"
listen_port = 8443
tls_cert_path = "/etc/certs/server.crt"
tls_key_path = "/etc/certs/server.key"
allowed_origins = ["http://localhost:3000"]
tool_rate_limit = 120
# tool_rate_limit_burst = 240 # optional bucket capacity (default: = rate)
key_eviction_policy = "evict_lru"
extra_route_rate_limit = 60
# extra_route_rate_limit_burst = 120 # optional bucket capacity (default: = rate)
# extra_route_rate_limit_exempt_paths = ["/.well-known/oauth-authorization-server"]
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `listen_addr` | `String` | `"127.0.0.1"` | Bind address |
| `listen_port` | `u16` | `8443` | Bind port |
| `tls_cert_path` | `Option<PathBuf>` | `None` | TLS certificate path |
| `tls_key_path` | `Option<PathBuf>` | `None` | TLS private key path |
| `tls_handshake_timeout` | `String` | `"10s"` | Humantime duration; per-handshake deadline on the TLS accept path. Startup-only (not hot-reloadable) |
| `max_concurrent_tls_handshakes` | `usize` | `256` | Cap on concurrently in-flight TLS handshakes; at saturation new connections wait in the kernel backlog. Startup-only |
| `shutdown_timeout` | `String` | `"30s"` | Humantime duration |
| `request_timeout` | `String` | `"120s"` | Humantime duration |
| `allowed_origins` | `Vec<String>` | `[]` | Origin validation |
| `stdio_enabled` | `bool` | `false` | Enable stdio transport (bypasses auth/RBAC/TLS - see warning in `transport`) |
| `tool_rate_limit` | `Option<u32>` | `None` | Tool calls/min per IP |
| `tool_list_filtering` | `bool` | `true` | Filter `tools/list` through RBAC visibility when RBAC is enabled and a role is present |
| `key_eviction_policy` | `KeyEvictionPolicy` | `"evict_lru"` | Full-table policy for per-IP limiter key maps; accepted values: `"evict_lru"`, `"reject_new"` |
| `session_idle_timeout` | `String` | `"20m"` | Humantime duration; idle MCP sessions are closed after this period |
| `session_binding` | `bool` | `true` | Stateless signed wrapper for `Mcp-Session-Id`; disables cross-identity session replay. Setting `false` reinstates CWE-384 risk and should only be used behind a gateway that deliberately re-authenticates each request under different labels |
| `session_binding_secret` | `Option<SecretString>` | `None` | Shared HMAC secret for session binding across replicas; at least 32 UTF-8 bytes. Also used by `task_binding`, domain-separated so a session token can never verify as a task token. No `session_store` or `event_store` TOML field exists because stores are trait objects supplied in code |
| `task_binding` | `bool` | `false` | Stateless signed wrapper for MCP task IDs (SEP-2663), preventing one authenticated identity from reading, updating, or cancelling another's task via a leaked `taskId`. Off by default because it changes the wire format of `taskId` values; handlers keep seeing raw IDs, so only a consumer that persists the *client-visible* ID as its own key is affected. Reuses `session_binding_secret`; multi-replica deployments must share it |
| `sse_keep_alive` | `String` | `"15s"` | Humantime duration; SSE keep-alive ping interval |
| `public_url` | `Option<String>` | `None` | Externally reachable base URL (e.g. `https://mcp.example.com`); required when `listen_addr` is `0.0.0.0` behind a reverse proxy or container |
| `compression_enabled` | `bool` | `false` | Enable gzip/br response compression |
| `compression_min_size` | `u16` | `1024` | Minimum bytes before compression kicks in (only used when `compression_enabled = true`) |
| `max_concurrent_requests` | `Option<usize>` | `None` | Global cap on in-flight HTTP requests; excess receive `503` via load shedding |
| `admin_enabled` | `bool` | `false` | Enable `/admin/*` diagnostic endpoints |
| `admin_role` | `String` | `"admin"` | RBAC role required to access `/admin/*` |
| `auth` | `Option<AuthConfig>` | `None` | Inline `[server.auth]` block selecting API-key / mTLS / OAuth - see [auth](#auth) |
| `trusted_proxies` | `Vec<String>` | `[]` | CIDRs or IPs whose forwarding headers are trusted for client-IP resolution. When non-empty, enables trusted-forwarder mode. Pairs with `forwarded_header`. |
| `forwarded_header` | `String` | `"x-forwarded-for"` | Which forwarding header to read when trusted-forwarder mode is active. Accepted values: `"x-forwarded-for"` (de-facto standard; nginx, HAProxy, CDNs) or `"forwarded"` (RFC 7239 `Forwarded` header). Ignored when `trusted_proxies` is empty. |
| `trusted_forwarder_max_entries` | `usize` | `16` | Maximum forwarding-chain entries scanned per request in trusted-forwarder mode. Longer chains are treated as a header bomb and resolution falls back to the direct socket peer. Valid range `1..=64`; the ceiling exists because an unbounded value would disable the header-bomb protection. Ignored when `trusted_proxies` is empty. |
##### Choosing a `key_eviction_policy`
Per-IP rate limiters track state in a map capped by `max_tracked_keys`
(default `10_000`). `key_eviction_policy` decides what happens when that cap is
reached and a request arrives from an IP not already tracked. Both options are
safe; they trade different failure modes against each other.
| Policy | Behaviour at capacity | Favours | Cost |
|---|---|---|---|
| `"evict_lru"` (default) | Evicts the least-recently-used entry to admit the new IP | Admitting new clients | A quiet tenant can be evicted and return with a fresh quota, so a spray attacker gets a small amount of extra budget |
| `"reject_new"` | Refuses the new IP and keeps existing entries | Isolating established tenants | Genuinely new clients are turned away while the table is full |
`"evict_lru"` is the default deliberately. Under a high-cardinality spray
attack, `"reject_new"` would let the attacker fill the table and then deny
*every* new legitimate client -- turning a rate-limit control into an outage.
`"evict_lru"` keeps the service reachable and accepts a bounded quota-reset
cost instead.
Prefer `"reject_new"` only when your client population is small, known and
stable -- for example a fixed set of internal services behind a gateway --
where admitting an unknown IP is itself suspicious and quota isolation for
existing tenants matters more than reachability for new ones.
> **`reject_new` returns `503`, not `429`.** A rejection under this policy means
> the server has run out of *admission capacity* to track another client, which
> is a server-side resource condition. It is not a statement that the caller
> exceeded a quota, so `429 Too Many Requests` would be misleading and would
> invite clients to back off on a per-client schedule that cannot help. Ordinary
> per-IP quota breaches still return `429`.
#### `ObservabilityConfig`
```toml
[observability]
log_level = "debug"
log_format = "json"
audit_log_path = "/var/log/my-server/audit.log"
metrics_enabled = true
metrics_bind = "127.0.0.1:9090"
log_plaintext_oauth_tokens = false
log_oauth_claim_values = false
log_tool_call_arguments = false
log_upstream_error_bodies = false
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `log_level` | `String` | `"info"` | trace, debug, info, warn, error |
| `log_format` | `String` | `"json"` | json, pretty, or text |
| `audit_log_path` | `Option<PathBuf>` | `None` | JSON audit log file |
| `log_request_headers` | `bool` | `false` | Emit inbound HTTP request headers at DEBUG level (sensitive headers remain redacted) |
| `metrics_enabled` | `bool` | `false` | Enable Prometheus |
| `metrics_bind` | `String` | `"127.0.0.1:9090"` | Metrics listener |
| `log_plaintext_oauth_tokens` | `bool` | `false` | Defaults to redacted; enabling writes secrets to logs, is for local debugging only, and is process-wide, not per-server |
| `log_oauth_claim_values` | `bool` | `false` | Defaults to redacted; enabling writes secrets to logs, is for local debugging only, and is process-wide, not per-server |
| `log_tool_call_arguments` | `bool` | `false` | Defaults to redacted; enabling writes secrets to logs, is for local debugging only, and is process-wide, not per-server |
| `log_upstream_error_bodies` | `bool` | `false` | Defaults to redacted. Logs the `error_description` an authorization server returns on a failed RFC 8693 token exchange; that text is chosen upstream and may echo request parameters back. Process-wide, not per-server |
#### Validation
```rust
use rmcp_server_kit::config::{
ServerConfig, ObservabilityConfig,
validate_server_config, validate_observability_config,
};
let server: ServerConfig = toml::from_str(&config_str)?;
validate_server_config(&server)?; // Checks port, TLS pairing, durations
let obs: ObservabilityConfig = toml::from_str(&config_str)?;
validate_observability_config(&obs)?; // Checks log levels, formats
```
Returns `RmcpServerKitError::Config` with a descriptive message on failure.
---
### error
#### `RmcpServerKitError`
Central error type with automatic HTTP status code mapping:
```rust
#[non_exhaustive]
pub enum RmcpServerKitError {
// Client-facing: the String is rendered VERBATIM to the HTTP client.
// Construction sites must keep these free of internal detail.
Auth(String), // -> 401 Unauthorized
Rbac(String), // -> 403 Forbidden
RateLimited(String), // -> 429 Too Many Requests
RateLimitedFor { // -> 429 + Retry-After (RFC 9110 delta-seconds)
message: String,
retry_after: std::time::Duration,
},
// Internal-only: detail is logged server-side and the client receives
// a generic "internal server error" body.
Config(String), // -> 500
Io(std::io::Error), // -> 500
Json(serde_json::Error), // -> 500
Toml(toml::de::Error), // -> 500
Tls(String), // -> 500
Startup(String), // -> 500
Internal(String), // -> 500
Metrics(String), // -> 500 (feature = "metrics")
}
```
Implements `IntoResponse` for axum, so you can return `RmcpServerKitError` directly
from handlers and middleware. Use
[`client_message`](https://docs.rs/rmcp-server-kit/latest/rmcp_server_kit/error/enum.RmcpServerKitError.html#method.client_message)
to obtain the exact body a given error will send.
The enum is `#[non_exhaustive]`, so a `match` on it in downstream code must
carry a wildcard arm; new variants can therefore be added without a breaking
change.
#### `Result<T>`
```rust
pub type Result<T> = std::result::Result<T, RmcpServerKitError>;
```
---
### observability
#### `init_tracing(default_filter)`
Simple tracing initialization. Returns `Result<(), TryInitError>` so it
is safe to call from tests or embedders that may have already installed
a global subscriber:
```rust
rmcp_server_kit::observability::init_tracing("info,my_crate=debug")?;
```
Respects `RUST_LOG` environment variable (takes precedence over the default).
The `Err` variant indicates that a global tracing subscriber was already
installed; production binaries can propagate the error, while embedders
that tolerate double-initialization can ignore it (`let _ = init_tracing(..)`).
#### `init_tracing_from_config_strict(config)`
Full initialization from `ObservabilityConfig`. Returns a `TracingGuard` that
must be held for the process lifetime so the audit writer thread can keep
draining queued events. Dropping the guard signals shutdown and makes a
best-effort, time-bounded (5s) attempt to drain queued audit entries, flush the
file, and join the writer thread. Events emitted after drop are lost; if the
writer thread is blocked on a slow or stuck filesystem past the timeout, drop
returns and remaining queued entries may never reach disk.
```rust
use rmcp_server_kit::config::ObservabilityConfig;
let obs: ObservabilityConfig = toml::from_str(&config_toml)?;
let _tracing_guard = rmcp_server_kit::observability::init_tracing_from_config_strict(&obs)?;
```
Features:
- JSON or pretty-printed output
- Optional JSON audit log file (append mode, auto-creates parent dirs)
- `RUST_LOG` env var takes precedence
When `audit_log_path` is configured, strict initialization fails startup if the
file or parent directory cannot be opened. Audit writes use a bounded
non-blocking channel plus a dedicated writer thread so tracing calls on tokio
worker threads do not perform synchronous file I/O.
#### Audit-log file permissions
The audit log carries identities, and under the diagnostic switches it can carry
credential material, so it is created with owner-only permissions.
- **Unix:** owner-only (`0o600`) is applied **at file creation** via
`OpenOptions::mode`. There is no window in which the file is readable by other
local principals. A pre-existing file has its mode corrected after opening.
- **Windows:** the file is created and a protected owner-only DACL is applied
immediately afterwards, discarding inherited ACEs.
**These are not equivalent.** Rust's standard library cannot pass
`SECURITY_ATTRIBUTES` to file creation ([rust-lang/libs-team#324]), so Windows
has no safe creation-time equivalent of `mode(0o600)`. The Windows path
therefore leaves a small create-then-harden race that Unix does not have: it
removes the *persistent* exposure, not the momentary one.
If Windows ACL hardening fails, startup fails and the crate attempts to delete
the unprotected file. The error reports whether that deletion succeeded, so you
can tell whether an unprotected audit log may remain at that path.
On a platform that is neither Unix nor Windows, a configured `audit_log_path`
fails closed: startup errors and no file is created.
[rust-lang/libs-team#324]: https://github.com/rust-lang/libs-team/issues/324
The deprecated `init_tracing_from_config(config)` compatibility entry point
keeps the old fail-open audit-log behaviour and returns `Result<(), TryInitError>`.
---
### cancel
Cancel-safe detach helper for tool handlers that own remote-side
resources (SSH channels, in-flight HTTP bodies, DB transactions).
`tokio::select!` arms racing a long-running future against
`CancellationToken` or `tokio::time::sleep` drop the losing future
mid-`.await`, which leaves remote-side resources half-open until
some outer lifetime ends. The `cancel` module fixes that by
spawning the future onto its own task frame and racing the
`JoinHandle` instead: when cancel/timeout wins, the spawned task
keeps running to completion and drives its own cleanup path.
```rust,ignore
use rmcp_server_kit::cancel::{run_with_cancel_and_timeout, DetachOutcome};
use std::time::Duration;
use tokio_util::sync::CancellationToken;
# async fn handle(ct: CancellationToken, work: impl Future<Output = String> + Send + 'static) -> String {
match run_with_cancel_and_timeout(work, &ct, Some(Duration::from_secs(30))).await {
DetachOutcome::Completed(value) => value,
DetachOutcome::Cancelled => "cancelled".into(),
DetachOutcome::TimedOut => "timed out".into(),
DetachOutcome::Panicked(join_err) => format!("panicked: {join_err}"),
}
# }
```
Highlights:
- Pre-cancel short-circuit: an already-cancelled token never spawns
the future.
- Completion wins on tie under `biased;`: prevents misreporting
cancel for an operation that actually succeeded.
- Panics surface distinctly via `DetachOutcome::Panicked` rather
than folding into Cancelled/TimedOut.
- Originating tracing span is propagated into the detached task
via `.instrument(Span::current())`.
RBAC task-locals (`rbac::current_role()` and friends) are NOT
propagated into the detached future -- detached work should
finish/close already-authorized resources rather than initiate
fresh RBAC-gated operations. See the module-level `# Caveats`
rustdoc for a worked example of capturing and rebinding RBAC
context when a caller genuinely needs it.
---
### oauth
*Requires feature: `oauth`*
OAuth 2.1 JWT bearer token authentication with JWKS-based key rotation.
#### `OAuthConfig`
```toml
[server.auth.oauth]
issuer = "https://auth.example.com"
audience = "my-mcp-server"
jwks_uri = "https://auth.example.com/.well-known/jwks.json"
jwks_cache_ttl = "10m"
[[server.auth.oauth.scopes]]
scope = "mcp:admin"
role = "admin"
[[server.auth.oauth.scopes]]
scope = "mcp:read"
role = "viewer"
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `issuer` | `String` | -- | Expected `iss` claim. |
| `audience` | `String` | -- | Expected `aud` claim. |
| `jwks_uri` | `String` | -- | JWKS endpoint URL. |
| `scopes` | `Vec<ScopeMapping>` | `[]` | OAuth scope -> RBAC role mapping. |
| `jwks_cache_ttl` | `String` | `"10m"` | JWKS cache refresh interval. |
| `max_jwks_keys` | `usize` | `256` | Fail-closed cap on public keys in a JWKS document. |
| `allowed_algorithms` | `Option<Vec<String>>` | _unset_ | Pin the accepted JWT signing algorithms. When unset, the built-in set applies: `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `PS256`, `PS384`, `PS512`, `EdDSA`. When set, it must be a non-empty **subset** of that set; names are case-insensitive. This can only **narrow** the accepted algorithms -- `HS256`/`HS384`/`HS512` and `none` are never selectable, so it cannot be used to open an algorithm-confusion hole. An empty list is rejected (it would reject every token). Env: `RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ALLOWED_ALGORITHMS` (comma-separated). |
| `jwks_max_response_bytes` | `u64` | `1048576` | Fail-closed cap on the JWKS HTTP response body size (1 MiB default). |
| `allow_http_oauth_urls` | `bool` | `false` | Permit `http://` issuer/JWKS/etc. for local dev only. |
| `audience_validation_mode` | `String` (`"permissive"` \| `"warn"` \| `"strict"`) | `"strict"` | How the resource server treats the legacy `azp` audience fallback. `"strict"` (default) accepts only `aud` matches and rejects `azp`-only matches; `"warn"` accepts `azp`-only matches but emits a one-shot WARN per process to surface IdPs not populating `aud`; `"permissive"` accepts `azp`-only matches silently (pre-1.7 behavior). |
| `strict_audience_validation` | `Option<bool>` | _unset_ | **Deprecated since 1.7.0** - superseded by `audience_validation_mode`. Consulted only when `audience_validation_mode` is unset: `Some(true)` resolves to `"strict"`, `Some(false)` resolves to `"warn"`, and unset resolves to `"strict"` (the secure default). |
| `ssrf_allowlist` | `table` | _unset_ | Operator opt-in allowlist of `hosts` and/or `cidrs` whose otherwise-blocked addresses (private/loopback/CGNAT/unique-local) the OAuth/JWKS fetcher is allowed to reach. Cloud-metadata addresses remain blocked. See "Allowing in-cluster IdPs" below and the "Operator allowlist" section in [`SECURITY.md`](../SECURITY.md). |
| `role_claim` | `Option<String>` | `None` | JWT claim path (dot-notation for nested claims) to extract role values from; e.g. `"roles"` or `"realm_access.roles"`. When set, claim values are matched against `role_mappings` instead of `scopes`. Supports space-separated string claims and JSON array claims. Pairs with `role_mappings`. |
| `role_mappings` | `Vec<RoleMapping>` | `[]` | Claim-value-to-role mappings used when `role_claim` is set. First matching entry wins. See the worked example below. |
| `require_subject` | `bool` | `false` | Reject tokens that lack a `sub` (subject) claim. Leave `false` for client-credentials / machine-to-machine tokens, which legitimately carry no subject. |
| `authorization_servers` | `Option<Vec<String>>` | _unset_ (topology) | RFC 9728 Protected Resource Metadata `authorization_servers`. **Unset resolves from topology**: the upstream `issuer` when `proxy` is absent, this server's own public URL (from `public_url`) when `proxy` is set. **Set it explicitly when your application mounts its own `/authorize` + `/token` via `with_extra_router` without `proxy`** - the crate cannot detect that case and would otherwise advertise the upstream issuer at a URL that 404s. An empty list omits the claim entirely (RFC 9728 §3.2). Validated at startup: no userinfo, no literal-IP host, scheme per `allow_http_oauth_urls`. See "OAuth discovery metadata" below. |
| `authorization_server_metadata_issuer` | `Option<String>` | _unset_ (own URL) | RFC 8414 Authorization Server Metadata `issuer`, served by the proxy at `/.well-known/oauth-authorization-server`. **Unset publishes this server's own public URL (from `public_url`)**, as RFC 8414 §3.3 / §6.2 require. Legacy opt-out: set to your upstream `issuer` only when the IdP emits RFC 9207 `iss` and clients validate it. Validated at startup as above. |
##### `ScopeMapping`
| Field | Type | Description |
|-------|------|-------------|
| `scope` | `String` | OAuth scope string matched against the token's `scope` claim. |
| `role` | `String` | RBAC role granted when this scope is present. |
##### `RoleMapping`
Used with `role_claim` for non-scope-based role extraction (e.g. Keycloak `realm_access.roles`, Azure AD `roles`).
| Field | Type | Description |
|-------|------|-------------|
| `claim_value` | `String` | Expected value of the claim named by `role_claim` (e.g. a Keycloak role name or an Azure AD role string). |
| `role` | `String` | RBAC role granted when `claim_value` is present in the claim. |
**Worked example - Keycloak `realm_access.roles` claim:**
```toml
[server.auth.oauth]
issuer = "https://keycloak.example.com/realms/my-realm"
audience = "my-mcp-server"
jwks_uri = "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs"
role_claim = "realm_access.roles"
[[server.auth.oauth.role_mappings]]
claim_value = "mcp-admin" # Keycloak role name
role = "admin" # RBAC role in rmcp-server-kit
[[server.auth.oauth.role_mappings]]
claim_value = "mcp-viewer"
role = "viewer"
```
`role_claim` accepts dot-notation for nested JWT claims (`"realm_access.roles"`) and handles both space-separated string claims (`"read write"`) and JSON array claims (`["read", "write"]`). When `role_claim` is set, `scopes` is ignored.
**First match in configuration order wins.** Resolution scans `role_mappings` (or `scopes` when
`role_claim` is unset) top to bottom and stops at the first entry whose value appears in the token.
It does **not** scan in token order, and it does not collect every match. Ordering is therefore
significant: list the most specific mapping first. In the example above a token carrying both
`mcp-admin` and `mcp-viewer` resolves to `admin`, because that mapping is declared first; swapping
the two blocks would resolve it to `viewer`.
A token whose claims match no mapping is rejected. See
[One role per identity](#one-role-per-identity) for how to grant a caller the combined capability
of several claims.
#### OAuth discovery metadata (RFC 9728 / RFC 8414)
With the `oauth` feature enabled the server publishes two unauthenticated discovery
documents whose values are derived from your topology, so most deployments need no
extra configuration:
- **Protected Resource Metadata** - `/.well-known/oauth-protected-resource`, plus the
RFC 9728 §3.1 path-inserted alias `/.well-known/oauth-protected-resource/mcp` - is
served unconditionally. Its `authorization_servers` list resolves to the upstream
`issuer` when no `proxy` is configured, or to this server's own public URL when the
built-in `proxy` is mounted.
- **Authorization Server Metadata** - `/.well-known/oauth-authorization-server` - is
served only when `proxy` is configured. Its published `issuer` is this server's own
public URL (RFC 8414 §3.3).
**One topology needs explicit handling: the facade.** If your application mounts its
*own* `/authorize` and `/token` through `McpServerConfig::with_extra_router` **without**
configuring `oauth.proxy`, the crate cannot tell that apart from a plain resource server
(both leave `oauth.proxy` unset), so it advertises the upstream `oauth.issuer` by default.
That is correct for a plain resource server but wrong for a facade, whose endpoints live
at this server rather than upstream. A facade therefore needs one of two explicit settings
under `[server.auth.oauth]`, and exactly one is required:
- **If you know your public URL** (recommended): `authorization_servers =
["https://mcp.example.com"]`, using the URL clients actually reach and keeping it
identical to `public_url`. Clients then discover the facade's own endpoints.
- **If you have no stable public URL** (for example behind a dynamic ingress you cannot
name at startup): `authorization_servers = []`. An empty list is a deliberate,
RFC 9728 §3.2-compliant "no authorization server advertised", **not** a misconfiguration:
it passes startup validation, and clients fall back to other discovery (the
`WWW-Authenticate` challenge or out-of-band configuration). Prefer this over advertising
a URL you cannot stand behind.
`public_url` does **not** drive `authorization_servers` for a facade: with `oauth.proxy`
unset, that list resolves to your explicit value or, if unset, to `oauth.issuer`, never to
the `public_url`-derived URL. The derivation described next applies only to the built-in
`proxy` topology and to the Authorization Server Metadata `issuer` (which the crate does
not serve without `proxy`), so for a no-proxy facade `authorization_servers` is the only
lever.
In the derived cases (the built-in `proxy` `authorization_servers` and the metadata
`issuer`), "this server's own public URL" comes from `McpServerConfig::with_public_url`.
When `public_url` is unset it falls back to the
server's bind address - behind a TLS-terminating proxy an internal `http://` address -
so `authorization_servers`, the metadata `issuer`, and the advertised endpoint URLs
would point clients somewhere they cannot reach, and the `WWW-Authenticate`
`resource_metadata` challenge degrades to a relative path. **Set `public_url` on any
proxied or public deployment**, and make an explicit `authorization_servers` entry
match it.
Inbound JWT validation is independent of these documents: the token `iss` is always
validated against `oauth.issuer`, whatever the metadata advertises.
#### JWKS keys without an `alg` member
RFC 7517 §4.4 makes the JWK `alg` member **OPTIONAL**, and several identity
providers omit it. Microsoft Entra ID (Azure AD) v2.0 is the most prominent:
every key at
`https://login.microsoftonline.com/common/discovery/v2.0/keys` is published as
`kty=RSA`, `use=sig`, with **no `alg` field**.
Such keys are accepted. When `alg` is present it pins exactly one algorithm.
When it is absent, the permitted algorithms are inferred from the key material
itself - never from the token header - as follows:
| JWK key type | Permitted algorithms |
|---|---|
| `RSA` | `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512` |
| `EC`, `crv=P-256` | `ES256` |
| `EC`, `crv=P-384` | `ES384` |
| `OKP`, `crv=Ed25519` | `EdDSA` |
| `oct` (symmetric) | *none - key is dropped* |
Inference is deliberately constrained:
- It reads only the JWK's own key type, so an attacker cannot steer it via the
token header.
- Symmetric (`oct`) keys are never inferred, so an `HS*` secret can never become
a verification key. `HS*` and `none` are additionally rejected before key
lookup even reaches this stage.
- The inferred set is always a subset of the algorithms the server accepts, so
inference can never widen the policy.
> **`EC` keys on curve `P-521` are not supported**, with or without an `alg`
> member. The [`jsonwebtoken`](https://docs.rs/jsonwebtoken) crate that performs
> signature verification defines no `ES512` variant in its `Algorithm` enum - it
> implements only `ES256` and `ES384` for ECDSA - and its own
> `EllipticCurve::P521` documentation notes the curve is unsupported by `ring`,
> the backing cryptography provider. A `P-521` key is therefore dropped from the
> JWKS cache rather than cached as unusable. Supporting it requires upstream
> `jsonwebtoken` support first.
#### SSRF and DoS Hardening (OAuth)
OAuth URL hardening operates in two layers:
- **At config-construction time**, `OAuthConfig::validate` rejects any of
the six configured URL fields (`issuer`, `jwks_uri`, `authorization_endpoint`,
`token_endpoint`, `revocation_endpoint`, `introspection_endpoint`) that
contain HTTP userinfo (`user:pass@host`) or that use a literal IP host
(IPv4 or IPv6). Operators must use DNS hostnames. The two discovery-metadata
URLs reflected verbatim by the unauthenticated `/.well-known/oauth-*` endpoints
- `authorization_server_metadata_issuer` and each `authorization_servers[]` entry
- are held to the same policy.
- **At runtime, on every HTTP redirect hop**, both the shared
`OauthHttpClient` and the `JwksCache` redirect closures run a sync
per-hop SSRF guard that rejects targets resolving to private, loopback,
link-local, multicast, broadcast, unspecified, or cloud-metadata
IP ranges. `https -> http` downgrades are always rejected; `http -> http`
is permitted only when `allow_http_oauth_urls = true`.
- **Before every initial outbound connect**, OAuth/JWKS fetches resolve the
hostname with DNS and reject any target whose resolved IP falls in the same
blocked ranges. This closes the post-DNS SSRF gap for the first request hop.
For new deployments, prefer:
```toml
[server.auth.oauth]
audience_validation_mode = "strict" # accept only `aud` matches; reject `azp`-only fallback
jwks_max_response_bytes = 1048576
```
The default `audience_validation_mode = "strict"` rejects tokens whose
configured audience appears only in the `azp` claim (not `aud`). **To keep the
previous behavior** - accepting `azp`-only matches - set
`audience_validation_mode = "warn"` (accept with a one-shot WARN per process so
operators can detect IdPs that leave `aud` unpopulated) or `"permissive"`
(accept silently). Once your IdP issues tokens carrying `aud` reliably, keep the
`"strict"` default.
The redirect-hop limit (max 2) and per-request HTTP timeouts are enforced
internally and are not configurable knobs.
#### Allowing in-cluster IdPs
By default, the post-DNS SSRF guard rejects OAuth/JWKS targets whose
hostnames resolve to private (RFC 1918), loopback, link-local, CGNAT,
unique-local, or cloud-metadata address space. This is the right default
for internet-facing IdPs but blocks legitimate in-cluster deployments
where, for example, Keycloak resolves to a `10.x.x.x` ClusterIP.
Operators can opt in to **specific** trust by listing the hostnames or
CIDR blocks the fetcher is permitted to reach. Cloud-metadata addresses
(AWS/GCP/Alibaba IPv4 + IPv6) remain blocked **unconditionally**, even
if a containing CIDR is listed -- see [`SECURITY.md`](../SECURITY.md)
under "Operator allowlist" for the full trust model.
```toml
[server.auth.oauth]
issuer = "https://rhbk.ops.example.com/realms/ops"
audience = "mcp"
jwks_uri = "https://rhbk.ops.example.com/realms/ops/protocol/openid-connect/certs"
[server.auth.oauth.ssrf_allowlist]
hosts = ["rhbk.ops.example.com"]
cidrs = ["10.0.0.0/8"]
```
Builder API:
```rust,ignore
use rmcp_server_kit::oauth::{OAuthConfig, OAuthSsrfAllowlist};
// `OAuthSsrfAllowlist` is `#[non_exhaustive]`; construct it via
// `Default::default()` and append to the public fields, so future
// additions remain non-breaking.
let mut allowlist = OAuthSsrfAllowlist::default();
allowlist.hosts.push("rhbk.ops.example.com".into());
allowlist.cidrs.push("10.0.0.0/8".into());
let cfg = OAuthConfig::builder(
"https://rhbk.ops.example.com/realms/ops",
"mcp",
"https://rhbk.ops.example.com/realms/ops/protocol/openid-connect/certs",
)
.ssrf_allowlist(allowlist)
.build();
```
Configuration is validated up-front:
- `hosts` entries must be bare DNS hostnames (no scheme, port, path,
userinfo, query, fragment) and must not be literal IPs (use `cidrs`
for those). Matching is case-insensitive exact match -- no wildcards.
- `cidrs` entries are family-strict (no IPv4-mapped-IPv6, no `/0`, no
zone IDs); host bits must be zero.
- A misconfigured allowlist is rejected by `OAuthConfig::validate()` and
by `JwksCache::new()` -- the server fails to start, rather than
fail-open.
- A non-empty allowlist emits a `tracing::warn!` at validate time
naming the host and CIDR counts so the elevated trust is auditable.
#### `OAuthProxyConfig` (optional)
Optional sub-table that turns rmcp-server-kit into an OAuth proxy in front of an upstream IdP. When present, MCP clients see this server as the authorization server and perform a standard Authorization Code + PKCE flow; rmcp-server-kit forwards `/oauth/authorize`, `/oauth/token`, and -- when the relevant URLs and `expose_admin_endpoints` are set -- `/introspect` and `/revoke` to the upstream IdP, injecting `client_id` and `client_secret` as required.
```toml
[server.auth.oauth.proxy]
authorize_url = "https://auth.example.com/oauth/authorize"
token_url = "https://auth.example.com/oauth/token"
client_id = "my-mcp-server"
client_secret = "..." # confidential clients only
introspection_url = "https://auth.example.com/oauth/introspect"
revocation_url = "https://auth.example.com/oauth/revoke"
expose_admin_endpoints = true
require_auth_on_admin_endpoints = true # recommended for new deployments
strip_resource_param = false # set true for Microsoft Entra v2.0
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `authorize_url` | `String` | -- | Upstream authorization endpoint. |
| `token_url` | `String` | -- | Upstream token endpoint. |
| `client_id` | `String` | -- | OAuth `client_id` registered at the upstream IdP. |
| `client_secret` | `Option<SecretString>` | `None` | OAuth `client_secret` for confidential clients. Omit (TOML: leave unset) for public clients. |
| `introspection_url` | `Option<String>` | `None` | Upstream RFC 7662 introspection endpoint. Local `/introspect` is exposed only when this is set **and** `expose_admin_endpoints = true`. |
| `revocation_url` | `Option<String>` | `None` | Upstream RFC 7009 revocation endpoint. Local `/revoke` is exposed only when this is set **and** `expose_admin_endpoints = true`. |
| `expose_admin_endpoints` | `bool` | `false` | Mount `/introspect` and `/revoke`, and advertise them in the authorization-server metadata document. When `false` both endpoints return 404. |
| `require_auth_on_admin_endpoints` | `bool` | `false` | Run the normal authentication middleware before `/introspect` and `/revoke`. **Recommended `true` for new deployments.** Pre-1.6 default of `false` is preserved for backward compatibility. |
| `allow_unauthenticated_admin_endpoints` | `bool` | `false` | Operator opt-out for the M3 startup check that rejects `expose_admin_endpoints = true` combined with `require_auth_on_admin_endpoints = false`. Set `true` only when an authenticated reverse proxy / ingress screens `/introspect` and `/revoke` itself. Production should leave this `false` and set `require_auth_on_admin_endpoints = true` instead. |
| `strip_resource_param` | `bool` | `false` | Drop the RFC 8707 `resource` parameter when forwarding `/authorize` and `/token` upstream. Set `true` for **Microsoft Entra ID (Azure AD) v2.0**, which rejects `resource` carried alongside a differing `api://` scope with `AADSTS9010010`; MCP clients send it because the MCP spec requires it. Only `resource` is ever dropped -- `state`, `code_challenge`, `code_challenge_method`, `code_verifier`, `redirect_uri`, `nonce`, and `scope` are always forwarded, so this cannot disable PKCE or CSRF protection. `/introspect` and `/revoke` are unaffected. Env: `RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__PROXY__STRIP_RESOURCE_PARAM`. |
#### `TokenExchangeConfig` (optional)
Optional sub-table that performs an RFC 8693 token exchange after authentication, swapping the inbound token for a downstream-API token that subsequent tool invocations can retrieve via `rmcp_server_kit::rbac::current_token()`.
```toml
[server.auth.oauth.token_exchange]
token_url = "https://downstream.example.com/oauth/token"
client_id = "downstream-client-id"
client_secret = "..." # exactly one of client_secret / client_cert
# All four below are RFC 8693 §2.1 OPTIONAL -- omit the key to leave the
# parameter out of the exchange request entirely.
audience = "downstream-audience"
resource = "https://api.example.com/v1" # RFC 8707 absolute URI, no fragment
scope = "read write"
requested_token_type = "access_token" # or "omit", or any token-type URI
# OR -- RFC 8705 §2 mTLS client authentication (requires the `oauth-mtls-client` cargo feature):
[server.auth.oauth.token_exchange.client_cert]
cert_path = "/etc/certs/oauth-client.pem"
key_path = "/etc/certs/oauth-client.key"
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `token_url` | `String` | -- | Authorization-server token endpoint used for the exchange. |
| `client_id` | `String` | -- | OAuth `client_id` of the MCP server (the requester). |
| `client_secret` | `Option<SecretString>` | `None` | RFC 6749 §2.3.1 HTTP-Basic client secret. **Mutually exclusive with `client_cert`** -- `OAuthConfig::validate` rejects configs that set both, or neither. |
| `client_cert` | `Option<ClientCertConfig>` | `None` | RFC 8705 §2 mTLS client authentication. **Requires the `oauth-mtls-client` cargo feature**; without it, `OAuthConfig::validate` fails closed at startup. See `ClientCertConfig` below. |
| `audience` | `Option<String>` | `None` (omitted) | RFC 8693 §2.1 **OPTIONAL**. Logical name of the downstream API; the exchanged token carries it in `aud`. Omit the key to leave the parameter out. Distinct from `oauth.audience`, which is the `aud` this server *expects* on inbound tokens. |
| `resource` | `Option<String>` | `None` (omitted) | RFC 8693 §2.1 **OPTIONAL**; an RFC 8707 resource indicator. Must be an absolute URI with no fragment. **Unrelated to `oauth.proxy.strip_resource_param`**, which governs the OAuth *proxy* endpoints, not token exchange. |
| `scope` | `Option<String>` | `None` (omitted) | RFC 8693 §2.1 **OPTIONAL**. Space-delimited scopes requested for the exchanged token. |
| `requested_token_type` | `String` | `"access_token"` | RFC 8693 §2.1 **OPTIONAL**. `"access_token"` sends `urn:ietf:params:oauth:token-type:access_token`; `"omit"` leaves the parameter out so the authorization server chooses; any other string is sent verbatim as a token-type URI. |
> Empty strings are rejected at startup: `audience = ""` is a malformed request
> parameter, not an omission. Omit the key instead.
#### `ClientCertConfig` (sub-table of `TokenExchangeConfig.client_cert`)
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `cert_path` | `PathBuf` | -- | Path to the PEM-encoded X.509 client certificate (single leaf or full chain). PEM-validated at startup. |
| `key_path` | `PathBuf` | -- | Path to the PEM-encoded private key (PKCS#8 or RSA / EC). **Encrypted (passphrase-protected) keys are not supported** and are rejected at startup. |
Operational notes for `client_cert`:
- Cert + key files are read **once at server startup**; in-place rotation requires a process restart.
- The token-exchange request authenticates by presenting the configured certificate at TLS handshake -- **no `Authorization` header is sent**.
- The cert-bearing HTTP client uses `redirect::Policy::none()` so an attacker-controlled 3xx from the token endpoint cannot re-present the client certificate to a different host.
- **Scope**: implements RFC 8705 §2 (PKI-bound client auth) only. RFC 8705 §3 self-signed client auth and the `cnf.x5t#S256` certificate-bound access-token confirmation claim are **not** enforced -- issued access tokens behave as bearer tokens once minted.
---
### metrics
*Requires feature: `metrics`*
Prometheus metrics collection and exposition.
#### `McpMetrics`
```rust
use rmcp_server_kit::metrics::McpMetrics;
let metrics = McpMetrics::new()?;
// After handling some requests...
let prometheus_text = metrics.encode();
tracing::info!(%prometheus_text, "exposition snapshot");
```
Tracks:
- `http_requests_total` -- counter by method, path, status
- `http_request_duration_seconds` -- histogram by method, path
#### `serve_metrics()`
```rust
pub async fn serve_metrics(bind: String, metrics: Arc<McpMetrics>) -> rmcp_server_kit::Result<()>
```
Spawns a dedicated HTTP listener serving `/metrics` in Prometheus text format.
You don't call this directly -- rmcp-server-kit spawns it automatically when
`metrics_enabled = true` on `McpServerConfig`.
---
## Additional Built-in Endpoints and Features
### `/version`
Always-on unauthenticated endpoint that returns a small JSON payload
describing the running binary:
```json
{
"name": "my-server",
"version": "1.2.3",
"build_sha": "abcdef0",
"build_time": "2025-01-15T12:00:00Z",
"rust_version": "rustc 1.98.0",
"rmcp_server_kit_version": "1.0.0"
}
```
`build_sha`, `build_time`, and `rust_version` are populated from the
`RMCP_SERVER_KIT_BUILD_SHA`, `RMCP_SERVER_KIT_BUILD_TIME`, and
`RMCP_SERVER_KIT_RUSTC_VERSION` build-time environment variables. Unset
variables become `null`.
### Response compression
Set `compression_enabled = true` on `McpServerConfig` to enable gzip and
brotli content-encoding for responses larger than `compression_min_size`
bytes (default 1024). Compression is negotiated via `Accept-Encoding`.
### Global concurrency limit
Set `max_concurrent_requests = Some(N)` to cap in-flight HTTP requests
across the server. When the cap is reached, excess requests are shed
with `503 Service Unavailable` (JSON body `{"error":"overloaded"}`)
rather than queued.
### Extra routes and the client peer address
`McpServerConfig::with_extra_router` merges your own axum routes into the
top-level router. These routes **bypass** rmcp-server-kit auth and RBAC, so the
application is responsible for its own protection - typically per-IP rate
limiting on unauthenticated endpoints (OAuth callbacks, registration, …).
To support that, every request served by `serve()` carries the client
peer address **regardless of whether TLS is enabled**, in two forms:
1. **`transport::PeerAddr`** - the framework-blessed extractor for your
own handlers:
```rust,ignore
use axum::{Router, routing::get};
use rmcp_server_kit::transport::PeerAddr;
async fn authorize(peer: PeerAddr) -> String {
// e.g. key a rate-limit bucket by peer.addr.ip()
peer.addr.ip().to_string()
}
let extra = Router::new().route("/authorize", get(authorize));
let config = config.with_extra_router(extra);
```
2. **`axum::extract::ConnectInfo<SocketAddr>`** - for compatibility with
stock third-party middleware. On the TLS listener the kit mirrors the
peer address into this standard axum extension, so per-IP middleware
that expects it (e.g. `tower_governor`'s `PeerIpKeyExtractor`) works
unmodified on both plain and TLS deployments.
Caveats:
- **Direct socket peer only.** Behind an L4/L7 proxy or load balancer
this is the proxy's address; the kit performs no `X-Forwarded-For` /
`Forwarded` interpretation.
- **Absent under `serve_stdio`** - a stdio session has no network peer.
- The separate Prometheus metrics listener is a different router and
does not carry these extensions.
- **Privacy**: `PeerAddr` exposes raw peer network metadata. The
framework deliberately never logs it on its own; whether to log or
persist peer addresses is application policy.
#### Built-in per-IP rate limiting
For the common case - throttling unauthenticated extra routes (OAuth
`/authorize`, `/token`, registration, callbacks) - the kit ships an
opt-in limiter so you don't need third-party middleware:
```rust,ignore
let config = config
.with_extra_router(extra)
.with_extra_route_rate_limit(60); // requests/min per source IP
```
or in TOML: `extra_route_rate_limit = 60` under `[server]`. The limiter
wraps **only** the extra router (layered before it is merged), responds
`429 Too Many Requests` with a plain-text body and a `Retry-After`
header (delta-seconds, like every kit limiter), and is startup-only.
Specific paths can be exempted - typically the RFC 8414 metadata
document MCP clients fetch on every connect, which would otherwise 429
behind a shared egress:
```rust,ignore
let config = config
.with_extra_route_rate_limit(60)
.with_extra_route_rate_limit_exempt_paths([
"/.well-known/oauth-authorization-server",
]);
```
or in TOML: `extra_route_rate_limit_exempt_paths = [...]`. Matching is
a **raw exact string comparison** against the request path - no globs,
no prefixes, no normalization (trailing slashes, percent-encoding, and
dot-segments must match byte-for-byte). The check is fail-closed
(anything not listed stays limited) and runs before key extraction, so
exempt requests consume no limiter budget and never appear in deny
telemetry. Entries must be non-empty, start with `/`, and require the
base rate knob (all validated at startup).
##### Rate limiting across the kit
All four built-in limiters - the auth pre-auth gate, the post-failure
auth limiter, the `tools/call` limiter, and the extra-route limiter -
share one deny contract: HTTP `429`, a plain-text body, and a
`Retry-After: n` header where `n` is the best-effort wait in whole
seconds (rounded up, never `0`).
Each per-minute rate knob has an optional **burst** companion setting
the bucket capacity (maximum requests admitted back-to-back); the
sustained rate is unchanged, and burst may be smaller (smoothing) or
larger (spike tolerance) than the rate. Unset = burst equals the rate.
| Limiter | Rate (builder / TOML) | Burst |
|---|---|---|
| Tool (`tools/call`) | `with_tool_rate_limit` / `tool_rate_limit` | `with_tool_rate_limit_burst` / `tool_rate_limit_burst` |
| Extra routes | `with_extra_route_rate_limit` / `extra_route_rate_limit` | `with_extra_route_rate_limit_burst` / `extra_route_rate_limit_burst` |
| Auth post-failure | `RateLimitConfig::new(n)` / `auth.rate_limit.max_attempts_per_minute` | `.with_burst(n)` / `auth.rate_limit.burst` |
| Auth pre-auth gate | `.with_pre_auth_max_per_minute(n)` / `auth.rate_limit.pre_auth_max_per_minute` | `.with_pre_auth_burst(n)` / `auth.rate_limit.pre_auth_burst` |
Bursts must be greater than zero; the tool and extra-route bursts also
require their base knob to be set. The pre-auth burst is valid without
an explicit pre-auth rate (the gate's base always resolves to
`max_attempts_per_minute × 10`).
With the `metrics` feature enabled, every limiter deny increments the
Prometheus counter `rmcp_server_kit_rate_limited_total` with a single
`limiter` label (`tool`, `auth_pre`, `auth_post`, or `extra_route`),
alongside the existing warn-level log. Exempted extra-route requests
increment nothing.
Limitations to understand before relying on it:
- **Direct peer keying.** Same semantics as `PeerAddr`: behind a
reverse proxy every client collapses into the proxy's bucket, and a
hostile IPv6 host rotating addresses within its /64 can evade per-IP
keying. This is an abuse speed bump, not tenant isolation.
- **Bounded memory, shared fate.** At the 10,000 tracked-key cap the
limiter prunes idle entries, then LRU-evicts; memory stays bounded
under key spray, but quieter legitimate IPs may be churned back to
fresh buckets.
Need custom keys (API key, header) or proxy-aware client IPs? Reach
for `tower_governor` on your extra router instead - its stock
`PeerIpKeyExtractor` works on both plain and TLS listeners thanks to
the `ConnectInfo<SocketAddr>` normalization described above.
#### Trusted-forwarder mode (proxy-aware client IPs)
Behind a reverse proxy, every client shares the proxy's IP - per-IP
rate limiting collapses into one bucket. **Trusted-forwarder mode**
fixes that by resolving the real client from the forwarding header,
but only when it is safe to do so:
```rust,ignore
let config = config
.with_trusted_proxies(["10.0.0.0/8"]) // your proxy fleet (CIDRs or IPs)
// optional: read RFC 7239 `Forwarded` instead of X-Forwarded-For
.with_forwarded_header(rmcp_server_kit::transport::ForwardedHeaderMode::Forwarded);
```
TOML under `[server]`: set `trusted_proxies = ["10.0.0.0/8"]` (list of CIDRs or individual IPs) to declare your proxy fleet, and optionally `forwarded_header = "forwarded"` to read the RFC 7239 `Forwarded` header instead of the default. Accepted values for `forwarded_header` are `"x-forwarded-for"` (default; de-facto standard used by nginx, HAProxy, CDNs) and `"forwarded"` (RFC 7239). Trusted-forwarder mode is inactive when `trusted_proxies` is empty; `forwarded_header` is ignored in that case.
How it resolves (the **rightmost-untrusted** algorithm, as in nginx
`real_ip` / Envoy):
1. If the **direct socket peer** is not in `trusted_proxies`, the
header is ignored entirely - prepending `X-Forwarded-For` from the
open internet does nothing (the leftmost-trust anti-pattern is never
used).
2. Otherwise, walk the LAST header instance right-to-left, skip
addresses that are themselves trusted proxies, and take the first
that is not: that is the client.
3. Anything ambiguous - malformed entries, RFC 7239 obfuscated
identifiers (`unknown`, `_…`), chains that are entirely trusted,
more than 16 entries - falls back to the **direct peer**, never to a
header value. Only a reason code is logged (`debug`), never raw
header contents.
The result is exposed as the `transport::ClientIp` request extension
(also extractable in your handlers) and is what **all four rate
limiters key by**. `PeerAddr` is unchanged - it stays the direct socket
peer, so you can compare the two when you need provenance:
| Extension | Meaning |
|---|---|
| `PeerAddr` | Direct socket peer, always (proxy's address behind an LB) |
| `ClientIp` | Resolved client when trusted-forwarder mode applies, else = direct peer |
**Enable this only when every ingress path traverses the listed
proxies.** If clients can also reach the server directly, their direct
IPs and the proxied clients' resolved IPs share one keyspace by design,
but a direct attacker could choose their own bucket only via their real
source IP - never via a header.
#### MCP session identity binding
When authentication is enabled, rmcp-server-kit wraps every newly minted
`Mcp-Session-Id` in a stateless token bound to the `AuthIdentity` that
performed `initialize`. Later requests must present both valid credentials
and a session token whose MAC verifies for that same stable identity
fingerprint. A token minted for API key `ops-a` therefore cannot be replayed
by API key `ops-b`, even if both keys map to the same RBAC role.
The wrapper is enabled by default (`McpServerConfig::with_session_binding(true)`;
TOML `session_binding = true`). Set it to `false` only for a trusted gateway
that re-authenticates every request but intentionally changes the visible
identity label between requests. Disabling the wrapper restores the CWE-384
condition where a leaked raw rmcp session ID can be reused by another
authenticated caller.
#### Multi-replica session recovery
For load-balanced deployments, supply an external rmcp `SessionStore` in code
so a request routed to replica B can restore a session initialized on replica A:
```rust,ignore
use std::sync::Arc;
use rmcp::transport::streamable_http_server::session::SessionStore;
use rmcp_server_kit::secret::SecretString;
use rmcp_server_kit::transport::McpServerConfig;
let store: Arc<dyn SessionStore> = Arc::new(MyRedisSessionStore::new(redis_pool));
let config = McpServerConfig::new("0.0.0.0:8443", "my-server", "0.1.0")
.with_auth(auth)
.with_session_store(store)
.with_session_binding_secret(SecretString::from(
std::env::var("RMCP_SERVER_KIT__SERVER__SESSION_BINDING_SECRET")?,
));
```
`session_store` is programmatic only: it is an `Arc<dyn SessionStore>` trait
object and therefore has no TOML representation. `server.session_binding_secret`
is available in TOML and via environment variables. For Kubernetes or any
secret-mounted deployment, prefer the `_FILE` form:
```text
RMCP_SERVER_KIT__SERVER__SESSION_BINDING_SECRET_FILE=/var/run/secrets/rmcp/session-binding-secret
```
The file is treated as text; one terminal newline (`\n`, `\r`, or `\r\n`) is
removed and all other whitespace is preserved. Generate at least 32 random
bytes, for example `openssl rand -base64 32`, and share the exact same value
across every replica.
Startup fails closed when all of these are true: `with_session_store(...)` is
set, session binding is enabled, authentication is enabled, and no shared
`session_binding_secret` is configured. The error names both `session_store`
and `session_binding` and explains that a shared secret is required for
cross-instance verification. With authentication disabled, session binding is
inert because there is no `AuthIdentity`, so this validation rule does not
apply.
Rotating `session_binding_secret` invalidates active bound sessions. Clients
will need to reinitialize after rotation; coordinate the change across replicas
to avoid a mixed-secret window. When `task_binding` is also enabled, rotation
additionally invalidates every outstanding external task ID.
### Binding MCP task IDs
Session binding covers `Mcp-Session-Id` and nothing else. MCP tasks (SEP-2663)
introduce a second long-lived identifier: `rmcp` resolves `tasks/get`,
`tasks/update`, and `tasks/cancel` **by task ID alone**, and this crate's RBAC
layer inspects only `tools/call`. So without task binding, any authenticated
identity holding another identity's `taskId` can read, update, or cancel that
task.
```rust,ignore
let config = McpServerConfig::new("127.0.0.1:8443", "my-server", "0.1.0")
.with_task_binding(true);
```
or in TOML:
```toml
[server]
task_binding = true
```
Enabled, the `taskId` handed to the client is a signed wrapper bound to the
authenticated identity; it is verified and rewritten back to the raw ID before
your handler runs, so **handlers always see raw task IDs and need no changes**.
A wrapper that fails verification is rejected with the same error `rmcp` returns
for an unknown task, so it cannot be used to probe for another identity's tasks.
It is off by default because it changes the wire format of `taskId`. The only
consumer this breaks is one that persists the *client-visible* ID as its own
key. It reuses `session_binding_secret` (domain-separated from session tokens),
so multi-replica deployments must share that secret; a process-random secret
invalidates outstanding task IDs on restart. With authentication disabled it is
a no-op, since there is no identity to bind to.
In MCP 2026-07-28 stateless mode there is no session ID, so session binding is
inert for those clients -- authentication and RBAC still apply per request, and
task binding is unaffected because it works on task IDs rather than sessions.
This is an explicit opt-in compatibility control, not a staged default flip.
Future releases may revisit the default only with a fresh compatibility review
and migration note; enable `task_binding` now if your server exposes tasks across
authenticated identities.
API-key and mTLS identity fingerprints are stable when every replica uses the
same API-key metadata and certificate identity mapping. OAuth fingerprints are
stable only when the JWT `sub` claim is present; otherwise the identity name may
fall back through mutable claims such as `preferred_username`. For OAuth
deployments using an external session store, set `require_subject = true` on
the OAuth config so token refreshes keep the same session-binding fingerprint.
#### MRTR `requestState` identity binding
SEP-2322 multi-round-trip requests let a handler return an
`InputRequiredResult` with an opaque `requestState` that the client later echoes.
Treat the echoed value as untrusted input. rmcp-server-kit does not wrap
`requestState`: session binding protects only `Mcp-Session-Id`, and task binding
protects only `taskId`.
If `requestState` affects authorization, resource selection, tenant selection,
or business logic, seal it with `rmcp::model::RequestStateCodec` and
`SealOptions::associated_data`. That type sits behind rmcp's `request-state`
feature, which is neither an rmcp default nor enabled by this crate, so add it
to your own `rmcp` dependency first. Build the associated data from:
- the authenticated principal: `current_sub().or_else(current_identity)` --
`current_sub()` is the OAuth `sub` claim and is preferred where present;
`current_identity()` is the stable API-key name or mTLS certificate identity;
- the request scope: method/tool name plus a canonical digest of the original
request, resource, or tenant the retry is allowed to resume;
- a deployment-stable namespace/version so future format changes fail closed.
Use the same request-state signing key or keyring on every replica, set a short
TTL, and enforce nonce/single-use server-side if redemption must be one-time.
The sealed payload is authenticated, not encrypted: do not put secrets, bearer
tokens, or hidden authorization decisions in it. Map every open/verification
failure to one generic client-facing "invalid request state" error.
#### Resumable SSE event replay
MCP stream resumability is optional: the transport spec says servers MAY attach
SSE event IDs and MAY use the client's `Last-Event-ID` header to replay missed
messages. rmcp-server-kit remains conformant when this is unset; omitting an
event store leaves existing behaviour unchanged.
Without a store, rmcp already supports a best-effort in-process resume for a
live legacy session: a client can reconnect with `Mcp-Session-Id` and
`Last-Event-ID` while the session worker still exists and the target events
remain in rmcp's bounded channel cache (default capacity 16). Once
`session_idle_timeout` lets that worker exit, or once the bounded cache evicts
the event, nothing from that local cache is resumable.
For durable replay, provide an application-owned rmcp `EventStore` in code:
```rust,ignore
use std::sync::Arc;
use rmcp::transport::streamable_http_server::session::EventStore;
use rmcp_server_kit::transport::McpServerConfig;
let events: Arc<dyn EventStore> = Arc::new(MyDurableEventStore::new(pool));
let config = McpServerConfig::new("0.0.0.0:8443", "my-server", "0.1.0")
.with_event_store(events);
```
`event_store` is programmatic only: it is an `Arc<dyn EventStore>` trait object
and therefore has no TOML representation. The crate does not ship any
`EventStore` implementation; production retention, eviction, replication, and
backpressure policy belong to the application or infrastructure backing the
store.
The store adds durability, cross-instance replay, and stateless replay. It does
not create resumption from nothing: the implementation must persist each event
before returning the ID that will be sent to the client.
**Stream isolation is the implementor's responsibility.** rmcp passes only
`last_event_id` to `EventStore::replay_events_after`; it does not pass the
stream ID. Event IDs must therefore be globally unique and must encode or let
the store recover their owning stream. Returning events from another stream
violates the MCP spec's hard `MUST NOT` for resumability.
Cross-replica deployments have two distinct resume paths:
| Path | Client request | Shared requirements |
|---|---|---|
| Legacy session-bound resume | `GET /mcp` with wrapped `Mcp-Session-Id` and `Last-Event-ID` | shared `EventStore`, shared `SessionStore`, and shared `session_binding_secret` when auth/session binding are enabled (the default) |
| Stateless replay | non-legacy `GET /mcp` with `Last-Event-ID` and no `Mcp-Session-Id` | shared `EventStore` only, plus otherwise identical auth/config across replicas |
If you configure only an event store and then test the legacy session-bound path
through another replica, rmcp still has to recover the session first; add a
shared `SessionStore` and shared binding secret for that path.
### Customising security headers
By default, rmcp-server-kit emits twelve OWASP security headers on every
response (`X-Content-Type-Options`, `X-Frame-Options`, `Cache-Control`,
`Referrer-Policy`, three `Cross-Origin-*-Policy` headers,
`Permissions-Policy`, `X-Permitted-Cross-Domain-Policies`,
`Content-Security-Policy`, `X-DNS-Prefetch-Control`, plus
`Strict-Transport-Security` when TLS is active). The defaults are
deliberately strict.
For deployments that need to relax or tighten any of them, supply a
`SecurityHeadersConfig` to
[`McpServerConfig::with_security_headers`](https://docs.rs/rmcp-server-kit/latest/rmcp_server_kit/transport/struct.McpServerConfig.html#method.with_security_headers). Each field follows a
three-state semantic:
| Value | Behaviour |
|-----------------|-----------------------------------------------------------|
| `None` | Use the built-in default (current behaviour). |
| `Some("")` | **Omit** the header entirely from responses. |
| `Some(value)` | Emit `header: value`. Validated at config-load time. |
Non-empty values are validated via `axum::http::HeaderValue::from_str`
inside `McpServerConfig::validate()`; invalid values fail the server
startup with a `Config` error before any traffic is accepted.
Example -- relax CSP for an admin panel that legitimately embeds
rmcp-server-kit responses, and shorten HSTS during initial rollout:
```rust,ignore
use rmcp_server_kit::transport::{McpServerConfig, SecurityHeadersConfig};
let mut headers = SecurityHeadersConfig::default();
headers.content_security_policy =
Some("default-src 'self'; frame-ancestors https://admin.example.com".into());
headers.strict_transport_security = Some("max-age=600; includeSubDomains".into());
// Disable Cross-Origin-Embedder-Policy entirely for this deployment.
headers.cross_origin_embedder_policy = Some(String::new());
let config = McpServerConfig::new("127.0.0.1:8443", "my-server", "0.1.0")
.with_tls("/etc/certs/server.crt", "/etc/certs/server.key")
.with_security_headers(headers);
```
All twelve headers are also configurable from TOML under
`[server.security_headers]`. The same three-state semantics apply: omit a key
to keep the built-in default; set it to `""` to drop that header entirely from
every response; set it to a non-empty string to use that value verbatim
(validated at startup by `validate()`).
| TOML key | Header | Built-in default |
|---|---|---|
| `content_security_policy` | `Content-Security-Policy` | `default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests` |
| `strict_transport_security` | `Strict-Transport-Security` | `max-age=63072000; includeSubDomains` (TLS only) |
| `cross_origin_embedder_policy` | `Cross-Origin-Embedder-Policy` | `require-corp` |
| `cross_origin_resource_policy` | `Cross-Origin-Resource-Policy` | `same-origin` |
| `cross_origin_opener_policy` | `Cross-Origin-Opener-Policy` | `same-origin` |
| `permissions_policy` | `Permissions-Policy` | `accelerometer=(), camera=(), geolocation=(), microphone=()` |
| `referrer_policy` | `Referrer-Policy` | `no-referrer` |
| `x_frame_options` | `X-Frame-Options` | `deny` |
| `cache_control` | `Cache-Control` | `no-store, max-age=0` |
| `x_content_type_options` | `X-Content-Type-Options` | `nosniff` |
| `x_dns_prefetch_control` | `X-DNS-Prefetch-Control` | `off` |
| `x_permitted_cross_domain_policies` | `X-Permitted-Cross-Domain-Policies` | `none` |
```toml
[server.security_headers]
# Relax CSP for a panel that embeds responses in an iframe.
content_security_policy = "default-src 'self'; frame-ancestors https://admin.example.com"
# Shorten HSTS during initial rollout (TLS only; preload is rejected).
strict_transport_security = "max-age=600; includeSubDomains"
# Omit COEP if a third-party script requires cross-origin resources.
cross_origin_embedder_policy = ""
```
**HSTS is only emitted under TLS.** On plaintext deployments the
`strict_transport_security` override is silently ignored, matching the Rust
builder behaviour.
**CSP and HSTS at the edge.** When a reverse proxy or ingress controller
already injects `Content-Security-Policy` or `Strict-Transport-Security`,
manage them there and clear the kit's copies with `""` to avoid duplicate
headers reaching clients.
**Startup warnings.** Every header that is overridden or omitted via TOML or
the Rust builder is named in a structured `warn`-level log entry at startup,
so a weakened policy appears in logs rather than only in a config diff.
**Unknown keys are rejected.** Operator TOML config structs use
`deny_unknown_fields`, so a typo such as `contnet_security_policy` aborts
config loading instead of silently leaving the intended header at its default.
Check key spellings against the table above.
> **Harden your own root type too.** rmcp-server-kit ships reusable *sections*
> (`[server]`, `[observability]`, `[rbac]`, ...), not a kit-owned root type -
> your application composes them into its own struct. The kit's sections reject
> unknown **keys**, but only your root type can reject a misspelled **table
> name**: without `deny_unknown_fields` on it, `[serverr]` is silently dropped
> and the server starts with defaults for that whole section. See
> `examples/config_file_server.rs`:
>
> ```rust,ignore
> #[derive(Debug, Deserialize)]
> #[serde(deny_unknown_fields)]
> struct AppConfig {
> server: ServerConfig,
> observability: ObservabilityConfig,
> rbac: RbacConfig,
> }
> ```
**HSTS preload caveat.** The validator deliberately rejects any
`strict_transport_security` value containing the substring `preload`
(case-insensitive). Committing a domain to the public HSTS preload list
is irrevocable for practical purposes; opting in must be a conscious,
explicit decision and will require a future dedicated builder rather
than a string-smuggled override.
### `/admin/*` diagnostic endpoints (opt-in)
When `admin_enabled = true` and an authenticated role equal to
`admin_role` (default `"admin"`) is configured, rmcp-server-kit exposes:
- `GET /admin/status` -- server name, version, uptime.
- `GET /admin/auth/keys` -- names, roles, and expiry of configured API
keys (never the hashes).
- `GET /admin/auth/counters` -- authentication success/failure counters.
- `GET /admin/rbac` -- the live RBAC policy summary.
All four require a caller with the admin role; every other role gets
`403 forbidden`. The endpoints participate in the normal auth/RBAC
middleware stack, so anonymous access is never possible.
`admin_enabled = true` with no configured authentication fails at
startup with a configuration error.
### `Secret<T>` re-exports
`rmcp_server_kit::secret` re-exports `ExposeSecret`, `SecretBox`, and `SecretString`
from [`secrecy`]. Prefer these wrappers for any secret-bearing fields
added to application config structs so that `Debug` and serialization
never leak plaintext.
### OAuth 2.1 introspection (RFC 7662) and revocation (RFC 7009)
Set `OAuthProxyConfig::introspection_url` and/or
`OAuthProxyConfig::revocation_url` to upstream endpoint URLs and rmcp-server-kit
will expose matching local proxies:
- `POST /introspect` -- forwards the form body to the upstream
introspection endpoint, injecting `client_id` (and
`client_secret` for confidential clients) before forwarding.
- `POST /revoke` -- same shape for token revocation.
For backward compatibility these endpoints are mounted unauthenticated unless
you opt in with:
```toml,fragment
[server.auth.oauth.proxy]
expose_admin_endpoints = true
require_auth_on_admin_endpoints = true
```
New deployments should set `require_auth_on_admin_endpoints = true`.
The Authorization Server Metadata document
(`/.well-known/oauth-authorization-server`) automatically advertises
`introspection_endpoint` and `revocation_endpoint` only when the
corresponding URLs are configured.
### Tool hooks and result-size cap
`rmcp_server_kit::tool_hooks::HookedHandler` is an opt-in wrapper around any
`ServerHandler` that adds:
- An async `before` hook that returns `HookOutcome::Continue` (proceed),
`HookOutcome::Deny(rmcp::ErrorData)` (short-circuit with a
structured JSON-RPC error), or
`HookOutcome::Replace(Box<rmcp::model::CallToolResult>)`
(short-circuit with a synthesized result).
- An async `after` hook that observes each completed call along with
the approximate serialized result size in bytes and a
`HookDisposition` describing what actually happened
(`InnerExecuted`, `InnerErrored`, `DeniedBefore`, `ReplacedBefore`,
`ResultTooLarge`). After-hooks run via `tokio::spawn`, so they never
block the response path; panics inside them are isolated from the
caller.
- A hard `max_result_bytes` cap: oversized tool results (whether
produced by the inner handler or returned via `Replace`) are
swapped for a structured `result_too_large` error before reaching
the client.
Applications opt in at their handler-factory callsite using the
fluent `ToolHooks::new()` builder (the struct is `#[non_exhaustive]`,
so direct struct-literal construction is no longer supported):
```rust
use std::sync::Arc;
use rmcp_server_kit::tool_hooks::{HookOutcome, ToolHooks, with_hooks};
let hooks = Arc::new(
ToolHooks::new()
.with_max_result_bytes(256 * 1024)
.with_before(Arc::new(|ctx| Box::pin(async move {
// Example: deny calls to any tool whose name starts with
// "danger_" unless the caller is in the "admin" role.
if ctx.tool_name.starts_with("danger_")
&& ctx.role.as_deref() != Some("admin")
{
return HookOutcome::Deny(rmcp::ErrorData::invalid_request(
"tool restricted to admin role",
None,
));
}
HookOutcome::Continue
})))
.with_after(Arc::new(|ctx, disposition, size_bytes| {
let tool = ctx.tool_name.clone();
Box::pin(async move {
tracing::info!(
%tool,
?disposition,
size_bytes,
"tool call observed"
);
})
})),
);
let handler = with_hooks(MyHandler::new(), hooks);
// ...pass `handler` to `serve()`...
```
`rmcp_server_kit::serve()` itself never wraps handlers automatically.
---
## Full Example: Building a Custom MCP Server
A complete server with auth, RBAC, custom tools, and readiness probe:
```rust
use std::sync::Arc;
use rmcp_server_kit::auth::{AuthConfig, ApiKeyEntry, RateLimitConfig, generate_api_key};
use rmcp_server_kit::rbac::{RbacConfig, RbacPolicy, RoleConfig, current_role};
use rmcp_server_kit::transport::{McpServerConfig, serve};
use rmcp::handler::server::ServerHandler;
use rmcp::model::{ServerCapabilities, ServerConfig};
use rmcp::{tool, Error as McpError};
#[derive(Clone)]
struct MyHandler;
#[tool(tool_box)]
impl MyHandler {
/// Greet a user by name.
#[tool(description = "Say hello")]
async fn greet(&self, #[tool(param)] name: String) -> Result<String, McpError> {
let role = current_role().unwrap_or_else(|| "unknown".into());
Ok(format!("Hello, {name}! (caller role: {role})"))
}
/// List available items (safe for viewers).
#[tool(description = "List items")]
async fn list_items(&self) -> Result<String, McpError> {
Ok("item-1, item-2, item-3".into())
}
}
#[tool(tool_box)]
impl ServerHandler for MyHandler {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
}
}
#[tokio::main]
async fn main() -> rmcp_server_kit::Result<()> {
let mut observability = rmcp_server_kit::config::ObservabilityConfig::default();
observability.log_level = "info".into();
let _tracing_guard = rmcp_server_kit::observability::init_tracing_from_config_strict(&observability)?;
// Generate API keys (in production, store hashes in a config file)
let (admin_token, admin_hash) = generate_api_key()?;
let (viewer_token, viewer_hash) = generate_api_key()?;
tracing::info!(token = %admin_token, "admin token (rotate before production)");
tracing::info!(token = %viewer_token, "viewer token (rotate before production)");
// Authentication
let auth = AuthConfig::with_keys(vec![
ApiKeyEntry::new("admin-key", admin_hash, "admin"),
ApiKeyEntry::new("viewer-key", viewer_hash, "viewer"),
])
.with_rate_limit(RateLimitConfig::new(30));
// RBAC
let rbac = Arc::new(RbacPolicy::new(&RbacConfig::with_roles(vec![
RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]),
RoleConfig::new("viewer", vec!["list_items".into()], vec!["*".into()]),
])));
// Server config
let mut config = McpServerConfig::new("0.0.0.0:8443", "my-mcp-server", "1.0.0");
config.auth = Some(auth);
config.rbac = Some(rbac);
config.allowed_origins = vec!["http://localhost:3000".into()];
config.tool_rate_limit = Some(120);
// Optional: TLS
// config.tls_cert_path = Some("/etc/certs/server.crt".into());
// config.tls_key_path = Some("/etc/certs/server.key".into());
serve(config.validate()?, || MyHandler).await
}
```
---
## Client Usage Guide
### Health Check
```bash
curl http://127.0.0.1:8443/healthz
# {"status":"ok","name":"my-mcp-server","version":"1.0.0"}
```
### Readiness Check
```bash
curl http://127.0.0.1:8443/readyz
# 200: {"status":"ok","name":"my-mcp-server","version":"1.0.0"}
# 503: {"ready":false,"reason":"database unreachable"}
```
### MCP Initialize (required before tool calls)
```bash
curl -X POST http://127.0.0.1:8443/mcp \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "my-client", "version": "0.1"}
}
}'
```
> **Important:** The `Accept: application/json, text/event-stream` header is
> required by the MCP Streamable HTTP transport. Without it, you receive
> 406 Not Acceptable.
### List Available Tools
```bash
curl -X POST http://127.0.0.1:8443/mcp \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
```
### Call a Tool
```bash
curl -X POST http://127.0.0.1:8443/mcp \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "greet",
"arguments": {"name": "World"}
}
}'
```
### Error Responses
| HTTP Status | Meaning | Cause |
|-------------|---------|-------|
| 200 | Success | Valid MCP response (may contain JSON-RPC error) |
| 401 | Unauthorized | Missing, invalid, or expired credentials |
| 403 | Forbidden | RBAC denied the operation, or origin rejected |
| 406 | Not Acceptable | Missing required `Accept` header |
| 408 | Request Timeout | Request exceeded `request_timeout` |
| 413 | Payload Too Large | Body exceeded `max_request_body` |
| 429 | Too Many Requests | Auth or tool rate limit exceeded |
### Using with MCP Clients
rmcp-server-kit implements the standard MCP Streamable HTTP transport, so any compliant
MCP client works:
```json
{
"mcpServers": {
"my-server": {
"url": "http://127.0.0.1:8443/mcp",
"headers": {
"Authorization": "Bearer <TOKEN>"
}
}
}
}
```
---
## Recipes
Short, copy-pasteable snippets for the most common production setups. Each
recipe shows only the wiring relevant to that feature; assemble them inside
the `Quick Start` `main()` skeleton.
Two of these recipes are also available as runnable examples in the
repository:
```bash
cargo run --example api_key_rbac
cargo run --example oauth_server --features oauth
```
### Recipe 1: OAuth 2.1 resource server (JWT validation)
Validate `Authorization: Bearer <jwt>` against a remote JWKS and map scopes
onto RBAC roles. Requires the `oauth` feature.
```rust,ignore
use std::sync::Arc;
use rmcp_server_kit::auth::AuthConfig;
use rmcp_server_kit::oauth::OAuthConfig;
use rmcp_server_kit::rbac::{RbacConfig, RbacPolicy, RoleConfig};
use rmcp_server_kit::transport::McpServerConfig;
let oauth = OAuthConfig::builder(
"https://auth.example.com/",
"my-mcp-server",
"https://auth.example.com/.well-known/jwks.json",
)
.scope("mcp:admin", "admin")
.scope("mcp:read", "viewer")
.build();
let mut auth = AuthConfig::with_keys(vec![]);
auth.oauth = Some(oauth);
let rbac = Arc::new(RbacPolicy::new(&RbacConfig::with_roles(vec![
RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]),
RoleConfig::new("viewer", vec!["resource_list".into()], vec!["*".into()]),
])));
let config = McpServerConfig::new("127.0.0.1:8080", "my-server", "0.1.0")
.with_auth(auth)
.with_rbac(rbac)
.with_public_url("http://127.0.0.1:8080");
```
### Recipe 2: OAuth proxy + token exchange + introspection
Expose `/oauth/authorize`, `/oauth/token`, `/oauth/introspect`, and
`/oauth/revoke` endpoints that proxy to your IdP, optionally exchanging
the client's token for a downstream service token (RFC 8693). Requires
`oauth`.
```rust,ignore
use rmcp_server_kit::oauth::{OAuthConfig, OAuthProxyConfig, TokenExchangeConfig};
use secrecy::SecretString;
let proxy = OAuthProxyConfig::builder(
"https://auth.example.com/oauth/authorize",
"https://auth.example.com/oauth/token",
"my-client-id",
)
.client_secret(SecretString::new("my-client-secret".into()))
.introspection_url("https://auth.example.com/oauth/introspect")
.revocation_url("https://auth.example.com/oauth/revoke")
.expose_admin_endpoints(true)
.build();
let token_exchange = TokenExchangeConfig::new(
"https://downstream.example.com/oauth/token".to_string(),
"downstream-client-id".to_string(),
Some(SecretString::new("downstream-secret".into())), // RFC 6749 §2.3.1 client_secret
None, // RFC 8705 §2 client_cert (mTLS) -- see below
)
.with_audience("downstream-audience"); // RFC 8693 §2.1 OPTIONAL
let oauth = OAuthConfig::builder(
"https://auth.example.com/",
"my-mcp-server",
"https://auth.example.com/.well-known/jwks.json",
)
.proxy(proxy)
.token_exchange(token_exchange)
.build();
```
`OAuthConfig::validate` enforces RFC 8705 §2 mutual exclusion: pass exactly one of `client_secret` or `client_cert`. Passing both, or neither, is a startup error.
**RFC 8705 §2 mTLS client authentication** (requires the `oauth-mtls-client` cargo feature):
```rust,ignore
use std::path::PathBuf;
use rmcp_server_kit::oauth::{ClientCertConfig, TokenExchangeConfig};
let token_exchange = TokenExchangeConfig::new(
"https://downstream.example.com/oauth/token".to_string(),
"downstream-client-id".to_string(),
None, // omit client_secret
Some(ClientCertConfig::new(
PathBuf::from("/etc/certs/oauth-client.pem"), // PEM cert (leaf or full chain)
PathBuf::from("/etc/certs/oauth-client.key"), // PEM private key (PKCS#8 or RSA / EC; unencrypted)
)),
)
.with_audience("downstream-audience");
```
Without the `oauth-mtls-client` feature enabled, a `client_cert`-bearing config fails closed at `OAuthConfig::validate` time. Cert + key paths are PEM-validated at startup (missing files, malformed PEM, and encrypted keys all surface before the first request). The token-exchange request authenticates by presenting the configured certificate at TLS handshake -- no `Authorization` header is sent -- and uses `redirect::Policy::none()` so an attacker-controlled 3xx from the token endpoint cannot re-present the client cert to a different host. Issued access tokens behave as bearer tokens once minted (`cnf.x5t#S256` certificate-binding per RFC 8705 §3 is out of scope). In-place certificate rotation requires server restart.
Inside a tool handler, retrieve the (already-exchanged) downstream token via:
```rust,ignore
if let Some(token) = rmcp_server_kit::rbac::current_token() {
// use token.expose_secret() as Authorization header
}
```
### Recipe 3: API key + RBAC + per-tool argument allowlist
Argon2-hashed API keys with role-based tool allowlists and per-argument
constraints.
```rust,ignore
use std::sync::Arc;
use rmcp_server_kit::auth::{ApiKeyEntry, AuthConfig, generate_api_key};
use rmcp_server_kit::rbac::{ArgumentAllowlist, RbacConfig, RbacPolicy, RoleConfig};
// In production, load pre-generated PHC hashes from config instead.
let (admin_token, admin_hash) = generate_api_key()?;
let (viewer_token, viewer_hash) = generate_api_key()?;
let auth = AuthConfig::with_keys(vec![
ApiKeyEntry::new("admin-key", admin_hash, "admin"),
ApiKeyEntry::new("viewer-key", viewer_hash, "viewer"),
]);
let viewer = RoleConfig::new(
"viewer",
vec!["echo".into(), "resource_list".into()],
vec!["*".into()],
)
.with_argument_allowlists(vec![ArgumentAllowlist::new_required(
"echo", "message", vec!["hello".into(), "ping".into()],
)]);
let rbac = Arc::new(RbacPolicy::new(&RbacConfig::with_roles(vec![
RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]),
viewer,
])));
```
### Recipe 4: mTLS server (client certificate authentication)
Require client certificates signed by a known CA. Identity (CN) and role
are extracted from the cert. Combine with API keys / OAuth for hybrid auth,
or use mTLS-only by leaving `api_keys` empty.
```rust,ignore
use std::path::PathBuf;
use rmcp_server_kit::auth::{AuthConfig, MtlsConfig};
let mut auth = AuthConfig::with_keys(vec![]);
auth.mtls = Some(MtlsConfig {
ca_cert_path: PathBuf::from("/etc/certs/client-ca.pem"),
required: true, // reject connections without a client cert
default_role: "operator".into(), // role used when cert CN has no explicit mapping
});
let config = McpServerConfig::new("127.0.0.1:8443", "my-server", "0.1.0")
.with_auth(auth)
.with_tls("/etc/certs/server.crt", "/etc/certs/server.key");
```
The TLS accept path can be tuned for unusual environments (since 1.9.0;
both values are startup-only - they bind at listener construction and
are not hot-reloadable):
```rust,ignore
use std::time::Duration;
let config = McpServerConfig::new("127.0.0.1:8443", "my-server", "0.1.0")
.with_tls("/etc/certs/server.crt", "/etc/certs/server.key")
// Allow slow mTLS clients up to 30s to complete the handshake
// (default: 10s).
.with_tls_handshake_timeout(Duration::from_secs(30))
// Permit more simultaneous handshakes for bursty fleets
// (default: 256).
.with_max_concurrent_tls_handshakes(1024);
```
### Recipe 5: Prometheus metrics
Expose a `/metrics` endpoint on a separate listener (so it can bind to a
private interface or different port). Requires the `metrics` feature.
```rust,ignore
let config = McpServerConfig::new("127.0.0.1:8080", "my-server", "0.1.0")
.with_metrics("127.0.0.1:9090".parse().unwrap());
```
The registry exposes request counters, latency histograms, auth/RBAC
outcomes, and tool-call metrics out of the box. Add your own metrics by
registering them against `rmcp_server_kit::metrics::registry()`.
### Recipe 6: Tool hooks (audit + deny + result-size cap)
Wrap a `ServerHandler` with async `before` / `after` hooks to audit every
tool invocation, deny calls based on runtime state, and cap result sizes.
```rust,ignore
use std::sync::Arc;
use rmcp_server_kit::tool_hooks::{HookOutcome, ToolHooks, with_hooks};
let hooks = Arc::new(
ToolHooks::new()
.with_max_result_bytes(1_048_576) // 1 MiB cap on tool results
.with_before(Arc::new(|ctx| {
Box::pin(async move {
tracing::info!(tool = %ctx.tool_name, role = ?ctx.role, "tool call");
// Return HookOutcome::Deny(...) to reject, or
// HookOutcome::Replace(Box::new(result)) to short-circuit.
HookOutcome::Continue
})
}))
.with_after(Arc::new(|ctx, disposition, bytes| {
Box::pin(async move {
tracing::info!(
tool = %ctx.tool_name,
?disposition,
bytes,
"tool call finished"
);
})
})),
);
let handler_factory = move || with_hooks(MyHandler, Arc::clone(&hooks));
serve(config.validate()?, handler_factory).await
```
---
### Complete TOML configuration reference
rmcp-server-kit config structs derive `Deserialize`, so you can load them directly from
TOML. Keys annotated with `# env: VAR` can be overridden at runtime via `apply_env_overrides`; see [Environment variable overrides](#environment-variable-overrides-opt-in) for full semantics.
TOML fences in this guide are parsed by CI: `toml` fences are complete operator
configuration and must match the strict rmcp-server-kit schema; `toml,cargo`
fences are Cargo manifest snippets; `toml,fragment` fences are intentionally
incomplete excerpts and are syntax-checked only.
```toml
[server]
listen_addr = "0.0.0.0" # env: RMCP_SERVER_KIT__SERVER__LISTEN_ADDR
listen_port = 8443 # env: RMCP_SERVER_KIT__SERVER__LISTEN_PORT
tls_cert_path = "/etc/certs/server.crt" # env: RMCP_SERVER_KIT__SERVER__TLS_CERT_PATH
tls_key_path = "/etc/certs/server.key" # env: RMCP_SERVER_KIT__SERVER__TLS_KEY_PATH
shutdown_timeout = "30s"
request_timeout = "120s"
allowed_origins = ["http://localhost:3000", "https://myapp.example.com"]
tool_rate_limit = 120
session_binding = true
# session_binding_secret = "replace-with-32-plus-random-bytes" # env: RMCP_SERVER_KIT__SERVER__SESSION_BINDING_SECRET
tool_list_filtering = true
key_eviction_policy = "evict_lru" # env: RMCP_SERVER_KIT__SERVER__KEY_EVICTION_POLICY
max_request_body = 1048576
expose_build_metadata = false
# public_url = "https://mcp.example.com" # env: RMCP_SERVER_KIT__SERVER__PUBLIC_URL
admin_enabled = false # env: RMCP_SERVER_KIT__SERVER__ADMIN_ENABLED
[server.security_headers]
# Customise any of the twelve OWASP headers; omit a key to keep the built-in default.
content_security_policy = "default-src 'self'; frame-ancestors https://admin.example.com"
strict_transport_security = "max-age=600; includeSubDomains"
cross_origin_embedder_policy = "" # omit this header entirely
[server.auth]
enabled = true
[[server.auth.api_keys]]
name = "admin-key"
hash = "$argon2id$v=19$m=19456,t=2,p=1$..."
role = "admin"
[[server.auth.api_keys]]
name = "viewer-key"
hash = "$argon2id$v=19$m=19456,t=2,p=1$..."
role = "viewer"
expires_at = "2025-12-31T23:59:59Z"
[server.auth.mtls]
ca_cert_path = "/etc/certs/client-ca.pem"
required = false
default_role = "operator"
[server.auth.rate_limit]
max_attempts_per_minute = 30
# Optional: cap on unauthenticated requests/min per source IP, consulted
# BEFORE Argon2id verification runs. Protects against CPU-spray attacks.
# Defaults to 10 * max_attempts_per_minute when omitted. mTLS callers
# bypass this gate entirely.
# pre_auth_max_per_minute = 300
# OAuth 2.1 (requires 'oauth' feature)
[server.auth.oauth]
issuer = "https://auth.example.com" # env: RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ISSUER
audience = "my-mcp-server" # env: RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__AUDIENCE
jwks_uri = "https://auth.example.com/.well-known/jwks.json" # env: RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__JWKS_URI
# Optional: pin the accepted JWT signing algorithms. Omit to accept the
# built-in set (RS256/384/512, ES256/384, PS256/384/512, EdDSA). May only
# narrow that set -- HS* and `none` are never selectable.
allowed_algorithms = ["RS256"] # env: RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ALLOWED_ALGORITHMS
jwks_cache_ttl = "10m"
[[server.auth.oauth.scopes]]
scope = "mcp:admin"
role = "admin"
[[server.auth.oauth.scopes]]
scope = "mcp:read"
role = "viewer"
# Optional OAuth 2.1 proxy: exposes /authorize, /token, /register on this
# server and forwards them to the upstream IdP.
[server.auth.oauth.proxy]
authorize_url = "https://auth.example.com/authorize"
token_url = "https://auth.example.com/token"
client_id = "my-mcp-server"
# Drop the RFC 8707 `resource` parameter when forwarding /authorize and
# /token upstream. Required for Microsoft Entra v2.0, which rejects it
# alongside a differing api:// scope (AADSTS9010010). Leave false to
# preserve spec behaviour. Never strips PKCE/state/redirect_uri.
strip_resource_param = false # env: RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__PROXY__STRIP_RESOURCE_PARAM
[rbac]
enabled = true
# How `allow` entries are matched against operation names.
# "legacy" (default) - exact string equality, plus the literal "*" (all ops).
# "glob" - `*` wildcards are honoured, e.g. allow = ["container_*"].
# `deny` entries are ALWAYS glob-matched regardless of this setting: widening a
# deny can only remove capability, whereas widening an allow grants access, so
# allow-globbing stays opt-in. Under "legacy" the server warns about any `allow`
# entry containing a `*`, because the `*` is then matched literally.
allow_operation_matching = "glob"
# Server-wide kill switch, evaluated before any role and always glob-matched.
# Vetoes even a role's `allow = ["*"]`; can only ever remove capability.
# Gated on `enabled` above. Invocation is enforced on `tools/call`; `tools/list`
# is filtered by default via server.tool_list_filtering.
global_deny = ["*_purge_*"]
# Optional: stable HMAC key used to redact argument values in deny logs.
# When an argument fails the per-tool allowlist, the denied value is
# logged as `arg_hmac=<8-hex-chars>` (HMAC-SHA256 prefix) instead of the
# raw value, so log readers can correlate repeats without seeing the
# secret. When omitted, a random per-process salt is used (so the same
# input hashes differently across restarts). Set this to a long random
# string from your secret manager if you want stable correlation.
# redaction_salt = "replace-with-long-random-string-from-secrets-manager" # env: RMCP_SERVER_KIT__RBAC__REDACTION_SALT
# (Kubernetes: use RMCP_SERVER_KIT__RBAC__REDACTION_SALT_FILE to supply the salt from a mounted Secret file.)
[[rbac.roles]]
name = "admin"
allow = ["*"]
hosts = ["*"]
[[rbac.roles]]
name = "ops"
allow = ["container_*", "image_*", "pod_*"]
deny = ["container_delete"]
hosts = ["prod-*", "staging-*"]
[[rbac.roles]]
name = "viewer"
allow = ["container_list", "container_inspect", "image_list"]
hosts = ["prod-*"]
[[rbac.roles]]
name = "restricted"
allow = ["container_exec"]
hosts = ["*"]
[[rbac.roles.argument_allowlists]]
tool = "container_exec"
argument = "cmd"
allowed = ["ls", "cat", "ps", "df", "top"]
[observability]
log_level = "info"
log_format = "json" # env: RMCP_SERVER_KIT__OBSERVABILITY__LOG_FORMAT
audit_log_path = "/var/log/my-server/audit.log"
metrics_enabled = true # env: RMCP_SERVER_KIT__OBSERVABILITY__METRICS_ENABLED
metrics_bind = "127.0.0.1:9090" # env: RMCP_SERVER_KIT__OBSERVABILITY__METRICS_BIND
log_plaintext_oauth_tokens = false # env: RMCP_SERVER_KIT__OBSERVABILITY__LOG_PLAINTEXT_OAUTH_TOKENS
log_oauth_claim_values = false # env: RMCP_SERVER_KIT__OBSERVABILITY__LOG_OAUTH_CLAIM_VALUES
log_tool_call_arguments = false # env: RMCP_SERVER_KIT__OBSERVABILITY__LOG_TOOL_CALL_ARGUMENTS
log_upstream_error_bodies = false # env: RMCP_SERVER_KIT__OBSERVABILITY__LOG_UPSTREAM_ERROR_BODIES
```
### Bridging TOML config to `McpServerConfig`
`ServerConfig` is a TOML schema - it deserializes cleanly from your config file
but cannot reach `serve()` on its own. `serve()` takes `McpServerConfig`, which
holds runtime-only state that cannot be expressed in TOML: callbacks, RBAC
policy objects, metrics listeners, and extra routers. The `ServerConfig`
existed before this bridge, but nothing in the kit consumed it, so downstreams
had to hand-wire every field manually.
`ServerConfig::apply_to_mcp_config` closes that gap. Call it with a bare
`McpServerConfig::new(...)` and it returns a new `McpServerConfig` with every
TOML-controlled transport field applied. See the compiled rustdoc example on
[`ServerConfig::apply_to_mcp_config`](https://docs.rs/rmcp-server-kit/latest/rmcp_server_kit/config/struct.ServerConfig.html#method.apply_to_mcp_config) for the API-local call shape, and
[`examples/config_file_server.rs`](../examples/config_file_server.rs) for the
complete runnable pipeline. Name and version come from the binary, not TOML;
chain application builder calls after the bridge when they must take precedence
over TOML.
**Replacement semantics.** The bridge uses replacement semantics for every
field it covers: `None` and `false` values from TOML overwrite whatever was on
`base`, including options you set programmatically before calling the bridge.
The full precedence chain is:
> built-in defaults < TOML `ServerConfig` < application builder methods chained
> **after** `apply_to_mcp_config` < `validate()`
Fields preserved from `base` unchanged are the runtime-only ones TOML cannot
express: `name`, `version`, `rbac`, `readiness_check`, `extra_router`,
`on_reload_ready`, `metrics_enabled`, and `metrics_bind`.
**Fallibility.** `apply_to_mcp_config` returns
`Result<McpServerConfig, RmcpServerKitError>`. It fails with `RmcpServerKitError::Config` when
any duration string in the config cannot be parsed by `humantime` - for
example `request_timeout = "not-a-duration"`. Calling `validate_server_config`
first catches common structural errors before the bridge runs, giving cleaner
diagnostics.
**`stdio_enabled` is not bridged.** The `[server]` TOML field `stdio_enabled`
selects the separate `serve_stdio()` entry point, which bypasses auth, RBAC,
TLS, and origin checks entirely. Routing between `serve()` and `serve_stdio()`
is the caller's responsibility; the bridge covers `serve()` only.
### Environment variable overrides (opt-in)
Environment reading is never automatic. `serve()`, `validate()`, and every config constructor read no process environment. Three opt-in methods layer env overrides onto already-constructed config structs:
- `ServerConfig::apply_env_overrides` reads `RMCP_SERVER_KIT__SERVER__*`
- `ObservabilityConfig::apply_env_overrides` reads `RMCP_SERVER_KIT__OBSERVABILITY__*`
- `RbacConfig::apply_env_overrides` reads `RMCP_SERVER_KIT__RBAC__*` (implemented in `src/rbac.rs`)
**Why three methods instead of one?** The crate has no root config struct. Each downstream composes these three structs differently into its own root type, so a single kit-level method would collide with the downstream's own root. Each method mutates only its own struct; the caller concatenates the returned audit reports.
**Precedence chain:**
> struct defaults < TOML deserialization < `apply_env_overrides` < application builder methods after `apply_to_mcp_config` < `validate()`
#### Call-order skeleton
The complete, compiled, runnable version of this pipeline is
[`examples/config_file_server.rs`](../examples/config_file_server.rs). Keep that
example as the source of truth for imports, the downstream root config type,
handler wiring, and feature-gated metrics handling. Inline here, the minimal
call order is:
1. Parse TOML into your downstream root config.
2. Call `ServerConfig::apply_env_overrides`.
3. Call `ObservabilityConfig::apply_env_overrides`.
4. Call `RbacConfig::apply_env_overrides`.
5. Initialize tracing from the final `ObservabilityConfig`, then log the reports.
6. Call `validate_server_config(&server_cfg)`.
7. Call `server_cfg.apply_to_mcp_config(McpServerConfig::new(...))`.
8. Attach runtime-only state after the bridge: RBAC policy, metrics, handlers.
9. Call `mcp_cfg.validate()`, then `serve(...)`.
#### Variable reference
<!-- BEGIN ENV_OVERRIDE_TABLE -->
| Environment variable | Target TOML path | Type | Notes |
|---|---|---|---|
| `RMCP_SERVER_KIT__SERVER__LISTEN_ADDR` | `server.listen_addr` | String | |
| `RMCP_SERVER_KIT__SERVER__LISTEN_PORT` | `server.listen_port` | u16 | |
| `RMCP_SERVER_KIT__SERVER__PUBLIC_URL` | `server.public_url` | String | |
| `RMCP_SERVER_KIT__SERVER__TLS_CERT_PATH` | `server.tls_cert_path` | Path | |
| `RMCP_SERVER_KIT__SERVER__TLS_KEY_PATH` | `server.tls_key_path` | Path | |
| `RMCP_SERVER_KIT__SERVER__ADMIN_ENABLED` | `server.admin_enabled` | bool | |
| `RMCP_SERVER_KIT__SERVER__KEY_EVICTION_POLICY` | `server.key_eviction_policy` | KeyEvictionPolicy | |
| `RMCP_SERVER_KIT__SERVER__SESSION_BINDING_SECRET` | `server.session_binding_secret` | SecretString | secret; redacted in report |
| `RMCP_SERVER_KIT__SERVER__SESSION_BINDING_SECRET_FILE` | `server.session_binding_secret` | Path | secret; redacted in report |
| `RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ISSUER` | `server.auth.oauth.issuer` | String | requires `oauth` feature |
| `RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__AUDIENCE` | `server.auth.oauth.audience` | String | requires `oauth` feature |
| `RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__JWKS_URI` | `server.auth.oauth.jwks_uri` | String | requires `oauth` feature |
| `RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ALLOWED_ALGORITHMS` | `server.auth.oauth.allowed_algorithms` | comma-separated algorithm list | requires `oauth` feature; may only narrow the built-in set |
| `RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__PROXY__STRIP_RESOURCE_PARAM` | `server.auth.oauth.proxy.strip_resource_param` | bool | requires `oauth` feature; also requires `[server.auth.oauth.proxy]` to be declared |
| `RMCP_SERVER_KIT__OBSERVABILITY__LOG_FORMAT` | `observability.log_format` | String | |
| `RMCP_SERVER_KIT__OBSERVABILITY__METRICS_ENABLED` | `observability.metrics_enabled` | bool | |
| `RMCP_SERVER_KIT__OBSERVABILITY__METRICS_BIND` | `observability.metrics_bind` | String | |
| `RMCP_SERVER_KIT__OBSERVABILITY__LOG_PLAINTEXT_OAUTH_TOKENS` | `observability.log_plaintext_oauth_tokens` | bool | |
| `RMCP_SERVER_KIT__OBSERVABILITY__LOG_OAUTH_CLAIM_VALUES` | `observability.log_oauth_claim_values` | bool | |
| `RMCP_SERVER_KIT__OBSERVABILITY__LOG_TOOL_CALL_ARGUMENTS` | `observability.log_tool_call_arguments` | bool | |
| `RMCP_SERVER_KIT__OBSERVABILITY__LOG_UPSTREAM_ERROR_BODIES` | `observability.log_upstream_error_bodies` | bool | |
| `RMCP_SERVER_KIT__RBAC__REDACTION_SALT` | `rbac.redaction_salt` | SecretString | secret; redacted in report |
| `RMCP_SERVER_KIT__RBAC__REDACTION_SALT_FILE` | `rbac.redaction_salt` | Path | secret; redacted in report |
<!-- END ENV_OVERRIDE_TABLE -->
#### Naming convention
All variables share the `RMCP_SERVER_KIT` prefix. The nesting delimiter is `__` (double underscore). Single underscores appear within field names themselves (`listen_addr`, `jwks_uri`, `redaction_salt`), so a single-underscore delimiter would be ambiguous: `RMCP_SERVER_KIT_SERVER_LISTEN_ADDR` reads equally as `SERVER` + `LISTEN_ADDR` or `SERVER_LISTEN` + `ADDR`. The `__` convention is unambiguous and mirrors the dotted TOML path directly.
#### Failure semantics
Every parse failure fails closed. If a variable is present but unparseable (e.g. `RMCP_SERVER_KIT__SERVER__LISTEN_PORT=not-a-number`), `apply_env_overrides` immediately returns `Err(RmcpServerKitError::Config)` naming the exact variable and the expected type. No partial mutation occurs. There is no warn-and-ignore path.
#### Secret handling
`RMCP_SERVER_KIT__RBAC__REDACTION_SALT` accepts the salt value directly as a string. For Kubernetes Secret volume mounts, set `RMCP_SERVER_KIT__RBAC__REDACTION_SALT_FILE` to the path of the mounted file; `RbacConfig::apply_env_overrides` reads the file and uses its contents as the salt.
Setting both the direct variable and the `_FILE` variable simultaneously is a hard startup error: `apply_env_overrides` returns `RmcpServerKitError::Config` naming both variables.
An empty or whitespace-only salt (either form) is rejected with `RmcpServerKitError::Config`.
**File normalization.** Exactly one terminal line ending is stripped from the file contents: `\r\n` (CRLF), a lone `\n` (LF), or a lone `\r` (CR). All other content is preserved exactly, including leading and trailing spaces and any internal newlines. The same logical secret therefore produces the same redaction salt whether supplied inline (no trailing newline) or written to a file with a standard trailing newline (`echo "my-salt" > salt.txt` produces `my-salt\n`, which normalizes to `my-salt`). Spaces surrounding the value are significant: `" my-salt "` and `"my-salt"` hash differently.
#### Audit report
Each method returns `Vec<EnvOverride>`. Each entry carries:
- `env_var` -- the name of the variable applied
- `target_field` -- the dotted TOML path overridden (e.g. `server.listen_port`)
- `source` -- `EnvOverrideSource::Env` (value read directly from the variable) or `EnvOverrideSource::File` (value read from the file named by a `_FILE` variable)
- `value` -- the applied string for non-secret targets; `None` for secret-typed targets
Secret-typed targets (`server.session_binding_secret`, `rbac.redaction_salt`) always carry `value: None`. The secret never appears in `EnvOverride::value` or in its `Debug` output. Log the report after initializing tracing so any env variable that shadowed a TOML value leaves a structured trail at startup, as shown in [`examples/config_file_server.rs`](../examples/config_file_server.rs).
#### `oauth` feature interaction
The three `RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__*` variables require the `oauth` Cargo feature. Setting any one of them in a binary built without `--features oauth` is a startup error, never a silent no-op: `ServerConfig::apply_env_overrides` returns `RmcpServerKitError::Config` naming the variable and stating it requires the `oauth` feature.
When the `oauth` feature is enabled, `[server.auth.oauth]` must already be declared in TOML. The method cannot create the table; it only populates fields within an existing one. If the table is absent, the call fails with `RmcpServerKitError::Config` instructing the operator to declare `[server.auth.oauth]` first.
The intended Kubernetes pattern: declare a minimal `[server.auth.oauth]` stub in a ConfigMap (with `role_claim` and other static RBAC-mapping config), and supply the environment-specific `issuer`, `audience`, and `jwks_uri` via Secrets or Deployment env vars.
#### `RUST_LOG` and log level
`RUST_LOG` remains the log-level control, read directly by `init_tracing` and `init_tracing_from_config_strict` via `tracing-subscriber`'s env filter. There is deliberately no `RMCP_SERVER_KIT__` prefixed alias: `RUST_LOG` is the established convention across the Rust ecosystem, and a parallel alias would create two sources of truth for the same setting.
#### Metrics caveat
`ObservabilityConfig::apply_env_overrides` mutates `obs_cfg.metrics_enabled` and `obs_cfg.metrics_bind` on the `ObservabilityConfig` struct. These fields do not reach `serve()` automatically: `init_tracing_from_config_strict` reads only the logging and audit-log fields; metrics configuration lives on `McpServerConfig` and must be wired there explicitly. The conditional `with_metrics` call in [`examples/config_file_server.rs`](../examples/config_file_server.rs) is the reference pattern.
#### What is not env-configurable
These fields are intentionally absent from the env path:
- **`auth.enabled`** -- disabling authentication via a single env var is too consequential and too easy to set accidentally.
- **`security_headers`** -- semicolon-heavy CSP values are brittle in env and trivially weakened by accident; review them in a config-file diff.
- **`max_request_body`** and **`expose_build_metadata`** -- best reviewed alongside related infrastructure settings in a config file.
- **API key lists and RBAC roles** -- security policy belongs in a structured, version-controlled config file.
- **OAuth proxy and token-exchange internals, SSRF allowlists, rate-limit tuning, `trusted_proxies`** -- consequential enough to warrant the full config-file review path.
---
## Testing Your Server
rmcp-server-kit includes 114 tests (unit, integration, and end-to-end). For your own
server, you can write similar e2e tests using `reqwest`:
```rust
use rmcp_server_kit::auth::{AuthConfig, ApiKeyEntry, generate_api_key};
use rmcp_server_kit::transport::{McpServerConfig, serve};
use std::time::Duration;
async fn free_port() -> u16 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
listener.local_addr().unwrap().port()
}
async fn spawn_test_server(config: McpServerConfig) -> String {
let port = config.bind_addr.rsplit_once(':').unwrap().1.to_string();
let base = format!("http://127.0.0.1:{port}");
tokio::spawn(async move {
let _ = serve(config.validate().expect("test config valid"), || MyHandler).await;
});
// Wait for startup
for _ in 0..50 {
if reqwest::get(&format!("{base}/healthz")).await.is_ok() {
return base;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("server did not start");
}
#[tokio::test]
async fn test_health() {
let port = free_port().await;
let config = McpServerConfig::new(format!("127.0.0.1:{port}"), "test", "0.1");
let base = spawn_test_server(config).await;
let resp = reqwest::get(&format!("{base}/healthz")).await.unwrap();
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn test_auth_rejects_unauthenticated() {
let port = free_port().await;
let mut config = McpServerConfig::new(format!("127.0.0.1:{port}"), "test", "0.1");
config.auth = Some(AuthConfig::with_keys(vec![]));
let base = spawn_test_server(config).await;
let client = reqwest::Client::new();
let resp = client
.post(&format!("{base}/mcp"))
.body("{}")
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
```
Run the rmcp-server-kit test suite:
```bash
# All tests (requires all features)
cargo test -p rmcp-server-kit --all-features
# Just e2e tests
cargo test -p rmcp-server-kit --all-features --test e2e
# Just unit tests
cargo test -p rmcp-server-kit --all-features --lib
```