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
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>
461 lines
17 KiB
Rust
461 lines
17 KiB
Rust
//! 语义入口只负责操作选择和参数位置转换,业务校验与副作用仍由 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;
|