Choosing a semantic code search MCP for agents (Cursor example)

Jul 18, 2026 · 8 min read
Fair MCP benchmark: choosing a semantic code search server for Cursor agents
blog AI coding

Cursor 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:

ParameterValue
CPUIntel Core i5-6300U @ 2.40 GHz, 4 cores
RAM15 GiB
ClientCursor MCP over stdio
Corpuslarge 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.

ToolRuntimeRole in the experiment
SemblePython / uvxWas Cursor primary; baseline
KenGoDrop-in candidate
SonarRustDrop-in port; .sonarignore
VelesRustDrop-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).

MetricKenSembleSonarVeles
Cold Q1256.6 s71.5 s19.5 s15.6 s
Warm Q1 median43 ms448 ms38 ms7 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 paritynoyes (.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 corpusVerdict
Semble RAM “~100–300 MB”idle ~633 MBUnderstated for a large repo; still sub-GB
Ken “extremely low” RAMidle ~2.1 GB, peak ~2.9 GBNot confirmed — heavier than Semble here
Warm search is “cheap”Ken/Sonar tens of ms; Semble ~0.45 sWarm 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 .sonarignore for 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 .gitignore alone 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.

Денис Трофимов
Authors
Архитектор ПО, Архитектор данных

Архитектор ПО, Архитектор данных


Опытный разработчик ПО с опытом работы в стартапах, банках и отраслях вроде космоса и железных дорог.


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