Merge pull request #4859 from menloresearch/feat/mcp-jan-backend
feat: Jan - MCP Client Host
This commit is contained in:
commit
b6c051dc77
@ -21,7 +21,11 @@ tauri-build = { version = "2.0.2", features = [] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.1.0", features = [ "protocol-asset",'macos-private-api'] }
|
tauri = { version = "2.1.0", features = [
|
||||||
|
"protocol-asset",
|
||||||
|
'macos-private-api',
|
||||||
|
"test",
|
||||||
|
] }
|
||||||
tauri-plugin-log = "2.0.0-rc"
|
tauri-plugin-log = "2.0.0-rc"
|
||||||
tauri-plugin-shell = "2.2.0"
|
tauri-plugin-shell = "2.2.0"
|
||||||
flate2 = "1.0"
|
flate2 = "1.0"
|
||||||
@ -33,3 +37,9 @@ hyper = { version = "0.14", features = ["server"] }
|
|||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
|
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "main", features = [
|
||||||
|
"client",
|
||||||
|
"transport-sse",
|
||||||
|
"transport-child-process",
|
||||||
|
"tower",
|
||||||
|
] }
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
use rmcp::model::{CallToolRequestParam, CallToolResult, Tool};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
use std::{fs, path::PathBuf};
|
use std::{fs, path::PathBuf};
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, Runtime, State};
|
||||||
|
|
||||||
use super::{server, setup, state::AppState};
|
use super::{server, setup, state::AppState};
|
||||||
|
|
||||||
@ -21,7 +23,7 @@ impl AppConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_app_configurations(app_handle: tauri::AppHandle) -> AppConfiguration {
|
pub fn get_app_configurations<R: Runtime>(app_handle: tauri::AppHandle<R>) -> AppConfiguration {
|
||||||
let mut app_default_configuration = AppConfiguration::default();
|
let mut app_default_configuration = AppConfiguration::default();
|
||||||
|
|
||||||
if std::env::var("CI").unwrap_or_default() == "e2e" {
|
if std::env::var("CI").unwrap_or_default() == "e2e" {
|
||||||
@ -90,7 +92,7 @@ pub fn update_app_configuration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_jan_data_folder_path(app_handle: tauri::AppHandle) -> PathBuf {
|
pub fn get_jan_data_folder_path<R: Runtime>(app_handle: tauri::AppHandle<R>) -> PathBuf {
|
||||||
let app_configurations = get_app_configurations(app_handle);
|
let app_configurations = get_app_configurations(app_handle);
|
||||||
PathBuf::from(app_configurations.data_folder)
|
PathBuf::from(app_configurations.data_folder)
|
||||||
}
|
}
|
||||||
@ -132,8 +134,13 @@ pub fn read_theme(app_handle: tauri::AppHandle, theme_name: String) -> Result<St
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_configuration_file_path(app_handle: tauri::AppHandle) -> PathBuf {
|
pub fn get_configuration_file_path<R: Runtime>(app_handle: tauri::AppHandle<R>) -> PathBuf {
|
||||||
let app_path = app_handle.path().app_data_dir().unwrap_or_else(|err| {
|
let app_path = app_handle.path().app_data_dir().unwrap_or_else(|err| {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to get app data directory: {}. Using home directory instead.",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
|
||||||
let home_dir = std::env::var(if cfg!(target_os = "windows") {
|
let home_dir = std::env::var(if cfg!(target_os = "windows") {
|
||||||
"USERPROFILE"
|
"USERPROFILE"
|
||||||
} else {
|
} else {
|
||||||
@ -148,7 +155,7 @@ pub fn get_configuration_file_path(app_handle: tauri::AppHandle) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn default_data_folder_path(app_handle: tauri::AppHandle) -> String {
|
pub fn default_data_folder_path<R: Runtime>(app_handle: tauri::AppHandle<R>) -> String {
|
||||||
return app_handle
|
return app_handle
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
@ -258,12 +265,9 @@ pub async fn start_server(
|
|||||||
port: u16,
|
port: u16,
|
||||||
prefix: String,
|
prefix: String,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
server::start_server(
|
server::start_server(host, port, prefix, app_token(app.state()).unwrap())
|
||||||
host,
|
.await
|
||||||
port,
|
.map_err(|e| e.to_string())?;
|
||||||
prefix,
|
|
||||||
app_token(app.state()).unwrap(),
|
|
||||||
).await.map_err(|e| e.to_string())?;
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,3 +276,75 @@ pub async fn stop_server() -> Result<(), String> {
|
|||||||
server::stop_server().await.map_err(|e| e.to_string())?;
|
server::stop_server().await.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieves all available tools from all MCP servers
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `state` - Application state containing MCP server connections
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Result<Vec<Tool>, String>` - A vector of all tools if successful, or an error message if failed
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Locks the MCP servers mutex to access server connections
|
||||||
|
/// 2. Iterates through all connected servers
|
||||||
|
/// 3. Gets the list of tools from each server
|
||||||
|
/// 4. Combines all tools into a single vector
|
||||||
|
/// 5. Returns the combined list of all available tools
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<Tool>, String> {
|
||||||
|
let servers = state.mcp_servers.lock().await;
|
||||||
|
let mut all_tools: Vec<Tool> = Vec::new();
|
||||||
|
|
||||||
|
for (_, service) in servers.iter() {
|
||||||
|
// List tools
|
||||||
|
let tools = service.list_all_tools().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
for tool in tools {
|
||||||
|
all_tools.push(tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(all_tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls a tool on an MCP server by name with optional arguments
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `state` - Application state containing MCP server connections
|
||||||
|
/// * `tool_name` - Name of the tool to call
|
||||||
|
/// * `arguments` - Optional map of argument names to values
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Result<CallToolResult, String>` - Result of the tool call if successful, or error message if failed
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Locks the MCP servers mutex to access server connections
|
||||||
|
/// 2. Searches through all servers for one containing the named tool
|
||||||
|
/// 3. When found, calls the tool on that server with the provided arguments
|
||||||
|
/// 4. Returns error if no server has the requested tool
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn call_tool(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
tool_name: String,
|
||||||
|
arguments: Option<Map<String, Value>>,
|
||||||
|
) -> Result<CallToolResult, String> {
|
||||||
|
let servers = state.mcp_servers.lock().await;
|
||||||
|
|
||||||
|
// Iterate through servers and find the first one that contains the tool
|
||||||
|
for (_, service) in servers.iter() {
|
||||||
|
if let Ok(tools) = service.list_all_tools().await {
|
||||||
|
if tools.iter().any(|t| t.name == tool_name) {
|
||||||
|
return service
|
||||||
|
.call_tool(CallToolRequestParam {
|
||||||
|
name: tool_name.into(),
|
||||||
|
arguments,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Tool {} not found", tool_name))
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
|
// WARNING: These APIs will be deprecated soon due to removing FS API access from frontend.
|
||||||
|
// It's added to ensure the legacy implementation from frontend still functions before removal.
|
||||||
use crate::core::cmd::get_jan_data_folder_path;
|
use crate::core::cmd::get_jan_data_folder_path;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use tauri::Runtime;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rm(app_handle: tauri::AppHandle, args: Vec<String>) -> Result<(), String> {
|
pub fn rm<R: Runtime>(app_handle: tauri::AppHandle<R>, args: Vec<String>) -> Result<(), String> {
|
||||||
if args.is_empty() || args[0].is_empty() {
|
if args.is_empty() || args[0].is_empty() {
|
||||||
return Err("rm error: Invalid argument".to_string());
|
return Err("rm error: Invalid argument".to_string());
|
||||||
}
|
}
|
||||||
@ -12,7 +15,7 @@ pub fn rm(app_handle: tauri::AppHandle, args: Vec<String>) -> Result<(), String>
|
|||||||
fs::remove_dir_all(&path).map_err(|e| e.to_string())
|
fs::remove_dir_all(&path).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn mkdir(app_handle: tauri::AppHandle, args: Vec<String>) -> Result<(), String> {
|
pub fn mkdir<R: Runtime>(app_handle: tauri::AppHandle<R>, args: Vec<String>) -> Result<(), String> {
|
||||||
if args.is_empty() || args[0].is_empty() {
|
if args.is_empty() || args[0].is_empty() {
|
||||||
return Err("mkdir error: Invalid argument".to_string());
|
return Err("mkdir error: Invalid argument".to_string());
|
||||||
}
|
}
|
||||||
@ -22,7 +25,10 @@ pub fn mkdir(app_handle: tauri::AppHandle, args: Vec<String>) -> Result<(), Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn join_path(app_handle: tauri::AppHandle, args: Vec<String>) -> Result<String, String> {
|
pub fn join_path<R: Runtime>(
|
||||||
|
app_handle: tauri::AppHandle<R>,
|
||||||
|
args: Vec<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
return Err("join_path error: Invalid argument".to_string());
|
return Err("join_path error: Invalid argument".to_string());
|
||||||
}
|
}
|
||||||
@ -32,7 +38,10 @@ pub fn join_path(app_handle: tauri::AppHandle, args: Vec<String>) -> Result<Stri
|
|||||||
Ok(joined_path.to_string_lossy().to_string())
|
Ok(joined_path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn exists_sync(app_handle: tauri::AppHandle, args: Vec<String>) -> Result<bool, String> {
|
pub fn exists_sync<R: Runtime>(
|
||||||
|
app_handle: tauri::AppHandle<R>,
|
||||||
|
args: Vec<String>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
if args.is_empty() || args[0].is_empty() {
|
if args.is_empty() || args[0].is_empty() {
|
||||||
return Err("exist_sync error: Invalid argument".to_string());
|
return Err("exist_sync error: Invalid argument".to_string());
|
||||||
}
|
}
|
||||||
@ -42,7 +51,10 @@ pub fn exists_sync(app_handle: tauri::AppHandle, args: Vec<String>) -> Result<bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn read_file_sync(app_handle: tauri::AppHandle, args: Vec<String>) -> Result<String, String> {
|
pub fn read_file_sync<R: Runtime>(
|
||||||
|
app_handle: tauri::AppHandle<R>,
|
||||||
|
args: Vec<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
if args.is_empty() || args[0].is_empty() {
|
if args.is_empty() || args[0].is_empty() {
|
||||||
return Err("read_file_sync error: Invalid argument".to_string());
|
return Err("read_file_sync error: Invalid argument".to_string());
|
||||||
}
|
}
|
||||||
@ -52,8 +64,8 @@ pub fn read_file_sync(app_handle: tauri::AppHandle, args: Vec<String>) -> Result
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn readdir_sync(
|
pub fn readdir_sync<R: Runtime>(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle<R>,
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
if args.is_empty() || args[0].is_empty() {
|
if args.is_empty() || args[0].is_empty() {
|
||||||
@ -74,7 +86,7 @@ fn normalize_file_path(path: &str) -> String {
|
|||||||
path.replace("file:/", "").replace("file:\\", "")
|
path.replace("file:/", "").replace("file:\\", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_path(app_handle: tauri::AppHandle, path: &str) -> PathBuf {
|
fn resolve_path<R: Runtime>(app_handle: tauri::AppHandle<R>, path: &str) -> PathBuf {
|
||||||
let path = if path.starts_with("file:/") || path.starts_with("file:\\") {
|
let path = if path.starts_with("file:/") || path.starts_with("file:\\") {
|
||||||
let normalized = normalize_file_path(path);
|
let normalized = normalize_file_path(path);
|
||||||
let relative_normalized = normalized.strip_prefix("/").unwrap_or(&normalized);
|
let relative_normalized = normalized.strip_prefix("/").unwrap_or(&normalized);
|
||||||
@ -89,3 +101,93 @@ fn resolve_path(app_handle: tauri::AppHandle, path: &str) -> PathBuf {
|
|||||||
path.canonicalize().unwrap_or(path)
|
path.canonicalize().unwrap_or(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::Write;
|
||||||
|
use tauri::test::mock_app;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rm() {
|
||||||
|
let app = mock_app();
|
||||||
|
let path = "test_rm_dir";
|
||||||
|
fs::create_dir_all(get_jan_data_folder_path(app.handle().clone()).join(path)).unwrap();
|
||||||
|
let args = vec![format!("file://{}", path).to_string()];
|
||||||
|
let result = rm(app.handle().clone(), args);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(!get_jan_data_folder_path(app.handle().clone())
|
||||||
|
.join(path)
|
||||||
|
.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mkdir() {
|
||||||
|
let app = mock_app();
|
||||||
|
let path = "test_mkdir_dir";
|
||||||
|
let args = vec![format!("file://{}", path).to_string()];
|
||||||
|
let result = mkdir(app.handle().clone(), args);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(get_jan_data_folder_path(app.handle().clone())
|
||||||
|
.join(path)
|
||||||
|
.exists());
|
||||||
|
fs::remove_dir_all(get_jan_data_folder_path(app.handle().clone()).join(path)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_join_path() {
|
||||||
|
let app = mock_app();
|
||||||
|
let path = "file://test_dir";
|
||||||
|
let args = vec![path.to_string(), "test_file".to_string()];
|
||||||
|
let result = join_path(app.handle().clone(), args).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
get_jan_data_folder_path(app.handle().clone())
|
||||||
|
.join("test_dir/test_file")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exists_sync() {
|
||||||
|
let app = mock_app();
|
||||||
|
let path = "file://test_exists_sync_file";
|
||||||
|
let file_path = get_jan_data_folder_path(app.handle().clone()).join(path);
|
||||||
|
File::create(&file_path).unwrap();
|
||||||
|
let args = vec![path.to_string()];
|
||||||
|
let result = exists_sync(app.handle().clone(), args).unwrap();
|
||||||
|
assert!(result);
|
||||||
|
fs::remove_file(file_path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_file_sync() {
|
||||||
|
let app = mock_app();
|
||||||
|
let path = "file://test_read_file_sync_file";
|
||||||
|
let file_path = get_jan_data_folder_path(app.handle().clone()).join(path);
|
||||||
|
let mut file = File::create(&file_path).unwrap();
|
||||||
|
file.write_all(b"test content").unwrap();
|
||||||
|
let args = vec![path.to_string()];
|
||||||
|
let result = read_file_sync(app.handle().clone(), args).unwrap();
|
||||||
|
assert_eq!(result, "test content".to_string());
|
||||||
|
fs::remove_file(file_path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_readdir_sync() {
|
||||||
|
let app = mock_app();
|
||||||
|
let path = "file://test_readdir_sync_dir";
|
||||||
|
let dir_path = get_jan_data_folder_path(app.handle().clone()).join(path);
|
||||||
|
fs::create_dir_all(&dir_path).unwrap();
|
||||||
|
File::create(dir_path.join("file1.txt")).unwrap();
|
||||||
|
File::create(dir_path.join("file2.txt")).unwrap();
|
||||||
|
|
||||||
|
let args = vec![path.to_string()];
|
||||||
|
let result = readdir_sync(app.handle().clone(), args).unwrap();
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
|
||||||
|
fs::remove_dir_all(dir_path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
94
src-tauri/src/core/mcp.rs
Normal file
94
src-tauri/src/core/mcp.rs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
|
use rmcp::{service::RunningService, transport::TokioChildProcess, RoleClient, ServiceExt};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::{process::Command, sync::Mutex};
|
||||||
|
|
||||||
|
/// Runs MCP commands by reading configuration from a JSON file and initializing servers
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `app_path` - Path to the application directory containing mcp_config.json
|
||||||
|
/// * `servers_state` - Shared state containing running MCP services
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Ok(())` if servers were initialized successfully
|
||||||
|
/// * `Err(String)` if there was an error reading config or starting servers
|
||||||
|
pub async fn run_mcp_commands(
|
||||||
|
app_path: String,
|
||||||
|
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
println!(
|
||||||
|
"Load MCP configs from {}",
|
||||||
|
app_path.clone() + "/mcp_config.json"
|
||||||
|
);
|
||||||
|
// let mut client_list = HashMap::new();
|
||||||
|
let config_content = std::fs::read_to_string(app_path.clone() + "/mcp_config.json")
|
||||||
|
.map_err(|e| format!("Failed to read config file: {}", e))?;
|
||||||
|
|
||||||
|
let mcp_servers: serde_json::Value = serde_json::from_str(&config_content)
|
||||||
|
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(server_map) = mcp_servers.get("mcpServers").and_then(Value::as_object) {
|
||||||
|
println!("MCP Servers: {server_map:#?}");
|
||||||
|
|
||||||
|
for (name, config) in server_map {
|
||||||
|
if let Some((command, args)) = extract_command_args(config) {
|
||||||
|
let mut cmd = Command::new(command);
|
||||||
|
args.iter().filter_map(Value::as_str).for_each(|arg| { cmd.arg(arg); });
|
||||||
|
|
||||||
|
let service = ().serve(TokioChildProcess::new(&mut cmd).map_err(|e| e.to_string())?)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
servers_state.lock().await.insert(name.clone(), service);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect servers into a Vec to avoid holding the RwLockReadGuard across await points
|
||||||
|
let servers_map = servers_state.lock().await;
|
||||||
|
for (_, service) in servers_map.iter() {
|
||||||
|
// Initialize
|
||||||
|
let _server_info = service.peer_info();
|
||||||
|
println!("Connected to server: {_server_info:#?}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_command_args(config: &Value) -> Option<(&str, &Vec<Value>)> {
|
||||||
|
let obj = config.as_object()?;
|
||||||
|
let command = obj.get("command")?.as_str()?;
|
||||||
|
let args = obj.get("args")?.as_array()?;
|
||||||
|
Some((command, args))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_run_mcp_commands() {
|
||||||
|
// Create a mock mcp_config.json file
|
||||||
|
let config_path = "mcp_config.json";
|
||||||
|
let mut file = File::create(config_path).expect("Failed to create config file");
|
||||||
|
file.write_all(b"{\"mcpServers\":{}}")
|
||||||
|
.expect("Failed to write to config file");
|
||||||
|
|
||||||
|
// Call the run_mcp_commands function
|
||||||
|
let app_path = ".".to_string();
|
||||||
|
let servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>> =
|
||||||
|
Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let result = run_mcp_commands(app_path, servers_state).await;
|
||||||
|
|
||||||
|
// Assert that the function returns Ok(())
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Clean up the mock config file
|
||||||
|
std::fs::remove_file(config_path).expect("Failed to remove config file");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,4 +2,5 @@ pub mod cmd;
|
|||||||
pub mod fs;
|
pub mod fs;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
pub mod mcp;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
use hyper::service::{make_service_fn, service_fn};
|
use hyper::service::{make_service_fn, service_fn};
|
||||||
use hyper::{Body, Method, Request, Response, Server, StatusCode};
|
use hyper::{Body, Request, Response, Server, StatusCode};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@ -25,7 +25,7 @@ struct ProxyConfig {
|
|||||||
/// Removes a prefix from a path, ensuring proper formatting
|
/// Removes a prefix from a path, ensuring proper formatting
|
||||||
fn remove_prefix(path: &str, prefix: &str) -> String {
|
fn remove_prefix(path: &str, prefix: &str) -> String {
|
||||||
debug!("Processing path: {}, removing prefix: {}", path, prefix);
|
debug!("Processing path: {}, removing prefix: {}", path, prefix);
|
||||||
|
|
||||||
if !prefix.is_empty() && path.starts_with(prefix) {
|
if !prefix.is_empty() && path.starts_with(prefix) {
|
||||||
let result = path[prefix.len()..].to_string();
|
let result = path[prefix.len()..].to_string();
|
||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
@ -41,10 +41,13 @@ fn remove_prefix(path: &str, prefix: &str) -> String {
|
|||||||
/// Determines the final destination path based on the original request path
|
/// Determines the final destination path based on the original request path
|
||||||
fn get_destination_path(original_path: &str, prefix: &str) -> String {
|
fn get_destination_path(original_path: &str, prefix: &str) -> String {
|
||||||
let removed_prefix_path = remove_prefix(original_path, prefix);
|
let removed_prefix_path = remove_prefix(original_path, prefix);
|
||||||
|
|
||||||
println!("Removed prefix path: {}", removed_prefix_path);
|
println!("Removed prefix path: {}", removed_prefix_path);
|
||||||
// Special paths don't need the /v1 prefix
|
// Special paths don't need the /v1 prefix
|
||||||
if !original_path.contains(prefix) || removed_prefix_path.contains("/healthz") || removed_prefix_path.contains("/process") {
|
if !original_path.contains(prefix)
|
||||||
|
|| removed_prefix_path.contains("/healthz")
|
||||||
|
|| removed_prefix_path.contains("/process")
|
||||||
|
{
|
||||||
original_path.to_string()
|
original_path.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("/v1{}", removed_prefix_path)
|
format!("/v1{}", removed_prefix_path)
|
||||||
@ -55,7 +58,7 @@ fn get_destination_path(original_path: &str, prefix: &str) -> String {
|
|||||||
fn build_upstream_url(upstream: &str, path: &str) -> String {
|
fn build_upstream_url(upstream: &str, path: &str) -> String {
|
||||||
let upstream_clean = upstream.trim_end_matches('/');
|
let upstream_clean = upstream.trim_end_matches('/');
|
||||||
let path_clean = path.trim_start_matches('/');
|
let path_clean = path.trim_start_matches('/');
|
||||||
|
|
||||||
format!("{}/{}", upstream_clean, path_clean)
|
format!("{}/{}", upstream_clean, path_clean)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,7 +70,7 @@ async fn proxy_request(
|
|||||||
) -> Result<Response<Body>, hyper::Error> {
|
) -> Result<Response<Body>, hyper::Error> {
|
||||||
let original_path = req.uri().path();
|
let original_path = req.uri().path();
|
||||||
let path = get_destination_path(original_path, &config.prefix);
|
let path = get_destination_path(original_path, &config.prefix);
|
||||||
|
|
||||||
// Block access to /configs endpoint
|
// Block access to /configs endpoint
|
||||||
if path.contains("/configs") {
|
if path.contains("/configs") {
|
||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
@ -79,12 +82,13 @@ async fn proxy_request(
|
|||||||
// Build the outbound request
|
// Build the outbound request
|
||||||
let upstream_url = build_upstream_url(&config.upstream, &path);
|
let upstream_url = build_upstream_url(&config.upstream, &path);
|
||||||
debug!("Proxying request to: {}", upstream_url);
|
debug!("Proxying request to: {}", upstream_url);
|
||||||
|
|
||||||
let mut outbound_req = client.request(req.method().clone(), &upstream_url);
|
let mut outbound_req = client.request(req.method().clone(), &upstream_url);
|
||||||
|
|
||||||
// Copy original headers
|
// Copy original headers
|
||||||
for (name, value) in req.headers() {
|
for (name, value) in req.headers() {
|
||||||
if name != hyper::header::HOST { // Skip host header
|
if name != hyper::header::HOST {
|
||||||
|
// Skip host header
|
||||||
outbound_req = outbound_req.header(name, value);
|
outbound_req = outbound_req.header(name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -97,9 +101,9 @@ async fn proxy_request(
|
|||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
debug!("Received response with status: {}", status);
|
debug!("Received response with status: {}", status);
|
||||||
|
|
||||||
let mut builder = Response::builder().status(status);
|
let mut builder = Response::builder().status(status);
|
||||||
|
|
||||||
// Copy response headers
|
// Copy response headers
|
||||||
for (name, value) in response.headers() {
|
for (name, value) in response.headers() {
|
||||||
builder = builder.header(name, value);
|
builder = builder.header(name, value);
|
||||||
@ -151,7 +155,7 @@ pub async fn start_server(
|
|||||||
prefix,
|
prefix,
|
||||||
auth_token,
|
auth_token,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create HTTP client
|
// Create HTTP client
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
@ -161,7 +165,7 @@ pub async fn start_server(
|
|||||||
let make_svc = make_service_fn(move |_conn| {
|
let make_svc = make_service_fn(move |_conn| {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
Ok::<_, Infallible>(service_fn(move |req| {
|
Ok::<_, Infallible>(service_fn(move |req| {
|
||||||
proxy_request(req, client.clone(), config.clone())
|
proxy_request(req, client.clone(), config.clone())
|
||||||
@ -189,13 +193,13 @@ pub async fn start_server(
|
|||||||
/// Stops the currently running proxy server
|
/// Stops the currently running proxy server
|
||||||
pub async fn stop_server() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn stop_server() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let mut handle_guard = SERVER_HANDLE.lock().await;
|
let mut handle_guard = SERVER_HANDLE.lock().await;
|
||||||
|
|
||||||
if let Some(handle) = handle_guard.take() {
|
if let Some(handle) = handle_guard.take() {
|
||||||
handle.abort();
|
handle.abort();
|
||||||
info!("Proxy server stopped");
|
info!("Proxy server stopped");
|
||||||
} else {
|
} else {
|
||||||
debug!("No server was running");
|
debug!("No server was running");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,19 +11,18 @@ use tauri_plugin_shell::process::CommandEvent;
|
|||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
use super::{cmd::get_jan_extensions_path, state::AppState};
|
use super::{
|
||||||
|
cmd::{get_jan_data_folder_path, get_jan_extensions_path},
|
||||||
|
mcp::run_mcp_commands,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), String> {
|
pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), String> {
|
||||||
let store = app.store("store.json").expect("Store not initialized");
|
let store = app.store("store.json").expect("Store not initialized");
|
||||||
let stored_version = if let Some(version) = store.get("version") {
|
let stored_version = store
|
||||||
if let Some(version_str) = version.as_str() {
|
.get("version")
|
||||||
version_str.to_string()
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
} else {
|
.unwrap_or_default();
|
||||||
"".to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let app_version = app
|
let app_version = app
|
||||||
.config()
|
.config()
|
||||||
@ -31,10 +30,8 @@ pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), Stri
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "".to_string());
|
.unwrap_or_else(|| "".to_string());
|
||||||
|
|
||||||
if !force {
|
if !force && stored_version == app_version {
|
||||||
if stored_version == app_version {
|
return Ok(());
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let extensions_path = get_jan_extensions_path(app.clone());
|
let extensions_path = get_jan_extensions_path(app.clone());
|
||||||
let pre_install_path = PathBuf::from("./resources/pre-install");
|
let pre_install_path = PathBuf::from("./resources/pre-install");
|
||||||
@ -46,6 +43,10 @@ pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), Stri
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
// Attempt to create it again
|
// Attempt to create it again
|
||||||
if !extensions_path.exists() {
|
if !extensions_path.exists() {
|
||||||
fs::create_dir_all(&extensions_path).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&extensions_path).map_err(|e| e.to_string())?;
|
||||||
@ -177,6 +178,19 @@ fn extract_extension_manifest<R: Read>(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn setup_mcp(app: &App) {
|
||||||
|
let app_path = get_jan_data_folder_path(app.handle().clone());
|
||||||
|
|
||||||
|
let state = app.state::<AppState>().inner();
|
||||||
|
let app_path_str = app_path.to_str().unwrap().to_string();
|
||||||
|
let servers = state.mcp_servers.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = run_mcp_commands(app_path_str, servers).await {
|
||||||
|
eprintln!("Failed to run mcp commands: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub fn setup_sidecar(app: &App) -> Result<(), String> {
|
pub fn setup_sidecar(app: &App) -> Result<(), String> {
|
||||||
// Setup sidecar
|
// Setup sidecar
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
use rand::{distributions::Alphanumeric, Rng};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
|
use rand::{distributions::Alphanumeric, Rng};
|
||||||
|
use rmcp::{service::RunningService, RoleClient};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[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 fn generate_app_token() -> String {
|
pub fn generate_app_token() -> String {
|
||||||
rand::thread_rng()
|
rand::thread_rng()
|
||||||
.sample_iter(&Alphanumeric)
|
.sample_iter(&Alphanumeric)
|
||||||
.take(32)
|
.take(32)
|
||||||
.map(char::from)
|
.map(char::from)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
mod core;
|
mod core;
|
||||||
use core::{
|
use core::{
|
||||||
setup::{self, setup_engine_binaries, setup_sidecar},
|
setup::{self, setup_engine_binaries, setup_mcp, setup_sidecar},
|
||||||
state::{generate_app_token, AppState},
|
state::{generate_app_token, AppState},
|
||||||
};
|
};
|
||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@ -13,6 +15,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
// FS commands - Deperecate soon
|
||||||
core::fs::join_path,
|
core::fs::join_path,
|
||||||
core::fs::mkdir,
|
core::fs::mkdir,
|
||||||
core::fs::exists_sync,
|
core::fs::exists_sync,
|
||||||
@ -35,9 +38,13 @@ pub fn run() {
|
|||||||
core::cmd::app_token,
|
core::cmd::app_token,
|
||||||
core::cmd::start_server,
|
core::cmd::start_server,
|
||||||
core::cmd::stop_server,
|
core::cmd::stop_server,
|
||||||
|
// MCP commands
|
||||||
|
core::cmd::get_tools,
|
||||||
|
core::cmd::call_tool
|
||||||
])
|
])
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
app_token: Some(generate_app_token()),
|
app_token: Some(generate_app_token()),
|
||||||
|
mcp_servers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
@ -53,6 +60,8 @@ pub fn run() {
|
|||||||
eprintln!("Failed to install extensions: {}", e);
|
eprintln!("Failed to install extensions: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setup_mcp(app);
|
||||||
|
|
||||||
setup_sidecar(app).expect("Failed to setup sidecar");
|
setup_sidecar(app).expect("Failed to setup sidecar");
|
||||||
|
|
||||||
setup_engine_binaries(app).expect("Failed to setup engine binaries");
|
setup_engine_binaries(app).expect("Failed to setup engine binaries");
|
||||||
@ -60,7 +69,7 @@ pub fn run() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| match event {
|
.on_window_event(|window, event| match event {
|
||||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
tauri::WindowEvent::CloseRequested { .. } => {
|
||||||
window.emit("kill-sidecar", ()).unwrap();
|
window.emit("kill-sidecar", ()).unwrap();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@ -24,7 +24,9 @@ export const CoreConfigurator = ({ children }: PropsWithChildren) => {
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (!isCoreExtensionInstalled()) {
|
if (!isCoreExtensionInstalled()) {
|
||||||
setSettingUp(true)
|
setSettingUp(true)
|
||||||
await setupBaseExtensions()
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
setupBaseExtensions()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,16 @@ import { CoreRoutes, APIRoutes } from '@janhq/core'
|
|||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
// Define API routes based on different route types
|
// Define API routes based on different route types
|
||||||
export const Routes = [...CoreRoutes, ...APIRoutes, 'installExtensions'].map(
|
export const Routes = [
|
||||||
(r) => ({
|
...CoreRoutes,
|
||||||
path: `app`,
|
...APIRoutes,
|
||||||
route: r,
|
'installExtensions',
|
||||||
})
|
'getTools',
|
||||||
)
|
'callTool',
|
||||||
|
].map((r) => ({
|
||||||
|
path: `app`,
|
||||||
|
route: r,
|
||||||
|
}))
|
||||||
|
|
||||||
// Function to open an external URL in a new browser window
|
// Function to open an external URL in a new browser window
|
||||||
export function openExternalUrl(url: string) {
|
export function openExternalUrl(url: string) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user