679 lines
28 KiB
Rust
679 lines
28 KiB
Rust
//! MCP server 主循环 — stdio JSON-RPC 2.0
|
|
//!
|
|
//! 协议流程:
|
|
//! 1. 客户端发 initialize → 回 serverInfo + capabilities.tools
|
|
//! 2. 客户端发 notifications/initialized(通知,不回)
|
|
//! 3. 客户端发 tools/list → 回工具清单(read-only 模式过滤)
|
|
//! 4. 客户端发 tools/call → dispatch 到 handler
|
|
//!
|
|
//! 高风险(High)工具:tools/list 不暴露(从清单剔除),tools/call 即便绕过也由 handler 兜底拒绝。
|
|
//! read-only:tools/list 仅留 Low,tools/call Medium/High 一律拒绝。
|
|
//! 生命周期:空闲超时(默认 60s 无请求)自动退出,防客户端强杀后进程残留;写操作(risk != Low)可选回调(stdio 预留接线点)。
|
|
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use df_storage::db::Database;
|
|
use serde_json::{json, Value};
|
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
|
|
use crate::protocol::{
|
|
CallToolResult, Capabilities, InitializeResult, McpMethod, Request, Response, ServerInfo,
|
|
ToolsCapability,
|
|
};
|
|
use crate::tools::{self, Ctx, RiskLevel};
|
|
|
|
/// 协议版本(MCP 2025-06-18)
|
|
pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
|
|
pub(crate) const SERVER_NAME: &str = "devflow-mcp";
|
|
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
/// MCP server 运行配置。
|
|
///
|
|
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
|
|
/// [`Default`] 即常用生产配置(可写 + 空闲超时 60s + 无写回调)。
|
|
pub struct ServerConfig {
|
|
/// 只读模式:true 则仅暴露 list/get 工具,Medium/High 写操作一律拒绝。
|
|
pub read_only: bool,
|
|
/// 空闲超时:连续 N 时长无 stdin 输入则自动退出进程(防客户端强杀后孤儿残留)。
|
|
/// `None` = 永不因空闲退出(常驻)。默认 60s。
|
|
pub idle_timeout: Option<Duration>,
|
|
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
|
|
/// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI;
|
|
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。
|
|
pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
|
}
|
|
|
|
impl Default for ServerConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
read_only: false,
|
|
idle_timeout: Some(Duration::from_secs(60)),
|
|
on_write_call: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 启动 MCP server(stdin/stdout 接实际进程句柄)。
|
|
///
|
|
/// - `db_path`:SQLite 数据库文件路径(应用同库,开 WAL 模式)
|
|
/// - `config`:运行配置(只读/空闲超时/写操作回调),见 [`ServerConfig`]
|
|
///
|
|
/// 复用 [`Database::open`](df_storage::Database::open)(已含 `PRAGMA journal_mode=WAL`),
|
|
/// 故 SQLite WAL 状态:随 df-storage 一起已启用,无需额外处理。
|
|
pub async fn run_server(db_path: &Path, config: ServerConfig) -> anyhow::Result<()> {
|
|
let db = Arc::new(Database::open(db_path).await?);
|
|
let ctx = Ctx::new(db);
|
|
|
|
let stdin = tokio::io::stdin();
|
|
let stdout = tokio::io::stdout();
|
|
main_loop(stdin, stdout, &ctx, &config).await
|
|
}
|
|
|
|
/// 可单测的主循环(参数化 stdin/stdout)。
|
|
///
|
|
/// 协议正确性:每行一个 JSON-RPC 消息,Response 单行写回(末尾 \n)。
|
|
/// Notification(id=None)不回响应。
|
|
///
|
|
/// 生命周期:
|
|
/// - 空闲超时:仅在**等待下一个请求**(`read_line`)时计时;正在处理的请求不受影响,
|
|
/// 超时即 break 优雅退出(防客户端强杀后进程残留)。
|
|
/// - 写操作回调:成功执行的写工具(risk != Low)在响应写回后触发,供外部(GUI)感知数据变更。
|
|
pub async fn main_loop<R, W>(
|
|
stdin: R,
|
|
stdout: W,
|
|
ctx: &Ctx,
|
|
config: &ServerConfig,
|
|
) -> anyhow::Result<()>
|
|
where
|
|
R: tokio::io::AsyncRead + Unpin,
|
|
W: tokio::io::AsyncWrite + Unpin,
|
|
{
|
|
let mut reader = BufReader::new(stdin);
|
|
let mut writer = stdout;
|
|
let mut line = String::new();
|
|
|
|
loop {
|
|
line.clear();
|
|
// 空闲超时:只包裹「等待下一请求」,不包裹 dispatch/写响应,处理期间绝不误杀。
|
|
let n = match config.idle_timeout {
|
|
Some(dur) => match tokio::time::timeout(dur, reader.read_line(&mut line)).await {
|
|
Ok(n) => n,
|
|
Err(_elapsed) => {
|
|
tracing::info!(target: "df_mcp", idle_secs = dur.as_secs(), "空闲超时无新请求,自动退出");
|
|
break;
|
|
}
|
|
}?,
|
|
None => reader.read_line(&mut line).await?,
|
|
};
|
|
if n == 0 {
|
|
// EOF(stdin 关闭),优雅退出
|
|
break;
|
|
}
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// 解析 JSON-RPC 请求
|
|
let req: Request = match serde_json::from_str(trimmed) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
// 解析失败:无 id 时无法回响应,只能 log;有 id(尽力猜)回 PARSE_ERROR
|
|
tracing::warn!(target: "df_mcp", line = %trimmed, err = %e, "解析 JSON-RPC 行失败");
|
|
let resp = Response::err(Some(Value::Null), crate::protocol::PARSE_ERROR, "Parse error", None);
|
|
write_response(&mut writer, &resp).await?;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let is_notification = req.id.is_none();
|
|
let method = McpMethod::from_request(&req);
|
|
|
|
// Notification 不回响应(除解析错外)
|
|
if is_notification {
|
|
match method {
|
|
McpMethod::Initialized => {
|
|
tracing::debug!(target: "df_mcp", "客户端 initialized 通知已收");
|
|
}
|
|
_ => {
|
|
tracing::debug!(target: "df_mcp", m = ?method, "忽略未识别通知");
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 写回调需在 dispatch 后判定,先预取工具名(仅 tools/call 需要,避免 clone 整包请求)
|
|
let tool_name = match &method {
|
|
McpMethod::ToolsCall { name, .. } => Some(name.clone()),
|
|
_ => None,
|
|
};
|
|
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
|
|
write_response(&mut writer, &resp).await?;
|
|
if let Some(name) = tool_name {
|
|
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
|
|
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
|
|
if is_success_tool_call(&resp) {
|
|
fire_write_hook(config, &name);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 判定 tools/call 响应是否为「业务成功」(写回调触发条件)。
|
|
///
|
|
/// - JSON-RPC 层 `error` 非空(协议错/未识别方法)→ 失败
|
|
/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败
|
|
/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功
|
|
///
|
|
/// stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定,
|
|
/// 保证两 transport 对「业务失败不触发写回调」语义一致。
|
|
pub(crate) fn is_success_tool_call(resp: &Response) -> bool {
|
|
if resp.error.is_some() {
|
|
return false;
|
|
}
|
|
match &resp.result {
|
|
Some(v) => !matches!(v.get("isError"), Some(Value::Bool(true))),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// 方法分发 → 构造 Response。
|
|
///
|
|
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
|
|
/// `pub(crate)`:stdio(main_loop)与 HTTP(server_http)transport 共用。
|
|
pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
|
|
match method {
|
|
McpMethod::Initialize { .. } => {
|
|
let result = InitializeResult {
|
|
protocol_version: PROTOCOL_VERSION,
|
|
capabilities: Capabilities {
|
|
tools: ToolsCapability { list_changed: false },
|
|
},
|
|
server_info: ServerInfo {
|
|
name: SERVER_NAME,
|
|
version: SERVER_VERSION,
|
|
},
|
|
};
|
|
Response::ok(
|
|
id,
|
|
serde_json::to_value(result).unwrap_or(Value::Null),
|
|
)
|
|
}
|
|
McpMethod::Initialized => {
|
|
// 通知分支(由 main_loop 已过滤);到这里说明带 id 的 initialized,回空 result
|
|
Response::ok(id, Value::Object(Default::default()))
|
|
}
|
|
McpMethod::ToolsList => {
|
|
let tools: Vec<_> = tools::all_tools()
|
|
.iter()
|
|
.filter(|t| visible(read_only, t.risk))
|
|
.map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null))
|
|
.filter(|v| !v.is_null())
|
|
.collect();
|
|
Response::ok(id, json!({ "tools": tools }))
|
|
}
|
|
McpMethod::Ping => Response::ok(id, Value::Object(Default::default())),
|
|
McpMethod::ToolsCall { name, arguments } => {
|
|
// 查工具
|
|
let Some(spec) = tools::find(&name) else {
|
|
return Response::ok(
|
|
id,
|
|
serde_json::to_value(CallToolResult::error(format!("未知工具: {name}")))
|
|
.unwrap_or(Value::Null),
|
|
);
|
|
};
|
|
// 执行前防御:read-only 拒 Medium/High;High 兜底拒(handler 内也会拒,双保险)。
|
|
// 判定收口到 should_execute,与 main_loop 写回调共用同一事实源,避免两份逻辑漂移。
|
|
if !should_execute(read_only, spec.risk) {
|
|
let msg = if read_only && spec.risk != RiskLevel::Low {
|
|
format!("只读模式拒绝执行 {name}(风险等级 {:?})", spec.risk)
|
|
} else {
|
|
format!("High 风险操作 {name} 默认拒绝,请在 DevFlow 应用内执行。")
|
|
};
|
|
let r = CallToolResult::error(msg);
|
|
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
|
|
}
|
|
// Low / Medium:执行前先校验 schema 必填参数(缺必填直接拒,
|
|
// 防 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入)
|
|
let missing: Vec<String> = required_names(&spec.tool.input_schema)
|
|
.into_iter()
|
|
.filter(|name| arguments.get(name).map_or(true, |v| v.is_null()))
|
|
.collect();
|
|
if !missing.is_empty() {
|
|
let r = CallToolResult::error(format!(
|
|
"缺少必填参数: {}",
|
|
missing.join(", ")
|
|
));
|
|
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
|
|
}
|
|
let result = (spec.handler)(ctx, arguments).await;
|
|
Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null))
|
|
}
|
|
McpMethod::Unknown(m) => Response::err(
|
|
id,
|
|
crate::protocol::METHOD_NOT_FOUND,
|
|
format!("Method not found: {m}"),
|
|
None,
|
|
),
|
|
}
|
|
}
|
|
|
|
/// 工具是否会被执行(dispatch 与 main_loop 写回调共用的判定)。
|
|
///
|
|
/// read-only 下仅 Low 可执行;非 read-only 下 High 仍兜底拒绝。两条件都过 → 可执行。
|
|
fn should_execute(read_only: bool, risk: RiskLevel) -> bool {
|
|
!(read_only && risk != RiskLevel::Low) && risk != RiskLevel::High
|
|
}
|
|
|
|
/// 从工具 inputSchema 提取必填参数名列表。
|
|
///
|
|
/// 兼容 MCP schema required 的两种形态:array 显式列出必填属性;bool true 表示全部
|
|
/// properties 必填;false/缺失表示无必填。供 tools/call 执行前校验参数完整性,
|
|
/// 缺必填直接拒绝,杜绝 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入。
|
|
fn required_names(schema: &Value) -> Vec<String> {
|
|
match schema.get("required") {
|
|
Some(Value::Array(items)) => items
|
|
.iter()
|
|
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
|
|
.collect(),
|
|
Some(Value::Bool(true)) => {
|
|
let mut names: Vec<String> = schema
|
|
.get("properties")
|
|
.and_then(|p| p.as_object())
|
|
.map(|props| props.keys().cloned().collect())
|
|
.unwrap_or_default();
|
|
names.sort();
|
|
names
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发,
|
|
/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。
|
|
fn fire_write_hook(config: &ServerConfig, name: &str) {
|
|
let Some(cb) = &config.on_write_call else { return };
|
|
let Some(spec) = tools::find(name) else { return };
|
|
if spec.risk != RiskLevel::Low && should_execute(config.read_only, spec.risk) {
|
|
cb(name);
|
|
}
|
|
}
|
|
|
|
/// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见)
|
|
pub(crate) fn visible(read_only: bool, risk: RiskLevel) -> bool {
|
|
if read_only {
|
|
risk == RiskLevel::Low
|
|
} else {
|
|
risk != RiskLevel::High
|
|
}
|
|
}
|
|
|
|
/// 单行写 Response(末尾 \n,MCP 客户端按行 framing)
|
|
async fn write_response<W: tokio::io::AsyncWrite + Unpin>(
|
|
writer: &mut W,
|
|
resp: &Response,
|
|
) -> anyhow::Result<()> {
|
|
let mut buf = serde_json::to_string(resp)?;
|
|
buf.push('\n');
|
|
writer.write_all(buf.as_bytes()).await?;
|
|
writer.flush().await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================
|
|
// 单测:协议 framing / initialize / tools/list / tools/call / High 拒绝 / read-only 过滤
|
|
// ============================================================
|
|
//
|
|
// 测试驱动 dispatch()(协议层逻辑核心),不跑真实 stdio(真实 stdio 需集成测试)。
|
|
// main_loop 的 framing(write_response 单行 +\n、notification 不回)由 dispatch 的输入输出间接覆盖。
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::tools::RiskLevel;
|
|
use std::sync::Mutex;
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
/// 构造内存 DB + Ctx
|
|
async fn test_ctx() -> Ctx {
|
|
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
|
Ctx::new(db)
|
|
}
|
|
|
|
/// 直接驱动 dispatch:喂一行 → 收一行(逐行,notification 跳过)
|
|
async fn run_io_lines(lines: &[&str], read_only: bool) -> Vec<String> {
|
|
let ctx = test_ctx().await;
|
|
let mut out = Vec::new();
|
|
for line in lines {
|
|
let req: Request = serde_json::from_str(line).unwrap();
|
|
let is_notif = req.id.is_none();
|
|
let method = McpMethod::from_request(&req);
|
|
if is_notif {
|
|
continue;
|
|
}
|
|
let resp = dispatch(&ctx, read_only, req.id.clone(), method).await;
|
|
out.push(serde_json::to_string(&resp).unwrap());
|
|
}
|
|
out
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn initialize_returns_server_info_and_capabilities() {
|
|
let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
|
|
let out = run_io_lines(&[input], false).await;
|
|
assert_eq!(out.len(), 1);
|
|
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
|
assert_eq!(v["jsonrpc"], "2.0");
|
|
assert_eq!(v["id"], 1);
|
|
assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
|
|
assert_eq!(v["result"]["serverInfo"]["name"], SERVER_NAME);
|
|
// capabilities.tools 存在
|
|
assert!(v["result"]["capabilities"]["tools"].is_object());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tools_list_excludes_high_risk_by_default() {
|
|
let input = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#;
|
|
let out = run_io_lines(&[input], false).await;
|
|
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
|
let tools = v["result"]["tools"].as_array().unwrap();
|
|
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
|
// 默认(非 read-only):Low + Medium 可见,High 不可见
|
|
assert!(names.contains(&"list_projects"));
|
|
assert!(names.contains(&"create_project")); // Medium
|
|
assert!(!names.contains(&"delete_project")); // High
|
|
assert!(!names.contains(&"run_workflow")); // High
|
|
// MCP Tool schema:name + inputSchema
|
|
let first = &tools[0];
|
|
assert!(first["name"].is_string());
|
|
assert!(first["inputSchema"]["type"] == "object");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tools_list_read_only_keeps_only_low() {
|
|
let input = r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#;
|
|
let out = run_io_lines(&[input], true).await;
|
|
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
|
let tools = v["result"]["tools"].as_array().unwrap();
|
|
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
|
assert!(names.contains(&"list_projects"));
|
|
assert!(!names.contains(&"create_project")); // Medium 被过滤
|
|
assert!(!names.contains(&"delete_project")); // High
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tools_call_unknown_method_returns_method_not_found() {
|
|
let input = r#"{"jsonrpc":"2.0","id":4,"method":"bogus/method","params":{}}"#;
|
|
let out = run_io_lines(&[input], false).await;
|
|
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
|
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tools_call_missing_name_returns_clear_error() {
|
|
// tools/call 缺 name 参数:不应回「未知工具: 」(空名),应回明确 METHOD_NOT_FOUND
|
|
let input =
|
|
r#"{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"arguments":{}}}"#;
|
|
let out = run_io_lines(&[input], false).await;
|
|
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
|
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
|
|
let msg = v["error"]["message"].as_str().unwrap();
|
|
assert!(
|
|
msg.contains("name"),
|
|
"空 name 应给出明确提示,实际: {msg}"
|
|
);
|
|
assert!(!msg.contains("未知工具: "), "不应是空名「未知工具: 」: {msg}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tools_call_high_risk_is_rejected() {
|
|
let input =
|
|
r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"delete_project","arguments":{"id":"x"}}}"#;
|
|
let out = run_io_lines(&[input], false).await;
|
|
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
|
let content = v["result"]["content"].as_array().unwrap();
|
|
assert_eq!(content[0]["type"], "text");
|
|
let text = content[0]["text"].as_str().unwrap();
|
|
assert!(text.contains("拒绝") || text.contains("High"));
|
|
assert_eq!(v["result"]["isError"], true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tools_call_read_only_rejects_medium() {
|
|
let input =
|
|
r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"t","description":"d"}}}"#;
|
|
let out = run_io_lines(&[input], true).await;
|
|
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
|
let text = v["result"]["content"][0]["text"].as_str().unwrap();
|
|
assert!(text.contains("只读模式拒绝"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tools_call_create_then_list_project_roundtrip() {
|
|
let create =
|
|
r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"McpProj","description":"via mcp"}}}"#;
|
|
let list =
|
|
r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
|
|
let out = run_io_lines(&[create, list], false).await;
|
|
let created: Value = serde_json::from_str(&out[0]).unwrap();
|
|
let id = created["result"]["content"][0]["text"]
|
|
.as_str()
|
|
.unwrap();
|
|
let created_v: Value = serde_json::from_str(id).unwrap();
|
|
assert!(created_v["id"].is_string());
|
|
|
|
let listed: Value = serde_json::from_str(&out[1]).unwrap();
|
|
let list_text = listed["result"]["content"][0]["text"].as_str().unwrap();
|
|
let list_v: Value = serde_json::from_str(list_text).unwrap();
|
|
assert_eq!(list_v["count"], 1);
|
|
assert_eq!(list_v["projects"][0]["name"], "McpProj");
|
|
}
|
|
|
|
// ── schema required 校验(dispatch 层缺必填直接拒)────────────────
|
|
|
|
#[tokio::test]
|
|
async fn tools_call_missing_required_arg_returns_error() {
|
|
// create_project schema required 含 name:缺 name 应被 dispatch 层拒绝
|
|
// (而非 handler 内 arg_str_or 静默兜底把 description 当空串)
|
|
let input =
|
|
r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"create_project","arguments":{"description":"缺 name"}}}"#;
|
|
let out = run_io_lines(&[input], false).await;
|
|
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
|
assert_eq!(v["result"]["isError"], true);
|
|
let text = v["result"]["content"][0]["text"].as_str().unwrap();
|
|
assert!(text.contains("缺少必填参数: name"), "实际: {text}");
|
|
}
|
|
|
|
#[test]
|
|
fn required_names_from_array() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": { "id": {}, "name": {} },
|
|
"required": ["id", "name"]
|
|
});
|
|
assert_eq!(required_names(&schema), vec!["id".to_string(), "name".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn required_names_from_bool_true_means_all_properties() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": { "id": {}, "name": {}, "desc": {} },
|
|
"required": true
|
|
});
|
|
// bool true = 全部 properties 必填,返回全部属性名(排序保证确定性)
|
|
assert_eq!(
|
|
required_names(&schema),
|
|
vec!["desc".to_string(), "id".to_string(), "name".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn required_names_false_or_missing_is_empty() {
|
|
assert_eq!(required_names(&json!({ "type": "object" })), Vec::<String>::new());
|
|
assert_eq!(
|
|
required_names(&json!({ "type": "object", "required": false })),
|
|
Vec::<String>::new()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn visible_predicate() {
|
|
assert!(visible(false, RiskLevel::Low));
|
|
assert!(visible(false, RiskLevel::Medium));
|
|
assert!(!visible(false, RiskLevel::High));
|
|
assert!(visible(true, RiskLevel::Low));
|
|
assert!(!visible(true, RiskLevel::Medium));
|
|
assert!(!visible(true, RiskLevel::High));
|
|
}
|
|
|
|
// ── should_execute 判定(dispatch 与写回调共用)────────────────────
|
|
|
|
#[test]
|
|
fn should_execute_predicate() {
|
|
assert!(should_execute(false, RiskLevel::Low));
|
|
assert!(should_execute(false, RiskLevel::Medium));
|
|
assert!(!should_execute(false, RiskLevel::High));
|
|
assert!(should_execute(true, RiskLevel::Low));
|
|
assert!(!should_execute(true, RiskLevel::Medium));
|
|
assert!(!should_execute(true, RiskLevel::High));
|
|
}
|
|
|
|
// ── 空闲超时 / 写操作回调(main_loop 集成)────────────────────────
|
|
|
|
/// 构造测试配置:只读开关 + 默认空闲超时 + 默认无回调(字段可覆盖)
|
|
async fn test_config(read_only: bool) -> ServerConfig {
|
|
ServerConfig {
|
|
read_only,
|
|
idle_timeout: Some(Duration::from_secs(60)),
|
|
on_write_call: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn main_loop_exits_on_idle_timeout() {
|
|
let ctx = test_ctx().await;
|
|
// 用 duplex 造一个「开着但永不写数据」的 stdin:read_line 会一直挂起,
|
|
// 空闲超时(100ms)触发后应正常 break 退出,而非阻塞或 panic。
|
|
let (_tx, rx) = tokio::io::duplex(1024);
|
|
let config = ServerConfig {
|
|
idle_timeout: Some(Duration::from_millis(100)),
|
|
..test_config(false).await
|
|
};
|
|
let outer = tokio::time::timeout(
|
|
Duration::from_secs(2),
|
|
main_loop(rx, tokio::io::sink(), &ctx, &config),
|
|
)
|
|
.await;
|
|
let inner = outer.expect("main_loop 应在空闲超时后返回,而非一直阻塞");
|
|
assert!(inner.is_ok(), "空闲超时退出应为 Ok,实际: {inner:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn main_loop_fires_write_callback_for_write_tool() {
|
|
let ctx = test_ctx().await;
|
|
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let calls_cb = calls.clone();
|
|
let config = ServerConfig {
|
|
on_write_call: Some(Arc::new(move |name| {
|
|
calls_cb.lock().unwrap().push(name.to_string());
|
|
})),
|
|
..test_config(false).await
|
|
};
|
|
let (mut tx, rx) = tokio::io::duplex(1024);
|
|
let line = r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"McpProj","description":"via mcp"}}}"#;
|
|
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
|
drop(tx); // 关 stdin → 处理后 EOF,正常退出
|
|
main_loop(rx, tokio::io::sink(), &ctx, &config)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
calls.lock().unwrap().contains(&"create_project".to_string()),
|
|
"写工具 create_project 应触发写回调,实际: {:?}",
|
|
*calls.lock().unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn main_loop_no_write_callback_for_read_tool() {
|
|
let ctx = test_ctx().await;
|
|
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let calls_cb = calls.clone();
|
|
let config = ServerConfig {
|
|
on_write_call: Some(Arc::new(move |name| {
|
|
calls_cb.lock().unwrap().push(name.to_string());
|
|
})),
|
|
..test_config(false).await
|
|
};
|
|
let (mut tx, rx) = tokio::io::duplex(1024);
|
|
let line = r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
|
|
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
|
drop(tx);
|
|
main_loop(rx, tokio::io::sink(), &ctx, &config)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
calls.lock().unwrap().is_empty(),
|
|
"只读工具 list_projects 不应触发写回调,实际: {:?}",
|
|
*calls.lock().unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn main_loop_no_write_callback_when_read_only_denies_write() {
|
|
let ctx = test_ctx().await;
|
|
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let calls_cb = calls.clone();
|
|
let config = ServerConfig {
|
|
read_only: true,
|
|
on_write_call: Some(Arc::new(move |name| {
|
|
calls_cb.lock().unwrap().push(name.to_string());
|
|
})),
|
|
..test_config(true).await
|
|
};
|
|
let (mut tx, rx) = tokio::io::duplex(1024);
|
|
let line = r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"X","description":"d"}}}"#;
|
|
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
|
drop(tx);
|
|
main_loop(rx, tokio::io::sink(), &ctx, &config)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
calls.lock().unwrap().is_empty(),
|
|
"read-only 下 Medium 写被拒,不应触发写回调,实际: {:?}",
|
|
*calls.lock().unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn main_loop_no_write_callback_on_business_failure() {
|
|
// MC-5业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。
|
|
// create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。
|
|
let ctx = test_ctx().await;
|
|
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let calls_cb = calls.clone();
|
|
let config = ServerConfig {
|
|
on_write_call: Some(Arc::new(move |name| {
|
|
calls_cb.lock().unwrap().push(name.to_string());
|
|
})),
|
|
..test_config(false).await
|
|
};
|
|
let (mut tx, rx) = tokio::io::duplex(1024);
|
|
let line = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":" ","description":"d"}}}"#;
|
|
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
|
drop(tx);
|
|
main_loop(rx, tokio::io::sink(), &ctx, &config)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
calls.lock().unwrap().is_empty(),
|
|
"业务失败(isError=true)不应触发写回调,实际: {:?}",
|
|
*calls.lock().unwrap()
|
|
);
|
|
}
|
|
}
|