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 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/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 07186ab..1be5da7 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 Plugin MCP Container config at `/brain3.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 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 @@ -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 `/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 @@ -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 +- 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 @@ -91,6 +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. +- 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 c7cf70e..26c79e7 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_plugin_containers( host, Arc::clone(&runtime.config), runtime.upstream_secret.clone(), + runtime.plugin_mcp_containers.clone(), shutdown_signal(), ) .await?; diff --git a/apps/gateway/src/server.rs b/apps/gateway/src/server.rs index e4e9b14..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; @@ -12,11 +13,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, 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::StartedPluginMcpContainer; use brain3_platform::http::rate_limit::OAuthRateLimiter; use brain3_platform::http::registrar::GatewayRegistrar; use brain3_platform::http::router::{build_local_router, build_router}; @@ -30,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, @@ -95,12 +101,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_plugin_containers( + host, + config, + upstream_secret, + Vec::new(), + shutdown, + ) + .await +} + +pub async fn run_gateway_server_until_with_plugin_containers( + host: &str, + config: Arc, + upstream_secret: String, + plugin_mcp_containers: Vec, + shutdown: F, +) -> Result<()> where F: Future + Send + 'static, { @@ -119,7 +146,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, &plugin_mcp_containers).await?; let (shutdown_tx, _) = watch::channel(false); tracing::info!( @@ -220,6 +248,15 @@ pub async fn spawn_gateway_server( host: &str, config: Arc, upstream_secret: String, +) -> Result { + spawn_gateway_server_with_plugin_containers(host, config, upstream_secret, Vec::new()).await +} + +pub async fn spawn_gateway_server_with_plugin_containers( + host: &str, + config: Arc, + upstream_secret: String, + plugin_mcp_containers: Vec, ) -> Result { tracing::info!( access_mode = ?config.access_mode, @@ -249,7 +286,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, &plugin_mcp_containers).await?; let (shutdown_tx, _) = watch::channel(false); let mut join_handles = Vec::new(); @@ -332,10 +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( + let server = spawn_gateway_server_with_plugin_containers( host, Arc::clone(&runtime.config), runtime.upstream_secret.clone(), + runtime.plugin_mcp_containers.clone(), ) .await?; let local_url = server.local_url().to_string(); @@ -378,9 +417,10 @@ async fn bind_local_mcp_listener(config: &GatewayConfig) -> Result, upstream_secret: String, + plugin_mcp_containers: &[StartedPluginMcpContainer], ) -> Result> { let registrar = Arc::new(GatewayRegistrar::new( &config.oauth.client_id, @@ -399,7 +439,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 +447,13 @@ 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 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, + plugin_clients, + )); let app_state = AppState { registrar, @@ -421,6 +467,104 @@ fn build_gateway_state( Ok(app_state) } +async fn build_plugin_mcp_clients( + proxy: Arc, + containers: &[StartedPluginMcpContainer], +) -> Vec>> { + let mut clients = Vec::new(); + for container in containers { + 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 Plugin MCP Container client because bearer token could not be loaded" + ); + continue; + } + }; + + match initialize_plugin_mcp_client_with_retry( + 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 Plugin MCP Container tools because initialize/tools-list failed" + ); + } + } + } + + clients +} + +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", + container_ip, container.config.container_port + ), + None => format!("http://127.0.0.1:{}/mcp", container.host_port), + } +} + +fn plugin_bearer_token(container: &StartedPluginMcpContainer) -> Result> { + match &container.config.auth { + 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())) + } + } +} + fn native_mcp_tools_from_config(config: &GatewayConfig) -> Result>> { let transcription = &config.native_audio_transcription; if !transcription.enabled { @@ -479,8 +623,9 @@ mod tests { use std::path::PathBuf; use brain3_core::domain::model::{ - AccessMode, GatewayConfig, HostnameValidationConfig, MCPReverseProxyConfig, - NativeAudioTranscriptionConfig, OAuthConfig, + AccessMode, ContainerRuntime, GatewayConfig, HostnameValidationConfig, + MCPReverseProxyConfig, NativeAudioTranscriptionConfig, OAuthConfig, PluginMcpContainerAuth, + PluginMcpContainerConfig, }; use super::*; @@ -502,6 +647,73 @@ mod tests { ); } + #[test] + 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!(plugin_mcp_url(&container), "http://172.18.0.2:8420/mcp"); + } + + #[test] + 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!(plugin_mcp_url(&container), "http://127.0.0.1:18420/mcp"); + } + + #[test] + fn plugin_bearer_token_reads_and_trims_secret_file() { + let secret_file = std::env::temp_dir().join(format!( + "brain3-plugin-token-test-{}-{}.token", + std::process::id(), + "phase3" + )); + std::fs::write(&secret_file, "secret-token\n").expect("write secret"); + let container = StartedPluginMcpContainer { + config: sample_plugin_container_config( + 8420, + PluginMcpContainerAuth::BearerToken { + secret_file, + secret_mount_path: "/run/secrets/mcp_bearer_token".into(), + }, + ), + host_port: 18420, + container_ip: None, + }; + + assert_eq!( + plugin_bearer_token(&container).expect("token should load"), + Some("secret-token".into()) + ); + if let PluginMcpContainerAuth::BearerToken { secret_file, .. } = &container.config.auth { + let _ = std::fs::remove_file(secret_file); + } + } + + fn sample_plugin_container_config( + container_port: u16, + auth: PluginMcpContainerAuth, + ) -> PluginMcpContainerConfig { + PluginMcpContainerConfig { + 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/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 877bb44..d5aeb4c 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, + plugin_containers: Vec>>, } impl McpRouterUseCase

