@@ -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
0 commit comments