Skip to content
Open
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
6 changes: 5 additions & 1 deletion .github/workflows/mcp_conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ env:
MCP_CONFORMANCE_SOURCE_SHA: c321dd32035556e6769d3724a8ee97d87c3faaac # pragma: allowlist secret
MCP_CONFORMANCE_SPEC_VERSION: 2026-07-28
MCP_CONFORMANCE_SERVER_ID: 3f33286667d34b65a31c3bafd30e4c21
CF_CONTROLPLANE_IMAGE: ghcr.io/ibm/mcp-context-forge:latest
CF_DATAPLANE_IMAGE: contextforge-data-plane:conformance

jobs:
Expand Down Expand Up @@ -79,6 +78,11 @@ jobs:
working-directory: .conformance-suite
run: git apply ../tests/conformance/disable-flaky-progress.patch

- name: Resolve latest control-plane main image
env:
GITHUB_TOKEN: ${{ github.token }}
run: echo "CF_CONTROLPLANE_IMAGE=$(tests/conformance/resolve-control-plane-image.sh)" >> "${GITHUB_ENV}"

- name: Pull external stack images
env:
MCP_CONFORMANCE_TOKEN: pull-only
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 14 additions & 3 deletions _context/wiki/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,16 @@ boundary. See [Security](security.md#mcp-origin-and-host-validation).
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.
MCP handlers read typed extensions and never parse paths or Redis keys directly.
`tools/call` reads the downstream header map from RMCP's request-context
`Parts` extension for parameter-header validation.

## Pipeline Shape

```text
downstream request
-> Origin validation → MCP header limits → virtual host extraction → JWT validation → session extraction
-> user config lookup → RMCP Host validation → MCP handler validation
-> user config lookup → RMCP request validation → MCP handler validation
-> request plugin hooks
-> backend MCP call (concurrent via join_all for initialize/list)

Expand All @@ -66,7 +68,7 @@ flowchart TD
flowchart TD
D(["downstream request"])
A["virtual host · JWT\nsession extract"]
C["user config lookup\nMCP validate"]
C["user config lookup\nRMCP · MCP validate"]
P1["request plugins\ntool_pre_invoke"]
B["backend MCP call\njoin_all for init/list"]
P2["response plugins\ntool_post_invoke"]
Expand All @@ -77,6 +79,15 @@ flowchart TD
```


RMCP enforces its configured request-body cap and validates modern standard
headers before dispatch. The `tools/call` handler then resolves the request's
backend and original tool name and validates `Mcp-Param-*` from the request
context against the schema published in `UserConfig`; it does not call backend
`tools/list`.
Validated headers are forwarded unchanged; request plugins run afterward, so a
plugin that changes an annotated argument also owns the resulting upstream
mismatch.

Order is invariant: auth/config before backend selection; request plugins before upstream; response plugins before returning.

## Module Boundaries (`contextforge-data-plane-lib`)
Expand Down
5 changes: 3 additions & 2 deletions _context/wiki/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,9 @@ BackendMCPGateway
passthrough_headers: Vec<String> ← snapshotted at initialize; session-scoped
add_headers: HashMap<String, String> ← injected after passthrough
remove_headers: Vec<String> ← stripped after add
tool_name_aliases: HashMap<String, String> ← downstream_alias → upstream_original
allowed_tool_names: Vec<String> ← model exists, NOT currently enforced
tool_schemas: HashMap<String, JsonObject> ← required; upstream name → input schema
tool_name_aliases: HashMap<String, String> ← downstream_alias → upstream_original
allowed_resource_names: Vec<String> ← model exists, NOT currently enforced
allowed_prompt_names: Vec<String> ← model exists, NOT currently enforced
```
Expand All @@ -146,7 +147,7 @@ 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-*` |
| 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
Expand Down
10 changes: 10 additions & 0 deletions _context/wiki/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ 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.

Backend header policy cannot add, remove, or replace MCP standard or parameter
headers. For modern `tools/call`, the dataplane resolves the authenticated
user, virtual host, backend, and original tool name before validating
`Mcp-Param-*` against the control-plane-published input schema. A missing schema
or header/body mismatch fails closed with JSON-RPC `-32020`.
Validation does not call backend `tools/list`. Validated values are forwarded
unchanged, while RMCP regenerates method, routed-name, and protocol-version
headers. If a plugin later changes an annotated argument, the original header
remains and the upstream server may reject the mismatch.

## Local Bootstrap Helpers (`with_tools`)

The `contextforge-data-plane-lib/with_tools` feature compiles in:
Expand Down
13 changes: 9 additions & 4 deletions _context/wiki/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Protocol-sensitive tests and fixtures must cover MCP `2026-07-28` and `2025-11-2
| `gateway_list_tools.rs` | List fanout, prefixing, and merged output. |
| `gateway_prompts.rs` | Prompt listing and prefixed `get_prompt` routing. |
| `gateway_resource_templates.rs` | Template fanout with prefixed names and URI templates, plus `read_resource` round-trips. |
| `gateway_plugins.rs` | CPEX pre/post tool hooks around `call_tool` and stream events, and prompt hooks around `get_prompt`. |
| `gateway_plugins.rs` | Request-scoped parameter-header validation/forwarding, CPEX pre/post tool hooks around `call_tool` and stream events, and prompt hooks around `get_prompt`. |

These run in `cargo nextest run` with no Docker dependencies.

Expand All @@ -39,9 +39,9 @@ These run in `cargo nextest run` with no Docker dependencies.
`.github/workflows/mcp_conformance.yml` runs the pinned official conformance
suite `0.2.0-alpha.11` for MCP `2026-07-28` in both directions. The server leg
is official client → nginx → checked-out external dataplane → fixture proxy
→ official server, with the published `latest` Python image's control plane
registering and publishing the fixture through Redis. The backend-only proxy
rewrites `Host` to
→ official server, with the newest available image built from the control plane's `main`
branch registering and publishing the fixture through Redis. The backend-only
proxy rewrites `Host` to
`localhost:3000`, which the official fixture's DNS-rebinding protection
requires, while leaving external-dataplane header protections unchanged. The
control plane uses ephemeral SQLite, so PostgreSQL is unnecessary. The harness lives
Expand All @@ -58,6 +58,11 @@ a control-plane responsibility. Server and client results are written below
`server/` and `client/`, with separate `expected-failures.yml` and
`client-expected-failures.yml` baselines.

The official fixture keeps some diagnostic tools out of `tools/list`. Because
the external dataplane fails closed unless the control plane publishes a tool
schema, checks that require those hidden tools remain explicit server-leg
baseline entries rather than bypassing schema validation in the harness.

`make conformance` runs both legs locally, while `make conformance-bless` runs
both and refreshes both expected-failure baselines from that run.

Expand Down
2 changes: 2 additions & 0 deletions crates/contextforge-data-plane-apis/src/user_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ pub struct BackendMCPGateway {
pub prompt_name_aliases: HashSet<NameAlias>,
#[serde(default)]
pub completion: HashMap<String, String>,
/// Input schemas keyed by the original upstream tool name.
pub tool_schemas: HashMap<String, serde_json::Map<String, serde_json::Value>>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
Expand Down
1 change: 1 addition & 0 deletions crates/contextforge-data-plane-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ clap.workspace = true
thiserror.workspace = true
rmp-serde.workspace = true
async-trait.workspace = true
base64 = "0.22.1"
reqwest.workspace = true
uuid.workspace = true
lru_time_cache = "0.11.11"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ fn resolve_route<'a, N: AsRef<str>>(

/// Resolves an exact control-plane alias to its backend and upstream name. Without an alias,
/// single-backend hosts preserve the upstream name and multi-backend hosts use the legacy prefix.
pub(super) fn resolve_tool_route<'a, N: AsRef<str>>(
pub(crate) fn resolve_tool_route<'a, N: AsRef<str>>(
virtual_host: &'a VirtualHost,
name: &'a str,
backend_names: &'a [N],
Expand Down Expand Up @@ -164,7 +164,8 @@ mod tests {
"tool_name_aliases": [
{"downstream_prefixed_name":"Public.Tool", "upstream_name":"get_stats"},
{"downstream_prefixed_name":"Echo_Tool", "upstream_name":"echo"}
]
],
"tool_schemas": {}
}
}
});
Expand All @@ -190,12 +191,14 @@ mod tests {
"url": "http://upstream:9000/mcp",
"mcp_protocol_version": "2026_07_28",
"passthrough_headers": [],
"tool_schemas": {}
},
"other": {
"name": "other",
"url": "http://other:9000/mcp",
"mcp_protocol_version": "2026_07_28",
"passthrough_headers": [],
"tool_schemas": {}
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ fn apply_header_config(
headers.insert(name, value.clone());
}
}
headers.extend(
downstream
.iter()
.filter(|(name, _)| mcp_standard_headers::is_param(name))
.map(|(name, value)| (name.clone(), value.clone())),
);
}
for (name, value) in &backend.add_headers {
let (Ok(name), Ok(value)) = (http::HeaderName::from_bytes(name.as_bytes()), http::HeaderValue::from_str(value))
Expand Down Expand Up @@ -131,6 +137,8 @@ fn apply_header_config(
/// - 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-*`
///
/// Downstream `Mcp-Param-*` headers are forwarded automatically and cannot be changed by backend config.
fn is_protected_header(name: &http::HeaderName) -> bool {
const PROTECTED: &[&str] = &[
"host",
Expand Down Expand Up @@ -171,6 +179,7 @@ mod tests {
passthrough_headers: passthrough.iter().map(|s| (*s).to_owned()).collect(),
add_headers: add.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(),
remove_headers: remove.iter().map(|s| (*s).to_owned()).collect(),
tool_schemas: HashMap::new(),
tool_name_aliases: HashSet::new(),
resource_uri_aliases: HashSet::new(),
prompt_name_aliases: HashSet::new(),
Expand Down Expand Up @@ -280,15 +289,14 @@ mod tests {
}

#[test]
fn computed_mcp_headers_cannot_be_passed_through_added_or_removed() {
fn mcp_param_headers_are_forwarded_but_cannot_be_changed_by_backend_config() {
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"),
("Mcp-Param-User", "client-user"),
]);
let cfg = backend(
&["mcp-method", "mcp-name", "mcp-protocol-version", "mcp-param-user"],
Expand All @@ -304,7 +312,7 @@ mod tests {
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_eq!(headers[&http::HeaderName::from_static("mcp-param-user")], "client-user");
assert!(!headers.contains_key(&http::HeaderName::from_static("mcp-name")));
assert!(!headers.contains_key(&http::HeaderName::from_static("mcp-protocol-version")));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use contextforge_data_plane_cpex::ToolPreCallResult;
use http::request::Parts;
use rmcp::{
ErrorData, RoleServer,
model::{CallToolRequestParams, CallToolResponse, ErrorCode},
model::{CallToolRequestParams, CallToolResponse, ErrorCode, ProtocolVersion},
service::RequestContext,
};
use tracing::{info, warn};
Expand All @@ -13,6 +14,7 @@ use crate::gateway::{
mcp_call_validator::AuthorizedCallValidator,
mcp_service::initialization::connect_backend_for_request,
};
use crate::mcp_standard_headers;

pub(super) async fn call_tool(
mcp_service: &McpService,
Expand Down Expand Up @@ -44,6 +46,19 @@ pub(super) async fn call_tool(
data: None,
})?;

if cx.protocol_version().is_some_and(|version| version >= ProtocolVersion::STANDARD_HEADERS) {
let downstream_headers = cx
.extensions
.get::<Parts>()
.map(|parts| &parts.headers)
.ok_or_else(|| ErrorData::internal_error("Routing problem... request headers not found", None))?;
let tool_schema = backend.tool_schemas.get(&tool_name).ok_or_else(|| {
ErrorData::header_mismatch(format!("Missing published schema for tool '{tool_name}'"), None)
})?;
mcp_standard_headers::validate_tool_params(downstream_headers, request.arguments.as_ref(), tool_schema)
.map_err(|message| ErrorData::header_mismatch(message, None))?;
}

let service_name = backend_name.clone();
let pre_result = if let Some(plugin_runtime) = &mcp_service.plugin_runtime {
plugin_runtime.before_tool_call(&request, &tool_name, &service_name).await?
Expand Down
1 change: 0 additions & 1 deletion crates/contextforge-data-plane-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ impl Gateway {
} else {
StreamableHttpServerConfig::default().disable_allowed_hosts().disable_allowed_origins()
};

let reqwest_backend_client = reqwest::Client::try_from(&config)?;

// Create streamable HTTP service
Expand Down
Loading
Loading