Skip to content

Commit b02d5f6

Browse files
committed
file snippets for file-content in mcp
1 parent d3ef073 commit b02d5f6

4 files changed

Lines changed: 121 additions & 15 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ style-file = "public/output.css"
164164
assets-dir = "public"
165165

166166
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
167-
site-addr = "127.0.0.1:3000"
167+
site-addr = "0.0.0.0:3000"
168168

169169
# The port to use for automatic reload monitoring
170170
reload-port = 3001

src/mcp/server.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ async fn mcp_docs() -> impl IntoResponse {
8787
"regex": "Use regex field to enable regex content matching.",
8888
"path_search_behavior": "path_search requires a non-empty query and is for fuzzy path matching only.",
8989
"file_list_behavior": "file_list enumerates directories and files with optional recursive depth and limit.",
90+
"file_content_behavior": "file_content supports optional start_line/end_line (1-based, inclusive) to return snippets instead of full files.",
9091
"recency_workflow": "For recent or older change questions: repositories -> repo_branches -> search by branch and compare indexed_at or is_live.",
9192
"search_fields": [
9293
"repo: string",
@@ -116,10 +117,11 @@ async fn mcp_docs() -> impl IntoResponse {
116117
"5) search({repo, regex:\"pattern\"}) for regex matching",
117118
"6) file_list(repo, branch, path, depth, limit) for enumeration",
118119
"7) path_search(repo, branch, query) for fuzzy path lookup",
119-
"8) file_content(repo, branch, path) for raw source text",
120-
"9) symbol_insights(params) for definitions and references",
121-
"10) OR behavior: search({repo, any_terms:[\"termA\",\"termB\"], dedupe:\"repo_path_line\"})",
122-
"11) For no results, broaden filters and retry per branch"
120+
"8) file_content(repo, branch, path, start_line?, end_line?) for raw source text or snippets",
121+
"9) For large files, prefer file_content with line snippets first, then expand only if needed",
122+
"10) symbol_insights(params) for definitions and references",
123+
"11) OR behavior: search({repo, any_terms:[\"termA\",\"termB\"], dedupe:\"repo_path_line\"})",
124+
"12) For no results, broaden filters and retry per branch"
123125
]
124126
}));
125127
(StatusCode::OK, Json(payload))
@@ -188,7 +190,7 @@ async fn mcp_rpc(Json(req): Json<JsonRpcRequest>) -> Response {
188190
"name": "pointer-mcp",
189191
"version": env!("CARGO_PKG_VERSION"),
190192
},
191-
"instructions": "Use tools to query indexed code and symbol information. Operational flow: repositories -> repo_branches -> file_list/path_search -> file_content/search/symbol_insights. Use structured search fields: all_terms are AND semantics and any_terms are OR semantics (fanout + dedupe). For recency/version questions like 'recent change', call repo_branches first, then run search with explicit branch values and compare indexed_at/is_live metadata; add historical:true when historical snapshots should be included. Plain terms do not support wildcard matching; use regex for pattern matching. path_search requires a non-empty query and is not a directory listing endpoint; use file_list for enumeration.",
193+
"instructions": "Use tools to query indexed code and symbol information. Operational flow: repositories -> repo_branches -> file_list/path_search -> file_content/search/symbol_insights. Use structured search fields: all_terms are AND semantics and any_terms are OR semantics (fanout + dedupe). For recency/version questions like 'recent change', call repo_branches first, then run search with explicit branch values and compare indexed_at/is_live metadata; add historical:true when historical snapshots should be included. Plain terms do not support wildcard matching; use regex for pattern matching. path_search requires a non-empty query and is not a directory listing endpoint; use file_list for enumeration. For large files, call file_content with start_line/end_line first to limit context size.",
192194
});
193195
jsonrpc_result(req.id, result)
194196
}
@@ -373,13 +375,15 @@ fn mcp_tools() -> Vec<Value> {
373375
}),
374376
json!({
375377
"name": "file_content",
376-
"description": "Read raw indexed file content (no syntax highlighting) for an exact repo/branch/path from the index. Use this after file_list/path_search to inspect implementation details. Includes branch freshness metadata.",
378+
"description": "Read raw indexed file content (no syntax highlighting) for an exact repo/branch/path from the index. Supports optional start_line/end_line (1-based, inclusive) for snippets to reduce context usage. Use this after file_list/path_search to inspect implementation details. Includes branch freshness metadata.",
377379
"inputSchema": {
378380
"type": "object",
379381
"properties": {
380382
"repo": { "type": "string" },
381383
"branch": { "type": "string" },
382-
"path": { "type": "string" }
384+
"path": { "type": "string" },
385+
"start_line": { "type": "integer", "minimum": 1, "description": "Optional 1-based inclusive start line for snippet responses." },
386+
"end_line": { "type": "integer", "minimum": 1, "description": "Optional 1-based inclusive end line for snippet responses." }
383387
},
384388
"required": ["repo", "branch", "path"],
385389
"additionalProperties": false

src/mcp/tools.rs

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ use tokio::time::{Duration, timeout};
77
use crate::db::models::{FacetCount, SearchResult, SearchResultsPage};
88
use crate::db::{Database, postgres::PostgresDb};
99
use crate::mcp::types::{
10-
ApiResponse, BranchFreshness, FileContentToolRequest, FileContentToolResponse, FileListEntry,
11-
FileListToolRequest, FileListToolResponse, IndexFreshness, PathSearchToolRequest,
12-
PathSearchToolResponse, RepoBranchesToolRequest, RepoBranchesToolResponse,
13-
RepositoriesToolRequest, RepositoriesToolResponse, SearchCaseMode, SearchDedupeMode,
14-
SearchToolRequest, SymbolInsightsToolRequest, SymbolInsightsToolResponse,
10+
ApiResponse, BranchFreshness, FileContentSnippet, FileContentToolRequest,
11+
FileContentToolResponse, FileListEntry, FileListToolRequest, FileListToolResponse,
12+
IndexFreshness, PathSearchToolRequest, PathSearchToolResponse, RepoBranchesToolRequest,
13+
RepoBranchesToolResponse, RepositoriesToolRequest, RepositoriesToolResponse, SearchCaseMode,
14+
SearchDedupeMode, SearchToolRequest, SymbolInsightsToolRequest, SymbolInsightsToolResponse,
1515
};
1616
use crate::pages::file_viewer::{fetch_symbol_insights, search_repo_paths};
1717
use crate::pages::repo_detail::{RepoBranchDisplay, get_repo_branches};
@@ -84,6 +84,12 @@ pub async fn execute_file_content(
8484
.map_err(|err| err.to_string())?;
8585

8686
let line_count = raw.content.lines().count();
87+
let (content, snippet, returned_line_count) = slice_file_content(
88+
&raw.content,
89+
payload.start_line,
90+
payload.end_line,
91+
line_count,
92+
)?;
8793
let index_freshness = resolve_branch_freshness(&payload.repo, &payload.branch, Some(&commit))
8894
.await
8995
.unwrap_or_else(|_| unknown_freshness());
@@ -93,12 +99,66 @@ pub async fn execute_file_content(
9399
commit_sha: raw.commit_sha,
94100
file_path: raw.file_path,
95101
language: raw.language,
96-
content: raw.content,
102+
content,
97103
line_count,
104+
returned_line_count,
105+
snippet,
98106
index_freshness,
99107
})
100108
}
101109

110+
fn slice_file_content(
111+
content: &str,
112+
start_line: Option<u32>,
113+
end_line: Option<u32>,
114+
total_line_count: usize,
115+
) -> Result<(String, Option<FileContentSnippet>, usize), String> {
116+
if start_line.is_none() && end_line.is_none() {
117+
return Ok((content.to_string(), None, total_line_count));
118+
}
119+
120+
let start = start_line.unwrap_or(1);
121+
let end = end_line.unwrap_or(total_line_count as u32);
122+
123+
if start == 0 {
124+
return Err("start_line must be >= 1".to_string());
125+
}
126+
if end == 0 {
127+
return Err("end_line must be >= 1".to_string());
128+
}
129+
if end < start {
130+
return Err("end_line must be >= start_line".to_string());
131+
}
132+
if total_line_count == 0 {
133+
return Err("cannot request line snippets from an empty file".to_string());
134+
}
135+
if start as usize > total_line_count {
136+
return Err(format!(
137+
"start_line {} exceeds file line count {}",
138+
start, total_line_count
139+
));
140+
}
141+
142+
let bounded_end = end.min(total_line_count as u32);
143+
let start_idx = (start - 1) as usize;
144+
let count = (bounded_end - start + 1) as usize;
145+
let snippet_content = content
146+
.lines()
147+
.skip(start_idx)
148+
.take(count)
149+
.collect::<Vec<_>>()
150+
.join("\n");
151+
152+
Ok((
153+
snippet_content,
154+
Some(FileContentSnippet {
155+
start_line: start,
156+
end_line: bounded_end,
157+
}),
158+
count,
159+
))
160+
}
161+
102162
pub async fn execute_file_list(
103163
payload: FileListToolRequest,
104164
) -> Result<FileListToolResponse, String> {
@@ -964,7 +1024,7 @@ fn split_query_tokens(query: &str) -> Vec<String> {
9641024
mod tests {
9651025
use super::{
9661026
build_file_list_entries, build_no_result_guidance, compile_query, normalize_repo_path,
967-
quote_if_needed,
1027+
quote_if_needed, slice_file_content,
9681028
};
9691029

9701030
#[test]
@@ -1021,4 +1081,33 @@ mod tests {
10211081
.any(|e| e.kind == "dir" && e.path == "src/mcp")
10221082
);
10231083
}
1084+
1085+
#[test]
1086+
fn slice_file_content_returns_full_content_without_bounds() {
1087+
let content = "a\nb\nc\n";
1088+
let (sliced, snippet, returned_line_count) =
1089+
slice_file_content(content, None, None, 3).expect("slice should succeed");
1090+
assert_eq!(sliced, content);
1091+
assert!(snippet.is_none());
1092+
assert_eq!(returned_line_count, 3);
1093+
}
1094+
1095+
#[test]
1096+
fn slice_file_content_returns_requested_snippet() {
1097+
let content = "a\nb\nc\nd";
1098+
let (sliced, snippet, returned_line_count) =
1099+
slice_file_content(content, Some(2), Some(3), 4).expect("slice should succeed");
1100+
assert_eq!(sliced, "b\nc");
1101+
assert_eq!(returned_line_count, 2);
1102+
let snippet = snippet.expect("snippet metadata must be present");
1103+
assert_eq!(snippet.start_line, 2);
1104+
assert_eq!(snippet.end_line, 3);
1105+
}
1106+
1107+
#[test]
1108+
fn slice_file_content_rejects_invalid_bounds() {
1109+
let content = "a\nb\nc";
1110+
let err = slice_file_content(content, Some(4), None, 3).expect_err("must reject");
1111+
assert!(err.contains("exceeds file line count"));
1112+
}
10241113
}

src/mcp/types.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ pub struct FileContentToolRequest {
135135
pub repo: String,
136136
pub branch: String,
137137
pub path: String,
138+
#[serde(default)]
139+
pub start_line: Option<u32>,
140+
#[serde(default)]
141+
pub end_line: Option<u32>,
138142
}
139143

140144
#[derive(Debug, Deserialize)]
@@ -171,9 +175,18 @@ pub struct FileContentToolResponse {
171175
pub language: Option<String>,
172176
pub content: String,
173177
pub line_count: usize,
178+
pub returned_line_count: usize,
179+
#[serde(skip_serializing_if = "Option::is_none")]
180+
pub snippet: Option<FileContentSnippet>,
174181
pub index_freshness: IndexFreshness,
175182
}
176183

184+
#[derive(Debug, Serialize)]
185+
pub struct FileContentSnippet {
186+
pub start_line: u32,
187+
pub end_line: u32,
188+
}
189+
177190
#[derive(Debug, Deserialize)]
178191
pub struct FileListToolRequest {
179192
pub repo: String,

0 commit comments

Comments
 (0)