diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2479a09bb..02bac20f2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -35,8 +35,10 @@ dependencies = [ "regex", "reqwest-middleware", "reqwest-retry", + "rmcp", "rsa", "sanitize-filename", + "schemars 1.0.4", "semver", "serde", "serde_json", @@ -1268,8 +1270,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1286,13 +1298,37 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.106", ] @@ -3954,6 +3990,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -5022,6 +5064,51 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmcp" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.9.2", + "rmcp-macros", + "schemars 1.0.4", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.106", +] + [[package]] name = "ron" version = "0.8.1" @@ -5194,7 +5281,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -5219,8 +5306,10 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.0.4", "serde", "serde_json", ] @@ -5237,6 +5326,18 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -5420,7 +5521,7 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.106", @@ -5696,6 +5797,19 @@ dependencies = [ "der", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -6577,6 +6691,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.16" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3cdce7ed9..a89c271b3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -91,6 +91,8 @@ axum = "0.8.6" tower-http = { version = "0.6.6", features = ["cors"] } sha2 = "0.10.9" async-trait = "0.1.89" +schemars = "1.0.4" +rmcp = { version = "0.15.0", features = ["server", "transport-streamable-http-server"] } [target."cfg(windows)".dependencies] winreg = "0.55.0" diff --git a/src-tauri/src/instance/commands.rs b/src-tauri/src/instance/commands.rs index 4838ae782..66a223897 100644 --- a/src-tauri/src/instance/commands.rs +++ b/src-tauri/src/instance/commands.rs @@ -58,7 +58,6 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; -use tauri::State; use tauri::{AppHandle, Manager}; use tauri_plugin_http::reqwest; use tokio; @@ -446,7 +445,6 @@ pub async fn retrieve_game_server_list( pub async fn retrieve_local_mod_list( app: AppHandle, instance_id: String, - local_mod_translations_cache_state: State<'_, Mutex>, ) -> SJMCLResult> { let mods_dir = match get_instance_subdir_path_by_id(&app, &instance_id, &InstanceSubdirType::Mods) { @@ -552,6 +550,7 @@ pub async fn retrieve_local_mod_list( } // sort by name (and version) mod_infos.sort(); + let local_mod_translations_cache_state = app.state::>(); let mut cache = local_mod_translations_cache_state.lock()?; for info in mod_infos.iter() { if let Some(entry) = cache.translations.get(&info.file_name) { diff --git a/src-tauri/src/intelligence/mcp_server/launcher/macro.rs b/src-tauri/src/intelligence/mcp_server/launcher/macro.rs new file mode 100644 index 000000000..8ae71493c --- /dev/null +++ b/src-tauri/src/intelligence/mcp_server/launcher/macro.rs @@ -0,0 +1,219 @@ +#[macro_export] +macro_rules! mcp_tool { + (sync $name:expr, $command:path, $description:expr) => {{ + rmcp::handler::server::tool::ToolRoute::new_dyn( + rmcp::model::Tool::new( + $name, + $description, + rmcp::handler::server::tool::schema_for_type::(), + ), + move |context: rmcp::handler::server::tool::ToolCallContext< + '_, + $crate::intelligence::mcp_server::launcher::McpContext, + >| { + use futures::FutureExt; + + async move { + let app = context.service.app_handle.clone(); + $crate::intelligence::mcp_server::launcher::command_result_to_tool_result($command(app)) + } + .boxed() + }, + ) + }}; + ($name:expr, $command:path, $description:expr) => {{ + rmcp::handler::server::tool::ToolRoute::new_dyn( + rmcp::model::Tool::new( + $name, + $description, + rmcp::handler::server::tool::schema_for_type::(), + ), + move |context: rmcp::handler::server::tool::ToolCallContext< + '_, + $crate::intelligence::mcp_server::launcher::McpContext, + >| { + use futures::FutureExt; + + async move { + let app = context.service.app_handle.clone(); + $crate::intelligence::mcp_server::launcher::command_result_to_tool_result($command(app).await) + } + .boxed() + }, + ) + }}; + (sync $name:expr, $command:path, $description:expr, { $($arg:ident : $ty:ty),* $(,)? }) => {{ + #[derive(::serde::Deserialize, ::schemars::JsonSchema)] + struct __McpToolParams { + $(pub $arg: $ty),* + } + + rmcp::handler::server::tool::ToolRoute::new_dyn( + rmcp::model::Tool::new( + $name, + $description, + rmcp::handler::server::tool::schema_for_type::<__McpToolParams>(), + ), + move |context: rmcp::handler::server::tool::ToolCallContext< + '_, + $crate::intelligence::mcp_server::launcher::McpContext, + >| { + use futures::FutureExt; + + async move { + let mut context = context; + let params: __McpToolParams = rmcp::handler::server::tool::parse_json_object( + context.arguments.take().unwrap_or_default(), + )?; + let app = context.service.app_handle.clone(); + $crate::intelligence::mcp_server::launcher::command_result_to_tool_result($command(app, $(params.$arg),*)) + } + .boxed() + }, + ) + }}; + ($name:expr, $command:path, $description:expr, { $($arg:ident : $ty:ty),* $(,)? }) => {{ + #[derive(::serde::Deserialize, ::schemars::JsonSchema)] + struct __McpToolParams { + $(pub $arg: $ty),* + } + + rmcp::handler::server::tool::ToolRoute::new_dyn( + rmcp::model::Tool::new( + $name, + $description, + rmcp::handler::server::tool::schema_for_type::<__McpToolParams>(), + ), + move |context: rmcp::handler::server::tool::ToolCallContext< + '_, + $crate::intelligence::mcp_server::launcher::McpContext, + >| { + use futures::FutureExt; + + async move { + let mut context = context; + let params: __McpToolParams = rmcp::handler::server::tool::parse_json_object( + context.arguments.take().unwrap_or_default(), + )?; + let app = context.service.app_handle.clone(); + $crate::intelligence::mcp_server::launcher::command_result_to_tool_result( + $command(app, $(params.$arg),*).await, + ) + } + .boxed() + }, + ) + }}; + ($name:expr, $description:expr, |$app:ident, $params:ident : $params_ty:ty| $call:expr) => {{ + rmcp::handler::server::tool::ToolRoute::new_dyn( + rmcp::model::Tool::new( + $name, + $description, + rmcp::handler::server::tool::schema_for_type::(), + ), + move |context: rmcp::handler::server::tool::ToolCallContext< + '_, + $crate::intelligence::mcp_server::launcher::McpContext, + >| { + use futures::FutureExt; + + async move { + let mut context = context; + let $params: $params_ty = rmcp::handler::server::tool::parse_json_object( + context.arguments.take().unwrap_or_default(), + )?; + let $app = context.service.app_handle.clone(); + $crate::intelligence::mcp_server::launcher::command_result_to_tool_result($call.await) + } + .boxed() + }, + ) + }}; + (raw $name:expr, $handler:path, $description:expr) => {{ + rmcp::handler::server::tool::ToolRoute::new_dyn( + rmcp::model::Tool::new( + $name, + $description, + rmcp::handler::server::tool::schema_for_type::(), + ), + move |context: rmcp::handler::server::tool::ToolCallContext< + '_, + $crate::intelligence::mcp_server::launcher::McpContext, + >| { + use futures::FutureExt; + $handler(context).boxed() + }, + ) + }}; + (deeplink $name:expr, $description:expr, |$params:ident| { $($arg:ident : $ty:ty),* $(,)? } => $deeplink:expr) => {{ + #[derive(::serde::Deserialize, ::schemars::JsonSchema)] + struct __McpToolParams { + $(pub $arg: $ty),* + } + + rmcp::handler::server::tool::ToolRoute::new_dyn( + rmcp::model::Tool::new( + $name, + $description, + rmcp::handler::server::tool::schema_for_type::<__McpToolParams>(), + ), + move |context: rmcp::handler::server::tool::ToolCallContext< + '_, + $crate::intelligence::mcp_server::launcher::McpContext, + >| { + use futures::FutureExt; + + async move { + use tauri_plugin_opener::OpenerExt; + + let mut context = context; + let $params: __McpToolParams = rmcp::handler::server::tool::parse_json_object( + context.arguments.take().unwrap_or_default(), + )?; + let app = context.service.app_handle.clone(); + let deeplink: String = $deeplink; + app + .opener() + .open_url(&deeplink, None::<&str>) + .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; + + Ok(rmcp::model::CallToolResult::success(vec![ + rmcp::model::Content::text(format!("Opened deeplink: {deeplink}")), + ])) + } + .boxed() + }, + ) + }}; + (deeplink $name:expr, $description:expr, $deeplink:expr) => {{ + rmcp::handler::server::tool::ToolRoute::new_dyn( + rmcp::model::Tool::new( + $name, + $description, + rmcp::handler::server::tool::schema_for_type::(), + ), + move |context: rmcp::handler::server::tool::ToolCallContext< + '_, + $crate::intelligence::mcp_server::launcher::McpContext, + >| { + use futures::FutureExt; + + async move { + use tauri_plugin_opener::OpenerExt; + + let app = context.service.app_handle.clone(); + let deeplink: String = $deeplink; + app + .opener() + .open_url(&deeplink, None::<&str>) + .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; + + Ok(rmcp::model::CallToolResult::success(vec![ + rmcp::model::Content::text(format!("Opened deeplink: {deeplink}")), + ])) + } + .boxed() + }, + ) + }}; +} diff --git a/src-tauri/src/intelligence/mcp_server/launcher/mod.rs b/src-tauri/src/intelligence/mcp_server/launcher/mod.rs new file mode 100644 index 000000000..7c9feece1 --- /dev/null +++ b/src-tauri/src/intelligence/mcp_server/launcher/mod.rs @@ -0,0 +1,159 @@ +mod r#macro; +pub mod tools; + +use crate::error::{SJMCLError, SJMCLResult}; +use crate::launcher_config::models::LauncherMcpServerConfig; +use crate::utils::sys_info::find_free_port; +use rmcp::handler::server::router::Router; +use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerInfo}; +use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, +}; +use rmcp::{ErrorData as McpError, ServerHandler}; +use serde::Serialize; +use tauri::AppHandle; + +pub const MCP_SERVER_HOST: &str = "127.0.0.1"; +pub const MCP_SERVER_PATH: &str = "/mcp"; + +#[derive(Clone)] +pub struct McpContext { + pub app_handle: AppHandle, +} + +impl McpContext { + pub fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +impl ServerHandler for McpContext { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation { + name: "sjmcl-mcp".to_string(), + title: Some("SJMCL MCP".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + description: Some("MCP tools exposed by SJMCL, a modern Minecraft launcher".to_string()), + icons: None, + website_url: None, + }, + instructions: Some( + "Use tools to query Minecraft instances and accounts managed by SJMC Launcher. For tools requiring instance_id (for example retrieve_game_server_list), call retrieve_instance_list first and pass one returned id. This server is intended for local trusted clients." + .to_string(), + ), + ..Default::default() + } + } +} + +pub fn command_result_to_tool_result( + command_result: SJMCLResult, +) -> Result +where + T: Serialize, +{ + let value = command_result.map_err(|e| McpError::internal_error(e.0, None))?; + + let json_value = serde_json::to_value(value).map_err(|e| { + McpError::internal_error(format!("failed to serialize command result: {e}"), None) + })?; + + // convert to object, support most MCP clients (e.g. Claude Desktop) + let structured_content = match json_value { + serde_json::Value::Object(_) => json_value, + serde_json::Value::Array(_) => serde_json::json!({ "items": json_value }), + _ => serde_json::json!({ "value": json_value }), + }; + + Ok(CallToolResult::structured(structured_content)) +} + +pub fn run(app_handle: AppHandle, mcp_config: &LauncherMcpServerConfig) { + let enabled = mcp_config.enabled; + let port = mcp_config.port; + + if !enabled { + return; + } + + // bind preset fixed port. + match find_free_port(Some(port)) { + Ok(free_port) if free_port == port => {} + Ok(free_port) => { + log::warn!( + "MCP server unavailable, configured port {} is occupied (next free: {}).", + port, + free_port + ); + return; + } + Err(err) => { + log::warn!( + "MCP server unavailable, failed to probe free port: {}", + err.0 + ); + return; + } + } + + let bind_addr = format!("{MCP_SERVER_HOST}:{port}"); + let listener = (|| -> std::io::Result { + let std_listener = std::net::TcpListener::bind(&bind_addr)?; + std_listener.set_nonblocking(true)?; + tokio::net::TcpListener::from_std(std_listener) + })(); + let listener = match listener { + Ok(listener) => listener, + Err(err) => { + log::warn!("MCP server unavailable, failed to prepare listener on {bind_addr}: {err}"); + return; + } + }; + + log::info!("MCP server endpoint: http://{bind_addr}{MCP_SERVER_PATH}"); + + // spawn MCP server + tauri::async_runtime::spawn(async move { + if let Err(e) = serve(app_handle.clone(), listener, port).await { + log::error!("MCP HTTP server exited with error: {}", e.0); + } + }); +} + +async fn serve( + app_handle: AppHandle, + listener: tokio::net::TcpListener, + port: u16, +) -> SJMCLResult<()> { + let service_app_handle = app_handle.clone(); + let service: StreamableHttpService, LocalSessionManager> = + StreamableHttpService::new( + move || { + Ok( + Router::new(McpContext::new(service_app_handle.clone())).with_tools(tools::tool_routes()), + ) + }, + Default::default(), + StreamableHttpServerConfig { + stateful_mode: true, + ..Default::default() + }, + ); + + let axum_router = axum::Router::new().nest_service(MCP_SERVER_PATH, service); + let bind_addr = format!("{MCP_SERVER_HOST}:{port}"); + + log::info!( + "MCP HTTP server listening on http://{}{}", + bind_addr, + MCP_SERVER_PATH + ); + + axum::serve(listener, axum_router) + .await + .map_err(|e| SJMCLError(e.to_string()))?; + + Ok(()) +} diff --git a/src-tauri/src/intelligence/mcp_server/launcher/tools/account.rs b/src-tauri/src/intelligence/mcp_server/launcher/tools/account.rs new file mode 100644 index 000000000..82bd99c7a --- /dev/null +++ b/src-tauri/src/intelligence/mcp_server/launcher/tools/account.rs @@ -0,0 +1,39 @@ +use crate::intelligence::mcp_server::launcher::McpContext; +use crate::mcp_tool; +use rmcp::handler::server::tool::ToolRoute; + +pub fn tool_routes() -> Vec> { + vec![ + mcp_tool!( + "retrieve_player_list", + "Retrieve all Minecraft account(player) profiles stored in the launcher, including offline, Microsoft and 3rd-party authenticated accounts.", + |app, _params: rmcp::model::JsonObject| async move { + let mut players = crate::account::commands::retrieve_player_list(app)?; + // remove token and base64 texture data in MCP responses to reduce context length. + for player in &mut players { + player.avatar = Vec::new(); + player.textures = Vec::new(); + player.access_token = None; + player.refresh_token = None; + } + Ok(players) + } + ), + mcp_tool!( + "select_player", + "A shortcut tool to update selected player by its ID in launcher config, which will be used for Minecraft game launches. Player ID can be obtained from retrieve_player_list tool.", + |app, params: rmcp::model::JsonObject| async move { + let json_value = serde_json::Value::Object(params); + let id = json_value["id"].as_str().unwrap_or("").trim(); + + let value = + serde_json::to_string(&id).map_err(|e| crate::error::SJMCLError(e.to_string()))?; + crate::launcher_config::commands::update_launcher_config( + app, + "states.shared.selectedPlayerId".to_string(), + value, + ) + } + ), + ] +} diff --git a/src-tauri/src/intelligence/mcp_server/launcher/tools/debug.rs b/src-tauri/src/intelligence/mcp_server/launcher/tools/debug.rs new file mode 100644 index 000000000..b89a660ca --- /dev/null +++ b/src-tauri/src/intelligence/mcp_server/launcher/tools/debug.rs @@ -0,0 +1,26 @@ +use crate::intelligence::mcp_server::launcher::McpContext; +use crate::mcp_tool; +use rmcp::handler::server::tool::{parse_json_object, ToolCallContext, ToolRoute}; +use rmcp::model::{CallToolResult, Content}; +use serde::Deserialize; + +// For debugging. +#[derive(Debug, Clone, Deserialize)] +pub struct EchoTextParams { + pub text: String, +} + +async fn echo_text( + mut context: ToolCallContext<'_, McpContext>, +) -> Result { + let params: EchoTextParams = parse_json_object(context.arguments.take().unwrap_or_default())?; + Ok(CallToolResult::success(vec![Content::text(params.text)])) +} + +pub fn tool_routes() -> Vec> { + vec![mcp_tool!( + raw "echo_text", + echo_text, + "Echo the provided text. Demonstrates arbitrary Rust function tool wiring." + )] +} diff --git a/src-tauri/src/intelligence/mcp_server/launcher/tools/instance.rs b/src-tauri/src/intelligence/mcp_server/launcher/tools/instance.rs new file mode 100644 index 000000000..f9706e161 --- /dev/null +++ b/src-tauri/src/intelligence/mcp_server/launcher/tools/instance.rs @@ -0,0 +1,89 @@ +use crate::intelligence::mcp_server::launcher::McpContext; +use crate::mcp_tool; +use rmcp::handler::server::tool::ToolRoute; + +pub fn tool_routes() -> Vec> { + vec![ + mcp_tool!( + "retrieve_instance_list", + crate::instance::commands::retrieve_instance_list, + "List all local Minecraft instances and return their IDs and metadata. Use this first when another tool requires instance_id." + ), + mcp_tool!( + "retrieve_world_list", + crate::instance::commands::retrieve_world_list, + "Retrieve metadata of local worlds (saves) in the given Minecraft instance. Input param: instance_id (string).", + { instance_id: String } + ), + mcp_tool!( + "retrieve_game_server_list", + "Retrieve metadata of servers configured in the given Minecraft instance and query online status. Input param: instance_id (string). Call retrieve_instance_list first to get a valid instance_id.", + |app, params: rmcp::model::JsonObject| async move { + let instance_id = params["instance_id"] + .as_str() + .unwrap_or_default() + .to_string(); + // always query online status in MCP context. + crate::instance::commands::retrieve_game_server_list(app, instance_id, true).await + } + ), + mcp_tool!( + "retrieve_local_mod_list", + "Retrieve metadata of local mods in the given Minecraft instance. Input param: instance_id (string).", + |app, params: rmcp::model::JsonObject| async move { + let instance_id = params["instance_id"] + .as_str() + .unwrap_or_default() + .to_string(); + let mut mods = crate::instance::commands::retrieve_local_mod_list(app, instance_id).await?; + // strip icon binary payload in MCP responses to reduce context length. + for mod_info in &mut mods { + mod_info.icon_src = Default::default(); + } + Ok(mods) + } + ), + mcp_tool!( + "retrieve_resource_pack_list", + crate::instance::commands::retrieve_resource_pack_list, + "Retrieve resource packs in the given Minecraft instance. Input param: instance_id (string).", + { instance_id: String } + ), + mcp_tool!( + "retrieve_server_resource_pack_list", + crate::instance::commands::retrieve_server_resource_pack_list, + "Retrieve server resource packs in the given Minecraft instance. Input param: instance_id (string).", + { instance_id: String } + ), + mcp_tool!( + sync "retrieve_schematic_list", + crate::instance::commands::retrieve_schematic_list, + "Retrieve schematics in the given Minecraft instance. Input param: instance_id (string).", + { instance_id: String } + ), + mcp_tool!( + sync "retrieve_shader_pack_list", + crate::instance::commands::retrieve_shader_pack_list, + "Retrieve shader packs in the given Minecraft instance. Input param: instance_id (string).", + { instance_id: String } + ), + mcp_tool!( + sync "retrieve_screenshot_list", + crate::instance::commands::retrieve_screenshot_list, + "Retrieve screenshots in the given Minecraft instance. Input param: instance_id (string).", + { instance_id: String } + ), + mcp_tool!( + "toggle_mod_by_extension", + "Enable or disable a mod file by toggling .disabled extension. Input params: file_path (string), enable (boolean). File path can be obtained from retrieve_local_mod_list tool.", + |_app, params: rmcp::model::JsonObject| async move { + let file_path = params["file_path"].as_str().unwrap_or_default().to_string(); + let enable = params["enable"].as_bool().unwrap_or(true); + crate::instance::commands::toggle_mod_by_extension( + std::path::PathBuf::from(file_path), + enable, + ) + } + ), + ] +} diff --git a/src-tauri/src/intelligence/mcp_server/launcher/tools/launch.rs b/src-tauri/src/intelligence/mcp_server/launcher/tools/launch.rs new file mode 100644 index 000000000..359da8f56 --- /dev/null +++ b/src-tauri/src/intelligence/mcp_server/launcher/tools/launch.rs @@ -0,0 +1,14 @@ +use crate::intelligence::mcp_server::launcher::McpContext; +use crate::mcp_tool; +use rmcp::handler::server::tool::ToolRoute; + +pub fn tool_routes() -> Vec> { + vec![mcp_tool!( + deeplink "launch_instance", + "Launch a specific Minecraft instance by its instance_id via SJMCL deeplink. Before launch, check and update, if user requested, the selected player in launcher configuration.", + |params| { instance_id: String } => format!( + "sjmcl://launch?id={}", + urlencoding::encode(¶ms.instance_id) + ) + )] +} diff --git a/src-tauri/src/intelligence/mcp_server/launcher/tools/launcher_config.rs b/src-tauri/src/intelligence/mcp_server/launcher/tools/launcher_config.rs new file mode 100644 index 000000000..2b08cefdc --- /dev/null +++ b/src-tauri/src/intelligence/mcp_server/launcher/tools/launcher_config.rs @@ -0,0 +1,19 @@ +use crate::intelligence::mcp_server::launcher::McpContext; +use crate::mcp_tool; +use rmcp::handler::server::tool::ToolRoute; + +pub fn tool_routes() -> Vec> { + vec![ + mcp_tool!( + sync "retrieve_launcher_config", + crate::launcher_config::commands::retrieve_launcher_config, + "Retrieve full launcher configuration snapshot. This includes game launch settings, game directory settings, launcher app settings, currently selected instance/account, and other global preferences." + ), + mcp_tool!( + sync "update_launcher_config", + crate::launcher_config::commands::update_launcher_config, + "Update a launcher config field by key_path. Use this to change game settings, launcher app settings, selected instance/account ID, and other config values.", + { key_path: String, value: String } + ), + ] +} diff --git a/src-tauri/src/intelligence/mcp_server/launcher/tools/mod.rs b/src-tauri/src/intelligence/mcp_server/launcher/tools/mod.rs new file mode 100644 index 000000000..3666caa68 --- /dev/null +++ b/src-tauri/src/intelligence/mcp_server/launcher/tools/mod.rs @@ -0,0 +1,22 @@ +mod account; +mod debug; +mod instance; +mod launch; +mod launcher_config; +mod resource; + +use crate::intelligence::mcp_server::launcher::McpContext; +use rmcp::handler::server::tool::ToolRoute; + +pub fn tool_routes() -> Vec> { + let mut routes = Vec::new(); + + routes.extend(launcher_config::tool_routes()); + routes.extend(account::tool_routes()); + routes.extend(instance::tool_routes()); + routes.extend(launch::tool_routes()); + routes.extend(resource::tool_routes()); + routes.extend(debug::tool_routes()); + + routes +} diff --git a/src-tauri/src/intelligence/mcp_server/launcher/tools/resource.rs b/src-tauri/src/intelligence/mcp_server/launcher/tools/resource.rs new file mode 100644 index 000000000..76c321a6a --- /dev/null +++ b/src-tauri/src/intelligence/mcp_server/launcher/tools/resource.rs @@ -0,0 +1,11 @@ +use crate::intelligence::mcp_server::launcher::McpContext; +use crate::mcp_tool; +use rmcp::handler::server::tool::ToolRoute; + +pub fn tool_routes() -> Vec> { + vec![mcp_tool!( + "fetch_game_version_list", + crate::resource::commands::fetch_game_version_list, + "Fetch the list of available Minecraft game versions." + )] +} diff --git a/src-tauri/src/intelligence/mcp_server/mod.rs b/src-tauri/src/intelligence/mcp_server/mod.rs new file mode 100644 index 000000000..0f21b796a --- /dev/null +++ b/src-tauri/src/intelligence/mcp_server/mod.rs @@ -0,0 +1 @@ +pub mod launcher; diff --git a/src-tauri/src/intelligence/mod.rs b/src-tauri/src/intelligence/mod.rs new file mode 100644 index 000000000..b8870b84c --- /dev/null +++ b/src-tauri/src/intelligence/mod.rs @@ -0,0 +1 @@ +pub mod mcp_server; diff --git a/src-tauri/src/launcher_config/models.rs b/src-tauri/src/launcher_config/models.rs index e33681b60..17fefe051 100644 --- a/src-tauri/src/launcher_config/models.rs +++ b/src-tauri/src/launcher_config/models.rs @@ -266,6 +266,16 @@ structstruck::strike! { pub auto_purge_launcher_logs: bool, } }, + pub intelligence: struct Intelligence { + pub mcp_server: struct { + pub launcher: struct LauncherMcpServerConfig{ + #[default = true] + pub enabled: bool, + #[default = 18970] + pub port: u16, + }, + } + }, pub global_game_config: GameConfig, pub local_game_directories: Vec, // Changed from Vec to Vec<(String, bool)> with default enabled=true diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95aa4055b..d23f58a8d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod account; mod discover; mod error; mod instance; +mod intelligence; mod launch; mod launcher_config; mod partial; @@ -179,6 +180,7 @@ pub async fn run() { let os = launcher_config.basic_info.platform.clone(); let exe_sha256 = launcher_config.basic_info.exe_sha256.clone(); let auto_purge_launcher_logs = launcher_config.general.advanced.auto_purge_launcher_logs; + let launcher_mcp_config = launcher_config.intelligence.mcp_server.launcher.clone(); app.manage(Mutex::new(launcher_config)); let account_info = AccountInfo::load().unwrap_or_default(); @@ -284,6 +286,11 @@ pub async fn run() { app.deep_link().register_all()?; } + // Start the launcher MCP server if enabled + if launcher_mcp_config.enabled { + intelligence::mcp_server::launcher::run(app.handle().clone(), &launcher_mcp_config); + } + Ok(()) }) .run(tauri::generate_context!()) diff --git a/src-tauri/src/resource/commands.rs b/src-tauri/src/resource/commands.rs index 3bcfd05dd..d19942594 100644 --- a/src-tauri/src/resource/commands.rs +++ b/src-tauri/src/resource/commands.rs @@ -30,13 +30,11 @@ use tauri::{AppHandle, Manager, State}; use tauri_plugin_http::reqwest; #[tauri::command] -pub async fn fetch_game_version_list( - app: AppHandle, - state: State<'_, Mutex>, -) -> SJMCLResult> { +pub async fn fetch_game_version_list(app: AppHandle) -> SJMCLResult> { let priority_list = { - let state = state.lock()?; - get_source_priority_list(&state) + let launcher_config_state = app.state::>(); + let launcher_config = launcher_config_state.lock()?; + get_source_priority_list(&launcher_config) }; get_game_version_manifest(&app, &priority_list).await } @@ -44,10 +42,9 @@ pub async fn fetch_game_version_list( #[tauri::command] pub async fn fetch_game_version_specific( app: AppHandle, - state: State<'_, Mutex>, game_version: String, ) -> SJMCLResult { - let all_versions = fetch_game_version_list(app.clone(), state).await?; + let all_versions = fetch_game_version_list(app.clone()).await?; all_versions .into_iter() diff --git a/src/layouts/settings-layout.tsx b/src/layouts/settings-layout.tsx index 24304b996..343ed6e03 100644 --- a/src/layouts/settings-layout.tsx +++ b/src/layouts/settings-layout.tsx @@ -13,6 +13,7 @@ import { LuPalette, LuRefreshCcw, LuSettings, + LuSparkles, } from "react-icons/lu"; import NavMenu from "@/components/common/nav-menu"; import { isDev } from "@/utils/env"; @@ -34,6 +35,7 @@ const SettingsLayout: React.FC = ({ children }) => { { key: "general", icon: LuSettings }, { key: "appearance", icon: LuPalette }, { key: "download", icon: LuCloudDownload }, + { key: "intelligence", icon: LuSparkles }, { key: "sync-restore", icon: LuRefreshCcw }, { key: "help", icon: LuCircleHelp }, { key: "about", icon: LuInfo }, diff --git a/src/locales/en.json b/src/locales/en.json index c8898b8fb..b3ef23548 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1315,6 +1315,29 @@ "launch": "Play this Server" } }, + "IntelligenceSettingsPage": { + "title": "SJMCL Intelligence", + "description": "Integrate AI models into your launcher and Minecraft gameplay experience.", + "mcpServer": { + "title": "MCP Service", + "headExtra": "Restart Required", + "settings": { + "enabled": { + "title": "Launcher MCP Service", + "description": "Enable the local MCP service to connect external agent applications for automated launcher control." + }, + "docs": { + "title": "User Guide", + "description": "Learn how to connect the launcher to external agent applications (such as Claude, Cherry Studio, and more).", + "url": "https://mc.sjtu.cn/sjmcl/en/docs/intelligence/launcher-mcp" + }, + "port": { + "title": "Port", + "description": "The fixed local port used by the launcher MCP service." + } + } + } + }, "JavaSettingsPage": { "javaList": { "title": "Java Management", @@ -2456,6 +2479,7 @@ "general": "General", "appearance": "Appearance", "download": "Download", + "intelligence": "Intelligence", "sync-restore": "Sync & Restore", "help": "Docs & Help", "about": "About", @@ -2611,4 +2635,4 @@ "title": "World Level Data - {{worldName}}" } } -} \ No newline at end of file +} diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index fc4a90e0d..cd565ac05 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1315,6 +1315,29 @@ "launch": "游玩此服务器" } }, + "IntelligenceSettingsPage": { + "title": "SJMCL 智能", + "description": "将人工智能模型整合进您的启动器使用与 Minecraft 游玩体验", + "mcpServer": { + "title": "MCP 服务", + "headExtra": "以下设置重启后生效", + "settings": { + "enabled": { + "title": "启动器 MCP 服务", + "description": "启用本地 MCP 服务,以连接外部 Agent 应用实现启动器的自动化控制" + }, + "docs": { + "title": "使用指南", + "description": "了解如何将启动器连接到外部 Agent 应用(如 Claude, Cherry Studio 等)", + "url": "https://mc.sjtu.cn/sjmcl/zh/docs/intelligence/launcher-mcp" + }, + "port": { + "title": "端口", + "description": "启动器 MCP 服务使用的固定本地端口" + } + } + } + }, "JavaSettingsPage": { "javaList": { "title": "Java 管理", @@ -2456,6 +2479,7 @@ "general": "通用", "appearance": "外观", "download": "下载资源", + "intelligence": "智能", "sync-restore": "同步与还原", "help": "文档与帮助", "about": "关于", @@ -2611,4 +2635,4 @@ "title": "世界基础数据 - {{worldName}}" } } -} \ No newline at end of file +} diff --git a/src/models/config.ts b/src/models/config.ts index 8e62d55d1..b78bfa27d 100644 --- a/src/models/config.ts +++ b/src/models/config.ts @@ -127,6 +127,14 @@ export interface LauncherConfig { autoPurgeLauncherLogs: boolean; }; }; + intelligence: { + mcpServer: { + launcher: { + enabled: boolean; + port: number; + }; + }; + }; localGameDirectories: GameDirectory[]; globalGameConfig: GameConfig; discoverSourceEndpoints: [string, boolean][]; @@ -286,6 +294,14 @@ export const defaultConfig: LauncherConfig = { autoPurgeLauncherLogs: true, }, }, + intelligence: { + mcpServer: { + launcher: { + enabled: true, + port: 18970, + }, + }, + }, localGameDirectories: [{ name: "Current", dir: ".minecraft/" }], globalGameConfig: defaultGameConfig, discoverSourceEndpoints: [ diff --git a/src/pages/settings/intelligence.tsx b/src/pages/settings/intelligence.tsx new file mode 100644 index 000000000..14077cbf4 --- /dev/null +++ b/src/pages/settings/intelligence.tsx @@ -0,0 +1,174 @@ +import { + Box, + Icon, + NumberInput, + NumberInputField, + Switch, + Text, + useColorModeValue, +} from "@chakra-ui/react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuSparkles } from "react-icons/lu"; +import { CommonIconButton } from "@/components/common/common-icon-button"; +import { + OptionItemGroup, + OptionItemGroupProps, +} from "@/components/common/option-item"; +import { useLauncherConfig } from "@/contexts/config"; + +const IntelligenceSettingsPage = () => { + const { t } = useTranslation(); + const { config, update } = useLauncherConfig(); + const primaryColor = config.appearance.theme.primaryColor; + + const [port, setPort] = useState( + config.intelligence.mcpServer.launcher.port + ); + + useEffect(() => { + setPort(config.intelligence.mcpServer.launcher.port); + }, [config.intelligence.mcpServer.launcher.port]); + + const SparklesIconBox = () => { + const bg = useColorModeValue( + // light mode: colorful background + ` + radial-gradient(circle at top left, #4299E1 0%, transparent 70%), // blue.400 + radial-gradient(circle at top right, #ED64A6 0%, transparent 70%), // pink.400 + radial-gradient(circle at bottom left, #ED8936 0%, transparent 70%), // orange.400 + radial-gradient(circle at bottom right, #ED64A6 0%, transparent 70%) + `, + // dark mode: neutral gray background + "linear-gradient(135deg, #171923, #2D3748)" + ); + + return ( + + + + ); + }; + + const settingsGroups: OptionItemGroupProps[] = [ + { + items: [ + { + prefixElement: , + title: t("IntelligenceSettingsPage.title"), + description: t("IntelligenceSettingsPage.description"), + children: <>, + }, + ], + }, + { + title: t("IntelligenceSettingsPage.mcpServer.title"), + headExtra: ( + + + {t("IntelligenceSettingsPage.mcpServer.headExtra")} + + + ), + items: [ + { + title: t("IntelligenceSettingsPage.mcpServer.settings.enabled.title"), + description: t( + "IntelligenceSettingsPage.mcpServer.settings.enabled.description" + ), + children: ( + { + update( + "intelligence.mcpServer.launcher.enabled", + e.target.checked + ); + }} + /> + ), + }, + ...(config.intelligence.mcpServer.launcher.enabled + ? [ + { + title: t( + "IntelligenceSettingsPage.mcpServer.settings.docs.title" + ), + description: t( + "IntelligenceSettingsPage.mcpServer.settings.docs.description" + ), + children: ( + { + openUrl( + t( + "IntelligenceSettingsPage.mcpServer.settings.docs.url" + ) + ); + }} + /> + ), + }, + { + title: t( + "IntelligenceSettingsPage.mcpServer.settings.port.title" + ), + description: t( + "IntelligenceSettingsPage.mcpServer.settings.port.description" + ), + children: ( + { + if (!/^\d*$/.test(value)) return; + setPort(Number(value)); + }} + onBlur={() => { + const nextPort = Math.max( + 1, + Math.min(port || 18970, 65535) + ); + setPort(nextPort); + update("intelligence.mcpServer.launcher.port", nextPort); + }} + > + + + ), + }, + ] + : []), + ], + }, + ]; + + return ( + <> + {settingsGroups.map((group, index) => ( + + ))} + + ); +}; + +export default IntelligenceSettingsPage;