Add MCP server, note mutation endpoint, and updated_at tracking (v3.0.0)

New MCP server (mcp/) exposes kb operations as native MCP tools over
Streamable HTTP with Bearer token auth. Supports collections via tag
conventions, chunked file uploads, and agent-side search patterns.

Engine gains PATCH /api/v1/notes/{id} for in-place note updates with
transactional re-chunk/re-embed, and updated_at column on documents.

Go client adds updatenote command and Patch HTTP method.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 21:34:55 +01:00
parent adeba21712
commit e7136a4a20
32 changed files with 1679 additions and 8 deletions
+1 -1
View File
@@ -1 +1 @@
2.1.0
3.0.0
+14
View File
@@ -15,3 +15,17 @@ services:
- KB_SEARCH_THRESHOLD=${KB_SEARCH_THRESHOLD:-0.01}
- HF_HUB_OFFLINE=${HF_HUB_OFFLINE:-}
restart: unless-stopped
kb-mcp:
build:
context: ../mcp
dockerfile: Dockerfile
ports:
- "${KB_MCP_PORT:-3000}:3000"
environment:
- KB_ENGINE_URL=http://kb-engine:8000
- KB_API_KEY=${KB_API_KEY:-}
- KB_MCP_API_KEY=${KB_MCP_API_KEY:-}
depends_on:
- kb-engine
restart: unless-stopped
+14
View File
@@ -23,3 +23,17 @@ services:
- KB_SEARCH_THRESHOLD=${KB_SEARCH_THRESHOLD:-0.01}
- HF_HUB_OFFLINE=${HF_HUB_OFFLINE:-}
restart: unless-stopped
kb-mcp:
build:
context: ../mcp
dockerfile: Dockerfile
ports:
- "${KB_MCP_PORT:-3000}:3000"
environment:
- KB_ENGINE_URL=http://kb-engine:8000
- KB_API_KEY=${KB_API_KEY:-}
- KB_MCP_API_KEY=${KB_MCP_API_KEY:-}
depends_on:
- kb-engine
restart: unless-stopped
+14
View File
@@ -20,3 +20,17 @@ services:
- KB_SEARCH_THRESHOLD=${KB_SEARCH_THRESHOLD:-0.01}
- HF_HUB_OFFLINE=${HF_HUB_OFFLINE:-}
restart: unless-stopped
kb-mcp:
build:
context: ../mcp
dockerfile: Dockerfile
ports:
- "${KB_MCP_PORT:-3000}:3000"
environment:
- KB_ENGINE_URL=http://kb-engine:8000
- KB_API_KEY=${KB_API_KEY:-}
- KB_MCP_API_KEY=${KB_MCP_API_KEY:-}
depends_on:
- kb-engine
restart: unless-stopped
+4
View File
@@ -185,6 +185,10 @@ def init_schema(conn: sqlite3.Connection, embedding_dim: int) -> None:
_backfill_enriched_text(conn)
_rebuild_fts(conn)
# Migrate: add updated_at to documents if missing (v3.0.0)
if "updated_at" not in doc_cols:
conn.execute("ALTER TABLE documents ADD COLUMN updated_at TEXT")
conn.commit()
+1 -1
View File
@@ -1 +1 @@
from kb.routes import health, search, jobs, documents, tags, status, reindex, auth
from kb.routes import health, search, jobs, documents, tags, status, reindex, auth, notes
+3 -2
View File
@@ -26,7 +26,7 @@ async def list_documents(
sql = """
SELECT d.id, d.title, d.doc_type,
(SELECT COUNT(*) FROM chunks c WHERE c.document_id = d.id) AS chunk_count,
d.created_at
d.created_at, d.updated_at
FROM documents d
"""
joins: list[str] = []
@@ -50,7 +50,7 @@ async def list_documents(
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY d.created_at DESC"
sql += " ORDER BY COALESCE(d.updated_at, d.created_at) DESC"
rows = conn.execute(sql, params).fetchall()
@@ -74,6 +74,7 @@ async def list_documents(
"tags": [t["name"] for t in tag_rows],
"chunk_count": row["chunk_count"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
})
return results
+120
View File
@@ -0,0 +1,120 @@
"""Note mutation endpoint — update existing notes in place."""
import hashlib
import logging
from fastapi import HTTPException
from pydantic import BaseModel
from main import app
from kb.config import cfg
from kb.database import (
get_connection,
build_enriched_text,
insert_chunk,
insert_embedding,
)
from kb.embeddings import embed_texts
from kb.ingest.note import chunk_note
logger = logging.getLogger("kb.routes.notes")
class NoteUpdateRequest(BaseModel):
text: str
@app.patch("/api/v1/notes/{doc_id}")
async def update_note(doc_id: int, req: NoteUpdateRequest):
conn = get_connection(cfg.db_path)
try:
doc = conn.execute(
"SELECT id, title, doc_type FROM documents WHERE id = ?", (doc_id,)
).fetchone()
if not doc:
raise HTTPException(status_code=404, detail="Document not found.")
if doc["doc_type"] != "note":
raise HTTPException(
status_code=422,
detail="Only notes can be updated via this endpoint.",
)
title = doc["title"]
# Delete existing chunks and their embeddings
chunk_ids = conn.execute(
"SELECT id FROM chunks WHERE document_id = ?", (doc_id,)
).fetchall()
for row in chunk_ids:
conn.execute("DELETE FROM chunks_vec WHERE chunk_id = ?", (row["id"],))
conn.execute("DELETE FROM chunks WHERE document_id = ?", (doc_id,))
# Run note chunking pipeline on new text
chunks = chunk_note(req.text)
chunk_texts = [c["text"] for c in chunks]
chunk_metas = [
{k: v for k, v in c.items() if k != "text"} or None for c in chunks
]
enriched_texts = [
build_enriched_text(title, ct, cm)
for ct, cm in zip(chunk_texts, chunk_metas)
]
# Embed — if this fails, the transaction rolls back
vectors = embed_texts(enriched_texts)
for idx, (chunk_text, enriched, vector) in enumerate(
zip(chunk_texts, enriched_texts, vectors)
):
chunk_id = insert_chunk(
conn,
document_id=doc_id,
chunk_index=idx,
text=chunk_text,
enriched_text=enriched,
metadata=chunk_metas[idx],
)
insert_embedding(conn, chunk_id, vector)
# Update content_hash and updated_at
content_hash = hashlib.sha256(req.text.encode("utf-8")).hexdigest()
conn.execute(
"UPDATE documents SET content_hash = ?, updated_at = current_timestamp WHERE id = ?",
(content_hash, doc_id),
)
conn.commit()
# Return updated document
updated_doc = conn.execute(
"SELECT * FROM documents WHERE id = ?", (doc_id,)
).fetchone()
new_chunks = conn.execute(
"SELECT * FROM chunks WHERE document_id = ? ORDER BY chunk_index",
(doc_id,),
).fetchall()
tag_rows = conn.execute(
"""
SELECT t.name FROM tags t
JOIN document_tags dt ON t.id = dt.tag_id
WHERE dt.document_id = ?
ORDER BY t.name
""",
(doc_id,),
).fetchall()
return {
**dict(updated_doc),
"tags": [t["name"] for t in tag_rows],
"chunks": [dict(c) for c in new_chunks],
}
except HTTPException:
raise
except Exception:
conn.rollback()
logger.exception("Failed to update note %d", doc_id)
raise HTTPException(status_code=500, detail="Failed to update note.")
finally:
conn.close()
+7
View File
@@ -48,6 +48,13 @@ async def update_document_tags(doc_id: int, req: TagUpdateRequest):
if req.remove:
untag_document(conn, doc_id, req.remove)
if req.add or req.remove:
conn.execute(
"UPDATE documents SET updated_at = current_timestamp WHERE id = ?",
(doc_id,),
)
conn.commit()
tag_rows = conn.execute(
"""
SELECT t.name FROM tags t
+1 -1
View File
@@ -62,7 +62,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="kb-engine", version=__version__, lifespan=lifespan)
# Import routes after app is created
from kb.routes import health, search, jobs, documents, tags, status, reindex, auth # noqa: E402, F401
from kb.routes import health, search, jobs, documents, tags, status, reindex, auth, notes # noqa: E402, F401
if __name__ == "__main__":
import uvicorn