use std::sync::Arc;
use rmcp::ErrorData as McpError;
use rmcp::model::{CallToolResult, ContentBlock};
use serde::Serialize;
use super::ServerState;
use super::types::{BlameHunkView, BlameResponse, BlameSymbolResponse, CommitFileView, CommitView};
use crate::extract::SymbolKind;
use crate::lang::LangId;
pub(super) use super::helpers_calls::{RefsSource, run_find_callers, run_find_references};
#[cfg(feature = "documents")]
pub(super) use super::helpers_documents::format_response;
pub(super) use super::helpers_files::{run_find_files, run_list_files};
pub(super) use super::helpers_fingerprint::{HashMode, outline_entry_for_blob, parse_hash_mode, symbol_fingerprint};
pub(super) use super::helpers_graph::run_call_graph;
pub(super) use super::helpers_grep::run_workspace_grep;
pub(super) use super::helpers_impls::run_find_implementations;
pub(super) use super::helpers_telemetry::record_call;
pub(super) const SEARCH_LIMIT_DEFAULT: u32 = 100;
pub(super) const SEARCH_LIMIT_MAX: u32 = 1000;
pub(super) const LIST_LIMIT_DEFAULT: u32 = 200;
pub(super) const LIST_LIMIT_MAX: u32 = 5000;
pub(super) const LOG_LIMIT_DEFAULT: u32 = 20;
pub(super) const LOG_LIMIT_MAX: u32 = 100;
pub(super) const LOG_WALK_MAX: usize = 10_000;
pub(super) const BLAME_LIMIT_MAX: u32 = 1000;
#[macro_export]
macro_rules! instrument_tool {
($state:expr, $tool:literal, $params:expr, $body:expr) => {{
let __started = ::std::time::Instant::now();
let __params_json = ::serde_json::to_value(&$params).unwrap_or(::serde_json::Value::Null);
let __result = $body;
$crate::mcp::helpers::record_call($state, $tool, &__params_json, __started, &__result);
__result
}};
}
pub(super) fn kind_to_str(k: SymbolKind) -> &'static str {
match k {
SymbolKind::Function => "function",
SymbolKind::Method => "method",
SymbolKind::Struct => "struct",
SymbolKind::Enum => "enum",
SymbolKind::Class => "class",
SymbolKind::Interface => "interface",
SymbolKind::Trait => "trait",
SymbolKind::Type => "type",
SymbolKind::Const => "const",
SymbolKind::Module => "module",
SymbolKind::Macro => "macro",
SymbolKind::Impl => "impl",
SymbolKind::Namespace => "namespace",
SymbolKind::Getter => "getter",
SymbolKind::Setter => "setter",
SymbolKind::Field => "field",
SymbolKind::Variable => "variable",
SymbolKind::EnumVariant => "enum_variant",
SymbolKind::Constructor => "constructor",
SymbolKind::Decorator => "decorator",
SymbolKind::Heading => "heading",
SymbolKind::Unknown => "unknown",
}
}
pub(super) fn parse_kind(s: &str) -> Result<SymbolKind, McpError> {
Ok(match s.to_ascii_lowercase().as_str() {
"function" => SymbolKind::Function,
"method" => SymbolKind::Method,
"struct" => SymbolKind::Struct,
"enum" => SymbolKind::Enum,
"class" => SymbolKind::Class,
"interface" => SymbolKind::Interface,
"trait" => SymbolKind::Trait,
"type" => SymbolKind::Type,
"const" => SymbolKind::Const,
"module" => SymbolKind::Module,
"macro" => SymbolKind::Macro,
"impl" => SymbolKind::Impl,
"namespace" => SymbolKind::Namespace,
"getter" => SymbolKind::Getter,
"setter" => SymbolKind::Setter,
"field" => SymbolKind::Field,
"variable" => SymbolKind::Variable,
"enum_variant" | "variant" => SymbolKind::EnumVariant,
"constructor" => SymbolKind::Constructor,
"decorator" => SymbolKind::Decorator,
"heading" => SymbolKind::Heading,
other => {
return Err(McpError::invalid_params(format!("unknown symbol kind: {other}"), None));
}
})
}
pub(super) fn json_result<T: Serialize>(value: &T) -> Result<CallToolResult, McpError> {
let content =
ContentBlock::json(value).map_err(|e| McpError::internal_error(format!("serialize response: {e}"), None))?;
Ok(CallToolResult::success(vec![content]))
}
pub(super) use timing::elapsed_us;
pub(super) mod timing {
use std::time::Instant;
pub(in crate::mcp) fn elapsed_us(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)
}
}
pub(super) fn commit_to_view(c: crate::git::CommitInfo, include_files: bool) -> CommitView {
let files = if include_files {
Some(
c.files
.into_iter()
.map(|(path, kind)| CommitFileView {
path,
change: kind.as_str(),
})
.collect(),
)
} else {
None
};
CommitView {
sha: c.sha,
short_sha: c.short_sha,
summary: c.summary,
author: c.author,
author_time_unix: c.author_time_unix,
files,
}
}
pub(super) fn require_git_repo(state: &ServerState) -> Result<&Arc<crate::git::Repo>, McpError> {
state.shared.repo.as_ref().ok_or_else(|| {
McpError::invalid_request(
"this tool requires `basemind serve` to be run inside a git repository",
None,
)
})
}
pub(super) fn git_history_if_fresh<'a>(
state: &'a ServerState,
head: &str,
) -> Option<&'a crate::git_history::GitHistoryIndex> {
let index = state.shared.git_history.as_deref()?;
(index.last_indexed_head_hex().as_deref() == Some(head)).then_some(index)
}
pub(super) fn head_snapshot_id(head_sha: &str) -> u32 {
let bytes = head_sha.as_bytes();
if bytes.len() < 8 {
return 0;
}
let mut out: u32 = 0;
for &b in &bytes[..8] {
let nibble = match b {
b'0'..=b'9' => b - b'0',
b'a'..=b'f' => b - b'a' + 10,
b'A'..=b'F' => b - b'A' + 10,
_ => return 0,
};
out = (out << 4) | (nibble as u32);
}
out
}
pub(crate) fn normalize_for_history(lang: LangId, raw: &[u8]) -> Vec<u8> {
let lc_marker = line_comment_marker(lang);
let block_open: &[u8] = b"/*";
let block_close: &[u8] = b"*/";
let has_block = has_block_comments(lang);
let block_close_finder = if has_block {
Some(memchr::memmem::Finder::new(block_close))
} else {
None
};
let mut out = Vec::with_capacity(raw.len());
let mut i = 0;
while i < raw.len() {
if !lc_marker.is_empty() && raw[i..].starts_with(lc_marker) {
i += lc_marker.len();
i = memchr::memchr(b'\n', &raw[i..]).map(|off| i + off).unwrap_or(raw.len());
continue;
}
if has_block && raw[i..].starts_with(block_open) {
i += block_open.len();
if let Some(finder) = &block_close_finder
&& let Some(off) = finder.find(&raw[i..])
{
i = (i + off + block_close.len()).min(raw.len());
} else {
i = raw.len();
}
continue;
}
if raw[i].is_ascii_whitespace() {
if !out.is_empty() && out.last() != Some(&b' ') {
out.push(b' ');
}
while i < raw.len() && raw[i].is_ascii_whitespace() {
i += 1;
}
continue;
}
out.push(raw[i]);
i += 1;
}
while out.last() == Some(&b' ') {
out.pop();
}
out
}
fn line_comment_marker(lang: LangId) -> &'static [u8] {
match lang {
"python" | "ruby" | "shell" | "bash" | "yaml" | "toml" | "make" => b"#",
"rust" | "typescript" | "tsx" | "javascript" | "go" | "cpp" | "c" | "java" | "csharp" | "kotlin" | "swift"
| "scala" | "zig" => b"//",
_ => b"",
}
}
fn has_block_comments(lang: LangId) -> bool {
matches!(
lang,
"rust"
| "typescript"
| "tsx"
| "javascript"
| "go"
| "cpp"
| "c"
| "java"
| "csharp"
| "kotlin"
| "swift"
| "scala"
| "css"
| "json"
)
}
pub(super) fn blame_hunk_view(h: &crate::git::BlameHunk) -> BlameHunkView {
BlameHunkView {
commit_sha: h.commit_sha.clone(),
short_sha: h.short_sha.clone(),
start_line: h.start_line,
len: h.len,
source_start_line: h.source_start_line,
author: h.author.clone(),
author_time_unix: h.author_time_unix,
summary: h.summary.clone(),
source_path: h.source_path.clone(),
}
}
pub(super) fn paginate_blame_hunks<'a, I>(
iter: I,
resume_after: u32,
limit: Option<u32>,
) -> (Vec<BlameHunkView>, Option<super::cursor::Cursor>)
where
I: IntoIterator<Item = &'a crate::git::BlameHunk>,
{
let cap = limit.map(|n| n.min(BLAME_LIMIT_MAX) as usize);
let mut out: Vec<BlameHunkView> = Vec::new();
let mut last_line: u32 = 0;
let mut has_more = false;
for h in iter {
if h.start_line <= resume_after {
continue;
}
if let Some(c) = cap
&& out.len() >= c
{
has_more = true;
break;
}
last_line = h.start_line;
out.push(blame_hunk_view(h));
}
let next_cursor = if has_more {
Some(super::cursor::Cursor::encode_in_memory(last_line as u64, 0))
} else {
None
};
(out, next_cursor)
}
fn line_range_in_blob(sym: &crate::extract::Symbol, bytes: &[u8]) -> (u32, u32) {
let start_line = sym.start_row + 1;
let s = sym.start_byte as usize;
let e = (sym.end_byte as usize).min(bytes.len());
let slice = if s < e { &bytes[s..e] } else { &[][..] };
let newlines = memchr::memchr_iter(b'\n', slice).count() as u32;
(start_line, start_line + newlines)
}
pub(super) fn symbol_line_range(
repo: &crate::git::Repo,
path: &crate::path::RelPath,
sym: &crate::extract::Symbol,
) -> (u32, u32) {
let bytes = path
.as_str()
.and_then(|s| repo.read_blob_at_rev("HEAD", s).ok().flatten())
.or_else(|| path.as_str().and_then(|s| repo.read_blob_staged(s).ok().flatten()))
.or_else(|| std::fs::read(repo.workdir().join(path.to_path_buf())).ok())
.unwrap_or_default();
line_range_in_blob(sym, &bytes)
}
pub(super) fn blame_too_large_response(
path: &crate::path::RelPath,
suspect_sha: &str,
err: &crate::git_cache::CacheError,
started: std::time::Instant,
) -> Option<BlameResponse> {
if matches!(
err,
crate::git_cache::CacheError::Git(crate::git::GitError::BlameTooLarge { .. })
) {
Some(BlameResponse {
path: path.clone(),
suspect_sha: suspect_sha.to_string(),
hunks: Vec::new(),
truncated: true,
truncated_reason: Some("too_large"),
next_cursor: None,
elapsed_us: elapsed_us(started),
})
} else {
None
}
}
pub(super) fn blame_symbol_too_large_response(
path: &crate::path::RelPath,
suspect_sha: &str,
sym: &crate::extract::Symbol,
line_start: u32,
line_end: u32,
err: &crate::git_cache::CacheError,
started: std::time::Instant,
) -> Option<BlameSymbolResponse> {
if matches!(
err,
crate::git_cache::CacheError::Git(crate::git::GitError::BlameTooLarge { .. })
) {
Some(BlameSymbolResponse {
path: path.clone(),
suspect_sha: suspect_sha.to_string(),
name: sym.name.clone(),
kind: kind_to_str(sym.kind).to_string(),
line_start,
line_end,
hunks: Vec::new(),
truncated: true,
truncated_reason: Some("too_large"),
next_cursor: None,
elapsed_us: elapsed_us(started),
})
} else {
None
}
}
pub(super) fn head_sha(repo: &crate::git::Repo) -> Result<String, McpError> {
let info = repo
.info()
.map_err(|e| McpError::internal_error(format!("HEAD: {e}"), None))?;
info.head_sha
.ok_or_else(|| McpError::internal_error("repository has no HEAD", None))
}
pub(super) async fn scan_and_refresh(
state: Arc<ServerState>,
scoped_paths: Option<Vec<std::path::PathBuf>>,
embed: crate::scanner::EmbedMode,
) -> Result<crate::scanner::ScanReport, McpError> {
if state.shared.read_only {
return Err(McpError::invalid_request(
"this basemind serve is read-only: another serve process holds the write lock for \
this repo, so it owns index refresh. Reads are served from the shared index; run \
rescans from the lock-holding serve (or close it and retry).",
None,
));
}
let root = state.shared.root.clone();
let config = Arc::clone(&state.shared.config);
let was_scoped = scoped_paths.is_some();
let state_for_scan = Arc::clone(&state);
let report = tokio::task::spawn_blocking(move || {
let mut store = state_for_scan.shared.store.blocking_write();
if let Some(paths) = scoped_paths {
crate::scanner::scan_paths(&root, &mut store, &config, &paths, embed)
} else {
crate::scanner::scan(
&root,
&mut store,
&config,
crate::scanner::ScanSource::WorkingTree,
embed,
)
}
})
.await
.map_err(|e| McpError::internal_error(format!("scan join: {e}"), None))?
.map_err(|e| McpError::internal_error(format!("rescan: {e}"), None))?;
if was_scoped && report.stats.updated == 0 && report.stats.removed == 0 {
return Ok(report);
}
let updated: Vec<crate::path::RelPath> = report
.results
.iter()
.filter(|r| matches!(r.status, crate::scanner::FileStatus::Updated { .. }))
.map(|r| crate::path::RelPath::from(r.path.as_str()))
.collect();
let removed: Vec<crate::path::RelPath> = report
.results
.iter()
.filter(|r| matches!(r.status, crate::scanner::FileStatus::Removed))
.map(|r| crate::path::RelPath::from(r.path.as_str()))
.collect();
let new_cache = {
let store = state.shared.store.read().await;
let corpus_bytes: u64 = store.index.files.values().map(|e| e.size_bytes).sum();
state
.shared
.corpus_bytes
.store(corpus_bytes, std::sync::atomic::Ordering::Relaxed);
let cache = if was_scoped {
state.shared.cache.load().with_delta(&store, &updated, &removed)
} else {
super::MapCache::build(&store)
};
std::sync::Arc::new(cache)
};
state.shared.cache.store(new_cache);
state
.shared
.cache_generation
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
#[cfg(feature = "memory")]
super::helpers_governance::audit_scope_on_rescan(&state).await;
Ok(report)
}
pub(super) async fn run_telemetry_summary(
state: &ServerState,
params: super::types::TelemetrySummaryParams,
) -> Result<CallToolResult, McpError> {
let response = super::telemetry::summarize(state.shared.telemetry.path(), params).await?;
json_result(&response)
}
#[cfg(test)]
mod tests {
use super::normalize_for_history;
use crate::lang::LangId;
const RUST: LangId = "rust";
const PYTHON: LangId = "python";
#[test]
fn rust_whitespace_only_changes_normalize_equal() {
let a = b"fn foo() {\n let x = 1;\n}";
let b = b"fn foo() {\r\n let x = 1;\n }\n";
assert_eq!(
normalize_for_history(RUST, a),
normalize_for_history(RUST, b),
"autoformat-style whitespace changes should normalize to the same bytes"
);
}
#[test]
fn rust_line_comment_changes_normalize_equal() {
let a = b"fn foo() { let x = 1; }";
let b = b"fn foo() {\n // explain x\n let x = 1; // trailing\n}";
assert_eq!(
normalize_for_history(RUST, a),
normalize_for_history(RUST, b),
"adding line comments should not register as a symbol-body change"
);
}
#[test]
fn rust_block_comment_changes_normalize_equal() {
let a = b"fn foo() { let x = 1; }";
let b = b"fn foo() { /* docs */ let x = 1; /* trailing */ }";
assert_eq!(
normalize_for_history(RUST, a),
normalize_for_history(RUST, b),
"adding block comments should not register as a symbol-body change"
);
}
#[test]
fn semantic_change_still_differs() {
let a = b"fn foo() { let x = 1; }";
let b = b"fn foo() { let x = 2; }";
assert_ne!(
normalize_for_history(RUST, a),
normalize_for_history(RUST, b),
"a literal value change must still register as different"
);
}
#[test]
fn python_uses_hash_comments() {
let a = b"def foo():\n return 1";
let b = b"def foo():\n # comment\n return 1";
assert_eq!(normalize_for_history(PYTHON, a), normalize_for_history(PYTHON, b),);
}
use super::{HashMode, outline_entry_for_blob, symbol_fingerprint};
use crate::mcp::OutlineCache;
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
fn fresh_cache() -> OutlineCache {
Mutex::new(lru::LruCache::new(NonZeroUsize::new(8).unwrap()))
}
fn fingerprint_for(source: &[u8], lang: LangId, mode: HashMode) -> Vec<u8> {
let cache = fresh_cache();
let oid: gix::ObjectId = "0000000000000000000000000000000000000001"
.parse()
.expect("synthetic oid");
let entry = outline_entry_for_blob(&cache, oid, lang, source.to_vec()).expect("outline entry");
symbol_fingerprint(&entry, "alpha", None, lang, mode).expect("fingerprint")
}
#[test]
fn structural_hash_ignores_formatter_and_comments() {
let a = b"pub fn alpha() {\n let x = 1;\n x + 1\n}\n";
let b = b"pub fn alpha() { /* doc */\n let x = 1; // explain\n x + 1\n}\n";
assert_eq!(
fingerprint_for(a, RUST, HashMode::Structural),
fingerprint_for(b, RUST, HashMode::Structural),
"structural hash must be stable under formatting + comment edits"
);
}
#[test]
fn structural_hash_catches_literal_change() {
let a = b"pub fn alpha() {\n let x = 1;\n x + 1\n}\n";
let b = b"pub fn alpha() {\n let x = 2;\n x + 1\n}\n";
assert_ne!(
fingerprint_for(a, RUST, HashMode::Structural),
fingerprint_for(b, RUST, HashMode::Structural),
"Structural mode must register a literal value change as a body change"
);
}
#[test]
fn structural_loose_ignores_literal_change() {
let a = b"pub fn alpha() {\n let x = 1;\n x + 1\n}\n";
let b = b"pub fn alpha() {\n let x = 2;\n x + 1\n}\n";
assert_eq!(
fingerprint_for(a, RUST, HashMode::StructuralLoose),
fingerprint_for(b, RUST, HashMode::StructuralLoose),
"StructuralLoose must ignore literal value churn"
);
}
#[test]
fn structural_loose_still_catches_identifier_rename() {
let a = b"pub fn alpha() {\n let original = 1;\n original + 1\n}\n";
let b = b"pub fn alpha() {\n let renamed = 1;\n renamed + 1\n}\n";
assert_ne!(
fingerprint_for(a, RUST, HashMode::StructuralLoose),
fingerprint_for(b, RUST, HashMode::StructuralLoose),
"StructuralLoose must still catch identifier renames"
);
}
#[test]
fn line_range_counts_newlines_against_the_given_blob() {
use super::line_range_in_blob;
use crate::extract::{Symbol, SymbolKind};
let blob = b"// hdr\n\nfn alpha() {\n body;\n}\nfn beta() {}\n";
let start = blob.windows(8).position(|w| w == b"fn alpha").unwrap();
let end = blob.iter().position(|&b| b == b'}').unwrap() + 1;
let sym = Symbol {
name: "alpha".into(),
kind: SymbolKind::Function,
start_byte: start as u32,
end_byte: end as u32,
start_row: 2,
start_col: 0,
signature: None,
decorators: Vec::new(),
};
assert_eq!(line_range_in_blob(&sym, blob), (3, 5));
assert_eq!(line_range_in_blob(&sym, &blob[..start + 4]), (3, 3));
}
#[test]
fn outline_cache_returns_same_arc_for_same_oid() {
let cache = fresh_cache();
let oid: gix::ObjectId = "0000000000000000000000000000000000000002".parse().unwrap();
let src = b"pub fn alpha() {}\n".to_vec();
let a = outline_entry_for_blob(&cache, oid, RUST, src.clone()).unwrap();
let b = outline_entry_for_blob(&cache, oid, RUST, src).unwrap();
assert!(Arc::ptr_eq(&a, &b), "second lookup must return the same cached Arc");
}
}