11//! MCP (Model Context Protocol) tools for RLM operations.
22//!
3- //! This module provides 7 specialized MCP tools:
3+ //! This module provides 8 specialized MCP tools:
44//! - `rlm_code`: Execute Python code in isolated VM
55//! - `rlm_bash`: Execute bash commands in isolated VM
66//! - `rlm_query`: Query LLM recursively
99//! - `rlm_status`: Get session status
1010//! - `mcp_search_tools`: Search a caller-supplied corpus of MCP tools by
1111//! free-text query (powered by `terraphim_mcp_search::mcp_search_tools`)
12+ //! - `mcp_search_skills`: Search a caller-supplied corpus of skill entries
13+ //! by free-text query (powered by `terraphim_mcp_search::mcp_search_skills`)
14+ //!
15+ //! ## Tool Search Tool compatibility (SEP-1821)
16+ //!
17+ //! `mcp_search_tools` and `mcp_search_skills` are the canonical
18+ //! implementations of MCP's dynamic-tool-discovery pattern (SEP-1821 in the
19+ //! Model Context Protocol spec). The MCP server SHOULD advertise this
20+ //! capability via `ServerCapabilities.tools.listChanged = true` and accept
21+ //! the `query` parameter on `tools/list` for clients that want native
22+ //! integration. Today the implementation exposes the search via discrete
23+ //! tools (more portable across the current MCP client ecosystem); the
24+ //! capability advertisement is left for a follow-up when the
25+ //! `rmcp` SDK ships SEP-1821 support upstream.
1226
1327use std:: sync:: Arc ;
1428
1529use rmcp:: model:: { CallToolResult , Content , ErrorData , Tool } ;
1630use serde:: { Deserialize , Serialize } ;
1731use serde_json:: Map ;
18- use terraphim_mcp_search:: mcp_search_tools;
32+ use terraphim_mcp_search:: { mcp_search_skills , mcp_search_tools, SkillEntry } ;
1933use terraphim_types:: McpToolEntry ;
2034use tokio:: sync:: RwLock ;
2135
@@ -39,6 +53,21 @@ pub struct McpSearchToolsResponse {
3953 pub tools : Vec < McpToolEntry > ,
4054}
4155
56+ /// Response payload for the `mcp_search_skills` MCP tool.
57+ ///
58+ /// Parallel to [`McpSearchToolsResponse`] but for Terraphim skills. Same
59+ /// serialisation pattern: pretty-printed JSON inside a single text content
60+ /// block.
61+ #[ derive( Debug , Clone , Serialize , Deserialize ) ]
62+ pub struct McpSearchSkillsResponse {
63+ /// The original query string (echoed back).
64+ pub query : String ,
65+ /// Number of matching skills returned.
66+ pub count : usize ,
67+ /// The matching skill entries, in original (Aho-Corasick) order.
68+ pub skills : Vec < SkillEntry > ,
69+ }
70+
4271/// RLM MCP service providing specialized tools for code execution.
4372#[ derive( Clone ) ]
4473pub struct RlmMcpService {
@@ -79,6 +108,7 @@ impl RlmMcpService {
79108 Self :: rlm_snapshot_tool( ) ,
80109 Self :: rlm_status_tool( ) ,
81110 Self :: mcp_search_tools_tool( ) ,
111+ Self :: mcp_search_skills_tool( ) ,
82112 ]
83113 }
84114
@@ -96,6 +126,7 @@ impl RlmMcpService {
96126 "rlm_snapshot" => self . handle_rlm_snapshot ( arguments) . await ,
97127 "rlm_status" => self . handle_rlm_status ( arguments) . await ,
98128 "mcp_search_tools" => self . handle_mcp_search_tools ( arguments) ,
129+ "mcp_search_skills" => self . handle_mcp_search_skills ( arguments) ,
99130 _ => Err ( ErrorData :: internal_error (
100131 format ! ( "Unknown RLM tool: {}" , name) ,
101132 None ,
@@ -386,6 +417,64 @@ impl RlmMcpService {
386417 }
387418 }
388419
420+ /// MCP tool definition for `mcp_search_skills`.
421+ ///
422+ /// Parallel to `mcp_search_tools` but for Terraphim skills. Takes a
423+ /// free-text query and an inline array of skill entries to search
424+ /// across. Returns matching entries as JSON. Same stateless contract:
425+ /// the caller decides which corpus to feed; this service does not
426+ /// maintain its own skill registry.
427+ ///
428+ /// `SkillEntry` is a discovery projection (name + description + version
429+ /// + author + tags) — `inputs` and `steps` are intentionally NOT part
430+ /// of the search index because they're workflow-shape, not
431+ /// discoverability-shape.
432+ fn mcp_search_skills_tool ( ) -> Tool {
433+ let schema = serde_json:: json!( {
434+ "type" : "object" ,
435+ "properties" : {
436+ "query" : {
437+ "type" : "string" ,
438+ "description" : "Free-text search query (whitespace-split into keywords)"
439+ } ,
440+ "skills" : {
441+ "type" : "array" ,
442+ "description" : "Array of skill entries to search across. \
443+ Each entry must have at least 'name' and 'description'; \
444+ 'version', 'author', and 'tags' are optional.",
445+ "items" : {
446+ "type" : "object" ,
447+ "properties" : {
448+ "name" : { "type" : "string" } ,
449+ "description" : { "type" : "string" } ,
450+ "version" : { "type" : "string" } ,
451+ "author" : { "type" : "string" } ,
452+ "tags" : { "type" : "array" , "items" : { "type" : "string" } }
453+ } ,
454+ "required" : [ "name" , "description" ]
455+ }
456+ }
457+ } ,
458+ "required" : [ "query" , "skills" ]
459+ } ) ;
460+
461+ Tool {
462+ name : "mcp_search_skills" . into ( ) ,
463+ title : Some ( "Search Skills" . into ( ) ) ,
464+ description : Some (
465+ "Search a caller-supplied corpus of Terraphim skill entries for \
466+ those matching the query. Uses Aho-Corasick pattern matching \
467+ over name + description + tags. NFR: < 70ms for 100 skills."
468+ . into ( ) ,
469+ ) ,
470+ input_schema : Arc :: new ( schema. as_object ( ) . unwrap ( ) . clone ( ) ) ,
471+ output_schema : None ,
472+ annotations : None ,
473+ icons : None ,
474+ meta : None ,
475+ }
476+ }
477+
389478 // Tool handlers
390479
391480 async fn handle_rlm_code (
@@ -881,6 +970,71 @@ impl RlmMcpService {
881970 ) ] ) )
882971 }
883972
973+ /// Handler for the `mcp_search_skills` MCP tool.
974+ ///
975+ /// Parallel to `handle_mcp_search_tools` but for Terraphim skills.
976+ /// Stateless: takes a query string and an inline `skills` array, runs
977+ /// `terraphim_mcp_search::mcp_search_skills`, and returns matching
978+ /// entries as JSON.
979+ ///
980+ /// # Arguments
981+ ///
982+ /// Expected JSON shape:
983+ /// ```json
984+ /// {
985+ /// "query": "review",
986+ /// "skills": [
987+ /// {"name": "code-review", "description": "Automated review",
988+ /// "version": "1.0.0", "author": "Terraphim", "tags": ["quality"]}
989+ /// ]
990+ /// }
991+ /// ```
992+ ///
993+ /// `skills` is parsed loosely: only `name` + `description` are required;
994+ /// other fields default sensibly.
995+ fn handle_mcp_search_skills (
996+ & self ,
997+ arguments : Option < Map < String , serde_json:: Value > > ,
998+ ) -> Result < CallToolResult , ErrorData > {
999+ let args =
1000+ arguments. ok_or_else ( || ErrorData :: invalid_params ( "Missing arguments" , None ) ) ?;
1001+
1002+ let query = args
1003+ . get ( "query" )
1004+ . and_then ( |v| v. as_str ( ) )
1005+ . ok_or_else ( || ErrorData :: invalid_params ( "Missing 'query' parameter" , None ) ) ?;
1006+
1007+ let skills_value = args
1008+ . get ( "skills" )
1009+ . ok_or_else ( || ErrorData :: invalid_params ( "Missing 'skills' parameter" , None ) ) ?;
1010+
1011+ let skills_array = skills_value. as_array ( ) . ok_or_else ( || {
1012+ ErrorData :: invalid_params ( "'skills' must be an array of skill entries" , None )
1013+ } ) ?;
1014+
1015+ // Explicit closure (not function pointer) to avoid stable-Rust
1016+ // inference quirks with `serde_json::from_value` (consumes its
1017+ // argument).
1018+ let skills: Vec < SkillEntry > = skills_array
1019+ . iter ( )
1020+ . cloned ( )
1021+ . map ( |v| serde_json:: from_value :: < SkillEntry > ( v) )
1022+ . collect :: < Result < Vec < _ > , _ > > ( )
1023+ . map_err ( |e| ErrorData :: invalid_params ( format ! ( "Invalid skill entry: {}" , e) , None ) ) ?;
1024+
1025+ let hits = mcp_search_skills ( query, skills. as_slice ( ) ) ;
1026+
1027+ let response = McpSearchSkillsResponse {
1028+ query : query. to_string ( ) ,
1029+ count : hits. len ( ) ,
1030+ skills : hits,
1031+ } ;
1032+
1033+ Ok ( CallToolResult :: success ( vec ! [ Content :: text(
1034+ serde_json:: to_string_pretty( & response) . unwrap( ) ,
1035+ ) ] ) )
1036+ }
1037+
8841038 // Helper methods
8851039
8861040 async fn resolve_session_id (
@@ -987,7 +1141,7 @@ mod tests {
9871141 #[ test]
9881142 fn test_get_tools ( ) {
9891143 let tools = RlmMcpService :: get_tools ( ) ;
990- assert_eq ! ( tools. len( ) , 7 ) ;
1144+ assert_eq ! ( tools. len( ) , 8 ) ;
9911145
9921146 let names: Vec < & str > = tools. iter ( ) . map ( |t| t. name . as_ref ( ) ) . collect ( ) ;
9931147 assert ! ( names. contains( & "rlm_code" ) ) ;
@@ -997,6 +1151,100 @@ mod tests {
9971151 assert ! ( names. contains( & "rlm_snapshot" ) ) ;
9981152 assert ! ( names. contains( & "rlm_status" ) ) ;
9991153 assert ! ( names. contains( & "mcp_search_tools" ) ) ;
1154+ assert ! ( names. contains( & "mcp_search_skills" ) ) ;
1155+ }
1156+
1157+ /// End-to-end test for the new MCP search tools. Builds a small corpus,
1158+ /// calls each tool's handler, and asserts the deserialised response shape.
1159+ #[ tokio:: test]
1160+ async fn test_mcp_search_tools_e2e ( ) {
1161+ let service = RlmMcpService :: new ( ) ;
1162+
1163+ let tools = vec ! [
1164+ McpToolEntry :: new( "search_files" , "Search for files" , "filesystem" ) ,
1165+ McpToolEntry :: new( "read_file" , "Read file contents" , "filesystem" ) ,
1166+ McpToolEntry :: new( "grep_text" , "Grep text with regex" , "search" ) ,
1167+ ] ;
1168+
1169+ // Invoke via the public dispatch path
1170+ let args: serde_json:: Map < String , serde_json:: Value > = serde_json:: from_value (
1171+ serde_json:: json!( {
1172+ "query" : "file" ,
1173+ "tools" : tools,
1174+ } ) ,
1175+ )
1176+ . unwrap ( ) ;
1177+
1178+ let result = service
1179+ . call_tool ( "mcp_search_tools" , Some ( args) )
1180+ . await
1181+ . expect ( "call_tool mcp_search_tools" ) ;
1182+
1183+ // Extract the JSON body from the first text content block.
1184+ let body = match & result. content . first ( ) . expect ( "content present" ) . raw {
1185+ rmcp:: model:: RawContent :: Text ( t) => & t. text ,
1186+ _ => panic ! ( "expected text content" ) ,
1187+ } ;
1188+ let response: McpSearchToolsResponse = serde_json:: from_str ( body) . unwrap ( ) ;
1189+ assert_eq ! ( response. query, "file" ) ;
1190+ assert_eq ! ( response. count, 2 ) ;
1191+ let names: Vec < & str > = response. tools . iter ( ) . map ( |t| t. name . as_str ( ) ) . collect ( ) ;
1192+ assert ! ( names. contains( & "search_files" ) ) ;
1193+ assert ! ( names. contains( & "read_file" ) ) ;
1194+ }
1195+
1196+ #[ tokio:: test]
1197+ async fn test_mcp_search_skills_e2e ( ) {
1198+ let service = RlmMcpService :: new ( ) ;
1199+
1200+ let skills = vec ! [
1201+ SkillEntry :: new( "code-review" , "Automated code review" )
1202+ . with_tags( vec![ "quality" . into( ) ] ) ,
1203+ SkillEntry :: new( "deploy" , "Deploy to staging" ) ,
1204+ ] ;
1205+
1206+ let args: serde_json:: Map < String , serde_json:: Value > = serde_json:: from_value (
1207+ serde_json:: json!( {
1208+ "query" : "review" ,
1209+ "skills" : skills,
1210+ } ) ,
1211+ )
1212+ . unwrap ( ) ;
1213+
1214+ let result = service
1215+ . call_tool ( "mcp_search_skills" , Some ( args) )
1216+ . await
1217+ . expect ( "call_tool mcp_search_skills" ) ;
1218+
1219+ let body = match & result. content . first ( ) . expect ( "content present" ) . raw {
1220+ rmcp:: model:: RawContent :: Text ( t) => & t. text ,
1221+ _ => panic ! ( "expected text content" ) ,
1222+ } ;
1223+ let response: McpSearchSkillsResponse = serde_json:: from_str ( body) . unwrap ( ) ;
1224+ assert_eq ! ( response. query, "review" ) ;
1225+ assert_eq ! ( response. count, 1 ) ;
1226+ assert_eq ! ( response. skills[ 0 ] . name, "code-review" ) ;
1227+ }
1228+
1229+ #[ tokio:: test]
1230+ async fn test_mcp_search_tools_invalid_args ( ) {
1231+ let service = RlmMcpService :: new ( ) ;
1232+
1233+ // Missing 'tools' parameter.
1234+ let args: serde_json:: Map < String , serde_json:: Value > = serde_json:: from_value (
1235+ serde_json:: json!( { "query" : "anything" } ) ,
1236+ )
1237+ . unwrap ( ) ;
1238+ let result = service. call_tool ( "mcp_search_tools" , Some ( args) ) . await ;
1239+ assert ! ( result. is_err( ) , "expected error for missing 'tools' param" ) ;
1240+
1241+ // 'tools' is not an array.
1242+ let args = serde_json:: from_value :: < serde_json:: Map < String , serde_json:: Value > > (
1243+ serde_json:: json!( { "query" : "x" , "tools" : "not-an-array" } ) ,
1244+ )
1245+ . unwrap ( ) ;
1246+ let result = service. call_tool ( "mcp_search_tools" , Some ( args) ) . await ;
1247+ assert ! ( result. is_err( ) , "expected error for non-array 'tools'" ) ;
10001248 }
10011249
10021250 #[ test]
0 commit comments