Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ csp search "database host port" ./my-project --content config
csp search "authentication" ./my-project --content all
```

`--max-snippet-lines N`으로 결과당 반환되는 코드를 제한할 수 있습니다. `10`은 시그니처+본문 앞부분 미리보기, `0`은 파일 경로와 라인 범위만 반환하며, 기본값은 전체 청크입니다. 파일을 열기 전 위치를 확인하는 데는 짧은 미리보기로 충분한 경우가 많아, 약간의 맥락을 토큰 절감과 맞바꿉니다.

```bash
csp search "authentication" ./my-project --max-snippet-lines 10
```

`csp find-related`로 기존 위치와 비슷한 코드를 찾을 수 있습니다 (이전 검색 결과의 `file_path`와 `line`을 사용).

```bash
Expand Down Expand Up @@ -349,6 +355,8 @@ args = [
| `search` | 자연어 또는 코드 쿼리로 코드베이스를 검색. `repo`는 로컬 디렉터리 경로 또는 https:// git URL. |
| `find_related` | 파일 경로와 라인 번호를 받아, 해당 위치의 코드와 의미적으로 유사한 청크를 반환. |

두 도구 모두 결과당 반환 코드를 제한하는 `max_snippet_lines`를 받습니다. 기본값은 `10`으로, 시그니처+본문 앞부분 미리보기라 에이전트가 위치를 싸게 확인한 뒤 전체 맥락은 파일로 이동해 봅니다. 위치만 필요하면 `0`, 미리보기로 부족하면 `null`로 전체 청크를 받습니다.

기본적으로 MCP 서버는 코드 파일만 인덱싱합니다. 문서/설정/전체를 함께 인덱싱하려면 명령에 `--content docs`, `--content config`, `--content all` 또는 조합(예: `--content code docs`)을 추가하세요. 예를 들어 Claude Code에서는 `claude mcp add csp -s user -- bunx @pleaseai/csp mcp --content all`.

## 서브 에이전트 설정
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ csp search "database host port" ./my-project --content config
csp search "authentication" ./my-project --content all
```

Use `--max-snippet-lines N` to cap the code returned per result: `10` gives a signature-plus-first-lines preview, `0` returns only the file path and line range. The default returns the full chunk. A short preview is often enough to confirm a location before you open the file, so it trades a little context for fewer tokens.

```bash
csp search "authentication" ./my-project --max-snippet-lines 10
```

Use `csp find-related` to discover code similar to a known location (pass `file_path` and `line` from a prior search result):

```bash
Expand Down Expand Up @@ -349,6 +355,8 @@ Add to `~/.config/zed/settings.json` (or `.zed/settings.json` in your project):
| `search` | Search a codebase with a natural-language or code query. Pass `repo` as a local directory path or an https:// git URL. |
| `find_related` | Given a file path and line number, return chunks semantically similar to the code at that location. |

Both tools accept `max_snippet_lines` to cap the code returned per result. It defaults to `10` — a signature-plus-first-lines preview that lets an agent confirm a location cheaply, then navigate to the file for full context. Pass `0` for the location only, or `null` for the full chunk when the preview lacks context.

By default the MCP server indexes only code files. To also index documentation, config, or everything, append `--content docs`, `--content config`, or `--content all` to the server command, or a combination, e.g. `--content code docs`. For example, in Claude Code: `claude mcp add csp -s user -- bunx @pleaseai/csp mcp --content all`.

## Sub-agent setup
Expand Down
82 changes: 70 additions & 12 deletions crates/csp/src/bin/csp/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ enum Command {
path: Option<String>,
#[arg(long = "top-k", short = 'k')]
top_k: Option<usize>,
/// Lines of source per result (default: full chunk). 10 = signature +
/// body, 0 = no code.
#[arg(long = "max-snippet-lines", value_name = "N")]
max_snippet_lines: Option<i64>,
#[arg(long, value_enum, num_args = 1..)]
content: Vec<ContentFilter>,
/// Path to a pre-built index (bypasses the auto-cache).
Expand All @@ -73,6 +77,10 @@ enum Command {
path: Option<String>,
#[arg(long = "top-k", short = 'k')]
top_k: Option<usize>,
/// Lines of source per result (default: full chunk). 10 = signature +
/// body, 0 = no code.
#[arg(long = "max-snippet-lines", value_name = "N")]
max_snippet_lines: Option<i64>,
#[arg(long, value_enum, num_args = 1..)]
content: Vec<ContentFilter>,
#[arg(long)]
Expand Down Expand Up @@ -210,8 +218,19 @@ fn load_index(
}
}

/// Map the CLI `--max-snippet-lines` value to the `Option<usize>` cap. Absent
/// (`None`) → full chunk content; a negative value clamps to `0` (no code).
fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
value.map(|n| n.max(0) as usize)
}
Comment on lines +221 to +225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] 중복 구현된 resolve_snippet_lines 제거 및 공통 함수 사용

