[WIP] feat: add launcher mcp server (initial version)#1370
[WIP] feat: add launcher mcp server (initial version)#1370
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an initial “Intelligence” feature area to SJMCL, including a local MCP (Model Context Protocol) HTTP server in the Tauri backend and a new Settings page in the Next.js frontend to configure it.
Changes:
- Introduce a new Settings → Intelligence page with MCP server enable/port controls and i18n strings.
- Add an
intelligence.mcpServer.launcherconfig section (frontend + backend config models) and start the MCP server on app setup when enabled. - Implement an MCP server (rmcp + streamable HTTP) exposing several launcher tools (config/account/instance/launch/resource/debug).
Reviewed changes
Copilot reviewed 21 out of 22 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/pages/settings/intelligence.tsx | New Intelligence settings UI (MCP enable + port). |
| src/models/config.ts | Add intelligence.mcpServer.launcher config and defaults. |
| src/locales/en.json | Add Intelligence page strings + settings nav label. |
| src/locales/zh-Hans.json | Add Chinese translations + settings nav label. |
| src/layouts/settings-layout.tsx | Add “intelligence” entry to settings navigation. |
| src-tauri/src/resource/commands.rs | Refactor to read config via app.state() (better for MCP tool wiring). |
| src-tauri/src/lib.rs | Register intelligence module and start MCP server during setup. |
| src-tauri/src/launcher_config/models.rs | Add backend config structs for MCP server settings. |
| src-tauri/src/intelligence/mod.rs | New intelligence module root. |
| src-tauri/src/intelligence/mcp_server/mod.rs | MCP server module root. |
| src-tauri/src/intelligence/mcp_server/launcher/mod.rs | MCP HTTP server implementation + tool result adapter. |
| src-tauri/src/intelligence/mcp_server/launcher/macro.rs | mcp_tool! macro for wiring commands/tools. |
| src-tauri/src/intelligence/mcp_server/launcher/tools/* | Tool routes for config/account/instance/launch/resource/debug. |
| src-tauri/src/instance/commands.rs | Refactor to use app.state() instead of State<...> param. |
| src-tauri/Cargo.toml | Add rmcp + schemars dependencies. |
| src-tauri/Cargo.lock | Lockfile updates for new dependencies. |
| 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 |
There was a problem hiding this comment.
The bg string for the light-mode background includes // ... inline comments inside the template literal. Those characters become part of the CSS value and will make the background invalid in the browser. Remove the // comments (or replace with CSS-valid /* ... */ comments outside the string) so the gradient parses correctly.
| 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 top left, #4299E1 0%, transparent 70%), | |
| radial-gradient(circle at top right, #ED64A6 0%, transparent 70%), | |
| radial-gradient(circle at bottom left, #ED8936 0%, transparent 70%), |
| intelligence: { | ||
| mcpServer: { | ||
| launcher: { | ||
| enabled: true, |
There was a problem hiding this comment.
The MCP server is enabled by default (enabled: true). Since this starts an HTTP control surface on localhost that can trigger actions like updating config and launching instances, it should default to disabled and require explicit user opt-in (and ideally some authentication/secret) to reduce attack surface.
| enabled: true, | |
| enabled: false, |
| pub launcher: struct LauncherMcpServerConfig{ | ||
| #[default = true] | ||
| pub enabled: bool, | ||
| #[default = 18970] | ||
| pub port: u16, | ||
| pub is_available: bool, |
There was a problem hiding this comment.
LauncherMcpServerConfig.enabled defaults to true, which will start the MCP HTTP server for all users by default. For a server that exposes privileged launcher controls, a secure default is to keep it disabled until the user explicitly opts in (and ideally pair this with an auth mechanism / shared secret).
src/models/config.ts
Outdated
| launcher: { | ||
| enabled: boolean; | ||
| port: number; | ||
| isAvailable: boolean; |
There was a problem hiding this comment.
isAvailable is added to the config model but (as of this PR) it isn't read or updated anywhere, so it will stay at the default value and may confuse future readers. Either remove it until it's wired up, or update it from the backend when the MCP server successfully binds / fails to bind.
| isAvailable: boolean; |
| 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".to_string()), | ||
| icons: None, | ||
| website_url: None, | ||
| }, | ||
| instructions: Some( | ||
| "Use tools to query launcher states. This server is intended for local trusted clients." | ||
| .to_string(), | ||
| ), | ||
| ..Default::default() |
There was a problem hiding this comment.
The MCP server exposes privileged launcher controls over HTTP (config updates, launching instances via deeplinks, etc.) but there is no authentication/authorization check in the handler stack. Even though it binds to 127.0.0.1, any local process can invoke these tools; consider adding an opt-in shared secret (e.g., token in config + required header), and/or restricting access further (random ephemeral port, OS-level transport, etc.).
| 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; | ||
| } |
There was a problem hiding this comment.
LauncherMcpServerConfig.is_available is part of the config, but run() never updates it to reflect whether the server successfully bound or failed (e.g., port occupied). Either wire this up by updating the managed LauncherConfig state / emitting a partial update when the server starts/stops, or drop the field until it’s actually used.
| 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 |
There was a problem hiding this comment.
The light-mode gradient string includes // ... comments inside the template literal. // isn’t valid in CSS, so this will likely make the bg value invalid and the icon background won’t render as intended. Use CSS comments (/* ... */) or remove the inline comments from the gradient string.
| 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 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 */, |
| onChange={(value) => { | ||
| if (!/^\d*$/.test(value)) return; | ||
| setPort(Number(value)); |
There was a problem hiding this comment.
NumberInput allows an empty string while editing, but setPort(Number(value)) turns "" into 0. This prevents users from clearing the field (it snaps to 0) and can lead to confusing UX. Consider keeping a separate string state for the input and only parsing/clamping to a number on blur (or use Chakra’s valueAsNumber argument and handle NaN).
| onChange={(value) => { | |
| if (!/^\d*$/.test(value)) return; | |
| setPort(Number(value)); | |
| onChange={(valueAsString, valueAsNumber) => { | |
| if (!/^\d*$/.test(valueAsString)) return; | |
| if (Number.isNaN(valueAsNumber)) { | |
| // Allow the field to be cleared without snapping to 0 | |
| setPort(undefined as any); | |
| return; | |
| } | |
| setPort(valueAsNumber); |
| 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<tokio::net::TcpListener> { | ||
| 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<Router<McpContext>, 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()))?; |
There was a problem hiding this comment.
The MCP server starts an HTTP endpoint on 127.0.0.1 with no authentication/authorization or origin checks. Since tools can read accounts/instances and trigger actions (e.g., deeplinks/config updates), add an auth mechanism (e.g., per-install token), and/or apply strict request filtering (Origin/Host validation, CORS preflight denial) to reduce risk from untrusted local clients or browser-based attacks.
| "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." | ||
| } | ||
| } | ||
| } | ||
| }, |
There was a problem hiding this comment.
This file adds new locale keys, but the repo’s locale consistency check (npm run locale diff en / npm run locale diff zh-Hans in lint-staged) requires all locale JSON files to have identical key sets. Please add the same keys to fr.json, ja.json, and zh-Hant.json (with real translations; %TODO placeholders fail the check).
| "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." | |
| } | |
| } | |
| } | |
| }, |
| "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 服务使用的固定本地端口" | ||
| } | ||
| } | ||
| } | ||
| }, |
There was a problem hiding this comment.
Same locale-key consistency issue as in en.json: adding keys here requires updating all other locale files (fr.json, ja.json, zh-Hant.json) to keep key sets identical, otherwise npm run locale diff zh-Hans will fail.
| "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 服务使用的固定本地端口" | |
| } | |
| } | |
| } | |
| }, |
Checklist
This PR is a ..
Related Issues
Description
Additional Context