Remove MCP backend, entrypoint injection, and docs; add migration shim

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-<projectId> 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 10:31:18 -07:00
co-authored by Claude Opus 5
parent 657c61939f
commit d0bb631d4d
18 changed files with 204 additions and 918 deletions
+11 -129
View File
@@ -11,7 +11,6 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
- [The Interface](#the-interface) - [The Interface](#the-interface)
- [Project Management](#project-management) - [Project Management](#project-management)
- [Project Configuration](#project-configuration) - [Project Configuration](#project-configuration)
- [MCP Servers (Beta)](#mcp-servers-beta)
- [AWS Bedrock Configuration](#aws-bedrock-configuration) - [AWS Bedrock Configuration](#aws-bedrock-configuration)
- [Ollama Configuration](#ollama-configuration) - [Ollama Configuration](#ollama-configuration)
- [OpenAI Compatible Configuration](#openai-compatible-configuration) - [OpenAI Compatible Configuration](#openai-compatible-configuration)
@@ -136,7 +135,6 @@ Claude Code launches automatically. By default, it runs in standard permission m
│ Sidebar │ │ │ Sidebar │ │
│ │ Terminal View │ │ │ Terminal View │
│ Projects │ (xterm.js) │ │ Projects │ (xterm.js) │
│ MCP │ │
│ Settings │ │ │ Settings │ │
├────────────┴────────────────────────────────────────┤ ├────────────┴────────────────────────────────────────┤
│ StatusBar X projects · X running · X terminals │ │ 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). - **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. - **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. - **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 ### 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. 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 ### 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. > 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 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.
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.
--- ---
@@ -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. - 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. - 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 ### "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: 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:
+6 -34
View File
@@ -27,9 +27,9 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
### Container Lifecycle ### Container Lifecycle
1. **Create**: New container created with bind mounts, env vars, and labels 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 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) 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) 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 | | `/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` | | `/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 | | `/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 ### 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. 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 <mcp-container> <command>` from the project container |
| HTTP + Manual | External / user-provided | Connects to the URL you specify |
| HTTP + Docker | Separate MCP container | `http://<mcp-container>:<port>/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 ### 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. 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/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) | | `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/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/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/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) | | `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/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/useTerminal.ts` | Terminal session management (claude and bash modes) |
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) | | `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/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/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/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/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/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, Mission Control) |
| `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/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) | | `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/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/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/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/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/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-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 | | `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/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) | | `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/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/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode | | `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
+3 -9
View File
@@ -236,7 +236,7 @@ triple-c/
├── container/ ├── container/
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code │ ├── 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) │ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52)
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO) │ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
│ ├── triple-c-scheduler # Bash-based cron task system │ ├── triple-c-scheduler # Bash-based cron task system
@@ -259,11 +259,10 @@ triple-c/
│ ├── App.tsx # Top-level layout │ ├── App.tsx # Top-level layout
│ ├── index.css # CSS variables, dark theme, scrollbars │ ├── index.css # CSS variables, dark theme, scrollbars
│ ├── store/ │ ├── store/
│ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI) │ │ └── appState.ts # Zustand store (projects, sessions, UI)
│ ├── hooks/ │ ├── hooks/
│ │ ├── useDocker.ts # Docker status, image build/pull │ │ ├── useDocker.ts # Docker status, image build/pull
│ │ ├── useFileManager.ts # File manager operations │ │ ├── useFileManager.ts # File manager operations
│ │ ├── useMcpServers.ts # MCP server CRUD
│ │ ├── useProjects.ts # Project CRUD operations │ │ ├── useProjects.ts # Project CRUD operations
│ │ ├── useSettings.ts # App settings │ │ ├── useSettings.ts # App settings
│ │ ├── useTerminal.ts # Terminal I/O, resize, session events │ │ ├── useTerminal.ts # Terminal I/O, resize, session events
@@ -275,7 +274,6 @@ triple-c/
│ │ └── constants.ts # App-wide constants │ │ └── constants.ts # App-wide constants
│ └── components/ │ └── components/
│ ├── layout/ # Sidebar, TopBar, StatusBar │ ├── layout/ # Sidebar, TopBar, StatusBar
│ ├── mcp/ # McpPanel, McpServerCard
│ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog, │ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
│ │ # FileManagerModal, ContainerProgressModal, modals │ │ # FileManagerModal, ContainerProgressModal, modals
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings, │ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
@@ -294,7 +292,6 @@ triple-c/
├── commands/ # Tauri command handlers ├── commands/ # Tauri command handlers
│ ├── docker_commands.rs # Docker status, image ops │ ├── docker_commands.rs # Docker status, image ops
│ ├── file_commands.rs # File manager (list/download/upload) │ ├── file_commands.rs # File manager (list/download/upload)
│ ├── mcp_commands.rs # MCP server CRUD
│ ├── project_commands.rs # Start/stop/rebuild containers │ ├── project_commands.rs # Start/stop/rebuild containers
│ ├── settings_commands.rs # Settings CRUD │ ├── settings_commands.rs # Settings CRUD
│ ├── terminal_commands.rs # Terminal I/O, resize │ ├── terminal_commands.rs # Terminal I/O, resize
@@ -309,17 +306,14 @@ triple-c/
│ ├── client.rs # bollard singleton connection │ ├── client.rs # bollard singleton connection
│ ├── container.rs # Create, start, stop, remove, fingerprinting │ ├── container.rs # Create, start, stop, remove, fingerprinting
│ ├── exec.rs # PTY exec sessions with bidirectional streaming │ ├── exec.rs # PTY exec sessions with bidirectional streaming
── image.rs # Build from Dockerfile, pull from registry ── image.rs # Build from Dockerfile, pull from registry
│ └── network.rs # Per-project bridge networks for MCP
├── models/ # Data structures ├── models/ # Data structures
│ ├── project.rs # Project, Backend, BedrockConfig │ ├── project.rs # Project, Backend, BedrockConfig
│ ├── mcp_server.rs # MCP server configuration
│ ├── app_settings.rs # Global settings (image source, AWS, etc.) │ ├── app_settings.rs # Global settings (image source, AWS, etc.)
│ ├── container_config.rs # Image name resolution │ ├── container_config.rs # Image name resolution
│ └── update_info.rs # Update metadata │ └── update_info.rs # Update metadata
└── storage/ # Persistence └── storage/ # Persistence
├── projects_store.rs # JSON file with atomic writes ├── projects_store.rs # JSON file with atomic writes
├── mcp_store.rs # MCP server persistence
├── settings_store.rs # App settings (Tauri plugin-store) ├── settings_store.rs # App settings (Tauri plugin-store)
└── secure.rs # OS keychain via keyring └── secure.rs # OS keychain via keyring
``` ```
+4 -3
View File
@@ -157,9 +157,10 @@ pub async fn download_container_file(
/// - the workspace (default /workspace), minus regenerable build artifacts /// - the workspace (default /workspace), minus regenerable build artifacts
/// (node_modules, target), under `workspace/`, and /// (node_modules, target), under `workspace/`, and
/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json /// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json
/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/ /// with secret-bearing keys removed (`mcpServers` — Claude Code's own native
/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills /// MCP config — and `settings` are kept) and ~/.claude/ minus the OAuth
/// set up via Claude Code survive a Reset. /// `.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, /// `.git` is kept in full so the backup faithfully preserves git history,
/// including unpushed commits. Build + gzip happen inside the container so a /// including unpushed commits. Build + gzip happen inside the container so a
/// large workspace isn't streamed in full. The container must be RUNNING (the /// large workspace isn't streamed in full. The container must be RUNNING (the
@@ -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<Vec<McpServer>, String> {
Ok(state.mcp_store.list())
}
#[tauri::command]
pub async fn add_mcp_server(
name: String,
state: State<'_, AppState>,
) -> Result<McpServer, String> {
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<McpServer, String> {
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)
}
-1
View File
@@ -3,7 +3,6 @@ pub mod docker_commands;
pub mod file_commands; pub mod file_commands;
pub mod help_commands; pub mod help_commands;
pub mod install_helper_commands; pub mod install_helper_commands;
pub mod mcp_commands;
pub mod project_commands; pub mod project_commands;
pub mod settings_commands; pub mod settings_commands;
pub mod stt_commands; pub mod stt_commands;
+11 -82
View File
@@ -2,7 +2,7 @@ use tauri::{Emitter, State};
use crate::commands::aws_commands; use crate::commands::aws_commands;
use crate::docker; 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::storage::secure;
use crate::AppState; 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<McpServer>, Vec<McpServer>) {
let all_mcp_servers = state.mcp_store.list();
let enabled_mcp: Vec<McpServer> = project.enabled_mcp_servers.iter()
.filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned())
.collect();
let docker_mcp: Vec<McpServer> = enabled_mcp.iter()
.filter(|s| s.is_docker())
.cloned()
.collect();
(enabled_mcp, docker_mcp)
}
#[tauri::command] #[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> { pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
Ok(state.projects_store.list()) Ok(state.projects_store.list())
@@ -121,16 +108,10 @@ pub async fn remove_project(
let _ = docker::remove_container(container_id).await; let _ = docker::remove_container(container_id).await;
} }
// Remove MCP containers and network // Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(project, &state); // containers first, then the per-project network they were attached to.
if !docker_mcp.is_empty() { docker::remove_legacy_mcp_containers(&project.id).await;
if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await { docker::remove_legacy_project_network(&project.id).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);
}
// Clean up the snapshot image + volumes // Clean up the snapshot image + volumes
if let Err(e) = docker::remove_snapshot_image(project).await { 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 settings = state.settings_store.get();
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name); 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 // Validate backend requirements
if project.backend == Backend::Bedrock { if project.backend == Backend::Bedrock {
let bedrock = project.bedrock_config.as_ref() let bedrock = project.bedrock_config.as_ref()
@@ -300,39 +278,6 @@ pub async fn start_project_container(
// AWS config path from global settings // AWS config path from global settings
let aws_config_path = settings.global_aws.aws_config_path.clone(); 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? { let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
// Check if config changed — if so, snapshot + recreate // Check if config changed — if so, snapshot + recreate
let needs_recreate = docker::container_needs_recreation( 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_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
&enabled_mcp,
settings.global_claude_code_settings.as_ref(), settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(), settings.default_ssh_key_path.as_deref(),
settings.default_git_user_name.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; let _ = docker::stop_container(&existing_id).await;
docker::remove_container(&existing_id).await?; docker::remove_container(&existing_id).await?;
// Legacy MCP cleanup: the old container may have been attached to
// `triple-c-net-<projectId>`. 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) // Create from snapshot image (preserves system-level changes)
let snapshot_image = docker::get_snapshot_image_name(&project); let snapshot_image = docker::get_snapshot_image_name(&project);
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { 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_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
&enabled_mcp,
network_name.as_deref(),
settings.global_claude_code_settings.as_ref(), settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(), settings.default_ssh_key_path.as_deref(),
settings.default_git_user_name.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_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
&enabled_mcp,
network_name.as_deref(),
settings.global_claude_code_settings.as_ref(), settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(), settings.default_ssh_key_path.as_deref(),
settings.default_git_user_name.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)?; state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
Ok(()) Ok(())
} }
@@ -514,14 +451,6 @@ pub async fn rebuild_project_container(
state.projects_store.set_container_id(&project_id, None)?; 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 // Remove snapshot image + volumes so Reset creates from the clean base image
if let Err(e) = docker::remove_snapshot_image(&project).await { if let Err(e) = docker::remove_snapshot_image(&project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
+30 -271
View File
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use sha2::{Sha256, Digest}; use sha2::{Sha256, Digest};
use super::client::get_docker; 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 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. /// Sorted alphabetically so order changes do not cause spurious recreation.
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; 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 reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"];
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
for env_var in custom_env_vars { 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 <mcp-container-name> <command> ...args`
/// - Stdio+Manual: `<command> ...args` (existing behavior)
/// - HTTP+Docker: `streamableHttp` URL pointing to `http://<mcp-container-name>:<port>/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<Option<String>, String> { pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
let docker = get_docker()?; let docker = get_docker()?;
let container_name = project.container_name(); let container_name = project.container_name();
@@ -594,8 +519,6 @@ pub async fn create_container(
global_claude_instructions: Option<&str>, global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar], global_custom_env_vars: &[EnvVar],
timezone: Option<&str>, timezone: Option<&str>,
mcp_servers: &[McpServer],
network_name: Option<&str>,
global_claude_code_settings: Option<&ClaudeCodeSettings>, global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>, default_ssh_key_path: Option<&str>,
default_git_user_name: 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) // 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 merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; 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 reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"];
for env_var in &merged_env { for env_var in &merged_env {
let key = env_var.key.trim(); let key = env_var.key.trim();
@@ -838,12 +763,6 @@ pub async fn create_container(
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); 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) // Claude Code settings (global + per-project merged)
let merged_cc_settings = merge_claude_code_settings( let merged_cc_settings = merge_claude_code_settings(
global_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) // Docker socket (if allowed)
let needs_docker_for_mcp = any_stdio_docker_mcp(mcp_servers); if project.allow_docker_access {
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");
}
// On Windows, the named pipe (//./pipe/docker_engine) cannot be // On Windows, the named pipe (//./pipe/docker_engine) cannot be
// bind-mounted into a Linux container. Docker Desktop exposes the // bind-mounted into a Linux container. Docker Desktop exposes the
// daemon socket as /var/run/docker.sock for container mounts. // 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.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.image".to_string(), image_name.to_string());
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").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.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.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(), labels.insert("triple-c.claude-code-settings-fingerprint".to_string(),
@@ -1030,8 +944,6 @@ pub async fn create_container(
mounts: Some(mounts), mounts: Some(mounts),
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) }, port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
init: Some(true), init: Some(true),
// Connect to project network if specified (for MCP container communication)
network_mode: network_name.map(|n| n.to_string()),
..Default::default() ..Default::default()
}; };
@@ -1307,7 +1219,6 @@ pub async fn container_needs_recreation(
global_claude_instructions: Option<&str>, global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar], global_custom_env_vars: &[EnvVar],
timezone: Option<&str>, timezone: Option<&str>,
mcp_servers: &[McpServer],
global_claude_code_settings: Option<&ClaudeCodeSettings>, global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>, default_ssh_key_path: Option<&str>,
default_git_user_name: Option<&str>, default_git_user_name: Option<&str>,
@@ -1514,11 +1425,29 @@ pub async fn container_needs_recreation(
return Ok(true); return Ok(true);
} }
// ── MCP servers fingerprint ───────────────────────────────────────── // ── Legacy MCP migration shim ───────────────────────────────────────
let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers); // One-release migration for containers created before the built-in MCP
let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default(); // feature was removed. Such containers carry a `triple-c.mcp-fingerprint`
if container_mcp_fp != expected_mcp_fp { // label and/or are attached to the per-project `triple-c-net-<id>` network.
log::info!("MCP servers fingerprint mismatch (container={:?}, expected={:?})", container_mcp_fp, expected_mcp_fp); // 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); return Ok(true);
} }
@@ -1590,173 +1519,3 @@ pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String>
Ok(siblings) 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<Option<String>, String> {
let docker = get_docker()?;
let container_name = server.mcp_container_name();
let filters: HashMap<String, Vec<String>> = HashMap::from([
("name".to_string(), vec![container_name.clone()]),
]);
let containers: Vec<ContainerSummary> = 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<String, String> {
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<String> = Vec::new();
for (k, v) in &server.env {
env_vars.push(format!("{}={}", k, v));
}
// Build command + args as Cmd
let mut cmd: Vec<String> = 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(())
}
+137
View File
@@ -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-<projectId>`) 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<String, Vec<String>> = 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::<InspectNetworkOptions<String>>)
.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),
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ pub mod client;
pub mod container; pub mod container;
pub mod image; pub mod image;
pub mod exec; pub mod exec;
pub mod network; pub mod legacy_cleanup;
pub mod stt; pub mod stt;
#[allow(unused_imports)] #[allow(unused_imports)]
@@ -16,4 +16,4 @@ pub use image::*;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use exec::*; pub use exec::*;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use network::*; pub use legacy_cleanup::*;
-129
View File
@@ -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<String, String> {
let docker = get_docker()?;
let network_name = project_network_name(project_id);
// Check if network already exists
match docker
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
.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::<InspectNetworkOptions<String>>)
.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(())
}
-15
View File
@@ -11,14 +11,12 @@ use std::sync::Arc;
use docker::exec::ExecSessionManager; use docker::exec::ExecSessionManager;
use storage::projects_store::ProjectsStore; use storage::projects_store::ProjectsStore;
use storage::settings_store::SettingsStore; use storage::settings_store::SettingsStore;
use storage::mcp_store::McpStore;
use tauri::Manager; use tauri::Manager;
use web_terminal::WebTerminalServer; use web_terminal::WebTerminalServer;
pub struct AppState { pub struct AppState {
pub projects_store: Arc<ProjectsStore>, pub projects_store: Arc<ProjectsStore>,
pub settings_store: Arc<SettingsStore>, pub settings_store: Arc<SettingsStore>,
pub mcp_store: Arc<McpStore>,
pub exec_manager: Arc<ExecSessionManager>, pub exec_manager: Arc<ExecSessionManager>,
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>, pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
} }
@@ -40,13 +38,6 @@ pub fn run() {
panic!("Failed to initialize settings store: {}", e); 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()); let exec_manager = Arc::new(ExecSessionManager::new());
// Clone Arcs for the setup closure (web terminal auto-start) // Clone Arcs for the setup closure (web terminal auto-start)
@@ -61,7 +52,6 @@ pub fn run() {
.manage(AppState { .manage(AppState {
projects_store, projects_store,
settings_store, settings_store,
mcp_store,
exec_manager, exec_manager,
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), 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_file,
commands::file_commands::download_container_backup, commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container, 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 // AWS
commands::aws_commands::aws_sso_refresh, commands::aws_commands::aws_sso_refresh,
// Updates // Updates
-70
View File
@@ -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<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
pub url: Option<String>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub docker_image: Option<String>,
#[serde(default)]
pub container_port: Option<u16>,
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)
}
}
-2
View File
@@ -2,10 +2,8 @@ pub mod project;
pub mod container_config; pub mod container_config;
pub mod app_settings; pub mod app_settings;
pub mod update_info; pub mod update_info;
pub mod mcp_server;
pub use project::*; pub use project::*;
pub use container_config::*; pub use container_config::*;
pub use app_settings::*; pub use app_settings::*;
pub use update_info::*; pub use update_info::*;
pub use mcp_server::*;
-3
View File
@@ -92,8 +92,6 @@ pub struct Project {
#[serde(default)] #[serde(default)]
pub claude_instructions: Option<String>, pub claude_instructions: Option<String>,
#[serde(default)] #[serde(default)]
pub enabled_mcp_servers: Vec<String>,
#[serde(default)]
pub claude_code_settings: Option<ClaudeCodeSettings>, pub claude_code_settings: Option<ClaudeCodeSettings>,
/// User-defined display names for terminal tabs, keyed by session id. /// User-defined display names for terminal tabs, keyed by session id.
#[serde(default)] #[serde(default)]
@@ -220,7 +218,6 @@ impl Project {
custom_env_vars: Vec::new(), custom_env_vars: Vec::new(),
port_mappings: Vec::new(), port_mappings: Vec::new(),
claude_instructions: None, claude_instructions: None,
enabled_mcp_servers: Vec::new(),
claude_code_settings: None, claude_code_settings: None,
renamed_session_names: HashMap::new(), renamed_session_names: HashMap::new(),
created_at: now.clone(), created_at: now.clone(),
-106
View File
@@ -1,106 +0,0 @@
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use crate::models::McpServer;
pub struct McpStore {
servers: Mutex<Vec<McpServer>>,
file_path: PathBuf,
}
impl McpStore {
pub fn new() -> Result<Self, String> {
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::<Vec<McpServer>>(&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<McpServer>> {
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<McpServer> {
self.lock().clone()
}
pub fn get(&self, id: &str) -> Option<McpServer> {
self.lock().iter().find(|s| s.id == id).cloned()
}
pub fn add(&self, server: McpServer) -> Result<McpServer, String> {
let mut servers = self.lock();
let cloned = server.clone();
servers.push(server);
self.save(&servers)?;
Ok(cloned)
}
pub fn update(&self, updated: McpServer) -> Result<McpServer, String> {
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(())
}
}
-3
View File
@@ -1,7 +1,6 @@
pub mod projects_store; pub mod projects_store;
pub mod secure; pub mod secure;
pub mod settings_store; pub mod settings_store;
pub mod mcp_store;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use projects_store::*; pub use projects_store::*;
@@ -9,5 +8,3 @@ pub use projects_store::*;
pub use secure::*; pub use secure::*;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use settings_store::*; pub use settings_store::*;
#[allow(unused_imports)]
pub use mcp_store::*;
-21
View File
@@ -167,27 +167,6 @@ if [ "$MISSION_CONTROL_ENABLED" = "1" ]; then
unset MISSION_CONTROL_ENABLED unset MISSION_CONTROL_ENABLED
fi 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 ──────────────────────────────────────────────────── # ── Claude Code settings ────────────────────────────────────────────────────
# Merge Claude Code settings into ~/.claude/settings.json (preserves existing # Merge Claude Code settings into ~/.claude/settings.json (preserves existing
# keys). Creates the file if it doesn't exist. These control TUI mode, effort # keys). Creates the file if it doesn't exist. These control TUI mode, effort