From 63cb942009b010765e5666abcb9920f5cf0b1192 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 Oct 2026 08:56:07 +0100 Subject: [PATCH] fix: retrieve authored MCP synonyms and bind role indexes (Fixes #62, #63) --- .github/workflows/ci.yml | 2 + crates/terraphim_mcp_server/src/lib.rs | 584 ++++++++++++------ .../tests/integration_test.rs | 11 +- .../tests/mcp_autocomplete_e2e_test.rs | 162 ----- .../tests/test_mcp_role_contract.rs | 554 +++++++++++++++++ .../tests/test_mcp_stdio.rs | 199 ------ docs/mcp/role-search.md | 43 ++ 7 files changed, 996 insertions(+), 559 deletions(-) create mode 100644 crates/terraphim_mcp_server/tests/test_mcp_role_contract.rs delete mode 100644 crates/terraphim_mcp_server/tests/test_mcp_stdio.rs create mode 100644 docs/mcp/role-search.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 776d854a..664218ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,8 @@ jobs: # Exercise the released grep command against real files and exported # synonym graphs, including Linux-only hostile filesystem names. - run: cargo test --locked -p terraphim_grep --test synonym_retrieval --test answer_request --test path_guard_hostile_tree + - name: Verify authored synonyms and role boundaries over real MCP stdio + run: cargo test --locked -p terraphim_mcp_server --test test_mcp_role_contract # GitHub-side home for the updater integration contracts (managed-mode # receipt/refusal, strict v2 manifest readers, R2 checksum fail-closed). # On Gitea these lanes live in .gitea/workflows/native-ci.yml, which is diff --git a/crates/terraphim_mcp_server/src/lib.rs b/crates/terraphim_mcp_server/src/lib.rs index aef05605..084cb64d 100644 --- a/crates/terraphim_mcp_server/src/lib.rs +++ b/crates/terraphim_mcp_server/src/lib.rs @@ -29,14 +29,16 @@ use rmcp::{ }, service::RequestContext, }; -use std::sync::Arc; +use std::{collections::HashMap, 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_config::{Config, ConfigState, Role}; use terraphim_file_search::kg_scorer::KgPathScorer; use terraphim_service::TerraphimService; -use terraphim_types::{Layer, NormalizedTermValue, RoleName, SearchQuery}; +use terraphim_types::{ + Document, Layer, NormalizedTermValue, RelevanceFunction, RoleName, SearchQuery, +}; use thiserror::Error; use tracing::{error, info}; @@ -358,12 +360,50 @@ fn reject_hostile_tree(base: &std::path::Path) -> Result<(), String> { Ok(()) } +// RoleGraph keys documents by ID. Use a temporary composite key during union +// ranking so equal source IDs from different haystacks cannot overwrite each other. +fn rank_expanded_documents( + graph: &mut terraphim_rolegraph::RoleGraph, + canonical: &str, + documents: &mut [Document], +) -> Result<()> { + documents.sort_by(|a, b| { + a.source_haystack + .cmp(&b.source_haystack) + .then_with(|| a.id.cmp(&b.id)) + }); + let keys: Vec<_> = documents + .iter() + .map(|document| serde_json::to_string(&(&document.source_haystack, &document.id))) + .collect::>()?; + for (key, document) in keys.iter().zip(documents.iter()) { + graph.insert_document(key, document.clone()); + } + let ranks: HashMap<_, _> = graph + .query_graph(canonical, None, None)? + .into_iter() + .map(|(id, document)| (id, document.rank)) + .collect(); + for (key, document) in keys.iter().zip(documents.iter_mut()) { + document.rank = Some(ranks.get(key).copied().unwrap_or(0)); + } + Ok(()) +} + +// A deserialised index has no role provenance. Keep it available to the explicit +// index operations without allowing it to impersonate a configured role. +#[derive(Default)] +struct AutocompleteState { + active: Option>, + roles: HashMap)>, +} + /// The main service type for the Terraphim MCP server #[derive(Clone)] pub struct McpService { config_state: Arc, resource_mapper: Arc, - autocomplete_index: Arc>>, + autocomplete_index: Arc>, /// Optional KG scorer for boosting file search results by path concept matches. kg_scorer: Option>, /// Optional persistent frecency tracker (LMDB-backed) for access-frequency scoring. @@ -390,7 +430,7 @@ impl McpService { Self { config_state, resource_mapper: Arc::new(TerraphimResourceMapper::new()), - autocomplete_index: Arc::new(tokio::sync::RwLock::new(None)), + autocomplete_index: Arc::new(tokio::sync::RwLock::new(AutocompleteState::default())), kg_scorer: None, frecency, } @@ -443,6 +483,7 @@ impl McpService { let config = self.config_state.config.clone(); let mut current_config = config.lock().await; *current_config = new_config; + *self.autocomplete_index.write().await = AutocompleteState::default(); Ok(()) } @@ -485,19 +526,16 @@ impl McpService { (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, - }; + if limit.is_some_and(|value| value < 0) || skip.is_some_and(|value| value < 0) { + return Ok(CallToolResult::error(vec![Content::text( + "Search limit and skip must be non-negative", + )])); + } - match service.search(&search_query).await { + match self + .search_role_documents(&mut service, &query, &role_name, limit, skip) + .await + { Ok(documents) => { let mut contents = Vec::new(); if let Some(ref ar) = auto { @@ -534,6 +572,141 @@ impl McpService { } } + // Expand only exact authored concepts, using the same normalised-term + // identity as terraphim-grep. Free-text and compound query syntax is unchanged. + async fn search_role_documents( + &self, + service: &mut TerraphimService, + query: &str, + role_name: &RoleName, + limit: Option, + skip: Option, + ) -> Result> { + let role = + self.config_state.get_role(role_name).await.ok_or_else(|| { + anyhow::anyhow!("Role '{}' not found in configuration", role_name) + })?; + let mut terms = vec![query.to_owned()]; + let mut expansion_graph = None; + if role.relevance_function == RelevanceFunction::TerraphimGraph { + let thesaurus = service.ensure_thesaurus_loaded(role_name).await?; + let normalised_query = NormalizedTermValue::from(query.trim()); + if let Some(matched) = find_matches(query.trim(), &thesaurus, false)? + .into_iter() + .find(|matched| { + NormalizedTermValue::from(matched.term.as_str()) == normalised_query + }) + { + let canonical = matched.normalized_term.value; + terms.clear(); + terms.extend( + (&thesaurus) + .into_iter() + .filter(|(_, term)| term.value == canonical) + .map(|(alias, _)| alias.to_string()), + ); + terms.push(canonical.to_string()); + terms.sort(); + terms.dedup(); + expansion_graph = Some(( + terraphim_rolegraph::RoleGraph::new_sync(role_name.clone(), thesaurus)?, + canonical, + )); + } + } + if expansion_graph.is_none() { + // Preserve existing pagination and ordering for non-KG/free-text + // queries instead of widening their underlying retrieval. + return Ok(service + .search(&SearchQuery { + search_term: NormalizedTermValue::from(query), + search_terms: None, + operator: None, + role: Some(role_name.clone()), + limit: limit.map(|value| value as usize), + skip: skip.map(|value| value as usize), + layer: Layer::default(), + include_pinned: false, + min_quality: None, + }) + .await?); + } + let expanded = terms.len() > 1; + let mut documents = HashMap::new(); + for term in terms { + let search_query = SearchQuery { + search_term: NormalizedTermValue::from(term), + search_terms: None, + operator: None, + role: Some(role_name.clone()), + // Apply pagination once to the merged, deduplicated documents. + limit: None, + skip: None, + layer: Layer::default(), + include_pinned: false, + min_quality: None, + }; + for document in service.search(&search_query).await? { + let key = (document.source_haystack.clone(), document.id.clone()); + let entry = documents.entry(key).or_insert_with(|| document.clone()); + if document.rank > entry.rank { + *entry = document; + } + } + } + let mut documents: Vec = documents.into_values().collect(); + if let Some((mut graph, canonical)) = expansion_graph { + // Per-query ranks describe different haystacks and cannot be merged + // as scores. Rank the union once with the existing RoleGraph engine. + // Insert in identity order to keep repeated calls deterministic. + rank_expanded_documents(&mut graph, canonical.as_str(), &mut documents)?; + } + // A stable identity tie-breaker makes synonym and canonical pagination + // agree, independent of the haystack/index map's iteration order. + documents.sort_by(|a, b| { + b.rank + .cmp(&a.rank) + .then_with(|| a.id.cmp(&b.id)) + .then_with(|| a.source_haystack.cmp(&b.source_haystack)) + }); + tracing::debug!( + expanded, + documents = documents.len(), + "Resolved MCP search documents" + ); + Ok(documents + .into_iter() + .skip(skip.unwrap_or(0) as usize) + .take(limit.map_or(usize::MAX, |value| value as usize)) + .collect()) + } + + async fn role_autocomplete_index( + &self, + role: Option, + ) -> Result<(RoleName, Arc), CallToolResult> { + // Lock order is config then index, also used by update/build. A request + // receives one coherent role snapshot rather than another role's cache. + let config = self.config_state.config.lock().await; + let name = role + .map(RoleName::from) + .unwrap_or_else(|| config.selected_role.clone()); + let Some(role) = config.roles.get(&name) else { + return Err(CallToolResult::error(vec![Content::text(format!( + "Role '{}' not found in configuration", + name + ))])); + }; + let state = self.autocomplete_index.read().await; + match state.roles.get(&name) { + Some((built_role, index)) if built_role == role => Ok((name, Arc::clone(index))), + _ => Err(CallToolResult::error(vec![Content::text(format!( + "Autocomplete index not built for role '{}'. Run 'build_autocomplete_index' for this role first.", + name + ))])), + } + } + /// Update the Terraphim configuration pub async fn update_config_tool( &self, @@ -565,11 +738,6 @@ impl McpService { &self, role: Option, ) -> Result { - let mut service = self - .terraphim_service() - .await - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - // Determine which role to use (provided role or selected role) let role_name = if let Some(role_str) = role { RoleName::from(role_str) @@ -579,7 +747,7 @@ impl McpService { // Check if the role exists and has proper knowledge graph configuration let role_config = self.config_state.get_role(&role_name).await; - if let Some(role_cfg) = role_config { + if let Some(ref role_cfg) = role_config { // Check if role uses TerraphimGraph relevance function (required for knowledge graph) if role_cfg.relevance_function != terraphim_types::RelevanceFunction::TerraphimGraph { let error_content = Content::text(format!( @@ -612,6 +780,11 @@ impl McpService { return Ok(CallToolResult::error(vec![error_content])); } + let mut service = self + .terraphim_service() + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + // Load thesaurus for the role match service.ensure_thesaurus_loaded(&role_name).await { Ok(thesaurus_data) => { @@ -635,9 +808,19 @@ impl McpService { Some(config), ) { Ok(index) => { - // Store the index with role context - let mut autocomplete_lock = self.autocomplete_index.write().await; - *autocomplete_lock = Some(index); + let config = self.config_state.config.lock().await; + if config.roles.get(&role_name) != role_config.as_ref() { + return Ok(CallToolResult::error(vec![Content::text( + "Role configuration changed while building the index; rebuild it", + )])); + } + let index = Arc::new(index); + let mut state = self.autocomplete_index.write().await; + state.roles.insert( + role_name.clone(), + (role_config.unwrap(), Arc::clone(&index)), + ); + state.active = Some(index); let content = Content::text(format!( "Autocomplete index built successfully with {} terms for role '{}'", @@ -672,89 +855,71 @@ impl McpService { limit: Option, role: Option, ) -> Result { - // Determine which role to use (provided role or selected role) - let _role_name = if let Some(role_str) = role { - RoleName::from(role_str) - } else { - self.config_state.get_selected_role().await + let (_, index) = match self.role_autocomplete_index(role).await { + Ok(value) => value, + Err(result) => return Ok(result), }; - - // Check if we need to rebuild the autocomplete index for this role - // For now, we'll use the existing index, but in the future we could implement - // role-specific indices or rebuild when the role changes - 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 = Vec::new(); - // Prefer exact/prefix first - if let Ok(mut exact) = - terraphim_automata::autocomplete_search(index, &query, Some(max_results)) + let max_results = limit.unwrap_or(10); + let mut combined: Vec = Vec::new(); + // Prefer exact/prefix first + if let Ok(mut exact) = + terraphim_automata::autocomplete_search(&index, &query, Some(max_results)) + { + combined.append(&mut exact); + } + // Fill remaining with fuzzy + 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 exact); - } - // Fill remaining with fuzzy - 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); + combined.append(&mut fuzzy); + } + } + // Graph embeddings-style expansion: include additional synonyms sharing the same concept id + if combined.len() < max_results { + use std::collections::HashSet; + let mut seen_terms: HashSet = combined.iter().map(|r| r.term.clone()).collect(); + let concept_ids: HashSet = combined.iter().map(|r| r.id).collect(); + let mut candidates: Vec = Vec::new(); + // Iterate over all metadata and pick terms with same concept id + 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, + }); } } - // Graph embeddings-style expansion: include additional synonyms sharing the same concept id - if combined.len() < max_results { - use std::collections::HashSet; - let mut seen_terms: HashSet = - combined.iter().map(|r| r.term.clone()).collect(); - let concept_ids: HashSet = combined.iter().map(|r| r.id).collect(); - let mut candidates: Vec = Vec::new(); - // Iterate over all metadata and pick terms with same concept id - 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, - }); - } + // Prefer shorter terms, stable order + 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; } - // Prefer shorter terms, stable order - 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); - } + 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])) + 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)); + } + Ok(CallToolResult::success(contents)) } /// Autocomplete with snippets: returns term and a short snippet (stub/description) when available @@ -764,87 +929,62 @@ impl McpService { limit: Option, role: Option, ) -> Result { - // Determine which role to use (provided role or selected role) - let _role_name = if let Some(role_str) = role { - RoleName::from(role_str) - } else { - self.config_state.get_selected_role().await + let (role_name, index) = match self.role_autocomplete_index(role).await { + Ok(value) => value, + Err(result) => return Ok(result), }; - - // We only need the terms from automata index, snippets will be pulled from documents when possible - // For now, we'll use the existing index, but in the future we could implement - // role-specific indices or rebuild when the role changes - 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 = Vec::new(); - if let Ok(mut prefix) = - terraphim_automata::autocomplete_search(index, &query, Some(max_results)) + let max_results = limit.unwrap_or(10); + let mut results: Vec = 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 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); - } + results.append(&mut fuzzy); } + } - // Build snippets by searching for each term to fetch a related document and use its stub/description/body excerpt - let mut service = self - .terraphim_service() + // Build snippets by searching for each term to fetch a related document and use its stub/description/body excerpt + 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 snippet = match self + .search_role_documents(&mut service, &r.term, &role_name, Some(1), Some(0)) .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::() - } + { + 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::new(), - }; - let line = if snippet.is_empty() { - r.term - } else { - format!("{} โ€” {}", r.term, snippet) - }; - contents.push(Content::text(line)); - } - return Ok(CallToolResult::success(contents)); + } + _ => String::new(), + }; + let line = if snippet.is_empty() { + r.term + } else { + format!("{} โ€” {}", r.term, snippet) + }; + contents.push(Content::text(line)); } - let error_content = Content::text( - "Autocomplete index not built. Please run 'build_autocomplete_index' first." - .to_string(), - ); - Ok(CallToolResult::error(vec![error_content])) + Ok(CallToolResult::success(contents)) } /// Fuzzy autocomplete search using Jaro-Winkler similarity (default, faster) @@ -856,7 +996,7 @@ impl McpService { ) -> Result { let autocomplete_lock = self.autocomplete_index.read().await; - if let Some(ref index) = *autocomplete_lock { + if let Some(index) = autocomplete_lock.active.as_deref() { let min_similarity = similarity.unwrap_or(0.6); let max_results = limit.unwrap_or(10); @@ -906,7 +1046,7 @@ impl McpService { ) -> Result { let autocomplete_lock = self.autocomplete_index.read().await; - if let Some(ref index) = *autocomplete_lock { + if let Some(index) = autocomplete_lock.active.as_deref() { let max_distance = max_edit_distance.unwrap_or(2); let max_results = limit.unwrap_or(10); @@ -957,7 +1097,7 @@ impl McpService { ) -> Result { let autocomplete_lock = self.autocomplete_index.read().await; - if let Some(ref index) = *autocomplete_lock { + if let Some(index) = autocomplete_lock.active.as_deref() { let min_similarity = similarity.unwrap_or(0.6); let max_results = limit.unwrap_or(10); @@ -1003,7 +1143,7 @@ impl McpService { pub async fn serialize_autocomplete_index(&self) -> Result { let autocomplete_lock = self.autocomplete_index.read().await; - if let Some(ref index) = *autocomplete_lock { + if let Some(index) = autocomplete_lock.active.as_deref() { match terraphim_automata::serialize_autocomplete_index(index) { Ok(bytes) => { let mut contents = Vec::new(); @@ -1053,12 +1193,13 @@ impl McpService { Ok(index) => { // Store the deserialized index let mut autocomplete_lock = self.autocomplete_index.write().await; - *autocomplete_lock = Some(index); + let count = index.len(); + autocomplete_lock.active = Some(Arc::new(index)); let mut contents = Vec::new(); contents.push(Content::text(format!( "Successfully deserialized autocomplete index with {} terms", - autocomplete_lock.as_ref().unwrap().len() + count ))); Ok(CallToolResult::success(contents)) @@ -2027,11 +2168,15 @@ impl ServerHandler for McpService { }, "limit": { "type": "integer", - "description": "Maximum number of results to return" + "description": "Maximum number of deduplicated results to return", + "minimum": 0, + "maximum": 2147483647 }, "skip": { "type": "integer", - "description": "Number of results to skip" + "description": "Number of deduplicated results to skip", + "minimum": 0, + "maximum": 2147483647 } }, "required": ["query"] @@ -2199,7 +2344,7 @@ impl ServerHandler for McpService { Tool { name: "search".into(), title: Some("Search Knowledge Graph".into()), - description: Some("Search for documents in Terraphim knowledge graph".into()), + description: Some("Search documents in the selected role. Exact authored KG concepts expand to their synonyms; compound queries retain their existing semantics. Pagination applies after deduplication.".into()), input_schema: Arc::new(search_map), output_schema: None, annotations: None, @@ -2680,15 +2825,28 @@ impl ServerHandler for McpService { .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); + let parse_page = |name: &str| -> Result, ErrorData> { + arguments + .get(name) + .map(|value| { + value + .as_i64() + .and_then(|number| i32::try_from(number).ok()) + .filter(|number| *number >= 0) + .ok_or_else(|| { + ErrorData::invalid_params( + format!( + "'{name}' must be an integer from 0 to {}", + i32::MAX + ), + None, + ) + }) + }) + .transpose() + }; + let limit = parse_page("limit")?; + let skip = parse_page("skip")?; self.search(query, role, limit, skip) .await @@ -3253,3 +3411,43 @@ impl ServerHandler for McpService { } } } + +#[cfg(test)] +mod synonym_ranking_tests { + use super::*; + use terraphim_types::{NormalizedTerm, Thesaurus}; + + #[test] + fn same_document_id_in_different_haystacks_keeps_distinct_graph_ranks() -> Result<()> { + let mut thesaurus = Thesaurus::new("concepts".into()); + for term in ["risk", "concern"] { + thesaurus.insert( + NormalizedTermValue::from(term), + NormalizedTerm::new(1, NormalizedTermValue::from("risk")), + ); + } + let mut graph = + terraphim_rolegraph::RoleGraph::new_sync(RoleName::from("project"), thesaurus)?; + let mut documents = vec![ + Document { + id: "same-id".into(), + source_haystack: Some("one".into()), + body: "Risk Concern Risk".into(), + ..Default::default() + }, + Document { + id: "same-id".into(), + source_haystack: Some("two".into()), + body: "Risk".into(), + ..Default::default() + }, + ]; + rank_expanded_documents(&mut graph, "risk", &mut documents)?; + assert_eq!(graph.get_document_count(), 2); + assert!(documents[0].rank > documents[1].rank); + assert_eq!(documents[1].rank, Some(0)); + assert_eq!(documents[0].id, "same-id"); + assert_eq!(documents[1].id, "same-id"); + Ok(()) + } +} diff --git a/crates/terraphim_mcp_server/tests/integration_test.rs b/crates/terraphim_mcp_server/tests/integration_test.rs index c550f3db..c9c2acf2 100644 --- a/crates/terraphim_mcp_server/tests/integration_test.rs +++ b/crates/terraphim_mcp_server/tests/integration_test.rs @@ -461,7 +461,7 @@ async fn test_search_invalid_pagination_params() -> Result<()> { let transport = TokioChildProcess::new(command)?; let service = ().serve(transport).await?; - // Negative limit โ€“ server may coerce or error; ensure it does not crash. + // Negative pagination is a protocol error, before any retrieval occurs. let res = service .call_tool(CallToolRequestParam { name: "search".into(), @@ -472,10 +472,10 @@ async fn test_search_invalid_pagination_params() -> Result<()> { .as_object() .cloned(), }) - .await?; - assert!(res.is_error.unwrap_or(false) || !res.content.is_empty()); + .await; + assert!(res.is_err(), "negative limit must be rejected: {res:?}"); - // Excessive limit should error + // A large in-range limit remains valid and does not terminate the server. let res2 = service .call_tool(CallToolRequestParam { name: "search".into(), @@ -487,7 +487,8 @@ async fn test_search_invalid_pagination_params() -> Result<()> { .cloned(), }) .await?; - assert!(res2.is_error.unwrap_or(false) || !res2.content.is_empty()); + assert!(!res2.is_error.unwrap_or(false)); + assert!(!res2.content.is_empty()); Ok(()) } diff --git a/crates/terraphim_mcp_server/tests/mcp_autocomplete_e2e_test.rs b/crates/terraphim_mcp_server/tests/mcp_autocomplete_e2e_test.rs index 69f0c27d..73a0d45b 100644 --- a/crates/terraphim_mcp_server/tests/mcp_autocomplete_e2e_test.rs +++ b/crates/terraphim_mcp_server/tests/mcp_autocomplete_e2e_test.rs @@ -123,11 +123,6 @@ async fn create_autocomplete_test_config() -> Result { async fn start_mcp_server() -> Result { let mut cmd = Command::new(env!("CARGO_BIN_EXE_terraphim_mcp_server")); - if std::env::var_os("CI").is_some() { - cmd.arg("--features").arg("zlob"); - cmd.arg("--release"); - } - cmd.stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); @@ -190,106 +185,6 @@ async fn test_build_autocomplete_index_terraphim_engineer() -> Result<()> { Ok(()) } -/// Test fuzzy autocomplete search using Jaro-Winkler algorithm with knowledge graph terms -#[tokio::test] -#[serial] -#[ignore = "Test expects specific autocomplete results that may not be available"] -async fn test_fuzzy_autocomplete_search_kg_terms() -> Result<()> { - println!("๐Ÿงช Testing fuzzy autocomplete search with knowledge graph terms"); - - let config_json = create_autocomplete_test_config().await?; - let transport = start_mcp_server().await?; - let service = ().serve(transport).await?; - - // Update configuration - let update_config_args = json!({"config_str": config_json}); - let _update_result = service - .call_tool(CallToolRequestParam { - name: "update_config_tool".into(), - arguments: Some(update_config_args.as_object().unwrap().clone()), - }) - .await?; - - // Build autocomplete index - let build_index_args = json!({"role": "Terraphim Engineer"}); - let build_result = service - .call_tool(CallToolRequestParam { - name: "build_autocomplete_index".into(), - arguments: Some(build_index_args.as_object().unwrap().clone()), - }) - .await?; - - assert!(build_result.is_error != Some(true)); - - // Test various knowledge graph terms - let test_queries = vec![ - ("terrapi", vec!["terraphim-graph"]), // Partial match - ("graph", vec!["terraphim-graph", "graph embeddings"]), // Common term - ("embedd", vec!["graph embeddings"]), // Partial embedding - ("hayst", vec!["haystack"]), // Haystack term - ("servic", vec!["service"]), // Service term - ("machine", vec![]), // Should not match KG terms - ]; - - for (query, expected_terms) in test_queries { - println!("๐Ÿ” Testing query: '{}'", query); - - let search_args = json!({ - "query": query, - "similarity": 0.6, - "limit": 10 - }); - - let search_result = service - .call_tool(CallToolRequestParam { - name: "fuzzy_autocomplete_search".into(), - arguments: Some(search_args.as_object().unwrap().clone()), - }) - .await?; - - println!("Search result for '{}': {:?}", query, search_result); - assert!(search_result.is_error != Some(true)); - - // Check if expected terms are found - if let Some(summary_content) = search_result.content.first() { - let summary_text = summary_content.as_text().unwrap().text.clone(); - println!("Summary: {}", summary_text); - - // For terms that should match, verify suggestions are returned - if !expected_terms.is_empty() { - assert!(summary_text.contains("Found")); - assert!(!summary_text.contains("Found 0")); - - // Check individual suggestions - for (i, content) in search_result.content.iter().skip(1).enumerate() { - let suggestion_text = content.as_text().unwrap().text.clone(); - println!("Suggestion {}: {}", i + 1, suggestion_text); - - // Verify suggestions contain expected terms - let suggestion_contains_expected = expected_terms.iter().any(|expected| { - suggestion_text - .to_lowercase() - .contains(&expected.to_lowercase()) - }); - - if !suggestion_contains_expected { - println!( - "โš ๏ธ Suggestion '{}' doesn't contain expected terms: {:?}", - suggestion_text, expected_terms - ); - } - } - } else { - // For queries that shouldn't match, verify few or no results - println!("Query '{}' expected to have no matches", query); - } - } - } - - println!("โœ… Fuzzy autocomplete search testing completed"); - Ok(()) -} - /// Test Levenshtein autocomplete search for comparison #[tokio::test] #[serial] @@ -456,63 +351,6 @@ async fn test_autocomplete_algorithm_comparison() -> Result<()> { Ok(()) } -/// Test new autocomplete_terms tool (prefix + fuzzy) -#[tokio::test] -#[serial] -#[ignore = "Test expects specific autocomplete results that may not be available"] -async fn test_autocomplete_terms_tool() -> Result<()> { - let config_json = create_autocomplete_test_config().await?; - let transport = start_mcp_server().await?; - let service = ().serve(transport).await?; - - // Update configuration - let update_config_args = json!({"config_str": config_json}); - service - .call_tool(CallToolRequestParam { - name: "update_config_tool".into(), - arguments: Some(update_config_args.as_object().unwrap().clone()), - }) - .await?; - - // Build index - let build_index_args = json!({"role": "Terraphim Engineer"}); - let build_result = service - .call_tool(CallToolRequestParam { - name: "build_autocomplete_index".into(), - arguments: Some(build_index_args.as_object().unwrap().clone()), - }) - .await?; - assert!(build_result.is_error != Some(true)); - - // Call autocomplete_terms: expect graph expansion to include synonyms of same concept id - let ac_args = json!({"query": "terraph", "limit": 8}); - let ac_result = service - .call_tool(CallToolRequestParam { - name: "autocomplete_terms".into(), - arguments: Some(ac_args.as_object().unwrap().clone()), - }) - .await?; - assert!(ac_result.is_error != Some(true)); - if let Some(summary) = ac_result.content.first() { - let text = summary.as_text().unwrap().text.clone(); - assert!(text.contains("Found")); - } - // Verify that related terms like "graph" or "graph embeddings" appear via concept expansion - let all_texts: Vec = ac_result - .content - .iter() - .skip(1) - .filter_map(|c| c.as_text().map(|t| t.text.clone())) - .collect(); - let has_graph_related = all_texts.iter().any(|t| t.to_lowercase().contains("graph")); - assert!( - has_graph_related, - "Autocomplete should include graph-related synonyms via concept expansion: {:?}", - all_texts - ); - Ok(()) -} - /// Test new autocomplete_with_snippets tool #[tokio::test] #[serial] diff --git a/crates/terraphim_mcp_server/tests/test_mcp_role_contract.rs b/crates/terraphim_mcp_server/tests/test_mcp_role_contract.rs new file mode 100644 index 00000000..e84c247e --- /dev/null +++ b/crates/terraphim_mcp_server/tests/test_mcp_role_contract.rs @@ -0,0 +1,554 @@ +//! Real stdio regressions for authored synonyms and role-bound autocomplete. +use anyhow::{Context, Result}; +use rmcp::{ + RoleClient, + model::CallToolRequestParam, + service::{RunningService, ServiceExt}, + transport::TokioChildProcess, +}; +use serde_json::{Value, json}; +use std::{collections::BTreeMap, fs, time::Duration}; +use tempfile::TempDir; +use tokio::process::Command; + +struct Fixture { + root: TempDir, + client: RunningService, +} +impl Fixture { + async fn start() -> Result { + let root = tempfile::tempdir()?; + let project = root.path().join("project with spaces"); + let settings = root.path().join("settings"); + fs::create_dir_all(project.join(".terraphim"))?; + fs::create_dir_all(&settings)?; + let mut roles = serde_json::Map::new(); + for (name, canonical, alias) in [ + ("project", "Risk", "Concern"), + ("garden", "Orchid", "Flower"), + ] { + let concepts = project.join(name).join("concepts"); + let corpus = project.join(name).join("corpus"); + fs::create_dir_all(&concepts)?; + fs::create_dir_all(&corpus)?; + fs::write( + concepts.join(format!("{canonical}.md")), + format!("# {canonical}\nsynonyms:: {alias}\n"), + )?; + for (i, suffix) in [ + "requires a decision.", + "requires a mitigation.", + "requires a review.", + ] + .iter() + .enumerate() + { + fs::write( + corpus.join(format!("decision-{i}.md")), + format!("{canonical} {suffix}\n"), + )?; + } + roles.insert(name.into(), json!({"name":name,"relevance_function":"terraphim-graph","terraphim_it":false,"theme":"lumen","kg":{"automata_path":null,"knowledge_graph_local":{"input_type":"markdown","path":concepts},"public":false,"publish":false},"haystacks":[{"location":corpus,"service":"Ripgrep","read_only":true,"extra_parameters":{}}],"extra":{}})); + } + // Replace the profile's remote-backed defaults with local, empty roles. + for name in ["Default", "Engineer", "System Operator"] { + roles.insert(name.into(), json!({"name":name,"relevance_function":"title-scorer","terraphim_it":false,"theme":"lumen","kg":null,"haystacks":[],"extra":{}})); + } + fs::write( + project.join(".terraphim/config.json"), + serde_json::to_vec_pretty( + &json!({"selected_role":"project","default_role":"project","roles":roles}), + )?, + )?; + fs::write( + settings.join("settings.toml"), + format!( + "server_hostname = \"127.0.0.1:8000\"\napi_endpoint = \"http://localhost:8000/api\"\ninitialized = true\ndefault_data_path = {}\n[profiles.memory]\ntype = \"memory\"\n", + serde_json::to_string(&root.path().join("data"))? + ), + )?; + let binary = std::env::var_os("TERRAPHIM_MCP_SERVER_BIN") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| env!("CARGO_BIN_EXE_terraphim_mcp_server").into()); + let mut command = Command::new(binary); + command + .args(["--profile", "server", "--config-dir"]) + .arg(&project) + .current_dir(&project) + .kill_on_drop(true); + // No HOME override or inherited credentials. Preserve only executable discovery. + let path = std::env::var_os("PATH").context("PATH")?; + command + .env_clear() + .env("PATH", path) + .env("TERRAPHIM_SETTINGS_PATH", &settings) + .env("TERRAPHIM_DATA_PATH", root.path().join("data")); + if let Some(home) = std::env::var_os("HOME") { + command.env("HOME", home); + } + let client = tokio::time::timeout( + Duration::from_secs(20), + ().serve(TokioChildProcess::new(command)?), + ) + .await??; + Ok(Self { root, client }) + } + async fn call(&self, name: &str, args: Value, evidence: &str) -> Result { + let result = tokio::time::timeout( + Duration::from_secs(20), + self.client.call_tool(CallToolRequestParam { + name: name.to_owned().into(), + arguments: args.as_object().cloned(), + }), + ) + .await??; + let value = serde_json::to_value(result)?; + if let Some(dir) = std::env::var_os("TERRAPHIM_MCP_TEST_EVIDENCE") { + fs::create_dir_all(&dir)?; + fs::write( + std::path::Path::new(&dir).join(format!("{evidence}.json")), + serde_json::to_vec_pretty(&json!({"tool":name,"arguments":args,"result":value}))?, + )?; + } + Ok(value) + } +} +fn resources(value: &Value) -> BTreeMap { + value["content"] + .as_array() + .unwrap() + .iter() + .filter_map(|item| { + let resource = item.get("resource")?; + Some(( + resource["uri"].as_str()?.to_owned(), + resource["text"].as_str()?.to_owned(), + )) + }) + .collect() +} +fn texts(value: &Value) -> String { + value["content"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v["text"].as_str()) + .collect::>() + .join("\n") +} + +#[tokio::test] +async fn authored_synonym_retrieves_actual_canonical_documents() -> Result<()> { + let f = Fixture::start().await?; + let canonical = f + .call( + "search", + json!({"query":"Risk","role":"project","limit":20}), + "synonym-canonical", + ) + .await?; + let synonym = f + .call( + "search", + json!({"query":"Concern","role":"project","limit":20}), + "synonym-alias", + ) + .await?; + let expected = resources(&canonical); + assert_eq!(expected.len(), 3, "canonical retrieval: {canonical}"); + for text in expected.values() { + assert!(text.contains("Risk requires")); + assert!(!text.contains("Concern")); + } + assert_eq!( + resources(&synonym), + expected, + "authored synonym must retrieve the same saved documents: {synonym}" + ); + let baseline = f + .call( + "search", + json!({"query":"Unrelated","role":"project"}), + "synonym-unrelated", + ) + .await?; + assert!(resources(&baseline).is_empty()); + // Keep the fixture alive until the child is cancelled. + assert!(f.root.path().exists()); + f.client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn autocomplete_refuses_unknown_roles() -> Result<()> { + let f = Fixture::start().await?; + f.call( + "build_autocomplete_index", + json!({"role":"project"}), + "unknown-built", + ) + .await?; + for tool in ["autocomplete_terms", "autocomplete_with_snippets"] { + let value = f + .call( + tool, + json!({"query":"Con","role":"missing-role","limit":8}), + &format!("unknown-{tool}"), + ) + .await?; + assert_eq!( + value["isError"], true, + "unknown role must not use another role's index: {value}" + ); + assert!(!texts(&value).contains("\nConcern")); + } + f.client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn autocomplete_retains_disjoint_role_indexes_and_selected_role() -> Result<()> { + let f = Fixture::start().await?; + for role in ["project", "garden"] { + let result = f + .call( + "build_autocomplete_index", + json!({"role":role}), + &format!("switch-build-{role}"), + ) + .await?; + assert_ne!(result["isError"], true, "{result}"); + } + for (role, query, expected, forbidden) in [ + ("project", "Con", "Concern", "Flower"), + ("garden", "Flo", "Flower", "Concern"), + ("project", "Con", "Concern", "Flower"), + ] { + for tool in ["autocomplete_terms", "autocomplete_with_snippets"] { + let result = f + .call( + tool, + json!({"query":query,"role":role,"limit":8}), + &format!("switch-{role}-{tool}"), + ) + .await?; + assert_ne!(result["isError"], true, "{result}"); + assert!( + texts(&result) + .to_lowercase() + .contains(&expected.to_lowercase()), + "{result}" + ); + assert!( + !texts(&result) + .to_lowercase() + .contains(&forbidden.to_lowercase()), + "{result}" + ); + } + } + let selected = f + .call( + "autocomplete_terms", + json!({"query":"Con","limit":8}), + "switch-selected", + ) + .await?; + assert!( + texts(&selected).contains("concern"), + "omitted role must use selected project role: {selected}" + ); + f.client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn synonym_union_deduplicates_before_pagination() -> Result<()> { + let f = Fixture::start().await?; + fs::write( + f.root + .path() + .join("project with spaces/project/corpus/both.md"), + "Risk and Concern refer to the same event.\n", + )?; + let all = f + .call( + "search", + json!({"query":"Concern","role":"project"}), + "pages-all", + ) + .await?; + assert_eq!(resources(&all).len(), 4, "{all}"); + let mut pages = BTreeMap::new(); + for skip in 0..4 { + let alias = f + .call( + "search", + json!({"query":"Concern","role":"project","limit":1,"skip":skip}), + &format!("pages-alias-{skip}"), + ) + .await?; + let canonical = f + .call( + "search", + json!({"query":"Risk","role":"project","limit":1,"skip":skip}), + &format!("pages-canonical-{skip}"), + ) + .await?; + assert_eq!(resources(&alias), resources(&canonical)); + assert_eq!(resources(&alias).len(), 1); + pages.extend(resources(&alias)); + } + assert_eq!(pages, resources(&all)); + for (name, args) in [ + ( + "zero", + json!({"query":"Concern","role":"project","limit":0}), + ), + ( + "exhausted", + json!({"query":"Concern","role":"project","skip":4}), + ), + ( + "substring", + json!({"query":"UnrelatedConcern","role":"project"}), + ), + ( + "compound", + json!({"query":"Concern requires","role":"project"}), + ), + ] { + let result = f.call("search", args, &format!("pages-{name}")).await?; + assert!(resources(&result).is_empty(), "{name}: {result}"); + } + for args in [ + json!({"query":"Concern","role":"project","limit":-1}), + json!({"query":"Concern","role":"project","skip":-1}), + json!({"query":"Concern","role":"missing-role"}), + ] { + let result = f + .client + .call_tool(CallToolRequestParam { + name: "search".into(), + arguments: args.as_object().cloned(), + }) + .await; + if args["role"] == "missing-role" { + assert_eq!(result?.is_error, Some(true)); + } else { + assert!( + result.is_err(), + "invalid pagination must fail before execution" + ); + } + } + f.client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn autocomplete_snippets_use_the_requested_role_haystack() -> Result<()> { + let f = Fixture::start().await?; + f.call( + "build_autocomplete_index", + json!({"role":"garden"}), + "snippet-build", + ) + .await?; + let result = f + .call( + "autocomplete_with_snippets", + json!({"query":"Flo","role":"garden","limit":2}), + "snippet-garden", + ) + .await?; + assert_ne!(result["isError"], true, "{result}"); + assert!(texts(&result).contains("Orchid requires"), "{result}"); + assert!(!texts(&result).contains("Risk requires"), "{result}"); + f.client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn deserialised_index_cannot_impersonate_a_role_and_config_changes_invalidate_cache() +-> Result<()> { + let f = Fixture::start().await?; + for role in ["project", "garden"] { + f.call( + "build_autocomplete_index", + json!({"role":role}), + &format!("invalidate-build-{role}"), + ) + .await?; + } + let serialised = f + .call( + "serialize_autocomplete_index", + json!({}), + "invalidate-serialise", + ) + .await?; + let encoded = serialised["content"][2]["text"] + .as_str() + .context("encoded index")?; + let loaded = f + .call( + "deserialize_autocomplete_index", + json!({"base64_data":encoded}), + "invalidate-deserialise", + ) + .await?; + assert_ne!(loaded["isError"], true, "{loaded}"); + let project = f + .call( + "autocomplete_terms", + json!({"query":"Con","role":"project"}), + "invalidate-project-unchanged", + ) + .await?; + assert!(texts(&project).contains("concern"), "{project}"); + let mut config: Value = serde_json::from_slice(&fs::read( + f.root + .path() + .join("project with spaces/.terraphim/config.json"), + )?)?; + config["id"] = json!("Server"); + config["roles"]["project"]["kg"] = config["roles"]["garden"]["kg"].clone(); + config["roles"].as_object_mut().unwrap().remove("garden"); + let updated = f + .call( + "update_config_tool", + json!({"config_str":config.to_string()}), + "invalidate-config", + ) + .await?; + assert_ne!(updated["isError"], true, "{updated}"); + for role in ["project", "garden"] { + let result = f + .call( + "autocomplete_terms", + json!({"query":"Con","role":role}), + &format!("invalidate-{role}-refused"), + ) + .await?; + assert_eq!(result["isError"], true, "{result}"); + } + f.call( + "build_autocomplete_index", + json!({"role":"project"}), + "invalidate-rebuild", + ) + .await?; + let new = f + .call( + "autocomplete_terms", + json!({"query":"Flo","role":"project"}), + "invalidate-new-graph", + ) + .await?; + assert!(texts(&new).contains("flower"), "{new}"); + assert!(!texts(&new).contains("concern"), "{new}"); + f.client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn pagination_schema_is_enforced_without_integer_wrapping() -> Result<()> { + let f = Fixture::start().await?; + let tools = f.client.list_tools(Default::default()).await?; + let search = tools + .tools + .iter() + .find(|tool| tool.name == "search") + .context("search tool")?; + assert_eq!( + search.input_schema["properties"]["limit"]["maximum"], + i32::MAX + ); + for field in ["limit", "skip"] { + for value in [ + json!(4294967296_u64), + json!(i64::MAX), + json!(1.5), + json!("2"), + json!(-1), + json!(null), + ] { + let mut args = json!({"query":"Concern","role":"project"}); + args[field] = value; + let result = f + .client + .call_tool(CallToolRequestParam { + name: "search".into(), + arguments: args.as_object().cloned(), + }) + .await; + assert!( + result.is_err(), + "{field} accepted invalid arguments: {args}" + ); + } + } + f.client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn fuzzy_autocomplete_returns_authored_terms_from_the_real_index() -> Result<()> { + let f = Fixture::start().await?; + let built = f + .call( + "build_autocomplete_index", + json!({"role":"project"}), + "fuzzy-built", + ) + .await?; + assert_ne!(built["isError"], true); + for (tool, arguments) in [ + ( + "fuzzy_autocomplete_search", + json!({"query":"concrn","similarity":0.8,"limit":8}), + ), + ( + "fuzzy_autocomplete_search_jaro_winkler", + json!({"query":"concrn","similarity":0.8,"limit":8}), + ), + ( + "fuzzy_autocomplete_search_levenshtein", + json!({"query":"concrn","max_edit_distance":1,"limit":8}), + ), + ] { + let result = f.call(tool, arguments, tool).await?; + assert_ne!(result["isError"], true, "{result}"); + let suggestions = result["content"] + .as_array() + .unwrap() + .iter() + .skip(1) + .filter_map(|item| item["text"].as_str()) + .collect::>(); + assert!( + suggestions + .iter() + .any(|value| value.to_lowercase().contains("concern")), + "{result}" + ); + assert!( + suggestions + .iter() + .all(|value| !value.to_lowercase().contains("flower")), + "{result}" + ); + } + let absent = f + .call( + "fuzzy_autocomplete_search", + json!({"query":"zzzzzz","similarity":1.0,"limit":8}), + "fuzzy-absent", + ) + .await?; + assert_ne!(absent["isError"], true); + assert_eq!(absent["content"].as_array().unwrap().len(), 1, "{absent}"); + f.client.cancel().await?; + Ok(()) +} diff --git a/crates/terraphim_mcp_server/tests/test_mcp_stdio.rs b/crates/terraphim_mcp_server/tests/test_mcp_stdio.rs deleted file mode 100644 index 0579ace9..00000000 --- a/crates/terraphim_mcp_server/tests/test_mcp_stdio.rs +++ /dev/null @@ -1,199 +0,0 @@ -use std::env; -use std::io::{BufRead, BufReader, Write}; -use std::process::{Command, Stdio}; - -#[test] -fn test_mcp_autocomplete_via_stdio() { - // This test drives stdio JSON-RPC manually. It is sensitive to timing/framing and - // can be flaky in CI environments. The rmcp-based integration tests cover the same - // functionality more reliably. - if std::env::var("RUN_MCP_STDIO_TEST").ok().as_deref() != Some("1") { - eprintln!("Skipping: set RUN_MCP_STDIO_TEST=1 to run"); - return; - } - // Set the environment variable for local dev settings - unsafe { - env::set_var( - "TERRAPHIM_SETTINGS_PATH", - "../terraphim_settings/default/settings_local_dev.toml", - ); - } - - // Start the MCP server - // NOTE: Don't pass --verbose here. It can enable non-JSON output on stdout, - // which breaks stdio JSON-RPC framing. - let mut command = Command::new(env!("CARGO_BIN_EXE_terraphim_mcp_server")); - let mut child = command - .args(["--"]) - .current_dir(".") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("Failed to start MCP server"); - - let mut stdin = child.stdin.take().expect("Failed to get stdin"); - let stdout = child.stdout.take().expect("Failed to get stdout"); - let mut reader = BufReader::new(stdout); - - // Note: don't sleep here; instead we do a request/response handshake below. - // (Sleeping can be flaky and can race with server startup.) - - // Step 1: Send initialization request - let init_request = serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": { - "tools": {} - }, - "clientInfo": { - "name": "MCP Test Client", - "version": "1.0.0" - } - } - }); - - println!("1. Sending initialization request..."); - writeln!(stdin, "{}", init_request).expect("Failed to write to stdin"); - stdin.flush().expect("Failed to flush stdin"); - - // Read response - let mut response = String::new(); - reader - .read_line(&mut response) - .expect("Failed to read response"); - println!("Response: {}", response.trim()); - - // Step 2: List available tools - let tools_request = serde_json::json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list", - "params": {} - }); - - println!("2. Listing available tools..."); - writeln!(stdin, "{}", tools_request).expect("Failed to write to stdin"); - stdin.flush().expect("Failed to flush stdin"); - - // Read tools/list response (with retry, because server may still be initializing) - response.clear(); - let mut got_tools = false; - for _ in 0..20 { - response.clear(); - if reader.read_line(&mut response).is_err() { - break; - } - if response.trim().is_empty() { - std::thread::sleep(std::time::Duration::from_millis(100)); - continue; - } - println!("Tools list response: {}", response.trim()); - - if let Ok(tools_response) = serde_json::from_str::(&response) - && tools_response.get("result").is_some() - { - println!("Parsed tools response: {:#?}", tools_response); - got_tools = true; - break; - } - - std::thread::sleep(std::time::Duration::from_millis(100)); - } - - assert!( - got_tools, - "Did not receive tools/list response from MCP server" - ); - - // Step 3: Build autocomplete index - let build_index_request = serde_json::json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "build_autocomplete_index", - "arguments": { - "role": "Terraphim Engineer" - } - } - }); - - println!("3. Building autocomplete index..."); - writeln!(stdin, "{}", build_index_request).expect("Failed to write to stdin"); - stdin.flush().expect("Failed to flush stdin"); - - response.clear(); - reader - .read_line(&mut response) - .expect("Failed to read response"); - println!("Build index response: {}", response.trim()); - - // If the server exits unexpectedly, avoid BrokenPipe errors later. - assert!( - child - .try_wait() - .expect("Failed to check child status") - .is_none(), - "MCP server exited early" - ); - - // Step 4: Test autocomplete with snippets - let autocomplete_request = serde_json::json!({ - "jsonrpc": "2.0", - "id": 4, - "method": "tools/call", - "params": { - "name": "autocomplete_with_snippets", - "arguments": { - "query": "terraphim", - "limit": 5, - "role": "Terraphim Engineer" - } - } - }); - - println!("4. Testing autocomplete with snippets..."); - writeln!(stdin, "{}", autocomplete_request).expect("Failed to write to stdin"); - stdin.flush().expect("Failed to flush stdin"); - - response.clear(); - reader - .read_line(&mut response) - .expect("Failed to read response"); - println!("Autocomplete response: {}", response.trim()); - - // Step 5: Test autocomplete terms - let terms_request = serde_json::json!({ - "jsonrpc": "2.0", - "id": 5, - "method": "tools/call", - "params": { - "name": "autocomplete_terms", - "arguments": { - "query": "terraphim", - "limit": 5, - "role": "Terraphim Engineer" - } - } - }); - - println!("5. Testing autocomplete terms..."); - writeln!(stdin, "{}", terms_request).expect("Failed to write to stdin"); - stdin.flush().expect("Failed to flush stdin"); - - response.clear(); - reader - .read_line(&mut response) - .expect("Failed to read response"); - println!("Terms response: {}", response.trim()); - - println!("Test completed successfully!"); - - // Clean up - child.kill().expect("Failed to kill child process"); - child.wait().expect("Failed to wait for child"); -} diff --git a/docs/mcp/role-search.md b/docs/mcp/role-search.md new file mode 100644 index 00000000..0ae92efb --- /dev/null +++ b/docs/mcp/role-search.md @@ -0,0 +1,43 @@ +# Authored synonyms and role-scoped autocomplete + +The Rust MCP server reads the same `synonyms::` concept notes used by Terraphim's knowledge graph: + +```markdown +# Risk +synonyms:: Concern +``` + +With this concept in a role's local KG, `search` for the exact term `Concern` +retrieves documents containing `Risk` as well as documents containing `Concern`. +Both names resolve through Terraphim's normalised concept identity. The server +retrieves each alias through the existing service, deduplicates document identities, +and ranks the combined results with Terraphim RoleGraph before applying `skip` +and `limit`. Synonym expansion preserves resource identities and does not replace words in the +returned document; the role's existing content-processing settings still apply. +Compound/free-text queries retain their existing query semantics. + +```json +{"name":"search","arguments":{"query":"Concern","role":"project","limit":10,"skip":0}} +``` + +Autocomplete indexes belong to the role used to build them. Build each required +role once; switching roles never borrows the last role's index. Omitting `role` +from either autocomplete tool uses the selected role. An unknown role or a role +whose index has not been built returns an error without suggestions. + +```json +{"name":"build_autocomplete_index","arguments":{"role":"project"}} +{"name":"autocomplete_terms","arguments":{"query":"Con","role":"project"}} +``` + +`autocomplete_with_snippets` uses that same role's haystacks. Updating the +configuration clears cached indexes: rebuild before requesting suggestions. +Deserialising an index makes it available to the explicit index/fuzzy operations; +it does not register a graph or replace a role-bound index. + +The subprocess regression suite creates isolated Markdown graphs, corpora and +memory storage and calls the real server over MCP stdio: + +```sh +cargo test --locked -p terraphim_mcp_server --test test_mcp_role_contract +```