The knowledgebase’s semantic search, exposed to Claude as MCP tools on its own tailnet node and through the Aperture connector telebkb. This note is the architecture, the tool contract, and the one non-obvious failure (421 Invalid Host header) that eats an afternoon if you don’t know it.

Architecture

Claude ──► https://ai.taild4189d.ts.net/v1/connectors/telebkb/   (Aperture connector, 307 →)
       ──► https://knowledgebase.taild4189d.ts.net:8443/mcp      (tailscale serve)
       ──► 127.0.0.1:9099                                        telep-kb-mcp.service (FastMCP)
       ──► 127.0.0.1:8092                                        knowledgebase.service (Flask)
       ──► kb-qdrant (docker)                                    vectors
PieceWhere
MCP servertelep-kb-mcp.service/home/levander/telep-kb-mcp/server.py, bound 127.0.0.1:9099
TransportFastMCP streamable-http
Backing appknowledgebase.service on :8092 (see kb-agent-api)
Vector storekb-qdrant docker container
Vector code/home/levander/kb-vectors, reached because app.py:8 does sys.path.insert(0, "/home/levander/kb-vectors")
EmbeddingsBAAI/bge-large-en-v1.5, 1024-dim, cosine, CPU-only torch build
Tailnet nodeknowledgebase.taild4189d.ts.net:8443/mcp — own node per tailnet-service-exposure-convention
ConnectorAperture telebkb at https://ai.taild4189d.ts.net/v1/connectors/telebkb/, 307-redirects to the upstream

The host is telep-mainframe. The 9099 loopback port is also recorded in the code-server port map.

Tool surface

Reshaped this session to mirror the historian subagent’s “named sources, explicit selection” pattern — the model asks what exists, then picks, rather than being handed a single opaque search().

get_collections()                                  → the named sources + their shape
search(collection, query, limit, folder)           → hits from ONE named collection
get_note(path)                                     → full text of an indexed note
list_topics()                                      → topic index

For Agents

An unknown collection name returns {error, available} — it does not throw. The recovery loop is: call search with a guess, read available from the error, retry. You never need get_collections() first, but it’s cheaper if you’re unsure.

CollectionPointsContentRetrieval
manuals7816OCR’d repair manualshybrid — BM25 + semantic (RRF)
notessee 2026-08-31-obsidian-vault-qdrant-index-selectionObsidian vault notessemantic only

Why notes is semantic-only

hybrid.py holds its BM25 index in module globals (_bm25, _docs, _facets), so the process can serve exactly one collection’s keyword index. Giving notes hybrid retrieval means refactoring that module state into per-collection instances. Deliberately deferred — semantic-only is adequate for prose notes, whereas the manuals genuinely need keyword matching for part numbers and section codes.

FastMCP returns 421 Invalid Host header behind tailscale serve

Symptom: every tailnet request to /mcp returns 421; loopback 127.0.0.1:9099 works fine. It reads like a proxy misconfiguration or an auth failure — the response body names no layer, so nothing points at the app.

Cause: mcp ≥ 1.29 turns on DNS-rebinding protection with an empty allowed_hosts, which admits only 127.0.0.1. tailscale serve forwards the original Host header (knowledgebase.taild4189d.ts.net:8443), so every tailnet request fails the check.

Fix: pass transport security explicitly when constructing the server —

FastMCP(..., transport_security=TransportSecuritySettings(allowed_hosts=[...]))

overridable at runtime via TELEP_KB_MCP_ALLOWED_HOSTS.

Generalizes: any reverse proxy that preserves Host (which tailscale serve does by design) will trip host-allowlist defaults in the app behind it. Check the app before the proxy when a 4xx has no matching proxy log line.