use std::path::PathBuf;
use std::sync::Arc;
use crate::lsp::{LspManager, LspProvider};
use anyhow::{Context, Result};
#[cfg(feature = "http")]
use axum::response::IntoResponse;
use rmcp::{
model::{
CallToolRequestParams, CallToolResult, Content, ListToolsResult, PaginatedRequestParams,
RawContent, ServerCapabilities, ServerInfo, Tool as McpTool,
},
service::RequestContext,
ErrorData as McpError, Peer, RoleServer, ServerHandler, ServiceExt,
};
use serde_json::Value;
use crate::agent::Agent;
use crate::tools::{
approve_write::ApproveWrite,
config::Workspace,
create_file::CreateFile,
edit_file::EditFile,
grep::Grep,
library::Library,
markdown::{EditMarkdown, ReadMarkdown},
memory::Memory,
progress,
read_file::ReadFile,
semantic::{Index, SemanticSearch},
symbol::{CallGraph, EditCode, References, SymbolAt, Symbols},
tree::Tree,
Onboarding, RunCommand, Tool, ToolContext,
};
use crate::usage::UsageRecorder;
#[derive(Clone)]
pub struct CodeScoutServer {
agent: Agent,
lsp: Arc<dyn LspProvider>,
output_buffer: Arc<crate::tools::output_buffer::OutputBuffer>,
tools: Vec<Arc<dyn Tool>>,
instructions: Arc<parking_lot::RwLock<String>>,
section_coverage: Arc<std::sync::Mutex<crate::tools::section_coverage::SectionCoverage>>,
guide_hints_emitted: Arc<parking_lot::Mutex<crate::tools::guide_ledger::GuideLedger>>,
session_id: String,
debug: bool,
last_broadcast_caps: Arc<parking_lot::Mutex<Option<crate::tools::ToolCapabilities>>>,
resources: Arc<tokio::sync::RwLock<Arc<crate::mcp_resources::ResourceRegistry>>>,
path_note_emitted_since_activation: Arc<std::sync::atomic::AtomicBool>,
}
impl CodeScoutServer {
pub async fn new(agent: Agent) -> Self {
let lsp = match agent.project_root().await {
Some(root) => LspManager::new_arc_with_root(root),
None => LspManager::new_arc(),
};
Self::from_parts(agent, lsp, false).await
}
pub async fn from_parts(agent: Agent, lsp: Arc<dyn LspProvider>, debug: bool) -> Self {
let status = agent.project_status().await;
let instructions = crate::prompts::build_server_instructions(status.as_ref());
#[cfg_attr(not(feature = "librarian"), allow(unused_mut))]
let mut tools: Vec<Arc<dyn Tool>> = vec![
Arc::new(ReadFile),
Arc::new(Tree),
Arc::new(Grep),
Arc::new(CreateFile),
Arc::new(EditFile),
Arc::new(EditMarkdown),
Arc::new(ReadMarkdown),
Arc::new(RunCommand),
Arc::new(Onboarding),
Arc::new(ApproveWrite),
Arc::new(Symbols),
Arc::new(References),
Arc::new(SymbolAt),
Arc::new(CallGraph),
Arc::new(EditCode),
Arc::new(Memory),
Arc::new(SemanticSearch),
Arc::new(Index),
Arc::new(Workspace),
Arc::new(Library),
Arc::new(crate::tools::guide::GetGuide::new()),
#[cfg(unix)]
Arc::new(crate::tools::peer::PeerTool),
];
if std::env::var("CODESCOUT_PROBE")
.ok()
.filter(|v| !v.is_empty() && v != "0")
.is_some()
{
tools.push(Arc::new(crate::tools::probe::ProbeTool));
tracing::warn!(
"CODESCOUT_PROBE=1 — registering __probe_description_cap__ \
(debug-only; ~8.8KB description with sentinel markers)"
);
}
#[cfg(feature = "librarian")]
if librarian_enabled_at_runtime(status.as_ref().map(|s| s.path.as_str())) {
if let Some(lib_ctx) = crate::librarian::try_build_runtime().await {
tools.extend(crate::librarian::adapters_for(lib_ctx));
}
}
let output_buffer = Arc::new(crate::tools::output_buffer::OutputBuffer::new(50));
let section_coverage = Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
));
let guide_project_root = agent.project_root().await;
let cc_session_id = std::env::var("CLAUDE_CODE_SESSION_ID")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
guide_project_root
.as_ref()
.and_then(|r| {
std::fs::read_to_string(r.join(".codescout").join("cc_session_id")).ok()
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let guide_hints_dir = guide_project_root
.as_ref()
.map(|r| r.join(".codescout").join("guide_hints"));
let guide_hints_emitted = Arc::new(parking_lot::Mutex::new(
crate::tools::guide_ledger::GuideLedger::load(&cc_session_id, guide_hints_dir),
));
let resources = Arc::new(tokio::sync::RwLock::new(Arc::new(
build_resource_registry(&agent, Arc::clone(&lsp), &tools).await,
)));
if let Some(root) = agent.project_root().await {
let prewarm_langs = agent
.with_project(|p| Ok(p.config.project.languages.clone()))
.await
.unwrap_or_default();
crate::lsp::prewarm_lsp_background(Arc::clone(&lsp), root, &prewarm_langs);
}
Self {
agent,
lsp,
output_buffer,
tools,
instructions: Arc::new(parking_lot::RwLock::new(instructions)),
section_coverage,
guide_hints_emitted,
session_id: uuid::Uuid::new_v4().to_string(),
debug,
last_broadcast_caps: Arc::new(parking_lot::Mutex::new(None)),
resources,
path_note_emitted_since_activation: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
fn find_tool(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.iter().find(|t| t.name() == name).cloned()
}
fn resolve_tool(&self, name: &str) -> std::result::Result<Arc<dyn Tool>, McpError> {
self.find_tool(name)
.ok_or_else(|| McpError::invalid_params(format!("unknown tool: '{}'", name), None))
}
fn is_write_call(&self, tool_name: &str, input: &serde_json::Value) -> bool {
self.find_tool(tool_name)
.map(|t| t.is_write(input))
.unwrap_or(false)
}
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn tool_names(&self) -> Vec<String> {
self.tools.iter().map(|t| t.name().to_string()).collect()
}
#[cfg_attr(not(unix), allow(dead_code))] pub(crate) fn output_buffer_ref(
&self,
) -> std::sync::Arc<crate::tools::output_buffer::OutputBuffer> {
self.output_buffer.clone()
}
#[cfg_attr(not(unix), allow(dead_code))] pub(crate) async fn project_root_string(&self) -> String {
self.agent
.project_root()
.await
.map(|r| r.display().to_string())
.unwrap_or_default()
}
#[cfg_attr(not(unix), allow(dead_code))] pub(crate) async fn project_name(&self) -> String {
self.agent
.project_root()
.await
.and_then(|r| r.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_default()
}
#[cfg(all(test, unix))]
pub(crate) async fn agent_security_config(
&self,
) -> crate::util::path_security::PathSecurityConfig {
self.agent.security_config().await
}
fn parse_input(arguments: Option<serde_json::Map<String, Value>>) -> Value {
arguments
.map(Value::Object)
.unwrap_or(Value::Object(Default::default()))
}
async fn check_tool_access(&self, name: &str) -> std::result::Result<(), CallToolResult> {
let security = self.agent.security_config().await;
crate::util::path_security::check_tool_access(name, &security)
.map_err(|e| CallToolResult::error(vec![Content::text(e.to_string())]))
}
fn build_context(
&self,
progress: Option<Arc<progress::ProgressReporter>>,
peer: Option<Peer<RoleServer>>,
) -> ToolContext {
ToolContext {
agent: self.agent.clone(),
lsp: self.lsp.clone(),
output_buffer: self.output_buffer.clone(),
progress,
peer,
section_coverage: self.section_coverage.clone(),
guide_hints_emitted: self.guide_hints_emitted.clone(),
workspace_override: None,
}
}
fn extract_workspace_override(input: &Value) -> Option<std::path::PathBuf> {
let raw = input.get("workspace")?.as_str()?;
if raw.trim().is_empty() {
return None;
}
let p = std::path::PathBuf::from(raw);
Some(std::fs::canonicalize(&p).unwrap_or(p))
}
fn inject_workspace_param(schema_obj: &mut serde_json::Map<String, Value>) {
let props = schema_obj
.entry("properties")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Some(props) = props.as_object_mut() {
props.entry("workspace").or_insert_with(|| {
serde_json::json!({
"type": "string",
"description": "Optional. Absolute path of the workspace this call targets, pinning project resolution to it regardless of the session default. Omit to use the active project; set it when concurrent subagents operate on different workspaces."
})
});
}
}
async fn acquire_write_guard_if_writing(
&self,
name: &str,
input: &Value,
) -> std::result::Result<
std::result::Result<Option<crate::agent::WriteGuard>, CallToolResult>,
McpError,
> {
if !self.is_write_call(name, input) {
return Ok(Ok(None));
}
let override_root = Self::extract_workspace_override(input);
let (mutex, fd_lock, timeout_secs) = self
.agent
.with_project_at(override_root.as_deref(), |p| {
Ok((
p.write_lock.clone(),
p.file_lock.clone(),
p.config.security.write_lock_timeout_secs,
))
})
.await
.map_err(|e| McpError::internal_error(format!("write gate: {}", e), None))?;
match crate::agent::acquire_write_guard(
mutex,
fd_lock,
std::time::Duration::from_secs(timeout_secs),
)
.await
{
Ok(g) => Ok(Ok(Some(g))),
Err(rec_err) => Ok(Err(route_tool_error(rec_err.into()))),
}
}
async fn race_against_cancel<F, G>(
tool_call_fut: F,
cancel_token: tokio_util::sync::CancellationToken,
timeout_secs: Option<u64>,
tool_name: &str,
release_on_cancel: G,
) -> Result<Vec<Content>, anyhow::Error>
where
F: std::future::Future<Output = Result<Vec<Content>, anyhow::Error>>,
G: Send + 'static,
{
if let Some(secs) = timeout_secs {
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
drop(release_on_cancel);
std::future::pending::<Result<Vec<Content>, anyhow::Error>>().await
}
res = tokio::time::timeout(
std::time::Duration::from_secs(secs),
tool_call_fut,
) => res.unwrap_or_else(|_| {
Err(anyhow::anyhow!(
"Tool '{}' timed out after {}s. \
Increase tool_timeout_secs in .codescout/project.toml if needed.",
tool_name,
secs
))
}),
}
} else {
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
drop(release_on_cancel);
std::future::pending::<Result<Vec<Content>, anyhow::Error>>().await
}
res = tool_call_fut => res,
}
}
}
async fn post_process(&self, call_result: CallToolResult, tool_name: &str) -> CallToolResult {
let root_prefix = self
.agent
.project_root()
.await
.map(|p| format!("{}/", p.display()))
.unwrap_or_default();
let should_strip = tool_name != "run_command";
let (mut call_result, stripped) = if should_strip {
strip_project_root_from_result(call_result, &root_prefix)
} else {
(call_result, false)
};
if stripped {
let already_emitted = self
.path_note_emitted_since_activation
.swap(true, std::sync::atomic::Ordering::Relaxed);
if !already_emitted {
let root = root_prefix.trim_end_matches('/');
call_result.content.push(Content::text(format!(
"\n[codescout] paths are relative to {root}"
)));
}
}
call_result
}
async fn refresh_resources(&self) {
let new_rr = build_resource_registry(&self.agent, Arc::clone(&self.lsp), &self.tools).await;
*self.resources.write().await = Arc::new(new_rr);
}
async fn refresh_instructions(&self) {
let status = self.agent.project_status().await;
let new_instructions = crate::prompts::build_server_instructions(status.as_ref());
*self.instructions.write() = new_instructions;
}
async fn current_capabilities(&self) -> crate::tools::ToolCapabilities {
let has_lsp = self
.agent
.with_project(|p| {
let has = p
.config
.project
.languages
.iter()
.any(|lang| crate::lsp::servers::has_lsp_config(lang));
Ok(has)
})
.await
.unwrap_or(false);
let has_embeddings = cfg!(any(
feature = "local-embed",
feature = "local-embed-dynamic",
feature = "remote-embed"
));
let has_git_remote = self
.agent
.with_project(|p| Ok(p.has_git_remote))
.await
.unwrap_or(false);
let has_libraries = self
.agent
.library_registry()
.await
.map(|reg| !reg.all().is_empty())
.unwrap_or(false);
crate::tools::ToolCapabilities {
has_lsp,
has_embeddings,
has_git_remote,
has_libraries,
}
}
#[cfg_attr(not(unix), allow(dead_code))] pub(crate) async fn call_tool_by_name(
&self,
name: &str,
args: Value,
) -> std::result::Result<CallToolResult, McpError> {
let req: CallToolRequestParams = serde_json::from_value(serde_json::json!({
"name": name,
"arguments": args,
}))
.map_err(|e| {
McpError::invalid_params(format!("failed to build tool request: {e}"), None)
})?;
self.call_tool_inner(req, None, None, tokio_util::sync::CancellationToken::new())
.await
}
#[tracing::instrument(skip_all, fields(tool = %req.name))]
async fn call_tool_inner(
&self,
req: CallToolRequestParams,
progress: Option<Arc<progress::ProgressReporter>>,
peer: Option<Peer<RoleServer>>,
cancel_token: tokio_util::sync::CancellationToken,
) -> std::result::Result<CallToolResult, McpError> {
tracing::debug!(args = ?req.arguments, "tool call");
let arg_keys: Vec<&str> = req
.arguments
.as_ref()
.map(|m| m.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
tracing::info!(tool = %req.name, ?arg_keys, "tool_call");
let tool_start = std::time::Instant::now();
let tool = self.resolve_tool(&req.name)?;
if let Err(err) = self.check_tool_access(&req.name).await {
return Ok(err);
}
let input: Value = Self::parse_input(req.arguments);
let mut ctx = self.build_context(progress, peer);
ctx.workspace_override = Self::extract_workspace_override(&input);
let timeout_secs = if tool_skips_server_timeout(&req.name) {
None
} else {
self.agent
.with_project(|p| Ok(p.config.project.tool_timeout_secs))
.await
.ok()
};
let recorder = UsageRecorder::new(self.agent.clone(), self.debug, self.session_id.clone());
let input_for_record = input.clone();
let write_guard = match self
.acquire_write_guard_if_writing(&req.name, &input_for_record)
.await?
{
Ok(g) => g,
Err(result) => return Ok(result),
};
let tool_call_fut = recorder.record_content(&req.name, &input_for_record, || {
tool.call_content(input, &ctx)
});
let result = Self::race_against_cancel(
tool_call_fut,
cancel_token,
timeout_secs,
&req.name,
write_guard,
)
.await;
let call_result = match result {
Ok(blocks) => CallToolResult::success(blocks),
Err(e) => route_tool_error(e),
};
let ok = call_result.is_error.is_none_or(|e| !e);
tracing::debug!(ok, "tool result");
tracing::info!(
tool = %req.name,
duration_ms = tool_start.elapsed().as_millis() as u64,
ok,
"tool_done"
);
let call_result = self.post_process(call_result, &req.name).await;
Ok(call_result)
}
}
#[derive(Default, Copy, Clone)]
struct SelfMemoryKb {
vm_size_kb: u64,
vm_rss_kb: u64,
vm_data_kb: u64,
vm_peak_kb: u64,
}
fn read_self_memory_kb() -> SelfMemoryKb {
let mut out = SelfMemoryKb::default();
let Ok(text) = std::fs::read_to_string("/proc/self/status") else {
return out;
};
for line in text.lines() {
let Some((key, rest)) = line.split_once(':') else {
continue;
};
let value_kb = rest
.split_whitespace()
.next()
.and_then(|n| n.parse::<u64>().ok())
.unwrap_or(0);
match key {
"VmSize" => out.vm_size_kb = value_kb,
"VmRSS" => out.vm_rss_kb = value_kb,
"VmData" => out.vm_data_kb = value_kb,
"VmPeak" => out.vm_peak_kb = value_kb,
_ => {}
}
}
out
}
fn tool_skips_server_timeout(name: &str) -> bool {
matches!(name, "index" | "index_library" | "run_command")
}
#[cfg(feature = "librarian")]
fn librarian_enabled_at_runtime(project_path: Option<&str>) -> bool {
if let Ok(v) = std::env::var("LIBRARIAN_ENABLED") {
let v = v.trim().to_ascii_lowercase();
if matches!(v.as_str(), "0" | "false" | "off" | "no") {
return false;
}
if matches!(v.as_str(), "1" | "true" | "on" | "yes") {
return true;
}
}
if let Some(root) = project_path {
let cfg = std::path::Path::new(root)
.join(".codescout")
.join("project.toml");
if let Ok(text) = std::fs::read_to_string(&cfg) {
if let Ok(parsed) = toml::from_str::<toml::Value>(&text) {
if let Some(v) = parsed
.get("librarian")
.and_then(|t| t.get("enabled"))
.and_then(|v| v.as_bool())
{
return v;
}
}
}
}
true
}
impl ServerHandler for CodeScoutServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.enable_resources()
.build(),
)
.with_instructions(self.instructions.read().clone())
}
async fn list_tools(
&self,
_req: Option<PaginatedRequestParams>,
_ctx: RequestContext<RoleServer>,
) -> std::result::Result<ListToolsResult, McpError> {
let caps = self.current_capabilities().await;
let tools = self
.tools
.iter()
.filter(|t| t.availability(&caps).is_available(&caps))
.map(|t| {
let schema = t.input_schema();
let mut schema_obj = schema.as_object().cloned().unwrap_or_default();
if t.pinnable() {
Self::inject_workspace_param(&mut schema_obj);
}
McpTool::new(t.name().to_owned(), t.description().to_owned(), schema_obj)
})
.collect();
Ok(ListToolsResult::with_all_items(tools))
}
async fn list_resources(
&self,
_req: Option<PaginatedRequestParams>,
_ctx: RequestContext<RoleServer>,
) -> std::result::Result<rmcp::model::ListResourcesResult, McpError> {
use rmcp::model::{AnnotateAble as _, RawResource};
let rr = self.resources.read().await.clone();
let resources = rr
.list()
.into_iter()
.map(|d| {
let mut raw = RawResource::new(d.uri, d.name);
if let Some(desc) = d.description {
raw = raw.with_description(desc);
}
raw = raw.with_mime_type(d.mime_type);
raw.no_annotation()
})
.collect();
Ok(rmcp::model::ListResourcesResult {
meta: None,
resources,
next_cursor: None,
})
}
async fn read_resource(
&self,
req: rmcp::model::ReadResourceRequestParams,
_ctx: RequestContext<RoleServer>,
) -> std::result::Result<rmcp::model::ReadResourceResult, McpError> {
use crate::mcp_resources::{ResourceBytes, ResourceError};
use rmcp::model::{ReadResourceResult, ResourceContents};
let rr = self.resources.read().await.clone();
match rr.read(&req.uri).await {
Ok(ResourceBytes::Text(t)) => {
Ok(ReadResourceResult::new(vec![ResourceContents::text(
t, &req.uri,
)]))
}
Ok(ResourceBytes::Blob(_)) => Err(McpError::internal_error(
"blob resource encoding not supported in this build",
None,
)),
Err(ResourceError::NotFound(u)) => Err(McpError::resource_not_found(
format!("resource not found: {u}"),
None,
)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
async fn call_tool(
&self,
req: CallToolRequestParams,
req_ctx: RequestContext<RoleServer>,
) -> std::result::Result<CallToolResult, McpError> {
let is_activate = req.name == "workspace"
&& req
.arguments
.as_ref()
.and_then(|m| m.get("action"))
.and_then(|v| v.as_str())
== Some("activate");
let progress = req_ctx
.meta
.get_progress_token()
.map(|token| progress::ProgressReporter::new(req_ctx.peer.clone(), token.0));
let peer = Some(req_ctx.peer.clone());
let result = self
.call_tool_inner(req, progress, peer, req_ctx.ct.clone())
.await?;
if is_activate {
let new_caps = self.current_capabilities().await;
let caps_changed = {
let mut last = self.last_broadcast_caps.lock();
let changed = last.as_ref() != Some(&new_caps);
if changed {
*last = Some(new_caps);
}
changed
};
if caps_changed {
let _ = req_ctx.peer.notify_tool_list_changed().await;
}
self.refresh_resources().await;
self.refresh_instructions().await;
self.path_note_emitted_since_activation
.store(false, std::sync::atomic::Ordering::Relaxed);
}
Ok(result)
}
}
fn static_doc_sources() -> Vec<crate::mcp_resources::doc::DocSource> {
use crate::mcp_resources::doc::DocSource;
vec![
DocSource {
uri: "doc://progressive-disclosure".into(),
name: "progressive-disclosure".into(),
description: Some(
"Output sizing, overflow hints, agent guidance for codescout tools.".into(),
),
content: include_str!("../docs/PROGRESSIVE_DISCOVERABILITY.md"),
},
DocSource {
uri: "doc://librarian-guide".into(),
name: "librarian-guide".into(),
description: Some(
"Full reference: artifact model, filter syntax, tracker workflow, \
augmentation lifecycle, librarian actions."
.into(),
),
content: include_str!("prompts/guides/librarian.md"),
},
]
}
async fn build_resource_registry(
agent: &Agent,
lsp: Arc<dyn LspProvider>,
tools: &[Arc<dyn Tool>],
) -> crate::mcp_resources::ResourceRegistry {
use crate::mcp_resources::{
doc::DocProvider,
memory::MemoryProvider,
project_summary::{AgentSummarySource, ProjectSummaryProvider},
tool_guide::ToolGuideProvider,
tool_usage::{AgentUsageSource, ToolUsageProvider},
ResourceRegistry,
};
let mut rr = ResourceRegistry::new();
let _ = rr.try_register(Box::new(DocProvider::new(static_doc_sources())));
if let Ok(memory_dir) = agent
.with_project(|p| Ok(p.memory.dir().to_path_buf()))
.await
{
let _ = rr.try_register(Box::new(MemoryProvider::new(memory_dir)));
}
let _ = rr.try_register(Box::new(ProjectSummaryProvider::new(
AgentSummarySource::new(agent.clone(), lsp),
)));
let _ = rr.try_register(Box::new(ToolGuideProvider::new(tools.to_vec())));
let _ = rr.try_register(Box::new(ToolUsageProvider::new(AgentUsageSource::new(
agent.clone(),
tools.to_vec(),
))));
if std::env::var("CODESCOUT_PROBE")
.ok()
.filter(|v| !v.is_empty() && v != "0")
.is_some()
{
let _ = rr.try_register(Box::new(crate::mcp_resources::probe::ProbeProvider));
}
rr
}
fn route_tool_error(e: anyhow::Error) -> CallToolResult {
if let Some(rec) = e.downcast_ref::<crate::tools::RecoverableError>() {
let mut body = serde_json::json!({ "ok": false, "error": rec.message });
if let Some(g) = &rec.guidance {
body[g.field_name()] = serde_json::json!(g.text());
}
if let Some(obj) = body.as_object_mut() {
for (k, v) in rec.extra.iter() {
obj.insert(k.clone(), v.clone());
}
}
let text = serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string());
CallToolResult::success(vec![Content::text(text)])
} else if e.to_string().contains("code -32800") || e.to_string().contains("code -32801") {
let code = if e.to_string().contains("code -32801") {
"-32801"
} else {
"-32800"
};
tracing::warn!("LSP transient error ({}): {}", code, e);
let body = serde_json::json!({
"error": e.to_string(),
"hint": "The LSP server returned a transient error (RequestCancelled -32800 or \
ContentModified -32801). The client already auto-retries idempotent \
methods on these codes; this surfaces only when the retry budget is \
exhausted or the method is non-idempotent. Common causes:\n\
(1) Cold indexing window — server still building its workspace index \
(can take 1-5 minutes after `/mcp` reconnect or fresh launch).\n\
(2) Workspace lock contention — another codescout instance or editor \
LSP holds the workspace. For kotlin-lsp, each instance needs a separate \
--system-path to avoid contention on the IntelliJ platform's .app.lock.\n\
Wait and retry; or for non-idempotent methods (rename, applyEdit) \
re-issue manually after confirming server state."
});
let text = serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string());
CallToolResult::success(vec![Content::text(text)])
} else {
tracing::error!(error = format!("{e:#}"), "tool error");
CallToolResult::error(vec![Content::text(e.to_string())])
}
}
#[deprecated(
since = "0.9.0",
note = "Not cryptographically secure. Use os_random_auth_token() internally or pass --auth-token."
)]
pub fn generate_auth_token() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid = std::process::id() as u64;
let hi = nanos as u64;
let lo = pid.wrapping_mul(0x517cc1b727220a95);
format!("{:016x}{:016x}", hi, lo)
}
pub(crate) async fn shutdown_signal() -> &'static str {
let ctrl_c = async {
tokio::signal::ctrl_c().await.ok();
"SIGINT"
};
#[cfg(unix)]
{
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())
.expect("failed to install SIGHUP handler");
tokio::select! {
v = ctrl_c => v,
_ = sigterm.recv() => "SIGTERM",
_ = sighup.recv() => "SIGHUP",
}
}
#[cfg(not(unix))]
{
ctrl_c.await
}
}
struct ResilientStdin {
inner: tokio::io::Stdin,
backoff: Option<std::pin::Pin<Box<tokio::time::Sleep>>>,
}
impl ResilientStdin {
fn new(stdin: tokio::io::Stdin) -> Self {
Self {
inner: stdin,
backoff: None,
}
}
}
impl tokio::io::AsyncRead for ResilientStdin {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
use std::future::Future;
let this = self.get_mut();
if let Some(ref mut sleep) = this.backoff {
if sleep.as_mut().poll(cx).is_pending() {
return std::task::Poll::Pending;
}
this.backoff = None;
}
match std::pin::Pin::new(&mut this.inner).poll_read(cx, buf) {
std::task::Poll::Ready(Err(ref e)) if e.kind() == std::io::ErrorKind::WouldBlock => {
tracing::trace!("stdin EAGAIN — backing off 1ms before retry");
let mut sleep = Box::pin(tokio::time::sleep(std::time::Duration::from_millis(1)));
let _ = sleep.as_mut().poll(cx);
this.backoff = Some(sleep);
std::task::Poll::Pending
}
other => other,
}
}
}
fn os_random_auth_token() -> Result<String> {
let mut buf = [0u8; 32];
use std::io::Read;
std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut buf))
.map_err(|e| anyhow::anyhow!("Failed to read /dev/urandom for auth token: {e}"))?;
Ok(hex::encode(buf))
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
#[allow(clippy::too_many_arguments)]
pub async fn run(
project: Option<PathBuf>,
transport: &str,
host: &str,
port: u16,
auth_token: Option<String>,
debug: bool,
instance_id: Option<String>,
) -> Result<()> {
let project = match project.or_else(|| std::env::current_dir().ok()) {
Some(p) => Some(std::fs::canonicalize(&p).with_context(|| {
format!(
"failed to canonicalize project path {} — check it exists and is readable",
p.display()
)
})?),
None => None,
};
let lsp = match project.clone() {
Some(root) => LspManager::new_arc_with_root(root),
None => LspManager::new_arc(),
};
let agent = Agent::new(project).await?;
let instance_tag = instance_id.as_deref().unwrap_or("----");
if debug {
let project_display = agent
.project_root()
.await
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<none>".to_string());
tracing::info!(
pid = std::process::id(),
version = env!("CARGO_PKG_VERSION"),
instance = %instance_tag,
project = %project_display,
transport = %transport,
"codescout_start"
);
}
if debug {
let agent_hb = agent.clone();
let lsp_hb = lsp.clone();
let start = tokio::time::Instant::now();
let instance_tag_hb = instance_tag.to_owned();
let _heartbeat = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
interval.tick().await; loop {
interval.tick().await;
let uptime_secs = start.elapsed().as_secs();
let lsp_servers = lsp_hb.active_languages().await;
let active_projects: usize = if agent_hb.project_root().await.is_some() {
1
} else {
0
};
let mem = read_self_memory_kb();
tracing::info!(
instance = %instance_tag_hb,
uptime_secs,
active_projects,
?lsp_servers,
vm_size_kb = mem.vm_size_kb,
vm_rss_kb = mem.vm_rss_kb,
vm_data_kb = mem.vm_data_kb,
vm_peak_kb = mem.vm_peak_kb,
"heartbeat"
);
}
});
}
match transport {
"stdio" => {
if auth_token.is_some() {
tracing::warn!("--auth-token is ignored for stdio transport");
}
tracing::info!("codescout MCP server ready (stdio)");
let server = CodeScoutServer::from_parts(agent, lsp.clone(), debug).await;
let (stdin, stdout) = rmcp::transport::stdio();
let service = server
.serve((ResilientStdin::new(stdin), stdout))
.await
.map_err(|e| anyhow::anyhow!("MCP server error: {}", e))?;
tokio::select! {
result = service.waiting() => {
match result {
Ok(reason) => tracing::info!(instance = %instance_tag, ?reason, "service_exit"),
Err(e) => {
tracing::info!(instance = %instance_tag, %e, "service_exit join_error");
return Err(anyhow::anyhow!("MCP server exited: {}", e));
}
}
}
reason = shutdown_signal() => {
tracing::info!(instance = %instance_tag, reason, "service_exit");
}
}
tracing::info!("Shutting down LSP servers...");
lsp.shutdown_all().await;
tracing::info!("All LSP servers shut down");
Ok(())
}
#[cfg(feature = "http")]
"http" => {
use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager, StreamableHttpServerConfig,
StreamableHttpService,
};
let server = CodeScoutServer::from_parts(agent, lsp.clone(), debug).await;
let ct = tokio_util::sync::CancellationToken::new();
let service = StreamableHttpService::new(
move || {
let mut s = server.clone();
s.session_id = uuid::Uuid::new_v4().to_string();
Ok(s)
},
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()),
);
let token = match auth_token {
Some(t) => t,
None => {
let t = os_random_auth_token()?;
eprintln!("Generated auth token: {t}");
t
}
};
let router =
axum::Router::new()
.nest_service("/mcp", service)
.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let expected = format!("Bearer {token}");
async move {
let ok = req
.headers()
.get("authorization")
.map(|v| ct_eq(v.as_bytes(), expected.as_bytes()))
.unwrap_or(false);
if ok {
next.run(req).await
} else {
axum::http::StatusCode::UNAUTHORIZED.into_response()
}
}
},
));
let bind_addr = format!("{host}:{port}");
let tcp_listener = tokio::net::TcpListener::bind(&bind_addr)
.await
.map_err(|e| anyhow::anyhow!("Failed to bind {bind_addr}: {e}"))?;
tracing::info!(
%bind_addr,
instance = %instance_tag,
"codescout MCP server ready (HTTP)"
);
eprintln!("codescout listening on http://{bind_addr}/mcp");
let ct_shutdown = ct.clone();
let instance_tag_http = instance_tag.to_owned();
axum::serve(tcp_listener, router)
.with_graceful_shutdown(async move {
let reason = shutdown_signal().await;
tracing::info!(instance = %instance_tag_http, reason, "service_exit");
ct_shutdown.cancel();
})
.await
.map_err(|e| anyhow::anyhow!("HTTP server error: {e}"))?;
tracing::info!("Shutting down LSP servers...");
lsp.shutdown_all().await;
tracing::info!("All LSP servers shut down");
Ok(())
}
#[cfg(not(feature = "http"))]
"http" => {
let _ = (host, port, auth_token);
anyhow::bail!(
"HTTP transport is not available in this build. \
Build with `--features http` to enable it."
);
}
other => anyhow::bail!("Unknown transport '{}'. Use 'stdio' or 'http'.", other),
}
}
fn strip_project_root_from_result(
mut result: CallToolResult,
root_prefix: &str,
) -> (CallToolResult, bool) {
if root_prefix.is_empty() {
return (result, false);
}
debug_assert!(
root_prefix.ends_with('/'),
"root_prefix must end with '/' to avoid stripping partial path components"
);
let mut stripped = false;
for block in &mut result.content {
if let RawContent::Text(ref mut t) = block.raw {
let new_text = strip_prefix_from_text(&t.text, root_prefix);
if new_text != t.text {
stripped = true;
t.text = new_text;
}
}
}
(result, stripped)
}
fn strip_prefix_from_text(text: &str, prefix: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut last = 0;
for (pos, _) in text.match_indices(prefix) {
let is_value_boundary = pos == 0
|| {
let prev = text[..pos].chars().next_back();
!matches!(prev, Some(c) if c == '/' || c == '.' || c == '-' || c == '_' || c.is_ascii_alphanumeric())
};
if is_value_boundary {
result.push_str(&text[last..pos]);
last = pos + prefix.len();
}
}
result.push_str(&text[last..]);
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::Agent;
use tempfile::tempdir;
async fn make_server() -> (tempfile::TempDir, CodeScoutServer) {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let server = CodeScoutServer::new(agent).await;
(dir, server)
}
async fn make_server_no_project() -> CodeScoutServer {
let agent = Agent::new(None).await.unwrap();
CodeScoutServer::new(agent).await
}
#[tokio::test]
async fn server_registers_all_tools() {
let (_dir, server) = make_server().await;
#[allow(unused_mut)]
let mut expected_tools = vec![
"read_file",
"tree",
"grep",
"create_file",
"edit_file",
"edit_markdown",
"read_markdown",
"run_command",
"onboarding",
"approve_write",
"symbols",
"references",
"call_graph",
"edit_code",
"symbol_at",
"memory",
"semantic_search",
"index",
"workspace",
"library",
"get_guide",
];
#[cfg(unix)]
expected_tools.push("peer");
let core_count = server
.tools
.iter()
.filter(|t| !is_librarian_tool(t.name()))
.count();
assert_eq!(
core_count,
expected_tools.len(),
"core tool count mismatch: expected {}, got {}\nregistered: {:?}",
expected_tools.len(),
core_count,
server.tools.iter().map(|t| t.name()).collect::<Vec<_>>()
);
for name in &expected_tools {
assert!(
server.find_tool(name).is_some(),
"tool '{}' not found in server",
name
);
}
}
#[tokio::test]
async fn server_tool_count_is_l3_target() {
let (_dir, server) = make_server().await;
let core_count = server
.tools
.iter()
.filter(|t| !is_librarian_tool(t.name()))
.count();
#[cfg(unix)]
let expected = 22;
#[cfg(not(unix))]
let expected = 21;
assert_eq!(
core_count,
expected,
"L3 target is {expected} core tools; got {}: {:?}",
core_count,
server.tools.iter().map(|t| t.name()).collect::<Vec<_>>()
);
}
fn is_librarian_tool(name: &str) -> bool {
name.starts_with("artifact_")
|| name.starts_with("librarian_")
|| name.starts_with("tracker_")
|| name == "workspace_state_at"
|| name == "tracker_design"
|| name == "artifact"
|| name == "librarian"
}
#[tokio::test]
async fn tool_descriptions_stay_under_budget() {
let (_dir, server) = make_server().await;
for t in &server.tools {
if is_librarian_tool(t.name()) {
continue;
}
let d = t.description();
assert!(
d.len() <= 300,
"tool `{}` description is {} chars (cap 300): {:?}",
t.name(),
d.len(),
d
);
}
}
#[tokio::test]
async fn tool_descriptions_report_lengths() {
let (_dir, server) = make_server().await;
let mut lengths: Vec<(String, usize, bool)> = server
.tools
.iter()
.map(|t| {
(
t.name().to_string(),
t.description().len(),
is_librarian_tool(t.name()),
)
})
.collect();
lengths.sort_by_key(|b| std::cmp::Reverse(b.1));
println!(
"\n len cap tool (exempt = librarian)"
);
println!(" --- --- ---------------------------------------------");
for (name, len, exempt) in &lengths {
let cap = if *exempt { " -" } else { "300" };
let flag = if *exempt {
""
} else if *len > 270 {
" ⚠ near cap"
} else {
""
};
println!(" {len:>3} {cap} {name:<45}{flag}");
}
}
#[tokio::test]
async fn prompt_surfaces_reference_only_real_tools() {
use std::collections::{HashMap, HashSet};
let (_dir, server) = make_server().await;
let real_tools: HashSet<&str> = server.tools.iter().map(|t| t.name()).collect();
let allowlist_entries: &[&str] = &[
"architecture",
"conventions",
"gotchas",
"hardware",
"model",
"model_options",
"protected_memories",
"untracked",
"url",
];
let allowlist: HashSet<&str> = allowlist_entries.iter().copied().collect();
let mut allowlist_hits: HashMap<&str, usize> = allowlist_entries
.iter()
.copied()
.map(|s| (s, 0usize))
.collect();
let draft = crate::prompts::builders::build_system_prompt_draft(&[], &[], None, None, &[]);
let surfaces: &[(&str, &str)] = &[
(
"server_instructions.md",
crate::prompts::SERVER_INSTRUCTIONS,
),
(
"onboarding_prompt.md",
crate::prompts::RAW_ONBOARDING_PROMPT,
),
("build_system_prompt_draft", draft.as_str()),
];
let re = regex::Regex::new(r"`([a-z][a-z_0-9]{2,})`").unwrap();
let mut drift = Vec::<String>::new();
for (surface, body) in surfaces {
for cap in re.captures_iter(body) {
let ident = cap.get(1).unwrap().as_str();
if real_tools.contains(ident) {
continue;
}
if allowlist.contains(ident) {
*allowlist_hits.get_mut(ident).unwrap() += 1;
continue;
}
drift.push(format!(
"{surface}: `{ident}` looks like a tool name but is not \
registered — rename the reference to a real tool, or add \
it to the allowlist in this test if it's a non-tool token"
));
}
}
let mut unused: Vec<&str> = allowlist_hits
.iter()
.filter(|(_, count)| **count == 0)
.map(|(token, _)| *token)
.collect();
unused.sort();
let mut messages = drift;
if !unused.is_empty() {
messages.push(format!(
"unused allowlist entries (no longer appear backticked in any \
surface — remove them from the allowlist): {}",
unused.join(", ")
));
}
assert!(
messages.is_empty(),
"prompt-surface drift detected:\n {}",
messages.join("\n ")
);
}
#[tokio::test]
async fn companion_surfaces_reference_only_real_tools() {
use std::collections::HashSet;
use std::path::PathBuf;
let hooks_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.map(|p| p.join("claude-plugins/codescout-companion/hooks"));
let hooks_dir = match hooks_dir {
Some(p) if p.is_dir() => p,
_ => {
eprintln!(
"companion_surfaces_reference_only_real_tools: skipping — \
../claude-plugins/codescout-companion/hooks not present"
);
return;
}
};
let (_dir, server) = make_server().await;
let real_tools: HashSet<&str> = server.tools.iter().map(|t| t.name()).collect();
let non_codescout_tools: HashSet<&str> = ["activate_project"].into_iter().collect();
let stale_names = &[
"replace_symbol",
"insert_code",
"remove_symbol",
"edit_lines",
"create_or_update_file",
];
let positive_re = regex::Regex::new(r"mcp__codescout__\(?([a-z_|]+)\)?").unwrap();
let case_re = regex::Regex::new(r"\*__([a-z_]+)").unwrap();
let mut stale_regexes: Vec<(&str, regex::Regex)> = Vec::new();
for name in stale_names {
let re = regex::Regex::new(&format!(r"\b{}\b", regex::escape(name))).unwrap();
stale_regexes.push((name, re));
}
fn scrub_shell_comments(content: &str) -> String {
content
.lines()
.map(|l| {
let trimmed = l.trim_start();
if trimmed.starts_with('#') {
""
} else {
l
}
})
.collect::<Vec<_>>()
.join("\n")
}
let mut drift = Vec::<String>::new();
let entries: Vec<_> = std::fs::read_dir(&hooks_dir)
.unwrap()
.filter_map(Result::ok)
.collect();
for entry in entries {
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !matches!(ext, "sh" | "json") {
continue;
}
let fname = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
if fname.ends_with(".test.sh") {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
for cap in positive_re.captures_iter(&content) {
let group = cap.get(1).map(|m| m.as_str()).unwrap_or("");
for name in group.split('|') {
if name.is_empty() || real_tools.contains(name) {
continue;
}
drift.push(format!(
"{fname}: matcher references nonexistent tool \
`mcp__codescout__{name}` — update to a registered \
tool name or remove the alternation branch"
));
}
}
if ext == "sh" {
for cap in case_re.captures_iter(&content) {
let name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
if name.is_empty()
|| real_tools.contains(name)
|| non_codescout_tools.contains(name)
{
continue;
}
drift.push(format!(
"{fname}: case-statement branch `*__{name}` cannot \
fire — no codescout tool named `{name}` is registered \
(add to non_codescout_tools allowlist if it is a \
host-harness tool)"
));
}
}
let scrubbed = if ext == "sh" {
scrub_shell_comments(&content)
} else {
content.clone()
};
for (stale, re) in &stale_regexes {
if re.is_match(&scrubbed) {
drift.push(format!(
"{fname}: contains stale tool name `{stale}` in live \
(non-comment) code — replace with the live equivalent \
(e.g. `edit_code` consolidated \
replace_symbol/insert_code/remove_symbol)"
));
}
}
}
assert!(
drift.is_empty(),
"companion-surface drift detected:\n {}",
drift.join("\n ")
);
}
#[tokio::test]
async fn static_doc_sources_all_readable() {
use crate::mcp_resources::{doc::DocProvider, ResourceProvider};
let sources = super::static_doc_sources();
assert!(
!sources.is_empty(),
"static_doc_sources() should register at least one doc URI"
);
let provider = DocProvider::new(sources.clone());
for src in &sources {
let res = provider.read(&src.uri).await;
assert!(
res.is_ok(),
"doc:// URI {} failed to read: {:?}",
src.uri,
res.err()
);
assert!(
!src.content.is_empty(),
"doc:// URI {} embedded content is empty",
src.uri
);
}
}
#[tokio::test]
async fn every_tool_description_under_cap() {
const CAP: usize = 1800;
let (_dir, server) = make_server().await;
let over: Vec<(String, usize)> = server
.tools
.iter()
.map(|t| (t.name().to_string(), t.description().len()))
.filter(|(_, n)| *n > CAP)
.collect();
assert!(
over.is_empty(),
"tool descriptions over the {CAP}-char cap: {:?}",
over
);
}
#[tokio::test]
async fn find_tool_returns_none_for_unknown() {
let (_dir, server) = make_server().await;
assert!(server.find_tool("nonexistent_tool").is_none());
assert!(server.find_tool("").is_none());
assert!(server.find_tool("READ_FILE").is_none()); }
#[tokio::test]
async fn tool_names_are_unique() {
let (_dir, server) = make_server().await;
let mut names: Vec<&str> = server.tools.iter().map(|t| t.name()).collect();
let original_len = names.len();
names.sort();
names.dedup();
assert_eq!(names.len(), original_len, "duplicate tool names found");
}
#[tokio::test]
async fn all_tools_have_valid_schemas() {
let (_dir, server) = make_server().await;
for tool in &server.tools {
let schema = tool.input_schema();
assert!(
schema.is_object(),
"tool '{}' schema is not an object",
tool.name()
);
assert_eq!(
schema["type"],
"object",
"tool '{}' schema missing type:object",
tool.name()
);
}
}
#[tokio::test]
async fn pinnable_tools_advertise_workspace_param() {
let (_dir, server) = make_server().await;
let (mut saw_pinnable, mut saw_unpinnable) = (false, false);
for tool in &server.tools {
if tool.pinnable() {
let mut schema_obj = tool.input_schema().as_object().cloned().unwrap_or_default();
CodeScoutServer::inject_workspace_param(&mut schema_obj);
let has_ws = schema_obj
.get("properties")
.and_then(|p| p.as_object())
.is_some_and(|p| p.contains_key("workspace"));
assert!(
has_ws,
"pinnable tool '{}' must advertise the `workspace` param",
tool.name()
);
saw_pinnable = true;
} else {
saw_unpinnable = true;
}
}
assert!(
saw_pinnable && saw_unpinnable,
"partition must be non-trivial"
);
let pinnable: std::collections::HashSet<&str> = server
.tools
.iter()
.filter(|t| t.pinnable())
.map(|t| t.name())
.collect();
for n in ["read_file", "edit_file", "memory", "grep"] {
assert!(pinnable.contains(n), "{n} must be pinnable");
}
for n in ["workspace", "get_guide", "librarian", "get_usage_stats"] {
assert!(!pinnable.contains(n), "{n} must NOT be pinnable");
}
}
#[test]
fn inject_workspace_param_is_optional_and_idempotent() {
let mut schema = serde_json::json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
})
.as_object()
.unwrap()
.clone();
CodeScoutServer::inject_workspace_param(&mut schema);
let props = schema["properties"].as_object().unwrap();
assert!(
props.contains_key("workspace"),
"workspace must be injected"
);
assert!(
props.contains_key("path"),
"existing properties must be preserved"
);
let required: Vec<&str> = schema["required"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(
!required.contains(&"workspace"),
"workspace must stay optional"
);
CodeScoutServer::inject_workspace_param(&mut schema);
assert_eq!(schema["properties"].as_object().unwrap().len(), 2);
}
#[tokio::test]
async fn all_tools_have_descriptions() {
let (_dir, server) = make_server().await;
for tool in &server.tools {
let desc = tool.description();
assert!(
!desc.is_empty(),
"tool '{}' has empty description",
tool.name()
);
}
}
#[tokio::test]
async fn get_info_contains_instructions() {
let (_dir, server) = make_server().await;
let info = server.get_info();
assert!(info.instructions.is_some());
let instructions = info.instructions.unwrap();
assert!(!instructions.is_empty());
}
#[tokio::test]
async fn get_info_without_project_still_works() {
let server = make_server_no_project().await;
let info = server.get_info();
assert!(info.instructions.is_some());
}
#[tokio::test]
async fn server_instructions_mention_project_when_active() {
let (_dir, server) = make_server().await;
let info = server.get_info();
let instructions = info.instructions.unwrap();
assert!(
instructions.contains("Project:") || instructions.contains("project"),
"instructions should mention the active project"
);
}
#[test]
#[allow(deprecated)]
fn generate_auth_token_produces_nonempty_hex() {
let token = super::generate_auth_token();
assert!(!token.is_empty(), "token must not be empty");
assert_eq!(token.len(), 32, "token should be 32 hex chars");
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token must be valid hex: {}",
token
);
}
#[test]
#[allow(deprecated)]
fn generate_auth_token_is_unique_across_calls() {
let t1 = super::generate_auth_token();
let t2 = super::generate_auth_token();
assert_ne!(t1, t2, "consecutive tokens should differ");
}
#[tokio::test]
async fn shell_tool_allowed_by_default() {
let (_dir, server) = make_server().await;
let security = server.agent.security_config().await;
assert!(security.shell_enabled);
assert!(crate::util::path_security::check_tool_access("run_command", &security).is_ok());
}
#[test]
fn recoverable_error_routes_to_success_not_is_error() {
let err = anyhow::Error::new(crate::tools::RecoverableError::new("path not found"));
let result = route_tool_error(err);
assert!(
result.is_error != Some(true),
"RecoverableError must not set isError:true"
);
}
#[test]
fn recoverable_error_body_has_ok_false() {
let err = anyhow::Error::new(crate::tools::RecoverableError::new("old_string not found"));
let result = route_tool_error(err);
let text = &result.content[0].as_text().unwrap().text;
let body: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(
body["ok"],
serde_json::Value::Bool(false),
"RecoverableError body must include ok:false so models cannot confuse it with the success string \"ok\""
);
}
#[test]
fn recoverable_error_body_has_error_key() {
let err = anyhow::Error::new(crate::tools::RecoverableError::new(
"path not found: foo/bar",
));
let result = route_tool_error(err);
let text = &result.content[0].as_text().unwrap().text;
let body: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(body["error"], "path not found: foo/bar");
}
#[test]
fn recoverable_error_body_includes_hint_when_present() {
let err = anyhow::Error::new(crate::tools::RecoverableError::with_hint(
"not found",
"use tree to explore",
));
let result = route_tool_error(err);
let text = &result.content[0].as_text().unwrap().text;
let body: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(body["hint"], "use tree to explore");
}
#[test]
fn recoverable_error_without_hint_omits_hint_from_body() {
let err = anyhow::Error::new(crate::tools::RecoverableError::new("not found"));
let result = route_tool_error(err);
let text = &result.content[0].as_text().unwrap().text;
let body: serde_json::Value = serde_json::from_str(text).unwrap();
assert!(body.get("hint").is_none(), "hint key must be absent");
}
#[test]
fn recoverable_error_body_serializes_warning_under_warning_key() {
let err = anyhow::Error::new(crate::tools::RecoverableError::with_warning(
"too many results",
"narrow with path=",
));
let result = route_tool_error(err);
let text = &result.content[0].as_text().unwrap().text;
let body: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(body["warning"], "narrow with path=");
assert!(body.get("hint").is_none());
assert!(body.get("must_follow").is_none());
}
#[test]
fn recoverable_error_body_serializes_must_follow_under_must_follow_key() {
let err = anyhow::Error::new(crate::tools::RecoverableError::with_must_follow(
"heading too large",
"IRON LAW #6: use @file_xxx",
));
let result = route_tool_error(err);
let text = &result.content[0].as_text().unwrap().text;
let body: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(body["must_follow"], "IRON LAW #6: use @file_xxx");
assert!(body.get("hint").is_none());
assert!(body.get("warning").is_none());
}
#[test]
fn recoverable_error_body_splices_extra_fields_at_top_level() {
let err_struct =
crate::tools::RecoverableError::with_must_follow("heading too large", "IRON LAW #6")
.with_extra("file_id", serde_json::json!("@file_abc"))
.with_extra(
"section_map",
serde_json::json!([{"level": 2, "text": "## X", "line": 10}]),
);
let err: anyhow::Error = err_struct.into();
let result = route_tool_error(err);
let text = &result.content[0].as_text().unwrap().text;
let body: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(body["file_id"], "@file_abc");
assert_eq!(body["section_map"][0]["line"], 10);
assert_eq!(body["ok"], serde_json::Value::Bool(false));
assert_eq!(body["error"], "heading too large");
assert_eq!(body["must_follow"], "IRON LAW #6");
}
#[test]
fn plain_anyhow_error_routes_to_is_error_true() {
let err = anyhow::anyhow!("LSP crashed unexpectedly");
let result = route_tool_error(err);
assert_eq!(result.is_error, Some(true));
}
#[test]
fn lsp_request_cancelled_routes_to_recoverable_not_fatal() {
let err = anyhow::anyhow!("LSP error (code -32800): cancelled");
let result = route_tool_error(err);
assert!(
result.is_error != Some(true),
"LSP RequestCancelled must not set isError:true"
);
let text = &result.content[0].as_text().unwrap().text;
let body: serde_json::Value = serde_json::from_str(text).unwrap();
assert!(body.get("hint").is_some(), "must include retry hint");
}
#[test]
fn lsp_content_modified_routes_to_recoverable_not_fatal() {
let err = anyhow::anyhow!("LSP error (code -32801): content modified");
let result = route_tool_error(err);
assert!(
result.is_error != Some(true),
"LSP ContentModified must not set isError:true"
);
let text = &result.content[0].as_text().unwrap().text;
let body: serde_json::Value = serde_json::from_str(text).unwrap();
assert!(body.get("hint").is_some(), "must include retry hint");
assert!(
body["hint"].as_str().unwrap().contains("-32801")
|| body["hint"].as_str().unwrap().contains("ContentModified"),
"hint should mention -32801 / ContentModified, got: {}",
body["hint"]
);
}
#[test]
fn other_lsp_errors_still_route_to_is_error_true() {
let err = anyhow::anyhow!("LSP error (code -32603): internal error");
let result = route_tool_error(err);
assert_eq!(result.is_error, Some(true));
}
#[test]
fn run_command_skips_server_timeout() {
assert!(
tool_skips_server_timeout("run_command"),
"run_command must not be wrapped by the server-level timeout"
);
}
#[test]
fn indexing_tools_skip_server_timeout() {
assert!(tool_skips_server_timeout("index"));
assert!(tool_skips_server_timeout("index_library"));
}
#[test]
fn other_tools_do_not_skip_server_timeout() {
for name in &["read_file", "edit_file", "symbols", "semantic_search"] {
assert!(
!tool_skips_server_timeout(name),
"tool '{}' should be subject to the server-level timeout",
name
);
}
}
#[tokio::test]
async fn call_tool_strips_project_root_from_output() {
let (dir, server) = make_server().await;
let root = dir.path().to_string_lossy().to_string();
let req = CallToolRequestParams::new("tree")
.with_arguments(serde_json::from_value(serde_json::json!({"path": "."})).unwrap());
let result = server
.call_tool_inner(req, None, None, tokio_util::sync::CancellationToken::new())
.await
.unwrap();
let text = result
.content
.iter()
.find_map(|c| c.as_text().map(|t| t.text.as_str()))
.unwrap_or("");
assert!(
!text.is_empty(),
"tree returned empty output — the strip test is not actually exercising anything"
);
assert!(
!text.contains(&root),
"Expected absolute root to be stripped, but found it in output:\n{text}"
);
}
#[tokio::test]
async fn stripped_responses_emit_paths_relative_annotation_once_per_activation() {
let (_dir, server) = make_server().await;
let root = server
.agent
.project_root()
.await
.expect("server has an active project root")
.display()
.to_string();
let trimmed_root = root.trim_end_matches('/');
let make_payload = || {
CallToolResult::success(vec![Content::text(format!(
r#"{{"file":"{root}/src/main.rs","line":1}}"#
))])
};
let first = server.post_process(make_payload(), "read_file").await;
let joined: String = first
.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect::<Vec<_>>()
.join("\n");
assert!(
joined.contains(&format!("[codescout] paths are relative to {trimmed_root}")),
"first stripped response must carry the annotation, got:\n{joined}"
);
for tool_name in ["read_file", "tree", "symbols", "librarian", "grep"] {
let processed = server.post_process(make_payload(), tool_name).await;
let joined: String = processed
.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect::<Vec<_>>()
.join("\n");
assert!(
!joined.contains("[codescout] paths are relative to"),
"tool '{tool_name}' must not re-emit the annotation within the same activation window, got:\n{joined}"
);
}
let processed = server.post_process(make_payload(), "run_command").await;
let joined: String = processed
.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect::<Vec<_>>()
.join("\n");
assert!(
!joined.contains("[codescout] paths are relative to"),
"run_command must not get the annotation (its raw stdout is left unstripped), got:\n{joined}"
);
server
.path_note_emitted_since_activation
.store(false, std::sync::atomic::Ordering::Relaxed);
let after_reset = server.post_process(make_payload(), "read_file").await;
let joined: String = after_reset
.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect::<Vec<_>>()
.join("\n");
assert!(
joined.contains(&format!("[codescout] paths are relative to {trimmed_root}")),
"post-activation reset: next stripped response must re-emit the annotation, got:\n{joined}"
);
}
#[tokio::test]
async fn run_command_output_keeps_absolute_project_paths() {
let (dir, server) = make_server().await;
let abs = dir
.path()
.join("some")
.join("nested")
.join("path")
.to_string_lossy()
.into_owned();
let req = CallToolRequestParams::new("run_command").with_arguments(
serde_json::from_value(serde_json::json!({
"command": format!("echo {abs}"),
}))
.unwrap(),
);
let result = server
.call_tool_inner(req, None, None, tokio_util::sync::CancellationToken::new())
.await
.unwrap();
let text = result
.content
.iter()
.find_map(|c| c.as_text().map(|t| t.text.as_str()))
.unwrap_or("");
let parsed: serde_json::Value =
serde_json::from_str(text).expect("run_command result should be JSON");
let stdout = parsed["stdout"]
.as_str()
.expect("run_command result should expose `stdout` as a string");
assert!(
stdout.contains(&abs),
"run_command stdout must keep the absolute project path verbatim.\n expected substring: {abs}\n actual stdout: {stdout}"
);
}
#[tokio::test]
async fn call_tool_cancellation_kills_long_running_run_command() {
let (dir, server) = make_server().await;
let marker = dir.path().join("cancel-test-marker");
let marker_str = marker.to_string_lossy().to_string();
let req = CallToolRequestParams::new("run_command").with_arguments(
serde_json::from_value(serde_json::json!({
"command": format!("sleep 5 && touch '{}'", marker_str),
"timeout_secs": 30u64,
}))
.unwrap(),
);
let ct = tokio_util::sync::CancellationToken::new();
let server_clone = server.clone();
let ct_clone = ct.clone();
let handle = tokio::spawn(async move {
server_clone
.call_tool_inner(req, None, None, ct_clone)
.await
});
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
ct.cancel();
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
handle.abort();
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
assert!(
!marker.exists(),
"marker file {marker:?} exists — sleep child was NOT killed by cancel"
);
}
#[test]
fn strip_project_root_removes_prefix_from_text_content() {
let prefix = "/home/user/myproject/";
let result = CallToolResult::success(vec![Content::text(
r#"{"file":"/home/user/myproject/src/foo.rs","line":1}"#,
)]);
let (stripped, did_strip) = strip_project_root_from_result(result, prefix);
assert!(did_strip, "should report that stripping occurred");
assert_eq!(extract_text(&stripped), r#"{"file":"src/foo.rs","line":1}"#);
assert_eq!(stripped.content.len(), 1);
}
#[test]
fn strip_project_root_no_op_when_prefix_empty() {
let result = CallToolResult::success(vec![Content::text("some output")]);
let (stripped, did_strip) = strip_project_root_from_result(result, "");
assert!(!did_strip);
assert_eq!(extract_text(&stripped), "some output");
}
#[test]
fn strip_project_root_no_op_when_prefix_absent() {
let prefix = "/home/user/myproject/";
let result = CallToolResult::success(vec![Content::text("no paths here")]);
let (stripped, did_strip) = strip_project_root_from_result(result, prefix);
assert!(!did_strip);
assert_eq!(extract_text(&stripped), "no paths here");
assert_eq!(stripped.content.len(), 1);
}
#[test]
fn strip_prefix_only_at_value_boundary() {
let prefix = "/home/user/proj/";
assert_eq!(
strip_prefix_from_text("\"/home/user/proj/src/lib.rs\"", prefix),
"\"src/lib.rs\""
);
assert_eq!(
strip_prefix_from_text("/home/user/proj/src/lib.rs", prefix),
"src/lib.rs"
);
assert_eq!(
strip_prefix_from_text("could not open /home/user/proj/foo.rs", prefix),
"could not open foo.rs"
);
assert_eq!(
strip_prefix_from_text("files:\n/home/user/proj/a.rs\n/home/user/proj/b.rs", prefix),
"files:\na.rs\nb.rs"
);
}
#[test]
fn strip_prefix_not_inside_longer_path() {
let prefix = "/home/user/proj/";
let text = "/other/home/user/proj/src/lib.rs";
assert_eq!(strip_prefix_from_text(text, prefix), text);
}
#[test]
fn strip_prefix_not_inside_code_string_preceded_by_path_char() {
let prefix = "/home/user/proj/";
let text = "foo/home/user/proj/bar";
assert_eq!(strip_prefix_from_text(text, prefix), text);
}
#[tokio::test]
async fn list_tools_hides_lsp_tools_when_no_lsp() {
use crate::tools::{Availability, ToolCapabilities};
let caps_no_lsp = ToolCapabilities {
has_lsp: false,
has_embeddings: true,
has_git_remote: false,
has_libraries: false,
};
let caps_with_lsp = ToolCapabilities {
has_lsp: true,
has_embeddings: true,
has_git_remote: false,
has_libraries: false,
};
assert!(
!Availability::RequiresLsp.is_available(&caps_no_lsp),
"RequiresLsp should not be available when has_lsp=false"
);
assert!(
Availability::RequiresLsp.is_available(&caps_with_lsp),
"RequiresLsp should be available when has_lsp=true"
);
let (_dir, server) = make_server().await;
let caps = server.current_capabilities().await;
if !caps.has_lsp {
let visible: Vec<&str> = server
.tools
.iter()
.filter(|t| t.availability(&caps).is_available(&caps))
.map(|t| t.name())
.collect();
for lsp_tool in &["symbol_at", "references"] {
assert!(
!visible.contains(lsp_tool),
"LSP tool '{}' should be hidden when has_lsp=false",
lsp_tool
);
}
for always_tool in &["read_file", "tree", "memory", "workspace"] {
assert!(
visible.contains(always_tool),
"Always-available tool '{}' should remain visible",
always_tool
);
}
}
}
#[tokio::test]
async fn list_tools_shows_lsp_tools_when_has_lsp() {
use crate::tools::ToolCapabilities;
let caps_with_lsp = ToolCapabilities {
has_lsp: true,
has_embeddings: true,
has_git_remote: false,
has_libraries: false,
};
let (_dir, server) = make_server().await;
let visible: Vec<&str> = server
.tools
.iter()
.filter(|t| t.availability(&caps_with_lsp).is_available(&caps_with_lsp))
.map(|t| t.name())
.collect();
for lsp_tool in &["symbol_at", "references"] {
assert!(
visible.contains(lsp_tool),
"LSP tool '{}' should be visible when has_lsp=true",
lsp_tool
);
}
}
#[cfg(any(
feature = "local-embed",
feature = "local-embed-dynamic",
feature = "remote-embed"
))]
#[tokio::test]
async fn current_capabilities_returns_without_panic() {
let (_dir, server) = make_server().await;
let caps = server.current_capabilities().await;
assert!(
caps.has_embeddings,
"has_embeddings should be true when local-embed or remote-embed feature is active"
);
}
#[tokio::test]
async fn list_resources_includes_doc_and_summary() {
let (_dir, server) = make_server().await;
let rr = server.resources.read().await.clone();
let uris: Vec<String> = rr.list().into_iter().map(|d| d.uri).collect();
assert!(
uris.iter().any(|u| u.starts_with("doc://")),
"expected at least one doc:// URI, got: {uris:?}"
);
assert!(
uris.contains(&"project://summary".to_string()),
"expected project://summary URI, got: {uris:?}"
);
}
#[tokio::test]
async fn read_resource_roundtrips_project_summary() {
let (_dir, server) = make_server().await;
let rr = server.resources.read().await.clone();
let bytes = rr.read("project://summary").await.unwrap();
let text = match bytes {
crate::mcp_resources::ResourceBytes::Text(t) => t,
_ => panic!("expected text resource"),
};
let json: serde_json::Value =
serde_json::from_str(&text).expect("project://summary must be valid JSON");
for key in ["active_project", "index_status", "language", "lsp_ready"] {
assert!(
json.get(key).is_some(),
"missing key '{}' in summary JSON",
key
);
}
}
#[tokio::test]
async fn read_resource_unknown_returns_not_found() {
let (_dir, server) = make_server().await;
let rr = server.resources.read().await.clone();
let err = rr
.read("doc://does-not-exist")
.await
.expect_err("reading unknown URI must fail");
assert!(
matches!(err, crate::mcp_resources::ResourceError::NotFound(_)),
"expected NotFound, got: {err}"
);
}
#[tokio::test]
async fn get_info_advertises_resources_capability() {
let (_dir, server) = make_server().await;
let info = server.get_info();
assert!(
info.capabilities.resources.is_some(),
"server must advertise resources capability"
);
}
fn extract_text(result: &CallToolResult) -> String {
result
.content
.iter()
.find_map(|c| c.as_text().map(|t| t.text.clone()))
.unwrap_or_default()
}
#[tokio::test]
async fn is_write_call_classifies_plain_writes() {
use serde_json::json;
let (_dir, server) = make_server().await;
assert!(server.is_write_call("edit_file", &json!({})));
assert!(server.is_write_call("create_file", &json!({})));
assert!(server.is_write_call("edit_code", &json!({"action": "replace"})));
assert!(server.is_write_call("edit_code", &json!({"action": "insert"})));
assert!(server.is_write_call("edit_code", &json!({"action": "remove"})));
assert!(server.is_write_call("edit_code", &json!({"action": "rename"})));
assert!(server.is_write_call("edit_markdown", &json!({})));
assert!(server.is_write_call("index", &json!({"action": "build"})));
assert!(!server.is_write_call("index", &json!({"action": "status"})));
assert!(server.is_write_call("onboarding", &json!({})));
assert!(server.is_write_call("library", &json!({"action": "register"})));
assert!(!server.is_write_call("library", &json!({"action": "list"})));
assert!(!server.is_write_call("read_file", &json!({})));
assert!(!server.is_write_call("symbols", &json!({})));
}
#[tokio::test]
async fn is_write_call_memory_depends_on_action() {
use serde_json::json;
let (_dir, server) = make_server().await;
assert!(server.is_write_call("memory", &json!({"action": "write"})));
assert!(server.is_write_call("memory", &json!({"action": "remember"})));
assert!(server.is_write_call("memory", &json!({"action": "forget"})));
assert!(server.is_write_call("memory", &json!({"action": "delete"})));
assert!(server.is_write_call("memory", &json!({"action": "refresh_anchors"})));
assert!(!server.is_write_call("memory", &json!({"action": "read"})));
assert!(!server.is_write_call("memory", &json!({"action": "list"})));
assert!(!server.is_write_call("memory", &json!({"action": "recall"})));
assert!(!server.is_write_call("memory", &json!({})));
}
#[tokio::test]
async fn call_tool_by_name_dispatches_a_read_tool() {
let (_dir, server) = make_server().await;
let result = server
.call_tool_by_name("tree", serde_json::json!({ "path": "." }))
.await
.expect("dispatch ok");
assert!(result.is_error.is_none_or(|e| !e), "tree should succeed");
}
#[tokio::test]
async fn call_tool_by_name_rejects_unknown_tool() {
let (_dir, server) = make_server().await;
let err = server
.call_tool_by_name("does_not_exist", serde_json::json!({}))
.await;
assert!(err.is_err(), "unknown tool must error");
}
}
#[cfg(feature = "librarian")]
#[cfg(test)]
mod guide_hint_tests {
use super::*;
use serde_json::{json, Value};
use serial_test::serial;
struct EnvGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set<V: AsRef<std::ffi::OsStr>>(key: &'static str, value: V) -> Self {
let original = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, original }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.original.take() {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
async fn make_server() -> (tempfile::TempDir, EnvGuard, CodeScoutServer) {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let db_env = EnvGuard::set("LIBRARIAN_DB", dir.path().join("librarian.db"));
let agent = crate::agent::Agent::new(Some(dir.path().to_path_buf()))
.await
.unwrap();
let server = CodeScoutServer::new(agent).await;
(dir, db_env, server)
}
fn tool_by_name(server: &CodeScoutServer, name: &str) -> Arc<dyn crate::tools::Tool> {
server
.tools
.iter()
.find(|t| t.name() == name)
.unwrap_or_else(|| panic!("tool '{}' not registered", name))
.clone()
}
fn shared_ctx(server: &CodeScoutServer) -> crate::tools::ToolContext {
crate::tools::ToolContext {
agent: server.agent.clone(),
lsp: server.lsp.clone(),
output_buffer: server.output_buffer.clone(),
progress: None,
peer: None,
section_coverage: server.section_coverage.clone(),
guide_hints_emitted: server.guide_hints_emitted.clone(),
workspace_override: None,
}
}
fn extract_hint(content: &[rmcp::model::Content]) -> Option<String> {
let text = content.first()?.as_text()?.text.clone();
let v: Value = serde_json::from_str(&text).ok()?;
v.get("_guide_hint")
.and_then(|h| h.as_str())
.map(String::from)
}
#[tokio::test]
#[serial]
async fn first_artifact_call_emits_librarian_hint() {
let (_dir, _env, server) = make_server().await;
let ctx = shared_ctx(&server);
let tool = tool_by_name(&server, "artifact");
let result = tool
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
assert!(
extract_hint(&result)
.unwrap_or_default()
.contains("librarian"),
"expected _guide_hint mentioning 'librarian' on first artifact call"
);
}
#[tokio::test]
#[serial]
async fn is_write_call_classifies_librarian_surface() {
let (_dir, _env, server) = make_server().await;
assert!(server.is_write_call("artifact", &json!({"action": "create"})));
assert!(server.is_write_call("artifact", &json!({"action": "update"})));
assert!(server.is_write_call("artifact", &json!({"action": "move"})));
assert!(server.is_write_call("artifact", &json!({"action": "delete"})));
assert!(server.is_write_call("artifact", &json!({"action": "link"})));
assert!(!server.is_write_call("artifact", &json!({"action": "find"})));
assert!(!server.is_write_call("artifact", &json!({"action": "get"})));
assert!(!server.is_write_call("artifact", &json!({"action": "graph"})));
assert!(!server.is_write_call("artifact", &json!({"action": "state_at"})));
assert!(server.is_write_call("artifact_event", &json!({"action": "create"})));
assert!(!server.is_write_call("artifact_event", &json!({"action": "list"})));
assert!(server.is_write_call("artifact_augment", &json!({"id": "x"})));
assert!(!server.is_write_call("artifact_refresh", &json!({"action": "gather"})));
assert!(!server.is_write_call("artifact_refresh", &json!({"action": "list_stale"})));
assert!(server.is_write_call("librarian", &json!({"action": "reindex"})));
assert!(server.is_write_call("librarian", &json!({"action": "audit_doc_refs"})));
assert!(!server.is_write_call(
"librarian",
&json!({"action": "audit_doc_refs", "emit_tracker": false})
));
assert!(!server.is_write_call("librarian", &json!({"action": "context"})));
assert!(!server.is_write_call("librarian", &json!({"action": "doctor"})));
assert!(!server.is_write_call("librarian", &json!({"action": "tracker_design"})));
assert!(server.is_write_call("librarian", &json!({"action": "legibility_scan"})));
assert!(server.is_write_call(
"librarian",
&json!({"action": "legibility_scan", "write": true})
));
assert!(!server.is_write_call(
"librarian",
&json!({"action": "legibility_scan", "write": false})
));
}
#[tokio::test]
#[serial]
async fn second_artifact_call_no_hint() {
let (_dir, _env, server) = make_server().await;
let ctx = shared_ctx(&server);
let tool = tool_by_name(&server, "artifact");
let _ = tool
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
let result = tool
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
assert!(
extract_hint(&result).is_none(),
"second call must not re-emit the hint"
);
}
#[tokio::test]
#[serial]
async fn first_artifact_call_appends_librarian_guide_body_v2() {
let (_dir, _env, server) = make_server().await;
let ctx = shared_ctx(&server);
let tool = tool_by_name(&server, "artifact");
let result = tool
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
assert_eq!(
result.len(),
2,
"expected 2 content blocks on first librarian-topic call (primary + auto-injected guide), got {}",
result.len()
);
let second = result[1].as_text().expect("second block must be text");
assert!(
second
.text
.contains("<!-- auto-injected get_guide('librarian')"),
"second block missing auto-inject opening marker: {:?}",
&second.text[..second.text.len().min(200)]
);
assert!(
second
.text
.contains("<!-- end auto-injected get_guide('librarian') -->"),
"second block missing auto-inject closing marker"
);
assert!(
second.text.contains("artifact"),
"second block should contain librarian guide content (mentions 'artifact')"
);
}
#[tokio::test]
#[serial]
async fn second_artifact_call_no_guide_body_block_v2() {
let (_dir, _env, server) = make_server().await;
let ctx = shared_ctx(&server);
let tool = tool_by_name(&server, "artifact");
let _ = tool
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
let result = tool
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
assert_eq!(
result.len(),
1,
"second call must not re-inject the guide body block, got {} blocks",
result.len()
);
}
#[tokio::test]
#[serial]
async fn artifact_event_after_artifact_no_hint() {
let (_dir, _env, server) = make_server().await;
let ctx = shared_ctx(&server);
let artifact = tool_by_name(&server, "artifact");
let event = tool_by_name(&server, "artifact_event");
let _ = artifact
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
let result = event
.call_content(
json!({"action": "list", "artifact_id": "nonexistent"}),
&ctx,
)
.await;
if let Ok(content) = result {
assert!(
extract_hint(&content).is_none(),
"subsequent librarian-topic tool must not re-emit hint"
);
}
}
#[tokio::test]
#[serial]
async fn activate_project_resets_hints() {
let (dir, _env, server) = make_server().await;
let ctx = shared_ctx(&server);
let artifact = tool_by_name(&server, "artifact");
let workspace = tool_by_name(&server, "workspace");
let _ = artifact
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
let _ = workspace
.call_content(
json!({"action": "activate", "path": dir.path().to_str().unwrap()}),
&ctx,
)
.await
.unwrap();
let result = artifact
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
assert!(
extract_hint(&result)
.unwrap_or_default()
.contains("librarian"),
"activate should reset emitted set; first post-activate call should re-emit"
);
}
#[tokio::test]
#[serial]
async fn guide_ledger_survives_mcp_restart() {
let _sid = EnvGuard::set("CLAUDE_CODE_SESSION_ID", "restart-survival-session");
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let _db = EnvGuard::set("LIBRARIAN_DB", dir.path().join("librarian.db"));
{
let agent = crate::agent::Agent::new(Some(dir.path().to_path_buf()))
.await
.unwrap();
let server = CodeScoutServer::new(agent).await;
assert!(
!server.guide_hints_emitted.lock().contains("librarian"),
"a fresh session starts with an empty ledger"
);
server
.guide_hints_emitted
.lock()
.insert("librarian".to_string());
}
let agent = crate::agent::Agent::new(Some(dir.path().to_path_buf()))
.await
.unwrap();
let server2 = CodeScoutServer::new(agent).await;
assert!(
server2.guide_hints_emitted.lock().contains("librarian"),
"guide ledger must survive MCP restart within one conversation"
);
let _sid2 = EnvGuard::set("CLAUDE_CODE_SESSION_ID", "other-session");
let agent = crate::agent::Agent::new(Some(dir.path().to_path_buf()))
.await
.unwrap();
let server3 = CodeScoutServer::new(agent).await;
assert!(
!server3.guide_hints_emitted.lock().contains("librarian"),
"a different session must not inherit another session's ledger"
);
}
#[tokio::test]
#[serial]
async fn post_compact_rearms_guide_hints() {
let (_dir, _env, server) = make_server().await;
let ctx = shared_ctx(&server);
let artifact = tool_by_name(&server, "artifact");
let workspace = tool_by_name(&server, "workspace");
let _ = artifact
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
let _ = workspace
.call_content(json!({"action": "status", "post_compact": true}), &ctx)
.await
.unwrap();
let result = artifact
.call_content(json!({"action": "find", "kind": "tracker"}), &ctx)
.await
.unwrap();
assert!(
extract_hint(&result)
.unwrap_or_default()
.contains("librarian"),
"post_compact must re-arm guide hints so they re-inject after compaction"
);
}
#[tokio::test]
#[serial]
async fn run_command_without_overflow_no_progressive_hint() {
let (_dir, _env, server) = make_server().await;
let ctx = shared_ctx(&server);
let tool = tool_by_name(&server, "run_command");
let result = tool
.call_content(json!({"command": "echo small"}), &ctx)
.await
.unwrap();
assert!(
extract_hint(&result).is_none(),
"small output should not trigger progressive-disclosure hint"
);
}
#[cfg_attr(
target_os = "windows",
ignore = "uses 'yes filler | head -2000' shell pipeline — both 'yes' and 'head' are Unix-only and the inject_tee path-validator rejects Windows temp file paths (C:\\Users\\...\\Temp\\codescout-unfiltered-XXX has chars outside the [a-zA-Z0-9/_-.] allowlist). See docs/issues/2026-05-24-ci-windows-default-feature-failures.md"
)]
#[tokio::test]
#[serial]
async fn run_command_with_overflow_emits_progressive_hint_once() {
let (_dir, _env, server) = make_server().await;
let ctx = shared_ctx(&server);
let tool = tool_by_name(&server, "run_command");
let big = tool
.call_content(json!({"command": "yes filler | head -2000"}), &ctx)
.await
.unwrap();
assert!(
extract_hint(&big)
.unwrap_or_default()
.contains("progressive-disclosure"),
"overflowing output should emit progressive-disclosure hint"
);
let second = tool
.call_content(json!({"command": "yes filler | head -2000"}), &ctx)
.await
.unwrap();
assert!(
extract_hint(&second).is_none(),
"second overflow must not re-emit the hint"
);
}
}
#[allow(dead_code)]
struct WouldBlockThenData {
returned_eagain: bool,
}
impl tokio::io::AsyncRead for WouldBlockThenData {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
if !self.returned_eagain {
self.returned_eagain = true;
std::task::Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"EAGAIN",
)))
} else {
buf.put_slice(b"hello");
std::task::Poll::Ready(Ok(()))
}
}
}
#[tokio::test]
async fn resilient_stdin_absorbs_would_block() {
use std::future::Future;
use tokio::io::AsyncReadExt;
struct ResilientReader<R> {
inner: R,
backoff: Option<std::pin::Pin<Box<tokio::time::Sleep>>>,
}
impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for ResilientReader<R> {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let this = self.get_mut();
if let Some(ref mut sleep) = this.backoff {
if sleep.as_mut().poll(cx).is_pending() {
return std::task::Poll::Pending;
}
this.backoff = None;
}
match std::pin::Pin::new(&mut this.inner).poll_read(cx, buf) {
std::task::Poll::Ready(Err(ref e))
if e.kind() == std::io::ErrorKind::WouldBlock =>
{
let mut sleep =
Box::pin(tokio::time::sleep(std::time::Duration::from_millis(1)));
let _ = sleep.as_mut().poll(cx);
this.backoff = Some(sleep);
std::task::Poll::Pending
}
other => other,
}
}
}
let mock = WouldBlockThenData {
returned_eagain: false,
};
let mut reader = ResilientReader {
inner: mock,
backoff: None,
};
let mut buf = [0u8; 16];
let n = reader.read(&mut buf).await.expect("should not error");
assert_eq!(&buf[..n], b"hello");
}