feat: Jan supports MCP as a client host
This commit is contained in:
parent
4ab5393f3e
commit
d287586ae8
@ -21,7 +21,10 @@ tauri-build = { version = "2.0.2", features = [] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
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-shell = "2.2.0"
|
||||
flate2 = "1.0"
|
||||
@ -33,3 +36,10 @@ hyper = { version = "0.14", features = ["server"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
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"] }
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
use rmcp::{
|
||||
model::{CallToolRequestParam, CallToolResult, Tool},
|
||||
object,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::{fs, path::PathBuf};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
@ -134,6 +139,11 @@ pub fn read_theme(app_handle: tauri::AppHandle, theme_name: String) -> Result<St
|
||||
#[tauri::command]
|
||||
pub fn get_configuration_file_path(app_handle: tauri::AppHandle) -> PathBuf {
|
||||
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") {
|
||||
"USERPROFILE"
|
||||
} else {
|
||||
@ -258,12 +268,9 @@ pub async fn start_server(
|
||||
port: u16,
|
||||
prefix: String,
|
||||
) -> Result<bool, String> {
|
||||
server::start_server(
|
||||
host,
|
||||
port,
|
||||
prefix,
|
||||
app_token(app.state()).unwrap(),
|
||||
).await.map_err(|e| e.to_string())?;
|
||||
server::start_server(host, port, prefix, app_token(app.state()).unwrap())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@ -272,3 +279,48 @@ pub async fn stop_server() -> Result<(), String> {
|
||||
server::stop_server().await.map_err(|e| e.to_string())?;
|
||||
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
73
src-tauri/src/core/mcp.rs
Normal 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(())
|
||||
}
|
||||
@ -2,4 +2,5 @@ pub mod cmd;
|
||||
pub mod fs;
|
||||
pub mod setup;
|
||||
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::{Body, Method, Request, Response, Server, StatusCode};
|
||||
use hyper::{Body, Request, Response, Server, StatusCode};
|
||||
use reqwest::Client;
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
@ -25,7 +25,7 @@ struct ProxyConfig {
|
||||
/// Removes a prefix from a path, ensuring proper formatting
|
||||
fn remove_prefix(path: &str, prefix: &str) -> String {
|
||||
debug!("Processing path: {}, removing prefix: {}", path, prefix);
|
||||
|
||||
|
||||
if !prefix.is_empty() && path.starts_with(prefix) {
|
||||
let result = path[prefix.len()..].to_string();
|
||||
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
|
||||
fn get_destination_path(original_path: &str, prefix: &str) -> String {
|
||||
let removed_prefix_path = remove_prefix(original_path, prefix);
|
||||
|
||||
|
||||
println!("Removed prefix path: {}", removed_prefix_path);
|
||||
// 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()
|
||||
} else {
|
||||
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 {
|
||||
let upstream_clean = upstream.trim_end_matches('/');
|
||||
let path_clean = path.trim_start_matches('/');
|
||||
|
||||
|
||||
format!("{}/{}", upstream_clean, path_clean)
|
||||
}
|
||||
|
||||
@ -67,7 +70,7 @@ async fn proxy_request(
|
||||
) -> Result<Response<Body>, hyper::Error> {
|
||||
let original_path = req.uri().path();
|
||||
let path = get_destination_path(original_path, &config.prefix);
|
||||
|
||||
|
||||
// Block access to /configs endpoint
|
||||
if path.contains("/configs") {
|
||||
return Ok(Response::builder()
|
||||
@ -79,12 +82,13 @@ async fn proxy_request(
|
||||
// Build the outbound request
|
||||
let upstream_url = build_upstream_url(&config.upstream, &path);
|
||||
debug!("Proxying request to: {}", upstream_url);
|
||||
|
||||
|
||||
let mut outbound_req = client.request(req.method().clone(), &upstream_url);
|
||||
|
||||
|
||||
// Copy original 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);
|
||||
}
|
||||
}
|
||||
@ -97,9 +101,9 @@ async fn proxy_request(
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
debug!("Received response with status: {}", status);
|
||||
|
||||
|
||||
let mut builder = Response::builder().status(status);
|
||||
|
||||
|
||||
// Copy response headers
|
||||
for (name, value) in response.headers() {
|
||||
builder = builder.header(name, value);
|
||||
@ -151,7 +155,7 @@ pub async fn start_server(
|
||||
prefix,
|
||||
auth_token,
|
||||
};
|
||||
|
||||
|
||||
// Create HTTP client
|
||||
let client = Client::builder()
|
||||
.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 client = client.clone();
|
||||
let config = config.clone();
|
||||
|
||||
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |req| {
|
||||
proxy_request(req, client.clone(), config.clone())
|
||||
@ -189,13 +193,13 @@ pub async fn start_server(
|
||||
/// Stops the currently running proxy server
|
||||
pub async fn stop_server() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut handle_guard = SERVER_HANDLE.lock().await;
|
||||
|
||||
|
||||
if let Some(handle) = handle_guard.take() {
|
||||
handle.abort();
|
||||
info!("Proxy server stopped");
|
||||
} else {
|
||||
debug!("No server was running");
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -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 app_token: Option<String>,
|
||||
pub mcp_servers: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>
|
||||
}
|
||||
|
||||
pub fn generate_app_token() -> String {
|
||||
rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
mod core;
|
||||
use core::{
|
||||
cmd::get_jan_data_folder_path,
|
||||
mcp::run_mcp_commands,
|
||||
setup::{self, setup_engine_binaries, setup_sidecar},
|
||||
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)]
|
||||
pub fn run() {
|
||||
@ -13,6 +17,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// FS commands - Deperecate soon
|
||||
core::fs::join_path,
|
||||
core::fs::mkdir,
|
||||
core::fs::exists_sync,
|
||||
@ -35,9 +40,13 @@ pub fn run() {
|
||||
core::cmd::app_token,
|
||||
core::cmd::start_server,
|
||||
core::cmd::stop_server,
|
||||
// MCP commands
|
||||
core::cmd::get_tools,
|
||||
core::cmd::call_tool
|
||||
])
|
||||
.manage(AppState {
|
||||
app_token: Some(generate_app_token()),
|
||||
mcp_servers: Arc::new(Mutex::new(HashMap::new())),
|
||||
})
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
@ -53,6 +62,17 @@ pub fn run() {
|
||||
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_engine_binaries(app).expect("Failed to setup engine binaries");
|
||||
@ -60,7 +80,7 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
tauri::WindowEvent::CloseRequested { .. } => {
|
||||
window.emit("kill-sidecar", ()).unwrap();
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@ -2,12 +2,16 @@ import { CoreRoutes, APIRoutes } from '@janhq/core'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
// Define API routes based on different route types
|
||||
export const Routes = [...CoreRoutes, ...APIRoutes, 'installExtensions'].map(
|
||||
(r) => ({
|
||||
path: `app`,
|
||||
route: r,
|
||||
})
|
||||
)
|
||||
export const Routes = [
|
||||
...CoreRoutes,
|
||||
...APIRoutes,
|
||||
'installExtensions',
|
||||
'getTools',
|
||||
'callTool',
|
||||
].map((r) => ({
|
||||
path: `app`,
|
||||
route: r,
|
||||
}))
|
||||
|
||||
// Function to open an external URL in a new browser window
|
||||
export function openExternalUrl(url: string) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user