From 5e811d4522dfab2ed1ac76c079b298d481cf1fd5 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Mon, 13 Jul 2026 09:35:40 +0100 Subject: [PATCH] Preserve MCP identifiers for single-backend hosts Signed-off-by: lucarlig --- .../src/gateway/backend_client.rs | 27 +- .../src/gateway/mcp_gateway.rs | 244 ++++++++++++++---- .../tests/gateway_plugins.rs | 36 +-- docs/book/src/architectural-choices.md | 16 +- docs/book/src/mcp-method-reference.md | 30 +-- docs/book/src/mcp-routing-semantics.md | 61 +++-- docs/book/src/request-flow.md | 18 +- docs/book/src/runtime-configuration.md | 3 +- docs/book/src/session-ownership.md | 4 +- docs/book/src/what-is-contextforge-gateway.md | 12 +- 10 files changed, 317 insertions(+), 134 deletions(-) diff --git a/crates/contextforge-gateway-rs-lib/src/gateway/backend_client.rs b/crates/contextforge-gateway-rs-lib/src/gateway/backend_client.rs index 247cfe7..da89425 100644 --- a/crates/contextforge-gateway-rs-lib/src/gateway/backend_client.rs +++ b/crates/contextforge-gateway-rs-lib/src/gateway/backend_client.rs @@ -19,6 +19,7 @@ use super::mcp_gateway::prefixed_name; #[derive(Clone)] pub(crate) struct GatewayBackendClient { backend_name: String, + namespace_identifiers: bool, initialize_request: InitializeRequestParams, plugin_runtime: Option, in_flight_calls: Arc>>>, @@ -36,11 +37,13 @@ struct InFlightToolCall { impl GatewayBackendClient { pub(crate) fn new( backend_name: String, + namespace_identifiers: bool, initialize_request: InitializeRequestParams, plugin_runtime: Option, ) -> Self { Self { backend_name, + namespace_identifiers, initialize_request, plugin_runtime, in_flight_calls: Arc::default(), @@ -155,13 +158,17 @@ impl ClientHandler for GatewayBackendClient { return; }; - params.uri = prefixed_name(&self.backend_name, ¶ms.uri); + params.uri = resource_uri_for_downstream(&self.backend_name, params.uri, self.namespace_identifiers); if let Err(error) = downstream.notify_resource_updated(params).await { warn!("resource_updated: unable to forward backend notification downstream: {error:?}"); } } } +fn resource_uri_for_downstream(backend_name: &str, uri: String, namespace_identifiers: bool) -> String { + if namespace_identifiers { prefixed_name(backend_name, &uri) } else { uri } +} + /// Calls the tool on the backend, keeping the downstream progress token on /// the request (`Peer::call_tool` would stamp an auto-generated token over it /// at serialization) and relaying a downstream cancellation to the backend. @@ -196,3 +203,21 @@ pub(crate) async fn call_backend_tool( _ => Err(ServiceError::UnexpectedResponse), } } + +#[cfg(test)] +mod tests { + use super::resource_uri_for_downstream; + + #[test] + fn single_backend_resource_update_preserves_uri() { + assert_eq!("test://resource", resource_uri_for_downstream("backend-id", "test://resource".to_owned(), false)); + } + + #[test] + fn multi_backend_resource_update_prefixes_uri() { + assert_eq!( + "backend-id-test://resource", + resource_uri_for_downstream("backend-id", "test://resource".to_owned(), true) + ); + } +} diff --git a/crates/contextforge-gateway-rs-lib/src/gateway/mcp_gateway.rs b/crates/contextforge-gateway-rs-lib/src/gateway/mcp_gateway.rs index 853469a..8bc81e5 100644 --- a/crates/contextforge-gateway-rs-lib/src/gateway/mcp_gateway.rs +++ b/crates/contextforge-gateway-rs-lib/src/gateway/mcp_gateway.rs @@ -140,13 +140,18 @@ where }); }; + let namespace_identifiers = virtual_host.backends.len() > 1; let tasks: Vec<_> = virtual_host .backends .iter() .map(|(name, backend)| { let client = self.http_client.clone(); - let backend_client = - GatewayBackendClient::new(name.clone(), request.clone(), self.plugin_runtime.clone()); + let backend_client = GatewayBackendClient::new( + name.clone(), + namespace_identifiers, + request.clone(), + self.plugin_runtime.clone(), + ); let backend_url = backend.url.clone(); let downstream_session_id = downstream_session_id.clone(); @@ -242,7 +247,6 @@ where ) -> Result { let mcp_call_validator = AuthorizedCallValidator::new("list_tools", &cx); let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; - let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &self.transports); let backend_transports: Vec<_> = session_manager.borrow_transports().await; @@ -318,6 +322,7 @@ where ) -> Result { let mcp_call_validator = AuthorizedCallValidator::new("list_resources", &cx); let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; + let namespace_identifiers = virtual_host.backends.len() > 1; let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &self.transports); let backend_transports: Vec<_> = session_manager.borrow_transports().await; @@ -333,7 +338,11 @@ where ) .await; - Ok(ListResourcesResult { meta: None, resources: merge_resources(responses), next_cursor: None }) + Ok(ListResourcesResult { + meta: None, + resources: merge_resources(responses, namespace_identifiers), + next_cursor: None, + }) } async fn read_resource( @@ -345,7 +354,7 @@ where let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &self.transports); - let (service_name, service, resource_uri) = route_prefixed_name( + let (service_name, service, resource_uri) = route_identifier_to_backend( &session_manager, "read_resource", &request.uri, @@ -370,6 +379,7 @@ where ) -> Result { let mcp_call_validator = AuthorizedCallValidator::new("list_resource_templates", &cx); let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; + let namespace_identifiers = virtual_host.backends.len() > 1; let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &self.transports); let backend_transports: Vec<_> = session_manager.borrow_transports().await; @@ -387,7 +397,7 @@ where Ok(ListResourceTemplatesResult { meta: None, - resource_templates: merge_resource_templates(responses), + resource_templates: merge_resource_templates(responses, namespace_identifiers), next_cursor: None, }) } @@ -401,9 +411,13 @@ where let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &self.transports); - let (service_name, service, resource_uri) = - route_prefixed_name(&session_manager, "subscribe", &request.uri, "Routing problem... wrong resource name") - .await?; + let (service_name, service, resource_uri) = route_identifier_to_backend( + &session_manager, + "subscribe", + &request.uri, + "Routing problem... wrong resource name", + ) + .await?; let mut routed_request = request; routed_request.uri = resource_uri.clone(); @@ -426,7 +440,7 @@ where let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &self.transports); - let (service_name, service, resource_uri) = route_prefixed_name( + let (service_name, service, resource_uri) = route_identifier_to_backend( &session_manager, "unsubscribe", &request.uri, @@ -452,6 +466,7 @@ where ) -> Result { let mcp_call_validator = AuthorizedCallValidator::new("list_prompts", &cx); let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; + let namespace_identifiers = virtual_host.backends.len() > 1; let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &self.transports); let backend_transports: Vec<_> = session_manager.borrow_transports().await; @@ -467,7 +482,11 @@ where ) .await; - Ok(ListPromptsResult { meta: None, prompts: merge_prompts(responses), next_cursor: None }) + Ok(ListPromptsResult { + meta: None, + prompts: merge_prompts(responses, namespace_identifiers), + next_cursor: None, + }) } async fn get_prompt( @@ -479,9 +498,13 @@ where let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &self.transports); - let (service_name, service, prompt_name) = - route_prefixed_name(&session_manager, "get_prompt", &request.name, "Routing problem... wrong prompt name") - .await?; + let (service_name, service, prompt_name) = route_identifier_to_backend( + &session_manager, + "get_prompt", + &request.name, + "Routing problem... wrong prompt name", + ) + .await?; let mut routed_request = request; routed_request.name = prompt_name; @@ -502,24 +525,23 @@ where let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &self.transports); - // The reference carries a namespaced prompt name or resource URI; route on that. - let namespaced = match &request.r#ref { + let identifier = match &request.r#ref { Reference::Prompt(prompt) => prompt.name.as_str(), Reference::Resource(resource) => resource.uri.as_str(), }; - let (service_name, service, stripped) = route_prefixed_name( + let (service_name, service, routed_identifier) = route_identifier_to_backend( &session_manager, "complete", - namespaced, + identifier, "Routing problem... wrong completion reference", ) .await?; let mut routed_request = request; match &mut routed_request.r#ref { - Reference::Prompt(prompt) => prompt.name = stripped, - Reference::Resource(resource) => resource.uri = stripped, + Reference::Prompt(prompt) => prompt.name = routed_identifier, + Reference::Resource(resource) => resource.uri = routed_identifier, } let response = service .complete(routed_request) @@ -530,23 +552,26 @@ where } } -/// Splits a `{backend}-{rest}` routing name, returning `(backend_name, rest)` for the first -/// backend whose name is a `-`-delimited prefix. Shared by tool, resource, and prompt routing. -fn split_prefixed_name<'a, N: AsRef>(name: &'a str, backend_names: &'a [N]) -> Option<(&'a str, &'a str)> { +/// 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(); - name.strip_prefix(backend)?.strip_prefix('-').map(|rest| (backend, rest)) + 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. -/// Inverse of [`split_prefixed_name`]; together they own the naming convention. 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. -/// Older configs without aliases retain the legacy `{backend}-{tool}` route. +/// 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. fn resolve_tool_route<'a, N: AsRef>( virtual_host: &'a VirtualHost, name: &'a str, @@ -561,11 +586,11 @@ fn resolve_tool_route<'a, N: AsRef>( if aliases.next().is_some() { return None; } - alias.or_else(|| split_prefixed_name(name, backend_names)) + alias.or_else(|| route_identifier(name, backend_names)) } -/// Returns the control-plane name for an upstream tool, with the legacy -/// namespaced form as a fallback for configs published before aliases existed. +/// 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 @@ -576,7 +601,13 @@ fn exposed_tool_name(virtual_host: &VirtualHost, backend_name: &str, original_na .iter() .find_map(|(alias, original)| (original == original_name).then(|| alias.clone())) }) - .unwrap_or_else(|| prefixed_name(backend_name, original_name)) + .unwrap_or_else(|| { + if virtual_host.backends.len() == 1 { + original_name.to_owned() + } else { + prefixed_name(backend_name, original_name) + } + }) } /// Logs a backend forwarding failure and maps it to the routing error every handler returns. @@ -627,22 +658,21 @@ where .collect() } -/// Routes a namespaced `{backend}-{rest}` name: splits it against the session's backend names -/// and resolves the owning backend, returning `(backend_name, service, rest)`. Shared by tool, -/// resource, prompt, and completion routing. -async fn route_prefixed_name( +/// Routes an identifier to its backend, preserving it for a single backend and splitting the +/// namespace for multiple backends. Returns `(backend_name, service, backend_local_identifier)`. +async fn route_identifier_to_backend( session_manager: &SessionManager<'_>, op: &str, - namespaced: &str, + identifier: &str, no_route_message: &'static str, ) -> Result<(String, McpClientService, String), ErrorData> { let backend_names = session_manager.get_backend_names(); - let Some((backend_name, rest)) = split_prefixed_name(namespaced, &backend_names) else { + let Some((backend_name, routed_identifier)) = route_identifier(identifier, &backend_names) else { return Err(ErrorData { code: ErrorCode::INTERNAL_ERROR, message: no_route_message.into(), data: None }); }; - let rest = rest.to_owned(); + let routed_identifier = routed_identifier.to_owned(); let (backend_name, service) = resolve_backend(session_manager, op, backend_name).await?; - Ok((backend_name, service, rest)) + Ok((backend_name, service, routed_identifier)) } /// Resolves the single connected backend named `backend_name` and takes its running service. @@ -730,7 +760,7 @@ fn merge_tools(tools: Vec<(String, ListToolsResult)>, virtual_host: &VirtualHost .collect::>() } -fn merge_resources(resources: Vec<(String, ListResourcesResult)>) -> Vec { +fn merge_resources(resources: Vec<(String, ListResourcesResult)>, namespace_identifiers: bool) -> Vec { resources .into_iter() .flat_map(|(backend_name, result)| { @@ -738,8 +768,10 @@ fn merge_resources(resources: Vec<(String, ListResourcesResult)>) -> Vec>() @@ -748,13 +780,18 @@ fn merge_resources(resources: Vec<(String, ListResourcesResult)>) -> Vec>() } -fn merge_resource_templates(templates: Vec<(String, ListResourceTemplatesResult)>) -> Vec { +fn merge_resource_templates( + templates: Vec<(String, ListResourceTemplatesResult)>, + namespace_identifiers: bool, +) -> Vec { templates .into_iter() .flat_map(|(backend_name, result)| { result.resource_templates.into_iter().map(move |mut template| { - template.name = prefixed_name(&backend_name, &template.name); - template.uri_template = prefixed_name(&backend_name, &template.uri_template); + if namespace_identifiers { + template.name = prefixed_name(&backend_name, &template.name); + template.uri_template = prefixed_name(&backend_name, &template.uri_template); + } template }) }) @@ -762,12 +799,14 @@ fn merge_resource_templates(templates: Vec<(String, ListResourceTemplatesResult) .collect::>() } -fn merge_prompts(prompts: Vec<(String, ListPromptsResult)>) -> Vec { +fn merge_prompts(prompts: Vec<(String, ListPromptsResult)>, namespace_identifiers: bool) -> Vec { prompts .into_iter() .flat_map(|(backend_name, result)| { result.prompts.into_iter().map(move |mut prompt| { - prompt.name = prefixed_name(&backend_name, &prompt.name); + if namespace_identifiers { + prompt.name = prefixed_name(&backend_name, &prompt.name); + } prompt }) }) @@ -779,24 +818,108 @@ fn merge_prompts(prompts: Vec<(String, ListPromptsResult)>) -> Vec { mod tests { // Note this useful idiom: importing names from outer (for mod tests) scope. use super::*; + use rmcp::model::{AnnotateAble, RawResource, RawResourceTemplate}; #[test] fn test_splitting() { let backend_names = vec!["counter-on", "counter-oneee", "counter-one"]; - assert_eq!(Some(("counter-one", "increment")), split_prefixed_name("counter-one-increment", &backend_names)); - assert_eq!(None, split_prefixed_name("counter-oneincrement", &backend_names)); - assert_eq!(None, split_prefixed_name("counteroneincrement", &backend_names)); - assert_eq!(Some(("counter-one", "get-value")), split_prefixed_name("counter-one-get-value", &backend_names)); + assert_eq!(Some(("counter-one", "increment")), route_identifier("counter-one-increment", &backend_names)); + assert_eq!(None, route_identifier("counter-oneincrement", &backend_names)); + assert_eq!(None, route_identifier("counteroneincrement", &backend_names)); + assert_eq!(Some(("counter-one", "get-value")), route_identifier("counter-one-get-value", &backend_names)); // Tool, resource, and prompt routing all share this splitter. assert_eq!( Some(("counter-one", "example-prompt")), - split_prefixed_name("counter-one-example-prompt", &backend_names) + route_identifier("counter-one-example-prompt", &backend_names) ); - assert_eq!(None, split_prefixed_name("counter-oneexample-prompt", &backend_names)); + assert_eq!(None, route_identifier("counter-oneexample-prompt", &backend_names)); let backend_names = vec!["counter_on", "counter_oneee", "counter_one"]; - assert_eq!(Some(("counter_one", "get-value")), split_prefixed_name("counter_one-get-value", &backend_names)); + assert_eq!(Some(("counter_one", "get-value")), route_identifier("counter_one-get-value", &backend_names)); + } + + #[test] + fn single_backend_routes_unprefixed_identifier_unchanged() { + let backend_names = vec!["backend-id"]; + + assert_eq!(Some(("backend-id", "test_simple_text")), route_identifier("test_simple_text", &backend_names)); + assert_eq!(Some(("backend-id", "backend-id-tool")), route_identifier("backend-id-tool", &backend_names)); + assert_eq!( + Some(("backend-id", "test://template/123/data")), + route_identifier("test://template/123/data", &backend_names) + ); + } + + #[test] + fn single_backend_listings_preserve_identifiers() { + let config_json = serde_json::json!({ + "backends": { + "backend-id": { + "name": "backend", + "url": "http://upstream:9000/mcp", + "transport": "STREAMABLEHTTP", + "passthrough_headers": [], + "allowed_tool_names": ["test_simple_text"], + "allowed_resource_names": [], + "allowed_prompt_names": [] + } + } + }); + let virtual_host: VirtualHost = serde_json::from_value(config_json).expect("valid virtual host"); + let tools = merge_tools( + vec![( + "backend-id".to_owned(), + ListToolsResult { + tools: vec![Tool::new("test_simple_text", "", serde_json::Map::new())], + next_cursor: None, + meta: None, + }, + )], + &virtual_host, + ); + let prompts = merge_prompts( + vec![( + "backend-id".to_owned(), + ListPromptsResult { + prompts: vec![Prompt::new("test_prompt", None::, None)], + next_cursor: None, + meta: None, + }, + )], + false, + ); + let resources = merge_resources( + vec![( + "backend-id".to_owned(), + ListResourcesResult { + resources: vec![RawResource::new("test://resource", "test_resource").no_annotation()], + next_cursor: None, + meta: None, + }, + )], + false, + ); + let templates = merge_resource_templates( + vec![( + "backend-id".to_owned(), + ListResourceTemplatesResult { + resource_templates: vec![ + RawResourceTemplate::new("test://template/{id}/data", "test_template").no_annotation(), + ], + next_cursor: None, + meta: None, + }, + )], + false, + ); + + assert_eq!("test_simple_text", tools[0].name); + assert_eq!("test_prompt", prompts[0].name); + assert_eq!("test_resource", resources[0].name); + assert_eq!("test://resource", resources[0].uri); + assert_eq!("test_template", templates[0].name); + assert_eq!("test://template/{id}/data", templates[0].uri_template); } #[test] @@ -832,7 +955,7 @@ mod tests { } #[test] - fn test_tool_routing_falls_back_to_legacy_prefixed_names() { + fn multi_backend_tool_routing_falls_back_to_legacy_prefixed_names() { let config_json = serde_json::json!({ "backends": { "compliance-reference": { @@ -843,11 +966,20 @@ mod tests { "allowed_tool_names": ["get_stats"], "allowed_resource_names": [], "allowed_prompt_names": [] + }, + "other": { + "name": "other", + "url": "http://other:9000/mcp", + "transport": "STREAMABLEHTTP", + "passthrough_headers": [], + "allowed_tool_names": [], + "allowed_resource_names": [], + "allowed_prompt_names": [] } } }); let virtual_host: VirtualHost = serde_json::from_value(config_json).expect("valid virtual host"); - let backend_names = vec!["compliance-reference"]; + let backend_names = vec!["compliance-reference", "other"]; assert_eq!( "compliance-reference-get_stats", diff --git a/crates/contextforge-gateway-rs-lib/tests/gateway_plugins.rs b/crates/contextforge-gateway-rs-lib/tests/gateway_plugins.rs index c8f67a2..2368939 100644 --- a/crates/contextforge-gateway-rs-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-gateway-rs-lib/tests/gateway_plugins.rs @@ -58,7 +58,7 @@ async fn send_progress_sum( let progress = Arc::clone(&client.progress); let service = gateway.connect_with_handler(user, client).await; let token = ProgressToken(NumberOrString::String("package-progress".into())); - let handle = send_progress_call(&service, &format!("{}-progress_sum", gateway.backend_name), token).await; + let handle = send_progress_call(&service, "progress_sum", token).await; let ServerResult::CallToolResult(result) = handle.await_response().await.expect("progress_sum call succeeds") else { @@ -232,10 +232,10 @@ async fn concurrent_progress_calls_forward_each_token_without_plugins() { let client = RecordingClient::default(); let progress = Arc::clone(&client.progress); let service = gateway.connect_with_handler("admin@example.com", client).await; - let tool_name = format!("{}-progress_sum", gateway.backend_name); + let tool_name = "progress_sum"; - let first = send_progress_call(&service, &tool_name, ProgressToken(NumberOrString::Number(1))).await; - let second = send_progress_call(&service, &tool_name, ProgressToken(NumberOrString::Number(2))).await; + let first = send_progress_call(&service, tool_name, ProgressToken(NumberOrString::Number(1))).await; + let second = send_progress_call(&service, tool_name, ProgressToken(NumberOrString::Number(2))).await; let (first, second) = tokio::time::timeout(std::time::Duration::from_secs(3), async { tokio::join!(first.await_response(), second.await_response()) @@ -272,11 +272,11 @@ async fn raw_streamable_http_concurrent_progress_calls_complete_without_plugins( let client = reqwest::Client::new(); let session_id = start_raw_mcp_session(&client, &gateway, "admin@example.com").await; - let tool_name = format!("{}-progress_sum", gateway.backend_name); + let tool_name = "progress_sum"; let first = - raw_mcp_request(&client, &gateway, "admin@example.com", Some(&session_id), &raw_tool_call(&tool_name, 2, 1)); + raw_mcp_request(&client, &gateway, "admin@example.com", Some(&session_id), &raw_tool_call(tool_name, 2, 1)); let second = - raw_mcp_request(&client, &gateway, "admin@example.com", Some(&session_id), &raw_tool_call(&tool_name, 3, 2)); + raw_mcp_request(&client, &gateway, "admin@example.com", Some(&session_id), &raw_tool_call(tool_name, 3, 2)); let (first_body, second_body) = read_concurrent_raw_progress_streams(first, second).await; assert_raw_progress_stream(&first_body, 2, 1); @@ -290,8 +290,8 @@ async fn backend_generated_progress_tokens_are_dropped() { let progress = Arc::clone(&client.progress); let service = gateway.connect_with_handler("admin@example.com", client).await; - let tool_name = format!("{}-progress_counter_tokens", gateway.backend_name); - let handle = send_progress_call(&service, &tool_name, ProgressToken(NumberOrString::Number(10))).await; + let tool_name = "progress_counter_tokens"; + let handle = send_progress_call(&service, tool_name, ProgressToken(NumberOrString::Number(10))).await; let ServerResult::CallToolResult(result) = handle.await_response().await.expect("progress_counter_tokens call succeeds") @@ -318,7 +318,7 @@ async fn disabled_runtime_does_not_invoke_registered_plugin() { let gateway = start_gateway("admin@example.com", false, runtime).await; let service = gateway.connect("admin@example.com").await; - let result = service.call_tool(sum_request(format!("{}-sum", gateway.backend_name), 1, 2)).await.unwrap(); + let result = service.call_tool(sum_request("sum", 1, 2)).await.unwrap(); assert_eq!("3", text(&result)); assert_eq!(0, pre_observations.lock().expect("observations lock poisoned").pre_calls); @@ -333,7 +333,7 @@ async fn pre_hook_modifies_backend_arguments_without_rerouting_tool() { let gateway = start_gateway("admin@example.com", true, runtime).await; let service = gateway.connect("admin@example.com").await; - let result = service.call_tool(sum_request(format!("{}-sum", gateway.backend_name), 1, 2)).await.unwrap(); + let result = service.call_tool(sum_request("sum", 1, 2)).await.unwrap(); assert_eq!((REWRITTEN_SUM_A + REWRITTEN_SUM_B).to_string(), text(&result)); let backend_calls = gateway.backend_state.calls.lock().expect("backend calls lock poisoned"); @@ -355,7 +355,7 @@ async fn post_hook_receives_backend_result_and_modifies_client_result() { let gateway = start_gateway("admin@example.com", true, runtime).await; let service = gateway.connect("admin@example.com").await; - let result = service.call_tool(sum_request(format!("{}-sum", gateway.backend_name), 1, 2)).await.unwrap(); + let result = service.call_tool(sum_request("sum", 1, 2)).await.unwrap(); assert_eq!("post:3", text(&result)); let observations = observations.lock().expect("observations lock poisoned"); @@ -425,7 +425,7 @@ async fn downstream_cancellation_is_relayed_to_backend() { let gateway = start_gateway("admin@example.com", true, Arc::new(CpexRuntimeRegistry::default())).await; let service = gateway.connect("admin@example.com").await; - let request = CallToolRequestParams::new(format!("{}-wait_for_cancellation", gateway.backend_name)); + let request = CallToolRequestParams::new("wait_for_cancellation"); let handle = service .send_cancellable_request( ClientRequest::CallToolRequest(Request::new(request)), @@ -446,7 +446,7 @@ async fn post_hook_can_return_raw_cmf_result_content() { let gateway = start_gateway("admin@example.com", true, runtime).await; let service = gateway.connect("admin@example.com").await; - let result = service.call_tool(sum_request(format!("{}-sum", gateway.backend_name), 1, 2)).await.unwrap(); + let result = service.call_tool(sum_request("sum", 1, 2)).await.unwrap(); assert_eq!("raw-post", text(&result)); } @@ -462,7 +462,7 @@ async fn pre_and_post_hooks_share_gateway_call_context() { let gateway = start_gateway("admin@example.com", true, runtime).await; let service = gateway.connect("admin@example.com").await; - let result = service.call_tool(sum_request(format!("{}-sum", gateway.backend_name), 1, 2)).await.unwrap(); + let result = service.call_tool(sum_request("sum", 1, 2)).await.unwrap(); assert_eq!("3", text(&result)); assert_eq!(1, post_observations.lock().expect("observations lock poisoned").post_calls); @@ -474,7 +474,7 @@ async fn pre_and_post_denials_return_plugin_error_codes() { let runtime = runtime_with_pre(pre_plugin).await; let gateway = start_gateway("admin@example.com", true, runtime).await; let service = gateway.connect("admin@example.com").await; - let error = service.call_tool(sum_request(format!("{}-sum", gateway.backend_name), 1, 2)).await.unwrap_err(); + let error = service.call_tool(sum_request("sum", 1, 2)).await.unwrap_err(); assert_eq!(ErrorCode(PRE_DENY_ERROR_CODE), error_code(error)); assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty()); @@ -482,7 +482,7 @@ async fn pre_and_post_denials_return_plugin_error_codes() { let runtime = runtime_with_post(post_plugin).await; let gateway = start_gateway("admin@example.com", true, runtime).await; let service = gateway.connect("admin@example.com").await; - let error = service.call_tool(sum_request(format!("{}-sum", gateway.backend_name), 1, 2)).await.unwrap_err(); + let error = service.call_tool(sum_request("sum", 1, 2)).await.unwrap_err(); assert_eq!(ErrorCode(POST_DENY_ERROR_CODE), error_code(error)); assert_eq!(1, gateway.backend_state.calls.lock().expect("backend calls lock poisoned").len()); } @@ -494,7 +494,7 @@ async fn pre_hook_invalid_arguments_return_invalid_params() { let runtime = runtime_with_pre(plugin).await; let gateway = start_gateway("admin@example.com", true, runtime).await; let service = gateway.connect("admin@example.com").await; - let error = service.call_tool(sum_request(format!("{}-sum", gateway.backend_name), 1, 2)).await.unwrap_err(); + let error = service.call_tool(sum_request("sum", 1, 2)).await.unwrap_err(); assert_eq!(ErrorCode::INVALID_PARAMS, error_code(error)); assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty()); diff --git a/docs/book/src/architectural-choices.md b/docs/book/src/architectural-choices.md index 608d0ec..7b82bf6 100644 --- a/docs/book/src/architectural-choices.md +++ b/docs/book/src/architectural-choices.md @@ -54,9 +54,10 @@ That is why request code should stay behind `UserConfigStore`. Redis key encoding, MessagePack, cache expiry, and retry settings belong in the adapter, not in MCP method handling. -## Backend Names Are Public +## Backend Names Are Conditionally Public -Backend names are visible in the MCP namespace: +Backend map keys are visible in the MCP namespace when multiple backends need +disambiguation: ```text backend map key: gateway-one @@ -64,9 +65,10 @@ backend tool: increment gateway tool: gateway-one-increment ``` -The map key, not `BackendMCPGateway.name`, is the namespace used by current -routing. Any future aliasing, filtering, or prettier naming scheme needs a -migration plan because clients may already refer to these prefixed names. +For a single backend, upstream identifiers pass through unchanged. Explicit +`tool_name_aliases` published by the control plane take precedence in either +case. Otherwise the map key, not `BackendMCPGateway.name`, is the namespace +used by multi-backend routing. ## Session State Is Local Today @@ -92,8 +94,8 @@ client -> merged tools/resources/prompts ``` -Backend identity appears through namespaced objects, but clients should not -need to know transport details, Redis storage, fanout mechanics, or plugin +Backend identity appears only when namespacing is needed, and clients should +not need to know transport details, Redis storage, fanout mechanics, or plugin runtime internals. This choice leaves room for filtering, policy, and route changes without diff --git a/docs/book/src/mcp-method-reference.md b/docs/book/src/mcp-method-reference.md index 9162f55..a0bcf84 100644 --- a/docs/book/src/mcp-method-reference.md +++ b/docs/book/src/mcp-method-reference.md @@ -1,11 +1,11 @@ # MCP Method Reference > 📋 **Reference lens:** this page lists what each MCP method does at the -> gateway today, from the client's point of view. For how prefixed names are -> split and merged, see [MCP Routing Semantics](mcp-routing-semantics.md). +> gateway today, from the client's point of view. For how identifiers are +> preserved, aliased, or namespaced, see [MCP Routing Semantics](mcp-routing-semantics.md). Gateway methods fall into three groups: `initialize` creates backend sessions, -routed methods use them, and a few methods remain local to the gateway process. +routed methods use them, and `ping` remains local to the gateway process. ## Initialize @@ -25,35 +25,35 @@ share one fanout path: | Aspect | Behavior | | --- | --- | | Fanout | Concurrent call to every connected backend in the session. | -| Namespacing | Every returned name is prefixed with its backend name. Resource templates get both the template name and the URI template prefixed. | +| Identifiers | A single backend preserves upstream identifiers; multiple backends use the backend map-key prefix. Explicit control-plane tool aliases are returned exactly as configured. Resource templates apply the same single-versus-multi rule to both names and URI templates. | | Ordering | Merged output is sorted by name. | | Failures | Failed or unavailable backends are logged and omitted from the merged result. | | Pagination | One backend call per request and no downstream cursor; see [Known Gaps](mcp-routing-semantics.md#known-gaps). | ## Routed Targeted Methods -`call_tool`, `read_resource`, `get_prompt`, and `complete` share the prefix -splitter and resolve exactly one backend: +Targeted calls resolve exactly one backend using an explicit tool alias, a +single-backend pass-through, or a multi-backend prefix: | Method | Behavior | | --- | --- | -| `call_tool` | Splits `{backend_name}-{tool_name}`, optionally runs the plugin pre hook, forwards the stripped tool name, tracks the downstream progress token, and optionally runs the plugin post hook on the result. Backend progress notifications for the tracked token are forwarded downstream, and a downstream cancellation is propagated to the backend call. | -| `read_resource` | Splits the prefixed resource name, strips the gateway prefix, and returns the single backend's result. | -| `get_prompt` | Splits the prefixed prompt name, strips the gateway prefix, and returns the single backend's result. | -| `complete` | Routes on the backend-prefixed prompt name or resource URI in `ref`, strips that prefix, and returns the selected backend's completion result. | +| `call_tool` | Resolves an exact control-plane alias first, then falls back to the single-versus-multi rule. It runs the optional plugin hooks, tracks progress, forwards the backend-local tool name, and propagates downstream cancellation. | +| `read_resource` | Preserves a single-backend URI or strips a multi-backend prefix, then returns the selected backend's result. | +| `subscribe`, `unsubscribe` | Apply the same resource-URI routing, track the downstream subscription, and forward or stop forwarding matching resource-update notifications. | +| `get_prompt` | Preserves a single-backend prompt name or strips a multi-backend prefix, then returns the selected backend's result. | +| `complete` | Applies the same routing to the prompt name or resource URI in `ref` and returns the selected backend's completion result. | -Routed failures are JSON-RPC errors: a malformed prefixed name or an -unavailable backend returns an internal error, and duplicate backend matches -invalidate the session; see [Failure Modes](failure-modes.md). +Routed failures are JSON-RPC errors: an identifier that cannot select a backend +or an unavailable backend returns an internal error, and duplicate backend +matches invalidate the session; see [Failure Modes](failure-modes.md). ## Local Methods -These methods pass through the same HTTP middleware but do not touch backends: +This method passes through the same HTTP middleware but does not touch backends: | Method | Current behavior | | --- | --- | | `ping` | Returns success. | -| `subscribe`, `unsubscribe` | Mutate a local subscription set only. | ## Session Delete diff --git a/docs/book/src/mcp-routing-semantics.md b/docs/book/src/mcp-routing-semantics.md index 1495c6c..5b2923b 100644 --- a/docs/book/src/mcp-routing-semantics.md +++ b/docs/book/src/mcp-routing-semantics.md @@ -1,12 +1,15 @@ # MCP Routing Semantics The gateway presents multiple backend MCP servers as one downstream MCP server. -It does that by namespacing backend objects, fanning out list operations, and -routing exact calls back to the selected backend. +It fans out list operations, preserves identifiers when only one backend is +configured, and adds a backend namespace when multiple backends need to be +disambiguated. Explicit tool aliases published by the control plane take +precedence over both forms. ## Backend Prefixes -Backend names are part of the public namespace: +Backend map keys are part of the public namespace only for multi-backend +virtual hosts without an explicit tool alias: ```text backend tool "increment" from backend "gateway-one" @@ -19,8 +22,20 @@ backend prompt "summarize" from backend "research" -> "research-summarize" ``` -The prefix is a routing contract, not a display detail. Renaming a backend -changes downstream tool, resource, and prompt names. +For a single-backend virtual host, those identifiers remain `increment`, +`counter`, and `summarize`. Resource URIs and resource-template URI templates +follow the same rule. This keeps a transparent gateway from changing the MCP +contract when no collision is possible. + +For tools, `BackendMCPGateway.tool_name_aliases` maps an exact downstream alias +to the upstream original name. An alias is advertised and routed exactly as +published, including case, dots, and underscores. When no alias exists, +single-backend hosts preserve the original tool name and multi-backend hosts +fall back to `{backend-map-key}-{tool-name}`. + +When a prefix is required, it is a routing contract rather than a display +detail. Changing a backend map key changes downstream identifiers for that +multi-backend virtual host. ## List Operations @@ -33,14 +48,18 @@ list_prompts -> all connected backends -> merged sorted prompts list_resource_templates -> all connected backends -> merged sorted templates ``` -Each successful backend result is rewritten with its backend prefix before the -merged response is returned. For resource templates, both the template name and -the URI template are prefixed with the backend name. Failed or unavailable backends are logged and -omitted from the current merged list result. +For a single backend, each successful result is returned with its identifier +unchanged, except for explicit tool aliases. For multiple backends, resources, +prompts, and tools without aliases are rewritten with their backend map-key +prefix. Resource-template names and URI templates are both prefixed. Failed or +unavailable backends are logged and omitted from the current merged list +result. ## Routed Operations -Calls that target one object split the prefixed name: +Calls that target one object use the inverse rule. A single-backend host selects +its only backend and forwards the identifier unchanged. A multi-backend host +splits the prefixed identifier: ```text gateway-one-increment @@ -48,18 +67,24 @@ gateway-one-increment -> upstream tool name = increment ``` -`call_tool`, `read_resource`, `get_prompt`, and `complete` all share the same -splitter. For `complete`, the routed value is the prompt name or resource URI -inside its `ref`. Backend names can themselves contain `-` (as in -`gateway-one`), so the splitter does not cut on the first `-`. Instead it walks -the configured backend names, takes the first one the prefixed name starts +`read_resource`, `subscribe`, `unsubscribe`, `get_prompt`, and `complete` share +this conditional routing helper. For `complete`, the routed value is the prompt +name or resource URI inside its `ref`. Resource-update notifications apply the +same rule on the response path, so their URI matches the URI originally exposed +to the downstream client. + +`call_tool` first resolves an exact control-plane alias, then applies the same +single-versus-multi-backend fallback. Backend names can themselves contain `-` +(as in `gateway-one`), so the splitter does not cut on the first `-`. Instead it +walks the configured backend names, takes the first one the prefixed name starts with, and then requires a `-` immediately after that name. That is why `gateway-one-increment` resolves to backend `gateway-one` and tool `increment`, while a malformed name such as `gateway-oneincrement` is rejected. -After the split, the gateway resolves exactly one connected backend service for -the principal and downstream session. Missing backends fail the call. Duplicate -matches are treated as invalid session state and trigger backend cleanup. +After selecting a route, the gateway resolves exactly one connected backend +service for the principal and downstream session. Missing backends fail the +call. Duplicate matches are treated as invalid session state and trigger +backend cleanup. ## Known Gaps diff --git a/docs/book/src/request-flow.md b/docs/book/src/request-flow.md index 1b142d4..21e40c2 100644 --- a/docs/book/src/request-flow.md +++ b/docs/book/src/request-flow.md @@ -156,10 +156,10 @@ Current routed method families: | Method family | Flow | | --- | --- | -| `list_tools`, `list_resources`, `list_prompts`, `list_resource_templates` | Borrow all configured backend services, call every available backend concurrently with `fan_out_list`, namespace results with the backend name, sort merged output, and return one list. | -| `call_tool` | Split `{backend_name}-{tool_name}`, resolve one backend, optionally run `before_tool_call`, apply argument/name changes, track the downstream progress token, call the backend, optionally run `after_tool_call`, and return the backend result. | -| `read_resource`, `get_prompt` | Split the prefixed resource or prompt name, resolve one backend, strip the gateway prefix, call the backend, and return the backend result. | -| `complete` | Split the backend-prefixed prompt name or resource URI in `ref`, resolve one backend, strip the gateway prefix, and return the backend completion result. | +| `list_tools`, `list_resources`, `list_prompts`, `list_resource_templates` | Borrow all configured backend services, call every available backend concurrently with `fan_out_list`, preserve identifiers for one backend or namespace them for multiple backends, sort merged output, and return one list. Explicit tool aliases are preserved exactly. | +| `call_tool` | Resolve an exact tool alias first; otherwise preserve the name for one backend or split `{backend_name}-{tool_name}` for multiple backends. Resolve one backend, run the optional tool hooks, track progress, call the backend, and return the result. | +| `read_resource`, `subscribe`, `unsubscribe`, `get_prompt` | Select the only backend and forward the identifier unchanged, or split and strip the prefix for a multi-backend host, then call the resolved backend. | +| `complete` | Apply the same conditional routing to the prompt name or resource URI in `ref`, then return the selected backend's completion result. | `GatewayBackendClient` handles backend progress notifications for `call_tool`. If a progress token matches an in-flight downstream tool call, it optionally @@ -178,17 +178,15 @@ backend-routed: | Method | Current behavior | | --- | --- | | `ping` | Returns success. | -| `subscribe`, `unsubscribe` | Mutate the local subscription set. | -These paths still pass through the same HTTP middleware, but they do not use the -backend fanout or prefixed routing path today. +This path still passes through the same HTTP middleware, but it does not use +backend fanout or identifier routing. ## Response Path Backend responses return to `McpService` first. `call_tool` may run response -plugin hooks before returning. List calls merge and namespace backend output -before returning. Single-backend calls return the selected backend result after -gateway prefix removal. +plugin hooks before returning. List calls merge backend output, preserving +single-backend identifiers and namespacing multi-backend identifiers as needed. The HTTP response then unwinds through `virtual_host_config_layer`, `user_config_store_layer`, `session_id_layer`, `claims_layer`, diff --git a/docs/book/src/runtime-configuration.md b/docs/book/src/runtime-configuration.md index 5f32b4d..80279c5 100644 --- a/docs/book/src/runtime-configuration.md +++ b/docs/book/src/runtime-configuration.md @@ -62,12 +62,13 @@ but the distinction should stay explicit: | Config field | Current MCP dataplane behavior | | --- | --- | | `UserConfig.virtual_hosts` | Required. `VirtualHostId` from the path selects one entry. | -| `VirtualHost.backends` map key | Required. This key is the public backend namespace used in tool/resource/prompt prefixes. | +| `VirtualHost.backends` map key | Required. Selects backend session state and becomes the public prefix when a multi-backend identifier has no explicit alias. | | `BackendMCPGateway.url` | Required. Used to build the upstream `StreamableHttpClientTransport`. | | `BackendMCPGateway.name` | Present in the model. Current routing uses the backend map key, not this field, as the namespace. | | `transport` | Present in the model. Current upstream code always builds a streamable HTTP client transport. | | `passthrough_headers` | Present in the model. Current MCP routing does not apply header pass-through policy from this field. | | `allowed_tool_names` | Present in the model. Current list/call routing does not enforce it. | +| `tool_name_aliases` | Optional exact `{downstream_alias: upstream_original}` mapping used by tool list/call routing before the single-versus-multi-backend fallback. | | `allowed_resource_names` | Present in the model. Current resource routing does not enforce it. | | `allowed_prompt_names` | Present in the model. Current prompt routing does not enforce it. | diff --git a/docs/book/src/session-ownership.md b/docs/book/src/session-ownership.md index 8c60002..e33d095 100644 --- a/docs/book/src/session-ownership.md +++ b/docs/book/src/session-ownership.md @@ -73,8 +73,8 @@ Backend service lookup splits into two patterns: | MCP call shape | Session behavior | | --- | --- | -| `list_tools`, `list_resources`, `list_prompts`, `list_resource_templates` | Borrow every available backend service for the selected virtual host, call them concurrently, then merge and namespace successful responses. | -| `call_tool`, `read_resource`, `get_prompt` | Split the prefixed name into `{backend_name}-{object_name}`, resolve the single backend service, strip the gateway prefix, and call that backend. | +| `list_tools`, `list_resources`, `list_prompts`, `list_resource_templates` | Borrow every available backend service for the selected virtual host, call them concurrently, then merge successful responses with aliases or conditional namespacing. | +| `call_tool`, `read_resource`, `subscribe`, `unsubscribe`, `get_prompt`, `complete` | Resolve an explicit tool alias, select the only backend without rewriting, or split a multi-backend prefix; then call the single resolved backend service. | An initialized backend entry can still contain no running service if backend initialize failed. List calls skip unavailable backends. Routed calls to that diff --git a/docs/book/src/what-is-contextforge-gateway.md b/docs/book/src/what-is-contextforge-gateway.md index bc10540..e3bc8c5 100644 --- a/docs/book/src/what-is-contextforge-gateway.md +++ b/docs/book/src/what-is-contextforge-gateway.md @@ -73,7 +73,7 @@ These words appear on almost every page. It is worth pinning them down once. | Downstream | The client side of the gateway: the calling MCP client and its requests. | | Upstream | The backend side of the gateway: the configured backend MCP servers. | | Virtual host | A named routing group inside the caller's config. The URL path selects exactly one. | -| Backend | One configured MCP server behind the gateway. Its name prefixes every tool, resource, and prompt it exposes. | +| Backend | One configured MCP server behind the gateway. Its map key becomes a routing prefix when a virtual host has multiple backends. | | Principal | The authenticated caller identity, taken from the JWT `sub` claim. | | Session | An initialized MCP session, tracked by `Mcp-session-id`, that owns the per-caller backend client sessions. | @@ -85,8 +85,8 @@ set of configured backend MCP servers. On `initialize`, the gateway validates the caller, resolves the requested virtual host, and creates upstream MCP client sessions for that virtual host's backends. On list calls, it fans out to those backends and merges the result. On -targeted calls, it uses the backend prefix in the public tool, resource, or -prompt name to route to exactly one backend. +targeted calls, it uses an explicit tool alias, the only configured backend, or +a multi-backend prefix to route to exactly one backend. For a stateful MCP call to work, five facts must line up: @@ -112,9 +112,9 @@ not become the admin UI, tenant management API, policy authoring system, credential store, or durable observability backend. It also should not expose backend topology as more than the MCP routing -contract requires. Backend names are visible in prefixed tool, resource, and -prompt names, but clients should still experience one gateway endpoint and one -logical MCP server. +contract requires. Backend map keys are visible when multi-backend identifiers +need prefixes, but clients should still experience one gateway endpoint and +one logical MCP server. ## Where to go next