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
2 changes: 1 addition & 1 deletion .secrets.baseline

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

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.

1 change: 1 addition & 0 deletions crates/contextforge-data-plane-apis/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 39 additions & 8 deletions crates/contextforge-data-plane-apis/src/user_store.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
Expand All @@ -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<H: std::hash::Hasher>(&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<String>,
/// Static headers injected onto the upstream connection (override passthrough).
Expand All @@ -25,17 +60,13 @@ pub struct BackendMCPGateway {
#[serde(default)]
pub remove_headers: Vec<String>,
#[serde(default)]
pub tool_name_aliases: HashMap<String, String>,
pub tool_name_aliases: HashSet<NameAlias>,
#[serde(default)]
pub resource_name_aliases: HashMap<String, String>,
pub resource_uri_aliases: HashSet<NameAlias>,
#[serde(default)]
pub prompt_name_aliases: HashMap<String, String>,
pub prompt_name_aliases: HashSet<NameAlias>,
#[serde(default)]
pub completion: HashMap<String, String>,

pub allowed_resource_names: Vec<String>,
pub allowed_prompt_names: Vec<String>,
pub allowed_tool_names: Vec<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
Expand Down
176 changes: 90 additions & 86 deletions crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs
Original file line number Diff line number Diff line change
@@ -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<str>>(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<str>>(
fn resolve_route<'a, N: AsRef<str>>(
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<Option<(&'a str, &'a str)>, Box<dyn std::error::Error + Send + Sync>> {
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<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>>(
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<Option<(&'a str, &'a str)>, Box<dyn std::error::Error + Send + Sync>> {
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<str>>(
pub(super) fn resolve_resources_route<'a, N: AsRef<str>>(
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<Option<(&'a str, &'a str)>, Box<dyn std::error::Error + Send + Sync>> {
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<str>>(
virtual_host: &'a VirtualHost,
name: &'a str,
backend_names: &'a [N],
) -> Result<Option<(&'a str, &'a str)>, Box<dyn std::error::Error + Send + Sync>> {
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 {
Expand All @@ -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<str>>(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"];
Expand Down Expand Up @@ -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"}
]
}
}
});
Expand All @@ -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")
);
}

Expand All @@ -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": []
}
}
});
Expand All @@ -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")
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Comment thread
dawid-nowak marked this conversation as resolved.
Comment thread
dawid-nowak marked this conversation as resolved.

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
Expand Down Expand Up @@ -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(),
}
}
Expand Down
Loading
Loading