Skip to content

Commit 4978d70

Browse files
committed
feat(gateway): Phase A — Meta invoke core
Autonomous decisions: - routing_service as Option in MetaToolContext — existing meta_tools tests stay lightweight without PoolService wiring - Updated service_container.rs and meta_tools.rs fixtures — mechanical compile wiring for build_default_registry signature change - mcpmux_invoke_tool is_read (not write) — backend invoke is not a gateway state mutation and must not require approval - get_advertised_tools_for_grants returns empty until Phase C surfaced flag — hard cut keeps tools/list meta-only now Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent f767e79 commit 4978d70

14 files changed

Lines changed: 1060 additions & 16 deletions

File tree

crates/mcpmux-gateway/src/mcp/handler.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -671,12 +671,12 @@ impl ServerHandler for McpMuxGatewayHandler {
671671
.resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id)
672672
.await?;
673673

674-
// Get tools via FeatureService — using the *resolved* space.
674+
// Get advertised tools (meta + surfaced only) for client tools/list.
675675
let tools = self
676676
.services
677677
.pool_services
678678
.feature_service
679-
.get_tools_for_grants(
679+
.get_advertised_tools_for_grants(
680680
&space_id.to_string(),
681681
&feature_set_ids,
682682
session_id_owned.as_deref(),
@@ -785,6 +785,29 @@ impl ServerHandler for McpMuxGatewayHandler {
785785
.resolve_routing(session_id, &oauth_ctx.client_id)
786786
.await?;
787787

788+
// Hard cut: reject direct backend tool calls — agents must use mcpmux_invoke_tool.
789+
let space_id_str = space_id.to_string();
790+
if let Ok(Some((server_id, actual_tool_name))) = self
791+
.services
792+
.pool_services
793+
.feature_service
794+
.find_server_for_qualified_tool(&space_id_str, &params.name)
795+
.await
796+
{
797+
let message = crate::pool::format_direct_call_redirect(
798+
&params.name,
799+
&server_id,
800+
&actual_tool_name,
801+
);
802+
return Ok(CallToolResult::error(vec![Content::text(
803+
serde_json::json!({
804+
"error": "use_invoke_tool",
805+
"message": message,
806+
})
807+
.to_string(),
808+
)]));
809+
}
810+
788811
// Call tool via routing service (handles auth and routing)
789812
let tool_result = self
790813
.services

crates/mcpmux-gateway/src/pool/features/facade.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ impl FeatureService {
9090
.await
9191
}
9292

93-
/// Resolve granted feature sets to tools, applying session server overrides.
94-
pub async fn get_tools_for_grants(
93+
/// Resolve granted feature sets to tools invokable via search/invoke ACL.
94+
pub async fn get_invokable_tools_for_grants(
9595
&self,
9696
space_id: &str,
9797
feature_set_ids: &[String],
@@ -106,6 +106,36 @@ impl FeatureService {
106106
.await
107107
}
108108

109+
/// Tools promoted into client `tools/list` (surfaced backend tools only).
110+
///
111+
/// Phase C adds per-member `surfaced: true`; until then this returns an
112+
/// empty list so clients see meta tools only.
113+
pub async fn get_advertised_tools_for_grants(
114+
&self,
115+
space_id: &str,
116+
feature_set_ids: &[String],
117+
session_id: Option<&str>,
118+
) -> Result<Vec<ServerFeature>> {
119+
let invokable = self
120+
.get_invokable_tools_for_grants(space_id, feature_set_ids, session_id)
121+
.await?;
122+
Ok(invokable
123+
.into_iter()
124+
.filter(|_| false) // Phase C: filter surfaced members
125+
.collect())
126+
}
127+
128+
/// Resolve granted feature sets to tools, applying session server overrides.
129+
pub async fn get_tools_for_grants(
130+
&self,
131+
space_id: &str,
132+
feature_set_ids: &[String],
133+
session_id: Option<&str>,
134+
) -> Result<Vec<ServerFeature>> {
135+
self.get_invokable_tools_for_grants(space_id, feature_set_ids, session_id)
136+
.await
137+
}
138+
109139
/// Resolve granted feature sets to prompts, applying session server overrides.
110140
pub async fn get_prompts_for_grants(
111141
&self,

crates/mcpmux-gateway/src/pool/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ pub use oauth::{
4242
// SOLID Services
4343
pub use connection::{ConnectionResult, ConnectionService};
4444
pub use features::{CachedFeatures, FeatureService};
45-
pub use routing::{RoutedPrompt, RoutedResource, RoutedTool, RoutingService};
45+
pub use routing::{
46+
format_direct_call_redirect, format_invoke_permission_denied, format_server_inactive_error,
47+
RoutedPrompt, RoutedResource, RoutedTool, RoutingService,
48+
};
4649
pub use service::{InstalledServerInfo, PoolService, PoolStats, ReconnectResult};
4750
pub use token::TokenService;
4851
pub use transport::{ResolvedTransport, Transport, TransportConnectResult, TransportFactory};

crates/mcpmux-gateway/src/pool/routing.rs

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,45 @@ pub struct ToolCallResult {
5858
/// Default timeout for MCP tool calls (60 seconds)
5959
const TOOL_CALL_TIMEOUT: Duration = Duration::from_secs(60);
6060

61+
/// Actionable error when a server is not in the effective enable set.
62+
pub fn format_server_inactive_error(server_id: &str) -> String {
63+
format!(
64+
"server '{server_id}' is inactive → mcpmux_enable_server({{ \"server_id\": \"{server_id}\" }})"
65+
)
66+
}
67+
68+
/// Actionable error when invoke targets a tool outside the permission set.
69+
pub fn format_invoke_permission_denied(
70+
qualified_name: &str,
71+
server_id: &str,
72+
tool_name: &str,
73+
suggestions: &[String],
74+
) -> String {
75+
if suggestions.is_empty() {
76+
format!(
77+
"tool '{qualified_name}' is not invokable with current grants (server_id='{server_id}', tool='{tool_name}')"
78+
)
79+
} else {
80+
format!(
81+
"tool '{qualified_name}' is not invokable — did you mean {}?",
82+
suggestions.join(", ")
83+
)
84+
}
85+
}
86+
87+
/// Redirect message for direct backend `call_tool` attempts.
88+
pub fn format_direct_call_redirect(
89+
qualified_name: &str,
90+
server_id: &str,
91+
tool_name: &str,
92+
) -> String {
93+
format!(
94+
"Direct backend tool calls are not supported. Use mcpmux_invoke_tool instead: \
95+
mcpmux_invoke_tool({{ \"server_id\": \"{server_id}\", \"tool\": \"{tool_name}\", \"args\": {{}} }}) \
96+
(qualified name was '{qualified_name}')"
97+
)
98+
}
99+
61100
/// RoutingService dispatches requests to backend MCP servers
62101
pub struct RoutingService {
63102
feature_service: Arc<FeatureService>,
@@ -92,7 +131,7 @@ impl RoutingService {
92131
// Resolve feature sets to allowed features
93132
let allowed_features = self
94133
.feature_service
95-
.get_tools_for_grants(&space_id_str, feature_set_ids, session_id)
134+
.get_invokable_tools_for_grants(&space_id_str, feature_set_ids, session_id)
96135
.await?;
97136

98137
// Filter to just tools
@@ -204,7 +243,7 @@ impl RoutingService {
204243
// 2. Check if the tool is allowed by grants (session overrides included)
205244
let allowed_features = self
206245
.feature_service
207-
.get_tools_for_grants(&space_id_str, feature_set_ids, session_id)
246+
.get_invokable_tools_for_grants(&space_id_str, feature_set_ids, session_id)
208247
.await?;
209248

210249
info!(
@@ -240,10 +279,12 @@ impl RoutingService {
240279
"[RoutingService] Tool '{}' NOT allowed. Looking for server_id='{}', feature_name='{}', is_available=true",
241280
tool_name, server_id, actual_tool_name
242281
);
243-
return Err(anyhow!(
244-
"Tool '{}' is not allowed by the current grants",
245-
tool_name
246-
));
282+
return Err(anyhow!(format_invoke_permission_denied(
283+
tool_name,
284+
&server_id,
285+
&actual_tool_name,
286+
&[],
287+
)));
247288
}
248289

249290
info!("[RoutingService] Tool '{}' is ALLOWED", tool_name);

crates/mcpmux-gateway/src/server/service_container.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ impl ServiceContainer {
134134
deps.installed_server_repo.clone(),
135135
feature_set_resolver.clone(),
136136
pool_services.feature_service.clone(),
137+
Some(pool_services.routing_service.clone()),
137138
session_roots.clone(),
138139
session_overrides.clone(),
139140
approval_broker.clone(),
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
//! `mcpmux_invoke_tool` — permission-checked gateway into backend MCP tools.
2+
3+
use async_trait::async_trait;
4+
use rmcp::model::{CallToolResult, Content};
5+
use serde_json::{json, Value};
6+
7+
use super::registry::{MetaTool, MetaToolCall, MetaToolError};
8+
use super::tools::{caller_resolution, caller_space_id};
9+
use crate::pool::{format_invoke_permission_denied, format_server_inactive_error};
10+
use crate::services::tool_discovery::ToolDiscoveryService;
11+
use mcpmux_core::FeatureType;
12+
13+
/// Meta tool that forwards invocations to [`RoutingService::call_tool`].
14+
pub struct InvokeToolTool;
15+
16+
#[async_trait]
17+
impl MetaTool for InvokeToolTool {
18+
fn name(&self) -> &'static str {
19+
"mcpmux_invoke_tool"
20+
}
21+
22+
fn description(&self) -> &'static str {
23+
"Invoke a backend MCP tool by server_id and tool name. Requires the \
24+
server to be active (binding or session enable) and the tool to be \
25+
in the current permission set. Use mcpmux_search_tools and \
26+
mcpmux_get_tool_schema before calling."
27+
}
28+
29+
fn input_schema(&self) -> Value {
30+
json!({
31+
"type": "object",
32+
"required": ["server_id", "tool"],
33+
"properties": {
34+
"server_id": {
35+
"type": "string",
36+
"description": "Registry server id (e.g. github)"
37+
},
38+
"tool": {
39+
"type": "string",
40+
"description": "Bare tool name on that server (e.g. list_issues), not the qualified name"
41+
},
42+
"args": {
43+
"type": "object",
44+
"description": "Arguments object passed to the backend tool",
45+
"default": {}
46+
}
47+
}
48+
})
49+
}
50+
51+
fn is_write(&self) -> bool {
52+
false
53+
}
54+
55+
async fn call(&self, call: MetaToolCall<'_>) -> Result<CallToolResult, MetaToolError> {
56+
let server_id = call
57+
.args
58+
.get("server_id")
59+
.and_then(|v| v.as_str())
60+
.ok_or_else(|| MetaToolError::InvalidArgument("missing `server_id`".into()))?
61+
.to_string();
62+
let tool_name = call
63+
.args
64+
.get("tool")
65+
.and_then(|v| v.as_str())
66+
.ok_or_else(|| MetaToolError::InvalidArgument("missing `tool`".into()))?
67+
.to_string();
68+
let args = call.args.get("args").cloned().unwrap_or_else(|| json!({}));
69+
70+
let resolved = caller_resolution(&call).await?;
71+
let space_id = caller_space_id(&call).await?;
72+
let session_id = call.session_id;
73+
74+
let invokable = call
75+
.ctx
76+
.feature_service
77+
.get_invokable_tools_for_grants(
78+
&space_id.to_string(),
79+
&resolved.feature_set_ids,
80+
session_id,
81+
)
82+
.await
83+
.map_err(|e| MetaToolError::Internal(e.to_string()))?;
84+
85+
let binding_features = call
86+
.ctx
87+
.feature_service
88+
.resolve_feature_sets(&space_id.to_string(), &resolved.feature_set_ids)
89+
.await
90+
.map_err(|e| MetaToolError::Internal(e.to_string()))?;
91+
let binding_servers: std::collections::HashSet<String> = binding_features
92+
.iter()
93+
.map(|f| f.server_id.clone())
94+
.collect();
95+
let session_enabled = session_id
96+
.map(|sid| call.ctx.session_overrides.enabled_set(sid))
97+
.unwrap_or_default();
98+
let session_disabled = session_id
99+
.map(|sid| call.ctx.session_overrides.disabled_set(sid))
100+
.unwrap_or_default();
101+
102+
let is_server_active = binding_servers.contains(&server_id)
103+
|| (session_enabled.contains(&server_id) && !session_disabled.contains(&server_id));
104+
105+
if session_disabled.contains(&server_id) {
106+
return Ok(invoke_error(format!(
107+
"server '{server_id}' is disabled for this session → mcpmux_enable_server({{ \"server_id\": \"{server_id}\" }})"
108+
)));
109+
}
110+
111+
if !is_server_active {
112+
return Ok(invoke_error(format_server_inactive_error(&server_id)));
113+
}
114+
115+
let qualified_name = invokable
116+
.iter()
117+
.find(|f| f.server_id == server_id && f.feature_name == tool_name)
118+
.map(|f| f.qualified_name())
119+
.unwrap_or_else(|| format!("{server_id}_{tool_name}"));
120+
let is_invokable = invokable.iter().any(|f| {
121+
f.feature_type == FeatureType::Tool
122+
&& f.server_id == server_id
123+
&& f.feature_name == tool_name
124+
&& f.is_available
125+
});
126+
127+
if !is_invokable {
128+
let index = call
129+
.ctx
130+
.tool_discovery
131+
.build_index(&space_id.to_string(), &invokable)
132+
.await
133+
.map_err(|e| MetaToolError::Internal(e.to_string()))?;
134+
let suggestions: Vec<String> = ToolDiscoveryService::search(
135+
&index,
136+
Some(&tool_name),
137+
Some(&server_id),
138+
crate::services::tool_discovery::DetailLevel::Name,
139+
5,
140+
None,
141+
)
142+
.tools
143+
.iter()
144+
.filter_map(|v| {
145+
v.get("qualified_name")
146+
.and_then(|n| n.as_str().map(String::from))
147+
})
148+
.collect();
149+
return Ok(invoke_error(format_invoke_permission_denied(
150+
&qualified_name,
151+
&server_id,
152+
&tool_name,
153+
&suggestions,
154+
)));
155+
}
156+
157+
let routing = call
158+
.ctx
159+
.routing_service
160+
.as_ref()
161+
.ok_or_else(|| MetaToolError::Internal("invoke routing not configured".into()))?;
162+
match routing
163+
.call_tool(
164+
space_id,
165+
&resolved.feature_set_ids,
166+
session_id,
167+
&qualified_name,
168+
args,
169+
)
170+
.await
171+
{
172+
Ok(result) => {
173+
let content: Vec<Content> = result
174+
.content
175+
.into_iter()
176+
.filter_map(|v| serde_json::from_value(v).ok())
177+
.collect();
178+
let mut mcp_result = if result.is_error {
179+
CallToolResult::error(content)
180+
} else {
181+
CallToolResult::success(content)
182+
};
183+
mcp_result.structured_content = result.structured_content;
184+
Ok(mcp_result)
185+
}
186+
Err(e) => Ok(invoke_error(e.to_string())),
187+
}
188+
}
189+
}
190+
191+
/// Build a structured MCP error payload for invoke failures.
192+
fn invoke_error(message: String) -> CallToolResult {
193+
let payload = json!({
194+
"error": "invoke_failed",
195+
"message": message,
196+
});
197+
CallToolResult::error(vec![Content::text(payload.to_string())])
198+
}

0 commit comments

Comments
 (0)