Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
537 changes: 537 additions & 0 deletions .github/api-surface-baseline.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
Expand All @@ -125,6 +126,7 @@ jobs:
- uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

### v4.11.1 (2026-10-25) — Fresh-install startup fix and Windows-safe state writes

Patch release. On v4.10.0 and v4.11.0, every fresh install and every new data directory, new Docker volumes included, failed to start with `Embedding function conflict … persisted: default`. Installs that already hit the error start normally after upgrading. No breaking changes and no migration steps. Thanks to @mmathoosh and @rshun for the reports, @franzgranlund for tracing it to the preflight probe, @cauger-dev for the detailed Windows analysis in #249, and @vishalhabib99 and @viniciussantos45 for #236 and #242.

**Added:**

- The Docker image is published for `linux/amd64` and `linux/arm64` (#242), so Apple Silicon and ARM servers run it natively instead of under emulation.

**Fixed:**

- `search_knowledge(category)`, `list_documents(category)` and `add_from_url(title)` now accept an explicit `null` (#236). They were annotated `str = None`, so the tool schema advertised `"default": null` on a string-only field and the MCP SDK rejected `{"category": null}` with a validation error before the tool ran. They are now `Optional[str]`, matching the other optional arguments.
- Fresh installs and new data directories start again (#248, #252). Since v4.10.0 they failed with `Embedding function conflict: new: fastembed-… vs persisted: default`: the startup preflight probe created the collection without an embedding function, so ChromaDB persisted its `default` one and refused FastEmbed afterwards. The probe now only reads the collection, and the empty collection the old probe left behind is recreated on the next start. A collection that holds data is never dropped.
- On Windows, saving the index metadata, the reindex checkpoint, the repair journal, the FTS5 state or a document no longer fails when an antivirus, indexer or concurrent reader briefly holds the file (#249). The atomic replace is retried for about 1.5 s on `WinError 5`/`32`, then the original error is raised. Other platforms are unchanged.
- `reindex_documents` answers `already_running` instead of `started` when a file-watcher pass or a document write holds the index, instead of starting a reindex that was refused at once (#249). `full_rebuild=True` still starts and waits for the lock.

---

### v4.11.0 (2026-10-04) — Cloud-sync data_dir guard

**Added:**
Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Index or reindex all documents in the knowledge base. **Runs in background** —
| `full_rebuild` | bool | false | Re-embed all documents into a staging collection and replace the live collection after validation. Requires a running orchestrator and temporary storage for both collections. |
| `resume` | bool | false | Resume a compatible smart-reindex checkpoint; missing, corrupt, or incompatible checkpoints start a fresh smart run. Cannot be combined with `full_rebuild=true`. |

**Returns:** `{"status": "started", "operation": "..."}` immediately. If already running, returns `{"status": "already_running", "progress": "1200/3734"}`.
**Returns:** `{"status": "started", "operation": "..."}` immediately. If a reindex, a file-watcher pass or a document write is already running, returns `{"status": "already_running", "progress": "1200/3734"}`; retry after it finishes. `full_rebuild=true` waits for a watcher pass or document write instead.

Poll until `reindex.active` is false, then inspect `last_error` and `last_result.errors`. An idle worker is not proof of a successful rebuild. Smart reindexing writes a resumable checkpoint; see the [operations guide](reindex-operations.md).

Expand Down
2 changes: 1 addition & 1 deletion mcp_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Knowledge RAG MCP Server - Local Retrieval-Augmented Generation System"""

__version__ = "4.11.0"
__version__ = "4.11.1"
__author__ = "Ailton Rocha (Lyon.)"

from .config import Config # noqa: E402
Expand Down
30 changes: 27 additions & 3 deletions mcp_server/file_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,33 @@
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from time import sleep

logger = logging.getLogger(__name__)

# ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION: a scanner, indexer or reader holds
# the path without FILE_SHARE_DELETE, and Windows cannot replace over it until it lets go.
_WINDOWS_HELD_ERRORS = frozenset({5, 32})
_REPLACE_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.4, 0.8)


def replace_with_retry(source: str | os.PathLike[str], destination: str | os.PathLike[str]) -> None:
"""``os.replace`` that waits out a transient Windows handle on either path (GH #249).

Only PermissionError carrying winerror 5 or 32 is retried, for about 1.5 s; then
it is re-raised, so a real permission problem still surfaces. Elsewhere this is
a plain ``os.replace``.
"""
for delay in _REPLACE_RETRY_DELAYS:
try:
os.replace(source, destination)
return
except PermissionError as exc:
if getattr(exc, "winerror", None) not in _WINDOWS_HELD_ERRORS:
raise
sleep(delay)
os.replace(source, destination)


class StagedTextFile:
"""Prepare beside the destination so rename works on every supported OS."""
Expand Down Expand Up @@ -47,19 +71,19 @@ def publish(self) -> None:
) as stream:
backup = Path(stream.name)
try:
os.replace(self.destination, backup)
replace_with_retry(self.destination, backup)
except BaseException:
backup.unlink(missing_ok=True)
raise
self.backup = backup
os.replace(self.path, self.destination)
replace_with_retry(self.path, self.destination)
self.published = True

def close(self) -> None:
"""Restore old bytes after failure; never delete a backup whose restore failed."""
if not self.committed:
if self.backup is not None:
os.replace(self.backup, self.destination)
replace_with_retry(self.backup, self.destination)
elif self.published:
self.destination.unlink(missing_ok=True)
for temporary in (self.path, self.backup):
Expand Down
4 changes: 3 additions & 1 deletion mcp_server/fts5_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
from pathlib import Path
from typing import Any, Callable, Iterable, List, Optional, Sequence, Tuple

from .file_transaction import replace_with_retry

ChunkRow = Tuple[str, str, str, str]
ChunkIterFactory = Callable[[], Iterable[ChunkRow]]
ProgressCallback = Callable[[int, int], None]
Expand Down Expand Up @@ -137,7 +139,7 @@ def write(self, payload: dict[str, Any]) -> None:
json.dump(payload, tmp)
tmp.flush()
os.fsync(tmp.fileno())
os.replace(tmp_name, self._path)
replace_with_retry(tmp_name, self._path)
except Exception:
try:
os.unlink(tmp_name)
Expand Down
9 changes: 8 additions & 1 deletion mcp_server/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ def _probe_chroma(timeout_seconds: int = PROBE_TIMEOUT_SECONDS) -> subprocess.Co
"""Check Chroma in a child process so native crashes do not kill MCP startup."""
code = r"""
import chromadb
from chromadb.errors import NotFoundError

from mcp_server.config import config

Expand All @@ -168,7 +169,13 @@ def _probe_chroma(timeout_seconds: int = PROBE_TIMEOUT_SECONDS) -> subprocess.Co
raise SystemExit(0)

client = chromadb.PersistentClient(path=str(config.chroma_dir))
collection = client.get_or_create_collection(name=config.collection_name)
try:
# GH #248/#252: read only. Creating it here persisted Chroma's default
# embedding function, which the server then refused to open.
collection = client.get_collection(name=config.collection_name, embedding_function=None)
except NotFoundError:
print("missing")
raise SystemExit(0)
print(collection.count())
"""
env = os.environ.copy()
Expand Down
69 changes: 52 additions & 17 deletions mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
# Local imports
from . import __version__
from .config import config
from .file_transaction import staged_text_file
from .file_transaction import replace_with_retry, staged_text_file
from .filesystem import io_path
from .fts5_index import Fts5LexicalIndex, Fts5MigrationError, Fts5NotReadyError
from .ingestion import Document, DocumentParser
Expand Down Expand Up @@ -1642,17 +1642,41 @@ def _init_chroma_client(self):
def _safe_get_collection(self):
"""Open the collection without discarding data on initialization errors."""
try:
return self.chroma_client.get_or_create_collection(
name=config.collection_name,
embedding_function=self.embed_fn,
metadata={"description": "Knowledge base for RAG"},
)
return self._open_collection()
except Exception as exc:
raise RuntimeError(
f"Could not open ChromaDB collection {config.collection_name!r} "
f"at {config.chroma_dir}: {exc}. Existing index preserved. "
"Check database access and embedding configuration before retrying."
) from exc
if not self._drop_empty_default_ef_collection():
raise RuntimeError(
f"Could not open ChromaDB collection {config.collection_name!r} "
f"at {config.chroma_dir}: {exc}. Existing index preserved. "
"Check database access and embedding configuration before retrying."
) from exc
return self._open_collection()

def _open_collection(self) -> Any:
"""Get or create the production collection bound to the FastEmbed function."""
return self.chroma_client.get_or_create_collection(
name=config.collection_name,
embedding_function=self.embed_fn,
metadata={"description": "Knowledge base for RAG"},
)

def _drop_empty_default_ef_collection(self) -> bool:
"""Drop the collection only when it is empty and bound to Chroma's default EF.

GH #248/#252: the preflight probe used to create it without an embedding
function, so Chroma persisted ``default`` and refused FastEmbed on every
later open. An empty one holds nothing; one with data is never touched.
"""
try:
stale = self.chroma_client.get_collection(name=config.collection_name, embedding_function=None)
ef_name = (stale.configuration_json.get("embedding_function") or {}).get("name")
if ef_name != "default" or stale.count() != 0:
return False
self.chroma_client.delete_collection(name=config.collection_name)
except Exception:
return False
print("[RECOVERY] Recreating empty collection bound to Chroma's default embedding (GH #248)", file=sys.stderr)
return True

def _check_dimension_mismatch(self) -> bool:
"""Check if stored embeddings have different dimension than current config.
Expand Down Expand Up @@ -2387,6 +2411,17 @@ def start_reindex_background(
if self._reindex_progress.get("active"):
return {"status": "already_running", "progress": dict(self._reindex_progress)}

# GH #249: index_all refuses at once while a watcher pass or a document
# write holds the index lock, so "started" would hide the refusal.
# nuclear_rebuild waits for the lock instead.
# ponytail: a probe, not admission. A pass that takes the lock between this
# check and the worker's index_all is still refused, as before, and shows up
# as last_result.skipped_reason; move admission into the worker if it matters.
if mode != "nuclear_rebuild":
if not self._index_lock.acquire(blocking=False):
return {"status": "already_running", "progress": {**self._reindex_progress, "operation": None}}
self._index_lock.release()

self._reindex_progress = self._fresh_reindex_progress(mode, resume_state)

# GH #163 (v4.8.3): propagate force through smart_reindex so
Expand Down Expand Up @@ -4367,7 +4402,7 @@ def _save_document_repairs(self, repairs: Dict[str, List[str]]) -> None:
temporary.unlink(missing_ok=True)
raise
try:
os.replace(temporary, path)
replace_with_retry(temporary, path)
finally:
temporary.unlink(missing_ok=True)

Expand Down Expand Up @@ -4497,7 +4532,7 @@ def _save_metadata(self) -> None:
temporary.unlink(missing_ok=True)
raise
try:
os.replace(temporary, self._metadata_file)
replace_with_retry(temporary, self._metadata_file)
finally:
temporary.unlink(missing_ok=True)

Expand Down Expand Up @@ -4556,7 +4591,7 @@ def _write_checkpoint(
json.dumps(payload, indent=2, ensure_ascii=False),
encoding="utf-8",
)
os.replace(tmp_path, self._checkpoint_file)
replace_with_retry(tmp_path, self._checkpoint_file)
# Reflect in progress dict for status polling.
self._reindex_progress["checkpoint_saved_at"] = payload["checkpoint_at"]

Expand Down Expand Up @@ -4688,7 +4723,7 @@ def _make_snippet(content: str, max_chars: int = 500) -> str:
def search_knowledge(
query: str,
max_results: int = 5,
category: str = None,
category: Optional[str] = None,
hybrid_alpha: float = 0.3,
min_score: float = 0.0,
snippet_mode: bool = True,
Expand Down Expand Up @@ -4986,7 +5021,7 @@ def list_categories() -> str:
@mcp.tool()
@rate_limited
@instrument("list_documents")
def list_documents(category: str = None) -> str:
def list_documents(category: Optional[str] = None) -> str:
"""
List all indexed documents, optionally filtered by category.

Expand Down Expand Up @@ -5155,7 +5190,7 @@ def remove_document(filepath: str, delete_file: bool = False) -> str:
@mcp.tool()
@rate_limited
@instrument("add_from_url")
def add_from_url(url: str, category: str = "general", title: str = None) -> str:
def add_from_url(url: str, category: str = "general", title: Optional[str] = None) -> str:
"""
Fetch content from a URL, convert to markdown, and add to the knowledge base.

Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "knowledge-rag",
"version": "4.11.0",
"version": "4.11.1",
"description": "Local RAG MCP server with hybrid search, cross-encoder reranking and local document indexing.",
"bin": {
"knowledge-rag": "./bin/cli.js"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "knowledge-rag"
version = "4.11.0"
version = "4.11.1"
description = "Local RAG System for Claude Code — Hybrid search + Cross-encoder Reranking + 13 MCP Tools + 20 Format Parsers. Zero external servers."
readme = "README.md"
license = {text = "MIT"}
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

# ── Linting & formatting ────────────────────────────────────────────────────
ruff>=0.16.9,<1.0
pre-commit>=4.6.2,<5.0
pre-commit>=4.7.0,<5.0

# ── Tests ───────────────────────────────────────────────────────────────────
pytest>=9.1.1,<10.0
Expand Down
Loading
Loading