Skip to main content
Glama
maiteuxzanela

Cirurgic Search MCP

README.md
# πŸ”¬ Cirurgic Search MCP

[![npm version](https://img.shields.io/npm/v/cirurgic-search-mcp.svg?color=cb3837&style=flat-square)](https://www.npmjs.com/package/cirurgic-search-mcp)
[![npm downloads](https://img.shields.io/npm/dm/cirurgic-search-mcp.svg?style=flat-square)](https://www.npmjs.com/package/cirurgic-search-mcp)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT)

A token-bounded, read-only code search engine exposed as a
[Model Context Protocol](https://modelcontextprotocol.io) server.

It combines **exact lexical recall** (ripgrep) with **relational AST symbols**
(Tree-Sitter, with a memory-safe Python `ast` path) and fuses both rankings with
**Reciprocal Rank Fusion (RRF)**. The result is a small, high-signal payload:
whole functions and classes with line ranges, call metadata and the exact
literal matches that led there β€” never a raw directory dump.

```bash
# Run universally via NPX (Node/Bun):
npx -y cirurgic-search-mcp

# Or via UVX / Pip (Python):
uvx cirurgic-search-mcp
pip install cirurgic-search-mcp
```

---

## The problem: context dumping

An agent asked to "find where billing is calculated" usually reaches for
`cat`, `grep -r`, or a full-file reader. That approach breaks down quickly:

- **Context flooding.** A single `grep -r` over a monorepo can return tens of
  thousands of lines, exhausting the context window before reasoning starts.
- **Lost structure.** A matching line without its enclosing function, class,
  decorators and call graph is hard to act on safely.
- **Truncation roulette.** When the payload is naively truncated, the useful
  block is often the one that gets cut.
- **Stale indexes.** Semantic indexes are expensive to build and drift as soon
  as a file changes; agents need answers about the *current* working tree.
- **No budget.** Most tools cannot promise "use at most N tokens".

Surgical search flips the default: instead of maximizing **recall per call**,
it maximizes **useful signal per token**.

---

## Why not plain ripgrep or BM25?

| Capability | ripgrep alone | BM25 / chunk index | Cirurgic Search |
|---|---|---|---|
| Exact literal recall | Yes, excellent | Tokenizer-dependent | Yes (ripgrep lane) |
| Whole symbol boundaries | No | No (fixed chunks) | Yes (`ast` / Tree-Sitter) |
| Call graph / base classes | No | No | Yes (`calls:`, `bases:`) |
| Works on uncommitted edits | Yes | Only after reindex | Yes (live filesystem walk) |
| Hard token ceiling | No | Usually no | Yes (strict guard) |
| Cold start on a large repo | Fast | Index build required | Bounded walk + candidate ceiling |
| Background daemon | No | Often | No |

Pure lexical search has perfect precision on literals but no notion of a
function. Pure semantic search handles paraphrases but loses exact identifiers,
line numbers and freshness. Fusing both covers the failure mode of each.

---

## Architecture

```
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   query ──────────▢│        HybridSearchEngine    β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                   β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚                                         β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚   Lexical lane      β”‚                  β”‚    Symbol lane        β”‚
   β”‚  ripgrep / grep     β”‚                  β”‚  Python ast           β”‚
   β”‚  literal unions     β”‚                  β”‚  Tree-Sitter grammars β”‚
   β”‚  match counts       β”‚                  β”‚  relation extraction  β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚  file ranking                           β”‚  file ranking
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β–Ό
                    Reciprocal Rank Fusion (RRF)
                               β”‚
                               β–Ό
                 Macro/micro packaging + Token Guard
                               β”‚
                               β–Ό
             snippets + line ranges + relations + exact matches
```

### 1. Lexical lane

`ripgrep` runs with literal-union patterns (the full query plus its significant
words), skips vendor/build directories and groups matches by file. A POSIX
`grep` fallback keeps the server usable when `rg` is not installed. This lane is
what catches config files, lockfiles and strings that no parser understands.

### 2. Symbol lane

Python files are parsed with the standard-library `ast` module, which is
memory-safe and gives exact ranges **including decorators**. Other languages are
parsed with Tree-Sitter when a grammar bundle is available:

`JavaScript`, `TypeScript`, `TSX`, `Go`, `Rust`, `Dart`, `Java`, `Kotlin`,
`C#`, `Ruby`, `PHP`, `Swift`, `C`, `C++` and `Bash`.

Every extracted block carries relational metadata:

```
type:function | name:calculate_total
calls:parse_amount,apply_tax
type:class | name:BillingService | bases:BaseService
```

The extractor degrades gracefully: Tree-Sitter β†’ regex (JS/TS) β†’ lexical
chunks. A missing grammar never aborts a search.

### 3. Reciprocal Rank Fusion

Each lane ranks files independently. Fusion uses the standard RRF formula:

```
score(file) = Ξ£  weight_lane / (k + rank_lane(file))
```

with `k = 60` by default, `weight_lexical = 1.0` and `weight_symbol = 1.2`.
RRF needs no score calibration between lanes: it only consumes ranks, so a
strong signal from either side promotes the file.

### 4. Macro/micro packaging and the Token Guard

After fusion, the engine iterates files in RRF order. For each file it emits the
highest-scoring AST blocks (micro) and attaches any exact lexical matches that
fall inside the block's line range (macro). If a file has no parsed symbol, a
small literal preview is returned instead.

Before anything is appended, its estimated size is checked against the remaining
budget. The budget is a **hard ceiling**, not a suggestion: the first oversized
snippet is trimmed to fit and subsequent snippets that do not fit are dropped.
Token counts use a conservative `chars / 3.8` estimator.

---

## Installation

Requirements: Python 3.10+ on Linux or macOS. `ripgrep` is recommended for the
fast lane; without it the server falls back to POSIX `grep`.

```bash
# Ephemeral execution, no install step
uvx cirurgic-search-mcp

# Regular installation
pip install cirurgic-search-mcp

# Recommended: maintained grammar bundle with wider language coverage
pip install "cirurgic-search-mcp[language-pack]"
```

The base install uses `tree-sitter-languages` on Python < 3.13 and
`tree-sitter-language-pack` on newer interpreters. Installing the
`language-pack` extra explicitly is recommended on every version.

---

## MCP client configuration

With `uvx`:

```json
{
  "mcpServers": {
    "cirurgic-search": {
      "command": "uvx",
      "args": ["cirurgic-search-mcp"]
    }
  }
}
```

With a regular installation:

```json
{
  "mcpServers": {
    "cirurgic-search": {
      "command": "cirurgic-search-mcp"
    }
  }
}
```

The server speaks stdio by default. Other transports are available for local
experimentation:

```bash
cirurgic-search-mcp --transport sse
cirurgic-search-mcp --transport streamable-http
```

---

## Tool reference

### `cirurgic_search`

| Argument | Type | Default | Description |
|---|---|---|---|
| `query` | string | required | Identifier, technical term or natural-language question. |
| `workspace` | string | current cwd | Repository root. Must be an existing directory. |
| `scope` | string | `""` | Comma-separated directories, packages or files to restrict the walk. |
| `max_tokens` | integer | `1200` | Hard ceiling for the returned payload. |
| `depth` | integer | `2` | Relational depth reserved for symbol expansion. |

Example response:

```json
{
  "status": "success",
  "workspace": "/path/to/repo",
  "latency_ms": 18.4,
  "total_tokens": 742,
  "token_savings_pct": 97.8,
  "snippets_count": 2,
  "files_found": 4,
  "candidates_scanned": 312,
  "candidates_truncated": false,
  "snippets": [
    {
      "file": "billing/service.py",
      "lines": "42-67",
      "rrf_score": 0.0284,
      "relations": [
        "type:function | name:calculate_total",
        "calls:parse_amount,apply_tax"
      ],
      "exact_matches": [
        {"line": 51, "text": "# rule: daily rate for ICU"}
      ],
      "content": "def calculate_total(...):\n    ...",
      "source": "hybrid"
    }
  ]
}
```

`source` is `hybrid` when the file was found by both lanes, `ast` when it came
from the symbol lane only, and `lexical` for literal-only files.

### `qmd_search`

Backwards-compatible alias of `cirurgic_search`. It is registered with the exact
same coroutine object and schema, so the two names cannot drift. New clients
should call `cirurgic_search`; existing clients keep working unchanged.

---

## Environment variables

| Variable | Default | Purpose |
|---|---|---|
| `CIRURGIC_RG_PATH` | auto-detected | Explicit path to a ripgrep binary. |
| `CIRURGIC_GREP_TIMEOUT` | `5.0` | Subprocess timeout in seconds for the lexical lane. |
| `CIRURGIC_MAX_CANDIDATE_FILES` | `800` | Upper bound on files walked per search. Narrow `scope` when hit. |
| `CIRURGIC_TS_EXTRACT` | `1` | Set to `0` to disable Tree-Sitter and use regex/lexical fallbacks only. |

---

## Design principles

- **Read-only.** The server never writes to the workspace, spawns no daemon and
  makes no network calls.
- **Fresh by construction.** Every search walks the current working tree, so
  uncommitted edits are visible immediately.
- **Fail-soft.** Missing binaries, missing grammars, unreadable files and parse
  errors degrade to the next lane instead of failing the request.
- **Memory-safe Python path.** Python always uses `ast`, never a native binding.
- **Bounded work.** Candidate files, AST nodes and returned tokens are all
  capped, protecting latency and memory on pathological repositories.
- **No hidden state.** Results are deterministic for a given workspace, query
  and budget.

---

## Development

```bash
python -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"
python -m pytest
```

The deterministic suite uses real temporary directories and real `rg`/`grep`
subprocesses. Tests that require an optional grammar skip cleanly when that
grammar is not installed.

---

## Limitations

- Ranking is lexical/AST-based, not embedding-based. It favors exact
  identifiers, symbol names and structural proximity over paraphrase.
- Very large repositories should be searched with a narrow `scope`; the
  candidate ceiling exists to protect latency, not to index the world.
- Tree-Sitter coverage depends on the installed grammar bundle. Python works
  everywhere; other languages fall back to regex or lexical chunks.
- Symbolic links are not followed and binary files are ignored.
- `depth` is reserved for relational expansion; the current release ranks
  symbols by name, relations and content and does not traverse the full call
  graph transitively.

---

## License

MIT. See [LICENSE](LICENSE).