Files
Genarrative/server-rs/crates/api-server/src/external_mcp/semantic.rs
T
lhk229 a07bff85ec
Project CI / AI game creator shell Rust crates (push) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / Backend tests (push) Successful in 3m45s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m30s
Project CI / Frontend tests (push) Successful in 1m53s
Project CI / Native shell tests (push) Successful in 5m50s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m15s
Project CI / Repository checks (push) Successful in 1m58s
Project CI / AI game creator shell web tests (push) Successful in 1m27s
升级mcp,增加按语义分类的工具。旧工具不变 (#493)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/493
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-24 17:19:45 +08:00

461 lines
17 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.
//! 语义入口只负责操作选择和参数位置转换,业务校验与副作用仍由 External router 承担。
use super::*;
use axum::http::HeaderValue;
pub(super) static TOOLS: LazyLock<Vec<SemanticTool>> = LazyLock::new(build_tools);
pub(super) struct SemanticTool {
pub(super) tool: Tool,
actions: Vec<Action>,
}
struct Action {
name: Option<&'static str>,
operation: &'static McpOperation,
input_schema: Value,
key_schema: Option<Value>,
fixed_body: Map<String, Value>,
destructive: bool,
}
pub(super) struct PreparedCall {
pub(super) operation: &'static McpOperation,
pub(super) arguments: Map<String, Value>,
pub(super) optional_idempotency_key: Option<HeaderValue>,
}
pub(super) fn find(name: &str) -> Option<&'static SemanticTool> {
TOOLS.iter().find(|entry| entry.tool.name == name)
}
fn build_tools() -> Vec<SemanticTool> {
let openapi: Value = serde_json::from_str(OPENAPI_JSON).expect("embedded OpenAPI must parse");
let descriptions: Value = serde_json::from_str(include_str!(
"../../prompts/external_mcp/semantic_tools.json"
))
.expect("semantic tool descriptions must parse");
let definitions: &[(&str, &[(&str, &str)])] = &[
(
"find_canvas_projects",
&[
("list", "listEditorProjects"),
("recent", "loadRecentEditorProject"),
("get", "getEditorProject"),
],
),
(
"manage_canvas_projects",
&[
("create", "createEditorProject"),
("rename", "renameEditorProject"),
],
),
(
"find_assets",
&[
("list_library", "getEditorAssetLibrary"),
("get_project_resources", "getEditorProject"),
("get_download_url", "getExternalAssetReadUrl"),
],
),
(
"prepare_asset_upload",
&[
("create_upload_ticket", "createExternalDirectUploadTicket"),
("confirm_upload", "confirmExternalAssetObject"),
],
),
("generate_image", &[("", "generateExternalEditorImage")]),
(
"modify_image",
&[
("edit", "editExternalEditorImage"),
("variation", "generateExternalEditorImage"),
("remove_background", "removeExternalEditorImageBackground"),
],
),
(
"generate_icon_spritesheet",
&[("", "generateExternalEditorIconSpritesheet")],
),
(
"extract_ui_assets",
&[("", "extractExternalEditorUiDesignAssets")],
),
(
"generate_character_animation",
&[("", "generateExternalEditorCharacterAnimation")],
),
("generate_video", &[("", "generateExternalEditorVideo")]),
(
"generate_audio",
&[
("sound_effect", "generateExternalEditorSoundEffect"),
("background_music", "generateExternalEditorBackgroundMusic"),
],
),
(
"edit_canvas",
&[
("get", "getEditorProject"),
("save_layout", "saveEditorProjectCanvas"),
("register_resource", "createEditorProjectResource"),
],
),
(
"organize_asset_library",
&[
("create_folder", "createEditorAssetFolder"),
("update_folder", "updateEditorAssetFolder"),
("create_asset", "createEditorAsset"),
("update_asset", "updateEditorAsset"),
],
),
(
"check_generation",
&[("", "getExternalEditorGenerationJob")],
),
(
"delete_resources",
&[
("delete_project", "deleteEditorProject"),
("delete_folder", "deleteEditorAssetFolder"),
("delete_asset", "deleteEditorAsset"),
],
),
];
definitions
.iter()
.map(|(name, operations)| {
let actions = operations
.iter()
.map(|(action, operation)| {
Action::new((!action.is_empty()).then_some(*action), operation, &openapi)
})
.collect::<Vec<_>>();
let read_only = actions.iter().all(|a| a.operation.method == Method::GET);
let generation = actions.iter().any(|a| a.operation.requires_idempotency_key);
let destructive = actions.iter().any(|a| a.destructive);
let schema = tool_schema(&actions);
let mut tool = Tool::new(
name.to_string(),
descriptions[name]["description"]
.as_str()
.expect("tool description")
.to_string(),
Arc::new(schema.as_object().expect("object schema").clone()),
);
tool.title = Some(
descriptions[name]["title"]
.as_str()
.expect("tool title")
.to_string(),
);
tool.annotations = Some(
ToolAnnotations::new()
.read_only(read_only)
.destructive(destructive)
.idempotent(
read_only || actions.iter().all(|a| a.operation.requires_idempotency_key),
)
.open_world(
generation || *name == "prepare_asset_upload" || *name == "find_assets",
),
);
SemanticTool { tool, actions }
})
.collect()
}
impl Action {
fn new(name: Option<&'static str>, operation_id: &str, openapi: &Value) -> Self {
// 按实际副作用声明;POST 也可能覆盖已有记录,参数名不能代表风险。
let destructive = match operation_id {
"listEditorProjects"
| "loadRecentEditorProject"
| "getEditorProject"
| "getEditorAssetLibrary"
| "getExternalAssetReadUrl"
| "getExternalEditorGenerationJob"
| "createEditorProject"
| "createEditorProjectResource"
| "createEditorAssetFolder"
| "createEditorAsset"
| "createExternalDirectUploadTicket" => false,
// 对象确认允许更新同一 owner 的已有对象元数据。
"confirmExternalAssetObject"
| "renameEditorProject"
| "saveEditorProjectCanvas"
| "updateEditorAssetFolder"
| "updateEditorAsset"
| "deleteEditorProject"
| "deleteEditorAssetFolder"
| "deleteEditorAsset" => true,
// 生成完成可修改已有画布状态,编辑与抠图还支持原位替换。
"generateExternalEditorImage"
| "editExternalEditorImage"
| "removeExternalEditorImageBackground"
| "generateExternalEditorIconSpritesheet"
| "extractExternalEditorUiDesignAssets"
| "generateExternalEditorCharacterAnimation"
| "generateExternalEditorVideo"
| "generateExternalEditorSoundEffect"
| "generateExternalEditorBackgroundMusic" => true,
_ => panic!("semantic operation must declare destructive risk: {operation_id}"),
};
let operation = MCP_OPERATIONS
.iter()
.find(|op| op.operation_id == operation_id)
.expect("semantic tools must map to existing operations");
let wrapped = &operation.input_schema["properties"];
// 保留 body 的 if/then/allOf 等约束;仅合并位置包装,不重建字段定义。
let mut input_schema = wrapped
.get("body")
.cloned()
.unwrap_or_else(|| json!({"type": "object", "properties": {}}));
let mut required = input_schema
.get("required")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
for location in ["pathParameters", "queryParameters"] {
if let Some(schema) = wrapped.get(location) {
for (name, field) in schema["properties"]
.as_object()
.expect("parameter properties")
{
assert!(
input_schema["properties"].get(name).is_none(),
"ambiguous field {name}"
);
input_schema["properties"][name] = inline_openapi_schema(openapi, field, 0);
}
required.extend(
schema
.get("required")
.and_then(Value::as_array)
.into_iter()
.flatten()
.cloned(),
);
}
}
let mut fixed_body = Map::new();
if name == Some("variation") {
input_schema["properties"]
.as_object_mut()
.unwrap()
.remove("kind");
required.retain(|field| field != "kind");
fixed_body.insert("kind".into(), json!("quick-edit"));
}
if operation_id == "confirmExternalAssetObject" {
input_schema["properties"]
.as_object_mut()
.unwrap()
.remove("ownerUserId");
}
input_schema["required"] = Value::Array(required);
input_schema["additionalProperties"] = json!(false);
let path = operation.path_template.split('?').next().unwrap();
let path_item = &openapi["paths"][path];
let rest = &path_item[operation.method.as_str().to_ascii_lowercase()];
let key_schema = path_item
.get("parameters")
.and_then(Value::as_array)
.into_iter()
.flatten()
.chain(
rest.get("parameters")
.and_then(Value::as_array)
.into_iter()
.flatten(),
)
.filter_map(|p| resolve_openapi_reference(openapi, p))
.find(|p| p["in"] == "header" && p["name"] == "Idempotency-Key")
.map(|p| {
let mut schema = inline_openapi_schema(openapi, &p["schema"], 0);
if let Some(description) = p.get("description") {
schema["description"] = description.clone();
}
schema
});
Self {
name,
operation,
input_schema,
key_schema,
fixed_body,
destructive,
}
}
fn call_schema(&self) -> Value {
let mut schema = match self.name {
Some(name) => json!({
"type": "object",
"properties": {"action": {"type": "string", "const": name}, "input": self.input_schema},
"required": ["action", "input"],
"additionalProperties": false
}),
None => self.input_schema.clone(),
};
if let Some(key) = &self.key_schema {
schema["properties"]["idempotencyKey"] = key.clone();
if self.operation.requires_idempotency_key {
schema["required"]
.as_array_mut()
.unwrap()
.push(json!("idempotencyKey"));
}
}
schema
}
}
fn tool_schema(actions: &[Action]) -> Value {
if actions[0].name.is_none() {
return actions[0].call_schema();
}
let mut schema = json!({
"type": "object",
"properties": {
"action": {"type": "string", "enum": actions.iter().map(|a| a.name.unwrap()).collect::<Vec<_>>()},
"input": {"type": "object"}
},
"required": ["action", "input"],
"additionalProperties": false,
"oneOf": actions.iter().map(Action::call_schema).collect::<Vec<_>>()
});
if let Some(key) = actions.iter().find_map(|a| a.key_schema.as_ref()) {
schema["properties"]["idempotencyKey"] = key.clone();
}
schema
}
impl SemanticTool {
pub(super) fn prepare(&self, mut arguments: Map<String, Value>) -> Result<PreparedCall, Value> {
let action = if self.actions[0].name.is_none() {
&self.actions[0]
} else {
let name = arguments
.get("action")
.and_then(Value::as_str)
.ok_or_else(|| json!({"error": "必须提供字符串 action"}))?;
self.actions
.iter()
.find(|a| a.name == Some(name))
.ok_or_else(|| json!({"error": "未知 action"}))?
};
validate_fields(&action.call_schema(), &arguments)?;
let key = arguments.remove("idempotencyKey");
let mut optional_idempotency_key = None;
if let Some(key) = &key {
let key = key
.as_str()
.ok_or_else(|| json!({"error": "idempotencyKey 必须是字符串"}))?;
if key.is_empty() || key.len() > 128 || !key.bytes().all(|c| (b'!'..=b'~').contains(&c))
{
return Err(json!({"error": "idempotencyKey 必须为 1–128 个非空白 ASCII 字符"}));
}
if !action.operation.requires_idempotency_key {
optional_idempotency_key = Some(
HeaderValue::from_str(key)
.map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?,
);
}
}
let input = if action.name.is_some() {
arguments
.remove("input")
.and_then(|value| value.as_object().cloned())
.ok_or_else(|| json!({"error": "input 必须是 JSON 对象"}))?
} else {
arguments
};
validate_fields(&action.input_schema, &input)?;
let wrapped = &action.operation.input_schema["properties"];
let mut mapped = Map::new();
for location in ["pathParameters", "queryParameters", "body"] {
if let Some(schema) = wrapped.get(location) {
let mut fields = input
.iter()
.filter(|(name, _)| schema["properties"].get(*name).is_some())
.map(|(name, value)| (name.clone(), value.clone()))
.collect::<Map<_, _>>();
if location == "body" {
fields.extend(action.fixed_body.clone());
}
// 有请求体的操作始终发送对象,包括无字段的项目创建。
if location == "body" || !fields.is_empty() {
mapped.insert(location.into(), Value::Object(fields));
}
}
}
if action.operation.requires_idempotency_key {
if let Some(key) = key {
mapped.insert("idempotencyKey".into(), key);
}
}
Ok(PreparedCall {
operation: action.operation,
arguments: mapped,
optional_idempotency_key,
})
}
}
// 只校验适配层结构和直接字段,不实现第二套业务 schema 验证器。
// 嵌套字段与跨字段条件在现有 REST DTO/业务入口中校验,完整 schema 仍向客户端提供。
fn validate_fields(schema: &Value, input: &Map<String, Value>) -> Result<(), Value> {
let properties = schema["properties"]
.as_object()
.expect("input schema properties");
for name in schema["required"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
{
if !input.contains_key(name) {
return Err(json!({"error": "缺少必填字段", "field": name}));
}
}
for (name, value) in input {
let field = properties
.get(name)
.ok_or_else(|| json!({"error": "当前操作不接受此字段", "field": name}))?;
let matches_type = |kind: &str| match kind {
"string" => value.is_string(),
"object" => value.is_object(),
"array" => value.is_array(),
"boolean" => value.is_boolean(),
"number" => value.is_number(),
"integer" => {
value.is_i64() || value.is_u64() || value.as_f64().is_some_and(|v| v.fract() == 0.0)
}
"null" => value.is_null(),
_ => true,
};
let valid_type = match &field["type"] {
Value::String(kind) => matches_type(kind),
Value::Array(kinds) => kinds.iter().filter_map(Value::as_str).any(matches_type),
_ => true,
};
if !valid_type
|| field
.get("enum")
.and_then(Value::as_array)
.is_some_and(|values| !values.contains(value))
|| field.get("const").is_some_and(|expected| expected != value)
{
return Err(json!({"error": "字段类型或取值不符合当前操作", "field": name}));
}
}
Ok(())
}
#[cfg(test)]
mod tests;