Choosing a semantic code search MCP for agents (Cursor example)
Fair MCP benchmark: choosing a semantic code search server for Cursor agentsCursor agents need more than Grep. Lexical search finds strings; it does not reliably surface the service or controller you meant when the query is a mix of names and intent. A local semantic / hybrid MCP with search and find_related cuts the blind-read loop that burns tokens.
Picking that MCP is the same class of problem I have been solving on real machines for years: measure under your constraints, distrust marketing RAM numbers, then cut over.
Constraints first
The bench host is not a lab workstation:
| Parameter | Value |
|---|---|
| CPU | Intel Core i5-6300U @ 2.40 GHz, 4 cores |
| RAM | 15 GiB |
| Client | Cursor MCP over stdio |
| Corpus | large ERP-class monorepo for a loan/lease firm (cars, equipment, real estate), multi-root with a second API repo |
That rules out heavy transformer forwards and anything that wants Ollama or a cloud vector DB on the same box. The useful class is BM25 plus a static Model2Vec-style embedding — the family behind Semble, Ken, and their Rust ports.
The codebase itself is a classic PHP / Yii monolith: one fat application tree, not a fleet of tiny services. Beside controllers, modules, and services sit years of migrations/, upload and generated trees under web/ (images, PDFs, built assets), and large static bundles that are not source. .gitignore already keeps vendor/, node_modules/, runtime/, and similar out of VCS — and out of tools that only honor gitignore. What it does not exclude is the rest of that low-signal bulk. For semantic search that gap matters: every extra path becomes files on disk to walk, chunks to embed, cold-start time, and RSS. With an extra ignore file in the Semble/Sonar style, the fair index on this corpus landed around ~4.3k files / ~18.6k chunks. The paths that only .sembleignore / .sonarignore cut — migrations/, web/image|assets|…, assets/reports|bonds, and similar — are about ~3.6k files / ~340 MiB on disk here (including ~1.1k migration files alone). Without those excludes beyond gitignore, the indexer is happy to chew that bulk on top of the ~4.3k-file application index — nearly doubling the walk — for trees that almost never answer an agent query.
I had already wired Semble into agent workflows (earlier post on Zoo Code). The question was whether to keep it as Cursor primary.
Candidates
In scope: local (or self-hosted) MCP with semantic / hybrid retrieval and a drop-in search / find_related surface.
| Tool | Runtime | Role in the experiment |
|---|---|---|
| Semble | Python / uvx | Was Cursor primary; baseline |
| Ken | Go | Drop-in candidate |
| Sonar | Rust | Drop-in port; .sonarignore |
| Veles | Rust | Drop-in + extra symbol tools; gitignore-only |
Out of scope after a quick check: Bloop as a search MCP (archived desktop app, wrong tool surface), ripgrep MCP as a Semble replacement (Cursor already has Grep), Livegrep / Zoekt (lexical UI), and stacks that need Ollama or a remote vector DB on this host.
Setup
Below is enough to reproduce the fair MCP bench on your own machine. Paths are placeholders (~/projects/lease-erp, ~/.local/bin). Put binaries on PATH. Restart Cursor after any mcp.json change. In multi-root workspaces, agents must pass an absolute repo path to search / find_related.
Semble (baseline)
Python / uvx. Was Cursor primary before the cutover; still the easiest way to get a reference MCP with .sembleignore.
# needs uv (https://docs.astral.sh/uv/)
uvx --from 'semble[mcp]' semble --help
{
"mcpServers": {
"semble": {
"command": "uvx",
"args": ["--from", "semble[mcp]", "semble", "--content", "code", "docs"],
"type": "stdio"
}
}
}
Repo-root .sembleignore uses gitignore syntax (see the shared ignore sketch under Sonar).
Ken
Pure Go drop-in (townsendmerino/ken). Hybrid mode for the bench: KEN_MCP_MODE=hybrid. Ignore surface is gitignore-only (no .kenignore).
# Go 1.26+ on PATH, or grab a release binary
go install github.com/townsendmerino/ken/cmd/ken@latest
go install github.com/townsendmerino/ken/cmd/ken-mcp@latest
# ensure $(go env GOPATH)/bin is on PATH, or copy into ~/.local/bin
ken download-model # potion-code-16M (~60 MB); ken-mcp can auto-fetch on first run
{
"mcpServers": {
"ken": {
"command": "ken-mcp",
"env": { "KEN_MCP_MODE": "hybrid" },
"type": "stdio"
}
}
}
CLI warm times are misleading: ken search reindexes per call. Measure long-lived ken-mcp the way Cursor does.
Veles
Pure Rust (julymetodiev/Veles). Prebuilt installer is the short path; MCP is veles serve-mcp. Walk is gitignore-only — no .sembleignore equivalent at index time.
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/julymetodiev/Veles/releases/latest/download/veles-cli-installer.sh | sh
# or: cargo install veles-cli
veles --version
# optional: symlink into ~/.local/bin if the installer landed under ~/.cargo/bin
{
"mcpServers": {
"veles": {
"command": "veles",
"args": ["serve-mcp"],
"type": "stdio"
}
}
}
Index lives under <repo>/.veles/. For a fair cold start: veles clean ~/projects/lease-erp (and remove residual .veles/ if needed) before the first MCP search.
Sonar
Pure Rust drop-in (ooboai/sonar). No prebuilt releases when I cut over — build with stable rustup. Supports .sonarignore (gitignore syntax).
# rustup stable → ~/.cargo/bin on PATH
git clone https://github.com/ooboai/sonar.git ~/src/sonar
cd ~/src/sonar && cargo build --release
install -m 755 target/release/sonar target/release/sonar-mcp ~/.local/bin/
sonar download-model # minishlab/potion-code-16M into Sonar's cache
which sonar-mcp
{
"mcpServers": {
"sonar": {
"command": "sonar-mcp",
"args": ["--content", "code", "docs"],
"type": "stdio"
}
}
}
Cursor-parity with the Semble baseline is --content code docs (default Sonar MCP is often code only). Smoke: sonar search "SchedulePreviewService" -p ~/projects/lease-erp -k 5. Clear ~/.cache/sonar before a cold bench run.
Shared .sonarignore / .sembleignore shape for a PHP/Yii ERP tree (paths beyond what .gitignore already covers — do not repeat vendor/, node_modules/, runtime/):
web/assets/
web/tmp/
web/upload/
web/pdf/
web/docs/
web/image/
assets/reports/
assets/bonds/
migrations/
tests/_output/
tests/_support/_generated/
Method: MCP, not CLI
I measured long-lived MCP stdio — one process per server, cold first search, then warm repeats. Metrics: cold Q1 latency, warm Q1 median (5×), idle RSS / peak HWM, top-1 path on three fixed queries, and ignore parity — whether the tool can exclude paths beyond .gitignore the way .sembleignore does. On a PHP/Yii ERP monolith that is not a nice-to-have; it is what keeps the index on application code instead of migrations and web/ bulk.
CLI one-shots are a trap here. Ken’s CLI reindexes on every call, so “warm” CLI times are not a proxy for Cursor. Fair comparison clears caches before cold and, for Sonar, uses a .sonarignore copied from the former .sembleignore.
Sonar’s Cursor-parity config is --content code docs (same as Semble in mcp.json). An earlier run with code only was close; the code docs rebench is what I treat as canonical.
What I did not measure: multi-day index churn, non-PHP tree-sitter quality in depth, or every non-drop-in MCP with a different tool contract.
Harness sketch
The fair run is one long-lived MCP process per server: clear index caches, cold search, then warm repeats, while sampling RSS from /proc. Paths below are placeholders — point --repo at your own tree.
# Cursor-parity content modes; binaries on PATH
export BENCH_REPO="$HOME/projects/lease-erp"
uvx --with mcp --from mcp python bench_fair_mcp.py \
--repo "$BENCH_REPO" --order ken-first
uvx --with mcp --from mcp python bench_fair_rust_ports.py \
--repo "$BENCH_REPO" --servers sonar --sonar-content "code docs"
Core of the measurement loop (Python MCP client SDK):
REPO = os.environ.get("BENCH_REPO", str(Path.home() / "projects" / "lease-erp"))
QUERIES = [
"SchedulePreviewService preview",
"ReferenceCacheService",
"actionPreview schedule leasing",
]
TOP_K = 5
async def call_search(session, query: str) -> float:
t0 = time.perf_counter()
await session.call_tool(
"search",
arguments={"query": query, "repo": REPO, "top_k": TOP_K},
)
return time.perf_counter() - t0
async def measure(command: str, args: list[str]) -> dict:
# wipe this server's disk cache before cold (tool-specific)
params = StdioServerParameters(command=command, args=args)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
cold_s = await call_search(session, QUERIES[0])
warm = [await call_search(session, QUERIES[0]) for _ in range(5)]
return {
"cold_s": cold_s,
"warm_q1_median_s": statistics.median(warm),
}
RSS / peak HWM come from the worker PID’s /proc/<pid>/status (VmRSS, VmHWM), excluding any long-lived Cursor MCP already sitting in the IDE. That is the difference from a one-shot CLI that reindexes on every call.
Results
Corpus: that ERP-class loan/lease monorepo. Ken and Semble numbers are from the 2026-07-17 fair MCP run; Sonar / Veles from 2026-07-18 (Sonar column = code docs).
| Metric | Ken | Semble | Sonar | Veles |
|---|---|---|---|---|
| Cold Q1 | 256.6 s | 71.5 s | 19.5 s | 15.6 s |
| Warm Q1 median | 43 ms | 448 ms | 38 ms | 7 ms |
| Idle RSS | ~2.1 GB | ~0.63 GB | ~0.66 GB | ~0.52 GB |
| Peak HWM | ~2.9 GB | ~0.68 GB | ~0.84 GB | ~0.52 GB |
| Ignore parity | no | yes (.sembleignore) | yes (.sonarignore) | gitignore-only |
Top-1 on the three fixed queries: all four hit the expected PHP services on Q1/Q2. On the natural-language Q3 (actionPreview schedule leasing), all four miss into the same wrong controller — parity of failure, not a Sonar win.
A dual-MCP smoke (Cursor’s warm Semble plus a cold Sonar start) sat around ~1.1 GB combined idle. Dual is viable on 15 GiB; it was not required after cutover.
Claims vs measurement
| Claim (marketing / reviews) | On this corpus | Verdict |
|---|---|---|
| Semble RAM “~100–300 MB” | idle ~633 MB | Understated for a large repo; still sub-GB |
| Ken “extremely low” RAM | idle ~2.1 GB, peak ~2.9 GB | Not confirmed — heavier than Semble here |
| Warm search is “cheap” | Ken/Sonar tens of ms; Semble ~0.45 s | Warm is fine for Ken/Sonar; cold is what hurts UX |
Decision
On an i5 / ≤16 GiB host and a large monorepo, cold start, RAM, and ignore control matter more than the fastest warm millisecond.
I cut over Cursor primary to Sonar (sonar-mcp --content code docs) on 2026-07-18.
- Sonar — ~3.5× faster cold than Semble, ~10× faster warm, RAM in the same band as Semble, and
.sonarignorefor ignore-parity. - Veles — best raw cold/warm/RSS in this table, but gitignore-only. On this monorepo that excludes
migrations/, generated assets, and similar paths that.gitignorealone does not cover. Not primary. - Ken — rejected: cold ~257 s, ~2–3 GB RSS, no ignore file; marketing RAM claim did not hold.
- Semble — superseded as Cursor primary; still a useful reference for the contract and ignore model.
Non-drop-in semantic MCPs (different tool surfaces, knowledge graphs, multi-repo indexes) stay out of primary until there is a separate need.
Takeaway
For Cursor agents on constrained laptops, do not buy a code-search MCP on warm-ms screenshots. Run a fair long-lived MCP bench on your corpus, check ignore parity, and treat CLI numbers as a different product.
On this host, that process selected Sonar — not because it won every cell, but because it won the cells that decide whether the agent stays usable after a cold start.

Архитектор ПО, Архитектор данных
Опытный разработчик ПО с опытом работы в стартапах, банках и отраслях вроде космоса и железных дорог.
- Инженер Go, Python, C++, C с 2006 года.
- Последние 3 года: инженерия платформ, создание внутренних порталов разработчиков (IDP) и сдвиг организаций влево в DevOps.
- Проектировал и строил автономные и клиент-серверные приложения с базами Oracle DB, PostgreSQL и MySQL.
- Разрабатывал CRM-системы, веб-автоматизированную обработку заказов и симуляции для эксплуатации железнодорожного подвижного состава.