{ pub fn new(proxy: Arc>, native_tools: Arc) -> Self { + Self::new_with_plugin_containers(proxy, native_tools, Vec::new()) + } + + pub fn new_with_plugin_containers( + proxy: Arc>, + native_tools: Arc, + plugin_containers: Vec>>, + ) -> Self { Self { proxy, native_tools, + plugin_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_plugin_tool(parsed_body.as_ref()).await? { + return Ok(response); + } self.proxy .handle_unvalidated(request_host, method, path, query, headers, body) @@ -154,9 +168,71 @@ impl McpRouterUseCase

{ Ok(Some(native_tool_response(request, output)?)) } - fn append_native_tool_schemas(&self, response: McpProxyResponse) -> McpProxyResponse { + async fn maybe_call_plugin_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.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, + tool_name = unprefixed_name, + request_id = ?request.get("id"), + "MCP router: routing Plugin MCP tool call" + ); + + // 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))); + } + } + } + } + + Ok(None) + } + + fn append_tool_schemas(&self, response: McpProxyResponse) -> McpProxyResponse { let native_schemas = self.native_tools.list_schemas(); - if native_schemas.is_empty() { + let plugin_schemas = self + .plugin_containers + .iter() + .flat_map(|client| client.prefixed_tool_schemas()) + .collect::>(); + + if native_schemas.is_empty() && plugin_schemas.is_empty() { return response; } @@ -175,7 +251,9 @@ impl McpRouterUseCase

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

{ tracing::info!( native_tool_count = native_tool_count, + plugin_tool_count = plugin_tool_count, total_tool_count = total_tool_count, - "MCP router: appended native MCP tools to tools/list response" + "MCP router: appended native and Plugin MCP tools to tools/list response" ); McpProxyResponse { @@ -242,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}; @@ -251,6 +372,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 +487,67 @@ mod tests { ) } + fn router_with_tool_and_plugin( + proxy_body: Vec, + plugin_proxy: Arc, + plugin_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 plugin_captured = Arc::clone(&plugin_proxy.captured); + ( + McpRouterUseCase::new_with_plugin_containers( + proxy_use_case, + Arc::new(registry), + vec![plugin_client], + ), + captured, + tool, + plugin_captured, + ) + } + + async fn initialized_plugin_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("plugin 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 +656,148 @@ mod tests { ); } + #[tokio::test] + async fn tools_list_appends_prefixed_plugin_container_tool_schemas() { + let (plugin_proxy, plugin_client) = initialized_plugin_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, _, plugin_captured) = router_with_tool_and_plugin( + json!({ + "jsonrpc": "2.0", + "id": 3, + "result": { + "tools": [ + { + "name": "container_tool", + "description": "Container tool", + "inputSchema": { "type": "object" } + } + ] + } + }) + .to_string() + .into_bytes(), + 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": 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!( + plugin_captured + .lock() + .expect("plugin capture lock should succeed") + .len(), + 2, + "plugin 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_plugin_tool_bypasses_vault_proxy_and_strips_prefix() { + 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, captured, _, 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__search_deck", + "arguments": { "query": "rust" } + } + }) + .to_string() + .into_bytes(), + ) + .await + .expect("plugin 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 plugin tool call" + ); + let plugin_requests = plugin_captured + .lock() + .expect("plugin capture lock should succeed"); + assert_eq!(plugin_requests.len(), 3); + let body: Value = + 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"); + } + #[tokio::test] async fn initialize_forwards_to_proxy_and_initializes_native_tools() { let (router, captured, tool) = @@ -510,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/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..69a561c --- /dev/null +++ b/crates/core/src/application/remote_mcp_container_client.rs @@ -0,0 +1,401 @@ +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 Plugin 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 Plugin 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 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, + 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( + "Plugin 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 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 Plugin 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-plugin-{}-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 Plugin 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-plugin-{}-tools-list", self.container_name), + "method": "tools/list", + "params": {} + })) + .map_err(|error| { + ProxyError::BadGateway(format!( + "failed to serialize Plugin 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!( + "Plugin 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!( + "Plugin 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 Plugin 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 Plugin 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 Plugin 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!( + "Plugin 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": "plugin 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"))); + } +} diff --git a/crates/core/src/domain/model.rs b/crates/core/src/domain/model.rs index b7965b1..1c9f2b5 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 PluginMcpContainerConfig { + 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: PluginMcpContainerAuth, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginMcpContainerAuth { + 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 @@ -86,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_PLUGIN_MCP_ROLE_LABEL_PREFIX: &str = "brain3-mcp-plugin:"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContainerLabel { 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/brain3_yaml.rs b/crates/platform/src/config/brain3_yaml.rs new file mode 100644 index 0000000..c4a00ab --- /dev/null +++ b/crates/platform/src/config/brain3_yaml.rs @@ -0,0 +1,418 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use brain3_core::domain::model::{ + ContainerRuntime, PluginMcpContainerAuth, PluginMcpContainerConfig, +}; +use serde::Deserialize; + +const DEFAULT_CONTAINER_DIRECTORY: &str = "/data"; +const DEFAULT_SECRET_MOUNT_PATH: &str = "/run/secrets/mcp_bearer_token"; + +#[derive(Debug, Deserialize)] +struct RawBrain3YamlConfig { + #[serde(default)] + plugin_mcp_containers: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawPluginMcpContainerConfig { + 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 RawPluginMcpContainerAuth { + #[serde(rename = "type")] + auth_type: Option, + secret_file: Option, + secret_mount_path: Option, +} + +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(), + "brain3.yaml config file not found" + ); + return Vec::new(); + } + Err(error) => { + tracing::error!( + path = %path.display(), + error = %error, + "failed to read brain3.yaml config file" + ); + return Vec::new(); + } + }; + + let raw: RawBrain3YamlConfig = match serde_saphyr::from_str(&contents) { + Ok(raw) => raw, + Err(error) => { + tracing::error!( + path = %path.display(), + error = %error, + "failed to parse brain3.yaml config file" + ); + return Vec::new(); + } + }; + + validate_plugin_mcp_containers(raw.plugin_mcp_containers) +} + +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_plugin_mcp_container(entry, &mut seen_names) { + Ok(config) => configs.push(config), + Err(reason) => { + tracing::error!( + container = %name_for_log, + reason = %reason, + "skipping invalid Plugin MCP Container config" + ); + } + } + } + + configs +} + +fn validate_plugin_mcp_container( + entry: RawPluginMcpContainerConfig, + 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(PluginMcpContainerConfig { + 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(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(PluginMcpContainerAuth::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, PluginMcpContainerAuth, PluginMcpContainerConfig, + }; + + use super::load_plugin_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("brain3.yaml"); + + let configs = load_plugin_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("brain3.yaml"); + write_file( + &config_path, + &format!( + r#" +plugin_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_plugin_mcp_containers_config(&config_path); + + assert_eq!(configs.len(), 2); + assert_eq!( + configs[0], + PluginMcpContainerConfig { + 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: PluginMcpContainerAuth::BearerToken { + secret_file: first_secret, + secret_mount_path: "/run/secrets/mcp_bearer_token".into(), + }, + } + ); + assert_eq!( + configs[1], + PluginMcpContainerConfig { + 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: PluginMcpContainerAuth::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("brain3.yaml"); + write_file( + &config_path, + &format!( + r#" +plugin_mcp_containers: +{} +{} +"#, + valid_entry("missing_secret", &bad_dir, &missing_secret), + valid_entry("good_tool", &good_dir, &good_secret) + ), + ); + + let configs = load_plugin_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("brain3.yaml"); + write_file( + &config_path, + &format!( + r#" +plugin_mcp_containers: +{} +{} +"#, + valid_entry("same_name", &first_dir, &first_secret), + valid_entry("same_name", &second_dir, &second_secret) + ), + ); + + let configs = load_plugin_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("brain3.yaml"); + write_file( + &config_path, + &format!( + r#" +plugin_mcp_containers: +{} +{} +"#, + valid_entry("Bad-Name", &bad_dir, &bad_secret), + valid_entry("good_name", &good_dir, &good_secret) + ), + ); + + let configs = load_plugin_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("brain3.yaml"); + write_file(&config_path, "plugin_mcp_containers:\n - name: ["); + + 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 554c0c7..03feb5e 100644 --- a/crates/platform/src/config/mod.rs +++ b/crates/platform/src/config/mod.rs @@ -1,2 +1,3 @@ +pub mod brain3_yaml; pub mod env_file; pub mod log_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 e93c180..4028445 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, ManagedContainerInfo, ManagedContainerScope, + PluginMcpContainerAuth, PluginMcpContainerConfig, PortMapping, + BRAIN3_INSTALLATION_ID_LABEL_KEY, BRAIN3_MANAGED_LABEL_KEY, BRAIN3_MANAGED_LABEL_VALUE, + 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}; @@ -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_PLUGIN_MCP_NETWORK_NAME: &str = "brain3-mcp-net"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartedPluginMcpContainer { + pub config: PluginMcpContainerConfig, + 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_plugin_mcp_container( + plugin: &PluginMcpContainerConfig, + startup_policy: RuntimeStartupPolicy, + installation_id: &str, +) -> Result { + let host_port = resolve_plugin_host_port(plugin)?; + let role = plugin_mcp_role_label(plugin.name.as_str()); + tracing::info!( + container = %plugin.name, + image = %plugin.image, + host_directory = %plugin.host_directory.display(), + host_port, + container_port = plugin.container_port, + installation_id, + auth = plugin_auth_kind(&plugin.auth), + "ensuring Plugin MCP Container is running" + ); + tracing::info!( + container = %plugin.name, + network_isolated = true, + isolation_strategy = ?plugin_isolation_strategy(plugin.runtime), + startup_policy = ?startup_policy, + role = %role, + "resolved Plugin MCP Container network isolation mode" + ); + + let port = container_port_for_runtime(plugin.runtime); + maybe_handle_managed_container_orphans( + port.as_ref(), + plugin.runtime, + plugin.name.as_str(), + role.as_str(), + startup_policy, + installation_id, + ) + .await?; + + let config = build_plugin_container_config(plugin, host_port, installation_id); + let (_id, container_ip) = EnsureContainerUseCase::new(port).ensure(&config).await?; + Ok(StartedPluginMcpContainer { + config: plugin.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()); @@ -95,13 +159,47 @@ 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"); + } + + // 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( + plugin: &PluginMcpContainerConfig, +) -> Result<(), ContainerError> { + let port = container_port_for_runtime(plugin.runtime); + let id = ContainerId(plugin.name.clone()); + + if !port.exists(&id).await? { + tracing::debug!(container = %plugin.name, "managed Plugin MCP Container already absent during shutdown"); return Ok(()); } - tracing::info!(container = %startup.container_name, runtime = ?startup.runtime, "stopping managed MCP container during shutdown"); - port.stop(&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"); + } + + // 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 { @@ -111,11 +209,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 +228,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 +237,10 @@ fn managed_container_labels(installation_id: &str) -> Vec { ] } +fn plugin_mcp_role_label(name: &str) -> String { + format!("{BRAIN3_PLUGIN_MCP_ROLE_LABEL_PREFIX}{name}") +} + fn build_container_config( startup: &ContainerStartupConfig, installation_id: &str, @@ -230,26 +339,135 @@ fn build_container_config( } } +fn build_plugin_container_config( + plugin: &PluginMcpContainerConfig, + 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: plugin.host_directory.clone(), + container_path: plugin.container_directory.clone(), + readonly: false, + }]; + + if let PluginMcpContainerAuth::BearerToken { + secret_file, + secret_mount_path, + } = &plugin.auth + { + bind_mounts.push(BindMount { + host_path: secret_file.clone(), + container_path: secret_mount_path.clone(), + readonly: true, + }); + } + + let isolation_strategy = Some(plugin_isolation_strategy(plugin.runtime)); + tracing::info!( + 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!(":{}", plugin.container_port), + auth = plugin_auth_kind(&plugin.auth), + "prepared Plugin MCP Container runtime networking configuration" + ); + + ContainerConfig { + image: plugin.image.clone(), + name: plugin.name.clone(), + isolation_strategy, + network_name: DEFAULT_PLUGIN_MCP_NETWORK_NAME.into(), + port_mappings: vec![PortMapping { + host_address: "127.0.0.1".into(), + host_port, + container_port: plugin.container_port, + }], + env_vars: Vec::new(), + labels: managed_container_labels_for_role( + installation_id, + plugin_mcp_role_label(plugin.name.as_str()).as_str(), + ), + bind_mounts, + user, + detach: true, + remove_on_exit: matches!(plugin.runtime, ContainerRuntime::Docker), + workdir: None, + command: Vec::new(), + } +} + +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 plugin_auth_kind(auth: &PluginMcpContainerAuth) -> &'static str { + match auth { + PluginMcpContainerAuth::None => "none", + PluginMcpContainerAuth::BearerToken { .. } => "bearer_token", + } +} + +fn resolve_plugin_host_port(plugin: &PluginMcpContainerConfig) -> Result { + match plugin.host_port { + Some(port) => Ok(port), + 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 + )) + }) + } + } +} + +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 +476,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 +488,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( @@ -430,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 { @@ -473,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( @@ -586,6 +813,117 @@ mod tests { ); } + fn sample_plugin_config() -> PluginMcpContainerConfig { + PluginMcpContainerConfig { + 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: PluginMcpContainerAuth::BearerToken { + secret_file: "/tmp/fluensy.token".into(), + secret_mount_path: "/run/secrets/mcp_bearer_token".into(), + }, + } + } + + #[test] + 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_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); + 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-plugin: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_plugin_container_config_omits_secret_mount_for_no_auth() { + let mut plugin = sample_plugin_config(); + plugin.auth = PluginMcpContainerAuth::None; + + let config = build_plugin_container_config(&plugin, 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 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!( + plugin_isolation_strategy(ContainerRuntime::Docker), + ContainerNetworkIsolationStrategy::DiscoverContainerIp + ); + assert_eq!( + plugin_isolation_strategy(ContainerRuntime::MacOSContainer), + ContainerNetworkIsolationStrategy::PublishToLoopback + ); + } + #[tokio::test] async fn orphan_preflight_requires_explicit_gc() { let port = MockContainerPort::new(MockState { @@ -600,7 +938,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 +977,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 +1052,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..dfd8729 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, 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::container::startup::{ensure_mcp_container, installation_scope_id, stop_mcp_container}; +use crate::container::startup::{ + ensure_mcp_container, ensure_plugin_mcp_container, installation_scope_id, stop_mcp_container, + stop_plugin_mcp_container, StartedPluginMcpContainer, +}; 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 plugin_mcp_containers: Vec, managed_container_started: bool, tunnel: Option>, } @@ -58,6 +65,7 @@ impl RuntimeBootstrap { public_url, container_status, tunnel_status, + plugin_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 plugin in &self.plugin_mcp_containers { + if let Err(error) = stop_plugin_mcp_container(&plugin.config).await { + tracing::warn!( + container = %plugin.config.name, + runtime = ?plugin.config.runtime, + error = %error, + "failed to stop managed Plugin 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 plugin_mcp_containers = if container_status.allows_gateway_start() { + start_plugin_mcp_containers( + &launch_plan.paths.app_home, + startup_policy, + installation_id.as_str(), + ) + .await + } else { + tracing::debug!( + container_status = ?container_status, + "skipping Plugin 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, + plugin_mcp_containers, managed_container_started, tunnel: tunnel_guard, }) } +async fn start_plugin_mcp_containers( + app_home: &std::path::Path, + startup_policy: RuntimeStartupPolicy, + installation_id: &str, +) -> 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 Plugin MCP Containers configured" + ); + return Vec::new(); + } + + tracing::info!( + path = %config_path.display(), + count = configs.len(), + "loaded Plugin MCP Container configs" + ); + + let mut started = Vec::new(); + for config in configs { + 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, + "Plugin MCP Container ready" + ); + started.push(container); + } + Err(error) => { + log_plugin_container_startup_failure(&config, &error); + if error.started_container() { + stop_failed_plugin_container(&config).await; + } + } + } + } + + started +} + +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, + "Plugin MCP Container startup failed; continuing without it" + ); + } else { + tracing::error!( + container = %config.name, + summary, + "Plugin MCP Container startup failed; continuing without it" + ); + } +} + +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 Plugin 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() { 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 new file mode 100644 index 0000000..9f9d491 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-plugin-mcp-containers-config.md @@ -0,0 +1,502 @@ +# Plugin MCP Containers (Experimental, Hidden Config) + +## Goal + +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 +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 (all phases) + +- 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 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 + the schema and code structured so that migration is easy later, not doing + the migration itself. +- 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 + +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-plugin:{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 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: 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 + 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: `/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 (`plugin_mcp_containers` section of `brain3.yaml`) + +```yaml +# 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 + 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 +``` + +- `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). +- `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 (required). +- `host_port`: **optional**. If omitted, the gateway auto-picks a free + 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. +- `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: + 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. +- `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`) 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. + +### 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, + 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: PluginMcpContainerAuth, +} + +pub enum PluginMcpContainerAuth { + None, + 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 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`, +`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/brain3_yaml.rs`) + +- 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 + - `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. + +### 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 +(isolated in `build_container_config`). + +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-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 + +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; 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. + +### Startup sequence in `bootstrap.rs` + +1. Ensure core container (unchanged). +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 + same shutdown path that stops the core container. + +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. + +### 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 `/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 + 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 `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 + still starts and serves the core vault tools normally. +- Gateway shutdown stops/removes the plugin 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: +- **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. + +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. + +Proposed approach — generalize instead of special-casing per container: + +- 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 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 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 + 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 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 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; plugin-container schemas are appended + alongside it, not merged into it. +- 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 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 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). + +--- + +## 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 + 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 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_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. + +### 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 `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 + 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_plugin_mcp_container`): + - `TempTestDir::create`, write `.env` (as today) *and* the new + `brain3.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 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_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 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 +together, then get it green. + +--- + +## File/module summary + +- `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) +- `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 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) +- `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_brain3_yaml` helper, new + `e2e_smoke_5_plugin_mcp_container` test. (Phase 5) 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()