feat: many indexes on one host — usable explorer, URL-path routing, proxy-safe MCP - #16
Merged
Conversation
`serve --brains <root>` mounts every brain under a directory at its own path,
so /<name>/mcp is that brain's endpoint. One process, one port, one container.
Running a container per brain was the obvious approach and does not scale: each
carries its own interpreter and model cache, so a host runs out of memory long
before it runs out of useful indexes. Mounted apps do not get their lifespan run
by Starlette, so each child's is entered explicitly via AsyncExitStack — without
it the MCP session manager never starts and every request hangs.
Also here:
- build_transport_security() allows the proxied public host. The MCP SDK's
DNS-rebinding guard rejects a forwarded Host with 421 Misdirected Request,
which behind any reverse proxy means nothing connects.
- per-brain tokens via OPEN_INDEX_TOKEN_<NAME>, so one endpoint being open
does not open the rest.
- the container entrypoint no longer assumes exactly one brain at /brain; it
detects the mode and reconciles each brain before serving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old UI was only useful for search: it showed no structure and no way to see
the graph. Rebuilt around what a first-time reader needs, in order:
How to use? the default tab — what a doc_type and an entity are, that
relationships are optional, the MCP endpoint, and the tool list
Schema every doc_type and its fields, so the shape is visible at all
Explore search, unchanged in spirit
Map the whole index, not just one entity's neighbourhood
The map previously drew a label on every node, which at any real size is an
unreadable wall of text. Nodes are now bare and identify themselves on hover.
Presentation logic lives in view.py with no Streamlit import, so it is testable
directly rather than through the app harness.
Two fixes that only appear once one process serves many brains:
- the index comes from the URL path, and nothing else selects it. Streamlit's
own router resolves programmatic pages by an internal identifier, so every
path silently rendered whichever brain sorted first; a curl status check
cannot catch this because Streamlit returns its shell for any path. Read
st.context.url instead.
- the help tab derives its MCP endpoint from the page URL. A shared explorer
cannot be handed one correct URL as configuration — the answer depends on
which index you are looking at. Deriving it also keeps it right behind any
proxy or hostname. OPEN_INDEX_PUBLIC_URL remains the fallback for
single-brain deployments, where the path carries no index name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fuzzy matching is only valid on keyword and text fields, so as soon as a brain had one numeric or date field OpenSearch rejected the whole query — every search, not just ones touching that field. Non-string fields are now excluded from the fuzzy field list, and date_detection is off so a string that happens to look like a date is not silently remapped. Writes also create the index if it is missing, rather than letting OpenSearch auto-create one with a guessed dynamic mapping that then behaves differently from a deliberately created index. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
How to run a directory of brains from one process, what each index's MCP endpoint looks like, and how to point an agent at one. Also ignores the rendered fleet deployment state: those files describe one specific machine and do not belong in the repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dimittal
force-pushed
the
feat/ui-and-search-fixes
branch
from
August 10, 2026 03:36
fd055d7 to
327d2c2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four commits, each reviewable on its own. Everything here came out of running the tool for real rather than from reading the code.
1. Many brains from one process
serve --brains <root>mounts every brain under a directory at its own path, so/<name>/mcpis that brain's endpoint. One process, one port, one container.A container per brain was the obvious approach and does not scale — each carries its own interpreter and model cache. Measured: 200 brains in one process is ~373MB (~1.8MB marginal each) against roughly 53GB as containers. SQLite matters here too; one OpenSearch index per brain makes each brain a shard, which is the thing that stops scaling first.
Starlette does not run a mounted app's lifespan, so each child's is entered explicitly via
AsyncExitStack. Without it the MCP session manager never starts and every request hangs.Per-brain tokens via
OPEN_INDEX_TOKEN_<NAME>, so one open endpoint doesn't open the rest. The container entrypoint no longer assumes exactly one brain at/brain.2. A usable explorer
The UI was only useful for search — it showed no structure and no way to see the graph. Rebuilt around what a first-time reader needs, in order:
The map was unreadable. It drew every entity's name beside its dot; names are long and arbitrary, so they overlapped each other and their own edges. The canvas now carries shape and colour only, with full detail on hover. It also opened blank until you picked an anchor — the wrong default for someone who has never seen the index. It now shows the whole index, capped at the 250 most-connected nodes, and says so when the cap bites rather than presenting a subset as everything.
Two bugs that only appear once one process serves many brains:
/aand/bboth served whichever sorted first. Acurlstatus check cannot catch this — Streamlit returns its shell for any path — so this was confirmed with a WebSocket probe carrying a per-brain marker. The index now comes fromst.context.url, and nothing else selects it.OPEN_INDEX_PUBLIC_URLstays as the fallback for single-brain deployments.Presentation logic lives in
ui/view.pywith no Streamlit import, so it's testable directly;app.pyis widgets over it.3. OpenSearch — three bugs, all found with real data
multi_matchif a non-text field is listed, so one numeric field broke all search.stringfield holding date-shaped values was mapped as a date by dynamic detection, producing the same rejection for a correctly-declared field.date_detectionis off, so the schema decides the type rather than the first document indexed.doc_typeas text, breaking every aggregation long after the write that caused it.4. Serving behind a proxy
servecould not sit behind any reverse proxy: the MCP SDK enables DNS-rebinding protection with a localhost-only allow-list, so a proxied request arrives with a foreignHostand is rejected421. Passing a non-localhost bind address to the SDK would have silently disabled the protection, so the allow-list is built explicitly from--public-urlplus--allowed-host/OPEN_INDEX_ALLOWED_HOSTS.*opts out for a trusted proxy.Tests
535 pass,
ui/view.pyat 100%. New coverage for multi-brain mounting and lifespans, URL-path selection and its fallbacks, endpoint derivation, the overview graph, label-free rendering, the schema tables, the host allow-list (including the with/without-port case that caused the 421), and field-type filtering.Verified end to end on a real deployment over HTTPS, several indexes behind one host.
🤖 Generated with Claude Code