use std::path::{Component, Path, PathBuf};
use std::process::{Command as Shell, Stdio};
use std::sync::{Arc, Mutex};
use anyhow::Result;
use pluck_core::callees::extract_callees;
use pluck_core::chunker::Language;
use pluck_core::digest::{self, Format};
use pluck_core::index::{ImpactHit, PluckIndex, SearchHit};
use pluck_core::indexer::index_repo;
use pluck_core::outliner::{outline_source, render as render_outline};
use pluck_core::semantic::{selected_model_id, StaticEncoder};
use pluck_core::watcher::{spawn_watcher, WatcherHandle, DEFAULT_DEBOUNCE};
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{ProtocolVersion, ServerCapabilities, ServerInfo},
schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler,
};
use serde::Deserialize;
use crate::session::SessionState;
#[derive(Clone)]
pub struct PluckServer {
inner: Arc<ServerInner>,
tool_router: ToolRouter<Self>,
}
struct ServerInner {
repo_root: PathBuf,
index: Arc<PluckIndex>,
session: Mutex<SessionState>,
_watcher: Option<WatcherHandle>,
}
impl PluckServer {
pub fn new(repo_root: PathBuf) -> Result<Self> {
Self::build(repo_root, None, false, None)
}
pub fn new_with_watcher(repo_root: PathBuf) -> Result<Self> {
Self::build(repo_root, Some(DEFAULT_DEBOUNCE), true, None)
}
fn build(
repo_root: PathBuf,
debounce: Option<std::time::Duration>,
load_encoder: bool,
model_id_override: Option<String>,
) -> Result<Self> {
let encoder: Option<Arc<StaticEncoder>> = if !load_encoder {
None
} else if std::env::var("PLUCK_DISABLE_EMBEDDINGS").is_ok() {
tracing::info!("PLUCK_DISABLE_EMBEDDINGS set; running BM25-only");
None
} else {
let model_id = model_id_override.unwrap_or_else(selected_model_id);
match StaticEncoder::load_or_fetch(&model_id) {
Ok(enc) => {
tracing::info!(
model = model_id,
dim = enc.dim(),
"embedding encoder loaded; hybrid search active"
);
Some(Arc::new(enc))
}
Err(e) => {
tracing::warn!("failed to load embedding model ({e}); running BM25-only");
None
}
}
};
let mut idx = PluckIndex::in_ram()?;
if let Some(enc) = encoder.as_ref() {
idx = idx.with_encoder(Arc::clone(enc));
}
let index = Arc::new(idx);
let stats = index_repo(&index, &repo_root)?;
tracing::info!(
files = stats.files_indexed,
chunks = stats.chunks_indexed,
repo = ?repo_root,
"indexed repo on startup"
);
let watcher = match debounce {
Some(d) => match spawn_watcher(repo_root.clone(), Arc::clone(&index), d) {
Ok(w) => Some(w),
Err(e) => {
tracing::warn!("watcher failed to start: {e}; running without auto-reindex");
None
}
},
None => None,
};
Ok(Self {
inner: Arc::new(ServerInner {
repo_root,
index,
session: Mutex::new(SessionState::default()),
_watcher: watcher,
}),
tool_router: Self::tool_router(),
})
}
fn resolve_in_repo(&self, path: &str) -> Result<PathBuf, McpError> {
let raw = PathBuf::from(path);
let joined = if raw.is_absolute() {
raw
} else {
self.inner.repo_root.join(raw)
};
let normalized = normalize_path(&joined);
let root = normalize_path(&self.inner.repo_root);
if !normalized.starts_with(&root) {
return Err(McpError::invalid_params(
format!(
"pluck: {path}: path is outside the indexed repo ({}). \
Use bash for byte-level work outside the indexed root.",
root.display()
),
None,
));
}
Ok(normalized)
}
}
fn normalize_path(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
fn configured_protocol_version() -> ProtocolVersion {
protocol_version_from_env(std::env::var("PLUCK_MCP_PROTOCOL_VERSION").ok().as_deref())
}
fn protocol_version_from_env(value: Option<&str>) -> ProtocolVersion {
match value {
Some("2025-11-25") | None => ProtocolVersion::V_2025_11_25,
Some("2025-06-18") => ProtocolVersion::V_2025_06_18,
Some("2025-03-26") => ProtocolVersion::V_2025_03_26,
Some("2024-11-05") => ProtocolVersion::V_2024_11_05,
Some(other) => {
tracing::warn!(
version = other,
"unsupported PLUCK_MCP_PROTOCOL_VERSION; using 2025-11-25"
);
ProtocolVersion::V_2025_11_25
}
}
}
pub const MAX_READ_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ReadParams {
pub path: String,
#[serde(default)]
pub raw: bool,
#[serde(default)]
pub lines: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SearchParams {
pub query: String,
#[serde(default = "default_top_k")]
pub top_k: usize,
#[serde(default)]
pub compact: bool,
}
fn default_top_k() -> usize {
10
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GrepParams {
pub pattern: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub cwd: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SymbolParams {
pub name: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct PeekParams {
pub name: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExpandParams {
pub name: String,
#[serde(default = "default_hop")]
pub hop: u8,
}
fn default_hop() -> u8 {
1
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DigestParams {
pub input: String,
#[serde(default)]
pub format: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ImpactParams {
pub name: String,
#[serde(default = "default_impact_depth")]
pub depth: u8,
}
fn default_impact_depth() -> u8 {
1
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DepsParams {
pub path: String,
#[serde(default)]
pub reverse: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct PlanParams {
pub task: String,
#[serde(default = "default_plan_top_k")]
pub top_k: usize,
}
fn default_plan_top_k() -> usize {
4
}
#[tool_router(router = tool_router)]
impl PluckServer {
#[doc = include_str!("../descriptions/read.md")]
#[tool(name = "read")]
pub async fn read(&self, Parameters(p): Parameters<ReadParams>) -> Result<String, McpError> {
let path = self.resolve_in_repo(&p.path)?;
let meta = std::fs::metadata(&path)
.map_err(|e| McpError::invalid_params(read_err(&p.path, &e), None))?;
if meta.is_dir() {
return Err(McpError::invalid_params(
format!("pluck.read: {}: is a directory", p.path),
None,
));
}
if meta.len() > MAX_READ_BYTES {
return Err(McpError::invalid_params(
format!(
"pluck.read: {} is {} bytes (cap {}). Use `lines: \"A-B\"` to slice, or fall back to bash for the full file.",
p.path,
meta.len(),
MAX_READ_BYTES
),
None,
));
}
let bytes = std::fs::read(&path)
.map_err(|e| McpError::invalid_params(read_err(&p.path, &e), None))?;
let src = match std::str::from_utf8(&bytes) {
Ok(s) => s.to_string(),
Err(_) => {
return Err(McpError::invalid_params(
format!(
"pluck.read: {} is not valid UTF-8 (likely binary). Use bash `cat` for byte-level reads.",
p.path
),
None,
));
}
};
if p.raw {
return Ok(src);
}
if let Some(range) = p.lines {
let (s, e) = parse_line_range(&range)?;
let mut out = String::new();
for (i, line) in src.lines().enumerate() {
let n = (i + 1) as u32;
if n >= s && n <= e {
out.push_str(line);
out.push('\n');
}
}
return Ok(out);
}
let lang = Language::from_path(&path);
let display = path.to_string_lossy();
let outline = outline_source(&src, lang, &display);
Ok(render_outline(&outline))
}
#[doc = include_str!("../descriptions/search.md")]
#[tool(name = "search")]
pub async fn search(
&self,
Parameters(p): Parameters<SearchParams>,
) -> Result<String, McpError> {
let hits = self
.inner
.index
.search_hybrid(&p.query, p.top_k, 0.12, None)
.map_err(|e| McpError::internal_error(format!("search failed: {e}"), None))?;
if hits.is_empty() {
return Ok("(no hits)\n".to_string());
}
let (already_shown, fresh): (Vec<_>, Vec<_>) = {
let session = self.inner.session.lock().expect("session mutex");
hits.into_iter().partition(|h| session.was_seen(h.chunk_id))
};
{
let mut s = self.inner.session.lock().expect("session mutex");
for h in &fresh {
s.mark_seen(h.chunk_id);
}
}
let mut out = String::new();
if p.compact {
out.push_str(&render_compact(&fresh, &p.query));
} else {
out.push_str(&render_full(&fresh));
}
for h in &already_shown {
out.push_str(&format!(
"[already-shown: {}:L{}-{} {} score={:.4}]\n",
h.path, h.start_line, h.end_line, h.symbol, h.score
));
}
Ok(out)
}
#[doc = include_str!("../descriptions/grep.md")]
#[tool(name = "grep")]
pub async fn grep(&self, Parameters(p): Parameters<GrepParams>) -> Result<String, McpError> {
let cwd = match p.cwd.as_deref() {
Some(c) => self.resolve_in_repo(c)?,
None => self.inner.repo_root.clone(),
};
if !cwd.is_dir() {
return Err(McpError::invalid_params(
format!(
"pluck.grep: cwd does not exist or is not a directory: {}",
cwd.display()
),
None,
));
}
let mut cmd = Shell::new("rg");
if !pattern_mode_specified(&p.args) {
cmd.arg("--fixed-strings");
}
cmd.arg(&p.pattern);
for a in &p.args {
cmd.arg(a);
}
cmd.current_dir(&cwd);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let out = cmd.output().map_err(|e| {
McpError::internal_error(
format!("pluck.grep: failed to invoke ripgrep: {e} (is `rg` on PATH?)"),
None,
)
})?;
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let code = out.status.code().unwrap_or(-1);
if code == 2 {
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
return Err(McpError::invalid_params(
format!("pluck.grep: ripgrep exited 2:\n{stderr}"),
None,
));
}
Ok(stdout)
}
#[doc = include_str!("../descriptions/symbol.md")]
#[tool(name = "symbol")]
pub async fn symbol(
&self,
Parameters(p): Parameters<SymbolParams>,
) -> Result<String, McpError> {
let (path_filter, name) = match p.name.rsplit_once('/') {
Some((path, sym)) => (Some(path), sym),
None => (None, p.name.as_str()),
};
let hits = self
.inner
.index
.lookup_symbol(name, path_filter)
.map_err(|e| McpError::internal_error(format!("symbol lookup failed: {e}"), None))?;
if hits.is_empty() {
return Ok(format!(
"no symbol named `{}` found{}.\n\nTry pluck.search with the same name as a free-text query — BM25 picks up partial / fuzzy matches.\n",
p.name,
path_filter.map(|p| format!(" under path `{p}`")).unwrap_or_default()
));
}
let (already_shown, fresh): (Vec<_>, Vec<_>) = {
let session = self.inner.session.lock().expect("session mutex");
hits.into_iter().partition(|h| session.was_seen(h.chunk_id))
};
{
let mut s = self.inner.session.lock().expect("session mutex");
for h in &fresh {
s.mark_seen(h.chunk_id);
}
}
if fresh.len() > 1 {
let mut out = format!(
"`{}` is ambiguous — {} candidates. Disambiguate with `<path>/<name>`:\n",
p.name,
fresh.len()
);
for h in &fresh {
out.push_str(&format!(
" {}:L{}-{} {} ({:?})\n",
h.path, h.start_line, h.end_line, h.symbol, h.kind
));
}
for h in &already_shown {
out.push_str(&format!(
" [already-shown: {}:L{}-{} {}]\n",
h.path, h.start_line, h.end_line, h.symbol
));
}
return Ok(out);
}
let mut out = String::new();
for h in &fresh {
out.push_str(&format!(
"{}:L{}-{} {} ({:?})\n",
h.path, h.start_line, h.end_line, h.symbol, h.kind
));
out.push_str(&h.content);
if !out.ends_with('\n') {
out.push('\n');
}
}
for h in &already_shown {
out.push_str(&format!(
"[already-shown: {}:L{}-{} {}]\n",
h.path, h.start_line, h.end_line, h.symbol
));
}
Ok(out)
}
#[doc = include_str!("../descriptions/peek.md")]
#[tool(name = "peek")]
pub async fn peek(&self, Parameters(p): Parameters<PeekParams>) -> Result<String, McpError> {
let (path_filter, name) = match p.name.rsplit_once('/') {
Some((path, sym)) => (Some(path), sym),
None => (None, p.name.as_str()),
};
let hits = self
.inner
.index
.lookup_symbol(name, path_filter)
.map_err(|e| McpError::internal_error(format!("symbol lookup failed: {e}"), None))?;
if hits.is_empty() {
return Ok(format!(
"no symbol named `{}` found{}.\n",
p.name,
path_filter
.map(|p| format!(" under path `{p}`"))
.unwrap_or_default(),
));
}
if hits.len() > 1 {
let mut out = format!(
"`{}` matches {} symbols — disambiguate with `<path>/<name>`:\n",
p.name,
hits.len()
);
for h in &hits {
out.push_str(&format!(
" {}:L{}-{} {} ({:?})\n",
h.path, h.start_line, h.end_line, h.symbol, h.kind
));
}
return Ok(out);
}
let h = &hits[0];
let lang =
Language::from_path(std::path::Path::new(&h.path)).unwrap_or(Language::TypeScript);
let callees = extract_callees(&h.content, lang);
let mut out = format!(
"{}:L{}-{} {} ({:?})\n",
h.path, h.start_line, h.end_line, h.symbol, h.kind
);
out.push_str(&h.signature);
if !out.ends_with('\n') {
out.push('\n');
}
if callees.is_empty() {
out.push_str(" (no direct callees)\n");
} else {
out.push_str(" calls: ");
out.push_str(&callees.join(", "));
out.push('\n');
}
Ok(out)
}
#[doc = include_str!("../descriptions/expand.md")]
#[tool(name = "expand")]
pub async fn expand(
&self,
Parameters(p): Parameters<ExpandParams>,
) -> Result<String, McpError> {
let hop = p.hop.clamp(1, 3); let (path_filter, name) = match p.name.rsplit_once('/') {
Some((path, sym)) => (Some(path), sym),
None => (None, p.name.as_str()),
};
let root_hits = self
.inner
.index
.lookup_symbol(name, path_filter)
.map_err(|e| McpError::internal_error(format!("symbol lookup failed: {e}"), None))?;
if root_hits.is_empty() {
return Ok(format!("no symbol named `{}` found.\n", p.name));
}
if root_hits.len() > 1 {
let mut out = format!(
"`{}` is ambiguous — {} candidates. Disambiguate with `<path>/<name>`:\n",
p.name,
root_hits.len()
);
for h in &root_hits {
out.push_str(&format!(
" {}:L{}-{} {} ({:?})\n",
h.path, h.start_line, h.end_line, h.symbol, h.kind
));
}
return Ok(out);
}
let root = &root_hits[0];
let mut visited: std::collections::HashSet<u64> = std::collections::HashSet::new();
visited.insert(root.chunk_id);
{
let mut s = self.inner.session.lock().expect("session mutex");
s.mark_seen(root.chunk_id);
}
let mut out = format!(
"{}:L{}-{} {} ({:?})\n",
root.path, root.start_line, root.end_line, root.symbol, root.kind
);
out.push_str(&root.content);
if !out.ends_with('\n') {
out.push('\n');
}
let mut frontier: Vec<String> = callees_of(root);
let mut total_rendered = 0usize;
let max_per_level = 30;
for level in 1..=hop {
if frontier.is_empty() {
break;
}
out.push_str(&format!("\n=== hop {level} ===\n"));
let mut next_frontier: Vec<String> = Vec::new();
for callee_name in frontier.iter().take(max_per_level) {
let leaf = callee_leaf(callee_name);
let hits = self
.inner
.index
.lookup_symbol(leaf, None)
.unwrap_or_default();
let Some(hit) = hits.first() else {
out.push_str(&format!(" · {callee_name} (external / not indexed)\n"));
continue;
};
if !visited.insert(hit.chunk_id) {
out.push_str(&format!(" · {} — already expanded above\n", hit.symbol));
continue;
}
{
let mut s = self.inner.session.lock().expect("session mutex");
s.mark_seen(hit.chunk_id);
}
let nested =
pluck_core::callees::extract_callees(&hit.content, lang_for_path(&hit.path));
out.push_str(&format!(
" → {}:L{}-{} {} ({:?})\n {}\n",
hit.path,
hit.start_line,
hit.end_line,
hit.symbol,
hit.kind,
hit.signature.replace('\n', "\n ")
));
if !nested.is_empty() {
out.push_str(" calls: ");
out.push_str(&nested.join(", "));
out.push('\n');
}
next_frontier.extend(nested);
total_rendered += 1;
}
if frontier.len() > max_per_level {
out.push_str(&format!(
" (+ {} more callees at this hop suppressed — re-call with a path-qualified name to drill into specific branches)\n",
frontier.len() - max_per_level
));
}
frontier = dedup_keep_order(next_frontier);
}
out.push_str(&format!(
"\n[expanded {} callees across {} hop(s)]\n",
total_rendered, hop
));
Ok(out)
}
#[doc = include_str!("../descriptions/digest.md")]
#[tool(name = "digest")]
pub async fn digest_tool(
&self,
Parameters(p): Parameters<DigestParams>,
) -> Result<String, McpError> {
let fmt: Option<Format> = match p.format.as_deref() {
Some(name) => Some(Format::parse_name(name).ok_or_else(|| {
McpError::invalid_params(
format!(
"unknown format {:?}; valid names: cargo, npm, pnpm, yarn, bun, pytest, ci, gha, actions",
name
),
None,
)
})?),
None => None,
};
let result = digest::digest(&p.input, fmt);
let saved = result.input_bytes.saturating_sub(result.text.len());
let pct = (result.savings_fraction() * 100.0).round() as u32;
let mut out = result.text;
out.push_str(&format!(
"\n[digest: format={} saved={saved}B ({pct}%)]\n",
result.format.name()
));
Ok(out)
}
#[doc = include_str!("../descriptions/impact.md")]
#[tool(name = "impact")]
pub async fn impact(
&self,
Parameters(p): Parameters<ImpactParams>,
) -> Result<String, McpError> {
let results = self
.inner
.index
.impact(&p.name, p.depth)
.map_err(|e| McpError::internal_error(format!("impact failed: {e}"), None))?;
if results.is_empty() {
return Ok(format!(
"no callers found for `{}`.\n\n\
Try `pluck.grep` with the symbol name for callers in unindexed files, \
or check the spelling (impact is leaf-matched and case-insensitive).\n",
p.name
));
}
let prod: Vec<&ImpactHit> = results.iter().filter(|h| !h.is_test).collect();
let test: Vec<&ImpactHit> = results.iter().filter(|h| h.is_test).collect();
let mut out = format!(
"=== impact: {} (depth {}) — {} caller(s) ===\n\n",
p.name,
p.depth,
results.len()
);
for h in &prod {
out.push_str(&format!(
"[depth {}] {}:L{}-{} {} ({:?})\n",
h.depth, h.hit.path, h.hit.start_line, h.hit.end_line, h.hit.symbol, h.hit.kind
));
out.push_str(&h.hit.content);
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
if !test.is_empty() {
out.push_str(&format!("[test callers — {}]\n", test.len()));
for h in &test {
out.push_str(&format!(
"[depth {}] {}:L{}-{} {} ({:?})\n",
h.depth, h.hit.path, h.hit.start_line, h.hit.end_line, h.hit.symbol, h.hit.kind
));
out.push_str(&h.hit.content);
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
}
{
let mut s = self.inner.session.lock().expect("session mutex");
for h in &results {
s.mark_seen(h.hit.chunk_id);
}
}
Ok(out)
}
#[doc = include_str!("../descriptions/deps.md")]
#[tool(name = "deps")]
pub async fn deps(&self, Parameters(p): Parameters<DepsParams>) -> Result<String, McpError> {
let abs = self.resolve_in_repo(&p.path)?;
let rel = abs
.strip_prefix(&self.inner.repo_root)
.unwrap_or(&abs)
.to_string_lossy()
.into_owned();
let edges = if p.reverse {
self.inner.index.importers(&rel)
} else {
self.inner.index.deps(&rel)
};
if edges.is_empty() {
let kind = if p.reverse { "importers" } else { "deps" };
return Ok(format!(
"no {kind} found for `{}`.\n\n\
Forward `deps` is empty when the file imports nothing or the\n\
language isn't yet supported. Reverse `importers` is empty\n\
when nothing in the indexed repo imports this path; external\n\
callers (other repos, generated code) won't show up.\n",
rel
));
}
let header = if p.reverse {
format!(
"=== importers of: {} — {} edge(s) ===\n\n",
rel,
edges.len()
)
} else {
format!("=== deps of: {} — {} edge(s) ===\n\n", rel, edges.len())
};
let mut out = header;
let (resolved, unresolved): (Vec<_>, Vec<_>) =
edges.iter().partition(|d| d.resolved.is_some());
for d in &resolved {
if p.reverse {
out.push_str(&format!("{}\n", d.raw));
} else {
out.push_str(&format!(
"{} -> {}\n",
d.raw,
d.resolved.as_deref().unwrap_or("?")
));
}
}
if !unresolved.is_empty() {
out.push_str(&format!(
"\n[external — {} edge(s), no in-repo match]\n",
unresolved.len()
));
for d in &unresolved {
out.push_str(&format!("{}\n", d.raw));
}
}
Ok(out)
}
#[doc = include_str!("../descriptions/plan.md")]
#[tool(name = "plan")]
pub async fn plan(&self, Parameters(p): Parameters<PlanParams>) -> Result<String, McpError> {
let plan = self
.inner
.index
.plan(&p.task, p.top_k)
.map_err(|e| McpError::internal_error(format!("plan failed: {e}"), None))?;
let mut out = format!(
"=== plan: \"{}\" — confidence: {} ===\n\n",
p.task,
plan.confidence.as_str()
);
if plan.probe_hits.is_empty() {
out.push_str("No probe hits.\n\n");
if let Some(b) = &plan.broaden {
out.push_str(b);
out.push('\n');
}
return Ok(out);
}
out.push_str("Top probe results:\n");
for h in plan.probe_hits.iter().take(5) {
out.push_str(&format!(
" {:.2} {}:L{}-{} {} ({:?})\n",
h.score, h.path, h.start_line, h.end_line, h.symbol, h.kind
));
}
out.push('\n');
out.push_str("Recommended next calls:\n");
for (i, step) in plan.steps.iter().enumerate() {
out.push_str(&format!(
" {}. pluck.{} {}\n → {}\n",
i + 1,
step.tool,
step.target,
step.reason
));
}
if let Some(b) = &plan.broaden {
out.push('\n');
out.push_str(b);
out.push('\n');
}
Ok(out)
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for PluckServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_protocol_version(configured_protocol_version())
.with_instructions(
"pluck — token-efficient code reading. Prefer pluck.read/search/grep \
over Bash cat/grep/rg whenever the target is inside the indexed repo. \
All pluck tools have a --raw or equivalent fallback that matches \
cat/grep byte-for-byte if you need exact parity.",
)
}
}
fn read_err(path: &str, e: &std::io::Error) -> String {
use std::io::ErrorKind;
match e.kind() {
ErrorKind::NotFound => format!("pluck.read: {path}: No such file or directory"),
ErrorKind::PermissionDenied => format!("pluck.read: {path}: Permission denied"),
_ => format!("pluck.read: {path}: {e}"),
}
}
fn parse_line_range(s: &str) -> Result<(u32, u32), McpError> {
let (a, b) = s.split_once('-').ok_or_else(|| {
McpError::invalid_params(format!("expected 'start-end', got {s:?}"), None)
})?;
let a: u32 = a
.trim()
.parse()
.map_err(|_| McpError::invalid_params("bad start line", None))?;
let b: u32 = b
.trim()
.parse()
.map_err(|_| McpError::invalid_params("bad end line", None))?;
if a == 0 || b < a {
return Err(McpError::invalid_params(
format!("invalid line range {s}"),
None,
));
}
Ok((a, b))
}
fn callees_of(hit: &SearchHit) -> Vec<String> {
extract_callees(&hit.content, lang_for_path(&hit.path))
}
fn lang_for_path(path: &str) -> Language {
Language::from_path(std::path::Path::new(path)).unwrap_or(Language::TypeScript)
}
fn callee_leaf(name: &str) -> &str {
if let Some(after) = name.rsplit_once("::") {
return after.1;
}
if let Some(after) = name.rsplit_once('.') {
return after.1;
}
name
}
fn dedup_keep_order(xs: Vec<String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::with_capacity(xs.len());
for x in xs {
if seen.insert(x.clone()) {
out.push(x);
}
}
out
}
fn pattern_mode_specified(args: &[String]) -> bool {
args.iter().any(|a| {
matches!(
a.as_str(),
"-F" | "--fixed-strings"
| "-e"
| "--regexp"
| "-P"
| "--pcre2"
| "-f"
| "--file"
)
})
}
fn render_full(hits: &[SearchHit]) -> String {
let mut out = String::new();
for h in hits {
out.push_str(&format!(
"{:.4} {}:L{}-{} {} ({:?})\n",
h.score, h.path, h.start_line, h.end_line, h.symbol, h.kind
));
out.push_str(&h.content);
out.push_str("\n\n");
}
out
}
fn render_compact(hits: &[SearchHit], query: &str) -> String {
let words: Vec<String> = query
.split_whitespace()
.filter(|w| w.len() >= 2)
.map(|w| w.to_lowercase())
.collect();
let mut out = String::new();
for h in hits {
out.push_str(&format!(
"{:.4}\t{}:{}-{}\n",
h.score, h.path, h.start_line, h.end_line
));
for (i, line) in h.content.lines().enumerate() {
let lower = line.to_lowercase();
if words.iter().any(|w| lower.contains(w)) {
let ln = h.start_line as usize + i;
out.push_str(&format!(" L{ln}: {}\n", line.trim()));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_line_range_ok() {
assert_eq!(parse_line_range("10-20").unwrap(), (10, 20));
}
#[test]
fn parse_line_range_rejects_inverted() {
assert!(parse_line_range("20-10").is_err());
}
#[test]
fn parse_line_range_rejects_missing_dash() {
assert!(parse_line_range("10").is_err());
}
#[tokio::test]
async fn server_serves_read_outline_for_pluck_repo() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo.clone()).expect("server new");
let res = server
.read(Parameters(ReadParams {
path: "crates/pluck-core/src/lib.rs".to_string(),
raw: false,
lines: None,
}))
.await;
assert!(res.is_ok());
}
#[tokio::test]
async fn pluck_expand_includes_root_body_and_hop_one_callees() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.expand(Parameters(ExpandParams {
name: "chunk_source".into(),
hop: 1,
}))
.await
.expect("expand");
assert!(
out.contains("pub fn chunk_source"),
"missing root sig: {out}"
);
assert!(out.contains("=== hop 1 ==="), "missing hop header: {out}");
assert!(out.contains("[expanded"), "missing footer: {out}");
}
#[tokio::test]
async fn pluck_expand_unknown_name() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.expand(Parameters(ExpandParams {
name: "definitely_not_a_real_function_xyzzy".into(),
hop: 1,
}))
.await
.expect("expand");
assert!(out.contains("no symbol"), "got: {out}");
}
#[test]
fn callee_leaf_strips_namespace_prefixes() {
assert_eq!(callee_leaf("foo"), "foo");
assert_eq!(callee_leaf("db.user.findOne"), "findOne");
assert_eq!(callee_leaf("Logger::new"), "new");
assert_eq!(callee_leaf("std::collections::HashMap::new"), "new");
}
#[test]
fn protocol_version_env_supports_compatibility_pins() {
assert_eq!(
protocol_version_from_env(None),
ProtocolVersion::V_2025_11_25
);
assert_eq!(
protocol_version_from_env(Some("2025-11-25")),
ProtocolVersion::V_2025_11_25
);
assert_eq!(
protocol_version_from_env(Some("2025-06-18")),
ProtocolVersion::V_2025_06_18
);
assert_eq!(
protocol_version_from_env(Some("2025-03-26")),
ProtocolVersion::V_2025_03_26
);
assert_eq!(
protocol_version_from_env(Some("2024-11-05")),
ProtocolVersion::V_2024_11_05
);
}
#[test]
fn protocol_version_env_rejects_unknown_values_closed() {
assert_eq!(
protocol_version_from_env(Some("2099-01-01")),
ProtocolVersion::V_2025_11_25
);
}
#[tokio::test]
async fn read_rejects_directory() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let res = server
.read(Parameters(ReadParams {
path: "crates".to_string(),
raw: true,
lines: None,
}))
.await;
assert!(res.is_err(), "directory must be rejected");
let msg = format!("{:?}", res.err().unwrap());
assert!(
msg.contains("is a directory"),
"expected cat-style 'is a directory' diagnostic, got: {msg}"
);
}
#[tokio::test]
async fn read_rejects_missing_with_cat_style_message() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let res = server
.read(Parameters(ReadParams {
path: "does/not/exist.rs".to_string(),
raw: true,
lines: None,
}))
.await;
assert!(res.is_err());
let msg = format!("{:?}", res.err().unwrap());
assert!(
msg.contains("No such file or directory"),
"expected cat-style 'No such file or directory', got: {msg}"
);
}
#[tokio::test]
async fn read_rejects_binary_file() {
let nano = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp = std::env::temp_dir().join(format!("pluck-bin-{nano}"));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("bin.dat"), [0xFFu8, 0xFE, 0x00, 0x01, 0x02]).unwrap();
let server = PluckServer::new(tmp.clone()).expect("server new");
let res = server
.read(Parameters(ReadParams {
path: "bin.dat".to_string(),
raw: true,
lines: None,
}))
.await;
let _ = std::fs::remove_dir_all(&tmp);
let msg = format!("{:?}", res.expect_err("binary must error"));
assert!(
msg.contains("not valid UTF-8") || msg.contains("binary"),
"expected binary diagnostic, got: {msg}"
);
}
#[tokio::test]
async fn read_rejects_absolute_path_outside_repo() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let res = server
.read(Parameters(ReadParams {
path: "/etc/hosts".to_string(),
raw: true,
lines: None,
}))
.await;
let msg = format!(
"{:?}",
res.expect_err("outside-repo absolute path must error")
);
assert!(
msg.contains("outside the indexed repo"),
"expected boundary diagnostic, got: {msg}"
);
}
#[tokio::test]
async fn read_rejects_parent_traversal_outside_repo() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let res = server
.read(Parameters(ReadParams {
path: "../../../../etc/hosts".to_string(),
raw: true,
lines: None,
}))
.await;
let msg = format!("{:?}", res.expect_err(".. escape must error"));
assert!(
msg.contains("outside the indexed repo"),
"expected boundary diagnostic, got: {msg}"
);
}
#[tokio::test]
async fn build_falls_back_to_bm25_when_encoder_load_fails() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::build(
repo,
None,
true,
Some("pluck-test/this-model-does-not-exist".to_string()),
)
.expect("daemon must come up even when encoder load fails");
let out = server
.search(Parameters(SearchParams {
query: "chunk_source".to_string(),
top_k: 3,
compact: false,
}))
.await
.expect("BM25 search must work without encoder");
assert!(!out.is_empty(), "BM25-only search should still return hits");
}
#[tokio::test]
async fn read_allows_internal_parent_traversal() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let res = server
.read(Parameters(ReadParams {
path: "crates/../README.md".to_string(),
raw: true,
lines: None,
}))
.await;
assert!(
res.is_ok(),
"internal `..` traversal must be allowed, got: {res:?}"
);
}
#[tokio::test]
async fn pluck_peek_returns_signature_plus_callees() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.peek(Parameters(PeekParams {
name: "chunk_source_with_meta_labeled".into(),
}))
.await
.expect("peek");
assert!(
out.contains("fn chunk_source_with_meta_labeled"),
"got: {out}"
);
assert!(out.contains("calls:"), "got: {out}");
let server2 = PluckServer::new(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf(),
)
.unwrap();
let symbol_out = server2
.symbol(Parameters(SymbolParams {
name: "chunk_source_with_meta_labeled".into(),
}))
.await
.expect("symbol");
assert!(
out.len() * 2 < symbol_out.len(),
"peek should be at least 2x smaller than symbol; peek={} symbol={}",
out.len(),
symbol_out.len()
);
}
#[tokio::test]
async fn pluck_peek_unknown_name() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.peek(Parameters(PeekParams {
name: "definitely_not_a_real_function_xyzzy".into(),
}))
.await
.expect("peek");
assert!(out.contains("no symbol"), "got: {out}");
}
#[tokio::test]
async fn pluck_symbol_returns_named_function_body() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.symbol(Parameters(SymbolParams {
name: "chunk_source".into(),
}))
.await
.expect("symbol lookup");
assert!(
out.contains("pub fn chunk_source"),
"expected symbol body, got: {out}"
);
assert!(out.contains("chunker/mod.rs"), "missing path: {out}");
}
#[tokio::test]
async fn pluck_symbol_unknown_name_returns_no_match_message() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.symbol(Parameters(SymbolParams {
name: "definitely_not_a_real_function_xyzzy".into(),
}))
.await
.expect("symbol lookup");
assert!(out.contains("no symbol"), "got: {out}");
}
#[tokio::test]
async fn pluck_symbol_repeat_call_uses_placeholder() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let first = server
.symbol(Parameters(SymbolParams {
name: "chunk_source_with_meta_labeled".into(),
}))
.await
.expect("first lookup");
let second = server
.symbol(Parameters(SymbolParams {
name: "chunk_source_with_meta_labeled".into(),
}))
.await
.expect("second lookup");
assert!(
second.len() < first.len() / 4,
"second call should collapse to placeholder; first={} second={}",
first.len(),
second.len()
);
assert!(
second.contains("[already-shown:"),
"expected placeholder in repeat: {second}"
);
}
#[tokio::test]
async fn session_dedup_replaces_repeat_results_with_placeholder() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let first = server
.search(Parameters(SearchParams {
query: "chunk source".into(),
top_k: 5,
compact: false,
}))
.await
.expect("first search");
let second = server
.search(Parameters(SearchParams {
query: "chunk source".into(),
top_k: 5,
compact: false,
}))
.await
.expect("second search");
assert!(
second.len() < first.len() / 4,
"second call should be < 25% of first; got first={} second={}",
first.len(),
second.len()
);
for line in second.lines() {
if line.trim().is_empty() {
continue;
}
assert!(
line.starts_with("[already-shown:"),
"non-placeholder line on repeat: {line:?}"
);
}
for line in first.lines() {
if let Some(path_seg) = line.split(" ").nth(1) {
if let Some(path) = path_seg.split(':').next() {
if !path.is_empty() && path.contains('/') {
assert!(
second.contains(path),
"path {path:?} from first call missing on repeat:\n{second}"
);
}
}
}
}
}
#[tokio::test]
async fn digest_tool_compresses_cargo_output() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let cargo_log = " Compiling serde v1.0.0\n Compiling tokio v1.30.0\n Compiling app v0.1.0\n Finished `dev` profile in 3.21s\n";
let out = server
.digest_tool(Parameters(DigestParams {
input: cargo_log.to_string(),
format: None,
}))
.await
.expect("digest_tool");
assert!(out.contains("[cargo] compiled 3"), "missing summary: {out}");
assert!(out.contains("Finished"), "Finished must survive: {out}");
assert!(
!out.contains("Compiling serde"),
"progress must be collapsed: {out}"
);
assert!(
out.contains("[digest: format=cargo"),
"missing metadata footer: {out}"
);
}
#[tokio::test]
async fn impact_returns_callers_of_pluck_symbol() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.impact(Parameters(ImpactParams {
name: "chunk_source".into(),
depth: 1,
}))
.await
.expect("impact");
assert!(
out.contains("impact: chunk_source"),
"missing header: {out}"
);
assert!(
!out.contains("no callers found"),
"chunk_source must have callers: {out}"
);
}
#[tokio::test]
async fn impact_unknown_symbol_returns_helpful_message() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.impact(Parameters(ImpactParams {
name: "definitely_not_a_real_fn_xyzzy".into(),
depth: 1,
}))
.await
.expect("impact");
assert!(out.contains("no callers found"), "got: {out}");
}
#[tokio::test]
async fn deps_forward_reports_indexed_file_imports() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.deps(Parameters(DepsParams {
path: "crates/pluck-mcp/src/server.rs".into(),
reverse: false,
}))
.await
.expect("deps");
assert!(out.contains("=== deps of:"), "got: {out}");
assert!(
out.contains("index.rs") || out.contains("pluck_core"),
"expected an in-repo or symbolic edge, got: {out}"
);
}
#[tokio::test]
async fn deps_reverse_reports_known_importers() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.deps(Parameters(DepsParams {
path: "crates/pluck-core/src/index.rs".into(),
reverse: true,
}))
.await
.expect("deps reverse");
assert!(
out.contains("importers of") || out.contains("no importers"),
"got: {out}"
);
}
#[tokio::test]
async fn plan_returns_actionable_recommendations() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let out = server
.plan(Parameters(PlanParams {
task: "tree sitter chunk source extraction".into(),
top_k: 4,
}))
.await
.expect("plan");
assert!(out.contains("=== plan:"), "got: {out}");
assert!(out.contains("confidence:"), "got: {out}");
assert!(out.contains("Top probe results:"), "got: {out}");
assert!(out.contains("Recommended next calls:"), "got: {out}");
assert!(
out.contains("pluck.symbol")
|| out.contains("pluck.peek")
|| out.contains("pluck.read")
|| out.contains("pluck.impact"),
"expected at least one tool recommendation, got: {out}"
);
}
#[tokio::test]
async fn digest_tool_rejects_unknown_format() {
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let server = PluckServer::new(repo).expect("server new");
let res = server
.digest_tool(Parameters(DigestParams {
input: "anything".to_string(),
format: Some("not-a-format".to_string()),
}))
.await;
assert!(res.is_err(), "unknown format must error");
let msg = format!("{:?}", res.err().unwrap());
assert!(msg.contains("unknown format"), "got: {msg}");
}
#[test]
fn pattern_mode_specified_detects_regex_and_literal_flags() {
let flags = [
("-F", true),
("--fixed-strings", true),
("-e", true),
("--regexp", true),
("-P", true),
("--pcre2", true),
("-f", true),
("--file", true),
("-n", false),
("--type", false),
("-A", false),
("--", false),
];
for (flag, expected) in flags {
let args = vec![flag.to_string()];
assert_eq!(
pattern_mode_specified(&args),
expected,
"flag {flag} expected {expected}"
);
}
assert!(!pattern_mode_specified(&[]), "empty args must be false");
}
fn repo_root_for_tests() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
#[tokio::test]
async fn grep_rejects_missing_cwd_with_invalid_params() {
let server = PluckServer::new(repo_root_for_tests()).expect("server new");
let res = server
.grep(Parameters(GrepParams {
pattern: "anything".to_string(),
args: vec![],
cwd: Some("definitely-not-a-real-dir-xyzzy".to_string()),
}))
.await;
assert!(res.is_err(), "missing cwd must error");
let msg = format!("{:?}", res.err().unwrap());
assert!(
msg.contains("cwd does not exist"),
"want explicit cwd diagnostic, got: {msg}"
);
assert!(
!msg.contains("is `rg` on PATH"),
"must not blame PATH for a missing cwd, got: {msg}"
);
}
#[tokio::test]
async fn grep_pattern_is_literal_by_default() {
let server = PluckServer::new(repo_root_for_tests()).expect("server new");
let res = server
.grep(Parameters(GrepParams {
pattern: "definitely_not_a_real_symbol_xyzzy(".to_string(),
args: vec!["--no-messages".to_string()],
cwd: None,
}))
.await;
assert!(
res.is_ok(),
"literal pattern with metachars must not fail: {res:?}"
);
}
#[tokio::test]
async fn grep_preserves_explicit_regex_via_dash_e() {
let server = PluckServer::new(repo_root_for_tests()).expect("server new");
let res = server
.grep(Parameters(GrepParams {
pattern: "ignored".to_string(),
args: vec!["-e".to_string(), "(unclosed".to_string()],
cwd: None,
}))
.await;
assert!(res.is_err(), "explicit bad regex must still error");
let msg = format!("{:?}", res.err().unwrap());
assert!(
msg.contains("regex parse error") || msg.contains("ripgrep exited 2"),
"want rg regex diagnostic, got: {msg}"
);
}
}