diff --git a/crates/aisix-mcp/src/gateway.rs b/crates/aisix-mcp/src/gateway.rs index ff1f5b3a..d9034f9e 100644 --- a/crates/aisix-mcp/src/gateway.rs +++ b/crates/aisix-mcp/src/gateway.rs @@ -14,6 +14,11 @@ //! - `tools/call` strips the namespace prefix and routes to the owning //! upstream. //! +//! A gateway may instead be **scoped** to a single upstream +//! ([`McpGateway::from_snapshot_scoped`], mounted at `/mcp/{server}`): it then +//! serves that server's tools under their original, un-namespaced names while +//! ACL decisions keep evaluating the namespaced form. +//! //! The aggregator holds no per-request or per-session state, so governance //! never depends on a transport session — which keeps it aligned with the //! stateless direction of the MCP 2026-07-28 revision. @@ -54,6 +59,17 @@ struct NamedUpstream { bridge: Arc, } +/// Strip `server`'s namespace prefix from `name`: `Some(bare)` when `name` +/// is `__`, `None` otherwise. The one primitive both the +/// scoped gateway and the proxy's attribution peek use to interpret a tool +/// name on `/mcp/{server}`, so the two can never drift. Prefix matching is +/// by whole-string prefix (not first-separator split), so a server name +/// that itself ends in `_` still namespaces cleanly. +pub fn strip_server_prefix<'a>(server: &str, name: &'a str) -> Option<&'a str> { + name.strip_prefix(server) + .and_then(|rest| rest.strip_prefix(TOOL_NAMESPACE_SEPARATOR)) +} + /// Which tools a gateway instance may expose and call, in the namespaced /// `__` form. Built per request from the caller's API key and /// the environment's / the key's team's MCP access policies, so MCP tool @@ -229,6 +245,26 @@ impl ToolAcl { pub struct McpGateway { upstreams: Arc<[NamedUpstream]>, tool_acl: ToolAcl, + /// When set, this gateway serves exactly one upstream under its **original** + /// tool names: `tools/list` strips the `__` namespace prefix and + /// `tools/call` accepts both the bare and the prefixed form. ACL decisions + /// still evaluate the namespaced form, so per-tool grants keep one meaning + /// across the aggregated and the scoped endpoint. + scoped: Option>, +} + +/// The single-server scope of a gateway built by +/// [`McpGateway::from_snapshot_scoped`]. +struct ScopedServer { + /// The scoped upstream's registered name — the namespace every ACL check + /// re-applies and the name `initialize` reports. + name: String, + /// Every **other** registered server name, enabled or not. A `tools/call` + /// whose name is prefixed with one of these is a cross-server mistake and + /// fails closed rather than being silently served as a bare name. + /// Disabled servers stay reserved so this scope's callable name surface + /// does not shift when another server's `enabled` flag is toggled. + foreign: std::collections::HashSet, } impl McpGateway { @@ -265,6 +301,7 @@ impl McpGateway { Self { upstreams: deduped.into(), tool_acl: ToolAcl::allow_all(), + scoped: None, } } @@ -303,6 +340,36 @@ impl McpGateway { McpGateway::new(upstreams) } + /// Build a gateway scoped to the single **enabled** `mcp_servers` entry + /// named `server`, serving its tools under their original names (see + /// [`McpGateway::scoped`]). Returns `None` when the server is not + /// registered or is disabled — a disabled server is treated as absent, + /// same as the aggregated endpoint skipping it. + pub fn from_snapshot_scoped(snapshot: &AisixSnapshot, server: &str) -> Option { + let entry = snapshot.mcp_servers.get_by_name(server)?; + if !entry.value.enabled { + return None; + } + let name = entry.value.name.clone(); + let bridge: Arc = match entry.value.server_type { + McpServerType::Mcp => { + let upstream = upstream_from_mcp_server(&entry.value); + Arc::new(EphemeralBridge::new(upstream)) + } + McpServerType::Openapi => Arc::new(OpenApiBridge::new(entry)), + }; + let foreign = snapshot + .mcp_servers + .entries() + .into_iter() + .filter(|e| e.value.name != name) + .map(|e| e.value.name.clone()) + .collect(); + let mut gateway = McpGateway::new([(name.clone(), bridge)]); + gateway.scoped = Some(Arc::new(ScopedServer { name, foreign })); + Some(gateway) + } + fn find(&self, server: &str) -> Option<&Arc> { self.upstreams .iter() @@ -345,6 +412,28 @@ impl ServerHandler for McpGateway { } // Per-tool ACL: expose only the tools this caller's key permits. tools.retain(|tool| self.tool_acl.permits(tool.name.as_ref())); + // A scoped gateway serves its single upstream's tools under their + // original names — the namespace prefix exists to disambiguate the + // aggregate, and a single-server endpoint has nothing to disambiguate. + // ACL filtering above ran on the namespaced form. Strip only when the + // bare name round-trips through `call_tool`'s parsing: a literal + // upstream name that itself starts with a registered server's prefix + // would be re-stripped (or fail closed) if advertised bare, so those + // stay namespaced — that spelling is the one `call_tool` accepts. + if let Some(scoped) = &self.scoped { + for tool in &mut tools { + if let Some(bare) = strip_server_prefix(&scoped.name, tool.name.as_ref()) { + let round_trips = strip_server_prefix(&scoped.name, bare).is_none() + && !scoped + .foreign + .iter() + .any(|f| strip_server_prefix(f, bare).is_some()); + if round_trips { + tool.name = Cow::Owned(bare.to_string()); + } + } + } + } Ok(ListToolsResult::with_all_items(tools)) } @@ -353,26 +442,68 @@ impl ServerHandler for McpGateway { request: CallToolRequestParams, _context: RequestContext, ) -> Result { - let (server, tool) = request - .name - .split_once(TOOL_NAMESPACE_SEPARATOR) - .ok_or_else(|| { - ErrorData::invalid_params( - format!( - "tool name '{}' is missing a 'server__tool' prefix", - request.name - ), - None, + // Resolve `(server, tool, namespaced)` from the caller's tool name. + // Scoped: the name is the upstream's original one, but a caller that + // sends the namespaced form anyway (an aggregated-endpoint client + // pointed at the scoped URL) keeps working — `__x` reduces to + // `x`. (An upstream tool literally named `__x` is therefore + // only callable as `____x` — one strip, own prefix + // first; `tools/list` advertises exactly that spelling.) A name + // prefixed with a *different* registered server's name fails closed: + // the scoped endpoint never cross-routes, and silently serving it as + // a bare name would mask the caller's mistake. An unregistered + // prefix stays a bare name, since tool names may legitimately + // contain the separator. + let request_name = request.name.as_ref(); + let (server, tool, namespaced): (&str, &str, Cow<'_, str>) = match &self.scoped { + Some(scoped) => { + let scope = scoped.name.as_str(); + let bare = match strip_server_prefix(scope, request_name) { + Some(rest) => rest, + None if scoped + .foreign + .iter() + .any(|f| strip_server_prefix(f, request_name).is_some()) => + { + // Same neutral wording as the ACL reject: don't + // confirm what the other server serves. + return Err(ErrorData::invalid_params( + format!("tool '{request_name}' is not available"), + None, + )); + } + None => request_name, + }; + ( + scope, + bare, + Cow::Owned(format!("{scope}{TOOL_NAMESPACE_SEPARATOR}{bare}")), ) - })?; + } + None => { + let (server, tool) = request_name + .split_once(TOOL_NAMESPACE_SEPARATOR) + .ok_or_else(|| { + ErrorData::invalid_params( + format!( + "tool name '{request_name}' is missing a 'server__tool' prefix" + ), + None, + ) + })?; + (server, tool, Cow::Borrowed(request_name)) + } + }; // Per-tool ACL: reject a call the caller's key doesn't permit. A // disallowed tool is also absent from `tools/list`, so this is // defense-in-depth; the message stays neutral and does not reveal - // whether the tool exists upstream. - if !self.tool_acl.permits(request.name.as_ref()) { + // whether the tool exists upstream. The check runs on the namespaced + // form so grants mean the same thing on every endpoint; the message + // echoes the caller's own spelling. + if !self.tool_acl.permits(&namespaced) { return Err(ErrorData::invalid_params( - format!("tool '{}' is not available", request.name), + format!("tool '{request_name}' is not available"), None, )); } @@ -407,11 +538,25 @@ impl ServerHandler for McpGateway { fn get_info(&self) -> ServerInfo { let mut info = ServerInfo::default(); info.capabilities = ServerCapabilities::builder().enable_tools().build(); - info.instructions = Some( - "AISIX MCP gateway: aggregates tools from registered upstream MCP \ - servers, namespaced as `server__tool`." - .to_string(), - ); + match &self.scoped { + // The scoped endpoint presents as the upstream server itself, so + // `initialize` reports that server's registered name. + Some(scoped) => { + info.server_info.name = scoped.name.clone(); + info.instructions = Some(format!( + "AISIX MCP gateway: serves the tools of MCP server `{}` \ + under their original names.", + scoped.name + )); + } + None => { + info.instructions = Some( + "AISIX MCP gateway: aggregates tools from registered upstream MCP \ + servers, namespaced as `server__tool`." + .to_string(), + ); + } + } info } } diff --git a/crates/aisix-mcp/src/lib.rs b/crates/aisix-mcp/src/lib.rs index 09f15952..736a561d 100644 --- a/crates/aisix-mcp/src/lib.rs +++ b/crates/aisix-mcp/src/lib.rs @@ -24,5 +24,7 @@ pub use bridge::{ McpUpstream, OAuthClientConfig, RmcpBridge, }; pub use error::McpError; -pub use gateway::{streamable_http_service, McpGateway, ToolAcl, TOOL_NAMESPACE_SEPARATOR}; +pub use gateway::{ + streamable_http_service, strip_server_prefix, McpGateway, ToolAcl, TOOL_NAMESPACE_SEPARATOR, +}; pub use openapi::{validate_spec, OpenApiBridge}; diff --git a/crates/aisix-mcp/tests/gateway_scoped.rs b/crates/aisix-mcp/tests/gateway_scoped.rs new file mode 100644 index 00000000..1e432878 --- /dev/null +++ b/crates/aisix-mcp/tests/gateway_scoped.rs @@ -0,0 +1,380 @@ +//! End-to-end test of the **scoped** gateway (`/mcp/{server}`): AISIX as an +//! MCP server fronting exactly one registered upstream, serving its tools +//! under their original (un-namespaced) names. +//! +//! Topology, all real Streamable HTTP over ephemeral ports (no mock +//! transport): +//! +//! downstream rmcp client ──► McpGateway (scoped "alpha") ──► upstream "alpha" (echo) +//! +//! Pins: `initialize` reports the scoped server's name; `tools/list` strips +//! the `alpha__` prefix; `tools/call` accepts both the bare and the +//! namespaced spelling; a foreign prefix stays pinned to the scoped upstream; +//! ACL patterns keep their namespaced meaning; unknown/disabled servers +//! resolve to no gateway at all. + +use std::net::SocketAddr; +use std::sync::Arc; + +use aisix_core::{AisixSnapshot, McpServer, ResourceEntry}; +use aisix_mcp::{streamable_http_service, McpGateway, ToolAcl}; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Content, ErrorData, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, +}; +use rmcp::service::RequestContext; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; +use rmcp::transport::StreamableHttpClientTransport; +use rmcp::{RoleServer, ServerHandler, ServiceExt}; + +/// A real upstream MCP server exposing one echo tool under `tool_name`, +/// prefixing its reply with `label` so routing is observable. +#[derive(Clone)] +struct LabeledEcho { + label: &'static str, + tool_name: &'static str, +} + +impl ServerHandler for LabeledEcho { + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let schema = serde_json::json!({ + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"], + }); + let tool = Tool::new( + self.tool_name, + "Echo back the provided text", + schema.as_object().expect("schema is an object").clone(), + ); + Ok(ListToolsResult::with_all_items(vec![tool])) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + if request.name != self.tool_name { + return Err(ErrorData::invalid_params( + format!("unknown tool: {}", request.name), + None, + )); + } + let text = request + .arguments + .as_ref() + .and_then(|m| m.get("text")) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + Ok(CallToolResult::success(vec![Content::text(format!( + "{}:{text}", + self.label + ))])) + } + + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::default(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info + } +} + +/// Start a labeled upstream echo server; return its bound address. +async fn spawn_upstream(label: &'static str, tool_name: &'static str) -> SocketAddr { + let service = StreamableHttpService::new( + move || Ok(LabeledEcho { label, tool_name }), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + serve(axum::Router::new().nest_service("/mcp", service)).await +} + +/// Serve the gateway itself; return its bound address. +async fn spawn_gateway(gateway: McpGateway) -> SocketAddr { + serve(axum::Router::new().nest_service("/mcp", streamable_http_service(gateway))).await +} + +async fn serve(app: axum::Router) -> SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + addr +} + +/// Build a snapshot resource entry for an upstream at `addr`. +fn mcp_entry(id: &str, name: &str, addr: &SocketAddr, enabled: bool) -> ResourceEntry { + let server: McpServer = serde_json::from_value(serde_json::json!({ + "display_name": name, + "url": format!("http://{addr}/mcp"), + "enabled": enabled + })) + .unwrap(); + ResourceEntry::new(id, server, 1) +} + +/// A snapshot with one enabled server `alpha` at `addr` and one disabled +/// server `dark`. +fn snapshot_with_alpha(addr: &SocketAddr) -> AisixSnapshot { + let snapshot = AisixSnapshot::new(); + snapshot + .mcp_servers + .insert(mcp_entry("e1", "alpha", addr, true)); + snapshot + .mcp_servers + .insert(mcp_entry("e2", "dark", addr, false)); + snapshot +} + +async fn connect(gw_addr: SocketAddr) -> rmcp::service::RunningService { + ().serve(StreamableHttpClientTransport::from_uri(format!( + "http://{gw_addr}/mcp" + ))) + .await + .expect("downstream client connects to gateway") +} + +fn call(name: &'static str, text: &str) -> CallToolRequestParams { + let args = serde_json::json!({ "text": text }); + CallToolRequestParams::new(name).with_arguments(args.as_object().unwrap().clone()) +} + +/// Decode the first text content block of a tool result. +fn first_text(result: &CallToolResult) -> String { + let value = serde_json::to_value(&result.content).expect("encode content"); + value[0]["text"].as_str().unwrap_or_default().to_string() +} + +#[tokio::test] +async fn scoped_serves_original_names_and_accepts_both_call_forms() { + let upstream = spawn_upstream("alpha", "echo").await; + let snapshot = snapshot_with_alpha(&upstream); + let gateway = + McpGateway::from_snapshot_scoped(&snapshot, "alpha").expect("alpha is registered"); + let gw_addr = spawn_gateway(gateway).await; + let client = connect(gw_addr).await; + + // `initialize` presents the scoped server, not the aggregate. + let info = client.peer_info().expect("initialize completed"); + assert_eq!(info.server_info.name, "alpha"); + + // tools/list carries the upstream's original names — no `alpha__` prefix. + let tools = client.list_all_tools().await.expect("list tools"); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!(names, vec!["echo"], "original name only: {names:?}"); + + // The bare (original) spelling calls through… + let bare = client + .call_tool(call("echo", "hi")) + .await + .expect("bare call"); + assert_eq!(first_text(&bare), "alpha:hi"); + + // …and the namespaced spelling still works, so an aggregated-endpoint + // client pointed at the scoped URL keeps functioning. + let prefixed = client + .call_tool(call("alpha__echo", "hi")) + .await + .expect("namespaced call"); + assert_eq!(first_text(&prefixed), "alpha:hi"); +} + +#[tokio::test] +async fn scoped_missing_or_disabled_server_resolves_to_none() { + let upstream = spawn_upstream("alpha", "echo").await; + let snapshot = snapshot_with_alpha(&upstream); + + assert!( + McpGateway::from_snapshot_scoped(&snapshot, "ghost").is_none(), + "unregistered server must not resolve" + ); + assert!( + McpGateway::from_snapshot_scoped(&snapshot, "dark").is_none(), + "disabled server must resolve like a missing one" + ); +} + +#[tokio::test] +async fn scoped_acl_keeps_namespaced_meaning() { + let upstream = spawn_upstream("alpha", "echo").await; + let snapshot = snapshot_with_alpha(&upstream); + + // A grant written against the aggregated form covers the scoped endpoint. + let gateway = McpGateway::from_snapshot_scoped(&snapshot, "alpha") + .expect("alpha is registered") + .with_tool_acl(ToolAcl::from_allowed(Some(&["alpha__echo".to_string()]))); + let client = connect(spawn_gateway(gateway).await).await; + let tools = client.list_all_tools().await.expect("list tools"); + assert_eq!(tools.len(), 1, "granted tool listed (bare)"); + assert_eq!(tools[0].name.as_ref(), "echo"); + let ok = client.call_tool(call("echo", "hi")).await.expect("allowed"); + assert_eq!(first_text(&ok), "alpha:hi"); + + // A grant for a different server admits nothing here — the bare name is + // re-namespaced before the check, so the scoped surface cannot widen a + // key's grant. + let gateway = McpGateway::from_snapshot_scoped(&snapshot, "alpha") + .expect("alpha is registered") + .with_tool_acl(ToolAcl::from_allowed(Some(&["beta__*".to_string()]))); + let client = connect(spawn_gateway(gateway).await).await; + let tools = client.list_all_tools().await.expect("list tools"); + assert!(tools.is_empty(), "foreign grant must expose nothing"); + assert!( + client.call_tool(call("echo", "hi")).await.is_err(), + "foreign grant must not admit a bare-name call" + ); +} + +#[tokio::test] +async fn scoped_registered_foreign_prefix_fails_closed() { + // The upstream serves a tool literally named `beta__echo`, and `beta` IS + // a registered, enabled server. On alpha's scoped gateway that spelling + // must fail closed — a cross-server mistake, never silently served as a + // bare name (which this upstream would happily answer). + let upstream = spawn_upstream("alpha", "beta__echo").await; + let snapshot = snapshot_with_alpha(&upstream); + snapshot + .mcp_servers + .insert(mcp_entry("e3", "beta", &upstream, true)); + let gateway = + McpGateway::from_snapshot_scoped(&snapshot, "alpha").expect("alpha is registered"); + let client = connect(spawn_gateway(gateway).await).await; + + // tools/list keeps the colliding literal name namespaced: advertising + // the bare `beta__echo` would advertise a spelling `tools/call` rejects. + let tools = client.list_all_tools().await.expect("list tools"); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!(names, vec!["alpha__beta__echo"], "list must round-trip"); + + assert!( + client.call_tool(call("beta__echo", "x")).await.is_err(), + "a registered foreign prefix must fail closed, never route or serve" + ); + + // The advertised spelling reaches the literal tool: `alpha__beta__echo` + // strips alpha's own prefix first. + let escaped = client + .call_tool(call("alpha__beta__echo", "hi")) + .await + .expect("the namespaced spelling reaches the literal tool"); + assert_eq!(first_text(&escaped), "alpha:hi"); +} + +#[tokio::test] +async fn scoped_disabled_foreign_prefix_still_fails_closed() { + // `dark` is registered but disabled. Its name stays reserved: a scope's + // callable name surface must not shift when another server's `enabled` + // flag is toggled, so `dark__echo` fails closed on alpha even though the + // upstream would serve that literal name. + let upstream = spawn_upstream("alpha", "dark__echo").await; + let snapshot = snapshot_with_alpha(&upstream); + let gateway = + McpGateway::from_snapshot_scoped(&snapshot, "alpha").expect("alpha is registered"); + let client = connect(spawn_gateway(gateway).await).await; + + assert!( + client.call_tool(call("dark__echo", "x")).await.is_err(), + "a disabled registered server's prefix must still fail closed" + ); + // The literal tool stays reachable through the advertised namespaced + // spelling, same as the enabled-foreign case. + let tools = client.list_all_tools().await.expect("list tools"); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!(names, vec!["alpha__dark__echo"]); + let escaped = client + .call_tool(call("alpha__dark__echo", "hi")) + .await + .expect("the namespaced spelling reaches the literal tool"); + assert_eq!(first_text(&escaped), "alpha:hi"); +} + +#[tokio::test] +async fn scoped_server_name_ending_in_underscore_namespaces_cleanly() { + // `data_` is a legal server name (only `__` inside a name is rejected). + // Prefix parsing is whole-string based, not first-separator based, so + // the namespaced spelling `data___query` (= `data_` + `__` + `query`) + // resolves to `query` even while a server named `data` also exists. + let upstream = spawn_upstream("data_", "query").await; + let snapshot = AisixSnapshot::new(); + snapshot + .mcp_servers + .insert(mcp_entry("e1", "data_", &upstream, true)); + snapshot + .mcp_servers + .insert(mcp_entry("e2", "data", &upstream, true)); + let gateway = + McpGateway::from_snapshot_scoped(&snapshot, "data_").expect("data_ is registered"); + let client = connect(spawn_gateway(gateway).await).await; + + let tools = client.list_all_tools().await.expect("list tools"); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!(names, vec!["query"]); + + let bare = client.call_tool(call("query", "hi")).await.expect("bare"); + assert_eq!(first_text(&bare), "data_:hi"); + let namespaced = client + .call_tool(call("data___query", "hi")) + .await + .expect("namespaced spelling of a trailing-underscore server"); + assert_eq!(first_text(&namespaced), "data_:hi"); +} + +#[tokio::test] +async fn scoped_unregistered_prefix_is_a_bare_name() { + // `ghost` is not a registered server, so `ghost__echo` is just a tool + // name that happens to contain the separator — it must reach the scoped + // upstream verbatim (which serves exactly that name here). + let upstream = spawn_upstream("alpha", "ghost__echo").await; + let snapshot = snapshot_with_alpha(&upstream); + let gateway = + McpGateway::from_snapshot_scoped(&snapshot, "alpha").expect("alpha is registered"); + let client = connect(spawn_gateway(gateway).await).await; + + let served = client + .call_tool(call("ghost__echo", "hi")) + .await + .expect("an unregistered prefix stays a bare tool name"); + assert_eq!(first_text(&served), "alpha:hi"); +} + +#[tokio::test] +async fn scoped_upstream_tool_spelled_like_the_prefix_keeps_precedence() { + // Pathological upstream: a tool literally named `alpha__echo` on server + // `alpha`. Prefix-stripping takes precedence (documented in `call_tool`), + // so the bare spelling would not round-trip — tools/list therefore keeps + // the namespaced `alpha__alpha__echo`, which IS the callable spelling; + // `alpha__echo` reduces to `echo`, which does not exist. + let upstream = spawn_upstream("alpha", "alpha__echo").await; + let snapshot = snapshot_with_alpha(&upstream); + let gateway = + McpGateway::from_snapshot_scoped(&snapshot, "alpha").expect("alpha is registered"); + let client = connect(spawn_gateway(gateway).await).await; + + let tools = client.list_all_tools().await.expect("list tools"); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!( + names, + vec!["alpha__alpha__echo"], + "a non-round-tripping name stays namespaced: {names:?}" + ); + + let advertised = client + .call_tool(call("alpha__alpha__echo", "hi")) + .await + .expect("the advertised spelling reaches the literal tool"); + assert_eq!(first_text(&advertised), "alpha:hi"); + + assert!( + client.call_tool(call("alpha__echo", "x")).await.is_err(), + "the single-prefixed spelling reduces to `echo`, which must error" + ); +} diff --git a/crates/aisix-proxy/src/lib.rs b/crates/aisix-proxy/src/lib.rs index d9b32898..c683c9e9 100644 --- a/crates/aisix-proxy/src/lib.rs +++ b/crates/aisix-proxy/src/lib.rs @@ -160,8 +160,11 @@ pub fn build_router(state: ProxyState) -> Router { ) // Downstream-facing MCP gateway. Authentication (AISIX API key) is // enforced inside the handler via the `AuthenticatedKey` extractor. + // `/mcp/{server}` is the single-server variant (original tool names); + // the static `/mcp/` route wins over the param route for that path. .route("/mcp", any(mcp::mcp_endpoint)) .route("/mcp/", any(mcp::mcp_endpoint)) + .route("/mcp/:server", any(mcp::mcp_scoped_endpoint)) // Downstream-facing A2A gateway. One route per registered agent; the // agent's card (with the service URL rewritten to the gateway) is served // at the RFC 8615 well-known path under it. Authentication (AISIX API @@ -275,6 +278,7 @@ fn normalize_endpoint_label(path: &str) -> &'static str { _ if path.starts_with("/v1/files/") => "/v1/files/:id", _ if path.starts_with("/v1/batches/") => "/v1/batches/:id", _ if path.starts_with("/v1/fine_tuning/jobs/") => "/v1/fine_tuning/jobs/:id", + _ if path.starts_with("/mcp/") => "/mcp/{server}", _ if path.starts_with("/a2a/") => "/a2a", _ if path.starts_with("/passthrough/") => "/passthrough/:provider/*rest", _ => "other", @@ -284,7 +288,7 @@ fn normalize_endpoint_label(path: &str) -> &'static str { fn inbound_protocol_for_endpoint(endpoint: &str) -> &'static str { if endpoint == "/v1/messages" || endpoint == "/v1/messages/count_tokens" { "anthropic" - } else if endpoint == "/mcp" { + } else if endpoint == "/mcp" || endpoint == "/mcp/{server}" { "mcp" } else if endpoint == "/a2a" { "a2a" @@ -618,6 +622,14 @@ mod tests { // Arbitrary unauthenticated paths bucket to a single label. assert_eq!(normalize_endpoint_label("/random/x"), "other"); assert_eq!(normalize_endpoint_label("/random/y"), "other"); + // The scoped MCP endpoint collapses to one label regardless of the + // server segment; the aggregated endpoint (with and without the + // trailing slash) keeps its own — the exact `"/mcp/"` arm must stay + // ahead of the `/mcp/` prefix arm. + assert_eq!(normalize_endpoint_label("/mcp/alpha"), "/mcp/{server}"); + assert_eq!(normalize_endpoint_label("/mcp/unique-xyz"), "/mcp/{server}"); + assert_eq!(normalize_endpoint_label("/mcp"), "/mcp"); + assert_eq!(normalize_endpoint_label("/mcp/"), "/mcp"); } use aisix_core::resource::ResourceEntry; diff --git a/crates/aisix-proxy/src/mcp.rs b/crates/aisix-proxy/src/mcp.rs index ee2452d1..46011736 100644 --- a/crates/aisix-proxy/src/mcp.rs +++ b/crates/aisix-proxy/src/mcp.rs @@ -1,8 +1,9 @@ -//! `/mcp` — the downstream-facing MCP gateway endpoint. +//! `/mcp` and `/mcp/{server}` — the downstream-facing MCP gateway endpoints. //! //! AISIX presents as a single MCP server to a downstream agent: it aggregates //! the tools of the registered `mcp_servers` and routes tool calls back to -//! them. The caller authenticates with an AISIX API key — the +//! them. `/mcp/{server}` scopes the same gateway to one registered server, +//! serving its tools under their original (un-namespaced) names. The caller authenticates with an AISIX API key — the //! [`AuthenticatedKey`] extractor rejects a missing or invalid key with `401` //! before the request reaches the gateway. The gateway is rebuilt from the //! current configuration snapshot on each request, so it always reflects the @@ -63,6 +64,32 @@ pub async fn mcp_endpoint( auth: AuthenticatedKey, State(state): State, request: Request, +) -> Response { + serve(auth, state, request, None).await +} + +/// Serve a `/mcp/{server}` request: the single-server variant of +/// [`mcp_endpoint`]. The path names a registered MCP server; the gateway is +/// scoped to it and serves its tools under their original names (`tools/call` +/// also accepts the namespaced form). Everything else — auth, per-tool ACL, +/// quota, guardrails, usage — is the same pipeline as the aggregated endpoint; +/// only the server selection and the tool-name surface differ. An unknown or +/// disabled server is `404` (after auth, so an unauthenticated caller learns +/// nothing about which servers exist). +pub async fn mcp_scoped_endpoint( + auth: AuthenticatedKey, + axum::extract::Path(server): axum::extract::Path, + State(state): State, + request: Request, +) -> Response { + serve(auth, state, request, Some(server)).await +} + +async fn serve( + auth: AuthenticatedKey, + state: ProxyState, + request: Request, + scope: Option, ) -> Response { // #698: /mcp emits the same access log + request metrics as every other // handler — pre-fix the endpoint was invisible in both. One wrapper @@ -77,13 +104,19 @@ pub async fn mcp_endpoint( let api_key_id = auth.entry.id.clone(); let method = request.method().clone(); - let response = dispatch(auth, &state, request, &request_id).await; + let response = dispatch(auth, scope.as_deref(), &state, request, &request_id).await; let elapsed = started.elapsed(); let status = response.status().as_u16(); AccessLog { method: method.as_str(), - path: "/mcp", + // Bounded route template, mirroring `/a2a` (the per-request server is + // on the usage event, not the access log). + path: if scope.is_some() { + "/mcp/{server}" + } else { + "/mcp" + }, status, latency: elapsed, provider: Some("mcp"), @@ -115,10 +148,33 @@ pub async fn mcp_endpoint( async fn dispatch( auth: AuthenticatedKey, + scope: Option<&str>, state: &ProxyState, request: Request, request_id: &str, ) -> Response { + // One snapshot for the whole request: the scoped-server resolution below + // and the gateway construction further down must see the same resource + // set. + let snapshot = state.snapshot.load(); + + // Scoped endpoint: resolve the path's server before doing any work. A + // disabled server is treated as absent — not served, same as the + // aggregated endpoint skipping it (and same as `/a2a/:agent`). + if let Some(server) = scope { + let known = snapshot + .mcp_servers + .get_by_name(server) + .is_some_and(|entry| entry.value.enabled); + if !known { + return ( + StatusCode::NOT_FOUND, + format!("unknown MCP server: {server}"), + ) + .into_response(); + } + } + // Buffer the body so the JSON-RPC method can be inspected, then rebuilt for // the gateway. The global body-limit layer has already capped the size. let (parts, body) = request.into_parts(); @@ -143,15 +199,29 @@ async fn dispatch( let peek = serde_json::from_slice::(&bytes).ok(); let is_tool_call = peek.as_ref().and_then(|p| p.method.as_deref()) == Some("tools/call"); - // Split the namespaced tool name into (server, tool) up front, owned, so it - // survives the body being consumed when the request is rebuilt. + // Resolve the called (server, tool) up front, owned, so it survives the + // body being consumed when the request is rebuilt. Aggregated: split the + // namespaced name. Scoped: the server comes from the path and the name is + // the upstream's original one — stripped through the same primitive the + // gateway parses with (`strip_server_prefix`), so quota and usage + // attribute the same tool for both spellings and can never drift from + // what actually dispatches. let (mcp_server, mcp_tool) = if is_tool_call { - peek.as_ref() + let name = peek + .as_ref() .and_then(|p| p.params.as_ref()) .and_then(|p| p.name.as_deref()) - .and_then(|name| name.split_once(aisix_mcp::TOOL_NAMESPACE_SEPARATOR)) - .map(|(server, tool)| (server.to_string(), tool.to_string())) - .unwrap_or_default() + .unwrap_or_default(); + match scope { + Some(server) => { + let bare = aisix_mcp::strip_server_prefix(server, name).unwrap_or(name); + (server.to_string(), bare.to_string()) + } + None => name + .split_once(aisix_mcp::TOOL_NAMESPACE_SEPARATOR) + .map(|(server, tool)| (server.to_string(), tool.to_string())) + .unwrap_or_default(), + } } else { (String::new(), String::new()) }; @@ -243,12 +313,25 @@ async fn dispatch( } } - let snapshot = state.snapshot.load(); // Scope the gateway to the tools this caller's key permits — resolved // from the key together with the environment/team MCP access policies — // so MCP tool access is governed by the same key object as LLM access. let acl = aisix_mcp::ToolAcl::resolve(&snapshot, auth.key()); - let gateway = aisix_mcp::McpGateway::from_snapshot(&snapshot).with_tool_acl(acl); + let gateway = match scope { + // Same snapshot as the resolution above, so the entry is still there. + Some(server) => match aisix_mcp::McpGateway::from_snapshot_scoped(&snapshot, server) { + Some(gateway) => gateway, + None => { + return ( + StatusCode::NOT_FOUND, + format!("unknown MCP server: {server}"), + ) + .into_response() + } + }, + None => aisix_mcp::McpGateway::from_snapshot(&snapshot), + } + .with_tool_acl(acl); let service = aisix_mcp::streamable_http_service(gateway); let request = Request::from_parts(parts, Body::from(bytes)); // `StreamableHttpService` is a tower service that dispatches on method and @@ -1318,4 +1401,206 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(25)).await; } } + + // ── /mcp/{server} — the scoped, single-server endpoint ── + + /// Seed an MCP server row. The URL is unroutable on purpose: these tests + /// pin routing, resolution and attribution, not upstream success (that is + /// covered by the aisix-mcp integration tests and the e2e suite). + fn insert_mcp_server(snapshot: &AisixSnapshot, id: &str, name: &str, enabled: bool) { + let server: aisix_core::McpServer = serde_json::from_value(serde_json::json!({ + "display_name": name, + "url": "http://127.0.0.1:9/mcp", + "enabled": enabled, + })) + .expect("valid mcp server"); + snapshot + .mcp_servers + .insert(ResourceEntry::new(id, server, 1)); + } + + /// A JSON-RPC request to `/mcp/{server}`, optionally authenticated. + fn scoped_request( + server: &str, + auth: Option<&str>, + method: &str, + params: serde_json::Value, + ) -> HttpRequest { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params + }); + let mut builder = HttpRequest::post(format!("/mcp/{server}")) + .header("host", "mcp.aisix.example.com") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream"); + if let Some(token) = auth { + builder = builder.header("authorization", format!("Bearer {token}")); + } + builder.body(Body::from(body.to_string())).unwrap() + } + + fn scoped_tools_call(server: &str, name: &str) -> HttpRequest { + scoped_request( + server, + Some(TOKEN), + "tools/call", + serde_json::json!({ "name": name, "arguments": {} }), + ) + } + + /// A snapshot with one enabled server `alpha` and a key that may call + /// every tool. + fn scoped_snapshot() -> AisixSnapshot { + let apikey: ApiKey = serde_json::from_value(serde_json::json!({ + "key_hash": ApiKey::hash_bearer(TOKEN), + "allowed_models": ["*"], + "allowed_tools": ["*"], + })) + .expect("valid apikey"); + let snapshot = AisixSnapshot::new(); + snapshot + .apikeys + .insert(ResourceEntry::new("ak-1", apikey, 1)); + insert_mcp_server(&snapshot, "mcp-1", "alpha", true); + snapshot + } + + #[tokio::test] + async fn scoped_endpoint_auth_precedes_server_resolution() { + // No credentials → 401, even for a server that does not exist: an + // unauthenticated caller must not learn which servers are registered. + let router = router_with(snapshot_with_key()); + let response = router + .oneshot(scoped_request( + "ghost", + None, + "initialize", + serde_json::json!({}), + )) + .await + .expect("router responds"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn scoped_endpoint_unknown_or_disabled_server_is_404() { + // Unregistered server. + let router = router_with(scoped_snapshot()); + let response = router + .oneshot(scoped_tools_call("ghost", "tool")) + .await + .expect("router responds"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // Registered but disabled server: treated as absent, no fallback to + // the aggregated surface. + let snapshot = scoped_snapshot(); + insert_mcp_server(&snapshot, "mcp-2", "dark", false); + let router = router_with(snapshot); + let response = router + .oneshot(scoped_tools_call("dark", "tool")) + .await + .expect("router responds"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn scoped_tool_call_attributes_server_from_path() { + use aisix_obs::{UsageEvent, UsageSink}; + + let (tx, mut rx) = tokio::sync::mpsc::channel::(8); + let handle = SnapshotHandle::new(scoped_snapshot()); + let hub = Arc::new(aisix_gateway::Hub::new()); + let state = ProxyState::new(handle, hub, &cfg()) + .without_cache() + .with_usage_sink(UsageSink::new(tx)); + let router = build_router(state); + + // A bare (original) tool name: the server comes from the path, not + // from a namespace prefix inside the name. + let _ = router + .clone() + .oneshot(scoped_tools_call("alpha", "tool")) + .await + .expect("router responds"); + let event = rx.try_recv().expect("usage event for the bare-name call"); + assert_eq!(event.inbound_protocol, "mcp"); + assert_eq!(event.mcp_server_name, "alpha"); + assert_eq!(event.mcp_tool_name, "tool"); + + // The namespaced spelling attributes identically — same tool, same + // server — so quota and usage cannot be split by client spelling. + let _ = router + .oneshot(scoped_tools_call("alpha", "alpha__tool")) + .await + .expect("router responds"); + let event = rx.try_recv().expect("usage event for the prefixed call"); + assert_eq!(event.mcp_server_name, "alpha"); + assert_eq!(event.mcp_tool_name, "tool"); + } + + #[tokio::test] + async fn scoped_per_server_rate_limit_keys_on_path_server() { + // The key may call `alpha` once a minute. On the scoped endpoint the + // limit must key on the path's server even for bare tool names. + let snapshot = snapshot_with_mcp_server_limits(&[( + "ak-1", + TOKEN, + serde_json::json!({ "alpha": { "rpm": 1 } }), + )]); + insert_mcp_server(&snapshot, "mcp-1", "alpha", true); + let router = router_with(snapshot); + + let first = router + .clone() + .oneshot(scoped_tools_call("alpha", "tool")) + .await + .expect("router responds"); + assert_eq!(first.status(), StatusCode::OK); + + let second = router + .clone() + .oneshot(scoped_tools_call("alpha", "tool")) + .await + .expect("router responds"); + assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); + + // The namespaced spelling shares the SAME bucket — a client cannot + // double its per-server allowance by switching spellings. + let prefixed = router + .oneshot(scoped_tools_call("alpha", "alpha__tool")) + .await + .expect("router responds"); + assert_eq!(prefixed.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn scoped_and_aggregated_endpoints_share_the_per_server_bucket() { + // rpm=1 for `alpha`: one call through the aggregated endpoint must + // exhaust the allowance for the scoped endpoint too — switching + // endpoints cannot double the limit. + let snapshot = snapshot_with_mcp_server_limits(&[( + "ak-1", + TOKEN, + serde_json::json!({ "alpha": { "rpm": 1 } }), + )]); + insert_mcp_server(&snapshot, "mcp-1", "alpha", true); + let router = router_with(snapshot); + + let aggregated = router + .clone() + .oneshot(tools_call_on(TOKEN, "alpha")) + .await + .expect("router responds"); + assert_eq!(aggregated.status(), StatusCode::OK); + + let scoped = router + .oneshot(scoped_tools_call("alpha", "tool")) + .await + .expect("router responds"); + assert_eq!(scoped.status(), StatusCode::TOO_MANY_REQUESTS); + } } diff --git a/tests/e2e/src/cases/mcp-scoped-endpoint-e2e.test.ts b/tests/e2e/src/cases/mcp-scoped-endpoint-e2e.test.ts new file mode 100644 index 00000000..17fc8c5c --- /dev/null +++ b/tests/e2e/src/cases/mcp-scoped-endpoint-e2e.test.ts @@ -0,0 +1,303 @@ +import { createHash, randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + EtcdClient, + SeedClient, + spawnApp, + startMcpUpstream, + waitConfigPropagation, + type McpUpstream, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: the scoped single-server MCP endpoint `/mcp/{server}` against a real +// gateway + etcd + two real MCP upstreams (official TypeScript SDK servers). +// +// Pinned contract: +// - `initialize` reports the scoped server's registered name; +// - `tools/list` returns the upstream's ORIGINAL tool names (no `server__` +// prefix), filtered by the caller's ACL evaluated in namespaced form; +// - `tools/call` accepts both the bare and the namespaced spelling; the +// path — not the tool name — picks the server, so the same bare name +// reaches different servers on different URLs; +// - a foreign `other__` prefix stays pinned to the path's server; +// - unknown and disabled servers are 404 (no fallback to the aggregate); +// - the aggregated `/mcp` surface is unchanged (still namespaced). + +const KEY_WILD = "sk-scoped-wild"; +const KEY_ALPHA_ECHO = "sk-scoped-alpha-echo"; +const KEY_BETA_ONLY = "sk-scoped-beta-only"; + +const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); + +interface RpcReply { + status: number; + json?: { + result?: { + serverInfo?: { name?: string }; + tools?: Array<{ name: string }>; + content?: Array<{ type: string; text?: string }>; + isError?: boolean; + }; + error?: { code: number; message: string }; + }; +} + +describe("mcp scoped endpoint e2e: /mcp/{server}", () => { + let app: SpawnedApp | undefined; + let alpha: McpUpstream | undefined; + let beta: McpUpstream | undefined; + let etcdReachable = false; + let seed: SeedClient; + + const post = async ( + path: string, + token: string, + body: unknown, + ): Promise => { + const res = await fetch(`${app!.proxyUrl}${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let json: RpcReply["json"]; + try { + json = text ? JSON.parse(text) : undefined; + } catch { + json = undefined; + } + return { status: res.status, json }; + }; + + /** Spec-faithful per-operation handshake (the endpoint is stateless). */ + const initialize = async ( + path: string, + token: string, + ): Promise<{ status: number; serverName?: string }> => { + const init = await post(path, token, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "mcp-scoped-e2e", version: "0.1" }, + }, + }); + if (init.status !== 200) return { status: init.status }; + await post(path, token, { + jsonrpc: "2.0", + method: "notifications/initialized", + }); + return { + status: init.status, + serverName: init.json?.result?.serverInfo?.name, + }; + }; + + /** Sorted tool names visible to `token` at `path`, or an HTTP status. */ + const listToolNames = async ( + path: string, + token: string, + ): Promise<{ status: number; names?: string[] }> => { + const { status } = await initialize(path, token); + if (status !== 200) return { status }; + const r = await post(path, token, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }); + const tools = r.json?.result?.tools; + if (r.status !== 200 || !tools) return { status: r.status }; + return { status: r.status, names: tools.map((t) => t.name).sort() }; + }; + + const callTool = async ( + path: string, + token: string, + name: string, + text: string, + ): Promise<{ ok: boolean; text?: string; error?: string }> => { + await initialize(path, token); + const r = await post(path, token, { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name, arguments: { text } }, + }); + if (r.json?.error) return { ok: false, error: r.json.error.message }; + const result = r.json?.result; + if (!result || result.isError) { + return { ok: false, error: JSON.stringify(r.json ?? r.status) }; + } + return { ok: true, text: result.content?.[0]?.text }; + }; + + /** True once `token` lists exactly `names` at `path` — propagation probe. */ + const listMatches = async ( + path: string, + token: string, + names: string[], + ): Promise => { + const listed = await listToolNames(path, token); + return ( + listed.status === 200 && + JSON.stringify(listed.names) === JSON.stringify(names) + ); + }; + + beforeAll(async () => { + const etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) return; + + alpha = await startMcpUpstream("alpha"); + beta = await startMcpUpstream("beta"); + app = await spawnApp(); + seed = new SeedClient(etcd, app.etcdPrefix); + + await seed.update("mcp_servers", randomUUID(), { + display_name: "alpha", + url: alpha.url, + enabled: true, + }); + await seed.update("mcp_servers", randomUUID(), { + display_name: "beta", + url: beta.url, + enabled: true, + }); + await seed.update("mcp_servers", randomUUID(), { + display_name: "dark", + url: alpha.url, + enabled: false, + }); + + const keyDoc = ( + plaintext: string, + allowedTools: string[], + ): Record => ({ + key_hash: sha256(plaintext), + allowed_models: [], + allowed_tools: allowedTools, + }); + await seed.createApiKey(keyDoc(KEY_WILD, ["*"])); + await seed.createApiKey(keyDoc(KEY_ALPHA_ECHO, ["alpha__echo"])); + await seed.createApiKey(keyDoc(KEY_BETA_ONLY, ["beta__*"])); + + // Probe every key to its steady state so no later assertion races a row + // that has not landed yet. + await waitConfigPropagation(async () => { + if (!(await listMatches("/mcp/alpha", KEY_WILD, ["echo", "reverse"]))) { + return false; + } + if (!(await listMatches("/mcp/alpha", KEY_ALPHA_ECHO, ["echo"]))) { + return false; + } + return listMatches("/mcp/beta", KEY_BETA_ONLY, ["echo", "reverse"]); + }); + }, 60_000); + + afterAll(async () => { + await app?.exit(); + await alpha?.close(); + await beta?.close(); + }); + + test("initialize presents the scoped server, not the aggregate", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + const init = await initialize("/mcp/alpha", KEY_WILD); + expect(init.status).toBe(200); + expect(init.serverName).toBe("alpha"); + }); + + test("tools/list returns original names; the aggregate stays namespaced", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + const scoped = await listToolNames("/mcp/alpha", KEY_WILD); + expect(scoped.status).toBe(200); + expect(scoped.names).toEqual(["echo", "reverse"]); + + // Regression: the aggregated endpoint is untouched by the scoped surface. + const aggregated = await listToolNames("/mcp", KEY_WILD); + expect(aggregated.status).toBe(200); + expect(aggregated.names).toEqual([ + "alpha__echo", + "alpha__reverse", + "beta__echo", + "beta__reverse", + ]); + }); + + test("the path picks the server: the same bare name reaches different upstreams", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + const viaAlpha = await callTool("/mcp/alpha", KEY_WILD, "echo", "hi"); + expect(viaAlpha).toEqual({ ok: true, text: "alpha:hi" }); + + const viaBeta = await callTool("/mcp/beta", KEY_WILD, "echo", "hi"); + expect(viaBeta).toEqual({ ok: true, text: "beta:hi" }); + + // The namespaced spelling keeps working on the scoped URL. + const namespaced = await callTool( + "/mcp/alpha", + KEY_WILD, + "alpha__echo", + "hi", + ); + expect(namespaced).toEqual({ ok: true, text: "alpha:hi" }); + }); + + test("a registered foreign prefix fails closed; an unregistered one is a bare name", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + // `beta` is a registered server, so `beta__echo` on /mcp/alpha is a + // cross-server mistake: rejected, never routed to beta and never served + // bare by alpha (the harness upstream would answer any name). + const crossed = await callTool("/mcp/alpha", KEY_WILD, "beta__echo", "hi"); + expect(crossed.ok).toBe(false); + expect(crossed.error).toContain("not available"); + + // `ghost` is not registered, so `ghost__echo` is just a tool name that + // contains the separator — it reaches alpha verbatim (the harness + // upstream echoes for any non-`reverse` name, labelling the answer). + const bare = await callTool("/mcp/alpha", KEY_WILD, "ghost__echo", "hi"); + expect(bare).toEqual({ ok: true, text: "alpha:hi" }); + }); + + test("ACL keeps its namespaced meaning on the scoped endpoint", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + // A grant written as `alpha__echo` admits the bare `echo` here… + const listed = await listToolNames("/mcp/alpha", KEY_ALPHA_ECHO); + expect(listed.names).toEqual(["echo"]); + const ok = await callTool("/mcp/alpha", KEY_ALPHA_ECHO, "echo", "hi"); + expect(ok).toEqual({ ok: true, text: "alpha:hi" }); + const denied = await callTool("/mcp/alpha", KEY_ALPHA_ECHO, "reverse", "hi"); + expect(denied.ok).toBe(false); + expect(denied.error).toContain("not available"); + + // …and a key scoped to beta reaches nothing on alpha's URL. + const foreign = await listToolNames("/mcp/alpha", KEY_BETA_ONLY); + expect(foreign.names).toEqual([]); + const rejected = await callTool("/mcp/alpha", KEY_BETA_ONLY, "echo", "hi"); + expect(rejected.ok).toBe(false); + expect(rejected.error).toContain("not available"); + }); + + test("unknown and disabled servers are 404 with no fallback", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + const unknown = await initialize("/mcp/ghost", KEY_WILD); + expect(unknown.status).toBe(404); + + const disabled = await initialize("/mcp/dark", KEY_WILD); + expect(disabled.status).toBe(404); + }); +});