Skip to content

Commit 4a8ce5c

Browse files
author
shimaguru
committed
feat(rlm): add mcp_search_skills MCP tool + SEP-1821 compatibility doc
Extends RlmMcpService with the 8th MCP tool, mcp_search_skills, parallel to mcp_search_tools but for Terraphim skill entries. Same stateless contract: callers pass the skills array inline with the query, the service returns matching entries as JSON. SkillEntry is the discovery projection (name + description + version + author + tags) — the workflow-shape fields (inputs, steps) are NOT indexed because they're not discoverability-shape. Module doc updated to advertise the SEP-1821 connection. The implementation exposes search via discrete tools rather than via the tools/list?query= protocol extension because the rmcp SDK doesn't yet ship SEP-1821 support. When it does, the discrete tools can stay as a portability fallback for clients that don't support the spec extension. Adds 3 new end-to-end tests: - test_mcp_search_tools_e2e: full dispatch via call_tool, asserts the deserialised McpSearchToolsResponse shape. - test_mcp_search_skills_e2e: parallel for skills. - test_mcp_search_tools_invalid_args: covers missing 'tools' param and non-array 'tools' value. Tests: cargo test -p terraphim_rlm --features mcp --lib 135/135 pass (was 132; +3 new tests, test_get_tools count updated 7 -> 8).
1 parent e08fba3 commit 4a8ce5c

1 file changed

Lines changed: 251 additions & 3 deletions

File tree

crates/terraphim_rlm/src/mcp_tools.rs

Lines changed: 251 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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
@@ -9,13 +9,27 @@
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
1327
use std::sync::Arc;
1428

1529
use rmcp::model::{CallToolResult, Content, ErrorData, Tool};
1630
use serde::{Deserialize, Serialize};
1731
use serde_json::Map;
18-
use terraphim_mcp_search::mcp_search_tools;
32+
use terraphim_mcp_search::{mcp_search_skills, mcp_search_tools, SkillEntry};
1933
use terraphim_types::McpToolEntry;
2034
use 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)]
4473
pub 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

Comments
 (0)