feat: Jan supports MCP as a client host

This commit is contained in:
Louis 2025-03-30 15:40:16 +07:00
parent 4ab5393f3e
commit d287586ae8
No known key found for this signature in database
GPG Key ID: 44FA9F4D33C37DE2
8 changed files with 203 additions and 34 deletions

View File

@ -21,7 +21,10 @@ 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',
] }
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 +36,10 @@ 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",
] }
schemars = { version = "0.8.22", features = ["derive"] }

View File

@ -1,4 +1,9 @@
use rmcp::{
model::{CallToolRequestParam, CallToolResult, Tool},
object,
};
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, State};
@ -134,6 +139,11 @@ 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(app_handle: tauri::AppHandle) -> 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 {
@ -258,12 +268,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 +279,48 @@ 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(())
} }
#[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)
}
#[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;
for (_, service) in servers.iter() {
if let Ok(tool) = service.list_all_tools().await {
for t in tool {
if t.name == tool_name {
let result = service
.call_tool(CallToolRequestParam {
name: tool_name.into(),
arguments,
})
.await
.map_err(|e| e.to_string())?;
return Ok(result);
}
}
}
}
return Err(format!("Tool {} not found", tool_name));
}

73
src-tauri/src/core/mcp.rs Normal file
View File

@ -0,0 +1,73 @@
use std::{collections::HashMap, sync::Arc};
use rmcp::{
model::{CallToolRequestParam, GetPromptRequestParam, ReadResourceRequestParam},
service::RunningService,
transport::TokioChildProcess,
RoleClient, ServiceExt,
};
use tokio::{process::Command, sync::Mutex};
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 = match std::fs::read_to_string(app_path.clone() + "/mcp_config.json") {
Ok(content) => content,
Err(e) => return Err(format!("Failed to read config file: {}", e)),
};
let mcp_servers: serde_json::Value = match serde_json::from_str(&config_content) {
Ok(v) => v,
Err(e) => return Err(format!("Failed to parse config: {}", e)),
};
if let Some(servers) = mcp_servers.get("mcpServers") {
println!("MCP Servers: {servers:#?}");
if let Some(server_map) = servers.as_object() {
for (name, config) in server_map {
println!("Server Name: {}", name);
if let Some(config_obj) = config.as_object() {
if let (Some(command), Some(args)) = (
config_obj.get("command").and_then(|v| v.as_str()),
config_obj.get("args").and_then(|v| v.as_array()),
) {
let mut cmd = Command::new(command);
for arg in args {
if let Some(arg_str) = arg.as_str() {
cmd.arg(arg_str);
}
}
let service =
().serve(TokioChildProcess::new(&mut cmd).map_err(|e| e.to_string())?)
.await
.map_err(|e| e.to_string())?;
{
let mut servers_map = servers_state.lock().await;
servers_map.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:#?}");
// List tools
let _tools = service.list_all_tools().await.map_err(|e| e.to_string())?;
println!("Tools: {_tools:#?}");
}
Ok(())
}

View File

@ -3,3 +3,4 @@ pub mod fs;
pub mod setup; pub mod setup;
pub mod state; pub mod state;
pub mod server; pub mod server;
pub mod mcp;

View File

@ -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;
@ -44,7 +44,10 @@ fn get_destination_path(original_path: &str, prefix: &str) -> String {
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)
@ -84,7 +87,8 @@ async fn proxy_request(
// 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);
} }
} }

View File

@ -1,9 +1,14 @@
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)

View File

@ -1,10 +1,14 @@
mod core; mod core;
use core::{ use core::{
cmd::get_jan_data_folder_path,
mcp::run_mcp_commands,
setup::{self, setup_engine_binaries, setup_sidecar}, setup::{self, setup_engine_binaries, setup_sidecar},
state::{generate_app_token, AppState}, state::{generate_app_token, AppState},
}; };
use std::{collections::HashMap, sync::Arc};
use tauri::Emitter; use tauri::{Emitter, Manager};
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 +17,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 +40,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 +62,17 @@ pub fn run() {
eprintln!("Failed to install extensions: {}", e); eprintln!("Failed to install extensions: {}", e);
} }
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);
}
});
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 +80,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();
} }
_ => {} _ => {}

View File

@ -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) {