Merge pull request #6188 from menloresearch/feat/mcp-enhancement

feat: mcp enhancement
This commit is contained in:
Louis 2025-08-18 09:55:44 +07:00 committed by GitHub
commit 362324cb87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 3019 additions and 359 deletions

View File

@ -44,9 +44,10 @@ jan-utils = { path = "./utils" }
libloading = "0.8.7" libloading = "0.8.7"
log = "0.4" log = "0.4"
reqwest = { version = "0.11", features = ["json", "blocking", "stream"] } reqwest = { version = "0.11", features = ["json", "blocking", "stream"] }
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "3196c95f1dfafbffbdcdd6d365c94969ac975e6a", features = [ rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "209dbac50f51737ad953c3a2c8e28f3619b6c277", features = [
"client", "client",
"transport-sse-client", "transport-sse-client",
"transport-streamable-http-client",
"transport-child-process", "transport-child-process",
"tower", "tower",
"reqwest", "reqwest",

View File

@ -1,15 +1,17 @@
use rmcp::model::{CallToolRequestParam, CallToolResult, Tool}; use rmcp::model::{CallToolRequestParam, CallToolResult};
use rmcp::{service::RunningService, RoleClient};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::{collections::HashMap, sync::Arc};
use tauri::{AppHandle, Emitter, Runtime, State}; use tauri::{AppHandle, Emitter, Runtime, State};
use tokio::{sync::Mutex, time::timeout}; use tokio::time::timeout;
use super::{ use super::{
constants::{DEFAULT_MCP_CONFIG, MCP_TOOL_CALL_TIMEOUT}, constants::{DEFAULT_MCP_CONFIG, MCP_TOOL_CALL_TIMEOUT},
helpers::{restart_active_mcp_servers, start_mcp_server_with_restart, stop_mcp_servers}, helpers::{restart_active_mcp_servers, start_mcp_server_with_restart, stop_mcp_servers},
}; };
use crate::core::{app::commands::get_jan_data_folder_path, state::AppState}; use crate::core::{app::commands::get_jan_data_folder_path, state::AppState};
use crate::core::{
mcp::models::ToolWithServer,
state::{RunningServiceEnum, SharedMcpServers},
};
use std::fs; use std::fs;
#[tauri::command] #[tauri::command]
@ -19,8 +21,7 @@ pub async fn activate_mcp_server<R: Runtime>(
name: String, name: String,
config: Value, config: Value,
) -> Result<(), String> { ) -> Result<(), String> {
let servers: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>> = let servers: SharedMcpServers = state.mcp_servers.clone();
state.mcp_servers.clone();
// Use the modified start_mcp_server_with_restart that returns first attempt result // Use the modified start_mcp_server_with_restart that returns first attempt result
start_mcp_server_with_restart(app, servers, name, config, Some(3)).await start_mcp_server_with_restart(app, servers, name, config, Some(3)).await
@ -63,7 +64,16 @@ pub async fn deactivate_mcp_server(state: State<'_, AppState>, name: String) ->
// Release the lock before calling cancel // Release the lock before calling cancel
drop(servers_map); drop(servers_map);
service.cancel().await.map_err(|e| e.to_string())?; match service {
RunningServiceEnum::NoInit(service) => {
log::info!("Stopping server {name}...");
service.cancel().await.map_err(|e| e.to_string())?;
}
RunningServiceEnum::WithInit(service) => {
log::info!("Stopping server {name} with initialization...");
service.cancel().await.map_err(|e| e.to_string())?;
}
}
log::info!("Server {name} stopped successfully and marked as deactivated."); log::info!("Server {name} stopped successfully and marked as deactivated.");
Ok(()) Ok(())
} }
@ -116,7 +126,7 @@ pub async fn get_connected_servers(
Ok(servers_map.keys().cloned().collect()) Ok(servers_map.keys().cloned().collect())
} }
/// Retrieves all available tools from all MCP servers /// Retrieves all available tools from all MCP servers with server information
/// ///
/// # Arguments /// # Arguments
/// * `state` - Application state containing MCP server connections /// * `state` - Application state containing MCP server connections
@ -128,14 +138,15 @@ pub async fn get_connected_servers(
/// 1. Locks the MCP servers mutex to access server connections /// 1. Locks the MCP servers mutex to access server connections
/// 2. Iterates through all connected servers /// 2. Iterates through all connected servers
/// 3. Gets the list of tools from each server /// 3. Gets the list of tools from each server
/// 4. Combines all tools into a single vector /// 4. Associates each tool with its parent server name
/// 5. Returns the combined list of all available tools /// 5. Combines all tools into a single vector
/// 6. Returns the combined list of all available tools with server information
#[tauri::command] #[tauri::command]
pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<Tool>, String> { pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<ToolWithServer>, String> {
let servers = state.mcp_servers.lock().await; let servers = state.mcp_servers.lock().await;
let mut all_tools: Vec<Tool> = Vec::new(); let mut all_tools: Vec<ToolWithServer> = Vec::new();
for (_, service) in servers.iter() { for (server_name, service) in servers.iter() {
// List tools with timeout // List tools with timeout
let tools_future = service.list_all_tools(); let tools_future = service.list_all_tools();
let tools = match timeout(MCP_TOOL_CALL_TIMEOUT, tools_future).await { let tools = match timeout(MCP_TOOL_CALL_TIMEOUT, tools_future).await {
@ -150,7 +161,12 @@ pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<Tool>, String>
}; };
for tool in tools { for tool in tools {
all_tools.push(tool); all_tools.push(ToolWithServer {
name: tool.name.to_string(),
description: tool.description.as_ref().map(|d| d.to_string()),
input_schema: serde_json::Value::Object((*tool.input_schema).clone()),
server: server_name.clone(),
});
} }
} }

View File

@ -1,7 +1,15 @@
use rmcp::{service::RunningService, transport::TokioChildProcess, RoleClient, ServiceExt}; use rmcp::{
model::{ClientCapabilities, ClientInfo, Implementation},
transport::{
streamable_http_client::StreamableHttpClientTransportConfig, SseClientTransport,
StreamableHttpClientTransport, TokioChildProcess,
},
ServiceExt,
};
use serde_json::Value; use serde_json::Value;
use std::{collections::HashMap, env, sync::Arc, time::Duration}; use std::{collections::HashMap, env, sync::Arc, time::Duration};
use tauri::{AppHandle, Emitter, Manager, Runtime, State}; use tauri::{AppHandle, Emitter, Manager, Runtime, State};
use tauri_plugin_http::reqwest;
use tokio::{ use tokio::{
process::Command, process::Command,
sync::Mutex, sync::Mutex,
@ -11,7 +19,11 @@ use tokio::{
use super::constants::{ use super::constants::{
MCP_BACKOFF_MULTIPLIER, MCP_BASE_RESTART_DELAY_MS, MCP_MAX_RESTART_DELAY_MS, MCP_BACKOFF_MULTIPLIER, MCP_BASE_RESTART_DELAY_MS, MCP_MAX_RESTART_DELAY_MS,
}; };
use crate::core::{app::commands::get_jan_data_folder_path, state::AppState}; use crate::core::{
app::commands::get_jan_data_folder_path,
mcp::models::McpServerConfig,
state::{AppState, RunningServiceEnum, SharedMcpServers},
};
use jan_utils::can_override_npx; use jan_utils::can_override_npx;
/// Calculate exponential backoff delay with jitter /// Calculate exponential backoff delay with jitter
@ -72,7 +84,7 @@ pub fn calculate_exponential_backoff_delay(attempt: u32) -> u64 {
/// * `Err(String)` if there was an error reading config or starting servers /// * `Err(String)` if there was an error reading config or starting servers
pub async fn run_mcp_commands<R: Runtime>( pub async fn run_mcp_commands<R: Runtime>(
app: &AppHandle<R>, app: &AppHandle<R>,
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>, servers_state: SharedMcpServers,
) -> Result<(), String> { ) -> Result<(), String> {
let app_path = get_jan_data_folder_path(app.clone()); let app_path = get_jan_data_folder_path(app.clone());
let app_path_str = app_path.to_str().unwrap().to_string(); let app_path_str = app_path.to_str().unwrap().to_string();
@ -168,7 +180,7 @@ pub async fn run_mcp_commands<R: Runtime>(
/// Monitor MCP server health without removing it from the HashMap /// Monitor MCP server health without removing it from the HashMap
pub async fn monitor_mcp_server_handle( pub async fn monitor_mcp_server_handle(
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>, servers_state: SharedMcpServers,
name: String, name: String,
) -> Option<rmcp::service::QuitReason> { ) -> Option<rmcp::service::QuitReason> {
log::info!("Monitoring MCP server {} health", name); log::info!("Monitoring MCP server {} health", name);
@ -213,7 +225,16 @@ pub async fn monitor_mcp_server_handle(
let mut servers = servers_state.lock().await; let mut servers = servers_state.lock().await;
if let Some(service) = servers.remove(&name) { if let Some(service) = servers.remove(&name) {
// Try to cancel the service gracefully // Try to cancel the service gracefully
let _ = service.cancel().await; match service {
RunningServiceEnum::NoInit(service) => {
log::info!("Stopping server {name}...");
let _ = service.cancel().await;
}
RunningServiceEnum::WithInit(service) => {
log::info!("Stopping server {name} with initialization...");
let _ = service.cancel().await;
}
}
} }
return Some(rmcp::service::QuitReason::Closed); return Some(rmcp::service::QuitReason::Closed);
} }
@ -224,7 +245,7 @@ pub async fn monitor_mcp_server_handle(
/// Returns the result of the first start attempt, then continues with restart monitoring /// Returns the result of the first start attempt, then continues with restart monitoring
pub async fn start_mcp_server_with_restart<R: Runtime>( pub async fn start_mcp_server_with_restart<R: Runtime>(
app: AppHandle<R>, app: AppHandle<R>,
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>, servers_state: SharedMcpServers,
name: String, name: String,
config: Value, config: Value,
max_restarts: Option<u32>, max_restarts: Option<u32>,
@ -297,7 +318,7 @@ pub async fn start_mcp_server_with_restart<R: Runtime>(
/// Helper function to handle the restart loop logic /// Helper function to handle the restart loop logic
pub async fn start_restart_loop<R: Runtime>( pub async fn start_restart_loop<R: Runtime>(
app: AppHandle<R>, app: AppHandle<R>,
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>, servers_state: SharedMcpServers,
name: String, name: String,
config: Value, config: Value,
max_restarts: u32, max_restarts: u32,
@ -450,9 +471,9 @@ pub async fn start_restart_loop<R: Runtime>(
} }
} }
pub async fn schedule_mcp_start_task<R: Runtime>( async fn schedule_mcp_start_task<R: Runtime>(
app: tauri::AppHandle<R>, app: tauri::AppHandle<R>,
servers: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>, servers: SharedMcpServers,
name: String, name: String,
config: Value, config: Value,
) -> Result<(), String> { ) -> Result<(), String> {
@ -463,136 +484,278 @@ pub async fn schedule_mcp_start_task<R: Runtime>(
.expect("Executable must have a parent directory"); .expect("Executable must have a parent directory");
let bin_path = exe_parent_path.to_path_buf(); let bin_path = exe_parent_path.to_path_buf();
let (command, args, envs) = extract_command_args(&config) let config_params = extract_command_args(&config)
.ok_or_else(|| format!("Failed to extract command args from config for {name}"))?; .ok_or_else(|| format!("Failed to extract command args from config for {name}"))?;
let mut cmd = Command::new(command.clone()); if config_params.transport_type.as_deref() == Some("http") && config_params.url.is_some() {
let transport = StreamableHttpClientTransport::with_client(
reqwest::Client::builder()
.default_headers({
// Map envs to request headers
let mut headers: tauri::http::HeaderMap = reqwest::header::HeaderMap::new();
for (key, value) in config_params.headers.iter() {
if let Some(v_str) = value.as_str() {
// Try to map env keys to HTTP header names (case-insensitive)
// Most HTTP headers are Title-Case, so we try to convert
let header_name =
reqwest::header::HeaderName::from_bytes(key.as_bytes());
if let Ok(header_name) = header_name {
if let Ok(header_value) =
reqwest::header::HeaderValue::from_str(v_str)
{
headers.insert(header_name, header_value);
}
}
}
}
headers
})
.connect_timeout(config_params.timeout.unwrap_or(Duration::MAX))
.build()
.unwrap(),
StreamableHttpClientTransportConfig {
uri: config_params.url.unwrap().into(),
..Default::default()
},
);
if command == "npx" && can_override_npx() { let client_info = ClientInfo {
let mut cache_dir = app_path.clone(); protocol_version: Default::default(),
cache_dir.push(".npx"); capabilities: ClientCapabilities::default(),
let bun_x_path = format!("{}/bun", bin_path.display()); client_info: Implementation {
cmd = Command::new(bun_x_path); name: "Jan Streamable Client".to_string(),
cmd.arg("x"); version: "0.0.1".to_string(),
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string()); },
} };
let client = client_info.serve(transport).await.inspect_err(|e| {
log::error!("client error: {:?}", e);
});
if command == "uvx" { match client {
let mut cache_dir = app_path.clone(); Ok(client) => {
cache_dir.push(".uvx"); log::info!("Connected to server: {:?}", client.peer_info());
let bun_x_path = format!("{}/uv", bin_path.display()); servers
cmd = Command::new(bun_x_path); .lock()
cmd.arg("tool"); .await
cmd.arg("run"); .insert(name.clone(), RunningServiceEnum::WithInit(client));
cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string());
}
#[cfg(windows)] // Mark server as successfully connected (for restart policy)
{
let app_state = app.state::<AppState>();
let mut connected = app_state.mcp_successfully_connected.lock().await;
connected.insert(name.clone(), true);
log::info!("Marked MCP server {} as successfully connected", name);
}
}
Err(e) => {
log::error!("Failed to connect to server: {}", e);
return Err(format!("Failed to connect to server: {}", e));
}
}
} else if config_params.transport_type.as_deref() == Some("sse") && config_params.url.is_some()
{ {
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows let transport = SseClientTransport::start_with_client(
} reqwest::Client::builder()
.default_headers({
let app_path_str = app_path.to_str().unwrap().to_string(); // Map envs to request headers
let log_file_path = format!("{}/logs/app.log", app_path_str); let mut headers = reqwest::header::HeaderMap::new();
match std::fs::OpenOptions::new() for (key, value) in config_params.headers.iter() {
.create(true) if let Some(v_str) = value.as_str() {
.append(true) // Try to map env keys to HTTP header names (case-insensitive)
.open(log_file_path) // Most HTTP headers are Title-Case, so we try to convert
{ let header_name =
Ok(file) => { reqwest::header::HeaderName::from_bytes(key.as_bytes());
cmd.stderr(std::process::Stdio::from(file)); if let Ok(header_name) = header_name {
} if let Ok(header_value) =
Err(err) => { reqwest::header::HeaderValue::from_str(v_str)
log::error!("Failed to open log file: {}", err); {
} headers.insert(header_name, header_value);
}; }
}
cmd.kill_on_drop(true); }
log::trace!("Command: {cmd:#?}"); }
headers
args.iter().filter_map(Value::as_str).for_each(|arg| { })
cmd.arg(arg); .connect_timeout(config_params.timeout.unwrap_or(Duration::MAX))
}); .build()
envs.iter().for_each(|(k, v)| { .unwrap(),
if let Some(v_str) = v.as_str() { rmcp::transport::sse_client::SseClientConfig {
cmd.env(k, v_str); sse_endpoint: config_params.url.unwrap().into(),
} ..Default::default()
}); },
)
let process = TokioChildProcess::new(cmd).map_err(|e| {
log::error!("Failed to run command {name}: {e}");
format!("Failed to run command {name}: {e}")
})?;
let service = ()
.serve(process)
.await .await
.map_err(|e| format!("Failed to start MCP server {name}: {e}"))?; .map_err(|e| {
log::error!("transport error: {:?}", e);
format!("Failed to start SSE transport: {}", e)
})?;
// Get peer info and clone the needed values before moving the service let client_info = ClientInfo {
let (server_name, server_version) = { protocol_version: Default::default(),
capabilities: ClientCapabilities::default(),
client_info: Implementation {
name: "Jan SSE Client".to_string(),
version: "0.0.1".to_string(),
},
};
let client = client_info.serve(transport).await.map_err(|e| {
log::error!("client error: {:?}", e);
e.to_string()
});
match client {
Ok(client) => {
log::info!("Connected to server: {:?}", client.peer_info());
servers
.lock()
.await
.insert(name.clone(), RunningServiceEnum::WithInit(client));
// Mark server as successfully connected (for restart policy)
{
let app_state = app.state::<AppState>();
let mut connected = app_state.mcp_successfully_connected.lock().await;
connected.insert(name.clone(), true);
log::info!("Marked MCP server {} as successfully connected", name);
}
}
Err(e) => {
log::error!("Failed to connect to server: {}", e);
return Err(format!("Failed to connect to server: {}", e));
}
}
} else {
let mut cmd = Command::new(config_params.command.clone());
if config_params.command.clone() == "npx" && can_override_npx() {
let mut cache_dir = app_path.clone();
cache_dir.push(".npx");
let bun_x_path = format!("{}/bun", bin_path.display());
cmd = Command::new(bun_x_path);
cmd.arg("x");
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string());
}
if config_params.command.clone() == "uvx" {
let mut cache_dir = app_path.clone();
cache_dir.push(".uvx");
let bun_x_path = format!("{}/uv", bin_path.display());
cmd = Command::new(bun_x_path);
cmd.arg("tool");
cmd.arg("run");
cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string());
}
#[cfg(windows)]
{
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows
}
let app_path_str = app_path.to_str().unwrap().to_string();
let log_file_path = format!("{}/logs/app.log", app_path_str);
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_file_path)
{
Ok(file) => {
cmd.stderr(std::process::Stdio::from(file));
}
Err(err) => {
log::error!("Failed to open log file: {}", err);
}
};
cmd.kill_on_drop(true);
log::trace!("Command: {cmd:#?}");
config_params
.args
.iter()
.filter_map(Value::as_str)
.for_each(|arg| {
cmd.arg(arg);
});
config_params.envs.iter().for_each(|(k, v)| {
if let Some(v_str) = v.as_str() {
cmd.env(k, v_str);
}
});
let process = TokioChildProcess::new(cmd).map_err(|e| {
log::error!("Failed to run command {name}: {e}");
format!("Failed to run command {name}: {e}")
})?;
let service = ()
.serve(process)
.await
.map_err(|e| format!("Failed to start MCP server {name}: {e}"))?;
// Get peer info and clone the needed values before moving the service
let server_info = service.peer_info(); let server_info = service.peer_info();
log::trace!("Connected to server: {server_info:#?}"); log::trace!("Connected to server: {server_info:#?}");
(
server_info.unwrap().server_info.name.clone(),
server_info.unwrap().server_info.version.clone(),
)
};
// Now move the service into the HashMap // Now move the service into the HashMap
servers.lock().await.insert(name.clone(), service); servers
log::info!("Server {name} started successfully."); .lock()
.await
.insert(name.clone(), RunningServiceEnum::NoInit(service));
log::info!("Server {name} started successfully.");
// Wait a short time to verify the server is stable before marking as connected // Wait a short time to verify the server is stable before marking as connected
// This prevents race conditions where the server quits immediately // This prevents race conditions where the server quits immediately
let verification_delay = Duration::from_millis(500); let verification_delay = Duration::from_millis(500);
sleep(verification_delay).await; sleep(verification_delay).await;
// Check if server is still running after the verification delay // Check if server is still running after the verification delay
let server_still_running = { let server_still_running = {
let servers_map = servers.lock().await; let servers_map = servers.lock().await;
servers_map.contains_key(&name) servers_map.contains_key(&name)
}; };
if !server_still_running { if !server_still_running {
return Err(format!( return Err(format!(
"MCP server {} quit immediately after starting", "MCP server {} quit immediately after starting",
name name
)); ));
}
// Mark server as successfully connected (for restart policy)
{
let app_state = app.state::<AppState>();
let mut connected = app_state.mcp_successfully_connected.lock().await;
connected.insert(name.clone(), true);
log::info!("Marked MCP server {} as successfully connected", name);
}
} }
// Mark server as successfully connected (for restart policy)
{
let app_state = app.state::<AppState>();
let mut connected = app_state.mcp_successfully_connected.lock().await;
connected.insert(name.clone(), true);
log::info!("Marked MCP server {} as successfully connected", name);
}
// Emit event to the frontend
let event = format!("mcp-connected");
let payload = serde_json::json!({
"name": server_name,
"version": server_version,
});
app.emit(&event, payload)
.map_err(|e| format!("Failed to emit event: {}", e))?;
Ok(()) Ok(())
} }
pub fn extract_command_args( pub fn extract_command_args(config: &Value) -> Option<McpServerConfig> {
config: &Value,
) -> Option<(String, Vec<Value>, serde_json::Map<String, Value>)> {
let obj = config.as_object()?; let obj = config.as_object()?;
let command = obj.get("command")?.as_str()?.to_string(); let command = obj.get("command")?.as_str()?.to_string();
let args = obj.get("args")?.as_array()?.clone(); let args = obj.get("args")?.as_array()?.clone();
let url = obj.get("url").and_then(|u| u.as_str()).map(String::from);
let transport_type = obj.get("type").and_then(|t| t.as_str()).map(String::from);
let timeout = obj
.get("timeout")
.and_then(|t| t.as_u64())
.map(Duration::from_secs);
let headers = obj
.get("headers")
.unwrap_or(&Value::Object(serde_json::Map::new()))
.as_object()?
.clone();
let envs = obj let envs = obj
.get("env") .get("env")
.unwrap_or(&Value::Object(serde_json::Map::new())) .unwrap_or(&Value::Object(serde_json::Map::new()))
.as_object()? .as_object()?
.clone(); .clone();
Some((command, args, envs)) Some(McpServerConfig {
timeout,
transport_type,
url,
command,
args,
envs,
headers
})
} }
pub fn extract_active_status(config: &Value) -> Option<bool> { pub fn extract_active_status(config: &Value) -> Option<bool> {
@ -604,7 +767,7 @@ pub fn extract_active_status(config: &Value) -> Option<bool> {
/// Restart only servers that were previously active (like cortex restart behavior) /// Restart only servers that were previously active (like cortex restart behavior)
pub async fn restart_active_mcp_servers<R: Runtime>( pub async fn restart_active_mcp_servers<R: Runtime>(
app: &AppHandle<R>, app: &AppHandle<R>,
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>, servers_state: SharedMcpServers,
) -> Result<(), String> { ) -> Result<(), String> {
let app_state = app.state::<AppState>(); let app_state = app.state::<AppState>();
let active_servers = app_state.mcp_active_servers.lock().await; let active_servers = app_state.mcp_active_servers.lock().await;
@ -656,14 +819,21 @@ pub async fn clean_up_mcp_servers(state: State<'_, AppState>) {
log::info!("MCP servers cleaned up successfully"); log::info!("MCP servers cleaned up successfully");
} }
pub async fn stop_mcp_servers( pub async fn stop_mcp_servers(servers_state: SharedMcpServers) -> Result<(), String> {
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
) -> Result<(), String> {
let mut servers_map = servers_state.lock().await; let mut servers_map = servers_state.lock().await;
let keys: Vec<String> = servers_map.keys().cloned().collect(); let keys: Vec<String> = servers_map.keys().cloned().collect();
for key in keys { for key in keys {
if let Some(service) = servers_map.remove(&key) { if let Some(service) = servers_map.remove(&key) {
service.cancel().await.map_err(|e| e.to_string())?; match service {
RunningServiceEnum::NoInit(service) => {
log::info!("Stopping server {key}...");
service.cancel().await.map_err(|e| e.to_string())?;
}
RunningServiceEnum::WithInit(service) => {
log::info!("Stopping server {key} with initialization...");
service.cancel().await.map_err(|e| e.to_string())?;
}
}
} }
} }
drop(servers_map); // Release the lock after stopping drop(servers_map); // Release the lock after stopping
@ -689,7 +859,7 @@ pub async fn reset_restart_count(restart_counts: &Arc<Mutex<HashMap<String, u32>
/// Spawn the server monitoring task for handling restarts /// Spawn the server monitoring task for handling restarts
pub async fn spawn_server_monitoring_task<R: Runtime>( pub async fn spawn_server_monitoring_task<R: Runtime>(
app: AppHandle<R>, app: AppHandle<R>,
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>, servers_state: SharedMcpServers,
name: String, name: String,
config: Value, config: Value,
max_restarts: u32, max_restarts: u32,

View File

@ -1,6 +1,7 @@
pub mod commands; pub mod commands;
mod constants; mod constants;
pub mod helpers; pub mod helpers;
pub mod models;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@ -0,0 +1,26 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Configuration parameters extracted from MCP server config
#[derive(Debug, Clone)]
pub struct McpServerConfig {
pub transport_type: Option<String>,
pub url: Option<String>,
pub command: String,
pub args: Vec<Value>,
pub envs: serde_json::Map<String, Value>,
pub timeout: Option<Duration>,
pub headers: serde_json::Map<String, Value>,
}
/// Tool with server information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolWithServer {
pub name: String,
pub description: Option<String>,
#[serde(rename = "inputSchema")]
pub input_schema: serde_json::Value,
pub server: String,
}

View File

@ -1,6 +1,6 @@
use super::helpers::run_mcp_commands; use super::helpers::run_mcp_commands;
use crate::core::app::commands::get_jan_data_folder_path; use crate::core::app::commands::get_jan_data_folder_path;
use rmcp::{service::RunningService, RoleClient}; use crate::core::state::SharedMcpServers;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
@ -27,7 +27,7 @@ async fn test_run_mcp_commands() {
.expect("Failed to write to config file"); .expect("Failed to write to config file");
// Call the run_mcp_commands function // Call the run_mcp_commands function
let servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>> = let servers_state: SharedMcpServers =
Arc::new(Mutex::new(HashMap::new())); Arc::new(Mutex::new(HashMap::new()));
let result = run_mcp_commands(app.handle(), servers_state).await; let result = run_mcp_commands(app.handle(), servers_state).await;

View File

@ -1,20 +1,48 @@
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use crate::core::downloads::models::DownloadManagerState; use crate::core::downloads::models::DownloadManagerState;
use rmcp::{service::RunningService, RoleClient}; use rmcp::{
model::{CallToolRequestParam, CallToolResult, InitializeRequestParam, Tool},
service::RunningService,
RoleClient, ServiceError,
};
use tokio::sync::Mutex;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
/// Server handle type for managing the proxy server lifecycle /// Server handle type for managing the proxy server lifecycle
pub type ServerHandle = JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>>; pub type ServerHandle = JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>>;
use tokio::sync::Mutex;
pub enum RunningServiceEnum {
NoInit(RunningService<RoleClient, ()>),
WithInit(RunningService<RoleClient, InitializeRequestParam>),
}
pub type SharedMcpServers = Arc<Mutex<HashMap<String, RunningServiceEnum>>>;
#[derive(Default)] #[derive(Default)]
pub struct AppState { pub struct AppState {
pub app_token: Option<String>, pub app_token: Option<String>,
pub mcp_servers: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>, pub mcp_servers: SharedMcpServers,
pub download_manager: Arc<Mutex<DownloadManagerState>>, pub download_manager: Arc<Mutex<DownloadManagerState>>,
pub mcp_restart_counts: Arc<Mutex<HashMap<String, u32>>>, pub mcp_restart_counts: Arc<Mutex<HashMap<String, u32>>>,
pub mcp_active_servers: Arc<Mutex<HashMap<String, serde_json::Value>>>, pub mcp_active_servers: Arc<Mutex<HashMap<String, serde_json::Value>>>,
pub mcp_successfully_connected: Arc<Mutex<HashMap<String, bool>>>, pub mcp_successfully_connected: Arc<Mutex<HashMap<String, bool>>>,
pub server_handle: Arc<Mutex<Option<ServerHandle>>>, pub server_handle: Arc<Mutex<Option<ServerHandle>>>,
} }
impl RunningServiceEnum {
pub async fn list_all_tools(&self) -> Result<Vec<Tool>, ServiceError> {
match self {
Self::NoInit(s) => s.list_all_tools().await,
Self::WithInit(s) => s.list_all_tools().await,
}
}
pub async fn call_tool(
&self,
params: CallToolRequestParam,
) -> Result<CallToolResult, ServiceError> {
match self {
Self::NoInit(s) => s.call_tool(params).await,
Self::WithInit(s) => s.call_tool(params).await,
}
}
}

View File

@ -17,11 +17,12 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@janhq/core": "link:../core", "@janhq/core": "link:../core",
"@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.11", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14", "@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.4", "@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-slider": "^1.3.2", "@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2", "@radix-ui/react-switch": "^1.2.2",
@ -43,13 +44,14 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"culori": "^4.0.1", "culori": "^4.0.1",
"emoji-picker-react": "^4.12.2", "emoji-picker-react": "^4.12.2",
"framer-motion": "^12.23.12",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"fzf": "^0.5.2", "fzf": "^0.5.2",
"i18next": "^25.0.1", "i18next": "^25.0.1",
"katex": "^0.16.22", "katex": "^0.16.22",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lucide-react": "^0.522.0", "lucide-react": "^0.536.0",
"motion": "^12.10.5", "motion": "^12.10.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"posthog-js": "^1.246.0", "posthog-js": "^1.246.0",
@ -75,6 +77,7 @@
"ulidx": "^2.4.1", "ulidx": "^2.4.1",
"unified": "^11.0.5", "unified": "^11.0.5",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
@ -104,7 +107,7 @@
"istanbul-lib-report": "^3.0.1", "istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.7", "istanbul-reports": "^3.1.7",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.3.1",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.26.1", "typescript-eslint": "^8.26.1",
"vite": "^6.3.0", "vite": "^6.3.0",

View File

@ -0,0 +1,533 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '@testing-library/jest-dom'
import {
DropDrawer,
DropDrawerContent,
DropDrawerFooter,
DropDrawerGroup,
DropDrawerItem,
DropDrawerLabel,
DropDrawerSeparator,
DropDrawerSub,
DropDrawerSubContent,
DropDrawerSubTrigger,
DropDrawerTrigger,
} from '../dropdrawer'
// Mock the media query hook
const mockUseSmallScreen = vi.fn()
vi.mock('@/hooks/useMediaQuery', () => ({
useSmallScreen: () => mockUseSmallScreen(),
}))
// Mock framer-motion to avoid animation complexity in tests
vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <div data-testid="animate-presence">{children}</div>,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
}))
describe('DropDrawer Utilities', () => {
it('renders without crashing', () => {
expect(() => {
render(
<DropDrawer>
<DropDrawerTrigger>Test</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Item</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
}).not.toThrow()
})
})
describe('DropDrawer Component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Desktop Mode', () => {
beforeEach(() => {
mockUseSmallScreen.mockReturnValue(false)
})
it('renders dropdown menu on desktop', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Item 1</DropDrawerItem>
<DropDrawerItem>Item 2</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
expect(screen.getByText('Open Menu')).toBeInTheDocument()
})
it('renders dropdown menu structure', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Desktop Item</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Only the trigger is visible initially
expect(screen.getByText('Open Menu')).toBeInTheDocument()
expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'menu')
})
it('structures dropdown with separators', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Item 1</DropDrawerItem>
<DropDrawerSeparator />
<DropDrawerItem>Item 2</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Verify component structure - content is not visible until opened
expect(screen.getByText('Open Menu')).toBeInTheDocument()
})
it('structures dropdown with labels', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerLabel>Menu Section</DropDrawerLabel>
<DropDrawerItem>Item 1</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Only verify trigger is present - content shows on interaction
expect(screen.getByText('Open Menu')).toBeInTheDocument()
})
})
describe('Mobile Mode', () => {
beforeEach(() => {
mockUseSmallScreen.mockReturnValue(true)
})
it('renders drawer on mobile', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Mobile Item</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
})
it('renders drawer structure', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Mobile Item</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Verify drawer trigger is present
const trigger = screen.getByText('Open Drawer')
expect(trigger).toBeInTheDocument()
expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'dialog')
})
it('does not render separators in mobile mode', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Item 1</DropDrawerItem>
<DropDrawerSeparator />
<DropDrawerItem>Item 2</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Mobile separators return null, so they shouldn't be in the DOM
const separators = screen.queryAllByRole('separator')
expect(separators).toHaveLength(0)
})
it('renders drawer with labels structure', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerLabel>Drawer Section</DropDrawerLabel>
<DropDrawerItem>Item 1</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Verify drawer structure is present
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
})
})
describe('DropDrawerItem', () => {
beforeEach(() => {
mockUseSmallScreen.mockReturnValue(false)
})
it('can be structured with click handlers', () => {
const handleClick = vi.fn()
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem onClick={handleClick}>Clickable Item</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Verify structure is valid
expect(screen.getByText('Open Menu')).toBeInTheDocument()
expect(handleClick).not.toHaveBeenCalled()
})
it('can be structured with icons', () => {
const TestIcon = () => <span data-testid="test-icon">Icon</span>
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem icon={<TestIcon />}>Item with Icon</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Structure is valid
expect(screen.getByText('Open Menu')).toBeInTheDocument()
})
it('accepts variant props', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem variant="destructive">
Delete Item
</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Component structure is valid with variants
expect(screen.getByText('Open Menu')).toBeInTheDocument()
})
it('accepts disabled prop', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem disabled>
Disabled Item
</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Component structure is valid with disabled prop
expect(screen.getByText('Open Menu')).toBeInTheDocument()
})
})
describe('DropDrawerGroup', () => {
it('structures groups in desktop mode', () => {
mockUseSmallScreen.mockReturnValue(false)
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerGroup>
<DropDrawerItem>Group Item 1</DropDrawerItem>
<DropDrawerItem>Group Item 2</DropDrawerItem>
</DropDrawerGroup>
</DropDrawerContent>
</DropDrawer>
)
// Component structure is valid
expect(screen.getByText('Open Menu')).toBeInTheDocument()
})
it('structures groups in mobile mode', () => {
mockUseSmallScreen.mockReturnValue(true)
render(
<DropDrawer>
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerGroup>
<DropDrawerItem>Item 1</DropDrawerItem>
<DropDrawerItem>Item 2</DropDrawerItem>
</DropDrawerGroup>
</DropDrawerContent>
</DropDrawer>
)
// Component structure is valid in mobile mode
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
})
})
describe('DropDrawerFooter', () => {
it('structures footer in desktop mode', () => {
mockUseSmallScreen.mockReturnValue(false)
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Item</DropDrawerItem>
<DropDrawerFooter>Footer Content</DropDrawerFooter>
</DropDrawerContent>
</DropDrawer>
)
// Component structure is valid
expect(screen.getByText('Open Menu')).toBeInTheDocument()
})
it('structures footer in mobile mode', () => {
mockUseSmallScreen.mockReturnValue(true)
render(
<DropDrawer>
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Item</DropDrawerItem>
<DropDrawerFooter>Mobile Footer</DropDrawerFooter>
</DropDrawerContent>
</DropDrawer>
)
// Component structure is valid in mobile mode
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
})
})
describe('Submenu Components', () => {
beforeEach(() => {
mockUseSmallScreen.mockReturnValue(false)
})
it('structures submenu in desktop mode', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerSub>
<DropDrawerSubTrigger>Submenu Trigger</DropDrawerSubTrigger>
<DropDrawerSubContent>
<DropDrawerItem>Submenu Item</DropDrawerItem>
</DropDrawerSubContent>
</DropDrawerSub>
</DropDrawerContent>
</DropDrawer>
)
// Component structure is valid
expect(screen.getByText('Open Menu')).toBeInTheDocument()
})
it('structures submenu in mobile mode', () => {
mockUseSmallScreen.mockReturnValue(true)
render(
<DropDrawer>
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerSub>
<DropDrawerSubTrigger>
Mobile Submenu
</DropDrawerSubTrigger>
<DropDrawerSubContent>
<DropDrawerItem>Submenu Item</DropDrawerItem>
</DropDrawerSubContent>
</DropDrawerSub>
</DropDrawerContent>
</DropDrawer>
)
// Component structure is valid in mobile mode
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
})
it('handles submenu content correctly in mobile mode', () => {
mockUseSmallScreen.mockReturnValue(true)
render(
<DropDrawer>
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerSub>
<DropDrawerSubTrigger>Mobile Submenu</DropDrawerSubTrigger>
<DropDrawerSubContent>
<DropDrawerItem>Hidden Item</DropDrawerItem>
</DropDrawerSubContent>
</DropDrawerSub>
</DropDrawerContent>
</DropDrawer>
)
// Component handles mobile submenu structure correctly
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
})
})
describe('Accessibility', () => {
beforeEach(() => {
mockUseSmallScreen.mockReturnValue(false)
})
it('maintains proper ARIA attributes on triggers', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerGroup>
<DropDrawerItem>Item 1</DropDrawerItem>
</DropDrawerGroup>
</DropDrawerContent>
</DropDrawer>
)
const trigger = screen.getByRole('button')
expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
})
it('supports disabled state', () => {
const handleClick = vi.fn()
mockUseSmallScreen.mockReturnValue(true)
render(
<DropDrawer>
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem disabled onClick={handleClick}>
Disabled Item
</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Component structure supports disabled prop
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
expect(handleClick).not.toHaveBeenCalled()
})
})
describe('Error Boundaries', () => {
it('requires proper context usage', () => {
// Suppress console.error for this test
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
render(<DropDrawerItem>Orphan Item</DropDrawerItem>)
}).toThrow()
consoleSpy.mockRestore()
})
})
describe('Custom Props and Styling', () => {
beforeEach(() => {
mockUseSmallScreen.mockReturnValue(false)
})
it('applies custom className', () => {
render(
<DropDrawer>
<DropDrawerTrigger className="custom-trigger">Custom Trigger</DropDrawerTrigger>
<DropDrawerContent className="custom-content">
<DropDrawerItem className="custom-item">Custom Item</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
const trigger = screen.getByText('Custom Trigger')
expect(trigger).toHaveClass('custom-trigger')
})
it('accepts additional props', () => {
render(
<DropDrawer>
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem data-custom="test-value">Custom Props Item</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Component structure accepts custom props
expect(screen.getByText('Open Menu')).toBeInTheDocument()
})
})
describe('Responsive Behavior', () => {
it('adapts to different screen sizes', () => {
const { rerender } = render(
<DropDrawer>
<DropDrawerTrigger>Responsive Trigger</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Responsive Item</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
// Desktop mode
mockUseSmallScreen.mockReturnValue(false)
rerender(
<DropDrawer>
<DropDrawerTrigger>Responsive Trigger</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Responsive Item</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
let trigger = screen.getByText('Responsive Trigger')
expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
// Mobile mode
mockUseSmallScreen.mockReturnValue(true)
rerender(
<DropDrawer>
<DropDrawerTrigger>Responsive Trigger</DropDrawerTrigger>
<DropDrawerContent>
<DropDrawerItem>Responsive Item</DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
)
trigger = screen.getByText('Responsive Trigger')
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog')
})
})
})

View File

@ -0,0 +1,62 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RadioGroup, RadioGroupItem } from '../radio-group'
describe('RadioGroup', () => {
it('renders radio items correctly', () => {
render(
<RadioGroup defaultValue="http">
<div className="flex items-center space-x-2">
<RadioGroupItem value="http" id="http" />
<label htmlFor="http">HTTP</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="sse" id="sse" />
<label htmlFor="sse">SSE</label>
</div>
</RadioGroup>
)
expect(screen.getByLabelText('HTTP')).toBeInTheDocument()
expect(screen.getByLabelText('SSE')).toBeInTheDocument()
})
it('allows selecting different options', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(
<RadioGroup defaultValue="http" onValueChange={onValueChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="http" id="http" />
<label htmlFor="http">HTTP</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="sse" id="sse" />
<label htmlFor="sse">SSE</label>
</div>
</RadioGroup>
)
await user.click(screen.getByLabelText('SSE'))
expect(onValueChange).toHaveBeenCalledWith('sse')
})
it('has correct default selection', () => {
render(
<RadioGroup defaultValue="http">
<div className="flex items-center space-x-2">
<RadioGroupItem value="http" id="http" />
<label htmlFor="http">HTTP</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="sse" id="sse" />
<label htmlFor="sse">SSE</label>
</div>
</RadioGroup>
)
expect(screen.getByLabelText('HTTP')).toBeChecked()
expect(screen.getByLabelText('SSE')).not.toBeChecked()
})
})

View File

@ -0,0 +1,133 @@
import * as React from 'react'
import { Drawer as DrawerPrimitive } from 'vaul'
import { cn } from '@/lib/utils'
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
'data-[state=open]:animate-in backdrop-blur data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
'group/drawer-content fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className
)}
{...props}
>
<div className="bg-main-view-fg/10 mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
className={cn(
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn('font-semibold', className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn('text-sm', className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@ -61,14 +61,17 @@ function DropdownMenuGroup({
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = 'default',
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean
variant?: 'default' | 'destructive'
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item" data-slot="dropdown-menu-item"
data-inset={inset} data-inset={inset}
data-variant={variant}
className={cn( className={cn(
"relative cursor-pointer hover:bg-main-view-fg/4 flex items-center gap-2 rounded-sm px-2 py-1 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative cursor-pointer hover:bg-main-view-fg/4 flex items-center gap-2 rounded-sm px-2 py-1 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
@ -213,7 +216,7 @@ function DropdownMenuSubTrigger({
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4 text-main-view-fg/50" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) )
} }

View File

@ -0,0 +1,949 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import * as React from 'react'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { useSmallScreen } from '@/hooks/useMediaQuery'
const ANIMATION_CONFIG = {
variants: {
enter: (direction: 'forward' | 'backward') => ({
x: direction === 'forward' ? '100%' : '-100%',
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: 'forward' | 'backward') => ({
x: direction === 'forward' ? '-100%' : '100%',
opacity: 0,
}),
},
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1.0],
},
} as const
const getMobileItemStyles = (
isInsideGroup: boolean,
inset?: boolean,
variant?: string,
disabled?: boolean
) => {
return cn(
'flex cursor-pointer items-center justify-between px-4 py-4 w-full gap-4',
!isInsideGroup && 'bg-main-view-fg/50 mx-2 my-1.5 rounded-md',
isInsideGroup && 'bg-transparent py-4',
inset && 'pl-8',
variant === 'destructive' && 'text-destructive',
disabled && 'pointer-events-none opacity-50'
)
}
const DropDrawerContext = React.createContext<{ isMobile: boolean }>({
isMobile: false,
})
const useDropDrawerContext = () => {
const context = React.useContext(DropDrawerContext)
if (!context) {
throw new Error(
'DropDrawer components cannot be rendered outside the DropDrawer Context'
)
}
return context
}
const useComponentSelection = () => {
const { isMobile } = useDropDrawerContext()
const selectComponent = <T, D>(mobileComponent: T, desktopComponent: D) => {
return isMobile ? mobileComponent : desktopComponent
}
return { isMobile, selectComponent }
}
const useGroupDetection = () => {
const isInGroup = React.useCallback(
(element: HTMLElement | null): boolean => {
if (!element) return false
let parent = element.parentElement
while (parent) {
if (parent.hasAttribute('data-drop-drawer-group')) {
return true
}
parent = parent.parentElement
}
return false
},
[]
)
const useGroupState = () => {
const { isMobile } = useComponentSelection()
const itemRef = React.useRef<HTMLDivElement>(null)
const [isInsideGroup, setIsInsideGroup] = React.useState(false)
React.useEffect(() => {
if (!isMobile) return
const timer = setTimeout(() => {
if (itemRef.current) {
setIsInsideGroup(isInGroup(itemRef.current))
}
}, 0)
return () => clearTimeout(timer)
}, [isMobile])
return { itemRef, isInsideGroup }
}
return { isInGroup, useGroupState }
}
type ConditionalComponentProps<T, D> = {
children: React.ReactNode
className?: string
} & (T | D)
const ConditionalComponent = <T, D>({
mobileComponent,
desktopComponent,
children,
...props
}: {
mobileComponent: React.ComponentType<any>
desktopComponent: React.ComponentType<any>
children: React.ReactNode
} & ConditionalComponentProps<T, D>) => {
const { selectComponent } = useComponentSelection()
const Component = selectComponent(mobileComponent, desktopComponent)
return <Component {...props}>{children}</Component>
}
function DropDrawer({
children,
...props
}:
| React.ComponentProps<typeof Drawer>
| React.ComponentProps<typeof DropdownMenu>) {
const isMobile = useSmallScreen()
return (
<DropDrawerContext.Provider value={{ isMobile }}>
<ConditionalComponent
mobileComponent={Drawer}
desktopComponent={DropdownMenu}
data-slot="drop-drawer"
{...props}
>
{children}
</ConditionalComponent>
</DropDrawerContext.Provider>
)
}
function DropDrawerTrigger({
className,
children,
...props
}:
| React.ComponentProps<typeof DrawerTrigger>
| React.ComponentProps<typeof DropdownMenuTrigger>) {
return (
<ConditionalComponent
mobileComponent={DrawerTrigger}
desktopComponent={DropdownMenuTrigger}
data-slot="drop-drawer-trigger"
className={className}
{...props}
>
{children}
</ConditionalComponent>
)
}
function DropDrawerContent({
className,
children,
...props
}:
| React.ComponentProps<typeof DrawerContent>
| React.ComponentProps<typeof DropdownMenuContent>) {
const { isMobile } = useDropDrawerContext()
const [activeSubmenu, setActiveSubmenu] = React.useState<string | null>(null)
const [submenuTitle, setSubmenuTitle] = React.useState<string | null>(null)
const [submenuStack, setSubmenuStack] = React.useState<
{ id: string; title: string }[]
>([])
// Add animation direction state
const [animationDirection, setAnimationDirection] = React.useState<
'forward' | 'backward'
>('forward')
// Create a ref to store submenu content by ID
const submenuContentRef = React.useRef<Map<string, React.ReactNode[]>>(
new Map()
)
// Function to navigate to a submenu
const navigateToSubmenu = React.useCallback((id: string, title: string) => {
// Set animation direction to forward when navigating to a submenu
setAnimationDirection('forward')
setActiveSubmenu(id)
setSubmenuTitle(title)
setSubmenuStack((prev) => [...prev, { id, title }])
}, [])
// Function to go back to previous menu
const goBack = React.useCallback(() => {
// Set animation direction to backward when going back
setAnimationDirection('backward')
if (submenuStack.length <= 1) {
// If we're at the first level, go back to main menu
setActiveSubmenu(null)
setSubmenuTitle(null)
setSubmenuStack([])
} else {
// Go back to previous submenu
const newStack = [...submenuStack]
newStack.pop() // Remove current
const previous = newStack[newStack.length - 1]
setActiveSubmenu(previous.id)
setSubmenuTitle(previous.title)
setSubmenuStack(newStack)
}
}, [submenuStack])
// Function to register submenu content
const registerSubmenuContent = React.useCallback(
(id: string, content: React.ReactNode[]) => {
submenuContentRef.current.set(id, content)
},
[]
)
const extractSubmenuContent = React.useCallback(
(elements: React.ReactNode, targetId: string): React.ReactNode[] => {
const result: React.ReactNode[] = []
const findSubmenuContent = (node: React.ReactNode) => {
if (!React.isValidElement(node)) return
const element = node as React.ReactElement
const props = element.props as {
'id'?: string
'data-submenu-id'?: string
'children'?: React.ReactNode
}
if (element.type === DropDrawerSub) {
const elementId = props.id || props['data-submenu-id']
if (elementId === targetId) {
React.Children.forEach(props.children, (child) => {
if (
React.isValidElement(child) &&
child.type === DropDrawerSubContent
) {
const subContentProps = child.props as {
children?: React.ReactNode
}
React.Children.forEach(
subContentProps.children,
(contentChild) => {
result.push(contentChild)
}
)
}
})
return
}
}
if (props.children) {
React.Children.forEach(props.children, findSubmenuContent)
}
}
React.Children.forEach(elements, findSubmenuContent)
return result
},
[]
)
// Get submenu content (always extract fresh to reflect state changes)
const getSubmenuContent = React.useCallback(
(id: string) => {
// Always extract fresh content to ensure state changes are reflected
const submenuContent = extractSubmenuContent(children, id)
return submenuContent
},
[children, extractSubmenuContent]
)
if (isMobile) {
return (
<SubmenuContext.Provider
value={{
activeSubmenu,
setActiveSubmenu: (id) => {
if (id === null) {
setActiveSubmenu(null)
setSubmenuTitle(null)
setSubmenuStack([])
}
},
submenuTitle,
setSubmenuTitle,
navigateToSubmenu,
registerSubmenuContent,
}}
>
<DrawerContent
data-slot="drop-drawer-content"
className={cn(
'max-h-[90vh] w-full overflow-hidden max-w-none',
className
)}
{...props}
>
{activeSubmenu ? (
<>
<DrawerHeader>
<div className="flex items-center gap-2">
<button
onClick={goBack}
className="hover:bg-muted/50 rounded-full "
>
<ChevronLeftIcon className="h-5 w-5 text-main-view-fg/50" />
</button>
<DrawerTitle className="text-main-view-fg/80 text-sm">
{submenuTitle || 'Submenu'}
</DrawerTitle>
</div>
</DrawerHeader>
<div className="flex-1 relative overflow-hidden max-h-[70vh]">
{/* Use AnimatePresence to handle exit animations */}
<AnimatePresence
initial={false}
mode="wait"
custom={animationDirection}
>
<motion.div
key={activeSubmenu || 'main'}
custom={animationDirection}
variants={ANIMATION_CONFIG.variants}
initial="enter"
animate="center"
exit="exit"
transition={ANIMATION_CONFIG.transition as any}
className="pb-6 space-y-1.5 w-full h-full overflow-hidden"
>
{activeSubmenu
? getSubmenuContent(activeSubmenu)
: children}
</motion.div>
</AnimatePresence>
</div>
</>
) : (
<>
<DrawerHeader className="sr-only">
<DrawerTitle>Menu</DrawerTitle>
</DrawerHeader>
<div className="overflow-hidden max-h-[70vh]">
<AnimatePresence
initial={false}
mode="wait"
custom={animationDirection}
>
<motion.div
key="main-menu"
custom={animationDirection}
variants={ANIMATION_CONFIG.variants}
initial="enter"
animate="center"
exit="exit"
transition={ANIMATION_CONFIG.transition as any}
className="pb-6 space-y-1.5 w-full overflow-hidden"
>
{children}
</motion.div>
</AnimatePresence>
</div>
</>
)}
</DrawerContent>
</SubmenuContext.Provider>
)
}
return (
<SubmenuContext.Provider
value={{
activeSubmenu,
setActiveSubmenu,
submenuTitle,
setSubmenuTitle,
navigateToSubmenu,
registerSubmenuContent,
}}
>
<DropdownMenuContent
data-slot="drop-drawer-content"
sideOffset={4}
className={cn(
'max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[220px] overflow-hidden',
className
)}
{...props}
>
{children}
</DropdownMenuContent>
</SubmenuContext.Provider>
)
}
function DropDrawerItem({
className,
children,
onSelect,
onClick,
icon,
variant = 'default',
inset,
disabled,
...props
}: React.ComponentProps<typeof DropdownMenuItem> & {
icon?: React.ReactNode
}) {
const { isMobile } = useComponentSelection()
const { useGroupState } = useGroupDetection()
const { itemRef, isInsideGroup } = useGroupState()
if (isMobile) {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (disabled) return
// If this item only has an icon (like a switch) and no other interactive content,
// don't handle clicks on the main area - let the icon handle everything
if (icon && !onClick && !onSelect) {
return
}
// Check if the click came from the icon area (where the Switch is)
const target = e.target as HTMLElement
const iconContainer = (e.currentTarget as HTMLElement).querySelector(
'[data-icon-container]'
)
if (iconContainer && iconContainer.contains(target)) {
// Don't handle the click if it came from the icon area
return
}
if (onClick) onClick(e)
if (onSelect) onSelect(e as unknown as Event)
}
// Only wrap in DrawerClose if it's not a submenu item
const content = (
<div
ref={itemRef}
data-slot="drop-drawer-item"
data-variant={variant}
data-inset={inset}
data-disabled={disabled}
className={cn(
getMobileItemStyles(isInsideGroup, inset, variant, disabled),
className
)}
onClick={handleClick}
aria-disabled={disabled}
{...props}
>
<div className="flex items-center gap-2">{children}</div>
{icon && (
<div className="flex-shrink-0" data-icon-container>
{icon}
</div>
)}
</div>
)
// Check if this is inside a submenu
const isInSubmenu =
(props as Record<string, unknown>)['data-parent-submenu-id'] ||
(props as Record<string, unknown>)['data-parent-submenu']
if (isInSubmenu) {
return content
}
return <DrawerClose asChild>{content}</DrawerClose>
}
return (
<DropdownMenuItem
data-slot="drop-drawer-item"
data-variant={variant}
data-inset={inset}
className={className}
onSelect={onSelect}
onClick={onClick as React.MouseEventHandler<HTMLDivElement>}
variant={variant}
inset={inset}
disabled={disabled}
{...props}
>
<div className="flex w-full items-start justify-between gap-4">
<div>{children}</div>
{icon && <div>{icon}</div>}
</div>
</DropdownMenuItem>
)
}
function DropDrawerSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
const { isMobile } = useComponentSelection()
if (isMobile) {
return null
}
return (
<DropdownMenuSeparator
data-slot="drop-drawer-separator"
className={className}
{...props}
/>
)
}
function DropDrawerLabel({
className,
children,
...props
}:
| React.ComponentProps<typeof DropdownMenuLabel>
| React.ComponentProps<typeof DrawerTitle>) {
const { isMobile } = useComponentSelection()
if (isMobile) {
return (
<DrawerHeader className="p-0">
<DrawerTitle
data-slot="drop-drawer-label"
className={cn(
'px-4 py-2 text-sm font-medium text-main-view-fg/60',
className
)}
{...props}
>
{children}
</DrawerTitle>
</DrawerHeader>
)
}
return (
<DropdownMenuLabel
data-slot="drop-drawer-label"
className={className}
{...props}
>
{children}
</DropdownMenuLabel>
)
}
function DropDrawerFooter({
className,
children,
...props
}: React.ComponentProps<typeof DrawerFooter> | React.ComponentProps<'div'>) {
const { isMobile } = useDropDrawerContext()
if (isMobile) {
return (
<DrawerFooter
data-slot="drop-drawer-footer"
className={cn('p-4', className)}
{...props}
>
{children}
</DrawerFooter>
)
}
// No direct equivalent in DropdownMenu, so we'll just render a div
return (
<div
data-slot="drop-drawer-footer"
className={cn('p-2', className)}
{...props}
>
{children}
</div>
)
}
function DropDrawerGroup({
className,
children,
...props
}: React.ComponentProps<'div'> & {
children: React.ReactNode
}) {
const { isMobile } = useDropDrawerContext()
// Add separators between children on mobile
const childrenWithSeparators = React.useMemo(() => {
if (!isMobile) return children
const childArray = React.Children.toArray(children)
// Filter out any existing separators
const filteredChildren = childArray.filter(
(child) =>
React.isValidElement(child) && child.type !== DropDrawerSeparator
)
// Add separators between items
return filteredChildren.flatMap((child, index) => {
if (index === filteredChildren.length - 1) return [child]
return [
child,
<div
key={`separator-${index}`}
className="bg-border h-px"
aria-hidden="true"
/>,
]
})
}, [children, isMobile])
if (isMobile) {
return (
<div
data-drop-drawer-group
data-slot="drop-drawer-group"
role="group"
className={cn(
'bg-main-view-fg/2 border border-main-view-fg/4 mx-2 my-3 overflow-hidden rounded-xl',
className
)}
{...props}
>
{childrenWithSeparators}
</div>
)
}
// On desktop, use a div with proper role and attributes
return (
<div
data-drop-drawer-group
data-slot="drop-drawer-group"
role="group"
className={className}
{...props}
>
{children}
</div>
)
}
// Context for managing submenu state on mobile
interface SubmenuContextType {
activeSubmenu: string | null
setActiveSubmenu: (id: string | null) => void
submenuTitle: string | null
setSubmenuTitle: (title: string | null) => void
navigateToSubmenu?: (id: string, title: string) => void
registerSubmenuContent?: (id: string, content: React.ReactNode[]) => void
}
const SubmenuContext = React.createContext<SubmenuContextType>({
activeSubmenu: null,
setActiveSubmenu: () => {},
submenuTitle: null,
setSubmenuTitle: () => {},
navigateToSubmenu: undefined,
registerSubmenuContent: undefined,
})
// Submenu components
// Counter for generating simple numeric IDs
let submenuIdCounter = 0
function DropDrawerSub({
children,
id,
title,
...props
}: React.ComponentProps<typeof DropdownMenuSub> & {
id?: string
title?: string
}) {
const { isMobile } = useDropDrawerContext()
const { registerSubmenuContent } = React.useContext(SubmenuContext)
// Generate a simple numeric ID instead of using React.useId()
const [generatedId] = React.useState(() => `submenu-${submenuIdCounter++}`)
const submenuId = id || generatedId
// Extract submenu content to register with parent
React.useEffect(() => {
if (!registerSubmenuContent) return
// Find the SubContent within this Sub
const contentItems: React.ReactNode[] = []
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === DropDrawerSubContent) {
// Add all children of the SubContent to the result
React.Children.forEach(
(child.props as { children?: React.ReactNode }).children,
(contentChild) => {
contentItems.push(contentChild)
}
)
}
})
// Register the content with the parent
if (contentItems.length > 0) {
registerSubmenuContent(submenuId, contentItems)
}
}, [children, registerSubmenuContent, submenuId])
if (isMobile) {
// For mobile, we'll use the context to manage submenu state
// Process children to pass the submenu ID to the trigger and content
const processedChildren = React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child
if (child.type === DropDrawerSubTrigger) {
return React.cloneElement(
child as React.ReactElement,
{
...(child.props as object),
'data-parent-submenu-id': submenuId,
'data-submenu-id': submenuId,
// Use only data attributes, not custom props
'data-parent-submenu': submenuId,
'data-submenu-title': title,
} as React.HTMLAttributes<HTMLElement>
)
}
if (child.type === DropDrawerSubContent) {
return React.cloneElement(
child as React.ReactElement,
{
...(child.props as object),
'data-parent-submenu-id': submenuId,
'data-submenu-id': submenuId,
// Use only data attributes, not custom props
'data-parent-submenu': submenuId,
} as React.HTMLAttributes<HTMLElement>
)
}
return child
})
return (
<div
data-slot="drop-drawer-sub"
data-submenu-id={submenuId}
id={submenuId}
>
{processedChildren}
</div>
)
}
// For desktop, use the standard DropdownMenuSub
return <DropdownMenuSub {...props}>{children}</DropdownMenuSub>
}
function DropDrawerSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
icon?: React.ReactNode
}) {
const { isMobile } = useComponentSelection()
const { navigateToSubmenu } = React.useContext(SubmenuContext)
const { useGroupState } = useGroupDetection()
const { itemRef, isInsideGroup } = useGroupState()
if (isMobile) {
// Find the parent submenu ID
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// Get the closest parent with data-submenu-id attribute
const element = e.currentTarget as HTMLElement
let submenuId: string | null = null
// First check if the element itself has the data attribute
if (element.closest('[data-submenu-id]')) {
const closestElement = element.closest('[data-submenu-id]')
const id = closestElement?.getAttribute('data-submenu-id')
if (id) {
submenuId = id
}
}
// If not found, try props
if (!submenuId) {
submenuId =
((props as Record<string, unknown>)[
'data-parent-submenu-id'
] as string) ||
((props as Record<string, unknown>)['data-parent-submenu'] as string)
}
if (!submenuId) {
return
}
// Get the title - first try data attribute, then children, then fallback
const dataTitle = (props as Record<string, unknown>)[
'data-submenu-title'
] as string
const title =
dataTitle || (typeof children === 'string' ? children : 'Submenu')
// Navigate to the submenu
if (navigateToSubmenu) {
navigateToSubmenu(submenuId, title)
}
}
// Combine onClick handlers
const combinedOnClick = (e: React.MouseEvent) => {
// Call the original onClick if provided
const typedProps = props as Record<string, unknown>
if (typedProps.onClick) {
const originalOnClick =
typedProps.onClick as React.MouseEventHandler<HTMLDivElement>
originalOnClick(e as React.MouseEvent<HTMLDivElement>)
}
// Call our navigation handler
handleClick(e)
}
// Remove onClick from props to avoid duplicate handlers
const { ...restProps } = props as Record<string, unknown>
// Don't wrap in DrawerClose for submenu triggers
return (
<div
ref={itemRef}
data-slot="drop-drawer-sub-trigger"
data-inset={inset}
className={cn(getMobileItemStyles(isInsideGroup, inset), className)}
onClick={combinedOnClick}
{...restProps}
>
<div className="flex items-center gap-2 w-full">{children}</div>
<ChevronRightIcon className="h-5 w-5 text-main-view-fg/50 " />
</div>
)
}
return (
<DropdownMenuSubTrigger
data-slot="drop-drawer-sub-trigger"
data-inset={inset}
className={className}
inset={inset}
{...props}
>
{children}
</DropdownMenuSubTrigger>
)
}
function DropDrawerSubContent({
className,
sideOffset = 4,
children,
...props
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
const { isMobile } = useDropDrawerContext()
if (isMobile) {
// For mobile, we don't render the content directly
// It will be rendered by the DropDrawerContent component when active
return null
}
return (
<DropdownMenuSubContent
data-slot="drop-drawer-sub-content"
sideOffset={sideOffset}
className={className}
{...props}
>
{children}
</DropdownMenuSubContent>
)
}
export {
DropDrawer,
DropDrawerContent,
DropDrawerFooter,
DropDrawerGroup,
DropDrawerItem,
DropDrawerLabel,
DropDrawerSeparator,
DropDrawerSub,
DropDrawerSubContent,
DropDrawerSubTrigger,
DropDrawerTrigger,
}

View File

@ -0,0 +1,42 @@
import * as React from 'react'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square size-4 rounded-full border border-main-view-fg/20 text-main-view-fg ring-offset-main-view focus:outline-none focus-visible:ring-2 focus-visible:ring-main-view-fg/50 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<CircleIcon className="size-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -483,7 +483,9 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
initialMessage={initialMessage} initialMessage={initialMessage}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
setDropdownToolsAvailable(isOpen) setDropdownToolsAvailable(isOpen)
setTooltipToolsAvailable(false) if (isOpen) {
setTooltipToolsAvailable(false)
}
}} }}
> >
{(isOpen, toolsCount) => { {(isOpen, toolsCount) => {

View File

@ -471,7 +471,10 @@ const DropdownModelProvider = ({
)} )}
> >
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate text-main-view-fg/80 text-sm"> <span
className="truncate text-main-view-fg/80 text-sm"
title={searchableModel.model.id}
>
{searchableModel.model.id} {searchableModel.model.id}
</span> </span>

View File

@ -1,12 +1,18 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
DropdownMenu, DropDrawer,
DropdownMenuContent, DropDrawerContent,
DropdownMenuItem, DropDrawerItem,
DropdownMenuLabel, DropDrawerSub,
DropdownMenuSeparator, DropDrawerLabel,
DropdownMenuTrigger, DropDrawerSubContent,
} from '@/components/ui/dropdown-menu' DropDrawerSeparator,
DropDrawerSubTrigger,
DropDrawerTrigger,
DropDrawerGroup,
} from '@/components/ui/dropdrawer'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { useThreads } from '@/hooks/useThreads' import { useThreads } from '@/hooks/useThreads'
@ -15,6 +21,7 @@ import { useToolAvailable } from '@/hooks/useToolAvailable'
import React from 'react' import React from 'react'
import { useAppState } from '@/hooks/useAppState' import { useAppState } from '@/hooks/useAppState'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { cn } from '@/lib/utils'
interface DropdownToolsAvailableProps { interface DropdownToolsAvailableProps {
children: (isOpen: boolean, toolsCount: number) => React.ReactNode children: (isOpen: boolean, toolsCount: number) => React.ReactNode
@ -82,6 +89,23 @@ export default function DropdownToolsAvailable({
return false return false
} }
const handleDisableAllServerTools = (
serverName: string,
disable: boolean
) => {
const allToolsByServer = getToolsByServer()
const serverTools = allToolsByServer[serverName] || []
serverTools.forEach((tool) => {
handleToolToggle(tool.name, !disable)
})
}
const areAllServerToolsDisabled = (serverName: string): boolean => {
const allToolsByServer = getToolsByServer()
const serverTools = allToolsByServer[serverName] || []
return serverTools.every((tool) => !isToolChecked(tool.name))
}
const getEnabledToolsCount = (): number => { const getEnabledToolsCount = (): number => {
const disabledTools = initialMessage const disabledTools = initialMessage
? getDefaultDisabledTools() ? getDefaultDisabledTools()
@ -91,69 +115,153 @@ export default function DropdownToolsAvailable({
return tools.filter((tool) => !disabledTools.includes(tool.name)).length return tools.filter((tool) => !disabledTools.includes(tool.name)).length
} }
const getToolsByServer = () => {
const toolsByServer = tools.reduce(
(acc, tool) => {
if (!acc[tool.server]) {
acc[tool.server] = []
}
acc[tool.server].push(tool)
return acc
},
{} as Record<string, typeof tools>
)
return toolsByServer
}
const renderTrigger = () => children(isOpen, getEnabledToolsCount()) const renderTrigger = () => children(isOpen, getEnabledToolsCount())
if (tools.length === 0) { if (tools.length === 0) {
return ( return (
<DropdownMenu onOpenChange={handleOpenChange}> <DropDrawer onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>{renderTrigger()}</DropdownMenuTrigger> <DropDrawerTrigger asChild>{renderTrigger()}</DropDrawerTrigger>
<DropdownMenuContent align="start" className="max-w-64"> <DropDrawerContent align="start" className="max-w-64">
<DropdownMenuItem disabled>{t('common:noToolsAvailable')}</DropdownMenuItem> <DropDrawerItem disabled>
</DropdownMenuContent> {t('common:noToolsAvailable')}
</DropdownMenu> </DropDrawerItem>
</DropDrawerContent>
</DropDrawer>
) )
} }
return ( const toolsByServer = getToolsByServer()
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>{renderTrigger()}</DropdownMenuTrigger>
<DropdownMenuContent return (
<DropDrawer onOpenChange={handleOpenChange}>
<DropDrawerTrigger asChild>{renderTrigger()}</DropDrawerTrigger>
<DropDrawerContent
side="top" side="top"
align="start" align="start"
className="max-w-64 backdrop-blur-xl bg-main-view" className="bg-main-view !overflow-hidden"
onClick={(e) => e.stopPropagation()}
> >
<DropdownMenuLabel className="flex items-center gap-2 sticky -top-1 z-10 px-4 pl-2 py-2 "> <DropDrawerLabel className="flex items-center gap-2 sticky -top-1 z-10 px-4 pl-2 py-1">
Available Tools Available Tools
</DropdownMenuLabel> </DropDrawerLabel>
<DropdownMenuSeparator /> <DropDrawerSeparator />
<div className="max-h-64 overflow-y-auto"> <div className="max-h-64 overflow-y-auto">
{tools.map((tool) => { <DropDrawerGroup>
const isChecked = isToolChecked(tool.name) {Object.entries(toolsByServer).map(([serverName, serverTools]) => (
return ( <DropDrawerSub
<div id={`server-${serverName}`}
key={tool.name} key={serverName}
className="py-2 hover:bg-main-view-fg/5 hover:backdrop-blur-2xl rounded-sm px-2 mx-auto w-full" title={serverName}
> >
<div className="flex items-start justify-center gap-3"> <DropDrawerSubTrigger className="py-2 hover:bg-main-view-fg/5 hover:backdrop-blur-2xl rounded-sm px-2 mx-auto w-full">
<div className="flex items-start justify-between gap-4 w-full"> <div className="flex items-center justify-between w-full">
<div className="overflow-hidden w-full flex flex-col "> <span className="text-sm text-main-view-fg/80">
<div className="truncate"> {serverName}
<span className="text-sm font-medium" title={tool.name}> </span>
{tool.name} <span className="text-xs text-main-view-fg/50 inline-flex items-center mr-1 border border-main-view-fg/20 px-1 rounded-sm">
</span> {
</div> serverTools.filter((tool) => isToolChecked(tool.name))
{tool.description && ( .length
<p className="text-xs text-main-view-fg/70 mt-1 line-clamp-2"> }
{tool.description} </span>
</p>
)}
</div>
<div className="shrink-0 mx-auto">
<Switch
checked={isChecked}
onCheckedChange={(checked) =>
handleToolToggle(tool.name, checked)
}
/>
</div>
</div> </div>
</div> </DropDrawerSubTrigger>
</div> <DropDrawerSubContent className="max-w-64 max-h-70 w-full overflow-hidden">
) <DropDrawerGroup>
})} {serverTools.length > 1 && (
<div className="sticky top-0 z-10 bg-main-view border-b border-main-view-fg/10 px-4 md:px-2 pr-2 py-1.5 flex items-center justify-between">
<span className="text-xs font-medium text-main-view-fg/70">
Disable All Tools
</span>
<div
className={cn(
'flex items-center gap-2',
serverTools.length > 5
? 'mr-3 md:mr-1.5'
: 'mr-2 md:mr-0'
)}
>
<Switch
checked={!areAllServerToolsDisabled(serverName)}
onCheckedChange={(checked) =>
handleDisableAllServerTools(serverName, !checked)
}
/>
</div>
</div>
)}
<div className="max-h-56 overflow-y-auto">
{serverTools.map((tool) => {
const isChecked = isToolChecked(tool.name)
return (
<DropDrawerItem
onClick={(e) => {
handleToolToggle(tool.name, !isChecked)
e.preventDefault()
}}
onSelect={(e) => {
handleToolToggle(tool.name, !isChecked)
e.preventDefault()
}}
key={tool.name}
className="mt-1 first:mt-0 py-1.5"
icon={
<Switch
checked={isChecked}
onCheckedChange={(checked) => {
console.log('checked', checked)
handleToolToggle(tool.name, checked)
}}
onClick={(e) => {
e.stopPropagation()
}}
/>
}
>
<div className="overflow-hidden flex flex-col items-start ">
<div className="truncate">
<span
className="text-sm font-medium text-main-view-fg"
title={tool.name}
>
{tool.name}
</span>
</div>
{tool.description && (
<p
className="text-xs text-main-view-fg/70 mt-1 line-clamp-1"
title={tool.description}
>
{tool.description}
</p>
)}
</div>
</DropDrawerItem>
)
})}
</div>
</DropDrawerGroup>
</DropDrawerSubContent>
</DropDrawerSub>
))}
</DropDrawerGroup>
</div> </div>
</DropdownMenuContent> </DropDrawerContent>
</DropdownMenu> </DropDrawer>
) )
} }

View File

@ -21,15 +21,15 @@ export default function GlobalError({ error }: GlobalErrorProps) {
> >
<path <path
d="M6 8H6.01M6 16H6.01M6 12H18C20.2091 12 22 10.2091 22 8C22 5.79086 20.2091 4 18 4H6C3.79086 4 2 5.79086 2 8C2 10.2091 3.79086 12 6 12ZM6 12C3.79086 12 2 13.7909 2 16C2 18.2091 3.79086 20 6 20H14" d="M6 8H6.01M6 16H6.01M6 12H18C20.2091 12 22 10.2091 22 8C22 5.79086 20.2091 4 18 4H6C3.79086 4 2 5.79086 2 8C2 10.2091 3.79086 12 6 12ZM6 12C3.79086 12 2 13.7909 2 16C2 18.2091 3.79086 20 6 20H14"
stroke-width="2" strokeWidth="2"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
></path> ></path>
<path <path
d="M17 16L22 21M22 16L17 21" d="M17 16L22 21M22 16L17 21"
stroke-width="2" strokeWidth="2"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
></path> ></path>
</svg> </svg>
</div> </div>

View File

@ -8,7 +8,13 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { IconPlus, IconTrash, IconGripVertical } from '@tabler/icons-react' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
IconPlus,
IconTrash,
IconGripVertical,
IconCodeDots,
} from '@tabler/icons-react'
import { MCPServerConfig } from '@/hooks/useMCPServers' import { MCPServerConfig } from '@/hooks/useMCPServers'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { import {
@ -27,6 +33,8 @@ import {
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import CodeEditor from '@uiw/react-textarea-code-editor'
import '@uiw/react-textarea-code-editor/dist.css'
// Sortable argument item component // Sortable argument item component
function SortableArgItem({ function SortableArgItem({
@ -114,13 +122,34 @@ export default function AddEditMCPServer({
const [args, setArgs] = useState<string[]>(['']) const [args, setArgs] = useState<string[]>([''])
const [envKeys, setEnvKeys] = useState<string[]>(['']) const [envKeys, setEnvKeys] = useState<string[]>([''])
const [envValues, setEnvValues] = useState<string[]>(['']) const [envValues, setEnvValues] = useState<string[]>([''])
const [transportType, setTransportType] = useState<'stdio' | 'http' | 'sse'>(
'stdio'
)
const [url, setUrl] = useState('')
const [headerKeys, setHeaderKeys] = useState<string[]>([''])
const [headerValues, setHeaderValues] = useState<string[]>([''])
const [timeout, setTimeout] = useState('')
const [isToggled, setIsToggled] = useState(false)
const [jsonContent, setJsonContent] = useState('')
const [error, setError] = useState<string | null>(null)
// Reset form when modal opens/closes or editing key changes // Reset form when modal opens/closes or editing key changes
useEffect(() => { useEffect(() => {
if (open && editingKey && initialData) { if (open && editingKey && initialData) {
setServerName(editingKey) setServerName(editingKey)
setCommand(initialData.command) setCommand(initialData.command || '')
setUrl(initialData.url || '')
setTimeout(initialData.timeout ? initialData.timeout.toString() : '')
setArgs(initialData.args?.length > 0 ? initialData.args : ['']) setArgs(initialData.args?.length > 0 ? initialData.args : [''])
setTransportType(initialData?.type || 'stdio')
// Initialize JSON content for toggle mode
try {
const jsonData = { [editingKey]: initialData }
setJsonContent(JSON.stringify(jsonData, null, 2))
} catch {
setJsonContent('')
}
if (initialData.env) { if (initialData.env) {
// Convert env object to arrays of keys and values // Convert env object to arrays of keys and values
@ -130,6 +159,17 @@ export default function AddEditMCPServer({
setEnvKeys(keys.length > 0 ? keys : ['']) setEnvKeys(keys.length > 0 ? keys : [''])
setEnvValues(values.length > 0 ? values : ['']) setEnvValues(values.length > 0 ? values : [''])
} }
if (initialData.headers) {
// Convert headers object to arrays of keys and values
const headerKeysList = Object.keys(initialData.headers)
const headerValuesList = headerKeysList.map(
(key) => initialData.headers![key]
)
setHeaderKeys(headerKeysList.length > 0 ? headerKeysList : [''])
setHeaderValues(headerValuesList.length > 0 ? headerValuesList : [''])
}
} else if (open) { } else if (open) {
// Add mode - reset form // Add mode - reset form
resetForm() resetForm()
@ -139,9 +179,17 @@ export default function AddEditMCPServer({
const resetForm = () => { const resetForm = () => {
setServerName('') setServerName('')
setCommand('') setCommand('')
setUrl('')
setTimeout('')
setArgs(['']) setArgs([''])
setEnvKeys(['']) setEnvKeys([''])
setEnvValues(['']) setEnvValues([''])
setHeaderKeys([''])
setHeaderValues([''])
setTransportType('stdio')
setIsToggled(false)
setJsonContent('')
setError(null)
} }
const handleAddArg = () => { const handleAddArg = () => {
@ -201,7 +249,57 @@ export default function AddEditMCPServer({
setEnvValues(newValues) setEnvValues(newValues)
} }
const handleAddHeader = () => {
setHeaderKeys([...headerKeys, ''])
setHeaderValues([...headerValues, ''])
}
const handleRemoveHeader = (index: number) => {
const newKeys = [...headerKeys]
const newValues = [...headerValues]
newKeys.splice(index, 1)
newValues.splice(index, 1)
setHeaderKeys(newKeys.length > 0 ? newKeys : [''])
setHeaderValues(newValues.length > 0 ? newValues : [''])
}
const handleHeaderKeyChange = (index: number, value: string) => {
const newKeys = [...headerKeys]
newKeys[index] = value
setHeaderKeys(newKeys)
}
const handleHeaderValueChange = (index: number, value: string) => {
const newValues = [...headerValues]
newValues[index] = value
setHeaderValues(newValues)
}
const handleSave = () => { const handleSave = () => {
// Handle JSON mode
if (isToggled) {
try {
const parsedData = JSON.parse(jsonContent)
// Validate that it's an object with server configurations
if (typeof parsedData !== 'object' || parsedData === null) {
setError(t('mcp-servers:editJson.errorFormat'))
return
}
// For each server in the JSON, call onSave
Object.entries(parsedData).forEach(([serverName, config]) => {
onSave(serverName.trim(), config as MCPServerConfig)
})
onOpenChange(false)
resetForm()
setError(null)
return
} catch {
setError(t('mcp-servers:editJson.errorFormat'))
return
}
}
// Handle form mode
// Convert env arrays to object // Convert env arrays to object
const envObj: Record<string, string> = {} const envObj: Record<string, string> = {}
envKeys.forEach((key, index) => { envKeys.forEach((key, index) => {
@ -211,13 +309,28 @@ export default function AddEditMCPServer({
} }
}) })
// Convert headers arrays to object
const headersObj: Record<string, string> = {}
headerKeys.forEach((key, index) => {
const keyName = key.trim()
if (keyName !== '') {
headersObj[keyName] = headerValues[index]?.trim() || ''
}
})
// Filter out empty args // Filter out empty args
const filteredArgs = args.map((arg) => arg.trim()).filter((arg) => arg) const filteredArgs = args.map((arg) => arg.trim()).filter((arg) => arg)
const config: MCPServerConfig = { const config: MCPServerConfig = {
command: command.trim(), command: transportType === 'stdio' ? command.trim() : '',
args: filteredArgs, args: transportType === 'stdio' ? filteredArgs : [],
env: envObj, env: transportType === 'stdio' ? envObj : {},
type: transportType,
...(transportType !== 'stdio' && {
url: url.trim(),
headers: Object.keys(headersObj).length > 0 ? headersObj : undefined,
timeout: timeout.trim() !== '' ? parseInt(timeout) : undefined,
}),
} }
if (serverName.trim() !== '') { if (serverName.trim() !== '') {
@ -229,121 +342,297 @@ export default function AddEditMCPServer({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle className="flex items-center justify-between">
{editingKey <span>
? t('mcp-servers:editServer') {editingKey
: t('mcp-servers:addServer')} ? t('mcp-servers:editServer')
: t('mcp-servers:addServer')}
</span>
<div
className={cn(
'size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out',
isToggled && 'bg-main-view-fg/10 text-accent'
)}
title="Add server by JSON"
onClick={() => setIsToggled(!isToggled)}
>
<IconCodeDots className="h-5 w-5 cursor-pointer transition-colors duration-200" />
</div>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> {isToggled ? (
<div className="space-y-2"> <div className="space-y-4">
<label className="text-sm mb-2 inline-block"> <div className="space-y-2">
{t('mcp-servers:serverName')} <label className="text-sm mb-2 inline-block">
</label> {t('mcp-servers:editJson.placeholder')}
<Input </label>
value={serverName} <div className="border border-main-view-fg/10 rounded-md overflow-hidden">
onChange={(e) => setServerName(e.target.value)} <CodeEditor
placeholder={t('mcp-servers:enterServerName')} value={jsonContent}
autoFocus language="json"
/> placeholder={`{
</div> "serverName": {
"command": "command",
<div className="space-y-2"> "args": ["arg1", "arg2"],
<label className="text-sm mb-2 inline-block"> "env": {
{t('mcp-servers:command')} "KEY": "value"
</label> }
<Input }
value={command} }`}
onChange={(e) => setCommand(e.target.value)} onChange={(e) => {
placeholder={t('mcp-servers:enterCommand')} setJsonContent(e.target.value)
/> setError(null)
</div> }}
onPaste={() => setError(null)}
<div className="space-y-2"> style={{
<div className="flex items-center justify-between"> fontFamily: 'ui-monospace',
<label className="text-sm">{t('mcp-servers:arguments')}</label> backgroundColor: 'transparent',
<div wordBreak: 'break-all',
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out" overflowWrap: 'anywhere',
onClick={handleAddArg} whiteSpace: 'pre-wrap',
> }}
<IconPlus size={18} className="text-main-view-fg/60" /> className="w-full !text-sm min-h-[300px]"
/>
</div> </div>
{error && <div className="text-destructive text-sm">{error}</div>}
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">
{t('mcp-servers:serverName')}
</label>
<Input
value={serverName}
onChange={(e) => setServerName(e.target.value)}
placeholder={t('mcp-servers:enterServerName')}
autoFocus
/>
</div> </div>
<DndContext <div className="space-y-2">
sensors={sensors} <label className="text-sm mb-2 inline-block">
collisionDetection={closestCenter} Transport Type
onDragEnd={(event) => { </label>
const { active, over } = event <RadioGroup
if (active.id !== over?.id) { value={transportType}
const oldIndex = parseInt(active.id.toString()) onValueChange={(value) =>
const newIndex = parseInt(over?.id.toString() || '0') setTransportType(value as 'http' | 'sse')
handleReorderArgs(oldIndex, newIndex)
} }
}} className="flex gap-6"
>
<SortableContext
items={args.map((_, index) => index)}
strategy={verticalListSortingStrategy}
> >
{args.map((arg, index) => ( <div className="flex items-center space-x-2">
<SortableArgItem <RadioGroupItem value="stdio" id="stdio" />
key={index} <label
id={index} htmlFor="stdio"
value={arg} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
onChange={(value) => handleArgChange(index, value)} >
onRemove={() => handleRemoveArg(index)} STDIO
canRemove={args.length > 1} </label>
placeholder={t('mcp-servers:argument', { </div>
index: index + 1, <div className="flex items-center space-x-2">
})} <RadioGroupItem value="http" id="http" />
/> <label
))} htmlFor="http"
</SortableContext> className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
</DndContext> >
</div> HTTP
</label>
<div className="space-y-2"> </div>
<div className="flex items-center justify-between"> <div className="flex items-center space-x-2">
<label className="text-sm">{t('mcp-servers:envVars')}</label> <RadioGroupItem value="sse" id="sse" />
<div <label
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out" htmlFor="sse"
onClick={handleAddEnv} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
<IconPlus size={18} className="text-main-view-fg/60" /> SSE
</div> </label>
</div>
</RadioGroup>
</div> </div>
{envKeys.map((key, index) => ( {transportType === 'stdio' ? (
<div key={`env-${index}`} className="flex items-center gap-2"> <div className="space-y-2">
<label className="text-sm mb-2 inline-block">
{t('mcp-servers:command')}
</label>
<Input <Input
value={key} value={command}
onChange={(e) => handleEnvKeyChange(index, e.target.value)} onChange={(e) => setCommand(e.target.value)}
placeholder={t('mcp-servers:key')} placeholder={t('mcp-servers:enterCommand')}
className="flex-1"
/> />
</div>
) : (
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">URL</label>
<Input <Input
value={envValues[index] || ''} value={url}
onChange={(e) => handleEnvValueChange(index, e.target.value)} onChange={(e) => setUrl(e.target.value)}
placeholder={t('mcp-servers:value')} placeholder="Enter URL"
className="flex-1"
/> />
{envKeys.length > 1 && ( </div>
)}
{transportType === 'stdio' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm">
{t('mcp-servers:arguments')}
</label>
<div <div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out" className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={() => handleRemoveEnv(index)} onClick={handleAddArg}
> >
<IconTrash size={18} className="text-destructive" /> <IconPlus size={18} className="text-main-view-fg/60" />
</div> </div>
)} </div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = parseInt(active.id.toString())
const newIndex = parseInt(over?.id.toString() || '0')
handleReorderArgs(oldIndex, newIndex)
}
}}
>
<SortableContext
items={args.map((_, index) => index)}
strategy={verticalListSortingStrategy}
>
{args.map((arg, index) => (
<SortableArgItem
key={index}
id={index}
value={arg}
onChange={(value) => handleArgChange(index, value)}
onRemove={() => handleRemoveArg(index)}
canRemove={args.length > 1}
placeholder={t('mcp-servers:argument', {
index: index + 1,
})}
/>
))}
</SortableContext>
</DndContext>
</div> </div>
))} )}
{transportType === 'stdio' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm">{t('mcp-servers:envVars')}</label>
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={handleAddEnv}
>
<IconPlus size={18} className="text-main-view-fg/60" />
</div>
</div>
{envKeys.map((key, index) => (
<div key={`env-${index}`} className="flex items-center gap-2">
<Input
value={key}
onChange={(e) =>
handleEnvKeyChange(index, e.target.value)
}
placeholder={t('mcp-servers:key')}
className="flex-1"
/>
<Input
value={envValues[index] || ''}
onChange={(e) =>
handleEnvValueChange(index, e.target.value)
}
placeholder={t('mcp-servers:value')}
className="flex-1"
/>
{envKeys.length > 1 && (
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={() => handleRemoveEnv(index)}
>
<IconTrash size={18} className="text-destructive" />
</div>
)}
</div>
))}
</div>
)}
{(transportType === 'http' || transportType === 'sse') && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm">Headers</label>
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={handleAddHeader}
>
<IconPlus size={18} className="text-main-view-fg/60" />
</div>
</div>
{headerKeys.map((key, index) => (
<div
key={`header-${index}`}
className="flex items-center gap-2"
>
<Input
value={key}
onChange={(e) =>
handleHeaderKeyChange(index, e.target.value)
}
placeholder="Header name"
className="flex-1"
/>
<Input
value={headerValues[index] || ''}
onChange={(e) =>
handleHeaderValueChange(index, e.target.value)
}
placeholder="Header value"
className="flex-1"
/>
{headerKeys.length > 1 && (
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={() => handleRemoveHeader(index)}
>
<IconTrash size={18} className="text-destructive" />
</div>
)}
</div>
))}
</div>
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">
Timeout (seconds)
</label>
<Input
value={timeout}
onChange={(e) => setTimeout(e.target.value)}
placeholder="Enter timeout in seconds"
type="number"
/>
</div>
</>
)}
</div> </div>
</div> )}
<DialogFooter> <DialogFooter>
<Button variant="link" onClick={() => onOpenChange(false)}>
{t('common:cancel')}
</Button>
<Button onClick={handleSave}>{t('mcp-servers:save')}</Button> <Button onClick={handleSave}>{t('mcp-servers:save')}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -43,19 +43,9 @@ export default function EditJsonMCPserver({
} }
}, [open, initialData, t]) }, [open, initialData, t])
const handlePaste = (e: React.ClipboardEvent) => { const handlePaste = () => {
const pastedText = e.clipboardData.getData('text') // Clear any existing errors when pasting
try { setError(null)
const parsedJson = JSON.parse(pastedText)
const prettifiedJson = JSON.stringify(parsedJson, null, 2)
e.preventDefault()
setJsonContent(prettifiedJson)
setError(null)
} catch (error) {
e.preventDefault()
setError(t('mcp-servers:editJson.errorPaste'))
console.error('Paste error:', error)
}
} }
const handleSave = () => { const handleSave = () => {
@ -80,7 +70,18 @@ export default function EditJsonMCPserver({
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-2"> <div className="space-y-2">
<div className="border border-main-view-fg/10 rounded-md overflow-hidden"> <div className="border border-main-view-fg/10 rounded-md !overflow-hidden">
<style>{`
.w-tc-editor textarea {
word-break: break-all !important;
overflow-wrap: anywhere !important;
white-space: pre-wrap !important;
}
.w-tc-editor .token.string {
word-break: break-all !important;
overflow-wrap: anywhere !important;
}
`}</style>
<CodeEditor <CodeEditor
value={jsonContent} value={jsonContent}
language="json" language="json"
@ -90,8 +91,11 @@ export default function EditJsonMCPserver({
style={{ style={{
fontFamily: 'ui-monospace', fontFamily: 'ui-monospace',
backgroundColor: 'transparent', backgroundColor: 'transparent',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
whiteSpace: 'pre-wrap',
}} }}
className="w-full !text-sm " className="w-full !text-sm overflow-hidden break-all"
/> />
</div> </div>
{error && <div className="text-destructive text-sm">{error}</div>} {error && <div className="text-destructive text-sm">{error}</div>}

View File

@ -0,0 +1,180 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { SystemEvent } from '@/types/events'
// Mock functions
const mockGetTools = vi.fn()
const mockUpdateTools = vi.fn()
const mockListen = vi.fn()
const mockUnsubscribe = vi.fn()
// Mock the dependencies
vi.mock('@/services/mcp', () => ({
getTools: mockGetTools,
}))
vi.mock('../useAppState', () => ({
useAppState: () => ({
updateTools: mockUpdateTools,
}),
}))
vi.mock('@tauri-apps/api/event', () => ({
listen: mockListen,
}))
describe('useTools', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListen.mockResolvedValue(mockUnsubscribe)
mockGetTools.mockResolvedValue([])
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should call getTools and updateTools on mount', async () => {
const { useTools } = await import('../useTools')
const mockTools = [
{ name: 'test-tool', description: 'A test tool' },
{ name: 'another-tool', description: 'Another test tool' },
]
mockGetTools.mockResolvedValue(mockTools)
renderHook(() => useTools())
// Wait for async operations to complete
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockGetTools).toHaveBeenCalledTimes(1)
expect(mockUpdateTools).toHaveBeenCalledWith(mockTools)
})
it('should set up event listener for MCP_UPDATE', async () => {
const { useTools } = await import('../useTools')
renderHook(() => useTools())
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockListen).toHaveBeenCalledWith(
SystemEvent.MCP_UPDATE,
expect.any(Function)
)
})
it('should call setTools when MCP_UPDATE event is triggered', async () => {
const { useTools } = await import('../useTools')
const mockTools = [{ name: 'updated-tool', description: 'Updated tool' }]
mockGetTools.mockResolvedValue(mockTools)
let eventCallback: () => void
mockListen.mockImplementation((_event, callback) => {
eventCallback = callback
return Promise.resolve(mockUnsubscribe)
})
renderHook(() => useTools())
// Wait for initial setup
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
// Clear the initial calls
vi.clearAllMocks()
mockGetTools.mockResolvedValue(mockTools)
// Trigger the event
await act(async () => {
eventCallback()
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockGetTools).toHaveBeenCalledTimes(1)
expect(mockUpdateTools).toHaveBeenCalledWith(mockTools)
})
it('should return unsubscribe function for cleanup', async () => {
const { useTools } = await import('../useTools')
const { unmount } = renderHook(() => useTools())
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockListen).toHaveBeenCalled()
// Unmount should call the unsubscribe function
unmount()
expect(mockListen).toHaveBeenCalledWith(
SystemEvent.MCP_UPDATE,
expect.any(Function)
)
})
it('should handle getTools errors gracefully', async () => {
const { useTools } = await import('../useTools')
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockGetTools.mockRejectedValue(new Error('Failed to get tools'))
renderHook(() => useTools())
await act(async () => {
// Give enough time for the promise to be handled
await new Promise(resolve => setTimeout(resolve, 100))
})
expect(mockGetTools).toHaveBeenCalledTimes(1)
// updateTools should not be called if getTools fails
expect(mockUpdateTools).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should handle event listener setup errors gracefully', async () => {
const { useTools } = await import('../useTools')
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockListen.mockRejectedValue(new Error('Failed to set up listener'))
renderHook(() => useTools())
await act(async () => {
// Give enough time for the promise to be handled
await new Promise(resolve => setTimeout(resolve, 100))
})
// Initial getTools should still work
expect(mockGetTools).toHaveBeenCalledTimes(1)
expect(mockListen).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should only set up effect once with empty dependency array', async () => {
const { useTools } = await import('../useTools')
const { rerender } = renderHook(() => useTools())
// Initial render
expect(mockGetTools).toHaveBeenCalledTimes(1)
expect(mockListen).toHaveBeenCalledTimes(1)
// Rerender should not trigger additional calls
rerender()
expect(mockGetTools).toHaveBeenCalledTimes(1)
expect(mockListen).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { usePrompt } from './usePrompt' import { usePrompt } from './usePrompt'
import { useModelProvider } from './useModelProvider' import { useModelProvider } from './useModelProvider'
import { useThreads } from './useThreads' import { useThreads } from './useThreads'
@ -19,10 +19,7 @@ import {
import { CompletionMessagesBuilder } from '@/lib/messages' import { CompletionMessagesBuilder } from '@/lib/messages'
import { ChatCompletionMessageToolCall } from 'openai/resources' import { ChatCompletionMessageToolCall } from 'openai/resources'
import { useAssistant } from './useAssistant' import { useAssistant } from './useAssistant'
import { getTools } from '@/services/mcp'
import { MCPTool } from '@/types/completion'
import { listen } from '@tauri-apps/api/event'
import { SystemEvent } from '@/types/events'
import { stopModel, startModel, stopAllModels } from '@/services/models' import { stopModel, startModel, stopAllModels } from '@/services/models'
import { useToolApproval } from '@/hooks/useToolApproval' import { useToolApproval } from '@/hooks/useToolApproval'
@ -40,7 +37,6 @@ export const useChat = () => {
tools, tools,
updateTokenSpeed, updateTokenSpeed,
resetTokenSpeed, resetTokenSpeed,
updateTools,
updateStreamingContent, updateStreamingContent,
updateLoadingModel, updateLoadingModel,
setAbortController, setAbortController,
@ -77,22 +73,6 @@ export const useChat = () => {
const selectedAssistant = const selectedAssistant =
assistants.find((a) => a.id === currentAssistant.id) || assistants[0] assistants.find((a) => a.id === currentAssistant.id) || assistants[0]
useEffect(() => {
function setTools() {
getTools().then((data: MCPTool[]) => {
updateTools(data)
})
}
setTools()
let unsubscribe = () => {}
listen(SystemEvent.MCP_UPDATE, setTools).then((unsub) => {
// Unsubscribe from the event when the component unmounts
unsubscribe = unsub
})
return unsubscribe
}, [updateTools])
const getCurrentThread = useCallback(async () => { const getCurrentThread = useCallback(async () => {
let currentThread = retrieveThread() let currentThread = retrieveThread()

View File

@ -7,6 +7,10 @@ export type MCPServerConfig = {
args: string[] args: string[]
env: Record<string, string> env: Record<string, string>
active?: boolean active?: boolean
type?: 'stdio' | 'http' | 'sse'
url?: string
headers?: Record<string, string>
timeout?: number
} }
// Define the structure of all MCP servers // Define the structure of all MCP servers

View File

@ -0,0 +1,31 @@
import { useEffect } from 'react'
import { getTools } from '@/services/mcp'
import { MCPTool } from '@/types/completion'
import { listen } from '@tauri-apps/api/event'
import { SystemEvent } from '@/types/events'
import { useAppState } from './useAppState'
export const useTools = () => {
const { updateTools } = useAppState()
useEffect(() => {
function setTools() {
getTools().then((data: MCPTool[]) => {
updateTools(data)
}).catch((error) => {
console.error('Failed to fetch MCP tools:', error)
})
}
setTools()
let unsubscribe = () => {}
listen(SystemEvent.MCP_UPDATE, setTools).then((unsub) => {
// Unsubscribe from the event when the component unmounts
unsubscribe = unsub
}).catch((error) => {
console.error('Failed to set up MCP update listener:', error)
})
return unsubscribe
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}

View File

@ -3,6 +3,7 @@ import { createFileRoute, useSearch } from '@tanstack/react-router'
import ChatInput from '@/containers/ChatInput' import ChatInput from '@/containers/ChatInput'
import HeaderPage from '@/containers/HeaderPage' import HeaderPage from '@/containers/HeaderPage'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { useTools } from '@/hooks/useTools'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import SetupScreen from '@/containers/SetupScreen' import SetupScreen from '@/containers/SetupScreen'
@ -31,6 +32,7 @@ function Index() {
const search = useSearch({ from: route.home as any }) const search = useSearch({ from: route.home as any })
const selectedModel = search.model const selectedModel = search.model
const { setCurrentThreadId } = useThreads() const { setCurrentThreadId } = useThreads()
useTools()
// Conditional to check if there are any valid providers // Conditional to check if there are any valid providers
// required min 1 api_key or 1 model in llama.cpp // required min 1 api_key or 1 model in llama.cpp

View File

@ -29,6 +29,58 @@ const maskSensitiveValue = (value: string) => {
return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4) return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4)
} }
// Function to mask sensitive URL parameters
const maskSensitiveUrl = (url: string) => {
if (!url) return url
try {
const urlObj = new URL(url)
const params = urlObj.searchParams
// List of sensitive parameter names (case-insensitive)
const sensitiveParams = [
'api_key',
'apikey',
'key',
'token',
'secret',
'password',
'pwd',
'auth',
'authorization',
'bearer',
'access_token',
'refresh_token',
'client_secret',
'private_key',
'signature',
'hash',
]
// Mask sensitive parameters
sensitiveParams.forEach((paramName) => {
// Check both exact match and case-insensitive match
for (const [key, value] of params.entries()) {
if (key.toLowerCase() === paramName.toLowerCase()) {
params.set(key, maskSensitiveValue(value))
}
}
})
// Reconstruct URL with masked parameters
urlObj.search = params.toString()
return urlObj.toString()
} catch {
// If URL parsing fails, just mask the entire query string after '?'
const queryIndex = url.indexOf('?')
if (queryIndex === -1) return url
const baseUrl = url.substring(0, queryIndex + 1)
const queryString = url.substring(queryIndex + 1)
return baseUrl + maskSensitiveValue(queryString)
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.mcp_servers as any)({ export const Route = createFileRoute(route.settings.mcp_servers as any)({
component: MCPServers, component: MCPServers,
@ -195,6 +247,7 @@ function MCPServers() {
getConnectedServers().then(setConnectedServers) getConnectedServers().then(setConnectedServers)
}) })
.catch((error) => { .catch((error) => {
console.log(error, 'error.mcp')
editServer(serverKey, { editServer(serverKey, {
...(config ?? (mcpServers[serverKey] as MCPServerConfig)), ...(config ?? (mcpServers[serverKey] as MCPServerConfig)),
active: false, active: false,
@ -326,22 +379,56 @@ function MCPServers() {
} }
descriptionOutside={ descriptionOutside={
<div className="text-sm text-main-view-fg/70"> <div className="text-sm text-main-view-fg/70">
<div> <div className="mb-1">
{t('mcp-servers:command')}: {config.command} Transport:{' '}
<span className="uppercase">
{config.type || 'stdio'}
</span>
</div> </div>
<div className="my-1 break-all">
{t('mcp-servers:args')}: {config?.args?.join(', ')} {config.type === 'stdio' || !config.type ? (
</div> <>
{config.env && Object.keys(config.env).length > 0 && ( <div>
<div className="break-all"> {t('mcp-servers:command')}: {config.command}
{t('mcp-servers:env')}:{' '} </div>
{Object.entries(config.env) <div className="my-1 break-all">
.map( {t('mcp-servers:args')}:{' '}
([key, value]) => {config?.args?.join(', ')}
`${key}=${maskSensitiveValue(value)}` </div>
) {config.env &&
.join(', ')} Object.keys(config.env).length > 0 && (
</div> <div className="break-all">
{t('mcp-servers:env')}:{' '}
{Object.entries(config.env)
.map(
([key, value]) =>
`${key}=${maskSensitiveValue(value)}`
)
.join(', ')}
</div>
)}
</>
) : (
<>
<div className="break-all">
URL: {maskSensitiveUrl(config.url || '')}
</div>
{config.headers &&
Object.keys(config.headers).length > 0 && (
<div className="my-1 break-all">
Headers:{' '}
{Object.entries(config.headers)
.map(
([key, value]) =>
`${key}=${maskSensitiveValue(value)}`
)
.join(', ')}
</div>
)}
{config.timeout && (
<div>Timeout: {config.timeout}s</div>
)}
</>
)} )}
</div> </div>
} }

View File

@ -23,6 +23,7 @@ import { ContentType, ThreadMessage } from '@janhq/core'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { useChat } from '@/hooks/useChat' import { useChat } from '@/hooks/useChat'
import { useSmallScreen } from '@/hooks/useMediaQuery' import { useSmallScreen } from '@/hooks/useMediaQuery'
import { useTools } from '@/hooks/useTools'
// as route.threadsDetail // as route.threadsDetail
export const Route = createFileRoute('/threads/$threadId')({ export const Route = createFileRoute('/threads/$threadId')({
@ -43,6 +44,7 @@ function ThreadDetail() {
const { appMainViewBgColor, chatWidth } = useAppearance() const { appMainViewBgColor, chatWidth } = useAppearance()
const { sendMessage } = useChat() const { sendMessage } = useChat()
const isSmallScreen = useSmallScreen() const isSmallScreen = useSmallScreen()
useTools()
const { messages } = useMessages( const { messages } = useMessages(
useShallow((state) => ({ useShallow((state) => ({

View File

@ -6,4 +6,5 @@ export type MCPTool = {
name: string name: string
description: string description: string
inputSchema: Record<string, unknown> inputSchema: Record<string, unknown>
server: string
} }