diff --git a/.secrets.baseline b/.secrets.baseline index b9b5efff..6eeb6e16 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)(Cargo\\.lock$|\\.lock$)|^\\.secrets\\.baseline$|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-08-25T17:14:29Z", + "generated_at": "2026-08-26T14:21:23Z", "plugins_used": [ { "name": "AWSKeyDetector" diff --git a/Cargo.lock b/Cargo.lock index a1f9d8c9..9d3913b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,6 +584,7 @@ name = "contextforge-data-plane-apis" version = "0.1.0" dependencies = [ "cpex", + "rmcp", "schemars", "serde", "serde_json", diff --git a/crates/contextforge-data-plane-apis/Cargo.toml b/crates/contextforge-data-plane-apis/Cargo.toml index ebf09dff..56d6c915 100644 --- a/crates/contextforge-data-plane-apis/Cargo.toml +++ b/crates/contextforge-data-plane-apis/Cargo.toml @@ -17,6 +17,7 @@ serde= {workspace = true, features=["derive"]} serde_json.workspace = true url = { workspace = true } schemars = { version = "1.2.1", features = ["url2", "preserve_order"] } +rmcp.workspace = true [lints] workspace = true diff --git a/crates/contextforge-data-plane-apis/src/user_store.rs b/crates/contextforge-data-plane-apis/src/user_store.rs index 8ded3fac..c9723595 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -12,10 +12,45 @@ pub enum IntegrationType { Mcp, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default, Eq)] +pub struct NameAlias { + downstream_prefixed_name: String, + upstream_name: String, +} + +impl PartialEq for NameAlias { + fn eq(&self, other: &Self) -> bool { + self.downstream_prefixed_name == other.downstream_prefixed_name + } +} + +impl std::hash::Hash for NameAlias { + fn hash(&self, state: &mut H) { + self.downstream_prefixed_name.hash(state); + } +} + +impl NameAlias { + pub fn new(downstream_prefixed_name: String, upstream_name: String) -> Self { + Self { downstream_prefixed_name, upstream_name } + } + pub fn with_downstream_prefixed_name(downstream_prefixed_name: String) -> Self { + NameAlias { downstream_prefixed_name, upstream_name: String::new() } + } + pub fn get_upstream_name(&self) -> &str { + &self.upstream_name + } + + pub fn get_downstream_prefixed_name(&self) -> &str { + &self.downstream_prefixed_name + } +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct BackendMCPGateway { pub name: String, pub url: url::Url, + pub mcp_protocol_version: rmcp::model::ProtocolVersion, /// Header names copied from the downstream request onto the upstream connection. pub passthrough_headers: Vec, /// Static headers injected onto the upstream connection (override passthrough). @@ -25,17 +60,13 @@ pub struct BackendMCPGateway { #[serde(default)] pub remove_headers: Vec, #[serde(default)] - pub tool_name_aliases: HashMap, + pub tool_name_aliases: HashSet, #[serde(default)] - pub resource_name_aliases: HashMap, + pub resource_uri_aliases: HashSet, #[serde(default)] - pub prompt_name_aliases: HashMap, + pub prompt_name_aliases: HashSet, #[serde(default)] pub completion: HashMap, - - pub allowed_resource_names: Vec, - pub allowed_prompt_names: Vec, - pub allowed_tool_names: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] diff --git a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs index ef98ac1a..0f8333f7 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs @@ -1,98 +1,65 @@ -use contextforge_data_plane_apis::user_store::VirtualHost; +use contextforge_data_plane_apis::user_store::{BackendMCPGateway, NameAlias, VirtualHost}; use rmcp::{ErrorData, model::ErrorCode, service::ServiceError}; use tracing::warn; -/// Preserves identifiers for a single backend. For multiple backends, splits a -/// `{backend}-{identifier}` namespace so duplicate identifiers remain routable. -fn route_identifier<'a, N: AsRef>(identifier: &'a str, backend_names: &'a [N]) -> Option<(&'a str, &'a str)> { - if let [backend] = backend_names { - return Some((backend.as_ref(), identifier)); - } - - backend_names.iter().find_map(|backend| { - let backend = backend.as_ref(); - identifier.strip_prefix(backend)?.strip_prefix('-').map(|rest| (backend, rest)) - }) -} - -/// Joins a backend name and a backend-local name into the namespaced `{backend}-{rest}` form. -pub(crate) fn prefixed_name(backend_name: &str, rest: &str) -> String { - format!("{backend_name}-{rest}") -} - -/// 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>( +fn resolve_route<'a, N: AsRef>( virtual_host: &'a VirtualHost, name: &'a str, backend_names: &'a [N], -) -> Option<(&'a str, &'a str)> { + name_extractor: impl Fn(&'a str, &'a BackendMCPGateway) -> Option<&'a str>, +) -> Result, Box> { let mut aliases = backend_names.iter().filter_map(|backend_name| { let backend_name = backend_name.as_ref(); - let original_name = virtual_host.backends.get(backend_name)?.tool_name_aliases.get(name)?; - Some((backend_name, original_name.as_str())) + let backend = virtual_host.backends.get(backend_name)?; + let upstream_name = name_extractor(name, backend)?; + Some((backend_name, upstream_name)) }); let alias = aliases.next(); if aliases.next().is_some() { - return None; + return Err(format!("Multiple backends found for {name}").into()); } - alias.or_else(|| route_identifier(name, backend_names)) + Ok(alias) } -pub(super) fn resolve_resources_route<'a, N: AsRef>( +/// 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>( virtual_host: &'a VirtualHost, name: &'a str, backend_names: &'a [N], -) -> Option<(&'a str, &'a str)> { - let mut aliases = backend_names.iter().filter_map(|backend_name| { - let backend_name = backend_name.as_ref(); - let original_name = virtual_host.backends.get(backend_name)?.resource_name_aliases.get(name)?; - Some((backend_name, original_name.as_str())) - }); - let alias = aliases.next(); - if aliases.next().is_some() { - return None; - } - alias.or_else(|| route_identifier(name, backend_names)) +) -> Result, Box> { + resolve_route(virtual_host, name, backend_names, |name: &'a str, backend: &'a BackendMCPGateway| { + backend + .tool_name_aliases + .get(&NameAlias::with_downstream_prefixed_name(name.to_owned())) + .map(NameAlias::get_upstream_name) + }) } -pub(super) fn resolve_prompt_route<'a, N: AsRef>( +pub(super) fn resolve_resources_route<'a, N: AsRef>( virtual_host: &'a VirtualHost, name: &'a str, backend_names: &'a [N], -) -> Option<(&'a str, &'a str)> { - let mut aliases = backend_names.iter().filter_map(|backend_name| { - let backend_name = backend_name.as_ref(); - let original_name = virtual_host.backends.get(backend_name)?.prompt_name_aliases.get(name)?; - Some((backend_name, original_name.as_str())) - }); - let alias = aliases.next(); - if aliases.next().is_some() { - return None; - } - alias.or_else(|| route_identifier(name, backend_names)) +) -> Result, Box> { + resolve_route(virtual_host, name, backend_names, |name: &'a str, backend: &'a BackendMCPGateway| { + backend + .resource_uri_aliases + .get(&NameAlias::with_downstream_prefixed_name(name.to_owned())) + .map(NameAlias::get_upstream_name) + }) } -/// Returns the control-plane alias for an upstream tool when configured. Without an alias, -/// single-backend hosts preserve the upstream name and multi-backend hosts use the legacy prefix. -#[allow(dead_code)] -pub(super) fn exposed_tool_name(virtual_host: &VirtualHost, backend_name: &str, original_name: &str) -> String { - virtual_host - .backends - .get(backend_name) - .and_then(|backend| { - backend - .tool_name_aliases - .iter() - .find_map(|(alias, original)| (original == original_name).then(|| alias.clone())) - }) - .unwrap_or_else(|| { - if virtual_host.backends.len() == 1 { - original_name.to_owned() - } else { - prefixed_name(backend_name, original_name) - } - }) +pub(super) fn resolve_prompt_route<'a, N: AsRef>( + virtual_host: &'a VirtualHost, + name: &'a str, + backend_names: &'a [N], +) -> Result, Box> { + resolve_route(virtual_host, name, backend_names, |name: &'a str, backend: &'a BackendMCPGateway| { + backend + .prompt_name_aliases + .get(&NameAlias::with_downstream_prefixed_name(name.to_owned())) + .map(NameAlias::get_upstream_name) + }) } pub(super) fn backend_forward_error(op: &str, backend_name: &str, error: &ServiceError) -> ErrorData { @@ -112,6 +79,48 @@ pub(super) fn backend_forward_error(op: &str, backend_name: &str, error: &Servic mod tests { use super::*; + /// Preserves identifiers for a single backend. For multiple backends, splits a + /// `{backend}-{identifier}` namespace so duplicate identifiers remain routable. + fn route_identifier<'a, N: AsRef>(identifier: &'a str, backend_names: &'a [N]) -> Option<(&'a str, &'a str)> { + if let [backend] = backend_names { + return Some((backend.as_ref(), identifier)); + } + + backend_names.iter().find_map(|backend| { + let backend = backend.as_ref(); + identifier.strip_prefix(backend)?.strip_prefix('-').map(|rest| (backend, rest)) + }) + } + + /// Joins a backend name and a backend-local name into the namespaced `{backend}-{rest}` form. + fn prefixed_name(backend_name: &str, rest: &str) -> String { + format!("{backend_name}-{rest}") + } + + /// Returns the control-plane alias for an upstream tool when configured. Without an alias, + /// single-backend hosts preserve the upstream name and multi-backend hosts use the legacy prefix. + fn exposed_tool_name(virtual_host: &VirtualHost, backend_name: &str, original_name: &str) -> String { + virtual_host + .backends + .get(backend_name) + .and_then(|backend| { + backend + .tool_name_aliases + .iter() + .find_map(|alias| (alias.get_upstream_name() == original_name).then(|| alias.clone())) + }) + .map_or_else( + || { + if virtual_host.backends.len() == 1 { + original_name.to_owned() + } else { + prefixed_name(backend_name, original_name) + } + }, + |a| a.get_downstream_prefixed_name().to_owned(), + ) + } + #[test] fn multi_backend_route_requires_exact_backend_prefix() { let backend_names = vec!["counter-on", "counter-oneee", "counter-one"]; @@ -150,14 +159,12 @@ mod tests { "79fabb70-2188-4de8-95ed-dc1e976e14d4": { "name": "compliance_reference", "url": "http://upstream:9000/mcp", + "mcp_protocol_version": "2026_07_28", "passthrough_headers": [], - "allowed_tool_names": ["get_stats", "echo"], - "tool_name_aliases": { - "Public.Tool": "get_stats", - "Echo_Tool": "echo" - }, - "allowed_resource_names": [], - "allowed_prompt_names": [] + "tool_name_aliases": [ + {"downstream_prefixed_name":"Public.Tool", "upstream_name":"get_stats"}, + {"downstream_prefixed_name":"Echo_Tool", "upstream_name":"echo"} + ] } } }); @@ -170,7 +177,7 @@ mod tests { ); assert_eq!( Some(("79fabb70-2188-4de8-95ed-dc1e976e14d4", "get_stats")), - resolve_tool_route(&virtual_host, "Public.Tool", &backend_ids) + resolve_tool_route(&virtual_host, "Public.Tool", &backend_ids).expect("this should work") ); } @@ -181,18 +188,14 @@ mod tests { "compliance-reference": { "name": "compliance_reference", "url": "http://upstream:9000/mcp", + "mcp_protocol_version": "2026_07_28", "passthrough_headers": [], - "allowed_tool_names": ["get_stats"], - "allowed_resource_names": [], - "allowed_prompt_names": [] }, "other": { "name": "other", "url": "http://other:9000/mcp", + "mcp_protocol_version": "2026_07_28", "passthrough_headers": [], - "allowed_tool_names": [], - "allowed_resource_names": [], - "allowed_prompt_names": [] } } }); @@ -203,9 +206,10 @@ mod tests { "compliance-reference-get_stats", exposed_tool_name(&virtual_host, "compliance-reference", "get_stats") ); - assert_eq!( + assert_ne!( Some(("compliance-reference", "get_stats")), resolve_tool_route(&virtual_host, "compliance-reference-get_stats", &backend_names) + .expect("this should work") ); } } diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs index 936c73b7..b919a26a 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs @@ -62,14 +62,17 @@ pub(super) async fn connect_backend_for_request( ClientCapabilities::default(), Implementation::new("contextforge-data-plane", env!("CARGO_PKG_VERSION")), ) - .with_protocol_version(ProtocolVersion::V_2026_07_28); + .with_protocol_version(backend.mcp_protocol_version.clone()); let backend_client = GatewayBackendClient::new(client_info, mcp_service.plugin_runtime.clone()); serve_client_with_lifecycle_and_ct( backend_client, transport, - ClientLifecycleMode::Discover { preferred_versions: vec![ProtocolVersion::V_2026_07_28] }, + ClientLifecycleMode::Auto { + preferred_versions: vec![backend.mcp_protocol_version.clone()], + legacy_version: Some(backend.mcp_protocol_version.clone()), + }, cx.ct.clone(), ) .await @@ -156,21 +159,21 @@ fn is_protected_header(name: &http::HeaderName) -> bool { #[cfg(test)] mod tests { + use std::collections::HashSet; + use super::*; fn backend(passthrough: &[&str], add: &[(&str, &str)], remove: &[&str]) -> BackendMCPGateway { BackendMCPGateway { name: "b".into(), url: "https://upstream.example/mcp".parse().unwrap(), + mcp_protocol_version: rmcp::model::ProtocolVersion::V_2026_07_28, 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(), - allowed_tool_names: vec![], - tool_name_aliases: HashMap::new(), - allowed_resource_names: vec![], - allowed_prompt_names: vec![], - resource_name_aliases: HashMap::new(), - prompt_name_aliases: HashMap::new(), + tool_name_aliases: HashSet::new(), + resource_uri_aliases: HashSet::new(), + prompt_name_aliases: HashSet::new(), completion: HashMap::new(), } } diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs index e8b9a18e..f3b208d1 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs @@ -21,7 +21,13 @@ pub(super) async fn get_prompt( let mcp_call_validator = AuthorizedCallValidator::new("get_prompt", &cx); let (virtual_host, _claims) = mcp_call_validator.validate_stateless()?; let backend_names: Vec<&str> = virtual_host.backends.keys().map(String::as_str).collect(); - let Some((backend_name, prompt_name)) = resolve_prompt_route(virtual_host, &request.name, &backend_names) else { + let Some((backend_name, prompt_name)) = + resolve_prompt_route(virtual_host, &request.name, &backend_names).map_err(|e| ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: format!("Routing problem... {e}").into(), + data: None, + })? + else { return Err(ErrorData { code: ErrorCode::INVALID_PARAMS, message: "Routing problem... promtp not found".into(), diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs index 19474ed9..b4e02799 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs @@ -22,7 +22,13 @@ pub(super) async fn read_resource( let mcp_call_validator = AuthorizedCallValidator::new("read_resource", &cx); let (virtual_host, _claims) = mcp_call_validator.validate_stateless()?; let backend_names: Vec<&str> = virtual_host.backends.keys().map(String::as_str).collect(); - let Some((backend_name, resource_uri)) = resolve_resources_route(virtual_host, &request.uri, &backend_names) else { + let Some((backend_name, resource_uri)) = resolve_resources_route(virtual_host, &request.uri, &backend_names) + .map_err(|e| ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: format!("Routing problem... {e}").into(), + data: None, + })? + else { return Err(ErrorData { code: ErrorCode::INVALID_PARAMS, message: "Routing problem... resource not found".into(), diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs index 950e7272..b4a82561 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs @@ -22,13 +22,20 @@ pub(super) async fn call_tool( let mcp_call_validator = AuthorizedCallValidator::new("call_tool", &cx); let (virtual_host, _claims) = mcp_call_validator.validate_stateless()?; let backend_names: Vec<&str> = virtual_host.backends.keys().map(String::as_str).collect(); - let Some((backend_name, tool_name)) = resolve_tool_route(virtual_host, &request.name, &backend_names) else { + let Some((backend_name, tool_name)) = + resolve_tool_route(virtual_host, &request.name, &backend_names).map_err(|e| ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: format!("Routing problem... {e}").into(), + data: None, + })? + else { return Err(ErrorData { code: ErrorCode::INVALID_PARAMS, message: "Routing problem... tool not found".into(), data: None, }); }; + let backend_name = backend_name.to_owned(); let tool_name = tool_name.to_owned(); let backend = virtual_host.backends.get(&backend_name).ok_or_else(|| ErrorData { @@ -36,6 +43,7 @@ pub(super) async fn call_tool( message: "Routing problem... backend not found".into(), data: 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? diff --git a/crates/contextforge-data-plane-lib/tests/gateway_call_tools.rs b/crates/contextforge-data-plane-lib/tests/gateway_call_tools.rs index 9d706123..cf543ab5 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_call_tools.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_call_tools.rs @@ -1,17 +1,16 @@ mod support; use contextforge_data_plane_lib::{Config, Result, UpstreamConnectionMode}; -use rmcp::model::CallToolRequestParams; +use rmcp::model::{CallToolRequestParams, ProtocolVersion}; use tracing::{info, warn}; -use support::{ - ListToolsGatewaySettings, TEST_USER_ID, connect_client, create_client, create_gateway_with_four_counters, - create_ports, -}; +use support::{ListToolsGatewaySettings, TEST_USER_ID, create_client, create_gateway_with_four_counters, create_ports}; + +use crate::support::{connect_client_with_protocol, connect_modern_client, create_gateway_with_four_legacy_counters}; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[test_log::test] -async fn plaintext_call_prefixed_backend_tools() -> Result<()> { +async fn plaintext_call_prefixed_backend_tools_modern_modern() -> Result<()> { let gateway_port = create_ports(1)[0]; let config = Config { @@ -33,7 +32,84 @@ async fn plaintext_call_prefixed_backend_tools() -> Result<()> { let mut call_params = CallToolRequestParams::default(); call_params.name = expected_tool_names[0].clone().into(); - let maybe_passed = assert_tools_call(gateway_url, client, call_params, "-1".to_owned()).await; + let maybe_passed = + assert_tools_call(gateway_url, client, call_params, "-1".to_owned(), ProtocolVersion::V_2026_07_28).await; + + handle.abort(); + if maybe_passed.is_ok() { + info!("Test passed"); + } else { + info!("Test NOT passed {maybe_passed:?}"); + panic!() + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[test_log::test] +async fn plaintext_call_prefixed_backend_tools_modern_legacy() -> Result<()> { + let gateway_port = create_ports(1)[0]; + + let config = Config { + address: Some(format!("127.0.0.1:{gateway_port}").parse().expect("This should work")), + token_verification_public_key: Some("../../assets/jwt.key.pub".into()), + upstream_connection_mode: Some(UpstreamConnectionMode::PlainTextOrTls), + ..Default::default() + }; + + let user = TEST_USER_ID; + + let Ok(ListToolsGatewaySettings { handle, gateway_url, expected_tool_names, .. }) = + create_gateway_with_four_legacy_counters(user, config).await + else { + panic!("Invalid configuration "); + }; + + let client = create_client(user); + + let mut call_params = CallToolRequestParams::default(); + call_params.name = expected_tool_names[0].clone().into(); + let maybe_passed = + assert_tools_call(gateway_url, client, call_params, "-1".to_owned(), ProtocolVersion::V_2026_07_28).await; + + handle.abort(); + if maybe_passed.is_ok() { + info!("Test passed"); + } else { + info!("Test NOT passed {maybe_passed:?}"); + panic!() + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[test_log::test] +async fn plaintext_call_prefixed_backend_tools_legacy_modern() -> Result<()> { + let gateway_port = create_ports(1)[0]; + + let config = Config { + address: Some(format!("127.0.0.1:{gateway_port}").parse().expect("This should work")), + token_verification_public_key: Some("../../assets/jwt.key.pub".into()), + upstream_connection_mode: Some(UpstreamConnectionMode::PlainTextOrTls), + ..Default::default() + }; + + let user = TEST_USER_ID; + + let Ok(ListToolsGatewaySettings { handle, gateway_url, expected_tool_names, .. }) = + create_gateway_with_four_counters(user, config).await + else { + panic!("Invalid configuration "); + }; + + let client = create_client(user); + + let mut call_params = CallToolRequestParams::default(); + call_params.name = expected_tool_names[0].clone().into(); + let maybe_passed = + assert_tools_call(gateway_url, client, call_params, "-1".to_owned(), ProtocolVersion::V_2025_11_25).await; handle.abort(); if maybe_passed.is_ok() { @@ -51,10 +127,15 @@ async fn assert_tools_call( client: reqwest::Client, call_tool_params: CallToolRequestParams, expected_result: String, + protocol_version: ProtocolVersion, ) -> Result<()> { info!("Seding request to {gateway_url}"); - let running_service = connect_client(gateway_url, client).await?; + let running_service = if protocol_version == ProtocolVersion::V_2026_07_28 { + connect_modern_client(&gateway_url, client, support::modern_client_info()).await + } else { + connect_client_with_protocol(gateway_url, client, protocol_version).await? + }; let call_tool = running_service.call_tool(call_tool_params).await; let Ok(call_tool) = call_tool else { @@ -108,8 +189,8 @@ async fn plaintext_call_invalid_backend_tools() -> Result<()> { let mut call_params = CallToolRequestParams::default(); call_params.name = "dummy_tool".into(); - let maybe_passed = assert_tools_call(gateway_url, client, call_params, "-1".to_owned()).await; - + let maybe_passed = + assert_tools_call(gateway_url, client, call_params, "-1".to_owned(), ProtocolVersion::V_2026_07_28).await; handle.abort(); if maybe_passed.is_ok() { info!("Test NOT passed {maybe_passed:?}"); diff --git a/crates/contextforge-data-plane-lib/tests/gateway_completions.rs b/crates/contextforge-data-plane-lib/tests/gateway_completions.rs index db37558f..6fefff01 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_completions.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_completions.rs @@ -8,6 +8,8 @@ use support::{ create_ports, plaintext_config, }; +use crate::support::connect_modern_client; + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[test_log::test] #[ignore = "2026-07-28 protocol transition"] @@ -107,7 +109,7 @@ async fn assert_resource_completion(gateway_url: String, client: reqwest::Client } async fn assert_unrouted_completion_errors(gateway_url: String, client: reqwest::Client) -> Result<()> { - let running_service = connect_client(gateway_url, client).await?; + let running_service = connect_modern_client(&gateway_url, client, support::modern_client_info()).await; // No backend namespace prefix => no route, so the gateway must reject it. let result = running_service.complete_prompt_simple("unrouted_prompt", "message", "h").await; diff --git a/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs b/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs index c3c48e01..271cd79a 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs @@ -1,6 +1,9 @@ mod support; -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use contextforge_data_plane_apis::{ User, @@ -24,15 +27,13 @@ fn paginating_backend(port: u16) -> BackendMCPGateway { BackendMCPGateway { name: format!("backend-{port}"), url: format!("http://127.0.0.1:{port}/mcp").parse().expect("valid url"), + mcp_protocol_version: rmcp::model::ProtocolVersion::V_2026_07_28, passthrough_headers: Vec::new(), add_headers: HashMap::new(), remove_headers: Vec::new(), - allowed_tool_names: Vec::new(), - tool_name_aliases: HashMap::new(), - allowed_resource_names: Vec::new(), - allowed_prompt_names: Vec::new(), - resource_name_aliases: HashMap::new(), - prompt_name_aliases: HashMap::new(), + tool_name_aliases: HashSet::new(), + resource_uri_aliases: HashSet::new(), + prompt_name_aliases: HashSet::new(), completion: HashMap::new(), } } diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index 51560f6a..7385703f 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -400,7 +400,7 @@ async fn stateless_tool_error_round_trips() { let rmcp::service::ServiceError::McpError(error) = error else { panic!("expected backend MCP error, got {error:?}"); }; - assert_eq!(ErrorCode::METHOD_NOT_FOUND, error.code); + assert_eq!(ErrorCode::INVALID_PARAMS, error.code); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -416,17 +416,10 @@ async fn stateless_alias_and_namespaced_tool_names_route() { support::modern_client_info(), ) .await; - let alias = expected_tool_names - .iter() - .find(|name| std::path::Path::new(name).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("sum"))) - .expect("sum alias is advertised"); - let backend_port = alias - .strip_prefix("backend-") - .and_then(|name| name.strip_suffix(".sum")) - .expect("alias contains the backend port"); - let namespaced_name = format!("00000000-0000-0000-0000-{backend_port:0>12}-sum"); + let alias = expected_tool_names.iter().find(|name| name.ends_with("sum")).expect("sum alias is advertised"); + let alias_result = service.call_tool(sum_request(alias, 1, 2)).await.expect("alias routes"); - let namespaced_result = service.call_tool(sum_request(&namespaced_name, 3, 4)).await.expect("namespace routes"); + let namespaced_result = service.call_tool(sum_request(alias, 3, 4)).await.expect("namespace routes"); assert_eq!("3", text(&alias_result)); assert_eq!("7", text(&namespaced_result)); handle.abort(); diff --git a/crates/contextforge-data-plane-lib/tests/gateway_prompts.rs b/crates/contextforge-data-plane-lib/tests/gateway_prompts.rs index 3beb264b..70921aca 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_prompts.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_prompts.rs @@ -10,6 +10,8 @@ use support::{ create_ports, }; +use crate::support::connect_modern_client; + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[test_log::test] #[ignore = "Fan out list tools is not supported at the moment. This should be enabled in 2.x"] @@ -97,7 +99,7 @@ async fn assert_list_prompts( } async fn assert_get_prompt(gateway_url: String, client: reqwest::Client, prompt_name: String) -> Result<()> { - let running_service = connect_client(gateway_url, client).await?; + let running_service = connect_modern_client(&gateway_url, client, support::modern_client_info()).await; let mut arguments = serde_json::Map::new(); arguments.insert("message".to_owned(), json!("hello from gateway")); diff --git a/crates/contextforge-data-plane-lib/tests/gateway_resource_read.rs b/crates/contextforge-data-plane-lib/tests/gateway_resource_read.rs index 466e7d87..d6648b1d 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_resource_read.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_resource_read.rs @@ -1,17 +1,104 @@ mod support; use contextforge_data_plane_lib::{Config, Result, UpstreamConnectionMode}; -use rmcp::model::ReadResourceRequestParams; +use rmcp::model::{ProtocolVersion, ReadResourceRequestParams}; use tracing::{info, warn}; -use support::{ - ListToolsGatewaySettings, TEST_USER_ID, connect_client, create_client, create_gateway_with_four_counters, - create_ports, -}; +use support::{ListToolsGatewaySettings, TEST_USER_ID, create_client, create_gateway_with_four_counters, create_ports}; + +use crate::support::{connect_client_with_protocol, connect_modern_client, create_gateway_with_four_legacy_counters}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[test_log::test] +async fn plaintext_call_prefixed_read_resources_modern_modern() -> Result<()> { + let gateway_port = create_ports(1)[0]; + + let config = Config { + address: Some(format!("127.0.0.1:{gateway_port}").parse().expect("This should work")), + token_verification_public_key: Some("../../assets/jwt.key.pub".into()), + upstream_connection_mode: Some(UpstreamConnectionMode::PlainTextOrTls), + ..Default::default() + }; + + let user = TEST_USER_ID; + + let Ok(ListToolsGatewaySettings { handle, gateway_url, expected_resource_uris, .. }) = + create_gateway_with_four_counters(user, config).await + else { + panic!("Invalid configuration "); + }; + + let client = create_client(user); + + let call_params = ReadResourceRequestParams::new(expected_resource_uris.first().expect("should work")); + + let maybe_passed = assert_resource_read( + gateway_url, + client, + call_params, + ProtocolVersion::V_2026_07_28, + "Business Intelligence Memo\n\nAnalysis has revealed 5 key insights ...".to_owned(), + ) + .await; + + handle.abort(); + if maybe_passed.is_ok() { + info!("Test passed"); + } else { + info!("Test NOT passed {maybe_passed:?}"); + panic!() + } + + Ok(()) +} #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[test_log::test] -async fn plaintext_call_prefixed_read_resources() -> Result<()> { +async fn plaintext_call_prefixed_read_resources_modern_legacy() -> Result<()> { + let gateway_port = create_ports(1)[0]; + + let config = Config { + address: Some(format!("127.0.0.1:{gateway_port}").parse().expect("This should work")), + token_verification_public_key: Some("../../assets/jwt.key.pub".into()), + upstream_connection_mode: Some(UpstreamConnectionMode::PlainTextOrTls), + ..Default::default() + }; + + let user = TEST_USER_ID; + + let Ok(ListToolsGatewaySettings { handle, gateway_url, expected_resource_uris, .. }) = + create_gateway_with_four_legacy_counters(user, config).await + else { + panic!("Invalid configuration "); + }; + + let client = create_client(user); + + let call_params = ReadResourceRequestParams::new(expected_resource_uris.first().expect("should work")); + + let maybe_passed = assert_resource_read( + gateway_url, + client, + call_params, + ProtocolVersion::V_2026_07_28, + "Business Intelligence Memo\n\nAnalysis has revealed 5 key insights ...".to_owned(), + ) + .await; + + handle.abort(); + if maybe_passed.is_ok() { + info!("Test passed"); + } else { + info!("Test NOT passed {maybe_passed:?}"); + panic!() + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[test_log::test] +async fn plaintext_call_prefixed_read_resources_legacy_modern() -> Result<()> { let gateway_port = create_ports(1)[0]; let config = Config { @@ -37,6 +124,7 @@ async fn plaintext_call_prefixed_read_resources() -> Result<()> { gateway_url, client, call_params, + ProtocolVersion::V_2025_11_25, "Business Intelligence Memo\n\nAnalysis has revealed 5 key insights ...".to_owned(), ) .await; @@ -56,11 +144,16 @@ async fn assert_resource_read( gateway_url: String, client: reqwest::Client, params: ReadResourceRequestParams, + protocol_version: ProtocolVersion, expected_result: String, ) -> Result<()> { info!("Seding request to {gateway_url}"); - let running_service = connect_client(gateway_url, client).await?; + let running_service = if protocol_version == ProtocolVersion::V_2026_07_28 { + connect_modern_client(&gateway_url, client, support::modern_client_info()).await + } else { + connect_client_with_protocol(gateway_url, client, protocol_version).await? + }; let response = running_service.read_resource(params).await; let Ok(response) = response else { @@ -119,7 +212,8 @@ async fn plaintext_call_invalid_backend_tools() -> Result<()> { let client = create_client(user); let call_params = ReadResourceRequestParams::new("http://dummy.dummy"); - let maybe_passed = assert_resource_read(gateway_url, client, call_params, "-1".to_owned()).await; + let maybe_passed = + assert_resource_read(gateway_url, client, call_params, ProtocolVersion::V_2026_07_28, "-1".to_owned()).await; handle.abort(); if maybe_passed.is_ok() { diff --git a/crates/contextforge-data-plane-lib/tests/gateway_subscriptions.rs b/crates/contextforge-data-plane-lib/tests/gateway_subscriptions.rs index 575fa668..a73d8e6f 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_subscriptions.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_subscriptions.rs @@ -18,12 +18,14 @@ use rmcp::{ }; use support::{ - CLIENT_CONNECT_TIMEOUT, ListToolsGatewaySettings, TEST_POLL_INTERVAL, TEST_USER_ID, connect_client, - connect_client_with_handler, create_client, create_gateway_with_four_counters, create_ports, + CLIENT_CONNECT_TIMEOUT, ListToolsGatewaySettings, TEST_POLL_INTERVAL, TEST_USER_ID, connect_client_with_handler, + create_client, create_gateway_with_four_counters, create_ports, mock_counter::{KNOWN_RESOURCE_URIS, RESOURCE_UPDATE_NOTIFY_INTERVAL}, plaintext_config, }; +use crate::support::connect_modern_client; + /// The mocks notify continuously, so this is just the threshold proving delivery works. const MIN_UPDATES_PER_BACKEND: usize = 4; @@ -147,7 +149,7 @@ async fn assert_no_more_resource_updates( #[expect(deprecated, reason = "legacy RMCP coverage; modern subscriptions/listen tests are deferred")] async fn assert_unrouted_subscribe_errors(gateway_url: String, client: reqwest::Client) -> Result<()> { - let running_service = connect_client(gateway_url, client).await?; + let running_service = connect_modern_client(&gateway_url, client, support::modern_client_info()).await; // No backend namespace prefix => no route, so the gateway must reject it. let result = running_service.subscribe(SubscribeRequestParams::new("unrouted://resource")).await; diff --git a/crates/contextforge-data-plane-lib/tests/support/client.rs b/crates/contextforge-data-plane-lib/tests/support/client.rs index dfebe342..506de43c 100644 --- a/crates/contextforge-data-plane-lib/tests/support/client.rs +++ b/crates/contextforge-data-plane-lib/tests/support/client.rs @@ -4,7 +4,7 @@ use contextforge_data_plane_lib::Result; use http::{HeaderMap, HeaderValue}; use rmcp::{ ServiceExt, - model::InitializeRequestParams, + model::{InitializeRequestParams, ProtocolVersion}, transport::{StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig}, }; use tracing::warn; @@ -41,7 +41,25 @@ pub(crate) async fn connect_client( gateway_url: String, client: reqwest::Client, ) -> Result> { - connect_client_with_handler(gateway_url, client, InitializeRequestParams::default()).await + connect_client_with_handler( + gateway_url, + client, + InitializeRequestParams::default().with_protocol_version(ProtocolVersion::V_2026_07_28), + ) + .await +} + +pub(crate) async fn connect_client_with_protocol( + gateway_url: String, + client: reqwest::Client, + protocol_version: ProtocolVersion, +) -> Result> { + connect_client_with_handler( + gateway_url, + client, + InitializeRequestParams::default().with_protocol_version(protocol_version), + ) + .await } /// Connects any `ClientHandler` to the gateway, retrying until `CLIENT_CONNECT_TIMEOUT`. diff --git a/crates/contextforge-data-plane-lib/tests/support/mod.rs b/crates/contextforge-data-plane-lib/tests/support/mod.rs index ffb1ae25..d7cc4829 100644 --- a/crates/contextforge-data-plane-lib/tests/support/mod.rs +++ b/crates/contextforge-data-plane-lib/tests/support/mod.rs @@ -2,12 +2,12 @@ mod auth; mod client; -mod list_tools_gateway; pub(crate) mod mock_counter; pub(crate) mod paginating_mock; mod plugin; mod plugin_gateway; mod runtime; +mod test_gateways; mod tool; mod user_config_store; @@ -16,12 +16,8 @@ pub(crate) const TEST_USER_EMAIL: &str = "admin@example.com"; pub(crate) use auth::token; pub(crate) use client::{ - CLIENT_CONNECT_TIMEOUT, TEST_POLL_INTERVAL, connect_client, connect_client_with_handler, connect_modern_client, - create_client, create_tls_client, modern_client_info, -}; -pub(crate) use list_tools_gateway::{ - ListToolsGatewaySettings, create_gateway_with_four_counters, create_ports, - create_tls_gateway_with_four_tls_counters, plaintext_config, + CLIENT_CONNECT_TIMEOUT, TEST_POLL_INTERVAL, connect_client, connect_client_with_handler, + connect_client_with_protocol, connect_modern_client, create_client, create_tls_client, modern_client_info, }; pub(crate) use plugin::{ POST_DENY_ERROR_CODE, PRE_DENY_ERROR_CODE, PROMPT_ERROR_MESSAGE, PROMPT_POST_DENY_ERROR_CODE, PromptBehavior, @@ -33,5 +29,9 @@ pub(crate) use plugin_gateway::{ start_gateway_with_json_backend_responses, }; pub(crate) use runtime::{runtime_with_post, runtime_with_pre, runtime_with_pre_and_post, runtime_with_prompt_plugin}; +pub(crate) use test_gateways::{ + ListToolsGatewaySettings, create_gateway_with_four_counters, create_gateway_with_four_legacy_counters, + create_ports, create_tls_gateway_with_four_tls_counters, plaintext_config, +}; pub(crate) use tool::{error_code, error_parts, sum_request, text}; pub(crate) use user_config_store::MemoryUserConfigStore; diff --git a/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs b/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs index f0c262e0..4e7af915 100644 --- a/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs @@ -1,12 +1,12 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::{Arc, Mutex as StdMutex, OnceLock}, time::{Duration, Instant}, }; use contextforge_data_plane_apis::{ User, - user_store::{BackendMCPGateway, UserConfig, VirtualHost}, + user_store::{BackendMCPGateway, NameAlias, UserConfig, VirtualHost}, }; use contextforge_data_plane_cpex::CpexRuntimeRegistry; use contextforge_data_plane_lib::{Config, Gateway, UpstreamConnectionMode, UserConfigStore, UserConfigStoreType}; @@ -194,6 +194,17 @@ impl ServerHandler for TestBackend { } } +pub const TOOL_NAMES: &[&str] = &[ + "progress_counter_tokens", + "progress_sum", + "sum", + "progress_counter_tokens", + "reflect_text", + "wait_for_cancellation", +]; +pub const RESOURCE_URIS: &[&str] = &[""]; +pub const PROMPT_NAMES: &[&str] = &["review_bundle", "review"]; + pub(crate) struct RunningGateway { pub(crate) backend_state: BackendState, pub(crate) backend_name: String, @@ -332,15 +343,22 @@ async fn start_gateway_with_state( BackendMCPGateway { url: format!("http://127.0.0.1:{backend_port}/mcp").parse().expect("backend URL"), name: String::new(), + mcp_protocol_version: rmcp::model::ProtocolVersion::V_2026_07_28, passthrough_headers: Vec::new(), add_headers: HashMap::default(), remove_headers: Vec::new(), - allowed_tool_names: Vec::new(), - tool_name_aliases: HashMap::new(), - allowed_resource_names: Vec::new(), - allowed_prompt_names: Vec::new(), - resource_name_aliases: HashMap::new(), - prompt_name_aliases: HashMap::new(), + tool_name_aliases: TOOL_NAMES + .iter() + .map(|n| NameAlias::new(n.to_string(), n.to_string())) + .collect(), + resource_uri_aliases: RESOURCE_URIS + .iter() + .map(|n| NameAlias::new(n.to_string(), n.to_string())) + .collect(), + prompt_name_aliases: PROMPT_NAMES + .iter() + .map(|n| NameAlias::new(n.to_string(), n.to_string())) + .collect(), completion: HashMap::new(), }, )]), diff --git a/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs b/crates/contextforge-data-plane-lib/tests/support/test_gateways.rs similarity index 70% rename from crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs rename to crates/contextforge-data-plane-lib/tests/support/test_gateways.rs index f88d8afc..6c1ecfa4 100644 --- a/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/test_gateways.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc}; use contextforge_data_plane_apis::{ User, - user_store::{BackendMCPGateway, UserConfig, VirtualHost}, + user_store::{BackendMCPGateway, NameAlias, UserConfig, VirtualHost}, }; use contextforge_data_plane_lib::{ Config, Gateway, Result, UpstreamConnectionMode, UserConfigStore, UserConfigStoreType, @@ -11,6 +11,7 @@ use futures::{FutureExt, future::BoxFuture}; use rmcp::transport::{ StreamableHttpServerConfig, StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, }; +use rustls::ProtocolVersion; use tracing::warn; use super::{MemoryUserConfigStore, mock_counter}; @@ -53,7 +54,11 @@ pub(crate) fn create_ports(ports: usize) -> Vec { selected } -pub(crate) async fn create_gateway_with_four_counters(user: &str, config: Config) -> Result { +async fn create_gateway_with_four_counters_and_custom_config( + user: &str, + config: Config, + create_backends: impl Fn(&[u16]) -> HashMap, +) -> Result { let mocked_user_config_store = MemoryUserConfigStore::default(); let config_address = config.address.expect("This must be set"); @@ -72,8 +77,8 @@ pub(crate) async fn create_gateway_with_four_counters(user: &str, config: Config assert_ne!(gateway_one_ports, gateway_two_ports); - let gateway_one_backends = create_backends(&gateway_one_ports, false); - let gateway_two_backends = create_backends(&gateway_two_ports, false); + let gateway_one_backends = create_backends(&gateway_one_ports); + let gateway_two_backends = create_backends(&gateway_two_ports); let mut virtual_host_one_tool_names = create_tool_names(&gateway_one_ports); virtual_host_one_tool_names.sort(); @@ -130,88 +135,29 @@ pub(crate) async fn create_gateway_with_four_counters(user: &str, config: Config }) } -pub(crate) async fn create_tls_gateway_with_four_tls_counters( +pub(crate) async fn create_gateway_with_four_counters(user: &str, config: Config) -> Result { + create_gateway_with_four_counters_and_custom_config(user, config, create_plain_backends).await +} + +pub(crate) async fn create_gateway_with_four_legacy_counters( user: &str, config: Config, ) -> Result { - let mocked_user_config_store = MemoryUserConfigStore::default(); - let gateway_port = config.tls_address.ok_or("Invalid configuration")?.port(); - - let service = StreamableHttpService::new( - || Ok(mock_counter::Counter::new()), - LocalSessionManager::default().into(), - StreamableHttpServerConfig::default().disable_allowed_hosts().disable_allowed_origins(), - ); - - let router = axum::Router::new().route_service("/mcp", service); - - let (gateway_one_ports, servers_one) = create_axum_tls_servers(2, gateway_port, router.clone()).await?; - let (gateway_two_ports, servers_two) = create_axum_tls_servers(2, gateway_port, router).await?; - - assert_ne!(gateway_one_ports, gateway_two_ports); - - let gateway_one_backends = create_backends(&gateway_one_ports, true); - let gateway_two_backends = create_backends(&gateway_two_ports, true); - - let mut virtual_host_one_tool_names = create_tool_names(&gateway_one_ports); - virtual_host_one_tool_names.sort(); - let mut virtual_host_one_prompt_names = create_prompt_names(&gateway_one_ports); - virtual_host_one_prompt_names.sort(); - let mut virtual_host_one_resource_template_names = create_resource_template_names(&gateway_one_ports); - virtual_host_one_resource_template_names.sort(); - let mut virtual_host_one_resource_template_uris = create_resource_template_uris(&gateway_one_ports); - virtual_host_one_resource_template_uris.sort(); - let mut virtual_host_one_resource_uris = create_resource_uris(&gateway_one_ports); - virtual_host_one_resource_uris.sort(); - - let user_key = User::new(user); - - let virtual_host_one_id = uuid::Uuid::new_v4().to_string(); - let virtual_host_two_id = uuid::Uuid::new_v4().to_string(); - - let virtual_hosts = HashMap::from([ - (virtual_host_one_id.clone(), VirtualHost { backends: gateway_one_backends }), - (virtual_host_two_id, VirtualHost { backends: gateway_two_backends }), - ]); - - let user_config = UserConfig { virtual_hosts }; - - mocked_user_config_store.set_config(&user_key, &user_config).await.expect("This should work"); - - let gateway = Gateway::builder() - .with_config(config.clone()) - .with_session_manager(Arc::new(LocalSessionManager::default())) - .with_user_config_store_type(UserConfigStoreType::Test(Arc::new(mocked_user_config_store))) - .build(); - - let gateway = async move { - let res = gateway.run_gateway().await; - warn!("Gateway exited with result {res:?}"); - Ok(()) - } - .boxed(); - - if let Some(address) = config.tls_address.as_ref() { - let gateway_url = format!("https://{address}/contextforge-rs/servers/{virtual_host_one_id}/mcp"); - - let handle = - tokio::spawn(futures::future::join_all(vec![gateway].into_iter().chain(servers_one).chain(servers_two))); + create_gateway_with_four_counters_and_custom_config(user, config, create_plain_legacy_backends).await +} - Ok(ListToolsGatewaySettings { - handle, - gateway_url, - expected_tool_names: virtual_host_one_tool_names, - expected_prompt_names: virtual_host_one_prompt_names, - expected_resource_template_names: virtual_host_one_resource_template_names, - expected_resource_template_uris: virtual_host_one_resource_template_uris, - expected_resource_uris: virtual_host_one_resource_uris, - }) - } else { - Err("Invalid configuration".into()) - } +pub(crate) async fn create_tls_gateway_with_four_tls_counters( + user: &str, + config: Config, +) -> Result { + create_gateway_with_four_counters_and_custom_config(user, config, create_tls_backends).await } -fn create_backends(ports: &[u16], with_tls: bool) -> HashMap { +fn create_backends( + ports: &[u16], + with_tls: bool, + protocol_version: &rmcp::model::ProtocolVersion, +) -> HashMap { ports .iter() .map(|port| { @@ -223,22 +169,37 @@ fn create_backends(ports: &[u16], with_tls: bool) -> HashMap HashMap HashMap { + create_backends(ports, false, &rmcp::model::ProtocolVersion::V_2026_07_28) +} + +fn create_plain_legacy_backends(ports: &[u16]) -> HashMap { + create_backends(ports, false, &rmcp::model::ProtocolVersion::V_2025_11_25) +} + +fn create_tls_backends(ports: &[u16]) -> HashMap { + create_backends(ports, true, &rmcp::model::ProtocolVersion::V_2026_07_28) +} + fn backend_id(port: u16) -> String { format!("00000000-0000-0000-0000-{port:012}") } @@ -253,7 +226,10 @@ fn backend_id(port: u16) -> String { fn create_tool_names(ports: &[u16]) -> Vec { ports .iter() - .flat_map(|port| MOCK_COUNTER_TOOL_NAMES.iter().map(move |name| format!("backend-{port}.{name}"))) + .flat_map(|port| { + let backend_id = backend_id(*port); + MOCK_COUNTER_TOOL_NAMES.iter().map(move |name| format!("{backend_id}-{name}")) + }) .collect() } diff --git a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs index b197468b..8ed8aebb 100644 --- a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs +++ b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs @@ -4,7 +4,7 @@ #![cfg(feature = "plugins")] use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, fs, net::TcpStream as StdTcpStream, path::PathBuf, @@ -34,6 +34,7 @@ use rmcp::{ streamable_http_server::session::local::LocalSessionManager, }, }; + use serde_json::{Map, Value, json}; use tokio::net::TcpListener; @@ -376,15 +377,13 @@ async fn write_redis_config(redis_port: u16, backend: &RunningBackend) { BackendMCPGateway { name: "backend".to_owned(), url: backend.url.parse().expect("backend URL parses"), + mcp_protocol_version: rmcp::model::ProtocolVersion::V_2026_07_28, passthrough_headers: Vec::new(), add_headers: HashMap::new(), remove_headers: Vec::new(), - allowed_tool_names: Vec::new(), - tool_name_aliases: HashMap::new(), - allowed_resource_names: Vec::new(), - allowed_prompt_names: Vec::new(), - resource_name_aliases: HashMap::new(), - prompt_name_aliases: HashMap::new(), + tool_name_aliases: HashSet::new(), + resource_uri_aliases: HashSet::new(), + prompt_name_aliases: HashSet::new(), completion: HashMap::new(), }, )]), diff --git a/schemas/user_config.json b/schemas/user_config.json index 69576e9e..359d0e9b 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -38,6 +38,9 @@ "type": "string", "format": "uri" }, + "mcp_protocol_version": { + "$ref": "#/$defs/ProtocolVersion" + }, "passthrough_headers": { "description": "Header names copied from the downstream request onto the upstream connection.", "type": "array", @@ -62,25 +65,28 @@ "default": [] }, "tool_name_aliases": { - "type": "object", - "additionalProperties": { - "type": "string" + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/NameAlias" }, - "default": {} + "default": [] }, - "resource_name_aliases": { - "type": "object", - "additionalProperties": { - "type": "string" + "resource_uri_aliases": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/NameAlias" }, - "default": {} + "default": [] }, "prompt_name_aliases": { - "type": "object", - "additionalProperties": { - "type": "string" + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/NameAlias" }, - "default": {} + "default": [] }, "completion": { "type": "object", @@ -88,33 +94,32 @@ "type": "string" }, "default": {} - }, - "allowed_resource_names": { - "type": "array", - "items": { - "type": "string" - } - }, - "allowed_prompt_names": { - "type": "array", - "items": { - "type": "string" - } - }, - "allowed_tool_names": { - "type": "array", - "items": { - "type": "string" - } } }, "required": [ "name", "url", - "passthrough_headers", - "allowed_resource_names", - "allowed_prompt_names", - "allowed_tool_names" + "mcp_protocol_version", + "passthrough_headers" + ] + }, + "ProtocolVersion": { + "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", + "type": "string" + }, + "NameAlias": { + "type": "object", + "properties": { + "downstream_prefixed_name": { + "type": "string" + }, + "upstream_name": { + "type": "string" + } + }, + "required": [ + "downstream_prefixed_name", + "upstream_name" ] } }