Skip to content
4 changes: 2 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"files": "Cargo\\.lock$|\\.lock$|target/|^.secrets.baseline$",
"lines": null
},
"generated_at": "2026-08-18T10:01:42Z",
"generated_at": "2026-08-19T10:02:56Z",
"plugins_used": [
{
"name": "AWSKeyDetector"
Expand Down Expand Up @@ -158,7 +158,7 @@
"hashed_secret": "bfc6000db1195a9522813fc405c666dd4ce669ad",
"is_secret": false,
"is_verified": false,
"line_number": 250,
"line_number": 263,
"type": "Secret Keyword",
"verified_result": null
}
Expand Down
6 changes: 5 additions & 1 deletion _context/wiki/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ TCP/TLS listener
-> /contextforge-rs nested router
-> mcp_origin_layer → validates Origin (403 when invalid/disallowed)
-> CORS layer
-> mcp_header_limits_layer → MCP standard header budgets (431 when exceeded)
-> virtual_host_id_layer → inserts VirtualHostId (400 on path mismatch)
-> claims_layer → inserts ContextForgeClaims (401 on bad/missing JWT)
-> session_id_layer → inserts SessionId if present
Expand All @@ -28,14 +29,17 @@ DNS-rebinding validation is split by behavior. `mcp_origin_layer` rejects any
present Origin that is malformed or not allowlisted; requests without Origin
continue. RMCP validates the optional Host allowlist at the MCP service
boundary. See [Security](security.md#mcp-origin-and-host-validation).
`mcp_header_limits_layer` rejects excessive MCP standard headers before JWT
validation, config lookup, session creation, backend fanout, or RMCP body
parsing.

MCP handlers read typed extensions — they never parse headers, paths, or Redis keys directly.

## Pipeline Shape

```text
downstream request
-> Origin validation → virtual host extraction → JWT validation → session extraction
-> Origin validation → MCP header limits → virtual host extraction → JWT validation → session extraction
-> user config lookup → RMCP Host validation → MCP handler validation
-> request plugin hooks
-> backend MCP call (concurrent via join_all for initialize/list)
Expand Down
17 changes: 17 additions & 0 deletions _context/wiki/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,22 @@ Origin and Host settings retain the explicitly configured
| --- | --- | --- | --- |
| `--mcp-allowed-origins <origin,...>` | `CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS` | None | Browser Origin allowlist. Without it, requests lacking `Origin` pass and every request carrying `Origin` receives HTTP `403`. |
| `--mcp-allowed-hosts <authority,...>` | `CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS` | None | Optional RMCP request-authority allowlist. For requests that reach the RMCP service, missing or malformed authorities receive HTTP `400`; unlisted authorities receive HTTP `403`. Earlier middleware may return first. |
| `--mcp-standard-header-max-count <n>` | `CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_COUNT` | `32` | Maximum MCP standard headers accepted on one request. |
| `--mcp-standard-header-max-value-bytes <n>` | `CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_VALUE_BYTES` | `8192` | Maximum byte length accepted for one MCP standard header value. |
| `--mcp-standard-header-max-total-bytes <n>` | `CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES` | `65536` | Approximate request-level aggregate bytes across all matched MCP standard header names and values. |

Values are comma-separated. Origin entries must be fully qualified serialized
origins such as `https://app.example.com`; Host entries are authorities such as
`gateway.example.com` or `gateway.example.com:8443`. See [Security](security.md#mcp-origin-and-host-validation).
The MCP standard header limits apply to `Mcp-Method`, `Mcp-Name`,
`Mcp-Protocol-Version`, and `Mcp-Param-*`. The same guardrail also covers the
legacy/RMCP transport header `Mcp-Session-Id`. A configured value of `0` is
treated as the documented default. The byte totals are application-level
aggregate budgets based on all matched header name and value lengths on one
request; they do not allow a single oversized value, which is still capped by
`--mcp-standard-header-max-value-bytes`. They are not exact wire-size accounting
and do not model HTTP/2 header compression. Non-MCP headers remain bounded by
the HTTP transport.

### Redis

Expand Down Expand Up @@ -134,6 +146,11 @@ BackendMCPGateway
| Hop-by-hop | `Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `Proxy-Connection`, `TE`, `Trailer`, `Trailers`, `Transfer-Encoding`, `Upgrade` |
| RMCP-reserved | `Mcp-Session-Id`, `Accept`, `Last-Event-Id` |
| Gateway-managed | `Host` (set from backend URL host + port; never overridden by config) |
| Computed MCP standard | `Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, `Mcp-Param-*` |

`Authorization` and `Cookie` are not protected here because backend
authentication through `passthrough_headers` or `add_headers` is intentional
runtime configuration.

Redis storage: `MessagePack(User::new(sub))` → `MessagePack(UserConfig)`.

Expand Down
10 changes: 10 additions & 0 deletions _context/wiki/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ CORS, authentication, user-config, and virtual-host middleware can return a
response first, so the Host-specific `400` and `403` statuses apply only after
those earlier stages succeed.

`mcp_header_limits_layer` enforces configurable count, per-value byte, and
approximate request-level aggregate byte budgets for MCP standard request
headers before JWT validation or RMCP body parsing. The aggregate budget covers
all matched header names and values on one request, while the per-value budget
still caps each individual header value. That budget covers `Mcp-Method`,
`Mcp-Name`, `Mcp-Protocol-Version`, and `Mcp-Param-*`; the same guardrail also
covers the legacy/RMCP transport header `Mcp-Session-Id`. It is an
application-level guard for MCP-related headers only; non-MCP headers remain
bounded by the HTTP transport.

## Local Bootstrap Helpers (`with_tools`)

The `contextforge-data-plane-lib/with_tools` feature compiles in:
Expand Down
17 changes: 17 additions & 0 deletions crates/contextforge-data-plane-lib/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,19 @@ pub struct Config {
#[arg(long, env = "CONTEXTFORGE_DATA_PLANE_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT")]
pub otlp_metrics_endpoint: Option<http::Uri>,

/// Maximum number of MCP standard headers accepted on a single request.
#[arg(long, env = "CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_COUNT", default_value_t = DEFAULT_MCP_STANDARD_HEADER_MAX_COUNT)]
pub mcp_standard_header_max_count: usize,

/// Maximum byte length accepted for a single MCP standard header value.
#[arg(long, env = "CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_VALUE_BYTES", default_value_t = DEFAULT_MCP_STANDARD_HEADER_MAX_VALUE_BYTES)]
pub mcp_standard_header_max_value_bytes: usize,

/// Approximate request-level aggregate bytes accepted across all matched
/// MCP standard header names and values.
#[arg(long, env = "CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES", default_value_t = DEFAULT_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES)]
pub mcp_standard_header_max_total_bytes: usize,

#[arg(long, env = "CONTEXTFORGE_DATA_PLANE_NUMBER_OF_CPUS")]
pub number_of_cpus: Option<usize>,

Expand Down Expand Up @@ -275,6 +288,10 @@ pub struct Config {
pub mcp_allowed_hosts: Option<Vec<Authority>>,
}

pub const DEFAULT_MCP_STANDARD_HEADER_MAX_COUNT: usize = 32;
pub const DEFAULT_MCP_STANDARD_HEADER_MAX_VALUE_BYTES: usize = 8 * 1024;
pub const DEFAULT_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES: usize = 64 * 1024;
Comment thread
dawid-nowak marked this conversation as resolved.

#[derive(Error, Debug)]
pub enum ConfigValidationError {
#[error("Redis Configuration Error")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::gateway::{
mcp_call_validator::InitializeCallValidator,
session_store::{UserSession, UserSessionStore},
};
use crate::mcp_standard_headers;

pub(super) async fn initialize<T>(
mcp_service: &McpService<T>,
Expand Down Expand Up @@ -247,6 +248,7 @@ fn apply_header_config(
/// - Hop-by-hop (RFC 7230 §6.1): `Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Trailers`, `Transfer-Encoding`, `Upgrade`
/// - Non-standard hop-by-hop: `Proxy-Connection` (must not cross gateway boundary)
/// - RMCP transport-reserved: `Mcp-Session-Id`, `Accept`, `Last-Event-Id`
/// - MCP standard computed headers: `Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, `Mcp-Param-*`
fn is_protected_header(name: &http::HeaderName) -> bool {
const PROTECTED: &[&str] = &[
"host",
Expand All @@ -270,7 +272,7 @@ fn is_protected_header(name: &http::HeaderName) -> bool {
"accept",
"last-event-id",
];
PROTECTED.iter().any(|&p| name.as_str().eq_ignore_ascii_case(p))
PROTECTED.iter().any(|&p| name.as_str().eq_ignore_ascii_case(p)) || mcp_standard_headers::is_computed(name)
}

#[cfg(test)]
Expand Down Expand Up @@ -406,6 +408,36 @@ mod tests {
assert!(headers.is_empty(), "no RMCP-reserved header must reach the upstream config");
}

#[test]
fn computed_mcp_headers_cannot_be_passed_through_added_or_removed() {
let mut headers = HashMap::new();
headers.insert(http::HeaderName::from_static("mcp-method"), http::HeaderValue::from_static("tools/call"));
headers.insert(http::HeaderName::from_static("mcp-param-user"), http::HeaderValue::from_static("computed"));
let ds = downstream(&[
("Mcp-Method", "wrong/method"),
("Mcp-Name", "wrong-tool"),
("Mcp-Protocol-Version", "2020-01-01"),
("Mcp-Param-User", "wrong-user"),
]);
let cfg = backend(
&["mcp-method", "mcp-name", "mcp-protocol-version", "mcp-param-user"],
&[
("Mcp-Method", "added/method"),
("Mcp-Name", "added-tool"),
("Mcp-Protocol-Version", "2020-01-01"),
("Mcp-Param-User", "added-user"),
],
&["mcp-method", "mcp-param-user"],
);

apply_header_config(&mut headers, &cfg, Some(&ds));

assert_eq!(headers[&http::HeaderName::from_static("mcp-method")], "tools/call");
assert_eq!(headers[&http::HeaderName::from_static("mcp-param-user")], "computed");
assert!(!headers.contains_key(&http::HeaderName::from_static("mcp-name")));
assert!(!headers.contains_key(&http::HeaderName::from_static("mcp-protocol-version")));
}

#[test]
fn body_framing_and_connection_management_headers_cannot_be_forwarded() {
let mut headers = HashMap::new();
Expand Down
Loading