From de4935b54d0aa5a7f4560f5b4b80ac86fb0c9f22 Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 20 Mar 2026 21:13:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=A8=E7=8E=B0=E6=9C=89=20Rust=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=96=B0=E5=A2=9E=20MCP=20=E6=9C=8D=E5=8A=A1=E4=B8=8E?= =?UTF-8?q?=E5=8A=A0=E5=88=86=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/mcp.rs | 419 ++++++++++++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 3 + 4 files changed, 425 insertions(+) create mode 100644 src-tauri/src/commands/mcp.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ac2c981..1a617e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } +axum = "0.7" sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres"] } sea-orm = { version = "0.12", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls"] } uuid = { version = "1", features = ["v4", "serde"] } diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs new file mode 100644 index 0000000..5fc86ec --- /dev/null +++ b/src-tauri/src/commands/mcp.rs @@ -0,0 +1,419 @@ +use axum::{ + extract::State as AxumState, + http::StatusCode, + response::{IntoResponse, Response}, + routing::post, + Json, Router, +}; +use parking_lot::RwLock; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::net::SocketAddr; +use std::sync::Arc; +use tauri::State; +use tokio::sync::{oneshot, Mutex}; +use uuid::Uuid; + +use crate::db::entities::{score_events, students}; +use crate::services::permission::PermissionLevel; +use crate::state::AppState; + +use super::database::realtime_dual_write_sync; +use super::response::IpcResponse; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerConfig { + pub port: u16, + pub host: String, +} + +impl Default for McpServerConfig { + fn default() -> Self { + Self { + port: 3901, + host: "127.0.0.1".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerStartResult { + pub url: String, + pub config: McpServerConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerStatus { + pub is_running: bool, + pub config: McpServerConfig, + pub url: Option, +} + +struct McpServerState { + pub is_running: bool, + pub config: McpServerConfig, + pub url: Option, + pub shutdown_tx: Option>, +} + +impl Default for McpServerState { + fn default() -> Self { + Self { + is_running: false, + config: McpServerConfig::default(), + url: None, + shutdown_tx: None, + } + } +} + +#[derive(Debug, Deserialize)] +struct McpRequest { + #[allow(dead_code)] + jsonrpc: Option, + id: Option, + method: String, + params: Option, +} + +#[derive(Debug, Deserialize)] +struct ToolCallParams { + name: String, + #[serde(default)] + arguments: Value, +} + +#[derive(Debug, Deserialize)] +struct AddScoreArgs { + student_name: String, + delta: i32, + #[serde(default)] + reason_content: Option, +} + +#[derive(Debug, Serialize)] +struct AddScoreResult { + event_id: i32, + event_uuid: String, + student_name: String, + delta: i32, + val_prev: i32, + val_curr: i32, + reason_content: String, + event_time: String, +} + +static MCP_SERVER_STATE: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(McpServerState::default()))); + +fn check_admin_permission(state: &Arc>) -> Result<(), String> { + let state_guard = state.read(); + let mut permissions = state_guard.permissions.write(); + if !permissions.require_permission(0, PermissionLevel::Admin) { + return Err("Permission denied: Admin required".to_string()); + } + Ok(()) +} + +fn jsonrpc_ok(id: Value, result: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + }) +} + +fn jsonrpc_error(id: Value, code: i32, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }) +} + +fn initialize_result() -> Value { + json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": { + "listChanged": false + } + }, + "serverInfo": { + "name": "secscore-mcp", + "version": "1.0.0" + } + }) +} + +fn tools_list_result() -> Value { + json!({ + "tools": [ + { + "name": "add_score", + "description": "给指定学生加分/扣分,并写入 score_events 记录。", + "inputSchema": { + "type": "object", + "properties": { + "student_name": {"type": "string", "description": "学生姓名"}, + "delta": {"type": "integer", "description": "分值变化,正数加分,负数扣分"}, + "reason_content": {"type": "string", "description": "加分原因,默认 MCP 加分"} + }, + "required": ["student_name", "delta"] + } + } + ] + }) +} + +async fn handle_mcp( + AxumState(app_state): AxumState>>, + Json(payload): Json, +) -> Response { + let req = match serde_json::from_value::(payload) { + Ok(v) => v, + Err(e) => { + let body = jsonrpc_error(Value::Null, -32700, &format!("Parse error: {}", e)); + return (StatusCode::OK, Json(body)).into_response(); + } + }; + + let Some(id) = req.id.clone() else { + return StatusCode::NO_CONTENT.into_response(); + }; + + let response = match req.method.as_str() { + "initialize" => jsonrpc_ok(id, initialize_result()), + "tools/list" => jsonrpc_ok(id, tools_list_result()), + "tools/call" => { + let params = req + .params + .and_then(|p| serde_json::from_value::(p).ok()); + + match params { + None => jsonrpc_error(id, -32602, "Invalid tools/call params"), + Some(params) if params.name != "add_score" => { + jsonrpc_error(id, -32601, &format!("Unknown tool: {}", params.name)) + } + Some(params) => match serde_json::from_value::(params.arguments) { + Ok(args) => match mcp_add_score(&app_state, args).await { + Ok(payload) => jsonrpc_ok( + id, + json!({ + "content": [ + { + "type": "text", + "text": format!( + "已记录:{} {:+} 分({} -> {})", + payload.student_name, payload.delta, payload.val_prev, payload.val_curr + ) + } + ], + "isError": false, + "structuredContent": payload + }), + ), + Err(e) => jsonrpc_ok( + id, + json!({ + "content": [ + { + "type": "text", + "text": format!("加分失败: {}", e) + } + ], + "isError": true + }), + ), + }, + Err(e) => jsonrpc_error(id, -32602, &format!("Invalid arguments: {}", e)), + }, + } + } + "notifications/initialized" => return StatusCode::NO_CONTENT.into_response(), + _ => jsonrpc_error(id, -32601, &format!("Method not found: {}", req.method)), + }; + + (StatusCode::OK, Json(response)).into_response() +} + +async fn mcp_add_score( + app_state: &Arc>, + args: AddScoreArgs, +) -> Result { + let student_name = args.student_name.trim(); + if student_name.is_empty() { + return Err("student_name 不能为空".to_string()); + } + + let reason_content = args + .reason_content + .as_deref() + .unwrap_or("MCP 加分") + .trim() + .to_string(); + + let db_conn = { + let state_guard = app_state.read(); + let db_guard = state_guard.db.read(); + db_guard.clone() + } + .ok_or_else(|| "Database not connected".to_string())?; + + let student = students::Entity::find() + .filter(students::Column::Name.eq(student_name)) + .one(&db_conn) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Student not found: {}", student_name))?; + + let val_prev = student.score; + let val_curr = val_prev + args.delta; + let reward_curr = student.reward_points + args.delta; + let event_time = chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + let event_uuid = Uuid::new_v4().to_string(); + + let txn = db_conn.begin().await.map_err(|e| e.to_string())?; + + let new_event = score_events::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + uuid: Set(event_uuid.clone()), + student_name: Set(student_name.to_string()), + reason_content: Set(reason_content.clone()), + delta: Set(args.delta), + val_prev: Set(val_prev), + val_curr: Set(val_curr), + event_time: Set(event_time.clone()), + settlement_id: Set(None), + }; + + let inserted = new_event.insert(&txn).await.map_err(|e| e.to_string())?; + + let mut student_model: students::ActiveModel = student.into(); + student_model.score = Set(val_curr); + student_model.reward_points = Set(reward_curr); + student_model.updated_at = Set(event_time.clone()); + student_model + .update(&txn) + .await + .map_err(|e| e.to_string())?; + + txn.commit().await.map_err(|e| e.to_string())?; + + realtime_dual_write_sync(app_state).await?; + + Ok(AddScoreResult { + event_id: inserted.id, + event_uuid, + student_name: student_name.to_string(), + delta: args.delta, + val_prev, + val_curr, + reason_content, + event_time, + }) +} + +#[tauri::command] +pub async fn mcp_server_start( + config: Option, + state: State<'_, Arc>>, +) -> Result, String> { + check_admin_permission(&state)?; + + let mut server_state = MCP_SERVER_STATE.lock().await; + if server_state.is_running { + return Ok(IpcResponse::failure_with_type( + "MCP server is already running", + )); + } + + let config = config.unwrap_or_default(); + + let host: std::net::IpAddr = config + .host + .parse() + .map_err(|e| format!("Invalid host address: {}", e))?; + + let bind_addr: SocketAddr = (host, config.port).into(); + + let listener = tokio::net::TcpListener::bind(bind_addr) + .await + .map_err(|e| format!("Failed to bind MCP server: {}", e))?; + + let local_addr = listener + .local_addr() + .map_err(|e| format!("Failed to get local addr: {}", e))?; + + let app_state = state.inner().clone(); + let router = Router::new() + .route("/mcp", post(handle_mcp)) + .with_state(app_state); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + tauri::async_runtime::spawn(async move { + let server = axum::serve(listener, router).with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }); + + if let Err(e) = server.await { + eprintln!("MCP server error: {}", e); + } + }); + + let url = format!("http://{}:{}/mcp", local_addr.ip(), local_addr.port()); + + server_state.is_running = true; + server_state.config = McpServerConfig { + host: local_addr.ip().to_string(), + port: local_addr.port(), + }; + server_state.url = Some(url.clone()); + server_state.shutdown_tx = Some(shutdown_tx); + + Ok(IpcResponse::success(McpServerStartResult { + url, + config: server_state.config.clone(), + })) +} + +#[tauri::command] +pub async fn mcp_server_stop( + state: State<'_, Arc>>, +) -> Result, String> { + check_admin_permission(&state)?; + + let mut server_state = MCP_SERVER_STATE.lock().await; + + if !server_state.is_running { + return Ok(IpcResponse::success_empty()); + } + + if let Some(tx) = server_state.shutdown_tx.take() { + let _ = tx.send(()); + } + + server_state.is_running = false; + server_state.url = None; + + Ok(IpcResponse::success_empty()) +} + +#[tauri::command] +pub async fn mcp_server_status( + _state: State<'_, Arc>>, +) -> Result, String> { + let server_state = MCP_SERVER_STATE.lock().await; + + Ok(IpcResponse::success(McpServerStatus { + is_running: server_state.is_running, + config: server_state.config.clone(), + url: server_state.url.clone(), + })) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5b3419c..a2c92c3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod event; pub mod filesystem; pub mod http_server; pub mod log; +pub mod mcp; pub mod reason; pub mod response; pub mod reward; @@ -28,6 +29,7 @@ pub use event::*; pub use filesystem::*; pub use http_server::*; pub use log::*; +pub use mcp::*; pub use reason::*; pub use response::*; pub use reward::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 408dab1..3603139 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -114,6 +114,9 @@ pub fn run() { http_server_start, http_server_stop, http_server_status, + mcp_server_start, + mcp_server_stop, + mcp_server_status, register_url_protocol, app_quit, app_restart,