Skip to content

feat: many indexes on one host — usable explorer, URL-path routing, proxy-safe MCP - #16

Merged
dimittal merged 4 commits into
mainfrom
feat/ui-and-search-fixes
Aug 10, 2026
Merged

feat: many indexes on one host — usable explorer, URL-path routing, proxy-safe MCP#16
dimittal merged 4 commits into
mainfrom
feat/ui-and-search-fixes

Conversation

@dimittal

@dimittal dimittal commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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>/mcp is 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:

Tab
How to use? default tab — what a doc_type and an entity are, that relationships are optional, the MCP endpoint, the tool list
Schema every doc_type and its fields, so the shape is visible at all
Explore search
Map the whole index, not one entity's neighbourhood

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:

  • Every path rendered the same brain. Streamlit's router resolves programmatic pages by an internal identifier, so /a and /b both served whichever sorted first. A curl status 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 from st.context.url, and nothing else selects it.
  • The help tab showed no MCP endpoint. A shared explorer cannot be handed one correct URL as configuration; the answer depends on which index you're looking at. It's derived from the page URL, which also keeps it right behind any proxy or hostname. OPEN_INDEX_PUBLIC_URL stays as the fallback for single-brain deployments.

Presentation logic lives in ui/view.py with no Streamlit import, so it's testable directly; app.py is widgets over it.

3. OpenSearch — three bugs, all found with real data

  1. Search failed outright on any brain with a numeric, boolean or timestamp field. OpenSearch rejects the whole multi_match if a non-text field is listed, so one numeric field broke all search.
  2. A string field holding date-shaped values was mapped as a date by dynamic detection, producing the same rejection for a correctly-declared field. date_detection is off, so the schema decides the type rather than the first document indexed.
  3. If the index was dropped after the brain was opened, the next write auto-created it with a dynamic mappingdoc_type as text, breaking every aggregation long after the write that caused it.

4. Serving behind a proxy

serve could 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 foreign Host and is rejected 421. Passing a non-localhost bind address to the SDK would have silently disabled the protection, so the allow-list is built explicitly from --public-url plus --allowed-host / OPEN_INDEX_ALLOWED_HOSTS. * opts out for a trusted proxy.

Tests

535 pass, ui/view.py at 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

dimittal and others added 4 commits August 10, 2026 09:05
`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
dimittal force-pushed the feat/ui-and-search-fixes branch from fd055d7 to 327d2c2 Compare August 10, 2026 03:36
@dimittal dimittal changed the title feat(ui): readable map, Schema tab, help-first layout; fix proxied and typed search feat: many indexes on one host — usable explorer, URL-path routing, proxy-safe MCP Aug 10, 2026
@dimittal
dimittal merged commit 95828bb into main Aug 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant