GrooveSeek

Semantic search over a Markdown knowledge base, served over MCP.

View the Project on GitHub alphabet-h/grooveseek

Connecting to Claude Code / Cursor

How to point an MCP client at GrooveSeek — .mcp.json over stdio, the HTTP transport for several clients at once, and the pieces around them.

日本語版: clients.ja.md

Looking for full deployment recipes? See grooveseek/examples/deployments/ for ready-to-adapt configs covering three patterns: personal stdio, NAS-shared (one writer + many read-only clients), and intranet HTTP server (one server + many clients). For a single-machine loopback daemon shared by several parallel Claude Code sessions, use groove service install — it replaced the former personal-http recipe in v0.8.0. The snippets below are the canonical stdio entry point you’ll find in those recipes.

Add the following to .mcp.json in your project root (or the equivalent MCP config for your client):

{
  "mcpServers": {
    "ai-knowledge": {
      "command": "/path/to/groove",
      "args": ["serve", "--kb-path", "/path/to/knowledge-base"],
      "type": "stdio"
    }
  }
}

Using a groove.toml that sits in the project? Add "--config", "/abs/path/to/groove.toml" to args, before serve. groove decides from a config’s location how far to trust it, and a file it merely found has its privileged keys reset — [parsers] among them, so a knowledge base of anything but Markdown would be served as if it were Markdown only. The same applies to every other groove invocation against that config, index most of all. See Trusted and untrusted config locations. A config beside the binary, or one groove service install placed, is trusted as it is.

With a multilingual model and reranker enabled:

{
  "mcpServers": {
    "ai-knowledge": {
      "command": "/path/to/groove",
      "args": [
        "serve",
        "--kb-path", "/path/to/knowledge-base",
        "--model", "bge-m3",
        "--reranker", "bge-v2-m3"
      ],
      "env": {
        "FASTEMBED_CACHE_DIR": "/path/to/.cache/huggingface/hub"
      },
      "type": "stdio"
    }
  }
}

For agent workflows, a more conservative alternative: load the reranker but leave it off by default, letting the caller opt in with rerank: true on individual search calls.

{
  "mcpServers": {
    "ai-knowledge": {
      "command": "/path/to/groove",
      "args": [
        "serve",
        "--kb-path", "/path/to/knowledge-base",
        "--model", "bge-m3",
        "--reranker", "bge-v2-m3",
        "--rerank-by-default=false"
      ],
      "env": { "FASTEMBED_CACHE_DIR": "/path/to/.cache/huggingface/hub" },
      "type": "stdio"
    }
  }
}

Or, if you placed a groove.toml somewhere on the discovery path with those options set, the .mcp.json can shrink to:

{
  "mcpServers": {
    "ai-knowledge": {
      "command": "/path/to/groove",
      "args": ["serve"],
      "type": "stdio"
    }
  }
}

The server will be started automatically when the client connects.

Keeping the index fresh via PostToolUse hook

If you edit the knowledge base from inside a Claude Code session (or run a skill that writes Markdown files), the running MCP server will keep returning stale results until the index is rebuilt. A PostToolUse hook in .claude/settings.json can re-index automatically after every write. Minimal form:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit|Skill",
        "hooks": [
          { "type": "command", "command": "groove index" }
        ]
      }
    ]
  }
}

If your groove.toml sits beside the project rather than beside the binary, name it here too: groove --config /abs/path/groove.toml index. A discovered config has [parsers] reset to Markdown alone (Trusted and untrusted config locations), and groove index deletes the documents it did not visit — so a hook that rebuilds with the default parser set removes every .txt, PDF, Office document and source file already in the index, on the first edit after you upgrade. A config next to the binary, or one groove service install placed, is trusted and needs nothing added.

SHA-256 diffing in groove index makes the second-and-later invocations fast (usually sub-second on small KBs). A richer shell script that inspects the tool payload and only rebuilds when the edited file is under $KB_PATH ships with the repo: see grooveseek/examples/hooks/ — it takes GROOVE_CONFIG for exactly this. SQLite runs in WAL mode so the hook can safely run while the MCP server is still up.

Frontmatter schema validation

If your knowledge base follows a frontmatter convention (e.g. title required, date is YYYY-MM-DD, topic limited to an enum), you can check every .md file for violations with:

groove validate --kb-path /path/to/knowledge-base

Put a groove-schema.toml at the root of --kb-path (template: groove-schema.toml.example):

