From 226297d3435c05e5be0229d7a8e1e7d89ac58fc9 Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 16:32:54 +0200 Subject: [PATCH 01/12] initial plan --- .../2026-07-09-extra-mcp-containers-config.md | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md diff --git a/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md b/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md new file mode 100644 index 0000000..55aaa10 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md @@ -0,0 +1,284 @@ +# Extra MCP Containers (Experimental, Hidden Config) + +## Goal + +Let a user drop extra Docker/macOS-container MCP servers (e.g. a prototype +"fluensy_learn" container) next to the existing core vault container, purely +via a hand-edited config file. No setup-wizard UI, no docs beyond a README +"Experimental" section. Brain3 discovers them on startup, manages their +lifecycle the same way it manages the vault container, and merges their tools +into the single MCP tool list the gateway exposes. + +This is a prototyping/dogfooding feature ("secret agent zoo" per the user). +Keep it best-effort and low-ceremony: if a container is misconfigured or fails +to start, log an error and continue running with whatever did come up. + +## Non-goals (phase 1) + +- No setup-wizard integration. No TUI screen. Config is a hand-authored YAML + file the user places manually. +- No multiple bind mounts per container — one host directory, one container + path. +- No OAuth between gateway and extra containers — this is an internal, + gateway-only trust boundary (see Auth section). +- Not folding the core vault container into this same list yet. The core + container keeps its current `.env`-driven config path. This plan only adds + the schema and code structured so that migration is easy later, not doing + the migration itself. +- Not moving `.env` into YAML wholesale. Just reserve the top-level shape so + that's a additive, non-breaking change later. + +## Config file + +- New optional file: `/mcp_containers.yaml` (next to the existing + `.env`). Absence of the file is the default, normal state — nothing changes + for existing users. +- Loaded once at gateway startup, after the core container is ensured and + before the HTTP server starts accepting MCP traffic (tool list must be + complete before `initialize`/`tools/list` can be served correctly). +- Parse failures or per-container startup failures are logged at `error` and + that container is skipped. A malformed YAML file does not crash the + gateway — it behaves as if the file were absent, but loudly logs why. + +### Schema (forward-compatible root shape) + +```yaml +# mcp_containers.yaml — EXPERIMENTAL, undocumented outside README "Experimental" section. +mcp_containers: + - name: fluensy_learn + platform: docker # docker | macos_container + image: ghcr.io/example/fluensy-learn + tag: latest + port: 8420 # port the container's MCP server listens on inside the container + host_directory: /Users/tleyden/fluensy-data # single dir, mounted read-write + auth: + type: bearer_token # none | bearer_token + secret_file: /Users/tleyden/.brain3/secrets/fluensy_learn.token +``` + +- Root key `mcp_containers` is a list so the same file can later carry the + core container's config too (each entry could grow an optional `role: core` + field then). Not doing that now — just don't paint ourselves into a corner + with a root shape that can't hold it. +- `name` must be unique, DNS/label-safe (used as the container name and + Docker network alias — reuse whatever validation `ContainerConfig.name` + already implies). +- `platform` reuses the existing `ContainerRuntime` enum (`Docker` | + `MacOSContainer`) — no new runtime concept. +- `image` + `tag` are split (not one string) so we can validate/log them + separately and because that's how most registry tooling models it. +- `port`: single container-listen port. Host port is **not** user-specified — + the gateway auto-picks a free loopback port the same way it would for any + ad hoc port mapping, to avoid asking the user to manage a second port + number and to avoid collisions between multiple extra containers. (Open + question below if you'd rather make host port explicit too.) +- `host_directory`: one path, bind-mounted read-write at a fixed, documented + container path (proposal: `/data`). No multi-mount config in phase 1 — + confirmed out of scope per your notes (SQLite DB + scratch markdown dir + both fit under one root). +- `auth.type`: `none` or `bearer_token`. This mirrors what the vault + container already does today (`B3_UPSTREAM_SHARED_SECRET` / + `x-brain3-upstream-secret` header) — same trust model, just formalized as a + named choice instead of being implicit. Confirmed by a quick check of + current MCP ecosystem practice: full OAuth 2.1 is the standard only when a + server must authenticate *third-party* clients; for a private + gateway-to-container hop where brain3 is the only caller, a static bearer + token is the standard, simpler pattern (e.g. Docker's own MCP gateway + writeups, mcp-auth.dev bearer-auth docs). OAuth is explicitly not an option + here, consistent with your instinct. +- `auth.secret_file`: path to a file on the host containing the raw bearer + token. Brain3 mounts it **read-only** into the container at a fixed path + (proposal: `/run/secrets/mcp_bearer_token`) and does *not* pass it as an + env var — matches your "mount it, don't env-var it" preference and is more + in line with how Docker/K8s secrets are conventionally delivered. The + gateway itself reads the same file from the host to know what token to + send as `Authorization: Bearer ` (or whatever header the container + expects — see open question) when calling the container's MCP endpoint. + +## Domain model changes (`crates/core/src/domain/model.rs`) + +Add, alongside the existing `ContainerStartupConfig`: + +```rust +pub struct ExtraMcpContainerConfig { + pub name: String, + pub runtime: ContainerRuntime, + pub image: String, // "image:tag" already joined + pub container_port: u16, + pub host_directory: PathBuf, + pub auth: ExtraMcpContainerAuth, +} + +pub enum ExtraMcpContainerAuth { + None, + BearerToken { secret_file: PathBuf }, +} +``` + +These are intentionally *not* the same struct as `ContainerStartupConfig` +(that one carries vault-specific fields like `vault_path`, +`enable_sync_reindex_tool`). Both should build a `ContainerConfig` (the +runtime-agnostic one `ContainerPort::run` already takes) through their own +small builder function, same pattern as `build_container_config` in +`startup.rs`. Resist the urge to unify them into one generic struct now — +the vault container has enough special-cased fields that a shared struct +would just grow a pile of `Option`s no one else uses. + +## Config loading (`crates/platform/src/config/`) + +- New module `mcp_containers_config.rs`: parses the YAML file into + `Vec` (or an empty vec if the file doesn't exist). +- Needs a YAML crate — none is currently a dependency. `serde_yaml` is + unmaintained upstream; recommend `serde_yml` or `serde_norway` (active + forks) — pick one during implementation, not a phase-1 blocker either way. +- Validation at load time (best-effort, one bad entry doesn't kill the rest): + - unique `name` across entries + - `host_directory` exists and is a directory + - `auth.secret_file` exists and is readable, if `bearer_token` + - Any entry that fails validation: log `tracing::error!` with the + container name and reason, skip it, keep going. + +## Lifecycle (reuse, don't reinvent) + +`ensure_mcp_container` / `stop_mcp_container` in +`crates/platform/src/container/startup.rs` already implement: image +pull-if-missing, name-conflict check, internal-network join, startup TCP +probe with timeout, managed-container labels for orphan GC, logs-on-failure. +None of that is vault-specific except the env vars and vault bind mount. + +Plan: extract the runtime-agnostic parts (already mostly separated — `build_container_config` +is the only vault-specific piece) so a second, small +`build_extra_container_config(&ExtraMcpContainerConfig, installation_id) +-> ContainerConfig` function can reuse `EnsureContainerUseCase`, +`managed_container_labels`, the orphan-GC pass, and the startup TCP probe +as-is. Same `BRAIN3_MANAGED_LABEL_KEY`/`BRAIN3_ROLE_LABEL_KEY` labeling scheme, +but `role` becomes e.g. `mcp-extra` (or `mcp-extra:{name}`) instead of `mcp`, +so orphan GC and `list_managed_containers` can still tell core vs. extra +containers apart per installation. + +Startup sequence in `bootstrap.rs`: +1. Ensure core container (unchanged). +2. Load `mcp_containers.yaml` (if present). +3. For each entry, `ensure_mcp_container`-equivalent call. On error: log and + drop that entry from the "live" set — do not abort gateway startup. +4. Build the merged tool-routing table (see below) only from containers that + are actually up. +5. Register `stop`s for all successfully-started extra containers in the + same shutdown path that stops the core container. + +## Tool aggregation / routing (the hard part) + +Today `McpRouterUseCase` (`crates/core/src/application/mcp_router.rs`) only +knows two tool sources: +- **native tools** (in-process Rust, e.g. whisper transcription) — schemas + appended to `tools/list`, `tools/call` intercepted by exact name match. +- **the proxy** — a single upstream URL (the vault container), everything + else falls through to it. + +Extra containers need a **third kind of source**: another MCP server reached +over HTTP, just like the proxy target, but there can be N of them and their +schemas must be merged into `tools/list` the same way native tool schemas +already are. + +Proposed approach — generalize instead of special-casing per container: + +- New port trait (or reuse `McpProxyPort` per container instance) — + `RemoteMcpContainerClient`: does `initialize`, `tools/list`, `tools/call` + against one container's `http://:/mcp`, attaching the bearer + token header if configured. Same JSON-RPC shape the existing + `ProxyMcpUseCase` already round-trips. +- On gateway startup (after each extra container passes its TCP-readiness + probe), call `initialize` + `tools/list` once per container, cache the + resulting tool schemas in memory (mirrors what `NativeMcpToolRegistry` + already does for native tools — just fetched over HTTP instead of built + in-process). +- **Tool name collisions**: prefix every extra-container tool name with its + container name, e.g. `fluensy_learn__search_deck` — always, not just on + collision. Rationale: per AGENTS.MD, the AI composing tool calls is smart + but limited on ambiguity; a stable, predictable prefix costs nothing and + avoids silent shadowing if two containers both expose e.g. `search`. Native + tools and the core vault tools keep their current unprefixed names + (unchanged behavior for existing users). +- `McpRouterUseCase::route_request` grows a third branch: on `tools/call`, + after checking native tools, check whether the name matches + `{container_name}__` for any live extra container; if so, forward to that + container's client with the prefix stripped, same pattern as + `maybe_call_native_tool`. On `tools/list`, append each live container's + cached (prefixed) schemas the same way `append_native_tool_schemas` already + does — this generalizes naturally to "append schemas from every registered + tool source," so native tools and extra-container tools can likely share + one accumulation code path. +- Tool-list caching means an extra container's tools are frozen at gateway + startup; a container that changes its own tool set requires a gateway + restart to pick up. Fine for a prototyping feature — flag as a known + limitation, not solved here. + +## Networking + +Reuse exactly what the core container already does per platform — same +`ContainerNetworkIsolationStrategy` (`DiscoverContainerIp` for Docker, +`PublishToLoopback` for macOS containers) via the same `EnsureContainerUseCase`. +No new isolation concept needed; extra containers join the same +`brain3-mcp-net` internal network as the core container, keeping them off +the host network by default, consistent with the existing threat model. + +## Required: update SECURITY_AUDIT.MD threat model + +Per AGENTS.MD, any new ingress needs a threat-model update before landing. +This feature is a new ingress point: it lets a human with filesystem access +to `/mcp_containers.yaml` cause the gateway to `docker run` +arbitrary images, mount arbitrary host directories into them, and forward +their tool output straight into the AI's context (which the AI then acts on +with the same trust as vault tools). Needs at minimum: +- A new "Assets"/"Attacker Capabilities" note: anyone who can write + `mcp_containers.yaml` or the referenced image can execute arbitrary code + with Docker-level access to the mounted `host_directory`. +- Explicit statement that this is opt-in, local-file-only (no remote/API way + to add a container), and Experimental/undocumented by design in phase 1. +- Note that extra-container tool output is not sandboxed or vetted before + being appended to `tools/list` / returned from `tools/call` — same trust + level as the vault container's tools today. + +## Open questions for you (before implementation starts) + +1. **Host port**: auto-pick a free loopback port (proposed above) vs. let the + user pin it in YAML like they do for the vault container's `host_port`? + Auto-pick is simpler and avoids collisions across multiple extra + containers, but is less predictable if you want to `curl` the container + directly while developing it. +2. **Bearer header name**: reuse the existing convention + (`x-brain3-upstream-secret`) so extra containers can share code/tooling + with the vault container, or use the more standard `Authorization: Bearer + ` since these are arbitrary third-party-ish MCP servers (e.g. your + own fluensy_learn one) that might expect the conventional header if you + ever run them outside brain3 too? +3. **Container path for the mounted directory and secret file**: proposed + `/data` and `/run/secrets/mcp_bearer_token` — fine as fixed conventions, + or do you want these configurable per container too? +4. **YAML crate choice**: `serde_yml` vs `serde_norway` vs something else — + no strong preference from the codebase today, pick at implementation + time. +5. **Role label value** for orphan-GC scoping (`mcp-extra` vs + `mcp-extra:{name}`) — affects how `list_managed_containers` filters extra + containers from the core one; needs a concrete value before + `managed_container_labels`-equivalent code is written. + +## File/module summary + +- `crates/core/src/domain/model.rs` — add `ExtraMcpContainerConfig`, + `ExtraMcpContainerAuth`. +- `crates/platform/src/config/mcp_containers_config.rs` — new, YAML loader + + validation. +- `crates/platform/src/container/startup.rs` — add + `build_extra_container_config`, `ensure_extra_mcp_container`, + `stop_extra_mcp_container`, extend orphan-GC role scoping. +- `crates/core/src/application/` — new `remote_mcp_container_client.rs` + (port trait) or extend `mcp_proxy.rs`; extend `mcp_router.rs` to route + through a list of extra-container tool sources alongside native tools. +- `crates/platform/src/runtime/bootstrap.rs` — load config, ensure extra + containers, wire their clients into the router, register shutdown. +- `SECURITY_AUDIT.MD` — Threat Model section update (required, see above). +- `README.md` — one short "Experimental" section pointing at the YAML file + and schema, explicitly marked unsupported/subject to change. + +No setup wizard, TUI, or `.env`-migration work in this phase. From 4c56d4de1af931649ae7df7088c295abef0f61ea Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 16:39:31 +0200 Subject: [PATCH 02/12] v2 plan --- .../2026-07-09-extra-mcp-containers-config.md | 208 +++++++++++------- 1 file changed, 124 insertions(+), 84 deletions(-) diff --git a/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md b/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md index 55aaa10..b73457b 100644 --- a/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md +++ b/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md @@ -31,8 +31,9 @@ to start, log an error and continue running with whatever did come up. ## Config file - New optional file: `/mcp_containers.yaml` (next to the existing - `.env`). Absence of the file is the default, normal state — nothing changes - for existing users. + `.env`). `app_home` defaults to `~/.brain3` (overridable via `B3_HOME`), so + in the common case this is `~/.brain3/mcp_containers.yaml`. Absence of the + file is the default, normal state — nothing changes for existing users. - Loaded once at gateway startup, after the core container is ensured and before the HTTP server starts accepting MCP traffic (tool list must be complete before `initialize`/`tools/list` can be served correctly). @@ -45,15 +46,31 @@ to start, log an error and continue running with whatever did come up. ```yaml # mcp_containers.yaml — EXPERIMENTAL, undocumented outside README "Experimental" section. mcp_containers: - - name: fluensy_learn + - name: fluensy_learn # must be snake_case: lowercase letters, digits, underscores only platform: docker # docker | macos_container image: ghcr.io/example/fluensy-learn tag: latest port: 8420 # port the container's MCP server listens on inside the container + + # Host port the gateway binds on the loopback interface to reach this + # container. Optional — if omitted, the gateway auto-picks a free port. + # Set this only if you want a stable, predictable port for local + # debugging (e.g. curling the container directly). + # host_port: 18420 + host_directory: /Users/tleyden/fluensy-data # single dir, mounted read-write + + # Container path the host_directory is mounted at. Optional — defaults + # to /data if omitted. + # container_directory: /data + auth: type: bearer_token # none | bearer_token secret_file: /Users/tleyden/.brain3/secrets/fluensy_learn.token + + # Container path the secret_file is mounted at (read-only). Optional — + # defaults to /run/secrets/mcp_bearer_token if omitted. + # secret_mount_path: /run/secrets/mcp_bearer_token ``` - Root key `mcp_containers` is a list so the same file can later carry the @@ -67,33 +84,40 @@ mcp_containers: `MacOSContainer`) — no new runtime concept. - `image` + `tag` are split (not one string) so we can validate/log them separately and because that's how most registry tooling models it. -- `port`: single container-listen port. Host port is **not** user-specified — - the gateway auto-picks a free loopback port the same way it would for any - ad hoc port mapping, to avoid asking the user to manage a second port - number and to avoid collisions between multiple extra containers. (Open - question below if you'd rather make host port explicit too.) -- `host_directory`: one path, bind-mounted read-write at a fixed, documented - container path (proposal: `/data`). No multi-mount config in phase 1 — - confirmed out of scope per your notes (SQLite DB + scratch markdown dir - both fit under one root). -- `auth.type`: `none` or `bearer_token`. This mirrors what the vault - container already does today (`B3_UPSTREAM_SHARED_SECRET` / - `x-brain3-upstream-secret` header) — same trust model, just formalized as a - named choice instead of being implicit. Confirmed by a quick check of - current MCP ecosystem practice: full OAuth 2.1 is the standard only when a - server must authenticate *third-party* clients; for a private - gateway-to-container hop where brain3 is the only caller, a static bearer - token is the standard, simpler pattern (e.g. Docker's own MCP gateway - writeups, mcp-auth.dev bearer-auth docs). OAuth is explicitly not an option - here, consistent with your instinct. +- `port`: single container-listen port (required). +- `host_port`: **optional**. If omitted, the gateway auto-picks a free + loopback port at startup (same probe-and-bind approach used elsewhere for + ad hoc ports). Set it explicitly only if you want a stable port for local + debugging. YAML comments in the schema explain the auto-pick default. +- `host_directory`: one path, bind-mounted read-write into the container. + No multi-mount config in phase 1 — confirmed out of scope (SQLite DB + + scratch markdown dir both fit under one root). +- `container_directory`: **optional**, defaults to `/data` if omitted. Lets + a container that has its own baked-in path expectation override the + default instead of being forced to conform. +- `auth.type`: `none` or `bearer_token`. This mirrors the trust model the + vault container already uses today (a shared secret presented on every + call) — just formalized as a named choice instead of being implicit. + Confirmed by a quick check of current MCP ecosystem practice: full OAuth + 2.1 is the standard only when a server must authenticate *third-party* + clients; for a private gateway-to-container hop, a static bearer token is + the standard, simpler pattern (e.g. Docker's own MCP gateway writeups, + mcp-auth.dev bearer-auth docs). +- **Header**: use the standard `Authorization: Bearer ` header (not + the vault container's custom `x-brain3-upstream-secret`). Decision driver: + extra containers are meant to be drop-in, often third-party or + not-maintained-by-us MCP servers, so the gateway must speak the header + convention those servers already expect out of the box rather than + requiring them to special-case brain3's internal header. - `auth.secret_file`: path to a file on the host containing the raw bearer - token. Brain3 mounts it **read-only** into the container at a fixed path - (proposal: `/run/secrets/mcp_bearer_token`) and does *not* pass it as an - env var — matches your "mount it, don't env-var it" preference and is more - in line with how Docker/K8s secrets are conventionally delivered. The - gateway itself reads the same file from the host to know what token to - send as `Authorization: Bearer ` (or whatever header the container - expects — see open question) when calling the container's MCP endpoint. + token. Brain3 mounts it **read-only** into the container (path controlled + by `secret_mount_path`, see below) and does *not* pass it as an env var. + The gateway itself reads the same file from the host to know what token to + send as `Authorization: Bearer ` when calling the container's MCP + endpoint. +- `auth.secret_mount_path`: **optional**, defaults to + `/run/secrets/mcp_bearer_token` if omitted. Override when the container + image expects its token at a specific baked-in path. ## Domain model changes (`crates/core/src/domain/model.rs`) @@ -101,20 +125,30 @@ Add, alongside the existing `ContainerStartupConfig`: ```rust pub struct ExtraMcpContainerConfig { - pub name: String, + pub name: String, // validated snake_case: [a-z0-9_]+ pub runtime: ContainerRuntime, - pub image: String, // "image:tag" already joined + pub image: String, // "image:tag" already joined pub container_port: u16, + pub host_port: Option, // None => gateway auto-picks a free loopback port pub host_directory: PathBuf, + pub container_directory: PathBuf, // defaults to "/data" when not set in YAML pub auth: ExtraMcpContainerAuth, } pub enum ExtraMcpContainerAuth { None, - BearerToken { secret_file: PathBuf }, + BearerToken { + secret_file: PathBuf, + secret_mount_path: PathBuf, // defaults to "/run/secrets/mcp_bearer_token" + }, } ``` +`name` must pass a `[a-z0-9_]+` check at config-load time — same character +set as the tool-name prefix (see Tool aggregation below), so a container name +that fails this check is rejected with a clear error rather than silently +producing a malformed tool name later. + These are intentionally *not* the same struct as `ContainerStartupConfig` (that one carries vault-specific fields like `vault_path`, `enable_sync_reindex_tool`). Both should build a `ContainerConfig` (the @@ -128,11 +162,16 @@ would just grow a pile of `Option`s no one else uses. - New module `mcp_containers_config.rs`: parses the YAML file into `Vec` (or an empty vec if the file doesn't exist). -- Needs a YAML crate — none is currently a dependency. `serde_yaml` is - unmaintained upstream; recommend `serde_yml` or `serde_norway` (active - forks) — pick one during implementation, not a phase-1 blocker either way. +- YAML crate: **`serde-saphyr`** + (https://github.com/bourumir-wyngs/serde-saphyr) — a strongly typed, + deserialize-only YAML parser that decodes directly into Rust types (no + intermediate `serde_yaml::Value`-style tree), panic-free on malformed + input, no unsafe code, actively maintained. We only ever read this config + (never write it back), so deserialize-only is sufficient — no need for a + round-trip-capable crate like `serde_yaml`/forks. - Validation at load time (best-effort, one bad entry doesn't kill the rest): - unique `name` across entries + - `name` matches `[a-z0-9_]+` (snake_case; also the tool-prefix charset) - `host_directory` exists and is a directory - `auth.secret_file` exists and is readable, if `bearer_token` - Any entry that fails validation: log `tracing::error!` with the @@ -151,10 +190,11 @@ is the only vault-specific piece) so a second, small `build_extra_container_config(&ExtraMcpContainerConfig, installation_id) -> ContainerConfig` function can reuse `EnsureContainerUseCase`, `managed_container_labels`, the orphan-GC pass, and the startup TCP probe -as-is. Same `BRAIN3_MANAGED_LABEL_KEY`/`BRAIN3_ROLE_LABEL_KEY` labeling scheme, -but `role` becomes e.g. `mcp-extra` (or `mcp-extra:{name}`) instead of `mcp`, -so orphan GC and `list_managed_containers` can still tell core vs. extra -containers apart per installation. +as-is. Same `BRAIN3_MANAGED_LABEL_KEY`/`BRAIN3_ROLE_LABEL_KEY` labeling scheme, but +the role label value becomes `brain3-mcp-extra:{name}` (e.g. +`brain3-mcp-extra:fluensy_learn`) instead of `mcp`, so orphan GC and +`list_managed_containers` can tell core vs. extra containers apart, and tell +different extra containers apart from each other, per installation. Startup sequence in `bootstrap.rs`: 1. Ensure core container (unchanged). @@ -182,32 +222,45 @@ already are. Proposed approach — generalize instead of special-casing per container: -- New port trait (or reuse `McpProxyPort` per container instance) — - `RemoteMcpContainerClient`: does `initialize`, `tools/list`, `tools/call` - against one container's `http://:/mcp`, attaching the bearer - token header if configured. Same JSON-RPC shape the existing - `ProxyMcpUseCase` already round-trips. +- One new type, `RemoteMcpContainerClient`: does `initialize`, `tools/list`, + `tools/call` against a single container's `http://:/mcp`, + attaching `Authorization: Bearer ` if configured. Same JSON-RPC + shape the existing `ProxyMcpUseCase` already round-trips. This is not a + new HTTP library or a separate client-per-request thing — it's one + lightweight **struct**, and the gateway holds **one instance of it per + configured extra container** (so N extra containers ⇒ N client instances, + each pointed at its own host/port/token). Conceptually identical to how + `ProxyMcpUseCase` already holds one instance for the core container's + upstream URL — this is just that same shape, made instantiable per + container instead of hardcoded to one upstream. - On gateway startup (after each extra container passes its TCP-readiness - probe), call `initialize` + `tools/list` once per container, cache the - resulting tool schemas in memory (mirrors what `NativeMcpToolRegistry` + probe), call `initialize` + `tools/list` once per client instance, cache + the resulting tool schemas in memory (mirrors what `NativeMcpToolRegistry` already does for native tools — just fetched over HTTP instead of built in-process). -- **Tool name collisions**: prefix every extra-container tool name with its - container name, e.g. `fluensy_learn__search_deck` — always, not just on - collision. Rationale: per AGENTS.MD, the AI composing tool calls is smart - but limited on ambiguity; a stable, predictable prefix costs nothing and - avoids silent shadowing if two containers both expose e.g. `search`. Native - tools and the core vault tools keep their current unprefixed names - (unchanged behavior for existing users). +- **Tool name collisions, and scope of the change**: prefix every + extra-container tool name with its container name and a `__` separator, + e.g. `fluensy_learn__search_deck` — always, not just on collision. + Both the container `name` (validated `[a-z0-9_]+`, see domain model above) + and MCP tool names are conventionally already snake_case, so + `{name}__{tool_name}` stays entirely lowercase/underscore — no case + conversion needed, just validate the container name's charset at load time + so a bad name can't produce a malformed prefix. **This prefixing applies + only to extra-container tools.** Native tools (whisper transcription etc.) + and the core vault container's tools are completely untouched — same + unprefixed names, same code paths, zero behavior change for existing + users. The only new code is additive: a third source feeding into the same + merge point. - `McpRouterUseCase::route_request` grows a third branch: on `tools/call`, - after checking native tools, check whether the name matches - `{container_name}__` for any live extra container; if so, forward to that - container's client with the prefix stripped, same pattern as - `maybe_call_native_tool`. On `tools/list`, append each live container's - cached (prefixed) schemas the same way `append_native_tool_schemas` already - does — this generalizes naturally to "append schemas from every registered - tool source," so native tools and extra-container tools can likely share - one accumulation code path. + after checking native tools (existing, unchanged), check whether the name + starts with `{container_name}__` for any live extra container; if so, + forward to that container's client instance with the prefix stripped, same + pattern as `maybe_call_native_tool`. On `tools/list`, append each live + container's cached (prefixed) schemas the same way + `append_native_tool_schemas` already does — this generalizes naturally to + "append schemas from every registered tool source." The native-tool + accumulation code itself does not need to change; extra-container schemas + are appended alongside it, not merged into it. - Tool-list caching means an extra container's tools are frozen at gateway startup; a container that changes its own tool set requires a gateway restart to pick up. Fine for a prototyping feature — flag as a known @@ -239,29 +292,16 @@ with the same trust as vault tools). Needs at minimum: being appended to `tools/list` / returned from `tools/call` — same trust level as the vault container's tools today. -## Open questions for you (before implementation starts) - -1. **Host port**: auto-pick a free loopback port (proposed above) vs. let the - user pin it in YAML like they do for the vault container's `host_port`? - Auto-pick is simpler and avoids collisions across multiple extra - containers, but is less predictable if you want to `curl` the container - directly while developing it. -2. **Bearer header name**: reuse the existing convention - (`x-brain3-upstream-secret`) so extra containers can share code/tooling - with the vault container, or use the more standard `Authorization: Bearer - ` since these are arbitrary third-party-ish MCP servers (e.g. your - own fluensy_learn one) that might expect the conventional header if you - ever run them outside brain3 too? -3. **Container path for the mounted directory and secret file**: proposed - `/data` and `/run/secrets/mcp_bearer_token` — fine as fixed conventions, - or do you want these configurable per container too? -4. **YAML crate choice**: `serde_yml` vs `serde_norway` vs something else — - no strong preference from the codebase today, pick at implementation - time. -5. **Role label value** for orphan-GC scoping (`mcp-extra` vs - `mcp-extra:{name}`) — affects how `list_managed_containers` filters extra - containers from the core one; needs a concrete value before - `managed_container_labels`-equivalent code is written. +## Decisions locked in (previously open questions) + +1. **Host port**: optional; auto-pick a free loopback port when omitted. +2. **Auth header**: standard `Authorization: Bearer ` — required so + drop-in, not-maintained-by-us MCP server images work without modification. +3. **Container mount paths**: `container_directory` (default `/data`) and + `auth.secret_mount_path` (default `/run/secrets/mcp_bearer_token`) are + both optionally overridable per container. +4. **YAML crate**: `serde-saphyr`. +5. **Role label value**: `brain3-mcp-extra:{name}`. ## File/module summary From 59ea0967fd03433c7677d7cc03982e70e7406530 Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 16:42:45 +0200 Subject: [PATCH 03/12] v3 plan --- .../2026-07-09-extra-mcp-containers-config.md | 408 ++++++++++++------ 1 file changed, 278 insertions(+), 130 deletions(-) diff --git a/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md b/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md index b73457b..77a9889 100644 --- a/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md +++ b/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md @@ -13,7 +13,7 @@ This is a prototyping/dogfooding feature ("secret agent zoo" per the user). Keep it best-effort and low-ceremony: if a container is misconfigured or fails to start, log an error and continue running with whatever did come up. -## Non-goals (phase 1) +## Non-goals (all phases) - No setup-wizard integration. No TUI screen. Config is a hand-authored YAML file the user places manually. @@ -26,20 +26,58 @@ to start, log an error and continue running with whatever did come up. the schema and code structured so that migration is easy later, not doing the migration itself. - Not moving `.env` into YAML wholesale. Just reserve the top-level shape so - that's a additive, non-breaking change later. + that's an additive, non-breaking change later. -## Config file +## Decisions locked in + +1. **Host port**: optional; auto-pick a free loopback port when omitted. +2. **Auth header**: standard `Authorization: Bearer ` — required so + drop-in, not-maintained-by-us MCP server images work without modification. +3. **Container mount paths**: `container_directory` (default `/data`) and + `auth.secret_mount_path` (default `/run/secrets/mcp_bearer_token`) are + both optionally overridable per container. +4. **YAML crate**: `serde-saphyr` (https://github.com/bourumir-wyngs/serde-saphyr) + — strongly typed, deserialize-only, decodes straight into Rust types with + no intermediate `Value` tree, panic-free on malformed input, no unsafe + code. We only ever read this config, never write it back, so + deserialize-only is sufficient. +5. **Role label value**: `brain3-mcp-extra:{name}`. + +## How this is broken into phases + +Each phase below is a self-contained, independently mergeable unit of work — +do them in order, and stop after any phase to check in. Nothing in an earlier +phase depends on a later one being done. Roughly: + +- **Phase 1** — config schema, domain model, YAML loader. No runtime effect; + purely parsing + validation, unit-testable in isolation. +- **Phase 2** — container lifecycle: actually `docker run`/stop extra + containers on gateway startup/shutdown. This is the phase that introduces + the new ingress, so it's also where the required SECURITY_AUDIT.MD update + happens. +- **Phase 3** — tool aggregation/routing: extra containers' tools become + visible and callable through the gateway's MCP endpoint. +- **Phase 4** — README "Experimental" section. +- **Phase 5** (final) — end-to-end test proving the whole pipeline works + against a real (test-only) container image. + +No TDD requirement for any phase — write tests where they're cheap and +valuable (config parsing is a good unit-test target; the E2E test in Phase 5 +is the main correctness gate for the feature as a whole). + +--- + +## Phase 1 — Config schema, domain model, YAML loader + +### Config file - New optional file: `/mcp_containers.yaml` (next to the existing `.env`). `app_home` defaults to `~/.brain3` (overridable via `B3_HOME`), so in the common case this is `~/.brain3/mcp_containers.yaml`. Absence of the file is the default, normal state — nothing changes for existing users. -- Loaded once at gateway startup, after the core container is ensured and - before the HTTP server starts accepting MCP traffic (tool list must be - complete before `initialize`/`tools/list` can be served correctly). -- Parse failures or per-container startup failures are logged at `error` and - that container is skipped. A malformed YAML file does not crash the - gateway — it behaves as if the file were absent, but loudly logs why. +- Parse failures or per-entry validation failures are logged at `error` and + that entry is skipped. A malformed YAML file does not crash the gateway — + it behaves as if the file were absent, but loudly logs why. ### Schema (forward-compatible root shape) @@ -86,42 +124,34 @@ mcp_containers: separately and because that's how most registry tooling models it. - `port`: single container-listen port (required). - `host_port`: **optional**. If omitted, the gateway auto-picks a free - loopback port at startup (same probe-and-bind approach used elsewhere for - ad hoc ports). Set it explicitly only if you want a stable port for local - debugging. YAML comments in the schema explain the auto-pick default. + loopback port at startup. Set it explicitly only if you want a stable port + for local debugging. - `host_directory`: one path, bind-mounted read-write into the container. No multi-mount config in phase 1 — confirmed out of scope (SQLite DB + scratch markdown dir both fit under one root). -- `container_directory`: **optional**, defaults to `/data` if omitted. Lets - a container that has its own baked-in path expectation override the - default instead of being forced to conform. -- `auth.type`: `none` or `bearer_token`. This mirrors the trust model the - vault container already uses today (a shared secret presented on every - call) — just formalized as a named choice instead of being implicit. - Confirmed by a quick check of current MCP ecosystem practice: full OAuth - 2.1 is the standard only when a server must authenticate *third-party* - clients; for a private gateway-to-container hop, a static bearer token is - the standard, simpler pattern (e.g. Docker's own MCP gateway writeups, - mcp-auth.dev bearer-auth docs). +- `container_directory`: **optional**, defaults to `/data` if omitted. +- `auth.type`: `none` or `bearer_token`. Mirrors the trust model the vault + container already uses today (a shared secret presented on every call) — + just formalized as a named choice instead of being implicit. Confirmed by a + quick check of current MCP ecosystem practice: full OAuth 2.1 is the + standard only when a server must authenticate *third-party* clients; for a + private gateway-to-container hop, a static bearer token is the standard, + simpler pattern. - **Header**: use the standard `Authorization: Bearer ` header (not the vault container's custom `x-brain3-upstream-secret`). Decision driver: extra containers are meant to be drop-in, often third-party or not-maintained-by-us MCP servers, so the gateway must speak the header - convention those servers already expect out of the box rather than - requiring them to special-case brain3's internal header. + convention those servers already expect rather than requiring them to + special-case brain3's internal header. - `auth.secret_file`: path to a file on the host containing the raw bearer token. Brain3 mounts it **read-only** into the container (path controlled - by `secret_mount_path`, see below) and does *not* pass it as an env var. - The gateway itself reads the same file from the host to know what token to - send as `Authorization: Bearer ` when calling the container's MCP - endpoint. + by `secret_mount_path`) and does *not* pass it as an env var. The gateway + itself reads the same file from the host to know what token to send when + calling the container's MCP endpoint. - `auth.secret_mount_path`: **optional**, defaults to - `/run/secrets/mcp_bearer_token` if omitted. Override when the container - image expects its token at a specific baked-in path. + `/run/secrets/mcp_bearer_token` if omitted. -## Domain model changes (`crates/core/src/domain/model.rs`) - -Add, alongside the existing `ContainerStartupConfig`: +### Domain model (`crates/core/src/domain/model.rs`) ```rust pub struct ExtraMcpContainerConfig { @@ -145,9 +175,9 @@ pub enum ExtraMcpContainerAuth { ``` `name` must pass a `[a-z0-9_]+` check at config-load time — same character -set as the tool-name prefix (see Tool aggregation below), so a container name -that fails this check is rejected with a clear error rather than silently -producing a malformed tool name later. +set as the Phase 3 tool-name prefix — so a container name that fails this +check is rejected with a clear error rather than silently producing a +malformed tool name later. These are intentionally *not* the same struct as `ContainerStartupConfig` (that one carries vault-specific fields like `vault_path`, @@ -158,55 +188,103 @@ small builder function, same pattern as `build_container_config` in the vault container has enough special-cased fields that a shared struct would just grow a pile of `Option`s no one else uses. -## Config loading (`crates/platform/src/config/`) - -- New module `mcp_containers_config.rs`: parses the YAML file into - `Vec` (or an empty vec if the file doesn't exist). -- YAML crate: **`serde-saphyr`** - (https://github.com/bourumir-wyngs/serde-saphyr) — a strongly typed, - deserialize-only YAML parser that decodes directly into Rust types (no - intermediate `serde_yaml::Value`-style tree), panic-free on malformed - input, no unsafe code, actively maintained. We only ever read this config - (never write it back), so deserialize-only is sufficient — no need for a - round-trip-capable crate like `serde_yaml`/forks. +### Config loading (`crates/platform/src/config/mcp_containers_config.rs`) + +- New module: parses the YAML file into `Vec` (or an + empty vec if the file doesn't exist), using `serde-saphyr`. - Validation at load time (best-effort, one bad entry doesn't kill the rest): - unique `name` across entries - - `name` matches `[a-z0-9_]+` (snake_case; also the tool-prefix charset) + - `name` matches `[a-z0-9_]+` - `host_directory` exists and is a directory - `auth.secret_file` exists and is readable, if `bearer_token` - Any entry that fails validation: log `tracing::error!` with the container name and reason, skip it, keep going. -## Lifecycle (reuse, don't reinvent) +### Phase 1 exit criteria + +- Unit tests cover: file absent → empty vec; valid multi-entry file; missing + file for a `bearer_token` entry → that entry dropped, others kept; + duplicate `name` → later duplicate dropped; bad `name` charset → dropped; + malformed YAML → whole file treated as absent, error logged. +- No wiring into `bootstrap.rs` yet — this phase does not start any + containers. + +--- + +## Phase 2 — Container lifecycle + +### Reuse, don't reinvent `ensure_mcp_container` / `stop_mcp_container` in `crates/platform/src/container/startup.rs` already implement: image pull-if-missing, name-conflict check, internal-network join, startup TCP probe with timeout, managed-container labels for orphan GC, logs-on-failure. -None of that is vault-specific except the env vars and vault bind mount. - -Plan: extract the runtime-agnostic parts (already mostly separated — `build_container_config` -is the only vault-specific piece) so a second, small -`build_extra_container_config(&ExtraMcpContainerConfig, installation_id) --> ContainerConfig` function can reuse `EnsureContainerUseCase`, -`managed_container_labels`, the orphan-GC pass, and the startup TCP probe -as-is. Same `BRAIN3_MANAGED_LABEL_KEY`/`BRAIN3_ROLE_LABEL_KEY` labeling scheme, but -the role label value becomes `brain3-mcp-extra:{name}` (e.g. -`brain3-mcp-extra:fluensy_learn`) instead of `mcp`, so orphan GC and -`list_managed_containers` can tell core vs. extra containers apart, and tell -different extra containers apart from each other, per installation. - -Startup sequence in `bootstrap.rs`: +None of that is vault-specific except the env vars and vault bind mount +(isolated in `build_container_config`). + +Plan: add a second, small `build_extra_container_config(&ExtraMcpContainerConfig, +installation_id) -> ContainerConfig` function that reuses +`EnsureContainerUseCase`, `managed_container_labels`, the orphan-GC pass, and +the startup TCP probe as-is. Same `BRAIN3_MANAGED_LABEL_KEY`/ +`BRAIN3_ROLE_LABEL_KEY` labeling scheme, but the role label value becomes +`brain3-mcp-extra:{name}` (e.g. `brain3-mcp-extra:fluensy_learn`) instead of +`mcp`, so orphan GC and `list_managed_containers` can tell core vs. extra +containers apart, and tell different extra containers apart from each other, +per installation. + +### Networking + +Reuse exactly what the core container already does per platform — same +`ContainerNetworkIsolationStrategy` (`DiscoverContainerIp` for Docker, +`PublishToLoopback` for macOS containers) via the same `EnsureContainerUseCase`. +No new isolation concept needed; extra containers join the same +`brain3-mcp-net` internal network as the core container, keeping them off +the host network by default, consistent with the existing threat model. + +### Startup sequence in `bootstrap.rs` + 1. Ensure core container (unchanged). -2. Load `mcp_containers.yaml` (if present). +2. Load `mcp_containers.yaml` (if present) via the Phase 1 loader. 3. For each entry, `ensure_mcp_container`-equivalent call. On error: log and drop that entry from the "live" set — do not abort gateway startup. -4. Build the merged tool-routing table (see below) only from containers that - are actually up. -5. Register `stop`s for all successfully-started extra containers in the +4. Register `stop`s for all successfully-started extra containers in the same shutdown path that stops the core container. -## Tool aggregation / routing (the hard part) +Tool routing (Phase 3) is not part of this phase — after Phase 2, extra +containers run and are reachable on their port, but the gateway does not yet +expose their tools. + +### Required: update SECURITY_AUDIT.MD threat model + +Per AGENTS.MD, any new ingress needs a threat-model update before landing. +This phase is what actually introduces the new ingress: it lets a human with +filesystem access to `/mcp_containers.yaml` cause the gateway to +`docker run` arbitrary images and mount arbitrary host directories into +them. Needs at minimum: +- A new "Assets"/"Attacker Capabilities" note: anyone who can write + `mcp_containers.yaml` or the referenced image can execute arbitrary code + with Docker-level access to the mounted `host_directory`. +- Explicit statement that this is opt-in, local-file-only (no remote/API way + to add a container), and Experimental/undocumented by design. +- Note (can be added now even though tool output flows in Phase 3) that + extra-container tool output is not sandboxed or vetted before being + appended to `tools/list` / returned from `tools/call` — same trust level as + the vault container's tools today. + +### Phase 2 exit criteria + +- With a valid `mcp_containers.yaml` present, `docker ps` shows the extra + container running alongside the core container after gateway startup, with + the `brain3-mcp-extra:{name}` role label. +- A misconfigured/unreachable extra container logs an error and the gateway + still starts and serves the core vault tools normally. +- Gateway shutdown stops/removes the extra container the same way it does + the core container. +- SECURITY_AUDIT.MD updated. + +--- + +## Phase 3 — Tool aggregation / routing Today `McpRouterUseCase` (`crates/core/src/application/mcp_router.rs`) only knows two tool sources: @@ -233,92 +311,162 @@ Proposed approach — generalize instead of special-casing per container: `ProxyMcpUseCase` already holds one instance for the core container's upstream URL — this is just that same shape, made instantiable per container instead of hardcoded to one upstream. -- On gateway startup (after each extra container passes its TCP-readiness - probe), call `initialize` + `tools/list` once per client instance, cache - the resulting tool schemas in memory (mirrors what `NativeMcpToolRegistry` - already does for native tools — just fetched over HTTP instead of built - in-process). +- On gateway startup (after each extra container passes its Phase-2 + TCP-readiness probe), call `initialize` + `tools/list` once per client + instance, cache the resulting tool schemas in memory (mirrors what + `NativeMcpToolRegistry` already does for native tools — just fetched over + HTTP instead of built in-process). - **Tool name collisions, and scope of the change**: prefix every extra-container tool name with its container name and a `__` separator, e.g. `fluensy_learn__search_deck` — always, not just on collision. - Both the container `name` (validated `[a-z0-9_]+`, see domain model above) - and MCP tool names are conventionally already snake_case, so - `{name}__{tool_name}` stays entirely lowercase/underscore — no case - conversion needed, just validate the container name's charset at load time - so a bad name can't produce a malformed prefix. **This prefixing applies - only to extra-container tools.** Native tools (whisper transcription etc.) - and the core vault container's tools are completely untouched — same - unprefixed names, same code paths, zero behavior change for existing - users. The only new code is additive: a third source feeding into the same - merge point. + Both the container `name` (validated `[a-z0-9_]+`, Phase 1) and MCP tool + names are conventionally already snake_case, so `{name}__{tool_name}` + stays entirely lowercase/underscore — no case conversion needed. + **This prefixing applies only to extra-container tools.** Native tools + (whisper transcription etc.) and the core vault container's tools are + completely untouched — same unprefixed names, same code paths, zero + behavior change for existing users. The only new code is additive: a third + source feeding into the same merge point. - `McpRouterUseCase::route_request` grows a third branch: on `tools/call`, after checking native tools (existing, unchanged), check whether the name starts with `{container_name}__` for any live extra container; if so, forward to that container's client instance with the prefix stripped, same pattern as `maybe_call_native_tool`. On `tools/list`, append each live container's cached (prefixed) schemas the same way - `append_native_tool_schemas` already does — this generalizes naturally to - "append schemas from every registered tool source." The native-tool - accumulation code itself does not need to change; extra-container schemas - are appended alongside it, not merged into it. + `append_native_tool_schemas` already does. The native-tool accumulation + code itself does not need to change; extra-container schemas are appended + alongside it, not merged into it. - Tool-list caching means an extra container's tools are frozen at gateway startup; a container that changes its own tool set requires a gateway restart to pick up. Fine for a prototyping feature — flag as a known limitation, not solved here. -## Networking - -Reuse exactly what the core container already does per platform — same -`ContainerNetworkIsolationStrategy` (`DiscoverContainerIp` for Docker, -`PublishToLoopback` for macOS containers) via the same `EnsureContainerUseCase`. -No new isolation concept needed; extra containers join the same -`brain3-mcp-net` internal network as the core container, keeping them off -the host network by default, consistent with the existing threat model. +### Phase 3 exit criteria -## Required: update SECURITY_AUDIT.MD threat model +- With Phase 2's extra container running, an MCP client's `tools/list` call + against the gateway includes the extra container's tools under their + prefixed names, alongside the unchanged native and vault tool names. +- A `tools/call` for a prefixed extra-container tool name round-trips to the + right container and returns its result. +- Existing vault/native tool behavior is provably unchanged (existing unit + tests in `mcp_router.rs` still pass unmodified). -Per AGENTS.MD, any new ingress needs a threat-model update before landing. -This feature is a new ingress point: it lets a human with filesystem access -to `/mcp_containers.yaml` cause the gateway to `docker run` -arbitrary images, mount arbitrary host directories into them, and forward -their tool output straight into the AI's context (which the AI then acts on -with the same trust as vault tools). Needs at minimum: -- A new "Assets"/"Attacker Capabilities" note: anyone who can write - `mcp_containers.yaml` or the referenced image can execute arbitrary code - with Docker-level access to the mounted `host_directory`. -- Explicit statement that this is opt-in, local-file-only (no remote/API way - to add a container), and Experimental/undocumented by design in phase 1. -- Note that extra-container tool output is not sandboxed or vetted before - being appended to `tools/list` / returned from `tools/call` — same trust - level as the vault container's tools today. +--- -## Decisions locked in (previously open questions) +## Phase 4 — Documentation -1. **Host port**: optional; auto-pick a free loopback port when omitted. -2. **Auth header**: standard `Authorization: Bearer ` — required so - drop-in, not-maintained-by-us MCP server images work without modification. -3. **Container mount paths**: `container_directory` (default `/data`) and - `auth.secret_mount_path` (default `/run/secrets/mcp_bearer_token`) are - both optionally overridable per container. -4. **YAML crate**: `serde-saphyr`. -5. **Role label value**: `brain3-mcp-extra:{name}`. +- `README.md` — one short "Experimental" section pointing at the YAML file + location and schema, explicitly marked unsupported/subject to change. + No setup-wizard mention, since there isn't one for this feature. + +--- + +## Phase 5 (final) — End-to-end test + +We already have an E2E harness (`apps/gateway/tests/e2e_smoke.rs`, driven by +`scripts/e2e_smoke.py`) that builds the real vault-tools Docker image and +spawns the actual `brain3` gateway binary against a temp app-home directory, +then drives it over a real MCP client. This phase adds a new, separate E2E +test that proves the whole extra-container pipeline (Phases 1–3) end to end +against a real (test-only) container — not mocks. + +Prefer a **new test function** (e.g. `e2e_smoke_5_extra_mcp_container`) +rather than folding this into `e2e_smoke_1_local_docker`, so a failure here +doesn't cloud the existing vault-tools smoke test's signal, and so it can be +skipped/run independently the same way the other numbered smoke tests can. + +### What to build + +1. **A minimal "hello world" test container image**, analogous to + `brain3-mcp-vault-tools/Containerfile` but far simpler: + - New `Containerfile` (or `Dockerfile`) under something like + `testdata/e2e_hello_mcp_container/` — a tiny Python (or whatever's + fastest to stand up) MCP-over-HTTP server exposing exactly one tool, + `hello`, taking no required arguments and returning a fixed string + (e.g. `"hello world"`). + - It must require the same `Authorization: Bearer ` auth Phase 1/3 + designed for — i.e. it validates the header itself and rejects + unauthenticated/wrong-token calls (401/403), so the test also proves + the gateway is actually sending the configured token, not just that an + unauthenticated call happens to work. + - Static test fixture, not a real product — keep it as small as possible + (no need for a real MCP SDK if a hand-rolled JSON-RPC handler for just + `initialize`/`tools/list`/`tools/call` is simpler; match whatever's + least code, this doesn't need to be a general-purpose server). + - Built by the test infrastructure the same way the vault-tools image is + built today: extend `scripts/e2e_smoke.py`'s + `docker_build_command()`-equivalent to also build this image (a second + `docker build -f .../Containerfile -t brain3-e2e-hello-mcp:e2e-local + testdata/e2e_hello_mcp_container`), and add the new test name to + `DEFAULT_E2E_TESTS`. This keeps the "build image, then run test" shape + the harness already uses for the vault-tools container, rather than + having the Rust test shell out to `docker build` itself mid-test. + +2. **Wire it into the test's `mcp_containers.yaml`**: + - Add a `write_mcp_containers_yaml` helper to `TempTestDir` (alongside the + existing `write_env_file`), writing to `self.root.join("mcp_containers.yaml")` + — same directory `B3_HOME` already points `.env` at in these tests + (`.env("B3_HOME", &temp.root)`), consistent with the `/...` + convention from Phase 1. + - Write a bearer token to a temp secret file under `temp.root` (e.g. + `hello_mcp.token`) and reference it from the YAML's `auth.secret_file`. + - Point `image`/`tag` at the image built in step 1, `port` at whatever the + hello server listens on inside the container, and `host_directory` at a + throwaway subdirectory of `temp.root` (the tool itself doesn't need to + use it — just exercises the mount path). + +3. **Test body** (`e2e_smoke_5_extra_mcp_container`): + - `TempTestDir::create`, write `.env` (as today) *and* the new + `mcp_containers.yaml`. + - Spawn `Brain3Process` as usual. + - Connect the local MCP client (reuse `connect_local_mcp`). + - `tools/list` and assert the response includes + `hello_mcp__hello` (or whatever the container's `name` field in the + test YAML is) alongside the existing vault tool names — proves Phase 3 + merging works, not just that the container started. + - Call the prefixed tool and assert the expected `"hello world"`-style + result — proves the full round trip: gateway → bearer-token-authed + HTTP call → container → response → gateway → MCP client. + - Reuse `assert_no_container_residue()` at the end (extend it if needed to + also check the extra container is gone) to prove teardown works for + extra containers too, matching the existing core-container check. + +### Passing criteria for this phase (and for the feature as a whole) + +- `uv run scripts/e2e_smoke.py e2e_smoke_5_extra_mcp_container` (or the full + default suite) passes: the hello-world container starts, its `hello` tool + is visible in `tools/list` under its prefixed name, calling it returns the + expected result, and no container residue remains after the gateway + process exits. +- This is the overall correctness gate for the feature — treat it as + required before considering the extra-container feature "done," even + though Phases 1–4 can each be merged incrementally beforehand. + +No TDD needed here — write the container, the config wiring, and the test +together, then get it green. + +--- ## File/module summary - `crates/core/src/domain/model.rs` — add `ExtraMcpContainerConfig`, - `ExtraMcpContainerAuth`. + `ExtraMcpContainerAuth`. (Phase 1) - `crates/platform/src/config/mcp_containers_config.rs` — new, YAML loader + - validation. + validation. (Phase 1) - `crates/platform/src/container/startup.rs` — add `build_extra_container_config`, `ensure_extra_mcp_container`, - `stop_extra_mcp_container`, extend orphan-GC role scoping. + `stop_extra_mcp_container`, extend orphan-GC role scoping. (Phase 2) +- `SECURITY_AUDIT.MD` — Threat Model section update. (Phase 2) - `crates/core/src/application/` — new `remote_mcp_container_client.rs` - (port trait) or extend `mcp_proxy.rs`; extend `mcp_router.rs` to route - through a list of extra-container tool sources alongside native tools. + (or extend `mcp_proxy.rs`); extend `mcp_router.rs` to route through a list + of extra-container tool sources alongside native tools. (Phase 3) - `crates/platform/src/runtime/bootstrap.rs` — load config, ensure extra containers, wire their clients into the router, register shutdown. -- `SECURITY_AUDIT.MD` — Threat Model section update (required, see above). -- `README.md` — one short "Experimental" section pointing at the YAML file - and schema, explicitly marked unsupported/subject to change. - -No setup wizard, TUI, or `.env`-migration work in this phase. + (Phases 2–3) +- `README.md` — "Experimental" section. (Phase 4) +- `testdata/e2e_hello_mcp_container/Containerfile` — new test-only image. + (Phase 5) +- `scripts/e2e_smoke.py` — build the hello-mcp image, register the new test + name. (Phase 5) +- `apps/gateway/tests/e2e_smoke.rs` — `write_mcp_containers_yaml` helper, + new `e2e_smoke_5_extra_mcp_container` test. (Phase 5) From 4fd3a2961281128fdd8f7fa1ae9b6766808a635e Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 16:46:58 +0200 Subject: [PATCH 04/12] rename plugin --- ...026-07-09-plugin-mcp-containers-config.md} | 96 +++++++++---------- 1 file changed, 48 insertions(+), 48 deletions(-) rename docs/superpowers/plans/{2026-07-09-extra-mcp-containers-config.md => 2026-07-09-plugin-mcp-containers-config.md} (87%) diff --git a/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md b/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md similarity index 87% rename from docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md rename to docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md index 77a9889..515b04b 100644 --- a/docs/superpowers/plans/2026-07-09-extra-mcp-containers-config.md +++ b/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md @@ -1,8 +1,8 @@ -# Extra MCP Containers (Experimental, Hidden Config) +# Plugin MCP Containers (Experimental, Hidden Config) ## Goal -Let a user drop extra Docker/macOS-container MCP servers (e.g. a prototype +Let a user drop plugin Docker/macOS-container MCP servers (e.g. a prototype "fluensy_learn" container) next to the existing core vault container, purely via a hand-edited config file. No setup-wizard UI, no docs beyond a README "Experimental" section. Brain3 discovers them on startup, manages their @@ -19,7 +19,7 @@ to start, log an error and continue running with whatever did come up. file the user places manually. - No multiple bind mounts per container — one host directory, one container path. -- No OAuth between gateway and extra containers — this is an internal, +- No OAuth between gateway and plugin containers — this is an internal, gateway-only trust boundary (see Auth section). - Not folding the core vault container into this same list yet. The core container keeps its current `.env`-driven config path. This plan only adds @@ -41,7 +41,7 @@ to start, log an error and continue running with whatever did come up. no intermediate `Value` tree, panic-free on malformed input, no unsafe code. We only ever read this config, never write it back, so deserialize-only is sufficient. -5. **Role label value**: `brain3-mcp-extra:{name}`. +5. **Role label value**: `brain3-mcp-plugin:{name}`. ## How this is broken into phases @@ -51,11 +51,11 @@ phase depends on a later one being done. Roughly: - **Phase 1** — config schema, domain model, YAML loader. No runtime effect; purely parsing + validation, unit-testable in isolation. -- **Phase 2** — container lifecycle: actually `docker run`/stop extra +- **Phase 2** — container lifecycle: actually `docker run`/stop plugin containers on gateway startup/shutdown. This is the phase that introduces the new ingress, so it's also where the required SECURITY_AUDIT.MD update happens. -- **Phase 3** — tool aggregation/routing: extra containers' tools become +- **Phase 3** — tool aggregation/routing: plugin containers' tools become visible and callable through the gateway's MCP endpoint. - **Phase 4** — README "Experimental" section. - **Phase 5** (final) — end-to-end test proving the whole pipeline works @@ -139,7 +139,7 @@ mcp_containers: simpler pattern. - **Header**: use the standard `Authorization: Bearer ` header (not the vault container's custom `x-brain3-upstream-secret`). Decision driver: - extra containers are meant to be drop-in, often third-party or + plugin containers are meant to be drop-in, often third-party or not-maintained-by-us MCP servers, so the gateway must speak the header convention those servers already expect rather than requiring them to special-case brain3's internal header. @@ -154,7 +154,7 @@ mcp_containers: ### Domain model (`crates/core/src/domain/model.rs`) ```rust -pub struct ExtraMcpContainerConfig { +pub struct PluginMcpContainerConfig { pub name: String, // validated snake_case: [a-z0-9_]+ pub runtime: ContainerRuntime, pub image: String, // "image:tag" already joined @@ -162,10 +162,10 @@ pub struct ExtraMcpContainerConfig { pub host_port: Option, // None => gateway auto-picks a free loopback port pub host_directory: PathBuf, pub container_directory: PathBuf, // defaults to "/data" when not set in YAML - pub auth: ExtraMcpContainerAuth, + pub auth: PluginMcpContainerAuth, } -pub enum ExtraMcpContainerAuth { +pub enum PluginMcpContainerAuth { None, BearerToken { secret_file: PathBuf, @@ -190,7 +190,7 @@ would just grow a pile of `Option`s no one else uses. ### Config loading (`crates/platform/src/config/mcp_containers_config.rs`) -- New module: parses the YAML file into `Vec` (or an +- New module: parses the YAML file into `Vec` (or an empty vec if the file doesn't exist), using `serde-saphyr`. - Validation at load time (best-effort, one bad entry doesn't kill the rest): - unique `name` across entries @@ -222,14 +222,14 @@ probe with timeout, managed-container labels for orphan GC, logs-on-failure. None of that is vault-specific except the env vars and vault bind mount (isolated in `build_container_config`). -Plan: add a second, small `build_extra_container_config(&ExtraMcpContainerConfig, +Plan: add a second, small `build_plugin_container_config(&PluginMcpContainerConfig, installation_id) -> ContainerConfig` function that reuses `EnsureContainerUseCase`, `managed_container_labels`, the orphan-GC pass, and the startup TCP probe as-is. Same `BRAIN3_MANAGED_LABEL_KEY`/ `BRAIN3_ROLE_LABEL_KEY` labeling scheme, but the role label value becomes -`brain3-mcp-extra:{name}` (e.g. `brain3-mcp-extra:fluensy_learn`) instead of -`mcp`, so orphan GC and `list_managed_containers` can tell core vs. extra -containers apart, and tell different extra containers apart from each other, +`brain3-mcp-plugin:{name}` (e.g. `brain3-mcp-plugin:fluensy_learn`) instead of +`mcp`, so orphan GC and `list_managed_containers` can tell core vs. plugin +containers apart, and tell different plugin containers apart from each other, per installation. ### Networking @@ -237,7 +237,7 @@ per installation. Reuse exactly what the core container already does per platform — same `ContainerNetworkIsolationStrategy` (`DiscoverContainerIp` for Docker, `PublishToLoopback` for macOS containers) via the same `EnsureContainerUseCase`. -No new isolation concept needed; extra containers join the same +No new isolation concept needed; plugin containers join the same `brain3-mcp-net` internal network as the core container, keeping them off the host network by default, consistent with the existing threat model. @@ -247,10 +247,10 @@ the host network by default, consistent with the existing threat model. 2. Load `mcp_containers.yaml` (if present) via the Phase 1 loader. 3. For each entry, `ensure_mcp_container`-equivalent call. On error: log and drop that entry from the "live" set — do not abort gateway startup. -4. Register `stop`s for all successfully-started extra containers in the +4. Register `stop`s for all successfully-started plugin containers in the same shutdown path that stops the core container. -Tool routing (Phase 3) is not part of this phase — after Phase 2, extra +Tool routing (Phase 3) is not part of this phase — after Phase 2, plugin containers run and are reachable on their port, but the gateway does not yet expose their tools. @@ -267,18 +267,18 @@ them. Needs at minimum: - Explicit statement that this is opt-in, local-file-only (no remote/API way to add a container), and Experimental/undocumented by design. - Note (can be added now even though tool output flows in Phase 3) that - extra-container tool output is not sandboxed or vetted before being + plugin-container tool output is not sandboxed or vetted before being appended to `tools/list` / returned from `tools/call` — same trust level as the vault container's tools today. ### Phase 2 exit criteria -- With a valid `mcp_containers.yaml` present, `docker ps` shows the extra +- With a valid `mcp_containers.yaml` present, `docker ps` shows the plugin container running alongside the core container after gateway startup, with - the `brain3-mcp-extra:{name}` role label. -- A misconfigured/unreachable extra container logs an error and the gateway + the `brain3-mcp-plugin:{name}` role label. +- A misconfigured/unreachable plugin container logs an error and the gateway still starts and serves the core vault tools normally. -- Gateway shutdown stops/removes the extra container the same way it does +- Gateway shutdown stops/removes the plugin container the same way it does the core container. - SECURITY_AUDIT.MD updated. @@ -293,7 +293,7 @@ knows two tool sources: - **the proxy** — a single upstream URL (the vault container), everything else falls through to it. -Extra containers need a **third kind of source**: another MCP server reached +Plugin containers need a **third kind of source**: another MCP server reached over HTTP, just like the proxy target, but there can be N of them and their schemas must be merged into `tools/list` the same way native tool schemas already are. @@ -306,47 +306,47 @@ Proposed approach — generalize instead of special-casing per container: shape the existing `ProxyMcpUseCase` already round-trips. This is not a new HTTP library or a separate client-per-request thing — it's one lightweight **struct**, and the gateway holds **one instance of it per - configured extra container** (so N extra containers ⇒ N client instances, + configured plugin container** (so N plugin containers ⇒ N client instances, each pointed at its own host/port/token). Conceptually identical to how `ProxyMcpUseCase` already holds one instance for the core container's upstream URL — this is just that same shape, made instantiable per container instead of hardcoded to one upstream. -- On gateway startup (after each extra container passes its Phase-2 +- On gateway startup (after each plugin container passes its Phase-2 TCP-readiness probe), call `initialize` + `tools/list` once per client instance, cache the resulting tool schemas in memory (mirrors what `NativeMcpToolRegistry` already does for native tools — just fetched over HTTP instead of built in-process). - **Tool name collisions, and scope of the change**: prefix every - extra-container tool name with its container name and a `__` separator, + plugin-container tool name with its container name and a `__` separator, e.g. `fluensy_learn__search_deck` — always, not just on collision. Both the container `name` (validated `[a-z0-9_]+`, Phase 1) and MCP tool names are conventionally already snake_case, so `{name}__{tool_name}` stays entirely lowercase/underscore — no case conversion needed. - **This prefixing applies only to extra-container tools.** Native tools + **This prefixing applies only to plugin-container tools.** Native tools (whisper transcription etc.) and the core vault container's tools are completely untouched — same unprefixed names, same code paths, zero behavior change for existing users. The only new code is additive: a third source feeding into the same merge point. - `McpRouterUseCase::route_request` grows a third branch: on `tools/call`, after checking native tools (existing, unchanged), check whether the name - starts with `{container_name}__` for any live extra container; if so, + starts with `{container_name}__` for any live plugin container; if so, forward to that container's client instance with the prefix stripped, same pattern as `maybe_call_native_tool`. On `tools/list`, append each live container's cached (prefixed) schemas the same way `append_native_tool_schemas` already does. The native-tool accumulation - code itself does not need to change; extra-container schemas are appended + code itself does not need to change; plugin-container schemas are appended alongside it, not merged into it. -- Tool-list caching means an extra container's tools are frozen at gateway +- Tool-list caching means a plugin container's tools are frozen at gateway startup; a container that changes its own tool set requires a gateway restart to pick up. Fine for a prototyping feature — flag as a known limitation, not solved here. ### Phase 3 exit criteria -- With Phase 2's extra container running, an MCP client's `tools/list` call - against the gateway includes the extra container's tools under their +- With Phase 2's plugin container running, an MCP client's `tools/list` call + against the gateway includes the plugin container's tools under their prefixed names, alongside the unchanged native and vault tool names. -- A `tools/call` for a prefixed extra-container tool name round-trips to the +- A `tools/call` for a prefixed plugin-container tool name round-trips to the right container and returns its result. - Existing vault/native tool behavior is provably unchanged (existing unit tests in `mcp_router.rs` still pass unmodified). @@ -367,10 +367,10 @@ We already have an E2E harness (`apps/gateway/tests/e2e_smoke.rs`, driven by `scripts/e2e_smoke.py`) that builds the real vault-tools Docker image and spawns the actual `brain3` gateway binary against a temp app-home directory, then drives it over a real MCP client. This phase adds a new, separate E2E -test that proves the whole extra-container pipeline (Phases 1–3) end to end +test that proves the whole plugin-container pipeline (Phases 1–3) end to end against a real (test-only) container — not mocks. -Prefer a **new test function** (e.g. `e2e_smoke_5_extra_mcp_container`) +Prefer a **new test function** (e.g. `e2e_smoke_5_plugin_mcp_container`) rather than folding this into `e2e_smoke_1_local_docker`, so a failure here doesn't cloud the existing vault-tools smoke test's signal, and so it can be skipped/run independently the same way the other numbered smoke tests can. @@ -415,7 +415,7 @@ skipped/run independently the same way the other numbered smoke tests can. throwaway subdirectory of `temp.root` (the tool itself doesn't need to use it — just exercises the mount path). -3. **Test body** (`e2e_smoke_5_extra_mcp_container`): +3. **Test body** (`e2e_smoke_5_plugin_mcp_container`): - `TempTestDir::create`, write `.env` (as today) *and* the new `mcp_containers.yaml`. - Spawn `Brain3Process` as usual. @@ -428,18 +428,18 @@ skipped/run independently the same way the other numbered smoke tests can. result — proves the full round trip: gateway → bearer-token-authed HTTP call → container → response → gateway → MCP client. - Reuse `assert_no_container_residue()` at the end (extend it if needed to - also check the extra container is gone) to prove teardown works for - extra containers too, matching the existing core-container check. + also check the plugin container is gone) to prove teardown works for + plugin containers too, matching the existing core-container check. ### Passing criteria for this phase (and for the feature as a whole) -- `uv run scripts/e2e_smoke.py e2e_smoke_5_extra_mcp_container` (or the full +- `uv run scripts/e2e_smoke.py e2e_smoke_5_plugin_mcp_container` (or the full default suite) passes: the hello-world container starts, its `hello` tool is visible in `tools/list` under its prefixed name, calling it returns the expected result, and no container residue remains after the gateway process exits. - This is the overall correctness gate for the feature — treat it as - required before considering the extra-container feature "done," even + required before considering the plugin-container feature "done," even though Phases 1–4 can each be merged incrementally beforehand. No TDD needed here — write the container, the config wiring, and the test @@ -449,18 +449,18 @@ together, then get it green. ## File/module summary -- `crates/core/src/domain/model.rs` — add `ExtraMcpContainerConfig`, - `ExtraMcpContainerAuth`. (Phase 1) +- `crates/core/src/domain/model.rs` — add `PluginMcpContainerConfig`, + `PluginMcpContainerAuth`. (Phase 1) - `crates/platform/src/config/mcp_containers_config.rs` — new, YAML loader + validation. (Phase 1) - `crates/platform/src/container/startup.rs` — add - `build_extra_container_config`, `ensure_extra_mcp_container`, - `stop_extra_mcp_container`, extend orphan-GC role scoping. (Phase 2) + `build_plugin_container_config`, `ensure_plugin_mcp_container`, + `stop_plugin_mcp_container`, extend orphan-GC role scoping. (Phase 2) - `SECURITY_AUDIT.MD` — Threat Model section update. (Phase 2) - `crates/core/src/application/` — new `remote_mcp_container_client.rs` (or extend `mcp_proxy.rs`); extend `mcp_router.rs` to route through a list - of extra-container tool sources alongside native tools. (Phase 3) -- `crates/platform/src/runtime/bootstrap.rs` — load config, ensure extra + of plugin-container tool sources alongside native tools. (Phase 3) +- `crates/platform/src/runtime/bootstrap.rs` — load config, ensure plugin containers, wire their clients into the router, register shutdown. (Phases 2–3) - `README.md` — "Experimental" section. (Phase 4) @@ -469,4 +469,4 @@ together, then get it green. - `scripts/e2e_smoke.py` — build the hello-mcp image, register the new test name. (Phase 5) - `apps/gateway/tests/e2e_smoke.rs` — `write_mcp_containers_yaml` helper, - new `e2e_smoke_5_extra_mcp_container` test. (Phase 5) + new `e2e_smoke_5_plugin_mcp_container` test. (Phase 5) From 8ef794b6914f84f6b417c9a4a71e226a0d8be3f3 Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 16:49:59 +0200 Subject: [PATCH 05/12] rename to brain3.yaml --- ...2026-07-09-plugin-mcp-containers-config.md | 110 +++++++++++------- 1 file changed, 69 insertions(+), 41 deletions(-) diff --git a/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md b/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md index 515b04b..8ff317d 100644 --- a/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md +++ b/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md @@ -25,8 +25,10 @@ to start, log an error and continue running with whatever did come up. container keeps its current `.env`-driven config path. This plan only adds the schema and code structured so that migration is easy later, not doing the migration itself. -- Not moving `.env` into YAML wholesale. Just reserve the top-level shape so - that's an additive, non-breaking change later. +- Not moving `.env` into YAML wholesale in this plan. `brain3.yaml` (see + below) is intentionally a general-purpose, multi-section config file so + that migration is additive later, but only the `plugin_mcp_containers` + section is implemented/read now. ## Decisions locked in @@ -71,19 +73,27 @@ is the main correctness gate for the feature as a whole). ### Config file -- New optional file: `/mcp_containers.yaml` (next to the existing - `.env`). `app_home` defaults to `~/.brain3` (overridable via `B3_HOME`), so - in the common case this is `~/.brain3/mcp_containers.yaml`. Absence of the - file is the default, normal state — nothing changes for existing users. +- New optional file: `/brain3.yaml` (next to the existing `.env`). + `app_home` defaults to `~/.brain3` (overridable via `B3_HOME`), so in the + common case this is `~/.brain3/brain3.yaml`. Absence of the file is the + default, normal state — nothing changes for existing users. +- `brain3.yaml` is a **general-purpose, multi-section config file** — over + time, more of what's currently in `.env` (and new config that doesn't fit + `.env`'s flat key=value shape) is expected to move here as its own + top-level section. This plan only defines and reads one section, + `plugin_mcp_containers`; other sections are simply not present yet, and an + unrecognized/absent section is not an error. - Parse failures or per-entry validation failures are logged at `error` and that entry is skipped. A malformed YAML file does not crash the gateway — it behaves as if the file were absent, but loudly logs why. -### Schema (forward-compatible root shape) +### Schema (`plugin_mcp_containers` section of `brain3.yaml`) ```yaml -# mcp_containers.yaml — EXPERIMENTAL, undocumented outside README "Experimental" section. -mcp_containers: +# brain3.yaml — EXPERIMENTAL, undocumented outside README "Experimental" section. +# General-purpose Brain3 config file. Today only `plugin_mcp_containers` is +# read; more top-level sections will be added over time. +plugin_mcp_containers: - name: fluensy_learn # must be snake_case: lowercase letters, digits, underscores only platform: docker # docker | macos_container image: ghcr.io/example/fluensy-learn @@ -111,10 +121,11 @@ mcp_containers: # secret_mount_path: /run/secrets/mcp_bearer_token ``` -- Root key `mcp_containers` is a list so the same file can later carry the - core container's config too (each entry could grow an optional `role: core` - field then). Not doing that now — just don't paint ourselves into a corner - with a root shape that can't hold it. +- `brain3.yaml`'s root is an **object with named sections** (not a bare + list), so future config (core container settings, whatever else migrates + out of `.env`) gets its own top-level key alongside `plugin_mcp_containers` + without touching this one. `plugin_mcp_containers` itself is a list because + there can be any number of plugin containers. - `name` must be unique, DNS/label-safe (used as the container name and Docker network alias — reuse whatever validation `ContainerConfig.name` already implies). @@ -154,6 +165,14 @@ mcp_containers: ### Domain model (`crates/core/src/domain/model.rs`) ```rust +/// Root shape of `brain3.yaml`. Only `plugin_mcp_containers` is populated +/// today; more `#[serde(default)]` sections get added here as `.env` config +/// migrates over, one section at a time. +pub struct Brain3YamlConfig { + #[serde(default)] + pub plugin_mcp_containers: Vec, +} + pub struct PluginMcpContainerConfig { pub name: String, // validated snake_case: [a-z0-9_]+ pub runtime: ContainerRuntime, @@ -188,11 +207,14 @@ small builder function, same pattern as `build_container_config` in the vault container has enough special-cased fields that a shared struct would just grow a pile of `Option`s no one else uses. -### Config loading (`crates/platform/src/config/mcp_containers_config.rs`) +### Config loading (`crates/platform/src/config/brain3_yaml.rs`) -- New module: parses the YAML file into `Vec` (or an - empty vec if the file doesn't exist), using `serde-saphyr`. -- Validation at load time (best-effort, one bad entry doesn't kill the rest): +- New module: parses `brain3.yaml` into `Brain3YamlConfig` (or the + all-defaults value if the file doesn't exist), using `serde-saphyr`. Named + after the file, not the section, since this module will grow to parse + additional sections later — it is not a plugin-container-specific loader. +- Validation of `plugin_mcp_containers` entries at load time (best-effort, + one bad entry doesn't kill the rest): - unique `name` across entries - `name` matches `[a-z0-9_]+` - `host_directory` exists and is a directory @@ -244,7 +266,8 @@ the host network by default, consistent with the existing threat model. ### Startup sequence in `bootstrap.rs` 1. Ensure core container (unchanged). -2. Load `mcp_containers.yaml` (if present) via the Phase 1 loader. +2. Load `brain3.yaml` (if present) via the Phase 1 loader and read its + `plugin_mcp_containers` section. 3. For each entry, `ensure_mcp_container`-equivalent call. On error: log and drop that entry from the "live" set — do not abort gateway startup. 4. Register `stop`s for all successfully-started plugin containers in the @@ -258,12 +281,15 @@ expose their tools. Per AGENTS.MD, any new ingress needs a threat-model update before landing. This phase is what actually introduces the new ingress: it lets a human with -filesystem access to `/mcp_containers.yaml` cause the gateway to -`docker run` arbitrary images and mount arbitrary host directories into -them. Needs at minimum: -- A new "Assets"/"Attacker Capabilities" note: anyone who can write - `mcp_containers.yaml` or the referenced image can execute arbitrary code - with Docker-level access to the mounted `host_directory`. +filesystem access to `/brain3.yaml`'s `plugin_mcp_containers` +section cause the gateway to `docker run` arbitrary images and mount +arbitrary host directories into them. Needs at minimum: +- A new "Assets"/"Attacker Capabilities" note: anyone who can write the + `plugin_mcp_containers` section of `brain3.yaml` or the referenced image + can execute arbitrary code with Docker-level access to the mounted + `host_directory`. Note that `brain3.yaml` is expected to grow other, + non-ingress sections over time — this finding is scoped to + `plugin_mcp_containers` specifically, not the file as a whole. - Explicit statement that this is opt-in, local-file-only (no remote/API way to add a container), and Experimental/undocumented by design. - Note (can be added now even though tool output flows in Phase 3) that @@ -273,7 +299,8 @@ them. Needs at minimum: ### Phase 2 exit criteria -- With a valid `mcp_containers.yaml` present, `docker ps` shows the plugin +- With a valid `brain3.yaml` (`plugin_mcp_containers` section) present, + `docker ps` shows the plugin container running alongside the core container after gateway startup, with the `brain3-mcp-plugin:{name}` role label. - A misconfigured/unreachable plugin container logs an error and the gateway @@ -355,9 +382,10 @@ Proposed approach — generalize instead of special-casing per container: ## Phase 4 — Documentation -- `README.md` — one short "Experimental" section pointing at the YAML file - location and schema, explicitly marked unsupported/subject to change. - No setup-wizard mention, since there isn't one for this feature. +- `README.md` — one short "Experimental" section pointing at `brain3.yaml`'s + location and the `plugin_mcp_containers` schema, explicitly marked + unsupported/subject to change. No setup-wizard mention, since there isn't + one for this feature. --- @@ -402,12 +430,12 @@ skipped/run independently the same way the other numbered smoke tests can. the harness already uses for the vault-tools container, rather than having the Rust test shell out to `docker build` itself mid-test. -2. **Wire it into the test's `mcp_containers.yaml`**: - - Add a `write_mcp_containers_yaml` helper to `TempTestDir` (alongside the - existing `write_env_file`), writing to `self.root.join("mcp_containers.yaml")` - — same directory `B3_HOME` already points `.env` at in these tests - (`.env("B3_HOME", &temp.root)`), consistent with the `/...` - convention from Phase 1. +2. **Wire it into the test's `brain3.yaml`**: + - Add a `write_brain3_yaml` helper to `TempTestDir` (alongside the + existing `write_env_file`), writing a `plugin_mcp_containers` section to + `self.root.join("brain3.yaml")` — same directory `B3_HOME` already + points `.env` at in these tests (`.env("B3_HOME", &temp.root)`), + consistent with the `/...` convention from Phase 1. - Write a bearer token to a temp secret file under `temp.root` (e.g. `hello_mcp.token`) and reference it from the YAML's `auth.secret_file`. - Point `image`/`tag` at the image built in step 1, `port` at whatever the @@ -417,7 +445,7 @@ skipped/run independently the same way the other numbered smoke tests can. 3. **Test body** (`e2e_smoke_5_plugin_mcp_container`): - `TempTestDir::create`, write `.env` (as today) *and* the new - `mcp_containers.yaml`. + `brain3.yaml`. - Spawn `Brain3Process` as usual. - Connect the local MCP client (reuse `connect_local_mcp`). - `tools/list` and assert the response includes @@ -449,10 +477,10 @@ together, then get it green. ## File/module summary -- `crates/core/src/domain/model.rs` — add `PluginMcpContainerConfig`, - `PluginMcpContainerAuth`. (Phase 1) -- `crates/platform/src/config/mcp_containers_config.rs` — new, YAML loader + - validation. (Phase 1) +- `crates/core/src/domain/model.rs` — add `Brain3YamlConfig`, + `PluginMcpContainerConfig`, `PluginMcpContainerAuth`. (Phase 1) +- `crates/platform/src/config/brain3_yaml.rs` — new, `brain3.yaml` loader + + `plugin_mcp_containers` validation. (Phase 1) - `crates/platform/src/container/startup.rs` — add `build_plugin_container_config`, `ensure_plugin_mcp_container`, `stop_plugin_mcp_container`, extend orphan-GC role scoping. (Phase 2) @@ -468,5 +496,5 @@ together, then get it green. (Phase 5) - `scripts/e2e_smoke.py` — build the hello-mcp image, register the new test name. (Phase 5) -- `apps/gateway/tests/e2e_smoke.rs` — `write_mcp_containers_yaml` helper, - new `e2e_smoke_5_plugin_mcp_container` test. (Phase 5) +- `apps/gateway/tests/e2e_smoke.rs` — `write_brain3_yaml` helper, new + `e2e_smoke_5_plugin_mcp_container` test. (Phase 5) From 1f7561d8ebb0b266238139b9a9c4ee273d8129ea Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 16:58:45 +0200 Subject: [PATCH 06/12] Add extra MCP containers config loader --- Cargo.lock | 74 ++++ crates/core/src/domain/model.rs | 21 + crates/platform/Cargo.toml | 1 + .../src/config/mcp_containers_config.rs | 418 ++++++++++++++++++ crates/platform/src/config/mod.rs | 1 + 5 files changed, 515 insertions(+) create mode 100644 crates/platform/src/config/mcp_containers_config.rs diff --git a/Cargo.lock b/Cargo.lock index 69af654..7905217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,19 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -26,6 +39,16 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96401ca08501972288ecbcde33902fce858bf73fbcbdf91dab8c3a9544e106bb" +dependencies = [ + "anstyle", + "unicode-width", +] + [[package]] name = "anstream" version = "1.0.0" @@ -91,6 +114,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -368,6 +397,7 @@ dependencies = [ "rand 0.10.2", "reqwest 0.12.28", "serde", + "serde-saphyr", "serde_json", "sha2 0.10.9", "sqlx", @@ -916,6 +946,24 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + [[package]] name = "enum-ordinalize" version = "4.4.1" @@ -1300,6 +1348,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "granit-parser" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d03f81ad4732830d85cfd417a9f62cde6dadda4354d37d078a6084a19560aa2d" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -3022,6 +3080,22 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" +dependencies = [ + "ahash", + "annotate-snippets", + "encoding_rs_io", + "getrandom 0.3.4", + "granit-parser", + "num-traits", + "serde_core", + "smallvec", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/crates/core/src/domain/model.rs b/crates/core/src/domain/model.rs index b7965b1..02336fe 100644 --- a/crates/core/src/domain/model.rs +++ b/crates/core/src/domain/model.rs @@ -65,6 +65,27 @@ pub enum ContainerRuntime { MacOSContainer, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtraMcpContainerConfig { + pub name: String, + pub runtime: ContainerRuntime, + pub image: String, + pub container_port: u16, + pub host_port: Option, + pub host_directory: PathBuf, + pub container_directory: PathBuf, + pub auth: ExtraMcpContainerAuth, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExtraMcpContainerAuth { + None, + BearerToken { + secret_file: PathBuf, + secret_mount_path: PathBuf, + }, +} + /// How the gateway reaches the MCP container when it is on an internal-only network. /// /// The strategy is independent of the container runtime so future runtimes can diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index 3f85903..dafad12 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -14,6 +14,7 @@ dotenvy = "0.15" rand = "0.10" reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } serde = { version = "1", features = ["derive"] } +serde-saphyr = { version = "0.0.29", default-features = false, features = ["deserialize"] } serde_json = "1" sha2 = "0.10" sqlx = { version = "0.9", features = ["sqlite", "runtime-tokio", "time"] } diff --git a/crates/platform/src/config/mcp_containers_config.rs b/crates/platform/src/config/mcp_containers_config.rs new file mode 100644 index 0000000..5f04589 --- /dev/null +++ b/crates/platform/src/config/mcp_containers_config.rs @@ -0,0 +1,418 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use brain3_core::domain::model::{ + ContainerRuntime, ExtraMcpContainerAuth, ExtraMcpContainerConfig, +}; +use serde::Deserialize; + +const DEFAULT_CONTAINER_DIRECTORY: &str = "/data"; +const DEFAULT_SECRET_MOUNT_PATH: &str = "/run/secrets/mcp_bearer_token"; + +#[derive(Debug, Deserialize)] +struct RawExtraMcpContainersConfig { + #[serde(default)] + mcp_containers: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawExtraMcpContainerConfig { + name: Option, + platform: Option, + image: Option, + tag: Option, + port: Option, + host_port: Option, + host_directory: Option, + container_directory: Option, + auth: Option, +} + +#[derive(Debug, Deserialize)] +struct RawExtraMcpContainerAuth { + #[serde(rename = "type")] + auth_type: Option, + secret_file: Option, + secret_mount_path: Option, +} + +pub fn load_extra_mcp_containers_config(path: &Path) -> Vec { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + tracing::debug!( + path = %path.display(), + "extra MCP containers config file not found" + ); + return Vec::new(); + } + Err(error) => { + tracing::error!( + path = %path.display(), + error = %error, + "failed to read extra MCP containers config file" + ); + return Vec::new(); + } + }; + + let raw: RawExtraMcpContainersConfig = match serde_saphyr::from_str(&contents) { + Ok(raw) => raw, + Err(error) => { + tracing::error!( + path = %path.display(), + error = %error, + "failed to parse extra MCP containers config file" + ); + return Vec::new(); + } + }; + + validate_extra_mcp_containers(raw.mcp_containers) +} + +fn validate_extra_mcp_containers( + entries: Vec, +) -> Vec { + let mut seen_names = HashSet::new(); + let mut configs = Vec::new(); + + for entry in entries { + let name_for_log = entry.name.as_deref().unwrap_or("").to_string(); + match validate_extra_mcp_container(entry, &mut seen_names) { + Ok(config) => configs.push(config), + Err(reason) => { + tracing::error!( + container = %name_for_log, + reason = %reason, + "skipping invalid extra MCP container config" + ); + } + } + } + + configs +} + +fn validate_extra_mcp_container( + entry: RawExtraMcpContainerConfig, + seen_names: &mut HashSet, +) -> Result { + let name = required_string(entry.name, "name")?; + validate_name(&name)?; + if !seen_names.insert(name.clone()) { + return Err(format!("duplicate name '{name}'")); + } + + let runtime = parse_runtime(&required_string(entry.platform, "platform")?)?; + let image = required_string(entry.image, "image")?; + let tag = required_string(entry.tag, "tag")?; + let container_port = entry.port.ok_or_else(|| "missing port".to_string())?; + let host_directory = entry + .host_directory + .ok_or_else(|| "missing host_directory".to_string())?; + validate_directory(&host_directory, "host_directory")?; + let container_directory = entry + .container_directory + .unwrap_or_else(|| DEFAULT_CONTAINER_DIRECTORY.into()); + let auth = parse_auth(entry.auth)?; + + Ok(ExtraMcpContainerConfig { + name, + runtime, + image: format!("{image}:{tag}"), + container_port, + host_port: entry.host_port, + host_directory, + container_directory, + auth, + }) +} + +fn required_string(value: Option, field: &str) -> Result { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("missing {field}")) +} + +fn validate_name(name: &str) -> Result<(), String> { + if !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Ok(()); + } + + Err("name must match [a-z0-9_]+".to_string()) +} + +fn parse_runtime(value: &str) -> Result { + match value { + "docker" => Ok(ContainerRuntime::Docker), + "macos_container" => Ok(ContainerRuntime::MacOSContainer), + other => Err(format!( + "platform must be 'docker' or 'macos_container'; got '{other}'" + )), + } +} + +fn parse_auth(auth: Option) -> Result { + let auth = auth.ok_or_else(|| "missing auth".to_string())?; + match required_string(auth.auth_type, "auth.type")?.as_str() { + "none" => Ok(ExtraMcpContainerAuth::None), + "bearer_token" => { + let secret_file = auth + .secret_file + .ok_or_else(|| "missing auth.secret_file".to_string())?; + validate_readable_file(&secret_file, "auth.secret_file")?; + Ok(ExtraMcpContainerAuth::BearerToken { + secret_file, + secret_mount_path: auth + .secret_mount_path + .unwrap_or_else(|| DEFAULT_SECRET_MOUNT_PATH.into()), + }) + } + other => Err(format!( + "auth.type must be 'none' or 'bearer_token'; got '{other}'" + )), + } +} + +fn validate_directory(path: &Path, field: &str) -> Result<(), String> { + let metadata = fs::metadata(path) + .map_err(|error| format!("{field} '{}' is not accessible: {error}", path.display()))?; + if metadata.is_dir() { + Ok(()) + } else { + Err(format!("{field} '{}' is not a directory", path.display())) + } +} + +fn validate_readable_file(path: &Path, field: &str) -> Result<(), String> { + let metadata = fs::metadata(path) + .map_err(|error| format!("{field} '{}' is not accessible: {error}", path.display()))?; + if !metadata.is_file() { + return Err(format!("{field} '{}' is not a file", path.display())); + } + fs::File::open(path) + .map(|_| ()) + .map_err(|error| format!("{field} '{}' is not readable: {error}", path.display())) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use brain3_core::domain::model::{ + ContainerRuntime, ExtraMcpContainerAuth, ExtraMcpContainerConfig, + }; + + use super::load_extra_mcp_containers_config; + + fn write_file(path: &Path, contents: &str) { + fs::write(path, contents).expect("write test file"); + } + + fn valid_entry(name: &str, host_directory: &Path, secret_file: &Path) -> String { + format!( + r#" + - name: {name} + platform: docker + image: ghcr.io/example/{name} + tag: latest + port: 8420 + host_directory: {} + auth: + type: bearer_token + secret_file: {} +"#, + host_directory.display(), + secret_file.display() + ) + } + + #[test] + fn absent_file_loads_empty_config() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("mcp_containers.yaml"); + + let configs = load_extra_mcp_containers_config(&path); + + assert!(configs.is_empty()); + } + + #[test] + fn valid_multi_entry_file_loads_configs_with_defaults() { + let temp = tempfile::tempdir().expect("tempdir"); + let first_dir = temp.path().join("first-data"); + let second_dir = temp.path().join("second-data"); + fs::create_dir_all(&first_dir).expect("first dir"); + fs::create_dir_all(&second_dir).expect("second dir"); + let first_secret = temp.path().join("first.token"); + let second_secret = temp.path().join("second.token"); + write_file(&first_secret, "first-secret"); + write_file(&second_secret, "second-secret"); + let config_path = temp.path().join("mcp_containers.yaml"); + write_file( + &config_path, + &format!( + r#" +mcp_containers: +{} + - name: second_tool + platform: macos_container + image: ghcr.io/example/second + tag: v1 + port: 9000 + host_port: 19000 + host_directory: {} + container_directory: /workspace + auth: + type: none +"#, + valid_entry("first_tool", &first_dir, &first_secret), + second_dir.display() + ), + ); + + let configs = load_extra_mcp_containers_config(&config_path); + + assert_eq!(configs.len(), 2); + assert_eq!( + configs[0], + ExtraMcpContainerConfig { + name: "first_tool".to_string(), + runtime: ContainerRuntime::Docker, + image: "ghcr.io/example/first_tool:latest".to_string(), + container_port: 8420, + host_port: None, + host_directory: first_dir, + container_directory: "/data".into(), + auth: ExtraMcpContainerAuth::BearerToken { + secret_file: first_secret, + secret_mount_path: "/run/secrets/mcp_bearer_token".into(), + }, + } + ); + assert_eq!( + configs[1], + ExtraMcpContainerConfig { + name: "second_tool".to_string(), + runtime: ContainerRuntime::MacOSContainer, + image: "ghcr.io/example/second:v1".to_string(), + container_port: 9000, + host_port: Some(19000), + host_directory: second_dir, + container_directory: "/workspace".into(), + auth: ExtraMcpContainerAuth::None, + } + ); + } + + #[test] + fn missing_bearer_token_secret_file_drops_only_that_entry() { + let temp = tempfile::tempdir().expect("tempdir"); + let good_dir = temp.path().join("good-data"); + let bad_dir = temp.path().join("bad-data"); + fs::create_dir_all(&good_dir).expect("good dir"); + fs::create_dir_all(&bad_dir).expect("bad dir"); + let good_secret = temp.path().join("good.token"); + let missing_secret = temp.path().join("missing.token"); + write_file(&good_secret, "good-secret"); + let config_path = temp.path().join("mcp_containers.yaml"); + write_file( + &config_path, + &format!( + r#" +mcp_containers: +{} +{} +"#, + valid_entry("missing_secret", &bad_dir, &missing_secret), + valid_entry("good_tool", &good_dir, &good_secret) + ), + ); + + let configs = load_extra_mcp_containers_config(&config_path); + + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].name, "good_tool"); + } + + #[test] + fn duplicate_name_drops_later_duplicate() { + let temp = tempfile::tempdir().expect("tempdir"); + let first_dir = temp.path().join("first-data"); + let second_dir = temp.path().join("second-data"); + fs::create_dir_all(&first_dir).expect("first dir"); + fs::create_dir_all(&second_dir).expect("second dir"); + let first_secret = temp.path().join("first.token"); + let second_secret = temp.path().join("second.token"); + write_file(&first_secret, "first-secret"); + write_file(&second_secret, "second-secret"); + let config_path = temp.path().join("mcp_containers.yaml"); + write_file( + &config_path, + &format!( + r#" +mcp_containers: +{} +{} +"#, + valid_entry("same_name", &first_dir, &first_secret), + valid_entry("same_name", &second_dir, &second_secret) + ), + ); + + let configs = load_extra_mcp_containers_config(&config_path); + + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].host_directory, first_dir); + } + + #[test] + fn bad_name_charset_is_dropped() { + let temp = tempfile::tempdir().expect("tempdir"); + let good_dir = temp.path().join("good-data"); + let bad_dir = temp.path().join("bad-data"); + fs::create_dir_all(&good_dir).expect("good dir"); + fs::create_dir_all(&bad_dir).expect("bad dir"); + let good_secret = temp.path().join("good.token"); + let bad_secret = temp.path().join("bad.token"); + write_file(&good_secret, "good-secret"); + write_file(&bad_secret, "bad-secret"); + let config_path = temp.path().join("mcp_containers.yaml"); + write_file( + &config_path, + &format!( + r#" +mcp_containers: +{} +{} +"#, + valid_entry("Bad-Name", &bad_dir, &bad_secret), + valid_entry("good_name", &good_dir, &good_secret) + ), + ); + + let configs = load_extra_mcp_containers_config(&config_path); + + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].name, "good_name"); + } + + #[test] + fn malformed_yaml_loads_empty_config() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_path = temp.path().join("mcp_containers.yaml"); + write_file(&config_path, "mcp_containers:\n - name: ["); + + let configs = load_extra_mcp_containers_config(&config_path); + + assert!(configs.is_empty()); + } +} diff --git a/crates/platform/src/config/mod.rs b/crates/platform/src/config/mod.rs index 554c0c7..53f762f 100644 --- a/crates/platform/src/config/mod.rs +++ b/crates/platform/src/config/mod.rs @@ -1,2 +1,3 @@ pub mod env_file; pub mod log_config; +pub mod mcp_containers_config; From 4a05594db6b88ea1285cf7ffc157d9f482b08d1e Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 17:07:44 +0200 Subject: [PATCH 07/12] Start extra MCP containers on gateway boot --- SECURITY_AUDIT.md | 6 + crates/core/src/domain/model.rs | 1 + crates/platform/src/container/startup.rs | 333 +++++++++++++++++++++-- crates/platform/src/runtime/bootstrap.rs | 113 +++++++- 4 files changed, 435 insertions(+), 18 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 07186ab..56b8abb 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -54,6 +54,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Local `.env`, Cloudflare tunnel config, and SQLite token database files - Gateway public origin, Cloudflare tunnel identity, and container network isolation state - Native Whisper model files loaded by the gateway process for audio transcription +- Experimental extra MCP container config at `/mcp_containers.yaml`, referenced container images, referenced bearer-token secret files, and host directories mounted into those containers ### Trust Boundaries @@ -62,6 +63,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Local host filesystem and temp-directory principals - Local process-control principals that can signal the running gateway process - The boundary between the Rust gateway and the `brain3-mcp-vault-tools` container +- The boundary between the Rust gateway and any opt-in extra MCP containers started from `/mcp_containers.yaml` - The native transcription boundary where the Rust gateway downloads and parses untrusted audio bytes in-process instead of forwarding them to the container - Gateway-initiated internet egress for temporary audio `download_url` fetches and setup-time Whisper model downloads - Vault content that may be user-controlled or, in some deployments, third-party-controlled @@ -72,6 +74,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Operate or compromise the preregistered OAuth client after it is provisioned with Brain3 credentials - Supply a `transcribe_audio_file` audio `download_url` through an authorized MCP client, causing the gateway to issue an outbound GET and parse the returned bytes as audio - Read local files or logs available to the current OS principal or broader local principals +- Write `/mcp_containers.yaml`, any referenced bearer-token secret file, or any referenced extra-container image/tag. A principal with that capability can cause Brain3 to run arbitrary Docker/macOS-container images and mount the configured `host_directory` read-write into those containers, with the effective privileges of the selected container runtime and the user running Brain3. - Supply hostile vault content when the user does not fully control imported or shared notes ### Security Objectives @@ -80,6 +83,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Public ingress should be opt-in and should not broaden exposure accidentally - Local secrets and vault contents should not leak through logs or insecure default storage/permissions - Container networking should keep the MCP server private by default +- Extra MCP containers should remain an opt-in, local-file-only experimental surface; there must be no remote API, setup wizard path, or dynamic client registration path that can add or modify these containers. - Native transcription should enforce bounded downloads and reject bytes that cannot be decoded as audio before running Whisper inference ### Assumptions @@ -91,6 +95,8 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Whisper model downloads are setup-time, user-initiated egress and must be checksum-verified before model files are treated as usable. - Prompt injection is generally out of scope for user-controlled vault content, but not for vaults the user does not fully control - The gateway's SIGUSR1 diagnostics trigger is local-only: a sender must already be able to signal the gateway process, and the dump is written only to that process's stdout plus a log-file marker. The dump includes the MCP container's own logs, so the container must continue to avoid logging secrets; trace-level body logging remains covered by Finding 4. +- Extra MCP containers are Experimental and intentionally undocumented outside the README experimental section. They are configured only by hand-editing local files under `` and are trusted at the same level as the core vault MCP container once started. +- Extra-container tool metadata and tool results are not sandboxed or semantically vetted by Brain3 before being appended to `tools/list` or returned from `tools/call`. This matches the current trust model for the vault container's tools and must be treated as trusted local-container output, not as untrusted internet content. ## Findings diff --git a/crates/core/src/domain/model.rs b/crates/core/src/domain/model.rs index 02336fe..0f708e6 100644 --- a/crates/core/src/domain/model.rs +++ b/crates/core/src/domain/model.rs @@ -107,6 +107,7 @@ pub const BRAIN3_ROLE_LABEL_KEY: &str = "io.brain3.role"; pub const BRAIN3_INSTALLATION_ID_LABEL_KEY: &str = "io.brain3.installation_id"; pub const BRAIN3_MANAGED_LABEL_VALUE: &str = "true"; pub const BRAIN3_MCP_ROLE_LABEL_VALUE: &str = "mcp"; +pub const BRAIN3_EXTRA_MCP_ROLE_LABEL_PREFIX: &str = "brain3-mcp-extra:"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContainerLabel { diff --git a/crates/platform/src/container/startup.rs b/crates/platform/src/container/startup.rs index e93c180..7f2184a 100644 --- a/crates/platform/src/container/startup.rs +++ b/crates/platform/src/container/startup.rs @@ -1,13 +1,15 @@ +use std::net::TcpListener; use std::path::Path; use std::sync::Arc; use brain3_core::application::ensure_container::EnsureContainerUseCase; use brain3_core::domain::errors::ContainerError; use brain3_core::domain::model::{ - BindMount, ContainerConfig, ContainerLabel, ContainerRuntime, ContainerStartupConfig, - ManagedContainerInfo, ManagedContainerScope, PortMapping, BRAIN3_INSTALLATION_ID_LABEL_KEY, - BRAIN3_MANAGED_LABEL_KEY, BRAIN3_MANAGED_LABEL_VALUE, BRAIN3_MCP_ROLE_LABEL_VALUE, - BRAIN3_ROLE_LABEL_KEY, + BindMount, ContainerConfig, ContainerLabel, ContainerNetworkIsolationStrategy, + ContainerRuntime, ContainerStartupConfig, ExtraMcpContainerAuth, ExtraMcpContainerConfig, + ManagedContainerInfo, ManagedContainerScope, PortMapping, BRAIN3_EXTRA_MCP_ROLE_LABEL_PREFIX, + BRAIN3_INSTALLATION_ID_LABEL_KEY, BRAIN3_MANAGED_LABEL_KEY, BRAIN3_MANAGED_LABEL_VALUE, + BRAIN3_MCP_ROLE_LABEL_VALUE, BRAIN3_ROLE_LABEL_KEY, }; use brain3_core::domain::setup::RuntimeStartupPolicy; use brain3_core::ports::container::{ContainerId, ContainerPort}; @@ -26,6 +28,15 @@ const GC_POLL_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_mill #[cfg(test)] const GC_POLL_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_millis(100); +const DEFAULT_EXTRA_MCP_NETWORK_NAME: &str = "brain3-mcp-net"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartedExtraMcpContainer { + pub config: ExtraMcpContainerConfig, + pub host_port: u16, + pub container_ip: Option, +} + pub fn installation_scope_id(app_home: &Path, env_file: &Path) -> String { let app_home = normalize_scope_path(app_home); let env_file = normalize_scope_path(env_file); @@ -78,14 +89,67 @@ pub async fn ensure_mcp_container( ); let port = container_port_for_runtime(startup.runtime); - maybe_handle_managed_container_orphans(port.as_ref(), startup, startup_policy, installation_id) - .await?; + maybe_handle_managed_container_orphans( + port.as_ref(), + startup.runtime, + startup.container_name.as_str(), + BRAIN3_MCP_ROLE_LABEL_VALUE, + startup_policy, + installation_id, + ) + .await?; let config = build_container_config(startup, installation_id); let (_id, container_ip) = EnsureContainerUseCase::new(port).ensure(&config).await?; Ok(container_ip) } +pub async fn ensure_extra_mcp_container( + extra: &ExtraMcpContainerConfig, + startup_policy: RuntimeStartupPolicy, + installation_id: &str, +) -> Result { + let host_port = resolve_extra_host_port(extra)?; + let role = extra_mcp_role_label(extra.name.as_str()); + tracing::info!( + container = %extra.name, + image = %extra.image, + host_directory = %extra.host_directory.display(), + host_port, + container_port = extra.container_port, + installation_id, + auth = extra_auth_kind(&extra.auth), + "ensuring extra MCP container is running" + ); + tracing::info!( + container = %extra.name, + network_isolated = true, + isolation_strategy = ?extra_isolation_strategy(extra.runtime), + startup_policy = ?startup_policy, + role = %role, + "resolved extra MCP container network isolation mode" + ); + + let port = container_port_for_runtime(extra.runtime); + maybe_handle_managed_container_orphans( + port.as_ref(), + extra.runtime, + extra.name.as_str(), + role.as_str(), + startup_policy, + installation_id, + ) + .await?; + + let config = build_extra_container_config(extra, host_port, installation_id); + let (_id, container_ip) = EnsureContainerUseCase::new(port).ensure(&config).await?; + Ok(StartedExtraMcpContainer { + config: extra.clone(), + host_port, + container_ip, + }) +} + pub async fn stop_mcp_container(startup: &ContainerStartupConfig) -> Result<(), ContainerError> { let port = container_port_for_runtime(startup.runtime); let id = ContainerId(startup.container_name.clone()); @@ -104,6 +168,26 @@ pub async fn stop_mcp_container(startup: &ContainerStartupConfig) -> Result<(), port.stop(&id).await } +pub async fn stop_extra_mcp_container( + extra: &ExtraMcpContainerConfig, +) -> Result<(), ContainerError> { + let port = container_port_for_runtime(extra.runtime); + let id = ContainerId(extra.name.clone()); + + if !port.exists(&id).await? { + tracing::debug!(container = %extra.name, "managed extra MCP container already absent during shutdown"); + return Ok(()); + } + + if !port.is_running(&id).await? { + tracing::debug!(container = %extra.name, "managed extra MCP container already stopped during shutdown"); + return Ok(()); + } + + tracing::info!(container = %extra.name, runtime = ?extra.runtime, "stopping managed extra MCP container during shutdown"); + port.stop(&id).await +} + pub(crate) fn container_port_for_runtime(runtime: ContainerRuntime) -> Arc { match runtime { ContainerRuntime::Docker => Arc::new(DockerContainerAdapter), @@ -111,11 +195,18 @@ pub(crate) fn container_port_for_runtime(runtime: ContainerRuntime) -> Arc ManagedContainerScope { - ManagedContainerScope::mcp(installation_id.to_string()) +fn managed_container_scope(installation_id: &str, role: &str) -> ManagedContainerScope { + ManagedContainerScope { + installation_id: installation_id.to_string(), + role: role.to_string(), + } } fn managed_container_labels(installation_id: &str) -> Vec { + managed_container_labels_for_role(installation_id, BRAIN3_MCP_ROLE_LABEL_VALUE) +} + +fn managed_container_labels_for_role(installation_id: &str, role: &str) -> Vec { vec![ ContainerLabel { key: BRAIN3_MANAGED_LABEL_KEY.into(), @@ -123,7 +214,7 @@ fn managed_container_labels(installation_id: &str) -> Vec { }, ContainerLabel { key: BRAIN3_ROLE_LABEL_KEY.into(), - value: BRAIN3_MCP_ROLE_LABEL_VALUE.into(), + value: role.into(), }, ContainerLabel { key: BRAIN3_INSTALLATION_ID_LABEL_KEY.into(), @@ -132,6 +223,10 @@ fn managed_container_labels(installation_id: &str) -> Vec { ] } +fn extra_mcp_role_label(name: &str) -> String { + format!("{BRAIN3_EXTRA_MCP_ROLE_LABEL_PREFIX}{name}") +} + fn build_container_config( startup: &ContainerStartupConfig, installation_id: &str, @@ -230,26 +325,126 @@ fn build_container_config( } } +fn build_extra_container_config( + extra: &ExtraMcpContainerConfig, + host_port: u16, + installation_id: &str, +) -> ContainerConfig { + #[cfg(unix)] + let user = Some(format!("{}:{}", unsafe { libc::getuid() }, unsafe { + libc::getgid() + })); + #[cfg(not(unix))] + let user: Option = None; + + let mut bind_mounts = vec![BindMount { + host_path: extra.host_directory.clone(), + container_path: extra.container_directory.clone(), + readonly: false, + }]; + + if let ExtraMcpContainerAuth::BearerToken { + secret_file, + secret_mount_path, + } = &extra.auth + { + bind_mounts.push(BindMount { + host_path: secret_file.clone(), + container_path: secret_mount_path.clone(), + readonly: true, + }); + } + + let isolation_strategy = Some(extra_isolation_strategy(extra.runtime)); + tracing::info!( + container = %extra.name, + installation_id, + network_isolated = true, + isolation_strategy = ?isolation_strategy, + host_probe_target = %format!("127.0.0.1:{host_port}"), + isolated_probe_target = %format!(":{}", extra.container_port), + auth = extra_auth_kind(&extra.auth), + "prepared extra MCP container runtime networking configuration" + ); + + ContainerConfig { + image: extra.image.clone(), + name: extra.name.clone(), + isolation_strategy, + network_name: DEFAULT_EXTRA_MCP_NETWORK_NAME.into(), + port_mappings: vec![PortMapping { + host_address: "127.0.0.1".into(), + host_port, + container_port: extra.container_port, + }], + env_vars: Vec::new(), + labels: managed_container_labels_for_role( + installation_id, + extra_mcp_role_label(extra.name.as_str()).as_str(), + ), + bind_mounts, + user, + detach: true, + remove_on_exit: matches!(extra.runtime, ContainerRuntime::Docker), + workdir: None, + command: Vec::new(), + } +} + +fn extra_isolation_strategy(runtime: ContainerRuntime) -> ContainerNetworkIsolationStrategy { + match runtime { + ContainerRuntime::Docker => ContainerNetworkIsolationStrategy::DiscoverContainerIp, + ContainerRuntime::MacOSContainer => ContainerNetworkIsolationStrategy::PublishToLoopback, + } +} + +fn extra_auth_kind(auth: &ExtraMcpContainerAuth) -> &'static str { + match auth { + ExtraMcpContainerAuth::None => "none", + ExtraMcpContainerAuth::BearerToken { .. } => "bearer_token", + } +} + +fn resolve_extra_host_port(extra: &ExtraMcpContainerConfig) -> Result { + match extra.host_port { + Some(port) => Ok(port), + None => pick_free_loopback_port().map_err(|error| { + ContainerError::Other(format!( + "failed to pick host port for extra MCP container '{}': {error}", + extra.name + )) + }), + } +} + +fn pick_free_loopback_port() -> std::io::Result { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + listener.local_addr().map(|addr| addr.port()) +} + async fn maybe_handle_managed_container_orphans( port: &dyn ContainerPort, - startup: &ContainerStartupConfig, + runtime: ContainerRuntime, + container_name: &str, + role: &str, startup_policy: RuntimeStartupPolicy, installation_id: &str, ) -> Result<(), ContainerError> { if !startup_policy.checks_for_orphans() { tracing::debug!( - container = %startup.container_name, + container = %container_name, installation_id, "skipping managed-container orphan preflight during setup or reconfiguration" ); return Ok(()); } - let scope = managed_container_scope(installation_id); + let scope = managed_container_scope(installation_id, role); let containers = port.list_managed_containers(&scope).await?; if containers.is_empty() { tracing::debug!( installation_id, + role, "no managed orphan containers found for this installation scope" ); return Ok(()); @@ -258,6 +453,7 @@ async fn maybe_handle_managed_container_orphans( if !startup_policy.gc_containers_enabled() { tracing::warn!( installation_id, + role, containers = ?containers, "managed orphan containers detected; refusing cleanup without explicit startup approval" ); @@ -269,7 +465,7 @@ async fn maybe_handle_managed_container_orphans( ); } - garbage_collect_managed_containers(port, startup.runtime, installation_id, containers).await + garbage_collect_managed_containers(port, runtime, installation_id, containers).await } async fn garbage_collect_managed_containers( @@ -586,6 +782,105 @@ mod tests { ); } + fn sample_extra_config() -> ExtraMcpContainerConfig { + ExtraMcpContainerConfig { + name: "fluensy_learn".into(), + runtime: ContainerRuntime::Docker, + image: "ghcr.io/example/fluensy-learn:latest".into(), + container_port: 8420, + host_port: None, + host_directory: "/tmp/fluensy-data".into(), + container_directory: "/data".into(), + auth: ExtraMcpContainerAuth::BearerToken { + secret_file: "/tmp/fluensy.token".into(), + secret_mount_path: "/run/secrets/mcp_bearer_token".into(), + }, + } + } + + #[test] + fn build_extra_container_config_adds_extra_role_labels_and_mounts() { + let config = build_extra_container_config(&sample_extra_config(), 18420, "scope-1"); + + assert_eq!(config.name, "fluensy_learn"); + assert_eq!(config.image, "ghcr.io/example/fluensy-learn:latest"); + assert_eq!( + config.isolation_strategy, + Some(ContainerNetworkIsolationStrategy::DiscoverContainerIp) + ); + assert_eq!(config.network_name, DEFAULT_EXTRA_MCP_NETWORK_NAME); + assert_eq!(config.port_mappings.len(), 1); + assert_eq!(config.port_mappings[0].host_address, "127.0.0.1"); + assert_eq!(config.port_mappings[0].host_port, 18420); + assert_eq!(config.port_mappings[0].container_port, 8420); + assert_eq!(config.env_vars, Vec::<(String, String)>::new()); + assert_eq!( + config.labels, + vec![ + ContainerLabel { + key: BRAIN3_MANAGED_LABEL_KEY.into(), + value: BRAIN3_MANAGED_LABEL_VALUE.into(), + }, + ContainerLabel { + key: BRAIN3_ROLE_LABEL_KEY.into(), + value: "brain3-mcp-extra:fluensy_learn".into(), + }, + ContainerLabel { + key: BRAIN3_INSTALLATION_ID_LABEL_KEY.into(), + value: "scope-1".into(), + }, + ] + ); + assert_eq!(config.bind_mounts.len(), 2); + assert_eq!( + config.bind_mounts[0].host_path, + Path::new("/tmp/fluensy-data") + ); + assert_eq!(config.bind_mounts[0].container_path, Path::new("/data")); + assert!(!config.bind_mounts[0].readonly); + assert_eq!( + config.bind_mounts[1].host_path, + Path::new("/tmp/fluensy.token") + ); + assert_eq!( + config.bind_mounts[1].container_path, + Path::new("/run/secrets/mcp_bearer_token") + ); + assert!(config.bind_mounts[1].readonly); + assert!(config.detach); + assert!(config.remove_on_exit); + assert!(config.workdir.is_none()); + assert!(config.command.is_empty()); + } + + #[test] + fn build_extra_container_config_omits_secret_mount_for_no_auth() { + let mut extra = sample_extra_config(); + extra.auth = ExtraMcpContainerAuth::None; + + let config = build_extra_container_config(&extra, 18420, "scope-1"); + + assert_eq!(config.bind_mounts.len(), 1); + assert_eq!( + config.bind_mounts[0].host_path, + Path::new("/tmp/fluensy-data") + ); + assert_eq!(config.bind_mounts[0].container_path, Path::new("/data")); + assert!(!config.bind_mounts[0].readonly); + } + + #[test] + fn extra_isolation_strategy_matches_runtime() { + assert_eq!( + extra_isolation_strategy(ContainerRuntime::Docker), + ContainerNetworkIsolationStrategy::DiscoverContainerIp + ); + assert_eq!( + extra_isolation_strategy(ContainerRuntime::MacOSContainer), + ContainerNetworkIsolationStrategy::PublishToLoopback + ); + } + #[tokio::test] async fn orphan_preflight_requires_explicit_gc() { let port = MockContainerPort::new(MockState { @@ -600,7 +895,9 @@ mod tests { let error = maybe_handle_managed_container_orphans( &port, - &sample_startup(), + ContainerRuntime::Docker, + "brain3-mcp-vault-tools", + BRAIN3_MCP_ROLE_LABEL_VALUE, RuntimeStartupPolicy::configured(false), "scope-1", ) @@ -637,7 +934,9 @@ mod tests { maybe_handle_managed_container_orphans( &port, - &sample_startup(), // Docker runtime + ContainerRuntime::Docker, + "brain3-mcp-vault-tools", + BRAIN3_MCP_ROLE_LABEL_VALUE, RuntimeStartupPolicy::configured(true), "scope-1", ) @@ -710,7 +1009,9 @@ mod tests { maybe_handle_managed_container_orphans( &port, - &startup, + startup.runtime, + startup.container_name.as_str(), + BRAIN3_MCP_ROLE_LABEL_VALUE, RuntimeStartupPolicy::configured(true), "scope-1", ) diff --git a/crates/platform/src/runtime/bootstrap.rs b/crates/platform/src/runtime/bootstrap.rs index 406c1b7..39d28e4 100644 --- a/crates/platform/src/runtime/bootstrap.rs +++ b/crates/platform/src/runtime/bootstrap.rs @@ -2,12 +2,18 @@ use std::sync::Arc; use anyhow::{bail, Result}; use brain3_core::domain::errors::ContainerError; -use brain3_core::domain::model::{ContainerNetworkIsolationStrategy, GatewayConfig, TunnelConfig}; +use brain3_core::domain::model::{ + ContainerNetworkIsolationStrategy, ExtraMcpContainerConfig, GatewayConfig, TunnelConfig, +}; use brain3_core::domain::setup::{RuntimeLaunchPlan, RuntimeStartupPolicy}; use brain3_core::ports::tunnel::TunnelPort; use crate::config::log_config; -use crate::container::startup::{ensure_mcp_container, installation_scope_id, stop_mcp_container}; +use crate::config::mcp_containers_config::load_extra_mcp_containers_config; +use crate::container::startup::{ + ensure_extra_mcp_container, ensure_mcp_container, installation_scope_id, + stop_extra_mcp_container, stop_mcp_container, StartedExtraMcpContainer, +}; use crate::tunnel::start_tunnel; #[derive(Debug, Clone, PartialEq, Eq)] @@ -37,6 +43,7 @@ pub struct RuntimeBootstrap { pub public_url: Option, pub container_status: StartupStatus, pub tunnel_status: StartupStatus, + pub extra_mcp_containers: Vec, managed_container_started: bool, tunnel: Option>, } @@ -58,6 +65,7 @@ impl RuntimeBootstrap { public_url, container_status, tunnel_status, + extra_mcp_containers: Vec::new(), managed_container_started, tunnel: None, } @@ -74,6 +82,17 @@ impl RuntimeBootstrap { pub async fn shutdown_managed_runtime(&mut self) { self.stop_tunnel().await; + for extra in &self.extra_mcp_containers { + if let Err(error) = stop_extra_mcp_container(&extra.config).await { + tracing::warn!( + container = %extra.config.name, + runtime = ?extra.config.runtime, + error = %error, + "failed to stop managed extra MCP container during shutdown; continuing exit" + ); + } + } + let Some(startup) = self.config.container.as_ref() else { return; }; @@ -283,6 +302,21 @@ pub async fn bootstrap_configured_runtime( } else { container_status }; + + let extra_mcp_containers = if container_status.allows_gateway_start() { + start_extra_mcp_containers( + &launch_plan.paths.app_home, + startup_policy, + installation_id.as_str(), + ) + .await + } else { + tracing::debug!( + container_status = ?container_status, + "skipping extra MCP containers because core MCP container is not ready" + ); + Vec::new() + }; let pid_file = launch_plan.paths.app_home.join("cloudflared.pid"); let (tunnel_status, public_url, tunnel_guard) = if !container_status.allows_gateway_start() { @@ -326,11 +360,86 @@ pub async fn bootstrap_configured_runtime( public_url, container_status, tunnel_status, + extra_mcp_containers, managed_container_started, tunnel: tunnel_guard, }) } +async fn start_extra_mcp_containers( + app_home: &std::path::Path, + startup_policy: RuntimeStartupPolicy, + installation_id: &str, +) -> Vec { + let config_path = app_home.join("mcp_containers.yaml"); + let configs = load_extra_mcp_containers_config(&config_path); + if configs.is_empty() { + tracing::debug!( + path = %config_path.display(), + "no extra MCP containers configured" + ); + return Vec::new(); + } + + tracing::info!( + path = %config_path.display(), + count = configs.len(), + "loaded extra MCP container configs" + ); + + let mut started = Vec::new(); + for config in configs { + match ensure_extra_mcp_container(&config, startup_policy, installation_id).await { + Ok(container) => { + tracing::info!( + container = %container.config.name, + host_port = container.host_port, + container_ip = ?container.container_ip, + "extra MCP container ready" + ); + started.push(container); + } + Err(error) => { + log_extra_container_startup_failure(&config, &error); + if error.started_container() { + stop_failed_extra_container(&config).await; + } + } + } + } + + started +} + +fn log_extra_container_startup_failure(config: &ExtraMcpContainerConfig, error: &ContainerError) { + let summary = error.summary(); + if let Some(logs) = error.recent_logs() { + tracing::error!( + container = %config.name, + summary, + logs = %logs, + "extra MCP container startup failed; continuing without it" + ); + } else { + tracing::error!( + container = %config.name, + summary, + "extra MCP container startup failed; continuing without it" + ); + } +} + +async fn stop_failed_extra_container(config: &ExtraMcpContainerConfig) { + if let Err(error) = stop_extra_mcp_container(config).await { + tracing::warn!( + container = %config.name, + runtime = ?config.runtime, + error = %error, + "failed to stop extra MCP container after startup failure; continuing gateway startup" + ); + } +} + fn container_failure_status(container_name: &str, error: &ContainerError) -> StartupStatus { let summary = error.summary(); if let Some(logs) = error.recent_logs() { From af8fbbc33c48f38bfa11ecc284146e82062801a2 Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 17:19:58 +0200 Subject: [PATCH 08/12] Route tools to extra MCP containers --- apps/gateway/src/main.rs | 3 +- apps/gateway/src/server.rs | 187 ++++++++- crates/core/src/application/mcp_router.rs | 265 +++++++++++- crates/core/src/application/mod.rs | 1 + .../remote_mcp_container_client.rs | 393 ++++++++++++++++++ 5 files changed, 836 insertions(+), 13 deletions(-) create mode 100644 crates/core/src/application/remote_mcp_container_client.rs diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index c7cf70e..bf686fd 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -741,10 +741,11 @@ async fn run_cli_mode( let _diag_listener = diagnostics::spawn_diagnostics_signal_listener(Arc::clone(&runtime.config)); - server::run_gateway_server_until( + server::run_gateway_server_until_with_extra_containers( host, Arc::clone(&runtime.config), runtime.upstream_secret.clone(), + runtime.extra_mcp_containers.clone(), shutdown_signal(), ) .await?; diff --git a/apps/gateway/src/server.rs b/apps/gateway/src/server.rs index e4e9b14..b783783 100644 --- a/apps/gateway/src/server.rs +++ b/apps/gateway/src/server.rs @@ -12,11 +12,13 @@ use tokio::sync::{watch, Mutex}; use tokio::task::JoinHandle; use brain3_core::application::proxy_mcp::ProxyMcpUseCase; -use brain3_core::domain::model::{AccessMode, GatewayConfig}; +use brain3_core::application::remote_mcp_container_client::RemoteMcpContainerClient; +use brain3_core::domain::model::{AccessMode, ExtraMcpContainerAuth, GatewayConfig}; use brain3_core::domain::setup::{RuntimeLaunchPlan, RuntimeStartupPolicy}; use brain3_core::ports::config::ConfigPort; use brain3_core::ports::native_mcp_tool::NativeMcpTool; use brain3_platform::config::env_file::EnvFileConfigAdapter; +use brain3_platform::container::startup::StartedExtraMcpContainer; use brain3_platform::http::rate_limit::OAuthRateLimiter; use brain3_platform::http::registrar::GatewayRegistrar; use brain3_platform::http::router::{build_local_router, build_router}; @@ -95,12 +97,33 @@ impl GatewayServerHandle { } } +#[allow(dead_code)] pub async fn run_gateway_server_until( host: &str, config: Arc, upstream_secret: String, shutdown: F, ) -> Result<()> +where + F: Future + Send + 'static, +{ + run_gateway_server_until_with_extra_containers( + host, + config, + upstream_secret, + Vec::new(), + shutdown, + ) + .await +} + +pub async fn run_gateway_server_until_with_extra_containers( + host: &str, + config: Arc, + upstream_secret: String, + extra_mcp_containers: Vec, + shutdown: F, +) -> Result<()> where F: Future + Send + 'static, { @@ -119,7 +142,8 @@ where .as_ref() .and_then(|(listener, _)| listener.local_addr().ok()) .map(local_url_from_addr); - let app_state = build_gateway_state(Arc::clone(&config), upstream_secret)?; + let app_state = + build_gateway_state(Arc::clone(&config), upstream_secret, &extra_mcp_containers).await?; let (shutdown_tx, _) = watch::channel(false); tracing::info!( @@ -220,6 +244,15 @@ pub async fn spawn_gateway_server( host: &str, config: Arc, upstream_secret: String, +) -> Result { + spawn_gateway_server_with_extra_containers(host, config, upstream_secret, Vec::new()).await +} + +pub async fn spawn_gateway_server_with_extra_containers( + host: &str, + config: Arc, + upstream_secret: String, + extra_mcp_containers: Vec, ) -> Result { tracing::info!( access_mode = ?config.access_mode, @@ -249,7 +282,8 @@ pub async fn spawn_gateway_server( }; let bind_addr_display = bind_addr.to_string(); let local_url = local_url_from_addr(bind_addr); - let app_state = build_gateway_state(Arc::clone(&config), upstream_secret)?; + let app_state = + build_gateway_state(Arc::clone(&config), upstream_secret, &extra_mcp_containers).await?; let (shutdown_tx, _) = watch::channel(false); let mut join_handles = Vec::new(); @@ -332,10 +366,11 @@ pub async fn spawn_configured_gateway_session( bootstrap_configured_runtime(Arc::clone(&config), launch_plan, startup_policy).await?; let (server, display_url) = if runtime.can_start_gateway() { - let server = spawn_gateway_server( + let server = spawn_gateway_server_with_extra_containers( host, Arc::clone(&runtime.config), runtime.upstream_secret.clone(), + runtime.extra_mcp_containers.clone(), ) .await?; let local_url = server.local_url().to_string(); @@ -378,9 +413,10 @@ async fn bind_local_mcp_listener(config: &GatewayConfig) -> Result, upstream_secret: String, + extra_mcp_containers: &[StartedExtraMcpContainer], ) -> Result> { let registrar = Arc::new(GatewayRegistrar::new( &config.oauth.client_id, @@ -399,7 +435,7 @@ fn build_gateway_state( let mcp_proxy = Arc::new(ReqwestMcpProxy::new()); let proxy_mcp = Arc::new(ProxyMcpUseCase::new( - mcp_proxy, + Arc::clone(&mcp_proxy), config.mcp_reverse_proxy.mcp_upstream_url.clone(), upstream_secret, config.hostname_validation.clone(), @@ -407,7 +443,12 @@ fn build_gateway_state( let native_tools = Arc::new(NativeMcpToolRegistry::new(native_mcp_tools_from_config( config.as_ref(), )?)); - let mcp_router = Arc::new(McpRouterUseCase::new(proxy_mcp, native_tools)); + let extra_clients = build_extra_mcp_clients(Arc::clone(&mcp_proxy), extra_mcp_containers).await; + let mcp_router = Arc::new(McpRouterUseCase::new_with_extra_containers( + proxy_mcp, + native_tools, + extra_clients, + )); let app_state = AppState { registrar, @@ -421,6 +462,68 @@ fn build_gateway_state( Ok(app_state) } +async fn build_extra_mcp_clients( + proxy: Arc, + containers: &[StartedExtraMcpContainer], +) -> Vec>> { + let mut clients = Vec::new(); + for container in containers { + let mcp_url = extra_mcp_url(container); + let bearer_token = match extra_bearer_token(container) { + Ok(token) => token, + Err(error) => { + tracing::error!( + container = %container.config.name, + error = %error, + "skipping extra MCP container client because bearer token could not be loaded" + ); + continue; + } + }; + + match RemoteMcpContainerClient::initialize_and_cache_tools( + container.config.name.clone(), + mcp_url, + bearer_token, + Arc::clone(&proxy), + ) + .await + { + Ok(client) => clients.push(Arc::new(client)), + Err(error) => { + tracing::error!( + container = %container.config.name, + error = %error, + "skipping extra MCP container tools because initialize/tools-list failed" + ); + } + } + } + + clients +} + +fn extra_mcp_url(container: &StartedExtraMcpContainer) -> String { + match &container.container_ip { + Some(container_ip) => format!( + "http://{}:{}/mcp", + container_ip, container.config.container_port + ), + None => format!("http://127.0.0.1:{}/mcp", container.host_port), + } +} + +fn extra_bearer_token(container: &StartedExtraMcpContainer) -> Result> { + match &container.config.auth { + ExtraMcpContainerAuth::None => Ok(None), + ExtraMcpContainerAuth::BearerToken { secret_file, .. } => { + let token = std::fs::read_to_string(secret_file) + .with_context(|| format!("failed to read {}", secret_file.display()))?; + Ok(Some(token.trim().to_string())) + } + } +} + fn native_mcp_tools_from_config(config: &GatewayConfig) -> Result>> { let transcription = &config.native_audio_transcription; if !transcription.enabled { @@ -479,7 +582,8 @@ mod tests { use std::path::PathBuf; use brain3_core::domain::model::{ - AccessMode, GatewayConfig, HostnameValidationConfig, MCPReverseProxyConfig, + AccessMode, ContainerRuntime, ExtraMcpContainerAuth, ExtraMcpContainerConfig, + GatewayConfig, HostnameValidationConfig, MCPReverseProxyConfig, NativeAudioTranscriptionConfig, OAuthConfig, }; @@ -502,6 +606,73 @@ mod tests { ); } + #[test] + fn extra_mcp_url_uses_container_ip_when_available() { + let container = StartedExtraMcpContainer { + config: sample_extra_container_config(8420, ExtraMcpContainerAuth::None), + host_port: 18420, + container_ip: Some("172.18.0.2".into()), + }; + + assert_eq!(extra_mcp_url(&container), "http://172.18.0.2:8420/mcp"); + } + + #[test] + fn extra_mcp_url_uses_loopback_host_port_without_container_ip() { + let container = StartedExtraMcpContainer { + config: sample_extra_container_config(8420, ExtraMcpContainerAuth::None), + host_port: 18420, + container_ip: None, + }; + + assert_eq!(extra_mcp_url(&container), "http://127.0.0.1:18420/mcp"); + } + + #[test] + fn extra_bearer_token_reads_and_trims_secret_file() { + let secret_file = std::env::temp_dir().join(format!( + "brain3-extra-token-test-{}-{}.token", + std::process::id(), + "phase3" + )); + std::fs::write(&secret_file, "secret-token\n").expect("write secret"); + let container = StartedExtraMcpContainer { + config: sample_extra_container_config( + 8420, + ExtraMcpContainerAuth::BearerToken { + secret_file, + secret_mount_path: "/run/secrets/mcp_bearer_token".into(), + }, + ), + host_port: 18420, + container_ip: None, + }; + + assert_eq!( + extra_bearer_token(&container).expect("token should load"), + Some("secret-token".into()) + ); + if let ExtraMcpContainerAuth::BearerToken { secret_file, .. } = &container.config.auth { + let _ = std::fs::remove_file(secret_file); + } + } + + fn sample_extra_container_config( + container_port: u16, + auth: ExtraMcpContainerAuth, + ) -> ExtraMcpContainerConfig { + ExtraMcpContainerConfig { + name: "fluensy_learn".into(), + runtime: ContainerRuntime::Docker, + image: "ghcr.io/example/fluensy-learn:latest".into(), + container_port, + host_port: None, + host_directory: "/tmp/fluensy-data".into(), + container_directory: "/data".into(), + auth, + } + } + fn gateway_config_with_native_audio( native_audio_transcription: NativeAudioTranscriptionConfig, ) -> GatewayConfig { diff --git a/crates/core/src/application/mcp_router.rs b/crates/core/src/application/mcp_router.rs index 877bb44..f57fd5b 100644 --- a/crates/core/src/application/mcp_router.rs +++ b/crates/core/src/application/mcp_router.rs @@ -4,6 +4,7 @@ use serde_json::{json, Value}; use crate::application::native_mcp_tool_registry::NativeMcpToolRegistry; use crate::application::proxy_mcp::ProxyMcpUseCase; +use crate::application::remote_mcp_container_client::RemoteMcpContainerClient; use crate::application::validate_request::validate_host; use crate::domain::errors::ProxyError; use crate::domain::model::HostnameValidationConfig; @@ -13,13 +14,23 @@ use crate::ports::native_mcp_tool::{NativeMcpToolError, NativeMcpToolOutput}; pub struct McpRouterUseCase { proxy: Arc>, native_tools: Arc, + extra_containers: Vec>>, } impl McpRouterUseCase

