Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ jobs:
- name: Run tests
run: cargo test --all-features

audit:
name: Supply-chain audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@cargo-audit
- name: cargo audit
run: cargo audit

build:
name: Build (${{ matrix.target }})
needs: test
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to MCPlex are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.7.1] β€” 2026-07-21

### πŸ”’ Security
- **RBAC self-assertion bypass (HIGH)** β€” `dispatch_real_tool` fell back to a client-supplied `_mcplex_role` request param whenever no server-verified role was established (i.e. no `api_key`/`api_keys` configured). Any caller could self-assert `role: "admin"` and bypass RBAC entirely. Role now comes exclusively from the auth middleware's server-verified `trusted_role` β€” the client-controlled fallback has been removed.
- **Dashboard authentication** β€” `/api/*` dashboard routes had no authentication, only permissive CORS. Added optional `gateway.dashboard_api_key`; when set, all dashboard API routes require a matching `Authorization: Bearer` or `X-API-Key` header (constant-time compared). Unset by default for backward compatibility, with a startup warning recommending a localhost-only bind in that case.
- **Cross-tenant cache leakage** β€” the tool response cache was keyed only by tool name + arguments, so in a multi-key deployment one tenant's cached response could be served to another. Cache entries are now partitioned by caller role.
- **Empty-string secrets from unset env vars** β€” `${ENV_VAR}` expansion silently produced an empty string for unset variables, which could make `gateway.api_key`/`dashboard_api_key`/`api_keys` resolve to `""` and turn "auth required" into a no-op. `validate_config` now rejects empty-string keys/roles at startup instead of silently bypassing auth.
- **Audit log redaction list expanded** β€” added `pwd`, `auth`, `cookie`, `session`, `bearer` to the sensitive-key redaction list used before writing audit log entries.
- **RBAC-without-auth warning** β€” startup now warns when `security.enable_rbac = true` but no `api_key`/`api_keys` are configured, since every tool call will correctly deny-by-default in that configuration (previously silent).

