use super::{
ToolResult, ToolResultDisplay, ToolRuntime,
args::{DiagnosticsArgs, ReferencesArgs},
contract::{metadata_key as meta, tool_name},
fs::ExistingPathPolicy,
};
use crate::{agent::cancellation::AgentCancellation, output::redact_sensitive_text};
use serde_json::json;
const LSP_DISABLED_DIAGNOSTICS: &str =
"LSP diagnostics unavailable: set lsp.enabled=true and configure a language server";
const LSP_DISABLED_REFERENCES: &str =
"LSP references unavailable: set lsp.enabled=true and configure a language server";
impl ToolRuntime {
pub(super) fn diagnostics(
&self,
args: DiagnosticsArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
let limit = args.limit();
let path = args
.path
.as_deref()
.map(|path| self.resolve_existing_path(path, ExistingPathPolicy::lsp()))
.transpose()?;
cancellation.check()?;
let Some(manager) = self.lsp_manager() else {
return Ok(lsp_tool_result(
tool_name::DIAGNOSTICS,
LSP_DISABLED_DIAGNOSTICS.to_string(),
json!({
(meta::LSP_ENABLED): false,
(meta::LIMIT): limit,
}),
));
};
manager.shutdown_idle();
let content = manager
.diagnostics_tool(path.as_deref(), limit, cancellation)
.map_err(redact_lsp_error)?;
manager.shutdown_idle();
Ok(lsp_tool_result(
tool_name::DIAGNOSTICS,
content.clone(),
json!({
(meta::LSP_ENABLED): true,
(meta::PATH): path.as_ref().map(|path| path.display().to_string()),
(meta::LIMIT): limit,
(meta::DIAGNOSTIC_COUNT): content.lines().filter(|line| line.starts_with("- ")).count(),
}),
))
}
pub(super) fn references(
&self,
args: ReferencesArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
let limit = args.limit();
let path = self.resolve_existing_path(&args.path, ExistingPathPolicy::lsp())?;
cancellation.check()?;
let Some(manager) = self.lsp_manager() else {
return Ok(lsp_tool_result(
tool_name::REFERENCES,
LSP_DISABLED_REFERENCES.to_string(),
json!({
(meta::LSP_ENABLED): false,
(meta::PATH): path.display().to_string(),
(meta::LIMIT): limit,
}),
));
};
let content = manager
.references_tool(
&path,
args.line,
args.column,
args.include_declaration,
limit,
cancellation,
)
.map_err(redact_lsp_error)?;
manager.shutdown_idle();
Ok(lsp_tool_result(
tool_name::REFERENCES,
content.clone(),
json!({
(meta::LSP_ENABLED): true,
(meta::PATH): path.display().to_string(),
(meta::LIMIT): limit,
(meta::REFERENCES): count_reference_lines(&content),
}),
))
}
}
fn lsp_tool_result(tool_name: &str, content: String, metadata: serde_json::Value) -> ToolResult {
ToolResult {
tool_name: tool_name.to_string(),
success: true,
content,
metadata,
display: ToolResultDisplay::default(),
}
}
fn redact_lsp_error(error: anyhow::Error) -> anyhow::Error {
anyhow::anyhow!(redact_sensitive_text(&error.to_string()))
}
fn count_reference_lines(content: &str) -> usize {
content
.lines()
.filter(|line| !line.trim().is_empty() && !line.starts_with("... truncated"))
.count()
}
#[cfg(test)]
mod tests {
use crate::{
config::{LspServerConfig, LspSettings, McPaths, Settings},
tools::ToolRuntime,
};
use serde_json::json;
use std::fs;
#[test]
fn diagnostics_and_references_dispatch_return_disabled_guidance() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("lib.rs");
fs::write(&path, "fn main() {}\n").unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let diagnostics = runtime.dispatch("diagnostics", json!({"path":"lib.rs"}));
assert!(diagnostics.success);
assert_eq!(diagnostics.tool_name, "diagnostics");
assert!(diagnostics.content.contains("lsp.enabled=true"));
let references =
runtime.dispatch("references", json!({"path":"lib.rs","line":1,"column":1}));
assert!(references.success);
assert_eq!(references.tool_name, "references");
assert!(references.content.contains("lsp.enabled=true"));
}
#[test]
fn diagnostics_and_references_reject_paths_outside_cwd() {
let root = tempfile::TempDir::new().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
let runtime = ToolRuntime::new(root.path()).unwrap();
let diagnostics = runtime.dispatch("diagnostics", json!({"path": outside.path()}));
assert!(!diagnostics.success);
assert!(diagnostics.content.contains("escapes cwd"));
let references = runtime.dispatch(
"references",
json!({"path": outside.path(), "line": 1, "column": 1}),
);
assert!(!references.success);
assert!(references.content.contains("escapes cwd"));
}
fn lsp_error_server_script() -> String {
r#"
import json, sys
def read_msg():
header = b''
while not header.endswith(b'\r\n\r\n'):
chunk = sys.stdin.buffer.readline()
if not chunk:
return None
header += chunk
length = 0
for line in header.decode().splitlines():
if line.lower().startswith('content-length:'):
length = int(line.split(':', 1)[1].strip())
return json.loads(sys.stdin.buffer.read(length).decode())
def send(value):
body = json.dumps(value, separators=(',', ':')).encode()
sys.stdout.buffer.write(b'Content-Length: ' + str(len(body)).encode() + b'\r\n\r\n' + body)
sys.stdout.buffer.flush()
while True:
msg = read_msg()
if msg is None:
break
method = msg.get('method')
if method == 'initialize':
send({'jsonrpc':'2.0','id':msg['id'],'result':{'capabilities':{'textDocumentSync':1,'referencesProvider':True}}})
elif method in ('initialized','textDocument/didOpen','textDocument/didChange'):
pass
elif method == 'textDocument/references':
send({'jsonrpc':'2.0','id':msg['id'],'error':{'code':-32000,'message':'api_key=' + 'sk-' + ('x' * 24) + ' token=' + ('x' * 16)}})
elif method == 'shutdown':
send({'jsonrpc':'2.0','id':msg['id'],'result':None})
elif method == 'exit':
break
"#
.to_string()
}
#[test]
fn lsp_manager_errors_are_redacted_before_tool_result_content() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("lib.rs");
fs::write(&path, "fn main() {}\n").unwrap();
let api_key = format!("sk-{}", "x".repeat(24));
let token = "x".repeat(16);
let mut settings = Settings {
lsp: LspSettings {
enabled: true,
diagnostics_wait_ms: 1_000,
..LspSettings::default()
},
..Settings::default()
};
settings.lsp.servers.insert(
"rust-analyzer".to_string(),
LspServerConfig {
command: "python3".to_string(),
args: vec![
"-u".to_string(),
"-c".to_string(),
lsp_error_server_script(),
],
enabled: true,
},
);
let runtime = ToolRuntime::new_with_full_settings_and_mcp(
temp.path(),
McPaths::from_root(temp.path().join("mc-home")),
settings,
None,
)
.unwrap();
let result = runtime.dispatch("references", json!({"path":"lib.rs","line":1,"column":1}));
assert!(!result.success);
assert!(result.content.contains("<redacted>"), "{}", result.content);
assert!(!result.content.contains(&api_key));
assert!(!result.content.contains(&token));
}
}