{ pub fn new(proxy: Arc>, native_tools: Arc) -> Self { + Self::new_with_extra_containers(proxy, native_tools, Vec::new()) + } + + pub fn new_with_extra_containers( + proxy: Arc>, + native_tools: Arc, + extra_containers: Vec>>, + ) -> Self { Self { proxy, native_tools, + extra_containers, } } @@ -92,12 +103,15 @@ impl McpRouterUseCase

{ .proxy .handle_unvalidated(request_host, method, path, query, headers, body) .await?; - Ok(self.append_native_tool_schemas(response)) + Ok(self.append_tool_schemas(response)) } Some("tools/call") => { if let Some(response) = self.maybe_call_native_tool(parsed_body.as_ref()).await? { return Ok(response); } + if let Some(response) = self.maybe_call_extra_tool(parsed_body.as_ref()).await? { + return Ok(response); + } self.proxy .handle_unvalidated(request_host, method, path, query, headers, body) @@ -154,9 +168,45 @@ impl McpRouterUseCase

{ Ok(Some(native_tool_response(request, output)?)) } - fn append_native_tool_schemas(&self, response: McpProxyResponse) -> McpProxyResponse { + async fn maybe_call_extra_tool( + &self, + request: Option<&Value>, + ) -> Result, ProxyError> { + let Some(request) = request else { + return Ok(None); + }; + let Some(params) = request.get("params").and_then(Value::as_object) else { + return Ok(None); + }; + let Some(name) = params.get("name").and_then(Value::as_str) else { + return Ok(None); + }; + + for client in &self.extra_containers { + if let Some(unprefixed_name) = client.strip_prefix(name) { + tracing::info!( + container = %client.container_name(), + prefixed_tool_name = name, + tool_name = unprefixed_name, + request_id = ?request.get("id"), + "MCP router: routing extra MCP tool call" + ); + return Ok(Some(client.call_tool(request, unprefixed_name).await?)); + } + } + + Ok(None) + } + + fn append_tool_schemas(&self, response: McpProxyResponse) -> McpProxyResponse { let native_schemas = self.native_tools.list_schemas(); - if native_schemas.is_empty() { + let extra_schemas = self + .extra_containers + .iter() + .flat_map(|client| client.prefixed_tool_schemas()) + .collect::>(); + + if native_schemas.is_empty() && extra_schemas.is_empty() { return response; } @@ -175,7 +225,9 @@ impl McpRouterUseCase

{ }; let native_tool_count = native_schemas.len(); + let extra_tool_count = extra_schemas.len(); tools.extend(native_schemas); + tools.extend(extra_schemas); let total_tool_count = tools.len(); let Ok(new_body) = serde_json::to_vec(&body) else { @@ -185,8 +237,9 @@ impl McpRouterUseCase

{ tracing::info!( native_tool_count = native_tool_count, + extra_tool_count = extra_tool_count, total_tool_count = total_tool_count, - "MCP router: appended native MCP tools to tools/list response" + "MCP router: appended native and extra MCP tools to tools/list response" ); McpProxyResponse { @@ -251,6 +304,7 @@ mod tests { use crate::application::native_mcp_tool_registry::NativeMcpToolRegistry; use crate::application::proxy_mcp::ProxyMcpUseCase; + use crate::application::remote_mcp_container_client::RemoteMcpContainerClient; use crate::domain::errors::ProxyError; use crate::domain::model::HostnameValidationConfig; use crate::ports::mcp_proxy::{McpProxyPort, McpProxyRequest, McpProxyResponse}; @@ -365,6 +419,67 @@ mod tests { ) } + fn router_with_tool_and_extra( + proxy_body: Vec, + extra_proxy: Arc, + extra_client: Arc>, + ) -> ( + McpRouterUseCase, + Arc>>, + Arc, + Arc>>, + ) { + let captured = Arc::new(Mutex::new(Vec::new())); + let proxy = Arc::new(CapturingProxy { + captured: Arc::clone(&captured), + response_body: proxy_body, + }); + let proxy_use_case = Arc::new(ProxyMcpUseCase::new( + proxy, + "http://127.0.0.1:8420".into(), + "shared-secret".into(), + HostnameValidationConfig { + expected_host: None, + enforce: true, + }, + )); + let tool = Arc::new(FakeNativeTool::new()); + let registry = NativeMcpToolRegistry::new(vec![tool.clone() as Arc]); + let extra_captured = Arc::clone(&extra_proxy.captured); + ( + McpRouterUseCase::new_with_extra_containers( + proxy_use_case, + Arc::new(registry), + vec![extra_client], + ), + captured, + tool, + extra_captured, + ) + } + + async fn initialized_extra_client( + response_body: Vec, + ) -> ( + Arc, + Arc>, + ) { + let captured = Arc::new(Mutex::new(Vec::new())); + let proxy = Arc::new(CapturingProxy { + captured, + response_body, + }); + let client = RemoteMcpContainerClient::initialize_and_cache_tools( + "fluensy_learn".into(), + "http://127.0.0.1:18420/mcp".into(), + None, + Arc::clone(&proxy), + ) + .await + .expect("extra client should initialize"); + (proxy, Arc::new(client)) + } + #[tokio::test] async fn tools_call_for_native_tool_bypasses_proxy_and_returns_local_result() { let (router, captured, tool) = @@ -473,6 +588,148 @@ mod tests { ); } + #[tokio::test] + async fn tools_list_appends_prefixed_extra_container_tool_schemas() { + let (extra_proxy, extra_client) = initialized_extra_client( + json!({ + "jsonrpc": "2.0", + "id": 99, + "result": { + "tools": [ + { + "name": "search_deck", + "description": "Search deck", + "inputSchema": { "type": "object" } + } + ] + } + }) + .to_string() + .into_bytes(), + ) + .await; + let (router, captured, _, extra_captured) = router_with_tool_and_extra( + json!({ + "jsonrpc": "2.0", + "id": 3, + "result": { + "tools": [ + { + "name": "container_tool", + "description": "Container tool", + "inputSchema": { "type": "object" } + } + ] + } + }) + .to_string() + .into_bytes(), + extra_proxy, + extra_client, + ); + + let response = router + .handle( + "brain3.example.com", + "POST", + "/mcp", + None, + vec![("content-type".into(), "application/json".into())], + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/list" + }) + .to_string() + .into_bytes(), + ) + .await + .expect("tools/list should succeed"); + + assert_eq!( + captured.lock().expect("capture lock should succeed").len(), + 1, + "vault proxy should still receive tools/list" + ); + assert_eq!( + extra_captured + .lock() + .expect("extra capture lock should succeed") + .len(), + 2, + "extra client should only have startup initialize and tools/list calls" + ); + + let body: Value = + serde_json::from_slice(&response.body).expect("response body should be JSON"); + let tool_names = body["result"]["tools"] + .as_array() + .expect("tools should be an array") + .iter() + .map(|tool| tool["name"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!( + tool_names, + vec![ + "container_tool", + "fake_native_tool", + "fluensy_learn__search_deck" + ] + ); + } + + #[tokio::test] + async fn tools_call_for_prefixed_extra_tool_bypasses_vault_proxy_and_strips_prefix() { + let (extra_proxy, extra_client) = initialized_extra_client( + br#"{"jsonrpc":"2.0","id":99,"result":{"tools":[]}}"#.to_vec(), + ) + .await; + let (router, captured, _, extra_captured) = router_with_tool_and_extra( + br#"{"jsonrpc":"2.0","result":{}}"#.to_vec(), + extra_proxy, + extra_client, + ); + + let response = router + .handle( + "brain3.example.com", + "POST", + "/mcp", + None, + vec![("content-type".into(), "application/json".into())], + json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "fluensy_learn__search_deck", + "arguments": { "query": "rust" } + } + }) + .to_string() + .into_bytes(), + ) + .await + .expect("extra tools/call should succeed"); + + assert_eq!(response.status, 200); + assert!( + captured + .lock() + .expect("vault capture lock should succeed") + .is_empty(), + "vault proxy should not receive prefixed extra tool call" + ); + let extra_requests = extra_captured + .lock() + .expect("extra capture lock should succeed"); + assert_eq!(extra_requests.len(), 3); + let body: Value = + serde_json::from_slice(&extra_requests[2].body).expect("request should be JSON"); + assert_eq!(body["params"]["name"], "search_deck"); + assert_eq!(body["params"]["arguments"]["query"], "rust"); + } + #[tokio::test] async fn initialize_forwards_to_proxy_and_initializes_native_tools() { let (router, captured, tool) = diff --git a/crates/core/src/application/mod.rs b/crates/core/src/application/mod.rs index 0ff0d27..f88065f 100644 --- a/crates/core/src/application/mod.rs +++ b/crates/core/src/application/mod.rs @@ -3,4 +3,5 @@ pub mod first_run_setup; pub mod mcp_router; pub mod native_mcp_tool_registry; pub mod proxy_mcp; +pub mod remote_mcp_container_client; pub mod validate_request; diff --git a/crates/core/src/application/remote_mcp_container_client.rs b/crates/core/src/application/remote_mcp_container_client.rs new file mode 100644 index 0000000..60d6b6c --- /dev/null +++ b/crates/core/src/application/remote_mcp_container_client.rs @@ -0,0 +1,393 @@ +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::domain::errors::ProxyError; +use crate::domain::redact::elide_secret; +use crate::ports::mcp_proxy::{McpProxyPort, McpProxyRequest, McpProxyResponse}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteMcpContainerToolSchema { + pub prefixed_name: String, + pub original_name: String, + pub schema: Value, +} + +pub struct RemoteMcpContainerClient { + container_name: String, + mcp_url: String, + bearer_token: Option, + proxy: Arc

, + tool_schemas: Vec, +} + +impl RemoteMcpContainerClient

{ + pub async fn initialize_and_cache_tools( + container_name: String, + mcp_url: String, + bearer_token: Option, + proxy: Arc

, + ) -> Result { + let client = Self { + container_name, + mcp_url, + bearer_token, + proxy, + tool_schemas: Vec::new(), + }; + + tracing::info!( + container = %client.container_name, + mcp_url = %client.mcp_url, + bearer_token_configured = client.bearer_token.is_some(), + "initializing extra MCP container client" + ); + client.initialize().await?; + let tool_schemas = client.fetch_prefixed_tool_schemas().await?; + + tracing::info!( + container = %client.container_name, + tool_count = tool_schemas.len(), + "cached extra MCP container tool schemas" + ); + + Ok(Self { + tool_schemas, + ..client + }) + } + + pub fn container_name(&self) -> &str { + &self.container_name + } + + pub fn prefixed_tool_schemas(&self) -> Vec { + self.tool_schemas + .iter() + .map(|tool| tool.schema.clone()) + .collect() + } + + pub fn strip_prefix<'a>(&self, tool_name: &'a str) -> Option<&'a str> { + tool_name + .strip_prefix(self.container_name.as_str()) + .and_then(|rest| rest.strip_prefix("__")) + .filter(|name| !name.is_empty()) + } + + pub async fn call_tool( + &self, + request: &Value, + unprefixed_tool_name: &str, + ) -> Result { + let mut forwarded = request.clone(); + let Some(params) = forwarded.get_mut("params").and_then(Value::as_object_mut) else { + return Err(ProxyError::BadGateway( + "extra MCP tools/call request missing params object".into(), + )); + }; + params.insert( + "name".into(), + Value::String(unprefixed_tool_name.to_string()), + ); + + let body = serde_json::to_vec(&forwarded).map_err(|error| { + ProxyError::BadGateway(format!("failed to serialize extra MCP tools/call: {error}")) + })?; + + tracing::info!( + container = %self.container_name, + tool_name = unprefixed_tool_name, + request_id = ?request.get("id"), + "forwarding tools/call to extra MCP container" + ); + + self.forward_json(body).await + } + + async fn initialize(&self) -> Result<(), ProxyError> { + let response = self + .forward_json( + serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": format!("brain3-extra-{}-initialize", self.container_name), + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "brain3-gateway", + "version": env!("CARGO_PKG_VERSION"), + } + } + })) + .map_err(|error| { + ProxyError::BadGateway(format!( + "failed to serialize extra MCP initialize: {error}" + )) + })?, + ) + .await?; + self.require_success("initialize", &response) + } + + async fn fetch_prefixed_tool_schemas( + &self, + ) -> Result, ProxyError> { + let response = self + .forward_json( + serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": format!("brain3-extra-{}-tools-list", self.container_name), + "method": "tools/list", + "params": {} + })) + .map_err(|error| { + ProxyError::BadGateway(format!( + "failed to serialize extra MCP tools/list: {error}" + )) + })?, + ) + .await?; + self.require_success("tools/list", &response)?; + + let body = serde_json::from_slice::(&response.body).map_err(|error| { + ProxyError::BadGateway(format!( + "extra MCP container '{}' returned invalid tools/list JSON: {error}", + self.container_name + )) + })?; + let tools = body + .get("result") + .and_then(|result| result.get("tools")) + .and_then(Value::as_array) + .ok_or_else(|| { + ProxyError::BadGateway(format!( + "extra MCP container '{}' tools/list response missing result.tools array", + self.container_name + )) + })?; + + let mut schemas = Vec::new(); + for tool in tools { + let Some(original_name) = tool.get("name").and_then(Value::as_str) else { + tracing::warn!( + container = %self.container_name, + schema = %tool, + "skipping extra MCP tool schema without string name" + ); + continue; + }; + let mut schema = tool.clone(); + if let Some(object) = schema.as_object_mut() { + object.insert( + "name".into(), + Value::String(format!("{}__{}", self.container_name, original_name)), + ); + } else { + tracing::warn!( + container = %self.container_name, + schema = %tool, + "skipping non-object extra MCP tool schema" + ); + continue; + } + + schemas.push(RemoteMcpContainerToolSchema { + prefixed_name: format!("{}__{}", self.container_name, original_name), + original_name: original_name.to_string(), + schema, + }); + } + + Ok(schemas) + } + + async fn forward_json(&self, body: Vec) -> Result { + let mut headers = vec![ + ("content-type".into(), "application/json".into()), + ( + "accept".into(), + "application/json, text/event-stream".into(), + ), + ]; + if let Some(token) = &self.bearer_token { + headers.push(("authorization".into(), format!("Bearer {token}"))); + } + + tracing::debug!( + container = %self.container_name, + mcp_url = %self.mcp_url, + bearer_token_hint = ?self.bearer_token.as_ref().map(|token| elide_secret(token)), + body_bytes = body.len(), + "sending request to extra MCP container" + ); + + self.proxy + .forward(McpProxyRequest { + method: "POST".into(), + url: self.mcp_url.clone(), + headers, + body, + }) + .await + } + + fn require_success( + &self, + operation: &str, + response: &McpProxyResponse, + ) -> Result<(), ProxyError> { + if (200..300).contains(&response.status) { + return Ok(()); + } + + Err(ProxyError::BadGateway(format!( + "extra MCP container '{}' {operation} failed with HTTP {}", + self.container_name, response.status + ))) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + use serde_json::json; + + use crate::ports::mcp_proxy::McpProxyRequest; + + use super::*; + + #[derive(Default)] + struct CapturingProxy { + requests: Arc>>, + responses: Arc>>, + } + + impl CapturingProxy { + fn with_responses(responses: Vec) -> Self { + Self { + responses: Arc::new(Mutex::new(responses)), + ..Default::default() + } + } + } + + #[async_trait] + impl McpProxyPort for CapturingProxy { + async fn forward(&self, request: McpProxyRequest) -> Result { + self.requests + .lock() + .expect("requests lock should succeed") + .push(request); + Ok(self + .responses + .lock() + .expect("responses lock should succeed") + .remove(0)) + } + } + + fn json_response(value: Value) -> McpProxyResponse { + McpProxyResponse { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body: serde_json::to_vec(&value).expect("test JSON should serialize"), + } + } + + #[tokio::test] + async fn initialize_and_cache_tools_prefixes_tool_names_and_sends_bearer_auth() { + let proxy = Arc::new(CapturingProxy::with_responses(vec![ + json_response(json!({"jsonrpc": "2.0", "id": 1, "result": {}})), + json_response(json!({ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "search_deck", + "description": "Search deck", + "inputSchema": { "type": "object" } + } + ] + } + })), + ])); + + let client = RemoteMcpContainerClient::initialize_and_cache_tools( + "fluensy_learn".into(), + "http://127.0.0.1:18420/mcp".into(), + Some("secret-token".into()), + proxy.clone(), + ) + .await + .expect("client should initialize"); + + let schemas = client.prefixed_tool_schemas(); + assert_eq!(schemas.len(), 1); + assert_eq!(schemas[0]["name"], "fluensy_learn__search_deck"); + assert_eq!(schemas[0]["description"], "Search deck"); + + let requests = proxy.requests.lock().expect("requests lock should succeed"); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].url, "http://127.0.0.1:18420/mcp"); + assert!(requests[0] + .headers + .iter() + .any(|(name, value)| name.eq_ignore_ascii_case("authorization") + && value == "Bearer secret-token")); + } + + #[tokio::test] + async fn call_tool_strips_container_prefix_before_forwarding() { + let proxy = Arc::new(CapturingProxy::with_responses(vec![ + json_response(json!({"jsonrpc": "2.0", "id": 1, "result": {}})), + json_response(json!({"jsonrpc": "2.0", "id": 2, "result": {"tools": []}})), + json_response(json!({ + "jsonrpc": "2.0", + "id": 7, + "result": { + "content": [{ "type": "text", "text": "extra response" }] + } + })), + ])); + let client = RemoteMcpContainerClient::initialize_and_cache_tools( + "fluensy_learn".into(), + "http://127.0.0.1:18420/mcp".into(), + None, + proxy.clone(), + ) + .await + .expect("client should initialize"); + + let response = client + .call_tool( + &json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "fluensy_learn__search_deck", + "arguments": { "query": "rust" } + } + }), + "search_deck", + ) + .await + .expect("tool call should forward"); + + assert_eq!(response.status, 200); + let requests = proxy.requests.lock().expect("requests lock should succeed"); + let body: Value = + serde_json::from_slice(&requests[2].body).expect("forwarded body should be JSON"); + assert_eq!(body["params"]["name"], "search_deck"); + assert_eq!(body["params"]["arguments"]["query"], "rust"); + assert!(!requests[2] + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization"))); + } +} From 79bc136e5e0772823b9b9db939e2aad09df875fb Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 17:20:40 +0200 Subject: [PATCH 09/12] add rename to plan --- .../plans/2026-07-09-plugin-mcp-containers-config.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md b/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md index 8ff317d..9f9d491 100644 --- a/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md +++ b/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md @@ -380,7 +380,9 @@ Proposed approach — generalize instead of special-casing per container: --- -## Phase 4 — Documentation +## Phase 4 — Documentation and rename + +We used "extra container" and "extra mcp container" in the earlier phases. change to use "Plugin Container" and "Plugin MCP Container" everywerher consistently. - `README.md` — one short "Experimental" section pointing at `brain3.yaml`'s location and the `plugin_mcp_containers` schema, explicitly marked From 83abe95eae13818e511609ed517f0871cf7ef8e6 Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 17:45:56 +0200 Subject: [PATCH 10/12] phase 4 and 5, e2e test passes --- README.MD | 31 ++++ SECURITY_AUDIT.md | 13 +- apps/gateway/src/main.rs | 4 +- apps/gateway/src/server.rs | 139 +++++++++------ apps/gateway/src/tui/screens.rs | 5 +- apps/gateway/tests/e2e_smoke.rs | 122 +++++++++++-- crates/core/src/application/mcp_router.rs | 88 +++++----- .../remote_mcp_container_client.rs | 34 ++-- crates/core/src/domain/model.rs | 8 +- ...cp_containers_config.rs => brain3_yaml.rs} | 94 +++++----- crates/platform/src/config/mod.rs | 2 +- crates/platform/src/container/docker.rs | 5 +- crates/platform/src/container/startup.rs | 163 ++++++++++-------- crates/platform/src/runtime/bootstrap.rs | 62 +++---- scripts/e2e_smoke.py | 48 ++++-- scripts/test_e2e_smoke.py | 32 +++- .../e2e_hello_mcp_container/Containerfile | 7 + testdata/e2e_hello_mcp_container/server.py | 104 +++++++++++ 18 files changed, 645 insertions(+), 316 deletions(-) rename crates/platform/src/config/{mcp_containers_config.rs => brain3_yaml.rs} (81%) create mode 100644 testdata/e2e_hello_mcp_container/Containerfile create mode 100644 testdata/e2e_hello_mcp_container/server.py diff --git a/README.MD b/README.MD index d2fc75c..81b67e7 100644 --- a/README.MD +++ b/README.MD @@ -391,6 +391,37 @@ brain3.yourdomain.com { +

+Experimental: Plugin MCP Containers + +Plugin MCP Containers are unsupported and subject to change. They are configured +only by hand-editing `brain3.yaml` in your Brain3 home directory, usually +`~/.brain3/brain3.yaml`. + +Today Brain3 reads only the `plugin_mcp_containers` section: + +```yaml +plugin_mcp_containers: + - name: hello_mcp + platform: docker + image: ghcr.io/example/hello-mcp + tag: latest + port: 8420 + host_directory: /Users/you/hello-mcp-data + container_directory: /data + auth: + type: bearer_token + secret_file: /Users/you/.brain3/secrets/hello_mcp.token + secret_mount_path: /run/secrets/mcp_bearer_token +``` + +`name` must use lowercase letters, digits, and underscores only. Brain3 starts +each configured Plugin MCP Container with one read-write host directory mount, +sends `Authorization: Bearer ` when `auth.type` is `bearer_token`, and +exposes its tools with a `{name}__` prefix. + +
+ ## Privacy & Security
diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 56b8abb..1be5da7 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -54,7 +54,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Local `.env`, Cloudflare tunnel config, and SQLite token database files - Gateway public origin, Cloudflare tunnel identity, and container network isolation state - Native Whisper model files loaded by the gateway process for audio transcription -- Experimental extra MCP container config at `/mcp_containers.yaml`, referenced container images, referenced bearer-token secret files, and host directories mounted into those containers +- Experimental Plugin MCP Container config at `/brain3.yaml`, referenced container images, referenced bearer-token secret files, and host directories mounted into those containers ### Trust Boundaries @@ -63,7 +63,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Local host filesystem and temp-directory principals - Local process-control principals that can signal the running gateway process - The boundary between the Rust gateway and the `brain3-mcp-vault-tools` container -- The boundary between the Rust gateway and any opt-in extra MCP containers started from `/mcp_containers.yaml` +- The boundary between the Rust gateway and any opt-in Plugin MCP Containers started from `/brain3.yaml` - The native transcription boundary where the Rust gateway downloads and parses untrusted audio bytes in-process instead of forwarding them to the container - Gateway-initiated internet egress for temporary audio `download_url` fetches and setup-time Whisper model downloads - Vault content that may be user-controlled or, in some deployments, third-party-controlled @@ -74,7 +74,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Operate or compromise the preregistered OAuth client after it is provisioned with Brain3 credentials - Supply a `transcribe_audio_file` audio `download_url` through an authorized MCP client, causing the gateway to issue an outbound GET and parse the returned bytes as audio - Read local files or logs available to the current OS principal or broader local principals -- Write `/mcp_containers.yaml`, any referenced bearer-token secret file, or any referenced extra-container image/tag. A principal with that capability can cause Brain3 to run arbitrary Docker/macOS-container images and mount the configured `host_directory` read-write into those containers, with the effective privileges of the selected container runtime and the user running Brain3. +- Write `/brain3.yaml`, any referenced bearer-token secret file, or any referenced Plugin MCP Container image/tag. A principal with that capability can cause Brain3 to run arbitrary Docker/macOS-container images and mount the configured `host_directory` read-write into those containers, with the effective privileges of the selected container runtime and the user running Brain3. - Supply hostile vault content when the user does not fully control imported or shared notes ### Security Objectives @@ -83,7 +83,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Public ingress should be opt-in and should not broaden exposure accidentally - Local secrets and vault contents should not leak through logs or insecure default storage/permissions - Container networking should keep the MCP server private by default -- Extra MCP containers should remain an opt-in, local-file-only experimental surface; there must be no remote API, setup wizard path, or dynamic client registration path that can add or modify these containers. +- Plugin MCP Containers should remain an opt-in, local-file-only experimental surface; there must be no remote API, setup wizard path, or dynamic client registration path that can add or modify these containers. - Native transcription should enforce bounded downloads and reject bytes that cannot be decoded as audio before running Whisper inference ### Assumptions @@ -95,8 +95,9 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Whisper model downloads are setup-time, user-initiated egress and must be checksum-verified before model files are treated as usable. - Prompt injection is generally out of scope for user-controlled vault content, but not for vaults the user does not fully control - The gateway's SIGUSR1 diagnostics trigger is local-only: a sender must already be able to signal the gateway process, and the dump is written only to that process's stdout plus a log-file marker. The dump includes the MCP container's own logs, so the container must continue to avoid logging secrets; trace-level body logging remains covered by Finding 4. -- Extra MCP containers are Experimental and intentionally undocumented outside the README experimental section. They are configured only by hand-editing local files under `` and are trusted at the same level as the core vault MCP container once started. -- Extra-container tool metadata and tool results are not sandboxed or semantically vetted by Brain3 before being appended to `tools/list` or returned from `tools/call`. This matches the current trust model for the vault container's tools and must be treated as trusted local-container output, not as untrusted internet content. +- Plugin MCP Containers are Experimental and intentionally undocumented outside the README experimental section. They are configured only by hand-editing local files under `` and are trusted at the same level as the core vault MCP container once started. +- Plugin MCP Container tool metadata and tool results are not sandboxed or semantically vetted by Brain3 before being appended to `tools/list` or returned from `tools/call`. This matches the current trust model for the vault container's tools and must be treated as trusted local-container output, not as untrusted internet content. +- Docker-backed Plugin MCP Containers on macOS are reached through a loopback-published host port because Docker Desktop does not expose published ports from internal-only networks. This remains local-only ingress, but it is not the same internal-network-only posture used by Linux Docker Plugin MCP Containers. ## Findings diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index bf686fd..26c79e7 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -741,11 +741,11 @@ async fn run_cli_mode( let _diag_listener = diagnostics::spawn_diagnostics_signal_listener(Arc::clone(&runtime.config)); - server::run_gateway_server_until_with_extra_containers( + server::run_gateway_server_until_with_plugin_containers( host, Arc::clone(&runtime.config), runtime.upstream_secret.clone(), - runtime.extra_mcp_containers.clone(), + runtime.plugin_mcp_containers.clone(), shutdown_signal(), ) .await?; diff --git a/apps/gateway/src/server.rs b/apps/gateway/src/server.rs index b783783..6f2a3b1 100644 --- a/apps/gateway/src/server.rs +++ b/apps/gateway/src/server.rs @@ -1,6 +1,7 @@ use std::future::Future; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; +use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; use brain3_core::application::mcp_router::McpRouterUseCase; @@ -13,12 +14,12 @@ use tokio::task::JoinHandle; use brain3_core::application::proxy_mcp::ProxyMcpUseCase; use brain3_core::application::remote_mcp_container_client::RemoteMcpContainerClient; -use brain3_core::domain::model::{AccessMode, ExtraMcpContainerAuth, GatewayConfig}; +use brain3_core::domain::model::{AccessMode, GatewayConfig, PluginMcpContainerAuth}; use brain3_core::domain::setup::{RuntimeLaunchPlan, RuntimeStartupPolicy}; use brain3_core::ports::config::ConfigPort; use brain3_core::ports::native_mcp_tool::NativeMcpTool; use brain3_platform::config::env_file::EnvFileConfigAdapter; -use brain3_platform::container::startup::StartedExtraMcpContainer; +use brain3_platform::container::startup::StartedPluginMcpContainer; use brain3_platform::http::rate_limit::OAuthRateLimiter; use brain3_platform::http::registrar::GatewayRegistrar; use brain3_platform::http::router::{build_local_router, build_router}; @@ -32,6 +33,9 @@ use brain3_platform::token_store::sqlite::SqliteTokenStore; use crate::{apply_runtime_overrides, release, RuntimeOverrides}; +const PLUGIN_MCP_CLIENT_INIT_TIMEOUT: Duration = Duration::from_secs(15); +const PLUGIN_MCP_CLIENT_INIT_RETRY_INTERVAL: Duration = Duration::from_millis(250); + pub struct ConfiguredGatewaySession { pub runtime: RuntimeBootstrap, pub server: Option, @@ -107,7 +111,7 @@ pub async fn run_gateway_server_until( where F: Future + Send + 'static, { - run_gateway_server_until_with_extra_containers( + run_gateway_server_until_with_plugin_containers( host, config, upstream_secret, @@ -117,11 +121,11 @@ where .await } -pub async fn run_gateway_server_until_with_extra_containers( +pub async fn run_gateway_server_until_with_plugin_containers( host: &str, config: Arc, upstream_secret: String, - extra_mcp_containers: Vec, + plugin_mcp_containers: Vec, shutdown: F, ) -> Result<()> where @@ -143,7 +147,7 @@ where .and_then(|(listener, _)| listener.local_addr().ok()) .map(local_url_from_addr); let app_state = - build_gateway_state(Arc::clone(&config), upstream_secret, &extra_mcp_containers).await?; + build_gateway_state(Arc::clone(&config), upstream_secret, &plugin_mcp_containers).await?; let (shutdown_tx, _) = watch::channel(false); tracing::info!( @@ -245,14 +249,14 @@ pub async fn spawn_gateway_server( config: Arc, upstream_secret: String, ) -> Result { - spawn_gateway_server_with_extra_containers(host, config, upstream_secret, Vec::new()).await + spawn_gateway_server_with_plugin_containers(host, config, upstream_secret, Vec::new()).await } -pub async fn spawn_gateway_server_with_extra_containers( +pub async fn spawn_gateway_server_with_plugin_containers( host: &str, config: Arc, upstream_secret: String, - extra_mcp_containers: Vec, + plugin_mcp_containers: Vec, ) -> Result { tracing::info!( access_mode = ?config.access_mode, @@ -283,7 +287,7 @@ pub async fn spawn_gateway_server_with_extra_containers( let bind_addr_display = bind_addr.to_string(); let local_url = local_url_from_addr(bind_addr); let app_state = - build_gateway_state(Arc::clone(&config), upstream_secret, &extra_mcp_containers).await?; + build_gateway_state(Arc::clone(&config), upstream_secret, &plugin_mcp_containers).await?; let (shutdown_tx, _) = watch::channel(false); let mut join_handles = Vec::new(); @@ -366,11 +370,11 @@ pub async fn spawn_configured_gateway_session( bootstrap_configured_runtime(Arc::clone(&config), launch_plan, startup_policy).await?; let (server, display_url) = if runtime.can_start_gateway() { - let server = spawn_gateway_server_with_extra_containers( + let server = spawn_gateway_server_with_plugin_containers( host, Arc::clone(&runtime.config), runtime.upstream_secret.clone(), - runtime.extra_mcp_containers.clone(), + runtime.plugin_mcp_containers.clone(), ) .await?; let local_url = server.local_url().to_string(); @@ -416,7 +420,7 @@ async fn bind_local_mcp_listener(config: &GatewayConfig) -> Result, upstream_secret: String, - extra_mcp_containers: &[StartedExtraMcpContainer], + plugin_mcp_containers: &[StartedPluginMcpContainer], ) -> Result> { let registrar = Arc::new(GatewayRegistrar::new( &config.oauth.client_id, @@ -443,11 +447,12 @@ async fn build_gateway_state( let native_tools = Arc::new(NativeMcpToolRegistry::new(native_mcp_tools_from_config( config.as_ref(), )?)); - let extra_clients = build_extra_mcp_clients(Arc::clone(&mcp_proxy), extra_mcp_containers).await; - let mcp_router = Arc::new(McpRouterUseCase::new_with_extra_containers( + let plugin_clients = + build_plugin_mcp_clients(Arc::clone(&mcp_proxy), plugin_mcp_containers).await; + let mcp_router = Arc::new(McpRouterUseCase::new_with_plugin_containers( proxy_mcp, native_tools, - extra_clients, + plugin_clients, )); let app_state = AppState { @@ -462,26 +467,26 @@ async fn build_gateway_state( Ok(app_state) } -async fn build_extra_mcp_clients( +async fn build_plugin_mcp_clients( proxy: Arc, - containers: &[StartedExtraMcpContainer], + containers: &[StartedPluginMcpContainer], ) -> Vec>> { let mut clients = Vec::new(); for container in containers { - let mcp_url = extra_mcp_url(container); - let bearer_token = match extra_bearer_token(container) { + let mcp_url = plugin_mcp_url(container); + let bearer_token = match plugin_bearer_token(container) { Ok(token) => token, Err(error) => { tracing::error!( container = %container.config.name, error = %error, - "skipping extra MCP container client because bearer token could not be loaded" + "skipping Plugin MCP Container client because bearer token could not be loaded" ); continue; } }; - match RemoteMcpContainerClient::initialize_and_cache_tools( + match initialize_plugin_mcp_client_with_retry( container.config.name.clone(), mcp_url, bearer_token, @@ -494,7 +499,7 @@ async fn build_extra_mcp_clients( tracing::error!( container = %container.config.name, error = %error, - "skipping extra MCP container tools because initialize/tools-list failed" + "skipping Plugin MCP Container tools because initialize/tools-list failed" ); } } @@ -503,7 +508,43 @@ async fn build_extra_mcp_clients( clients } -fn extra_mcp_url(container: &StartedExtraMcpContainer) -> String { +async fn initialize_plugin_mcp_client_with_retry( + container_name: String, + mcp_url: String, + bearer_token: Option, + proxy: Arc, +) -> Result> { + let started_at = Instant::now(); + let deadline = started_at + PLUGIN_MCP_CLIENT_INIT_TIMEOUT; + let mut attempt = 1_u32; + + loop { + match RemoteMcpContainerClient::initialize_and_cache_tools( + container_name.clone(), + mcp_url.clone(), + bearer_token.clone(), + Arc::clone(&proxy), + ) + .await + { + Ok(client) => return Ok(client), + Err(error) if Instant::now() < deadline => { + tracing::warn!( + container = %container_name, + attempt, + elapsed_ms = started_at.elapsed().as_millis() as u64, + error = %error, + "Plugin MCP Container initialize/tools-list failed; retrying" + ); + attempt += 1; + tokio::time::sleep(PLUGIN_MCP_CLIENT_INIT_RETRY_INTERVAL).await; + } + Err(error) => return Err(error.into()), + } + } +} + +fn plugin_mcp_url(container: &StartedPluginMcpContainer) -> String { match &container.container_ip { Some(container_ip) => format!( "http://{}:{}/mcp", @@ -513,10 +554,10 @@ fn extra_mcp_url(container: &StartedExtraMcpContainer) -> String { } } -fn extra_bearer_token(container: &StartedExtraMcpContainer) -> Result> { +fn plugin_bearer_token(container: &StartedPluginMcpContainer) -> Result> { match &container.config.auth { - ExtraMcpContainerAuth::None => Ok(None), - ExtraMcpContainerAuth::BearerToken { secret_file, .. } => { + PluginMcpContainerAuth::None => Ok(None), + PluginMcpContainerAuth::BearerToken { secret_file, .. } => { let token = std::fs::read_to_string(secret_file) .with_context(|| format!("failed to read {}", secret_file.display()))?; Ok(Some(token.trim().to_string())) @@ -582,9 +623,9 @@ mod tests { use std::path::PathBuf; use brain3_core::domain::model::{ - AccessMode, ContainerRuntime, ExtraMcpContainerAuth, ExtraMcpContainerConfig, - GatewayConfig, HostnameValidationConfig, MCPReverseProxyConfig, - NativeAudioTranscriptionConfig, OAuthConfig, + AccessMode, ContainerRuntime, GatewayConfig, HostnameValidationConfig, + MCPReverseProxyConfig, NativeAudioTranscriptionConfig, OAuthConfig, PluginMcpContainerAuth, + PluginMcpContainerConfig, }; use super::*; @@ -607,39 +648,39 @@ mod tests { } #[test] - fn extra_mcp_url_uses_container_ip_when_available() { - let container = StartedExtraMcpContainer { - config: sample_extra_container_config(8420, ExtraMcpContainerAuth::None), + fn plugin_mcp_url_uses_container_ip_when_available() { + let container = StartedPluginMcpContainer { + config: sample_plugin_container_config(8420, PluginMcpContainerAuth::None), host_port: 18420, container_ip: Some("172.18.0.2".into()), }; - assert_eq!(extra_mcp_url(&container), "http://172.18.0.2:8420/mcp"); + assert_eq!(plugin_mcp_url(&container), "http://172.18.0.2:8420/mcp"); } #[test] - fn extra_mcp_url_uses_loopback_host_port_without_container_ip() { - let container = StartedExtraMcpContainer { - config: sample_extra_container_config(8420, ExtraMcpContainerAuth::None), + fn plugin_mcp_url_uses_loopback_host_port_without_container_ip() { + let container = StartedPluginMcpContainer { + config: sample_plugin_container_config(8420, PluginMcpContainerAuth::None), host_port: 18420, container_ip: None, }; - assert_eq!(extra_mcp_url(&container), "http://127.0.0.1:18420/mcp"); + assert_eq!(plugin_mcp_url(&container), "http://127.0.0.1:18420/mcp"); } #[test] - fn extra_bearer_token_reads_and_trims_secret_file() { + fn plugin_bearer_token_reads_and_trims_secret_file() { let secret_file = std::env::temp_dir().join(format!( - "brain3-extra-token-test-{}-{}.token", + "brain3-plugin-token-test-{}-{}.token", std::process::id(), "phase3" )); std::fs::write(&secret_file, "secret-token\n").expect("write secret"); - let container = StartedExtraMcpContainer { - config: sample_extra_container_config( + let container = StartedPluginMcpContainer { + config: sample_plugin_container_config( 8420, - ExtraMcpContainerAuth::BearerToken { + PluginMcpContainerAuth::BearerToken { secret_file, secret_mount_path: "/run/secrets/mcp_bearer_token".into(), }, @@ -649,19 +690,19 @@ mod tests { }; assert_eq!( - extra_bearer_token(&container).expect("token should load"), + plugin_bearer_token(&container).expect("token should load"), Some("secret-token".into()) ); - if let ExtraMcpContainerAuth::BearerToken { secret_file, .. } = &container.config.auth { + if let PluginMcpContainerAuth::BearerToken { secret_file, .. } = &container.config.auth { let _ = std::fs::remove_file(secret_file); } } - fn sample_extra_container_config( + fn sample_plugin_container_config( container_port: u16, - auth: ExtraMcpContainerAuth, - ) -> ExtraMcpContainerConfig { - ExtraMcpContainerConfig { + auth: PluginMcpContainerAuth, + ) -> PluginMcpContainerConfig { + PluginMcpContainerConfig { name: "fluensy_learn".into(), runtime: ContainerRuntime::Docker, image: "ghcr.io/example/fluensy-learn:latest".into(), diff --git a/apps/gateway/src/tui/screens.rs b/apps/gateway/src/tui/screens.rs index 206478b..3c37f99 100644 --- a/apps/gateway/src/tui/screens.rs +++ b/apps/gateway/src/tui/screens.rs @@ -622,10 +622,7 @@ fn ports_and_settings_lines(state: &FirstRunTuiState) -> Vec> { fn audio_transcription_lines(state: &FirstRunTuiState) -> Vec> { vec![ - Line::from(Span::styled( - "Audio Transcription", - section_heading_style(), - )), + Line::from(Span::styled("Audio Transcription", section_heading_style())), blank_line(), muted_line("Audio Transcription is an MCP tool that runs natively in this process."), muted_line( diff --git a/apps/gateway/tests/e2e_smoke.rs b/apps/gateway/tests/e2e_smoke.rs index 4b1cbd0..08a7891 100644 --- a/apps/gateway/tests/e2e_smoke.rs +++ b/apps/gateway/tests/e2e_smoke.rs @@ -4,6 +4,7 @@ use std::collections::BTreeSet; use std::env; use std::fs; use std::io::{self, BufRead, BufReader, Read}; +use std::net::TcpListener as StdTcpListener; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdout, Command, Stdio}; use std::sync::mpsc::{self, Receiver}; @@ -43,6 +44,8 @@ const OAUTH_PASSWORD: &str = "e2e-test-password"; const OAUTH_REDIRECT_URI: &str = "https://claude.ai/api/mcp/auth_callback"; const DIAGNOSTICS_TIMEOUT: Duration = Duration::from_secs(10); const NETWORKED_E2E_TIMEOUT: Duration = Duration::from_secs(15); +const HELLO_MCP_CONTAINER_NAME: &str = "hello_mcp"; +const HELLO_MCP_BEARER_TOKEN: &str = "e2e-hello-mcp-token"; const WHISPER_MODEL_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600); const DEFAULT_WHISPER_MODEL_URL: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin"; @@ -85,6 +88,7 @@ struct TempTestDir { env_file: PathBuf, brain3_db: PathBuf, cloudflared_shim_dir: PathBuf, + container_host_port: u16, tunnel_mode: TunnelMode, } @@ -99,6 +103,7 @@ impl TempTestDir { let cloudflared_shim_dir = root.join("bin"); fs::create_dir_all(&vault)?; fs::create_dir_all(&cloudflared_shim_dir)?; + let container_host_port = pick_free_loopback_port()?; let temp = Self { env_file: root.join(".env"), @@ -106,6 +111,7 @@ impl TempTestDir { root, vault, cloudflared_shim_dir, + container_host_port, tunnel_mode, }; if tunnel_mode.uses_cloudflared_shim() { @@ -144,6 +150,7 @@ impl TempTestDir { B3_CONTAINER_IMAGE_REPO=brain3-mcp-vault-tools\n\ B3_CONTAINER_IMAGE_TAG=e2e-local\n\ B3_UPSTREAM_SHARED_SECRET=e2e-test-upstream-secret\n\ + B3_CONTAINER_HOST_PORT={}\n\ B3_CONTAINER_INTERNAL_NETWORK_ISOLATION=false\n\ B3_LOCAL_MCP_PORT={LOCAL_MCP_PORT}\n\ LOCAL_GATEWAY_MCP_BEARER_TOKEN={LOCAL_BEARER_TOKEN}\n\ @@ -152,6 +159,7 @@ impl TempTestDir { self.brain3_db.display(), self.tunnel_mode.quick_tunnel_env_value(), self.vault.display(), + self.container_host_port, self.tunnel_mode.enforce_hostname_check_env_value(), ); for (key, value) in extra { @@ -163,6 +171,32 @@ impl TempTestDir { fs::write(&self.env_file, env_file) } + fn write_brain3_yaml_with_hello_mcp(&self) -> io::Result<()> { + let host_directory = self.root.join("hello-mcp-data"); + fs::create_dir_all(&host_directory)?; + let secret_file = self.root.join("hello_mcp.token"); + fs::write(&secret_file, format!("{HELLO_MCP_BEARER_TOKEN}\n"))?; + fs::write( + self.root.join("brain3.yaml"), + format!( + r#"plugin_mcp_containers: + - name: {HELLO_MCP_CONTAINER_NAME} + platform: docker + image: brain3-e2e-hello-mcp + tag: e2e-local + port: 8420 + host_directory: {} + container_directory: /data + auth: + type: bearer_token + secret_file: {} +"#, + host_directory.display(), + secret_file.display() + ), + ) + } + fn path_with_shim(&self) -> String { let mut paths = vec![self.cloudflared_shim_dir.clone()]; if let Some(existing) = env::var_os("PATH") { @@ -175,6 +209,14 @@ impl TempTestDir { } } +fn pick_free_loopback_port() -> io::Result { + StdTcpListener::bind(("127.0.0.1", 0)).and_then(|listener| { + let port = listener.local_addr()?.port(); + drop(listener); + Ok(port) + }) +} + #[test] fn recognizes_container_diagnostics_end_sentinel_line() { assert!(is_container_diagnostics_end_sentinel( @@ -268,6 +310,10 @@ impl Brain3Process { .env("B3_CONTAINER_IMAGE_REPO", "brain3-mcp-vault-tools") .env("B3_CONTAINER_IMAGE_TAG", "e2e-local") .env("B3_UPSTREAM_SHARED_SECRET", "e2e-test-upstream-secret") + .env( + "B3_CONTAINER_HOST_PORT", + temp.container_host_port.to_string(), + ) .env("B3_CONTAINER_INTERNAL_NETWORK_ISOLATION", "false") .env("B3_LOCAL_MCP_PORT", LOCAL_MCP_PORT.to_string()) .env("LOCAL_GATEWAY_MCP_BEARER_TOKEN", LOCAL_BEARER_TOKEN) @@ -770,6 +816,43 @@ async fn e2e_smoke_4_local_mcp_transcribes_tts_audio() -> Result<(), Box Result<(), Box> { + let temp = TempTestDir::create(TunnelMode::Disabled)?; + temp.write_env_file()?; + temp.write_brain3_yaml_with_hello_mcp()?; + + { + let gateway = Brain3Process::spawn(&temp, TunnelMode::Disabled).await?; + let _diagnostics_guard = DiagnosticsDumpGuard::new(&gateway); + assert_container_running_and_vault_visible(&gateway).await?; + let client = connect_local_mcp().await?; + + let tools = client.list_tools(Default::default()).await?; + let tool_names = tools + .tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect::>(); + assert!( + tool_names.contains("vault_list"), + "vault tools should still be present alongside Plugin MCP Container tools: {tool_names:?}" + ); + assert!( + tool_names.contains("hello_mcp__hello"), + "Plugin MCP Container tool should be advertised with container prefix: {tool_names:?}" + ); + + let hello = call_tool_text(&client, "hello_mcp__hello", json!({})).await?; + assert_eq!(hello, "hello world"); + + client.cancel().await?; + } + + assert_no_container_residue().await?; + Ok(()) +} + #[tokio::test] async fn e2e_smoke_2_oauth_public_flow() -> Result<(), Box> { let temp = TempTestDir::create(TunnelMode::Disabled)?; @@ -1514,20 +1597,35 @@ fn tool_result_value_json(value: &Value) -> Result Result<(), Box> { let deadline = Instant::now() + Duration::from_secs(15); let mut last_output = String::new(); + let managed_names = [CONTAINER_NAME, HELLO_MCP_CONTAINER_NAME]; while Instant::now() < deadline { - let output = Command::new("docker") - .args([ - "ps", - "-a", - "--filter", - &format!("name={CONTAINER_NAME}"), - "--format", - "{{.Names}}", - ]) - .output()?; - last_output = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if output.status.success() && last_output.is_empty() { + let mut residues = Vec::new(); + for name in managed_names { + let output = Command::new("docker") + .args([ + "ps", + "-a", + "--filter", + &format!("name={name}"), + "--format", + "{{.Names}}", + ]) + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + residues.push(format!("docker ps failed for {name}: {stderr}")); + } else if !stdout.is_empty() { + residues.push(stdout); + } + } + last_output = residues + .into_iter() + .filter(|name| !name.is_empty()) + .collect::>() + .join(", "); + if last_output.is_empty() { return Ok(()); } tokio::time::sleep(Duration::from_millis(250)).await; diff --git a/crates/core/src/application/mcp_router.rs b/crates/core/src/application/mcp_router.rs index f57fd5b..4d32113 100644 --- a/crates/core/src/application/mcp_router.rs +++ b/crates/core/src/application/mcp_router.rs @@ -14,23 +14,23 @@ use crate::ports::native_mcp_tool::{NativeMcpToolError, NativeMcpToolOutput}; pub struct McpRouterUseCase { proxy: Arc>, native_tools: Arc, - extra_containers: Vec>>, + plugin_containers: Vec>>, } impl McpRouterUseCase

{ pub fn new(proxy: Arc>, native_tools: Arc) -> Self { - Self::new_with_extra_containers(proxy, native_tools, Vec::new()) + Self::new_with_plugin_containers(proxy, native_tools, Vec::new()) } - pub fn new_with_extra_containers( + pub fn new_with_plugin_containers( proxy: Arc>, native_tools: Arc, - extra_containers: Vec>>, + plugin_containers: Vec>>, ) -> Self { Self { proxy, native_tools, - extra_containers, + plugin_containers, } } @@ -109,7 +109,7 @@ impl McpRouterUseCase

{ if let Some(response) = self.maybe_call_native_tool(parsed_body.as_ref()).await? { return Ok(response); } - if let Some(response) = self.maybe_call_extra_tool(parsed_body.as_ref()).await? { + if let Some(response) = self.maybe_call_plugin_tool(parsed_body.as_ref()).await? { return Ok(response); } @@ -168,7 +168,7 @@ impl McpRouterUseCase

{ Ok(Some(native_tool_response(request, output)?)) } - async fn maybe_call_extra_tool( + async fn maybe_call_plugin_tool( &self, request: Option<&Value>, ) -> Result, ProxyError> { @@ -182,14 +182,14 @@ impl McpRouterUseCase

{ return Ok(None); }; - for client in &self.extra_containers { + for client in &self.plugin_containers { if let Some(unprefixed_name) = client.strip_prefix(name) { tracing::info!( container = %client.container_name(), prefixed_tool_name = name, tool_name = unprefixed_name, request_id = ?request.get("id"), - "MCP router: routing extra MCP tool call" + "MCP router: routing Plugin MCP tool call" ); return Ok(Some(client.call_tool(request, unprefixed_name).await?)); } @@ -200,13 +200,13 @@ impl McpRouterUseCase

{ fn append_tool_schemas(&self, response: McpProxyResponse) -> McpProxyResponse { let native_schemas = self.native_tools.list_schemas(); - let extra_schemas = self - .extra_containers + let plugin_schemas = self + .plugin_containers .iter() .flat_map(|client| client.prefixed_tool_schemas()) .collect::>(); - if native_schemas.is_empty() && extra_schemas.is_empty() { + if native_schemas.is_empty() && plugin_schemas.is_empty() { return response; } @@ -225,9 +225,9 @@ impl McpRouterUseCase

{ }; let native_tool_count = native_schemas.len(); - let extra_tool_count = extra_schemas.len(); + let plugin_tool_count = plugin_schemas.len(); tools.extend(native_schemas); - tools.extend(extra_schemas); + tools.extend(plugin_schemas); let total_tool_count = tools.len(); let Ok(new_body) = serde_json::to_vec(&body) else { @@ -237,9 +237,9 @@ impl McpRouterUseCase

{ tracing::info!( native_tool_count = native_tool_count, - extra_tool_count = extra_tool_count, + plugin_tool_count = plugin_tool_count, total_tool_count = total_tool_count, - "MCP router: appended native and extra MCP tools to tools/list response" + "MCP router: appended native and Plugin MCP tools to tools/list response" ); McpProxyResponse { @@ -419,10 +419,10 @@ mod tests { ) } - fn router_with_tool_and_extra( + fn router_with_tool_and_plugin( proxy_body: Vec, - extra_proxy: Arc, - extra_client: Arc>, + plugin_proxy: Arc, + plugin_client: Arc>, ) -> ( McpRouterUseCase, Arc>>, @@ -445,20 +445,20 @@ mod tests { )); let tool = Arc::new(FakeNativeTool::new()); let registry = NativeMcpToolRegistry::new(vec![tool.clone() as Arc]); - let extra_captured = Arc::clone(&extra_proxy.captured); + let plugin_captured = Arc::clone(&plugin_proxy.captured); ( - McpRouterUseCase::new_with_extra_containers( + McpRouterUseCase::new_with_plugin_containers( proxy_use_case, Arc::new(registry), - vec![extra_client], + vec![plugin_client], ), captured, tool, - extra_captured, + plugin_captured, ) } - async fn initialized_extra_client( + async fn initialized_plugin_client( response_body: Vec, ) -> ( Arc, @@ -476,7 +476,7 @@ mod tests { Arc::clone(&proxy), ) .await - .expect("extra client should initialize"); + .expect("plugin client should initialize"); (proxy, Arc::new(client)) } @@ -589,8 +589,8 @@ mod tests { } #[tokio::test] - async fn tools_list_appends_prefixed_extra_container_tool_schemas() { - let (extra_proxy, extra_client) = initialized_extra_client( + async fn tools_list_appends_prefixed_plugin_container_tool_schemas() { + let (plugin_proxy, plugin_client) = initialized_plugin_client( json!({ "jsonrpc": "2.0", "id": 99, @@ -608,7 +608,7 @@ mod tests { .into_bytes(), ) .await; - let (router, captured, _, extra_captured) = router_with_tool_and_extra( + let (router, captured, _, plugin_captured) = router_with_tool_and_plugin( json!({ "jsonrpc": "2.0", "id": 3, @@ -624,8 +624,8 @@ mod tests { }) .to_string() .into_bytes(), - extra_proxy, - extra_client, + plugin_proxy, + plugin_client, ); let response = router @@ -652,12 +652,12 @@ mod tests { "vault proxy should still receive tools/list" ); assert_eq!( - extra_captured + plugin_captured .lock() - .expect("extra capture lock should succeed") + .expect("plugin capture lock should succeed") .len(), 2, - "extra client should only have startup initialize and tools/list calls" + "plugin client should only have startup initialize and tools/list calls" ); let body: Value = @@ -679,15 +679,15 @@ mod tests { } #[tokio::test] - async fn tools_call_for_prefixed_extra_tool_bypasses_vault_proxy_and_strips_prefix() { - let (extra_proxy, extra_client) = initialized_extra_client( + async fn tools_call_for_prefixed_plugin_tool_bypasses_vault_proxy_and_strips_prefix() { + let (plugin_proxy, plugin_client) = initialized_plugin_client( br#"{"jsonrpc":"2.0","id":99,"result":{"tools":[]}}"#.to_vec(), ) .await; - let (router, captured, _, extra_captured) = router_with_tool_and_extra( + let (router, captured, _, plugin_captured) = router_with_tool_and_plugin( br#"{"jsonrpc":"2.0","result":{}}"#.to_vec(), - extra_proxy, - extra_client, + plugin_proxy, + plugin_client, ); let response = router @@ -710,7 +710,7 @@ mod tests { .into_bytes(), ) .await - .expect("extra tools/call should succeed"); + .expect("plugin tools/call should succeed"); assert_eq!(response.status, 200); assert!( @@ -718,14 +718,14 @@ mod tests { .lock() .expect("vault capture lock should succeed") .is_empty(), - "vault proxy should not receive prefixed extra tool call" + "vault proxy should not receive prefixed plugin tool call" ); - let extra_requests = extra_captured + let plugin_requests = plugin_captured .lock() - .expect("extra capture lock should succeed"); - assert_eq!(extra_requests.len(), 3); + .expect("plugin capture lock should succeed"); + assert_eq!(plugin_requests.len(), 3); let body: Value = - serde_json::from_slice(&extra_requests[2].body).expect("request should be JSON"); + serde_json::from_slice(&plugin_requests[2].body).expect("request should be JSON"); assert_eq!(body["params"]["name"], "search_deck"); assert_eq!(body["params"]["arguments"]["query"], "rust"); } diff --git a/crates/core/src/application/remote_mcp_container_client.rs b/crates/core/src/application/remote_mcp_container_client.rs index 60d6b6c..828cb1a 100644 --- a/crates/core/src/application/remote_mcp_container_client.rs +++ b/crates/core/src/application/remote_mcp_container_client.rs @@ -40,7 +40,7 @@ impl RemoteMcpContainerClient

{ container = %client.container_name, mcp_url = %client.mcp_url, bearer_token_configured = client.bearer_token.is_some(), - "initializing extra MCP container client" + "initializing Plugin MCP Container client" ); client.initialize().await?; let tool_schemas = client.fetch_prefixed_tool_schemas().await?; @@ -48,7 +48,7 @@ impl RemoteMcpContainerClient

{ tracing::info!( container = %client.container_name, tool_count = tool_schemas.len(), - "cached extra MCP container tool schemas" + "cached Plugin MCP Container tool schemas" ); Ok(Self { @@ -83,7 +83,7 @@ impl RemoteMcpContainerClient

{ let mut forwarded = request.clone(); let Some(params) = forwarded.get_mut("params").and_then(Value::as_object_mut) else { return Err(ProxyError::BadGateway( - "extra MCP tools/call request missing params object".into(), + "Plugin MCP tools/call request missing params object".into(), )); }; params.insert( @@ -92,14 +92,16 @@ impl RemoteMcpContainerClient

{ ); let body = serde_json::to_vec(&forwarded).map_err(|error| { - ProxyError::BadGateway(format!("failed to serialize extra MCP tools/call: {error}")) + ProxyError::BadGateway(format!( + "failed to serialize Plugin MCP tools/call: {error}" + )) })?; tracing::info!( container = %self.container_name, tool_name = unprefixed_tool_name, request_id = ?request.get("id"), - "forwarding tools/call to extra MCP container" + "forwarding tools/call to Plugin MCP Container" ); self.forward_json(body).await @@ -110,7 +112,7 @@ impl RemoteMcpContainerClient

{ .forward_json( serde_json::to_vec(&json!({ "jsonrpc": "2.0", - "id": format!("brain3-extra-{}-initialize", self.container_name), + "id": format!("brain3-plugin-{}-initialize", self.container_name), "method": "initialize", "params": { "protocolVersion": "2024-11-05", @@ -123,7 +125,7 @@ impl RemoteMcpContainerClient

{ })) .map_err(|error| { ProxyError::BadGateway(format!( - "failed to serialize extra MCP initialize: {error}" + "failed to serialize Plugin MCP initialize: {error}" )) })?, ) @@ -138,13 +140,13 @@ impl RemoteMcpContainerClient

{ .forward_json( serde_json::to_vec(&json!({ "jsonrpc": "2.0", - "id": format!("brain3-extra-{}-tools-list", self.container_name), + "id": format!("brain3-plugin-{}-tools-list", self.container_name), "method": "tools/list", "params": {} })) .map_err(|error| { ProxyError::BadGateway(format!( - "failed to serialize extra MCP tools/list: {error}" + "failed to serialize Plugin MCP tools/list: {error}" )) })?, ) @@ -153,7 +155,7 @@ impl RemoteMcpContainerClient

{ let body = serde_json::from_slice::(&response.body).map_err(|error| { ProxyError::BadGateway(format!( - "extra MCP container '{}' returned invalid tools/list JSON: {error}", + "Plugin MCP Container '{}' returned invalid tools/list JSON: {error}", self.container_name )) })?; @@ -163,7 +165,7 @@ impl RemoteMcpContainerClient

{ .and_then(Value::as_array) .ok_or_else(|| { ProxyError::BadGateway(format!( - "extra MCP container '{}' tools/list response missing result.tools array", + "Plugin MCP Container '{}' tools/list response missing result.tools array", self.container_name )) })?; @@ -174,7 +176,7 @@ impl RemoteMcpContainerClient

{ tracing::warn!( container = %self.container_name, schema = %tool, - "skipping extra MCP tool schema without string name" + "skipping Plugin MCP tool schema without string name" ); continue; }; @@ -188,7 +190,7 @@ impl RemoteMcpContainerClient

{ tracing::warn!( container = %self.container_name, schema = %tool, - "skipping non-object extra MCP tool schema" + "skipping non-object Plugin MCP tool schema" ); continue; } @@ -220,7 +222,7 @@ impl RemoteMcpContainerClient

{ mcp_url = %self.mcp_url, bearer_token_hint = ?self.bearer_token.as_ref().map(|token| elide_secret(token)), body_bytes = body.len(), - "sending request to extra MCP container" + "sending request to Plugin MCP Container" ); self.proxy @@ -243,7 +245,7 @@ impl RemoteMcpContainerClient

{ } Err(ProxyError::BadGateway(format!( - "extra MCP container '{}' {operation} failed with HTTP {}", + "Plugin MCP Container '{}' {operation} failed with HTTP {}", self.container_name, response.status ))) } @@ -350,7 +352,7 @@ mod tests { "jsonrpc": "2.0", "id": 7, "result": { - "content": [{ "type": "text", "text": "extra response" }] + "content": [{ "type": "text", "text": "plugin response" }] } })), ])); diff --git a/crates/core/src/domain/model.rs b/crates/core/src/domain/model.rs index 0f708e6..1c9f2b5 100644 --- a/crates/core/src/domain/model.rs +++ b/crates/core/src/domain/model.rs @@ -66,7 +66,7 @@ pub enum ContainerRuntime { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtraMcpContainerConfig { +pub struct PluginMcpContainerConfig { pub name: String, pub runtime: ContainerRuntime, pub image: String, @@ -74,11 +74,11 @@ pub struct ExtraMcpContainerConfig { pub host_port: Option, pub host_directory: PathBuf, pub container_directory: PathBuf, - pub auth: ExtraMcpContainerAuth, + pub auth: PluginMcpContainerAuth, } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ExtraMcpContainerAuth { +pub enum PluginMcpContainerAuth { None, BearerToken { secret_file: PathBuf, @@ -107,7 +107,7 @@ pub const BRAIN3_ROLE_LABEL_KEY: &str = "io.brain3.role"; pub const BRAIN3_INSTALLATION_ID_LABEL_KEY: &str = "io.brain3.installation_id"; pub const BRAIN3_MANAGED_LABEL_VALUE: &str = "true"; pub const BRAIN3_MCP_ROLE_LABEL_VALUE: &str = "mcp"; -pub const BRAIN3_EXTRA_MCP_ROLE_LABEL_PREFIX: &str = "brain3-mcp-extra:"; +pub const BRAIN3_PLUGIN_MCP_ROLE_LABEL_PREFIX: &str = "brain3-mcp-plugin:"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContainerLabel { diff --git a/crates/platform/src/config/mcp_containers_config.rs b/crates/platform/src/config/brain3_yaml.rs similarity index 81% rename from crates/platform/src/config/mcp_containers_config.rs rename to crates/platform/src/config/brain3_yaml.rs index 5f04589..c4a00ab 100644 --- a/crates/platform/src/config/mcp_containers_config.rs +++ b/crates/platform/src/config/brain3_yaml.rs @@ -3,7 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; use brain3_core::domain::model::{ - ContainerRuntime, ExtraMcpContainerAuth, ExtraMcpContainerConfig, + ContainerRuntime, PluginMcpContainerAuth, PluginMcpContainerConfig, }; use serde::Deserialize; @@ -11,13 +11,13 @@ const DEFAULT_CONTAINER_DIRECTORY: &str = "/data"; const DEFAULT_SECRET_MOUNT_PATH: &str = "/run/secrets/mcp_bearer_token"; #[derive(Debug, Deserialize)] -struct RawExtraMcpContainersConfig { +struct RawBrain3YamlConfig { #[serde(default)] - mcp_containers: Vec, + plugin_mcp_containers: Vec, } #[derive(Debug, Deserialize)] -struct RawExtraMcpContainerConfig { +struct RawPluginMcpContainerConfig { name: Option, platform: Option, image: Option, @@ -26,24 +26,24 @@ struct RawExtraMcpContainerConfig { host_port: Option, host_directory: Option, container_directory: Option, - auth: Option, + auth: Option, } #[derive(Debug, Deserialize)] -struct RawExtraMcpContainerAuth { +struct RawPluginMcpContainerAuth { #[serde(rename = "type")] auth_type: Option, secret_file: Option, secret_mount_path: Option, } -pub fn load_extra_mcp_containers_config(path: &Path) -> Vec { +pub fn load_plugin_mcp_containers_config(path: &Path) -> Vec { let contents = match fs::read_to_string(path) { Ok(contents) => contents, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { tracing::debug!( path = %path.display(), - "extra MCP containers config file not found" + "brain3.yaml config file not found" ); return Vec::new(); } @@ -51,42 +51,42 @@ pub fn load_extra_mcp_containers_config(path: &Path) -> Vec raw, Err(error) => { tracing::error!( path = %path.display(), error = %error, - "failed to parse extra MCP containers config file" + "failed to parse brain3.yaml config file" ); return Vec::new(); } }; - validate_extra_mcp_containers(raw.mcp_containers) + validate_plugin_mcp_containers(raw.plugin_mcp_containers) } -fn validate_extra_mcp_containers( - entries: Vec, -) -> Vec { +fn validate_plugin_mcp_containers( + entries: Vec, +) -> Vec { let mut seen_names = HashSet::new(); let mut configs = Vec::new(); for entry in entries { let name_for_log = entry.name.as_deref().unwrap_or("").to_string(); - match validate_extra_mcp_container(entry, &mut seen_names) { + match validate_plugin_mcp_container(entry, &mut seen_names) { Ok(config) => configs.push(config), Err(reason) => { tracing::error!( container = %name_for_log, reason = %reason, - "skipping invalid extra MCP container config" + "skipping invalid Plugin MCP Container config" ); } } @@ -95,10 +95,10 @@ fn validate_extra_mcp_containers( configs } -fn validate_extra_mcp_container( - entry: RawExtraMcpContainerConfig, +fn validate_plugin_mcp_container( + entry: RawPluginMcpContainerConfig, seen_names: &mut HashSet, -) -> Result { +) -> Result { let name = required_string(entry.name, "name")?; validate_name(&name)?; if !seen_names.insert(name.clone()) { @@ -118,7 +118,7 @@ fn validate_extra_mcp_container( .unwrap_or_else(|| DEFAULT_CONTAINER_DIRECTORY.into()); let auth = parse_auth(entry.auth)?; - Ok(ExtraMcpContainerConfig { + Ok(PluginMcpContainerConfig { name, runtime, image: format!("{image}:{tag}"), @@ -159,16 +159,16 @@ fn parse_runtime(value: &str) -> Result { } } -fn parse_auth(auth: Option) -> Result { +fn parse_auth(auth: Option) -> Result { let auth = auth.ok_or_else(|| "missing auth".to_string())?; match required_string(auth.auth_type, "auth.type")?.as_str() { - "none" => Ok(ExtraMcpContainerAuth::None), + "none" => Ok(PluginMcpContainerAuth::None), "bearer_token" => { let secret_file = auth .secret_file .ok_or_else(|| "missing auth.secret_file".to_string())?; validate_readable_file(&secret_file, "auth.secret_file")?; - Ok(ExtraMcpContainerAuth::BearerToken { + Ok(PluginMcpContainerAuth::BearerToken { secret_file, secret_mount_path: auth .secret_mount_path @@ -208,10 +208,10 @@ mod tests { use std::path::Path; use brain3_core::domain::model::{ - ContainerRuntime, ExtraMcpContainerAuth, ExtraMcpContainerConfig, + ContainerRuntime, PluginMcpContainerAuth, PluginMcpContainerConfig, }; - use super::load_extra_mcp_containers_config; + use super::load_plugin_mcp_containers_config; fn write_file(path: &Path, contents: &str) { fs::write(path, contents).expect("write test file"); @@ -238,9 +238,9 @@ mod tests { #[test] fn absent_file_loads_empty_config() { let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("mcp_containers.yaml"); + let path = temp.path().join("brain3.yaml"); - let configs = load_extra_mcp_containers_config(&path); + let configs = load_plugin_mcp_containers_config(&path); assert!(configs.is_empty()); } @@ -256,12 +256,12 @@ mod tests { let second_secret = temp.path().join("second.token"); write_file(&first_secret, "first-secret"); write_file(&second_secret, "second-secret"); - let config_path = temp.path().join("mcp_containers.yaml"); + let config_path = temp.path().join("brain3.yaml"); write_file( &config_path, &format!( r#" -mcp_containers: +plugin_mcp_containers: {} - name: second_tool platform: macos_container @@ -279,12 +279,12 @@ mcp_containers: ), ); - let configs = load_extra_mcp_containers_config(&config_path); + let configs = load_plugin_mcp_containers_config(&config_path); assert_eq!(configs.len(), 2); assert_eq!( configs[0], - ExtraMcpContainerConfig { + PluginMcpContainerConfig { name: "first_tool".to_string(), runtime: ContainerRuntime::Docker, image: "ghcr.io/example/first_tool:latest".to_string(), @@ -292,7 +292,7 @@ mcp_containers: host_port: None, host_directory: first_dir, container_directory: "/data".into(), - auth: ExtraMcpContainerAuth::BearerToken { + auth: PluginMcpContainerAuth::BearerToken { secret_file: first_secret, secret_mount_path: "/run/secrets/mcp_bearer_token".into(), }, @@ -300,7 +300,7 @@ mcp_containers: ); assert_eq!( configs[1], - ExtraMcpContainerConfig { + PluginMcpContainerConfig { name: "second_tool".to_string(), runtime: ContainerRuntime::MacOSContainer, image: "ghcr.io/example/second:v1".to_string(), @@ -308,7 +308,7 @@ mcp_containers: host_port: Some(19000), host_directory: second_dir, container_directory: "/workspace".into(), - auth: ExtraMcpContainerAuth::None, + auth: PluginMcpContainerAuth::None, } ); } @@ -323,12 +323,12 @@ mcp_containers: let good_secret = temp.path().join("good.token"); let missing_secret = temp.path().join("missing.token"); write_file(&good_secret, "good-secret"); - let config_path = temp.path().join("mcp_containers.yaml"); + let config_path = temp.path().join("brain3.yaml"); write_file( &config_path, &format!( r#" -mcp_containers: +plugin_mcp_containers: {} {} "#, @@ -337,7 +337,7 @@ mcp_containers: ), ); - let configs = load_extra_mcp_containers_config(&config_path); + let configs = load_plugin_mcp_containers_config(&config_path); assert_eq!(configs.len(), 1); assert_eq!(configs[0].name, "good_tool"); @@ -354,12 +354,12 @@ mcp_containers: let second_secret = temp.path().join("second.token"); write_file(&first_secret, "first-secret"); write_file(&second_secret, "second-secret"); - let config_path = temp.path().join("mcp_containers.yaml"); + let config_path = temp.path().join("brain3.yaml"); write_file( &config_path, &format!( r#" -mcp_containers: +plugin_mcp_containers: {} {} "#, @@ -368,7 +368,7 @@ mcp_containers: ), ); - let configs = load_extra_mcp_containers_config(&config_path); + let configs = load_plugin_mcp_containers_config(&config_path); assert_eq!(configs.len(), 1); assert_eq!(configs[0].host_directory, first_dir); @@ -385,12 +385,12 @@ mcp_containers: let bad_secret = temp.path().join("bad.token"); write_file(&good_secret, "good-secret"); write_file(&bad_secret, "bad-secret"); - let config_path = temp.path().join("mcp_containers.yaml"); + let config_path = temp.path().join("brain3.yaml"); write_file( &config_path, &format!( r#" -mcp_containers: +plugin_mcp_containers: {} {} "#, @@ -399,7 +399,7 @@ mcp_containers: ), ); - let configs = load_extra_mcp_containers_config(&config_path); + let configs = load_plugin_mcp_containers_config(&config_path); assert_eq!(configs.len(), 1); assert_eq!(configs[0].name, "good_name"); @@ -408,10 +408,10 @@ mcp_containers: #[test] fn malformed_yaml_loads_empty_config() { let temp = tempfile::tempdir().expect("tempdir"); - let config_path = temp.path().join("mcp_containers.yaml"); - write_file(&config_path, "mcp_containers:\n - name: ["); + let config_path = temp.path().join("brain3.yaml"); + write_file(&config_path, "plugin_mcp_containers:\n - name: ["); - let configs = load_extra_mcp_containers_config(&config_path); + let configs = load_plugin_mcp_containers_config(&config_path); assert!(configs.is_empty()); } diff --git a/crates/platform/src/config/mod.rs b/crates/platform/src/config/mod.rs index 53f762f..03feb5e 100644 --- a/crates/platform/src/config/mod.rs +++ b/crates/platform/src/config/mod.rs @@ -1,3 +1,3 @@ +pub mod brain3_yaml; pub mod env_file; pub mod log_config; -pub mod mcp_containers_config; diff --git a/crates/platform/src/container/docker.rs b/crates/platform/src/container/docker.rs index d53cd94..998894f 100644 --- a/crates/platform/src/container/docker.rs +++ b/crates/platform/src/container/docker.rs @@ -267,7 +267,10 @@ impl ContainerPort for DockerContainerAdapter { args.push("--workdir".into()); args.push(wd.clone()); } - if config.isolation_strategy.is_some() { + if matches!( + config.isolation_strategy, + Some(ContainerNetworkIsolationStrategy::DiscoverContainerIp) + ) { args.push("--network".into()); args.push(config.network_name.clone()); } diff --git a/crates/platform/src/container/startup.rs b/crates/platform/src/container/startup.rs index 7f2184a..bc9afc6 100644 --- a/crates/platform/src/container/startup.rs +++ b/crates/platform/src/container/startup.rs @@ -6,10 +6,10 @@ use brain3_core::application::ensure_container::EnsureContainerUseCase; use brain3_core::domain::errors::ContainerError; use brain3_core::domain::model::{ BindMount, ContainerConfig, ContainerLabel, ContainerNetworkIsolationStrategy, - ContainerRuntime, ContainerStartupConfig, ExtraMcpContainerAuth, ExtraMcpContainerConfig, - ManagedContainerInfo, ManagedContainerScope, PortMapping, BRAIN3_EXTRA_MCP_ROLE_LABEL_PREFIX, + ContainerRuntime, ContainerStartupConfig, ManagedContainerInfo, ManagedContainerScope, + PluginMcpContainerAuth, PluginMcpContainerConfig, PortMapping, BRAIN3_INSTALLATION_ID_LABEL_KEY, BRAIN3_MANAGED_LABEL_KEY, BRAIN3_MANAGED_LABEL_VALUE, - BRAIN3_MCP_ROLE_LABEL_VALUE, BRAIN3_ROLE_LABEL_KEY, + BRAIN3_MCP_ROLE_LABEL_VALUE, BRAIN3_PLUGIN_MCP_ROLE_LABEL_PREFIX, BRAIN3_ROLE_LABEL_KEY, }; use brain3_core::domain::setup::RuntimeStartupPolicy; use brain3_core::ports::container::{ContainerId, ContainerPort}; @@ -28,11 +28,11 @@ const GC_POLL_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_mill #[cfg(test)] const GC_POLL_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_millis(100); -const DEFAULT_EXTRA_MCP_NETWORK_NAME: &str = "brain3-mcp-net"; +const DEFAULT_PLUGIN_MCP_NETWORK_NAME: &str = "brain3-mcp-net"; #[derive(Debug, Clone, PartialEq, Eq)] -pub struct StartedExtraMcpContainer { - pub config: ExtraMcpContainerConfig, +pub struct StartedPluginMcpContainer { + pub config: PluginMcpContainerConfig, pub host_port: u16, pub container_ip: Option, } @@ -104,47 +104,47 @@ pub async fn ensure_mcp_container( Ok(container_ip) } -pub async fn ensure_extra_mcp_container( - extra: &ExtraMcpContainerConfig, +pub async fn ensure_plugin_mcp_container( + plugin: &PluginMcpContainerConfig, startup_policy: RuntimeStartupPolicy, installation_id: &str, -) -> Result { - let host_port = resolve_extra_host_port(extra)?; - let role = extra_mcp_role_label(extra.name.as_str()); +) -> Result { + let host_port = resolve_plugin_host_port(plugin)?; + let role = plugin_mcp_role_label(plugin.name.as_str()); tracing::info!( - container = %extra.name, - image = %extra.image, - host_directory = %extra.host_directory.display(), + container = %plugin.name, + image = %plugin.image, + host_directory = %plugin.host_directory.display(), host_port, - container_port = extra.container_port, + container_port = plugin.container_port, installation_id, - auth = extra_auth_kind(&extra.auth), - "ensuring extra MCP container is running" + auth = plugin_auth_kind(&plugin.auth), + "ensuring Plugin MCP Container is running" ); tracing::info!( - container = %extra.name, + container = %plugin.name, network_isolated = true, - isolation_strategy = ?extra_isolation_strategy(extra.runtime), + isolation_strategy = ?plugin_isolation_strategy(plugin.runtime), startup_policy = ?startup_policy, role = %role, - "resolved extra MCP container network isolation mode" + "resolved Plugin MCP Container network isolation mode" ); - let port = container_port_for_runtime(extra.runtime); + let port = container_port_for_runtime(plugin.runtime); maybe_handle_managed_container_orphans( port.as_ref(), - extra.runtime, - extra.name.as_str(), + plugin.runtime, + plugin.name.as_str(), role.as_str(), startup_policy, installation_id, ) .await?; - let config = build_extra_container_config(extra, host_port, installation_id); + let config = build_plugin_container_config(plugin, host_port, installation_id); let (_id, container_ip) = EnsureContainerUseCase::new(port).ensure(&config).await?; - Ok(StartedExtraMcpContainer { - config: extra.clone(), + Ok(StartedPluginMcpContainer { + config: plugin.clone(), host_port, container_ip, }) @@ -168,23 +168,23 @@ pub async fn stop_mcp_container(startup: &ContainerStartupConfig) -> Result<(), port.stop(&id).await } -pub async fn stop_extra_mcp_container( - extra: &ExtraMcpContainerConfig, +pub async fn stop_plugin_mcp_container( + plugin: &PluginMcpContainerConfig, ) -> Result<(), ContainerError> { - let port = container_port_for_runtime(extra.runtime); - let id = ContainerId(extra.name.clone()); + let port = container_port_for_runtime(plugin.runtime); + let id = ContainerId(plugin.name.clone()); if !port.exists(&id).await? { - tracing::debug!(container = %extra.name, "managed extra MCP container already absent during shutdown"); + tracing::debug!(container = %plugin.name, "managed Plugin MCP Container already absent during shutdown"); return Ok(()); } if !port.is_running(&id).await? { - tracing::debug!(container = %extra.name, "managed extra MCP container already stopped during shutdown"); + tracing::debug!(container = %plugin.name, "managed Plugin MCP Container already stopped during shutdown"); return Ok(()); } - tracing::info!(container = %extra.name, runtime = ?extra.runtime, "stopping managed extra MCP container during shutdown"); + tracing::info!(container = %plugin.name, runtime = ?plugin.runtime, "stopping managed Plugin MCP Container during shutdown"); port.stop(&id).await } @@ -223,8 +223,8 @@ fn managed_container_labels_for_role(installation_id: &str, role: &str) -> Vec String { - format!("{BRAIN3_EXTRA_MCP_ROLE_LABEL_PREFIX}{name}") +fn plugin_mcp_role_label(name: &str) -> String { + format!("{BRAIN3_PLUGIN_MCP_ROLE_LABEL_PREFIX}{name}") } fn build_container_config( @@ -325,8 +325,8 @@ fn build_container_config( } } -fn build_extra_container_config( - extra: &ExtraMcpContainerConfig, +fn build_plugin_container_config( + plugin: &PluginMcpContainerConfig, host_port: u16, installation_id: &str, ) -> ContainerConfig { @@ -338,15 +338,15 @@ fn build_extra_container_config( let user: Option = None; let mut bind_mounts = vec![BindMount { - host_path: extra.host_directory.clone(), - container_path: extra.container_directory.clone(), + host_path: plugin.host_directory.clone(), + container_path: plugin.container_directory.clone(), readonly: false, }]; - if let ExtraMcpContainerAuth::BearerToken { + if let PluginMcpContainerAuth::BearerToken { secret_file, secret_mount_path, - } = &extra.auth + } = &plugin.auth { bind_mounts.push(BindMount { host_path: secret_file.clone(), @@ -355,63 +355,66 @@ fn build_extra_container_config( }); } - let isolation_strategy = Some(extra_isolation_strategy(extra.runtime)); + let isolation_strategy = Some(plugin_isolation_strategy(plugin.runtime)); tracing::info!( - container = %extra.name, + container = %plugin.name, installation_id, network_isolated = true, isolation_strategy = ?isolation_strategy, host_probe_target = %format!("127.0.0.1:{host_port}"), - isolated_probe_target = %format!(":{}", extra.container_port), - auth = extra_auth_kind(&extra.auth), - "prepared extra MCP container runtime networking configuration" + isolated_probe_target = %format!(":{}", plugin.container_port), + auth = plugin_auth_kind(&plugin.auth), + "prepared Plugin MCP Container runtime networking configuration" ); ContainerConfig { - image: extra.image.clone(), - name: extra.name.clone(), + image: plugin.image.clone(), + name: plugin.name.clone(), isolation_strategy, - network_name: DEFAULT_EXTRA_MCP_NETWORK_NAME.into(), + network_name: DEFAULT_PLUGIN_MCP_NETWORK_NAME.into(), port_mappings: vec![PortMapping { host_address: "127.0.0.1".into(), host_port, - container_port: extra.container_port, + container_port: plugin.container_port, }], env_vars: Vec::new(), labels: managed_container_labels_for_role( installation_id, - extra_mcp_role_label(extra.name.as_str()).as_str(), + plugin_mcp_role_label(plugin.name.as_str()).as_str(), ), bind_mounts, user, detach: true, - remove_on_exit: matches!(extra.runtime, ContainerRuntime::Docker), + remove_on_exit: matches!(plugin.runtime, ContainerRuntime::Docker), workdir: None, command: Vec::new(), } } -fn extra_isolation_strategy(runtime: ContainerRuntime) -> ContainerNetworkIsolationStrategy { +fn plugin_isolation_strategy(runtime: ContainerRuntime) -> ContainerNetworkIsolationStrategy { match runtime { + #[cfg(target_os = "macos")] + ContainerRuntime::Docker => ContainerNetworkIsolationStrategy::PublishToLoopback, + #[cfg(not(target_os = "macos"))] ContainerRuntime::Docker => ContainerNetworkIsolationStrategy::DiscoverContainerIp, ContainerRuntime::MacOSContainer => ContainerNetworkIsolationStrategy::PublishToLoopback, } } -fn extra_auth_kind(auth: &ExtraMcpContainerAuth) -> &'static str { +fn plugin_auth_kind(auth: &PluginMcpContainerAuth) -> &'static str { match auth { - ExtraMcpContainerAuth::None => "none", - ExtraMcpContainerAuth::BearerToken { .. } => "bearer_token", + PluginMcpContainerAuth::None => "none", + PluginMcpContainerAuth::BearerToken { .. } => "bearer_token", } } -fn resolve_extra_host_port(extra: &ExtraMcpContainerConfig) -> Result { - match extra.host_port { +fn resolve_plugin_host_port(plugin: &PluginMcpContainerConfig) -> Result { + match plugin.host_port { Some(port) => Ok(port), None => pick_free_loopback_port().map_err(|error| { ContainerError::Other(format!( - "failed to pick host port for extra MCP container '{}': {error}", - extra.name + "failed to pick host port for Plugin MCP Container '{}': {error}", + plugin.name )) }), } @@ -782,8 +785,8 @@ mod tests { ); } - fn sample_extra_config() -> ExtraMcpContainerConfig { - ExtraMcpContainerConfig { + fn sample_plugin_config() -> PluginMcpContainerConfig { + PluginMcpContainerConfig { name: "fluensy_learn".into(), runtime: ContainerRuntime::Docker, image: "ghcr.io/example/fluensy-learn:latest".into(), @@ -791,7 +794,7 @@ mod tests { host_port: None, host_directory: "/tmp/fluensy-data".into(), container_directory: "/data".into(), - auth: ExtraMcpContainerAuth::BearerToken { + auth: PluginMcpContainerAuth::BearerToken { secret_file: "/tmp/fluensy.token".into(), secret_mount_path: "/run/secrets/mcp_bearer_token".into(), }, @@ -799,16 +802,22 @@ mod tests { } #[test] - fn build_extra_container_config_adds_extra_role_labels_and_mounts() { - let config = build_extra_container_config(&sample_extra_config(), 18420, "scope-1"); + fn build_plugin_container_config_adds_plugin_role_labels_and_mounts() { + let config = build_plugin_container_config(&sample_plugin_config(), 18420, "scope-1"); assert_eq!(config.name, "fluensy_learn"); assert_eq!(config.image, "ghcr.io/example/fluensy-learn:latest"); + #[cfg(target_os = "macos")] + assert_eq!( + config.isolation_strategy, + Some(ContainerNetworkIsolationStrategy::PublishToLoopback) + ); + #[cfg(not(target_os = "macos"))] assert_eq!( config.isolation_strategy, Some(ContainerNetworkIsolationStrategy::DiscoverContainerIp) ); - assert_eq!(config.network_name, DEFAULT_EXTRA_MCP_NETWORK_NAME); + assert_eq!(config.network_name, DEFAULT_PLUGIN_MCP_NETWORK_NAME); assert_eq!(config.port_mappings.len(), 1); assert_eq!(config.port_mappings[0].host_address, "127.0.0.1"); assert_eq!(config.port_mappings[0].host_port, 18420); @@ -823,7 +832,7 @@ mod tests { }, ContainerLabel { key: BRAIN3_ROLE_LABEL_KEY.into(), - value: "brain3-mcp-extra:fluensy_learn".into(), + value: "brain3-mcp-plugin:fluensy_learn".into(), }, ContainerLabel { key: BRAIN3_INSTALLATION_ID_LABEL_KEY.into(), @@ -854,11 +863,11 @@ mod tests { } #[test] - fn build_extra_container_config_omits_secret_mount_for_no_auth() { - let mut extra = sample_extra_config(); - extra.auth = ExtraMcpContainerAuth::None; + fn build_plugin_container_config_omits_secret_mount_for_no_auth() { + let mut plugin = sample_plugin_config(); + plugin.auth = PluginMcpContainerAuth::None; - let config = build_extra_container_config(&extra, 18420, "scope-1"); + let config = build_plugin_container_config(&plugin, 18420, "scope-1"); assert_eq!(config.bind_mounts.len(), 1); assert_eq!( @@ -870,13 +879,19 @@ mod tests { } #[test] - fn extra_isolation_strategy_matches_runtime() { + fn plugin_isolation_strategy_matches_runtime() { + #[cfg(target_os = "macos")] + assert_eq!( + plugin_isolation_strategy(ContainerRuntime::Docker), + ContainerNetworkIsolationStrategy::PublishToLoopback + ); + #[cfg(not(target_os = "macos"))] assert_eq!( - extra_isolation_strategy(ContainerRuntime::Docker), + plugin_isolation_strategy(ContainerRuntime::Docker), ContainerNetworkIsolationStrategy::DiscoverContainerIp ); assert_eq!( - extra_isolation_strategy(ContainerRuntime::MacOSContainer), + plugin_isolation_strategy(ContainerRuntime::MacOSContainer), ContainerNetworkIsolationStrategy::PublishToLoopback ); } diff --git a/crates/platform/src/runtime/bootstrap.rs b/crates/platform/src/runtime/bootstrap.rs index 39d28e4..dfd8729 100644 --- a/crates/platform/src/runtime/bootstrap.rs +++ b/crates/platform/src/runtime/bootstrap.rs @@ -3,16 +3,16 @@ use std::sync::Arc; use anyhow::{bail, Result}; use brain3_core::domain::errors::ContainerError; use brain3_core::domain::model::{ - ContainerNetworkIsolationStrategy, ExtraMcpContainerConfig, GatewayConfig, TunnelConfig, + ContainerNetworkIsolationStrategy, GatewayConfig, PluginMcpContainerConfig, TunnelConfig, }; use brain3_core::domain::setup::{RuntimeLaunchPlan, RuntimeStartupPolicy}; use brain3_core::ports::tunnel::TunnelPort; +use crate::config::brain3_yaml::load_plugin_mcp_containers_config; use crate::config::log_config; -use crate::config::mcp_containers_config::load_extra_mcp_containers_config; use crate::container::startup::{ - ensure_extra_mcp_container, ensure_mcp_container, installation_scope_id, - stop_extra_mcp_container, stop_mcp_container, StartedExtraMcpContainer, + ensure_mcp_container, ensure_plugin_mcp_container, installation_scope_id, stop_mcp_container, + stop_plugin_mcp_container, StartedPluginMcpContainer, }; use crate::tunnel::start_tunnel; @@ -43,7 +43,7 @@ pub struct RuntimeBootstrap { pub public_url: Option, pub container_status: StartupStatus, pub tunnel_status: StartupStatus, - pub extra_mcp_containers: Vec, + pub plugin_mcp_containers: Vec, managed_container_started: bool, tunnel: Option>, } @@ -65,7 +65,7 @@ impl RuntimeBootstrap { public_url, container_status, tunnel_status, - extra_mcp_containers: Vec::new(), + plugin_mcp_containers: Vec::new(), managed_container_started, tunnel: None, } @@ -82,13 +82,13 @@ impl RuntimeBootstrap { pub async fn shutdown_managed_runtime(&mut self) { self.stop_tunnel().await; - for extra in &self.extra_mcp_containers { - if let Err(error) = stop_extra_mcp_container(&extra.config).await { + for plugin in &self.plugin_mcp_containers { + if let Err(error) = stop_plugin_mcp_container(&plugin.config).await { tracing::warn!( - container = %extra.config.name, - runtime = ?extra.config.runtime, + container = %plugin.config.name, + runtime = ?plugin.config.runtime, error = %error, - "failed to stop managed extra MCP container during shutdown; continuing exit" + "failed to stop managed Plugin MCP Container during shutdown; continuing exit" ); } } @@ -303,8 +303,8 @@ pub async fn bootstrap_configured_runtime( container_status }; - let extra_mcp_containers = if container_status.allows_gateway_start() { - start_extra_mcp_containers( + let plugin_mcp_containers = if container_status.allows_gateway_start() { + start_plugin_mcp_containers( &launch_plan.paths.app_home, startup_policy, installation_id.as_str(), @@ -313,7 +313,7 @@ pub async fn bootstrap_configured_runtime( } else { tracing::debug!( container_status = ?container_status, - "skipping extra MCP containers because core MCP container is not ready" + "skipping Plugin MCP Containers because core MCP container is not ready" ); Vec::new() }; @@ -360,23 +360,23 @@ pub async fn bootstrap_configured_runtime( public_url, container_status, tunnel_status, - extra_mcp_containers, + plugin_mcp_containers, managed_container_started, tunnel: tunnel_guard, }) } -async fn start_extra_mcp_containers( +async fn start_plugin_mcp_containers( app_home: &std::path::Path, startup_policy: RuntimeStartupPolicy, installation_id: &str, -) -> Vec { - let config_path = app_home.join("mcp_containers.yaml"); - let configs = load_extra_mcp_containers_config(&config_path); +) -> Vec { + let config_path = app_home.join("brain3.yaml"); + let configs = load_plugin_mcp_containers_config(&config_path); if configs.is_empty() { tracing::debug!( path = %config_path.display(), - "no extra MCP containers configured" + "no Plugin MCP Containers configured" ); return Vec::new(); } @@ -384,25 +384,25 @@ async fn start_extra_mcp_containers( tracing::info!( path = %config_path.display(), count = configs.len(), - "loaded extra MCP container configs" + "loaded Plugin MCP Container configs" ); let mut started = Vec::new(); for config in configs { - match ensure_extra_mcp_container(&config, startup_policy, installation_id).await { + match ensure_plugin_mcp_container(&config, startup_policy, installation_id).await { Ok(container) => { tracing::info!( container = %container.config.name, host_port = container.host_port, container_ip = ?container.container_ip, - "extra MCP container ready" + "Plugin MCP Container ready" ); started.push(container); } Err(error) => { - log_extra_container_startup_failure(&config, &error); + log_plugin_container_startup_failure(&config, &error); if error.started_container() { - stop_failed_extra_container(&config).await; + stop_failed_plugin_container(&config).await; } } } @@ -411,31 +411,31 @@ async fn start_extra_mcp_containers( started } -fn log_extra_container_startup_failure(config: &ExtraMcpContainerConfig, error: &ContainerError) { +fn log_plugin_container_startup_failure(config: &PluginMcpContainerConfig, error: &ContainerError) { let summary = error.summary(); if let Some(logs) = error.recent_logs() { tracing::error!( container = %config.name, summary, logs = %logs, - "extra MCP container startup failed; continuing without it" + "Plugin MCP Container startup failed; continuing without it" ); } else { tracing::error!( container = %config.name, summary, - "extra MCP container startup failed; continuing without it" + "Plugin MCP Container startup failed; continuing without it" ); } } -async fn stop_failed_extra_container(config: &ExtraMcpContainerConfig) { - if let Err(error) = stop_extra_mcp_container(config).await { +async fn stop_failed_plugin_container(config: &PluginMcpContainerConfig) { + if let Err(error) = stop_plugin_mcp_container(config).await { tracing::warn!( container = %config.name, runtime = ?config.runtime, error = %error, - "failed to stop extra MCP container after startup failure; continuing gateway startup" + "failed to stop Plugin MCP Container after startup failure; continuing gateway startup" ); } } diff --git a/scripts/e2e_smoke.py b/scripts/e2e_smoke.py index a6cd022..b9cd1dd 100755 --- a/scripts/e2e_smoke.py +++ b/scripts/e2e_smoke.py @@ -12,11 +12,13 @@ CommandRunner = Callable[[list[str], Path], int] -IMAGE_TAG = "brain3-mcp-vault-tools:e2e-local" +VAULT_TOOLS_IMAGE_TAG = "brain3-mcp-vault-tools:e2e-local" +HELLO_MCP_IMAGE_TAG = "brain3-e2e-hello-mcp:e2e-local" DEFAULT_E2E_TESTS = [ "e2e_smoke_1_local_docker", "e2e_smoke_2_oauth_public_flow", "e2e_smoke_3_oauth_quick_tunnel", + "e2e_smoke_5_plugin_mcp_container", ] @@ -24,15 +26,26 @@ def repo_root() -> Path: return Path(__file__).resolve().parents[1] -def docker_build_command() -> list[str]: +def docker_build_commands() -> list[list[str]]: return [ - "docker", - "build", - "-f", - "./brain3-mcp-vault-tools/Containerfile", - "-t", - IMAGE_TAG, - "./brain3-mcp-vault-tools", + [ + "docker", + "build", + "-f", + "./brain3-mcp-vault-tools/Containerfile", + "-t", + VAULT_TOOLS_IMAGE_TAG, + "./brain3-mcp-vault-tools", + ], + [ + "docker", + "build", + "-f", + "./testdata/e2e_hello_mcp_container/Containerfile", + "-t", + HELLO_MCP_IMAGE_TAG, + "./testdata/e2e_hello_mcp_container", + ], ] @@ -66,14 +79,15 @@ def run( run_command: CommandRunner = subprocess_runner, ) -> int: root = repo_root() - build_exit_code = run_command(docker_build_command(), root) - if build_exit_code != 0: - print( - f"Docker image build failed with exit code {build_exit_code}; " - "aborting before running the E2E smoke test.", - file=sys.stderr, - ) - return build_exit_code + for build_command in docker_build_commands(): + build_exit_code = run_command(build_command, root) + if build_exit_code != 0: + print( + f"Docker image build failed with exit code {build_exit_code}; " + "aborting before running the E2E smoke test.", + file=sys.stderr, + ) + return build_exit_code if extra_args: return run_command(cargo_test_command(extra_args), root) diff --git a/scripts/test_e2e_smoke.py b/scripts/test_e2e_smoke.py index b92a3e0..a607ffc 100644 --- a/scripts/test_e2e_smoke.py +++ b/scripts/test_e2e_smoke.py @@ -25,7 +25,7 @@ def fake_run(command, cwd): exit_code = module.run(["e2e_smoke_starts_gateway"], run_command=fake_run) self.assertEqual(exit_code, 0) - self.assertEqual(len(calls), 2) + self.assertEqual(len(calls), 3) self.assertEqual( calls[0][0], [ @@ -40,6 +40,18 @@ def fake_run(command, cwd): ) self.assertEqual( calls[1][0], + [ + "docker", + "build", + "-f", + "./testdata/e2e_hello_mcp_container/Containerfile", + "-t", + "brain3-e2e-hello-mcp:e2e-local", + "./testdata/e2e_hello_mcp_container", + ], + ) + self.assertEqual( + calls[2][0], [ "cargo", "test", @@ -67,14 +79,17 @@ def fake_run(command, cwd): exit_code = module.run([], run_command=fake_run) self.assertEqual(exit_code, 0) - self.assertEqual(len(calls), 4) + self.assertEqual(len(calls), 6) self.assertEqual(calls[0][0][0:2], ["docker", "build"]) - self.assertEqual(calls[1][0][-1], "e2e_smoke_1_local_docker") - self.assertEqual(calls[2][0][-1], "e2e_smoke_2_oauth_public_flow") - self.assertEqual(calls[3][0][-1], "e2e_smoke_3_oauth_quick_tunnel") - self.assertNotIn("--fail-fast", calls[1][0]) + self.assertEqual(calls[1][0][0:2], ["docker", "build"]) self.assertNotIn("--fail-fast", calls[2][0]) self.assertNotIn("--fail-fast", calls[3][0]) + self.assertNotIn("--fail-fast", calls[4][0]) + self.assertNotIn("--fail-fast", calls[5][0]) + self.assertEqual(calls[2][0][-1], "e2e_smoke_1_local_docker") + self.assertEqual(calls[3][0][-1], "e2e_smoke_2_oauth_public_flow") + self.assertEqual(calls[4][0][-1], "e2e_smoke_3_oauth_quick_tunnel") + self.assertEqual(calls[5][0][-1], "e2e_smoke_5_plugin_mcp_container") def test_default_run_aborts_before_oauth_when_local_test_fails(self): module = load_script() @@ -89,9 +104,10 @@ def fake_run(command, cwd): exit_code = module.run([], run_command=fake_run) self.assertEqual(exit_code, 23) - self.assertEqual(len(calls), 2) + self.assertEqual(len(calls), 3) self.assertEqual(calls[0][0][0:2], ["docker", "build"]) - self.assertEqual(calls[1][0][-1], "e2e_smoke_1_local_docker") + self.assertEqual(calls[1][0][0:2], ["docker", "build"]) + self.assertEqual(calls[2][0][-1], "e2e_smoke_1_local_docker") def test_build_failure_aborts_before_cargo_runs(self): module = load_script() diff --git a/testdata/e2e_hello_mcp_container/Containerfile b/testdata/e2e_hello_mcp_container/Containerfile new file mode 100644 index 0000000..d73fea4 --- /dev/null +++ b/testdata/e2e_hello_mcp_container/Containerfile @@ -0,0 +1,7 @@ +FROM python:3.12-alpine + +WORKDIR /app +COPY server.py /app/server.py + +EXPOSE 8420 +CMD ["python", "/app/server.py"] diff --git a/testdata/e2e_hello_mcp_container/server.py b/testdata/e2e_hello_mcp_container/server.py new file mode 100644 index 0000000..c9fcf35 --- /dev/null +++ b/testdata/e2e_hello_mcp_container/server.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +HOST = "0.0.0.0" +PORT = 8420 +SECRET_FILE = Path("/run/secrets/mcp_bearer_token") + + +def bearer_token() -> str: + return SECRET_FILE.read_text(encoding="utf-8").strip() + + +class Handler(BaseHTTPRequestHandler): + server_version = "brain3-e2e-hello-mcp" + + def do_POST(self) -> None: + if self.path != "/mcp": + self.send_json(404, {"error": "not_found"}) + return + + expected = f"Bearer {bearer_token()}" + supplied = self.headers.get("authorization") + if supplied is None: + self.send_json(401, {"error": "missing_bearer"}) + return + if supplied != expected: + self.send_json(403, {"error": "wrong_bearer"}) + return + + length = int(self.headers.get("content-length", "0")) + try: + request = json.loads(self.rfile.read(length)) + except json.JSONDecodeError: + self.send_json(400, {"error": "invalid_json"}) + return + + method = request.get("method") + request_id = request.get("id") + if method == "initialize": + result = { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "brain3-e2e-hello-mcp", "version": "0.0.0"}, + } + elif method == "tools/list": + result = { + "tools": [ + { + "name": "hello", + "description": "Return a fixed hello-world response.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + } + ] + } + elif method == "tools/call": + params = request.get("params") or {} + if params.get("name") != "hello": + self.send_json( + 200, + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": "unknown tool"}, + }, + ) + return + result = { + "content": [{"type": "text", "text": "hello world"}], + "isError": False, + } + else: + self.send_json( + 200, + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": "method not found"}, + }, + ) + return + + self.send_json(200, {"jsonrpc": "2.0", "id": request_id, "result": result}) + + def log_message(self, format: str, *args: object) -> None: + return + + def send_json(self, status: int, payload: object) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +if __name__ == "__main__": + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() From cea8698a9b740b36c7cd4fc7b3219306025fc8bb Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 18:07:34 +0200 Subject: [PATCH 11/12] check in bump version skill --- .agents/skills/bump-version/SKILL.md | 116 +++++++++++++++++++++++++++ .agents/skills/bump-version/bump.sh | 77 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 .agents/skills/bump-version/SKILL.md create mode 100755 .agents/skills/bump-version/bump.sh diff --git a/.agents/skills/bump-version/SKILL.md b/.agents/skills/bump-version/SKILL.md new file mode 100644 index 0000000..fa9a33a --- /dev/null +++ b/.agents/skills/bump-version/SKILL.md @@ -0,0 +1,116 @@ +--- +name: bump-version +description: Bump the release version everywhere in the Brain3 project — Cargo.toml, pyproject.toml, README, first_run_setup.rs, and test fixtures — then execute the full release process: tag, CI workflow, and verification. +--- + +# bump-version / release + +Full release process for Brain3. Execute each step using the Bash tool — do not show commands for the user to copy-paste, run them directly. + +## Files updated by the bump script + +| File | What changes | +|------|-------------| +| `apps/gateway/Cargo.toml` | `version = "X.Y.Z"` | +| `brain3-mcp-vault-tools/pyproject.toml` | `version = "X.Y.Z"` | +| `README.MD` | S3 install URL (`/releases/vX.Y.Z/install.sh`) | +| `crates/core/src/application/first_run_setup.rs` | `CURRENT_RELEASE = "vX.Y.Z"` | +| `brain3-mcp-vault-tools/tests/test_server_startup.py` | Two version string fixtures | +| `Cargo.lock` | Auto-updated via `cargo fetch` | +| `brain3-mcp-vault-tools/uv.lock` | Regenerated via `uv lock --project ./brain3-mcp-vault-tools` | + +--- + +## Step 0 — Ensure we're on a release branch + +Before bumping anything, check the current branch: + +```bash +git rev-parse --abbrev-ref HEAD +``` + +The version bump must happen on a dedicated release branch named `bump_version_` (the version with dots removed — e.g. `0.2.8` → `bump_version_028`), **not** on `main`. + +- If already on the expected `bump_version_` branch, proceed to Step 1. +- Otherwise (on `main` or any other branch), **stop and offer to create the branch** for the user. Do not create it without confirmation. Once confirmed, run: + +```bash +git checkout -b bump_version_ +``` + +Only after we're on the release branch should you continue to Step 1. + +--- + +## Step 1 — Bump version in files + +Run the bump script (it detects the current version from `apps/gateway/Cargo.toml`, applies all replacements, and refreshes `Cargo.lock`): + +```bash +bash .Codex/skills/bump-version/bump.sh VERSION +``` + +Then refresh and verify the Python lockfile (pyproject.toml was just bumped, so uv.lock will be stale): + +```bash +uv lock --project ./brain3-mcp-vault-tools +uv lock --project ./brain3-mcp-vault-tools --check +``` + +Then run `cargo test` to verify nothing is broken. + +Do NOT commit — tell the user to commit themselves, as per project instructions. Remind them that both `brain3-mcp-vault-tools/pyproject.toml` and `brain3-mcp-vault-tools/uv.lock` must be included in the commit. + +--- + +## Step 2 — Tag and push (triggers CI release workflow) + +**The tag does not require the PR to be merged first.** The release workflow builds from whatever commit the tag points at, so you can tag the `bump_version_` branch HEAD directly — even while its PR is still open or blocked on review. Don't wait for the merge to cut the release. + +Ask the user to confirm before tagging and pushing. Once confirmed, run (tags the current HEAD, i.e. the release branch commit): + +```bash +VERSION=vX.Y.Z +git tag -a "$VERSION" -m "Release $VERSION" +git push origin "$VERSION" +``` + +Pushing the tag triggers `.github/workflows/release.yml`, which builds all four targets, signs `SHA256SUMS`, and publishes to GitHub Releases and S3 (~5–10 min). + +Then monitor progress by running: + +```bash +gh run watch +``` + +--- + +## Step 3 — Verify + +Once the workflow completes, run: + +```bash +VERSION=vX.Y.Z +gh release view "$VERSION" +``` + +Report the listed assets to the user. + +Then verify the Docker image published to GHCR by pulling it: + +```bash +VERSION=vX.Y.Z +docker pull ghcr.io/tleyden/brain3-mcp-vault-tools:$VERSION +``` + +Confirm the pull succeeds and report the digest to the user. + +--- + +## Gotchas + +- The version argument can have or omit a leading `v` — both `0.2.3` and `v0.2.3` work. +- Push only the tag (`git push origin "$VERSION"`), not all branches — pushing everything can trigger unintended workflows. +- Tag and push **after** the user has committed the version bump. +- `cargo fetch` (not `cargo build`) is used to update `Cargo.lock` quickly. +- Any change to `brain3-mcp-vault-tools/pyproject.toml` or other package metadata requires regenerating `brain3-mcp-vault-tools/uv.lock` in the same commit — `uv lock --project ./brain3-mcp-vault-tools --check` will fail in CI otherwise. diff --git a/.agents/skills/bump-version/bump.sh b/.agents/skills/bump-version/bump.sh new file mode 100755 index 0000000..b87be48 --- /dev/null +++ b/.agents/skills/bump-version/bump.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + +usage() { + echo "Usage: $0 " + echo "" + echo " New version without 'v' prefix, e.g. 0.2.3" + echo "" + echo "The script will update all version references, then ask before tagging and pushing." + exit 1 +} + +[ $# -lt 1 ] && usage + +NEW="${1#v}" # strip leading 'v' if present + +cd "$REPO_ROOT" + +# Detect current version from gateway Cargo.toml +OLD=$(grep '^version' apps/gateway/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + +if [ "$OLD" = "$NEW" ]; then + echo "Already at version $NEW — nothing to do." + exit 0 +fi + +echo "Bumping $OLD → $NEW" + +# 1. apps/gateway/Cargo.toml +sed -i.bak "s/^version = \"$OLD\"/version = \"$NEW\"/" apps/gateway/Cargo.toml +rm -f apps/gateway/Cargo.toml.bak + +# 2. brain3-mcp-vault-tools/pyproject.toml +sed -i.bak "s/^version = \"$OLD\"/version = \"$NEW\"/" brain3-mcp-vault-tools/pyproject.toml +rm -f brain3-mcp-vault-tools/pyproject.toml.bak + +# 3. README.MD (URL contains v-prefixed version) +sed -i.bak "s|/v${OLD}/|/v${NEW}/|g" README.MD +rm -f README.MD.bak + +# 4. crates/core/src/application/first_run_setup.rs (v-prefixed) +sed -i.bak "s|\"v${OLD}\"|\"v${NEW}\"|g" crates/core/src/application/first_run_setup.rs +rm -f crates/core/src/application/first_run_setup.rs.bak + +# 5. brain3-mcp-vault-tools/tests/test_server_startup.py (bare version, no v) +sed -i.bak "s/\"${OLD}\"/\"${NEW}\"/g" brain3-mcp-vault-tools/tests/test_server_startup.py +rm -f brain3-mcp-vault-tools/tests/test_server_startup.py.bak + +# 6. Update Cargo.lock by running cargo fetch (fast, no build needed) +echo "Updating Cargo.lock..." +cargo fetch --quiet 2>&1 | grep -v '^$' || true + +echo "" +echo "Done. Files updated:" +echo " apps/gateway/Cargo.toml" +echo " brain3-mcp-vault-tools/pyproject.toml" +echo " README.MD" +echo " crates/core/src/application/first_run_setup.rs" +echo " brain3-mcp-vault-tools/tests/test_server_startup.py" +echo " Cargo.lock" +echo "" +echo "Review the diff, then commit with:" +echo " git commit -am \"bump version $NEW\"" +echo "" + +read -rp "Tag and push v${NEW} now? [y/N] " CONFIRM +if [[ "${CONFIRM,,}" == "y" ]]; then + git tag -a "v${NEW}" -m "Release v${NEW}" + git push origin "v${NEW}" + echo "Tagged and pushed v${NEW}. Monitor the release workflow with: gh run watch" +else + echo "Skipped tagging. To tag later:" + echo " git tag -a \"v${NEW}\" -m \"Release v${NEW}\"" + echo " git push origin \"v${NEW}\"" +fi From e8b6f46e22afb8001e7d16419d7bc359e12263de Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Thu, 9 Jul 2026 18:21:00 +0200 Subject: [PATCH 12/12] address greptile feedback --- crates/core/src/application/mcp_router.rs | 122 +++++++++++++++++- .../remote_mcp_container_client.rs | 6 + crates/platform/src/container/startup.rs | 58 ++++++--- 3 files changed, 169 insertions(+), 17 deletions(-) diff --git a/crates/core/src/application/mcp_router.rs b/crates/core/src/application/mcp_router.rs index 4d32113..d5aeb4c 100644 --- a/crates/core/src/application/mcp_router.rs +++ b/crates/core/src/application/mcp_router.rs @@ -184,6 +184,18 @@ impl McpRouterUseCase

{ for client in &self.plugin_containers { if let Some(unprefixed_name) = client.strip_prefix(name) { + // Verify that the unprefixed tool name was advertised during initialization + if !client.has_tool(unprefixed_name) { + tracing::warn!( + container = %client.container_name(), + prefixed_tool_name = name, + tool_name = unprefixed_name, + request_id = ?request.get("id"), + "MCP router: rejecting call to unadvertised Plugin MCP tool" + ); + return Ok(Some(tool_not_found_response(request, name))); + } + tracing::info!( container = %client.container_name(), prefixed_tool_name = name, @@ -191,7 +203,21 @@ impl McpRouterUseCase

{ request_id = ?request.get("id"), "MCP router: routing Plugin MCP tool call" ); - return Ok(Some(client.call_tool(request, unprefixed_name).await?)); + + // Handle transport errors gracefully and convert them to JSON-RPC errors + match client.call_tool(request, unprefixed_name).await { + Ok(response) => return Ok(Some(response)), + Err(error) => { + tracing::error!( + container = %client.container_name(), + tool_name = unprefixed_name, + request_id = ?request.get("id"), + error = %error, + "MCP router: Plugin MCP tool call failed" + ); + return Ok(Some(plugin_transport_error_response(request, name, &error))); + } + } } } @@ -295,6 +321,48 @@ fn native_tool_error_to_proxy_error(error: NativeMcpToolError) -> ProxyError { ProxyError::BadGateway(format!("native MCP tool initialization failed: {error}")) } +fn tool_not_found_response(request: &Value, tool_name: &str) -> McpProxyResponse { + let body = json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned().unwrap_or(Value::Null), + "error": { + "code": -32601, + "message": format!("Tool not found: {tool_name}"), + } + }); + + let body = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()); + + McpProxyResponse { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body, + } +} + +fn plugin_transport_error_response( + request: &Value, + tool_name: &str, + error: &ProxyError, +) -> McpProxyResponse { + let body = json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned().unwrap_or(Value::Null), + "error": { + "code": -32603, + "message": format!("Plugin container error calling {tool_name}: {error}"), + } + }); + + let body = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()); + + McpProxyResponse { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body, + } +} + #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; @@ -681,7 +749,7 @@ mod tests { #[tokio::test] async fn tools_call_for_prefixed_plugin_tool_bypasses_vault_proxy_and_strips_prefix() { let (plugin_proxy, plugin_client) = initialized_plugin_client( - br#"{"jsonrpc":"2.0","id":99,"result":{"tools":[]}}"#.to_vec(), + br#"{"jsonrpc":"2.0","id":99,"result":{"tools":[{"name":"search_deck","description":"Search deck","inputSchema":{"type":"object"}}]}}"#.to_vec(), ) .await; let (router, captured, _, plugin_captured) = router_with_tool_and_plugin( @@ -767,4 +835,54 @@ mod tests { 1 ); } + + #[tokio::test] + async fn tools_call_rejects_unadvertised_plugin_tool() { + let (plugin_proxy, plugin_client) = initialized_plugin_client( + br#"{"jsonrpc":"2.0","id":99,"result":{"tools":[{"name":"search_deck","description":"Search deck","inputSchema":{"type":"object"}}]}}"#.to_vec(), + ) + .await; + let (router, _, _, plugin_captured) = router_with_tool_and_plugin( + br#"{"jsonrpc":"2.0","result":{}}"#.to_vec(), + plugin_proxy, + plugin_client, + ); + + let response = router + .handle( + "brain3.example.com", + "POST", + "/mcp", + None, + vec![("content-type".into(), "application/json".into())], + json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "fluensy_learn__unadvertised_tool", + "arguments": {} + } + }) + .to_string() + .into_bytes(), + ) + .await + .expect("should return error response"); + + assert_eq!(response.status, 200); + let body: Value = serde_json::from_slice(&response.body).expect("response should be JSON"); + assert!(body.get("error").is_some(), "response should contain error"); + assert_eq!(body["error"]["code"], -32601); + + // Plugin should only have initialization calls, not the tool call + let plugin_requests = plugin_captured + .lock() + .expect("plugin capture lock should succeed"); + assert_eq!( + plugin_requests.len(), + 2, + "plugin should only receive initialize and tools/list, not the rejected tool call" + ); + } } diff --git a/crates/core/src/application/remote_mcp_container_client.rs b/crates/core/src/application/remote_mcp_container_client.rs index 828cb1a..69a561c 100644 --- a/crates/core/src/application/remote_mcp_container_client.rs +++ b/crates/core/src/application/remote_mcp_container_client.rs @@ -75,6 +75,12 @@ impl RemoteMcpContainerClient

{ .filter(|name| !name.is_empty()) } + pub fn has_tool(&self, unprefixed_tool_name: &str) -> bool { + self.tool_schemas + .iter() + .any(|schema| schema.original_name == unprefixed_tool_name) + } + pub async fn call_tool( &self, request: &Value, diff --git a/crates/platform/src/container/startup.rs b/crates/platform/src/container/startup.rs index bc9afc6..4028445 100644 --- a/crates/platform/src/container/startup.rs +++ b/crates/platform/src/container/startup.rs @@ -159,13 +159,20 @@ pub async fn stop_mcp_container(startup: &ContainerStartupConfig) -> Result<(), return Ok(()); } - if !port.is_running(&id).await? { + if port.is_running(&id).await? { + tracing::info!(container = %startup.container_name, runtime = ?startup.runtime, "stopping managed MCP container during shutdown"); + port.stop(&id).await?; + } else { tracing::debug!(container = %startup.container_name, "managed MCP container already stopped during shutdown"); - return Ok(()); } - tracing::info!(container = %startup.container_name, runtime = ?startup.runtime, "stopping managed MCP container during shutdown"); - port.stop(&id).await + // macOS containers require explicit removal; Docker containers use --rm + if startup.runtime == ContainerRuntime::MacOSContainer { + tracing::info!(container = %startup.container_name, "removing managed macOS MCP container during shutdown"); + port.remove(&id).await?; + } + + Ok(()) } pub async fn stop_plugin_mcp_container( @@ -179,13 +186,20 @@ pub async fn stop_plugin_mcp_container( return Ok(()); } - if !port.is_running(&id).await? { + if port.is_running(&id).await? { + tracing::info!(container = %plugin.name, runtime = ?plugin.runtime, "stopping managed Plugin MCP Container during shutdown"); + port.stop(&id).await?; + } else { tracing::debug!(container = %plugin.name, "managed Plugin MCP Container already stopped during shutdown"); - return Ok(()); } - tracing::info!(container = %plugin.name, runtime = ?plugin.runtime, "stopping managed Plugin MCP Container during shutdown"); - port.stop(&id).await + // macOS containers require explicit removal; Docker containers use --rm + if plugin.runtime == ContainerRuntime::MacOSContainer { + tracing::info!(container = %plugin.name, "removing managed macOS Plugin MCP Container during shutdown"); + port.remove(&id).await?; + } + + Ok(()) } pub(crate) fn container_port_for_runtime(runtime: ContainerRuntime) -> Arc { @@ -411,12 +425,18 @@ fn plugin_auth_kind(auth: &PluginMcpContainerAuth) -> &'static str { fn resolve_plugin_host_port(plugin: &PluginMcpContainerConfig) -> Result { match plugin.host_port { Some(port) => Ok(port), - None => pick_free_loopback_port().map_err(|error| { - ContainerError::Other(format!( - "failed to pick host port for Plugin MCP Container '{}': {error}", - plugin.name - )) - }), + None => { + // KNOWN LIMITATION: There is a small race window between selecting a free port + // and the container runtime binding to it. Another process could bind to the + // same port in that gap. If this becomes a problem, specify an explicit host_port + // in the plugin configuration. + pick_free_loopback_port().map_err(|error| { + ContainerError::Other(format!( + "failed to pick host port for Plugin MCP Container '{}': {error}", + plugin.name + )) + }) + } } } @@ -629,6 +649,8 @@ mod tests { exists_calls: Vec, /// Responses returned by `exists()` in order; cycles `false` once exhausted. exists_responses: Vec, + /// Responses returned by `is_running()` in order; cycles `false` once exhausted. + is_running_responses: Vec, } struct MockContainerPort { @@ -672,7 +694,13 @@ mod tests { } async fn is_running(&self, _id: &ContainerId) -> Result { - Ok(false) + let mut s = self.state.lock().expect("lock should not be poisoned"); + let result = if s.is_running_responses.is_empty() { + false + } else { + s.is_running_responses.remove(0) + }; + Ok(result) } async fn logs_tail(