Unlucky Launcher v0.1.0
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

单提交快照:main 只保留已发布版本的代码树,开发史在 dev。
This commit is contained in:
AskaEth committed 2026-11-01 20:02:49 +08:00
commit 5f8382565e
570 files changed
+104468

No files matched your search

+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()
}