From d0bb631d4d30d27876761dd7c2f46dbda9f57405 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 10:31:18 -0700 Subject: [PATCH] Remove MCP backend, entrypoint injection, and docs; add migration shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the removal begun in the previous commit. Backend: deletes models/mcp_server.rs, storage/mcp_store.rs and commands/mcp_commands.rs, the McpStore on AppState, the four IPC handlers, Project::enabled_mcp_servers, build_mcp_servers_json(), compute_mcp_fingerprint(), the MCP_SERVERS_JSON env injection, the mcp-fingerprint label, and the whole MCP container lifecycle. create_container() and container_needs_recreation() lose their mcp_servers/network_name parameters. Container: entrypoint.sh no longer merges MCP_SERVERS_JSON into ~/.claude.json. MCP_SERVERS_JSON stays in the reserved env blocklist. Security: the Docker socket is no longer auto-mounted for stdio+Docker MCP servers — it now mounts only when allow_docker_access is set. Migration: old containers were created with network_mode=triple-c-net- and refuse to start once that network is gone. docker/network.rs becomes docker/legacy_cleanup.rs with label-driven, best-effort removal of leftover MCP containers and the per-project network, called on both delete and recreate. container_needs_recreation() now forces a rebuild for any container carrying a non-empty triple-c.mcp-fingerprint label or attached to a triple-c-net-* network, moving it onto the default bridge. Both can be dropped a release later. Docs: drops the MCP sections from README/HOW-TO-USE/TECHNICAL and adds a short note pointing at Claude Code's native `claude mcp` / `/mcp` / .mcp.json instead. Co-Authored-By: Claude Opus 5 (1M context) --- HOW-TO-USE.md | 140 +------- README.md | 40 +-- TECHNICAL.md | 12 +- app/src-tauri/src/commands/file_commands.rs | 7 +- app/src-tauri/src/commands/mcp_commands.rs | 38 --- app/src-tauri/src/commands/mod.rs | 1 - .../src/commands/project_commands.rs | 93 +----- app/src-tauri/src/docker/container.rs | 301 ++---------------- app/src-tauri/src/docker/legacy_cleanup.rs | 137 ++++++++ app/src-tauri/src/docker/mod.rs | 4 +- app/src-tauri/src/docker/network.rs | 129 -------- app/src-tauri/src/lib.rs | 15 - app/src-tauri/src/models/mcp_server.rs | 70 ---- app/src-tauri/src/models/mod.rs | 2 - app/src-tauri/src/models/project.rs | 3 - app/src-tauri/src/storage/mcp_store.rs | 106 ------ app/src-tauri/src/storage/mod.rs | 3 - container/entrypoint.sh | 21 -- 18 files changed, 204 insertions(+), 918 deletions(-) delete mode 100644 app/src-tauri/src/commands/mcp_commands.rs create mode 100644 app/src-tauri/src/docker/legacy_cleanup.rs delete mode 100644 app/src-tauri/src/docker/network.rs delete mode 100644 app/src-tauri/src/models/mcp_server.rs delete mode 100644 app/src-tauri/src/storage/mcp_store.rs diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 6c92943..ddb99a7 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -11,7 +11,6 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code - [The Interface](#the-interface) - [Project Management](#project-management) - [Project Configuration](#project-configuration) -- [MCP Servers (Beta)](#mcp-servers-beta) - [AWS Bedrock Configuration](#aws-bedrock-configuration) - [Ollama Configuration](#ollama-configuration) - [OpenAI Compatible Configuration](#openai-compatible-configuration) @@ -136,7 +135,6 @@ Claude Code launches automatically. By default, it runs in standard permission m │ Sidebar │ │ │ │ Terminal View │ │ Projects │ (xterm.js) │ -│ MCP │ │ │ Settings │ │ ├────────────┴────────────────────────────────────────┤ │ StatusBar X projects · X running · X terminals │ @@ -144,7 +142,7 @@ Claude Code launches automatically. By default, it runs in standard permission m ``` - **TopBar** — Terminal tabs for switching between sessions. Bash shell tabs show a "(bash)" suffix. Status dots on the right show Docker connection (green = connected) and image availability (green = ready). -- **Sidebar** — Toggle between the **Projects** list, **MCP** server configuration, and **Settings** panel. +- **Sidebar** — Toggle between the **Projects** list and the **Settings** panel. - **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering. Includes a **Jump to Current** button that appears when you scroll up, so you can quickly return to the latest output. - **StatusBar** — Counts of total projects, running containers, and open terminal sessions. @@ -192,7 +190,7 @@ Only **Remove** deletes everything, including the config volume and any stored c ### Container Progress Feedback -When starting, stopping, or resetting a container, a progress modal shows real-time status messages (e.g., "Setting up MCP network...", "Starting MCP containers...", "Creating container..."). If an error occurs, the modal displays the error with a **Close** button. A **Force Stop** option is available if the operation stalls. The modal auto-closes on success. +When starting, stopping, or resetting a container, a progress modal shows real-time status messages (e.g., "Creating container...", "Starting container..."). If an error occurs, the modal displays the error with a **Close** button. A **Force Stop** option is available if the operation stalls. The modal auto-closes on success. --- @@ -253,7 +251,7 @@ When **disabled** (default), Claude prompts you for approval before executing ea Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key. -> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `TRIPLE_C_`) and specific internal variables (`CLAUDE_INSTRUCTIONS`, `MCP_SERVERS_JSON`, etc.) are filtered out to prevent conflicts. `CLAUDE_CODE_*` variables are now allowed, so you can set Claude Code feature flags directly (e.g., `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`). +> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `TRIPLE_C_`) and specific internal variables (`CLAUDE_INSTRUCTIONS`, `CLAUDE_CODE_SETTINGS_JSON`, etc.) are filtered out to prevent conflicts. `CLAUDE_CODE_*` variables are now allowed, so you can set Claude Code feature flags directly (e.g., `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`). ### Port Mappings @@ -287,127 +285,17 @@ Per-project settings override global defaults set in Settings. If all settings a > These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect. ---- +### MCP Servers -## MCP Servers (Beta) +Triple-C no longer manages [MCP](https://modelcontextprotocol.io/) servers itself. Configure them with Claude Code's own tooling from a terminal inside the container: -Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, which extend Claude Code with access to external tools and data sources. MCP servers are configured in a **global library** and **enabled per-project**. +- `claude mcp add` — register a server +- `claude mcp list` — show configured servers +- `claude mcp remove` — delete a server +- `/mcp` — slash command inside a Claude Code session for MCP status and authentication +- A project-level `.mcp.json` in `/workspace` — checked into your repo and shared with anyone who opens the project -### How It Works - -There are two dimensions to MCP server configuration: - -| | **Manual** (no Docker image) | **Docker** (Docker image specified) | -|---|---|---| -| **Stdio** | Command runs inside the project container | Command runs in a separate MCP container via `docker exec` | -| **HTTP** | Connects to a URL you provide | Runs in a separate container, reached by hostname on a shared Docker network | - -**Docker images are pulled automatically** if not already present when the project starts. - -### Accessing MCP Configuration - -Click the **MCP** tab in the sidebar to open the MCP server library. This is where you define all available MCP servers. - -### Adding an MCP Server - -1. Type a name in the input field and click **Add**. -2. Expand the server card and configure it. - -The key decision is whether to set a **Docker Image**: -- **With Docker image** — The MCP server runs in its own isolated container. Best for servers that need specific dependencies or system-level packages. -- **Without Docker image** (manual) — The command runs directly inside your project container. Best for lightweight npx-based servers that just need Node.js. - -Then choose the **Transport Type**: -- **Stdio** — The MCP server communicates over stdin/stdout. This is the most common type. -- **HTTP** — The MCP server exposes an HTTP endpoint (streamable HTTP transport). - -### Configuration Examples - -#### Example 1: Filesystem Server (Stdio, Manual) - -A simple npx-based server that runs inside the project container. No Docker image needed since Node.js is already installed. - -| Field | Value | -|-------|-------| -| **Docker Image** | *(empty)* | -| **Transport** | Stdio | -| **Command** | `npx` | -| **Arguments** | `-y @modelcontextprotocol/server-filesystem /workspace` | - -This gives Claude Code access to browse and read files via MCP. The command runs directly inside the project container using the pre-installed Node.js. - -#### Example 2: GitHub Server (Stdio, Manual) - -Another npx-based server, with an environment variable for authentication. - -| Field | Value | -|-------|-------| -| **Docker Image** | *(empty)* | -| **Transport** | Stdio | -| **Command** | `npx` | -| **Arguments** | `-y @modelcontextprotocol/server-github` | -| **Environment Variables** | `GITHUB_PERSONAL_ACCESS_TOKEN` = `ghp_your_token` | - -#### Example 3: Custom MCP Server (HTTP, Docker) - -An MCP server packaged as a Docker image that exposes an HTTP endpoint. - -| Field | Value | -|-------|-------| -| **Docker Image** | `myregistry/my-mcp-server:latest` | -| **Transport** | HTTP | -| **Container Port** | `8080` | -| **Environment Variables** | `API_KEY` = `your_key` | - -Triple-C will: -1. Pull the image automatically if not present -2. Start the container on the project's bridge network -3. Configure Claude Code to reach it at `http://triple-c-mcp-{id}:8080/mcp` - -The hostname is the MCP container's name on the Docker network — **not** `localhost`. - -#### Example 4: Database Server (Stdio, Docker) - -An MCP server that needs its own runtime environment, communicating over stdio. - -| Field | Value | -|-------|-------| -| **Docker Image** | `mcp/postgres-server:latest` | -| **Transport** | Stdio | -| **Command** | `node` | -| **Arguments** | `dist/index.js` | -| **Environment Variables** | `DATABASE_URL` = `postgresql://user:pass@host:5432/db` | - -Triple-C will: -1. Pull the image and start it on the project network -2. Configure Claude Code to communicate via `docker exec -i triple-c-mcp-{id} node dist/index.js` -3. Automatically enable Docker socket access on the project container (required for `docker exec`) - -### Enabling MCP Servers Per-Project - -In a project's configuration panel (click **Config**), the **MCP Servers** section shows checkboxes for all globally defined servers. Toggle each server on or off for that project. Changes take effect on the next container start. - -### How Docker-Based MCP Works - -When a project with Docker-based MCP servers starts: - -1. Missing Docker images are **automatically pulled** (progress shown in the progress modal) -2. A dedicated **bridge network** is created for the project (`triple-c-net-{projectId}`) -3. Each enabled Docker MCP server gets its own container on that network -4. The main project container is connected to the same network -5. MCP server configuration is written to `~/.claude.json` inside the container - -**Networking**: Docker-based MCP containers are reached by their container name as a hostname (e.g., `triple-c-mcp-{serverId}`), not by `localhost`. Docker DNS resolves these names automatically on the shared bridge network. - -**Stdio + Docker**: The project container uses `docker exec` to communicate with the MCP container over stdin/stdout. This automatically enables Docker socket access on the project container. - -**HTTP + Docker**: The project container connects to the MCP container's HTTP endpoint using the container hostname and port (e.g., `http://triple-c-mcp-{serverId}:3000/mcp`). - -**Manual (no Docker image)**: Stdio commands run directly inside the project container. HTTP URLs connect to wherever you point them (could be an external service or something running on the host). - -### Configuration Change Detection - -MCP server configuration is tracked via SHA-256 fingerprints stored as Docker labels. If you add, remove, or modify MCP servers for a project, the container is automatically recreated on the next start to apply the new configuration. The container filesystem is snapshotted first, so installed packages are preserved. +Your MCP configuration persists across container stop/start because `~/.claude.json` and `~/.claude` live on named Docker volumes. A **Reset** wipes them, so you would need to re-add your servers afterwards. --- @@ -753,12 +641,6 @@ These features are built into Claude Code and work inside Triple-C containers wi - Most project settings can only be changed when the container is **stopped**. Stop the container first, make your changes, then start it again. - Some changes (like toggling Docker access, Mission Control, or changing mounted folders) trigger an automatic container recreation on the next start. -### MCP Containers Not Starting - -- Ensure the Docker image for the MCP server exists (pull it first if needed). -- Check that Docker socket access is available (stdio + Docker MCP servers auto-enable this). -- Try resetting the project container to force a clean recreation. - ### "Failed to install Anthropic marketplace" Error If Claude Code shows **"Failed to install Anthropic marketplace - Will retry on next startup"** repeatedly, the marketplace metadata in `~/.claude.json` may be corrupted. To fix this, open a **Shell** session in the project and run: diff --git a/README.md b/README.md index a0b7244..2e1c9d5 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi ### Container Lifecycle 1. **Create**: New container created with bind mounts, env vars, and labels -2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers, injects Claude Code settings +2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, injects Claude Code settings 3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY -4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped +4. **Stop**: Container halted (filesystem persists in named volume) 5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint) 6. **Reset**: Container removed and recreated from scratch (named volume preserved) @@ -41,7 +41,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi | `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation | | `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` | | `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth | -| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON, or auto-enabled by stdio+Docker MCP servers | +| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON | ### Authentication Modes @@ -60,27 +60,6 @@ When "Allow container spawning" is enabled per-project, the host Docker socket i If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation. -### MCP Server Architecture - -Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers as a Beta feature. MCP servers extend Claude Code with external tools and data sources. - -**Modes**: Each MCP server operates in one of four modes based on transport type and whether a Docker image is specified: - -| Mode | Where It Runs | How It Communicates | -|------|--------------|---------------------| -| Stdio + Manual | Inside the project container | Direct stdin/stdout (e.g., `npx -y @mcp/server`) | -| Stdio + Docker | Separate MCP container | `docker exec -i ` from the project container | -| HTTP + Manual | External / user-provided | Connects to the URL you specify | -| HTTP + Docker | Separate MCP container | `http://:/mcp` via Docker DNS on a shared bridge network | - -**Key behaviors**: -- **Global library**: MCP servers are defined globally in the MCP sidebar tab and stored in `mcp_servers.json` -- **Per-project toggles**: Each project enables/disables individual servers via checkboxes -- **Auto-pull**: Docker images for MCP servers are pulled automatically if not present when the project starts -- **Docker networking**: Docker-based MCP containers run on a per-project bridge network (`triple-c-net-{projectId}`), reachable by container name — not localhost -- **Auto-detection**: Config changes are detected via SHA-256 fingerprints and trigger automatic container recreation -- **Config injection**: MCP server configuration is written to `~/.claude.json` inside the container via the `MCP_SERVERS_JSON` environment variable, merged by the entrypoint using `jq` - ### Mission Control Integration Optional per-project integration with Flight Control — an AI-first development methodology bundled with Triple-C. When enabled, the bundled files are installed into the container, skills are installed, and workflow instructions are injected into CLAUDE.md. @@ -132,8 +111,6 @@ Users can override this in Settings via the global `docker_socket_path` option. | `app/src/components/projects/ProjectList.tsx` | Project list in sidebar | | `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) | | `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress | -| `app/src/components/mcp/McpPanel.tsx` | MCP server library (global configuration) | -| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card | | `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, and global settings | | `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management | | `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) | @@ -142,30 +119,25 @@ Users can override this in Settings via the global `docker_socket_path` option. | `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) | | `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) | | `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) | -| `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations | | `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management | -| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting | +| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, fingerprinting | | `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar | | `app/src-tauri/src/docker/image.rs` | Image building/pulling | -| `app/src-tauri/src/docker/network.rs` | Per-project bridge networks for MCP containers | | `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers | | `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) | -| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands | -| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, Claude Code settings, MCP servers, Mission Control) | -| `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) | +| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, Claude Code settings, Mission Control) | | `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) | | `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access | | `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management | | `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) | | `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands | | `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands | -| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) | | `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) | | `app/src/lib/wav.ts` | WAV audio encoding for STT transcription | | `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) | | `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) | | `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims | -| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Claude Code settings injection, Mission Control setup | +| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, Claude Code settings injection, Mission Control setup | | `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) | | `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode | diff --git a/TECHNICAL.md b/TECHNICAL.md index cf02576..5a89d49 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -236,7 +236,7 @@ triple-c/ │ ├── container/ │ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code -│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, MCP injection +│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, settings injection │ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52) │ ├── audio-shim # Audio capture shim (rec/arecord via FIFO) │ ├── triple-c-scheduler # Bash-based cron task system @@ -259,11 +259,10 @@ triple-c/ │ ├── App.tsx # Top-level layout │ ├── index.css # CSS variables, dark theme, scrollbars │ ├── store/ - │ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI) + │ │ └── appState.ts # Zustand store (projects, sessions, UI) │ ├── hooks/ │ │ ├── useDocker.ts # Docker status, image build/pull │ │ ├── useFileManager.ts # File manager operations - │ │ ├── useMcpServers.ts # MCP server CRUD │ │ ├── useProjects.ts # Project CRUD operations │ │ ├── useSettings.ts # App settings │ │ ├── useTerminal.ts # Terminal I/O, resize, session events @@ -275,7 +274,6 @@ triple-c/ │ │ └── constants.ts # App-wide constants │ └── components/ │ ├── layout/ # Sidebar, TopBar, StatusBar - │ ├── mcp/ # McpPanel, McpServerCard │ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog, │ │ # FileManagerModal, ContainerProgressModal, modals │ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings, @@ -294,7 +292,6 @@ triple-c/ ├── commands/ # Tauri command handlers │ ├── docker_commands.rs # Docker status, image ops │ ├── file_commands.rs # File manager (list/download/upload) - │ ├── mcp_commands.rs # MCP server CRUD │ ├── project_commands.rs # Start/stop/rebuild containers │ ├── settings_commands.rs # Settings CRUD │ ├── terminal_commands.rs # Terminal I/O, resize @@ -309,17 +306,14 @@ triple-c/ │ ├── client.rs # bollard singleton connection │ ├── container.rs # Create, start, stop, remove, fingerprinting │ ├── exec.rs # PTY exec sessions with bidirectional streaming - │ ├── image.rs # Build from Dockerfile, pull from registry - │ └── network.rs # Per-project bridge networks for MCP + │ └── image.rs # Build from Dockerfile, pull from registry ├── models/ # Data structures │ ├── project.rs # Project, Backend, BedrockConfig - │ ├── mcp_server.rs # MCP server configuration │ ├── app_settings.rs # Global settings (image source, AWS, etc.) │ ├── container_config.rs # Image name resolution │ └── update_info.rs # Update metadata └── storage/ # Persistence ├── projects_store.rs # JSON file with atomic writes - ├── mcp_store.rs # MCP server persistence ├── settings_store.rs # App settings (Tauri plugin-store) └── secure.rs # OS keychain via keyring ``` diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 7d4d848..37f670f 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -157,9 +157,10 @@ pub async fn download_container_file( /// - the workspace (default /workspace), minus regenerable build artifacts /// (node_modules, target), under `workspace/`, and /// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json -/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/ -/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills -/// set up via Claude Code survive a Reset. +/// with secret-bearing keys removed (`mcpServers` — Claude Code's own native +/// MCP config — and `settings` are kept) and ~/.claude/ minus the OAuth +/// `.credentials.json`, so settings and skills set up via Claude Code +/// survive a Reset. /// `.git` is kept in full so the backup faithfully preserves git history, /// including unpushed commits. Build + gzip happen inside the container so a /// large workspace isn't streamed in full. The container must be RUNNING (the diff --git a/app/src-tauri/src/commands/mcp_commands.rs b/app/src-tauri/src/commands/mcp_commands.rs deleted file mode 100644 index 771a227..0000000 --- a/app/src-tauri/src/commands/mcp_commands.rs +++ /dev/null @@ -1,38 +0,0 @@ -use tauri::State; - -use crate::models::McpServer; -use crate::AppState; - -#[tauri::command] -pub async fn list_mcp_servers(state: State<'_, AppState>) -> Result, String> { - Ok(state.mcp_store.list()) -} - -#[tauri::command] -pub async fn add_mcp_server( - name: String, - state: State<'_, AppState>, -) -> Result { - let name = name.trim().to_string(); - if name.is_empty() { - return Err("MCP server name cannot be empty.".to_string()); - } - let server = McpServer::new(name); - state.mcp_store.add(server) -} - -#[tauri::command] -pub async fn update_mcp_server( - server: McpServer, - state: State<'_, AppState>, -) -> Result { - state.mcp_store.update(server) -} - -#[tauri::command] -pub async fn remove_mcp_server( - server_id: String, - state: State<'_, AppState>, -) -> Result<(), String> { - state.mcp_store.remove(&server_id) -} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 555b692..27edfa8 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -3,7 +3,6 @@ pub mod docker_commands; pub mod file_commands; pub mod help_commands; pub mod install_helper_commands; -pub mod mcp_commands; pub mod project_commands; pub mod settings_commands; pub mod stt_commands; diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index c947a64..dee3c99 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -2,7 +2,7 @@ use tauri::{Emitter, State}; use crate::commands::aws_commands; use crate::docker; -use crate::models::{container_config, Backend, BedrockAuthMethod, McpServer, Project, ProjectPath, ProjectStatus}; +use crate::models::{container_config, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus}; use crate::storage::secure; use crate::AppState; @@ -63,19 +63,6 @@ fn load_secrets_for_project(project: &mut Project) { } } -/// Resolve enabled MCP servers and filter to Docker-only ones. -fn resolve_mcp_servers(project: &Project, state: &AppState) -> (Vec, Vec) { - let all_mcp_servers = state.mcp_store.list(); - let enabled_mcp: Vec = project.enabled_mcp_servers.iter() - .filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned()) - .collect(); - let docker_mcp: Vec = enabled_mcp.iter() - .filter(|s| s.is_docker()) - .cloned() - .collect(); - (enabled_mcp, docker_mcp) -} - #[tauri::command] pub async fn list_projects(state: State<'_, AppState>) -> Result, String> { Ok(state.projects_store.list()) @@ -121,16 +108,10 @@ pub async fn remove_project( let _ = docker::remove_container(container_id).await; } - // Remove MCP containers and network - let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(project, &state); - if !docker_mcp.is_empty() { - if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await { - log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e); - } - } - if let Err(e) = docker::remove_project_network(&project.id).await { - log::warn!("Failed to remove project network for project {}: {}", project_id, e); - } + // Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP + // containers first, then the per-project network they were attached to. + docker::remove_legacy_mcp_containers(&project.id).await; + docker::remove_legacy_project_network(&project.id).await; // Clean up the snapshot image + volumes if let Err(e) = docker::remove_snapshot_image(project).await { @@ -177,9 +158,6 @@ pub async fn start_project_container( let settings = state.settings_store.get(); let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name); - // Resolve enabled MCP servers for this project - let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); - // Validate backend requirements if project.backend == Backend::Bedrock { let bedrock = project.bedrock_config.as_ref() @@ -300,39 +278,6 @@ pub async fn start_project_container( // AWS config path from global settings let aws_config_path = settings.global_aws.aws_config_path.clone(); - // Set up Docker network and MCP containers if needed - let network_name = if !docker_mcp.is_empty() { - // Pull any missing MCP Docker images before starting containers - for server in &docker_mcp { - if let Some(ref image) = server.docker_image { - if !docker::image_exists(image).await.unwrap_or(false) { - emit_progress( - &app_handle, - &project_id, - &format!("Pulling MCP image for '{}'...", server.name), - ); - let image_clone = image.clone(); - let app_clone = app_handle.clone(); - let pid_clone = project_id.clone(); - let sname = server.name.clone(); - docker::pull_image(&image_clone, move |msg| { - emit_progress(&app_clone, &pid_clone, &format!("[{}] {}", sname, msg)); - }).await.map_err(|e| { - format!("Failed to pull MCP image '{}' for '{}': {}", image, server.name, e) - })?; - } - } - } - - emit_progress(&app_handle, &project_id, "Setting up MCP network..."); - let net = docker::ensure_project_network(&project.id).await?; - emit_progress(&app_handle, &project_id, "Starting MCP containers..."); - docker::start_mcp_containers(&docker_mcp, &net).await?; - Some(net) - } else { - None - }; - let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? { // Check if config changed — if so, snapshot + recreate let needs_recreate = docker::container_needs_recreation( @@ -344,7 +289,6 @@ pub async fn start_project_container( settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), - &enabled_mcp, settings.global_claude_code_settings.as_ref(), settings.default_ssh_key_path.as_deref(), settings.default_git_user_name.as_deref(), @@ -362,6 +306,12 @@ pub async fn start_project_container( let _ = docker::stop_container(&existing_id).await; docker::remove_container(&existing_id).await?; + // Legacy MCP cleanup: the old container may have been attached to + // `triple-c-net-`. Tear down leftover MCP containers and + // that network now, before the replacement is created without it. + docker::remove_legacy_mcp_containers(&project.id).await; + docker::remove_legacy_project_network(&project.id).await; + // Create from snapshot image (preserves system-level changes) let snapshot_image = docker::get_snapshot_image_name(&project); let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { @@ -381,8 +331,6 @@ pub async fn start_project_container( settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), - &enabled_mcp, - network_name.as_deref(), settings.global_claude_code_settings.as_ref(), settings.default_ssh_key_path.as_deref(), settings.default_git_user_name.as_deref(), @@ -420,8 +368,6 @@ pub async fn start_project_container( settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), - &enabled_mcp, - network_name.as_deref(), settings.global_claude_code_settings.as_ref(), settings.default_ssh_key_path.as_deref(), settings.default_git_user_name.as_deref(), @@ -482,15 +428,6 @@ pub async fn stop_project_container( } } - // Stop MCP containers (best-effort) - let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); - if !docker_mcp.is_empty() { - emit_progress(&app_handle, &project_id, "Stopping MCP containers..."); - if let Err(e) = docker::stop_mcp_containers(&docker_mcp).await { - log::warn!("Failed to stop MCP containers for project {}: {}", project_id, e); - } - } - state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?; Ok(()) } @@ -514,14 +451,6 @@ pub async fn rebuild_project_container( state.projects_store.set_container_id(&project_id, None)?; } - // Remove MCP containers before rebuild - let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); - if !docker_mcp.is_empty() { - if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await { - log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e); - } - } - // Remove snapshot image + volumes so Reset creates from the clean base image if let Err(e) = docker::remove_snapshot_image(&project).await { log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 0c9f997..e18f0a5 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use sha2::{Sha256, Digest}; use super::client::get_docker; -use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath}; +use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath}; const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks @@ -175,6 +175,8 @@ fn build_claude_instructions( /// Sorted alphabetically so order changes do not cause spurious recreation. fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; + // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was + // removed, but the name stays blocked so users cannot hand-set it. let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; let mut parts: Vec = Vec::new(); for env_var in custom_env_vars { @@ -476,83 +478,6 @@ fn build_claude_code_settings_json( } } -/// Build the JSON value for MCP servers config to be injected into ~/.claude.json. -/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`. -/// -/// Handles 4 modes: -/// - Stdio+Docker: `docker exec -i ...args` -/// - Stdio+Manual: ` ...args` (existing behavior) -/// - HTTP+Docker: `streamableHttp` URL pointing to `http://:/mcp` -/// - HTTP+Manual: `streamableHttp` with user-provided URL + headers -fn build_mcp_servers_json(servers: &[McpServer]) -> String { - let mut mcp_map = serde_json::Map::new(); - for server in servers { - let mut entry = serde_json::Map::new(); - match server.transport_type { - McpTransportType::Stdio => { - entry.insert("type".to_string(), serde_json::json!("stdio")); - if server.is_docker() { - // Stdio+Docker: use `docker exec` to communicate with MCP container - entry.insert("command".to_string(), serde_json::json!("docker")); - let mut args = vec![ - "exec".to_string(), - "-i".to_string(), - server.mcp_container_name(), - ]; - if let Some(ref cmd) = server.command { - args.push(cmd.clone()); - } - args.extend(server.args.iter().cloned()); - entry.insert("args".to_string(), serde_json::json!(args)); - } else { - // Stdio+Manual: existing behavior - if let Some(ref cmd) = server.command { - entry.insert("command".to_string(), serde_json::json!(cmd)); - } - if !server.args.is_empty() { - entry.insert("args".to_string(), serde_json::json!(server.args)); - } - } - if !server.env.is_empty() { - entry.insert("env".to_string(), serde_json::json!(server.env)); - } - } - McpTransportType::Http => { - entry.insert("type".to_string(), serde_json::json!("streamableHttp")); - if server.is_docker() { - // HTTP+Docker: point to MCP container by name on the shared network - let url = format!( - "http://{}:{}/mcp", - server.mcp_container_name(), - server.effective_container_port() - ); - entry.insert("url".to_string(), serde_json::json!(url)); - } else { - // HTTP+Manual: user-provided URL + headers - if let Some(ref url) = server.url { - entry.insert("url".to_string(), serde_json::json!(url)); - } - if !server.headers.is_empty() { - entry.insert("headers".to_string(), serde_json::json!(server.headers)); - } - } - } - } - mcp_map.insert(server.name.clone(), serde_json::Value::Object(entry)); - } - let wrapper = serde_json::json!({ "mcpServers": mcp_map }); - serde_json::to_string(&wrapper).unwrap_or_default() -} - -/// Compute a fingerprint for MCP server configuration so we can detect changes. -fn compute_mcp_fingerprint(servers: &[McpServer]) -> String { - if servers.is_empty() { - return String::new(); - } - let json = build_mcp_servers_json(servers); - sha256_hex(&json) -} - pub async fn find_existing_container(project: &Project) -> Result, String> { let docker = get_docker()?; let container_name = project.container_name(); @@ -594,8 +519,6 @@ pub async fn create_container( global_claude_instructions: Option<&str>, global_custom_env_vars: &[EnvVar], timezone: Option<&str>, - mcp_servers: &[McpServer], - network_name: Option<&str>, global_claude_code_settings: Option<&ClaudeCodeSettings>, default_ssh_key_path: Option<&str>, default_git_user_name: Option<&str>, @@ -795,6 +718,8 @@ pub async fn create_container( // Custom environment variables (global + per-project, project overrides global for same key) let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; + // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was + // removed, but the name stays blocked so users cannot hand-set it. let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; for env_var in &merged_env { let key = env_var.key.trim(); @@ -838,12 +763,6 @@ pub async fn create_container( env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); } - // MCP servers config - if !mcp_servers.is_empty() { - let mcp_json = build_mcp_servers_json(mcp_servers); - env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json)); - } - // Claude Code settings (global + per-project merged) let merged_cc_settings = merge_claude_code_settings( global_claude_code_settings, @@ -964,12 +883,8 @@ pub async fn create_container( } } - // Docker socket (if allowed, or auto-enabled for stdio+Docker MCP servers) - let needs_docker_for_mcp = any_stdio_docker_mcp(mcp_servers); - if project.allow_docker_access || needs_docker_for_mcp { - if needs_docker_for_mcp && !project.allow_docker_access { - log::info!("Auto-enabling Docker socket access for stdio+Docker MCP servers"); - } + // Docker socket (if allowed) + if project.allow_docker_access { // On Windows, the named pipe (//./pipe/docker_engine) cannot be // bind-mounted into a Linux container. Docker Desktop exposes the // daemon socket as /var/run/docker.sock for container mounts. @@ -1014,7 +929,6 @@ pub async fn create_container( labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings)); labels.insert("triple-c.image".to_string(), image_name.to_string()); labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string()); - labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers)); labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string()); labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone()); labels.insert("triple-c.claude-code-settings-fingerprint".to_string(), @@ -1030,8 +944,6 @@ pub async fn create_container( mounts: Some(mounts), port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) }, init: Some(true), - // Connect to project network if specified (for MCP container communication) - network_mode: network_name.map(|n| n.to_string()), ..Default::default() }; @@ -1307,7 +1219,6 @@ pub async fn container_needs_recreation( global_claude_instructions: Option<&str>, global_custom_env_vars: &[EnvVar], timezone: Option<&str>, - mcp_servers: &[McpServer], global_claude_code_settings: Option<&ClaudeCodeSettings>, default_ssh_key_path: Option<&str>, default_git_user_name: Option<&str>, @@ -1514,11 +1425,29 @@ pub async fn container_needs_recreation( return Ok(true); } - // ── MCP servers fingerprint ───────────────────────────────────────── - let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers); - let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default(); - if container_mcp_fp != expected_mcp_fp { - log::info!("MCP servers fingerprint mismatch (container={:?}, expected={:?})", container_mcp_fp, expected_mcp_fp); + // ── Legacy MCP migration shim ─────────────────────────────────────── + // One-release migration for containers created before the built-in MCP + // feature was removed. Such containers carry a `triple-c.mcp-fingerprint` + // label and/or are attached to the per-project `triple-c-net-` network. + // That user-defined network is deleted during cleanup, and a container + // whose NetworkMode points at a missing network refuses to start — so force + // a recreation to move them onto the default bridge. Containers created by + // the current code never carry the label or the network, so this is a no-op + // for them and can be dropped a release later. + if let Some(fp) = get_label("triple-c.mcp-fingerprint") { + if !fp.is_empty() { + log::info!("Legacy container carries triple-c.mcp-fingerprint label — recreating without MCP"); + return Ok(true); + } + } + let legacy_network = info + .host_config + .as_ref() + .and_then(|hc| hc.network_mode.as_deref()) + .map(|nm| nm.starts_with("triple-c-net-")) + .unwrap_or(false); + if legacy_network { + log::info!("Legacy container attached to a triple-c-net-* network — recreating without MCP"); return Ok(true); } @@ -1590,173 +1519,3 @@ pub async fn list_sibling_containers() -> Result, String> Ok(siblings) } - -// ── MCP Container Lifecycle ───────────────────────────────────────────── - -/// Returns true if any MCP server uses stdio transport with Docker. -pub fn any_stdio_docker_mcp(servers: &[McpServer]) -> bool { - servers.iter().any(|s| s.is_docker() && s.transport_type == McpTransportType::Stdio) -} - -/// Find an existing MCP container by its expected name. -pub async fn find_mcp_container(server: &McpServer) -> Result, String> { - let docker = get_docker()?; - let container_name = server.mcp_container_name(); - - let filters: HashMap> = HashMap::from([ - ("name".to_string(), vec![container_name.clone()]), - ]); - - let containers: Vec = docker - .list_containers(Some(ListContainersOptions { - all: true, - filters, - ..Default::default() - })) - .await - .map_err(|e| format!("Failed to list MCP containers: {}", e))?; - - let expected = format!("/{}", container_name); - for c in &containers { - if let Some(names) = &c.names { - if names.iter().any(|n| n == &expected) { - return Ok(c.id.clone()); - } - } - } - - Ok(None) -} - -/// Create a Docker container for an MCP server. -pub async fn create_mcp_container( - server: &McpServer, - network_name: &str, -) -> Result { - let docker = get_docker()?; - let container_name = server.mcp_container_name(); - - let image = server - .docker_image - .as_ref() - .ok_or_else(|| format!("MCP server '{}' has no docker_image", server.name))?; - - let mut env_vars: Vec = Vec::new(); - for (k, v) in &server.env { - env_vars.push(format!("{}={}", k, v)); - } - - // Build command + args as Cmd - let mut cmd: Vec = Vec::new(); - if let Some(ref command) = server.command { - cmd.push(command.clone()); - } - cmd.extend(server.args.iter().cloned()); - - let mut labels = HashMap::new(); - labels.insert("triple-c.managed".to_string(), "true".to_string()); - labels.insert("triple-c.mcp-server".to_string(), server.id.clone()); - - let host_config = HostConfig { - network_mode: Some(network_name.to_string()), - ..Default::default() - }; - - let config = Config { - image: Some(image.clone()), - env: if env_vars.is_empty() { None } else { Some(env_vars) }, - cmd: if cmd.is_empty() { None } else { Some(cmd) }, - labels: Some(labels), - host_config: Some(host_config), - ..Default::default() - }; - - let options = CreateContainerOptions { - name: container_name.clone(), - ..Default::default() - }; - - let response = docker - .create_container(Some(options), config) - .await - .map_err(|e| format!("Failed to create MCP container '{}': {}", container_name, e))?; - - log::info!( - "Created MCP container {} (image: {}) on network {}", - container_name, - image, - network_name - ); - Ok(response.id) -} - -/// Start all Docker-based MCP server containers. Finds or creates each one. -pub async fn start_mcp_containers( - servers: &[McpServer], - network_name: &str, -) -> Result<(), String> { - for server in servers { - if !server.is_docker() { - continue; - } - - let container_id = if let Some(existing_id) = find_mcp_container(server).await? { - log::debug!("Found existing MCP container for '{}'", server.name); - existing_id - } else { - create_mcp_container(server, network_name).await? - }; - - // Start the container (ignore already-started errors) - if let Err(e) = start_container(&container_id).await { - let err_str = e.to_string(); - if err_str.contains("already started") || err_str.contains("304") { - log::debug!("MCP container '{}' already running", server.name); - } else { - return Err(format!( - "Failed to start MCP container '{}': {}", - server.name, e - )); - } - } - - log::info!("MCP container '{}' started", server.name); - } - - Ok(()) -} - -/// Stop all Docker-based MCP server containers (best-effort). -pub async fn stop_mcp_containers(servers: &[McpServer]) -> Result<(), String> { - for server in servers { - if !server.is_docker() { - continue; - } - if let Ok(Some(container_id)) = find_mcp_container(server).await { - if let Err(e) = stop_container(&container_id).await { - log::warn!("Failed to stop MCP container '{}': {}", server.name, e); - } else { - log::info!("Stopped MCP container '{}'", server.name); - } - } - } - Ok(()) -} - -/// Stop and remove all Docker-based MCP server containers (best-effort). -pub async fn remove_mcp_containers(servers: &[McpServer]) -> Result<(), String> { - for server in servers { - if !server.is_docker() { - continue; - } - if let Ok(Some(container_id)) = find_mcp_container(server).await { - let _ = stop_container(&container_id).await; - if let Err(e) = remove_container(&container_id).await { - log::warn!("Failed to remove MCP container '{}': {}", server.name, e); - } else { - log::info!("Removed MCP container '{}'", server.name); - } - } - } - Ok(()) -} diff --git a/app/src-tauri/src/docker/legacy_cleanup.rs b/app/src-tauri/src/docker/legacy_cleanup.rs new file mode 100644 index 0000000..9a464d0 --- /dev/null +++ b/app/src-tauri/src/docker/legacy_cleanup.rs @@ -0,0 +1,137 @@ +//! One-release migration shim for the removed built-in MCP feature. +//! +//! Older releases created a per-project user-defined bridge network +//! (`triple-c-net-`) plus one container per Docker-backed MCP +//! server, and attached the project container to that network. Now that MCP +//! support is gone, those leftovers have to be torn down — a container whose +//! `NetworkMode` names a network that no longer exists refuses to start, so +//! the cleanup is paired with a forced container recreation (see +//! `container_needs_recreation`). +//! +//! Everything here is best-effort: failures are logged and never abort the +//! caller, and absent resources are a silent no-op. This module can be deleted +//! a release after all users have migrated. + +use bollard::container::{ListContainersOptions, RemoveContainerOptions}; +use bollard::network::InspectNetworkOptions; +use std::collections::HashMap; + +use super::client::get_docker; + +/// Network name used by the old MCP implementation for a project. +fn legacy_network_name(project_id: &str) -> String { + format!("triple-c-net-{}", project_id) +} + +/// Force-remove every leftover MCP server container. +/// +/// Matched by the `triple-c.mcp-server` label rather than by name, so +/// containers survive even if the MCP server definitions they came from are +/// already gone from storage. Best-effort: errors are logged and skipped. +pub async fn remove_legacy_mcp_containers(project_id: &str) { + let docker = match get_docker() { + Ok(d) => d, + Err(e) => { + log::debug!( + "Skipping legacy MCP container cleanup for project {}: {}", + project_id, + e + ); + return; + } + }; + + let filters: HashMap> = HashMap::from([( + "label".to_string(), + vec!["triple-c.mcp-server".to_string()], + )]); + + let containers = match docker + .list_containers(Some(ListContainersOptions { + all: true, + filters, + ..Default::default() + })) + .await + { + Ok(c) => c, + Err(e) => { + log::warn!("Failed to list legacy MCP containers: {}", e); + return; + } + }; + + for container in containers { + let Some(id) = container.id else { continue }; + match docker + .remove_container( + &id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await + { + Ok(_) => log::info!("Removed legacy MCP container {}", id), + Err(e) => log::warn!("Failed to remove legacy MCP container {}: {}", id, e), + } + } +} + +/// Remove the old per-project Docker network, disconnecting any remaining +/// members first (a network with attached endpoints cannot be deleted). +/// +/// Silent no-op when the network does not exist. Best-effort: errors are +/// logged and never propagated. +pub async fn remove_legacy_project_network(project_id: &str) { + let docker = match get_docker() { + Ok(d) => d, + Err(e) => { + log::debug!( + "Skipping legacy network cleanup for project {}: {}", + project_id, + e + ); + return; + } + }; + let network_name = legacy_network_name(project_id); + + // Inspect to discover connected containers; absence means nothing to do. + let info = match docker + .inspect_network(&network_name, None::>) + .await + { + Ok(info) => info, + Err(_) => { + log::debug!("Legacy network {} not present, nothing to do", network_name); + return; + } + }; + + if let Some(containers) = info.containers { + for container_id in containers.into_keys() { + let disconnect_opts = bollard::network::DisconnectNetworkOptions { + container: container_id.clone(), + force: true, + }; + if let Err(e) = docker + .disconnect_network(&network_name, disconnect_opts) + .await + { + log::warn!( + "Failed to disconnect container {} from legacy network {}: {}", + container_id, + network_name, + e + ); + } + } + } + + match docker.remove_network(&network_name).await { + Ok(_) => log::info!("Removed legacy Docker network {}", network_name), + Err(e) => log::warn!("Failed to remove legacy network {}: {}", network_name, e), + } +} diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index bf3e610..f20aaf6 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -2,7 +2,7 @@ pub mod client; pub mod container; pub mod image; pub mod exec; -pub mod network; +pub mod legacy_cleanup; pub mod stt; #[allow(unused_imports)] @@ -16,4 +16,4 @@ pub use image::*; #[allow(unused_imports)] pub use exec::*; #[allow(unused_imports)] -pub use network::*; +pub use legacy_cleanup::*; diff --git a/app/src-tauri/src/docker/network.rs b/app/src-tauri/src/docker/network.rs deleted file mode 100644 index 90789fa..0000000 --- a/app/src-tauri/src/docker/network.rs +++ /dev/null @@ -1,129 +0,0 @@ -use bollard::network::{CreateNetworkOptions, InspectNetworkOptions}; -use std::collections::HashMap; - -use super::client::get_docker; - -/// Network name for a project's MCP containers. -fn project_network_name(project_id: &str) -> String { - format!("triple-c-net-{}", project_id) -} - -/// Ensure a Docker bridge network exists for the project. -/// Returns the network name. -pub async fn ensure_project_network(project_id: &str) -> Result { - let docker = get_docker()?; - let network_name = project_network_name(project_id); - - // Check if network already exists - match docker - .inspect_network(&network_name, None::>) - .await - { - Ok(_) => { - log::debug!("Network {} already exists", network_name); - return Ok(network_name); - } - Err(_) => { - // Network doesn't exist, create it - } - } - - let options = CreateNetworkOptions { - name: network_name.clone(), - driver: "bridge".to_string(), - labels: HashMap::from([ - ("triple-c.managed".to_string(), "true".to_string()), - ("triple-c.project-id".to_string(), project_id.to_string()), - ]), - ..Default::default() - }; - - docker - .create_network(options) - .await - .map_err(|e| format!("Failed to create network {}: {}", network_name, e))?; - - log::info!("Created Docker network {}", network_name); - Ok(network_name) -} - -/// Connect a container to the project network. -#[allow(dead_code)] -pub async fn connect_container_to_network( - container_id: &str, - network_name: &str, -) -> Result<(), String> { - let docker = get_docker()?; - - let config = bollard::network::ConnectNetworkOptions { - container: container_id.to_string(), - ..Default::default() - }; - - docker - .connect_network(network_name, config) - .await - .map_err(|e| { - format!( - "Failed to connect container {} to network {}: {}", - container_id, network_name, e - ) - })?; - - log::debug!( - "Connected container {} to network {}", - container_id, - network_name - ); - Ok(()) -} - -/// Remove the project network (best-effort). Disconnects all containers first. -pub async fn remove_project_network(project_id: &str) -> Result<(), String> { - let docker = get_docker()?; - let network_name = project_network_name(project_id); - - // Inspect to get connected containers - let info = match docker - .inspect_network(&network_name, None::>) - .await - { - Ok(info) => info, - Err(_) => { - log::debug!( - "Network {} not found, nothing to remove", - network_name - ); - return Ok(()); - } - }; - - // Disconnect all containers - if let Some(containers) = info.containers { - for (container_id, _) in containers { - let disconnect_opts = bollard::network::DisconnectNetworkOptions { - container: container_id.clone(), - force: true, - }; - if let Err(e) = docker - .disconnect_network(&network_name, disconnect_opts) - .await - { - log::warn!( - "Failed to disconnect container {} from network {}: {}", - container_id, - network_name, - e - ); - } - } - } - - // Remove the network - match docker.remove_network(&network_name).await { - Ok(_) => log::info!("Removed Docker network {}", network_name), - Err(e) => log::warn!("Failed to remove network {}: {}", network_name, e), - } - - Ok(()) -} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 527e9b5..c4ef25f 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -11,14 +11,12 @@ use std::sync::Arc; use docker::exec::ExecSessionManager; use storage::projects_store::ProjectsStore; use storage::settings_store::SettingsStore; -use storage::mcp_store::McpStore; use tauri::Manager; use web_terminal::WebTerminalServer; pub struct AppState { pub projects_store: Arc, pub settings_store: Arc, - pub mcp_store: Arc, pub exec_manager: Arc, pub web_terminal_server: Arc>>, } @@ -40,13 +38,6 @@ pub fn run() { panic!("Failed to initialize settings store: {}", e); } }); - let mcp_store = Arc::new(match McpStore::new() { - Ok(s) => s, - Err(e) => { - log::error!("Failed to initialize MCP store: {}", e); - panic!("Failed to initialize MCP store: {}", e); - } - }); let exec_manager = Arc::new(ExecSessionManager::new()); // Clone Arcs for the setup closure (web terminal auto-start) @@ -61,7 +52,6 @@ pub fn run() { .manage(AppState { projects_store, settings_store, - mcp_store, exec_manager, web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), }) @@ -187,11 +177,6 @@ pub fn run() { commands::file_commands::download_container_file, commands::file_commands::download_container_backup, commands::file_commands::upload_file_to_container, - // MCP - commands::mcp_commands::list_mcp_servers, - commands::mcp_commands::add_mcp_server, - commands::mcp_commands::update_mcp_server, - commands::mcp_commands::remove_mcp_server, // AWS commands::aws_commands::aws_sso_refresh, // Updates diff --git a/app/src-tauri/src/models/mcp_server.rs b/app/src-tauri/src/models/mcp_server.rs deleted file mode 100644 index 1fad1d8..0000000 --- a/app/src-tauri/src/models/mcp_server.rs +++ /dev/null @@ -1,70 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum McpTransportType { - Stdio, - #[serde(alias = "sse")] - Http, -} - -impl Default for McpTransportType { - fn default() -> Self { - Self::Stdio - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpServer { - pub id: String, - pub name: String, - #[serde(default)] - pub transport_type: McpTransportType, - pub command: Option, - #[serde(default)] - pub args: Vec, - #[serde(default)] - pub env: HashMap, - pub url: Option, - #[serde(default)] - pub headers: HashMap, - #[serde(default)] - pub docker_image: Option, - #[serde(default)] - pub container_port: Option, - pub created_at: String, - pub updated_at: String, -} - -impl McpServer { - pub fn new(name: String) -> Self { - let now = chrono::Utc::now().to_rfc3339(); - Self { - id: uuid::Uuid::new_v4().to_string(), - name, - transport_type: McpTransportType::default(), - command: None, - args: Vec::new(), - env: HashMap::new(), - url: None, - headers: HashMap::new(), - docker_image: None, - container_port: None, - created_at: now.clone(), - updated_at: now, - } - } - - pub fn is_docker(&self) -> bool { - self.docker_image.is_some() - } - - pub fn mcp_container_name(&self) -> String { - format!("triple-c-mcp-{}", self.id) - } - - pub fn effective_container_port(&self) -> u16 { - self.container_port.unwrap_or(3000) - } -} diff --git a/app/src-tauri/src/models/mod.rs b/app/src-tauri/src/models/mod.rs index 66cae6f..5abbf24 100644 --- a/app/src-tauri/src/models/mod.rs +++ b/app/src-tauri/src/models/mod.rs @@ -2,10 +2,8 @@ pub mod project; pub mod container_config; pub mod app_settings; pub mod update_info; -pub mod mcp_server; pub use project::*; pub use container_config::*; pub use app_settings::*; pub use update_info::*; -pub use mcp_server::*; diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index fc0937f..209cb14 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -92,8 +92,6 @@ pub struct Project { #[serde(default)] pub claude_instructions: Option, #[serde(default)] - pub enabled_mcp_servers: Vec, - #[serde(default)] pub claude_code_settings: Option, /// User-defined display names for terminal tabs, keyed by session id. #[serde(default)] @@ -220,7 +218,6 @@ impl Project { custom_env_vars: Vec::new(), port_mappings: Vec::new(), claude_instructions: None, - enabled_mcp_servers: Vec::new(), claude_code_settings: None, renamed_session_names: HashMap::new(), created_at: now.clone(), diff --git a/app/src-tauri/src/storage/mcp_store.rs b/app/src-tauri/src/storage/mcp_store.rs deleted file mode 100644 index b28c99b..0000000 --- a/app/src-tauri/src/storage/mcp_store.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::fs; -use std::path::PathBuf; -use std::sync::Mutex; - -use crate::models::McpServer; - -pub struct McpStore { - servers: Mutex>, - file_path: PathBuf, -} - -impl McpStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())? - .join("triple-c"); - - fs::create_dir_all(&data_dir).ok(); - - let file_path = data_dir.join("mcp_servers.json"); - - let servers = if file_path.exists() { - match fs::read_to_string(&file_path) { - Ok(data) => { - match serde_json::from_str::>(&data) { - Ok(parsed) => parsed, - Err(e) => { - log::error!("Failed to parse mcp_servers.json: {}. Starting with empty list.", e); - let backup = file_path.with_extension("json.bak"); - if let Err(be) = fs::copy(&file_path, &backup) { - log::error!("Failed to back up corrupted mcp_servers.json: {}", be); - } - Vec::new() - } - } - } - Err(e) => { - log::error!("Failed to read mcp_servers.json: {}", e); - Vec::new() - } - } - } else { - Vec::new() - }; - - Ok(Self { - servers: Mutex::new(servers), - file_path, - }) - } - - fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { - self.servers.lock().unwrap_or_else(|e| e.into_inner()) - } - - fn save(&self, servers: &[McpServer]) -> Result<(), String> { - let data = serde_json::to_string_pretty(servers) - .map_err(|e| format!("Failed to serialize MCP servers: {}", e))?; - - // Atomic write: write to temp file, then rename - let tmp_path = self.file_path.with_extension("json.tmp"); - fs::write(&tmp_path, data) - .map_err(|e| format!("Failed to write temp MCP servers file: {}", e))?; - fs::rename(&tmp_path, &self.file_path) - .map_err(|e| format!("Failed to rename MCP servers file: {}", e))?; - Ok(()) - } - - pub fn list(&self) -> Vec { - self.lock().clone() - } - - pub fn get(&self, id: &str) -> Option { - self.lock().iter().find(|s| s.id == id).cloned() - } - - pub fn add(&self, server: McpServer) -> Result { - let mut servers = self.lock(); - let cloned = server.clone(); - servers.push(server); - self.save(&servers)?; - Ok(cloned) - } - - pub fn update(&self, updated: McpServer) -> Result { - let mut servers = self.lock(); - if let Some(s) = servers.iter_mut().find(|s| s.id == updated.id) { - *s = updated.clone(); - self.save(&servers)?; - Ok(updated) - } else { - Err(format!("MCP server {} not found", updated.id)) - } - } - - pub fn remove(&self, id: &str) -> Result<(), String> { - let mut servers = self.lock(); - let initial_len = servers.len(); - servers.retain(|s| s.id != id); - if servers.len() == initial_len { - return Err(format!("MCP server {} not found", id)); - } - self.save(&servers)?; - Ok(()) - } -} diff --git a/app/src-tauri/src/storage/mod.rs b/app/src-tauri/src/storage/mod.rs index 6183392..ca3a674 100644 --- a/app/src-tauri/src/storage/mod.rs +++ b/app/src-tauri/src/storage/mod.rs @@ -1,7 +1,6 @@ pub mod projects_store; pub mod secure; pub mod settings_store; -pub mod mcp_store; #[allow(unused_imports)] pub use projects_store::*; @@ -9,5 +8,3 @@ pub use projects_store::*; pub use secure::*; #[allow(unused_imports)] pub use settings_store::*; -#[allow(unused_imports)] -pub use mcp_store::*; diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 11a4056..a961beb 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -167,27 +167,6 @@ if [ "$MISSION_CONTROL_ENABLED" = "1" ]; then unset MISSION_CONTROL_ENABLED fi -# ── MCP server configuration ──────────────────────────────────────────────── -# Merge MCP server config into ~/.claude.json (preserves existing keys like -# OAuth tokens). Creates the file if it doesn't exist. -if [ -n "$MCP_SERVERS_JSON" ]; then - CLAUDE_JSON="/home/claude/.claude.json" - if [ -f "$CLAUDE_JSON" ]; then - # Merge: existing config + MCP config (MCP keys override on conflict) - MERGED=$(jq -s '.[0] * .[1]' "$CLAUDE_JSON" <(printf '%s' "$MCP_SERVERS_JSON") 2>/dev/null) - if [ -n "$MERGED" ]; then - printf '%s\n' "$MERGED" > "$CLAUDE_JSON" - else - echo "entrypoint: warning — failed to merge MCP config into $CLAUDE_JSON" - fi - else - printf '%s\n' "$MCP_SERVERS_JSON" > "$CLAUDE_JSON" - fi - chown claude:claude "$CLAUDE_JSON" - chmod 600 "$CLAUDE_JSON" - unset MCP_SERVERS_JSON -fi - # ── Claude Code settings ──────────────────────────────────────────────────── # Merge Claude Code settings into ~/.claude/settings.json (preserves existing # keys). Creates the file if it doesn't exist. These control TUI mode, effort