Files
Unlucky-Launcher/crates/agent/src/mcp/projection.rs
T
AskaEth 5f8382565e
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.1.0
单提交快照:main 只保留已发布版本的代码树,开发史在 dev。
2026-11-01 20:02:49 +08:00

107 lines
3.8 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 侧**:`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);
}
}
}