Drop MCP management; add permission modes, Project Home, Auth Bridge and shared auth #13
+11
-129
@@ -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:
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
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 |
|
||||
|
||||
|
||||
+3
-9
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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]
|
||||
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, 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-<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)
|
||||
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);
|
||||
|
||||
@@ -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<String> = 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 <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> {
|
||||
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-<id>` 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<Vec<ContainerSummary>, 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<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(())
|
||||
}
|
||||
|
||||
@@ -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,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::*;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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<ProjectsStore>,
|
||||
pub settings_store: Arc<SettingsStore>,
|
||||
pub mcp_store: Arc<McpStore>,
|
||||
pub exec_manager: Arc<ExecSessionManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,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::*;
|
||||
|
||||
@@ -92,8 +92,6 @@ pub struct Project {
|
||||
#[serde(default)]
|
||||
pub claude_instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled_mcp_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
/// 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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user