Skip to content

Commit 12a2acd

Browse files
committed
fix(gateway): Phase 2 — call_* hard-cut guards
Restore dev hard-cut model on call_tool, get_prompt, and read_resource: non-surfaced invokable features redirect to meta-tool paths; inactive tools get bind_feature_set hints via list_inactive_discovery_tools. Re-export format_direct_* helpers from pool/mod.rs. Restore structured_content passthrough on call_tool results. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 3d00dab commit 12a2acd

2 files changed

Lines changed: 180 additions & 34 deletions

File tree

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

Lines changed: 179 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,102 @@ impl ServerHandler for McpMuxGatewayHandler {
840840
};
841841
}
842842

843+
// Hard cut: non-surfaced backend tools must use mcpmux_invoke_tool.
844+
// Surfaced tools stay in tools/list for one-hop calls.
845+
let space_id_str = space_id.to_string();
846+
if let Ok(Some((server_id, actual_tool_name))) = self
847+
.services
848+
.pool_services
849+
.feature_service
850+
.find_server_for_qualified_tool(&space_id_str, &params.name)
851+
.await
852+
{
853+
let advertised = self
854+
.services
855+
.pool_services
856+
.feature_service
857+
.get_advertised_tools_for_grants(&space_id_str, &feature_set_ids)
858+
.await
859+
.map_err(|e| {
860+
McpError::internal_error(format!("Failed to get advertised tools: {}", e), None)
861+
})?;
862+
863+
let is_surfaced = advertised
864+
.iter()
865+
.any(|feature| feature.qualified_name() == params.name.as_ref());
866+
867+
if !is_surfaced {
868+
let invokable = self
869+
.services
870+
.pool_services
871+
.feature_service
872+
.get_invokable_tools_for_grants(&space_id_str, &feature_set_ids)
873+
.await
874+
.map_err(|e| {
875+
McpError::internal_error(
876+
format!("Failed to get invokable tools: {}", e),
877+
None,
878+
)
879+
})?;
880+
let is_invokable = invokable.iter().any(|feature| {
881+
feature.qualified_name() == params.name.as_ref() && feature.is_available
882+
});
883+
884+
let message = if !is_invokable {
885+
let inactive = self
886+
.services
887+
.pool_services
888+
.feature_service
889+
.list_inactive_discovery_tools(&space_id_str, &feature_set_ids, None)
890+
.await
891+
.map_err(|e| {
892+
McpError::internal_error(
893+
format!("Failed to list inactive tools: {}", e),
894+
None,
895+
)
896+
})?;
897+
if let Some(entry) = inactive.iter().find(|candidate| {
898+
candidate.feature.qualified_name() == params.name.as_ref()
899+
}) {
900+
format!(
901+
"Tool '{}' is inactive for this workspace → \
902+
mcpmux_bind_current_workspace({{ \"feature_set_id\": \"{}\" }}) \
903+
(discover bundles via mcpmux_search_tools with include_inactive: true \
904+
or mcpmux_list_feature_sets)",
905+
params.name, entry.bindable_feature_set_id
906+
)
907+
} else {
908+
format!(
909+
"Tool '{}' is not invokable — no FeatureSet in this Space contains it. \
910+
Ask the user to create a bundle in the McpMux desktop or web UI \
911+
(Workspaces → Feature Sets), then mcpmux_bind_current_workspace \
912+
with the new feature_set_id",
913+
params.name
914+
)
915+
}
916+
} else {
917+
crate::pool::format_direct_call_redirect(
918+
&params.name,
919+
&server_id,
920+
&actual_tool_name,
921+
)
922+
};
923+
924+
let error_code = if is_invokable {
925+
"use_invoke_tool"
926+
} else {
927+
"bind_feature_set"
928+
};
929+
return Ok(CallToolResult::error(vec![Content::text(
930+
serde_json::json!({
931+
"error": error_code,
932+
"message": message,
933+
})
934+
.to_string(),
935+
)]));
936+
}
937+
}
938+
843939
// Call tool via routing service (handles auth and routing)
844940
let tool_result = self
845941
.services
@@ -906,11 +1002,12 @@ impl ServerHandler for McpMuxGatewayHandler {
9061002
"call_tool result"
9071003
);
9081004

909-
let result = if tool_result.is_error {
1005+
let mut result = if tool_result.is_error {
9101006
CallToolResult::error(content)
9111007
} else {
9121008
CallToolResult::success(content)
9131009
};
1010+
result.structured_content = tool_result.structured_content;
9141011

9151012
Ok(result)
9161013
}
@@ -988,33 +1085,64 @@ impl ServerHandler for McpMuxGatewayHandler {
9881085
.resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id)
9891086
.await?;
9901087

991-
// Authorize + route by matching the requested qualified name against
992-
// the resolved prompt set — the SAME encoding the list path uses
993-
// (ServerFeature::qualified_name). Guarantees "if it lists, it's
994-
// callable"; no dependency on the prefix-cache reverse lookup (which
995-
// could be stale and reject a listed prompt). Mirrors call_tool.
996-
let authorized_prompts = self
1088+
let (server_id, prompt_name) = self
1089+
.services
1090+
.pool_services
1091+
.feature_service
1092+
.parse_qualified_prompt_name(&space_id.to_string(), &params.name)
1093+
.await
1094+
.map_err(|e| McpError::invalid_params(format!("Invalid prompt name: {}", e), None))?;
1095+
1096+
let advertised_prompts = self
9971097
.services
9981098
.pool_services
9991099
.feature_service
10001100
.get_advertised_prompts_for_grants(&space_id.to_string(), &feature_set_ids)
10011101
.await
1102+
.map_err(|e| {
1103+
McpError::internal_error(format!("Failed to get advertised prompts: {}", e), None)
1104+
})?;
1105+
1106+
let is_surfaced = advertised_prompts
1107+
.iter()
1108+
.any(|p| p.server_id == server_id && p.feature_name == prompt_name);
1109+
1110+
if !is_surfaced {
1111+
let message = crate::pool::format_direct_fetch_prompt_redirect(
1112+
&params.name,
1113+
&server_id,
1114+
&prompt_name,
1115+
);
1116+
return Err(McpError::invalid_params(
1117+
serde_json::json!({
1118+
"error": "use_fetch_prompt",
1119+
"message": message,
1120+
})
1121+
.to_string(),
1122+
None,
1123+
));
1124+
}
1125+
1126+
let authorized_prompts = self
1127+
.services
1128+
.pool_services
1129+
.feature_service
1130+
.get_fetchable_prompts_for_grants(&space_id.to_string(), &feature_set_ids)
1131+
.await
10021132
.map_err(|e| {
10031133
McpError::internal_error(format!("Failed to verify authorization: {}", e), None)
10041134
})?;
10051135

1006-
let (server_id, prompt_name) = match authorized_prompts
1136+
let is_authorized = authorized_prompts
10071137
.iter()
1008-
.find(|p| p.is_available && p.qualified_name() == params.name)
1009-
{
1010-
Some(p) => (p.server_id.clone(), p.feature_name.clone()),
1011-
None => {
1012-
return Err(McpError::invalid_params(
1013-
format!("Prompt '{}' not authorized", params.name),
1014-
None,
1015-
));
1016-
}
1017-
};
1138+
.any(|p| p.server_id == server_id && p.feature_name == prompt_name && p.is_available);
1139+
1140+
if !is_authorized {
1141+
return Err(McpError::invalid_params(
1142+
format!("Prompt '{}' not authorized", params.name),
1143+
None,
1144+
));
1145+
}
10181146

10191147
let result_value = self
10201148
.services
@@ -1103,32 +1231,49 @@ impl ServerHandler for McpMuxGatewayHandler {
11031231
.resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id)
11041232
.await?;
11051233

1106-
// Authorize + route by matching the requested URI against the resolved
1107-
// resource set (resources are namespaced by URI, so qualified_name ==
1108-
// feature_name == uri). The server_id comes from the matched feature,
1109-
// so a listed resource is always readable. Mirrors call_tool / get_prompt.
11101234
let authorized_resources = self
11111235
.services
11121236
.pool_services
11131237
.feature_service
1114-
.get_advertised_resources_for_grants(&space_id.to_string(), &feature_set_ids)
1238+
.get_readable_resources_for_grants(&space_id.to_string(), &feature_set_ids)
11151239
.await
11161240
.map_err(|e| {
11171241
McpError::internal_error(format!("Failed to verify authorization: {}", e), None)
11181242
})?;
11191243

1120-
let server_id = match authorized_resources
1244+
let server_id = crate::pool::FeatureService::resolve_resource_server_from_grants(
1245+
&authorized_resources,
1246+
&params.uri,
1247+
)
1248+
.ok_or_else(|| {
1249+
McpError::invalid_params(format!("Resource '{}' not authorized", params.uri), None)
1250+
})?;
1251+
1252+
let advertised_resources = self
1253+
.services
1254+
.pool_services
1255+
.feature_service
1256+
.get_advertised_resources_for_grants(&space_id.to_string(), &feature_set_ids)
1257+
.await
1258+
.map_err(|e| {
1259+
McpError::internal_error(format!("Failed to get advertised resources: {}", e), None)
1260+
})?;
1261+
1262+
let is_surfaced = advertised_resources
11211263
.iter()
1122-
.find(|r| r.is_available && r.qualified_name() == params.uri)
1123-
{
1124-
Some(r) => r.server_id.clone(),
1125-
None => {
1126-
return Err(McpError::invalid_params(
1127-
format!("Resource '{}' not authorized", params.uri),
1128-
None,
1129-
));
1130-
}
1131-
};
1264+
.any(|r| r.server_id == server_id && r.feature_name == params.uri);
1265+
1266+
if !is_surfaced {
1267+
let message = crate::pool::format_direct_read_redirect(&params.uri);
1268+
return Err(McpError::invalid_params(
1269+
serde_json::json!({
1270+
"error": "use_read_resource",
1271+
"message": message,
1272+
})
1273+
.to_string(),
1274+
None,
1275+
));
1276+
}
11321277

11331278
let contents_values = self
11341279
.services

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub use oauth::{
4343
pub use connection::{ConnectionResult, ConnectionService};
4444
pub use features::{CachedFeatures, FeatureService, InactiveDiscoveryEntry};
4545
pub use routing::{
46+
format_direct_call_redirect, format_direct_fetch_prompt_redirect, format_direct_read_redirect,
4647
format_invoke_permission_denied, format_server_bound_offline_error,
4748
format_server_inactive_error, format_server_not_in_binding_error, RoutedPrompt, RoutedResource,
4849
RoutedTool, RoutingService, ToolCallResult,

0 commit comments

Comments
 (0)