Merge branch 'SecScore-tauri' of https://github.com/SECTL/SecScore into SecScore-tauri

This commit is contained in:
Fox_block
2026-03-20 21:35:10 +08:00
12 changed files with 871 additions and 2 deletions
+139
View File
@@ -0,0 +1,139 @@
# SecScore MCP 使用说明
本文说明如何在现有 Tauri Rust 后端中使用 MCP 服务(HTTP 方式),并调用 `add_score` 工具完成加分/扣分。
## 1. 功能概览
当前后端已提供以下 Tauri 命令:
- `mcp_server_start(config?)`
- `mcp_server_stop()`
- `mcp_server_status()`
服务启动后会暴露 MCP HTTP 入口:
- `POST /mcp`
- 默认地址:`http://127.0.0.1:3901/mcp`
当前已实现 MCP 工具:
- `add_score`:给指定学生加分或扣分,并写入 `score_events` 记录。
## 2. 启动与停止 MCP 服务
你可以在前端通过 Tauri `invoke` 调用:
```ts
import { invoke } from '@tauri-apps/api/core';
await invoke('mcp_server_start', {
config: {
host: '127.0.0.1',
port: 3901,
},
});
const status = await invoke('mcp_server_status');
console.log(status);
await invoke('mcp_server_stop');
```
说明:
- 不传 `config` 时,默认使用 `127.0.0.1:3901`
- 只有管理权限(Admin)可调用 `mcp_server_start/stop/status`
## 3. MCP 请求方式
服务采用 JSON-RPC 2.0 格式,请求头使用 `Content-Type: application/json`
### 3.1 initialize
```bash
curl -X POST http://127.0.0.1:3901/mcp \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
}'
```
### 3.2 tools/list
```bash
curl -X POST http://127.0.0.1:3901/mcp \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}'
```
### 3.3 tools/call(调用 add_score
```bash
curl -X POST http://127.0.0.1:3901/mcp \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "add_score",
"arguments": {
"student_name": "张三",
"delta": 5,
"reason_content": "课堂表现优秀"
}
}
}'
```
`add_score` 参数:
- `student_name`string,必填):学生姓名。
- `delta`(integer,必填):分值变化,正数加分,负数扣分。
- `reason_content`string,可选):原因,默认 `MCP 加分`
## 4. 返回结果说明
调用 `add_score` 成功时:
- `result.isError = false`
- `result.structuredContent` 包含:
- `event_id`
- `event_uuid`
- `student_name`
- `delta`
- `val_prev`
- `val_curr`
- `reason_content`
- `event_time`
调用失败时:
- `result.isError = true`
- `result.content[0].text` 含失败原因(例如学生不存在、数据库未连接)。
## 5. 常见问题
### 5.1 调用报 `MCP server is already running`
表示服务已启动,可直接调用 `/mcp`,或先执行 `mcp_server_stop` 再重启。
### 5.2 调用报 `Permission denied: Admin required`
说明当前会话未获得管理权限,先在应用中解锁管理权限再调用服务管理命令。
### 5.3 调用 `add_score` 提示 `Student not found`
请确认学生已在学生列表中存在,且 `student_name` 与库内名称完全一致。
## 6. 安全建议
- 推荐仅绑定到 `127.0.0.1`,避免暴露到局域网。
- 若需要远程访问,建议配合网关鉴权、反向代理白名单或专用内网通道。
+4
View File
@@ -102,6 +102,10 @@ SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScrip
## 开发与运行(面向贡献者) ## 开发与运行(面向贡献者)
## MCP 文档
- [MCP-使用说明](./MCP-使用说明.md)
### 环境要求 ### 环境要求
- Node.js(建议使用 LTS 版本) - Node.js(建议使用 LTS 版本)
+1
View File
@@ -19,6 +19,7 @@ tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
axum = "0.7"
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres"] } sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres"] }
sea-orm = { version = "0.12", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls"] } sea-orm = { version = "0.12", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls"] }
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
+419
View File
@@ -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<String>,
}
struct McpServerState {
pub is_running: bool,
pub config: McpServerConfig,
pub url: Option<String>,
pub shutdown_tx: Option<oneshot::Sender<()>>,
}
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<String>,
id: Option<Value>,
method: String,
params: Option<Value>,
}
#[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<String>,
}
#[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<Arc<Mutex<McpServerState>>> =
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(McpServerState::default())));
fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> 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<Arc<RwLock<AppState>>>,
Json(payload): Json<Value>,
) -> Response {
let req = match serde_json::from_value::<McpRequest>(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::<ToolCallParams>(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::<AddScoreArgs>(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<RwLock<AppState>>,
args: AddScoreArgs,
) -> Result<AddScoreResult, String> {
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<McpServerConfig>,
state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<McpServerStartResult>, 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<RwLock<AppState>>>,
) -> Result<IpcResponse<()>, 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<RwLock<AppState>>>,
) -> Result<IpcResponse<McpServerStatus>, 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(),
}))
}
+2
View File
@@ -8,6 +8,7 @@ pub mod event;
pub mod filesystem; pub mod filesystem;
pub mod http_server; pub mod http_server;
pub mod log; pub mod log;
pub mod mcp;
pub mod reason; pub mod reason;
pub mod response; pub mod response;
pub mod reward; pub mod reward;
@@ -28,6 +29,7 @@ pub use event::*;
pub use filesystem::*; pub use filesystem::*;
pub use http_server::*; pub use http_server::*;
pub use log::*; pub use log::*;
pub use mcp::*;
pub use reason::*; pub use reason::*;
pub use response::*; pub use response::*;
pub use reward::*; pub use reward::*;
+3
View File
@@ -114,6 +114,9 @@ pub fn run() {
http_server_start, http_server_start,
http_server_stop, http_server_stop,
http_server_status, http_server_status,
mcp_server_start,
mcp_server_stop,
mcp_server_status,
register_url_protocol, register_url_protocol,
app_quit, app_quit,
app_restart, app_restart,
+2 -2
View File
@@ -22,8 +22,8 @@
"decorations": false, "decorations": false,
"transparent": false, "transparent": false,
"center": true, "center": true,
"minWidth": 800, "minWidth": 360,
"minHeight": 600 "minHeight": 640
} }
], ],
"security": { "security": {
+88
View File
@@ -63,6 +63,7 @@ function MainContent(): React.JSX.Element {
const syncCheckingRef = useRef(false) const syncCheckingRef = useRef(false)
const syncApplyLoadingRef = useRef(false) const syncApplyLoadingRef = useRef(false)
const lastLocalMutationAtRef = useRef(0) const lastLocalMutationAtRef = useRef(0)
const lastLandscapeSizeRef = useRef<{ width: number; height: number } | null>(null)
const activeMenu = useMemo(() => { const activeMenu = useMemo(() => {
const p = location.pathname const p = location.pathname
@@ -284,6 +285,93 @@ function MainContent(): React.JSX.Element {
if (key === "settings") navigate("/settings") if (key === "settings") navigate("/settings")
} }
<<<<<<< HEAD
=======
const getMaxWindowSize = () => {
const availableWidth = window.screen?.availWidth || window.innerWidth || 1200
const availableHeight = window.screen?.availHeight || window.innerHeight || 800
return {
maxWidth: Math.max(360, Math.round(availableWidth - 64)),
maxHeight: Math.max(640, Math.round(availableHeight - 64)),
}
}
const getFixedPortraitSize = () => {
const { maxWidth, maxHeight } = getMaxWindowSize()
const phonePortraitBase = { width: 430, height: 932 }
let width = Math.max(360, Math.min(phonePortraitBase.width, maxWidth))
let height = Math.max(640, Math.min(phonePortraitBase.height, maxHeight))
// 保证竖屏高于宽,维持手机风格比例。
if (height <= width) {
height = Math.min(maxHeight, Math.max(640, Math.round(width * 2.0)))
}
if (height <= width) {
width = Math.max(360, Math.min(maxWidth, Math.round(height * 0.5)))
}
return { width, height }
}
const getLandscapeRestoreSize = () => {
const { maxWidth, maxHeight } = getMaxWindowSize()
const fallback = { width: 1180, height: 680 }
const source = lastLandscapeSizeRef.current || fallback
let width = Math.max(800, Math.min(maxWidth, Math.round(source.width)))
let height = Math.max(600, Math.min(maxHeight, Math.round(source.height)))
if (width < height) {
const swappedWidth = Math.max(800, Math.min(maxWidth, height))
const swappedHeight = Math.max(600, Math.min(maxHeight, width))
width = swappedWidth
height = swappedHeight
}
return { width, height }
}
const toggleOrientationMode = async () => {
const api = (window as any).api
if (!api) return
const nextPortraitMode = !isPortraitMode
try {
const maximized = await api.windowIsMaximized()
if (maximized) {
await api.windowMaximize()
}
if (nextPortraitMode) {
lastLandscapeSizeRef.current = {
width: Math.max(1, Math.round(window.innerWidth || 0)),
height: Math.max(1, Math.round(window.innerHeight || 0)),
}
const targetSize = getFixedPortraitSize()
await api.windowSetResizable(false)
await api.windowResize(targetSize.width, targetSize.height)
setSidebarCollapsed(true)
setFloatingSidebarExpanded(false)
} else {
const targetSize = getLandscapeRestoreSize()
await api.windowSetResizable(true)
await api.windowResize(targetSize.width, targetSize.height)
setSidebarCollapsed(false)
setFloatingSidebarExpanded(false)
}
setIsPortraitMode(nextPortraitMode)
} catch (error) {
messageApi.error(t("common.error"))
console.error("Failed to toggle orientation mode:", error)
}
}
useEffect(() => {
if (!isPortraitMode || !sidebarCollapsed) {
setFloatingSidebarExpanded(false)
}
}, [isPortraitMode, sidebarCollapsed])
>>>>>>> 1fe9dbe66a8a3df649fd86fb5ba64f3178057ec1
const toggleSidebar = () => { const toggleSidebar = () => {
if (isPortraitMode && sidebarCollapsed) { if (isPortraitMode && sidebarCollapsed) {
setFloatingSidebarExpanded((prev) => !prev) setFloatingSidebarExpanded((prev) => !prev)
+155
View File
@@ -88,6 +88,20 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const [urlRegisterLoading, setUrlRegisterLoading] = useState(false) const [urlRegisterLoading, setUrlRegisterLoading] = useState(false)
const [appQuitLoading, setAppQuitLoading] = useState(false) const [appQuitLoading, setAppQuitLoading] = useState(false)
const [appRestartLoading, setAppRestartLoading] = useState(false) const [appRestartLoading, setAppRestartLoading] = useState(false)
const [mcpLoading, setMcpLoading] = useState(false)
const [mcpConfig, setMcpConfig] = useState<{ host: string; port: number }>({
host: "127.0.0.1",
port: 3901,
})
const [mcpStatus, setMcpStatus] = useState<{
is_running: boolean
config: { host: string; port: number }
url?: string | null
}>({
is_running: false,
config: { host: "127.0.0.1", port: 3901 },
url: null,
})
const canAdmin = permission === "admin" const canAdmin = permission === "admin"
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
@@ -119,6 +133,21 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } }))
} }
const loadMcpStatus = async () => {
if (!(window as any).api?.mcpServerStatus) return
try {
const res = await (window as any).api.mcpServerStatus()
if (res.success && res.data) {
setMcpStatus(res.data)
if (res.data.config?.host && res.data.config?.port) {
setMcpConfig({ host: res.data.config.host, port: res.data.config.port })
}
}
} catch {
// ignore status polling errors in settings page
}
}
const loadAll = async () => { const loadAll = async () => {
if (!(window as any).api) return if (!(window as any).api) return
const res = await (window as any).api.getAllSettings() const res = await (window as any).api.getAllSettings()
@@ -129,6 +158,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
} }
const authRes = await (window as any).api.authGetStatus() const authRes = await (window as any).api.authGetStatus()
if (authRes.success && authRes.data) setSecurityStatus(authRes.data) if (authRes.success && authRes.data) setSecurityStatus(authRes.data)
await loadMcpStatus()
} }
useEffect(() => { useEffect(() => {
@@ -464,6 +494,61 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
} }
} }
const startMcpServer = async () => {
if (!(window as any).api?.mcpServerStart) return
const host = mcpConfig.host.trim()
const port = Number(mcpConfig.port)
if (!host) {
messageApi.warning(t("settings.mcp.hostRequired"))
return
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
messageApi.warning(t("settings.mcp.portInvalid"))
return
}
setMcpLoading(true)
try {
const res = await withTimeout(
(window as any).api.mcpServerStart({ host, port }),
10_000,
t("settings.mcp.startTimeout")
)
if (res.success) {
messageApi.success(t("settings.mcp.startSuccess"))
} else {
messageApi.error(res.message || t("settings.mcp.startFailed"))
}
} catch (e: any) {
messageApi.error(e?.message || t("settings.mcp.startFailed"))
} finally {
await loadMcpStatus()
setMcpLoading(false)
}
}
const stopMcpServer = async () => {
if (!(window as any).api?.mcpServerStop) return
setMcpLoading(true)
try {
const res = await withTimeout(
(window as any).api.mcpServerStop(),
10_000,
t("settings.mcp.stopTimeout")
)
if (res.success) {
messageApi.success(t("settings.mcp.stopSuccess"))
} else {
messageApi.error(res.message || t("settings.mcp.stopFailed"))
}
} catch (e: any) {
messageApi.error(e?.message || t("settings.mcp.stopFailed"))
} finally {
await loadMcpStatus()
setMcpLoading(false)
}
}
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const confirmQuitApp = () => { const confirmQuitApp = () => {
@@ -1065,6 +1150,76 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
</Space> </Space>
</div> </div>
<Divider /> <Divider />
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
{t("settings.mcp.title")}
</div>
<div style={{ color: "var(--ss-text-secondary)", marginBottom: "12px", fontSize: "12px" }}>
{t("settings.mcp.description")}
</div>
<Space style={{ marginBottom: "12px" }}>
<Tag color={mcpStatus.is_running ? "success" : "default"}>
{mcpStatus.is_running ? t("settings.mcp.running") : t("settings.mcp.stopped")}
</Tag>
{mcpStatus.url ? (
<Tag color="blue">{mcpStatus.url}</Tag>
) : (
<Tag>{t("settings.mcp.noUrl")}</Tag>
)}
</Space>
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
<Form.Item label={t("settings.mcp.host")}>
<Input
value={mcpConfig.host}
onChange={(e) => setMcpConfig((prev) => ({ ...prev, host: e.target.value }))}
disabled={!canAdmin || mcpStatus.is_running}
style={{ width: "280px" }}
placeholder="127.0.0.1"
/>
</Form.Item>
<Form.Item label={t("settings.mcp.port")}>
<Input
value={String(mcpConfig.port)}
onChange={(e) => {
const next = Number(e.target.value.replace(/[^\d]/g, ""))
if (Number.isFinite(next) && next > 0) {
setMcpConfig((prev) => ({ ...prev, port: next }))
} else if (!e.target.value) {
setMcpConfig((prev) => ({ ...prev, port: 0 }))
}
}}
disabled={!canAdmin || mcpStatus.is_running}
style={{ width: "180px" }}
placeholder="3901"
/>
</Form.Item>
<Form.Item label={t("common.operation")}>
<Space>
<Button
type="primary"
onClick={startMcpServer}
loading={mcpLoading}
disabled={!canAdmin || mcpStatus.is_running}
>
{t("settings.mcp.start")}
</Button>
<Button
danger
onClick={stopMcpServer}
loading={mcpLoading}
disabled={!canAdmin || !mcpStatus.is_running}
>
{t("settings.mcp.stop")}
</Button>
<Button onClick={loadMcpStatus} disabled={mcpLoading}>
{t("settings.mcp.refresh")}
</Button>
</Space>
</Form.Item>
</Form>
<div style={{ color: "var(--ss-text-secondary)", marginTop: "-8px", fontSize: "12px" }}>
{t("settings.mcp.hint")}
</div>
<Divider />
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}> <div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
{t("settings.app.title")} {t("settings.app.title")}
</div> </div>
+21
View File
@@ -276,6 +276,27 @@
"registerFailed": "Registration failed", "registerFailed": "Registration failed",
"installerRequired": "Requires installed SecScore, may not work in development mode." "installerRequired": "Requires installed SecScore, may not work in development mode."
}, },
"mcp": {
"title": "MCP Service",
"description": "Manage the built-in MCP HTTP service for external MCP clients.",
"running": "Running",
"stopped": "Stopped",
"noUrl": "No URL",
"host": "Host",
"port": "Port",
"start": "Start",
"stop": "Stop",
"refresh": "Refresh Status",
"hint": "Recommended to bind only to 127.0.0.1; after start, endpoint is http://host:port/mcp",
"hostRequired": "Please enter MCP host",
"portInvalid": "Please enter a valid port (1-65535)",
"startSuccess": "MCP server started",
"startFailed": "Failed to start MCP server",
"stopSuccess": "MCP server stopped",
"stopFailed": "Failed to stop MCP server",
"startTimeout": "MCP server start timed out, please try again",
"stopTimeout": "MCP server stop timed out, please try again"
},
"app": { "app": {
"title": "App Controls", "title": "App Controls",
"description": "Quickly restart or quit the app.", "description": "Quickly restart or quit the app.",
+21
View File
@@ -276,6 +276,27 @@
"registerFailed": "注册失败", "registerFailed": "注册失败",
"installerRequired": "需要安装版 SecScore,开发模式下可能无效。" "installerRequired": "需要安装版 SecScore,开发模式下可能无效。"
}, },
"mcp": {
"title": "MCP 服务",
"description": "管理内置 MCP HTTP 服务,供外部 MCP 客户端调用。",
"running": "运行中",
"stopped": "已停止",
"noUrl": "暂无地址",
"host": "主机",
"port": "端口",
"start": "启动",
"stop": "停止",
"refresh": "刷新状态",
"hint": "建议仅绑定到 127.0.0.1;启动后 MCP 入口为 http://host:port/mcp",
"hostRequired": "请输入 MCP 主机地址",
"portInvalid": "请输入有效端口(1-65535",
"startSuccess": "MCP 服务启动成功",
"startFailed": "MCP 服务启动失败",
"stopSuccess": "MCP 服务已停止",
"stopFailed": "MCP 服务停止失败",
"startTimeout": "启动 MCP 服务超时,请稍后重试",
"stopTimeout": "停止 MCP 服务超时,请稍后重试"
},
"app": { "app": {
"title": "应用控制", "title": "应用控制",
"description": "快速重启或退出应用。", "description": "快速重启或退出应用。",
+16
View File
@@ -295,6 +295,22 @@ const api = {
data: { isRunning: boolean; config?: any; url?: string } data: { isRunning: boolean; config?: any; url?: string }
}> => invoke("http_server_status"), }> => invoke("http_server_status"),
// MCP Server
mcpServerStart: (config?: {
port?: number
host?: string
}): Promise<{ success: boolean; data?: { url: string; config: { port: number; host: string } } }> =>
invoke("mcp_server_start", { config }),
mcpServerStop: (): Promise<{ success: boolean }> => invoke("mcp_server_stop"),
mcpServerStatus: (): Promise<{
success: boolean
data?: {
is_running: boolean
config: { port: number; host: string }
url?: string | null
}
}> => invoke("mcp_server_status"),
// File System // File System
fsGetConfigStructure: (): Promise<{ fsGetConfigStructure: (): Promise<{
success: boolean success: boolean