Semantic search over a Markdown knowledge base, served over MCP.
Reference for groove.toml: every key it accepts, where the file is
looked for, and which of those locations are trusted.
日本語版: configuration.ja.md
Any CLI option in docs/usage.md can be given a default via a groove.toml file. CLI arguments always win; the file just removes repetition for a given deployment. The discovery order is described in Config file discovery below — the most common placement is the project root (CWD) or alongside the binary. Those two are not equivalent: a file groove found is trusted only in part, so a project-root placement wants --config naming it. See Trusted and untrusted config locations. Copy grooveseek/groove.toml.example to groove.toml and edit.
A fresh copy of that template changes nothing. It is not blank — twelve sections ([quality_filter], [best_practice], [parsers], [parsers.code], [watch], [transport], [transport.http], [eval], [search], [search.mmr], [search.fusion], [search.parent_retriever]) are left active so that the shape of the file is visible — but every active value in it is already the built-in default, so copying it pins those defaults rather than altering anything. Everything that would alter behavior is commented out. The block below is a different thing: an illustration of what each key does, with values filled in — some of them non-default, some of them just the default spelled out. Read it as a menu, not as a file to paste wholesale:
# groove.toml (placed in the project root, the .git ancestor, or next to groove)
kb_path = "/path/to/knowledge-base"
model = "bge-m3"
reranker = "bge-v2-m3"
# Read by `groove serve` and by `groove search` alike (v0.27.0+). For a single
# query, override it with the MCP tool's `rerank` parameter, or by naming
# `--reranker` on the command line — `--reranker none` opts that query out.
rerank_by_default = true
fastembed_cache_dir = "/home/you/.cache/huggingface/hub"
# Where grammar plugins live (v1.3.0+). A language that is not compiled in —
# everything except Rust — is a library you download and place yourself; this
# names the directory it goes in. `GROOVE_GRAMMAR_DIR` overrides it and must be
# absolute. Read **only** when `[parsers].enabled` names a language that needs a
# plugin, so a Markdown knowledge base never consults it.
# Default: `<local data dir>/groove/grammars`, which is
# `%LOCALAPPDATA%\groove\grammars` on Windows, `~/.local/share/groove/grammars`
# on Linux, and `~/Library/Application Support/groove/grammars` on macOS. If none
# of those can be determined the key has no default, and a command that needs a
# plugin says so rather than guessing a working-directory-relative path.
# It must not be inside `kb_path`: loading a grammar plugin is executing it, and
# the knowledge base is not a security boundary, so a directory inside it would
# let anyone who can write there choose what this process runs. A run that needs
# a plugin stops rather than loading from such a directory (ADR-0016).
# See docs/clients.md for how to put a plugin in place.
grammar_dir = "/home/you/.local/share/groove/grammars"
# Heading substrings to exclude from chunking. Omit the key for no exclusions
# (the default is an empty list). Any heading containing one of these
# substrings — and its body content — is dropped from the chunk stream.
exclude_headings = ["次の深堀り候補", "参考リンク"]
# Directory basenames skipped during indexing (whole-name match). Omit the key
# for the default [".obsidian", ".git", "node_modules", "target", ".vscode",
# ".idea"]. A user-specified list replaces the default entirely; `[]` traverses
# everything.
# exclude_dirs = [".obsidian", ".git", "node_modules", "target", ".vscode", ".idea", "dist", ".next"]
# Optional external embedding provider. FastEmbed remains the default when this
# section is absent. `endpoint` is the full OpenAI-compatible embeddings URL;
# document chunks and search queries are sent there. Use one shared `model`,
# role-specific aliases, or both (see "The [embedding] section" below for which
# wins). `dimension` is required and is checked on
# every response without probing the endpoint first. The one probe is a fixed
# text sent before `groove index --force` (or MCP `rebuild_index` with `force`)
# empties the index (ADR-0024).
# [embedding]
# provider = "openai-compatible"
# endpoint = "http://127.0.0.1:8001/v1/embeddings"
# query_model = "query-model"
# document_model = "document-model"
# dimension = 768
# request_dimensions = false
# api_key = "secret" # GROOVE_EMBEDDING_API_KEY takes precedence
# timeout_seconds = 60
# max_input_chars = 8000 # inputs are cut to this many characters before sending
# max_retries = 3 # resends after 429 / 5xx / timeout; 0 sends once; at most 10
# Per-chunk quality filter. Enabled by default, threshold 0.3.
# Set `enabled = false` to restore the previous (filter-off) behavior (return every chunk).
[quality_filter]
enabled = true
threshold = 0.3
# The `get_best_practice` MCP tool is opt-in: without this section (or with an
# empty list) it answers with a "not configured" error. Templates are tried in
# order with `{target}` substituted, resolved relative to kb_path; the first
# existing file wins.
[best_practice]
path_templates = ["best-practices/{target}/PERFECT.md", "docs/{target}.md"]
# Indexing extensions. Omit the section to keep the previous default
# behavior (.md only). Opt-in to .txt / .pdf / .docx / .xlsx / .pptx
# via an explicit list. An empty array is rejected to prevent silent
# "nothing is indexed" failures.
# Currently supported ids: "md", "txt", "pdf" (v0.10.0+), "docx", "xlsx",
# "pptx" (v0.11.0+), "rs" (v1.2.0+). ("xls" was withdrawn in v0.14.0 — see
# behavior.md.) A language whose grammar is not compiled in — "py" (v1.3.0+),
# "php" (v1.5.0+) — also needs its plugin placed first; see clients.md.
# Example enabling everything:
[parsers]
enabled = ["md", "txt", "pdf", "docx", "xlsx", "pptx", "rs"]
# Source code only. max_chunk_chars is the budget for one chunk in
# non-whitespace characters: a definition that fits becomes one chunk, one that
# does not is split into its nested definitions, or by lines when it has none
# (the usual case for a long function). Lower it for finer-grained hits, raise
# it to keep long bodies whole. Default 3500. Values under 30 are rejected when
# the config loads: 30 is the smallest piece the chunker keeps on its own, so a
# smaller budget asks for pieces it then merges or drops again.
[parsers.code]
max_chunk_chars = 3500
# Optional: fail `groove index` on a Markdown file whose YAML frontmatter does
# not parse (v1.7.0+). Off by default: the file is indexed with empty metadata
# and the tag `frontmatter:unparsed`, named in a `warning:` line, and counted
# in the summary. On, the run still indexes everything and then exits 1. The
# MCP `rebuild_index` tool answers with an `error` alongside the counts. The
# watcher ignores this key.
#
# Optional: the size caps. `max_binary_file_size` (pdf / docx / xlsx / pptx)
# and `max_text_file_size` (md / txt) are the largest file, in raw bytes, that
# `groove index`, `rebuild_index` and the watcher read; a larger one is skipped
# with a warning. `max_decompressed_size` is how far an .xlsx / .docx / .pptx
# may inflate in total and how much text a PDF may yield. Each defaults to
# 50 MiB and takes a byte count, a size with a unit ("300 MiB", "2 GB"; KB, MB,
# GB and TB count in 1000s, KiB, MiB, GiB and TiB in 1024s; no fractions), or
# "unlimited". `0` is refused and the message points at "unlimited", because
# tools disagree on whether 0 means no limit, the default, or nothing at all.
# A 300 MB workbook needs both `max_binary_file_size` and
# `max_decompressed_size`: the XML inside inflates to several times the file,
# and raising the file cap alone keeps the zip-bomb check in place. A value
# above the default is announced once with a warning: a file is held in memory
# whole while it is indexed, an allocation failure aborts the process instead
# of skipping the file, and PDF extraction still stops after 120 s. Lowering a
# cap leaves documents already indexed in place until `groove index --force`
# runs or the file shrinks under the cap; an edit that leaves it over the cap
# keeps the old text searchable. Reads do not follow: `get_document` and
# `resources/read` stop at 50 MiB for binary formats and 1 MiB for text, so a
# document indexed past that is searchable but carries no `uri`. Reads also
# parse with the built-in 50 MiB decompression budget, so while
# `max_decompressed_size` is above it no binary document carries a `uri` or is
# offered by `resources/list`; it stays searchable. The watcher
# reads these three keys; a config found beside a knowledge base cannot set
# them (see the table below). Reasoning: ADR-0026.
# [index]
# fail_on_frontmatter_error = true
# max_binary_file_size = "unlimited"
# max_text_file_size = "50 MiB"
# max_decompressed_size = "16 GiB"
# `groove index` also reads `<kb_path>/groove-schema.toml` when it exists
# (v1.9.0+). The keys it declares — beyond `title` / `date` / `topic` /
# `depth` / `tags` — are stored per document as the values `search --field`
# filters on. There is no key for the path; it is the file `groove validate`
# reads. A schema that fails to load stops the index with the load error
# rather than indexing without it. `groove index` reads only that one path;
# a schema kept elsewhere and passed to `groove validate --schema <path>` is
# not seen by the index, so `search --field` filters on it answer empty.
# Live-sync file watcher. When `groove serve` is running, changes
# under kb_path are detected and the affected files are re-indexed incrementally
# within `debounce_ms`. Complementary to the PostToolUse hook: covers manual
# edits, `git pull`, external scripts, etc. CLI `--no-watch` / `--debounce-ms`
# overrides. Omitting the section keeps watcher on with a 500 ms debounce.
[watch]
enabled = true
debounce_ms = 500
# Transport for `groove serve`. `kind = "stdio"` (default)
# supports one client at a time; `kind = "http"` (Streamable HTTP) allows
# many simultaneous clients at `/mcp`. `/healthz` returns 200 for health
# checks. CLI `--transport http --port 3100` overrides.
[transport]
kind = "http"
[transport.http]
bind = "127.0.0.1:3100"
# allowed_hosts = ["kb.example.lan", "192.168.1.10"] # opt-in for LAN exposure (v0.5.0+)
# Browser Origin allow-list for /mcp (the admin routes have no Origin check).
# Unlike allowed_hosts this is ON by default: the MCP specification requires
# Origin validation, so omitting the key accepts the loopback origins for the
# bind port. Set it when a browser-based client reaches the server through a
# proxy, since it then sends your public origin. Requests with no Origin
# (ordinary MCP clients, the tray, curl) pass either way; an empty list disables
# validation and warns at startup.
# Two spelling rules, both stricter than they look. Every entry must carry a
# scheme -- unlike allowed_hosts, which takes a bare host or host:port -- and
# groove refuses to start on one that does not, because an entry it cannot
# parse would be dropped at match time and leave the check refusing every
# browser. And an entry with no port matches EVERY port on that host, so write
# the port unless you mean the scheme's default (https://kb.example.com is 443).
# allowed_origins = ["https://kb.example.com"]
# Whether /healthz sits outside the allowed_hosts check. Default true (public,
# no Host check). Set false to have /healthz validated like every other
# endpoint, so a request whose Host header is not on the allow-list gets 403
# instead of 200 (v0.7.5+). Not authentication: the Host header is chosen by
# the caller, so anyone who can reach the port and sends an allowed value still
# gets a 200. It raises the bar for incidental probes, nothing more.
# healthz_public = false
# How many MCP sessions may be alive at once. Default 256 (~25 MB; a live
# session costs about 100 KB). While it is full, a request that would open a
# NEW session gets 429 with a Retry-After header, and established sessions
# keep working. 0 disables the limit. Concerns MCP 2025-11-25 and older only:
# the 2026-07-28 protocol has no sessions, so those requests hold nothing and
# are never refused by this limit (v0.19.0+).
# max_sessions = 256
# Take the listening socket from the service manager instead of binding one.
# Linux only: it needs a service manager that passes LISTEN_FDS, such as
# systemd, and a build for any other operating system refuses the key.
# Exclusive with bind above -- two listening addresses is not a configuration,
# and groove refuses to start rather than pick one. With this set, groove never
# falls back to TCP: if LISTEN_FDS does not describe exactly one listening
# stream socket addressed to this process, serve exits (v1.11.0+).
# An abstract socket -- a ListenStream= whose name starts with @ -- is refused
# as well, because it has no file for SocketMode= to apply to.
# It also needs a transport that accepts a socket: setting this while the
# resolved transport is stdio is refused rather than ignored, because groove
# would otherwise start on stdin while nobody accepts the socket the unit
# bound. Passing --transport stdio on the command line still runs such a
# config on stdio for that one session.
# systemd_socket = true
# Optional: `groove eval` (retrieval quality evaluation, power-user feature).
# You only need this section if you run `groove eval` for tuning or
# regression tracking. Omit the section entirely for built-in defaults.
# [eval]
# golden = ".groove-eval.yml" # default: <kb_path>/.groove-eval.yml
# history_size = 10 # default: 10
# k_values = [1, 5, 10] # default: [1, 5, 10]
# regression_threshold = 0.05 # default: 0.05
# Optional: `search` tool tuning (v0.3.0+). Omit the section for defaults.
# [search]
# # rank-based low-confidence flag: trips when
# # top1.score / mean(top-N.score) < min_confidence_ratio.
# # 0.0 disables the flag. CLI `--min-confidence-ratio` and the MCP
# # param `min_confidence_ratio` override per query. Must be finite and
# # >= 0.0 — a non-finite value compares false against every score and
# # would disable the flag rather than tighten it, so groove refuses to
# # start with one.
# min_confidence_ratio = 1.5
# Optional: MMR diversity re-rank (v0.7.0+). Off by default.
# Applied AFTER reranker and BEFORE parent retriever.
# [search.mmr]
# enabled = false
# lambda = 0.7 # 1.0 = no diversity (MMR off equiv); < 0.5 leans exploration
# same_doc_penalty = 0.0 # > 0 deduplicates same-document chunks; 0 = pure MMR
# Optional: parent retriever content expansion (v0.7.0+). Off by default.
# When a hit chunk is short, expand its `content` to adjacent siblings or the
# whole document so the LLM gets enough context. Score / order untouched.
# [search.parent_retriever]
# enabled = false
# whole_doc_threshold_tokens = 100 # token_count below this -> whole document fallback
# max_expanded_tokens = 2000 # cap for adjacent merge / whole-doc (BGE-M3 <= 8192)
# Optional: RRF / bm25 fusion parameters (v0.13.0+). Defaults shown.
# Leave them alone unless `groove tune` says otherwise on your own KB.
# [search.fusion]
# rrf_k = 60.0 # >= 1.0; lower favors a single retriever's top hit
# bm25_heading_weight = 2.0 # >= 0.0
# bm25_context_weight = 1.0 # >= 0.0
# bm25_content_weight = 1.0 # >= 0.0
# Optional: static Contextual Retrieval (v0.12.0+). Off by default; strongly
# recommended only when a reranker is also configured (see "Contextual
# Retrieval" in usage.md for why).
# [contextual]
# enabled = true
With the file in place groove serve / index / status / graph / search all work without any of those flags — provided groove trusts where the file is. One it merely found, which is what a project-root or .git-ancestor placement means, keeps everything about presentation but has kb_path, [parsers], [embedding], grammar_dir, fastembed_cache_dir and the [transport.http] gates reset to safe defaults. Name it — groove --config ./groove.toml index — to have it honoured in full. Trusted and untrusted config locations says which keys and why; index is the one to get right first, since it deletes documents whose extension the parser set no longer covers. Unknown keys are rejected to catch typos early. FASTEMBED_CACHE_DIR and GROOVE_EMBEDDING_API_KEY from the real environment override their file entries.
[embedding] sectionOmitting the section keeps FastEmbed with the model chosen by the top-level
model key (default bge-small-en-v1.5). Every key in the section is optional
to the parser; which ones are required depends on provider.
| Key | Values | Default | Notes |
|---|---|---|---|
provider |
"fastembed", "openai-compatible" |
"fastembed" |
Selects the embedding backend. |
model |
FastEmbed: "bge-small-en-v1.5", "bge-m3". openai-compatible: any alias the endpoint accepts |
none | FastEmbed: selects the model, like the top-level model. openai-compatible: the shared alias for both roles. |
query_model |
Any alias (openai-compatible only) | none | Overrides model for queries. |
document_model |
Any alias (openai-compatible only) | none | Overrides model for document chunks. |
endpoint |
Full http / https URL of the embeddings endpoint, without credentials (openai-compatible only) |
none | Required for openai-compatible. A loopback endpoint (127.0.0.0/8, ::1, localhost) is always contacted directly; any other endpoint follows HTTP_PROXY / HTTPS_PROXY / ALL_PROXY and, on Windows and macOS, the OS proxy settings. |
dimension |
Integer greater than 0 (openai-compatible only) | none | Required for openai-compatible. |
request_dimensions |
true, false (openai-compatible only) |
false |
Sends the optional dimensions request field when true. |
api_key |
String (openai-compatible only) | none | Sent as a bearer token. GROOVE_EMBEDDING_API_KEY takes precedence. |
timeout_seconds |
Integer greater than 0 (openai-compatible only) | 60 |
HTTP request timeout. |
max_input_chars |
Integer greater than 0 (openai-compatible only) | 8000 |
Each input is cut to this many characters (not tokens) before it is sent, documents and queries alike. Not part of the index identity: changing it does not re-embed what is already indexed. |
max_retries |
Integer 0-10 (openai-compatible only) | 3 |
How many times a batch is sent again after HTTP 429, a 5xx, or a timeout or failed connection before the status line (a 2xx whose body times out counts too). Waits 1 s, 2 s, 4 s … (capped at 60 s, plus a jitter below a quarter of that wait) or exactly what Retry-After asks; a Retry-After over 60 s fails at once. 0 sends once. |
Which model is used:
--model on the command line selects FastEmbed with that model for the
one invocation, overriding [embedding] and the top-level model. It does
not count as setting either key, so it never trips the rules below — but it
does not rescue a file that breaks them either, because the file is checked
when it loads, before the flag is looked at.[embedding].model or from the top-level
model, whichever is set; with neither, bge-small-en-v1.5. Setting both
is refused when the config loads. Earlier releases let [embedding].model
win silently. Remove one of them.query_model or
document_model wins for its role, and model fills a role left unset.
Both roles must end up with an alias. The top-level model is FastEmbed’s
alone and is refused alongside this provider.request_dimensions = false, timeout_seconds = 60,
max_input_chars = 8000 or max_retries = 3 written out explicitly.groove looks up groove.toml in the following order on every invocation
and stops at the first hit:
| Priority | Location | Notes |
|---|---|---|
| 1 | --config <PATH> (any subcommand) |
Errors out if the file does not exist. |
| 2 | ./groove.toml (current working dir) |
Most natural for project-local KBs — but found, not named, so trusted only in part (below). |
| 3 | <git-root>/groove.toml (walks up) |
Checks CWD + up to 19 ancestors (20 dirs total). Found, not named, as above. |
| 4 | <binary-dir>/groove.toml |
Legacy / global-install fallback. Trusted in full. |
| 5 | (no config — built-in defaults) | --kb-path becomes mandatory on the CLI. |
~ in --config is expanded to the home directory on all platforms
(including Windows cmd.exe where the shell does not expand it).
The chosen file is logged to stderr at startup as
grooveseek::config: loaded config source=... path=... trust=..., so you can see
which file is in effect and how far it was trusted.
Priorities 2 and 3 find a file you did not name. If you cd into a repository
someone else wrote — or an MCP client launches the server with that directory
as its cwd — that file would otherwise be honoured in full. So groove decides
from the location alone (never from the file’s contents) whether to treat
it as yours:
--config (you named it), <binary-dir> (writing there needs
install-directory access), a config home used by groove service install,
and “no file at all”..git ancestor.An untrusted config still loads, and everything that shapes how a knowledge
base is presented — [search], [quality_filter], exclude_dirs,
[watch], [contextual], [index].fail_on_frontmatter_error — is honoured
unchanged. Eight fields are restricted, because they decide which code runs,
what is read or leaves the machine, how much of a file a run holds in memory,
and who can reach it:
| Field | From an untrusted config |
|---|---|
fastembed_cache_dir |
Ignored with a warning; the standard cache directory is used. It selects which .onnx file is loaded, and nothing verifies a model already present in a cache directory. (Related: FASTEMBED_CACHE_DIR must be an absolute path, and the model directory is never resolved relative to the working directory.) |
[transport.http].bind |
A non-loopback address keeps its port and moves to 127.0.0.1, with a warning. allowed_hosts, allowed_origins, healthz_public, max_sessions, and systemd_socket are dropped — the first three restore the loopback-only defaults, and the last two fall back to the built-in limit and to binding a TCP address, so that neither a planted max_sessions = 1 can leave the server unable to accept a second client, nor a planted systemd_socket = true keep it from starting at all where no socket was passed. Dropping allowed_origins matters in both directions: a planted list could name an attacker’s origin, or be empty, which is how “do not validate Origin at all” is spelled. kind is honoured. |
kb_path |
Ignored with a warning if it is a filesystem root, your home directory, an ancestor of it, or an ancestor of the directory holding the config file. --kb-path still applies, so you can override it; with neither, the command stops with the usual “--kb-path is required”. |
grammar_dir |
Ignored with a warning; the standard location is used. It selects which native library is dlopened into the process, and a grammar plugin is code, not data. Set for every untrusted config, present or not — omitting the key would otherwise be a way to influence the choice by saying nothing. If no standard location can be determined the key is dropped instead, and a command that needs a plugin then stops with a message naming GROOVE_GRAMMAR_DIR. |
[parsers] |
Ignored with a warning; the default set — Markdown alone — is used. enabled decides which parsers run at all, so a config found beside a knowledge base could otherwise switch on the formats with the widest input surface (pdf, xlsx, pptx, docx) that the operator had left off, or name a language whose grammar plugin then gets dlopened. It is the switch grammar_dir only aims: no enabled language needs a plugin, and no plugin is looked for. Unlike the two above, an absent key needs no substitute — omitting [parsers] already lands on Markdown alone, which is where this rule puts it. [parsers.code] goes with it, having no parser left to configure. |
[eval].golden |
Ignored with a warning; groove eval and groove tune fall back to <kb_path>/.groove-eval.yml, and --golden still applies. It names the file those commands read, and as an absolute path it can name any file on the machine. The read is bounded (1 MiB) and parsed as YAML, so what a planted path could expose is a bounded read surfaced through a parse error — not code execution — but a config found beside a knowledge base has no business choosing it. Like [parsers], an absent key needs no substitute: the fallback is a constant under the knowledge base the run uses. The other [eval] keys are honoured. |
[embedding] |
Ignored with a warning, restoring the built-in FastEmbed provider. An external endpoint receives document chunks while indexing and queries while searching, so only a config named with --config (or another trusted location above) may opt in to that outbound traffic. |
[index] size caps (max_binary_file_size, max_text_file_size, max_decompressed_size) |
Ignored with a warning; the built-in 50 MiB caps apply. rebuild_index is an MCP tool, so a config planted beside a knowledge base together with one huge file could otherwise make the daemon read that file into memory whole at a moment of a client’s choosing, and an allocation failure aborts the process rather than skipping the file. A planted value below the default is dropped as well, since a 1-byte cap would keep every document out of the index. Like [parsers], an absent key needs no substitute: the fallback is a built-in constant. fail_on_frontmatter_error is honoured. |
The kb_path rule bounds rather than confines: kb_path = "./docs" and
kb_path = "/srv/kb/knowledge-base" are fine, so a project-local
groove.toml naming an absolute path keeps working. What it refuses are the
paths that can be written without knowing anything about your machine —
../.., /, C:\Users — and symlinks pointing at them.
To accept a config in full, name it: groove serve --config ./groove.toml.
Installed services are unaffected. Since v0.20.0 groove service install puts
--config <config home>/groove.toml into the unit, plist or scheduled task it
registers, so the daemon names its own config rather than discovering it — and
is therefore trusted whatever the environment looks like at start-up. That
closes the one case where it was not: setting GROOVE_CONFIG_HOME for the
service install command alone, since the variable is no longer in the
environment when the service later runs.
A service registered by an earlier version keeps its old launch line. To update
it, re-run your own groove service install command with --force added —
a bare service install would reset the service name, auto-start and bind.
Set GROOVE_CONFIG_HOME again if you set it the first time. It is not
remembered anywhere: service install resolves the config home from the
environment it is run in, so without the variable the re-install writes a
different, minimal config and points the service at that one, leaving your
real settings unused. This applies to precisely the people the fix is for.
On Linux and macOS the re-install restarts the service, so the new launch line
takes effect immediately — on Linux that holds for a --no-auto-start service
someone started by hand too, which is restarted only if it is actually running.
Two cases still need a manual restart: Windows, where the scheduled task is
re-registered but the detached daemon is not stopped, and a --no-auto-start
LaunchAgent that is already loaded on macOS, which the installer deliberately
does not touch. Sign out and back in, or stop and start the service yourself.
What this does not cover: if a repository ships its own .mcp.json, it
controls the whole command line, not just the config file. No rule inside
groove can help there; that is what your MCP client’s approval prompt is for.
// repo-root/.mcp.json
{
"mcpServers": {
"kb": { "command": "groove", "args": ["serve", "--config", "./groove.toml"] }
}
}
Commit groove.toml next to .mcp.json. Opening the project in Claude Code
launches groove serve from the repo root, and --config names the file that
is sitting right there.
Naming it is what makes it count. Left off, the CWD lookup would still find
the file — and then treat it as untrusted, because that is exactly the shape
this section is about: a config groove found rather than one you pointed at. A
project KB of anything but Markdown would be served as Markdown only. Every
other groove command run against this config wants the same argument, index
most of all: it deletes the documents it did not visit, so a rebuild with the
default parser set removes the non-Markdown ones from the index.
{
"mcpServers": {
"kb-personal": { "command": "groove", "args": ["serve", "--config", "~/kb/personal/groove.toml"] },
"kb-project": { "command": "groove", "args": ["serve", "--config", "./groove.toml"] },
"kb-rust-docs":{ "command": "groove", "args": ["serve", "--config", "~/kb/rust-docs/groove.toml"] }
}
}
Each entry runs as an independent MCP server with its own groove.toml and
its own .groove.db, so Claude can disambiguate by server name.
docs/usage.md — the CLI flags these defaults backdocs/behavior.md — what the indexing keys actually doREADME.md — install and quick start