[fields.title]
required = true
type = "string"
min_length = 1

[fields.date]
required = true
type = "string"
pattern = '^\d{4}-\d{2}-\d{2}$'

[fields.topic]
required = true
type = "string"
enum = ["mcp", "rag", "ai", "tooling", "ops"]

[fields.tags]
required = true
type = "array"
min_length = 1

HTTP transport for multiple simultaneous clients

By default groove serve speaks MCP over stdio — one client per server process. To serve multiple clients simultaneously (e.g. several Claude Code sessions or an external script hitting the same index), switch to Streamable HTTP:

groove serve --kb-path /path/to/knowledge-base --transport http --port 3100
# or, to accept connections from outside this machine: --bind 0.0.0.0:3100 --i-know

The server mounts the MCP endpoint at /mcp and exposes /healthz for probes. .mcp.json for an HTTP-capable client:

{
  "mcpServers": {
    "ai-knowledge": {
      "type": "http",
      "url": "http://127.0.0.1:3100/mcp"
    }
  }
}

Security notes:

Web UI and admin API (HTTP transport only)

Running serve with --transport http mounts two more routes beside /mcp and /healthz. Nothing enables them — they exist whenever the HTTP transport does — and both are loopback-only: the middleware rejects any request whose peer address is not loopback, then checks the Host header against the loopback aliases (127.0.0.1, ::1, localhost) — plus the bind address, but only when that is itself loopback. Bind to 0.0.0.0 and Host: 0.0.0.0 is rejected too, deliberately: a LAN browser must not reach these routes through the bind address. A machine elsewhere on the network gets 403 even if you allow-listed its Host for /mcp. Origin is checked after the Host, against [transport.http].allowed_origins.

Both routes answer with X-Content-Type-Options: nosniff and a Content-Security-Policy of default-src 'none' plus exactly what the page uses: its own inline <script> and <style>, its data: favicon, and same-origin fetch. Nothing external loads from /ui, and the policy is what keeps that true.

Route What it is
/ui The operator’s view: a status band (version, documents, chunks, model, watcher, uptime, pid) over a search box. It searches by calling /mcp, which makes the page the smallest working example of an MCP client over Streamable HTTP.
/api/admin/status Daemon / indexing / watcher / KB status as JSON. This is what the Windows tray polls every 5 seconds, and what the band above reads.

/api/search was removed in v0.27.0. It accepted 2 of the 17 parameters the search tool takes, so /mcp was already the better endpoint for anything outside the process; /ui uses /mcp now. See docs/stability.md.

curl http://127.0.0.1:3100/api/admin/status
{
  "daemon":   { "version": "0.13.1", "pid": 36400, "uptime_secs": 4210, "started_at": "2026-07-26T09:12:03Z" },
  "indexing": { "active": false, "started_at": null, "progress": null },
  "watcher":  { "active": true, "debounce_ms": 500 },
  "kb":       { "documents": 596, "chunks": 8878, "model": "bge-m3" },
  "config_source": "Cwd"
}

kb.path was removed. It carried the knowledge base’s absolute path, so on Windows the payload — and the status band that printed it — read C:\Users\<name>\.... Nothing consumed it: the tray reads daemon.pid and indexing.active. See docs/stability.md.

/ui is what the Windows tray’s Open Web UI menu item opens, but it is not Windows-specific. On Linux or macOS, browse to it on the machine running the daemon, or forward the port:

ssh -L 3100:127.0.0.1:3100 kb-server.lan   # then open http://127.0.0.1:3100/ui

Do not map these routes in a reverse proxy: the proxy is itself a loopback peer and its default Host is allow-listed, so proxying /ui hands the page to whoever can reach the proxy. Forward /mcp and /healthz only.

Live-sync via file watcher

groove serve runs a notify-based file watcher by default. Any change under --kb-path (create / modify / delete / rename) is detected, debounced, and only the affected file is re-indexed. This covers manual editor saves, git pull, and external scripts — cases the PostToolUse hook cannot intercept.

Placing a grammar plugin (v1.3.0+)

