Files
Unlucky-Launcher/crates/agent/src/mcp/mod.rs
T
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

160 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 对外 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}");
}
}