Symptom: resolve_snippet_lines 함수가 main.rs에 로컬로 중복 구현되어 있습니다.
Source: Fowler — Refactoring (Duplicate Code) / Hunt & Thomas — Pragmatic Programmer (DRY)
Consequence: 동일한 유틸리티 로직이 여러 곳에 중복 존재하여 유지보수성이 저하됩니다.
Remedy: 로컬 구현을 제거하고 csp::utils::resolve_snippet_lines를 가져와 사용하도록 변경합니다.

Suggested change
/// Map the CLI `--max-snippet-lines` value to the `Option<usize>` cap. Absent
/// (`None`) → full chunk content; a negative value clamps to `0` (no code).
fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
value.map(|n| n.max(0) as usize)
}
use csp::utils::resolve_snippet_lines;


/// JSON output for `search` (pure — testable without stdout capture).
fn search_output(index: &CspIndex, query: &str, top_k: usize) -> String {
fn search_output(
index: &CspIndex,
query: &str,
top_k: usize,
max_snippet_lines: Option<usize>,
) -> String {
let results = index.search(
query,
&QueryOptions {
Expand All @@ -222,7 +241,7 @@ fn search_output(index: &CspIndex, query: &str, top_k: usize) -> String {
let out = if results.is_empty() {
serde_json::json!({ "error": "No results found." })
} else {
format_results(query, &results)
format_results(query, &results, max_snippet_lines)
};
out.to_string()
}
Expand All @@ -233,6 +252,7 @@ fn find_related_output(
file: &str,
line: &str,
top_k: usize,
max_snippet_lines: Option<usize>,
) -> Result<String, String> {
let Ok(line_num) = line.parse::<i64>() else {
return Err(format!("line must be an integer, got: {line}"));
Expand All @@ -257,7 +277,11 @@ fn find_related_output(
let out = if related.is_empty() {
serde_json::json!({ "error": format!("No related chunks found for {file}:{line_num}.") })
} else {
format_results(&format!("Chunks related to {file}:{line_num}"), &related)
format_results(
&format!("Chunks related to {file}:{line_num}"),
&related,
max_snippet_lines,
)
};
Ok(out.to_string())
}
Expand Down Expand Up @@ -382,6 +406,7 @@ fn dispatch(command: Command) -> u8 {
query,
path,
top_k,
max_snippet_lines,
content,
index,
git_ref,
Expand All @@ -394,7 +419,15 @@ fn dispatch(command: Command) -> u8 {
git_ref,
) {
Ok(idx) => {
println!("{}", search_output(&idx, &query, top_k.unwrap_or(5)));
println!(
"{}",
search_output(
&idx,
&query,
top_k.unwrap_or(5),
resolve_snippet_lines(max_snippet_lines),
)
);
EXIT_SUCCESS
}
Err(e) => {
Expand All @@ -408,6 +441,7 @@ fn dispatch(command: Command) -> u8 {
line,
path,
top_k,
max_snippet_lines,
content,
index,
git_ref,
Expand All @@ -425,7 +459,13 @@ fn dispatch(command: Command) -> u8 {
return EXIT_FAILURE;
}
};
match find_related_output(&idx, &file, &line, top_k.unwrap_or(5)) {
match find_related_output(
&idx,
&file,
&line,
top_k.unwrap_or(5),
resolve_snippet_lines(max_snippet_lines),
) {
Ok(out) => {
println!("{out}");
EXIT_SUCCESS
Expand Down Expand Up @@ -519,32 +559,46 @@ mod tests {
fn search_output_shapes_results() {
let dir = build_index_dir();
let idx = CspIndex::from_path(dir.path(), &LoadOptions::default()).unwrap();
let out = search_output(&idx, "greet", 5);
let out = search_output(&idx, "greet", 5, None);
let value: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(value.get("results").is_some() || value.get("error").is_some());
if let Some(results) = value.get("results").and_then(|r| r.as_array()) {
if let Some(first) = results.first() {
let chunk = &first["chunk"];
assert!(chunk.get("file_path").is_some());
assert!(chunk.get("start_line").is_some());
assert!(chunk.get("location").is_some());
// Flat wire shape (semble#198): fields at the top level.
assert!(first.get("chunk").is_none());
assert!(first.get("file_path").is_some());
assert!(first.get("start_line").is_some());
// CLI default (None) keeps the full content.
assert!(first.get("content").is_some());
}
}
}

#[test]
fn search_output_caps_snippet_lines() {
let dir = build_index_dir();
let idx = CspIndex::from_path(dir.path(), &LoadOptions::default()).unwrap();
let out = search_output(&idx, "greet", 5, Some(0));
let value: serde_json::Value = serde_json::from_str(&out).unwrap();
if let Some(first) = value["results"].as_array().and_then(|r| r.first()) {
assert!(first.get("content").is_none());
assert!(first.get("file_path").is_some());
}
}

#[test]
fn find_related_rejects_non_integer_line() {
let dir = build_index_dir();
let idx = CspIndex::from_path(dir.path(), &LoadOptions::default()).unwrap();
let err = find_related_output(&idx, "sample.ts", "abc", 5).unwrap_err();
let err = find_related_output(&idx, "sample.ts", "abc", 5, None).unwrap_err();
assert!(err.contains("line must be an integer"));
}

#[test]
fn find_related_no_chunk_at_location() {
let dir = build_index_dir();
let idx = CspIndex::from_path(dir.path(), &LoadOptions::default()).unwrap();
let err = find_related_output(&idx, "nope.ts", "1", 5).unwrap_err();
let err = find_related_output(&idx, "nope.ts", "1", 5, None).unwrap_err();
assert!(err.contains("No chunk found"));
}

Expand Down Expand Up @@ -611,6 +665,7 @@ mod tests {
query: "greet".to_string(),
path: None,
top_k: Some(5),
max_snippet_lines: None,
content: vec![],
index: Some(idx_path.clone()),
git_ref: None,
Expand All @@ -623,6 +678,7 @@ mod tests {
line: "1".to_string(),
path: None,
top_k: Some(5),
max_snippet_lines: None,
content: vec![],
index: Some(idx_path.clone()),
git_ref: None,
Expand All @@ -635,6 +691,7 @@ mod tests {
line: "abc".to_string(),
path: None,
top_k: Some(5),
max_snippet_lines: None,
content: vec![],
index: Some(idx_path),
git_ref: None,
Expand All @@ -650,6 +707,7 @@ mod tests {
query: "greet".to_string(),
path: None,
top_k: Some(1),
max_snippet_lines: None,
content: vec![],
index: Some(
missing
Expand Down
42 changes: 41 additions & 1 deletion crates/csp/src/bin/csp/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ use tokio::sync::Mutex;
use csp::mcp::{find_related_tool, search_tool, IndexCache, SERVER_INSTRUCTIONS};
use csp::types::ContentType;

/// MCP default: signature + first body lines, enough to confirm a location
/// while spending far fewer tokens than the full chunk (semble#198).
fn default_max_snippet_lines() -> Option<i64> {
Some(10)
}

/// Convert the wire value to the `Option<usize>` the handlers take. `None` (the
/// caller passed JSON `null`) → full content; a negative value clamps to `0`.
fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
value.map(|n| n.max(0) as usize)
}
Comment on lines +29 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicated resolve_snippet_lines helper

resolve_snippet_lines (and its default_max_snippet_lines companion) is defined identically in both mcp_server.rs and main.rs. Since both binaries already depend on the csp library crate (they import from csp::utils, csp::mcp, etc.), moving this pair to csp::utils (or a dedicated csp::snippet submodule) and re-exporting it would eliminate the duplication and keep the single source of truth close to result_to_dict, which it directly feeds.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/csp/src/bin/csp/mcp_server.rs
Line: 29-31

Comment:
**Duplicated `resolve_snippet_lines` helper**

`resolve_snippet_lines` (and its `default_max_snippet_lines` companion) is defined identically in both `mcp_server.rs` and `main.rs`. Since both binaries already depend on the `csp` library crate (they import from `csp::utils`, `csp::mcp`, etc.), moving this pair to `csp::utils` (or a dedicated `csp::snippet` submodule) and re-exporting it would eliminate the duplication and keep the single source of truth close to `result_to_dict`, which it directly feeds.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment on lines +27 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] 중복 구현된 resolve_snippet_lines 제거 및 공통 함수 사용

Symptom: resolve_snippet_lines 함수가 mcp_server.rs에 로컬로 중복 구현되어 있습니다.
Source: Fowler — Refactoring (Duplicate Code) / Hunt & Thomas — Pragmatic Programmer (DRY)
Consequence: 동일한 유틸리티 로직이 여러 곳에 중복 존재하여 유지보수성이 저하됩니다.
Remedy: 로컬 구현을 제거하고 csp::utils::resolve_snippet_lines를 가져와 사용하도록 변경합니다.

Suggested change
/// Convert the wire value to the `Option<usize>` the handlers take. `None` (the
/// caller passed JSON `null`) → full content; a negative value clamps to `0`.
fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
value.map(|n| n.max(0) as usize)
}
use csp::utils::resolve_snippet_lines;


/// Parameters for the `search` tool (mirrors the TS MCP tool's args).
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SearchParams {
Expand All @@ -28,6 +40,11 @@ pub struct SearchParams {
pub repo: Option<String>,
/// Maximum number of results (default 5).
pub top_k: Option<u32>,
/// Lines of source per result. Default 10 = signature + first body lines,
/// enough to confirm the location. 0 = file path and line range only. Pass
/// `null` for the full chunk when the snippet lacks context.
#[serde(default = "default_max_snippet_lines")]
pub max_snippet_lines: Option<i64>,
}

/// Parameters for the `find_related` tool.
Expand All @@ -41,6 +58,10 @@ pub struct FindRelatedParams {
pub repo: Option<String>,
/// Maximum number of results (default 5).
pub top_k: Option<u32>,
/// Lines of source per result. Default 10 = signature + first body lines.
/// 0 = location only. Pass `null` for the full chunk.
#[serde(default = "default_max_snippet_lines")]
pub max_snippet_lines: Option<i64>,
}

/// MCP server holding the session index cache and the default source.
Expand Down Expand Up @@ -82,6 +103,7 @@ impl CspMcpServer {
&p.query,
p.repo.as_deref(),
p.top_k.unwrap_or(5) as usize,
resolve_snippet_lines(p.max_snippet_lines),
);
Ok(CallToolResult::success(vec![Content::text(out)]))
}
Expand All @@ -102,6 +124,7 @@ impl CspMcpServer {
p.line,
p.repo.as_deref(),
p.top_k.unwrap_or(5) as usize,
resolve_snippet_lines(p.max_snippet_lines),
);
Ok(CallToolResult::success(vec![Content::text(out)]))
}
Expand Down Expand Up @@ -152,15 +175,30 @@ mod tests {
assert_eq!(minimal.query, "greet");
assert!(minimal.repo.is_none());
assert!(minimal.top_k.is_none());
// Absent max_snippet_lines → the MCP default of 10.
assert_eq!(minimal.max_snippet_lines, Some(10));

let full: SearchParams = serde_json::from_value(serde_json::json!({
"query": "greet",
"repo": "./x",
"top_k": 3
"top_k": 3,
"max_snippet_lines": 0
}))
.unwrap();
assert_eq!(full.repo.as_deref(), Some("./x"));
assert_eq!(full.top_k, Some(3));
assert_eq!(full.max_snippet_lines, Some(0));

// Explicit null → None (full chunk), distinct from the absent default.
let nulled: SearchParams = serde_json::from_value(serde_json::json!({
"query": "greet",
"max_snippet_lines": null
}))
.unwrap();
assert!(nulled.max_snippet_lines.is_none());
assert!(resolve_snippet_lines(nulled.max_snippet_lines).is_none());
assert_eq!(resolve_snippet_lines(Some(3)), Some(3));
assert_eq!(resolve_snippet_lines(Some(-4)), Some(0));
}

#[test]
Expand Down Expand Up @@ -208,6 +246,7 @@ mod tests {
query: "greet".to_string(),
repo: None,
top_k: Some(5),
max_snippet_lines: None,
}))
.await
.unwrap();
Expand Down Expand Up @@ -235,6 +274,7 @@ mod tests {
line: 1,
repo: None,
top_k: Some(5),
max_snippet_lines: None,
}))
.await
.unwrap();
Expand Down
Loading
Loading