Compare commits
1 Commits
v0.1.76
...
v0.1.77-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| 625d48a6ed |
38
app/src-tauri/src/commands/mcp_commands.rs
Normal file
38
app/src-tauri/src/commands/mcp_commands.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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,4 +1,5 @@
|
|||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
|
pub mod mcp_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use tauri::{Emitter, State};
|
use tauri::{Emitter, State};
|
||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus};
|
use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus};
|
||||||
use crate::storage::secure;
|
use crate::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -142,6 +142,12 @@ 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 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();
|
||||||
|
|
||||||
// Validate auth mode requirements
|
// Validate auth mode requirements
|
||||||
if project.auth_mode == AuthMode::Bedrock {
|
if project.auth_mode == AuthMode::Bedrock {
|
||||||
let bedrock = project.bedrock_config.as_ref()
|
let bedrock = project.bedrock_config.as_ref()
|
||||||
@@ -180,6 +186,7 @@ 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,
|
||||||
).await.unwrap_or(false);
|
).await.unwrap_or(false);
|
||||||
|
|
||||||
if needs_recreate {
|
if needs_recreate {
|
||||||
@@ -210,6 +217,7 @@ 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,
|
||||||
).await?;
|
).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
@@ -241,6 +249,7 @@ 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,
|
||||||
).await?;
|
).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::collections::hash_map::DefaultHasher;
|
|||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath};
|
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||||
|
|
||||||
@@ -176,6 +176,61 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
|
|||||||
format!("{:x}", hasher.finish())
|
format!("{:x}", hasher.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
|
||||||
|
/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`.
|
||||||
|
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 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!("http"));
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
McpTransportType::Sse => {
|
||||||
|
entry.insert("type".to_string(), serde_json::json!("sse"));
|
||||||
|
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);
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
json.hash(&mut hasher);
|
||||||
|
format!("{:x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
@@ -215,6 +270,7 @@ 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],
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
let container_name = project.container_name();
|
||||||
@@ -355,6 +411,12 @@ 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));
|
||||||
|
}
|
||||||
|
|
||||||
let mut mounts: Vec<Mount> = Vec::new();
|
let mut mounts: Vec<Mount> = Vec::new();
|
||||||
|
|
||||||
// Project directories -> /workspace/{mount_name}
|
// Project directories -> /workspace/{mount_name}
|
||||||
@@ -474,6 +536,7 @@ 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));
|
||||||
|
|
||||||
let host_config = HostConfig {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
@@ -637,6 +700,7 @@ 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],
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let info = docker
|
let info = docker
|
||||||
@@ -801,6 +865,14 @@ pub async fn container_needs_recreation(
|
|||||||
return Ok(true);
|
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);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ mod storage;
|
|||||||
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;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub projects_store: ProjectsStore,
|
pub projects_store: ProjectsStore,
|
||||||
pub settings_store: SettingsStore,
|
pub settings_store: SettingsStore,
|
||||||
|
pub mcp_store: McpStore,
|
||||||
pub exec_manager: ExecSessionManager,
|
pub exec_manager: ExecSessionManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +34,13 @@ pub fn run() {
|
|||||||
panic!("Failed to initialize settings store: {}", e);
|
panic!("Failed to initialize settings store: {}", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let mcp_store = match McpStore::new() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to initialize MCP store: {}", e);
|
||||||
|
panic!("Failed to initialize MCP store: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
@@ -40,6 +49,7 @@ pub fn run() {
|
|||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
projects_store,
|
projects_store,
|
||||||
settings_store,
|
settings_store,
|
||||||
|
mcp_store,
|
||||||
exec_manager: ExecSessionManager::new(),
|
exec_manager: ExecSessionManager::new(),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
@@ -91,6 +101,11 @@ pub fn run() {
|
|||||||
commands::terminal_commands::terminal_resize,
|
commands::terminal_commands::terminal_resize,
|
||||||
commands::terminal_commands::close_terminal_session,
|
commands::terminal_commands::close_terminal_session,
|
||||||
commands::terminal_commands::paste_image_to_terminal,
|
commands::terminal_commands::paste_image_to_terminal,
|
||||||
|
// 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,
|
||||||
// Updates
|
// Updates
|
||||||
commands::update_commands::get_app_version,
|
commands::update_commands::get_app_version,
|
||||||
commands::update_commands::check_for_updates,
|
commands::update_commands::check_for_updates,
|
||||||
|
|||||||
52
app/src-tauri/src/models/mcp_server.rs
Normal file
52
app/src-tauri/src/models/mcp_server.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum McpTransportType {
|
||||||
|
Stdio,
|
||||||
|
Http,
|
||||||
|
Sse,
|
||||||
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
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(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ 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::*;
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ pub struct Project {
|
|||||||
pub port_mappings: Vec<PortMapping>,
|
pub port_mappings: Vec<PortMapping>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub claude_instructions: Option<String>,
|
pub claude_instructions: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled_mcp_servers: Vec<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -130,6 +132,7 @@ 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(),
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
|
|||||||
106
app/src-tauri/src/storage/mcp_store.rs
Normal file
106
app/src-tauri/src/storage/mcp_store.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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,9 @@
|
|||||||
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;
|
||||||
|
|
||||||
pub use projects_store::*;
|
pub use projects_store::*;
|
||||||
pub use secure::*;
|
pub use secure::*;
|
||||||
pub use settings_store::*;
|
pub use settings_store::*;
|
||||||
|
pub use mcp_store::*;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import TerminalView from "./components/terminal/TerminalView";
|
|||||||
import { useDocker } from "./hooks/useDocker";
|
import { useDocker } from "./hooks/useDocker";
|
||||||
import { useSettings } from "./hooks/useSettings";
|
import { useSettings } from "./hooks/useSettings";
|
||||||
import { useProjects } from "./hooks/useProjects";
|
import { useProjects } from "./hooks/useProjects";
|
||||||
|
import { useMcpServers } from "./hooks/useMcpServers";
|
||||||
import { useUpdates } from "./hooks/useUpdates";
|
import { useUpdates } from "./hooks/useUpdates";
|
||||||
import { useAppState } from "./store/appState";
|
import { useAppState } from "./store/appState";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export default function App() {
|
|||||||
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||||
const { loadSettings } = useSettings();
|
const { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
|
const { refresh: refreshMcp } = useMcpServers();
|
||||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId } = useAppState(
|
const { sessions, activeSessionId } = useAppState(
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
|
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
|
||||||
@@ -31,6 +33,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
refresh();
|
refresh();
|
||||||
|
refreshMcp();
|
||||||
|
|
||||||
// Update detection
|
// Update detection
|
||||||
loadVersion();
|
loadVersion();
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ vi.mock("../projects/ProjectList", () => ({
|
|||||||
vi.mock("../settings/SettingsPanel", () => ({
|
vi.mock("../settings/SettingsPanel", () => ({
|
||||||
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../mcp/McpPanel", () => ({
|
||||||
|
default: () => <div data-testid="mcp-panel">McpPanel</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
describe("Sidebar", () => {
|
describe("Sidebar", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import ProjectList from "../projects/ProjectList";
|
import ProjectList from "../projects/ProjectList";
|
||||||
|
import McpPanel from "../mcp/McpPanel";
|
||||||
import SettingsPanel from "../settings/SettingsPanel";
|
import SettingsPanel from "../settings/SettingsPanel";
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
@@ -8,35 +9,37 @@ export default function Sidebar() {
|
|||||||
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
|
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tabCls = (view: typeof sidebarView) =>
|
||||||
|
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
sidebarView === view
|
||||||
|
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
{/* Nav tabs */}
|
{/* Nav tabs */}
|
||||||
<div className="flex border-b border-[var(--border-color)]">
|
<div className="flex border-b border-[var(--border-color)]">
|
||||||
<button
|
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
||||||
onClick={() => setSidebarView("projects")}
|
|
||||||
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
|
||||||
sidebarView === "projects"
|
|
||||||
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Projects
|
Projects
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => setSidebarView("mcp")} className={tabCls("mcp")}>
|
||||||
onClick={() => setSidebarView("settings")}
|
MCP
|
||||||
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
</button>
|
||||||
sidebarView === "settings"
|
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
||||||
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
||||||
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
{sidebarView === "projects" ? (
|
||||||
|
<ProjectList />
|
||||||
|
) : sidebarView === "mcp" ? (
|
||||||
|
<McpPanel />
|
||||||
|
) : (
|
||||||
|
<SettingsPanel />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
76
app/src/components/mcp/McpPanel.tsx
Normal file
76
app/src/components/mcp/McpPanel.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useMcpServers } from "../../hooks/useMcpServers";
|
||||||
|
import McpServerCard from "./McpServerCard";
|
||||||
|
|
||||||
|
export default function McpPanel() {
|
||||||
|
const { mcpServers, refresh, add, update, remove } = useMcpServers();
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await add(name);
|
||||||
|
setNewName("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 p-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-[var(--text-primary)]">MCP Servers</h2>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mt-0.5">
|
||||||
|
Define MCP servers globally, then enable them per-project.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add new server */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
|
||||||
|
placeholder="Server name..."
|
||||||
|
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
className="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-[var(--error)]">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mcpServers.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] italic">
|
||||||
|
No MCP servers configured.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
mcpServers.map((server) => (
|
||||||
|
<McpServerCard
|
||||||
|
key={server.id}
|
||||||
|
server={server}
|
||||||
|
onUpdate={update}
|
||||||
|
onRemove={remove}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
app/src/components/mcp/McpServerCard.tsx
Normal file
262
app/src/components/mcp/McpServerCard.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { McpServer, McpTransportType } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
server: McpServer;
|
||||||
|
onUpdate: (server: McpServer) => Promise<McpServer | void>;
|
||||||
|
onRemove: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [name, setName] = useState(server.name);
|
||||||
|
const [transportType, setTransportType] = useState<McpTransportType>(server.transport_type);
|
||||||
|
const [command, setCommand] = useState(server.command ?? "");
|
||||||
|
const [args, setArgs] = useState(server.args.join(" "));
|
||||||
|
const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env));
|
||||||
|
const [url, setUrl] = useState(server.url ?? "");
|
||||||
|
const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(server.name);
|
||||||
|
setTransportType(server.transport_type);
|
||||||
|
setCommand(server.command ?? "");
|
||||||
|
setArgs(server.args.join(" "));
|
||||||
|
setEnvPairs(Object.entries(server.env));
|
||||||
|
setUrl(server.url ?? "");
|
||||||
|
setHeaderPairs(Object.entries(server.headers));
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
const saveServer = async (patch: Partial<McpServer>) => {
|
||||||
|
try {
|
||||||
|
await onUpdate({ ...server, ...patch });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update MCP server:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameBlur = () => {
|
||||||
|
if (name !== server.name) saveServer({ name });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransportChange = (t: McpTransportType) => {
|
||||||
|
setTransportType(t);
|
||||||
|
saveServer({ transport_type: t });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommandBlur = () => {
|
||||||
|
saveServer({ command: command || null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArgsBlur = () => {
|
||||||
|
const parsed = args.trim() ? args.trim().split(/\s+/) : [];
|
||||||
|
saveServer({ args: parsed });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUrlBlur = () => {
|
||||||
|
saveServer({ url: url || null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEnv = (pairs: [string, string][]) => {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const [k, v] of pairs) {
|
||||||
|
if (k.trim()) env[k.trim()] = v;
|
||||||
|
}
|
||||||
|
saveServer({ env });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveHeaders = (pairs: [string, string][]) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const [k, v] of pairs) {
|
||||||
|
if (k.trim()) headers[k.trim()] = v;
|
||||||
|
}
|
||||||
|
saveServer({ headers });
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
|
||||||
|
|
||||||
|
const transportBadge = {
|
||||||
|
stdio: "Stdio",
|
||||||
|
http: "HTTP",
|
||||||
|
sse: "SSE",
|
||||||
|
}[transportType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[var(--border-color)] rounded bg-[var(--bg-primary)]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">{expanded ? "\u25BC" : "\u25B6"}</span>
|
||||||
|
<span className="text-sm font-medium truncate">{server.name}</span>
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
|
||||||
|
{transportBadge}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm(`Remove MCP server "${server.name}"?`)) onRemove(server.id); }}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded config */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-3 pb-3 space-y-2 border-t border-[var(--border-color)] pt-2">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Name</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onBlur={handleNameBlur}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transport type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Transport</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(["stdio", "http", "sse"] as McpTransportType[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => handleTransportChange(t)}
|
||||||
|
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||||
|
transportType === t
|
||||||
|
? "bg-[var(--accent)] text-white"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "stdio" ? "Stdio" : t === "http" ? "HTTP" : "SSE"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stdio fields */}
|
||||||
|
{transportType === "stdio" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Command</label>
|
||||||
|
<input
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
onBlur={handleCommandBlur}
|
||||||
|
placeholder="npx"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Arguments (space-separated)</label>
|
||||||
|
<input
|
||||||
|
value={args}
|
||||||
|
onChange={(e) => setArgs(e.target.value)}
|
||||||
|
onBlur={handleArgsBlur}
|
||||||
|
placeholder="-y @modelcontextprotocol/server-filesystem /path"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<KeyValueEditor
|
||||||
|
label="Environment Variables"
|
||||||
|
pairs={envPairs}
|
||||||
|
onChange={(pairs) => { setEnvPairs(pairs); }}
|
||||||
|
onSave={saveEnv}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HTTP/SSE fields */}
|
||||||
|
{(transportType === "http" || transportType === "sse") && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">URL</label>
|
||||||
|
<input
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onBlur={handleUrlBlur}
|
||||||
|
placeholder="http://localhost:3000/mcp"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<KeyValueEditor
|
||||||
|
label="Headers"
|
||||||
|
pairs={headerPairs}
|
||||||
|
onChange={(pairs) => { setHeaderPairs(pairs); }}
|
||||||
|
onSave={saveHeaders}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyValueEditor({
|
||||||
|
label,
|
||||||
|
pairs,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
pairs: [string, string][];
|
||||||
|
onChange: (pairs: [string, string][]) => void;
|
||||||
|
onSave: (pairs: [string, string][]) => void;
|
||||||
|
}) {
|
||||||
|
const inputCls = "flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">{label}</label>
|
||||||
|
{pairs.map(([key, value], i) => (
|
||||||
|
<div key={i} className="flex gap-1 items-center mb-1">
|
||||||
|
<input
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...pairs] as [string, string][];
|
||||||
|
updated[i] = [e.target.value, value];
|
||||||
|
onChange(updated);
|
||||||
|
}}
|
||||||
|
onBlur={() => onSave(pairs)}
|
||||||
|
placeholder="KEY"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">=</span>
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...pairs] as [string, string][];
|
||||||
|
updated[i] = [key, e.target.value];
|
||||||
|
onChange(updated);
|
||||||
|
}}
|
||||||
|
onBlur={() => onSave(pairs)}
|
||||||
|
placeholder="value"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const updated = pairs.filter((_, j) => j !== i);
|
||||||
|
onChange(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onChange([...pairs, ["", ""]]);
|
||||||
|
}}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,16 @@ vi.mock("../../hooks/useTerminal", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useMcpServers", () => ({
|
||||||
|
useMcpServers: () => ({
|
||||||
|
mcpServers: [],
|
||||||
|
refresh: vi.fn(),
|
||||||
|
add: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
let mockSelectedProjectId: string | null = null;
|
let mockSelectedProjectId: string | null = null;
|
||||||
vi.mock("../../store/appState", () => ({
|
vi.mock("../../store/appState", () => ({
|
||||||
useAppState: vi.fn((selector) =>
|
useAppState: vi.fn((selector) =>
|
||||||
@@ -55,7 +65,9 @@ const mockProject: Project = {
|
|||||||
git_user_name: null,
|
git_user_name: null,
|
||||||
git_user_email: null,
|
git_user_email: null,
|
||||||
custom_env_vars: [],
|
custom_env_vars: [],
|
||||||
|
port_mappings: [],
|
||||||
claude_instructions: null,
|
claude_instructions: null,
|
||||||
|
enabled_mcp_servers: [],
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog";
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
|
import { useMcpServers } from "../../hooks/useMcpServers";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import EnvVarsModal from "./EnvVarsModal";
|
import EnvVarsModal from "./EnvVarsModal";
|
||||||
@@ -18,6 +19,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const selectedProjectId = useAppState(s => s.selectedProjectId);
|
const selectedProjectId = useAppState(s => s.selectedProjectId);
|
||||||
const setSelectedProject = useAppState(s => s.setSelectedProject);
|
const setSelectedProject = useAppState(s => s.setSelectedProject);
|
||||||
const { start, stop, rebuild, remove, update } = useProjects();
|
const { start, stop, rebuild, remove, update } = useProjects();
|
||||||
|
const { mcpServers } = useMcpServers();
|
||||||
const { open: openTerminal } = useTerminal();
|
const { open: openTerminal } = useTerminal();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -613,6 +615,40 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* MCP Servers */}
|
||||||
|
{mcpServers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{mcpServers.map((server) => {
|
||||||
|
const enabled = project.enabled_mcp_servers.includes(server.id);
|
||||||
|
return (
|
||||||
|
<label key={server.id} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onChange={async () => {
|
||||||
|
const updated = enabled
|
||||||
|
? project.enabled_mcp_servers.filter((id) => id !== server.id)
|
||||||
|
: [...project.enabled_mcp_servers, server.id];
|
||||||
|
try {
|
||||||
|
await update({ ...project, enabled_mcp_servers: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update MCP servers:", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border-[var(--border-color)] disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[var(--text-primary)]">{server.name}</span>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">({server.transport_type})</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bedrock config */}
|
{/* Bedrock config */}
|
||||||
{project.auth_mode === "bedrock" && (() => {
|
{project.auth_mode === "bedrock" && (() => {
|
||||||
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
|||||||
55
app/src/hooks/useMcpServers.ts
Normal file
55
app/src/hooks/useMcpServers.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import type { McpServer } from "../lib/types";
|
||||||
|
|
||||||
|
export function useMcpServers() {
|
||||||
|
const {
|
||||||
|
mcpServers,
|
||||||
|
setMcpServers,
|
||||||
|
updateMcpServerInList,
|
||||||
|
removeMcpServerFromList,
|
||||||
|
} = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
mcpServers: s.mcpServers,
|
||||||
|
setMcpServers: s.setMcpServers,
|
||||||
|
updateMcpServerInList: s.updateMcpServerInList,
|
||||||
|
removeMcpServerFromList: s.removeMcpServerFromList,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const list = await commands.listMcpServers();
|
||||||
|
setMcpServers(list);
|
||||||
|
}, [setMcpServers]);
|
||||||
|
|
||||||
|
const add = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
const server = await commands.addMcpServer(name);
|
||||||
|
const list = await commands.listMcpServers();
|
||||||
|
setMcpServers(list);
|
||||||
|
return server;
|
||||||
|
},
|
||||||
|
[setMcpServers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
async (server: McpServer) => {
|
||||||
|
const updated = await commands.updateMcpServer(server);
|
||||||
|
updateMcpServerInList(updated);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
[updateMcpServerInList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await commands.removeMcpServer(id);
|
||||||
|
removeMcpServerFromList(id);
|
||||||
|
},
|
||||||
|
[removeMcpServerFromList],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { mcpServers, refresh, add, update, remove };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo } from "./types";
|
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -50,6 +50,15 @@ export const closeTerminalSession = (sessionId: string) =>
|
|||||||
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||||
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||||
|
|
||||||
|
// MCP Servers
|
||||||
|
export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers");
|
||||||
|
export const addMcpServer = (name: string) =>
|
||||||
|
invoke<McpServer>("add_mcp_server", { name });
|
||||||
|
export const updateMcpServer = (server: McpServer) =>
|
||||||
|
invoke<McpServer>("update_mcp_server", { server });
|
||||||
|
export const removeMcpServer = (serverId: string) =>
|
||||||
|
invoke<void>("remove_mcp_server", { serverId });
|
||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||||
export const checkForUpdates = () =>
|
export const checkForUpdates = () =>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface Project {
|
|||||||
custom_env_vars: EnvVar[];
|
custom_env_vars: EnvVar[];
|
||||||
port_mappings: PortMapping[];
|
port_mappings: PortMapping[];
|
||||||
claude_instructions: string | null;
|
claude_instructions: string | null;
|
||||||
|
enabled_mcp_servers: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -115,3 +116,18 @@ export interface ReleaseAsset {
|
|||||||
browser_download_url: string;
|
browser_download_url: string;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type McpTransportType = "stdio" | "http" | "sse";
|
||||||
|
|
||||||
|
export interface McpServer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transport_type: McpTransportType;
|
||||||
|
command: string | null;
|
||||||
|
args: string[];
|
||||||
|
env: Record<string, string>;
|
||||||
|
url: string | null;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Project, TerminalSession, AppSettings, UpdateInfo } from "../lib/types";
|
import type { Project, TerminalSession, AppSettings, UpdateInfo, McpServer } from "../lib/types";
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// Projects
|
// Projects
|
||||||
@@ -17,9 +17,15 @@ interface AppState {
|
|||||||
removeSession: (id: string) => void;
|
removeSession: (id: string) => void;
|
||||||
setActiveSession: (id: string | null) => void;
|
setActiveSession: (id: string | null) => void;
|
||||||
|
|
||||||
|
// MCP servers
|
||||||
|
mcpServers: McpServer[];
|
||||||
|
setMcpServers: (servers: McpServer[]) => void;
|
||||||
|
updateMcpServerInList: (server: McpServer) => void;
|
||||||
|
removeMcpServerFromList: (id: string) => void;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
sidebarView: "projects" | "settings";
|
sidebarView: "projects" | "mcp" | "settings";
|
||||||
setSidebarView: (view: "projects" | "settings") => void;
|
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
||||||
dockerAvailable: boolean | null;
|
dockerAvailable: boolean | null;
|
||||||
setDockerAvailable: (available: boolean | null) => void;
|
setDockerAvailable: (available: boolean | null) => void;
|
||||||
imageExists: boolean | null;
|
imageExists: boolean | null;
|
||||||
@@ -75,6 +81,20 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||||
|
|
||||||
|
// MCP servers
|
||||||
|
mcpServers: [],
|
||||||
|
setMcpServers: (servers) => set({ mcpServers: servers }),
|
||||||
|
updateMcpServerInList: (server) =>
|
||||||
|
set((state) => ({
|
||||||
|
mcpServers: state.mcpServers.map((s) =>
|
||||||
|
s.id === server.id ? server : s,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
removeMcpServerFromList: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
mcpServers: state.mcpServers.filter((s) => s.id !== id),
|
||||||
|
})),
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
sidebarView: "projects",
|
sidebarView: "projects",
|
||||||
setSidebarView: (view) => set({ sidebarView: view }),
|
setSidebarView: (view) => set({ sidebarView: view }),
|
||||||
|
|||||||
@@ -103,6 +103,27 @@ if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
|
|||||||
unset CLAUDE_INSTRUCTIONS
|
unset CLAUDE_INSTRUCTIONS
|
||||||
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
|
||||||
|
|
||||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||||
if [ -S /var/run/docker.sock ]; then
|
if [ -S /var/run/docker.sock ]; then
|
||||||
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
|
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||||
|
|||||||
Reference in New Issue
Block a user