mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat: refresh frontend data after MCP mutations
This commit is contained in:
@@ -19,7 +19,9 @@ tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = "0.7"
|
||||
axum = "0.7"
|
||||
rmcp = { version = "1.2", features = ["transport-streamable-http-server"] }
|
||||
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"] }
|
||||
|
||||
+138
-248
@@ -1,11 +1,19 @@
|
||||
use axum::{
|
||||
extract::State as AxumState,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use axum::Router;
|
||||
use parking_lot::RwLock;
|
||||
use rmcp::{
|
||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||
model::{
|
||||
CallToolResult, Content, Implementation, InitializeResult, ProtocolVersion,
|
||||
ServerCapabilities, ServerInfo,
|
||||
},
|
||||
schemars,
|
||||
service::RequestContext,
|
||||
tool, tool_handler, tool_router,
|
||||
transport::streamable_http_server::{
|
||||
session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
|
||||
},
|
||||
ErrorData as McpError, RoleServer, ServerHandler,
|
||||
};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||
TransactionTrait,
|
||||
@@ -14,8 +22,9 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tauri::{Emitter, State};
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::entities::{score_events, students};
|
||||
@@ -58,6 +67,7 @@ struct McpServerState {
|
||||
pub config: McpServerConfig,
|
||||
pub url: Option<String>,
|
||||
pub shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
pub cancellation_token: Option<CancellationToken>,
|
||||
}
|
||||
|
||||
impl Default for McpServerState {
|
||||
@@ -67,27 +77,12 @@ impl Default for McpServerState {
|
||||
config: McpServerConfig::default(),
|
||||
url: None,
|
||||
shutdown_tx: None,
|
||||
cancellation_token: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct McpRequest {
|
||||
#[allow(dead_code)]
|
||||
jsonrpc: Option<String>,
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
params: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolCallParams {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
arguments: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct AddScoreArgs {
|
||||
student_name: String,
|
||||
delta: i32,
|
||||
@@ -95,13 +90,13 @@ struct AddScoreArgs {
|
||||
reason_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
|
||||
struct ListStudentsArgs {
|
||||
#[serde(default)]
|
||||
limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, schemars::JsonSchema)]
|
||||
struct AddScoreResult {
|
||||
event_id: i32,
|
||||
event_uuid: String,
|
||||
@@ -113,7 +108,7 @@ struct AddScoreResult {
|
||||
event_time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, schemars::JsonSchema)]
|
||||
struct StudentListItem {
|
||||
id: i32,
|
||||
name: String,
|
||||
@@ -122,7 +117,7 @@ struct StudentListItem {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, schemars::JsonSchema)]
|
||||
struct ListStudentsResult {
|
||||
total: usize,
|
||||
students: Vec<StudentListItem>,
|
||||
@@ -154,241 +149,112 @@ fn mcp_log_error(app_state: &Arc<RwLock<AppState>>, message: &str, meta: Value)
|
||||
logger.error_with_meta(message, meta.clone());
|
||||
}
|
||||
|
||||
fn jsonrpc_ok(id: Value, result: Value) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result
|
||||
})
|
||||
#[derive(Clone)]
|
||||
struct SecScoreMcpServer {
|
||||
app_state: Arc<RwLock<AppState>>,
|
||||
tool_router: ToolRouter<SecScoreMcpServer>,
|
||||
}
|
||||
|
||||
fn jsonrpc_error(id: Value, code: i32, message: &str) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message
|
||||
#[tool_router]
|
||||
impl SecScoreMcpServer {
|
||||
fn new(app_state: Arc<RwLock<AppState>>) -> Self {
|
||||
Self {
|
||||
app_state,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_students",
|
||||
"description": "获取学生列表,包含姓名、积分、奖励积分和标签。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "最多返回多少条,默认全部"}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_mcp(
|
||||
AxumState(app_state): AxumState<Arc<RwLock<AppState>>>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Response {
|
||||
let req = match serde_json::from_value::<McpRequest>(payload) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
mcp_log_error(
|
||||
&app_state,
|
||||
"mcp:request_parse_failed",
|
||||
json!({ "error": e.to_string() }),
|
||||
);
|
||||
let body = jsonrpc_error(Value::Null, -32700, &format!("Parse error: {}", e));
|
||||
return (StatusCode::OK, Json(body)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
mcp_log_info(
|
||||
&app_state,
|
||||
"mcp:request_received",
|
||||
json!({
|
||||
"method": req.method.clone(),
|
||||
"has_id": req.id.is_some()
|
||||
}),
|
||||
);
|
||||
|
||||
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::<ToolCallParams>(p).ok());
|
||||
|
||||
match params {
|
||||
None => {
|
||||
mcp_log_error(
|
||||
&app_state,
|
||||
"mcp:tools_call_invalid_params",
|
||||
json!({ "reason": "invalid_tools_call_params" }),
|
||||
);
|
||||
jsonrpc_error(id, -32602, "Invalid tools/call params")
|
||||
}
|
||||
Some(params) => match params.name.as_str() {
|
||||
"add_score" => match serde_json::from_value::<AddScoreArgs>(params.arguments) {
|
||||
Ok(args) => match mcp_add_score(&app_state, args).await {
|
||||
Ok(payload) => jsonrpc_ok(
|
||||
id,
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
#[tool(
|
||||
name = "add_score",
|
||||
description = "给指定学生加分/扣分,并写入 score_events 记录。"
|
||||
)]
|
||||
async fn add_score(
|
||||
&self,
|
||||
Parameters(args): Parameters<AddScoreArgs>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
match mcp_add_score(&self.app_state, args).await {
|
||||
Ok(payload) => {
|
||||
let text = format!(
|
||||
"已记录:{} {:+} 分({} -> {})",
|
||||
payload.student_name, payload.delta, payload.val_prev, payload.val_curr
|
||||
)
|
||||
);
|
||||
let structured = serde_json::to_value(&payload)
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
let mut result = CallToolResult::structured(structured);
|
||||
result.content = vec![Content::text(text)];
|
||||
Ok(result)
|
||||
}
|
||||
],
|
||||
"isError": false,
|
||||
"structuredContent": payload
|
||||
}),
|
||||
),
|
||||
Err(e) => {
|
||||
mcp_log_error(
|
||||
&app_state,
|
||||
&self.app_state,
|
||||
"mcp:tool_call_failed",
|
||||
json!({
|
||||
"tool": "add_score",
|
||||
"error": e
|
||||
}),
|
||||
);
|
||||
jsonrpc_ok(
|
||||
id,
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": format!("加分失败: {}", e)
|
||||
}
|
||||
],
|
||||
"isError": true
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
mcp_log_error(
|
||||
&app_state,
|
||||
"mcp:tool_call_invalid_arguments",
|
||||
json!({
|
||||
"tool": "add_score",
|
||||
"error": e.to_string()
|
||||
}),
|
||||
);
|
||||
jsonrpc_error(id, -32602, &format!("Invalid arguments: {}", e))
|
||||
}
|
||||
},
|
||||
"list_students" => {
|
||||
match serde_json::from_value::<ListStudentsArgs>(params.arguments) {
|
||||
Ok(args) => match mcp_list_students(&app_state, args).await {
|
||||
Ok(payload) => jsonrpc_ok(
|
||||
id,
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": format!("已获取 {} 名学生", payload.total)
|
||||
}
|
||||
],
|
||||
"isError": false,
|
||||
"structuredContent": payload
|
||||
}),
|
||||
),
|
||||
Err(e) => {
|
||||
mcp_log_error(
|
||||
&app_state,
|
||||
"mcp:tool_call_failed",
|
||||
json!({
|
||||
"tool": "list_students",
|
||||
"error": e
|
||||
}),
|
||||
);
|
||||
jsonrpc_ok(
|
||||
id,
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": format!("获取学生列表失败: {}", e)
|
||||
}
|
||||
],
|
||||
"isError": true
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
mcp_log_error(
|
||||
&app_state,
|
||||
"mcp:tool_call_invalid_arguments",
|
||||
json!({
|
||||
"tool": "list_students",
|
||||
"error": e.to_string()
|
||||
}),
|
||||
);
|
||||
jsonrpc_error(id, -32602, &format!("Invalid arguments: {}", e))
|
||||
Ok(CallToolResult::error(vec![Content::text(format!(
|
||||
"加分失败: {}",
|
||||
e
|
||||
))]))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
mcp_log_error(
|
||||
&app_state,
|
||||
"mcp:tool_call_unknown_tool",
|
||||
json!({
|
||||
"tool": params.name
|
||||
}),
|
||||
);
|
||||
jsonrpc_error(id, -32601, &format!("Unknown tool: {}", params.name))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
"notifications/initialized" => return StatusCode::NO_CONTENT.into_response(),
|
||||
_ => jsonrpc_error(id, -32601, &format!("Method not found: {}", req.method)),
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
#[tool(
|
||||
name = "list_students",
|
||||
description = "获取学生列表,包含姓名、积分、奖励积分和标签。"
|
||||
)]
|
||||
async fn list_students(
|
||||
&self,
|
||||
Parameters(args): Parameters<ListStudentsArgs>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
match mcp_list_students(&self.app_state, args).await {
|
||||
Ok(payload) => {
|
||||
let text = format!("已获取 {} 名学生", payload.total);
|
||||
let structured = serde_json::to_value(&payload)
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
let mut result = CallToolResult::structured(structured);
|
||||
result.content = vec![Content::text(text)];
|
||||
Ok(result)
|
||||
}
|
||||
Err(e) => {
|
||||
mcp_log_error(
|
||||
&self.app_state,
|
||||
"mcp:tool_call_failed",
|
||||
json!({
|
||||
"tool": "list_students",
|
||||
"error": e
|
||||
}),
|
||||
);
|
||||
Ok(CallToolResult::error(vec![Content::text(format!(
|
||||
"获取学生列表失败: {}",
|
||||
e
|
||||
))]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for SecScoreMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(Implementation::new("secscore-mcp", "1.0.0"))
|
||||
.with_protocol_version(ProtocolVersion::V_2024_11_05)
|
||||
}
|
||||
|
||||
async fn initialize(
|
||||
&self,
|
||||
request: rmcp::model::InitializeRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<InitializeResult, McpError> {
|
||||
if context.peer.peer_info().is_none() {
|
||||
context.peer.set_peer_info(request);
|
||||
}
|
||||
mcp_log_info(&self.app_state, "mcp:initialized", json!({}));
|
||||
Ok(self.get_info())
|
||||
}
|
||||
}
|
||||
|
||||
async fn mcp_add_score(
|
||||
@@ -467,6 +333,16 @@ async fn mcp_add_score(
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
realtime_dual_write_sync(app_state).await?;
|
||||
{
|
||||
let state_guard = app_state.read();
|
||||
let _ = state_guard.app_handle.emit(
|
||||
"ss:data-updated",
|
||||
json!({
|
||||
"category": "all",
|
||||
"source": "mcp"
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
mcp_log_info(
|
||||
app_state,
|
||||
@@ -514,7 +390,6 @@ async fn mcp_list_students(
|
||||
|
||||
let mut query = students::Entity::find().order_by_asc(students::Column::Name);
|
||||
if let Some(limit) = args.limit {
|
||||
// 防止极大值在 PostgreSQL 绑定参数时溢出导致 panic。
|
||||
let safe_limit = limit.min(i64::MAX as u64);
|
||||
query = query.limit(safe_limit);
|
||||
}
|
||||
@@ -593,9 +468,19 @@ pub async fn mcp_server_start(
|
||||
.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 cancellation_token = CancellationToken::new();
|
||||
let mcp_service = StreamableHttpService::new(
|
||||
move || Ok(SecScoreMcpServer::new(app_state.clone())),
|
||||
LocalSessionManager::default().into(),
|
||||
StreamableHttpServerConfig {
|
||||
stateful_mode: false,
|
||||
json_response: true,
|
||||
cancellation_token: cancellation_token.child_token(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let router = Router::new().nest_service("/mcp", mcp_service);
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -617,6 +502,7 @@ pub async fn mcp_server_start(
|
||||
};
|
||||
server_state.url = Some(url.clone());
|
||||
server_state.shutdown_tx = Some(shutdown_tx);
|
||||
server_state.cancellation_token = Some(cancellation_token);
|
||||
|
||||
mcp_log_info(
|
||||
state.inner(),
|
||||
@@ -648,6 +534,10 @@ pub async fn mcp_server_stop(
|
||||
return Ok(IpcResponse::success_empty());
|
||||
}
|
||||
|
||||
if let Some(token) = server_state.cancellation_token.take() {
|
||||
token.cancel();
|
||||
}
|
||||
|
||||
if let Some(tx) = server_state.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
+30
@@ -140,6 +140,36 @@ function MainContent(): React.JSX.Element {
|
||||
}
|
||||
}, [isIosDevice])
|
||||
|
||||
useEffect(() => {
|
||||
const api = (window as any).api
|
||||
if (!api || typeof api.onDataUpdated !== "function") return
|
||||
|
||||
let disposed = false
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
api
|
||||
.onDataUpdated((payload: { category?: string; source?: string }) => {
|
||||
const detail = {
|
||||
category: payload?.category || "all",
|
||||
source: payload?.source || "tauri",
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail }))
|
||||
})
|
||||
.then((fn: () => void) => {
|
||||
if (disposed) {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
unlisten = fn
|
||||
})
|
||||
.catch(() => void 0)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (unlisten) unlisten()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applySyncStrategy = async (strategy: "keep_local" | "keep_remote") => {
|
||||
const api = (window as any).api
|
||||
if (!api) return
|
||||
|
||||
@@ -17,6 +17,11 @@ export interface settingChange {
|
||||
oldValue: any
|
||||
}
|
||||
|
||||
export interface dataUpdatedEvent {
|
||||
category?: "events" | "students" | "reasons" | "all"
|
||||
source?: string
|
||||
}
|
||||
|
||||
export type settingsKey =
|
||||
| "is_wizard_completed"
|
||||
| "log_level"
|
||||
@@ -228,6 +233,11 @@ const api = {
|
||||
callback(event.payload)
|
||||
})
|
||||
},
|
||||
onDataUpdated: (callback: (payload: dataUpdatedEvent) => void): Promise<UnlistenFn> => {
|
||||
return listen<dataUpdatedEvent>("ss:data-updated", (event) => {
|
||||
callback(event.payload || {})
|
||||
})
|
||||
},
|
||||
|
||||
// Logger
|
||||
queryLogs: (
|
||||
|
||||
Reference in New Issue
Block a user