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
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:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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(®istry);
|
||||
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(®istry).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(®istry()) {
|
||||
let value = Value::Object((*attr.input_schema).clone());
|
||||
assert_eq!(value["type"], json!("object"), "{}", attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(®istry)
|
||||
};
|
||||
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(®istry), 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user