Semantic search over a Markdown knowledge base, served over MCP.
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, usegroove service install— it replaced the formerpersonal-httprecipe 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.tomlthat sits in the project? Add"--config", "/abs/path/to/groove.toml"toargs, beforeserve. 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 othergrooveinvocation against that config,indexmost of all. See Trusted and untrusted config locations. A config beside the binary, or onegroove service installplaced, 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.
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.tomlsits 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), andgroove indexdeletes 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 onegroove service installplaced, 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.
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
--format text (default, color when TTY) / json / github for CI annotations.0 (no violations), 1 (violations), 2 (schema load error)..txt files are skipped (no frontmatter concept).index and serve commands are not affected — validation is opt-in only.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:
127.0.0.1:3100 (loopback). groove has no built-in authentication, so the bind address is the only access control — use --bind 0.0.0.0:3100 on trusted networks only. Since v0.17.0 a non-loopback --bind is refused unless you add --i-know, matching groove service install. A non-loopback address coming from [transport.http].bind in groove.toml is not gated — existing service deployments keep working — and it warns at startup only when the Host allow-list is missing or empty (see the next two bullets). Writing an explicit allowed_hosts list is taken as a statement of intent, so that combination is silent by design.Host header itself, loopback-only by default, to prevent DNS rebinding attacks; rmcp’s own check is switched off so that one gate answers wherever the check runs (ADR-0009). Host validation is not authentication — any peer that can reach the port may send Host: localhost. Treat it as a browser-side defence, and restrict reachability at the network layer.For LAN / intranet exposure, set [transport.http].allowed_hosts in groove.toml to your public hostnames / IPs (e.g. ["kb.example.lan", "192.168.1.10"]). Binding to a non-loopback address with the default loopback-only allow-list means external requests are 403’d by Host validation; groove emits a tracing::warn at startup when this misconfiguration is detected. An empty allowed_hosts = [] disables the check entirely, which combined with a non-loopback bind leaves /mcp open to every peer that can reach the port — that combination now warns at startup too.
Origin validation is on by default, unlike allowed_hosts. The MCP specification states that a Streamable HTTP server “MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks”, so omitting [transport.http].allowed_origins accepts the loopback origins for the bind port (http://localhost:PORT, http://127.0.0.1:PORT, http://[::1]:PORT) rather than accepting everything. Requests that carry no Origin header — every ordinary MCP client, the tray, curl — pass regardless, per RFC 6454; the check exists to stop a web page open in your own browser from reaching the port, and it is not a second access control. Behind a reverse proxy a browser-based client sends your public origin, so name it explicitly. Setting the key replaces the default list rather than extending it, so keep the loopback entries too if browser clients also reach you over loopback: allowed_origins = ["https://kb.example.com", "http://127.0.0.1:3100", "http://localhost:3100"]. An empty list disables validation and warns at startup.
Origin validation covers /mcp, and /ui searches through /mcp. So this list decides whether the built-in page can search: replace the default with only a public origin and /ui is still served but every query it makes is refused. allowed_hosts does the same one step earlier — Host validation runs first, so the documented LAN recipe (allowed_hosts = ["kb.example.lan"]) refuses the Host: localhost a locally opened /ui sends. Either key replaces its default rather than extending it, so list the exact names and origins you browse with — allowed_hosts = ["127.0.0.1"] still refuses a page opened through localhost. The server warns at startup when a list has no loopback entry at all, but not when it has one that does not match the address you use; /ui reports the host and origin it needs when a search is refused. One check answers this wherever it runs — GrooveSeek performs it itself rather than leaving /mcp to rmcp, so two surfaces can no longer read the same value two different ways; ADR-0009 records the five Host spellings on which they used to, and why /mcp now refuses them. What each route compares against still differs: this key reaches /mcp and the admin routes, the admin routes match Host against a loopback-only list of their own, and /healthz validates Host only, and only when healthz_public = false. Requests carrying no Origin pass wherever the check runs, which is what the page’s own status poll and the tray send.search takes the embedder, reranker and database mutexes, and holds the last two for the rest of the pipeline. Measured with eight clients at once (cargo test -p grooveseek --release --test http_lock_contention -- --ignored), search throughput goes from ~7 to ~9 qps on a 9,800-chunk corpus and from ~12–16 to ~13–20 qps on an 800-chunk one, while latency grows about linearly with the number of clients. The table, and why a lock refactor would buy little while one embedding already uses every core, are in deployment-topologies.md.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/searchwas removed in v0.27.0. It accepted 2 of the 17 parameters thesearchtool takes, so/mcpwas already the better endpoint for anything outside the process;/uiuses/mcpnow. 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.pathwas removed. It carried the knowledge base’s absolute path, so on Windows the payload — and the status band that printed it — readC:\Users\<name>\.... Nothing consumed it: the tray readsdaemon.pidandindexing.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.
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.
[watch].enabled = false in groove.toml or --no-watch on the command line disables it.[watch].debounce_ms or --debounce-ms.Mutex<Database> / Mutex<Embedder>, so concurrent triggers are serialized at the Rust layer and are idempotent.rebuild_index, so only files whose extension is enabled in [parsers].enabled are re-indexed; other events are dropped.rebuild_index manually after the burst to recover any missed events.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.
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.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()
%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.[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.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.tomlthat 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.
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
docs/mcp-tools.md — what a connected client can calldocs/configuration.md — the same options as groove.toml keysREADME.md — install and quick start