{
  "markdown": "# zoekt-mcp\n\n[![CI](https://github.com/radiovisual/zoekt-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/radiovisual/zoekt-mcp/actions/workflows/ci.yml)\n[![PyPI](https://img.shields.io/pypi/v/zoekt-mcp.svg)](https://pypi.org/project/zoekt-mcp/)\n[![ghcr.io](https://img.shields.io/badge/ghcr.io-zoekt--mcp-blue)](https://github.com/radiovisual/zoekt-mcp/pkgs/container/zoekt-mcp)\n[![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-blue)](https://registry.modelcontextprotocol.io/v0.1/servers?search=zoekt-mcp)\n[![License](https://img.shields.io/github/license/radiovisual/zoekt-mcp.svg)](LICENSE)\n\nAn [MCP](https://modelcontextprotocol.io) server that exposes\n[Sourcegraph Zoekt](https://github.com/sourcegraph/zoekt) code search to any\nMCP-capable AI agent — Claude Code, Claude Desktop, Cursor, MCP Inspector,\netc. — so the agent can run fast, indexed, regex/symbol-aware code search\nover your repositories regardless of the language you're working in.\n\n- **MCP server:** Python, built on\n  [FastMCP](https://github.com/modelcontextprotocol/python-sdk), runs over\n  stdio so clients can spawn it as a subprocess. Published to\n  [PyPI](https://pypi.org/project/zoekt-mcp/),\n  [ghcr.io](https://github.com/radiovisual/zoekt-mcp/pkgs/container/zoekt-mcp),\n  and listed in the\n  [official MCP Registry](https://registry.modelcontextprotocol.io/v0.1/servers?search=zoekt-mcp),\n  so **no clone is required to use it**.\n- **Backend:** a `zoekt-webserver` you run yourself via the Docker\n  Compose file attached to every\n  [GitHub release](https://github.com/radiovisual/zoekt-mcp/releases/latest) —\n  or point the MCP server at any existing zoekt-webserver you have\n  lying around.\n- **Tools exposed:** `search_code`, `list_repos`, `get_file`.\n\n## Architecture\n\n```mermaid\nflowchart LR\n    subgraph Client[\"MCP client\"]\n        CC[\"Claude Code<br/>Claude Desktop<br/>Cursor, etc.\"]\n    end\n\n    subgraph Server[\"zoekt-mcp (Python)\"]\n        Tools[\"search_code<br/>list_repos<br/>get_file\"]\n    end\n\n    subgraph Backend[\"Docker: zoekt backend\"]\n        Web[\"zoekt-webserver\"]\n        Idx[(\"zoekt index<br/>named volume\")]\n        Indexer[\"zoekt-indexer<br/>(one-shot)\"]\n    end\n\n    Code[(\"Your code<br/>bind mount\")]\n\n    CC <-->|\"stdio<br/>MCP protocol\"| Tools\n    Tools <-->|\"HTTP JSON<br/>/api/search<br/>/api/list<br/>/print\"| Web\n    Web --> Idx\n    Code --> Indexer\n    Indexer --> Idx\n```\n\n### How a single search flows through the system\n\n```mermaid\nsequenceDiagram\n    actor You\n    participant Claude as Claude Code\n    participant MCP as zoekt-mcp\n    participant Web as zoekt-webserver\n    participant Idx as zoekt index\n\n    You->>Claude: \"where is getVideoId defined?\"\n    Claude->>MCP: search_code(\"sym:getVideoId\")\n    MCP->>Web: POST /api/search\n    Web->>Idx: scan shards\n    Idx-->>Web: matches + ctags symbols\n    Web-->>MCP: raw JSON result\n    Note over MCP: trim to {repo, file,<br/>line, text, symbols}\n    MCP-->>Claude: shaped result\n    Claude-->>You: \"src/index.js:17 (function)\"\n```\n\n## Quickstart\n\nGetting from \"nothing installed\" to \"Claude can search my code\" is\nthree steps: install the MCP server, run the backend, wire it into\nyour client. No git clone required in any of them.\n\n### Prerequisites\n\nYou need exactly one of these to run the MCP server, plus Docker for\nthe backend:\n\n- **[uv](https://docs.astral.sh/uv/) on your `PATH`** — for the\n  `uvx zoekt-mcp` install path. MCP clients spawn the server via\n  `uvx`, so `which uv` must resolve in whatever shell your client\n  launches processes in. Install once per machine:\n\n  ```bash\n  # Official installer (macOS / Linux)\n  curl -LsSf https://astral.sh/uv/install.sh | sh\n\n  # Homebrew\n  brew install uv\n\n  # pipx\n  pipx install uv\n  ```\n\n  The installer drops `uv` and `uvx` into `~/.local/bin/` (Linux/macOS)\n  or `%USERPROFILE%\\.local\\bin\\` (Windows). Verify with `uv --version`.\n\n- **…or Docker** — for the `docker run ghcr.io/radiovisual/zoekt-mcp`\n  install path. Any recent Docker Desktop or engine works. You need\n  Docker anyway for the backend, so this path saves you from\n  installing `uv` if you don't already have it.\n\nAnd for the backend:\n\n- **Docker with Compose v2** — runs `zoekt-webserver` and the\n  one-shot indexer via the compose file attached to every\n  [release](https://github.com/radiovisual/zoekt-mcp/releases/latest).\n\n### 1. Start the backend (once per machine)\n\nThe zoekt backend is a regular Docker Compose stack you run\nyourself — zoekt-mcp does **not** lifecycle-manage it. Grab the\ncompose file and helper script from the latest GitHub release and\nbring them up against whatever directory holds your code:\n\n```bash\n# Fetch the two files you need from the latest release.\nmkdir -p ~/.zoekt-mcp && cd ~/.zoekt-mcp\ncurl -LO https://github.com/radiovisual/zoekt-mcp/releases/latest/download/docker-compose.yml\ncurl -LO https://github.com/radiovisual/zoekt-mcp/releases/latest/download/index.sh\nchmod +x index.sh\n\n# Point the indexer at any parent directory on your machine.\n# Every top-level subdirectory becomes one searchable repo.\necho \"ZOEKT_REPOS_DIR=/home/you/code\" > .env\n\n# Bring up zoekt-webserver + the one-shot indexer.\ndocker compose up -d\n```\n\nSo if `/home/you/code` looks like this:\n\n```text\n~/code/\n├── project-a/       → indexed as zoekt repo \"project-a\"\n├── project-b/       → indexed as zoekt repo \"project-b\"\n└── scratch-notes/   → indexed as zoekt repo \"scratch-notes\"\n```\n\n…zoekt indexes **all three repos in one pass** and you can scope any\nquery with `repo:project-a` — or leave `repo:` off to search across\neverything at once. See\n[Indexing multiple codebases](#indexing-multiple-codebases) below for\nmore on the one-server-many-repos model.\n\nSanity check:\n\n```bash\ncurl -s -XPOST -d '{\"Q\":\"repo:.\"}' http://localhost:6070/api/list \\\n  | python3 -m json.tool | head -20\n```\n\nYou should see each subdirectory of `ZOEKT_REPOS_DIR` listed as a\nzoekt repo.\n\n> On macOS / Windows Docker Desktop, the path you pick must be under\n> an allowed file-sharing root (check Docker Desktop → Settings →\n> Resources → File Sharing). On Linux there's no such restriction.\n>\n> **Just want to try it without touching your real code directory?**\n> Clone the repo and use the in-tree test fixture:\n> `./tests/fixtures/up.sh` — see [Development](#development) at the\n> bottom of this file.\n\n### 2. Wire the MCP server into your client\n\nTwo install paths — pick whichever matches your existing tooling.\nBoth end up running the same versioned server binary; the only\ndifference is how it's launched.\n\n#### Path A — `uvx` (recommended if you already have uv)\n\n`uvx` downloads the latest `zoekt-mcp` from PyPI on first\ninvocation, caches it, and spawns it. No permanent install, no venv\nto manage.\n\n**Claude Code (`~/.claude.json`):**\n\n```json\n{\n  \"mcpServers\": {\n    \"zoekt\": {\n      \"type\": \"stdio\",\n      \"command\": \"uvx\",\n      \"args\": [\"zoekt-mcp\"],\n      \"env\": { \"ZOEKT_URL\": \"http://localhost:6070\" }\n    }\n  }\n}\n```\n\nOr via the `claude` CLI:\n\n```bash\nclaude mcp add zoekt \\\n    --env ZOEKT_URL=http://localhost:6070 \\\n    -- uvx zoekt-mcp\n```\n\n**Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):**\n\n```json\n{\n  \"mcpServers\": {\n    \"zoekt\": {\n      \"command\": \"uvx\",\n      \"args\": [\"zoekt-mcp\"],\n      \"env\": { \"ZOEKT_URL\": \"http://localhost:6070\" }\n    }\n  }\n}\n```\n\n**Cursor (`~/.cursor/mcp.json` or `.cursor/mcp.json` in a project):**\n\n```json\n{\n  \"mcpServers\": {\n    \"zoekt\": {\n      \"command\": \"uvx\",\n      \"args\": [\"zoekt-mcp\"],\n      \"env\": { \"ZOEKT_URL\": \"http://localhost:6070\" }\n    }\n  }\n}\n```\n\nTo pin a specific version instead of always using the latest:\n\n```json\n\"args\": [\"zoekt-mcp==0.1.0\"]\n```\n\n#### Path B — Docker image (no Python tooling required)\n\nIf you already have Docker running for the backend and would rather\nnot install `uv`, use the container image instead. MCP clients\nspawn it over stdio exactly like the `uvx` path.\n\n**Claude Code / Claude Desktop / Cursor:**\n\n```json\n{\n  \"mcpServers\": {\n    \"zoekt\": {\n      \"command\": \"docker\",\n      \"args\": [\n        \"run\", \"-i\", \"--rm\",\n        \"--network=host\",\n        \"-e\", \"ZOEKT_URL=http://localhost:6070\",\n        \"ghcr.io/radiovisual/zoekt-mcp:latest\"\n      ]\n    }\n  }\n}\n```\n\nOn Docker Desktop (macOS/Windows) the host isn't reachable via\n`localhost` from inside a container. Drop `--network=host` and use\n`host.docker.internal` instead:\n\n```json\n\"args\": [\n  \"run\", \"-i\", \"--rm\",\n  \"-e\", \"ZOEKT_URL=http://host.docker.internal:6070\",\n  \"ghcr.io/radiovisual/zoekt-mcp:latest\"\n]\n```\n\nTo pin a specific version, replace `:latest` with the semver tag\n(e.g. `:0.1.0`). The image is multi-arch (`linux/amd64` +\n`linux/arm64`), so it works on Apple Silicon and ARM Linux hosts\nwithout extra flags.\n\n### 3. Restart the client\n\nRestart Claude Code / Claude Desktop / Cursor and the three tools\n(`search_code`, `list_repos`, `get_file`) should appear. Try\nsomething like \"where is `getVideoId` defined?\" and watch it call\n`search_code(\"sym:getVideoId\")`.\n\n## Indexing multiple codebases\n\n**One zoekt-mcp server handles as many repos as you want** — that's\nthe default. Every top-level subdirectory of `ZOEKT_REPOS_DIR` becomes\na separate searchable repo in one shared index. The `search_code`\ntool can scope to a subset with `repo:NAME` (regex matched against\nrepo names) or leave `repo:` off to search across everything.\n\nIf your projects live under different parent directories (e.g.\n`~/work/` and `~/personal/`), the simplest fix is to create a single\n\"index root\" directory with symlinks pointing at each project and set\n`ZOEKT_REPOS_DIR` to that index root. One server, one config, all\nrepos searchable.\n\n```bash\nmkdir -p ~/.zoekt-root\nln -s ~/work/project-a       ~/.zoekt-root/project-a\nln -s ~/personal/side-thing  ~/.zoekt-root/side-thing\n\ncd ~/.zoekt-mcp\necho \"ZOEKT_REPOS_DIR=$HOME/.zoekt-root\" > .env\ndocker compose up -d\n```\n\n> Docker has to follow the symlinks when it resolves the bind mount,\n> which works on Linux but is hit-or-miss on Docker Desktop. If the\n> linked directories don't show up inside the container, fall back\n> to putting real directories (or clones) under `~/.zoekt-root`\n> instead of symlinks.\n\n### When you actually need two servers\n\nA second zoekt-mcp instance is only worth the setup cost when you\nwant **fully isolated index pools** — for example, keeping work code\nand personal code in completely separate search namespaces, or\nrunning two different backends (e.g. different zoekt versions) side\nby side. It is **not** needed just to index more code; one server\nwith many subdirectories is the right tool for that.\n\nIf you genuinely want two instances:\n\n1. Copy `~/.zoekt-mcp/docker-compose.yml` to a second file, e.g.\n   `~/.zoekt-mcp/docker-compose.personal.yml`.\n2. In the copy, change:\n   - the compose project `name:` (e.g. `zoekt-mcp-personal`)\n   - the host port mapping (e.g. `6071:6070`)\n   - the named volume (e.g. `zoekt-mcp-personal-index`)\n   - the container names (e.g. `zoekt-mcp-personal-webserver`)\n3. Give the second stack its own env file, e.g.\n   `~/.zoekt-mcp/.env.personal`, pointing `ZOEKT_REPOS_DIR` at a\n   different directory.\n4. Bring each stack up with its own compose file and env file:\n\n   ```bash\n   cd ~/.zoekt-mcp\n   docker compose up -d\n   docker compose -f docker-compose.personal.yml \\\n     --env-file .env.personal up -d\n   ```\n\n5. Wire both into your MCP client as distinct servers — same\n   `zoekt-mcp` binary, different `ZOEKT_URL` values:\n\n   ```json\n   {\n     \"mcpServers\": {\n       \"zoekt-work\": {\n         \"command\": \"uvx\",\n         \"args\": [\"zoekt-mcp\"],\n         \"env\": { \"ZOEKT_URL\": \"http://localhost:6070\" }\n       },\n       \"zoekt-personal\": {\n         \"command\": \"uvx\",\n         \"args\": [\"zoekt-mcp\"],\n         \"env\": { \"ZOEKT_URL\": \"http://localhost:6071\" }\n       }\n     }\n   }\n   ```\n\nClaude Code sees two independent sets of tools (`search_code` /\n`list_repos` / `get_file` from each namespace) and decides which to\ncall based on the question.\n\nFor most users, **one server with a well-populated `ZOEKT_REPOS_DIR`\nis all you need.** Don't reach for multi-server unless you have a\nconcrete reason to isolate.\n\n### Advanced: staging code under a dedicated repos directory\n\nAs an alternative to pointing `ZOEKT_REPOS_DIR` at your real code,\nyou can create a dedicated staging directory and drop clones or\ndirectories into it. Useful when you can't expose your real code\ndirectory to Docker (e.g. corporate file-sharing restrictions on\nDocker Desktop), or for one-off experiments with a repo you don't\nhave locally:\n\n```bash\nmkdir -p ~/.zoekt-mcp/repos\ngit clone https://github.com/myorg/myrepo ~/.zoekt-mcp/repos/myrepo\n\ncd ~/.zoekt-mcp\necho \"ZOEKT_REPOS_DIR=$HOME/.zoekt-mcp/repos\" > .env\ndocker compose up -d\n```\n\nThe trade-off is a **freshness trap**: you now have two copies of\nevery project — the one you actually edit, and the staged copy.\nRe-running the indexer re-reads the staged copy, so you'd need to\n`git pull` (or `cp -r` your edits) inside\n`~/.zoekt-mcp/repos/myrepo/` before each re-index. Prefer pointing\n`ZOEKT_REPOS_DIR` at your live code directory unless you have a\nspecific reason not to.\n\n## Tool surface\n\n| Tool | Parameters | Returns |\n|------|------------|---------|\n| `search_code` | `query: str`, `max_results: int = 50`, `context_lines: int = 3` | `{query, file_count, match_count, duration_ns, files: [{repo, file, language, branches, matches: [{line, text, ranges, symbols}]}]}` |\n| `list_repos` | `filter: str = \"\"` (optional `repo:` atom) | `{count, repos: [{name, url, branches, index_time}]}` |\n| `get_file` | `repo: str`, `path: str`, `branch: str = \"HEAD\"` | `{repo, path, branch, content}` |\n\n### Query language\n\nZoekt's query DSL ([full reference](https://github.com/sourcegraph/zoekt/blob/main/doc/query_syntax.md)):\n\n| Atom | Example | Meaning |\n|------|---------|---------|\n| `repo:` | `repo:flask-app` | Restrict to repos whose name matches (regex) |\n| `file:` | `file:app.py` | Restrict to file paths matching |\n| `lang:` | `lang:python` | Restrict to a language |\n| `sym:` | `sym:list_users` | Match symbol definitions |\n| `case:yes` | `case:yes Foo` | Case-sensitive content match |\n| `/regex/` | `/users?/` | Regex content match |\n| (whitespace) | `lang:go func main` | Boolean AND |\n| `or` | `def hello or function hello` | Boolean OR |\n\n## Keeping the index fresh\n\nZoekt searches a **pre-built index**, not your files directly. When\nyou edit code, the index doesn't auto-update — your next search can\nreturn stale line numbers, miss newly-added symbols, or point Claude\nat functions that have moved or been renamed. Stale search is the\nmain thing that burns tokens, because Claude falls back to reading\nwhole files with `get_file` when `search_code` returns nothing useful.\n\nHere's what happens every time the indexer runs:\n\n```mermaid\nflowchart LR\n    Src[\"Your code<br/>(live files)\"]\n    Mount[\"/src<br/>(read-only<br/>bind mount)\"]\n    Scratch[\"/tmp/{repo}<br/>(ephemeral<br/>copy)\"]\n    Git[\"throwaway<br/>git repo<br/>+ snapshot commit\"]\n    Shard[(\"index shard<br/>/data/*.zoekt\")]\n\n    Src -->|bind mount| Mount\n    Mount -->|cp -r| Scratch\n    Scratch -->|\"git init; git add -A;<br/>git commit\"| Git\n    Git -->|zoekt-git-index| Shard\n```\n\nThe copy to `/tmp/` is ephemeral — it happens fresh on every indexer\nrun and never touches your real files. Each refresh always reads\nwhatever is currently in the mounted source directory.\n\nFortunately, re-indexing is fast (seconds, even for large repos),\nruns entirely in Docker, involves no LLM calls, and costs zero\ntokens. You just need to decide **how** you want to trigger it.\n\nBecause the main quickstart already points `ZOEKT_REPOS_DIR` at your\nlive code directory, every re-index automatically reflects your\nlatest edits — no copy step to keep in sync. (If you're on the\n[advanced staging workflow](#advanced-staging-code-under-a-dedicated-repos-directory)\ninstead, update the clones under `~/.zoekt-mcp/repos/` before you\ntrigger a re-index, otherwise zoekt just re-reads the stale copies.)\n\n### Recipes for triggering the re-index\n\nAll four recipes run out-of-band — no Claude, no tokens, no context\nwindow involvement. Pick whichever matches how you work.\n\n#### 1. Manual re-index\n\nRun `~/.zoekt-mcp/index.sh` whenever you know you've made significant\nchanges. The script runs just the indexer container against the\ncurrent `ZOEKT_REPOS_DIR` without bouncing the webserver, so search\nstays available throughout.\n\n```bash\n~/.zoekt-mcp/index.sh\n```\n\n*Good when:* you only use Claude for occasional sessions and don't\nmind typing one command before you start. Zero background cost.\n\n#### 2. Cron (scheduled re-index)\n\nBackground re-index on a schedule. No manual step, slightly stale\nbetween ticks.\n\n```cron\n# Re-index every 15 minutes\n*/15 * * * * cd ~/.zoekt-mcp && ./index.sh >/dev/null 2>&1\n```\n\n*Good when:* you work on code most days and want fresh-ish search\nany time you open Claude. Once an hour is fine for most users.\n\n#### 3. Filesystem watcher\n\nReact to file changes in near-real-time via `inotifywait` (Linux)\nor `fswatch` (macOS). Catches every edit, idle otherwise.\n\n```bash\n# Linux: one-liner, run it in a tmux pane or as a systemd --user service\nwhile inotifywait -r -e modify,create,delete,move \\\n    --exclude '\\.git/|node_modules/|__pycache__/' \\\n    /path/to/your/code 2>/dev/null; do\n  ~/.zoekt-mcp/index.sh\ndone\n```\n\n```bash\n# macOS equivalent with fswatch (brew install fswatch)\nfswatch -o /Users/you/code | xargs -n1 -I{} ~/.zoekt-mcp/index.sh\n```\n\n*Good when:* you want \"search is always current, no matter when I\nask.\" Caveat: on projects with noisy tooling (compilers writing to\nbuild dirs, IDE lockfiles), the excludes list is important — without\nthem you'll re-index constantly.\n\n#### 4. Claude Code SessionStart hook\n\nRe-index every time you launch a new Claude Code session, so the\nfirst search of every session is guaranteed fresh. This is probably\nthe best default for most users: no background process, no cron\nentry, and freshness is tied exactly to when you'd actually notice\nstaleness.\n\n```json\n// ~/.claude.json\n{\n  \"hooks\": {\n    \"SessionStart\": [\n      {\n        \"command\": \"$HOME/.zoekt-mcp/index.sh\"\n      }\n    ]\n  }\n}\n```\n\n*Good when:* you want zero ongoing processes and guaranteed fresh\nsearch at the moment you actually use Claude. The session start is\nblocked on the re-index, but that's a few seconds at most.\n\n### Which one should I pick?\n\n| If you… | Use |\n|---------|-----|\n| …occasionally fire up Claude and don't mind a manual step | **Recipe 1** (manual) |\n| …want \"set it and forget it\" but tolerate N-minute staleness | **Recipe 2** (cron) |\n| …want always-fresh search and can tune the exclude list | **Recipe 3** (watcher) |\n| …mostly interact with code via Claude Code sessions | **Recipe 4** (SessionStart hook) |\n\nNone of these recipes are exclusive — e.g. running cron *and* the\nSessionStart hook is fine if you want both ambient freshness and a\nguarantee at session start.\n\n## Manual testing with MCP Inspector\n\n```bash\nnpx @modelcontextprotocol/inspector uvx --from . zoekt-mcp\n```\n\nThe Inspector opens a browser UI on `http://localhost:6274`. Under **Tools**\n→ **search_code**, try:\n\n- `lang:python def hello` — expect a match in `flask-app/app.py`\n- `lang:javascript USERS` — expect a match in `express-app/index.js`\n- `sym:users` — expect matches in **both** examples\n\nUnder **Tools → list_repos**, an empty filter should return both\n`flask-app` and `express-app`.\n\n## Development\n\nThis section is for hacking on zoekt-mcp itself. If you just want to\n*use* it, the [Quickstart](#quickstart) above covers everything — no\nclone required. Only come here if you want to change the Python\nserver, run the full test suite, or cut a release.\n\n### Setup\n\nClone the repo and let `uv` manage the venv for you:\n\n```bash\ngit clone https://github.com/radiovisual/zoekt-mcp\ncd zoekt-mcp\nuv sync\n```\n\n`uv sync` creates `.venv/`, resolves everything against `uv.lock`, and\ninstalls all runtime + dev dependencies. The dev group (`pytest`,\n`pytest-asyncio`, `respx`, `ruff`, `pre-commit`, `pymarkdownlnt`) is\ninstalled by default; pass `uv sync --no-dev` for a runtime-only\ninstall.\n\nCommon dev commands:\n\n```bash\nuv run pytest                    # run the full test suite\nuv run zoekt-mcp --help          # run the CLI from source\nuv add <package>                 # add a new runtime dep\nuv add --dev <package>           # add a new dev dep\nuv lock --upgrade                # refresh uv.lock\n```\n\nTo run zoekt-mcp from your local clone against a running backend\n(e.g. while iterating on the server code):\n\n```bash\nuv run zoekt-mcp --zoekt-url http://localhost:6070\n```\n\n### Releasing\n\nReleases are fully automated — a tag push triggers the pipeline\nthat publishes to PyPI and ghcr.io and cuts a GitHub release with\nthe compose file attached. See [`RELEASING.md`](RELEASING.md) for\nthe cut-a-release flow (helper script + manual paths) and the\none-time PyPI/GHCR setup required before the first tag.\n\n### Commit routine\n\nLinting and tests are wired into the git flow via a\n[pre-commit](https://pre-commit.com) hook so you never have to remember\nto run them by hand. After `uv sync`, install both hook types once per\nclone:\n\n```bash\nuv run pre-commit install --hook-type pre-commit --hook-type pre-push\n```\n\nFrom then on, every `git commit` runs:\n\n- **ruff** (`ruff check` + `ruff format --check`) against staged\n  Python files — config lives under `[tool.ruff]` in\n  [`pyproject.toml`](pyproject.toml).\n- **pymarkdownlnt** (`pymarkdown scan`) against staged Markdown files\n  — config lives under `[tool.pymarkdown]` in\n  [`pyproject.toml`](pyproject.toml). We disable `MD013` (line length)\n  and `MD046` (code block style) because they fight readable prose and\n  wide tables, and `MD033` so the troubleshooting `<details>` blocks\n  are allowed.\n\nAnd every `git push` runs the offline pytest suites\n(`tests/test_client_unit.py` and `tests/test_server_shaping.py`) before\nthe push leaves the machine, so a broken test can never hit the remote.\nTests are scoped to `pre-push` rather than `pre-commit` to keep local\ncommits snappy; the integration suite is excluded because it needs a\nrunning zoekt-webserver.\n\nThe hooks shell out to `uv run`, so the tool versions pinned in\n`uv.lock` are what runs locally and in CI — no drift between\nenvironments. The same linters **and** the same unit tests run on\nevery push to `main` and every pull request via\n[`.github/workflows/ci.yml`](.github/workflows/ci.yml).\n\nTo run everything manually (e.g. before opening a PR):\n\n```bash\nuv run pre-commit run --all-files\n```\n\nTo fix Python formatting in place rather than just checking it:\n\n```bash\nuv run ruff format\nuv run ruff check --fix\n```\n\n## Automated tests\n\n```bash\n# Unit tests (no Docker required)\nuv run pytest tests/test_client_unit.py tests/test_server_shaping.py -v\n\n# Integration tests: bring the stack up against the examples/ corpus,\n# then run the live assertions.\n./tests/fixtures/up.sh\nuv run pytest tests/test_integration.py -v\n./tests/fixtures/down.sh\n```\n\n`tests/fixtures/up.sh` sets `ZOEKT_REPOS_DIR=../examples` and invokes\nthe same `deploy/docker-compose.yml`, so the test fixtures don't leak\ninto the production deploy path. The integration tests skip\nautomatically when `ZOEKT_URL` is unreachable, so a plain\n`uv run pytest` in a fresh checkout without Docker still passes.\n\n## Configuration\n\n| Setting | Env var | Flag | Default |\n|---------|---------|------|---------|\n| Zoekt backend URL | `ZOEKT_URL` | `--zoekt-url` | `http://localhost:6070` |\n| HTTP timeout (s) | `ZOEKT_TIMEOUT` | `--timeout` | `30` |\n\nThe env var and the flag are equivalent — pick whichever fits your\nMCP client's config shape better. Most clients set environment\nvariables via an `\"env\"` block in their JSON config, which is why\nthe `uvx` and Docker snippets above use `ZOEKT_URL` rather than\n`--zoekt-url`.\n\n## Repo layout\n\n```text\nzoekt-mcp/\n├── src/zoekt_mcp/         # the Python MCP server\n├── tests/\n│   ├── test_client_unit.py     # offline unit tests\n│   ├── test_integration.py     # live tests (skip when backend down)\n│   └── fixtures/               # test-only helpers (up.sh / down.sh)\n├── deploy/\n│   ├── docker-compose.yml      # generic zoekt backend (env-driven)\n│   └── repos/                  # user-populated source mount (gitignored)\n└── examples/\n    ├── flask-app/              # Flask verification corpus\n    └── express-app/            # Express verification corpus\n```\n\n## Troubleshooting\n\nCommon indexing pitfalls, in Q&A form. Click any question to expand\nthe answer.\n\n<details>\n<summary><b>Q: <code>search_code</code> returns 0 hits for a string I know is in my project. What's wrong?</b></summary>\n\nNine times out of ten the index doesn't actually contain your code —\nzoekt is searching a different (or stale) corpus. The MCP server\nitself doesn't filter or rewrite queries; whatever you send goes\nstraight to `/api/search`, so 0 hits means 0 hits *in the index*.\n\nDiagnose it in three steps:\n\n1. Ask the agent to call `list_repos` (or `curl -s -XPOST -d '{\"Q\":\"repo:.\"}' http://localhost:6070/api/list`). This is the source of truth for what zoekt can see.\n2. If your project isn't in the list, the indexer was pointed somewhere else. Common culprits:\n   - `~/.zoekt-mcp/.env` is missing or has the wrong `ZOEKT_REPOS_DIR`, so `docker compose up` indexed an empty or unexpected directory.\n   - Someone ran `./tests/fixtures/up.sh` from a dev clone, which sets `ZOEKT_REPOS_DIR=../examples` and indexes only `examples/express-app` and `examples/flask-app`.\n   - The indexer wipes `/data/*` on every run, so a previous good run does **not** persist alongside a later one — the most recent indexer invocation is the only thing the webserver can see.\n3. Re-run the indexer against the right directory:\n\n    ```bash\n    cd ~/.zoekt-mcp\n    echo \"ZOEKT_REPOS_DIR=/absolute/path/to/parent-of-your-repo\" > .env\n    docker compose up -d --force-recreate zoekt-indexer\n    ```\n\n    `ZOEKT_REPOS_DIR` must be a **parent** directory; every top-level subdirectory under it becomes one repo. Re-run `list_repos` after the indexer exits to confirm.\n\n</details>\n\n<details>\n<summary><b>Q: The indexer exits with <code>WARNING: no repositories were indexed</code>. Now what?</b></summary>\n\nThe directory pointed at by `ZOEKT_REPOS_DIR` has no top-level\nsubdirectories the indexer could turn into repos. Set\n`ZOEKT_REPOS_DIR` to a parent that already contains your project\nsubdirectories:\n\n```bash\ncd ~/.zoekt-mcp\necho \"ZOEKT_REPOS_DIR=$HOME/code\" > .env\ndocker compose up -d\n```\n\nLoose files at the top of `ZOEKT_REPOS_DIR` are ignored — the loop\nin the compose file only iterates over directories.\n\n</details>\n\n<details>\n<summary><b>Q: <code>list_repos</code> shows <code>express-app</code> and <code>flask-app</code> but not my code.</b></summary>\n\nThose are the in-repo verification fixtures under `examples/` in a\ndev clone. They end up in your index when something — usually\n`tests/fixtures/up.sh` from a local clone — ran the indexer with\n`ZOEKT_REPOS_DIR=../examples`. Re-index against your real project\ndirectory (see the first Q&A above) and they'll be replaced; the\nindexer wipes `/data/` at the start of every run, so there's no need\nto clean up separately.\n\n</details>\n\n<details>\n<summary><b>Q: I edited a file but search results still show the old content / line numbers.</b></summary>\n\nThe index is a snapshot, not a live view. zoekt only sees what was\nin `ZOEKT_REPOS_DIR` the last time the indexer ran. Trigger a refresh\nwith `~/.zoekt-mcp/index.sh`, or set up one of the four automation\nrecipes in [Keeping the index fresh](#keeping-the-index-fresh) so it\nhappens on its own. Re-indexing is fast (seconds, even for large\nrepos) and runs entirely in Docker — no LLM calls, zero token cost.\n\n</details>\n\n<details>\n<summary><b>Q: <code>POST /api/search</code> returns HTML instead of JSON.</b></summary>\n\nThe webserver was started without `-rpc`, so `/api/*` falls through\nto the HTML search handler. The release-bundled `docker-compose.yml`\nalready passes `-rpc` (see the `command:` block under\n`zoekt-webserver`); if you're running your own zoekt-webserver\nelsewhere, add `-rpc` to its argv and restart.\n\n</details>\n\n## License\n\nMIT — see [`LICENSE`](LICENSE).\n\n<!-- mcp-name: io.github.radiovisual/zoekt-mcp -->\n",
  "bytes": 28338,
  "sha": "091a704156d52f8d54fe8bfacf76319dd7714ff600e5aed4f6bbc39250eda3cd",
  "repo_slug": "radiovisual/zoekt-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_radiovisual_zoekt_mcp_071adffa/readme"
}