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
12 changes: 11 additions & 1 deletion .please/docs/references/semble.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,11 +327,21 @@ Clean two-layer split:
- **`csp::mcp`** (lib) — the unit-tested tool **core**: `search` / `find_related` handler logic,
in-process LRU `IndexCache` (`CACHE_MAX_SIZE = 10`, `Arc<CspIndex>` so indexes are `Send`
across tasks), `_get_index` with git-transport guards.
- **Per-call `content`** (upstream #247): both tools take `content: Option<ContentSelection>`
(`code | docs | config | all`); `resolve_content_selection` maps `None` → the server's
`--content` default and `all` → every `ContentType` (upstream `_resolve_content_selection`).
`IndexCache` is keyed by `CacheKey { source, content }` where `content` is normalized to enum
order and de-duplicated (upstream `_CacheKey = (source_key, tuple[ContentType, ...])`), so one
repo searched as `code` and as `docs` holds two independent session entries; `get` / `evict`
and fingerprint revalidation all take the content slice. No on-disk change was needed —
`indexing/cache.rs::resolve_cache_dir` already hashes `sourceId + content + ref` (upstream
instead added `index-<scope>` sibling dirs in `cache.py`).
- **`csp` bin `mcp_server`** (bin) — **rmcp 1.7** stdio wiring: `CspMcpServer` with
`#[tool_router]` + `#[tool]` async `search`/`find_related`, `#[tool_handler(router =
self.tool_router)]` (routes through the stored field; the default `Self::tool_router()` would
rebuild per call and trip clippy `dead_code`). `run_mcp(path, ref, content)` serves on a tokio
runtime. Verified on the wire (initialize / tools/list / tools/call).
runtime with `content` as the per-call default. Verified on the wire (initialize / tools/list /
tools/call).

### 4.17 `csp/src/bin/csp/main.rs` — CLI (clap)

Expand Down
2 changes: 2 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,8 @@ args = [

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

또한 두 도구 모두 호출 단위로 검색 대상을 고르는 `content`(`code`, `docs`, `config`, `all`)를 받습니다. 예를 들어 서버가 코드만 인덱싱하는 저장소에서 가이드 문서를 찾고 싶다면 `content: "docs"`를 넘기면 됩니다. 생략하면 아래의 서버 설정 값을 따르며, 서로 다른 `content` 선택은 세션 안에서 각각 별도로 인덱싱·캐시됩니다.

기본적으로 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,8 @@ Add to `~/.config/zed/settings.json` (or `.zed/settings.json` in your project):

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.

Both tools also accept `content` (`code`, `docs`, `config`, or `all`) to choose what a single call searches, e.g. `content: "docs"` to look up a guide in a repo the server otherwise indexes as code. It defaults to the server's configured content (below). Each distinct content selection is indexed and cached separately for the session.

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
170 changes: 166 additions & 4 deletions crates/csp/src/bin/csp/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ use rmcp::transport::stdio;
use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler, ServiceExt};
use tokio::sync::Mutex;

use csp::mcp::{find_related_tool, search_tool, IndexCache, SERVER_INSTRUCTIONS};
use csp::mcp::{
find_related_tool, resolve_content_selection, search_tool, ContentSelection, IndexCache,
SERVER_INSTRUCTIONS,
};
use csp::stats::default_stats_file;
use csp::types::ContentType;
use csp::utils::resolve_snippet_lines;
Expand All @@ -41,6 +44,9 @@ pub struct SearchParams {
/// `null` for the full chunk when the snippet lacks context.
#[serde(default = "default_max_snippet_lines")]
pub max_snippet_lines: Option<i64>,
/// Content to search: `code`, `docs`, `config`, or `all`. Defaults to the
/// MCP server's configured content (`--content`).
pub content: Option<ContentSelection>,
}

/// Parameters for the `find_related` tool.
Expand All @@ -58,14 +64,21 @@ pub struct FindRelatedParams {
/// 0 = location only. Pass `null` for the full chunk.
#[serde(default = "default_max_snippet_lines")]
pub max_snippet_lines: Option<i64>,
/// Content containing the related file: `code`, `docs`, `config`, or
/// `all`. Defaults to the MCP server's configured content (`--content`).
pub content: Option<ContentSelection>,
}

/// MCP server holding the session index cache and the default source.
/// MCP server holding the session index cache, the default source, and the
/// default content selection.
#[derive(Clone)]
pub struct CspMcpServer {
cache: Arc<Mutex<IndexCache>>,
default_source: Option<String>,
default_ref: Option<String>,
/// Content types indexed when a tool call omits `content` (the `--content`
/// flag; code-only by default).
default_content: Vec<ContentType>,
/// Where token-savings telemetry is appended; `None` disables recording
/// (used by tests so they don't touch the real `~/.csp/savings.jsonl`).
stats_file: Option<std::path::PathBuf>,
Expand All @@ -80,9 +93,10 @@ impl CspMcpServer {
content: Vec<ContentType>,
) -> Self {
Self {
cache: Arc::new(Mutex::new(IndexCache::new(content))),
cache: Arc::new(Mutex::new(IndexCache::new())),
default_source,
default_ref,
default_content: content,
stats_file: Some(default_stats_file()),
tool_router: Self::tool_router(),
}
Expand All @@ -95,13 +109,15 @@ impl CspMcpServer {
&self,
Parameters(p): Parameters<SearchParams>,
) -> Result<CallToolResult, McpError> {
let content = resolve_content_selection(p.content, &self.default_content);
let mut cache = self.cache.lock().await;
let out = search_tool(
&mut cache,
self.default_source.as_deref(),
self.default_ref.as_deref(),
&p.query,
p.repo.as_deref(),
&content,
p.top_k.unwrap_or(5) as usize,
resolve_snippet_lines(p.max_snippet_lines),
self.stats_file.as_deref(),
Expand All @@ -116,6 +132,7 @@ impl CspMcpServer {
&self,
Parameters(p): Parameters<FindRelatedParams>,
) -> Result<CallToolResult, McpError> {
let content = resolve_content_selection(p.content, &self.default_content);
let mut cache = self.cache.lock().await;
let out = find_related_tool(
&mut cache,
Expand All @@ -124,6 +141,7 @@ impl CspMcpServer {
&p.file_path,
p.line,
p.repo.as_deref(),
&content,
p.top_k.unwrap_or(5) as usize,
resolve_snippet_lines(p.max_snippet_lines),
self.stats_file.as_deref(),
Expand All @@ -147,7 +165,8 @@ impl ServerHandler for CspMcpServer {
///
/// `default_source` is the source indexed when a tool call omits `repo`;
/// `default_ref` pins the git revision for that default source (the `--ref`
/// flag); `content` is the content-type filter applied when building indexes.
/// flag); `content` is the content-type filter applied when a tool call omits
/// its own `content` selection.
pub fn run_mcp(
default_source: Option<String>,
default_ref: Option<String>,
Expand Down Expand Up @@ -177,6 +196,7 @@ mod tests {
assert_eq!(minimal.query, "greet");
assert!(minimal.repo.is_none());
assert!(minimal.top_k.is_none());
assert!(minimal.content.is_none());
// Absent max_snippet_lines → the MCP default of 10.
assert_eq!(minimal.max_snippet_lines, Some(10));

Expand Down Expand Up @@ -214,6 +234,81 @@ mod tests {
assert_eq!(p.line, 1);
assert!(p.repo.is_none());
assert!(p.top_k.is_none());
assert!(p.content.is_none());
}

#[test]
fn params_accept_content_selection_and_reject_unknown() {
let s: SearchParams =
serde_json::from_value(serde_json::json!({ "query": "q", "content": "docs" })).unwrap();
assert_eq!(s.content, Some(ContentSelection::Docs));
let f: FindRelatedParams = serde_json::from_value(serde_json::json!({
"file_path": "a.ts",
"line": 1,
"content": "all"
}))
.unwrap();
assert_eq!(f.content, Some(ContentSelection::All));
// Only the four documented values are accepted (no `tests`, no casing).
assert!(serde_json::from_value::<SearchParams>(
serde_json::json!({ "query": "q", "content": "tests" })
)
.is_err());
assert!(serde_json::from_value::<SearchParams>(
serde_json::json!({ "query": "q", "content": "Docs" })
)
.is_err());
}

/// Resolve a schema node to the one carrying `enum`: either the node
/// itself, a `$ref` into the document's `$defs`, or the first `anyOf`
/// branch that resolves (schemars wraps `Option<Enum>` as
/// `anyOf: [{ $ref }, { type: null }]`).
fn enum_values<'a>(
doc: &'a serde_json::Value,
node: &'a serde_json::Value,
) -> Option<&'a Vec<serde_json::Value>> {
if let Some(values) = node.get("enum").and_then(|e| e.as_array()) {
return Some(values);
}
if let Some(reference) = node.get("$ref").and_then(|r| r.as_str()) {
let name = reference.strip_prefix("#/$defs/")?;
return enum_values(doc, doc.get("$defs")?.get(name)?);
}
node.get("anyOf")?
.as_array()?
.iter()
.find_map(|branch| enum_values(doc, branch))
}

#[test]
fn tool_schemas_advertise_content_enum() {
// The wire schema clients see must list the content selection so an
// agent can discover the parameter without reading the README. Compare
// the enum values structurally (through the `$ref`), not by substring.
let expected: std::collections::BTreeSet<&str> =
["code", "docs", "config", "all"].into_iter().collect();
let tools = CspMcpServer::tool_router().list_all();
for tool in tools {
let schema = serde_json::to_value(&tool.input_schema).unwrap();
let content = &schema["properties"]["content"];
assert!(
content.is_object(),
"{} schema lacks `content`: {schema}",
tool.name
);
let values = enum_values(&schema, content)
.unwrap_or_else(|| panic!("{} `content` has no enum: {content}", tool.name));
let actual: std::collections::BTreeSet<&str> = values
.iter()
.map(|v| {
v.as_str().unwrap_or_else(|| {
panic!("{} `content` enum has non-string value: {v}", tool.name)
})
})
.collect();
assert_eq!(actual, expected, "{} `content` enum", tool.name);
}
}

#[test]
Expand Down Expand Up @@ -251,6 +346,7 @@ mod tests {
repo: None,
top_k: Some(5),
max_snippet_lines: None,
content: None,
}))
.await
.unwrap();
Expand All @@ -264,6 +360,71 @@ mod tests {
assert!(value.get("results").is_some() || value.get("error").is_some());
}

/// Extract the text payload of a tool result as parsed JSON.
fn payload(result: &CallToolResult) -> serde_json::Value {
let text = match &result.content[0].raw {
rmcp::model::RawContent::Text(t) => t.text.clone(),
_ => panic!("expected text content"),
};
serde_json::from_str(&text).unwrap()
}

/// File paths of every result in a search payload.
fn result_paths(value: &serde_json::Value) -> Vec<String> {
value["results"]
.as_array()
.map(|r| {
r.iter()
.map(|e| e["file_path"].as_str().unwrap().to_string())
.collect()
})
.unwrap_or_default()
}

#[tokio::test]
async fn search_tool_call_honors_per_call_content() {
// A code-only server (the default) whose repo also holds a doc file.
let dir = sample_source();
fs::write(
dir.path().join("README.md"),
"# Guide\n\nHow to greet a user by name from the command line.\n",
)
.unwrap();
let mut server = CspMcpServer::new(
Some(dir.path().to_string_lossy().into_owned()),
None,
vec![ContentType::Code],
);
server.stats_file = None;
let call = |content: Option<ContentSelection>| {
server.search(Parameters(SearchParams {
query: "greet".to_string(),
repo: None,
top_k: Some(5),
max_snippet_lines: Some(0),
content,
}))
};

// Omitted → the server default (code): only sample.ts is indexed.
let paths = result_paths(&payload(&call(None).await.unwrap()));
assert!(!paths.is_empty());
assert!(paths.iter().all(|p| p == "sample.ts"), "{paths:?}");

// `docs` on the same repo → a separate docs-only index.
let paths = result_paths(&payload(&call(Some(ContentSelection::Docs)).await.unwrap()));
assert!(!paths.is_empty());
assert!(paths.iter().all(|p| p == "README.md"), "{paths:?}");

// `all` → both files are searchable in one index.
let paths = result_paths(&payload(&call(Some(ContentSelection::All)).await.unwrap()));
assert!(paths.iter().any(|p| p == "sample.ts"), "{paths:?}");
assert!(paths.iter().any(|p| p == "README.md"), "{paths:?}");

// Three distinct content selections → three cached entries.
assert_eq!(server.cache.lock().await.size(), 3);
}

#[tokio::test]
async fn find_related_tool_call_reports_missing_chunk() {
let dir = sample_source();
Expand All @@ -279,6 +440,7 @@ mod tests {
repo: None,
top_k: Some(5),
max_snippet_lines: None,
content: None,
}))
.await
.unwrap();
Expand Down
Loading
Loading