### ✨ Features
- **Configurable stdio handshake timeout** (fixes [#20](https://github.com/ModernOps888/mcplex/issues/20)) β€” the initial stdio MCP handshake (`initialize`) previously used a hardcoded 30s timeout shared with steady-state tool calls, causing spurious failures for servers with a slow cold start (e.g. `npx` resolving/downloading a package on first run). The handshake timeout is now independently configurable via `security.default_handshake_timeout_secs` (global default, 30s) and `servers[].handshake_timeout_secs` (per-server override), fully decoupled from `security.request_timeout_secs`, which now applies only to steady-state requests once connected.
- **`0 = no timeout` is now implemented** for both `request_timeout_secs` and `default_handshake_timeout_secs` β€” previously documented but never actually honored anywhere in the code; a `0` value now waits indefinitely instead of timing out immediately.

### πŸ§ͺ Testing
- 38 tests passing (up from 32) β€” new regression tests for the RBAC self-assertion fix, role-partitioned cache, and handshake-timeout config parsing/defaults.

## [0.7.0] β€” 2026-07-21

### πŸ€– Model Ecosystem Refresh (July 2026)
Expand Down Expand Up @@ -144,6 +161,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CLI with `--config`, `--verbose`, `--listen`, `--dashboard`, and `--check` options
- MIT licensed

[0.7.1]: https://github.com/ModernOps888/mcplex/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/ModernOps888/mcplex/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/ModernOps888/mcplex/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/ModernOps888/mcplex/compare/v0.4.0...v0.5.0
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcplex"
version = "0.7.0"
version = "0.7.1"
edition = "2021"
description = "MCPlex - The MCP Smart Gateway. Semantic tool routing, security guardrails, and real-time observability for AI agents."
license = "MIT"
Expand Down
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,8 @@ allowed_tools = ["*/list_*", "*/get_*"]
blocked_tools = ["*/delete_*", "*/drop_*"]
```

> **Role trust boundary:** a caller's role is only ever established server-side, from a verified `api_key`/`api_keys` match β€” never from anything a client sends in the request body. When `enable_rbac = true` and no `api_key`/`api_keys` are configured, every tool call is denied by default (no role can be established), and the gateway logs a startup warning so this isn't mistaken for a bug.

### Per-Server Tool Blocklists

```toml
Expand Down Expand Up @@ -457,6 +459,13 @@ Built-in observability dashboard at `http://localhost:9090`:

Glassmorphism design with animated gradients. Auto-refreshes every 3 seconds. Zero configuration.

By default the dashboard's `/api/*` routes are unauthenticated β€” fine for `127.0.0.1`-only binds, but if you expose the dashboard beyond localhost, set `gateway.dashboard_api_key` so requests need an `Authorization: Bearer <key>` (or `X-API-Key`) header:

```toml
[gateway]
dashboard_api_key = "${MCPLEX_DASHBOARD_API_KEY}"
```

## πŸ“¦ Response Caching

Avoid redundant upstream calls for read-only tools:
Expand All @@ -468,7 +477,7 @@ ttl_seconds = 300 # 5 minute TTL
max_entries = 1000 # Max cached responses
```

MCPlex **auto-detects** read-only tools by prefix (`list_*`, `get_*`, `search_*`, `query_*`, `describe_*`, `show_*`). You can override with custom patterns:
Cached responses are partitioned by caller role, so a cache entry from one role/tenant is never served to another. MCPlex **auto-detects** read-only tools by prefix (`list_*`, `get_*`, `search_*`, `query_*`, `describe_*`, `show_*`). You can override with custom patterns:

```toml
[cache]
Expand Down Expand Up @@ -527,6 +536,7 @@ MCPlex aggregates and forwards **all three** MCP capability types from upstream
| `hot_reload` | bool | `true` | Auto-reload config on file change |
| `name` | string | `mcplex` | Gateway instance name |
| `api_key` | string | β€” | API key for client auth (supports `${ENV}`) |
| `dashboard_api_key` | string | β€” | API key for the dashboard `/api/*` routes (supports `${ENV}`). If unset, the dashboard is unauthenticated β€” bind it to `127.0.0.1` in that case. |
| `rate_limit_rps` | int | `0` | Max requests/sec per client (0 = unlimited) |

### `[router]`
Expand All @@ -549,7 +559,8 @@ MCPlex aggregates and forwards **all three** MCP capability types from upstream
| `max_log_size_mb` | int | `100` | Max log file size before rotation (keeps 5 backups) |
| `circuit_breaker_max_crashes` | int | `5` | Max crashes within the window before respawns are blocked |
| `circuit_breaker_window_secs` | int | `60` | Sliding window (seconds) for crash counting |
| `request_timeout_secs` | int | `30` | Per-server request timeout for HTTP upstream calls (0 = no timeout) |
| `request_timeout_secs` | int | `30` | Steady-state per-request timeout for HTTP and stdio upstream calls, once connected (0 = no timeout) |
| `default_handshake_timeout_secs` | int | `30` | Default timeout for the initial stdio MCP handshake (`initialize`), before a server is considered failed and enters the respawn loop. Overridable per server β€” see `servers[].handshake_timeout_secs` below. (0 = no timeout) |

### `[[servers]]`

Expand All @@ -564,11 +575,22 @@ MCPlex aggregates and forwards **all three** MCP capability types from upstream
| `blocked_tools` | list | β€” | Tool blocklist patterns (glob) |
| `allowed_tools` | list | β€” | Tool allowlist patterns (glob) |
| `enabled` | bool | `true` | Enable/disable this server |
| `handshake_timeout_secs` | int | β€” | Per-server override for the initial stdio handshake timeout. Falls back to `security.default_handshake_timeout_secs` when unset. Useful for servers with a slow cold start, e.g. `npx` resolving/downloading a package on first run (0 = no timeout) |

⚑ = One of `command` or `url` is required

> **Note:** For stdio servers, `command` should be the **executable path** (e.g. `npx`, `/usr/bin/python3`). Additional arguments go in the `args` array.

> **Handshake timeout vs. request timeout:** the initial stdio `initialize` handshake and steady-state `tools/call` requests use two independent timeouts. A slow-starting server (e.g. `npx` fetching a package on first run) can be given a longer `handshake_timeout_secs` without loosening the timeout applied to every later tool call:
> ```toml
> [[servers]]
> name = "slow-npx-server"
> command = "npx"
> args = ["-y", "@some/mcp-server"]
> handshake_timeout_secs = 90 # default 30s, 0 = no timeout
> ```


### `[roles.<name>]`

| Key | Type | Description |
Expand Down Expand Up @@ -763,7 +785,18 @@ Contributions are welcome! Please:

MIT License β€” see [LICENSE](LICENSE) for details.

## πŸ”§ Recent Changes (v0.7.0 β€” Model Ecosystem Refresh)
## πŸ”§ Recent Changes (v0.7.1 β€” Security Hardening &amp; Configurable Handshake Timeout)

- **RBAC Self-Assertion Bypass Fixed (HIGH)** β€” `dispatch_real_tool` previously fell back to a client-supplied `_mcplex_role` request param whenever no server-verified role was established (e.g. no `api_key`/`api_keys` configured). Any caller could self-assert `role: "admin"` and bypass RBAC entirely. The role now comes exclusively from the auth middleware's server-verified `trusted_role`.
- **Dashboard Authentication** β€” `/api/*` dashboard routes were previously unauthenticated. Added optional `gateway.dashboard_api_key`; when set, dashboard routes require a matching `Authorization: Bearer`/`X-API-Key` header. Unset by default (backward compatible), with a startup warning to bind localhost-only in that case.
- **Role-Partitioned Cache** β€” The tool response cache is now partitioned by caller role, closing a cross-tenant leakage gap in multi-key deployments.
- **Empty-Secret Rejection** β€” `validate_config` now rejects `gateway.api_key`/`dashboard_api_key`/`api_keys` that resolve to an empty string (e.g. from an unset `${ENV_VAR}`), instead of silently turning "auth required" into a no-op.
- **Configurable Stdio Handshake Timeout** (fixes [#20](https://github.com/ModernOps888/mcplex/issues/20)) β€” the initial `initialize` handshake and steady-state tool calls now use independent timeouts: `security.default_handshake_timeout_secs` (global, default 30s) and `servers[].handshake_timeout_secs` (per-server override) for the handshake, vs. `security.request_timeout_secs` for steady-state calls. Fixes spurious failures on slow-cold-start servers (e.g. `npx` downloading a package on first run). `0 = no timeout` is now actually implemented for both.
- **Audit Redaction List Expanded** β€” added `pwd`, `auth`, `cookie`, `session`, `bearer` to the sensitive-key redaction list.
- **38 tests passing** (up from 32) β€” new regression tests for the RBAC fix, cache partitioning, and handshake-timeout config.

<details>
<summary>Previous (v0.7.0 β€” Model Ecosystem Refresh)</summary>

- **Compatible Models Dashboard Panel** β€” Built-in dashboard now shows a `πŸ€– Compatible Models` panel with colour-coded provider badges for all 9 active frontier models: GPT-5.6 Sol/Terra/Luna, Claude Fable 5/Mythos 5/Sonnet 5, Gemini 3.5 Flash/3.1 Pro, Grok 4.5.
- **Ecosystem Footer** β€” Dashboard footer now links to AgentLens, The Forge, and GitHub with the current MCP protocol version displayed.
Expand All @@ -773,6 +806,8 @@ MIT License β€” see [LICENSE](LICENSE) for details.
- **Fixed** Python example protocol version `2025-03-26` β†’ `2025-11-25`.
- **Fixed** missing CHANGELOG comparison links for v0.4.0–v0.6.0.

</details>

<details>
<summary>Previous (v0.6.0 β€” Audit Hardening &amp; Denial Telemetry)</summary>

Expand All @@ -787,6 +822,7 @@ MIT License β€” see [LICENSE](LICENSE) for details.




- **Constant-Time API Key Verification** β€” Key comparison is no longer vulnerable to timing side-channels.
- **Trusted Role Binding** β€” Multi-tenant API keys now bind their RBAC role at the middleware layer (`X-MCPlex-Role` injected server-side). Clients can no longer self-assert a role via `_mcplex_role` when authenticated.
- **Tool Call Input Validation** β€” Tool names are restricted to a safe charset (max 128 chars); arguments are capped at 64KB with a max JSON nesting depth of 16. Malformed calls are rejected before touching upstream servers.
Expand Down
128 changes: 127 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ pub struct GatewayConfig {
/// Maximum requests per second per client (0 = unlimited)
#[serde(default)]
pub rate_limit_rps: u32,
/// API key for the observability dashboard (optional β€” if unset, the
/// dashboard is unauthenticated and should only be bound to localhost).
/// Supports ${ENV_VAR} expansion, e.g. "${MCPLEX_DASHBOARD_API_KEY}"
pub dashboard_api_key: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -93,9 +97,18 @@ pub struct SecurityConfig {
/// Circuit breaker: sliding window duration in seconds
#[serde(default = "default_cb_window_secs")]
pub circuit_breaker_window_secs: u64,
/// Per-server request timeout in seconds for HTTP upstream calls (0 = no timeout)
/// Per-server request timeout in seconds for HTTP upstream calls, and the
/// steady-state per-request timeout for stdio upstream calls once
/// connected (0 = no timeout). See `default_handshake_timeout_secs` for
/// the separate timeout that covers the initial stdio handshake.
#[serde(default = "default_request_timeout")]
pub request_timeout_secs: u64,
/// Default timeout in seconds for the initial stdio MCP handshake
/// (`initialize`) before a server is considered failed and enters the
/// respawn loop. Can be overridden per server via
/// `servers[].handshake_timeout_secs`. 0 = no timeout.
#[serde(default = "default_handshake_timeout")]
pub default_handshake_timeout_secs: u64,
}

/// Cache configuration for tool response caching
Expand Down Expand Up @@ -157,6 +170,12 @@ pub struct ServerConfig {
/// Whether this server is enabled
#[serde(default = "default_true")]
pub enabled: bool,
/// Override for how long the initial stdio MCP handshake (`initialize`)
/// may take before this server is considered failed and enters the
/// respawn loop. Falls back to `security.default_handshake_timeout_secs`
/// when unset. Useful for servers with a slow cold start (e.g. `npx`
/// resolving/downloading a package on first run). 0 = no timeout.
pub handshake_timeout_secs: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -242,6 +261,9 @@ fn default_cb_window_secs() -> u64 {
fn default_request_timeout() -> u64 {
30
}
fn default_handshake_timeout() -> u64 {
30
}
fn default_agentlens_url() -> String {
"http://127.0.0.1:3000/api/ingest".to_string()
}
Expand Down Expand Up @@ -321,6 +343,7 @@ impl Default for SecurityConfig {
circuit_breaker_max_crashes: default_cb_max_crashes(),
circuit_breaker_window_secs: default_cb_window_secs(),
request_timeout_secs: default_request_timeout(),
default_handshake_timeout_secs: default_handshake_timeout(),
}
}
}
Expand Down Expand Up @@ -407,6 +430,43 @@ fn validate_config(config: &AppConfig) -> anyhow::Result<()> {
anyhow::bail!("router.similarity_threshold must be between 0.0 and 1.0");
}

// An api_key/dashboard_api_key set to "${SOME_UNSET_ENV_VAR}" expands to an
// empty string rather than None β€” silently turning "auth required" into
// "auth is a no-op" (an empty bearer token would match). Treat that as a
// hard config error rather than a silent bypass.
if config.gateway.api_key.as_deref() == Some("") {
anyhow::bail!(
"gateway.api_key resolved to an empty string β€” check that its ${{ENV_VAR}} is set"
);
}
if config.gateway.dashboard_api_key.as_deref() == Some("") {
anyhow::bail!(
"gateway.dashboard_api_key resolved to an empty string β€” check that its ${{ENV_VAR}} is set"
);
}
for (key, cfg) in &config.api_keys {
if key.is_empty() {
anyhow::bail!("api_keys contains an empty key β€” check its ${{ENV_VAR}} is set");
}
if cfg.role.is_empty() {
anyhow::bail!("api_keys.\"{}\".role must not be empty", key);
}
}

// RBAC only has an effect when the gateway can determine a caller's role,
// which requires api_key/api_keys auth. Without it, is_tool_allowed()
// correctly denies every call by default (see SecurityEngine) β€” but that
// makes RBAC-enabled-with-no-auth look like "the gateway rejects
// everything" rather than a misconfiguration, so warn loudly at startup.
if config.security.enable_rbac && config.gateway.api_key.is_none() && config.api_keys.is_empty()
{
warn!(
"⚠️ security.enable_rbac is true but no gateway.api_key/api_keys are configured β€” \
every tool call will be denied by default since no caller role can be established. \
Configure api_key/api_keys, or disable RBAC."
);
}

Ok(())
}

Expand Down Expand Up @@ -484,3 +544,69 @@ pub async fn watch_config(config_path: &str, state: Arc<crate::AppState>) -> any

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

/// Regression test for GitHub issue #20: the handshake timeout must be
/// independently configurable per-server, and default to 30s globally,
/// distinct from the steady-state `request_timeout_secs`.
#[test]
fn handshake_timeout_defaults_and_per_server_override_parse() {
let toml_str = r#"
[gateway]

[security]
request_timeout_secs = 15
default_handshake_timeout_secs = 45

[[servers]]
name = "slow-npx-server"
command = "npx"
handshake_timeout_secs = 90

[[servers]]
name = "fast-server"
command = "some-binary"
"#;

let config: AppConfig = toml::from_str(toml_str).expect("config must parse");

assert_eq!(config.security.request_timeout_secs, 15);
assert_eq!(config.security.default_handshake_timeout_secs, 45);
assert_eq!(
config.servers[0].handshake_timeout_secs,
Some(90),
"per-server override must be honored"
);
assert_eq!(
config.servers[1].handshake_timeout_secs, None,
"server without an override must fall back to the global default at use-site"
);
}

#[test]
fn security_config_defaults_handshake_timeout_to_30s() {
let config: AppConfig =
toml::from_str("[gateway]\n").expect("minimal config with defaults must parse");
assert_eq!(config.security.default_handshake_timeout_secs, 30);
assert_eq!(config.security.request_timeout_secs, 30);
}

#[test]
fn empty_expanded_api_key_is_rejected() {
let mut config: AppConfig =
toml::from_str("[gateway]\n").expect("minimal config with defaults must parse");
config.gateway.api_key = Some(String::new());
assert!(validate_config(&config).is_err());
}

#[test]
fn empty_expanded_dashboard_api_key_is_rejected() {
let mut config: AppConfig =
toml::from_str("[gateway]\n").expect("minimal config with defaults must parse");
config.gateway.dashboard_api_key = Some(String::new());
assert!(validate_config(&config).is_err());
}
}
Loading
Loading