use anyhow::Result;
use base64::Engine;
use fff_search::{
FFFMode, FilePicker, FilePickerOptions, FuzzySearchOptions, GrepMode, GrepSearchOptions,
PaginationArgs, QueryParser, SharedFrecency, parse_grep_query,
};
use rmcp::{
RoleServer, ServerHandler,
model::{
CallToolRequestParam, CallToolResult, Content, ErrorData, ListResourcesResult,
ListToolsResult, ReadResourceRequestParam, ReadResourceResult, ServerInfo, Tool,
},
service::RequestContext,
};
use std::sync::Arc;
use terraphim_automata::builder::json_decode;
use terraphim_automata::matcher::{extract_paragraphs_from_automata, find_matches};
use terraphim_automata::{AutocompleteConfig, AutocompleteIndex, AutocompleteResult};
use terraphim_config::{Config, ConfigState};
use terraphim_file_search::kg_scorer::KgPathScorer;
use terraphim_service::TerraphimService;
use terraphim_types::{Layer, NormalizedTermValue, RoleName, SearchQuery};
use thiserror::Error;
use tracing::{error, info};
pub mod resource_mapper;
use crate::resource_mapper::TerraphimResourceMapper;
fn find_terraphim_rlm_binary() -> std::path::PathBuf {
if let Ok(home) = std::env::var("HOME") {
let path = std::path::PathBuf::from(home).join(".cargo/bin/terraphim_rlm");
if path.exists() {
return path;
}
}
if let Ok(path) = std::process::Command::new("which")
.arg("terraphim_rlm")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
&& !path.is_empty()
{
return std::path::PathBuf::from(path);
}
std::path::PathBuf::from("terraphim_rlm")
}
#[derive(Error, Debug)]
pub enum TerraphimMcpError {
#[error("Service error: {0}")]
Service(#[from] terraphim_service::ServiceError),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("MCP error: {0}")]
Mcp(#[from] ErrorData),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Anyhow error: {0}")]
Anyhow(#[from] anyhow::Error),
}
impl From<TerraphimMcpError> for ErrorData {
fn from(err: TerraphimMcpError) -> Self {
ErrorData::internal_error(err.to_string(), None)
}
}
#[derive(Clone)]
pub struct McpService {
config_state: Arc<ConfigState>,
resource_mapper: Arc<TerraphimResourceMapper>,
autocomplete_index: Arc<tokio::sync::RwLock<Option<AutocompleteIndex>>>,
kg_scorer: Option<Arc<KgPathScorer>>,
#[allow(dead_code)]
frecency: Option<SharedFrecency>,
}
impl McpService {
pub fn new(config_state: Arc<ConfigState>) -> Self {
let frecency = std::env::var("FFF_FRECENCY_PATH").ok().and_then(|path| {
fff_search::FrecencyTracker::open(&path)
.map(|tracker| {
let shared = SharedFrecency::default();
shared.init(tracker).ok();
shared
})
.ok()
});
Self {
config_state,
resource_mapper: Arc::new(TerraphimResourceMapper::new()),
autocomplete_index: Arc::new(tokio::sync::RwLock::new(None)),
kg_scorer: None,
frecency,
}
}
pub fn with_kg_scorer(mut self, scorer: Arc<KgPathScorer>) -> Self {
self.kg_scorer = Some(scorer);
self
}
pub async fn init_autocomplete_default(&self) {
if let Ok(result) = self.build_autocomplete_index(None).await {
if result.is_error == Some(true) {
tracing::debug!("Autocomplete init skipped: {:?}", result.content);
} else {
tracing::info!("Autocomplete index initialized by default");
}
} else {
tracing::debug!("Autocomplete init failed");
}
}
pub async fn terraphim_service(&self) -> Result<TerraphimService, anyhow::Error> {
let config = self.config_state.config.clone();
let current_config = config.lock().await;
let mut fresh_config = current_config.clone();
drop(current_config);
let fresh_config_state = terraphim_config::ConfigState::new(&mut fresh_config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create fresh ConfigState: {}", e))?;
Ok(TerraphimService::new(fresh_config_state))
}
pub async fn update_config(&self, new_config: Config) -> Result<()> {
let config = self.config_state.config.clone();
let mut current_config = config.lock().await;
*current_config = new_config;
Ok(())
}
pub async fn search(
&self,
query: String,
role: Option<String>,
limit: Option<i32>,
skip: Option<i32>,
) -> Result<CallToolResult, ErrorData> {
let mut service = self
.terraphim_service()
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let (role_name, auto) = if let Some(role_str) = role {
(RoleName::from(role_str), None)
} else {
let config_snapshot = self.config_state.config.lock().await.clone();
let selected = self.config_state.get_selected_role().await;
let selected_normalised = if config_snapshot.roles.contains_key(&selected) {
Some(selected)
} else {
None
};
let ctx =
terraphim_service::auto_route::AutoRouteContext::from_env(selected_normalised);
let result = terraphim_service::auto_route::auto_select_role(
&query,
&config_snapshot,
&self.config_state,
&ctx,
)
.await;
(result.role.clone(), Some(result))
};
let search_query = SearchQuery {
search_term: NormalizedTermValue::from(query),
search_terms: None,
operator: None,
role: Some(role_name),
limit: limit.map(|l| l as usize),
skip: skip.map(|s| s as usize),
layer: Layer::default(),
include_pinned: false,
min_quality: None,
};
match service.search(&search_query).await {
Ok(documents) => {
let mut contents = Vec::new();
if let Some(ref ar) = auto {
contents.push(Content::text(format!(
"[auto-route] picked role \"{}\" (score={}, candidates={}); pass role parameter to override",
ar.role.as_str(),
ar.score,
ar.candidates.len(),
)));
}
let summary = format!("Found {} documents matching your query.", documents.len());
contents.push(Content::text(summary));
let limit = limit.unwrap_or(documents.len() as i32) as usize;
for (idx, doc) in documents.iter().enumerate() {
if idx >= limit {
break;
}
let resource_contents = self
.resource_mapper
.document_to_resource_contents(doc)
.unwrap();
contents.push(Content::resource(resource_contents));
}
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Search failed: {}", e);
let error_content = Content::text(format!("Search failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
pub async fn update_config_tool(
&self,
config_str: String,
) -> Result<CallToolResult, ErrorData> {
match serde_json::from_str::<Config>(&config_str) {
Ok(new_config) => match self.update_config(new_config).await {
Ok(()) => {
let content = Content::text("Configuration updated successfully".to_string());
Ok(CallToolResult::success(vec![content]))
}
Err(e) => {
error!("Failed to update configuration: {}", e);
let error_content =
Content::text(format!("Failed to update configuration: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
},
Err(e) => {
error!("Failed to parse config: {}", e);
let error_content = Content::text(format!("Invalid configuration JSON: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
pub async fn build_autocomplete_index(
&self,
role: Option<String>,
) -> Result<CallToolResult, ErrorData> {
let mut service = self
.terraphim_service()
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let role_name = if let Some(role_str) = role {
RoleName::from(role_str)
} else {
self.config_state.get_selected_role().await
};
let role_config = self.config_state.get_role(&role_name).await;
if let Some(role_cfg) = role_config {
if role_cfg.relevance_function != terraphim_types::RelevanceFunction::TerraphimGraph {
let error_content = Content::text(format!(
"Role '{}' does not use knowledge graph ranking (TerraphimGraph). Autocomplete is only available for roles with knowledge graph-based ranking. Current relevance function: {:?}",
role_name, role_cfg.relevance_function
));
return Ok(CallToolResult::error(vec![error_content]));
}
let kg_is_properly_configured = role_cfg
.kg
.as_ref()
.map(|kg| kg.automata_path.is_some() || kg.knowledge_graph_local.is_some())
.unwrap_or(false);
if !kg_is_properly_configured {
let error_content = Content::text(format!(
"Role '{}' does not have a properly configured knowledge graph. Autocomplete requires a role with defined automata_path or local knowledge graph.",
role_name
));
return Ok(CallToolResult::error(vec![error_content]));
}
} else {
let error_content = Content::text(format!(
"Role '{}' not found in configuration. Available roles: {:?}",
role_name,
self.config_state.roles.keys().collect::<Vec<_>>()
));
return Ok(CallToolResult::error(vec![error_content]));
}
match service.ensure_thesaurus_loaded(&role_name).await {
Ok(thesaurus_data) => {
if thesaurus_data.is_empty() {
let error_content = Content::text(format!(
"No thesaurus data available for role '{}'. Please ensure the role has a properly configured and loaded knowledge graph.",
role_name
));
return Ok(CallToolResult::error(vec![error_content]));
}
info!(
"Building autocomplete index from {} thesaurus entries for role: {}",
thesaurus_data.len(),
role_name
);
let config = AutocompleteConfig::default();
match terraphim_automata::build_autocomplete_index(
thesaurus_data.clone(),
Some(config),
) {
Ok(index) => {
let mut autocomplete_lock = self.autocomplete_index.write().await;
*autocomplete_lock = Some(index);
let content = Content::text(format!(
"Autocomplete index built successfully with {} terms for role '{}'",
thesaurus_data.len(),
role_name
));
Ok(CallToolResult::success(vec![content]))
}
Err(e) => {
error!("Failed to build autocomplete index: {}", e);
let error_content =
Content::text(format!("Failed to build autocomplete index: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
Err(e) => {
error!("Failed to load thesaurus for role '{}': {}", role_name, e);
let error_content = Content::text(format!(
"Failed to load thesaurus for role '{}': {}. Please ensure the role has a valid knowledge graph configuration with accessible automata_path.",
role_name, e
));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
pub async fn autocomplete_terms(
&self,
query: String,
limit: Option<usize>,
role: Option<String>,
) -> Result<CallToolResult, ErrorData> {
let _role_name = if let Some(role_str) = role {
RoleName::from(role_str)
} else {
self.config_state.get_selected_role().await
};
let autocomplete_lock = self.autocomplete_index.read().await;
if let Some(ref index) = *autocomplete_lock {
let max_results = limit.unwrap_or(10);
let mut combined: Vec<AutocompleteResult> = Vec::new();
if let Ok(mut exact) =
terraphim_automata::autocomplete_search(index, &query, Some(max_results))
{
combined.append(&mut exact);
}
if combined.len() < max_results {
let remaining = max_results - combined.len();
if let Ok(mut fuzzy) = terraphim_automata::fuzzy_autocomplete_search(
index,
&query,
0.6,
Some(remaining),
) {
combined.append(&mut fuzzy);
}
}
if combined.len() < max_results {
use std::collections::HashSet;
let mut seen_terms: HashSet<String> =
combined.iter().map(|r| r.term.clone()).collect();
let concept_ids: HashSet<u64> = combined.iter().map(|r| r.id).collect();
let mut candidates: Vec<AutocompleteResult> = Vec::new();
for (term, meta) in terraphim_automata::autocomplete_helpers::iter_metadata(index) {
if concept_ids.contains(&meta.id) && !seen_terms.contains(term) {
candidates.push(AutocompleteResult {
term: meta.original_term.clone(),
normalized_term: meta.normalized_term.clone(),
id: meta.id,
url: meta.url.clone(),
score: 0.0,
});
}
}
candidates.sort_by(|a, b| {
a.term
.len()
.cmp(&b.term.len())
.then_with(|| a.term.cmp(&b.term))
});
for cand in candidates {
if combined.len() >= max_results {
break;
}
if seen_terms.insert(cand.term.clone()) {
combined.push(cand);
}
}
}
let mut contents = Vec::new();
contents.push(Content::text(format!(
"Found {} suggestions",
combined.len()
)));
for r in combined.into_iter().take(max_results) {
let line = r.term.to_string();
contents.push(Content::text(line));
}
return Ok(CallToolResult::success(contents));
}
let error_content = Content::text(
"Autocomplete index not built. Please run 'build_autocomplete_index' first."
.to_string(),
);
Ok(CallToolResult::error(vec![error_content]))
}
pub async fn autocomplete_with_snippets(
&self,
query: String,
limit: Option<usize>,
role: Option<String>,
) -> Result<CallToolResult, ErrorData> {
let _role_name = if let Some(role_str) = role {
RoleName::from(role_str)
} else {
self.config_state.get_selected_role().await
};
let autocomplete_lock = self.autocomplete_index.read().await;
if let Some(ref index) = *autocomplete_lock {
let max_results = limit.unwrap_or(10);
let mut results: Vec<AutocompleteResult> = Vec::new();
if let Ok(mut prefix) =
terraphim_automata::autocomplete_search(index, &query, Some(max_results))
{
results.append(&mut prefix);
}
if results.len() < max_results {
let remaining = max_results - results.len();
if let Ok(mut fuzzy) = terraphim_automata::fuzzy_autocomplete_search(
index,
&query,
0.6,
Some(remaining),
) {
results.append(&mut fuzzy);
}
}
let mut service = self
.terraphim_service()
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let mut contents = Vec::new();
contents.push(Content::text(format!(
"Found {} suggestions",
results.len()
)));
for r in results.into_iter().take(max_results) {
let sq = SearchQuery {
search_term: NormalizedTermValue::from(r.term.clone()),
search_terms: None,
operator: None,
role: None,
limit: Some(1),
skip: Some(0),
layer: Layer::default(),
include_pinned: false,
min_quality: None,
};
let snippet = match service.search(&sq).await {
Ok(documents) if !documents.is_empty() => {
let d = &documents[0];
if let Some(stub) = &d.stub {
stub.clone()
} else if let Some(desc) = &d.description {
desc.clone()
} else {
let body = d.body.as_str();
body.chars().take(160).collect::<String>()
}
}
_ => String::new(),
};
let line = if snippet.is_empty() {
r.term
} else {
format!("{} — {}", r.term, snippet)
};
contents.push(Content::text(line));
}
return Ok(CallToolResult::success(contents));
}
let error_content = Content::text(
"Autocomplete index not built. Please run 'build_autocomplete_index' first."
.to_string(),
);
Ok(CallToolResult::error(vec![error_content]))
}
pub async fn fuzzy_autocomplete_search(
&self,
query: String,
similarity: Option<f64>,
limit: Option<usize>,
) -> Result<CallToolResult, ErrorData> {
let autocomplete_lock = self.autocomplete_index.read().await;
if let Some(ref index) = *autocomplete_lock {
let min_similarity = similarity.unwrap_or(0.6);
let max_results = limit.unwrap_or(10);
match terraphim_automata::fuzzy_autocomplete_search(
index,
&query,
min_similarity,
Some(max_results),
) {
Ok(results) => {
let mut contents = Vec::new();
let summary = format!(
"Found {} autocomplete suggestions for '{}'",
results.len(),
query
);
contents.push(Content::text(summary));
for result in results {
let suggestion = format!("• {} (score: {:.3})", result.term, result.score);
contents.push(Content::text(suggestion));
}
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Autocomplete search failed: {}", e);
let error_content = Content::text(format!("Autocomplete search failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
} else {
let error_content = Content::text(
"Autocomplete index not built. Please run 'build_autocomplete_index' first."
.to_string(),
);
Ok(CallToolResult::error(vec![error_content]))
}
}
pub async fn fuzzy_autocomplete_search_levenshtein(
&self,
query: String,
max_edit_distance: Option<usize>,
limit: Option<usize>,
) -> Result<CallToolResult, ErrorData> {
let autocomplete_lock = self.autocomplete_index.read().await;
if let Some(ref index) = *autocomplete_lock {
let max_distance = max_edit_distance.unwrap_or(2);
let max_results = limit.unwrap_or(10);
match terraphim_automata::fuzzy_autocomplete_search_levenshtein(
index,
&query,
max_distance,
Some(max_results),
) {
Ok(results) => {
let mut contents = Vec::new();
let summary = format!(
"Found {} Levenshtein autocomplete suggestions for '{}'",
results.len(),
query
);
contents.push(Content::text(summary));
for result in results {
let suggestion = format!("• {} (score: {:.3})", result.term, result.score);
contents.push(Content::text(suggestion));
}
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Levenshtein autocomplete search failed: {}", e);
let error_content =
Content::text(format!("Levenshtein autocomplete search failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
} else {
let error_content = Content::text(
"Autocomplete index not built. Please run 'build_autocomplete_index' first."
.to_string(),
);
Ok(CallToolResult::error(vec![error_content]))
}
}
pub async fn fuzzy_autocomplete_search_jaro_winkler(
&self,
query: String,
similarity: Option<f64>,
limit: Option<usize>,
) -> Result<CallToolResult, ErrorData> {
let autocomplete_lock = self.autocomplete_index.read().await;
if let Some(ref index) = *autocomplete_lock {
let min_similarity = similarity.unwrap_or(0.6);
let max_results = limit.unwrap_or(10);
match terraphim_automata::fuzzy_autocomplete_search(
index,
&query,
min_similarity,
Some(max_results),
) {
Ok(results) => {
let mut contents = Vec::new();
let summary = format!(
"Found {} Jaro-Winkler autocomplete suggestions for '{}'",
results.len(),
query
);
contents.push(Content::text(summary));
for result in results {
let suggestion = format!("• {} (score: {:.3})", result.term, result.score);
contents.push(Content::text(suggestion));
}
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Jaro-Winkler autocomplete search failed: {}", e);
let error_content =
Content::text(format!("Jaro-Winkler autocomplete search failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
} else {
let error_content = Content::text(
"Autocomplete index not built. Please run 'build_autocomplete_index' first."
.to_string(),
);
Ok(CallToolResult::error(vec![error_content]))
}
}
pub async fn serialize_autocomplete_index(&self) -> Result<CallToolResult, ErrorData> {
let autocomplete_lock = self.autocomplete_index.read().await;
if let Some(ref index) = *autocomplete_lock {
match terraphim_automata::serialize_autocomplete_index(index) {
Ok(bytes) => {
let mut contents = Vec::new();
contents.push(Content::text(format!(
"Successfully serialized autocomplete index to {} bytes",
bytes.len()
)));
let base64_data = base64::engine::general_purpose::STANDARD.encode(&bytes);
contents.push(Content::text("Base64 encoded data:".to_string()));
contents.push(Content::text(base64_data));
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Serialize autocomplete index failed: {}", e);
let error_content =
Content::text(format!("Serialize autocomplete index failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
} else {
let error_content = Content::text(
"Autocomplete index not built. Please run 'build_autocomplete_index' first."
.to_string(),
);
Ok(CallToolResult::error(vec![error_content]))
}
}
pub async fn deserialize_autocomplete_index(
&self,
base64_data: String,
) -> Result<CallToolResult, ErrorData> {
let bytes = match base64::engine::general_purpose::STANDARD.decode(&base64_data) {
Ok(data) => data,
Err(e) => {
let error_content = Content::text(format!("Invalid base64 data: {}", e));
return Ok(CallToolResult::error(vec![error_content]));
}
};
match terraphim_automata::deserialize_autocomplete_index(&bytes) {
Ok(index) => {
let mut autocomplete_lock = self.autocomplete_index.write().await;
*autocomplete_lock = Some(index);
let mut contents = Vec::new();
contents.push(Content::text(format!(
"Successfully deserialized autocomplete index with {} terms",
autocomplete_lock.as_ref().unwrap().len()
)));
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Deserialize autocomplete index failed: {}", e);
let error_content =
Content::text(format!("Deserialize autocomplete index failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
pub async fn find_matches(
&self,
text: String,
role: Option<String>,
return_positions: Option<bool>,
) -> Result<CallToolResult, ErrorData> {
let mut service = self
.terraphim_service()
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let role_name = if let Some(role_str) = role {
RoleName::from(role_str)
} else {
self.config_state.get_selected_role().await
};
match service.ensure_thesaurus_loaded(&role_name).await {
Ok(thesaurus_data) => {
if thesaurus_data.is_empty() {
let error_content = Content::text(format!(
"No thesaurus data available for role '{}'. Please ensure the role has a properly configured and loaded knowledge graph.",
role_name
));
return Ok(CallToolResult::error(vec![error_content]));
}
let return_pos = return_positions.unwrap_or(false);
match find_matches(&text, thesaurus_data, return_pos) {
Ok(matches) => {
let mut contents = Vec::new();
let summary = format!(
"Found {} term matches in text for role '{}'",
matches.len(),
role_name
);
contents.push(Content::text(summary));
for matched in matches.iter() {
let match_info = if return_pos {
if let Some((start, end)) = matched.pos {
format!("• {} (pos: {}-{})", matched.term, start, end)
} else {
format!("• {} (no position)", matched.term)
}
} else {
format!("• {}", matched.term)
};
contents.push(Content::text(match_info));
}
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Find matches failed: {}", e);
let error_content = Content::text(format!("Find matches failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
Err(e) => {
error!("Failed to load thesaurus for role '{}': {}", role_name, e);
let error_content = Content::text(format!(
"Failed to load thesaurus for role '{}': {}. Please ensure the role has a valid knowledge graph configuration.",
role_name, e
));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
pub async fn replace_matches(
&self,
text: String,
role: Option<String>,
link_type: String,
) -> Result<CallToolResult, ErrorData> {
let mut service = self
.terraphim_service()
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let role_name = if let Some(role_str) = role {
RoleName::from(role_str)
} else {
self.config_state.get_selected_role().await
};
let link_type_enum = match link_type.to_lowercase().as_str() {
"wiki" | "wikilinks" => terraphim_hooks::LinkType::WikiLinks,
"html" | "htmllinks" => terraphim_hooks::LinkType::HTMLLinks,
"markdown" | "md" => terraphim_hooks::LinkType::MarkdownLinks,
"plain" | "plaintext" => terraphim_hooks::LinkType::PlainText,
_ => {
let error_content = Content::text(format!(
"Invalid link type '{}'. Supported types: wiki, html, markdown, plain",
link_type
));
return Ok(CallToolResult::error(vec![error_content]));
}
};
match service.ensure_thesaurus_loaded(&role_name).await {
Ok(thesaurus_data) => {
if thesaurus_data.is_empty() {
let error_content = Content::text(format!(
"No thesaurus data available for role '{}'. Please ensure the role has a properly configured and loaded knowledge graph.",
role_name
));
return Ok(CallToolResult::error(vec![error_content]));
}
let replacement_service = terraphim_hooks::ReplacementService::new(thesaurus_data)
.with_link_type(link_type_enum);
match replacement_service.replace(&text) {
Ok(hook_result) => {
let mut contents = Vec::new();
contents.push(Content::text(format!(
"Replaced {} term(s) for role '{}' using {} format",
hook_result.replacements, role_name, link_type
)));
contents.push(Content::text(hook_result.result));
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Replace matches failed: {}", e);
let error_content = Content::text(format!("Replace matches failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
Err(e) => {
error!("Failed to load thesaurus for role '{}': {}", role_name, e);
let error_content = Content::text(format!(
"Failed to load thesaurus for role '{}': {}. Please ensure the role has a valid knowledge graph configuration.",
role_name, e
));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
pub async fn extract_paragraphs_from_automata(
&self,
text: String,
role: Option<String>,
include_term: Option<bool>,
) -> Result<CallToolResult, ErrorData> {
let mut service = self
.terraphim_service()
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let role_name = if let Some(role_str) = role {
RoleName::from(role_str)
} else {
self.config_state.get_selected_role().await
};
match service.ensure_thesaurus_loaded(&role_name).await {
Ok(thesaurus_data) => {
if thesaurus_data.is_empty() {
let error_content = Content::text(format!(
"No thesaurus data available for role '{}'. Please ensure the role has a properly configured and loaded knowledge graph.",
role_name
));
return Ok(CallToolResult::error(vec![error_content]));
}
let include_term_bool = include_term.unwrap_or(true);
match extract_paragraphs_from_automata(&text, thesaurus_data, include_term_bool) {
Ok(paragraphs) => {
let mut contents = Vec::new();
let summary = format!(
"Extracted {} paragraphs containing matched terms for role '{}'",
paragraphs.len(),
role_name
);
contents.push(Content::text(summary));
for (idx, (matched, paragraph)) in paragraphs.iter().enumerate() {
let match_info = format!("Match {}: {}", idx + 1, matched.term);
contents.push(Content::text(match_info));
contents.push(Content::text(format!("Paragraph: {}", paragraph)));
contents.push(Content::text("---".to_string()));
}
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Extract paragraphs failed: {}", e);
let error_content =
Content::text(format!("Extract paragraphs failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
Err(e) => {
error!("Failed to load thesaurus for role '{}': {}", role_name, e);
let error_content = Content::text(format!(
"Failed to load thesaurus for role '{}': {}. Please ensure the role has a valid knowledge graph configuration.",
role_name, e
));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
pub async fn json_decode(&self, jsonlines: String) -> Result<CallToolResult, ErrorData> {
match json_decode(&jsonlines) {
Ok(messages) => {
let mut contents = Vec::new();
let summary = format!("Successfully parsed {} Logseq messages", messages.len());
contents.push(Content::text(summary));
for (idx, message) in messages.iter().enumerate() {
let message_info = format!("Message {}: {:?}", idx + 1, message);
contents.push(Content::text(message_info));
}
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("JSON decode failed: {}", e);
let error_content = Content::text(format!("JSON decode failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
pub async fn load_thesaurus(&self, automata_path: String) -> Result<CallToolResult, ErrorData> {
let path = if automata_path.starts_with("http://") || automata_path.starts_with("https://")
{
terraphim_automata::AutomataPath::from_remote(&automata_path)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?
} else {
terraphim_automata::AutomataPath::from_local(&automata_path)
};
match terraphim_automata::load_thesaurus(&path).await {
Ok(thesaurus) => {
let mut contents = Vec::new();
let summary = format!(
"Successfully loaded thesaurus from '{}' with {} terms",
automata_path,
thesaurus.len()
);
contents.push(Content::text(summary));
let preview_terms: Vec<_> = thesaurus.keys().take(10).collect();
if !preview_terms.is_empty() {
contents.push(Content::text("Preview of terms:".to_string()));
for term in preview_terms {
let normalized = thesaurus.get(term).unwrap();
let term_info = format!("• {} -> {}", term, normalized.value);
contents.push(Content::text(term_info));
}
if thesaurus.len() > 10 {
contents.push(Content::text(format!(
"... and {} more terms",
thesaurus.len() - 10
)));
}
}
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Load thesaurus failed: {}", e);
let error_content = Content::text(format!("Load thesaurus failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
pub async fn load_thesaurus_from_json(
&self,
json_str: String,
) -> Result<CallToolResult, ErrorData> {
match terraphim_automata::load_thesaurus_from_json(&json_str) {
Ok(thesaurus) => {
let mut contents = Vec::new();
let summary = format!(
"Successfully loaded thesaurus from JSON with {} terms",
thesaurus.len()
);
contents.push(Content::text(summary));
let preview_terms: Vec<_> = thesaurus.keys().take(10).collect();
if !preview_terms.is_empty() {
contents.push(Content::text("Preview of terms:".to_string()));
for term in preview_terms {
let normalized = thesaurus.get(term).unwrap();
let term_info = format!("• {} -> {}", term, normalized.value);
contents.push(Content::text(term_info));
}
if thesaurus.len() > 10 {
contents.push(Content::text(format!(
"... and {} more terms",
thesaurus.len() - 10
)));
}
}
Ok(CallToolResult::success(contents))
}
Err(e) => {
error!("Load thesaurus from JSON failed: {}", e);
let error_content =
Content::text(format!("Load thesaurus from JSON failed: {}", e));
Ok(CallToolResult::error(vec![error_content]))
}
}
}
pub async fn is_all_terms_connected_by_path(
&self,
text: String,
role: Option<String>,
) -> Result<CallToolResult, ErrorData> {
let role_name = if let Some(role_str) = role {
RoleName::from(role_str)
} else {
self.config_state.get_selected_role().await
};
let role_config = self.config_state.get_role(&role_name).await;
if let Some(role_cfg) = role_config {
if role_cfg.relevance_function != terraphim_types::RelevanceFunction::TerraphimGraph {
let error_content = Content::text(format!(
"Role '{}' does not use knowledge graph ranking (TerraphimGraph). Graph connectivity check is only available for roles with knowledge graph-based ranking. Current relevance function: {:?}",
role_name, role_cfg.relevance_function
));
return Ok(CallToolResult::error(vec![error_content]));
}
let kg_is_properly_configured = role_cfg
.kg
.as_ref()
.map(|kg| kg.automata_path.is_some() || kg.knowledge_graph_local.is_some())
.unwrap_or(false);
if !kg_is_properly_configured {
let error_content = Content::text(format!(
"Role '{}' does not have a properly configured knowledge graph. Graph connectivity check requires a role with defined automata_path or local knowledge graph.",
role_name
));
return Ok(CallToolResult::error(vec![error_content]));
}
} else {
let error_content = Content::text(format!(
"Role '{}' not found in configuration. Available roles: {:?}",
role_name,
self.config_state.roles.keys().collect::<Vec<_>>()
));
return Ok(CallToolResult::error(vec![error_content]));
}
let rolegraph_sync = match self.config_state.roles.get(&role_name) {
Some(rg) => rg,
None => {
let error_content = Content::text(format!(
"RoleGraph not loaded for role '{}'. The role may not have been initialized with a knowledge graph. Available loaded roles: {:?}",
role_name,
self.config_state.roles.keys().collect::<Vec<_>>()
));
return Ok(CallToolResult::error(vec![error_content]));
}
};
let rolegraph = rolegraph_sync.lock().await;
let matched_terms = rolegraph.find_matching_node_ids(&text);
if matched_terms.is_empty() {
let content = Content::text(format!(
"No terms from role '{}' knowledge graph found in the provided text. Cannot check graph connectivity.",
role_name
));
return Ok(CallToolResult::success(vec![content]));
}
let is_connected = rolegraph.is_all_terms_connected_by_path(&text);
let mut contents = Vec::new();
let term_names: Vec<String> = matched_terms
.iter()
.filter_map(|node_id| {
rolegraph
.ac_reverse_nterm
.get(node_id)
.map(|nterm| nterm.to_string())
})
.collect();
contents.push(Content::text(format!(
"Graph Connectivity Result for role '{}':\n\
- Connected: {}\n\
- Matched terms count: {}\n\
- Matched terms: {:?}",
role_name,
is_connected,
matched_terms.len(),
term_names
)));
if is_connected {
contents.push(Content::text(
"All matched terms are connected by a single path in the knowledge graph, indicating semantic coherence."
));
} else {
contents.push(Content::text(
"The matched terms are NOT all connected by a single path. This may indicate:\n\
- The text spans multiple unrelated concepts\n\
- Some terms are isolated in the knowledge graph\n\
- The knowledge graph may need additional edges",
));
}
Ok(CallToolResult::success(contents))
}
pub async fn find_files(
&self,
query: String,
path: Option<String>,
limit: Option<usize>,
) -> Result<CallToolResult, ErrorData> {
let base_path = path.unwrap_or_else(|| ".".to_string());
let max_results = limit.unwrap_or(20);
let mut picker = FilePicker::new(FilePickerOptions {
base_path: base_path.clone(),
mode: FFFMode::Ai,
watch: false,
cache_budget: None,
..FilePickerOptions::default()
})
.map_err(|e| ErrorData::internal_error(format!("FilePicker init failed: {e}"), None))?;
picker
.collect_files()
.map_err(|e| ErrorData::internal_error(format!("File scan failed: {e}"), None))?;
let files = picker.get_files();
if files.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(format!(
"No files found under '{base_path}'"
))]));
}
let parser = QueryParser::default();
let fff_query = parser.parse(&query);
let result = picker.fuzzy_search(
&fff_query,
None,
FuzzySearchOptions {
max_threads: 0,
pagination: PaginationArgs {
offset: 0,
limit: max_results * 4, },
..Default::default()
},
);
let mut scored: Vec<(i32, String)> = result
.items
.iter()
.zip(result.scores.iter())
.map(|(file, score)| {
let base = score.total;
let relative_path = file.relative_path(&picker);
let kg_boost = self
.kg_scorer
.as_ref()
.map(|s| s.score_path(&relative_path))
.unwrap_or(0);
(base + kg_boost, relative_path)
})
.collect();
#[allow(clippy::unnecessary_sort_by)]
scored.sort_by(|a, b| b.0.cmp(&a.0));
let mut contents = Vec::new();
contents.push(Content::text(format!(
"Found {} files matching '{}' (searched {} files under '{}')",
result.total_matched.min(max_results),
query,
result.total_files,
base_path
)));
for (score, path) in scored.into_iter().take(max_results) {
contents.push(Content::text(format!("{score:>5} {path}")));
}
Ok(CallToolResult::success(contents))
}
pub async fn grep_files(
&self,
query: String,
path: Option<String>,
limit: Option<usize>,
output_mode: Option<String>,
cursor: Option<String>,
) -> Result<CallToolResult, ErrorData> {
let base_path = path.unwrap_or_else(|| ".".to_string());
let max_results = limit.unwrap_or(50);
let files_only = output_mode.as_deref() == Some("files");
let file_offset = cursor
.as_deref()
.and_then(|c| {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(c)
.ok()
})
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let mut picker = FilePicker::new(FilePickerOptions {
base_path: base_path.clone(),
mode: FFFMode::Ai,
watch: false,
cache_budget: None,
..FilePickerOptions::default()
})
.map_err(|e| ErrorData::internal_error(format!("FilePicker init failed: {e}"), None))?;
picker
.collect_files()
.map_err(|e| ErrorData::internal_error(format!("File scan failed: {e}"), None))?;
if picker.get_files().is_empty() {
return Ok(CallToolResult::success(vec![Content::text(format!(
"No files found under '{base_path}'"
))]));
}
let fff_query = parse_grep_query(&query);
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset,
page_limit: max_results,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
..GrepSearchOptions::default()
};
let result = picker.grep(&fff_query, &options);
let mut contents = Vec::new();
let header = format!(
"Found {} matches across {} files (searched {} files under '{}')",
result.matches.len(),
result.files_with_matches,
result.total_files_searched,
base_path
);
contents.push(Content::text(header));
if files_only {
let mut seen = std::collections::HashSet::new();
for m in &result.matches {
if let Some(file) = result.files.get(m.file_index) {
let relative_path = file.relative_path(&picker);
if seen.insert(relative_path.clone()) {
contents.push(Content::text(relative_path));
}
}
}
} else {
for m in result.matches.iter().take(max_results) {
if let Some(file) = result.files.get(m.file_index) {
let relative_path = file.relative_path(&picker);
let line = format!(
"{}:{}:{}",
relative_path,
m.line_number,
m.line_content.trim_end()
);
contents.push(Content::text(line));
}
}
}
if result.next_file_offset > 0 {
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(result.next_file_offset.to_string());
contents.push(Content::text(format!("next_cursor: {token}")));
}
Ok(CallToolResult::success(contents))
}
pub async fn multi_grep_files(
&self,
patterns: Vec<String>,
path: Option<String>,
constraints: Option<String>,
limit: Option<usize>,
cursor: Option<String>,
output_mode: Option<String>,
) -> Result<CallToolResult, ErrorData> {
let base_path = path.unwrap_or_else(|| ".".to_string());
let max_results = limit.unwrap_or(50);
let files_only = output_mode.as_deref() == Some("files");
if patterns.is_empty() {
return Ok(CallToolResult::error(vec![Content::text(
"At least one pattern is required".to_string(),
)]));
}
let file_offset = cursor
.as_deref()
.and_then(|c| {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(c)
.ok()
})
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let mut picker = FilePicker::new(FilePickerOptions {
base_path: base_path.clone(),
mode: FFFMode::Ai,
watch: false,
cache_budget: None,
..FilePickerOptions::default()
})
.map_err(|e| ErrorData::internal_error(format!("FilePicker init failed: {e}"), None))?;
picker
.collect_files()
.map_err(|e| ErrorData::internal_error(format!("File scan failed: {e}"), None))?;
if picker.get_files().is_empty() {
return Ok(CallToolResult::success(vec![Content::text(format!(
"No files found under '{base_path}'"
))]));
}
let patterns_refs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect();
let parser = QueryParser::new(fff_search::AiGrepConfig);
let constraint_str = constraints.as_deref().unwrap_or("");
let parsed = parser.parse(constraint_str);
let parsed_constraints = parsed.constraints.as_slice();
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset,
page_limit: max_results,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
..GrepSearchOptions::default()
};
let result = picker.multi_grep(&patterns_refs, parsed_constraints, &options);
let mut contents = Vec::new();
let header = format!(
"Found {} matches across {} files for patterns {:?} (searched {} files under '{}')",
result.matches.len(),
result.files_with_matches,
patterns,
result.total_files_searched,
base_path
);
contents.push(Content::text(header));
if files_only {
let mut seen = std::collections::HashSet::new();
for m in &result.matches {
if let Some(file) = result.files.get(m.file_index) {
let relative_path = file.relative_path(&picker);
if seen.insert(relative_path.clone()) {
contents.push(Content::text(relative_path));
}
}
}
} else {
for m in result.matches.iter().take(max_results) {
if let Some(file) = result.files.get(m.file_index) {
let relative_path = file.relative_path(&picker);
let line = format!(
"{}:{}:{}",
relative_path,
m.line_number,
m.line_content.trim_end()
);
contents.push(Content::text(line));
}
}
}
if result.next_file_offset > 0 {
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(result.next_file_offset.to_string());
contents.push(Content::text(format!("next_cursor: {token}")));
}
Ok(CallToolResult::success(contents))
}
async fn spawn_rlm_cli(
&self,
command: &str,
arguments: Option<serde_json::Map<String, serde_json::Value>>,
) -> Result<CallToolResult, ErrorData> {
let args = arguments.unwrap_or_default();
let session_id = args
.get("session_id")
.and_then(|v| v.as_str())
.unwrap_or("auto")
.to_string();
let mut stdin_args = args.clone();
stdin_args.remove("session_id");
let stdin_json = serde_json::to_string(&stdin_args).map_err(|e| {
ErrorData::internal_error(format!("Failed to serialize args: {}", e), None)
})?;
let binary_path = find_terraphim_rlm_binary();
tracing::info!(
"Spawning terraphim_rlm {} --session-id {}",
command,
session_id
);
let mut child = tokio::process::Command::new(&binary_path)
.arg(command)
.arg("--session-id")
.arg(&session_id)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
ErrorData::internal_error(format!("Failed to spawn terraphim_rlm: {}", e), None)
})?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin.write_all(stdin_json.as_bytes()).await.map_err(|e| {
ErrorData::internal_error(
format!("Failed to write to terraphim_rlm stdin: {}", e),
None,
)
})?;
stdin.shutdown().await.map_err(|e| {
ErrorData::internal_error(
format!("Failed to close terraphim_rlm stdin: {}", e),
None,
)
})?;
}
let output = child.wait_with_output().await.map_err(|e| {
ErrorData::internal_error(format!("terraphim_rlm process failed: {}", e), None)
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
tracing::error!(
"terraphim_rlm exited with code {:?}: stderr={}",
output.status.code(),
stderr
);
return Ok(CallToolResult::error(vec![Content::text(format!(
"RLM execution failed (exit code {:?}):\nstdout: {}\nstderr: {}",
output.status.code(),
stdout,
stderr
))]));
}
match serde_json::from_str::<serde_json::Value>(&stdout) {
Ok(response) => {
if response
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let data = response
.get("data")
.cloned()
.unwrap_or(serde_json::Value::Null);
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&data).unwrap_or_else(|_| data.to_string()),
)]))
} else {
let error = response
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.unwrap_or("Unknown RLM error");
Ok(CallToolResult::error(vec![Content::text(
error.to_string(),
)]))
}
}
Err(e) => {
tracing::error!(
"Failed to parse terraphim_rlm output: {}. stdout: {}",
e,
stdout
);
Ok(CallToolResult::error(vec![Content::text(format!(
"Failed to parse RLM output: {}\nRaw output: {}",
e, stdout
))]))
}
}
}
}
impl ServerHandler for McpService {
async fn initialize(
&self,
request: rmcp::model::InitializeRequestParam,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::InitializeResult, ErrorData> {
if context.peer.peer_info().is_none() {
context.peer.set_peer_info(request);
}
Ok(self.get_info())
}
async fn list_tools(
&self,
_request: Option<rmcp::model::PaginatedRequestParam>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
tracing::debug!("list_tools function called!");
let search_schema = serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
},
"role": {
"type": "string",
"description": "Optional role to filter by"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return"
},
"skip": {
"type": "integer",
"description": "Number of results to skip"
}
},
"required": ["query"]
});
let search_map = search_schema.as_object().unwrap().clone();
let config_schema = serde_json::json!({
"type": "object",
"properties": {
"config_str": {
"type": "string",
"description": "JSON configuration string"
}
},
"required": ["config_str"]
});
let config_map = config_schema.as_object().unwrap().clone();
let build_autocomplete_schema = serde_json::json!({
"type": "object",
"properties": {
"role": {
"type": "string",
"description": "Optional role name to build autocomplete index for. If not provided, uses the currently selected role."
}
},
"required": []
});
let build_autocomplete_map = build_autocomplete_schema.as_object().unwrap().clone();
let autocomplete_terms_schema = serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Prefix or term for suggestions" },
"limit": { "type": "integer", "description": "Max suggestions (default 10)" },
"role": { "type": "string", "description": "Optional role name to use for autocomplete. If not provided, uses the currently selected role." }
},
"required": ["query"]
});
let autocomplete_terms_map = autocomplete_terms_schema.as_object().unwrap().clone();
let autocomplete_snippets_schema = serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Prefix or term for suggestions with snippets" },
"limit": { "type": "integer", "description": "Max suggestions (default 10)" },
"role": { "type": "string", "description": "Optional role name to use for autocomplete. If not provided, uses the currently selected role." }
},
"required": ["query"]
});
let autocomplete_snippets_map = autocomplete_snippets_schema.as_object().unwrap().clone();
let fuzzy_autocomplete_schema = serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The text to get autocomplete suggestions for"
},
"similarity": {
"type": "number",
"description": "Minimum Jaro-Winkler similarity threshold (0.0-1.0, default: 0.6)"
},
"limit": {
"type": "integer",
"description": "Maximum number of suggestions to return (default: 10)"
}
},
"required": ["query"]
});
let fuzzy_autocomplete_map = fuzzy_autocomplete_schema.as_object().unwrap().clone();
let levenshtein_autocomplete_schema = serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The text to get autocomplete suggestions for"
},
"max_edit_distance": {
"type": "integer",
"description": "Maximum Levenshtein edit distance allowed (default: 2)"
},
"limit": {
"type": "integer",
"description": "Maximum number of suggestions to return (default: 10)"
}
},
"required": ["query"]
});
let levenshtein_autocomplete_map =
levenshtein_autocomplete_schema.as_object().unwrap().clone();
let find_matches_schema = serde_json::json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "The text to search in" },
"role": { "type": "string", "description": "Optional role to filter by" },
"return_positions": { "type": "boolean", "description": "Whether to return positions (default: false)" }
},
"required": ["text"]
});
let find_matches_map = find_matches_schema.as_object().unwrap().clone();
let replace_matches_schema = serde_json::json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "The text to replace terms in" },
"role": { "type": "string", "description": "Optional role to filter by" },
"link_type": { "type": "string", "description": "The type of link to use (wiki, html, markdown)" }
},
"required": ["text", "link_type"]
});
let replace_matches_map = replace_matches_schema.as_object().unwrap().clone();
let extract_paragraphs_schema = serde_json::json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "The text to extract paragraphs from" },
"role": { "type": "string", "description": "Optional role to filter by" },
"include_term": { "type": "boolean", "description": "Whether to include the matched term in the paragraph (default: true)" }
},
"required": ["text"]
});
let extract_paragraphs_map = extract_paragraphs_schema.as_object().unwrap().clone();
let json_decode_schema = serde_json::json!({
"type": "object",
"properties": {
"jsonlines": { "type": "string", "description": "The JSON lines string to decode" }
},
"required": ["jsonlines"]
});
let json_decode_map = json_decode_schema.as_object().unwrap().clone();
let load_thesaurus_schema = serde_json::json!({
"type": "object",
"properties": {
"automata_path": { "type": "string", "description": "The path to the automata file (local or remote URL)" }
},
"required": ["automata_path"]
});
let load_thesaurus_map = load_thesaurus_schema.as_object().unwrap().clone();
let load_thesaurus_json_schema = serde_json::json!({
"type": "object",
"properties": {
"json_str": { "type": "string", "description": "The JSON string to load thesaurus from" }
},
"required": ["json_str"]
});
let load_thesaurus_json_map = load_thesaurus_json_schema.as_object().unwrap().clone();
let is_all_terms_connected_schema = serde_json::json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "The text to check for term connectivity" },
"role": { "type": "string", "description": "Optional role to use for thesaurus and graph" }
},
"required": ["text"]
});
let is_all_terms_connected_map = is_all_terms_connected_schema.as_object().unwrap().clone();
let tools = vec![
Tool {
name: "search".into(),
title: Some("Search Knowledge Graph".into()),
description: Some("Search for documents in Terraphim knowledge graph".into()),
input_schema: Arc::new(search_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "update_config_tool".into(),
title: Some("Update Configuration".into()),
description: Some("Update the Terraphim configuration".into()),
input_schema: Arc::new(config_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "build_autocomplete_index".into(),
title: Some("Build Autocomplete Index".into()),
description: Some("Build FST-based autocomplete index from role's knowledge graph. Only available for roles with TerraphimGraph relevance function and configured knowledge graph.".into()),
input_schema: Arc::new(build_autocomplete_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "fuzzy_autocomplete_search".into(),
title: Some("Fuzzy Autocomplete Search".into()),
description: Some("Perform fuzzy autocomplete search using Jaro-Winkler similarity (default, faster and higher quality)".into()),
input_schema: Arc::new(fuzzy_autocomplete_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "autocomplete_terms".into(),
title: Some("Autocomplete Terms".into()),
description: Some("Autocomplete terms using FST prefix + fuzzy fallback".into()),
input_schema: Arc::new(autocomplete_terms_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "autocomplete_with_snippets".into(),
title: Some("Autocomplete With Snippets".into()),
description: Some("Autocomplete and return short snippets from matching documents".into()),
input_schema: Arc::new(autocomplete_snippets_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "fuzzy_autocomplete_search_levenshtein".into(),
title: Some("Fuzzy Search (Levenshtein)".into()),
description: Some("Perform fuzzy autocomplete search using Levenshtein distance (baseline comparison algorithm)".into()),
input_schema: Arc::new(levenshtein_autocomplete_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "fuzzy_autocomplete_search_jaro_winkler".into(),
title: Some("Fuzzy Search (Jaro-Winkler)".into()),
description: Some("Perform fuzzy autocomplete search using Jaro-Winkler similarity (explicit)".into()),
input_schema: Arc::new(fuzzy_autocomplete_schema.as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "serialize_autocomplete_index".into(),
title: Some("Serialize Index".into()),
description: Some("Serialize the current autocomplete index to a base64-encoded string for storage/transmission".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {},
"required": []
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "deserialize_autocomplete_index".into(),
title: Some("Deserialize Index".into()),
description: Some("Deserialize an autocomplete index from a base64-encoded string".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {
"base64_data": { "type": "string", "description": "The base64-encoded string of the serialized index" }
},
"required": ["base64_data"]
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "find_matches".into(),
title: Some("Find Matches".into()),
description: Some("Find all term matches in text using Aho-Corasick algorithm".into()),
input_schema: Arc::new(find_matches_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "replace_matches".into(),
title: Some("Replace Matches".into()),
description: Some("Replace matched terms in text with links using specified format".into()),
input_schema: Arc::new(replace_matches_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "extract_paragraphs_from_automata".into(),
title: Some("Extract Paragraphs".into()),
description: Some("Extract paragraphs containing matched terms from text".into()),
input_schema: Arc::new(extract_paragraphs_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "json_decode".into(),
title: Some("JSON Decode".into()),
description: Some("Parse Logseq JSON output using terraphim_automata".into()),
input_schema: Arc::new(json_decode_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "load_thesaurus".into(),
title: Some("Load Thesaurus".into()),
description: Some("Load thesaurus from a local file or remote URL".into()),
input_schema: Arc::new(load_thesaurus_map.clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "load_thesaurus_from_json".into(),
title: Some("Load Thesaurus from JSON".into()),
description: Some("Load thesaurus from a JSON string".into()),
input_schema: Arc::new(load_thesaurus_json_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "is_all_terms_connected_by_path".into(),
title: Some("Check Terms Connectivity".into()),
description: Some("Check if all matched terms in text can be connected by a single path in the knowledge graph".into()),
input_schema: Arc::new(is_all_terms_connected_map),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "terraphim_find_files".into(),
title: Some("Find Files (KG-boosted)".into()),
description: Some("Fuzzy file search using fff-search with optional knowledge-graph path scoring. Results are ranked by fuzzy match score plus KG concept boost.".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Fuzzy search string"
},
"path": {
"type": "string",
"description": "Directory to search (defaults to current directory)"
},
"limit": {
"type": "integer",
"description": "Maximum number of results (default 20)"
}
},
"required": ["query"]
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "terraphim_grep".into(),
title: Some("Grep (KG-ordered, paginated)".into()),
description: Some("Search file contents with ripgrep-style matching. Files are searched in KG path-score order so conceptually relevant files appear first. Supports cursor-based pagination for large result sets.".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search pattern (plain text)"
},
"path": {
"type": "string",
"description": "Directory to search (defaults to current directory)"
},
"limit": {
"type": "integer",
"description": "Maximum number of matches to return (default 50)"
},
"output_mode": {
"type": "string",
"enum": ["content", "files"],
"description": "Return 'content' (file:line:text, default) or 'files' (unique file paths only)"
},
"cursor": {
"type": "string",
"description": "Pagination cursor from a previous result's next_cursor field"
}
},
"required": ["query"]
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "terraphim_multi_grep".into(),
title: Some("Multi-Grep (OR patterns, KG-ordered, paginated)".into()),
description: Some("Search file contents for lines matching ANY of multiple patterns (OR logic) using Aho-Corasick. Faster than running separate grep calls. Files are KG path-score ordered. Supports cursor pagination.".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {
"patterns": {
"type": "array",
"items": { "type": "string" },
"description": "Patterns to match (OR logic). Include all naming conventions: snake_case, PascalCase, camelCase."
},
"path": {
"type": "string",
"description": "Directory to search (defaults to current directory)"
},
"constraints": {
"type": "string",
"description": "File constraints (e.g. '*.rs !test/')"
},
"limit": {
"type": "integer",
"description": "Maximum number of matches to return (default 50)"
},
"output_mode": {
"type": "string",
"enum": ["content", "files"],
"description": "Return 'content' (file:line:text, default) or 'files' (unique file paths only)"
},
"cursor": {
"type": "string",
"description": "Pagination cursor from a previous result's next_cursor field"
}
},
"required": ["patterns"]
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "rlm_code".into(),
title: Some("Execute Python Code".into()),
description: Some("Execute Python code in an isolated execution environment. Returns stdout, stderr, and exit status.".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Python code to execute"
},
"session_id": {
"type": "string",
"description": "Optional session ID (auto-created if not provided)"
},
"timeout_ms": {
"type": "integer",
"description": "Optional execution timeout in milliseconds"
}
},
"required": ["code"]
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "rlm_bash".into(),
title: Some("Execute Bash Command".into()),
description: Some("Execute a bash command in an isolated execution environment.".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Bash command to execute"
},
"session_id": {
"type": "string",
"description": "Optional session ID (auto-created if not provided)"
},
"timeout_ms": {
"type": "integer",
"description": "Optional execution timeout in milliseconds"
}
},
"required": ["command"]
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "rlm_query".into(),
title: Some("Query LLM".into()),
description: Some("Query the LLM from within an RLM session.".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "The prompt/query to send to the LLM"
},
"session_id": {
"type": "string",
"description": "Optional session ID (auto-created if not provided)"
}
},
"required": ["prompt"]
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "rlm_context".into(),
title: Some("Manage Context Variables".into()),
description: Some("Manage context variables within an RLM session.".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["get", "set", "list", "delete"],
"description": "The action to perform"
},
"session_id": {
"type": "string",
"description": "Optional session ID (auto-created if not provided)"
},
"key": {
"type": "string",
"description": "Variable key"
},
"value": {
"type": "string",
"description": "Variable value (for set)"
}
},
"required": ["action"]
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "rlm_snapshot".into(),
title: Some("Manage VM Snapshots".into()),
description: Some("Manage execution environment snapshots for rollback support.".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "restore", "list", "delete"],
"description": "The snapshot action"
},
"session_id": {
"type": "string",
"description": "Optional session ID (auto-created if not provided)"
},
"snapshot_name": {
"type": "string",
"description": "Name for the snapshot"
}
},
"required": ["action"]
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
Tool {
name: "rlm_status".into(),
title: Some("Get Session Status".into()),
description: Some("Get the status of an RLM session.".into()),
input_schema: Arc::new(serde_json::json!({
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Optional session ID (auto-created if not provided)"
},
"include_history": {
"type": "boolean",
"description": "Whether to include command history"
}
},
"required": []
}).as_object().unwrap().clone()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
}
];
tracing::debug!("Created {} tools", tools.len());
tracing::debug!("First tool name: {:?}", tools.first().map(|t| &t.name));
let result = ListToolsResult {
tools,
next_cursor: None,
};
tracing::debug!(
"Returning ListToolsResult with {} tools",
result.tools.len()
);
Ok(result)
}
async fn call_tool(
&self,
request: CallToolRequestParam,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
match request.name.as_ref() {
"search" => {
let arguments = request.arguments.unwrap_or_default();
let query = arguments
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'query' parameter".to_string(), None)
})?
.to_string();
let role = arguments
.get("role")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let limit = arguments
.get("limit")
.and_then(|v| v.as_i64())
.map(|i| i as i32);
let skip = arguments
.get("skip")
.and_then(|v| v.as_i64())
.map(|i| i as i32);
self.search(query, role, limit, skip)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"update_config_tool" => {
let arguments = request.arguments.unwrap_or_default();
let config_str = arguments
.get("config_str")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params(
"Missing 'config_str' parameter".to_string(),
None,
)
})?
.to_string();
self.update_config_tool(config_str)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"build_autocomplete_index" => {
let arguments = request.arguments.unwrap_or_default();
let role = arguments
.get("role")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
self.build_autocomplete_index(role)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"fuzzy_autocomplete_search" => {
let arguments = request.arguments.unwrap_or_default();
let query = arguments
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'query' parameter".to_string(), None)
})?
.to_string();
let similarity = arguments.get("similarity").and_then(|v| v.as_f64());
let limit = arguments
.get("limit")
.and_then(|v| v.as_i64())
.map(|i| i as usize);
self.fuzzy_autocomplete_search(query, similarity, limit)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"autocomplete_terms" => {
let arguments = request.arguments.unwrap_or_default();
let query = arguments
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'query' parameter".to_string(), None)
})?
.to_string();
let limit = arguments
.get("limit")
.and_then(|v| v.as_i64())
.map(|i| i as usize);
let role = arguments
.get("role")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
self.autocomplete_terms(query, limit, role)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"autocomplete_with_snippets" => {
let arguments = request.arguments.unwrap_or_default();
let query = arguments
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'query' parameter".to_string(), None)
})?
.to_string();
let limit = arguments
.get("limit")
.and_then(|v| v.as_i64())
.map(|i| i as usize);
let role = arguments
.get("role")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
self.autocomplete_with_snippets(query, limit, role)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"fuzzy_autocomplete_search_levenshtein" => {
let arguments = request.arguments.unwrap_or_default();
let query = arguments
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'query' parameter".to_string(), None)
})?
.to_string();
let max_edit_distance = arguments
.get("max_edit_distance")
.and_then(|v| v.as_i64())
.map(|i| i as usize);
let limit = arguments
.get("limit")
.and_then(|v| v.as_i64())
.map(|i| i as usize);
self.fuzzy_autocomplete_search_levenshtein(query, max_edit_distance, limit)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"fuzzy_autocomplete_search_jaro_winkler" => {
let arguments = request.arguments.unwrap_or_default();
let query = arguments
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'query' parameter".to_string(), None)
})?
.to_string();
let similarity = arguments.get("similarity").and_then(|v| v.as_f64());
let limit = arguments
.get("limit")
.and_then(|v| v.as_i64())
.map(|i| i as usize);
self.fuzzy_autocomplete_search_jaro_winkler(query, similarity, limit)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"serialize_autocomplete_index" => self
.serialize_autocomplete_index()
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from),
"deserialize_autocomplete_index" => {
let arguments = request.arguments.unwrap_or_default();
let base64_data = arguments
.get("base64_data")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params(
"Missing 'base64_data' parameter".to_string(),
None,
)
})?
.to_string();
self.deserialize_autocomplete_index(base64_data)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"find_matches" => {
let arguments = request.arguments.unwrap_or_default();
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'text' parameter".to_string(), None)
})?
.to_string();
let role = arguments
.get("role")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let return_positions = arguments
.get("return_positions")
.and_then(|v| v.as_bool())
.unwrap_or(false);
self.find_matches(text, role, Some(return_positions))
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"replace_matches" => {
let arguments = request.arguments.unwrap_or_default();
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'text' parameter".to_string(), None)
})?
.to_string();
let role = arguments
.get("role")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let link_type = arguments
.get("link_type")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'link_type' parameter".to_string(), None)
})?
.to_string();
self.replace_matches(text, role, link_type)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"extract_paragraphs_from_automata" => {
let arguments = request.arguments.unwrap_or_default();
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'text' parameter".to_string(), None)
})?
.to_string();
let role = arguments
.get("role")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let include_term = arguments
.get("include_term")
.and_then(|v| v.as_bool())
.unwrap_or(true);
self.extract_paragraphs_from_automata(text, role, Some(include_term))
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"json_decode" => {
let arguments = request.arguments.unwrap_or_default();
let jsonlines = arguments
.get("jsonlines")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'jsonlines' parameter".to_string(), None)
})?
.to_string();
self.json_decode(jsonlines)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"load_thesaurus" => {
let arguments = request.arguments.unwrap_or_default();
let automata_path = arguments
.get("automata_path")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params(
"Missing 'automata_path' parameter".to_string(),
None,
)
})?
.to_string();
self.load_thesaurus(automata_path)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"load_thesaurus_from_json" => {
let arguments = request.arguments.unwrap_or_default();
let json_str = arguments
.get("json_str")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'json_str' parameter".to_string(), None)
})?
.to_string();
self.load_thesaurus_from_json(json_str)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"is_all_terms_connected_by_path" => {
let arguments = request.arguments.unwrap_or_default();
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'text' parameter".to_string(), None)
})?
.to_string();
let role = arguments
.get("role")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
self.is_all_terms_connected_by_path(text, role)
.await
.map_err(TerraphimMcpError::Mcp)
.map_err(ErrorData::from)
}
"terraphim_find_files" => {
let arguments = request.arguments.unwrap_or_default();
let query = arguments
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'query' parameter".to_string(), None)
})?
.to_string();
let path = arguments
.get("path")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let limit = arguments
.get("limit")
.and_then(|v| v.as_i64())
.map(|i| i as usize);
self.find_files(query, path, limit).await
}
"terraphim_grep" => {
let arguments = request.arguments.unwrap_or_default();
let query = arguments
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ErrorData::invalid_params("Missing 'query' parameter".to_string(), None)
})?
.to_string();
let path = arguments
.get("path")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let limit = arguments
.get("limit")
.and_then(|v| v.as_i64())
.map(|i| i as usize);
let output_mode = arguments
.get("output_mode")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let cursor = arguments
.get("cursor")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
self.grep_files(query, path, limit, output_mode, cursor)
.await
}
"terraphim_multi_grep" => {
let arguments = request.arguments.unwrap_or_default();
let patterns: Vec<String> = arguments
.get("patterns")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
if patterns.is_empty() {
return Err(ErrorData::invalid_params(
"Missing or empty 'patterns' parameter".to_string(),
None,
));
}
let path = arguments
.get("path")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let constraints = arguments
.get("constraints")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let limit = arguments
.get("limit")
.and_then(|v| v.as_i64())
.map(|i| i as usize);
let cursor = arguments
.get("cursor")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let output_mode = arguments
.get("output_mode")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
self.multi_grep_files(patterns, path, constraints, limit, cursor, output_mode)
.await
}
"rlm_code" => self.spawn_rlm_cli("code", request.arguments).await,
"rlm_bash" => self.spawn_rlm_cli("bash", request.arguments).await,
"rlm_query" => self.spawn_rlm_cli("query", request.arguments).await,
"rlm_context" => self.spawn_rlm_cli("context", request.arguments).await,
"rlm_snapshot" => self.spawn_rlm_cli("snapshot", request.arguments).await,
"rlm_status" => self.spawn_rlm_cli("status", request.arguments).await,
_ => Err(ErrorData::method_not_found::<
rmcp::model::CallToolRequestMethod,
>()),
}
}
async fn list_resources(
&self,
_request: Option<rmcp::model::PaginatedRequestParam>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, ErrorData> {
let mut service = self
.terraphim_service()
.await
.map_err(TerraphimMcpError::Anyhow)?;
let search_terms = vec!["terraphim", "graph", "service", "haystack"];
let mut all_documents = std::collections::HashSet::new();
for term in search_terms {
let search_query = terraphim_types::SearchQuery {
search_term: terraphim_types::NormalizedTermValue::new(term.to_string()),
search_terms: None,
operator: None,
limit: Some(50), skip: None,
role: None,
layer: terraphim_types::Layer::default(),
include_pinned: false,
min_quality: None,
};
match service.search(&search_query).await {
Ok(documents) => {
for doc in documents {
all_documents.insert(doc.id.clone());
}
}
Err(_) => {
continue;
}
}
}
if all_documents.is_empty() {
let fallback_query = terraphim_types::SearchQuery {
search_term: terraphim_types::NormalizedTermValue::new("*".to_string()),
search_terms: None,
operator: None,
limit: Some(100),
skip: None,
role: None,
layer: terraphim_types::Layer::default(),
include_pinned: false,
min_quality: None,
};
let documents = service
.search(&fallback_query)
.await
.map_err(TerraphimMcpError::Service)?;
let resources = self
.resource_mapper
.documents_to_resources(&documents)
.map_err(TerraphimMcpError::Anyhow)?;
return Ok(ListResourcesResult {
resources,
next_cursor: None,
});
}
let mut final_documents = Vec::new();
for doc_id in all_documents.iter().take(50) {
if let Ok(Some(doc)) = service.get_document_by_id(doc_id).await {
final_documents.push(doc);
}
}
let resources = self
.resource_mapper
.documents_to_resources(&final_documents)
.map_err(TerraphimMcpError::Anyhow)?;
Ok(ListResourcesResult {
resources,
next_cursor: None,
})
}
async fn read_resource(
&self,
request: ReadResourceRequestParam,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, ErrorData> {
let doc_id = self
.resource_mapper
.uri_to_id(&request.uri)
.map_err(TerraphimMcpError::Anyhow)?;
let mut service = self
.terraphim_service()
.await
.map_err(TerraphimMcpError::Anyhow)?;
let document = service
.get_document_by_id(&doc_id)
.await
.map_err(TerraphimMcpError::Service)?;
if let Some(doc) = document {
let contents = self
.resource_mapper
.document_to_resource_contents(&doc)
.map_err(TerraphimMcpError::Anyhow)?;
Ok(ReadResourceResult {
contents: vec![contents],
})
} else {
Err(ErrorData::resource_not_found(
format!("Document not found: {}", doc_id),
None,
))
}
}
fn get_info(&self) -> ServerInfo {
ServerInfo {
server_info: rmcp::model::Implementation {
name: "terraphim-mcp".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
title: Some("Terraphim MCP Server".to_string()),
icons: None,
website_url: None,
},
instructions: Some("This server provides Terraphim knowledge graph search capabilities through the Model Context Protocol. You can search for documents using the search tool and access resources that represent Terraphim documents.".to_string()),
..Default::default()
}
}
}