Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/book/src/mcp/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,20 @@ Read a specific section of code from a file by line range.

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `file` | string | yes | — | File path relative to repo root |
| `file` | string | yes | — | Repository-relative or workspace-relative file path |
| `repo` | string | no | — | Repository name from the search result; required for ambiguous relative paths |
| `start_line` | integer | yes | — | Starting line number |
| `end_line` | integer | yes | — | Ending line number |
| `context` | integer | no | 0 | Context lines to include before and after |

For a search result, pass its repository as `repo` and its relative path as
`file`. Existing workspace paths such as `repos/alpha/src/main.rs` still work
without `repo`. When `repo` is omitted, an existing workspace-relative file
takes precedence; otherwise exactly one match under the configured repository
prefix (default: `repos/`) is required. Multiple matches refuse and list the
repository names so the caller can choose explicitly. Missing files and unknown
repositories refuse with guidance; no index rebuild is performed.

**Response fields:** `file`, `start_line`, `end_line`, `actual_start_line`, `actual_end_line`, `content`, `language`

## hotspots
Expand Down
11 changes: 7 additions & 4 deletions src/http/handlers/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ pub(super) async fn context(

#[derive(Deserialize)]
pub(super) struct ReadChunkParams {
/// Repository name from the search result.
repo: Option<String>,
/// File path (relative to repo root)
file: String,
/// Start line
Expand Down Expand Up @@ -317,6 +319,8 @@ pub(super) async fn read_chunk(
let (content, actual_start, actual_end) = read_file_lines(
&state.repo_root,
&params.file,
params.repo.as_deref(),
state.config.server.repo_path_prefix.as_deref(),
params.start_line,
params.end_line,
ctx,
Expand All @@ -337,14 +341,13 @@ pub(super) async fn read_chunk(
fn read_file_lines(
repo_root: &std::path::Path,
file: &str,
repo: Option<&str>,
repo_prefix: Option<&str>,
start: u32,
end: u32,
context: u32,
) -> anyhow::Result<(String, u32, u32)> {
let file_path = repo_root.join(file);
if !file_path.exists() {
anyhow::bail!("File not found: {}", file);
}
let file_path = crate::read_path::resolve_read_path(repo_root, file, repo, repo_prefix)?;

let content = std::fs::read_to_string(&file_path)?;
let lines: Vec<&str> = content.lines().collect();
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mod operational_metrics;
/// asked for) — what makes retrieval cumulative rather than per-prompt.
pub mod plate;
pub mod reactions;
mod read_path;
mod search;
mod storage;
pub mod tags;
Expand Down
77 changes: 77 additions & 0 deletions src/mcp/read_path_contract_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use super::*;

#[tokio::test]
async fn private_index_definition_is_readable_without_workspace_prefix() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
Config::default().save(&Config::config_path(root)).unwrap();
let relative = "src/example.rs";
let content = "fn example() { /* requested content */ }";
let source = root.join("repos/alpha").join(relative);
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, content).unwrap();
let mut store = VectorStore::open(&Config::lance_path(root)).await.unwrap();
store
.insert(
&[crate::types::Chunk {
id: "private-example".into(),
file_path: relative.into(),
chunk_type: ChunkType::Function,
name: Some("example".into()),
start_line: 1,
end_line: 1,
content: content.into(),
language: "rust".into(),
tags: String::new(),
}],
&[vec![0.1; 384]],
&[None],
"alpha",
"fixture-hash",
"2026-01-01T00:00:00Z",
)
.await
.unwrap();
drop(store);
let server = BobbinMcpServer::new(root.to_path_buf()).unwrap();
let result = server
.find_refs(Parameters(FindRefsRequest {
symbol: "example".into(),
repo: Some("alpha".into()),
r#type: None,
limit: Some(5),
}))
.await
.unwrap();
let value: serde_json::Value =
serde_json::from_str(&result.content[0].as_text().unwrap().text).unwrap();
let definition = &value["definition"];
assert_eq!(definition["file_path"], relative);
for repo in [None, Some("alpha".to_string())] {
let result = server
.read_chunk(Parameters(ReadChunkRequest {
file: definition["file_path"].as_str().unwrap().into(),
repo,
start_line: 1,
end_line: 1,
context: None,
}))
.await
.unwrap();
let value: serde_json::Value =
serde_json::from_str(&result.content[0].as_text().unwrap().text).unwrap();
assert_eq!(value["content"], content);
}
assert!(server
.read_chunk(Parameters(ReadChunkRequest {
file: relative.into(),
repo: Some("unknown".into()),
start_line: 1,
end_line: 1,
context: None,
}))
.await
.unwrap_err()
.message
.contains("Unknown repository"));
}
1 change: 1 addition & 0 deletions src/mcp/remote/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ pub(super) fn context_params(req: &ContextRequest) -> Params {

pub(super) fn read_chunk_params(req: &ReadChunkRequest) -> Params {
Params::new()
.opt_str("repo", &req.repo)
.set("file", &req.file)
.set("start_line", req.start_line)
.set("end_line", req.end_line)
Expand Down
12 changes: 12 additions & 0 deletions src/mcp/remote/params_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,13 @@ fn context_carries_every_field() {
#[test]
fn read_chunk_carries_every_field() {
let p = read_chunk_params(&ReadChunkRequest {
repo: Some("alpha".into()),
file: "src/main.rs".into(),
start_line: 10,
end_line: 42,
context: Some(3),
});
assert_eq!(p.get("repo"), Some("alpha"));
assert_eq!(p.get("file"), Some("src/main.rs"));
assert_eq!(p.get("start_line"), Some("10"));
assert_eq!(p.get("end_line"), Some("42"));
Expand Down Expand Up @@ -297,3 +299,13 @@ fn feedback_params_carry_every_field() {
assert_eq!(p.get("commit_hash"), Some("deadbeef"));
assert_eq!(p.get("limit"), Some("2"));
}

#[test]
fn read_chunk_without_repo_remains_compatible() {
let req: ReadChunkRequest = serde_json::from_value(serde_json::json!({
"file": "repos/alpha/src/main.rs", "start_line": 1, "end_line": 5
}))
.unwrap();
assert!(req.repo.is_none());
assert_eq!(read_chunk_params(&req).get("repo"), None);
}
30 changes: 24 additions & 6 deletions src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,23 @@ impl BobbinMcpServer {
fn read_file_lines(
&self,
file: &str,
repo: Option<&str>,
start: u32,
end: u32,
context: u32,
) -> Result<(String, u32, u32)> {
let file_path = self.repo_root.join(file);

if !file_path.exists() {
anyhow::bail!("File not found: {}", file);
}
let config_path = Config::config_path(&self.repo_root);
let config = if config_path.exists() {
Config::load(&config_path)?
} else {
Config::default()
};
let file_path = crate::read_path::resolve_read_path(
&self.repo_root,
file,
repo,
config.server.repo_path_prefix.as_deref(),
)?;

let content = std::fs::read_to_string(&file_path)
.with_context(|| format!("Failed to read file: {}", file))?;
Expand Down Expand Up @@ -1352,7 +1360,13 @@ impl BobbinMcpServer {
let context = req.context.unwrap_or(0);

let (content, actual_start, actual_end) = self
.read_file_lines(&req.file, req.start_line, req.end_line, context)
.read_file_lines(
&req.file,
req.repo.as_deref(),
req.start_line,
req.end_line,
context,
)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;

let response = ReadChunkResponse {
Expand Down Expand Up @@ -3510,3 +3524,7 @@ pub async fn run_http_server(

Ok(())
}

#[cfg(test)]
#[path = "read_path_contract_tests.rs"]
mod read_path_contract_tests;
4 changes: 4 additions & 0 deletions src/mcp/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,10 @@ pub struct SymbolItemOutput {
/// Request for reading a specific code chunk
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ReadChunkRequest {
/// Repository name from search; omit for a unique or workspace path.
#[schemars(description = "Repository name; required for ambiguous relative paths")]
pub repo: Option<String>,

/// File path containing the chunk
#[schemars(description = "File path (relative to repo root) containing the code chunk")]
pub file: String,
Expand Down
Loading
Loading