Latest changes all archived

This commit is contained in:
2026-04-04 22:50:19 +01:00
parent e9a282ddb1
commit 223ff2cf5d
31 changed files with 748 additions and 7 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-04
@@ -0,0 +1,194 @@
## Context
The engine API (`engine/kb/routes/`) provides single-document operations for delete (`DELETE /api/v1/documents/{id}`) and tag management (`PUT /api/v1/documents/{id}/tags`). The MCP server (`mcp/server.py`) wraps these and adds a "collection" abstraction via `collection:`-prefixed tags — ~70 lines of helpers and translation logic that only the MCP layer understands.
The database is SQLite with WAL mode, FTS5 for full-text search, and sqlite-vec for embeddings. Foreign keys with `ON DELETE CASCADE` handle chunk cleanup when documents are deleted. Stored files on disk must be cleaned up separately.
## Goals / Non-Goals
**Goals:**
- Bulk delete, bulk tag add/remove, and bulk set-tags (replace) via engine API, MCP tools, and CLI
- Filter-based selection: by tag, doc_type, ID list, and ID range
- Safety threshold to prevent accidental mass operations
- Audit trail via jobs table
- Remove collection abstraction from MCP server
**Non-Goals:**
- Async/queued bulk operations (SQLite handles thousands of rows synchronously in <1s)
- Bulk document retrieval or bulk note creation
- Undo/recycle bin for bulk deletes
- Adding collection concept to engine or CLI (collections are being removed, not moved)
## Decisions
### 1. Common selection filter for all bulk endpoints
All three bulk endpoints accept the same selection body:
```json
{
"document_ids": [1, 5, 12],
"tags": ["agent:mybot", "draft"],
"doc_type": "note",
"from_id": 10,
"to_id": 50
}
```
Filters combine with AND logic. At least one filter is required — the engine rejects requests with no selection criteria (400).
**Selection SQL generation**: A shared helper in `database.py` builds the WHERE clause from the filter. The `tags` filter uses the same JOIN pattern as `list_documents` (all specified tags must match). The `document_ids` filter uses `IN (?)`. The `from_id`/`to_id` filter uses `id >= ? AND id <= ?`.
**Alternative considered**: Separate endpoints per filter type. Rejected — combinable filters are more powerful and the SQL generation is straightforward.
### 2. Safety threshold with configurable percentage
Before executing, the engine counts matched documents and total documents. If `matched / total > threshold`, the request is rejected:
```
HTTP 409 Conflict
{
"error": "safety_threshold_exceeded",
"message": "Operation would affect 750 of 1000 documents (75.0%). Exceeds safety threshold of 70%. Use force: true to proceed.",
"matched": 750,
"total": 1000,
"percent": 75.0,
"threshold": 70
}
```
- Default threshold: 70% (env var `KB_BULK_SAFETY_PERCENT`, integer 0-100)
- Override per-request: `"force": true` in the request body
- Threshold of 0 effectively disables the safety check
- CLI maps this to `--force` / `-f` flag
The check is a SELECT COUNT before the operation — minimal overhead.
**Alternative considered**: Dry-run mode (preview what would be affected, then confirm). Rejected — adds a two-step flow that doesn't help LLM callers (they'd just always confirm) and the safety threshold covers the dangerous case.
### 3. Synchronous execution with audit logging
Bulk operations execute synchronously and return a summary response:
```json
{
"job_id": 42,
"status": "done",
"matched": 750,
"succeeded": 748,
"failed": 2,
"errors": [
{"document_id": 42, "error": "file locked"},
{"document_id": 99, "error": "not found"}
]
}
```
A job record is created in the `jobs` table with a new `bulk_delete` / `bulk_tags` / `bulk_set_tags` status type. This requires extending the jobs table:
- Add `job_type` column: `"ingest"` (default, for existing jobs) or `"bulk_delete"` / `"bulk_tags"` / `"bulk_set_tags"`
- The job's `filename` field stores a JSON summary of the selection filter for auditability
- `document_id` field stores the count of affected documents
- `error` field stores JSON array of individual errors if any
**Alternative considered**: Full async with job polling. Rejected — SQLite bulk operations are fast enough synchronously and async would require extra polling calls (defeating the purpose of reducing token usage).
### 4. Bulk delete implementation
For each matched document:
1. Collect chunk IDs
2. Delete embeddings from `chunks_vec`
3. Delete the document row (cascades to chunks, document_tags)
4. Delete stored file from disk
This follows the same logic as the existing `delete_document` endpoint but batched in a single transaction (except file deletion, which happens after commit). If a file deletion fails, the document is still counted as succeeded (the DB record is gone) but a warning is logged.
The operation processes documents within a single SQLite transaction for atomicity of the DB changes. File deletions happen post-commit and are best-effort.
### 5. Bulk tags implementation
Two distinct operations:
**`POST /api/v1/bulk/tags`** — Add and/or remove tags:
```json
{
"add": ["reviewed", "approved"],
"remove": ["draft"],
...selection filters...
}
```
**`POST /api/v1/bulk/set-tags`** — Replace all tags:
```json
{
"tags": ["final", "approved"],
...selection filters...
}
```
The `set-tags` operation removes all existing tags from matched documents, then applies the new set. This is useful for cleaning up tag clutter or migrating tagging schemes.
Both update `updated_at` on affected documents.
### 6. Remove collection abstraction from MCP
Remove from `mcp/server.py`:
- Constants: `COLLECTION_TAG_PREFIX`, `DEFAULT_COLLECTION`
- Functions: `_collection_tag`, `_strip_collection_tags`, `_process_document`, `_process_search_results`, `_ensure_exclusive_collection`
- Tool: `kb_set_collection` (entire tool removed)
- Parameters: `collection` from `kb_search`, `kb_addnote`, `kb_upload_start`
The `_process_document` and `_process_search_results` calls in remaining tools are removed — documents are returned as-is from the engine, with all tags visible.
Users/agents that need namespace isolation use a tag convention (e.g. `agent:claude-code`) communicated via system prompt or tool instructions.
### 7. Engine bulk route module
New file: `engine/kb/routes/bulk.py`
Three endpoints sharing common infrastructure:
- `_resolve_selection(conn, filters)` → list of document IDs + count
- `_check_safety_threshold(matched, total, force)` → raises HTTPException if exceeded
- `_log_bulk_job(conn, job_type, filters, matched, succeeded, failed, errors)` → job_id
### 8. MCP bulk tools
Three new tools in `mcp/server.py`, thin wrappers calling new `engine.py` methods:
- `kb_bulk_delete(document_ids?, tags?, doc_type?, from_id?, to_id?, force?)` → str (JSON)
- `kb_bulk_tags(document_ids?, tags?, doc_type?, from_id?, to_id?, add?, remove?, force?)` → str (JSON)
- `kb_bulk_set_tags(document_ids?, tags?, doc_type?, from_id?, to_id?, new_tags?, force?)` → str (JSON)
Note: The `tags` parameter on bulk tools serves as a **selection filter** (which documents to target), while `add`/`remove` (on bulk_tags) and `new_tags` (on bulk_set_tags) are the **operation** (what to do to the tags). Tool descriptions must make this distinction clear.
### 9. CLI bulk commands
Three new commands under `client/cmd/`:
```
kb bulk-remove --tags "draft,old" --type note --force --yes
kb bulk-tag --tags "agent:mybot" --add "reviewed" --remove "pending" --yes
kb bulk-set-tags --ids "1,5,12" --tags "clean,final" --yes
```
Filter flags (shared): `--tags`, `--type`, `--ids` (comma-separated), `--from-id`, `--to-id`, `--force`
Confirmation: `--yes` / `-y` to skip interactive prompt.
Without `--yes`, the CLI first shows the match count and asks for confirmation:
```
This will delete 47 documents matching: tags=[draft,old] type=note
Proceed? [y/N]
```
### 10. Engine config for safety threshold
New env var: `KB_BULK_SAFETY_PERCENT` (integer, default 70). Added to `engine/kb/config.py`.
## Risks / Trade-offs
- **[Bulk delete is irreversible]** → Safety threshold mitigates accidental mass deletion. CLI requires interactive confirmation. No undo mechanism — this is deliberate to keep the system simple.
- **[Naming collision: `tags` as filter vs operation]** → The `tags` parameter in bulk_tags selects documents, while `add`/`remove` specifies the tag changes. Clear naming and tool descriptions mitigate confusion. Engine request model uses the same field name as the existing list/search filter.
- **[SQLite lock during large bulk ops]** → A single transaction deleting 5000 documents will hold a write lock. With WAL mode, readers are not blocked. The lock duration should be under a few seconds for typical workloads.
- **[Breaking change: collection removal]** → Any MCP client relying on `collection` parameters will break. Since collections were only recently added and are not widely deployed, this is acceptable. Existing `collection:*` tags in the database remain as regular tags — they still work as filters, just without special treatment.
- **[Jobs table overload]** → Bulk operations add a new job type to a table designed for ingestion jobs. The schema change is minimal (one new column) and the audit trail value outweighs the mixing of concerns.
@@ -0,0 +1,91 @@
## Why
Bulk operations on documents (delete, tag, retag) currently require one API/MCP call per document. When an LLM manages hundreds or thousands of documents, this means hundreds of tool calls — burning tokens, adding latency, and creating fragile multi-step flows that can fail partway through.
Additionally, the "collection" abstraction in the MCP server adds complexity without real benefit. Collections are implemented as `collection:`-prefixed tags, but this convention is only enforced in the MCP layer — the CLI and engine don't know about it. This creates inconsistency and extra code. Tags alone, with a naming convention communicated via system prompt or configuration, achieve the same namespace isolation more simply and uniformly.
## What Changes
### 1. Remove collections from MCP server
Strip all collection logic from `mcp/server.py`:
- Remove `COLLECTION_TAG_PREFIX`, `DEFAULT_COLLECTION`, and all collection helper functions
- Remove `collection` parameter from `kb_search`, `kb_addnote`, `kb_upload_start`
- Remove `kb_set_collection` tool entirely
- Remove `_process_document` / `_process_search_results` collection-tag stripping
- Update MCP server instructions to explain tag-based namespace convention
### 2. Add bulk engine endpoints
Three new endpoints in the engine API:
- **POST /api/v1/bulk/delete** — Delete multiple documents matching a filter
- **POST /api/v1/bulk/tags** — Add/remove tags on multiple documents matching a filter
- **POST /api/v1/bulk/set-tags** — Replace all tags on multiple documents matching a filter
All accept a common **selection filter** (combinable with AND logic):
- `document_ids` — explicit list of IDs
- `tags` — documents matching ALL specified tags
- `doc_type` — documents of this type
- `from_id` / `to_id` — ID range (inclusive)
At least one selection criterion is required.
**Safety threshold**: If the operation would affect more than N% of all documents (default 70%, configurable via `KB_BULK_SAFETY_PERCENT` env var), the request is rejected with a 409 response showing what would be affected. The caller must re-send with `force: true` to proceed.
**Response model**: Synchronous execution with summary response. The operation is logged to the jobs table for audit trail:
```json
{
"job_id": 42,
"status": "done",
"matched": 750,
"succeeded": 748,
"failed": 2,
"errors": [
{"document_id": 42, "error": "file locked"},
{"document_id": 99, "error": "not found"}
]
}
```
### 3. Add bulk MCP tools
Expose the bulk engine endpoints as MCP tools:
- `kb_bulk_delete` — bulk delete with filter selection
- `kb_bulk_tags` — bulk add/remove tags with filter selection
- `kb_bulk_set_tags` — bulk replace tags with filter selection
These are thin wrappers around the engine bulk endpoints — no collection translation, no special logic.
### 4. Add bulk CLI commands
- `kb bulk-remove` — bulk delete with `--tags`, `--type`, `--ids`, `--from-id`, `--to-id`, `--force` flags
- `kb bulk-tag` — bulk tag/untag with `--add`, `--remove`, and the same filter flags
- `kb bulk-set-tags` — bulk replace tags with `--tags` (new tags) and the same filter flags
All show a confirmation prompt with match count before executing (unless `--yes`).
## Capabilities
### New Capabilities
- `bulk-operations`: Engine endpoints, MCP tools, and CLI commands for bulk delete, tag, and set-tags operations with filter-based selection and safety threshold.
### Modified Capabilities
- `mcp-document-management`: Remove `kb_set_collection` tool. Remove `collection` parameter from all tools.
### Removed Capabilities
- `mcp-collections`: The collection abstraction (collection helpers, collection parameters, collection tag stripping) is removed from the MCP server entirely.
## Impact
- **Engine API** (`engine/kb/routes/`): New `bulk.py` route module with 3 endpoints. New `bulk` job type in jobs table.
- **Engine database** (`engine/kb/database.py`): Helper functions for bulk selection queries and bulk delete/tag operations.
- **MCP server** (`mcp/server.py`): Remove ~70 lines of collection logic. Add 3 bulk tool definitions. Remove `collection` param from `kb_search`, `kb_addnote`, `kb_upload_start`. Remove `kb_set_collection`.
- **MCP engine client** (`mcp/engine.py`): Add bulk operation methods. Remove no longer needed code.
- **CLI** (`client/cmd/`): New `bulk_remove.go`, `bulk_tag.go`, `bulk_set_tags.go` command files.
- **CLI API client** (`client/internal/api/`): Add `Post` with JSON body support if not present.
- **Breaking changes**: `kb_set_collection` MCP tool removed. `collection` parameter removed from `kb_search`, `kb_addnote`, `kb_upload_start` MCP tools. Any MCP clients using collections will need to switch to tags.
@@ -0,0 +1,230 @@
## ADDED Requirements
### Requirement: Common selection filter
All bulk engine endpoints SHALL accept a JSON body with the following optional selection fields, combined with AND logic:
- `document_ids` (list of int) — match documents with these specific IDs
- `tags` (list of str) — match documents that have ALL specified tags
- `doc_type` (str) — match documents with this document type
- `from_id` (int) — match documents with id >= this value
- `to_id` (int) — match documents with id <= this value
At least one selection field MUST be present. If no selection fields are provided, the endpoint SHALL return 400 Bad Request.
#### Scenario: Filter by tags and doc_type
- **WHEN** a bulk endpoint receives `{"tags": ["draft"], "doc_type": "note"}`
- **THEN** it SHALL match only documents that have the tag "draft" AND have doc_type "note"
#### Scenario: Filter by ID range
- **WHEN** a bulk endpoint receives `{"from_id": 10, "to_id": 50}`
- **THEN** it SHALL match documents with id >= 10 AND id <= 50
#### Scenario: Filter by explicit IDs
- **WHEN** a bulk endpoint receives `{"document_ids": [1, 5, 12]}`
- **THEN** it SHALL match only documents with those specific IDs
#### Scenario: Combined filters
- **WHEN** a bulk endpoint receives `{"tags": ["agent:mybot"], "doc_type": "note", "from_id": 100}`
- **THEN** it SHALL match documents satisfying ALL three criteria
#### Scenario: No selection fields provided
- **WHEN** a bulk endpoint receives `{}` or `{"force": true}` with no selection fields
- **THEN** it SHALL return 400 Bad Request
### Requirement: Safety threshold
All bulk endpoints SHALL enforce a safety threshold. Before executing, the engine SHALL count the matched documents and the total documents in the database. If `matched / total * 100` exceeds the configured threshold, the request SHALL be rejected with 409 Conflict.
The response SHALL include: `error` ("safety_threshold_exceeded"), `message` (human-readable), `matched` (int), `total` (int), `percent` (float), and `threshold` (int).
The threshold SHALL default to 70 and be configurable via the `KB_BULK_SAFETY_PERCENT` environment variable (integer 0-100). A value of 0 disables the check.
The caller MAY override the threshold by including `"force": true` in the request body.
#### Scenario: Threshold exceeded
- **GIVEN** 1000 total documents and `KB_BULK_SAFETY_PERCENT` is 70
- **WHEN** a bulk endpoint matches 750 documents (75%) without `force: true`
- **THEN** it SHALL return 409 with `matched: 750`, `total: 1000`, `percent: 75.0`, `threshold: 70`
#### Scenario: Threshold not exceeded
- **GIVEN** 1000 total documents and `KB_BULK_SAFETY_PERCENT` is 70
- **WHEN** a bulk endpoint matches 500 documents (50%) without `force: true`
- **THEN** the operation SHALL proceed normally
#### Scenario: Force override
- **GIVEN** 1000 total documents and a match of 900 (90%)
- **WHEN** the request includes `"force": true`
- **THEN** the operation SHALL proceed regardless of threshold
#### Scenario: Zero threshold
- **GIVEN** `KB_BULK_SAFETY_PERCENT` is 0
- **THEN** the safety check SHALL be effectively disabled for all operations
### Requirement: Synchronous response with audit log
All bulk endpoints SHALL execute synchronously and return a JSON response with:
- `job_id` (int) — ID of the audit log entry in the jobs table
- `status` (str) — "done" or "partial_failure"
- `matched` (int) — number of documents that matched the selection
- `succeeded` (int) — number of documents successfully processed
- `failed` (int) — number of documents that failed
- `errors` (list) — array of `{"document_id": int, "error": str}` for each failure (empty on full success)
A job record SHALL be created in the jobs table with `job_type` set to the operation type. The `filename` field SHALL store a JSON representation of the selection filter. The `error` field SHALL store a JSON array of individual errors if any occurred.
#### Scenario: Full success
- **WHEN** a bulk operation matches 50 documents and all succeed
- **THEN** the response SHALL have `status: "done"`, `matched: 50`, `succeeded: 50`, `failed: 0`, `errors: []`
#### Scenario: Partial failure
- **WHEN** a bulk operation matches 50 documents but 2 fail
- **THEN** the response SHALL have `status: "partial_failure"`, `matched: 50`, `succeeded: 48`, `failed: 2`, and `errors` listing the 2 failures
### Requirement: Bulk delete endpoint
The engine SHALL expose `POST /api/v1/bulk/delete` which permanently deletes all documents matching the selection filter. For each matched document, it SHALL delete embeddings from `chunks_vec`, delete the document row (cascading to chunks and document_tags), and delete any stored file from disk.
Database deletions SHALL be performed within a single transaction. File deletions SHALL occur after the transaction commits and SHALL be best-effort (failures logged but not counted as document failures).
#### Scenario: Bulk delete by tag
- **WHEN** `POST /api/v1/bulk/delete` receives `{"tags": ["old", "draft"]}`
- **THEN** all documents with both tags "old" and "draft" SHALL be deleted
- **AND** their chunks, embeddings, tag associations, and stored files SHALL be removed
#### Scenario: Bulk delete with no matches
- **WHEN** `POST /api/v1/bulk/delete` receives a filter that matches 0 documents
- **THEN** the response SHALL have `matched: 0`, `succeeded: 0`, `failed: 0`
### Requirement: Bulk tags endpoint
The engine SHALL expose `POST /api/v1/bulk/tags` which adds and/or removes tags on all documents matching the selection filter. The request body SHALL include the selection filter plus:
- `add` (list of str, optional) — tags to add
- `remove` (list of str, optional) — tags to remove
At least one of `add` or `remove` MUST be present. The endpoint SHALL return 400 if neither is provided.
The endpoint SHALL update `updated_at` on all affected documents.
#### Scenario: Add and remove tags in one call
- **WHEN** `POST /api/v1/bulk/tags` receives `{"tags": ["agent:mybot"], "add": ["reviewed"], "remove": ["pending"]}`
- **THEN** all documents tagged "agent:mybot" SHALL have "reviewed" added and "pending" removed
### Requirement: Bulk set-tags endpoint
The engine SHALL expose `POST /api/v1/bulk/set-tags` which replaces all tags on matched documents with a new set. The request body SHALL include the selection filter plus:
- `new_tags` (list of str) — the replacement tag set
The endpoint SHALL remove all existing tag associations from matched documents, then apply the new set. It SHALL update `updated_at` on all affected documents.
#### Scenario: Replace all tags
- **WHEN** `POST /api/v1/bulk/set-tags` receives `{"doc_type": "note", "new_tags": ["clean", "final"]}`
- **THEN** all notes SHALL have their existing tags removed and replaced with "clean" and "final"
### Requirement: Jobs table extension
The jobs table SHALL be extended with a `job_type` column (TEXT, default "ingest") to distinguish ingestion jobs from bulk operation audit entries. Valid values: "ingest", "bulk_delete", "bulk_tags", "bulk_set_tags".
Existing jobs SHALL default to `job_type = "ingest"`. The existing jobs list endpoint and CLI `kb jobs` command SHALL continue to work unchanged.
#### Scenario: Migration adds column
- **GIVEN** an existing database without the `job_type` column
- **WHEN** the engine starts
- **THEN** the column SHALL be added with default value "ingest"
### Requirement: Engine config for safety threshold
The engine `Config` class SHALL read `KB_BULK_SAFETY_PERCENT` from the environment as an integer (default 70, range 0-100). This value SHALL be used as the default safety threshold for all bulk endpoints.
### Requirement: MCP bulk delete tool
The MCP server SHALL expose a `kb_bulk_delete` tool with parameters: `document_ids` (optional list of int), `tags` (optional list of str), `doc_type` (optional str), `from_id` (optional int), `to_id` (optional int), `force` (optional bool).
The tool SHALL call `POST /api/v1/bulk/delete` on the engine via the engine client and return the JSON response.
The tool description SHALL clearly state that `tags` is a selection filter (which documents to delete), not tags to delete.
#### Scenario: MCP bulk delete by tag
- **WHEN** `kb_bulk_delete(tags=["old"])` is called
- **THEN** the engine client SHALL send `POST /api/v1/bulk/delete` with `{"tags": ["old"]}`
- **AND** the tool SHALL return the engine's JSON response
### Requirement: MCP bulk tags tool
The MCP server SHALL expose a `kb_bulk_tags` tool with parameters: `document_ids`, `tags`, `doc_type`, `from_id`, `to_id` (selection filters), plus `add` (optional list of str), `remove` (optional list of str), and `force` (optional bool).
The tool description SHALL clearly distinguish `tags` (selection filter) from `add`/`remove` (tag changes to apply).
#### Scenario: MCP bulk tag update
- **WHEN** `kb_bulk_tags(tags=["agent:mybot"], add=["reviewed"], remove=["draft"])` is called
- **THEN** the engine client SHALL send the appropriate `POST /api/v1/bulk/tags` request
### Requirement: MCP bulk set-tags tool
The MCP server SHALL expose a `kb_bulk_set_tags` tool with parameters: `document_ids`, `tags`, `doc_type`, `from_id`, `to_id` (selection filters), plus `new_tags` (list of str) and `force` (optional bool).
#### Scenario: MCP bulk set tags
- **WHEN** `kb_bulk_set_tags(doc_type="note", new_tags=["clean"])` is called
- **THEN** the engine client SHALL send `POST /api/v1/bulk/set-tags` with `{"doc_type": "note", "new_tags": ["clean"]}`
### Requirement: MCP engine client bulk methods
The MCP engine client (`mcp/engine.py`) SHALL provide three new methods:
- `bulk_delete(document_ids?, tags?, doc_type?, from_id?, to_id?, force?)` → dict
- `bulk_tags(document_ids?, tags?, doc_type?, from_id?, to_id?, add?, remove?, force?)` → dict
- `bulk_set_tags(document_ids?, tags?, doc_type?, from_id?, to_id?, new_tags?, force?)` → dict
Each SHALL send a POST request to the corresponding `/api/v1/bulk/*` endpoint with the parameters as a JSON body. Each SHALL raise on non-2xx status codes, consistent with existing methods.
### Requirement: CLI bulk-remove command
The CLI SHALL expose a `kb bulk-remove` command with flags: `--tags` (comma-separated), `--type`, `--ids` (comma-separated), `--from-id`, `--to-id`, `--force`/`-f`, `--yes`/`-y`.
Without `--yes`, the CLI SHALL first display the match count and ask for interactive confirmation before proceeding.
The command SHALL call `POST /api/v1/bulk/delete` with the constructed filter.
#### Scenario: CLI bulk remove with confirmation
- **WHEN** `kb bulk-remove --tags "draft,old" --type note` is run without `--yes`
- **THEN** the CLI SHALL display "This will delete N documents matching: tags=[draft,old] type=note" and prompt "Proceed? [y/N]"
#### Scenario: CLI bulk remove with --yes
- **WHEN** `kb bulk-remove --tags "draft" --yes` is run
- **THEN** the CLI SHALL proceed without prompting
### Requirement: CLI bulk-tag command
The CLI SHALL expose a `kb bulk-tag` command with the same filter flags as `bulk-remove`, plus `--add` and `--remove` (comma-separated tag lists).
The command SHALL call `POST /api/v1/bulk/tags` with the constructed filter and tag changes.
### Requirement: CLI bulk-set-tags command
The CLI SHALL expose a `kb bulk-set-tags` command with the filter flags, plus `--set` (comma-separated list of replacement tags).
The command SHALL call `POST /api/v1/bulk/set-tags` with the constructed filter and `new_tags`.
@@ -0,0 +1,55 @@
## REMOVED Requirements
### Requirement: Collection abstraction in MCP server
The MCP server SHALL NOT maintain any collection abstraction. The following SHALL be removed:
- Constants: `COLLECTION_TAG_PREFIX`, `DEFAULT_COLLECTION`
- Functions: `_collection_tag`, `_strip_collection_tags`, `_process_document`, `_process_search_results`, `_ensure_exclusive_collection`
- Tool: `kb_set_collection` (entire tool)
- Parameters: `collection` from `kb_search`, `kb_addnote`, `kb_upload_start`
Documents SHALL be returned as-is from the engine with all tags visible. No tag stripping or collection field injection SHALL occur.
#### Scenario: Search results show all tags
- **WHEN** `kb_search` is called and a result has tags `["agent:mybot", "collection:documents", "draft"]`
- **THEN** all three tags SHALL be returned as-is — no stripping of `collection:*` tags
#### Scenario: kb_set_collection no longer exists
- **WHEN** an MCP client attempts to call `kb_set_collection`
- **THEN** the tool SHALL not be found (removed)
## MODIFIED Requirements
### Requirement: kb_search without collection parameter
The `kb_search` MCP tool SHALL accept `tags` (optional list of str) for filtering but SHALL NOT accept a `collection` parameter. Callers that previously used `collection="memory"` SHALL instead use `tags=["collection:memory"]` or whatever tag convention they prefer.
#### Scenario: Filter by tag instead of collection
- **WHEN** `kb_search(query="test", tags=["agent:mybot"])` is called
- **THEN** results SHALL be filtered to documents tagged "agent:mybot"
- **AND** no collection field SHALL be present in the response
### Requirement: kb_addnote without collection parameter
The `kb_addnote` MCP tool SHALL accept `tags` (optional list of str) but SHALL NOT accept a `collection` parameter. The tool SHALL NOT automatically apply any default collection tag — only explicitly provided tags are applied.
#### Scenario: Add note with explicit tags
- **WHEN** `kb_addnote(text="hello", tags=["agent:mybot", "memory"])` is called
- **THEN** the note SHALL be created with exactly those two tags — no `collection:documents` tag added
### Requirement: kb_upload_start without collection parameter
The `kb_upload_start` MCP tool SHALL accept `tags` (optional list of str) but SHALL NOT accept a `collection` parameter. The tool SHALL NOT automatically apply any default collection tag.
### Requirement: kb_update_note without collection processing
The `kb_update_note` MCP tool SHALL return the document as-is from the engine without passing it through `_process_document`. All tags SHALL be visible in the response.
### Requirement: kb_get without collection processing
The `kb_get` MCP tool SHALL return documents as-is from the engine without passing through `_process_document`. All tags SHALL be visible in the response. No `collection` field SHALL be injected.
@@ -0,0 +1,45 @@
## 1. Remove collections from MCP server
- [x] 1.1 Remove collection constants and helper functions from `mcp/server.py` (`COLLECTION_TAG_PREFIX`, `DEFAULT_COLLECTION`, `_collection_tag`, `_strip_collection_tags`, `_process_document`, `_process_search_results`, `_ensure_exclusive_collection`)
- [x] 1.2 Remove `collection` parameter from `kb_search`, `kb_addnote`, `kb_upload_start` tools
- [x] 1.3 Remove `kb_set_collection` tool entirely
- [x] 1.4 Remove `_process_document` / `_process_search_results` calls from `kb_get`, `kb_update_note`, `kb_search`
- [x] 1.5 Update MCP server instructions text to reflect tags-only approach
## 2. Engine bulk infrastructure
- [x] 2.1 Add `bulk_safety_percent` to `Config` class in `engine/kb/config.py` (env var `KB_BULK_SAFETY_PERCENT`, default 70)
- [x] 2.2 Add `job_type` column migration to `database.py` `init_schema` (TEXT, default "ingest")
- [x] 2.3 Add `resolve_bulk_selection(conn, document_ids, tags, doc_type, from_id, to_id)` helper to `database.py` — returns list of matching document IDs
- [x] 2.4 Add `create_bulk_job(conn, job_type, filters_json, matched, succeeded, failed, errors_json)` helper to `database.py`
## 3. Engine bulk endpoints
- [x] 3.1 Create `engine/kb/routes/bulk.py` with shared Pydantic request model (`BulkSelectionRequest` with selection fields + `force` bool)
- [x] 3.2 Add `_check_safety_threshold` helper that returns 409 if threshold exceeded
- [x] 3.3 Implement `POST /api/v1/bulk/delete` — resolve selection, check threshold, delete documents in transaction, clean up files, log job, return summary
- [x] 3.4 Implement `POST /api/v1/bulk/tags` — resolve selection, check threshold, add/remove tags on matched docs, log job, return summary
- [x] 3.5 Implement `POST /api/v1/bulk/set-tags` — resolve selection, check threshold, clear and replace tags on matched docs, log job, return summary
- [x] 3.6 Import bulk routes in engine app startup (add to `engine/kb/routes/__init__.py` or `main.py`)
## 4. MCP bulk tools
- [x] 4.1 Add `bulk_delete`, `bulk_tags`, `bulk_set_tags` methods to `mcp/engine.py`
- [x] 4.2 Add `kb_bulk_delete` tool to `mcp/server.py`
- [x] 4.3 Add `kb_bulk_tags` tool to `mcp/server.py`
- [x] 4.4 Add `kb_bulk_set_tags` tool to `mcp/server.py`
## 5. CLI bulk commands
- [x] 5.1 Create `client/cmd/bulk_remove.go``kb bulk-remove` with filter flags, confirmation prompt, JSON output support
- [x] 5.2 Create `client/cmd/bulk_tag.go``kb bulk-tag` with filter flags + `--add`/`--remove`, confirmation prompt
- [x] 5.3 Create `client/cmd/bulk_set_tags.go``kb bulk-set-tags` with filter flags + `--set`, confirmation prompt
## 6. Verification
- [x] 6.1 Test collection removal: verify `kb_search`, `kb_addnote`, `kb_get`, `kb_update_note`, `kb_upload_start` work without collection params
- [x] 6.2 Test bulk delete via engine API: filter by tags, by IDs, by range, safety threshold trigger and force override
- [x] 6.3 Test bulk tags and bulk set-tags via engine API
- [x] 6.4 Test MCP bulk tools against running engine
- [x] 6.5 Test CLI bulk commands against running engine
- [x] 6.6 Test audit trail: verify bulk jobs appear in `kb jobs` output
@@ -0,0 +1,145 @@
## Context
kb v2 is a client-server knowledge base: a Python FastAPI engine (SQLite + FTS5 + sqlite-vec, sentence-transformers embeddings) serving a Go CLI client over HTTP. Agent integration currently works via a Claude Code skill that shells out to the Go binary and parses JSON output.
The engine runs in Docker (NVIDIA/ROCm/CPU variants), keeps the embedding model warm in memory, and handles async ingestion via a background worker. The data model has documents, chunks, embeddings, tags, and jobs — but no concept of collections or note mutation.
This design covers three changes: adding an MCP server as a new integration surface, adding collection-scoped search via tag conventions, and adding in-place note updates.
## Goals / Non-Goals
**Goals:**
- Expose kb as native MCP tools so agents interact with it directly, not via shell subprocess
- Separate agent memory from user documents via collection tags
- Allow notes to be updated in place, preserving document identity
- Support file upload from remote agents via the MCP server
- Keep the engine fully local — no cloud API dependencies
- Maintain backward compatibility: existing CLI, API, and data all continue to work
**Non-Goals:**
- Query expansion or LLM reranking inside the engine (agent-side responsibility)
- File-watching / inotify for auto-reindexing (useful but separate concern)
- Collection-level access control or permissions
- New schema columns for collections (use existing tags)
- Stdio MCP transport (Streamable HTTP only)
## Decisions
### D1 — MCP server as a separate container, Streamable HTTP transport, with its own auth
The MCP server runs as its own Docker container alongside the engine, exposed via Streamable HTTP. It is not embedded into the FastAPI engine app. It requires its own Bearer token (`KB_MCP_API_KEY`) from calling agents.
**Why:** The engine and MCP server have different concerns — the engine manages embeddings, search, and ingestion; the MCP server translates MCP protocol to engine API calls. Keeping them separate means either can be updated independently. Both run as long-lived containers in a Docker Compose stack.
Streamable HTTP (not stdio) because the MCP server is a network service that remote agents connect to, not a subprocess spawned by a local agent. This matches the deployment model: engine + MCP server run on an infrastructure host, agents connect over the network.
The MCP server must have its own authentication because it is HTTP-exposed. Without it, anyone who discovers the endpoint has a direct pipe to the engine via `KB_API_KEY`. The MCP server validates the agent's Bearer token (`KB_MCP_API_KEY`) before proxying requests to the engine.
**Alternative considered:** Embedding MCP into the FastAPI app as additional routes. Rejected — it couples the MCP SDK lifecycle to the engine, and the engine shouldn't need to know about MCP protocol details. Also considered stdio transport, rejected because it requires the agent and MCP server to share a host. Also considered relying solely on the engine's `KB_API_KEY` for auth. Rejected — the MCP server is a separate network surface and must authenticate its own callers.
**Implementation:** Separate Python package/directory (`mcp/` at repo root). Uses the `mcp` Python SDK with Streamable HTTP transport. Reads engine URL and engine API key from environment variables (`KB_ENGINE_URL`, `KB_API_KEY`). Reads its own auth token from `KB_MCP_API_KEY`. Makes HTTP calls to the engine using `httpx`. Docker Compose file adds the MCP server as a service alongside the engine.
### D2 — Collections via tag conventions, with MCP-enforced exclusivity
Collections are implemented using the existing tag system with a naming convention: `collection:documents`, `collection:memory`, `collection:workspace`.
**Why:** Tags already exist, already filter search, and are already mutable via the API. A dedicated `collection` column would add a schema migration, new API parameters, and new CLI flags — all duplicating what tags can do.
**Exclusive membership:** The MCP server enforces one collection per document. When adding a document to a collection, the MCP server first removes any existing `collection:*` tags via the engine's tag API, then applies the new one. This prevents a document from appearing in multiple collections and keeps search results clean.
**Tag stripping in MCP responses:** The MCP server strips `collection:*` tags from the `tags` array in search results and presents the collection as a separate `collection` field. Agents see a clean interface: `{"collection": "memory", "tags": ["feedback", "email"]}` rather than raw `collection:memory` mixed in with user tags.
**Implementation:** The MCP tools accept a `collection` parameter (e.g. `"memory"`). The MCP server translates this to tag operations:
- On search: adds `collection:<name>` to the tag filter
- On addnote/addfile: removes any existing `collection:*` tags, then applies `collection:<name>`
- On results: strips `collection:*` from tags, adds a `collection` field
The engine is unchanged. The Go CLI can use the same convention manually via `--tags collection:memory`.
**Convention:** `collection:documents` is the default. Standard names: `documents`, `memory`, `workspace`. The MCP tool descriptions document these.
### D3 — Note mutation via dedicated PATCH endpoint, with full chunking support
Note updates go through a new synchronous `PATCH /api/v1/notes/{id}` endpoint, not through the async job queue. The endpoint uses the same chunking logic as the ingestion pipeline, not a hardcoded single-chunk assumption.
**Why:** Most notes are short and produce a single chunk. But if an agent updates a note with text that exceeds the embedding model's token window (~256 tokens for MiniLM), a single-chunk approach would silently embed only a portion of the text. Using the standard note chunking pipeline (which today produces one chunk for typical notes) means the endpoint naturally handles longer notes without silent data loss.
**Alternative considered:** Truncating long notes and returning a warning. Rejected — silent data loss or warnings that the agent might ignore are worse than just doing the right thing. Also considered reusing the job queue for consistency. Rejected — the queue's value is async processing of heavy workloads. Notes don't need it.
**Implementation:** The PATCH endpoint:
1. Validates the document exists and is `doc_type = 'note'`
2. Deletes existing chunks, FTS entries, and vector embeddings for that document
3. Runs the new text through the note chunking pipeline (same as ingestion)
4. Embeds each chunk and inserts into chunks_vec
5. Updates the document's `content_hash` and `updated_at`
6. Returns the updated document
All within a single transaction. FTS5 triggers keep the full-text index in sync automatically (existing `chunks_au` and `chunks_ad` triggers handle this). If embedding fails, the transaction rolls back and the old note is preserved.
### D4 — `updated_at` column on documents, set only on mutation
A new `updated_at TEXT` column on `documents`, initially NULL for all existing documents. Set to `current_timestamp` only when a document is modified (note update, tag change).
**Why:** Distinguishes "created" from "last modified". The agent memory use case needs to know when a memory was last updated, not just when it was first created. NULL means "never updated" — cleaner than duplicating `created_at`.
**Date sorting:** Any query that sorts or filters by "most recent" must use `COALESCE(updated_at, created_at)` to ensure un-mutated documents don't disappear from recent lists. This applies to the documents list endpoint and any future "recent" views.
### D5 — File upload via chunked base64, proxied to engine's existing upload API
The MCP server supports file uploads from remote agents using a three-step chunked upload pattern:
1. `kb_upload_start(filename, total_size, tags, collection)` — creates a temporary staging entry on the MCP server, returns a server-generated UUID `upload_id`
2. `kb_upload_chunk(upload_id, data, chunk_index)` — appends a base64-encoded chunk to the staging entry. Called N times.
3. `kb_upload_finish(upload_id)` — reassembles chunks, decodes from base64, and forwards the complete file as a multipart upload to the engine's existing `POST /api/v1/jobs` endpoint. Returns the job ID.
**Why:** The MCP server is remote from the calling agent, so file paths are meaningless. The agent reads the file locally, splits it into chunks, base64-encodes each chunk, and sends them as individual tool calls. No single MCP message needs to carry the entire file, avoiding message size limits regardless of file size.
The engine's existing upload pipeline handles everything from there: staging, type detection, chunking, embedding. No new engine code needed for file transfer.
**Alternative considered:** Single-message base64 upload (`kb_addfile` with full file content). Rejected — works for small files but hits practical MCP message size limits on larger PDFs. Also considered a separate file transfer service (SFTP container). Rejected — adds operational complexity for no benefit over the chunked approach. Also considered a plain HTTP upload endpoint on the MCP server. Rejected — adds a second protocol surface the agent needs to interact with. Also considered a single-call shortcut for small files. Rejected — one path for all files is simpler for agents to learn, and the overhead of 3 calls vs 1 is negligible for an LLM.
**Upload ID:** Server-generated UUID, returned by `kb_upload_start`. Prevents collision and is unpredictable (important since the MCP server is network-exposed).
**Chunk size:** Recommended 1MB raw (before base64 encoding, ~1.33MB encoded) per chunk. A 10MB PDF = ~10 tool calls. The MCP server holds chunks in a temporary directory, cleans up on finish or after a timeout (e.g. 10 minutes for abandoned uploads).
**Staging cleanup:** The MCP server tracks active uploads in memory. Chunks are written to a temporary directory. On `kb_upload_finish`, chunks are assembled and forwarded. On timeout or error, the temporary files are cleaned up. No persistent state needed — abandoned uploads are simply garbage collected. The temp directory does not need to survive container restarts; if the MCP server restarts mid-upload, the agent retries from `kb_upload_start`.
### D6 — MCP tool descriptions include agent-side search patterns
The MCP tool descriptions for `kb_search` include guidance on query expansion and reranking as documented patterns, not as engine parameters.
**Why:** The calling agent has an LLM. Expanding queries (call search N times with variant phrasings, merge results) and reranking (read top results, reorder by relevance) are better done in the agent's context. This keeps the engine deterministic and local.
**Implementation:** The `kb_search` tool description includes a note like: *"For complex queries, consider expanding into 2-3 variant phrasings and calling this tool multiple times, then deduplicating results by chunk_id. For precision, rerank the returned results using your own judgement."*
### D7 — Version bump to 3.0.0 for both engine and client
Engine and client both bump to v3.0.0. MIN_ENGINE_VERSION updates to v3.0.0.
**Why:** The `updated_at` column is a schema addition and the new `PATCH /api/v1/notes/{id}` endpoint is a new API surface. The new client command (`updatenote`) requires the new engine. A major version bump signals this clearly. The clean break is worth it given the MCP server is a new integration paradigm.
## Risks / Trade-offs
**MCP SDK maturity** — The `mcp` Python SDK is relatively new. Breaking changes in the SDK could require MCP server updates. Mitigation: the MCP server is a thin adapter, so updating it is low cost. Pin the SDK version.
**Tag convention enforcement** — Collection tags are a convention, not a constraint at the engine level. Typos create new collections silently (e.g. `collection:memeory`). Mitigation: the MCP server enforces exclusivity (removes old `collection:*` tags before applying new) and validates collection names against a known list. The Go CLI does not enforce this — it's a convention for manual users. Direct engine API users can still create arbitrary tags.
**Note mutation with long text** — The PATCH endpoint uses the standard note chunking pipeline, so long notes are chunked correctly. However, a note that grows very large (thousands of tokens) will produce many chunks and embeddings, making the synchronous PATCH slower. Mitigation: for the agent memory use case, notes are typically short. If a note grows large enough for this to matter, the agent should consider splitting it into multiple notes.
**Chunked upload complexity** — The three-step upload pattern (start/chunk/finish) is more complex than a single tool call. An agent must make N+2 calls to upload a file. Mitigation: the pattern is deterministic and easily scripted by agents. The MCP tool descriptions will include a clear usage example. Abandoned uploads (agent crashes mid-upload) are cleaned up by a timeout on the MCP server — no permanent state leaks.
**MCP server as HTTP client** — The MCP server calls the engine over HTTP, adding a network hop. For a compose deployment (both containers on the same Docker network) this adds sub-millisecond latency per call. Acceptable.
## Migration Plan
1. **Engine schema migration** — runs automatically on startup (same pattern as existing migrations in `init_schema`):
- `ALTER TABLE documents ADD COLUMN updated_at TEXT`
2. **New engine endpoint**`PATCH /api/v1/notes/{id}` for note mutation
3. **Engine version bump** — update `engine/VERSION` to `3.0.0`
4. **Client updates** — new `updatenote` command, version bump to `3.0.0`, `MIN_ENGINE_VERSION` to `3.0.0`
5. **MCP server** — new `mcp/` directory, Dockerfile, added to Docker Compose
6. **Rollback** — the schema change is additive (one new column). Rolling back to v2 engine code works fine — v2 ignores `updated_at`. Rolling back the client is a binary swap. Removing the MCP server container has no effect on engine or CLI.
@@ -0,0 +1,81 @@
## Why
The kb engine exposes a well-structured REST API, but agent integration today goes through a Claude Code skill that shells out to the Go CLI binary, parses JSON output, and re-synthesises results. This works but is indirect: subprocess overhead on every call, fragile output parsing, no streaming, and no composability with other MCP tools in the same session. As agents increasingly rely on kb for both document retrieval and memory storage, this friction compounds.
At the same time, there is no way to scope searches to "agent memory" vs "user documents" without careful manual tagging, and no way to update an existing note in place without delete + re-add. These gaps cause agents to accumulate stale duplicates and pollute the user's document index with internal memory notes.
kb v3 adds an MCP server as a new integration surface alongside the existing CLI, establishes collection tag conventions for scoped search, and adds note mutation to support the agent memory use case natively.
## What Changes
### 1. MCP Server (new component)
A Model Context Protocol server that exposes kb operations as native MCP tools. Runs as a separate Docker container alongside the engine, using Streamable HTTP transport. Translates MCP tool calls into engine HTTP API calls.
**MCP tool surface:**
| MCP Tool | Maps to Engine API | Notes |
|---|---|---|
| `kb_search` | `POST /api/v1/search` | Query, top_n, tags, doc_type, collection, mode |
| `kb_addnote` | `POST /api/v1/jobs` | Body text, tags, collection (default: `documents`) |
| `kb_upload_start` | _(MCP server internal)_ | Start chunked upload: filename, size, tags, collection → returns upload_id |
| `kb_upload_chunk` | _(MCP server internal)_ | Append base64 chunk to staging: upload_id, data, chunk_index |
| `kb_upload_finish` | `POST /api/v1/jobs` | Reassemble chunks, decode, proxy as multipart upload → returns job_id |
| `kb_update_note` | `PATCH /api/v1/notes/{id}` | Replace note text, re-chunk and re-embed in place |
| `kb_get` | `GET /api/v1/documents` | Retrieve by document ID or source_path |
| `kb_status` | `GET /api/v1/status` | Index health, doc counts, model info, queue state |
| `kb_jobs` | `GET /api/v1/jobs` | Check ingestion queue status |
The `collection` parameter on search/addnote/addfile is translated by the MCP server into tag filters using the convention `collection:<name>` (e.g. `collection:memory`). No engine changes required for collections.
### 2. Collection Tag Conventions (no engine changes)
Scoped document organisation using existing tags with a naming convention.
- Convention: `collection:documents` (default), `collection:memory`, `collection:workspace`
- MCP tools accept a `collection` parameter and translate to tag operations
- The Go CLI can use the same convention via `--tags collection:memory`
- No new schema, no new API parameters on the engine — uses existing tag infrastructure
### 3. Note Mutation (engine extension)
Allow existing notes to be updated in place without delete + re-add.
- `PATCH /api/v1/notes/{id}` endpoint — accepts new text, re-chunks and re-embeds
- Preserves original `created_at`, updates `updated_at`
- `kb updatenote <id> "new text"` CLI command
- `kb_update_note` MCP tool
### 4. Agent-Side Search Patterns (no engine changes)
Query expansion and reranking are **caller responsibilities**, not engine features. The calling agent already has an LLM — adding one inside the engine would duplicate capability, introduce a cloud API dependency into a fully local system, and complicate testing.
**Query expansion** — the agent expands its query into 2-3 variant phrasings, makes multiple `kb_search` calls, and merges/deduplicates results in its own context. The MCP tool descriptions should document this as a recommended pattern for complex natural-language questions.
**Reranking** — the agent reads the top N search results and applies its own judgement to reorder by relevance. This is what agents already do when synthesising answers from retrieved chunks.
These patterns should be documented in the MCP tool descriptions and the kb skill guidance, not implemented as engine features.
## Capabilities
### New Capabilities
- `mcp-server`: MCP protocol server exposing kb tools (search, addnote, chunked file upload, update_note, get, status, jobs) for native agent integration. Runs as a Docker container with Streamable HTTP transport. Calls engine HTTP API internally. File uploads use a three-step chunked pattern (start → chunk × N → finish) to avoid message size limits, then proxy to the engine's existing upload endpoint.
- `note-mutation`: In-place update of existing notes. New PATCH endpoint re-chunks and re-embeds while preserving document identity and creation timestamp.
- `agent-search-patterns`: Documented patterns for agent-side query expansion (multi-query + merge) and reranking (LLM-based result reordering). No engine changes — these are caller responsibilities, documented in MCP tool descriptions and skill guidance.
### Modified Capabilities
- `engine-api`: New endpoint for note mutation (`PATCH /api/v1/notes/{id}`). `documents` table gains `updated_at` column.
- `go-client`: New `updatenote` command.
## Impact
- **Code — new**: `mcp/` directory — MCP server package. Thin adapter translating MCP tool calls to engine HTTP API calls, with base64 file upload decoding.
- **Code — engine**: `kb/database.py` — add `updated_at` column, migration logic. New `kb/routes/notes.py` for PATCH endpoint.
- **Code — client**: New `cmd/updatenote.go`. `internal/api/client.go` for new endpoint.
- **APIs**: New `PATCH /api/v1/notes/{id}`.
- **Dependencies**: MCP Python SDK (`mcp` package) and `httpx` for the MCP server.
- **Systems**: MCP server added to Docker Compose stack. Agents connect to it via Streamable HTTP.
- **Data**: SQLite schema migration — `updated_at TEXT` column on `documents` table. Non-destructive.
- **Versioning**: Engine bumps to v3.0.0 (new endpoint + schema). Client bumps to v3.0.0 (new command). MIN_ENGINE_VERSION updated to v3.0.0.
@@ -0,0 +1,35 @@
# Agent-Side Search Patterns
## Purpose
Documents recommended patterns for agent-side query expansion and reranking, which are caller responsibilities rather than engine features. These patterns are communicated via MCP tool descriptions.
## Requirements
### Requirement: Query expansion guidance in tool description
The `kb_search` MCP tool description SHALL include guidance on query expansion as a recommended pattern for complex queries.
#### Scenario: Tool description includes expansion pattern
- **WHEN** an agent reads the `kb_search` tool description
- **THEN** the description SHALL include guidance such as: "For complex queries, consider expanding into 2-3 variant phrasings and calling this tool multiple times, then deduplicating results by chunk_id"
---
### Requirement: Reranking guidance in tool description
The `kb_search` MCP tool description SHALL include guidance on agent-side reranking as a recommended pattern for improving precision.
#### Scenario: Tool description includes reranking pattern
- **WHEN** an agent reads the `kb_search` tool description
- **THEN** the description SHALL include guidance such as: "For precision, rerank the returned results using your own judgement based on relevance to the original question"
---
### Requirement: No engine-side LLM dependency
The engine SHALL NOT require or use any external LLM API for search operations. Query expansion and reranking SHALL remain entirely agent-side concerns.
#### Scenario: Engine has no LLM dependency
- **WHEN** the engine is deployed without any `ANTHROPIC_API_KEY` or similar LLM API configuration
- **THEN** all search operations SHALL function fully, with no degraded results or missing features
@@ -0,0 +1,79 @@
# Engine API (Delta)
## ADDED Requirements
### Requirement: Note mutation endpoint
The engine SHALL provide a `PATCH /api/v1/notes/{id}` endpoint for updating existing notes in place. See the `note-mutation` spec for full details.
#### Scenario: Note update endpoint exists
- **WHEN** a client sends `PATCH /api/v1/notes/42` with body `{"text": "new content"}`
- **THEN** the engine SHALL process the update synchronously and return the updated document
---
### Requirement: Document updated_at tracking
The engine SHALL track when documents are modified via an `updated_at` column. This column SHALL be NULL for documents that have never been updated.
#### Scenario: New document has no updated_at
- **WHEN** a document is first ingested
- **THEN** `updated_at` SHALL be NULL and `created_at` SHALL be set to the ingestion timestamp
#### Scenario: Note update sets updated_at
- **WHEN** a note is updated via `PATCH /api/v1/notes/{id}`
- **THEN** `updated_at` SHALL be set to the current timestamp
#### Scenario: Tag change sets updated_at
- **WHEN** tags are modified via `PUT /api/v1/documents/{id}/tags`
- **THEN** `updated_at` SHALL be set to the current timestamp
#### Scenario: Schema migration for updated_at
- **WHEN** the engine starts against a v2 database without an `updated_at` column
- **THEN** the engine SHALL automatically add `ALTER TABLE documents ADD COLUMN updated_at TEXT` and all existing documents SHALL have `updated_at = NULL`
## MODIFIED Requirements
### Requirement: Document management
The engine SHALL provide endpoints to list, inspect, remove, and download original files for ingested documents.
#### Scenario: List documents
- **WHEN** a client sends `GET /api/v1/documents`
- **THEN** the engine SHALL return a JSON array of documents with id, title, doc_type, tags, chunk_count, created_at, and updated_at
#### Scenario: List documents with filters
- **WHEN** a client sends `GET /api/v1/documents?type=pdf&tags=manual`
- **THEN** the engine SHALL return only documents matching all specified filters
#### Scenario: List documents sorted by most recent
- **WHEN** a client requests documents sorted by date
- **THEN** the engine SHALL use `COALESCE(updated_at, created_at)` for ordering, so un-mutated documents sort by creation time and mutated documents sort by their last update
#### Scenario: Get document details
- **WHEN** a client sends `GET /api/v1/documents/{id}`
- **THEN** the engine SHALL return the full document record including all chunks, their text content, `updated_at`, and whether the original file is available (`has_file: true/false`)
#### Scenario: Download original file
- **WHEN** a client sends `GET /api/v1/documents/{id}/file`
- **THEN** the engine SHALL return the original file with appropriate Content-Type and `Content-Disposition: attachment; filename="{original_filename}"` headers, or HTTP 404 if the file is not available
#### Scenario: Remove a document
- **WHEN** a client sends `DELETE /api/v1/documents/{id}`
- **THEN** the engine SHALL delete the document, all its chunks, associated embeddings, tag associations, and the stored original file from disk, and return HTTP 200 with a confirmation
#### Scenario: Remove non-existent document
- **WHEN** a client sends `DELETE /api/v1/documents/{id}` with a non-existent ID
- **THEN** the engine SHALL return HTTP 404
### Requirement: Engine status and reindex
The engine SHALL provide status information and support re-embedding all chunks. The `version` field in the status response SHALL always be present and SHALL reflect the engine's release version as read from the `VERSION` file. This field is the contract used by clients for compatibility checking.
#### Scenario: Get engine status
- **WHEN** a client sends `GET /api/v1/status`
- **THEN** the engine SHALL return JSON with `version` (string, from VERSION file), model_name, embedding_dim, GPU device info, database stats (document count by type, total chunks, DB size), and queue stats (queued/processing job count)
#### Scenario: Trigger reindex
- **WHEN** a client sends `POST /api/v1/reindex`
- **THEN** the engine SHALL re-embed all existing chunks using the `enriched_text` column and the currently loaded model, and return progress information. This operation SHALL NOT block search queries.
@@ -0,0 +1,57 @@
# Go Client (Delta)
## ADDED Requirements
### Requirement: Update note command
The client SHALL provide a `kb updatenote <id> <text>` command that updates an existing note's content via the engine's `PATCH /api/v1/notes/{id}` endpoint.
#### Scenario: Update a note
- **WHEN** the user runs `kb updatenote 42 "Updated note content"`
- **THEN** the client SHALL send `PATCH /api/v1/notes/42` with body `{"text": "Updated note content"}` and display the result
#### Scenario: Update a note with JSON output
- **WHEN** the user runs `kb updatenote 42 "new content" --format json`
- **THEN** the client SHALL output the raw JSON response from the engine
#### Scenario: Update a non-existent document
- **WHEN** the user runs `kb updatenote 999 "text"` and the engine returns HTTP 404
- **THEN** the client SHALL display an error indicating the document was not found and exit with a non-zero code
#### Scenario: Update a non-note document
- **WHEN** the user runs `kb updatenote 42 "text"` and the engine returns HTTP 422
- **THEN** the client SHALL display an error indicating that only notes can be updated and exit with a non-zero code
#### Scenario: Missing arguments
- **WHEN** the user runs `kb updatenote` or `kb updatenote 42` with insufficient arguments
- **THEN** the client SHALL display usage help indicating that both document ID and text are required
## MODIFIED Requirements
### Requirement: Engine version compatibility check
The client SHALL verify that the connected engine meets a minimum version requirement before executing any API command. The minimum required engine version SHALL be embedded in the client binary at build time. If the engine version is below the minimum, the client SHALL print an error message and exit with a non-zero code. There SHALL be no flag to skip or suppress this check.
#### Scenario: Compatible engine version
- **WHEN** the client connects to an engine reporting version `3.0.0` and `MinEngineVersion` is `3.0.0`
- **THEN** the client SHALL proceed with the command normally
#### Scenario: Incompatible engine version
- **WHEN** the client connects to an engine reporting version `2.1.0` and `MinEngineVersion` is `3.0.0`
- **THEN** the client SHALL print to stderr: `Error: kb client vX.Y.Z requires engine v3.0.0+ (connected engine is v2.1.0)` followed by an upgrade hint, and exit with code 1
#### Scenario: Engine unreachable during version check
- **WHEN** the client cannot reach the engine's `/api/v1/status` endpoint
- **THEN** the client SHALL skip the version check and proceed with the original command (the actual API call will surface the connectivity error)
#### Scenario: Version check is cached per session
- **WHEN** the client has already verified engine compatibility during the current invocation
- **THEN** subsequent API calls within the same invocation SHALL NOT repeat the version check
#### Scenario: Client version command does not check engine
- **WHEN** the user runs `kb --version`
- **THEN** the client SHALL print the client version without contacting the engine
#### Scenario: MinEngineVersion not set
- **WHEN** the client binary has `MinEngineVersion` set to empty string or `dev`
- **THEN** the client SHALL skip the version check entirely (development builds)
@@ -0,0 +1,205 @@
# MCP Server
## Purpose
The MCP server provides a Model Context Protocol interface to the kb engine, exposing knowledge base operations as native MCP tools over Streamable HTTP transport. It runs as a separate Docker container alongside the engine, translating MCP tool calls into engine HTTP API calls.
## Requirements
### Requirement: MCP server transport and deployment
The MCP server SHALL expose tools via Streamable HTTP transport. It SHALL run as a Docker container, configured to connect to the kb engine's HTTP API. It SHALL read `KB_ENGINE_URL` and `KB_API_KEY` from environment variables to connect to the engine.
#### Scenario: MCP server starts and connects to engine
- **WHEN** the MCP server container starts with `KB_ENGINE_URL=http://engine:8000` and `KB_API_KEY=secret`
- **THEN** it SHALL begin accepting MCP connections over Streamable HTTP and use the configured URL and API key for all engine API calls
#### Scenario: Engine unreachable at startup
- **WHEN** the MCP server starts but cannot reach the engine at `KB_ENGINE_URL`
- **THEN** it SHALL start and accept connections, but tool calls SHALL return errors indicating the engine is unreachable
#### Scenario: Docker Compose deployment
- **WHEN** the MCP server is deployed via Docker Compose alongside the engine
- **THEN** it SHALL connect to the engine via the Docker network using the service name (e.g. `http://engine:8000`)
---
### Requirement: MCP server authentication
The MCP server SHALL require Bearer token authentication from calling agents via the `KB_MCP_API_KEY` environment variable. This is independent of the engine's `KB_API_KEY`.
#### Scenario: Valid MCP API key
- **WHEN** `KB_MCP_API_KEY` is set and a calling agent provides a matching Bearer token
- **THEN** the MCP server SHALL process the request normally
#### Scenario: Missing MCP API key when required
- **WHEN** `KB_MCP_API_KEY` is set and a calling agent connects without a Bearer token
- **THEN** the MCP server SHALL reject the connection with an authentication error
#### Scenario: Invalid MCP API key
- **WHEN** `KB_MCP_API_KEY` is set and a calling agent provides a non-matching Bearer token
- **THEN** the MCP server SHALL reject the connection with an authentication error
#### Scenario: MCP auth disabled
- **WHEN** `KB_MCP_API_KEY` is not set
- **THEN** the MCP server SHALL accept all connections without authentication
---
### Requirement: Search tool
The MCP server SHALL expose a `kb_search` tool that queries the knowledge base via the engine's search API.
#### Scenario: Basic search
- **WHEN** an agent calls `kb_search` with `{"query": "pension revaluation", "top": 5}`
- **THEN** the MCP server SHALL POST to the engine's `/api/v1/search` endpoint and return the results with chunk text, scores, document metadata, and tags
#### Scenario: Search with collection filter
- **WHEN** an agent calls `kb_search` with `{"query": "email preferences", "collection": "memory"}`
- **THEN** the MCP server SHALL add `collection:memory` to the tags filter and POST to the engine's search endpoint
#### Scenario: Search with tags and collection
- **WHEN** an agent calls `kb_search` with `{"query": "feedback", "tags": ["email"], "collection": "memory"}`
- **THEN** the MCP server SHALL combine the explicit tags with `collection:memory` in the tag filter
#### Scenario: Search results strip collection tags
- **WHEN** the engine returns search results containing tags `["collection:memory", "feedback", "email"]`
- **THEN** the MCP server SHALL strip `collection:*` tags from the `tags` array and add a separate `collection` field, returning `{"collection": "memory", "tags": ["feedback", "email"], ...}`
#### Scenario: Search with mode override
- **WHEN** an agent calls `kb_search` with `{"query": "error log", "fts_only": true}`
- **THEN** the MCP server SHALL pass `fts_only: true` to the engine search endpoint
---
### Requirement: Add note tool
The MCP server SHALL expose a `kb_addnote` tool that submits a text note to the engine for ingestion.
#### Scenario: Add a note with default collection
- **WHEN** an agent calls `kb_addnote` with `{"text": "User prefers concise responses"}`
- **THEN** the MCP server SHALL submit the note to the engine's `POST /api/v1/jobs` endpoint with the tag `collection:documents` and return the job ID
#### Scenario: Add a note to a specific collection
- **WHEN** an agent calls `kb_addnote` with `{"text": "User prefers concise responses", "collection": "memory", "tags": ["feedback"]}`
- **THEN** the MCP server SHALL submit the note with tags `["collection:memory", "feedback"]` to the engine
#### Scenario: Add a note to a collection replaces existing collection tag
- **WHEN** an agent calls `kb_addnote` with `{"text": "some note", "collection": "memory"}` and the note is ingested
- **THEN** the resulting document SHALL have exactly one `collection:*` tag: `collection:memory`
---
### Requirement: Chunked file upload tools
The MCP server SHALL expose a three-step chunked file upload pattern for transferring files from remote agents to the engine.
#### Scenario: Start an upload
- **WHEN** an agent calls `kb_upload_start` with `{"filename": "report.pdf", "total_size": 5242880, "tags": ["insurance"], "collection": "documents"}`
- **THEN** the MCP server SHALL create a staging entry, generate a UUID `upload_id`, and return `{"upload_id": "<uuid>"}`
#### Scenario: Upload a chunk
- **WHEN** an agent calls `kb_upload_chunk` with `{"upload_id": "<uuid>", "data": "<base64-encoded-data>", "chunk_index": 0}`
- **THEN** the MCP server SHALL decode the base64 data and write it to the staging area for the given upload
#### Scenario: Upload multiple chunks in sequence
- **WHEN** an agent calls `kb_upload_chunk` multiple times with sequential `chunk_index` values for the same `upload_id`
- **THEN** the MCP server SHALL store each chunk and track the sequence
#### Scenario: Finish an upload
- **WHEN** an agent calls `kb_upload_finish` with `{"upload_id": "<uuid>"}`
- **THEN** the MCP server SHALL reassemble the chunks in order, forward the complete file as a multipart upload to the engine's `POST /api/v1/jobs` endpoint with the tags from `kb_upload_start` (including `collection:<name>`), and return the job ID
#### Scenario: Upload with invalid upload_id
- **WHEN** an agent calls `kb_upload_chunk` or `kb_upload_finish` with an `upload_id` that does not exist
- **THEN** the MCP server SHALL return an error indicating the upload ID is not found
#### Scenario: Abandoned upload cleanup
- **WHEN** an agent starts an upload but does not call `kb_upload_finish` within 10 minutes
- **THEN** the MCP server SHALL clean up the staged chunks and remove the upload tracking entry
#### Scenario: MCP server restart during upload
- **WHEN** the MCP server container restarts while an upload is in progress
- **THEN** the in-progress upload SHALL be lost and the agent SHALL need to restart from `kb_upload_start`
---
### Requirement: Update note tool
The MCP server SHALL expose a `kb_update_note` tool that updates an existing note in place via the engine's note mutation endpoint.
#### Scenario: Update an existing note
- **WHEN** an agent calls `kb_update_note` with `{"document_id": 42, "text": "Updated preference: user prefers bullet points"}`
- **THEN** the MCP server SHALL send `PATCH /api/v1/notes/42` to the engine and return the updated document
#### Scenario: Update a non-existent document
- **WHEN** an agent calls `kb_update_note` with a `document_id` that does not exist
- **THEN** the MCP server SHALL return an error indicating the document was not found
#### Scenario: Update a non-note document
- **WHEN** an agent calls `kb_update_note` with a `document_id` that refers to a PDF
- **THEN** the MCP server SHALL return an error indicating that only notes can be updated
---
### Requirement: Get document tool
The MCP server SHALL expose a `kb_get` tool that retrieves document details from the engine.
#### Scenario: Get by document ID
- **WHEN** an agent calls `kb_get` with `{"document_id": 42}`
- **THEN** the MCP server SHALL fetch `GET /api/v1/documents/42` and return the document details with chunks
#### Scenario: Get by source path
- **WHEN** an agent calls `kb_get` with `{"source_path": "memory/feedback_testing.md"}`
- **THEN** the MCP server SHALL query the engine's documents endpoint filtered by source path and return matching documents
#### Scenario: Get results strip collection tags
- **WHEN** the engine returns document details with tags including `collection:memory`
- **THEN** the MCP server SHALL strip `collection:*` from tags and present a separate `collection` field
---
### Requirement: Status tool
The MCP server SHALL expose a `kb_status` tool that returns engine health and statistics.
#### Scenario: Get engine status
- **WHEN** an agent calls `kb_status` with no parameters
- **THEN** the MCP server SHALL fetch `GET /api/v1/status` and return engine version, model info, device info, document counts, and queue state
---
### Requirement: Jobs tool
The MCP server SHALL expose a `kb_jobs` tool that returns ingestion job status.
#### Scenario: List recent jobs
- **WHEN** an agent calls `kb_jobs` with no parameters
- **THEN** the MCP server SHALL fetch `GET /api/v1/jobs` and return the list of recent jobs
#### Scenario: Filter jobs by status
- **WHEN** an agent calls `kb_jobs` with `{"status": "failed"}`
- **THEN** the MCP server SHALL fetch `GET /api/v1/jobs?status=failed` and return matching jobs
---
### Requirement: Collection management via tags
The MCP server SHALL manage collections using tag conventions. The MCP server SHALL enforce exclusive collection membership — a document SHALL belong to exactly one collection.
#### Scenario: Default collection on addnote
- **WHEN** an agent calls `kb_addnote` without specifying a collection
- **THEN** the MCP server SHALL apply the tag `collection:documents`
#### Scenario: Explicit collection on addnote
- **WHEN** an agent calls `kb_addnote` with `{"collection": "memory"}`
- **THEN** the MCP server SHALL apply the tag `collection:memory`
#### Scenario: Exclusive collection enforcement
- **WHEN** a document already has the tag `collection:documents` and an operation changes its collection to `memory`
- **THEN** the MCP server SHALL first remove `collection:documents` via the engine's tag API, then add `collection:memory`
#### Scenario: Collection field in search results
- **WHEN** search results include documents with `collection:*` tags
- **THEN** the MCP server SHALL present the collection as a top-level `collection` field and exclude `collection:*` from the `tags` array
@@ -0,0 +1,43 @@
# Note Mutation
## Purpose
Note mutation allows existing notes to be updated in place without requiring delete and re-add, preserving document identity (ID, creation timestamp) while updating content, embeddings, and the full-text index.
## Requirements
### Requirement: Note update endpoint
The engine SHALL provide a `PATCH /api/v1/notes/{id}` endpoint that accepts new text for an existing note, re-chunks and re-embeds it, and returns the updated document.
#### Scenario: Update an existing note
- **WHEN** a client sends `PATCH /api/v1/notes/42` with body `{"text": "Updated note content"}`
- **THEN** the engine SHALL delete existing chunks and embeddings for document 42, run the new text through the note chunking pipeline, generate embeddings for each chunk, insert new chunks and embeddings, update the document's `content_hash` and `updated_at`, and return the updated document with HTTP 200
#### Scenario: Update preserves document identity
- **WHEN** a note is updated via PATCH
- **THEN** the document SHALL retain its original `id` and `created_at` values, and `updated_at` SHALL be set to the current timestamp
#### Scenario: Update with long text that produces multiple chunks
- **WHEN** a client sends `PATCH /api/v1/notes/42` with text longer than the embedding model's token window
- **THEN** the engine SHALL chunk the text using the same note chunking pipeline as ingestion, producing multiple chunks, and embed each chunk separately
#### Scenario: Update a non-existent document
- **WHEN** a client sends `PATCH /api/v1/notes/999` and document 999 does not exist
- **THEN** the engine SHALL return HTTP 404
#### Scenario: Update a non-note document
- **WHEN** a client sends `PATCH /api/v1/notes/42` and document 42 has `doc_type = 'pdf'`
- **THEN** the engine SHALL return HTTP 422 with an error indicating that only notes can be updated via this endpoint
#### Scenario: Embedding failure during update
- **WHEN** a client sends `PATCH /api/v1/notes/42` but the embedding step fails
- **THEN** the engine SHALL roll back the entire transaction, preserving the original note content, chunks, and embeddings, and return HTTP 500
#### Scenario: FTS5 index updated on note mutation
- **WHEN** a note is updated via PATCH
- **THEN** the FTS5 virtual table SHALL be updated via the existing chunk triggers (`chunks_ad` for deletes, `chunks_ai` for inserts), keeping the full-text index consistent with the new content
#### Scenario: Tags preserved on update
- **WHEN** a note with tags `["feedback", "collection:memory"]` is updated via PATCH
- **THEN** the document's tags SHALL be unchanged — only the text content, chunks, and embeddings are replaced
@@ -0,0 +1,90 @@
## 1. Engine: Schema Migration & updated_at
- [x] 1.1 Add `updated_at TEXT` column migration to `init_schema()` in `kb/database.py` (same pattern as existing `ALTER TABLE` migrations)
- [x] 1.2 Update `insert_document()` to include `updated_at` in returned/stored fields
- [x] 1.3 Update document list endpoint (`GET /api/v1/documents`) to include `updated_at` in response and use `COALESCE(updated_at, created_at)` for date sorting
- [x] 1.4 Update document detail endpoint (`GET /api/v1/documents/{id}`) to include `updated_at` in response
- [x] 1.5 Update tag management endpoint (`PUT /api/v1/documents/{id}/tags`) to set `updated_at = current_timestamp` on tag changes
## 2. Engine: Note Mutation Endpoint
- [x] 2.1 Create `kb/routes/notes.py` with `PATCH /api/v1/notes/{id}` endpoint
- [x] 2.2 Implement validation: document must exist and have `doc_type = 'note'` (404 / 422 on failure)
- [x] 2.3 Implement note update logic: delete old chunks/embeddings, run note chunking pipeline, re-embed, insert new chunks, update `content_hash` and `updated_at` — all in a single transaction
- [x] 2.4 Register the notes router in `engine/main.py`
- [x] 2.5 Test: update a note and verify chunks, embeddings, FTS index, and `updated_at` are all correctly updated
- [x] 2.6 Test: verify rollback on embedding failure preserves original note
## 3. Engine: Version Bump
- [x] 3.1 Update `engine/VERSION` to `3.0.0`
## 4. Go Client: Update Note Command
- [x] 4.1 Add `PATCH /api/v1/notes/{id}` method to `internal/api/client.go`
- [x] 4.2 Create `cmd/updatenote.go` — takes document ID and text as positional args, calls PATCH endpoint, formats output (human/json)
- [x] 4.3 Handle error cases: 404 (not found), 422 (not a note), missing arguments
- [x] 4.4 Update `cmd/examples.go` to include `updatenote` usage
## 5. Go Client: Version Bump
- [x] 5.1 Update `client/VERSION` to `3.0.0`
- [x] 5.2 Update `client/MIN_ENGINE_VERSION` to `3.0.0`
## 6. MCP Server: Project Setup
- [x] 6.1 Create `mcp/` directory at repo root with Python package structure
- [x] 6.2 Add `mcp` SDK and `httpx` as dependencies (requirements.txt or pyproject.toml)
- [x] 6.3 Implement config: read `KB_ENGINE_URL`, `KB_API_KEY`, `KB_MCP_API_KEY` from environment
- [x] 6.4 Implement Streamable HTTP transport setup using `mcp` SDK
- [x] 6.5 Implement Bearer token authentication for incoming agent connections (`KB_MCP_API_KEY`)
## 7. MCP Server: Core Tools
- [x] 7.1 Implement `kb_search` tool — proxy to engine search API, translate `collection` param to `collection:*` tag filter, strip `collection:*` tags from results and add `collection` field
- [x] 7.2 Implement `kb_addnote` tool — proxy to engine jobs API, apply `collection:<name>` tag (default `collection:documents`)
- [x] 7.3 Implement `kb_update_note` tool — proxy to engine `PATCH /api/v1/notes/{id}`
- [x] 7.4 Implement `kb_get` tool — proxy to engine documents API, support lookup by ID or source_path, strip collection tags from response
- [x] 7.5 Implement `kb_status` tool — proxy to engine status API
- [x] 7.6 Implement `kb_jobs` tool — proxy to engine jobs API with optional status filter
## 8. MCP Server: Chunked File Upload
- [x] 8.1 Implement `kb_upload_start` tool — generate UUID, create temp staging directory, store upload metadata (filename, tags, collection) in memory
- [x] 8.2 Implement `kb_upload_chunk` tool — validate upload_id exists, decode base64, write chunk to staging directory by chunk_index
- [x] 8.3 Implement `kb_upload_finish` tool — reassemble chunks in order, forward as multipart upload to engine `POST /api/v1/jobs` with tags (including `collection:*`), return job ID, clean up staging
- [x] 8.4 Implement abandoned upload cleanup — background task that removes uploads older than 10 minutes
- [x] 8.5 Test: upload a multi-chunk file and verify it arrives at the engine correctly
## 9. MCP Server: Collection Management
- [x] 9.1 Implement exclusive collection enforcement — on addnote/addfile, query document tags, remove any existing `collection:*` tags via engine tag API before applying new one
- [x] 9.2 Implement collection tag stripping in all tool responses (search results, document details)
## 10. MCP Server: Tool Descriptions
- [x] 10.1 Write `kb_search` tool description including query expansion and reranking guidance
- [x] 10.2 Write descriptions for all other tools with parameter documentation and usage examples
- [x] 10.3 Include chunked upload usage example in `kb_upload_start` description
## 11. MCP Server: Docker & Compose
- [x] 11.1 Create `mcp/Dockerfile` — Python base image, install dependencies, run MCP server
- [x] 11.2 Add MCP server service to Docker Compose file(s) — connect to engine via Docker network, expose Streamable HTTP port
- [x] 11.3 Document environment variables (`KB_ENGINE_URL`, `KB_API_KEY`, `KB_MCP_API_KEY`) in compose file
## 12. Integration Testing
- [x] 12.1 Test: MCP search with collection filter returns only matching documents
- [x] 12.2 Test: MCP addnote with collection applies correct tag and enforces exclusivity
- [x] 12.3 Test: MCP update note preserves document ID and tags, updates content and `updated_at`
- [x] 12.4 Test: chunked file upload end-to-end (start → chunk × N → finish → verify job created)
- [x] 12.5 Test: MCP server rejects unauthenticated connections when `KB_MCP_API_KEY` is set
## 13. Release
- [x] 13.1 Build and tag engine Docker images (`engine-v3.0.0-*`)
- [x] 13.2 Build and tag MCP server Docker image
- [x] 13.3 Build Go client binaries for all platforms
- [x] 13.4 Create git tags: `engine-v3.0.0`, `client-v3.0.0`
- [x] 13.5 Update SKILL.md to reference MCP server as primary agent integration path
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-04
@@ -0,0 +1,59 @@
## Context
The MCP Python SDK includes DNS rebinding protection via `TransportSecuritySettings` in `mcp.server.transport_security`. When enabled, it validates the `Host` header against an allowlist and returns 421 for unrecognised hosts.
`FastMCP` auto-enables this protection when `host` is `127.0.0.1`, `localhost`, or `::1`, with a default allowlist of those three values (wildcard port). The kb MCP server does not pass a `host` to `FastMCP()` and does not pass `transport_security`, so the behaviour depends on the SDK's defaults — which have changed between versions and will likely keep changing.
Currently the server calls `mcp.streamable_http_app()` to get a Starlette sub-app and wraps it in its own Starlette app with `BearerAuthMiddleware`. The `transport_security` settings flow through `FastMCP()``Settings``StreamableHTTPSessionManager`, so they must be set at `FastMCP` construction time.
When a remote client connects (e.g. `Host: 192.168.1.50:3000`), the SDK rejects the request with 421 before our auth middleware even runs.
## Goals / Non-Goals
**Goals:**
- Allow operators to configure additional allowed hosts via environment variable so remote clients can connect.
- Support both IP addresses and FQDNs, with or without port.
- Preserve DNS rebinding protection (keep it enabled, just widen the allowlist).
- Maintain backward compatibility — unset variable means localhost-only, same as today.
**Non-Goals:**
- Disabling DNS rebinding protection entirely.
- Configuring allowed origins separately from allowed hosts (derive origins automatically from hosts).
- TLS termination or HTTPS — that belongs to a reverse proxy in front of the MCP container.
## Decisions
### 1. Environment variable format
`KB_MCP_ALLOWED_HOSTS` is a comma-separated list of hosts. Each entry is an IP address or FQDN without port and without scheme.
Examples: `192.168.1.50`, `kb.example.com`, `192.168.1.50,kb.example.com,10.0.0.1`
**Rationale:** Comma-separated is the simplest format that doesn't require quoting in Docker Compose YAML or shell environments. Ports are omitted because the wildcard-port pattern (`host:*`) covers all ports — operators shouldn't need to know the internal port.
**Alternative considered:** JSON array — rejected, awkward in env vars and Compose files.
### 2. Merge with localhost defaults
The parsed hosts are merged with the hardcoded localhost set (`127.0.0.1`, `localhost`, `[::1]`). Localhost is always allowed regardless of the env var value.
**Rationale:** Removing localhost would break local development and health checks. There's no reason to ever disallow it.
### 3. Auto-derive allowed origins from allowed hosts
For each allowed host, generate `http://<host>:*` as an allowed origin. No separate env var for origins.
**Rationale:** The MCP server doesn't serve HTTPS (TLS is terminated by a reverse proxy), so `http://` is always correct at the container level. If HTTPS origins are needed in future, a separate env var can be added then.
### 4. Pass TransportSecuritySettings explicitly to FastMCP
Always construct a `TransportSecuritySettings` with `enable_dns_rebinding_protection=True` and the merged allowlist, and pass it as `transport_security=` to `FastMCP()`. This makes the behaviour explicit rather than depending on SDK defaults.
**Rationale:** The SDK's auto-detection logic depends on the `host` parameter which we don't set, and the defaults may change between SDK versions. Being explicit removes the ambiguity.
## Risks / Trade-offs
- **Operator must know their Host header value** — If a reverse proxy rewrites the Host header, the operator needs to allowlist the rewritten value, not the original. → Mitigation: document this in Compose file comments.
- **No HTTPS origin support** — If a client sends `Origin: https://...`, it will be rejected. → Mitigation: acceptable for now; the MCP server sits behind a proxy that terminates TLS. Can add `KB_MCP_ALLOWED_ORIGINS` later if needed.
@@ -0,0 +1,26 @@
## Why
The MCP server uses the Python MCP SDK's built-in DNS rebinding protection, which validates the `Host` header on every request. By default it only allows `localhost`, `127.0.0.1`, and `[::1]`. When clients connect remotely — using an IP address or FQDN — the server returns 421 "Invalid Host header" and the connection fails. There is no way to configure allowed hosts without changing code.
## What Changes
- Add a new environment variable `KB_MCP_ALLOWED_HOSTS` that accepts a comma-separated list of additional allowed hosts (IPs and/or FQDNs).
- The MCP server passes these hosts (plus the existing localhost defaults) to the MCP SDK's `TransportSecuritySettings` when constructing the ASGI app.
- Both bare hosts and wildcard-port patterns are supported (e.g. `192.168.1.50` and `kb.example.com` both work, with any port).
- When `KB_MCP_ALLOWED_HOSTS` is empty or unset, behaviour is unchanged (localhost-only).
## Capabilities
### New Capabilities
_None — this is configuration of an existing component, not a new capability._
### Modified Capabilities
- `docker-deployment`: Add `KB_MCP_ALLOWED_HOSTS` to the MCP container's environment variables in Compose files and document its usage.
## Impact
- **mcp/config.py** — new `KB_MCP_ALLOWED_HOSTS` env var.
- **mcp/server.py** — construct `TransportSecuritySettings` with merged allowed hosts/origins and pass to the FastMCP app.
- **engine/compose.\*.yaml** — add `KB_MCP_ALLOWED_HOSTS` to the kb-mcp service environment block.
@@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: Configurable MCP allowed hosts
The MCP server SHALL accept a `KB_MCP_ALLOWED_HOSTS` environment variable containing a comma-separated list of additional hosts (IP addresses or FQDNs) that are permitted to connect. The server SHALL always allow `127.0.0.1`, `localhost`, and `[::1]` regardless of this setting. DNS rebinding protection SHALL always be enabled.
#### Scenario: Remote client connects with allowed host
- **WHEN** `KB_MCP_ALLOWED_HOSTS` is set to `192.168.1.50` and a client connects with `Host: 192.168.1.50:3000`
- **THEN** the server SHALL accept the request and process it normally
#### Scenario: Remote client connects with disallowed host
- **WHEN** `KB_MCP_ALLOWED_HOSTS` is set to `192.168.1.50` and a client connects with `Host: 10.0.0.99:3000`
- **THEN** the server SHALL return HTTP 421 "Invalid Host header"
#### Scenario: Multiple allowed hosts
- **WHEN** `KB_MCP_ALLOWED_HOSTS` is set to `192.168.1.50,kb.example.com`
- **THEN** the server SHALL accept requests with `Host` matching either `192.168.1.50` or `kb.example.com` on any port
#### Scenario: Variable unset or empty
- **WHEN** `KB_MCP_ALLOWED_HOSTS` is unset or empty
- **THEN** the server SHALL allow only localhost addresses (`127.0.0.1`, `localhost`, `[::1]`) with any port
#### Scenario: Localhost always allowed
- **WHEN** `KB_MCP_ALLOWED_HOSTS` is set to `192.168.1.50`
- **THEN** the server SHALL still accept requests with `Host: localhost:3000` or `Host: 127.0.0.1:3000`
#### Scenario: Allowed origins derived from allowed hosts
- **WHEN** `KB_MCP_ALLOWED_HOSTS` includes `192.168.1.50`
- **THEN** the server SHALL accept `Origin: http://192.168.1.50:3000` (and any port) in addition to localhost origins
## MODIFIED Requirements
### Requirement: Compose files for deployment
The project SHALL provide Docker Compose files for single-command deployment. Compose files SHALL use `build:` context for local development. Release notes SHALL document the versioned image tag for users pulling pre-built images.
#### Scenario: Start NVIDIA deployment
- **WHEN** an admin runs `docker compose -f compose.nvidia.yaml up -d`
- **THEN** the engine SHALL start with GPU access, bind-mount the data directory, and be reachable on the configured port
#### Scenario: Start ROCm deployment
- **WHEN** an admin runs `docker compose -f compose.rocm.yaml up -d`
- **THEN** the engine SHALL start with GPU access via ROCm device passthrough, bind-mount the data directory, and be reachable on the configured port
#### Scenario: Automatic restart
- **WHEN** the engine process crashes or the host reboots
- **THEN** Docker SHALL automatically restart the container (restart policy `unless-stopped`)
#### Scenario: Configure via environment
- **WHEN** an admin sets environment variables in the compose file (KB_MODEL, KB_API_KEY, KB_DEVICE, KB_MCP_ALLOWED_HOSTS, etc.)
- **THEN** the engine and MCP server SHALL use those values
#### Scenario: Pre-built image deployment
- **WHEN** an admin wants to use a pre-built engine image without building from source
- **THEN** the engine release notes SHALL include the exact `docker pull` command with the versioned tag (e.g. `docker.dcglab.co.uk/dcg/kb/engine:engine-v2.1.0-nvidia`)
#### Scenario: MCP allowed hosts in Compose
- **WHEN** the kb-mcp service is defined in a Compose file
- **THEN** the environment block SHALL include `KB_MCP_ALLOWED_HOSTS` with a comment explaining its format and purpose
@@ -0,0 +1,18 @@
## 1. Configuration
- [x] 1.1 Add `KB_MCP_ALLOWED_HOSTS` to `mcp/config.py` — read from env, default empty string
- [x] 1.2 Add host-parsing helper that splits the comma-separated value, strips whitespace, and filters empty entries
## 2. Transport security
- [x] 2.1 Build `TransportSecuritySettings` in `mcp/server.py` — merge localhost defaults with parsed `KB_MCP_ALLOWED_HOSTS`, derive allowed origins from allowed hosts
- [x] 2.2 Pass `transport_security=` to the `FastMCP()` constructor
## 3. Compose files
- [x] 3.1 Add `KB_MCP_ALLOWED_HOSTS=${KB_MCP_ALLOWED_HOSTS:-}` to the kb-mcp environment block in `compose.cpu.yaml`, `compose.nvidia.yaml`, and `compose.rocm.yaml` with a comment explaining the format
## 4. Verification
- [x] 4.1 Test: unset `KB_MCP_ALLOWED_HOSTS` — confirm localhost connects, remote host gets 421
- [x] 4.2 Test: set `KB_MCP_ALLOWED_HOSTS` to the server IP — confirm remote host connects successfully
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-04
@@ -0,0 +1,39 @@
## Context
The MCP server (`mcp/server.py`) exposes KB operations as tools for LLM clients. Collections are an abstraction over tags — internally stored with a `collection:` prefix. The server already has helpers for managing collection tags (`_collection_tag`, `_ensure_exclusive_collection`, `_strip_collection_tags`) and the engine client (`mcp/engine.py`) already has an `update_tags()` method.
Document deletion is supported by the engine API at `DELETE /api/v1/documents/{doc_id}` but has no corresponding engine client method or MCP tool.
## Goals / Non-Goals
**Goals:**
- Expose collection assignment for existing documents via MCP (`kb_set_collection`)
- Expose document deletion via MCP (`kb_delete`)
- Follow existing patterns in `server.py` and `engine.py`
**Non-Goals:**
- Bulk operations (multi-document collection assignment or deletion)
- Tag management beyond collections (direct tag add/remove via MCP)
- Undo/recycle bin for deleted documents
- Changes to the engine API layer — all endpoints already exist
## Decisions
### 1. Reuse `_ensure_exclusive_collection` for kb_set_collection
The server already has `_ensure_exclusive_collection(doc_id, collection)` which removes any existing `collection:*` tags and applies the new one. The `kb_set_collection` tool will use this directly when a collection is provided, and manually remove collection tags when clearing.
**Alternative considered**: Exposing raw tag add/remove to the LLM. Rejected because it leaks the `collection:` prefix implementation detail and the LLM could create inconsistent state (multiple collections on one document).
### 2. New `engine.delete_document()` method for kb_delete
Add a simple `delete_document(doc_id)` to `mcp/engine.py` that calls `DELETE /api/v1/documents/{doc_id}`. This follows the same pattern as all other engine client methods.
### 3. Return confirmation with document metadata on delete
`kb_delete` will return the response from the engine API which includes `{"status": "deleted", "document_id": ..., "title": ...}`. This gives the LLM confirmation of what was deleted without needing a separate get call.
## Risks / Trade-offs
- **[Accidental deletion]** → The LLM could delete the wrong document. Mitigation: the tool requires an explicit `document_id`, and the response includes the title so the LLM can verify. No bulk delete is exposed.
- **[Collection cleared unexpectedly]** → Passing `collection=None` to `kb_set_collection` removes collection assignment. Mitigation: the parameter description will make this behavior explicit.
@@ -0,0 +1,25 @@
## Why
LLMs using the KB MCP server can create notes in collections and search by collection, but cannot assign existing documents to a collection or delete documents. This forces users to drop out to the HTTP API for routine document management. Both operations are fully supported at the database and HTTP API layers but aren't wired through to MCP tools.
## What Changes
- Add `kb_set_collection` MCP tool — assigns, changes, or removes the collection on an existing document by manipulating `collection:` prefixed tags via the existing `engine.update_tags()` method.
- Add `kb_delete` MCP tool — deletes a document by ID, calling the existing `DELETE /api/v1/documents/{doc_id}` endpoint via a new `engine.delete_document()` method.
## Capabilities
### New Capabilities
- `mcp-document-management`: MCP tools for modifying and deleting existing documents (kb_set_collection, kb_delete).
### Modified Capabilities
_(none — the engine API endpoints already exist; this change only adds MCP tool wrappers)_
## Impact
- **MCP server** (`mcp/server.py`): Two new tool registrations.
- **MCP engine client** (`mcp/engine.py`): One new method (`delete_document`). The `update_tags` method already exists and will be reused.
- **Engine API**: No changes — `DELETE /api/v1/documents/{doc_id}` and `PUT /api/v1/documents/{doc_id}/tags` already exist.
- **Breaking changes**: None. Additive only.
@@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: Set collection on existing document via MCP
The MCP server SHALL expose a `kb_set_collection` tool that assigns or changes the collection of an existing document. The tool SHALL accept a `document_id` (required) and `collection` (optional string). When `collection` is provided, the tool SHALL ensure the document belongs to exactly that collection by removing any existing `collection:*` tags and adding the new one. When `collection` is omitted or null, the tool SHALL remove all `collection:*` tags from the document, leaving it unassigned.
The tool SHALL return the updated document with the `collection` field and cleaned tags (collection tags stripped), consistent with other MCP tool responses.
#### Scenario: Assign untagged document to a collection
- **WHEN** `kb_set_collection` is called with `document_id=42` and `collection="workspace"`
- **THEN** the document SHALL have the tag `collection:workspace` added
- **AND** the response SHALL include `"collection": "workspace"`
#### Scenario: Change document from one collection to another
- **WHEN** `kb_set_collection` is called with `document_id=42` and `collection="memory"` on a document currently in collection "documents"
- **THEN** the tag `collection:documents` SHALL be removed and `collection:memory` SHALL be added
- **AND** the response SHALL include `"collection": "memory"`
#### Scenario: Remove document from all collections
- **WHEN** `kb_set_collection` is called with `document_id=42` and no `collection` parameter
- **THEN** all `collection:*` tags SHALL be removed from the document
- **AND** the response SHALL include `"collection": null`
#### Scenario: Document not found
- **WHEN** `kb_set_collection` is called with a `document_id` that does not exist
- **THEN** the tool SHALL return an error response indicating the document was not found
### Requirement: Delete document via MCP
The MCP server SHALL expose a `kb_delete` tool that permanently deletes a document from the knowledge base. The tool SHALL accept a `document_id` (required integer). Deletion SHALL remove the document, its chunks, embeddings, tags, and any stored file on disk.
The tool SHALL return a confirmation response including the deleted document's ID and title.
#### Scenario: Successful deletion
- **WHEN** `kb_delete` is called with `document_id=42`
- **THEN** the document, its chunks, embeddings, tag associations, and stored file SHALL be deleted
- **AND** the response SHALL include `"status": "deleted"`, the `document_id`, and the document `title`
#### Scenario: Document not found
- **WHEN** `kb_delete` is called with a `document_id` that does not exist
- **THEN** the tool SHALL return an error response indicating the document was not found
### Requirement: Engine client delete method
The MCP engine client (`mcp/engine.py`) SHALL provide a `delete_document(doc_id)` method that sends a `DELETE` request to `/api/v1/documents/{doc_id}` and returns the JSON response. The method SHALL raise on non-2xx status codes, consistent with other engine client methods.
#### Scenario: Successful engine client delete call
- **WHEN** `delete_document(42)` is called and the engine API returns 200
- **THEN** the method SHALL return the parsed JSON response
#### Scenario: Engine client delete for missing document
- **WHEN** `delete_document(999)` is called and the engine API returns 404
- **THEN** the method SHALL raise an `httpx.HTTPStatusError`
@@ -0,0 +1,12 @@
## 1. Engine Client
- [x] 1.1 Add `delete_document(doc_id)` method to `mcp/engine.py`
## 2. MCP Tools
- [x] 2.1 Add `kb_set_collection` tool to `mcp/server.py`
- [x] 2.2 Add `kb_delete` tool to `mcp/server.py`
## 3. Verification
- [x] 3.1 Test kb_set_collection and kb_delete against running engine