groove parses source code one definition at a time, but only Rust is compiled into the binary. Every other language is a small library you download and place — that asymmetry, and why it is not a feature flag, is ADR-0013.

  1. Find the archive named groove-grammar-<language>-<target> for your groove version on the releases page. The plugin and the binary share an ABI version, so a plugin from a different release may be refused.
  2. Verify its checksum before unpacking. Every archive is published with a .sha256 beside it. Loading a library runs its own initialisation before groove can inspect a single symbol, so a substituted or corrupted archive is native code running as you — and no check groove performs afterwards changes that. This step is the one that has to happen before the file is ever opened.

    Run these in the directory you downloaded both files into: a .sha256 names the archive it belongs to, and the check looks for it by that name. Substitute the target you actually downloaded — the published ones are x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, aarch64-apple-darwin and x86_64-pc-windows-msvc.

    # Linux
    sha256sum -c groove-grammar-python-x86_64-unknown-linux-gnu.tar.xz.sha256
    
    # macOS — no sha256sum in the base system, and Apple Silicon is the only Mac target published
    shasum -a 256 -c groove-grammar-python-aarch64-apple-darwin.tar.xz.sha256
    
    # Windows
    (Get-FileHash groove-grammar-python-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash -eq `
        (Get-Content groove-grammar-python-x86_64-pc-windows-msvc.zip.sha256).Split()[0].ToUpper()
    
  3. Unpack it and put the library in the grammar directory. The default is %LOCALAPPDATA%\groove\grammars on Windows, ~/.local/share/groove/grammars on Linux, and ~/Library/Application Support/groove/grammars on macOS. To use a different one, set grammar_dir in groove.toml, or the GROOVE_GRAMMAR_DIR environment variable — which must be an absolute path, because a relative one would resolve against whatever directory the client happened to launch groove from.
  4. Add the language to [parsers].enabled, e.g. enabled = ["md", "py"] — in a config groove trusts. A groove.toml groove merely found beside your project has its [parsers] ignored, so the language would never be enabled and the plugin never opened; name the file with --config (or keep it next to the binary, or let groove service install place it). See Trusted and untrusted config locations.
  5. Run groove index once by hand before letting a service do it — with the same --config you gave in step 4 if the file is one groove would otherwise merely discover. A registered Windows service discards stdio, so if the plugin is missing or refused, the message saying so goes nowhere and the daemon simply does not work. groove index resolves every enabled language before it opens the database or loads a model, so a bad plugin stops it immediately, on your screen, having created nothing. Leaving --config off here does not fail loudly — the language is simply not enabled, nothing looks for a plugin, and the run succeeds without ever having checked the one you placed. (groove doctor checks an index that already exists; on a fresh setup it answers “No index found” without ever reaching the plugin, so it is not the command for this step.)

Nothing is downloaded automatically and nothing but the enabled languages is opened — a file in that directory belonging to a language you did not enable is never touched. If an enabled language has no usable plugin, the command stops and says which file it wanted and where; it does not fall back to indexing the source as plain text.

When you replace a plugin, re-index with groove index --force. A rebuilt grammar can cut the same file into different chunks, but indexing skips files whose content has not changed — so a plain re-index leaves those files with chunks the old grammar made and applies the new one only to files you edit afterwards, and the index comes to hold two generations at once. Nothing detects this for you yet: groove does not currently warn that the plugin has changed, so the --force is on you. The same is true after upgrading groove itself. Changing [parsers.code].max_chunk_chars is the one case groove does report: the index records the budget its code chunks were cut at, so a later run configured with a different one prints a warning naming --force.

A grammar plugin is native code that groove loads into its own process. Treat one like any other binary you install: take it from the release page for the version you are running, and not from anywhere else. This is also why a groove.toml that groove merely found — rather than one you named with --config — can choose neither the directory nor the language, which is what would open a library from it at all; see Trusted and untrusted config locations.

Working around HuggingFace TLS failures on first download

Some environments (corporate proxies, firewalls with TLS inspection) reject fastembed’s native TLS connection to huggingface.co with os error 10054 / “Connection was reset”. In that case, pre-download the model via the Python HuggingFace CLI and point FASTEMBED_CACHE_DIR at the HF Hub cache:

# Install once
pip install --user huggingface_hub

# Pre-download BGE-M3 (required ONNX files only)
hf download BAAI/bge-m3 \
    --include 'onnx/*' 'tokenizer*' 'config.json' 'special_tokens_map.json'

# Pre-download BGE-reranker-v2-m3 (for `--reranker bge-v2-m3`)
hf download BAAI/bge-reranker-v2-m3

# Run groove pointing at the HF cache (HF Hub cache layout is compatible with fastembed)
FASTEMBED_CACHE_DIR=~/.cache/huggingface/hub \
    groove index --kb-path ./knowledge-base --model bge-m3 --force