Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
//! 对外 MCP server(02 文档 §1「双形态共享一个工具内核」的第二形态)。
//!
//! 传输定论(§0):**Streamable HTTP on 127.0.0.1 是主力**(外部 agent 起 stdio 会变成
//! 双实例抢数据目录锁),**stdio 形态保留**给 llama.cpp server 的内置 MCP client。
//!
//! 与内置客户端的关系:两侧都只吃 [`crate::registry::ToolRegistry`] 的 `(name, description,
//! input_schema)`,并且**都必须走** [`crate::brain::exec::ToolExecutor`]——schema 校验、
//! 分级确认、YOLO、审计一个字都不少。所以「用户在聊天里让 AI 改配置」与「Claude Code
//! 通过 MCP 改配置」是同一条路径(02 §1 的铁律)。
//!
//! 安全边界(MCP 场景的差别只有一条):
//! - 敏感键(`Sensitivity::High`)默认**拒绝**而不是弹窗——外部客户端没有我们的 UI;
//! 拒绝文案引导用户去设置页确认,或在设置里打开 `ai.yolo`(YOLO 下照记 `yolo=true` 审计);
//! - 写工具默认不暴露(`agent.mcp.allow_write=false`):只读子集注册进注册表,
//! 调写工具会得到「没有这个工具 + 可用工具清单」(并留一条 `unknown_tool` 审计)。
//!
//! 依赖面纪律:整个 crate 只有本模块允许 `use rmcp::`(与 `brain::rig` 对 rig 的规则同形)。
pub mod projection;
pub mod sample;
pub mod serve;
pub mod server;
use std::fmt;
pub use sample::{config_samples, ConfigSamples};
pub use serve::{bind, serve_http, serve_http_on, serve_stdio};
pub use server::RegistryServer;
/// 一次 MCP server 运行的配置(由设置键 `agent.mcp.*` + 命令行覆盖合成)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpConfig {
/// 监听地址。**只允许回环地址**(默认 `127.0.0.1`):MCP 工具能改配置,不能对外开。
pub host: String,
/// Streamable HTTP 端口(默认 47823,设置键 `agent.mcp.port`)。
pub port: u16,
/// 访问令牌(`Authorization: Bearer <token>`);`None` = 不校验(仅限回环监听时允许)。
pub token: Option<String>,
/// 是否只暴露只读工具(默认 **true**;`agent.mcp.allow_write=true` 才放开写侧)。
pub read_only: bool,
}
impl Default for McpConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 47823,
token: None,
read_only: true,
}
}
}
impl McpConfig {
/// `host:port` 形态的监听地址(IPv6 字面量自动加方括号,`::1` 不会拼成 `::1:47823`)。
#[must_use]
pub fn socket_addr(&self) -> String {
if self.host.contains(':') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
/// MCP 端点 URL(客户端配置样例里用的那串)。
#[must_use]
pub fn url(&self) -> String {
format!("http://{}/mcp", self.socket_addr())
}
/// 拒绝非回环监听——这是安全铁律的**代码化**,不是文档里的建议。
///
/// # Errors
/// `host` 不是回环地址时返回 [`McpServeError::NotLoopback`]。
pub fn ensure_loopback(&self) -> Result<(), McpServeError> {
let host = self.host.trim();
if host == "127.0.0.1" || host == "localhost" || host == "::1" {
return Ok(());
}
Err(McpServeError::NotLoopback(host.to_string()))
}
}
/// MCP server 启动/运行期错误(文案面向用户,可直接进 CLI 输出)。
#[derive(Debug)]
pub enum McpServeError {
/// 监听地址不是回环地址。
NotLoopback(String),
/// 端口被占用(最常见:另一个 `ull agent serve` 已经在跑)。
PortInUse {
/// 监听地址(`127.0.0.1:47823`)。
addr: String,
},
/// 其他 IO 失败(权限、端口不足、传输中断)。
Io(String),
}
impl fmt::Display for McpServeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotLoopback(host) => write!(
f,
"拒绝在 `{host}` 上监听:MCP server 只允许回环地址(127.0.0.1 / localhost / ::1)"
),
Self::PortInUse { addr } => write!(
f,
"端口已被占用:{addr}。可能另一个 `ull agent serve` 正在运行;\
用 `--port <端口>` 或设置键 `agent.mcp.port` 换一个"
),
Self::Io(message) => write!(f, "MCP server 传输失败:{message}"),
}
}
}
impl std::error::Error for McpServeError {}
impl From<std::io::Error> for McpServeError {
fn from(error: std::io::Error) -> Self {
Self::Io(error.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_loopback_hosts_are_accepted() {
let mut cfg = McpConfig::default();
assert!(cfg.ensure_loopback().is_ok());
cfg.host = "localhost".to_string();
assert!(cfg.ensure_loopback().is_ok());
cfg.host = "::1".to_string();
assert_eq!(cfg.url(), "http://[::1]:47823/mcp");
assert!(cfg.ensure_loopback().is_ok());
cfg.host = "0.0.0.0".to_string();
let err = cfg.ensure_loopback().unwrap_err();
assert!(matches!(err, McpServeError::NotLoopback(_)));
assert!(err.to_string().contains("回环地址"));
}
#[test]
fn default_is_read_only_on_the_documented_port() {
let cfg = McpConfig::default();
assert!(cfg.read_only);
assert_eq!(cfg.port, 47823);
assert_eq!(cfg.url(), "http://127.0.0.1:47823/mcp");
}
#[test]
fn port_in_use_message_is_actionable() {
let err = McpServeError::PortInUse {
addr: "127.0.0.1:47823".to_string(),
};
let text = err.to_string();
assert!(text.contains("--port"), "{text}");
assert!(text.contains("agent.mcp.port"), "{text}");
}
}
+106
View File
@@ -0,0 +1,106 @@
//! 双投影的 **MCP 侧**:`ToolDefinition` → rmcp `Tool`(`ToolRoute::new_dyn` 的属性入参)。
//!
//! rig 侧在 [`crate::brain::rig`](`ToolDefinition → RigToolDefinition`)。两侧都只吃裸
//! `serde_json::Value` schema,所以「一致性」是可断言的:`tests/mcp_contract.rs` 遍历注册表,
//! 断言 MCP 侧的 `(name, description, input_schema)` 与注册表逐项相等——这就是
//! research/02 §6.2 建议进 CI 的那条契约。
//!
//! 额外一位信息:`annotations.read_only_hint`。MCP 客户端拿它决定「这个工具能不能
//! 静默调用」,我们用它把 [`Access`] 语义透出去(只读提示,不是权限——权限在编排层)。
use std::sync::Arc;
use rmcp::model::{JsonObject, Tool, ToolAnnotations};
use serde_json::Value;
use crate::registry::{Access, ToolDefinition, ToolRegistry};
/// 一个 `ToolDefinition` → rmcp `Tool`。
///
/// 注册表在注册阶段已保证 `input_schema` 根是 `object`,所以这里的兜底分支
/// (非对象 → 空 schema)只是为了不 panic:真走到它说明注册校验被绕过了。
#[must_use]
pub fn tool_attr(definition: &ToolDefinition, access: Access) -> Tool {
let schema: JsonObject = match &definition.input_schema {
Value::Object(map) => map.clone(),
_ => JsonObject::new(),
};
let mut attr = Tool::new(
definition.name.clone(),
definition.description.clone(),
Arc::new(schema),
);
let mut annotations = ToolAnnotations::default();
annotations.read_only_hint = Some(access == Access::ReadOnly);
annotations.destructive_hint = Some(access == Access::Mutating);
attr.annotations = Some(annotations);
attr
}
/// 注册表 → rmcp `Tool` 列表(顺序同注册顺序,便于两侧比对)。
#[must_use]
pub fn tool_attrs(registry: &ToolRegistry) -> Vec<Tool> {
registry
.specs()
.iter()
.map(|spec| tool_attr(&spec.definition(), spec.access))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::mock_ports;
use crate::tools::build_v1_registry;
use serde_json::json;
fn registry() -> ToolRegistry {
let (ports, _settings) = mock_ports();
build_v1_registry(&ports).expect("注册表应装配成功")
}
#[test]
fn projection_keeps_name_description_and_schema() {
let registry = registry();
let attrs = tool_attrs(&registry);
assert_eq!(attrs.len(), registry.len());
for (attr, spec) in attrs.iter().zip(registry.specs()) {
let definition = spec.definition();
assert_eq!(attr.name.as_ref(), definition.name);
assert_eq!(
attr.description.as_deref(),
Some(definition.description.as_str()),
"{} 的说明必须逐字相等",
definition.name
);
let projected = Value::Object((*attr.input_schema).clone());
assert_eq!(
projected, definition.input_schema,
"{} 的 JSON Schema 必须逐字相等",
definition.name
);
}
}
#[test]
fn read_only_hint_matches_access() {
let registry = registry();
for (attr, spec) in tool_attrs(&registry).iter().zip(registry.specs()) {
let hint = attr
.annotations
.as_ref()
.and_then(|a| a.read_only_hint)
.unwrap_or(false);
assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name);
}
}
#[test]
fn schema_root_stays_an_object_on_the_wire() {
for attr in tool_attrs(&registry()) {
let value = Value::Object((*attr.input_schema).clone());
assert_eq!(value["type"], json!("object"), "{}", attr.name);
}
}
}
+80
View File
@@ -0,0 +1,80 @@
//! 对外客户端配置样例(用户把它们粘进 Claude Code / Cursor 就能连上)。
//!
//! 为什么要生成而不是写在文档里:端口与令牌是**用户本机**的值,手抄必错。
//! 两个客户端的形状不同(02 §0):
//! - **Claude Code**:`{"type":"http","url":…}`(`type` 必填,缺了会被当成 stdio)
//! - **Cursor**:`{"url":…}`(`mcp.json` 里只认 url;多写 type 会被忽略但不报错)
//!
//! 令牌只在设置里非空时出现——回环 + 无令牌是允许的(研究里“至少一个 token”是建议不是铁律,
//! 而我们用 `McpConfig::ensure_loopback` 硬性禁掉对外监听)。
use serde_json::{json, Value};
/// 客户端配置样例(三件套:端点 URL + 两份可直接粘的 JSON)。
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigSamples {
/// `http://127.0.0.1:<port>/mcp`。
pub url: String,
/// Claude Code(`claude mcp add` / `~/.claude.json` 的 `mcpServers` 片段)。
pub claude_code: Value,
/// Cursor(`.cursor/mcp.json` 的 `mcpServers` 片段)。
pub cursor: Value,
}
/// 生成样例。`token` 非空时补 `headers.Authorization`。
#[must_use]
pub fn config_samples(url: &str, token: Option<&str>) -> ConfigSamples {
let headers = token
.filter(|t| !t.trim().is_empty())
.map(|t| json!({ "Authorization": format!("Bearer {t}") }));
let mut claude_entry = json!({ "type": "http", "url": url });
let mut cursor_entry = json!({ "url": url });
if let Some(headers) = &headers {
claude_entry["headers"] = headers.clone();
cursor_entry["headers"] = headers.clone();
}
ConfigSamples {
url: url.to_string(),
claude_code: json!({ "mcpServers": { "unlucky-launcher": claude_entry } }),
cursor: json!({ "mcpServers": { "unlucky-launcher": cursor_entry } }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_code_needs_type_http_and_cursor_does_not() {
let samples = config_samples("http://127.0.0.1:47823/mcp", None);
assert_eq!(
samples.claude_code["mcpServers"]["unlucky-launcher"]["type"],
json!("http")
);
assert_eq!(
samples.claude_code["mcpServers"]["unlucky-launcher"]["url"],
json!("http://127.0.0.1:47823/mcp")
);
assert!(samples.cursor["mcpServers"]["unlucky-launcher"]
.get("type")
.is_none());
assert_eq!(
samples.cursor["mcpServers"]["unlucky-launcher"]["url"],
json!("http://127.0.0.1:47823/mcp")
);
}
#[test]
fn token_becomes_bearer_header_and_empty_token_is_skipped() {
let with = config_samples("http://127.0.0.1:1/mcp", Some("s3cret"));
let header = &with.cursor["mcpServers"]["unlucky-launcher"]["headers"]["Authorization"];
assert_eq!(header, &json!("Bearer s3cret"));
let without = config_samples("http://127.0.0.1:1/mcp", Some(" "));
assert!(without.claude_code["mcpServers"]["unlucky-launcher"]
.get("headers")
.is_none());
}
}
+144
View File
@@ -0,0 +1,144 @@
//! 两种传输:stdio(llama.cpp 内置 MCP client 用)与 Streamable HTTP(主力)。
//!
//! 传输定论(02 §0):**外部 MCP 主力是 127.0.0.1 上的 Streamable HTTP**,
//! 因为 stdio 形态要被外部 agent 拉起 = 同一份数据目录出现第二个实例,
//! 迟早撞上单写者锁(M18 的会话 jsonl、M12 的 launcher.json 都是单写者)。
//! stdio 保留给**已经常驻的**本地模型服务(llama.cpp server 的 MCP client 由它自己拉子进程)。
//!
//! 令牌:`Authorization: Bearer <agent.mcp.token>`。设置里为空 = 不校验(只允许回环监听,
//! `McpConfig::ensure_loopback` 已经拦掉对外监听)。research/02 §4.4 的原话是
//! 「至少要有 headers 里的一枚随机 token」——我们做成可配、默认留空但样例里带。
use std::net::SocketAddr;
use axum::extract::Request;
use axum::http::header::AUTHORIZATION;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
use rmcp::ServiceExt;
use tokio::net::TcpListener;
use crate::mcp::server::RegistryServer;
use crate::mcp::{McpConfig, McpServeError};
/// stdio 形态:读 stdin 写 stdout,直到对端关闭。
///
/// 注意:**stdout 只能有 JSON-RPC**。任何日志都要走 stderr(否则客户端解析失败)。
///
/// # Errors
/// 传输中断或初始化失败时返回 [`McpServeError`]。
pub async fn serve_stdio(server: RegistryServer) -> Result<(), McpServeError> {
let service = server
.serve(rmcp::transport::stdio())
.await
.map_err(|error| McpServeError::Io(format!("stdio 初始化失败:{error}")))?;
service
.waiting()
.await
.map_err(|error| McpServeError::Io(format!("stdio 传输中断:{error}")))?;
Ok(())
}
/// 绑定 Streamable HTTP 监听(**只绑回环**),返回实际地址。
///
/// 拆出来是为了让调用方在进入 `axum::serve` 之前就能打印「监听在哪」——
/// 端口被占用时也能拿到一条可操作的错误(tower 的提醒:撞端口要给出换端口的指引)。
///
/// # Errors
/// 非回环地址、端口被占用、或绑定失败时返回 [`McpServeError`]。
pub async fn bind(cfg: &McpConfig) -> Result<(SocketAddr, TcpListener), McpServeError> {
cfg.ensure_loopback()?;
let addr = cfg.socket_addr();
let listener = TcpListener::bind(&addr).await.map_err(|error| {
if error.kind() == std::io::ErrorKind::AddrInUse {
McpServeError::PortInUse { addr: addr.clone() }
} else {
McpServeError::Io(format!("绑定 {addr} 失败:{error}"))
}
})?;
let local = listener
.local_addr()
.map_err(|error| McpServeError::Io(error.to_string()))?;
Ok((local, listener))
}
/// Streamable HTTP 形态:`POST /mcp` 一条 JSON-RPC 请求 / 一条响应。
///
/// `json_response = true`:简单请求直接回 `application/json`(脚本与裸 JSON-RPC
/// 客户端最省事;需要服务端推送时 rmcp 会自动回落到 SSE)。
///
/// # Errors
/// 绑定失败(见 [`bind`])或服务运行期致命错误时返回 [`McpServeError`]。
pub async fn serve_http(server: RegistryServer, cfg: McpConfig) -> Result<(), McpServeError> {
let (local, listener) = bind(&cfg).await?;
serve_http_on(server, cfg, local, listener).await
}
/// 在已绑定的监听上跑(测试用临时端口,避免抢 47823)。
///
/// # Errors
/// 服务运行期致命错误时返回 [`McpServeError`]。
pub async fn serve_http_on(
server: RegistryServer,
cfg: McpConfig,
local: SocketAddr,
listener: TcpListener,
) -> Result<(), McpServeError> {
let http_config = StreamableHttpServerConfig::default()
.with_json_response(true)
.with_legacy_session_mode(false);
let factory_server = server.clone();
let service: StreamableHttpService<RegistryServer, LocalSessionManager> =
StreamableHttpService::new(
move || Ok(factory_server.clone()),
std::sync::Arc::new(LocalSessionManager::default()),
http_config,
);
let token = cfg.token.clone();
let app = axum::Router::new()
.nest_service("/mcp", service)
.layer(axum::middleware::from_fn(
move |request: Request, next: Next| {
let token = token.clone();
async move { authorize(token, request, next).await }
},
));
eprintln!(
"[unlucky-agent] MCP server 监听 http://{local}/mcp({} 个工具;令牌:{})",
server.tool_count(),
if cfg.token.is_some() {
"已启用"
} else {
"未设置(仅回环监听)"
}
);
axum::serve(listener, app)
.await
.map_err(|error| McpServeError::Io(error.to_string()))
}
/// Bearer 令牌闸门:未配置 = 放行;配置了则严格比对。
async fn authorize(token: Option<String>, request: Request, next: Next) -> Response {
let Some(expected) = token else {
return next.run(request).await;
};
let presented = request
.headers()
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if presented == format!("Bearer {expected}") {
return next.run(request).await;
}
(
StatusCode::UNAUTHORIZED,
"缺少或错误的 Authorization: Bearer <agent.mcp.token>",
)
.into_response()
}
+243
View File
@@ -0,0 +1,243 @@
//! rmcp `ServerHandler`:把同一份注册表挂成 MCP 工具路由。
//!
//! 每次 `tools/call` 都走 [`ToolExecutor`]——schema 校验、确认闸门、YOLO、审计、
//! 事件**与内置客户端完全同一条路径**(02 §1 铁律)。工具级失败返回
//! `CallToolResult::error`(客户端能看到文案),协议级错误只留给「路由不到」的情况
//! (research/02 §3.2:`Err(McpError)` 会被大多数客户端吞成 "internal error")。
use std::sync::Arc;
use rmcp::handler::server::tool::{ToolCallContext, ToolRoute, ToolRouter};
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
};
use rmcp::service::RequestContext;
use rmcp::{ErrorData as McpError, RoleServer, ServerHandler};
use serde_json::Value;
use crate::brain::chat::ToolCall;
use crate::brain::exec::ToolExecutor;
use crate::mcp::projection::tool_attr;
use crate::registry::{Access, ToolRegistry};
/// MCP server 的 `ServerHandler` 实现。
///
/// `Clone` 是 Streamable HTTP 要的:每个会话/请求由工厂造一份(共享同一批 `Arc`,
/// 因此审计与策略仍是同一份)。
#[derive(Clone)]
pub struct RegistryServer {
router: ToolRouter<RegistryServer>,
visible: Arc<Vec<Tool>>,
session_id: String,
}
impl std::fmt::Debug for RegistryServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegistryServer")
.field("session_id", &self.session_id)
.field("tools", &self.visible.len())
.finish()
}
}
impl RegistryServer {
/// 用注册表 + 执行器装配。
///
/// **`router_registry` 必须是完整注册表**(含写工具),`executor` 才决定「哪些工具真能跑」:
/// - 只读模式:`-executor` 用 `read_only_subset()` 装配,`tools/list` 只列只读项,
/// 硬调写工具会路由到执行器 → `unknown_tool`(**tool-level 错误 + 审计**),
/// 而不是 rmcp 的协议级 `-32602 tool not found`(那个客户端只会显示 "internal error")。
/// - 写模式:两者都是完整注册表。
///
/// `session_id` 进审计的 `agent_session_id` 字段(`mcp-http` / `mcp-stdio`),
/// 让复盘时能区分「内置对话改的」和「外部 MCP 改的」。
#[must_use]
pub fn new(
router_registry: Arc<ToolRegistry>,
executor: Arc<ToolExecutor>,
session_id: impl Into<String>,
read_only: bool,
) -> Self {
let mut router = ToolRouter::new();
for spec in router_registry.specs() {
let attr = tool_attr(&spec.definition(), spec.access);
router.add_route(route(attr, Arc::clone(&executor)));
}
let visible: Vec<Tool> = router_registry
.specs()
.iter()
.filter(|spec| !read_only || spec.access == Access::ReadOnly)
.map(|spec| tool_attr(&spec.definition(), spec.access))
.collect();
Self {
router,
visible: Arc::new(visible),
session_id: session_id.into(),
}
}
/// 本 server **对外可见**的工具(`tools/list` 的内容)。
#[must_use]
pub fn tools(&self) -> Vec<Tool> {
(*self.visible).clone()
}
/// 审计用的会话标签。
#[must_use]
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 对外可见的工具数(启动日志/断言用)。
#[must_use]
pub fn tool_count(&self) -> usize {
self.visible.len()
}
}
fn route(attr: Tool, executor: Arc<ToolExecutor>) -> ToolRoute<RegistryServer> {
ToolRoute::new_dyn(attr, move |context: ToolCallContext<'_, RegistryServer>| {
let executor = Arc::clone(&executor);
let session_id = context.service.session_id().to_string();
let name = context.name().to_string();
let arguments = context
.arguments
.clone()
.map_or_else(|| Value::Object(serde_json::Map::new()), Value::Object);
Box::pin(async move {
let call = ToolCall {
id: format!("mcp-{name}"),
name,
arguments,
};
let outcome = executor.execute(&session_id, &call, 0).await;
let text = outcome.to_model_content();
let content = vec![ContentBlock::text(text)];
let result = if outcome.ok {
CallToolResult::structured(outcome.content)
} else {
CallToolResult::error(content)
};
Ok(result.into())
})
})
}
impl ServerHandler for RegistryServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new(
"unlucky-launcher",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"Unlucky-Launcher 的工具服务:读写启动器配置、看实例/版本/Java、读游戏日志、跑崩溃诊断。\
账户凭据永不经由本服务出口;写配置受 schema 白名单与敏感键确认约束。",
)
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult::with_all_items(self.tools()))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
let call_context = ToolCallContext::new(self, request, context);
self.router.call(call_context).await
}
fn get_tool(&self, name: &str) -> Option<Tool> {
self.router.get(name).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::MemoryAudit;
use crate::clock::{FixedClock, SharedClock};
use crate::confirm::{AutoDenyGate, ConfirmPolicy};
use crate::event::NoopSink;
use crate::testing::mock_ports;
use crate::tools::build_v1_registry;
use serde_json::json;
fn server(registry: Arc<ToolRegistry>, read_only: bool) -> (RegistryServer, Arc<MemoryAudit>) {
let audit = Arc::new(MemoryAudit::new());
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let exposed = if read_only {
Arc::new(registry.read_only_subset())
} else {
Arc::clone(&registry)
};
let executor = Arc::new(ToolExecutor::new(
exposed,
audit.clone(),
Arc::new(AutoDenyGate),
ConfirmPolicy::default(),
clock,
Arc::new(NoopSink),
));
(
RegistryServer::new(registry, executor, "mcp-test", read_only),
audit,
)
}
fn registry() -> Arc<ToolRegistry> {
let (ports, _settings) = mock_ports();
Arc::new(build_v1_registry(&ports).expect("注册表应装配成功"))
}
#[test]
fn lists_every_registered_tool_in_write_mode() {
let registry = registry();
let (server, _audit) = server(Arc::clone(&registry), false);
let tools = server.tools();
assert_eq!(tools.len(), registry.len());
assert_eq!(server.tool_count(), registry.len());
assert_eq!(server.session_id(), "mcp-test");
assert!(tools.iter().any(|tool| tool.name == "set_config"));
}
#[test]
fn read_only_mode_hides_the_writing_tool_but_keeps_it_routable() {
let (server, _audit) = server(registry(), true);
let names: Vec<String> = server.tools().iter().map(|t| t.name.to_string()).collect();
assert!(!names.iter().any(|n| n == "set_config"), "{names:?}");
assert!(names.iter().any(|n| n == "get_config"));
assert_eq!(names.len(), 9);
// 路由仍然存在(否则 rmcp 会回协议级 -32602,客户端看到的是 "internal error"),
// 真正拦下来的是只读子集执行器 → tool-level 错误 + 审计。
assert!(server.router.get("set_config").is_some());
}
#[test]
fn info_advertises_tools_and_a_stable_identity() {
let (server, _audit) = server(registry(), false);
let info = server.get_info();
assert!(info.capabilities.tools.is_some());
assert_eq!(info.server_info.name, "unlucky-launcher");
}
#[test]
fn get_tool_returns_the_projected_attr() {
let (server, _audit) = server(registry(), false);
let attr = server.get_tool("get_config").expect("工具存在");
assert_eq!(attr.name, "get_config");
assert_eq!(
Value::Object((*attr.input_schema).clone())["type"],
json!("object")
);
assert!(server.get_tool("nope").is_none());
}
}